chore(root): scaffold monorepo — Phase 0 complete

This commit is contained in:
Joey Yakimowich-Payne 2026-04-16 13:32:21 -06:00
commit f3a38d44be
No known key found for this signature in database
32 changed files with 4659 additions and 0 deletions

64
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,64 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
name: Typecheck, Lint, Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Cache bun dependencies
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Typecheck
run: bun run typecheck
- name: Lint
run: bun run lint
- name: Test with coverage
run: bun run test:coverage
build:
name: Build packages
runs-on: ubuntu-latest
needs: check
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build
run: bun run build
audit:
name: Security audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Audit
run: bun audit || true
# e2e:
# name: E2E (Playwright)
# runs-on: ubuntu-latest
# # Enabled in Phase 3 when chess UI exists

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
node_modules/
dist/
.sisyphus/evidence/
*.log
.DS_Store
coverage/
playwright-report/
test-results/
.vite/
*.tsbuildinfo
bun.lock
node-compile-cache/

14
.sisyphus/boulder.json Normal file
View file

@ -0,0 +1,14 @@
{
"active_plan": "/home/joey/Projects/rules/.sisyphus/plans/rete-rules-engine.md",
"started_at": "2026-04-16T19:18:14.222Z",
"session_ids": [
"ses_26872eb19ffeDMaEoxdTsorFej",
"ses_26842c860ffec63Ov7Llrz7Lyg",
"ses_26843b5b0ffejVpXmmHcQ8CoUp",
"ses_268415bf0ffernrRKPtHro341E",
"ses_2684239eeffeaz52nJeIRqphnJ",
"ses_2683e067effeLzndjG25qlXH3G"
],
"plan_name": "rete-rules-engine",
"agent": "atlas"
}

File diff suppressed because it is too large Load diff

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Paratype Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

22
README.md Normal file
View file

@ -0,0 +1,22 @@
# @paratype
A Doorenbos-style Rete II rules engine for TypeScript games, with an authoritative WebSocket chess demo.
## Packages
- [`packages/rete`](packages/rete) — Rete II engine core
- [`packages/chess`](packages/chess) — Browser chess demo (React + Vite)
- [`packages/server`](packages/server) — Authoritative Bun WebSocket server
## Docs
- [SPEC.md](packages/rete/SPEC.md) — Engine specification
- [PHASES.md](docs/PHASES.md) — Development phases & perf budgets
- [RULES.md](packages/chess/RULES.md) — Chess rule presets
- [PROTOCOL.md](packages/server/PROTOCOL.md) — WebSocket message protocol
## Getting Started
```bash
bun install && bun run check
```

194
docs/PHASES.md Normal file
View file

@ -0,0 +1,194 @@
# Project Phases
## Phase 0 — Specification Lock
In-scope deliverables:
- `packages/rete/SPEC.md` — engine semantics (10 sections)
- `docs/PHASES.md` — this document
- `packages/chess/RULES.md` — 15 preset custom rules with compat matrix
- `packages/server/PROTOCOL.md` — WebSocket protocol v1 (8+ message types)
- Monorepo scaffold: Bun workspaces, tsconfig base, eslint flat config, vitest workspace, playwright config, lefthook pre-commit hook, GitHub Actions CI
- Three package skeletons: `packages/rete`, `packages/chess`, `packages/server`
Acceptance gate (executable):
```bash
test -f packages/rete/SPEC.md && test -f docs/PHASES.md && test -f packages/chess/RULES.md && test -f packages/server/PROTOCOL.md
bun install && bun run check # exits 0
gh run list --limit 1 --json conclusion -q '.[0].conclusion' # "success"
```
Must NOT have in this phase: any engine implementation, any chess rules, any UI, any server logic.
## Phase 1 — Pararules Parity
In-scope deliverables:
- `packages/rete/src/schema.ts` — typed EAV schema + Fact type
- `packages/rete/src/wm.ts` — WorkingMemory with deterministic iteration
- `packages/rete/src/alpha.ts` — AlphaNetwork + AlphaMemory with inverted-index dispatch
- `packages/rete/src/session.ts` — Session lifecycle (init, add, insert, retract, fireRules)
- `packages/rete/src/builder.ts` — Typed rule builder + `v()` variable helper
- `packages/rete/src/registry.ts` — HandlerRegistry + PredicateRegistry
- `packages/rete/src/serialize.ts` — JSON round-trip serialization (RULE_SCHEMA_V1)
- `packages/rete/src/beta.ts` — BetaMemory + Token propagation
- `packages/rete/src/join.ts` — JoinNode with variable-binding equality tests
- `packages/rete/src/condition.ts` — FilterNode with registered predicates
- `packages/rete/src/query.ts` — query() / queryAll() API
- `packages/rete/src/derived.ts` — thenFinally + truth maintenance
- `packages/rete/src/cycle.ts` — RecursionLimitExceededError + recursion counter
- `packages/rete/src/conflict.ts` — deterministic activation ordering
- `packages/rete/tests/golden/` — pararules golden-file test port (5-10 tests)
- tsup build output: `packages/rete/dist/` with ESM + CJS + .d.ts
Acceptance gate:
```bash
bun test packages/rete # all green
bun run test:coverage -- packages/rete # ≥90% line coverage
bun run check # exits 0
git tag v0.1.0-phase1 # after gate passes
```
Must NOT have: negation nodes, NCC, existential, aggregation (those are Phase 2).
## Phase 2 — Rete II + Chess Engine
In-scope deliverables:
- `packages/rete/src/negation.ts` — NegationNode (NOT)
- `packages/rete/src/existential.ts` — ExistentialNode (EXISTS)
- `packages/rete/src/ncc.ts` — NccNode (not-count-condition)
- `packages/rete/src/aggregate.ts` — AggregationNode (count/sum/collect/min/max)
- `packages/chess/src/schema.ts` — chess attribute schema (piece types, squares, colors)
- `packages/chess/src/coord.ts` — square coordinate helpers
- `packages/chess/src/starting-position.ts` — FIDE starting position fact generator
- `packages/chess/src/rules/` — all FIDE chess rules as Rete productions:
- `primitives.ts`, `pawn.ts`, `knight.ts`, `sliding.ts`, `king.ts`, `turn.ts`, `capture.ts`
- `castling.ts`, `enpassant.ts`, `promotion.ts`, `check.ts`
- `checkmate.ts`, `stalemate.ts`, `draws.ts`, `insufficient.ts`
- `packages/chess/src/pgn.ts` — minimal SAN parser (no external dep)
- `packages/chess/tests/fide-games/` — 5 classic game PGN fixtures + replay tests
Acceptance gate:
```bash
bun test packages/rete # Rete II nodes green
bun test packages/chess/tests/fide-games # 5 games replay to completion
bun run check # exits 0
git tag v0.2.0-phase2
```
Must NOT have: time-travel, UI, localStorage, server, presets, Playwright.
## Phase 3 — Time-Travel + Presets + UI
In-scope deliverables:
- `packages/rete/src/eventlog.ts` — append-only event log with monotonic seq
- `packages/rete/src/snapshot.ts` — Immer snapshots every N ticks
- `packages/rete/src/replay.ts` — replay engine + stateHash() determinism verifier
- `scripts/replay-determinism.ts` — CI replay verifier script
- `packages/chess/src/presets/` — all 15 preset rules with registry
- `packages/chess/src/ui/` — React + Vite chess app:
- `Board.tsx`, `Rules.tsx`, `SavePanel.tsx`, `ImportExport.tsx`
- `packages/chess/src/persist/` — autosave.ts + io.ts
- `packages/chess/e2e/full-flow.spec.ts` — end-to-end UI scenario
Acceptance gate:
```bash
bun run playwright test packages/chess/e2e/full-flow.spec.ts # green
bun run scripts/replay-determinism.ts # all hashes match
bun run check # exits 0
git tag v0.3.0-phase3
```
Must NOT have: WebSocket server, multiplayer, networked rooms.
## Phase 4 — Authoritative Multiplayer
In-scope deliverables:
- `packages/server/src/index.ts` — Bun HTTP+WS server
- `packages/server/src/protocol.ts` — zod message schemas
- `packages/server/src/rooms.ts` — RoomRegistry (6-char codes)
- `packages/server/src/middleware.ts` — rate limiting, origin allow-list, 64KB cap
- `packages/server/src/game-session.ts` — authoritative engine session per room
- `packages/server/src/broadcast.ts` — move validation + fact-delta broadcast
- `packages/server/src/reconnect.ts` — 60s grace reconnection
- `packages/server/src/logging.ts` — pino + Prometheus metrics
- `packages/chess/src/net/client.ts` — WS client with reconnect + seq ack
- `packages/chess/src/net/prediction.ts` — client prediction + reconciliation
- `packages/chess/src/ui/Lobby.tsx` — room create/join UI
- `packages/chess/e2e/multiplayer.spec.ts` — two-browser multiplayer scenario
- `scripts/ws-client.ts` — scripted WS client for integration tests
Acceptance gate:
```bash
bun run playwright test packages/chess/e2e/multiplayer.spec.ts # green
bun run test:integration # WebSocket handshake + move exchange + reconnect
bun run check # exits 0
git tag v0.4.0-phase4
```
Must NOT have: spectators, AI opponent, server-side persistence across restart, mid-game rule toggle, accounts.
## Non-Goals (v1)
The following are explicitly out of scope for v1. Any agent that adds these will be considered scope-creep:
- Chess AI (Stockfish, minimax, MCTS, opening books)
- Puzzles, tutorials, analysis mode, engine evaluation
- ELO rating, matchmaking, tournaments, leaderboards
- Social features: chat, emotes, friend lists, profiles, avatars
- Rule marketplace, remote rule repository, user-authored JS rule upload
- Mid-game rule toggle (toggle only allowed between games)
- Spectators (2-player rooms only in v1)
- Server-side game persistence across server restart
- Mobile-native clients (responsive web only, no React Native / Capacitor)
- User accounts, OAuth, email/password, session tokens, analytics
- Telemetry, crash reporting, A/B testing, feature flags
- Internationalization (i18n), accessibility beyond keyboard-playable chess
- Additional games on the engine (no "build another game as proof")
- Visual rule editor / node graph editor (preset toggle only)
- Pararules Nim macro equivalents via runtime code-gen or eval
- External Rete library dependency (greenfield)
- chess.js or any hardcoded FIDE chess library
- Stockfish.wasm or any external chess engine
- Persistent user data beyond localStorage
## Performance Budgets
These are contractual. CI enforces bundle size. Unit tests enforce microsecond budgets.
| Metric | Budget | Enforcement |
|--------|--------|-------------|
| `session.insert(fact)` | < 0.5ms @ 10,000 facts | Vitest benchmark |
| `session.fireRules()` | < 5ms for chess ruleset (64 rules, ~100 facts) | Vitest benchmark |
| Replay 1,000 events | < 500ms | `scripts/replay-determinism.ts` |
| Alpha dispatch 10,000 inserts | < 50ms | Alpha network benchmark test |
| 3-condition join on 100 entities | < 10ms | Join benchmark test |
| 1,000 Immer snapshots memory | < 10MB (structural sharing) | Snapshot memory test |
| Engine bundle (`@paratype/rete`) | < 50KB min+gzip | size-limit in CI |
| Chess bundle (`@paratype/chess`) | < 200KB min+gzip | size-limit in CI |
| Server broadcast p99 latency | < 50ms per delta | Server integration test |
## Demo Scenarios
One scripted demo per phase that proves the phase deliverable works end-to-end.
**Phase 0 Demo**: Specs and scaffold exist.
```bash
bun install && bun run check # exits 0 with zero errors
cat packages/rete/SPEC.md | grep '^## ' | wc -l # outputs 10
cat packages/chess/RULES.md | grep '^### ' | wc -l # outputs 15
cat packages/server/PROTOCOL.md | grep '^### Message:' | wc -l # outputs ≥8
```
**Phase 1 Demo**: Run pararules golden test suite.
```bash
bun test packages/rete/tests/golden # all green
bun run test:coverage -- packages/rete # "All files | XX% | ... " with ≥90% line
```
**Phase 2 Demo**: Replay Kasparov vs Topalov 1999 (27 moves to spectacular win).
```bash
bun test packages/chess/tests/fide-games/kasparov-topalov-1999.test.ts
# Output: "Kasparov vs Topalov: all 27 moves legal, checkmate detected ✓"
```
**Phase 3 Demo**: Full UI flow.
```bash
bun run dev --filter @paratype/chess &
bun x playwright test packages/chess/e2e/full-flow.spec.ts --headed # watch in browser
```
**Phase 4 Demo**: Two-browser multiplayer with reconnect.
```bash
bun run start:server &
bun x playwright test packages/chess/e2e/multiplayer.spec.ts --headed # watch two windows play
```

