+
+
+ );
+}
/** The live sheet: `SheetView` fed by the `characters.sheet` subscription. */
export function CharacterSheetPage() {
@@ -55,6 +108,13 @@ export function CharacterSheetPage() {
)}
+ {/* Outside the keyed Match: live sheet updates (e.g. rolling vitals
+ mid-edit) must not remount the editor and wipe typed text. */}
+
+
+
+
+
);
}
diff --git a/packages/backend/convex/characters.ts b/packages/backend/convex/characters.ts
index 6752777..c45ea62 100644
--- a/packages/backend/convex/characters.ts
+++ b/packages/backend/convex/characters.ts
@@ -57,6 +57,28 @@ export const update = mutation({
},
});
+/**
+ * Replace the player-authored narrative (epithet, appearance, traits,
+ * backstory) without resubmitting the whole build. Story, not mechanics —
+ * but it still round-trips through the rules layer so bounds (trait count,
+ * lengths) are enforced at the write. Passing no narrative clears it.
+ */
+export const updateNarrative = mutation({
+ args: {
+ id: v.id("characters"),
+ narrative: characterFields.narrative,
+ },
+ returns: v.null(),
+ handler: async (ctx, { id, narrative }) => {
+ const doc = await ctx.db.get(id);
+ if (doc === null) throw new Error(`Character ${id} not found.`);
+ const { _id, _creationTime, ...stored } = doc;
+ validateCharacter({ ...stored, narrative });
+ await ctx.db.patch(id, { narrative });
+ return null;
+ },
+});
+
/**
* Roll the character's dice-derived vitals — Hit Points, physical S.D.C., and
* (for P.P.E.-bearing O.C.C.s) permanent P.P.E. — and pin the results on the
diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts
index dbb4a79..3bd7c9b 100644
--- a/packages/backend/convex/schema.ts
+++ b/packages/backend/convex/schema.ts
@@ -46,6 +46,23 @@ export const characterFields = {
ppe: v.optional(v.number()),
}),
),
+ narrative: v.optional(
+ v.object({
+ epithet: v.optional(v.string()),
+ appearance: v.optional(
+ v.object({
+ height: v.optional(v.string()),
+ weight: v.optional(v.string()),
+ age: v.optional(v.string()),
+ eyes: v.optional(v.string()),
+ origin: v.optional(v.string()),
+ disposition: v.optional(v.string()),
+ }),
+ ),
+ traits: v.optional(v.array(v.string())),
+ backstory: v.optional(v.string()),
+ }),
+ ),
};
export default defineSchema({
diff --git a/packages/backend/tests/characters.test.ts b/packages/backend/tests/characters.test.ts
index 7a17074..63f826e 100644
--- a/packages/backend/tests/characters.test.ts
+++ b/packages/backend/tests/characters.test.ts
@@ -107,6 +107,48 @@ describe("characters — a saved Ley Line Walker round-trips", () => {
expect(stored?.rolled).toEqual(second);
});
+ test("narrative round-trips: create with it, edit it narrowly, sheet carries it", async () => {
+ const t = convexTest(schema, modules);
+ const id = await t.mutation(api.characters.create, {
+ ...vesper,
+ narrative: { epithet: "First of her line." },
+ });
+ let sheet = await t.query(api.characters.sheet, { id });
+ if (sheet === null) throw new Error("sheet should exist for a saved character");
+ expect(sheet.narrative).toEqual({ epithet: "First of her line." });
+
+ await t.mutation(api.characters.updateNarrative, {
+ id,
+ narrative: {
+ epithet: "The ley lines whisper.",
+ traits: ["Magic Zone survivor"],
+ appearance: { age: "19" },
+ backstory: "She walked out of the Magic Zone on her fourteenth birthday.",
+ },
+ });
+ sheet = await t.query(api.characters.sheet, { id });
+ if (sheet === null) throw new Error("sheet should exist for a saved character");
+ expect(sheet.narrative?.traits).toEqual(["Magic Zone survivor"]);
+ expect(sheet.narrative?.epithet).toBe("The ley lines whisper.");
+
+ // Clearing works, and the rest of the character is untouched.
+ await t.mutation(api.characters.updateNarrative, { id, narrative: undefined });
+ const stored = await t.query(api.characters.get, { id });
+ expect(stored?.narrative).toBeUndefined();
+ expect(stored).toMatchObject(vesper);
+ });
+
+ test("updateNarrative enforces rules-layer bounds", async () => {
+ const t = convexTest(schema, modules);
+ const id = await t.mutation(api.characters.create, vesper);
+ await expect(
+ t.mutation(api.characters.updateNarrative, {
+ id,
+ narrative: { traits: Array.from({ length: 13 }, (_, i) => `trait ${i}`) },
+ }),
+ ).rejects.toThrow();
+ });
+
test("alignment round-trips and resolves on the sheet", async () => {
const t = convexTest(schema, modules);
const id = await t.mutation(api.characters.create, { ...vesper, alignmentId: "anarchist" });
diff --git a/packages/rules/src/engine/character.ts b/packages/rules/src/engine/character.ts
index b8b2b9f..f46969a 100644
--- a/packages/rules/src/engine/character.ts
+++ b/packages/rules/src/engine/character.ts
@@ -1,5 +1,5 @@
import type { Alignment } from "../schema/alignments.ts";
-import type { Character, CharacterInput } from "../schema/character.ts";
+import type { Character, CharacterInput, Narrative } from "../schema/character.ts";
import { characterSchema } from "../schema/character.ts";
import type { Occ } from "../schema/occ.ts";
import type { Spell } from "../schema/spells.ts";
@@ -37,6 +37,8 @@ export interface CharacterSheet {
occ: { id: string; name: string; category: string };
/** Present when the character has picked an alignment. */
alignment?: Alignment;
+ /** Player-authored identity, passed through untouched (never affects numbers). */
+ narrative?: Narrative;
level: number;
attributes: Character["attributes"];
attributeBonuses: Record;
@@ -161,6 +163,7 @@ export function deriveSheet(input: CharacterInput): CharacterSheet {
name: character.name,
occ: { id: occ.id, name: occ.name, category: occ.category },
alignment,
+ narrative: character.narrative,
level,
attributes: attrs,
attributeBonuses,
diff --git a/packages/rules/src/schema/character.ts b/packages/rules/src/schema/character.ts
index f45bb35..ec8c73f 100644
--- a/packages/rules/src/schema/character.ts
+++ b/packages/rules/src/schema/character.ts
@@ -30,6 +30,33 @@ export type CharacterAttributes = z.infer;
export const psychicClassSchema = z.enum(["masterPsychic", "majorOrMinorPsychic", "ordinary"]);
export type PsychicClass = z.infer;
+/** Player-authored physical description — free text, no mechanics. */
+export const appearanceSchema = z.object({
+ height: z.string().max(40).optional(),
+ weight: z.string().max(40).optional(),
+ age: z.string().max(40).optional(),
+ eyes: z.string().max(80).optional(),
+ origin: z.string().max(120).optional(),
+ disposition: z.string().max(120).optional(),
+});
+export type Appearance = z.infer;
+
+/**
+ * Player-authored narrative identity ("users own their characters" — see
+ * DESIGN.md). The rules engine ignores every field: this is story, not
+ * mechanics, so nothing here can affect a derived number.
+ */
+export const narrativeSchema = z.object({
+ /** One-line quote/tagline rendered under the name. */
+ epithet: z.string().min(1).max(200).optional(),
+ appearance: appearanceSchema.optional(),
+ /** Short identity chips (e.g. "MAGIC ZONE SURVIVOR"). */
+ traits: z.array(z.string().min(1).max(60)).max(12).optional(),
+ /** Long-form prose. Generous but bounded (~a few pages). */
+ backstory: z.string().min(1).max(20_000).optional(),
+});
+export type Narrative = z.infer;
+
/**
* A built character — the player's *choices*. Derived stats (bonuses, attacks,
* save targets, resolved skill %s, spell strength, …) are computed by
@@ -65,6 +92,8 @@ export const characterSchema = z.object({
ppe: z.number().int().nonnegative().optional(),
})
.optional(),
+ /** Optional player-authored identity; passed through to the sheet untouched. */
+ narrative: narrativeSchema.optional(),
});
/** A fully-resolved character (defaulted fields present) — e.g. after parsing/from storage. */
export type Character = z.infer;
diff --git a/packages/rules/tests/narrative.test.ts b/packages/rules/tests/narrative.test.ts
new file mode 100644
index 0000000..e1d10d9
--- /dev/null
+++ b/packages/rules/tests/narrative.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, test } from "vite-plus/test";
+import { deriveSheet, type CharacterInput } from "../src/index.ts";
+
+const base: CharacterInput = {
+ name: "Kestrel",
+ occId: "ley-line-walker",
+ level: 1,
+ attributes: { IQ: 12, ME: 10, MA: 9, PS: 11, PP: 13, PE: 14, PB: 10, Spd: 12 },
+ hthType: "basic",
+};
+
+const narrative = {
+ epithet: "The ley lines whisper, and she whispers back.",
+ appearance: {
+ height: "5'7\"",
+ weight: "128 lbs",
+ age: "19",
+ eyes: "grey (faint glow)",
+ origin: "Magic Zone fringe",
+ disposition: "quiet, watchful",
+ },
+ traits: ["Magic Zone survivor", "Coalition watchlist", "D-Bee sympathizer"],
+ backstory: "She walked out of the Magic Zone on her fourteenth birthday.",
+};
+
+describe("narrative identity — story, not mechanics", () => {
+ test("passes through deriveSheet untouched", () => {
+ const sheet = deriveSheet({ ...base, narrative });
+ expect(sheet.narrative).toEqual(narrative);
+ });
+
+ test("is optional and absent by default", () => {
+ expect(deriveSheet(base).narrative).toBeUndefined();
+ });
+
+ test("partial narratives are fine — every field is optional", () => {
+ const sheet = deriveSheet({ ...base, narrative: { epithet: "Just a tagline." } });
+ expect(sheet.narrative).toEqual({ epithet: "Just a tagline." });
+ });
+
+ test("never affects derived numbers", () => {
+ const bare = deriveSheet(base);
+ const storied = deriveSheet({ ...base, narrative });
+ expect(storied.combat).toEqual(bare.combat);
+ expect(storied.vitals).toEqual(bare.vitals);
+ expect(storied.skills).toEqual(bare.skills);
+ expect(storied.saves).toEqual(bare.saves);
+ });
+
+ test("bounds are enforced: too many traits, empty epithet", () => {
+ expect(() =>
+ deriveSheet({ ...base, narrative: { traits: Array.from({ length: 13 }, () => "x") } }),
+ ).toThrow();
+ expect(() => deriveSheet({ ...base, narrative: { epithet: "" } })).toThrow();
+ });
+});
From 5b1007ead822f55e20593061cfa9277c5416be04 Mon Sep 17 00:00:00 2001
From: StreamDemon
Date: Sun, 5 Jul 2026 01:15:42 +0800
Subject: [PATCH 2/2] =?UTF-8?q?fix(rules,web):=20Cubic=20review=20fixes=20?=
=?UTF-8?q?=E2=80=94=20editor=20reset=20on=20id=20change,=20no-comma=20tra?=
=?UTF-8?q?its,=20disclosure=20a11y,=20form=20caps,=20empty-appearance=20g?=
=?UTF-8?q?uard?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- the dossier editor resets its form (and closes) whenever the route
id changes, so one character''s draft can never be written into
another''s file — no longer relying on remount timing
- traits reject commas at the schema (chips are comma-separated in
editors; a comma would split a chip on round-trip) + regression test
- both disclosure toggles (PERSONNEL FILE, EDIT FILE) carry
aria-expanded/aria-controls
- form controls mirror the schema caps via maxLength (epithet 200,
appearance 40-120, backstory 20k, traits worst-legal-case), so
writes cannot be rejected for length
- the physicals block hides when an API-written `appearance: {}` has
no rows to show
---
apps/web/src/builder/steps/identity.tsx | 10 +++++--
apps/web/src/components/narrative-fields.tsx | 27 ++++++++++++-------
apps/web/src/components/sheet-view.tsx | 28 +++++++++++---------
apps/web/src/pages/character-sheet.tsx | 28 +++++++++++++++++---
packages/rules/src/schema/character.ts | 16 +++++++++--
packages/rules/tests/narrative.test.ts | 6 +++++
6 files changed, 85 insertions(+), 30 deletions(-)
diff --git a/apps/web/src/builder/steps/identity.tsx b/apps/web/src/builder/steps/identity.tsx
index 4275a75..38c341a 100644
--- a/apps/web/src/builder/steps/identity.tsx
+++ b/apps/web/src/builder/steps/identity.tsx
@@ -23,7 +23,13 @@ export function IdentityStep(props: { store: BuilderStore }) {