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
216 changes: 214 additions & 2 deletions apps/web/src/pages/character-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@ import {
type Spell,
} from "@riftforge/rules";
import { useParams } from "@solidjs/router";
import { createEffect, createSignal, Match, on, Show, Switch, type Accessor } from "solid-js";
import {
createEffect,
createSignal,
Match,
on,
Show,
Switch,
type Accessor,
type JSX,
} 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";
Expand Down Expand Up @@ -90,6 +99,32 @@ function NarrativeEditor(props: { id: Id<"characters">; narrative: Narrative | u
);
}

/** A pressed/unpressed mode chip (nexus, professional care). Cyan tone is
* for arcane context only, per the color rules. */
function ToggleChip(props: {
pressed: boolean;
onToggle: () => void;
tone?: "ley" | "amber";
children: JSX.Element;
}) {
return (
<button
type="button"
aria-pressed={props.pressed}
class={`notch-6 cursor-pointer border bg-inset px-2 font-hud text-[11.5px] font-semibold tracking-[0.08em] uppercase ${
props.pressed
? props.tone === "ley"
? "border-ley/60 text-ley [text-shadow:0_0_8px_rgb(79_216_255/0.5)]"
: "border-amber/60 text-amber"
: "border-line text-dead hover:border-muted"
}`}
onClick={() => props.onToggle()}
>
{props.children}
</button>
);
}

