import { describe, expect, it } from 'vitest'; import { currentHealthPercentBandPredicate, eventFieldComparisonPredicate, parseConditionalMechanicDraft, readCurrentHealthPercentBand, readEventFieldComparison, registeredEventFields, readStatePoolThreshold, statePoolThresholdPredicate, 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('resolves and validates variant-specific pool expiration durations', () => { const value = draft(); value.pools[0] = { ...value.pools[0], expiresAfterSeconds: undefined, expiresAfterSecondsVariants: { I: 12, II: 18 }, }; const result = parseConditionalMechanicDraft(value, { variantKeys: ['I', 'II'], }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.programs.I.pools?.[0].expiresAfterSeconds).toBe(12); expect(result.value.programs.II.pools?.[0].expiresAfterSeconds).toBe(18); value.pools[0].expiresAfterSecondsVariants = { I: 12 }; const invalid = parseConditionalMechanicDraft(value, { variantKeys: ['I', 'II'], }); expect(invalid.ok).toBe(false); if (!invalid.ok) { expect(invalid.errors).toContain( 'State pool 1 variant expiration durations must define one positive number for each variant.', ); } }); it('round-trips strict manual current Health percentage band editor state', () => { const predicate = currentHealthPercentBandPredicate({ minimum: 25, maximum: 50, }); expect(readCurrentHealthPercentBand(predicate)).toEqual({ minimum: 25, maximum: 50, }); expect(predicate).toEqual({ kind: 'all', predicates: [ { kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current', }, operator: 'gte', right: 25, }, { kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current', }, operator: 'lt', right: 50, }, ], }); expect( readCurrentHealthPercentBand({ kind: 'compare', left: { source: 'event', path: 'values.healthPercent' }, operator: 'lt', right: 50, }), ).toBeNull(); }); it('round-trips editable event scalar comparisons without claiming advanced predicates', () => { for (const value of ['moving', 4, true]) { const comparison = { path: 'values.state', operator: 'eq' as const, value, }; const predicate = eventFieldComparisonPredicate(comparison); expect(readEventFieldComparison(predicate)).toEqual(comparison); } expect( readEventFieldComparison({ kind: 'compare', left: { source: 'event', path: 'values.state' }, operator: 'eq', right: { source: 'event', path: 'values.previousState' }, }), ).toBeNull(); expect( readEventFieldComparison({ kind: 'all', predicates: [], }), ).toBeNull(); }); it('exposes typed Extra Health fields and parses their manual editor state', () => { expect(registeredEventFields('extra_health_changed')).toEqual([ { path: 'values.hasExtraHealth', label: 'Has Extra Health', type: 'boolean', required: true, }, { path: 'values.currentExtraHealth', label: 'Current Extra Health', type: 'number', minimum: 0, }, ]); const value = draft(); value.clauses[0].event = 'extra_health_changed'; value.clauses[0].recipeKey = undefined; value.clauses[0].reducer = { kind: 'none' }; value.clauses[0].if = eventFieldComparisonPredicate({ path: 'values.hasExtraHealth', operator: 'eq', value: true, }); const result = parseConditionalMechanicDraft(value, { variantKeys: ['I', 'II'], allowedAttributeIds: new Set(['weapon-damage']), }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.programs.I.clauses[0]).toMatchObject({ when: 'extra_health_changed', if: value.clauses[0].if, }); }); it('rejects an unregistered Extra Health event field deterministically', () => { const value = draft(); value.clauses[0].event = 'extra_health_changed'; value.clauses[0].recipeKey = undefined; value.clauses[0].reducer = { kind: 'none' }; value.clauses[0].if = eventFieldComparisonPredicate({ path: 'values.extraHealthActive', operator: 'eq', value: true, }); const result = parseConditionalMechanicDraft(value, { variantKeys: ['I', 'II'], allowedAttributeIds: new Set(['weapon-damage']), }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.errors).toEqual( expect.arrayContaining([ expect.stringContaining('is not defined for Extra Health changed'), ]), ); } }); it('round-trips state-pool thresholds and preserves unsupported targeted pools as advanced', () => { const comparison = { pool: 'stacks', operator: 'gte' as const, value: 10 }; const predicate = statePoolThresholdPredicate(comparison); expect(readStatePoolThreshold(predicate)).toEqual(comparison); expect( readStatePoolThreshold({ kind: 'compare', left: { source: 'pool', pool: 'stacks', entity: { kind: 'event_target' }, }, operator: 'gt', right: 0, }), ).toBeNull(); }); it('parses movement-state and stack-threshold authoring predicates unchanged', () => { const value = draft(); value.clauses[0].event = 'movement_snapshot'; value.clauses[0].if = eventFieldComparisonPredicate({ path: 'values.state', operator: 'eq', value: 'sprinting', }); value.clauses.push({ ...structuredClone(value.clauses[0]), key: 'consume-stacks', event: 'shot_fired', if: statePoolThresholdPredicate({ pool: 'effect', operator: 'gt', value: 0, }), }); const result = parseConditionalMechanicDraft(value, { variantKeys: ['I', 'II'], allowedAttributeIds: new Set(['weapon-damage']), }); expect(result.ok).toBe(true); if (!result.ok) return; expect(result.value.draft.clauses.map((clause) => clause.if)).toEqual( value.clauses.map((clause) => clause.if), ); expect(result.value.programs.I.clauses.map((clause) => clause.if)).toEqual( value.clauses.map((clause) => clause.if), ); }); 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.'], }); }); it('rejects invalid pre-event timing for current Health percentage references', () => { const invalid = draft(); invalid.clauses[0].if = { kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'pre_event', }, operator: 'lt', right: 50, }; const result = parseConditionalMechanicDraft(invalid, { variantKeys: ['I', 'II'], allowedAttributeIds: new Set(['weapon-damage']), }); expect(result.ok).toBe(false); }); it('accepts compatible radius targeting and rejects incompatible scopes', () => { const valid = draft(); valid.clauses[0].outcomes[0].scope = 'allies'; valid.clauses[0].outcomes[0].targeting = { kind: 'within_radius', population: 'allies', radiusMeters: 40, }; const parsed = parseConditionalMechanicDraft(valid, { variantKeys: ['I', 'II'], allowedAttributeIds: new Set(['weapon-damage']), }); expect(parsed.ok).toBe(true); if (parsed.ok) { expect(parsed.value.draft.clauses[0].outcomes[0].targeting).toEqual( valid.clauses[0].outcomes[0].targeting, ); } valid.clauses[0].outcomes[0].scope = 'self'; const incompatible = parseConditionalMechanicDraft(valid, { variantKeys: ['I', 'II'], allowedAttributeIds: new Set(['weapon-damage']), }); expect(incompatible.ok).toBe(false); if (!incompatible.ok) { expect(incompatible.errors).toContain( 'Clause 1 outcome 1 ally radius targeting requires allies or self-and-allies scope.', ); } }); });