import { useEffect, useState } from 'react' import { npcInitials } from './components' // 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). function withRenamedKey(obj, oldKey, newKey) { if (!newKey || oldKey === newKey || obj[newKey] !== undefined) return null const next = {} for (const [k, v] of Object.entries(obj)) next[k === oldKey ? newKey : k] = v return next } // A key/id input that commits on blur (renaming a key mid-keystroke would // rebuild the parent object and steal focus). function KeyInput({ value, onCommit, placeholder }) { const [v, setV] = useState(value) useEffect(() => { setV(value) }, [value]) const commit = () => { const trimmed = v.trim() if (trimmed && trimmed !== value) onCommit(trimmed) else setV(value) } return ( setV(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur() }} /> ) } // 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 ( ) } function Num({ label, value, onChange }) { return ( onChange(e.target.value === '' ? undefined : Number(e.target.value))} /> ) } function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) { const set = (field, val) => { const next = { ...def } if (val === undefined || val === '') delete next[field] 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) => { const nb = bands.map((b) => (Array.isArray(b) ? [...b] : [0, 0, ''])) while (nb[i].length < 3) nb[i].push(j < 2 ? 0 : '') 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 (
{isText ? ( set('initial', e.target.value)} /> ) : ( <> set('min', v)} /> set('max', v)} /> set('initial', v)} /> set('max_delta_per_turn', v)} /> )} set('cooldown', v)} />
set('desc', e.target.value || undefined)} /> {!isText && (
Bands — low, high, label
{bands.map((b, i) => (
updBand(i, 0, e.target.value)} /> updBand(i, 1, e.target.value)} /> updBand(i, 2, e.target.value)} />
))}
)}
) } function StatSection({ title, hint, defs, onChange, addLabel = '+ stat', nested }) { const entries = Object.entries(defs || {}) const rename = (o, n) => { const x = withRenamedKey(defs, o, n); if (x) onChange(x) } const setDef = (k, val) => onChange({ ...defs, [k]: val }) const remove = (k) => { const x = { ...defs }; delete x[k]; onChange(x) } const add = () => { let i = 1, key = 'stat' while (defs[key]) key = `stat${i++}` onChange({ ...defs, [key]: { min: 0, max: 100, initial: 0 } }) } return (
{title}
{hint &&

{hint}

} {entries.length === 0 &&
None yet.
} {entries.map(([k, d]) => ( rename(k, nk)} onChange={(nd) => setDef(k, nd)} onRemove={() => remove(k)} /> ))}
) } function FlagSection({ flags, onChange }) { const entries = Object.entries(flags || {}) const rename = (o, n) => { const x = withRenamedKey(flags, o, n); if (x) onChange(x) } const setFlag = (k, val) => onChange({ ...flags, [k]: val }) const remove = (k) => { const x = { ...flags }; delete x[k]; onChange(x) } const add = () => { let i = 1, key = 'flag' while (flags[key]) key = `flag${i++}` onChange({ ...flags, [key]: { initial: false, desc: '' } }) } return (
Flags

On/off switches the AI can flip either way — a door unlocked, an alarm raised.

{entries.length === 0 &&
No flags.
} {entries.map(([k, f]) => (
rename(k, nk)} placeholder="flag_name" />
setFlag(k, { ...f, desc: e.target.value })} />
))}
) } function MilestoneSection({ milestones, onChange }) { const entries = Object.entries(milestones || {}) const rename = (o, n) => { const x = withRenamedKey(milestones, o, n); if (x) onChange(x) } const setM = (k, val) => onChange({ ...milestones, [k]: val }) const remove = (k) => { const x = { ...milestones }; delete x[k]; onChange(x) } const add = () => { let i = 1, key = 'goal' while (milestones[key]) key = `goal${i++}` onChange({ ...milestones, [key]: { desc: '' } }) } return (
Milestones

Objectives that stick once reached — they never un-tick on their own.

{entries.length === 0 &&
No milestones.
} {entries.map(([k, m]) => (
rename(k, nk)} placeholder="milestone_id" />
setM(k, { ...m, desc: e.target.value })} />
))}
) } // ---- NPCs ---------------------------------------------------------------- // 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 (
set('name', e.target.value)} /> npc.{npcId}
{statCount} {statCount === 1 ? 'stat' : 'stats'}
set('keys', e.target.value)} />