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,
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) {
|
|||
type: "data" 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 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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) {
|
|||
type: "data" 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 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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
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,
|
||||
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 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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ function makeContext(session: Session, pieceId: EntityId) {
|
|||
type: "data" 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,
|
||||
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 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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue