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_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_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_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_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.