diff --git a/.codex/superpowers/plans/2026-07-26-skill-legality-sdc-foundation.md b/.codex/superpowers/plans/2026-07-26-skill-legality-sdc-foundation.md new file mode 100644 index 0000000..731aa40 --- /dev/null +++ b/.codex/superpowers/plans/2026-07-26-skill-legality-sdc-foundation.md @@ -0,0 +1,507 @@ +# Skill Legality and Additive S.D.C. Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver issue #61 by replacing prose-parsed skill legality with typed source-backed rules, persisting only player skill decisions for new builds, deriving every skill bonus on the rules/backend side, and accumulating the printed physical S.D.C. base with all O.C.C. and selected-skill contributions. + +**Architecture:** Expand the RUE skill catalog to the complete printed category sections reachable by the Ley Line Walker's O.C.C.-related and Secondary Skill choices. Put reusable `any | none | include | exclude` policies and pick costs in rules content, then validate the whole O.C.C./skill content graph at load. A single pure assembler resolves persisted selection inputs into authoritative skill entries and Hand-to-Hand training for builder preview, sheet derivation, and backend writes. Legacy documents may still derive from their flattened skill metadata, but all new or fully edited builds use selections. Physical S.D.C. becomes a sourced contribution list whose range and server-side roll share the same formulas. + +**Tech Stack:** TypeScript, Zod 4, page-stamped JSON rules content, Convex 1.x, SolidJS 1.9, Vite+ (`vp`), Vite+ Test, pnpm. + +## Global Constraints + +- Work on `feat/skill-legality-sdc`, created from synchronized `origin/main` at `25b9231397e3686c5a7359be181a7704662af76c`. +- Treat `.codex/superpowers/specs/2026-07-22-occ-breadth-foundation-design.md` and live issue #61 as the approved contract. +- Source authority is the rendered Rifts Ultimate Edition, never memory or copied issue prose: + - p.287: `2D6+12` base physical S.D.C. when an O.C.C. does not specify its own base; O.C.C. and Physical skill bonuses are cumulative. + - p.299: Physical bonuses are cumulative; O.C.C.-granted skills are automatic; a prerequisite implied by a starting O.C.C. skill is supplied by training. + - p.300: independent Secondary Skills category restrictions and no O.C.C./category bonuses. + - pp.304-330: complete affected skill-description sections, including Physical S.D.C. formulas and all categories exposed by LLW legality. +- Preserve source pages on every catalog entry. Never add a skill from the p.302-303 index without checking its full description in pp.304-330. +- Complete category sections touched by the LLW/Secondary selection policies; do not leave a policy mechanically `any` over a partial catalog. +- Production Coalition Grunt content remains out of scope for #57. Use Grunt-shaped fixtures only to prove reusable policy/validation behavior. +- Load-time validation must reject duplicate ids/names, dangling prerequisites, unknown policy/grant ids, category mismatches, contradictory policies, duplicate grants, impossible minimum-pick rules, and unknown Hand-to-Hand ids. +- New build persistence stores only player decisions: O.C.C. slot picks, related picks, secondary picks, repeatable labels, and Hand-to-Hand choice. The browser cannot author `occBonus`, `categoryBonus`, or `overrideValue`. +- Existing documents with only flattened `skills` and `hthType` remain readable and usable. Full edits migrate through selections; narrow mutations preserve legacy data. +- The builder, `deriveSheet`, and Convex writes must call the same pure assembler. +- S.D.C. contribution formulas are narrow dice formulas only. Do not introduce a generic effects system. +- Roll every S.D.C. formula server-side with injected RNG and store only the final maximum. +- Preserve current-pool invariants: stored/rolled S.D.C. must be within the derived min/max; current S.D.C. requires a rolled maximum and cannot exceed it. +- Use TDD for every production change: add a focused failing test, run it and verify the expected failure, implement the minimum change, rerun green, then refactor. +- Run affected package checks after each task. Before handoff run root `vp check`, root `vp test`, `git diff --check`, and live browser acceptance. +- Do not push, create a PR, alter tracker state, close issues, or merge without the user's explicit workflow choice after local completion. +- No AI attribution. + +--- + +## File Map + +### Skill content and typed legality + +- Modify `packages/rules/src/schema/skills.ts` + - add canonical `skillCategorySchema` + - add `source`/page stamping, optional `sdcBonus`, and typed prerequisite ids + - add reusable `skillPoolPolicySchema` (`any | none | include | exclude`) + - add the source-backed Secondary Skills category policy +- Modify `packages/rules/src/content/skills/skills.json` + - transcribe the complete reachable printed skill sections from pp.304-330 + - correct existing entries against their full descriptions + - encode S.D.C. bonuses exactly for affected Physical skills +- Modify `packages/rules/src/engine/skills.ts` + - validate the catalog graph and expose policy resolution + - expose Secondary Skill options independently + - validate prerequisites against a completed assembly +- Modify `packages/rules/tests/skills.test.ts` + - exact values/pages, complete category counts/ids, S.D.C. formulas, prerequisites, duplicate/collision/dangling failures + +### O.C.C. content and authoritative assembly + +- Modify `packages/rules/src/schema/occ.ts` + - replace catchall skill grants with a discriminated typed union + - replace free-text category `allowed` rules with typed policies + - encode exceptional pick costs and a typed O.C.C. S.D.C. formula + - reject legacy prose fields +- Modify `packages/rules/src/content/occ/ley-line-walker.json` + - migrate every grant/policy to typed ids + - preserve exact bonuses, constraints, H2H costs, and source facts +- Modify `packages/rules/src/engine/occ.ts` + - validate the O.C.C.-to-skill graph at import +- Modify `packages/rules/src/engine/builder.ts` + - remove prose parsing + - model the independent Secondary Skill pool + - apply weighted selection costs + - enforce completed-build prerequisites + - return source-owned assembled skill bonuses and H2H +- Modify `packages/rules/src/index.ts` + - export the typed selection/assembly/S.D.C. surfaces +- Modify `packages/rules/tests/occ.test.ts` and `packages/rules/tests/builder.test.ts` + - typed content, LLW exact choices/bonuses, Secondary pool, costs, minimums, labels, duplicates, prerequisites, hostile bonus payloads + +### Character derivation and additive S.D.C. + +- Modify `packages/rules/src/schema/character.ts` + - add persisted `skillSelections` + - retain optional legacy flattened `skills`/`hthType` only for compatibility +- Modify `packages/rules/src/engine/combat.ts` + - accept S.D.C. formulas/contributions and derive an accumulated range +- Modify `packages/rules/src/engine/character.ts` + - select authoritative assembly when `skillSelections` exist + - retain the legacy flattened read path + - expose S.D.C. contribution breakdown + - validate rolled S.D.C. against the accumulated range +- Modify `packages/rules/tests/combat.test.ts` and `packages/rules/tests/character.test.ts` + - base, fixed/dice contributions, accumulation, exact min/max, legacy compatibility, illegal persisted values + +### Backend authority + +- Modify `packages/backend/convex/schema.ts` + - add optional stored authoritative selections + - retain legacy flattened fields +- Modify `packages/backend/convex/characters.ts` + - require selections for create/full update + - reject flattened bonus metadata from new writes + - derive before every write + - roll complete S.D.C. contributions with injected RNG +- Modify `packages/backend/tests/characters.test.ts` and `packages/backend/tests/combat.test.ts` + - legal new inputs, hostile/tampered inputs, legacy reads/narrow mutations, accumulated S.D.C. roll persistence + +### Web and delivery + +- Modify `apps/web/src/builder/store.ts` + - retain raw selections as draft state + - preview via the shared assembler + - submit `skillSelections`, never flattened bonuses +- Modify `apps/web/src/builder/steps/occ-skills.tsx` +- Modify `apps/web/src/builder/steps/related-skills.tsx` +- Modify `apps/web/src/builder/steps/review.tsx` + - render typed options, costs, labels, derived bonuses, and legality errors +- Modify `apps/web/tests/builder.test.ts` and `apps/web/tests/character-sheet.test.ts` + - source-owned bonus display and mutation input contract +- Modify `README.md`, `docs/rules/PAGE_MAP.md`, and any exact gap notes in #22/#25/#26 only after implementation proof. + +--- + +## Task 1: Complete and Validate the Source-Backed Skill Catalog + +**Files:** `packages/rules/src/schema/skills.ts`, `packages/rules/src/content/skills/skills.json`, `packages/rules/src/engine/skills.ts`, `packages/rules/src/index.ts`, `packages/rules/tests/skills.test.ts`. + +- [ ] **Step 1: Preserve the source evidence** + + Record the inspected render set in the implementation notes: + + - p.287 for the physical S.D.C. base and cumulative rule + - p.299 for cumulative Physical bonuses and implied prerequisite training + - p.300 for Secondary Skills + - pp.304-330 for the complete affected descriptions + + Do not commit rendered images or the source PDF. + +- [ ] **Step 2: Add failing schema/catalog tests** + + Tests must prove: + + - categories are a closed enum matching the printed headings + - every skill has a source ref/page + - the catalog contains every entry from the affected printed sections, with stable ids and no duplicate canonical/alias names + - corrected printed values include `Excavation 30% +5%` (p.323) and `Firefighting 40% +5%` (p.323) + - Physical formulas include at minimum: + - Acrobatics `1D6` + - Aerobic Athletics `2D4` + - Athletics (General) `1D8` + - Body Building & Weight Lifting `10` + - Boxing `3D6` + - Forced March `2D6` + - Gymnastics `2D6` + - Kick Boxing `1D10` + - Outdoorsmanship `2D6` + - Physical Labor `2D8` + - Running `1D6` + - Wrestling `4D6` + - dangling prerequisite ids, duplicate ids, alias collisions, and wrong-category policy ids throw at load + + Run: + + ```powershell + vp test packages/rules/tests/skills.test.ts + ``` + + Expected: FAIL on missing typed category/source/policy/S.D.C. support and incomplete content. + +- [ ] **Step 3: Add closed schemas and graph validation** + + Introduce: + + ```ts + type SkillPoolPolicy = + | { kind: "any" } + | { kind: "none" } + | { kind: "include"; skillIds: string[] } + | { kind: "exclude"; skillIds: string[] }; + ``` + + Add optional `sdcBonus: DiceFormula` and require `source: SourceRef` for every skill. Validate prerequisite ids after indexes exist. + +- [ ] **Step 4: Transcribe the complete affected sections** + + Transcribe from the rendered descriptions, not the p.302-303 index alone. Preserve two-value bases, repeatability, aliases, prerequisites, and S.D.C. exactly. Split W.P. into the catalog categories already used by selection policy (`W.P. Ancient` and `W.P. Modern`) or provide an equally typed mapping; do not retain an ambiguous free-string `W.P.` category. + +- [ ] **Step 5: Encode the p.300 Secondary Skills policy** + + Put the independent policy beside the skill catalog and page-stamp it to p.300. Explicitly encode every category restriction; no caller may derive it from O.C.C.-related policies. + +- [ ] **Step 6: Run focused gates** + + ```powershell + vp test packages/rules/tests/skills.test.ts + vp check packages/rules + ``` + + Expected: PASS. + +- [ ] **Step 7: Commit** + + ```powershell + git add packages/rules/src/schema/skills.ts packages/rules/src/content/skills/skills.json packages/rules/src/engine/skills.ts packages/rules/src/index.ts packages/rules/tests/skills.test.ts + git commit -m "feat(rules): complete typed skill catalog" + ``` + +--- + +## Task 2: Replace Prose O.C.C. Skill Rules with Typed Content + +**Files:** `packages/rules/src/schema/occ.ts`, `packages/rules/src/content/occ/ley-line-walker.json`, `packages/rules/src/engine/occ.ts`, `packages/rules/tests/occ.test.ts`. + +- [ ] **Step 1: Add failing O.C.C. content tests** + + Prove that: + + - legacy `allowed` prose and loose catchall grants are rejected + - LLW typed related rules match the printed restrictions + - LLW has at least two Science and one Technical related pick + - Paramedic carries selection cost 2 + - H2H upgrade costs and evil-only Assassin are structured + - all grant/policy ids exist in the catalog and match their categories + - duplicate fixed grants, contradictory policies, and impossible minimums fail load + + Run: + + ```powershell + vp test packages/rules/tests/occ.test.ts + ``` + + Expected: FAIL while catchall/prose content remains. + +- [ ] **Step 2: Implement discriminated grant/policy schemas** + + Model fixed grants, fixed overrides, repeatable choices, category choices, prefix/tag choices, and H2H grants as explicit discriminated unions. Model selection cost on a chosen skill/policy exception, not in a note string. + +- [ ] **Step 3: Migrate LLW content** + + Replace every `allowed` string with an explicit policy and every loose field with a typed grant. Preserve all printed bonuses and source behavior. + +- [ ] **Step 4: Validate the whole O.C.C. graph** + + Run graph validation after both catalogs load. Minimum-pick feasibility must use weighted costs and the admitted category pools. + +- [ ] **Step 5: Run focused gates and commit** + + ```powershell + vp test packages/rules/tests/occ.test.ts + vp check packages/rules + git add packages/rules/src/schema/occ.ts packages/rules/src/content/occ/ley-line-walker.json packages/rules/src/engine/occ.ts packages/rules/tests/occ.test.ts + git commit -m "feat(rules): type O.C.C. skill legality" + ``` + +--- + +## Task 3: Build the Single Authoritative Skill Assembler + +**Files:** `packages/rules/src/engine/builder.ts`, `packages/rules/src/index.ts`, `packages/rules/tests/builder.test.ts`. + +- [ ] **Step 1: Add failing assembler tests** + + Cover: + + - exact LLW fixed skills/overrides and choice-slot bonuses + - related `include/exclude/none/any` behavior without prose parsing + - p.300 Secondary options independently from LLW related options + - weighted Paramedic selection cost + - H2H costs reducing available related picks + - exact minimum categories, duplicate prevention, repeatable labels, and wrong slot ids + - prerequisites checked against the completed build, regardless of pick order + - O.C.C.-granted prerequisite behavior from p.299 + - no selection input field can supply `occBonus`, `categoryBonus`, or `overrideValue` + + Run: + + ```powershell + vp test packages/rules/tests/builder.test.ts + ``` + + Expected: FAIL on the new authoritative selection shape and typed policies. + +- [ ] **Step 2: Define the persisted decision shape** + + Use a schema-owned type equivalent to: + + ```ts + interface SkillSelections { + occChoices: Record; + related: SkillPick[]; + secondary: SkillPick[]; + hthId?: string; + } + ``` + + `SkillPick` contains only `skillId` and an optional repeatable label. Alignment remains character input and is passed to assembly context. + +- [ ] **Step 3: Remove prose parsing and assemble from typed rules** + + Policy resolution, bonuses, overrides, costs, H2H, and prerequisite checks all originate in content. Return best-effort assembly plus stable human-readable errors for UI preview. + +- [ ] **Step 4: Run focused gates and commit** + + ```powershell + vp test packages/rules/tests/builder.test.ts + vp check packages/rules + git add packages/rules/src/engine/builder.ts packages/rules/src/index.ts packages/rules/tests/builder.test.ts + git commit -m "feat(rules): assemble authoritative skill choices" + ``` + +--- + +## Task 4: Derive and Roll Additive Physical S.D.C. + +**Files:** `packages/rules/src/schema/occ.ts`, `packages/rules/src/engine/combat.ts`, `packages/rules/src/engine/character.ts`, `packages/rules/tests/combat.test.ts`, `packages/rules/tests/character.test.ts`. + +- [ ] **Step 1: Add failing range/breakdown tests** + + Prove: + + - no contributions => `2D6+12`, min 14/max 24 + - fixed and dice formulas add component-wise + - selecting Body Building (`+10`) and Running (`+1D6`) produces min 25/max 40 before any O.C.C. contribution + - every contribution is labeled with its source id/name/page + - LLW has no invented O.C.C. S.D.C. bonus + - a fixture O.C.C. typed bonus accumulates with selected skills + - stored S.D.C. below min or above max is rejected + + Run: + + ```powershell + vp test packages/rules/tests/combat.test.ts packages/rules/tests/character.test.ts + ``` + + Expected: FAIL while physical S.D.C. is base-only. + +- [ ] **Step 2: Implement contribution-based range derivation** + + Return a stable breakdown including the p.287 base plus applicable O.C.C./skill formulas. Compute min/max through the existing dice helpers. + +- [ ] **Step 3: Integrate authoritative selections with `deriveSheet`** + + If `skillSelections` exist, assemble and reject any errors, then resolve percentages and S.D.C. from the assembled output. If selections do not exist, read legacy flattened skills/H2H and preserve current behavior. Never allow a document to mix authoritative selections with client-authored flattened bonus metadata. + +- [ ] **Step 4: Run focused gates and commit** + + ```powershell + vp test packages/rules/tests/combat.test.ts packages/rules/tests/character.test.ts + vp check packages/rules + git add packages/rules/src/schema/occ.ts packages/rules/src/engine/combat.ts packages/rules/src/engine/character.ts packages/rules/tests/combat.test.ts packages/rules/tests/character.test.ts + git commit -m "feat(rules): derive additive physical S.D.C." + ``` + +--- + +## Task 5: Make Convex the Final Write and Roll Authority + +**Files:** `packages/backend/convex/schema.ts`, `packages/backend/convex/characters.ts`, `packages/backend/tests/characters.test.ts`, `packages/backend/tests/combat.test.ts`. + +- [ ] **Step 1: Add failing backend tests** + + Cover: + + - create accepts authoritative selections and persists them + - create/full update rejects flattened skill bonus/H2H payloads + - illegal related/secondary picks, missing labels, prerequisites, or H2H choices fail before write + - legacy flattened documents still read/derive + - narrow legacy mutations preserve flattened data + - a full legacy edit requires authoritative selections + - `rollVitals` rolls base plus every S.D.C. contribution via injected RNG and persists only the total + - rolled/current S.D.C. invariants hold against the expanded range + + Run: + + ```powershell + vp test packages/backend/tests/characters.test.ts packages/backend/tests/combat.test.ts + ``` + + Expected: FAIL on old mutation arguments and base-only S.D.C. rolling. + +- [ ] **Step 2: Extend storage compatibility** + + Make authoritative selections optional in storage for legacy compatibility. Keep flattened fields only as legacy data; do not populate them for new writes. + +- [ ] **Step 3: Update create/full update/narrow mutations** + + Public new/full write validators accept decisions only. Parse through `deriveSheet` before persistence. Narrow mutations continue carrying the existing document shape through derivation. + +- [ ] **Step 4: Roll contribution formulas server-side** + + Reuse the exact contribution list/range used by `deriveSheet`; do not duplicate S.D.C. selection logic in Convex. + +- [ ] **Step 5: Run focused gates and commit** + + ```powershell + vp test packages/backend/tests/characters.test.ts packages/backend/tests/combat.test.ts + vp check packages/backend + git add packages/backend/convex/schema.ts packages/backend/convex/characters.ts packages/backend/tests/characters.test.ts packages/backend/tests/combat.test.ts + git commit -m "feat(backend): enforce authoritative skill choices" + ``` + +--- + +## Task 6: Submit Decisions from the Builder + +**Files:** `apps/web/src/builder/store.ts`, `apps/web/src/builder/steps/occ-skills.tsx`, `apps/web/src/builder/steps/related-skills.tsx`, `apps/web/src/builder/steps/review.tsx`, `apps/web/tests/builder.test.ts`, `apps/web/tests/character-sheet.test.ts`. + +- [ ] **Step 1: Add failing browser-contract tests** + + Prove: + + - store retains only selections/labels/H2H for skills + - preview bonuses come back from shared assembly + - mutation input contains `skillSelections` and omits flattened `skills`/`hthType` + - weighted costs and minimum-category errors are visible + - illegal selections cannot advance/submit + - review displays derived percentages and S.D.C. contribution range + + Run: + + ```powershell + vp test apps/web/tests/builder.test.ts apps/web/tests/character-sheet.test.ts + ``` + + Expected: FAIL on the old flattened input contract. + +- [ ] **Step 2: Refactor draft state and preview** + + Keep raw decisions in the store. Call the shared assembler reactively with O.C.C., alignment, and attributes; render its source-owned result. + +- [ ] **Step 3: Update the UI** + + Display typed option pools, repeatable labels, weighted costs, minimums, derived category/O.C.C. bonuses, and legality messages without parsing content notes. + +- [ ] **Step 4: Update submit input** + + Send authoritative selections only. Treat server rejection as a blocking build error. + +- [ ] **Step 5: Run focused gates and commit** + + ```powershell + vp test apps/web/tests/builder.test.ts apps/web/tests/character-sheet.test.ts + vp check apps/web + git add apps/web/src/builder/store.ts apps/web/src/builder/steps/occ-skills.tsx apps/web/src/builder/steps/related-skills.tsx apps/web/src/builder/steps/review.tsx apps/web/tests/builder.test.ts apps/web/tests/character-sheet.test.ts + git commit -m "feat(web): submit authoritative skill selections" + ``` + +--- + +## Task 7: Documentation, Full Verification, and Review + +**Files:** `README.md`, `docs/rules/PAGE_MAP.md`, issue gap documentation if present. + +- [ ] **Step 1: Update exact documentation** + + Document the authoritative selection boundary, legacy compatibility, independent Secondary Skills pool, additive S.D.C. breakdown, and rendered page citations. Correct PAGE_MAP only where the inspected printed pages prove a mismatch. + +- [ ] **Step 2: Run all fresh verification** + + ```powershell + vp install + vp check packages/rules + vp test packages/rules + vp check packages/backend + vp test packages/backend + vp check apps/web + vp test apps/web + vp check + vp test + git diff --check + git status --short + ``` + + Expected: all commands exit 0. + +- [ ] **Step 3: Perform live browser acceptance** + + Start the real web/backend environment using the repository's documented command. In a real browser: + + - create a legal Ley Line Walker + - choose repeatable labels, related skills, Secondary Skills, and H2H + - confirm O.C.C./category bonuses are derived, not editable + - include at least one Physical S.D.C. skill and verify the expanded range/roll + - attempt an illegal related or Secondary choice and prove submission is blocked + - reload the saved character and confirm selections, resolved percentages, and rolled/current S.D.C. + - verify no console errors + + Record exact browser observations and stop only processes attributable to this task. + +- [ ] **Step 4: Inspect tracker gaps without mutating them** + + Compare implementation/tests against #22, #25, and #26. Prepare exact recommended updates, but do not change issue state before PR delivery and user approval. + +- [ ] **Step 5: Request code review and address findings** + + Review the complete diff against issue #61, the approved design, and source citations. Fix all substantive findings and rerun the affected gates. + +- [ ] **Step 6: Commit documentation** + + ```powershell + git add README.md docs/rules/PAGE_MAP.md + git commit -m "docs: record skill legality and S.D.C. sources" + ``` + +- [ ] **Step 7: Present the workflow choice** + + Do not push or create a PR automatically. Present the required completion menu and follow the user's selected workflow. diff --git a/README.md b/README.md index 51e174e..733dec5 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,19 @@ Character identity is source-validated: Human is currently the sole playable species, while known but unimplemented identities remain unavailable rather than silently accepted. +The builder persists source choices—O.C.C. slots, O.C.C.-Related and independent +Secondary Skill picks, repeatable labels, category routes, and Hand-to-Hand +selection—rather than client-authored bonuses. The shared rules engine assembles +those choices from the complete printed skill catalog (RUE pp.304–330), validates +prerequisites and O.C.C. legality (pp.299–300), and derives the same result in the +preview, backend, and saved sheet. Pre-existing flattened characters remain +readable, but new and fully edited characters use the authoritative selection +contract. + +Physical S.D.C. is additive: the p.287 `2D6+12` base and every applicable +O.C.C./Physical-skill formula are exposed as a sourced breakdown, combined into +the preview range, and rolled server-side from that same contribution list. + ## Development Tooling is [Vite+](https://viteplus.dev) (`vp`) over a pnpm workspace. diff --git a/apps/web/src/builder/steps/occ-skills.tsx b/apps/web/src/builder/steps/occ-skills.tsx index 71d7ccd..37dd629 100644 --- a/apps/web/src/builder/steps/occ-skills.tsx +++ b/apps/web/src/builder/steps/occ-skills.tsx @@ -28,18 +28,29 @@ export function OccSkillsStep(props: { store: BuilderStore }) { const setLabel = (slot: OccSkillChoice, index: number, label: string) => { const next = [...picks(slot)]; - if (label.trim() === "") { - next.splice(index, 1); - } else if (index >= next.length) { + const trimmed = label.trim(); + if (index >= next.length) { + if (trimmed === "") return; // Append instead of writing past the end: filling input #2 before #1 // must not leave a hole that skill assembly would crash on. - next.push({ skillId: slot.options[0]!.id, label: label.trim() }); + next.push({ skillId: slot.options[0]!.id, label: trimmed }); } else { - next[index] = { skillId: slot.options[0]!.id, label: label.trim() }; + next[index] = { + skillId: slot.options[0]!.id, + ...(trimmed !== "" ? { label: trimmed } : {}), + }; } setPicks(slot, next); }; + const commitLabel = (slot: OccSkillChoice, index: number) => { + const next = [...picks(slot)]; + if (next[index]?.label === undefined) { + next.splice(index, 1); + setPicks(slot, next); + } + }; + const setPickLabel = (slot: OccSkillChoice, skillId: string, label: string) => setPicks( slot, @@ -122,7 +133,7 @@ export function OccSkillsStep(props: { store: BuilderStore }) { value={ picks(slot).find((p) => p.skillId === option.id)?.label ?? "" } - onChange={(e) => + onInput={(e) => setPickLabel(slot, option.id, e.currentTarget.value) } /> @@ -141,7 +152,8 @@ export function OccSkillsStep(props: { store: BuilderStore }) { setLabel(slot, index, e.currentTarget.value)} + onInput={(e) => setLabel(slot, index, e.currentTarget.value)} + onBlur={() => commitLabel(slot, index)} /> )} diff --git a/apps/web/src/builder/steps/related-skills.tsx b/apps/web/src/builder/steps/related-skills.tsx index 626b616..8e1e6a7 100644 --- a/apps/web/src/builder/steps/related-skills.tsx +++ b/apps/web/src/builder/steps/related-skills.tsx @@ -3,53 +3,137 @@ import { relatedSkillPlan, secondarySkillPlan, type Skill, + type SkillCategory, type SkillPick, } from "@riftforge/rules"; -import { createMemo, For, Show } from "solid-js"; -import { Alert, Panel } from "../../components/ui.tsx"; +import { createMemo, For, Index, Show } from "solid-js"; +import { Alert, Panel, TextInput } from "../../components/ui.tsx"; import type { BuilderStore } from "../store.ts"; -function groupByCategory(skills: Skill[]): [string, Skill[]][] { - const groups = new Map(); - for (const skill of skills) { - const list = groups.get(skill.category) ?? []; - list.push(skill); - groups.set(skill.category, list); +interface PoolOption { + skill: Skill; + category: SkillCategory; + bonus?: number; + cost: number; + persistCategory: boolean; +} + +function groupByCategory(options: PoolOption[]): [SkillCategory, PoolOption[]][] { + const groups = new Map(); + for (const option of options) { + const list = groups.get(option.category) ?? []; + list.push(option); + groups.set(option.category, list); } return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)); } +function matchesPick(option: PoolOption, pick: SkillPick): boolean { + return ( + option.skill.id === pick.skillId && + (!option.persistCategory || option.category === pick.category) + ); +} + function CheckboxPool(props: { - options: Skill[]; + options: PoolOption[]; picks: SkillPick[]; - max: number; - bonuses?: Record; - onToggle: (skillId: string) => void; + onToggle: (option: PoolOption) => void; + onAddRepeat: (option: PoolOption) => void; + onRemoveRepeat: (pickIndex: number) => void; + onLabel: (pickIndex: number, label: string) => void; }) { return (
- {([category, skills]) => ( + {([category, options]) => (

{category} - - +{props.bonuses![category]}% + + +{options[0]!.bonus}%

- - {(skill) => ( - - )} + + {(option) => { + const selected = () => + props.picks.flatMap((pick, pickIndex) => + matchesPick(option, pick) ? [{ pick, pickIndex }] : [], + ); + const descriptor = ( + <> + {option.skill.name}{" "} + (base {option.skill.baseSkill}%) + 1}> + [{option.cost} PICKS] + + + {(bonus) => [SECOND PICK +{bonus()}%]} + + + {(bonus) => +{bonus()} S.D.C.} + + + ); + return ( +
+ + 0} + onChange={() => props.onToggle(option)} + /> + {descriptor} + + } + > +
+ + {descriptor} +
+ + {(entry, selectionIndex) => ( +
+ + props.onLabel(entry().pickIndex, event.currentTarget.value) + } + /> + +
+ )} +
+
+
+ ); + }}
@@ -79,18 +163,93 @@ export function RelatedSkillsStep(props: { store: BuilderStore }) { const chosen = props.store.draft.hthId; const grant = hth(); if (!grant || chosen === undefined || chosen === grant.hthId) return 0; - return grant.upgrades.find((u) => u.hthId === chosen)?.cost ?? 0; + return grant.upgrades.find((upgrade) => upgrade.hthId === chosen)?.cost ?? 0; }); const relatedTarget = () => Math.max(0, (related()?.count ?? 0) - upgradeCost()); - const toggle = (field: "related" | "secondary", max: number) => (skillId: string) => { - const current = props.store.draft[field]; - const without = current.filter((p) => p.skillId !== skillId); + const relatedOptions = createMemo( + (): PoolOption[] => + related()?.variants.map((variant) => ({ + ...variant, + persistCategory: true, + })) ?? [], + ); + const secondaryOptions = createMemo( + (): PoolOption[] => + secondary()?.options.map((skill) => ({ + skill, + category: skill.category, + cost: 1, + persistCategory: false, + })) ?? [], + ); + + const spent = (field: "related" | "secondary", options: PoolOption[]) => + props.store.draft[field].reduce((total, pick) => { + const option = options.find((candidate) => matchesPick(candidate, pick)); + return total + (option?.cost ?? 0); + }, 0); + + const toggle = + (field: "related" | "secondary", budget: number, options: PoolOption[]) => + (option: PoolOption) => { + const current = props.store.draft[field]; + const chosen = current.some((pick) => matchesPick(option, pick)); + const existing = options.find((candidate) => + current.some((pick) => pick.skillId === option.skill.id && matchesPick(candidate, pick)), + ); + const withoutSkill = current.filter((pick) => pick.skillId !== option.skill.id); + if (chosen) { + props.store.setDraft(field, withoutSkill); + return; + } + const nextSpent = spent(field, options) - (existing?.cost ?? 0) + option.cost; + if (nextSpent > budget) return; + props.store.setDraft(field, [ + ...withoutSkill, + { + skillId: option.skill.id, + ...(option.persistCategory ? { category: option.category } : {}), + }, + ]); + }; + + const addRepeat = + (field: "related" | "secondary", budget: number, options: PoolOption[]) => + (option: PoolOption) => { + const current = props.store.draft[field]; + const selectedCount = current.filter((pick) => pick.skillId === option.skill.id).length; + if (option.skill.maxSelections !== undefined && selectedCount >= option.skill.maxSelections) { + return; + } + if (spent(field, options) + option.cost > budget) return; + props.store.setDraft(field, [ + ...current, + { + skillId: option.skill.id, + ...(option.persistCategory ? { category: option.category } : {}), + }, + ]); + }; + + const removeRepeat = (field: "related" | "secondary") => (pickIndex: number) => { + props.store.setDraft( + field, + props.store.draft[field].filter((_, index) => index !== pickIndex), + ); + }; + + const setLabel = (field: "related" | "secondary") => (pickIndex: number, label: string) => { props.store.setDraft( field, - without.length < current.length || current.length >= max - ? without - : [...current, { skillId }], + props.store.draft[field].map((pick, index) => + index === pickIndex + ? { + ...pick, + ...(label.trim() === "" ? { label: undefined } : { label: label.trim() }), + } + : pick, + ), ); }; @@ -154,17 +313,21 @@ export function RelatedSkillsStep(props: { store: BuilderStore }) { {(plan) => (

- // O.C.C. RELATED SKILLS — PICK {relatedTarget()}{" "} - {countBadge(props.store.draft.related.length, relatedTarget())} + // O.C.C. RELATED SKILLS — SPEND {relatedTarget()}{" "} + {countBadge(spent("related", relatedOptions()), relatedTarget())}

    {(constraint) => { const have = () => - props.store.draft.related.filter((p) => + props.store.draft.related.filter((pick) => plan() - .options.filter((s) => s.category === constraint.fromCategory) - .some((s) => s.id === p.skillId), + .variants.filter((variant) => variant.category === constraint.fromCategory) + .some( + (variant) => + variant.skill.id === pick.skillId && + (pick.category === undefined || pick.category === variant.category), + ), ).length; return (
)} @@ -196,16 +360,18 @@ export function RelatedSkillsStep(props: { store: BuilderStore }) {

// SECONDARY SKILLS — PICK {plan().count}{" "} - {countBadge(props.store.draft.secondary.length, plan().count)} + {countBadge(spent("secondary", secondaryOptions()), plan().count)}

- // no O.C.C. bonuses apply to secondary skills + // independent pool; no O.C.C. bonuses apply

)} diff --git a/apps/web/src/builder/store.ts b/apps/web/src/builder/store.ts index 33c1fb3..aed27db 100644 --- a/apps/web/src/builder/store.ts +++ b/apps/web/src/builder/store.ts @@ -103,8 +103,7 @@ export function createBuilderStore(): BuilderStore { const characterInput = createMemo((): (CharacterInput & { speciesId: string }) | undefined => { const attributes = attributeTotals(); - const skills = assembled(); - if (!draft.occId || !attributes || !skills) return undefined; + if (!draft.occId || !attributes || !assembled()) return undefined; const narrative = toNarrative(draft.narrative); return { name: draft.name.trim(), @@ -113,9 +112,13 @@ export function createBuilderStore(): BuilderStore { ...(draft.alignmentId !== undefined ? { alignmentId: draft.alignmentId } : {}), level: 1, attributes, - hthType: skills.hthType, psychicClass: draft.psychicClass, - skills: skills.skills, + skillSelections: { + occChoices: draft.occChoices, + related: draft.related, + secondary: draft.secondary, + ...(draft.hthId !== undefined ? { hthId: draft.hthId } : {}), + }, spellIds: draft.spellIds, ...(narrative !== undefined ? { narrative } : {}), }; diff --git a/apps/web/src/components/sheet-view.tsx b/apps/web/src/components/sheet-view.tsx index 4165509..100020f 100644 --- a/apps/web/src/components/sheet-view.tsx +++ b/apps/web/src/components/sheet-view.tsx @@ -449,6 +449,22 @@ export function SheetView(props: {
+
+ PHYSICAL S.D.C. SOURCES + + {(contribution) => ( + + {contribution.name}{" "} + RUE P.{contribution.source.page} + + } + value={contribution.formula} + /> + )} + +
{(armor) => ( ({ total: dice.reduce((sum, die) => sum + die, 0), @@ -90,3 +92,101 @@ describe("builder species identity", () => { expect(identitySource).not.toContain('name="species"'); }); }); + +describe("builder authoritative skill payload", () => { + test("updates repeatable labels on each input event", () => { + expect(occSkillsSource).toContain( + "onInput={(e) => setLabel(slot, index, e.currentTarget.value)}", + ); + expect(occSkillsSource).toMatch( + /onInput=\{\(e\) =>\s+setPickLabel\(slot, option\.id, e\.currentTarget\.value\)/, + ); + expect(relatedSkillsSource).toContain("addRepeat"); + expect(relatedSkillsSource).toContain("removeRepeat"); + expect(relatedSkillsSource).toContain("SPECIALIZATION"); + }); + + test("submits player decisions and never flattened bonuses or Hand-to-Hand metadata", () => { + createRoot((dispose) => { + const store = createBuilderStore(); + store.setDraft("name", "Vesper"); + store.setDraft("attributes", legalLeyLineWalkerAttributes); + store.setDraft("occId", "ley-line-walker"); + store.setDraft("alignmentId", "scrupulous"); + store.setDraft("occChoices", { + "language-other": [ + { skillId: "language-other", label: "Dragonese" }, + { skillId: "language-other", label: "Euro" }, + ], + "category:Pilot": [{ skillId: "pilot-hovercycle" }], + lore: [ + { skillId: "lore-demons-monsters", label: "Vampires" }, + { skillId: "lore-american-indians" }, + { skillId: "lore-cattle-animals" }, + { skillId: "lore-dbee" }, + ], + }); + store.setDraft("related", [ + { skillId: "math-advanced" }, + { skillId: "biology" }, + { skillId: "history-pre-rifts" }, + { skillId: "body-building-weight-lifting" }, + { skillId: "dowsing" }, + { skillId: "pilot-automobile" }, + { skillId: "first-aid" }, + ]); + store.setDraft("secondary", [ + { skillId: "carpentry" }, + { skillId: "fasting" }, + { skillId: "preserve-food" }, + { skillId: "identify-plants-fruit" }, + { skillId: "gambling-standard" }, + { skillId: "radio-basic" }, + ]); + store.setDraft("hthId", "basic"); + + const input = store.characterInput(); + expect(input).toMatchObject({ + skillSelections: { + occChoices: store.draft.occChoices, + related: store.draft.related, + secondary: store.draft.secondary, + hthId: "basic", + }, + }); + expect(input).not.toHaveProperty("skills"); + expect(input).not.toHaveProperty("hthType"); + expect( + store.assembled()?.skills.find((skill) => skill.skillId === "math-advanced"), + ).toMatchObject({ categoryBonus: 10 }); + expect(store.preview().sheet?.vitals).toMatchObject({ + sdc: { min: 24, max: 34 }, + sdcBreakdown: [ + { kind: "base", formula: "2D6+12", source: { page: 287 } }, + { + kind: "skill", + id: "body-building-weight-lifting", + formula: "10", + source: { page: 316 }, + }, + ], + }); + dispose(); + }); + }); + + test("preserves the selected category for a multi-category related skill", () => { + createRoot((dispose) => { + const store = createBuilderStore(); + store.setDraft("name", "Vesper"); + store.setDraft("attributes", legalLeyLineWalkerAttributes); + store.setDraft("occId", "ley-line-walker"); + store.setDraft("related", [{ skillId: "recycling", category: "Technical" }]); + + expect(store.characterInput()?.skillSelections?.related).toEqual([ + { skillId: "recycling", category: "Technical" }, + ]); + dispose(); + }); + }); +}); diff --git a/apps/web/tests/character-sheet.test.ts b/apps/web/tests/character-sheet.test.ts index 38e1ed2..b5e3f89 100644 --- a/apps/web/tests/character-sheet.test.ts +++ b/apps/web/tests/character-sheet.test.ts @@ -43,6 +43,13 @@ describe("application shell", () => { }); describe("terminal sheet dossier", () => { + test("shows the source-owned additive Physical S.D.C. contribution list", () => { + expect(sheetViewSource).toContain("PHYSICAL S.D.C. SOURCES"); + expect(sheetViewSource).toContain("s().vitals.sdcBreakdown"); + expect(sheetViewSource).toContain("contribution.formula"); + expect(sheetViewSource).toContain("RUE P.{contribution.source.page}"); + }); + test("shows resolved species in the dossier identity line", () => { expect(sheetViewSource).toContain("s().species.name.toUpperCase()"); }); diff --git a/docs/rules/PAGE_MAP.md b/docs/rules/PAGE_MAP.md index e61048e..456a216 100644 --- a/docs/rules/PAGE_MAP.md +++ b/docs/rules/PAGE_MAP.md @@ -33,15 +33,30 @@ images using the maintained inline rendering and vision workflow in ## Skills (printed 298+) -| Section | Printed pp. | PDF idx | -| --------------------------------------------------------------------- | ----------- | ------- | -| Skill Rules (Selecting, Bonuses, Penalties) | 298–301 | 300–303 | -| — O.C.C. / O.C.C.-Related / Secondary Skills | 299–300 | 301–302 | -| Skill List | 302 | 304 | -| Skill Descriptions (all categories) | 304–329 | 306–331 | -| — Communication 304 · Domestic 307 · Electrical 308 · Espionage 308 | | | -| — Mechanical 312 · Medical 313 · Physical 316 · Pilot 318 · Rogue 320 | | | -| — Science 321 · Technical 323 · W.P. 326 · Wilderness 329 | | | +| Section | Printed pp. | PDF idx | +| ----------------------------------------------------------- | ----------- | ------- | +| Skill Rules (Selecting, Bonuses, Penalties) | 298–301 | 300–303 | +| — O.C.C. / O.C.C.-Related / Secondary Skills | 299–300 | 301–302 | +| Skill List | 302 | 304 | +| Skill Descriptions (all categories) | 304–330 | 306–332 | +| — Communication 304–305 · Cowboy 306 · Domestic 307–308 | | | +| — Electrical 308 · Espionage 308–310 · Horsemanship 310–311 | | | +| — Mechanical 312–313 · Medical 313–314 · Military 314–316 | | | +| — Physical 316–317 · Pilot 318–320 · Pilot Related 320 | | | +| — Rogue 320–321 · Science 321–322 · Technical 323–326 | | | +| — W.P. 326–329 · Wilderness 329–330 | | | + +Implemented skill-legality coverage is anchored to the rendered printed pages: + +- p.299: O.C.C. skills are automatic, O.C.C.-starting prerequisites are supplied + as part of training, and applicable bonuses are cumulative. +- p.300: Secondary Skills use their own selection pool and do not receive + O.C.C.-Related bonuses. +- pp.304–330: the complete printed skill headings, categories, prerequisites, + percentages, and Physical-skill S.D.C. formulas are represented in typed + content. +- p.287: Physical S.D.C. starts at `2D6+12`; the engine adds source-owned + O.C.C./skill contributions to both its range and server-side roll. ## Combat & Saving Throws (printed 339+) diff --git a/packages/backend/convex/characters.ts b/packages/backend/convex/characters.ts index e23edee..83a1849 100644 --- a/packages/backend/convex/characters.ts +++ b/packages/backend/convex/characters.ts @@ -40,7 +40,6 @@ const characterDoc = v.object({ const characterInputFields = { ...characterFields, psychicClass: v.optional(characterFields.psychicClass), - skills: v.optional(characterFields.skills), spellIds: v.optional(characterFields.spellIds), }; @@ -49,11 +48,32 @@ const newCharacterInputFields = { speciesId: v.string(), }; +function validateAuthoritativeCharacter(input: unknown) { + if ( + typeof input !== "object" || + input === null || + !("skillSelections" in input) || + input.skillSelections === undefined + ) { + throw new Error("New and fully edited characters require authoritative skill selections."); + } + if ("skills" in input || "hthType" in input) { + throw new Error( + "Authoritative character writes cannot include legacy skills or hthType metadata.", + ); + } + const parsed = validateCharacter(input); + const { skills, hthType, ...choices } = parsed; + void skills; + void hthType; + return choices; +} + export const create = mutation({ args: newCharacterInputFields, returns: v.id("characters"), handler: async (ctx, args) => { - return await ctx.db.insert("characters", validateCharacter(args)); + return await ctx.db.insert("characters", validateAuthoritativeCharacter(args)); }, }); @@ -63,7 +83,7 @@ export const update = mutation({ handler: async (ctx, { id, character }) => { const existing = await loadCharacter(ctx, id); requireLiving(existing, "be updated"); - await ctx.db.replace(id, validateCharacter(character)); + await ctx.db.replace(id, validateAuthoritativeCharacter(character)); return null; }, }); @@ -112,10 +132,13 @@ export const rollVitals = mutation({ requireLiving(character, "roll vitals"); const occ = getOcc(character.occId); if (!occ) throw new Error(`Unknown O.C.C. "${character.occId}".`); + const sheet = deriveSheet(character); const pe = character.attributes.PE; const rolled = { hitPoints: rollHitPoints(pe, character.level), - sdc: rollPhysicalSdc(), + sdc: rollPhysicalSdc( + sheet.vitals.sdcBreakdown.slice(1).map((contribution) => contribution.formula), + ), ...(occ.ppe ? { ppe: rollPpe(occ, pe, character.level) } : {}), }; // New maximums invalidate the old live state — a reroll is a fresh start, diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 834589d..d1395ed 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -2,6 +2,40 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; import { combatExchangeValidator } from "./combat_values"; +const skillCategoryValidator = v.union( + v.literal("Communication"), + v.literal("Cowboy"), + v.literal("Domestic"), + v.literal("Electrical"), + v.literal("Espionage"), + v.literal("Horsemanship"), + v.literal("Mechanical"), + v.literal("Medical"), + v.literal("Military"), + v.literal("Physical"), + v.literal("Pilot"), + v.literal("Pilot Related"), + v.literal("Rogue"), + v.literal("Science"), + v.literal("Technical"), + v.literal("W.P. Ancient"), + v.literal("W.P. Modern"), + v.literal("Wilderness"), +); + +const skillPickValidator = v.object({ + skillId: v.string(), + label: v.optional(v.string()), + category: v.optional(skillCategoryValidator), +}); + +export const skillSelectionsValidator = v.object({ + occChoices: v.record(v.string(), v.array(skillPickValidator)), + related: v.array(skillPickValidator), + secondary: v.array(skillPickValidator), + hthId: v.optional(v.string()), +}); + /** * Convex validators mirroring `characterSchema` from @riftforge/rules. * The table stores the player's *choices* (a parsed `Character`); derived @@ -26,21 +60,26 @@ export const characterFields = { PB: v.number(), Spd: v.number(), }), - hthType: v.string(), + /** Legacy flattened field; authoritative new builds use skillSelections. */ + hthType: v.optional(v.string()), psychicClass: v.union( v.literal("masterPsychic"), v.literal("majorOrMinorPsychic"), v.literal("ordinary"), ), - skills: v.array( - v.object({ - skillId: v.string(), - occBonus: v.optional(v.number()), - categoryBonus: v.optional(v.number()), - overrideValue: v.optional(v.number()), - label: v.optional(v.string()), - }), + /** Legacy flattened field; preserved for old documents and narrow writes. */ + skills: v.optional( + v.array( + v.object({ + skillId: v.string(), + occBonus: v.optional(v.number()), + categoryBonus: v.optional(v.number()), + overrideValue: v.optional(v.number()), + label: v.optional(v.string()), + }), + ), ), + skillSelections: v.optional(skillSelectionsValidator), 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 diff --git a/packages/backend/tests/characters.test.ts b/packages/backend/tests/characters.test.ts index 7a5cff6..3df208c 100644 --- a/packages/backend/tests/characters.test.ts +++ b/packages/backend/tests/characters.test.ts @@ -8,8 +8,8 @@ const modules = { ...import.meta.glob("../convex/_generated/*.js"), }; -/** The same level-1 Ley Line Walker the rules package pins in its tests. */ -const vesper = { +/** A pre-#61 flattened document retained for compatibility coverage. */ +const legacyVesper = { name: "Vesper", occId: "ley-line-walker", speciesId: "human", @@ -25,11 +25,61 @@ const vesper = { spellIds: ["globe-of-daylight", "energy-bolt", "armor-of-ithan"], }; +/** A new authoritative build: decisions only, no client-authored bonuses. */ +const vesper = { + name: "Vesper", + occId: "ley-line-walker", + speciesId: "human", + alignmentId: "scrupulous", + level: 1, + attributes: { IQ: 18, ME: 16, MA: 12, PS: 16, PP: 20, PE: 14, PB: 11, Spd: 12 }, + psychicClass: "ordinary" as const, + skillSelections: { + occChoices: { + "language-other": [ + { skillId: "language-other", label: "Dragonese" }, + { skillId: "language-other", label: "Euro" }, + ], + "category:Pilot": [{ skillId: "pilot-hovercycle" }], + lore: [ + { skillId: "lore-demons-monsters", label: "Vampires" }, + { skillId: "lore-american-indians" }, + { skillId: "lore-cattle-animals" }, + { skillId: "lore-dbee" }, + ], + }, + related: [ + { skillId: "math-advanced" }, + { skillId: "biology" }, + { skillId: "history-pre-rifts" }, + { skillId: "track-trap-animals" }, + { skillId: "dowsing" }, + { skillId: "pilot-automobile" }, + { skillId: "first-aid" }, + ], + secondary: [ + { skillId: "carpentry" }, + { skillId: "fasting" }, + { skillId: "preserve-food" }, + { skillId: "identify-plants-fruit" }, + { skillId: "gambling-standard" }, + { skillId: "radio-basic" }, + ], + hthId: "basic", + }, + spellIds: ["globe-of-daylight", "energy-bolt", "armor-of-ithan"], +}; + describe("characters — a saved Ley Line Walker round-trips", () => { test("create requires and stores explicit Human identity", async () => { const t = convexTest(schema, modules); const id = await t.mutation(api.characters.create, vesper); expect(await t.query(api.characters.get, { id })).toMatchObject({ speciesId: "human" }); + expect(await t.query(api.characters.get, { id })).toMatchObject({ + skillSelections: vesper.skillSelections, + }); + expect(await t.query(api.characters.get, { id })).not.toHaveProperty("skills"); + expect(await t.query(api.characters.get, { id })).not.toHaveProperty("hthType"); expect(await t.query(api.characters.sheet, { id })).toMatchObject({ species: { id: "human", name: "Human" }, }); @@ -38,6 +88,33 @@ describe("characters — a saved Ley Line Walker round-trips", () => { await expect(t.mutation(api.characters.create, missing as typeof vesper)).rejects.toThrow(); }); + test("new/full writes reject flattened or tampered skill metadata", async () => { + const t = convexTest(schema, modules); + await expect( + t.mutation(api.characters.create, legacyVesper as unknown as typeof vesper), + ).rejects.toThrow(/authoritative skill selections/i); + await expect( + t.mutation(api.characters.create, { + ...vesper, + skillSelections: { + ...vesper.skillSelections, + related: [ + ...vesper.skillSelections.related.slice(0, 6), + { skillId: "first-aid", occBonus: 999 }, + ], + }, + } as typeof vesper), + ).rejects.toThrow(); + + const id = await t.mutation(api.characters.create, vesper); + await expect( + t.mutation(api.characters.update, { + id, + character: legacyVesper as unknown as typeof vesper, + }), + ).rejects.toThrow(/authoritative skill selections/i); + }); + test("backend rejects unknown, unavailable, and attribute-ineligible writes", async () => { const t = convexTest(schema, modules); await expect( @@ -65,7 +142,7 @@ describe("characters — a saved Ley Line Walker round-trips", () => { test("legacy storage without speciesId derives Human without a read-time rewrite", async () => { const t = convexTest(schema, modules); - const { speciesId: _speciesId, ...legacy } = vesper; + const { speciesId: _speciesId, ...legacy } = legacyVesper; const id = await t.run((ctx) => ctx.db.insert("characters", legacy)); expect(await t.query(api.characters.sheet, { id })).toMatchObject({ @@ -74,6 +151,24 @@ describe("characters — a saved Ley Line Walker round-trips", () => { expect(await t.query(api.characters.get, { id })).not.toHaveProperty("speciesId"); }); + test("a pre-#61 Physical-skill S.D.C. roll remains readable and can be rerolled", async () => { + const t = convexTest(schema, modules); + const id = await t.run((ctx) => + ctx.db.insert("characters", { + ...legacyVesper, + skills: [{ skillId: "body-building-weight-lifting" }], + rolled: { sdc: 14 }, + }), + ); + + expect(await t.query(api.characters.sheet, { id })).toMatchObject({ + vitals: { sdc: { min: 24, max: 34, rolled: 14 } }, + }); + const rerolled = await t.mutation(api.characters.rollVitals, { id }); + expect(rerolled.sdc).toBeGreaterThanOrEqual(24); + expect(rerolled.sdc).toBeLessThanOrEqual(34); + }); + test("create → get returns the stored choices", async () => { const t = convexTest(schema, modules); const id = await t.mutation(api.characters.create, vesper); @@ -156,6 +251,31 @@ describe("characters — a saved Ley Line Walker round-trips", () => { expect(stored?.rolled).toEqual(second); }); + test("rollVitals includes every selected Physical skill contribution", async () => { + const t = convexTest(schema, modules); + const physical = { + ...vesper, + skillSelections: { + ...vesper.skillSelections, + secondary: [ + ...vesper.skillSelections.secondary.slice(0, 4), + { skillId: "body-building-weight-lifting" }, + { skillId: "running" }, + ], + }, + }; + const id = await t.mutation(api.characters.create, physical); + const rolled = await t.mutation(api.characters.rollVitals, { id }); + expect(rolled.sdc).toBeGreaterThanOrEqual(25); + expect(rolled.sdc).toBeLessThanOrEqual(40); + const sheet = await t.query(api.characters.sheet, { id }); + expect(sheet?.vitals.sdcBreakdown.map((entry: { formula: string }) => entry.formula)).toEqual([ + "2D6+12", + "10", + "1D6", + ]); + }); + test("narrative round-trips: create with it, edit it narrowly, sheet carries it", async () => { const t = convexTest(schema, modules); const id = await t.mutation(api.characters.create, { diff --git a/packages/backend/tests/combat.test.ts b/packages/backend/tests/combat.test.ts index 9b02e5d..2a42127 100644 --- a/packages/backend/tests/combat.test.ts +++ b/packages/backend/tests/combat.test.ts @@ -4,7 +4,7 @@ import { deriveAttackProfile, deriveSheet, itemCatalog, - type Character, + type CharacterInput, type CombatContext, type CombatExchangeErrorCode, } from "@riftforge/rules"; @@ -19,19 +19,74 @@ const modules = { ...import.meta.glob("../convex/_generated/*.js"), }; -const character: Character = { +type AuthoritativeCharacterInput = Omit< + CharacterInput, + "hthType" | "psychicClass" | "skillSelections" | "skills" | "speciesId" | "spellIds" +> & { + speciesId: string; + psychicClass: NonNullable; + skillSelections: NonNullable; + spellIds: string[]; +}; + +const character: AuthoritativeCharacterInput = { name: "Vesper", occId: "ley-line-walker", speciesId: "human", + alignmentId: "scrupulous", level: 1, attributes: { IQ: 18, ME: 16, MA: 12, PS: 16, PP: 20, PE: 14, PB: 11, Spd: 12 }, - hthType: "basic", psychicClass: "ordinary" as const, - skills: [], + skillSelections: { + occChoices: { + "language-other": [ + { skillId: "language-other", label: "Dragonese" }, + { skillId: "language-other", label: "Euro" }, + ], + "category:Pilot": [{ skillId: "pilot-hovercycle" }], + lore: [ + { skillId: "lore-demons-monsters", label: "Vampires" }, + { skillId: "lore-american-indians" }, + { skillId: "lore-cattle-animals" }, + { skillId: "lore-dbee" }, + ], + }, + related: [ + { skillId: "math-advanced" }, + { skillId: "biology" }, + { skillId: "history-pre-rifts" }, + { skillId: "track-trap-animals" }, + { skillId: "dowsing" }, + { skillId: "pilot-automobile" }, + { skillId: "first-aid" }, + ], + secondary: [ + { skillId: "carpentry" }, + { skillId: "fasting" }, + { skillId: "preserve-food" }, + { skillId: "identify-plants-fruit" }, + { skillId: "gambling-standard" }, + { skillId: "radio-basic" }, + ], + hthId: "basic", + }, spellIds: [], items: [], }; +const highSdcSelections = { + ...character.skillSelections!, + related: [ + { skillId: "math-advanced" }, + { skillId: "biology" }, + { skillId: "history-pre-rifts" }, + { skillId: "body-building-weight-lifting" }, + { skillId: "boxing" }, + { skillId: "kick-boxing" }, + { skillId: "physical-labor" }, + ], +}; + const ready = { hitPoints: 18, sdc: 20 }; const meleeContext = { kind: "melee" as const, @@ -45,8 +100,34 @@ function testDb() { type TestDb = ReturnType; -async function createCharacter(t: TestDb, overrides: Partial = {}) { - return t.mutation(api.characters.create, { ...character, ...overrides }); +async function createCharacter( + t: TestDb, + overrides: Partial & { hthType?: string } = {}, +) { + const { hthType, ...authoritativeOverrides } = overrides; + if (hthType === "commando") { + return t.run((ctx) => + ctx.db.insert("characters", { + ...character, + ...authoritativeOverrides, + skillSelections: undefined, + hthType, + skills: [], + }), + ); + } + return t.mutation(api.characters.create, { + ...character, + ...authoritativeOverrides, + ...(hthType === undefined + ? {} + : { + skillSelections: { + ...character.skillSelections!, + hthId: hthType, + }, + }), + }); } async function exchangeCount(t: TestDb): Promise { @@ -57,6 +138,22 @@ async function getCharacter(t: TestDb, id: Id<"characters">) { return t.run((ctx) => ctx.db.get(id)); } +async function changeHandToHand(t: TestDb, id: Id<"characters">, hthId: string) { + return t.run(async (ctx) => { + const stored = await ctx.db.get(id); + if (stored?.skillSelections === undefined) { + throw new Error("Expected authoritative skill selections."); + } + await ctx.db.patch(id, { + skillSelections: { + ...stored.skillSelections, + related: stored.skillSelections.related.slice(0, 6), + hthId, + }, + }); + }); +} + async function declarePending( t: TestDb, attackerId: Id<"characters">, @@ -747,13 +844,17 @@ describe("combat attack declaration", () => { "persists a legal M.D. $0 declaration after exactly one strike roll", async (_label, itemId, category, damageFormula) => { const t = testDb(); - const attacker: Character = { + const attacker: CharacterInput = { ...character, name: "M.D. attacker", rolled: ready, items: [{ itemId }], }; - const defender: Character = { ...character, name: "M.D. defender", rolled: ready }; + const defender: AuthoritativeCharacterInput = { + ...character, + name: "M.D. defender", + rolled: ready, + }; const attackerId = await createCharacter(t, attacker); const defenderId = await createCharacter(t, defender); const random = vi.spyOn(Math, "random").mockReturnValue(0.5); @@ -988,13 +1089,17 @@ describe("combat attack declaration", () => { test("persists a pending declaration with exact snapshots, tokens, and explicit no-defense", async () => { const t = testDb(); - const attacker: Character = { + const attacker: CharacterInput = { ...character, name: "Attacker", rolled: ready, items: [{ itemId: "survival-knife" }], }; - const defender: Character = { ...character, name: "Defender", rolled: ready }; + const defender: AuthoritativeCharacterInput = { + ...character, + name: "Defender", + rolled: ready, + }; const attackerId = await createCharacter(t, attacker); const defenderId = await createCharacter(t, defender); const context = { @@ -1322,7 +1427,8 @@ describe("atomic tiered combat response persistence", () => { items: [{ itemId: "wilks-320-laser-pistol" }], }); const defenderId = await createCharacter(t, { - rolled: { hitPoints: 18, sdc: 120 }, + rolled: { hitPoints: 18, sdc: 75 }, + skillSelections: highSdcSelections, items: [{ itemId: "gladiator", worn: true }], current: { armor: 0 }, }); @@ -1354,24 +1460,24 @@ describe("atomic tiered combat response persistence", () => { after: 0, }, body: { - before: { sdc: 120, hitPoints: 18 }, - after: { sdc: 20, hitPoints: 18 }, + before: { sdc: 75, hitPoints: 18 }, + after: { sdc: 0, hitPoints: -7 }, }, - lifeState: { before: "alive", after: "alive" }, + lifeState: { before: "alive", after: "coma" }, }); expect((await getCharacter(t, defenderId))?.current).toEqual({ armor: 0, - sdc: 20, - hitPoints: 18, + sdc: 0, + hitPoints: -7, }); }); test.each([ - [12, "armor", 0, 120], - [13, "body", 10, 20], + [12, "armor", 0, 75, 18], + [13, "body", 10, 0, -7], ] as const)( "routes M.D. at strike total %i against S.D.C. armor by A.R. as %s", - async (strikeTotal, expectedKind, expectedArmor, expectedSdc) => + async (strikeTotal, expectedKind, expectedArmor, expectedSdc, expectedHitPoints) => withCatalogFixture( "gladiator", (item) => { @@ -1386,7 +1492,8 @@ describe("atomic tiered combat response persistence", () => { items: [{ itemId: "wilks-320-laser-pistol" }], }); const defenderId = await createCharacter(t, { - rolled: { hitPoints: 18, sdc: 120 }, + rolled: { hitPoints: 18, sdc: 75 }, + skillSelections: highSdcSelections, items: [{ itemId: "gladiator", worn: true }], current: { armor: 10 }, }); @@ -1420,8 +1527,8 @@ describe("atomic tiered combat response persistence", () => { after: 0, }, body: { - before: { sdc: 120, hitPoints: 18 }, - after: { sdc: 120, hitPoints: 18 }, + before: { sdc: 75, hitPoints: 18 }, + after: { sdc: 75, hitPoints: 18 }, }, finalBlastAbsorbed: true, } @@ -1438,16 +1545,16 @@ describe("atomic tiered combat response persistence", () => { after: 10, }, body: { - before: { sdc: 120, hitPoints: 18 }, - after: { sdc: 20, hitPoints: 18 }, + before: { sdc: 75, hitPoints: 18 }, + after: { sdc: 0, hitPoints: -7 }, }, - lifeState: { before: "alive", after: "alive" }, + lifeState: { before: "alive", after: "coma" }, }, ); expect((await getCharacter(t, defenderId))?.current).toEqual( expectedKind === "armor" ? { armor: expectedArmor } - : { armor: expectedArmor, sdc: expectedSdc, hitPoints: 18 }, + : { armor: expectedArmor, sdc: expectedSdc, hitPoints: expectedHitPoints }, ); }, ), @@ -1473,7 +1580,8 @@ describe("atomic tiered combat response persistence", () => { items: [{ itemId: "survival-knife" }], }); const defenderId = await createCharacter(t, { - rolled: { hitPoints: 18, sdc: 0 }, + rolled: ready, + current: { sdc: 0 }, }); const pending = await declarePendingAtTotal( t, @@ -2176,8 +2284,7 @@ describe("combat response stale-state finalization", () => { ], [ "Hand-to-Hand profile", - async (t: TestDb, id: Id<"characters">) => - t.run((ctx) => ctx.db.patch(id, { hthType: "none" })), + async (t: TestDb, id: Id<"characters">) => changeHandToHand(t, id, "expert"), ], [ "selected weapon", @@ -2229,8 +2336,7 @@ describe("combat response stale-state finalization", () => { ], [ "defense profile", - async (t: TestDb, id: Id<"characters">) => - t.run((ctx) => ctx.db.patch(id, { hthType: "none" })), + async (t: TestDb, id: Id<"characters">) => changeHandToHand(t, id, "expert"), ], [ "worn armor", diff --git a/packages/backend/tests/healing-cast.test.ts b/packages/backend/tests/healing-cast.test.ts index 0284d6d..60f1729 100644 --- a/packages/backend/tests/healing-cast.test.ts +++ b/packages/backend/tests/healing-cast.test.ts @@ -15,11 +15,43 @@ const vesper = { name: "Vesper", occId: "ley-line-walker", speciesId: "human", + alignmentId: "scrupulous", level: 1, attributes: { IQ: 18, ME: 16, MA: 12, PS: 16, PP: 20, PE: 14, PB: 11, Spd: 12 }, - hthType: "basic", psychicClass: "ordinary" as const, - skills: [], + skillSelections: { + occChoices: { + "language-other": [ + { skillId: "language-other", label: "Dragonese" }, + { skillId: "language-other", label: "Euro" }, + ], + "category:Pilot": [{ skillId: "pilot-hovercycle" }], + lore: [ + { skillId: "lore-demons-monsters", label: "Vampires" }, + { skillId: "lore-american-indians" }, + { skillId: "lore-cattle-animals" }, + { skillId: "lore-dbee" }, + ], + }, + related: [ + { skillId: "math-advanced" }, + { skillId: "biology" }, + { skillId: "history-pre-rifts" }, + { skillId: "track-trap-animals" }, + { skillId: "dowsing" }, + { skillId: "pilot-automobile" }, + { skillId: "first-aid" }, + ], + secondary: [ + { skillId: "carpentry" }, + { skillId: "fasting" }, + { skillId: "preserve-food" }, + { skillId: "identify-plants-fruit" }, + { skillId: "gambling-standard" }, + { skillId: "radio-basic" }, + ], + hthId: "basic", + }, spellIds: [ "energy-bolt", "heal-wounds", diff --git a/packages/rules/src/content/occ/ley-line-walker.json b/packages/rules/src/content/occ/ley-line-walker.json index 2101b37..6f70dc2 100644 --- a/packages/rules/src/content/occ/ley-line-walker.json +++ b/packages/rules/src/content/occ/ley-line-walker.json @@ -170,33 +170,82 @@ ], "occSkills": [ { - "skill": "Language: Native Tongue", + "kind": "override", "skillId": "language-native-tongue", - "atLevel1": 98, - "unit": "percent", - "fixed": true + "value": 98 }, - { "skill": "Language: Other", "skillId": "language-other", "choose": 2, "occBonus": 20 }, - { "skill": "Climbing", "skillId": "climbing", "occBonus": 5 }, - { "skill": "Math: Basic", "skillId": "math-basic", "occBonus": 10 }, - { "skill": "Land Navigation", "skillId": "land-navigation", "occBonus": 4 }, - { "skill": "Wilderness Survival", "skillId": "wilderness-survival", "occBonus": 10 }, - { "skill": "Pilot", "chooseFromCategory": "Pilot", "choose": 1, "occBonus": 5 }, - { "skill": "Lore: Demon & Monster", "skillId": "lore-demons-monsters", "occBonus": 15 }, { - "skill": "Lore", - "skillPrefix": "Lore", + "kind": "repeatableChoice", + "key": "language-other", + "label": "Language: Other", + "skillId": "language-other", + "choose": 2, + "occBonus": 20 + }, + { + "kind": "fixed", + "skillId": "climbing", + "occBonus": 5 + }, + { + "kind": "fixed", + "skillId": "math-basic", + "occBonus": 10 + }, + { + "kind": "fixed", + "skillId": "land-navigation", + "occBonus": 4 + }, + { + "kind": "fixed", + "skillId": "wilderness-survival", + "occBonus": 10 + }, + { + "kind": "choice", + "key": "category:Pilot", + "label": "Pilot", + "choose": 1, + "pool": { + "kind": "category", + "category": "Pilot", + "policy": { "kind": "any" } + }, + "occBonus": 5 + }, + { + "kind": "fixed", + "skillId": "lore-demons-monsters", + "occBonus": 15 + }, + { + "kind": "choice", + "key": "lore", + "label": "Lore", "choose": 4, - "occBonus": 10, - "note": "Any Lore skills." + "pool": { + "kind": "skills", + "skillIds": [ + "lore-american-indians", + "lore-cattle-animals", + "lore-dbee", + "lore-demons-monsters", + "lore-faeries-creatures-magic", + "lore-juicers", + "lore-magic", + "lore-psychics-psionics" + ] + }, + "occBonus": 10 }, { - "skill": "Hand to Hand: Basic", + "kind": "hth", "hthId": "basic", "upgrades": [ - { "to": "Hand to Hand: Expert", "cost": { "occRelatedSkills": 1 } }, - { "to": "Martial Arts", "cost": { "occRelatedSkills": 2 } }, - { "to": "Assassin", "cost": { "occRelatedSkills": 2 }, "requiresAlignment": "evil" } + { "hthId": "expert", "cost": 1 }, + { "hthId": "martial-arts", "cost": 2 }, + { "hthId": "assassin", "cost": 2, "requiresAlignmentCategory": "evil" } ] } ], @@ -214,27 +263,45 @@ ], "newSkillsStartAtLevel1": true, "categoryRules": [ - { "category": "Communication", "allowed": "Radio: Basic only" }, - { "category": "Cowboy", "allowed": "none" }, - { "category": "Domestic", "allowed": "any", "bonus": 10 }, - { "category": "Electrical", "allowed": "none" }, - { "category": "Espionage", "allowed": "Intelligence only", "bonus": 5 }, - { "category": "Horsemanship", "allowed": "General and Exotic Animals only" }, - { "category": "Mechanical", "allowed": "none" }, { - "category": "Medical", - "allowed": "First Aid or Paramedic (Paramedic counts as two skills)", + "category": "Communication", + "policy": { "kind": "include", "skillIds": ["radio-basic"] } + }, + { "category": "Cowboy", "policy": { "kind": "none" } }, + { "category": "Domestic", "policy": { "kind": "any" }, "bonus": 10 }, + { "category": "Electrical", "policy": { "kind": "none" } }, + { + "category": "Espionage", + "policy": { "kind": "include", "skillIds": ["intelligence"] }, "bonus": 5 }, - { "category": "Military", "allowed": "none" }, - { "category": "Physical", "allowed": "any except Gymnastics and Wrestling" }, - { "category": "Pilot", "allowed": "any", "bonus": 2 }, - { "category": "Pilot Related", "allowed": "any", "bonus": 2 }, - { "category": "Rogue", "allowed": "any" }, - { "category": "Science", "allowed": "any", "bonus": 10 }, - { "category": "Technical", "allowed": "any", "bonus": 5 }, - { "category": "W.P.", "allowed": "any" }, - { "category": "Wilderness", "allowed": "any" } + { + "category": "Horsemanship", + "policy": { + "kind": "include", + "skillIds": ["horsemanship-general", "horsemanship-exotic-animals"] + } + }, + { "category": "Mechanical", "policy": { "kind": "none" } }, + { + "category": "Medical", + "policy": { "kind": "include", "skillIds": ["first-aid", "paramedic"] }, + "bonus": 5, + "exceptionalCosts": [{ "skillId": "paramedic", "cost": 2 }] + }, + { "category": "Military", "policy": { "kind": "none" } }, + { + "category": "Physical", + "policy": { "kind": "exclude", "skillIds": ["gymnastics", "wrestling"] } + }, + { "category": "Pilot", "policy": { "kind": "any" }, "bonus": 2 }, + { "category": "Pilot Related", "policy": { "kind": "any" }, "bonus": 2 }, + { "category": "Rogue", "policy": { "kind": "any" } }, + { "category": "Science", "policy": { "kind": "any" }, "bonus": 10 }, + { "category": "Technical", "policy": { "kind": "any" }, "bonus": 5 }, + { "category": "W.P. Ancient", "policy": { "kind": "any" } }, + { "category": "W.P. Modern", "policy": { "kind": "any" } }, + { "category": "Wilderness", "policy": { "kind": "any" } } ] }, "secondarySkills": { diff --git a/packages/rules/src/content/skills/skills.json b/packages/rules/src/content/skills/skills.json index 59f8458..099daf8 100644 --- a/packages/rules/src/content/skills/skills.json +++ b/packages/rules/src/content/skills/skills.json @@ -1,6 +1,123 @@ { "book": "Rifts Ultimate Edition", "maxPercent": 98, + "secondarySkillsSource": { + "book": "Rifts Ultimate Edition", + "page": 300, + "table": "Secondary Skills" + }, + "secondarySkillRules": [ + { + "category": "Communication", + "policy": { + "kind": "exclude", + "skillIds": ["cryptography", "laser-communications", "surveillance-systems", "tv-video"] + } + }, + { "category": "Cowboy", "policy": { "kind": "none" } }, + { "category": "Domestic", "policy": { "kind": "any" } }, + { + "category": "Electrical", + "policy": { + "kind": "include", + "skillIds": ["basic-electronics", "computer-repair"] + } + }, + { "category": "Espionage", "policy": { "kind": "none" } }, + { + "category": "Horsemanship", + "policy": { + "kind": "include", + "skillIds": ["horsemanship-general", "horsemanship-exotic-animals"] + } + }, + { + "category": "Mechanical", + "policy": { + "kind": "include", + "skillIds": ["automotive-mechanics", "basic-mechanics"] + } + }, + { + "category": "Medical", + "policy": { + "kind": "include", + "skillIds": ["animal-husbandry", "first-aid"] + } + }, + { + "category": "Military", + "policy": { + "kind": "include", + "skillIds": ["camouflage", "recognize-weapon-quality"] + } + }, + { + "category": "Physical", + "policy": { + "kind": "include", + "skillIds": [ + "hand-to-hand-basic", + "aerobic-athletics", + "athletics-general", + "body-building-weight-lifting", + "climbing", + "running", + "swimming" + ] + } + }, + { + "category": "Pilot", + "policy": { + "kind": "include", + "skillIds": [ + "pilot-automobile", + "pilot-motorcycles-snowmobiles", + "pilot-hovercycle", + "pilot-hover-craft-ground", + "pilot-boats-motor-race-hydrofoil", + "pilot-boats-sail", + "pilot-bicycling", + "pilot-boats-paddle" + ] + } + }, + { "category": "Pilot Related", "policy": { "kind": "none" } }, + { + "category": "Rogue", + "policy": { "kind": "include", "skillIds": ["gambling-standard"] } + }, + { + "category": "Science", + "policy": { + "kind": "include", + "skillIds": ["astronomy-navigation", "math-basic", "math-advanced"] + } + }, + { "category": "Technical", "policy": { "kind": "any" } }, + { + "category": "W.P. Ancient", + "policy": { + "kind": "exclude", + "skillIds": ["wp-paired-weapons"] + } + }, + { + "category": "W.P. Modern", + "policy": { + "kind": "include", + "skillIds": ["wp-handguns", "wp-rifles", "wp-energy-pistol", "wp-energy-rifle"] + } + }, + { + "category": "Wilderness", + "policy": { + "kind": "exclude", + "skillIds": ["boat-building", "spelunking"] + } + } + ], "skills": [ { "id": "language-native-tongue", @@ -96,6 +213,7 @@ "id": "wilderness-survival", "name": "Wilderness Survival", "category": "Wilderness", + "additionalCategories": ["Espionage"], "baseSkill": 30, "perLevel": 5, "page": 330 @@ -180,6 +298,10 @@ "category": "Technical", "baseSkill": 30, "perLevel": 5, + "repeatable": true, + "maxSelections": 2, + "repeatBonus": 15, + "note": "May be taken twice; the second selection adds +15% and specializes in one normally excluded goods category.", "page": 323 }, { @@ -313,6 +435,7 @@ "id": "lore-american-indians", "name": "Lore: American Indians", "category": "Technical", + "additionalCategories": ["Cowboy"], "baseSkill": 25, "perLevel": 5, "note": "+10% for characters of Native American descent.", @@ -322,6 +445,7 @@ "id": "lore-cattle-animals", "name": "Lore: Cattle & Animals", "category": "Technical", + "additionalCategories": ["Cowboy"], "baseSkill": 30, "perLevel": 5, "page": 324 @@ -384,7 +508,1712 @@ "perLevel": 5, "note": "May be specialized by taking the skill twice (+20% one-time bonus for the chosen specialty).", "page": 322, - "repeatable": true + "repeatable": true, + "maxSelections": 2, + "repeatBonus": 20 + }, + { + "id": "literacy", + "name": "Literacy: Other", + "aliases": ["Literacy"], + "category": "Communication", + "baseSkill": 30, + "perLevel": 5, + "repeatable": true, + "page": 304 + }, + { + "id": "cryptography", + "name": "Cryptography", + "category": "Communication", + "baseSkill": 25, + "perLevel": 5, + "page": 304 + }, + { + "id": "laser-communications", + "name": "Laser Communications", + "category": "Communication", + "baseSkill": 30, + "perLevel": 5, + "page": 304 + }, + { + "id": "radio-basic", + "name": "Radio: Basic", + "category": "Communication", + "baseSkill": 45, + "perLevel": 5, + "page": 305 + }, + { + "id": "surveillance-systems", + "name": "Surveillance Systems", + "category": "Communication", + "baseSkill": 30, + "perLevel": 5, + "page": 305 + }, + { + "id": "tv-video", + "name": "TV/Video", + "category": "Communication", + "baseSkill": 25, + "perLevel": 5, + "page": 305 + }, + { + "id": "basic-electronics", + "name": "Basic Electronics", + "category": "Electrical", + "baseSkill": 30, + "perLevel": 5, + "page": 308 + }, + { + "id": "computer-repair", + "name": "Computer Repair", + "category": "Electrical", + "baseSkill": 30, + "perLevel": 5, + "page": 308 + }, + { + "id": "horsemanship-general", + "name": "Horsemanship: General", + "category": "Horsemanship", + "baseSkill": 40, + "baseSkill2": 20, + "perLevel": 4, + "page": 311 + }, + { + "id": "horsemanship-exotic-animals", + "name": "Horsemanship: Exotic Animals", + "category": "Horsemanship", + "additionalCategories": ["Cowboy"], + "baseSkill": 30, + "baseSkill2": 20, + "perLevel": 5, + "page": 311 + }, + { + "id": "automotive-mechanics", + "name": "Automotive Mechanics", + "category": "Mechanical", + "baseSkill": 25, + "perLevel": 5, + "page": 312 + }, + { + "id": "basic-mechanics", + "name": "Basic Mechanics", + "category": "Mechanical", + "baseSkill": 30, + "perLevel": 5, + "page": 312 + }, + { + "id": "animal-husbandry", + "name": "Animal Husbandry", + "category": "Medical", + "baseSkill": 35, + "perLevel": 5, + "page": 313 + }, + { + "id": "first-aid", + "name": "First Aid", + "category": "Medical", + "baseSkill": 45, + "perLevel": 5, + "page": 314 + }, + { + "id": "camouflage", + "name": "Camouflage", + "category": "Military", + "baseSkill": 20, + "perLevel": 5, + "page": 314 + }, + { + "id": "military-etiquette", + "name": "Military Etiquette", + "category": "Military", + "baseSkill": 35, + "perLevel": 5, + "page": 315 + }, + { + "id": "recognize-weapon-quality", + "name": "Recognize Weapon Quality", + "category": "Military", + "baseSkill": 25, + "perLevel": 5, + "page": 315 + }, + { + "id": "hand-to-hand-basic", + "name": "Hand to Hand: Basic", + "category": "Physical", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "note": "Non-percent combat training; resolved by the Hand-to-Hand subsystem.", + "page": 316 + }, + { + "id": "acrobatics", + "name": "Acrobatics", + "category": "Physical", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "sdcBonus": "1D6", + "page": 316 + }, + { + "id": "aerobic-athletics", + "name": "Aerobic Athletics", + "category": "Physical", + "baseSkill": 30, + "perLevel": 5, + "sdcBonus": "2D4", + "page": 316 + }, + { + "id": "athletics-general", + "name": "Athletics (General)", + "category": "Physical", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "sdcBonus": "1D8", + "page": 316 + }, + { + "id": "body-building-weight-lifting", + "name": "Body Building & Weight Lifting", + "category": "Physical", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "sdcBonus": "10", + "page": 316 + }, + { + "id": "boxing", + "name": "Boxing", + "category": "Physical", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "sdcBonus": "3D6", + "page": 316 + }, + { + "id": "forced-march", + "name": "Forced March", + "category": "Physical", + "additionalCategories": ["Military"], + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "sdcBonus": "2D6", + "page": 317 + }, + { + "id": "gymnastics", + "name": "Gymnastics", + "category": "Physical", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "sdcBonus": "2D6", + "page": 317 + }, + { + "id": "kick-boxing", + "name": "Kick Boxing", + "category": "Physical", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "sdcBonus": "1D10", + "page": 317 + }, + { + "id": "outdoorsmanship", + "name": "Outdoorsmanship", + "category": "Physical", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "requires": ["wilderness-survival"], + "sdcBonus": "2D6", + "page": 317 + }, + { + "id": "physical-labor", + "name": "Physical Labor", + "category": "Physical", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "sdcBonus": "2D8", + "page": 317 + }, + { + "id": "running", + "name": "Running", + "category": "Physical", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "sdcBonus": "1D6", + "page": 317 + }, + { + "id": "swimming", + "name": "Swimming", + "category": "Physical", + "baseSkill": 50, + "perLevel": 5, + "page": 317 + }, + { + "id": "wrestling", + "name": "Wrestling", + "category": "Physical", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "sdcBonus": "4D6", + "page": 317 + }, + { + "id": "pilot-bicycling", + "name": "Pilot: Bicycling", + "category": "Pilot", + "baseSkill": 44, + "perLevel": 4, + "page": 318 + }, + { + "id": "pilot-boats-motor-race-hydrofoil", + "name": "Pilot: Boats — Motor, Race & Hydrofoil", + "category": "Pilot", + "baseSkill": 55, + "perLevel": 5, + "page": 318 + }, + { + "id": "pilot-boats-paddle", + "name": "Pilot: Boats — Paddle/Canoe/Kayak", + "category": "Pilot", + "baseSkill": 50, + "perLevel": 5, + "page": 318 + }, + { + "id": "pilot-boats-sail", + "name": "Pilot: Boats — Sail", + "category": "Pilot", + "baseSkill": 60, + "perLevel": 5, + "page": 318 + }, + { + "id": "pilot-motorcycles-snowmobiles", + "name": "Pilot: Motorcycles & Snowmobiles", + "category": "Pilot", + "baseSkill": 60, + "perLevel": 4, + "page": 319 + }, + { + "id": "computer-hacking", + "name": "Computer Hacking", + "category": "Rogue", + "baseSkill": 20, + "perLevel": 5, + "requires": ["literacy", "computer-operation", "computer-programming", "math-basic"], + "page": 320 + }, + { + "id": "gambling-standard", + "name": "Gambling (Standard)", + "category": "Rogue", + "baseSkill": 30, + "perLevel": 5, + "page": 320 + }, + { + "id": "astronomy-navigation", + "name": "Astronomy & Navigation", + "category": "Science", + "baseSkill": 30, + "perLevel": 5, + "requires": ["math-basic", "literacy"], + "page": 322 + }, + { + "id": "wp-paired-weapons", + "name": "W.P. Paired Weapons", + "category": "W.P. Ancient", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 327 + }, + { + "id": "wp-handguns", + "name": "W.P. Handguns", + "category": "W.P. Modern", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 328 + }, + { + "id": "wp-rifles", + "name": "W.P. Rifles", + "category": "W.P. Modern", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 328 + }, + { + "id": "wp-shotgun", + "name": "W.P. Shotgun", + "category": "W.P. Modern", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 329 + }, + { + "id": "wp-energy-pistol", + "name": "W.P. Energy Pistol", + "category": "W.P. Modern", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 329 + }, + { + "id": "wp-energy-rifle", + "name": "W.P. Energy Rifle", + "category": "W.P. Modern", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 329 + }, + { + "id": "barter", + "name": "Barter", + "category": "Communication", + "baseSkill": 30, + "perLevel": 4, + "page": 304 + }, + { + "id": "creative-writing", + "name": "Creative Writing", + "category": "Communication", + "baseSkill": 25, + "perLevel": 5, + "page": 304 + }, + { + "id": "electronic-countermeasures", + "name": "Electronic Countermeasures", + "category": "Communication", + "baseSkill": 30, + "perLevel": 5, + "page": 304 + }, + { + "id": "literacy-native-language", + "name": "Literacy: Native Language", + "category": "Communication", + "baseSkill": 40, + "perLevel": 5, + "page": 304 + }, + { + "id": "optic-systems", + "name": "Optic Systems", + "category": "Communication", + "baseSkill": 30, + "perLevel": 5, + "page": 305 + }, + { + "id": "performance", + "name": "Performance", + "category": "Communication", + "baseSkill": 30, + "perLevel": 5, + "page": 305 + }, + { + "id": "public-speaking", + "name": "Public Speaking", + "category": "Communication", + "baseSkill": 30, + "perLevel": 5, + "page": 305 + }, + { + "id": "sensory-equipment", + "name": "Sensory Equipment", + "category": "Communication", + "additionalCategories": ["Pilot Related"], + "baseSkill": 30, + "perLevel": 5, + "page": 305 + }, + { + "id": "sign-language", + "name": "Sign Language", + "category": "Communication", + "baseSkill": 25, + "perLevel": 5, + "page": 305 + }, + { + "id": "sing", + "name": "Sing", + "category": "Communication", + "additionalCategories": ["Domestic"], + "baseSkill": 35, + "perLevel": 5, + "page": 305 + }, + { + "id": "branding", + "name": "Branding", + "category": "Cowboy", + "baseSkill": 50, + "perLevel": 5, + "page": 306 + }, + { + "id": "breaking-taming-wild-horse", + "name": "Breaking/Taming a Wild Horse", + "category": "Cowboy", + "baseSkill": 20, + "perLevel": 5, + "page": 306 + }, + { + "id": "herding-cattle", + "name": "Herding Cattle", + "category": "Cowboy", + "baseSkill": 30, + "perLevel": 5, + "page": 306 + }, + { + "id": "roping", + "name": "Roping", + "category": "Cowboy", + "baseSkill": 20, + "perLevel": 5, + "page": 306 + }, + { + "id": "trick-riding", + "name": "Trick Riding", + "category": "Cowboy", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 306 + }, + { + "id": "brewing", + "name": "Brewing", + "category": "Domestic", + "baseSkill": 25, + "baseSkill2": 30, + "perLevel": 5, + "page": 307 + }, + { + "id": "cook", + "name": "Cook", + "category": "Domestic", + "baseSkill": 35, + "perLevel": 5, + "page": 307 + }, + { + "id": "dance", + "name": "Dance", + "category": "Domestic", + "baseSkill": 30, + "perLevel": 5, + "page": 307 + }, + { + "id": "fishing", + "name": "Fishing", + "category": "Domestic", + "baseSkill": 40, + "perLevel": 5, + "page": 307 + }, + { + "id": "gardening", + "name": "Gardening", + "category": "Domestic", + "baseSkill": 36, + "perLevel": 4, + "page": 307 + }, + { + "id": "housekeeping", + "name": "Housekeeping", + "category": "Domestic", + "baseSkill": 35, + "perLevel": 5, + "page": 307 + }, + { + "id": "play-musical-instrument", + "name": "Play Musical Instrument", + "category": "Domestic", + "baseSkill": 35, + "perLevel": 5, + "repeatable": true, + "page": 307 + }, + { + "id": "sewing", + "name": "Sewing", + "category": "Domestic", + "baseSkill": 40, + "perLevel": 5, + "page": 307 + }, + { + "id": "wardrobe-grooming", + "name": "Wardrobe & Grooming", + "category": "Domestic", + "baseSkill": 50, + "perLevel": 4, + "page": 308 + }, + { + "id": "electrical-engineer", + "name": "Electrical Engineer", + "category": "Electrical", + "baseSkill": 35, + "perLevel": 5, + "requires": ["math-advanced", "literacy"], + "page": 308 + }, + { + "id": "electricity-generation", + "name": "Electricity Generation", + "category": "Electrical", + "baseSkill": 50, + "perLevel": 5, + "page": 308 + }, + { + "id": "robot-electronics", + "name": "Robot Electronics", + "category": "Electrical", + "baseSkill": 30, + "perLevel": 5, + "requires": ["electrical-engineer", "math-basic"], + "page": 308 + }, + { + "id": "detect-ambush", + "name": "Detect Ambush", + "category": "Espionage", + "baseSkill": 30, + "perLevel": 5, + "page": 308 + }, + { + "id": "detect-concealment", + "name": "Detect Concealment", + "category": "Espionage", + "baseSkill": 25, + "perLevel": 5, + "page": 308 + }, + { + "id": "disguise", + "name": "Disguise", + "category": "Espionage", + "baseSkill": 25, + "perLevel": 5, + "page": 308 + }, + { + "id": "escape-artist", + "name": "Escape Artist", + "category": "Espionage", + "baseSkill": 30, + "perLevel": 5, + "page": 308 + }, + { + "id": "forgery", + "name": "Forgery", + "category": "Espionage", + "baseSkill": 20, + "perLevel": 5, + "page": 309 + }, + { + "id": "impersonation", + "name": "Impersonation", + "category": "Espionage", + "baseSkill": 30, + "baseSkill2": 16, + "perLevel": 4, + "page": 309 + }, + { + "id": "intelligence", + "name": "Intelligence", + "category": "Espionage", + "baseSkill": 32, + "perLevel": 4, + "page": 309 + }, + { + "id": "interrogation", + "name": "Interrogation", + "category": "Espionage", + "baseSkill": 30, + "perLevel": 5, + "page": 309 + }, + { + "id": "pick-locks", + "name": "Pick Locks", + "category": "Espionage", + "additionalCategories": ["Rogue"], + "baseSkill": 30, + "perLevel": 5, + "page": 309 + }, + { + "id": "pick-pockets", + "name": "Pick Pockets", + "category": "Espionage", + "additionalCategories": ["Rogue"], + "baseSkill": 25, + "perLevel": 5, + "page": 309 + }, + { + "id": "sniper", + "name": "Sniper", + "category": "Espionage", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 309 + }, + { + "id": "tracking-people", + "name": "Tracking (People)", + "category": "Espionage", + "baseSkill": 25, + "perLevel": 5, + "page": 309 + }, + { + "id": "undercover-ops", + "name": "Undercover Ops", + "category": "Espionage", + "baseSkill": 30, + "perLevel": 5, + "page": 309 + }, + { + "id": "horsemanship-cowboy", + "name": "Horsemanship: Cowboy", + "category": "Horsemanship", + "additionalCategories": ["Cowboy"], + "baseSkill": 66, + "baseSkill2": 50, + "perLevel": 3, + "page": 310 + }, + { + "id": "horsemanship-cossack", + "name": "Horsemanship: Cossack", + "category": "Horsemanship", + "baseSkill": 55, + "baseSkill2": 45, + "perLevel": 5, + "page": 310 + }, + { + "id": "horsemanship-cyber-knight", + "name": "Horsemanship: Cyber-Knight", + "category": "Horsemanship", + "baseSkill": 70, + "baseSkill2": 50, + "perLevel": 3, + "page": 310 + }, + { + "id": "horsemanship-equestrian", + "name": "Horsemanship: Equestrian", + "category": "Horsemanship", + "baseSkill": 40, + "baseSkill2": 30, + "perLevel": 5, + "page": 311 + }, + { + "id": "aircraft-mechanics", + "name": "Aircraft Mechanics", + "category": "Mechanical", + "baseSkill": 25, + "perLevel": 5, + "page": 312 + }, + { + "id": "bioware-mechanics", + "name": "Bioware Mechanics", + "category": "Mechanical", + "baseSkill": 30, + "perLevel": 5, + "requires": ["mechanical-engineer", "math-basic"], + "page": 312 + }, + { + "id": "locksmith", + "name": "Locksmith", + "category": "Mechanical", + "baseSkill": 25, + "perLevel": 5, + "requires": ["basic-electronics"], + "note": "Basic Electronics satisfies the minimum prerequisite; Electrical Engineer grants the printed bonus.", + "page": 312 + }, + { + "id": "mechanical-engineer", + "name": "Mechanical Engineer", + "category": "Mechanical", + "baseSkill": 25, + "perLevel": 5, + "requires": ["math-basic", "basic-electronics", "literacy"], + "page": 312 + }, + { + "id": "robot-mechanics", + "name": "Robot Mechanics", + "category": "Mechanical", + "baseSkill": 20, + "perLevel": 5, + "requires": ["mechanical-engineer", "electrical-engineer", "math-basic"], + "page": 312 + }, + { + "id": "vehicle-armorer", + "name": "Vehicle Armorer", + "category": "Mechanical", + "baseSkill": 30, + "perLevel": 5, + "page": 313 + }, + { + "id": "weapons-engineer", + "name": "Weapons Engineer", + "category": "Mechanical", + "baseSkill": 25, + "perLevel": 5, + "requires": ["mechanical-engineer"], + "page": 313 + }, + { + "id": "brewing-medicinal", + "name": "Brewing: Medicinal", + "category": "Medical", + "baseSkill": 25, + "baseSkill2": 30, + "perLevel": 5, + "page": 313 + }, + { + "id": "crime-scene-investigation", + "name": "Crime Scene Investigation", + "category": "Medical", + "baseSkill": 35, + "perLevel": 5, + "requires": ["biology", "chemistry", "chemistry-analytical", "math-advanced", "literacy"], + "page": 313 + }, + { + "id": "cybernetic-medicine", + "name": "Cybernetic Medicine", + "category": "Medical", + "baseSkill": 40, + "baseSkill2": 60, + "perLevel": 5, + "page": 313 + }, + { + "id": "entomological-medicine", + "name": "Entomological Medicine", + "category": "Medical", + "baseSkill": 40, + "baseSkill2": 20, + "perLevel": 5, + "requires": ["math-basic", "chemistry", "chemistry-analytical"], + "page": 314 + }, + { + "id": "field-surgery", + "name": "Field Surgery", + "category": "Medical", + "baseSkill": 16, + "perLevel": 4, + "page": 314 + }, + { + "id": "forensics", + "name": "Forensics", + "category": "Medical", + "baseSkill": 35, + "perLevel": 5, + "requires": ["biology", "chemistry"], + "page": 314 + }, + { + "id": "holistic-medicine", + "name": "Holistic Medicine", + "category": "Medical", + "baseSkill": 30, + "baseSkill2": 20, + "perLevel": 5, + "page": 314 + }, + { + "id": "pathology", + "name": "Pathology", + "category": "Medical", + "baseSkill": 40, + "perLevel": 5, + "requires": ["biology", "chemistry", "literacy"], + "page": 314 + }, + { + "id": "paramedic", + "name": "Paramedic", + "category": "Medical", + "baseSkill": 40, + "perLevel": 5, + "page": 314 + }, + { + "id": "medical-doctor", + "name": "Medical Doctor", + "category": "Medical", + "baseSkill": 60, + "baseSkill2": 50, + "perLevel": 5, + "requires": ["biology", "pathology", "chemistry", "math-basic", "literacy"], + "page": 314 + }, + { + "id": "psychology", + "name": "Psychology", + "category": "Medical", + "baseSkill": 35, + "perLevel": 5, + "requires": ["biology", "chemistry", "literacy"], + "page": 314 + }, + { + "id": "veterinary-science", + "name": "Veterinary Science", + "category": "Medical", + "baseSkill": 50, + "perLevel": 4, + "requires": ["biology", "animal-husbandry"], + "page": 314 + }, + { + "id": "demolitions", + "name": "Demolitions", + "category": "Military", + "baseSkill": 60, + "perLevel": 3, + "page": 315 + }, + { + "id": "demolitions-disposal", + "name": "Demolitions Disposal", + "category": "Military", + "baseSkill": 60, + "perLevel": 3, + "page": 315 + }, + { + "id": "demolitions-underwater", + "name": "Demolitions: Underwater", + "category": "Military", + "baseSkill": 56, + "perLevel": 4, + "page": 315 + }, + { + "id": "field-armorer-munitions-expert", + "name": "Field Armorer & Munitions Expert", + "category": "Military", + "baseSkill": 40, + "perLevel": 5, + "page": 315 + }, + { + "id": "military-fortification", + "name": "Military Fortification", + "category": "Military", + "baseSkill": 30, + "perLevel": 5, + "page": 315 + }, + { + "id": "naval-history", + "name": "Naval History", + "category": "Military", + "baseSkill": 30, + "perLevel": 5, + "page": 315 + }, + { + "id": "naval-tactics", + "name": "Naval Tactics", + "category": "Military", + "baseSkill": 25, + "perLevel": 5, + "page": 315 + }, + { + "id": "nbc-warfare", + "name": "NBC Warfare", + "category": "Military", + "baseSkill": 35, + "perLevel": 5, + "page": 315 + }, + { + "id": "parachuting", + "name": "Parachuting", + "category": "Military", + "baseSkill": 40, + "perLevel": 5, + "page": 315 + }, + { + "id": "trap-mine-detection", + "name": "Trap & Mine Detection", + "category": "Military", + "baseSkill": 20, + "perLevel": 5, + "page": 316 + }, + { + "id": "fencing", + "name": "Fencing", + "category": "Physical", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "requires": ["wp-sword"], + "page": 316 + }, + { + "id": "juggling", + "name": "Juggling", + "category": "Physical", + "baseSkill": 35, + "perLevel": 5, + "page": 317 + }, + { + "id": "prowl", + "name": "Prowl", + "category": "Physical", + "additionalCategories": ["Rogue"], + "baseSkill": 25, + "perLevel": 5, + "page": 317 + }, + { + "id": "scuba", + "name": "SCUBA", + "category": "Physical", + "baseSkill": 50, + "perLevel": 5, + "requires": ["swimming"], + "page": 317 + }, + { + "id": "pilot-boats-ships-seamanship", + "name": "Pilot: Boats — Ships/Seamanship", + "category": "Pilot", + "baseSkill": 45, + "baseSkill2": 40, + "perLevel": 5, + "requires": ["sewing", "rope-works"], + "page": 318 + }, + { + "id": "pilot-combat-driving", + "name": "Pilot: Combat Driving", + "category": "Pilot", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 318 + }, + { + "id": "pilot-flight-system-combat-juicer", + "name": "Pilot: Flight System Combat (Juicer)", + "category": "Pilot", + "baseSkill": 40, + "perLevel": 5, + "page": 318 + }, + { + "id": "pilot-jet-aircraft", + "name": "Pilot: Jet Aircraft", + "category": "Pilot", + "baseSkill": 40, + "perLevel": 4, + "page": 319 + }, + { + "id": "pilot-jet-packs", + "name": "Pilot: Jet Packs", + "category": "Pilot", + "baseSkill": 42, + "perLevel": 4, + "page": 319 + }, + { + "id": "pilot-jump-bike-combat-juicer", + "name": "Pilot: Jump Bike Combat (Juicer)", + "category": "Pilot", + "baseSkill": 45, + "perLevel": 5, + "page": 319 + }, + { + "id": "pilot-military-combat-helicopters", + "name": "Pilot: Military Combat Helicopters", + "category": "Pilot", + "baseSkill": 52, + "perLevel": 3, + "page": 319 + }, + { + "id": "pilot-military-jet-fighters", + "name": "Pilot: Military Jet Fighters", + "category": "Pilot", + "baseSkill": 40, + "perLevel": 4, + "page": 319 + }, + { + "id": "pilot-military-submersibles", + "name": "Pilot: Military Submersibles", + "category": "Pilot", + "baseSkill": 40, + "perLevel": 4, + "page": 319 + }, + { + "id": "pilot-military-tanks-apcs", + "name": "Pilot: Military Tanks & APCs", + "category": "Pilot", + "baseSkill": 36, + "perLevel": 4, + "page": 319 + }, + { + "id": "pilot-military-warships-patrol-boats", + "name": "Pilot: Military Warships & Patrol Boats", + "category": "Pilot", + "baseSkill": 40, + "perLevel": 4, + "page": 319 + }, + { + "id": "pilot-robots-power-armor", + "name": "Pilot: Robots & Power Armor", + "category": "Pilot", + "baseSkill": 56, + "perLevel": 3, + "page": 319 + }, + { + "id": "robot-combat-basic", + "name": "Robot Combat: Basic", + "category": "Pilot", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 319 + }, + { + "id": "robot-combat-elite", + "name": "Robot Combat: Elite", + "category": "Pilot", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "repeatable": true, + "page": 319 + }, + { + "id": "pilot-tracked-construction-vehicles", + "name": "Pilot: Tracked & Construction Vehicles", + "category": "Pilot", + "baseSkill": 40, + "perLevel": 4, + "page": 319 + }, + { + "id": "pilot-truck", + "name": "Pilot: Truck", + "category": "Pilot", + "baseSkill": 40, + "perLevel": 4, + "page": 319 + }, + { + "id": "pilot-water-scooters", + "name": "Pilot: Water Scooters", + "category": "Pilot", + "baseSkill": 50, + "perLevel": 5, + "page": 320 + }, + { + "id": "pilot-water-skiing-surfing", + "name": "Pilot: Water Skiing & Surfing", + "category": "Pilot", + "baseSkill": 40, + "perLevel": 4, + "page": 320 + }, + { + "id": "navigation", + "name": "Navigation", + "category": "Pilot Related", + "baseSkill": 40, + "perLevel": 5, + "requires": ["math-basic", "sensory-equipment", "literacy"], + "page": 320 + }, + { + "id": "weapon-systems", + "name": "Weapon Systems", + "category": "Pilot Related", + "baseSkill": 40, + "perLevel": 5, + "page": 320 + }, + { + "id": "cardsharp", + "name": "Cardsharp", + "aliases": ["Card Shark"], + "category": "Rogue", + "baseSkill": 24, + "perLevel": 4, + "page": 320 + }, + { + "id": "concealment", + "name": "Concealment", + "category": "Rogue", + "baseSkill": 20, + "perLevel": 4, + "page": 320 + }, + { + "id": "find-contraband", + "name": "Find Contraband", + "category": "Rogue", + "additionalCategories": ["Military"], + "baseSkill": 26, + "perLevel": 4, + "page": 320 + }, + { + "id": "gambling-dirty-tricks", + "name": "Gambling (Dirty Tricks)", + "category": "Rogue", + "baseSkill": 20, + "perLevel": 4, + "page": 320 + }, + { + "id": "id-undercover-agent", + "name": "I.D. Undercover Agent", + "category": "Rogue", + "baseSkill": 30, + "perLevel": 4, + "page": 320 + }, + { + "id": "imitate-voices-sounds", + "name": "Imitate Voices & Sounds", + "category": "Rogue", + "baseSkill": 42, + "baseSkill2": 36, + "perLevel": 4, + "page": 321 + }, + { + "id": "palming", + "name": "Palming", + "category": "Rogue", + "baseSkill": 20, + "perLevel": 5, + "page": 321 + }, + { + "id": "roadwise", + "name": "Roadwise", + "category": "Rogue", + "baseSkill": 26, + "perLevel": 4, + "page": 321 + }, + { + "id": "safe-cracking", + "name": "Safe-Cracking", + "category": "Rogue", + "baseSkill": 20, + "perLevel": 4, + "page": 321 + }, + { + "id": "seduction", + "name": "Seduction", + "category": "Rogue", + "baseSkill": 20, + "perLevel": 3, + "page": 321 + }, + { + "id": "streetwise", + "name": "Streetwise", + "category": "Rogue", + "baseSkill": 20, + "perLevel": 4, + "page": 321 + }, + { + "id": "tailing", + "name": "Tailing", + "category": "Rogue", + "baseSkill": 30, + "perLevel": 5, + "page": 321 + }, + { + "id": "anthropology", + "name": "Anthropology", + "category": "Science", + "baseSkill": 30, + "perLevel": 5, + "page": 321 + }, + { + "id": "archaeology", + "name": "Archaeology", + "category": "Science", + "baseSkill": 30, + "baseSkill2": 20, + "perLevel": 5, + "page": 322 + }, + { + "id": "artificial-intelligence", + "name": "Artificial Intelligence", + "category": "Science", + "baseSkill": 30, + "perLevel": 3, + "requires": ["computer-operation"], + "page": 322 + }, + { + "id": "astrophysics", + "name": "Astrophysics", + "category": "Science", + "baseSkill": 30, + "perLevel": 5, + "requires": ["math-basic", "math-advanced"], + "page": 322 + }, + { + "id": "chemistry-analytical", + "name": "Chemistry: Analytical", + "category": "Science", + "baseSkill": 25, + "perLevel": 5, + "requires": ["chemistry", "math-advanced", "literacy"], + "page": 322 + }, + { + "id": "chemistry-pharmaceutical", + "name": "Chemistry: Pharmaceutical", + "category": "Science", + "baseSkill": 30, + "perLevel": 5, + "page": 322 + }, + { + "id": "breed-dogs", + "name": "Breed Dogs", + "category": "Technical", + "baseSkill": 40, + "baseSkill2": 20, + "perLevel": 5, + "page": 323 + }, + { + "id": "lore-faeries-creatures-magic", + "name": "Lore: Faeries & Creatures of Magic", + "category": "Technical", + "baseSkill": 25, + "perLevel": 5, + "page": 325 + }, + { + "id": "lore-juicers", + "name": "Lore: Juicers", + "category": "Technical", + "baseSkill": 30, + "perLevel": 5, + "page": 325 + }, + { + "id": "lore-magic", + "name": "Lore: Magic", + "category": "Technical", + "baseSkill": 25, + "perLevel": 5, + "page": 325 + }, + { + "id": "lore-psychics-psionics", + "name": "Lore: Psychics & Psionics", + "category": "Technical", + "baseSkill": 25, + "perLevel": 5, + "page": 325 + }, + { + "id": "masonry", + "name": "Masonry", + "category": "Technical", + "baseSkill": 40, + "perLevel": 5, + "page": 325 + }, + { + "id": "mining", + "name": "Mining", + "category": "Technical", + "baseSkill": 35, + "perLevel": 5, + "page": 325 + }, + { + "id": "mythology", + "name": "Mythology", + "category": "Technical", + "baseSkill": 30, + "perLevel": 5, + "page": 325 + }, + { + "id": "philosophy", + "name": "Philosophy", + "category": "Technical", + "baseSkill": 30, + "perLevel": 5, + "page": 325 + }, + { + "id": "photography", + "name": "Photography", + "category": "Technical", + "baseSkill": 35, + "perLevel": 5, + "page": 325 + }, + { + "id": "recycling", + "name": "Recycling", + "aliases": ["Recycle"], + "category": "Technical", + "additionalCategories": ["Domestic"], + "baseSkill": 30, + "perLevel": 5, + "page": 325 + }, + { + "id": "research", + "name": "Research", + "category": "Technical", + "baseSkill": 40, + "perLevel": 5, + "page": 325 + }, + { + "id": "rope-works", + "name": "Rope Works", + "category": "Technical", + "baseSkill": 30, + "perLevel": 5, + "page": 325 + }, + { + "id": "salvage", + "name": "Salvage", + "category": "Technical", + "baseSkill": 35, + "perLevel": 5, + "page": 326 + }, + { + "id": "ventriloquism", + "name": "Ventriloquism", + "category": "Technical", + "baseSkill": 16, + "perLevel": 4, + "page": 326 + }, + { + "id": "whittling-sculpting", + "name": "Whittling & Sculpting", + "category": "Technical", + "baseSkill": 30, + "perLevel": 5, + "page": 326 + }, + { + "id": "wp-archery", + "name": "W.P. Archery", + "category": "W.P. Ancient", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 326 + }, + { + "id": "wp-axe", + "name": "W.P. Axe", + "category": "W.P. Ancient", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 326 + }, + { + "id": "wp-blunt", + "name": "W.P. Blunt", + "category": "W.P. Ancient", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 326 + }, + { + "id": "wp-chain", + "name": "W.P. Chain", + "category": "W.P. Ancient", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 326 + }, + { + "id": "wp-forked", + "name": "W.P. Forked", + "category": "W.P. Ancient", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 326 + }, + { + "id": "wp-grappling-hook", + "name": "W.P. Grappling Hook", + "category": "W.P. Ancient", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 327 + }, + { + "id": "wp-knife", + "name": "W.P. Knife", + "category": "W.P. Ancient", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 327 + }, + { + "id": "wp-pole-arm", + "name": "W.P. Pole Arm", + "category": "W.P. Ancient", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 327 + }, + { + "id": "wp-quick-draw", + "name": "W.P. Quick Draw", + "category": "W.P. Ancient", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 327 + }, + { + "id": "wp-rope", + "name": "W.P. Rope", + "category": "W.P. Ancient", + "additionalCategories": ["Cowboy"], + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 327 + }, + { + "id": "wp-shield", + "name": "W.P. Shield", + "category": "W.P. Ancient", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 327 + }, + { + "id": "wp-spear", + "name": "W.P. Spear", + "category": "W.P. Ancient", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 327 + }, + { + "id": "wp-staff", + "name": "W.P. Staff", + "category": "W.P. Ancient", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 327 + }, + { + "id": "wp-sword", + "name": "W.P. Sword", + "category": "W.P. Ancient", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 327 + }, + { + "id": "wp-targeting", + "name": "W.P. Targeting", + "category": "W.P. Ancient", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 328 + }, + { + "id": "wp-whip", + "name": "W.P. Whip", + "category": "W.P. Ancient", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 328 + }, + { + "id": "wp-submachine-gun", + "name": "W.P. Submachine-Gun", + "category": "W.P. Modern", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 329 + }, + { + "id": "wp-heavy-military-weapons", + "name": "W.P. Heavy Military Weapons", + "category": "W.P. Modern", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 329 + }, + { + "id": "wp-military-flamethrowers", + "name": "W.P. Military Flamethrowers", + "category": "W.P. Modern", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 329 + }, + { + "id": "wp-harpoon-spear-gun", + "name": "W.P. Harpoon & Spear Gun", + "category": "W.P. Modern", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 329 + }, + { + "id": "wp-heavy-mega-damage-weapons", + "name": "W.P. Heavy Mega-Damage Weapons", + "aliases": ["W.P. Heavy M.D. Weapons"], + "category": "W.P. Modern", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 329 + }, + { + "id": "hunting", + "name": "Hunting", + "category": "Wilderness", + "baseSkill": 0, + "perLevel": 0, + "fixed": true, + "page": 330 } ] } diff --git a/packages/rules/src/engine/builder.ts b/packages/rules/src/engine/builder.ts index 67381f3..8f809d5 100644 --- a/packages/rules/src/engine/builder.ts +++ b/packages/rules/src/engine/builder.ts @@ -1,12 +1,16 @@ -import { z } from "zod"; -import type { CharacterSkill, PsychicClass } from "../schema/character.ts"; +import type { + CharacterSkill, + PsychicClass, + SkillPickInput, + SkillSelections, +} from "../schema/character.ts"; import type { Occ } from "../schema/occ.ts"; -import type { Skill } from "../schema/skills.ts"; +import type { Skill, SkillCategory } from "../schema/skills.ts"; import { getAlignment } from "./alignments.ts"; import { getHandToHand } from "./combat.ts"; import type { Rng } from "./dice.ts"; import { rollPercentile } from "./rolls.ts"; -import { getSkill, getSkillByName, skillCatalog } from "./skills.ts"; +import { getSkill, secondarySkillOptions, skillCatalog, skillsForPolicy } from "./skills.ts"; import { initialSpellChoices } from "./spells.ts"; /** @@ -42,56 +46,6 @@ export function rollPsionics(rng: Rng = Math.random): PsionicsRoll { // --------------------------------------------------------------------------- // O.C.C. skill grants -> a typed plan -/** The grant shapes O.C.C. content uses (schema keeps `occSkills` loose). */ -const overrideGrant = z.object({ - skill: z.string(), - skillId: z.string(), - atLevel1: z.number().int(), - fixed: z.literal(true), -}); -const repeatChoiceGrant = z.object({ - skill: z.string(), - skillId: z.string(), - choose: z.number().int().positive(), - occBonus: z.number().int().optional(), -}); -const categoryChoiceGrant = z.object({ - skill: z.string(), - chooseFromCategory: z.string(), - choose: z.number().int().positive(), - occBonus: z.number().int().optional(), -}); -const prefixChoiceGrant = z.object({ - skill: z.string(), - skillPrefix: z.string(), - choose: z.number().int().positive(), - occBonus: z.number().int().optional(), -}); -const hthUpgradeShape = z.object({ - to: z.string(), - cost: z.object({ occRelatedSkills: z.number().int().positive() }), - requiresAlignment: z.string().optional(), -}); -const hthGrant = z.object({ - skill: z.string(), - hthId: z.string(), - upgrades: z.array(hthUpgradeShape).optional(), -}); -const fixedGrant = z.object({ - skill: z.string(), - skillId: z.string(), - occBonus: z.number().int().optional(), -}); -// Order matters: shapes with required distinguishing fields come first. -const grantSchema = z.union([ - overrideGrant, - repeatChoiceGrant, - categoryChoiceGrant, - prefixChoiceGrant, - hthGrant, - fixedGrant, -]); - /** A choose-N slot from an O.C.C. skill grant. */ export interface OccSkillChoice { /** Stable key for wiring UI selections back to this slot. */ @@ -128,12 +82,6 @@ export interface OccSkillPlan { hth?: { hthId: string; name: string; upgrades: HthUpgrade[] }; } -/** "Hand to Hand: Expert" -> "expert", "Martial Arts" -> "martial-arts". */ -function hthIdFromName(name: string): string { - const last = name.includes(":") ? name.slice(name.lastIndexOf(":") + 1) : name; - return last.trim().toLowerCase().replace(/\s+/g, "-"); -} - function requireSkill(id: string, context: string): Skill { const skill = getSkill(id); if (!skill) throw new Error(`${context} references unknown skill "${id}".`); @@ -150,70 +98,47 @@ export function occSkillPlan(occ: Occ): OccSkillPlan { const choices: OccSkillChoice[] = []; let hth: OccSkillPlan["hth"]; - for (const raw of occ.occSkills ?? []) { - const parsed = grantSchema.safeParse(raw); - if (!parsed.success) { - throw new Error(`O.C.C. "${occ.id}" has an unrecognized skill grant: "${raw.skill}".`); - } - const grant = parsed.data; - - if ("atLevel1" in grant) { + for (const grant of occ.occSkills ?? []) { + if (grant.kind === "override") { requireSkill(grant.skillId, `O.C.C. "${occ.id}"`); - fixed.push({ skillId: grant.skillId, overrideValue: grant.atLevel1 }); - } else if ("hthId" in grant) { - if (!getHandToHand(grant.hthId)) { + fixed.push({ skillId: grant.skillId, overrideValue: grant.value }); + } else if (grant.kind === "hth") { + const granted = getHandToHand(grant.hthId); + if (!granted) { throw new Error(`O.C.C. "${occ.id}" grants unknown hand-to-hand "${grant.hthId}".`); } hth = { hthId: grant.hthId, - name: grant.skill, + name: granted.name, upgrades: (grant.upgrades ?? []).map((u) => { - const id = hthIdFromName(u.to); - const available = getHandToHand(id) !== undefined; + const target = getHandToHand(u.hthId); return { - to: u.to, - hthId: available ? id : undefined, - cost: u.cost.occRelatedSkills, - requiresAlignmentCategory: u.requiresAlignment, - available, + to: target?.name ?? u.hthId, + hthId: target ? u.hthId : undefined, + cost: u.cost, + requiresAlignmentCategory: u.requiresAlignmentCategory, + available: target !== undefined, }; }), }; - } else if ("choose" in grant && "skillId" in grant) { + } else if (grant.kind === "repeatableChoice") { const skill = requireSkill(grant.skillId, `O.C.C. "${occ.id}"`); choices.push({ - key: grant.skillId, - label: grant.skill, + key: grant.key, + label: grant.label, choose: grant.choose, occBonus: grant.occBonus, options: [skill], - repeatable: skill.repeatable === true, - }); - } else if ("chooseFromCategory" in grant) { - const options = skillCatalog.skills.filter((s) => s.category === grant.chooseFromCategory); - if (options.length === 0) { - throw new Error( - `O.C.C. "${occ.id}" chooses from category "${grant.chooseFromCategory}", which has no skills in the catalog.`, - ); - } - choices.push({ - key: `category:${grant.chooseFromCategory}`, - label: grant.skill, - choose: grant.choose, - occBonus: grant.occBonus, - options, - repeatable: false, + repeatable: true, }); - } else if ("skillPrefix" in grant) { - const options = skillCatalog.skills.filter((s) => s.name.startsWith(grant.skillPrefix)); - if (options.length === 0) { - throw new Error( - `O.C.C. "${occ.id}" chooses skills prefixed "${grant.skillPrefix}", of which the catalog has none.`, - ); - } + } else if (grant.kind === "choice") { + const options = + grant.pool.kind === "category" + ? skillsForPolicy(skillCatalog.skills, grant.pool.category, grant.pool.policy) + : grant.pool.skillIds.map((id) => requireSkill(id, `O.C.C. "${occ.id}"`)); choices.push({ - key: `prefix:${grant.skillPrefix}`, - label: grant.skill, + key: grant.key, + label: grant.label, choose: grant.choose, occBonus: grant.occBonus, options, @@ -243,83 +168,69 @@ export interface RelatedSkillPlan { categoryBonuses: Record; /** Catalog skills eligible as related picks. */ options: Skill[]; - /** Category rules that could not be applied mechanically (kept for display). */ + /** Every category-specific way a skill may be selected. */ + variants: RelatedSkillVariant[]; + /** Typed content leaves no unparsed rule notes; retained for UI compatibility. */ notes: string[]; } -/** Split a printed list like "General and Exotic Animals" / "First Aid or Paramedic". */ -function splitNames(text: string): string[] { - return text - .split(/,|\band\b|\bor\b/) - .map((n) => n.trim()) - .filter((n) => n.length > 0); +export interface RelatedSkillVariant { + skill: Skill; + category: SkillCategory; + bonus?: number; + cost: number; } -/** - * Which catalog skills a category rule admits. `undefined` means the whole - * category; an empty array means none of the named skills exist in the - * catalog yet. - */ -function allowedSkillsForRule(rule: { category: string; allowed: string }): Skill[] | undefined { - const text = rule.allowed.replace(/\([^)]*\)/g, "").trim(); - if (text === "any") return undefined; - const inCategory = skillCatalog.skills.filter((s) => s.category === rule.category); - if (text === "none") return []; - const except = text.match(/^any except (.+)$/i); - if (except) { - const excluded = new Set( - splitNames(except[1]!) - .map((n) => getSkillByName(n)?.id) - .filter((id) => id !== undefined), - ); - // Names that don't resolve yet exclude nothing — they're future content. - return inCategory.filter((s) => !excluded.has(s.id)); - } - const only = text.match(/^(.+?)\s+only$/i); - if (only) { - return splitNames(only[1]!) - .map((n) => getSkillByName(n)) - .filter((s): s is Skill => s !== undefined && s.category === rule.category); - } - // Unparsed free text: admit the named skills we can resolve, none otherwise. - return splitNames(text).filter((n) => getSkillByName(n) !== undefined).length > 0 - ? splitNames(text) - .map((n) => getSkillByName(n)) - .filter((s): s is Skill => s !== undefined && s.category === rule.category) - : []; +const BASIC_HAND_TO_HAND_SKILL_ID = "hand-to-hand-basic"; + +function startsWithHandToHandTraining(occ: Occ): boolean { + return occ.occSkills?.some((grant) => grant.kind === "hth" && grant.hthId !== "none") ?? false; } /** The O.C.C. Related skill selections available at level 1. */ export function relatedSkillPlan(occ: Occ): RelatedSkillPlan { const related = occ.occRelatedSkills; if (!related) { - return { count: 0, constraints: [], categoryBonuses: {}, options: [], notes: [] }; + return { + count: 0, + constraints: [], + categoryBonuses: {}, + options: [], + variants: [], + notes: [], + }; } const categoryBonuses: Record = {}; - const notes: string[] = []; - const rules = related.categoryRules ?? []; - const ruled = new Set(rules.map((r) => r.category)); - let options: Skill[] = []; - - for (const rule of rules) { - const allowed = allowedSkillsForRule(rule); - const inCategory = skillCatalog.skills.filter((s) => s.category === rule.category); - options = options.concat(allowed ?? inCategory); + const variants: RelatedSkillVariant[] = []; + for (const rule of related.categoryRules ?? []) { + const costs = new Map( + (rule.exceptionalCosts ?? []).map(({ skillId, cost }) => [skillId, cost]), + ); + const admitted = skillsForPolicy(skillCatalog.skills, rule.category, rule.policy).filter( + (skill) => !startsWithHandToHandTraining(occ) || skill.id !== BASIC_HAND_TO_HAND_SKILL_ID, + ); + variants.push( + ...admitted.map((skill) => ({ + skill, + category: rule.category, + bonus: rule.bonus, + cost: costs.get(skill.id) ?? 1, + })), + ); if (rule.bonus !== undefined) categoryBonuses[rule.category] = rule.bonus; - if (rule.allowed !== "any" && rule.allowed !== "none") { - notes.push(`${rule.category}: ${rule.allowed}`); - } } - // Categories without a printed rule are available as-is. - options = options.concat(skillCatalog.skills.filter((s) => !ruled.has(s.category))); + const options = [ + ...new Map(variants.map((variant) => [variant.skill.id, variant.skill])).values(), + ]; return { count: related.count, constraints: related.constraints ?? [], categoryBonuses, options, - notes, + variants, + notes: [], }; } @@ -332,18 +243,17 @@ export interface SecondarySkillPlan { } /** - * Secondary skill selections at level 1. The RUE "Secondary Skills list" is - * not modeled as content yet, so this offers the same eligible pool as - * related picks — without any bonuses. + * Secondary skill selections at level 1 use the independent RUE p.300 pool, + * never an O.C.C.'s related-skill rules. */ export function secondarySkillPlan(occ: Occ): SecondarySkillPlan { const secondary = occ.secondarySkills; if (!secondary) return { count: 0, options: [], notes: [] }; - // Without related-skill category rules, the whole catalog is the pool. - const options = occ.occRelatedSkills ? relatedSkillPlan(occ).options : [...skillCatalog.skills]; return { count: secondary.count, - options, + options: secondarySkillOptions.filter( + (skill) => !startsWithHandToHandTraining(occ) || skill.id !== BASIC_HAND_TO_HAND_SKILL_ID, + ), notes: secondary.notes ? [secondary.notes] : [], }; } @@ -352,19 +262,9 @@ export function secondarySkillPlan(occ: Occ): SecondarySkillPlan { // Selections -> validated CharacterSkill[] /** One player pick: a skill plus the label that distinguishes repeatable picks. */ -export interface SkillPick { - skillId: string; - /** e.g. which language ("Dragonese") for a repeatable skill. */ - label?: string; -} +export type SkillPick = SkillPickInput; -export interface BuilderSelections { - /** Picks per choice slot, keyed by `OccSkillChoice.key`. */ - occChoices: Record; - related: SkillPick[]; - secondary: SkillPick[]; - /** Chosen hand-to-hand id; defaults to the O.C.C. grant. */ - hthId?: string; +export interface BuilderSelections extends SkillSelections { /** Needed when an H2H upgrade has an alignment requirement. */ alignmentId?: string; } @@ -388,6 +288,13 @@ export function assembleSkills(occ: Occ, selections: BuilderSelections): Assembl const related = relatedSkillPlan(occ); const secondary = secondarySkillPlan(occ); const skills: CharacterSkill[] = [...plan.fixed]; + const occOwnedIds = new Set(plan.fixed.map((skill) => skill.skillId)); + + const validatePickLabel = (pick: SkillPick, skill: Skill, context: string): void => { + if (skill.repeatable === true && pick.label === undefined) { + errors.push(`${context}: "${skill.name}" requires a label for this selection.`); + } + }; // Hand-to-hand: the grant itself, or a printed upgrade. let hthType = plan.hth?.hthId ?? "none"; @@ -409,6 +316,12 @@ export function assembleSkills(occ: Occ, selections: BuilderSelections): Assembl } // O.C.C. choice slots. + const knownChoiceKeys = new Set(plan.choices.map((slot) => slot.key)); + for (const suppliedKey of Object.keys(selections.occChoices)) { + if (!knownChoiceKeys.has(suppliedKey)) { + errors.push(`Unknown O.C.C. choice slot "${suppliedKey}" for "${occ.name}".`); + } + } for (const slot of plan.choices) { const picks = selections.occChoices[slot.key] ?? []; if (picks.length !== slot.choose) { @@ -420,14 +333,31 @@ export function assembleSkills(occ: Occ, selections: BuilderSelections): Assembl errors.push(`${slot.label}: "${pick.skillId}" is not an option for this slot.`); continue; } + const skill = getSkill(pick.skillId)!; + validatePickLabel(pick, skill, slot.label); skills.push({ skillId: pick.skillId, ...(slot.occBonus !== undefined ? { occBonus: slot.occBonus } : {}), ...(pick.label !== undefined ? { label: pick.label } : {}), }); + occOwnedIds.add(pick.skillId); } } + // RUE p.299: an O.C.C.-starting skill that normally has prerequisites + // includes those prerequisites as part of the character's formal training. + const addOccPrerequisites = (skillId: string): void => { + const skill = getSkill(skillId); + for (const requiredId of skill?.requires ?? []) { + if (!occOwnedIds.has(requiredId)) { + occOwnedIds.add(requiredId); + skills.push({ skillId: requiredId }); + addOccPrerequisites(requiredId); + } + } + }; + for (const skillId of occOwnedIds) addOccPrerequisites(skillId); + // O.C.C. Related picks: count (minus upgrade cost), eligibility, constraints, bonuses. if (upgradeCost > related.count) { errors.push( @@ -435,33 +365,63 @@ export function assembleSkills(occ: Occ, selections: BuilderSelections): Assembl ); } const relatedCount = Math.max(0, related.count - upgradeCost); - if (selections.related.length !== relatedCount) { + let relatedSpent = 0; + const perCategory: Partial> = {}; + const relatedEntries: CharacterSkill[] = []; + for (const pick of selections.related) { + const variants = related.variants.filter((variant) => variant.skill.id === pick.skillId); + if (variants.length === 0) { + errors.push(`O.C.C. Related skills: "${pick.skillId}" is not an eligible pick.`); + continue; + } + let variant: RelatedSkillVariant | undefined; + if (pick.category !== undefined) { + variant = variants.find((candidate) => candidate.category === pick.category); + if (!variant) { + errors.push( + `O.C.C. Related skills: "${pick.skillId}" is not eligible through ${pick.category}.`, + ); + continue; + } + } else if (variants.length === 1) { + variant = variants[0]; + } else { + errors.push( + `O.C.C. Related skills: "${pick.skillId}" is available through multiple categories; choose one.`, + ); + continue; + } + validatePickLabel(pick, variant.skill, "O.C.C. Related skills"); + relatedSpent += variant.cost; + perCategory[variant.category] = (perCategory[variant.category] ?? 0) + 1; + if (pick.skillId === BASIC_HAND_TO_HAND_SKILL_ID) { + if (hthType === "none") { + hthType = "basic"; + } else { + errors.push('O.C.C. Related skills: "Hand to Hand: Basic" is already trained.'); + } + } else { + relatedEntries.push({ + skillId: pick.skillId, + ...(variant.bonus !== undefined ? { categoryBonus: variant.bonus } : {}), + ...(pick.label !== undefined ? { label: pick.label } : {}), + }); + } + } + + if (relatedSpent !== relatedCount) { errors.push( - `O.C.C. Related skills: pick ${relatedCount}` + + `O.C.C. Related skills: spend ${relatedCount} selections` + (upgradeCost > 0 ? ` (${related.count} minus ${upgradeCost} for the hand-to-hand upgrade)` : "") + - ` (picked ${selections.related.length}).`, + ` (spent ${relatedSpent}).`, ); } - const relatedIds = new Set(related.options.map((s) => s.id)); - const perCategory: Record = {}; - for (const pick of selections.related) { - const skill = getSkill(pick.skillId); - if (!skill || !relatedIds.has(pick.skillId)) { - errors.push(`O.C.C. Related skills: "${pick.skillId}" is not an eligible pick.`); - continue; - } - perCategory[skill.category] = (perCategory[skill.category] ?? 0) + 1; - const bonus = related.categoryBonuses[skill.category]; - skills.push({ - skillId: pick.skillId, - ...(bonus !== undefined ? { categoryBonus: bonus } : {}), - ...(pick.label !== undefined ? { label: pick.label } : {}), - }); - } + skills.push(...relatedEntries); for (const constraint of related.constraints) { - const have = perCategory[constraint.fromCategory] ?? 0; + const category = constraint.fromCategory as SkillCategory; + const have = perCategory[category] ?? 0; if (have < constraint.min) { errors.push( `O.C.C. Related skills: at least ${constraint.min} from ${constraint.fromCategory} (picked ${have}).`, @@ -477,14 +437,34 @@ export function assembleSkills(occ: Occ, selections: BuilderSelections): Assembl } const secondaryIds = new Set(secondary.options.map((s) => s.id)); for (const pick of selections.secondary) { - if (!secondaryIds.has(pick.skillId)) { + const skill = getSkill(pick.skillId); + if (!skill || !secondaryIds.has(pick.skillId)) { errors.push(`Secondary skills: "${pick.skillId}" is not an eligible pick.`); continue; } - skills.push({ - skillId: pick.skillId, - ...(pick.label !== undefined ? { label: pick.label } : {}), - }); + validatePickLabel(pick, skill, "Secondary skills"); + if (pick.skillId === BASIC_HAND_TO_HAND_SKILL_ID) { + if (hthType === "none") { + hthType = "basic"; + } else { + errors.push('Secondary skills: "Hand to Hand: Basic" is already trained.'); + } + } else { + skills.push({ + skillId: pick.skillId, + ...(pick.label !== undefined ? { label: pick.label } : {}), + }); + } + } + + const completedIds = new Set(skills.map((entry) => entry.skillId)); + for (const entry of skills) { + const skill = getSkill(entry.skillId); + for (const requiredId of skill?.requires ?? []) { + if (!completedIds.has(requiredId)) { + errors.push(`"${skill!.name}" requires "${getSkill(requiredId)?.name ?? requiredId}".`); + } + } } // Duplicates across the whole build: repeatable skills need distinct labels. @@ -498,6 +478,8 @@ export function assembleSkills(occ: Occ, selections: BuilderSelections): Assembl } if (skill && skill.repeatable !== true) { errors.push(`"${skill.name}" cannot be taken twice.`); + } else if (skill?.maxSelections !== undefined && labels.size >= skill.maxSelections) { + errors.push(`"${skill.name}" can be taken at most ${skill.maxSelections} times.`); } else if (labels.has(entry.label)) { errors.push( `"${skill?.name ?? entry.skillId}" is picked more than once with the same label — label each pick (e.g. which language).`, diff --git a/packages/rules/src/engine/character.ts b/packages/rules/src/engine/character.ts index 01a4cef..1e109b3 100644 --- a/packages/rules/src/engine/character.ts +++ b/packages/rules/src/engine/character.ts @@ -6,6 +6,7 @@ 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 { assembleSkills } from "./builder.ts"; import { diceMax, diceMin } from "./dice.ts"; import { describeOccEligibilityFailure, validateOccEligibility } from "./eligibility.ts"; import { armorMaxPool, armorNeedsRoll, getItem } from "./items.ts"; @@ -19,6 +20,7 @@ import { type CombatProfile, type LifeState, type StatRange, + vitals, } from "./combat.ts"; import { getOcc, ppeRange } from "./occ.ts"; import { getSkill, iqSkillBonus, resolveSkill, type ResolvedSkill } from "./skills.ts"; @@ -58,6 +60,17 @@ export interface SheetArmor { current?: number; } +export interface SdcContribution { + kind: "base" | "occ" | "skill"; + id: string; + name: string; + formula: string; + source: { + book: string; + page: number; + }; +} + export interface CharacterSheet { name: string; occ: { id: string; name: string; category: string }; @@ -76,6 +89,7 @@ export interface CharacterSheet { vitals: { hitPoints: StatValue; sdc: StatValue; + sdcBreakdown: SdcContribution[]; comaDeathFloor: number; lifeState: LifeState; /** Days of battle-injury treatment already applied this course. */ @@ -92,6 +106,43 @@ export interface CharacterSheet { armor?: SheetArmor; } +/** Source-owned additive physical S.D.C. formulas for one completed build. */ +export function physicalSdcContributions( + occ: Occ, + skills: readonly Character["skills"][number][], +): SdcContribution[] { + const contributions: SdcContribution[] = [ + { + kind: "base", + id: "physical-sdc-base", + name: "Physical S.D.C. base", + formula: vitals.physicalSdc.baseFormula, + source: { book: "Rifts Ultimate Edition", page: 287 }, + }, + ]; + if (occ.sdcBonus !== undefined) { + contributions.push({ + kind: "occ", + id: occ.id, + name: occ.name, + formula: occ.sdcBonus, + source: { book: occ.source.book, page: occ.source.page }, + }); + } + for (const entry of skills) { + const skill = getSkill(entry.skillId); + if (skill?.sdcBonus === undefined) continue; + contributions.push({ + kind: "skill", + id: skill.id, + name: skill.name, + formula: skill.sdcBonus, + source: { book: "Rifts Ultimate Edition", page: skill.page }, + }); + } + return contributions; +} + /** Total O.C.C. save bonus for a given save target at a level (respects level gating). */ function occSaveBonus(occ: Occ, target: string, level: number): number { let total = 0; @@ -141,9 +192,29 @@ export function deriveSheet(input: CharacterInput): CharacterSheet { const { level } = character; const iqBonus = iqSkillBonus(attrs.IQ); const attributeBonuses = deriveAttributeBonuses(attrs); + const skillSelections = character.skillSelections; + const hasAuthoritativeSkills = skillSelections !== undefined; + let effectiveSkills = character.skills; + let effectiveHthType = character.hthType ?? "none"; + if (skillSelections !== undefined) { + if (character.skills.length > 0 || character.hthType !== undefined) { + throw new Error( + "Authoritative skill selections cannot include legacy skills or hthType metadata.", + ); + } + const assembled = assembleSkills(occ, { + ...skillSelections, + alignmentId: character.alignmentId, + }); + if (assembled.errors.length > 0) { + throw new Error(`Invalid skill selections: ${assembled.errors.join(" ")}`); + } + effectiveSkills = assembled.skills; + effectiveHthType = assembled.hthType; + } const combat = combatProfile({ attributes: attrs, - hthType: character.hthType, + hthType: effectiveHthType, level, }); const { saveBonuses, ...sheetCombat } = combat; @@ -180,7 +251,25 @@ export function deriveSheet(input: CharacterInput): CharacterSheet { character.rolled?.hitPoints, character.current?.hitPoints, ); - const sdc = withRolled(physicalSdcRange(), character.rolled?.sdc, character.current?.sdc); + const sdcBreakdown = physicalSdcContributions(occ, effectiveSkills); + const sdcRange = physicalSdcRange( + sdcBreakdown.slice(1).map((contribution) => contribution.formula), + ); + const rolledSdc = character.rolled?.sdc; + const legacySdcRange = physicalSdcRange([]); + const rolledSdcIsCurrent = + rolledSdc === undefined || (rolledSdc >= sdcRange.min && rolledSdc <= sdcRange.max); + const rolledSdcIsLegacyBase = + rolledSdc !== undefined && + !hasAuthoritativeSkills && + rolledSdc >= legacySdcRange.min && + rolledSdc <= legacySdcRange.max; + if (!rolledSdcIsCurrent && !rolledSdcIsLegacyBase) { + throw new Error( + `rolled.sdc (${rolledSdc}) is outside the derived range (${sdcRange.min}-${sdcRange.max}).`, + ); + } + const sdc = withRolled(sdcRange, rolledSdc, character.current?.sdc); let lifeState: LifeState; if (character.current?.lifeState === "dead") { @@ -228,19 +317,24 @@ export function deriveSheet(input: CharacterInput): CharacterSheet { comaDeath: { bonus: saveBonuses.comaDeathPct, percent: true }, }; - const seenSkillIds = new Set(); - const skills = character.skills.map((s): ResolvedSkill => { + const seenSkillCounts = new Map(); + const skills = effectiveSkills.map((s): ResolvedSkill => { const skill = getSkill(s.skillId); if (!skill) throw new Error(`Unknown skill "${s.skillId}".`); - if (seenSkillIds.has(skill.id) && !skill.repeatable) { + const priorSelections = seenSkillCounts.get(skill.id) ?? 0; + if (priorSelections > 0 && !skill.repeatable) { throw new Error(`Skill "${skill.id}" cannot be taken twice.`); } - seenSkillIds.add(skill.id); + if (skill.maxSelections !== undefined && priorSelections >= skill.maxSelections) { + throw new Error(`Skill "${skill.id}" can be taken at most ${skill.maxSelections} times.`); + } + seenSkillCounts.set(skill.id, priorSelections + 1); const resolved = resolveSkill(skill.id, { level, occBonus: s.occBonus, categoryBonus: s.categoryBonus, iqBonus, + otherBonus: priorSelections > 0 ? skill.repeatBonus : undefined, overrideValue: s.overrideValue, }); if (!resolved) throw new Error(`Unknown skill "${s.skillId}".`); @@ -324,6 +418,7 @@ export function deriveSheet(input: CharacterInput): CharacterSheet { vitals: { hitPoints, sdc, + sdcBreakdown, comaDeathFloor: floor, lifeState, treatmentDays: character.current?.treatmentDays ?? 0, diff --git a/packages/rules/src/engine/combat.ts b/packages/rules/src/engine/combat.ts index 5fce423..864b73c 100644 --- a/packages/rules/src/engine/combat.ts +++ b/packages/rules/src/engine/combat.ts @@ -59,13 +59,23 @@ export function rollHitPoints(pe: number, level: number, rng: Rng = Math.random) return hp; } -export function physicalSdcRange(): StatRange { - const f = vitals.physicalSdc.baseFormula; - return { min: diceMin(f), max: diceMax(f), average: diceAverage(f) }; +export function physicalSdcRange(contributions: readonly string[] = []): StatRange { + const formulas = [vitals.physicalSdc.baseFormula, ...contributions]; + return { + min: formulas.reduce((total, formula) => total + diceMin(formula), 0), + max: formulas.reduce((total, formula) => total + diceMax(formula), 0), + average: formulas.reduce((total, formula) => total + diceAverage(formula), 0), + }; } -export function rollPhysicalSdc(rng: Rng = Math.random): number { - return rollDice(vitals.physicalSdc.baseFormula, rng); +export function rollPhysicalSdc( + contributions: readonly string[] = [], + rng: Rng = Math.random, +): number { + return [vitals.physicalSdc.baseFormula, ...contributions].reduce( + (total, formula) => total + rollDice(formula, rng), + 0, + ); } /** Negative-H.P. threshold a character can survive to (coma until then, dead below). */ diff --git a/packages/rules/src/engine/occ.ts b/packages/rules/src/engine/occ.ts index 7b1f0ce..7865b62 100644 --- a/packages/rules/src/engine/occ.ts +++ b/packages/rules/src/engine/occ.ts @@ -1,6 +1,8 @@ import { occSchema, type Occ } from "../schema/occ.ts"; import leyLineWalkerRaw from "../content/occ/ley-line-walker.json" with { type: "json" }; import { diceAverage, diceMax, diceMin, rollDice, type Rng } from "./dice.ts"; +import { getHandToHand } from "./combat.ts"; +import { getSkill, skillCatalog, skillsForPolicy, validateSkillPolicy } from "./skills.ts"; import { getSpecies } from "./species.ts"; /** The Ley Line Walker O.C.C. (RUE pp.113-116), validated at load. */ @@ -15,7 +17,200 @@ export function validateOccSpeciesReferences(occ: Occ): void { } } +/** Fail fast when typed O.C.C. skill content cannot be resolved or satisfied. */ +export function validateOccSkillReferences(occ: Occ): void { + const fixedIds = new Set(); + const choiceKeys = new Set(); + let hthGrantSeen = false; + + for (const grant of occ.occSkills ?? []) { + if (grant.kind === "hth") { + if (hthGrantSeen) throw new Error(`O.C.C. "${occ.id}" has duplicate Hand-to-Hand grants.`); + hthGrantSeen = true; + if (getHandToHand(grant.hthId) === undefined) { + throw new Error(`O.C.C. "${occ.id}" references unknown Hand-to-Hand "${grant.hthId}".`); + } + const upgradeIds = new Set(); + for (const upgrade of grant.upgrades ?? []) { + if (getHandToHand(upgrade.hthId) === undefined) { + throw new Error( + `O.C.C. "${occ.id}" references unknown Hand-to-Hand upgrade "${upgrade.hthId}".`, + ); + } + if (upgradeIds.has(upgrade.hthId)) { + throw new Error( + `O.C.C. "${occ.id}" has duplicate Hand-to-Hand upgrade "${upgrade.hthId}".`, + ); + } + upgradeIds.add(upgrade.hthId); + } + continue; + } + + if (grant.kind === "choice") { + if (choiceKeys.has(grant.key)) { + throw new Error(`O.C.C. "${occ.id}" has duplicate choice key "${grant.key}".`); + } + choiceKeys.add(grant.key); + let optionCount = 0; + if (grant.pool.kind === "category") { + validateSkillPolicy( + grant.pool.policy, + grant.pool.category, + `O.C.C. "${occ.id}" choice "${grant.key}"`, + ); + optionCount = skillsForPolicy( + skillCatalog.skills, + grant.pool.category, + grant.pool.policy, + ).length; + } else { + for (const skillId of grant.pool.skillIds) { + if (getSkill(skillId) === undefined) { + throw new Error(`O.C.C. "${occ.id}" references unknown skill "${skillId}".`); + } + } + optionCount = grant.pool.skillIds.length; + } + if (grant.choose > optionCount) { + throw new Error( + `O.C.C. "${occ.id}" choice "${grant.key}" requires ${grant.choose} picks from only ${optionCount} options.`, + ); + } + continue; + } + + const skill = getSkill(grant.skillId); + if (skill === undefined) { + throw new Error(`O.C.C. "${occ.id}" references unknown skill "${grant.skillId}".`); + } + if (grant.kind === "repeatableChoice") { + if (choiceKeys.has(grant.key)) { + throw new Error(`O.C.C. "${occ.id}" has duplicate choice key "${grant.key}".`); + } + choiceKeys.add(grant.key); + if (skill.repeatable !== true) { + throw new Error( + `O.C.C. "${occ.id}" repeatable choice "${grant.key}" references non-repeatable skill "${grant.skillId}".`, + ); + } + continue; + } + if (fixedIds.has(grant.skillId)) { + throw new Error(`O.C.C. "${occ.id}" grants skill "${grant.skillId}" more than once.`); + } + fixedIds.add(grant.skillId); + } + + const related = occ.occRelatedSkills; + if (!related) return; + const rulesByCategory = new Map< + NonNullable[number]["category"], + NonNullable[number] + >(); + for (const rule of related.categoryRules ?? []) { + if (rulesByCategory.has(rule.category)) { + throw new Error( + `O.C.C. "${occ.id}" has duplicate related-skill rule for "${rule.category}".`, + ); + } + rulesByCategory.set(rule.category, rule); + validateSkillPolicy( + rule.policy, + rule.category, + `O.C.C. "${occ.id}" related "${rule.category}" policy`, + ); + const optionIds = new Set( + skillsForPolicy(skillCatalog.skills, rule.category, rule.policy).map((skill) => skill.id), + ); + const costIds = new Set(); + for (const exceptional of rule.exceptionalCosts ?? []) { + if (!optionIds.has(exceptional.skillId)) { + throw new Error( + `O.C.C. "${occ.id}" exceptional cost references ineligible skill "${exceptional.skillId}".`, + ); + } + if (costIds.has(exceptional.skillId)) { + throw new Error( + `O.C.C. "${occ.id}" has duplicate exceptional cost for "${exceptional.skillId}".`, + ); + } + costIds.add(exceptional.skillId); + } + } + + const minimumTotal = (related.constraints ?? []).reduce( + (total, constraint) => total + constraint.min, + 0, + ); + if (minimumTotal > related.count) { + throw new Error( + `O.C.C. "${occ.id}" minimum constraints require ${minimumTotal} selections but only ${related.count} are granted.`, + ); + } + const constraintSlots: { + category: string; + candidates: { skillId: string; cost: number; limit: number }[]; + }[] = []; + let minimumWeightedCost = 0; + for (const constraint of related.constraints ?? []) { + const rule = rulesByCategory.get(constraint.fromCategory); + const options = rule ? skillsForPolicy(skillCatalog.skills, rule.category, rule.policy) : []; + const exceptionalCosts = new Map( + (rule?.exceptionalCosts ?? []).map((entry) => [entry.skillId, entry.cost]), + ); + const candidates = options.map((skill) => ({ + skillId: skill.id, + cost: exceptionalCosts.get(skill.id) ?? 1, + limit: + skill.repeatable === true ? Math.min(minimumTotal, skill.maxSelections ?? minimumTotal) : 1, + })); + const optionCosts = candidates + .flatMap((candidate) => + Array.from({ length: Math.min(constraint.min, candidate.limit) }, () => candidate.cost), + ) + .sort((a, b) => a - b); + if (optionCosts.length < constraint.min) { + throw new Error( + `O.C.C. "${occ.id}" requires ${constraint.min} "${constraint.fromCategory}" selections but only ${optionCosts.length} are eligible.`, + ); + } + minimumWeightedCost += optionCosts + .slice(0, constraint.min) + .reduce((total, cost) => total + cost, 0); + for (let index = 0; index < constraint.min; index += 1) { + constraintSlots.push({ category: constraint.fromCategory, candidates }); + } + } + if (minimumWeightedCost > related.count) { + throw new Error( + `O.C.C. "${occ.id}" minimum constraints cost ${minimumWeightedCost} selections but only ${related.count} is granted.`, + ); + } + constraintSlots.sort((left, right) => left.candidates.length - right.candidates.length); + const usedBySkill = new Map(); + const canAssignMinimums = (slotIndex: number, spent: number): boolean => { + if (slotIndex >= constraintSlots.length) return true; + const slot = constraintSlots[slotIndex]!; + for (const candidate of slot.candidates) { + const used = usedBySkill.get(candidate.skillId) ?? 0; + if (used >= candidate.limit || spent + candidate.cost > related.count) continue; + usedBySkill.set(candidate.skillId, used + 1); + if (canAssignMinimums(slotIndex + 1, spent + candidate.cost)) return true; + if (used === 0) usedBySkill.delete(candidate.skillId); + else usedBySkill.set(candidate.skillId, used); + } + return false; + }; + if (!canAssignMinimums(0, 0)) { + throw new Error( + `O.C.C. "${occ.id}" minimum constraints cannot be satisfied without duplicating non-repeatable skills or exceeding ${related.count} selections.`, + ); + } +} + validateOccSpeciesReferences(leyLineWalker); +validateOccSkillReferences(leyLineWalker); /** All O.C.C.s currently modeled, keyed by id. */ export const occRegistry: Readonly> = { diff --git a/packages/rules/src/engine/skills.ts b/packages/rules/src/engine/skills.ts index 594bcbe..fa3ea16 100644 --- a/packages/rules/src/engine/skills.ts +++ b/packages/rules/src/engine/skills.ts @@ -1,14 +1,27 @@ -import { skillCatalogSchema, type Skill } from "../schema/skills.ts"; +import { + skillCatalogSchema, + type Skill, + type SkillCatalog, + type SkillCategory, + type SkillPoolPolicy, +} from "../schema/skills.ts"; import skillsRaw from "../content/skills/skills.json" with { type: "json" }; import { bonusesForAttribute } from "./attributes.ts"; -/** The skill catalog (RUE Skill Descriptions), validated at load. */ -export const skillCatalog = skillCatalogSchema.parse(skillsRaw); - function normalizeName(name: string): string { return name.trim().toLowerCase(); } +/** Every printed list heading under which this skill appears. */ +export function skillCategories(skill: Skill): readonly SkillCategory[] { + return [skill.category, ...(skill.additionalCategories ?? [])]; +} + +/** Whether a skill appears under a printed category heading. */ +export function skillIsInCategory(skill: Skill, category: SkillCategory): boolean { + return skillCategories(skill).includes(category); +} + /** * Build the id and name indexes for a set of skills, failing fast on any * collision. A duplicate id, or a name/alias that normalizes to a key already @@ -41,8 +54,82 @@ export function buildSkillIndexes(skills: readonly Skill[]): { return { byId, byName }; } +function validatePolicy( + policy: SkillPoolPolicy, + category: SkillCategory, + byId: ReadonlyMap, + context: string, +): void { + if (policy.kind === "any" || policy.kind === "none") return; + for (const id of policy.skillIds) { + const skill = byId.get(id); + if (!skill) throw new Error(`${context} references unknown skill "${id}".`); + if (!skillIsInCategory(skill, category)) { + throw new Error(`${context} references "${id}", which is not in the "${category}" category.`); + } + } +} + +/** Parse and validate the whole skill-content graph before any consumer sees it. */ +export function buildSkillCatalog(raw: unknown): SkillCatalog { + const catalog = skillCatalogSchema.parse(raw); + const { byId } = buildSkillIndexes(catalog.skills); + for (const skill of catalog.skills) { + for (const requiredId of skill.requires ?? []) { + if (!byId.has(requiredId)) { + throw new Error(`Skill "${skill.id}" requires unknown skill "${requiredId}".`); + } + } + } + const seenSecondaryCategories = new Set(); + for (const rule of catalog.secondarySkillRules) { + if (seenSecondaryCategories.has(rule.category)) { + throw new Error(`Secondary Skills has duplicate "${rule.category}" category rules.`); + } + seenSecondaryCategories.add(rule.category); + validatePolicy(rule.policy, rule.category, byId, `Secondary Skills "${rule.category}" policy`); + } + return catalog; +} + +/** Apply one typed pool policy to one printed category. */ +export function skillsForPolicy( + skills: readonly Skill[], + category: SkillCategory, + policy: SkillPoolPolicy, +): Skill[] { + const inCategory = skills.filter((skill) => skillIsInCategory(skill, category)); + if (policy.kind === "any") return inCategory; + if (policy.kind === "none") return []; + const ids = new Set(policy.skillIds); + return policy.kind === "include" + ? inCategory.filter((skill) => ids.has(skill.id)) + : inCategory.filter((skill) => !ids.has(skill.id)); +} + +/** The skill catalog (RUE Skill Descriptions), validated at load. */ +export const skillCatalog = buildSkillCatalog(skillsRaw); + const { byId: skillById, byName: skillByName } = buildSkillIndexes(skillCatalog.skills); +/** Validate a policy against the loaded catalog and its printed category membership. */ +export function validateSkillPolicy( + policy: SkillPoolPolicy, + category: SkillCategory, + context: string, +): void { + validatePolicy(policy, category, skillById, context); +} + +/** Independent p.300 Secondary Skills options, de-duplicated across headings. */ +export const secondarySkillOptions = [ + ...new Map( + skillCatalog.secondarySkillRules + .flatMap((rule) => skillsForPolicy(skillCatalog.skills, rule.category, rule.policy)) + .map((skill) => [skill.id, skill]), + ).values(), +]; + export function getSkill(id: string): Skill | undefined { return skillById.get(id); } diff --git a/packages/rules/src/schema/character.ts b/packages/rules/src/schema/character.ts index 7e05d40..4666954 100644 --- a/packages/rules/src/schema/character.ts +++ b/packages/rules/src/schema/character.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { skillCategorySchema } from "./skills.ts"; /** A skill the character has taken, with the O.C.C./category bonuses that apply. */ export const characterSkillSchema = z.object({ @@ -13,6 +14,27 @@ export const characterSkillSchema = z.object({ }); export type CharacterSkill = z.infer; +/** One player-authored skill decision; mechanical bonuses are intentionally absent. */ +export const skillPickSchema = z + .object({ + skillId: z.string().min(1), + label: z.string().min(1).optional(), + category: skillCategorySchema.optional(), + }) + .strict(); +export type SkillPickInput = z.infer; + +/** Authoritative new-build decisions persisted instead of flattened bonuses. */ +export const skillSelectionsSchema = z + .object({ + occChoices: z.record(z.string().min(1), z.array(skillPickSchema)), + related: z.array(skillPickSchema), + secondary: z.array(skillPickSchema), + hthId: z.string().min(1).optional(), + }) + .strict(); +export type SkillSelections = 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 @@ -102,13 +124,15 @@ export const characterSchema = z.object({ alignmentId: z.string().min(1).optional(), level: z.number().int().positive(), attributes: characterAttributesSchema, - /** Hand-to-Hand combat type id (e.g. "basic"). */ - hthType: z.string().min(1), + /** Legacy flattened Hand-to-Hand id; new builds derive it from skillSelections. */ + hthType: z.string().min(1).optional(), /** The character's psychic aptitude (sets the save-vs-psionics target). */ psychicClass: psychicClassSchema.default("ordinary"), /** Duplicate skillIds are checked in `deriveSheet`, where the catalog's * per-skill `repeatable` flag is available (schemas can't see content). */ skills: z.array(characterSkillSchema).default([]), + /** Present on authoritative new builds; absent on legacy flattened documents. */ + skillSelections: skillSelectionsSchema.optional(), spellIds: z .array(z.string().min(1)) .refine((arr) => new Set(arr).size === arr.length, { diff --git a/packages/rules/src/schema/occ.ts b/packages/rules/src/schema/occ.ts index 44a1dcf..dde31a8 100644 --- a/packages/rules/src/schema/occ.ts +++ b/packages/rules/src/schema/occ.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { attributeCodeSchema, sourceRefSchema } from "./attributes.ts"; import { diceFormulaSchema } from "./dice.ts"; +import { skillCategorySchema, skillPoolPolicySchema } from "./skills.ts"; /** A minimum attribute a character must have to take this O.C.C. */ export const attributeRequirementSchema = z.object({ @@ -66,18 +67,39 @@ const skillProgressionSchema = z.object({ export const occRelatedSkillsSchema = z.object({ count: z.number().int(), - constraints: z.array(z.object({ fromCategory: z.string(), min: z.number().int() })).optional(), + constraints: z + .array( + z + .object({ + fromCategory: skillCategorySchema, + min: z.number().int().positive(), + }) + .strict(), + ) + .optional(), progression: z.array(skillProgressionSchema).optional(), newSkillsStartAtLevel1: z.boolean().optional(), categoryRules: z .array( - z.object({ - category: z.string(), - /** Which skills of the category are permitted (free text for now). */ - allowed: z.string(), - /** Flat percentage O.C.C. bonus applied to skills taken from it. */ - bonus: z.number().optional(), - }), + z + .object({ + category: skillCategorySchema, + policy: skillPoolPolicySchema, + /** Flat percentage O.C.C. bonus applied to skills taken from it. */ + bonus: z.number().int().optional(), + /** A specific legal pick may consume more than one selection. */ + exceptionalCosts: z + .array( + z + .object({ + skillId: z.string().min(1), + cost: z.number().int().min(2), + }) + .strict(), + ) + .optional(), + }) + .strict(), ) .optional(), }); @@ -94,12 +116,90 @@ export const moneySchema = z.object({ blackMarketItems: diceFormulaSchema.optional(), }); -/** - * A skill granted directly by the O.C.C. Kept loose on purpose: entries name a - * skill (or a category to choose from) plus O.C.C. bonuses and choice rules, - * which the skills subsystem will resolve into concrete percentages later. - */ -export const occSkillGrantSchema = z.object({ skill: z.string() }).catchall(z.unknown()); +const fixedSkillGrantSchema = z + .object({ + kind: z.literal("fixed"), + skillId: z.string().min(1), + occBonus: z.number().int().optional(), + }) + .strict(); + +const overrideSkillGrantSchema = z + .object({ + kind: z.literal("override"), + skillId: z.string().min(1), + value: z.number().int().positive(), + }) + .strict(); + +const repeatableSkillChoiceGrantSchema = z + .object({ + kind: z.literal("repeatableChoice"), + key: z.string().min(1), + label: z.string().min(1), + skillId: z.string().min(1), + choose: z.number().int().positive(), + occBonus: z.number().int().optional(), + }) + .strict(); + +const skillChoicePoolSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("category"), + category: skillCategorySchema, + policy: skillPoolPolicySchema, + }) + .strict(), + z + .object({ + kind: z.literal("skills"), + skillIds: z + .array(z.string().min(1)) + .min(1) + .refine((ids) => new Set(ids).size === ids.length, { + message: "An O.C.C. skill choice pool cannot contain duplicate ids.", + }), + }) + .strict(), +]); + +const skillChoiceGrantSchema = z + .object({ + kind: z.literal("choice"), + key: z.string().min(1), + label: z.string().min(1), + choose: z.number().int().positive(), + pool: skillChoicePoolSchema, + occBonus: z.number().int().optional(), + }) + .strict(); + +const hthUpgradeSchema = z + .object({ + hthId: z.string().min(1), + cost: z.number().int().positive(), + requiresAlignmentCategory: z.string().min(1).optional(), + }) + .strict(); + +const hthSkillGrantSchema = z + .object({ + kind: z.literal("hth"), + hthId: z.string().min(1), + upgrades: z.array(hthUpgradeSchema).optional(), + }) + .strict(); + +/** A source-owned fixed grant, choice slot, or Hand-to-Hand grant. */ +export const occSkillGrantSchema = z.discriminatedUnion("kind", [ + fixedSkillGrantSchema, + overrideSkillGrantSchema, + repeatableSkillChoiceGrantSchema, + skillChoiceGrantSchema, + hthSkillGrantSchema, +]); +export type OccSkillGrant = z.infer; /** A special O.C.C. ability. Rich/descriptive; only `name` is required. */ export const occAbilitySchema = z.object({ name: z.string() }).catchall(z.unknown()); @@ -137,6 +237,8 @@ export const occSchema = z occSkills: z.array(occSkillGrantSchema).optional(), occRelatedSkills: occRelatedSkillsSchema.optional(), secondarySkills: secondarySkillsSchema.optional(), + /** Cumulative physical S.D.C. contribution, when printed by the O.C.C. */ + sdcBonus: diceFormulaSchema.optional(), bonuses: z.array(occBonusSchema).optional(), standardEquipment: z.array(z.string()).optional(), weapons: z.string().optional(), diff --git a/packages/rules/src/schema/skills.ts b/packages/rules/src/schema/skills.ts index 99710ed..2f9b399 100644 --- a/packages/rules/src/schema/skills.ts +++ b/packages/rules/src/schema/skills.ts @@ -1,4 +1,51 @@ import { z } from "zod"; +import { sourceRefSchema } from "./attributes.ts"; +import { diceFormulaSchema } from "./dice.ts"; + +/** Printed RUE skill-list headings (pp.302-303). */ +export const skillCategorySchema = z.enum([ + "Communication", + "Cowboy", + "Domestic", + "Electrical", + "Espionage", + "Horsemanship", + "Mechanical", + "Medical", + "Military", + "Physical", + "Pilot", + "Pilot Related", + "Rogue", + "Science", + "Technical", + "W.P. Ancient", + "W.P. Modern", + "Wilderness", +]); +export type SkillCategory = z.infer; + +const uniqueSkillIds = z + .array(z.string().min(1)) + .min(1) + .refine((ids) => new Set(ids).size === ids.length, { + message: "A skill policy cannot contain duplicate ids.", + }); + +/** A mechanically complete pool rule; printed prose is not parsed at runtime. */ +export const skillPoolPolicySchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("any") }), + z.object({ kind: z.literal("none") }), + z.object({ kind: z.literal("include"), skillIds: uniqueSkillIds }), + z.object({ kind: z.literal("exclude"), skillIds: uniqueSkillIds }), +]); +export type SkillPoolPolicy = z.infer; + +export const secondarySkillRuleSchema = z.object({ + category: skillCategorySchema, + policy: skillPoolPolicySchema, +}); +export type SecondarySkillRule = z.infer; /** * A single skill's rules. A skill's proficiency at level L is: @@ -7,36 +54,65 @@ import { z } from "zod"; * Some skills list two percentages (e.g. History, Track & Trap Animals) — the * second is carried in `baseSkill2` and grows at the same per-level rate. */ -export const skillSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - /** RUE skill category, e.g. "Wilderness", "Technical", "Communication". */ - category: z.string(), - /** Base skill percentage at level 1 (before any bonuses). */ - baseSkill: z.number().int().min(0), - /** Second base percentage for two-value skills. */ - baseSkill2: z.number().int().min(0).optional(), - /** Percent gained per experience level (0 for fixed skills). */ - perLevel: z.number().int().min(0), - /** True for flat skills that never grow with level (e.g. Native Tongue 98%). */ - fixed: z.boolean().optional(), - /** True for skills that can be taken more than once (e.g. Language: Other). */ - repeatable: z.boolean().optional(), - /** Alternate names this skill is printed under across books/O.C.C. entries, - * used for name-based resolution (e.g. "Lore: Demon & Monster"). */ - aliases: z.array(z.string().min(1)).optional(), - /** Prerequisite skill ids/names, if any. */ - requires: z.array(z.string()).optional(), - note: z.string().optional(), - /** Printed page in the source book. */ - page: z.number().int().positive(), -}); +export const skillSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + /** Primary RUE skill-list heading. */ + category: skillCategorySchema, + /** Additional headings that print the same skill by reference. */ + additionalCategories: z.array(skillCategorySchema).optional(), + /** Base skill percentage at level 1 (before any bonuses). */ + baseSkill: z.number().int().min(0), + /** Second base percentage for two-value skills. */ + baseSkill2: z.number().int().min(0).optional(), + /** Percent gained per experience level (0 for fixed skills). */ + perLevel: z.number().int().min(0), + /** True for flat skills that never grow with level (e.g. Native Tongue 98%). */ + fixed: z.boolean().optional(), + /** True for skills that can be taken more than once (e.g. Language: Other). */ + repeatable: z.boolean().optional(), + /** Printed ceiling for how many times this repeatable skill may be selected. */ + maxSelections: z.number().int().min(2).optional(), + /** One-time percentage added to each selection after the first. */ + repeatBonus: z.number().int().positive().optional(), + /** Alternate names this skill is printed under across books/O.C.C. entries, + * used for name-based resolution (e.g. "Lore: Demon & Monster"). */ + aliases: z.array(z.string().min(1)).optional(), + /** Prerequisite skill ids/names, if any. */ + requires: z.array(z.string().min(1)).optional(), + /** Cumulative physical S.D.C. contribution printed by the skill. */ + sdcBonus: diceFormulaSchema.optional(), + note: z.string().optional(), + /** Printed page in the source book. */ + page: z.number().int().positive(), + }) + .superRefine((skill, ctx) => { + if ( + (skill.maxSelections !== undefined || skill.repeatBonus !== undefined) && + skill.repeatable !== true + ) { + ctx.addIssue({ + code: "custom", + message: "maxSelections and repeatBonus require repeatable: true.", + }); + } + if (skill.repeatBonus !== undefined && skill.maxSelections === undefined) { + ctx.addIssue({ + code: "custom", + message: "repeatBonus requires a printed maxSelections limit.", + }); + } + }); export type Skill = z.infer; export const skillCatalogSchema = z.object({ book: z.string().min(1), /** Hard ceiling any skill percentage is capped at (98% in RUE). */ maxPercent: z.number().int().positive(), + secondarySkillsSource: sourceRefSchema, + /** Independent Secondary Skills list from RUE p.300. */ + secondarySkillRules: z.array(secondarySkillRuleSchema).default([]), skills: z.array(skillSchema), }); export type SkillCatalog = z.infer; diff --git a/packages/rules/tests/builder.test.ts b/packages/rules/tests/builder.test.ts index b9c6933..1a95711 100644 --- a/packages/rules/tests/builder.test.ts +++ b/packages/rules/tests/builder.test.ts @@ -9,6 +9,7 @@ import { rollPsionics, secondarySkillPlan, validateInitialSpells, + validateOccSkillReferences, type BuilderSelections, } from "../src/index.ts"; import type { Rng } from "../src/engine/dice.ts"; @@ -61,15 +62,19 @@ describe("occSkillPlan — the LLW's printed grants", () => { ).toEqual([ { key: "language-other", choose: 2, occBonus: 20 }, { key: "category:Pilot", choose: 1, occBonus: 5 }, - { key: "prefix:Lore", choose: 4, occBonus: 10 }, + { key: "lore", choose: 4, occBonus: 10 }, ]); expect(plan.choices[0]!.repeatable).toBe(true); expect(plan.choices[1]!.options.map((s) => s.id)).toContain("pilot-hovercycle"); expect(plan.choices[2]!.options.map((s) => s.id)).toEqual([ - "lore-demons-monsters", "lore-american-indians", "lore-cattle-animals", "lore-dbee", + "lore-demons-monsters", + "lore-faeries-creatures-magic", + "lore-juicers", + "lore-magic", + "lore-psychics-psionics", ]); }); @@ -84,8 +89,13 @@ describe("occSkillPlan — the LLW's printed grants", () => { })), ).toEqual([ { to: "Hand to Hand: Expert", hthId: "expert", cost: 1, available: true }, - { to: "Martial Arts", hthId: "martial-arts", cost: 2, available: true }, - { to: "Assassin", hthId: "assassin", cost: 2, available: true }, + { + to: "Hand to Hand: Martial Arts", + hthId: "martial-arts", + cost: 2, + available: true, + }, + { to: "Hand to Hand: Assassin", hthId: "assassin", cost: 2, available: true }, ]); }); }); @@ -107,7 +117,8 @@ describe("relatedSkillPlan — LLW related picks", () => { test("excluded and restricted categories drop out of the options", () => { const ids = new Set(plan.options.map((s) => s.id)); - // Communication is "Radio: Basic only" and radio-basic isn't in the catalog yet. + // Communication is Radio: Basic only. + expect(ids.has("radio-basic")).toBe(true); expect(ids.has("language-other")).toBe(false); expect(ids.has("language-native-tongue")).toBe(false); // Science/Technical/Wilderness are open. @@ -116,10 +127,17 @@ describe("relatedSkillPlan — LLW related picks", () => { expect(ids.has("track-trap-animals")).toBe(true); // Physical is "any except Gymnastics and Wrestling" — climbing stays. expect(ids.has("climbing")).toBe(true); + // Basic is already granted, so it must not appear as a redundant pick. + expect(ids.has("hand-to-hand-basic")).toBe(false); }); - test("unparsable printed rules are surfaced as notes", () => { - expect(plan.notes.join("\n")).toContain("Radio: Basic only"); + test("typed exceptional costs are preserved without prose notes", () => { + expect(plan.notes).toEqual([]); + expect( + plan.variants.find( + (variant) => variant.skill.id === "paramedic" && variant.category === "Medical", + )?.cost, + ).toBe(2); }); }); @@ -131,7 +149,7 @@ const legalSelections: BuilderSelections = { { skillId: "language-other", label: "Euro" }, ], "category:Pilot": [{ skillId: "pilot-hovercycle" }], - "prefix:Lore": [ + lore: [ { skillId: "lore-demons-monsters", label: "Vampires" }, { skillId: "lore-american-indians" }, { skillId: "lore-cattle-animals" }, @@ -142,18 +160,18 @@ const legalSelections: BuilderSelections = { { skillId: "math-advanced" }, { skillId: "biology" }, { skillId: "history-pre-rifts" }, - { skillId: "computer-operation" }, { skillId: "track-trap-animals" }, { skillId: "dowsing" }, { skillId: "pilot-automobile" }, + { skillId: "first-aid" }, ], secondary: [ { skillId: "carpentry" }, { skillId: "fasting" }, { skillId: "preserve-food" }, - { skillId: "spelunking" }, { skillId: "identify-plants-fruit" }, - { skillId: "boat-building" }, + { skillId: "body-building-weight-lifting" }, + { skillId: "gambling-standard" }, ], alignmentId: "scrupulous", }; @@ -224,7 +242,7 @@ describe("assembleSkills — rule violations", () => { occChoices: { "language-other": [{ skillId: "language-other", label: "Euro" }], "category:Pilot": [{ skillId: "boat-building" }], - "prefix:Lore": legalSelections.occChoices["prefix:Lore"]!, + lore: legalSelections.occChoices.lore!, }, related: [ { skillId: "math-advanced" }, @@ -270,6 +288,64 @@ describe("assembleSkills — rule violations", () => { expect(errors).toContainEqual(expect.stringContaining("same label")); }); + test("unknown O.C.C. choice-slot keys are rejected", () => { + const { errors } = assembleSkills(leyLineWalker, { + ...legalSelections, + occChoices: { + ...legalSelections.occChoices, + "stale-or-hostile-slot": [{ skillId: "boxing" }], + }, + }); + expect(errors).toContainEqual( + expect.stringContaining('Unknown O.C.C. choice slot "stale-or-hostile-slot"'), + ); + }); + + test("Paramedic consumes two related selections as structured content", () => { + const result = assembleSkills(leyLineWalker, { + ...legalSelections, + related: [ + { skillId: "math-advanced" }, + { skillId: "biology" }, + { skillId: "history-pre-rifts" }, + { skillId: "track-trap-animals" }, + { skillId: "dowsing" }, + { skillId: "paramedic" }, + ], + }); + expect(result.errors).toEqual([]); + expect(result.skills).toContainEqual({ skillId: "paramedic", categoryBonus: 5 }); + }); + + test("related and secondary prerequisites must exist in the completed build", () => { + const { errors } = assembleSkills(leyLineWalker, { + ...legalSelections, + related: [ + { skillId: "math-advanced" }, + { skillId: "biology" }, + { skillId: "history-pre-rifts" }, + { skillId: "track-trap-animals" }, + { skillId: "dowsing" }, + { skillId: "pilot-automobile" }, + { skillId: "computer-programming" }, + ], + }); + expect(errors).toContainEqual( + expect.stringContaining('"Computer Programming" requires "Computer Operation"'), + ); + expect(errors).toContainEqual( + expect.stringContaining('"Computer Programming" requires "Literacy: Other"'), + ); + }); + + test("a multi-category related skill requires the player to choose its category", () => { + const { errors } = assembleSkills(leyLineWalker, { + ...legalSelections, + related: [...legalSelections.related.slice(0, 6), { skillId: "recycling" }], + }); + expect(errors).toContainEqual(expect.stringContaining("available through multiple categories")); + }); + test("upgrading to Expert spends one related pick (#15)", () => { const result = assembleSkills(leyLineWalker, { ...legalSelections, @@ -314,23 +390,26 @@ describe("assembleSkills — hand-to-hand upgrades (synthetic O.C.C.)", () => { attributeRequirements: [], speciesEligibility: { kind: "any" }, occSkills: [ - { skill: "Climbing", skillId: "climbing", occBonus: 5 }, + { kind: "fixed", skillId: "climbing", occBonus: 5 }, { - skill: "Hand to Hand: None", + kind: "hth", hthId: "none", upgrades: [ - { to: "Basic", cost: { occRelatedSkills: 1 } }, - { to: "Hand to Hand: Expert", cost: { occRelatedSkills: 2 }, requiresAlignment: "evil" }, - { to: "Ninjutsu", cost: { occRelatedSkills: 1 } }, + { hthId: "basic", cost: 1 }, + { hthId: "expert", cost: 2, requiresAlignmentCategory: "evil" }, + { hthId: "ninjutsu", cost: 1 }, ], }, ], - occRelatedSkills: { count: 2, categoryRules: [{ category: "Science", allowed: "any" }] }, + occRelatedSkills: { + count: 2, + categoryRules: [{ category: "Science", policy: { kind: "any" } }], + }, }); test("an upgrade to an unmodeled style stays unavailable", () => { const plan = occSkillPlan(brawler); - expect(plan.hth?.upgrades.find((u) => u.to === "Ninjutsu")).toMatchObject({ + expect(plan.hth?.upgrades.find((u) => u.to === "ninjutsu")).toMatchObject({ hthId: undefined, available: false, }); @@ -380,9 +459,9 @@ describe("assembleSkills — hand-to-hand upgrades (synthetic O.C.C.)", () => { ...brawler, occSkills: [ { - skill: "Hand to Hand: None", + kind: "hth", hthId: "none", - upgrades: [{ to: "Basic", cost: { occRelatedSkills: 1 }, requiresAlignment: "evil" }], + upgrades: [{ hthId: "basic", cost: 1, requiresAlignmentCategory: "evil" }], }, ], }); @@ -406,6 +485,63 @@ describe("assembleSkills — hand-to-hand upgrades (synthetic O.C.C.)", () => { }); }); +describe("assembleSkills — hand-to-hand through O.C.C. Related training", () => { + const untrained = occSchema.parse({ + source: { book: "Test Fixture", page: 300 }, + id: "test-related-hand-to-hand", + name: "Test Related Hand to Hand", + category: "Test", + alignment: "Any", + attributeRequirements: [], + speciesEligibility: { kind: "any" }, + occRelatedSkills: { + count: 1, + categoryRules: [{ category: "Physical", policy: { kind: "any" } }], + }, + }); + + test("an untrained O.C.C. can learn Basic through an eligible related pick", () => { + expect(relatedSkillPlan(untrained).options.map((skill) => skill.id)).toContain( + "hand-to-hand-basic", + ); + + const assembled = assembleSkills(untrained, { + occChoices: {}, + related: [{ skillId: "hand-to-hand-basic", category: "Physical" }], + secondary: [], + }); + expect(assembled.errors).toEqual([]); + expect(assembled.hthType).toBe("basic"); + expect(assembled.skills).not.toContainEqual({ skillId: "hand-to-hand-basic" }); + }); +}); + +describe("O.C.C.-starting prerequisite training (RUE p.299)", () => { + test("automatically supplies prerequisites for a typed fixed grant", () => { + const occ = occSchema.parse({ + source: { book: "Test Fixture", page: 299 }, + id: "prerequisite-training", + name: "Prerequisite Training", + category: "Test", + alignment: "Any", + attributeRequirements: [], + speciesEligibility: { kind: "any" }, + occSkills: [{ kind: "fixed", skillId: "computer-programming" }], + }); + const assembled = assembleSkills(occ, { + occChoices: {}, + related: [], + secondary: [], + }); + expect(assembled.errors).toEqual([]); + expect(assembled.skills.map((skill) => skill.skillId)).toEqual([ + "computer-programming", + "computer-operation", + "literacy", + ]); + }); +}); + describe("validateInitialSpells — LLW picks 3 from each of levels 1-4", () => { test("a legal 12-spell selection passes", () => { expect(validateInitialSpells(leyLineWalker, legalSpells)).toEqual([]); @@ -442,10 +578,11 @@ describe("secondarySkillPlan", () => { const plan = secondarySkillPlan(leyLineWalker); expect(plan.count).toBe(6); expect(plan.options.map((s) => s.id)).toContain("carpentry"); + expect(plan.options.map((s) => s.id)).not.toContain("hand-to-hand-basic"); expect(plan.notes.join(" ")).toContain("Secondary Skills"); }); - test("an O.C.C. with secondary skills but no related rules pools the whole catalog", () => { + test("an O.C.C. with no related rules still uses the independent p.300 pool", () => { const occ = occSchema.parse({ source: { book: "Test Fixture", page: 1 }, id: "test-secondary-only", @@ -458,7 +595,33 @@ describe("secondarySkillPlan", () => { }); const plan = secondarySkillPlan(occ); expect(plan.count).toBe(3); - expect(plan.options.length).toBeGreaterThan(0); + expect(plan.options.map((skill) => skill.id)).toContain("radio-basic"); + expect(plan.options.map((skill) => skill.id)).not.toContain("cryptography"); + }); + + test("an untrained O.C.C. can learn Basic through its printed Secondary option", () => { + const occ = occSchema.parse({ + source: { book: "Test Fixture", page: 300 }, + id: "test-secondary-hand-to-hand", + name: "Test Secondary Hand to Hand", + category: "Test", + alignment: "Any", + attributeRequirements: [], + speciesEligibility: { kind: "any" }, + secondarySkills: { count: 1 }, + }); + expect(secondarySkillPlan(occ).options.map((skill) => skill.id)).toContain( + "hand-to-hand-basic", + ); + + const assembled = assembleSkills(occ, { + occChoices: {}, + related: [], + secondary: [{ skillId: "hand-to-hand-basic" }], + }); + expect(assembled.errors).toEqual([]); + expect(assembled.hthType).toBe("basic"); + expect(assembled.skills).not.toContainEqual({ skillId: "hand-to-hand-basic" }); }); }); @@ -473,20 +636,40 @@ describe("content-bug fail-fasts and edge counts", () => { speciesEligibility: { kind: "any" }, }; - test("a category choice with no catalog skills throws at plan time", () => { + test("a category choice with no legal options fails content validation", () => { const occ = occSchema.parse({ ...baseOcc, - occSkills: [{ skill: "Medical", chooseFromCategory: "Medical", choose: 1 }], + occSkills: [ + { + kind: "choice", + key: "empty", + label: "Empty", + choose: 1, + pool: { + kind: "category", + category: "Medical", + policy: { kind: "none" }, + }, + }, + ], }); - expect(() => occSkillPlan(occ)).toThrow(/category "Medical".*no skills/); + expect(() => validateOccSkillReferences(occ)).toThrow(/requires 1 picks from only 0 options/); }); - test("a prefix choice matching nothing throws at plan time", () => { + test("an explicit choice with an unknown id fails content validation", () => { const occ = occSchema.parse({ ...baseOcc, - occSkills: [{ skill: "W.P.", skillPrefix: "W.P.", choose: 2 }], + occSkills: [ + { + kind: "choice", + key: "missing", + label: "Missing", + choose: 1, + pool: { kind: "skills", skillIds: ["not-real"] }, + }, + ], }); - expect(() => occSkillPlan(occ)).toThrow(/prefixed "W\.P\."/); + expect(() => validateOccSkillReferences(occ)).toThrow(/unknown skill "not-real"/); }); test("an upgrade costing more than the related grant errors instead of going negative", () => { @@ -494,12 +677,15 @@ describe("content-bug fail-fasts and edge counts", () => { ...baseOcc, occSkills: [ { - skill: "Hand to Hand: None", + kind: "hth", hthId: "none", - upgrades: [{ to: "Basic", cost: { occRelatedSkills: 3 } }], + upgrades: [{ hthId: "basic", cost: 3 }], }, ], - occRelatedSkills: { count: 2, categoryRules: [{ category: "Science", allowed: "any" }] }, + occRelatedSkills: { + count: 2, + categoryRules: [{ category: "Science", policy: { kind: "any" } }], + }, }); const { errors } = assembleSkills(occ, { occChoices: {}, @@ -514,12 +700,34 @@ describe("content-bug fail-fasts and edge counts", () => { test("zoology can be taken twice (specialization) with distinct labels", () => { const { errors } = assembleSkills(leyLineWalker, { ...legalSelections, - secondary: [ - ...legalSelections.secondary.slice(0, 4), + related: [ + { skillId: "math-advanced" }, { skillId: "zoology", label: "Birds" }, { skillId: "zoology", label: "Reptiles" }, + { skillId: "history-pre-rifts" }, + { skillId: "track-trap-animals" }, + { skillId: "dowsing" }, + { skillId: "pilot-automobile" }, ], }); expect(errors).toEqual([]); }); + + test("zoology cannot be taken more than its printed two selections", () => { + const { errors } = assembleSkills(leyLineWalker, { + ...legalSelections, + related: [ + { skillId: "zoology", label: "General" }, + { skillId: "zoology", label: "Birds" }, + { skillId: "zoology", label: "Reptiles" }, + { skillId: "history-pre-rifts" }, + { skillId: "track-trap-animals" }, + { skillId: "dowsing" }, + { skillId: "pilot-automobile" }, + ], + }); + expect(errors).toContainEqual( + expect.stringContaining('"Zoology" can be taken at most 2 times'), + ); + }); }); diff --git a/packages/rules/tests/character.test.ts b/packages/rules/tests/character.test.ts index 4122826..d095d6d 100644 --- a/packages/rules/tests/character.test.ts +++ b/packages/rules/tests/character.test.ts @@ -16,6 +16,49 @@ const leyLineWalker: CharacterInput = { spellIds: ["globe-of-daylight", "energy-bolt", "armor-of-ithan"], }; +const authoritativeLeyLineWalker = { + name: "Kestrel", + occId: "ley-line-walker", + speciesId: "human", + alignmentId: "scrupulous", + level: 1, + attributes: { IQ: 12, ME: 10, MA: 9, PS: 11, PP: 13, PE: 14, PB: 10, Spd: 12 }, + skillSelections: { + occChoices: { + "language-other": [ + { skillId: "language-other", label: "Dragonese" }, + { skillId: "language-other", label: "Euro" }, + ], + "category:Pilot": [{ skillId: "pilot-hovercycle" }], + lore: [ + { skillId: "lore-demons-monsters", label: "Vampires" }, + { skillId: "lore-american-indians" }, + { skillId: "lore-cattle-animals" }, + { skillId: "lore-dbee" }, + ], + }, + related: [ + { skillId: "math-advanced" }, + { skillId: "biology" }, + { skillId: "history-pre-rifts" }, + { skillId: "track-trap-animals" }, + { skillId: "dowsing" }, + { skillId: "pilot-automobile" }, + { skillId: "first-aid" }, + ], + secondary: [ + { skillId: "carpentry" }, + { skillId: "fasting" }, + { skillId: "preserve-food" }, + { skillId: "identify-plants-fruit" }, + { skillId: "body-building-weight-lifting" }, + { skillId: "running" }, + ], + hthId: "basic", + }, + spellIds: ["globe-of-daylight", "energy-bolt", "armor-of-ithan"], +} as CharacterInput; + describe("deriveSheet — a level-1 Ley Line Walker", () => { const sheet = deriveSheet(leyLineWalker); @@ -122,6 +165,82 @@ describe("deriveSheet — species and O.C.C. eligibility", () => { }); }); +describe("deriveSheet — authoritative skill choices and additive S.D.C.", () => { + test("derives bonuses and Hand-to-Hand from player decisions", () => { + const sheet = deriveSheet(authoritativeLeyLineWalker); + expect(sheet.combat.handToHandType).toBe("basic"); + expect(sheet.skills.find((skill) => skill.id === "math-advanced")?.value).toBe(55); + expect(sheet.skills.find((skill) => skill.id === "language-other")).toMatchObject({ + value: 70, + label: "Dragonese", + }); + }); + + test("adds the p.287 base, Body Building, and Running with source breakdown", () => { + const sheet = deriveSheet(authoritativeLeyLineWalker); + expect(sheet.vitals.sdc).toEqual({ min: 25, max: 40, average: 32.5 }); + expect(sheet.vitals.sdcBreakdown).toEqual([ + { + kind: "base", + id: "physical-sdc-base", + name: "Physical S.D.C. base", + formula: "2D6+12", + source: { book: "Rifts Ultimate Edition", page: 287 }, + }, + { + kind: "skill", + id: "body-building-weight-lifting", + name: "Body Building & Weight Lifting", + formula: "10", + source: { book: "Rifts Ultimate Edition", page: 316 }, + }, + { + kind: "skill", + id: "running", + name: "Running", + formula: "1D6", + source: { book: "Rifts Ultimate Edition", page: 317 }, + }, + ]); + }); + + test("rejects a stored S.D.C. maximum outside the authoritative range", () => { + expect(() => deriveSheet({ ...authoritativeLeyLineWalker, rolled: { sdc: 24 } })).toThrow( + /rolled\.sdc \(24\).*outside the derived range \(25-40\)/, + ); + expect(() => deriveSheet({ ...authoritativeLeyLineWalker, rolled: { sdc: 41 } })).toThrow( + /rolled\.sdc \(41\).*outside the derived range \(25-40\)/, + ); + expect( + deriveSheet({ ...authoritativeLeyLineWalker, rolled: { sdc: 25 } }).vitals.sdc, + ).toMatchObject({ rolled: 25, current: 25 }); + }); + + test("keeps pre-#61 flattened Physical-skill rolls readable until rerolled", () => { + const legacy = deriveSheet({ + ...leyLineWalker, + skills: [{ skillId: "body-building-weight-lifting" }], + rolled: { sdc: 14 }, + }); + expect(legacy.vitals.sdc).toMatchObject({ + min: 24, + max: 34, + rolled: 14, + current: 14, + }); + }); + + test("rejects mixing authoritative choices with flattened bonus metadata", () => { + expect(() => + deriveSheet({ + ...authoritativeLeyLineWalker, + hthType: "basic", + skills: [{ skillId: "math-basic", occBonus: 999 }], + }), + ).toThrow(/cannot include legacy skills or hthType/i); + }); +}); + describe("deriveSheet — edge cases", () => { test("derives alive from positive H.P. and coma from zero through the floor", () => { const rolled = { hitPoints: 18, sdc: 20 }; @@ -286,6 +405,20 @@ describe("deriveSheet — edge cases", () => { ]); }); + test("a second Zoology selection gains its printed specialty bonus (RUE pp.322-323)", () => { + const sheet = deriveSheet({ + ...leyLineWalker, + skills: [ + { skillId: "zoology", label: "General" }, + { skillId: "zoology", label: "Birds" }, + ], + }); + expect(sheet.skills.map((skill) => ({ label: skill.label, value: skill.value }))).toEqual([ + { label: "General", value: 34 }, + { label: "Birds", value: 54 }, + ]); + }); + test("unknown skill and spell ids throw instead of silently dropping", () => { expect(() => deriveSheet({ ...leyLineWalker, skills: [{ skillId: "underwater-basket-weaving" }] }), diff --git a/packages/rules/tests/combat.test.ts b/packages/rules/tests/combat.test.ts index a58bbf6..5c6ba3f 100644 --- a/packages/rules/tests/combat.test.ts +++ b/packages/rules/tests/combat.test.ts @@ -10,6 +10,7 @@ import { physicalSdcRange, psionicsSaveTarget, rollHitPoints, + rollPhysicalSdc, saveTargetSchema, savingThrowTarget, } from "../src/index.ts"; @@ -31,6 +32,16 @@ describe("Hit Points & S.D.C. (RUE p.287)", () => { expect(physicalSdcRange()).toEqual({ min: 14, max: 24, average: 19 }); }); + test("physical S.D.C. adds every fixed and dice contribution", () => { + expect(physicalSdcRange(["10", "1D6"])).toEqual({ + min: 25, + max: 40, + average: 32.5, + }); + expect(rollPhysicalSdc(["10", "1D6"], () => 0)).toBe(25); + expect(rollPhysicalSdc(["10", "1D6"], () => 0.999)).toBe(40); + }); + test("coma/death floor is -(P.E.)", () => { expect(comaDeathFloor(12)).toBe(-12); }); diff --git a/packages/rules/tests/occ.test.ts b/packages/rules/tests/occ.test.ts index 996cd8c..5fb61ca 100644 --- a/packages/rules/tests/occ.test.ts +++ b/packages/rules/tests/occ.test.ts @@ -5,6 +5,7 @@ import { leyLineWalker, occSchema, rollBasePpe, + validateOccSkillReferences, validateOccSpeciesReferences, } from "../src/index.ts"; @@ -108,4 +109,140 @@ describe("Ley Line Walker O.C.C. (RUE pp.113-116)", () => { expect(leyLineWalker.money?.credits).toBe("1D4*1000"); expect(leyLineWalker.money?.blackMarketItems).toBe("3D4*1000"); }); + + test("uses typed skill grants and policies instead of runtime prose parsing", () => { + expect(leyLineWalker.occSkills?.map((grant) => grant.kind)).toEqual([ + "override", + "repeatableChoice", + "fixed", + "fixed", + "fixed", + "fixed", + "choice", + "fixed", + "choice", + "hth", + ]); + const medical = leyLineWalker.occRelatedSkills?.categoryRules?.find( + (rule) => rule.category === "Medical", + ); + expect(medical).toEqual({ + category: "Medical", + policy: { kind: "include", skillIds: ["first-aid", "paramedic"] }, + bonus: 5, + exceptionalCosts: [{ skillId: "paramedic", cost: 2 }], + }); + expect(leyLineWalker.occRelatedSkills?.categoryRules?.some((rule) => "allowed" in rule)).toBe( + false, + ); + }); + + test("validates every O.C.C.-to-skill reference and feasibility constraint", () => { + expect(() => validateOccSkillReferences(leyLineWalker)).not.toThrow(); + + const base = { + source: { book: "Fixture", page: 1 }, + id: "invalid-skills", + name: "Invalid skills", + category: "Test", + alignment: "Any", + attributeRequirements: [], + speciesEligibility: { kind: "any" }, + }; + const dangling = occSchema.parse({ + ...base, + occSkills: [{ kind: "fixed", skillId: "not-real" }], + }); + expect(() => validateOccSkillReferences(dangling)).toThrow( + /references unknown skill "not-real"/, + ); + + const wrongCategory = occSchema.parse({ + ...base, + occRelatedSkills: { + count: 1, + categoryRules: [ + { + category: "Science", + policy: { kind: "include", skillIds: ["first-aid"] }, + }, + ], + }, + }); + expect(() => validateOccSkillReferences(wrongCategory)).toThrow( + /"first-aid".*not in the "Science" category/, + ); + + const impossible = occSchema.parse({ + ...base, + occRelatedSkills: { + count: 1, + constraints: [{ fromCategory: "Science", min: 2 }], + categoryRules: [{ category: "Science", policy: { kind: "any" } }], + }, + }); + expect(() => validateOccSkillReferences(impossible)).toThrow( + /minimum constraints require 2 selections but only 1/, + ); + + const weightedImpossible = occSchema.parse({ + ...base, + occRelatedSkills: { + count: 1, + constraints: [{ fromCategory: "Medical", min: 1 }], + categoryRules: [ + { + category: "Medical", + policy: { kind: "include", skillIds: ["paramedic"] }, + exceptionalCosts: [{ skillId: "paramedic", cost: 2 }], + }, + ], + }, + }); + expect(() => validateOccSkillReferences(weightedImpossible)).toThrow( + /minimum constraints cost 2 selections but only 1 is granted/, + ); + + const sharedNonRepeatableImpossible = occSchema.parse({ + ...base, + occRelatedSkills: { + count: 2, + constraints: [ + { fromCategory: "Technical", min: 1 }, + { fromCategory: "Domestic", min: 1 }, + ], + categoryRules: [ + { + category: "Technical", + policy: { kind: "include", skillIds: ["recycling"] }, + }, + { + category: "Domestic", + policy: { kind: "include", skillIds: ["recycling"] }, + }, + ], + }, + }); + expect(() => validateOccSkillReferences(sharedNonRepeatableImpossible)).toThrow( + /minimum constraints cannot be satisfied without duplicating non-repeatable skills/, + ); + }); + + test("rejects legacy free-text related rules at schema load", () => { + expect(() => + occSchema.parse({ + source: { book: "Fixture", page: 1 }, + id: "legacy-prose", + name: "Legacy prose", + category: "Test", + alignment: "Any", + attributeRequirements: [], + speciesEligibility: { kind: "any" }, + occRelatedSkills: { + count: 1, + categoryRules: [{ category: "Medical", allowed: "First Aid only" }], + }, + }), + ).toThrow(); + }); }); diff --git a/packages/rules/tests/skills.test.ts b/packages/rules/tests/skills.test.ts index d9393a4..0506909 100644 --- a/packages/rules/tests/skills.test.ts +++ b/packages/rules/tests/skills.test.ts @@ -1,10 +1,14 @@ import { describe, expect, test } from "vite-plus/test"; import { + buildSkillCatalog, buildSkillIndexes, getSkillByName, iqSkillBonus, resolveSkill, + secondarySkillOptions, skillCatalog, + skillCategorySchema, + skillIsInCategory, } from "../src/index.ts"; describe("skill resolution (RUE p.299 formula)", () => { @@ -76,8 +80,22 @@ describe("skill index building fails fast on collisions", () => { test("a duplicate id throws", () => { expect(() => buildSkillIndexes([ - { id: "a", name: "A", category: "X", baseSkill: 10, perLevel: 5, page: 1 }, - { id: "a", name: "B", category: "X", baseSkill: 10, perLevel: 5, page: 1 }, + { + id: "a", + name: "A", + category: "Technical", + baseSkill: 10, + perLevel: 5, + page: 1, + }, + { + id: "a", + name: "B", + category: "Technical", + baseSkill: 10, + perLevel: 5, + page: 1, + }, ]), ).toThrow(/Duplicate skill id/); }); @@ -85,12 +103,19 @@ describe("skill index building fails fast on collisions", () => { test("a name/alias colliding with a different skill throws (no silent shadow)", () => { expect(() => buildSkillIndexes([ - { id: "a", name: "Foo", category: "X", baseSkill: 10, perLevel: 5, page: 1 }, + { + id: "a", + name: "Foo", + category: "Technical", + baseSkill: 10, + perLevel: 5, + page: 1, + }, { id: "b", name: "Bar", aliases: ["foo"], - category: "X", + category: "Technical", baseSkill: 10, perLevel: 5, page: 1, @@ -100,6 +125,142 @@ describe("skill index building fails fast on collisions", () => { }); }); +describe("typed skill content graph (RUE pp.299-330)", () => { + test("uses a closed set of printed skill categories", () => { + expect(skillCategorySchema.safeParse("Communication").success).toBe(true); + expect(skillCategorySchema.safeParse("W.P. Modern").success).toBe(true); + expect(skillCategorySchema.safeParse("Made Up").success).toBe(false); + }); + + test("pins corrected values from the complete descriptions, not the index", () => { + expect(getSkillByName("Excavation")).toMatchObject({ + baseSkill: 30, + perLevel: 5, + page: 323, + }); + expect(getSkillByName("Firefighting")).toMatchObject({ + baseSkill: 40, + perLevel: 5, + page: 323, + }); + expect(getSkillByName("Zoology")).toMatchObject({ + repeatable: true, + maxSelections: 2, + repeatBonus: 20, + page: 322, + }); + expect(getSkillByName("Appraise Goods")).toMatchObject({ + repeatable: true, + maxSelections: 2, + repeatBonus: 15, + page: 323, + }); + }); + + test("pins the complete unique roster under every printed heading", () => { + expect(skillCatalog.skills).toHaveLength(243); + expect( + Object.fromEntries( + [...skillCategorySchema.options].map((category) => [ + category, + skillCatalog.skills.filter((skill) => skillIsInCategory(skill, category)).length, + ]), + ), + ).toEqual({ + Communication: 18, + Cowboy: 10, + Domestic: 11, + Electrical: 5, + Espionage: 14, + Horsemanship: 6, + Mechanical: 9, + Medical: 14, + Military: 15, + Physical: 19, + Pilot: 27, + "Pilot Related": 3, + Rogue: 17, + Science: 14, + Technical: 36, + "W.P. Ancient": 17, + "W.P. Modern": 10, + Wilderness: 12, + }); + }); + + test("carries the printed cumulative S.D.C. formulas for Physical skills", () => { + expect(getSkillByName("Acrobatics")?.sdcBonus).toBe("1D6"); + expect(getSkillByName("Aerobic Athletics")?.sdcBonus).toBe("2D4"); + expect(getSkillByName("Athletics (General)")?.sdcBonus).toBe("1D8"); + expect(getSkillByName("Body Building & Weight Lifting")?.sdcBonus).toBe("10"); + expect(getSkillByName("Boxing")?.sdcBonus).toBe("3D6"); + expect(getSkillByName("Forced March")?.sdcBonus).toBe("2D6"); + expect(getSkillByName("Gymnastics")?.sdcBonus).toBe("2D6"); + expect(getSkillByName("Kick Boxing")?.sdcBonus).toBe("1D10"); + expect(getSkillByName("Outdoorsmanship")?.sdcBonus).toBe("2D6"); + expect(getSkillByName("Physical Labor")?.sdcBonus).toBe("2D8"); + expect(getSkillByName("Running")?.sdcBonus).toBe("1D6"); + expect(getSkillByName("Wrestling")?.sdcBonus).toBe("4D6"); + }); + + test("fails catalog loading for a dangling prerequisite", () => { + expect(() => + buildSkillCatalog({ + book: "Fixture", + maxPercent: 98, + secondarySkillsSource: { book: "Fixture", page: 1 }, + secondarySkillRules: [], + skills: [ + { + id: "advanced", + name: "Advanced", + category: "Technical", + baseSkill: 10, + perLevel: 5, + requires: ["missing"], + page: 1, + }, + ], + }), + ).toThrow(/requires unknown skill "missing"/); + }); +}); + +describe("Secondary Skills are their own p.300 pool", () => { + const optionIds = new Set(secondarySkillOptions.map((skill) => skill.id)); + + test("pins the independent pool to its printed source", () => { + expect(skillCatalog.secondarySkillsSource).toEqual({ + book: "Rifts Ultimate Edition", + page: 300, + table: "Secondary Skills", + }); + }); + + test("admits and excludes exact Communication choices", () => { + expect(optionIds.has("radio-basic")).toBe(true); + expect(optionIds.has("cryptography")).toBe(false); + expect(optionIds.has("laser-communications")).toBe(false); + expect(optionIds.has("surveillance-systems")).toBe(false); + expect(optionIds.has("tv-video")).toBe(false); + }); + + test("admits only the printed narrow Physical and modern W.P. choices", () => { + expect(optionIds.has("body-building-weight-lifting")).toBe(true); + expect(optionIds.has("running")).toBe(true); + expect(optionIds.has("boxing")).toBe(false); + expect(optionIds.has("wp-handguns")).toBe(true); + expect(optionIds.has("wp-energy-rifle")).toBe(true); + expect(optionIds.has("wp-shotgun")).toBe(false); + }); + + test("does not inherit LLW related-skill permissions", () => { + expect(optionIds.has("gambling-standard")).toBe(true); + expect(optionIds.has("computer-hacking")).toBe(false); + expect(optionIds.has("military-etiquette")).toBe(false); + }); +}); + describe("name-based resolution handles the book's naming variants", () => { test("O.C.C.-grant names resolve to catalog entries via aliases", () => { // LLW grants use the O.C.C.-entry wording, which differs from the skill-description name.