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:
parent
161bc0e78a
commit
3b6f79ac74
21 changed files with 648 additions and 109 deletions
|
|
@ -81,6 +81,11 @@ function walkAndApply(input: {
|
||||||
type: descriptor.type,
|
type: descriptor.type,
|
||||||
version: descriptor.version,
|
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);
|
runPrimitive(primitive, ctx, node.params);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) {
|
||||||
type: "data" as const,
|
type: "data" as const,
|
||||||
version: 1 as const,
|
version: 1 as const,
|
||||||
},
|
},
|
||||||
|
target: "self" as const,
|
||||||
|
event: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,18 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||||
const session = new Session();
|
const session = new Session();
|
||||||
const pieceId = session.nextId();
|
const pieceId = session.nextId();
|
||||||
const ctx: PrimitiveApplyContext = {
|
const ctx: PrimitiveApplyContext = {
|
||||||
engine: new ChessEngine(),
|
engine: new ChessEngine(),
|
||||||
session,
|
session,
|
||||||
pieceId,
|
pieceId,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
descriptor: {
|
descriptor: {
|
||||||
id: "custom:test-add-aura",
|
id: "custom:test-add-aura",
|
||||||
type: "data",
|
type: "data",
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
};
|
target: "self",
|
||||||
|
event: undefined,
|
||||||
|
};
|
||||||
return { ctx, session };
|
return { ctx, session };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,18 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||||
const session = new Session();
|
const session = new Session();
|
||||||
const pieceId = session.nextId();
|
const pieceId = session.nextId();
|
||||||
const ctx: PrimitiveApplyContext = {
|
const ctx: PrimitiveApplyContext = {
|
||||||
engine: new ChessEngine(),
|
engine: new ChessEngine(),
|
||||||
session,
|
session,
|
||||||
pieceId,
|
pieceId,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
descriptor: {
|
descriptor: {
|
||||||
id: "custom:test-add-direction",
|
id: "custom:test-add-direction",
|
||||||
type: "data",
|
type: "data",
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
};
|
target: "self",
|
||||||
|
event: undefined,
|
||||||
|
};
|
||||||
return { ctx, session };
|
return { ctx, session };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,18 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||||
const session = new Session();
|
const session = new Session();
|
||||||
const pieceId = session.nextId();
|
const pieceId = session.nextId();
|
||||||
const ctx: PrimitiveApplyContext = {
|
const ctx: PrimitiveApplyContext = {
|
||||||
engine: new ChessEngine(),
|
engine: new ChessEngine(),
|
||||||
session,
|
session,
|
||||||
pieceId,
|
pieceId,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
descriptor: {
|
descriptor: {
|
||||||
id: "custom:test-add-to-attribute",
|
id: "custom:test-add-to-attribute",
|
||||||
type: "data",
|
type: "data",
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
};
|
target: "self",
|
||||||
|
event: undefined,
|
||||||
|
};
|
||||||
return { ctx, session };
|
return { ctx, session };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) {
|
||||||
type: "data" as const,
|
type: "data" as const,
|
||||||
version: 1 as const,
|
version: 1 as const,
|
||||||
},
|
},
|
||||||
|
target: "self" as const,
|
||||||
|
event: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,18 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||||
const session = new Session();
|
const session = new Session();
|
||||||
const pieceId = session.nextId();
|
const pieceId = session.nextId();
|
||||||
const ctx: PrimitiveApplyContext = {
|
const ctx: PrimitiveApplyContext = {
|
||||||
engine: new ChessEngine(),
|
engine: new ChessEngine(),
|
||||||
session,
|
session,
|
||||||
pieceId,
|
pieceId,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
descriptor: {
|
descriptor: {
|
||||||
id: "custom:test-conditional",
|
id: "custom:test-conditional",
|
||||||
type: "data",
|
type: "data",
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
};
|
target: "self",
|
||||||
|
event: undefined,
|
||||||
|
};
|
||||||
return { ctx, session };
|
return { ctx, session };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
230
packages/chess/src/modifiers/primitives/context.test.ts
Normal file
230
packages/chess/src/modifiers/primitives/context.test.ts
Normal 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
215
packages/chess/src/modifiers/primitives/context.ts
Normal file
215
packages/chess/src/modifiers/primitives/context.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) {
|
||||||
type: "data" as const,
|
type: "data" as const,
|
||||||
version: 1 as const,
|
version: 1 as const,
|
||||||
},
|
},
|
||||||
|
target: "self" as const,
|
||||||
|
event: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,18 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||||
const session = new Session();
|
const session = new Session();
|
||||||
const pieceId = session.nextId();
|
const pieceId = session.nextId();
|
||||||
const ctx: PrimitiveApplyContext = {
|
const ctx: PrimitiveApplyContext = {
|
||||||
engine: new ChessEngine(),
|
engine: new ChessEngine(),
|
||||||
session,
|
session,
|
||||||
pieceId,
|
pieceId,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
descriptor: {
|
descriptor: {
|
||||||
id: "custom:test-multiply-attribute",
|
id: "custom:test-multiply-attribute",
|
||||||
type: "data",
|
type: "data",
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
};
|
target: "self",
|
||||||
|
event: undefined,
|
||||||
|
};
|
||||||
return { ctx, session };
|
return { ctx, session };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,18 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||||
const session = new Session();
|
const session = new Session();
|
||||||
const pieceId = session.nextId();
|
const pieceId = session.nextId();
|
||||||
const ctx: PrimitiveApplyContext = {
|
const ctx: PrimitiveApplyContext = {
|
||||||
engine: new ChessEngine(),
|
engine: new ChessEngine(),
|
||||||
session,
|
session,
|
||||||
pieceId,
|
pieceId,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
descriptor: {
|
descriptor: {
|
||||||
id: "custom:test-on-capture",
|
id: "custom:test-on-capture",
|
||||||
type: "data",
|
type: "data",
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
};
|
target: "self",
|
||||||
|
event: undefined,
|
||||||
|
};
|
||||||
return { ctx, session };
|
return { ctx, session };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,18 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||||
const session = new Session();
|
const session = new Session();
|
||||||
const pieceId = session.nextId();
|
const pieceId = session.nextId();
|
||||||
const ctx: PrimitiveApplyContext = {
|
const ctx: PrimitiveApplyContext = {
|
||||||
engine: new ChessEngine(),
|
engine: new ChessEngine(),
|
||||||
session,
|
session,
|
||||||
pieceId,
|
pieceId,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
descriptor: {
|
descriptor: {
|
||||||
id: "custom:test-on-damaged",
|
id: "custom:test-on-damaged",
|
||||||
type: "data",
|
type: "data",
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
};
|
target: "self",
|
||||||
|
event: undefined,
|
||||||
|
};
|
||||||
return { ctx, session };
|
return { ctx, session };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,18 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||||
const session = new Session();
|
const session = new Session();
|
||||||
const pieceId = session.nextId();
|
const pieceId = session.nextId();
|
||||||
const ctx: PrimitiveApplyContext = {
|
const ctx: PrimitiveApplyContext = {
|
||||||
engine: new ChessEngine(),
|
engine: new ChessEngine(),
|
||||||
session,
|
session,
|
||||||
pieceId,
|
pieceId,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
descriptor: {
|
descriptor: {
|
||||||
id: "custom:test-on-turn-start",
|
id: "custom:test-on-turn-start",
|
||||||
type: "data",
|
type: "data",
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
};
|
target: "self",
|
||||||
|
event: undefined,
|
||||||
|
};
|
||||||
return { ctx, session };
|
return { ctx, session };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) {
|
||||||
type: "data" as const,
|
type: "data" as const,
|
||||||
version: 1 as const,
|
version: 1 as const,
|
||||||
},
|
},
|
||||||
|
target: "self" as const,
|
||||||
|
event: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) {
|
||||||
type: "data" as const,
|
type: "data" as const,
|
||||||
version: 1 as const,
|
version: 1 as const,
|
||||||
},
|
},
|
||||||
|
target: "self" as const,
|
||||||
|
event: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,18 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||||
const session = new Session();
|
const session = new Session();
|
||||||
const pieceId = session.nextId();
|
const pieceId = session.nextId();
|
||||||
const ctx: PrimitiveApplyContext = {
|
const ctx: PrimitiveApplyContext = {
|
||||||
engine: new ChessEngine(),
|
engine: new ChessEngine(),
|
||||||
session,
|
session,
|
||||||
pieceId,
|
pieceId,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
descriptor: {
|
descriptor: {
|
||||||
id: "custom:test-seed-attribute",
|
id: "custom:test-seed-attribute",
|
||||||
type: "data",
|
type: "data",
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
};
|
target: "self",
|
||||||
|
event: undefined,
|
||||||
|
};
|
||||||
return { ctx, session };
|
return { ctx, session };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,18 @@ function makeContext(): { ctx: PrimitiveApplyContext; session: Session } {
|
||||||
const session = new Session();
|
const session = new Session();
|
||||||
const pieceId = session.nextId();
|
const pieceId = session.nextId();
|
||||||
const ctx: PrimitiveApplyContext = {
|
const ctx: PrimitiveApplyContext = {
|
||||||
engine: new ChessEngine(),
|
engine: new ChessEngine(),
|
||||||
session,
|
session,
|
||||||
pieceId,
|
pieceId,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
descriptor: {
|
descriptor: {
|
||||||
id: "custom:test-set-capture-flag",
|
id: "custom:test-set-capture-flag",
|
||||||
type: "data",
|
type: "data",
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
};
|
target: "self",
|
||||||
|
event: undefined,
|
||||||
|
};
|
||||||
return { ctx, session };
|
return { ctx, session };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { EntityId, Session } from "@paratype/rete";
|
||||||
import type { ZodType } from "zod";
|
import type { ZodType } from "zod";
|
||||||
import type { ChessEngine } from "../../engine.js";
|
import type { ChessEngine } from "../../engine.js";
|
||||||
import type { ChessAttrKey } from "../../schema.js";
|
import type { ChessAttrKey } from "../../schema.js";
|
||||||
|
import type { PrimitiveEvent, TargetResolver } from "./context.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* T3 primitive ids (ADR-2).
|
* T3 primitive ids (ADR-2).
|
||||||
|
|
@ -44,13 +45,38 @@ export interface EffectPrimitiveNode {
|
||||||
readonly params: unknown;
|
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 {
|
export interface PrimitiveApplyContext {
|
||||||
readonly engine: ChessEngine;
|
readonly engine: ChessEngine;
|
||||||
readonly session: Session;
|
readonly session: Session;
|
||||||
readonly pieceId: EntityId;
|
readonly pieceId: EntityId;
|
||||||
readonly depth: number;
|
readonly depth: number;
|
||||||
readonly descriptor: CustomModifierDescriptorRef;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,12 @@ function runPrimitives(
|
||||||
// Trigger evaluation has no parent descriptor — synthesise a
|
// Trigger evaluation has no parent descriptor — synthesise a
|
||||||
// minimal ref so the type contract is satisfied.
|
// minimal ref so the type contract is satisfied.
|
||||||
descriptor: { id: "__trigger__", type: "data", version: 1 },
|
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);
|
primitive.apply(ctx, node.params);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,19 @@
|
||||||
*/
|
*/
|
||||||
import type { EntityId } from "@paratype/rete";
|
import type { EntityId } from "@paratype/rete";
|
||||||
import type { EffectPrimitiveNode } from "./modifiers/primitives/types.js";
|
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 PieceType = "pawn" | "knight" | "bishop" | "rook" | "queen" | "king";
|
||||||
export type PieceColor = "white" | "black";
|
export type PieceColor = "white" | "black";
|
||||||
|
|
@ -96,14 +109,28 @@ export interface ChessAttrMap {
|
||||||
* `computeAuraFacts` — never written directly by primitives.
|
* `computeAuraFacts` — never written directly by primitives.
|
||||||
*/
|
*/
|
||||||
AuraContributions: Readonly<Record<string, number>>;
|
AuraContributions: Readonly<Record<string, number>>;
|
||||||
OnTurnStartHooks: readonly EffectPrimitiveNode[][];
|
OnTurnStartHooks: readonly EffectPrimitiveNode[][];
|
||||||
OnCaptureHooks: readonly EffectPrimitiveNode[][];
|
OnCaptureHooks: readonly EffectPrimitiveNode[][];
|
||||||
OnDamagedHooks: readonly EffectPrimitiveNode[][];
|
OnDamagedHooks: readonly EffectPrimitiveNode[][];
|
||||||
ConditionalHooks: readonly {
|
ConditionalHooks: readonly {
|
||||||
readonly condition: ConditionSpec;
|
readonly condition: ConditionSpec;
|
||||||
readonly then: readonly EffectPrimitiveNode[];
|
readonly then: readonly EffectPrimitiveNode[];
|
||||||
readonly else?: 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;
|
export type ChessAttrKey = keyof ChessAttrMap;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue