diff --git a/packages/chess/src/modifiers/primitives/conditional.test.ts b/packages/chess/src/modifiers/primitives/conditional.test.ts new file mode 100644 index 0000000..4f622c7 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/conditional.test.ts @@ -0,0 +1,123 @@ +import { Session } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { CONDITIONAL_PRIMITIVE } from "./conditional.js"; +import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js"; +import "./conditional.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-conditional", + type: "data", + version: 1, + }, + }; + return { ctx, session }; +} + +describe("conditional primitive — registry", () => { + it("registers in PRIMITIVE_REGISTRY under key 'conditional'", () => { + expect(PRIMITIVE_REGISTRY.has("conditional")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("conditional")).toBe(CONDITIONAL_PRIMITIVE); + }); +}); + +describe("conditional primitive — apply()", () => { + it("seeds one conditional hook", () => { + const { ctx, session } = makeContext(); + const thenBranch: EffectPrimitiveNode[] = [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } }, + ]; + const elseBranch: EffectPrimitiveNode[] = [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: -1 } }, + ]; + + CONDITIONAL_PRIMITIVE.apply(ctx, { + condition: { type: "attr-lt", attr: "Hp", value: 3 }, + then: thenBranch, + else: elseBranch, + }); + + expect(session.get(ctx.pieceId, "ConditionalHooks")).toEqual([ + { + condition: { type: "attr-lt", attr: "Hp", value: 3 }, + then: thenBranch, + else: elseBranch, + }, + ]); + }); + + it("appends to existing ConditionalHooks", () => { + const { ctx, session } = makeContext(); + + CONDITIONAL_PRIMITIVE.apply(ctx, { + condition: { type: "always" }, + then: [{ kind: "seed-attribute", params: { attr: "Hp", value: 5 } }], + }); + CONDITIONAL_PRIMITIVE.apply(ctx, { + condition: { type: "never" }, + then: [{ kind: "set-capture-flag", params: { flag: 2 } }], + else: [{ kind: "set-capture-flag", params: { flag: 1 } }], + }); + + expect(session.get(ctx.pieceId, "ConditionalHooks")).toEqual([ + { + condition: { type: "always" }, + then: [{ kind: "seed-attribute", params: { attr: "Hp", value: 5 } }], + else: undefined, + }, + { + condition: { type: "never" }, + then: [{ kind: "set-capture-flag", params: { flag: 2 } }], + else: [{ kind: "set-capture-flag", params: { flag: 1 } }], + }, + ]); + }); +}); + +describe("conditional primitive — childPrimitives()", () => { + it("returns then + else primitives combined", () => { + const thenBranch: EffectPrimitiveNode[] = [ + { kind: "add-direction", params: { direction: "forward" } }, + ]; + const elseBranch: EffectPrimitiveNode[] = [ + { kind: "block-move-type", params: { moveType: "capture" } }, + ]; + + const children = CONDITIONAL_PRIMITIVE.childPrimitives?.({ + condition: { type: "always" }, + then: thenBranch, + else: elseBranch, + }); + + expect(children).toEqual([...thenBranch, ...elseBranch]); + }); + + it("accepts conditional params with no else branch", () => { + const parsed = CONDITIONAL_PRIMITIVE.paramsSchema.parse({ + condition: { type: "attr-gt", attr: "Hp", value: 2 }, + then: [{ kind: "seed-attribute", params: { attr: "HpBonus", value: 1 } }], + }); + + expect(parsed.else).toBeUndefined(); + }); +}); + +describe("conditional primitive — paramsSchema", () => { + it("rejects unknown condition.type values", () => { + expect(() => + CONDITIONAL_PRIMITIVE.paramsSchema.parse({ + condition: { type: "attr-between", attr: "Hp", min: 1, max: 3 }, + then: [], + }), + ).toThrow(); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/conditional.ts b/packages/chess/src/modifiers/primitives/conditional.ts new file mode 100644 index 0000000..ab55717 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/conditional.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; +import type { ChessAttrMap, ConditionSpec } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { + EffectPrimitive, + EffectPrimitiveNode, + PrimitiveApplyContext, + PrimitiveKind, +} from "./types.js"; + +/** + * Primitive kinds are runtime-validated later by the tree validator (T19). + * Keeping this as `string` avoids hard-coding every primitive literal here. + */ +const NodeSchema: z.ZodType = z.object({ + kind: z.string() as z.ZodType, + params: z.unknown(), +}); + +const ConditionSchema: z.ZodType = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("attr-lt"), + attr: z.string(), + value: z.number(), + }), + z.object({ + type: z.literal("attr-gt"), + attr: z.string(), + value: z.number(), + }), + z.object({ + type: z.literal("attr-eq"), + attr: z.string(), + value: z.union([z.string(), z.number(), z.boolean(), z.null()]), + }), + z.object({ + type: z.literal("always"), + }), + z.object({ + type: z.literal("never"), + }), +]); + +const schema = z.object({ + condition: ConditionSchema, + then: z.array(NodeSchema), + else: z.array(NodeSchema).optional(), +}); +type Params = z.infer; +type ConditionalHook = ChessAttrMap["ConditionalHooks"][number]; + +const descriptor: EffectPrimitive = { + kind: "conditional", + label: "Conditional", + description: + "Seeds ConditionalHooks entries consumed by the trigger evaluation pipeline.", + paramsSchema: schema, + apply(ctx: PrimitiveApplyContext, params: Params): void { + const existing = + (ctx.session.get(ctx.pieceId, "ConditionalHooks") as + | ChessAttrMap["ConditionalHooks"] + | undefined) ?? []; + + let next: ConditionalHook; + if (params.else !== undefined) { + next = { + condition: params.condition, + then: [...params.then], + else: [...params.else], + }; + } else { + next = { + condition: params.condition, + then: [...params.then], + }; + } + + ctx.session.insert(ctx.pieceId, "ConditionalHooks", [...existing, next]); + }, + childPrimitives(params: Params): EffectPrimitiveNode[] { + return [...params.then, ...(params.else ?? [])]; + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as CONDITIONAL_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/index.ts b/packages/chess/src/modifiers/primitives/index.ts index a17eeb5..98642bd 100644 --- a/packages/chess/src/modifiers/primitives/index.ts +++ b/packages/chess/src/modifiers/primitives/index.ts @@ -8,4 +8,23 @@ export type { Session, } from "./types.js"; -// Side-effect registration of individual primitives is added by Wave 2 (T4-T18). +// State primitives (Wave 2 batch A — T4–T8): +import "./seed-attribute.js"; +import "./add-to-attribute.js"; +import "./multiply-attribute.js"; +import "./add-direction.js"; +import "./set-capture-flag.js"; + +// Mechanic primitives (Wave 2 batch B — T9–T13): +import "./absorb-damage-with-attribute.js"; +import "./reflect-damage.js"; +import "./modify-movement-range.js"; +import "./block-move-type.js"; +import "./override-promotion.js"; + +// Advanced primitives (Wave 2 batch C — T14–T18): +import "./add-aura.js"; +import "./on-turn-start.js"; +import "./on-capture.js"; +import "./on-damaged.js"; +import "./conditional.js"; diff --git a/packages/chess/src/modifiers/primitives/on-capture.test.ts b/packages/chess/src/modifiers/primitives/on-capture.test.ts new file mode 100644 index 0000000..a92ac69 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/on-capture.test.ts @@ -0,0 +1,72 @@ +import { Session } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { ON_CAPTURE_PRIMITIVE } from "./on-capture.js"; +import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js"; +import "./on-capture.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-on-capture", + type: "data", + version: 1, + }, + }; + return { ctx, session }; +} + +describe("on-capture primitive — registry", () => { + it("registers in PRIMITIVE_REGISTRY under key 'on-capture'", () => { + expect(PRIMITIVE_REGISTRY.has("on-capture")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("on-capture")).toBe(ON_CAPTURE_PRIMITIVE); + }); +}); + +describe("on-capture primitive — apply()", () => { + it("seeds one capture hook", () => { + const { ctx, session } = makeContext(); + const primitives: EffectPrimitiveNode[] = [ + { kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 1 } }, + ]; + + ON_CAPTURE_PRIMITIVE.apply(ctx, { primitives }); + + expect(session.get(ctx.pieceId, "OnCaptureHooks")).toEqual([primitives]); + }); + + it("appends additional hooks to existing OnCaptureHooks", () => { + const { ctx, session } = makeContext(); + const first: EffectPrimitiveNode[] = [ + { kind: "seed-attribute", params: { attr: "Hp", value: 2 } }, + ]; + const second: EffectPrimitiveNode[] = [ + { kind: "multiply-attribute", params: { attr: "HpBonus", factor: 2 } }, + ]; + + ON_CAPTURE_PRIMITIVE.apply(ctx, { primitives: first }); + ON_CAPTURE_PRIMITIVE.apply(ctx, { primitives: second }); + + expect(session.get(ctx.pieceId, "OnCaptureHooks")).toEqual([first, second]); + }); +}); + +describe("on-capture primitive — childPrimitives()", () => { + it("returns the inner primitive list for validator tree traversal", () => { + const primitives: EffectPrimitiveNode[] = [ + { kind: "add-direction", params: { direction: "left" } }, + { kind: "set-capture-flag", params: { flag: 2 } }, + ]; + + const children = ON_CAPTURE_PRIMITIVE.childPrimitives?.({ primitives }); + + expect(children).toEqual(primitives); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/on-capture.ts b/packages/chess/src/modifiers/primitives/on-capture.ts new file mode 100644 index 0000000..7581e6f --- /dev/null +++ b/packages/chess/src/modifiers/primitives/on-capture.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import type { ChessAttrMap } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { + EffectPrimitive, + EffectPrimitiveNode, + PrimitiveApplyContext, + PrimitiveKind, +} from "./types.js"; + +/** + * Primitive kinds are runtime-validated later by the tree validator (T19). + * Keeping this as `string` avoids hard-coding every primitive literal here. + */ +const NodeSchema: z.ZodType = z.object({ + kind: z.string() as z.ZodType, + params: z.unknown(), +}); + +const schema = z.object({ + primitives: z.array(NodeSchema), +}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "on-capture", + label: "On Capture", + description: "Seeds OnCaptureHooks entries consumed during capture events.", + paramsSchema: schema, + apply(ctx: PrimitiveApplyContext, params: Params): void { + const existing = + (ctx.session.get(ctx.pieceId, "OnCaptureHooks") as + | ChessAttrMap["OnCaptureHooks"] + | undefined) ?? []; + + ctx.session.insert(ctx.pieceId, "OnCaptureHooks", [ + ...existing, + [...params.primitives], + ]); + }, + childPrimitives(params: Params): EffectPrimitiveNode[] { + return [...params.primitives]; + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as ON_CAPTURE_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/on-damaged.test.ts b/packages/chess/src/modifiers/primitives/on-damaged.test.ts new file mode 100644 index 0000000..af47de9 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/on-damaged.test.ts @@ -0,0 +1,72 @@ +import { Session } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { ON_DAMAGED_PRIMITIVE } from "./on-damaged.js"; +import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js"; +import "./on-damaged.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-on-damaged", + type: "data", + version: 1, + }, + }; + return { ctx, session }; +} + +describe("on-damaged primitive — registry", () => { + it("registers in PRIMITIVE_REGISTRY under key 'on-damaged'", () => { + expect(PRIMITIVE_REGISTRY.has("on-damaged")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("on-damaged")).toBe(ON_DAMAGED_PRIMITIVE); + }); +}); + +describe("on-damaged primitive — apply()", () => { + it("seeds one damaged hook", () => { + const { ctx, session } = makeContext(); + const primitives: EffectPrimitiveNode[] = [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: -1 } }, + ]; + + ON_DAMAGED_PRIMITIVE.apply(ctx, { primitives }); + + expect(session.get(ctx.pieceId, "OnDamagedHooks")).toEqual([primitives]); + }); + + it("appends additional hooks to existing OnDamagedHooks", () => { + const { ctx, session } = makeContext(); + const first: EffectPrimitiveNode[] = [ + { kind: "seed-attribute", params: { attr: "Hp", value: 1 } }, + ]; + const second: EffectPrimitiveNode[] = [ + { kind: "reflect-damage", params: { percent: 0.5 } }, + ]; + + ON_DAMAGED_PRIMITIVE.apply(ctx, { primitives: first }); + ON_DAMAGED_PRIMITIVE.apply(ctx, { primitives: second }); + + expect(session.get(ctx.pieceId, "OnDamagedHooks")).toEqual([first, second]); + }); +}); + +describe("on-damaged primitive — childPrimitives()", () => { + it("returns the inner primitive list for validator tree traversal", () => { + const primitives: EffectPrimitiveNode[] = [ + { kind: "absorb-damage-with-attribute", params: { attr: "Hp", rate: 1 } }, + { kind: "set-capture-flag", params: { flag: 4 } }, + ]; + + const children = ON_DAMAGED_PRIMITIVE.childPrimitives?.({ primitives }); + + expect(children).toEqual(primitives); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/on-damaged.ts b/packages/chess/src/modifiers/primitives/on-damaged.ts new file mode 100644 index 0000000..b70ec41 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/on-damaged.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import type { ChessAttrMap } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { + EffectPrimitive, + EffectPrimitiveNode, + PrimitiveApplyContext, + PrimitiveKind, +} from "./types.js"; + +/** + * Primitive kinds are runtime-validated later by the tree validator (T19). + * Keeping this as `string` avoids hard-coding every primitive literal here. + */ +const NodeSchema: z.ZodType = z.object({ + kind: z.string() as z.ZodType, + params: z.unknown(), +}); + +const schema = z.object({ + primitives: z.array(NodeSchema), +}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "on-damaged", + label: "On Damaged", + description: "Seeds OnDamagedHooks entries consumed during damage events.", + paramsSchema: schema, + apply(ctx: PrimitiveApplyContext, params: Params): void { + const existing = + (ctx.session.get(ctx.pieceId, "OnDamagedHooks") as + | ChessAttrMap["OnDamagedHooks"] + | undefined) ?? []; + + ctx.session.insert(ctx.pieceId, "OnDamagedHooks", [ + ...existing, + [...params.primitives], + ]); + }, + childPrimitives(params: Params): EffectPrimitiveNode[] { + return [...params.primitives]; + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as ON_DAMAGED_PRIMITIVE }; diff --git a/packages/chess/src/modifiers/primitives/on-turn-start.test.ts b/packages/chess/src/modifiers/primitives/on-turn-start.test.ts new file mode 100644 index 0000000..d332c97 --- /dev/null +++ b/packages/chess/src/modifiers/primitives/on-turn-start.test.ts @@ -0,0 +1,72 @@ +import { Session } from "@paratype/rete"; +import { describe, expect, it } from "vitest"; +import { ChessEngine } from "../../engine.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import { ON_TURN_START_PRIMITIVE } from "./on-turn-start.js"; +import type { EffectPrimitiveNode, PrimitiveApplyContext } from "./types.js"; +import "./on-turn-start.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-on-turn-start", + type: "data", + version: 1, + }, + }; + return { ctx, session }; +} + +describe("on-turn-start primitive — registry", () => { + it("registers in PRIMITIVE_REGISTRY under key 'on-turn-start'", () => { + expect(PRIMITIVE_REGISTRY.has("on-turn-start")).toBe(true); + expect(PRIMITIVE_REGISTRY.get("on-turn-start")).toBe(ON_TURN_START_PRIMITIVE); + }); +}); + +describe("on-turn-start primitive — apply()", () => { + it("seeds one turn-start hook", () => { + const { ctx, session } = makeContext(); + const primitives: EffectPrimitiveNode[] = [ + { kind: "add-to-attribute", params: { attr: "HpBonus", delta: 1 } }, + ]; + + ON_TURN_START_PRIMITIVE.apply(ctx, { primitives }); + + expect(session.get(ctx.pieceId, "OnTurnStartHooks")).toEqual([primitives]); + }); + + it("appends additional hooks to existing OnTurnStartHooks", () => { + const { ctx, session } = makeContext(); + const first: EffectPrimitiveNode[] = [ + { kind: "seed-attribute", params: { attr: "Hp", value: 3 } }, + ]; + const second: EffectPrimitiveNode[] = [ + { kind: "set-capture-flag", params: { flag: 1 } }, + ]; + + ON_TURN_START_PRIMITIVE.apply(ctx, { primitives: first }); + ON_TURN_START_PRIMITIVE.apply(ctx, { primitives: second }); + + expect(session.get(ctx.pieceId, "OnTurnStartHooks")).toEqual([first, second]); + }); +}); + +describe("on-turn-start primitive — childPrimitives()", () => { + it("returns the inner primitive list for validator tree traversal", () => { + const primitives: EffectPrimitiveNode[] = [ + { kind: "add-direction", params: { direction: "backward" } }, + { kind: "block-move-type", params: { moveType: "slide" } }, + ]; + + const children = ON_TURN_START_PRIMITIVE.childPrimitives?.({ primitives }); + + expect(children).toEqual(primitives); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/on-turn-start.ts b/packages/chess/src/modifiers/primitives/on-turn-start.ts new file mode 100644 index 0000000..a16db5e --- /dev/null +++ b/packages/chess/src/modifiers/primitives/on-turn-start.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import type { ChessAttrMap } from "../../schema.js"; +import { PRIMITIVE_REGISTRY } from "./registry.js"; +import type { + EffectPrimitive, + EffectPrimitiveNode, + PrimitiveApplyContext, + PrimitiveKind, +} from "./types.js"; + +/** + * Primitive kinds are runtime-validated later by the tree validator (T19). + * Keeping this as `string` avoids hard-coding every primitive literal here. + */ +const NodeSchema: z.ZodType = z.object({ + kind: z.string() as z.ZodType, + params: z.unknown(), +}); + +const schema = z.object({ + primitives: z.array(NodeSchema), +}); +type Params = z.infer; + +const descriptor: EffectPrimitive = { + kind: "on-turn-start", + label: "On Turn Start", + description: + "Seeds OnTurnStartHooks entries consumed during the engine's turn-start phase.", + paramsSchema: schema, + apply(ctx: PrimitiveApplyContext, params: Params): void { + const existing = + (ctx.session.get(ctx.pieceId, "OnTurnStartHooks") as + | ChessAttrMap["OnTurnStartHooks"] + | undefined) ?? []; + + ctx.session.insert(ctx.pieceId, "OnTurnStartHooks", [ + ...existing, + [...params.primitives], + ]); + }, + childPrimitives(params: Params): EffectPrimitiveNode[] { + return [...params.primitives]; + }, +}; + +PRIMITIVE_REGISTRY.register(descriptor); +export { descriptor as ON_TURN_START_PRIMITIVE };