From 98f7f51e46bfbc4a1763b5b1d1373a389507453e Mon Sep 17 00:00:00 2001 From: JB Date: Wed, 26 Aug 2026 23:50:03 -0400 Subject: [PATCH] feat: add authored mechanic contracts --- .../mechanic-authored-description.test.ts | 283 +++ .../mechanic-authored-description.ts | 431 +++++ .../calculations/mechanic-lifecycle.test.ts | 458 +++++ src/lib/calculations/mechanic-lifecycle.ts | 694 ++++++++ .../mechanic-public-renderer.test.ts | 714 ++++++++ .../calculations/mechanic-public-renderer.ts | 1581 +++++++++++++++++ src/lib/calculations/mechanic.test.ts | 98 + src/lib/calculations/mechanic.ts | 19 +- 8 files changed, 4272 insertions(+), 6 deletions(-) create mode 100644 src/lib/calculations/mechanic-authored-description.test.ts create mode 100644 src/lib/calculations/mechanic-authored-description.ts create mode 100644 src/lib/calculations/mechanic-lifecycle.test.ts create mode 100644 src/lib/calculations/mechanic-lifecycle.ts create mode 100644 src/lib/calculations/mechanic-public-renderer.test.ts create mode 100644 src/lib/calculations/mechanic-public-renderer.ts diff --git a/src/lib/calculations/mechanic-authored-description.test.ts b/src/lib/calculations/mechanic-authored-description.test.ts new file mode 100644 index 0000000..6fde376 --- /dev/null +++ b/src/lib/calculations/mechanic-authored-description.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from 'vitest'; +import { + materializeMechanicAuthoredDescription, + mechanicAuthoredFingerprintPayload, + parseMechanicAuthoredSource, + type MechanicAuthoredSource, +} from './mechanic-authored-description.js'; + +const talentSource = (): MechanicAuthoredSource => ({ + version: 1, + owner: { type: 'talent', id: 'talent-1', scope: 'standard' }, + template: 'Gain {1} for {duration}. Gain {1} again, up to {max_stacks}.', + variants: ['I', 'II'].map((key, index) => ({ + key, + materialized: `Gain ${10 + index}% for 5 seconds. Gain ${10 + index}% again, up to 3.`, + slots: [ + { key: 'max_stacks', value: '3', order: 2 }, + { key: '1', value: `${10 + index}%`, order: 0, unit: 'percent' }, + { key: 'duration', value: '5 seconds', order: 1, unit: 'seconds' }, + ], + })), +}); + +describe('mechanic authored descriptions', () => { + it('materializes repeated strict tokens for ordered Talent variants', () => { + expect(materializeMechanicAuthoredDescription(talentSource())).toEqual({ + ok: true, + variants: talentSource().variants.map((variant) => ({ + key: variant.key, + text: variant.materialized, + slots: variant.slots, + })), + }); + }); + + it.each([ + { + owner: { type: 'talent', id: 'exotic', scope: 'exotic' } as const, + keys: ['I'], + }, + { + owner: { + type: 'talent', + id: 'exotic', + scope: 'exotic', + hasTierII: true, + } as const, + keys: ['I', 'II'], + }, + { + owner: { type: 'os_protocol_talent', id: 'protocol' } as const, + keys: ['base'], + }, + { + owner: { type: 'skill_chip_talent', id: 'chip' } as const, + keys: ['minimum', 'maximum'], + }, + ])('enforces the $owner.type variant contract', ({ owner, keys }) => { + const source: MechanicAuthoredSource = { + version: 1, + owner, + template: 'Gain {1}.', + variants: keys.map((key) => ({ + key, + materialized: 'Gain 10%.', + slots: [{ key: '1', value: '10%', order: 0 }], + })), + }; + expect(materializeMechanicAuthoredDescription(source).ok).toBe(true); + }); + + it('requires Specialization rank source identity', () => { + const result = materializeMechanicAuthoredDescription({ + version: 1, + owner: { type: 'specialization_tree_rank', id: 'rank-1' }, + template: 'Gain {1}.', + variants: [ + { + key: 'base', + materialized: 'Gain 10%.', + slots: [{ key: '1', value: '10%', order: 0 }], + }, + ], + }); + expect(result).toMatchObject({ + ok: false, + errors: [{ code: 'invalid_specialization_source' }], + }); + }); + + it('rejects malformed and slot-injected placeholders', () => { + const malformed = talentSource(); + malformed.template = 'Gain {1.'; + malformed.variants.forEach((variant) => { + variant.materialized = 'Gain {1.'; + variant.slots = []; + }); + expect(materializeMechanicAuthoredDescription(malformed)).toMatchObject({ + ok: false, + errors: expect.arrayContaining([ + expect.objectContaining({ code: 'unknown_token' }), + ]), + }); + + const injected = talentSource(); + injected.variants[0].slots.find((slot) => slot.key === '1')!.value = + '{raw}'; + injected.variants[0].materialized = + 'Gain {raw} for 5 seconds. Gain {raw} again, up to 3.'; + expect(materializeMechanicAuthoredDescription(injected)).toMatchObject({ + ok: false, + errors: expect.arrayContaining([ + expect.objectContaining({ code: 'invalid_slot' }), + expect.objectContaining({ code: 'unresolved_token' }), + ]), + }); + }); + + it('rejects unknown, unresolved, unused, and mismatched authored values', () => { + const source = talentSource(); + source.template = 'Gain {1} for {cooldown}.'; + source.variants[0].slots.push({ key: '2', value: 'unused', order: 4 }); + const result = materializeMechanicAuthoredDescription(source); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(new Set(result.errors.map((error) => error.code))).toEqual( + new Set([ + 'unknown_token', + 'unresolved_token', + 'unused_slot', + 'materialization_mismatch', + ]), + ); + }); + + it('rejects a missing required slot rather than retaining its placeholder', () => { + const source = talentSource(); + source.variants[0].slots = source.variants[0].slots.filter( + (slot) => slot.key !== 'duration', + ); + const result = materializeMechanicAuthoredDescription(source); + expect(result).toMatchObject({ + ok: false, + errors: expect.arrayContaining([ + expect.objectContaining({ code: 'unresolved_token' }), + ]), + }); + }); + + it('preserves exact authored whitespace and materialized prose', () => { + const source = talentSource(); + source.template = ' Gain {1}.\nNext line. '; + for (const variant of source.variants) { + const value = variant.slots.find((slot) => slot.key === '1')?.value; + variant.slots = variant.slots.filter((slot) => slot.key === '1'); + variant.materialized = ` Gain ${value}.\nNext line. `; + } + const result = materializeMechanicAuthoredDescription(source); + expect(result.ok && result.variants[0].text).toBe( + ' Gain 10%.\nNext line. ', + ); + }); + + it('parses unknown source values without throwing on malformed input', () => { + expect(parseMechanicAuthoredSource(null)).toMatchObject({ + ok: false, + errors: [{ code: 'invalid_source' }], + }); + expect( + parseMechanicAuthoredSource({ + ...talentSource(), + owner: { + type: new String('os_protocol_talent'), + id: 'protocol-1', + }, + }), + ).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] }); + expect( + parseMechanicAuthoredSource({ + ...talentSource(), + variants: [{ key: 'I', materialized: 10, slots: [] }], + }), + ).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] }); + expect(parseMechanicAuthoredSource(talentSource())).toMatchObject({ + ok: true, + }); + }); + + it('rejects malformed typed values and whitespace-only authored content', () => { + expect( + materializeMechanicAuthoredDescription({ + ...talentSource(), + owner: { + type: 'talent', + id: 'talent-1', + scope: 'exotic', + hasTierII: 'false', + }, + }), + ).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] }); + + const whitespace = talentSource(); + whitespace.template = ' '; + whitespace.variants.forEach((variant) => { + variant.materialized = ' '; + variant.slots = []; + }); + expect(materializeMechanicAuthoredDescription(whitespace)).toMatchObject({ + ok: false, + errors: expect.arrayContaining([ + expect.objectContaining({ code: 'invalid_template' }), + ]), + }); + }); + + it('strictly parses Talent Tier II and unknown source fields', () => { + expect( + parseMechanicAuthoredSource({ + ...talentSource(), + owner: { + type: 'talent', + id: 'talent-1', + scope: 'exotic', + hasTierII: 'false', + }, + }), + ).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] }); + expect( + parseMechanicAuthoredSource({ ...talentSource(), unexpected: true }), + ).toMatchObject({ ok: false, errors: [{ code: 'invalid_source' }] }); + }); + + it('normalizes omitted and false optional Talent Tier II flags identically', () => { + const omitted = talentSource(); + const explicit = talentSource(); + explicit.owner = { + type: 'talent', + id: 'talent-1', + scope: 'standard', + hasTierII: false, + }; + expect(mechanicAuthoredFingerprintPayload(omitted)).toEqual( + mechanicAuthoredFingerprintPayload(explicit), + ); + }); + + it('reports unsupported parser contracts specifically', () => { + expect( + parseMechanicAuthoredSource({ ...talentSource(), version: 2 }), + ).toMatchObject({ + ok: false, + errors: [{ code: 'unsupported_contract', path: 'version' }], + }); + }); + + it('rejects duplicate slot ordering', () => { + const source = talentSource(); + source.variants[0].slots[1].order = source.variants[0].slots[0].order; + const result = materializeMechanicAuthoredDescription(source); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'invalid_slot' }), + ]), + ); + }); + + it('creates a versioned canonical fingerprint payload with ordered slots', () => { + expect(mechanicAuthoredFingerprintPayload(talentSource())).toMatchObject({ + kind: 'mechanic_authored_source', + version: 1, + owner: { type: 'talent', id: 'talent-1' }, + variants: [ + { + key: 'I', + slots: [{ key: '1' }, { key: 'duration' }, { key: 'max_stacks' }], + }, + { key: 'II' }, + ], + }); + }); +}); diff --git a/src/lib/calculations/mechanic-authored-description.ts b/src/lib/calculations/mechanic-authored-description.ts new file mode 100644 index 0000000..9b4b035 --- /dev/null +++ b/src/lib/calculations/mechanic-authored-description.ts @@ -0,0 +1,431 @@ +export const MECHANIC_AUTHORED_CONTRACT_VERSION = 1 as const; + +export type MechanicAuthoredOwner = + | { + type: 'talent'; + id: string; + scope: 'standard' | 'exotic'; + hasTierII?: boolean; + } + | { type: 'os_protocol_talent'; id: string } + | { type: 'skill_chip_talent'; id: string } + | { type: 'specialization_tree_rank'; id: string }; + +export type MechanicAuthoredSlot = { + key: string; + value: string; + order: number; + unit?: string; + attributeId?: string; +}; + +export type MechanicAuthoredVariant = { + key: string; + materialized: string; + slots: MechanicAuthoredSlot[]; +}; + +export type SpecializationAuthoredInputs = { + rankDescription: string; + effectId: string; + effectTemplate: string; +}; + +export type MechanicAuthoredSource = { + version: typeof MECHANIC_AUTHORED_CONTRACT_VERSION; + owner: MechanicAuthoredOwner; + template: string; + variants: MechanicAuthoredVariant[]; + specialization?: SpecializationAuthoredInputs; +}; + +export type MaterializedMechanicVariant = { + key: string; + text: string; + slots: MechanicAuthoredSlot[]; +}; + +export type MechanicAuthoredDescriptionError = { + code: + | 'invalid_source' + | 'unsupported_contract' + | 'invalid_owner' + | 'invalid_template' + | 'invalid_variant' + | 'invalid_slot' + | 'unknown_token' + | 'unresolved_token' + | 'unused_slot' + | 'materialization_mismatch' + | 'invalid_specialization_source'; + path: string; + message: string; +}; + +export type MechanicAuthoredDescriptionResult = + | { ok: true; variants: MaterializedMechanicVariant[] } + | { ok: false; errors: MechanicAuthoredDescriptionError[] }; + +export type ParsedMechanicAuthoredSourceResult = + | { ok: true; value: MechanicAuthoredSource } + | { ok: false; errors: MechanicAuthoredDescriptionError[] }; + +export type MechanicAuthoredFingerprintPayload = { + kind: 'mechanic_authored_source'; + version: typeof MECHANIC_AUTHORED_CONTRACT_VERSION; + owner: MechanicAuthoredOwner; + template: string; + variants: Array<{ + key: string; + materialized: string; + slots: MechanicAuthoredSlot[]; + }>; + specialization?: SpecializationAuthoredInputs; +}; + +const TOKEN_PATTERN = /\{([^{}]+)\}/g; +const ALLOWED_NAMED_TOKENS = new Set(['duration', 'max_stacks']); +const objectValue = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); +const hasOnlyKeys = ( + value: Record, + allowed: readonly string[], +) => Object.keys(value).every((key) => allowed.includes(key)); + +function requiredVariantKeys(owner: MechanicAuthoredOwner): readonly string[] { + switch (owner.type) { + case 'talent': + return owner.scope === 'standard' || owner.hasTierII + ? ['I', 'II'] + : ['I']; + case 'os_protocol_talent': + case 'specialization_tree_rank': + return ['base']; + case 'skill_chip_talent': + return ['minimum', 'maximum']; + } +} + +function tokenKeys(template: string) { + return [...template.matchAll(TOKEN_PATTERN)].map((match) => match[1]); +} + +function isAllowedToken(key: string) { + return ALLOWED_NAMED_TOKENS.has(key) || /^[1-9]\d*$/.test(key); +} + +function sourceErrors(source: MechanicAuthoredSource) { + const errors: MechanicAuthoredDescriptionError[] = []; + if (source.version !== MECHANIC_AUTHORED_CONTRACT_VERSION) { + errors.push({ + code: 'unsupported_contract', + path: 'version', + message: `Authored-description contract version ${String(source.version)} is not supported.`, + }); + } + if (!source.owner.id.trim()) { + errors.push({ + code: 'invalid_owner', + path: 'owner.id', + message: 'The owner ID is required.', + }); + } + if (!source.template.trim()) { + errors.push({ + code: 'invalid_template', + path: 'template', + message: 'The authored template is required.', + }); + } + + const tokens = tokenKeys(source.template); + if (/[{}]/.test(source.template.replace(TOKEN_PATTERN, ''))) { + errors.push({ + code: 'unknown_token', + path: 'template', + message: 'The authored template contains malformed placeholder braces.', + }); + } + for (const token of tokens) { + if (!isAllowedToken(token)) { + errors.push({ + code: 'unknown_token', + path: 'template', + message: `The authored template contains unsupported token {${token}}.`, + }); + } + } + + if (source.owner.type === 'specialization_tree_rank') { + if ( + !source.specialization?.rankDescription.trim() || + !source.specialization.effectId.trim() || + !source.specialization.effectTemplate.trim() + ) { + errors.push({ + code: 'invalid_specialization_source', + path: 'specialization', + message: + 'Specialization rank description, effect identity, and effect template are required.', + }); + } + } else if (source.specialization !== undefined) { + errors.push({ + code: 'invalid_specialization_source', + path: 'specialization', + message: + 'Specialization inputs are valid only for a Specialization tree-rank owner.', + }); + } + + const expected = requiredVariantKeys(source.owner); + const actual = source.variants.map((variant) => variant.key); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + errors.push({ + code: 'invalid_variant', + path: 'variants', + message: `Expected authored variants in this order: ${expected.join(', ')}.`, + }); + } + return { errors, tokens }; +} + +function materializeKnownMechanicAuthoredDescription( + source: MechanicAuthoredSource, +): MechanicAuthoredDescriptionResult { + const { errors, tokens } = sourceErrors(source); + const usedTokens = new Set(tokens.filter(isAllowedToken)); + const variants: MaterializedMechanicVariant[] = []; + + source.variants.forEach((variant, variantIndex) => { + const path = `variants[${variantIndex}]`; + const slots = new Map(); + const slotOrders = new Set(); + for (const [slotIndex, slot] of variant.slots.entries()) { + if ( + !isAllowedToken(slot.key) || + !Number.isInteger(slot.order) || + slot.order < 0 || + !slot.value.trim() || + /[{}]/.test(slot.value) || + slots.has(slot.key) || + slotOrders.has(slot.order) + ) { + errors.push({ + code: 'invalid_slot', + path: `${path}.slots[${slotIndex}]`, + message: `Authored slot ${slot.key || '(empty)'} is invalid or duplicated.`, + }); + continue; + } + slots.set(slot.key, slot); + slotOrders.add(slot.order); + if (!usedTokens.has(slot.key)) { + errors.push({ + code: 'unused_slot', + path: `${path}.slots[${slotIndex}]`, + message: `Authored slot ${slot.key} is not referenced by the template.`, + }); + } + } + + for (const token of usedTokens) { + if (!slots.has(token)) { + errors.push({ + code: 'unresolved_token', + path: `${path}.slots`, + message: `Authored token {${token}} has no value for variant ${variant.key}.`, + }); + } + } + + const text = source.template.replace(TOKEN_PATTERN, (match, key: string) => + isAllowedToken(key) ? (slots.get(key)?.value ?? match) : match, + ); + if (/[{}]/.test(text)) { + errors.push({ + code: 'unresolved_token', + path: `${path}.materialized`, + message: `Materialized variant ${variant.key} contains an unresolved placeholder.`, + }); + } + if (variant.materialized !== text) { + errors.push({ + code: 'materialization_mismatch', + path: `${path}.materialized`, + message: `Stored materialization for variant ${variant.key} does not match the authored source.`, + }); + } + variants.push({ key: variant.key, text, slots: variant.slots }); + }); + + return errors.length ? { ok: false, errors } : { ok: true, variants }; +} + +function parseMechanicAuthoredSourceShape( + value: unknown, +): ParsedMechanicAuthoredSourceResult { + const invalid = ( + path: string, + message: string, + ): ParsedMechanicAuthoredSourceResult => ({ + ok: false, + errors: [{ code: 'invalid_source', path, message }], + }); + if (!objectValue(value)) + return invalid('', 'The authored source must be an object.'); + if ( + !hasOnlyKeys(value, [ + 'version', + 'owner', + 'template', + 'variants', + 'specialization', + ]) + ) + return invalid('', 'The authored source contains an unknown field.'); + if (value.version !== MECHANIC_AUTHORED_CONTRACT_VERSION) + return { + ok: false, + errors: [ + { + code: 'unsupported_contract', + path: 'version', + message: 'The authored source has an unsupported contract version.', + }, + ], + }; + if (!objectValue(value.owner) || typeof value.owner.id !== 'string') + return invalid('owner', 'The authored source owner is invalid.'); + const ownerType = value.owner.type; + if ( + typeof ownerType !== 'string' || + ![ + 'talent', + 'os_protocol_talent', + 'skill_chip_talent', + 'specialization_tree_rank', + ].includes(ownerType) + ) + return invalid('owner.type', 'The authored source owner type is invalid.'); + if (ownerType === 'talent') { + if (!hasOnlyKeys(value.owner, ['type', 'id', 'scope', 'hasTierII'])) + return invalid('owner', 'The Talent owner contains an unknown field.'); + if (value.owner.scope !== 'standard' && value.owner.scope !== 'exotic') + return invalid('owner.scope', 'The Talent scope is invalid.'); + if ( + value.owner.hasTierII !== undefined && + typeof value.owner.hasTierII !== 'boolean' + ) + return invalid( + 'owner.hasTierII', + 'Talent Tier II presence must be boolean.', + ); + } else if (!hasOnlyKeys(value.owner, ['type', 'id'])) { + return invalid( + 'owner', + 'The authored source owner contains an unknown field.', + ); + } + if (typeof value.template !== 'string' || !Array.isArray(value.variants)) + return invalid( + 'template', + 'The authored template or variants are invalid.', + ); + for (const [variantIndex, variant] of value.variants.entries()) { + if ( + !objectValue(variant) || + !hasOnlyKeys(variant, ['key', 'materialized', 'slots']) || + typeof variant.key !== 'string' || + typeof variant.materialized !== 'string' || + !Array.isArray(variant.slots) + ) + return invalid( + `variants[${variantIndex}]`, + 'The authored variant is invalid.', + ); + for (const [slotIndex, slot] of variant.slots.entries()) { + if ( + !objectValue(slot) || + !hasOnlyKeys(slot, ['key', 'value', 'order', 'unit', 'attributeId']) || + typeof slot.key !== 'string' || + typeof slot.value !== 'string' || + typeof slot.order !== 'number' || + (slot.unit !== undefined && typeof slot.unit !== 'string') || + (slot.attributeId !== undefined && typeof slot.attributeId !== 'string') + ) + return invalid( + `variants[${variantIndex}].slots[${slotIndex}]`, + 'The authored slot is invalid.', + ); + } + } + if (value.specialization !== undefined) { + if ( + !objectValue(value.specialization) || + !hasOnlyKeys(value.specialization, [ + 'rankDescription', + 'effectId', + 'effectTemplate', + ]) || + typeof value.specialization.rankDescription !== 'string' || + typeof value.specialization.effectId !== 'string' || + typeof value.specialization.effectTemplate !== 'string' + ) + return invalid( + 'specialization', + 'The Specialization authored inputs are invalid.', + ); + } + return { ok: true, value: value as unknown as MechanicAuthoredSource }; +} + +export function materializeMechanicAuthoredDescription( + value: unknown, +): MechanicAuthoredDescriptionResult { + const parsed = parseMechanicAuthoredSourceShape(value); + return parsed.ok + ? materializeKnownMechanicAuthoredDescription(parsed.value) + : parsed; +} + +export function parseMechanicAuthoredSource( + value: unknown, +): ParsedMechanicAuthoredSourceResult { + const parsed = parseMechanicAuthoredSourceShape(value); + if (!parsed.ok) return parsed; + const materialized = materializeKnownMechanicAuthoredDescription( + parsed.value, + ); + return materialized.ok ? parsed : materialized; +} + +export function mechanicAuthoredFingerprintPayload( + source: MechanicAuthoredSource, +): MechanicAuthoredFingerprintPayload | null { + const materialized = materializeMechanicAuthoredDescription(source); + if (!materialized.ok) return null; + return { + kind: 'mechanic_authored_source', + version: MECHANIC_AUTHORED_CONTRACT_VERSION, + owner: + source.owner.type === 'talent' + ? { + type: source.owner.type, + id: source.owner.id, + scope: source.owner.scope, + ...(source.owner.hasTierII ? { hasTierII: true } : {}), + } + : { type: source.owner.type, id: source.owner.id }, + template: source.template, + variants: materialized.variants.map((variant) => ({ + key: variant.key, + materialized: variant.text, + slots: [...variant.slots].sort((left, right) => left.order - right.order), + })), + ...(source.specialization ? { specialization: source.specialization } : {}), + }; +} diff --git a/src/lib/calculations/mechanic-lifecycle.test.ts b/src/lib/calculations/mechanic-lifecycle.test.ts new file mode 100644 index 0000000..b0bb146 --- /dev/null +++ b/src/lib/calculations/mechanic-lifecycle.test.ts @@ -0,0 +1,458 @@ +import { describe, expect, it } from 'vitest'; +import { + canonicalMechanicProgramJson, + classifyMechanicLifecycle, + hashMechanicAuthoredSource, + hashMechanicProgram, + mechanicProgramSemanticPayload, + type MechanicLifecycleInput, + type MechanicModelRevision, + type MechanicProgramHashSource, +} from './mechanic-lifecycle.js'; +import type { MechanicProgram } from './mechanic.js'; + +const program = (): MechanicProgram => ({ + id: 'generated-program-id', + name: 'Sustained fire', + scenarioInputs: [ + { + key: 'distance', + label: 'Distance', + kind: 'number', + readiness: 'requires_assumption', + description: 'Distance to target', + defaultValue: 10, + bestCaseValue: 5, + min: 0, + max: 100, + }, + { + key: 'headshot', + label: 'Headshots', + kind: 'toggle', + defaultValue: false, + bestCaseValue: true, + }, + ], + pools: [ + { + key: 'stacks', + label: 'Stacks', + scope: 'agent', + initial: 0, + min: 0, + max: 3, + bestCaseValue: 3, + }, + { + key: 'cooldown', + label: 'Cooldown', + scope: 'agent', + initial: 0, + min: 0, + expiresAfterSeconds: 10, + }, + ], + clauses: [ + { + id: 'generated-clause-id', + label: 'On weapon hit', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [ + { + kind: 'add', + pool: 'stacks', + value: { ref: { source: 'scenario', key: 'headshot' } }, + }, + ], + outcomes: [ + { + id: 'generated-outcome-id', + label: 'Weapon damage', + phase: 'on_event', + kind: 'attribute_modifier', + scope: 'equipped_weapon', + value: 5, + basis: 'flat', + attributeId: 'weaponDamage', + perStack: true, + }, + ], + }, + ], +}); + +const hashSource = (): MechanicProgramHashSource => ({ + ...program(), + simulationReadiness: 'captured_only', + sourceVariants: ['I', 'II'], +}); + +const revision = ( + overrides: Partial = {}, +): MechanicModelRevision => ({ + program: program(), + sourceFingerprint: 'a'.repeat(64), + programHash: '0'.repeat(64), + mechanicContractVersion: 1, + simulationReadiness: 'ready', + ...overrides, +}); + +const lifecycleInput = ( + selectedRevision: MechanicModelRevision | null, + publiclyRenderable = true, +) => ({ + selectedRevision, + currentSourceFingerprint: 'a'.repeat(64), + ...(publiclyRenderable + ? { resolveAttributeLabel: (attributeId: string) => attributeId } + : {}), +}); + +describe('Mechanic Program lifecycle and semantic hashing', () => { + it('builds explicit canonical JSON and excludes generated IDs and provenance', () => { + const source = hashSource() as MechanicProgramHashSource & + Record; + source.persistenceRowId = 'row-1'; + source.updatedAt = '2026-08-27T00:00:00Z'; + source.editorState = { activeClause: 'generated-clause-id' }; + source.recipeProvenance = 'recipe-a'; + + const json = canonicalMechanicProgramJson(source); + expect(json).toContain('"kind":"mechanic_program"'); + expect(json).toContain('"version":1'); + expect(json).not.toContain('generated-program-id'); + expect(json).not.toContain('generated-clause-id'); + expect(json).not.toContain('generated-outcome-id'); + expect(json).not.toContain('persistenceRowId'); + expect(json).not.toContain('recipeProvenance'); + }); + + it('does not change when keyed Scenario Inputs or pools are reordered', async () => { + const original = hashSource(); + const reordered: MechanicProgramHashSource = { + ...original, + scenarioInputs: [...(original.scenarioInputs ?? [])].reverse(), + pools: [...(original.pools ?? [])].reverse(), + }; + await expect(hashMechanicProgram(original)).resolves.toBe( + await hashMechanicProgram(reordered), + ); + }); + + it('changes when clause, mutation, or outcome execution/render order changes', async () => { + const original = program(); + const reorderedClause: MechanicProgram = { + ...original, + clauses: [ + ...original.clauses, + { + id: 'second-clause', + label: 'Later clause', + when: 'tick', + reducer: { kind: 'none' }, + mutations: [], + }, + ], + }; + const reorderedMutation: MechanicProgram = { + ...original, + clauses: [ + { + ...original.clauses[0], + mutations: [{ kind: 'none' }, ...original.clauses[0].mutations], + }, + ], + }; + const reorderedOutcome: MechanicProgram = { + ...original, + clauses: [ + { + ...original.clauses[0], + outcomes: [ + ...(original.clauses[0].outcomes ?? []), + { + ...(original.clauses[0].outcomes?.[0] ?? { + id: 'second-outcome', + label: 'Second', + phase: 'on_event', + kind: 'marker', + scope: 'self', + value: 0, + basis: 'flat', + }), + id: 'second-outcome', + label: 'Second', + }, + ], + }, + ], + }; + const originalHash = await hashMechanicProgram(original); + await expect(hashMechanicProgram(reorderedClause)).resolves.not.toBe( + originalHash, + ); + await expect(hashMechanicProgram(reorderedMutation)).resolves.not.toBe( + originalHash, + ); + await expect(hashMechanicProgram(reorderedOutcome)).resolves.not.toBe( + originalHash, + ); + }); + + it('rejects invalid pool scopes and reducer kinds from canonical hashes', async () => { + const invalidScope = program(); + (invalidScope.pools![0] as { scope: string }).scope = 'squad'; + await expect(hashMechanicProgram(invalidScope)).rejects.toThrow( + 'unsupported scope', + ); + + const invalidReducer = program(); + (invalidReducer.clauses[0] as { reducer: { kind: string } }).reducer = { + kind: 'unknown', + }; + await expect(hashMechanicProgram(invalidReducer)).rejects.toThrow( + 'unsupported kind', + ); + }); + + it('keeps generated identities out of the semantic hash', async () => { + const changedIds: MechanicProgram = { + ...program(), + id: 'another-program-id', + clauses: [ + { + ...program().clauses[0], + id: 'another-clause-id', + outcomes: [ + { + ...(program().clauses[0].outcomes?.[0] as NonNullable< + MechanicProgram['clauses'][number]['outcomes'] + >[number]), + id: 'another-outcome-id', + }, + ], + }, + ], + }; + await expect(hashMechanicProgram(program())).resolves.toBe( + await hashMechanicProgram(changedIds), + ); + }); + + it('classifies selected models with fail-closed lifecycle boundaries', async () => { + await expect( + classifyMechanicLifecycle(lifecycleInput(null)), + ).resolves.toEqual({ + status: 'absent', + reason: 'no_selected_revision', + }); + await expect( + classifyMechanicLifecycle( + lifecycleInput( + revision({ + program: { ...program(), clauses: [] }, + simulationReadiness: 'captured_only', + }), + ), + ), + ).resolves.toEqual({ status: 'absent', reason: 'empty_captured_program' }); + await expect( + classifyMechanicLifecycle( + lifecycleInput(revision({ program: { ...program(), name: '' } })), + ), + ).resolves.toMatchObject({ status: 'absent', reason: 'invalid_program' }); + await expect( + classifyMechanicLifecycle({ + selectedRevision: revision({ sourceFingerprint: 'b'.repeat(64) }), + currentSourceFingerprint: 'c'.repeat(64), + resolveAttributeLabel: (attributeId) => attributeId, + }), + ).resolves.toEqual({ status: 'stale', reason: 'fingerprint_mismatch' }); + await expect( + classifyMechanicLifecycle( + lifecycleInput(revision({ mechanicContractVersion: 999 })), + ), + ).resolves.toEqual({ status: 'stale', reason: 'unsupported_contract' }); + await expect( + classifyMechanicLifecycle(lifecycleInput(revision(), false)), + ).resolves.toMatchObject({ + status: 'absent', + reason: 'unrenderable_program', + }); + await expect( + classifyMechanicLifecycle({ + selectedRevision: revision(), + currentSourceFingerprint: 'a'.repeat(64), + } as MechanicLifecycleInput), + ).resolves.toMatchObject({ + status: 'absent', + reason: 'unrenderable_program', + }); + }); + + it('rejects non-SHA fingerprints and publicly unrenderable program fields', async () => { + await expect( + classifyMechanicLifecycle({ + selectedRevision: revision({ sourceFingerprint: 'same' }), + currentSourceFingerprint: 'same', + resolveAttributeLabel: (attributeId) => attributeId, + }), + ).resolves.toMatchObject({ status: 'absent', reason: 'invalid_program' }); + + const invalidPhase = program(); + (invalidPhase.clauses[0].outcomes![0] as { phase: string }).phase = + 'sometimes'; + const selectedRevision = revision({ program: invalidPhase }); + selectedRevision.programHash = await hashMechanicProgram({ + ...invalidPhase, + simulationReadiness: selectedRevision.simulationReadiness, + }); + await expect( + classifyMechanicLifecycle(lifecycleInput(selectedRevision)), + ).resolves.toMatchObject({ + status: 'absent', + reason: 'unrenderable_program', + }); + }); + + it.each(['ready', 'requires_assumptions', 'captured_only'] as const)( + 'classifies a matching non-empty %s model as current', + async (simulationReadiness) => { + const selectedRevision = revision({ simulationReadiness }); + selectedRevision.programHash = await hashMechanicProgram({ + ...selectedRevision.program!, + simulationReadiness, + }); + await expect( + classifyMechanicLifecycle(lifecycleInput(selectedRevision)), + ).resolves.toEqual({ + status: 'current', + fingerprint: 'a'.repeat(64), + programHash: selectedRevision.programHash, + simulationReadiness, + }); + }, + ); + + it('fails closed when the selected program hash is missing or does not match', async () => { + await expect( + classifyMechanicLifecycle( + lifecycleInput({ + ...revision(), + programHash: undefined, + } as unknown as MechanicModelRevision), + ), + ).resolves.toEqual({ status: 'absent', reason: 'invalid_program_hash' }); + await expect( + classifyMechanicLifecycle( + lifecycleInput(revision({ programHash: '0'.repeat(64) })), + ), + ).resolves.toEqual({ status: 'absent', reason: 'invalid_program_hash' }); + }); + + it('distinguishes an explicit zero weight from an omitted weight', async () => { + const implicit = program(); + const zeroWeight: MechanicProgram = { + ...implicit, + clauses: [{ ...implicit.clauses[0], weight: 0 }], + }; + await expect(hashMechanicProgram(zeroWeight)).resolves.not.toBe( + await hashMechanicProgram(implicit), + ); + }); + + it('normalizes omitted runtime defaults to their explicit equivalents', async () => { + const implicit = program(); + const explicit: MechanicProgram = { + ...implicit, + scenarioInputs: implicit.scenarioInputs?.map((input) => ({ + ...input, + readiness: input.readiness ?? 'ready', + })), + clauses: implicit.clauses.map((clause) => ({ + ...clause, + mutations: clause.mutations.map((mutation) => + mutation.kind === 'transfer' + ? { + ...mutation, + value: mutation.value ?? 'all', + clearSource: mutation.clearSource ?? true, + } + : mutation, + ), + outcomes: clause.outcomes?.map((outcome) => ({ + ...outcome, + delivery: outcome.delivery ?? 'instant', + combatTiming: outcome.combatTiming ?? 'current', + perStack: outcome.perStack ?? false, + })), + })), + }; + await expect(hashMechanicProgram(implicit)).resolves.toBe( + await hashMechanicProgram(explicit), + ); + }); + + it('hashes a valid authored source and rejects unresolved source values', async () => { + const source = { + version: 1 as const, + owner: { type: 'os_protocol_talent' as const, id: 'protocol-1' }, + template: 'Gain {1}.', + variants: [ + { + key: 'base', + materialized: 'Gain 10%.', + slots: [{ key: '1', value: '10%', order: 0 }], + }, + ], + }; + await expect(hashMechanicAuthoredSource(source)).resolves.toMatchObject({ + ok: true, + fingerprint: expect.stringMatching(/^[0-9a-f]{64}$/), + payload: { kind: 'mechanic_authored_source', version: 1 }, + }); + source.variants[0].slots = []; + await expect(hashMechanicAuthoredSource(source)).resolves.toMatchObject({ + ok: false, + errors: expect.arrayContaining([ + expect.objectContaining({ code: 'unresolved_token' }), + ]), + }); + }); + + it('returns a stable fixed SHA-256 vector as lowercase hexadecimal', async () => { + const hash = await hashMechanicProgram({ + id: 'generated-id', + name: 'x', + clauses: [ + { + id: 'generated-clause-id', + when: 'tick', + reducer: { kind: 'none' }, + mutations: [], + }, + ], + }); + expect(hash).toBe( + '4ba3a6b9ddc7831375779c865928a715bc3dbd2b8aac77b889fc7fcc672d8339', + ); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it('exposes canonical payload fields in stable key order', () => { + const payload = mechanicProgramSemanticPayload(hashSource()); + expect(Object.keys(payload)).toEqual([ + 'kind', + 'version', + 'name', + 'scenarioInputs', + 'pools', + 'clauses', + 'simulationReadiness', + 'sourceVariants', + ]); + }); +}); diff --git a/src/lib/calculations/mechanic-lifecycle.ts b/src/lib/calculations/mechanic-lifecycle.ts new file mode 100644 index 0000000..2765d22 --- /dev/null +++ b/src/lib/calculations/mechanic-lifecycle.ts @@ -0,0 +1,694 @@ +import { + mechanicAuthoredFingerprintPayload, + materializeMechanicAuthoredDescription, + type MechanicAuthoredDescriptionError, + type MechanicAuthoredFingerprintPayload, + type MechanicAuthoredSource, +} from './mechanic-authored-description.js'; +import { + renderPublicMechanicPrograms, + type MechanicAttributeLabelResolver, +} from './mechanic-public-renderer.js'; +import { + validateMechanicProgram, + type ClauseMutation, + type EntitySelector, + type MechanicClause, + type MechanicOutcome, + type MechanicOutcomeTargeting, + type MechanicProgram, + type NumericExpression, + type PoolDefinition, + type Predicate, + type Reducer, + type ScenarioInputDefinition, + type Scalar, + type ValueReference, +} from './mechanic.js'; + +/** The version of the canonical Mechanic Program hash contract. */ +export const MECHANIC_PROGRAM_HASH_VERSION = 1 as const; +export const MECHANIC_PROGRAM_HASH_KIND = 'mechanic_program' as const; + +export type MechanicLifecycle = 'absent' | 'stale' | 'current'; +export type MechanicAuthoredFingerprintResult = + | { + ok: true; + fingerprint: string; + payload: MechanicAuthoredFingerprintPayload; + } + | { ok: false; errors: MechanicAuthoredDescriptionError[] }; +export type MechanicSimulationReadiness = + 'ready' | 'requires_assumptions' | 'captured_only'; + +export type CanonicalJsonValue = + | null + | boolean + | number + | string + | CanonicalJsonValue[] + | { [key: string]: CanonicalJsonValue }; + +export type CanonicalMechanicProgram = { + kind: typeof MECHANIC_PROGRAM_HASH_KIND; + version: typeof MECHANIC_PROGRAM_HASH_VERSION; + name: string; + simulationReadiness?: MechanicSimulationReadiness; + sourceVariants?: CanonicalJsonValue; + scenarioInputs: Array<{ [key: string]: CanonicalJsonValue }>; + pools: Array<{ [key: string]: CanonicalJsonValue }>; + clauses: Array<{ [key: string]: CanonicalJsonValue }>; +}; + +/** + * A program plus source metadata which belongs to the model contract rather + * than to the executable graph. Metadata is optional because the current + * runtime MechanicProgram type predates model revisions. + */ +export type MechanicProgramHashSource = MechanicProgram & { + simulationReadiness?: MechanicSimulationReadiness; + sourceVariants?: CanonicalJsonValue; +}; + +export type MechanicProgramHashInput = MechanicProgramHashSource; + +export type MechanicModelRevision = { + program: MechanicProgram | null; + sourceFingerprint: string; + programHash: string; + sourceVariants?: CanonicalJsonValue; + mechanicContractVersion: number; + simulationReadiness: MechanicSimulationReadiness; +}; + +export type MechanicLifecycleInput = { + selectedRevision: MechanicModelRevision | null; + currentSourceFingerprint: string; + resolveAttributeLabel?: MechanicAttributeLabelResolver; + supportedMechanicContractVersion?: number; +}; + +export type MechanicLifecycleReason = + | 'no_selected_revision' + | 'empty_program' + | 'empty_captured_program' + | 'invalid_program' + | 'unrenderable_program' + | 'fingerprint_mismatch' + | 'unsupported_contract' + | 'invalid_program_hash' + | 'hash_unavailable'; + +export type MechanicLifecycleResult = + | { + status: 'absent'; + reason: + | 'no_selected_revision' + | 'empty_program' + | 'empty_captured_program' + | 'invalid_program' + | 'unrenderable_program' + | 'invalid_program_hash' + | 'hash_unavailable'; + errors?: string[]; + } + | { + status: 'stale'; + reason: 'fingerprint_mismatch' | 'unsupported_contract'; + } + | { + status: 'current'; + fingerprint: string; + programHash: string; + simulationReadiness: MechanicSimulationReadiness; + }; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function canonicalizeValue(value: unknown): CanonicalJsonValue { + if (value === null) return null; + if (typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new TypeError( + 'Mechanic Program hash values must be finite numbers.', + ); + } + return value; + } + if (Array.isArray(value)) return value.map(canonicalizeValue); + if (isRecord(value)) { + const result: { [key: string]: CanonicalJsonValue } = {}; + for (const key of Object.keys(value).sort()) { + const child = value[key]; + if (child !== undefined) result[key] = canonicalizeValue(child); + } + return result; + } + throw new TypeError('Mechanic Program hash values must be JSON values.'); +} + +function canonicalObject(fields: Record): { + [key: string]: CanonicalJsonValue; +} { + return canonicalizeValue(fields) as { [key: string]: CanonicalJsonValue }; +} + +function stableJson(value: CanonicalJsonValue): string { + return JSON.stringify(value); +} + +function stableSemanticSort(values: T[]): T[] { + return values + .map((value, index) => ({ value, index, key: stableJson(value) })) + .sort((left, right) => { + if (left.key < right.key) return -1; + if (left.key > right.key) return 1; + return left.index - right.index; + }) + .map(({ value }) => value); +} + +function canonicalScenarioInput(input: ScenarioInputDefinition): { + [key: string]: CanonicalJsonValue; +} { + return canonicalObject({ + key: input.key, + label: input.label, + kind: input.kind, + readiness: input.readiness ?? 'ready', + description: input.description, + defaultValue: input.defaultValue, + bestCaseValue: input.bestCaseValue, + min: input.min, + max: input.max, + }); +} + +function canonicalPool(pool: PoolDefinition): { + [key: string]: CanonicalJsonValue; +} { + if (pool.scope !== 'agent' && pool.scope !== 'target') { + throw new TypeError('Mechanic pools have an unsupported scope.'); + } + return canonicalObject({ + key: pool.key, + label: pool.label, + scope: pool.scope, + initial: pool.initial, + min: pool.min, + max: pool.max, + bestCaseValue: pool.bestCaseValue, + expiresAfterSeconds: pool.expiresAfterSeconds, + }); +} + +function canonicalEntitySelector(selector: EntitySelector): { + [key: string]: CanonicalJsonValue; +} { + return canonicalObject({ + kind: selector.kind, + path: 'path' in selector ? selector.path : undefined, + }); +} + +function canonicalReference(reference: ValueReference): { + [key: string]: CanonicalJsonValue; +} { + return canonicalObject({ + source: reference.source, + path: 'path' in reference ? reference.path : undefined, + timing: 'timing' in reference ? reference.timing : undefined, + key: 'key' in reference ? reference.key : undefined, + attributeId: 'attributeId' in reference ? reference.attributeId : undefined, + pool: 'pool' in reference ? reference.pool : undefined, + entity: + 'entity' in reference && reference.entity + ? canonicalEntitySelector(reference.entity) + : undefined, + }); +} + +function canonicalExpression( + expression: NumericExpression, +): CanonicalJsonValue { + if (typeof expression === 'number') return canonicalizeValue(expression); + if (!isRecord(expression)) { + throw new TypeError('Numeric expressions must be typed JSON values.'); + } + if ('ref' in expression) { + if (!isRecord(expression.ref)) + throw new TypeError('Numeric expression references must be objects.'); + return canonicalObject({ + ref: canonicalReference(expression.ref as ValueReference), + }); + } + const op = expression.op; + switch (op) { + case 'floor': + return canonicalObject({ + op, + value: canonicalExpression(expression.value as NumericExpression), + }); + case 'clamp': + return canonicalObject({ + op, + value: canonicalExpression(expression.value as NumericExpression), + min: canonicalExpression(expression.min as NumericExpression), + max: canonicalExpression(expression.max as NumericExpression), + }); + case 'add': + case 'subtract': + case 'multiply': + case 'divide': + case 'min': + case 'max': + return canonicalObject({ + op, + left: canonicalExpression(expression.left as NumericExpression), + right: canonicalExpression(expression.right as NumericExpression), + }); + default: + throw new TypeError('Numeric expressions have an unsupported operator.'); + } +} + +function canonicalOperand(value: ValueReference | Scalar): CanonicalJsonValue { + return isRecord(value) && 'source' in value + ? canonicalReference(value as ValueReference) + : canonicalizeValue(value); +} + +function canonicalPredicate(predicate: Predicate): CanonicalJsonValue { + if (!isRecord(predicate)) { + throw new TypeError('Predicates must be typed JSON values.'); + } + switch (predicate.kind) { + case 'true': + return { kind: 'true' }; + case 'exists': + return canonicalObject({ + kind: 'exists', + value: canonicalReference(predicate.value as ValueReference), + }); + case 'compare': + return canonicalObject({ + kind: 'compare', + left: canonicalOperand(predicate.left as ValueReference | Scalar), + operator: predicate.operator, + right: canonicalOperand(predicate.right as ValueReference | Scalar), + }); + case 'all': + case 'any': + return canonicalObject({ + kind: predicate.kind, + // Logical member order is retained because deterministic rendering + // presents the members in this order. + predicates: (predicate.predicates as Predicate[]).map( + canonicalPredicate, + ), + }); + case 'not': + return canonicalObject({ + kind: 'not', + predicate: canonicalPredicate(predicate.predicate as Predicate), + }); + default: + throw new TypeError('Predicates have an unsupported kind.'); + } +} + +function canonicalReducer(reducer: Reducer): CanonicalJsonValue { + if (!isRecord(reducer)) { + throw new TypeError('Reducers must be typed JSON values.'); + } + const groupBy = + 'groupBy' in reducer && reducer.groupBy + ? canonicalReference(reducer.groupBy as ValueReference) + : undefined; + switch (reducer.kind) { + case 'none': + return { kind: 'none' }; + case 'count': + return canonicalObject({ + kind: reducer.kind, + threshold: reducer.threshold, + reset: reducer.reset ?? 'zero', + groupBy, + }); + case 'sliding_count': + return canonicalObject({ + kind: reducer.kind, + threshold: reducer.threshold, + reset: reducer.reset ?? 'zero', + windowSeconds: reducer.windowSeconds, + groupBy, + }); + case 'modulo': + return canonicalObject({ kind: 'modulo', every: reducer.every, groupBy }); + case 'consecutive_count': + return canonicalObject({ + kind: 'consecutive_count', + threshold: reducer.threshold, + groupBy, + }); + case 'distinct_count': + return canonicalObject({ + kind: 'distinct_count', + threshold: reducer.threshold, + windowSeconds: reducer.windowSeconds, + distinctBy: canonicalReference(reducer.distinctBy), + }); + case 'duration': + return canonicalObject({ + kind: 'duration', + seconds: reducer.seconds, + repeat: reducer.repeat ?? false, + groupBy, + }); + case 'periodic': + return canonicalObject({ + kind: 'periodic', + intervalSeconds: canonicalExpression(reducer.intervalSeconds), + immediate: reducer.immediate ?? false, + groupBy, + }); + case 'derived_scalar': + return canonicalObject({ + kind: 'derived_scalar', + value: canonicalExpression(reducer.value), + }); + default: + throw new TypeError('Reducers have an unsupported kind.'); + } +} + +function canonicalMutation(mutation: ClauseMutation): CanonicalJsonValue { + if (!isRecord(mutation)) { + throw new TypeError('Mutations must be typed JSON values.'); + } + const entity = + 'entity' in mutation && mutation.entity + ? canonicalEntitySelector(mutation.entity as EntitySelector) + : undefined; + switch (mutation.kind) { + case 'none': + return { kind: 'none' }; + case 'add': + case 'set': + case 'consume': + case 'derive': + return canonicalObject({ + kind: mutation.kind, + pool: mutation.pool, + entity, + value: canonicalExpression(mutation.value), + }); + case 'clear': + return canonicalObject({ kind: 'clear', pool: mutation.pool, entity }); + case 'transfer': + return canonicalObject({ + kind: 'transfer', + pool: mutation.pool, + from: canonicalEntitySelector(mutation.from), + to: canonicalEntitySelector(mutation.to), + value: + mutation.value === undefined + ? 'all' + : mutation.value === 'all' + ? 'all' + : canonicalExpression(mutation.value), + clearSource: mutation.clearSource ?? true, + }); + case 'extend_duration': + return canonicalObject({ + kind: 'extend_duration', + target: mutation.target, + seconds: canonicalExpression(mutation.seconds), + }); + case 'start_cooldown_on_max': + return canonicalObject({ + kind: 'start_cooldown_on_max', + stackPool: mutation.stackPool, + cooldownPool: mutation.cooldownPool, + }); + default: + throw new TypeError('Mutations have an unsupported kind.'); + } +} + +function canonicalTargeting( + targeting: MechanicOutcomeTargeting, +): CanonicalJsonValue { + return canonicalObject({ + kind: targeting.kind, + population: 'population' in targeting ? targeting.population : undefined, + center: 'center' in targeting ? targeting.center : undefined, + validTargets: + 'validTargets' in targeting ? targeting.validTargets : undefined, + radiusMeters: targeting.radiusMeters, + }); +} + +function canonicalOutcome(outcome: MechanicOutcome): { + [key: string]: CanonicalJsonValue; +} { + // Outcome IDs are generated identities. Every other field is either typed + // behavior or text consumed by deterministic rendering. + return canonicalObject({ + label: outcome.label, + phase: outcome.phase, + kind: outcome.kind, + scope: outcome.scope, + value: canonicalExpression(outcome.value), + basis: outcome.basis, + resource: outcome.resource, + delivery: outcome.delivery ?? 'instant', + durationSeconds: outcome.durationSeconds, + combatTiming: outcome.combatTiming ?? 'current', + attributeId: outcome.attributeId, + statePool: outcome.statePool, + perStack: outcome.perStack ?? false, + minStacks: outcome.minStacks, + maxStacks: outcome.maxStacks, + targeting: outcome.targeting + ? canonicalTargeting(outcome.targeting) + : undefined, + probabilityPercent: outcome.probabilityPercent, + }); +} + +function canonicalClause(clause: MechanicClause): { + [key: string]: CanonicalJsonValue; +} { + // Clause IDs and recipe keys are generated/persistence provenance. The + // remaining fields preserve execution and deterministic rendering order. + return canonicalObject({ + label: clause.label, + when: clause.when, + if: clause.if ? canonicalPredicate(clause.if) : undefined, + reducer: canonicalReducer(clause.reducer), + cooldownSeconds: clause.cooldownSeconds, + weight: + clause.weight !== undefined + ? canonicalExpression(clause.weight) + : undefined, + procChancePercent: clause.procChancePercent, + requiresAssignedAbilityActive: clause.requiresAssignedAbilityActive, + mutations: clause.mutations.map(canonicalMutation), + outcomes: (clause.outcomes ?? []).map(canonicalOutcome), + }); +} + +/** + * Builds the versioned semantic payload used by the Mechanic Program hash. + * + * Keyed Scenario Inputs and pools are sets and therefore sorted by their + * canonical semantics. Clauses, mutations, predicate members, and outcomes + * stay in source order because order affects execution or rendered text. + */ +export function mechanicProgramSemanticPayload( + input: MechanicProgramHashInput, +): CanonicalMechanicProgram { + const scenarioInputs = stableSemanticSort( + (input.scenarioInputs ?? []).map(canonicalScenarioInput), + ); + const pools = stableSemanticSort((input.pools ?? []).map(canonicalPool)); + const payload: CanonicalMechanicProgram = { + kind: MECHANIC_PROGRAM_HASH_KIND, + version: MECHANIC_PROGRAM_HASH_VERSION, + name: input.name, + scenarioInputs, + pools, + clauses: input.clauses.map(canonicalClause), + }; + if (input.simulationReadiness !== undefined) { + payload.simulationReadiness = input.simulationReadiness; + } + if (input.sourceVariants !== undefined) + payload.sourceVariants = canonicalizeValue(input.sourceVariants); + return payload; +} + +/** Returns canonical JSON bytes as a string, without a hidden domain separator. */ +export function canonicalMechanicProgramJson( + input: MechanicProgramHashInput, +): string { + return stableJson(canonicalizeValue(mechanicProgramSemanticPayload(input))); +} + +async function sha256Utf8(value: string): Promise { + const crypto = globalThis.crypto; + if (!crypto?.subtle) { + throw new Error('Web Crypto SHA-256 is unavailable.'); + } + const bytes = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); +} + +/** Computes the source fingerprint only after the authored contract validates. */ +export async function hashMechanicAuthoredSource( + source: MechanicAuthoredSource, +): Promise { + const validation = materializeMechanicAuthoredDescription(source); + if (!validation.ok) return validation; + const payload = mechanicAuthoredFingerprintPayload(source); + if (!payload) { + return { + ok: false, + errors: [ + { + code: 'invalid_source', + path: '', + message: 'The validated authored source could not be serialized.', + }, + ], + }; + } + return { + ok: true, + fingerprint: await sha256Utf8(stableJson(canonicalizeValue(payload))), + payload, + }; +} + +/** Computes lowercase SHA-256 over UTF-8 canonical Mechanic Program JSON. */ +export async function hashMechanicProgram( + input: MechanicProgramHashInput, +): Promise { + return sha256Utf8(canonicalMechanicProgramJson(input)); +} + +const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/; + +function lifecycleAbsent( + reason: Extract['reason'], + errors?: string[], +): MechanicLifecycleResult { + return errors?.length + ? { status: 'absent', reason, errors } + : { status: 'absent', reason }; +} + +/** + * Classifies one selected model without ever treating readiness as currency. + * Validation and renderability are checked before currency, so malformed + * records fail closed as absent rather than leaking a stale or current model. + */ +export async function classifyMechanicLifecycle( + input: MechanicLifecycleInput, +): Promise { + const revision = input.selectedRevision; + if (!revision || !revision.program) { + return lifecycleAbsent('no_selected_revision'); + } + + const program = revision.program; + let validation; + try { + validation = validateMechanicProgram(program); + } catch { + return lifecycleAbsent('invalid_program', [ + 'The selected mechanic could not be validated safely.', + ]); + } + if (!validation.ok) + return lifecycleAbsent('invalid_program', validation.errors); + if (program.clauses.length === 0) { + return lifecycleAbsent( + revision.simulationReadiness === 'captured_only' + ? 'empty_captured_program' + : 'empty_program', + ); + } + + const rendering = renderPublicMechanicPrograms( + [{ key: 'base', label: 'Base', program }], + { resolveAttributeLabel: input.resolveAttributeLabel }, + ); + if (!rendering.ok) { + return lifecycleAbsent( + 'unrenderable_program', + rendering.errors.map((error) => error.message), + ); + } + + if ( + !['ready', 'requires_assumptions', 'captured_only'].includes( + revision.simulationReadiness, + ) + ) { + return { status: 'stale', reason: 'unsupported_contract' }; + } + const supportedContractVersion = + input.supportedMechanicContractVersion ?? MECHANIC_PROGRAM_HASH_VERSION; + if ( + revision.mechanicContractVersion !== supportedContractVersion || + !Number.isInteger(revision.mechanicContractVersion) || + revision.mechanicContractVersion <= 0 + ) { + return { status: 'stale', reason: 'unsupported_contract' }; + } + + if ( + !SHA256_HEX_PATTERN.test(revision.sourceFingerprint) || + !SHA256_HEX_PATTERN.test(input.currentSourceFingerprint) + ) { + return lifecycleAbsent('invalid_program', [ + 'The selected revision and authored source require lowercase SHA-256 fingerprints.', + ]); + } + if (revision.sourceFingerprint !== input.currentSourceFingerprint) { + return { status: 'stale', reason: 'fingerprint_mismatch' }; + } + + if (!SHA256_HEX_PATTERN.test(revision.programHash)) + return lifecycleAbsent('invalid_program_hash'); + let programHash: string; + try { + programHash = await hashMechanicProgram({ + ...program, + simulationReadiness: revision.simulationReadiness, + ...(revision.sourceVariants !== undefined + ? { sourceVariants: revision.sourceVariants } + : {}), + }); + } catch { + return lifecycleAbsent('hash_unavailable'); + } + if (programHash !== revision.programHash) { + return lifecycleAbsent('invalid_program_hash'); + } + + return { + status: 'current', + fingerprint: input.currentSourceFingerprint, + programHash, + simulationReadiness: revision.simulationReadiness, + }; +} diff --git a/src/lib/calculations/mechanic-public-renderer.test.ts b/src/lib/calculations/mechanic-public-renderer.test.ts new file mode 100644 index 0000000..8dcb2eb --- /dev/null +++ b/src/lib/calculations/mechanic-public-renderer.test.ts @@ -0,0 +1,714 @@ +import { describe, expect, it } from 'vitest'; +import { + renderPublicMechanicPrograms, + type MechanicPublicProgramVariant, +} from './mechanic-public-renderer.js'; +import { MECHANIC_EVENT_KINDS } from './mechanic.js'; +import type { MechanicProgram } from './mechanic.js'; + +const baseProgram = ( + overrides: Partial = {}, +): MechanicProgram => ({ + id: 'public-renderer-test', + name: 'Public renderer test', + clauses: [ + { + id: 'hit', + label: 'Hit target', + when: 'weapon_hit', + if: { + kind: 'compare', + left: { source: 'event', path: 'values.headshot' }, + operator: 'eq', + right: true, + }, + reducer: { kind: 'none' }, + mutations: [{ kind: 'none' }], + outcomes: [ + { + id: 'damage', + label: 'Headshot damage', + phase: 'on_event', + kind: 'direct_damage', + scope: 'target', + value: 12.5, + basis: 'flat', + }, + ], + }, + ], + ...overrides, +}); + +const variants = ( + ...programs: Array<[string, MechanicProgram]> +): MechanicPublicProgramVariant[] => + programs.map(([key, program]) => ({ key, program })); + +const render = ( + program: MechanicProgram, + options?: { + resolveAttributeLabel?: (attributeId: string) => string | null | undefined; + }, +) => renderPublicMechanicPrograms([{ key: 'base', program }], options); + +describe('public mechanic renderer', () => { + it('renders deterministic, readable prose for ordered variants', () => { + const input = variants( + ['I', baseProgram()], + ['II', baseProgram({ id: 'public-renderer-test-ii' })], + ); + + expect(renderPublicMechanicPrograms(input)).toEqual({ + ok: true, + variants: [ + { + key: 'I', + label: 'Tier I', + text: 'Tier I\n\nHit target: When Weapon hit occurs, provided that the event’s Headshot is equal to true. Headshot damage: when this event occurs, deal 12.5 damage to the target immediately.', + }, + { + key: 'II', + label: 'Tier II', + text: 'Tier II\n\nHit target: When Weapon hit occurs, provided that the event’s Headshot is equal to true. Headshot damage: when this event occurs, deal 12.5 damage to the target immediately.', + }, + ], + }); + }); + + it('preserves the supplied order of variants', () => { + const result = renderPublicMechanicPrograms( + variants( + ['II', baseProgram({ id: 'tier-ii' })], + ['I', baseProgram({ id: 'tier-i' })], + ), + ); + + expect(result.ok && result.variants.map((variant) => variant.key)).toEqual([ + 'II', + 'I', + ]); + }); + + it('renders every event registry label without exposing the script format', () => { + const program = baseProgram({ + clauses: MECHANIC_EVENT_KINDS.map((when) => ({ + id: when, + when, + label: when, + reducer: { kind: 'none' as const }, + mutations: [{ kind: 'none' as const }], + })), + }); + const result = render(program); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.variants[0].text).not.toContain('WHEN '); + expect(result.variants[0].text).not.toContain('EVENT.'); + expect(result.variants[0].text).not.toContain('{'); + for (const clause of program.clauses) { + expect(result.variants[0].text).toContain( + ( + { + shot_fired: 'Shot fired', + weapon_hit: 'Weapon hit', + skill_deployed: 'Skill deployed', + signature_ability_deployed: 'Signature Ability deployed', + skill_hit: 'Skill hit', + skill_multishot_triggered: 'Skill Multi-Shot triggered', + agent_hit: 'Agent hit', + health_damage_taken: 'Health damage taken', + damage_dealt: 'Damage dealt', + enemy_killed: 'Enemy killed', + assist: 'Assist', + ally_downed: 'Ally downed', + agent_downed: 'Agent downed', + ally_rescued: 'Ally rescued', + grenade_thrown: 'Grenade thrown', + medical_kit_used: 'Medical Kit used', + healing_applied: 'Healing applied', + extra_health_granted: 'Extra Health granted', + extra_health_changed: 'Extra Health changed', + movement_snapshot: 'Movement snapshot', + proximity_snapshot: 'Proximity snapshot', + firing_started: 'Firing started', + firing_stopped: 'Firing stopped', + reload_started: 'Reload started', + reload_completed: 'Reload completed', + weapon_swapped: 'Weapon swapped', + tick: 'Simulation tick', + } as Record + )[clause.when], + ); + } + }); + + it('covers conditions, references, reducers, state, mutations, and outcome metadata', () => { + const program = baseProgram({ + scenarioInputs: [ + { + key: 'stacks-to-add', + label: 'Stacks to add', + kind: 'number', + defaultValue: 1.25, + bestCaseValue: 2.5, + min: 0, + max: 3, + }, + ], + pools: [ + { + key: 'stacks', + label: 'Momentum stacks', + scope: 'agent', + initial: 0, + min: 0, + max: 4, + bestCaseValue: 3, + expiresAfterSeconds: 8.75, + }, + { + key: 'target-mark', + label: 'Target mark', + scope: 'target', + initial: 0, + min: 0, + max: 1, + }, + { + key: 'cooldown', + label: 'Momentum cooldown', + scope: 'agent', + initial: 0, + min: 0, + max: 1, + expiresAfterSeconds: 12, + }, + ], + clauses: [ + { + id: 'advanced', + label: 'Build momentum', + when: 'weapon_hit', + if: { + kind: 'all', + predicates: [ + { + kind: 'compare', + left: { source: 'event', path: 'values.distanceMeters' }, + operator: 'lte', + right: 15.5, + }, + { + kind: 'not', + predicate: { + kind: 'exists', + value: { source: 'event', path: 'values.critical' }, + }, + }, + { + kind: 'compare', + left: { source: 'attribute', attributeId: 'firepower' }, + operator: 'gte', + right: { source: 'scenario', key: 'stacks-to-add' }, + }, + ], + }, + reducer: { + kind: 'count', + threshold: 2, + reset: 'subtract', + groupBy: { source: 'event', path: 'targetId' }, + }, + cooldownSeconds: 4.5, + weight: { + op: 'clamp', + value: { ref: { source: 'pool', pool: 'stacks' } }, + min: 0, + max: 1, + }, + procChancePercent: 37.5, + requiresAssignedAbilityActive: true, + mutations: [ + { + kind: 'add', + pool: 'stacks', + value: { ref: { source: 'scenario', key: 'stacks-to-add' } }, + }, + { + kind: 'set', + pool: 'target-mark', + entity: { kind: 'event_target' }, + value: 1, + }, + { kind: 'consume', pool: 'stacks', value: 0.5 }, + { + kind: 'derive', + pool: 'stacks', + value: { op: 'floor', value: { ref: { source: 'reducer' } } }, + }, + { + kind: 'transfer', + pool: 'target-mark', + from: { kind: 'event_target' }, + to: { kind: 'agent' }, + value: 'all', + clearSource: false, + }, + { + kind: 'extend_duration', + target: 'assigned_ability', + seconds: 2.25, + }, + { + kind: 'start_cooldown_on_max', + stackPool: 'stacks', + cooldownPool: 'cooldown', + }, + ], + outcomes: [ + { + id: 'buff', + label: 'Momentum buff', + phase: 'while_active', + kind: 'attribute_modifier', + scope: 'self', + value: { + op: 'multiply', + left: 2, + right: { + ref: { source: 'attribute', attributeId: 'firepower' }, + }, + }, + basis: 'flat', + attributeId: 'firepower', + statePool: 'stacks', + perStack: true, + minStacks: 1, + maxStacks: 4, + combatTiming: 'current', + probabilityPercent: 88.25, + }, + { + id: 'restore', + label: 'Regeneration', + phase: 'after_event', + kind: 'restoration', + scope: 'self_and_allies', + value: 10, + basis: 'max_health_percent', + resource: 'health', + delivery: 'continuous', + durationSeconds: 3.5, + }, + { + id: 'cost', + label: 'Ammo cost', + phase: 'on_event', + kind: 'resource_cost', + scope: 'self', + value: 2, + basis: 'flat', + resource: 'ammo', + delivery: 'instant', + }, + { + id: 'reload', + label: 'Reload inactive weapon', + phase: 'on_exit', + kind: 'weapon_reload', + scope: 'inactive_weapon', + value: { op: 'add', left: 1.125, right: 0.25 }, + basis: 'flat', + }, + { + id: 'area', + label: 'Area strike', + phase: 'on_event', + kind: 'direct_damage', + scope: 'target', + value: 15, + basis: 'weapon_base_percent', + targeting: { + kind: 'event_target_area', + center: 'event_target', + validTargets: 'enemies', + radiusMeters: 4, + }, + }, + { + id: 'marker', + label: 'Marker', + phase: 'on_enter', + kind: 'marker', + scope: 'assigned_ability', + value: 1, + basis: 'flat', + }, + ], + }, + ], + }); + const result = render(program, { + resolveAttributeLabel: (id) => + id === 'firepower' ? 'Firepower' : undefined, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.variants[0].text).toMatchInlineSnapshot(` + "Base + + The “Stacks to add” scenario input is a number, from 0, to 3, default 1.25, maximum 2.5. + + State “Momentum cooldown” tracks the Agent, starting at 0, from 0 to 1. It expires 12 seconds after it is written. State “Momentum stacks” tracks the Agent, starting at 0, from 0 to 4. The maximum scenario value is 3. It expires 8.75 seconds after it is written. State “Target mark” tracks each target, starting at 0, from 0 to 1. + + Build momentum: When Weapon hit occurs, provided that all of these conditions are met: (the event’s Target distance is at most 15.5); (it is not the case that the event’s Critical hit is present); (the Firepower Attribute value is at least the “Stacks to add” scenario input), after 2 matching events grouped by the event’s Target ID while retaining any matches beyond the threshold, only while the assigned ability is active, with a weight of the value of the “Momentum stacks” state for the Agent limited from 0 to 1, with a 37.5% chance to trigger, then unavailable for 4.5 seconds. Add the “Stacks to add” scenario input to the “Momentum stacks” state for the Agent. Set 1 as the “Target mark” state for the event target. Consume 0.5 from the “Momentum stacks” state for the Agent. Derive the whole-number value of the reduced value as the “Momentum stacks” state for the Agent. Transfer all of the “Target mark” state from the event target to the Agent, without clearing the source. Extend the assigned ability by 2.25 seconds. Start the “Momentum cooldown” cooldown when the “Momentum stacks” state reaches its maximum. Momentum buff: while the state is active, modify “Firepower” by the product of 2 and the Firepower Attribute value for the Agent using the “Momentum stacks” state per stack when stacks are from 1 through 4 immediately with a 88.25% chance. Regeneration: after this event, restore a total of 10% of maximum Health to Health for the Agent and allies continuously over 3.5 seconds. Ammo cost: when this event occurs, spend 2 of Ammo from the Agent immediately. Reload inactive weapon: when the state ends, reload the inactive weapon after the sum of 1.125 and 0.25 seconds. Area strike: when this event occurs, deal damage equal to 15% of weapon base damage to the target, affecting valid enemies within 4 meters of the event target immediately. Marker: when the state begins, record a marker of 1 for the assigned ability immediately." + `); + }); + + it('renders a custom variant label and resolves all attribute references', () => { + const result = renderPublicMechanicPrograms( + [ + { + key: 'rank-1', + label: 'Rank 1', + program: baseProgram({ + clauses: [ + { + ...baseProgram().clauses[0], + if: { + kind: 'exists', + value: { source: 'attribute', attributeId: 'armor' }, + }, + outcomes: [ + { + ...baseProgram().clauses[0].outcomes![0], + kind: 'attribute_modifier', + attributeId: 'armor', + scope: 'self', + }, + ], + }, + ], + }), + }, + ], + { resolveAttributeLabel: (id) => (id === 'armor' ? 'Armor' : null) }, + ); + + expect(result).toMatchObject({ + ok: true, + variants: [{ key: 'rank-1', label: 'Rank 1' }], + }); + if (result.ok) expect(result.variants[0].text).toContain('“Armor”'); + }); + + it('fails closed for an invalid program before attempting to render it', () => { + const invalid = baseProgram({ + clauses: [{ ...baseProgram().clauses[0], when: 'not-an-event' as never }], + }); + const result = render(invalid); + + expect(result).toMatchObject({ + ok: false, + errors: [{ code: 'invalid_program', path: 'variants[base]' }], + }); + }); + + it('validates true predicates instead of dropping their structure', () => { + const program = baseProgram({ + clauses: [ + { + ...baseProgram().clauses[0], + if: { kind: 'true', unexpected: true } as never, + }, + ], + }); + const result = render(program); + + expect(result).toMatchObject({ + ok: false, + errors: [ + { + code: 'unsupported_fragment', + path: 'variants[base].clauses[0].if.unexpected', + }, + ], + }); + }); + + it('rejects overlapping or non-array input shapes', () => { + const result = renderPublicMechanicPrograms({ + variants: [{ key: 'base', program: baseProgram() }], + } as never); + + expect(result).toMatchObject({ + ok: false, + errors: [{ code: 'invalid_program', path: 'variants' }], + }); + }); + + it('rejects combat references outside the combat reference registry', () => { + const result = render( + baseProgram({ + clauses: [ + { + ...baseProgram().clauses[0], + outcomes: [ + { + ...baseProgram().clauses[0].outcomes![0], + value: { + ref: { source: 'combat', path: 'maxHealth' }, + }, + }, + ], + }, + ], + }), + ); + + expect(result).toMatchObject({ + ok: false, + errors: [ + { + code: 'unsupported_fragment', + path: 'variants[base].clauses[0].outcomes[0].value.ref.path', + }, + ], + }); + }); + + it('fails closed when an Attribute ID cannot be resolved', () => { + const result = render( + baseProgram({ + clauses: [ + { + ...baseProgram().clauses[0], + outcomes: [ + { + ...baseProgram().clauses[0].outcomes![0], + kind: 'attribute_modifier', + attributeId: 'missing-attribute', + }, + ], + }, + ], + }), + { resolveAttributeLabel: () => undefined }, + ); + + expect(result).toMatchObject({ + ok: false, + errors: [ + { + code: 'unresolved_attribute', + path: 'variants[base].clauses[0].outcomes[0].attributeId', + }, + ], + }); + }); + + it('does not render ignored combat timing on flat outcomes', () => { + const program = baseProgram({ + clauses: [ + { + ...baseProgram().clauses[0], + outcomes: [ + { + ...baseProgram().clauses[0].outcomes![0], + basis: 'flat', + combatTiming: 'pre_event', + }, + ], + }, + ], + }); + const result = render(program); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.variants[0].text).not.toContain('before this event'); + expect(result.variants[0].text).not.toContain('using current values'); + } + }); + + it('fails closed for targeting the runtime does not expand', () => { + const result = render( + baseProgram({ + clauses: [ + { + ...baseProgram().clauses[0], + outcomes: [ + { + ...baseProgram().clauses[0].outcomes![0], + targeting: { + kind: 'within_radius', + population: 'enemies', + radiusMeters: 4, + }, + }, + ], + }, + ], + }), + ); + + expect(result).toMatchObject({ + ok: false, + errors: [ + { + code: 'unsupported_fragment', + path: 'variants[base].clauses[0].outcomes[0].targeting', + }, + ], + }); + }); + + it('sorts set-like inputs and pools by semantic key', () => { + const first = baseProgram({ + scenarioInputs: [ + { + key: 'z-input', + label: 'Z input', + kind: 'toggle', + defaultValue: false, + bestCaseValue: true, + }, + { + key: 'a-input', + label: 'A input', + kind: 'toggle', + defaultValue: false, + bestCaseValue: true, + }, + ], + pools: [ + { key: 'z-pool', label: 'Z pool', scope: 'agent', initial: 0 }, + { key: 'a-pool', label: 'A pool', scope: 'agent', initial: 0 }, + ], + }); + const second = baseProgram({ + scenarioInputs: [...first.scenarioInputs!].reverse(), + pools: [...first.pools!].reverse(), + }); + + const firstResult = render(first); + const secondResult = render(second); + expect(firstResult.ok && secondResult.ok).toBe(true); + if (firstResult.ok && secondResult.ok) + expect(firstResult.variants[0].text).toBe(secondResult.variants[0].text); + }); + + it('requires renderable labels instead of humanizing generated IDs', () => { + const missingVariantLabel = renderPublicMechanicPrograms([ + { key: 'generated-variant-id', program: baseProgram() }, + ]); + expect(missingVariantLabel).toMatchObject({ + ok: false, + errors: [ + { + code: 'unsupported_fragment', + path: 'variants[generated-variant-id].label', + }, + ], + }); + + const missingClauseLabel = render( + baseProgram({ + clauses: [{ ...baseProgram().clauses[0], label: undefined }], + }), + ); + expect(missingClauseLabel).toMatchObject({ + ok: false, + errors: [ + { + code: 'unsupported_fragment', + path: 'variants[base].clauses[0].label', + }, + ], + }); + + const missingOutcomeLabel = render( + baseProgram({ + clauses: [ + { + ...baseProgram().clauses[0], + outcomes: [ + { + ...baseProgram().clauses[0].outcomes![0], + label: undefined as never, + }, + ], + }, + ], + }), + ); + expect(missingOutcomeLabel).toMatchObject({ + ok: false, + errors: [ + { + code: 'unsupported_fragment', + path: 'variants[base].clauses[0].outcomes[0].label', + }, + ], + }); + + const missingPoolLabel = render( + baseProgram({ + pools: [ + { + key: 'generated-pool-id', + label: undefined as never, + scope: 'agent', + initial: 0, + }, + ], + }), + ); + expect(missingPoolLabel).toMatchObject({ + ok: false, + errors: [ + { code: 'unsupported_fragment', path: 'variants[base].pools[0].label' }, + ], + }); + }); + + it('fails closed for unsupported fields instead of leaking them', () => { + const program = baseProgram(); + ( + program.clauses[0] as MechanicProgram['clauses'][number] & { + script?: string; + } + ).script = 'WHEN raw'; + const result = render(program); + + expect(result).toMatchObject({ + ok: false, + errors: [ + { + code: 'unsupported_fragment', + path: 'variants[base].clauses[0].script', + }, + ], + }); + }); + + it('does not round represented numeric values or depend on locale settings', () => { + const program = baseProgram({ + clauses: [ + { + ...baseProgram().clauses[0], + outcomes: [ + { ...baseProgram().clauses[0].outcomes![0], value: 1.23456789 }, + ], + }, + ], + }); + const result = render(program); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.variants[0].text).toContain('1.23456789'); + }); +}); diff --git a/src/lib/calculations/mechanic-public-renderer.ts b/src/lib/calculations/mechanic-public-renderer.ts new file mode 100644 index 0000000..f971e8e --- /dev/null +++ b/src/lib/calculations/mechanic-public-renderer.ts @@ -0,0 +1,1581 @@ +import { + MECHANIC_COMBAT_REFERENCE_REGISTRY, + MECHANIC_EVENT_REGISTRY, + validateMechanicProgram, + type ClauseMutation, + type EntitySelector, + type MechanicClause, + type MechanicEventKind, + type MechanicOutcome, + type MechanicProgram, + type NumericExpression, + type PoolDefinition, + type Predicate, + type Reducer, + type Scalar, + type StateScope, + type ValueReference, +} from './mechanic.js'; + +/** Resolves a stored Attribute ID to the label that may be shown publicly. */ +export type MechanicAttributeLabelResolver = ( + attributeId: string, +) => string | null | undefined; + +export type MechanicPublicProgramVariant = { + key: string; + label?: string; + program: MechanicProgram; +}; + +export type MechanicPublicRenderOptions = { + resolveAttributeLabel?: MechanicAttributeLabelResolver; +}; + +export type MechanicPublicRenderedVariant = { + key: string; + label: string; + text: string; +}; + +export type MechanicPublicRenderErrorCode = + 'invalid_program' | 'unsupported_fragment' | 'unresolved_attribute'; + +export type MechanicPublicRenderError = { + code: MechanicPublicRenderErrorCode; + path: string; + message: string; +}; + +export type MechanicPublicRenderResult = + | { ok: true; variants: MechanicPublicRenderedVariant[] } + | { ok: false; errors: MechanicPublicRenderError[] }; + +type PublicRenderContext = { + eventKind: MechanicEventKind; + pools: Map; + inputs: Map; + attributeLabels: Map; + attributeLabelResolver?: MechanicAttributeLabelResolver; +}; + +type RenderFailureData = { + code: MechanicPublicRenderErrorCode; + path: string; + message: string; +}; + +class RenderFailure extends Error { + readonly data: RenderFailureData; + + constructor(data: RenderFailureData) { + super(data.message); + this.name = 'RenderFailure'; + this.data = data; + } +} + +const fail = ( + code: MechanicPublicRenderErrorCode, + path: string, + message: string, +): never => { + throw new RenderFailure({ code, path, message }); +}; + +const unsupported = (path: string, fragment: string): never => + fail( + 'unsupported_fragment', + path, + `The public behavior renderer does not support ${fragment}.`, + ); + +const record = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const assertRecord = ( + value: unknown, + path: string, + fragment: string, +): Record => { + if (!record(value)) unsupported(path, `an invalid ${fragment}`); + return value as Record; +}; + +const assertKeys = ( + value: Record, + allowed: readonly string[], + path: string, +) => { + const allowedSet = new Set(allowed); + for (const key of Object.keys(value)) { + if (!allowedSet.has(key)) { + unsupported(`${path}.${key}`, `the field "${key}"`); + } + } +}; + +const assertFinite = (value: unknown, path: string): number => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + unsupported(path, 'a non-finite number'); + } + return value as number; +}; + +const stringField = ( + value: Record, + key: string, + path: string, +): string => { + const field = value[key]; + if (typeof field !== 'string') unsupported(path, `a string ${key}`); + return field as string; +}; + +const optionalStringField = ( + value: Record, + key: string, + path: string, +): string | undefined => { + const field = value[key]; + if (field === undefined) return undefined; + if (typeof field !== 'string') unsupported(path, `a string ${key}`); + return field as string; +}; + +const optionalNumberField = ( + value: Record, + key: string, + path: string, +): number | undefined => { + const field = value[key]; + if (field === undefined) return undefined; + return assertFinite(field, path); +}; + +const optionalBooleanField = ( + value: Record, + key: string, + path: string, +): boolean | undefined => { + const field = value[key]; + if (field === undefined) return undefined; + if (typeof field !== 'boolean') unsupported(path, `a boolean ${key}`); + return field as boolean; +}; + +const assertText = ( + value: unknown, + path: string, + fallback?: string, +): string => { + const text = + value === undefined + ? (fallback?.trim() ?? '') + : typeof value === 'string' + ? value.trim() + : ''; + if (!text) unsupported(path, 'an empty public label'); + if (/[{}]/.test(text)) { + unsupported(path, 'an unresolved placeholder'); + } + if (/\p{Cc}/u.test(text)) { + unsupported(path, 'a label containing control characters'); + } + return text.replace(/\s+/g, ' '); +}; + +const publicSentence = (value: string, path: string): string => { + if (/[{}]/.test(value)) unsupported(path, 'an unresolved placeholder'); + // Newlines are part of the structured public behavior block. Reject the + // remaining C0 controls instead of silently emitting unsafe text. + if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(value)) + unsupported(path, 'text containing control characters'); + return value; +}; + +/** String conversion deliberately does not use locale-sensitive formatting or rounding. */ +function formatNumber(value: number, path: string): string { + assertFinite(value, path); + return Object.is(value, -0) ? '-0' : String(value); +} + +function stableKeyCompare(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function humanize(value: string): string { + const words = value + .replace(/([a-z\d])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .replace(/\.+/g, ' ') + .trim() + .replace(/\s+/g, ' '); + if (!words) return words; + return words.charAt(0).toUpperCase() + words.slice(1); +} + +function quote(value: string, path: string): string { + const safe = assertText(value, path); + return `“${safe.replace(/[“”]/g, (character) => + character === '“' ? '«' : '»', + )}”`; +} + +function formatScalar(value: Scalar, path: string): string { + if (value === null) return 'nothing'; + if (typeof value === 'string') return quote(value, path); + if (typeof value === 'boolean') return value ? 'true' : 'false'; + return formatNumber(value, path); +} + +function selectorText( + selector: EntitySelector | undefined, + path: string, + defaultScope: StateScope = 'agent', +): string { + if (selector === undefined) + return defaultScope === 'agent' ? 'the Agent' : 'each target'; + const value = assertRecord(selector, path, 'entity selector'); + assertKeys(value, ['kind', 'path'], path); + const kind = stringField(value, 'kind', `${path}.kind`); + if (kind !== 'event_field' && value.path !== undefined) + unsupported( + `${path}.path`, + 'a path on an entity selector that does not use one', + ); + switch (kind) { + case 'agent': + return 'the Agent'; + case 'event_actor': + return 'the event actor'; + case 'event_target': + return 'the event target'; + case 'event_field': + const fieldPath = stringField(value, 'path', `${path}.path`); + if (!fieldPath.trim()) + unsupported(`${path}.path`, 'an empty event field selector'); + const fieldLabel = humanize(fieldPath); + if (!fieldLabel) + unsupported(`${path}.path`, 'an unreadable event field selector'); + return `the event’s ${fieldLabel}`; + default: + return unsupported(`${path}.kind`, `the entity selector kind "${kind}"`); + } +} + +function eventFieldLabel(path: string, eventKind: MechanicEventKind): string { + const definition = MECHANIC_EVENT_REGISTRY[eventKind]; + const field = definition?.fields?.find((item) => item.path === path); + if (field) return field.label; + const known: Record = { + actorId: 'Actor ID', + targetId: 'Target ID', + kind: 'Event kind', + at: 'Event time', + }; + return known[path] ?? humanize(path); +} + +function renderReference( + reference: ValueReference, + context: PublicRenderContext, + path: string, +): string { + const value = assertRecord(reference, path, 'value reference'); + const source = stringField(value, 'source', `${path}.source`); + switch (source) { + case 'event': { + assertKeys(value, ['source', 'path'], path); + const eventPath = stringField(value, 'path', `${path}.path`); + if (!eventPath.trim()) + unsupported(`${path}.path`, 'an empty event reference'); + const fieldLabel = eventFieldLabel(eventPath, context.eventKind); + if (!fieldLabel) + unsupported(`${path}.path`, 'an unreadable event reference'); + return `the event’s ${fieldLabel}`; + } + case 'scenario': { + assertKeys(value, ['source', 'key'], path); + const inputKey = stringField(value, 'key', `${path}.key`); + if (!inputKey.trim()) + unsupported(`${path}.key`, 'an empty scenario reference'); + const input = context.inputs.get(inputKey); + if (!input) + unsupported(`${path}.key`, `an unknown scenario input "${inputKey}"`); + const resolvedInput = input as { label: string }; + return `the ${quote(resolvedInput.label, `${path}.key`)} scenario input`; + } + case 'combat': { + assertKeys(value, ['source', 'path', 'timing'], path); + const combatPath = stringField(value, 'path', `${path}.path`); + if (!combatPath.trim()) + unsupported(`${path}.path`, 'an empty combat reference'); + const combatDefinition = + MECHANIC_COMBAT_REFERENCE_REGISTRY[ + combatPath as keyof typeof MECHANIC_COMBAT_REFERENCE_REGISTRY + ]; + if (!combatDefinition) + unsupported( + `${path}.path`, + `an unsupported combat reference "${combatPath}"`, + ); + const combatLabel = combatDefinition.label; + const combatTiming = optionalStringField( + value, + 'timing', + `${path}.timing`, + ); + if ( + combatTiming !== undefined && + combatTiming !== 'pre_event' && + combatTiming !== 'current' + ) { + unsupported(`${path}.timing`, 'an unsupported combat timing'); + } + return combatTiming === 'pre_event' + ? `the ${combatLabel} before this event` + : `the current ${combatLabel.replace(/^Current /, '')} value`; + } + case 'attribute': { + assertKeys(value, ['source', 'attributeId'], path); + const attributeId = stringField( + value, + 'attributeId', + `${path}.attributeId`, + ); + if (!attributeId.trim()) + unsupported(`${path}.attributeId`, 'an empty Attribute ID'); + return `the ${resolveAttributeLabel(attributeId, context, `${path}.attributeId`)} Attribute value`; + } + case 'equipment': { + assertKeys(value, ['source', 'path'], path); + const equipmentPath = stringField(value, 'path', `${path}.path`); + if (!equipmentPath.trim()) + unsupported(`${path}.path`, 'an empty equipment reference'); + const equipmentLabel = humanize(equipmentPath); + if (!equipmentLabel) + unsupported(`${path}.path`, 'an unreadable equipment reference'); + return `the ${equipmentLabel} equipment value`; + } + case 'assigned_ability': { + assertKeys(value, ['source', 'path'], path); + const abilityPath = stringField(value, 'path', `${path}.path`); + if ( + abilityPath !== 'active' && + abilityPath !== 'remainingDurationSeconds' + ) + unsupported( + `${path}.path`, + 'an unsupported assigned-ability reference', + ); + return abilityPath === 'active' + ? 'whether the assigned ability is active' + : 'the assigned ability’s remaining duration in seconds'; + } + case 'reducer': + assertKeys(value, ['source'], path); + return 'the reduced value'; + case 'pool': { + assertKeys(value, ['source', 'pool', 'entity'], path); + const poolKey = stringField(value, 'pool', `${path}.pool`); + if (!poolKey.trim()) + unsupported(`${path}.pool`, 'an empty pool reference'); + const poolDefinition = context.pools.get(poolKey); + if (!poolDefinition) + unsupported(`${path}.pool`, `an unknown state pool "${poolKey}"`); + const pool = poolDefinition as PoolDefinition; + const entity = selectorText( + value.entity as EntitySelector | undefined, + `${path}.entity`, + pool.scope, + ); + return `the ${quote(pool.label, `${path}.pool`)} state for ${entity}`; + } + default: + return unsupported( + `${path}.source`, + `the value reference source "${source}"`, + ); + } +} + +function resolveAttributeLabel( + attributeId: string, + context: PublicRenderContext, + path: string, +): string { + const cached = context.attributeLabels.get(attributeId); + if (cached) return cached; + const resolver = context.attributeLabelResolver; + if (resolver === undefined) { + fail( + 'unresolved_attribute', + path, + `Attribute "${attributeId}" has no public label and cannot be rendered.`, + ); + } + const resolvedResolver = resolver as MechanicAttributeLabelResolver; + let label: string | null | undefined; + try { + label = resolvedResolver(attributeId); + } catch { + label = undefined; + } + if (typeof label !== 'string' || !label.trim()) { + fail( + 'unresolved_attribute', + path, + `Attribute "${attributeId}" has no public label and cannot be rendered.`, + ); + } + const resolved = assertText(label, path); + context.attributeLabels.set(attributeId, resolved); + return resolved; +} + +function renderExpression( + expression: NumericExpression, + context: PublicRenderContext, + path: string, +): string { + if (typeof expression === 'number') return formatNumber(expression, path); + const value = assertRecord(expression, path, 'numeric expression'); + if ('ref' in value) { + assertKeys(value, ['ref'], path); + return renderReference(value.ref as ValueReference, context, `${path}.ref`); + } + const op = stringField(value, 'op', `${path}.op`); + if (op === 'floor') { + assertKeys(value, ['op', 'value'], path); + return `the whole-number value of ${renderExpression(value.value as NumericExpression, context, `${path}.value`)}`; + } + if (op === 'clamp') { + assertKeys(value, ['op', 'value', 'min', 'max'], path); + return `the value of ${renderExpression(value.value as NumericExpression, context, `${path}.value`)} limited from ${renderExpression(value.min as NumericExpression, context, `${path}.min`)} to ${renderExpression(value.max as NumericExpression, context, `${path}.max`)}`; + } + if (!['add', 'subtract', 'multiply', 'divide', 'min', 'max'].includes(op)) + unsupported(`${path}.op`, `the numeric operator "${op}"`); + assertKeys(value, ['op', 'left', 'right'], path); + const left = renderExpression( + value.left as NumericExpression, + context, + `${path}.left`, + ); + const right = renderExpression( + value.right as NumericExpression, + context, + `${path}.right`, + ); + switch (op) { + case 'add': + return `the sum of ${left} and ${right}`; + case 'subtract': + return `${left} minus ${right}`; + case 'multiply': + return `the product of ${left} and ${right}`; + case 'divide': + return `${left} divided by ${right}`; + case 'min': + return `the smaller of ${left} and ${right}`; + case 'max': + return `the larger of ${left} and ${right}`; + default: + return unsupported(`${path}.op`, 'an unsupported numeric operator'); + } +} + +function renderOperand( + operand: ValueReference | Scalar, + context: PublicRenderContext, + path: string, +): string { + if (record(operand) && 'source' in operand) + return renderReference(operand as ValueReference, context, path); + if (record(operand)) unsupported(path, 'an unsupported predicate operand'); + if ( + typeof operand === 'string' || + typeof operand === 'number' || + typeof operand === 'boolean' || + operand === null + ) + return formatScalar(operand, path); + return unsupported(path, 'an unsupported predicate operand'); +} + +function renderPredicate( + predicate: Predicate, + context: PublicRenderContext, + path: string, +): string { + const value = assertRecord(predicate, path, 'predicate'); + const kind = stringField(value, 'kind', `${path}.kind`); + switch (kind) { + case 'true': + assertKeys(value, ['kind'], path); + return 'always'; + case 'exists': + assertKeys(value, ['kind', 'value'], path); + return `${renderReference(value.value as ValueReference, context, `${path}.value`)} is present`; + case 'compare': { + assertKeys(value, ['kind', 'left', 'operator', 'right'], path); + if (typeof value.operator !== 'string') + unsupported(`${path}.operator`, 'a comparison operator'); + const operators: Record = { + eq: 'equal to', + ne: 'not equal to', + gt: 'greater than', + gte: 'at least', + lt: 'less than', + lte: 'at most', + }; + const comparisonOperator = stringField( + value, + 'operator', + `${path}.operator`, + ); + const operator = operators[comparisonOperator]; + if (!operator) + unsupported( + `${path}.operator`, + `the comparison operator "${comparisonOperator}"`, + ); + return `${renderOperand(value.left as ValueReference | Scalar, context, `${path}.left`)} is ${operator} ${renderOperand(value.right as ValueReference | Scalar, context, `${path}.right`)}`; + } + case 'all': + case 'any': { + assertKeys(value, ['kind', 'predicates'], path); + const predicateValues = value.predicates; + if (!Array.isArray(predicateValues) || !predicateValues.length) + unsupported(`${path}.predicates`, 'an empty predicate group'); + const predicates = (predicateValues as unknown[]).map( + (item: unknown, index: number) => + renderPredicate( + item as Predicate, + context, + `${path}.predicates[${index}]`, + ), + ); + const grouped = predicates + .map((predicate) => `(${predicate})`) + .join('; '); + return kind === 'all' + ? `all of these conditions are met: ${grouped}` + : `any of these conditions is met: ${grouped}`; + } + case 'not': + assertKeys(value, ['kind', 'predicate'], path); + return `it is not the case that ${renderPredicate(value.predicate as Predicate, context, `${path}.predicate`)}`; + default: + return unsupported(`${path}.kind`, `the predicate kind "${kind}"`); + } +} + +function resetText( + reset: 'zero' | 'subtract' | 'never' | undefined, + path: string, +): string { + switch (reset ?? 'zero') { + case 'zero': + return 'and then starts counting again'; + case 'subtract': + return 'while retaining any matches beyond the threshold'; + case 'never': + return 'and does not trigger again'; + default: + return unsupported(`${path}.reset`, `the reset mode "${reset}"`); + } +} + +function reducerGroup( + reducer: Reducer, + context: PublicRenderContext, + path: string, +): string { + if (!('groupBy' in reducer) || reducer.groupBy === undefined) return ''; + return ` grouped by ${renderReference(reducer.groupBy, context, `${path}.groupBy`)}`; +} + +function renderReducer( + reducer: Reducer, + context: PublicRenderContext, + path: string, +): string { + const value = assertRecord(reducer, path, 'reducer'); + const kind = stringField(value, 'kind', `${path}.kind`); + switch (kind) { + case 'none': + assertKeys(value, ['kind'], path); + return ''; + case 'count': { + assertKeys(value, ['kind', 'threshold', 'reset', 'groupBy'], path); + const threshold = formatNumber( + assertFinite(value.threshold, `${path}.threshold`), + `${path}.threshold`, + ); + const reset = optionalStringField(value, 'reset', `${path}.reset`); + return `after ${threshold} matching events${reducerGroup(value as Reducer, context, path)} ${resetText(reset as 'zero' | 'subtract' | 'never' | undefined, path)}`; + } + case 'modulo': { + assertKeys(value, ['kind', 'every', 'groupBy'], path); + const every = formatNumber( + assertFinite(value.every, `${path}.every`), + `${path}.every`, + ); + return `on every ${every}th matching event${reducerGroup(value as Reducer, context, path)}`; + } + case 'distinct_count': { + assertKeys( + value, + ['kind', 'threshold', 'windowSeconds', 'distinctBy'], + path, + ); + const threshold = formatNumber( + assertFinite(value.threshold, `${path}.threshold`), + `${path}.threshold`, + ); + const seconds = formatNumber( + assertFinite(value.windowSeconds, `${path}.windowSeconds`), + `${path}.windowSeconds`, + ); + return `after ${threshold} distinct values of ${renderReference(value.distinctBy as ValueReference, context, `${path}.distinctBy`)} within ${seconds} seconds`; + } + case 'consecutive_count': { + assertKeys(value, ['kind', 'threshold', 'groupBy'], path); + const threshold = formatNumber( + assertFinite(value.threshold, `${path}.threshold`), + `${path}.threshold`, + ); + return `after ${threshold} consecutive matching events${reducerGroup(value as Reducer, context, path)}`; + } + case 'sliding_count': { + assertKeys( + value, + ['kind', 'threshold', 'windowSeconds', 'reset', 'groupBy'], + path, + ); + const threshold = formatNumber( + assertFinite(value.threshold, `${path}.threshold`), + `${path}.threshold`, + ); + const seconds = formatNumber( + assertFinite(value.windowSeconds, `${path}.windowSeconds`), + `${path}.windowSeconds`, + ); + const reset = optionalStringField(value, 'reset', `${path}.reset`); + return `after ${threshold} matching events within ${seconds} seconds${reducerGroup(value as Reducer, context, path)} ${resetText(reset as 'zero' | 'subtract' | 'never' | undefined, path)}`; + } + case 'duration': { + assertKeys(value, ['kind', 'seconds', 'repeat', 'groupBy'], path); + const seconds = formatNumber( + assertFinite(value.seconds, `${path}.seconds`), + `${path}.seconds`, + ); + const repeatValue = optionalBooleanField( + value, + 'repeat', + `${path}.repeat`, + ); + const repeat = repeatValue === true ? ' and repeats' : ''; + return `after the condition holds for ${seconds} seconds${reducerGroup(value as Reducer, context, path)}${repeat}`; + } + case 'periodic': { + assertKeys( + value, + ['kind', 'intervalSeconds', 'immediate', 'groupBy'], + path, + ); + const interval = renderExpression( + value.intervalSeconds as NumericExpression, + context, + `${path}.intervalSeconds`, + ); + const immediateValue = optionalBooleanField( + value, + 'immediate', + `${path}.immediate`, + ); + const immediate = immediateValue === true ? ' immediately, then' : ''; + return `at each interval${immediate} every ${interval} seconds while the condition holds${reducerGroup(value as Reducer, context, path)}`; + } + case 'derived_scalar': + assertKeys(value, ['kind', 'value'], path); + return `using the derived value ${renderExpression(value.value as NumericExpression, context, `${path}.value`)}`; + default: + return unsupported(`${path}.kind`, `the reducer kind "${kind}"`); + } +} + +function renderMutation( + mutation: ClauseMutation, + context: PublicRenderContext, + path: string, +): string { + const value = assertRecord(mutation, path, 'state mutation'); + const kind = stringField(value, 'kind', `${path}.kind`); + switch (kind) { + case 'none': + assertKeys(value, ['kind'], path); + return 'make no state change'; + case 'add': + case 'set': + case 'consume': + case 'derive': { + assertKeys(value, ['kind', 'pool', 'entity', 'value'], path); + const poolKey = stringField(value, 'pool', `${path}.pool`); + if (!poolKey.trim()) unsupported(`${path}.pool`, 'an empty state pool'); + const poolDefinition = context.pools.get(poolKey); + if (!poolDefinition) + unsupported(`${path}.pool`, `an unknown state pool "${poolKey}"`); + const pool = poolDefinition as PoolDefinition; + const action = + kind === 'add' + ? 'add' + : kind === 'set' + ? 'set' + : kind === 'consume' + ? 'consume' + : 'derive'; + const relation = + kind === 'add' ? 'to' : kind === 'consume' ? 'from' : 'as'; + return `${action} ${renderExpression(value.value as NumericExpression, context, `${path}.value`)} ${relation} the ${quote(pool.label, `${path}.pool`)} state for ${selectorText(value.entity as EntitySelector | undefined, `${path}.entity`, pool.scope)}`; + } + case 'clear': { + assertKeys(value, ['kind', 'pool', 'entity'], path); + const poolKey = stringField(value, 'pool', `${path}.pool`); + if (!poolKey.trim()) unsupported(`${path}.pool`, 'an empty state pool'); + const poolDefinition = context.pools.get(poolKey); + if (!poolDefinition) + unsupported(`${path}.pool`, `an unknown state pool "${poolKey}"`); + const pool = poolDefinition as PoolDefinition; + return `clear the ${quote(pool.label, `${path}.pool`)} state for ${selectorText(value.entity as EntitySelector | undefined, `${path}.entity`, pool.scope)}`; + } + case 'transfer': { + assertKeys( + value, + ['kind', 'pool', 'from', 'to', 'value', 'clearSource'], + path, + ); + const poolKey = stringField(value, 'pool', `${path}.pool`); + if (!poolKey.trim()) unsupported(`${path}.pool`, 'an empty state pool'); + const poolDefinition = context.pools.get(poolKey); + if (!poolDefinition) + unsupported(`${path}.pool`, `an unknown state pool "${poolKey}"`); + const pool = poolDefinition as PoolDefinition; + const amount = + value.value === undefined || value.value === 'all' + ? 'all of' + : renderExpression( + value.value as NumericExpression, + context, + `${path}.value`, + ); + const clearSource = optionalBooleanField( + value, + 'clearSource', + `${path}.clearSource`, + ); + if (value.from === undefined || value.to === undefined) + unsupported( + path, + 'a transfer without both source and destination selectors', + ); + const clear = + clearSource === false ? ', without clearing the source' : ''; + return `transfer ${amount} the ${quote(pool.label, `${path}.pool`)} state from ${selectorText(value.from as EntitySelector, `${path}.from`)} to ${selectorText(value.to as EntitySelector, `${path}.to`)}${clear}`; + } + case 'extend_duration': + assertKeys(value, ['kind', 'target', 'seconds'], path); + const target = stringField(value, 'target', `${path}.target`); + if (target !== 'assigned_ability') + unsupported(`${path}.target`, 'an unsupported duration target'); + return `extend the assigned ability by ${renderExpression(value.seconds as NumericExpression, context, `${path}.seconds`)} seconds`; + case 'start_cooldown_on_max': + assertKeys(value, ['kind', 'stackPool', 'cooldownPool'], path); + const stackKey = stringField(value, 'stackPool', `${path}.stackPool`); + const cooldownKey = stringField( + value, + 'cooldownPool', + `${path}.cooldownPool`, + ); + const stackDefinition = context.pools.get(stackKey); + const cooldownDefinition = context.pools.get(cooldownKey); + if (!stackDefinition || !cooldownDefinition) + unsupported( + path, + 'a maximum-state cooldown mutation with unknown pools', + ); + const stack = stackDefinition as PoolDefinition; + const cooldown = cooldownDefinition as PoolDefinition; + return `start the ${quote(cooldown.label, `${path}.cooldownPool`)} cooldown when the ${quote(stack.label, `${path}.stackPool`)} state reaches its maximum`; + default: + return unsupported(`${path}.kind`, `the mutation kind "${kind}"`); + } +} + +function scopeText( + scope: MechanicOutcome['scope'], + path = 'outcome.scope', +): string { + switch (scope) { + case 'self': + return 'the Agent'; + case 'equipped_weapon': + return 'the equipped weapon'; + case 'inactive_weapon': + return 'the inactive weapon'; + case 'target': + return 'the target'; + case 'allies': + return 'allies'; + case 'self_and_allies': + return 'the Agent and allies'; + case 'assigned_ability': + return 'the assigned ability'; + case 'all_skills': + return 'all Skills'; + default: + return unsupported(path, `the outcome scope "${scope}"`); + } +} + +function phaseText( + phase: MechanicOutcome['phase'], + path = 'outcome.phase', +): string { + switch (phase) { + case 'on_event': + return 'when this event occurs'; + case 'after_event': + return 'after this event'; + case 'on_enter': + return 'when the state begins'; + case 'on_exit': + return 'when the state ends'; + case 'while_active': + return 'while the state is active'; + default: + return unsupported(path, `the outcome phase "${phase}"`); + } +} + +function basisText( + outcome: MechanicOutcome, + context: PublicRenderContext, + path: string, +): string { + const value = renderExpression(outcome.value, context, `${path}.value`); + switch (outcome.basis) { + case 'flat': + return value; + case 'max_health_percent': + return `${value}% of maximum Health`; + case 'current_health_percent': + return `${value}% of current Health`; + case 'weapon_base_percent': + return `${value}% of weapon base damage`; + default: + return unsupported( + `${path}.basis`, + `the outcome basis "${outcome.basis}"`, + ); + } +} + +function targetingText(outcome: MechanicOutcome, path: string): string { + if (!outcome.targeting) return ''; + const targeting = assertRecord( + outcome.targeting, + `${path}.targeting`, + 'outcome targeting', + ); + if (targeting.kind === 'within_radius') { + unsupported( + `${path}.targeting`, + 'within-radius targeting because runtime evaluation does not expand it', + ); + assertKeys( + targeting, + ['kind', 'population', 'radiusMeters'], + `${path}.targeting`, + ); + if (targeting.population !== 'allies' && targeting.population !== 'enemies') + unsupported( + `${path}.targeting.population`, + 'an unsupported radius population', + ); + const radius = formatNumber( + assertFinite(targeting.radiusMeters, `${path}.targeting.radiusMeters`), + `${path}.targeting.radiusMeters`, + ); + return `, affecting ${targeting.population} within ${radius} meters`; + } + if (targeting.kind === 'event_target_area') { + assertKeys( + targeting, + ['kind', 'center', 'validTargets', 'radiusMeters'], + `${path}.targeting`, + ); + if ( + targeting.center !== 'event_target' || + targeting.validTargets !== 'enemies' + ) + unsupported(`${path}.targeting`, 'an unsupported event-target area'); + const radius = formatNumber( + assertFinite(targeting.radiusMeters, `${path}.targeting.radiusMeters`), + `${path}.targeting.radiusMeters`, + ); + return `, affecting valid enemies within ${radius} meters of the event target`; + } + return unsupported( + `${path}.targeting.kind`, + `the targeting kind "${targeting.kind}"`, + ); +} + +function renderOutcome( + outcome: MechanicOutcome, + context: PublicRenderContext, + path: string, +): string { + const value = assertRecord(outcome, path, 'outcome'); + assertKeys( + value, + [ + 'id', + 'label', + 'phase', + 'kind', + 'scope', + 'value', + 'basis', + 'resource', + 'delivery', + 'durationSeconds', + 'combatTiming', + 'attributeId', + 'statePool', + 'perStack', + 'minStacks', + 'maxStacks', + 'targeting', + 'probabilityPercent', + ], + path, + ); + const outcomeId = stringField(value, 'id', `${path}.id`); + if (!outcomeId.trim()) unsupported(`${path}.id`, 'an empty outcome ID'); + const label = assertText(value.label, `${path}.label`); + const kind = stringField(value, 'kind', `${path}.kind`); + const scopeValue = stringField(value, 'scope', `${path}.scope`); + const scope = scopeText( + scopeValue as MechanicOutcome['scope'], + `${path}.scope`, + ); + const basis = basisText(value as MechanicOutcome, context, path); + const resourceValue = optionalStringField( + value, + 'resource', + `${path}.resource`, + ); + const resource = + resourceValue === undefined ? '' : ` ${humanize(resourceValue)}`; + const combatTiming = optionalStringField( + value, + 'combatTiming', + `${path}.combatTiming`, + ); + if ( + combatTiming !== undefined && + combatTiming !== 'pre_event' && + combatTiming !== 'current' + ) + unsupported(`${path}.combatTiming`, 'an unsupported combat timing'); + const timing = + value.basis === 'flat' || combatTiming === undefined + ? '' + : combatTiming === 'pre_event' + ? ' using values before this event' + : ' using current values'; + const deliveryMode = + value.delivery === undefined ? 'instant' : value.delivery; + const isContinuous = deliveryMode === 'continuous'; + let action: string; + switch (kind) { + case 'attribute_modifier': { + const attributeId = stringField( + value, + 'attributeId', + `${path}.attributeId`, + ); + if (!attributeId.trim()) + unsupported(`${path}.attributeId`, 'an Attribute ID'); + const attribute = resolveAttributeLabel( + attributeId, + context, + `${path}.attributeId`, + ); + action = `modify ${quote(attribute, `${path}.attributeId`)} by ${basis} for ${scope}`; + break; + } + case 'direct_damage': + action = `deal ${value.basis === 'flat' ? `${basis} damage` : `damage equal to ${basis}`} to ${scope}`; + break; + case 'restoration': + action = `restore ${isContinuous ? 'a total of ' : ''}${basis} to${resource} for ${scope}`; + break; + case 'resource_cost': + action = `spend ${basis} of${resource} from ${scope}`; + break; + case 'weapon_reload': + action = `reload ${scope} after ${renderExpression(value.value as NumericExpression, context, `${path}.value`)} seconds`; + break; + case 'marker': + action = `record a marker of ${basis} for ${scope}`; + break; + default: + return unsupported(`${path}.kind`, `the outcome kind "${kind}"`); + } + const attributeId = optionalStringField( + value, + 'attributeId', + `${path}.attributeId`, + ); + if (attributeId !== undefined && kind !== 'attribute_modifier') { + if (!attributeId.trim()) + unsupported(`${path}.attributeId`, 'an Attribute ID'); + action += ` affecting ${quote(resolveAttributeLabel(attributeId, context, `${path}.attributeId`), `${path}.attributeId`)}`; + } + const statePoolKey = optionalStringField( + value, + 'statePool', + `${path}.statePool`, + ); + const statePool = + statePoolKey === undefined + ? '' + : (() => { + if (!statePoolKey.trim()) + unsupported(`${path}.statePool`, 'an empty state pool'); + const poolDefinition = context.pools.get(statePoolKey); + if (!poolDefinition) + unsupported( + `${path}.statePool`, + `an unknown state pool "${statePoolKey}"`, + ); + const pool = poolDefinition as PoolDefinition; + return ` using the ${quote(pool.label, `${path}.statePool`)} state`; + })(); + const minStacks = optionalNumberField( + value, + 'minStacks', + `${path}.minStacks`, + ); + const maxStacks = optionalNumberField( + value, + 'maxStacks', + `${path}.maxStacks`, + ); + const stackBand = + minStacks === undefined && maxStacks === undefined + ? '' + : minStacks !== undefined && maxStacks !== undefined + ? ` when stacks are from ${formatNumber(minStacks, `${path}.minStacks`)} through ${formatNumber(maxStacks, `${path}.maxStacks`)}` + : minStacks !== undefined + ? ` when stacks are at least ${formatNumber(minStacks, `${path}.minStacks`)}` + : ` when stacks are at most ${formatNumber(maxStacks as number, `${path}.maxStacks`)}`; + const perStackValue = optionalBooleanField( + value, + 'perStack', + `${path}.perStack`, + ); + const perStack = perStackValue === true ? ' per stack' : ''; + const delivery = + kind === 'weapon_reload' + ? '' + : deliveryMode === 'continuous' + ? ` continuously over ${formatNumber(assertFinite(value.durationSeconds, `${path}.durationSeconds`), `${path}.durationSeconds`)} seconds` + : deliveryMode === 'instant' + ? ' immediately' + : unsupported( + `${path}.delivery`, + `the delivery mode "${deliveryMode}"`, + ); + if (value.delivery === 'instant' && value.durationSeconds !== undefined) + unsupported(`${path}.durationSeconds`, 'a duration on instant delivery'); + const probability = + value.probabilityPercent === undefined + ? '' + : ` with a ${formatNumber(assertFinite(value.probabilityPercent, `${path}.probabilityPercent`), `${path}.probabilityPercent`)}% chance`; + const targeting = targetingText(value as MechanicOutcome, path); + const phase = phaseText( + value.phase as MechanicOutcome['phase'], + `${path}.phase`, + ); + return publicSentence( + `${label}: ${phase}, ${action}${targeting}${timing}${statePool}${perStack}${stackBand}${delivery}${probability}.`, + path, + ); +} + +function renderPool(pool: PoolDefinition, path: string): string { + const value = assertRecord(pool, path, 'state pool'); + assertKeys( + value, + [ + 'key', + 'label', + 'scope', + 'initial', + 'min', + 'max', + 'bestCaseValue', + 'expiresAfterSeconds', + ], + path, + ); + const poolKey = stringField(value, 'key', `${path}.key`); + if (!poolKey.trim()) unsupported(`${path}.key`, 'an empty state pool key'); + const scope = stringField(value, 'scope', `${path}.scope`); + if (scope !== 'agent' && scope !== 'target') + unsupported(`${path}.scope`, 'an unsupported state scope'); + const label = assertText(value.label, `${path}.label`); + const initial = formatNumber( + assertFinite(value.initial, `${path}.initial`), + `${path}.initial`, + ); + const bounds = + value.min === undefined && value.max === undefined + ? '' + : value.min !== undefined && value.max !== undefined + ? `, from ${formatNumber(assertFinite(value.min, `${path}.min`), `${path}.min`)} to ${formatNumber(assertFinite(value.max, `${path}.max`), `${path}.max`)}` + : value.min !== undefined + ? `, at least ${formatNumber(assertFinite(value.min, `${path}.min`), `${path}.min`)}` + : `, at most ${formatNumber(assertFinite(value.max, `${path}.max`), `${path}.max`)}`; + const best = + value.bestCaseValue === undefined + ? '' + : ` The maximum scenario value is ${formatNumber(assertFinite(value.bestCaseValue, `${path}.bestCaseValue`), `${path}.bestCaseValue`)}.`; + const expiry = + value.expiresAfterSeconds === undefined + ? '' + : ` It expires ${formatNumber(assertFinite(value.expiresAfterSeconds, `${path}.expiresAfterSeconds`), `${path}.expiresAfterSeconds`)} seconds after it is written.`; + return publicSentence( + `State “${label}” tracks ${scope === 'agent' ? 'the Agent' : 'each target'}, starting at ${initial}${bounds}.` + + best + + expiry, + path, + ); +} + +function renderScenarioInput( + input: Record, + path: string, +): string { + assertKeys( + input, + [ + 'key', + 'label', + 'kind', + 'readiness', + 'description', + 'defaultValue', + 'bestCaseValue', + 'min', + 'max', + ], + path, + ); + const inputKey = stringField(input, 'key', `${path}.key`); + if (!inputKey.trim()) + unsupported(`${path}.key`, 'an empty scenario input key'); + const inputKind = stringField(input, 'kind', `${path}.kind`); + if ( + inputKind !== 'toggle' && + inputKind !== 'slider' && + inputKind !== 'number' + ) + unsupported(`${path}.kind`, 'an unsupported scenario input kind'); + const readiness = optionalStringField( + input, + 'readiness', + `${path}.readiness`, + ); + if ( + readiness !== undefined && + readiness !== 'ready' && + readiness !== 'requires_assumption' + ) + unsupported(`${path}.readiness`, 'an unsupported scenario readiness'); + const label = assertText(input.label, `${path}.label`); + const kind = + inputKind === 'toggle' + ? 'toggle' + : inputKind === 'slider' + ? 'slider' + : 'number'; + const details: string[] = [kind]; + if (input.min !== undefined) + details.push( + `from ${formatNumber(assertFinite(input.min, `${path}.min`), `${path}.min`)}`, + ); + if (input.max !== undefined) + details.push( + `to ${formatNumber(assertFinite(input.max, `${path}.max`), `${path}.max`)}`, + ); + if (input.defaultValue !== undefined) + details.push( + `default ${formatScalar(input.defaultValue as Scalar, `${path}.defaultValue`)}`, + ); + if (input.bestCaseValue !== undefined) + details.push( + `maximum ${formatScalar(input.bestCaseValue as Scalar, `${path}.bestCaseValue`)}`, + ); + const description = + input.description === undefined + ? '' + : `: ${assertText(input.description, `${path}.description`)}`; + return publicSentence( + `The ${quote(label, `${path}.label`)} scenario input is a ${details.join(', ')}${description}.`, + path, + ); +} + +function renderClause( + clause: MechanicClause, + context: PublicRenderContext, + path: string, +): string { + const value = assertRecord(clause, path, 'clause'); + assertKeys( + value, + [ + 'id', + 'label', + 'recipeKey', + 'when', + 'if', + 'reducer', + 'cooldownSeconds', + 'weight', + 'procChancePercent', + 'requiresAssignedAbilityActive', + 'mutations', + 'outcomes', + ], + path, + ); + const clauseId = stringField(value, 'id', `${path}.id`); + if (!clauseId.trim()) unsupported(`${path}.id`, 'an empty clause ID'); + const when = stringField(value, 'when', `${path}.when`); + if (!MECHANIC_EVENT_REGISTRY[when as MechanicEventKind]) + unsupported(`${path}.when`, 'an unsupported event'); + const label = assertText(value.label, `${path}.label`); + const event = MECHANIC_EVENT_REGISTRY[when as MechanicEventKind]; + context.eventKind = when as MechanicEventKind; + let sentence = `${label}: When ${event.label} occurs`; + if (value.if !== undefined) { + const predicateText = renderPredicate( + value.if as Predicate, + context, + `${path}.if`, + ); + if (predicateText !== 'always') + sentence += `, provided that ${predicateText}`; + } + const reducer = renderReducer( + value.reducer as Reducer, + context, + `${path}.reducer`, + ); + if (reducer) sentence += `, ${reducer}`; + if (value.requiresAssignedAbilityActive === true) + sentence += ', only while the assigned ability is active'; + if (value.weight !== undefined) + sentence += `, with a weight of ${renderExpression(value.weight as NumericExpression, context, `${path}.weight`)}`; + if (value.procChancePercent !== undefined) + sentence += `, with a ${formatNumber(assertFinite(value.procChancePercent, `${path}.procChancePercent`), `${path}.procChancePercent`)}% chance to trigger`; + if (value.cooldownSeconds !== undefined) + sentence += `, then unavailable for ${formatNumber(assertFinite(value.cooldownSeconds, `${path}.cooldownSeconds`), `${path}.cooldownSeconds`)} seconds`; + sentence += '.'; + const mutations = Array.isArray(value.mutations) + ? value.mutations.map((mutation, index) => + renderMutation( + mutation as ClauseMutation, + context, + `${path}.mutations[${index}]`, + ), + ) + : unsupported(`${path}.mutations`, 'an invalid mutation list'); + const outcomes = + value.outcomes === undefined + ? [] + : Array.isArray(value.outcomes) + ? value.outcomes.map((outcome, index) => + renderOutcome( + outcome as MechanicOutcome, + context, + `${path}.outcomes[${index}]`, + ), + ) + : unsupported(`${path}.outcomes`, 'an invalid outcome list'); + const actions = [...mutations, ...outcomes] + .filter((action) => action !== 'make no state change') + .map((action) => { + if (action.endsWith('.')) return action; + return `${action.charAt(0).toUpperCase()}${action.slice(1)}.`; + }); + if (!actions.length) + return publicSentence( + `${sentence} No additional public effect is specified.`, + path, + ); + return publicSentence(`${sentence} ${actions.join(' ')}`, path); +} + +function renderProgram( + program: MechanicProgram, + key: string, + label: string, + options: MechanicPublicRenderOptions, +): MechanicPublicRenderedVariant { + const value = assertRecord(program, `variants[${key}]`, 'mechanic program'); + assertKeys( + value, + ['id', 'name', 'scenarioInputs', 'pools', 'clauses'], + `variants[${key}]`, + ); + const poolsValue: unknown[] = + value.pools === undefined ? [] : (value.pools as unknown[]); + if (!Array.isArray(poolsValue)) + unsupported(`variants[${key}].pools`, 'an invalid state pool list'); + const inputsValue: unknown[] = + value.scenarioInputs === undefined + ? [] + : (value.scenarioInputs as unknown[]); + if (!Array.isArray(inputsValue)) + unsupported( + `variants[${key}].scenarioInputs`, + 'an invalid scenario input list', + ); + const clausesValue: unknown[] = value.clauses as unknown[]; + if (!Array.isArray(clausesValue)) + unsupported(`variants[${key}].clauses`, 'an invalid clause list'); + const sortedPools = poolsValue + .map((pool, index) => { + const item = assertRecord( + pool, + `variants[${key}].pools[${index}]`, + 'state pool', + ); + return { + item, + key: stringField(item, 'key', `variants[${key}].pools[${index}].key`), + index, + }; + }) + .sort((left, right) => stableKeyCompare(left.key, right.key)); + const pools = new Map(); + for (const pool of sortedPools) { + pools.set(pool.key, pool.item as PoolDefinition); + } + const sortedInputs = inputsValue + .map((input, index) => { + const item = assertRecord( + input, + `variants[${key}].scenarioInputs[${index}]`, + 'scenario input', + ); + return { + item, + key: stringField( + item, + 'key', + `variants[${key}].scenarioInputs[${index}].key`, + ), + index, + }; + }) + .sort((left, right) => stableKeyCompare(left.key, right.key)); + const inputs = new Map(); + for (const input of sortedInputs) { + inputs.set(input.key, { + label: assertText( + input.item.label, + `variants[${key}].scenarioInputs[${input.index}].label`, + ), + }); + } + const context: PublicRenderContext = { + eventKind: 'tick', + pools, + inputs, + attributeLabels: new Map(), + attributeLabelResolver: options.resolveAttributeLabel, + }; + const sections: string[] = [publicSentence(label, `variants[${key}].label`)]; + if (sortedInputs.length) + sections.push( + sortedInputs + .map((input) => + renderScenarioInput( + input.item, + `variants[${key}].scenarioInputs[${input.index}]`, + ), + ) + .join(' '), + ); + if (sortedPools.length) + sections.push( + sortedPools + .map((pool) => + renderPool( + pool.item as PoolDefinition, + `variants[${key}].pools[${pool.index}]`, + ), + ) + .join(' '), + ); + if (!clausesValue.length) + unsupported(`variants[${key}].clauses`, 'an empty clause list'); + sections.push( + clausesValue + .map((clause, index) => + renderClause( + clause as MechanicClause, + context, + `variants[${key}].clauses[${index}]`, + ), + ) + .join('\n'), + ); + const text = publicSentence(sections.join('\n\n'), `variants[${key}]`); + if (!text || /[{}]/.test(text)) + unsupported(`variants[${key}]`, 'an empty or placeholder-only behavior'); + return { key, label, text }; +} + +function derivedVariantLabel(key: string): string | undefined { + switch (key) { + case 'I': + return 'Tier I'; + case 'II': + return 'Tier II'; + case 'minimum': + return 'Minimum'; + case 'maximum': + return 'Maximum'; + case 'base': + return 'Base'; + default: + return undefined; + } +} + +function variantLabel( + variant: MechanicPublicProgramVariant, + path: string, +): string { + if (variant.label !== undefined) return assertText(variant.label, path); + const knownLabel = derivedVariantLabel(variant.key); + if (knownLabel === undefined) + unsupported(path, 'a variant without a renderable public label'); + return knownLabel as string; +} + +/** + * Renders the ordered variants of one authored Mechanic Program source into + * public prose. Validation and rendering are all-or-nothing: one invalid or + * unsupported variant prevents any variant from being returned. + */ +export function renderPublicMechanicPrograms( + variants: readonly MechanicPublicProgramVariant[], + options: MechanicPublicRenderOptions = {}, +): MechanicPublicRenderResult { + try { + if (!Array.isArray(variants) || !variants.length) + fail( + 'invalid_program', + 'variants', + 'At least one mechanic variant is required.', + ); + const normalized = variants.map((variant, index) => { + const value = assertRecord( + variant, + `variants[${index}]`, + 'mechanic variant', + ); + assertKeys(value, ['key', 'label', 'program'], `variants[${index}]`); + const key = stringField(value, 'key', `variants[${index}].key`); + if (!key.trim()) + unsupported(`variants[${index}].key`, 'an empty variant key'); + const program = value.program; + if (!record(program)) + unsupported( + `variants[${index}].program`, + 'an invalid mechanic program', + ); + if (value.label !== undefined && typeof value.label !== 'string') + unsupported(`variants[${index}].label`, 'a variant label'); + return { + key, + label: value.label as string | undefined, + program: program as MechanicProgram, + }; + }); + const keys = new Set(); + for (const variant of normalized) { + if (keys.has(variant.key)) + unsupported(`variants[${variant.key}]`, 'a duplicate variant key'); + keys.add(variant.key); + } + const errors: MechanicPublicRenderError[] = []; + for (const variant of normalized) { + try { + const validation = validateMechanicProgram(variant.program); + if (!validation.ok) { + errors.push( + ...validation.errors.map((message) => ({ + code: 'invalid_program' as const, + path: `variants[${variant.key}]`, + message, + })), + ); + } + } catch { + errors.push({ + code: 'invalid_program', + path: `variants[${variant.key}]`, + message: 'The mechanic program could not be validated safely.', + }); + } + } + if (errors.length) return { ok: false, errors }; + return { + ok: true, + variants: normalized.map((variant) => + renderProgram( + variant.program, + variant.key, + variantLabel(variant, `variants[${variant.key}].label`), + options, + ), + ), + }; + } catch (error) { + if (error instanceof RenderFailure) + return { ok: false, errors: [error.data] }; + return { + ok: false, + errors: [ + { + code: 'unsupported_fragment', + path: 'program', + message: + 'The mechanic contains an unsupported fragment and cannot be rendered publicly.', + }, + ], + }; + } +} diff --git a/src/lib/calculations/mechanic.test.ts b/src/lib/calculations/mechanic.test.ts index 94ee3a3..9f12b8a 100644 --- a/src/lib/calculations/mechanic.test.ts +++ b/src/lib/calculations/mechanic.test.ts @@ -1355,6 +1355,104 @@ describe('structured mechanic runtime', () => { expect(maximum.finalState[0].value).toBe(4); }); + it('preserves an explicit zero clause weight', () => { + const result = evaluateMechanicProgram( + { + id: 'zero-weight', + name: 'Zero weight', + clauses: [ + { + id: 'zero-weight-clause', + when: 'agent_hit', + reducer: { kind: 'none' }, + weight: 0, + mutations: [], + }, + ], + }, + [{ kind: 'agent_hit', at: 0 }], + custom(1), + ); + expect(result.emissions).toHaveLength(1); + expect(result.emissions[0].weight).toBe(0); + expect(result.estimatedActivationCount).toBe(0); + }); + + it.each([0, 0.5])( + 'keeps a weighted reload delay intact at weight %s', + (weight) => { + const result = evaluateMechanicProgram( + { + id: 'weighted-reload', + name: 'Weighted reload', + clauses: [ + { + id: 'weighted-reload-clause', + when: 'weapon_hit', + reducer: { kind: 'none' }, + weight, + mutations: [], + outcomes: [ + { + id: 'reload', + label: 'Reload', + phase: 'on_event', + kind: 'weapon_reload', + scope: 'equipped_weapon', + value: 5, + basis: 'flat', + }, + ], + }, + ], + }, + [{ kind: 'weapon_hit', at: 1 }], + custom(10), + ); + expect(result.outcomes).toHaveLength(1); + expect(result.outcomes[0]).toMatchObject({ + at: 6, + weight, + resolvedValue: 5, + appliedValue: 5, + }); + }, + ); + + it('preserves a zero weight for maximum-mode passive outcomes', () => { + const result = evaluateMechanicProgram( + { + id: 'zero-weight-maximum', + name: 'Zero weight maximum', + clauses: [ + { + id: 'zero-weight-maximum-clause', + label: 'Maximum passive', + when: 'weapon_hit', + reducer: { kind: 'none' }, + weight: 0, + mutations: [], + outcomes: [ + { + id: 'marker', + label: 'Passive marker', + phase: 'while_active', + kind: 'marker', + scope: 'self', + value: 1, + basis: 'flat', + }, + ], + }, + ], + }, + [], + { mode: 'maximum', encounterSeconds: 10 }, + ); + expect(result.outcomes).toHaveLength(1); + expect(result.outcomes[0]).toMatchObject({ weight: 0, appliedValue: 0 }); + }); + it('derives proximity stacks directly from a builder-controlled snapshot', () => { const program: MechanicProgram = { id: 'nearby-allies', diff --git a/src/lib/calculations/mechanic.ts b/src/lib/calculations/mechanic.ts index 9620cd1..6a0b2ae 100644 --- a/src/lib/calculations/mechanic.ts +++ b/src/lib/calculations/mechanic.ts @@ -1633,8 +1633,10 @@ function emitOutcomes( } const targetIds = outcomeTargetIds(clause, outcome, context); if (!targetIds) continue; - const emittedAt = - outcome.kind === 'weapon_reload' ? at + Math.max(0, appliedValue) : at; + if (outcome.kind === 'weapon_reload') { + appliedValue = Math.max(0, resolvedValue); + } + const emittedAt = outcome.kind === 'weapon_reload' ? at + appliedValue : at; for (const targetId of targetIds) { emissions.push({ clauseId: clause.id, @@ -3115,6 +3117,10 @@ export function evaluateMechanicProgram( .filter((pool): pool is string => Boolean(pool)) .map((pool) => [pool, stateValue(pool, undefined, context)]), ); + const weight = + clause.weight !== undefined + ? clamp(numericValue(clause.weight, context), 0, 1) + : 1; appendScheduledOutcomes( outcomes, emitOutcomes( @@ -3126,7 +3132,7 @@ export function evaluateMechanicProgram( }, 'after', 0, - 1, + weight, before, context, continuousDeliveries, @@ -3248,9 +3254,10 @@ export function evaluateMechanicProgram( for (const reduction of reduced) { if (reduction.at + EPSILON < (cooldowns.get(stateKey) ?? 0)) continue; const context = { ...baseContext, reducerValue: reduction.value }; - const weight = clause.weight - ? clamp(numericValue(clause.weight, context), 0, 1) - : 1; + const weight = + clause.weight !== undefined + ? clamp(numericValue(clause.weight, context), 0, 1) + : 1; const relevantPools = new Set( (clause.outcomes ?? []) .map((outcome) => outcome.statePool)