1225 lines
34 KiB
TypeScript
1225 lines
34 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
MECHANIC_EVENT_REGISTRY,
|
|
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('advertises the authoritative weapon-hit target and headshot fields', () => {
|
|
expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({
|
|
description: expect.stringContaining('headshot boolean'),
|
|
fields: [
|
|
{ path: 'targetId', label: 'Target ID', type: 'string' },
|
|
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' }
|
|
]
|
|
});
|
|
});
|
|
|
|
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('caps and refreshes target-scoped stacks independently', () => {
|
|
const program: MechanicProgram = {
|
|
id: 'target-trauma',
|
|
name: 'Target Trauma',
|
|
pools: [{
|
|
key: 'trauma',
|
|
label: 'Trauma',
|
|
scope: 'target',
|
|
initial: 0,
|
|
min: 0,
|
|
max: 10,
|
|
expiresAfterSeconds: 1
|
|
}],
|
|
clauses: [
|
|
{
|
|
id: 'body-stack',
|
|
when: 'weapon_hit',
|
|
if: {
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.headshot' },
|
|
operator: 'eq',
|
|
right: false
|
|
},
|
|
reducer: { kind: 'none' },
|
|
mutations: [{
|
|
kind: 'add',
|
|
pool: 'trauma',
|
|
entity: { kind: 'event_target' },
|
|
value: 1
|
|
}]
|
|
},
|
|
{
|
|
id: 'head-stack',
|
|
when: 'weapon_hit',
|
|
if: {
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.headshot' },
|
|
operator: 'eq',
|
|
right: true
|
|
},
|
|
reducer: { kind: 'none' },
|
|
mutations: [{
|
|
kind: 'add',
|
|
pool: 'trauma',
|
|
entity: { kind: 'event_target' },
|
|
value: 2
|
|
}]
|
|
}
|
|
]
|
|
};
|
|
const result = evaluateMechanicProgram(program, [
|
|
{ kind: 'weapon_hit', at: 0, targetId: 'enemy-a', values: { headshot: false } },
|
|
{ kind: 'weapon_hit', at: 0.25, targetId: 'enemy-b', values: { headshot: false } },
|
|
{ kind: 'weapon_hit', at: 0.5, targetId: 'enemy-a', values: { headshot: true } },
|
|
...Array.from({ length: 5 }, (_, index): MechanicEvent => ({
|
|
kind: 'weapon_hit',
|
|
at: 0.55 + index * 0.05,
|
|
targetId: 'enemy-a',
|
|
values: { headshot: true }
|
|
}))
|
|
], custom(0.9));
|
|
expect(result.status).toBe('OK');
|
|
expect(result.finalState).toEqual([
|
|
expect.objectContaining({
|
|
pool: 'trauma',
|
|
entityId: 'enemy-a',
|
|
value: 10,
|
|
expiresAt: 1.75
|
|
}),
|
|
expect.objectContaining({
|
|
pool: 'trauma',
|
|
entityId: 'enemy-b',
|
|
value: 1,
|
|
expiresAt: 1.25
|
|
})
|
|
]);
|
|
});
|
|
|
|
it('uses the prior target stack count for live-Attribute damage on pool exit', () => {
|
|
const skillDamageId = 'skill-damage';
|
|
const program: MechanicProgram = {
|
|
id: 'precise-trauma',
|
|
name: 'Precise Trauma',
|
|
pools: [{
|
|
key: 'trauma',
|
|
label: 'Trauma',
|
|
scope: 'target',
|
|
initial: 0,
|
|
min: 0,
|
|
max: 10,
|
|
expiresAfterSeconds: 1
|
|
}],
|
|
clauses: [{
|
|
id: 'apply-trauma',
|
|
when: 'weapon_hit',
|
|
reducer: { kind: 'none' },
|
|
mutations: [{
|
|
kind: 'add',
|
|
pool: 'trauma',
|
|
entity: { kind: 'event_target' },
|
|
value: 2
|
|
}],
|
|
outcomes: [{
|
|
id: 'trauma-damage',
|
|
label: 'Trauma Damage',
|
|
phase: 'on_exit',
|
|
kind: 'direct_damage',
|
|
scope: 'target',
|
|
basis: 'flat',
|
|
value: { ref: { source: 'attribute', attributeId: skillDamageId } },
|
|
statePool: 'trauma',
|
|
perStack: true,
|
|
minStacks: 2
|
|
}]
|
|
}]
|
|
};
|
|
const result = evaluateMechanicProgram(
|
|
program,
|
|
[{ kind: 'weapon_hit', at: 0, targetId: 'enemy-a', values: { headshot: true } }],
|
|
custom(2),
|
|
{
|
|
maxHealth: 100,
|
|
currentHealth: 100,
|
|
attributes: { [skillDamageId]: 75 }
|
|
}
|
|
);
|
|
expect(result.status).toBe('OK');
|
|
expect(result.outcomes).toMatchObject([{
|
|
outcomeId: 'trauma-damage',
|
|
targetId: 'enemy-a',
|
|
at: 1,
|
|
phase: 'on_exit',
|
|
value: 150,
|
|
resolvedValue: 150,
|
|
appliedValue: 150,
|
|
stateValue: 2
|
|
}]);
|
|
expect(result.finalState).toContainEqual(expect.objectContaining({
|
|
pool: 'trauma',
|
|
entityId: 'enemy-a',
|
|
value: 0,
|
|
expiresAt: null
|
|
}));
|
|
});
|
|
|
|
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]');
|
|
});
|
|
it('resolves live Attribute percentages and delivers a restoration total continuously', () => {
|
|
const firepowerMultiplierId = '82a95611-283b-4fd8-a6ed-3567a316f36e';
|
|
const program: MechanicProgram = {
|
|
id: 'predatory-aura',
|
|
name: 'Predatory Aura',
|
|
clauses: [{
|
|
id: 'restore-on-kill',
|
|
when: 'enemy_killed',
|
|
reducer: { kind: 'none' },
|
|
mutations: [],
|
|
outcomes: [{
|
|
id: 'health-restoration',
|
|
label: 'Health restoration',
|
|
phase: 'after_event',
|
|
kind: 'restoration',
|
|
scope: 'self',
|
|
resource: 'health',
|
|
basis: 'flat',
|
|
delivery: 'continuous',
|
|
durationSeconds: 3,
|
|
value: {
|
|
op: 'multiply',
|
|
left: 240,
|
|
right: {
|
|
op: 'divide',
|
|
left: { ref: { source: 'attribute', attributeId: firepowerMultiplierId } },
|
|
right: 100
|
|
}
|
|
}
|
|
}]
|
|
}]
|
|
};
|
|
const result = evaluateMechanicProgram(
|
|
program,
|
|
[{ kind: 'enemy_killed', at: 0 }],
|
|
custom(3),
|
|
{
|
|
maxHealth: 1_000,
|
|
currentHealth: 500,
|
|
attributes: { [firepowerMultiplierId]: 100 }
|
|
}
|
|
);
|
|
expect(result.status).toBe('OK');
|
|
expect(result.outcomes).toHaveLength(1);
|
|
expect(result.outcomes[0]).toMatchObject({
|
|
at: 3,
|
|
delivery: 'continuous',
|
|
deliveryStart: 0,
|
|
deliveryEnd: 3,
|
|
totalResolvedValue: 240,
|
|
resolvedValue: 240,
|
|
appliedValue: 240
|
|
});
|
|
expect(result.finalCombat?.currentHealth).toBe(740);
|
|
expect(result.script).toContain(
|
|
'ATTRIBUTE[82a95611-283b-4fd8-a6ed-3567a316f36e] / 100'
|
|
);
|
|
expect(result.script).toContain('DELIVER TOTAL CONTINUOUSLY OVER 3s');
|
|
|
|
const missingAttribute = evaluateMechanicProgram(
|
|
program,
|
|
[{ kind: 'enemy_killed', at: 0 }],
|
|
custom(3),
|
|
{ maxHealth: 1_000, currentHealth: 500 }
|
|
);
|
|
expect(missingAttribute.status).toBe('INVALID');
|
|
expect(missingAttribute.errors).toContain(
|
|
`This mechanic requires authoritative Attribute ${firepowerMultiplierId}.`
|
|
);
|
|
});
|
|
|
|
it('integrates overlapping restoration streams at event boundaries without banking overheal', () => {
|
|
const program: MechanicProgram = {
|
|
id: 'continuous-restoration',
|
|
name: 'Continuous Restoration',
|
|
clauses: [{
|
|
id: 'restore',
|
|
when: 'enemy_killed',
|
|
reducer: { kind: 'none' },
|
|
mutations: [],
|
|
outcomes: [{
|
|
id: 'restore-health',
|
|
label: 'Restore Health',
|
|
phase: 'after_event',
|
|
kind: 'restoration',
|
|
scope: 'self',
|
|
resource: 'health',
|
|
basis: 'flat',
|
|
value: 240,
|
|
delivery: 'continuous',
|
|
durationSeconds: 3
|
|
}]
|
|
}, {
|
|
id: 'damage-between-streams',
|
|
when: 'agent_hit',
|
|
reducer: { kind: 'none' },
|
|
mutations: [],
|
|
outcomes: [{
|
|
id: 'health-cost',
|
|
label: 'Incoming damage proxy',
|
|
phase: 'on_event',
|
|
kind: 'resource_cost',
|
|
scope: 'self',
|
|
resource: 'health',
|
|
basis: 'flat',
|
|
value: 100
|
|
}]
|
|
}]
|
|
};
|
|
const result = evaluateMechanicProgram(
|
|
program,
|
|
[
|
|
{ kind: 'enemy_killed', at: 0 },
|
|
{ kind: 'agent_hit', at: 1 },
|
|
{ kind: 'enemy_killed', at: 1 }
|
|
],
|
|
custom(4),
|
|
{ maxHealth: 1_000, currentHealth: 500 }
|
|
);
|
|
const continuous = result.outcomes.filter((outcome) => outcome.delivery === 'continuous');
|
|
expect(continuous[0]).toMatchObject({ at: 1, resolvedValue: 80, appliedValue: 80 });
|
|
expect(continuous.reduce((total, outcome) => total + outcome.resolvedValue, 0)).toBeCloseTo(480);
|
|
expect(continuous.reduce((total, outcome) => total + outcome.appliedValue, 0)).toBeCloseTo(480);
|
|
expect(result.finalCombat?.currentHealth).toBe(880);
|
|
|
|
const capped = evaluateMechanicProgram(
|
|
program,
|
|
[{ kind: 'enemy_killed', at: 0 }],
|
|
custom(3),
|
|
{ maxHealth: 1_000, currentHealth: 950 }
|
|
);
|
|
expect(capped.outcomes.reduce((total, outcome) => total + outcome.appliedValue, 0)).toBe(50);
|
|
expect(capped.finalCombat?.currentHealth).toBe(1_000);
|
|
});
|
|
});
|
|
|
|
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('rejects invalid continuous-delivery combinations', () => {
|
|
const base = {
|
|
id: 'delivery-validation',
|
|
name: 'Delivery Validation',
|
|
clauses: [{
|
|
id: 'invalid-delivery',
|
|
when: 'enemy_killed' as const,
|
|
reducer: { kind: 'none' as const },
|
|
mutations: [],
|
|
outcomes: [{
|
|
id: 'damage-over-time',
|
|
label: 'Damage',
|
|
phase: 'after_event' as const,
|
|
kind: 'direct_damage' as const,
|
|
scope: 'target' as const,
|
|
basis: 'flat' as const,
|
|
value: 10,
|
|
delivery: 'continuous' as const,
|
|
durationSeconds: 3
|
|
}]
|
|
}]
|
|
};
|
|
const result = validateMechanicProgram(base);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.errors).toContain(
|
|
'Clause 1 outcome 1 can only use continuous delivery for restoration.'
|
|
);
|
|
}
|
|
});
|
|
|
|
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'));
|
|
});
|
|
});
|