38
eslint.config.js Normal file
View file

@ -0,0 +1,38 @@
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["**/dist/**", "**/node_modules/**", "**/*.js", "**/*.mjs"],
},
...tseslint.configs.recommended,
{
rules: {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
},
},
// Engine RHS purity: ban impure globals in rete/src
{
files: ["packages/rete/src/**/*.ts"],
rules: {
"no-restricted-globals": [
"error",
{ name: "Date", message: "RHS purity: use session.now() instead" },
{ name: "performance", message: "RHS purity: forbidden in engine" },
{ name: "setTimeout", message: "RHS purity: forbidden in engine" },
{ name: "setInterval", message: "RHS purity: forbidden in engine" },
{ name: "clearTimeout", message: "RHS purity: forbidden in engine" },
{ name: "clearInterval", message: "RHS purity: forbidden in engine" },
{ name: "fetch", message: "RHS purity: forbidden in engine" },
{ name: "console", message: "RHS purity: use debug flag instead" },
],
"no-restricted-syntax": [
"error",
{
selector: "MemberExpression[object.name='Math'][property.name='random']",
message: "RHS purity: Math.random() forbidden in engine",
},
],
},
}
);

4
lefthook.yml Normal file
View file

@ -0,0 +1,4 @@
pre-commit:
commands:
check:
run: bun run check

27
package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "@paratype/monorepo",
"private": true,
"version": "0.0.0",
"type": "module",
"workspaces": ["packages/*"],
"scripts": {
"check": "bun run typecheck && bun run lint && bun run test",
"typecheck": "tsc -b",
"lint": "eslint .",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"build": "bun run --filter '*' build",
"size-limit": "echo 'size-limit: TODO wire after build'"
},
"devDependencies": {
"@playwright/test": "^1.52.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^3.0.0",
"eslint": "^9.0.0",
"lefthook": "^1.10.0",
"typescript": "^5.7.0",
"typescript-eslint": "^8.0.0",
"vitest": "^3.0.0"
}
}

1
packages/chess/README.md Normal file
View file

@ -0,0 +1 @@
# @paratype/chess — custom-rules chess demo

371
packages/chess/RULES.md Normal file
View file

