diff --git a/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.test.ts b/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.test.ts new file mode 100644 index 0000000..3f48f61 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { ChessEngine } from "../../engine.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE } from "./absorb-damage-with-attribute.js"; + +function makeContext(session: Session, pieceId: EntityId) { + return { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "test-descriptor", + type: "data" as const, + version: 1 as const, + }, + }; +} + +describe("ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE", () => { + it("registers in PRIMITIVE_REGISTRY", () => { + expect(PRIMITIVE_REGISTRY.has("absorb-damage-with-attribute")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("absorb-damage-with-attribute")).toBe( + ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE, + ); + }); + + it("writes both AbsorbDamageAttr and AbsorbDamageRate facts", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "pawn"); + + const params = ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.paramsSchema.parse({ + attr: "ShieldCharges", + rate: 2, + }); + ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.apply(makeContext(session, pieceId), params); + + expect(session.get(pieceId, "AbsorbDamageAttr")).toBe("ShieldCharges"); + expect(session.get(pieceId, "AbsorbDamageRate")).toBe(2); + }); + + it("rejects non-positive rate values", () => { + expect(() => { + ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.paramsSchema.parse({ + attr: "ShieldCharges", + rate: 0, + }); + }).toThrow(); + + expect(() => { + ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.paramsSchema.parse({ + attr: "ShieldCharges", + rate: -1, + }); + }).toThrow(); + }); + + it("is idempotent when applying the same params twice", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "pawn"); + + const params = ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.paramsSchema.parse({ + attr: "ShieldCharges", + rate: 3, + }); + + ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.apply(makeContext(session, pieceId), params); + ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE.apply(makeContext(session, pieceId), params); + + expect(session.get(pieceId, "AbsorbDamageAttr")).toBe("ShieldCharges"); + expect(session.get(pieceId, "AbsorbDamageRate")).toBe(3); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.ts b/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.ts new file mode 100644 index 0000000..add977b --- /dev/null +++ b/packages/chess/src/modifiers/primitives/absorb-damage-with-attribute.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; + +const schema = z.object({ + attr: z.string(), + rate: z.number().int().positive(), +}); + +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "absorb-damage-with-attribute", + label: "Absorb Damage with Attribute", + description: + "Seed absorb-damage facts so damage can consume an attribute before HP.", + paramsSchema: schema, + apply(ctx: PrimitiveApplyContext, params: Params): void { + ctx.session.insert(ctx.pieceId, "AbsorbDamageAttr", params.attr); + ctx.session.insert(ctx.pieceId, "AbsorbDamageRate", params.rate); + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); + +export { descriptor as ABSORB_DAMAGE_WITH_ATTRIBUTE_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/block-move-type.test.ts b/packages/chess/src/modifiers/primitives/block-move-type.test.ts new file mode 100644 index 0000000..8f9d132 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/block-move-type.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { ChessEngine } from "../../engine.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { BLOCK_MOVE_TYPE_PRIMITIVE } from "./block-move-type.js"; + +function makeContext(session: Session, pieceId: EntityId) { + return { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "test-descriptor", + type: "data" as const, + version: 1 as const, + }, + }; +} + +describe("BLOCK_MOVE_TYPE_PRIMITIVE", () => { + it("registers in PRIMITIVE_REGISTRY", () => { + expect(PRIMITIVE_REGISTRY.has("block-move-type")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("block-move-type")).toBe(BLOCK_MOVE_TYPE_PRIMITIVE); + }); + + it("blocks one move type", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "rook"); + + BLOCK_MOVE_TYPE_PRIMITIVE.apply( + makeContext(session, pieceId), + BLOCK_MOVE_TYPE_PRIMITIVE.paramsSchema.parse({ moveType: "capture" }), + ); + + expect(session.get(pieceId, "BlockedMoveTypes")).toEqual(["capture"]); + }); + + it("composes unique types across multiple applies", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "bishop"); + + BLOCK_MOVE_TYPE_PRIMITIVE.apply( + makeContext(session, pieceId), + BLOCK_MOVE_TYPE_PRIMITIVE.paramsSchema.parse({ moveType: "step" }), + ); + BLOCK_MOVE_TYPE_PRIMITIVE.apply( + makeContext(session, pieceId), + BLOCK_MOVE_TYPE_PRIMITIVE.paramsSchema.parse({ moveType: "slide" }), + ); + + expect(session.get(pieceId, "BlockedMoveTypes")).toEqual(["step", "slide"]); + }); + + it("is idempotent when the same move type is applied repeatedly", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "queen"); + + const params = BLOCK_MOVE_TYPE_PRIMITIVE.paramsSchema.parse({ moveType: "capture" }); + BLOCK_MOVE_TYPE_PRIMITIVE.apply(makeContext(session, pieceId), params); + BLOCK_MOVE_TYPE_PRIMITIVE.apply(makeContext(session, pieceId), params); + + expect(session.get(pieceId, "BlockedMoveTypes")).toEqual(["capture"]); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/block-move-type.ts b/packages/chess/src/modifiers/primitives/block-move-type.ts new file mode 100644 index 0000000..22c3429 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/block-move-type.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import type { MoveType } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; + +const moveTypeSchema = z.enum(["capture", "step", "slide"]); +const schema = z.object({ + moveType: moveTypeSchema, +}); + +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "block-move-type", + label: "Block Move Type", + description: "Seed blocked move types for deferred move-filter integration.", + paramsSchema: schema, + apply(ctx: PrimitiveApplyContext, params: Params): void { + const existingRaw = ctx.session.contains(ctx.pieceId, "BlockedMoveTypes") + ? ctx.session.get(ctx.pieceId, "BlockedMoveTypes") + : []; + const existing: MoveType[] = Array.isArray(existingRaw) + ? existingRaw.filter((v): v is MoveType => moveTypeSchema.safeParse(v).success) + : []; + const next = existing.includes(params.moveType) ? existing : [...existing, params.moveType]; + ctx.session.insert(ctx.pieceId, "BlockedMoveTypes", next); + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); + +export { descriptor as BLOCK_MOVE_TYPE_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/modify-movement-range.test.ts b/packages/chess/src/modifiers/primitives/modify-movement-range.test.ts new file mode 100644 index 0000000..9d551f7 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/modify-movement-range.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { ChessEngine } from "../../engine.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { MODIFY_MOVEMENT_RANGE_PRIMITIVE } from "./modify-movement-range.js"; + +function makeContext(session: Session, pieceId: EntityId) { + return { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "test-descriptor", + type: "data" as const, + version: 1 as const, + }, + }; +} + +describe("MODIFY_MOVEMENT_RANGE_PRIMITIVE", () => { + it("registers in PRIMITIVE_REGISTRY", () => { + expect(PRIMITIVE_REGISTRY.has("modify-movement-range")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("modify-movement-range")).toBe( + MODIFY_MOVEMENT_RANGE_PRIMITIVE, + ); + }); + + it("treats absent RangeBonus as 0 before applying delta", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "bishop"); + + MODIFY_MOVEMENT_RANGE_PRIMITIVE.apply( + makeContext(session, pieceId), + MODIFY_MOVEMENT_RANGE_PRIMITIVE.paramsSchema.parse({ delta: 2 }), + ); + + expect(session.get(pieceId, "RangeBonus")).toBe(2); + }); + + it("adds delta to existing RangeBonus", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "rook"); + session.insert(pieceId, "RangeBonus", 3); + + MODIFY_MOVEMENT_RANGE_PRIMITIVE.apply( + makeContext(session, pieceId), + MODIFY_MOVEMENT_RANGE_PRIMITIVE.paramsSchema.parse({ delta: -1 }), + ); + + expect(session.get(pieceId, "RangeBonus")).toBe(2); + }); + + it("accumulates when applied multiple times", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "queen"); + + MODIFY_MOVEMENT_RANGE_PRIMITIVE.apply( + makeContext(session, pieceId), + MODIFY_MOVEMENT_RANGE_PRIMITIVE.paramsSchema.parse({ delta: 1 }), + ); + MODIFY_MOVEMENT_RANGE_PRIMITIVE.apply( + makeContext(session, pieceId), + MODIFY_MOVEMENT_RANGE_PRIMITIVE.paramsSchema.parse({ delta: 2 }), + ); + + expect(session.get(pieceId, "RangeBonus")).toBe(3); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/modify-movement-range.ts b/packages/chess/src/modifiers/primitives/modify-movement-range.ts new file mode 100644 index 0000000..c868cff --- /dev/null +++ b/packages/chess/src/modifiers/primitives/modify-movement-range.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; + +const schema = z.object({ + delta: z.number().int().min(-7).max(7), +}); + +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "modify-movement-range", + label: "Modify Movement Range", + description: "Additively contributes to the existing RangeBonus attribute.", + paramsSchema: schema, + apply(ctx: PrimitiveApplyContext, params: Params): void { + const existing = ctx.session.contains(ctx.pieceId, "RangeBonus") + ? ctx.session.get(ctx.pieceId, "RangeBonus") + : 0; + const baseline = typeof existing === "number" ? existing : 0; + ctx.session.insert(ctx.pieceId, "RangeBonus", baseline + params.delta); + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); + +export { descriptor as MODIFY_MOVEMENT_RANGE_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/override-promotion.test.ts b/packages/chess/src/modifiers/primitives/override-promotion.test.ts new file mode 100644 index 0000000..dc8eaa9 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/override-promotion.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { ChessEngine } from "../../engine.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { OVERRIDE_PROMOTION_PRIMITIVE } from "./override-promotion.js"; + +function makeContext(session: Session, pieceId: EntityId) { + return { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "test-descriptor", + type: "data" as const, + version: 1 as const, + }, + }; +} + +describe("OVERRIDE_PROMOTION_PRIMITIVE", () => { + it("registers in PRIMITIVE_REGISTRY", () => { + expect(PRIMITIVE_REGISTRY.has("override-promotion")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("override-promotion")).toBe(OVERRIDE_PROMOTION_PRIMITIVE); + }); + + it("writes PromotionOverride with the requested target", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "pawn"); + + OVERRIDE_PROMOTION_PRIMITIVE.apply( + makeContext(session, pieceId), + OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "queen" }), + ); + + expect(session.get(pieceId, "PromotionOverride")).toBe("queen"); + }); + + it("is last-wins when re-applied", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "pawn"); + + OVERRIDE_PROMOTION_PRIMITIVE.apply( + makeContext(session, pieceId), + OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "knight" }), + ); + OVERRIDE_PROMOTION_PRIMITIVE.apply( + makeContext(session, pieceId), + OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "bishop" }), + ); + + expect(session.get(pieceId, "PromotionOverride")).toBe("bishop"); + }); + + it("accepts canonical PieceType values from schema", () => { + expect(() => OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "pawn" })).not.toThrow(); + expect(() => OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "king" })).not.toThrow(); + expect(() => OVERRIDE_PROMOTION_PRIMITIVE.paramsSchema.parse({ target: "queen" })).not.toThrow(); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/override-promotion.ts b/packages/chess/src/modifiers/primitives/override-promotion.ts new file mode 100644 index 0000000..856eedf --- /dev/null +++ b/packages/chess/src/modifiers/primitives/override-promotion.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; +import type { PieceType } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; + +const schema = z.object({ + target: z.enum(["pawn", "knight", "bishop", "rook", "queen", "king"]), +}); + +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "override-promotion", + label: "Override Promotion", + description: "Write PromotionOverride directly to enforce a promotion target.", + paramsSchema: schema, + apply(ctx: PrimitiveApplyContext, params: Params): void { + const target: PieceType = params.target; + ctx.session.insert(ctx.pieceId, "PromotionOverride", target); + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); + +export { descriptor as OVERRIDE_PROMOTION_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/reflect-damage.test.ts b/packages/chess/src/modifiers/primitives/reflect-damage.test.ts new file mode 100644 index 0000000..090ca48 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/reflect-damage.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { Session, type EntityId } from "@paratype/rete"; +import { ChessEngine } from "../../engine.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { REFLECT_DAMAGE_PRIMITIVE } from "./reflect-damage.js"; + +function makeContext(session: Session, pieceId: EntityId) { + return { + engine: new ChessEngine(), + session, + pieceId, + depth: 0, + descriptor: { + id: "test-descriptor", + type: "data" as const, + version: 1 as const, + }, + }; +} + +describe("REFLECT_DAMAGE_PRIMITIVE", () => { + it("registers in PRIMITIVE_REGISTRY", () => { + expect(PRIMITIVE_REGISTRY.has("reflect-damage")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("reflect-damage")).toBe(REFLECT_DAMAGE_PRIMITIVE); + }); + + it("writes ReflectDamagePercent fact", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "pawn"); + + const params = REFLECT_DAMAGE_PRIMITIVE.paramsSchema.parse({ percentage: 35 }); + REFLECT_DAMAGE_PRIMITIVE.apply(makeContext(session, pieceId), params); + + expect(session.get(pieceId, "ReflectDamagePercent")).toBe(35); + }); + + it("rejects percentage outside 0..100", () => { + expect(() => REFLECT_DAMAGE_PRIMITIVE.paramsSchema.parse({ percentage: -1 })).toThrow(); + expect(() => REFLECT_DAMAGE_PRIMITIVE.paramsSchema.parse({ percentage: 101 })).toThrow(); + }); + + it("overwrites existing value on re-apply", () => { + const session = new Session(); + const pieceId = session.nextId(); + session.insert(pieceId, "PieceType", "pawn"); + + REFLECT_DAMAGE_PRIMITIVE.apply( + makeContext(session, pieceId), + REFLECT_DAMAGE_PRIMITIVE.paramsSchema.parse({ percentage: 20 }), + ); + REFLECT_DAMAGE_PRIMITIVE.apply( + makeContext(session, pieceId), + REFLECT_DAMAGE_PRIMITIVE.paramsSchema.parse({ percentage: 60 }), + ); + + expect(session.get(pieceId, "ReflectDamagePercent")).toBe(60); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/reflect-damage.ts b/packages/chess/src/modifiers/primitives/reflect-damage.ts new file mode 100644 index 0000000..a7baa32 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/reflect-damage.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; + +const schema = z.object({ + percentage: z.number().int().min(0).max(100), +}); + +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "reflect-damage", + label: "Reflect Damage", + description: "Seed reflected-damage percentage for deferred damage-pipeline wiring.", + paramsSchema: schema, + apply(ctx: PrimitiveApplyContext, params: Params): void { + ctx.session.insert(ctx.pieceId, "ReflectDamagePercent", params.percentage); + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); + +export { descriptor as REFLECT_DAMAGE_PRIMITIVE };