Relics (AMD)
Reads a relic interior out of an .amd file and builds it — the declarative front end to
Volume.
Overview
A relic is authored as flat sibling records in a Relics section: one record for the
relic itself, one per chamber, box or subtracted solid, joined by a Relic: field. See
Relic interiors for the authoring guide; this page is the
function list.
Two things the reader remembers that are easy to overlook, because both exist to make live preview work without any code in the mission:
- Where the relic came from.
relics_loadandrelics_buildstamp the file path and section onto every record, sorelic_reloadcan re-read it later. A relic assembled in code has no source and is not reloadable — deliberately, and the tools say so rather than pretending. - Which volume it built. A mission may build a relic under a name of its own. Anything
that guesses the record's key instead then addresses a volume that does not exist and
silently does nothing — which is exactly how one demo's authored
Scrape band:never reached its watcher.relic_volume_nameis the one right answer, andrelic_containandrelic_reloadboth use it.
Quick example
# build it, and start containment from the authored fields
rec = relics_build("maps/ossuary.amd")
relic_contain(rec)
# later - the editor's Preview button does this for you
relic_reload("ossuary")
A rebuild replaces the volume in place under the same name, so a live
volume_watch follows it automatically, keeping its margin and hold. It then emits
relic_rebuilt (key, volume, file) so a mission can re-scatter its own props;
nothing listening to that is a perfectly good outcome.
API
Declarative relic interiors from AMD - a structure a ship flies INSIDE, authored as data instead of a YAML string buried in a Python file.
A relic is a navigable VOLUME (procedural/volume.py): chambers are spheres, passages
are capsules, boxes are rectangles, solids are subtracted. Containment is script-side, so
the engine's collision system - one keep-out sphere per object - is never involved.
A section authors the relic and its parts as FLAT SIBLINGS, the same shape a cutscene bed and its shots use::
## [Relics](relics)
### [The Ossuary](ossuary)
---
Loc: 12000, 0, -8000
Atmosphere: purple
Containment: tractor
Margin: 60
---
### [hub](ossuary_hub)
---
Relic: ossuary
Chamber: 0, 0, 0, 900
---
### [gallery](ossuary_gallery)
---
Relic: ossuary
Chamber: 3000, 0, 0, 700
Passage to: hub 300
---
A record carrying Relic: is a PART; one carrying neither is the relic itself.
Which kind of part follows from the field it carries - Chamber:, Box: or
Solid:. This is the bed/shot discriminator, and it is why relics and their parts share
ONE archetype: a section resolves to a single archetype, so splitting them would leave
half of every relic file untyped and lint calling its fields unknown.
Chamber coordinates are relative to the relic's Loc:, which is what lets one
authored layout be dropped at two places in a system.
Parts are records rather than a nested fence on purpose. AMD does support nesting inside one fence, but inner names are unschema'd and unlinted by design - and a record gets a key, a heading and a source span, which is what an editor needs to write one chamber back.
amd_relic_data(text)
Parse one relic fence into a data dict.
amd_relic_facts()
amd_parse_facts handler for relic fences.
Unknown labels return None so they chain to the field registry and then to the
default coercion - the same contract amd_landmark_facts follows.
relic_barrier_destroyed(obj_id)
A destroyed barrier object opens its barrier. What a //damage/destroy route calls.
Returns the barrier key it opened, or None when the object was not a barrier - so a route can hand it every destruction without asking first.
relic_barriers(relic_key)
{name: [x, y, z, radius, opens_when, clear_with, display]} as AUTHORED.
Positions are relic-relative, like every other authored part. For the live state - is
it open, which edges is it severing - ask the web with rail_barriers.
relic_barriers_spawn(relic_key, name=None)
Give every SHUT barrier a space object, so a beam has something to hit.
A barrier is a sphere in the rail web, and nothing in the engine can shoot a sphere -
which is the whole reason cutting one used to be a scripted timer with no beam on
screen. A real object makes it an ordinary weapons problem: point at it, fire, and the
thing dies. relic_barrier_destroyed is the other end.
Idempotent per (relic, barrier): a reload replaces rather than accumulates, the same identity rule the markers and contents use. Returns how many were placed.
relic_contain(record, name=None)
Start containment for a built relic, honoring its authored fields.
Returns the watcher, or None if the volume has not been built yet.
relic_contents(relic_key)
Every authored content record for a relic, with its world position resolved.
[{part, item, qty, spawn, starts_when, pos}]. The position comes from whichever part
carries it - a point marks a spot, a chamber means "somewhere in this room" and
resolves to its centre.
relic_contents_arm(relic_key, radius_default=900.0, reveal=RELIC_REVEAL_RANGE)
Arm a relic's authored contents. Returns how many are waiting on a trigger.
Three things happen, in this order:
- Every point carrying
Roles:gets a role marker - an invisible, selectable object at that spot holding those roles. That is what makesStarts when: reach <role>work at all, since the quest driver's reach test measures against OBJECTS holding a role, and it is the plumbing an author should never have to think about. - Contents with no trigger are placed now. That is the common case - a ruin with things in it - and it needs no word in the file.
- The rest are armed and checked by ONE shared tick, in the pattern
quest_tick_reachestablished: a watcher per item would be the same work done many times.
Idempotent by (relic, part): re-arming, or a live reload, places nothing twice.
relic_contents_can_trigger(phrase)
True when phrase is one this can actually evaluate. What lint asks.
relic_contents_clear(relic_key=None)
Forget what has been armed and placed. Does not delete objects.
With no key this is the mission reset: everything, including the signal observer and the shared tick, since the world is going away anyway.
With a KEY it forgets one relic - the galaxy case, where a system is torn down while other systems are still live. The observer and the tick stay, because the relics that are still standing are still waiting on them.
relic_contents_count()
How many content records are armed. The reset-ledger probe - an armed record that survives a mission reset would place loot in the NEXT mission.
relic_contents_state(relic_key, part)
"placed", "waiting" or "unarmed" for one content record.
What a report or a test asks. Distinguishing WAITING from UNARMED matters: both look like "the loot is not there", and only one of them is a bug.
relic_keys()
Every registered relic key.
relic_open_barrier(relic_key, barrier, name=None)
Open one of a relic's barriers - what a cutting beam or a haul ends in.
Emits rail_opened so a suit holding for a shut way re-plans at once rather than
waiting out its stall counter. False when there is no such barrier, or it was already
open.
relic_place(record, x, y, z)
Put a relic somewhere at RUNTIME, overriding its authored Loc:.
An .amd cannot know where a relic will stand when the world decides that late. An
Open Universe cell has a transient world origin - the same system lands at a different
slot on a different visit - so a galaxy relic has to be placed when the cell is built,
not when the file is read.
Every reader goes through relic_pos, so setting loc here moves the geometry, the
points, the contents and the containment together. Anything already built keeps the
position it was built at: place BEFORE relic_volume.
Takes the record (or a key) and returns it, so it reads as one step in a build.
relic_point(relic_key, name)
The WORLD position of a named point in a relic, or None.
Points are authored RELATIVE to the relic's Loc:, like every other part, so this
shifts them - which is the whole reason a point belongs in the relic rather than being
a landmark of its own. Move the relic and its cache, its entrance and its ambush move
with it; a landmark's Loc: is absolute and would stay behind.
What goes there is the mission's business::
item_spawn("relic_core", *relic_point("ossuary", "cache"), qty=2)
npc_spawn(*relic_point("ossuary", "picket"), "Sentry", "raider", ...)
marker_point(*relic_point("ossuary", "mouth"), "The Ossuary")
relic_point_display(relic_key, name)
What a point is CALLED - the authored label, else its key.
The same string _relic_place_role_markers names the marker with, so a list of places
to go and the label that lights up on the radar cannot disagree.
relic_point_has_marker(relic_key, name)
Whether this point was armed with a marker at all.
The companion relic_point_revealed answers TRUE for a point with no marker, which
is right for its own job - it gates destinations, and a relic that does not use the
reveal mechanism must not have every destination hidden. It makes it useless as a
record of where the crew has BEEN, though: without this test, every place in an
unarmed relic reads as already seen.
relic_point_hidden(relic_key, name)
Whether a point is authored Hidden: - off the destination list until found.
Hidden is a property of the LIST, never of the graph: a route still passes THROUGH a hidden place, because stumbling into a secret on the way somewhere else is the point of having one.
relic_point_revealed(relic_key, name)
Whether the crew has been close enough to light this point's marker.
TRUE WHEN THERE IS NO MARKER, which is the case that matters: a point without
Roles: is never armed, and a relic whose contents were never armed has no markers at
all. Answering False for those would hide every destination in a relic that simply
does not use the reveal mechanism.
relic_point_roles(relic_key, name)
The roles authored on one point, lowercased. Empty when it has none.
relic_points(relic_key, role=None)
Every point in a relic as {name: (x, y, z)} in world coordinates.
role narrows to one purpose - relic_points("ossuary", "spawn") for every place an
NPC may appear, "entrance" for the ways in. Roles are matched lowercased, the way
they are authored.
relic_pos(record)
A relic's world [x, y, z] - its Loc:, else the origin.
Deliberately simpler than landmark_pos: relics have no galaxy placer, because
the landmark one has never been used by a shipped mission (Open Universe rolls its
own). If a galaxy mission needs one, add it the way landmarks did rather than
assuming this hook exists.
relic_rails(record, name=None, margin=None)
Solve this relic's rail web. Called by relic_volume; returns the stats dict.
ONCE PER RELIC, HERE, rather than once per trip. Every destination a console picks used to re-derive the ruin's connectivity from the geometry - doorways, skirts and an N-squared visibility graph, measured at 96ms for the first pick and 17ms for every one after, per console, on a bridge. Connectivity is a property of the RUIN, so it is solved when the ruin is built.
Nothing about it is authored. The relic's own Point: records become named
destinations; everything else - the stations through each room, the doorways, the way
round a pillar - is derived. Rail step: is the one dial, and Barrier: parts are
registered here so a shut way is shut from the first route anybody asks for.
relic_rails_ensure(relic_key, name=None)
The relic's rail web name, solving the web now if it has not been solved yet.
relic_volume builds it, which covers every relic read from an .amd. A mission - or
a test - that defines the volume itself and registers the points by hand never goes
through that, and a route with no web to walk would simply refuse. So the first route
asked for is what builds it, once, and everything after that walks the cache.
Returns the volume name, or None when there is no such relic or its volume has not been built.
relic_record(key)
The registered record for key, or None.
relic_release(key)
Tear ONE relic down: stop its containment, drop its volume, forget what was armed.
The counterpart to building a relic into a world that comes and goes. A galaxy tears a
system down while the next one is already being built, so the whole-registry verbs
(volume_clear, relics_clear) are the wrong tools there - they would take the relic
the crew is currently inside.
The RECORD stays registered: the relic is a thing the mission still knows about and may rebuild on the next visit. Objects are not deleted either - whoever tore the world down did that, and a relic outliving its props is not this function's business.
relic_reload(key)
Re-read one relic's .amd and rebuild its volume in place. Returns a summary dict.
THE POINT: this is what a live preview needs, and until now every mission had to write
it. The editor's Preview button can only ring a doorbell over the debug channel; the
rebuild has to happen inside the running mission, so it belongs here rather than in
the tool. See cosmos_dev.mission_runner's relic_reload debug action, which calls
this and needs no mission code at all.
Geometry only. The props a mission scatters over the walls are its own art, and this
cannot know what they are - so it emits relic_rebuilt afterwards and a mission that
draws walls re-dresses on that signal.
Rebuilds UNDER THE SAME VOLUME NAME, so a watcher, a brain, or a stored id that
addresses the relic keeps addressing it. Containment is re-applied from the AUTHORED
fields (relic_contain), so an edit to Margin: or Containment: takes effect on
the same Preview as an edit to a chamber - which is the whole promise of authoring it
declaratively.
Returns {"key", "volume", "source", "chambers", "passages", "boxes", "solids"}, or
None if the key is unknown or the record has no source (built in code, not read
from a file - there is nothing to re-read).
relic_volume(record, name=None)
Build the navigable volume for a record and return it.
The layout is authored RELATIVE to the relic's Loc, so the record's position becomes the volume's origin - which is what lets the same layout be placed twice.
relic_volume_name(record, name=None)
Which volume a record's geometry lives in: an explicit name, else the one the record actually BUILT, else its key.
The middle term is the one that matters. A mission is free to build a relic under a
name of its own (relics_build(..., name="relic")), and when it does, anything that
guesses the record's key instead - containment, reload - silently addresses a volume
that does not exist and does nothing at all. That is not hypothetical: it is why the
Ossuary's authored Scrape band: 120 never once reached its watcher.
relics_build(file_path, section_key='relics', name=None)
Load a file, build the first relic's volume, and return (record, volume).
The whole declarative path in one call, for the common case of a mission with one
relic. name overrides the volume's name; it defaults to the relic's own key.
relics_clear()
Drop every registered relic record. Called by reset_mission_state().
relics_count()
Number of registered relic records. The reset-ledger probe.
relics_from_section(section, source=None, section_key=None)
Relic records from a section node's children, each with its parts attached.
Grouping mirrors the cutscene reader: a record naming a relic is a part of it, collected in DOCUMENT ORDER; a record naming none is the relic itself.
source and section_key are carried onto every record so the relic can be REBUILT
from its file later - see relic_reload. They are the reader's own arguments, not
anything the author writes; without them a record is a snapshot with no way back to
the text it came from, and a live preview has to be written per mission.
relics_load(file_path, section_key='relics', content=None)
Read relics straight from an .amd file. The verb a mission actually wants.
Without this every mission repeats the same three lines - load the document with the
relic fence handler wired in, find the section, walk it - and the fence handler is
the part that is easy to forget. Miss it and every field silently falls through to
the default coercion, so Chamber: 0, 0, 0, 900 becomes a string and the relic
builds as nothing.
Returns the records; they are registered too, so relic_record(key) finds them
later on a story cue.
content is the text, for a caller that has already read it - an addon inside a
packaged .mastlib cannot open its own files by path, so it resolves them with its
own reader and hands the text over. file_path is still recorded as the source, so a
live editor reload knows what to re-read.
relics_register(section, source=None, section_key=None)
Remember every relic record in section by key, without building any.
Separate from relics_build for the same reason landmarks are: a mission builds
most of its relics at setup, but a story beat reveals one on cue, and both need the
same record.
relics_reload_all()
Re-read every relic that came from a file. Returns a list of summaries.
What the editor's Preview posts when it does not name one - the common case of a mission with a single relic, where naming it would only be a way to get it wrong.