From 4c90f2efdd1918e7e088b8949b1717602e439576 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Wed, 22 Jul 2026 18:03:14 +0800 Subject: [PATCH 01/10] docs: design OCC breadth foundation --- ...026-07-22-occ-breadth-foundation-design.md | 475 ++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 .codex/superpowers/specs/2026-07-22-occ-breadth-foundation-design.md diff --git a/.codex/superpowers/specs/2026-07-22-occ-breadth-foundation-design.md b/.codex/superpowers/specs/2026-07-22-occ-breadth-foundation-design.md new file mode 100644 index 0000000..4a24616 --- /dev/null +++ b/.codex/superpowers/specs/2026-07-22-occ-breadth-foundation-design.md @@ -0,0 +1,475 @@ +# O.C.C. Breadth Foundation and Coalition Grunt Design + +**Date:** 2026-07-22 + +**Status:** Approved in conversation; implementation not started + +**Design branch:** `docs/occ-breadth-foundation-design` + +**Umbrella issue:** [#12 — Additional O.C.C.s beyond the Ley Line Walker](https://github.com/StreamDemon/RiftForge/issues/12) + +**Delivery issues:** [#60](https://github.com/StreamDemon/RiftForge/issues/60) -> [#61](https://github.com/StreamDemon/RiftForge/issues/61) -> [#62](https://github.com/StreamDemon/RiftForge/issues/62) -> [#57](https://github.com/StreamDemon/RiftForge/issues/57) + +## Goal + +Establish Coalition Grunt as RiftForge's second complete playable O.C.C. and +prove that the Ley Line Walker vertical generalizes without pulling power armor, +augmentation, playable Psi-Stalkers, or a full psionics system into the same +delivery. + +The work is deliberately split into three reusable prerequisites followed by a +focused integration: + +1. species identity and O.C.C. eligibility; +2. authoritative skill legality and additive physical S.D.C.; +3. structured starting loadouts and atomic provisioning; and +4. Coalition Grunt content and end-to-end integration. + +Each prerequisite ships through its own branch and pull request. The next slice +does not start until the preceding PR is merged by the human maintainer, local +`main` is synchronized, dependencies are installed, and the repository gates +pass. The split changes delivery order, not the final acceptance standard. + +## Approved decisions + +1. **Foundation first.** Coalition Grunt is the first new O.C.C.; Glitter Boy + and Juicer follow only after the shared foundation and Grunt integration. +2. **Separate prerequisite PRs.** Issues #60, #61, #62, and #57 are independently + reviewable and merge sequentially. +3. **Human is the compatibility species.** Human is playable and the default + identity for legacy records. Psi-Stalker is recorded only as deferred Grunt + eligibility. +4. **Choices, not bonuses, cross the authority boundary.** New builds persist + player skill decisions; the rules engine derives grants and bonuses. +5. **Starting inventory is server-authored.** The client submits loadout choices; + the backend resolves and provisions inventory atomically. +6. **New Ley Line Walkers use the loadout foundation.** Their current prose + equipment is migrated to structured content for new character creation. + Existing characters are never backfilled or rewritten. +7. **No class-specific engine branches.** O.C.C. differences live in validated, + page-stamped content rather than `occId` conditionals. +8. **Missing subsystems remain visible.** Every affected O.C.C. issue keeps its + `needs:*` labels until the dependency is actually implemented and verified. + +## Rendered rules evidence + +The local Rifts Ultimate Edition PDF is scanned and has no reliable text layer. +The following source areas were rendered and visually inspected during design; +implementation must rerender the exact pages it transcribes and follow +`docs/rules/TRANSCRIPTION.md`. + +| Printed pages | Design use | +| ------------- | -------------------------------------------------------------------------------------------------------------------- | +| 67-73 | Glitter Boy boundary and the need for a future power-armor subsystem. | +| 78-80 | Juicer boundary and the need for augmentation, additive bonuses, and attributes above 30. | +| 113-116 | Existing Ley Line Walker skills, equipment, weapons, money, and vehicle guidance. | +| 118-119 | Mystic boundary and the future psionics dependency. | +| 231-233 | Coalition military context and the complete Coalition Grunt entry; p.233 supplies the Human/Psi-Stalker restriction. | +| 286-287 | Physical S.D.C. base and accumulation boundary. | +| 304-329 | Skill descriptions and prerequisites required by the Grunt; complete touched sections must be transcribed. | + +No mechanic, amount, option, item, skill, or page reference may be filled from +memory. If rendered text conflicts with this design, the rendered page wins and +the discrepancy is brought back for approval. + +## Delivery architecture + +| Order | Issue | Deliverable | Activation rule | +| ----- | ----- | --------------------------------------- | -------------------------------------------------------------------------------- | +| 1 | #60 | Species identity and O.C.C. eligibility | Sole `next-up` issue now. | +| 2 | #61 | Skill legality and additive S.D.C. | Becomes `next-up` only after #60 is merged, synchronized, validated, and closed. | +| 3 | #62 | Structured starting loadouts | Becomes `next-up` only after #61 completes under the same gate. | +| 4 | #57 | Coalition Grunt integration | Starts only after all three foundations are on validated `main`. | +| 5 | #58 | Glitter Boy | Starts only after #57 is merged and validated. | +| 6 | #59 | Juicer | Starts only after #58 is merged and validated. | + +Every slice uses the normal protected-main workflow: + +```text +validated main -> feature branch -> implementation and evidence + -> active automated review -> human merge + -> sync and validate main -> activate successor +``` + +The agent never commits directly to `main`, merges a PR, or manually retriggers +an automated reviewer that already reviews new commits. + +## Species identity and eligibility (#60) + +### Content model + +Introduce a small source-stamped species catalog. Its first entries are: + +- Human: playable; +- Psi-Stalker: known but deferred and nonplayable. + +O.C.C. content replaces the current free-text racial requirement with a +discriminated eligibility rule: + +- `any`; or +- `oneOf` with explicit species IDs. + +Catalog validation rejects duplicate or unknown species IDs, empty `oneOf` +lists, and references that do not resolve. Production Coalition Grunt content +remains in #57; #60 uses a focused p.233 fixture to prove the first restricted +consumer. + +### Character and engine behavior + +- New characters write an explicit `speciesId`. +- Legacy characters without the field derive as Human without a data rewrite. +- A pure eligibility validator resolves species identity, playability, O.C.C. + species rules, and existing attribute requirements. +- The builder, `deriveSheet`, and backend writes call the same validator. +- Unknown or unavailable species fail closed. Deferred Psi-Stalker may appear in + explanatory eligibility text but cannot be selected or persisted. + +### Builder and sheet + +While Human is the only playable species, the builder shows it as a locked +identity field rather than adding an empty wizard step. The resolved sheet also +displays species. Tests cover explicit Human, the legacy default, unknown IDs, +deferred identity rejection, restricted O.C.C. eligibility, attribute failure, +backend rejection, and the live locked-field flow. + +### Explicit exclusions + +Playable Psi-Stalker content, R.C.C./O.C.C. interaction, I.S.P., psychic powers, +and psionics rules remain deferred to dedicated work and issue #14. + +## Skill legality and additive S.D.C. (#61) + +### Authoritative player choices + +New character builds store the decisions a player actually made: + +- picks for each O.C.C. choice slot; +- related-skill picks; +- secondary-skill picks; +- labels distinguishing repeatable skills; and +- the selected Hand-to-Hand option. + +O.C.C. bonuses, related-category bonuses, and fixed percentage overrides are no +longer client-authorable. A single pure assembler validates the choices and +derives the canonical fixed grants, chosen skills, bonuses, and Hand-to-Hand +result for builder preview, `deriveSheet`, and backend writes. + +Existing flattened Ley Line Walker skill records remain readable and usable. +New build writes cannot introduce or alter legacy bonus metadata. Editing a +legacy build requires resubmitting authoritative selections; unrelated combat, +resource, inventory, and narrative mutations continue to preserve it. + +### Typed skill restrictions + +Replace the current free-text related-skill rules with explicit policies over +catalog IDs: + +- `any`; +- `none`; +- `include`; or +- `exclude`. + +Exceptional costs such as one skill consuming two selections are structured +data. The printed Secondary Skills pool is modeled independently instead of +reusing the related-skill pool. + +Content loading fails for: + +- dangling prerequisites or grant references; +- unknown category or skill IDs; +- a referenced skill in the wrong category; +- duplicate or contradictory grants/rules; +- invalid exceptional costs; or +- minimum-pick constraints that the available pool cannot satisfy. + +The completed build must satisfy every selected skill's prerequisites. Missing +catalog prerequisites tracked by #22 are transcribed or corrected from rendered +source, never guessed. + +### Additive physical S.D.C. + +Add only the narrow channels required by the printed rules: + +- a fixed-or-dice S.D.C. bonus formula on a skill; and +- a typed O.C.C. S.D.C. bonus entry. + +The pure engine sums the printed physical S.D.C. base with every applicable +O.C.C. and selected-skill contribution. The derived sheet exposes the total +range and a source breakdown; it does not roll dice. + +`rollVitals` rolls the base and each applicable component server-side through an +injected random source, then persists only the final S.D.C. maximum. A stored +maximum outside the currently derived legal range is rejected, and current S.D.C. +continues to be constrained by that maximum. + +### Proof and issue reconciliation + +Rules, backend, and web tests cover exact rendered values, restrictions, +exceptional costs, prerequisites, legacy compatibility, S.D.C. accumulation and +ranges, and hostile payloads. Live acceptance creates a legal Ley Line Walker, +confirms its engine-derived bonuses, rolls the expanded S.D.C. path, and proves +that illegal selections cannot be submitted. + +Issues #22, #25, and #26 are audited after implementation. An issue closes only +if its complete current scope is proven satisfied; otherwise its body is updated +with the precise remaining gap. + +## Structured starting loadouts (#62) + +### Content model + +Each participating O.C.C. gains a source-stamped `startingLoadout` containing: + +- fixed catalog grants; +- fixed or dice-based quantities; +- named choice groups with exact selection counts; +- explicit item IDs or printed item-category constraints; and +- checklist-only entries for benefits without a real subsystem. + +Prose is never parsed to create inventory. Content loading rejects unknown item +IDs, empty or unsatisfiable choice groups, invalid quantities, duplicate keys, +contradictory options, and category mismatches. + +Vehicles, loose ammunition, credits, and incidental non-catalog gear remain +visible page-stamped checklist guidance. They never become invented inventory, +wallet, vehicle, or ammunition state. + +### Pure planning and builder behavior + +The authority flow is: + +```text +O.C.C. loadout content -> pure plan -> player choices + -> backend revalidation -> server rolls + -> stored inventory -> derived sheet +``` + +One pure planner returns fixed grants, legal choices, quantity ranges, and +checklist guidance. The builder adds an applicable loadout step, stores only +player selections, and clears selections owned by a previously selected O.C.C. + +Review shows fixed and selected items, ranges for quantities and armor capacity +that are not rolled yet, and a separate checklist. A printed category choice +offers only source-verified catalog items matching that structured category. + +### Atomic provisioning + +Character creation accepts loadout selections, not a client-authored starting +inventory. In one Convex mutation the backend: + +1. reloads authoritative O.C.C. content; +2. validates every selection; +3. rolls dice quantities and every dice-capacity armor instance; +4. constructs the item instances; +5. validates the complete character; and +6. inserts the character and inventory atomically. + +Any failure aborts the complete creation. Starting armor is granted as owned but +unworn; equipping is an explicit player action. After creation, inventory is +ordinary mutable state, so disposing of original equipment never invalidates the +character build. + +### Ley Line Walker migration boundary + +The existing Ley Line Walker prose equipment and weapon fields move to the +structured model. Newly forged Ley Line Walkers receive their catalog-backed +starting inventory. Existing characters receive no retroactive grants, rerolls, +replacement items, or document rewrites. + +Tests cover exact plans, malformed content, tampered choices, transactional +failure, quantity and per-suit armor rolls, builder reset behavior, review +presentation, and unchanged legacy characters. Live acceptance forges a new Ley +Line Walker, verifies its inventory and concrete armor roll after navigation and +reload, equips the armor, and confirms an older character remains unchanged. + +## Coalition Grunt integration (#57) + +### Content + +Transcribe the complete Coalition Grunt entry from rendered RUE pp. 231-233, +then render and transcribe every referenced skill and equipment source required +by the implementation. Production content includes: + +- requirements and Human/Psi-Stalker eligibility; +- fixed and selectable O.C.C. skills; +- related and secondary skill rules; +- O.C.C. and skill-derived bonuses; +- the complete starting loadout; +- pay; and +- cybernetics restrictions. + +All mechanics use the generic foundations from #60, #61, and #62. Pay, optional +augmentation guidance, and other non-live facts remain structured dossier or +checklist content rather than inventing absent subsystems. + +Only source-required catalog entries are added. The implementation does not add +SAMAS Pilot, Military Specialist, Technical Officer, Glitter Boy, Juicer, +Cyber-Knight, Mystic, or unrelated generic effect machinery. + +### Builder and live sheet + +- Human remains the locked playable species. +- Psi-Stalker is visible only as deferred eligibility information. +- Fixed skills are automatic; every required skill and loadout choice is + collected and validated. +- A non-caster path skips spell selection instead of presenting an empty step. +- Changing O.C.C. clears prior O.C.C.-owned skills, spells, and loadout choices. +- Review and sheet show species, O.C.C., resolved bonuses, accumulated S.D.C., + skills, provisioned inventory, and page-stamped dossier/checklist information. + +The implementation must not contain a Coalition-specific validation or +provisioning path. A legal Grunt is evidence that the content-driven system +works, not a second hard-coded vertical. + +### Backend boundary + +The generic eligibility, skill, S.D.C., and loadout pipelines revalidate the +complete build. Unknown/unavailable species, deferred Psi-Stalker, illegal +skills, client-authored bonuses, invalid equipment choices, client-authored +starting inventory, and illegal S.D.C. fail before insertion. Creation and all +server-owned rolls remain atomic. + +## Error handling + +Failures are owned by the layer with enough information to explain them: + +- catalog contradictions fail synchronously at content load; +- the pure planner/assembler returns actionable builder violations; +- the builder blocks progression while showing the violated rule; +- the backend reruns the same validation and rejects stale or hostile payloads; + and +- no write path silently clamps, drops, substitutes, or invents a rules value. + +Unavailable content is distinct from unknown content. A known deferred +Psi-Stalker can be explained to the player; an unknown species ID is corrupt +input. Likewise, checklist-only gear is intentionally unmodeled rather than +silently omitted. + +## Compatibility and migration + +- `speciesId` defaults to Human only for legacy records; no storage migration is + required. +- Legacy flattened skills remain runtime-compatible but cannot be authored by + new build writes. +- Existing inventories remain byte-for-byte unchanged by loadout delivery. +- New Ley Line Walkers and Grunts use authoritative selection/provisioning paths. +- Starting-loadout validity applies at creation only; later inventory mutations + do not need to reproduce the starting manifest. +- Existing Ley Line Walker magic, combat, healing, navigation, and inventory + behavior remains regression-covered. + +## Testing strategy + +### Rules package + +- Pin page stamps and exact rendered values for every new source. +- Reject malformed species, eligibility, skill, S.D.C., and loadout content. +- Test legal and illegal eligibility, including legacy Human and deferred + Psi-Stalker. +- Test complete skill assembly, prerequisites, restrictions, costs, duplicates, + repeatable labels, and derived bonuses. +- Test S.D.C. contribution ranges and deterministic minimum/maximum rolls. +- Test loadout planning, category constraints, quantities, and checklist output. +- Run complete legal Ley Line Walker and Coalition Grunt derivations through the + same public functions. + +### Backend + +- Reject hostile or stale identity, skill, bonus, loadout, inventory, and rolled + S.D.C. input. +- Prove character creation is atomic across validation, quantity rolls, armor + rolls, and insertion. +- Reload new Ley Line Walker and Grunt documents and derive identical sheets. +- Preserve legacy documents through resource, inventory, narrative, and combat + mutations. +- Prove an invalid request consumes no persistent write. + +### Web + +- Show locked Human identity and deferred eligibility clearly. +- Derive every skill bonus rather than trusting form state. +- Reset O.C.C.-owned choices when O.C.C. or route ownership changes. +- Skip inapplicable spell selection for the Grunt. +- Present unrolled loadout ranges, concrete post-create inventory, and checklist + guidance without implying unimplemented mechanics. +- Preserve accessible names, non-color status cues, narrow-layout containment, + and the Ley Terminal design system. + +## Live-browser acceptance + +Use local Convex and the real SolidJS app to verify the complete user-visible +story: + +1. Forge a Coalition Grunt. +2. Confirm Human identity, O.C.C., skills, S.D.C. range, inventory, and checklist. +3. Roll vitals, equip armor, and deal damage. +4. Navigate between the Grunt and a Ley Line Walker without a document remount; + confirm all route-owned drafts and async results reset correctly. +5. Cast with the Ley Line Walker to prove the magic vertical remains intact. +6. Reload both dossiers and confirm persisted state. +7. Forge a new Ley Line Walker through the shared loadout path and confirm its + provisioned inventory and armor roll. +8. Confirm a character created before #62 remains unchanged. +9. Check desktop and narrow layouts, keyboard operation, explicit labels, and a + clean browser console. + +## Validation gates + +Each implementation PR runs the affected package gates before publication. The +final evidence for every slice includes fresh, time-scoped results for: + +```text +vp run @riftforge/rules#check +vp run @riftforge/rules#test +vp run @riftforge/backend#check # when backend is affected +vp run @riftforge/backend#test # when backend is affected +vp run @riftforge/web#check # when web is affected +vp run @riftforge/web#test # when web is affected +vp check +vp test +git diff --check +``` + +User-visible changes additionally require the live-browser acceptance relevant +to that slice. Test counts are reported only as observations from the validated +revision. + +## Tracker and missing-subsystem labels + +The approved queue and dependency labels are: + +| Issue | O.C.C./foundation | Required labels while missing | +| ----- | ----------------------- | --------------------------------------------------------------------------------------- | +| #60 | Species foundation | `needs:species`, `next-up` | +| #61 | Skill/S.D.C. foundation | `needs:sdc-bonuses` | +| #62 | Loadout foundation | `needs:loadouts` | +| #57 | Coalition Grunt | `needs:species`, `needs:sdc-bonuses`, `needs:loadouts` | +| #58 | Glitter Boy | `needs:species`, `needs:power-armor`, `needs:sdc-bonuses`, `needs:loadouts` | +| #59 | Juicer | `needs:augmentation`, `needs:sdc-bonuses`, `needs:attributes-30-plus`, `needs:loadouts` | + +`needs:psionics` is reserved for future Cyber-Knight, Mystic, Psi-Stalker, or +other O.C.C./R.C.C. issues that genuinely require the unimplemented psionics +subsystem. + +A dependency label is removed only after its implementing PR is merged and the +result is synchronized and verified on local `main`. Exactly one issue carries +`next-up`. After #57 completes, #58 becomes that issue; #59 remains queued after +#58. + +## Success criteria + +This design is fully delivered when: + +- #60, #61, and #62 have each merged through their independent review gates; +- their dependency labels have been removed only from issues they genuinely + unblock; +- Coalition Grunt is a complete, source-faithful, playable second O.C.C.; +- new Ley Line Walkers and Grunts use the same authoritative skill and loadout + machinery; +- existing characters remain compatible and unmodified; +- hostile clients cannot author species eligibility, skill bonuses, starting + inventory, armor rolls, or S.D.C. totals outside the approved boundaries; +- package, root, diff, and live-browser gates pass with fresh evidence; +- #57 is reviewed and merged by the human maintainer; and +- #58 is the sole `next-up` issue with every still-missing subsystem visibly + tagged. From a8ec30ba5104f0e513875680317e577c77647838 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Wed, 22 Jul 2026 18:24:35 +0800 Subject: [PATCH 02/10] docs: plan species eligibility foundation --- ...26-07-22-species-eligibility-foundation.md | 1265 +++++++++++++++++ 1 file changed, 1265 insertions(+) create mode 100644 .codex/superpowers/plans/2026-07-22-species-eligibility-foundation.md diff --git a/.codex/superpowers/plans/2026-07-22-species-eligibility-foundation.md b/.codex/superpowers/plans/2026-07-22-species-eligibility-foundation.md new file mode 100644 index 0000000..9ae00b1 --- /dev/null +++ b/.codex/superpowers/plans/2026-07-22-species-eligibility-foundation.md @@ -0,0 +1,1265 @@ +# Species Identity and O.C.C. Eligibility Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver issue #60 by adding source-stamped Human/Psi-Stalker identity, structured O.C.C. species eligibility, shared species-and-attribute validation, explicit new-character persistence, and locked-Human builder/sheet presentation without implementing playable Psi-Stalkers or psionics. + +**Architecture:** Add a small validated species catalog beside the existing rules catalogs, replace O.C.C. racial prose with an `any | oneOf` eligibility union, and centralize species plus attribute checks in one pure validator. `deriveSheet` becomes the semantic authority used by Convex writes, while SolidJS consumes the same structured result for gating and explanation. Storage keeps `speciesId` optional only for old documents; the rules parser derives missing legacy identity as Human, while the public create mutation requires an explicit species. + +**Tech Stack:** TypeScript, Zod 4, page-stamped JSON rules content, Convex 1.x, SolidJS 1.9, Tailwind CSS 4, Vite+ (`vp`), Vite+ Test, pnpm. + +## Global Constraints + +- Work in the main checkout at `D:\Projects\riftforge`; do not create a worktree. +- Before Task 1, create `feat/species-eligibility` from the current approved-design commit so the spec and this plan remain in the implementation PR: + + ```powershell + git switch -c feat/species-eligibility + ``` + +- Treat `.codex/superpowers/specs/2026-07-22-occ-breadth-foundation-design.md` and issue #60 as the approved contract. +- Treat the rendered RUE p.233 page as the authority for the Human/Psi-Stalker Grunt eligibility fixture. The page wins over memory or this plan. +- Keep production Coalition Grunt content in #57. Issue #60 proves the restricted rule with a focused test fixture only. +- Human is the only playable species. Psi-Stalker is known but unavailable; do not add R.C.C., I.S.P., psychic powers, or psionics behavior. +- Existing documents without `speciesId` derive as Human without a bulk rewrite. New `characters.create` calls require explicit `speciesId`. +- O.C.C. eligibility is structured as `any | oneOf`; never parse free text. +- Use one pure combined validator for species availability, O.C.C. species rules, and attribute requirements. Builder and `deriveSheet` consume the same result; Convex relies on `deriveSheet` for semantic rejection. +- Rules functions stay pure and deterministic. Content contradictions fail at import; write contradictions fail before persistence. +- Use Vite+. In `packages/backend`, run `pnpm exec`, never `npx`. +- Work red -> green -> refactor. Run the affected package gates after every task and checkpoint-commit every task. +- Preserve the Ley Terminal design in `DESIGN.md`; Human is a locked identity field, not a new empty wizard step. +- No AI attribution. Never commit to `main`, merge a PR, remove #57's `needs:species` label, close #60, or activate #61 before the human merge is synchronized and verified. +- User-visible work requires live-browser acceptance, including parameter-route navigation ownership and a clean console. + +--- + +## File Map + +### Species content and rules + +- Create `packages/rules/src/schema/species.ts`: source-stamped species and catalog schemas. +- Create `packages/rules/src/content/species/species.json`: Human playable; Psi-Stalker known and unavailable, both sourced to p.233 for this boundary. +- Create `packages/rules/src/engine/species.ts`: catalog parsing, duplicate detection, ID lookup, and required Human invariant. +- Create `packages/rules/src/engine/eligibility.ts`: combined species/playability/O.C.C./attribute validation and stable failure formatting. +- Modify `packages/rules/src/schema/occ.ts`: required `speciesEligibility` union and explicit rejection of legacy `racialRequirement` prose. +- Modify `packages/rules/src/content/occ/ley-line-walker.json`: replace racial prose with structured `any` eligibility. +- Modify `packages/rules/src/engine/occ.ts`: fail-fast O.C.C.-to-species reference validation. +- Modify `packages/rules/src/engine/builder.ts`: remove the duplicate attribute-only validator. +- Modify `packages/rules/src/index.ts`: export the new schema and engines. +- Create `packages/rules/tests/species.test.ts` and `packages/rules/tests/eligibility.test.ts`. +- Modify `packages/rules/tests/occ.test.ts` and `packages/rules/tests/builder.test.ts`. + +### Character derivation and backend + +- Modify `packages/rules/src/schema/character.ts`: default missing legacy `speciesId` to `human`. +- Modify `packages/rules/src/engine/character.ts`: enforce eligibility and return resolved species on `CharacterSheet`. +- Modify `packages/rules/tests/character.test.ts`: explicit/legacy identity, attributes, unknown/unavailable failures, and sheet projection. +- Modify `packages/backend/convex/schema.ts`: optional stored field for legacy compatibility. +- Modify `packages/backend/convex/characters.ts`: require explicit species only on new-character creation. +- Modify `packages/backend/tests/characters.test.ts`, `healing-cast.test.ts`, and `combat.test.ts`: explicit new-write fixtures plus backend/legacy coverage. + +### Web and delivery + +- Modify `apps/web/src/builder/store.ts`: locked Human draft identity, shared eligibility memo, and explicit create input. +- Modify `apps/web/src/builder/steps/identity.tsx`: display Human as a locked source-owned field. +- Modify `apps/web/src/builder/steps/occ.tsx`: use shared eligibility and explain species plus attribute failures. +- Modify `apps/web/src/components/sheet-view.tsx`: display resolved species in the dossier header. +- Create `apps/web/tests/builder.test.ts`: reactive store and source-contract coverage. +- Modify `apps/web/tests/character-sheet.test.ts`: dossier species contract. +- Modify `README.md`: record the current Human-only species identity boundary. + +--- + +### Task 1: Render the Source and Add the Species Catalog + +**Files:** Create `packages/rules/src/schema/species.ts`, `packages/rules/src/content/species/species.json`, `packages/rules/src/engine/species.ts`, and `packages/rules/tests/species.test.ts`; modify `packages/rules/src/index.ts`. + +**Interfaces:** + +- Produces `Species`, `SpeciesCatalog`, `speciesCatalog`, `buildSpeciesIndex(...)`, `getSpecies(id)`, and `humanSpecies`. +- `Species.playable` means the identity is legal for a new persisted character; known unavailable identities still resolve through `getSpecies`. +- Every entry carries its own `SourceRef` because availability facts may come from different printed pages as the catalog grows. + +- [ ] **Step 1: Render and inspect printed p.233** + + Create an explicit temporary directory and render PDF index 235 (`233 + 2`) at 2.2x: + + ```powershell + New-Item -ItemType Directory -Force -LiteralPath 'C:\Users\Reven\AppData\Local\Temp\riftforge-species60-20260722' | Out-Null + python -c "import fitz; d=fitz.open('docs/rules/Rifts-Ultimate-Edition-Main-Book.pdf'); d[235].get_pixmap(matrix=fitz.Matrix(2.2,2.2)).save(r'C:\Users\Reven\AppData\Local\Temp\riftforge-species60-20260722\p235.png')" + ``` + + Visually inspect `p235.png`. Confirm only the boundary used here: the Coalition Grunt racial requirement admits Humans and Psi-Stalkers. Do not transcribe Psi-Stalker mechanics from this mention. + +- [ ] **Step 2: Write the failing catalog tests** + + Create `packages/rules/tests/species.test.ts`: + + ```ts + import { describe, expect, test } from "vite-plus/test"; + import { + buildSpeciesIndex, + getSpecies, + humanSpecies, + speciesCatalog, + type Species, + } from "../src/index.ts"; + + describe("species catalog — first O.C.C. breadth boundary", () => { + test("pins the complete two-entry catalog and p.233 source", () => { + expect(speciesCatalog.species).toHaveLength(2); + expect(speciesCatalog.species.map((species) => species.id)).toEqual(["human", "psi-stalker"]); + expect(humanSpecies).toMatchObject({ id: "human", name: "Human", playable: true }); + expect(getSpecies("psi-stalker")).toMatchObject({ + name: "Psi-Stalker", + playable: false, + source: { book: "Rifts Ultimate Edition", page: 233 }, + }); + expect(getSpecies("unknown")).toBeUndefined(); + }); + + test("rejects duplicate ids instead of shadowing entries", () => { + const duplicate: Species[] = [humanSpecies, { ...humanSpecies, name: "Duplicate" }]; + expect(() => buildSpeciesIndex(duplicate)).toThrow('Duplicate species id "human"'); + }); + }); + ``` + +- [ ] **Step 3: Run the test and observe the missing exports** + + Run: + + ```powershell + vp test packages/rules/tests/species.test.ts + ``` + + Expected: FAIL because the species schema, catalog, and engine exports do not exist. + +- [ ] **Step 4: Implement the schema and content** + + Create `packages/rules/src/schema/species.ts`: + + ```ts + import { z } from "zod"; + import { sourceRefSchema } from "./attributes.ts"; + + export const speciesSchema = z.object({ + source: sourceRefSchema, + id: z.string().min(1), + name: z.string().min(1), + playable: z.boolean(), + availabilityNote: z.string().min(1).optional(), + }); + export type Species = z.infer; + + export const speciesCatalogSchema = z.object({ + book: z.string().min(1), + species: z.array(speciesSchema).min(1), + }); + export type SpeciesCatalog = z.infer; + ``` + + Create `packages/rules/src/content/species/species.json` with the visually verified page stamp: + + ```json + { + "book": "Rifts Ultimate Edition", + "species": [ + { + "source": { + "book": "Rifts Ultimate Edition", + "page": 233, + "table": "Coalition Grunt O.C.C. Racial Requirements" + }, + "id": "human", + "name": "Human", + "playable": true + }, + { + "source": { + "book": "Rifts Ultimate Edition", + "page": 233, + "table": "Coalition Grunt O.C.C. Racial Requirements" + }, + "id": "psi-stalker", + "name": "Psi-Stalker", + "playable": false, + "availabilityNote": "Known eligibility identity only; playable Psi-Stalker and psionics rules are deferred." + } + ] + } + ``` + +- [ ] **Step 5: Implement lookup and load-time invariants** + + Create `packages/rules/src/engine/species.ts`: + + ```ts + import speciesRaw from "../content/species/species.json" with { type: "json" }; + import { speciesCatalogSchema, type Species } from "../schema/species.ts"; + + export const speciesCatalog = speciesCatalogSchema.parse(speciesRaw); + + export function buildSpeciesIndex(species: readonly Species[]): Map { + const byId = new Map(); + for (const entry of species) { + if (byId.has(entry.id)) throw new Error(`Duplicate species id "${entry.id}".`); + byId.set(entry.id, entry); + } + return byId; + } + + const speciesById = buildSpeciesIndex(speciesCatalog.species); + + export function getSpecies(id: string): Species | undefined { + return speciesById.get(id); + } + + const human = getSpecies("human"); + if (human === undefined || human.playable !== true) { + throw new Error('Species catalog must contain playable species "human".'); + } + export const humanSpecies: Species = human; + ``` + + Export both new modules from `packages/rules/src/index.ts`: + + ```ts + export * from "./schema/species.ts"; + export * from "./engine/species.ts"; + ``` + +- [ ] **Step 6: Run the focused and package gates** + + Run: + + ```powershell + vp test packages/rules/tests/species.test.ts + vp run @riftforge/rules#check + vp run @riftforge/rules#test + ``` + + Expected: all commands PASS; the focused test reports both catalog tests passing. + +- [ ] **Step 7: Commit the catalog** + + ```powershell + git add -- packages/rules/src/schema/species.ts packages/rules/src/content/species/species.json packages/rules/src/engine/species.ts packages/rules/src/index.ts packages/rules/tests/species.test.ts + git commit -m "feat(rules): add species catalog" + ``` + +--- + +### Task 2: Replace Racial Prose with Structured O.C.C. Eligibility + +**Files:** Modify `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`, and `packages/rules/tests/builder.test.ts`. + +**Interfaces:** + +- Produces `SpeciesEligibility = { kind: "any" } | { kind: "oneOf"; speciesIds: string[] }`. +- Produces `validateOccSpeciesReferences(occ): void` for import-time cross-catalog validation. +- `racialRequirement` is explicitly illegal even though `occSchema` retains `.catchall(...)` for other rich O.C.C. content. + +- [ ] **Step 1: Add failing schema and source-reference tests** + + Extend `packages/rules/tests/occ.test.ts` with: + + ```ts + import { + basePpeRange, + getOcc, + leyLineWalker, + occSchema, + rollBasePpe, + validateOccSpeciesReferences, + } from "../src/index.ts"; + + const gruntEligibilityFixture = occSchema.parse({ + source: { book: "Rifts Ultimate Edition", page: 233 }, + id: "coalition-grunt-eligibility-fixture", + name: "Coalition Grunt eligibility fixture", + category: "Men at Arms", + alignment: "Any", + attributeRequirements: [], + speciesEligibility: { kind: "oneOf", speciesIds: ["human", "psi-stalker"] }, + }); + + test("Ley Line Walker accepts any playable species through structured content", () => { + expect(leyLineWalker.speciesEligibility).toEqual({ kind: "any" }); + expect("racialRequirement" in leyLineWalker).toBe(false); + }); + + test("the p.233 Grunt fixture references Human and Psi-Stalker", () => { + expect(gruntEligibilityFixture.speciesEligibility).toEqual({ + kind: "oneOf", + speciesIds: ["human", "psi-stalker"], + }); + expect(() => validateOccSpeciesReferences(gruntEligibilityFixture)).not.toThrow(); + }); + + test("rejects legacy prose, empty/duplicate lists, and unknown species references", () => { + const base = { + source: { book: "Rifts Ultimate Edition", page: 233 }, + id: "invalid", + name: "Invalid", + category: "Test", + alignment: "Any", + attributeRequirements: [], + }; + expect(() => occSchema.parse({ ...base, racialRequirement: "Humans only" })).toThrow(); + expect(() => + occSchema.parse({ ...base, speciesEligibility: { kind: "oneOf", speciesIds: [] } }), + ).toThrow(); + expect(() => + occSchema.parse({ + ...base, + speciesEligibility: { kind: "oneOf", speciesIds: ["human", "human"] }, + }), + ).toThrow(); + const unknown = occSchema.parse({ + ...base, + speciesEligibility: { kind: "oneOf", speciesIds: ["human", "kryptonian"] }, + }); + expect(() => validateOccSpeciesReferences(unknown)).toThrow( + 'O.C.C. "invalid" references unknown species "kryptonian".', + ); + }); + ``` + +- [ ] **Step 2: Run the O.C.C. tests and observe the missing structure** + + Run: + + ```powershell + vp test packages/rules/tests/occ.test.ts + ``` + + Expected: FAIL because `speciesEligibility` and `validateOccSpeciesReferences` do not exist and Ley Line Walker still carries racial prose. + +- [ ] **Step 3: Add the discriminated schema** + + Add to `packages/rules/src/schema/occ.ts` before `occSchema`: + + ```ts + export const speciesEligibilitySchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("any") }), + z.object({ + kind: z.literal("oneOf"), + speciesIds: z + .array(z.string().min(1)) + .min(1) + .refine((ids) => new Set(ids).size === ids.length, { + message: "O.C.C. species eligibility cannot contain duplicate ids.", + }), + }), + ]); + export type SpeciesEligibility = z.infer; + ``` + + Replace the old field inside `occSchema` with: + + ```ts + speciesEligibility: speciesEligibilitySchema, + racialRequirement: z.never().optional(), + ``` + + In `packages/rules/src/content/occ/ley-line-walker.json`, replace: + + ```json + "racialRequirement": "None. At least 30% are D-Bees." + ``` + + with: + + ```json + "speciesEligibility": { "kind": "any" } + ``` + +- [ ] **Step 4: Add fail-fast cross-catalog validation** + + In `packages/rules/src/engine/occ.ts`, import `getSpecies` and add: + + ```ts + export function validateOccSpeciesReferences(occ: Occ): void { + if (occ.speciesEligibility.kind === "any") return; + for (const speciesId of occ.speciesEligibility.speciesIds) { + if (getSpecies(speciesId) === undefined) { + throw new Error(`O.C.C. "${occ.id}" references unknown species "${speciesId}".`); + } + } + } + + validateOccSpeciesReferences(leyLineWalker); + ``` + + Run validation before placing the O.C.C. in `occRegistry`. + +- [ ] **Step 5: Update every synthetic O.C.C. fixture** + + In `packages/rules/tests/builder.test.ts`, add this exact required field to the synthetic objects currently parsed at the five `occSchema.parse(...)` call sites: + + ```ts + speciesEligibility: { kind: "any" }, + ``` + + Do not weaken the production schema with a default merely to preserve test fixtures. + +- [ ] **Step 6: Run focused and package validation** + + ```powershell + vp test packages/rules/tests/occ.test.ts packages/rules/tests/builder.test.ts + vp run @riftforge/rules#check + vp run @riftforge/rules#test + ``` + + Expected: PASS; the Grunt fixture remains test-only and `occRegistry` still contains only Ley Line Walker. + +- [ ] **Step 7: Commit structured O.C.C. eligibility** + + ```powershell + 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 packages/rules/tests/builder.test.ts + git commit -m "feat(rules): structure OCC species eligibility" + ``` + +--- + +### Task 3: Centralize Species and Attribute Eligibility + +**Files:** Create `packages/rules/src/engine/eligibility.ts` and `packages/rules/tests/eligibility.test.ts`; modify `packages/rules/src/engine/builder.ts`, `packages/rules/src/index.ts`, and `packages/rules/tests/builder.test.ts`. + +**Interfaces:** + +```ts +export type OccEligibilityFailure = + | { kind: "unknownSpecies"; speciesId: string } + | { kind: "unavailableSpecies"; speciesId: string; name: string } + | { kind: "speciesNotAllowed"; speciesId: string; allowedSpeciesIds: string[] } + | { kind: "attribute"; code: AttributeCode; min: number; actual: number }; + +export interface OccEligibilityResult { + ok: boolean; + species?: Species; + failures: OccEligibilityFailure[]; +} + +export function validateOccEligibility( + occ: Occ, + speciesId: string, + attributes: Partial>, +): OccEligibilityResult; + +export function describeOccEligibilityFailure(failure: OccEligibilityFailure): string; +``` + +- [ ] **Step 1: Write the failing combined-validator tests** + + Create `packages/rules/tests/eligibility.test.ts`: + + ```ts + import { describe, expect, test } from "vite-plus/test"; + import { + describeOccEligibilityFailure, + leyLineWalker, + occSchema, + validateOccEligibility, + } from "../src/index.ts"; + + const gruntFixture = occSchema.parse({ + source: { book: "Rifts Ultimate Edition", page: 233 }, + id: "coalition-grunt-eligibility-fixture", + name: "Coalition Grunt eligibility fixture", + category: "Men at Arms", + alignment: "Any", + attributeRequirements: [], + speciesEligibility: { kind: "oneOf", speciesIds: ["human", "psi-stalker"] }, + }); + + describe("validateOccEligibility", () => { + test("accepts playable Human for the p.233 Grunt fixture", () => { + expect(validateOccEligibility(gruntFixture, "human", {})).toMatchObject({ + ok: true, + species: { id: "human", playable: true }, + failures: [], + }); + }); + + test("distinguishes unknown, unavailable, and disallowed species", () => { + expect(validateOccEligibility(gruntFixture, "kryptonian", {}).failures).toEqual([ + { kind: "unknownSpecies", speciesId: "kryptonian" }, + ]); + expect(validateOccEligibility(gruntFixture, "psi-stalker", {}).failures).toEqual([ + { kind: "unavailableSpecies", speciesId: "psi-stalker", name: "Psi-Stalker" }, + ]); + const psiOnly = occSchema.parse({ + ...gruntFixture, + id: "psi-only-fixture", + speciesEligibility: { kind: "oneOf", speciesIds: ["psi-stalker"] }, + }); + expect(validateOccEligibility(psiOnly, "human", {}).failures).toEqual([ + { + kind: "speciesNotAllowed", + speciesId: "human", + allowedSpeciesIds: ["psi-stalker"], + }, + ]); + }); + + test("reports every failed printed attribute requirement", () => { + const result = validateOccEligibility(leyLineWalker, "human", { IQ: 9, PE: 11 }); + expect(result.failures).toEqual([ + { kind: "attribute", code: "IQ", min: 10, actual: 9 }, + { kind: "attribute", code: "PE", min: 12, actual: 11 }, + ]); + expect(result.failures.map(describeOccEligibilityFailure)).toEqual([ + "I.Q. 9; requires 10+.", + "P.E. 11; requires 12+.", + ]); + }); + }); + ``` + +- [ ] **Step 2: Run the test and observe the missing validator** + + ```powershell + vp test packages/rules/tests/eligibility.test.ts + ``` + + Expected: FAIL because the validator and formatter are not exported. + +- [ ] **Step 3: Implement the pure combined validator** + + Create `packages/rules/src/engine/eligibility.ts`: + + ```ts + import type { AttributeCode } from "../schema/attributes.ts"; + import type { Occ } from "../schema/occ.ts"; + import type { Species } from "../schema/species.ts"; + import { getSpecies } from "./species.ts"; + + export type OccEligibilityFailure = + | { kind: "unknownSpecies"; speciesId: string } + | { kind: "unavailableSpecies"; speciesId: string; name: string } + | { kind: "speciesNotAllowed"; speciesId: string; allowedSpeciesIds: string[] } + | { kind: "attribute"; code: AttributeCode; min: number; actual: number }; + + export interface OccEligibilityResult { + ok: boolean; + species?: Species; + failures: OccEligibilityFailure[]; + } + + export function validateOccEligibility( + occ: Occ, + speciesId: string, + attributes: Partial>, + ): OccEligibilityResult { + const failures: OccEligibilityFailure[] = []; + const species = getSpecies(speciesId); + + if (species === undefined) { + failures.push({ kind: "unknownSpecies", speciesId }); + } else { + if (!species.playable) { + failures.push({ kind: "unavailableSpecies", speciesId, name: species.name }); + } + if ( + occ.speciesEligibility.kind === "oneOf" && + !occ.speciesEligibility.speciesIds.includes(speciesId) + ) { + failures.push({ + kind: "speciesNotAllowed", + speciesId, + allowedSpeciesIds: [...occ.speciesEligibility.speciesIds], + }); + } + } + + for (const requirement of occ.attributeRequirements) { + const actual = attributes[requirement.code] ?? 0; + if (actual < requirement.min) { + failures.push({ + kind: "attribute", + code: requirement.code, + min: requirement.min, + actual, + }); + } + } + + return { + ok: failures.length === 0, + ...(species === undefined ? {} : { species }), + failures, + }; + } + + const attributeLabel: Record = { + IQ: "I.Q.", + ME: "M.E.", + MA: "M.A.", + PS: "P.S.", + PP: "P.P.", + PE: "P.E.", + PB: "P.B.", + Spd: "Spd", + }; + + export function describeOccEligibilityFailure(failure: OccEligibilityFailure): string { + switch (failure.kind) { + case "unknownSpecies": + return `Unknown species "${failure.speciesId}".`; + case "unavailableSpecies": + return `${failure.name} is known but not playable.`; + case "speciesNotAllowed": + return `Species "${failure.speciesId}" is not allowed; expected ${failure.allowedSpeciesIds.join( + ", ", + )}.`; + case "attribute": + return `${attributeLabel[failure.code]} ${failure.actual}; requires ${failure.min}+.`; + } + } + ``` + + Export `engine/eligibility.ts` from `packages/rules/src/index.ts`. + +- [ ] **Step 4: Remove the duplicate attribute-only validator** + + Delete `RequirementFailure`, `RequirementCheck`, and `meetsAttributeRequirements(...)` from `packages/rules/src/engine/builder.ts`, including its now-unused `AttributeCode` import. + + In `packages/rules/tests/builder.test.ts`, remove `meetsAttributeRequirements` from the import and delete the three-test `meetsAttributeRequirements` describe block. The new combined tests are the sole validator contract. + +- [ ] **Step 5: Run focused and package validation** + + ```powershell + vp test packages/rules/tests/eligibility.test.ts packages/rules/tests/builder.test.ts + vp run @riftforge/rules#check + vp run @riftforge/rules#test + ``` + + Expected: PASS with no remaining reference to `meetsAttributeRequirements`. + +- [ ] **Step 6: Commit the shared validator** + + ```powershell + git add -- packages/rules/src/engine/eligibility.ts packages/rules/src/engine/builder.ts packages/rules/src/index.ts packages/rules/tests/eligibility.test.ts packages/rules/tests/builder.test.ts + git commit -m "feat(rules): validate OCC eligibility" + ``` + +--- + +### Task 4: Enforce Eligibility in Character Derivation + +**Files:** Modify `packages/rules/src/schema/character.ts`, `packages/rules/src/engine/character.ts`, and `packages/rules/tests/character.test.ts`. + +**Interfaces:** + +```ts +characterSchema.shape.speciesId // z.string().min(1).default("human") + +CharacterSheet.species: { + id: string; + name: string; +} +``` + +`Character` always has a resolved `speciesId`; `CharacterInput` may omit it only so legacy documents can still enter `deriveSheet`. + +- [ ] **Step 1: Add failing character tests** + + Add `speciesId: "human"` to the primary `leyLineWalker` fixture in `packages/rules/tests/character.test.ts`, then add: + + ```ts + describe("deriveSheet — species and O.C.C. eligibility", () => { + test("projects explicit Human identity onto the sheet", () => { + expect(deriveSheet(leyLineWalker).species).toEqual({ id: "human", name: "Human" }); + }); + + test("defaults a legacy document without speciesId to Human without mutating input", () => { + const { speciesId: _speciesId, ...legacy } = leyLineWalker; + expect(deriveSheet(legacy).species).toEqual({ id: "human", name: "Human" }); + expect("speciesId" in legacy).toBe(false); + }); + + test("rejects unknown and unavailable species", () => { + expect(() => deriveSheet({ ...leyLineWalker, speciesId: "kryptonian" })).toThrow( + 'Unknown species "kryptonian".', + ); + expect(() => deriveSheet({ ...leyLineWalker, speciesId: "psi-stalker" })).toThrow( + "Psi-Stalker is known but not playable.", + ); + }); + + test("enforces printed O.C.C. attribute requirements in derivation", () => { + expect(() => + deriveSheet({ + ...leyLineWalker, + attributes: { ...leyLineWalker.attributes, IQ: 9, PE: 11 }, + }), + ).toThrow("I.Q. 9; requires 10+. P.E. 11; requires 12+."); + }); + }); + ``` + +- [ ] **Step 2: Run the character test and observe missing enforcement** + + ```powershell + vp test packages/rules/tests/character.test.ts + ``` + + Expected: FAIL because the sheet has no species and derivation currently ignores O.C.C. requirements. + +- [ ] **Step 3: Add legacy-defaulted character identity** + + In `packages/rules/src/schema/character.ts`, add immediately after `occId`: + + ```ts + /** Explicit for new characters; missing legacy documents derive as Human. */ + speciesId: z.string().min(1).default("human"), + ``` + +- [ ] **Step 4: Validate once and project the resolved species** + + In `packages/rules/src/engine/character.ts`, import `describeOccEligibilityFailure` and `validateOccEligibility`. Add to `CharacterSheet`: + + ```ts + species: { + id: string; + name: string; + } + ``` + + Immediately after resolving the O.C.C. and before deriving any stats, add: + + ```ts + const eligibility = validateOccEligibility(occ, character.speciesId, character.attributes); + if (!eligibility.ok || eligibility.species === undefined) { + throw new Error( + eligibility.failures.map(describeOccEligibilityFailure).join(" ") || + "O.C.C. eligibility could not be resolved.", + ); + } + const species = eligibility.species; + ``` + + Add the resolved projection beside `occ` in the returned sheet: + + ```ts + species: { id: species.id, name: species.name }, + ``` + +- [ ] **Step 5: Run rules tests and checks** + + ```powershell + vp test packages/rules/tests/character.test.ts packages/rules/tests/eligibility.test.ts packages/rules/tests/combat-exchange.test.ts + vp run @riftforge/rules#check + vp run @riftforge/rules#test + ``` + + Expected: PASS. Combat tokens remain unchanged because species currently has no combat effect; derived `CharacterSheet` fixtures obtain species through `deriveSheet`. + +- [ ] **Step 6: Commit character enforcement** + + ```powershell + git add -- packages/rules/src/schema/character.ts packages/rules/src/engine/character.ts packages/rules/tests/character.test.ts + git commit -m "feat(rules): derive character species eligibility" + ``` + +--- + +### Task 5: Persist Explicit New-Character Species in Convex + +**Files:** Modify `packages/backend/convex/schema.ts`, `packages/backend/convex/characters.ts`, `packages/backend/tests/characters.test.ts`, `packages/backend/tests/healing-cast.test.ts`, and `packages/backend/tests/combat.test.ts`. + +**Interfaces:** + +- Stored `speciesId` remains optional so old documents satisfy the Convex table validator. +- `characters.create` requires `speciesId: string` and stores the parsed explicit value. +- `characters.update` retains the compatibility input shape; replacing a legacy document naturally writes the rules parser's Human default. +- All semantic validation continues through `validateCharacter -> deriveSheet`. + +- [ ] **Step 1: Add explicit Human to new-write fixtures** + + Add this field beside `occId` in the `vesper` fixtures in `characters.test.ts` and `healing-cast.test.ts`, and in the typed `character` fixture in `combat.test.ts`: + + ```ts + speciesId: "human", + ``` + +- [ ] **Step 2: Add failing backend authority and compatibility tests** + + In `packages/backend/tests/characters.test.ts`, extend the first describe block: + + ```ts + 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.sheet, { id })).toMatchObject({ + species: { id: "human", name: "Human" }, + }); + + const { speciesId: _speciesId, ...missing } = vesper; + await expect(t.mutation(api.characters.create, missing as typeof vesper)).rejects.toThrow(); + }); + + test("backend rejects unknown, unavailable, and attribute-ineligible writes", async () => { + const t = convexTest(schema, modules); + await expect( + t.mutation(api.characters.create, { ...vesper, speciesId: "kryptonian" }), + ).rejects.toThrow('Unknown species "kryptonian".'); + await expect( + t.mutation(api.characters.create, { ...vesper, speciesId: "psi-stalker" }), + ).rejects.toThrow("Psi-Stalker is known but not playable."); + await expect( + t.mutation(api.characters.create, { + ...vesper, + attributes: { ...vesper.attributes, PE: 11 }, + }), + ).rejects.toThrow("P.E. 11; requires 12+."); + + const id = await t.mutation(api.characters.create, vesper); + await expect( + t.mutation(api.characters.update, { + id, + character: { ...vesper, speciesId: "psi-stalker" }, + }), + ).rejects.toThrow("Psi-Stalker is known but not playable."); + expect(await t.query(api.characters.get, { id })).toMatchObject({ speciesId: "human" }); + }); + + test("legacy storage without speciesId derives Human without a read-time rewrite", async () => { + const t = convexTest(schema, modules); + const { speciesId: _speciesId, ...legacy } = vesper; + const id = await t.run((ctx) => ctx.db.insert("characters", legacy)); + + expect(await t.query(api.characters.sheet, { id })).toMatchObject({ + species: { id: "human", name: "Human" }, + }); + expect(await t.query(api.characters.get, { id })).not.toHaveProperty("speciesId"); + }); + ``` + +- [ ] **Step 3: Run backend tests and observe missing validators** + + ```powershell + vp test packages/backend/tests/characters.test.ts packages/backend/tests/healing-cast.test.ts packages/backend/tests/combat.test.ts + ``` + + Expected: FAIL because Convex does not store or require `speciesId` yet. + +- [ ] **Step 4: Add the legacy-compatible table field** + + In `packages/backend/convex/schema.ts`, add beside `occId`: + + ```ts + /** Optional only for documents created before species identity existed. */ + speciesId: v.optional(v.string()), + ``` + +- [ ] **Step 5: Require the field only on create** + + In `packages/backend/convex/characters.ts`, retain the existing compatibility shape and add a create-specific override: + + ```ts + const characterInputFields = { + ...characterFields, + psychicClass: v.optional(characterFields.psychicClass), + skills: v.optional(characterFields.skills), + spellIds: v.optional(characterFields.spellIds), + }; + + const newCharacterInputFields = { + ...characterInputFields, + speciesId: v.string(), + }; + ``` + + Change only `characters.create` to: + + ```ts + export const create = mutation({ + args: newCharacterInputFields, + returns: v.id("characters"), + handler: async (ctx, args) => { + return await ctx.db.insert("characters", validateCharacter(args)); + }, + }); + ``` + + Leave `characters.update` on `characterInputFields` so a legacy document can still be submitted and normalized through `characterSchema`. + +- [ ] **Step 6: Run backend package validation** + + ```powershell + vp test packages/backend/tests/characters.test.ts packages/backend/tests/healing-cast.test.ts packages/backend/tests/combat.test.ts + vp run @riftforge/backend#check + vp run @riftforge/backend#test + ``` + + Expected: PASS. Do not run Convex code generation merely for the schema field: `_generated/dataModel.d.ts` derives directly from `convex/schema.ts`. + +- [ ] **Step 7: Commit backend persistence** + + ```powershell + git add -- packages/backend/convex/schema.ts packages/backend/convex/characters.ts packages/backend/tests/characters.test.ts packages/backend/tests/healing-cast.test.ts packages/backend/tests/combat.test.ts + git commit -m "feat(backend): persist character species" + ``` + +--- + +### Task 6: Show Locked Human Identity and Shared Eligibility in SolidJS + +**Files:** Modify `apps/web/src/builder/store.ts`, `apps/web/src/builder/steps/identity.tsx`, `apps/web/src/builder/steps/occ.tsx`, `apps/web/src/components/sheet-view.tsx`, `apps/web/tests/character-sheet.test.ts`, and `README.md`; create `apps/web/tests/builder.test.ts`. + +**Interfaces:** + +- `Draft.speciesId` is initialized to `human` and has no user editing control in this slice. +- `BuilderStore.species` resolves the draft ID through the rules catalog. +- `characterInput()` always includes `speciesId`. +- The O.C.C. step uses `validateOccEligibility(...)`; it does not reproduce species or attribute logic. + +- [ ] **Step 1: Write failing store and source-contract tests** + + Create `apps/web/tests/builder.test.ts`: + + ```ts + /// + + import { existsSync, readFileSync } from "node:fs"; + import { createRoot } from "solid-js"; + import { describe, expect, test } from "vite-plus/test"; + import { createBuilderStore } from "../src/builder/store.ts"; + + function source(relative: string): string { + const url = new URL(relative, import.meta.url); + if (!existsSync(url)) throw new Error(`Missing source under test: ${relative}`); + return readFileSync(url, "utf8"); + } + + const storeSource = source("../src/builder/store.ts"); + const identitySource = source("../src/builder/steps/identity.tsx"); + const occSource = source("../src/builder/steps/occ.tsx"); + + describe("builder species identity", () => { + test("starts with the sole playable Human identity", () => { + createRoot((dispose) => { + const store = createBuilderStore(); + expect(store.draft.speciesId).toBe("human"); + expect(store.species()).toMatchObject({ id: "human", name: "Human", playable: true }); + dispose(); + }); + }); + + test("persists species and delegates O.C.C. legality to the shared engine", () => { + expect(storeSource).toContain("speciesId: draft.speciesId"); + expect(storeSource).toContain("validateOccEligibility("); + expect(occSource).toContain("validateOccEligibility("); + expect(occSource).toContain("describeOccEligibilityFailure"); + }); + + test("renders Human as locked identity rather than an editable species step", () => { + expect(identitySource).toContain("SPECIES"); + expect(identitySource).toContain("HUMAN // LOCKED"); + expect(identitySource).not.toContain('name="species"'); + }); + }); + ``` + + In `apps/web/tests/character-sheet.test.ts`, add: + + ```ts + test("shows resolved species in the dossier identity line", () => { + expect(sheetViewSource).toContain("s().species.name.toUpperCase()"); + }); + ``` + +- [ ] **Step 2: Run web tests and observe missing species UI** + + ```powershell + vp test apps/web/tests/builder.test.ts apps/web/tests/character-sheet.test.ts + ``` + + Expected: FAIL because the builder store, identity step, and sheet do not expose species. + +- [ ] **Step 3: Add Human identity to the builder store** + + In `apps/web/src/builder/store.ts`: + + - import `getSpecies` and `validateOccEligibility`; + - add `speciesId: string` to `Draft`; + - add `species: Accessor>` to `BuilderStore`; + - initialize `speciesId: "human"`; + - add `const species = createMemo(() => getSpecies(draft.speciesId));`; + - include `speciesId: draft.speciesId` in `characterInput()`; + - return `species` from `createBuilderStore()`. + + Replace the attribute-only `requirements` memo in `stepValidity` with: + + ```ts + const eligibility = createMemo(() => { + const chosen = store.occ(); + const attrs = store.attributeTotals(); + return chosen && attrs + ? validateOccEligibility(chosen, store.draft.speciesId, attrs) + : undefined; + }); + ``` + + Return these gates: + + ```ts + eligibility, + identity: createMemo( + () => store.draft.name.trim().length > 0 && store.species()?.playable === true, + ), + occ: createMemo(() => store.occ() !== undefined && eligibility()?.ok === true), + ``` + +- [ ] **Step 4: Render the locked identity field** + + In `apps/web/src/builder/steps/identity.tsx`, add after the name field: + + ```tsx +
+ SPECIES +
+ HUMAN // LOCKED +
+

