feat: add source-owned combat mechanic programs

This commit is contained in:
JB
2026-07-27 13:35:00 -04:00
parent 5a67b252aa
commit 4eb754e7c8
62 changed files with 14815 additions and 905 deletions
+903
View File
@@ -0,0 +1,903 @@
import { describe, expect, it } from 'vitest';
import {
evaluateMechanicProgram,
formatMechanicProgram,
validateMechanicProgram,
type MechanicEvent,
type MechanicProgram
} from './mechanic.js';
const custom = (encounterSeconds = 30, inputs: Record<string, number | boolean> = {}) => ({
mode: 'custom' as const,
encounterSeconds,
inputs
});
describe('structured mechanic runtime', () => {
it('models Superior Firepower as same-target sliding hits and one shared expiring stack pool', () => {
const superiorFirepower: MechanicProgram = {
id: 'superior-firepower',
name: 'Superior Firepower',
pools: [{
key: 'relentless',
label: 'Relentless',
scope: 'agent',
initial: 0,
min: 0,
max: 10,
expiresAfterSeconds: 10
}],
clauses: [{
id: 'build-relentless',
label: 'Build Relentless',
when: 'weapon_hit',
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
reducer: {
kind: 'sliding_count',
threshold: 9,
windowSeconds: 3,
groupBy: { source: 'event', path: 'targetId' }
},
mutations: [{ kind: 'add', pool: 'relentless', value: 1 }],
outcomes: [
{
id: 'relentless-weapon-damage',
label: 'Weapon Damage',
phase: 'while_active',
kind: 'attribute_modifier',
basis: 'flat',
scope: 'equipped_weapon',
attributeId: 'weapon-damage',
value: 3,
statePool: 'relentless',
perStack: true
},
{
id: 'relentless-critical-damage',
label: 'Critical Damage',
phase: 'while_active',
kind: 'attribute_modifier',
basis: 'flat',
scope: 'equipped_weapon',
attributeId: 'critical-damage',
value: 1,
statePool: 'relentless',
perStack: true
}
]
}]
};
const events: MechanicEvent[] = Array.from({ length: 18 }, (_, index) => ({
kind: 'weapon_hit',
at: index * 0.2,
targetId: 'enemy-a'
}));
const result = evaluateMechanicProgram(superiorFirepower, events, custom(15));
expect(result.status).toBe('OK');
expect(result.emissions[0].at).toBeCloseTo(1.6);
expect(result.emissions[1].at).toBeCloseTo(3.4);
expect(result.finalState).toContainEqual(expect.objectContaining({
pool: 'relentless',
value: 0,
expiresAt: null
}));
expect(result.outcomes.every((outcome) => outcome.attributeId !== null)).toBe(true);
expect(result.outcomes.map((outcome) => outcome.value)).toEqual([3, 1, 6, 2]);
expect(result.script).toContain('COUNT 9 MATCHES WITHIN 3s PER EVENT.targetId');
expect(result.script).toContain('ADD 1 TO relentless');
const maximum = evaluateMechanicProgram(superiorFirepower, [], {
mode: 'maximum',
encounterSeconds: 15
});
expect(maximum.finalState[0].value).toBe(10);
expect(maximum.outcomes.map((outcome) => outcome.value)).toEqual([30, 10]);
});
it('supports every-N-shot reducers without a bespoke action family', () => {
const program: MechanicProgram = {
id: 'every-three-shots',
name: 'Every Three Shots',
pools: [{ key: 'charges', label: 'Charges', scope: 'agent', initial: 0, max: 10 }],
clauses: [{
id: 'charge',
when: 'shot_fired',
reducer: { kind: 'modulo', every: 3 },
mutations: [{ kind: 'add', pool: 'charges', value: 1 }]
}]
};
const events = Array.from({ length: 7 }, (_, index): MechanicEvent => ({
kind: 'shot_fired',
at: index
}));
const result = evaluateMechanicProgram(program, events, custom(10));
expect(result.emissions.map((emission) => emission.at)).toEqual([2, 5]);
expect(result.finalState[0].value).toBe(2);
});
it('supports duration and periodic reducers over deterministic timeline snapshots', () => {
const program: MechanicProgram = {
id: 'timed-movement',
name: 'Timed Movement',
pools: [
{ key: 'settled', label: 'Settled', scope: 'agent', initial: 0, max: 1 },
{ key: 'tempo', label: 'Tempo', scope: 'agent', initial: 0, max: 10 }
],
clauses: [
{
id: 'immobile-duration',
when: 'movement_snapshot',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.state' },
operator: 'eq',
right: 'immobile'
},
reducer: { kind: 'duration', seconds: 3 },
mutations: [{ kind: 'set', pool: 'settled', value: 1 }]
},
{
id: 'moving-periodic',
when: 'movement_snapshot',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.state' },
operator: 'eq',
right: 'moving'
},
reducer: { kind: 'periodic', intervalSeconds: 2 },
mutations: [{ kind: 'add', pool: 'tempo', value: 1 }]
}
]
};
const result = evaluateMechanicProgram(program, [
{ kind: 'movement_snapshot', at: 0, values: { state: 'immobile' } },
{ kind: 'movement_snapshot', at: 2, values: { state: 'immobile' } },
{ kind: 'movement_snapshot', at: 3, values: { state: 'immobile' } },
{ kind: 'movement_snapshot', at: 5, values: { state: 'moving' } },
{ kind: 'movement_snapshot', at: 7, values: { state: 'moving' } },
{ kind: 'movement_snapshot', at: 9, values: { state: 'moving' } }
], custom(12));
expect(result.emissions.map(({ clauseId, at }) => ({ clauseId, at }))).toEqual([
{ clauseId: 'immobile-duration', at: 3 },
{ clauseId: 'moving-periodic', at: 7 },
{ clauseId: 'moving-periodic', at: 9 }
]);
expect(result.finalState).toEqual([
expect.objectContaining({ pool: 'settled', value: 1 }),
expect.objectContaining({ pool: 'tempo', value: 2 })
]);
});
it('emits outcomes in the declared event and state-transition phases', () => {
const program: MechanicProgram = {
id: 'phases',
name: 'Outcome Phases',
pools: [{ key: 'active', label: 'Active', scope: 'agent', initial: 0, max: 1 }],
clauses: [
{
id: 'activate',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'active', value: 1 }],
outcomes: [
{
id: 'triggering-hit',
label: 'Triggering Hit',
phase: 'on_event',
kind: 'marker',
basis: 'flat',
scope: 'equipped_weapon',
value: 1
},
{
id: 'entered',
label: 'Entered',
phase: 'on_enter',
kind: 'marker',
basis: 'flat',
scope: 'self',
statePool: 'active',
value: 1
},
{
id: 'active-modifier',
label: 'Active Modifier',
phase: 'while_active',
kind: 'attribute_modifier',
basis: 'flat',
scope: 'equipped_weapon',
attributeId: 'weapon-damage',
statePool: 'active',
value: 10
}
]
},
{
id: 'deactivate',
when: 'firing_stopped',
reducer: { kind: 'none' },
mutations: [{ kind: 'clear', pool: 'active' }],
outcomes: [{
id: 'exited',
label: 'Exited',
phase: 'on_exit',
kind: 'marker',
basis: 'flat',
scope: 'self',
statePool: 'active',
value: 1
}]
}
]
};
const result = evaluateMechanicProgram(program, [
{ kind: 'weapon_hit', at: 1, targetId: 'enemy-a' },
{ kind: 'firing_stopped', at: 2 }
], custom());
expect(result.outcomes.map((outcome) => `${outcome.outcomeId}:${outcome.phase}`)).toEqual([
'triggering-hit:on_event',
'entered:on_enter',
'active-modifier:while_active',
'exited:on_exit'
]);
});
it('emits on-exit outcomes at pool expiry and applies their resource side effects', () => {
const program: MechanicProgram = {
id: 'expiry-exit',
name: 'Expiry Exit',
pools: [{
key: 'active',
label: 'Active',
scope: 'agent',
initial: 0,
max: 1,
expiresAfterSeconds: 2
}],
clauses: [{
id: 'activate',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [{ kind: 'set', pool: 'active', value: 1 }],
outcomes: [{
id: 'expiry-health-cost',
label: 'Expiry Health Cost',
phase: 'on_exit',
kind: 'resource_cost',
scope: 'self',
resource: 'health',
basis: 'max_health_percent',
statePool: 'active',
value: 10
}]
}]
};
const result = evaluateMechanicProgram(
program,
[{ kind: 'weapon_hit', at: 1, targetId: 'enemy-a' }],
custom(10),
{ maxHealth: 100, currentHealth: 100 }
);
expect(result.outcomes).toMatchObject([{
outcomeId: 'expiry-health-cost',
phase: 'on_exit',
at: 3,
appliedValue: 10,
combatBefore: { currentHealth: 100 },
combatAfter: { currentHealth: 90 }
}]);
expect(result.finalCombat?.currentHealth).toBe(90);
expect(result.finalState).toContainEqual(expect.objectContaining({
pool: 'active',
value: 0,
expiresAt: null
}));
});
it('uses a custom percentage as a weighted estimate and resolves 100% in maximum mode', () => {
const program: MechanicProgram = {
id: 'full-health-hit',
name: 'Full Health Hit',
pools: [{
key: 'estimated-procs',
label: 'Estimated Procs',
scope: 'agent',
initial: 0
}],
scenarioInputs: [{
key: 'fullHealthHitPercent',
label: 'Hits received at full Health',
kind: 'slider',
defaultValue: 25,
bestCaseValue: 100,
min: 0,
max: 100
}],
clauses: [{
id: 'estimate-full-health-hits',
when: 'agent_hit',
reducer: { kind: 'none' },
weight: {
op: 'divide',
left: { ref: { source: 'scenario', key: 'fullHealthHitPercent' } },
right: 100
},
mutations: [{ kind: 'add', pool: 'estimated-procs', value: 1 }]
}]
};
const events: MechanicEvent[] = [0, 5, 10, 15].map((at) => ({ kind: 'agent_hit', at }));
const estimate = evaluateMechanicProgram(program, events, custom(20, { fullHealthHitPercent: 25 }));
expect(estimate.estimatedActivationCount).toBe(1);
expect(estimate.emissions.every((emission) => emission.weight === 0.25)).toBe(true);
expect(estimate.emissions.map((emission) => emission.mutations[0].after)).toEqual([
0.25,
0.5,
0.75,
1
]);
expect(estimate.finalState[0].value).toBe(1);
const maximum = evaluateMechanicProgram(program, events, { mode: 'maximum', encounterSeconds: 20 });
expect(maximum.estimatedActivationCount).toBe(4);
expect(maximum.scenarioInputs.fullHealthHitPercent).toBe(100);
expect(maximum.finalState[0].value).toBe(4);
});
it('derives proximity stacks directly from a builder-controlled snapshot', () => {
const program: MechanicProgram = {
id: 'nearby-allies',
name: 'Nearby Allies',
pools: [{
key: 'nearby-ally-stacks',
label: 'Nearby Ally Stacks',
scope: 'agent',
initial: 0,
min: 0,
max: 3,
bestCaseValue: 3
}],
clauses: [{
id: 'derive-nearby-allies',
when: 'proximity_snapshot',
reducer: {
kind: 'derived_scalar',
value: { ref: { source: 'event', path: 'values.alliesWithinRadius' } }
},
mutations: [{
kind: 'derive',
pool: 'nearby-ally-stacks',
value: { ref: { source: 'reducer' } }
}]
}]
};
const result = evaluateMechanicProgram(program, [{
kind: 'proximity_snapshot',
at: 0,
values: { alliesWithinRadius: 2 }
}], custom());
expect(result.finalState[0].value).toBe(2);
const maximum = evaluateMechanicProgram(program, [], { mode: 'maximum', encounterSeconds: 30 });
expect(maximum.finalState[0].value).toBe(3);
});
it('shares one movement charge pool across generation and consumption clauses', () => {
const program: MechanicProgram = {
id: 'mobile-charge',
name: 'Mobile Charge',
pools: [{
key: 'mobile-charge',
label: 'Mobile Charge',
scope: 'agent',
initial: 0,
min: 0,
max: 20
}],
clauses: [
{
id: 'gain-moving',
when: 'movement_snapshot',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.state' },
operator: 'eq',
right: 'moving'
},
reducer: { kind: 'none' },
mutations: [{ kind: 'add', pool: 'mobile-charge', value: 1 }]
},
{
id: 'gain-sprinting',
when: 'movement_snapshot',
if: {
kind: 'compare',
left: { source: 'event', path: 'values.state' },
operator: 'eq',
right: 'sprinting'
},
reducer: { kind: 'none' },
mutations: [{ kind: 'add', pool: 'mobile-charge', value: 2 }]
},
{
id: 'spend-on-shot',
when: 'shot_fired',
if: {
kind: 'compare',
left: { source: 'pool', pool: 'mobile-charge' },
operator: 'gt',
right: 0
},
reducer: { kind: 'none' },
mutations: [{ kind: 'consume', pool: 'mobile-charge', value: 1 }]
},
{
id: 'spend-on-skill',
when: 'skill_deployed',
reducer: { kind: 'none' },
mutations: [{ kind: 'consume', pool: 'mobile-charge', value: 10 }]
}
]
};
const result = evaluateMechanicProgram(program, [
{ kind: 'movement_snapshot', at: 0, values: { state: 'moving' } },
{ kind: 'movement_snapshot', at: 1, values: { state: 'sprinting' } },
{ kind: 'movement_snapshot', at: 2, values: { state: 'sprinting' } },
{ kind: 'shot_fired', at: 3 },
{ kind: 'skill_deployed', at: 4 }
], custom());
expect(result.finalState[0].value).toBe(0);
expect(result.emissions.map((emission) => emission.clauseId)).toEqual([
'gain-moving',
'gain-sprinting',
'gain-sprinting',
'spend-on-shot',
'spend-on-skill'
]);
});
it('transfers a target-scoped mark to the target named by the death event', () => {
const program: MechanicProgram = {
id: 'transferring-mark',
name: 'Transferring Mark',
pools: [{
key: 'mark',
label: 'Mark',
scope: 'target',
initial: 0,
min: 0,
max: 1
}],
clauses: [
{
id: 'apply-mark',
when: 'weapon_hit',
reducer: { kind: 'none' },
mutations: [{
kind: 'set',
pool: 'mark',
entity: { kind: 'event_target' },
value: 1
}]
},
{
id: 'transfer-mark',
when: 'enemy_killed',
if: {
kind: 'compare',
left: { source: 'pool', pool: 'mark', entity: { kind: 'event_target' } },
operator: 'gt',
right: 0
},
reducer: { kind: 'none' },
mutations: [{
kind: 'transfer',
pool: 'mark',
from: { kind: 'event_target' },
to: { kind: 'event_field', path: 'values.transferTargetId' },
value: 'all'
}]
}
]
};
const result = evaluateMechanicProgram(program, [
{ kind: 'weapon_hit', at: 1, targetId: 'enemy-a' },
{
kind: 'enemy_killed',
at: 2,
targetId: 'enemy-a',
values: { transferTargetId: 'enemy-b' }
}
], custom());
expect(result.finalState).toEqual([
expect.objectContaining({ entityId: 'enemy-a', value: 0 }),
expect.objectContaining({ entityId: 'enemy-b', value: 1 })
]);
});
it('models scaled OS Protocol restoration, an unscaled Health cost, and post-cost bonus damage', () => {
const upgradeScale = {
op: 'add' as const,
left: 1,
right: {
op: 'divide' as const,
left: { ref: { source: 'equipment' as const, path: 'protocolTalentUpgrades' } },
right: 5
}
};
const program: MechanicProgram = {
id: 'os-protocol-health-engine',
name: 'OS Protocol Health Engine',
scenarioInputs: [{
key: 'restorationIntervalSeconds',
label: 'Restoration interval',
description: 'The game cadence is not yet verified; Custom Scenario must supply it.',
kind: 'number',
readiness: 'requires_assumption',
min: 0.01
}],
pools: [{
key: 'nearby-enemies',
label: 'Nearby Enemies',
scope: 'agent',
initial: 0,
min: 0,
max: 20
}],
clauses: [
{
id: 'sample-nearby-enemies',
when: 'proximity_snapshot',
reducer: {
kind: 'derived_scalar',
value: { ref: { source: 'event', path: 'values.enemiesWithinRadius' } }
},
mutations: [{
kind: 'derive',
pool: 'nearby-enemies',
value: { ref: { source: 'reducer' } }
}]
},
{
id: 'restore-nearby-enemies',
when: 'tick',
reducer: {
kind: 'periodic',
intervalSeconds: {
ref: {
source: 'scenario',
key: 'restorationIntervalSeconds'
}
}
},
mutations: [{ kind: 'none' }],
outcomes: [{
id: 'nearby-restoration',
label: 'Nearby Enemy Restoration',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
resource: 'health',
basis: 'max_health_percent',
value: {
op: 'multiply',
left: {
op: 'clamp',
value: {
op: 'multiply',
left: { ref: { source: 'pool', pool: 'nearby-enemies' } },
right: 0.5
},
min: 0,
max: 2.5
},
right: upgradeScale
}
}]
},
{
id: 'shot-health-exchange',
when: 'shot_fired',
reducer: { kind: 'none' },
mutations: [{ kind: 'none' }],
outcomes: [
{
id: 'shot-health-cost',
label: 'Shot Health Cost',
phase: 'on_event',
kind: 'resource_cost',
scope: 'self',
resource: 'health',
basis: 'max_health_percent',
value: 2
},
{
id: 'shot-health-damage',
label: 'Current Health Bonus Damage',
phase: 'on_event',
kind: 'direct_damage',
scope: 'target',
basis: 'current_health_percent',
combatTiming: 'current',
value: {
op: 'multiply',
left: 0.6,
right: upgradeScale
}
}
]
}
]
};
const events: MechanicEvent[] = [
{ kind: 'tick', at: 0 },
{ kind: 'shot_fired', at: 1 },
{ kind: 'proximity_snapshot', at: 2, values: { enemiesWithinRadius: 9 } },
{ kind: 'tick', at: 3 }
];
const result = evaluateMechanicProgram(
program,
events,
custom(10, { restorationIntervalSeconds: 3 }),
{ maxHealth: 10_000, currentHealth: 10_000, weaponBaseDamage: 500 },
{ protocolTalentUpgrades: 5 }
);
expect(result.status).toBe('OK');
const cost = result.outcomes.find((outcome) => outcome.outcomeId === 'shot-health-cost');
const damage = result.outcomes.find((outcome) => outcome.outcomeId === 'shot-health-damage');
const restoration = result.outcomes.find((outcome) => outcome.outcomeId === 'nearby-restoration');
expect(cost).toMatchObject({
value: 2,
resolvedValue: 200,
appliedValue: 200
});
expect(damage).toMatchObject({
value: 1.2,
resolvedValue: 117.6,
appliedValue: 117.6
});
expect(restoration).toMatchObject({
value: 5,
resolvedValue: 500,
appliedValue: 200
});
expect(result.finalCombat?.currentHealth).toBe(10_000);
expect(result.equipment?.protocolTalentUpgrades).toBe(5);
const preCostProgram = structuredClone(program);
const preCostDamage = preCostProgram.clauses[2].outcomes?.find(
(outcome) => outcome.id === 'shot-health-damage'
);
if (preCostDamage) preCostDamage.combatTiming = 'pre_event';
const preCost = evaluateMechanicProgram(
preCostProgram,
[{ kind: 'shot_fired', at: 1 }],
custom(10, { restorationIntervalSeconds: 3 }),
{ maxHealth: 10_000, currentHealth: 10_000 },
{ protocolTalentUpgrades: 5 }
);
expect(preCost.outcomes.find((outcome) => outcome.outcomeId === 'shot-health-damage'))
.toMatchObject({ resolvedValue: 120 });
const unresolved = evaluateMechanicProgram(
program,
[],
{ mode: 'maximum', encounterSeconds: 10 },
{ maxHealth: 10_000, currentHealth: 10_000 },
{ protocolTalentUpgrades: 5 }
);
expect(unresolved.readiness).toEqual({
status: 'REQUIRES_ASSUMPTIONS',
missingInputs: ['restorationIntervalSeconds']
});
});
it('counts distinct values in a rolling window, resets consecutive runs, and applies named resources', () => {
const program: MechanicProgram = {
id: 'runtime-gaps',
name: 'Runtime gaps',
clauses: [
{
id: 'distinct-targets',
recipeKey: 'distinct-enemies-within-window',
when: 'weapon_hit',
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
reducer: {
kind: 'distinct_count',
threshold: 3,
windowSeconds: 2,
distinctBy: { source: 'event', path: 'targetId' }
},
mutations: [{ kind: 'none' }],
outcomes: [{
id: 'distinct-marker',
label: 'Distinct target threshold',
phase: 'after_event',
kind: 'marker',
scope: 'all_skills',
basis: 'flat',
value: 1
}]
},
{
id: 'consecutive-targets',
when: 'shot_fired',
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
reducer: {
kind: 'consecutive_count',
threshold: 3,
groupBy: { source: 'event', path: 'targetId' }
},
mutations: [{ kind: 'none' }],
outcomes: [{
id: 'consecutive-marker',
label: 'Consecutive target threshold',
phase: 'after_event',
kind: 'marker',
scope: 'assigned_ability',
basis: 'flat',
value: 1
}]
},
{
id: 'resources',
when: 'tick',
reducer: { kind: 'none' },
mutations: [{ kind: 'none' }],
outcomes: [
{
id: 'health-cost', label: 'Health cost', phase: 'on_event', kind: 'resource_cost',
scope: 'self', resource: 'health', basis: 'flat', value: 10
},
{
id: 'armor-restore', label: 'Armor restore', phase: 'on_event', kind: 'restoration',
scope: 'self_and_allies', resource: 'armor', basis: 'flat', value: 20
},
{
id: 'ammo-restore', label: 'Ammo restore', phase: 'on_event', kind: 'restoration',
scope: 'equipped_weapon', resource: 'ammo', basis: 'flat', value: 5
}
]
}
]
};
const result = evaluateMechanicProgram(program, [
{ kind: 'weapon_hit', at: 0, targetId: 'alpha' },
{ kind: 'weapon_hit', at: 0.2, targetId: 'alpha' },
{ kind: 'weapon_hit', at: 0.4, targetId: 'bravo' },
{ kind: 'weapon_hit', at: 0.6, targetId: 'charlie' },
{ kind: 'shot_fired', at: 1, targetId: 'alpha' },
{ kind: 'shot_fired', at: 1.1, targetId: 'alpha' },
{ kind: 'shot_fired', at: 1.2 },
{ kind: 'shot_fired', at: 1.3, targetId: 'bravo' },
{ kind: 'shot_fired', at: 1.4, targetId: 'bravo' },
{ kind: 'shot_fired', at: 1.5, targetId: 'bravo' },
{ kind: 'tick', at: 2 }
], custom(), { maxHealth: 100, currentHealth: 50, maxArmor: 50, currentArmor: 30, maxAmmo: 10, currentAmmo: 7 });
expect(result.status).toBe('OK');
expect(result.emissions.filter(({ clauseId }) => clauseId === 'distinct-targets')).toMatchObject([{ at: 0.6, reducerValue: 3 }]);
expect(result.emissions.filter(({ clauseId }) => clauseId === 'consecutive-targets')).toMatchObject([{ at: 1.5, reducerValue: 3 }]);
expect(result.outcomes.filter(({ clauseId }) => clauseId === 'resources')).toMatchObject([
{ outcomeId: 'health-cost', resource: 'health', appliedValue: 10 },
{ outcomeId: 'armor-restore', resource: 'armor', appliedValue: 20 },
{ outcomeId: 'ammo-restore', resource: 'ammo', appliedValue: 3 }
]);
expect(result.finalCombat).toMatchObject({ currentHealth: 40, currentArmor: 50, currentAmmo: 10 });
expect(result.script).toContain('COUNT 3 DISTINCT EVENT.targetId WITHIN 2s');
expect(result.script).toContain('COUNT 3 CONSECUTIVE MATCHES PER EVENT.targetId');
expect(result.script).toContain('[RECIPE distinct-enemies-within-window]');
});
});
describe('mechanic validation and pseudo-script', () => {
it('enforces clause cooldowns without counting events received during the cooldown', () => {
const program: MechanicProgram = {
id: 'cooldown',
name: 'Cooldown',
clauses: [{
id: 'shot-proc',
when: 'shot_fired',
reducer: { kind: 'none' },
cooldownSeconds: 2,
mutations: [{ kind: 'none' }],
outcomes: [{
id: 'proc',
label: 'Proc',
phase: 'after_event',
kind: 'marker',
scope: 'self',
basis: 'flat',
value: 1
}]
}]
};
const result = evaluateMechanicProgram(program, [
{ kind: 'shot_fired', at: 0 },
{ kind: 'shot_fired', at: 1 },
{ kind: 'shot_fired', at: 2 },
{ kind: 'shot_fired', at: 3 }
], custom(4));
expect(result.emissions.map(({ at }) => at)).toEqual([0, 2]);
expect(result.outcomes.map(({ at }) => at)).toEqual([0, 2]);
expect(result.script).toContain('COOLDOWN 2s');
});
it('rejects invalid references, reducers, and target transfer scopes', () => {
const invalid: MechanicProgram = {
id: 'invalid',
name: 'Invalid',
pools: [{ key: 'agent-state', label: 'Agent State', scope: 'agent', initial: 0 }],
clauses: [{
id: 'bad',
recipeKey: '',
when: 'shot_fired',
reducer: { kind: 'modulo', every: 0 },
mutations: [{
kind: 'transfer',
pool: 'agent-state',
from: { kind: 'agent' },
to: { kind: 'event_target' }
}],
outcomes: [{
id: 'missing-attribute',
label: 'Missing',
phase: 'after_event',
kind: 'attribute_modifier',
basis: 'flat',
scope: 'self',
value: { ref: { source: 'scenario', key: 'missing-input' } }
}, {
id: 'missing-resource',
label: 'Missing resource',
phase: 'after_event',
kind: 'restoration',
scope: 'self',
basis: 'flat',
value: 1
}]
}]
};
const result = validateMechanicProgram(invalid);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors).toContain('Clause 1 modulo interval must be a positive integer.');
expect(result.errors).toContain('Clause 1 recipe key cannot be empty.');
expect(result.errors).toContain('Clause 1 mutation 1 can only transfer a target-scoped pool.');
expect(result.errors).toContain('Clause 1 outcome 1 requires an Attribute ID.');
expect(result.errors).toContain('Clause 1 outcome 1 value references unknown scenario input "missing-input".');
expect(result.errors).toContain('Clause 1 outcome 2 requires a resource.');
}
});
it('formats nested conditions and state operations deterministically', () => {
const program: MechanicProgram = {
id: 'format',
name: 'Prepared',
pools: [{ key: 'prepared', label: 'Prepared', scope: 'agent', initial: 0, max: 10 }],
clauses: [{
id: 'gain',
label: 'Gain Prepared',
when: 'agent_hit',
if: {
kind: 'all',
predicates: [
{
kind: 'compare',
left: { source: 'event', path: 'values.healthPercentBefore' },
operator: 'eq',
right: 100
},
{ kind: 'not', predicate: { kind: 'exists', value: { source: 'event', path: 'values.blocked' } } }
]
},
reducer: { kind: 'none' },
mutations: [{ kind: 'add', pool: 'prepared', value: 1 }]
}]
};
expect(formatMechanicProgram(program)).toBe([
'MECHANIC PREPARED',
'STATE prepared [AGENT] 0..10',
'',
'WHEN AGENT HIT // Gain Prepared',
'IF (EVENT.values.healthPercentBefore == 100) AND (NOT (EXISTS EVENT.values.blocked))',
'ADD 1 TO prepared'
].join('\n'));
});
});