feat(chess/modifiers): extend PrimitiveApplyContext with target resolver + event field

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.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 16:58:54 -06:00
commit 3b6f79ac74
No known key found for this signature in database
21 changed files with 648 additions and 109 deletions

View file

@ -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);

View file

@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) {
type: "data" as const,
version: 1 as const,
},
target: "self" as const,
event: undefined,
};
}

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) {
type: "data" as const,
version: 1 as const,
},
target: "self" as const,
event: undefined,
};
}

View file

@ -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 };
}

View file

@ -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");
}
});
});

View file

@ -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<Square>(squares);
const out: EntityId[] = [];
const seen = new Set<number>();
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<number>();
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;
}

View file

@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) {
type: "data" as const,
version: 1 as const,
},
target: "self" as const,
event: undefined,
};
}

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) {
type: "data" as const,
version: 1 as const,
},
target: "self" as const,
event: undefined,
};
}

View file

@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) {
type: "data" as const,
version: 1 as const,
},
target: "self" as const,
event: undefined,
};
}

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -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;
}
/**

View file

@ -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);

View file

@ -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<Record<string, number>>;
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;