+ // sole playable species in the current rules catalog +

+
+ ``` + +- [ ] **Step 5: Replace O.C.C.-step legality with the shared validator** + + In `apps/web/src/builder/steps/occ.tsx`, import `describeOccEligibilityFailure`, `getSpecies`, and `validateOccEligibility`. Replace `check` with: + + ```ts + const check = (occ: (typeof occs)[number]) => { + const attrs = props.store.attributeTotals(); + return attrs ? validateOccEligibility(occ, props.store.draft.speciesId, attrs) : undefined; + }; + + const eligibleSpecies = (occ: (typeof occs)[number]) => { + if (occ.speciesEligibility.kind === "any") return "ANY PLAYABLE SPECIES"; + return occ.speciesEligibility.speciesIds + .map((id) => { + const species = getSpecies(id); + return species ? `${species.name}${species.playable ? "" : " [DEFERRED]"}` : id; + }) + .join(", "); + }; + ``` + + Add this line below the attribute requirement line: + + ```tsx +

SPECIES: {eligibleSpecies(occ).toUpperCase()}

+ ``` + + Replace the old failure mapping with: + + ```tsx + { + result().failures.map(describeOccEligibilityFailure).join(" ").toUpperCase(); + } + ``` + + Set the radio's disabled state from the same result: + + ```tsx + disabled={check(occ)?.ok !== true} + ``` + +- [ ] **Step 6: Display resolved species on every sheet** + + In `apps/web/src/components/sheet-view.tsx`, change the dossier header label to: + + ```tsx + + LEVEL {s().level} // {s().species.name.toUpperCase()} // {s().occ.name.toUpperCase()} //{" "} + {s().occ.category.toUpperCase()} + + ``` + +- [ ] **Step 7: Align the README** + + After the paragraph beginning “The first vertical slice” in `README.md`, add: + + ```md + Character identity is source-validated: Human is currently the sole playable + species, while known but unimplemented identities remain unavailable rather than + silently accepted. + ``` + +- [ ] **Step 8: Run web and dependent package gates** + + ```powershell + vp test apps/web/tests/builder.test.ts apps/web/tests/character-sheet.test.ts apps/web/tests/combat-exchange.test.ts + vp run @riftforge/web#check + vp run @riftforge/web#test + ``` + + Expected: PASS. Confirm the source-contract helper throws for missing files and no test can pass vacuously. + +- [ ] **Step 9: Commit the builder and sheet integration** + + ```powershell + git add -- apps/web/src/builder/store.ts apps/web/src/builder/steps/identity.tsx apps/web/src/builder/steps/occ.tsx apps/web/src/components/sheet-view.tsx apps/web/tests/builder.test.ts apps/web/tests/character-sheet.test.ts README.md + git commit -m "feat(web): surface character species identity" + ``` + +--- + +### Task 7: Full Validation, Live Acceptance, and Review Publication + +**Files:** No planned production-code changes. Correct any defect in the owning task's files and rerun that task's RED/GREEN cycle before proceeding. + +**Interfaces:** This task produces fresh validation evidence, a clean published branch, a draft PR closing #60, and an issue update. It does not merge, close #60 manually, remove downstream dependency labels, or activate #61. + +- [ ] **Step 1: Prove the intended scope and clean diff** + + ```powershell + git status --short --branch + git diff main...HEAD --stat + git diff --check main...HEAD + rg -n "racialRequirement|meetsAttributeRequirements" packages apps + ``` + + Expected: only the approved design/plan and #60 implementation files differ; `git diff --check` has no output; the final `rg` returns no production reference to either removed legacy mechanism. + +- [ ] **Step 2: Run focused tests** + + ```powershell + vp test packages/rules/tests/species.test.ts packages/rules/tests/eligibility.test.ts packages/rules/tests/occ.test.ts packages/rules/tests/character.test.ts packages/rules/tests/builder.test.ts packages/backend/tests/characters.test.ts packages/backend/tests/healing-cast.test.ts packages/backend/tests/combat.test.ts apps/web/tests/builder.test.ts apps/web/tests/character-sheet.test.ts apps/web/tests/combat-exchange.test.ts + ``` + + Expected: PASS with no skipped or pending tests. + +- [ ] **Step 3: Run every affected package gate** + + ```powershell + vp run @riftforge/rules#check + vp run @riftforge/rules#test + vp run @riftforge/backend#check + vp run @riftforge/backend#test + vp run @riftforge/web#check + vp run @riftforge/web#test + ``` + + Expected: all six commands PASS. + +- [ ] **Step 4: Run root gates on the final revision** + + ```powershell + vp check + vp test + git diff --check main...HEAD + git status --short --branch + ``` + + Expected: root check and tests PASS, diff check is silent, and the worktree is clean. + +- [ ] **Step 5: Start clean local services** + + Verify ports before starting: + + ```powershell + Get-NetTCPConnection -LocalPort 3210,5173 -State Listen -ErrorAction SilentlyContinue | Select-Object LocalPort,OwningProcess + ``` + + Stop only positively identified stale RiftForge `convex-local-backend`, Convex CLI, or web processes. Then start: + + ```powershell + # packages/backend + pnpm exec convex dev + + # apps/web, in a separate hidden/background process managed by the executor + vp dev + ``` + + Wait for local Convex on port 3210 and the web app on `http://localhost:5173`. + +- [ ] **Step 6: Complete live browser acceptance** + + In the real browser: + + 1. Open `/characters/new` and confirm `HUMAN // LOCKED` is visible with no species picker. + 2. Roll legal Ley Line Walker attributes and confirm O.C.C. eligibility reports success through the shared gate. + 3. Complete the existing alignment, psionics, skill, and spell choices; review the character and confirm `HUMAN` appears in the dossier identity line. + 4. Forge the character and confirm the persisted sheet still shows Human after navigation and reload. + 5. Roll vitals and perform one existing legal spell cast to prove the magic vertical remains intact. + 6. Navigate directly between this dossier and another character without a document remount; confirm the correct identity and no stale command result follows the route. + 7. At 390x844 and desktop width, confirm the locked identity, O.C.C. cards, and sheet header do not overflow. + 8. Use keyboard navigation through the builder controls and confirm the disabled O.C.C. state has explanatory text, not color alone. + 9. Confirm zero browser console errors and warnings. + +- [ ] **Step 7: Stop local services and remove the render temporary directory** + + Stop the exact RiftForge processes started in Step 5, including an orphaned `convex-local-backend` if it remains on port 3210. Verify both ports are closed. + + Resolve and remove only the explicit render directory: + + ```powershell + $target = Resolve-Path -LiteralPath 'C:\Users\Reven\AppData\Local\Temp\riftforge-species60-20260722' + if ($target.Path -ne 'C:\Users\Reven\AppData\Local\Temp\riftforge-species60-20260722') { throw "Unexpected cleanup target: $($target.Path)" } + Remove-Item -LiteralPath $target.Path -Recurse -Force + ``` + +- [ ] **Step 8: Re-run final lightweight integrity checks after cleanup** + + ```powershell + git diff --check main...HEAD + git status --short --branch + Get-NetTCPConnection -LocalPort 3210,5173 -State Listen -ErrorAction SilentlyContinue + ``` + + Expected: clean diff, clean worktree, and no listeners on either port. + +- [ ] **Step 9: Publish a draft PR without changing tracker activation** + + ```powershell + git push -u origin feat/species-eligibility + gh pr create --draft --base main --head feat/species-eligibility --title "feat: add species eligibility foundation" --body "Closes #60. + + Adds source-stamped Human and deferred Psi-Stalker identity, structured O.C.C. species eligibility, shared species/attribute enforcement, explicit new-character persistence, legacy Human compatibility, and locked-Human builder/sheet presentation. + + Validation and live-browser evidence are recorded on issue #60. The human maintainer retains merge authority." + ``` + + Add a comment to #60 quoting the final commit SHA, actual observed command results, live-browser evidence, and the draft PR URL. Do not add generated-with text or attribution trailers. + +- [ ] **Step 10: Request the active automated review and stop at human merge** + + Move the PR out of draft only when the user asks and the branch evidence is complete. Let the configured active reviewer inspect new commits automatically; do not spend quota on a manual retrigger. Address every finding by reproduction and root-cause correction, rerun affected gates, and push the fix. + + Stop when the PR is green and approved. The human maintainer merges. Keep #60 as the sole `next-up` issue and keep `needs:species` on #57/#58 until the merged result is synchronized and verified on local `main`. + +--- + +## Post-Merge Handoff + +After the human merge, use the repository's post-merge procedure: + +```powershell +git switch main +git pull --ff-only origin main +vp install +vp check +vp test +git branch -d feat/species-eligibility +git branch -d docs/occ-breadth-foundation-design +git fetch --prune origin +``` + +Only after those commands pass: + +- confirm #60 closed automatically through the merged PR; +- remove `needs:species` from #57 and any other issue whose complete dependency is genuinely satisfied; +- remove `next-up` from #60 and add it to #61 so exactly one open issue carries it; +- preserve #58's other missing-subsystem labels; and +- write #61's implementation plan against the synchronized post-#60 codebase. From 1b26607fa8db318d2c0bff0efd5f049449489b7a Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Wed, 22 Jul 2026 18:30:25 +0800 Subject: [PATCH 03/10] feat(rules): add species catalog --- .../rules/src/content/species/species.json | 26 ++++++++++++++++++ packages/rules/src/engine/species.ts | 25 +++++++++++++++++ packages/rules/src/index.ts | 2 ++ packages/rules/src/schema/species.ts | 17 ++++++++++++ packages/rules/tests/species.test.ts | 27 +++++++++++++++++++ 5 files changed, 97 insertions(+) create mode 100644 packages/rules/src/content/species/species.json create mode 100644 packages/rules/src/engine/species.ts create mode 100644 packages/rules/src/schema/species.ts create mode 100644 packages/rules/tests/species.test.ts diff --git a/packages/rules/src/content/species/species.json b/packages/rules/src/content/species/species.json new file mode 100644 index 0000000..aab03a2 --- /dev/null +++ b/packages/rules/src/content/species/species.json @@ -0,0 +1,26 @@ +{ + "book": "Rifts Ultimate Edition", + "species": [ + { + "source": { + "book": "Rifts Ultimate Edition", + "page": 233, + "table": "Coalition Grunt O.C.C. Racial Requirements" + }, + "id": "human", + "name": "Human", + "playable": true + }, + { + "source": { + "book": "Rifts Ultimate Edition", + "page": 233, + "table": "Coalition Grunt O.C.C. Racial Requirements" + }, + "id": "psi-stalker", + "name": "Psi-Stalker", + "playable": false, + "availabilityNote": "Known eligibility identity only; playable Psi-Stalker and psionics rules are deferred." + } + ] +} diff --git a/packages/rules/src/engine/species.ts b/packages/rules/src/engine/species.ts new file mode 100644 index 0000000..040f617 --- /dev/null +++ b/packages/rules/src/engine/species.ts @@ -0,0 +1,25 @@ +import speciesRaw from "../content/species/species.json" with { type: "json" }; +import { speciesCatalogSchema, type Species } from "../schema/species.ts"; + +export const speciesCatalog = speciesCatalogSchema.parse(speciesRaw); + +export function buildSpeciesIndex(species: readonly Species[]): Map { + const byId = new Map(); + for (const entry of species) { + if (byId.has(entry.id)) throw new Error(`Duplicate species id "${entry.id}".`); + byId.set(entry.id, entry); + } + return byId; +} + +const speciesById = buildSpeciesIndex(speciesCatalog.species); + +export function getSpecies(id: string): Species | undefined { + return speciesById.get(id); +} + +const human = getSpecies("human"); +if (human === undefined || human.playable !== true) { + throw new Error('Species catalog must contain playable species "human".'); +} +export const humanSpecies: Species = human; diff --git a/packages/rules/src/index.ts b/packages/rules/src/index.ts index 1167dcf..05ae29c 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -1,5 +1,6 @@ export * from "./schema/alignments.ts"; export * from "./schema/attributes.ts"; +export * from "./schema/species.ts"; export * from "./schema/occ.ts"; export * from "./schema/combat.ts"; export * from "./schema/damage.ts"; @@ -12,6 +13,7 @@ export * from "./schema/combat-exchange.ts"; export * from "./schema/character.ts"; export * from "./engine/alignments.ts"; export * from "./engine/attributes.ts"; +export * from "./engine/species.ts"; export * from "./engine/dice.ts"; export * from "./engine/occ.ts"; export * from "./engine/combat.ts"; diff --git a/packages/rules/src/schema/species.ts b/packages/rules/src/schema/species.ts new file mode 100644 index 0000000..ea9339d --- /dev/null +++ b/packages/rules/src/schema/species.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; +import { sourceRefSchema } from "./attributes.ts"; + +export const speciesSchema = z.object({ + source: sourceRefSchema, + id: z.string().min(1), + name: z.string().min(1), + playable: z.boolean(), + availabilityNote: z.string().min(1).optional(), +}); +export type Species = z.infer; + +export const speciesCatalogSchema = z.object({ + book: z.string().min(1), + species: z.array(speciesSchema).min(1), +}); +export type SpeciesCatalog = z.infer; diff --git a/packages/rules/tests/species.test.ts b/packages/rules/tests/species.test.ts new file mode 100644 index 0000000..668ee77 --- /dev/null +++ b/packages/rules/tests/species.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + buildSpeciesIndex, + getSpecies, + humanSpecies, + speciesCatalog, + type Species, +} from "../src/index.ts"; + +describe("species catalog — first O.C.C. breadth boundary", () => { + test("pins the complete two-entry catalog and p.233 source", () => { + expect(speciesCatalog.species).toHaveLength(2); + expect(speciesCatalog.species.map((species) => species.id)).toEqual(["human", "psi-stalker"]); + expect(humanSpecies).toMatchObject({ id: "human", name: "Human", playable: true }); + expect(getSpecies("psi-stalker")).toMatchObject({ + name: "Psi-Stalker", + playable: false, + source: { book: "Rifts Ultimate Edition", page: 233 }, + }); + expect(getSpecies("unknown")).toBeUndefined(); + }); + + test("rejects duplicate ids instead of shadowing entries", () => { + const duplicate: Species[] = [humanSpecies, { ...humanSpecies, name: "Duplicate" }]; + expect(() => buildSpeciesIndex(duplicate)).toThrow('Duplicate species id "human"'); + }); +}); From f83984f91df5b42ce2f494dc2591d7c61a56fda5 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Wed, 22 Jul 2026 18:37:06 +0800 Subject: [PATCH 04/10] feat(rules): structure OCC species eligibility --- .../src/content/occ/ley-line-walker.json | 2 +- packages/rules/src/engine/occ.ts | 12 ++++ packages/rules/src/schema/occ.ts | 17 +++++- packages/rules/tests/builder.test.ts | 3 + packages/rules/tests/occ.test.ts | 60 ++++++++++++++++++- 5 files changed, 91 insertions(+), 3 deletions(-) diff --git a/packages/rules/src/content/occ/ley-line-walker.json b/packages/rules/src/content/occ/ley-line-walker.json index f9a15de..2101b37 100644 --- a/packages/rules/src/content/occ/ley-line-walker.json +++ b/packages/rules/src/content/occ/ley-line-walker.json @@ -13,7 +13,7 @@ { "code": "IQ", "min": 10 }, { "code": "PE", "min": 12 } ], - "racialRequirement": "None. At least 30% are D-Bees.", + "speciesEligibility": { "kind": "any" }, "concealedBodyArmor": { "source": { "book": "Rifts Ultimate Edition", "page": 113 }, "light": { "mainBodyMdc": "2D6+32" }, diff --git a/packages/rules/src/engine/occ.ts b/packages/rules/src/engine/occ.ts index 81198b5..7b1f0ce 100644 --- a/packages/rules/src/engine/occ.ts +++ b/packages/rules/src/engine/occ.ts @@ -1,10 +1,22 @@ 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 { getSpecies } from "./species.ts"; /** The Ley Line Walker O.C.C. (RUE pp.113-116), validated at load. */ export const leyLineWalker: Occ = occSchema.parse(leyLineWalkerRaw); +export function validateOccSpeciesReferences(occ: Occ): void { + if (occ.speciesEligibility.kind === "any") return; + for (const speciesId of occ.speciesEligibility.speciesIds) { + if (getSpecies(speciesId) === undefined) { + throw new Error(`O.C.C. "${occ.id}" references unknown species "${speciesId}".`); + } + } +} + +validateOccSpeciesReferences(leyLineWalker); + /** All O.C.C.s currently modeled, keyed by id. */ export const occRegistry: Readonly> = { [leyLineWalker.id]: leyLineWalker, diff --git a/packages/rules/src/schema/occ.ts b/packages/rules/src/schema/occ.ts index 343bd86..44a1dcf 100644 --- a/packages/rules/src/schema/occ.ts +++ b/packages/rules/src/schema/occ.ts @@ -104,6 +104,20 @@ export const occSkillGrantSchema = z.object({ skill: z.string() }).catchall(z.un /** A special O.C.C. ability. Rich/descriptive; only `name` is required. */ export const occAbilitySchema = z.object({ name: z.string() }).catchall(z.unknown()); +export const speciesEligibilitySchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("any") }), + z.object({ + kind: z.literal("oneOf"), + speciesIds: z + .array(z.string().min(1)) + .min(1) + .refine((ids) => new Set(ids).size === ids.length, { + message: "O.C.C. species eligibility cannot contain duplicate ids.", + }), + }), +]); +export type SpeciesEligibility = z.infer; + /** A full Occupational Character Class entry. */ export const occSchema = z .object({ @@ -114,7 +128,8 @@ export const occSchema = z description: z.string().optional(), alignment: z.string(), attributeRequirements: z.array(attributeRequirementSchema), - racialRequirement: z.string().optional(), + speciesEligibility: speciesEligibilitySchema, + racialRequirement: z.never().optional(), concealedBodyArmor: z.unknown().optional(), ppe: ppeSchema.optional(), spellKnowledge: spellKnowledgeSchema.optional(), diff --git a/packages/rules/tests/builder.test.ts b/packages/rules/tests/builder.test.ts index 75eecf6..f38a768 100644 --- a/packages/rules/tests/builder.test.ts +++ b/packages/rules/tests/builder.test.ts @@ -333,6 +333,7 @@ describe("assembleSkills — hand-to-hand upgrades (synthetic O.C.C.)", () => { category: "Test", alignment: "Any", attributeRequirements: [], + speciesEligibility: { kind: "any" }, occSkills: [ { skill: "Climbing", skillId: "climbing", occBonus: 5 }, { @@ -473,6 +474,7 @@ describe("secondarySkillPlan", () => { category: "Test", alignment: "Any", attributeRequirements: [], + speciesEligibility: { kind: "any" }, secondarySkills: { count: 3 }, }); const plan = secondarySkillPlan(occ); @@ -489,6 +491,7 @@ describe("content-bug fail-fasts and edge counts", () => { category: "Test", alignment: "Any", attributeRequirements: [], + speciesEligibility: { kind: "any" }, }; test("a category choice with no catalog skills throws at plan time", () => { diff --git a/packages/rules/tests/occ.test.ts b/packages/rules/tests/occ.test.ts index 2b61b07..a736f49 100644 --- a/packages/rules/tests/occ.test.ts +++ b/packages/rules/tests/occ.test.ts @@ -1,7 +1,65 @@ import { describe, expect, test } from "vite-plus/test"; -import { basePpeRange, getOcc, leyLineWalker, rollBasePpe } from "../src/index.ts"; +import { + basePpeRange, + getOcc, + leyLineWalker, + occSchema, + rollBasePpe, + validateOccSpeciesReferences, +} from "../src/index.ts"; + +const gruntEligibilityFixture = occSchema.parse({ + source: { book: "Rifts Ultimate Edition", page: 233 }, + id: "coalition-grunt-eligibility-fixture", + name: "Coalition Grunt eligibility fixture", + category: "Men at Arms", + alignment: "Any", + attributeRequirements: [], + speciesEligibility: { kind: "oneOf", speciesIds: ["human", "psi-stalker"] }, +}); describe("Ley Line Walker O.C.C. (RUE pp.113-116)", () => { + test("Ley Line Walker accepts any playable species through structured content", () => { + expect(leyLineWalker.speciesEligibility).toEqual({ kind: "any" }); + expect("racialRequirement" in leyLineWalker).toBe(false); + }); + + test("the p.233 Grunt fixture references Human and Psi-Stalker", () => { + expect(gruntEligibilityFixture.speciesEligibility).toEqual({ + kind: "oneOf", + speciesIds: ["human", "psi-stalker"], + }); + expect(() => validateOccSpeciesReferences(gruntEligibilityFixture)).not.toThrow(); + }); + + test("rejects legacy prose, empty/duplicate lists, and unknown species references", () => { + const base = { + source: { book: "Rifts Ultimate Edition", page: 233 }, + id: "invalid", + name: "Invalid", + category: "Test", + alignment: "Any", + attributeRequirements: [], + }; + expect(() => occSchema.parse({ ...base, racialRequirement: "Humans only" })).toThrow(); + expect(() => + occSchema.parse({ ...base, speciesEligibility: { kind: "oneOf", speciesIds: [] } }), + ).toThrow(); + expect(() => + occSchema.parse({ + ...base, + speciesEligibility: { kind: "oneOf", speciesIds: ["human", "human"] }, + }), + ).toThrow(); + const unknown = occSchema.parse({ + ...base, + speciesEligibility: { kind: "oneOf", speciesIds: ["human", "kryptonian"] }, + }); + expect(() => validateOccSpeciesReferences(unknown)).toThrow( + 'O.C.C. "invalid" references unknown species "kryptonian".', + ); + }); + test("validates and is registered", () => { expect(leyLineWalker.id).toBe("ley-line-walker"); expect(leyLineWalker.name).toBe("Ley Line Walker"); From d9458342ec155f89abb9aba2a59b8dee8ea37856 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Wed, 22 Jul 2026 18:43:27 +0800 Subject: [PATCH 05/10] feat(rules): validate OCC eligibility --- packages/rules/src/engine/builder.ts | 28 -------- packages/rules/src/engine/eligibility.ts | 87 ++++++++++++++++++++++++ packages/rules/src/index.ts | 1 + packages/rules/tests/builder.test.ts | 21 ------ packages/rules/tests/eligibility.test.ts | 60 ++++++++++++++++ 5 files changed, 148 insertions(+), 49 deletions(-) create mode 100644 packages/rules/src/engine/eligibility.ts create mode 100644 packages/rules/tests/eligibility.test.ts diff --git a/packages/rules/src/engine/builder.ts b/packages/rules/src/engine/builder.ts index 516824a..67381f3 100644 --- a/packages/rules/src/engine/builder.ts +++ b/packages/rules/src/engine/builder.ts @@ -1,6 +1,5 @@ import { z } from "zod"; import type { CharacterSkill, PsychicClass } from "../schema/character.ts"; -import type { AttributeCode } from "../schema/attributes.ts"; import type { Occ } from "../schema/occ.ts"; import type { Skill } from "../schema/skills.ts"; import { getAlignment } from "./alignments.ts"; @@ -17,33 +16,6 @@ import { initialSpellChoices } from "./spells.ts"; * levels (`progression`, `newSkillsStartAtLevel1`) is level-up territory. */ -// --------------------------------------------------------------------------- -// Attribute requirements (surfaced at the O.C.C. step; RUE per-O.C.C. entries) - -export interface RequirementFailure { - code: AttributeCode; - min: number; - actual: number; -} - -export interface RequirementCheck { - ok: boolean; - failures: RequirementFailure[]; -} - -/** Check rolled attributes against the O.C.C.'s printed requirements. */ -export function meetsAttributeRequirements( - occ: Occ, - attributes: Partial>, -): RequirementCheck { - const failures: RequirementFailure[] = []; - for (const req of occ.attributeRequirements) { - const actual = attributes[req.code] ?? 0; - if (actual < req.min) failures.push({ code: req.code, min: req.min, actual }); - } - return { ok: failures.length === 0, failures }; -} - // --------------------------------------------------------------------------- // Psionics (Step 4, RUE p.289 — Random Psionics Table) diff --git a/packages/rules/src/engine/eligibility.ts b/packages/rules/src/engine/eligibility.ts new file mode 100644 index 0000000..900cca4 --- /dev/null +++ b/packages/rules/src/engine/eligibility.ts @@ -0,0 +1,87 @@ +import type { AttributeCode } from "../schema/attributes.ts"; +import type { Occ } from "../schema/occ.ts"; +import type { Species } from "../schema/species.ts"; +import { getSpecies } from "./species.ts"; + +export type OccEligibilityFailure = + | { kind: "unknownSpecies"; speciesId: string } + | { kind: "unavailableSpecies"; speciesId: string; name: string } + | { kind: "speciesNotAllowed"; speciesId: string; allowedSpeciesIds: string[] } + | { kind: "attribute"; code: AttributeCode; min: number; actual: number }; + +export interface OccEligibilityResult { + ok: boolean; + species?: Species; + failures: OccEligibilityFailure[]; +} + +export function validateOccEligibility( + occ: Occ, + speciesId: string, + attributes: Partial>, +): OccEligibilityResult { + const failures: OccEligibilityFailure[] = []; + const species = getSpecies(speciesId); + + if (species === undefined) { + failures.push({ kind: "unknownSpecies", speciesId }); + } else { + if (!species.playable) { + failures.push({ kind: "unavailableSpecies", speciesId, name: species.name }); + } + if ( + occ.speciesEligibility.kind === "oneOf" && + !occ.speciesEligibility.speciesIds.includes(speciesId) + ) { + failures.push({ + kind: "speciesNotAllowed", + speciesId, + allowedSpeciesIds: [...occ.speciesEligibility.speciesIds], + }); + } + } + + for (const requirement of occ.attributeRequirements) { + const actual = attributes[requirement.code] ?? 0; + if (actual < requirement.min) { + failures.push({ + kind: "attribute", + code: requirement.code, + min: requirement.min, + actual, + }); + } + } + + return { + ok: failures.length === 0, + ...(species === undefined ? {} : { species }), + failures, + }; +} + +const attributeLabel: Record = { + IQ: "I.Q.", + ME: "M.E.", + MA: "M.A.", + PS: "P.S.", + PP: "P.P.", + PE: "P.E.", + PB: "P.B.", + Spd: "Spd", +}; + +export function describeOccEligibilityFailure(failure: OccEligibilityFailure): string { + switch (failure.kind) { + case "unknownSpecies": + return `Unknown species "${failure.speciesId}".`; + case "unavailableSpecies": + return `${failure.name} is known but not playable.`; + case "speciesNotAllowed": + return `Species "${failure.speciesId}" is not allowed; expected ${failure.allowedSpeciesIds.join( + ", ", + )}.`; + case "attribute": + return `${attributeLabel[failure.code]} ${failure.actual}; requires ${failure.min}+.`; + } +} diff --git a/packages/rules/src/index.ts b/packages/rules/src/index.ts index 05ae29c..505e6c9 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -14,6 +14,7 @@ export * from "./schema/character.ts"; export * from "./engine/alignments.ts"; export * from "./engine/attributes.ts"; export * from "./engine/species.ts"; +export * from "./engine/eligibility.ts"; export * from "./engine/dice.ts"; export * from "./engine/occ.ts"; export * from "./engine/combat.ts"; diff --git a/packages/rules/tests/builder.test.ts b/packages/rules/tests/builder.test.ts index f38a768..b9c6933 100644 --- a/packages/rules/tests/builder.test.ts +++ b/packages/rules/tests/builder.test.ts @@ -3,7 +3,6 @@ import { assembleSkills, deriveSheet, leyLineWalker, - meetsAttributeRequirements, occSchema, occSkillPlan, relatedSkillPlan, @@ -23,26 +22,6 @@ function d100Faces(...values: number[]): Rng { }; } -describe("meetsAttributeRequirements — LLW needs I.Q. 10, P.E. 12", () => { - test("passes at the printed minimums", () => { - const check = meetsAttributeRequirements(leyLineWalker, { IQ: 10, PE: 12 }); - expect(check).toEqual({ ok: true, failures: [] }); - }); - - test("reports each failing attribute with the shortfall", () => { - const check = meetsAttributeRequirements(leyLineWalker, { IQ: 9, PE: 11 }); - expect(check.ok).toBe(false); - expect(check.failures).toEqual([ - { code: "IQ", min: 10, actual: 9 }, - { code: "PE", min: 12, actual: 11 }, - ]); - }); - - test("missing attributes count as 0", () => { - expect(meetsAttributeRequirements(leyLineWalker, {}).failures).toHaveLength(2); - }); -}); - describe("rollPsionics — Random Psionics Table, RUE p.289", () => { test("01-10 is major, 11-25 minor, 26-00 none", () => { expect(rollPsionics(d100Faces(5))).toEqual({ diff --git a/packages/rules/tests/eligibility.test.ts b/packages/rules/tests/eligibility.test.ts new file mode 100644 index 0000000..f963a89 --- /dev/null +++ b/packages/rules/tests/eligibility.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + describeOccEligibilityFailure, + leyLineWalker, + occSchema, + validateOccEligibility, +} from "../src/index.ts"; + +const gruntFixture = occSchema.parse({ + source: { book: "Rifts Ultimate Edition", page: 233 }, + id: "coalition-grunt-eligibility-fixture", + name: "Coalition Grunt eligibility fixture", + category: "Men at Arms", + alignment: "Any", + attributeRequirements: [], + speciesEligibility: { kind: "oneOf", speciesIds: ["human", "psi-stalker"] }, +}); + +describe("validateOccEligibility", () => { + test("accepts playable Human for the p.233 Grunt fixture", () => { + expect(validateOccEligibility(gruntFixture, "human", {})).toMatchObject({ + ok: true, + species: { id: "human", playable: true }, + failures: [], + }); + }); + + test("distinguishes unknown, unavailable, and disallowed species", () => { + expect(validateOccEligibility(gruntFixture, "kryptonian", {}).failures).toEqual([ + { kind: "unknownSpecies", speciesId: "kryptonian" }, + ]); + expect(validateOccEligibility(gruntFixture, "psi-stalker", {}).failures).toEqual([ + { kind: "unavailableSpecies", speciesId: "psi-stalker", name: "Psi-Stalker" }, + ]); + const psiOnly = occSchema.parse({ + ...gruntFixture, + id: "psi-only-fixture", + speciesEligibility: { kind: "oneOf", speciesIds: ["psi-stalker"] }, + }); + expect(validateOccEligibility(psiOnly, "human", {}).failures).toEqual([ + { + kind: "speciesNotAllowed", + speciesId: "human", + allowedSpeciesIds: ["psi-stalker"], + }, + ]); + }); + + test("reports every failed printed attribute requirement", () => { + const result = validateOccEligibility(leyLineWalker, "human", { IQ: 9, PE: 11 }); + expect(result.failures).toEqual([ + { kind: "attribute", code: "IQ", min: 10, actual: 9 }, + { kind: "attribute", code: "PE", min: 12, actual: 11 }, + ]); + expect(result.failures.map(describeOccEligibilityFailure)).toEqual([ + "I.Q. 9; requires 10+.", + "P.E. 11; requires 12+.", + ]); + }); +}); From 8a2f6dc1e2bb529c6b483cf78c9791d9863f0d71 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Wed, 22 Jul 2026 18:49:59 +0800 Subject: [PATCH 06/10] feat(rules): derive character species eligibility --- packages/rules/src/engine/character.ts | 15 +++++++++++++ packages/rules/src/schema/character.ts | 2 ++ packages/rules/tests/character.test.ts | 31 ++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/packages/rules/src/engine/character.ts b/packages/rules/src/engine/character.ts index 7c136f5..01a4cef 100644 --- a/packages/rules/src/engine/character.ts +++ b/packages/rules/src/engine/character.ts @@ -7,6 +7,7 @@ import type { Spell } from "../schema/spells.ts"; import { getAlignment } from "./alignments.ts"; import { deriveAttributeBonuses } from "./attributes.ts"; import { diceMax, diceMin } from "./dice.ts"; +import { describeOccEligibilityFailure, validateOccEligibility } from "./eligibility.ts"; import { armorMaxPool, armorNeedsRoll, getItem } from "./items.ts"; import { combatProfile, @@ -60,6 +61,10 @@ export interface SheetArmor { export interface CharacterSheet { name: string; occ: { id: string; name: string; category: string }; + species: { + id: string; + name: string; + }; /** Present when the character has picked an alignment. */ alignment?: Alignment; /** Player-authored identity, passed through untouched (never affects numbers). */ @@ -117,6 +122,15 @@ export function deriveSheet(input: CharacterInput): CharacterSheet { const occ = getOcc(character.occId); if (!occ) throw new Error(`Unknown O.C.C. "${character.occId}".`); + const eligibility = validateOccEligibility(occ, character.speciesId, character.attributes); + if (!eligibility.ok || eligibility.species === undefined) { + throw new Error( + eligibility.failures.map(describeOccEligibilityFailure).join(" ") || + "O.C.C. eligibility could not be resolved.", + ); + } + const species = eligibility.species; + let alignment: Alignment | undefined; if (character.alignmentId !== undefined) { alignment = getAlignment(character.alignmentId); @@ -300,6 +314,7 @@ export function deriveSheet(input: CharacterInput): CharacterSheet { return { name: character.name, occ: { id: occ.id, name: occ.name, category: occ.category }, + species: { id: species.id, name: species.name }, alignment, narrative: character.narrative, level, diff --git a/packages/rules/src/schema/character.ts b/packages/rules/src/schema/character.ts index ee6670d..7e05d40 100644 --- a/packages/rules/src/schema/character.ts +++ b/packages/rules/src/schema/character.ts @@ -94,6 +94,8 @@ export type Narrative = z.infer; export const characterSchema = z.object({ name: z.string().min(1), occId: z.string().min(1), + /** Explicit for new characters; missing legacy documents derive as Human. */ + speciesId: z.string().min(1).default("human"), /** One of the seven canonical alignments (RUE pp.289-292). Optional because * characters stored before the alignment step existed have none; resolved * (and unknown ids rejected) in `deriveSheet`, like occ/skill/spell ids. */ diff --git a/packages/rules/tests/character.test.ts b/packages/rules/tests/character.test.ts index f79ba6c..4122826 100644 --- a/packages/rules/tests/character.test.ts +++ b/packages/rules/tests/character.test.ts @@ -4,6 +4,7 @@ import { deriveSheet, type CharacterInput } from "../src/index.ts"; const leyLineWalker: CharacterInput = { name: "Vesper", occId: "ley-line-walker", + speciesId: "human", level: 1, attributes: { IQ: 18, ME: 16, MA: 12, PS: 16, PP: 20, PE: 14, PB: 11, Spd: 12 }, hthType: "basic", @@ -91,6 +92,36 @@ describe("deriveSheet — a level-1 Ley Line Walker", () => { }); }); +describe("deriveSheet — species and O.C.C. eligibility", () => { + test("projects explicit Human identity onto the sheet", () => { + expect(deriveSheet(leyLineWalker).species).toEqual({ id: "human", name: "Human" }); + }); + + test("defaults a legacy document without speciesId to Human without mutating input", () => { + const { speciesId: _speciesId, ...legacy } = leyLineWalker; + expect(deriveSheet(legacy).species).toEqual({ id: "human", name: "Human" }); + expect("speciesId" in legacy).toBe(false); + }); + + test("rejects unknown and unavailable species", () => { + expect(() => deriveSheet({ ...leyLineWalker, speciesId: "kryptonian" })).toThrow( + 'Unknown species "kryptonian".', + ); + expect(() => deriveSheet({ ...leyLineWalker, speciesId: "psi-stalker" })).toThrow( + "Psi-Stalker is known but not playable.", + ); + }); + + test("enforces printed O.C.C. attribute requirements in derivation", () => { + expect(() => + deriveSheet({ + ...leyLineWalker, + attributes: { ...leyLineWalker.attributes, IQ: 9, PE: 11 }, + }), + ).toThrow("I.Q. 9; requires 10+. P.E. 11; requires 12+."); + }); +}); + describe("deriveSheet — edge cases", () => { test("derives alive from positive H.P. and coma from zero through the floor", () => { const rolled = { hitPoints: 18, sdc: 20 }; From bfbb3ea8c1e69c479148b636b6ed21da133efc5e Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Wed, 22 Jul 2026 19:01:20 +0800 Subject: [PATCH 07/10] feat(backend): persist character species --- packages/backend/convex/characters.ts | 7 ++- packages/backend/convex/schema.ts | 2 + packages/backend/tests/characters.test.ts | 63 ++++++++++++++++++--- packages/backend/tests/combat.test.ts | 1 + packages/backend/tests/healing-cast.test.ts | 1 + 5 files changed, 66 insertions(+), 8 deletions(-) diff --git a/packages/backend/convex/characters.ts b/packages/backend/convex/characters.ts index e93cd6d..e23edee 100644 --- a/packages/backend/convex/characters.ts +++ b/packages/backend/convex/characters.ts @@ -44,8 +44,13 @@ const characterInputFields = { spellIds: v.optional(characterFields.spellIds), }; +const newCharacterInputFields = { + ...characterInputFields, + speciesId: v.string(), +}; + export const create = mutation({ - args: characterInputFields, + args: newCharacterInputFields, returns: v.id("characters"), handler: async (ctx, args) => { return await ctx.db.insert("characters", validateCharacter(args)); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 2dcb272..834589d 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -12,6 +12,8 @@ import { combatExchangeValidator } from "./combat_values"; export const characterFields = { name: v.string(), occId: v.string(), + /** Optional only for documents created before species identity existed. */ + speciesId: v.optional(v.string()), alignmentId: v.optional(v.string()), level: v.number(), attributes: v.object({ diff --git a/packages/backend/tests/characters.test.ts b/packages/backend/tests/characters.test.ts index 83863e9..7a5cff6 100644 --- a/packages/backend/tests/characters.test.ts +++ b/packages/backend/tests/characters.test.ts @@ -12,6 +12,7 @@ const modules = { const vesper = { name: "Vesper", occId: "ley-line-walker", + speciesId: "human", level: 1, attributes: { IQ: 18, ME: 16, MA: 12, PS: 16, PP: 20, PE: 14, PB: 11, Spd: 12 }, hthType: "basic", @@ -25,6 +26,54 @@ const vesper = { }; 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.sheet, { id })).toMatchObject({ + species: { id: "human", name: "Human" }, + }); + + const { speciesId: _speciesId, ...missing } = vesper; + await expect(t.mutation(api.characters.create, missing as typeof vesper)).rejects.toThrow(); + }); + + test("backend rejects unknown, unavailable, and attribute-ineligible writes", async () => { + const t = convexTest(schema, modules); + await expect( + t.mutation(api.characters.create, { ...vesper, speciesId: "kryptonian" }), + ).rejects.toThrow('Unknown species "kryptonian".'); + await expect( + t.mutation(api.characters.create, { ...vesper, speciesId: "psi-stalker" }), + ).rejects.toThrow("Psi-Stalker is known but not playable."); + await expect( + t.mutation(api.characters.create, { + ...vesper, + attributes: { ...vesper.attributes, PE: 11 }, + }), + ).rejects.toThrow("P.E. 11; requires 12+."); + + const id = await t.mutation(api.characters.create, vesper); + await expect( + t.mutation(api.characters.update, { + id, + character: { ...vesper, speciesId: "psi-stalker" }, + }), + ).rejects.toThrow("Psi-Stalker is known but not playable."); + expect(await t.query(api.characters.get, { id })).toMatchObject({ speciesId: "human" }); + }); + + test("legacy storage without speciesId derives Human without a read-time rewrite", async () => { + const t = convexTest(schema, modules); + const { speciesId: _speciesId, ...legacy } = vesper; + const id = await t.run((ctx) => ctx.db.insert("characters", legacy)); + + expect(await t.query(api.characters.sheet, { id })).toMatchObject({ + species: { id: "human", name: "Human" }, + }); + expect(await t.query(api.characters.get, { id })).not.toHaveProperty("speciesId"); + }); + test("create → get returns the stored choices", async () => { const t = convexTest(schema, modules); const id = await t.mutation(api.characters.create, vesper); @@ -303,30 +352,30 @@ describe("living vitals — current vs. max (#38)", () => { }); test.each([ - { amount: 11, lifeState: "coma" as const, marker: undefined }, - { amount: 12, lifeState: "dead" as const, marker: "dead" as const }, + { amount: 13, lifeState: "coma" as const, marker: undefined }, + { amount: 14, lifeState: "dead" as const, marker: "dead" as const }, ])( "manual damage at the P.E. floor stores $lifeState without raw overflow", async ({ amount, lifeState, marker }) => { const t = convexTest(schema, modules); const id = await t.mutation(api.characters.create, { ...vesper, - attributes: { ...vesper.attributes, PE: 10 }, - rolled: { hitPoints: 11, sdc: 20, ppe: 84 }, + attributes: { ...vesper.attributes, PE: 12 }, + rolled: { hitPoints: 13, sdc: 20, ppe: 84 }, current: { sdc: 0, hitPoints: 1 }, }); expect(await t.mutation(api.characters.applyDamage, { id, amount })).toEqual({ before: { sdc: 0, hitPoints: 1 }, - after: { sdc: 0, hitPoints: -10 }, + after: { sdc: 0, hitPoints: -12 }, amount, lifeState, sdc: 0, - hitPoints: -10, + hitPoints: -12, }); expect((await t.query(api.characters.get, { id }))?.current).toEqual({ sdc: 0, - hitPoints: -10, + hitPoints: -12, ...(marker === undefined ? {} : { lifeState: marker }), }); }, diff --git a/packages/backend/tests/combat.test.ts b/packages/backend/tests/combat.test.ts index f77699c..9b02e5d 100644 --- a/packages/backend/tests/combat.test.ts +++ b/packages/backend/tests/combat.test.ts @@ -22,6 +22,7 @@ const modules = { const character: Character = { name: "Vesper", occId: "ley-line-walker", + speciesId: "human", level: 1, attributes: { IQ: 18, ME: 16, MA: 12, PS: 16, PP: 20, PE: 14, PB: 11, Spd: 12 }, hthType: "basic", diff --git a/packages/backend/tests/healing-cast.test.ts b/packages/backend/tests/healing-cast.test.ts index 565e004..0284d6d 100644 --- a/packages/backend/tests/healing-cast.test.ts +++ b/packages/backend/tests/healing-cast.test.ts @@ -14,6 +14,7 @@ const modules = { const vesper = { name: "Vesper", occId: "ley-line-walker", + speciesId: "human", level: 1, attributes: { IQ: 18, ME: 16, MA: 12, PS: 16, PP: 20, PE: 14, PB: 11, Spd: 12 }, hthType: "basic", From 8d3bb69cd37c95be71f6b65300ad00edf1efcd00 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Wed, 22 Jul 2026 19:17:55 +0800 Subject: [PATCH 08/10] feat(web): surface character species identity --- README.md | 4 +++ apps/web/src/builder/steps/identity.tsx | 13 ++++++++ apps/web/src/builder/steps/occ.tsx | 27 +++++++++++++---- apps/web/src/builder/store.ts | 39 ++++++++++++++++++------ apps/web/src/components/sheet-view.tsx | 3 +- apps/web/tests/builder.test.ts | 40 +++++++++++++++++++++++++ apps/web/tests/character-sheet.test.ts | 4 +++ 7 files changed, 115 insertions(+), 15 deletions(-) create mode 100644 apps/web/tests/builder.test.ts diff --git a/README.md b/README.md index 57a76de..51e174e 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ inventory and worn armor, take and recover damage, rest, draw P.P.E. from a ley receive treatment, and cast spells through server-validated state changes, while ephemeral sheet rolls use the shared rules package client-side. +Character identity is source-validated: Human is currently the sole playable +species, while known but unimplemented identities remain unavailable rather than +silently accepted. + ## Development Tooling is [Vite+](https://viteplus.dev) (`vp`) over a pnpm workspace. diff --git a/apps/web/src/builder/steps/identity.tsx b/apps/web/src/builder/steps/identity.tsx index 38c341a..e449d28 100644 --- a/apps/web/src/builder/steps/identity.tsx +++ b/apps/web/src/builder/steps/identity.tsx @@ -19,6 +19,19 @@ export function IdentityStep(props: { store: BuilderStore }) { /> +
+ SPECIES +
+ HUMAN // LOCKED +
+

