feat: add source-owned combat mechanic programs
This commit is contained in:
@@ -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'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user