From be6dbb0f24c94440835f58aaf76ac056bb4a1fbd Mon Sep 17 00:00:00 2001 From: parththakkar106 Date: Sun, 2 Aug 2026 19:46:32 +0530 Subject: [PATCH] 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. 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 Claude-Session: https://claude.ai/code/session_01UeQVy5bEjLhfgWNc27Efet --- frontend/src/SchemaEditor.jsx | 185 +++++++++++++++--------- frontend/src/components.jsx | 12 ++ frontend/src/index.css | 195 ++++++++++++++++++++++---- frontend/src/pages/Play.jsx | 38 +++-- frontend/src/pages/ScenarioEditor.jsx | 40 +++++- 5 files changed, 361 insertions(+), 109 deletions(-) diff --git a/frontend/src/SchemaEditor.jsx b/frontend/src/SchemaEditor.jsx index 0f99773..ea42d45 100644 --- a/frontend/src/SchemaEditor.jsx +++ b/frontend/src/SchemaEditor.jsx @@ -1,4 +1,5 @@ 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). @@ -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 ( + + ) +} + function Num({ label, value, onChange }) { return ( - + ) } @@ -65,18 +77,17 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) { onChange(next) } return ( -
+
{isText ? ( - + ) : ( <> set('min', v)} /> @@ -86,18 +97,19 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) { )} set('cooldown', v)} /> - +
- set('desc', e.target.value || undefined)} /> + + set('desc', e.target.value || undefined)} /> + {!isText && (
Bands — low, high, label
@@ -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 rename = (o, n) => { const x = withRenamedKey(defs, o, n); if (x) onChange(x) } const setDef = (k, val) => onChange({ ...defs, [k]: val }) @@ -137,6 +149,7 @@ function StatSection({ title, defs, onChange, addLabel = '+ stat', nested }) { {title}
+ {hint &&

{hint}

} {entries.length === 0 &&
None yet.
} {entries.map(([k, d]) => ( { 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 ( -
-
- NPCs - -
- {entries.length === 0 &&
No NPCs.
} - {entries.map(([k, npc]) => ( -
-
- rename(k, nk)} placeholder="npc_id" /> - npc.{k} - -
- setNpc(k, { ...npc, name: e.target.value })} /> - setNpc(k, { ...npc, keys: e.target.value })} /> - setNpc(k, { ...npc, desc: e.target.value })} /> - setNpc(k, { ...npc, stats: nd })} /> -
- ))} -
- ) -} - function FlagSection({ flags, onChange }) { const entries = Object.entries(flags || {}) const rename = (o, n) => { const x = withRenamedKey(flags, o, n); if (x) onChange(x) } @@ -199,12 +174,13 @@ function FlagSection({ flags, onChange }) { return (
- Flags (on/off) + 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 })} /> + + setFlag(k, { ...f, desc: e.target.value })} /> +
))}
@@ -235,26 +213,104 @@ function MilestoneSection({ milestones, onChange }) { return (
- Milestones (sticky objectives) + 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 })} /> + + setM(k, { ...m, desc: e.target.value })} /> +
))}
) } -// Form-based editor for a stat_schema. `schema` is the parsed object (or null); -// `onChange(nextSchema)` fires on every edit. Empty sections are dropped. +// ---- 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)} /> + + +