From 395c4603d815a08f882a36309e58d2f4261294a7 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 2 Jun 2026 04:14:49 +0700 Subject: [PATCH] improve first-turn action affordances --- scripts/play_scripted_dm.sh | 11 ++++---- viewer/openworlds/app.jsx | 8 +++--- viewer/openworlds/screen-table.jsx | 30 +++++++++++++++------- viewer/server.py | 23 +++++++++++------ viewer/tests/test_live_narration_stream.py | 28 ++++++++++++++++++++ viewer/tests/test_openworlds_static.py | 1 + viewer/tests/test_session_surface.py | 8 +++++- 7 files changed, 82 insertions(+), 27 deletions(-) diff --git a/scripts/play_scripted_dm.sh b/scripts/play_scripted_dm.sh index 0047933e..2e19f156 100755 --- a/scripts/play_scripted_dm.sh +++ b/scripts/play_scripted_dm.sh @@ -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})) @@ -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 @@ -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)) diff --git a/viewer/openworlds/app.jsx b/viewer/openworlds/app.jsx index a7a306b3..7752bb2c 100644 --- a/viewer/openworlds/app.jsx +++ b/viewer/openworlds/app.jsx @@ -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 }); }, []); @@ -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 @@ -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 @@ -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) { diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index ef80ddeb..06a1974b 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -209,8 +209,10 @@ 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 : []; @@ -218,11 +220,21 @@ function buildChronicleLog(recentEvents, chatBeats, 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), @@ -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; @@ -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"} @@ -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"} diff --git a/viewer/server.py b/viewer/server.py index 8aaf4914..f9d3c039 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -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")), @@ -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 @@ -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: @@ -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 @@ -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()), ], }, ], diff --git a/viewer/tests/test_live_narration_stream.py b/viewer/tests/test_live_narration_stream.py index d7488beb..d8a1c2a7 100644 --- a/viewer/tests/test_live_narration_stream.py +++ b/viewer/tests/test_live_narration_stream.py @@ -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 diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index 9e63e713..bfaf335a 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -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) diff --git a/viewer/tests/test_session_surface.py b/viewer/tests/test_session_surface.py index 13749c1f..59a92625 100644 --- a/viewer/tests/test_session_surface.py +++ b/viewer/tests/test_session_surface.py @@ -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") @@ -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 ( @@ -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):