The media extension contract asks for a scene snapshot a future image or video provider could be handed: location, who is present, what they hold, what must stay true, and where in the story it sits. Building one was the milestone's obvious first task, and it was the wrong one. That snapshot has existed since M5. `narrative_state["scene"]` holds the summary, the location, the cast and the coordinate it was written at; a validated `set_scene` event writes it, every position snapshots it, and every head move restores it. It survives Undo, Redo, Retry, divergence, Save Point restore and a process restart because it is the authoritative state rather than a copy of it. So there is no scenes table here. A second scene store would have been a second answer to "where is the story now", with its own lineage rules to get wrong — and the lineage rules are the expensive part, which is the argument for reusing the ones that already work rather than against it. The Scene Packet is derived on read, and its identity is computed from the campaign and the position rather than allocated: the same position yields the same id in another process, after a restart, and after the packet is thrown away and rebuilt, with no row to keep in step. That is the part of a future media_assets table that would be expensive to retrofit, so it is fixed now even though the table is not built. One table, then: visual_profiles, the only thing the contract's scene list asks for that nothing already stored. Campaign-scoped and not per-position, because a character does not change appearance when the story forks — a reader who diverged would otherwise lose their cast, and the same descriptors would land in every per-position snapshot, measured at 245 copies of 367 bytes in a 120-turn campaign to say something that never varies. Keyed by the M5 entity key rather than a new identity namespace, and one table for characters, locations and items alike, because a location is an entity with a type and splitting them would reintroduce the genre shape M5 spent a milestone removing. What the packet leaves out is the more interesting half. Not the transcript, and not imported knowledge — none of it, not merely the sources marked hidden. The rule is what the story established at this position, not everything the narrator was told, and drawing it by class is what makes it hold for a secret nobody thought to mark. A hidden Canon source proves it, with a positive control showing the narrator did receive the sentinel the packet does not carry. Once a validated event puts the observer in the room, the observer is in the packet: that is no longer narrator-only knowledge, and a packet that hid it would be hiding the story from itself. The providers are contracts and nothing else. Protocols for image, video, audio, speech and transcription, an empty registry, no adapter, no dependency, no socket, and no media setting to point anywhere — a setting that exists can be pointed at a cloud by mistake. A future provider endpoint must be loopback, stricter than narration's trusted-LAN allowance, because a picture of a scene carries the scene with it. Transcription returns an editable draft with no commit method, so STT structurally cannot bypass the authoritative path. Nothing here can write the story. Not by convention: no module under media/ imports the code that writes state, no media event type exists in the state vocabulary, and every test in the authority suite compares the authoritative document byte for byte either side of a media operation — including one where a provider insists Alice is in a red coat in a corridor, and the campaign goes on disagreeing. One defect, found by the milestone's own tests. M10 first added a migration creating an index that create_all already builds from the column, so an upgraded database ended up with two indexes and a fresh install with one. Comparing the two schemas is what caught it; neither database examined alone would have. The migration is gone rather than renamed, and the right number of migrations for a new table whose indexes are declared on its columns is zero. Backend 1,191 passed / 14 skipped / 0 failed, 89 of them M10's. Frontend 145 passed. Lint, production build and Docker build clean. No frontend file changed: M10 adds no reader-facing surface, and ordinary play — turns, state, memory, knowledge, Undo, Redo, Retry, Save Point restore, restart — runs with no media configuration, no warning, no connection attempt and no media row written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qyn3oRd4D6pi72nKBG725B
137 lines
5.3 KiB
Python
137 lines
5.3 KiB
Python
"""M10: a campaign with a scene worth depicting, deliberately not a fantasy one.
|
|
|
|
The M10 brief asks for at least one non-fantasy representation, and the reason
|
|
is a real risk rather than a preference: the media contract's own examples are
|
|
fantasy-shaped — hair and eyes, timber framing, oil lamps — and a schema written
|
|
while looking at them can acquire that shape without anyone deciding to give it
|
|
one. So the fixture is four people in an office, and the same code has to hold
|
|
it with no change.
|
|
|
|
Bill the protagonist
|
|
Alice a coworker, with a visual profile
|
|
Roger a coworker, with no profile at all
|
|
John a coworker who is not in the room
|
|
|
|
the office a location, with a visual profile
|
|
a badge an item Bill is carrying
|
|
the server room a second location, for divergence
|
|
|
|
The cast is the one from the post-M8 playtest finding, and that is deliberate
|
|
too — but only as *shape*. M10 does not investigate that finding, and nothing
|
|
here asserts anything about coreference; it is M11's, and §23 of the brief says
|
|
so. What the shape buys here is a scene with three present characters and one
|
|
absent, which is what makes "the packet describes who is in the room" a claim
|
|
with a wrong answer available.
|
|
|
|
Roger having no profile is load-bearing: it is how the tests tell "no profile"
|
|
from "an empty profile", which a future provider has to be able to distinguish.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from fakes import ScriptedProvider, state_block
|
|
|
|
#: A narrator-only secret, used by the hidden-information tests. It is imported
|
|
#: as an M7 hidden knowledge source — the product's real mechanism for
|
|
#: narrator-only material — rather than as an invented marker, so the test
|
|
#: exercises the boundary that actually exists.
|
|
SECRET_SENTINEL = "ZARQUON-CONCEALED-OBSERVER-7731"
|
|
|
|
SECRET_MD = f"""# What nobody in the room knows
|
|
|
|
There is a concealed observer behind the north wall of the office, watching the
|
|
meeting through a gap in the panelling. Their code name is {SECRET_SENTINEL}.
|
|
|
|
Nobody present is aware of this.
|
|
"""
|
|
|
|
#: A source that is *not* hidden, so a test can show the packet excludes
|
|
#: imported knowledge as a class rather than only excluding secrets.
|
|
HANDBOOK_MD = """# Office handbook
|
|
|
|
The building was refurbished in the spring. The north wall panelling is new.
|
|
"""
|
|
|
|
|
|
def play(client, adv_id, text, events, prose="The meeting continues."):
|
|
ScriptedProvider.replies = [f"{prose}\n" + state_block(events)]
|
|
response = client.post(
|
|
f"/api/adventures/{adv_id}/actions", json={"type": "do", "text": text}
|
|
)
|
|
assert response.status_code == 200, response.text[:400]
|
|
return response
|
|
|
|
|
|
def entity(key, kind, name):
|
|
return {"type": "create_entity", "entity": key, "entity_type": kind,
|
|
"name": name}
|
|
|
|
|
|
def build(client, adv_id) -> dict:
|
|
"""Plays the office campaign and returns what a test needs to check it.
|
|
|
|
Leaves the campaign with a scene set at the active head, two visual
|
|
profiles, one character deliberately unprofiled, and one character
|
|
deliberately not present.
|
|
"""
|
|
play(client, adv_id, "arrive at the office", [
|
|
entity("bill", "character", "Bill"),
|
|
entity("alice", "character", "Alice"),
|
|
entity("roger", "character", "Roger"),
|
|
entity("john", "character", "John"),
|
|
entity("office", "location", "The office"),
|
|
entity("server_room", "location", "The server room"),
|
|
entity("badge", "item", "Security badge"),
|
|
])
|
|
play(client, adv_id, "start the meeting", [
|
|
{"type": "set_possession", "item": "badge", "owner": "bill"},
|
|
{"type": "set_scene",
|
|
"summary": "Bill, Alice and Roger meet around the table.",
|
|
"location": "office",
|
|
"present": ["bill", "alice", "roger"]},
|
|
])
|
|
|
|
profiles = {
|
|
"alice": {
|
|
"descriptors": {"build": "tall", "hair": "short black",
|
|
"clothing": "grey blazer"},
|
|
"features": ["tortoiseshell glasses"],
|
|
"style_notes": "photographic, natural light",
|
|
},
|
|
"office": {
|
|
"descriptors": {"architecture": "open-plan floor",
|
|
"lighting": "flat fluorescent"},
|
|
"features": ["whiteboard covered in diagrams"],
|
|
"style_notes": "",
|
|
},
|
|
}
|
|
for key, profile in profiles.items():
|
|
response = client.put(
|
|
f"/api/adventures/{adv_id}/visual-profiles/{key}", json=profile
|
|
)
|
|
assert response.status_code == 200, response.text[:300]
|
|
return {"profiles": profiles}
|
|
|
|
|
|
def upload_secret(client, adv_id) -> int:
|
|
"""Imports the narrator-only source the hidden-information tests use."""
|
|
return _upload(client, adv_id, "observer.md", SECRET_MD, "canon",
|
|
visibility="hidden")
|
|
|
|
|
|
def upload_handbook(client, adv_id) -> int:
|
|
return _upload(client, adv_id, "handbook.md", HANDBOOK_MD, "reference")
|
|
|
|
|
|
def _upload(client, adv_id, name, body, classification, **fields):
|
|
data = {"classification": classification}
|
|
data.update({k: str(v).lower() if isinstance(v, bool) else str(v)
|
|
for k, v in fields.items()})
|
|
response = client.post(
|
|
f"/api/adventures/{adv_id}/knowledge",
|
|
files={"file": (name, body.encode("utf-8"), "text/markdown")},
|
|
data=data,
|
|
)
|
|
assert response.status_code == 201, response.text[:400]
|
|
return response.json()["id"]
|