+ // sole playable species in the current rules catalog +

+
+

// PERSONNEL FILE

diff --git a/apps/web/src/builder/steps/occ.tsx b/apps/web/src/builder/steps/occ.tsx index 779aa18..d1146e1 100644 --- a/apps/web/src/builder/steps/occ.tsx +++ b/apps/web/src/builder/steps/occ.tsx @@ -1,4 +1,9 @@ -import { meetsAttributeRequirements, occRegistry } from "@riftforge/rules"; +import { + describeOccEligibilityFailure, + getSpecies, + occRegistry, + validateOccEligibility, +} from "@riftforge/rules"; import { For, Show } from "solid-js"; import { Alert, MonoLabel, Panel } from "../../components/ui.tsx"; import type { BuilderStore } from "../store.ts"; @@ -8,7 +13,17 @@ export function OccStep(props: { store: BuilderStore }) { const occs = Object.values(occRegistry); const check = (occ: (typeof occs)[number]) => { const attrs = props.store.attributeTotals(); - return attrs ? meetsAttributeRequirements(occ, attrs) : undefined; + return attrs ? validateOccEligibility(occ, props.store.draft.speciesId, attrs) : undefined; + }; + + const eligibleSpecies = (occ: (typeof occs)[number]) => { + if (occ.speciesEligibility.kind === "any") return "ANY PLAYABLE SPECIES"; + return occ.speciesEligibility.speciesIds + .map((id) => { + const species = getSpecies(id); + return species ? `${species.name}${species.playable ? "" : " [DEFERRED]"}` : id; + }) + .join(", "); }; return ( @@ -30,6 +45,7 @@ export function OccStep(props: { store: BuilderStore }) { class="accent-amber" checked={props.store.draft.occId === occ.id} onChange={() => props.store.setDraft("occId", occ.id)} + disabled={check(occ)?.ok !== true} /> {occ.name} {occ.category} @@ -41,6 +57,9 @@ export function OccStep(props: { store: BuilderStore }) { REQUIRES:{" "} {occ.attributeRequirements.map((r) => `${r.code} ${r.min}+`).join(", ") || "NOTHING"}

+

+ SPECIES: {eligibleSpecies(occ).toUpperCase()} +

{(result) => ( NOT QUALIFIED:{" "} - {result() - .failures.map((f) => `${f.code} ${f.actual} (NEEDS ${f.min})`) - .join(", ")}{" "} + {result().failures.map(describeOccEligibilityFailure).join(" ").toUpperCase()}{" "} — REROLL ATTRIBUTES TO QUALIFY } diff --git a/apps/web/src/builder/store.ts b/apps/web/src/builder/store.ts index d8070c4..704015f 100644 --- a/apps/web/src/builder/store.ts +++ b/apps/web/src/builder/store.ts @@ -2,8 +2,9 @@ import { assembleSkills, deriveSheet, getOcc, - meetsAttributeRequirements, + getSpecies, occSkillPlan, + validateOccEligibility, validateInitialSpells, type AttributeCode, type AttributeRoll, @@ -21,6 +22,7 @@ import { emptyNarrativeForm, toNarrative, type NarrativeForm } from "../lib/narr /** Everything the player has chosen so far. */ export interface Draft { name: string; + speciesId: string; /** Optional player-authored identity (raw form strings). */ narrative: NarrativeForm; attributes?: Record; @@ -41,13 +43,15 @@ export interface BuilderStore { setDraft: SetStoreFunction; /** The chosen O.C.C., once picked. */ occ: Accessor>; + /** The resolved source-owned species identity. */ + species: Accessor>; /** Rolled attribute totals, once rolled. */ attributeTotals: Accessor | undefined>; /** Engine validation of every skill selection (empty errors = legal). */ assembled: Accessor | undefined>; spellErrors: Accessor; /** The final character, when every step is satisfied. */ - characterInput: Accessor; + characterInput: Accessor<(CharacterInput & { speciesId: string }) | undefined>; /** The derived preview for the review step, or the error preventing it. */ preview: Accessor<{ sheet?: CharacterSheet; error?: string }>; } @@ -55,6 +59,7 @@ export interface BuilderStore { export function createBuilderStore(): BuilderStore { const [draft, setDraft] = createStore({ name: "", + speciesId: "human", narrative: { ...emptyNarrativeForm }, psychicClass: "ordinary", occChoices: {}, @@ -64,6 +69,7 @@ export function createBuilderStore(): BuilderStore { }); const occ = createMemo(() => (draft.occId !== undefined ? getOcc(draft.occId) : undefined)); + const species = createMemo(() => getSpecies(draft.speciesId)); const attributeTotals = createMemo(() => { if (!draft.attributes) return undefined; @@ -94,7 +100,7 @@ export function createBuilderStore(): BuilderStore { return chosen ? validateInitialSpells(chosen, draft.spellIds) : []; }); - const characterInput = createMemo((): CharacterInput | undefined => { + const characterInput = createMemo((): (CharacterInput & { speciesId: string }) | undefined => { const attributes = attributeTotals(); const skills = assembled(); if (!draft.occId || !attributes || !skills) return undefined; @@ -102,6 +108,7 @@ export function createBuilderStore(): BuilderStore { return { name: draft.name.trim(), occId: draft.occId, + speciesId: draft.speciesId, ...(draft.alignmentId !== undefined ? { alignmentId: draft.alignmentId } : {}), level: 1, attributes, @@ -123,22 +130,36 @@ export function createBuilderStore(): BuilderStore { } }); - return { draft, setDraft, occ, attributeTotals, assembled, spellErrors, characterInput, preview }; + return { + draft, + setDraft, + occ, + species, + attributeTotals, + assembled, + spellErrors, + characterInput, + preview, + }; } /** Per-step gating — a step is reachable when all before it are valid. */ export function stepValidity(store: BuilderStore) { - const requirements = createMemo(() => { + const eligibility = createMemo(() => { const chosen = store.occ(); const attrs = store.attributeTotals(); - return chosen && attrs ? meetsAttributeRequirements(chosen, attrs) : undefined; + return chosen && attrs + ? validateOccEligibility(chosen, store.draft.speciesId, attrs) + : undefined; }); return { - requirements, - identity: createMemo(() => store.draft.name.trim().length > 0), + eligibility, + identity: createMemo( + () => store.draft.name.trim().length > 0 && store.species()?.playable === true, + ), attributes: createMemo(() => store.draft.attributes !== undefined), - occ: createMemo(() => store.occ() !== undefined && requirements()?.ok === true), + occ: createMemo(() => store.occ() !== undefined && eligibility()?.ok === true), alignment: createMemo(() => store.draft.alignmentId !== undefined), psionics: createMemo(() => true), // The O.C.C.-skills step only checks its own slots: related/secondary diff --git a/apps/web/src/components/sheet-view.tsx b/apps/web/src/components/sheet-view.tsx index 357f015..4165509 100644 --- a/apps/web/src/components/sheet-view.tsx +++ b/apps/web/src/components/sheet-view.tsx @@ -368,7 +368,8 @@ export function SheetView(props: {
- LEVEL {s().level} // {s().occ.name.toUpperCase()} // {s().occ.category.toUpperCase()} + LEVEL {s().level} // {s().species.name.toUpperCase()} // {s().occ.name.toUpperCase()} //{" "} + {s().occ.category.toUpperCase()}

{s().name}

diff --git a/apps/web/tests/builder.test.ts b/apps/web/tests/builder.test.ts new file mode 100644 index 0000000..9925de8 --- /dev/null +++ b/apps/web/tests/builder.test.ts @@ -0,0 +1,40 @@ +/// + +import { existsSync, readFileSync } from "node:fs"; +import { createRoot } from "solid-js"; +import { describe, expect, test } from "vite-plus/test"; +import { createBuilderStore } from "../src/builder/store.ts"; + +function source(relative: string): string { + const url = new URL(relative, import.meta.url); + if (!existsSync(url)) throw new Error(`Missing source under test: ${relative}`); + return readFileSync(url, "utf8"); +} + +const storeSource = source("../src/builder/store.ts"); +const identitySource = source("../src/builder/steps/identity.tsx"); +const occSource = source("../src/builder/steps/occ.tsx"); + +describe("builder species identity", () => { + test("starts with the sole playable Human identity", () => { + createRoot((dispose) => { + const store = createBuilderStore(); + expect(store.draft.speciesId).toBe("human"); + expect(store.species()).toMatchObject({ id: "human", name: "Human", playable: true }); + dispose(); + }); + }); + + test("persists species and delegates O.C.C. legality to the shared engine", () => { + expect(storeSource).toContain("speciesId: draft.speciesId"); + expect(storeSource).toContain("validateOccEligibility("); + expect(occSource).toContain("validateOccEligibility("); + expect(occSource).toContain("describeOccEligibilityFailure"); + }); + + test("renders Human as locked identity rather than an editable species step", () => { + expect(identitySource).toContain("SPECIES"); + expect(identitySource).toContain("HUMAN // LOCKED"); + expect(identitySource).not.toContain('name="species"'); + }); +}); diff --git a/apps/web/tests/character-sheet.test.ts b/apps/web/tests/character-sheet.test.ts index 3d3e2c1..38e1ed2 100644 --- a/apps/web/tests/character-sheet.test.ts +++ b/apps/web/tests/character-sheet.test.ts @@ -43,6 +43,10 @@ describe("application shell", () => { }); describe("terminal sheet dossier", () => { + test("shows resolved species in the dossier identity line", () => { + expect(sheetViewSource).toContain("s().species.name.toUpperCase()"); + }); + test("retains the dossier while disabling every gameplay roll with the terminal reason", () => { expect(characterSheetSource).toContain(TERMINAL_REASON); expect(sheetViewSource).toContain("gameplayDisabledReason?: string;"); From 4b3574443e00167e2433be97383183598e8eb3a1 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Wed, 22 Jul 2026 20:58:17 +0800 Subject: [PATCH 09/10] test(rules): pin eligibility contracts --- packages/rules/tests/eligibility.test.ts | 32 ++++++++++++++++++++++++ packages/rules/tests/occ.test.ts | 1 + 2 files changed, 33 insertions(+) diff --git a/packages/rules/tests/eligibility.test.ts b/packages/rules/tests/eligibility.test.ts index f963a89..0f0def3 100644 --- a/packages/rules/tests/eligibility.test.ts +++ b/packages/rules/tests/eligibility.test.ts @@ -3,6 +3,7 @@ import { describeOccEligibilityFailure, leyLineWalker, occSchema, + type OccEligibilityFailure, validateOccEligibility, } from "../src/index.ts"; @@ -57,4 +58,35 @@ describe("validateOccEligibility", () => { "P.E. 11; requires 12+.", ]); }); + + test.each([ + [ + "unknown species", + { kind: "unknownSpecies", speciesId: "kryptonian" }, + 'Unknown species "kryptonian".', + ], + [ + "unavailable species", + { + kind: "unavailableSpecies", + speciesId: "psi-stalker", + name: "Psi-Stalker", + }, + "Psi-Stalker is known but not playable.", + ], + [ + "disallowed species", + { + kind: "speciesNotAllowed", + speciesId: "human", + allowedSpeciesIds: ["psi-stalker"], + }, + 'Species "human" is not allowed; expected psi-stalker.', + ], + ] satisfies readonly (readonly [string, OccEligibilityFailure, string])[])( + "pins the exact formatter message for %s", + (_label, failure, expected) => { + expect(describeOccEligibilityFailure(failure)).toBe(expected); + }, + ); }); diff --git a/packages/rules/tests/occ.test.ts b/packages/rules/tests/occ.test.ts index a736f49..996cd8c 100644 --- a/packages/rules/tests/occ.test.ts +++ b/packages/rules/tests/occ.test.ts @@ -40,6 +40,7 @@ describe("Ley Line Walker O.C.C. (RUE pp.113-116)", () => { category: "Test", alignment: "Any", attributeRequirements: [], + speciesEligibility: { kind: "any" }, }; expect(() => occSchema.parse({ ...base, racialRequirement: "Humans only" })).toThrow(); expect(() => From cdf5f703c435983042b455b60bf51d5e0178f198 Mon Sep 17 00:00:00 2001 From: StreamDemon Date: Wed, 22 Jul 2026 23:44:29 +0800 Subject: [PATCH 10/10] fix: harden species eligibility review --- ...026-07-22-occ-breadth-foundation-design.md | 6 +- apps/web/src/builder/steps/occ.tsx | 4 +- apps/web/src/builder/store.ts | 7 ++ apps/web/tests/builder.test.ts | 68 ++++++++++++++++--- packages/rules/src/engine/eligibility.ts | 5 +- packages/rules/src/engine/species.ts | 27 ++++++-- packages/rules/tests/species.test.ts | 21 +++++- 7 files changed, 116 insertions(+), 22 deletions(-) diff --git a/.codex/superpowers/specs/2026-07-22-occ-breadth-foundation-design.md b/.codex/superpowers/specs/2026-07-22-occ-breadth-foundation-design.md index 4a24616..97c383e 100644 --- a/.codex/superpowers/specs/2026-07-22-occ-breadth-foundation-design.md +++ b/.codex/superpowers/specs/2026-07-22-occ-breadth-foundation-design.md @@ -2,7 +2,7 @@ **Date:** 2026-07-22 -**Status:** Approved in conversation; implementation not started +**Status:** Approved; #60 foundation implemented on PR #63, later phases not started **Design branch:** `docs/occ-breadth-foundation-design` @@ -453,8 +453,8 @@ subsystem. A dependency label is removed only after its implementing PR is merged and the result is synchronized and verified on local `main`. Exactly one issue carries -`next-up`. After #57 completes, #58 becomes that issue; #59 remains queued after -#58. +`next-up`. After #57 completes, #58 becomes that issue. #59 remains queued +after #58. ## Success criteria diff --git a/apps/web/src/builder/steps/occ.tsx b/apps/web/src/builder/steps/occ.tsx index d1146e1..17002e5 100644 --- a/apps/web/src/builder/steps/occ.tsx +++ b/apps/web/src/builder/steps/occ.tsx @@ -6,7 +6,7 @@ import { } from "@riftforge/rules"; import { For, Show } from "solid-js"; import { Alert, MonoLabel, Panel } from "../../components/ui.tsx"; -import type { BuilderStore } from "../store.ts"; +import { occEligibilityRecoveryGuidance, type BuilderStore } from "../store.ts"; /** Step 5 (RUE p.289): pick an O.C.C., gated on its attribute requirements. */ export function OccStep(props: { store: BuilderStore }) { @@ -68,7 +68,7 @@ export function OccStep(props: { store: BuilderStore }) { NOT QUALIFIED:{" "} {result().failures.map(describeOccEligibilityFailure).join(" ").toUpperCase()}{" "} - — REROLL ATTRIBUTES TO QUALIFY + — {occEligibilityRecoveryGuidance(result().failures)} } > diff --git a/apps/web/src/builder/store.ts b/apps/web/src/builder/store.ts index 704015f..33c1fb3 100644 --- a/apps/web/src/builder/store.ts +++ b/apps/web/src/builder/store.ts @@ -11,6 +11,7 @@ import { type BuilderSelections, type CharacterInput, type CharacterSheet, + type OccEligibilityFailure, type PsionicsRoll, type PsychicClass, type SkillPick, @@ -143,6 +144,12 @@ export function createBuilderStore(): BuilderStore { }; } +export function occEligibilityRecoveryGuidance(failures: readonly OccEligibilityFailure[]): string { + return failures.some((failure) => failure.kind !== "attribute") + ? "IDENTITY/O.C.C. UNAVAILABLE" + : "REROLL ATTRIBUTES TO QUALIFY"; +} + /** Per-step gating — a step is reachable when all before it are valid. */ export function stepValidity(store: BuilderStore) { const eligibility = createMemo(() => { diff --git a/apps/web/tests/builder.test.ts b/apps/web/tests/builder.test.ts index 9925de8..d08e55d 100644 --- a/apps/web/tests/builder.test.ts +++ b/apps/web/tests/builder.test.ts @@ -1,9 +1,14 @@ /// import { existsSync, readFileSync } from "node:fs"; +import type { AttributeCode, AttributeRoll, OccEligibilityFailure } from "@riftforge/rules"; import { createRoot } from "solid-js"; import { describe, expect, test } from "vite-plus/test"; -import { createBuilderStore } from "../src/builder/store.ts"; +import { + createBuilderStore, + occEligibilityRecoveryGuidance, + stepValidity, +} from "../src/builder/store.ts"; function source(relative: string): string { const url = new URL(relative, import.meta.url); @@ -11,9 +16,24 @@ function source(relative: string): string { return readFileSync(url, "utf8"); } -const storeSource = source("../src/builder/store.ts"); const identitySource = source("../src/builder/steps/identity.tsx"); -const occSource = source("../src/builder/steps/occ.tsx"); + +const rolled = (...dice: number[]): AttributeRoll => ({ + total: dice.reduce((sum, die) => sum + die, 0), + dice, + exceptional: false, +}); + +const legalLeyLineWalkerAttributes: Record = { + IQ: rolled(3, 3, 4), + ME: rolled(4, 4, 4), + MA: rolled(4, 4, 4), + PS: rolled(4, 4, 4), + PP: rolled(4, 4, 4), + PE: rolled(4, 4, 4), + PB: rolled(4, 4, 4), + Spd: rolled(4, 4, 4), +}; describe("builder species identity", () => { test("starts with the sole playable Human identity", () => { @@ -25,11 +45,43 @@ describe("builder species identity", () => { }); }); - test("persists species and delegates O.C.C. legality to the shared engine", () => { - expect(storeSource).toContain("speciesId: draft.speciesId"); - expect(storeSource).toContain("validateOccEligibility("); - expect(occSource).toContain("validateOccEligibility("); - expect(occSource).toContain("describeOccEligibilityFailure"); + test("derives legal and species-ineligible O.C.C. results through store accessors", () => { + createRoot((dispose) => { + const store = createBuilderStore(); + const validity = stepValidity(store); + store.setDraft("attributes", legalLeyLineWalkerAttributes); + store.setDraft("occId", "ley-line-walker"); + + expect(validity.eligibility()).toMatchObject({ + ok: true, + species: { id: "human", source: { book: "Rifts Ultimate Edition", page: 233 } }, + failures: [], + }); + expect(validity.occ()).toBe(true); + + store.setDraft("speciesId", "psi-stalker"); + const speciesIneligible = validity.eligibility(); + expect(speciesIneligible).toMatchObject({ + ok: false, + failures: [{ kind: "unavailableSpecies", speciesId: "psi-stalker", name: "Psi-Stalker" }], + }); + expect(occEligibilityRecoveryGuidance(speciesIneligible?.failures ?? [])).toBe( + "IDENTITY/O.C.C. UNAVAILABLE", + ); + expect(validity.occ()).toBe(false); + dispose(); + }); + }); + + test("uses identity guidance for species failures and reroll guidance for attributes", () => { + expect( + occEligibilityRecoveryGuidance([{ kind: "attribute", code: "PE", min: 12, actual: 11 }]), + ).toBe("REROLL ATTRIBUTES TO QUALIFY"); + const mixedFailures: OccEligibilityFailure[] = [ + { kind: "unavailableSpecies", speciesId: "psi-stalker", name: "Psi-Stalker" }, + { kind: "attribute", code: "PE", min: 12, actual: 11 }, + ]; + expect(occEligibilityRecoveryGuidance(mixedFailures)).toBe("IDENTITY/O.C.C. UNAVAILABLE"); }); test("renders Human as locked identity rather than an editable species step", () => { diff --git a/packages/rules/src/engine/eligibility.ts b/packages/rules/src/engine/eligibility.ts index 900cca4..ba71cd8 100644 --- a/packages/rules/src/engine/eligibility.ts +++ b/packages/rules/src/engine/eligibility.ts @@ -1,7 +1,6 @@ import type { AttributeCode } from "../schema/attributes.ts"; import type { Occ } from "../schema/occ.ts"; -import type { Species } from "../schema/species.ts"; -import { getSpecies } from "./species.ts"; +import { getSpecies, type ImmutableSpecies } from "./species.ts"; export type OccEligibilityFailure = | { kind: "unknownSpecies"; speciesId: string } @@ -11,7 +10,7 @@ export type OccEligibilityFailure = export interface OccEligibilityResult { ok: boolean; - species?: Species; + species?: ImmutableSpecies; failures: OccEligibilityFailure[]; } diff --git a/packages/rules/src/engine/species.ts b/packages/rules/src/engine/species.ts index 040f617..6da21a0 100644 --- a/packages/rules/src/engine/species.ts +++ b/packages/rules/src/engine/species.ts @@ -1,10 +1,27 @@ import speciesRaw from "../content/species/species.json" with { type: "json" }; import { speciesCatalogSchema, type Species } from "../schema/species.ts"; -export const speciesCatalog = speciesCatalogSchema.parse(speciesRaw); +export type ImmutableSpecies = Readonly< + Omit & { source: Readonly } +>; -export function buildSpeciesIndex(species: readonly Species[]): Map { - const byId = new Map(); +const parsedCatalog = speciesCatalogSchema.parse(speciesRaw); +const immutableSpecies = Object.freeze( + parsedCatalog.species.map( + (species): ImmutableSpecies => + Object.freeze({ ...species, source: Object.freeze({ ...species.source }) }), + ), +); + +export const speciesCatalog = Object.freeze({ + ...parsedCatalog, + species: immutableSpecies, +}); + +export function buildSpeciesIndex( + species: readonly ImmutableSpecies[], +): ReadonlyMap { + const byId = new Map(); for (const entry of species) { if (byId.has(entry.id)) throw new Error(`Duplicate species id "${entry.id}".`); byId.set(entry.id, entry); @@ -14,7 +31,7 @@ export function buildSpeciesIndex(species: readonly Species[]): Map { test("pins the complete two-entry catalog and p.233 source", () => { expect(speciesCatalog.species).toHaveLength(2); expect(speciesCatalog.species.map((species) => species.id)).toEqual(["human", "psi-stalker"]); - expect(humanSpecies).toMatchObject({ id: "human", name: "Human", playable: true }); + expect(humanSpecies).toMatchObject({ + id: "human", + name: "Human", + playable: true, + source: { book: "Rifts Ultimate Edition", page: 233 }, + }); expect(getSpecies("psi-stalker")).toMatchObject({ name: "Psi-Stalker", playable: false, @@ -20,6 +25,20 @@ describe("species catalog — first O.C.C. breadth boundary", () => { expect(getSpecies("unknown")).toBeUndefined(); }); + test("exposes only immutable source-stamped catalog entries", () => { + expect(Object.isFrozen(speciesCatalog)).toBe(true); + expect(Object.isFrozen(speciesCatalog.species)).toBe(true); + for (const species of speciesCatalog.species) { + expect(Object.isFrozen(species)).toBe(true); + expect(Object.isFrozen(species.source)).toBe(true); + } + + expect(() => { + (humanSpecies as unknown as { playable: boolean }).playable = false; + }).toThrow(); + expect(getSpecies("human")?.playable).toBe(true); + }); + test("rejects duplicate ids instead of shadowing entries", () => { const duplicate: Species[] = [humanSpecies, { ...humanSpecies, name: "Duplicate" }]; expect(() => buildSpeciesIndex(duplicate)).toThrow('Duplicate species id "human"');