Add free-text stat type to the world-state RPG layer

Numeric stats couldn't represent things like worn armor or held items.
A stat can now be marked type: "text" — the AI reports the new full
value instead of a delta, with no clamping/bands (cooldown still
applies). Updated the scenario editor's stat kind selector, the
World State drawer's stat display, and the emit-rule prompt; added
tests and an outfit stat to the RPG demo scenario.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parththakkar106
2026-07-21 17:35:57 +05:30
co-authored by Claude Sonnet 5
parent db1170d0db
commit 42073d5d89
5 changed files with 142 additions and 33 deletions
+53 -25
View File
@@ -44,6 +44,7 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
else next[field] = val
onChange(next)
}
const isText = def.type === 'text'
const bands = Array.isArray(def.bands) ? def.bands : []
const setBands = (nb) => set('bands', nb.length ? nb : undefined)
const updBand = (i, j, raw) => {
@@ -52,6 +53,17 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
nb[i][j] = j < 2 ? (raw === '' ? 0 : Number(raw)) : raw
setBands(nb)
}
const setType = (val) => {
const next = { ...def, type: val || undefined }
if (val === 'text') {
// Numeric-only fields don't apply to free text.
delete next.min; delete next.max; delete next.max_delta_per_turn; delete next.bands
if (typeof next.initial !== 'string') next.initial = ''
} else if (typeof next.initial === 'string') {
delete next.initial
}
onChange(next)
}
return (
<div className="se-stat">
<div className="se-row-top">
@@ -59,36 +71,52 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
<button type="button" className="se-remove" onClick={onRemove} title="Remove stat">✕</button>
</div>
<div className="se-fields">
<Num label="min" value={def.min} onChange={(v) => set('min', v)} />
<Num label="max" value={def.max} onChange={(v) => set('max', v)} />
<Num label="initial" value={def.initial} onChange={(v) => set('initial', v)} />
<Num label="±/turn" value={def.max_delta_per_turn} onChange={(v) => set('max_delta_per_turn', v)} />
{isText ? (
<label className="se-num">
<span>initial</span>
<input type="text" value={def.initial ?? ''}
onChange={(e) => set('initial', e.target.value)} />
</label>
) : (
<>
<Num label="min" value={def.min} onChange={(v) => set('min', v)} />
<Num label="max" value={def.max} onChange={(v) => set('max', v)} />
<Num label="initial" value={def.initial} onChange={(v) => set('initial', v)} />
<Num label="±/turn" value={def.max_delta_per_turn} onChange={(v) => set('max_delta_per_turn', v)} />
</>
)}
<Num label="cooldown" value={def.cooldown} onChange={(v) => set('cooldown', v)} />
<label className="se-check">
<input type="checkbox" checked={def.type === 'counter'}
onChange={(e) => set('type', e.target.checked ? 'counter' : undefined)} />
<span>counts up only</span>
<label className="se-num">
<span>kind</span>
<select value={isText ? 'text' : (def.type === 'counter' ? 'counter' : 'number')}
onChange={(e) => setType(e.target.value === 'number' ? undefined : e.target.value)}>
<option value="number">number</option>
<option value="counter">counts up only</option>
<option value="text">free text</option>
</select>
</label>
</div>
<input className="se-text" value={def.desc || ''} placeholder="description (shown to the AI)"
onChange={(e) => set('desc', e.target.value || undefined)} />
<div className="se-bands">
<div className="se-sub-head">Bands — low, high, label</div>
{bands.map((b, i) => (
<div key={i} className="se-band">
<input type="number" className="se-band-n" value={b?.[0] ?? ''}
onChange={(e) => updBand(i, 0, e.target.value)} />
<input type="number" className="se-band-n" value={b?.[1] ?? ''}
onChange={(e) => updBand(i, 1, e.target.value)} />
<input className="se-band-l" value={b?.[2] ?? ''} placeholder="label"
onChange={(e) => updBand(i, 2, e.target.value)} />
<button type="button" className="se-remove"
onClick={() => setBands(bands.filter((_, k) => k !== i))} title="Remove band">✕</button>
</div>
))}
<button type="button" className="se-add-sm"
onClick={() => setBands([...bands, [0, 0, '']])}>+ band</button>
</div>
{!isText && (
<div className="se-bands">
<div className="se-sub-head">Bands — low, high, label</div>
{bands.map((b, i) => (
<div key={i} className="se-band">
<input type="number" className="se-band-n" value={b?.[0] ?? ''}
onChange={(e) => updBand(i, 0, e.target.value)} />
<input type="number" className="se-band-n" value={b?.[1] ?? ''}
onChange={(e) => updBand(i, 1, e.target.value)} />
<input className="se-band-l" value={b?.[2] ?? ''} placeholder="label"
onChange={(e) => updBand(i, 2, e.target.value)} />
<button type="button" className="se-remove"
onClick={() => setBands(bands.filter((_, k) => k !== i))} title="Remove band">✕</button>
</div>
))}
<button type="button" className="se-add-sm"
onClick={() => setBands([...bands, [0, 0, '']])}>+ band</button>
</div>
)}
</div>
)
}
+11
View File
@@ -497,6 +497,17 @@ function bandLabel(def, value) {
}
function StatRow({ name, def, value }) {
if (def?.type === 'text') {
const text = typeof value === 'string' ? value : (def?.initial ?? '')
return (
<div className="ws-stat">
<div className="ws-stat-head">
<span className="ws-stat-name" title={def?.desc || undefined}>{name}</span>
<span className="ws-stat-val">{text || '(unset)'}</span>
</div>
</div>
)
}
const val = typeof value === 'number' ? value : (def?.initial ?? 0)
const { min, max } = def || {}
const hasRange = typeof min === 'number' && typeof max === 'number' && max > min