diff --git a/packages/chess/src/modifiers/primitives/add-aura.test.ts b/packages/chess/src/modifiers/primitives/add-aura.test.ts new file mode 100644 index 0000000..58d889b --- /dev/null +++ b/packages/chess/src/modifiers/primitives/add-aura.test.ts @@ -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(); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/add-aura.ts b/packages/chess/src/modifiers/primitives/add-aura.ts new file mode 100644 index 0000000..5962c8e --- /dev/null +++ b/packages/chess/src/modifiers/primitives/add-aura.ts @@ -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; + +const descriptor: EffectPrimitive = { + 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 }; diff --git a/packages/chess/src/modifiers/primitives/add-direction.test.ts b/packages/chess/src/modifiers/primitives/add-direction.test.ts new file mode 100644 index 0000000..d9c6573 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/add-direction.test.ts @@ -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(); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/add-direction.ts b/packages/chess/src/modifiers/primitives/add-direction.ts new file mode 100644 index 0000000..7424b30 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/add-direction.ts @@ -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; +type Params = z.infer; + +function dedupe(directions: readonly DirectionName[]): DirectionName[] { + const seen = new Set(); + const out: DirectionName[] = []; + for (const d of directions) { + if (seen.has(d)) continue; + seen.add(d); + out.push(d); + } + return out; +} + +const descriptor: EffectPrimitive = { + 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 }; diff --git a/packages/chess/src/modifiers/primitives/add-to-attribute.test.ts b/packages/chess/src/modifiers/primitives/add-to-attribute.test.ts new file mode 100644 index 0000000..b061390 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/add-to-attribute.test.ts @@ -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/); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/add-to-attribute.ts b/packages/chess/src/modifiers/primitives/add-to-attribute.ts new file mode 100644 index 0000000..9edbf90 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/add-to-attribute.ts @@ -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; + +const descriptor: EffectPrimitive = { + 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 }; diff --git a/packages/chess/src/modifiers/primitives/multiply-attribute.test.ts b/packages/chess/src/modifiers/primitives/multiply-attribute.test.ts new file mode 100644 index 0000000..e9ed1a0 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/multiply-attribute.test.ts @@ -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/); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/multiply-attribute.ts b/packages/chess/src/modifiers/primitives/multiply-attribute.ts new file mode 100644 index 0000000..53fbb6f --- /dev/null +++ b/packages/chess/src/modifiers/primitives/multiply-attribute.ts @@ -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; + +const descriptor: EffectPrimitive = { + 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 }; diff --git a/packages/chess/src/modifiers/primitives/seed-attribute.test.ts b/packages/chess/src/modifiers/primitives/seed-attribute.test.ts new file mode 100644 index 0000000..c9b4197 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/seed-attribute.test.ts @@ -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); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/seed-attribute.ts b/packages/chess/src/modifiers/primitives/seed-attribute.ts new file mode 100644 index 0000000..f60ac95 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/seed-attribute.ts @@ -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 = 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; + +const descriptor: EffectPrimitive = { + 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 }; diff --git a/packages/chess/src/modifiers/primitives/set-capture-flag.test.ts b/packages/chess/src/modifiers/primitives/set-capture-flag.test.ts new file mode 100644 index 0000000..0c35810 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/set-capture-flag.test.ts @@ -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, + ); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/set-capture-flag.ts b/packages/chess/src/modifiers/primitives/set-capture-flag.ts new file mode 100644 index 0000000..53e9704 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/set-capture-flag.ts @@ -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 = { + 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 }; diff --git a/packages/chess/src/schema.ts b/packages/chess/src/schema.ts index 11f0769..36c96f7 100644 --- a/packages/chess/src/schema.ts +++ b/packages/chess/src/schema.ts @@ -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;