From 332185999ba318df21e04e30d458640ff25f35ea Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Sun, 5 Jul 2026 00:50:19 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(rules,backend,web):=20narrative=20iden?= =?UTF-8?q?tity=20=E2=80=94=20epithet,=20appearance,=20traits,=20backstory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR B of #10: users own their characters. New optional player-authored fields, ignored by the rules engine (story, not mechanics — nothing here can affect a derived number). - rules: narrativeSchema (epithet <=200, appearance fields, <=12 traits, backstory <=20k) as optional characterSchema.narrative; deriveSheet passes it through onto the sheet untouched. 5 tests incl. proof that identical builds with/without narrative derive identical numbers. - backend: characterFields.narrative + characters.updateNarrative — a narrow mutation that edits the story without resubmitting the build, still round-tripping the merged character through the rules layer so bounds are enforced at the write; passing no narrative clears it. 2 tests (round-trip incl. clear; bounds rejection). - web: shared NarrativeFields form (wizard identity step grows a collapsible PERSONNEL FILE section; the dossier gains an EDIT FILE panel via updateNarrative). SheetView identity band: schematic portrait frame ("NO IMAGE ON FILE"), name + serif epithet, trait chips, physicals readout, and the backstory panel in the one human voice. The editor lives outside the keyed sheet Match so live updates (e.g. rolling vitals mid-edit) cannot remount it and wipe typed text. Browser-verified: edited Kestrel via EDIT FILE -> identity band, chips, physicals, and backstory streamed onto the dossier live; wizard personnel-file section renders. 157 tests, per-package checks and prod build clean. --- apps/web/src/builder/steps/identity.tsx | 34 ++++++- apps/web/src/builder/store.ts | 6 ++ apps/web/src/components/narrative-fields.tsx | 68 ++++++++++++++ apps/web/src/components/sheet-view.tsx | 98 ++++++++++++++++++-- apps/web/src/lib/narrative.ts | 71 ++++++++++++++ apps/web/src/pages/character-sheet.tsx | 66 ++++++++++++- packages/backend/convex/characters.ts | 22 +++++ packages/backend/convex/schema.ts | 17 ++++ packages/backend/tests/characters.test.ts | 42 +++++++++ packages/rules/src/engine/character.ts | 5 +- packages/rules/src/schema/character.ts | 29 ++++++ packages/rules/tests/narrative.test.ts | 56 +++++++++++ 12 files changed, 501 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/narrative-fields.tsx create mode 100644 apps/web/src/lib/narrative.ts create mode 100644 packages/rules/tests/narrative.test.ts diff --git a/apps/web/src/builder/steps/identity.tsx b/apps/web/src/builder/steps/identity.tsx index b1f00bb..4275a75 100644 --- a/apps/web/src/builder/steps/identity.tsx +++ b/apps/web/src/builder/steps/identity.tsx @@ -1,9 +1,13 @@ -import { MonoLabel, Panel, TextInput } from "../../components/ui.tsx"; +import { createSignal, Show } from "solid-js"; +import { NarrativeFields } from "../../components/narrative-fields.tsx"; +import { Button, MonoLabel, Panel, TextInput } from "../../components/ui.tsx"; import type { BuilderStore } from "../store.ts"; export function IdentityStep(props: { store: BuilderStore }) { + const [showFile, setShowFile] = createSignal(false); + return ( - +

DECLARE IDENTITY

+ +
+
+

// PERSONNEL FILE

+ OPTIONAL — AUTHOR: PLAYER + +
+ + // epithet, appearance, traits, backstory — make the file yours (editable later on the + dossier) +

+ } + > +
+ props.store.setDraft("narrative", field, value)} + /> +
+
+
); } diff --git a/apps/web/src/builder/store.ts b/apps/web/src/builder/store.ts index 0aa163d..d8070c4 100644 --- a/apps/web/src/builder/store.ts +++ b/apps/web/src/builder/store.ts @@ -16,10 +16,13 @@ import { } from "@riftforge/rules"; import { createMemo, type Accessor } from "solid-js"; import { createStore, type SetStoreFunction } from "solid-js/store"; +import { emptyNarrativeForm, toNarrative, type NarrativeForm } from "../lib/narrative.ts"; /** Everything the player has chosen so far. */ export interface Draft { name: string; + /** Optional player-authored identity (raw form strings). */ + narrative: NarrativeForm; attributes?: Record; occId?: string; alignmentId?: string; @@ -52,6 +55,7 @@ export interface BuilderStore { export function createBuilderStore(): BuilderStore { const [draft, setDraft] = createStore({ name: "", + narrative: { ...emptyNarrativeForm }, psychicClass: "ordinary", occChoices: {}, related: [], @@ -94,6 +98,7 @@ export function createBuilderStore(): BuilderStore { const attributes = attributeTotals(); const skills = assembled(); if (!draft.occId || !attributes || !skills) return undefined; + const narrative = toNarrative(draft.narrative); return { name: draft.name.trim(), occId: draft.occId, @@ -104,6 +109,7 @@ export function createBuilderStore(): BuilderStore { psychicClass: draft.psychicClass, skills: skills.skills, spellIds: draft.spellIds, + ...(narrative !== undefined ? { narrative } : {}), }; }); diff --git a/apps/web/src/components/narrative-fields.tsx b/apps/web/src/components/narrative-fields.tsx new file mode 100644 index 0000000..fbc7faa --- /dev/null +++ b/apps/web/src/components/narrative-fields.tsx @@ -0,0 +1,68 @@ +import { For } from "solid-js"; +import type { NarrativeForm } from "../lib/narrative.ts"; +import { MonoLabel, TextInput } from "./ui.tsx"; + +const APPEARANCE_FIELDS = [ + ["height", "HEIGHT"], + ["weight", "WEIGHT"], + ["age", "AGE"], + ["eyes", "EYES"], + ["origin", "ORIGIN"], + ["disposition", "DISPOSITION"], +] as const satisfies readonly (readonly [keyof NarrativeForm, string])[]; + +/** + * The player-authored identity fields — shared by the wizard's identity step + * and the dossier's edit panel. All optional: story, not mechanics. + */ +export function NarrativeFields(props: { + form: NarrativeForm; + onChange: (field: K, value: string) => void; +}) { + return ( +
+ +
+ + {([field, label]) => ( + + )} + +
+ +