diff --git a/apps/web/src/components/sheet-view.tsx b/apps/web/src/components/sheet-view.tsx index ef0997f..64117ab 100644 --- a/apps/web/src/components/sheet-view.tsx +++ b/apps/web/src/components/sheet-view.tsx @@ -1,5 +1,12 @@ -import type { Appearance, CharacterSheet, SheetSave, StatValue } from "@riftforge/rules"; -import { For, Show, type JSX } from "solid-js"; +import type { + Appearance, + CharacterSheet, + ResolvedSkill, + SheetSave, + Spell, + StatValue, +} from "@riftforge/rules"; +import { createEffect, createSignal, For, on, onCleanup, Show, type JSX } from "solid-js"; import { Chip, DataValue, MonoLabel, Panel, SectionTitle } from "./ui.tsx"; /** "rolled 17" once vitals are pinned, otherwise the possible range. */ @@ -29,6 +36,15 @@ const APPEARANCE_ROWS = [ ["disposition", "DISPOSITION"], ] as const satisfies readonly (readonly [keyof Appearance, string])[]; +/** Roll handlers the dossier page wires in; absent (e.g. in the wizard's + * review preview) the sheet renders read-only. */ +export interface SheetActions { + rollSave: (name: string, save: SheetSave) => void; + rollSkill: (skill: ResolvedSkill) => void; + castSpell: (spell: Spell) => void; + rollCombat: (kind: "strike" | "parry" | "dodge", bonus: number) => void; +} + /** Schematic silhouette — the portrait frame ships before upload does. */ function PortraitFrame() { return ( @@ -61,15 +77,94 @@ function PortraitFrame() { ); } -function StatRow(props: { label: JSX.Element; value: JSX.Element; live?: boolean }) { +/** A stat line; clickable (and amber on hover) when the page wires a roll. */ +function StatRow(props: { + label: JSX.Element; + value: JSX.Element; + live?: boolean; + onRoll?: () => void; +}) { + const value = () => ( + + {props.value} + + ); return ( -
- {props.label} - + {props.label} + {value()} +
+ } + > + + + ); +} + +const barFill = { + blood: "bg-gradient-to-r from-[#7a2018] to-blood shadow-[0_0_10px_rgb(226_59_46/0.4)]", + amber: "bg-gradient-to-r from-[#7a5a17] to-amber shadow-[0_0_10px_rgb(255_174_61/0.35)]", + ley: "bg-gradient-to-r from-[#14606e] to-ley ppe-pulse", +} as const; + +/** + * A vital as a resource bar: fill = rolled value against the range maximum, + * empty until rolled. The value flashes like a dot-matrix strike when a new + * roll lands (fine-grained update — the sheet is NOT remounted). + */ +function VitalBar(props: { label: string; stat: StatValue; tone: keyof typeof barFill }) { + const [flash, setFlash] = createSignal(false); + let timer: ReturnType | undefined; + createEffect( + on( + () => props.stat.rolled, + (rolled, previous) => { + if (rolled === undefined || rolled === previous) return; + setFlash(false); + requestAnimationFrame(() => setFlash(true)); + clearTimeout(timer); + timer = setTimeout(() => setFlash(false), 700); + }, + { defer: true }, + ), + ); + onCleanup(() => clearTimeout(timer)); + + const pct = () => + props.stat.rolled === undefined || props.stat.max <= 0 + ? 0 + : Math.min(100, Math.round((props.stat.rolled / props.stat.max) * 100)); + + return ( +
+
+ {props.label} + + {statValue(props.stat)} + +
+
+
+
); } @@ -77,11 +172,12 @@ function StatRow(props: { label: JSX.Element; value: JSX.Element; live?: boolean /** * Every `deriveSheet` section in Ley Terminal chrome (DESIGN.md). Shared by * the live sheet page and the builder's review preview so they never drift. + * With `actions`, saves/skills/spells/combat rows roll on click. */ -export function SheetView(props: { sheet: CharacterSheet; vitalsExtra?: JSX.Element }) { +export function SheetView(props: { sheet: CharacterSheet; actions?: SheetActions }) { const s = () => props.sheet; return ( -
+
@@ -155,26 +251,43 @@ export function SheetView(props: { sheet: CharacterSheet; vitalsExtra?: JSX.Elem VITALS
- - - + + - {(ppe) => } + {(ppe) => } + {(strength) => }
- {props.vitalsExtra}
COMBAT
- - - + props.actions!.rollCombat("strike", s().combat.strike)) + } + /> + props.actions!.rollCombat("parry", s().combat.parry)) + } + /> + props.actions!.rollCombat("dodge", s().combat.dodge)) + } + />
@@ -182,11 +295,26 @@ export function SheetView(props: { sheet: CharacterSheet; vitalsExtra?: JSX.Elem
- SAVING THROWS +
+ SAVING THROWS + + CLICK TO ROLL + +
{([name, save]) => ( - + props.actions!.rollSave(name, save) + : undefined + } + /> )}
@@ -194,7 +322,12 @@ export function SheetView(props: { sheet: CharacterSheet; vitalsExtra?: JSX.Elem
- SKILLS — FIELD RATED +
+ SKILLS — FIELD RATED + + CLICK TO ROLL + +
{(skill) => ( @@ -213,6 +346,7 @@ export function SheetView(props: { sheet: CharacterSheet; vitalsExtra?: JSX.Elem / {skill.value2}% } + onRoll={props.actions && (() => props.actions!.rollSkill(skill))} /> )} @@ -220,7 +354,12 @@ export function SheetView(props: { sheet: CharacterSheet; vitalsExtra?: JSX.Elem - SPELL KNOWLEDGE ({s().spells.count}) +
+ SPELL KNOWLEDGE ({s().spells.count}) + + CLICK TO CAST + +
{(spell) => ( @@ -235,6 +374,7 @@ export function SheetView(props: { sheet: CharacterSheet; vitalsExtra?: JSX.Elem {spell.ppe} PPE } + onRoll={props.actions && (() => props.actions!.castSpell(spell))} /> )} diff --git a/apps/web/src/components/telemetry-rail.tsx b/apps/web/src/components/telemetry-rail.tsx new file mode 100644 index 0000000..22f8a6e --- /dev/null +++ b/apps/web/src/components/telemetry-rail.tsx @@ -0,0 +1,48 @@ +import { createEffect, For, on, type JSX } from "solid-js"; +import type { TelemetryEntry, TelemetryTone } from "../lib/telemetry.ts"; + +const toneClass: Record = { + machine: "text-amber", + magic: "text-ley", + good: "text-ok", + bad: "text-blood-text", + dim: "text-dead", +}; + +/** + * The right-hand field-telemetry band (DESIGN.md): rolls print here like + * incoming field data, with quick actions below. + */ +export function TelemetryRail(props: { entries: TelemetryEntry[]; actions?: JSX.Element }) { + let logEl: HTMLDivElement | undefined; + + // Follow the feed: newest entry scrolls into view as it prints. + createEffect( + on( + () => props.entries.length, + () => logEl?.scrollTo({ top: logEl.scrollHeight }), + { defer: true }, + ), + ); + + return ( + + ); +} diff --git a/apps/web/src/lib/telemetry.ts b/apps/web/src/lib/telemetry.ts new file mode 100644 index 0000000..c68e0a8 --- /dev/null +++ b/apps/web/src/lib/telemetry.ts @@ -0,0 +1,53 @@ +import { createSignal, type Accessor } from "solid-js"; + +export type TelemetryTone = "machine" | "magic" | "good" | "bad" | "dim"; + +export interface TelemetryEntry { + id: number; + text: string; + tone: TelemetryTone; +} + +const MAX_ENTRIES = 60; + +/** + * The dossier's field-telemetry log: every roll prints here, newest last. + * Ephemeral by design — gameplay rolls are moments at the table, not records + * (only `rollVitals` persists, via its mutation). + */ +export function createTelemetry(boot: string[] = []): { + entries: Accessor; + log: (text: string, tone?: TelemetryTone) => void; + /** Back to the boot lines — e.g. when the dossier switches characters. */ + reset: () => void; +} { + let nextId = 0; + const bootEntries = () => boot.map((text) => ({ id: nextId++, text, tone: "dim" as const })); + const [entries, setEntries] = createSignal(bootEntries()); + const log = (text: string, tone: TelemetryTone = "machine") => + setEntries((current) => [...current, { id: nextId++, text, tone }].slice(-MAX_ENTRIES)); + const reset = () => setEntries(bootEntries()); + return { entries, log, reset }; +} + +/** "horrorFactor" -> "HORROR FACTOR" for the machine voice. */ +export function machineName(key: string): string { + return key.replace(/([A-Z])/g, " $1").toUpperCase(); +} + +/** `d20[14]+3 = 17 vs 15+ ✓` — the standard d20 telemetry fragment. */ +export function d20Line(roll: { + die: number; + bonus: number; + total: number; + target?: number; + success?: boolean; + naturalTwenty: boolean; + naturalOne: boolean; +}): string { + const bonus = roll.bonus >= 0 ? `+${roll.bonus}` : `${roll.bonus}`; + const verdict = + roll.target !== undefined ? ` vs ${roll.target}+ ${roll.success ? "✓" : "✗"}` : ""; + const nat = roll.naturalTwenty ? " — NAT 20" : roll.naturalOne ? " — NAT 1" : ""; + return `d20[${roll.die}]${bonus} = ${roll.total}${verdict}${nat}`; +} diff --git a/apps/web/src/pages/character-sheet.tsx b/apps/web/src/pages/character-sheet.tsx index 3d3ab57..1830bcf 100644 --- a/apps/web/src/pages/character-sheet.tsx +++ b/apps/web/src/pages/character-sheet.tsx @@ -1,15 +1,23 @@ import { api } from "@riftforge/backend/api"; import type { Id } from "@riftforge/backend/dataModel"; -import type { CharacterSheet, Narrative } from "@riftforge/rules"; +import { + rollD20, + rollSave, + rollSkillCheck, + type CharacterSheet, + type Narrative, +} from "@riftforge/rules"; import { useParams } from "@solidjs/router"; import { createEffect, createSignal, Match, on, Show, Switch, type Accessor } from "solid-js"; import { createStore, reconcile } from "solid-js/store"; import { NarrativeFields } from "../components/narrative-fields.tsx"; -import { SheetView } from "../components/sheet-view.tsx"; +import { SheetView, type SheetActions } from "../components/sheet-view.tsx"; +import { TelemetryRail } from "../components/telemetry-rail.tsx"; import { Alert, Button, MonoLabel, Panel } from "../components/ui.tsx"; import { convex } from "../lib/client.ts"; import { createMutation, createQuery } from "../lib/convex.ts"; import { fromNarrative, toNarrative } from "../lib/narrative.ts"; +import { createTelemetry, d20Line, machineName } from "../lib/telemetry.ts"; /** Edit the player-authored file fields in place; saves via updateNarrative. */ function NarrativeEditor(props: { id: Id<"characters">; narrative: Narrative | undefined }) { @@ -81,7 +89,12 @@ function NarrativeEditor(props: { id: Id<"characters">; narrative: Narrative | u ); } -/** The live sheet: `SheetView` fed by the `characters.sheet` subscription. */ +/** + * The dossier: the live sheet plus the field-telemetry rail. Gameplay rolls + * (saves, skills, strikes, casts) run CLIENT-SIDE through the isomorphic + * engine and print to the log — moments at the table, not records. Only + * `rollVitals` persists, via its mutation. + */ export function CharacterSheetPage() { const params = useParams<{ id: string }>(); const id = () => params.id as Id<"characters">; @@ -90,19 +103,66 @@ export function CharacterSheetPage() { // so re-pin the rules-layer type here. const sheet = query.data as Accessor; const rollVitals = createMutation(convex, api.characters.rollVitals); + const telemetry = createTelemetry(["// ley-link established", "// awaiting command…"]); const [rollError, setRollError] = createSignal(); + // A new dossier starts with a fresh log: rolls belong to the character + // they were rolled for, not whoever the page shows next. + createEffect( + on( + id, + () => { + telemetry.reset(); + setRollError(undefined); + }, + { defer: true }, + ), + ); + + const actions: SheetActions = { + rollSave: (name, save) => { + const roll = rollSave(save); + telemetry.log( + `> SAVE VS ${machineName(name)} :: ${d20Line(roll)}`, + roll.success === undefined ? "machine" : roll.success ? "good" : "bad", + ); + }, + rollSkill: (skill) => { + const check = rollSkillCheck(skill.value); + const label = skill.label ? ` (${skill.label.toUpperCase()})` : ""; + telemetry.log( + `> SKILL :: ${skill.name.toUpperCase()}${label} d%[${check.roll}] vs ${check.value}% ${check.success ? "✓" : "✗"}`, + check.success ? "good" : "bad", + ); + }, + castSpell: (spell) => { + telemetry.log(`> CAST :: ${spell.name.toUpperCase()} — ${spell.ppe} P.P.E.`, "magic"); + }, + rollCombat: (kind, bonus) => { + telemetry.log(`> ${kind.toUpperCase()} :: ${d20Line(rollD20(bonus))}`); + }, + }; + const roll = async () => { + // If the dossier switches while the mutation is in flight, the result + // belongs to the character it was rolled for — drop it silently. + const rolledFor = id(); setRollError(undefined); try { - await rollVitals({ id: id() }); + const rolled = await rollVitals({ id: rolledFor }); + if (id() !== rolledFor) return; + telemetry.log( + `> ROLL VITALS :: H.P. ${rolled.hitPoints} · S.D.C. ${rolled.sdc}${rolled.ppe !== undefined ? ` · P.P.E. ${rolled.ppe}` : ""} — LOCKED`, + ); } catch (error) { + if (id() !== rolledFor) return; setRollError(error instanceof Error ? error : new Error(String(error))); + telemetry.log("> ROLL VITALS :: WRITE FAILED", "bad"); } }; return ( -
+
// loading dossier…

}> {(err) => COULDN'T LOAD DOSSIER — {err().message}} @@ -110,13 +170,20 @@ export function CharacterSheetPage() { NO FILE ON RECORD. - - {(s) => ( - - @@ -125,16 +192,9 @@ 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/apps/web/src/styles.css b/apps/web/src/styles.css index 1b10c2f..67a0592 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -122,6 +122,36 @@ body::after { animation: ppe-pulse 2.5s ease-in-out infinite; } +/* Dot-matrix strike: a live stat just got struck onto the page. */ +@keyframes strike-flash { + 0% { + background: rgb(255 174 61 / 0.45); + box-shadow: 0 0 14px rgb(255 174 61 / 0.5); + } + 100% { + background: transparent; + box-shadow: none; + } +} +.strike-flash { + animation: strike-flash 0.6s ease-out; +} + +/* Scanline reveal: the dossier renders top-to-bottom on load. */ +@keyframes sheet-reveal { + from { + clip-path: inset(0 0 100% 0); + opacity: 0.4; + } + to { + clip-path: inset(0 0 0% 0); + opacity: 1; + } +} +.sheet-reveal { + animation: sheet-reveal 0.45s ease-out; +} + /* Telemetry cursor blink */ @keyframes cursor-blink { 0%, @@ -142,7 +172,9 @@ body::after { animation: none; box-shadow: 0 0 12px rgb(79 216 255 / 0.5); } - .cursor-blink { + .cursor-blink, + .strike-flash, + .sheet-reveal { animation: none; } }