From 279110f75b4693953b19bf77e8433e328b6add50 Mon Sep 17 00:00:00 2001 From: parththakkar106 Date: Tue, 21 Jul 2026 16:48:35 +0530 Subject: [PATCH] Show per-turn stat changes under each AI message Each AI action now carries a compact world_changes summary (derived from its stored snapshot), rendered as small chips beneath the message: numeric stats show a signed delta (green up / red down), flags show on/off, milestones show a check. Gives an at-a-glance "what changed" without opening Insights. Co-Authored-By: Claude Opus 4.8 --- backend/app/models.py | 25 ++++++++++++++++++++ backend/app/schemas.py | 2 ++ backend/tests/test_worldstate_integration.py | 14 +++++++++++ frontend/src/index.css | 22 +++++++++++++++++ frontend/src/pages/Play.jsx | 23 ++++++++++++++++++ 5 files changed, 86 insertions(+) diff --git a/backend/app/models.py b/backend/app/models.py index a047693..8d04f0f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -197,6 +197,31 @@ class Action(Base): adventure: Mapped[Adventure] = relationship(back_populates="actions") + @property + def world_changes(self) -> list[dict]: + """Compact per-turn RPG state changes (Phase 12), derived from the + stored snapshot, for the inline summary under an AI message. Labels are + path-based (no schema needed): `npc.gwen.trust` -> "gwen trust".""" + cs = self.context_snapshot if isinstance(self.context_snapshot, dict) else None + ws = cs.get("world_state") if cs else None + if not isinstance(ws, dict): + return [] + applied = (ws.get("report") or {}).get("applied") or [] + out: list[dict] = [] + for entry in applied: + parts = str(entry.get("path", "")).split(".") + section, name = parts[0], parts[-1] + if section == "flags": + out.append({"kind": "flag", "label": name, "on": bool(entry.get("new"))}) + elif section == "milestones": + out.append({"kind": "milestone", "label": name}) + else: + label = f"{parts[1]} {parts[2]}" if section == "npc" and len(parts) == 3 else name + old, new = entry.get("old"), entry.get("new") + delta = new - old if isinstance(old, (int, float)) and isinstance(new, (int, float)) else None + out.append({"kind": "stat", "label": label, "delta": delta, "value": new}) + return out + class Script(Base): __tablename__ = "scripts" diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 24444f1..e9838f7 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -131,6 +131,8 @@ class ActionOut(ORMModel): type: str text: str reasoning: str | None = None + # Phase 12: compact RPG state changes for this turn (from the model property). + world_changes: list[dict] = [] created_at: datetime diff --git a/backend/tests/test_worldstate_integration.py b/backend/tests/test_worldstate_integration.py index 0903d27..3293bde 100644 --- a/backend/tests/test_worldstate_integration.py +++ b/backend/tests/test_worldstate_integration.py @@ -140,6 +140,20 @@ def test_turn_applies_clamped_delta_and_strips_block(client): assert '"player.hp": -80' in snap["raw_output"] +def test_action_world_changes_summary(client): + _play(client) + db = SessionLocal() + try: + changes = db.get(models.Adventure, client.adv_id).actions[-1].world_changes + finally: + db.close() + by_label = {c["label"]: c for c in changes} + assert by_label["hp"]["delta"] == -30 # clamped stat, signed delta + assert by_label["gwen trust"]["delta"] == 15 # npc.. -> "id stat" + assert by_label["alarm"] == {"kind": "flag", "label": "alarm", "on": True} + assert by_label["win"]["kind"] == "milestone" + + def test_world_state_endpoint(client): _play(client) r = client.get(f"/api/adventures/{client.adv_id}/world-state") diff --git a/frontend/src/index.css b/frontend/src/index.css index 584bcd7..71e6e0d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -799,6 +799,28 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n .se-band-l { flex: 1; padding: 3px 6px; font-size: 0.82rem; } .ws-report { list-style: none; margin: 4px 0 0; padding: 0; font-size: 0.82rem; } .ws-report li { padding: 2px 0; color: var(--text); } + +/* Inline per-turn change chips under an AI message */ +.turn-changes { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} +.chg { + font-size: 0.74rem; + padding: 2px 8px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--bg-input); + color: var(--text-dim); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.chg-up { color: var(--player, #4caf82); border-color: color-mix(in srgb, var(--player, #4caf82) 40%, var(--border)); } +.chg-down { color: var(--danger, #e5484d); border-color: color-mix(in srgb, var(--danger, #e5484d) 40%, var(--border)); } +.chg-flag { color: var(--accent-bright); } +.chg-ms { color: var(--accent-bright); border-color: color-mix(in srgb, var(--accent-bright) 40%, var(--border)); } .ws-report code { color: var(--accent-bright); } .ws-delta { color: var(--player); font-variant-numeric: tabular-nums; } .ws-flag { color: var(--text-dim); font-style: italic; } diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx index 000f6ff..440909d 100644 --- a/frontend/src/pages/Play.jsx +++ b/frontend/src/pages/Play.jsx @@ -622,6 +622,28 @@ function WorldStateDrawer({ advId, refreshKey }) { ) } +// Compact chips shown under an AI message summarizing what state changed. +function StateChangeChips({ changes }) { + if (!changes?.length) return null + const nice = (s) => String(s).replace(/_/g, ' ') + return ( +
+ {changes.map((c, i) => { + if (c.kind === 'flag') { + return {nice(c.label)}: {c.on ? 'on' : 'off'} + } + if (c.kind === 'milestone') { + return ✓ {nice(c.label)} + } + const d = c.delta + const dir = typeof d === 'number' ? (d > 0 ? 'up' : d < 0 ? 'down' : 'flat') : 'flat' + const txt = typeof d === 'number' ? (d > 0 ? `+${d}` : `${d}`) : `→ ${c.value}` + return {nice(c.label)} {txt} + })} +
+ ) +} + // Per-turn RPG state change, shown when inspecting a past turn's snapshot. function WorldStateReport({ worldState }) { if (!worldState) return null @@ -1021,6 +1043,7 @@ export default function Play() { className={`action ${PLAYER_TYPES.includes(action.type) ? 'player' : ''}`}> {renderEmphasis(action.text)} + {action.type === 'ai' && } {!busy && ( {action.type === 'ai' && (