@ -0,0 +1,371 @@
# Preset Custom Rules
This document is the authoritative specification for the 15 preset custom chess rules shipped with the `@rules/chess` package (v1). Each rule is implementable as a set of Rete II productions that either *add to* or *override* the base FIDE productions. No rule here requires user-authored JavaScript — all behaviour must be expressible via the declarative production + WME vocabulary defined in the engine spec.
This document drives implementation tasks **P3.4 P3.8**.
## Conventions
- **ID**: kebab-case, globally unique, used as the registry key in `packages/chess/src/presets/`.
- **Mode**:
- `additive` — the rule adds *new* productions without retracting any base production.
- `override` — the rule retracts (or short-circuits via higher salience) one or more base FIDE productions and installs replacements.
- `hybrid` — both additive and override effects.
- **Requires**: other preset IDs that must also be active (dependencies form a DAG — no cycles).
- **Incompatible With**: preset IDs that produce contradictory WMEs or retract productions the other relies on.
- **HP meta-rule**: the `piece-hp` preset extends the chess schema (`packages/chess/src/presets/piece-hp.ts`) with an `hp: number` attribute on every `Piece` WME, and replaces the FIDE capture production. Any rule that reads or writes HP must declare `Requires: piece-hp`.
## Overview
| # | ID | Name | Category |
|---|----|------|----------|
| 1 | `pawns-move-backward` | Backward-Marching Pawns | Movement Modifier |
| 2 | `double-pawn-sprint` | Perpetual Sprint | Movement Modifier |
| 3 | `pawn-diagonal-no-capture` | Slanting Pawns | Movement Modifier |
| 4 | `knights-leap-twice` | Double-Leap Knights | Movement Modifier |
| 5 | `bishops-ignore-color` | Colour-Blind Bishops | Movement Modifier |
| 6 | `rook-warp` | Rook Warp | Board Geometry |
| 7 | `wrap-board` | Cylindrical Board | Board Geometry |
| 8 | `queen-splits` | Queen Fission | Piece Ability |
| 9 | `king-heals` | Regenerating King | Piece Ability |
| 10 | `explosive-rook` | Detonating Rook | Piece Ability |
| 11 | `capture-to-win` | First Blood | Win/Loss Condition |
| 12 | `last-piece-standing` | Annihilation | Win/Loss Condition |
| 13 | `piece-hp` | Hit Points | Persistent State |
| 14 | `knight-immunity` | Bishop-Proof Knights | Piece Ability |
| 15 | `poisoned-squares` | Poisoned Centre | Persistent State |
## Rule Definitions
### Backward-Marching Pawns
**ID**: `pawns-move-backward`
**Category**: Movement Modifier
**Description**: Pawns may, in addition to their normal forward moves, move exactly one square straight backward to an empty square. Backward moves may **not** capture and do **not** enable *en passant*. A pawn that has moved backward is still eligible for promotion once it later reaches the opposite last rank.
**Base Rule Affected**: `fide.pawn-single-push` (additive — adds a new production `preset.pawn-backward-push`, does not retract the forward push).
**Mode**: additive
**Requires**: none
**Incompatible With**: `double-pawn-sprint`
**Test Scenarios**:
1. White pawn on e4, e3 empty → `e4→e3` is a legal move.
2. White pawn on e4, enemy piece on d3 → `e4→d3` is **not** legal (no backward captures).
3. White pawn on e4, white piece on e3 → `e4→e3` is **not** legal (destination occupied).
**Edge Cases**:
- Backward move does **not** create an *en passant* target square (the FIDE `EnPassantTarget` WME is only asserted by the double-push production).
- A pawn that moves backward to its home rank may *not* then perform a double forward sprint on the following turn (home-rank privilege is tracked by a `HasMoved` WME, which is asserted on the pawn's first move of any kind).
- Backward moves do not count toward the fifty-move rule's "pawn move" reset — they **do** reset it, same as any pawn move.
---
### Perpetual Sprint
**ID**: `double-pawn-sprint`
**Category**: Movement Modifier
**Description**: A pawn may advance two squares straight forward from *any* rank, not only its home rank, provided both intervening squares are empty. The resulting move still asserts an *en passant* target on the skipped square for exactly one opponent turn.
**Base Rule Affected**: `fide.pawn-double-push` (override — removes the `HasMoved = false` guard).
**Mode**: override
**Requires**: none
**Incompatible With**: `pawns-move-backward`
**Test Scenarios**:
1. White pawn on e4, e5 and e6 empty → `e4→e6` is legal and asserts `EnPassantTarget(e5)`.
2. Black pawn on d5 after white plays `e4→e6` → Black `d5×e6` is **not** en-passant-legal; but `d5×e5` **is** legal en passant (standard FIDE consequence).
3. White pawn on e7, e8 is the promotion rank → `e7→e8` single push with promotion is legal; `e7→e9` is illegal (off board, not a "two-square" candidate).
**Edge Cases**:
- The *en passant* window remains one ply, exactly as in FIDE.
- A double sprint that jumps *over* a piece is illegal — both intervening squares must be empty (the slide-check production is retained).
- This rule is incompatible with `pawns-move-backward` because the two-square move is ambiguous when combined with an unrestricted backward step (e.g. e4→e2 vs e4→e6 would both be legal, breaking the pawn's identity as "forward-only").
---
### Slanting Pawns
**ID**: `pawn-diagonal-no-capture`
**Category**: Movement Modifier
**Description**: Pawns may move one square diagonally forward to an *empty* square without capturing, in addition to all normal pawn moves. Diagonal moves onto occupied enemy squares still capture exactly as in FIDE; the new behaviour is *only* the non-capturing diagonal slide.
**Base Rule Affected**: `fide.pawn-capture` (additive — adds a new production `preset.pawn-diagonal-quiet`).
**Mode**: additive
**Requires**: none
**Incompatible With**: none
**Test Scenarios**:
1. White pawn on e4, d5 and f5 empty → both `e4→d5` and `e4→f5` are legal quiet moves.
2. White pawn on e4, d5 occupied by a black piece → `e4→d5` is a legal capture (normal FIDE behaviour, unchanged).
3. White pawn on e4, d5 occupied by a *white* piece → `e4→d5` is illegal (cannot move onto a friendly piece — neither quiet nor capture).
**Edge Cases**:
- A quiet diagonal move does **not** create an *en passant* target.
- Diagonal quiet moves may still deliver check / checkmate and may still promote if they land on the last rank.
- *En passant* itself is unchanged: a black pawn on d5 may still capture white's e-pawn en passant after `e2→e4`, even though the diagonal-quiet variant offers `e5→d6` or `e5→f6` as non-capturing alternatives.
---
### Double-Leap Knights
**ID**: `knights-leap-twice`
**Category**: Movement Modifier
**Description**: A knight makes two consecutive L-shaped leaps in a single turn. The knight must land on a square reachable by the composition of two knight moves, and the *intermediate* square must be empty (it is "flown over" but the knight briefly stops there for legality purposes). The knight may optionally stop after the first leap — any single-leap destination is still legal.
**Base Rule Affected**: `fide.knight-move` (hybrid — retains the single-leap production and adds `preset.knight-double-leap`).
**Mode**: hybrid
**Requires**: none
**Incompatible With**: none
**Test Scenarios**:
1. White knight on b1, all squares empty → `b1→a3→c4` is legal (lands on c4); `b1→c4` alone is **not** a legal single-leap destination, so the double-leap is required.
2. White knight on b1, a3 occupied by a friendly pawn → `b1→?→c4` via a3 is illegal (intermediate square must be empty); other two-leap paths to the same final square (e.g. `b1→c3→a4`) must be evaluated independently.
3. White knight on b1, enemy piece on c3 → `b1→c3` single-leap capture is legal; `b1→c3→?` two-leap is illegal because the intermediate square must be empty, not occupied by an enemy.
**Edge Cases**:
- The intermediate square is **not** captured: a knight cannot "capture on the way through". Only the final landing square's occupant (if enemy) is captured.
- Double-leap may deliver check from squares ordinary knight moves cannot reach; the checkmate-detection production uses the full two-leap move generator when this rule is active.
- Double-leap does **not** allow the knight to end on its starting square (a→b→a paths are pruned as null moves).
---
### Colour-Blind Bishops
**ID**: `bishops-ignore-color`
**Category**: Movement Modifier
**Description**: A bishop may, in addition to its normal diagonal slides, make a single one-square orthogonal step (N/S/E/W) to an empty or enemy-occupied square. This lets a bishop change diagonal colour over multiple turns and effectively reach every square on the board.
**Base Rule Affected**: `fide.bishop-move` (additive — adds `preset.bishop-orthogonal-step`).
**Mode**: additive
**Requires**: none
**Incompatible With**: none
**Test Scenarios**:
1. White bishop on c1, d1 empty → `c1→d1` is a legal one-square orthogonal step.
2. White bishop on c1, b1 occupied by white knight → `c1→b1` is illegal (friendly blocker).
3. White bishop on c1, b2 occupied by an enemy pawn → `c1×b2` is legal as the normal diagonal capture; `c1→c2` is legal as a quiet orthogonal step if c2 is empty.
**Edge Cases**:
- The orthogonal step is exactly one square — bishops do **not** gain full rook mobility. A bishop on c1 cannot reach c3 in one move unless via its native diagonal.
- Orthogonal steps count as bishop moves for the fifty-move rule (not pawn moves, not captures unless capturing).
- A bishop that steps orthogonally onto a square of the opposite colour is now permanently on the new colour until it steps again — the schema does not tag bishops by colour.
---
### Rook Warp
**ID**: `rook-warp`
**Category**: Board Geometry
**Description**: After a rook completes a normal slide, it may optionally "warp" — that is, continue the slide as if the far edge of the board wrapped to the near edge, landing on the first square of the opposite end of the same rank or file that is empty or contains an enemy piece. The warp is a single atomic move, chosen at move-generation time.
**Base Rule Affected**: `fide.rook-move` (hybrid — retains base slide and adds `preset.rook-warp-slide`).
**Mode**: hybrid
**Requires**: none
**Incompatible With**: `wrap-board`
**Test Scenarios**:
1. White rook on a1, entire 1st rank empty → `a1→h1` is legal normal slide; `a1⇒h1` via warp is also legal but produces the same destination, so only the normal move is listed.
2. White rook on a4, all squares a5..a8 empty, a1..a3 empty, h4 occupied by enemy → slide `a4→a8` is legal normal; warp `a4⇒h4` is a legal capture (the slide from a4 up to a8, then wrapping down from a1 to h4).
3. White rook on d4, d5..d8 empty, d1..d3 empty, but d1 becomes the warp continuation point occupied by a friendly piece → warp is blocked at the friendly piece, so the warp destination is the last empty square before it.
4. White rook on d4 with enemy on d8, friendly on d1 → normal slide captures d8; warp path (d5..d8..wraps..d1..d3) is blocked immediately at d1 after wrapping around, so no warp capture beyond d8 is possible.
**Edge Cases**:
- Castling is unchanged. A rook that has warped once still has `HasMoved = true` and so cannot castle.
- A rook cannot warp *through* its own king; the warp path is interrupted by any friendly piece and by any enemy piece (capturing the first enemy encountered).
- Warp and `wrap-board` are incompatible because `wrap-board` makes *every* piece's lateral movement wrap, which collapses the rook-specific warp semantics into ambiguity about when a move is a "rook warp" vs an ordinary wrap-slide.
---
### Cylindrical Board
**ID**: `wrap-board`
**Category**: Board Geometry
**Description**: The board is topologically a vertical cylinder: the a-file and h-file are adjacent. A piece whose lateral movement would leave the board on the a-side re-enters on the h-file at the same rank, and vice versa. Vertical movement (ranks 1 and 8) does **not** wrap.
**Base Rule Affected**: All sliding-piece productions (`fide.rook-move`, `fide.bishop-move`, `fide.queen-move`) and `fide.king-move`, `fide.knight-move`, `fide.pawn-capture` — each is overridden with a wrap-aware successor generator.
**Mode**: override
**Requires**: none
**Incompatible With**: `rook-warp`
**Test Scenarios**:
1. White rook on a4, all squares empty → `a4→h4` is legal via wrap (westward one step); `a4→b4, c4, …` also legal normally.
2. White bishop on a1, diagonal empty → `a1→h2` is legal (one diagonal step westward wraps to h2).
3. White pawn on a5 (black to move with a black pawn on h5) → Black pawn on h5 *may* capture `h5×a6` diagonally through the wrap? **No** — pawn captures wrap: `h5×a6` is legal only if an enemy piece is on a6 (normal capture rule applied modulo wrap). If the white piece is on a6, the capture is legal.
**Edge Cases**:
- Castling: the king's two-square hop does not wrap — a king on e1 cannot castle "around the board" to d1 via wrap. Castling targets remain fixed squares (g1/c1 for White).
- Check detection: a rook on a4 attacks h4 through the wrap; the king-safety production must consider wrap-attacks, so `wrap-board` overrides check detection too.
- A pawn's *en passant* target square is computed modulo wrap — if a black pawn double-sprints from h7 to h5, it can be captured en passant by a white pawn on a5 (moving `a5×h6`) because the wrap makes a5 and h5 laterally adjacent.
---
### Queen Fission
**ID**: `queen-splits`
**Category**: Piece Ability
**Description**: When a queen captures an enemy piece, after the capture resolves the queen is retracted and replaced by a rook and a bishop of the same colour. The rook is placed on the capture square; the bishop is placed on the nearest empty orthogonally- or diagonally-adjacent square (searched in clockwise order N, NE, E, SE, S, SW, W, NW). If no adjacent square is empty, the bishop is forfeit and only the rook remains.
**Base Rule Affected**: `fide.queen-capture` (override — the queen is not placed on the target square; instead the fission production fires).
**Mode**: override
**Requires**: none
**Incompatible With**: none
**Test Scenarios**:
1. White queen on d1, black pawn on d4, d3/d5/c4/e4/c3/e3/c5/e5 all empty → queen captures d4; result: white rook on d4, white bishop on d5 (N is first clockwise empty).
2. White queen on d1 captures black rook on d4; every adjacent square of d4 is occupied by friendly pieces → result: white rook on d4, bishop forfeit (not placed).
3. White queen on h1 captures black pawn on h2 (corner capture); adjacency for h2 is only g1, g2, g3, h1, h3 → bishop is placed on the first empty in clockwise order starting N from h2 (i.e. h3 if empty).
4. White queen gives checkmate by capturing → fission still resolves; if removing the queen and placing a rook on the square no longer delivers checkmate, the game continues (fission is part of the move and is evaluated before legality of the resulting position).
**Edge Cases**:
- If the rook placed on the capture square is pinned (i.e. removing the queen leaves the king in check), the queen's capture was illegal to begin with — the production must back-check using the post-fission board, not the mid-capture board.
- Promotion interaction: a pawn that promotes to a queen and immediately captures (promotion-capture) fissions on the same move: the promotion square receives a rook and the adjacent square receives a bishop.
- Fissioned pieces do **not** retain castling rights; they are newly-materialised.
---
### Regenerating King
**ID**: `king-heals`
**Category**: Piece Ability
**Description**: At the end of a player's turn, if that player's king is **not** in check, the king regenerates 1 HP, up to a maximum of 3. The king starts the game with 1 HP. The king can only be eliminated when its HP reaches 0; a capture (or damage from other rules) decrements HP by 1.
**Base Rule Affected**: `fide.check-resolution` and `fide.king-capture` (hybrid — adds a new end-of-turn production and modifies capture semantics so that the king is a multi-HP piece).
**Mode**: hybrid
**Requires**: `piece-hp`
**Incompatible With**: none
**Test Scenarios**:
1. White king starts the game with HP=1. White plays a turn, ends not in check → at the end of White's turn, king HP becomes 2.
2. White king at HP=3, White ends turn not in check → king HP stays at 3 (cap).
3. White king at HP=2, Black delivers a legal "capture" on the king (only possible because `piece-hp` is active and king cannot be checkmated in the FIDE sense) → king HP becomes 1, game continues. White's next turn ends not in check → HP becomes 2.
**Edge Cases**:
- Being "in check" means *at the end of the turn*, not during it. A king briefly in check mid-move (impossible under FIDE but possible if combined with rules that allow illegal-intermediate positions) does not prevent healing.
- Healing fires *once* per turn-end, regardless of how many pieces threaten the king. If the king is in check the heal production is simply not eligible.
- `king-heals` does **not** disable checkmate detection on its own — checkmate still ends the game when the king has HP=1 and cannot escape; this rule merely gives the king additional hits.
---
### Detonating Rook
**ID**: `explosive-rook`
**Category**: Piece Ability
**Description**: When a rook makes a capture, it *also* removes every other piece (friendly or enemy) on the same rank or same file within a Chebyshev-style distance of 2 squares from the capture square, measured along rank and file only. The capturing rook itself survives and remains on the capture square.
**Base Rule Affected**: `fide.rook-capture` (override — the post-capture production asserts additional retracts).
**Mode**: override
**Requires**: none
**Incompatible With**: `piece-hp`
**Test Scenarios**:
1. White rook captures on d4; black pieces on d3, d5, d6, c4, e4, f4, d2, d7 → after detonation, removed: d3, d5, d6 (d6 is distance 2), c4, e4, f4 (f4 is distance 2), d2 (distance 2). d7 survives (distance 3).
2. White rook captures on d4; own king on d5 → own king is blown up. This is legal (self-detonation is permitted); however if it is the *only* king the game ends with that side losing.
3. White rook captures on a1 corner; pieces on a2, a3, b1, c1 → all four are in range (distances 1, 2, 1, 2) and are all removed; a4 and d1 (distance 3) survive.
**Edge Cases**:
- Detonation does **not** chain: if a captured piece is itself a rook, its capture does not re-trigger. Only the original capturing rook detonates.
- A rook that captures en passant — impossible in FIDE (only pawns do), and not introduced by any other preset — is not a special case.
- Incompatibility with `piece-hp`: when HP is active, captures are non-lethal damage; the detonation semantics ("removes pieces") conflict with "deals 1 HP damage". Rather than redefining, the v1 preset set treats these two as mutually exclusive.
---
### First Blood
**ID**: `capture-to-win`
**Category**: Win/Loss Condition
**Description**: The first player to make a capture — of *any* enemy piece, including pawns — wins the game immediately. Checkmate is disabled; stalemate still results in a draw if no capture is ever available.
**Base Rule Affected**: `fide.checkmate-win`, `fide.stalemate-draw` (override — disables checkmate, retains stalemate; adds `preset.first-capture-win`).
**Mode**: override
**Requires**: none
**Incompatible With**: `last-piece-standing`
**Test Scenarios**:
1. Starting position, White plays `1.e4 d5 2.exd5` → White wins immediately on move 2 (first capture).
2. Game reaches a position with no legal captures for either side and the side to move has no other legal moves → stalemate, draw.
3. White is in check; the only legal response is a capture of the checking piece → White plays the capture and wins (check is not a loss condition under this rule — only capture is a win condition; if White cannot respond, stalemate-by-no-moves rules determine the draw).
**Edge Cases**:
- The king is not special: capturing a pawn wins just as capturing a queen does. Players therefore play extremely cautiously, avoiding any offer of exchange.
- Promotions do not count as captures unless they *are* capture-promotions (a pawn capturing diagonally onto the last rank).
- *En passant* counts as a capture.
- A move that would give up the player's own piece to be captured by the opponent is not itself a capture — only the capturing move triggers the win.
---
### Annihilation
**ID**: `last-piece-standing`
**Category**: Win/Loss Condition
**Description**: A player wins when the opponent has **zero** remaining pieces. The king has no special status: it may be captured like any other piece, and is not subject to check or checkmate. Stalemate is replaced by the losing condition "no legal moves" = loss.
**Base Rule Affected**: `fide.check-detection`, `fide.checkmate-win`, `fide.stalemate-draw`, `fide.king-capture-illegal` (override — all four are retracted and replaced by a single annihilation-win production plus a "no legal moves = loss" production).
**Mode**: override
**Requires**: none
**Incompatible With**: `capture-to-win`
**Test Scenarios**:
1. White has only a king on e1; Black captures it with a queen → Black wins. No "check" warning fires in any prior position.
2. White has king + rook vs Black king. White sacrifices the rook to force Black's king into a position with no legal moves → Black loses by "no legal moves" rule.
3. White has king only; Black has king only → the game is drawn by the threefold-repetition or fifty-move rule eventually; no "insufficient material" automatic draw because kings are normal pieces here.
**Edge Cases**:
- A player may legally move *into* a "check" — there is no such thing as check. This interacts with pinning: pins do not exist either, since they rely on king-safety semantics.
- A side with a king and one other piece that cannot legally move loses. This is different from FIDE stalemate (draw).
- Incompatible with `capture-to-win` because the two rules trigger on opposite conditions (first capture vs last piece) and cannot both be active.
---
### Hit Points
**ID**: `piece-hp`
**Category**: Persistent State
**Description**: Every piece has an integer `hp` attribute. All non-king pieces start at `hp = 2`; the king starts at `hp = 1` (this can be modified by `king-heals`). When a piece is "captured" by another piece's move, the attacker's move resolves as in FIDE (the attacker ends on the target square), but instead of retracting the target, the target's `hp` is decremented by 1. Only when `hp` reaches 0 is the target retracted. If after decrement the target still has `hp > 0`, the attacker and defender "stack" on the same square for resolution purposes — in practice this means the defender is *pushed* to the nearest empty adjacent square (clockwise from N), or retracted if no adjacent empty square exists.
**Base Rule Affected**: `fide.capture` (override — replaces all capture productions with `preset.hp-damage`) and the schema itself (adds the `hp` attribute to the `Piece` WME).
**Mode**: override
**Requires**: none
**Incompatible With**: `explosive-rook`
**Test Scenarios**:
1. White rook captures black pawn (pawn hp=2) → pawn hp becomes 1, white rook ends on target square, black pawn is pushed to the clockwise-nearest empty adjacent square (e.g. N first).
2. White rook captures black pawn already at hp=1 → pawn hp becomes 0, pawn retracted, white rook ends on target square (standard FIDE appearance).
3. White queen captures black knight (hp=2) but every adjacent square to the target is occupied → knight has no push destination and is retracted instead (damage-over-push fallback).
**Edge Cases**:
- *En passant* capture damages the captured pawn on the pawn's actual square (not the en-passant target square). The captured pawn is pushed from its original square if it survives.
- Promotion: a promoting pawn that captures deals 1 damage as normal. The pawn promotes on the target square regardless of whether the target survives.
- This rule is incompatible with `explosive-rook` (see that rule's notes).
- The schema extension (`hp: number`) is defined in `packages/chess/src/presets/piece-hp.ts` and is the single source of truth for HP-related attribute writes; all dependent rules (`king-heals`, `poisoned-squares`) read/write through this extension.
---
### Bishop-Proof Knights
**ID**: `knight-immunity`
**Category**: Piece Ability
**Description**: A bishop may never capture a knight. Any bishop move that would land on a square occupied by an enemy knight is illegal. The bishop may still pass threats through (for check/pin purposes a bishop still "attacks" the square), but it cannot complete a capture on it.
**Base Rule Affected**: `fide.bishop-capture` (override — adds a guard: target must not be `Piece(type=Knight, color=opponent)`).
**Mode**: override
**Requires**: none
**Incompatible With**: none
**Test Scenarios**:
1. White bishop on c1, black knight on h6 along the diagonal, all intermediate empty → `c1×h6` is illegal; bishop may still move to any empty square along the diagonal up to but not including h6 (i.e., ending on g5 is legal, h6 is not).
2. Black bishop gives check to white king via a diagonal that passes through no knight → check is legal and must be resolved normally.
3. Position where the *only* way to block a bishop-check is to capture the bishop with a knight → this is still legal; knight immunity protects knights from bishops, not bishops from knights.
**Edge Cases**:
- For check and checkmate detection purposes, a bishop still *threatens* the knight's square (so a knight cannot "shield" its king from a bishop's ray — the bishop's attack passes through the knight as if it weren't there for threat-detection, but the actual capture move is illegal). Implementation: the bishop's threat-ray is computed ignoring immune pieces; the bishop's move-generation excludes them.
- Promotion to bishop: a pawn that promotes to a bishop is subject to the same restriction — it cannot capture knights on its promotion move if the promotion is a capture onto a knight.
- This rule does **not** restrict queens (which are not bishops) from capturing knights, even along diagonals.
---
### Poisoned Centre
**ID**: `poisoned-squares`
**Category**: Persistent State
**Description**: The four central squares — d4, d5, e4, e5 — are permanently poisoned. Any piece that ends its owner's turn on a poisoned square loses 1 HP. A piece that occupies and then leaves a poisoned square on the same turn is unaffected. Pieces with 0 HP are retracted at end-of-turn evaluation.
**Base Rule Affected**: End-of-turn phase (additive — adds `preset.poison-damage` production that fires in the post-move resolution phase before turn-pass).
**Mode**: additive
**Requires**: `piece-hp`
**Incompatible With**: none
**Test Scenarios**:
1. White rook (hp=2) ends the turn on e4 → rook hp becomes 1 at end-of-turn.
2. White knight (hp=1) ends the turn on d5 → knight hp becomes 0 and is retracted at end-of-turn.
3. White rook on e4 moves to e8 during its turn → no poison damage (rook did not *end* its turn on a poisoned square).
**Edge Cases**:
- The king (hp=1 or higher if `king-heals` is active) takes poison damage too. This can create "king on d4 = dies" positions; combined with `king-heals`, a king on a poisoned square at end-of-turn takes 1 damage *and* heals 1 only if not in check — net zero if not in check, net 1 if in check.
- If a pawn on d5 promotes and the promoted piece ends on d5, the promoted piece suffers poison (promotion does not grant immunity).
- Poison fires exactly once per turn per occupant: a piece cannot take 2 damage for being on a poisoned square for two half-moves, because end-of-turn evaluation is per-player-turn.
- Order of resolution: poison damage resolves *before* `king-heals`, so a king that enters a poisoned square while in check will lose HP from poison and not heal (since it is in check), and may be retracted if hp reaches 0.

View file

@ -0,0 +1,21 @@
{
"name": "@paratype/chess",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@paratype/rete": "workspace:*"
},
"devDependencies": {
"vite": "^6.0.0",
"@vitejs/plugin-react": "^4.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0"
}
}

View file

@ -0,0 +1,2 @@
// @paratype/chess — browser chess game
export {};

View file

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"composite": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*", "e2e/**/*"],
"references": [{ "path": "../rete" }]
}

