diff --git a/apps/web/src/components/sheet-view.tsx b/apps/web/src/components/sheet-view.tsx index 80515dd..928a527 100644 --- a/apps/web/src/components/sheet-view.tsx +++ b/apps/web/src/components/sheet-view.tsx @@ -1,13 +1,21 @@ -import type { - Appearance, - CharacterSheet, - ResolvedSkill, - SheetSave, - Spell, - StatValue, +import { + armorMaxPool, + diceAverage, + diceMax, + diceMin, + itemsByKind, + type Appearance, + type CharacterSheet, + type ResolvedSkill, + type SheetArmor, + type SheetEquipmentEntry, + type SheetSave, + type Spell, + type StatValue, + type Weapon, } from "@riftforge/rules"; import { createEffect, createSignal, For, on, onCleanup, Show, type JSX } from "solid-js"; -import { Chip, DataValue, MonoLabel, Panel, SectionTitle } from "./ui.tsx"; +import { Button, Chip, DataValue, MonoLabel, Panel, SectionTitle } from "./ui.tsx"; function statRange(stat: StatValue): string { return `${stat.min}–${stat.max} (avg ${stat.average})`; @@ -47,6 +55,14 @@ export interface SheetActions { rollSkill: (skill: ResolvedSkill) => void; castSpell: (spell: Spell) => void; rollCombat: (kind: "strike" | "parry" | "dodge", bonus: number) => void; + /** Weapon damage rolls are client-side telemetry, like skills and saves. */ + rollWeapon: (weapon: Weapon) => void; + /** Inventory writes persist via mutations. `index` points into + * `sheet.equipment`; the entry rides along so the server can verify the + * instance didn't shift under an in-flight click. */ + acquireItem: (itemId: string) => void; + discardItem: (index: number, entry: SheetEquipmentEntry) => void; + equipArmor: (index: number | null, entry?: SheetEquipmentEntry) => void; } /** Schematic silhouette — the portrait frame ships before upload does. */ @@ -130,9 +146,28 @@ function StatRow(props: { 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)]", + // Worn metal: the armor layer speaks rust, amber-family — it is NOT magic. + rust: "bg-gradient-to-r from-[#5c421c] to-rust shadow-[0_0_10px_rgb(168_121_50/0.35)]", ley: "bg-gradient-to-r from-[#14606e] to-ley ppe-pulse", } as const; +/** + * The worn armor's pool as a `StatValue`: the printed dice (or constant) give + * the range, the per-suit roll (or constant) is the maximum, and the live + * remainder rides `current` — so the armor bar behaves exactly like a vital. + */ +function armorStat(armor: SheetArmor): StatValue { + const formula = armor.item.mdc?.mainBody; + const fixed = armor.item.sdc ?? 0; + return { + min: formula ? diceMin(formula) : fixed, + max: formula ? diceMax(formula) : fixed, + average: formula ? diceAverage(formula) : fixed, + rolled: armor.max, + current: armor.current, + }; +} + /** * 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 @@ -188,6 +223,128 @@ function VitalBar(props: { label: string; stat: StatValue; tone: keyof typeof ba ); } +const KIND_LABEL = { weapon: "WPN", armor: "ARM", gear: "GEAR" } as const; + +/** The manifest value column: a weapon's dice, an armor's pool, a gear dash. */ +function equipmentValue(entry: SheetEquipmentEntry, armor: SheetArmor | undefined): string { + const item = entry.item; + if (item.kind === "weapon") { + return `${item.damage.formula} ${item.damage.type === "md" ? "M.D." : "S.D.C."}`; + } + if (item.kind === "armor") { + const unit = item.mdc ? "M.D.C." : "S.D.C."; + const max = armorMaxPool(item, entry.rolledMdc); + if (max === undefined) return `UNRATED ${unit}`; + // The worn suit shows its live remainder; stowed suits just their rating. + if (entry.worn === true && armor?.current !== undefined) { + return `${armor.current} / ${max} ${unit}`; + } + return `${max} ${unit}`; + } + return "—"; +} + +/** One manifest row: name + kind tag, the value column (weapons roll on + * click), and the equip/discard controls when the page wires actions. */ +function EquipmentRow(props: { + entry: SheetEquipmentEntry; + index: number; + armor: SheetArmor | undefined; + actions?: SheetActions; +}) { + const item = () => props.entry.item; + const value = () => equipmentValue(props.entry, props.armor); + return ( +
+ + {item().name} + + {KIND_LABEL[item().kind]} + + + + Worn + + + + + {value()}} + > + + + + + + + + + +
+ ); +} + +/** Catalog picker + acquire button, grouped by kind (armory requisition). */ +function AcquireControl(props: { onAcquire: (itemId: string) => void }) { + const [selected, setSelected] = createSignal(""); + return ( +
+ + +
+ ); +} + /** * 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. @@ -272,6 +429,15 @@ export function SheetView(props: { sheet: CharacterSheet; actions?: SheetActions
+ + {(armor) => ( + + )} + {(ppe) => } @@ -422,6 +588,37 @@ export function SheetView(props: { sheet: CharacterSheet; actions?: SheetActions
+ +
+ EQUIPMENT — MANIFEST ({s().equipment.length}) + + WEAPONS: CLICK DICE TO ROLL + +
+
+ + // NOTHING ON MANIFEST +

+ } + > + {(entry, index) => ( + + )} +
+
+ + {(actions) => actions().acquireItem(itemId)} />} + +
+ {(backstory) => ( diff --git a/apps/web/src/pages/character-sheet.tsx b/apps/web/src/pages/character-sheet.tsx index bc440ca..16edc3a 100644 --- a/apps/web/src/pages/character-sheet.tsx +++ b/apps/web/src/pages/character-sheet.tsx @@ -1,12 +1,16 @@ import { api } from "@riftforge/backend/api"; import type { Id } from "@riftforge/backend/dataModel"; import { + getItem, rollD20, + rollDice, rollSave, rollSkillCheck, type CharacterSheet, type Narrative, + type SheetEquipmentEntry, type Spell, + type Weapon, } from "@riftforge/rules"; import { useParams } from "@solidjs/router"; import { @@ -145,9 +149,15 @@ export function CharacterSheetPage() { const restMutation = createMutation(convex, api.characters.rest); const leyLineDrawMutation = createMutation(convex, api.characters.leyLineDraw); const treatMutation = createMutation(convex, api.characters.treat); + const addItemMutation = createMutation(convex, api.characters.addItem); + const removeItemMutation = createMutation(convex, api.characters.removeItem); + const equipArmorMutation = createMutation(convex, api.characters.equipArmor); const telemetry = createTelemetry(["// ley-link established", "// awaiting command…"]); const [rollError, setRollError] = createSignal(); const [damageInput, setDamageInput] = createSignal(""); + // Where the hit lands: the body pools, or the worn armor's own layer. + // WHICH hits strike armor (strike-vs-A.R.) is GM adjudication for now. + const [toArmor, setToArmor] = createSignal(false); const [restHours, setRestHours] = createSignal(""); const [atNexus, setAtNexus] = createSignal(false); const [professional, setProfessional] = createSignal(false); @@ -173,6 +183,7 @@ export function CharacterSheetPage() { telemetry.reset(); setRollError(undefined); setDamageInput(""); + setToArmor(false); setRestHours(""); setAtNexus(false); setProfessional(false); @@ -326,17 +337,86 @@ export function CharacterSheetPage() { return; } const damagedFor = id(); + const strikesArmor = toArmor(); try { - const next = await applyDamageMutation({ id: damagedFor, amount }); + const next = await applyDamageMutation({ + id: damagedFor, + amount, + ...(strikesArmor ? { toArmor: true } : {}), + }); if (id() !== damagedFor) return; setDamageInput(""); - telemetry.log(`> DAMAGE :: ${amount} — S.D.C. ${next.sdc} · H.P. ${next.hitPoints}`, "bad"); + telemetry.log( + "armor" in next + ? `> DAMAGE (ARMOR) :: ${amount} — ARMOR ${next.armor}` + : `> 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"); } }; + // Inventory writes persist; like every persisting action, a result that + // arrives after the dossier switched characters is dropped. + const acquire = async (itemId: string) => { + const forId = id(); + const name = (getItem(itemId)?.name ?? itemId).toUpperCase(); + try { + const result = await addItemMutation({ id: forId, itemId }); + if (id() !== forId) return; + // Dice-capacity suits (LLW concealed) are rated at acquisition. + telemetry.log( + `> ACQUIRE :: ${name}${result.rolledMdc !== undefined ? ` — SUIT RATED ${result.rolledMdc} M.D.C.` : ""}`, + "good", + ); + } catch (error) { + if (id() !== forId) return; + telemetry.log(`> ACQUIRE :: ${name} — REFUSED (${reason(error)})`, "bad"); + } + }; + + // The instance state the click targeted — the server refuses the write if + // the manifest shifted under an in-flight request (index races). + const expectOf = (entry: SheetEquipmentEntry) => ({ + itemId: entry.item.id, + ...(entry.worn === true ? { worn: true } : {}), + ...(entry.rolledMdc !== undefined ? { rolledMdc: entry.rolledMdc } : {}), + }); + + const discard = async (index: number, entry: SheetEquipmentEntry) => { + const forId = id(); + const name = entry.item.name.toUpperCase(); + try { + await removeItemMutation({ id: forId, index, expect: expectOf(entry) }); + if (id() !== forId) return; + telemetry.log(`> DISCARD :: ${name}`); + } catch (error) { + if (id() !== forId) return; + telemetry.log(`> DISCARD :: ${name} — REFUSED (${reason(error)})`, "bad"); + } + }; + + const equip = async (index: number | null, entry?: SheetEquipmentEntry) => { + const forId = id(); + const name = entry?.item.name.toUpperCase(); + try { + // Wear and doff both name the instance the click saw: doffing verifies + // the WORN suit, so a racing swap can't be unequipped blind. + await equipArmorMutation({ + id: forId, + index, + ...(entry !== undefined ? { expect: expectOf(entry) } : {}), + }); + if (id() !== forId) return; + telemetry.log(index === null ? "> DOFF :: ARMOR OFFLINE" : `> EQUIP :: ${name} — WORN`); + } catch (error) { + if (id() !== forId) return; + telemetry.log(`> EQUIP :: ${name ?? "—"} — REFUSED (${reason(error)})`, "bad"); + } + }; + const restore = async () => { const restoredFor = id(); try { @@ -371,6 +451,24 @@ export function CharacterSheetPage() { rollCombat: (kind, bonus) => { telemetry.log(`> ${kind.toUpperCase()} :: ${d20Line(rollD20(bonus))}`); }, + // Weapon damage is table telemetry, not a persisted write — the rolled + // points go through the Damage control on whoever they actually hit. + rollWeapon: (weapon: Weapon) => { + const total = rollDice(weapon.damage.formula); + const unit = weapon.damage.type === "md" ? "M.D." : "S.D.C."; + telemetry.log( + `> WEAPON :: ${weapon.name.toUpperCase()} — ${weapon.damage.formula} = ${total} ${unit}`, + ); + }, + acquireItem: (itemId) => { + void acquire(itemId); + }, + discardItem: (index, entry) => { + void discard(index, entry); + }, + equipArmor: (index, entry) => { + void equip(index, entry); + }, }; const roll = async () => { @@ -434,6 +532,9 @@ export function CharacterSheetPage() { + setToArmor((v) => !v)}> + Armor +