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 <noreply@anthropic.com>
This commit is contained in:
parththakkar106
2026-07-21 16:48:35 +05:30
co-authored by Claude Opus 4.8
parent 3b03bf737a
commit 279110f75b
5 changed files with 86 additions and 0 deletions
+25
View File
@@ -197,6 +197,31 @@ class Action(Base):
adventure: Mapped[Adventure] = relationship(back_populates="actions") 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): class Script(Base):
__tablename__ = "scripts" __tablename__ = "scripts"
+2
View File
@@ -131,6 +131,8 @@ class ActionOut(ORMModel):
type: str type: str
text: str text: str
reasoning: str | None = None reasoning: str | None = None
# Phase 12: compact RPG state changes for this turn (from the model property).
world_changes: list[dict] = []
created_at: datetime created_at: datetime
@@ -140,6 +140,20 @@ def test_turn_applies_clamped_delta_and_strips_block(client):
assert '"player.hp": -80' in snap["raw_output"] 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> -> "id stat"
assert by_label["alarm"] == {"kind": "flag", "label": "alarm", "on": True}
assert by_label["win"]["kind"] == "milestone"
def test_world_state_endpoint(client): def test_world_state_endpoint(client):
_play(client) _play(client)
r = client.get(f"/api/adventures/{client.adv_id}/world-state") r = client.get(f"/api/adventures/{client.adv_id}/world-state")
+22
View File
@@ -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; } .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 { list-style: none; margin: 4px 0 0; padding: 0; font-size: 0.82rem; }
.ws-report li { padding: 2px 0; color: var(--text); } .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-report code { color: var(--accent-bright); }
.ws-delta { color: var(--player); font-variant-numeric: tabular-nums; } .ws-delta { color: var(--player); font-variant-numeric: tabular-nums; }
.ws-flag { color: var(--text-dim); font-style: italic; } .ws-flag { color: var(--text-dim); font-style: italic; }
+23
View File
@@ -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 (
<div className="turn-changes">
{changes.map((c, i) => {
if (c.kind === 'flag') {
return <span key={i} className="chg chg-flag">{nice(c.label)}: {c.on ? 'on' : 'off'}</span>
}
if (c.kind === 'milestone') {
return <span key={i} className="chg chg-ms">✓ {nice(c.label)}</span>
}
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 <span key={i} className={`chg chg-stat chg-${dir}`}>{nice(c.label)} {txt}</span>
})}
</div>
)
}
// Per-turn RPG state change, shown when inspecting a past turn's snapshot. // Per-turn RPG state change, shown when inspecting a past turn's snapshot.
function WorldStateReport({ worldState }) { function WorldStateReport({ worldState }) {
if (!worldState) return null if (!worldState) return null
@@ -1021,6 +1043,7 @@ export default function Play() {
className={`action ${PLAYER_TYPES.includes(action.type) ? 'player' : ''}`}> className={`action ${PLAYER_TYPES.includes(action.type) ? 'player' : ''}`}>
<ReasoningBlock text={action.reasoning} /> <ReasoningBlock text={action.reasoning} />
{renderEmphasis(action.text)} {renderEmphasis(action.text)}
{action.type === 'ai' && <StateChangeChips changes={action.world_changes} />}
{!busy && ( {!busy && (
<span className="action-tools"> <span className="action-tools">
{action.type === 'ai' && ( {action.type === 'ai' && (