View file

@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
name: "chess",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
environment: "happy-dom",
},
});

1
packages/rete/README.md Normal file
View file

@ -0,0 +1 @@
# @paratype/rete — Rete II rules engine for TypeScript games

208
packages/rete/SPEC.md Normal file
View file

@ -0,0 +1,208 @@
# `@paratype/rete` — Engine Semantics Specification
This document is the canonical, normative specification of `@paratype/rete`, a Doorenbos-style Rete II rules engine implemented in TypeScript. All implementation, tests, and downstream consumers MUST conform to the semantics defined here. Where the specification disagrees with the implementation, the specification wins and the implementation is a bug.
This is a Phase 0 spec lock. Subsequent phases (engine implementation, chess domain, multiplayer server) depend on the contracts established here.
## Fact Model
Working memory is a strict EAV (Entity-Attribute-Value) store. Every fact is a triple of the form `(id: EntityId, attr: AttrKey, value: AttrValue<S, K>)`, where:
- `id` is a branded numeric entity identifier (see ID Authority).
- `attr` is a string key drawn from a schema-declared set of attribute names.
- `value` is the schema-declared TypeScript type for that attribute.
Each `(id, attr)` pair stores exactly one value. Inserting a fact for an `(id, attr)` pair that already has a value is an **update**: the previous value is replaced, and the engine treats this as a retract-then-insert for the purposes of match invalidation and re-firing (see Match Refraction).
Facts are typed: each `attr` key maps to a specific TypeScript type declared in a session-level schema `S`. The schema is the single source of truth for attribute typing and is consumed by both the public API surface (for type inference at insert/query call sites) and by JSON rule validation (see JSON Rule Schema).
Internally, working memory is stored as `Map<EntityId, Map<AttrKey, unknown>>`. The outer map is keyed by entity id, the inner map is keyed by attribute name, and the value is the schema-typed payload (erased to `unknown` at the storage layer and recovered via the schema at API boundaries). The canonical fact shape exposed by the public API is `{ id: EntityId, attr: AttrKey, value: T }`, where `T` is the schema-declared type for that attr.
This shape is the only fact shape the engine recognises. Composite shapes (objects, tuples) are not facts; they must be decomposed into multiple EAV triples sharing the same `id` before insertion.
## ID Authority
`EntityId` is a branded numeric type:
```
type EntityId = number & { readonly __brand: 'EntityId' }
```
The brand exists at the type level only; at runtime an `EntityId` is a plain `number`. The brand prevents accidental interchange between `EntityId` and ordinary `number` values at compile time.
In a standalone session (no network), IDs are minted by the Session itself via an auto-incrementing counter exposed as `session.nextId(): EntityId`. The counter starts at 1 and increments by 1 on each call. Each call returns a unique, never-reused id for the lifetime of the session.
In the multiplayer server context, ID minting is reserved exclusively to the server. Clients MUST NOT call `session.nextId()` directly. Instead, clients send intents that reference positions, squares, or other domain coordinates, and the server translates these intents into fact mutations using server-minted ids. The client-side session receives facts with server-assigned ids and references them opaquely thereafter.
This split guarantees network-deterministic replay: every replay of the same event log on every peer produces the same id assignments and therefore the same working-memory contents and the same rule firing order.
Derived facts produced by the engine itself (see Truth Maintenance) use a separate negative-id space and are minted by the engine, not by the user or the server.
## Conflict Resolution
When multiple rule activations are pending simultaneously inside a single `fireRules()` call, they are ordered deterministically before any RHS executes. The ordering is a pure function over the activation set and is identical on every machine and every run.
The ordering keys, in priority order:
1. **Salience descending.** Each rule has an integer `salience` (default `0`). Activations whose rule has higher salience fire first.
2. **Specificity descending.** Among activations of equal salience, those whose rule has more conditions (longer `what` / `conditions` array) fire first. Specificity is the cardinality of the rule's condition list at registration time.
3. **Insertion order ascending.** Among activations of equal salience and specificity, those whose rule was added to the session earlier (lower `addedAt` index assigned at registration time) fire first.
There is no randomness. There is no lexicographic ordering on rule names, fact ids, or any other identifier. The only tie-breaker beyond the three keys above is the order in which rules were added to the session, which is itself deterministic given a deterministic registration order.
The conflict resolution function is invoked once per `fireRules()` iteration: pending activations are collected, sorted by `(salience, specificity, +addedAt)`, and fired in that order. New activations produced by an RHS are added to the next iteration's pending set; they do not interleave with the current iteration's already-sorted firing order.
Tests MUST verify this exact ordering by constructing scenarios with deliberate ties at each level.
## Match Refraction
Each unique match fires its rule's RHS exactly once. A "unique match" is identified by the tuple of **fact identities** — not values — bound to the rule's variables at the time of activation. Concretely, a match is keyed by the ordered tuple of `EntityId` values bound to the rule's pattern variables, in the order those variables appear in the rule's condition list.
A match re-fires only when one of the following occurs:
- A fact whose value is bound to one of the rule's variables changes. Updating an `(id, attr)` pair is treated as a retract followed by an insert with the same `(id, attr)`; this invalidates the previous match (removing it from the fired-set) and creates a new match (which is then eligible to fire).
- A previously absent matching fact is inserted, producing a new match for which no entry exists in the fired-set.
This is CLIPS-style refraction. The engine maintains a `Set<MatchKey>`, where `MatchKey` is a stable serialisation of the bound entity ids in the canonical variable order for that rule. Before invoking an RHS, the engine checks whether the candidate match's key is already in the fired-set; if so, the activation is suppressed.
Retracting a fact removes every match that depended on that fact from the fired-set, so that if the same pattern later becomes true again the rule re-fires. This is what makes truth maintenance and re-derivation correct (see Truth Maintenance).
The fired-set is per-rule. Two different rules matching the same fact tuple have independent fired-set entries.
## Iteration Order
All iteration over working-memory facts in hot paths — alpha dispatch, beta join, query result assembly — uses sorted arrays with documented, deterministic sort keys. Iteration over a raw `Set<object>`, `Map<object, …>`, or any unordered structure is forbidden in any code path that affects rule firing order or query result order. Insertion-order iteration of `Set` and `Map` is also forbidden in these paths because it makes firing order depend on insertion history rather than on fact identity, defeating replay determinism.
The canonical sort keys, in priority order:
1. `id` ascending (numeric comparison on the underlying `number`).
2. `attr` ascending (string lexicographic, by JavaScript default string comparison).
Any collection of facts returned by `queryAll()` or by the session-level introspection method `session.allFacts()` is sorted by `[id, attr]` before being returned to the caller. Internal beta-memory tokens — partial matches consisting of multiple bound facts — are sorted by their constituent fact ids in the variable order defined by the rule's condition list, so that any deterministic enumeration of tokens produces the same sequence on every run.
Code review and the `no-impure-rhs` lint family (extended with iteration-order checks) MUST flag any use of `Set` or `Map` iteration in firing-order-sensitive paths.
## Truth Maintenance
Derived facts — facts produced as the conclusion of a `thenFinally`-style production rather than asserted by the user — are logically dependent on the matches that produced them. The engine maintains this dependency explicitly: each derived fact stores the `Set<MatchKey>` of matches that currently support it.
When any supporting match is removed from the fired-set (because a source fact was retracted, or because a condition no longer holds after an update), the derived fact's support set shrinks. When the support set becomes empty, the engine automatically retracts the derived fact. This retraction propagates: if other rules matched against the now-retracted derived fact, their matches are also invalidated, and any further derived facts they supported are likewise re-evaluated.
Derived facts use **negative** `EntityId` values, minted by the engine as `-(counter)` from a counter independent of the user-facing positive-id counter. This separation guarantees that derived ids cannot collide with user-asserted ids and makes derived facts trivially identifiable in logs and diagnostics.
Derived facts are excluded from the serialised event log. Replay re-derives them from the user-asserted facts and the rule set, which preserves both correctness (no stale derivations) and log compactness.
A `thenFinally` production whose match becomes true again after a previous derived fact was retracted will re-derive the fact (with a new negative id), because match refraction's invalidation rule applies to the derived production's match in the same way as for any other rule.
## Cycle Detection
`session.fireRules(opts?: { recursionLimit?: number })` accepts a configurable recursion limit. The default limit is **64**.
The engine tracks "depth" as the number of times `fireRules` has recursively triggered itself. Recursion occurs in two situations:
- In `autoFire: true` mode, when a rule's RHS calls `session.insert()` or `session.retract()`, the engine immediately re-enters `fireRules` to propagate the resulting activations.
- In any mode, when a rule's RHS explicitly calls `session.fireRules()`.
When depth exceeds `recursionLimit`, the engine throws `RecursionLimitExceededError`. The error contains:
- `message`: a human-readable description naming the limit and the depth reached.
- `depth`: the integer depth at which the limit was breached.
- `activationTrace`: an array of the last N rule names that fired, in order, where N is at most 10. This trace is the most recent suffix of the firing history and is intended for diagnosing the cycle.
Setting `recursionLimit: 0` disables the limit entirely. This is a deliberate escape hatch for advanced users who need unbounded fixpoint computations; it is dangerous because infinite loops will hang the host thread, and its use is a smell that warrants review.
In `autoFire: false` mode, the depth counter resets to zero at the start of each explicit `fireRules()` call, so successive top-level `fireRules()` invocations do not accumulate depth across calls. Within a single `fireRules()` call, depth accumulates across all recursive re-entries until the call returns.
## RHS Purity Contract
A Rule Right-Hand Side (RHS) handler — the function registered via `HandlerRegistry.register(name, fn)` and referenced by name from the JSON rule schema — MUST be pure with respect to the engine's notion of determinism. Specifically, an RHS MUST NOT call any of the following:
- `Date.now()`, `new Date()`, `performance.now()`, or any other source of wall-clock or monotonic time.
- `Math.random()`, or any other source of non-deterministic randomness.
- `setTimeout`, `setInterval`, `clearTimeout`, `clearInterval`, or any other timer API.
- `fetch`, `XMLHttpRequest`, `WebSocket`, or any other network I/O.
- `console.log`, `console.warn`, `console.error`, `console.info`, `console.debug`, or any other console API.
- Any DOM API, including `document`, `window`, and event-listener registration.
The only session mutations an RHS is permitted to perform are: `session.insert()`, `session.retract()`, and `session.nextId()` (the last only in standalone, non-multiplayer contexts).
Enforcement is layered:
1. **ESLint.** A custom rule `no-impure-rhs`, defined in `packages/rete/eslint-rules/`, applies to files matching `packages/rete/src/**/rhs/**` and `packages/chess/src/**/handlers/**`. It bans the prohibited globals via `no-restricted-globals` and forbids importing time, random, timer, network, console, and DOM modules. Lint failures block CI.
2. **Dev-mode runtime guard.** When `NODE_ENV !== 'production'`, the engine wraps each RHS invocation in a scope where `Date`, `Math.random`, `setTimeout`, `setInterval`, `fetch`, and `console` are replaced with stubs that throw on access. This catches violations the lint rule misses, including indirect calls through helper functions or third-party libraries pulled into RHS modules.
The combination — static lint plus dynamic guard — gives high confidence that no RHS observed in production was permitted to be impure during development or CI.
Time, randomness, and I/O belong outside the engine: time is provided by the host as a fact (`(world, tick, n)`), randomness is provided as pre-rolled facts produced by a seeded RNG outside the rule firing path, and network/console output is the responsibility of the calling layer.
## JSON Rule Schema
Rules are serialisable to and from JSON using a handler-registry pattern. There is no function-to-string conversion, no `eval`, no `new Function`, and no arbitrary JavaScript embedded in JSON. All executable behaviour — predicates and RHS handlers — is referenced by name and resolved against a registry at deserialisation time.
The v1 JSON rule schema:
```
{
"name": "string (unique rule name within the session)",
"salience": "number (optional, default 0)",
"conditions": [
{
"type": "alpha | negation | existential | ncc | aggregation",
"id": "string | number | null (null = wildcard)",
"attr": "string (attribute key)",
"binding": "string (variable name to bind value to, or null)",
"idBinding": "string (variable name to bind entity id to, or null)"
}
],
"filters": [
{
"predicate": "string (registered predicate name)",
"args": ["JsonValue (static arguments)"]
}
],
"handler": "string (registered handler name)",
"handlerArgs": ["JsonValue (static arguments passed to handler alongside match)"]
}
```
Field semantics:
- `name` MUST be unique within the session; duplicate registration is an error.
- `salience` is consumed by Conflict Resolution; omission is equivalent to `0`.
- `conditions[].type` selects the Rete II node type that handles the condition; `alpha` is the ordinary positive condition, the others correspond to the Phase 2 node types listed in Rete II Reference Target.
- `conditions[].id` is either a literal entity id (number), a variable reference (string starting with `?`), or `null` for wildcard.
- `conditions[].binding` and `conditions[].idBinding` declare variables introduced by this condition; downstream conditions and filters refer to them by name.
- `filters[].predicate` MUST be a name registered in the session's predicate registry; the predicate receives the bound variable values plus `args` and returns a boolean.
- `handler` MUST be a name registered in the session's `HandlerRegistry`; the handler receives the bound match plus `handlerArgs` and may perform the permitted session mutations subject to RHS Purity Contract.
Deserialisation validates every registry reference. If `handler` names a function not present in the `HandlerRegistry`, the engine throws `UnknownHandlerError`. If any `filters[].predicate` names a function not present in the predicate registry, the engine throws `UnknownPredicateError`. Both errors include the offending name and the rule name in their message.
The schema is exported as `RULE_SCHEMA_V1`, a Zod schema, for runtime structural validation prior to registry resolution. Schema-level errors (missing fields, wrong types, unknown `type` values) are reported with Zod's standard issue paths.
This handler-registry design makes rules safely portable across processes, persistable to disk, and shippable over the network without ever transmitting executable code.
## Rete II Reference Target
The canonical reference for the Rete II algorithm implemented by this engine is the **Doorenbos** PhD thesis:
> Doorenbos, R. B. (1995). *Production Matching for Large Learning Systems*. PhD Thesis, Carnegie Mellon University. CMU-CS-95-113.
All node types, memory structures, and algorithmic decisions in this engine trace back to that thesis. Where this specification diverges (notably in Conflict Resolution, ID Authority, and the JSON serialisation surface), the divergence is documented above and is intentional.
The following node types from the Doorenbos thesis are in scope for v1 of this engine:
| Node Type | Phase | Description |
|-----------|-------|-------------|
| `AlphaNode` | Phase 1 | Indexes facts by `(id?, attr)` pattern; feeds an `AlphaMemory`. |
| `AlphaMemory` | Phase 1 | Stores facts matching one alpha pattern. |
| `BetaMemory` | Phase 1 | Stores partial matches (tokens) produced by left activations. |
| `JoinNode` | Phase 1 | Combines left tokens with right alpha facts; performs variable binding and equality checks. |
| `FilterNode` | Phase 1 | Applies registered predicates to tokens; the analog of CLIPS-style `cond` / test nodes. |
| `ProductionNode` | Phase 1 | Terminal node; triggers an RHS handler on each full match (subject to refraction). |
| `DerivedFactProduction` | Phase 1 | The `thenFinally` variant of a production; retracts its derived fact when the supporting match is removed. |
| `NegationNode` | Phase 2 | Passes a token iff zero facts match the negated pattern (NOT). |
| `ExistentialNode` | Phase 2 | Passes a token iff at least one fact matches the pattern (EXISTS). |
| `NccNode` | Phase 2 | Passes a token iff no combination of N conditions matches (negated conjunctive condition). |
| `AggregationNode` | Phase 2 | Computes `count` / `sum` / `min` / `max` / `collect` over matching facts; binds the result to a variable. |
Out of scope for v1: RETE/UL (unlinking), right-unlinking optimisation, sequential mode, and any conflict-set priority queue beyond the three-key sort defined in Conflict Resolution. These may be revisited in later versions if profiling demonstrates a need; until then, the simpler implementation is preferred.

