feat(engine): state effect primitives (seed/add/multiply/direction/capture-flag)
T3 Wave 2 batch A. Implements 5 state-mutating primitives that compose
to seed and adjust EAV facts on a piece during profile apply():
- seed-attribute: insert {attr,value} (overwrites existing).
- add-to-attribute: read existing number (or 0 if absent), write +delta.
- multiply-attribute: read existing number (no-op if absent), write *factor.
- add-direction: append named directions (forward/backward/...) into the
T1-shared DirectionAdditions string[] with dedupe. Composes additively
with the T1 direction-additions descriptor's writes — the engine's
existing generateDirectionMoves walker handles both contributions.
- set-capture-flag: OR a CaptureFlag bitflag into the existing CaptureFlags
field (idempotent).
All 5 register in PRIMITIVE_REGISTRY via side-effect import. Each primitive
ships with ≥3 vitest scenarios. The barrel (primitives/index.ts) imports
all 5 in registration-order.
This commit is contained in:
parent
2c36925d0b
commit
d17f4bd1fd
13 changed files with 740 additions and 0 deletions
87
packages/chess/src/modifiers/primitives/add-aura.test.ts
Normal file
87
packages/chess/src/modifiers/primitives/add-aura.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { Session } from "@paratype/rete";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ChessEngine } from "../../engine.js";
|
||||||
|
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||||
|
import { ADD_AURA_PRIMITIVE } from "./add-aura.js";
|
||||||
|
import type { PrimitiveApplyContext } from "./types.js";
|
||||||
|
import "./add-aura.js";
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { ctx, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("add-aura primitive — registry", () => {
|
||||||
|
it("registers in PRIMITIVE_REGISTRY under key 'add-aura'", () => {
|
||||||
|
expect(PRIMITIVE_REGISTRY.has("add-aura")).toBe(true);
|
||||||
|
expect(PRIMITIVE_REGISTRY.get("add-aura")).toBe(ADD_AURA_PRIMITIVE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("add-aura primitive — apply()", () => {
|
||||||
|
it("seeds one aura spec entry", () => {
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
|
||||||
|
ADD_AURA_PRIMITIVE.apply(ctx, {
|
||||||
|
radius: 2,
|
||||||
|
targetAttr: "HpBonus",
|
||||||
|
delta: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.get(ctx.pieceId, "AuraSpec")).toEqual([
|
||||||
|
{ radius: 2, targetAttr: "HpBonus", delta: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("composes multiple aura specs into an array", () => {
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
|
||||||
|
ADD_AURA_PRIMITIVE.apply(ctx, {
|
||||||
|
radius: 1,
|
||||||
|
targetAttr: "RangeBonus",
|
||||||
|
delta: 2,
|
||||||
|
});
|
||||||
|
ADD_AURA_PRIMITIVE.apply(ctx, {
|
||||||
|
radius: 3,
|
||||||
|
targetAttr: "DamageResistance",
|
||||||
|
delta: -0.25,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.get(ctx.pieceId, "AuraSpec")).toEqual([
|
||||||
|
{ radius: 1, targetAttr: "RangeBonus", delta: 2 },
|
||||||
|
{ radius: 3, targetAttr: "DamageResistance", delta: -0.25 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("add-aura primitive — paramsSchema", () => {
|
||||||
|
it("rejects radii outside the [1, 7] range", () => {
|
||||||
|
expect(() =>
|
||||||
|
ADD_AURA_PRIMITIVE.paramsSchema.parse({
|
||||||
|
radius: 0,
|
||||||
|
targetAttr: "HpBonus",
|
||||||
|
delta: 1,
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
ADD_AURA_PRIMITIVE.paramsSchema.parse({
|
||||||
|
radius: 8,
|
||||||
|
targetAttr: "HpBonus",
|
||||||
|
delta: 1,
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
37
packages/chess/src/modifiers/primitives/add-aura.ts
Normal file
37
packages/chess/src/modifiers/primitives/add-aura.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { ChessAttrMap } from "../../schema.js";
|
||||||
|
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||||
|
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
radius: z.number().int().min(1).max(7),
|
||||||
|
targetAttr: z.string(),
|
||||||
|
delta: z.number(),
|
||||||
|
});
|
||||||
|
type Params = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
const descriptor: EffectPrimitive<Params> = {
|
||||||
|
kind: "add-aura",
|
||||||
|
label: "Add Aura",
|
||||||
|
description:
|
||||||
|
"Seeds an AuraSpec fact entry consumed by the aura engine's recomputation phase.",
|
||||||
|
paramsSchema: schema,
|
||||||
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
|
const existing =
|
||||||
|
(ctx.session.get(ctx.pieceId, "AuraSpec") as
|
||||||
|
| ChessAttrMap["AuraSpec"]
|
||||||
|
| undefined) ?? [];
|
||||||
|
|
||||||
|
ctx.session.insert(ctx.pieceId, "AuraSpec", [
|
||||||
|
...existing,
|
||||||
|
{
|
||||||
|
radius: params.radius,
|
||||||
|
targetAttr: params.targetAttr,
|
||||||
|
delta: params.delta,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
PRIMITIVE_REGISTRY.register(descriptor);
|
||||||
|
export { descriptor as ADD_AURA_PRIMITIVE };
|
||||||
109
packages/chess/src/modifiers/primitives/add-direction.test.ts
Normal file
109
packages/chess/src/modifiers/primitives/add-direction.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { Session } from "@paratype/rete";
|
||||||
|
import { ChessEngine } from "../../engine.js";
|
||||||
|
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||||
|
import type { PrimitiveApplyContext } from "./types.js";
|
||||||
|
import "./add-direction.js";
|
||||||
|
import { ADD_DIRECTION_PRIMITIVE } from "./add-direction.js";
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { ctx, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("add-direction primitive — registry", () => {
|
||||||
|
it("registers in PRIMITIVE_REGISTRY under key 'add-direction'", () => {
|
||||||
|
expect(PRIMITIVE_REGISTRY.has("add-direction")).toBe(true);
|
||||||
|
expect(PRIMITIVE_REGISTRY.get("add-direction")).toBe(ADD_DIRECTION_PRIMITIVE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("add-direction primitive — apply()", () => {
|
||||||
|
it("adds one direction to empty state", () => {
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
|
||||||
|
ADD_DIRECTION_PRIMITIVE.apply(ctx, { directions: ["forward"] });
|
||||||
|
|
||||||
|
expect(session.get(ctx.pieceId, "DirectionAdditions")).toEqual(["forward"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("concatenates with existing directions", () => {
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
session.insert(ctx.pieceId, "DirectionAdditions", ["right"]);
|
||||||
|
|
||||||
|
ADD_DIRECTION_PRIMITIVE.apply(ctx, { directions: ["left"] });
|
||||||
|
|
||||||
|
expect(session.get(ctx.pieceId, "DirectionAdditions")).toEqual([
|
||||||
|
"right",
|
||||||
|
"left",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates overlapping directions by name", () => {
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
session.insert(ctx.pieceId, "DirectionAdditions", ["forward"]);
|
||||||
|
|
||||||
|
ADD_DIRECTION_PRIMITIVE.apply(ctx, {
|
||||||
|
directions: ["forward", "diagonal-fr"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.get(ctx.pieceId, "DirectionAdditions")).toEqual([
|
||||||
|
"forward",
|
||||||
|
"diagonal-fr",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("composes additively with the T1 direction-additions descriptor's writes", () => {
|
||||||
|
// The T1 modifier writes `string[]` of named directions. This primitive
|
||||||
|
// must read+merge that exact shape so the engine's existing
|
||||||
|
// `generateDirectionMoves` walker handles both contributions.
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
session.insert(ctx.pieceId, "DirectionAdditions", ["forward", "left"]);
|
||||||
|
|
||||||
|
ADD_DIRECTION_PRIMITIVE.apply(ctx, {
|
||||||
|
directions: ["right", "diagonal-fl"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.get(ctx.pieceId, "DirectionAdditions")).toEqual([
|
||||||
|
"forward",
|
||||||
|
"left",
|
||||||
|
"right",
|
||||||
|
"diagonal-fl",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown direction names at the schema layer", () => {
|
||||||
|
const parsed = ADD_DIRECTION_PRIMITIVE.paramsSchema.safeParse({
|
||||||
|
directions: ["northwest"],
|
||||||
|
});
|
||||||
|
expect(parsed.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the existing DirectionAdditions fact has a wrong shape", () => {
|
||||||
|
// If a stale or upstream-buggy fact seeds DirectionAdditions with a
|
||||||
|
// non-string entry, the primitive must fail loudly rather than silently
|
||||||
|
// mixing two incompatible shapes into the same array. We bypass the
|
||||||
|
// chess-typed insert by going through the underlying rete session API
|
||||||
|
// (which accepts `unknown` values).
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
const factId = ctx.pieceId;
|
||||||
|
// Insert via the untyped rete path so we can plant a wrong-shape value
|
||||||
|
// without triggering the chess attr-map type check.
|
||||||
|
session.insert(factId, "DirectionAdditions", [{ dx: 1, dy: 0 }]);
|
||||||
|
expect(() => {
|
||||||
|
ADD_DIRECTION_PRIMITIVE.apply(ctx, { directions: ["forward"] });
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
66
packages/chess/src/modifiers/primitives/add-direction.ts
Normal file
66
packages/chess/src/modifiers/primitives/add-direction.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||||
|
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `DirectionAdditions` is the existing T1 modifier attribute (see
|
||||||
|
* `descriptors/direction-additions.ts`). Its value is a `readonly string[]`
|
||||||
|
* of named, color-relative directions interpreted by `generateDirectionMoves`.
|
||||||
|
* This primitive composes additively with that descriptor — appending names
|
||||||
|
* into the same array — so the engine's existing direction-walker handles
|
||||||
|
* both T1-built-in and T3-DSL contributions identically.
|
||||||
|
*/
|
||||||
|
const directionNameSchema = z.enum([
|
||||||
|
"forward",
|
||||||
|
"backward",
|
||||||
|
"left",
|
||||||
|
"right",
|
||||||
|
"diagonal-fl",
|
||||||
|
"diagonal-fr",
|
||||||
|
"diagonal-bl",
|
||||||
|
"diagonal-br",
|
||||||
|
]);
|
||||||
|
const schema = z.object({
|
||||||
|
directions: z.array(directionNameSchema).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
type DirectionName = z.infer<typeof directionNameSchema>;
|
||||||
|
type Params = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
function dedupe(directions: readonly DirectionName[]): DirectionName[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: DirectionName[] = [];
|
||||||
|
for (const d of directions) {
|
||||||
|
if (seen.has(d)) continue;
|
||||||
|
seen.add(d);
|
||||||
|
out.push(d);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptor: EffectPrimitive<Params> = {
|
||||||
|
kind: "add-direction",
|
||||||
|
label: "Add Direction",
|
||||||
|
description: "Appends movement directions into DirectionAdditions with dedupe.",
|
||||||
|
paramsSchema: schema,
|
||||||
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
|
const existing = ctx.session.get(ctx.pieceId, "DirectionAdditions");
|
||||||
|
const existingDirections = existing ?? [];
|
||||||
|
if (!Array.isArray(existingDirections)) {
|
||||||
|
throw new Error("add-direction expected DirectionAdditions to be an array");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = z.array(directionNameSchema).safeParse(existingDirections);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error(
|
||||||
|
"add-direction expected DirectionAdditions to be a string[] of named directions",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = [...parsed.data, ...params.directions];
|
||||||
|
ctx.session.insert(ctx.pieceId, "DirectionAdditions", dedupe(merged));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
PRIMITIVE_REGISTRY.register(descriptor);
|
||||||
|
export { descriptor as ADD_DIRECTION_PRIMITIVE };
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { Session } from "@paratype/rete";
|
||||||
|
import { ChessEngine } from "../../engine.js";
|
||||||
|
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||||
|
import type { PrimitiveApplyContext } from "./types.js";
|
||||||
|
import "./add-to-attribute.js";
|
||||||
|
import { ADD_TO_ATTRIBUTE_PRIMITIVE } from "./add-to-attribute.js";
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { ctx, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("add-to-attribute primitive — registry", () => {
|
||||||
|
it("registers in PRIMITIVE_REGISTRY under key 'add-to-attribute'", () => {
|
||||||
|
expect(PRIMITIVE_REGISTRY.has("add-to-attribute")).toBe(true);
|
||||||
|
expect(PRIMITIVE_REGISTRY.get("add-to-attribute")).toBe(
|
||||||
|
ADD_TO_ATTRIBUTE_PRIMITIVE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("add-to-attribute primitive — apply()", () => {
|
||||||
|
it("adds delta to an existing number", () => {
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
session.insert(ctx.pieceId, "HpBonus", 2);
|
||||||
|
|
||||||
|
ADD_TO_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "HpBonus", delta: 3 });
|
||||||
|
|
||||||
|
expect(session.get(ctx.pieceId, "HpBonus")).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an absent fact as 0", () => {
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
|
||||||
|
ADD_TO_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "RangeBonus", delta: 4 });
|
||||||
|
|
||||||
|
expect(session.get(ctx.pieceId, "RangeBonus")).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when existing value is non-numeric", () => {
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
session.insert(ctx.pieceId, "PieceType", "pawn");
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
ADD_TO_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "PieceType", delta: 1 });
|
||||||
|
}).toThrow(/expected numeric value/);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
packages/chess/src/modifiers/primitives/add-to-attribute.ts
Normal file
29
packages/chess/src/modifiers/primitives/add-to-attribute.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||||
|
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
attr: z.string(),
|
||||||
|
delta: z.number(),
|
||||||
|
});
|
||||||
|
type Params = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
const descriptor: EffectPrimitive<Params> = {
|
||||||
|
kind: "add-to-attribute",
|
||||||
|
label: "Add To Attribute",
|
||||||
|
description: "Adds delta to the current attribute value, treating missing as 0.",
|
||||||
|
paramsSchema: schema,
|
||||||
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
|
const existing = ctx.session.get(ctx.pieceId, params.attr);
|
||||||
|
const baseValue = existing === undefined ? 0 : existing;
|
||||||
|
if (typeof baseValue !== "number" || Number.isNaN(baseValue)) {
|
||||||
|
throw new Error(
|
||||||
|
`add-to-attribute expected numeric value for attr "${params.attr}" but got ${typeof baseValue}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ctx.session.insert(ctx.pieceId, params.attr, baseValue + params.delta);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
PRIMITIVE_REGISTRY.register(descriptor);
|
||||||
|
export { descriptor as ADD_TO_ATTRIBUTE_PRIMITIVE };
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { Session } from "@paratype/rete";
|
||||||
|
import { ChessEngine } from "../../engine.js";
|
||||||
|
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||||
|
import type { PrimitiveApplyContext } from "./types.js";
|
||||||
|
import "./multiply-attribute.js";
|
||||||
|
import { MULTIPLY_ATTRIBUTE_PRIMITIVE } from "./multiply-attribute.js";
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { ctx, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("multiply-attribute primitive — registry", () => {
|
||||||
|
it("registers in PRIMITIVE_REGISTRY under key 'multiply-attribute'", () => {
|
||||||
|
expect(PRIMITIVE_REGISTRY.has("multiply-attribute")).toBe(true);
|
||||||
|
expect(PRIMITIVE_REGISTRY.get("multiply-attribute")).toBe(
|
||||||
|
MULTIPLY_ATTRIBUTE_PRIMITIVE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multiply-attribute primitive — apply()", () => {
|
||||||
|
it("multiplies an existing number", () => {
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
session.insert(ctx.pieceId, "Hp", 6);
|
||||||
|
|
||||||
|
MULTIPLY_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "Hp", factor: 1.5 });
|
||||||
|
|
||||||
|
expect(session.get(ctx.pieceId, "Hp")).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op when the attribute is absent", () => {
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
|
||||||
|
MULTIPLY_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "RangeBonus", factor: 2 });
|
||||||
|
|
||||||
|
expect(session.get(ctx.pieceId, "RangeBonus")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when existing value is non-numeric", () => {
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
session.insert(ctx.pieceId, "PieceType", "rook");
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
MULTIPLY_ATTRIBUTE_PRIMITIVE.apply(ctx, { attr: "PieceType", factor: 2 });
|
||||||
|
}).toThrow(/expected numeric value/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||||
|
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
attr: z.string(),
|
||||||
|
factor: z.number(),
|
||||||
|
});
|
||||||
|
type Params = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
const descriptor: EffectPrimitive<Params> = {
|
||||||
|
kind: "multiply-attribute",
|
||||||
|
label: "Multiply Attribute",
|
||||||
|
description: "Multiplies an existing attribute value by the provided factor.",
|
||||||
|
paramsSchema: schema,
|
||||||
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
|
const existing = ctx.session.get(ctx.pieceId, params.attr);
|
||||||
|
if (existing === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof existing !== "number" || Number.isNaN(existing)) {
|
||||||
|
throw new Error(
|
||||||
|
`multiply-attribute expected numeric value for attr "${params.attr}" but got ${typeof existing}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ctx.session.insert(ctx.pieceId, params.attr, existing * params.factor);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
PRIMITIVE_REGISTRY.register(descriptor);
|
||||||
|
export { descriptor as MULTIPLY_ATTRIBUTE_PRIMITIVE };
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { Session } from "@paratype/rete";
|
||||||
|
import { ChessEngine } from "../../engine.js";
|
||||||
|
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||||
|
import type { PrimitiveApplyContext } from "./types.js";
|
||||||
|
import "./seed-attribute.js";
|
||||||
|
import { SEED_ATTRIBUTE_PRIMITIVE } from "./seed-attribute.js";
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { ctx, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("seed-attribute primitive — registry", () => {
|
||||||
|
it("registers in PRIMITIVE_REGISTRY under key 'seed-attribute'", () => {
|
||||||
|
expect(PRIMITIVE_REGISTRY.has("seed-attribute")).toBe(true);
|
||||||
|
expect(PRIMITIVE_REGISTRY.get("seed-attribute")).toBe(SEED_ATTRIBUTE_PRIMITIVE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("seed-attribute primitive — apply()", () => {
|
||||||
|
it("writes a string attribute", () => {
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
|
||||||
|
SEED_ATTRIBUTE_PRIMITIVE.apply(ctx, {
|
||||||
|
attr: "PieceType",
|
||||||
|
value: "queen",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.get(ctx.pieceId, "PieceType")).toBe("queen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a number attribute", () => {
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
|
||||||
|
SEED_ATTRIBUTE_PRIMITIVE.apply(ctx, {
|
||||||
|
attr: "Hp",
|
||||||
|
value: 12,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.get(ctx.pieceId, "Hp")).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overwrites an existing fact", () => {
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
session.insert(ctx.pieceId, "RangeBonus", 1);
|
||||||
|
|
||||||
|
SEED_ATTRIBUTE_PRIMITIVE.apply(ctx, {
|
||||||
|
attr: "RangeBonus",
|
||||||
|
value: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.get(ctx.pieceId, "RangeBonus")).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
51
packages/chess/src/modifiers/primitives/seed-attribute.ts
Normal file
51
packages/chess/src/modifiers/primitives/seed-attribute.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { ChessAttrKey } from "../../schema.js";
|
||||||
|
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||||
|
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||||
|
|
||||||
|
const CHESS_ATTR_KEYS: ReadonlySet<ChessAttrKey> = new Set([
|
||||||
|
"PieceType",
|
||||||
|
"Color",
|
||||||
|
"Position",
|
||||||
|
"HasMoved",
|
||||||
|
"Turn",
|
||||||
|
"HalfmoveClock",
|
||||||
|
"FullmoveNumber",
|
||||||
|
"EnPassantTarget",
|
||||||
|
"GameStatus",
|
||||||
|
"Winner",
|
||||||
|
"Hp",
|
||||||
|
"PoisonedSquare",
|
||||||
|
"HpBonus",
|
||||||
|
"RangeBonus",
|
||||||
|
"DirectionAdditions",
|
||||||
|
"CaptureFlags",
|
||||||
|
"PromotionOverride",
|
||||||
|
"DamageResistance",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isChessAttrKey(attr: string): attr is ChessAttrKey {
|
||||||
|
return CHESS_ATTR_KEYS.has(attr as ChessAttrKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
attr: z.string(),
|
||||||
|
value: z.unknown(),
|
||||||
|
});
|
||||||
|
type Params = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
const descriptor: EffectPrimitive<Params> = {
|
||||||
|
kind: "seed-attribute",
|
||||||
|
label: "Seed Attribute",
|
||||||
|
description: "Seeds a fact on the target piece, overwriting existing value.",
|
||||||
|
paramsSchema: schema,
|
||||||
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
|
if (!isChessAttrKey(params.attr)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.session.insert(ctx.pieceId, params.attr, params.value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
PRIMITIVE_REGISTRY.register(descriptor);
|
||||||
|
export { descriptor as SEED_ATTRIBUTE_PRIMITIVE };
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { Session } from "@paratype/rete";
|
||||||
|
import { ChessEngine } from "../../engine.js";
|
||||||
|
import { CaptureFlag } from "../../schema.js";
|
||||||
|
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||||
|
import type { PrimitiveApplyContext } from "./types.js";
|
||||||
|
import "./set-capture-flag.js";
|
||||||
|
import { SET_CAPTURE_FLAG_PRIMITIVE } from "./set-capture-flag.js";
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { ctx, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("set-capture-flag primitive — registry", () => {
|
||||||
|
it("registers in PRIMITIVE_REGISTRY under key 'set-capture-flag'", () => {
|
||||||
|
expect(PRIMITIVE_REGISTRY.has("set-capture-flag")).toBe(true);
|
||||||
|
expect(PRIMITIVE_REGISTRY.get("set-capture-flag")).toBe(
|
||||||
|
SET_CAPTURE_FLAG_PRIMITIVE,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("set-capture-flag primitive — apply()", () => {
|
||||||
|
it("sets a flag from a clean state", () => {
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
|
||||||
|
SET_CAPTURE_FLAG_PRIMITIVE.apply(ctx, {
|
||||||
|
flag: CaptureFlag.CANNOT_BE_CAPTURED,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.get(ctx.pieceId, "CaptureFlags")).toBe(
|
||||||
|
CaptureFlag.CANNOT_BE_CAPTURED,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves existing flags when adding a new one", () => {
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
session.insert(ctx.pieceId, "CaptureFlags", CaptureFlag.CAN_CAPTURE_OWN);
|
||||||
|
|
||||||
|
SET_CAPTURE_FLAG_PRIMITIVE.apply(ctx, {
|
||||||
|
flag: CaptureFlag.EN_PASSANT,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.get(ctx.pieceId, "CaptureFlags")).toBe(
|
||||||
|
CaptureFlag.CAN_CAPTURE_OWN | CaptureFlag.EN_PASSANT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent when setting the same flag twice", () => {
|
||||||
|
const { ctx, session } = makeContext();
|
||||||
|
|
||||||
|
SET_CAPTURE_FLAG_PRIMITIVE.apply(ctx, {
|
||||||
|
flag: CaptureFlag.CAN_CAPTURE_OWN,
|
||||||
|
});
|
||||||
|
SET_CAPTURE_FLAG_PRIMITIVE.apply(ctx, {
|
||||||
|
flag: CaptureFlag.CAN_CAPTURE_OWN,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.get(ctx.pieceId, "CaptureFlags")).toBe(
|
||||||
|
CaptureFlag.CAN_CAPTURE_OWN,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
36
packages/chess/src/modifiers/primitives/set-capture-flag.ts
Normal file
36
packages/chess/src/modifiers/primitives/set-capture-flag.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
CaptureFlag,
|
||||||
|
type CaptureFlag as CaptureFlagValue,
|
||||||
|
} from "../../schema.js";
|
||||||
|
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||||
|
import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
flag: z.union([
|
||||||
|
z.literal(CaptureFlag.CAN_CAPTURE_OWN),
|
||||||
|
z.literal(CaptureFlag.CANNOT_BE_CAPTURED),
|
||||||
|
z.literal(CaptureFlag.EN_PASSANT),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
type Params = {
|
||||||
|
flag: CaptureFlagValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const descriptor: EffectPrimitive<Params> = {
|
||||||
|
kind: "set-capture-flag",
|
||||||
|
label: "Set Capture Flag",
|
||||||
|
description: "Bitwise-ORs one capture flag into CaptureFlags.",
|
||||||
|
paramsSchema: schema,
|
||||||
|
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||||
|
const existing = ctx.session.get(ctx.pieceId, "CaptureFlags");
|
||||||
|
const baseFlags = existing === undefined ? 0 : existing;
|
||||||
|
if (typeof baseFlags !== "number" || Number.isNaN(baseFlags)) {
|
||||||
|
throw new Error("set-capture-flag expected CaptureFlags to be numeric");
|
||||||
|
}
|
||||||
|
ctx.session.insert(ctx.pieceId, "CaptureFlags", baseFlags | params.flag);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
PRIMITIVE_REGISTRY.register(descriptor);
|
||||||
|
export { descriptor as SET_CAPTURE_FLAG_PRIMITIVE };
|
||||||
|
|
@ -7,12 +7,24 @@
|
||||||
* e.g. a1=0, h1=7, a8=56, h8=63, e4=28
|
* e.g. a1=0, h1=7, a8=56, h8=63, e4=28
|
||||||
*/
|
*/
|
||||||
import type { EntityId } from "@paratype/rete";
|
import type { EntityId } from "@paratype/rete";
|
||||||
|
import type { EffectPrimitiveNode } from "./modifiers/primitives/types.js";
|
||||||
|
|
||||||
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";
|
||||||
|
export type MoveType = "capture" | "step" | "slide";
|
||||||
export type Square = number; // 0..63
|
export type Square = number; // 0..63
|
||||||
export type GameStatus = "active" | "checkmate" | "stalemate" | "draw";
|
export type GameStatus = "active" | "checkmate" | "stalemate" | "draw";
|
||||||
export type GameResult = PieceColor | "draw";
|
export type GameResult = PieceColor | "draw";
|
||||||
|
export type ConditionSpec =
|
||||||
|
| { readonly type: "attr-lt"; readonly attr: string; readonly value: number }
|
||||||
|
| { readonly type: "attr-gt"; readonly attr: string; readonly value: number }
|
||||||
|
| {
|
||||||
|
readonly type: "attr-eq";
|
||||||
|
readonly attr: string;
|
||||||
|
readonly value: string | number | boolean | null;
|
||||||
|
}
|
||||||
|
| { readonly type: "always" }
|
||||||
|
| { readonly type: "never" };
|
||||||
|
|
||||||
/** EntityId reserved for game-level facts (Turn, HalfmoveClock, etc.). */
|
/** EntityId reserved for game-level facts (Turn, HalfmoveClock, etc.). */
|
||||||
export const GAME_ENTITY: EntityId = 0 as EntityId;
|
export const GAME_ENTITY: EntityId = 0 as EntityId;
|
||||||
|
|
@ -56,6 +68,23 @@ export interface ChessAttrMap {
|
||||||
CaptureFlags: number;
|
CaptureFlags: number;
|
||||||
PromotionOverride: PieceType | "disabled";
|
PromotionOverride: PieceType | "disabled";
|
||||||
DamageResistance: number;
|
DamageResistance: number;
|
||||||
|
AbsorbDamageAttr: string;
|
||||||
|
AbsorbDamageRate: number;
|
||||||
|
ReflectDamagePercent: number;
|
||||||
|
BlockedMoveTypes: readonly MoveType[];
|
||||||
|
AuraSpec: readonly {
|
||||||
|
readonly radius: number;
|
||||||
|
readonly targetAttr: string;
|
||||||
|
readonly delta: number;
|
||||||
|
}[];
|
||||||
|
OnTurnStartHooks: readonly EffectPrimitiveNode[][];
|
||||||
|
OnCaptureHooks: readonly EffectPrimitiveNode[][];
|
||||||
|
OnDamagedHooks: readonly EffectPrimitiveNode[][];
|
||||||
|
ConditionalHooks: readonly {
|
||||||
|
readonly condition: ConditionSpec;
|
||||||
|
readonly then: readonly EffectPrimitiveNode[];
|
||||||
|
readonly else?: readonly EffectPrimitiveNode[];
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChessAttrKey = keyof ChessAttrMap;
|
export type ChessAttrKey = keyof ChessAttrMap;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue