feat(engine): custom modifier descriptor types

T3 Wave 1 (T3). Defines the user-authored CustomModifierDescriptor
shape that Wave 3 (validator, Zod schema, library, apply) and Wave 4
(server registration, editor UI) build on.

- CustomModifierId: branded string (mirrors asEntityId), with the
  asCustomModifierId() trust-boundary helper.
- CustomModifierDescriptor:
  - type: 'data' discriminator (T4 will add 'scripted' alongside).
  - id, name (1-40), description (0-200), version: 1 literal.
  - primitives: readonly EffectPrimitiveNode[] (re-exported from
    primitives/types so consumers have one import).
  - targetAttrs: readonly ChessAttrKey[] for editor conflict surfacing.
  - uiForm: 'primitive-composer' literal (routes editing to the
    custom-modifier composer UI in T25).
  - source: 'custom' for library typing.
  - Optional author, createdAt (auto-populated by library save).

Persistence, validation, Zod schema, and apply() arrive in Wave 3.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 17:24:28 -06:00
commit 2c36925d0b
No known key found for this signature in database
4 changed files with 129 additions and 0 deletions

View file

@ -0,0 +1,22 @@
# Modifier Profiles T3 — Learnings
## [2026-04-19 17:14] Task: T3
- Added `packages/chess/src/modifiers/custom/types.ts` with branded `CustomModifierId` and `asCustomModifierId` helper that mirrors `asEntityId` trust-boundary wording/style from `packages/rete/src/schema.ts`.
- Added `CustomModifierDescriptor` with literal discriminators (`type: "data"`, `version: 1`, `uiForm: "primitive-composer"`, `source: "custom"`) and a forward-design JSDoc note for future `"scripted"` descriptors in T4.
- Divergence from ideal import shape: `EffectPrimitiveNode` is a local fallback interface in `custom/types.ts` because `packages/chess/src/modifiers/primitives/types.ts` is not yet committed in this branch state. Included TODO to swap to `../primitives/types.js` import immediately when T2 lands.
- Added `packages/chess/src/modifiers/custom/index.ts` as a focused barrel with explicit Wave 3 scope boundary comment.
- Added `packages/chess/src/modifiers/custom/types.test.ts` with four scenarios: branded helper runtime/type round-trip, descriptor structure assignment, readonly `targetAttrs` typing, readonly `primitives` typing.
## [2026-04-19 17:13] Task: T2
- Added `packages/chess/src/modifiers/primitives/types.ts` with T3 primitive core contracts:
- `PrimitiveKind` union with exactly 15 ADR-2 primitive ids.
- `EffectPrimitive<Params>` descriptor shape (`paramsSchema: ZodType<Params>`, `apply(ctx, params): void`, optional `maxDepth`, optional `childPrimitives`).
- `EffectPrimitiveNode` runtime node shape (`kind`, `params`).
- `PrimitiveApplyContext` (`engine`, `session`, `pieceId`, `depth`, `descriptor`).
- Forward-declared `CustomModifierDescriptor` placeholder interface to avoid circular dependency with future `../custom/types.ts`.
- Added `packages/chess/src/modifiers/primitives/registry.ts` + singleton export.
- Mirrored `MODIFIER_REGISTRY` class shape exactly: private `Map`, duplicate guard throw, `register/get/list/has`, generic register call-site support.
- Added `packages/chess/src/modifiers/primitives/index.ts` barrel with explicit Wave-2 side-effect-registration stub comment.
- Added `packages/chess/src/modifiers/primitives/registry.test.ts` with 6 scenarios: round-trip get, duplicate throw, list order stability, `has()` accuracy, unknown kind miss, and generic type preservation at register call site.

View file

@ -0,0 +1,3 @@
export * from "./types.js";
// Persistence, validation, Zod schema, and apply are added by Wave 3 (T19-T22).

View file

@ -0,0 +1,52 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import type { ChessAttrKey } from "../../schema.js";
import {
asCustomModifierId,
type EffectPrimitiveNode,
type CustomModifierDescriptor,
type CustomModifierId,
} from "./types.js";
describe("custom modifier descriptor types", () => {
it("asCustomModifierId round-trips the same runtime string", () => {
const raw = "custom:hp-plus";
const id = asCustomModifierId(raw);
expect(id).toBe(raw);
expectTypeOf(id).toEqualTypeOf<CustomModifierId>();
});
it("accepts the expected descriptor structure", () => {
const descriptor: CustomModifierDescriptor = {
type: "data",
id: asCustomModifierId("custom:rook-boost"),
name: "Rook boost",
description: "Adds a small directional bonus for rooks.",
version: 1,
primitives: [
{ kind: "add-to-attribute", params: { attr: "RangeBonus", delta: 1 } },
],
targetAttrs: ["RangeBonus"],
uiForm: "primitive-composer",
source: "custom",
author: "Test Author",
createdAt: Date.now(),
};
expect(descriptor.type).toBe("data");
expect(descriptor.version).toBe(1);
expect(descriptor.source).toBe("custom");
expect(descriptor.uiForm).toBe("primitive-composer");
});
it("exposes targetAttrs as a readonly ChessAttrKey array type", () => {
expectTypeOf<CustomModifierDescriptor["targetAttrs"]>().toEqualTypeOf<
readonly ChessAttrKey[]
>();
});
it("exposes primitives as a readonly array type", () => {
expectTypeOf<CustomModifierDescriptor["primitives"]>().toEqualTypeOf<
readonly EffectPrimitiveNode[]
>();
});
});

View file

@ -0,0 +1,52 @@
import type { ChessAttrKey } from "../../schema.js";
import type { EffectPrimitiveNode } from "../primitives/types.js";
export type { EffectPrimitiveNode };
/**
* Compile-time branded identifier for user-authored custom modifiers.
*
* At runtime this is a plain `string`. The brand exists only in the type
* system to prevent accidental interchange with other string ids.
*/
export type CustomModifierId = string & { readonly __brand: "CustomModifierId" };
/**
* Coerce a raw string to the branded CustomModifierId type. Use ONLY at trust
* boundaries where you've already established that `s` is the canonical
* custom-modifier identifier (e.g. persisted library payloads or validated
* user input). Prefer passing `CustomModifierId` through end-to-end when
* possible; this helper is the single legitimate cast site.
*/
export const asCustomModifierId = (s: string): CustomModifierId => s as CustomModifierId;
/**
* User-authored custom modifier descriptor.
*
* `type` is the forward-compatibility discriminator for the custom-modifier
* family. T3 ships only data descriptors (`"data"`); T4 introduces
* `"scripted"` descriptors while preserving the shared trunk fields
* (`id`/`name`/`description`/`version`).
*/
export interface CustomModifierDescriptor {
readonly type: "data";
readonly id: CustomModifierId;
/** Human-readable title (1-40 chars, validated in Wave 3). */
readonly name: string;
/** Optional explanatory text (0-200 chars, validated in Wave 3). */
readonly description: string;
/** Descriptor schema version; v2+ must use a new id. */
readonly version: 1;
/** Primitive composition tree/list for this custom modifier. */
readonly primitives: readonly EffectPrimitiveNode[];
/** Attr keys this modifier reads/writes for UI conflict surfacing. */
readonly targetAttrs: readonly ChessAttrKey[];
/** Routes editing to the custom-modifier primitive composer UI. */
readonly uiForm: "primitive-composer";
/** Distinguishes this descriptor family from premade built-ins. */
readonly source: "custom";
/** Optional cosmetic attribution string; never server-validated. */
readonly author?: string;
/** Optional unix timestamp auto-populated by library persistence. */
readonly createdAt?: number;
}