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
11 changes: 6 additions & 5 deletions scripts/play_scripted_dm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ if not isinstance(pc, dict) or pc.get("error"):
raise SystemExit("could not seat a scripted player")
opening = (
f"You are {pc.get('name') or spec.get('name') or 'Hero'}, standing under a steady lantern at the edge of the road. "
"The smoke provider is awake, the table is listening, and the next move is yours."
"The lantern flame steadies, the street hushes, and the next move is yours."
)
server.log_event(campaign_id, "narration", opening)
print(json.dumps({"campaign_id": campaign_id, "player": pc, "opening": opening}))
Expand Down Expand Up @@ -231,6 +231,8 @@ while :; do
if [ "${count:-0}" -gt "$processed" ]; then
while IFS= read -r line; do
[ -n "$line" ] || continue
beat=$((processed + 1))
json_append "$CHAT" "player" "$(move_chat_text "$line")"
reply="$(
CLAWDND_STATE_DIR="$STATE_DIR" uv run --directory "$ROOT/servers/engine" python - "$CAMPAIGN_ID" "$line" <<'PY'
import json, sys
Expand All @@ -242,16 +244,15 @@ try:
except json.JSONDecodeError:
move = {"text": raw}
text = str(move.get("text") or move.get("label") or move.get("name") or "continue").strip()
suffix = "" if text.endswith((".", "!", "?")) else "."
reply = (
f"The table accepts your move: {text}. "
"The lantern brightens once, confirming the scripted smoke loop handled /move deterministically."
f"Your choice lands: {text}{suffix} "
"The lantern brightens once, and a nearby voice answers from the edge of the crowd."
)
server.log_event(campaign_id, "narration", reply)
print(reply)
PY
)"
beat=$((processed + 1))
json_append "$CHAT" "player" "$(move_chat_text "$line")"
json_append "$CHAT" "dm" "$reply" '{"engine_logged":true}'
trace_json "move_resolved" "$beat" "$line"
processed=$((processed + 1))
Expand Down
8 changes: 4 additions & 4 deletions viewer/openworlds/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ function useLiveSession(state) {
}
// #402: bound the echo tail so a long session doesn't grow `log` (and the rendered DOM /
// a11y tree) without limit. Keep the most-recent MAX_LIVE_ECHOES.
return boundTail([...l, { kind: "action", who, text, at: nextLogSeq() }], MAX_LIVE_ECHOES); // #274: creation-order stamp
return boundTail([...l, { kind: "action", who, text, at: nextLogSeq(), eventAt: Date.now() / 1000 }], MAX_LIVE_ECHOES); // #274: creation-order stamp
});
}, []);

