Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 72 additions & 32 deletions apps/web/src/components/sheet-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ import type {
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. */
function statRange(stat: StatValue): string {
return `${stat.min}–${stat.max} (avg ${stat.average})`;
}

/** `current / max` once vitals are pinned (DESIGN.md), otherwise the possible range. */
function statValue(stat: StatValue): string {
const range = `${stat.min}–${stat.max} (avg ${stat.average})`;
return stat.rolled === undefined ? range : `${stat.rolled} (range ${range})`;
if (stat.rolled === undefined) return statRange(stat);
return `${stat.current ?? stat.rolled} / ${stat.rolled}`;
}

function saveTarget(save: SheetSave): string {
Expand Down Expand Up @@ -77,12 +81,18 @@ function PortraitFrame() {
);
}

/** A stat line; clickable (and amber on hover) when the page wires a roll. */
/** A stat line; clickable (and amber on hover) when the page wires a roll.
* `disabled` renders the row dead-steel and inert — e.g. a spell the
* character can't afford — via `aria-disabled` (not the native attribute),
* so it stays focusable and the `title` reason reaches keyboard and
* screen-reader users. */
function StatRow(props: {
label: JSX.Element;
value: JSX.Element;
live?: boolean;
onRoll?: () => void;
disabled?: boolean;
title?: string;
}) {
const value = () => (
<span
Expand All @@ -103,9 +113,12 @@ function StatRow(props: {
>
<button
type="button"
title="Roll"
class="flex w-full cursor-pointer items-baseline justify-between gap-3 border-b border-dotted border-line py-1 text-left text-[13.5px] last:border-b-0 hover:bg-amber/5 hover:text-amber"
onClick={() => props.onRoll?.()}
title={props.title ?? "Roll"}
aria-disabled={props.disabled || undefined}
class="flex w-full items-baseline justify-between gap-3 border-b border-dotted border-line py-1 text-left text-[13.5px] last:border-b-0 aria-disabled:cursor-not-allowed aria-disabled:text-dead [&:not([aria-disabled])]:cursor-pointer [&:not([aria-disabled])]:hover:bg-amber/5 [&:not([aria-disabled])]:hover:text-amber"
onClick={() => {
if (!props.disabled) props.onRoll?.();
}}
>
<span>{props.label}</span>
{value()}
Expand All @@ -121,18 +134,20 @@ const barFill = {
} 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).
* A vital as a resource bar: fill = what's LEFT of the rolled maximum
* (`current / rolled`), full right after a roll, empty until one lands. The
* value flashes like a dot-matrix strike whenever the live value changes —
* a roll, damage, a cast, a restore (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<typeof setTimeout> | undefined;
createEffect(
on(
() => props.stat.rolled,
(rolled, previous) => {
if (rolled === undefined || rolled === previous) return;
() => props.stat.current,
(current, previous) => {
if (current === undefined || current === previous) return;
setFlash(false);
requestAnimationFrame(() => setFlash(true));
clearTimeout(timer);
Expand All @@ -143,16 +158,20 @@ function VitalBar(props: { label: string; stat: StatValue; tone: keyof typeof ba
);
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));
// H.P. can sit in the negative coma band — the bar just reads empty.
const pct = () => {
const { rolled, current } = props.stat;
if (rolled === undefined || rolled <= 0) return 0;
const ratio = (current ?? rolled) / rolled;
return Math.min(100, Math.max(0, Math.round(ratio * 100)));
};

return (
<div class="py-1">
<div class="flex items-baseline justify-between font-mono text-[11px] text-muted">
<span>{props.label}</span>
<span
title={`rolled range ${statRange(props.stat)}`}
classList={{ "strike-flash": flash() }}
class={`font-data text-[12.5px] font-semibold ${props.tone === "ley" ? "text-ley [text-shadow:0_0_10px_rgb(79_216_255/0.6)]" : "text-fg"}`}
>
Expand Down Expand Up @@ -362,21 +381,42 @@ export function SheetView(props: { sheet: CharacterSheet; actions?: SheetActions
</div>
<div class="mt-2">
<For each={s().spells.known}>
{(spell) => (
<StatRow
label={
<>
{spell.name} <span class="text-muted">LVL {spell.level}</span>
</>
}
value={
<span class="text-ley [text-shadow:0_0_10px_rgb(79_216_255/0.5)]">
{spell.ppe} PPE
</span>
}
onRoll={props.actions && (() => props.actions!.castSpell(spell))}
/>
)}
{(spell) => {
// Casting spends live P.P.E. — a spell the character can't
// pay for (or can't measure, pre-roll) goes dead-steel.
const ppeLeft = () => s().ppe?.current;
const blocked = () =>
props.actions === undefined
? undefined
: ppeLeft() === undefined
? "Roll vitals to cast"
: spell.ppe > ppeLeft()!
? "Insufficient P.P.E."
: undefined;
return (
<StatRow
label={
<>
{spell.name} <span class="text-muted">LVL {spell.level}</span>
</>
}
value={
<span
class={
blocked()
? "text-dead"
: "text-ley [text-shadow:0_0_10px_rgb(79_216_255/0.5)]"
}
>
{spell.ppe} PPE
</span>
}
onRoll={props.actions && (() => props.actions!.castSpell(spell))}
disabled={blocked() !== undefined}
title={blocked() ?? "Cast"}
/>
);
}}
</For>
</div>
</Panel>
Expand Down
89 changes: 87 additions & 2 deletions apps/web/src/pages/character-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import {
rollSkillCheck,
type CharacterSheet,
type Narrative,
type Spell,
} 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, type SheetActions } from "../components/sheet-view.tsx";
import { TelemetryRail } from "../components/telemetry-rail.tsx";
import { Alert, Button, MonoLabel, Panel } from "../components/ui.tsx";
import { Alert, Button, MonoLabel, Panel, TextInput } from "../components/ui.tsx";
import { convex } from "../lib/client.ts";
import { createMutation, createQuery } from "../lib/convex.ts";
import { fromNarrative, toNarrative } from "../lib/narrative.ts";
Expand Down Expand Up @@ -103,8 +104,12 @@ export function CharacterSheetPage() {
// so re-pin the rules-layer type here.
const sheet = query.data as Accessor<CharacterSheet | null | undefined>;
const rollVitals = createMutation(convex, api.characters.rollVitals);
const castSpellMutation = createMutation(convex, api.characters.castSpell);
const applyDamageMutation = createMutation(convex, api.characters.applyDamage);
const restoreVitals = createMutation(convex, api.characters.restoreVitals);
const telemetry = createTelemetry(["// ley-link established", "// awaiting command…"]);
const [rollError, setRollError] = createSignal<Error>();
const [damageInput, setDamageInput] = createSignal("");

// A new dossier starts with a fresh log: rolls belong to the character
// they were rolled for, not whoever the page shows next.
Expand All @@ -114,11 +119,72 @@ export function CharacterSheetPage() {
() => {
telemetry.reset();
setRollError(undefined);
setDamageInput("");
},
{ defer: true },
),
);

/** Convex mutation errors carry an "Uncaught Error: …" preamble — strip to the message. */
const reason = (error: unknown): string => {
const text = error instanceof Error ? error.message : String(error);
return text
.replace(/^.*Uncaught Error:\s*/s, "")
.split("\n")[0]!
.trim();
};

// Casting SPENDS — the server derives the cost and refuses what the
// character can't afford. Like all persisting actions, a result that comes
// back after the dossier switched characters is dropped.
const cast = async (spell: Spell) => {
const castFor = id();
try {
const result = await castSpellMutation({ id: castFor, spellId: spell.id });
if (id() !== castFor) return;
telemetry.log(
`> CAST :: ${spell.name.toUpperCase()} — ${result.spent} P.P.E. [${result.ppe.current}/${result.ppe.max}]`,
"magic",
);
} catch (error) {
if (id() !== castFor) return;
telemetry.log(`> CAST :: ${spell.name.toUpperCase()} — REFUSED (${reason(error)})`, "bad");
}
};

const damage = async () => {
// Strict parse: `Number` (unlike `parseInt`) makes "3.5" and "3abc"
// fail the integer check instead of silently truncating to 3.
const raw = damageInput().trim();
const amount = Number(raw);
if (raw === "" || !Number.isInteger(amount) || amount <= 0) {
if (raw !== "") telemetry.log(`> DAMAGE :: REFUSED (not a whole number: "${raw}")`, "bad");
return;
}
const damagedFor = id();
try {
const next = await applyDamageMutation({ id: damagedFor, amount });
if (id() !== damagedFor) return;
setDamageInput("");
telemetry.log(`> DAMAGE :: ${amount} — S.D.C. ${next.sdc} · H.P. ${next.hitPoints}`, "bad");
} catch (error) {
if (id() !== damagedFor) return;
telemetry.log(`> DAMAGE :: REFUSED (${reason(error)})`, "bad");
}
};

const restore = async () => {
const restoredFor = id();
try {
await restoreVitals({ id: restoredFor });
if (id() !== restoredFor) return;
telemetry.log("> RESTORE :: ALL POOLS FULL", "good");
} catch (error) {
if (id() !== restoredFor) return;
telemetry.log(`> RESTORE :: REFUSED (${reason(error)})`, "bad");
}
};

const actions: SheetActions = {
rollSave: (name, save) => {
const roll = rollSave(save);
Expand All @@ -136,7 +202,7 @@ export function CharacterSheetPage() {
);
},
castSpell: (spell) => {
telemetry.log(`> CAST :: ${spell.name.toUpperCase()} — ${spell.ppe} P.P.E.`, "magic");
void cast(spell);
},
rollCombat: (kind, bonus) => {
telemetry.log(`> ${kind.toUpperCase()} :: ${d20Line(rollD20(bonus))}`);
Expand Down Expand Up @@ -189,6 +255,25 @@ export function CharacterSheetPage() {
<Show when={rollError()}>
{(err) => <Alert tone="danger">ROLL FAILED — {err().message}</Alert>}
</Show>
<div class="flex gap-2">
<TextInput
aria-label="Damage amount"
inputmode="numeric"
placeholder="DMG"
class="w-16 min-w-0 px-2 py-1.5 text-center"
value={damageInput()}
onInput={(e) => setDamageInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void damage();
}}
/>
<Button class="flex-1 px-2 text-left" onClick={() => void damage()}>
{"> Damage"}
</Button>
</div>
<Button class="w-full text-left" onClick={() => void restore()}>
{"> Full Restore"}
</Button>
</div>
}
/>
Expand Down
Loading
Loading