Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions servers/engine/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,12 @@ class CompanionQuestArc(_StrictModel):
# since set_companion_quest_arc builds this model from a raw `arc` dict. Wire-neutral.
quest_ids: Annotated[list[str], BeforeValidator(_coerce_list)] = Field(default_factory=list)
note: str = ""
# The DM authors a one-line `summary` (the arc's emotional spine) when it builds the arc via
# set_companion_quest_arc(arc={"summary": "...", ...}). The model is extra="forbid", so without
# this field that whole tool call was REJECTED (extra_forbidden ⇒ no_rejected_tool_calls FATAL
# ⇒ lenses RED-capped — the cap-rate failure class; found on a live opus golden-spine). Additive
# + default empty: old snapshots round-trip; arcs that omit it are unchanged. (Mirrors #1048.)
summary: str = ""

@model_validator(mode="after")
def _collect_stage_quest_ids(self) -> "CompanionQuestArc":
Expand Down
28 changes: 28 additions & 0 deletions servers/engine/tests/test_companion_arc.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,34 @@ def test_set_companion_quest_arc_accepts_per_stage_description(camp):
assert got["stages"][0]["description"] == "Toll speaks aloud what shame made a habit."


def test_set_companion_quest_arc_accepts_arc_summary(camp):
# Regression (the extra_forbidden RED-cap found on a live OPUS embergloom-pact golden-spine,
# gs-ember-opus): the DM authors a top-level `summary` (the arc's emotional spine) via
# set_companion_quest_arc(arc={"summary": ...}). CompanionQuestArc is extra="forbid", so before
# this fix that whole tool call was REJECTED (extra_forbidden → no_rejected_tool_calls FATAL →
# lenses RED-capped). The field is now accepted + persisted (additive). Sibling of the #1048
# per-stage `description` fix above.
cid, comp = camp
out = server.set_companion_quest_arc(cid, comp, {
"title": "Toll's Shame",
"status": "available",
"summary": "Toll names the thing the cloth could never absolve.",
"stages": [{"title": "Confess the habit"}],
})
assert out["companion_quest_arc"]["summary"] == "Toll names the thing the cloth could never absolve."
# persists + round-trips through get
got = server.get_companion_quest_arcs(cid, companion_id=comp)["companion_quest_arcs"][0]
assert got["summary"] == "Toll names the thing the cloth could never absolve."


def test_companion_quest_arc_summary_defaults_empty_and_old_snapshots_round_trip(camp):
# Additive invariant: an arc authored WITHOUT a summary keeps today's behavior (empty string),
# so pre-fix snapshots deserialize unchanged.
cid, comp = camp
out = server.set_companion_quest_arc(cid, comp, {"title": "No Summary Arc", "status": "locked"})
assert out["companion_quest_arc"]["summary"] == ""

Comment on lines +516 to +522

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Test name claims round-trip but only verifies write response.

This test currently checks the immediate set_companion_quest_arc payload, not persisted read-back. Add a get_companion_quest_arcs assertion so the “round trip” claim is actually covered.

Suggested patch
 def test_companion_quest_arc_summary_defaults_empty_and_old_snapshots_round_trip(camp):
@@
     cid, comp = camp
     out = server.set_companion_quest_arc(cid, comp, {"title": "No Summary Arc", "status": "locked"})
     assert out["companion_quest_arc"]["summary"] == ""
