import { connect } from '@tursodatabase/database'; import { describe, expect, it } from 'vitest'; import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; import { conditionalRuleDraftFromRows, loadConditionalRuleDraft, saveConditionalRuleInTransaction, } from './conditional-effects.js'; const rule = { id: 'rule-1', simulation_readiness: 'simulation_ready', scenario_inputs_json: '[]', }; const clause = { id: 'clause-1', rule_id: 'rule-1', clause_key: 'hit', label: 'Hit', event_kind: 'weapon_hit', conditions_json: '{"kind":"true"}', reducer_json: '{"kind":"none"}', mutations_json: '[]', sort_order: 1, active: 1, }; const outcome = { rule_id: 'rule-1', clause_id: 'clause-1', effect_key: 'damage', outcome_key: 'Damage', application_kind: 'active_modifier', effect_scope: 'self', stat_attribute_id: 'weapon-damage', value_basis: 'flat', value_scaling: 'flat', damage_basis: null, application_phase: 'while_active', combat_timing: 'current', resource_kind: null, state_pool_id: null, min_state_value: null, max_state_value: null, description_slot: 1, value: 3, value_expression_json: null, }; describe('conditional mechanic persistence adapter', () => { it('rejects divergent metadata across value variants', () => { expect(() => conditionalRuleDraftFromRows( rule, [], [clause], [ { ...outcome, variant_key: 'I' }, { ...outcome, variant_key: 'II', application_phase: 'after_event', value: 5, }, ], ['I', 'II'], ), ).toThrow(/inconsistent application_phase metadata/); }); it('loads consistent value variants into one outcome', () => { const draft = conditionalRuleDraftFromRows( rule, [], [clause], [ { ...outcome, variant_key: 'I' }, { ...outcome, variant_key: 'II', value: 5 }, ], ['I', 'II'], ); expect(draft?.clauses[0].outcomes[0].variants).toEqual({ I: { value: 3 }, II: { value: 5 }, }); expect(draft?.clauses[0].outcomes[0]).not.toHaveProperty('delivery'); expect(draft?.clauses[0].outcomes[0]).not.toHaveProperty('durationSeconds'); }); it('loads continuous delivery metadata and normalizes legacy instant variants', () => { const continuous = conditionalRuleDraftFromRows( rule, [], [clause], [ { ...outcome, application_kind: 'restore_health', stat_attribute_id: null, resource_kind: 'health', delivery_mode: 'continuous', delivery_duration_seconds: 3, variant_key: 'I', }, { ...outcome, application_kind: 'restore_health', stat_attribute_id: null, resource_kind: 'health', delivery_mode: 'continuous', delivery_duration_seconds: 3, variant_key: 'II', }, ], ['I', 'II'], ); expect(continuous?.clauses[0].outcomes[0]).toMatchObject({ delivery: 'continuous', durationSeconds: 3, }); expect(() => conditionalRuleDraftFromRows( rule, [], [clause], [ { ...outcome, variant_key: 'I' }, { ...outcome, variant_key: 'II', delivery_mode: 'instant' }, ], ['I', 'II'], ), ).not.toThrow(); }); it('rejects divergent continuous-delivery durations across variants', () => { expect(() => conditionalRuleDraftFromRows( rule, [], [clause], [ { ...outcome, variant_key: 'I', delivery_mode: 'continuous', delivery_duration_seconds: 3, }, { ...outcome, variant_key: 'II', delivery_mode: 'continuous', delivery_duration_seconds: 4, }, ], ['I', 'II'], ), ).toThrow(/inconsistent delivery_duration_seconds metadata/); }); it('round-trips continuous delivery and direct event predicates through normalized rows', async () => { const database = await connect(':memory:'); await database.exec(`CREATE TABLE conditional_effect_rules ( id TEXT PRIMARY KEY, source_type TEXT, source_id TEXT, configured INTEGER, script_version INTEGER, simulation_readiness TEXT, scenario_inputs_json TEXT, created_at TEXT, updated_at TEXT, UNIQUE(source_type,source_id) )`); await database.exec(`CREATE TABLE conditional_effect_state_pools ( id TEXT PRIMARY KEY, rule_id TEXT, state_key TEXT, label TEXT, state_kind TEXT, state_scope TEXT, initial_value REAL, minimum_value REAL, maximum_value REAL, best_case_value REAL, duration_seconds REAL, expiry_policy TEXT, refresh_policy TEXT, parameters_json TEXT, sort_order INTEGER, created_at TEXT, updated_at TEXT )`); await database.exec(`CREATE TABLE conditional_effect_clauses ( id TEXT PRIMARY KEY, rule_id TEXT, clause_key TEXT, label TEXT, event_kind TEXT, recipe_key TEXT, recipe_parameters_json TEXT, conditions_json TEXT, reducer_json TEXT, weight_expression_json TEXT, emission_kind TEXT, state_pool_id TEXT, mutations_json TEXT, cooldown_seconds REAL, output_branch TEXT, sort_order INTEGER, active INTEGER, created_at TEXT, updated_at TEXT )`); await database.exec(`CREATE TABLE conditional_effect_outcomes ( id TEXT PRIMARY KEY, rule_id TEXT, clause_id TEXT, state_pool_id TEXT, effect_key TEXT, variant_key TEXT, outcome_key TEXT, application_kind TEXT, effect_scope TEXT, stat_attribute_id TEXT, value REAL, value_expression_json TEXT, value_basis TEXT, value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT, combat_timing TEXT, resource_kind TEXT, delivery_mode TEXT, delivery_duration_seconds REAL, targeting_json TEXT, min_state_value REAL, max_state_value REAL, probability_percent REAL, description_slot INTEGER, sort_order INTEGER, created_at TEXT, updated_at TEXT )`); await saveConditionalRuleInTransaction( database, 'talent', 'predatory-aura', { version: 1, simulationReadiness: 'ready', scenarioInputs: [], pools: [ { key: 'marked-target', label: 'Marked Target', stateKind: 'target_mark', scope: 'target', initial: 0, min: 0, max: 1, expiresAfterSecondsVariants: { I: 12, II: 18, }, } as ConditionalMechanicDraftV1['pools'][number] & { expiresAfterSecondsVariants: Record; }, ], clauses: [ { key: 'weapon-kill', label: 'Weapon kill', event: 'enemy_killed', procChancePercent: 29, requiresAssignedAbilityActive: true, if: { kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current', }, operator: 'lt', right: 75, }, reducer: { kind: 'none' }, mutations: [ { kind: 'set', pool: 'marked-target', entity: { kind: 'event_target' }, value: 1, }, ], outcomes: [ { key: 'restore-health', label: 'Restore Health', phase: 'after_event', kind: 'restoration', scope: 'self', resource: 'health', basis: 'flat', delivery: 'continuous', durationSeconds: 3, targeting: { kind: 'within_radius', population: 'allies', radiusMeters: 40, }, probabilityPercent: 29, variants: { I: { value: 240 }, II: { value: 336 }, }, }, { key: 'target-detonation', label: 'Target Detonation', phase: 'after_event', kind: 'direct_damage', scope: 'target', basis: 'flat', statePool: 'marked-target', perStack: true, targeting: { kind: 'event_target_area', center: 'event_target', validTargets: 'enemies', radiusMeters: 5, }, variants: { I: { value: 240, expression: { op: 'multiply', left: 240, right: { op: 'divide', left: { ref: { source: 'attribute', attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e', }, }, right: 100, }, }, }, II: { value: 336, expression: { op: 'multiply', left: 336, right: { op: 'divide', left: { ref: { source: 'attribute', attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e', }, }, right: 100, }, }, }, }, }, ], }, { key: 'extra-health-state', label: 'Extra Health state', event: 'extra_health_changed', if: { kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true, }, reducer: { kind: 'none' }, mutations: [], outcomes: [], }, { key: 'tactical-link-assist', label: 'Tactical Link assist', event: 'assist', if: { kind: 'compare', left: { source: 'event', path: 'values.assignedAbilityActive', }, operator: 'eq', right: true, }, reducer: { kind: 'none' }, mutations: [], outcomes: [], }, ], }, ); const saved = await ( await database.prepare( 'SELECT delivery_mode,delivery_duration_seconds FROM conditional_effect_outcomes ORDER BY variant_key', ) ).all(); expect(saved).toEqual([ { delivery_mode: 'continuous', delivery_duration_seconds: 3 }, { delivery_mode: 'instant', delivery_duration_seconds: null }, { delivery_mode: 'continuous', delivery_duration_seconds: 3 }, { delivery_mode: 'instant', delivery_duration_seconds: null }, ]); expect( await ( await database.prepare( 'SELECT duration_seconds,parameters_json FROM conditional_effect_state_pools', ) ).get(), ).toEqual({ duration_seconds: null, parameters_json: JSON.stringify({ expiresAfterSecondsVariants: { I: 12, II: 18, }, }), }); const loaded = await loadConditionalRuleDraft( database, 'talent', 'predatory-aura', ['I', 'II'], ); expect(loaded?.clauses[0].outcomes[0]).toMatchObject({ delivery: 'continuous', durationSeconds: 3, targeting: { kind: 'within_radius', population: 'allies', radiusMeters: 40, }, probabilityPercent: 29, variants: { I: { value: 240 }, II: { value: 336 }, }, }); expect(loaded?.clauses[0].outcomes[1]).toMatchObject({ phase: 'after_event', kind: 'direct_damage', scope: 'target', targeting: { kind: 'event_target_area', center: 'event_target', validTargets: 'enemies', radiusMeters: 5, }, }); expect(loaded?.clauses[0].if).toEqual({ kind: 'compare', left: { source: 'combat', path: 'currentHealthPercent', timing: 'current', }, operator: 'lt', right: 75, }); expect(loaded?.clauses[0]).toMatchObject({ procChancePercent: 29, requiresAssignedAbilityActive: true, }); expect(loaded?.pools[0]).toMatchObject({ key: 'marked-target', stateKind: 'target_mark', scope: 'target', expiresAfterSecondsVariants: { I: 12, II: 18, }, }); expect(loaded?.clauses[0].mutations).toEqual([ { kind: 'set', pool: 'marked-target', entity: { kind: 'event_target' }, value: 1, }, ]); expect(loaded?.clauses[0].outcomes[1]).toMatchObject({ phase: 'after_event', kind: 'direct_damage', scope: 'target', statePool: 'marked-target', perStack: true, variants: { I: { value: 240, expression: expect.objectContaining({ right: expect.objectContaining({ left: { ref: { source: 'attribute', attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e', }, }, }), }), }, II: { value: 336 }, }, }); expect(loaded?.clauses[1]).toMatchObject({ event: 'extra_health_changed', if: { kind: 'compare', left: { source: 'event', path: 'values.hasExtraHealth' }, operator: 'eq', right: true, }, }); expect(loaded?.clauses[2]).toMatchObject({ event: 'assist', if: { kind: 'compare', left: { source: 'event', path: 'values.assignedAbilityActive' }, operator: 'eq', right: true, }, }); }); });