View file

@ -0,0 +1,22 @@
{
"name": "@paratype/rete",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"tsup": "^8.0.0"
}
}

View file

@ -0,0 +1,3 @@
// @paratype/rete — Doorenbos-style Rete II rules engine
// Phase 1 implementation begins here
export {};

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": true
},
"include": ["src/**/*"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
name: "rete",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
},
});

291
packages/server/PROTOCOL.md Normal file
View file

@ -0,0 +1,291 @@
# Chess Server WebSocket Protocol v1
## Overview
All WebSocket messages are JSON. Every message includes:
- `v: 1` — protocol version; mismatch triggers hard disconnect
- `seq: number` — monotonic sequence number (client and server maintain separate counters; client acks server seq)
- `ts: number` — unix milliseconds timestamp of message creation
Maximum message size: 64KB. Exceeding this limit causes hard disconnect with `error` code `MSG_TOO_LARGE`.
The server is AUTHORITATIVE. Clients send INTENTS (what they want to do). The server validates, applies the intent to the engine session, and broadcasts FACTS (what changed) to all room members.
## Connection
WebSocket upgrade URL: `ws://{host}:{port}/ws`
Required query params on upgrade: none (auth happens via first message after connect).
Origin allow-list: Configurable via `ALLOWED_ORIGINS` env var (comma-separated). Requests from unlisted origins receive HTTP 403 before upgrade. Default: `http://localhost:5173` (dev).
## Message Envelope
Every message has this wrapper:
```json
{
"v": 1,
"seq": 42,
"ts": 1745000000000,
"type": "room.create",
"token": "optional-room-token",
"payload": {}
}
```
`token` is required on all messages AFTER the first `room.create` or `room.join`. The first message from a client need not include token.
## Auth & Rooms
- Room codes: 6 characters, uppercase `[A-Z0-9]`, randomly generated
- Room token: UUID v4, returned on `room.create`, required on subsequent messages
- Rooms are in-memory only (lost on server restart)
- Maximum 2 players per room
- 60-second reconnection grace window after disconnect
### Message: room.create
Direction: Client → Server
Purpose: Create a new game room. Server responds with room code and auth token.
Request payload:
```json
{
"rulesetIds": ["pawns-move-backward", "piece-hp"]
}
```
- `rulesetIds`: optional array of preset rule IDs to activate for this game
Response (Server → Client, type `room.created`):
```json
{
"v": 1, "seq": 1, "ts": 1745000000000,
"type": "room.created",
"payload": {
"code": "ABC123",
"token": "550e8400-e29b-41d4-a716-446655440000",
"color": "white"
}
}
```
Error cases:
- Server at capacity (too many rooms): `error` code `SERVER_FULL`
### Message: room.join
Direction: Client → Server
Purpose: Join an existing room as the second player.
Request payload:
```json
{
"code": "ABC123"
}
```
Response (Server → Client, type `room.joined`):
```json
{
"v": 1, "seq": 1, "ts": 1745000000000,
"type": "room.joined",
"payload": {
"code": "ABC123",
"token": "661f9500-f30c-52e5-b827-557766550111",
"color": "black",
"activeRules": ["pawns-move-backward"]
}
}
```
When second player joins, server broadcasts `game.state` to BOTH players (initial board state).
Error cases:
- Room not found: `error` code `ROOM_NOT_FOUND`
- Room full (2 players already): `error` code `ROOM_FULL`
- Wrong protocol version: disconnect + `error` code `VERSION_MISMATCH`
### Message: room.leave
Direction: Client → Server
Purpose: Voluntarily leave a room / concede.
Request payload: `{}`
Server broadcasts `game.end` with `reason: "player_left"` to remaining player.
### Message: game.move
Direction: Client → Server
Purpose: Express a move intent. Server validates and applies if legal.
Request payload:
```json
{
"from": "e2",
"to": "e4",
"promoteTo": "queen"
}
```
- `from`, `to`: algebraic square notation (a1h8)
- `promoteTo`: optional, only relevant for pawn promotion; one of `"queen"`, `"rook"`, `"bishop"`, `"knight"`
On valid move: Server applies to engine, broadcasts `game.delta` to both players.
On invalid move: Server sends `error` (code `ILLEGAL_MOVE`) to mover only; no broadcast.
Error cases:
- Not your turn: `error` code `NOT_YOUR_TURN`
- Illegal move: `error` code `ILLEGAL_MOVE`
- Game already over: `error` code `GAME_OVER`
### Message: game.state
Direction: Server → Client
Purpose: Full board state snapshot. Sent on room join and on reconnect.
Payload:
```json
{
"fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
"facts": [
{ "id": 1, "attr": "PieceType", "value": "pawn" },
{ "id": 1, "attr": "Color", "value": "white" },
{ "id": 1, "attr": "Position", "value": 12 }
],
"activeRules": ["pawns-move-backward"],
"turn": "black",
"lastSeq": 42,
"moveHistory": ["e2-e4"]
}
```
- `fen`: standard FEN string for UI rendering convenience
- `facts`: all non-derived working-memory facts
- `lastSeq`: highest server seq the client should ack from (used for reconnect delta)
### Message: game.delta
Direction: Server → Client
Purpose: Incremental fact changes after each move. Sent to BOTH players after valid move.
Payload:
```json
{
"inserted": [
{ "id": 1, "attr": "Position", "value": 28 }
],
"retracted": [
{ "id": 1, "attr": "Position", "value": 12 }
],
"moveNotation": "e2e4",
"turn": "black",
"gameOver": null
}
```
- `inserted`: facts added to working memory this tick
- `retracted`: facts removed this tick (identified by id+attr)
- `gameOver`: null if game continues; `{ winner: "white"|"black"|"draw", reason: "checkmate"|"stalemate"|"50-move"|"threefold"|"insufficient"|"player_left" }` when game ends
### Message: game.end
Direction: Server → Client
Purpose: Explicit game-over notification (also included in `game.delta` `gameOver` field, but sent separately for clarity).
Payload:
```json
{
"winner": "white",
"reason": "checkmate",
"finalFen": "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 0 3"
}
```
### Message: error
Direction: Server → Client
Purpose: Report validation failures, protocol errors, rate limit violations.
Payload:
```json
{
"code": "ILLEGAL_MOVE",
"message": "Move e2-e5 is not legal for white pawn on e2",
"fatal": false
}
```
- `fatal: true` means the connection will be closed immediately after this message (e.g., rate limit, version mismatch, bad auth)
- `fatal: false` means the connection stays open; client may retry
## Reconnection Flow
1. Client disconnects (network drop, tab sleep, etc.)
2. Server starts a 60-second grace timer for that client's slot in the room
3. During grace period: other player continues playing (server holds their moves but doesn't process if no opponent — or processes against a "ghost" state)
4. Client reconnects: sends `room.join` with same code and original token
5. Server validates token matches the disconnected slot
6. Server sends `game.state` (full snapshot) followed by all `game.delta` messages since the client's last acked `seq`
7. Client reconciles local state with the snapshot
8. After 60 seconds: server broadcasts `game.end` with `reason: "player_left"` to the remaining player; room is destroyed
Reconnect message flow:
```
Client Server
|-- room.join (code, token) -->|
|<-- game.state (full snap) ---|
|<-- game.delta (seq 43..50) --| (missed deltas since last ack)
| [game resumes] |
```
## Rate Limiting
Token-bucket algorithm per WebSocket connection:
- Capacity: 20 messages (burst)
- Refill rate: 100 messages per second
- When bucket is empty: connection is closed with `error` code `RATE_LIMIT`, `fatal: true`
Messages counted: all messages from client including heartbeats.
## Security
- **Origin**: Only connections from `ALLOWED_ORIGINS` (env var) are accepted
- **Message size**: Max 64KB; exceeded → disconnect + `error` code `MSG_TOO_LARGE`
- **JSON validation**: Every message validated against Zod schema; malformed JSON → disconnect
- **Token**: Room tokens are UUID v4; expire when room is destroyed; not reusable across rooms
- **No user auth**: v1 has no accounts; rooms are ephemeral; tokens are room-scoped only
- **No server-side persistence**: Game state lives in-memory; server restart destroys all rooms
## Error Codes
| Code | Fatal | Description |
|------|-------|-------------|
| `ILLEGAL_MOVE` | No | Move not in legal move set |
| `NOT_YOUR_TURN` | No | Move attempted when it's opponent's turn |
| `GAME_OVER` | No | Move attempted after game ended |
| `ROOM_NOT_FOUND` | No | Room code doesn't exist or expired |
| `ROOM_FULL` | No | Room already has 2 players |
| `SERVER_FULL` | No | Server at maximum room capacity |
| `VERSION_MISMATCH` | Yes | `v` field doesn't match server's protocol version |
| `RATE_LIMIT` | Yes | Message rate exceeded 100/sec |
| `MSG_TOO_LARGE` | Yes | Message exceeds 64KB |
| `BAD_TOKEN` | Yes | Token missing or invalid for room |
| `INVALID_MESSAGE` | Yes | JSON parse failure or schema validation failure |

View file

@ -0,0 +1 @@
# @paratype/chess-server — authoritative WebSocket server

View file

@ -0,0 +1,15 @@
{
"name": "@paratype/chess-server",
"version": "0.1.0",
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"build": "echo 'server runs via bun directly'",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@paratype/rete": "workspace:*",
"pino": "^9.0.0",
"zod": "^3.23.0"
}
}

View file

@ -0,0 +1,2 @@
// @paratype/chess-server — authoritative Bun WebSocket server
export {};

View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022"],
"composite": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"references": [{ "path": "../rete" }]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
name: "server",
include: ["src/**/*.test.ts"],
},
});

18
playwright.config.ts Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: ".",
testMatch: "**/e2e/**/*.spec.ts",
use: {
baseURL: "http://localhost:5173",
trace: "on-first-retry",
video: "on-first-retry",
},
webServer: {
command: "bun run --filter @paratype/chess dev",
url: "http://localhost:5173",
reuseExistingServer: !process.env.CI,
timeout: 30000,
},
reporter: [["html", { open: "never" }], ["list"]],
});

19
tsconfig.base.json Normal file
View file

@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"strict": true,
"noImplicitAny": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": true,
"esModuleInterop": true
}
}

9
tsconfig.json Normal file
View file

@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"files": [],
"references": [
{ "path": "packages/rete" },
{ "path": "packages/chess" },
{ "path": "packages/server" }
]
}

7
vitest.workspace.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineWorkspace } from "vitest/config";
export default defineWorkspace([
"packages/rete/vitest.config.ts",
"packages/chess/vitest.config.ts",
"packages/server/vitest.config.ts",
]);