Expand Down Expand Up @@ -393,7 +393,7 @@ function useLiveSession(state) {
// classification, and /chat replays it verbatim. Strip the tag for DISPLAY so the
// replayed dialog row shows the player's words, not "[do] …" (matches the optimistic
// echo above, which already strips via the same helper).
if (it.role === "player") return { kind: "dialog", who: "You", text: window.stripRoutingTag(it.text), at: nextLogSeq() };
if (it.role === "player") return { kind: "dialog", who: "You", text: window.stripRoutingTag(it.text), at: nextLogSeq(), eventAt: it.at };
dmLineArrived = true;
// #405: a /chat DM line is the turn-RESOLUTION signal (it clears the pending indicator
// below). It is NOT a second narration row when this run is streaming its prose via the
Expand All @@ -408,7 +408,7 @@ function useLiveSession(state) {
if (it.engine_logged === true) return null;
if (eventsStreamedThisTurnRef.current) return null;
const clean = sanitize(it.text);
return clean && claimNarration(clean) ? { kind: "narration", text: clean, at: nextLogSeq() } : null;
return clean && claimNarration(clean) ? { kind: "narration", text: clean, at: nextLogSeq(), eventAt: it.at } : null;
})
.filter(Boolean);
if (beats.length) setChatBeats((prev) => boundTail([...prev, ...beats], MAX_LIVE_BEATS)); // #402: cap the live tail
Expand Down Expand Up @@ -496,7 +496,7 @@ function useLiveSession(state) {
const fresh = (seq !== null) ? claimNarrationSeq(seq) : claimNarration(clean);
if (!fresh) return null;
eventsStreamedThisTurnRef.current = true; // the current turn HAS streamed live narration
return { kind: "narration", text: clean, at: nextLogSeq(), orderSeq: seq };
return { kind: "narration", text: clean, at: nextLogSeq(), orderSeq: seq, eventAt: e && e.t };
})
.filter(Boolean);
if (beats.length) {
Expand Down
30 changes: 21 additions & 9 deletions viewer/openworlds/screen-table.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,20 +209,32 @@ const CHRONICLE_RENDER_CAP = 50;
// windowing re-mount); fall back to a normalized TEXT key only for rows lacking a seq (legacy
// server / a chat-only beat), keyed identically to app.jsx's text fallback. Non-narration
// history rows (rolls/system/combat) are always kept.
// recentEvents stay the leading (oldest) band: they are the session log's trailing lines, all at or
// before the live tail's lines, and the dedup guarantees no overlap — so a plain concat is in order.
// recentEvents are usually the leading history band, but a player row replayed from /chat can sit
// BETWEEN two engine-owned recentEvents rows (opening narration → player move → DM reply). When rows
// carry eventAt, sort the combined de-duped chronicle by that real event time; legacy rows without a
// timestamp keep the old stable fallback order.
function buildChronicleLog(recentEvents, chatBeats, log) {
const recent = Array.isArray(recentEvents) ? recentEvents : [];
const beats = Array.isArray(chatBeats) ? chatBeats : [];
const echoes = Array.isArray(log) ? log : [];
const sanitize = (t) => (typeof window !== "undefined" && typeof window.sanitizeNarration === "function")
? window.sanitizeNarration(t || "") : (t || "");
const narrationKey = (t) => sanitize(t || "").replace(/\s+/g, " ").trim().toLowerCase();
const orderOf = (e) => (e && typeof e.orderSeq === "number") ? e.orderSeq : null;
const mergedTail = [...beats, ...echoes].sort((a, b) => {
const orderOf = (e) => {
if (e && typeof e.orderSeq === "number") return e.orderSeq;
if (e && typeof e.seq === "number") return e.seq;
return null;
};
const timeOf = (e) => (e && typeof e.eventAt === "number") ? e.eventAt : null;
const compareChronicle = (a, b) => {
const ta = timeOf(a), tb = timeOf(b);
if (ta !== null && tb !== null && ta !== tb) return ta - tb;
const sa = orderOf(a), sb = orderOf(b);
if (sa !== null && sb !== null) return sa - sb; // both from the session log → true beat order
return (a?.at || 0) - (b?.at || 0); // else fall back to client ingest order
if (sa !== null && sb !== null && sa !== sb) return sa - sb;
return (a?.at || 0) - (b?.at || 0);
};
const mergedTail = [...beats, ...echoes].sort((a, b) => {
return compareChronicle(a, b);
});
const liveSeqs = new Set(
mergedTail.filter((b) => b && b.kind === "narration" && typeof b.orderSeq === "number").map((b) => b.orderSeq),
Expand All @@ -238,7 +250,7 @@ function buildChronicleLog(recentEvents, chatBeats, log) {
const key = narrationKey(row && (row.text || row.detail));
return !key || !liveNarrationKeys.has(key);
});
return [...dedupedRecent, ...mergedTail];
return [...dedupedRecent, ...mergedTail].sort(compareChronicle);
}
// Exposed for tests/devtools introspection (additive — the component calls the local fn directly).
if (typeof window !== "undefined") window.buildChronicleLog = buildChronicleLog;
Expand Down Expand Up @@ -706,7 +718,7 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
key={`${a.group}:${a.id}`}
icon={a.available ? (a.icon || "quest.scroll") : "inventory.locked"}
label={a.label}
detail={a.available ? a.groupLabel : a.disabled_reason}
detail={a.available ? (a.detail || a.groupLabel) : a.disabled_reason}
hint={ACTION_HINTS[a.id]}
actionId={a.id}
tone={a.available ? "" : "crimson"}
Expand All @@ -727,7 +739,7 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
key={`${a.group}:${a.id}`}
icon={a.available ? (a.icon || "combat.attack") : "inventory.locked"}
label={a.label}
detail={a.available ? a.groupLabel : a.disabled_reason}
detail={a.available ? (a.detail || a.groupLabel) : a.disabled_reason}
hint={ACTION_HINTS[a.id]}
actionId={a.id}
tone={a.available ? "royal" : "crimson"}
Expand Down
23 changes: 15 additions & 8 deletions viewer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,7 @@ def _session_available_actions(action_model: dict) -> list[dict]:
item = {
"id": _text(action.get("id")),
"label": _text(action.get("label")),
"detail": _text(action.get("detail")),
"group": group_id,
"groupLabel": group_label,
"available": bool(action.get("available")),
Expand Down Expand Up @@ -1516,6 +1517,9 @@ def _session_recent_events(raw_events: list[dict] | None) -> list[dict]:
seq = row.get("seq")
if isinstance(seq, int) and not isinstance(seq, bool):
item["seq"] = seq
event_at = row.get("t")
if isinstance(event_at, (int, float)) and not isinstance(event_at, bool):
item["eventAt"] = event_at
out.append(item)
if len(out) >= 12:
break
Expand Down Expand Up @@ -5189,6 +5193,7 @@ def _action_item(
kind: str | None = None,
name: str | None = None,
text: str | None = None,
detail: str | None = None,
disabled_reason: str | None = None,
ui: str | None = None,
) -> dict:
Expand All @@ -5207,6 +5212,8 @@ def _action_item(
move["text"] = text
if move:
item["move"] = move
if detail:
item["detail"] = detail
if ui:
item["ui"] = ui
return item
Expand Down Expand Up @@ -5302,20 +5309,20 @@ def reaction_reason() -> str | None:
"id": "exploration",
"label": "Explore",
"actions": [
_action_item("continue", "Continue", kind="do", text="continue", disabled_reason=base_reason),
_action_item("say", "Say", disabled_reason=base_reason, ui="focus-say"),
_action_item("do", "Do", disabled_reason=base_reason, ui="focus-do"),
_action_item("check", "Check", disabled_reason=base_reason, ui="palette-skills"),
_action_item("save", "Save", disabled_reason=base_reason, ui="palette-saves"),
_action_item("continue", "Continue", kind="do", text="continue", detail="Press onward", disabled_reason=base_reason),
_action_item("say", "Say", detail="Speak aloud", disabled_reason=base_reason, ui="focus-say"),
_action_item("do", "Do", detail="Act in world", disabled_reason=base_reason, ui="focus-do"),
_action_item("check", "Check", detail="Roll a skill", disabled_reason=base_reason, ui="palette-skills"),
_action_item("save", "Save", detail="Resist danger", disabled_reason=base_reason, ui="palette-saves"),
],
},
{
"id": "combat",
"label": "Combat",
"actions": [
_action_item("attack", "Attack", kind="attack", name="Attack", disabled_reason=turn_action_reason("action")),
_action_item("bonus-action", "Bonus", kind="combat", name="Bonus Action", disabled_reason=turn_action_reason("bonus")),
_action_item("reaction", "Reaction", kind="combat", name="Reaction", disabled_reason=reaction_reason()),
_action_item("attack", "Attack", kind="attack", name="Attack", detail="Strike a foe", disabled_reason=turn_action_reason("action")),
_action_item("bonus-action", "Bonus", kind="combat", name="Bonus Action", detail="Quick extra move", disabled_reason=turn_action_reason("bonus")),
_action_item("reaction", "Reaction", kind="combat", name="Reaction", detail="Respond fast", disabled_reason=reaction_reason()),
],
},
],
Expand Down
28 changes: 28 additions & 0 deletions viewer/tests/test_live_narration_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,34 @@ def test_recent_events_deduped_against_live_tail_by_seq(self):
"recentEvents rows sharing a live seq must be dropped (immune to prose); the older un-twinned line is kept, leading, in order (#405)",
)

# --- #503: a player row replayed from /chat belongs BETWEEN recentEvents rows by event time ---
# After a reload/surface poll, the engine-owned history band can already contain opening
# narration + the DM reply, while the player's move is replayed from /chat. A plain
# recentEvents-before-tail concat rendered reply → YOU. eventAt restores the actual turn order.
def test_chat_player_row_interleaves_between_recent_events_by_event_time(self):
out = self._run(
"h.enqueue('/chat', { items: ["
" { role: 'player', text: '[do] Ask what changed tonight.', at: 20 }"
"], next: 1 });"
"await h.tick();"
"var recent = ["
" { kind: 'system', text: 'Session began.', seq: 0, eventAt: 5 },"
" { kind: 'narration', text: 'The lantern steadies.', seq: 1, eventAt: 10 },"
" { kind: 'narration', text: 'A nearby voice answers.', seq: 2, eventAt: 30 }"
"];"
"return ({ chronicle: h.chronicle(recent) });"
)
self.assertEqual(
out["chronicle"],
[
{"kind": "system", "text": "Session began."},
{"kind": "narration", "text": "The lantern steadies."},
{"kind": "dialog", "text": "Ask what changed tonight.", "who": "You"},
{"kind": "narration", "text": "A nearby voice answers."},
],
"the player move must render before the DM reply when /chat timestamps place it there (#503)",
)

# --- #479: provider wrappers may write the final DM reply to /chat only as a turn-resolution
# signal after also recording the same prose through the engine. That /chat row is marked
# engine_logged and must clear pending without adding a duplicate chronicle row, even if /chat
Expand Down
1 change: 1 addition & 0 deletions viewer/tests/test_openworlds_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,7 @@ def test_openworlds_table_renders_all_actions_without_truncation(self):
self.assertIn("actionsInCombat", source)
self.assertIn("explorationActions.map", source)
self.assertIn("combatActions.map", source)
self.assertIn("a.detail || a.groupLabel", source)
# The grouping keys off the engine-mutated combat gauge (encounter.active / a combat verb
# being available), never off fiction — keeping the gates/triggers invariant.
self.assertIn("surface?.encounter?.active", source)
Expand Down
8 changes: 7 additions & 1 deletion viewer/tests/test_session_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ def test_session_surface_projects_safe_read_model_without_private_fields(self):
campaign_id="camp_safe",
live=False,
is_live_view=False,
recent_events=[{"kind": "narration", "detail": "A bell rings once beyond the gate."}],
recent_events=[{"kind": "narration", "detail": "A bell rings once beyond the gate.", "t": 123.5}],
)

self.assertEqual(surface["campaign_id"], "camp_safe")
Expand All @@ -133,6 +133,7 @@ def test_session_surface_projects_safe_read_model_without_private_fields(self):
self.assertEqual(surface["activeQuests"][0]["objective"], "Speak to Harper Tull")
self.assertEqual([i["name"] for i in surface["quickInventory"]], ["Torch", "Potion of Healing"])
self.assertEqual(surface["recentEvents"][0]["text"], "A bell rings once beyond the gate.")
self.assertEqual(surface["recentEvents"][0]["eventAt"], 123.5)

encoded = json.dumps(surface)
for forbidden in (
Expand Down Expand Up @@ -166,8 +167,13 @@ def test_session_surface_routes_enabled_actions_through_move_contract(self):
say_action = _find_action(surface, "say")
self.assertTrue(continue_action["available"])
self.assertEqual(continue_action["move"], {"kind": "do", "text": "continue"})
self.assertEqual(continue_action["detail"], "Press onward")
self.assertTrue(say_action["available"])
self.assertEqual(say_action["detail"], "Speak aloud")
self.assertEqual(say_action["ui"], "focus-say")
self.assertEqual(_find_action(surface, "do")["detail"], "Act in world")
self.assertEqual(_find_action(surface, "check")["detail"], "Roll a skill")
self.assertEqual(_find_action(surface, "save")["detail"], "Resist danger")
self.assertNotIn("snapshot", json.dumps(surface))

def test_session_surface_projects_calendar_display_without_state_authority(self):
Expand Down
Loading