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:
co-authored by
Claude Opus 4.8
parent
3b03bf737a
commit
279110f75b
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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' && (
|
||||||
|
|||||||
Reference in New Issue
Block a user