Give NPCs their own editor section and unify the world-state boxes

NPCs are still part of stat_schema, but editing them inside the World State
panel meant a nested dashed sub-box among four flat stat lists. They now get
a top-level "Cast & NPCs" section: one card per NPC (avatar, name, npc.<id>
address, triggers, description, its own stats) in a responsive grid, saved
through the same schema path via the new NpcEditor/addNpc exports.

The panel's real inconsistency was CSS, not layout: the global field rule
keys off input[type="..."], which never matches the editor's typeless key and
description inputs, so those rendered with browser defaults beside properly
styled number boxes. One scoped base rule now covers the whole editor and
every entry is the same tile with a caption over each control.

Two bugs fell out of that: .se-band-n (0,1,0) always lost to
input[type="number"] (0,1,1), so band bounds rendered full-width; and the
exclusion has to be :not(:where([type="checkbox"])) because a plain :not()
inherits its argument's specificity and swallows every override.

In Play, NPCs group under a single "Cast" heading as compact plates instead
of each becoming another top-level group in the rail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeQVy5bEjLhfgWNc27Efet
This commit is contained in:
parththakkar106
2026-08-02 19:46:32 +05:30
co-authored by Claude Opus 5
parent d66fd1d6a2
commit be6dbb0f24
5 changed files with 361 additions and 109 deletions
+118 -61
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { npcInitials } from './components'
// Rebuild an object with one key renamed, preserving order. Returns null on a // Rebuild an object with one key renamed, preserving order. Returns null on a
// no-op or a collision (so the caller keeps the old object). // no-op or a collision (so the caller keeps the old object).
@@ -27,13 +28,24 @@ function KeyInput({ value, onCommit, placeholder }) {
) )
} }
// Every control in this editor wears the same tiny caption — that consistency
// is what keeps the mixed row types (stats, flags, milestones, NPCs) reading
// as one form rather than five.
function Field({ label, className = '', children }) {
return (
<label className={`se-field ${className}`}>
<span>{label}</span>
{children}
</label>
)
}
function Num({ label, value, onChange }) { function Num({ label, value, onChange }) {
return ( return (
<label className="se-num"> <Field label={label} className="se-num">
<span>{label}</span>
<input type="number" value={value ?? ''} <input type="number" value={value ?? ''}
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} /> onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} />
</label> </Field>
) )
} }
@@ -65,18 +77,17 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
onChange(next) onChange(next)
} }
return ( return (
<div className="se-stat"> <div className="se-row">
<div className="se-row-top"> <div className="se-row-top">
<KeyInput value={statKey} onCommit={onRenameKey} placeholder="stat_name" /> <KeyInput value={statKey} onCommit={onRenameKey} placeholder="stat_name" />
<button type="button" className="se-remove" onClick={onRemove} title="Remove stat">✕</button> <button type="button" className="se-remove" onClick={onRemove} title="Remove stat">✕</button>
</div> </div>
<div className="se-fields"> <div className="se-fields">
{isText ? ( {isText ? (
<label className="se-num se-initial-text"> <Field label="initial" className="se-num se-initial-text">
<span>initial</span>
<input type="text" value={def.initial ?? ''} <input type="text" value={def.initial ?? ''}
onChange={(e) => set('initial', e.target.value)} /> onChange={(e) => set('initial', e.target.value)} />
</label> </Field>
) : ( ) : (
<> <>
<Num label="min" value={def.min} onChange={(v) => set('min', v)} /> <Num label="min" value={def.min} onChange={(v) => set('min', v)} />
@@ -86,18 +97,19 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
</> </>
)} )}
<Num label="cooldown" value={def.cooldown} onChange={(v) => set('cooldown', v)} /> <Num label="cooldown" value={def.cooldown} onChange={(v) => set('cooldown', v)} />
<label className="se-num se-kind"> <Field label="kind" className="se-num se-kind">
<span>kind</span>
<select value={isText ? 'text' : (def.type === 'counter' ? 'counter' : 'number')} <select value={isText ? 'text' : (def.type === 'counter' ? 'counter' : 'number')}
onChange={(e) => setType(e.target.value === 'number' ? undefined : e.target.value)}> onChange={(e) => setType(e.target.value === 'number' ? undefined : e.target.value)}>
<option value="number">number</option> <option value="number">number</option>
<option value="counter">counts up only</option> <option value="counter">counts up only</option>
<option value="text">free text</option> <option value="text">free text</option>
</select> </select>
</label> </Field>
</div> </div>
<input className="se-text" value={def.desc || ''} placeholder="description (shown to the AI)" <Field label="description (shown to the AI)">
<input className="se-text" value={def.desc || ''} placeholder="e.g. how badly wounded you are"
onChange={(e) => set('desc', e.target.value || undefined)} /> onChange={(e) => set('desc', e.target.value || undefined)} />
</Field>
{!isText && ( {!isText && (
<div className="se-bands"> <div className="se-bands">
<div className="se-sub-head">Bands — low, high, label</div> <div className="se-sub-head">Bands — low, high, label</div>
@@ -121,7 +133,7 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
) )
} }
function StatSection({ title, defs, onChange, addLabel = '+ stat', nested }) { function StatSection({ title, hint, defs, onChange, addLabel = '+ stat', nested }) {
const entries = Object.entries(defs || {}) const entries = Object.entries(defs || {})
const rename = (o, n) => { const x = withRenamedKey(defs, o, n); if (x) onChange(x) } const rename = (o, n) => { const x = withRenamedKey(defs, o, n); if (x) onChange(x) }
const setDef = (k, val) => onChange({ ...defs, [k]: val }) const setDef = (k, val) => onChange({ ...defs, [k]: val })
@@ -137,6 +149,7 @@ function StatSection({ title, defs, onChange, addLabel = '+ stat', nested }) {
<span className="se-section-title">{title}</span> <span className="se-section-title">{title}</span>
<button type="button" className="se-add" onClick={add}>{addLabel}</button> <button type="button" className="se-add" onClick={add}>{addLabel}</button>
</div> </div>
{hint && <p className="se-hint">{hint}</p>}
{entries.length === 0 && <div className="se-empty">None yet.</div>} {entries.length === 0 && <div className="se-empty">None yet.</div>}
{entries.map(([k, d]) => ( {entries.map(([k, d]) => (
<StatEditor key={k} statKey={k} def={d || {}} <StatEditor key={k} statKey={k} def={d || {}}
@@ -148,44 +161,6 @@ function StatSection({ title, defs, onChange, addLabel = '+ stat', nested }) {
) )
} }
function NpcSection({ npcs, onChange }) {
const entries = Object.entries(npcs || {})
const rename = (o, n) => { const x = withRenamedKey(npcs, o, n); if (x) onChange(x) }
const setNpc = (k, val) => onChange({ ...npcs, [k]: val })
const remove = (k) => { const x = { ...npcs }; delete x[k]; onChange(x) }
const add = () => {
let i = 1, key = 'npc'
while (npcs[key]) key = `npc${i++}`
onChange({ ...npcs, [key]: { name: '', keys: '', desc: '', stats: {} } })
}
return (
<div className="se-section">
<div className="se-section-head">
<span className="se-section-title">NPCs</span>
<button type="button" className="se-add" onClick={add}>+ NPC</button>
</div>
{entries.length === 0 && <div className="se-empty">No NPCs.</div>}
{entries.map(([k, npc]) => (
<div key={k} className="se-npc">
<div className="se-row-top">
<KeyInput value={k} onCommit={(nk) => rename(k, nk)} placeholder="npc_id" />
<span className="schema-npc-id">npc.{k}</span>
<button type="button" className="se-remove" onClick={() => remove(k)} title="Remove NPC">✕</button>
</div>
<input className="se-text" value={npc.name || ''} placeholder="Display name"
onChange={(e) => setNpc(k, { ...npc, name: e.target.value })} />
<input className="se-text" value={npc.keys || ''} placeholder="trigger words, comma-separated (e.g. Gwen, ranger)"
onChange={(e) => setNpc(k, { ...npc, keys: e.target.value })} />
<input className="se-text" value={npc.desc || ''} placeholder="description (lore + shown to the AI)"
onChange={(e) => setNpc(k, { ...npc, desc: e.target.value })} />
<StatSection title="Stats" defs={npc.stats || {}} nested
onChange={(nd) => setNpc(k, { ...npc, stats: nd })} />
</div>
))}
</div>
)
}
function FlagSection({ flags, onChange }) { function FlagSection({ flags, onChange }) {
const entries = Object.entries(flags || {}) const entries = Object.entries(flags || {})
const rename = (o, n) => { const x = withRenamedKey(flags, o, n); if (x) onChange(x) } const rename = (o, n) => { const x = withRenamedKey(flags, o, n); if (x) onChange(x) }
@@ -199,12 +174,13 @@ function FlagSection({ flags, onChange }) {
return ( return (
<div className="se-section"> <div className="se-section">
<div className="se-section-head"> <div className="se-section-head">
<span className="se-section-title">Flags (on/off)</span> <span className="se-section-title">Flags</span>
<button type="button" className="se-add" onClick={add}>+ flag</button> <button type="button" className="se-add" onClick={add}>+ flag</button>
</div> </div>
<p className="se-hint">On/off switches the AI can flip either way — a door unlocked, an alarm raised.</p>
{entries.length === 0 && <div className="se-empty">No flags.</div>} {entries.length === 0 && <div className="se-empty">No flags.</div>}
{entries.map(([k, f]) => ( {entries.map(([k, f]) => (
<div key={k} className="se-stat"> <div key={k} className="se-row">
<div className="se-row-top"> <div className="se-row-top">
<KeyInput value={k} onCommit={(nk) => rename(k, nk)} placeholder="flag_name" /> <KeyInput value={k} onCommit={(nk) => rename(k, nk)} placeholder="flag_name" />
<label className="se-check"> <label className="se-check">
@@ -214,8 +190,10 @@ function FlagSection({ flags, onChange }) {
</label> </label>
<button type="button" className="se-remove" onClick={() => remove(k)} title="Remove flag">✕</button> <button type="button" className="se-remove" onClick={() => remove(k)} title="Remove flag">✕</button>
</div> </div>
<input className="se-text" value={f.desc || ''} placeholder="description" <Field label="description (shown to the AI)">
<input className="se-text" value={f.desc || ''} placeholder="e.g. the cellar door is unlocked"
onChange={(e) => setFlag(k, { ...f, desc: e.target.value })} /> onChange={(e) => setFlag(k, { ...f, desc: e.target.value })} />
</Field>
</div> </div>
))} ))}
</div> </div>
@@ -235,26 +213,104 @@ function MilestoneSection({ milestones, onChange }) {
return ( return (
<div className="se-section"> <div className="se-section">
<div className="se-section-head"> <div className="se-section-head">
<span className="se-section-title">Milestones (sticky objectives)</span> <span className="se-section-title">Milestones</span>
<button type="button" className="se-add" onClick={add}>+ milestone</button> <button type="button" className="se-add" onClick={add}>+ milestone</button>
</div> </div>
<p className="se-hint">Objectives that stick once reached — they never un-tick on their own.</p>
{entries.length === 0 && <div className="se-empty">No milestones.</div>} {entries.length === 0 && <div className="se-empty">No milestones.</div>}
{entries.map(([k, m]) => ( {entries.map(([k, m]) => (
<div key={k} className="se-stat"> <div key={k} className="se-row">
<div className="se-row-top"> <div className="se-row-top">
<KeyInput value={k} onCommit={(nk) => rename(k, nk)} placeholder="milestone_id" /> <KeyInput value={k} onCommit={(nk) => rename(k, nk)} placeholder="milestone_id" />
<button type="button" className="se-remove" onClick={() => remove(k)} title="Remove milestone">✕</button> <button type="button" className="se-remove" onClick={() => remove(k)} title="Remove milestone">✕</button>
</div> </div>
<input className="se-text" value={m?.desc || ''} placeholder="objective description" <Field label="objective">
<input className="se-text" value={m?.desc || ''} placeholder="e.g. escaped the bandit camp"
onChange={(e) => setM(k, { ...m, desc: e.target.value })} /> onChange={(e) => setM(k, { ...m, desc: e.target.value })} />
</Field>
</div> </div>
))} ))}
</div> </div>
) )
} }
// Form-based editor for a stat_schema. `schema` is the parsed object (or null); // ---- NPCs ----------------------------------------------------------------
// `onChange(nextSchema)` fires on every edit. Empty sections are dropped. // NPCs live inside the same stat_schema (`schema.npcs`) but get their own
// top-level section in the editor: each one is a small character sheet, which
// doesn't fit the flat stat rows the other sections use.
// Next `npcs` object with a fresh entry appended, and the id it used.
export function addNpc(npcs) {
let i = 1, key = 'npc'
while (npcs?.[key]) key = `npc${i++}`
return { ...(npcs || {}), [key]: { name: '', keys: '', desc: '', stats: {} } }
}
function NpcCard({ npcId, npc, onRenameKey, onChange, onRemove }) {
const set = (field, val) => onChange({ ...npc, [field]: val })
const statCount = Object.keys(npc.stats || {}).length
return (
<div className="se-npc">
<div className="se-npc-head">
<span className="se-avatar" aria-hidden="true">{npcInitials(npc.name, npcId)}</span>
<div className="se-npc-ident">
<input className="se-npc-name" value={npc.name || ''} placeholder="Display name"
onChange={(e) => set('name', e.target.value)} />
<code className="se-npc-addr">npc.{npcId}</code>
</div>
<span className="se-npc-count">{statCount} {statCount === 1 ? 'stat' : 'stats'}</span>
<button type="button" className="se-remove" onClick={onRemove} title="Remove NPC">✕</button>
</div>
<div className="se-npc-body">
<Field label="id (how the AI addresses them)" className="se-npc-idfield">
<KeyInput value={npcId} onCommit={onRenameKey} placeholder="npc_id" />
</Field>
<Field label="trigger words, comma-separated">
<input className="se-text" value={npc.keys || ''} placeholder="Gwen, ranger, the scout"
onChange={(e) => set('keys', e.target.value)} />
</Field>
<Field label="description (lore + shown to the AI)">
<textarea className="se-text se-area" rows={2} value={npc.desc || ''}
placeholder="A wary ranger who owes you a debt."
onChange={(e) => set('desc', e.target.value)} />
</Field>
<StatSection title="Their stats" defs={npc.stats || {}} nested
onChange={(nd) => set('stats', nd)} />
</div>
</div>
)
}
// The NPC roster. Takes/returns just the `npcs` slice of a stat_schema.
export function NpcEditor({ npcs, onChange }) {
const entries = Object.entries(npcs || {})
const rename = (o, n) => { const x = withRenamedKey(npcs, o, n); if (x) onChange(x) }
const setNpc = (k, val) => onChange({ ...npcs, [k]: val })
const remove = (k) => { const x = { ...npcs }; delete x[k]; onChange(x) }
if (entries.length === 0) {
return (
<div className="empty" style={{ padding: '20px 0' }}>
No NPCs yet. Each one gets its own stats (trust, health, ferocity — whatever suits them),
and a story card is created automatically so they show up in context when mentioned.
</div>
)
}
return (
<div className="se se-npc-grid">
{entries.map(([k, npc]) => (
<NpcCard key={k} npcId={k} npc={npc || {}}
onRenameKey={(nk) => rename(k, nk)}
onChange={(n) => setNpc(k, n)}
onRemove={() => remove(k)} />
))}
</div>
)
}
// Form-based editor for a stat_schema, minus the NPCs (see `NpcEditor`, which
// the scenario editor renders as its own page section). `schema` is the parsed
// object (or null); `onChange(nextSchema)` fires on every edit. Empty sections
// are dropped.
export default function SchemaEditor({ schema, onChange }) { export default function SchemaEditor({ schema, onChange }) {
const s = schema && typeof schema === 'object' ? schema : {} const s = schema && typeof schema === 'object' ? schema : {}
const setSection = (key, val) => { const setSection = (key, val) => {
@@ -265,9 +321,10 @@ export default function SchemaEditor({ schema, onChange }) {
} }
return ( return (
<div className="se"> <div className="se">
<StatSection title="World stats" defs={s.world || {}} onChange={(v) => setSection('world', v)} /> <StatSection title="World stats" defs={s.world || {}} onChange={(v) => setSection('world', v)}
<StatSection title="Player stats" defs={s.player || {}} onChange={(v) => setSection('player', v)} /> hint="Things about the situation, not any one character — time of day, the camp’s alert level." />
<NpcSection npcs={s.npcs || {}} onChange={(v) => setSection('npcs', v)} /> <StatSection title="Player stats" defs={s.player || {}} onChange={(v) => setSection('player', v)}
hint="The player’s own numbers — hp, gold, reputation — plus free-text ones like an outfit." />
<FlagSection flags={s.flags || {}} onChange={(v) => setSection('flags', v)} /> <FlagSection flags={s.flags || {}} onChange={(v) => setSection('flags', v)} />
<MilestoneSection milestones={s.milestones || {}} onChange={(v) => setSection('milestones', v)} /> <MilestoneSection milestones={s.milestones || {}} onChange={(v) => setSection('milestones', v)} />
</div> </div>
+12
View File
@@ -68,6 +68,18 @@ function monogram(title) {
return letters.join('').toUpperCase() return letters.join('').toUpperCase()
} }
/** Initials for an NPC's avatar disc — "Bandit Leader" → BL, "gwen" → GW.
*
* Deliberately not `monogram`: that one drops short words, which is right for
* scenario titles and wrong for names, and it has no id to fall back on.
*/
export function npcInitials(name, id) {
const src = String(name || id || '?').trim() || '?'
const words = src.split(/[\s_-]+/).filter(Boolean)
const letters = words.length > 1 ? words[0][0] + words[1][0] : src.slice(0, 2)
return letters.toUpperCase()
}
/** A scenario's plate: uploaded picture, else emoji, else generated art. /** A scenario's plate: uploaded picture, else emoji, else generated art.
* *
* `large` is for the Continue cards, where the plate carries more weight. The * `large` is for the Continue cards, where the plate carries more weight. The
+165 -30
View File
@@ -641,6 +641,37 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
background: var(--accent); background: var(--accent);
transition: width 0.35s ease; transition: width 0.35s ease;
} }
/* Cast — each NPC is a small plate inside the one "Cast" group, so a roster of
five doesn't read as five more unrelated sections in the rail. */
.ws-npc {
padding: 7px 9px;
margin-bottom: 7px;
border: 1px solid var(--border);
border-left: 2px solid var(--accent-dim);
border-radius: 7px;
background: rgba(255, 255, 255, 0.022);
}
.ws-npc:last-child { margin-bottom: 0; }
.ws-npc-head { display: flex; align-items: center; gap: 7px; margin-bottom: 3px; }
.ws-npc-avatar {
flex-shrink: 0;
width: 22px; height: 22px;
border-radius: 50%;
display: grid; place-items: center;
font-family: var(--font-display);
font-size: 0.6rem;
color: #14100a;
background: linear-gradient(150deg, var(--accent-bright), var(--accent-dim));
}
.ws-npc-name {
font-family: var(--font-display);
font-size: 0.85rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ws-none { font-size: 0.78rem; color: var(--text-dim); font-style: italic; }
.ws-milestones { list-style: none; margin: 0; padding: 0; } .ws-milestones { list-style: none; margin: 0; padding: 0; }
.ws-milestones li { .ws-milestones li {
display: flex; display: flex;
@@ -749,35 +780,47 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
.schema-desc { color: var(--text-dim); font-size: 0.8rem; margin-top: 2px; } .schema-desc { color: var(--text-dim); font-size: 0.8rem; margin-top: 2px; }
.schema-bands { color: var(--text-dim); font-size: 0.76rem; margin-top: 2px; font-style: italic; } .schema-bands { color: var(--text-dim); font-size: 0.76rem; margin-top: 2px; font-style: italic; }
/* ---- Form-based schema editor ---- */ /* ---- Form-based schema editor ----
One shape for everything: a section is a panel, every entry inside it is a
`.se-row` tile, and every control wears a `.se-field` caption. Adding a new
kind of entry means reusing those three, not inventing a fourth box. */
.se-section { .se-section {
margin-bottom: 16px; margin-bottom: 16px;
padding: 12px 14px; padding: 13px 15px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 10px;
background: var(--bg-panel); background: var(--bg-panel);
} }
.se-section-nested { .se-section-nested {
margin: 8px 0 0; margin: 10px 0 0;
background: transparent; padding: 11px 12px;
border-style: dashed; background: rgba(0, 0, 0, 0.22);
border-radius: 8px;
} }
.se-section-head { .se-section-head {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.se-section-title { .se-section-title {
font-size: 0.74rem; font-size: 0.74rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
color: var(--accent);
}
.se-hint {
margin: -2px 0 10px;
font-size: 0.78rem;
line-height: 1.45;
color: var(--text-dim); color: var(--text-dim);
} }
.se-empty { color: var(--text-dim); font-size: 0.82rem; padding: 4px 0; } .se-empty { color: var(--text-dim); font-size: 0.82rem; padding: 4px 0; }
.se-add, .se-add-sm { .se-add, .se-add-sm {
font-size: 0.78rem; font-size: 0.78rem;
padding: 3px 10px; padding: 3px 10px;
white-space: nowrap;
} }
.se-add-sm { margin-top: 6px; } .se-add-sm { margin-top: 6px; }
.se-remove { .se-remove {
@@ -788,53 +831,145 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
padding: 2px 6px; padding: 2px 6px;
font-size: 0.9rem; font-size: 0.9rem;
line-height: 1; line-height: 1;
flex-shrink: 0;
} }
.se-remove:hover { color: var(--danger, #e5484d); } .se-remove:hover { color: var(--danger, #e5484d); }
.se-stat, .se-npc {
padding: 8px 0; /* Every control in the editor, one look. The global field rule keys off
border-bottom: 1px solid var(--border); `input[type="…"]`, so the plain `<input>`s here (keys, descriptions) never
matched it and fell back to browser defaults — that mismatch is most of why
this panel used to read as a pile of unrelated boxes. Everything below is
qualified with `.se` so it outranks that global rule (0,1,1) too. */
/* `:where()` so the exclusion costs no specificity — a plain
`:not([type="checkbox"])` would rank this above the `.se .se-*` overrides
below and swallow every one of them. */
.se input:not(:where([type="checkbox"])), .se textarea, .se select {
width: 100%;
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 8px;
font-size: 0.84rem;
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
} }
.se-stat:last-child, .se-npc:last-child { border-bottom: none; }
.se-npc { /* One entry — a stat, a flag, a milestone. */
.se-row {
padding: 10px 12px; padding: 10px 12px;
margin-bottom: 8px; margin-bottom: 8px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: 8px;
background: rgba(255, 255, 255, 0.018);
transition: border-color 0.15s;
} }
.se-row-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .se-row:last-child { margin-bottom: 0; }
.se-key { .se-row:focus-within { border-color: var(--border-bright); }
/* Wraps because a phone-width row can't fit key + toggle + ✕ side by side, and
an input's default `min-width: auto` would push the ✕ off-screen instead. */
.se-row-top { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 8px; }
.se .se-key {
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
font-size: 0.85rem; font-size: 0.82rem;
padding: 4px 8px; min-width: 0;
max-width: 220px; max-width: 220px;
} }
/* Only in the row context — `.se-field` is a *column* flex, where a basis of
140px would set the input's height instead of its width. */
.se .se-row-top .se-key { flex: 1 1 140px; }
.se-field { display: flex; flex-direction: column; gap: 3px; }
.se-field > span {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--text-dim);
}
.se-fields { .se-fields {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px 12px; gap: 8px 12px;
align-items: flex-end; align-items: flex-end;
margin-bottom: 6px; margin-bottom: 8px;
} }
.se-num { display: flex; flex-direction: column; gap: 2px; } .se .se-num input, .se .se-num select { width: 72px; padding: 4px 6px; }
.se-num span { font-size: 0.7rem; color: var(--text-dim); } .se .se-kind select { width: 132px; }
.se-num input, .se-num select { width: 72px; padding: 3px 6px; font-size: 0.82rem; border-radius: 6px; } .se .se-initial-text input { width: 220px; max-width: 100%; }
.se-kind select { width: 118px; }
.se-initial-text input { width: 220px; }
.se-check { .se-check {
display: flex; align-items: center; gap: 5px; display: flex; align-items: center; gap: 5px;
font-size: 0.8rem; color: var(--text-dim); font-size: 0.8rem; color: var(--text-dim);
white-space: nowrap;
} }
.se-check input { width: auto; } .se-check input { width: auto; }
.se-text { /* The global textarea rule also forces min-height:70px, which a 2-row inline
width: 100%; field doesn't want. */
padding: 4px 8px; .se .se-area { min-height: 0; line-height: 1.45; resize: vertical; }
font-size: 0.82rem;
margin-bottom: 4px;
}
.se-sub-head { font-size: 0.7rem; color: var(--text-dim); margin: 4px 0 4px; } .se-sub-head { font-size: 0.7rem; color: var(--text-dim); margin: 4px 0 4px; }
.se-band { display: flex; gap: 6px; align-items: center; margin-bottom: 4px; } .se-band { display: flex; gap: 6px; align-items: center; margin-bottom: 4px; }
.se-band-n { width: 64px; padding: 3px 6px; font-size: 0.82rem; } /* Qualified so it outweighs the global input[type="number"] rule (0,1,1) —
.se-band-l { flex: 1; padding: 3px 6px; font-size: 0.82rem; } as a bare class it lost, and the band bounds rendered full-width. */
.se .se-band-n { width: 64px; padding: 4px 6px; }
.se .se-band-l { flex: 1 1 60px; width: auto; min-width: 0; }
.se-bands { margin-top: 8px; }
/* ---- NPC roster (its own page section) ---- */
.se-npc-grid {
display: grid;
/* min() so a 320px phone gets one column instead of a 340px overflow */
grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr));
gap: 14px;
align-items: start;
}
.se-npc {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-panel);
overflow: hidden;
}
.se-npc:focus-within { border-color: var(--border-bright); }
.se-npc-head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, rgba(212, 169, 78, 0.07), transparent);
}
.se-avatar {
flex-shrink: 0;
width: 34px; height: 34px;
border-radius: 50%;
display: grid; place-items: center;
font-family: var(--font-display);
font-size: 0.8rem;
letter-spacing: 0.02em;
color: #14100a;
background: linear-gradient(150deg, var(--accent-bright), var(--accent-dim));
}
.se-npc-ident { flex: 1; min-width: 0; }
.se .se-npc-name {
padding: 2px 6px;
font-family: var(--font-display);
font-size: 0.95rem;
background: transparent;
border-color: transparent;
}
.se .se-npc-name:focus { background: var(--bg-input); border-color: var(--border-bright); }
.se-npc-addr {
display: block;
margin: 1px 0 0 6px;
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
color: var(--text-dim);
}
.se-npc-count {
flex-shrink: 0;
font-size: 0.7rem;
color: var(--text-dim);
white-space: nowrap;
}
.se-npc-body { padding: 12px; display: flex; flex-direction: column; gap: 10px; }
.se-npc-idfield .se-key { max-width: 100%; }
.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); }
+25 -7
View File
@@ -2,7 +2,7 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'rea
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import { AutoTextarea, Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components' import { AutoTextarea, Field, StoryCardRow, downloadJSON, npcInitials, pickJSONFile, useToast } from '../components'
const MODES = ['do', 'say', 'story'] const MODES = ['do', 'say', 'story']
const PLAYER_TYPES = ['do', 'say', 'story'] const PLAYER_TYPES = ['do', 'say', 'story']
@@ -703,11 +703,16 @@ function StatRow({ name, def, value, editing, onChange }) {
// `values`/`draft` are both plain {statName: value} maps — `draft` (edit mode) // `values`/`draft` are both plain {statName: value} maps — `draft` (edit mode)
// is a slice of the drawer's flat path->value map for this group's prefix. // is a slice of the drawer's flat path->value map for this group's prefix.
function StatGroup({ title, defs, values, desc, editing, draft, onEdit }) { // `nested` = the caller already drew a heading (an NPC card), so this drops the
// group's own spacing and says "nothing here" rather than vanishing and leaving
// that heading dangling over empty space.
function StatGroup({ title, defs, values, desc, editing, draft, onEdit, nested }) {
const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object') const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object')
if (entries.length === 0) return null if (entries.length === 0) {
return nested ? <div className="ws-none">No tracked stats.</div> : null
}
return ( return (
<div className="ws-group"> <div className={nested ? 'ws-stats' : 'ws-group'}>
{title && <h3 className="ws-group-title" title={desc || undefined}>{title}</h3>} {title && <h3 className="ws-group-title" title={desc || undefined}>{title}</h3>}
{entries.map(([name, def]) => ( {entries.map(([name, def]) => (
<StatRow key={name} name={name} def={def} <StatRow key={name} name={name} def={def}
@@ -835,12 +840,25 @@ function WorldStateDrawer({ advId, refreshKey }) {
<StatGroup title="You" defs={schema.player} values={state.player} <StatGroup title="You" defs={schema.player} values={state.player}
editing={editing} draft={sliceDraft(draft, 'player')} editing={editing} draft={sliceDraft(draft, 'player')}
onEdit={(name, v) => setPath(`player.${name}`, v)} /> onEdit={(name, v) => setPath(`player.${name}`, v)} />
{npcs.length > 0 && (
<div className="ws-group">
<h3 className="ws-group-title">Cast</h3>
{npcs.map(([id, def]) => ( {npcs.map(([id, def]) => (
<StatGroup key={id} title={def.name || id} desc={def.desc} <div key={id} className="ws-npc">
defs={def.stats} values={npcState[id]} <div className="ws-npc-head" title={def.desc || undefined}>
<span className="ws-npc-avatar" aria-hidden="true">
{npcInitials(def.name, id)}
</span>
<span className="ws-npc-name">{def.name || id}</span>
</div>
<StatGroup defs={def.stats} values={npcState[id]}
editing={editing} draft={sliceDraft(draft, `npc.${id}`)} editing={editing} draft={sliceDraft(draft, `npc.${id}`)}
onEdit={(name, v) => setPath(`npc.${id}.${name}`, v)} /> onEdit={(name, v) => setPath(`npc.${id}.${name}`, v)}
nested />
</div>
))} ))}
</div>
)}
{flags.length > 0 && ( {flags.length > 0 && (
<div className="ws-group"> <div className="ws-group">
<h3 className="ws-group-title">Flags</h3> <h3 className="ws-group-title">Flags</h3>
+35 -5
View File
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components' import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
import ArtPicker from '../ArtPicker' import ArtPicker from '../ArtPicker'
import SchemaEditor from '../SchemaEditor' import SchemaEditor, { NpcEditor, addNpc } from '../SchemaEditor'
export default function ScenarioEditor() { export default function ScenarioEditor() {
const { id } = useParams() const { id } = useParams()
@@ -106,6 +106,15 @@ export default function ScenarioEditor() {
debounceSave('stat_schema', () => saveSchema(cleaned)) debounceSave('stat_schema', () => saveSchema(cleaned))
} }
// NPCs live in the same stat_schema, but are edited in their own page
// section — so this is just the `npcs` slice of the same save path.
const setNpcs = (npcs) => {
const next = { ...(parsedSchema || {}) }
if (npcs && Object.keys(npcs).length) next.npcs = npcs
else delete next.npcs
applySchema(next)
}
const addCard = async () => { const addCard = async () => {
const card = await api.createStoryCard({ scenario_id: Number(id) }) const card = await api.createStoryCard({ scenario_id: Number(id) })
setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] }) setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] })
@@ -243,10 +252,10 @@ export default function ScenarioEditor() {
</div> </div>
</div> </div>
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}> <p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
Optional. Define stats (with bands and rules), NPCs, flags, and milestones, and Optional. Define stats (with bands and rules), flags, and milestones, and the AI
the AI will track them each turn — HP, mana, an NPC’s trust, quest objectives. will track them each turn — HP, mana, a raised alarm, quest objectives. NPCs are
Each NPC has its own name, trigger keys, description, and its own stats; a story part of this same schema but have their own section below. Leave blank for a plain
card is created for it automatically. Leave blank for a plain narrative scenario. narrative scenario.
</p> </p>
{schemaView === 'form' ? ( {schemaView === 'form' ? (
<SchemaEditor schema={parsedSchema} onChange={applySchema} /> <SchemaEditor schema={parsedSchema} onChange={applySchema} />
@@ -272,6 +281,27 @@ export default function ScenarioEditor() {
</> </>
)} )}
<div className="page-header" style={{ marginTop: 28 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Cast &amp; NPCs</h2>
{schemaView === 'form' && (
<button onClick={() => setNpcs(addNpc(parsedSchema?.npcs))}>+ Add NPC</button>
)}
</div>
<p className="dim" style={{ margin: '0 0 10px', fontSize: '0.85rem' }}>
Characters the AI tracks by name. Each has its own trigger words, description and
stats — a dragon’s <em>ferocity</em>, a ranger’s <em>trust</em> — addressed as
<code> npc.&lt;id&gt;.&lt;stat&gt;</code>. Adding one here also creates a story card
for it when an adventure starts.
</p>
{schemaView === 'form' ? (
<NpcEditor npcs={parsedSchema?.npcs || {}} onChange={setNpcs} />
) : (
<div className="empty" style={{ padding: '20px 0' }}>
NPCs are part of the schema JSON above — switch to <strong>Editor</strong> to edit
them as cards.
</div>
)}
<div className="page-header" style={{ marginTop: 28 }}> <div className="page-header" style={{ marginTop: 28 }}>
<h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Attached Scripts</h2> <h2 style={{ margin: 0, fontFamily: 'Georgia, serif', fontSize: '1.2rem' }}>Attached Scripts</h2>
</div> </div>