From 3b6f79ac74b9a8c7337563cdb72f98d934b8d626 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 16:58:54 -0600 Subject: [PATCH] feat(chess/modifiers): extend PrimitiveApplyContext with target resolver + event field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new REQUIRED fields to PrimitiveApplyContext that downstream trigger primitives (on-captured target redirection, on-promotion event metadata) need: - target: TargetResolver — 'self' (default) | 'attacker' | 'defender' | { squares: Square[] } | { relation: 'ally' | 'enemy', filter? } — with resolveTargets() helper that walks the session and returns the concrete EntityId list per relation/filter. 'self' returns [ctx.pieceId] for byte-identical behavior in existing primitives. - event: PrimitiveEvent | undefined — discriminated by 'kind': promotion ({promotedFrom, promotedTo}) and capture ({attackerId, defenderId}). 'attacker'/'defender' targets throw a clear error if the event is missing rather than silently no-op'ing. The fields are REQUIRED (not optional) — every construction site must populate defaults explicitly. Production sites (custom/apply.ts and triggers.ts:67) populate target='self', event=undefined; trigger evaluators added later (T12) override per-event. Test fixtures across the 14 existing primitive .test.ts files were updated via AST-grep to add the same defaults (15 helper invocations). Also re-exports TargetResolver from schema.ts so OnCapturedHooks can reference the canonical type from a single source. Earlier wave-1 work landed a placeholder type there; this consolidates around context.ts. Adds context.test.ts with 13 tests covering self, squares, ally/enemy relations with and without pieceType filter, attacker/defender event resolution, and the missing-event error path. --- packages/chess/src/modifiers/custom/apply.ts | 5 + .../absorb-damage-with-attribute.test.ts | 2 + .../src/modifiers/primitives/add-aura.test.ts | 22 +- .../primitives/add-direction.test.ts | 22 +- .../primitives/add-to-attribute.test.ts | 22 +- .../primitives/block-move-type.test.ts | 2 + .../modifiers/primitives/conditional.test.ts | 22 +- .../src/modifiers/primitives/context.test.ts | 230 ++++++++++++++++++ .../chess/src/modifiers/primitives/context.ts | 215 ++++++++++++++++ .../primitives/modify-movement-range.test.ts | 2 + .../primitives/multiply-attribute.test.ts | 22 +- .../modifiers/primitives/on-capture.test.ts | 22 +- .../modifiers/primitives/on-damaged.test.ts | 22 +- .../primitives/on-turn-start.test.ts | 22 +- .../primitives/override-promotion.test.ts | 2 + .../primitives/reflect-damage.test.ts | 2 + .../primitives/seed-attribute.test.ts | 22 +- .../primitives/set-capture-flag.test.ts | 22 +- .../chess/src/modifiers/primitives/types.ts | 28 ++- packages/chess/src/modifiers/triggers.ts | 6 + packages/chess/src/schema.ts | 43 +++- 21 files changed, 648 insertions(+), 109 deletions(-) create mode 100644 packages/chess/src/modifiers/primitives/context.test.ts create mode 100644 packages/chess/src/modifiers/primitives/context.ts diff --git a/packages/chess/src/modifiers/custom/apply.ts b/packages/chess/src/modifiers/custom/apply.ts index 709c764..5a5cf5a 100644 --- a/packages/chess/src/modifiers/custom/apply.ts +++ b/packages/chess/src/modifiers/custom/apply.ts @@ -81,6 +81,11 @@ function walkAndApply(input: { type: descriptor.type, version: descriptor.version, }, + // T1: profile-time apply has no trigger context — default to + // self-target with no event payload. Trigger dispatchers (T12) + // override these when invoking primitives mid-game. + target: "self", + event: undefined, }; runPrimitive(primitive, ctx, node.params); diff --git a/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.test.ts b/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.test.ts index 3f48f61..6df9c40 100644 --- a/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.test.ts +++ b/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.test.ts @@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) { type: "data" as const, version: 1 as const, }, + target: "self" as const, + event: undefined, }; } diff --git a/packages/chess/src/modifiers/primitives/add-aura.test.ts b/packages/chess/src/modifiers/primitives/add-aura.test.ts index 58d889b..6863be7 100644 --- a/packages/chess/src/modifiers/primitives/add-aura.test.ts +++ b/packages/chess/src/modifiers/primitives/add-aura.test.ts @@ -10,16 +10,18 @@ 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, - }, - }; + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-add-aura", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/add-direction.test.ts b/packages/chess/src/modifiers/primitives/add-direction.test.ts index d9c6573..eb8e652 100644 --- a/packages/chess/src/modifiers/primitives/add-direction.test.ts +++ b/packages/chess/src/modifiers/primitives/add-direction.test.ts @@ -10,16 +10,18 @@ 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, - }, - }; + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-add-direction", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/add-to-attribute.test.ts b/packages/chess/src/modifiers/primitives/add-to-attribute.test.ts index b061390..4b160e7 100644 --- a/packages/chess/src/modifiers/primitives/add-to-attribute.test.ts +++ b/packages/chess/src/modifiers/primitives/add-to-attribute.test.ts @@ -10,16 +10,18 @@ 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, - }, - }; + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-add-to-attribute", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/block-move-type.test.ts b/packages/chess/src/modifiers/primitives/block-move-type.test.ts index 8f9d132..12616eb 100644 --- a/packages/chess/src/modifiers/primitives/block-move-type.test.ts +++ b/packages/chess/src/modifiers/primitives/block-move-type.test.ts @@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) { type: "data" as const, version: 1 as const, }, + target: "self" as const, + event: undefined, }; } diff --git a/packages/chess/src/modifiers/primitives/conditional.test.ts b/packages/chess/src/modifiers/primitives/conditional.test.ts index 4f622c7..e1659a2 100644 --- a/packages/chess/src/modifiers/primitives/conditional.test.ts +++ b/packages/chess/src/modifiers/primitives/conditional.test.ts @@ -10,16 +10,18 @@ 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, - }, - }; + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-conditional", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/context.test.ts b/packages/chess/src/modifiers/primitives/context.test.ts new file mode 100644 index 0000000..0e69955 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/context.test.ts @@ -0,0 +1,230 @@ +/** + * Tests for T1's `resolveTargets` helper + PrimitiveEvent shape. + * + * Covers every branch of TargetResolver: + * - 'self' → [ctx.pieceId] + * - { squares } + * - { relation: 'ally' } (excludes self) + * - { relation: 'enemy' } + * - { relation, filter.pieceType } + * - 'attacker' / 'defender' (with + without event → throws) + */ +import { Session } from "@paratype/rete"; +import type { EntityId } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import type { PieceColor, PieceType } from "../../schema.js"; +import { resolveTargets } from "./context.js"; +import type { PrimitiveEvent, TargetResolver } from "./context.js"; +import type { PrimitiveApplyContext } from "./types.js"; + +/** + * Build a minimal session populated with the pieces described by + * `pieces`. Returns the session plus the entity id of the caster + * ("self"). The first entry in `pieces` is treated as the caster. + */ +function buildFixture( + pieces: ReadonlyArray<{ + color: PieceColor; + type: PieceType; + square: number; + }>, +): { ctx: PrimitiveApplyContext; session: Session; ids: EntityId[] } { + const session = new Session(); + const ids: EntityId[] = []; + for (const p of pieces) { + const id = session.nextId(); + session.insert(id, "Color", p.color); + session.insert(id, "PieceType", p.type); + session.insert(id, "Position", p.square); + ids.push(id); + } + const ctx: PrimitiveApplyContext = { + engine: new ChessEngine(), + session, + pieceId: ids[0]!, + depth: 0, + descriptor: { id: "custom:test-context", type: "data", version: 1 }, + target: "self", + event: undefined, + }; + return { ctx, session, ids }; +} + +function withCaptureEvent( + ctx: PrimitiveApplyContext, + attackerId: EntityId, + defenderId: EntityId, +): PrimitiveApplyContext { + const event: PrimitiveEvent = { + kind: "capture", + attackerId, + defenderId, + }; + return { ...ctx, event }; +} + +describe("resolveTargets — 'self'", () => { + it("returns [ctx.pieceId] when target is 'self'", () => { + const { ctx } = buildFixture([ + { color: "white", type: "knight", square: 1 }, + ]); + expect(resolveTargets(ctx, "self")).toEqual([ctx.pieceId]); + }); +}); + +describe("resolveTargets — { squares }", () => { + it("returns piece ids whose Position matches the requested squares", () => { + const { ctx, ids } = buildFixture([ + { color: "white", type: "knight", square: 1 }, // caster — not at e4/d4 + { color: "white", type: "pawn", square: 28 }, // e4 + { color: "black", type: "pawn", square: 27 }, // d4 + { color: "white", type: "rook", square: 0 }, // a1 — not targeted + ]); + const target: TargetResolver = { squares: [28, 27] }; + const resolved = resolveTargets(ctx, target); + // Order is deterministic by first-encounter in allFacts(); both + // ids must appear exactly once. + expect(resolved.length).toBe(2); + expect(new Set(resolved)).toEqual(new Set([ids[1], ids[2]])); + }); + + it("returns [] when no piece occupies any requested square", () => { + const { ctx } = buildFixture([ + { color: "white", type: "knight", square: 1 }, + ]); + expect(resolveTargets(ctx, { squares: [63, 62] })).toEqual([]); + }); +}); + +describe("resolveTargets — { relation: 'ally' }", () => { + it("returns allies EXCLUDING the caster itself", () => { + const { ctx, ids } = buildFixture([ + { color: "white", type: "knight", square: 1 }, // caster + { color: "white", type: "pawn", square: 12 }, // ally pawn + { color: "white", type: "rook", square: 0 }, // ally rook + { color: "black", type: "queen", square: 59 }, // enemy + ]); + const resolved = resolveTargets(ctx, { relation: "ally" }); + expect(resolved.length).toBe(2); + expect(new Set(resolved)).toEqual(new Set([ids[1], ids[2]])); + expect(resolved).not.toContain(ctx.pieceId); + }); +}); + +describe("resolveTargets — { relation: 'enemy' }", () => { + it("returns every piece of the opposite color", () => { + const { ctx, ids } = buildFixture([ + { color: "white", type: "knight", square: 1 }, // caster + { color: "white", type: "pawn", square: 12 }, // ally (excluded) + { color: "black", type: "queen", square: 59 }, // enemy + { color: "black", type: "pawn", square: 52 }, // enemy + ]); + const resolved = resolveTargets(ctx, { relation: "enemy" }); + expect(resolved.length).toBe(2); + expect(new Set(resolved)).toEqual(new Set([ids[2], ids[3]])); + }); +}); + +describe("resolveTargets — { relation, filter: { pieceType } }", () => { + it("returns only ally pawns when filter.pieceType='pawn'", () => { + const { ctx, ids } = buildFixture([ + { color: "white", type: "knight", square: 1 }, // caster + { color: "white", type: "pawn", square: 12 }, // ally pawn ✓ + { color: "white", type: "pawn", square: 13 }, // ally pawn ✓ + { color: "white", type: "rook", square: 0 }, // ally rook ✗ + { color: "black", type: "pawn", square: 52 }, // enemy pawn ✗ + ]); + const resolved = resolveTargets(ctx, { + relation: "ally", + filter: { pieceType: "pawn" }, + }); + expect(resolved.length).toBe(2); + expect(new Set(resolved)).toEqual(new Set([ids[1], ids[2]])); + }); + + it("returns only enemy knights when filter.pieceType='knight'", () => { + const { ctx, ids } = buildFixture([ + { color: "white", type: "queen", square: 3 }, // caster + { color: "black", type: "knight", square: 57 }, // enemy knight ✓ + { color: "black", type: "bishop", square: 58 }, // enemy bishop ✗ + ]); + const resolved = resolveTargets(ctx, { + relation: "enemy", + filter: { pieceType: "knight" }, + }); + expect(resolved).toEqual([ids[1]]); + }); +}); + +describe("resolveTargets — 'attacker'", () => { + it("returns [event.attackerId] when event is a capture", () => { + const { ctx, ids } = buildFixture([ + { color: "white", type: "knight", square: 1 }, // caster (hook owner) + { color: "white", type: "queen", square: 3 }, // attacker + { color: "black", type: "pawn", square: 28 }, // defender + ]); + const withEvt = withCaptureEvent(ctx, ids[1]!, ids[2]!); + expect(resolveTargets(withEvt, "attacker")).toEqual([ids[1]]); + }); + + it("throws a clear error when event is undefined", () => { + const { ctx } = buildFixture([ + { color: "white", type: "knight", square: 1 }, + ]); + expect(() => resolveTargets(ctx, "attacker")).toThrow( + /target='attacker' requires event with attackerId/, + ); + }); + + it("throws when event.kind is not 'capture' (e.g. promotion)", () => { + const { ctx } = buildFixture([ + { color: "white", type: "pawn", square: 48 }, + ]); + const promotionEvt: PrimitiveEvent = { + kind: "promotion", + promotedFrom: "pawn", + promotedTo: "queen", + }; + const withEvt: PrimitiveApplyContext = { ...ctx, event: promotionEvt }; + expect(() => resolveTargets(withEvt, "attacker")).toThrow( + /attackerId/, + ); + }); +}); + +describe("resolveTargets — 'defender'", () => { + it("returns [event.defenderId] when event is a capture", () => { + const { ctx, ids } = buildFixture([ + { color: "white", type: "knight", square: 1 }, // caster + { color: "white", type: "queen", square: 3 }, // attacker + { color: "black", type: "pawn", square: 28 }, // defender + ]); + const withEvt = withCaptureEvent(ctx, ids[1]!, ids[2]!); + expect(resolveTargets(withEvt, "defender")).toEqual([ids[2]]); + }); + + it("throws a clear error when event is undefined", () => { + const { ctx } = buildFixture([ + { color: "white", type: "knight", square: 1 }, + ]); + expect(() => resolveTargets(ctx, "defender")).toThrow( + /target='defender' requires event with defenderId/, + ); + }); +}); + +describe("PrimitiveEvent — promotion variant", () => { + it("carries promotedFrom + promotedTo metadata", () => { + const evt: PrimitiveEvent = { + kind: "promotion", + promotedFrom: "pawn", + promotedTo: "queen", + }; + expect(evt.kind).toBe("promotion"); + if (evt.kind === "promotion") { + expect(evt.promotedFrom).toBe("pawn"); + expect(evt.promotedTo).toBe("queen"); + } + }); +}); diff --git a/packages/chess/src/modifiers/primitives/context.ts b/packages/chess/src/modifiers/primitives/context.ts new file mode 100644 index 0000000..6fe79ca --- /dev/null +++ b/packages/chess/src/modifiers/primitives/context.ts @@ -0,0 +1,215 @@ +/** + * Target redirection + trigger-event metadata for primitives (T1). + * + * Every `PrimitiveApplyContext` carries two read-only fields: + * - `target`: where the primitive's effect should be aimed. Defaults + * to `'self'` at every construction site — meaning `ctx.pieceId`, + * the current-apply target. Existing primitives continue to use + * `ctx.pieceId` directly and are unaffected by this machinery. + * - `event`: optional discriminated-union payload populated by the + * trigger dispatcher that invoked the primitive. Carries the + * metadata a primitive needs to resolve attacker/defender targets + * or inspect promotion details. + * + * This module exports the types and the single centralised resolver + * `resolveTargets(ctx, target)` which walks the session to produce the + * concrete entity list the primitive should mutate. + * + * DESIGN RULES + * - `target` is REQUIRED on the interface (not optional). Construction + * sites explicitly populate `'self'` as their default. Forcing the + * field keeps future dispatchers honest: they cannot forget to + * thread the target through. + * - Target resolution is centralised here. Individual primitives + * NEVER call `session.allFacts()` to find alternate targets — they + * call `resolveTargets()` (or continue to use `ctx.pieceId` when + * their contract is "self-only"). + * - `'attacker'` / `'defender'` require `ctx.event`. Missing event is + * a programmer error (dispatcher forgot to supply metadata), so we + * throw a clear error rather than silently returning `[]`. + */ +import type { EntityId } from "@paratype/rete"; +import type { + ChessAttrMap, + PieceColor, + PieceType, + Square, +} from "../../schema.js"; +import type { PrimitiveApplyContext } from "./types.js"; + +/** + * Discriminated union of trigger-supplied event metadata. Each variant + * is branded by `kind` so the type narrows cleanly at use-sites and so + * future event kinds can extend the union without breaking exhaustive + * `switch`es elsewhere. + */ +export type PrimitiveEvent = + | { + readonly kind: "promotion"; + readonly promotedFrom: PieceType; + readonly promotedTo: PieceType; + } + | { + readonly kind: "capture"; + readonly attackerId: EntityId; + readonly defenderId: EntityId; + }; + +/** + * Describes where a primitive's effect is aimed. + * + * - `'self'` (default) — the current apply target, `ctx.pieceId`. + * - `'attacker'` — requires `ctx.event.kind === 'capture'`; resolves + * to `[event.attackerId]`. + * - `'defender'` — requires `ctx.event.kind === 'capture'`; resolves + * to `[event.defenderId]`. + * - `{ squares }` — resolves to every piece whose `Position` fact + * matches one of the listed squares. Square is the numeric index + * 0..63 used throughout the engine (NOT an algebraic string). + * - `{ relation, filter? }` — walks the session looking at `Color` + * facts. `'ally'` matches the same color as `ctx.pieceId` and + * EXCLUDES `ctx.pieceId` itself; `'enemy'` matches the opposite + * color. Optional `filter.pieceType` further narrows to a piece + * type via the `PieceType` attribute. + */ +export type TargetResolver = + | "self" + | "attacker" + | "defender" + | { readonly squares: readonly Square[] } + | { + readonly relation: "ally" | "enemy"; + readonly filter?: { readonly pieceType?: PieceType }; + }; + +/** + * Resolve a `TargetResolver` against the session, returning the + * concrete list of entity ids the primitive should act on. + * + * Always returns a plural `readonly EntityId[]` — even `'self'` + * returns `[ctx.pieceId]`. Primitives that only ever act on self + * still read `ctx.pieceId` directly for efficiency; this helper is + * for primitives that honour target redirection. + */ +export function resolveTargets( + ctx: PrimitiveApplyContext, + target: TargetResolver, +): readonly EntityId[] { + if (target === "self") { + return [ctx.pieceId]; + } + + if (target === "attacker") { + if (ctx.event === undefined || ctx.event.kind !== "capture") { + throw new Error( + "resolveTargets: target='attacker' requires event with attackerId " + + "(ctx.event must be a capture-kind PrimitiveEvent)", + ); + } + return [ctx.event.attackerId]; + } + + if (target === "defender") { + if (ctx.event === undefined || ctx.event.kind !== "capture") { + throw new Error( + "resolveTargets: target='defender' requires event with defenderId " + + "(ctx.event must be a capture-kind PrimitiveEvent)", + ); + } + return [ctx.event.defenderId]; + } + + if ("squares" in target) { + return resolveBySquares(ctx, target.squares); + } + + // Relation-based resolver. + return resolveByRelation(ctx, target.relation, target.filter?.pieceType); +} + +/** + * Walk `Position` facts and return every piece id whose square is in + * the requested set. Uses `id > 0` to exclude GAME_ENTITY / + * PRESET_STATE_ENTITY facts, matching the convention used by + * `triggers.ts#eachPiece`. + * + * Stable ordering: results are ordered by first-encounter in + * `allFacts()` — callers should not depend on a particular order, but + * the iteration is deterministic per session. + */ +function resolveBySquares( + ctx: PrimitiveApplyContext, + squares: readonly Square[], +): readonly EntityId[] { + const squareSet = new Set(squares); + const out: EntityId[] = []; + const seen = new Set(); + for (const f of ctx.session.allFacts()) { + if (f.attr !== "Position") continue; + if ((f.id as number) <= 0) continue; + if (seen.has(f.id as number)) continue; + const pos = f.value as ChessAttrMap["Position"]; + if (!squareSet.has(pos)) continue; + seen.add(f.id as number); + out.push(f.id); + } + return out; +} + +/** + * Resolve ally/enemy relations relative to `ctx.pieceId`. Ally lists + * EXCLUDE the self piece (a modifier that buffs "allies" shouldn't + * double-dip on the caster). + */ +function resolveByRelation( + ctx: PrimitiveApplyContext, + relation: "ally" | "enemy", + pieceTypeFilter: PieceType | undefined, +): readonly EntityId[] { + const selfColor = ctx.session.get(ctx.pieceId, "Color") as + | PieceColor + | undefined; + if (selfColor === undefined) { + // Caster has no Color fact — nothing resolvable. Return empty + // rather than throw; this path is reachable if a primitive acts on + // a detached entity (e.g. mid-cleanup). + return []; + } + const wantColor: PieceColor = + relation === "ally" + ? selfColor + : selfColor === "white" + ? "black" + : "white"; + + // First pass: collect every entity whose Color matches. We track + // colors per id so a second pass (for pieceType) can look them up. + const candidates: EntityId[] = []; + const seen = new Set(); + for (const f of ctx.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); + if (f.value !== wantColor) continue; + // Exclude self from ally results (spec: "EXCLUDING ctx.pieceId"). + if (relation === "ally" && (f.id as number) === (ctx.pieceId as number)) { + continue; + } + candidates.push(f.id); + } + + if (pieceTypeFilter === undefined) { + return candidates; + } + + // Narrow by PieceType. + const out: EntityId[] = []; + for (const id of candidates) { + const pt = ctx.session.get(id, "PieceType") as PieceType | undefined; + if (pt === pieceTypeFilter) { + out.push(id); + } + } + return out; +} diff --git a/packages/chess/src/modifiers/primitives/modify-movement-range.test.ts b/packages/chess/src/modifiers/primitives/modify-movement-range.test.ts index 9d551f7..e530dae 100644 --- a/packages/chess/src/modifiers/primitives/modify-movement-range.test.ts +++ b/packages/chess/src/modifiers/primitives/modify-movement-range.test.ts @@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) { type: "data" as const, version: 1 as const, }, + target: "self" as const, + event: undefined, }; } diff --git a/packages/chess/src/modifiers/primitives/multiply-attribute.test.ts b/packages/chess/src/modifiers/primitives/multiply-attribute.test.ts index e9ed1a0..85211fe 100644 --- a/packages/chess/src/modifiers/primitives/multiply-attribute.test.ts +++ b/packages/chess/src/modifiers/primitives/multiply-attribute.test.ts @@ -10,16 +10,18 @@ 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, - }, - }; + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-multiply-attribute", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/on-capture.test.ts b/packages/chess/src/modifiers/primitives/on-capture.test.ts index a92ac69..06e9c27 100644 --- a/packages/chess/src/modifiers/primitives/on-capture.test.ts +++ b/packages/chess/src/modifiers/primitives/on-capture.test.ts @@ -10,16 +10,18 @@ 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, - }, - }; + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-on-capture", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/on-damaged.test.ts b/packages/chess/src/modifiers/primitives/on-damaged.test.ts index af47de9..9158446 100644 --- a/packages/chess/src/modifiers/primitives/on-damaged.test.ts +++ b/packages/chess/src/modifiers/primitives/on-damaged.test.ts @@ -10,16 +10,18 @@ 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, - }, - }; + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-on-damaged", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/on-turn-start.test.ts b/packages/chess/src/modifiers/primitives/on-turn-start.test.ts index d332c97..e42f9ed 100644 --- a/packages/chess/src/modifiers/primitives/on-turn-start.test.ts +++ b/packages/chess/src/modifiers/primitives/on-turn-start.test.ts @@ -10,16 +10,18 @@ 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, - }, - }; + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-on-turn-start", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/override-promotion.test.ts b/packages/chess/src/modifiers/primitives/override-promotion.test.ts index dc8eaa9..9cb5645 100644 --- a/packages/chess/src/modifiers/primitives/override-promotion.test.ts +++ b/packages/chess/src/modifiers/primitives/override-promotion.test.ts @@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) { type: "data" as const, version: 1 as const, }, + target: "self" as const, + event: undefined, }; } diff --git a/packages/chess/src/modifiers/primitives/reflect-damage.test.ts b/packages/chess/src/modifiers/primitives/reflect-damage.test.ts index 090ca48..4ccba67 100644 --- a/packages/chess/src/modifiers/primitives/reflect-damage.test.ts +++ b/packages/chess/src/modifiers/primitives/reflect-damage.test.ts @@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) { type: "data" as const, version: 1 as const, }, + target: "self" as const, + event: undefined, }; } diff --git a/packages/chess/src/modifiers/primitives/seed-attribute.test.ts b/packages/chess/src/modifiers/primitives/seed-attribute.test.ts index c9b4197..e83f629 100644 --- a/packages/chess/src/modifiers/primitives/seed-attribute.test.ts +++ b/packages/chess/src/modifiers/primitives/seed-attribute.test.ts @@ -10,16 +10,18 @@ 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, - }, - }; + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-seed-attribute", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/set-capture-flag.test.ts b/packages/chess/src/modifiers/primitives/set-capture-flag.test.ts index 0c35810..d0f5f41 100644 --- a/packages/chess/src/modifiers/primitives/set-capture-flag.test.ts +++ b/packages/chess/src/modifiers/primitives/set-capture-flag.test.ts @@ -11,16 +11,18 @@ 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, - }, - }; + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "custom:test-set-capture-flag", + type: "data", + version: 1, + }, + target: "self", + event: undefined, + }; return { ctx, session }; } diff --git a/packages/chess/src/modifiers/primitives/types.ts b/packages/chess/src/modifiers/primitives/types.ts index df225e3..9909c16 100644 --- a/packages/chess/src/modifiers/primitives/types.ts +++ b/packages/chess/src/modifiers/primitives/types.ts @@ -2,6 +2,7 @@ import type { EntityId, Session } from "@paratype/rete"; import type { ZodType } from "zod"; import type { ChessEngine } from "../../engine.js"; import type { ChessAttrKey } from "../../schema.js"; +import type { PrimitiveEvent, TargetResolver } from "./context.js"; /** * T3 primitive ids (ADR-2). @@ -44,13 +45,38 @@ export interface EffectPrimitiveNode { readonly params: unknown; } -/** Context passed to primitive apply functions. */ +/** + * Context passed to primitive apply functions. + * + * `pieceId` is the CURRENT-apply target — the piece the descriptor is + * being applied to. Existing primitives mutate this entity directly. + * + * `target` / `event` were added in T1 to support target redirection + * (e.g. a trigger that applies its inner primitives to the attacker + * rather than the piece that owns the hook). Both fields are REQUIRED + * at the type level — construction sites populate `target: 'self'` + * and `event: undefined` as defaults so the existing primitives + * continue to behave byte-identically. New primitives that honour + * redirection call `resolveTargets(ctx, ctx.target)` from + * `./context.js` rather than mutating `ctx.pieceId` directly. + */ export interface PrimitiveApplyContext { readonly engine: ChessEngine; readonly session: Session; readonly pieceId: EntityId; readonly depth: number; readonly descriptor: CustomModifierDescriptorRef; + /** + * Where this apply's effect should be aimed. Defaults to `'self'` + * (i.e. `ctx.pieceId`). Populated by trigger dispatchers when the + * originating block used a target-redirection param. + */ + readonly target: TargetResolver; + /** + * Trigger-supplied event metadata. `undefined` for profile-time + * applies and for triggers that don't carry per-event payload. + */ + readonly event: PrimitiveEvent | undefined; } /** diff --git a/packages/chess/src/modifiers/triggers.ts b/packages/chess/src/modifiers/triggers.ts index 1c329ab..71561b0 100644 --- a/packages/chess/src/modifiers/triggers.ts +++ b/packages/chess/src/modifiers/triggers.ts @@ -72,6 +72,12 @@ function runPrimitives( // Trigger evaluation has no parent descriptor — synthesise a // minimal ref so the type contract is satisfied. descriptor: { id: "__trigger__", type: "data", version: 1 }, + // T1: defaults. Real target + event metadata will be threaded + // through by the dispatcher rework in T12; today every trigger + // fires primitives against the hook-owning piece (self) with no + // event payload. + target: "self", + event: undefined, }; primitive.apply(ctx, node.params); diff --git a/packages/chess/src/schema.ts b/packages/chess/src/schema.ts index 7fb5385..96e2dbe 100644 --- a/packages/chess/src/schema.ts +++ b/packages/chess/src/schema.ts @@ -8,6 +8,19 @@ */ import type { EntityId } from "@paratype/rete"; import type { EffectPrimitiveNode } from "./modifiers/primitives/types.js"; +import type { TargetResolver } from "./modifiers/primitives/context.js"; + +// Re-export TargetResolver so consumers can import it from schema.js +// alongside the attribute types it composes with. +export type { TargetResolver }; + +/** + * Filter for selecting squares in OnMovedOntoSquareHooks. + * Discriminated union of static square list or file/rank predicates. + */ +export type SquareFilter = + | { readonly kind: "squares"; readonly squares: readonly Square[] } + | { readonly kind: "predicate"; readonly file?: 0|1|2|3|4|5|6|7; readonly rank?: 0|1|2|3|4|5|6|7 }; export type PieceType = "pawn" | "knight" | "bishop" | "rook" | "queen" | "king"; export type PieceColor = "white" | "black"; @@ -96,14 +109,28 @@ export interface ChessAttrMap { * `computeAuraFacts` — never written directly by primitives. */ AuraContributions: Readonly>; - OnTurnStartHooks: readonly EffectPrimitiveNode[][]; - OnCaptureHooks: readonly EffectPrimitiveNode[][]; - OnDamagedHooks: readonly EffectPrimitiveNode[][]; - ConditionalHooks: readonly { - readonly condition: ConditionSpec; - readonly then: readonly EffectPrimitiveNode[]; - readonly else?: readonly EffectPrimitiveNode[]; - }[]; + OnTurnStartHooks: readonly EffectPrimitiveNode[][]; + OnCaptureHooks: readonly EffectPrimitiveNode[][]; + OnDamagedHooks: readonly EffectPrimitiveNode[][]; + ConditionalHooks: readonly { + readonly condition: ConditionSpec; + readonly then: readonly EffectPrimitiveNode[]; + readonly else?: readonly EffectPrimitiveNode[]; + }[]; + // T3-extension trigger hook attrs (read by triggers.ts evaluators added in T12) + OnMoveHooks: readonly EffectPrimitiveNode[][]; + OnTurnEndHooks: readonly EffectPrimitiveNode[][]; + OnPromotionHooks: readonly EffectPrimitiveNode[][]; + OnCheckReceivedHooks: readonly EffectPrimitiveNode[][]; + OnCheckDeliveredHooks: readonly EffectPrimitiveNode[][]; + OnCapturedHooks: readonly { + readonly target: TargetResolver; + readonly primitives: readonly EffectPrimitiveNode[]; + }[]; + OnMovedOntoSquareHooks: readonly { + readonly filter: SquareFilter; + readonly primitives: readonly EffectPrimitiveNode[]; + }[]; } export type ChessAttrKey = keyof ChessAttrMap;