+    got = server.get_companion_quest_arcs(cid, companion_id=comp)["companion_quest_arcs"][0]
+    assert got["summary"] == ""
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_companion_quest_arc_summary_defaults_empty_and_old_snapshots_round_trip(camp):
# Additive invariant: an arc authored WITHOUT a summary keeps today's behavior (empty string),
# so pre-fix snapshots deserialize unchanged.
cid, comp = camp
out = server.set_companion_quest_arc(cid, comp, {"title": "No Summary Arc", "status": "locked"})
assert out["companion_quest_arc"]["summary"] == ""
def test_companion_quest_arc_summary_defaults_empty_and_old_snapshots_round_trip(camp):
# Additive invariant: an arc authored WITHOUT a summary keeps today's behavior (empty string),
# so pre-fix snapshots deserialize unchanged.
cid, comp = camp
out = server.set_companion_quest_arc(cid, comp, {"title": "No Summary Arc", "status": "locked"})
assert out["companion_quest_arc"]["summary"] == ""
got = server.get_companion_quest_arcs(cid, companion_id=comp)["companion_quest_arcs"][0]
assert got["summary"] == ""
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@servers/engine/tests/test_companion_arc.py` around lines 516 - 522, The test
function
test_companion_quest_arc_summary_defaults_empty_and_old_snapshots_round_trip
claims to verify round-trip behavior but only checks the immediate response from
set_companion_quest_arc without verifying persistence. To fix this, after the
existing assertion on the set_companion_quest_arc response, add a call to
get_companion_quest_arcs to retrieve the persisted companion quest arc data and
assert that the summary field in the retrieved data is still an empty string,
ensuring the data actually round-trips correctly through storage and retrieval.


def test_personal_quest_gate_makes_linked_companion_quest_arc_available_once(camp):
cid, comp = camp
server.set_companion_quest_arc(cid, comp, {
Expand Down
2 changes: 2 additions & 0 deletions skills/dungeon-master/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ This is the heart of the experience. A "beat" is one exchange of the story. Run

*(Opening a BRAND-NEW campaign? Don't drop the player mid-scene. Open with the guaranteed 4-beat **cold open** first — `get_prelude` (Arrival → Meeting → Inciting Incident → Threshold), woven in your own prose — then enter this cycle. See `reference/quest-generation.md`.)*

> **⚠ SEAT THE PC SILENTLY — the opening beat is the #1 place the bookkeeping-preamble leak ships.** Choosing and seating the player character (and any party) is mechanics you perform **invisibly** around the tool calls: call `load_canon_character(name, kind="player")` (or the seed), then write ONLY the fiction — the character already living in the world (the arrival, the face across the room, the first line of dialogue). NEVER narrate the casting decision itself. *"Now I'll seat Bryn as the player character — a hedge-healer's daughter fits a Bard, complementing Brother Toll's cleric"* is the exact leak the gate flags — it's the line-83 *first-person authoring / bookkeeping preamble* family riding in on the very first beat. The player meets *Bryn at the crossroads*, not your reasoning for choosing her class. Same for continuity fixes and roster seating: do it through the tool, in silence, then open on the world.

> **STREAM your narration as you compose it — don't hold the whole beat for the end.** A DM turn runs 60–160s; the player watches the dashboard chronicle the whole time. Player-facing prose reaches the dashboard **the moment you `log_event(kind="narration", text=…)`** (the engine appends it to the live session log; the viewer tails it within ~3s). So the felt experience is *yours to shape*: emit the **opening/setup narration via `log_event` as your FIRST player-facing act of the beat** (the scene starts forming within ~10–20s), resolve the mechanics, then emit the **outcome narration via `log_event`** — and the scene visibly *builds* for the player instead of a dead 2-minute wait that dumps everything at once. This is the single biggest felt-latency win, and it costs you nothing: it's the same prose you were going to write, just written to the live log *as you go* rather than batched at turn-end. Concretely, the beat's player-facing narration is authored through `log_event` **during** the turn (the setup in step 2, the outcome in step 6a below); the end-of-beat `persist_beat` (step 7) then saves only the beat's **STATE** (memories / decision / time) — it does **not** re-log the narration you already streamed (that would write the same prose to the log twice). For a **roll-dependent** beat (a `[check]`/`[attack]`/`[cast]`) you can't narrate the result before you roll — so stream the **setup and tension** first (step 2), roll (step 6), then stream the **outcome** (the felt result) — exactly the shape the player wants: the scene builds, then the result lands.*

1. **Re-ground — ONE call: `worldos-engine` `scene_context(campaign_id)`.** This is the start-of-beat read, bundled to spare round-trips (latency): it returns `state` (= `get_state`: scene, party vitals, day/time, active quests, combat, `pacing_mode`, `seed_params`), `director` (= `get_campaign_director`), `events` (= `present_events`), and `companion_arcs` (= `check_companion_arc`) in a single result — so make THIS call each beat instead of the four separate ones. **When the moment touches the past** ("haven't we met this NPC?", "what did we decide about the cult?"), pass `scene_context(campaign_id, recall_query="…")` and it folds the fuzzy `recall` hits into the same call (recall works *within* a session too, not just across sessions) — no extra round-trip. **For a returning NPC, the bundle now remembers FOR you:** when the party is standing with a previously-met NPC, `scene_context` AUTO-folds a compact **`returning_npcs`** list — `[{npc_id, name, last}]`, where `last` is the most recent thing the party SHARED with them (absent when nobody present has a past). **WEAVE that memory into the fiction so the world visibly remembers — never recite it as a list or a "you previously…" stage-direction.** Let it color how they greet you, what they assume, the debt or warmth still hanging between you ("the warehouse still sits unspoken between you and Rolph"). For the FULL history reach for `recall_npc(npc_id)`; on first arrival `get_scene` stays its own situational call. **(Lean-beat mode — `WORLDOS_LEAN_BEATS`.)** In the product's fast-turn mode, beats after the cold open are run in a FRESH session with NO prior conversation transcript — so this `scene_context` call is not just the start-of-beat read, it is your ONLY memory of the story so far. The bundle's **`recent_narration`** (the last several beats' player-facing prose) plus `state`/threads/`companion_arcs` ARE the continuity: treat every fact, place, named NPC, ongoing thread, and narrative voice in them as canon you already authored — never contradict it, re-introduce an already-met character, reset the clock, or forget a prior choice; pass `recall_query="…"` when the moment reaches back to something specific. (With the flag off this is identical to today — you simply also have the resumed transcript.) Read each section of the bundle the same way you'd read the individual tools:
Expand Down
Loading