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
|
||||
*/
|
||||
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 PieceColor = "white" | "black";
|
||||
export type MoveType = "capture" | "step" | "slide";
|
||||
export type Square = number; // 0..63
|
||||
export type GameStatus = "active" | "checkmate" | "stalemate" | "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.). */
|
||||
export const GAME_ENTITY: EntityId = 0 as EntityId;
|
||||
|
|
@ -56,6 +68,23 @@ export interface ChessAttrMap {
|
|||
CaptureFlags: number;
|
||||
PromotionOverride: PieceType | "disabled";
|
||||
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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue