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()} }
+ >
+ props.actions!.rollWeapon(item() as Weapon)}
+ >
+ {value()}
+
+
+
+
+ props.actions!.equipArmor(props.entry.worn === true ? null : props.index, props.entry)
+ }
+ >
+ {props.entry.worn === true ? "[doff]" : "[wear]"}
+
+
+
+ props.actions!.discardItem(props.index, props.entry)}
+ >
+ ✕
+
+
+
+
+ );
+}
+
+/** Catalog picker + acquire button, grouped by kind (armory requisition). */
+function AcquireControl(props: { onAcquire: (itemId: string) => void }) {
+ const [selected, setSelected] = createSignal("");
+ return (
+
+ setSelected(e.currentTarget.value)}
+ >
+
+ — SELECT ITEM —
+
+
+ {(kind) => (
+
+
+ {(item) => {item.name} }
+
+
+ )}
+
+
+ {
+ if (selected() !== "") props.onAcquire(selected());
+ }}
+ >
+ {"> Acquire"}
+
+
+ );
+}
+
/**
* 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() {
void damage()}>
{"> Damage"}
+ setToArmor((v) => !v)}>
+ Armor
+
void restore()}>
{"> Full Restore"}
diff --git a/docs/rules/PAGE_MAP.md b/docs/rules/PAGE_MAP.md
index 603387f..38176c2 100644
--- a/docs/rules/PAGE_MAP.md
+++ b/docs/rules/PAGE_MAP.md
@@ -53,6 +53,23 @@ vision extraction (`scratchpad/extract_pages.py` renders a page to PNG for readi
| Ranged Combat (Modern WP, Dodging, Missiles) | 360–365 | 362–367 |
| Psychic Combat / Horror Factor / Perception Rolls | 366–367 | 368–369 |
+## Equipment (printed 256+)
+
+| Section | Printed pp. | PDF idx |
+| --------------------------------------------------------------- | ----------- | ------- |
+| Missile Stats & Prices | 256 | 258 |
+| Coalition Combat Weapons (CS ammo, Vibro-Blades, explosives) | 257–260 | 259–262 |
+| Dead Boy Body Armor / Dog Pack Light Riot Armor | 261 | 263 |
+| Common Gear (basic, communications, medical, optics, sensors) | 261–265 | 263–267 |
+| Common Vehicles | 266 | 268 |
+| Mega-Damage Capacity Body Armor (Gladiator, Crusader, Juicer) | 267 | 269 |
+| — More suits (Urban Warrior, Plastic-Man, Huntsman, Bushman) | 268 | 270 |
+| Weapons (E-Clips, Wilk's 320; 447/NG pistols 269; rifles 270) | 268–270 | 270–272 |
+| Power Armor / Robot Vehicles | 271–273 | 273–275 |
+| S.D.C. Armor & A.R. rules (under Step 2: Damage Ratings) | 287 | 289 |
+| Ancient W.P. weapon damage (axe 326, knife 327) | 326–327 | 328–329 |
+| Modern W.P. weapon damage (handguns 328, SMG/shotgun/rifle 329) | 328–329 | 330–331 |
+
## Magic (printed 185+) — needed for Ley Line Walker
| Section | Printed pp. | PDF idx |
diff --git a/packages/backend/convex/characters.ts b/packages/backend/convex/characters.ts
index 5f721c6..229d590 100644
--- a/packages/backend/convex/characters.ts
+++ b/packages/backend/convex/characters.ts
@@ -1,12 +1,17 @@
import {
applyDamage as damagePools,
+ armorMaxPool,
+ armorNeedsRoll,
characterSchema,
comaDeathFloor,
+ damageArmor,
deriveSheet,
+ getItem,
getOcc,
getSpell,
leyLineDraw as leyLineDrawAmount,
restRecovery,
+ rollArmorMdc,
rollHitPoints,
rollPhysicalSdc,
rollPpe,
@@ -303,16 +308,180 @@ export const castSpell = mutation({
},
});
+/** `current` without the armor pool (dropped when nothing else remains). */
+function withoutArmorPool(current: Character["current"]): Character["current"] {
+ if (current === undefined) return undefined;
+ const { armor: _armor, ...rest } = current;
+ return Object.keys(rest).length > 0 ? rest : undefined;
+}
+
+/** Client's snapshot of the item instance it targeted (index + this state). */
+const expectedItemValidator = v.object({
+ itemId: v.string(),
+ worn: v.optional(v.boolean()),
+ rolledMdc: v.optional(v.number()),
+});
+type ExpectedItem = { itemId: string; worn?: boolean; rolledMdc?: number };
+
/**
- * Deal damage to the live pools: S.D.C. absorbs first, the overflow comes off
- * Hit Points, which stop at the -(P.E.) coma/death floor (rules-layer
- * `applyDamage`, RUE pp.287/347).
+ * Resolve the item instance an index-based inventory mutation targets. The
+ * index alone can go stale — the manifest may have changed while the click
+ * was in flight — so the client also names the instance state it saw, and a
+ * mismatch is refused instead of landing on whatever now sits at that slot.
+ */
+function requireItemAt(
+ character: Character,
+ index: number,
+ expect: ExpectedItem,
+): Character["items"][number] {
+ const entry = Number.isInteger(index) ? character.items[index] : undefined;
+ if (entry === undefined) throw new Error(`No item at index ${index}.`);
+ if (
+ entry.itemId !== expect.itemId ||
+ (entry.worn === true) !== (expect.worn === true) ||
+ entry.rolledMdc !== expect.rolledMdc
+ ) {
+ throw new Error("The manifest changed while the request was in flight — try again.");
+ }
+ return entry;
+}
+
+/**
+ * Add an item to the inventory. Dice-capacity armor (the Ley Line Walker's
+ * concealed suit prints "2D6+32 M.D.C. main body", RUE p.113) rolls its
+ * per-suit maximum HERE — a mutation, not an action, for the same reason as
+ * `rollVitals`: mutations get seeded randomness, and rolling + persisting in
+ * one transaction means a suit's roll can never be observed and then lost.
+ */
+export const addItem = mutation({
+ args: { id: v.id("characters"), itemId: v.string() },
+ returns: v.object({ index: v.number(), rolledMdc: v.optional(v.number()) }),
+ handler: async (ctx, { id, itemId }) => {
+ const character = await loadCharacter(ctx, id);
+ const item = getItem(itemId);
+ if (!item) throw new Error(`Unknown item "${itemId}".`);
+ const entry =
+ item.kind === "armor" && armorNeedsRoll(item)
+ ? { itemId, rolledMdc: rollArmorMdc(item, Math.random) }
+ : { itemId };
+ const items = [...character.items, entry];
+ validateCharacter({ ...character, items });
+ await ctx.db.patch(id, { items });
+ return {
+ index: items.length - 1,
+ ...("rolledMdc" in entry ? { rolledMdc: entry.rolledMdc } : {}),
+ };
+ },
+});
+
+/**
+ * Drop the item at `index` (verified against the client's `expect` snapshot).
+ * Removing the worn armor also clears its live pool — `current.armor`
+ * measures the WORN suit, so it can't outlive it.
+ */
+export const removeItem = mutation({
+ args: { id: v.id("characters"), index: v.number(), expect: expectedItemValidator },
+ returns: v.null(),
+ handler: async (ctx, { id, index, expect }) => {
+ const character = await loadCharacter(ctx, id);
+ const entry = requireItemAt(character, index, expect);
+ const items = character.items.filter((_, i) => i !== index);
+ const current = entry.worn === true ? withoutArmorPool(character.current) : character.current;
+ validateCharacter({ ...character, items, current });
+ await ctx.db.patch(id, { items, current });
+ return null;
+ },
+});
+
+/**
+ * Wear the armor at `index` (exclusively — at most one worn suit; verified
+ * against the client's `expect` snapshot), or pass `null` to unequip.
+ * Changing what's worn resets the live pool: a freshly equipped suit starts
+ * at its maximum (per-suit damage memory across swaps is future scope — the
+ * pool follows the fresh-pools pattern of vitals). Asking for the state the
+ * character is already in is a no-op, so a repeated click can never
+ * "repair" the worn suit by resetting its pool.
+ */
+export const equipArmor = mutation({
+ args: {
+ id: v.id("characters"),
+ index: v.union(v.number(), v.null()),
+ expect: v.optional(expectedItemValidator),
+ },
+ returns: v.null(),
+ handler: async (ctx, { id, index, expect }) => {
+ const character = await loadCharacter(ctx, id);
+ const wornIndex = character.items.findIndex((e) => e.worn === true);
+ if (index !== null) {
+ if (expect === undefined) throw new Error("Equipping needs the expected item snapshot.");
+ const entry = requireItemAt(character, index, expect);
+ const item = getItem(entry.itemId);
+ if (item?.kind !== "armor") {
+ throw new Error(`Only armor can be worn — "${entry.itemId}" is not armor.`);
+ }
+ } else if (wornIndex !== -1) {
+ // Doff verifies too: unequip the suit the CLIENT saw worn, not whatever
+ // a racing write made worn since. (Nothing worn = already unequipped —
+ // the desired state, a no-op below.)
+ if (expect === undefined) throw new Error("Unequipping needs the expected item snapshot.");
+ requireItemAt(character, wornIndex, expect);
+ }
+ if (index === (wornIndex === -1 ? null : wornIndex)) return null; // already there
+ const items = character.items.map((e, i) => {
+ const { worn: _worn, ...rest } = e;
+ return i === index ? { ...rest, worn: true } : rest;
+ });
+ const current = withoutArmorPool(character.current);
+ validateCharacter({ ...character, items, current });
+ await ctx.db.patch(id, { items, current });
+ return null;
+ },
+});
+
+/**
+ * Deal damage to the live pools. Body hits (the default): S.D.C. absorbs
+ * first, the overflow comes off Hit Points, which stop at the -(P.E.)
+ * coma/death floor (rules-layer `applyDamage`, RUE pp.287/347).
+ *
+ * `toArmor` lands the hit on the WORN armor instead: the suit absorbs the
+ * whole attack ("subtract the damage from the armor's S.D.C.", RUE p.287) and
+ * nothing spills onto the body — a depleted suit stops protecting *future*
+ * hits. WHICH hits strike armor (the strike-vs-A.R. threshold roll) is
+ * combat-resolver scope; until then the flag keeps it GM-adjudicated, the
+ * same philosophy as elapsed time in rest/treatment.
*/
export const applyDamage = mutation({
- args: { id: v.id("characters"), amount: v.number() },
- returns: v.object({ sdc: v.number(), hitPoints: v.number() }),
- handler: async (ctx, { id, amount }) => {
+ args: { id: v.id("characters"), amount: v.number(), toArmor: v.optional(v.boolean()) },
+ returns: v.object({
+ sdc: v.optional(v.number()),
+ hitPoints: v.optional(v.number()),
+ armor: v.optional(v.number()),
+ }),
+ handler: async (ctx, { id, amount, toArmor }) => {
const character = await loadCharacter(ctx, id);
+ if (toArmor === true) {
+ const worn = character.items.find((e) => e.worn === true);
+ if (worn === undefined) throw new Error("No armor is worn — nothing to strike.");
+ const item = getItem(worn.itemId);
+ if (item?.kind !== "armor") {
+ throw new Error(`Worn item "${worn.itemId}" is not armor.`); // unreachable for stored docs
+ }
+ const max = armorMaxPool(item, worn.rolledMdc);
+ if (max === undefined) {
+ throw new Error("The worn armor's M.D.C. has not been rolled — no pool to deplete.");
+ }
+ const pool = character.current?.armor ?? max;
+ // A depleted suit "no longer affords protection. Any future attacks
+ // will hit the character's body." (RUE p.287) — refuse rather than
+ // silently soak the hit at zero; the GM routes it to the body.
+ if (pool <= 0) {
+ throw new Error("The worn armor is depleted — the hit strikes the body (RUE p.287).");
+ }
+ // `damageArmor` rejects amounts that aren't whole, non-negative counts.
+ const next = damageArmor(pool, amount);
+ await patchCurrent(ctx, id, character, { ...character.current, armor: next });
+ return { armor: next };
+ }
const rolled = character.rolled;
if (rolled?.hitPoints === undefined || rolled.sdc === undefined) {
throw new Error("Roll vitals before applying damage — no pools to deplete.");
diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts
index 1290065..fcd5356 100644
--- a/packages/backend/convex/schema.ts
+++ b/packages/backend/convex/schema.ts
@@ -39,6 +39,18 @@ export const characterFields = {
}),
),
spellIds: v.array(v.string()),
+ /** Owned items (one entry per physical instance, with per-instance state).
+ * Optional in storage: characters stored before the inventory existed have
+ * none; the rules layer defaults it to []. */
+ items: v.optional(
+ v.array(
+ v.object({
+ itemId: v.string(),
+ worn: v.optional(v.boolean()),
+ rolledMdc: v.optional(v.number()),
+ }),
+ ),
+ ),
rolled: v.optional(
v.object({
hitPoints: v.optional(v.number()),
@@ -53,6 +65,7 @@ export const characterFields = {
hitPoints: v.optional(v.number()),
sdc: v.optional(v.number()),
ppe: v.optional(v.number()),
+ armor: v.optional(v.number()),
treatmentDays: v.optional(v.number()),
}),
),
diff --git a/packages/backend/tests/characters.test.ts b/packages/backend/tests/characters.test.ts
index 1c87a12..dfe2fe8 100644
--- a/packages/backend/tests/characters.test.ts
+++ b/packages/backend/tests/characters.test.ts
@@ -468,6 +468,217 @@ describe("living vitals — current vs. max (#38)", () => {
expect(result).toEqual({ spent: 5, ppe: { current: 79, max: 84 } });
});
+ test("addItem stocks the inventory; dice-capacity armor rolls its suit (#43)", async () => {
+ const t = convexTest(schema, modules);
+ const id = await savedVesper(t);
+
+ expect(await t.mutation(api.characters.addItem, { id, itemId: "canteen" })).toEqual({
+ index: 0,
+ });
+ expect(
+ await t.mutation(api.characters.addItem, { id, itemId: "wilks-320-laser-pistol" }),
+ ).toEqual({ index: 1 });
+
+ // The LLW concealed suit prints "2D6+32 M.D.C. main body" (p.113) — the
+ // per-suit roll happens at acquisition and lands in the same transaction.
+ const suit = await t.mutation(api.characters.addItem, { id, itemId: "llw-concealed-light" });
+ expect(suit.index).toBe(2);
+ expect(suit.rolledMdc).toBeGreaterThanOrEqual(34);
+ expect(suit.rolledMdc).toBeLessThanOrEqual(44);
+ expect((await t.query(api.characters.get, { id }))?.items?.[2]?.rolledMdc).toBe(suit.rolledMdc);
+
+ // A fixed suit (Gladiator: 70, p.267) has nothing to roll.
+ expect(await t.mutation(api.characters.addItem, { id, itemId: "gladiator" })).toEqual({
+ index: 3,
+ });
+
+ await expect(t.mutation(api.characters.addItem, { id, itemId: "bfg-9000" })).rejects.toThrow(
+ /Unknown item/,
+ );
+ });
+
+ test("equipArmor wears one suit exclusively; the sheet surfaces its pool (#43)", async () => {
+ const t = convexTest(schema, modules);
+ const id = await savedVesper(t);
+ await t.mutation(api.characters.addItem, { id, itemId: "canteen" }); // 0
+ await t.mutation(api.characters.addItem, { id, itemId: "gladiator" }); // 1
+ const suit = await t.mutation(api.characters.addItem, { id, itemId: "llw-concealed-light" }); // 2
+
+ await expect(
+ t.mutation(api.characters.equipArmor, { id, index: 0, expect: { itemId: "canteen" } }),
+ ).rejects.toThrow(/Only armor can be worn/);
+ await expect(
+ t.mutation(api.characters.equipArmor, { id, index: 9, expect: { itemId: "gladiator" } }),
+ ).rejects.toThrow(/No item at index/);
+
+ await t.mutation(api.characters.equipArmor, {
+ id,
+ index: 1,
+ expect: { itemId: "gladiator" },
+ });
+ let sheet = await t.query(api.characters.sheet, { id });
+ expect(sheet?.armor).toMatchObject({ max: 70, current: 70 }); // Gladiator, p.267
+
+ // Swapping suits moves the worn flag and the pool follows the new suit.
+ await t.mutation(api.characters.equipArmor, {
+ id,
+ index: 2,
+ expect: { itemId: "llw-concealed-light", rolledMdc: suit.rolledMdc },
+ });
+ sheet = await t.query(api.characters.sheet, { id });
+ expect(sheet?.armor?.max).toBe(suit.rolledMdc);
+ expect((await t.query(api.characters.get, { id }))?.items?.[1]?.worn).toBeUndefined();
+
+ // Doff verifies the worn suit's snapshot too — a stale one is refused...
+ await expect(
+ t.mutation(api.characters.equipArmor, {
+ id,
+ index: null,
+ expect: { itemId: "gladiator", worn: true },
+ }),
+ ).rejects.toThrow(/manifest changed/);
+ // ...and the matching snapshot unequips: no worn armor, no armor layer.
+ await t.mutation(api.characters.equipArmor, {
+ id,
+ index: null,
+ expect: { itemId: "llw-concealed-light", worn: true, rolledMdc: suit.rolledMdc },
+ });
+ sheet = await t.query(api.characters.sheet, { id });
+ expect(sheet?.armor).toBeUndefined();
+
+ // Nothing worn: doff is already satisfied — a no-op, snapshot or not.
+ await t.mutation(api.characters.equipArmor, { id, index: null });
+ });
+
+ test("toArmor damage lands on the worn suit and never spills onto the body (#43)", async () => {
+ const t = convexTest(schema, modules);
+ const id = await savedVesper(t);
+ await t.mutation(api.characters.addItem, { id, itemId: "gladiator" });
+ await t.mutation(api.characters.equipArmor, {
+ id,
+ index: 0,
+ expect: { itemId: "gladiator" },
+ });
+
+ // "Subtract the damage from the armor's S.D.C." (RUE p.287).
+ expect(await t.mutation(api.characters.applyDamage, { id, amount: 30, toArmor: true })).toEqual(
+ { armor: 40 },
+ );
+ await expect(
+ t.mutation(api.characters.applyDamage, { id, amount: -5, toArmor: true }),
+ ).rejects.toThrow(/non-negative/);
+ // The depleting hit is fully absorbed — the body pools stay untouched
+ // (only FUTURE attacks reach the body once the suit reads 0).
+ expect(await t.mutation(api.characters.applyDamage, { id, amount: 99, toArmor: true })).toEqual(
+ { armor: 0 },
+ );
+ const sheet = await t.query(api.characters.sheet, { id });
+ expect(sheet?.armor).toMatchObject({ max: 70, current: 0 });
+ expect(sheet?.vitals.hitPoints.current).toBe(18);
+ expect(sheet?.vitals.sdc.current).toBe(20);
+
+ // A depleted suit "no longer affords protection" (p.287) — further armor
+ // hits are refused, not silently soaked at zero.
+ await expect(
+ t.mutation(api.characters.applyDamage, { id, amount: 5, toArmor: true }),
+ ).rejects.toThrow(/depleted/);
+ });
+
+ test("re-equipping the worn suit is a no-op — a click can't repair the pool (#43)", async () => {
+ const t = convexTest(schema, modules);
+ const id = await savedVesper(t);
+ await t.mutation(api.characters.addItem, { id, itemId: "gladiator" });
+ await t.mutation(api.characters.equipArmor, {
+ id,
+ index: 0,
+ expect: { itemId: "gladiator" },
+ });
+ await t.mutation(api.characters.applyDamage, { id, amount: 30, toArmor: true }); // 40/70
+
+ await t.mutation(api.characters.equipArmor, {
+ id,
+ index: 0,
+ expect: { itemId: "gladiator", worn: true },
+ });
+ const sheet = await t.query(api.characters.sheet, { id });
+ expect(sheet?.armor).toMatchObject({ max: 70, current: 40 }); // NOT refilled
+ });
+
+ test("index-based inventory writes verify the instance snapshot (#43)", async () => {
+ const t = convexTest(schema, modules);
+ const id = await savedVesper(t);
+ await t.mutation(api.characters.addItem, { id, itemId: "canteen" }); // 0
+ await t.mutation(api.characters.addItem, { id, itemId: "gladiator" }); // 1
+
+ // The manifest shifted under the click (index 0 is not the pistol) — refuse.
+ await expect(
+ t.mutation(api.characters.removeItem, {
+ id,
+ index: 0,
+ expect: { itemId: "wilks-320-laser-pistol" },
+ }),
+ ).rejects.toThrow(/manifest changed/);
+ await expect(
+ t.mutation(api.characters.equipArmor, {
+ id,
+ index: 1,
+ expect: { itemId: "gladiator", worn: true }, // stale: not worn yet
+ }),
+ ).rejects.toThrow(/manifest changed/);
+ // Equipping without a snapshot is refused outright.
+ await expect(t.mutation(api.characters.equipArmor, { id, index: 1 })).rejects.toThrow(
+ /expected item snapshot/,
+ );
+ // Nothing was written by the refused calls.
+ expect((await t.query(api.characters.get, { id }))?.items).toEqual([
+ { itemId: "canteen" },
+ { itemId: "gladiator" },
+ ]);
+ });
+
+ test("toArmor without a worn (or rolled) suit is refused (#43)", async () => {
+ const t = convexTest(schema, modules);
+ const id = await savedVesper(t);
+ await expect(
+ t.mutation(api.characters.applyDamage, { id, amount: 5, toArmor: true }),
+ ).rejects.toThrow(/No armor is worn/);
+
+ // A worn dice-capacity suit that somehow lost its roll has no pool yet.
+ await t.run(async (ctx) => {
+ await ctx.db.patch(id, { items: [{ itemId: "llw-concealed-light", worn: true }] });
+ });
+ await expect(
+ t.mutation(api.characters.applyDamage, { id, amount: 5, toArmor: true }),
+ ).rejects.toThrow(/has not been rolled/);
+ });
+
+ test("removeItem drops the instance; removing the worn suit clears its pool (#43)", async () => {
+ const t = convexTest(schema, modules);
+ const id = await savedVesper(t);
+ await t.mutation(api.characters.addItem, { id, itemId: "gladiator" }); // 0
+ await t.mutation(api.characters.addItem, { id, itemId: "canteen" }); // 1
+ await t.mutation(api.characters.equipArmor, {
+ id,
+ index: 0,
+ expect: { itemId: "gladiator" },
+ });
+ await t.mutation(api.characters.applyDamage, { id, amount: 30, toArmor: true });
+
+ await expect(
+ t.mutation(api.characters.removeItem, { id, index: 5, expect: { itemId: "canteen" } }),
+ ).rejects.toThrow(/No item at index/);
+
+ await t.mutation(api.characters.removeItem, {
+ id,
+ index: 0,
+ expect: { itemId: "gladiator", worn: true },
+ });
+ const stored = await t.query(api.characters.get, { id });
+ expect(stored?.items).toEqual([{ itemId: "canteen" }]);
+ // current.armor measured the removed suit — it can't outlive it.
+ expect(stored?.current?.armor).toBeUndefined();
+ });
+
test("illegal `current` states are rejected at every write", async () => {
const t = convexTest(schema, modules);
// Above the maximum on create.
diff --git a/packages/rules/src/content/items/items.json b/packages/rules/src/content/items/items.json
new file mode 100644
index 0000000..ad2e265
--- /dev/null
+++ b/packages/rules/src/content/items/items.json
@@ -0,0 +1,362 @@
+{
+ "book": "Rifts Ultimate Edition",
+ "items": [
+ {
+ "kind": "armor",
+ "id": "llw-concealed-light",
+ "name": "Ley Line Walker Concealed Armor (Light)",
+ "mdc": { "mainBody": "2D6+32" },
+ "notes": "Light to medium body armor worn under the robes; chest, shoulders, thighs and back of the head are always protected. Made from natural M.D.C. materials interlaced with M.D. ceramic plates, padding and miracle fibers. Very common.",
+ "page": 113
+ },
+ {
+ "kind": "armor",
+ "id": "llw-concealed-medium",
+ "name": "Ley Line Walker Concealed Armor (Medium)",
+ "mdc": {
+ "mainBody": "3D6+50",
+ "byLocation": "Arms typically have 11-18 M.D.C., legs have 22-28 M.D.C."
+ },
+ "movementPenalty": "-5% to Prowl, Climb, Swimming and other physical skills",
+ "notes": "Medium concealed armor worn under the robes. Very common.",
+ "page": 113
+ },
+ {
+ "kind": "armor",
+ "id": "urban-warrior",
+ "name": "Urban Warrior Padded Environmental Body Armor",
+ "mdc": {
+ "mainBody": "50",
+ "byLocation": "Helmet: 35, Arms: 16 each, Legs: 30 each"
+ },
+ "environmental": true,
+ "movementPenalty": "excellent mobility, -5% movement penalty",
+ "weight": "11 pounds (5 kg)",
+ "cost": "35,000 credits",
+ "notes": "A lightweight full body suit with special flexible padding and strategic placement of light metal plates.",
+ "page": 268
+ },
+ {
+ "kind": "armor",
+ "id": "plastic-man",
+ "name": "Plastic-Man Full Environmental Body Armor",
+ "mdc": {
+ "mainBody": "35",
+ "byLocation": "Helmet: 30, Arms: 15 each, Legs: 22 each"
+ },
+ "environmental": true,
+ "movementPenalty": "fair mobility, -10% movement penalty",
+ "weight": "13 pounds (5.8 kg)",
+ "cost": "18,000 credits",
+ "notes": "An inexpensive armor made of lightweight M.D.C. polycarbonate plate.",
+ "page": 268
+ },
+ {
+ "kind": "armor",
+ "id": "huntsman",
+ "name": "Huntsman Plate & Padded Armor",
+ "mdc": {
+ "mainBody": "45",
+ "byLocation": "Helmet: 35, Arms: 15 each, Legs: 25 each"
+ },
+ "environmental": false,
+ "movementPenalty": "fair mobility, -10% movement penalty",
+ "weight": "16 pounds (7.2 kg)",
+ "cost": "24,000 credits",
+ "notes": "Non-environmental. A lightweight body with a heavy plate and padded vest and padded armor with leg and arm plates.",
+ "page": 268
+ },
+ {
+ "kind": "armor",
+ "id": "bushman",
+ "name": "Bushman Full Composite Environmental Body Armor",
+ "mdc": {
+ "mainBody": "60",
+ "byLocation": "Helmet: 50, Arms: 30 each, Legs: 55 each"
+ },
+ "environmental": true,
+ "movementPenalty": "fair mobility, -10% movement penalty",
+ "weight": "17 pounds (7.6 kg)",
+ "cost": "32,000 credits",
+ "notes": "A lightweight padding, Kevlar, and plate composite armor. Also available as the Bushman Trooper: a bit bulkier, main body 90 M.D.C., -15% movement penalty, 46,000 credits.",
+ "page": 268
+ },
+ {
+ "kind": "armor",
+ "id": "gladiator",
+ "name": "Gladiator Full Environmental Body Armor",
+ "mdc": {
+ "mainBody": "70",
+ "byLocation": "Helmet: 45, Arms: 25 each, Legs: 45 each"
+ },
+ "environmental": true,
+ "movementPenalty": "fair mobility, -10% movement penalty",
+ "weight": "21 pounds (9.5 kg)",
+ "cost": "38,000 credits",
+ "notes": "One of the most popular medium-weight full environmental suits on the market; super lightweight fiber armor resembling chain mail with light plates.",
+ "page": 267
+ },
+ {
+ "kind": "armor",
+ "id": "crusader",
+ "name": "Crusader Full Environmental Body Armor",
+ "mdc": {
+ "mainBody": "95",
+ "byLocation": "Helmet: 50, Arms: 30 each, Legs: 50 each"
+ },
+ "environmental": true,
+ "movementPenalty": "fair mobility, -15% movement penalty",
+ "weight": "24 pounds (10.8 kg)",
+ "cost": "55,000 credits",
+ "notes": "Full suit of body armor resembling the knights of the ancient Crusades, with chain mail-like M.D.C. materials and knee length skirt.",
+ "page": 267
+ },
+ {
+ "kind": "weapon",
+ "id": "survival-knife",
+ "name": "Survival Knife",
+ "category": "knife",
+ "damage": {
+ "formula": "1D6",
+ "type": "sdc",
+ "note": "Typical knife damage; very small knives do 1D4 (W.P. Knife)."
+ },
+ "page": 327
+ },
+ {
+ "kind": "weapon",
+ "id": "hand-axe",
+ "name": "Hand Axe",
+ "category": "axe",
+ "damage": {
+ "formula": "1D6",
+ "type": "sdc",
+ "note": "Small axes and hatchets do 1D6; battle axes do 2D6 or 2D8 depending on size and style (W.P. Axe)."
+ },
+ "page": 326
+ },
+ {
+ "kind": "weapon",
+ "id": "automatic-pistol",
+ "name": ".45 Automatic Pistol",
+ "category": "handgun",
+ "damage": {
+ "formula": "4D6",
+ "type": "sdc",
+ "note": "Heavy/large caliber handguns: 4D6 (.45 automatic) to 6D6 S.D.C. (Magnum revolvers). Double damage for a standard short burst (three rounds fired); only pistols (not revolvers) can fire in bursts."
+ },
+ "range": "140 feet (42.7 m) average",
+ "payload": "Automatic Pistol: 8-16 rounds",
+ "page": 328
+ },
+ {
+ "kind": "weapon",
+ "id": "submachine-gun",
+ "name": "Submachine-Gun",
+ "category": "submachineGun",
+ "damage": {
+ "formula": "4D6",
+ "type": "sdc",
+ "note": "4D6 S.D.C. per single round or 1D4*10 S.D.C. per three round burst. Can only fire in bursts."
+ },
+ "range": "500-600 feet (152 to 183 m; an Uzi is the latter range)",
+ "payload": "Fires pistol rounds",
+ "page": 329
+ },
+ {
+ "kind": "weapon",
+ "id": "wilks-320-laser-pistol",
+ "name": "Wilk's 320 Laser Pistol",
+ "category": "energyPistol",
+ "damage": { "formula": "1D6", "type": "md" },
+ "range": "1000 feet (305 m)",
+ "payload": "20 shots",
+ "strikeBonusNote": "+2 bonus to strike on an Aimed shot because of the light weight and superior balance",
+ "weight": "2 lbs (0.9 kg)",
+ "cost": "11,000 credits",
+ "notes": "An excellent laser pistol known for its durability, range, accuracy and light weight; sleek, black plastic and ceramic.",
+ "page": 268
+ },
+ {
+ "kind": "weapon",
+ "id": "wilks-447-laser-rifle",
+ "name": "Wilk's 447 Laser Rifle",
+ "category": "energyRifle",
+ "damage": { "formula": "3D6", "type": "md" },
+ "range": "2000 feet (610 m)",
+ "payload": "20 shots per standard clip, can not use a Long E-Clip",
+ "strikeBonusNote": "+1 to strike on an Aimed shot",
+ "weight": "5 lbs (2.25 kg)",
+ "cost": "18,000 credits",
+ "notes": "A rifle version of the handgun; sleek, black plastic and ceramic weapon with all the usual features of a Wilk's product.",
+ "page": 269
+ },
+ {
+ "kind": "weapon",
+ "id": "ng-57-ion-blaster",
+ "name": "NG-57 Northern Gun Heavy-Duty Ion Blaster",
+ "category": "energyPistol",
+ "damage": {
+ "formula": "3D6",
+ "type": "md",
+ "note": "Two settings, 2D4 or 3D6 M.D."
+ },
+ "range": "500 feet (152 m)",
+ "payload": "10 shots",
+ "weight": "5 lbs (2.25 kg)",
+ "cost": "8,000 credits",
+ "notes": "A sturdy pistol that packs a wallop, but is comparatively heavy. A variety of scopes can be attached.",
+ "page": 269
+ },
+ {
+ "kind": "weapon",
+ "id": "ng-33-laser-pistol",
+ "name": "NG-33 Northern Gun Laser Pistol",
+ "category": "energyPistol",
+ "damage": { "formula": "1D6", "type": "md" },
+ "range": "800 feet (244 m)",
+ "payload": "20 shots",
+ "weight": "4 lbs (1.8 kg)",
+ "cost": "6,500 credits",
+ "notes": "Looks like a sleeker ion blaster with a pointed nose.",
+ "page": 269
+ },
+ {
+ "kind": "weapon",
+ "id": "ng-l5-laser-rifle",
+ "name": "NG-L5 Northern Gun Laser Rifle",
+ "category": "energyRifle",
+ "damage": { "formula": "3D6", "type": "md" },
+ "range": "1600 feet (488 m)",
+ "payload": "10 shots standard clip or 20 shots Long E-Clip",
+ "weight": "14 lbs (6.3 kg)",
+ "cost": "16,000 credits",
+ "notes": "A durable, heavy-duty laser rifle that suffers from the usual problem of weight, but can endure a massive amount of abuse and keep on working.",
+ "page": 270
+ },
+ {
+ "kind": "weapon",
+ "id": "ng-p7-particle-beam-rifle",
+ "name": "NG-P7 Northern Gun Particle Beam Rifle",
+ "category": "energyRifle",
+ "damage": { "formula": "1D4*10", "type": "md" },
+ "range": "1200 feet (365 m)",
+ "payload": "8 shots",
+ "weight": "21 lbs (9.45 kg)",
+ "cost": "22,000 credits",
+ "notes": "Another heavy-duty weapon that is a bit heavy and awkward, but sturdy and dependable in combat.",
+ "page": 270
+ },
+ {
+ "kind": "weapon",
+ "id": "l-20-pulse-rifle",
+ "name": "L-20 Pulse Rifle",
+ "category": "energyRifle",
+ "damage": {
+ "formula": "2D6",
+ "type": "md",
+ "note": "2D6 M.D. single shot, or 6D6 multiple pulse burst (three simultaneous shots)."
+ },
+ "range": "1600 feet (488 m)",
+ "payload": "40 shots short E-Clip or 50 shots Long E-Clip",
+ "weight": "7 lbs (3 kg)",
+ "cost": "25,000 credits",
+ "notes": "A common frontier weapon manufactured by the Black Market and several kingdoms across the land; dependable, lightweight, with multiple laser bursts.",
+ "page": 270
+ },
+ {
+ "kind": "gear",
+ "id": "robe-or-cape",
+ "name": "Robe or Cape",
+ "notes": "Part of the traditional garb of the Ley Line Walker.",
+ "page": 116
+ },
+ {
+ "kind": "gear",
+ "id": "set-of-clothing",
+ "name": "Set of Clothing",
+ "page": 116
+ },
+ {
+ "kind": "gear",
+ "id": "traveling-clothes",
+ "name": "Set of Traveling Clothes",
+ "page": 116
+ },
+ {
+ "kind": "gear",
+ "id": "knapsack",
+ "name": "Knapsack",
+ "page": 116
+ },
+ {
+ "kind": "gear",
+ "id": "backpack",
+ "name": "Backpack",
+ "page": 116
+ },
+ {
+ "kind": "gear",
+ "id": "small-sacks",
+ "name": "Small Sacks",
+ "notes": "1D4 small sacks.",
+ "page": 116
+ },
+ {
+ "kind": "gear",
+ "id": "large-sack",
+ "name": "Large Sack",
+ "page": 116
+ },
+ {
+ "kind": "gear",
+ "id": "wooden-stakes-and-mallet",
+ "name": "Wooden Stakes & Mallet",
+ "notes": "Six wooden stakes and mallet (for vampires and other practical applications).",
+ "page": 116
+ },
+ {
+ "kind": "gear",
+ "id": "canteen",
+ "name": "Canteen",
+ "page": 116
+ },
+ {
+ "kind": "gear",
+ "id": "binoculars",
+ "name": "Binoculars",
+ "page": 116
+ },
+ {
+ "kind": "gear",
+ "id": "tinted-goggles",
+ "name": "Tinted Goggles or Sunglasses",
+ "page": 116
+ },
+ {
+ "kind": "gear",
+ "id": "air-filter-and-gas-mask",
+ "name": "Air Filter & Gas Mask",
+ "page": 116
+ },
+ {
+ "kind": "gear",
+ "id": "flashlight",
+ "name": "Flashlight",
+ "page": 116
+ },
+ {
+ "kind": "gear",
+ "id": "cord-and-grappling-hook",
+ "name": "Lightweight Cord & Grappling Hook",
+ "notes": "100 feet (30.5 m) of lightweight cord and grappling hook.",
+ "page": 116
+ },
+ {
+ "kind": "gear",
+ "id": "pens-and-sketch-pad",
+ "name": "Pen or Pencils & Note or Sketch Pad",
+ "page": 116
+ }
+ ]
+}
diff --git a/packages/rules/src/engine/character.ts b/packages/rules/src/engine/character.ts
index 0f5803c..5c99ec7 100644
--- a/packages/rules/src/engine/character.ts
+++ b/packages/rules/src/engine/character.ts
@@ -1,10 +1,13 @@
import type { Alignment } from "../schema/alignments.ts";
import type { Character, CharacterInput, Narrative } from "../schema/character.ts";
import { characterSchema } from "../schema/character.ts";
+import type { Armor, Item } from "../schema/items.ts";
import type { Occ } from "../schema/occ.ts";
import type { Spell } from "../schema/spells.ts";
import { getAlignment } from "./alignments.ts";
import { deriveAttributeBonuses } from "./attributes.ts";
+import { diceMax, diceMin } from "./dice.ts";
+import { armorMaxPool, armorNeedsRoll, getItem } from "./items.ts";
import {
combatProfile,
comaDeathFloor,
@@ -35,6 +38,23 @@ export interface SheetSave {
percent?: boolean;
}
+/** An owned item resolved against the catalog, with its per-instance state. */
+export interface SheetEquipmentEntry {
+ item: Item;
+ worn?: boolean;
+ rolledMdc?: number;
+}
+
+/** The worn armor's live layer — its own ablative pool, never mixed into the
+ * body vitals (there is no AC in Palladium; RUE p.287). */
+export interface SheetArmor {
+ item: Armor;
+ /** Maximum main-body pool; absent until a dice-capacity suit is rolled. */
+ max?: number;
+ /** Remaining pool (equals max until damaged); present exactly when `max` is. */
+ current?: number;
+}
+
export interface CharacterSheet {
name: string;
occ: { id: string; name: string; category: string };
@@ -65,6 +85,9 @@ export interface CharacterSheet {
saves: Record;
skills: ResolvedSkill[];
spells: { known: Spell[]; count: number };
+ equipment: SheetEquipmentEntry[];
+ /** Present when an armor is worn. */
+ armor?: SheetArmor;
}
/** Total O.C.C. save bonus for a given save target at a level (respects level gating). */
@@ -193,6 +216,64 @@ export function deriveSheet(input: CharacterInput): CharacterSheet {
return spell;
});
+ // Resolve owned items, enforcing the kind rules the schema can't see:
+ // `worn` and `rolledMdc` are armor-only, a per-suit roll must match its
+ // printed dice, and at most one armor is worn at a time.
+ let wornArmor: SheetArmor | undefined;
+ const equipment = character.items.map((entry): SheetEquipmentEntry => {
+ const item = getItem(entry.itemId);
+ if (!item) throw new Error(`Unknown item "${entry.itemId}".`);
+ if (entry.rolledMdc !== undefined) {
+ if (item.kind !== "armor" || !armorNeedsRoll(item)) {
+ throw new Error(
+ `rolledMdc on "${item.id}" — only armor with dice-capacity M.D.C. is rolled per suit.`,
+ );
+ }
+ const formula = item.mdc!.mainBody;
+ if (entry.rolledMdc < diceMin(formula) || entry.rolledMdc > diceMax(formula)) {
+ throw new Error(
+ `rolledMdc (${entry.rolledMdc}) is outside the printed ${formula} range for "${item.id}".`,
+ );
+ }
+ }
+ if (entry.worn === true) {
+ if (item.kind !== "armor") {
+ throw new Error(`Only armor can be worn — "${item.id}" is ${item.kind}.`);
+ }
+ if (wornArmor !== undefined) {
+ throw new Error("At most one armor can be worn at a time.");
+ }
+ wornArmor = { item, max: armorMaxPool(item, entry.rolledMdc) };
+ }
+ return {
+ item,
+ ...(entry.worn === true ? { worn: true } : {}),
+ ...(entry.rolledMdc !== undefined ? { rolledMdc: entry.rolledMdc } : {}),
+ };
+ });
+
+ // The armor pool follows the vitals rule: `current` is only meaningful
+ // against a known maximum, and a write above it is rejected, not clamped.
+ const currentArmor = character.current?.armor;
+ if (currentArmor !== undefined) {
+ if (wornArmor === undefined) {
+ throw new Error("current.armor requires a worn armor — no pool to measure against.");
+ }
+ if (wornArmor.max === undefined) {
+ throw new Error(
+ "current.armor requires the suit's rolled M.D.C. — no maximum to measure against.",
+ );
+ }
+ if (currentArmor > wornArmor.max) {
+ throw new Error(
+ `current.armor (${currentArmor}) exceeds the suit's maximum (${wornArmor.max}).`,
+ );
+ }
+ }
+ if (wornArmor !== undefined && wornArmor.max !== undefined) {
+ wornArmor.current = currentArmor ?? wornArmor.max;
+ }
+
return {
name: character.name,
occ: { id: occ.id, name: occ.name, category: occ.category },
@@ -225,5 +306,7 @@ export function deriveSheet(input: CharacterInput): CharacterSheet {
saves,
skills,
spells: { known: knownSpells, count: knownSpells.length },
+ equipment,
+ armor: wornArmor,
};
}
diff --git a/packages/rules/src/engine/items.ts b/packages/rules/src/engine/items.ts
new file mode 100644
index 0000000..4e5c5cd
--- /dev/null
+++ b/packages/rules/src/engine/items.ts
@@ -0,0 +1,66 @@
+import { itemCatalogSchema, type Armor, type Item } from "../schema/items.ts";
+import itemsRaw from "../content/items/items.json" with { type: "json" };
+import { diceMax, diceMin, rollDice, type Rng } from "./dice.ts";
+
+/** The item catalog (RUE equipment chapter + W.P. weapon damage), validated at load. */
+export const itemCatalog = itemCatalogSchema.parse(itemsRaw);
+
+// id index, failing fast on collisions (same approach as the spell/skill catalogs).
+const itemById = new Map();
+for (const item of itemCatalog.items) {
+ if (itemById.has(item.id)) {
+ throw new Error(`Duplicate item id "${item.id}" in the item catalog.`);
+ }
+ itemById.set(item.id, item);
+}
+
+export function getItem(id: string): Item | undefined {
+ return itemById.get(id);
+}
+
+/** All items of a given kind, in catalog order. */
+export function itemsByKind(kind: K): Extract- [] {
+ return itemCatalog.items.filter((i): i is Extract
- => i.kind === kind);
+}
+
+/**
+ * Whether this armor's main-body capacity is printed as a per-suit roll
+ * (LLW concealed: "2D6+32 M.D.C. main body", p.113) rather than a constant.
+ */
+export function armorNeedsRoll(armor: Armor): boolean {
+ if (armor.mdc === undefined) return false;
+ return diceMin(armor.mdc.mainBody) !== diceMax(armor.mdc.mainBody);
+}
+
+/** Roll a concrete main-body M.D.C. for a dice-capacity suit (throws otherwise). */
+export function rollArmorMdc(armor: Armor, rng: Rng = Math.random): number {
+ if (!armorNeedsRoll(armor)) {
+ throw new Error(`${armor.name} has a fixed capacity — nothing to roll.`);
+ }
+ // armorNeedsRoll only passes when mdc is present.
+ return rollDice(armor.mdc!.mainBody, rng);
+}
+
+/**
+ * The armor's maximum main-body pool: the S.D.C. pool for S.D.C. armor, the
+ * printed constant for fixed M.D.C. suits, or the recorded per-suit roll for
+ * dice-capacity suits — `undefined` until that roll exists.
+ */
+export function armorMaxPool(armor: Armor, rolledMdc?: number): number | undefined {
+ if (armor.mdc === undefined) return armor.sdc;
+ if (!armorNeedsRoll(armor)) return diceMin(armor.mdc.mainBody);
+ return rolledMdc;
+}
+
+/**
+ * Damage absorbed by a worn armor pool. The armor takes the WHOLE hit — "the
+ * armor absorbs the attack — subtract the damage from the armor's S.D.C."
+ * (RUE p.287); nothing spills onto the body. A depleted suit (0) no longer
+ * affords protection: *future* attacks hit the character's body.
+ */
+export function damageArmor(pool: number, damage: number): number {
+ if (!Number.isInteger(damage) || damage < 0) {
+ throw new Error(`Damage must be a non-negative integer, got ${damage}.`);
+ }
+ return Math.max(0, pool - damage);
+}
diff --git a/packages/rules/src/index.ts b/packages/rules/src/index.ts
index 65ed90a..ecddb90 100644
--- a/packages/rules/src/index.ts
+++ b/packages/rules/src/index.ts
@@ -5,6 +5,7 @@ export * from "./schema/combat.ts";
export * from "./schema/recovery.ts";
export * from "./schema/skills.ts";
export * from "./schema/spells.ts";
+export * from "./schema/items.ts";
export * from "./schema/character.ts";
export * from "./engine/alignments.ts";
export * from "./engine/attributes.ts";
@@ -14,6 +15,7 @@ export * from "./engine/combat.ts";
export * from "./engine/recovery.ts";
export * from "./engine/skills.ts";
export * from "./engine/spells.ts";
+export * from "./engine/items.ts";
export * from "./engine/character.ts";
export * from "./engine/rolls.ts";
export * from "./engine/builder.ts";
diff --git a/packages/rules/src/schema/character.ts b/packages/rules/src/schema/character.ts
index 07cff33..6721cd5 100644
--- a/packages/rules/src/schema/character.ts
+++ b/packages/rules/src/schema/character.ts
@@ -13,6 +13,22 @@ export const characterSkillSchema = z.object({
});
export type CharacterSkill = z.infer
;
+/**
+ * An item the character owns — one array entry per physical instance, with
+ * per-instance state. Only `itemId` is validated here; item-kind rules (worn
+ * requires armor, rolledMdc requires a dice-capacity suit, at most one worn
+ * armor) live in `deriveSheet`, where the catalog is available.
+ */
+export const characterItemSchema = z.object({
+ itemId: z.string().min(1),
+ /** This armor is currently worn (at most one worn armor across the inventory). */
+ worn: z.boolean().optional(),
+ /** Per-suit rolled main-body M.D.C. maximum, for armor whose printed
+ * capacity is dice (LLW concealed armor: "2D6+32 M.D.C.", RUE p.113). */
+ rolledMdc: z.number().int().positive().optional(),
+});
+export type CharacterItem = z.infer;
+
/** The eight rolled attributes (I.Q., M.E., M.A., P.S., P.P., P.E., P.B., Spd). */
export const characterAttributesSchema = z.object({
IQ: z.number().int().positive(),
@@ -97,6 +113,9 @@ export const characterSchema = z.object({
message: "A spell cannot be known twice (duplicate spellId).",
})
.default([]),
+ /** Owned items (one entry per physical instance). Item-kind rules are
+ * checked in `deriveSheet`, which can see the catalog. */
+ items: z.array(characterItemSchema).default([]),
rolled: z
.object({
hitPoints: z.number().int().positive().optional(),
@@ -115,6 +134,12 @@ export const characterSchema = z.object({
hitPoints: z.number().int().optional(),
sdc: z.number().int().nonnegative().optional(),
ppe: z.number().int().nonnegative().optional(),
+ /** Remaining main-body pool of the WORN armor — armor is its own
+ * ablative layer (RUE p.287), so it lives beside the body pools. Only
+ * legal while an armor with a known maximum is worn, never above that
+ * maximum (enforced in `deriveSheet`). Cleared when armor changes:
+ * a freshly equipped suit starts at its maximum. */
+ armor: z.number().int().nonnegative().optional(),
/** Days of battle-injury treatment already applied this course (drives
* the professional 2-then-4 ramp, RUE p.354). Lives in `current` on
* purpose: a full restore or vitals reroll clears it — fresh pools,
diff --git a/packages/rules/src/schema/items.ts b/packages/rules/src/schema/items.ts
new file mode 100644
index 0000000..8ffc2d9
--- /dev/null
+++ b/packages/rules/src/schema/items.ts
@@ -0,0 +1,108 @@
+import { z } from "zod";
+import { diceFormulaSchema } from "./dice.ts";
+
+/** Weapon category — the hook future W.P. proficiency wiring keys on. */
+export const weaponCategorySchema = z.enum([
+ "knife",
+ "axe",
+ "handgun",
+ "submachineGun",
+ "energyPistol",
+ "energyRifle",
+]);
+export type WeaponCategory = z.infer;
+
+/**
+ * Structured weapon damage: the dice the engine must roll, and which damage
+ * system they belong to — S.D.C. or Mega-Damage (one M.D. point is about one
+ * hundred S.D.C., RUE p.288; the conversion is combat-resolver scope, not
+ * stored here). Multi-setting or variable printed damage keeps the full
+ * sentence in `note` beside the headline dice, like `ppeNote` on spells.
+ */
+export const weaponDamageSchema = z.object({
+ formula: diceFormulaSchema,
+ type: z.enum(["sdc", "md"]),
+ note: z.string().optional(),
+});
+export type WeaponDamage = z.infer;
+
+/** Fields every item kind shares. `page` is the printed page number. */
+const itemBase = {
+ id: z.string().min(1),
+ name: z.string().min(1),
+ /** Weight as printed (e.g. "2 lbs (0.9 kg)"). */
+ weight: z.string().optional(),
+ /** Black Market cost as printed (e.g. "11,000 credits"). */
+ cost: z.string().optional(),
+ notes: z.string().optional(),
+ page: z.number().int().positive(),
+};
+
+export const weaponSchema = z.object({
+ ...itemBase,
+ kind: z.literal("weapon"),
+ category: weaponCategorySchema,
+ damage: weaponDamageSchema,
+ /** Effective range as printed (e.g. "1000 feet (305 m)"). */
+ range: z.string().optional(),
+ /** Payload as printed (e.g. "20 shots"). */
+ payload: z.string().optional(),
+ /** Printed strike bonus rule (e.g. "+2 to strike on an Aimed shot"). */
+ strikeBonusNote: z.string().optional(),
+});
+export type Weapon = z.infer;
+
+/**
+ * Armor is its own ablative layer — there is no AC in Palladium. S.D.C. armor
+ * pairs an Armor Rating threshold with an S.D.C. pool (RUE p.287: a strike
+ * roll above the A.R. penetrates; ties favor the defender). M.D.C. armor is a
+ * Mega-Damage shell with NO A.R. — the schema rejects the contradiction. Its
+ * main-body capacity is a dice formula because some suits print a roll per
+ * suit (Ley Line Walker concealed armor: "2D6+32 M.D.C. main body", p.113)
+ * while factory suits print a constant ("70"). M.D.C. combat *mechanics* are
+ * combat-resolver scope; here the shell is just an ablative pool.
+ */
+export const armorSchema = z
+ .object({
+ ...itemBase,
+ kind: z.literal("armor"),
+ /** Armor Rating of S.D.C. armor: strike rolls above it penetrate (RUE p.287). */
+ ar: z.number().int().min(1).max(20).optional(),
+ /** Ablative S.D.C. pool of S.D.C. armor. */
+ sdc: z.number().int().positive().optional(),
+ mdc: z
+ .object({
+ /** Main-body capacity: a constant ("70") or a per-suit roll ("2D6+32"). */
+ mainBody: diceFormulaSchema,
+ /** Other hit locations as printed (helmet/arms/legs — resolver scope). */
+ byLocation: z.string().optional(),
+ })
+ .optional(),
+ /** Full environmental battle armor (RUE p.267). */
+ environmental: z.boolean().optional(),
+ /** Movement/physical-skill penalty as printed (e.g. "-10% movement penalty"). */
+ movementPenalty: z.string().optional(),
+ })
+ .refine((a) => a.mdc !== undefined || (a.ar !== undefined && a.sdc !== undefined), {
+ message: "Armor must be M.D.C. (mdc) or S.D.C. (both ar and sdc).",
+ })
+ .refine((a) => a.mdc === undefined || (a.ar === undefined && a.sdc === undefined), {
+ message: "M.D.C. armor is a Mega-Damage shell — it cannot declare an A.R. or an S.D.C. pool.",
+ });
+export type Armor = z.infer;
+
+/** Flavor-tier gear: name and printed notes, no mechanics. */
+export const gearSchema = z.object({
+ ...itemBase,
+ kind: z.literal("gear"),
+});
+export type Gear = z.infer;
+
+export const itemSchema = z.discriminatedUnion("kind", [weaponSchema, armorSchema, gearSchema]);
+export type Item = z.infer;
+
+export const itemCatalogSchema = z.object({
+ book: z.string().min(1),
+ items: z.array(itemSchema),
+});
+export type ItemCatalog = z.infer;
diff --git a/packages/rules/tests/character.test.ts b/packages/rules/tests/character.test.ts
index 6f2a39f..bba9d83 100644
--- a/packages/rules/tests/character.test.ts
+++ b/packages/rules/tests/character.test.ts
@@ -172,6 +172,90 @@ describe("deriveSheet — edge cases", () => {
);
});
+ test("equipment resolves against the catalog; unknown item ids throw", () => {
+ const sheet = deriveSheet({
+ ...leyLineWalker,
+ items: [{ itemId: "wilks-320-laser-pistol" }, { itemId: "canteen" }],
+ });
+ expect(sheet.equipment.map((e) => e.item.id)).toEqual(["wilks-320-laser-pistol", "canteen"]);
+ expect(sheet.armor).toBeUndefined();
+ expect(() => deriveSheet({ ...leyLineWalker, items: [{ itemId: "bfg-9000" }] })).toThrow(
+ /Unknown item "bfg-9000"/,
+ );
+ });
+
+ test("a worn fixed suit surfaces its pool at maximum; current.armor depletes it", () => {
+ const items = [{ itemId: "gladiator", worn: true }]; // 70 M.D.C. main body, p.267
+ expect(deriveSheet({ ...leyLineWalker, items }).armor).toMatchObject({
+ max: 70,
+ current: 70,
+ });
+ const damaged = deriveSheet({ ...leyLineWalker, items, current: { armor: 12 } });
+ expect(damaged.armor).toMatchObject({ max: 70, current: 12 });
+ });
+
+ test("a dice-capacity suit (LLW concealed, 2D6+32, p.113) needs its per-suit roll", () => {
+ const worn = { itemId: "llw-concealed-light", worn: true };
+ // Unrolled: the suit is worn but has no pool yet.
+ expect(deriveSheet({ ...leyLineWalker, items: [worn] }).armor).toMatchObject({
+ max: undefined,
+ });
+ // Rolled: the roll is the maximum.
+ expect(
+ deriveSheet({ ...leyLineWalker, items: [{ ...worn, rolledMdc: 40 }] }).armor,
+ ).toMatchObject({ max: 40, current: 40 });
+ // A roll outside the printed 2D6+32 range (34-44) is a bug upstream.
+ expect(() => deriveSheet({ ...leyLineWalker, items: [{ ...worn, rolledMdc: 45 }] })).toThrow(
+ /outside the printed 2D6\+32 range/,
+ );
+ // rolledMdc on a fixed suit or a non-armor item is meaningless.
+ expect(() =>
+ deriveSheet({ ...leyLineWalker, items: [{ itemId: "gladiator", rolledMdc: 40 }] }),
+ ).toThrow(/only armor with dice-capacity/);
+ expect(() =>
+ deriveSheet({ ...leyLineWalker, items: [{ itemId: "canteen", rolledMdc: 40 }] }),
+ ).toThrow(/only armor with dice-capacity/);
+ });
+
+ test("worn is armor-only and exclusive; illegal current.armor states are rejected", () => {
+ expect(() =>
+ deriveSheet({ ...leyLineWalker, items: [{ itemId: "canteen", worn: true }] }),
+ ).toThrow(/Only armor can be worn/);
+ expect(() =>
+ deriveSheet({
+ ...leyLineWalker,
+ items: [
+ { itemId: "gladiator", worn: true },
+ { itemId: "crusader", worn: true },
+ ],
+ }),
+ ).toThrow(/At most one armor/);
+ // No worn armor -> no pool to measure against.
+ expect(() =>
+ deriveSheet({
+ ...leyLineWalker,
+ items: [{ itemId: "gladiator" }],
+ current: { armor: 10 },
+ }),
+ ).toThrow(/requires a worn armor/);
+ // Worn but unrolled dice suit -> no maximum yet.
+ expect(() =>
+ deriveSheet({
+ ...leyLineWalker,
+ items: [{ itemId: "llw-concealed-light", worn: true }],
+ current: { armor: 10 },
+ }),
+ ).toThrow(/requires the suit's rolled M\.D\.C\./);
+ // Above the suit's maximum.
+ expect(() =>
+ deriveSheet({
+ ...leyLineWalker,
+ items: [{ itemId: "gladiator", worn: true }],
+ current: { armor: 71 },
+ }),
+ ).toThrow(/exceeds the suit's maximum/);
+ });
+
test("an O.C.C. flat-value grant overrides the computed percentage (Native Tongue 98%)", () => {
const sheet = deriveSheet({
...leyLineWalker,
diff --git a/packages/rules/tests/items.test.ts b/packages/rules/tests/items.test.ts
new file mode 100644
index 0000000..a7fe832
--- /dev/null
+++ b/packages/rules/tests/items.test.ts
@@ -0,0 +1,181 @@
+import { describe, expect, test } from "vite-plus/test";
+import {
+ armorMaxPool,
+ armorNeedsRoll,
+ armorSchema,
+ damageArmor,
+ getItem,
+ itemCatalog,
+ itemsByKind,
+ rollArmorMdc,
+ type Armor,
+} from "../src/index.ts";
+
+describe("item catalog (RUE equipment)", () => {
+ test("pins the per-kind counts (silent drift is caught forever after)", () => {
+ expect(itemsByKind("armor")).toHaveLength(8);
+ expect(itemsByKind("weapon")).toHaveLength(11);
+ expect(itemsByKind("gear")).toHaveLength(15);
+ expect(itemCatalog.items).toHaveLength(34);
+ });
+
+ test("Ley Line Walker concealed armor rolls its M.D.C. per suit (p.113)", () => {
+ const light = getItem("llw-concealed-light");
+ expect(light).toMatchObject({
+ kind: "armor",
+ mdc: { mainBody: "2D6+32" },
+ page: 113,
+ });
+ const medium = getItem("llw-concealed-medium");
+ expect(medium).toMatchObject({
+ kind: "armor",
+ mdc: { mainBody: "3D6+50" },
+ page: 113,
+ });
+ });
+
+ test("M.D.C. body armor suits carry the printed main-body values (pp.267-268)", () => {
+ // RUE p.267
+ expect(getItem("gladiator")).toMatchObject({ mdc: { mainBody: "70" }, page: 267 });
+ expect(getItem("crusader")).toMatchObject({ mdc: { mainBody: "95" }, page: 267 });
+ // RUE p.268
+ expect(getItem("urban-warrior")).toMatchObject({ mdc: { mainBody: "50" }, page: 268 });
+ expect(getItem("plastic-man")).toMatchObject({ mdc: { mainBody: "35" }, page: 268 });
+ expect(getItem("huntsman")).toMatchObject({
+ mdc: { mainBody: "45" },
+ environmental: false,
+ page: 268,
+ });
+ expect(getItem("bushman")).toMatchObject({ mdc: { mainBody: "60" }, page: 268 });
+ });
+
+ test("energy weapons carry the printed damage dice (pp.268-270)", () => {
+ expect(getItem("wilks-320-laser-pistol")).toMatchObject({
+ damage: { formula: "1D6", type: "md" },
+ range: "1000 feet (305 m)",
+ page: 268,
+ });
+ expect(getItem("wilks-447-laser-rifle")).toMatchObject({
+ damage: { formula: "3D6", type: "md" },
+ range: "2000 feet (610 m)",
+ page: 269,
+ });
+ expect(getItem("ng-57-ion-blaster")).toMatchObject({
+ damage: { formula: "3D6", type: "md" },
+ page: 269,
+ });
+ expect(getItem("ng-33-laser-pistol")).toMatchObject({
+ damage: { formula: "1D6", type: "md" },
+ page: 269,
+ });
+ expect(getItem("ng-l5-laser-rifle")).toMatchObject({
+ damage: { formula: "3D6", type: "md" },
+ page: 270,
+ });
+ expect(getItem("ng-p7-particle-beam-rifle")).toMatchObject({
+ damage: { formula: "1D4*10", type: "md" },
+ page: 270,
+ });
+ expect(getItem("l-20-pulse-rifle")).toMatchObject({
+ damage: { formula: "2D6", type: "md" },
+ page: 270,
+ });
+ });
+
+ test("conventional weapons carry the printed W.P. damage (pp.326-329)", () => {
+ // W.P. Axe: small axes and hatchets do 1D6 (p.326).
+ expect(getItem("hand-axe")).toMatchObject({
+ damage: { formula: "1D6", type: "sdc" },
+ page: 326,
+ });
+ // W.P. Knife: typical 1D6 (p.327).
+ expect(getItem("survival-knife")).toMatchObject({
+ damage: { formula: "1D6", type: "sdc" },
+ page: 327,
+ });
+ // W.P. Handguns: heavy/large caliber 4D6 (.45 automatic) (p.328).
+ expect(getItem("automatic-pistol")).toMatchObject({
+ damage: { formula: "4D6", type: "sdc" },
+ page: 328,
+ });
+ // W.P. Submachine-Gun: 4D6 per single round (p.329).
+ expect(getItem("submachine-gun")).toMatchObject({
+ damage: { formula: "4D6", type: "sdc" },
+ page: 329,
+ });
+ });
+
+ test("the LLW standard-equipment gear is present, page-stamped to the list (p.116)", () => {
+ for (const id of ["robe-or-cape", "canteen", "wooden-stakes-and-mallet", "flashlight"]) {
+ expect(getItem(id)).toMatchObject({ kind: "gear", page: 116 });
+ }
+ });
+});
+
+describe("armor schema refinements", () => {
+ const base = { kind: "armor" as const, id: "x", name: "X", page: 1 };
+
+ test("rejects armor that is neither M.D.C. nor a full S.D.C. (A.R. + pool) shape", () => {
+ expect(() => armorSchema.parse(base)).toThrow();
+ expect(() => armorSchema.parse({ ...base, ar: 12 })).toThrow(); // A.R. without a pool
+ expect(() => armorSchema.parse({ ...base, sdc: 50 })).toThrow(); // pool without an A.R.
+ });
+
+ test("rejects an M.D.C. shell that also declares A.R./S.D.C. (no A.R. on M.D.C. armor)", () => {
+ expect(() =>
+ armorSchema.parse({ ...base, mdc: { mainBody: "70" }, ar: 12, sdc: 50 }),
+ ).toThrow();
+ });
+
+ test("accepts the two legal shapes", () => {
+ expect(armorSchema.parse({ ...base, ar: 14, sdc: 38 }).ar).toBe(14);
+ expect(armorSchema.parse({ ...base, mdc: { mainBody: "2D6+32" } }).mdc?.mainBody).toBe(
+ "2D6+32",
+ );
+ });
+});
+
+describe("armor pools", () => {
+ const sdcArmor = armorSchema.parse({
+ kind: "armor",
+ id: "test-sdc",
+ name: "Test S.D.C. Armor",
+ ar: 14,
+ sdc: 38,
+ page: 287,
+ });
+
+ test("fixed suits need no roll; dice suits do", () => {
+ expect(armorNeedsRoll(getItem("gladiator") as Armor)).toBe(false);
+ expect(armorNeedsRoll(getItem("llw-concealed-light") as Armor)).toBe(true);
+ expect(armorNeedsRoll(sdcArmor)).toBe(false);
+ });
+
+ test("armorMaxPool: S.D.C. pool, printed constant, or the per-suit roll", () => {
+ expect(armorMaxPool(sdcArmor)).toBe(38);
+ expect(armorMaxPool(getItem("gladiator") as Armor)).toBe(70); // p.267
+ const llw = getItem("llw-concealed-light") as Armor;
+ expect(armorMaxPool(llw)).toBeUndefined(); // 2D6+32: no pool until rolled
+ expect(armorMaxPool(llw, 40)).toBe(40);
+ });
+
+ test("rollArmorMdc stays within the printed 2D6+32 bounds (p.113)", () => {
+ const llw = getItem("llw-concealed-light") as Armor;
+ for (let i = 0; i < 50; i++) {
+ const rolled = rollArmorMdc(llw);
+ expect(rolled).toBeGreaterThanOrEqual(34);
+ expect(rolled).toBeLessThanOrEqual(44);
+ }
+ expect(() => rollArmorMdc(getItem("gladiator") as Armor)).toThrow(/fixed capacity/);
+ });
+
+ test("damageArmor absorbs the whole hit, floors at zero, never spills (p.287)", () => {
+ expect(damageArmor(38, 10)).toBe(28);
+ // "The armor absorbs the attack" — even the depleting hit is fully
+ // absorbed; only FUTURE attacks reach the body.
+ expect(damageArmor(5, 12)).toBe(0);
+ expect(damageArmor(0, 12)).toBe(0);
+ expect(() => damageArmor(10, -1)).toThrow();
+ expect(() => damageArmor(10, 2.5)).toThrow();
+ });
+});