import { describe, expect, it } from 'vitest'; import { parseConditionalMechanicDraft, type ConditionalMechanicDraftV1 } from './mechanic-editor.js'; const draft = (): ConditionalMechanicDraftV1 => ({ version: 1, simulationReadiness: 'ready', scenarioInputs: [], pools: [{ key: 'effect', label: 'Effect', stateKind: 'stack', scope: 'agent', initial: 0, min: 0, max: 10, bestCaseValue: 10, expiresAfterSeconds: 10 }], clauses: [{ key: 'gain-effect', label: 'Gain Effect', event: 'weapon_hit', recipeKey: 'same_target_hits_within_window', if: { kind: 'exists', value: { source: 'event', path: 'targetId' } }, reducer: { kind: 'sliding_count', threshold: 9, windowSeconds: 3, groupBy: { source: 'event', path: 'targetId' } }, cooldownSeconds: 2, weight: 1, mutations: [{ kind: 'add', pool: 'effect', value: 1 }], outcomes: [{ key: 'weapon-damage', label: 'Weapon Damage', phase: 'while_active', kind: 'attribute_modifier', scope: 'equipped_weapon', basis: 'flat', attributeId: 'weapon-damage', statePool: 'effect', perStack: true, descriptionSlot: 1, variants: { I: { value: 3 }, II: { value: 4.2 } } }] }] }); describe('conditional mechanic authoring payload', () => { it('accepts an empty captured-only program for deferred modeling', () => { const partial: ConditionalMechanicDraftV1 = { version: 1, simulationReadiness: 'captured_only', scenarioInputs: [], pools: [], clauses: [] }; const result = parseConditionalMechanicDraft(partial, { variantKeys: ['I', 'II'] }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.draft).toEqual(partial); expect(result.value.programs.I.clauses).toEqual([]); expect(result.value.programs.II.clauses).toEqual([]); }); it('requires a branch after a partial program is marked calculation-ready', () => { const partial: ConditionalMechanicDraftV1 = { version: 1, simulationReadiness: 'ready', scenarioInputs: [], pools: [], clauses: [] }; const result = parseConditionalMechanicDraft(partial, { variantKeys: ['I', 'II'] }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.errors).toContain('A simulation-ready mechanic requires at least one clause.'); } }); it('builds one validated runtime program per exact source variant', () => { const result = parseConditionalMechanicDraft(draft(), { variantKeys: ['I', 'II'], programId: 'superior-firepower', programName: 'Superior Firepower', allowedAttributeIds: new Set(['weapon-damage']), allowedRecipeKeys: new Set(['same_target_hits_within_window']) }); expect(result.ok).toBe(true); if (!result.ok) return; expect(Object.keys(result.value.programs)).toEqual(['I', 'II']); expect(result.value.programs.I.clauses[0]).toMatchObject({ id: 'gain-effect', when: 'weapon_hit', recipeKey: 'same_target_hits_within_window', cooldownSeconds: 2, weight: 1, outcomes: [{ id: 'weapon-damage', scope: 'equipped_weapon', value: 3 }] }); expect(result.value.programs.II.clauses[0].outcomes?.[0].value).toBe(4.2); }); it('preserves Happy Survival expressions, explicit cadence readiness, resources, and combat timing', () => { const happy: ConditionalMechanicDraftV1 = { version: 1, simulationReadiness: 'requires_assumptions', scenarioInputs: [{ key: 'restoration-events-per-minute', label: 'Restoration events per minute', kind: 'number', readiness: 'requires_assumption', min: 0 }], pools: [{ key: 'nearby-enemies', label: 'Nearby Enemies', stateKind: 'count', scope: 'agent', initial: 0, min: 0, max: 20, bestCaseValue: 5 }], clauses: [{ key: 'shot-health-exchange', event: 'shot_fired', reducer: { kind: 'none' }, mutations: [{ kind: 'none' }], outcomes: [ { key: 'health-cost', label: 'Health Cost', phase: 'on_event', kind: 'resource_cost', resource: 'health', scope: 'self', basis: 'max_health_percent', variants: { base: { value: 2 } } }, { key: 'bonus-damage', label: 'Current Health Bonus Damage', phase: 'on_event', kind: 'direct_damage', scope: 'target', basis: 'current_health_percent', combatTiming: 'current', variants: { base: { value: 0.6, expression: { op: 'multiply', left: 0.6, right: { op: 'add', left: 1, right: { op: 'divide', left: { ref: { source: 'equipment', path: 'protocolTalentUpgrades' } }, right: 5 } } } } } } ] }] }; const result = parseConditionalMechanicDraft(happy, { variantKeys: ['base'], programName: 'Happy Survival' }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.programs.base.clauses[0].outcomes).toEqual([ expect.objectContaining({ kind: 'resource_cost', resource: 'health', value: 2 }), expect.objectContaining({ kind: 'direct_damage', combatTiming: 'current', value: expect.objectContaining({ right: expect.objectContaining({ right: expect.objectContaining({ left: { ref: { source: 'equipment', path: 'protocolTalentUpgrades' } } }) }) }) }) ]); }); it('accepts catalog Attribute references and rejects unknown IDs recursively', () => { const referenced = draft(); referenced.clauses[0].outcomes[0] = { key: 'health-restoration', label: 'Health Restoration', phase: 'after_event', kind: 'restoration', resource: 'health', scope: 'self', basis: 'flat', delivery: 'continuous', durationSeconds: 3, variants: { I: { value: 240, expression: { op: 'multiply', left: 240, right: { op: 'divide', left: { ref: { source: 'attribute', attributeId: 'firepower-multiplier' } }, right: 100 } } }, II: { value: 336, expression: { op: 'multiply', left: 336, right: { op: 'divide', left: { ref: { source: 'attribute', attributeId: 'firepower-multiplier' } }, right: 100 } } } } }; const accepted = parseConditionalMechanicDraft(referenced, { variantKeys: ['I', 'II'], allowedAttributeIds: new Set(['firepower-multiplier']) }); expect(accepted.ok).toBe(true); if (accepted.ok) { expect(accepted.value.programs.I.clauses[0].outcomes?.[0].value).toMatchObject({ right: { left: { ref: { source: 'attribute', attributeId: 'firepower-multiplier' } } } }); expect(accepted.value.programs.II.clauses[0].outcomes?.[0]).toMatchObject({ delivery: 'continuous', durationSeconds: 3, value: { left: 336 } }); } const rejected = parseConditionalMechanicDraft(referenced, { variantKeys: ['I', 'II'], allowedAttributeIds: new Set(['weapon-damage']) }); expect(rejected.ok).toBe(false); if (!rejected.ok) { expect(rejected.errors).toContain( 'Conditional mechanic clauses item 1 outcomes item 1 variants I expression right left ref references unknown Attribute "firepower-multiplier".' ); } }); it('preserves target entity mutations and state-scaled exit outcomes', () => { const targeted: ConditionalMechanicDraftV1 = { version: 1, simulationReadiness: 'ready', scenarioInputs: [], pools: [{ key: 'marked-target', label: 'Marked Target', stateKind: 'target_mark', scope: 'target', initial: 0, min: 0, max: 1 }], clauses: [{ key: 'detonate-mark', event: 'enemy_killed', reducer: { kind: 'none' }, mutations: [{ kind: 'set', pool: 'marked-target', entity: { kind: 'event_target' }, value: 1 }], outcomes: [{ key: 'target-damage', label: 'Target Damage', phase: 'on_exit', kind: 'direct_damage', scope: 'target', basis: 'flat', statePool: 'marked-target', perStack: true, variants: { base: { value: 240, expression: { op: 'multiply', left: 240, right: { op: 'divide', left: { ref: { source: 'attribute', attributeId: 'firepower-multiplier' } }, right: 100 } } } } }] }] }; const result = parseConditionalMechanicDraft(targeted, { variantKeys: ['base'], allowedAttributeIds: new Set(['firepower-multiplier']) }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.draft).toMatchObject(targeted); expect(result.value.programs.base).toMatchObject({ pools: [{ key: 'marked-target', scope: 'target' }], clauses: [{ mutations: [{ kind: 'set', pool: 'marked-target', entity: { kind: 'event_target' }, value: 1 }], outcomes: [{ phase: 'on_exit', kind: 'direct_damage', statePool: 'marked-target', perStack: true, value: { right: { left: { ref: { source: 'attribute', attributeId: 'firepower-multiplier' } } } } }] }] }); }); it('rejects outcome fields that are absent from the canonical capability manifest', () => { const unsupported = draft(); (unsupported.clauses[0].outcomes[0] as unknown as Record).cadence = 'continuous'; const result = parseConditionalMechanicDraft(unsupported, { variantKeys: ['I', 'II'], allowedAttributeIds: new Set(['weapon-damage']) }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.errors).toContain( 'Clause 1 outcome 1 contains unsupported field "cadence".' ); } }); it('requires the exact configured variants and returns validation errors without throwing', () => { const invalid = draft(); invalid.clauses[0].outcomes[0].variants = { I: { value: 3 }, legacy: { value: 99 } }; const result = parseConditionalMechanicDraft(invalid, { variantKeys: ['I', 'II'] }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.errors).toContain( 'Clause 1 outcome 1 must define exactly these variants: I, II.' ); } expect(() => parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] })) .not.toThrow(); expect(parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] })) .toEqual({ ok: false, errors: ['The conditional mechanic contains malformed JSON.'] }); }); });