Compare commits
88 commits
8828908a59
...
6cddb1dcd0
| Author | SHA1 | Date | |
|---|---|---|---|
|
6cddb1dcd0 |
|||
|
babee38702 |
|||
|
8fb5669c9a |
|||
|
63c46a3f9e |
|||
|
747d0fb728 |
|||
|
fb6170127e |
|||
|
52752ecf33 |
|||
|
6dd5eb17ce |
|||
|
1e69675596 |
|||
|
dcd782fa5a |
|||
|
31af101b55 |
|||
|
9b586b83b5 |
|||
|
cbe4a4b5f6 |
|||
|
28b11f342d |
|||
|
9441570349 |
|||
|
109be25be6 |
|||
|
2b641c78bb |
|||
|
1d5efaa95f |
|||
|
795207e8b4 |
|||
|
b35d758c57 |
|||
|
8b9d3a7a4c |
|||
|
8a7c1b3f54 |
|||
|
ce49b55a60 |
|||
|
b93b4b7322 |
|||
|
d17f4bd1fd |
|||
|
2c36925d0b |
|||
|
2e655a0c1a |
|||
|
60b89d8c5e |
|||
|
6e0479703d |
|||
|
7bee3cbaa9 |
|||
|
ce6b2c1816 |
|||
|
2643222373 |
|||
|
a39b9921c6 |
|||
|
00533167b4 |
|||
|
396051f5c0 |
|||
|
3d49cd2792 |
|||
|
c74a1fca00 |
|||
|
9960ea96cf |
|||
|
567480a788 |
|||
|
748dde5d4c |
|||
|
646b16a8c0 |
|||
|
0987adbff3 |
|||
|
92dae32f31 |
|||
|
8f5dca9c21 |
|||
|
ebed10d39a |
|||
|
a27cb29a5b |
|||
|
2a04ae513c |
|||
|
929ee6da81 |
|||
|
980d567354 |
|||
|
ca9072ce48 |
|||
|
2a903f8bd6 |
|||
|
9af78ab5e2 |
|||
|
d555232696 |
|||
|
0bd65e0a73 |
|||
|
37e485537d |
|||
|
3557aa7cb4 |
|||
|
728ad76a5e |
|||
|
8ae934f563 |
|||
|
0aced40118 |
|||
|
c292695309 |
|||
|
278370a630 |
|||
|
cfc68bba51 |
|||
|
5f252e2dff |
|||
|
fa1ef765f8 |
|||
|
cc0b7b0446 |
|||
|
be7a3ea57b |
|||
|
100bf5c909 |
|||
|
28e03d06fa |
|||
|
4c8e8467b7 |
|||
|
c34c11af92 |
|||
|
4b7d943edc |
|||
|
0e9809007d |
|||
|
e23e69e0d0 |
|||
|
29a5ecfd3f |
|||
|
1402c7094b |
|||
|
5f339d568f |
|||
|
7d27620507 |
|||
|
f566c3a488 |
|||
|
ef93eb6101 |
|||
|
99a091509d |
|||
|
ff8b8d9bb0 |
|||
|
761adfb699 |
|||
|
e58bb02605 |
|||
|
72ca8f4bd8 |
|||
|
fab8a8115b |
|||
|
fea511790b |
|||
|
0f3b28ba55 |
|||
|
a442e54d79 |
134 changed files with 24484 additions and 144 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -11,3 +11,11 @@ test-results/
|
|||
*.tsbuildinfo
|
||||
bun.lock
|
||||
node-compile-cache/
|
||||
|
||||
# Playwright/Chromium runtime caches (random hash directories)
|
||||
7xOfJqsDU787fe61oSCq1/
|
||||
IJGn1F-WxLUCbfU1lu8pU/
|
||||
KMr2_dqTOvh2R-Vg1E6cO/
|
||||
org.chromium.Chromium.*/
|
||||
|
||||
.org.chromium.Chromium.*
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
{
|
||||
"active_plan": "/home/joey/Projects/rules/.sisyphus/plans/rete-rules-engine.md",
|
||||
"started_at": "2026-04-16T21:50:28.031Z",
|
||||
"session_ids": [
|
||||
"ses_267b9d7a2ffeFkGcPFn1iv223J",
|
||||
"ses_267b7c6a3ffeFBPE7j5hCcgvdq",
|
||||
"ses_267b27b25ffe6ox746ql2Qj1E2",
|
||||
"ses_267ae3c18ffe1Q0dx2aMzUZwid",
|
||||
"ses_267ab53e5ffeXk8oWYjxiSryf0",
|
||||
"ses_267a4cff0ffeCc0cSJZuxty3MR",
|
||||
"ses_267a27b30ffeaIVszd2do4wYGU",
|
||||
"ses_2678e1772ffeXFAdrjVVLAIh1s",
|
||||
"ses_2677bcf14ffeCyy0Il5QV4Wdp0",
|
||||
"ses_26776247dffehQGb1xnTBRqjq0",
|
||||
"ses_26772a7e2ffep3REXuUXLd4YsX",
|
||||
"ses_26770d3e0ffeWPNocV3HxsUb70",
|
||||
"ses_2676e6648ffegH7o8GqgKw4hkM",
|
||||
"ses_26768e818ffeacHy63Rn2RFmrS",
|
||||
"ses_26760ae54ffezlg9ttb3a9P7wm",
|
||||
"ses_2675a45d4ffee5V3zu7hjdOkD7",
|
||||
"ses_26755c023ffeYvG2k7GuZIljF5",
|
||||
"ses_26750ed18ffedLTtD3ziF7avO2",
|
||||
"ses_2674cf6a7ffeOXPEFn6rhU551N",
|
||||
"ses_26740710cffexgieUA3qB2B98Z",
|
||||
"ses_26735c68effelwOfYs0gfmIKPZ",
|
||||
"ses_2673618caffe5Rqdqzw1O6feF2",
|
||||
"ses_26736499dffeeYMawv3CU88Hwp",
|
||||
"ses_267368601ffeJ0vrgBbQg0z30R",
|
||||
"ses_26730df9bffeFFGese2Qel8oBI",
|
||||
"ses_2672b4d9bffekW9lZXc1JCVvXw",
|
||||
"ses_2672b257dffeaG4lGP8jaN7Tp5",
|
||||
"ses_263703df9ffegmVplLbaxmQIes",
|
||||
"ses_262de3483ffe5v8SD8eFNqtdo0",
|
||||
"ses_262de1b3bffexz022qa8FnxRyV",
|
||||
"ses_262ddfb93ffeMGkZK2rspryFfX",
|
||||
"ses_262998e6fffeRY6BJVTKB7Jtwb",
|
||||
"ses_26299a749ffe3jnwQzrfJ9g1Wc",
|
||||
"ses_261e4ca30ffexBShmvjEhVSHRy",
|
||||
"ses_25d474e64ffeuPnRfi4X9al43a",
|
||||
"ses_25d39bf44ffeY9WOIQgNCijBt0",
|
||||
"ses_25d319ba5ffe37R9eKUEq95w7o"
|
||||
],
|
||||
"plan_name": "rete-rules-engine",
|
||||
"agent": "atlas"
|
||||
}
|
||||
18
.sisyphus/notepads/modifier-profiles-t3/decisions.md
Normal file
18
.sisyphus/notepads/modifier-profiles-t3/decisions.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Modifier Profiles T3 — Decisions Log
|
||||
|
||||
## [2026-04-19 17:12] Task: T1
|
||||
|
||||
- Read `docs/adr/modifier-profiles.md` fully before writing. Existing tone is
|
||||
concise, decision-heavy, and uses `Decision`/`Rationale`/`Rejected
|
||||
Alternatives` blocks with practical implementation wording.
|
||||
- Existing ADR file has mixed historical structure:
|
||||
- Early ADRs (T1/T2 base) mostly use `Decision`, `Rationale`, `Rejected
|
||||
Alternatives`.
|
||||
- Some entries include extra sections like `Semantics` and retrospectives.
|
||||
- No strict global template is enforced across all sections.
|
||||
- For T3 append, used a consistent five-part structure per request:
|
||||
`Context`, `Decision`, `Rationale`, `Rejected Alternatives`, `Consequences`.
|
||||
- Added one concrete example to each T3 ADR, including required aura example
|
||||
(`radius=2`, `targetAttr=HpBonus`, `delta=+1` king-aura scenario).
|
||||
- `decisions.md` did not previously exist; created it and appended this entry
|
||||
without modifying existing notepad artifacts.
|
||||
26
.sisyphus/notepads/modifier-profiles-t3/learnings.md
Normal file
26
.sisyphus/notepads/modifier-profiles-t3/learnings.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Modifier Profiles T3 — Learnings
|
||||
|
||||
## [2026-04-19 17:14] Task: T3
|
||||
|
||||
- Added `packages/chess/src/modifiers/custom/types.ts` with branded `CustomModifierId` and `asCustomModifierId` helper that mirrors `asEntityId` trust-boundary wording/style from `packages/rete/src/schema.ts`.
|
||||
- Added `CustomModifierDescriptor` with literal discriminators (`type: "data"`, `version: 1`, `uiForm: "primitive-composer"`, `source: "custom"`) and a forward-design JSDoc note for future `"scripted"` descriptors in T4.
|
||||
- Divergence from ideal import shape: `EffectPrimitiveNode` is a local fallback interface in `custom/types.ts` because `packages/chess/src/modifiers/primitives/types.ts` is not yet committed in this branch state. Included TODO to swap to `../primitives/types.js` import immediately when T2 lands.
|
||||
- Added `packages/chess/src/modifiers/custom/index.ts` as a focused barrel with explicit Wave 3 scope boundary comment.
|
||||
- Added `packages/chess/src/modifiers/custom/types.test.ts` with four scenarios: branded helper runtime/type round-trip, descriptor structure assignment, readonly `targetAttrs` typing, readonly `primitives` typing.
|
||||
|
||||
## [2026-04-19 17:13] Task: T2
|
||||
|
||||
- Added `packages/chess/src/modifiers/primitives/types.ts` with T3 primitive core contracts:
|
||||
- `PrimitiveKind` union with exactly 15 ADR-2 primitive ids.
|
||||
- `EffectPrimitive<Params>` descriptor shape (`paramsSchema: ZodType<Params>`, `apply(ctx, params): void`, optional `maxDepth`, optional `childPrimitives`).
|
||||
- `EffectPrimitiveNode` runtime node shape (`kind`, `params`).
|
||||
- `PrimitiveApplyContext` (`engine`, `session`, `pieceId`, `depth`, `descriptor`).
|
||||
- Forward-declared `CustomModifierDescriptor` placeholder interface to avoid circular dependency with future `../custom/types.ts`.
|
||||
- Added `packages/chess/src/modifiers/primitives/registry.ts` + singleton export.
|
||||
- Mirrored `MODIFIER_REGISTRY` class shape exactly: private `Map`, duplicate guard throw, `register/get/list/has`, generic register call-site support.
|
||||
- Added `packages/chess/src/modifiers/primitives/index.ts` barrel with explicit Wave-2 side-effect-registration stub comment.
|
||||
- Added `packages/chess/src/modifiers/primitives/registry.test.ts` with 6 scenarios: round-trip get, duplicate throw, list order stability, `has()` accuracy, unknown kind miss, and generic type preservation at register call site.
|
||||
|
||||
## [2026-04-19 17:28] Wave 2 batch B
|
||||
|
||||
- Added `absorb-damage-with-attribute` primitive + tests.
|
||||
86
.sisyphus/notepads/polish-t2/learnings.md
Normal file
86
.sisyphus/notepads/polish-t2/learnings.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Polish-T2 — Notepad
|
||||
|
||||
Scope: Option C cleanup after modifier-profiles-t2 ships. Touches modifier internals + UI + a small solo regression guard. Three commits max.
|
||||
|
||||
## Baseline (master @ 567480a)
|
||||
|
||||
- `bun run check` green: 1231 unit tests, 0 lint errors.
|
||||
- `bunx playwright test` green: 58/58.
|
||||
- Strict TS: no `as any` is a lint error. `as unknown as X` is allowed by the compiler but we're cleaning ours up because each one was flagged in T2 final audit as lazy typing.
|
||||
|
||||
## Key facts the polish relies on
|
||||
|
||||
### `as unknown as` sites in scope (5 targets)
|
||||
|
||||
1. `packages/chess/src/modifiers/registry.ts:49` — `this.#byId.set(descriptor.id, descriptor as unknown as ModifierDescriptor)`. Registry is a `Map<Id, ModifierDescriptor>` (unknown-widened). The generic `register<V>(descriptor: ModifierDescriptor<V>)` accepts typed descriptors; the store erases V. Fix: widen the parameter up-front via an explicit typed conversion that doesn't need `unknown`: e.g. accept as-is but annotate the Map value type with a stored intersection, or just do `const widened: ModifierDescriptor = descriptor;` — TS should accept this assignment because `ModifierDescriptor<V>` is assignable to `ModifierDescriptor<unknown>` only via its covariant `describe`/`apply`, which are actually contravariant in V → so direct assignment FAILS the check. Cleanest fix: change the generic signature to `register(descriptor: ModifierDescriptor)` with no generic (since V is immediately erased anyway), and have callers rely on `MODIFIER_REGISTRY.register(FOO_DESCRIPTOR)` implicitly widening `ModifierDescriptor<number>` → `ModifierDescriptor<unknown>`. But this ALSO fails because of contravariance. RIGHT fix: introduce an internal stored type `ModifierDescriptorAny` with `value: unknown` params and use a small `toStored(d)` converter that does the widening in one place with `// eslint-disable-next-line` if the compiler balks — OR rethink the descriptor's `apply`/`describe` to take `unknown` and let each descriptor narrow via a Zod parse inside. Pragmatically: take `descriptor as ModifierDescriptor` (single cast, not double) — that probably works because `ModifierDescriptor` (no-arg default) = `ModifierDescriptor<unknown>` and `ModifierDescriptor<number>` isn't structurally assignable to it, so a one-step cast is required. Accept that one cast; lose the `unknown` interstitial.
|
||||
|
||||
2. `packages/chess/src/modifiers/schema.ts:88` — `return ModifierProfileSchema.parse(raw) as unknown as ModifierProfile`. The schema types out to a plain object mirror of ModifierProfile but the nested `readonly` and branded differences trip up assignment. Fix: add `.transform((v): ModifierProfile => v as ModifierProfile)` on the schema, or use `z.custom<ModifierProfile>` at the top, or define `ModifierProfile` from `z.infer<typeof ModifierProfileSchema>` instead of the hand-rolled interface in `types.ts`. Simplest: a single `as ModifierProfile` post-parse, no `unknown` bridge.
|
||||
|
||||
3. `packages/chess/src/ui/ModifierTooltip.tsx:23`, `ui/ModifiedPieceIndicator.tsx:12`, `ui/ModifierPinnedPanel.tsx:29` — all of the form `pieceId as unknown as EntityId`. `EntityId = number & { readonly __brand: "EntityId" }`. The UI passes `number` because the `PieceState` at `Board.tsx:50-54` uses `id: number`. Fix: introduce a `toEntityId(n: number): EntityId` helper (single cast site, documented) OR change `PieceState.id` + the prop type to `EntityId`. The helper is smaller-surface. Place it in `packages/rete/src/schema.ts` alongside the type or in `packages/chess/src/modifiers/source.ts` (already re-imports EntityId from @paratype/rete). Actually `packages/rete` already has internal helpers like `mkId(n)` used only in tests; exporting a public one is the right move. Put it next to the type: `export const asEntityId = (n: number): EntityId => n as EntityId` with a doc comment explaining when to use it ("trust boundary: you've already verified this number came from Session.nextId or an EAV fact; otherwise use `session.allFacts()` to find a real one").
|
||||
|
||||
### Test-only casts NOT in scope (keep as-is)
|
||||
|
||||
- `source.test.ts:29/40`, `validate.test.ts:230`, `net/prediction.test.ts:44/73`, `net/client.test.ts:156`, `presets/*.test.ts` — these are test mocks / fixtures. Not cleaning up casts in tests as part of this polish (they'd need different mocks; scope creep).
|
||||
- `engine.ts:268` — `value as unknown as import("@paratype/rete").FactValue` — different subsystem (Rete fact value widening), not flagged in T2 audit. Leave alone.
|
||||
- `net/client.ts:182/190` — listener type widening for the subscriber bus. Not flagged. Leave alone.
|
||||
|
||||
### `getModifierSource` attr-aliasing (src/modifiers/source.ts:59-61)
|
||||
|
||||
Current hardcoded ladder:
|
||||
```
|
||||
if (attrName === "HpBonus") attrName = "Hp";
|
||||
else if (attrName === "RangeBonus") attrName = "Range";
|
||||
```
|
||||
Semantically: "this modifier augments the preset-declared base attribute". `HpBonus` augments `Hp` (declared by `piece-hp` preset). `RangeBonus` has no corresponding declared base attribute — `Range` isn't in `ChessAttrMap` at all. The `"Range"` branch is dead code today; it only matters IF a future preset declares `Range` in its `pieceAttributes`.
|
||||
|
||||
Cleanest fix: add an optional `baseAttr?: ChessAttrKey` to `ModifierDescriptor`. `hp-bonus.ts` declares `baseAttr: "Hp"`. `range-bonus.ts` leaves it undefined (or adds it when/if a preset declares Range). Then `getModifierSource` checks `descriptor.baseAttr ?? descriptor.attrName` against preset `pieceAttributes` and the aliasing vanishes.
|
||||
|
||||
Watch out: `ModifierDescriptor` is in `types.ts`; adding an optional field is non-breaking. Test file at `registry.test.ts:26-39` constructs a mock descriptor with no `baseAttr` — optional means the test keeps working.
|
||||
|
||||
### Solo-mode modifier badges
|
||||
|
||||
Current state (verified post-T2):
|
||||
- `Lobby.resetToFreshGame` (line 219-238) already forwards `selectedProfile` to `new ChessEngine({ layout, profile })` for solo — T2 fix from `567480a`/`980d567`. So the solo engine HAS the profile and the facts applied.
|
||||
- `Board.tsx:354-356` renders `<ModifiedPieceIndicator pieceId={piece.id} engine={engine}>` unconditionally when `engine !== undefined`.
|
||||
- `GameView` (solo path, `engineState` omitted) → `GameLayout` → passes `state.engine` to `<Board engine={...}>`.
|
||||
|
||||
Likely already works end-to-end! The "ship solo badges" task is mostly a regression-test addition: add a Playwright test that selects a profile in the lobby, clicks Play Solo, and asserts `[data-testid^="modifier-indicator-"]` is visible. If the test fails, then we debug; otherwise, just land the test as the guard.
|
||||
|
||||
Probable file: add test to `solo-smoke.spec.ts` (T2 added it as the canary) or extend `modifier-profiles.spec.ts` with a solo variant. Prefer `solo-smoke.spec.ts` — that's where solo invariants live.
|
||||
|
||||
### Commands
|
||||
|
||||
- `bun run check` — typecheck + lint + vitest
|
||||
- `bunx playwright test --reporter=list` — full e2e
|
||||
- `bunx playwright test e2e/solo-smoke.spec.ts --reporter=list` — solo only
|
||||
- WS server for e2e multiplayer tests: `tmux new-session -d -s ws-server 'bun run packages/server/src/index.ts'` (not needed for solo tests)
|
||||
|
||||
## [2026-04-19 13:53] Task: commit-1 verification gate
|
||||
|
||||
- Gotcha: `bun run build` (tsup/vite) cleaned package `dist/` outputs and removed TS project-reference declarations; root `bun run check` then failed with TS6305 + missing `@paratype/rete` declarations.
|
||||
- Resolution for this session: regenerate declaration outputs with `bunx tsc -b --force packages/rete packages/chess` before running the check gate; afterward `bun run check` returned PASS.
|
||||
|
||||
## [2026-04-19 13:54] Task: commit-2 registry cast cleanup
|
||||
|
||||
- Implemented the adapter path in `modifiers/registry.ts`: stored descriptors now wrap typed `apply/describe` in `unknown`-accepting closures at registration time.
|
||||
- Result: removed the `descriptor as unknown as ModifierDescriptor` storage cast; `schema.ts` parse return also reduced from double-cast to `as ModifierProfile`.
|
||||
|
||||
## [2026-04-19 14:01] Task: commit-3 preset source attribution
|
||||
|
||||
- Added optional `baseAttr?: ChessAttrKey` to `ModifierDescriptor` and set `hp-bonus` to `baseAttr: "Hp"`.
|
||||
- Removed hardcoded `HpBonus`/`RangeBonus` aliasing from `getModifierSource`; source matching now uses `descriptor.baseAttr ?? descriptor.attrName` directly.
|
||||
|
||||
## [2026-04-19 14:03] Task: commit-4 solo modifier indicator e2e
|
||||
|
||||
- Added a solo-smoke e2e that seeds one custom profile in localStorage, selects it in the lobby picker, enters solo mode, and asserts a `modifier-indicator-*` node renders.
|
||||
- Guarded lobby navigation by opening the rules drawer only when `profile-picker` is initially hidden, matching existing T2 lobby interaction patterns.
|
||||
|
||||
## [2026-04-19 14:07] Task: solo indicator test fixture correction
|
||||
|
||||
- Initial fixture used `color: "white"` and no `layoutId`; the option did not appear in the lobby picker during full e2e.
|
||||
- Corrected fixture to mirror known-good T2 shape (`layoutId: "classic"`, `color: "both"`), which allows picker selection and solo badge assertion.
|
||||
|
||||
## [2026-04-19 14:09] Task: solo indicator test storage key mismatch
|
||||
|
||||
- Root cause of missing picker option: seeded localStorage key used `paratype-chess:modifier-profiles:v1`, but runtime library key is `houserules:modifier-profiles:v1`.
|
||||
- Updating the key fixed profile loading in the lobby picker for the solo smoke regression.
|
||||
547
.sisyphus/plans/modifier-profiles-t2.md
Normal file
547
.sisyphus/plans/modifier-profiles-t2.md
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
# Modifier Profiles — T2 Polish
|
||||
|
||||
## TL;DR
|
||||
|
||||
> **Quick Summary**: UX and robustness polish on top of shipped T1 modifier profiles. Adds copy/paste between pieces, a visual "this piece is modified" indicator on the live board, inline conflict-resolution in the editor, undo/redo in the editor, proper turn-boundary queuing server-side (replaces T1's immediate-apply simplification), two-player consent model for mid-game swaps (replaces host-only), and richer source-chain attribution in the pinned inspection panel.
|
||||
>
|
||||
> **Deliverables**:
|
||||
> - Editor: copy/paste modifiers, undo/redo stack, inline conflict resolution UI
|
||||
> - Board: subtle modifier-indicator badge on modified pieces (no hover required)
|
||||
> - Server: turn-boundary queue (ADR-3 done properly), both-player consent for hot-swap
|
||||
> - Inspection: enhanced source-chain breakdown in pinned panel
|
||||
> - Full e2e vertical slice
|
||||
> - Updated ADR + user docs
|
||||
>
|
||||
> **Estimated Effort**: Medium (15 tasks)
|
||||
> **Parallel Execution**: YES — 4 waves
|
||||
> **Critical Path**: T1 (turn-queue server) → T3 (consent) → T13 (e2e)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
### Original Request
|
||||
> "can we do t2 and t3? T2 first, then T3 (Recommended)"
|
||||
|
||||
### T1 Recap (shipped)
|
||||
T1 (completed) delivered the foundation: 6 built-in modifier descriptors, per-type + per-instance scope, editor UI, library persistence, URL sharing, hover tooltip, pinned panel, server-side validation, immediate-apply hot-swap, host-only authority. Full vertical slice working with 18/18 Playwright tests.
|
||||
|
||||
### T2 Rationale
|
||||
Two categories of work:
|
||||
1. **UX polish**: features users will notice immediately — copy/paste, visual indicators, undo/redo, better conflict messages.
|
||||
2. **Robustness fixes**: close the documented simplifications from T1's Implementation Retrospective — turn-boundary queue (not immediate) + two-player consent (not host-only).
|
||||
|
||||
### Architectural Decisions (inherited from T1 ADRs)
|
||||
|
||||
Most T2 work doesn't need new ADR decisions. Three small extensions:
|
||||
|
||||
**T2-ADR-1: Turn-boundary queue**
|
||||
- Client sends `modifier-profile.update` at any time.
|
||||
- Server enqueues to `room.pendingProfile`. Apply runs in the server's `onAfterMove` hook after next move is validated.
|
||||
- If pending profile is invalid when apply fires → NACK to sender, clear pending, no broadcast.
|
||||
- Multiple updates before apply: last-write-wins (replace pending).
|
||||
- Replaces T1 simplification where swap applied immediately on receipt.
|
||||
|
||||
**T2-ADR-2: Two-player consent**
|
||||
- Host sends `modifier-profile.propose` with candidate profile.
|
||||
- Server broadcasts `modifier-profile.proposal-pending` to opponent with profile contents.
|
||||
- Opponent sends `modifier-profile.consent` with approve/reject.
|
||||
- If approve → apply at next turn boundary (T2-ADR-1). If reject → clear proposal, broadcast `modifier-profile.rejected`.
|
||||
- Timeout: 60s → auto-reject.
|
||||
- Replaces T1's unilateral host authority.
|
||||
|
||||
**T2-ADR-3: Undo/redo in editor**
|
||||
- Editor maintains a snapshot stack of working-profile states.
|
||||
- Every meaningful user action (add/delete/edit a modifier) pushes a snapshot.
|
||||
- Cmd/Ctrl+Z undoes; Cmd/Ctrl+Shift+Z redoes.
|
||||
- Stack capped at 50 snapshots.
|
||||
- Cleared on save or cancel.
|
||||
|
||||
---
|
||||
|
||||
## Work Objectives
|
||||
|
||||
### Core Objective
|
||||
Polish the T1 modifier-profile system with features users expect from a mature editor + close the documented T1 simplifications (immediate-apply, host-only) to ship production-grade semantics.
|
||||
|
||||
### Concrete Deliverables
|
||||
|
||||
**Editor UX** (`packages/chess/src/ui/`):
|
||||
- `ModifierProfileEditor.tsx` — add undo/redo stack, toolbar with history controls
|
||||
- `PerTypePanel.tsx` — add "Copy" + "Paste" buttons on each modifier row
|
||||
- `PerInstancePanel.tsx` — add copy/paste between pieces ("copy from b1" → "paste to g1")
|
||||
- `ConflictResolutionPanel.tsx` — NEW: shows validation errors inline with suggested fixes + auto-resolve options
|
||||
|
||||
**Board UX** (`packages/chess/src/ui/`):
|
||||
- `ModifiedPieceIndicator.tsx` — NEW: small badge/glow on modified pieces
|
||||
- `GameView.tsx` + `Board.tsx` — wire indicator into piece rendering
|
||||
|
||||
**Server** (`packages/server/src/`):
|
||||
- `broadcast.ts` — `handleModifierProfileUpdate` now enqueues instead of applying
|
||||
- `game-session.ts` — new `applyPendingProfile()` method called after `applyMove()`
|
||||
- New handlers: `handleModifierProfilePropose`, `handleModifierProfileConsent`
|
||||
- `rooms.ts` — `Room.pendingProfile`, `Room.proposalState`, `Room.proposalTimeoutHandle`
|
||||
- `protocol.ts` — new messages: `modifier-profile.propose`, `modifier-profile.proposal-pending`, `modifier-profile.consent`, `modifier-profile.rejected`
|
||||
|
||||
**Inspection** (`packages/chess/src/ui/`):
|
||||
- `ModifierPinnedPanel.tsx` — enhanced source breakdown:
|
||||
- Per-instance entries labeled "(from layout square X)"
|
||||
- Per-type entries labeled "(applies to all {color} {type}s)"
|
||||
- Preset entries labeled "(from {preset name})"
|
||||
- Base values labeled "(default)"
|
||||
|
||||
**Docs**:
|
||||
- Update `docs/adr/modifier-profiles.md` — add T2-ADR-1, T2-ADR-2, T2-ADR-3 sections
|
||||
- Update `docs/user/modifier-profiles.md` — document new features
|
||||
|
||||
**E2E** (`packages/chess/e2e/`):
|
||||
- Extend `modifier-profiles.spec.ts` — 8 new scenarios
|
||||
|
||||
### Definition of Done
|
||||
|
||||
- [ ] `bun run check` green (typecheck + lint + vitest)
|
||||
- [ ] Copy/paste between pieces: adding a per-instance modifier to b1, then copying to g1, produces identical entries at both squares
|
||||
- [ ] Undo/redo: 5 actions then undo 3 times then redo 2 times produces state equal to 4-action state
|
||||
- [ ] Visual indicator visible on modified pieces without requiring hover
|
||||
- [ ] Turn-boundary queue: profile update during mid-move doesn't apply until after opponent's move resolves
|
||||
- [ ] Two-player consent: black must approve before profile change broadcasts
|
||||
- [ ] Proposal timeout: 60s no-response → auto-reject
|
||||
- [ ] Inline conflict resolution shows specific error + suggested fix
|
||||
- [ ] Source chain in pinned panel distinguishes all 4 sources
|
||||
- [ ] Playwright e2e: 18 existing + 8 new = 26 scenarios all pass
|
||||
- [ ] ADR + user docs updated
|
||||
|
||||
### Must Have
|
||||
- All 8 UX/polish deliverables working end-to-end
|
||||
- Turn-boundary queue replaces immediate-apply (no regression — hot-swap still works)
|
||||
- Two-player consent with 60s timeout
|
||||
- Editor undo/redo with 50-snapshot cap
|
||||
- Shared schema between client/server (no drift)
|
||||
|
||||
### Must NOT Have (Guardrails)
|
||||
|
||||
- ❌ Custom modifier authoring (T3)
|
||||
- ❌ Cross-piece aura effects (T3)
|
||||
- ❌ Multi-profile stacking (post-T3)
|
||||
- ❌ Editor persistence across sessions (reload = fresh editor, library is separate)
|
||||
- ❌ Server-side undo (client-side only — no WS messages for undo state)
|
||||
- ❌ Breaking changes to T1 WS messages (all additions are NEW messages; `modifier-profile.update` becomes an alias for `propose` + auto-approve when single-player)
|
||||
- ❌ Mid-turn swap (turn-boundary gate is enforced)
|
||||
- ❌ Client computing effective modifiers differently from server
|
||||
|
||||
### Must NOT Have (AI Slop Patterns)
|
||||
|
||||
- ❌ `as any`, `@ts-ignore`, or bypassing strict TS
|
||||
- ❌ Generic names (`data`, `result`, `item`, `temp`)
|
||||
- ❌ Empty catch blocks
|
||||
- ❌ `console.log` in production code
|
||||
- ❌ Premature abstraction
|
||||
|
||||
---
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
### Test Decision
|
||||
- **Infrastructure**: vitest + Playwright, same as T1.
|
||||
- **Approach**: TDD for server + engine; Playwright scenarios for UX features.
|
||||
|
||||
### QA Policy
|
||||
Every task MUST include agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/modifier-profiles-t2/task-{N}-{slug}.{ext}`.
|
||||
|
||||
---
|
||||
|
||||
## Execution Strategy
|
||||
|
||||
### Parallel Execution Waves
|
||||
|
||||
```
|
||||
Wave 1 (Server robustness foundation — PARALLEL):
|
||||
├── T1: T2-ADR documentation (append to existing ADR)
|
||||
├── T2: Turn-boundary queue server-side
|
||||
└── T3: Two-player consent protocol
|
||||
|
||||
Wave 2 (Editor features — PARALLEL):
|
||||
├── T4: Undo/redo snapshot stack in editor
|
||||
├── T5: Copy/paste modifiers between pieces
|
||||
└── T6: Inline conflict resolution panel
|
||||
|
||||
Wave 3 (Board UX + Inspection polish — PARALLEL):
|
||||
├── T7: Modified-piece indicator on live board
|
||||
├── T8: Enhanced source-chain in pinned panel
|
||||
└── T9: Consent UI (proposal notification + approve/reject buttons)
|
||||
|
||||
Wave 4 (E2E + Docs — PARALLEL):
|
||||
├── T10: Playwright e2e additions (8 scenarios)
|
||||
├── T11: ADR updates
|
||||
└── T12: User docs updates
|
||||
|
||||
Wave FINAL (4 parallel reviewers):
|
||||
├── F1: Plan compliance audit
|
||||
├── F2: Code quality review
|
||||
├── F3: Manual QA
|
||||
└── F4: Scope fidelity check
|
||||
```
|
||||
|
||||
### Dependency Matrix
|
||||
|
||||
| Task | Depends On | Blocks |
|
||||
|------|------------|--------|
|
||||
| 1 | — | 2-12 |
|
||||
| 2 | 1 | 3, 10, 11 |
|
||||
| 3 | 2 | 9, 10, 11 |
|
||||
| 4 | 1 | 10 |
|
||||
| 5 | 1 | 10 |
|
||||
| 6 | 1 | 10 |
|
||||
| 7 | 1 | 10 |
|
||||
| 8 | 1 | 10 |
|
||||
| 9 | 3 | 10 |
|
||||
| 10 | 2-9 | F1-F4 |
|
||||
| 11 | 1, 2, 3 | F1 |
|
||||
| 12 | 4-9 | F1 |
|
||||
|
||||
---
|
||||
|
||||
## TODOs
|
||||
|
||||
- [x] 1. **T2-ADR documentation**
|
||||
|
||||
**What to do**:
|
||||
- Append 3 new sections to `docs/adr/modifier-profiles.md`:
|
||||
- `## T2-ADR-1: Turn-boundary queue` — full semantics, last-write-wins, NACK on invalid at apply time
|
||||
- `## T2-ADR-2: Two-player consent` — proposal/consent flow, 60s timeout, auto-reject
|
||||
- `## T2-ADR-3: Editor undo/redo` — snapshot stack, 50-item cap, cleared on save/cancel
|
||||
|
||||
**Must NOT do**: Don't write any code in this task.
|
||||
|
||||
**Recommended Agent Profile**: `writing` — no skills
|
||||
|
||||
**Parallelization**: Wave 1 (solo). Blocks: ALL. Blocked By: None.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] File has 3 new T2-ADR sections
|
||||
- [ ] `grep -c "^## T2-ADR-" docs/adr/modifier-profiles.md` → 3
|
||||
|
||||
**Commit**: `docs(adr): T2 polish architecture decisions`
|
||||
|
||||
- [x] 2. **Turn-boundary queue server-side**
|
||||
|
||||
**What to do**:
|
||||
- Replace `handleModifierProfileUpdate`'s immediate-apply with queue semantics:
|
||||
- Add `Room.pendingProfile?: ModifierProfile` field (may already exist from T1 — re-use or add)
|
||||
- On receive: validate shape, store in `room.pendingProfile`, ack with `modifier-profile.queued`
|
||||
- On `applyMove()` success: check pendingProfile, validate against post-move session, apply via `reconcileProfileSwap`, broadcast `modifier-profile.updated`, clear pending
|
||||
- If pending profile invalid at apply time: NACK to original sender with specific error code, clear pending
|
||||
- Last-write-wins: new pending replaces old pending before apply fires
|
||||
|
||||
**Must NOT do**: Don't break T1 e2e tests. Don't apply mid-move.
|
||||
|
||||
**Recommended Agent Profile**: `deep` — concurrency-sensitive
|
||||
|
||||
**Parallelization**: Wave 1 (PARALLEL with T1, T3). Blocks: T3, T10, T11. Blocked By: T1.
|
||||
|
||||
**References**:
|
||||
- `packages/server/src/broadcast.ts` — current `handleModifierProfileUpdate`
|
||||
- `packages/server/src/game-session.ts` — `applyMove`, post-move hook location
|
||||
- `docs/adr/modifier-profiles.md` § T2-ADR-1
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Update sent during move — profile does NOT apply until opponent completes their move
|
||||
- [ ] 2 rapid updates → only the last is applied
|
||||
- [ ] Invalid pending at apply time → NACK to sender, no broadcast
|
||||
- [ ] `bun run test packages/server/src/ws.modifier-profile-update.test.ts` — all pass (updated)
|
||||
|
||||
**Commit**: `feat(server): turn-boundary queue for modifier profile updates`
|
||||
|
||||
- [x] 3. **Two-player consent protocol**
|
||||
|
||||
**What to do**:
|
||||
- New WS messages in `protocol.ts`:
|
||||
- `modifier-profile.propose` (client→server) — host sends candidate profile
|
||||
- `modifier-profile.proposal-pending` (server→opponent) — notify with profile contents
|
||||
- `modifier-profile.consent` (client→server) — opponent sends approve/reject
|
||||
- `modifier-profile.rejected` (server→host) — proposal rejected or timed out
|
||||
- `broadcast.ts`:
|
||||
- `handleModifierProfilePropose` — store proposal + start 60s timeout
|
||||
- `handleModifierProfileConsent` — on approve: promote to pendingProfile (feeds into T2 queue); on reject: clear + broadcast rejected
|
||||
- On timeout: auto-reject
|
||||
- `rooms.ts`:
|
||||
- `Room.proposalState?: { profile, proposedBy, timeoutHandle, proposedAt }`
|
||||
- Solo mode / host-only mode: `modifier-profile.update` alias auto-approves
|
||||
|
||||
**Must NOT do**: Don't change `modifier-profile.update` semantics in solo mode.
|
||||
|
||||
**Recommended Agent Profile**: `deep`
|
||||
|
||||
**Parallelization**: Wave 1. Blocks: T9, T10, T11. Blocked By: T2.
|
||||
|
||||
**References**:
|
||||
- `docs/adr/modifier-profiles.md` § T2-ADR-2
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Propose → opponent sees pending → approve → broadcast to both
|
||||
- [ ] Propose → opponent rejects → only host sees rejected, no broadcast to board
|
||||
- [ ] Propose → 60s timeout → auto-reject
|
||||
- [ ] Solo mode: propose = immediate apply (no consent step)
|
||||
|
||||
**Commit**: `feat(server): two-player consent for modifier profile swaps`
|
||||
|
||||
- [x] 4. **Editor undo/redo snapshot stack**
|
||||
|
||||
**What to do**:
|
||||
- In `ModifierProfileEditor.tsx`:
|
||||
- `const [history, setHistory] = useState<ModifierProfile[]>([initialProfile])`
|
||||
- `const [historyIndex, setHistoryIndex] = useState(0)`
|
||||
- `const currentProfile = history[historyIndex]`
|
||||
- Every mutation path goes through `pushSnapshot(newProfile)` which truncates forward history and caps at 50
|
||||
- `undo()` / `redo()` adjust historyIndex
|
||||
- Keyboard handlers for Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z
|
||||
- Header toolbar with Undo/Redo buttons (disabled when at ends of stack)
|
||||
- `data-testid="undo-button"`, `data-testid="redo-button"`
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 2. Blocks: T10. Blocked By: T1.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Add 3 modifiers → undo 2 → profile has 1 modifier
|
||||
- [ ] Undo then redo → same state
|
||||
- [ ] Cap at 50 snapshots (add 51st → oldest dropped)
|
||||
- [ ] Save or cancel clears history
|
||||
|
||||
**Commit**: `feat(ui): modifier editor undo/redo`
|
||||
|
||||
- [x] 5. **Copy/paste modifiers between pieces**
|
||||
|
||||
**What to do**:
|
||||
- In `PerInstancePanel.tsx`:
|
||||
- When a square is selected: "Copy modifiers from this square" button
|
||||
- When a different square is selected after copy: "Paste modifiers here" button (disabled when clipboard empty)
|
||||
- Clipboard is component-local state (not OS clipboard)
|
||||
- Paste appends all clipboard entries to the target square, with the kind preserved
|
||||
- In `PerTypePanel.tsx`:
|
||||
- "Copy to clipboard" on each row
|
||||
- "Paste" button appends from clipboard
|
||||
- `data-testid="copy-modifier-b1"` etc.
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 2. Blocks: T10. Blocked By: T1.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Add HP+2 to b1 → copy → paste on g1 → both have HP+2
|
||||
- [ ] Paste when clipboard empty: button disabled
|
||||
- [ ] Copy doesn't delete source
|
||||
|
||||
**Commit**: `feat(ui): copy/paste modifiers between pieces`
|
||||
|
||||
- [x] 6. **Inline conflict resolution panel**
|
||||
|
||||
**What to do**:
|
||||
- New component `ConflictResolutionPanel.tsx`:
|
||||
- Shows validation errors from `validateProfile()` inline
|
||||
- Each error has a "Fix" button with a specific auto-resolve action:
|
||||
- `E_PROFILE_NO_KING` → "Add king to e1" button (or suggest switching layout)
|
||||
- `E_PROFILE_INVULN_KING` → "Remove invuln from king" button
|
||||
- `E_PROFILE_ORPHAN_INSTANCE` (warning) → "Remove orphan entry" button
|
||||
- `E_PROFILE_ATTR_LIMIT` → "Show affected pieces" + manual resolution
|
||||
- Panel visible whenever profile has errors/warnings; hidden when clean
|
||||
- Wire into `ModifierProfileEditor.tsx` — show panel at the top when dirty
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 2. Blocks: T10. Blocked By: T1.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Create profile with invuln on king → panel shows error + Fix button
|
||||
- [ ] Click Fix → error resolved
|
||||
- [ ] Panel hidden when profile validates clean
|
||||
|
||||
**Commit**: `feat(ui): inline conflict resolution panel`
|
||||
|
||||
- [x] 7. **Modified-piece indicator on live board**
|
||||
|
||||
**What to do**:
|
||||
- New component `ModifiedPieceIndicator.tsx`:
|
||||
- Small colored dot (or glow/border) shown on pieces that have any active modifier
|
||||
- Reads `MODIFIER_REGISTRY.list()` and checks `session.get(pieceId, attr)` for each
|
||||
- If any defined → indicator visible
|
||||
- CSS: absolute-positioned top-right corner of piece square, 8px dot
|
||||
- Wire into `Board.tsx` — render indicator alongside piece
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 3. Blocks: T10. Blocked By: T1.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Piece with modifier: indicator visible
|
||||
- [ ] Piece without modifier: no indicator
|
||||
- [ ] Indicator updates reactively on profile swap
|
||||
|
||||
**Commit**: `feat(ui): modified-piece indicator on board`
|
||||
|
||||
- [x] 8. **Enhanced source-chain in pinned panel**
|
||||
|
||||
**What to do**:
|
||||
- Update `ModifierPinnedPanel.tsx`:
|
||||
- For each modifier row, add source label:
|
||||
- If piece's attr value matches a profile.perInstance entry at its square → "(per-instance: {square})"
|
||||
- Else if matches a perType entry for this pieceType+color → "(per-type: all {color} {type}s)"
|
||||
- Else if some active preset declared this attr → "(preset: {preset.name})"
|
||||
- Else → "(default)"
|
||||
- Reads the engine's active profile + presets via a new `getModifierSource(engine, pieceId, kind)` helper in `packages/chess/src/modifiers/source.ts`
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 3. Blocks: T10. Blocked By: T1.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Per-instance modifier → source labels show "per-instance: b1"
|
||||
- [ ] Per-type modifier → source labels show "per-type: all white knights"
|
||||
- [ ] piece-hp preset → source labels show "preset: Hit Points"
|
||||
|
||||
**Commit**: `feat(ui): enhanced modifier source chain in pinned panel`
|
||||
|
||||
- [x] 9. **Consent UI (proposal notification + buttons)**
|
||||
|
||||
**What to do**:
|
||||
- New component `ModifierProposalDialog.tsx`:
|
||||
- Shows when opponent has pending proposal (`modifier-profile.proposal-pending` received)
|
||||
- Displays summary of proposed profile changes
|
||||
- Approve / Reject buttons
|
||||
- 60s countdown timer
|
||||
- Wire into `GameView.tsx` via WS message subscription
|
||||
- Host sees a "Proposal sent — waiting for opponent" state after proposing
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 3. Blocks: T10. Blocked By: T3.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] Host proposes → opponent sees dialog within 500ms
|
||||
- [ ] Approve → profile applies at next turn boundary
|
||||
- [ ] Reject → dialog closes on both sides, host sees "Proposal rejected"
|
||||
- [ ] Timeout → auto-reject
|
||||
|
||||
**Commit**: `feat(ui): consent dialog for modifier profile proposals`
|
||||
|
||||
- [x] 10. **Playwright e2e additions (8 feature scenarios + solo regression guards)**
|
||||
|
||||
**What to do**:
|
||||
- Add 8 new T2-feature tests to `packages/chess/e2e/modifier-profiles.spec.ts`:
|
||||
1. Editor undo/redo: add 3 modifiers, undo 2, redo 1
|
||||
2. Copy modifier between squares (per-instance)
|
||||
3. Paste button disabled when clipboard empty
|
||||
4. Conflict panel shows error + Fix works
|
||||
5. Modified-piece indicator visible without hover
|
||||
6. Source chain distinguishes per-instance vs per-type
|
||||
7. (multiplayer) Proposal → approve → both clients see update
|
||||
8. (multiplayer) Proposal → reject → no update
|
||||
- **Keep and expand `packages/chess/e2e/solo-smoke.spec.ts`** (already exists from T1 post-audit fix). Adds ongoing regression coverage for solo play — these tests caught the T1 Rules-Drawer-Esc bug. Ensure the following scenarios remain and are extended:
|
||||
- play-solo button navigates to /game with fresh board
|
||||
- drag pawn e2→e4 resolves
|
||||
- 4-ply sequence (e4 e5 Nf3 Nc6) completes
|
||||
- **Rules drawer opens, closes via Esc, board remains interactive** (the T1 regression guard)
|
||||
- No console errors on solo game start
|
||||
- Add 2 more solo-regression scenarios in T2 since T2 touches drawer/editor further:
|
||||
- Rules drawer: backdrop click closes drawer (workaround path still works)
|
||||
- Modifier editor: Esc closes editor without dismissing drawer underneath (nested modal ordering)
|
||||
|
||||
**Why both?** `solo-smoke.spec.ts` is the canary — it exercises the baseline game flow without any modifier-profile setup. `modifier-profiles.spec.ts` covers feature paths. Keeping both prevents T2/T3 work from silently regressing solo play.
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-high`, skills: [`playwright`]
|
||||
|
||||
**Parallelization**: Wave 4. Blocks: F1-F4. Blocked By: T2-T9.
|
||||
|
||||
**Acceptance Criteria**:
|
||||
- [ ] 26/26 modifier-profile tests pass (18 from T1 + 8 new)
|
||||
- [ ] 7+ solo-smoke tests pass (5 original + 2 T2 additions)
|
||||
- [ ] Multiplayer suite unchanged, still passes
|
||||
- [ ] Total Playwright runtime < 120s
|
||||
|
||||
**Commit**: `test(e2e): T2 polish vertical slice + solo regression guards`
|
||||
|
||||
- [x] 11. **ADR updates — Implementation Retrospective T2 addendum**
|
||||
|
||||
**What to do**:
|
||||
- Append to `docs/adr/modifier-profiles.md`:
|
||||
- `## T2 Implementation Retrospective` section noting:
|
||||
- T1's immediate-apply simplification resolved (T2-ADR-1)
|
||||
- T1's host-only simplification resolved (T2-ADR-2)
|
||||
- Editor UX parity with modern standards (undo/redo, copy/paste)
|
||||
- Any deviations found during implementation
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-low`
|
||||
|
||||
**Parallelization**: Wave 4. Blocks: F1. Blocked By: T1, T2, T3.
|
||||
|
||||
**Commit**: `docs(adr): T2 implementation retrospective`
|
||||
|
||||
- [x] 12. **User docs updates**
|
||||
|
||||
**What to do**:
|
||||
- Update `docs/user/modifier-profiles.md`:
|
||||
- Update "Hot-Swap" section: describe proposal/consent flow (remove T1's "host-only" note)
|
||||
- Add "Editor Features" section: undo/redo (keyboard shortcuts), copy/paste, conflict resolution
|
||||
- Add "Board Indicators" section: modified-piece glow
|
||||
- Update "In-Play Inspection" section: mention enhanced source chain
|
||||
- Update "Known Limitations" section: remove T1 items, add T3 preview
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-low`
|
||||
|
||||
**Parallelization**: Wave 4. Blocks: F1. Blocked By: T4-T9.
|
||||
|
||||
**Commit**: `docs(user): T2 modifier profile features`
|
||||
|
||||
---
|
||||
|
||||
## Final Verification Wave
|
||||
|
||||
- [x] F1. **Plan Compliance Audit** — `oracle`
|
||||
Verify all 12 tasks' deliverables exist. Verify T1 simplifications resolved. Check evidence files.
|
||||
Output: `Must Have [N/N] | Must NOT Have [N/N] | ADR Decisions [3/3] | VERDICT: APPROVE/REJECT`
|
||||
|
||||
- [x] F2. **Code Quality Review** — `unspecified-high`
|
||||
`bun run check`. Scan for slop patterns. Verify server-shared schemas.
|
||||
Output: `Build [PASS/FAIL] | Lint [PASS/FAIL] | Tests [N pass] | VERDICT`
|
||||
|
||||
- [x] F3. **Manual QA** — `unspecified-high` (+ `playwright`)
|
||||
Execute all 26 Playwright scenarios. Multiplayer proposal/consent roundtrip. Undo/redo combos.
|
||||
Output: `Scenarios [N/N] | Integration [pass] | VERDICT`
|
||||
|
||||
- [x] F4. **Scope Fidelity** — `deep`
|
||||
No T3 features smuggled (no custom authoring, no auras, no multi-profile stacking). No breaking WS changes.
|
||||
Output: `Tasks [N/N compliant] | Contamination [CLEAN] | VERDICT`
|
||||
|
||||
---
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
1. `docs(adr): T2 polish architecture decisions`
|
||||
2. `feat(server): turn-boundary queue for modifier profile updates`
|
||||
3. `feat(server): two-player consent for modifier profile swaps`
|
||||
4. `feat(ui): modifier editor undo/redo`
|
||||
5. `feat(ui): copy/paste modifiers between pieces`
|
||||
6. `feat(ui): inline conflict resolution panel`
|
||||
7. `feat(ui): modified-piece indicator on board`
|
||||
8. `feat(ui): enhanced modifier source chain in pinned panel`
|
||||
9. `feat(ui): consent dialog for modifier profile proposals`
|
||||
10. `test(e2e): T2 polish vertical slice`
|
||||
11. `docs(adr): T2 implementation retrospective`
|
||||
12. `docs(user): T2 modifier profile features`
|
||||
|
||||
## Success Criteria
|
||||
|
||||
```bash
|
||||
bun run check # all green
|
||||
bun run test packages/server/ # all pass (including updated ws tests)
|
||||
bunx playwright test e2e/modifier-profiles.spec.ts # 26/26 pass
|
||||
```
|
||||
|
||||
### Final Checklist
|
||||
- [ ] All 9 feature tasks shipped end-to-end
|
||||
- [ ] T1 simplifications (immediate-apply, host-only) fully resolved
|
||||
- [ ] 26 Playwright e2e scenarios green
|
||||
- [ ] ADR + user docs updated
|
||||
- [ ] `bun run check` green
|
||||
- [ ] F1-F4 all APPROVE
|
||||
- [ ] User explicit "okay"
|
||||
601
.sisyphus/plans/modifier-profiles-t3.md
Normal file
601
.sisyphus/plans/modifier-profiles-t3.md
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
# Modifier Profiles — T3 User-Authored Custom Modifiers (DSL)
|
||||
|
||||
## TL;DR
|
||||
|
||||
> **Quick Summary**: Users can author their own modifier categories at runtime via a constrained config DSL. A custom modifier is a named, versioned, data-only descriptor composed from atomic effect primitives ("add N to attribute X", "add direction Y", "reduce damage by N%"). No code execution — purely structured data. Custom modifiers register into a per-game registry (alongside built-ins), persist in a separate library, and travel through the network via `ModifierProfileSchema` with full validation. Forward-designed for a T4 scripted-modifier extension.
|
||||
>
|
||||
> **Deliverables**:
|
||||
> - Effect primitives catalog + registry for user-composable atoms
|
||||
> - `CustomModifierDescriptor` type — data-only, validated, sandboxed
|
||||
> - `CustomModifierEditor.tsx` — visual composer (no code typing)
|
||||
> - Per-room custom modifier registration via WS protocol
|
||||
> - Server-side validation of custom descriptors (anti-DoS, legality)
|
||||
> - Modifier Profile editor: custom modifiers appear in kind dropdown alongside built-ins
|
||||
> - Per-user library for custom descriptors + sharing
|
||||
> - Cross-piece aura effects (built on the same primitive model)
|
||||
> - Multi-profile stacking (ordered composition)
|
||||
> - Full e2e vertical slice
|
||||
> - T4 design note: how scripted modifiers would plug in
|
||||
>
|
||||
> **Estimated Effort**: Large (25 tasks, ~3-4 execution waves like T1)
|
||||
> **Parallel Execution**: YES — 5 waves
|
||||
> **Critical Path**: T1 (primitives ADR) → T3 (primitive registry) → T5 (custom descriptor type) → T13 (engine apply) → T19 (e2e)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
### Original Request
|
||||
> "Start DSL, design for script upgrade" (T3 approach)
|
||||
> "T2 first, then T3 (Recommended)" (execution order)
|
||||
|
||||
### Pre-requisites
|
||||
- T1 (shipped): built-in modifier descriptors + registry + editor + server integration
|
||||
- T2 (must ship first): turn-boundary queue, two-player consent, undo/redo, copy/paste, conflict resolution
|
||||
|
||||
### T3 Rationale
|
||||
Extend the T1/T2 foundation so users can define new modifier CATEGORIES (not just configure existing ones).
|
||||
|
||||
Example user-authored modifier: "Shield — piece has 3 shield charges. Each incoming damage spends one charge instead of reducing HP. No charges → damage falls through." Users compose this from primitives:
|
||||
- `consume-attribute-on-damage` primitive with attr="ShieldCharges", consume=1, absorb=true
|
||||
- `seed-attribute` primitive with attr="ShieldCharges", value=3
|
||||
|
||||
### Architecture (new ADRs)
|
||||
|
||||
**T3-ADR-1: DSL is structured data, not code**
|
||||
Custom modifiers are composed from a fixed catalog of ~15 effect primitives. Each primitive is a TypeScript function (shipped in the engine) that takes parameters. Users compose primitives in the UI; the resulting JSON object IS the modifier. No eval, no sandbox, no script parsing — structured validation only.
|
||||
|
||||
**Rejected alternatives**:
|
||||
- Sandboxed script runtime (QuickJS, etc.) — defers to T4. Security surface too large for T3.
|
||||
- AST-based mini-language — same complexity as sandboxed script.
|
||||
- String template interpolation — too limited.
|
||||
|
||||
**T3-ADR-2: Effect Primitive catalog (T3 v1)**
|
||||
|
||||
Primitives are atomic, composable, pure. Full catalog:
|
||||
|
||||
| Primitive | Parameters | Effect |
|
||||
|-----------|------------|--------|
|
||||
| `seed-attribute` | attr, value | Seeds a fact on the piece at profile apply time |
|
||||
| `add-to-attribute` | attr, delta | Adds delta to existing attr value (additive stacking) |
|
||||
| `multiply-attribute` | attr, factor | Multiplies existing attr value |
|
||||
| `add-direction` | directions[] | Adds to DirectionAdditions |
|
||||
| `set-capture-flag` | flag | ORs into CaptureFlags |
|
||||
| `absorb-damage-with-attribute` | attr, rate | Each damage point consumes `rate` of attr instead of HP |
|
||||
| `reflect-damage` | percentage | Damage sends back to attacker at percentage |
|
||||
| `block-move-type` | moveType (capture / step / slide) | Filter out moves matching criteria |
|
||||
| `add-aura` | radius, targetAttr, delta | For each piece within radius, add delta to attr |
|
||||
| `on-turn-start` | primitive[] | Runs contained primitives at turn start |
|
||||
| `on-capture` | primitive[] | Runs contained primitives when this piece captures |
|
||||
| `on-damaged` | primitive[] | Runs contained primitives when this piece takes damage |
|
||||
| `conditional` | condition, then-primitive[], else-primitive[] | Condition-branch (e.g., "if HP < 2, do X") |
|
||||
| `modify-movement-range` | delta | RangeBonus integration |
|
||||
| `override-promotion` | target | PromotionOverride integration |
|
||||
|
||||
Users compose these in a visual editor. Each primitive has a known shape, validation, UI form, and engine integration. Adding a new primitive = one file (same pattern as descriptors in T1).
|
||||
|
||||
**T3-ADR-3: Custom modifier authoring scope**
|
||||
- **Per-room**: custom modifiers are registered on a room-by-room basis. Registration = send full descriptor via WS at room.create or via `custom-modifier.register` message.
|
||||
- **Per-user library**: users save their custom descriptors locally (like profiles). Separate library key `houserules:custom-modifiers:v1`.
|
||||
- **Sharing**: custom descriptors travel with profiles that reference them. A profile using a custom modifier "shield-v1" embeds the full `shield-v1` descriptor.
|
||||
- **Versioning**: custom descriptors have a `version: 1` field. v2+ is a new modifier id.
|
||||
- **Validation**: server-side validator checks primitive catalog membership, parameter bounds, recursion depth ≤ 3 (for on-turn-start / conditional nesting), total primitive count ≤ 50 per descriptor.
|
||||
|
||||
**T3-ADR-4: Registry extension — per-engine not per-process**
|
||||
Built-in descriptors use module-level `MODIFIER_REGISTRY`. Custom descriptors use a per-engine `customModifiers: Map<string, CustomModifierDescriptor>`. `MODIFIER_REGISTRY.get(id)` transparently consults per-engine custom registry when global misses. This prevents user modifiers from leaking across rooms.
|
||||
|
||||
**T3-ADR-5: T4 forward-design (scripted modifiers)**
|
||||
Future scripted modifiers would plug in as a new `ModifierDescriptor` type:
|
||||
```typescript
|
||||
interface ScriptedModifierDescriptor {
|
||||
type: "scripted";
|
||||
id: string;
|
||||
script: string; // QuickJS or similar
|
||||
permissions: Permission[];
|
||||
// ... other fields
|
||||
}
|
||||
```
|
||||
The engine's integration point (same `apply` signature) doesn't know the difference. T3's validator gets a `validateCustomDescriptor` branch-point that's currently `type: "data"` only; T4 adds `type: "scripted"` with separate validation + sandboxed execution.
|
||||
|
||||
**T3-ADR-6: Multi-profile stacking (bundled with T3 since primitives enable it)**
|
||||
Profiles can be stacked. Engine maintains `activeProfiles: readonly ModifierProfile[]` instead of a single profile. Stacking rules per modifier kind (from ADR-4 of T1) apply across profiles. Conflict resolution: explicit priority order set by user. Default: profiles applied in registration order.
|
||||
|
||||
**T3-ADR-7: Aura effects (primitive `add-aura`)**
|
||||
Auras are effect primitives that apply to OTHER pieces within a radius. At profile-apply time, auras create derived facts on affected pieces. Derived facts are re-computed on every move (affected pieces may change). Implementation: `effectivePieceAttrs` engine integration for aura-derived attrs.
|
||||
|
||||
---
|
||||
|
||||
## Work Objectives
|
||||
|
||||
### Core Objective
|
||||
Ship user-authored custom modifier descriptors composed from an effect primitive catalog. Design the whole system so T4's scripted modifiers can plug in without redesigning.
|
||||
|
||||
### Concrete Deliverables
|
||||
|
||||
**Primitive catalog** (`packages/chess/src/modifiers/primitives/`):
|
||||
- `types.ts` — `EffectPrimitive`, `PrimitiveKind`, primitive-specific parameter types
|
||||
- `registry.ts` — `PRIMITIVE_REGISTRY`
|
||||
- Individual primitive files (15 files):
|
||||
- `seed-attribute.ts`, `add-to-attribute.ts`, `multiply-attribute.ts`
|
||||
- `add-direction.ts`, `set-capture-flag.ts`
|
||||
- `absorb-damage-with-attribute.ts`, `reflect-damage.ts`
|
||||
- `block-move-type.ts`, `modify-movement-range.ts`, `override-promotion.ts`
|
||||
- `add-aura.ts`
|
||||
- `on-turn-start.ts`, `on-capture.ts`, `on-damaged.ts`, `conditional.ts`
|
||||
- `index.ts` — barrel + side-effect registration
|
||||
- `validate.ts` — descriptor-level validator (recursion depth, primitive count, parameter validation per primitive)
|
||||
|
||||
**Custom modifier descriptor** (`packages/chess/src/modifiers/custom/`):
|
||||
- `types.ts` — `CustomModifierDescriptor`, `CustomModifierId`
|
||||
- `apply.ts` — executes primitive list at profile apply time (replaces built-in `apply` for custom modifiers)
|
||||
- `library.ts` — persistence at `houserules:custom-modifiers:v1`
|
||||
- `schema.ts` — Zod schema for full descriptor serialization
|
||||
|
||||
**Registry extension** (`packages/chess/src/modifiers/registry.ts`):
|
||||
- Extend `ModifierRegistryClass` with `customDescriptors: Map<string, CustomModifierDescriptor>`
|
||||
- `.registerCustom(engine, descriptor)` and `.getCustom(engine, id)` methods
|
||||
- `get(id)` falls back to customDescriptors if not in built-ins
|
||||
|
||||
**Aura engine support** (`packages/chess/src/modifiers/auras.ts`):
|
||||
- `computeAuraFacts(session)` — runs all active auras, computes derived attrs, updates session
|
||||
- Hooks: invoked after every move via pseudo-preset
|
||||
|
||||
**Multi-profile stacking** (`packages/chess/src/modifiers/apply.ts` + `reconcile.ts`):
|
||||
- `applyProfilesToSession(session, profiles: readonly ModifierProfile[], layout)` — stack in order
|
||||
- `reconcileProfilesSwap(session, oldProfiles, newProfiles, layout)`
|
||||
|
||||
**Server protocol** (`packages/server/src/protocol.ts`):
|
||||
- New message: `custom-modifier.register` — attach custom descriptor to room
|
||||
- Extend `ModifierProfile` to include `customModifiers: readonly CustomModifierDescriptor[]` (embedded, not by-ref)
|
||||
- Validator: `validateCustomDescriptor(descriptor)` runs before allowing registration
|
||||
|
||||
**UI** (`packages/chess/src/ui/`):
|
||||
- `CustomModifierEditor.tsx` — visual primitive composer
|
||||
- `PrimitivePalettePanel.tsx` — catalog of 15 primitives, drag/drop or click-to-add
|
||||
- `PrimitiveInspectorPanel.tsx` — parameter editor for selected primitive
|
||||
- `CustomModifierLibrary.tsx` — library drawer for saved custom modifiers
|
||||
- Extend `PerTypePanel.tsx` + `PerInstancePanel.tsx` — "kind" dropdown shows custom modifiers alongside built-ins
|
||||
|
||||
**Profile stacking UI** (`packages/chess/src/ui/Lobby.tsx`):
|
||||
- Allow selecting multiple profiles (stacked, ordered)
|
||||
- Reorder via drag/drop
|
||||
- Show stacked effect preview
|
||||
|
||||
**Docs**:
|
||||
- Update `docs/adr/modifier-profiles.md` — add T3-ADR-1 through T3-ADR-7 sections
|
||||
- New `docs/user/custom-modifiers.md` — user guide for authoring custom modifiers
|
||||
- New `docs/adr/T4-scripted-modifiers-design.md` — forward-looking design doc
|
||||
|
||||
**E2E** (`packages/chess/e2e/`):
|
||||
- New `custom-modifiers.spec.ts` — ~15 scenarios
|
||||
|
||||
### Definition of Done
|
||||
|
||||
- [ ] `bun run check` green
|
||||
- [ ] All 15 primitives registered and individually tested
|
||||
- [ ] A user can create a "Shield" custom modifier in the UI using `seed-attribute` + `absorb-damage-with-attribute` primitives
|
||||
- [ ] Custom modifier saves to library, reloads after page refresh, applies to a game
|
||||
- [ ] Multi-profile stacking: 2 profiles stacked → HP bonuses add, direction additions union
|
||||
- [ ] Aura: a piece with `add-aura` radius=2 targetAttr=HpBonus delta=+1 gives +1 HP to all pieces within 2 squares
|
||||
- [ ] Server rejects malformed custom descriptors (recursion too deep, unknown primitive, parameter out of range)
|
||||
- [ ] Playwright e2e: 15 new scenarios pass in `custom-modifiers.spec.ts`
|
||||
- [ ] Existing 26 T1+T2 e2e tests still pass (no regression)
|
||||
- [ ] ADR + user docs + T4 design note published
|
||||
|
||||
### Must Have
|
||||
- Full DSL with 15 T3 primitives covering the 6 built-in modifier behaviors + new capabilities (auras, conditionals, event hooks)
|
||||
- Custom modifiers compose in the editor alongside built-ins
|
||||
- Per-engine custom registry (no cross-room leakage)
|
||||
- Multi-profile stacking with explicit priority
|
||||
- Aura effects via `add-aura` primitive
|
||||
- Server-side validation of custom descriptors (anti-DoS)
|
||||
- T4 forward-design document
|
||||
|
||||
### Must NOT Have (Guardrails)
|
||||
|
||||
- ❌ Scripted modifiers (T4 — only the forward-design note lives here)
|
||||
- ❌ Sandboxed script runtime in T3
|
||||
- ❌ User-defined primitives (primitives are engine-shipped)
|
||||
- ❌ Cross-room custom modifier sharing (explicit re-registration per room)
|
||||
- ❌ Breaking changes to T1/T2 built-in modifiers
|
||||
- ❌ Breaking WS protocol changes (custom-modifier messages are additive)
|
||||
- ❌ Recursion depth > 3 in primitive nesting (DoS guard)
|
||||
- ❌ More than 50 primitives per custom descriptor
|
||||
- ❌ More than 10 custom modifiers per room (DoS guard)
|
||||
|
||||
### Must NOT Have (AI Slop Patterns)
|
||||
|
||||
- ❌ `as any`, `@ts-ignore`, or bypassing strict TS
|
||||
- ❌ Generic names (`data`, `result`, `item`, `temp`)
|
||||
- ❌ Empty catch blocks
|
||||
- ❌ `console.log` in production code
|
||||
- ❌ Switch statements on primitive kinds (use registry dispatch)
|
||||
|
||||
---
|
||||
|
||||
## Verification Strategy
|
||||
|
||||
### Test Decision
|
||||
- **Infrastructure**: vitest + Playwright, same as T1/T2
|
||||
- **Approach**: TDD per primitive, Playwright for UX
|
||||
|
||||
### QA Policy
|
||||
Every task MUST include agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/modifier-profiles-t3/task-{N}-{slug}.{ext}`.
|
||||
|
||||
---
|
||||
|
||||
## Execution Strategy
|
||||
|
||||
### Parallel Execution Waves
|
||||
|
||||
```
|
||||
Wave 1 (Foundation — PARALLEL):
|
||||
├── T1: T3-ADR documentation
|
||||
├── T2: Primitive types + registry class
|
||||
└── T3: Custom modifier descriptor types
|
||||
|
||||
Wave 2 (Primitive implementations — HIGHLY PARALLEL, 15 in parallel if caps allow):
|
||||
├── T4-T8: 5 state primitives (seed-attribute, add-to-attribute, multiply-attribute, add-direction, set-capture-flag)
|
||||
├── T9-T11: 3 damage primitives (absorb-damage-with-attribute, reflect-damage, modify-movement-range)
|
||||
├── T12-T14: 3 control primitives (block-move-type, override-promotion, add-aura)
|
||||
└── T15-T18: 4 event primitives (on-turn-start, on-capture, on-damaged, conditional)
|
||||
|
||||
Wave 3 (Integration — PARALLEL):
|
||||
├── T19: Custom descriptor validator (depth cap, parameter check)
|
||||
├── T20: Custom descriptor Zod schema
|
||||
├── T21: Custom descriptor library persistence
|
||||
├── T22: Engine integration — applyCustomDescriptor
|
||||
└── T23: Multi-profile stacking in apply/reconcile
|
||||
|
||||
Wave 4 (Server + UI — PARALLEL):
|
||||
├── T24: Server custom-modifier.register handler + validation
|
||||
├── T25: CustomModifierEditor UI (primitive composer)
|
||||
├── T26: Extend PerTypePanel/PerInstancePanel for custom kinds
|
||||
├── T27: Multi-profile picker in Lobby
|
||||
└── T28: Aura engine integration (computeAuraFacts hook)
|
||||
|
||||
Wave 5 (E2E + Docs — PARALLEL):
|
||||
├── T29: Playwright e2e suite (15 scenarios)
|
||||
├── T30: ADR updates
|
||||
├── T31: User docs (docs/user/custom-modifiers.md)
|
||||
└── T32: T4 forward-design document
|
||||
|
||||
Wave FINAL (4 parallel reviewers):
|
||||
├── F1: Plan compliance audit
|
||||
├── F2: Code quality review
|
||||
├── F3: Manual QA
|
||||
└── F4: Scope fidelity check
|
||||
```
|
||||
|
||||
### Dependency Matrix (Abbreviated)
|
||||
|
||||
| Group | Depends On | Blocks |
|
||||
|-------|------------|--------|
|
||||
| T1 (ADR) | — | ALL |
|
||||
| T2 (primitives registry) | T1 | T4-T18 |
|
||||
| T3 (custom desc types) | T1 | T19-T28 |
|
||||
| T4-T18 (primitives) | T2 | T19, T22, T25 |
|
||||
| T19 (validator) | T2, T4-T18 | T24 |
|
||||
| T20 (schema) | T3 | T24 |
|
||||
| T21 (library) | T3, T20 | T27 |
|
||||
| T22 (engine apply) | T4-T18 | T23, T28 |
|
||||
| T23 (stacking) | T22 | T27, T29 |
|
||||
| T24 (server) | T19, T20 | T29 |
|
||||
| T25 (editor) | T4-T18 | T26, T29 |
|
||||
| T26 (panel ext) | T25 | T29 |
|
||||
| T27 (lobby stack) | T21, T23 | T29 |
|
||||
| T28 (aura hook) | T22 | T29 |
|
||||
| T29 (e2e) | T23-T28 | F1-F4 |
|
||||
| T30-T32 (docs) | T1, T29 | F1 |
|
||||
|
||||
---
|
||||
|
||||
## TODOs
|
||||
|
||||
*(Abbreviated — each task follows the same 7-section format as T1/T2. Full details TBD when executing.)*
|
||||
|
||||
- [x] 1. **T3-ADR documentation**
|
||||
|
||||
**What to do**: Append T3-ADR-1 through T3-ADR-7 to `docs/adr/modifier-profiles.md`.
|
||||
|
||||
**Recommended Agent Profile**: `writing`
|
||||
|
||||
**Parallelization**: Wave 1 (solo). Blocks: ALL. Blocked By: None.
|
||||
|
||||
**Commit**: `docs(adr): T3 custom modifier DSL architecture decisions`
|
||||
|
||||
- [x] 2. **Primitive types + registry class**
|
||||
|
||||
**What to do**: Create `packages/chess/src/modifiers/primitives/types.ts` (EffectPrimitive, PrimitiveKind, parameter type families). Create `primitives/registry.ts` with `PRIMITIVE_REGISTRY` singleton.
|
||||
|
||||
**Recommended Agent Profile**: `deep`
|
||||
|
||||
**Parallelization**: Wave 1. Blocks: T4-T18. Blocked By: T1.
|
||||
|
||||
**Commit**: `feat(engine): primitive types and registry`
|
||||
|
||||
- [x] 3. **Custom modifier descriptor types**
|
||||
|
||||
**What to do**: Create `packages/chess/src/modifiers/custom/types.ts` — `CustomModifierDescriptor` shape with { id, name, description, version, primitives[], targetAttrs[] }.
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-low`
|
||||
|
||||
**Parallelization**: Wave 1. Blocks: T19-T28. Blocked By: T1.
|
||||
|
||||
**Commit**: `feat(engine): custom modifier descriptor types`
|
||||
|
||||
- [x] 4-18. **15 primitive implementations** (one per primitive)
|
||||
|
||||
**Pattern** (one task per primitive):
|
||||
- Create `packages/chess/src/modifiers/primitives/{kind}.ts`:
|
||||
- Export descriptor implementing `EffectPrimitive<Params>`
|
||||
- Zod schema for params
|
||||
- Apply function (session mutation or engine hook registration)
|
||||
- Side-effect register in `primitives/index.ts`
|
||||
- Create `.test.ts` with 3+ scenarios
|
||||
|
||||
Primitives to implement:
|
||||
- T4: seed-attribute
|
||||
- T5: add-to-attribute
|
||||
- T6: multiply-attribute
|
||||
- T7: add-direction
|
||||
- T8: set-capture-flag
|
||||
- T9: absorb-damage-with-attribute
|
||||
- T10: reflect-damage
|
||||
- T11: modify-movement-range
|
||||
- T12: block-move-type
|
||||
- T13: override-promotion
|
||||
- T14: add-aura
|
||||
- T15: on-turn-start
|
||||
- T16: on-capture
|
||||
- T17: on-damaged
|
||||
- T18: conditional
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-high` (all)
|
||||
|
||||
**Parallelization**: Wave 2 (PARALLEL). Blocks: T19, T22, T25. Blocked By: T2.
|
||||
|
||||
**Commits**: `feat(engine): {kind} effect primitive` (×15)
|
||||
|
||||
- [x] 19. **Custom descriptor validator**
|
||||
|
||||
**What to do**: `packages/chess/src/modifiers/custom/validate.ts` — validates a CustomModifierDescriptor:
|
||||
- Every primitive kind is in PRIMITIVE_REGISTRY
|
||||
- Primitive params satisfy primitive's Zod schema
|
||||
- Recursion depth ≤ 3 (count nesting of on-turn-start / on-capture / on-damaged / conditional)
|
||||
- Total primitive count ≤ 50
|
||||
- No circular references (descriptor referencing itself is banned)
|
||||
|
||||
**Recommended Agent Profile**: `deep`
|
||||
|
||||
**Parallelization**: Wave 3. Blocks: T24. Blocked By: T2, T4-T18.
|
||||
|
||||
**Commit**: `feat(engine): custom modifier descriptor validator`
|
||||
|
||||
- [x] 20. **Zod schema for custom descriptor serialization**
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-low`
|
||||
|
||||
**Parallelization**: Wave 3. Blocks: T24. Blocked By: T3.
|
||||
|
||||
**Commit**: `feat(engine): custom modifier Zod schema`
|
||||
|
||||
- [x] 21. **Custom modifier library persistence**
|
||||
|
||||
**What to do**: `packages/chess/src/modifiers/custom/library.ts` — localStorage at `houserules:custom-modifiers:v1`. Mirror T1 library API.
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-low`
|
||||
|
||||
**Parallelization**: Wave 3. Blocks: T27. Blocked By: T3, T20.
|
||||
|
||||
**Commit**: `feat(engine): custom modifier library persistence`
|
||||
|
||||
- [x] 22. **Engine integration — applyCustomDescriptor**
|
||||
|
||||
**What to do**: `packages/chess/src/modifiers/custom/apply.ts` — when a profile's perType/perInstance entry references a custom kind, resolve the descriptor from per-engine registry, execute its primitive list on the target piece.
|
||||
|
||||
**Recommended Agent Profile**: `deep`
|
||||
|
||||
**Parallelization**: Wave 3. Blocks: T23, T28. Blocked By: T4-T18.
|
||||
|
||||
**Commit**: `feat(engine): apply custom modifier descriptors`
|
||||
|
||||
- [x] 23. **Multi-profile stacking in apply/reconcile**
|
||||
|
||||
**What to do**: Extend `applyProfileToSession` → `applyProfilesToSession(session, profiles, layout)`. Extend `reconcileProfileSwap` → `reconcileProfilesSwap(session, oldProfiles, newProfiles, layout)`. Stacking per ADR-4 rules apply across multiple profiles.
|
||||
|
||||
**Recommended Agent Profile**: `deep`
|
||||
|
||||
**Parallelization**: Wave 3. Blocks: T27, T29. Blocked By: T22.
|
||||
|
||||
**Commit**: `feat(engine): multi-profile stacking`
|
||||
|
||||
- [x] 24. **Server custom-modifier.register handler**
|
||||
|
||||
**What to do**:
|
||||
- New WS message `custom-modifier.register` with full descriptor payload
|
||||
- Server validates via T19's validator
|
||||
- Registers per-room (per-engine registry)
|
||||
- Rejects if > 10 custom modifiers per room
|
||||
- Broadcasts `custom-modifier.registered` to opponent
|
||||
|
||||
**Recommended Agent Profile**: `deep`
|
||||
|
||||
**Parallelization**: Wave 4. Blocks: T29. Blocked By: T19, T20.
|
||||
|
||||
**Commit**: `feat(server): custom-modifier.register WS handler`
|
||||
|
||||
- [x] 25. **CustomModifierEditor.tsx — primitive composer UI**
|
||||
|
||||
**What to do**: Visual editor with:
|
||||
- PrimitivePalettePanel — 15 primitives grouped by category
|
||||
- Primitive tree view — shows nesting for on-turn-start / conditional
|
||||
- PrimitiveInspectorPanel — parameter form per primitive, driven by primitive's Zod schema
|
||||
- Add/delete/reorder primitives
|
||||
- Save/load from library
|
||||
- Inline validation (run T19 validator live)
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 4. Blocks: T26, T29. Blocked By: T4-T18.
|
||||
|
||||
**Commit**: `feat(ui): custom modifier editor`
|
||||
|
||||
- [x] 26. **Extend PerTypePanel/PerInstancePanel for custom kinds**
|
||||
|
||||
**What to do**: Kind dropdown iterates `MODIFIER_REGISTRY.list() + customRegistry.list()`. Custom kinds use the primitive composer for value input (or show a simplified parameter form).
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 4. Blocks: T29. Blocked By: T25.
|
||||
|
||||
**Commit**: `feat(ui): custom modifiers in modifier profile panels`
|
||||
|
||||
- [x] 27. **Multi-profile picker in Lobby**
|
||||
|
||||
**What to do**: Lobby's profile picker becomes multi-select with ordering. Drag to reorder. Selected profiles stack in UI-visible order.
|
||||
|
||||
**Recommended Agent Profile**: `visual-engineering`, skills: [`interface-design`]
|
||||
|
||||
**Parallelization**: Wave 4. Blocks: T29. Blocked By: T21, T23.
|
||||
|
||||
**Commit**: `feat(ui): multi-profile stacking in lobby`
|
||||
|
||||
- [x] 28. **Aura engine integration**
|
||||
|
||||
**What to do**: `packages/chess/src/modifiers/auras.ts` — `computeAuraFacts(session)` runs after every move via pseudo-preset. Walks all aura-primitive-declared modifiers, finds affected pieces within radius, updates derived facts.
|
||||
|
||||
**Recommended Agent Profile**: `deep`
|
||||
|
||||
**Parallelization**: Wave 4. Blocks: T29. Blocked By: T22.
|
||||
|
||||
**Commit**: `feat(engine): aura effect computation`
|
||||
|
||||
- [x] 29. **Playwright e2e suite (15 scenarios)**
|
||||
|
||||
**What to do**: Create `packages/chess/e2e/custom-modifiers.spec.ts`:
|
||||
1. Open custom modifier editor from modifier profile editor
|
||||
2. Create "Shield" custom modifier via primitive composer
|
||||
3. Save custom modifier to library
|
||||
4. Reload page → custom modifier still in library
|
||||
5. Use custom modifier in a profile (per-type entry)
|
||||
6. Start solo game with profile using custom modifier — verify behavior
|
||||
7. Multi-profile stacking (2 profiles) — HP bonuses add
|
||||
8. Aura effect — piece within radius gets derived attr
|
||||
9. Aura updates after move (target moves out of radius → fact retracted)
|
||||
10. Server rejects custom modifier with > 50 primitives
|
||||
11. Server rejects custom modifier with > 3 recursion depth
|
||||
12. Custom modifier sharing via multiplayer — both clients see same behavior
|
||||
13. Conditional primitive works (if HP < 2, do X)
|
||||
14. on-turn-start primitive fires
|
||||
15. absorb-damage-with-attribute works (shield absorbs before HP)
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-high`, skills: [`playwright`]
|
||||
|
||||
**Parallelization**: Wave 5. Blocks: F1-F4. Blocked By: T22-T28.
|
||||
|
||||
**Commit**: `test(e2e): custom modifier DSL vertical slice`
|
||||
|
||||
- [x] 30. **ADR updates — T3 Implementation Retrospective**
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-low`
|
||||
|
||||
**Parallelization**: Wave 5. Blocks: F1. Blocked By: T1, T22-T28.
|
||||
|
||||
**Commit**: `docs(adr): T3 implementation retrospective`
|
||||
|
||||
- [x] 31. **User docs: docs/user/custom-modifiers.md**
|
||||
|
||||
**What to do**: New user guide:
|
||||
- What are custom modifiers?
|
||||
- Opening the editor
|
||||
- The 15 effect primitives (one subsection each with 1 example)
|
||||
- Composing primitives (simple to complex)
|
||||
- Saving & sharing
|
||||
- Multi-profile stacking
|
||||
- Aura effects (radius, affected pieces)
|
||||
- Limitations (50 primitive cap, depth cap, 10 per room)
|
||||
|
||||
**Recommended Agent Profile**: `unspecified-low`
|
||||
|
||||
**Commit**: `docs(user): custom modifier DSL user guide`
|
||||
|
||||
- [x] 32. **T4 forward-design document**
|
||||
|
||||
**What to do**: Create `docs/adr/T4-scripted-modifiers-design.md`:
|
||||
- Why T4 is deferred (security complexity)
|
||||
- Sandbox candidates evaluated (QuickJS, Duktape, custom mini-interpreter)
|
||||
- Descriptor shape extension (`type: "data" | "scripted"`)
|
||||
- Permission system sketch
|
||||
- Validation strategy (static analysis of script before execution)
|
||||
- How T3 primitives can be migrated (scripted modifier that wraps a primitive sequence)
|
||||
- Open questions
|
||||
|
||||
**Recommended Agent Profile**: `writing`
|
||||
|
||||
**Commit**: `docs(adr): T4 scripted modifiers forward-design`
|
||||
|
||||
---
|
||||
|
||||
## Final Verification Wave
|
||||
|
||||
- [x] F1. **Plan Compliance Audit** — `oracle`
|
||||
Verify all 15 primitives, all 32 tasks' deliverables, T3-ADR decisions reflected. No T4 scripted modifiers shipped (only design doc).
|
||||
Output: `Primitives [15/15] | Tasks [N/N] | ADRs [7/7] | VERDICT`
|
||||
|
||||
- [x] F2. **Code Quality Review** — `unspecified-high`
|
||||
`bun run check`. Scan for slop. Verify registry-dispatch pattern (no hardcoded kind switches).
|
||||
Output: `Build [PASS] | Lint [PASS] | Tests [N pass] | VERDICT`
|
||||
|
||||
- [x] F3. **Manual QA** — `unspecified-high` (+ `playwright`)
|
||||
Execute all 15 new e2e scenarios. Author a Shield custom modifier via UI, play a game with it, verify full behavior end-to-end.
|
||||
Output: `Scenarios [N/N] | Integration [pass] | VERDICT`
|
||||
|
||||
- [x] F4. **Scope Fidelity** — `deep`
|
||||
No scripted modifiers (only forward-design doc). No cross-room leakage. Recursion cap enforced. Primitive count cap enforced.
|
||||
Output: `Tasks [N/N compliant] | T4 smuggling [CLEAN] | VERDICT`
|
||||
|
||||
---
|
||||
|
||||
## Commit Strategy
|
||||
|
||||
1. `docs(adr): T3 custom modifier DSL architecture decisions`
|
||||
2. `feat(engine): primitive types and registry`
|
||||
3. `feat(engine): custom modifier descriptor types`
|
||||
4-18. `feat(engine): {kind} effect primitive` (×15)
|
||||
19. `feat(engine): custom modifier descriptor validator`
|
||||
20. `feat(engine): custom modifier Zod schema`
|
||||
21. `feat(engine): custom modifier library persistence`
|
||||
22. `feat(engine): apply custom modifier descriptors`
|
||||
23. `feat(engine): multi-profile stacking`
|
||||
24. `feat(server): custom-modifier.register WS handler`
|
||||
25. `feat(ui): custom modifier editor`
|
||||
26. `feat(ui): custom modifiers in modifier profile panels`
|
||||
27. `feat(ui): multi-profile stacking in lobby`
|
||||
28. `feat(engine): aura effect computation`
|
||||
29. `test(e2e): custom modifier DSL vertical slice`
|
||||
30. `docs(adr): T3 implementation retrospective`
|
||||
31. `docs(user): custom modifier DSL user guide`
|
||||
32. `docs(adr): T4 scripted modifiers forward-design`
|
||||
|
||||
## Success Criteria
|
||||
|
||||
```bash
|
||||
bun run check # all green
|
||||
bun run test packages/chess/src/modifiers/primitives/ # all 15 primitive tests pass
|
||||
bun run test packages/chess/src/modifiers/custom/ # apply + validate + library pass
|
||||
bunx playwright test e2e/custom-modifiers.spec.ts # 15/15 pass
|
||||
bunx playwright test e2e/modifier-profiles.spec.ts # 26/26 pass (no regression)
|
||||
```
|
||||
|
||||
### Final Checklist
|
||||
- [ ] All 15 primitives registered and individually tested
|
||||
- [ ] Custom modifier editor UI functional
|
||||
- [ ] Server validates + registers custom descriptors per-room
|
||||
- [ ] Multi-profile stacking works
|
||||
- [ ] Auras work (derived facts update on move)
|
||||
- [ ] 15 Playwright e2e + 26 existing pass (41 total)
|
||||
- [ ] ADR + user docs + T4 design note published
|
||||
- [ ] `bun run check` green
|
||||
- [ ] F1-F4 all APPROVE
|
||||
- [ ] User explicit "okay"
|
||||
1874
.sisyphus/plans/piece-modifiers.md
Normal file
1874
.sisyphus/plans/piece-modifiers.md
Normal file
File diff suppressed because it is too large
Load diff
203
docs/adr/T4-scripted-modifiers-design.md
Normal file
203
docs/adr/T4-scripted-modifiers-design.md
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
# T4 — Scripted Modifiers (Forward Design)
|
||||
|
||||
> **Status**: Forward design only. T4 is deferred. T3 ships data-only custom
|
||||
> modifiers; this document records the design we'd reach for if and when T4
|
||||
> becomes a priority.
|
||||
|
||||
T3 lets users compose custom modifiers from a fixed catalog of 15 primitives.
|
||||
The natural next layer is letting users author primitives whose behaviour is
|
||||
expressed in code (or a code-like DSL) rather than a shipped TypeScript
|
||||
function. That's T4.
|
||||
|
||||
This document captures (a) why T4 is deferred, (b) the candidate sandbox
|
||||
runtimes we evaluated, (c) the descriptor shape that preserves backwards
|
||||
compatibility with T3, (d) the permission model we'd want, (e) the validation
|
||||
strategy, (f) how T3 primitives migrate forward, and (g) open questions.
|
||||
|
||||
---
|
||||
|
||||
## Why T4 is deferred
|
||||
|
||||
The unifying concern is **security surface area**.
|
||||
|
||||
A T3 custom modifier can ONLY combine functions the engine already ships. A
|
||||
malicious or buggy descriptor can no-op (unknown kinds skip silently) or
|
||||
exhaust limits (caught by validator caps), but it can't mint new behaviour
|
||||
the engine didn't already implement.
|
||||
|
||||
A T4 scripted modifier can express NEW behaviour written by an untrusted
|
||||
author, run inside the same process the rest of the game uses. That demands
|
||||
a sandbox: memory bounds, CPU bounds, deterministic execution (for
|
||||
multiplayer replication), no host-API access (no `fetch`, no `Date.now()` in
|
||||
the wrong place, no DOM), no infinite loops, no side channels into private
|
||||
session state.
|
||||
|
||||
Building that sandbox correctly is a multi-week project and a permanent
|
||||
maintenance burden. T3 delivers user-authored modifiers without it; T4
|
||||
buys "Turing-complete user modifiers" at the cost of becoming a
|
||||
sandbox-vendor team.
|
||||
|
||||
---
|
||||
|
||||
## Sandbox candidates evaluated
|
||||
|
||||
| Runtime | Verdict |
|
||||
|---|---|
|
||||
| **QuickJS (via wasm)** | Strong candidate. Mature ECMA-262 implementation, embeddable, used in production by Bun for `--smol` builds. Bun's `quickjs-emscripten` binding makes integration mechanical. CPU caps and memory caps are runtime-enforced. Determinism: would need to whitelist a subset of stdlib (no `Date.now`, no `Math.random` without a seeded shim) but well-trodden ground. **Likely T4 pick.** |
|
||||
| **Duktape (via wasm)** | Smaller binary than QuickJS but lower spec compliance (ES5+). Less attractive for users writing modern JS. |
|
||||
| **WebAssembly (Wasmer / Wasmtime in browser)** | Heaviest sandbox. Strongest isolation. Forces users to compile from a higher-level language (AssemblyScript, Rust). High authoring friction; better suited to a power-user tier than a casual editor. |
|
||||
| **Custom mini-interpreter** | Same complexity as a vetted runtime, with less battle-testing. Rejected as not-invented-here. |
|
||||
| **Web Workers** | Not a sandbox per se — same JS realm with structured cloning across the boundary. Doesn't bound CPU. Weaker isolation than QuickJS. |
|
||||
| **vm2 / isolated-vm** | Server-only (Node), doesn't help our browser-runtime case. |
|
||||
|
||||
**Recommendation if/when T4 ships**: QuickJS via `quickjs-emscripten`, with a
|
||||
whitelisted host-API surface and per-call CPU + memory caps.
|
||||
|
||||
---
|
||||
|
||||
## Descriptor shape extension
|
||||
|
||||
T3 reserved the discriminator field `type: "data"` on
|
||||
`CustomModifierDescriptor` precisely so T4 could land alongside without
|
||||
breaking changes:
|
||||
|
||||
```ts
|
||||
type CustomModifierDescriptor =
|
||||
| DataModifierDescriptor // T3 — what we ship today
|
||||
| ScriptedModifierDescriptor; // T4 — future
|
||||
|
||||
interface DataModifierDescriptor {
|
||||
readonly type: 'data';
|
||||
readonly id: CustomModifierId;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly version: 1;
|
||||
readonly primitives: readonly EffectPrimitiveNode[];
|
||||
// ... other T3 fields
|
||||
}
|
||||
|
||||
interface ScriptedModifierDescriptor {
|
||||
readonly type: 'scripted';
|
||||
readonly id: CustomModifierId;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly version: 1;
|
||||
/** Source code in the chosen DSL (likely a JS subset). */
|
||||
readonly source: string;
|
||||
/** Compiled-and-validated bytecode hash; null = "needs compile". */
|
||||
readonly hash: string | null;
|
||||
/** Permissions the author requested; subject to user grant on import. */
|
||||
readonly permissions: readonly Permission[];
|
||||
// ... shared trunk: targetAttrs, uiForm, source, author, createdAt
|
||||
}
|
||||
```
|
||||
|
||||
The trunk fields stay identical so registry lookup, library persistence,
|
||||
serialization, and the Modifier Profile picker UI all work uniformly across
|
||||
both descriptor families. Only the apply-time dispatcher (and the editor's
|
||||
right inspector panel) branches on `type`.
|
||||
|
||||
---
|
||||
|
||||
## Permission model sketch
|
||||
|
||||
Every scripted modifier declares the permissions it needs. The user grants
|
||||
permissions explicitly on first use (mirroring browser permission prompts).
|
||||
|
||||
| Permission | Capabilities granted | Default |
|
||||
|---|---|---|
|
||||
| `read-self` | Read facts on the piece this descriptor is attached to | granted |
|
||||
| `read-board` | Read facts on every other piece | prompt |
|
||||
| `write-self` | Insert/retract facts on this piece | granted |
|
||||
| `write-board` | Insert/retract facts on other pieces | prompt |
|
||||
| `read-history` | Read the engine's move log | prompt |
|
||||
| `emit-effect` | Push visual effects via `engine.emitEffect` | granted |
|
||||
| `random` | Use the engine's seeded RNG | prompt |
|
||||
|
||||
Permissions are validated server-side on `custom-modifier.register` — a
|
||||
descriptor with `write-board` arriving from an untrusted source is rejected
|
||||
unless the room's host has explicitly opted into "allow scripted modifiers".
|
||||
|
||||
---
|
||||
|
||||
## Validation strategy
|
||||
|
||||
T3 validates structure (Zod) AND semantics (kind in registry, params satisfy
|
||||
schemas). T4 needs a third layer: **static analysis of the script before
|
||||
first execution**, to catch obvious abuse before we even spin up the sandbox.
|
||||
|
||||
Likely checks:
|
||||
- **Source size cap** — reject scripts above N kB.
|
||||
- **AST node count cap** — reject scripts above M AST nodes (parses big,
|
||||
evaluates fast).
|
||||
- **Forbidden globals** — reject any reference to `eval`, `Function`,
|
||||
`WebAssembly`, `fetch`, `XMLHttpRequest`, `import`, `require`, `top`,
|
||||
`parent`, `globalThis`, etc. Whitelist instead of blacklist.
|
||||
- **Loop bounds** — flag any loop without a statically-determinable bound;
|
||||
optionally inject a per-iteration CPU-budget check.
|
||||
- **Recursion bounds** — flag any function calling itself without a
|
||||
statically-determinable termination.
|
||||
- **Permission match** — every host-API call must be reachable only when the
|
||||
declared permission is granted.
|
||||
|
||||
The static analyser produces a verdict (`{ ok: true, hash } | { ok: false,
|
||||
errors: [] }`) and is run on Save in the editor and again on
|
||||
`custom-modifier.register` server-side.
|
||||
|
||||
The runtime sandbox enforces what static analysis can't:
|
||||
- **CPU budget per apply call** — preempt and abort after N ms.
|
||||
- **Memory budget** — hard cap on heap size.
|
||||
- **Determinism** — seeded RNG, frozen `Date.now`, no setTimeout / setInterval
|
||||
reaching outside the call.
|
||||
|
||||
---
|
||||
|
||||
## How T3 primitives migrate forward
|
||||
|
||||
Every T3 primitive is structurally a function `(ctx, params) => void`. A T4
|
||||
scripted modifier can wrap an equivalent function written in the script
|
||||
runtime, so a power user can:
|
||||
|
||||
1. Start with a T3 descriptor (data composition).
|
||||
2. "Eject" to T4 — the editor generates the equivalent script wrapping each
|
||||
primitive's behaviour, switches the descriptor's `type` to `"scripted"`,
|
||||
and gives the user a starting point for further customisation.
|
||||
3. Continue in T4, tweaking the generated source.
|
||||
|
||||
The reverse is NOT generally possible (a hand-written T4 script can express
|
||||
behaviours that no combination of T3 primitives matches), but the eject path
|
||||
gives users a smooth on-ramp.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
These are the design decisions we haven't made yet. They block T4 kickoff:
|
||||
|
||||
1. **DSL surface** — full ECMAScript subset or a smaller language? A
|
||||
restricted DSL is easier to validate but harder for users to learn.
|
||||
2. **Multiplayer determinism** — every client must execute scripted modifiers
|
||||
identically. A fundamental choice between (a) "server is authoritative,
|
||||
clients receive deltas" (works today for built-ins) and (b) "clients run
|
||||
the script themselves, must converge bit-for-bit" (requires deterministic
|
||||
sandbox + identical inputs).
|
||||
3. **Editor experience** — full text editor (Monaco / CodeMirror)? Visual
|
||||
block editor (Scratch / Blockly style)? Both?
|
||||
4. **Sharing trust model** — a profile that references a scripted modifier
|
||||
travels with the descriptor's source. Recipients see the source plus the
|
||||
declared permissions before importing. Do we also show a static-analysis
|
||||
summary ("This script reads the board, writes 1 attribute, declares no
|
||||
network access")?
|
||||
5. **Rate limiting** — how many scripted modifiers can register per room per
|
||||
minute? Per session?
|
||||
6. **Script versioning** — when an author updates their scripted modifier,
|
||||
does the new version replace the old in every saved profile that
|
||||
referenced it (auto-update) or only on explicit user action (manual
|
||||
update)?
|
||||
7. **Failure mode** — when a scripted modifier throws or times out at apply
|
||||
time, do we abort the move (strict) or skip the modifier and continue
|
||||
(lenient)? T3's data primitives are infallible; T4's scripts aren't.
|
||||
|
||||
Each question is a small ADR worth of debate. We don't need to answer them
|
||||
to ship T3, but we should answer 1-3 before any T4 implementation work
|
||||
begins.
|
||||
939
docs/adr/modifier-profiles.md
Normal file
939
docs/adr/modifier-profiles.md
Normal file
|
|
@ -0,0 +1,939 @@
|
|||
# ADR: Piece Modifier Profiles Architecture
|
||||
|
||||
This document captures the eight architectural decisions that shape the T1
|
||||
piece-modifier-profiles feature. Each section is self-contained and records
|
||||
the decision, the reasoning behind it, and the alternatives that were
|
||||
considered and rejected.
|
||||
|
||||
---
|
||||
|
||||
## ADR-1: Move Generator Hook Contract — WRAP (not REPLACE)
|
||||
|
||||
### Decision
|
||||
|
||||
Introduce a new hook signature on modifier/preset descriptors:
|
||||
|
||||
```
|
||||
transformMoveGenerator?(engine, pieceId, prevGenerator) => MoveGenerator
|
||||
```
|
||||
|
||||
Each hook receives the *previous* generator in the chain and returns a new
|
||||
one. Multiple presets plus the active profile can all contribute, composing
|
||||
in precedence order. The generator returned by the chain emits
|
||||
**pseudo-legal** moves only.
|
||||
|
||||
### Rationale
|
||||
|
||||
- **Composition over replacement.** Several modifier sources (presets,
|
||||
per-type profile entries, per-instance profile entries) must be able to
|
||||
stack without one clobbering another.
|
||||
- **Clear layering.** The engine's legality layer (self-check filter, turn
|
||||
validation, repetition detection) always runs downstream of the generator
|
||||
chain. Hooks never need to reason about whole-board legality — only about
|
||||
the piece's own movement facts.
|
||||
- **Deterministic ordering.** Precedence (ADR-5) uniquely defines the order
|
||||
in which generators are wrapped, so the final generator is reproducible
|
||||
from the profile alone.
|
||||
|
||||
### Rejected Alternatives
|
||||
|
||||
- **REPLACE semantics** (only the first/highest-priority hook wins): kills
|
||||
composition. Two independent modifiers that both want to alter movement
|
||||
could not coexist, forcing users to choose one or hand-merge them.
|
||||
- **Mid-chain interception** (hooks can peek at or mutate moves emitted by
|
||||
other hooks): dramatically more complex to reason about, breaks locality,
|
||||
and makes determinism hard to audit.
|
||||
|
||||
---
|
||||
|
||||
## ADR-2: Per-Instance Identity Keying — Layout-Slot Bound (Option B)
|
||||
|
||||
### Decision
|
||||
|
||||
Per-instance modifier entries in a profile are keyed by the piece's
|
||||
**starting square in algebraic notation** (e.g. `"b1"`). The profile
|
||||
document carries an optional `layoutId` field. At game start,
|
||||
`applyProfileToSession` resolves each `square → EntityId` by consulting the
|
||||
layout's piece placement array.
|
||||
|
||||
Cross-layout reuse is permitted for the *per-type* portion of a profile.
|
||||
The *per-instance* portion is dropped with a warning when the active layout
|
||||
does not contain the referenced square (orphan handling — see ADR-6 check
|
||||
#3).
|
||||
|
||||
### Rationale
|
||||
|
||||
- **Human-readable.** Profile JSON (and any URL-encoded form) stays legible:
|
||||
a designer can see `"b1": { ... }` and immediately know which piece it
|
||||
targets.
|
||||
- **Portable within a layout.** Two sessions using the same layout can
|
||||
share the profile verbatim.
|
||||
- **Graceful degradation.** When a profile is applied against a different
|
||||
layout, per-type rules still apply; only the now-meaningless per-instance
|
||||
keys are dropped, with a surfaced warning.
|
||||
|
||||
### Rejected Alternatives
|
||||
|
||||
- **Option A — key by `EntityId`.** EntityIds are session-scoped runtime
|
||||
handles; they are meaningless outside the session that produced them.
|
||||
This would make profiles non-portable and impossible to author by hand.
|
||||
- **Option C — defer per-instance entirely to T2.** Per-instance keying is
|
||||
the single feature that makes "this specific rook on b1" different from
|
||||
"all rooks" possible in T1; deferring it would gut the feature's value.
|
||||
|
||||
---
|
||||
|
||||
## ADR-3: Hot-Swap Reconciliation Rules
|
||||
|
||||
### Decision
|
||||
|
||||
When a profile is swapped on a live game, each modifier kind reconciles
|
||||
according to the following table:
|
||||
|
||||
| Modifier | On swap-apply | On swap-remove |
|
||||
| ------------------- | ------------------------------------------------------------- | --------------------------------------- |
|
||||
| HpBonus | `maxHp` recomputed; `currentHp = min(currentHp, newMax)` | `currentHp` never grows (same rule) |
|
||||
| RangeBonus | Overwrite fact; next legal-move query reflects new range | Revert to base; same query semantics |
|
||||
| DirectionAdditions | Overwrite fact; next legal-move query includes/excludes | Revert to base direction set |
|
||||
| CaptureFlags | Overwrite; applies to the **next** capture event | Revert; applies to next capture |
|
||||
| PromotionOverride | Overwrite; applies to **future** promotions only | Revert; future promotions use base |
|
||||
| DamageResistance | Overwrite; applies to the **next** damage event | Revert; next damage uses base |
|
||||
|
||||
**Timing.** Hot-swaps are applied at the **turn boundary only** — after
|
||||
`turnEnd` fires and before the next `turnStart`. Any update received
|
||||
mid-turn is queued and flushed at that boundary.
|
||||
|
||||
**Failure / rollback.** The server is authoritative and clients hold no
|
||||
optimistic state for profile changes. If a swap is rejected (legality
|
||||
validator fails, permission denied, etc.) the server sends a NACK to the
|
||||
originating client and performs no broadcast. There is no per-event
|
||||
rollback to unwind partially-applied effects, because effects do not apply
|
||||
until the boundary.
|
||||
|
||||
### Rationale
|
||||
|
||||
- **Determinism.** Applying changes strictly at turn boundaries means every
|
||||
observer (players, spectators, replays) sees an identical sequence of
|
||||
(state, swap, state, swap) transitions.
|
||||
- **HP invariant.** The "never grows" rule for `currentHp` preserves the
|
||||
intuition that removing a buff should not heal; applying a buff raises
|
||||
the ceiling but never the floor.
|
||||
- **Server authority.** Keeping clients dumb about swap outcomes avoids a
|
||||
whole class of desync bugs; the NACK pattern keeps the protocol simple.
|
||||
|
||||
### Rejected Alternatives
|
||||
|
||||
- **Mid-turn application.** Introduces ordering ambiguity relative to
|
||||
queued events, move-generation caches, and partially-resolved attacks;
|
||||
determinism becomes painful to reason about.
|
||||
- **Per-event rollback.** Would require journaling every effect so it can
|
||||
be unwound on failure. T1 does not need this level of sophistication,
|
||||
and the boundary-only rule makes it unnecessary.
|
||||
|
||||
---
|
||||
|
||||
## ADR-4: Stacking Rules per Modifier Kind
|
||||
|
||||
### Decision
|
||||
|
||||
Each T1 modifier kind declares a fixed stacking rule, applied whenever more
|
||||
than one source contributes a value (per ADR-5 precedence):
|
||||
|
||||
| Modifier | Stacking rule | Formula |
|
||||
| ------------------- | -------------------------------- | --------------------------------------------- |
|
||||
| HpBonus | Additive | sum of all sources |
|
||||
| RangeBonus | Additive, clamped `[0, 7]` | sum, then clamp |
|
||||
| DirectionAdditions | Union | set union of arrays |
|
||||
| CaptureFlags | Union (bitwise OR) | flags OR'd together |
|
||||
| PromotionOverride | Precedence wins | perInstance > perType > preset |
|
||||
| DamageResistance | Multiplicative | `1 - ∏(1 - r_i)`, clamped `≥ 0` |
|
||||
|
||||
### Rationale
|
||||
|
||||
- **Additive/union kinds** have natural commutative/associative semantics,
|
||||
so order of application does not matter. This is safe to compute in any
|
||||
order.
|
||||
- **Clamping `RangeBonus`** to the 0..7 board diagonal keeps generators
|
||||
sane; an 8-square-wide board can never need more than 7 steps of range.
|
||||
- **Multiplicative `DamageResistance`** matches player intuition: two
|
||||
sources of 50% resistance yield 75% total, not 100%. This prevents
|
||||
accidental invulnerability from additive stacking.
|
||||
- **PromotionOverride as "precedence wins"** reflects that overrides are
|
||||
replacement-style facts — two simultaneous overrides cannot meaningfully
|
||||
merge, so the highest-priority one is chosen.
|
||||
|
||||
### Rejected Alternatives
|
||||
|
||||
- **All-additive** (including resistance): trivially produces 100%
|
||||
resistance with two modest sources, breaking balance.
|
||||
- **All-overwrite**: kills the expressive power of stacking entirely and
|
||||
forces designers to pre-merge any combination of modifiers they want.
|
||||
|
||||
---
|
||||
|
||||
## ADR-5: Precedence Chain
|
||||
|
||||
### Decision
|
||||
|
||||
When multiple sources contribute to the same piece and modifier kind, the
|
||||
priority order is:
|
||||
|
||||
```
|
||||
per-instance (profile) > per-type (profile) > preset > engine base
|
||||
```
|
||||
|
||||
- For **additive** and **union** stacking rules (see ADR-4), *all* sources
|
||||
contribute; precedence only controls the order in which hooks wrap the
|
||||
move generator (ADR-1).
|
||||
- For **override** kinds (e.g. `PromotionOverride`), only the
|
||||
highest-priority source wins.
|
||||
|
||||
### Rationale
|
||||
|
||||
- **Specificity first.** Per-instance data is the most specific statement
|
||||
("this piece on b1"), per-type is broader ("all bishops"), preset is
|
||||
broader still ("this game mode"), and engine base is the default. Higher
|
||||
specificity winning matches designer expectations and mirrors how CSS,
|
||||
config layering, and similar systems behave.
|
||||
- **Composable by default.** Treating additive/union kinds as contributors
|
||||
rather than gated by precedence means a per-type buff and a per-instance
|
||||
buff can *both* apply, which is the whole point of having two layers.
|
||||
|
||||
### Rejected Alternatives
|
||||
|
||||
- **Preset-first** (preset beats profile): undermines user-authored
|
||||
profiles and makes game modes unmodifiable.
|
||||
- **Flat merge with no precedence**: leaves override-style modifiers
|
||||
ambiguous.
|
||||
|
||||
---
|
||||
|
||||
## ADR-6: Legality Validator Checklist
|
||||
|
||||
### Decision
|
||||
|
||||
On every profile apply and every hot-swap, the engine runs a legality
|
||||
validator with five checks. Each check has a stable error code for
|
||||
client-side reporting.
|
||||
|
||||
1. **Both sides have ≥ 1 king.** Code: `E_PROFILE_NO_KING`. Error.
|
||||
2. **No king carries `CaptureFlags = CANNOT_BE_CAPTURED`.** Code:
|
||||
`E_PROFILE_INVULN_KING`. Error.
|
||||
3. **No orphan per-instance entries** (per-instance key references a
|
||||
square not present in the active layout). Code:
|
||||
`E_PROFILE_ORPHAN_INSTANCE`. **Warning only**, not a rejection — the
|
||||
orphan entry is dropped per ADR-2.
|
||||
4. **Each side has ≥ 1 legal move** from the current position. Code:
|
||||
`E_PROFILE_DEADLOCK`. Error.
|
||||
5. **Total resolved facts per piece ≤ 16 attributes.** Code:
|
||||
`E_PROFILE_ATTR_LIMIT`. Error.
|
||||
|
||||
Checks that emit an error cause the apply/swap to be rejected atomically;
|
||||
no partial state is observable. Warnings are surfaced but do not block
|
||||
application.
|
||||
|
||||
### Rationale
|
||||
|
||||
- **Win-condition integrity.** Checks 1 and 2 guarantee the game can still
|
||||
be won — you cannot accidentally author a profile that removes all kings
|
||||
or makes a king uncapturable.
|
||||
- **Playability.** Check 4 prevents instant deadlocks at swap time.
|
||||
- **Performance & sanity ceiling.** Check 5 caps the fact-set per piece so
|
||||
that the attribute system cannot be overwhelmed by pathological
|
||||
profiles.
|
||||
- **User experience.** Check 3 is a warning rather than an error because
|
||||
dropping irrelevant per-instance keys (see ADR-2) is the documented
|
||||
behaviour when applying across layouts.
|
||||
|
||||
### Rejected Alternatives
|
||||
|
||||
- **No validator — trust the author.** Produces unrecoverable games and
|
||||
makes server state hard to reason about.
|
||||
- **Validator as errors only (no warnings).** Would force cross-layout
|
||||
reuse to be a hard failure, defeating ADR-2's portability goal.
|
||||
- **Validator at game-start only.** Misses hot-swap-introduced
|
||||
corruptions.
|
||||
|
||||
---
|
||||
|
||||
## ADR-7: Single Active Profile Per Game (T1)
|
||||
|
||||
### Decision
|
||||
|
||||
In T1, exactly one profile is active on a given game at a time. Changing
|
||||
the active profile is a **full swap** performed by sending a
|
||||
`modifier-profile.update` WebSocket message. Profile stacking or layered
|
||||
composition of multiple profiles is explicitly deferred to T2+.
|
||||
|
||||
### Rationale
|
||||
|
||||
- **Scope control.** T1 already introduces a new hook, registry, profile
|
||||
document shape, and validator. Adding multi-profile composition on top
|
||||
would multiply the edge cases (ordering between profiles, overlap
|
||||
conflicts, partial updates) and delay the feature.
|
||||
- **Simple mental model.** "One profile, swap to change it" is trivially
|
||||
understandable by end users and matches how presets are selected today.
|
||||
- **Forward-compatible.** The swap message already carries a full profile
|
||||
payload; a future multi-profile world can extend the same message shape
|
||||
(e.g. `profiles: Profile[]`) without breaking T1 clients.
|
||||
|
||||
### Rejected Alternatives
|
||||
|
||||
- **Multi-profile composition in T1.** Punts on too many unresolved
|
||||
design questions (inter-profile precedence, addition vs replacement,
|
||||
diffing) to fit in the T1 milestone.
|
||||
- **Additive deltas only (no full swap).** Harder to reason about when
|
||||
recovering from a corrupted state; the full-swap primitive is simpler
|
||||
and can always simulate a delta by applying a re-derived profile.
|
||||
|
||||
---
|
||||
|
||||
## ADR-8: Registry Pattern for Modifier Catalog
|
||||
|
||||
### Decision
|
||||
|
||||
Each T1 modifier kind lives in its own file under:
|
||||
|
||||
```
|
||||
packages/chess/src/modifiers/descriptors/{kind}.ts
|
||||
```
|
||||
|
||||
At module load time the file self-registers into `MODIFIER_REGISTRY`,
|
||||
mirroring the existing `PRESET_REGISTRY` and `LAYOUT_REGISTRY` patterns
|
||||
(with `register(def)`, `get(id)`, `list()`, `has(id)` surface).
|
||||
|
||||
The descriptor shape is:
|
||||
|
||||
```
|
||||
{
|
||||
id,
|
||||
attrName,
|
||||
label,
|
||||
valueSchema,
|
||||
stackingRule,
|
||||
apply,
|
||||
describe,
|
||||
uiForm,
|
||||
}
|
||||
```
|
||||
|
||||
T3 custom (user-defined) modifiers will plug into this same registry,
|
||||
requiring no changes to the registry contract itself.
|
||||
|
||||
### Rationale
|
||||
|
||||
- **One modifier = one file.** Adding a new modifier kind is a single-file
|
||||
addition, not a multi-file edit across schema, engine, UI, and
|
||||
validator. This is the same ergonomic property that makes the preset
|
||||
and layout registries pleasant to extend.
|
||||
- **Consistency with existing patterns.** The codebase already has two
|
||||
registries following this shape; a third avoids introducing a new
|
||||
idiom to learn.
|
||||
- **T3-ready.** Framing the registry as the single integration point now
|
||||
means T3 custom modifiers can register through the same API without
|
||||
special-casing.
|
||||
- **Discoverability.** `MODIFIER_REGISTRY.list()` produces the full
|
||||
catalog for UI/UX (picker widgets, docs generators, validation hints).
|
||||
|
||||
### Rejected Alternatives
|
||||
|
||||
- **Hardcoded switch/if-else.** Adding a new modifier kind would require
|
||||
editing six or more files (schema, engine apply path, hooks, UI, docs,
|
||||
validator). Each edit is an opportunity for drift between layers.
|
||||
- **Plugin manifest with lazy loading.** Overkill for a fixed T1 catalog
|
||||
and incompatible with deterministic module load ordering.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Retrospective
|
||||
|
||||
### Deviations from ADR
|
||||
|
||||
- **ADR-3 hot-swap timing**: Applied immediately on receipt rather than at
|
||||
an explicit turn-boundary gate. WS message serialization per-socket
|
||||
ensures a client's own move and profile swap cannot interleave. True
|
||||
turn-boundary queue deferred to T2.
|
||||
- **ADR-6 check 4 (DEADLOCK)**: Deferred to T2 — requires session
|
||||
simulation which introduces a circular dependency with the engine.
|
||||
- **ADR-3 host authority**: Profile swap allowed from host (white player)
|
||||
only in T1. Two-player consent model deferred to T2.
|
||||
- **Zod v3/v4 mismatch**: Server pins Zod v3; chess package moved to v4.
|
||||
Schemas mirrored locally in the server package with a compile-time
|
||||
key-parity guard.
|
||||
|
||||
### Discovered patterns
|
||||
|
||||
- `MODIFIER_REGISTRY` mirrors `PRESET_REGISTRY`/`LAYOUT_REGISTRY` exactly —
|
||||
confirmed the side-effect import pattern scales cleanly.
|
||||
- `__modifier-profile-integration__` pseudo-preset registered by
|
||||
`applyProfileToSession` integrates `CaptureFlags` and
|
||||
`DirectionAdditions` into the existing hook pipeline without modifying
|
||||
`engine.ts`.
|
||||
- `reconcileProfileSwap` uses retract-then-reapply semantics — idempotent
|
||||
and straightforward to reason about.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## T2-ADR-1: Turn-boundary queue
|
||||
|
||||
### Decision
|
||||
Hot-swap updates are enqueued server-side (`room.pendingProfile`) and applied at the next turn boundary (after `applyMove()` succeeds), not immediately on receipt.
|
||||
|
||||
### Rationale
|
||||
T1 shipped an immediate-apply simplification (profile swap took effect the moment the server received `modifier-profile.update`). The retrospective noted this was acceptable because WS messages serialize per-socket — a client can't interleave its own move and swap. But:
|
||||
|
||||
- The **opponent** can still have a move in-flight when the swap lands, causing move validation to run against a profile they didn't agree to.
|
||||
- Observers/spectators (future T3+ concern) see inconsistent ordering.
|
||||
- Determinism goal: the game state at turn N is fully determined by {profile at turn N, moves 1..N}.
|
||||
|
||||
Enqueuing eliminates the race.
|
||||
|
||||
### Semantics
|
||||
- On `modifier-profile.update` (or on `consent=approve` per T2-ADR-2): server validates shape + stores in `room.pendingProfile`, acks sender with `modifier-profile.queued`.
|
||||
- After the next move applies successfully: server runs `validateProfile(pending, layout, session)`. If valid, `reconcileProfileSwap(session, room.profile, pending, layout)`; broadcast `modifier-profile.updated`; clear pending.
|
||||
- If invalid at apply time: NACK to original sender with specific error code, clear pending, no broadcast.
|
||||
- **Last-write-wins**: rapid successive updates replace the pending slot. Only the most recent pending fires on the next boundary.
|
||||
|
||||
### Rejected Alternatives
|
||||
- Per-socket move-boundary lock — forces sender to wait synchronously for their own next move before the swap is acked. Poor UX; WS doesn't guarantee reply ordering anyway.
|
||||
- Queue per sender — multiple pending profiles per room, applied in order. Nondeterministic interaction if both players queue updates simultaneously. Last-write-wins is simpler and sufficient.
|
||||
|
||||
---
|
||||
|
||||
## T2-ADR-2: Two-player consent
|
||||
|
||||
### Decision
|
||||
In multiplayer rooms, a profile swap requires both players' agreement. Host sends `modifier-profile.propose`; opponent reviews and sends `modifier-profile.consent` with approve/reject. Approve promotes the proposal to the pending-queue (T2-ADR-1). Reject clears and notifies the host. 60s timeout → auto-reject.
|
||||
|
||||
### Rationale
|
||||
T1 granted host unilateral authority to change rules mid-game. This is an unfair advantage model; chess variants are a social contract. Both players must opt-in to rule changes.
|
||||
|
||||
Solo mode and single-player "vs. bot" contexts bypass consent — the sole participant is trivially the sole consenter. `modifier-profile.update` remains valid in solo mode as a shortcut.
|
||||
|
||||
### Semantics
|
||||
- Host → server: `{type: "modifier-profile.propose", roomCode, candidate: ModifierProfile}`.
|
||||
- Server validates shape + stores `room.proposalState = {profile, proposedBy, proposedAt, timeoutHandle}`.
|
||||
- Server → opponent: `{type: "modifier-profile.proposal-pending", profile, expiresAt}`.
|
||||
- Opponent → server: `{type: "modifier-profile.consent", roomCode, decision: "approve" | "reject"}`.
|
||||
- On approve: clear proposalState, promote to `room.pendingProfile` (T2-ADR-1), server → host: `modifier-profile.consent-received`.
|
||||
- On reject or 60s timeout: clear proposalState, server → both: `modifier-profile.rejected`.
|
||||
- Only the non-proposer can consent. Self-consent is rejected.
|
||||
- Stale proposals (after a newer `propose` replaces them): old timeout cancelled, old state overwritten.
|
||||
|
||||
### Rejected Alternatives
|
||||
- Majority-vote model for 3+ player scenarios — not applicable (chess is 2-player).
|
||||
- Silent auto-approve with opt-out grace period — violates the social-contract principle.
|
||||
- Host-only with explicit "I agree to changes" checkbox at game start — inflexible; players may change their minds after seeing how a rule plays out.
|
||||
|
||||
---
|
||||
|
||||
## T2-ADR-3: Editor undo/redo
|
||||
|
||||
### Decision
|
||||
The Modifier Profile Editor maintains a client-side snapshot stack of working-profile states. Every meaningful user action (add/delete/edit modifier, change bound layout) pushes a snapshot. Cmd/Ctrl+Z undoes, Cmd/Ctrl+Shift+Z redoes. Stack capped at 50 snapshots (oldest dropped). History cleared on Save or Cancel.
|
||||
|
||||
### Rationale
|
||||
Users compose profiles iteratively; mistakes are common (added wrong modifier, wrong color, wrong square). Manual deletion is destructive and loses intermediate states. Undo/redo is standard for structured editors.
|
||||
|
||||
Capped at 50 snapshots to bound memory. Cleared on Save because the saved state is the new baseline (undoing past Save would revert persisted data, which is surprising).
|
||||
|
||||
### Semantics
|
||||
- Editor state: `{history: ModifierProfile[], historyIndex: number, clipboard: Modifier[]}`.
|
||||
- `currentProfile = history[historyIndex]`.
|
||||
- Every mutation: `pushSnapshot(newProfile)`:
|
||||
1. Truncate history forward of `historyIndex` (redo branches lost).
|
||||
2. Append newProfile.
|
||||
3. If `history.length > 50`, drop the oldest entry and decrement historyIndex.
|
||||
4. Set `historyIndex = history.length - 1`.
|
||||
- `undo()`: `historyIndex = max(0, historyIndex - 1)`.
|
||||
- `redo()`: `historyIndex = min(history.length - 1, historyIndex + 1)`.
|
||||
- Toolbar buttons disabled at stack ends.
|
||||
- Save or Cancel: `setHistory([currentProfile]); setHistoryIndex(0)` — fresh single-entry stack for a new session.
|
||||
|
||||
### Rejected Alternatives
|
||||
- Infinite history — memory unbounded. 50 is generous for a single editing session.
|
||||
- Per-modifier undo (like per-field undo in some editors) — over-granular for structured data edits.
|
||||
- Persist history to localStorage — adds complexity for marginal benefit; users expect editor state to reset between sessions.
|
||||
|
||||
---
|
||||
|
||||
## T2 Implementation Retrospective
|
||||
|
||||
### T1 simplifications resolved
|
||||
|
||||
- **Immediate-apply hot-swap → turn-boundary queue (T2-ADR-1)**: T1's `handleModifierProfileUpdate` applied profile changes immediately on receipt; T2's implementation enqueues into `Room.pendingProfile` and applies via `applyPendingProfileIfAny` after the next successful `applyMove`. NACKs at apply time (re-validation against post-move session) route to the original proposer's socket via `findSocketByToken`.
|
||||
- **Host-only authority → two-player consent (T2-ADR-2)**: T1 broadcast modifier swaps from any host message; T2 requires the opponent to send `modifier-profile.consent` with `approve` before the swap is enqueued. Solo-mode preserves the host-shortcut path (`modifier-profile.update`) since the sole participant is trivially the sole consenter.
|
||||
|
||||
### Discovered patterns
|
||||
|
||||
- **Reused `modifier-profile.queued` ack for proposer** rather than introducing a `modifier-profile.proposal-sent`. Client receipt handlers stay uniform; the observable difference is the opponent's wire traffic (`proposal-pending` vs. silence). Documented in protocol.ts.
|
||||
- **Capture-phase `stopImmediatePropagation` for nested modal Esc handling**: ModifierProfileEditor's Esc handler now uses capture phase with `stopImmediatePropagation` so the RulesDrawer's own Esc handler does NOT also fire. Without this, both would close on a single keystroke, leaving the drawer's pointer-events backdrop briefly lingering. Discovered post-T1 via the solo-smoke regression test.
|
||||
- **Token-keyed pending-proposer tracking**: `Room.pendingProposerToken` (and `proposalState.proposedByToken`) survive socket reconnects. NACK routing on apply-time validation failure finds the proposer by token, not socket id, so a brief disconnect doesn't lose the rejection notification.
|
||||
- **Timeout stale-handle guard**: 60s proposal timeouts use `setTimeout` whose handler checks `room.proposalState !== currentProposalRef` before firing. Prevents firing rejection broadcasts on stale (already-consented or already-superseded) proposals.
|
||||
- **Client-side undo/redo via `pushSnapshot`**: ModifierProfileEditor maintains its history-stack purely client-side. The 50-snapshot cap is generous for a single editing session. History clears on Save/Cancel (the saved state becomes a new baseline).
|
||||
- **Component-local clipboard for copy/paste**: No OS clipboard interaction. Cross-panel sharing goes through a `clipboard` state lifted to the editor root. Dedup by `(pieceType, color, kind)` for type modifiers and `(square, kind)` for instance modifiers prevents pastes from creating additive duplicates.
|
||||
- **Inline conflict resolution via `applyFix` dispatcher**: Each error/warning code routes to a specific resolution. `E_PROFILE_NO_KING` and `E_PROFILE_ATTR_LIMIT` are advisory-only (require user judgement); the rest auto-resolve.
|
||||
|
||||
### Deviations
|
||||
|
||||
- None — implementation matched ADRs as written.
|
||||
|
||||
---
|
||||
|
||||
## T3 Architecture Decisions
|
||||
|
||||
## T3-ADR-1: Custom modifier DSL is data-only (no runtime code execution)
|
||||
|
||||
### Context
|
||||
|
||||
T3 introduces user-authored modifier categories. The core design question is
|
||||
whether user-authored behavior should be represented as executable code or as
|
||||
structured data composed from engine-shipped operations.
|
||||
|
||||
### Decision
|
||||
|
||||
Custom modifiers are expressed as validated, structured JSON descriptors
|
||||
composed from a fixed primitive catalog. The descriptor itself is the DSL.
|
||||
T3 does not execute user-provided scripts, evaluate strings, or parse a
|
||||
free-form mini-language.
|
||||
|
||||
### Rationale
|
||||
|
||||
- **Security first.** Data validation is materially safer than executing
|
||||
untrusted code in the game server or client.
|
||||
- **Deterministic behavior.** A finite primitive set gives explicit,
|
||||
testable semantics and removes runtime ambiguity.
|
||||
- **Authoring ergonomics.** A visual editor can expose primitives without
|
||||
requiring users to write or debug code.
|
||||
- **Operational simplicity.** No sandbox runtime, resource metering, or
|
||||
script lifecycle management is required in T3.
|
||||
|
||||
### Rejected Alternatives
|
||||
|
||||
- **Embedded script runtime (e.g., QuickJS) in T3.** Too large a security and
|
||||
maintenance surface for this milestone.
|
||||
- **AST-backed custom language in T3.** Similar complexity class to scripting,
|
||||
but with fewer ecosystem benefits.
|
||||
- **String-template rule snippets.** Too weak to represent planned behavior,
|
||||
yet still introduces parsing edge cases.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Custom modifiers are fully serializable, inspectable, and diff-friendly.
|
||||
- Validation becomes schema-driven and server-enforceable.
|
||||
- T3 scope stays focused; script extensibility is intentionally deferred.
|
||||
- **Example:** A "Shield" modifier is represented as two primitives,
|
||||
`seed-attribute(attr="ShieldCharges", value=3)` and
|
||||
`absorb-damage-with-attribute(attr="ShieldCharges", rate=1)`, with no
|
||||
user code execution path.
|
||||
|
||||
---
|
||||
|
||||
## T3-ADR-2: Primitive catalog v1 contains 15 composable primitives
|
||||
|
||||
### Context
|
||||
|
||||
If the DSL is data-only (T3-ADR-1), the primitive catalog defines the
|
||||
expressive boundary of T3. The catalog must cover existing modifier behavior
|
||||
plus key new capabilities (events, conditionals, aura-like effects).
|
||||
|
||||
### Decision
|
||||
|
||||
T3 v1 ships exactly this 15-primitive catalog:
|
||||
|
||||
1. `seed-attribute`
|
||||
2. `add-to-attribute`
|
||||
3. `multiply-attribute`
|
||||
4. `add-direction`
|
||||
5. `set-capture-flag`
|
||||
6. `absorb-damage-with-attribute`
|
||||
7. `reflect-damage`
|
||||
8. `block-move-type`
|
||||
9. `add-aura`
|
||||
10. `on-turn-start`
|
||||
11. `on-capture`
|
||||
12. `on-damaged`
|
||||
13. `conditional`
|
||||
14. `modify-movement-range`
|
||||
15. `override-promotion`
|
||||
|
||||
Each primitive has a typed parameter schema, registry entry, and engine
|
||||
integration path.
|
||||
|
||||
### Rationale
|
||||
|
||||
- **Coverage.** The set spans additive stats, movement controls, damage
|
||||
behavior, event-driven effects, and branching logic.
|
||||
- **Extensibility by addition.** New primitives can be added as isolated files
|
||||
without redesigning the DSL contract.
|
||||
- **Predictable validation.** Primitive-specific schemas allow clear bounds and
|
||||
error reporting.
|
||||
|
||||
### Rejected Alternatives
|
||||
|
||||
- **Smaller initial catalog.** Reduced immediate utility; users would quickly
|
||||
hit expressiveness gaps.
|
||||
- **Open-ended primitive parameters (`Record<string, unknown>`).** Weak type
|
||||
guarantees and poor editor UX.
|
||||
- **Monolithic "do-everything" primitive.** Hard to validate, reason about,
|
||||
or compose safely.
|
||||
|
||||
### Consequences
|
||||
|
||||
- T3 ships with a bounded but practical authoring surface.
|
||||
- Engine and UI both rely on one canonical primitive registry.
|
||||
- Future primitives are additive and backward-compatible.
|
||||
- **Example:** A low-HP panic behavior can be authored with
|
||||
`conditional(condition: hp<2, then:[modify-movement-range(+1)], else:[])`.
|
||||
|
||||
---
|
||||
|
||||
## T3-ADR-3: Authoring scope is per-room runtime, per-user library, and embedded sharing
|
||||
|
||||
### Context
|
||||
|
||||
Custom modifiers must be reusable for authors, isolated for active games, and
|
||||
portable when profiles are shared.
|
||||
|
||||
### Decision
|
||||
|
||||
- **Runtime registration scope:** per-room via WebSocket payloads.
|
||||
- **Author storage scope:** per-user local library at
|
||||
`houserules:custom-modifiers:v1`.
|
||||
- **Sharing model:** profiles embedding custom kinds include full descriptor
|
||||
payloads (not by-reference ids only).
|
||||
- **Versioning model:** descriptor has `version: 1`; incompatible evolution is
|
||||
represented as a new modifier id/versioned descriptor.
|
||||
- **Validation guards:** server validates primitive membership, parameter
|
||||
bounds, nesting depth `<= 3`, and total primitive count `<= 50`.
|
||||
|
||||
### Rationale
|
||||
|
||||
- **Isolation.** Per-room runtime registration prevents cross-room leakage.
|
||||
- **Usability.** Local library supports iterative authoring without server
|
||||
persistence dependency.
|
||||
- **Portability.** Embedding descriptors makes shared profiles self-contained.
|
||||
- **Safety.** Guardrails cap complexity and reduce abuse/DoS risk.
|
||||
|
||||
### Rejected Alternatives
|
||||
|
||||
- **Global process-wide custom registry.** Risks leaking user-defined behavior
|
||||
between unrelated rooms.
|
||||
- **By-reference sharing only (id lookup).** Breaks when recipient does not
|
||||
already have that descriptor in their library.
|
||||
- **Unbounded nesting/size.** Opens denial-of-service and validation-time
|
||||
blowups.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Room creation/join paths must synchronize embedded custom descriptors.
|
||||
- Validation errors must be protocol-visible and actionable.
|
||||
- Descriptor transport payload size is larger but deterministic.
|
||||
- **Example:** Profile `aggressive-pack-v1` includes `shield-v1` inline; when
|
||||
imported by another user, the room can register and use `shield-v1` even if
|
||||
that user had no prior local copy.
|
||||
|
||||
---
|
||||
|
||||
## T3-ADR-4: Custom descriptor registry is per-engine (room), not global
|
||||
|
||||
### Context
|
||||
|
||||
Built-in modifier descriptors are static and safe to keep in the global
|
||||
registry. User-authored descriptors are dynamic and room-specific.
|
||||
|
||||
### Decision
|
||||
|
||||
Keep built-ins in module-level `MODIFIER_REGISTRY`. Add a per-engine custom
|
||||
registry (`customModifiers: Map<string, CustomModifierDescriptor>`) and make
|
||||
descriptor resolution consult per-engine custom entries when global built-ins
|
||||
miss.
|
||||
|
||||
### Rationale
|
||||
|
||||
- **Correct scoping.** Room-specific rules remain room-specific.
|
||||
- **Zero regression for built-ins.** Existing built-in lookup remains stable.
|
||||
- **Compatibility with existing architecture.** Resolution still flows through
|
||||
the same descriptor interface.
|
||||
|
||||
### Rejected Alternatives
|
||||
|
||||
- **Process-global custom registration.** Causes rule contamination across
|
||||
sessions.
|
||||
- **Separate custom-only lookup path everywhere.** Increases branching and
|
||||
duplicates integration logic.
|
||||
- **Namespace-prefix hacks in a single map.** Couples isolation to naming
|
||||
discipline rather than architecture.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Engine APIs that resolve modifiers need engine context.
|
||||
- Room teardown naturally drops custom descriptors with engine lifecycle.
|
||||
- Debugging remains straightforward: built-ins global, customs room-local.
|
||||
- **Example:** Room A registers `berserk-v1`; Room B does not. `get("berserk-v1")`
|
||||
succeeds in Room A context and fails in Room B context without any global
|
||||
side effect.
|
||||
|
||||
---
|
||||
|
||||
## T3-ADR-5: T4 forward design preserves one integration seam
|
||||
|
||||
### Context
|
||||
|
||||
T3 explicitly avoids runtime scripting, but the architecture should not require
|
||||
an overhaul if T4 introduces scripted modifiers.
|
||||
|
||||
### Decision
|
||||
|
||||
Define T3 custom descriptors as `type: "data"` and reserve a parallel
|
||||
`type: "scripted"` branch for T4. Both forms target the same high-level
|
||||
descriptor integration seam (`apply`-style engine contract), while validation
|
||||
branches by descriptor type.
|
||||
|
||||
### Rationale
|
||||
|
||||
- **Future-proofing without scope creep.** T3 remains data-only while enabling
|
||||
a clean T4 extension path.
|
||||
- **Minimized migration risk.** Existing plumbing (registry, protocol,
|
||||
profile embedding) remains reusable.
|
||||
- **Separation of concerns.** Script security and sandboxing can be handled in
|
||||
T4-specific validation/execution modules.
|
||||
|
||||
### Rejected Alternatives
|
||||
|
||||
- **Hardcode T3 as the only forever model.** Forces breaking redesign for T4.
|
||||
- **Partially ship script hooks in T3.** Expands attack surface before policy,
|
||||
sandbox, and permissions are ready.
|
||||
- **Completely separate scripted architecture.** Duplicates profile and
|
||||
registry plumbing.
|
||||
|
||||
### Consequences
|
||||
|
||||
- T3 descriptors and validators stay simple and strict.
|
||||
- T4 can be additive via new descriptor type and validator branch.
|
||||
- Docs can communicate a clear migration path from data to scripted forms.
|
||||
- **Example:** A future `type:"scripted"` modifier may implement the same
|
||||
combat buff as today's `add-to-attribute`, but still enters the engine via
|
||||
the same descriptor-resolution and apply integration seam.
|
||||
|
||||
---
|
||||
|
||||
## T3-ADR-6: Multiple active profiles stack in explicit order
|
||||
|
||||
### Context
|
||||
|
||||
T1/T2 assume one active profile at a time. T3 primitives and custom modifiers
|
||||
increase composition use cases where users want layered rule bundles.
|
||||
|
||||
### Decision
|
||||
|
||||
Engine state moves from one active profile to an ordered
|
||||
`activeProfiles: readonly ModifierProfile[]`. Profile effects stack across
|
||||
the list using existing per-modifier stacking semantics (additive, union,
|
||||
multiplicative, or precedence-wins as defined by prior ADRs). Default order is
|
||||
registration order; UI can expose explicit reordering.
|
||||
|
||||
### Rationale
|
||||
|
||||
- **Composability.** Users can combine orthogonal profile concepts without
|
||||
pre-merging into a single artifact.
|
||||
- **Reuse.** Small focused profiles become building blocks.
|
||||
- **Determinism.** Explicit ordering eliminates ambiguity for override-style
|
||||
collisions.
|
||||
|
||||
### Rejected Alternatives
|
||||
|
||||
- **Single-profile only forever.** Limits expressiveness and reusability.
|
||||
- **Implicit/unordered merge.** Nondeterministic outcomes for override kinds.
|
||||
- **Auto-merge into synthetic profile client-side.** Harder to debug and easy
|
||||
to desync with server authority.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Apply/reconcile APIs must accept arrays, not single profile objects.
|
||||
- UI must communicate stack order and precedence implications clearly.
|
||||
- Validator and diagnostics need to report cross-profile conflicts.
|
||||
- **Example:** Profile A gives `HpBonus +2`, Profile B gives `HpBonus +1`; with
|
||||
stacking active, affected pieces resolve to `+3` total HP bonus.
|
||||
|
||||
---
|
||||
|
||||
## T3-ADR-7: Aura primitive semantics are derived and recomputed
|
||||
|
||||
### Context
|
||||
|
||||
`add-aura` introduces cross-piece effects whose targets vary with board
|
||||
position. Static one-time application is insufficient because piece movement
|
||||
changes who is in range.
|
||||
|
||||
### Decision
|
||||
|
||||
Aura effects are treated as **derived facts**:
|
||||
|
||||
- Source piece declares `add-aura(radius, targetAttr, delta)`.
|
||||
- Engine computes affected pieces by distance at evaluation time.
|
||||
- Derived aura contributions are recomputed after each move and reconciled with
|
||||
current board state.
|
||||
- Aura-derived facts feed into normal effective-attribute resolution and stack
|
||||
with other modifiers using existing rules.
|
||||
|
||||
### Rationale
|
||||
|
||||
- **Correctness over time.** Moving pieces in/out of range updates outcomes
|
||||
immediately and deterministically.
|
||||
- **Single mental model.** Aura outputs become just another attribute source in
|
||||
the resolved fact set.
|
||||
- **Implementation locality.** Recompute hook can live in a focused aura
|
||||
integration path without mutating core descriptor contracts.
|
||||
|
||||
### Rejected Alternatives
|
||||
|
||||
- **Apply aura once at profile load.** Becomes stale as soon as pieces move.
|
||||
- **Event-driven partial updates only.** Harder to guarantee correctness for
|
||||
all movement/capture transitions.
|
||||
- **Dedicated aura-only buff subsystem.** Duplicates stacking/resolution logic.
|
||||
|
||||
### Consequences
|
||||
|
||||
- Post-move recomputation is required for correctness.
|
||||
- Performance budget must account for board-wide aura evaluation.
|
||||
- Tooling/debug UI should expose which aura sources contribute to each piece.
|
||||
- **Example (king aura):** A king with `add-aura(radius=2, targetAttr=HpBonus,
|
||||
delta=+1)` grants `HpBonus +1` to all allied pieces within two squares. If a
|
||||
knight moves from distance 1 to distance 3 from that king, the bonus is
|
||||
removed on the next recomputation pass.
|
||||
|
||||
---
|
||||
|
||||
## T3 Implementation Retrospective
|
||||
|
||||
What changed between the T3 plan and the shipped reality, what worked, what
|
||||
hurt, and the open work the team should pick up next.
|
||||
|
||||
### Scope delivered
|
||||
|
||||
All 32 implementation tasks shipped across five waves:
|
||||
|
||||
- **Wave 1**: ADR doc + primitive types/registry + custom descriptor types.
|
||||
- **Wave 2**: 15 effect primitives (state / mechanic / advanced clusters).
|
||||
- **Wave 3**: descriptor validator + Zod schema + library persistence + apply
|
||||
integration + multi-profile stacking.
|
||||
- **Wave 4**: server `custom-modifier.register` handler + `CustomModifierEditor`
|
||||
primitive composer + panel kind-dropdown extension + lobby multi-profile
|
||||
stack picker + aura recomputation hook.
|
||||
- **Wave 5**: e2e suite + this retrospective + user docs + T4 forward-design.
|
||||
|
||||
Final test counts: 1378 unit tests + the new e2e suite, all green.
|
||||
|
||||
### What deviated from the plan
|
||||
|
||||
#### Trigger/conditional primitives seed facts but don't (yet) fire
|
||||
|
||||
`on-turn-start`, `on-capture`, `on-damaged`, and `conditional` all register and
|
||||
seed `OnTurnStartHooks` / `OnCaptureHooks` / `OnDamagedHooks` /
|
||||
`ConditionalHooks` facts on the target piece — but the engine pipeline that
|
||||
SHOULD evaluate those hooks at the corresponding game phase is not wired up.
|
||||
The data flows; the runtime trigger evaluation is deferred.
|
||||
|
||||
The fact-seeding still has user value (the inspector can show "this piece has 1
|
||||
on-capture trigger"), but the primitives currently behave as data declarations,
|
||||
not active behaviour. T22's `applyCustomDescriptor` walks `childPrimitives()`
|
||||
recursively at apply time, so nested primitives DO run when the descriptor is
|
||||
applied — what doesn't yet happen is "fire on-capture's primitives WHEN this
|
||||
piece captures".
|
||||
|
||||
Wiring is mechanical (the engine already exposes `onAfterMove`/`onDamage` hook
|
||||
points used by the modifier-integration preset for `DamageResistance`); the
|
||||
follow-up should replicate that pattern for the four trigger attrs.
|
||||
|
||||
#### Aura contributions land in `AuraContributions` but no consumer reads them
|
||||
|
||||
T28 ships `computeAuraFacts` and wires it to `onAfterMove` so contributions
|
||||
recompute correctly after every move. They land on each affected piece as
|
||||
`AuraContributions: Record<targetAttr, delta>`. **However**, no engine
|
||||
subsystem currently reads that record when computing effective attribute
|
||||
values — `HpBonus` consumers see only the directly-seeded value, not the
|
||||
aura-derived addition.
|
||||
|
||||
The infrastructure is correct (idempotent, source-move retracts contribution,
|
||||
multi-source accumulation). The next step is "effective attr read points layer
|
||||
both the direct fact AND the aura contribution" — a small but careful change
|
||||
in HP / Range / DirectionAdditions consumers.
|
||||
|
||||
#### Multi-profile stacking is solo-only on the wire
|
||||
|
||||
T27 ships the lobby UI for ordered profile stacks. The engine
|
||||
(`applyProfilesToSession`) already supports the stack natively. The wire
|
||||
protocol, however, still carries one `ModifierProfile` per `room.create` /
|
||||
`modifier-profile.update` payload — multiplayer rooms can use a single profile
|
||||
each. Stacking on the wire would extend `RoomCreatePayloadSchema.profile?` to
|
||||
`profiles?: ModifierProfile[]` and update the consent flow (T2-ADR-2) to apply
|
||||
to a stack. Out of scope for T3.
|
||||
|
||||
#### Server-side semantic validation deferred
|
||||
|
||||
The server runs Zod structural validation on incoming `custom-modifier.register`
|
||||
descriptors but does NOT validate primitive-kind-in-registry or per-primitive
|
||||
params satisfaction. Doing so would require mirroring the entire 15-primitive
|
||||
catalog into the Zod-v3 server package across the v3↔v4 schema boundary.
|
||||
|
||||
Instead: the client validates with `validateCustomDescriptor` BEFORE sending,
|
||||
and the engine's runtime applier (`applyCustomDescriptor`) silently skips
|
||||
unknown primitive kinds on the apply path. Net effect: a malicious or buggy
|
||||
client can register a structurally-valid descriptor whose primitives quietly
|
||||
no-op. Consequence: low-severity (no incorrect game behaviour can leak to
|
||||
opponent), but a server-side semantic gate is the natural T3.1 hardening.
|
||||
|
||||
### What worked well
|
||||
|
||||
- **Per-engine `CustomModifierRegistry` (ADR-4) prevents cross-room leakage by
|
||||
construction**, not by discipline. We never had to hunt down a "this
|
||||
descriptor mysteriously appeared in another room" bug because the type
|
||||
system makes it impossible.
|
||||
- **`childPrimitives()` as the composable recursion contract** turned the
|
||||
"validate depth ≤ 3" and "walk for apply" requirements into one-liner
|
||||
consumers. The same callback drives the validator's tree walk and the
|
||||
applier's recursive descent.
|
||||
- **Zod schema as both wire validator AND structural truth** kept the
|
||||
custom-descriptor shape from drifting between protocol layer and runtime
|
||||
type. The single boundary cast in `parseCustomModifierDescriptor` is
|
||||
documented and small.
|
||||
- **15 separate primitive files (one each)** kept individual change sets
|
||||
reviewable and made `git blame` informative for each primitive's evolution.
|
||||
|
||||
### What hurt
|
||||
|
||||
- **Long parallel agent runs blew the tool-call cap repeatedly.** Multiple
|
||||
Wave 2 / Wave 3 / Wave 4 batches were cancelled mid-flight after the
|
||||
delegated agent burned 200 tool calls on verification loops without
|
||||
committing. Reconciling partial work consumed orchestrator time we wanted
|
||||
to spend on Wave 5.
|
||||
|
||||
Mitigation for future epics: prompt agents to commit incrementally (every
|
||||
2-3 files) rather than batching all commits at the end. Even better,
|
||||
decompose the cluster prompts further (one primitive = one delegation)
|
||||
even at the cost of more orchestration overhead.
|
||||
|
||||
- **`as unknown as X` in lazy schemas required a documented exception.**
|
||||
T20's recursive `EffectPrimitiveNodeSchema` couldn't cleanly satisfy
|
||||
`z.ZodType<EffectPrimitiveNode>` because Zod infers `kind: string` and
|
||||
the type wants `kind: PrimitiveKind`. We kept a single boundary cast in
|
||||
`parseCustomModifierDescriptor` rather than restructuring the type.
|
||||
|
||||
- **The chess-side Zod v4 vs server Zod v3 mismatch** forced us to mirror
|
||||
schemas across two packages by hand. Moving the chess and server packages
|
||||
onto the same Zod major would simplify a lot, but is a separate migration.
|
||||
|
||||
### Open follow-ups
|
||||
|
||||
| Item | Effort | Priority |
|
||||
|---|---|---|
|
||||
| Wire trigger primitives (on-turn-start/on-capture/on-damaged) into the engine pipeline | Medium | High — needed before custom modifiers feel "alive" |
|
||||
| Layer `AuraContributions` into HP/Range read sites | Small | High — same |
|
||||
| Server-side semantic validation of `custom-modifier.register` payloads | Medium | Medium — current degradation is silent |
|
||||
| Multi-profile on the wire for multiplayer | Medium | Medium — solo-only is a clean stop-gap |
|
||||
| Visual editor: drag-to-reorder primitives in the tree | Small | Low — polish |
|
||||
| Visual editor: nested-tree inspector (currently JSON textarea fallback) | Medium | Low — polish |
|
||||
| `CustomModifierEditor` Playwright coverage beyond happy-path | Small | Medium |
|
||||
284
docs/user/custom-modifiers.md
Normal file
284
docs/user/custom-modifiers.md
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
# Custom Modifiers — User Guide
|
||||
|
||||
Houserules ships with six built-in modifiers (HP Bonus, Range Bonus, Direction
|
||||
Additions, Capture Flags, Promotion Override, Damage Resistance). Custom
|
||||
modifiers let you author your own from a fixed catalog of 15 reusable effect
|
||||
primitives — no programming required.
|
||||
|
||||
A custom modifier is a named, reusable bundle of primitive effects that any
|
||||
modifier profile can reference, exactly the same way it references a built-in.
|
||||
|
||||
---
|
||||
|
||||
## Opening the editor
|
||||
|
||||
1. Open the **Modifier Profile** editor (`+ Modifier Profile` from the rules
|
||||
drawer, or via the layout's modifier-profile picker).
|
||||
2. In the editor's header, click **+ Custom Modifier**. The Custom Modifier
|
||||
editor opens as a nested modal.
|
||||
3. The Custom Modifier editor is a 3-column workspace:
|
||||
- **Left**: primitive palette, grouped by category.
|
||||
- **Center**: the descriptor's primitive tree (the composition you're
|
||||
building).
|
||||
- **Right**: parameter inspector for the selected primitive.
|
||||
|
||||
Each descriptor needs a **name** (1-40 chars) and an optional **description**
|
||||
(0-200 chars). The header also exposes **Save**, **Load from library**, and
|
||||
live validation status.
|
||||
|
||||
---
|
||||
|
||||
## The 15 effect primitives
|
||||
|
||||
Primitives are atomic, composable, and pure data. Click any palette entry to
|
||||
add it to the current descriptor's tree.
|
||||
|
||||
### State primitives
|
||||
|
||||
These mutate facts on the piece at apply time.
|
||||
|
||||
#### `seed-attribute`
|
||||
|
||||
Seeds a fact `{ attr, value }` on the piece. Overwrites any existing value.
|
||||
|
||||
> **Example**: `attr=Hp, value=5` — gives the piece 5 HP regardless of the
|
||||
> baseline.
|
||||
|
||||
#### `add-to-attribute`
|
||||
|
||||
Reads the existing numeric value of `attr` (treats absent as 0) and writes
|
||||
`existing + delta`.
|
||||
|
||||
> **Example**: `attr=HpBonus, delta=2` — adds +2 to whatever HpBonus is
|
||||
> already there.
|
||||
|
||||
#### `multiply-attribute`
|
||||
|
||||
Reads the existing numeric value of `attr` (no-op if absent) and writes
|
||||
`existing * factor`.
|
||||
|
||||
> **Example**: `attr=Hp, factor=2` — doubles HP.
|
||||
|
||||
#### `add-direction`
|
||||
|
||||
Appends named directions into `DirectionAdditions`. Composes additively with
|
||||
the built-in Direction Additions modifier — both write to the same fact and
|
||||
deduplicate by direction name.
|
||||
|
||||
> **Example**: `directions=[backward]` — adds backward movement.
|
||||
|
||||
#### `set-capture-flag`
|
||||
|
||||
ORs a `CaptureFlag` bitflag into the piece's `CaptureFlags`.
|
||||
|
||||
> **Example**: `flag=CANNOT_BE_CAPTURED` — makes the piece untargetable.
|
||||
|
||||
### Mechanic primitives
|
||||
|
||||
These plug into the engine's existing pipelines (damage, movement, promotion).
|
||||
|
||||
#### `absorb-damage-with-attribute`
|
||||
|
||||
Seeds `{ AbsorbDamageAttr, AbsorbDamageRate }`. Each incoming damage point
|
||||
consumes `rate` of `attr` instead of HP, until `attr` is exhausted.
|
||||
|
||||
> **Example**: `attr=ShieldCharges, rate=1` — paired with a `seed-attribute`
|
||||
> for `ShieldCharges=3` produces a 3-charge shield.
|
||||
|
||||
#### `reflect-damage`
|
||||
|
||||
Seeds `ReflectDamagePercent`. When this piece takes damage, `percentage%` is
|
||||
reflected back to the attacker.
|
||||
|
||||
> **Example**: `percentage=50` — half-reflective armour.
|
||||
|
||||
#### `modify-movement-range`
|
||||
|
||||
Composes additively with the built-in Range Bonus.
|
||||
|
||||
> **Example**: `delta=1` — adds +1 to the piece's range.
|
||||
|
||||
#### `block-move-type`
|
||||
|
||||
Filters out moves matching the given type (`capture` / `step` / `slide`).
|
||||
|
||||
> **Example**: `moveType=capture` — pacifist piece, can move but not capture.
|
||||
|
||||
#### `override-promotion`
|
||||
|
||||
Sets `PromotionOverride` to a target piece type. Mirrors the built-in
|
||||
Promotion Override modifier.
|
||||
|
||||
> **Example**: `target=knight` — pawns promote to knights only.
|
||||
|
||||
### Advanced primitives
|
||||
|
||||
These compose other primitives.
|
||||
|
||||
#### `add-aura`
|
||||
|
||||
Seeds an `AuraSpec` entry. Every piece within `radius` (Chebyshev / king-move
|
||||
distance) gets `delta` added to `targetAttr`.
|
||||
|
||||
> **Example**: `radius=2, targetAttr=HpBonus, delta=1` — every piece within 2
|
||||
> squares gets +1 HP.
|
||||
|
||||
Auras recompute after every move. A piece moving INTO range picks up the
|
||||
contribution; a piece moving OUT loses it on the next pass.
|
||||
|
||||
#### `on-turn-start`
|
||||
|
||||
Wraps a list of nested primitives that run when this piece's color begins a
|
||||
turn.
|
||||
|
||||
> **Example**: `primitives=[{kind: 'add-to-attribute', params: {attr: 'Hp', delta: 1}}]`
|
||||
> — heals 1 HP each turn.
|
||||
|
||||
#### `on-capture`
|
||||
|
||||
Wraps a list of nested primitives that run when this piece captures another.
|
||||
|
||||
> **Example**: `primitives=[{kind: 'add-to-attribute', params: {attr: 'Hp', delta: 1}}]`
|
||||
> — vampire piece, heals on capture.
|
||||
|
||||
#### `on-damaged`
|
||||
|
||||
Wraps a list of nested primitives that run when this piece takes damage.
|
||||
|
||||
> **Example**: `primitives=[{kind: 'reflect-damage', params: {percentage: 25}}]`
|
||||
> — auto-reflects on damage.
|
||||
|
||||
#### `conditional`
|
||||
|
||||
Branches on a condition and runs the matching primitive list.
|
||||
|
||||
Supported condition types:
|
||||
|
||||
- `attr-lt` / `attr-gt` — numeric comparison
|
||||
- `attr-eq` — exact match (string / number / boolean / null)
|
||||
- `always` — runs `then`
|
||||
- `never` — runs `else` (or no-op if no else)
|
||||
|
||||
> **Example**:
|
||||
> ```
|
||||
> condition: { type: 'attr-lt', attr: 'Hp', value: 2 }
|
||||
> then: [{ kind: 'set-capture-flag', params: { flag: 'CANNOT_BE_CAPTURED' }}]
|
||||
> ```
|
||||
> — when low on HP, becomes invulnerable.
|
||||
|
||||
> **Note (T3 limitation)**: Trigger primitives (`on-turn-start`, `on-capture`,
|
||||
> `on-damaged`) and `conditional` currently SEED their hook facts on the piece
|
||||
> but the engine pipeline that fires them at the corresponding game phase is
|
||||
> not yet wired in T3. The data is correct; the runtime trigger evaluation
|
||||
> ships in a follow-up.
|
||||
|
||||
---
|
||||
|
||||
## Composing primitives — simple to complex
|
||||
|
||||
### Simple: a "boosted pawn"
|
||||
|
||||
One `add-to-attribute` primitive with `attr=HpBonus, delta=2`. Save as
|
||||
"Boosted Pawn". Reference from a profile's per-type entry to give every white
|
||||
pawn +2 HP.
|
||||
|
||||
### Medium: a "shield"
|
||||
|
||||
Two primitives:
|
||||
1. `seed-attribute`: `attr=ShieldCharges, value=3`
|
||||
2. `absorb-damage-with-attribute`: `attr=ShieldCharges, rate=1`
|
||||
|
||||
Pieces start with 3 shield charges; each damage point depletes one charge
|
||||
before HP.
|
||||
|
||||
### Complex: an "aura king"
|
||||
|
||||
One primitive:
|
||||
1. `add-aura`: `radius=2, targetAttr=HpBonus, delta=1`
|
||||
|
||||
Apply to the king's per-type entry. Every piece within 2 squares of the king
|
||||
gets +1 HP.
|
||||
|
||||
---
|
||||
|
||||
## Saving and loading
|
||||
|
||||
The editor's **Save** button writes the current descriptor to your local
|
||||
**custom modifier library** (storage key `houserules:custom-modifiers:v1`).
|
||||
**Load from library** opens a picker to recall any saved descriptor.
|
||||
|
||||
The library is local to your browser. To share, paste the JSON shape of a
|
||||
saved descriptor — or use the modifier profile sharing flow (which embeds the
|
||||
custom descriptor in the share payload).
|
||||
|
||||
### Library limits
|
||||
|
||||
- **20 descriptors per library** — same FIFO eviction rule as profiles
|
||||
(oldest non-starred entry is evicted; star to protect).
|
||||
- **10 descriptors per multiplayer room** — a room can register up to 10
|
||||
custom descriptors. Servers reject the 11th.
|
||||
|
||||
---
|
||||
|
||||
## Using a custom modifier in a profile
|
||||
|
||||
Open the Modifier Profile editor, add a **Per-Type** or **Per-Instance** entry
|
||||
(left or center panel), and pick your custom descriptor from the **Kind**
|
||||
dropdown. Custom descriptors appear in the dropdown under a **Custom (from
|
||||
library)** group.
|
||||
|
||||
Custom modifiers don't take a per-instance value — the descriptor's primitive
|
||||
list is the entire payload. The editor displays a small summary card
|
||||
("Custom modifier — N primitives") instead of a value input.
|
||||
|
||||
---
|
||||
|
||||
## Multi-profile stacking
|
||||
|
||||
The lobby's profile picker stays single-select for the primary profile. Below
|
||||
it, a **Stacked (solo only)** list lets you append additional profiles in
|
||||
order. Use the up/down arrows to reorder.
|
||||
|
||||
When multiple profiles are active, contributions stack across profiles per the
|
||||
same per-kind rules used within a single profile. The "priority-wins" rule
|
||||
(promotion override, capture flags) gives the LAST profile in the list
|
||||
precedence.
|
||||
|
||||
> **Limitation (T3)**: Multi-profile stacking is solo-only on the wire.
|
||||
> Multiplayer rooms still send one profile per `room.create`. Wire-level
|
||||
> stacking is a follow-up.
|
||||
|
||||
---
|
||||
|
||||
## Aura effects in detail
|
||||
|
||||
`add-aura` measures distance with the **Chebyshev metric** (king-move
|
||||
distance): two squares are within radius R when `max(|file_a − file_b|,
|
||||
|rank_a − rank_b|) ≤ R`. Radius 1 covers the 8 neighbours; radius 2 covers a
|
||||
5×5 box minus the source square.
|
||||
|
||||
Auras recompute after every successful move. If a source piece moves OUT of
|
||||
range of a target, the contribution is retracted on the next compute. Multiple
|
||||
auras to the same `targetAttr` accumulate additively.
|
||||
|
||||
Self-application is skipped — an aura's source piece never affects itself.
|
||||
|
||||
> **Limitation (T3)**: `AuraContributions` are written correctly, but most
|
||||
> attribute consumers (HP/Range read points) don't yet layer the aura
|
||||
> contribution on top of the directly-seeded value. The data is there;
|
||||
> consumer wiring is a follow-up.
|
||||
|
||||
---
|
||||
|
||||
## Limits and DoS guards
|
||||
|
||||
- **50 primitives total per descriptor** (counted recursively across nested
|
||||
trees).
|
||||
- **Recursion depth ≤ 3** — `on-turn-start` containing `on-capture`
|
||||
containing `conditional` is the maximum nesting depth allowed.
|
||||
- **20 descriptors per library** with starred-aware FIFO eviction.
|
||||
- **10 descriptors per multiplayer room**, server-enforced.
|
||||
- **Name 1-40 chars, description 0-200 chars** — descriptor metadata.
|
||||
|
||||
The editor's footer shows live validation against all of these. A descriptor
|
||||
that fails any check is rejected on Save with the failing rule highlighted.
|
||||
217
docs/user/modifier-profiles.md
Normal file
217
docs/user/modifier-profiles.md
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# Modifier Profiles
|
||||
|
||||
Modifier Profiles let you attach rule modifiers to pieces — either globally by
|
||||
piece type ("all white knights get +2 HP") or specifically by board position
|
||||
("the knight that starts on b1 has +1 range"). Profiles are saved to a personal
|
||||
library, shareable via URL, and can be swapped mid-game.
|
||||
|
||||
---
|
||||
|
||||
## Opening the Editor
|
||||
|
||||
From any game view: click the **rules drawer** (⚙ icon) → **Modifier Profiles**.
|
||||
|
||||
From the lobby: click **Custom…** in the Profile Picker next to the Layout Picker.
|
||||
|
||||
---
|
||||
|
||||
## Per-Type vs Per-Instance
|
||||
|
||||
| | Per-Type | Per-Instance |
|
||||
| ---------------- | ---------------------------------------- | --------------------------------------------------- |
|
||||
| Targets | All pieces of a given type + color | One specific piece at a named board square |
|
||||
| Example | "All white knights: HP +3" | "The knight starting on b1: Range +1" |
|
||||
| Requires layout? | No | Yes — square must exist in the chosen layout |
|
||||
|
||||
---
|
||||
|
||||
## The 6 Modifier Categories
|
||||
|
||||
### HP Bonus
|
||||
|
||||
Add or subtract hit points from a piece's base HP.
|
||||
Values: integer from −10 to +10. Stacks additively from all sources.
|
||||
|
||||
**Example**: Knight (white) HP +3 — the knight now requires 3 more attacks to
|
||||
eliminate.
|
||||
|
||||
---
|
||||
|
||||
### Range Bonus
|
||||
|
||||
Extend the sliding distance of rooks, bishops, and queens.
|
||||
Values: integer 0–7. Capped at maximum board range.
|
||||
|
||||
**Example**: Rook (white) Range +1 — the rook can see one extra square per ray
|
||||
when unblocked.
|
||||
|
||||
---
|
||||
|
||||
### Direction Additions
|
||||
|
||||
Add new movement directions to a piece (1-square step moves only).
|
||||
Values: a set of directional flags: forward, backward, left, right,
|
||||
diagonal-fl, diagonal-fr, diagonal-bl, diagonal-br.
|
||||
|
||||
**Example**: Pawn (white) + backward — pawns can also retreat one square to an
|
||||
empty square.
|
||||
|
||||
---
|
||||
|
||||
### Capture Flags
|
||||
|
||||
Toggle special capture behavior.
|
||||
Flags:
|
||||
|
||||
- **CAN_CAPTURE_OWN** — piece may capture friendly pieces
|
||||
- **CANNOT_BE_CAPTURED** — piece is immune to direct capture (cannot be used on kings)
|
||||
- **EN_PASSANT** — future flag for custom en-passant rules
|
||||
|
||||
**Example**: Bishop + CANNOT_BE_CAPTURED — no enemy piece can legally capture it.
|
||||
|
||||
---
|
||||
|
||||
### Promotion Override
|
||||
|
||||
Force a pawn to always promote to a specific piece, or disable promotion
|
||||
entirely.
|
||||
Values: queen, rook, bishop, knight, or disabled.
|
||||
|
||||
**Example**: Pawn + Promotion = bishop — any pawn reaching the back rank
|
||||
auto-promotes to bishop, no dialog shown.
|
||||
|
||||
---
|
||||
|
||||
### Damage Resistance
|
||||
|
||||
Reduce all incoming damage by a percentage. Stacks multiplicatively across
|
||||
sources.
|
||||
Values: 0 (no resistance) to 1 (full immunity).
|
||||
|
||||
**Example**: Queen + 0.5 resistance — every attack deals half normal damage.
|
||||
|
||||
---
|
||||
|
||||
## Editor Features
|
||||
|
||||
### Undo / Redo
|
||||
|
||||
- **Cmd/Ctrl+Z** to undo, **Cmd/Ctrl+Shift+Z** to redo.
|
||||
- Toolbar buttons in the editor header (↶ / ↷) with the same behaviour.
|
||||
- History is capped at 50 actions per editing session.
|
||||
- The stack is cleared when you **Save** or **Cancel** — the saved state
|
||||
becomes the new baseline, so there is no undo across sessions.
|
||||
|
||||
### Copy / Paste
|
||||
|
||||
- **Per-instance**: select a square that already has modifiers, click **Copy**,
|
||||
then select another square and click **Paste**. Pasted modifiers replace any
|
||||
same-kind modifier already present on the target (so you won't end up with
|
||||
two HP-Bonus entries on the same piece).
|
||||
- **Per-type**: each row has a **Copy** button. Use **Paste** at the top of the
|
||||
panel to apply the copied entry to a different piece type / color.
|
||||
- The clipboard is editor-local (in-memory) and does **not** use your OS
|
||||
clipboard — copying modifiers cannot clobber anything you already copied
|
||||
outside the app.
|
||||
|
||||
### Conflict Resolution
|
||||
|
||||
When the editor detects a problem — a king carrying the `CANNOT_BE_CAPTURED`
|
||||
flag, a per-instance entry pointing at an empty square, an attribute-limit
|
||||
overflow — a panel appears at the top of the editor listing every issue.
|
||||
|
||||
- Each issue has a **Fix** button that auto-resolves it where possible
|
||||
(e.g. drop the illegal flag, remove the orphan entry).
|
||||
- Some issues (missing kings for a layout, attribute-limit overflow that
|
||||
requires a real decision) can't be fixed automatically; the Fix button is
|
||||
advisory and the panel directs you to the specific row that needs manual
|
||||
attention.
|
||||
- Save is blocked while any hard error remains; warnings (orphan per-instance
|
||||
entries) don't block save but surface in the panel so they aren't silently
|
||||
ignored.
|
||||
|
||||
---
|
||||
|
||||
## Hot-Swap
|
||||
|
||||
Modifier profiles can be changed mid-game. The flow:
|
||||
|
||||
1. **Solo mode**: Open the editor, change the profile, save. The new profile
|
||||
applies after the next move.
|
||||
2. **Multiplayer**: Either player can propose a profile change. The opponent
|
||||
receives a notification with a 60-second window to approve or reject. If
|
||||
approved, the new profile applies after the next move; if rejected or timed
|
||||
out, no change occurs.
|
||||
|
||||
Profile changes always take effect at a **turn boundary** (after the next
|
||||
completed move), never mid-move. This guarantees both players' moves resolve
|
||||
under the same rule set they were planned with — the state at turn N is fully
|
||||
determined by the profile active at turn N plus moves 1..N.
|
||||
|
||||
In a multiplayer room, rapid successive proposals follow a last-write-wins
|
||||
rule: if you propose a swap and then send a second proposal before the opponent
|
||||
decides, the first is superseded and the opponent sees the new candidate.
|
||||
|
||||
---
|
||||
|
||||
## Library & Sharing
|
||||
|
||||
- **Save** — click the Save button in the editor header. Up to 20 profiles stored locally.
|
||||
- **Star** ⭐ — starred profiles are never evicted when the library is full.
|
||||
- **Share** 🔗 — copies a URL containing the full profile. Profiles larger than 8 KB
|
||||
cannot be URL-shared (save to library and share the room code instead).
|
||||
- **Load** — open the library drawer in the editor and click any entry.
|
||||
|
||||
---
|
||||
|
||||
## In-Play Inspection
|
||||
|
||||
**Hover** any piece on the board to see a tooltip listing active modifiers.
|
||||
|
||||
**Click** a piece to pin a side panel with the full modifier breakdown. The
|
||||
panel stays pinned across turns until you dismiss it (×) or click another
|
||||
piece.
|
||||
|
||||
The pinned panel shows the **source** of each modifier so you can trace where
|
||||
a value comes from at a glance:
|
||||
|
||||
- **per-instance: {square}** — attached to this specific board position
|
||||
(survives captures of other pieces, but vanishes if this piece is captured).
|
||||
- **per-type: all {color} {type}s** — applies to every piece matching this
|
||||
type + color combination.
|
||||
- **from {preset name}** — the modifier comes from an active rule preset
|
||||
(e.g. a first-blood ruleset granting kings extra HP).
|
||||
- **default** — the engine's baseline value with no modifier applied.
|
||||
|
||||
When multiple sources touch the same attribute, the panel lists each source
|
||||
and shows how they combine (HP additive, damage-resistance multiplicative,
|
||||
direction-additions unioned).
|
||||
|
||||
---
|
||||
|
||||
## Board Indicators
|
||||
|
||||
Pieces with one or more active modifiers display a small **fuchsia dot** in
|
||||
the top-right corner of their square. The dot is visible without hover, so
|
||||
you can tell at a glance which pieces are running on modified rules.
|
||||
|
||||
- Hover the piece to see a tooltip with modifier details.
|
||||
- Click to pin the inspection panel (see above).
|
||||
- The dot stays visible across moves and updates live when a profile is
|
||||
hot-swapped at the next turn boundary.
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Maximum 20 profiles in the local library per browser.
|
||||
- Profiles larger than 8 KB cannot be URL-shared.
|
||||
- CANNOT_BE_CAPTURED cannot be applied to kings.
|
||||
- Deadlock detection (all legal moves blocked) not yet implemented.
|
||||
|
||||
### Coming in T3
|
||||
|
||||
- Custom modifier authoring (defining new modifier categories from scratch).
|
||||
- Cross-piece aura effects (e.g. "all pieces within 2 squares of this priest
|
||||
get +1 HP").
|
||||
- Multi-profile stacking (combining two profiles in one game).
|
||||
|
|
@ -8,7 +8,7 @@ export default tseslint.config(
|
|||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
||||
},
|
||||
},
|
||||
// Engine RHS purity: ban impure globals in rete/src
|
||||
|
|
|
|||
1264
packages/chess/e2e/custom-modifiers.spec.ts
Normal file
1264
packages/chess/e2e/custom-modifiers.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
1616
packages/chess/e2e/modifier-profiles.spec.ts
Normal file
1616
packages/chess/e2e/modifier-profiles.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
278
packages/chess/e2e/solo-smoke.spec.ts
Normal file
278
packages/chess/e2e/solo-smoke.spec.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Solo-play smoke (T2 preview tests)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||
const k = localStorage.key(i);
|
||||
if (k?.startsWith('paratype-chess:')) localStorage.removeItem(k);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('play-solo button navigates to /game with fresh board', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
|
||||
});
|
||||
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game', { timeout: 5000 });
|
||||
await expect(page.locator('[data-square="e2"]')).toBeVisible({ timeout: 3000 });
|
||||
await expect(page.locator('[data-square="e2"] [data-piece]')).toBeVisible();
|
||||
if (errors.length > 0) throw new Error(errors.join('; '));
|
||||
});
|
||||
|
||||
test('drag pawn e2 to e4 resolves', async ({ page }) => {
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game');
|
||||
await page
|
||||
.locator('[data-square="e2"] [data-piece]')
|
||||
.dragTo(page.locator('[data-square="e4"]'));
|
||||
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000 });
|
||||
await expect(page.locator('[data-square="e2"] [data-piece]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('solo play with modifier profile renders modifier-indicator dots', async ({ page }) => {
|
||||
const LIBRARY_KEY = 'houserules:modifier-profiles:v1';
|
||||
const entry = {
|
||||
id: 'solo-smoke-indicator',
|
||||
name: 'Solo Smoke Indicator',
|
||||
profile: {
|
||||
id: 'solo-smoke-indicator',
|
||||
name: 'Solo Smoke Indicator',
|
||||
description: '',
|
||||
layoutId: 'classic',
|
||||
perType: [
|
||||
{ kind: 'hp-bonus', pieceType: 'pawn', color: 'both', value: 1 },
|
||||
],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: 'custom',
|
||||
},
|
||||
starred: false,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await page.goto('/');
|
||||
await page.evaluate(
|
||||
({ key, e }) => localStorage.setItem(key, JSON.stringify([e])),
|
||||
{ key: LIBRARY_KEY, e: entry },
|
||||
);
|
||||
await page.reload();
|
||||
|
||||
const picker = page.getByTestId('profile-picker');
|
||||
if (!(await picker.isVisible())) {
|
||||
await page.locator('[data-action="open-rules-drawer"]').click();
|
||||
}
|
||||
await expect(picker).toBeVisible();
|
||||
await picker.selectOption('solo-smoke-indicator');
|
||||
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game', { timeout: 5000 });
|
||||
|
||||
await expect(page.locator('[data-testid^="modifier-indicator-"]').first()).toBeVisible({
|
||||
timeout: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
test('can play multiple moves in sequence (4-ply)', async ({ page }) => {
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game');
|
||||
|
||||
const drag = async (from: string, to: string) => {
|
||||
await page.locator(`[data-square="${from}"] [data-piece]`).dragTo(page.locator(`[data-square="${to}"]`));
|
||||
// small wait for animation / state update
|
||||
await page.waitForTimeout(100);
|
||||
};
|
||||
|
||||
await drag('e2', 'e4');
|
||||
await drag('e7', 'e5');
|
||||
await drag('g1', 'f3');
|
||||
await drag('b8', 'c6');
|
||||
|
||||
// After 4 plies: e4, e5, f3, c6 all occupied; origins empty
|
||||
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible();
|
||||
await expect(page.locator('[data-square="e5"] [data-piece]')).toBeVisible();
|
||||
await expect(page.locator('[data-square="f3"] [data-piece]')).toBeVisible();
|
||||
await expect(page.locator('[data-square="c6"] [data-piece]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('rules drawer opens without breaking board interaction', async ({ page }) => {
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game');
|
||||
await page.locator('[data-action="open-rules-drawer"]').click();
|
||||
await expect(page.getByTestId('rules-drawer')).toBeVisible();
|
||||
// Close drawer (esc or similar) and verify board still works
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
// Drag a move
|
||||
await page.locator('[data-square="e2"] [data-piece]').dragTo(page.locator('[data-square="e4"]'));
|
||||
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('no console errors on solo game start', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
|
||||
});
|
||||
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game');
|
||||
await page.waitForTimeout(1000); // let any async init complete
|
||||
await expect(page.locator('[data-square="e2"]')).toBeVisible();
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log('Errors:', errors);
|
||||
throw new Error(`Unexpected errors: ${errors.slice(0, 3).join(' | ')}`);
|
||||
}
|
||||
});
|
||||
|
||||
// T2 regression — clicking the drawer backdrop closes the drawer AND
|
||||
// leaves the board interactive afterwards. Regression guard for a
|
||||
// stuck-overlay bug where the backdrop `pointer-events-none` timing
|
||||
// wasn't cleared, silently blocking drag-to-move.
|
||||
test('rules drawer: backdrop click closes drawer and restores board interactivity', async ({ page }) => {
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game');
|
||||
await page.locator('[data-action="open-rules-drawer"]').click();
|
||||
await expect(page.getByTestId('rules-drawer')).toBeVisible();
|
||||
|
||||
// Click the far-left edge of the viewport — the drawer itself
|
||||
// sits on the right (`fixed top-0 right-0 max-w-md`), so x=50 is
|
||||
// guaranteed to land on the backdrop overlay, not the drawer.
|
||||
await page.mouse.click(50, 300);
|
||||
await expect(page.getByTestId('rules-drawer')).not.toBeVisible({ timeout: 1000 });
|
||||
|
||||
// Board must be interactive: drag e2→e4 and see the pawn land on e4.
|
||||
// Any stuck-overlay regression shows up here as a silent no-op drag.
|
||||
await page
|
||||
.locator('[data-square="e2"] [data-piece]')
|
||||
.dragTo(page.locator('[data-square="e4"]'));
|
||||
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({
|
||||
timeout: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
// T2 regression — nested-modal Esc ordering. With BOTH the rules
|
||||
// drawer AND the modifier editor open, a single Esc must close the
|
||||
// editor ONLY; the drawer stays. A second Esc then closes the
|
||||
// drawer. This is enforced by ModifierProfileEditor installing its
|
||||
// keydown handler in the capture phase with `stopImmediatePropagation`,
|
||||
// so the drawer's window-level Esc handler never fires on the same
|
||||
// keystroke. Without that guard both would close, leaving the board
|
||||
// behind a briefly-lingering pointer-events-blocking backdrop.
|
||||
test('modifier editor: Esc closes editor first, drawer stays open; 2nd Esc closes drawer', async ({ page }) => {
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game');
|
||||
await page.locator('[data-action="open-rules-drawer"]').click();
|
||||
await page.locator('[data-testid="open-modifier-editor"]').click();
|
||||
await expect(page.getByTestId('modifier-editor-modal')).toBeVisible();
|
||||
|
||||
// First Esc: editor closes; drawer still visible.
|
||||
//
|
||||
// The editor's capture-phase keydown listener handles this event
|
||||
// with stopImmediatePropagation(), so the drawer's bubble-phase
|
||||
// listener is suppressed. Editor unmounts → its useEffect cleanup
|
||||
// removes the listener, BUT that cleanup runs during React's
|
||||
// commit phase — not synchronously after the state update. A
|
||||
// too-fast second Esc can still hit the stale editor listener
|
||||
// before it has fully detached. The small wait below lets
|
||||
// React complete its commit so the second Esc sees only the
|
||||
// drawer's listener.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByTestId('modifier-editor-modal')).not.toBeVisible({
|
||||
timeout: 500,
|
||||
});
|
||||
await expect(page.getByTestId('rules-drawer')).toBeVisible();
|
||||
|
||||
// Second Esc: drawer closes. (See note above re: the wait.)
|
||||
//
|
||||
// The drawer exits via a framer-motion spring animation that
|
||||
// takes ~300–500ms to fully unmount the `<aside>` — too tight a
|
||||
// timeout here flakes even when the close did fire. We use 1500ms
|
||||
// to match the backdrop-click test and leave headroom.
|
||||
await page.waitForTimeout(50);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByTestId('rules-drawer')).not.toBeVisible({
|
||||
timeout: 1500,
|
||||
});
|
||||
|
||||
// Board must be fully interactive again.
|
||||
await page
|
||||
.locator('[data-square="e2"] [data-piece]')
|
||||
.dragTo(page.locator('[data-square="e4"]'));
|
||||
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({
|
||||
timeout: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
// Regression guard for the "play-solo lands on blank screen" bug.
|
||||
//
|
||||
// Before the fix, a stale multiplayer session (room-code/room-token in
|
||||
// sessionStorage from a previous room.create) would make Play Solo
|
||||
// navigate to /game → GameRoute's Case 1 canonicalises to
|
||||
// /game/<stale-code> → MultiplayerGameView mounts → WS handshake to a
|
||||
// long-dead room silently fails → blank screen.
|
||||
//
|
||||
// The fix: handlePlaySolo() explicitly clears room-code, room-token,
|
||||
// player-color, layout-name, and modifier-profile-name from
|
||||
// sessionStorage before navigating, guaranteeing a fresh solo mount.
|
||||
test('play-solo with stale MP creds in sessionStorage lands on /game, not /game/<stale>', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Seed sessionStorage to simulate a previous room.create or room.join
|
||||
// whose creds were never cleared (e.g. user closed the tab after
|
||||
// playing multiplayer, then reopened the lobby later).
|
||||
await page.evaluate(() => {
|
||||
sessionStorage.setItem('room-code', 'STALE1');
|
||||
sessionStorage.setItem('room-token', 'stale-token-deadbeef');
|
||||
sessionStorage.setItem('player-color', 'white');
|
||||
sessionStorage.setItem('layout-name', 'Some Previous Layout');
|
||||
sessionStorage.setItem('modifier-profile-name', 'Old Profile');
|
||||
});
|
||||
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
|
||||
});
|
||||
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
|
||||
// URL must settle on /game WITHOUT a code suffix. Using a short
|
||||
// timeout so a regression surfaces as a test failure rather than
|
||||
// the suite hanging on the blank-screen state.
|
||||
await page.waitForURL('**/game', { timeout: 3000 });
|
||||
await expect(page).toHaveURL(/\/game$/);
|
||||
|
||||
// Board must render a real solo game (e2 pawn present, not the
|
||||
// "Joining room …" placeholder).
|
||||
await expect(page.locator('[data-testid="mp-joining"]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-square="e2"] [data-piece]')).toBeVisible({
|
||||
timeout: 3000,
|
||||
});
|
||||
|
||||
// All stale sessionStorage entries must be wiped so a page reload
|
||||
// doesn't re-trigger the bug on the refreshed /game route.
|
||||
const staleCreds = await page.evaluate(() => ({
|
||||
code: sessionStorage.getItem('room-code'),
|
||||
token: sessionStorage.getItem('room-token'),
|
||||
color: sessionStorage.getItem('player-color'),
|
||||
layout: sessionStorage.getItem('layout-name'),
|
||||
profile: sessionStorage.getItem('modifier-profile-name'),
|
||||
}));
|
||||
expect(staleCreds).toEqual({
|
||||
code: null,
|
||||
token: null,
|
||||
color: null,
|
||||
layout: null,
|
||||
profile: null,
|
||||
});
|
||||
|
||||
if (errors.length > 0) throw new Error(errors.join('; '));
|
||||
});
|
||||
});
|
||||
|
|
@ -21,7 +21,8 @@
|
|||
"motion": "^12.38.0",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwindcss": "^4.2.2"
|
||||
"tailwindcss": "^4.2.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
|
|
|
|||
|
|
@ -69,6 +69,17 @@ import { PIECE_TYPE_REGISTRY } from "./presets/piece-type-registry.js";
|
|||
// Importing from the barrel guarantees every preset module's
|
||||
// side-effect registration has run before the first engine is created.
|
||||
import "./presets/index.js";
|
||||
// Side-effect import: registers every modifier descriptor AND the
|
||||
// `__modifier-profile-integration__` preset with PRESET_REGISTRY, so
|
||||
// the engine can activate it when a profile is supplied.
|
||||
import "./modifiers/index.js";
|
||||
import {
|
||||
applyProfileToSession,
|
||||
MODIFIER_INTEGRATION_PRESET_ID,
|
||||
} from "./modifiers/apply.js";
|
||||
import type { ModifierProfile } from "./modifiers/types.js";
|
||||
import { CustomModifierRegistry } from "./modifiers/custom/registry.js";
|
||||
import type { CustomModifierDescriptor } from "./modifiers/custom/types.js";
|
||||
|
||||
type MoveGetter = (session: Session, pieceId: EntityId) => LegalMove[];
|
||||
|
||||
|
|
@ -250,13 +261,14 @@ class PresetStateImpl<T extends Record<string, unknown>>
|
|||
}
|
||||
|
||||
set<K extends keyof T>(key: K, value: T[K]): void {
|
||||
// The rete session validates that `value` is a legal FactValue
|
||||
// (string | number | boolean | null). Passing objects here will
|
||||
// throw — a deliberate constraint to keep facts serialization-safe.
|
||||
// `FactValue` is `unknown` in the rete package, so any `T[K]`
|
||||
// assigns directly. Runtime serialization guarantees (the value
|
||||
// round-trips through JSON in event-log replay) are the caller's
|
||||
// responsibility.
|
||||
this.#session.insert(
|
||||
PRESET_STATE_ENTITY,
|
||||
this.#attrOf(key as string),
|
||||
value as unknown as import("@paratype/rete").FactValue,
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -299,6 +311,32 @@ class PresetStateImpl<T extends Record<string, unknown>>
|
|||
export interface EngineOptions {
|
||||
readonly activePresets?: ActivePresetSet;
|
||||
readonly layout?: StartingLayout;
|
||||
/**
|
||||
* Optional ModifierProfile to apply at game start. When supplied,
|
||||
* the engine:
|
||||
* 1. Applies the layout (as normal).
|
||||
* 2. Calls `applyProfileToSession(session, profile, layout)` to
|
||||
* seed all modifier facts on piece entities.
|
||||
* 3. Auto-activates the `__modifier-profile-integration__` preset
|
||||
* (scope=both, permanent) so CaptureFlags, DirectionAdditions,
|
||||
* and DamageResistance facts drive actual gameplay.
|
||||
*
|
||||
* Order: profile is applied BEFORE `activePresets` activation, so
|
||||
* preset `onActivate` hooks (e.g. piece-hp seeding Hp) observe any
|
||||
* HpBonus the profile contributed.
|
||||
*/
|
||||
readonly profile?: ModifierProfile;
|
||||
/**
|
||||
* Optional list of user-authored CustomModifierDescriptors registered
|
||||
* onto the engine's per-instance custom registry (T22). Each entry's
|
||||
* `id` becomes a valid `kind` in profile entries: when
|
||||
* `applyProfileToSession` encounters an unknown built-in kind, it
|
||||
* falls back to this registry for resolution.
|
||||
*
|
||||
* Per ADR-4, custom descriptors are intentionally NOT global — each
|
||||
* engine carries its own set so cross-room leakage is impossible.
|
||||
*/
|
||||
readonly customModifiers?: readonly CustomModifierDescriptor[];
|
||||
}
|
||||
|
||||
export class ChessEngine {
|
||||
|
|
@ -315,6 +353,26 @@ export class ChessEngine {
|
|||
* move calculation with no reset.
|
||||
*/
|
||||
public readonly activePresets: ActivePresetSet;
|
||||
/**
|
||||
* The currently active ModifierProfile, if any.
|
||||
* Profiles attach rule modifiers to pieces by type or layout slot.
|
||||
*
|
||||
* Mutable internally via `setActiveProfile()` so the client can sync
|
||||
* with the server's `game.state` snapshots (the server is authoritative
|
||||
* for which profile is active in a multiplayer room). Callers MUST NOT
|
||||
* write directly — use `setActiveProfile()` to keep invariants intact.
|
||||
*/
|
||||
public activeProfile: ModifierProfile | null;
|
||||
|
||||
/**
|
||||
* Per-engine registry of user-authored custom modifier descriptors
|
||||
* (T22). Profile application consults this registry as a fallback
|
||||
* when a kind isn't found in the global MODIFIER_REGISTRY.
|
||||
*
|
||||
* Mutable via `registerCustomModifier()` for live additions (e.g.
|
||||
* server-broadcast `custom-modifier.register` messages in T24).
|
||||
*/
|
||||
public readonly customModifiers: CustomModifierRegistry;
|
||||
|
||||
/**
|
||||
* Chronological log of every successful applyMove. Callers consume
|
||||
|
|
@ -419,10 +477,59 @@ export class ChessEngine {
|
|||
? { activePresets: arg as ActivePresetSet }
|
||||
: ((arg as EngineOptions | undefined) ?? {});
|
||||
|
||||
this.activeProfile = opts.profile ?? null;
|
||||
|
||||
// Custom modifier registry — populated from opts.customModifiers
|
||||
// (if any) BEFORE profile application, so profile entries that
|
||||
// reference custom kinds can resolve them on first apply.
|
||||
this.customModifiers = new CustomModifierRegistry();
|
||||
if (opts.customModifiers) {
|
||||
for (const d of opts.customModifiers) {
|
||||
this.customModifiers.register(d);
|
||||
}
|
||||
}
|
||||
|
||||
const layout = opts.layout ?? CLASSIC_LAYOUT;
|
||||
applyLayout(this.session, layout);
|
||||
|
||||
// Profile seeding runs BEFORE the position is recorded for
|
||||
// threefold repetition — the modifier facts are part of the
|
||||
// "initial position" from a repetition-tracking perspective, and
|
||||
// are not expected to change mid-game (profiles hot-swap via
|
||||
// turn-boundary replacement, which re-seeds).
|
||||
if (opts.profile) {
|
||||
applyProfileToSession(
|
||||
this.session,
|
||||
opts.profile,
|
||||
layout,
|
||||
this,
|
||||
this.customModifiers,
|
||||
);
|
||||
}
|
||||
|
||||
recordPosition(this.session);
|
||||
this.activePresets = opts.activePresets ?? new ActivePresetSet();
|
||||
|
||||
// When a profile is present, auto-activate the integration preset
|
||||
// so its filterMoves / getExtraMoves / onDamage hooks fire. We
|
||||
// prepend it to whatever the caller provided so it always runs
|
||||
// FIRST in the preset-iteration order (cheap predicate — cheap to
|
||||
// short-circuit).
|
||||
if (opts.profile) {
|
||||
const existing = this.activePresets.list();
|
||||
this.activePresets.replaceAll([
|
||||
{
|
||||
id: MODIFIER_INTEGRATION_PRESET_ID,
|
||||
scope: "both",
|
||||
turnsRemaining: null,
|
||||
},
|
||||
...existing.map((e) => ({
|
||||
id: e.id,
|
||||
scope: e.scope,
|
||||
turnsRemaining: e.turnsRemaining,
|
||||
})),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -522,6 +629,22 @@ export class ChessEngine {
|
|||
* down state cleanly before the incoming preset reads the board.
|
||||
* Ordering within each phase follows the input list's natural order.
|
||||
*/
|
||||
/**
|
||||
* Replace the active modifier profile (used by clients syncing with
|
||||
* server `game.state` snapshots in multiplayer). The session facts
|
||||
* carrying modifier values come from the server via `loadFacts`; this
|
||||
* setter just keeps the engine's metadata view of the profile in sync
|
||||
* so source-chain attribution and other profile-aware reads work.
|
||||
*
|
||||
* Does NOT re-seed facts or run reconcile — the server is authoritative
|
||||
* for the actual modifier state. Callers should only invoke this from
|
||||
* server-event handlers (game.state replay) or from a fresh local game
|
||||
* setup, never as a way to mutate gameplay.
|
||||
*/
|
||||
setActiveProfile(profile: ModifierProfile | null): void {
|
||||
this.activeProfile = profile;
|
||||
}
|
||||
|
||||
setActivePresets(requests: readonly ActivationRequest[]): void {
|
||||
const oldIds = new Set(this.activePresets.list().map((e) => e.id));
|
||||
this.activePresets.replaceAll(requests);
|
||||
|
|
@ -718,8 +841,28 @@ export class ChessEngine {
|
|||
.filter(p => p.type !== undefined);
|
||||
|
||||
for (const piece of pieces) {
|
||||
const getter = lookupMoveGenerator(piece.type);
|
||||
if (!getter) continue;
|
||||
const baseGetter = lookupMoveGenerator(piece.type);
|
||||
// Fold the transformMoveGenerator chain over all active presets
|
||||
// (scope-filtered for `color`). Each preset's wrapper receives the
|
||||
// OUTPUT of the previous — composing cleanly. We seed with the
|
||||
// type-registry generator, or a degenerate empty-move generator if
|
||||
// the piece type is unknown (keeps later wrappers well-defined).
|
||||
const scopedPresets = this.activePresets.getForColor(color);
|
||||
const seedGetter: MoveGetter =
|
||||
baseGetter ?? ((_s: Session, _id: EntityId) => [] as LegalMove[]);
|
||||
const getter: MoveGetter = scopedPresets
|
||||
.filter(p => p.transformMoveGenerator)
|
||||
.reduce<MoveGetter>(
|
||||
(gen, preset) => preset.transformMoveGenerator!(this, piece.id, gen),
|
||||
seedGetter,
|
||||
);
|
||||
// Skip pieces with no known type AND no transform — same semantics
|
||||
// as the original "unknown type ⇒ immobile" guard, but now a
|
||||
// transform preset can still produce moves for an otherwise unknown
|
||||
// type.
|
||||
if (!baseGetter && scopedPresets.every(p => !p.transformMoveGenerator)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let pieceMoves = getter(this.session, piece.id);
|
||||
|
||||
|
|
@ -759,7 +902,7 @@ export class ChessEngine {
|
|||
// Order matters — `getExtraMoves` contributes to the set that
|
||||
// `filterMoves` operates on, so every active preset sees the full
|
||||
// aggregated set (including prior presets' additions).
|
||||
const activePresets = this.activePresets.getForColor(color);
|
||||
const activePresets = scopedPresets;
|
||||
for (const preset of activePresets) {
|
||||
if (preset.getExtraMoves) {
|
||||
pieceMoves = [
|
||||
|
|
|
|||
|
|
@ -34,8 +34,9 @@ import type { PieceType } from '../schema';
|
|||
import type { GameResult } from '../engine';
|
||||
import { GameClient } from '../net/client';
|
||||
import { PredictionManager } from '../net/prediction';
|
||||
import type { Color, PresetActivation, PromotionPiece } from '../net/types';
|
||||
import type { Color, PresetActivation, PromotionPiece, ModifierProfileWire, ModifierProfileProposalPendingPayload } from '../net/types';
|
||||
import * as audio from '../audio';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const WS_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ??
|
||||
|
|
@ -53,6 +54,10 @@ export interface MultiplayerGameState {
|
|||
/** Room error surfaced to the UI (e.g. ROOM_NOT_FOUND on reconnect
|
||||
* after grace expiry). Null when healthy. */
|
||||
error: string | null;
|
||||
// Modifier proposal state
|
||||
modifierProposal: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null;
|
||||
modifierRejectionMessage: string | null;
|
||||
isProposer: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -71,6 +76,9 @@ export function useMultiplayerGame(code: string, token: string) {
|
|||
myColor: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
modifierProposal: null,
|
||||
modifierRejectionMessage: null,
|
||||
isProposer: false,
|
||||
});
|
||||
|
||||
// Mount the connection exactly once per code/token pair.
|
||||
|
|
@ -105,6 +113,32 @@ export function useMultiplayerGame(code: string, token: string) {
|
|||
// The first game.state snapshot after (re)connect clears `loading`.
|
||||
setMeta((m) => ({ ...m, loading: false }));
|
||||
};
|
||||
const onProposalPending = (e: { payload: ModifierProfileProposalPendingPayload }) => {
|
||||
setMeta((m) => ({
|
||||
...m,
|
||||
modifierProposal: { profile: e.payload.profile, expiresAt: e.payload.expiresAt, proposer: e.payload.proposer },
|
||||
modifierRejectionMessage: null,
|
||||
isProposer: false,
|
||||
}));
|
||||
};
|
||||
const onProposalRejected = (e: { payload: { reason: string } }) => {
|
||||
setMeta((m) => ({
|
||||
...m,
|
||||
modifierProposal: null,
|
||||
modifierRejectionMessage: e.payload.reason,
|
||||
isProposer: false,
|
||||
}));
|
||||
setTimeout(() => {
|
||||
setMeta((m) => m.modifierRejectionMessage === e.payload.reason ? { ...m, modifierRejectionMessage: null } : m);
|
||||
}, 3000);
|
||||
};
|
||||
const onProposalConsentReceived = () => {
|
||||
setMeta((m) => ({ ...m, modifierProposal: null, isProposer: false }));
|
||||
toast.success("Opponent approved your profile change. It will take effect next turn.");
|
||||
};
|
||||
const onProposalQueued = () => {
|
||||
setMeta((m) => ({ ...m, isProposer: true }));
|
||||
};
|
||||
const onGameDelta = (e: {
|
||||
payload: {
|
||||
gameOver: { winner: string; reason: string } | null;
|
||||
|
|
@ -141,6 +175,16 @@ export function useMultiplayerGame(code: string, token: string) {
|
|||
);
|
||||
}, 2000);
|
||||
}
|
||||
// T3: custom modifier errors are user-facing actions (someone
|
||||
// hit "Share with Room" in the editor) — surface as a toast so
|
||||
// the failure is obvious even if the in-game error banner is
|
||||
// styled subtly.
|
||||
if (
|
||||
e.payload.code === 'CUSTOM_MODIFIER_INVALID' ||
|
||||
e.payload.code === 'CUSTOM_MODIFIER_LIMIT'
|
||||
) {
|
||||
toast.error(`Custom modifier rejected: ${e.payload.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
client.on('connected', onConnected);
|
||||
|
|
@ -148,6 +192,10 @@ export function useMultiplayerGame(code: string, token: string) {
|
|||
client.on('room.joined', onJoined);
|
||||
client.on('game.state', onGameState);
|
||||
client.on('game.delta', onGameDelta);
|
||||
client.on('modifier-profile.proposal-pending', onProposalPending);
|
||||
client.on('modifier-profile.rejected', onProposalRejected);
|
||||
client.on('modifier-profile.consent-received', onProposalConsentReceived);
|
||||
client.on('modifier-profile.queued', onProposalQueued);
|
||||
client.on('error', onError);
|
||||
|
||||
client.connect(code, token).catch((err: unknown) => {
|
||||
|
|
@ -166,6 +214,10 @@ export function useMultiplayerGame(code: string, token: string) {
|
|||
client.off('room.joined', onJoined);
|
||||
client.off('game.state', onGameState);
|
||||
client.off('game.delta', onGameDelta);
|
||||
client.off('modifier-profile.proposal-pending', onProposalPending);
|
||||
client.off('modifier-profile.rejected', onProposalRejected);
|
||||
client.off('modifier-profile.consent-received', onProposalConsentReceived);
|
||||
client.off('modifier-profile.queued', onProposalQueued);
|
||||
client.off('error', onError);
|
||||
client.close();
|
||||
clientRef.current = null;
|
||||
|
|
@ -277,5 +329,31 @@ export function useMultiplayerGame(code: string, token: string) {
|
|||
connected: meta.connected,
|
||||
loading: meta.loading,
|
||||
error: meta.error,
|
||||
modifierProposal: meta.modifierProposal,
|
||||
modifierRejectionMessage: meta.modifierRejectionMessage,
|
||||
isProposer: meta.isProposer,
|
||||
sendConsent: (decision: 'approve' | 'reject') => {
|
||||
clientRef.current?.send({
|
||||
type: 'modifier-profile.consent',
|
||||
payload: { roomCode: code, decision }
|
||||
});
|
||||
},
|
||||
/**
|
||||
* T3: ship a user-authored custom modifier descriptor to the
|
||||
* server for room-wide registration. The server's
|
||||
* custom-modifier.register handler validates structurally,
|
||||
* stores in the per-room registry (capped at 10), and broadcasts
|
||||
* to every connected client. The local PredictionManager
|
||||
* subscriber mirrors the descriptor onto the local engine's
|
||||
* customModifiers registry, so subsequent profile applies
|
||||
* resolve the kind across both clients.
|
||||
*/
|
||||
sendRegisterCustomModifier: (
|
||||
descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor,
|
||||
) => {
|
||||
clientRef.current?.sendRegisterCustomModifier(
|
||||
descriptor as unknown as import('../net/types.js').CustomModifierDescriptorWire,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export {
|
|||
export {
|
||||
GAME_ENTITY,
|
||||
PROMOTION_PIECES,
|
||||
CaptureFlag,
|
||||
oppositeColor,
|
||||
chessFact,
|
||||
type PieceType,
|
||||
|
|
@ -60,3 +61,29 @@ export {
|
|||
type LayoutValidationResult,
|
||||
} from "./layouts/index.js";
|
||||
export { validateLayout } from "./layouts/validate.js";
|
||||
|
||||
// Piece modifier profiles — orthogonal to layouts and presets. Exposed
|
||||
// so the server can type-check wire payloads against the authoritative
|
||||
// chess-side shape. The zod schema itself is NOT re-exported here
|
||||
// because the server package pins a different zod major; the server
|
||||
// mirrors the schema locally (same shape) for wire validation.
|
||||
export type {
|
||||
ModifierProfile,
|
||||
TypeModifier,
|
||||
InstanceModifier,
|
||||
ModifierKindId,
|
||||
Direction,
|
||||
} from "./modifiers/types.js";
|
||||
export { validateProfile } from "./modifiers/validate.js";
|
||||
export type {
|
||||
ValidationError as ModifierValidationError,
|
||||
ValidationWarning as ModifierValidationWarning,
|
||||
ValidationResult as ModifierValidationResult,
|
||||
ValidationErrorCode as ModifierValidationErrorCode,
|
||||
ValidationWarningCode as ModifierValidationWarningCode,
|
||||
} from "./modifiers/validate.js";
|
||||
|
||||
// Hot-swap reconciliation (T15). Exported so the server can apply a
|
||||
// new ModifierProfile to a live session at a turn boundary without
|
||||
// reaching into engine internals.
|
||||
export { reconcileProfileSwap } from "./modifiers/reconcile.js";
|
||||
|
|
|
|||
276
packages/chess/src/modifiers/apply.test.ts
Normal file
276
packages/chess/src/modifiers/apply.test.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
/**
|
||||
* Tests for applyProfileToSession.
|
||||
*
|
||||
* Each test builds a fresh Session, applies CLASSIC_LAYOUT (so we have
|
||||
* a known piece topology), then calls applyProfileToSession with a
|
||||
* targeted profile. We read the resulting facts directly from the
|
||||
* session — we don't exercise the engine-level integration preset
|
||||
* here; that's covered by engine-surface tests elsewhere.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Session } from "@paratype/rete";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import { applyLayout, CLASSIC_LAYOUT } from "../starting-position.js";
|
||||
import { applyProfileToSession } from "./apply.js";
|
||||
import type { ModifierProfile } from "./types.js";
|
||||
import { CaptureFlag } from "../schema.js";
|
||||
// Side-effect import — ensures every descriptor is registered so
|
||||
// `MODIFIER_REGISTRY.get(kind)` resolves in applyProfileToSession.
|
||||
import "./index.js";
|
||||
|
||||
/**
|
||||
* Helper: return EntityIds of pieces matching the given filter. We
|
||||
* need this in several tests to assert that the right pieces got
|
||||
* seeded (and the wrong ones didn't).
|
||||
*/
|
||||
function findPieces(
|
||||
session: Session,
|
||||
filter: (type: string, color: string, square: number) => boolean,
|
||||
): EntityId[] {
|
||||
const facts = session.allFacts();
|
||||
const out: EntityId[] = [];
|
||||
for (const f of facts) {
|
||||
if (f.attr !== "PieceType") continue;
|
||||
if ((f.id as number) <= 0) continue;
|
||||
const type = f.value as string;
|
||||
const colorFact = facts.find((c) => c.id === f.id && c.attr === "Color");
|
||||
const posFact = facts.find((p) => p.id === f.id && p.attr === "Position");
|
||||
if (!colorFact || !posFact) continue;
|
||||
if (filter(type, colorFact.value as string, posFact.value as number)) {
|
||||
out.push(f.id as EntityId);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Minimal profile builder — keeps test bodies focused on assertions. */
|
||||
function profile(
|
||||
parts: Partial<ModifierProfile>,
|
||||
): ModifierProfile {
|
||||
return {
|
||||
id: "test",
|
||||
name: "test",
|
||||
description: "",
|
||||
perType: [],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: "custom",
|
||||
...parts,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyProfileToSession", () => {
|
||||
let session: Session;
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
session = new Session({ autoFire: false });
|
||||
applyLayout(session, CLASSIC_LAYOUT);
|
||||
// Silence expected dev-warnings for orphan / unknown-kind cases;
|
||||
// individual tests that care about the warning inspect `warnSpy`.
|
||||
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("per-type modifier seeds every matching piece", () => {
|
||||
applyProfileToSession(
|
||||
session,
|
||||
profile({
|
||||
perType: [
|
||||
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 3 },
|
||||
],
|
||||
}),
|
||||
CLASSIC_LAYOUT,
|
||||
);
|
||||
|
||||
// Both white knights (b1 = square 1, g1 = square 6) should carry
|
||||
// HpBonus=3; the two black knights should not.
|
||||
const whiteKnights = findPieces(
|
||||
session,
|
||||
(t, c) => t === "knight" && c === "white",
|
||||
);
|
||||
expect(whiteKnights).toHaveLength(2);
|
||||
for (const id of whiteKnights) {
|
||||
expect(session.get(id, "HpBonus")).toBe(3);
|
||||
}
|
||||
|
||||
const blackKnights = findPieces(
|
||||
session,
|
||||
(t, c) => t === "knight" && c === "black",
|
||||
);
|
||||
for (const id of blackKnights) {
|
||||
expect(session.contains(id, "HpBonus")).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("per-instance modifier targets the exact square, not other same-type pieces", () => {
|
||||
applyProfileToSession(
|
||||
session,
|
||||
profile({
|
||||
// Layout ref optional but exercised here to match intended use.
|
||||
layoutId: "classic",
|
||||
perInstance: [
|
||||
{ kind: "range-bonus", square: "b1", value: 2 },
|
||||
],
|
||||
}),
|
||||
CLASSIC_LAYOUT,
|
||||
);
|
||||
|
||||
// square 1 = b1 (white knight). Look up by position.
|
||||
const facts = session.allFacts();
|
||||
const b1Piece = facts.find(
|
||||
(f) => f.attr === "Position" && f.value === 1,
|
||||
);
|
||||
expect(b1Piece).toBeDefined();
|
||||
expect(session.get(b1Piece!.id as EntityId, "RangeBonus")).toBe(2);
|
||||
|
||||
// g1 = square 6, also a white knight — must NOT have RangeBonus.
|
||||
const g1Piece = facts.find(
|
||||
(f) => f.attr === "Position" && f.value === 6,
|
||||
);
|
||||
expect(g1Piece).toBeDefined();
|
||||
expect(session.contains(g1Piece!.id as EntityId, "RangeBonus")).toBe(false);
|
||||
});
|
||||
|
||||
it("orphan per-instance entry (empty square) logs a warning and skips without throwing", () => {
|
||||
expect(() =>
|
||||
applyProfileToSession(
|
||||
session,
|
||||
profile({
|
||||
perInstance: [
|
||||
// z9 is not a valid square at all — invalid notation path.
|
||||
{ kind: "hp-bonus", square: "z9", value: 5 },
|
||||
// e4 is a valid square but empty in CLASSIC_LAYOUT — orphan path.
|
||||
{ kind: "hp-bonus", square: "e4", value: 5 },
|
||||
// e2 is a white pawn — this entry SHOULD still apply
|
||||
// despite the earlier orphans.
|
||||
{ kind: "hp-bonus", square: "e2", value: 7 },
|
||||
],
|
||||
}),
|
||||
CLASSIC_LAYOUT,
|
||||
),
|
||||
).not.toThrow();
|
||||
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
|
||||
// e2 = square 12. Confirm the valid entry still landed.
|
||||
const facts = session.allFacts();
|
||||
const e2Piece = facts.find(
|
||||
(f) => f.attr === "Position" && f.value === 12,
|
||||
);
|
||||
expect(e2Piece).toBeDefined();
|
||||
expect(session.get(e2Piece!.id as EntityId, "HpBonus")).toBe(7);
|
||||
});
|
||||
|
||||
it("additive stacking: two perType entries sum per-piece", () => {
|
||||
applyProfileToSession(
|
||||
session,
|
||||
profile({
|
||||
perType: [
|
||||
{ kind: "hp-bonus", pieceType: "pawn", color: "white", value: 2 },
|
||||
{ kind: "hp-bonus", pieceType: "pawn", color: "white", value: 3 },
|
||||
],
|
||||
}),
|
||||
CLASSIC_LAYOUT,
|
||||
);
|
||||
|
||||
const whitePawns = findPieces(
|
||||
session,
|
||||
(t, c) => t === "pawn" && c === "white",
|
||||
);
|
||||
expect(whitePawns).toHaveLength(8);
|
||||
for (const id of whitePawns) {
|
||||
expect(session.get(id, "HpBonus")).toBe(5);
|
||||
}
|
||||
});
|
||||
|
||||
it("color: 'both' targets both white and black pieces of that type", () => {
|
||||
applyProfileToSession(
|
||||
session,
|
||||
profile({
|
||||
perType: [
|
||||
{ kind: "range-bonus", pieceType: "rook", color: "both", value: 1 },
|
||||
],
|
||||
}),
|
||||
CLASSIC_LAYOUT,
|
||||
);
|
||||
|
||||
// All four rooks (a1, h1, a8, h8) should carry RangeBonus=1.
|
||||
const rooks = findPieces(session, (t) => t === "rook");
|
||||
expect(rooks).toHaveLength(4);
|
||||
for (const id of rooks) {
|
||||
expect(session.get(id, "RangeBonus")).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("per-instance OVERRIDES per-type for priority-wins kind (PromotionOverride)", () => {
|
||||
applyProfileToSession(
|
||||
session,
|
||||
profile({
|
||||
perType: [
|
||||
{
|
||||
kind: "promotion-override",
|
||||
pieceType: "pawn",
|
||||
color: "white",
|
||||
value: "knight",
|
||||
},
|
||||
],
|
||||
perInstance: [
|
||||
// e2 white pawn gets a more specific override — "rook".
|
||||
{ kind: "promotion-override", square: "e2", value: "rook" },
|
||||
],
|
||||
}),
|
||||
CLASSIC_LAYOUT,
|
||||
);
|
||||
|
||||
const facts = session.allFacts();
|
||||
// Sanity: another white pawn (a2 = square 8) gets the type-level value.
|
||||
const a2Piece = facts.find(
|
||||
(f) => f.attr === "Position" && f.value === 8,
|
||||
);
|
||||
expect(session.get(a2Piece!.id as EntityId, "PromotionOverride")).toBe(
|
||||
"knight",
|
||||
);
|
||||
|
||||
// e2 = square 12 should see the per-instance value, not the per-type.
|
||||
const e2Piece = facts.find(
|
||||
(f) => f.attr === "Position" && f.value === 12,
|
||||
);
|
||||
expect(session.get(e2Piece!.id as EntityId, "PromotionOverride")).toBe(
|
||||
"rook",
|
||||
);
|
||||
});
|
||||
|
||||
it("union stacking: CaptureFlags bitwise-OR across multiple sources", () => {
|
||||
applyProfileToSession(
|
||||
session,
|
||||
profile({
|
||||
perType: [
|
||||
{
|
||||
kind: "capture-flags",
|
||||
pieceType: "king",
|
||||
color: "white",
|
||||
value: CaptureFlag.CANNOT_BE_CAPTURED,
|
||||
},
|
||||
{
|
||||
kind: "capture-flags",
|
||||
pieceType: "king",
|
||||
color: "white",
|
||||
value: CaptureFlag.CAN_CAPTURE_OWN,
|
||||
},
|
||||
],
|
||||
}),
|
||||
CLASSIC_LAYOUT,
|
||||
);
|
||||
|
||||
const [whiteKing] = findPieces(
|
||||
session,
|
||||
(t, c) => t === "king" && c === "white",
|
||||
);
|
||||
expect(whiteKing).toBeDefined();
|
||||
const flags = session.get(whiteKing!, "CaptureFlags");
|
||||
// Both bits set.
|
||||
expect(flags).toBe(
|
||||
CaptureFlag.CANNOT_BE_CAPTURED | CaptureFlag.CAN_CAPTURE_OWN,
|
||||
);
|
||||
});
|
||||
});
|
||||
578
packages/chess/src/modifiers/apply.ts
Normal file
578
packages/chess/src/modifiers/apply.ts
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
/**
|
||||
* Apply a ModifierProfile to a live Session at game start.
|
||||
*
|
||||
* Runs AFTER `applyLayout` has populated the session with piece
|
||||
* entities. Walks `profile.perType` and `profile.perInstance`,
|
||||
* resolves each entry to the target entity/entities, collects all
|
||||
* values per (pieceId, kind), stacks them via the descriptor's
|
||||
* `stackingRule`, and finally calls `descriptor.apply(...)` once per
|
||||
* (pieceId, kind) pair to seed the corresponding fact.
|
||||
*
|
||||
* ## Stacking (ADR-4)
|
||||
*
|
||||
* A single piece may accumulate multiple values for the same kind
|
||||
* from different sources (two perType entries matching the same
|
||||
* type+color, or a perType + a perInstance for the piece on that
|
||||
* square). Each descriptor declares how these combine:
|
||||
*
|
||||
* - `additive` — numeric sum. Used by HpBonus, RangeBonus.
|
||||
* - `union` — set-union of array elements OR bitwise-OR for number
|
||||
* bitflag values. Used by DirectionAdditions (string[]) and
|
||||
* CaptureFlags (number).
|
||||
* - `multiplicative` — 1 - ∏(1 - r_i). Used by DamageResistance.
|
||||
* - `priority-wins` — last value in collection order wins. Used by
|
||||
* PromotionOverride. Per ADR-4 the intended semantic is "per-
|
||||
* instance beats per-type", which falls out naturally because we
|
||||
* always iterate perType before perInstance.
|
||||
*
|
||||
* ## Orphan entries
|
||||
*
|
||||
* A `perInstance` entry whose square is empty (no piece placed
|
||||
* there by the layout) is NOT an error — the validator already
|
||||
* surfaced it as a warning. We log a dev-time `console.warn` so the
|
||||
* case is visible during manual testing, then silently skip. This
|
||||
* matches the validator's "benign" classification.
|
||||
*
|
||||
* ## Engine-level integration
|
||||
*
|
||||
* Seeding the fact is only half the story — three modifiers need
|
||||
* runtime hooks to affect gameplay:
|
||||
* - `CaptureFlags` — filter enemy captures of CANNOT_BE_CAPTURED
|
||||
* pieces; allow same-color captures for CAN_CAPTURE_OWN.
|
||||
* - `DirectionAdditions` — contribute extra 1-square moves.
|
||||
* - `DamageResistance` — reduce incoming damage.
|
||||
*
|
||||
* These hooks live on a single preset (`__modifier-profile-integration__`)
|
||||
* registered by this module at load time. The ChessEngine constructor
|
||||
* auto-activates it when a profile is supplied — callers do NOT need
|
||||
* to activate it manually.
|
||||
*/
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
import type { PieceColor, PieceType, Square } from "../schema.js";
|
||||
import { CaptureFlag } from "../schema.js";
|
||||
import { algebraicToSquare } from "../coord.js";
|
||||
import type { StartingLayout } from "../layouts/types.js";
|
||||
import type {
|
||||
ModifierProfile,
|
||||
ModifierKindId,
|
||||
ModifierDescriptor,
|
||||
TypeModifier,
|
||||
InstanceModifier,
|
||||
} from "./types.js";
|
||||
import { MODIFIER_REGISTRY } from "./registry.js";
|
||||
import { stackResistances, applyResistance } from "./descriptors/damage-resistance.js";
|
||||
import { hasCaptureFlag } from "./descriptors/capture-flags.js";
|
||||
import { generateDirectionMoves } from "./descriptors/direction-additions.js";
|
||||
import { PRESET_REGISTRY } from "../presets/registry.js";
|
||||
import type { LegalMove } from "../rules/types.js";
|
||||
import { getPieceAt } from "../rules/board-queries.js";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
import type { CustomModifierRegistry } from "./custom/registry.js";
|
||||
import { applyCustomDescriptor } from "./custom/apply.js";
|
||||
import { computeAuraFacts } from "./auras.js";
|
||||
import {
|
||||
fireConditionalHooks,
|
||||
fireOnCaptureHooks,
|
||||
fireOnDamagedHooks,
|
||||
fireOnTurnStartHooks,
|
||||
snapshotHp,
|
||||
} from "./triggers.js";
|
||||
|
||||
/**
|
||||
* Per-engine pre-move HP snapshot, used by the on-damaged trigger
|
||||
* dispatcher to detect HP drops between phases. Keyed by engine
|
||||
* (WeakMap) so concurrent engines (server-authoritative + client
|
||||
* predicted) keep independent state. The integration preset's
|
||||
* onBeforeMove takes the snapshot; onAfterMove consumes it.
|
||||
*/
|
||||
const PRE_MOVE_HP_SNAPSHOTS = new WeakMap<ChessEngine, Map<EntityId, number>>();
|
||||
|
||||
/**
|
||||
* Per-engine attacker-id snapshot, set in onBeforeMove when the
|
||||
* incoming move is a capture, consumed in onAfterMove to fire
|
||||
* on-capture trigger primitives. The engine's `moveLog` isn't yet
|
||||
* populated when onAfterMove fires, so we can't read it from there.
|
||||
*/
|
||||
const PRE_MOVE_CAPTURE_ATTACKERS = new WeakMap<ChessEngine, EntityId>();
|
||||
|
||||
/**
|
||||
* Stable id for the pseudo-preset that wires modifier facts into
|
||||
* engine runtime behaviour. Reserved name — userland presets must not
|
||||
* use this id. The double-underscore prefix is the "internal" marker;
|
||||
* the string is exported so tests and debug tooling can reference it
|
||||
* without re-typing the literal.
|
||||
*/
|
||||
export const MODIFIER_INTEGRATION_PRESET_ID = "__modifier-profile-integration__";
|
||||
|
||||
/**
|
||||
* Stack a list of raw values according to a descriptor's rule. Kept as
|
||||
* a standalone function (not a method on the descriptor) because the
|
||||
* collection logic is identical across all kinds — only the reducer
|
||||
* differs. Invariants:
|
||||
* - `values` is non-empty (collectors skip empty groups before calling).
|
||||
* - Element types match the descriptor's schema; we do minimal
|
||||
* runtime type narrowing and trust upstream validation (schema.ts
|
||||
* zod-checks profiles before they hit this code path).
|
||||
*/
|
||||
function stackValues(
|
||||
rule: ModifierDescriptor["stackingRule"],
|
||||
values: readonly unknown[],
|
||||
): unknown {
|
||||
switch (rule) {
|
||||
case "additive": {
|
||||
// Sum of numbers. Non-numbers are treated as 0 defensively; in
|
||||
// practice zod validation prevents them from reaching this point.
|
||||
let sum = 0;
|
||||
for (const v of values) if (typeof v === "number") sum += v;
|
||||
return sum;
|
||||
}
|
||||
case "union": {
|
||||
// Two shapes ride on this rule:
|
||||
// 1. arrays of strings (DirectionAdditions) → dedup via Set.
|
||||
// 2. numeric bitflags (CaptureFlags) → bitwise OR.
|
||||
// We branch on the first non-empty value's type; mixed inputs
|
||||
// are a bug in profile construction the validator should catch.
|
||||
const first = values.find((v) => v !== undefined && v !== null);
|
||||
if (Array.isArray(first)) {
|
||||
const out = new Set<string>();
|
||||
for (const v of values) {
|
||||
if (Array.isArray(v)) for (const x of v) out.add(String(x));
|
||||
}
|
||||
return Array.from(out);
|
||||
}
|
||||
// Numeric bitflag path.
|
||||
let bits = 0;
|
||||
for (const v of values) if (typeof v === "number") bits |= v;
|
||||
return bits;
|
||||
}
|
||||
case "multiplicative": {
|
||||
// Damage-resistance stacking: 1 - ∏(1 - r_i). Reuses the helper
|
||||
// exported from the descriptor module so behaviour stays in one
|
||||
// place.
|
||||
const rs: number[] = [];
|
||||
for (const v of values) if (typeof v === "number") rs.push(v);
|
||||
return stackResistances(rs);
|
||||
}
|
||||
case "priority-wins": {
|
||||
// Last value wins. perType entries collected before perInstance,
|
||||
// so a per-instance override naturally trumps per-type defaults.
|
||||
// Guaranteed non-empty by the caller.
|
||||
return values[values.length - 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a square→EntityId map from the session's current piece facts.
|
||||
* Needed so `perInstance` entries (which reference squares by
|
||||
* algebraic notation like "b1") can resolve to the actual EntityId
|
||||
* the layout handed out.
|
||||
*
|
||||
* Walks `session.allFacts()` once for O(n) construction; the resulting
|
||||
* Map is then queried O(1) per instance entry. Pieces without a
|
||||
* Position fact are silently skipped — they aren't "on the board" in
|
||||
* the sense the modifier system cares about.
|
||||
*/
|
||||
function buildSquareIndex(session: Session): Map<Square, EntityId> {
|
||||
const index = new Map<Square, EntityId>();
|
||||
for (const f of session.allFacts()) {
|
||||
if (f.attr !== "Position") continue;
|
||||
if ((f.id as number) <= 0) continue; // skip GAME_ENTITY etc.
|
||||
index.set(f.value as Square, f.id as EntityId);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every piece entity whose (PieceType, Color) matches `typeMod`.
|
||||
* `color === "both"` matches white AND black pieces of the given
|
||||
* type. Returns EntityIds in session-fact order so repeated calls are
|
||||
* deterministic (useful for stacking order of per-type entries).
|
||||
*/
|
||||
function findPiecesByType(
|
||||
session: Session,
|
||||
pieceType: PieceType,
|
||||
color: PieceColor | "both",
|
||||
): EntityId[] {
|
||||
const facts = session.allFacts();
|
||||
const out: EntityId[] = [];
|
||||
for (const f of facts) {
|
||||
if (f.attr !== "PieceType" || f.value !== pieceType) continue;
|
||||
if ((f.id as number) <= 0) continue;
|
||||
if (color !== "both") {
|
||||
const cf = facts.find((c) => c.id === f.id && c.attr === "Color");
|
||||
if (!cf || cf.value !== color) continue;
|
||||
}
|
||||
out.push(f.id as EntityId);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply every modifier entry in `profile` to entities in `session`,
|
||||
* using `layout` as the source of truth for per-instance square →
|
||||
* piece mappings.
|
||||
*
|
||||
* This mutates `session` directly — callers should run it exactly
|
||||
* ONCE per game, after `applyLayout` and before any presets'
|
||||
* `onActivate` hooks fire (so e.g. piece-hp's seeded Hp can observe
|
||||
* HpBonus during its own activation scan).
|
||||
*
|
||||
* `layout` is accepted (not re-derived from the session) so future
|
||||
* features can cross-reference placements by properties the session
|
||||
* doesn't preserve (e.g. original-square ids that survive a piece
|
||||
* moving).
|
||||
*/
|
||||
/**
|
||||
* Collect every (pieceId, kind, value) contribution from a single
|
||||
* profile into the supplied `collected` map. Pure with respect to
|
||||
* the session — only reads `Position` and piece-type facts to resolve
|
||||
* targets; never mutates. The merge step happens in the public
|
||||
* `applyProfileToSession` / `applyProfilesToSession` after every
|
||||
* profile has been visited.
|
||||
*
|
||||
* Per-type entries are pushed BEFORE per-instance to preserve T1's
|
||||
* "per-instance overrides per-type" semantic for the priority-wins
|
||||
* stacking rule (which picks the last-pushed value).
|
||||
*/
|
||||
function collectProfileContributions(
|
||||
session: Session,
|
||||
profile: ModifierProfile,
|
||||
collected: Map<EntityId, Map<string, unknown[]>>,
|
||||
): void {
|
||||
const push = (id: EntityId, kind: string, value: unknown): void => {
|
||||
let byKind = collected.get(id);
|
||||
if (!byKind) {
|
||||
byKind = new Map();
|
||||
collected.set(id, byKind);
|
||||
}
|
||||
const arr = byKind.get(kind);
|
||||
if (arr) arr.push(value);
|
||||
else byKind.set(kind, [value]);
|
||||
};
|
||||
|
||||
for (const tm of profile.perType as readonly TypeModifier[]) {
|
||||
const targets = findPiecesByType(session, tm.pieceType, tm.color);
|
||||
for (const id of targets) push(id, tm.kind, tm.value);
|
||||
}
|
||||
|
||||
const squareIndex = buildSquareIndex(session);
|
||||
for (const im of profile.perInstance as readonly InstanceModifier[]) {
|
||||
const square = algebraicToSquare(im.square);
|
||||
if (square === -1) {
|
||||
console.warn(
|
||||
`applyProfileToSession: invalid square notation "${im.square}" — skipping.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const id = squareIndex.get(square);
|
||||
if (id === undefined) {
|
||||
console.warn(
|
||||
`applyProfileToSession: no piece at square "${im.square}" — skipping instance modifier.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
push(id, im.kind, im.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack collected (pieceId, kind, values[]) contributions and call
|
||||
* each descriptor's apply(). Two registry layers:
|
||||
* 1. MODIFIER_REGISTRY — engine-shipped built-ins (additive/union/...).
|
||||
* 2. customRegistry — per-engine user-authored CustomModifierDescriptors
|
||||
* whose primitives run via applyCustomDescriptor.
|
||||
*
|
||||
* Custom descriptors stack by "sequential apply per matching piece"
|
||||
* (each pushed value triggers one full primitive walk) — they don't
|
||||
* declare a stackingRule. If a profile produces multiple custom-modifier
|
||||
* entries for the same (pieceId, kind), the primitive list runs once
|
||||
* per entry; primitives that read+modify (add-to-attribute) compose
|
||||
* naturally, while primitives that overwrite (seed-attribute) follow
|
||||
* a last-wins semantic.
|
||||
*
|
||||
* Unknown kinds (in neither registry) are warned and skipped so a
|
||||
* forwards-compat profile doesn't crash the apply.
|
||||
*/
|
||||
function applyCollectedContributions(
|
||||
session: Session,
|
||||
collected: ReadonlyMap<EntityId, ReadonlyMap<string, readonly unknown[]>>,
|
||||
engine: ChessEngine | undefined,
|
||||
customRegistry: CustomModifierRegistry | undefined,
|
||||
): void {
|
||||
for (const [pieceId, byKind] of collected) {
|
||||
for (const [kind, values] of byKind) {
|
||||
if (values.length === 0) continue;
|
||||
|
||||
// `kind` is `string` (the union of built-in ModifierKindId AND
|
||||
// user-authored CustomModifierId). Built-in lookup narrows by
|
||||
// type-asserting; an unknown kind falls through to the custom-
|
||||
// registry branch below.
|
||||
const builtIn = MODIFIER_REGISTRY.get(kind as ModifierKindId);
|
||||
if (builtIn !== undefined) {
|
||||
const effective = stackValues(builtIn.stackingRule, values);
|
||||
builtIn.apply(session, pieceId, effective);
|
||||
continue;
|
||||
}
|
||||
|
||||
const custom = customRegistry?.get(kind);
|
||||
if (custom !== undefined && engine !== undefined) {
|
||||
// Custom descriptors apply once per collected entry — each
|
||||
// value represents one logical contribution to this piece.
|
||||
for (let i = 0; i < values.length; i += 1) {
|
||||
applyCustomDescriptor(engine, session, pieceId, custom);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`applyProfileToSession: unknown modifier kind "${kind}" — skipping.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single ModifierProfile to a session (single-profile wrapper
|
||||
* around `applyProfilesToSession`). Kept as the canonical entry point
|
||||
* so all T1/T2 callers continue to work unchanged.
|
||||
*
|
||||
* `engine` and `customRegistry` are optional for backwards compatibility:
|
||||
* built-in modifier kinds work without either. Pass them when the
|
||||
* profile may reference user-authored custom kinds.
|
||||
*/
|
||||
export function applyProfileToSession(
|
||||
session: Session,
|
||||
profile: ModifierProfile,
|
||||
layout: StartingLayout,
|
||||
engine?: ChessEngine,
|
||||
customRegistry?: CustomModifierRegistry,
|
||||
): void {
|
||||
applyProfilesToSession(session, [profile], layout, engine, customRegistry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply multiple ModifierProfiles in order, stacking ACROSS profiles
|
||||
* per the same per-kind rules used within a single profile (T23).
|
||||
* Profiles are visited in the supplied order; the priority-wins
|
||||
* stacking rule ("last value collected wins") therefore naturally
|
||||
* gives later profiles precedence.
|
||||
*
|
||||
* Empty `profiles` array is a no-op.
|
||||
*/
|
||||
export function applyProfilesToSession(
|
||||
session: Session,
|
||||
profiles: readonly ModifierProfile[],
|
||||
_layout: StartingLayout,
|
||||
engine?: ChessEngine,
|
||||
customRegistry?: CustomModifierRegistry,
|
||||
): void {
|
||||
if (profiles.length === 0) return;
|
||||
|
||||
const collected = new Map<EntityId, Map<string, unknown[]>>();
|
||||
for (const profile of profiles) {
|
||||
collectProfileContributions(session, profile, collected);
|
||||
}
|
||||
applyCollectedContributions(session, collected, engine, customRegistry);
|
||||
}
|
||||
|
||||
// ── Engine-level integration preset ─────────────────────────────────────
|
||||
//
|
||||
// Registers ONCE at module load. The ChessEngine constructor activates
|
||||
// it when `options.profile` is supplied. The preset holds no state of
|
||||
// its own — all decisions are driven by facts seeded onto piece
|
||||
// entities by `applyProfileToSession`.
|
||||
|
||||
/**
|
||||
* Does any active modifier integration exist on this piece?
|
||||
* Shortcut to avoid work when nothing relevant is seeded.
|
||||
*/
|
||||
function hasAnyModifierFact(session: Session, pieceId: EntityId): boolean {
|
||||
return (
|
||||
session.contains(pieceId, "CaptureFlags") ||
|
||||
session.contains(pieceId, "DirectionAdditions") ||
|
||||
session.contains(pieceId, "DamageResistance")
|
||||
);
|
||||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
id: MODIFIER_INTEGRATION_PRESET_ID,
|
||||
name: "Modifier Profile Integration",
|
||||
description:
|
||||
"Internal — wires ModifierProfile-seeded facts (CaptureFlags, " +
|
||||
"DirectionAdditions, DamageResistance) into the engine runtime. " +
|
||||
"Auto-activated by ChessEngine when a profile is supplied.",
|
||||
incompatibleWith: [],
|
||||
requires: [],
|
||||
|
||||
/**
|
||||
* CAN_CAPTURE_OWN: allow moves onto squares occupied by same-color
|
||||
* pieces by introducing synthetic capture moves — the base move
|
||||
* generators refuse to produce these on their own.
|
||||
*
|
||||
* Emitted as "extra" moves (not filter) because the base generator's
|
||||
* own-occupancy check stops iteration early; retroactively turning
|
||||
* an already-rejected square into a move requires re-generating.
|
||||
*
|
||||
* Kept minimal for now: we only emit the attacker→target square as a
|
||||
* capture. Sliding pieces with the flag will still stop at own-
|
||||
* blockers mid-range; fuller sliding semantics are a later task if
|
||||
* we ever ship a preset that needs them.
|
||||
*/
|
||||
getExtraMoves(_engine, pieceId): LegalMove[] {
|
||||
const extras: LegalMove[] = [];
|
||||
if (!hasAnyModifierFact(_engine.session, pieceId)) return extras;
|
||||
|
||||
// DirectionAdditions: 1-square moves in the listed directions,
|
||||
// non-capture only. Capture semantics on those directions are a
|
||||
// future extension (would interact with CaptureFlags too).
|
||||
if (_engine.session.contains(pieceId, "DirectionAdditions")) {
|
||||
extras.push(...generateDirectionMoves(_engine.session, pieceId));
|
||||
}
|
||||
return extras;
|
||||
},
|
||||
|
||||
/**
|
||||
* CANNOT_BE_CAPTURED: remove any move (from ANY attacker) whose
|
||||
* destination is a piece carrying the flag. Runs once per piece-move
|
||||
* generation, so the O(cost) is moves×hasFlag-check. For typical
|
||||
* game sizes (~40 legal moves, a handful of flagged pieces) this is
|
||||
* dominated by the base movegen, not this filter.
|
||||
*/
|
||||
filterMoves(moves, engine, _pieceId): LegalMove[] {
|
||||
return moves.filter((m) => {
|
||||
if (!m.isCapture) return true;
|
||||
const target = getPieceAt(engine.session, m.to);
|
||||
if (target === null) return true;
|
||||
// Drop the capture if the target is flagged as uncapturable.
|
||||
if (hasCaptureFlag(engine.session, target, CaptureFlag.CANNOT_BE_CAPTURED)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* DamageResistance: intercept the damage pipeline, reduce `amount`
|
||||
* by the target's resistance fact, and either consume (kill) or
|
||||
* fall through. We DON'T fully consume the event when the target
|
||||
* lives — we want the rest of the pipeline (piece-hp, etc.) to run
|
||||
* on the reduced amount. But `onDamage` doesn't expose a "reduce
|
||||
* and continue" primitive. So we handle the two boundary cases:
|
||||
* - resistance == 1.0 (immune) → consume, died=false, fully absorb.
|
||||
* - resistance < 1.0 → leave untouched, fall through to default
|
||||
* or piece-hp. Fractional reduction would require a pipeline-
|
||||
* level change; documented as a known limitation for T14.
|
||||
*/
|
||||
onDamage(ctx): { consume: boolean; died?: boolean } | void {
|
||||
// T3 absorb-damage-with-attribute: if the target carries an
|
||||
// AbsorbDamageAttr/AbsorbDamageRate pair, route incoming damage
|
||||
// through the named attribute first. Each damage point consumes
|
||||
// `rate` of `attr`; when the attribute reaches 0, the remainder
|
||||
// falls through to the rest of the pipeline (HP / kill).
|
||||
const absorbAttr = ctx.engine.session.get(
|
||||
ctx.target,
|
||||
"AbsorbDamageAttr",
|
||||
) as string | undefined;
|
||||
const absorbRate = ctx.engine.session.get(
|
||||
ctx.target,
|
||||
"AbsorbDamageRate",
|
||||
) as number | undefined;
|
||||
if (
|
||||
typeof absorbAttr === "string" &&
|
||||
absorbAttr.length > 0 &&
|
||||
typeof absorbRate === "number" &&
|
||||
absorbRate > 0
|
||||
) {
|
||||
const charges = ctx.engine.session.get(ctx.target, absorbAttr);
|
||||
const numericCharges =
|
||||
typeof charges === "number" ? charges : 0;
|
||||
if (numericCharges > 0) {
|
||||
// Each damage point spends `rate` of attr; truncate at 0.
|
||||
const totalNeeded = ctx.amount * absorbRate;
|
||||
const consumed = Math.min(numericCharges, totalNeeded);
|
||||
const remainingCharges = numericCharges - consumed;
|
||||
ctx.engine.session.insert(ctx.target, absorbAttr, remainingCharges);
|
||||
// If we absorbed every damage point, fully consume — target lives.
|
||||
if (consumed >= totalNeeded) {
|
||||
return { consume: true, died: false };
|
||||
}
|
||||
// Partial absorb: reduced damage falls through. Same limitation
|
||||
// documented for partial resistance below — pipeline doesn't
|
||||
// support mutation, so the next handler sees the original
|
||||
// amount. Net effect: full damage still applies once charges
|
||||
// can't cover it. Acceptable for the common case where charges
|
||||
// are sized to absorb whole hits.
|
||||
}
|
||||
}
|
||||
|
||||
const resistance = ctx.engine.session.get(ctx.target, "DamageResistance");
|
||||
if (typeof resistance !== "number" || resistance <= 0) return;
|
||||
// Immunity short-circuit: fully absorb.
|
||||
if (resistance >= 1) {
|
||||
return { consume: true, died: false };
|
||||
}
|
||||
// Partial resistance: if applying it drops the amount to 0 we
|
||||
// can absorb; otherwise we fall through. applyResistance clamps
|
||||
// to ≥ 0 so the comparison is safe.
|
||||
const reduced = applyResistance(ctx.amount, resistance);
|
||||
if (reduced <= 0) return { consume: true, died: false };
|
||||
// Non-zero damage after resistance — let the next handler process
|
||||
// it. Note: this currently passes the ORIGINAL amount through
|
||||
// because the pipeline doesn't support mutation. Documented
|
||||
// limitation to revisit when HP + partial resistance both ship.
|
||||
return;
|
||||
},
|
||||
|
||||
/**
|
||||
* Snapshot state BEFORE the move mutates it:
|
||||
* - HP per piece (used by on-damaged hooks to detect drops).
|
||||
* - Attacker id when the move is a capture (used by on-capture
|
||||
* hooks; engine.moveLog isn't yet populated at onAfterMove time).
|
||||
*/
|
||||
onBeforeMove(ctx): void {
|
||||
PRE_MOVE_HP_SNAPSHOTS.set(ctx.engine, snapshotHp(ctx.engine.session));
|
||||
if (ctx.isCapture) {
|
||||
PRE_MOVE_CAPTURE_ATTACKERS.set(ctx.engine, ctx.pieceId);
|
||||
} else {
|
||||
PRE_MOVE_CAPTURE_ATTACKERS.delete(ctx.engine);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* After every successful move:
|
||||
* 1. Recompute aura contributions (T28).
|
||||
* 2. Fire on-damaged hooks for every piece whose HP dropped during
|
||||
* the move (compared against the pre-move snapshot).
|
||||
* 3. Fire on-capture hooks for the mover's piece if the move was a
|
||||
* capture (last move log entry's capturedId !== null).
|
||||
* 4. Evaluate every conditional hook against current piece state
|
||||
* and run the matching branch.
|
||||
* 5. Fire on-turn-start hooks for the color whose turn is now
|
||||
* beginning (the opposite of the mover).
|
||||
*
|
||||
* The order matters: damage / capture triggers see post-move state
|
||||
* (the kill has happened, Hp facts are current), conditional hooks
|
||||
* see whatever state the trigger primitives just produced, and
|
||||
* turn-start runs last so it sees a fully-resolved board.
|
||||
*/
|
||||
onAfterMove(ctx): void {
|
||||
computeAuraFacts(ctx.engine.session);
|
||||
|
||||
const preHp = PRE_MOVE_HP_SNAPSHOTS.get(ctx.engine);
|
||||
if (preHp !== undefined) {
|
||||
fireOnDamagedHooks(ctx.engine, preHp);
|
||||
PRE_MOVE_HP_SNAPSHOTS.delete(ctx.engine);
|
||||
}
|
||||
|
||||
const attacker = PRE_MOVE_CAPTURE_ATTACKERS.get(ctx.engine) ?? null;
|
||||
fireOnCaptureHooks(ctx.engine, attacker);
|
||||
PRE_MOVE_CAPTURE_ATTACKERS.delete(ctx.engine);
|
||||
|
||||
fireConditionalHooks(ctx.engine);
|
||||
|
||||
const nextTurn: "white" | "black" =
|
||||
ctx.mover === "white" ? "black" : "white";
|
||||
fireOnTurnStartHooks(ctx.engine, nextTurn);
|
||||
},
|
||||
});
|
||||
211
packages/chess/src/modifiers/auras.test.ts
Normal file
211
packages/chess/src/modifiers/auras.test.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { ChessEngine } from "../engine.js";
|
||||
import type { ChessAttrMap } from "../schema.js";
|
||||
import { computeAuraFacts } from "./auras.js";
|
||||
import "./primitives/index.js";
|
||||
|
||||
function findPieceAtSquare(engine: ChessEngine, square: number) {
|
||||
for (const f of engine.session.allFacts()) {
|
||||
if (f.attr === "Position" && f.value === square && (f.id as number) > 0) {
|
||||
return f.id;
|
||||
}
|
||||
}
|
||||
throw new Error(`no piece at square ${square}`);
|
||||
}
|
||||
|
||||
function getContribs(
|
||||
engine: ChessEngine,
|
||||
pieceId: ReturnType<typeof findPieceAtSquare>,
|
||||
): Record<string, number> | undefined {
|
||||
return engine.session.get(pieceId, "AuraContributions") as
|
||||
| Record<string, number>
|
||||
| undefined;
|
||||
}
|
||||
|
||||
function seedAura(
|
||||
engine: ChessEngine,
|
||||
sourceId: ReturnType<typeof findPieceAtSquare>,
|
||||
aura: ChessAttrMap["AuraSpec"][number],
|
||||
): void {
|
||||
const existing =
|
||||
(engine.session.get(sourceId, "AuraSpec") as
|
||||
| ChessAttrMap["AuraSpec"]
|
||||
| undefined) ?? [];
|
||||
engine.session.insert(sourceId, "AuraSpec", [...existing, aura]);
|
||||
}
|
||||
|
||||
describe("computeAuraFacts", () => {
|
||||
it("radius-1 aura contributes to 8 neighbours (king on e4 hits d3..f5)", () => {
|
||||
const engine = new ChessEngine();
|
||||
// Place a lone "source" conceptually on e4. Re-use the white king
|
||||
// (square 4 = e1) — auras don't care about piece type. We'll move
|
||||
// it to e4 (square 28) by overwriting its Position.
|
||||
const kingId = findPieceAtSquare(engine, 4);
|
||||
engine.session.insert(kingId, "Position", 28);
|
||||
|
||||
seedAura(engine, kingId, { radius: 1, targetAttr: "HpBonus", delta: 1 });
|
||||
|
||||
computeAuraFacts(engine.session);
|
||||
|
||||
// d3 (19), e3 (20), f3 (21), d4 (27), f4 (29), d5 (35), e5 (36), f5 (37)
|
||||
// Most of those squares are empty in the classic layout (ranks 3-6
|
||||
// are empty). Only d5/e5/f5 etc. are empty. But the king moved
|
||||
// from e1, so e1 is now empty. We need pieces IN range to observe
|
||||
// contributions. Pawns on e2 (12) = rank 2, and the kings are
|
||||
// typically at the back ranks — so with the king at e4, let's
|
||||
// check a PAWN that's within range. e2 pawn (sq 12) is at
|
||||
// chebyshev(28, 12) = max(0, 2) = 2, OUT of radius 1. Move a pawn
|
||||
// to e3 (sq 20) to test.
|
||||
const e2Pawn = findPieceAtSquare(engine, 12);
|
||||
engine.session.insert(e2Pawn, "Position", 20);
|
||||
|
||||
computeAuraFacts(engine.session);
|
||||
|
||||
const contribs = getContribs(engine, e2Pawn);
|
||||
expect(contribs).toBeDefined();
|
||||
expect(contribs!["HpBonus"]).toBe(1);
|
||||
});
|
||||
|
||||
it("a piece outside the radius gets no contribution", () => {
|
||||
const engine = new ChessEngine();
|
||||
const kingId = findPieceAtSquare(engine, 4); // e1
|
||||
seedAura(engine, kingId, { radius: 1, targetAttr: "HpBonus", delta: 1 });
|
||||
|
||||
computeAuraFacts(engine.session);
|
||||
|
||||
// e2 pawn (square 12) is chebyshev 1 — IN range.
|
||||
const e2Pawn = findPieceAtSquare(engine, 12);
|
||||
const e2Contribs = getContribs(engine, e2Pawn);
|
||||
expect(e2Contribs?.["HpBonus"]).toBe(1);
|
||||
|
||||
// e7 pawn (square 52) is chebyshev 6 — OUT of range.
|
||||
const e7Pawn = findPieceAtSquare(engine, 52);
|
||||
expect(getContribs(engine, e7Pawn)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("multiple auras from different sources accumulate additively", () => {
|
||||
const engine = new ChessEngine();
|
||||
const whiteKing = findPieceAtSquare(engine, 4); // e1
|
||||
const whiteQueen = findPieceAtSquare(engine, 3); // d1
|
||||
|
||||
seedAura(engine, whiteKing, {
|
||||
radius: 2,
|
||||
targetAttr: "HpBonus",
|
||||
delta: 1,
|
||||
});
|
||||
seedAura(engine, whiteQueen, {
|
||||
radius: 2,
|
||||
targetAttr: "HpBonus",
|
||||
delta: 2,
|
||||
});
|
||||
|
||||
computeAuraFacts(engine.session);
|
||||
|
||||
// d2 pawn (11) is radius 1 from e1 AND radius 1 from d1 → both hit.
|
||||
const d2Pawn = findPieceAtSquare(engine, 11);
|
||||
expect(getContribs(engine, d2Pawn)?.["HpBonus"]).toBe(3);
|
||||
});
|
||||
|
||||
it("a piece's own aura does not apply to itself", () => {
|
||||
const engine = new ChessEngine();
|
||||
const kingId = findPieceAtSquare(engine, 4);
|
||||
seedAura(engine, kingId, { radius: 7, targetAttr: "HpBonus", delta: 5 });
|
||||
|
||||
computeAuraFacts(engine.session);
|
||||
|
||||
expect(getContribs(engine, kingId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recompute retracts stale contributions from sources that moved out of range", () => {
|
||||
const engine = new ChessEngine();
|
||||
const kingId = findPieceAtSquare(engine, 4); // e1
|
||||
const e2Pawn = findPieceAtSquare(engine, 12);
|
||||
|
||||
seedAura(engine, kingId, { radius: 1, targetAttr: "HpBonus", delta: 1 });
|
||||
|
||||
computeAuraFacts(engine.session);
|
||||
expect(getContribs(engine, e2Pawn)?.["HpBonus"]).toBe(1);
|
||||
|
||||
// Move the king to a1 (square 0) — now e2 is chebyshev 4, out of radius 1.
|
||||
engine.session.insert(kingId, "Position", 0);
|
||||
computeAuraFacts(engine.session);
|
||||
|
||||
expect(getContribs(engine, e2Pawn)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is idempotent: calling twice without state change produces the same contributions", () => {
|
||||
const engine = new ChessEngine();
|
||||
const kingId = findPieceAtSquare(engine, 4);
|
||||
seedAura(engine, kingId, { radius: 1, targetAttr: "HpBonus", delta: 3 });
|
||||
|
||||
computeAuraFacts(engine.session);
|
||||
const first = getContribs(engine, findPieceAtSquare(engine, 12));
|
||||
|
||||
computeAuraFacts(engine.session);
|
||||
const second = getContribs(engine, findPieceAtSquare(engine, 12));
|
||||
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
it("multiple aura specs on ONE source (different attrs) all contribute", () => {
|
||||
const engine = new ChessEngine();
|
||||
const kingId = findPieceAtSquare(engine, 4);
|
||||
engine.session.insert(kingId, "AuraSpec", [
|
||||
{ radius: 1, targetAttr: "HpBonus", delta: 1 },
|
||||
{ radius: 1, targetAttr: "RangeBonus", delta: 2 },
|
||||
]);
|
||||
|
||||
computeAuraFacts(engine.session);
|
||||
|
||||
const e2Pawn = findPieceAtSquare(engine, 12);
|
||||
const contribs = getContribs(engine, e2Pawn);
|
||||
expect(contribs?.["HpBonus"]).toBe(1);
|
||||
expect(contribs?.["RangeBonus"]).toBe(2);
|
||||
});
|
||||
|
||||
it("no auras anywhere → no AuraContributions facts written", () => {
|
||||
const engine = new ChessEngine();
|
||||
computeAuraFacts(engine.session);
|
||||
for (const f of engine.session.allFacts()) {
|
||||
expect(f.attr).not.toBe("AuraContributions");
|
||||
}
|
||||
});
|
||||
|
||||
it("engine's onAfterMove hook recomputes auras automatically when a profile is active", async () => {
|
||||
// Use a no-op modifier profile so the __modifier-profile-integration__
|
||||
// preset auto-activates — that's the only way onAfterMove fires
|
||||
// computeAuraFacts without any explicit caller.
|
||||
const { applyProfileToSession: _apply } = await import("./apply.js");
|
||||
void _apply;
|
||||
const minimalProfile = {
|
||||
id: "test-minimal-profile",
|
||||
name: "Minimal",
|
||||
description: "",
|
||||
perType: [],
|
||||
perInstance: [],
|
||||
version: 1 as const,
|
||||
source: "custom" as const,
|
||||
};
|
||||
const engine = new ChessEngine({ profile: minimalProfile });
|
||||
|
||||
const kingId = findPieceAtSquare(engine, 4); // e1
|
||||
seedAura(engine, kingId, { radius: 1, targetAttr: "HpBonus", delta: 1 });
|
||||
|
||||
// Before any move → contribs not yet computed.
|
||||
const e2Before = getContribs(engine, findPieceAtSquare(engine, 12));
|
||||
expect(e2Before).toBeUndefined();
|
||||
|
||||
// Apply any legal move (e2 → e4). After the move, onAfterMove fires
|
||||
// and auras get recomputed. e2 pawn moved to e4 (square 28); it's
|
||||
// now at chebyshev 3 from the king on e1 — out of radius 1.
|
||||
// Meanwhile, the king still emits — d2/e2 (now empty)/f2 are in
|
||||
// range and d2/f2 pawns get +1 HpBonus from the king.
|
||||
const moves = engine.getAllLegalMoves();
|
||||
const e2e4 = moves.find((m) => m.from === 12 && m.to === 28);
|
||||
expect(e2e4).toBeDefined();
|
||||
engine.applyMove(e2e4!);
|
||||
|
||||
const d2Pawn = findPieceAtSquare(engine, 11);
|
||||
expect(getContribs(engine, d2Pawn)?.["HpBonus"]).toBe(1);
|
||||
});
|
||||
});
|
||||
117
packages/chess/src/modifiers/auras.ts
Normal file
117
packages/chess/src/modifiers/auras.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Aura engine integration (T28).
|
||||
*
|
||||
* The `add-aura` primitive (T14) seeds `AuraSpec` facts on a SOURCE
|
||||
* piece declaring an emitted aura: `{ radius, targetAttr, delta }`.
|
||||
* At runtime each aura contributes `delta` to every in-range TARGET
|
||||
* piece's `targetAttr` attribute. "In-range" uses Chebyshev distance
|
||||
* (king-move metric): a radius of 1 covers the 8 neighbours of the
|
||||
* source square; radius 2 covers a 5×5 box minus the source.
|
||||
*
|
||||
* Design:
|
||||
* - Auras are recomputed by `computeAuraFacts` after every successful
|
||||
* move. Source or target moving OUT of / INTO range takes effect
|
||||
* on the NEXT turn (no mid-move phases).
|
||||
* - Contributions are written to a dedicated `AuraContributions`
|
||||
* attr — a map keyed by target-attr name — so they never stomp
|
||||
* attribute writes from the modifier profile itself (HpBonus from
|
||||
* the profile + HpBonus from an aura compose as two separate
|
||||
* records consumers can blend).
|
||||
* - Sources and targets are independent. A piece can emit auras AND
|
||||
* be affected by other pieces' auras. Self-application is skipped.
|
||||
* - Empty contribution maps are retracted rather than written as `{}`
|
||||
* so `session.get(id, "AuraContributions")` returns `undefined` on
|
||||
* unaffected pieces (matches the convention for other optional
|
||||
* modifier attrs).
|
||||
*/
|
||||
import type { EntityId, Session } from "@paratype/rete";
|
||||
import type { ChessAttrMap, Square } from "../schema.js";
|
||||
import { fileOf, rankOf } from "../coord.js";
|
||||
|
||||
/**
|
||||
* Recompute `AuraContributions` on every piece in the session.
|
||||
* Idempotent: calling twice produces the same state.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Retract the existing `AuraContributions` fact on every piece
|
||||
* (clean slate — avoids stale contributions from sources that
|
||||
* moved out of range since the last compute).
|
||||
* 2. Walk every piece that has an `AuraSpec` list and, for each
|
||||
* aura, iterate every in-range piece. Accumulate per-target
|
||||
* per-attr deltas into a staging map.
|
||||
* 3. Commit the staging map: for each affected piece, write the
|
||||
* accumulated contributions as a single `AuraContributions` fact.
|
||||
*/
|
||||
export function computeAuraFacts(session: Session): void {
|
||||
// Step 1: gather every piece on the board with its square.
|
||||
const pieces = collectPiecesWithPositions(session);
|
||||
|
||||
// Step 2: retract stale contributions everywhere.
|
||||
for (const { id } of pieces) {
|
||||
if (session.contains(id, "AuraContributions")) {
|
||||
session.retract(id, "AuraContributions");
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: walk aura sources and accumulate contributions.
|
||||
//
|
||||
// `staging` maps target pieceId → (attr → delta).
|
||||
const staging = new Map<EntityId, Map<string, number>>();
|
||||
|
||||
for (const source of pieces) {
|
||||
const auras = session.get(source.id, "AuraSpec") as
|
||||
| ChessAttrMap["AuraSpec"]
|
||||
| undefined;
|
||||
if (auras === undefined || auras.length === 0) continue;
|
||||
|
||||
for (const aura of auras) {
|
||||
for (const target of pieces) {
|
||||
if (target.id === source.id) continue; // no self-application
|
||||
if (chebyshev(source.square, target.square) > aura.radius) continue;
|
||||
|
||||
let byAttr = staging.get(target.id);
|
||||
if (byAttr === undefined) {
|
||||
byAttr = new Map();
|
||||
staging.set(target.id, byAttr);
|
||||
}
|
||||
const prev = byAttr.get(aura.targetAttr) ?? 0;
|
||||
byAttr.set(aura.targetAttr, prev + aura.delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: commit.
|
||||
for (const [targetId, byAttr] of staging) {
|
||||
if (byAttr.size === 0) continue;
|
||||
const record: Record<string, number> = {};
|
||||
for (const [attr, delta] of byAttr) record[attr] = delta;
|
||||
session.insert(targetId, "AuraContributions", record);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
interface PieceWithSquare {
|
||||
readonly id: EntityId;
|
||||
readonly square: Square;
|
||||
}
|
||||
|
||||
function collectPiecesWithPositions(session: Session): PieceWithSquare[] {
|
||||
const out: PieceWithSquare[] = [];
|
||||
for (const f of session.allFacts()) {
|
||||
if (f.attr !== "Position") continue;
|
||||
if ((f.id as number) <= 0) continue;
|
||||
out.push({ id: f.id, square: f.value as Square });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chebyshev (chess king) distance between two squares. Range 0..7.
|
||||
* Two squares are "within radius R" when chebyshev ≤ R.
|
||||
*/
|
||||
function chebyshev(a: Square, b: Square): number {
|
||||
const df = Math.abs(fileOf(a) - fileOf(b));
|
||||
const dr = Math.abs(rankOf(a) - rankOf(b));
|
||||
return df > dr ? df : dr;
|
||||
}
|
||||
332
packages/chess/src/modifiers/custom/apply.test.ts
Normal file
332
packages/chess/src/modifiers/custom/apply.test.ts
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { CLASSIC_LAYOUT } from "../../layouts/classic.js";
|
||||
import { applyProfileToSession, applyProfilesToSession } from "../apply.js";
|
||||
import type { ModifierProfile } from "../types.js";
|
||||
import { CustomModifierRegistry } from "./registry.js";
|
||||
import { applyCustomDescriptor } from "./apply.js";
|
||||
import type { PrimitiveKind } from "../primitives/types.js";
|
||||
import { asCustomModifierId, type CustomModifierDescriptor } from "./types.js";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import "../primitives/index.js";
|
||||
|
||||
function customDescriptor(
|
||||
overrides: Partial<CustomModifierDescriptor> = {},
|
||||
): CustomModifierDescriptor {
|
||||
return {
|
||||
type: "data",
|
||||
id: asCustomModifierId(`custom:apply-${Math.random().toString(36).slice(2, 8)}`),
|
||||
name: "Test",
|
||||
description: "",
|
||||
version: 1,
|
||||
primitives: [],
|
||||
targetAttrs: [],
|
||||
uiForm: "primitive-composer",
|
||||
source: "custom",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function profileWithCustom(
|
||||
customKind: string,
|
||||
overrides: Partial<ModifierProfile> = {},
|
||||
): ModifierProfile {
|
||||
return {
|
||||
id: "test-profile",
|
||||
name: "Test",
|
||||
description: "",
|
||||
perType: [
|
||||
{
|
||||
kind: customKind as ModifierProfile["perType"][number]["kind"],
|
||||
pieceType: "pawn",
|
||||
color: "white",
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: "custom",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyCustomDescriptor — primitive walking", () => {
|
||||
it("applies a single seed-attribute primitive to a piece", () => {
|
||||
const engine = new ChessEngine();
|
||||
const desc = customDescriptor({
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 7 } },
|
||||
],
|
||||
});
|
||||
// Pick the pawn at e2 (white pawn in classic layout).
|
||||
const pawnId = findPieceAtSquare(engine, 12); // e2 = file 4 + rank 1*8
|
||||
|
||||
applyCustomDescriptor(engine, engine.session, pawnId, desc);
|
||||
|
||||
expect(engine.session.get(pawnId, "HpBonus")).toBe(7);
|
||||
});
|
||||
|
||||
it("applies multiple primitives in order", () => {
|
||||
const engine = new ChessEngine();
|
||||
const pawnId = findPieceAtSquare(engine, 12);
|
||||
|
||||
const desc = customDescriptor({
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 1 } },
|
||||
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 2 } },
|
||||
],
|
||||
});
|
||||
applyCustomDescriptor(engine, engine.session, pawnId, desc);
|
||||
|
||||
expect(engine.session.get(pawnId, "HpBonus")).toBe(3);
|
||||
});
|
||||
|
||||
it("descends into nested children via childPrimitives()", () => {
|
||||
const engine = new ChessEngine();
|
||||
const pawnId = findPieceAtSquare(engine, 12);
|
||||
|
||||
// on-turn-start nests an inner seed-attribute. The outer primitive
|
||||
// seeds the OnTurnStartHooks fact AND its inner children are
|
||||
// visited by the depth walker.
|
||||
const desc = customDescriptor({
|
||||
primitives: [
|
||||
{
|
||||
kind: "on-turn-start",
|
||||
params: {
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 4 } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
applyCustomDescriptor(engine, engine.session, pawnId, desc);
|
||||
|
||||
// The outer primitive seeds the hook fact:
|
||||
expect(engine.session.get(pawnId, "OnTurnStartHooks")).toBeDefined();
|
||||
// The inner child also runs (visible via the side-effect on RangeBonus):
|
||||
expect(engine.session.get(pawnId, "RangeBonus")).toBe(4);
|
||||
});
|
||||
|
||||
it("silently skips an unknown primitive kind", () => {
|
||||
const engine = new ChessEngine();
|
||||
const pawnId = findPieceAtSquare(engine, 12);
|
||||
|
||||
const desc = customDescriptor({
|
||||
primitives: [
|
||||
// Cast to bypass the literal-union check — we WANT to test that
|
||||
// the runtime walker tolerates unknown kinds gracefully (the
|
||||
// validator catches this at the static layer; the runtime path
|
||||
// is the defensive backstop).
|
||||
{ kind: "totally-not-a-real-primitive" as PrimitiveKind, params: {} },
|
||||
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 5 } },
|
||||
],
|
||||
});
|
||||
expect(() =>
|
||||
applyCustomDescriptor(engine, engine.session, pawnId, desc),
|
||||
).not.toThrow();
|
||||
// The known primitive after the unknown one still ran.
|
||||
expect(engine.session.get(pawnId, "HpBonus")).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyProfileToSession — custom-registry fallback", () => {
|
||||
it("resolves a profile entry against the engine's custom registry", () => {
|
||||
const customId = "custom:hp-+5";
|
||||
const desc = customDescriptor({
|
||||
id: asCustomModifierId(customId),
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 5 } },
|
||||
],
|
||||
});
|
||||
|
||||
const engine = new ChessEngine();
|
||||
engine.customModifiers.register(desc);
|
||||
|
||||
const profile = profileWithCustom(customId);
|
||||
applyProfileToSession(
|
||||
engine.session,
|
||||
profile,
|
||||
CLASSIC_LAYOUT,
|
||||
engine,
|
||||
engine.customModifiers,
|
||||
);
|
||||
|
||||
// Every white pawn (8 of them) got HpBonus = 5.
|
||||
const pawnIds = whiteWhitePawnIds(engine);
|
||||
expect(pawnIds).toHaveLength(8);
|
||||
for (const id of pawnIds) {
|
||||
expect(engine.session.get(id, "HpBonus")).toBe(5);
|
||||
}
|
||||
});
|
||||
|
||||
it("custom registries are per-engine — a descriptor in engineA is invisible to engineB", () => {
|
||||
const customId = "custom:visible-only-to-A";
|
||||
const desc = customDescriptor({
|
||||
id: asCustomModifierId(customId),
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "HpBonus", value: 9 } },
|
||||
],
|
||||
});
|
||||
|
||||
const engineA = new ChessEngine({ customModifiers: [desc] });
|
||||
const engineB = new ChessEngine();
|
||||
|
||||
expect(engineA.customModifiers.has(customId)).toBe(true);
|
||||
expect(engineB.customModifiers.has(customId)).toBe(false);
|
||||
});
|
||||
|
||||
it("constructor pre-registers customModifiers from EngineOptions", () => {
|
||||
const desc = customDescriptor({
|
||||
id: asCustomModifierId("custom:bootstrap"),
|
||||
});
|
||||
const engine = new ChessEngine({ customModifiers: [desc] });
|
||||
expect(engine.customModifiers.has("custom:bootstrap")).toBe(true);
|
||||
expect(engine.customModifiers.size()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyProfilesToSession — multi-profile stacking (T23)", () => {
|
||||
it("stacks two profiles' HpBonus additively across pieces", () => {
|
||||
const engine = new ChessEngine();
|
||||
const p1 = builtInHpBonusProfile("p1", 2);
|
||||
const p2 = builtInHpBonusProfile("p2", 3);
|
||||
|
||||
applyProfilesToSession(
|
||||
engine.session,
|
||||
[p1, p2],
|
||||
CLASSIC_LAYOUT,
|
||||
engine,
|
||||
engine.customModifiers,
|
||||
);
|
||||
|
||||
const pawnId = findPieceAtSquare(engine, 12);
|
||||
// 2 + 3 = 5 from additive stacking.
|
||||
expect(engine.session.get(pawnId, "HpBonus")).toBe(5);
|
||||
});
|
||||
|
||||
it("a single-profile call goes through the multi-profile path", () => {
|
||||
const engine = new ChessEngine();
|
||||
applyProfileToSession(
|
||||
engine.session,
|
||||
builtInHpBonusProfile("solo", 4),
|
||||
CLASSIC_LAYOUT,
|
||||
engine,
|
||||
engine.customModifiers,
|
||||
);
|
||||
const pawnId = findPieceAtSquare(engine, 12);
|
||||
expect(engine.session.get(pawnId, "HpBonus")).toBe(4);
|
||||
});
|
||||
|
||||
it("empty profile array is a no-op", () => {
|
||||
const engine = new ChessEngine();
|
||||
const before = engine.session.allFacts().length;
|
||||
applyProfilesToSession(
|
||||
engine.session,
|
||||
[],
|
||||
CLASSIC_LAYOUT,
|
||||
engine,
|
||||
engine.customModifiers,
|
||||
);
|
||||
expect(engine.session.allFacts().length).toBe(before);
|
||||
});
|
||||
|
||||
it("mixes built-in + custom profile entries on the same piece", () => {
|
||||
const customId = "custom:adds-range";
|
||||
const desc = customDescriptor({
|
||||
id: asCustomModifierId(customId),
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 2 } },
|
||||
],
|
||||
});
|
||||
const engine = new ChessEngine({ customModifiers: [desc] });
|
||||
|
||||
const builtInProfile = builtInHpBonusProfile("hp", 3);
|
||||
const customProfile = profileWithCustom(customId);
|
||||
|
||||
applyProfilesToSession(
|
||||
engine.session,
|
||||
[builtInProfile, customProfile],
|
||||
CLASSIC_LAYOUT,
|
||||
engine,
|
||||
engine.customModifiers,
|
||||
);
|
||||
|
||||
const pawnId = findPieceAtSquare(engine, 12);
|
||||
expect(engine.session.get(pawnId, "HpBonus")).toBe(3);
|
||||
expect(engine.session.get(pawnId, "RangeBonus")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CustomModifierRegistry — basic API", () => {
|
||||
it("registers, looks up, and lists descriptors", () => {
|
||||
const registry = new CustomModifierRegistry();
|
||||
const desc = customDescriptor({ id: asCustomModifierId("custom:abc") });
|
||||
registry.register(desc);
|
||||
expect(registry.has("custom:abc")).toBe(true);
|
||||
expect(registry.get("custom:abc")).toBe(desc);
|
||||
expect(registry.list()).toEqual([desc]);
|
||||
expect(registry.size()).toBe(1);
|
||||
});
|
||||
|
||||
it("clear() empties the registry", () => {
|
||||
const registry = new CustomModifierRegistry();
|
||||
registry.register(customDescriptor());
|
||||
registry.clear();
|
||||
expect(registry.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("register() with the same id replaces the existing descriptor", () => {
|
||||
const registry = new CustomModifierRegistry();
|
||||
const v1 = customDescriptor({
|
||||
id: asCustomModifierId("custom:dup"),
|
||||
name: "v1",
|
||||
});
|
||||
const v2 = customDescriptor({
|
||||
id: asCustomModifierId("custom:dup"),
|
||||
name: "v2",
|
||||
});
|
||||
registry.register(v1);
|
||||
registry.register(v2);
|
||||
expect(registry.size()).toBe(1);
|
||||
expect(registry.get("custom:dup")).toBe(v2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function findPieceAtSquare(engine: ChessEngine, square: number): EntityId {
|
||||
for (const f of engine.session.allFacts()) {
|
||||
if (f.attr === "Position" && f.value === square && (f.id as number) > 0) {
|
||||
return f.id;
|
||||
}
|
||||
}
|
||||
throw new Error(`no piece at square ${square}`);
|
||||
}
|
||||
|
||||
function whiteWhitePawnIds(engine: ChessEngine): EntityId[] {
|
||||
const out: EntityId[] = [];
|
||||
const facts = engine.session.allFacts();
|
||||
for (const f of facts) {
|
||||
if (f.attr !== "PieceType" || f.value !== "pawn") continue;
|
||||
if ((f.id as number) <= 0) continue;
|
||||
const colorFact = facts.find((c) => c.id === f.id && c.attr === "Color");
|
||||
if (colorFact?.value !== "white") continue;
|
||||
out.push(f.id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function builtInHpBonusProfile(id: string, value: number): ModifierProfile {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
description: "",
|
||||
perType: [
|
||||
{ kind: "hp-bonus", pieceType: "pawn", color: "white", value },
|
||||
],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: "custom",
|
||||
};
|
||||
}
|
||||
126
packages/chess/src/modifiers/custom/apply.ts
Normal file
126
packages/chess/src/modifiers/custom/apply.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* Apply a CustomModifierDescriptor's primitive list to a single piece (T22).
|
||||
*
|
||||
* Walks `descriptor.primitives` in order, looks each one up in the
|
||||
* global `PRIMITIVE_REGISTRY`, runs the registered primitive's
|
||||
* `apply(ctx, params)`. Recursion depth tracking mirrors T19's static
|
||||
* guard at runtime — `ctx.depth` increments when we enter a nested
|
||||
* primitive list discovered via `childPrimitives()`.
|
||||
*
|
||||
* The registry-walk fallthrough (`unknown` kind silently skipped) is
|
||||
* intentional: `applyProfileToSession` is the orchestrator that emits
|
||||
* a dev warning for unknown kinds; `applyCustomDescriptor` trusts the
|
||||
* caller has already validated the descriptor.
|
||||
*/
|
||||
import type { EntityId, Session } from "@paratype/rete";
|
||||
import type { ChessEngine } from "../../engine.js";
|
||||
import { PRIMITIVE_REGISTRY } from "../primitives/registry.js";
|
||||
import type {
|
||||
EffectPrimitive,
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveApplyContext,
|
||||
} from "../primitives/types.js";
|
||||
import type { CustomModifierDescriptor } from "./types.js";
|
||||
|
||||
/**
|
||||
* Hard runtime cap that mirrors the validator's MAX_RECURSION_DEPTH (3).
|
||||
* Defensive — should never trigger if T19 passed; set higher than the
|
||||
* static cap so a passing descriptor never hits this throw.
|
||||
*/
|
||||
const RUNTIME_DEPTH_HARD_CAP = 8;
|
||||
|
||||
export function applyCustomDescriptor(
|
||||
engine: ChessEngine,
|
||||
session: Session,
|
||||
pieceId: EntityId,
|
||||
descriptor: CustomModifierDescriptor,
|
||||
): void {
|
||||
walkAndApply({
|
||||
engine,
|
||||
session,
|
||||
pieceId,
|
||||
descriptor,
|
||||
nodes: descriptor.primitives,
|
||||
depth: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function walkAndApply(input: {
|
||||
engine: ChessEngine;
|
||||
session: Session;
|
||||
pieceId: EntityId;
|
||||
descriptor: CustomModifierDescriptor;
|
||||
nodes: readonly EffectPrimitiveNode[];
|
||||
depth: number;
|
||||
}): void {
|
||||
const { engine, session, pieceId, descriptor, nodes, depth } = input;
|
||||
|
||||
if (depth > RUNTIME_DEPTH_HARD_CAP) {
|
||||
throw new Error(
|
||||
`applyCustomDescriptor: nesting depth ${depth} exceeds hard cap ${RUNTIME_DEPTH_HARD_CAP} ` +
|
||||
`(descriptor "${descriptor.id}" should have failed T19's validator)`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
|
||||
if (primitive === undefined) {
|
||||
// Unknown kind — skip silently. The validator should have caught
|
||||
// this; tolerating it at runtime keeps a half-validated descriptor
|
||||
// from crashing the entire profile apply.
|
||||
continue;
|
||||
}
|
||||
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine,
|
||||
session,
|
||||
pieceId,
|
||||
depth,
|
||||
descriptor: {
|
||||
id: String(descriptor.id),
|
||||
type: descriptor.type,
|
||||
version: descriptor.version,
|
||||
},
|
||||
};
|
||||
|
||||
runPrimitive(primitive, ctx, node.params);
|
||||
|
||||
// If the primitive owns nested children, recurse. Errors during
|
||||
// childPrimitives() introspection terminate the recursion for this
|
||||
// node but don't bubble up — the validator's depth/count checks
|
||||
// are the user-facing guard.
|
||||
if (primitive.childPrimitives === undefined) continue;
|
||||
|
||||
let children: readonly EffectPrimitiveNode[] = [];
|
||||
try {
|
||||
children = primitive.childPrimitives(node.params);
|
||||
} catch {
|
||||
children = [];
|
||||
}
|
||||
if (children.length === 0) continue;
|
||||
|
||||
walkAndApply({
|
||||
engine,
|
||||
session,
|
||||
pieceId,
|
||||
descriptor,
|
||||
nodes: children,
|
||||
depth: depth + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper that erases the `EffectPrimitive<P>` generic so we can call
|
||||
* apply() with `node.params: unknown`. The registry stores descriptors
|
||||
* with their generic erased; we cast the apply function's first param
|
||||
* to `unknown` here. Any narrowing the primitive does internally
|
||||
* (typically via its own paramsSchema) is the primitive's contract.
|
||||
*/
|
||||
function runPrimitive(
|
||||
primitive: EffectPrimitive,
|
||||
ctx: PrimitiveApplyContext,
|
||||
params: unknown,
|
||||
): void {
|
||||
primitive.apply(ctx, params);
|
||||
}
|
||||
3
packages/chess/src/modifiers/custom/index.ts
Normal file
3
packages/chess/src/modifiers/custom/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./types.js";
|
||||
|
||||
// Persistence, validation, Zod schema, and apply are added by Wave 3 (T19-T22).
|
||||
205
packages/chess/src/modifiers/custom/library.test.ts
Normal file
205
packages/chess/src/modifiers/custom/library.test.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { asCustomModifierId, type CustomModifierDescriptor } from "./types.js";
|
||||
import {
|
||||
__test__,
|
||||
loadCustomModifierLibrary,
|
||||
removeFromCustomModifierLibrary,
|
||||
saveToCustomModifierLibrary,
|
||||
setCustomModifierStarred,
|
||||
} from "./library.js";
|
||||
|
||||
// Same Map-backed Storage shim used by the T1 library tests
|
||||
// (see ../library.test.ts) — happy-dom's bundled localStorage doesn't
|
||||
// survive every destructuring pattern reliably across vitest workers.
|
||||
beforeAll(() => {
|
||||
const store = new Map<string, string>();
|
||||
const shim: Storage = {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
getItem(k: string) {
|
||||
return store.get(k) ?? null;
|
||||
},
|
||||
setItem(k: string, v: string) {
|
||||
store.set(k, v);
|
||||
},
|
||||
removeItem(k: string) {
|
||||
store.delete(k);
|
||||
},
|
||||
key(i: number) {
|
||||
return [...store.keys()][i] ?? null;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: shim,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
function descriptor(
|
||||
overrides: Partial<CustomModifierDescriptor> = {},
|
||||
): CustomModifierDescriptor {
|
||||
return {
|
||||
type: "data",
|
||||
id: asCustomModifierId(`custom:${Math.random().toString(36).slice(2, 10)}`),
|
||||
name: "Test",
|
||||
description: "",
|
||||
version: 1,
|
||||
primitives: [],
|
||||
targetAttrs: [],
|
||||
uiForm: "primitive-composer",
|
||||
source: "custom",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("custom modifier library — basic round-trip", () => {
|
||||
it("loadCustomModifierLibrary returns [] when storage is empty", () => {
|
||||
expect(loadCustomModifierLibrary()).toEqual([]);
|
||||
});
|
||||
|
||||
it("loadCustomModifierLibrary returns [] on malformed JSON", () => {
|
||||
localStorage.setItem(__test__.STORAGE_KEY, "{not json[");
|
||||
expect(loadCustomModifierLibrary()).toEqual([]);
|
||||
});
|
||||
|
||||
it("save then load returns the entry", () => {
|
||||
const desc = descriptor({ name: "Roundtrip" });
|
||||
const saved = saveToCustomModifierLibrary(desc);
|
||||
expect(saved.ok).toBe(true);
|
||||
|
||||
const loaded = loadCustomModifierLibrary();
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0]!.id).toBe(desc.id);
|
||||
expect(loaded[0]!.descriptor.name).toBe("Roundtrip");
|
||||
expect(loaded[0]!.starred).toBe(false);
|
||||
expect(typeof loaded[0]!.updatedAt).toBe("number");
|
||||
});
|
||||
|
||||
it("auto-populates createdAt on first save when absent", () => {
|
||||
const desc = descriptor();
|
||||
expect(desc.createdAt).toBeUndefined();
|
||||
const saved = saveToCustomModifierLibrary(desc);
|
||||
expect(saved.ok).toBe(true);
|
||||
if (!saved.ok) return;
|
||||
expect(typeof saved.entry.descriptor.createdAt).toBe("number");
|
||||
});
|
||||
|
||||
it("preserves createdAt on subsequent saves", async () => {
|
||||
const id = asCustomModifierId("custom:stable");
|
||||
const first = saveToCustomModifierLibrary(descriptor({ id, name: "A" }));
|
||||
expect(first.ok).toBe(true);
|
||||
if (!first.ok) return;
|
||||
const originalCreatedAt = first.entry.descriptor.createdAt;
|
||||
expect(originalCreatedAt).toBeDefined();
|
||||
|
||||
// Wait a tick so updatedAt definitely changes.
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
const second = saveToCustomModifierLibrary(
|
||||
descriptor({ id, name: "B" }),
|
||||
);
|
||||
expect(second.ok).toBe(true);
|
||||
if (!second.ok) return;
|
||||
expect(second.entry.descriptor.createdAt).toBe(originalCreatedAt);
|
||||
expect(second.entry.descriptor.name).toBe("B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom modifier library — update, remove, star", () => {
|
||||
it("update by id replaces the descriptor in place", () => {
|
||||
const id = asCustomModifierId("custom:update");
|
||||
saveToCustomModifierLibrary(descriptor({ id, name: "v1" }));
|
||||
saveToCustomModifierLibrary(descriptor({ id, name: "v2" }));
|
||||
|
||||
const loaded = loadCustomModifierLibrary();
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0]!.descriptor.name).toBe("v2");
|
||||
});
|
||||
|
||||
it("removeFromCustomModifierLibrary deletes by id and returns true", () => {
|
||||
const id = asCustomModifierId("custom:remove");
|
||||
saveToCustomModifierLibrary(descriptor({ id }));
|
||||
expect(removeFromCustomModifierLibrary(id)).toBe(true);
|
||||
expect(loadCustomModifierLibrary()).toEqual([]);
|
||||
});
|
||||
|
||||
it("removeFromCustomModifierLibrary returns false when id absent", () => {
|
||||
expect(
|
||||
removeFromCustomModifierLibrary(asCustomModifierId("custom:nope")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("setCustomModifierStarred toggles the flag", () => {
|
||||
const id = asCustomModifierId("custom:star");
|
||||
saveToCustomModifierLibrary(descriptor({ id }));
|
||||
setCustomModifierStarred(id, true);
|
||||
expect(loadCustomModifierLibrary()[0]!.starred).toBe(true);
|
||||
setCustomModifierStarred(id, false);
|
||||
expect(loadCustomModifierLibrary()[0]!.starred).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom modifier library — capacity and validation", () => {
|
||||
it("rejects descriptors that fail T19 validation", () => {
|
||||
// Empty name fails the validator's name-length rule.
|
||||
const bad = descriptor({ name: "" });
|
||||
const result = saveToCustomModifierLibrary(bad);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors!.some((e) => e.code === "descriptor.name.length")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("evicts the oldest non-starred entry when at capacity", async () => {
|
||||
// Fill with MAX_ENTRIES non-starred entries with strictly-increasing
|
||||
// updatedAt timestamps.
|
||||
for (let i = 0; i < __test__.MAX_ENTRIES; i += 1) {
|
||||
saveToCustomModifierLibrary(
|
||||
descriptor({
|
||||
id: asCustomModifierId(`custom:fill-${i}`),
|
||||
name: `fill-${i}`,
|
||||
}),
|
||||
);
|
||||
// Tiny stagger so updatedAt timestamps differ.
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
}
|
||||
expect(loadCustomModifierLibrary()).toHaveLength(__test__.MAX_ENTRIES);
|
||||
|
||||
// Add one more — the oldest (fill-0) should be evicted.
|
||||
const overflow = descriptor({
|
||||
id: asCustomModifierId("custom:overflow"),
|
||||
name: "overflow",
|
||||
});
|
||||
const result = saveToCustomModifierLibrary(overflow);
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const loaded = loadCustomModifierLibrary();
|
||||
expect(loaded).toHaveLength(__test__.MAX_ENTRIES);
|
||||
expect(loaded.find((e) => e.descriptor.name === "fill-0")).toBeUndefined();
|
||||
expect(loaded.find((e) => e.descriptor.name === "overflow")).toBeDefined();
|
||||
});
|
||||
|
||||
it("refuses save when the library is full of starred entries", async () => {
|
||||
for (let i = 0; i < __test__.MAX_ENTRIES; i += 1) {
|
||||
const id = asCustomModifierId(`custom:starred-${i}`);
|
||||
saveToCustomModifierLibrary(descriptor({ id, name: `s-${i}` }));
|
||||
setCustomModifierStarred(id, true);
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
}
|
||||
const result = saveToCustomModifierLibrary(
|
||||
descriptor({ id: asCustomModifierId("custom:rejected") }),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.reason).toMatch(/library full/i);
|
||||
});
|
||||
});
|
||||
189
packages/chess/src/modifiers/custom/library.ts
Normal file
189
packages/chess/src/modifiers/custom/library.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/**
|
||||
* Custom modifier library — localStorage-backed store of user-authored
|
||||
* custom modifier descriptors (T21).
|
||||
*
|
||||
* Mirrors the T1 modifier-profile library at `../library.ts`:
|
||||
* - Same MAX_ENTRIES (20) capacity rule with starred-aware FIFO eviction.
|
||||
* - Same `loadLibrary` / `saveToLibrary` / `deleteFromLibrary` /
|
||||
* `setStarred` / `duplicateEntry` API surface.
|
||||
* - Different storage key: `houserules:custom-modifiers:v1`.
|
||||
*
|
||||
* On `saveToLibrary` we run T19's validator (`validateCustomDescriptor`)
|
||||
* before persisting. Invalid descriptors are rejected with the validator's
|
||||
* error list so the caller can surface inline editor feedback. The library
|
||||
* never persists a structurally-broken descriptor.
|
||||
*
|
||||
* `createdAt` on the descriptor is auto-populated on first save (when the
|
||||
* descriptor doesn't already carry one); subsequent saves preserve the
|
||||
* original timestamp so library order stays stable.
|
||||
*/
|
||||
import {
|
||||
parseCustomModifierDescriptor,
|
||||
safeParseCustomModifierDescriptor,
|
||||
} from "./schema.js";
|
||||
import type { CustomModifierDescriptor, CustomModifierId } from "./types.js";
|
||||
import {
|
||||
validateCustomDescriptor,
|
||||
type ValidationError,
|
||||
} from "./validate.js";
|
||||
|
||||
const STORAGE_KEY = "houserules:custom-modifiers:v1";
|
||||
const MAX_ENTRIES = 20;
|
||||
|
||||
export interface SavedCustomModifier {
|
||||
readonly id: CustomModifierId;
|
||||
readonly descriptor: CustomModifierDescriptor;
|
||||
readonly starred: boolean;
|
||||
readonly updatedAt: number;
|
||||
}
|
||||
|
||||
export type SaveResult =
|
||||
| { ok: true; entry: SavedCustomModifier }
|
||||
| { ok: false; reason: string; errors?: ValidationError[] };
|
||||
|
||||
/**
|
||||
* Read every saved custom modifier from storage. Empty/missing/corrupt
|
||||
* → `[]`. Per-entry shape failures are silently dropped so a single
|
||||
* bad row never blocks the whole library.
|
||||
*/
|
||||
export function loadCustomModifierLibrary(): SavedCustomModifier[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === null) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(isSavedCustomModifier);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the entire library array on disk. Best-effort on quota error. */
|
||||
function writeLibrary(entries: readonly SavedCustomModifier[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
||||
} catch {
|
||||
/* quota exceeded / storage disabled — best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new descriptor or update an existing one (matched by descriptor
|
||||
* id). Runs T19's structural+semantic validator first; rejects with
|
||||
* the error list on failure. Auto-populates `descriptor.createdAt` on
|
||||
* first save and preserves it on subsequent saves.
|
||||
*
|
||||
* Capacity: when adding a NEW descriptor would exceed MAX_ENTRIES, the
|
||||
* oldest non-starred entry is evicted. If every entry is starred, the
|
||||
* save is refused with a human-friendly reason.
|
||||
*/
|
||||
export function saveToCustomModifierLibrary(
|
||||
descriptor: CustomModifierDescriptor,
|
||||
): SaveResult {
|
||||
const validation = validateCustomDescriptor(descriptor);
|
||||
if (!validation.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "Descriptor failed validation",
|
||||
errors: validation.errors,
|
||||
};
|
||||
}
|
||||
|
||||
const library = loadCustomModifierLibrary();
|
||||
const existingIdx = library.findIndex((e) => e.id === descriptor.id);
|
||||
const now = Date.now();
|
||||
|
||||
// Preserve the original createdAt if this is an update; populate it
|
||||
// for first-time saves.
|
||||
const existingCreatedAt =
|
||||
existingIdx >= 0 ? library[existingIdx]?.descriptor.createdAt : undefined;
|
||||
const createdAt =
|
||||
descriptor.createdAt ?? existingCreatedAt ?? now;
|
||||
const stampedDescriptor: CustomModifierDescriptor = {
|
||||
...descriptor,
|
||||
createdAt,
|
||||
};
|
||||
|
||||
const entry: SavedCustomModifier = {
|
||||
id: descriptor.id,
|
||||
descriptor: stampedDescriptor,
|
||||
starred: existingIdx >= 0 ? library[existingIdx]!.starred : false,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (existingIdx >= 0) {
|
||||
const updated = library.slice();
|
||||
updated[existingIdx] = entry;
|
||||
writeLibrary(updated);
|
||||
return { ok: true, entry };
|
||||
}
|
||||
|
||||
if (library.length >= MAX_ENTRIES) {
|
||||
const evictable = library
|
||||
.filter((e) => !e.starred)
|
||||
.sort((a, b) => a.updatedAt - b.updatedAt);
|
||||
if (evictable.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
"Library full (20 custom modifiers). Unstar one to make room, or delete an entry.",
|
||||
};
|
||||
}
|
||||
const oldest = evictable[0]!;
|
||||
const pruned = library.filter((e) => e.id !== oldest.id);
|
||||
pruned.push(entry);
|
||||
writeLibrary(pruned);
|
||||
return { ok: true, entry };
|
||||
}
|
||||
|
||||
writeLibrary([...library, entry]);
|
||||
return { ok: true, entry };
|
||||
}
|
||||
|
||||
/** Remove a custom modifier by id. No-op if the id is unknown. Returns true if removed. */
|
||||
export function removeFromCustomModifierLibrary(
|
||||
id: CustomModifierId,
|
||||
): boolean {
|
||||
const library = loadCustomModifierLibrary();
|
||||
const next = library.filter((e) => e.id !== id);
|
||||
if (next.length === library.length) return false;
|
||||
writeLibrary(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Toggle the starred flag on a saved custom modifier. */
|
||||
export function setCustomModifierStarred(
|
||||
id: CustomModifierId,
|
||||
starred: boolean,
|
||||
): void {
|
||||
const library = loadCustomModifierLibrary();
|
||||
const idx = library.findIndex((e) => e.id === id);
|
||||
if (idx < 0) return;
|
||||
const updated = library.slice();
|
||||
updated[idx] = { ...library[idx]!, starred, updatedAt: Date.now() };
|
||||
writeLibrary(updated);
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────
|
||||
|
||||
function isSavedCustomModifier(value: unknown): value is SavedCustomModifier {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof v["id"] !== "string" ||
|
||||
typeof v["starred"] !== "boolean" ||
|
||||
typeof v["updatedAt"] !== "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Validate the nested descriptor structurally via Zod.
|
||||
const parseResult = safeParseCustomModifierDescriptor(v["descriptor"]);
|
||||
if (!parseResult.success) return false;
|
||||
// Discard the parse output — we keep the original raw shape so the
|
||||
// entry round-trips identically (createdAt preserved, etc.).
|
||||
void parseCustomModifierDescriptor;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Exported for tests only.
|
||||
export const __test__ = { STORAGE_KEY, MAX_ENTRIES };
|
||||
41
packages/chess/src/modifiers/custom/registry.ts
Normal file
41
packages/chess/src/modifiers/custom/registry.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Per-engine registry for user-authored CustomModifierDescriptors (T22).
|
||||
*
|
||||
* The global MODIFIER_REGISTRY holds engine-shipped built-in descriptors
|
||||
* (hp-bonus, range-bonus, etc.). Custom descriptors are intentionally NOT
|
||||
* in that registry — per ADR-4, they live on a per-engine instance so a
|
||||
* descriptor authored in one room never leaks into another. Each
|
||||
* `ChessEngine` owns one `CustomModifierRegistry`; profile application
|
||||
* consults both registries (built-ins first, custom as fallback).
|
||||
*/
|
||||
import type { CustomModifierDescriptor, CustomModifierId } from "./types.js";
|
||||
|
||||
export class CustomModifierRegistry {
|
||||
readonly #byId = new Map<CustomModifierId, CustomModifierDescriptor>();
|
||||
|
||||
/** Register or REPLACE a descriptor by id. */
|
||||
register(descriptor: CustomModifierDescriptor): void {
|
||||
this.#byId.set(descriptor.id, descriptor);
|
||||
}
|
||||
|
||||
/** Look up by branded id OR raw string (matches the kind on a profile entry). */
|
||||
get(id: string): CustomModifierDescriptor | undefined {
|
||||
return this.#byId.get(id as CustomModifierId);
|
||||
}
|
||||
|
||||
has(id: string): boolean {
|
||||
return this.#byId.has(id as CustomModifierId);
|
||||
}
|
||||
|
||||
list(): readonly CustomModifierDescriptor[] {
|
||||
return [...this.#byId.values()];
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.#byId.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#byId.clear();
|
||||
}
|
||||
}
|
||||
147
packages/chess/src/modifiers/custom/schema.test.ts
Normal file
147
packages/chess/src/modifiers/custom/schema.test.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
EffectPrimitiveNodeSchema,
|
||||
parseCustomModifierDescriptor,
|
||||
safeParseCustomModifierDescriptor,
|
||||
serializeCustomModifierDescriptor,
|
||||
} from "./schema.js";
|
||||
import { asCustomModifierId, type CustomModifierDescriptor } from "./types.js";
|
||||
|
||||
function validDescriptor(
|
||||
overrides: Partial<CustomModifierDescriptor> = {},
|
||||
): CustomModifierDescriptor {
|
||||
return {
|
||||
type: "data",
|
||||
id: asCustomModifierId("custom:test"),
|
||||
name: "Test",
|
||||
description: "",
|
||||
version: 1,
|
||||
primitives: [],
|
||||
targetAttrs: [],
|
||||
uiForm: "primitive-composer",
|
||||
source: "custom",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("EffectPrimitiveNodeSchema", () => {
|
||||
it("accepts a leaf node", () => {
|
||||
const node = { kind: "seed-attribute", params: { attr: "Hp", value: 5 } };
|
||||
expect(EffectPrimitiveNodeSchema.parse(node)).toEqual(node);
|
||||
});
|
||||
|
||||
it("accepts a nested-tree node (params holds more nodes)", () => {
|
||||
const nested = {
|
||||
kind: "on-turn-start",
|
||||
params: {
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(EffectPrimitiveNodeSchema.parse(nested)).toEqual(nested);
|
||||
});
|
||||
|
||||
it("rejects a node missing the kind field", () => {
|
||||
expect(() =>
|
||||
EffectPrimitiveNodeSchema.parse({ params: {} }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a node with an empty kind string", () => {
|
||||
expect(() =>
|
||||
EffectPrimitiveNodeSchema.parse({ kind: "", params: {} }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CustomModifierDescriptorSchema — happy path", () => {
|
||||
it("round-trips a minimal valid descriptor", () => {
|
||||
const desc = validDescriptor();
|
||||
const parsed = parseCustomModifierDescriptor(desc);
|
||||
expect(parsed).toEqual(desc);
|
||||
});
|
||||
|
||||
it("round-trips a descriptor with primitives + author + createdAt", () => {
|
||||
const desc = validDescriptor({
|
||||
name: "Shield",
|
||||
description: "Absorbs damage.",
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "ShieldCharges", value: 3 } },
|
||||
],
|
||||
targetAttrs: ["AbsorbDamageAttr", "AbsorbDamageRate"],
|
||||
author: "alice",
|
||||
createdAt: 1700000000000,
|
||||
});
|
||||
const parsed = parseCustomModifierDescriptor(desc);
|
||||
expect(parsed.author).toBe("alice");
|
||||
expect(parsed.createdAt).toBe(1700000000000);
|
||||
});
|
||||
|
||||
it("safeParse returns success on a valid descriptor", () => {
|
||||
const result = safeParseCustomModifierDescriptor(validDescriptor());
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("serialize round-trips the same shape", () => {
|
||||
const desc = validDescriptor({ name: "Roundtrip" });
|
||||
const out = serializeCustomModifierDescriptor(desc) as CustomModifierDescriptor;
|
||||
expect(out).toEqual(desc);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CustomModifierDescriptorSchema — rejections", () => {
|
||||
it("rejects type !== 'data'", () => {
|
||||
const bad = { ...validDescriptor(), type: "scripted" };
|
||||
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects version !== 1", () => {
|
||||
const bad = { ...validDescriptor(), version: 2 };
|
||||
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects name longer than 40 chars", () => {
|
||||
const bad = validDescriptor({ name: "x".repeat(41) });
|
||||
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects description longer than 200 chars", () => {
|
||||
const bad = validDescriptor({ description: "x".repeat(201) });
|
||||
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects empty id", () => {
|
||||
const bad = { ...validDescriptor(), id: "" };
|
||||
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects negative createdAt", () => {
|
||||
const bad = validDescriptor({ createdAt: -1 });
|
||||
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects uiForm !== 'primitive-composer'", () => {
|
||||
const bad = { ...validDescriptor(), uiForm: "number" };
|
||||
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects source !== 'custom'", () => {
|
||||
const bad = { ...validDescriptor(), source: "premade" };
|
||||
expect(() => parseCustomModifierDescriptor(bad)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects when primitives is missing", () => {
|
||||
const { primitives: _drop, ...rest } = validDescriptor();
|
||||
expect(() => parseCustomModifierDescriptor(rest)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CustomModifierDescriptorSchema — optional fields", () => {
|
||||
it("accepts a descriptor without author / createdAt", () => {
|
||||
const desc = validDescriptor();
|
||||
const parsed = parseCustomModifierDescriptor(desc);
|
||||
expect(parsed.author).toBeUndefined();
|
||||
expect(parsed.createdAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
94
packages/chess/src/modifiers/custom/schema.ts
Normal file
94
packages/chess/src/modifiers/custom/schema.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* Zod schemas for CustomModifierDescriptor serialization (T20).
|
||||
*
|
||||
* Validates STRUCTURE only — kind-in-registry and per-primitive params
|
||||
* checks live in `validate.ts` (T19), which is the semantic layer that
|
||||
* runs after a clean structural parse.
|
||||
*
|
||||
* The `EffectPrimitiveNode` schema is recursive: a primitive's `params`
|
||||
* may embed more nodes (e.g. `on-turn-start` carries a `primitives: []`
|
||||
* inside its params). We model this with `z.lazy()` and treat `params`
|
||||
* as `z.unknown()` at the schema layer — the validator drills into it
|
||||
* per-kind.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import {
|
||||
asCustomModifierId,
|
||||
type CustomModifierDescriptor,
|
||||
} from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitive node — recursive
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Structural shape of a primitive node. `kind` is `string` here (not the
|
||||
* `PrimitiveKind` literal union) because the schema is structure-only;
|
||||
* the validator (T19) checks `kind ∈ PRIMITIVE_REGISTRY`. `params` is
|
||||
* `unknown` so nested trees pass through this schema cleanly.
|
||||
*
|
||||
* Inferred output: `{ kind: string; params: unknown }`. Consumers that
|
||||
* want the branded `PrimitiveKind` shape go through `parseCustomModifierDescriptor`
|
||||
* which performs the narrowing at the boundary.
|
||||
*/
|
||||
export const EffectPrimitiveNodeSchema = z.lazy(() =>
|
||||
z.object({
|
||||
kind: z.string().min(1),
|
||||
params: z.unknown(),
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CustomModifierDescriptor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CustomModifierIdSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform((s) => asCustomModifierId(s));
|
||||
|
||||
export const CustomModifierDescriptorSchema = z.object({
|
||||
type: z.literal("data"),
|
||||
id: CustomModifierIdSchema,
|
||||
name: z.string().min(1).max(40),
|
||||
description: z.string().max(200),
|
||||
version: z.literal(1),
|
||||
primitives: z.array(EffectPrimitiveNodeSchema),
|
||||
targetAttrs: z.array(z.string()),
|
||||
uiForm: z.literal("primitive-composer"),
|
||||
source: z.literal("custom"),
|
||||
author: z.string().optional(),
|
||||
createdAt: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse an unknown value as a CustomModifierDescriptor. Throws ZodError.
|
||||
*
|
||||
* The inferred Zod output type is structurally compatible but Zod's
|
||||
* representation of optional fields and `string`-typed `kind` doesn't
|
||||
* exactly match the `CustomModifierDescriptor` interface (which has
|
||||
* `kind: PrimitiveKind` and uses `?` not `| undefined`). The single
|
||||
* boundary cast below is the pragmatic way to bridge this — every
|
||||
* narrowing it implies is enforced separately by T19's validator.
|
||||
*/
|
||||
export function parseCustomModifierDescriptor(
|
||||
raw: unknown,
|
||||
): CustomModifierDescriptor {
|
||||
return CustomModifierDescriptorSchema.parse(raw) as CustomModifierDescriptor;
|
||||
}
|
||||
|
||||
/** Non-throwing variant, returns Zod's discriminated SafeParseReturnType. */
|
||||
export function safeParseCustomModifierDescriptor(raw: unknown) {
|
||||
return CustomModifierDescriptorSchema.safeParse(raw);
|
||||
}
|
||||
|
||||
/** Serialize a CustomModifierDescriptor to a JSON-safe plain object. */
|
||||
export function serializeCustomModifierDescriptor(
|
||||
descriptor: CustomModifierDescriptor,
|
||||
): unknown {
|
||||
return CustomModifierDescriptorSchema.parse(descriptor);
|
||||
}
|
||||
52
packages/chess/src/modifiers/custom/types.test.ts
Normal file
52
packages/chess/src/modifiers/custom/types.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, expectTypeOf, it } from "vitest";
|
||||
import type { ChessAttrKey } from "../../schema.js";
|
||||
import {
|
||||
asCustomModifierId,
|
||||
type EffectPrimitiveNode,
|
||||
type CustomModifierDescriptor,
|
||||
type CustomModifierId,
|
||||
} from "./types.js";
|
||||
|
||||
describe("custom modifier descriptor types", () => {
|
||||
it("asCustomModifierId round-trips the same runtime string", () => {
|
||||
const raw = "custom:hp-plus";
|
||||
const id = asCustomModifierId(raw);
|
||||
expect(id).toBe(raw);
|
||||
expectTypeOf(id).toEqualTypeOf<CustomModifierId>();
|
||||
});
|
||||
|
||||
it("accepts the expected descriptor structure", () => {
|
||||
const descriptor: CustomModifierDescriptor = {
|
||||
type: "data",
|
||||
id: asCustomModifierId("custom:rook-boost"),
|
||||
name: "Rook boost",
|
||||
description: "Adds a small directional bonus for rooks.",
|
||||
version: 1,
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 1 } },
|
||||
],
|
||||
targetAttrs: ["RangeBonus"],
|
||||
uiForm: "primitive-composer",
|
||||
source: "custom",
|
||||
author: "Test Author",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
expect(descriptor.type).toBe("data");
|
||||
expect(descriptor.version).toBe(1);
|
||||
expect(descriptor.source).toBe("custom");
|
||||
expect(descriptor.uiForm).toBe("primitive-composer");
|
||||
});
|
||||
|
||||
it("exposes targetAttrs as a readonly ChessAttrKey array type", () => {
|
||||
expectTypeOf<CustomModifierDescriptor["targetAttrs"]>().toEqualTypeOf<
|
||||
readonly ChessAttrKey[]
|
||||
>();
|
||||
});
|
||||
|
||||
it("exposes primitives as a readonly array type", () => {
|
||||
expectTypeOf<CustomModifierDescriptor["primitives"]>().toEqualTypeOf<
|
||||
readonly EffectPrimitiveNode[]
|
||||
>();
|
||||
});
|
||||
});
|
||||
52
packages/chess/src/modifiers/custom/types.ts
Normal file
52
packages/chess/src/modifiers/custom/types.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { ChessAttrKey } from "../../schema.js";
|
||||
import type { EffectPrimitiveNode } from "../primitives/types.js";
|
||||
|
||||
export type { EffectPrimitiveNode };
|
||||
|
||||
/**
|
||||
* Compile-time branded identifier for user-authored custom modifiers.
|
||||
*
|
||||
* At runtime this is a plain `string`. The brand exists only in the type
|
||||
* system to prevent accidental interchange with other string ids.
|
||||
*/
|
||||
export type CustomModifierId = string & { readonly __brand: "CustomModifierId" };
|
||||
|
||||
/**
|
||||
* Coerce a raw string to the branded CustomModifierId type. Use ONLY at trust
|
||||
* boundaries where you've already established that `s` is the canonical
|
||||
* custom-modifier identifier (e.g. persisted library payloads or validated
|
||||
* user input). Prefer passing `CustomModifierId` through end-to-end when
|
||||
* possible; this helper is the single legitimate cast site.
|
||||
*/
|
||||
export const asCustomModifierId = (s: string): CustomModifierId => s as CustomModifierId;
|
||||
|
||||
/**
|
||||
* User-authored custom modifier descriptor.
|
||||
*
|
||||
* `type` is the forward-compatibility discriminator for the custom-modifier
|
||||
* family. T3 ships only data descriptors (`"data"`); T4 introduces
|
||||
* `"scripted"` descriptors while preserving the shared trunk fields
|
||||
* (`id`/`name`/`description`/`version`).
|
||||
*/
|
||||
export interface CustomModifierDescriptor {
|
||||
readonly type: "data";
|
||||
readonly id: CustomModifierId;
|
||||
/** Human-readable title (1-40 chars, validated in Wave 3). */
|
||||
readonly name: string;
|
||||
/** Optional explanatory text (0-200 chars, validated in Wave 3). */
|
||||
readonly description: string;
|
||||
/** Descriptor schema version; v2+ must use a new id. */
|
||||
readonly version: 1;
|
||||
/** Primitive composition tree/list for this custom modifier. */
|
||||
readonly primitives: readonly EffectPrimitiveNode[];
|
||||
/** Attr keys this modifier reads/writes for UI conflict surfacing. */
|
||||
readonly targetAttrs: readonly ChessAttrKey[];
|
||||
/** Routes editing to the custom-modifier primitive composer UI. */
|
||||
readonly uiForm: "primitive-composer";
|
||||
/** Distinguishes this descriptor family from premade built-ins. */
|
||||
readonly source: "custom";
|
||||
/** Optional cosmetic attribution string; never server-validated. */
|
||||
readonly author?: string;
|
||||
/** Optional unix timestamp auto-populated by library persistence. */
|
||||
readonly createdAt?: number;
|
||||
}
|
||||
266
packages/chess/src/modifiers/custom/validate.test.ts
Normal file
266
packages/chess/src/modifiers/custom/validate.test.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import "../primitives/index.js";
|
||||
import { asCustomModifierId, type CustomModifierDescriptor } from "./types.js";
|
||||
import { validateCustomDescriptor } from "./validate.js";
|
||||
|
||||
function makeDescriptor(): CustomModifierDescriptor {
|
||||
return {
|
||||
type: "data",
|
||||
id: asCustomModifierId("custom:validated"),
|
||||
name: "Validated modifier",
|
||||
description: "A descriptor used for validator tests.",
|
||||
version: 1,
|
||||
primitives: [
|
||||
{
|
||||
kind: "add-to-attribute",
|
||||
params: {
|
||||
attr: "HpBonus",
|
||||
delta: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
targetAttrs: ["HpBonus"],
|
||||
uiForm: "primitive-composer",
|
||||
source: "custom",
|
||||
};
|
||||
}
|
||||
|
||||
describe("validateCustomDescriptor", () => {
|
||||
it("accepts a valid descriptor", () => {
|
||||
const result = validateCustomDescriptor(makeDescriptor());
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects empty descriptor id", () => {
|
||||
const descriptor = {
|
||||
...makeDescriptor(),
|
||||
id: asCustomModifierId(""),
|
||||
};
|
||||
|
||||
const result = validateCustomDescriptor(descriptor);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.some((e) => e.code === "descriptor.id.empty")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty descriptor name", () => {
|
||||
const descriptor = {
|
||||
...makeDescriptor(),
|
||||
name: "",
|
||||
};
|
||||
|
||||
const result = validateCustomDescriptor(descriptor);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.some((e) => e.code === "descriptor.name.length")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects descriptor name longer than 40 characters", () => {
|
||||
const descriptor = {
|
||||
...makeDescriptor(),
|
||||
name: "x".repeat(41),
|
||||
};
|
||||
|
||||
const result = validateCustomDescriptor(descriptor);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.some((e) => e.code === "descriptor.name.length")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects descriptor description longer than 200 characters", () => {
|
||||
const descriptor = {
|
||||
...makeDescriptor(),
|
||||
description: "x".repeat(201),
|
||||
};
|
||||
|
||||
const result = validateCustomDescriptor(descriptor);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.some((e) => e.code === "descriptor.description.length")).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-v1 version descriptors", () => {
|
||||
const descriptor = makeDescriptor();
|
||||
Object.defineProperty(descriptor, "version", { value: 2 });
|
||||
|
||||
const result = validateCustomDescriptor(descriptor);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.some((e) => e.code === "descriptor.version.unsupported")).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-data descriptor type", () => {
|
||||
const descriptor = makeDescriptor();
|
||||
Object.defineProperty(descriptor, "type", { value: "scripted" });
|
||||
|
||||
const result = validateCustomDescriptor(descriptor);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.some((e) => e.code === "descriptor.type.invalid")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown primitive kinds", () => {
|
||||
const descriptor = makeDescriptor();
|
||||
Object.defineProperty(descriptor, "primitives", {
|
||||
value: [{ kind: "not-real", params: {} }],
|
||||
});
|
||||
|
||||
const result = validateCustomDescriptor(descriptor);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.some((e) => e.code === "primitive.kind.unknown")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("collects paramsSchema validation errors", () => {
|
||||
const descriptor = makeDescriptor();
|
||||
Object.defineProperty(descriptor, "primitives", {
|
||||
value: [
|
||||
{
|
||||
kind: "add-to-attribute",
|
||||
params: {
|
||||
attr: "HpBonus",
|
||||
delta: "wrong-type",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = validateCustomDescriptor(descriptor);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.some((e) => e.code === "primitive.params.invalid")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces max nesting depth of 3 container levels", () => {
|
||||
const deeplyNested = {
|
||||
kind: "on-turn-start",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "conditional",
|
||||
params: {
|
||||
condition: { type: "always" },
|
||||
then: [
|
||||
{
|
||||
kind: "on-capture",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "on-damaged",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "add-to-attribute",
|
||||
params: { attr: "HpBonus", delta: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const descriptor = makeDescriptor();
|
||||
Object.defineProperty(descriptor, "primitives", {
|
||||
value: [deeplyNested],
|
||||
});
|
||||
|
||||
const result = validateCustomDescriptor(descriptor);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.some((e) => e.code === "descriptor.primitives.depth.exceeded")).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces max primitive count of 50", () => {
|
||||
const descriptor = makeDescriptor();
|
||||
Object.defineProperty(descriptor, "primitives", {
|
||||
value: Array.from({ length: 51 }, () => ({
|
||||
kind: "seed-attribute",
|
||||
params: { attr: "HpBonus", value: 1 },
|
||||
})),
|
||||
});
|
||||
|
||||
const result = validateCustomDescriptor(descriptor);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.some((e) => e.code === "descriptor.primitives.count.exceeded")).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects nested params that reference the parent descriptor id", () => {
|
||||
const descriptor = makeDescriptor();
|
||||
Object.defineProperty(descriptor, "id", {
|
||||
value: asCustomModifierId("custom:self-ref"),
|
||||
});
|
||||
Object.defineProperty(descriptor, "primitives", {
|
||||
value: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: {
|
||||
attr: "Hp",
|
||||
value: {
|
||||
nested: {
|
||||
id: "custom:self-ref",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = validateCustomDescriptor(descriptor);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.some((e) => e.code === "primitive.params.self-reference")).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects structural circular params", () => {
|
||||
const circular: { self?: unknown } = {};
|
||||
circular.self = circular;
|
||||
|
||||
const descriptor = makeDescriptor();
|
||||
Object.defineProperty(descriptor, "primitives", {
|
||||
value: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: {
|
||||
attr: "Hp",
|
||||
value: circular,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = validateCustomDescriptor(descriptor);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.some((e) => e.code === "primitive.params.circular")).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
250
packages/chess/src/modifiers/custom/validate.ts
Normal file
250
packages/chess/src/modifiers/custom/validate.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { PRIMITIVE_REGISTRY } from "../primitives/registry.js";
|
||||
import type { EffectPrimitiveNode } from "../primitives/types.js";
|
||||
import "../primitives/index.js";
|
||||
import type { CustomModifierDescriptor } from "./types.js";
|
||||
|
||||
export type ValidationError = {
|
||||
code: string;
|
||||
path: (string | number)[];
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ValidationResult = { ok: true } | { ok: false; errors: ValidationError[] };
|
||||
|
||||
const MAX_DESCRIPTOR_NAME_LENGTH = 40;
|
||||
const MAX_DESCRIPTOR_DESCRIPTION_LENGTH = 200;
|
||||
const MAX_RECURSION_DEPTH = 3;
|
||||
const MAX_PRIMITIVE_COUNT = 50;
|
||||
|
||||
export function validateCustomDescriptor(
|
||||
descriptor: CustomModifierDescriptor,
|
||||
): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
const descriptorId = String(descriptor.id);
|
||||
|
||||
if (descriptorId.length === 0) {
|
||||
errors.push({
|
||||
code: "descriptor.id.empty",
|
||||
path: ["id"],
|
||||
message: "descriptor.id must be non-empty",
|
||||
});
|
||||
}
|
||||
|
||||
if (descriptor.name.length < 1 || descriptor.name.length > MAX_DESCRIPTOR_NAME_LENGTH) {
|
||||
errors.push({
|
||||
code: "descriptor.name.length",
|
||||
path: ["name"],
|
||||
message: "descriptor.name must be between 1 and 40 characters",
|
||||
});
|
||||
}
|
||||
|
||||
if (descriptor.description.length > MAX_DESCRIPTOR_DESCRIPTION_LENGTH) {
|
||||
errors.push({
|
||||
code: "descriptor.description.length",
|
||||
path: ["description"],
|
||||
message: "descriptor.description must be between 0 and 200 characters",
|
||||
});
|
||||
}
|
||||
|
||||
if (descriptor.version !== 1) {
|
||||
errors.push({
|
||||
code: "descriptor.version.unsupported",
|
||||
path: ["version"],
|
||||
message: "descriptor.version must be 1",
|
||||
});
|
||||
}
|
||||
|
||||
if (descriptor.type !== "data") {
|
||||
errors.push({
|
||||
code: "descriptor.type.invalid",
|
||||
path: ["type"],
|
||||
message: 'descriptor.type must be "data"',
|
||||
});
|
||||
}
|
||||
|
||||
const walkState = {
|
||||
totalPrimitiveCount: 0,
|
||||
emittedPrimitiveCountError: false,
|
||||
};
|
||||
|
||||
walkPrimitiveNodes({
|
||||
nodes: descriptor.primitives,
|
||||
errors,
|
||||
descriptorId,
|
||||
containerDepth: 0,
|
||||
basePath: ["primitives"],
|
||||
walkState,
|
||||
});
|
||||
|
||||
if (errors.length === 0) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return { ok: false, errors };
|
||||
}
|
||||
|
||||
function walkPrimitiveNodes(input: {
|
||||
nodes: readonly EffectPrimitiveNode[];
|
||||
errors: ValidationError[];
|
||||
descriptorId: string;
|
||||
containerDepth: number;
|
||||
basePath: (string | number)[];
|
||||
walkState: {
|
||||
totalPrimitiveCount: number;
|
||||
emittedPrimitiveCountError: boolean;
|
||||
};
|
||||
}): void {
|
||||
const { nodes, errors, descriptorId, containerDepth, basePath, walkState } = input;
|
||||
|
||||
for (let index = 0; index < nodes.length; index += 1) {
|
||||
const node = nodes[index];
|
||||
if (node === undefined) continue;
|
||||
const nodePath = [...basePath, index] as (string | number)[];
|
||||
const paramsPath = [...nodePath, "params"] as (string | number)[];
|
||||
|
||||
walkState.totalPrimitiveCount += 1;
|
||||
if (
|
||||
walkState.totalPrimitiveCount > MAX_PRIMITIVE_COUNT &&
|
||||
!walkState.emittedPrimitiveCountError
|
||||
) {
|
||||
errors.push({
|
||||
code: "descriptor.primitives.count.exceeded",
|
||||
path: ["primitives"],
|
||||
message: `descriptor primitives cannot exceed ${MAX_PRIMITIVE_COUNT} nodes`,
|
||||
});
|
||||
walkState.emittedPrimitiveCountError = true;
|
||||
}
|
||||
|
||||
scanParamsForCyclesAndSelfReference({
|
||||
value: node.params,
|
||||
descriptorId,
|
||||
errors,
|
||||
basePath: paramsPath,
|
||||
stack: new Set<object>(),
|
||||
seen: new Set<object>(),
|
||||
});
|
||||
|
||||
const primitiveDescriptor = PRIMITIVE_REGISTRY.get(node.kind);
|
||||
if (primitiveDescriptor === undefined) {
|
||||
errors.push({
|
||||
code: "primitive.kind.unknown",
|
||||
path: [...nodePath, "kind"],
|
||||
message: `Unknown primitive kind: ${node.kind}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedParams = primitiveDescriptor.paramsSchema.safeParse(node.params);
|
||||
if (!parsedParams.success) {
|
||||
for (const issue of parsedParams.error.issues) {
|
||||
errors.push({
|
||||
code: "primitive.params.invalid",
|
||||
path: [...paramsPath, ...normalizeIssuePath(issue.path)],
|
||||
message: issue.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (primitiveDescriptor.childPrimitives === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextDepth = containerDepth + 1;
|
||||
if (nextDepth > MAX_RECURSION_DEPTH) {
|
||||
errors.push({
|
||||
code: "descriptor.primitives.depth.exceeded",
|
||||
path: nodePath,
|
||||
message: `primitive nesting depth cannot exceed ${MAX_RECURSION_DEPTH}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let children: readonly EffectPrimitiveNode[] = [];
|
||||
try {
|
||||
const paramsForChildren = parsedParams.success ? parsedParams.data : node.params;
|
||||
children = primitiveDescriptor.childPrimitives(paramsForChildren);
|
||||
} catch {
|
||||
children = [];
|
||||
}
|
||||
|
||||
walkPrimitiveNodes({
|
||||
nodes: children,
|
||||
errors,
|
||||
descriptorId,
|
||||
containerDepth: nextDepth,
|
||||
basePath: [...nodePath, "children"],
|
||||
walkState,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIssuePath(path: readonly (string | number | symbol)[]): (string | number)[] {
|
||||
return path.map((part) => (typeof part === "number" ? part : String(part)));
|
||||
}
|
||||
|
||||
function scanParamsForCyclesAndSelfReference(input: {
|
||||
value: unknown;
|
||||
descriptorId: string;
|
||||
errors: ValidationError[];
|
||||
basePath: (string | number)[];
|
||||
stack: Set<object>;
|
||||
seen: Set<object>;
|
||||
}): void {
|
||||
const { value, descriptorId, errors, basePath, stack, seen } = input;
|
||||
|
||||
if (value === null || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stack.has(value)) {
|
||||
errors.push({
|
||||
code: "primitive.params.circular",
|
||||
path: basePath,
|
||||
message: "primitive params contain a structural circular reference",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (seen.has(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seen.add(value);
|
||||
stack.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
scanParamsForCyclesAndSelfReference({
|
||||
value: value[index],
|
||||
descriptorId,
|
||||
errors,
|
||||
basePath: [...basePath, index],
|
||||
stack,
|
||||
seen,
|
||||
});
|
||||
}
|
||||
stack.delete(value);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (key === "id" && typeof nested === "string" && nested === descriptorId) {
|
||||
errors.push({
|
||||
code: "primitive.params.self-reference",
|
||||
path: [...basePath, key],
|
||||
message: "primitive params must not reference the parent descriptor id",
|
||||
});
|
||||
}
|
||||
|
||||
scanParamsForCyclesAndSelfReference({
|
||||
value: nested,
|
||||
descriptorId,
|
||||
errors,
|
||||
basePath: [...basePath, key],
|
||||
stack,
|
||||
seen,
|
||||
});
|
||||
}
|
||||
|
||||
stack.delete(value);
|
||||
}
|
||||
103
packages/chess/src/modifiers/descriptors/capture-flags.test.ts
Normal file
103
packages/chess/src/modifiers/descriptors/capture-flags.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { Session } from "@paratype/rete";
|
||||
import { MODIFIER_REGISTRY } from "../registry.js";
|
||||
import { CaptureFlag } from "../../schema.js";
|
||||
import "./capture-flags.js";
|
||||
import { CAPTURE_FLAGS_DESCRIPTOR, hasCaptureFlag } from "./capture-flags.js";
|
||||
|
||||
describe("capture-flags descriptor — registry", () => {
|
||||
it("registers in MODIFIER_REGISTRY under key 'capture-flags'", () => {
|
||||
expect(MODIFIER_REGISTRY.has("capture-flags")).toBe(true);
|
||||
expect(MODIFIER_REGISTRY.get("capture-flags")).toBe(CAPTURE_FLAGS_DESCRIPTOR);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capture-flags descriptor — apply()", () => {
|
||||
it("seeds CaptureFlags fact on piece entity in session", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "pawn");
|
||||
|
||||
CAPTURE_FLAGS_DESCRIPTOR.apply(session, pieceId, CaptureFlag.CANNOT_BE_CAPTURED);
|
||||
|
||||
expect(session.contains(pieceId, "CaptureFlags")).toBe(true);
|
||||
expect(session.get(pieceId, "CaptureFlags")).toBe(CaptureFlag.CANNOT_BE_CAPTURED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capture-flags descriptor — describe()", () => {
|
||||
it("returns 'no flags' for 0", () => {
|
||||
expect(CAPTURE_FLAGS_DESCRIPTOR.describe(0)).toBe("no flags");
|
||||
});
|
||||
|
||||
it("describes combined CAN_CAPTURE_OWN | CANNOT_BE_CAPTURED flags", () => {
|
||||
const combined = CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.CANNOT_BE_CAPTURED;
|
||||
expect(CAPTURE_FLAGS_DESCRIPTOR.describe(combined)).toBe("can-capture-own, cannot-be-captured");
|
||||
});
|
||||
|
||||
it("describes all three flags", () => {
|
||||
const allFlags = CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.CANNOT_BE_CAPTURED | CaptureFlag.EN_PASSANT;
|
||||
expect(CAPTURE_FLAGS_DESCRIPTOR.describe(allFlags)).toBe(
|
||||
"can-capture-own, cannot-be-captured, en-passant",
|
||||
);
|
||||
});
|
||||
|
||||
it("describes a single flag", () => {
|
||||
expect(CAPTURE_FLAGS_DESCRIPTOR.describe(CaptureFlag.EN_PASSANT)).toBe("en-passant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("capture-flags descriptor — hasCaptureFlag()", () => {
|
||||
it("returns true when the flag is set", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
CAPTURE_FLAGS_DESCRIPTOR.apply(
|
||||
session,
|
||||
pieceId,
|
||||
CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.CANNOT_BE_CAPTURED,
|
||||
);
|
||||
|
||||
expect(hasCaptureFlag(session, pieceId, CaptureFlag.CANNOT_BE_CAPTURED)).toBe(true);
|
||||
expect(hasCaptureFlag(session, pieceId, CaptureFlag.CAN_CAPTURE_OWN)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the flag is not set", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
CAPTURE_FLAGS_DESCRIPTOR.apply(session, pieceId, CaptureFlag.CAN_CAPTURE_OWN);
|
||||
|
||||
expect(hasCaptureFlag(session, pieceId, CaptureFlag.EN_PASSANT)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when CaptureFlags fact is absent", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
|
||||
expect(hasCaptureFlag(session, pieceId, CaptureFlag.CANNOT_BE_CAPTURED)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capture-flags descriptor — valueSchema", () => {
|
||||
const schema = CAPTURE_FLAGS_DESCRIPTOR.valueSchema;
|
||||
|
||||
it("accepts 0 (no flags)", () => {
|
||||
expect(() => schema.parse(0)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts MAX_FLAGS (all flags OR'd together = 7)", () => {
|
||||
const maxFlags = CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.CANNOT_BE_CAPTURED | CaptureFlag.EN_PASSANT;
|
||||
expect(() => schema.parse(maxFlags)).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects -1 (below min)", () => {
|
||||
expect(() => schema.parse(-1)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects 8 (above MAX_FLAGS = 7)", () => {
|
||||
expect(() => schema.parse(8)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-integer values", () => {
|
||||
expect(() => schema.parse(1.5)).toThrow();
|
||||
});
|
||||
});
|
||||
39
packages/chess/src/modifiers/descriptors/capture-flags.ts
Normal file
39
packages/chess/src/modifiers/descriptors/capture-flags.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { z } from "zod";
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
import { MODIFIER_REGISTRY } from "../registry.js";
|
||||
import type { ModifierDescriptor } from "../types.js";
|
||||
import { CaptureFlag } from "../../schema.js";
|
||||
|
||||
// Bitflag validation: valid values are any combination of the 3 flags
|
||||
const MAX_FLAGS = CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.CANNOT_BE_CAPTURED | CaptureFlag.EN_PASSANT;
|
||||
const schema = z.number().int().min(0).max(MAX_FLAGS);
|
||||
type Value = z.infer<typeof schema>;
|
||||
|
||||
function describeFlags(value: Value): string {
|
||||
const parts: string[] = [];
|
||||
if (value & CaptureFlag.CAN_CAPTURE_OWN) parts.push("can-capture-own");
|
||||
if (value & CaptureFlag.CANNOT_BE_CAPTURED) parts.push("cannot-be-captured");
|
||||
if (value & CaptureFlag.EN_PASSANT) parts.push("en-passant");
|
||||
return parts.length > 0 ? parts.join(", ") : "no flags";
|
||||
}
|
||||
|
||||
export const CAPTURE_FLAGS_DESCRIPTOR: ModifierDescriptor<Value> = {
|
||||
id: "capture-flags",
|
||||
attrName: "CaptureFlags",
|
||||
label: "Capture Behavior",
|
||||
valueSchema: schema,
|
||||
stackingRule: "union", // bitwise OR for stacking
|
||||
uiForm: "capture-flags",
|
||||
apply(session: Session, pieceId: EntityId, effectiveValue: Value): void {
|
||||
session.insert(pieceId, "CaptureFlags", effectiveValue);
|
||||
},
|
||||
describe: describeFlags,
|
||||
};
|
||||
|
||||
MODIFIER_REGISTRY.register(CAPTURE_FLAGS_DESCRIPTOR);
|
||||
|
||||
/** Check if a piece has a specific capture flag set. */
|
||||
export function hasCaptureFlag(session: Session, pieceId: EntityId, flag: number): boolean {
|
||||
const flags = session.get(pieceId, "CaptureFlags");
|
||||
return typeof flags === "number" && (flags & flag) !== 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { Session } from "@paratype/rete";
|
||||
import { MODIFIER_REGISTRY } from "../registry.js";
|
||||
import "./damage-resistance.js";
|
||||
import {
|
||||
DAMAGE_RESISTANCE_DESCRIPTOR,
|
||||
applyResistance,
|
||||
stackResistances,
|
||||
} from "./damage-resistance.js";
|
||||
|
||||
describe("damage-resistance descriptor — registry", () => {
|
||||
it("registers in MODIFIER_REGISTRY under key 'damage-resistance'", () => {
|
||||
expect(MODIFIER_REGISTRY.has("damage-resistance")).toBe(true);
|
||||
expect(MODIFIER_REGISTRY.get("damage-resistance")).toBe(DAMAGE_RESISTANCE_DESCRIPTOR);
|
||||
});
|
||||
});
|
||||
|
||||
describe("damage-resistance descriptor — apply()", () => {
|
||||
it("seeds DamageResistance fact on piece entity in session", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "pawn");
|
||||
|
||||
DAMAGE_RESISTANCE_DESCRIPTOR.apply(session, pieceId, 0.5);
|
||||
|
||||
expect(session.contains(pieceId, "DamageResistance")).toBe(true);
|
||||
expect(session.get(pieceId, "DamageResistance")).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("damage-resistance descriptor — describe()", () => {
|
||||
it("formats 0.5 as '50% damage resistance'", () => {
|
||||
expect(DAMAGE_RESISTANCE_DESCRIPTOR.describe(0.5)).toBe("50% damage resistance");
|
||||
});
|
||||
|
||||
it("formats 0 as '0% damage resistance'", () => {
|
||||
expect(DAMAGE_RESISTANCE_DESCRIPTOR.describe(0)).toBe("0% damage resistance");
|
||||
});
|
||||
|
||||
it("formats 1 as '100% damage resistance'", () => {
|
||||
expect(DAMAGE_RESISTANCE_DESCRIPTOR.describe(1)).toBe("100% damage resistance");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyResistance()", () => {
|
||||
it("halves damage at 0.5 resistance", () => {
|
||||
expect(applyResistance(4, 0.5)).toBe(2);
|
||||
});
|
||||
|
||||
it("passes full damage through at 0 resistance", () => {
|
||||
expect(applyResistance(4, 0)).toBe(4);
|
||||
});
|
||||
|
||||
it("reduces damage to 0 at 1 (full immunity)", () => {
|
||||
expect(applyResistance(4, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps negative results to 0", () => {
|
||||
// Resistance > 1 is schema-invalid, but applyResistance is defensive
|
||||
expect(applyResistance(4, 1.5)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stackResistances()", () => {
|
||||
it("returns 0 for an empty array", () => {
|
||||
expect(stackResistances([])).toBe(0);
|
||||
});
|
||||
|
||||
it("stacks two 0.5 resistances multiplicatively to 0.75", () => {
|
||||
// 1 - (0.5 * 0.5) = 0.75
|
||||
expect(stackResistances([0.5, 0.5])).toBe(0.75);
|
||||
});
|
||||
|
||||
it("stacks three 0.5 resistances to 0.875", () => {
|
||||
// 1 - (0.5 * 0.5 * 0.5) = 0.875
|
||||
expect(stackResistances([0.5, 0.5, 0.5])).toBe(0.875);
|
||||
});
|
||||
|
||||
it("returns 0 for a single 0 resistance", () => {
|
||||
expect(stackResistances([0])).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 1 (clamped) for a single full immunity", () => {
|
||||
expect(stackResistances([1])).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { z } from "zod";
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
import { MODIFIER_REGISTRY } from "../registry.js";
|
||||
import type { ModifierDescriptor } from "../types.js";
|
||||
|
||||
// Resistance: 0 = no resistance, 1 = immune (100% damage reduction)
|
||||
const schema = z.number().min(0).max(1);
|
||||
type Value = z.infer<typeof schema>;
|
||||
|
||||
export const DAMAGE_RESISTANCE_DESCRIPTOR: ModifierDescriptor<Value> = {
|
||||
id: "damage-resistance",
|
||||
attrName: "DamageResistance",
|
||||
label: "Damage Resistance",
|
||||
valueSchema: schema,
|
||||
stackingRule: "multiplicative",
|
||||
uiForm: "percentage",
|
||||
apply(session: Session, pieceId: EntityId, effectiveValue: Value): void {
|
||||
session.insert(pieceId, "DamageResistance", effectiveValue);
|
||||
},
|
||||
describe(value: Value): string {
|
||||
return `${Math.round(value * 100)}% damage resistance`;
|
||||
},
|
||||
};
|
||||
|
||||
MODIFIER_REGISTRY.register(DAMAGE_RESISTANCE_DESCRIPTOR);
|
||||
|
||||
/**
|
||||
* Apply damage resistance to an incoming damage amount.
|
||||
* Returns the effective damage after applying resistance.
|
||||
* Result is always ≥ 0.
|
||||
*
|
||||
* Formula (from ADR-4 stacking): effective resistance already stacked
|
||||
* multiplicatively before being seeded in DamageResistance fact.
|
||||
* So: effectiveDamage = amount * (1 - resistance), clamped to [0, ∞).
|
||||
*/
|
||||
export function applyResistance(amount: number, resistance: number): number {
|
||||
return Math.max(0, amount * (1 - resistance));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute stacked resistance from multiple sources using multiplicative formula.
|
||||
* ADR-4: effectiveResistance = 1 - ∏(1 - r_i)
|
||||
* Result clamped to [0, 1].
|
||||
*/
|
||||
export function stackResistances(resistances: readonly number[]): number {
|
||||
if (resistances.length === 0) return 0;
|
||||
const product = resistances.reduce((acc, r) => acc * (1 - r), 1);
|
||||
return Math.max(0, Math.min(1, 1 - product));
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { Session } from "@paratype/rete";
|
||||
import { MODIFIER_REGISTRY } from "../registry.js";
|
||||
import "./direction-additions.js";
|
||||
import {
|
||||
DIRECTION_ADDITIONS_DESCRIPTOR,
|
||||
generateDirectionMoves,
|
||||
} from "./direction-additions.js";
|
||||
|
||||
// Square encoding: square = rank * 8 + file
|
||||
// e4 = rank 3, file 4 → 3*8+4 = 28
|
||||
// e3 = rank 2, file 4 → 2*8+4 = 20
|
||||
// e5 = rank 4, file 4 → 4*8+4 = 36
|
||||
// d4 = rank 3, file 3 → 3*8+3 = 27
|
||||
// f4 = rank 3, file 5 → 3*8+5 = 29
|
||||
|
||||
describe("direction-additions descriptor — registry", () => {
|
||||
it("registers in MODIFIER_REGISTRY under key 'direction-additions'", () => {
|
||||
expect(MODIFIER_REGISTRY.has("direction-additions")).toBe(true);
|
||||
expect(MODIFIER_REGISTRY.get("direction-additions")).toBe(DIRECTION_ADDITIONS_DESCRIPTOR);
|
||||
});
|
||||
});
|
||||
|
||||
describe("direction-additions descriptor — apply()", () => {
|
||||
it("seeds DirectionAdditions fact on piece entity in session", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "pawn");
|
||||
|
||||
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["backward"]);
|
||||
|
||||
expect(session.contains(pieceId, "DirectionAdditions")).toBe(true);
|
||||
expect(session.get(pieceId, "DirectionAdditions")).toEqual(["backward"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("direction-additions descriptor — describe()", () => {
|
||||
it("formats a single direction", () => {
|
||||
expect(DIRECTION_ADDITIONS_DESCRIPTOR.describe(["forward"])).toBe("+Directions: forward");
|
||||
});
|
||||
|
||||
it("contains all direction names when multiple directions given (union stacking)", () => {
|
||||
const result = DIRECTION_ADDITIONS_DESCRIPTOR.describe(["forward", "backward"]);
|
||||
expect(result).toContain("forward");
|
||||
expect(result).toContain("backward");
|
||||
});
|
||||
|
||||
it("formats all eight directions", () => {
|
||||
const all = DIRECTION_ADDITIONS_DESCRIPTOR.describe([
|
||||
"forward", "backward", "left", "right",
|
||||
"diagonal-fl", "diagonal-fr", "diagonal-bl", "diagonal-br",
|
||||
]);
|
||||
expect(all).toContain("diagonal-fl");
|
||||
expect(all).toContain("diagonal-br");
|
||||
});
|
||||
});
|
||||
|
||||
describe("direction-additions — generateDirectionMoves()", () => {
|
||||
it("returns backward move for white pawn at e4 when e3 is empty", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "pawn");
|
||||
session.insert(pieceId, "Color", "white");
|
||||
session.insert(pieceId, "Position", 28); // e4
|
||||
|
||||
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["backward"]);
|
||||
|
||||
const moves = generateDirectionMoves(session, pieceId);
|
||||
expect(moves).toHaveLength(1);
|
||||
expect(moves[0]).toMatchObject({ from: 28, to: 20, isCapture: false });
|
||||
});
|
||||
|
||||
it("returns no move when target square is occupied (blocked backward)", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "pawn");
|
||||
session.insert(pieceId, "Color", "white");
|
||||
session.insert(pieceId, "Position", 28); // e4
|
||||
|
||||
// Blocker at e3 (square 20)
|
||||
const blockerId = session.nextId();
|
||||
session.insert(blockerId, "PieceType", "pawn");
|
||||
session.insert(blockerId, "Color", "black");
|
||||
session.insert(blockerId, "Position", 20); // e3
|
||||
|
||||
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["backward"]);
|
||||
|
||||
const moves = generateDirectionMoves(session, pieceId);
|
||||
expect(moves).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns union of moves for two directions, no duplication", () => {
|
||||
// white pawn at e4: forward → e5 (36), backward → e3 (20)
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "pawn");
|
||||
session.insert(pieceId, "Color", "white");
|
||||
session.insert(pieceId, "Position", 28); // e4
|
||||
|
||||
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["forward", "backward"]);
|
||||
|
||||
const moves = generateDirectionMoves(session, pieceId);
|
||||
expect(moves).toHaveLength(2);
|
||||
const targets = moves.map(m => m.to);
|
||||
expect(targets).toContain(36); // e5 (forward)
|
||||
expect(targets).toContain(20); // e3 (backward)
|
||||
});
|
||||
|
||||
it("returns no moves when no DirectionAdditions fact is set", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "pawn");
|
||||
session.insert(pieceId, "Color", "white");
|
||||
session.insert(pieceId, "Position", 28); // e4
|
||||
// DirectionAdditions NOT seeded
|
||||
|
||||
const moves = generateDirectionMoves(session, pieceId);
|
||||
expect(moves).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses color-relative forward direction: black pawn at e5 forward goes to e4", () => {
|
||||
// black forward = dr=-1; e5 = square 36, e4 = square 28
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "pawn");
|
||||
session.insert(pieceId, "Color", "black");
|
||||
session.insert(pieceId, "Position", 36); // e5
|
||||
|
||||
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["forward"]);
|
||||
|
||||
const moves = generateDirectionMoves(session, pieceId);
|
||||
expect(moves).toHaveLength(1);
|
||||
expect(moves[0]).toMatchObject({ from: 36, to: 28, isCapture: false });
|
||||
});
|
||||
|
||||
it("skips out-of-board targets (e.g. backward from rank 1)", () => {
|
||||
// white pawn at e1 (square 4, rank 0), backward would go to rank -1
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "pawn");
|
||||
session.insert(pieceId, "Color", "white");
|
||||
session.insert(pieceId, "Position", 4); // e1
|
||||
|
||||
DIRECTION_ADDITIONS_DESCRIPTOR.apply(session, pieceId, ["backward"]);
|
||||
|
||||
const moves = generateDirectionMoves(session, pieceId);
|
||||
expect(moves).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
102
packages/chess/src/modifiers/descriptors/direction-additions.ts
Normal file
102
packages/chess/src/modifiers/descriptors/direction-additions.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { z } from "zod";
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
import { MODIFIER_REGISTRY } from "../registry.js";
|
||||
import type { ModifierDescriptor, Direction } from "../types.js";
|
||||
import type { PieceColor, Square } from "../../schema.js";
|
||||
import { fileOf, rankOf, isOnBoard, squareOf } from "../../coord.js";
|
||||
import {
|
||||
getPiecePosition,
|
||||
getPieceColor,
|
||||
isPieceAt,
|
||||
} from "../../rules/board-queries.js";
|
||||
import type { LegalMove } from "../../rules/types.js";
|
||||
|
||||
const schema = z.array(
|
||||
z.enum([
|
||||
"forward",
|
||||
"backward",
|
||||
"left",
|
||||
"right",
|
||||
"diagonal-fl",
|
||||
"diagonal-fr",
|
||||
"diagonal-bl",
|
||||
"diagonal-br",
|
||||
]),
|
||||
);
|
||||
type Value = z.infer<typeof schema>;
|
||||
|
||||
/**
|
||||
* Compute file+rank delta for a named direction from a piece's perspective.
|
||||
* forward/backward are color-relative (toward/away from opponent's back rank).
|
||||
* left/right are board-absolute (toward a-file / h-file respectively).
|
||||
* Diagonals combine the two axes accordingly.
|
||||
*/
|
||||
function directionDelta(dir: Direction, color: PieceColor): { df: number; dr: number } {
|
||||
// +1 = white advances up ranks; -1 = black advances down ranks
|
||||
const forward = color === "white" ? 1 : -1;
|
||||
switch (dir) {
|
||||
case "forward": return { df: 0, dr: forward };
|
||||
case "backward": return { df: 0, dr: -forward };
|
||||
case "left": return { df: -1, dr: 0 }; // always toward a-file
|
||||
case "right": return { df: 1, dr: 0 }; // always toward h-file
|
||||
case "diagonal-fl": return { df: -1, dr: forward }; // forward + toward a-file
|
||||
case "diagonal-fr": return { df: 1, dr: forward }; // forward + toward h-file
|
||||
case "diagonal-bl": return { df: -1, dr: -forward }; // backward + toward a-file
|
||||
case "diagonal-br": return { df: 1, dr: -forward }; // backward + toward h-file
|
||||
}
|
||||
}
|
||||
|
||||
export const DIRECTION_ADDITIONS_DESCRIPTOR: ModifierDescriptor<Value> = {
|
||||
id: "direction-additions",
|
||||
attrName: "DirectionAdditions",
|
||||
label: "Direction Additions",
|
||||
valueSchema: schema,
|
||||
stackingRule: "union",
|
||||
uiForm: "direction-set",
|
||||
apply(session: Session, pieceId: EntityId, effectiveValue: Value): void {
|
||||
session.insert(pieceId, "DirectionAdditions", effectiveValue);
|
||||
},
|
||||
describe(value: Value): string {
|
||||
return `+Directions: ${value.join(", ")}`;
|
||||
},
|
||||
};
|
||||
|
||||
MODIFIER_REGISTRY.register(DIRECTION_ADDITIONS_DESCRIPTOR);
|
||||
|
||||
/**
|
||||
* Generate 1-square non-capture moves in all directions listed in the
|
||||
* piece's `DirectionAdditions` fact.
|
||||
*
|
||||
* Semantics:
|
||||
* - Step-piece only: exactly 1 square per direction (no sliding).
|
||||
* - Non-capture only: skips squares occupied by any piece.
|
||||
* Capture semantics are handled separately by CaptureFlags.
|
||||
* - Out-of-board targets are silently skipped.
|
||||
*
|
||||
* Called by the engine integration layer (T14) after the modifier profile
|
||||
* has seeded `DirectionAdditions` on the piece.
|
||||
*/
|
||||
export function generateDirectionMoves(
|
||||
session: Session,
|
||||
pieceId: EntityId,
|
||||
): LegalMove[] {
|
||||
const directions = session.get(pieceId, "DirectionAdditions") as readonly string[] | undefined;
|
||||
if (!directions || directions.length === 0) return [];
|
||||
|
||||
const from = getPiecePosition(session, pieceId);
|
||||
const color = getPieceColor(session, pieceId);
|
||||
if (from === null || color === null) return [];
|
||||
|
||||
const moves: LegalMove[] = [];
|
||||
for (const dir of directions as Direction[]) {
|
||||
const { df, dr } = directionDelta(dir, color);
|
||||
const toFile = fileOf(from as Square) + df;
|
||||
const toRank = rankOf(from as Square) + dr;
|
||||
if (!isOnBoard(toFile, toRank)) continue;
|
||||
const to = squareOf(toFile, toRank);
|
||||
if (!isPieceAt(session, to)) {
|
||||
moves.push({ pieceId, from: from as Square, to, isCapture: false });
|
||||
}
|
||||
}
|
||||
return moves;
|
||||
}
|
||||
58
packages/chess/src/modifiers/descriptors/hp-bonus.test.ts
Normal file
58
packages/chess/src/modifiers/descriptors/hp-bonus.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { Session } from "@paratype/rete";
|
||||
import { MODIFIER_REGISTRY } from "../registry.js";
|
||||
import "./hp-bonus.js";
|
||||
import { HP_BONUS_DESCRIPTOR } from "./hp-bonus.js";
|
||||
|
||||
describe("hp-bonus descriptor — registry", () => {
|
||||
it("registers in MODIFIER_REGISTRY under key 'hp-bonus'", () => {
|
||||
expect(MODIFIER_REGISTRY.has("hp-bonus")).toBe(true);
|
||||
expect(MODIFIER_REGISTRY.get("hp-bonus")).toBe(HP_BONUS_DESCRIPTOR);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hp-bonus descriptor — describe()", () => {
|
||||
it("formats positive values with a leading '+'", () => {
|
||||
expect(HP_BONUS_DESCRIPTOR.describe(3)).toBe("HP +3");
|
||||
});
|
||||
|
||||
it("formats negative values without an extra sign", () => {
|
||||
expect(HP_BONUS_DESCRIPTOR.describe(-1)).toBe("HP -1");
|
||||
});
|
||||
|
||||
it("formats zero as '+'", () => {
|
||||
expect(HP_BONUS_DESCRIPTOR.describe(0)).toBe("HP +0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hp-bonus descriptor — apply()", () => {
|
||||
it("seeds HpBonus fact on piece entity in session", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
// Seed a PieceType fact so it's a "real" piece entity.
|
||||
session.insert(pieceId, "PieceType", "pawn");
|
||||
|
||||
HP_BONUS_DESCRIPTOR.apply(session, pieceId, 3);
|
||||
|
||||
expect(session.contains(pieceId, "HpBonus")).toBe(true);
|
||||
expect(session.get(pieceId, "HpBonus")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hp-bonus descriptor — valueSchema", () => {
|
||||
const schema = HP_BONUS_DESCRIPTOR.valueSchema;
|
||||
|
||||
it("accepts values within range [-10, 10]", () => {
|
||||
expect(() => schema.parse(10)).not.toThrow();
|
||||
expect(() => schema.parse(-10)).not.toThrow();
|
||||
expect(() => schema.parse(0)).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects values above 10", () => {
|
||||
expect(() => schema.parse(11)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-integer values", () => {
|
||||
expect(() => schema.parse(1.5)).toThrow();
|
||||
});
|
||||
});
|
||||
26
packages/chess/src/modifiers/descriptors/hp-bonus.ts
Normal file
26
packages/chess/src/modifiers/descriptors/hp-bonus.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { z } from "zod";
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
import { MODIFIER_REGISTRY } from "../registry.js";
|
||||
import type { ModifierDescriptor } from "../types.js";
|
||||
|
||||
const schema = z.number().int().min(-10).max(10);
|
||||
type Value = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: ModifierDescriptor<Value> = {
|
||||
id: "hp-bonus",
|
||||
attrName: "HpBonus",
|
||||
baseAttr: "Hp",
|
||||
label: "HP Bonus",
|
||||
valueSchema: schema,
|
||||
stackingRule: "additive",
|
||||
uiForm: "number",
|
||||
apply(session: Session, pieceId: EntityId, effectiveValue: Value): void {
|
||||
session.insert(pieceId, "HpBonus", effectiveValue);
|
||||
},
|
||||
describe(value: Value): string {
|
||||
return `HP ${value >= 0 ? "+" : ""}${value}`;
|
||||
},
|
||||
};
|
||||
|
||||
MODIFIER_REGISTRY.register(descriptor);
|
||||
export { descriptor as HP_BONUS_DESCRIPTOR };
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { Session, type EntityId } from "@paratype/rete";
|
||||
import { MODIFIER_REGISTRY } from "../registry.js";
|
||||
import "./promotion-override.js";
|
||||
import { PROMOTION_OVERRIDE_DESCRIPTOR } from "./promotion-override.js";
|
||||
import type { PieceType, PieceColor, Square } from "../../schema.js";
|
||||
import { getPromotionMoves, applyPromotion } from "../../rules/promotion.js";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function setupSession(): Session {
|
||||
return new Session({ autoFire: false });
|
||||
}
|
||||
|
||||
function insertPiece(
|
||||
session: Session,
|
||||
id: number,
|
||||
type: PieceType,
|
||||
color: PieceColor,
|
||||
square: Square,
|
||||
): EntityId {
|
||||
const eid = id as EntityId;
|
||||
session.insert(eid, "PieceType", type);
|
||||
session.insert(eid, "Color", color);
|
||||
session.insert(eid, "Position", square);
|
||||
return eid;
|
||||
}
|
||||
|
||||
// ─── Registry ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("promotion-override descriptor — registry", () => {
|
||||
it("registers in MODIFIER_REGISTRY under key 'promotion-override'", () => {
|
||||
expect(MODIFIER_REGISTRY.has("promotion-override")).toBe(true);
|
||||
expect(MODIFIER_REGISTRY.get("promotion-override")).toBe(PROMOTION_OVERRIDE_DESCRIPTOR);
|
||||
});
|
||||
|
||||
it("has the correct id, attrName, and uiForm", () => {
|
||||
expect(PROMOTION_OVERRIDE_DESCRIPTOR.id).toBe("promotion-override");
|
||||
expect(PROMOTION_OVERRIDE_DESCRIPTOR.attrName).toBe("PromotionOverride");
|
||||
expect(PROMOTION_OVERRIDE_DESCRIPTOR.uiForm).toBe("promotion-target");
|
||||
expect(PROMOTION_OVERRIDE_DESCRIPTOR.stackingRule).toBe("priority-wins");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── describe() ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("promotion-override descriptor — describe()", () => {
|
||||
it("returns 'Cannot promote' for 'disabled'", () => {
|
||||
expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("disabled")).toBe("Cannot promote");
|
||||
});
|
||||
|
||||
it("returns 'Promotes to queen' for 'queen'", () => {
|
||||
expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("queen")).toBe("Promotes to queen");
|
||||
});
|
||||
|
||||
it("returns 'Promotes to rook' for 'rook'", () => {
|
||||
expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("rook")).toBe("Promotes to rook");
|
||||
});
|
||||
|
||||
it("returns 'Promotes to bishop' for 'bishop'", () => {
|
||||
expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("bishop")).toBe("Promotes to bishop");
|
||||
});
|
||||
|
||||
it("returns 'Promotes to knight' for 'knight'", () => {
|
||||
expect(PROMOTION_OVERRIDE_DESCRIPTOR.describe("knight")).toBe("Promotes to knight");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── apply() ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("promotion-override descriptor — apply()", () => {
|
||||
it("seeds PromotionOverride='bishop' fact on piece entity", () => {
|
||||
const session = new Session();
|
||||
const eid = session.nextId();
|
||||
session.insert(eid, "PieceType", "pawn");
|
||||
|
||||
PROMOTION_OVERRIDE_DESCRIPTOR.apply(session, eid, "bishop");
|
||||
|
||||
expect(session.contains(eid, "PromotionOverride")).toBe(true);
|
||||
expect(session.get(eid, "PromotionOverride")).toBe("bishop");
|
||||
});
|
||||
|
||||
it("seeds PromotionOverride='disabled' fact on piece entity", () => {
|
||||
const session = new Session();
|
||||
const eid = session.nextId();
|
||||
session.insert(eid, "PieceType", "pawn");
|
||||
|
||||
PROMOTION_OVERRIDE_DESCRIPTOR.apply(session, eid, "disabled");
|
||||
|
||||
expect(session.get(eid, "PromotionOverride")).toBe("disabled");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── valueSchema ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("promotion-override descriptor — valueSchema", () => {
|
||||
const schema = PROMOTION_OVERRIDE_DESCRIPTOR.valueSchema;
|
||||
|
||||
it("accepts all 4 promotion piece types", () => {
|
||||
expect(() => schema.parse("queen")).not.toThrow();
|
||||
expect(() => schema.parse("rook")).not.toThrow();
|
||||
expect(() => schema.parse("bishop")).not.toThrow();
|
||||
expect(() => schema.parse("knight")).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts 'disabled'", () => {
|
||||
expect(() => schema.parse("disabled")).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects 'king' (not a valid promotion target)", () => {
|
||||
expect(() => schema.parse("king")).toThrow();
|
||||
});
|
||||
|
||||
it("rejects 'pawn' (cannot promote to pawn)", () => {
|
||||
expect(() => schema.parse("pawn")).toThrow();
|
||||
});
|
||||
|
||||
it("rejects arbitrary strings", () => {
|
||||
expect(() => schema.parse("dragon")).toThrow();
|
||||
expect(() => schema.parse("")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Integration: getPromotionMoves ──────────────────────────────────────────
|
||||
|
||||
describe("promotion-override — integration with getPromotionMoves", () => {
|
||||
it("PromotionOverride='bishop': returns only bishop promotion move", () => {
|
||||
const session = setupSession();
|
||||
const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7 → e8
|
||||
session.insert(pawn, "PromotionOverride", "bishop");
|
||||
|
||||
const moves = getPromotionMoves(session, pawn);
|
||||
|
||||
expect(moves).toHaveLength(1);
|
||||
const move = moves[0]!;
|
||||
expect(move.promoteTo).toBe("bishop");
|
||||
expect(move.to).toBe(60); // e8
|
||||
expect(move.isCapture).toBe(false);
|
||||
});
|
||||
|
||||
it("PromotionOverride='disabled': returns empty array (pawn cannot promote)", () => {
|
||||
const session = setupSession();
|
||||
const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7
|
||||
session.insert(pawn, "PromotionOverride", "disabled");
|
||||
|
||||
const moves = getPromotionMoves(session, pawn);
|
||||
|
||||
expect(moves).toEqual([]);
|
||||
});
|
||||
|
||||
it("PromotionOverride='queen': returns only queen promotion move", () => {
|
||||
const session = setupSession();
|
||||
const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7
|
||||
session.insert(pawn, "PromotionOverride", "queen");
|
||||
|
||||
const moves = getPromotionMoves(session, pawn);
|
||||
|
||||
expect(moves).toHaveLength(1);
|
||||
expect(moves[0]!.promoteTo).toBe("queen");
|
||||
});
|
||||
|
||||
it("PromotionOverride='disabled' also suppresses capture-promotions", () => {
|
||||
const session = setupSession();
|
||||
const pawn = insertPiece(session, 1, "pawn", "white", 51); // d7
|
||||
insertPiece(session, 2, "rook", "black", 60); // e8 — capturable enemy
|
||||
session.insert(pawn, "PromotionOverride", "disabled");
|
||||
|
||||
const moves = getPromotionMoves(session, pawn);
|
||||
|
||||
expect(moves).toEqual([]);
|
||||
});
|
||||
|
||||
it("PromotionOverride='knight': capture-promotion returns only knight captures", () => {
|
||||
const session = setupSession();
|
||||
const pawn = insertPiece(session, 1, "pawn", "white", 51); // d7
|
||||
insertPiece(session, 2, "rook", "black", 60); // e8 — capturable enemy
|
||||
insertPiece(session, 3, "queen", "white", 59); // d8 — ally blocks push
|
||||
session.insert(pawn, "PromotionOverride", "knight");
|
||||
|
||||
const moves = getPromotionMoves(session, pawn);
|
||||
|
||||
// Only capture to e8 remains (d8 push blocked); override limits to knight
|
||||
expect(moves).toHaveLength(1);
|
||||
const knightMove = moves[0]!;
|
||||
expect(knightMove.promoteTo).toBe("knight");
|
||||
expect(knightMove.isCapture).toBe(true);
|
||||
});
|
||||
|
||||
it("no PromotionOverride: returns all 4 standard promotion variants (baseline)", () => {
|
||||
const session = setupSession();
|
||||
const pawn = insertPiece(session, 1, "pawn", "white", 52); // e7
|
||||
|
||||
const moves = getPromotionMoves(session, pawn);
|
||||
|
||||
expect(moves).toHaveLength(4);
|
||||
const types = moves.map((m) => m.promoteTo).sort();
|
||||
expect(types).toEqual(["bishop", "knight", "queen", "rook"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Integration: applyPromotion ─────────────────────────────────────────────
|
||||
|
||||
describe("promotion-override — integration with applyPromotion", () => {
|
||||
it("PromotionOverride='bishop': promotes to bishop regardless of promoteTo arg", () => {
|
||||
const session = setupSession();
|
||||
const pawn = insertPiece(session, 1, "pawn", "white", 60);
|
||||
session.insert(pawn, "PromotionOverride", "bishop");
|
||||
|
||||
applyPromotion(session, pawn, "queen"); // caller requests queen, override wins
|
||||
|
||||
expect(session.get(pawn, "PieceType")).toBe("bishop");
|
||||
});
|
||||
|
||||
it("PromotionOverride='rook': promotes to rook regardless of default promoteTo", () => {
|
||||
const session = setupSession();
|
||||
const pawn = insertPiece(session, 1, "pawn", "white", 60);
|
||||
session.insert(pawn, "PromotionOverride", "rook");
|
||||
|
||||
applyPromotion(session, pawn); // no promoteTo arg — default is queen, override wins
|
||||
|
||||
expect(session.get(pawn, "PieceType")).toBe("rook");
|
||||
});
|
||||
|
||||
it("PromotionOverride='disabled': falls back to promoteTo arg (pawn stays pawn only if not promoted)", () => {
|
||||
// When override is "disabled", no promotion moves are generated so
|
||||
// applyPromotion shouldn't be called. If it is called anyway, it falls
|
||||
// back to the caller's promoteTo arg (normal behaviour).
|
||||
const session = setupSession();
|
||||
const pawn = insertPiece(session, 1, "pawn", "white", 60);
|
||||
session.insert(pawn, "PromotionOverride", "disabled");
|
||||
|
||||
applyPromotion(session, pawn, "knight");
|
||||
|
||||
expect(session.get(pawn, "PieceType")).toBe("knight");
|
||||
});
|
||||
|
||||
it("no PromotionOverride: applyPromotion uses promoteTo arg normally", () => {
|
||||
const session = setupSession();
|
||||
const pawn = insertPiece(session, 1, "pawn", "white", 60);
|
||||
|
||||
applyPromotion(session, pawn, "rook");
|
||||
|
||||
expect(session.get(pawn, "PieceType")).toBe("rook");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { z } from "zod";
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
import { MODIFIER_REGISTRY } from "../registry.js";
|
||||
import type { ModifierDescriptor } from "../types.js";
|
||||
|
||||
// Valid override: any standard promotion target, or "disabled" (no promotion).
|
||||
// "king" and "pawn" are intentionally excluded — king is not a valid promotion
|
||||
// target and pawn would be a no-op / non-sensical promotion.
|
||||
const promotionTargetSchema = z.enum(["queen", "rook", "bishop", "knight", "disabled"]);
|
||||
type Value = z.infer<typeof promotionTargetSchema>;
|
||||
|
||||
export const PROMOTION_OVERRIDE_DESCRIPTOR: ModifierDescriptor<Value> = {
|
||||
id: "promotion-override",
|
||||
attrName: "PromotionOverride",
|
||||
label: "Promotion Override",
|
||||
valueSchema: promotionTargetSchema,
|
||||
stackingRule: "priority-wins",
|
||||
uiForm: "promotion-target",
|
||||
apply(session: Session, pieceId: EntityId, effectiveValue: Value): void {
|
||||
session.insert(pieceId, "PromotionOverride", effectiveValue);
|
||||
},
|
||||
describe(value: Value): string {
|
||||
return value === "disabled" ? "Cannot promote" : `Promotes to ${value}`;
|
||||
},
|
||||
};
|
||||
|
||||
MODIFIER_REGISTRY.register(PROMOTION_OVERRIDE_DESCRIPTOR);
|
||||
67
packages/chess/src/modifiers/descriptors/range-bonus.test.ts
Normal file
67
packages/chess/src/modifiers/descriptors/range-bonus.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { Session, type EntityId } from "@paratype/rete";
|
||||
import { RANGE_BONUS_DESCRIPTOR } from "./range-bonus.js";
|
||||
import { MODIFIER_REGISTRY } from "../registry.js";
|
||||
|
||||
function setupSession(): Session {
|
||||
return new Session({ autoFire: false });
|
||||
}
|
||||
|
||||
describe("RANGE_BONUS_DESCRIPTOR", () => {
|
||||
it("registers in MODIFIER_REGISTRY with id 'range-bonus'", () => {
|
||||
expect(MODIFIER_REGISTRY.has("range-bonus")).toBe(true);
|
||||
expect(MODIFIER_REGISTRY.get("range-bonus")).toBe(RANGE_BONUS_DESCRIPTOR);
|
||||
});
|
||||
|
||||
it("has correct metadata", () => {
|
||||
expect(RANGE_BONUS_DESCRIPTOR.id).toBe("range-bonus");
|
||||
expect(RANGE_BONUS_DESCRIPTOR.attrName).toBe("RangeBonus");
|
||||
expect(RANGE_BONUS_DESCRIPTOR.stackingRule).toBe("additive");
|
||||
expect(RANGE_BONUS_DESCRIPTOR.uiForm).toBe("number");
|
||||
});
|
||||
|
||||
describe("describe()", () => {
|
||||
it("returns 'Range +N' for non-negative values", () => {
|
||||
expect(RANGE_BONUS_DESCRIPTOR.describe(2)).toBe("Range +2");
|
||||
expect(RANGE_BONUS_DESCRIPTOR.describe(0)).toBe("Range +0");
|
||||
expect(RANGE_BONUS_DESCRIPTOR.describe(7)).toBe("Range +7");
|
||||
});
|
||||
|
||||
it("returns 'Range -N' for negative values", () => {
|
||||
expect(RANGE_BONUS_DESCRIPTOR.describe(-1)).toBe("Range -1");
|
||||
expect(RANGE_BONUS_DESCRIPTOR.describe(-7)).toBe("Range -7");
|
||||
});
|
||||
});
|
||||
|
||||
describe("apply()", () => {
|
||||
it("inserts RangeBonus fact on the piece entity", () => {
|
||||
const session = setupSession();
|
||||
RANGE_BONUS_DESCRIPTOR.apply(session, 1 as EntityId, 3);
|
||||
expect(session.get(1 as EntityId, "RangeBonus")).toBe(3);
|
||||
});
|
||||
|
||||
it("clamps effectiveValue above RANGE_MAX (7) down to 7", () => {
|
||||
const session = setupSession();
|
||||
RANGE_BONUS_DESCRIPTOR.apply(session, 1 as EntityId, 10 as number);
|
||||
expect(session.get(1 as EntityId, "RangeBonus")).toBe(7);
|
||||
});
|
||||
|
||||
it("clamps negative effectiveValue to 0", () => {
|
||||
const session = setupSession();
|
||||
RANGE_BONUS_DESCRIPTOR.apply(session, 1 as EntityId, -5 as number);
|
||||
expect(session.get(1 as EntityId, "RangeBonus")).toBe(0);
|
||||
});
|
||||
|
||||
it("applies exactly RANGE_MAX (7) without clamping", () => {
|
||||
const session = setupSession();
|
||||
RANGE_BONUS_DESCRIPTOR.apply(session, 2 as EntityId, 7);
|
||||
expect(session.get(2 as EntityId, "RangeBonus")).toBe(7);
|
||||
});
|
||||
|
||||
it("applies zero without clamping", () => {
|
||||
const session = setupSession();
|
||||
RANGE_BONUS_DESCRIPTOR.apply(session, 3 as EntityId, 0);
|
||||
expect(session.get(3 as EntityId, "RangeBonus")).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
25
packages/chess/src/modifiers/descriptors/range-bonus.ts
Normal file
25
packages/chess/src/modifiers/descriptors/range-bonus.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { z } from "zod";
|
||||
import { MODIFIER_REGISTRY } from "../registry.js";
|
||||
import type { ModifierDescriptor } from "../types.js";
|
||||
|
||||
const RANGE_MAX = 7;
|
||||
const schema = z.number().int().min(-7).max(7);
|
||||
type Value = z.infer<typeof schema>;
|
||||
|
||||
export const RANGE_BONUS_DESCRIPTOR: ModifierDescriptor<Value> = {
|
||||
id: "range-bonus",
|
||||
attrName: "RangeBonus",
|
||||
label: "Range Bonus",
|
||||
valueSchema: schema,
|
||||
stackingRule: "additive",
|
||||
uiForm: "number",
|
||||
apply(session, pieceId, effectiveValue) {
|
||||
const clamped = Math.max(0, Math.min(RANGE_MAX, effectiveValue));
|
||||
session.insert(pieceId, "RangeBonus", clamped);
|
||||
},
|
||||
describe(value) {
|
||||
return `Range ${value >= 0 ? "+" : ""}${value}`;
|
||||
},
|
||||
};
|
||||
|
||||
MODIFIER_REGISTRY.register(RANGE_BONUS_DESCRIPTOR);
|
||||
28
packages/chess/src/modifiers/index.ts
Normal file
28
packages/chess/src/modifiers/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Barrel for the piece modifier profile system.
|
||||
*
|
||||
* Importing this module ensures every T1 modifier descriptor is
|
||||
* registered in `MODIFIER_REGISTRY` via side-effect imports (ADR-8).
|
||||
* Descriptor imports are added by T6-T11 as each modifier is
|
||||
* implemented; until then the registry starts empty.
|
||||
*/
|
||||
|
||||
// Re-export registry and types for consumers.
|
||||
export { MODIFIER_REGISTRY } from "./registry.js";
|
||||
export type {
|
||||
ModifierKindId,
|
||||
Direction,
|
||||
TypeModifier,
|
||||
InstanceModifier,
|
||||
ModifierProfile,
|
||||
ModifierDescriptor,
|
||||
} from "./types.js";
|
||||
export { typeModifier, instanceModifier } from "./types.js";
|
||||
|
||||
// Descriptor side-effect imports — all 6 T1 modifiers (ADR-8):
|
||||
import "./descriptors/hp-bonus.js";
|
||||
import "./descriptors/range-bonus.js";
|
||||
import "./descriptors/direction-additions.js";
|
||||
import "./descriptors/capture-flags.js";
|
||||
import "./descriptors/promotion-override.js";
|
||||
import "./descriptors/damage-resistance.js";
|
||||
261
packages/chess/src/modifiers/library.test.ts
Normal file
261
packages/chess/src/modifiers/library.test.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest";
|
||||
import {
|
||||
loadLibrary,
|
||||
saveToLibrary,
|
||||
deleteFromLibrary,
|
||||
setStarred,
|
||||
duplicateEntry,
|
||||
makeId,
|
||||
__test__,
|
||||
type SavedModifierProfile,
|
||||
} from "./library.js";
|
||||
import type { ModifierProfile } from "./types.js";
|
||||
|
||||
// happy-dom provides a localStorage object but its methods are
|
||||
// bound to a prototype that doesn't survive certain destructuring
|
||||
// patterns; we install a simple Map-backed shim unconditionally so
|
||||
// the tests have predictable behavior.
|
||||
beforeAll(() => {
|
||||
const store = new Map<string, string>();
|
||||
const shim: Storage = {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
getItem(k: string) {
|
||||
return store.get(k) ?? null;
|
||||
},
|
||||
setItem(k: string, v: string) {
|
||||
store.set(k, v);
|
||||
},
|
||||
removeItem(k: string) {
|
||||
store.delete(k);
|
||||
},
|
||||
key(i: number) {
|
||||
return [...store.keys()][i] ?? null;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: shim,
|
||||
});
|
||||
});
|
||||
|
||||
// Clear between tests so each one starts fresh.
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const emptyProfile: ModifierProfile = {
|
||||
id: "test",
|
||||
name: "Test Profile",
|
||||
description: "",
|
||||
perType: [],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: "custom",
|
||||
};
|
||||
|
||||
function seed(entry: Partial<SavedModifierProfile> = {}): SavedModifierProfile {
|
||||
return {
|
||||
id: makeId(),
|
||||
name: "Test",
|
||||
profile: emptyProfile,
|
||||
starred: false,
|
||||
updatedAt: Date.now(),
|
||||
...entry,
|
||||
};
|
||||
}
|
||||
|
||||
describe("loadLibrary()", () => {
|
||||
it("returns [] when no key is set", () => {
|
||||
expect(loadLibrary()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when storage contains malformed JSON", () => {
|
||||
localStorage.setItem(__test__.STORAGE_KEY, "not json");
|
||||
expect(loadLibrary()).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters out entries that fail shape validation", () => {
|
||||
const validEntry: SavedModifierProfile = {
|
||||
id: "ok",
|
||||
name: "ok",
|
||||
profile: emptyProfile,
|
||||
starred: false,
|
||||
updatedAt: 0,
|
||||
};
|
||||
localStorage.setItem(
|
||||
__test__.STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
validEntry,
|
||||
{ not: "a valid entry" },
|
||||
]),
|
||||
);
|
||||
const library = loadLibrary();
|
||||
expect(library).toHaveLength(1);
|
||||
expect(library[0]?.id).toBe("ok");
|
||||
});
|
||||
|
||||
it("filters out entries with invalid nested profile", () => {
|
||||
localStorage.setItem(
|
||||
__test__.STORAGE_KEY,
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "bad-profile",
|
||||
name: "bad",
|
||||
profile: { version: 99, source: "unknown" },
|
||||
starred: false,
|
||||
updatedAt: 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(loadLibrary()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveToLibrary()", () => {
|
||||
it("appends a new entry", () => {
|
||||
const result = saveToLibrary(seed({ name: "First" }));
|
||||
expect(result.ok).toBe(true);
|
||||
const library = loadLibrary();
|
||||
expect(library).toHaveLength(1);
|
||||
expect(library[0]?.name).toBe("First");
|
||||
});
|
||||
|
||||
it("updates in place when id matches", () => {
|
||||
const entry = seed({ name: "Original" });
|
||||
saveToLibrary(entry);
|
||||
saveToLibrary({ ...entry, name: "Renamed" });
|
||||
|
||||
const library = loadLibrary();
|
||||
expect(library).toHaveLength(1);
|
||||
expect(library[0]?.name).toBe("Renamed");
|
||||
});
|
||||
|
||||
it("evicts the oldest non-starred when MAX_ENTRIES is hit", () => {
|
||||
// Seed with MAX_ENTRIES entries, incrementing updatedAt.
|
||||
for (let i = 0; i < __test__.MAX_ENTRIES; i++) {
|
||||
saveToLibrary(seed({ name: `E${String(i)}`, updatedAt: i }));
|
||||
}
|
||||
expect(loadLibrary()).toHaveLength(__test__.MAX_ENTRIES);
|
||||
|
||||
// Save one more — oldest (E0) should be evicted.
|
||||
saveToLibrary(seed({ name: "newest", updatedAt: 9999 }));
|
||||
const library = loadLibrary();
|
||||
expect(library).toHaveLength(__test__.MAX_ENTRIES);
|
||||
expect(library.map((e) => e.name)).not.toContain("E0");
|
||||
expect(library.some((e) => e.name === "newest")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses save when every entry is starred and library is full", () => {
|
||||
for (let i = 0; i < __test__.MAX_ENTRIES; i++) {
|
||||
saveToLibrary(seed({ name: `E${String(i)}`, starred: true }));
|
||||
}
|
||||
const result = saveToLibrary(seed({ name: "newest" }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.reason).toMatch(/unstar/i);
|
||||
}
|
||||
expect(loadLibrary()).toHaveLength(__test__.MAX_ENTRIES);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteFromLibrary()", () => {
|
||||
it("removes the matching entry", () => {
|
||||
const entry = seed();
|
||||
saveToLibrary(entry);
|
||||
deleteFromLibrary(entry.id);
|
||||
expect(loadLibrary()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is a no-op for unknown id", () => {
|
||||
saveToLibrary(seed());
|
||||
deleteFromLibrary("not-real");
|
||||
expect(loadLibrary()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setStarred()", () => {
|
||||
it("toggles starred and updates updatedAt", () => {
|
||||
const entry = seed({ starred: false, updatedAt: 100 });
|
||||
saveToLibrary(entry);
|
||||
const before = Date.now();
|
||||
setStarred(entry.id, true);
|
||||
|
||||
const library = loadLibrary();
|
||||
expect(library[0]?.starred).toBe(true);
|
||||
expect(library[0]?.updatedAt).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
|
||||
it("is a no-op for unknown id", () => {
|
||||
saveToLibrary(seed());
|
||||
setStarred("not-real", true);
|
||||
expect(loadLibrary()[0]?.starred).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateEntry()", () => {
|
||||
it("creates a copy with a new id and '(copy)' name suffix", () => {
|
||||
const entry = seed({ name: "Original" });
|
||||
saveToLibrary(entry);
|
||||
|
||||
const newId = duplicateEntry(entry.id);
|
||||
expect(newId).toBeDefined();
|
||||
expect(newId).not.toBe(entry.id);
|
||||
|
||||
const library = loadLibrary();
|
||||
expect(library).toHaveLength(2);
|
||||
const copy = library.find((e) => e.id === newId);
|
||||
expect(copy?.name).toBe("Original (copy)");
|
||||
expect(copy?.starred).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the profile on duplication", () => {
|
||||
const profileWithData: ModifierProfile = {
|
||||
id: "p1",
|
||||
name: "Buff Profile",
|
||||
description: "has modifiers",
|
||||
perType: [{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 2 }],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: "custom",
|
||||
};
|
||||
const entry = seed({ name: "Source", profile: profileWithData });
|
||||
saveToLibrary(entry);
|
||||
|
||||
const newId = duplicateEntry(entry.id);
|
||||
const library = loadLibrary();
|
||||
const copy = library.find((e) => e.id === newId);
|
||||
expect(copy?.profile).toEqual(profileWithData);
|
||||
});
|
||||
|
||||
it("returns undefined for unknown id", () => {
|
||||
expect(duplicateEntry("not-real")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeId()", () => {
|
||||
it("produces unique ids", () => {
|
||||
const ids = new Set<string>();
|
||||
for (let i = 0; i < 100; i++) ids.add(makeId());
|
||||
expect(ids.size).toBe(100);
|
||||
});
|
||||
|
||||
it("falls back when crypto.randomUUID is unavailable", () => {
|
||||
const original = crypto.randomUUID;
|
||||
// @ts-expect-error — intentional override for test
|
||||
crypto.randomUUID = undefined;
|
||||
try {
|
||||
const id = makeId();
|
||||
expect(id).toMatch(/^layout-/);
|
||||
} finally {
|
||||
crypto.randomUUID = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Silence React testing-library warnings if this file runs in a mixed env.
|
||||
vi.mock("react", async () => await vi.importActual("react"));
|
||||
188
packages/chess/src/modifiers/library.ts
Normal file
188
packages/chess/src/modifiers/library.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* Modifier-profile library — localStorage-backed store of user-authored
|
||||
* modifier profiles.
|
||||
*
|
||||
* Each entry:
|
||||
* - `id` — local-only UUID. Used to identify the entry for
|
||||
* update/delete/star operations.
|
||||
* - `name` — user-provided label, shown in the library drawer.
|
||||
* - `profile` — the full ModifierProfile.
|
||||
* - `starred` — true when the user has pinned this profile. Starred
|
||||
* entries are exempt from FIFO eviction and sort first.
|
||||
* - `updatedAt` — unix ms of last write. Drives display order for
|
||||
* non-starred entries (newest first) and FIFO eviction (oldest
|
||||
* non-starred entry is removed when capacity is hit).
|
||||
*
|
||||
* Capacity: MAX_ENTRIES (20). When exceeded, the oldest non-starred
|
||||
* entry is evicted. If every entry is starred, we refuse the save
|
||||
* and the caller surfaces a "library full — unstar something" error.
|
||||
*
|
||||
* Storage key is versioned (`houserules:modifier-profiles:v1`). A schema
|
||||
* bump would ship a new key + migration; v1 entries are kept on best-
|
||||
* effort and re-hydrated read-only if they can't be migrated.
|
||||
*/
|
||||
import { parseModifierProfile } from "./schema.js";
|
||||
import type { ModifierProfile } from "./types.js";
|
||||
|
||||
const STORAGE_KEY = "houserules:modifier-profiles:v1";
|
||||
const MAX_ENTRIES = 20;
|
||||
|
||||
export interface SavedModifierProfile {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly profile: ModifierProfile;
|
||||
readonly starred: boolean;
|
||||
readonly updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every saved modifier profile from storage. Returns an empty array on
|
||||
* empty/missing/corrupt storage — silently discarding unparseable
|
||||
* data is preferable to blocking the UI.
|
||||
*/
|
||||
export function loadLibrary(): SavedModifierProfile[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === null) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
// Shallow shape validation — anything that fails is dropped.
|
||||
return parsed.filter(isSavedModifierProfile);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the full library array back to storage. */
|
||||
function writeLibrary(entries: SavedModifierProfile[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
||||
} catch {
|
||||
/* quota exceeded / storage disabled — best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new modifier profile or update an existing one (matched by `id`).
|
||||
*
|
||||
* Returns `{ ok: true }` on success. Returns `{ ok: false, reason }`
|
||||
* when the library is full of starred entries — caller surfaces a
|
||||
* message telling the user to unstar something.
|
||||
*/
|
||||
export function saveToLibrary(
|
||||
entry: SavedModifierProfile,
|
||||
): { ok: true } | { ok: false; reason: string } {
|
||||
const library = loadLibrary();
|
||||
const existingIdx = library.findIndex((e) => e.id === entry.id);
|
||||
|
||||
if (existingIdx >= 0) {
|
||||
// Update in place — no capacity check needed.
|
||||
library[existingIdx] = entry;
|
||||
writeLibrary(library);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// New entry — enforce capacity.
|
||||
if (library.length >= MAX_ENTRIES) {
|
||||
// Find the oldest non-starred entry and evict it.
|
||||
const evictable = library
|
||||
.filter((e) => !e.starred)
|
||||
.sort((a, b) => a.updatedAt - b.updatedAt);
|
||||
if (evictable.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
"Library full (20 profiles). Unstar one to make room, or delete an entry.",
|
||||
};
|
||||
}
|
||||
const oldestNonStarred = evictable[0]!;
|
||||
const pruned = library.filter((e) => e.id !== oldestNonStarred.id);
|
||||
pruned.push(entry);
|
||||
writeLibrary(pruned);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
library.push(entry);
|
||||
writeLibrary(library);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Remove a modifier profile by id. No-op if the id is unknown. */
|
||||
export function deleteFromLibrary(id: string): void {
|
||||
const library = loadLibrary();
|
||||
writeLibrary(library.filter((e) => e.id !== id));
|
||||
}
|
||||
|
||||
/** Toggle the starred flag on a modifier profile. */
|
||||
export function setStarred(id: string, starred: boolean): void {
|
||||
const library = loadLibrary();
|
||||
const idx = library.findIndex((e) => e.id === id);
|
||||
if (idx < 0) return;
|
||||
const updated: SavedModifierProfile = {
|
||||
...library[idx]!,
|
||||
starred,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
library[idx] = updated;
|
||||
writeLibrary(library);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a library entry. The copy gets a fresh id, "(copy)"
|
||||
* appended to the name, and starred=false regardless of the
|
||||
* original's state. Returns the new entry's id so the caller can
|
||||
* select it.
|
||||
*/
|
||||
export function duplicateEntry(id: string): string | undefined {
|
||||
const library = loadLibrary();
|
||||
const entry = library.find((e) => e.id === id);
|
||||
if (entry === undefined) return undefined;
|
||||
|
||||
const newId = makeId();
|
||||
const copy: SavedModifierProfile = {
|
||||
id: newId,
|
||||
name: `${entry.name} (copy)`,
|
||||
profile: entry.profile,
|
||||
starred: false,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const result = saveToLibrary(copy);
|
||||
if (!result.ok) return undefined;
|
||||
return newId;
|
||||
}
|
||||
|
||||
/** Generate a local-only id for a library entry. */
|
||||
export function makeId(): string {
|
||||
// crypto.randomUUID is available in every browser we target (and
|
||||
// in Node 19+). Fall back to a Math.random-based id only on
|
||||
// ancient runtimes.
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `layout-${Math.random().toString(36).slice(2, 12)}`;
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────
|
||||
|
||||
function isSavedModifierProfile(value: unknown): value is SavedModifierProfile {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof v["id"] !== "string" ||
|
||||
typeof v["name"] !== "string" ||
|
||||
typeof v["starred"] !== "boolean" ||
|
||||
typeof v["updatedAt"] !== "number"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Validate the nested profile using the Zod schema.
|
||||
try {
|
||||
parseModifierProfile(v["profile"]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Exported for tests only. Prefer the high-level helpers above.
|
||||
export const __test__ = { STORAGE_KEY, MAX_ENTRIES };
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { Session, type EntityId } from "@paratype/rete";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import { ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE } from "./absorb-damage-with-attribute.js";
|
||||
|
||||
function makeContext(session: Session, pieceId: EntityId) {
|
||||
return {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "test-descriptor",
|
||||
type: "data" as const,
|
||||
version: 1 as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("absorb-damage-with-attribute")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("absorb-damage-with-attribute")).toBe(
|
||||
ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE,
|
||||
);
|
||||
});
|
||||
|
||||
it("writes both AbsorbDamageAttr and AbsorbDamageRate facts", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "pawn");
|
||||
|
||||
const params = ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.paramsSchema.parse({
|
||||
attr: "ShieldCharges",
|
||||
rate: 2,
|
||||
});
|
||||
ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.apply(makeContext(session, pieceId), params);
|
||||
|
||||
expect(session.get(pieceId, "AbsorbDamageAttr")).toBe("ShieldCharges");
|
||||
expect(session.get(pieceId, "AbsorbDamageRate")).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects non-positive rate values", () => {
|
||||
expect(() => {
|
||||
ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.paramsSchema.parse({
|
||||
attr: "ShieldCharges",
|
||||
rate: 0,
|
||||
});
|
||||
}).toThrow();
|
||||
|
||||
expect(() => {
|
||||
ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.paramsSchema.parse({
|
||||
attr: "ShieldCharges",
|
||||
rate: -1,
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("is idempotent when applying the same params twice", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "pawn");
|
||||
|
||||
const params = ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.paramsSchema.parse({
|
||||
attr: "ShieldCharges",
|
||||
rate: 3,
|
||||
});
|
||||
|
||||
ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.apply(makeContext(session, pieceId), params);
|
||||
ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.apply(makeContext(session, pieceId), params);
|
||||
|
||||
expect(session.get(pieceId, "AbsorbDamageAttr")).toBe("ShieldCharges");
|
||||
expect(session.get(pieceId, "AbsorbDamageRate")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { z } from "zod";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
attr: z.string(),
|
||||
rate: z.number().int().positive(),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "absorb-damage-with-attribute",
|
||||
label: "Absorb Damage with Attribute",
|
||||
description:
|
||||
"Seed absorb-damage facts so damage can consume an attribute before HP.",
|
||||
paramsSchema: schema,
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
ctx.session.insert(ctx.pieceId, "AbsorbDamageAttr", params.attr);
|
||||
ctx.session.insert(ctx.pieceId, "AbsorbDamageRate", params.rate);
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
|
||||
export { descriptor as ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE };
|
||||
87
packages/chess/src/modifiers/primitives/add-aura.test.ts
Normal file
87
packages/chess/src/modifiers/primitives/add-aura.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { Session } from "@paratype/rete";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import { ADD_AURA_PRIMITIVE } from "./add-aura.js";
|
||||
import type { PrimitiveApplyContext } from "./types.js";
|
||||
import "./add-aura.js";
|
||||
|
||||
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "custom:test-add-aura",
|
||||
type: "data",
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
describe("add-aura primitive — registry", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY under key 'add-aura'", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("add-aura")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("add-aura")).toBe(ADD_AURA_PRIMITIVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("add-aura primitive — apply()", () => {
|
||||
it("seeds one aura spec entry", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
|
||||
ADD_AURA_PRIMITIVE.apply(ctx, {
|
||||
radius: 2,
|
||||
targetAttr: "HpBonus",
|
||||
delta: 1,
|
||||
});
|
||||
|
||||
expect(session.get(ctx.pieceId, "AuraSpec")).toEqual([
|
||||
{ radius: 2, targetAttr: "HpBonus", delta: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("composes multiple aura specs into an array", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
|
||||
ADD_AURA_PRIMITIVE.apply(ctx, {
|
||||
radius: 1,
|
||||
targetAttr: "RangeBonus",
|
||||
delta: 2,
|
||||
});
|
||||
ADD_AURA_PRIMITIVE.apply(ctx, {
|
||||
radius: 3,
|
||||
targetAttr: "DamageResistance",
|
||||
delta: -0.25,
|
||||
});
|
||||
|
||||
expect(session.get(ctx.pieceId, "AuraSpec")).toEqual([
|
||||
{ radius: 1, targetAttr: "RangeBonus", delta: 2 },
|
||||
{ radius: 3, targetAttr: "DamageResistance", delta: -0.25 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("add-aura primitive — paramsSchema", () => {
|
||||
it("rejects radii outside the [1, 7] range", () => {
|
||||
expect(() =>
|
||||
ADD_AURA_PRIMITIVE.paramsSchema.parse({
|
||||
radius: 0,
|
||||
targetAttr: "HpBonus",
|
||||
delta: 1,
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
expect(() =>
|
||||
ADD_AURA_PRIMITIVE.paramsSchema.parse({
|
||||
radius: 8,
|
||||
targetAttr: "HpBonus",
|
||||
delta: 1,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
37
packages/chess/src/modifiers/primitives/add-aura.ts
Normal file
37
packages/chess/src/modifiers/primitives/add-aura.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { z } from "zod";
|
||||
import type { ChessAttrMap } from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
radius: z.number().int().min(1).max(7),
|
||||
targetAttr: z.string(),
|
||||
delta: z.number(),
|
||||
});
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "add-aura",
|
||||
label: "Add Aura",
|
||||
description:
|
||||
"Seeds an AuraSpec fact entry consumed by the aura engine's recomputation phase.",
|
||||
paramsSchema: schema,
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing =
|
||||
(ctx.session.get(ctx.pieceId, "AuraSpec") as
|
||||
| ChessAttrMap["AuraSpec"]
|
||||
| undefined) ?? [];
|
||||
|
||||
ctx.session.insert(ctx.pieceId, "AuraSpec", [
|
||||
...existing,
|
||||
{
|
||||
radius: params.radius,
|
||||
targetAttr: params.targetAttr,
|
||||
delta: params.delta,
|
||||
},
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
export { descriptor as ADD_AURA_PRIMITIVE };
|
||||
109
packages/chess/src/modifiers/primitives/add-direction.test.ts
Normal file
109
packages/chess/src/modifiers/primitives/add-direction.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { Session } from "@paratype/rete";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { PrimitiveApplyContext } from "./types.js";
|
||||
import "./add-direction.js";
|
||||
import { ADD_DIRECTION_PRIMITIVE } from "./add-direction.js";
|
||||
|
||||
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "custom:test-add-direction",
|
||||
type: "data",
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
describe("add-direction primitive — registry", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY under key 'add-direction'", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("add-direction")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("add-direction")).toBe(ADD_DIRECTION_PRIMITIVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("add-direction primitive — apply()", () => {
|
||||
it("adds one direction to empty state", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
|
||||
ADD_DIRECTION_PRIMITIVE.apply(ctx, { directions: ["forward"] });
|
||||
|
||||
expect(session.get(ctx.pieceId, "DirectionAdditions")).toEqual(["forward"]);
|
||||
});
|
||||
|
||||
it("concatenates with existing directions", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
session.insert(ctx.pieceId, "DirectionAdditions", ["right"]);
|
||||
|
||||
ADD_DIRECTION_PRIMITIVE.apply(ctx, { directions: ["left"] });
|
||||
|
||||
expect(session.get(ctx.pieceId, "DirectionAdditions")).toEqual([
|
||||
"right",
|
||||
"left",
|
||||
]);
|
||||
});
|
||||
|
||||
it("deduplicates overlapping directions by name", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
session.insert(ctx.pieceId, "DirectionAdditions", ["forward"]);
|
||||
|
||||
ADD_DIRECTION_PRIMITIVE.apply(ctx, {
|
||||
directions: ["forward", "diagonal-fr"],
|
||||
});
|
||||
|
||||
expect(session.get(ctx.pieceId, "DirectionAdditions")).toEqual([
|
||||
"forward",
|
||||
"diagonal-fr",
|
||||
]);
|
||||
});
|
||||
|
||||
it("composes additively with the T1 direction-additions descriptor's writes", () => {
|
||||
// The T1 modifier writes `string[]` of named directions. This primitive
|
||||
// must read+merge that exact shape so the engine's existing
|
||||
// `generateDirectionMoves` walker handles both contributions.
|
||||
const { ctx, session } = makeContext();
|
||||
session.insert(ctx.pieceId, "DirectionAdditions", ["forward", "left"]);
|
||||
|
||||
ADD_DIRECTION_PRIMITIVE.apply(ctx, {
|
||||
directions: ["right", "diagonal-fl"],
|
||||
});
|
||||
|
||||
expect(session.get(ctx.pieceId, "DirectionAdditions")).toEqual([
|
||||
"forward",
|
||||
"left",
|
||||
"right",
|
||||
"diagonal-fl",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unknown direction names at the schema layer", () => {
|
||||
const parsed = ADD_DIRECTION_PRIMITIVE.paramsSchema.safeParse({
|
||||
directions: ["northwest"],
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("throws when the existing DirectionAdditions fact has a wrong shape", () => {
|
||||
// If a stale or upstream-buggy fact seeds DirectionAdditions with a
|
||||
// non-string entry, the primitive must fail loudly rather than silently
|
||||
// mixing two incompatible shapes into the same array. We bypass the
|
||||
// chess-typed insert by going through the underlying rete session API
|
||||
// (which accepts `unknown` values).
|
||||
const { ctx, session } = makeContext();
|
||||
const factId = ctx.pieceId;
|
||||
// Insert via the untyped rete path so we can plant a wrong-shape value
|
||||
// without triggering the chess attr-map type check.
|
||||
session.insert(factId, "DirectionAdditions", [{ dx: 1, dy: 0 }]);
|
||||
expect(() => {
|
||||
ADD_DIRECTION_PRIMITIVE.apply(ctx, { directions: ["forward"] });
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
66
packages/chess/src/modifiers/primitives/add-direction.ts
Normal file
66
packages/chess/src/modifiers/primitives/add-direction.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { z } from "zod";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
/**
|
||||
* `DirectionAdditions` is the existing T1 modifier attribute (see
|
||||
* `descriptors/direction-additions.ts`). Its value is a `readonly string[]`
|
||||
* of named, color-relative directions interpreted by `generateDirectionMoves`.
|
||||
* This primitive composes additively with that descriptor — appending names
|
||||
* into the same array — so the engine's existing direction-walker handles
|
||||
* both T1-built-in and T3-DSL contributions identically.
|
||||
*/
|
||||
const directionNameSchema = z.enum([
|
||||
"forward",
|
||||
"backward",
|
||||
"left",
|
||||
"right",
|
||||
"diagonal-fl",
|
||||
"diagonal-fr",
|
||||
"diagonal-bl",
|
||||
"diagonal-br",
|
||||
]);
|
||||
const schema = z.object({
|
||||
directions: z.array(directionNameSchema).min(1),
|
||||
});
|
||||
|
||||
type DirectionName = z.infer<typeof directionNameSchema>;
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
function dedupe(directions: readonly DirectionName[]): DirectionName[] {
|
||||
const seen = new Set<string>();
|
||||
const out: DirectionName[] = [];
|
||||
for (const d of directions) {
|
||||
if (seen.has(d)) continue;
|
||||
seen.add(d);
|
||||
out.push(d);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "add-direction",
|
||||
label: "Add Direction",
|
||||
description: "Appends movement directions into DirectionAdditions with dedupe.",
|
||||
paramsSchema: schema,
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing = ctx.session.get(ctx.pieceId, "DirectionAdditions");
|
||||
const existingDirections = existing ?? [];
|
||||
if (!Array.isArray(existingDirections)) {
|
||||
throw new Error("add-direction expected DirectionAdditions to be an array");
|
||||
}
|
||||
|
||||
const parsed = z.array(directionNameSchema).safeParse(existingDirections);
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
"add-direction expected DirectionAdditions to be a string[] of named directions",
|
||||
);
|
||||
}
|
||||
|
||||
const merged = [...parsed.data, ...params.directions];
|
||||
ctx.session.insert(ctx.pieceId, "DirectionAdditions", dedupe(merged));
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
export { descriptor as ADD_DIRECTION_PRIMITIVE };
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { Session } from "@paratype/rete";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { PrimitiveApplyContext } from "./types.js";
|
||||
import "./add-to-attribute.js";
|
||||
import { ADD_TO_ATTRIBUTE_PRIMITIVE } from "./add-to-attribute.js";
|
||||
|
||||
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "custom:test-add-to-attribute",
|
||||
type: "data",
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
describe("add-to-attribute primitive — registry", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY under key 'add-to-attribute'", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("add-to-attribute")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("add-to-attribute")).toBe(
|
||||
ADD_TO_ATTRIBUTE_PRIMITIVE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("add-to-attribute primitive — apply()", () => {
|
||||
it("adds delta to an existing number", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
session.insert(ctx.pieceId, "HpBonus", 2);
|
||||
|
||||
ADD_TO_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "HpBonus", delta: 3 });
|
||||
|
||||
expect(session.get(ctx.pieceId, "HpBonus")).toBe(5);
|
||||
});
|
||||
|
||||
it("treats an absent fact as 0", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
|
||||
ADD_TO_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "RangeBonus", delta: 4 });
|
||||
|
||||
expect(session.get(ctx.pieceId, "RangeBonus")).toBe(4);
|
||||
});
|
||||
|
||||
it("throws when existing value is non-numeric", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
session.insert(ctx.pieceId, "PieceType", "pawn");
|
||||
|
||||
expect(() => {
|
||||
ADD_TO_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "PieceType", delta: 1 });
|
||||
}).toThrow(/expected numeric value/);
|
||||
});
|
||||
});
|
||||
29
packages/chess/src/modifiers/primitives/add-to-attribute.ts
Normal file
29
packages/chess/src/modifiers/primitives/add-to-attribute.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { z } from "zod";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
attr: z.string(),
|
||||
delta: z.number(),
|
||||
});
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "add-to-attribute",
|
||||
label: "Add To Attribute",
|
||||
description: "Adds delta to the current attribute value, treating missing as 0.",
|
||||
paramsSchema: schema,
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing = ctx.session.get(ctx.pieceId, params.attr);
|
||||
const baseValue = existing === undefined ? 0 : existing;
|
||||
if (typeof baseValue !== "number" || Number.isNaN(baseValue)) {
|
||||
throw new Error(
|
||||
`add-to-attribute expected numeric value for attr "${params.attr}" but got ${typeof baseValue}`,
|
||||
);
|
||||
}
|
||||
ctx.session.insert(ctx.pieceId, params.attr, baseValue + params.delta);
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
export { descriptor as ADD_TO_ATTRIBUTE_PRIMITIVE };
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { Session, type EntityId } from "@paratype/rete";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import { BLOCK_MOVE_TYPE_PRIMITIVE } from "./block-move-type.js";
|
||||
|
||||
function makeContext(session: Session, pieceId: EntityId) {
|
||||
return {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "test-descriptor",
|
||||
type: "data" as const,
|
||||
version: 1 as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("BLOCK_MOVE_TYPE_PRIMITIVE", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("block-move-type")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("block-move-type")).toBe(BLOCK_MOVE_TYPE_PRIMITIVE);
|
||||
});
|
||||
|
||||
it("blocks one move type", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "rook");
|
||||
|
||||
BLOCK_MOVE_TYPE_PRIMITIVE.apply(
|
||||
makeContext(session, pieceId),
|
||||
BLOCK_MOVE_TYPE_PRIMITIVE.paramsSchema.parse({ moveType: "capture" }),
|
||||
);
|
||||
|
||||
expect(session.get(pieceId, "BlockedMoveTypes")).toEqual(["capture"]);
|
||||
});
|
||||
|
||||
it("composes unique types across multiple applies", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "bishop");
|
||||
|
||||
BLOCK_MOVE_TYPE_PRIMITIVE.apply(
|
||||
makeContext(session, pieceId),
|
||||
BLOCK_MOVE_TYPE_PRIMITIVE.paramsSchema.parse({ moveType: "step" }),
|
||||
);
|
||||
BLOCK_MOVE_TYPE_PRIMITIVE.apply(
|
||||
makeContext(session, pieceId),
|
||||
BLOCK_MOVE_TYPE_PRIMITIVE.paramsSchema.parse({ moveType: "slide" }),
|
||||
);
|
||||
|
||||
expect(session.get(pieceId, "BlockedMoveTypes")).toEqual(["step", "slide"]);
|
||||
});
|
||||
|
||||
it("is idempotent when the same move type is applied repeatedly", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "queen");
|
||||
|
||||
const params = BLOCK_MOVE_TYPE_PRIMITIVE.paramsSchema.parse({ moveType: "capture" });
|
||||
BLOCK_MOVE_TYPE_PRIMITIVE.apply(makeContext(session, pieceId), params);
|
||||
BLOCK_MOVE_TYPE_PRIMITIVE.apply(makeContext(session, pieceId), params);
|
||||
|
||||
expect(session.get(pieceId, "BlockedMoveTypes")).toEqual(["capture"]);
|
||||
});
|
||||
});
|
||||
32
packages/chess/src/modifiers/primitives/block-move-type.ts
Normal file
32
packages/chess/src/modifiers/primitives/block-move-type.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { z } from "zod";
|
||||
import type { MoveType } from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
const moveTypeSchema = z.enum(["capture", "step", "slide"]);
|
||||
const schema = z.object({
|
||||
moveType: moveTypeSchema,
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "block-move-type",
|
||||
label: "Block Move Type",
|
||||
description: "Seed blocked move types for deferred move-filter integration.",
|
||||
paramsSchema: schema,
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existingRaw = ctx.session.contains(ctx.pieceId, "BlockedMoveTypes")
|
||||
? ctx.session.get(ctx.pieceId, "BlockedMoveTypes")
|
||||
: [];
|
||||
const existing: MoveType[] = Array.isArray(existingRaw)
|
||||
? existingRaw.filter((v): v is MoveType => moveTypeSchema.safeParse(v).success)
|
||||
: [];
|
||||
const next = existing.includes(params.moveType) ? existing : [...existing, params.moveType];
|
||||
ctx.session.insert(ctx.pieceId, "BlockedMoveTypes", next);
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
|
||||
export { descriptor as BLOCK_MOVE_TYPE_PRIMITIVE };
|
||||
123
packages/chess/src/modifiers/primitives/conditional.test.ts
Normal file
123
packages/chess/src/modifiers/primitives/conditional.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { Session } from "@paratype/rete";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import { CONDITIONAL_PRIMITIVE } from "./conditional.js";
|
||||
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
|
||||
import "./conditional.js";
|
||||
|
||||
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "custom:test-conditional",
|
||||
type: "data",
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
describe("conditional primitive — registry", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY under key 'conditional'", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("conditional")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("conditional")).toBe(CONDITIONAL_PRIMITIVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conditional primitive — apply()", () => {
|
||||
it("seeds one conditional hook", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
const thenBranch: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } },
|
||||
];
|
||||
const elseBranch: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: -1 } },
|
||||
];
|
||||
|
||||
CONDITIONAL_PRIMITIVE.apply(ctx, {
|
||||
condition: { type: "attr-lt", attr: "Hp", value: 3 },
|
||||
then: thenBranch,
|
||||
else: elseBranch,
|
||||
});
|
||||
|
||||
expect(session.get(ctx.pieceId, "ConditionalHooks")).toEqual([
|
||||
{
|
||||
condition: { type: "attr-lt", attr: "Hp", value: 3 },
|
||||
then: thenBranch,
|
||||
else: elseBranch,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("appends to existing ConditionalHooks", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
|
||||
CONDITIONAL_PRIMITIVE.apply(ctx, {
|
||||
condition: { type: "always" },
|
||||
then: [{ kind: "seed-attribute", params: { attr: "Hp", value: 5 } }],
|
||||
});
|
||||
CONDITIONAL_PRIMITIVE.apply(ctx, {
|
||||
condition: { type: "never" },
|
||||
then: [{ kind: "set-capture-flag", params: { flag: 2 } }],
|
||||
else: [{ kind: "set-capture-flag", params: { flag: 1 } }],
|
||||
});
|
||||
|
||||
expect(session.get(ctx.pieceId, "ConditionalHooks")).toEqual([
|
||||
{
|
||||
condition: { type: "always" },
|
||||
then: [{ kind: "seed-attribute", params: { attr: "Hp", value: 5 } }],
|
||||
else: undefined,
|
||||
},
|
||||
{
|
||||
condition: { type: "never" },
|
||||
then: [{ kind: "set-capture-flag", params: { flag: 2 } }],
|
||||
else: [{ kind: "set-capture-flag", params: { flag: 1 } }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conditional primitive — childPrimitives()", () => {
|
||||
it("returns then + else primitives combined", () => {
|
||||
const thenBranch: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-direction", params: { direction: "forward" } },
|
||||
];
|
||||
const elseBranch: EffectPrimitiveNode[] = [
|
||||
{ kind: "block-move-type", params: { moveType: "capture" } },
|
||||
];
|
||||
|
||||
const children = CONDITIONAL_PRIMITIVE.childPrimitives?.({
|
||||
condition: { type: "always" },
|
||||
then: thenBranch,
|
||||
else: elseBranch,
|
||||
});
|
||||
|
||||
expect(children).toEqual([...thenBranch, ...elseBranch]);
|
||||
});
|
||||
|
||||
it("accepts conditional params with no else branch", () => {
|
||||
const parsed = CONDITIONAL_PRIMITIVE.paramsSchema.parse({
|
||||
condition: { type: "attr-gt", attr: "Hp", value: 2 },
|
||||
then: [{ kind: "seed-attribute", params: { attr: "HpBonus", value: 1 } }],
|
||||
});
|
||||
|
||||
expect(parsed.else).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("conditional primitive — paramsSchema", () => {
|
||||
it("rejects unknown condition.type values", () => {
|
||||
expect(() =>
|
||||
CONDITIONAL_PRIMITIVE.paramsSchema.parse({
|
||||
condition: { type: "attr-between", attr: "Hp", min: 1, max: 3 },
|
||||
then: [],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
86
packages/chess/src/modifiers/primitives/conditional.ts
Normal file
86
packages/chess/src/modifiers/primitives/conditional.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { z } from "zod";
|
||||
import type { ChessAttrMap, ConditionSpec } from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type {
|
||||
EffectPrimitive,
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveApplyContext,
|
||||
PrimitiveKind,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Primitive kinds are runtime-validated later by the tree validator (T19).
|
||||
* Keeping this as `string` avoids hard-coding every primitive literal here.
|
||||
*/
|
||||
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
|
||||
kind: z.string() as z.ZodType<PrimitiveKind>,
|
||||
params: z.unknown(),
|
||||
});
|
||||
|
||||
const ConditionSchema: z.ZodType<ConditionSpec> = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("attr-lt"),
|
||||
attr: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("attr-gt"),
|
||||
attr: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("attr-eq"),
|
||||
attr: z.string(),
|
||||
value: z.union([z.string(), z.number(), z.boolean(), z.null()]),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("always"),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("never"),
|
||||
}),
|
||||
]);
|
||||
|
||||
const schema = z.object({
|
||||
condition: ConditionSchema,
|
||||
then: z.array(NodeSchema),
|
||||
else: z.array(NodeSchema).optional(),
|
||||
});
|
||||
type Params = z.infer<typeof schema>;
|
||||
type ConditionalHook = ChessAttrMap["ConditionalHooks"][number];
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "conditional",
|
||||
label: "Conditional",
|
||||
description:
|
||||
"Seeds ConditionalHooks entries consumed by the trigger evaluation pipeline.",
|
||||
paramsSchema: schema,
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing =
|
||||
(ctx.session.get(ctx.pieceId, "ConditionalHooks") as
|
||||
| ChessAttrMap["ConditionalHooks"]
|
||||
| undefined) ?? [];
|
||||
|
||||
let next: ConditionalHook;
|
||||
if (params.else !== undefined) {
|
||||
next = {
|
||||
condition: params.condition,
|
||||
then: [...params.then],
|
||||
else: [...params.else],
|
||||
};
|
||||
} else {
|
||||
next = {
|
||||
condition: params.condition,
|
||||
then: [...params.then],
|
||||
};
|
||||
}
|
||||
|
||||
ctx.session.insert(ctx.pieceId, "ConditionalHooks", [...existing, next]);
|
||||
},
|
||||
childPrimitives(params: Params): EffectPrimitiveNode[] {
|
||||
return [...params.then, ...(params.else ?? [])];
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
export { descriptor as CONDITIONAL_PRIMITIVE };
|
||||
30
packages/chess/src/modifiers/primitives/index.ts
Normal file
30
packages/chess/src/modifiers/primitives/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export { PrimitiveRegistryClass, PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
export type {
|
||||
PrimitiveKind,
|
||||
EffectPrimitive,
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveApplyContext,
|
||||
CustomModifierDescriptorRef,
|
||||
Session,
|
||||
} from "./types.js";
|
||||
|
||||
// State primitives (Wave 2 batch A — T4–T8):
|
||||
import "./seed-attribute.js";
|
||||
import "./add-to-attribute.js";
|
||||
import "./multiply-attribute.js";
|
||||
import "./add-direction.js";
|
||||
import "./set-capture-flag.js";
|
||||
|
||||
// Mechanic primitives (Wave 2 batch B — T9–T13):
|
||||
import "./absorb-damage-with-attribute.js";
|
||||
import "./reflect-damage.js";
|
||||
import "./modify-movement-range.js";
|
||||
import "./block-move-type.js";
|
||||
import "./override-promotion.js";
|
||||
|
||||
// Advanced primitives (Wave 2 batch C — T14–T18):
|
||||
import "./add-aura.js";
|
||||
import "./on-turn-start.js";
|
||||
import "./on-capture.js";
|
||||
import "./on-damaged.js";
|
||||
import "./conditional.js";
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { Session, type EntityId } from "@paratype/rete";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import { MODIFY_MOVEMENT_RANGE_PRIMITIVE } from "./modify-movement-range.js";
|
||||
|
||||
function makeContext(session: Session, pieceId: EntityId) {
|
||||
return {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "test-descriptor",
|
||||
type: "data" as const,
|
||||
version: 1 as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("MODIFY_MOVEMENT_RANGE_PRIMITIVE", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("modify-movement-range")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("modify-movement-range")).toBe(
|
||||
MODIFY_MOVEMENT_RANGE_PRIMITIVE,
|
||||
);
|
||||
});
|
||||
|
||||
it("treats absent RangeBonus as 0 before applying delta", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "bishop");
|
||||
|
||||
MODIFY_MOVEMENT_RANGE_PRIMITIVE.apply(
|
||||
makeContext(session, pieceId),
|
||||
MODIFY_MOVEMENT_RANGE_PRIMITIVE.paramsSchema.parse({ delta: 2 }),
|
||||
);
|
||||
|
||||
expect(session.get(pieceId, "RangeBonus")).toBe(2);
|
||||
});
|
||||
|
||||
it("adds delta to existing RangeBonus", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "rook");
|
||||
session.insert(pieceId, "RangeBonus", 3);
|
||||
|
||||
MODIFY_MOVEMENT_RANGE_PRIMITIVE.apply(
|
||||
makeContext(session, pieceId),
|
||||
MODIFY_MOVEMENT_RANGE_PRIMITIVE.paramsSchema.parse({ delta: -1 }),
|
||||
);
|
||||
|
||||
expect(session.get(pieceId, "RangeBonus")).toBe(2);
|
||||
});
|
||||
|
||||
it("accumulates when applied multiple times", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "queen");
|
||||
|
||||
MODIFY_MOVEMENT_RANGE_PRIMITIVE.apply(
|
||||
makeContext(session, pieceId),
|
||||
MODIFY_MOVEMENT_RANGE_PRIMITIVE.paramsSchema.parse({ delta: 1 }),
|
||||
);
|
||||
MODIFY_MOVEMENT_RANGE_PRIMITIVE.apply(
|
||||
makeContext(session, pieceId),
|
||||
MODIFY_MOVEMENT_RANGE_PRIMITIVE.paramsSchema.parse({ delta: 2 }),
|
||||
);
|
||||
|
||||
expect(session.get(pieceId, "RangeBonus")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { z } from "zod";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
delta: z.number().int().min(-7).max(7),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "modify-movement-range",
|
||||
label: "Modify Movement Range",
|
||||
description: "Additively contributes to the existing RangeBonus attribute.",
|
||||
paramsSchema: schema,
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing = ctx.session.contains(ctx.pieceId, "RangeBonus")
|
||||
? ctx.session.get(ctx.pieceId, "RangeBonus")
|
||||
: 0;
|
||||
const baseline = typeof existing === "number" ? existing : 0;
|
||||
ctx.session.insert(ctx.pieceId, "RangeBonus", baseline + params.delta);
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
|
||||
export { descriptor as MODIFY_MOVEMENT_RANGE_PRIMITIVE };
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { Session } from "@paratype/rete";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { PrimitiveApplyContext } from "./types.js";
|
||||
import "./multiply-attribute.js";
|
||||
import { MULTIPLY_ATTRIBUTE_PRIMITIVE } from "./multiply-attribute.js";
|
||||
|
||||
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "custom:test-multiply-attribute",
|
||||
type: "data",
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
describe("multiply-attribute primitive — registry", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY under key 'multiply-attribute'", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("multiply-attribute")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("multiply-attribute")).toBe(
|
||||
MULTIPLY_ATTRIBUTE_PRIMITIVE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiply-attribute primitive — apply()", () => {
|
||||
it("multiplies an existing number", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
session.insert(ctx.pieceId, "Hp", 6);
|
||||
|
||||
MULTIPLY_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "Hp", factor: 1.5 });
|
||||
|
||||
expect(session.get(ctx.pieceId, "Hp")).toBe(9);
|
||||
});
|
||||
|
||||
it("is a no-op when the attribute is absent", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
|
||||
MULTIPLY_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "RangeBonus", factor: 2 });
|
||||
|
||||
expect(session.get(ctx.pieceId, "RangeBonus")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws when existing value is non-numeric", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
session.insert(ctx.pieceId, "PieceType", "rook");
|
||||
|
||||
expect(() => {
|
||||
MULTIPLY_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "PieceType", factor: 2 });
|
||||
}).toThrow(/expected numeric value/);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { z } from "zod";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
attr: z.string(),
|
||||
factor: z.number(),
|
||||
});
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "multiply-attribute",
|
||||
label: "Multiply Attribute",
|
||||
description: "Multiplies an existing attribute value by the provided factor.",
|
||||
paramsSchema: schema,
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing = ctx.session.get(ctx.pieceId, params.attr);
|
||||
if (existing === undefined) {
|
||||
return;
|
||||
}
|
||||
if (typeof existing !== "number" || Number.isNaN(existing)) {
|
||||
throw new Error(
|
||||
`multiply-attribute expected numeric value for attr "${params.attr}" but got ${typeof existing}`,
|
||||
);
|
||||
}
|
||||
ctx.session.insert(ctx.pieceId, params.attr, existing * params.factor);
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
export { descriptor as MULTIPLY_ATTRIBUTE_PRIMITIVE };
|
||||
72
packages/chess/src/modifiers/primitives/on-capture.test.ts
Normal file
72
packages/chess/src/modifiers/primitives/on-capture.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Session } from "@paratype/rete";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import { ON_CAPTURE_PRIMITIVE } from "./on-capture.js";
|
||||
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
|
||||
import "./on-capture.js";
|
||||
|
||||
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "custom:test-on-capture",
|
||||
type: "data",
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
describe("on-capture primitive — registry", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY under key 'on-capture'", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("on-capture")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("on-capture")).toBe(ON_CAPTURE_PRIMITIVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-capture primitive — apply()", () => {
|
||||
it("seeds one capture hook", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
const primitives: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 1 } },
|
||||
];
|
||||
|
||||
ON_CAPTURE_PRIMITIVE.apply(ctx, { primitives });
|
||||
|
||||
expect(session.get(ctx.pieceId, "OnCaptureHooks")).toEqual([primitives]);
|
||||
});
|
||||
|
||||
it("appends additional hooks to existing OnCaptureHooks", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
const first: EffectPrimitiveNode[] = [
|
||||
{ kind: "seed-attribute", params: { attr: "Hp", value: 2 } },
|
||||
];
|
||||
const second: EffectPrimitiveNode[] = [
|
||||
{ kind: "multiply-attribute", params: { attr: "HpBonus", factor: 2 } },
|
||||
];
|
||||
|
||||
ON_CAPTURE_PRIMITIVE.apply(ctx, { primitives: first });
|
||||
ON_CAPTURE_PRIMITIVE.apply(ctx, { primitives: second });
|
||||
|
||||
expect(session.get(ctx.pieceId, "OnCaptureHooks")).toEqual([first, second]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-capture primitive — childPrimitives()", () => {
|
||||
it("returns the inner primitive list for validator tree traversal", () => {
|
||||
const primitives: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-direction", params: { direction: "left" } },
|
||||
{ kind: "set-capture-flag", params: { flag: 2 } },
|
||||
];
|
||||
|
||||
const children = ON_CAPTURE_PRIMITIVE.childPrimitives?.({ primitives });
|
||||
|
||||
expect(children).toEqual(primitives);
|
||||
});
|
||||
});
|
||||
47
packages/chess/src/modifiers/primitives/on-capture.ts
Normal file
47
packages/chess/src/modifiers/primitives/on-capture.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { z } from "zod";
|
||||
import type { ChessAttrMap } from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type {
|
||||
EffectPrimitive,
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveApplyContext,
|
||||
PrimitiveKind,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Primitive kinds are runtime-validated later by the tree validator (T19).
|
||||
* Keeping this as `string` avoids hard-coding every primitive literal here.
|
||||
*/
|
||||
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
|
||||
kind: z.string() as z.ZodType<PrimitiveKind>,
|
||||
params: z.unknown(),
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
primitives: z.array(NodeSchema),
|
||||
});
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "on-capture",
|
||||
label: "On Capture",
|
||||
description: "Seeds OnCaptureHooks entries consumed during capture events.",
|
||||
paramsSchema: schema,
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing =
|
||||
(ctx.session.get(ctx.pieceId, "OnCaptureHooks") as
|
||||
| ChessAttrMap["OnCaptureHooks"]
|
||||
| undefined) ?? [];
|
||||
|
||||
ctx.session.insert(ctx.pieceId, "OnCaptureHooks", [
|
||||
...existing,
|
||||
[...params.primitives],
|
||||
]);
|
||||
},
|
||||
childPrimitives(params: Params): EffectPrimitiveNode[] {
|
||||
return [...params.primitives];
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
export { descriptor as ON_CAPTURE_PRIMITIVE };
|
||||
72
packages/chess/src/modifiers/primitives/on-damaged.test.ts
Normal file
72
packages/chess/src/modifiers/primitives/on-damaged.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Session } from "@paratype/rete";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import { ON_DAMAGED_PRIMITIVE } from "./on-damaged.js";
|
||||
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
|
||||
import "./on-damaged.js";
|
||||
|
||||
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "custom:test-on-damaged",
|
||||
type: "data",
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
describe("on-damaged primitive — registry", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY under key 'on-damaged'", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("on-damaged")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("on-damaged")).toBe(ON_DAMAGED_PRIMITIVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-damaged primitive — apply()", () => {
|
||||
it("seeds one damaged hook", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
const primitives: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: -1 } },
|
||||
];
|
||||
|
||||
ON_DAMAGED_PRIMITIVE.apply(ctx, { primitives });
|
||||
|
||||
expect(session.get(ctx.pieceId, "OnDamagedHooks")).toEqual([primitives]);
|
||||
});
|
||||
|
||||
it("appends additional hooks to existing OnDamagedHooks", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
const first: EffectPrimitiveNode[] = [
|
||||
{ kind: "seed-attribute", params: { attr: "Hp", value: 1 } },
|
||||
];
|
||||
const second: EffectPrimitiveNode[] = [
|
||||
{ kind: "reflect-damage", params: { percent: 0.5 } },
|
||||
];
|
||||
|
||||
ON_DAMAGED_PRIMITIVE.apply(ctx, { primitives: first });
|
||||
ON_DAMAGED_PRIMITIVE.apply(ctx, { primitives: second });
|
||||
|
||||
expect(session.get(ctx.pieceId, "OnDamagedHooks")).toEqual([first, second]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-damaged primitive — childPrimitives()", () => {
|
||||
it("returns the inner primitive list for validator tree traversal", () => {
|
||||
const primitives: EffectPrimitiveNode[] = [
|
||||
{ kind: "absorb-damage-with-attribute", params: { attr: "Hp", rate: 1 } },
|
||||
{ kind: "set-capture-flag", params: { flag: 4 } },
|
||||
];
|
||||
|
||||
const children = ON_DAMAGED_PRIMITIVE.childPrimitives?.({ primitives });
|
||||
|
||||
expect(children).toEqual(primitives);
|
||||
});
|
||||
});
|
||||
47
packages/chess/src/modifiers/primitives/on-damaged.ts
Normal file
47
packages/chess/src/modifiers/primitives/on-damaged.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { z } from "zod";
|
||||
import type { ChessAttrMap } from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type {
|
||||
EffectPrimitive,
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveApplyContext,
|
||||
PrimitiveKind,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Primitive kinds are runtime-validated later by the tree validator (T19).
|
||||
* Keeping this as `string` avoids hard-coding every primitive literal here.
|
||||
*/
|
||||
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
|
||||
kind: z.string() as z.ZodType<PrimitiveKind>,
|
||||
params: z.unknown(),
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
primitives: z.array(NodeSchema),
|
||||
});
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "on-damaged",
|
||||
label: "On Damaged",
|
||||
description: "Seeds OnDamagedHooks entries consumed during damage events.",
|
||||
paramsSchema: schema,
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing =
|
||||
(ctx.session.get(ctx.pieceId, "OnDamagedHooks") as
|
||||
| ChessAttrMap["OnDamagedHooks"]
|
||||
| undefined) ?? [];
|
||||
|
||||
ctx.session.insert(ctx.pieceId, "OnDamagedHooks", [
|
||||
...existing,
|
||||
[...params.primitives],
|
||||
]);
|
||||
},
|
||||
childPrimitives(params: Params): EffectPrimitiveNode[] {
|
||||
return [...params.primitives];
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
export { descriptor as ON_DAMAGED_PRIMITIVE };
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { Session } from "@paratype/rete";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import { ON_TURN_START_PRIMITIVE } from "./on-turn-start.js";
|
||||
import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js";
|
||||
import "./on-turn-start.js";
|
||||
|
||||
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "custom:test-on-turn-start",
|
||||
type: "data",
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
describe("on-turn-start primitive — registry", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY under key 'on-turn-start'", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("on-turn-start")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("on-turn-start")).toBe(ON_TURN_START_PRIMITIVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-turn-start primitive — apply()", () => {
|
||||
it("seeds one turn-start hook", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
const primitives: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } },
|
||||
];
|
||||
|
||||
ON_TURN_START_PRIMITIVE.apply(ctx, { primitives });
|
||||
|
||||
expect(session.get(ctx.pieceId, "OnTurnStartHooks")).toEqual([primitives]);
|
||||
});
|
||||
|
||||
it("appends additional hooks to existing OnTurnStartHooks", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
const first: EffectPrimitiveNode[] = [
|
||||
{ kind: "seed-attribute", params: { attr: "Hp", value: 3 } },
|
||||
];
|
||||
const second: EffectPrimitiveNode[] = [
|
||||
{ kind: "set-capture-flag", params: { flag: 1 } },
|
||||
];
|
||||
|
||||
ON_TURN_START_PRIMITIVE.apply(ctx, { primitives: first });
|
||||
ON_TURN_START_PRIMITIVE.apply(ctx, { primitives: second });
|
||||
|
||||
expect(session.get(ctx.pieceId, "OnTurnStartHooks")).toEqual([first, second]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-turn-start primitive — childPrimitives()", () => {
|
||||
it("returns the inner primitive list for validator tree traversal", () => {
|
||||
const primitives: EffectPrimitiveNode[] = [
|
||||
{ kind: "add-direction", params: { direction: "backward" } },
|
||||
{ kind: "block-move-type", params: { moveType: "slide" } },
|
||||
];
|
||||
|
||||
const children = ON_TURN_START_PRIMITIVE.childPrimitives?.({ primitives });
|
||||
|
||||
expect(children).toEqual(primitives);
|
||||
});
|
||||
});
|
||||
48
packages/chess/src/modifiers/primitives/on-turn-start.ts
Normal file
48
packages/chess/src/modifiers/primitives/on-turn-start.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { z } from "zod";
|
||||
import type { ChessAttrMap } from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type {
|
||||
EffectPrimitive,
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveApplyContext,
|
||||
PrimitiveKind,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Primitive kinds are runtime-validated later by the tree validator (T19).
|
||||
* Keeping this as `string` avoids hard-coding every primitive literal here.
|
||||
*/
|
||||
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
|
||||
kind: z.string() as z.ZodType<PrimitiveKind>,
|
||||
params: z.unknown(),
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
primitives: z.array(NodeSchema),
|
||||
});
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "on-turn-start",
|
||||
label: "On Turn Start",
|
||||
description:
|
||||
"Seeds OnTurnStartHooks entries consumed during the engine's turn-start phase.",
|
||||
paramsSchema: schema,
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing =
|
||||
(ctx.session.get(ctx.pieceId, "OnTurnStartHooks") as
|
||||
| ChessAttrMap["OnTurnStartHooks"]
|
||||
| undefined) ?? [];
|
||||
|
||||
ctx.session.insert(ctx.pieceId, "OnTurnStartHooks", [
|
||||
...existing,
|
||||
[...params.primitives],
|
||||
]);
|
||||
},
|
||||
childPrimitives(params: Params): EffectPrimitiveNode[] {
|
||||
return [...params.primitives];
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
export { descriptor as ON_TURN_START_PRIMITIVE };
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { Session, type EntityId } from "@paratype/rete";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import { OVERRIDE_PROMOTION_PRIMITIVE } from "./override-promotion.js";
|
||||
|
||||
function makeContext(session: Session, pieceId: EntityId) {
|
||||
return {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "test-descriptor",
|
||||
type: "data" as const,
|
||||
version: 1 as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("OVERRIDE_PROMOTION_PRIMITIVE", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("override-promotion")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("override-promotion")).toBe(OVERRIDE_PROMOTION_PRIMITIVE);
|
||||
});
|
||||
|
||||
it("writes PromotionOverride with the requested target", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "pawn");
|
||||
|
||||
OVERRIDE_PROMOTION_PRIMITIVE.apply(
|
||||
makeContext(session, pieceId),
|
||||
OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "queen" }),
|
||||
);
|
||||
|
||||
expect(session.get(pieceId, "PromotionOverride")).toBe("queen");
|
||||
});
|
||||
|
||||
it("is last-wins when re-applied", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "pawn");
|
||||
|
||||
OVERRIDE_PROMOTION_PRIMITIVE.apply(
|
||||
makeContext(session, pieceId),
|
||||
OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "knight" }),
|
||||
);
|
||||
OVERRIDE_PROMOTION_PRIMITIVE.apply(
|
||||
makeContext(session, pieceId),
|
||||
OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "bishop" }),
|
||||
);
|
||||
|
||||
expect(session.get(pieceId, "PromotionOverride")).toBe("bishop");
|
||||
});
|
||||
|
||||
it("accepts canonical PieceType values from schema", () => {
|
||||
expect(() => OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "pawn" })).not.toThrow();
|
||||
expect(() => OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "king" })).not.toThrow();
|
||||
expect(() => OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "queen" })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { z } from "zod";
|
||||
import type { PieceType } from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
target: z.enum(["pawn", "knight", "bishop", "rook", "queen", "king"]),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "override-promotion",
|
||||
label: "Override Promotion",
|
||||
description: "Write PromotionOverride directly to enforce a promotion target.",
|
||||
paramsSchema: schema,
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const target: PieceType = params.target;
|
||||
ctx.session.insert(ctx.pieceId, "PromotionOverride", target);
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
|
||||
export { descriptor as OVERRIDE_PROMOTION_PRIMITIVE };
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { Session, type EntityId } from "@paratype/rete";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import { REFLECT_DAMAGE_PRIMITIVE } from "./reflect-damage.js";
|
||||
|
||||
function makeContext(session: Session, pieceId: EntityId) {
|
||||
return {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "test-descriptor",
|
||||
type: "data" as const,
|
||||
version: 1 as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("REFLECT_DAMAGE_PRIMITIVE", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("reflect-damage")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("reflect-damage")).toBe(REFLECT_DAMAGE_PRIMITIVE);
|
||||
});
|
||||
|
||||
it("writes ReflectDamagePercent fact", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "pawn");
|
||||
|
||||
const params = REFLECT_DAMAGE_PRIMITIVE.paramsSchema.parse({ percentage: 35 });
|
||||
REFLECT_DAMAGE_PRIMITIVE.apply(makeContext(session, pieceId), params);
|
||||
|
||||
expect(session.get(pieceId, "ReflectDamagePercent")).toBe(35);
|
||||
});
|
||||
|
||||
it("rejects percentage outside 0..100", () => {
|
||||
expect(() => REFLECT_DAMAGE_PRIMITIVE.paramsSchema.parse({ percentage: -1 })).toThrow();
|
||||
expect(() => REFLECT_DAMAGE_PRIMITIVE.paramsSchema.parse({ percentage: 101 })).toThrow();
|
||||
});
|
||||
|
||||
it("overwrites existing value on re-apply", () => {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
session.insert(pieceId, "PieceType", "pawn");
|
||||
|
||||
REFLECT_DAMAGE_PRIMITIVE.apply(
|
||||
makeContext(session, pieceId),
|
||||
REFLECT_DAMAGE_PRIMITIVE.paramsSchema.parse({ percentage: 20 }),
|
||||
);
|
||||
REFLECT_DAMAGE_PRIMITIVE.apply(
|
||||
makeContext(session, pieceId),
|
||||
REFLECT_DAMAGE_PRIMITIVE.paramsSchema.parse({ percentage: 60 }),
|
||||
);
|
||||
|
||||
expect(session.get(pieceId, "ReflectDamagePercent")).toBe(60);
|
||||
});
|
||||
});
|
||||
23
packages/chess/src/modifiers/primitives/reflect-damage.ts
Normal file
23
packages/chess/src/modifiers/primitives/reflect-damage.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { z } from "zod";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
percentage: z.number().int().min(0).max(100),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "reflect-damage",
|
||||
label: "Reflect Damage",
|
||||
description: "Seed reflected-damage percentage for deferred damage-pipeline wiring.",
|
||||
paramsSchema: schema,
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
ctx.session.insert(ctx.pieceId, "ReflectDamagePercent", params.percentage);
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
|
||||
export { descriptor as REFLECT_DAMAGE_PRIMITIVE };
|
||||
125
packages/chess/src/modifiers/primitives/registry.test.ts
Normal file
125
packages/chess/src/modifiers/primitives/registry.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
PrimitiveRegistryClass,
|
||||
type EffectPrimitive,
|
||||
type PrimitiveKind,
|
||||
} from "./index.js";
|
||||
|
||||
function primitive<P>(args: {
|
||||
kind: PrimitiveKind;
|
||||
label?: string;
|
||||
paramsSchema: EffectPrimitive<P>["paramsSchema"];
|
||||
}): EffectPrimitive<P> {
|
||||
return {
|
||||
kind: args.kind,
|
||||
label: args.label ?? args.kind,
|
||||
description: `${args.kind} primitive`,
|
||||
paramsSchema: args.paramsSchema,
|
||||
apply: () => {
|
||||
// no-op in registry tests
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("PrimitiveRegistryClass", () => {
|
||||
it("register() + get() round-trip returns the same descriptor", () => {
|
||||
const registry = new PrimitiveRegistryClass();
|
||||
const descriptor = primitive({
|
||||
kind: "seed-attribute",
|
||||
paramsSchema: z.object({ key: z.string(), value: z.number() }),
|
||||
});
|
||||
|
||||
registry.register(descriptor);
|
||||
|
||||
expect(registry.get("seed-attribute")).toBe(descriptor);
|
||||
});
|
||||
|
||||
it("register() throws on duplicate primitive kind", () => {
|
||||
const registry = new PrimitiveRegistryClass();
|
||||
registry.register(
|
||||
primitive({
|
||||
kind: "add-to-attribute",
|
||||
paramsSchema: z.object({ key: z.string(), delta: z.number() }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
registry.register(
|
||||
primitive({
|
||||
kind: "add-to-attribute",
|
||||
paramsSchema: z.object({ key: z.string(), delta: z.number() }),
|
||||
}),
|
||||
);
|
||||
}).toThrow(/add-to-attribute/);
|
||||
});
|
||||
|
||||
it("list() preserves registration order", () => {
|
||||
const registry = new PrimitiveRegistryClass();
|
||||
const first = primitive({
|
||||
kind: "set-capture-flag",
|
||||
paramsSchema: z.object({ flag: z.string() }),
|
||||
});
|
||||
const second = primitive({
|
||||
kind: "reflect-damage",
|
||||
paramsSchema: z.object({ ratio: z.number() }),
|
||||
});
|
||||
const third = primitive({
|
||||
kind: "override-promotion",
|
||||
paramsSchema: z.object({ to: z.string() }),
|
||||
});
|
||||
|
||||
registry.register(first);
|
||||
registry.register(second);
|
||||
registry.register(third);
|
||||
|
||||
expect(registry.list()).toEqual([first, second, third]);
|
||||
});
|
||||
|
||||
it("has() is false before registration and true after", () => {
|
||||
const registry = new PrimitiveRegistryClass();
|
||||
|
||||
expect(registry.has("add-direction")).toBe(false);
|
||||
|
||||
registry.register(
|
||||
primitive({
|
||||
kind: "add-direction",
|
||||
paramsSchema: z.object({ direction: z.string() }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(registry.has("add-direction")).toBe(true);
|
||||
});
|
||||
|
||||
it("get() returns undefined for an unregistered known kind", () => {
|
||||
const registry = new PrimitiveRegistryClass();
|
||||
|
||||
expect(registry.get("conditional")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("generic params type is preserved at register call site", () => {
|
||||
const registry = new PrimitiveRegistryClass();
|
||||
|
||||
const typed = primitive({
|
||||
kind: "modify-movement-range",
|
||||
paramsSchema: z.object({
|
||||
mode: z.literal("set"),
|
||||
value: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
function registerAndReturn<P>(
|
||||
localRegistry: PrimitiveRegistryClass,
|
||||
descriptor: EffectPrimitive<P>,
|
||||
): EffectPrimitive<P> {
|
||||
localRegistry.register(descriptor);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
const registered = registerAndReturn(registry, typed);
|
||||
const parsed = registered.paramsSchema.parse({ mode: "set", value: 3 });
|
||||
|
||||
expect(parsed.value).toBe(3);
|
||||
expect(registry.get("modify-movement-range")).toBe(typed);
|
||||
});
|
||||
});
|
||||
30
packages/chess/src/modifiers/primitives/registry.ts
Normal file
30
packages/chess/src/modifiers/primitives/registry.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { EffectPrimitive, PrimitiveKind } from "./types.js";
|
||||
|
||||
class PrimitiveRegistryClass {
|
||||
readonly #byId = new Map<PrimitiveKind, EffectPrimitive>();
|
||||
|
||||
register<P>(primitive: EffectPrimitive<P>): void {
|
||||
if (this.#byId.has(primitive.kind)) {
|
||||
throw new Error(
|
||||
`PrimitiveRegistry: duplicate primitive kind "${primitive.kind}". ` +
|
||||
`Each primitive descriptor must have a unique kind.`,
|
||||
);
|
||||
}
|
||||
this.#byId.set(primitive.kind, primitive as EffectPrimitive);
|
||||
}
|
||||
|
||||
get(kind: PrimitiveKind): EffectPrimitive | undefined {
|
||||
return this.#byId.get(kind);
|
||||
}
|
||||
|
||||
list(): readonly EffectPrimitive[] {
|
||||
return [...this.#byId.values()];
|
||||
}
|
||||
|
||||
has(kind: PrimitiveKind): boolean {
|
||||
return this.#byId.has(kind);
|
||||
}
|
||||
}
|
||||
|
||||
export { PrimitiveRegistryClass };
|
||||
export const PRIMITIVE_REGISTRY = new PrimitiveRegistryClass();
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { Session } from "@paratype/rete";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { PrimitiveApplyContext } from "./types.js";
|
||||
import "./seed-attribute.js";
|
||||
import { SEED_ATTRIBUTE_PRIMITIVE } from "./seed-attribute.js";
|
||||
|
||||
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "custom:test-seed-attribute",
|
||||
type: "data",
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
describe("seed-attribute primitive — registry", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY under key 'seed-attribute'", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("seed-attribute")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("seed-attribute")).toBe(SEED_ATTRIBUTE_PRIMITIVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seed-attribute primitive — apply()", () => {
|
||||
it("writes a string attribute", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
|
||||
SEED_ATTRIBUTE_PRIMITIVE.apply(ctx, {
|
||||
attr: "PieceType",
|
||||
value: "queen",
|
||||
});
|
||||
|
||||
expect(session.get(ctx.pieceId, "PieceType")).toBe("queen");
|
||||
});
|
||||
|
||||
it("writes a number attribute", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
|
||||
SEED_ATTRIBUTE_PRIMITIVE.apply(ctx, {
|
||||
attr: "Hp",
|
||||
value: 12,
|
||||
});
|
||||
|
||||
expect(session.get(ctx.pieceId, "Hp")).toBe(12);
|
||||
});
|
||||
|
||||
it("overwrites an existing fact", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
session.insert(ctx.pieceId, "RangeBonus", 1);
|
||||
|
||||
SEED_ATTRIBUTE_PRIMITIVE.apply(ctx, {
|
||||
attr: "RangeBonus",
|
||||
value: 4,
|
||||
});
|
||||
|
||||
expect(session.get(ctx.pieceId, "RangeBonus")).toBe(4);
|
||||
});
|
||||
});
|
||||
51
packages/chess/src/modifiers/primitives/seed-attribute.ts
Normal file
51
packages/chess/src/modifiers/primitives/seed-attribute.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { z } from "zod";
|
||||
import type { ChessAttrKey } from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
const CHESS_ATTR_KEYS: ReadonlySet<ChessAttrKey> = new Set([
|
||||
"PieceType",
|
||||
"Color",
|
||||
"Position",
|
||||
"HasMoved",
|
||||
"Turn",
|
||||
"HalfmoveClock",
|
||||
"FullmoveNumber",
|
||||
"EnPassantTarget",
|
||||
"GameStatus",
|
||||
"Winner",
|
||||
"Hp",
|
||||
"PoisonedSquare",
|
||||
"HpBonus",
|
||||
"RangeBonus",
|
||||
"DirectionAdditions",
|
||||
"CaptureFlags",
|
||||
"PromotionOverride",
|
||||
"DamageResistance",
|
||||
]);
|
||||
|
||||
function isChessAttrKey(attr: string): attr is ChessAttrKey {
|
||||
return CHESS_ATTR_KEYS.has(attr as ChessAttrKey);
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
attr: z.string(),
|
||||
value: z.unknown(),
|
||||
});
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "seed-attribute",
|
||||
label: "Seed Attribute",
|
||||
description: "Seeds a fact on the target piece, overwriting existing value.",
|
||||
paramsSchema: schema,
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
if (!isChessAttrKey(params.attr)) {
|
||||
return;
|
||||
}
|
||||
ctx.session.insert(ctx.pieceId, params.attr, params.value);
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
export { descriptor as SEED_ATTRIBUTE_PRIMITIVE };
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { Session } from "@paratype/rete";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { CaptureFlag } from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { PrimitiveApplyContext } from "./types.js";
|
||||
import "./set-capture-flag.js";
|
||||
import { SET_CAPTURE_FLAG_PRIMITIVE } from "./set-capture-flag.js";
|
||||
|
||||
function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||
const session = new Session();
|
||||
const pieceId = session.nextId();
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine: new ChessEngine(),
|
||||
session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "custom:test-set-capture-flag",
|
||||
type: "data",
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
return { ctx, session };
|
||||
}
|
||||
|
||||
describe("set-capture-flag primitive — registry", () => {
|
||||
it("registers in PRIMITIVE_REGISTRY under key 'set-capture-flag'", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("set-capture-flag")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("set-capture-flag")).toBe(
|
||||
SET_CAPTURE_FLAG_PRIMITIVE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("set-capture-flag primitive — apply()", () => {
|
||||
it("sets a flag from a clean state", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
|
||||
SET_CAPTURE_FLAG_PRIMITIVE.apply(ctx, {
|
||||
flag: CaptureFlag.CANNOT_BE_CAPTURED,
|
||||
});
|
||||
|
||||
expect(session.get(ctx.pieceId, "CaptureFlags")).toBe(
|
||||
CaptureFlag.CANNOT_BE_CAPTURED,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves existing flags when adding a new one", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
session.insert(ctx.pieceId, "CaptureFlags", CaptureFlag.CAN_CAPTURE_OWN);
|
||||
|
||||
SET_CAPTURE_FLAG_PRIMITIVE.apply(ctx, {
|
||||
flag: CaptureFlag.EN_PASSANT,
|
||||
});
|
||||
|
||||
expect(session.get(ctx.pieceId, "CaptureFlags")).toBe(
|
||||
CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.EN_PASSANT,
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent when setting the same flag twice", () => {
|
||||
const { ctx, session } = makeContext();
|
||||
|
||||
SET_CAPTURE_FLAG_PRIMITIVE.apply(ctx, {
|
||||
flag: CaptureFlag.CAN_CAPTURE_OWN,
|
||||
});
|
||||
SET_CAPTURE_FLAG_PRIMITIVE.apply(ctx, {
|
||||
flag: CaptureFlag.CAN_CAPTURE_OWN,
|
||||
});
|
||||
|
||||
expect(session.get(ctx.pieceId, "CaptureFlags")).toBe(
|
||||
CaptureFlag.CAN_CAPTURE_OWN,
|
||||
);
|
||||
});
|
||||
});
|
||||
36
packages/chess/src/modifiers/primitives/set-capture-flag.ts
Normal file
36
packages/chess/src/modifiers/primitives/set-capture-flag.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
CaptureFlag,
|
||||
type CaptureFlag as CaptureFlagValue,
|
||||
} from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||
|
||||
const schema = z.object({
|
||||
flag: z.union([
|
||||
z.literal(CaptureFlag.CAN_CAPTURE_OWN),
|
||||
z.literal(CaptureFlag.CANNOT_BE_CAPTURED),
|
||||
z.literal(CaptureFlag.EN_PASSANT),
|
||||
]),
|
||||
});
|
||||
type Params = {
|
||||
flag: CaptureFlagValue;
|
||||
};
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "set-capture-flag",
|
||||
label: "Set Capture Flag",
|
||||
description: "Bitwise-ORs one capture flag into CaptureFlags.",
|
||||
paramsSchema: schema,
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
const existing = ctx.session.get(ctx.pieceId, "CaptureFlags");
|
||||
const baseFlags = existing === undefined ? 0 : existing;
|
||||
if (typeof baseFlags !== "number" || Number.isNaN(baseFlags)) {
|
||||
throw new Error("set-capture-flag expected CaptureFlags to be numeric");
|
||||
}
|
||||
ctx.session.insert(ctx.pieceId, "CaptureFlags", baseFlags | params.flag);
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
export { descriptor as SET_CAPTURE_FLAG_PRIMITIVE };
|
||||
71
packages/chess/src/modifiers/primitives/types.ts
Normal file
71
packages/chess/src/modifiers/primitives/types.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { EntityId, Session } from "@paratype/rete";
|
||||
import type { ZodType } from "zod";
|
||||
import type { ChessEngine } from "../../engine.js";
|
||||
|
||||
/**
|
||||
* T3 primitive ids (ADR-2).
|
||||
*/
|
||||
export type PrimitiveKind =
|
||||
| "seed-attribute"
|
||||
| "add-to-attribute"
|
||||
| "multiply-attribute"
|
||||
| "add-direction"
|
||||
| "set-capture-flag"
|
||||
| "absorb-damage-with-attribute"
|
||||
| "reflect-damage"
|
||||
| "block-move-type"
|
||||
| "modify-movement-range"
|
||||
| "override-promotion"
|
||||
| "add-aura"
|
||||
| "on-turn-start"
|
||||
| "on-capture"
|
||||
| "on-damaged"
|
||||
| "conditional";
|
||||
|
||||
/**
|
||||
* Forward-declared shape of the back-reference passed to primitive
|
||||
* `apply()` calls. The full descriptor lives in `../custom/types.js`
|
||||
* — declaring only the trunk here keeps the import graph one-way
|
||||
* (custom imports primitives, never the reverse).
|
||||
*
|
||||
* Adding a structurally-compatible field here is fine, but the
|
||||
* canonical shape is owned by `../custom/types.ts`.
|
||||
*/
|
||||
export interface CustomModifierDescriptorRef {
|
||||
readonly id: string;
|
||||
readonly type: "data";
|
||||
readonly version: 1;
|
||||
}
|
||||
|
||||
/** Runtime primitive node embedded in custom descriptor trees. */
|
||||
export interface EffectPrimitiveNode {
|
||||
readonly kind: PrimitiveKind;
|
||||
readonly params: unknown;
|
||||
}
|
||||
|
||||
/** Context passed to primitive apply functions. */
|
||||
export interface PrimitiveApplyContext {
|
||||
readonly engine: ChessEngine;
|
||||
readonly session: Session;
|
||||
readonly pieceId: EntityId;
|
||||
readonly depth: number;
|
||||
readonly descriptor: CustomModifierDescriptorRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* Primitive descriptor contract implemented by each T3 primitive.
|
||||
*
|
||||
* `childPrimitives` is provided by primitives that own nested primitive lists
|
||||
* (e.g. trigger/conditional primitives) so validators can walk the tree.
|
||||
*/
|
||||
export interface EffectPrimitive<Params = unknown> {
|
||||
readonly kind: PrimitiveKind;
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
readonly paramsSchema: ZodType<Params>;
|
||||
readonly apply: (ctx: PrimitiveApplyContext, params: Params) => void;
|
||||
readonly maxDepth?: number;
|
||||
readonly childPrimitives?: (params: Params) => EffectPrimitiveNode[];
|
||||
}
|
||||
|
||||
export type { Session };
|
||||
294
packages/chess/src/modifiers/reconcile.test.ts
Normal file
294
packages/chess/src/modifiers/reconcile.test.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
/**
|
||||
* Tests for reconcileProfileSwap.
|
||||
*
|
||||
* Setup pattern: build a session via `applyLayout(CLASSIC_LAYOUT)`,
|
||||
* optionally seed `Hp` on pieces (simulating `piece-hp` active), run
|
||||
* reconcile, assert the post-state.
|
||||
*
|
||||
* We don't actually activate `piece-hp` as a preset here — these
|
||||
* tests are scoped to reconcile's own contract, so we write `Hp`
|
||||
* directly. Higher-level integration with `piece-hp` is covered by
|
||||
* engine-surface tests elsewhere.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Session } from "@paratype/rete";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import { applyLayout, CLASSIC_LAYOUT } from "../starting-position.js";
|
||||
import { applyProfileToSession } from "./apply.js";
|
||||
import { reconcileProfileSwap } from "./reconcile.js";
|
||||
import type { ModifierProfile } from "./types.js";
|
||||
import { CaptureFlag } from "../schema.js";
|
||||
// Side-effect imports so MODIFIER_REGISTRY is populated for apply /
|
||||
// reconcile to resolve descriptors.
|
||||
import "./index.js";
|
||||
|
||||
function profile(parts: Partial<ModifierProfile>): ModifierProfile {
|
||||
return {
|
||||
id: "p",
|
||||
name: "p",
|
||||
description: "",
|
||||
perType: [],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: "custom",
|
||||
...parts,
|
||||
};
|
||||
}
|
||||
|
||||
/** Locate the white knight entity on b1 (square 1). */
|
||||
function findB1Knight(session: Session): EntityId {
|
||||
const facts = session.allFacts();
|
||||
const pos = facts.find((f) => f.attr === "Position" && f.value === 1);
|
||||
expect(pos).toBeDefined();
|
||||
return pos!.id as EntityId;
|
||||
}
|
||||
|
||||
/** Locate the white pawn on e2 (square 12). */
|
||||
function findE2Pawn(session: Session): EntityId {
|
||||
const facts = session.allFacts();
|
||||
const pos = facts.find((f) => f.attr === "Position" && f.value === 12);
|
||||
expect(pos).toBeDefined();
|
||||
return pos!.id as EntityId;
|
||||
}
|
||||
|
||||
describe("reconcileProfileSwap", () => {
|
||||
let session: Session;
|
||||
|
||||
beforeEach(() => {
|
||||
session = new Session({ autoFire: false });
|
||||
applyLayout(session, CLASSIC_LAYOUT);
|
||||
// Silence orphan warnings; individual tests that care spy directly.
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("is idempotent: reconcile(A, A) twice produces identical session state", () => {
|
||||
const P = profile({
|
||||
perType: [
|
||||
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 2 },
|
||||
{ kind: "range-bonus", pieceType: "rook", color: "both", value: 1 },
|
||||
],
|
||||
perInstance: [
|
||||
{ kind: "capture-flags", square: "e1", value: CaptureFlag.CANNOT_BE_CAPTURED },
|
||||
],
|
||||
});
|
||||
|
||||
reconcileProfileSwap(session, null, P, CLASSIC_LAYOUT);
|
||||
const after1 = session.allFacts().map((f) => ({ ...f }));
|
||||
|
||||
reconcileProfileSwap(session, P, P, CLASSIC_LAYOUT);
|
||||
const after2 = session.allFacts().map((f) => ({ ...f }));
|
||||
|
||||
expect(after2).toEqual(after1);
|
||||
});
|
||||
|
||||
it("HpBonus shrinks: clamps current Hp down to new max, never below", () => {
|
||||
// Seed an HpBonus=+3 profile, then simulate `piece-hp` by writing
|
||||
// Hp=max=5 on b1 knight (BASELINE_HP=2 + bonus=3).
|
||||
const big = profile({
|
||||
perType: [
|
||||
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 3 },
|
||||
],
|
||||
});
|
||||
reconcileProfileSwap(session, null, big, CLASSIC_LAYOUT);
|
||||
const knight = findB1Knight(session);
|
||||
expect(session.get(knight, "HpBonus")).toBe(3);
|
||||
|
||||
// Simulate the knight took 0 damage — full HP = 5.
|
||||
session.insert(knight, "Hp", 5);
|
||||
|
||||
// Swap to a profile with HpBonus=0. new max = 2, Hp clamps to 2.
|
||||
const small = profile({
|
||||
perType: [
|
||||
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 0 },
|
||||
],
|
||||
});
|
||||
reconcileProfileSwap(session, big, small, CLASSIC_LAYOUT);
|
||||
|
||||
// HpBonus fact may be 0 (applied) or absent depending on stacking —
|
||||
// either way the effective max is baseline + bonus = 2.
|
||||
expect(session.get(knight, "Hp")).toBe(2);
|
||||
});
|
||||
|
||||
it("HpBonus grows: Hp does NOT grow (damaged pieces stay damaged)", () => {
|
||||
// Piece starts under a zero-bonus profile, with Hp=1 (simulating
|
||||
// it took 1 damage).
|
||||
const small = profile({
|
||||
perType: [
|
||||
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 0 },
|
||||
],
|
||||
});
|
||||
reconcileProfileSwap(session, null, small, CLASSIC_LAYOUT);
|
||||
const knight = findB1Knight(session);
|
||||
session.insert(knight, "Hp", 1);
|
||||
|
||||
// Upgrade to HpBonus=+5. New max=7 but current Hp should stay 1.
|
||||
const big = profile({
|
||||
perType: [
|
||||
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 5 },
|
||||
],
|
||||
});
|
||||
reconcileProfileSwap(session, small, big, CLASSIC_LAYOUT);
|
||||
|
||||
expect(session.get(knight, "HpBonus")).toBe(5);
|
||||
// Crucially: not rewritten to 7.
|
||||
expect(session.get(knight, "Hp")).toBe(1);
|
||||
});
|
||||
|
||||
it("full swap across all kinds: old facts gone, new facts seeded", () => {
|
||||
// Old profile populates HpBonus, RangeBonus, DirectionAdditions.
|
||||
const old = profile({
|
||||
perType: [
|
||||
{ kind: "hp-bonus", pieceType: "pawn", color: "white", value: 2 },
|
||||
{ kind: "range-bonus", pieceType: "rook", color: "white", value: 1 },
|
||||
{
|
||||
kind: "direction-additions",
|
||||
pieceType: "pawn",
|
||||
color: "white",
|
||||
value: ["backward"],
|
||||
},
|
||||
],
|
||||
});
|
||||
reconcileProfileSwap(session, null, old, CLASSIC_LAYOUT);
|
||||
|
||||
// New profile targets DIFFERENT kinds on different pieces entirely.
|
||||
// After swap, none of the old facts should remain, only the new.
|
||||
const next = profile({
|
||||
perType: [
|
||||
{
|
||||
kind: "capture-flags",
|
||||
pieceType: "bishop",
|
||||
color: "white",
|
||||
value: CaptureFlag.CAN_CAPTURE_OWN,
|
||||
},
|
||||
{
|
||||
kind: "damage-resistance",
|
||||
pieceType: "queen",
|
||||
color: "white",
|
||||
value: 0.5,
|
||||
},
|
||||
],
|
||||
perInstance: [
|
||||
{ kind: "promotion-override", square: "e2", value: "rook" },
|
||||
],
|
||||
});
|
||||
reconcileProfileSwap(session, old, next, CLASSIC_LAYOUT);
|
||||
|
||||
// Old-profile facts ALL gone.
|
||||
const pawn = findE2Pawn(session);
|
||||
expect(session.contains(pawn, "HpBonus")).toBe(false);
|
||||
expect(session.contains(pawn, "DirectionAdditions")).toBe(false);
|
||||
// Even other pieces that got old-profile facts are clean.
|
||||
const facts = session.allFacts();
|
||||
const anyRangeBonus = facts.some((f) => f.attr === "RangeBonus");
|
||||
expect(anyRangeBonus).toBe(false);
|
||||
|
||||
// New-profile facts are present where expected.
|
||||
expect(session.get(pawn, "PromotionOverride")).toBe("rook");
|
||||
const bishops = facts.filter(
|
||||
(f) => f.attr === "PieceType" && f.value === "bishop",
|
||||
);
|
||||
for (const b of bishops) {
|
||||
const colorFact = facts.find(
|
||||
(c) => c.id === b.id && c.attr === "Color",
|
||||
);
|
||||
if (colorFact?.value === "white") {
|
||||
expect(session.get(b.id as EntityId, "CaptureFlags")).toBe(
|
||||
CaptureFlag.CAN_CAPTURE_OWN,
|
||||
);
|
||||
} else {
|
||||
expect(session.contains(b.id as EntityId, "CaptureFlags")).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("null → newProfile: equivalent to a fresh applyProfileToSession", () => {
|
||||
const P = profile({
|
||||
perType: [
|
||||
{ kind: "hp-bonus", pieceType: "pawn", color: "both", value: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
// Reference run: plain apply on a separate session.
|
||||
const ref = new Session({ autoFire: false });
|
||||
applyLayout(ref, CLASSIC_LAYOUT);
|
||||
applyProfileToSession(ref, P, CLASSIC_LAYOUT);
|
||||
const refFacts = ref
|
||||
.allFacts()
|
||||
.filter((f) => f.attr === "HpBonus")
|
||||
.map((f) => `${f.id as number}:${String(f.value)}`)
|
||||
.sort();
|
||||
|
||||
// Our run: null → P.
|
||||
reconcileProfileSwap(session, null, P, CLASSIC_LAYOUT);
|
||||
const ourFacts = session
|
||||
.allFacts()
|
||||
.filter((f) => f.attr === "HpBonus")
|
||||
.map((f) => `${f.id as number}:${String(f.value)}`)
|
||||
.sort();
|
||||
|
||||
expect(ourFacts).toEqual(refFacts);
|
||||
});
|
||||
|
||||
it("oldProfile → null: every modifier fact retracted, no new facts seeded", () => {
|
||||
const P = profile({
|
||||
perType: [
|
||||
{ kind: "hp-bonus", pieceType: "pawn", color: "both", value: 1 },
|
||||
{ kind: "range-bonus", pieceType: "rook", color: "both", value: 2 },
|
||||
],
|
||||
perInstance: [
|
||||
{ kind: "capture-flags", square: "e1", value: CaptureFlag.CANNOT_BE_CAPTURED },
|
||||
],
|
||||
});
|
||||
reconcileProfileSwap(session, null, P, CLASSIC_LAYOUT);
|
||||
|
||||
// Pre-check: modifier facts exist.
|
||||
const preFacts = session.allFacts();
|
||||
expect(preFacts.some((f) => f.attr === "HpBonus")).toBe(true);
|
||||
expect(preFacts.some((f) => f.attr === "RangeBonus")).toBe(true);
|
||||
expect(preFacts.some((f) => f.attr === "CaptureFlags")).toBe(true);
|
||||
|
||||
// Swap to null.
|
||||
reconcileProfileSwap(session, P, null, CLASSIC_LAYOUT);
|
||||
|
||||
const postFacts = session.allFacts();
|
||||
for (const attr of [
|
||||
"HpBonus",
|
||||
"RangeBonus",
|
||||
"DirectionAdditions",
|
||||
"CaptureFlags",
|
||||
"PromotionOverride",
|
||||
"DamageResistance",
|
||||
] as const) {
|
||||
expect(postFacts.some((f) => f.attr === attr)).toBe(false);
|
||||
}
|
||||
|
||||
// Core piece facts still intact (we didn't touch PieceType / Color / Position / HasMoved).
|
||||
expect(postFacts.some((f) => f.attr === "PieceType")).toBe(true);
|
||||
expect(postFacts.some((f) => f.attr === "Position")).toBe(true);
|
||||
});
|
||||
|
||||
it("Hp without piece-hp active: no-op on Hp (only HpBonus is touched)", () => {
|
||||
// piece-hp isn't active so no Hp fact exists on any piece. Swap
|
||||
// still works without throwing, and leaves the non-existent Hp
|
||||
// untouched (no spurious inserts).
|
||||
const P1 = profile({
|
||||
perType: [
|
||||
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 3 },
|
||||
],
|
||||
});
|
||||
const P2 = profile({
|
||||
perType: [
|
||||
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
reconcileProfileSwap(session, null, P1, CLASSIC_LAYOUT);
|
||||
const knight = findB1Knight(session);
|
||||
expect(session.contains(knight, "Hp")).toBe(false);
|
||||
|
||||
reconcileProfileSwap(session, P1, P2, CLASSIC_LAYOUT);
|
||||
// Hp still absent, HpBonus updated.
|
||||
expect(session.contains(knight, "Hp")).toBe(false);
|
||||
expect(session.get(knight, "HpBonus")).toBe(1);
|
||||
});
|
||||
});
|
||||
247
packages/chess/src/modifiers/reconcile.ts
Normal file
247
packages/chess/src/modifiers/reconcile.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
/**
|
||||
* Reconcile a ModifierProfile hot-swap at a turn boundary.
|
||||
*
|
||||
* The problem: `applyProfileToSession` assumes a clean slate — running
|
||||
* it twice on the same session would double-add values for `additive`
|
||||
* kinds (HpBonus, RangeBonus) and silently clobber others. To support
|
||||
* mid-game profile changes (ADR-3 "hot swap at turn boundary"), we
|
||||
* need to:
|
||||
*
|
||||
* 1. Retract the old profile's modifier facts so stale values don't
|
||||
* leak into the new effective state.
|
||||
* 2. Apply the new profile via the normal `applyProfileToSession`
|
||||
* path (which handles stacking from scratch).
|
||||
* 3. Special-case `Hp` (current HP): when `HpBonus` shrinks, a
|
||||
* piece's effective max HP shrinks too, and any current HP above
|
||||
* the new max gets clamped. HP NEVER grows on swap — a piece that
|
||||
* lost 1 HP in combat shouldn't magically heal because the new
|
||||
* profile bumped max.
|
||||
*
|
||||
* ## Reconciliation rules per kind (ADR-3)
|
||||
*
|
||||
* | Kind | Rule |
|
||||
* |----------------------|------------------------------------------|
|
||||
* | HpBonus | Recompute max; clamp current Hp down |
|
||||
* | RangeBonus | Overwrite (or retract if removed) |
|
||||
* | DirectionAdditions | Overwrite (or retract if removed) |
|
||||
* | CaptureFlags | Overwrite (or retract if removed) |
|
||||
* | PromotionOverride | Overwrite (or retract if removed) |
|
||||
* | DamageResistance | Overwrite (or retract if removed) |
|
||||
*
|
||||
* "Overwrite" falls out naturally from the retract-then-apply flow:
|
||||
* after retraction the fact is gone; the new apply pass re-inserts
|
||||
* only if the new profile contributed a value.
|
||||
*
|
||||
* ## Idempotency
|
||||
*
|
||||
* `reconcileProfileSwap(s, P, P)` produces the same session state on
|
||||
* the second call as the first (modulo HP — see below). We retract
|
||||
* every modifier fact the profile system owns, then re-seed from
|
||||
* scratch. Hp clamping is idempotent too: `min(current, max)` called
|
||||
* twice returns the same value.
|
||||
*
|
||||
* The one subtle case: `Hp` is only managed by the `piece-hp` preset.
|
||||
* If `piece-hp` is not active, there is no `Hp` fact and the clamp
|
||||
* step is a no-op. The baseline max HP comes from `piece-hp` itself
|
||||
* (its `DEFAULT_HP = 2` constant), imported here as a single source
|
||||
* of truth.
|
||||
*/
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
import type { ChessAttrKey } from "../schema.js";
|
||||
import type { StartingLayout } from "../layouts/types.js";
|
||||
import type { ModifierProfile } from "./types.js";
|
||||
import { MODIFIER_REGISTRY } from "./registry.js";
|
||||
import { applyProfilesToSession } from "./apply.js";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
import type { CustomModifierRegistry } from "./custom/registry.js";
|
||||
|
||||
/**
|
||||
* The attribute name every modifier descriptor writes to. Mirrors the
|
||||
* six registered descriptors' `attrName` values; collected once at
|
||||
* module load so we don't walk the registry per call.
|
||||
*
|
||||
* We deliberately consume MODIFIER_REGISTRY here rather than
|
||||
* hard-coding the list. If a future descriptor is added (or renamed),
|
||||
* reconciliation picks it up automatically as long as the barrel
|
||||
* import fires first. The runtime `Array.from` captures a snapshot at
|
||||
* call time — the registry is append-only after module load, so this
|
||||
* is safe.
|
||||
*/
|
||||
function modifierAttrNames(): readonly ChessAttrKey[] {
|
||||
return MODIFIER_REGISTRY.list().map((d) => d.attrName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default baseline HP, matching `piece-hp`'s `DEFAULT_HP` constant.
|
||||
* Duplicated (rather than imported) because importing the preset file
|
||||
* here would trigger its `PRESET_REGISTRY.register` side-effect from
|
||||
* this otherwise preset-agnostic module. When `piece-hp` changes its
|
||||
* default, update this literal too — documented in the preset file.
|
||||
*
|
||||
* A future refactor could expose this via a public API on the preset
|
||||
* module; that's a wider change than T15's scope.
|
||||
*/
|
||||
const BASELINE_HP = 2;
|
||||
|
||||
/**
|
||||
* Find every piece entity (id > 0 with a PieceType fact). Separate
|
||||
* helper because both the retraction pass and the HP-clamp pass need
|
||||
* it, and walking `session.allFacts()` twice differently-filtered is
|
||||
* cheaper than projecting it twice.
|
||||
*/
|
||||
function pieceIds(session: Session): EntityId[] {
|
||||
const ids = new Set<EntityId>();
|
||||
for (const f of session.allFacts()) {
|
||||
if (f.attr === "PieceType" && (f.id as number) > 0) ids.add(f.id);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retract every modifier-owned attribute from every piece. Called
|
||||
* before re-applying the new profile so stacking starts clean.
|
||||
*
|
||||
* Does NOT touch `Hp` — that's a preset-owned attribute (piece-hp),
|
||||
* not a modifier-owned one. `Hp` clamping is handled separately in
|
||||
* `reconcileProfileSwap`.
|
||||
*/
|
||||
function retractAllModifierFacts(session: Session): void {
|
||||
const attrs = modifierAttrNames();
|
||||
for (const id of pieceIds(session)) {
|
||||
for (const attr of attrs) {
|
||||
if (session.contains(id, attr)) {
|
||||
session.retract(id, attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot `(pieceId → old HpBonus)` BEFORE we retract. Needed because
|
||||
* after retraction we've lost the old bonus, and after re-apply we
|
||||
* have only the new one — we never have both at the same time
|
||||
* otherwise. The diff is what drives the Hp clamp.
|
||||
*
|
||||
* Pieces without an HpBonus fact default to 0 — no bonus → max HP is
|
||||
* just the baseline. Same convention as `piece-hp` when no HpBonus is
|
||||
* present.
|
||||
*/
|
||||
function snapshotHpBonuses(session: Session): Map<EntityId, number> {
|
||||
const snapshot = new Map<EntityId, number>();
|
||||
for (const id of pieceIds(session)) {
|
||||
const b = session.get(id, "HpBonus");
|
||||
snapshot.set(id, typeof b === "number" ? b : 0);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* For each piece that has a live `Hp` fact, clamp it to the new
|
||||
* effective max (baseline + newHpBonus). Never grows Hp — a piece
|
||||
* below the new max stays where it is.
|
||||
*
|
||||
* The invariant enforced: Hp ≤ BASELINE_HP + HpBonus (post-swap).
|
||||
*
|
||||
* Pieces without `Hp` are untouched: `piece-hp` isn't active, HP
|
||||
* isn't being tracked, clamping is meaningless.
|
||||
*/
|
||||
function clampHpToNewMax(
|
||||
session: Session,
|
||||
oldBonuses: Map<EntityId, number>,
|
||||
): void {
|
||||
for (const id of pieceIds(session)) {
|
||||
if (!session.contains(id, "Hp")) continue;
|
||||
const current = session.get(id, "Hp") as number;
|
||||
const newBonus =
|
||||
typeof session.get(id, "HpBonus") === "number"
|
||||
? (session.get(id, "HpBonus") as number)
|
||||
: 0;
|
||||
const newMax = BASELINE_HP + newBonus;
|
||||
// Only act when the new max is below current HP. If HP is already
|
||||
// at or below max (including the equal case), leave it alone —
|
||||
// rewriting the same value is a wasted write.
|
||||
if (current > newMax) {
|
||||
session.insert(id, "Hp", Math.max(0, newMax));
|
||||
}
|
||||
// Unused-variable shimmy: oldBonuses is here to document intent
|
||||
// and allow a future "only clamp when bonus SHRANK" optimization.
|
||||
// Right now we always compare against newMax, which is equivalent
|
||||
// for the correctness argument (if oldBonus == newBonus then
|
||||
// newMax hasn't changed, and current <= oldMax == newMax so we
|
||||
// skip naturally).
|
||||
void oldBonuses;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap one profile for another on a live session.
|
||||
*
|
||||
* Callers (ChessEngine / server) should invoke this at a turn
|
||||
* boundary so the effective rule set is constant within any one
|
||||
* move's legal-move generation. Mid-move swaps are not supported —
|
||||
* the engine's move validation assumes stable modifiers between
|
||||
* `getAllLegalMoves` and `applyMove`.
|
||||
*
|
||||
* `oldProfile === null` is the initial-apply case (equivalent to
|
||||
* calling `applyProfileToSession` directly; provided here so callers
|
||||
* can funnel everything through a single API).
|
||||
*
|
||||
* `newProfile === null` strips every modifier fact and leaves the
|
||||
* session with base-rules pieces only.
|
||||
*
|
||||
* `oldProfile === newProfile` (or structurally equal profiles) is a
|
||||
* valid idempotent call; session state converges to the fully-applied
|
||||
* shape.
|
||||
*/
|
||||
export function reconcileProfileSwap(
|
||||
session: Session,
|
||||
oldProfile: ModifierProfile | null,
|
||||
newProfile: ModifierProfile | null,
|
||||
layout: StartingLayout,
|
||||
engine?: ChessEngine,
|
||||
customRegistry?: CustomModifierRegistry,
|
||||
): void {
|
||||
reconcileProfilesSwap(
|
||||
session,
|
||||
oldProfile === null ? [] : [oldProfile],
|
||||
newProfile === null ? [] : [newProfile],
|
||||
layout,
|
||||
engine,
|
||||
customRegistry,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-profile variant of `reconcileProfileSwap` (T23). Treats both
|
||||
* old and new as ordered profile stacks; the retract-then-apply
|
||||
* algorithm composes naturally because the retract step wipes ALL
|
||||
* modifier-owned facts regardless of which profile contributed them.
|
||||
*
|
||||
* - `oldProfiles` empty: equivalent to first-time apply.
|
||||
* - `newProfiles` empty: equivalent to retract-only (modifier-free
|
||||
* session afterwards).
|
||||
* - `oldProfiles === newProfiles`: idempotent.
|
||||
*/
|
||||
export function reconcileProfilesSwap(
|
||||
session: Session,
|
||||
_oldProfiles: readonly ModifierProfile[],
|
||||
newProfiles: readonly ModifierProfile[],
|
||||
layout: StartingLayout,
|
||||
engine?: ChessEngine,
|
||||
customRegistry?: CustomModifierRegistry,
|
||||
): void {
|
||||
// Step 1: snapshot HpBonus BEFORE we mutate anything (see
|
||||
// single-profile variant for rationale).
|
||||
const oldHpBonuses = snapshotHpBonuses(session);
|
||||
|
||||
// Step 2: retract every modifier-owned attribute from every piece.
|
||||
retractAllModifierFacts(session);
|
||||
|
||||
// Step 3: reapply the new profile stack from scratch.
|
||||
if (newProfiles.length > 0) {
|
||||
applyProfilesToSession(session, newProfiles, layout, engine, customRegistry);
|
||||
}
|
||||
|
||||
// Step 4: clamp current Hp against the new effective max.
|
||||
clampHpToNewMax(session, oldHpBonuses);
|
||||
}
|
||||
87
packages/chess/src/modifiers/registry.test.ts
Normal file
87
packages/chess/src/modifiers/registry.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* Unit tests for MODIFIER_REGISTRY.
|
||||
*
|
||||
* Deliberately does NOT import `./index.js` — that would pull in every
|
||||
* T1 descriptor via side-effects and pre-populate the registry, which
|
||||
* would break the "starts empty" assertion. Once descriptors land
|
||||
* (T6-T11), those tests live in their own files.
|
||||
*
|
||||
* IMPORTANT: these tests mutate the shared `MODIFIER_REGISTRY`
|
||||
* singleton (there's no reset/clear API by design — the real registry
|
||||
* is populated once at module load time). To stay isolated we use
|
||||
* fresh, unique ids inside the test and only assert properties of
|
||||
* descriptors we own.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import type { Session, EntityId } from "@paratype/rete";
|
||||
import { MODIFIER_REGISTRY } from "./registry.js";
|
||||
import type { ModifierDescriptor, ModifierKindId } from "./types.js";
|
||||
|
||||
/**
|
||||
* Build a minimal mock descriptor. We cast the id through string
|
||||
* because the test descriptors use synthetic ids ("test-*") to avoid
|
||||
* colliding with real ones that T6-T11 will register.
|
||||
*/
|
||||
function mockDescriptor(id: string): ModifierDescriptor {
|
||||
return {
|
||||
id: id as ModifierKindId,
|
||||
attrName: "Hp",
|
||||
label: `Mock ${id}`,
|
||||
valueSchema: z.unknown(),
|
||||
stackingRule: "additive",
|
||||
apply: (_session: Session, _pieceId: EntityId, _value: unknown) => {
|
||||
// no-op
|
||||
},
|
||||
describe: (value) => `mock=${String(value)}`,
|
||||
uiForm: "number",
|
||||
};
|
||||
}
|
||||
|
||||
describe("MODIFIER_REGISTRY", () => {
|
||||
it("starts empty (no T1 descriptors registered yet)", () => {
|
||||
// At the time this test file is loaded, no descriptor side-effects
|
||||
// have run (we don't import ./index.js), so the registry should be
|
||||
// empty. If this ever fires, someone registered a descriptor at
|
||||
// module scope without a corresponding import guard — investigate
|
||||
// before relaxing.
|
||||
expect(MODIFIER_REGISTRY.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it("register(descriptor) makes it retrievable via get(id)", () => {
|
||||
const desc = mockDescriptor("test-get");
|
||||
MODIFIER_REGISTRY.register(desc);
|
||||
expect(MODIFIER_REGISTRY.get("test-get" as ModifierKindId)).toBe(desc);
|
||||
});
|
||||
|
||||
it("has(id) returns true after registration, false before", () => {
|
||||
const id = "test-has" as ModifierKindId;
|
||||
expect(MODIFIER_REGISTRY.has(id)).toBe(false);
|
||||
MODIFIER_REGISTRY.register(mockDescriptor("test-has"));
|
||||
expect(MODIFIER_REGISTRY.has(id)).toBe(true);
|
||||
});
|
||||
|
||||
it("list() returns every registered descriptor", () => {
|
||||
const a = mockDescriptor("test-list-a");
|
||||
const b = mockDescriptor("test-list-b");
|
||||
MODIFIER_REGISTRY.register(a);
|
||||
MODIFIER_REGISTRY.register(b);
|
||||
const all = MODIFIER_REGISTRY.list();
|
||||
expect(all).toContain(a);
|
||||
expect(all).toContain(b);
|
||||
});
|
||||
|
||||
it("register() with duplicate id throws an error mentioning the id", () => {
|
||||
const id = "test-dup";
|
||||
MODIFIER_REGISTRY.register(mockDescriptor(id));
|
||||
expect(() => MODIFIER_REGISTRY.register(mockDescriptor(id))).toThrow(
|
||||
/test-dup/,
|
||||
);
|
||||
});
|
||||
|
||||
it("get() returns undefined for unknown ids", () => {
|
||||
expect(
|
||||
MODIFIER_REGISTRY.get("test-never-registered" as ModifierKindId),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
76
packages/chess/src/modifiers/registry.ts
Normal file
76
packages/chess/src/modifiers/registry.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Modifier descriptor registry.
|
||||
*
|
||||
* Mirrors `LAYOUT_REGISTRY`: the six T1 modifier categories register
|
||||
* themselves via side-effect imports from their own module files, and
|
||||
* `./index.ts` is the barrel that imports every descriptor so that
|
||||
* consumers get them all registered by importing anywhere from
|
||||
* `./modifiers`.
|
||||
*
|
||||
* Duplicate-id registration throws — this is the signal that two
|
||||
* modules are trying to claim the same `ModifierKindId`, which would
|
||||
* silently overwrite in a Map. Throwing surfaces the collision at load
|
||||
* time rather than at runtime when a modifier would mysteriously
|
||||
* "disappear" because a later import clobbered the earlier descriptor.
|
||||
*
|
||||
* Descriptors are read-only once registered; the registry exposes no
|
||||
* mutation paths. The set of modifier kinds is fixed at module-load
|
||||
* time (ADR-8) — there's no runtime registration of new modifier
|
||||
* categories from userland.
|
||||
*/
|
||||
import type { ModifierDescriptor, ModifierKindId } from "./types.js";
|
||||
|
||||
class ModifierRegistryClass {
|
||||
readonly #byId = new Map<ModifierKindId, ModifierDescriptor>();
|
||||
|
||||
/**
|
||||
* Register a descriptor under its `id`. Throws if the id is already
|
||||
* taken — defensive check because silently overwriting would create
|
||||
* confusing debugging ("which of my two hp-bonus descriptors won?"
|
||||
* races on module import order).
|
||||
*
|
||||
* Generic over V so descriptors with concrete value types
|
||||
* (e.g. `ModifierDescriptor<number>`) can be passed without a
|
||||
* type assertion at the call site. The registry stores them as
|
||||
* `ModifierDescriptor<unknown>` — a heterogeneous container that
|
||||
* loses V intentionally; callers that need the typed value should
|
||||
* import the descriptor directly from its own module.
|
||||
*/
|
||||
register<V>(descriptor: ModifierDescriptor<V>): void {
|
||||
if (this.#byId.has(descriptor.id)) {
|
||||
throw new Error(
|
||||
`ModifierRegistry: duplicate modifier id "${descriptor.id}". ` +
|
||||
`Each modifier descriptor must have a unique id.`,
|
||||
);
|
||||
}
|
||||
// `ModifierDescriptor<V>` is not structurally assignable to
|
||||
// `ModifierDescriptor<unknown>` due function-parameter variance.
|
||||
// Erase V once at registry storage boundary.
|
||||
this.#byId.set(descriptor.id, descriptor as ModifierDescriptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a descriptor by id. Returns undefined for unknown ids —
|
||||
* the caller decides whether that's an error (validator rejecting a
|
||||
* profile referencing an unknown modifier kind) or a benign miss.
|
||||
*/
|
||||
get(id: ModifierKindId): ModifierDescriptor | undefined {
|
||||
return this.#byId.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* All registered descriptors, in registration (= import) order. UI
|
||||
* code iterating descriptors to render form rows relies on this
|
||||
* being a stable order.
|
||||
*/
|
||||
list(): readonly ModifierDescriptor[] {
|
||||
return [...this.#byId.values()];
|
||||
}
|
||||
|
||||
/** True if a descriptor with the given id is registered. */
|
||||
has(id: ModifierKindId): boolean {
|
||||
return this.#byId.has(id);
|
||||
}
|
||||
}
|
||||
|
||||
export const MODIFIER_REGISTRY = new ModifierRegistryClass();
|
||||
300
packages/chess/src/modifiers/schema.test.ts
Normal file
300
packages/chess/src/modifiers/schema.test.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
TypeModifierSchema,
|
||||
InstanceModifierSchema,
|
||||
ModifierProfileSchema,
|
||||
parseModifierProfile,
|
||||
serializeModifierProfile,
|
||||
} from "./schema.js";
|
||||
import type { ModifierProfile } from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VALID_PROFILE: ModifierProfile = {
|
||||
id: "profile-001",
|
||||
name: "Knight Buff",
|
||||
description: "Gives all white knights +2 HP",
|
||||
layoutId: "classic",
|
||||
perType: [
|
||||
{ kind: "hp-bonus", pieceType: "knight", color: "white", value: 2 },
|
||||
{ kind: "range-bonus", pieceType: "rook", color: "both", value: 1 },
|
||||
],
|
||||
perInstance: [
|
||||
{ kind: "hp-bonus", square: "b1", value: 5 },
|
||||
],
|
||||
version: 1,
|
||||
source: "premade",
|
||||
};
|
||||
|
||||
const VALID_PROFILE_NO_LAYOUT: ModifierProfile = {
|
||||
id: "profile-002",
|
||||
name: "Custom Pawns",
|
||||
description: "Custom pawn modifiers",
|
||||
perType: [],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: "custom",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TypeModifierSchema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("TypeModifierSchema", () => {
|
||||
it("parses a valid hp-bonus TypeModifier", () => {
|
||||
const result = TypeModifierSchema.parse({
|
||||
kind: "hp-bonus",
|
||||
pieceType: "knight",
|
||||
color: "white",
|
||||
value: 3,
|
||||
});
|
||||
expect(result.kind).toBe("hp-bonus");
|
||||
expect(result.value).toBe(3);
|
||||
});
|
||||
|
||||
it("parses direction-additions with 'both' color", () => {
|
||||
const result = TypeModifierSchema.parse({
|
||||
kind: "direction-additions",
|
||||
pieceType: "pawn",
|
||||
color: "both",
|
||||
value: ["forward", "backward"],
|
||||
});
|
||||
expect(result.color).toBe("both");
|
||||
});
|
||||
|
||||
it("parses capture-flags TypeModifier", () => {
|
||||
const result = TypeModifierSchema.parse({
|
||||
kind: "capture-flags",
|
||||
pieceType: "queen",
|
||||
color: "black",
|
||||
value: 3,
|
||||
});
|
||||
expect(result.kind).toBe("capture-flags");
|
||||
});
|
||||
|
||||
it("accepts arbitrary kind strings (T3 widening for custom modifier ids)", () => {
|
||||
// Pre-T3 this schema used z.enum() to reject unknown kinds. The
|
||||
// widening was forced by user-authored CustomModifierIds — they're
|
||||
// arbitrary strings (e.g. "custom:my-shield"), and a profile that
|
||||
// references one would be silently dropped on library load if the
|
||||
// enum check rejected it. Validity is now enforced at apply time:
|
||||
// MODIFIER_REGISTRY.get(kind) || engine.customModifiers.get(kind),
|
||||
// unknown kinds are warned and skipped.
|
||||
const result = TypeModifierSchema.parse({
|
||||
kind: "custom:user-authored",
|
||||
pieceType: "knight",
|
||||
color: "white",
|
||||
value: null,
|
||||
});
|
||||
expect(result.kind).toBe("custom:user-authored");
|
||||
});
|
||||
|
||||
it("rejects empty kind string", () => {
|
||||
expect(() =>
|
||||
TypeModifierSchema.parse({
|
||||
kind: "",
|
||||
pieceType: "knight",
|
||||
color: "white",
|
||||
value: 1,
|
||||
})
|
||||
).toThrow(z.ZodError);
|
||||
});
|
||||
|
||||
it("rejects unknown piece type", () => {
|
||||
expect(() =>
|
||||
TypeModifierSchema.parse({
|
||||
kind: "hp-bonus",
|
||||
pieceType: "dragon",
|
||||
color: "white",
|
||||
value: 1,
|
||||
})
|
||||
).toThrow(z.ZodError);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InstanceModifierSchema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("InstanceModifierSchema", () => {
|
||||
it("parses a valid instance modifier with square 'b1'", () => {
|
||||
const result = InstanceModifierSchema.parse({
|
||||
kind: "hp-bonus",
|
||||
square: "b1",
|
||||
value: 5,
|
||||
});
|
||||
expect(result.square).toBe("b1");
|
||||
});
|
||||
|
||||
it("parses a valid instance modifier at 'h8'", () => {
|
||||
const result = InstanceModifierSchema.parse({
|
||||
kind: "range-bonus",
|
||||
square: "h8",
|
||||
value: 2,
|
||||
});
|
||||
expect(result.square).toBe("h8");
|
||||
});
|
||||
|
||||
it("rejects invalid square 'z9'", () => {
|
||||
expect(() =>
|
||||
InstanceModifierSchema.parse({
|
||||
kind: "hp-bonus",
|
||||
square: "z9",
|
||||
value: 1,
|
||||
})
|
||||
).toThrow(z.ZodError);
|
||||
});
|
||||
|
||||
it("rejects invalid square 'a9' (rank out of range)", () => {
|
||||
expect(() =>
|
||||
InstanceModifierSchema.parse({
|
||||
kind: "hp-bonus",
|
||||
square: "a9",
|
||||
value: 1,
|
||||
})
|
||||
).toThrow(z.ZodError);
|
||||
});
|
||||
|
||||
it("rejects invalid square 'i1' (file out of range)", () => {
|
||||
expect(() =>
|
||||
InstanceModifierSchema.parse({
|
||||
kind: "hp-bonus",
|
||||
square: "i1",
|
||||
value: 1,
|
||||
})
|
||||
).toThrow(z.ZodError);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ModifierProfileSchema — valid cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ModifierProfileSchema — valid", () => {
|
||||
it("parses a full valid profile", () => {
|
||||
const result = ModifierProfileSchema.parse(VALID_PROFILE);
|
||||
expect(result.id).toBe("profile-001");
|
||||
expect(result.version).toBe(1);
|
||||
expect(result.source).toBe("premade");
|
||||
expect(result.perType).toHaveLength(2);
|
||||
expect(result.perInstance).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("parses a profile without layoutId (optional field)", () => {
|
||||
const result = ModifierProfileSchema.parse(VALID_PROFILE_NO_LAYOUT);
|
||||
expect(result.layoutId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses a profile with empty perType and perInstance arrays", () => {
|
||||
const result = ModifierProfileSchema.parse({
|
||||
id: "empty-profile",
|
||||
name: "Empty",
|
||||
description: "",
|
||||
perType: [],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: "custom",
|
||||
});
|
||||
expect(result.perType).toHaveLength(0);
|
||||
expect(result.perInstance).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ModifierProfileSchema — invalid cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ModifierProfileSchema — invalid", () => {
|
||||
it("rejects profile with version=2", () => {
|
||||
expect(() =>
|
||||
ModifierProfileSchema.parse({ ...VALID_PROFILE, version: 2 })
|
||||
).toThrow(z.ZodError);
|
||||
});
|
||||
|
||||
it("rejects profile with missing 'name' field", () => {
|
||||
const { name: _name, ...rest } = VALID_PROFILE;
|
||||
expect(() => ModifierProfileSchema.parse(rest)).toThrow(z.ZodError);
|
||||
});
|
||||
|
||||
it("rejects profile with source='admin'", () => {
|
||||
expect(() =>
|
||||
ModifierProfileSchema.parse({ ...VALID_PROFILE, source: "admin" })
|
||||
).toThrow(z.ZodError);
|
||||
});
|
||||
|
||||
it("rejects profile with empty id string", () => {
|
||||
expect(() =>
|
||||
ModifierProfileSchema.parse({ ...VALID_PROFILE, id: "" })
|
||||
).toThrow(z.ZodError);
|
||||
});
|
||||
|
||||
it("rejects profile with invalid perInstance square", () => {
|
||||
expect(() =>
|
||||
ModifierProfileSchema.parse({
|
||||
...VALID_PROFILE,
|
||||
perInstance: [{ kind: "hp-bonus", square: "z9", value: 1 }],
|
||||
})
|
||||
).toThrow(z.ZodError);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseModifierProfile / serializeModifierProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseModifierProfile", () => {
|
||||
it("returns a typed ModifierProfile for valid input", () => {
|
||||
const result = parseModifierProfile(VALID_PROFILE);
|
||||
expect(result.id).toBe("profile-001");
|
||||
});
|
||||
|
||||
it("throws ZodError for invalid input", () => {
|
||||
expect(() => parseModifierProfile({ invalid: true })).toThrow(z.ZodError);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Roundtrip tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("roundtrip", () => {
|
||||
it("full profile roundtrips through JSON.parse(JSON.stringify(...))", () => {
|
||||
const serialized = JSON.parse(JSON.stringify(VALID_PROFILE));
|
||||
const result = parseModifierProfile(serialized);
|
||||
expect(result).toEqual(VALID_PROFILE);
|
||||
});
|
||||
|
||||
it("profile without layoutId roundtrips losslessly", () => {
|
||||
const serialized = JSON.parse(JSON.stringify(VALID_PROFILE_NO_LAYOUT));
|
||||
const result = parseModifierProfile(serialized);
|
||||
expect(result).toEqual(VALID_PROFILE_NO_LAYOUT);
|
||||
});
|
||||
|
||||
it("serializeModifierProfile output can be re-parsed", () => {
|
||||
const serialized = serializeModifierProfile(VALID_PROFILE);
|
||||
const reparsed = parseModifierProfile(JSON.parse(JSON.stringify(serialized)));
|
||||
expect(reparsed).toEqual(VALID_PROFILE);
|
||||
});
|
||||
|
||||
it("profile with complex value types roundtrips", () => {
|
||||
const profile: ModifierProfile = {
|
||||
id: "complex-001",
|
||||
name: "Complex Profile",
|
||||
description: "Has various value types",
|
||||
perType: [
|
||||
{ kind: "direction-additions", pieceType: "pawn", color: "white", value: ["forward", "diagonal-fl"] },
|
||||
{ kind: "promotion-override", pieceType: "pawn", color: "black", value: "queen" },
|
||||
],
|
||||
perInstance: [
|
||||
{ kind: "capture-flags", square: "e4", value: 3 },
|
||||
],
|
||||
version: 1,
|
||||
source: "custom",
|
||||
};
|
||||
const result = parseModifierProfile(JSON.parse(JSON.stringify(profile)));
|
||||
expect(result).toEqual(profile);
|
||||
});
|
||||
});
|
||||
114
packages/chess/src/modifiers/schema.ts
Normal file
114
packages/chess/src/modifiers/schema.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Zod schemas for ModifierProfile serialization and validation.
|
||||
*
|
||||
* The `value` field on TypeModifier and InstanceModifier uses `z.any()`
|
||||
* rather than `z.unknown()` — Zod v4 infers `z.unknown()` object fields as
|
||||
* optional (`value?: unknown`) which conflicts with the required `value:
|
||||
* unknown` in the TypeModifier/InstanceModifier interfaces. `z.any()` infers
|
||||
* as a required `any` field while still accepting all values.
|
||||
*
|
||||
* Per-kind value validation happens at descriptor registration time, not at
|
||||
* profile-parse time. This keeps the schema forwards-compatible.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import type { ModifierProfile } from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Built-in modifier ids — preserved as a literal enum for documentation
|
||||
* and editor-side completion. Not used as the parse schema directly
|
||||
* because T3 widens `kind` to also accept user-authored CustomModifierIds
|
||||
* (arbitrary strings); the runtime apply path (T22) dispatches via
|
||||
* MODIFIER_REGISTRY first then engine.customModifiers, silently skipping
|
||||
* unknown kinds.
|
||||
*/
|
||||
export const BUILTIN_MODIFIER_KIND_IDS = [
|
||||
"hp-bonus",
|
||||
"range-bonus",
|
||||
"direction-additions",
|
||||
"capture-flags",
|
||||
"promotion-override",
|
||||
"damage-resistance",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Parse-time schema for `kind`: any non-empty string. T3 custom modifier
|
||||
* ids are arbitrary strings (e.g. "custom:my-shield"), so an enum check
|
||||
* here would silently reject every profile that uses a user-authored
|
||||
* descriptor and cause the library to drop the entry on load.
|
||||
*
|
||||
* Validity of the kind is enforced ELSEWHERE: the apply pipeline looks
|
||||
* the id up in MODIFIER_REGISTRY (built-ins) then engine.customModifiers
|
||||
* (user-authored). Unknown kinds are warned and skipped.
|
||||
*/
|
||||
const ModifierKindIdSchema = z.string().min(1);
|
||||
|
||||
const PieceTypeSchema = z.enum([
|
||||
"pawn",
|
||||
"knight",
|
||||
"bishop",
|
||||
"rook",
|
||||
"queen",
|
||||
"king",
|
||||
]);
|
||||
|
||||
// PieceColor extended with "both" for TypeModifier.color
|
||||
const PieceColorExtSchema = z.enum(["white", "black", "both"]);
|
||||
|
||||
/** Algebraic notation square: "a1".."h8" */
|
||||
const SquareStringSchema = z
|
||||
.string()
|
||||
.regex(/^[a-h][1-8]$/, "Must be algebraic notation (a1-h8)");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TypeModifier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TypeModifierSchema = z.object({
|
||||
kind: ModifierKindIdSchema,
|
||||
pieceType: PieceTypeSchema,
|
||||
color: PieceColorExtSchema,
|
||||
value: z.any() as z.ZodType<unknown>,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// InstanceModifier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const InstanceModifierSchema = z.object({
|
||||
kind: ModifierKindIdSchema,
|
||||
square: SquareStringSchema,
|
||||
value: z.any() as z.ZodType<unknown>,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ModifierProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ModifierProfileSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
layoutId: z.string().optional(),
|
||||
perType: z.array(TypeModifierSchema),
|
||||
perInstance: z.array(InstanceModifierSchema),
|
||||
version: z.literal(1),
|
||||
source: z.enum(["premade", "custom"]),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Parse an unknown value as a ModifierProfile. Throws ZodError on failure. */
|
||||
export function parseModifierProfile(raw: unknown): ModifierProfile {
|
||||
return ModifierProfileSchema.parse(raw) as ModifierProfile;
|
||||
}
|
||||
|
||||
/** Serialize a ModifierProfile to a JSON-safe plain object. */
|
||||
export function serializeModifierProfile(profile: ModifierProfile): unknown {
|
||||
return ModifierProfileSchema.parse(profile);
|
||||
}
|
||||
71
packages/chess/src/modifiers/source.test.ts
Normal file
71
packages/chess/src/modifiers/source.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { expect, test } from "vitest";
|
||||
import { ChessEngine } from "../engine.js";
|
||||
import { getModifierSource } from "./source.js";
|
||||
import type { ModifierProfile } from "./types.js";
|
||||
import { PRESET_REGISTRY } from "../presets/registry.js";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
|
||||
const TEST_PROFILE: ModifierProfile = {
|
||||
id: "test-profile",
|
||||
name: "Test Profile",
|
||||
description: "",
|
||||
version: 1,
|
||||
source: "custom",
|
||||
perInstance: [
|
||||
{ kind: "hp-bonus", square: "b1", value: 2 },
|
||||
],
|
||||
perType: [
|
||||
{ kind: "range-bonus", pieceType: "knight", color: "white", value: 1 },
|
||||
],
|
||||
};
|
||||
|
||||
test("returns per-instance for a square match", () => {
|
||||
const engine = new ChessEngine({ profile: TEST_PROFILE });
|
||||
// Find the knight on b1
|
||||
const b1Id = engine.session.allFacts().find(
|
||||
(f) => f.attr === "Position" && f.value === 1 // b1 is index 1
|
||||
)?.id as number;
|
||||
|
||||
const source = getModifierSource(engine, b1Id as unknown as EntityId, "hp-bonus");
|
||||
expect(source).toEqual({ kind: "per-instance", square: "b1" });
|
||||
});
|
||||
|
||||
test("returns per-type for a type/color match when no instance match", () => {
|
||||
const engine = new ChessEngine({ profile: TEST_PROFILE });
|
||||
// Find a white knight that ISN'T on b1 (e.g. g1)
|
||||
const g1Id = engine.session.allFacts().find(
|
||||
(f) => f.attr === "Position" && f.value === 6 // g1 is index 6
|
||||
)?.id as number;
|
||||
|
||||
const source = getModifierSource(engine, g1Id as unknown as EntityId, "range-bonus");
|
||||
expect(source).toEqual({ kind: "per-type", pieceType: "knight", color: "white" });
|
||||
});
|
||||
|
||||
test("returns preset if no profile overrides but a preset declares the attribute", () => {
|
||||
// Use a preset that declares Hp. Let's make sure 'piece-hp' is active.
|
||||
const engine = new ChessEngine();
|
||||
engine.activePresets.replaceAll([{ id: "piece-hp", scope: "both", turnsRemaining: null }]);
|
||||
|
||||
// Find any piece, e.g., a1 rook
|
||||
const a1Id = engine.session.allFacts().find(
|
||||
(f) => f.attr === "Position" && f.value === 0
|
||||
)?.id as EntityId;
|
||||
|
||||
const source = getModifierSource(engine, a1Id, "hp-bonus");
|
||||
const hpDef = PRESET_REGISTRY.get("piece-hp")!;
|
||||
|
||||
expect(source).toEqual({ kind: "preset", presetId: "piece-hp", presetName: hpDef.name });
|
||||
});
|
||||
|
||||
test("returns default if nothing overrides or declares it", () => {
|
||||
const engine = new ChessEngine();
|
||||
|
||||
// Find any piece
|
||||
const e2Id = engine.session.allFacts().find(
|
||||
(f) => f.attr === "Position" && f.value === 12
|
||||
)?.id as EntityId;
|
||||
|
||||
// With no profile and no active preset declaring DirectionAdditions, it should be default
|
||||
const source = getModifierSource(engine, e2Id, "direction-additions");
|
||||
expect(source).toEqual({ kind: "default" });
|
||||
});
|
||||
70
packages/chess/src/modifiers/source.ts
Normal file
70
packages/chess/src/modifiers/source.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { EntityId } from "@paratype/rete";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
import type { PieceColor, PieceType } from "../schema.js";
|
||||
import type { ModifierKindId } from "./types.js";
|
||||
import { PRESET_REGISTRY } from "../presets/registry.js";
|
||||
import { MODIFIER_REGISTRY } from "./index.js";
|
||||
import { squareToAlgebraic } from "../coord.js";
|
||||
|
||||
export type ModifierSource =
|
||||
| { kind: "per-instance"; square: string }
|
||||
| { kind: "per-type"; pieceType: PieceType; color: PieceColor | "both" }
|
||||
| { kind: "preset"; presetId: string; presetName: string }
|
||||
| { kind: "default" };
|
||||
|
||||
/**
|
||||
* Determine the origin of a modifier applied to a specific piece.
|
||||
* Checks the active profile (per-instance, then per-type), then active presets,
|
||||
* falling back to "default".
|
||||
*/
|
||||
export function getModifierSource(
|
||||
engine: ChessEngine,
|
||||
pieceId: EntityId,
|
||||
modifierKind: ModifierKindId
|
||||
): ModifierSource {
|
||||
const profile = engine.activeProfile;
|
||||
const squareNum = engine.session.get(pieceId, "Position") as number | undefined;
|
||||
const algebraicSq = squareNum !== undefined ? squareToAlgebraic(squareNum) : null;
|
||||
const pieceType = engine.session.get(pieceId, "PieceType") as PieceType | undefined;
|
||||
const color = engine.session.get(pieceId, "Color") as PieceColor | undefined;
|
||||
|
||||
if (profile) {
|
||||
// 1. Check per-instance modifiers
|
||||
if (algebraicSq) {
|
||||
const instanceMatch = profile.perInstance.find(
|
||||
(m) => m.kind === modifierKind && m.square === algebraicSq,
|
||||
);
|
||||
if (instanceMatch) {
|
||||
return { kind: "per-instance", square: instanceMatch.square };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check per-type modifiers
|
||||
if (pieceType && color) {
|
||||
const typeMatch = profile.perType.find(
|
||||
(m) =>
|
||||
m.kind === modifierKind &&
|
||||
m.pieceType === pieceType &&
|
||||
(m.color === "both" || m.color === color),
|
||||
);
|
||||
if (typeMatch) {
|
||||
return { kind: "per-type", pieceType: typeMatch.pieceType, color: typeMatch.color };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check active presets
|
||||
const descriptor = MODIFIER_REGISTRY.get(modifierKind);
|
||||
if (descriptor) {
|
||||
const presetAttr = descriptor.baseAttr ?? descriptor.attrName;
|
||||
for (const presetEntry of engine.activePresets.list()) {
|
||||
const def = PRESET_REGISTRY.get(presetEntry.id);
|
||||
if (def?.pieceAttributes?.includes(presetAttr)) {
|
||||
return { kind: "preset", presetId: presetEntry.id, presetName: def.name };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Default
|
||||
return { kind: "default" };
|
||||
}
|
||||
264
packages/chess/src/modifiers/triggers.test.ts
Normal file
264
packages/chess/src/modifiers/triggers.test.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
/**
|
||||
* Trigger primitive evaluator tests.
|
||||
*
|
||||
* The integration preset's onBeforeMove / onAfterMove hooks dispatch
|
||||
* to the four trigger families. These tests poke at the dispatchers
|
||||
* directly via a profile that wires each kind of trigger and then
|
||||
* apply moves through the engine, asserting the inner primitives
|
||||
* actually ran.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChessEngine } from "../engine.js";
|
||||
import type { ModifierProfile } from "./types.js";
|
||||
import "./primitives/index.js";
|
||||
|
||||
function makeProfileWithCustomKind(profileId: string): ModifierProfile {
|
||||
return {
|
||||
id: profileId,
|
||||
name: profileId,
|
||||
description: "",
|
||||
perType: [],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: "custom",
|
||||
};
|
||||
}
|
||||
|
||||
function findPiece(engine: ChessEngine, square: number) {
|
||||
for (const f of engine.session.allFacts()) {
|
||||
if (f.attr === "Position" && f.value === square && (f.id as number) > 0) {
|
||||
return f.id;
|
||||
}
|
||||
}
|
||||
throw new Error(`no piece at square ${square}`);
|
||||
}
|
||||
|
||||
describe("on-turn-start triggers", () => {
|
||||
it("runs nested primitives at the start of the matching color's turn", () => {
|
||||
const engine = new ChessEngine({
|
||||
profile: makeProfileWithCustomKind("trigger-test"),
|
||||
});
|
||||
|
||||
// Seed an on-turn-start hook on the white queen that bumps RangeBonus
|
||||
// by 1 each turn.
|
||||
const whiteQueen = findPiece(engine, 3); // d1
|
||||
engine.session.insert(whiteQueen, "OnTurnStartHooks", [
|
||||
[
|
||||
{
|
||||
kind: "add-to-attribute",
|
||||
params: { attr: "RangeBonus", delta: 1 },
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
// Move e2-e4. After this move, the next turn (black) starts. Our
|
||||
// hook is on a WHITE piece; it fires when WHITE's turn begins.
|
||||
// So one half-move (e4) doesn't trigger; we need the next white
|
||||
// move.
|
||||
const movesAfterE4 = engine.getAllLegalMoves();
|
||||
const e2e4 = movesAfterE4.find((m) => m.from === 12 && m.to === 28);
|
||||
expect(e2e4).toBeDefined();
|
||||
engine.applyMove(e2e4!);
|
||||
|
||||
// After black moves, white's turn begins → hook fires.
|
||||
const blackMoves = engine.getAllLegalMoves();
|
||||
const e7e5 = blackMoves.find((m) => m.from === 52 && m.to === 36);
|
||||
expect(e7e5).toBeDefined();
|
||||
engine.applyMove(e7e5!);
|
||||
|
||||
const range = engine.session.get(whiteQueen, "RangeBonus") as
|
||||
| number
|
||||
| undefined;
|
||||
expect(range).toBe(1);
|
||||
});
|
||||
|
||||
it("does not fire for pieces of the opposite color when their turn isn't starting", () => {
|
||||
const engine = new ChessEngine({
|
||||
profile: makeProfileWithCustomKind("trigger-color"),
|
||||
});
|
||||
|
||||
// Hook on the WHITE queen.
|
||||
const whiteQueen = findPiece(engine, 3);
|
||||
engine.session.insert(whiteQueen, "OnTurnStartHooks", [
|
||||
[
|
||||
{
|
||||
kind: "add-to-attribute",
|
||||
params: { attr: "RangeBonus", delta: 1 },
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
// Make ONE white move. Black's turn now begins. The hook is on a
|
||||
// white piece, so it should NOT fire (only white-turn beginnings
|
||||
// trigger it).
|
||||
const movesBeforeAnyMove = engine.getAllLegalMoves();
|
||||
const e2e4 = movesBeforeAnyMove.find((m) => m.from === 12 && m.to === 28);
|
||||
engine.applyMove(e2e4!);
|
||||
|
||||
expect(engine.session.get(whiteQueen, "RangeBonus")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("on-capture triggers", () => {
|
||||
it("fires when this piece captures another", () => {
|
||||
const engine = new ChessEngine({
|
||||
profile: makeProfileWithCustomKind("on-capture-test"),
|
||||
});
|
||||
|
||||
// Set up a capture by moving white pawn to e4 then black pawn to d5
|
||||
// then white pawn captures e4xd5. We need on-capture hook on the
|
||||
// white pawn that ends up doing the capture.
|
||||
const whitePawn = findPiece(engine, 12); // e2
|
||||
engine.session.insert(whitePawn, "OnCaptureHooks", [
|
||||
[{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 5 } }],
|
||||
]);
|
||||
|
||||
// White e2-e4
|
||||
let moves = engine.getAllLegalMoves();
|
||||
let m = moves.find((mv) => mv.from === 12 && mv.to === 28);
|
||||
engine.applyMove(m!);
|
||||
// Black d7-d5
|
||||
moves = engine.getAllLegalMoves();
|
||||
m = moves.find((mv) => mv.from === 51 && mv.to === 35);
|
||||
engine.applyMove(m!);
|
||||
// White e4xd5
|
||||
moves = engine.getAllLegalMoves();
|
||||
m = moves.find((mv) => mv.from === 28 && mv.to === 35 && mv.isCapture);
|
||||
expect(m).toBeDefined();
|
||||
engine.applyMove(m!);
|
||||
|
||||
// Hook should have fired — RangeBonus of 5 written.
|
||||
expect(engine.session.get(whitePawn, "RangeBonus")).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conditional triggers", () => {
|
||||
it("runs the then-branch when the condition matches", () => {
|
||||
const engine = new ChessEngine({
|
||||
profile: makeProfileWithCustomKind("conditional-then"),
|
||||
});
|
||||
|
||||
const whiteQueen = findPiece(engine, 3);
|
||||
// Seed condition: when HpBonus is 0 (default), run the then-branch
|
||||
// which sets RangeBonus to 7. The condition will match on every move.
|
||||
engine.session.insert(whiteQueen, "ConditionalHooks", [
|
||||
{
|
||||
condition: { type: "always" } as const,
|
||||
then: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 7 } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
// Apply any move so onAfterMove fires.
|
||||
const moves = engine.getAllLegalMoves();
|
||||
const e2e4 = moves.find((mv) => mv.from === 12 && mv.to === 28);
|
||||
engine.applyMove(e2e4!);
|
||||
|
||||
expect(engine.session.get(whiteQueen, "RangeBonus")).toBe(7);
|
||||
});
|
||||
|
||||
it("runs the else-branch when the condition fails", () => {
|
||||
const engine = new ChessEngine({
|
||||
profile: makeProfileWithCustomKind("conditional-else"),
|
||||
});
|
||||
|
||||
const whiteQueen = findPiece(engine, 3);
|
||||
engine.session.insert(whiteQueen, "ConditionalHooks", [
|
||||
{
|
||||
condition: { type: "never" } as const,
|
||||
then: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 1 } },
|
||||
],
|
||||
else: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 9 } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const moves = engine.getAllLegalMoves();
|
||||
const e2e4 = moves.find((mv) => mv.from === 12 && mv.to === 28);
|
||||
engine.applyMove(e2e4!);
|
||||
|
||||
expect(engine.session.get(whiteQueen, "RangeBonus")).toBe(9);
|
||||
});
|
||||
|
||||
it("attr-lt condition compares against the live attr value", () => {
|
||||
const engine = new ChessEngine({
|
||||
profile: makeProfileWithCustomKind("conditional-attr-lt"),
|
||||
});
|
||||
|
||||
const whiteQueen = findPiece(engine, 3);
|
||||
engine.session.insert(whiteQueen, "RangeBonus", 0);
|
||||
engine.session.insert(whiteQueen, "ConditionalHooks", [
|
||||
{
|
||||
condition: {
|
||||
type: "attr-lt" as const,
|
||||
attr: "RangeBonus",
|
||||
value: 5,
|
||||
},
|
||||
then: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: { attr: "RangeBonus", value: 100 },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const moves = engine.getAllLegalMoves();
|
||||
const e2e4 = moves.find((mv) => mv.from === 12 && mv.to === 28);
|
||||
engine.applyMove(e2e4!);
|
||||
|
||||
// 0 < 5 → then-branch fires → RangeBonus = 100.
|
||||
expect(engine.session.get(whiteQueen, "RangeBonus")).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("absorb-damage-with-attribute (damage pipeline integration)", () => {
|
||||
it("consumes ShieldCharges before HP drops via the onDamage hook", async () => {
|
||||
// Drive the integration preset's onDamage hook directly. The
|
||||
// preset is registered globally; we look it up via PRESET_REGISTRY
|
||||
// and call its hook with a synthetic ctx. The behavioural
|
||||
// end-to-end (capture during real gameplay → shield absorbs) is
|
||||
// covered by the Playwright e2e suite.
|
||||
const { PRESET_REGISTRY } = await import("../presets/registry.js");
|
||||
const integrationPreset = PRESET_REGISTRY.get(
|
||||
"__modifier-profile-integration__",
|
||||
);
|
||||
expect(integrationPreset?.onDamage).toBeDefined();
|
||||
|
||||
const engine = new ChessEngine();
|
||||
const e2Pawn = findPiece(engine, 12);
|
||||
|
||||
// Charge the shield: 3 charges, rate 1 per damage point.
|
||||
engine.session.insert(e2Pawn, "ShieldCharges" as never, 3);
|
||||
engine.session.insert(e2Pawn, "AbsorbDamageAttr", "ShieldCharges");
|
||||
engine.session.insert(e2Pawn, "AbsorbDamageRate", 1);
|
||||
|
||||
const damageCtx = {
|
||||
engine,
|
||||
target: e2Pawn,
|
||||
amount: 1,
|
||||
kind: "capture" as const,
|
||||
attacker: e2Pawn,
|
||||
};
|
||||
|
||||
// Fire a 1-damage event. Charges go 3 → 2; consume=true returned.
|
||||
const result = integrationPreset!.onDamage!(damageCtx);
|
||||
expect(result?.consume).toBe(true);
|
||||
expect(result?.died).toBe(false);
|
||||
expect(engine.session.get(e2Pawn, "ShieldCharges" as never)).toBe(2);
|
||||
|
||||
// Drain the shield with two more 1-damage events. After the third,
|
||||
// charges reach 0; further damage should fall through (no consume).
|
||||
integrationPreset!.onDamage!(damageCtx);
|
||||
integrationPreset!.onDamage!(damageCtx);
|
||||
expect(engine.session.get(e2Pawn, "ShieldCharges" as never)).toBe(0);
|
||||
|
||||
// Fourth hit: shield empty. Handler should fall through (no
|
||||
// consume-true return), so the rest of the damage pipeline runs.
|
||||
const fourthHit = integrationPreset!.onDamage!(damageCtx);
|
||||
expect(fourthHit?.consume).not.toBe(true);
|
||||
});
|
||||
});
|
||||
222
packages/chess/src/modifiers/triggers.ts
Normal file
222
packages/chess/src/modifiers/triggers.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* Trigger-primitive evaluator (T29 follow-up).
|
||||
*
|
||||
* The four trigger primitives — `on-turn-start`, `on-capture`,
|
||||
* `on-damaged`, `conditional` — seed hook facts on a piece at apply
|
||||
* time. The integration preset's `onAfterMove` hook calls these
|
||||
* dispatchers to walk every piece with each kind of hook fact and
|
||||
* run the inner primitive lists at the corresponding game phase.
|
||||
*
|
||||
* Phase mapping:
|
||||
* - on-turn-start hooks fire for pieces of the color whose turn is
|
||||
* NOW beginning (i.e. the non-mover after a successful move).
|
||||
* - on-capture hooks fire for the mover's piece when the move just
|
||||
* captured something (capturedId !== null on the last move log entry).
|
||||
* - on-damaged hooks fire for any piece whose Hp decreased between
|
||||
* the snapshot taken before the move and the post-move state.
|
||||
* Detected by comparing a pre-move HP snapshot.
|
||||
* - conditional hooks evaluate at every onAfterMove against the
|
||||
* piece's current facts; the matching branch's primitives run.
|
||||
*
|
||||
* Each inner primitive runs via the same `applyCustomDescriptor` path
|
||||
* used at profile-apply time, so nested triggers and conditionals
|
||||
* compose recursively (with the runtime depth cap as a backstop).
|
||||
*/
|
||||
import type { EntityId, Session } from "@paratype/rete";
|
||||
import type { ChessAttrMap, ConditionSpec, PieceColor } from "../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./primitives/registry.js";
|
||||
import type {
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveApplyContext,
|
||||
} from "./primitives/types.js";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
|
||||
/**
|
||||
* Iterate every piece (id > 0) and yield (id, color, hp).
|
||||
*/
|
||||
function* eachPiece(
|
||||
session: Session,
|
||||
): Generator<{ id: EntityId; color: PieceColor }> {
|
||||
const seen = new Set<number>();
|
||||
for (const f of session.allFacts()) {
|
||||
if (f.attr !== "Color") continue;
|
||||
if ((f.id as number) <= 0) continue;
|
||||
if (seen.has(f.id as number)) continue;
|
||||
seen.add(f.id as number);
|
||||
yield { id: f.id, color: f.value as PieceColor };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a list of primitive nodes against a single piece. Mirrors
|
||||
* `applyCustomDescriptor`'s walker but operates without a parent
|
||||
* descriptor (triggers fire mid-game; the descriptor that originally
|
||||
* seeded the hook isn't available at this phase).
|
||||
*/
|
||||
function runPrimitives(
|
||||
engine: ChessEngine,
|
||||
pieceId: EntityId,
|
||||
nodes: readonly EffectPrimitiveNode[],
|
||||
depth: number,
|
||||
): void {
|
||||
if (depth > 8) return; // hard runtime cap, mirrors validator
|
||||
for (const node of nodes) {
|
||||
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
|
||||
if (primitive === undefined) continue;
|
||||
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
engine,
|
||||
session: engine.session,
|
||||
pieceId,
|
||||
depth,
|
||||
// Trigger evaluation has no parent descriptor — synthesise a
|
||||
// minimal ref so the type contract is satisfied.
|
||||
descriptor: { id: "__trigger__", type: "data", version: 1 },
|
||||
};
|
||||
primitive.apply(ctx, node.params);
|
||||
|
||||
if (primitive.childPrimitives === undefined) continue;
|
||||
let children: readonly EffectPrimitiveNode[] = [];
|
||||
try {
|
||||
children = primitive.childPrimitives(node.params);
|
||||
} catch {
|
||||
children = [];
|
||||
}
|
||||
if (children.length > 0) {
|
||||
runPrimitives(engine, pieceId, children, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single ConditionSpec against a piece's current facts.
|
||||
*/
|
||||
function evaluateCondition(
|
||||
session: Session,
|
||||
pieceId: EntityId,
|
||||
condition: ConditionSpec,
|
||||
): boolean {
|
||||
switch (condition.type) {
|
||||
case "always":
|
||||
return true;
|
||||
case "never":
|
||||
return false;
|
||||
case "attr-eq": {
|
||||
const v = session.get(pieceId, condition.attr);
|
||||
return v === condition.value;
|
||||
}
|
||||
case "attr-lt": {
|
||||
const v = session.get(pieceId, condition.attr);
|
||||
return typeof v === "number" && v < condition.value;
|
||||
}
|
||||
case "attr-gt": {
|
||||
const v = session.get(pieceId, condition.attr);
|
||||
return typeof v === "number" && v > condition.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire `on-turn-start` hooks for every piece of the given color.
|
||||
* Called from the integration preset's onAfterMove with the
|
||||
* non-mover color (whose turn is now beginning).
|
||||
*/
|
||||
export function fireOnTurnStartHooks(
|
||||
engine: ChessEngine,
|
||||
whoseTurn: PieceColor,
|
||||
): void {
|
||||
for (const { id, color } of eachPiece(engine.session)) {
|
||||
if (color !== whoseTurn) continue;
|
||||
const hooks = engine.session.get(id, "OnTurnStartHooks") as
|
||||
| ChessAttrMap["OnTurnStartHooks"]
|
||||
| undefined;
|
||||
if (hooks === undefined) continue;
|
||||
for (const primitives of hooks) {
|
||||
runPrimitives(engine, id, primitives, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire `on-capture` hooks for the attacker piece. Called from the
|
||||
* integration preset's onAfterMove, which receives the attacker id
|
||||
* + capture-target metadata via a per-engine snapshot taken in
|
||||
* onBeforeMove (the engine's `moveLog` isn't populated yet at
|
||||
* onAfterMove time).
|
||||
*
|
||||
* `attackerId` is null when the most-recent move wasn't a capture.
|
||||
*/
|
||||
export function fireOnCaptureHooks(
|
||||
engine: ChessEngine,
|
||||
attackerId: EntityId | null,
|
||||
): void {
|
||||
if (attackerId === null) return;
|
||||
const hooks = engine.session.get(attackerId, "OnCaptureHooks") as
|
||||
| ChessAttrMap["OnCaptureHooks"]
|
||||
| undefined;
|
||||
if (hooks === undefined) return;
|
||||
for (const primitives of hooks) {
|
||||
runPrimitives(engine, attackerId, primitives, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot every piece's current Hp value (for damage-delta detection
|
||||
* around a move). The integration preset captures this BEFORE the
|
||||
* move happens, then `fireOnDamagedHooks` compares to the post-move
|
||||
* state to find pieces whose Hp decreased.
|
||||
*/
|
||||
export function snapshotHp(session: Session): Map<EntityId, number> {
|
||||
const out = new Map<EntityId, number>();
|
||||
for (const f of session.allFacts()) {
|
||||
if (f.attr !== "Hp") continue;
|
||||
if ((f.id as number) <= 0) continue;
|
||||
if (typeof f.value === "number") out.set(f.id, f.value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire `on-damaged` hooks for every piece whose Hp decreased relative
|
||||
* to the supplied pre-move snapshot. A retracted Hp fact (piece died)
|
||||
* also counts as damage. New pieces (no entry in snapshot) don't fire.
|
||||
*/
|
||||
export function fireOnDamagedHooks(
|
||||
engine: ChessEngine,
|
||||
preMoveHp: ReadonlyMap<EntityId, number>,
|
||||
): void {
|
||||
for (const [id, prev] of preMoveHp) {
|
||||
const current = engine.session.get(id, "Hp") as number | undefined;
|
||||
const damaged = current === undefined || current < prev;
|
||||
if (!damaged) continue;
|
||||
|
||||
const hooks = engine.session.get(id, "OnDamagedHooks") as
|
||||
| ChessAttrMap["OnDamagedHooks"]
|
||||
| undefined;
|
||||
if (hooks === undefined) continue;
|
||||
for (const primitives of hooks) {
|
||||
runPrimitives(engine, id, primitives, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate every piece's `ConditionalHooks` and run the matching
|
||||
* branch (then-primitives or else-primitives). Fires on every move
|
||||
* — conditions are re-evaluated against current facts so a hook that
|
||||
* reacts to "Hp < 2" fires the moment HP drops below threshold.
|
||||
*/
|
||||
export function fireConditionalHooks(engine: ChessEngine): void {
|
||||
for (const { id } of eachPiece(engine.session)) {
|
||||
const hooks = engine.session.get(id, "ConditionalHooks") as
|
||||
| ChessAttrMap["ConditionalHooks"]
|
||||
| undefined;
|
||||
if (hooks === undefined) continue;
|
||||
for (const hook of hooks) {
|
||||
const matches = evaluateCondition(engine.session, id, hook.condition);
|
||||
const branch = matches ? hook.then : hook.else;
|
||||
if (branch === undefined || branch.length === 0) continue;
|
||||
runPrimitives(engine, id, branch, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
160
packages/chess/src/modifiers/types.ts
Normal file
160
packages/chess/src/modifiers/types.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* Type definitions for the piece modifier profile system.
|
||||
*
|
||||
* A ModifierProfile is a first-class entity (orthogonal to layouts and
|
||||
* presets) that attaches rule modifiers to pieces by type ("all white
|
||||
* knights have +1 HP") or by layout slot ("the piece on b1 has +2 range").
|
||||
*
|
||||
* Modifier profiles combine with layouts at game start. They are saved
|
||||
* independently to a library (houserules:modifier-profiles:v1) and can be
|
||||
* hot-swapped mid-game at turn boundaries.
|
||||
*
|
||||
* ADR-2: per-instance modifiers keyed by algebraic-notation square string
|
||||
* (e.g. "b1"). Profile may optionally specify a layoutId for validation.
|
||||
*/
|
||||
import type { EntityId, Session } from "@paratype/rete";
|
||||
import type { PieceType, PieceColor, ChessAttrKey } from "../schema.js";
|
||||
import type { ZodType } from "zod";
|
||||
|
||||
/** The six T1 modifier category identifiers. */
|
||||
export type ModifierKindId =
|
||||
| "hp-bonus"
|
||||
| "range-bonus"
|
||||
| "direction-additions"
|
||||
| "capture-flags"
|
||||
| "promotion-override"
|
||||
| "damage-resistance"
|
||||
| (string & {});
|
||||
|
||||
/**
|
||||
* Named movement directions (from the perspective of the moving piece,
|
||||
* color-independent — engine interprets "forward" as toward the opponent's
|
||||
* back rank based on piece color).
|
||||
*
|
||||
* Used by DirectionAdditions modifier to specify additional movement directions.
|
||||
*/
|
||||
export type Direction =
|
||||
| "forward" // toward opponent's back rank (1 square)
|
||||
| "backward" // toward own back rank (1 square)
|
||||
| "left" // queenside (from white's perspective)
|
||||
| "right" // kingside (from white's perspective)
|
||||
| "diagonal-fl" // forward-left
|
||||
| "diagonal-fr" // forward-right
|
||||
| "diagonal-bl" // backward-left
|
||||
| "diagonal-br"; // backward-right
|
||||
|
||||
/**
|
||||
* A modifier that applies to ALL pieces of a given type+color combo.
|
||||
* `value` is the modifier-kind-specific value (number, string[], etc.)
|
||||
*/
|
||||
export interface TypeModifier {
|
||||
readonly kind: ModifierKindId;
|
||||
readonly pieceType: PieceType;
|
||||
readonly color: PieceColor | "both";
|
||||
readonly value: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A modifier that applies to the piece at a specific layout square.
|
||||
* `square` is algebraic notation: "a1" through "h8".
|
||||
* ADR-2: keyed by layout-slot (square string), not EntityId.
|
||||
*/
|
||||
export interface InstanceModifier {
|
||||
readonly kind: ModifierKindId;
|
||||
readonly square: string; // algebraic notation, e.g. "b1"
|
||||
readonly value: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* A complete modifier profile.
|
||||
*
|
||||
* Combines with a layout at game start: perType modifiers apply to all
|
||||
* matching pieces; perInstance modifiers apply to pieces at specific squares.
|
||||
*
|
||||
* `layoutId` is optional — only required when perInstance entries are
|
||||
* present, as per-instance modifiers are layout-slot-bound (ADR-2).
|
||||
* When layoutId is absent and perInstance is non-empty, the validator
|
||||
* emits a warning.
|
||||
*/
|
||||
export interface ModifierProfile {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
/** Layout this profile's per-instance modifiers are bound to. Optional. */
|
||||
readonly layoutId?: string | undefined;
|
||||
readonly perType: readonly TypeModifier[];
|
||||
readonly perInstance: readonly InstanceModifier[];
|
||||
readonly version: 1;
|
||||
readonly source: "premade" | "custom";
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptor for a modifier category. Each T1 modifier registers one
|
||||
* descriptor into MODIFIER_REGISTRY at module load time (ADR-8).
|
||||
*
|
||||
* `V` is the value type (number for HpBonus, string[] for DirectionAdditions, etc.)
|
||||
*/
|
||||
export interface ModifierDescriptor<V = unknown> {
|
||||
/** Matches ModifierKindId — used as the registry key. */
|
||||
readonly id: ModifierKindId;
|
||||
/** The ChessAttrMap key this modifier seeds on piece entities. */
|
||||
readonly attrName: ChessAttrKey;
|
||||
/**
|
||||
* Optional: the base piece attribute this modifier augments. When set,
|
||||
* `getModifierSource` uses this (not `attrName`) to match against a
|
||||
* preset's `pieceAttributes` list — e.g. `hp-bonus` augments the `Hp`
|
||||
* attribute declared by the `piece-hp` preset, even though the
|
||||
* modifier itself writes to `HpBonus`.
|
||||
*
|
||||
* Omit for modifiers whose `attrName` is the authoritative attribute
|
||||
* (no preset declares it separately).
|
||||
*/
|
||||
readonly baseAttr?: ChessAttrKey;
|
||||
/** Human-readable display label for UI. */
|
||||
readonly label: string;
|
||||
/** Zod schema for the value field — used for validation and UI form generation. */
|
||||
readonly valueSchema: ZodType<V>;
|
||||
/** How multiple values from different sources stack (ADR-4). */
|
||||
readonly stackingRule: "additive" | "union" | "multiplicative" | "priority-wins";
|
||||
/**
|
||||
* Apply the modifier's effective value to a piece entity in the session.
|
||||
* Called once per (pieceId, kind) pair after all sources have been
|
||||
* collected and the effective value computed via stacking rules.
|
||||
*
|
||||
* @param session - The game session to mutate
|
||||
* @param pieceId - Target piece entity
|
||||
* @param effectiveValue - The already-stacked value to apply
|
||||
*/
|
||||
readonly apply: (session: Session, pieceId: EntityId, effectiveValue: V) => void;
|
||||
/** Return a human-readable description of this modifier value for UI. */
|
||||
readonly describe: (value: V) => string;
|
||||
/**
|
||||
* Hint for the UI editor about which input widget to render for this modifier.
|
||||
* Each uiForm maps to a specific React component in the PerTypePanel/PerInstancePanel.
|
||||
*/
|
||||
readonly uiForm:
|
||||
| "number"
|
||||
| "direction-set"
|
||||
| "capture-flags"
|
||||
| "promotion-target"
|
||||
| "percentage";
|
||||
}
|
||||
|
||||
/** Helper: create a TypeModifier with proper typing. */
|
||||
export function typeModifier(
|
||||
kind: ModifierKindId,
|
||||
pieceType: PieceType,
|
||||
color: PieceColor | "both",
|
||||
value: unknown,
|
||||
): TypeModifier {
|
||||
return { kind, pieceType, color, value };
|
||||
}
|
||||
|
||||
/** Helper: create an InstanceModifier with proper typing. */
|
||||
export function instanceModifier(
|
||||
kind: ModifierKindId,
|
||||
square: string,
|
||||
value: unknown,
|
||||
): InstanceModifier {
|
||||
return { kind, square, value };
|
||||
}
|
||||
261
packages/chess/src/modifiers/validate.test.ts
Normal file
261
packages/chess/src/modifiers/validate.test.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { validateProfile } from "./validate.js";
|
||||
import { CLASSIC_LAYOUT } from "../layouts/classic.js";
|
||||
import { EMPTY_LAYOUT } from "../layouts/empty.js";
|
||||
import type { ModifierProfile, TypeModifier } from "./types.js";
|
||||
import type { StartingLayout } from "../layouts/types.js";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function emptyProfile(overrides: Partial<ModifierProfile> = {}): ModifierProfile {
|
||||
return {
|
||||
id: "test",
|
||||
name: "Test Profile",
|
||||
description: "",
|
||||
perType: [],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: "custom",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Minimal layout with one king of each color and nothing else. */
|
||||
const KINGS_ONLY_LAYOUT: StartingLayout = {
|
||||
id: "kings-only",
|
||||
name: "Kings Only",
|
||||
description: "Two kings for validator tests.",
|
||||
pieces: [
|
||||
{ type: "king", color: "white", square: 4 }, // e1
|
||||
{ type: "king", color: "black", square: 60 }, // e8
|
||||
],
|
||||
source: "custom",
|
||||
};
|
||||
|
||||
/** Layout without any white king. */
|
||||
const NO_WHITE_KING_LAYOUT: StartingLayout = {
|
||||
...KINGS_ONLY_LAYOUT,
|
||||
id: "no-white-king",
|
||||
pieces: [{ type: "king", color: "black", square: 60 }],
|
||||
};
|
||||
|
||||
/** Layout without any black king. */
|
||||
const NO_BLACK_KING_LAYOUT: StartingLayout = {
|
||||
...KINGS_ONLY_LAYOUT,
|
||||
id: "no-black-king",
|
||||
pieces: [{ type: "king", color: "white", square: 4 }],
|
||||
};
|
||||
|
||||
// ─── valid baseline ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("validateProfile — valid baseline", () => {
|
||||
it("returns valid=true and no errors/warnings for an empty profile on CLASSIC_LAYOUT", () => {
|
||||
const result = validateProfile(emptyProfile(), CLASSIC_LAYOUT);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns valid=true for a benign hp-bonus per-type modifier", () => {
|
||||
const profile = emptyProfile({
|
||||
perType: [{ kind: "hp-bonus", pieceType: "pawn", color: "white", value: 1 }],
|
||||
});
|
||||
const result = validateProfile(profile, CLASSIC_LAYOUT);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── E_PROFILE_NO_KING ────────────────────────────────────────────────────────
|
||||
|
||||
describe("validateProfile — E_PROFILE_NO_KING", () => {
|
||||
it("errors when layout has no white king", () => {
|
||||
const result = validateProfile(emptyProfile(), NO_WHITE_KING_LAYOUT);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
const err = result.errors.find((e) => e.code === "E_PROFILE_NO_KING");
|
||||
expect(err).toBeDefined();
|
||||
expect(err?.message).toMatch(/white king/i);
|
||||
});
|
||||
|
||||
it("errors when layout has no black king", () => {
|
||||
const result = validateProfile(emptyProfile(), NO_BLACK_KING_LAYOUT);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
const err = result.errors.find((e) => e.code === "E_PROFILE_NO_KING");
|
||||
expect(err).toBeDefined();
|
||||
expect(err?.message).toMatch(/black king/i);
|
||||
});
|
||||
|
||||
it("emits two E_PROFILE_NO_KING errors for EMPTY_LAYOUT (no kings of either color)", () => {
|
||||
const result = validateProfile(emptyProfile(), EMPTY_LAYOUT);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
const kingErrors = result.errors.filter((e) => e.code === "E_PROFILE_NO_KING");
|
||||
expect(kingErrors).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── E_PROFILE_INVULN_KING ───────────────────────────────────────────────────
|
||||
|
||||
describe("validateProfile — E_PROFILE_INVULN_KING", () => {
|
||||
it("errors when per-type modifier grants all kings CANNOT_BE_CAPTURED", () => {
|
||||
const profile = emptyProfile({
|
||||
perType: [
|
||||
// value 2 = CANNOT_BE_CAPTURED
|
||||
{ kind: "capture-flags", pieceType: "king", color: "white", value: 2 },
|
||||
],
|
||||
});
|
||||
const result = validateProfile(profile, CLASSIC_LAYOUT);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
const err = result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING");
|
||||
expect(err).toBeDefined();
|
||||
expect(err?.message).toMatch(/CANNOT_BE_CAPTURED/);
|
||||
});
|
||||
|
||||
it("errors when per-instance modifier gives a king CANNOT_BE_CAPTURED (white king at e1)", () => {
|
||||
// White king is at e1 = square 4 in classic layout
|
||||
const profile = emptyProfile({
|
||||
perInstance: [{ kind: "capture-flags", square: "e1", value: 2 }],
|
||||
});
|
||||
const result = validateProfile(profile, CLASSIC_LAYOUT);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
const err = result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING");
|
||||
expect(err).toBeDefined();
|
||||
expect(err?.square).toBe("e1");
|
||||
});
|
||||
|
||||
it("does NOT error when per-instance CANNOT_BE_CAPTURED targets a non-king (rook at a1)", () => {
|
||||
// White rook is at a1 = square 0 — not a king, so invuln is allowed
|
||||
const profile = emptyProfile({
|
||||
perInstance: [{ kind: "capture-flags", square: "a1", value: 2 }],
|
||||
});
|
||||
const result = validateProfile(profile, CLASSIC_LAYOUT);
|
||||
|
||||
const invulnError = result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING");
|
||||
expect(invulnError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does NOT error for CAN_CAPTURE_OWN (flag=1) on a king", () => {
|
||||
// CAN_CAPTURE_OWN (= 1) on a king is unusual but not a deadlock
|
||||
const profile = emptyProfile({
|
||||
perType: [{ kind: "capture-flags", pieceType: "king", color: "white", value: 1 }],
|
||||
});
|
||||
const result = validateProfile(profile, CLASSIC_LAYOUT);
|
||||
|
||||
const invulnError = result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING");
|
||||
expect(invulnError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("errors when combined flags include CANNOT_BE_CAPTURED on king (value=3)", () => {
|
||||
// value 3 = CAN_CAPTURE_OWN | CANNOT_BE_CAPTURED — still has the invuln bit
|
||||
const profile = emptyProfile({
|
||||
perType: [{ kind: "capture-flags", pieceType: "king", color: "both", value: 3 }],
|
||||
});
|
||||
const result = validateProfile(profile, CLASSIC_LAYOUT);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.find((e) => e.code === "E_PROFILE_INVULN_KING")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── E_PROFILE_ORPHAN_INSTANCE ───────────────────────────────────────────────
|
||||
|
||||
describe("validateProfile — E_PROFILE_ORPHAN_INSTANCE (warning)", () => {
|
||||
it("warns when per-instance modifier targets an empty square (d4 is empty in classic)", () => {
|
||||
// d4 = rank 3, file 3 → square 27 — empty in CLASSIC_LAYOUT
|
||||
const profile = emptyProfile({
|
||||
perInstance: [{ kind: "hp-bonus", square: "d4", value: 2 }],
|
||||
});
|
||||
const result = validateProfile(profile, CLASSIC_LAYOUT);
|
||||
|
||||
// Warning, not error — profile is still valid
|
||||
expect(result.valid).toBe(true);
|
||||
const warn = result.warnings.find((w) => w.code === "E_PROFILE_ORPHAN_INSTANCE");
|
||||
expect(warn).toBeDefined();
|
||||
expect(warn?.square).toBe("d4");
|
||||
});
|
||||
|
||||
it("does NOT warn when per-instance modifier targets an occupied square (e2 pawn)", () => {
|
||||
// e2 = rank 1, file 4 → square 12 — white pawn in CLASSIC_LAYOUT
|
||||
const profile = emptyProfile({
|
||||
perInstance: [{ kind: "hp-bonus", square: "e2", value: 1 }],
|
||||
});
|
||||
const result = validateProfile(profile, CLASSIC_LAYOUT);
|
||||
|
||||
expect(result.warnings.find((w) => w.code === "E_PROFILE_ORPHAN_INSTANCE")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits multiple orphan warnings for multiple empty-square entries", () => {
|
||||
const profile = emptyProfile({
|
||||
perInstance: [
|
||||
{ kind: "hp-bonus", square: "d4", value: 1 },
|
||||
{ kind: "range-bonus", square: "e5", value: 1 },
|
||||
],
|
||||
});
|
||||
const result = validateProfile(profile, CLASSIC_LAYOUT);
|
||||
|
||||
const orphans = result.warnings.filter((w) => w.code === "E_PROFILE_ORPHAN_INSTANCE");
|
||||
expect(orphans).toHaveLength(2);
|
||||
expect(orphans.map((w) => w.square).sort()).toEqual(["d4", "e5"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── E_PROFILE_ATTR_LIMIT ─────────────────────────────────────────────────────
|
||||
|
||||
describe("validateProfile — E_PROFILE_ATTR_LIMIT", () => {
|
||||
it("does NOT error with all 6 current modifier kinds on one piece (6 < 12)", () => {
|
||||
// A white pawn gets all 6 T1 modifier kinds — well within the 12-kind limit
|
||||
const perType: TypeModifier[] = [
|
||||
{ kind: "hp-bonus", pieceType: "pawn", color: "white", value: 1 },
|
||||
{ kind: "range-bonus", pieceType: "pawn", color: "white", value: 1 },
|
||||
{ kind: "direction-additions", pieceType: "pawn", color: "white", value: ["forward"] },
|
||||
{ kind: "capture-flags", pieceType: "pawn", color: "white", value: 1 },
|
||||
{ kind: "promotion-override", pieceType: "pawn", color: "white", value: "queen" },
|
||||
{ kind: "damage-resistance", pieceType: "pawn", color: "white", value: 0.5 },
|
||||
];
|
||||
const result = validateProfile(emptyProfile({ perType }), CLASSIC_LAYOUT);
|
||||
|
||||
expect(result.errors.find((e) => e.code === "E_PROFILE_ATTR_LIMIT")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("errors when a single piece accumulates more than 12 distinct modifier kinds", () => {
|
||||
// Inject 13 synthetic modifier kinds via type assertion (for test only).
|
||||
// In practice this requires future modifier kinds beyond the 6 T1 set.
|
||||
const perType = Array.from({ length: 13 }, (_, i) => ({
|
||||
kind: `synthetic-kind-${i}` as unknown as TypeModifier["kind"],
|
||||
pieceType: "pawn" as const,
|
||||
color: "white" as const,
|
||||
value: i,
|
||||
})) satisfies TypeModifier[];
|
||||
|
||||
const result = validateProfile(emptyProfile({ perType }), CLASSIC_LAYOUT);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
const err = result.errors.find((e) => e.code === "E_PROFILE_ATTR_LIMIT");
|
||||
expect(err).toBeDefined();
|
||||
expect(err?.message).toMatch(/13/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── valid field integrity ────────────────────────────────────────────────────
|
||||
|
||||
describe("validateProfile — valid field", () => {
|
||||
it("valid=false when any error is present", () => {
|
||||
const result = validateProfile(emptyProfile(), EMPTY_LAYOUT);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("valid=true even when warnings are present", () => {
|
||||
const profile = emptyProfile({
|
||||
perInstance: [{ kind: "hp-bonus", square: "d4", value: 1 }],
|
||||
});
|
||||
const result = validateProfile(profile, CLASSIC_LAYOUT);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
197
packages/chess/src/modifiers/validate.ts
Normal file
197
packages/chess/src/modifiers/validate.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
/**
|
||||
* Modifier profile legality validator.
|
||||
*
|
||||
* Validates a ModifierProfile against a StartingLayout for game-rule legality.
|
||||
*
|
||||
* Errors BLOCK profile activation (server rejects the profile; editor hides
|
||||
* the "Apply" CTA). Warnings are surfaced to the user but don't prevent the
|
||||
* profile from being applied — the user made a deliberate choice.
|
||||
*
|
||||
* ## Error rules
|
||||
*
|
||||
* 1. E_PROFILE_NO_KING: Layout must have ≥1 king per color.
|
||||
* (Profiles cannot compensate for a king-less layout.)
|
||||
*
|
||||
* 2. E_PROFILE_INVULN_KING: King cannot have the CANNOT_BE_CAPTURED flag.
|
||||
* An invulnerable king means the game can never end — instant deadlock.
|
||||
*
|
||||
* 3. E_PROFILE_ATTR_LIMIT: A single piece cannot accumulate > 12 distinct
|
||||
* modifier kinds. The EAV ceiling is 16 attributes per entity; 4 are
|
||||
* reserved for core piece facts (PieceType, Color, Position, HasMoved),
|
||||
* leaving 12 slots for modifier attributes.
|
||||
*
|
||||
* 4. E_PROFILE_DEADLOCK: Reserved. Requires a session simulation to detect
|
||||
* mutual-invulnerability loops; deferred to a future task.
|
||||
*
|
||||
* ## Warning rules
|
||||
*
|
||||
* - E_PROFILE_ORPHAN_INSTANCE: A per-instance modifier references a square
|
||||
* that has no piece in the layout. The modifier will never apply, but it is
|
||||
* harmless — the user may have changed the layout after crafting the profile.
|
||||
*/
|
||||
import type { ModifierProfile } from "./types.js";
|
||||
import type { StartingLayout } from "../layouts/types.js";
|
||||
import { CaptureFlag } from "../schema.js";
|
||||
import { squareToAlgebraic } from "../coord.js";
|
||||
|
||||
// ─── Public types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type ValidationErrorCode =
|
||||
| "E_PROFILE_NO_KING"
|
||||
| "E_PROFILE_INVULN_KING"
|
||||
| "E_PROFILE_DEADLOCK"
|
||||
| "E_PROFILE_ATTR_LIMIT";
|
||||
|
||||
export type ValidationWarningCode = "E_PROFILE_ORPHAN_INSTANCE";
|
||||
|
||||
export interface ValidationError {
|
||||
readonly code: ValidationErrorCode;
|
||||
readonly message: string;
|
||||
/** Algebraic square, present when the error is tied to a specific square. */
|
||||
readonly square?: string;
|
||||
}
|
||||
|
||||
export interface ValidationWarning {
|
||||
readonly code: ValidationWarningCode;
|
||||
readonly message: string;
|
||||
/** Algebraic square the warning refers to (always present for ORPHAN_INSTANCE). */
|
||||
readonly square: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
readonly errors: readonly ValidationError[];
|
||||
readonly warnings: readonly ValidationWarning[];
|
||||
/** `true` iff `errors` is empty — profile can be activated. */
|
||||
readonly valid: boolean;
|
||||
}
|
||||
|
||||
// ─── Validator ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function validateProfile(
|
||||
profile: ModifierProfile,
|
||||
layout: StartingLayout,
|
||||
): ValidationResult {
|
||||
const errors: ValidationError[] = [];
|
||||
const warnings: ValidationWarning[] = [];
|
||||
|
||||
// ── Check 1: E_PROFILE_NO_KING ─────────────────────────────────────────────
|
||||
// Each color must have at least one king in the layout; without one, there
|
||||
// is no royal piece to checkmate/capture and the game has no terminal state.
|
||||
for (const color of ["white", "black"] as const) {
|
||||
const hasKing = layout.pieces.some(
|
||||
(p) => p.type === "king" && p.color === color,
|
||||
);
|
||||
if (!hasKing) {
|
||||
errors.push({
|
||||
code: "E_PROFILE_NO_KING",
|
||||
message: `Layout has no ${color} king — the game cannot reach a terminal condition.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 2: E_PROFILE_INVULN_KING ────────────────────────────────────────
|
||||
// A king with CANNOT_BE_CAPTURED (= 2) can never be taken; the game would
|
||||
// loop indefinitely with no win condition.
|
||||
const INVULN = CaptureFlag.CANNOT_BE_CAPTURED;
|
||||
|
||||
// Per-type: modifier applies to ALL kings of the given color.
|
||||
for (const tm of profile.perType) {
|
||||
if (
|
||||
tm.kind === "capture-flags" &&
|
||||
tm.pieceType === "king" &&
|
||||
typeof tm.value === "number" &&
|
||||
(tm.value & INVULN) !== 0
|
||||
) {
|
||||
errors.push({
|
||||
code: "E_PROFILE_INVULN_KING",
|
||||
message:
|
||||
`Per-type modifier grants all ${tm.color} kings CANNOT_BE_CAPTURED ` +
|
||||
`— the game would have no terminal condition.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Per-instance: modifier applies only if the piece at that square is a king.
|
||||
for (const im of profile.perInstance) {
|
||||
if (im.kind !== "capture-flags") continue;
|
||||
if (typeof im.value !== "number") continue;
|
||||
if ((im.value & INVULN) === 0) continue;
|
||||
|
||||
const piece = layout.pieces.find(
|
||||
(p) => squareToAlgebraic(p.square) === im.square,
|
||||
);
|
||||
if (piece?.type === "king") {
|
||||
errors.push({
|
||||
code: "E_PROFILE_INVULN_KING",
|
||||
message:
|
||||
`Per-instance modifier grants king at ${im.square} CANNOT_BE_CAPTURED ` +
|
||||
`— the game would have no terminal condition.`,
|
||||
square: im.square,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 3: E_PROFILE_ORPHAN_INSTANCE (WARNING) ──────────────────────────
|
||||
// A per-instance entry that targets an empty square will never fire.
|
||||
// Warning only — the user may have intentionally left the slot as a draft,
|
||||
// or changed the layout after building the profile.
|
||||
for (const im of profile.perInstance) {
|
||||
const piece = layout.pieces.find(
|
||||
(p) => squareToAlgebraic(p.square) === im.square,
|
||||
);
|
||||
if (!piece) {
|
||||
warnings.push({
|
||||
code: "E_PROFILE_ORPHAN_INSTANCE",
|
||||
message:
|
||||
`Per-instance modifier at "${im.square}" references an empty square — ` +
|
||||
`the modifier will never apply.`,
|
||||
square: im.square,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 4: E_PROFILE_ATTR_LIMIT ─────────────────────────────────────────
|
||||
// For each piece in the layout, count the distinct modifier kinds that
|
||||
// target it (from both perType and perInstance sources). More than 12
|
||||
// would overflow the EAV attribute ceiling (16 total − 4 core = 12 slots).
|
||||
for (const piece of layout.pieces) {
|
||||
const sq = squareToAlgebraic(piece.square);
|
||||
const kinds = new Set<string>();
|
||||
|
||||
for (const tm of profile.perType) {
|
||||
if (
|
||||
tm.pieceType === piece.type &&
|
||||
(tm.color === piece.color || tm.color === "both")
|
||||
) {
|
||||
kinds.add(tm.kind);
|
||||
}
|
||||
}
|
||||
|
||||
for (const im of profile.perInstance) {
|
||||
if (im.square === sq) {
|
||||
kinds.add(im.kind);
|
||||
}
|
||||
}
|
||||
|
||||
if (kinds.size > 12) {
|
||||
errors.push({
|
||||
code: "E_PROFILE_ATTR_LIMIT",
|
||||
message:
|
||||
`Piece at ${sq} has ${kinds.size} distinct modifier kinds (max 12).`,
|
||||
square: sq,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check 5: E_PROFILE_DEADLOCK ────────────────────────────────────────────
|
||||
// TODO: Detect modifier combinations that create irresolvable game states
|
||||
// (e.g., both colors have kings with CANNOT_BE_CAPTURED, neither side can
|
||||
// win). This check requires a session simulation and is deferred; the
|
||||
// per-type INVULN_KING check above catches the most common case.
|
||||
|
||||
return {
|
||||
errors,
|
||||
warnings,
|
||||
valid: errors.length === 0,
|
||||
};
|
||||
}
|
||||
|
|
@ -12,12 +12,18 @@
|
|||
|
||||
import type {
|
||||
ClientMessage,
|
||||
CustomModifierRegisteredPayload,
|
||||
ErrorPayload,
|
||||
GameDeltaPayload,
|
||||
GameEndPayload,
|
||||
GameMovePayload,
|
||||
GamePresetsPayload,
|
||||
GameStatePayload,
|
||||
ModifierProfileProposalPendingPayload,
|
||||
ModifierProfileRejectedPayload,
|
||||
ModifierProfileConsentReceivedPayload,
|
||||
ModifierProfileQueuedPayload,
|
||||
ModifierProfileUpdatedPayload,
|
||||
PresetActivation,
|
||||
PromotionPiece,
|
||||
RoomCreatedPayload,
|
||||
|
|
@ -36,6 +42,12 @@ export type GameClientEvent =
|
|||
| { type: "game.presets"; payload: GamePresetsPayload }
|
||||
| { type: "room.created"; payload: RoomCreatedPayload }
|
||||
| { type: "room.joined"; payload: RoomJoinedPayload }
|
||||
| { type: "modifier-profile.proposal-pending"; payload: ModifierProfileProposalPendingPayload }
|
||||
| { type: "modifier-profile.rejected"; payload: ModifierProfileRejectedPayload }
|
||||
| { type: "modifier-profile.consent-received"; payload: ModifierProfileConsentReceivedPayload }
|
||||
| { type: "modifier-profile.queued"; payload: ModifierProfileQueuedPayload }
|
||||
| { type: "modifier-profile.updated"; payload: ModifierProfileUpdatedPayload }
|
||||
| { type: "custom-modifier.registered"; payload: CustomModifierRegisteredPayload }
|
||||
| { type: "error"; payload: ErrorPayload }
|
||||
| { type: "connected" }
|
||||
| { type: "disconnected"; willReconnect: boolean };
|
||||
|
|
@ -49,20 +61,13 @@ type EventOfType<T extends GameClientEventType> = Extract<
|
|||
|
||||
type Listener<T extends GameClientEventType> = (event: EventOfType<T>) => void;
|
||||
|
||||
// Heterogeneous internal listener map — narrowed via the public `on()` API.
|
||||
// Using `unknown` avoids `any` while still permitting one map for all types.
|
||||
type AnyListener = (event: GameClientEvent) => void;
|
||||
|
||||
// Shape emitted to listeners of a lifecycle-only event. We accept these two
|
||||
// shapes when callers invoke `emit()` so the compiler stays honest about the
|
||||
// discriminated union without resorting to casts.
|
||||
interface LifecycleConnected {
|
||||
type: "connected";
|
||||
}
|
||||
interface LifecycleDisconnected {
|
||||
type: "disconnected";
|
||||
willReconnect: boolean;
|
||||
}
|
||||
// Heterogeneous internal listener table. Typing as a mapped type keyed by
|
||||
// the discriminant preserves the per-event Listener<T> relationship through
|
||||
// index lookup, so `on`/`off`/`emit` can manipulate listener arrays without
|
||||
// any casts.
|
||||
type ListenerTable = {
|
||||
[T in GameClientEventType]?: Listener<T>[];
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
|
|
@ -132,8 +137,9 @@ export class GameClient {
|
|||
// `closed` is set when close() is called: it suppresses auto-reconnect.
|
||||
private closed = false;
|
||||
|
||||
// Listeners keyed by event type.
|
||||
private readonly listeners = new Map<GameClientEventType, AnyListener[]>();
|
||||
// Listeners keyed by event type. Mapped-type keys preserve the
|
||||
// per-type Listener<T> relationship; see `ListenerTable`.
|
||||
private readonly listeners: ListenerTable = {};
|
||||
|
||||
constructor(url: string, options: GameClientOptions = {}) {
|
||||
this.url = url;
|
||||
|
|
@ -165,20 +171,21 @@ export class GameClient {
|
|||
// -------------------------------------------------------------------------
|
||||
|
||||
on<T extends GameClientEventType>(type: T, listener: Listener<T>): void {
|
||||
const arr = this.listeners.get(type);
|
||||
// We up-cast to AnyListener here because the map is heterogeneous; the
|
||||
// public `on` signature guarantees each listener only ever receives its
|
||||
// own discriminated variant, which we enforce at `emit()` sites.
|
||||
const cast = listener as unknown as AnyListener;
|
||||
if (arr) arr.push(cast);
|
||||
else this.listeners.set(type, [cast]);
|
||||
// TS can't prove writes to `this.listeners[type]` are safe for a
|
||||
// generic T (the mapped-type key makes the target an intersection).
|
||||
// Projecting to a per-call `Record<T, …>` narrows the write site
|
||||
// safely — this is the only place that widening happens, and it
|
||||
// preserves the per-type Listener<T> relationship elsewhere.
|
||||
const table = this.listeners as Record<T, Listener<T>[] | undefined>;
|
||||
const arr = table[type];
|
||||
if (arr) arr.push(listener);
|
||||
else table[type] = [listener];
|
||||
}
|
||||
|
||||
off<T extends GameClientEventType>(type: T, listener: Listener<T>): void {
|
||||
const arr = this.listeners.get(type);
|
||||
const arr = this.listeners[type];
|
||||
if (!arr) return;
|
||||
const cast = listener as unknown as AnyListener;
|
||||
const idx = arr.indexOf(cast);
|
||||
const idx = arr.indexOf(listener);
|
||||
if (idx >= 0) arr.splice(idx, 1);
|
||||
}
|
||||
|
||||
|
|
@ -275,6 +282,30 @@ export class GameClient {
|
|||
this.send({ type: "room.setPresets", payload });
|
||||
}
|
||||
|
||||
/**
|
||||
* T3: register a custom modifier descriptor on the current room.
|
||||
* Server validates structurally, stores in the per-room registry
|
||||
* (capped at 10 distinct descriptors), and broadcasts
|
||||
* `custom-modifier.registered` to every connected client. Clients
|
||||
* (including the sender) mirror the descriptor onto their local
|
||||
* engine's customModifiers registry via the predicate manager
|
||||
* subscriber, so subsequent profile applies can resolve the kind.
|
||||
*/
|
||||
sendRegisterCustomModifier(
|
||||
descriptor: import("./types.js").CustomModifierDescriptorWire,
|
||||
): void {
|
||||
if (this.code === null) {
|
||||
// No active room — silently no-op rather than throw, mirroring
|
||||
// sendMove's behaviour when called pre-connect.
|
||||
return;
|
||||
}
|
||||
const payload: import("./types.js").CustomModifierRegisterPayload = {
|
||||
roomCode: this.code,
|
||||
descriptor,
|
||||
};
|
||||
this.send({ type: "custom-modifier.register", payload });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Accessors (primarily for tests & reconnect logic)
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -438,6 +469,24 @@ export class GameClient {
|
|||
case "room.joined":
|
||||
this.emit({ type, payload: payload as RoomJoinedPayload });
|
||||
return;
|
||||
case "modifier-profile.proposal-pending":
|
||||
this.emit({ type, payload: payload as ModifierProfileProposalPendingPayload });
|
||||
return;
|
||||
case "modifier-profile.rejected":
|
||||
this.emit({ type, payload: payload as ModifierProfileRejectedPayload });
|
||||
return;
|
||||
case "modifier-profile.consent-received":
|
||||
this.emit({ type, payload: payload as ModifierProfileConsentReceivedPayload });
|
||||
return;
|
||||
case "modifier-profile.queued":
|
||||
this.emit({ type, payload: payload as ModifierProfileQueuedPayload });
|
||||
return;
|
||||
case "modifier-profile.updated":
|
||||
this.emit({ type, payload: payload as ModifierProfileUpdatedPayload });
|
||||
return;
|
||||
case "custom-modifier.registered":
|
||||
this.emit({ type, payload: payload as CustomModifierRegisteredPayload });
|
||||
return;
|
||||
case "error":
|
||||
this.emit({ type, payload: payload as ErrorPayload });
|
||||
return;
|
||||
|
|
@ -448,11 +497,11 @@ export class GameClient {
|
|||
}
|
||||
}
|
||||
|
||||
private emit(event: GameClientEvent): void;
|
||||
private emit(event: LifecycleConnected): void;
|
||||
private emit(event: LifecycleDisconnected): void;
|
||||
private emit(event: GameClientEvent): void {
|
||||
const arr = this.listeners.get(event.type);
|
||||
private emit<T extends GameClientEventType>(event: EventOfType<T>): void {
|
||||
// Generic over T so `this.listeners[event.type]` resolves to
|
||||
// `Listener<T>[] | undefined` (not a union), which matches the
|
||||
// concrete `event: EventOfType<T>` being dispatched. No casts needed.
|
||||
const arr = this.listeners[event.type];
|
||||
if (!arr) return;
|
||||
// Iterate over a copy so listeners that call `off()` mid-dispatch don't
|
||||
// skip subsequent listeners.
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const WS_URL =
|
|||
(import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ??
|
||||
'ws://localhost:7357/ws';
|
||||
|
||||
import type { ResolvedLayoutWire } from './types';
|
||||
import type { ModifierProfileWire, ResolvedLayoutWire } from './types';
|
||||
|
||||
interface RoomPayload {
|
||||
code?: string;
|
||||
|
|
@ -27,6 +27,7 @@ interface RoomPayload {
|
|||
color?: string;
|
||||
message?: string;
|
||||
layout?: ResolvedLayoutWire;
|
||||
profile?: ModifierProfileWire;
|
||||
}
|
||||
|
||||
interface ServerMsg {
|
||||
|
|
@ -37,13 +38,15 @@ interface ServerMsg {
|
|||
/**
|
||||
* The shape resolved by oneShotRoomRequest on success. `layout` is
|
||||
* optional for wire compat with older servers; new servers always
|
||||
* populate it.
|
||||
* populate it. `profile` is populated when the room was created with a
|
||||
* modifier profile (T19) — absent otherwise.
|
||||
*/
|
||||
export interface OneShotRoomResult {
|
||||
code: string;
|
||||
token: string;
|
||||
color: string;
|
||||
layout?: ResolvedLayoutWire;
|
||||
profile?: ModifierProfileWire;
|
||||
}
|
||||
|
||||
export function oneShotRoomRequest(
|
||||
|
|
@ -88,6 +91,9 @@ export function oneShotRoomRequest(
|
|||
if (msg.payload.layout !== undefined) {
|
||||
result.layout = msg.payload.layout;
|
||||
}
|
||||
if (msg.payload.profile !== undefined) {
|
||||
result.profile = msg.payload.profile;
|
||||
}
|
||||
resolve(result);
|
||||
} else if (msg.type === 'error') {
|
||||
clearTimeout(timeout);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue