Report the world-state changes the engine refuses
`apply_delta` records three outcomes for every change the model sends: `applied`, `clamped`, and `rejected`. Everything downstream read only `applied`. A refused change reached the player as an ordinary chip, and reached the model on the next turn as a change that had succeeded. Five parts: - `Action.world_changes` reads `clamped` and `rejected` beside `applied`. Accepted stats carry a `clamped` flag; refusals become `kind: "rejected"` entries. The `fix` key is present only when the engine wrote one, because this property runs for every action of every list response. - The UI separates the three outcomes. A clamp to a standstill reads `no change - at its limit` on a dashed chip, a partial clamp is marked `(limited)`, and a rejection carries its reason. Dashed and dimmed rather than red: a refused change means the rules are working. - The goals line names the milestone id, as `milestones.<id>`. The ids appeared nowhere in the prompt before, so the model could not send one. - Each rejection, and each clamp that moved nothing, builds a `fix` string from the stat definition at the point of refusal. `render_refusals()` renders them into the next prompt above `EMIT_REMINDER`. - `_history_text` replays `applied_delta()` instead of the sent delta, so a past turn's state block shows only what the engine accepted. A clamp that reduced a change but still moved the value reports nothing. If you tell a model its 80 damage became 30, it can treat the shortfall as a debt and send the remaining 50 next turn, which is the swing `max_delta_per_turn` prevents. In the demo scenario, `pokemon_left` becomes `pokemon_fainted` (`type: counter`, `initial: 0`). Starting at the ceiling turned a wrong-signed delta into a silent no-op; counting up puts the wrong sign on the counter rule, which refuses it out loud. The instructions also now ask for `world.turn`, which sat at 0 for a whole playtest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PacdRuPXSkQQy4ZYdH32hF
This commit is contained in:
committed by
Parth
co-authored by
Claude Opus 5
parent
2ca2dadc72
commit
47c7800903
@@ -1205,7 +1205,27 @@ function BranchPanel({ advId, refreshKey, onSwitched, onTreeChanged, onError })
|
||||
)
|
||||
}
|
||||
|
||||
// Reasons the engine gives for refusing a change, in the player's words.
|
||||
const REJECT_REASONS = {
|
||||
'not a number': 'expected a number',
|
||||
'not a string': 'expected text',
|
||||
'not a boolean': 'expected on or off',
|
||||
'not true': 'a milestone can only be set',
|
||||
"counter can't decrease": 'this only counts up',
|
||||
cooldown: 'changed too recently',
|
||||
'unknown stat': 'no such stat',
|
||||
'unknown npc': 'no such character',
|
||||
'unknown npc stat': 'no such stat',
|
||||
'unknown flag': 'no such flag',
|
||||
'unknown milestone': 'no such milestone',
|
||||
'unknown path': 'unrecognized name',
|
||||
}
|
||||
|
||||
// Compact chips shown under an AI message summarizing what state changed.
|
||||
//
|
||||
// Refused changes appear here too. A clamped stat is marked, and a stat whose
|
||||
// clamp left it exactly where it started reads "no change" rather than "+0",
|
||||
// which looked like an ordinary update.
|
||||
function StateChangeChips({ changes }) {
|
||||
if (!changes?.length) return null
|
||||
const nice = (s) => String(s).replace(/_/g, ' ')
|
||||
@@ -1218,12 +1238,32 @@ function StateChangeChips({ changes }) {
|
||||
if (c.kind === 'milestone') {
|
||||
return <span key={i} className="chg chg-ms">✓ {nice(c.label)}</span>
|
||||
}
|
||||
if (c.kind === 'rejected') {
|
||||
const why = REJECT_REASONS[c.reason] || c.reason
|
||||
// The engine's own wording quotes the real limits, so prefer it.
|
||||
return (
|
||||
<span key={i} className="chg chg-refused" title={c.fix || `The story asked to change this and the rules refused: ${why}.`}>
|
||||
{nice(c.label)} <span className="chg-val">refused — {why}</span>
|
||||
</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}`
|
||||
// A clamp that cancels the change entirely is its own outcome. It is
|
||||
// neither an update nor a refusal, and "+0" read as the former.
|
||||
const blocked = c.clamped && d === 0
|
||||
const dir = blocked ? 'refused' : typeof d === 'number' ? (d > 0 ? 'up' : d < 0 ? 'down' : 'flat') : 'flat'
|
||||
const txt = blocked
|
||||
? 'no change — at its limit'
|
||||
: typeof d === 'number' ? (d > 0 ? `+${d}` : `${d}`) : `→ ${c.value}`
|
||||
const title = blocked
|
||||
? c.fix || 'The story asked to change this and it is already at the limit the scenario allows.'
|
||||
: c.clamped
|
||||
? 'The scenario limits how far this can move in one turn, so the change was reduced.'
|
||||
: undefined
|
||||
return (
|
||||
<span key={i} className={`chg chg-stat chg-${dir}`}>
|
||||
<span key={i} className={`chg chg-stat chg-${dir}`} title={title}>
|
||||
{nice(c.label)} <span className="chg-val">{txt}</span>
|
||||
{c.clamped && !blocked ? <span className="chg-limited"> (limited)</span> : null}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user