diff --git a/packages/chess/src/modifiers/primitives/mr-freeze-validates.test.ts b/packages/chess/src/modifiers/primitives/mr-freeze-validates.test.ts new file mode 100644 index 0000000..858591f --- /dev/null +++ b/packages/chess/src/modifiers/primitives/mr-freeze-validates.test.ts @@ -0,0 +1,90 @@ +/** + * `mr_freeze.json` fixture validation — T7 (spawn-marker-pair widening verification). + * + * The mr_freeze descriptor is the deepest fixture (cascade depth 3) in the + * parity suite. It uses resolver shapes on `square` (ctx-build) and `owner` + * (ctx-attr) — exactly the fields widened in T7. + * + * This test verifies that mr_freeze.json validates cleanly AFTER the V2 + * widening, proving the premise of the wave: schema widening + resolver + * shape support enables real fixtures to pass validation. + */ +import { describe, expect, it } from "vitest"; +import type { CustomModifierDescriptor } from "../custom/types.js"; +import { asCustomModifierId } from "../custom/types.js"; +import { validateCustomDescriptor } from "../custom/validate.js"; + +// The mr_freeze descriptor: spawn-marker used inside nested for-row +// with resolver shapes on both square (ctx-build from row/col $vars) +// and owner (ctx-attr pulling Color from chooser). +const mrFreezeDescriptor: CustomModifierDescriptor = { + type: "data", + id: asCustomModifierId("parity:mr_freeze"), + name: "Mr Freeze (ThressGame)", + description: + "ThressGame parity (T60). Activate -> chooser picks column -> spawn 8 frozen-square markers down the column, lifetime moves/9, owned by chooser.", + version: 1, + uiForm: "primitive-composer", + source: "custom", + targetAttrs: [], + primitives: [ + { + kind: "on-rule-activated", + params: { + primitives: [ + { + kind: "request-choice", + params: { + kind: "column", + prompt: "Mr Freeze — pick a column to freeze for 9 moves", + forPlayer: "both", + bind: "col", + then: [ + { + kind: "for-row", + params: { + rows: [0, 1, 2, 3, 4, 5, 6, 7], + bind: "row", + then: [ + { + kind: "spawn-marker", + params: { + markerKind: "frozen-square", + square: { + "ctx-build": { + col: { $var: "col" }, + row: { $var: "row" }, + }, + }, + lifetime: { kind: "moves", expiresAtMove: 9 }, + owner: { + "ctx-attr": { + entity: "chooser", + attr: "Color", + }, + }, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +}; + +describe("mr_freeze.json fixture validation (T7 — V2 widening verification)", () => { + it("mr_freeze descriptor validates clean with resolver shapes on square & owner", () => { + const result = validateCustomDescriptor(mrFreezeDescriptor); + if (!result.ok) { + throw new Error( + `mr_freeze descriptor failed validation: ${JSON.stringify(result.errors, null, 2)}`, + ); + } + expect(result.ok).toBe(true); + }); +}); diff --git a/packages/chess/src/modifiers/primitives/spawn-marker-pair.test.ts b/packages/chess/src/modifiers/primitives/spawn-marker-pair.test.ts index 665c281..51793b2 100644 --- a/packages/chess/src/modifiers/primitives/spawn-marker-pair.test.ts +++ b/packages/chess/src/modifiers/primitives/spawn-marker-pair.test.ts @@ -237,6 +237,90 @@ describe("spawn-marker-pair primitive — paramsSchema (T29)", () => { }).success, ).toBe(false); }); + + it("accepts squareA as a $var resolver shape (V2 widening)", () => { + const result = SPAWN_MARKER_PAIR_PRIMITIVE.paramsSchema.safeParse({ + markerKind: "portal-end", + squareA: { $var: "startSquare" }, + squareB: 35, + lifetime: { kind: "permanent" }, + }); + expect(result.success).toBe(true); + }); + + it("accepts squareB as a ctx-build resolver shape (V2 widening)", () => { + const result = SPAWN_MARKER_PAIR_PRIMITIVE.paramsSchema.safeParse({ + markerKind: "portal-end", + squareA: 28, + squareB: { "ctx-build": { col: 5, row: 6 } }, + lifetime: { kind: "permanent" }, + }); + expect(result.success).toBe(true); + }); + + it("accepts both squareA and squareB as resolver shapes (V2 widening)", () => { + const result = SPAWN_MARKER_PAIR_PRIMITIVE.paramsSchema.safeParse({ + markerKind: "tornado", + squareA: { "ctx-attr": { entity: "self", attr: "Position" } }, + squareB: { $var: "targetSquare" }, + lifetime: { kind: "permanent" }, + }); + expect(result.success).toBe(true); + }); + + it("accepts owner as a $var resolver shape (V2 widening)", () => { + const result = SPAWN_MARKER_PAIR_PRIMITIVE.paramsSchema.safeParse({ + markerKind: "portal-end", + squareA: 0, + squareB: 1, + lifetime: { kind: "permanent" }, + owner: { $var: "ownerColor" }, + }); + expect(result.success).toBe(true); + }); + + it("accepts owner as a ctx-attr resolver shape (V2 widening)", () => { + const result = SPAWN_MARKER_PAIR_PRIMITIVE.paramsSchema.safeParse({ + markerKind: "tornado", + squareA: 12, + squareB: 51, + lifetime: { kind: "one-shot" }, + owner: { "ctx-attr": { entity: "chooser", attr: "Color" } }, + }); + expect(result.success).toBe(true); + }); + + it("rejects markerKind as a resolver shape (strict enum, no widening)", () => { + const result = SPAWN_MARKER_PAIR_PRIMITIVE.paramsSchema.safeParse({ + markerKind: { $var: "kind" }, + squareA: 0, + squareB: 1, + lifetime: { kind: "permanent" }, + }); + expect(result.success).toBe(false); + }); + + it("widened owner schema exposes __resolverEnumValues for ParamField introspection", () => { + // The owner field is enumOrResolverFor(PIECE_COLORS).optional(), so it's a ZodOptional + // wrapping the union. The __resolverEnumValues property is on the inner schema. + // Extract it from the parent object schema. + const paramsSchema = SPAWN_MARKER_PAIR_PRIMITIVE.paramsSchema as unknown as { + _def: { shape: Record }; + }; + const ownerOptional = paramsSchema._def.shape.owner as unknown as { + _def?: { innerType?: { __resolverEnumValues?: unknown } }; + __resolverEnumValues?: unknown; + }; + // Try to access either on the wrapper (unlikely) or on the innerType. + const innerSchema = ownerOptional._def?.innerType; + expect( + "__resolverEnumValues" in (innerSchema || ownerOptional), + ).toBe(true); + const enumValues = + innerSchema?.__resolverEnumValues || + ownerOptional.__resolverEnumValues; + expect(enumValues).toEqual(["white", "black"]); + }); }); describe("spawn-marker-pair primitive — apply() (T29)", () => { diff --git a/packages/chess/src/modifiers/primitives/spawn-marker-pair.ts b/packages/chess/src/modifiers/primitives/spawn-marker-pair.ts index 730a654..a4c24c0 100644 --- a/packages/chess/src/modifiers/primitives/spawn-marker-pair.ts +++ b/packages/chess/src/modifiers/primitives/spawn-marker-pair.ts @@ -90,6 +90,7 @@ import type { MarkerLifetimeValue, PieceColor, } from "../../schema.js"; +import { numberOrResolver, enumOrResolverFor } from "./param-resolver-schema.js"; import { PRIMITIVE_REGISTRY } from "./registry.js"; import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; @@ -136,10 +137,10 @@ const LIFETIME_SCHEMA = z.union([ const schema = z.object({ markerKind: z.enum(MARKER_KIND_VALUES), - squareA: z.number().int().min(0).max(63), - squareB: z.number().int().min(0).max(63), + squareA: numberOrResolver({ min: 0, max: 63 }), + squareB: numberOrResolver({ min: 0, max: 63 }), lifetime: LIFETIME_SCHEMA, - owner: z.enum(PIECE_COLORS).optional(), + owner: enumOrResolverFor(PIECE_COLORS).optional(), }); type Params = z.infer; @@ -149,7 +150,7 @@ const descriptor: EffectPrimitive = { description: "Spawns two markers of the same kind at squareA and squareB with mutual MarkerLinks pointers — used for portal endpoints (markerKind='portal-end') and other paired-marker mechanics.", longDescription: - "Imperative primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Spawns two marker entities of the SAME markerKind / lifetime / owner at squareA and squareB via engine.spawnMarker, then writes a cross-linked MarkerLinks fact on each so that A.MarkerLinks=[idB] and B.MarkerLinks=[idA]. The cross-link is written via session.insert AFTER both ids exist (the engine factory cannot resolve the partner id at the time of the first spawn). Both halves share lifetime + owner — asymmetric pairs require two separate spawn-marker calls. Squares may be authored as literal indices, { ctx-build: { col, row } }, or { $var: 'name' }; the param resolver substitutes both shapes before this apply() runs. Cardinality is hard-coded to 2 — a future spawn-marker-cluster primitive could generalise to N>2.", + "Drops two markers at once on two chosen squares and links them together. This is the easiest way to set up a portal: each portal-end knows its partner. Only works inside a trigger. Both markers share the same kind, the same lifetime, and (optionally) the same owner. If you need two markers with different kinds or owners, use two separate spawn-marker calls instead.", examples: [ { title: "Portal pair on e4 ↔ d5", @@ -160,10 +161,10 @@ const descriptor: EffectPrimitive = { lifetime: { kind: "permanent" }, }, effect: - "Spawns two portal-end markers — one on e4, one on d5 — each linking to the other via MarkerLinks. A piece that enters either end can teleport to the partner's square via the portal-resolve subsystem (which reads MarkerLinks at lookup time).", + "Two portal-end markers appear, one on e4 and one on d5, linked together. Any piece that steps onto either end can teleport to the other.", }, { - title: "One-shot tornado pair, white-aligned", + title: "One-shot tornado pair, owned by white", params: { markerKind: "tornado", squareA: 12, @@ -172,7 +173,7 @@ const descriptor: EffectPrimitive = { owner: "white", }, effect: - "Spawns two one-shot tornado markers owned by white at squares 12 and 51, cross-linked. Either end being consumed leaves the orphaned partner alive (paired-cleanup is a separate destroy-marker concern; the partner will eventually expire via its own lifetime).", + "Two linked white tornadoes appear on the board. The first piece that steps onto either tornado consumes that tornado; the other one stays put until something steps on it (or its lifetime runs out).", }, ], paramsSchema: schema, @@ -181,6 +182,15 @@ const descriptor: EffectPrimitive = { // already in the consumer registry (T6/T7/T10). seedsAttrs: [], apply(ctx: PrimitiveApplyContext, params: Params): void { + // By the time apply() runs, params.squareA, params.squareB, and + // params.owner have been resolved to literals by the param resolver + // (see param-resolver.ts). The schema accepts resolver shapes for + // validation, but the runtime guarantees literals here. We cast to + // the literal types. + const squareALiteral = params.squareA as number; + const squareBLiteral = params.squareB as number; + const ownerLiteral = params.owner as PieceColor | undefined; + // Build the engine.spawnMarker opts bag for both halves: omit // owner when undefined (engine.spawnMarker checks `!== undefined` // before inserting — meaningless EAV facts are avoided). Links are @@ -193,11 +203,11 @@ const descriptor: EffectPrimitive = { readonly owner?: PieceColor; } = { lifetime: params.lifetime, - ...(params.owner !== undefined ? { owner: params.owner } : {}), + ...(ownerLiteral !== undefined ? { owner: ownerLiteral } : {}), }; - const idA = ctx.engine.spawnMarker(params.markerKind, params.squareA, opts); - const idB = ctx.engine.spawnMarker(params.markerKind, params.squareB, opts); + const idA = ctx.engine.spawnMarker(params.markerKind, squareALiteral, opts); + const idB = ctx.engine.spawnMarker(params.markerKind, squareBLiteral, opts); // Cross-link via session.insert. Same shape engine.spawnMarker // uses internally for the `links` opt (engine.ts ~line 951), so diff --git a/packages/chess/src/modifiers/primitives/spawn-marker.test.ts b/packages/chess/src/modifiers/primitives/spawn-marker.test.ts index b90eadf..cdcd4aa 100644 --- a/packages/chess/src/modifiers/primitives/spawn-marker.test.ts +++ b/packages/chess/src/modifiers/primitives/spawn-marker.test.ts @@ -230,6 +230,84 @@ describe("spawn-marker primitive — paramsSchema (T28)", () => { }).success, ).toBe(false); }); + + it("accepts square as a $var resolver shape (V2 widening)", () => { + const result = SPAWN_MARKER_PRIMITIVE.paramsSchema.safeParse({ + markerKind: "mine", + square: { $var: "targetSquare" }, + lifetime: { kind: "permanent" }, + }); + expect(result.success).toBe(true); + }); + + it("accepts square as a ctx-build resolver shape (V2 widening)", () => { + const result = SPAWN_MARKER_PRIMITIVE.paramsSchema.safeParse({ + markerKind: "frozen-square", + square: { "ctx-build": { col: 3, row: 4 } }, + lifetime: { kind: "permanent" }, + }); + expect(result.success).toBe(true); + }); + + it("accepts square as a ctx-attr resolver shape (V2 widening)", () => { + const result = SPAWN_MARKER_PRIMITIVE.paramsSchema.safeParse({ + markerKind: "mine", + square: { "ctx-attr": { entity: "self", attr: "Position" } }, + lifetime: { kind: "permanent" }, + }); + expect(result.success).toBe(true); + }); + + it("accepts owner as a $var resolver shape (V2 widening)", () => { + const result = SPAWN_MARKER_PRIMITIVE.paramsSchema.safeParse({ + markerKind: "mine", + square: 0, + lifetime: { kind: "permanent" }, + owner: { $var: "ownerColor" }, + }); + expect(result.success).toBe(true); + }); + + it("accepts owner as a ctx-attr resolver shape (V2 widening)", () => { + const result = SPAWN_MARKER_PRIMITIVE.paramsSchema.safeParse({ + markerKind: "frozen-square", + square: 28, + lifetime: { kind: "permanent" }, + owner: { "ctx-attr": { entity: "chooser", attr: "Color" } }, + }); + expect(result.success).toBe(true); + }); + + it("rejects markerKind as a resolver shape (strict enum, no widening)", () => { + const result = SPAWN_MARKER_PRIMITIVE.paramsSchema.safeParse({ + markerKind: { $var: "kind" }, + square: 0, + lifetime: { kind: "permanent" }, + }); + expect(result.success).toBe(false); + }); + + it("widened owner schema exposes __resolverEnumValues for ParamField introspection", () => { + // The owner field is enumOrResolverFor(PIECE_COLORS).optional(), so it's a ZodOptional + // wrapping the union. The __resolverEnumValues property is on the inner schema. + // Extract it from the parent object schema. + const paramsSchema = SPAWN_MARKER_PRIMITIVE.paramsSchema as unknown as { + _def: { shape: Record }; + }; + const ownerOptional = paramsSchema._def.shape.owner as unknown as { + _def?: { innerType?: { __resolverEnumValues?: unknown } }; + __resolverEnumValues?: unknown; + }; + // Try to access either on the wrapper (unlikely) or on the innerType. + const innerSchema = ownerOptional._def?.innerType; + expect( + "__resolverEnumValues" in (innerSchema || ownerOptional), + ).toBe(true); + const enumValues = + innerSchema?.__resolverEnumValues || + ownerOptional.__resolverEnumValues; + expect(enumValues).toEqual(["white", "black"]); + }); }); describe("spawn-marker primitive — apply() (T28)", () => { diff --git a/packages/chess/src/modifiers/primitives/spawn-marker.ts b/packages/chess/src/modifiers/primitives/spawn-marker.ts index 68701b8..e86ad8a 100644 --- a/packages/chess/src/modifiers/primitives/spawn-marker.ts +++ b/packages/chess/src/modifiers/primitives/spawn-marker.ts @@ -55,6 +55,7 @@ import type { MarkerLifetimeValue, PieceColor, } from "../../schema.js"; +import { numberOrResolver, enumOrResolverFor } from "./param-resolver-schema.js"; import { PRIMITIVE_REGISTRY } from "./registry.js"; import type { EffectPrimitive, PrimitiveApplyContext } from "./types.js"; @@ -97,9 +98,9 @@ const LIFETIME_SCHEMA = z.union([ const schema = z.object({ markerKind: z.enum(MARKER_KIND_VALUES), - square: z.number().int().min(0).max(63), + square: numberOrResolver({ min: 0, max: 63 }), lifetime: LIFETIME_SCHEMA, - owner: z.enum(PIECE_COLORS).optional(), + owner: enumOrResolverFor(PIECE_COLORS).optional(), links: z.array(z.number().int().nonnegative()).optional(), }); type Params = z.infer; @@ -110,7 +111,7 @@ const descriptor: EffectPrimitive = { description: "Spawns a square-state marker entity (mine, pit, portal-end, frozen-square, treasure, death-square, tornado, or blocked) on the given square via the engine's canonical spawnMarker factory.", longDescription: - "Imperative primitive — only legal inside a trigger's primitives/then/else array (top-level placement is rejected by the validator with code 'descriptor.primitives.imperative-in-passive'). Calls engine.spawnMarker(markerKind, square, { lifetime, owner, links }), which writes EntityKind='marker', MarkerKind, Position, MarkerLifetime, and (when supplied) MarkerOwner / MarkerLinks. Lifetime is REQUIRED — markers without a lifetime would leak. Owner is optional (omit for neutral markers); links is optional (e.g. portal-end pairs link to each other). Square may be authored as a literal index, a { ctx-build: { col, row } } shape, or a { $var: 'name' } binding; the param resolver substitutes both shapes before this apply() runs. Stacking is permitted — markers on the same square are ordered by MARKER_KIND_PRIORITY at lookup time (see getMarkersAtSquare in engine.ts).", + "Drops a marker (mine, pit, portal-end, frozen square, treasure, death square, tornado, or blocked tile) onto a chosen square. Only works inside a trigger. You must say how long it lasts: forever, until a specific move number, or one-shot (consumed when a piece steps on it). You can optionally tag it with an owner (white or black) or link it to another marker (e.g. portals link in pairs). Multiple markers can stack on the same square; when something looks up 'what's on this square', the highest-priority marker is found first.", examples: [ { title: "Drop a permanent mine on e4", @@ -120,7 +121,7 @@ const descriptor: EffectPrimitive = { lifetime: { kind: "permanent" }, }, effect: - "When the wrapping trigger fires, a permanent mine marker materializes on e4 (square 28). Any piece that subsequently enters that square triggers the on-piece-entered-marker hooks bound to 'mine'.", + "A permanent mine appears on e4. Any piece that later steps onto e4 triggers whatever rules are listening for 'piece entered a mine'.", }, { title: "One-shot frozen square aligned with white", @@ -131,10 +132,10 @@ const descriptor: EffectPrimitive = { owner: "white", }, effect: - "Spawns a one-shot frozen-square on d5 owned by white. The first piece that enters consumes it (lifetime='one-shot' is retracted by the entry trigger).", + "A frozen-square owned by white appears on d5. The first piece to step on it consumes the marker, and the marker disappears.", }, { - title: "Portal pair via links (T28 sets only one end)", + title: "Spawn one end of a portal that links to another marker", params: { markerKind: "portal-end", square: 12, @@ -142,7 +143,7 @@ const descriptor: EffectPrimitive = { links: [42], }, effect: - "Spawns a portal-end on square 12 linking to entity id 42 (the other half of the pair, spawned by a sibling spawn-marker / spawn-marker-pair call). Inside an iteration arm where 'paired' binds to the partner id, authoring `links: [{ $var: 'paired' }]` resolves to the bound numeric id before apply() runs.", + "A portal-end appears on square 12 connected to its partner. Use this when you want to spawn one end on its own; for a paired portal it's usually easier to use the spawn-marker-pair primitive.", }, ], paramsSchema: schema, @@ -151,6 +152,13 @@ const descriptor: EffectPrimitive = { // all of which already have consumer subsystems registered (T6/T7/T10). seedsAttrs: [], apply(ctx: PrimitiveApplyContext, params: Params): void { + // By the time apply() runs, params.square and params.owner have been + // resolved to literals by the param resolver (see param-resolver.ts). + // The schema accepts resolver shapes for validation, but the runtime + // guarantees literals here. We cast to the literal type. + const squareLiteral = params.square as number; + const ownerLiteral = params.owner as PieceColor | undefined; + // Build the engine.spawnMarker opts bag carefully: omit owner / // links when undefined so we don't write meaningless EAV facts // (engine.spawnMarker checks `!== undefined` before inserting). @@ -160,12 +168,12 @@ const descriptor: EffectPrimitive = { readonly links?: readonly EntityId[]; } = { lifetime: params.lifetime, - ...(params.owner !== undefined ? { owner: params.owner } : {}), + ...(ownerLiteral !== undefined ? { owner: ownerLiteral } : {}), ...(params.links !== undefined ? { links: params.links as readonly unknown[] as readonly EntityId[] } : {}), }; - ctx.engine.spawnMarker(params.markerKind, params.square, opts); + ctx.engine.spawnMarker(params.markerKind, squareLiteral, opts); }, };