feat(engine): mechanic effect primitives (absorb/reflect/range/block/promotion)

T3 Wave 2 batch B. Implements 5 game-mechanic primitives that seed
specs the engine's existing pipelines (T22 will wire) consume:

- absorb-damage-with-attribute: seeds {AbsorbDamageAttr, AbsorbDamageRate}.
- reflect-damage: seeds ReflectDamagePercent (0-100, validated).
- modify-movement-range: composes with T1's range-bonus by reading
  existing RangeBonus (default 0) and writing existing+delta.
- block-move-type: appends a move-type filter into BlockedMoveTypes.
- override-promotion: writes the canonical PromotionOverride (mirrors
  T1's promotion-override descriptor exactly).

ChessAttrMap gains AbsorbDamageAttr, AbsorbDamageRate, ReflectDamagePercent,
and BlockedMoveTypes for the new fact namespaces. Engine integration
deferred to T22; this batch is data-seeding only.

All 5 register in PRIMITIVE_REGISTRY via side-effect import. Each
primitive ships with ≥3 vitest scenarios.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 17:41:44 -06:00
commit b93b4b7322
No known key found for this signature in database
10 changed files with 470 additions and 0 deletions

View file

@ -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);
});
});

View file

@ -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<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
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 };

View file

@ -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"]);
});
});

View file

@ -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<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
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 };

View file

@ -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);
});
});

View file

@ -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<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
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 };

View file

@ -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();
});
});

View file

@ -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<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
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 };

View file

@ -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);
});
});

View file

@ -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<typeof schema>;
const descriptor: EffectPrimitive<Params> = {
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 };