/**
* The dossier: the live sheet plus the field-telemetry rail. Gameplay rolls
* (saves, skills, strikes, casts) run CLIENT-SIDE through the isomorphic
Expand All @@ -107,9 +142,27 @@ export function CharacterSheetPage() {
const castSpellMutation = createMutation(convex, api.characters.castSpell);
const applyDamageMutation = createMutation(convex, api.characters.applyDamage);
const restoreVitals = createMutation(convex, api.characters.restoreVitals);
const restMutation = createMutation(convex, api.characters.rest);
const leyLineDrawMutation = createMutation(convex, api.characters.leyLineDraw);
const treatMutation = createMutation(convex, api.characters.treat);
const telemetry = createTelemetry(["// ley-link established", "// awaiting command…"]);
const [rollError, setRollError] = createSignal<Error>();
const [damageInput, setDamageInput] = createSignal("");
const [restHours, setRestHours] = createSignal("");
const [atNexus, setAtNexus] = createSignal(false);
const [professional, setProfessional] = createSignal(false);
// The treatment-course position lives on the DOCUMENT (`current.
// treatmentDays`) — it survives reloads and clears with a full restore or
// vitals reroll. `dayInput` is the GM override: "" follows the stored
// course (next day = stored + 1); a typed value declares which course day
// this is (e.g. a new injury course). `treating` serializes clicks: two
// in-flight calls would apply the same day twice.
const [dayInput, setDayInput] = createSignal("");
const [treating, setTreating] = createSignal(false);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// Monotonic token naming the request that holds the `treating` gate. Newer
// requests and route changes bump it, so a stale settle can never release a
// gate it no longer owns — not even back on the same dossier.
let treatToken = 0;

// 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 @@ -120,6 +173,12 @@ export function CharacterSheetPage() {
telemetry.reset();
setRollError(undefined);
setDamageInput("");
setRestHours("");
setAtNexus(false);
setProfessional(false);
setDayInput("");
treatToken++;
setTreating(false);
},
{ defer: true },
),
Expand All @@ -142,8 +201,17 @@ export function CharacterSheetPage() {
try {
const result = await castSpellMutation({ id: castFor, spellId: spell.id });
if (id() !== castFor) return;
// Healing spells report what actually landed (post-clamp), per pool.
const healed = result.healed
? ` → ${[
result.healed.hitPoints !== undefined ? `H.P. +${result.healed.hitPoints}` : undefined,
result.healed.sdc !== undefined ? `S.D.C. +${result.healed.sdc}` : undefined,
]
.filter(Boolean)
.join(" · ")}`
: "";
telemetry.log(
`> CAST :: ${spell.name.toUpperCase()} — ${result.spent} P.P.E. [${result.ppe.current}/${result.ppe.max}]`,
`> CAST :: ${spell.name.toUpperCase()} — ${result.spent} P.P.E. [${result.ppe.current}/${result.ppe.max}]${healed}`,
"magic",
);
} catch (error) {
Expand All @@ -152,6 +220,86 @@ export function CharacterSheetPage() {
}
};

// Recovery names TIME (hours, melees, days) — the server owns the rates and
// clamps at the rolled maximums; the log reports what actually landed.
const rest = async (mode: "rest" | "meditation") => {
const verb = mode === "meditation" ? "MEDITATE" : "REST";
// Strict parse, like damage: "3.5" and "3abc" are refused, not truncated.
const raw = restHours().trim();
const hours = Number(raw);
if (raw === "" || !Number.isInteger(hours) || hours <= 0) {
if (raw !== "") {
telemetry.log(`> ${verb} :: REFUSED (not a whole number of hours: "${raw}")`, "bad");
}
return;
}
const restedFor = id();
try {
const result = await restMutation({ id: restedFor, hours, mode });
if (id() !== restedFor) return;
setRestHours("");
telemetry.log(
`> ${verb} :: ${hours} HR — P.P.E. +${result.gained} [${result.ppe.current}/${result.ppe.max}]`,
"magic",
);
} catch (error) {
if (id() !== restedFor) return;
telemetry.log(`> ${verb} :: REFUSED (${reason(error)})`, "bad");
}
};

const leyDraw = async () => {
const drewFor = id();
const nexus = atNexus();
try {
const result = await leyLineDrawMutation({ id: drewFor, melees: 1, atNexus: nexus });
if (id() !== drewFor) return;
telemetry.log(
`> LEY DRAW${nexus ? " (NEXUS)" : ""} :: +${result.gained} P.P.E. [${result.ppe.current}/${result.ppe.max}]`,
"magic",
);
} catch (error) {
if (id() !== drewFor) return;
telemetry.log(`> LEY DRAW :: REFUSED (${reason(error)})`, "bad");
}
};

const treatDay = async () => {
if (treating()) return;
// Strict parse, like damage/hours: only a whole positive day number counts
// as a GM override; an untouched input follows the stored course.
const raw = dayInput().trim();
const override = raw === "" ? undefined : Number(raw);
if (override !== undefined && (!Number.isInteger(override) || override <= 0)) {
telemetry.log(`> TREATMENT :: REFUSED (not a whole day number: "${raw}")`, "bad");
return;
}
setTreating(true);
const token = ++treatToken;
const treatedFor = id();
const pro = professional();
try {
const result = await treatMutation({
id: treatedFor,
professional: pro,
...(override !== undefined ? { day: override } : {}),
});
if (id() !== treatedFor) return;
setDayInput(""); // back to following the stored course
telemetry.log(
`> TREATMENT :: DAY ${result.day}${pro ? " (PRO)" : ""} — H.P. +${result.gained.hitPoints} · S.D.C. +${result.gained.sdc}`,
"good",
);
} catch (error) {
if (id() !== treatedFor) return;
telemetry.log(`> TREATMENT :: REFUSED (${reason(error)})`, "bad");
} finally {
// Only the gate's current owner may release it: a stale settle must not
// unlock a treat a newer request has in flight.
if (token === treatToken) setTreating(false);
}
};

const damage = async () => {
// Strict parse: `Number` (unlike `parseInt`) makes "3.5" and "3abc"
// fail the integer check instead of silently truncating to 3.
Expand Down Expand Up @@ -274,6 +422,70 @@ export function CharacterSheetPage() {
<Button class="w-full text-left" onClick={() => void restore()}>
{"> Full Restore"}
</Button>
<div class="space-y-2 border-t border-line pt-2">
<MonoLabel class="block text-dead">RECOVERY</MonoLabel>
<Show when={sheet()?.ppe}>
<div class="flex gap-2">
<TextInput
aria-label="Hours of rest"
inputmode="numeric"
placeholder="HRS"
class="w-14 min-w-0 px-2 py-1.5 text-center"
value={restHours()}
onInput={(e) => setRestHours(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void rest("rest");
}}
/>
<Button class="flex-1 px-2 text-left" onClick={() => void rest("rest")}>
{"> Rest"}
</Button>
<Button
class="shrink-0 px-2 text-left whitespace-nowrap"
onClick={() => void rest("meditation")}
>
{"> Meditate"}
</Button>
</div>
<div class="flex gap-2">
<Button class="flex-1 px-2 text-left" onClick={() => void leyDraw()}>
{"> Ley Draw"}
</Button>
<ToggleChip
pressed={atNexus()}
onToggle={() => setAtNexus((v) => !v)}
tone="ley"
>
Nexus
</ToggleChip>
</div>
</Show>
<div class="flex gap-2">
<TextInput
aria-label="Treatment day"
inputmode="numeric"
class="w-14 min-w-0 px-2 py-1.5 text-center"
value={
dayInput() !== ""
? dayInput()
: String((sheet()?.vitals.treatmentDays ?? 0) + 1)
}
onInput={(e) => setDayInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void treatDay();
}}
/>
<Button class="flex-1 px-2 text-left" onClick={() => void treatDay()}>
{"> Treatment Day"}
</Button>
<ToggleChip
pressed={professional()}
onToggle={() => setProfessional((v) => !v)}
>
Pro
</ToggleChip>
</div>
</div>
</div>
}
/>
Expand Down
Loading
Loading