3543 lines
98 KiB
TypeScript
3543 lines
98 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
MECHANIC_EVENT_REGISTRY,
|
|
evaluateMechanicProgram,
|
|
formatMechanicProgram,
|
|
validateMechanicProgram,
|
|
type MechanicEvent,
|
|
type MechanicEventKind,
|
|
type MechanicProgram,
|
|
type NumericExpression,
|
|
} from './mechanic.js';
|
|
|
|
const custom = (
|
|
encounterSeconds = 30,
|
|
inputs: Record<string, number | boolean> = {},
|
|
) => ({
|
|
mode: 'custom' as const,
|
|
encounterSeconds,
|
|
inputs,
|
|
});
|
|
|
|
const closeAndPersonalProgram = (
|
|
resolvedVariantValue: number,
|
|
): MechanicProgram => ({
|
|
id: 'close-and-personal-engineering',
|
|
name: 'Close and Personal Engineering',
|
|
pools: [
|
|
{
|
|
key: 'eligible-allies',
|
|
label: 'Eligible Allies',
|
|
scope: 'agent',
|
|
initial: 0,
|
|
min: 0,
|
|
max: 20,
|
|
},
|
|
],
|
|
clauses: [
|
|
{
|
|
id: 'sample-eligible-allies',
|
|
when: 'proximity_snapshot',
|
|
reducer: {
|
|
kind: 'derived_scalar',
|
|
value: {
|
|
ref: {
|
|
source: 'event',
|
|
path: 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
|
},
|
|
},
|
|
},
|
|
mutations: [
|
|
{
|
|
kind: 'derive',
|
|
pool: 'eligible-allies',
|
|
value: { ref: { source: 'reducer' } },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'resolve-engineering',
|
|
when: 'tick',
|
|
reducer: { kind: 'none' },
|
|
mutations: [{ kind: 'none' }],
|
|
outcomes: [
|
|
{
|
|
id: 'engineering',
|
|
label: 'Engineering',
|
|
phase: 'on_event',
|
|
kind: 'attribute_modifier',
|
|
scope: 'self',
|
|
basis: 'flat',
|
|
attributeId: 'engineering',
|
|
value: {
|
|
op: 'multiply',
|
|
left: {
|
|
op: 'multiply',
|
|
left: { ref: { source: 'pool', pool: 'eligible-allies' } },
|
|
right: resolvedVariantValue,
|
|
},
|
|
right: {
|
|
op: 'add',
|
|
left: 1,
|
|
right: {
|
|
op: 'divide',
|
|
left: {
|
|
ref: {
|
|
source: 'equipment',
|
|
path: 'protocolTalentUpgrades',
|
|
},
|
|
},
|
|
right: 5,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
describe('deterministic mechanic probabilities and assigned abilities', () => {
|
|
it('rolls outcome probability independently with a reproducible seed and preserves a refreshed window', () => {
|
|
const program: MechanicProgram = {
|
|
id: 'ammo-refill',
|
|
name: 'Ammo Refill',
|
|
pools: [
|
|
{
|
|
key: 'tactical-link-window',
|
|
label: 'Tactical Link window',
|
|
scope: 'agent',
|
|
initial: 0,
|
|
min: 0,
|
|
max: 1,
|
|
expiresAfterSeconds: 6,
|
|
},
|
|
],
|
|
clauses: [
|
|
{
|
|
id: 'start-window',
|
|
when: 'signature_ability_deployed',
|
|
reducer: { kind: 'none' },
|
|
mutations: [{ kind: 'set', pool: 'tactical-link-window', value: 1 }],
|
|
},
|
|
{
|
|
id: 'refresh-window',
|
|
when: 'enemy_killed',
|
|
reducer: { kind: 'none' },
|
|
mutations: [{ kind: 'set', pool: 'tactical-link-window', value: 1 }],
|
|
},
|
|
{
|
|
id: 'recover-ammo',
|
|
when: 'shot_fired',
|
|
if: {
|
|
kind: 'compare',
|
|
left: { source: 'pool', pool: 'tactical-link-window' },
|
|
operator: 'eq',
|
|
right: 1,
|
|
},
|
|
reducer: { kind: 'none' },
|
|
mutations: [],
|
|
outcomes: [
|
|
{
|
|
id: 'recover-one-ammo',
|
|
label: 'Recover one ammo',
|
|
phase: 'after_event',
|
|
kind: 'restoration',
|
|
scope: 'self',
|
|
basis: 'flat',
|
|
resource: 'ammo',
|
|
value: 1,
|
|
probabilityPercent: 29,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const events = [
|
|
{
|
|
kind: 'signature_ability_deployed' as const,
|
|
at: 0,
|
|
values: { assignedAbilityDurationSeconds: 6 },
|
|
},
|
|
{ kind: 'shot_fired' as const, at: 1 },
|
|
{ kind: 'shot_fired' as const, at: 2 },
|
|
{ kind: 'enemy_killed' as const, at: 5 },
|
|
{ kind: 'shot_fired' as const, at: 6 },
|
|
{ kind: 'shot_fired' as const, at: 11 },
|
|
];
|
|
const first = evaluateMechanicProgram(
|
|
program,
|
|
events,
|
|
{ mode: 'custom', encounterSeconds: 12, randomSeed: 1 },
|
|
{ maxHealth: 100, currentHealth: 100, maxAmmo: 20, currentAmmo: 5 },
|
|
);
|
|
const second = evaluateMechanicProgram(
|
|
program,
|
|
events,
|
|
{ mode: 'custom', encounterSeconds: 12, randomSeed: 1 },
|
|
{ maxHealth: 100, currentHealth: 100, maxAmmo: 20, currentAmmo: 5 },
|
|
);
|
|
|
|
expect(first.status).toBe('OK');
|
|
expect(first.randomSeed).toBe(1);
|
|
expect(first.outcomes.map((outcome) => outcome.at)).toEqual([2]);
|
|
expect(first.outcomes[0]).toMatchObject({
|
|
probabilityPercent: 29,
|
|
probabilityRoll: expect.any(Number),
|
|
appliedValue: 1,
|
|
});
|
|
expect(first.finalCombat?.currentAmmo).toBe(6);
|
|
expect(second.outcomes).toEqual(first.outcomes);
|
|
});
|
|
|
|
it('gates a proc before mutations, extends a live assigned ability, and starts cooldown on the eighth stack', () => {
|
|
const program: MechanicProgram = {
|
|
id: 'tactical-loop',
|
|
name: 'Tactical Loop',
|
|
pools: [
|
|
{
|
|
key: 'extension-stacks',
|
|
label: 'Extension stacks',
|
|
scope: 'agent',
|
|
initial: 7,
|
|
min: 0,
|
|
max: 8,
|
|
},
|
|
{
|
|
key: 'extension-cooldown',
|
|
label: 'Extension cooldown',
|
|
scope: 'agent',
|
|
initial: 0,
|
|
min: 0,
|
|
max: 1,
|
|
expiresAfterSeconds: 15,
|
|
},
|
|
],
|
|
clauses: [
|
|
{
|
|
id: 'extend-tactical-link',
|
|
when: 'skill_hit',
|
|
if: {
|
|
kind: 'compare',
|
|
left: { source: 'pool', pool: 'extension-cooldown' },
|
|
operator: 'eq',
|
|
right: 0,
|
|
},
|
|
requiresAssignedAbilityActive: true,
|
|
procChancePercent: 10,
|
|
reducer: { kind: 'none' },
|
|
mutations: [
|
|
{ kind: 'add', pool: 'extension-stacks', value: 1 },
|
|
{
|
|
kind: 'extend_duration',
|
|
target: 'assigned_ability',
|
|
seconds: 0.3,
|
|
},
|
|
{
|
|
kind: 'start_cooldown_on_max',
|
|
stackPool: 'extension-stacks',
|
|
cooldownPool: 'extension-cooldown',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const success = evaluateMechanicProgram(
|
|
program,
|
|
[
|
|
{
|
|
kind: 'signature_ability_deployed',
|
|
at: 0,
|
|
values: { assignedAbilityDurationSeconds: 6 },
|
|
},
|
|
{ kind: 'skill_hit', at: 5.9 },
|
|
{ kind: 'skill_hit', at: 6.1 },
|
|
],
|
|
{ mode: 'custom', encounterSeconds: 7, randomSeed: 7 },
|
|
);
|
|
const failed = evaluateMechanicProgram(
|
|
program,
|
|
[
|
|
{
|
|
kind: 'signature_ability_deployed',
|
|
at: 0,
|
|
values: { assignedAbilityDurationSeconds: 6 },
|
|
},
|
|
{ kind: 'skill_hit', at: 5.9 },
|
|
],
|
|
{ mode: 'custom', encounterSeconds: 7, randomSeed: 1 },
|
|
);
|
|
|
|
expect(success.status).toBe('OK');
|
|
expect(success.emissions).toHaveLength(1);
|
|
const extension = success.emissions[0].mutations.find(
|
|
(mutation) => mutation.kind === 'extend_duration',
|
|
);
|
|
expect(extension).toMatchObject({ kind: 'extend_duration' });
|
|
expect(extension?.before).toBeCloseTo(0.1);
|
|
expect(extension?.after).toBeCloseTo(0.4);
|
|
expect(success.emissions[0].mutations).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
kind: 'start_cooldown_on_max',
|
|
pool: 'extension-cooldown',
|
|
after: 1,
|
|
}),
|
|
]),
|
|
);
|
|
expect(success.finalState).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
pool: 'extension-stacks',
|
|
value: 8,
|
|
}),
|
|
expect.objectContaining({
|
|
pool: 'extension-cooldown',
|
|
value: 1,
|
|
expiresAt: 20.9,
|
|
}),
|
|
]),
|
|
);
|
|
expect(failed.emissions).toEqual([]);
|
|
});
|
|
|
|
it('does not extend an assigned ability at its exact expiry and rejects invalid chance data', () => {
|
|
const boundaryProgram: MechanicProgram = {
|
|
id: 'exact-expiry',
|
|
name: 'Exact expiry',
|
|
clauses: [
|
|
{
|
|
id: 'extend',
|
|
when: 'skill_hit',
|
|
requiresAssignedAbilityActive: true,
|
|
procChancePercent: 100,
|
|
reducer: { kind: 'none' },
|
|
mutations: [
|
|
{
|
|
kind: 'extend_duration',
|
|
target: 'assigned_ability',
|
|
seconds: 0.3,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
expect(
|
|
evaluateMechanicProgram(
|
|
boundaryProgram,
|
|
[
|
|
{
|
|
kind: 'signature_ability_deployed',
|
|
at: 0,
|
|
values: { assignedAbilityDurationSeconds: 1 },
|
|
},
|
|
{ kind: 'skill_hit', at: 1 },
|
|
],
|
|
{ mode: 'custom', encounterSeconds: 2, randomSeed: 7 },
|
|
).emissions,
|
|
).toEqual([]);
|
|
|
|
const invalid = structuredClone(boundaryProgram);
|
|
invalid.clauses[0].procChancePercent = 101;
|
|
expect(validateMechanicProgram(invalid)).toMatchObject({
|
|
ok: false,
|
|
errors: [
|
|
expect.stringContaining('proc chance must be from 0 through 100'),
|
|
],
|
|
});
|
|
const invalidOutcome = structuredClone(boundaryProgram);
|
|
invalidOutcome.clauses[0].outcomes = [
|
|
{
|
|
id: 'bad-chance',
|
|
label: 'Bad chance',
|
|
phase: 'after_event',
|
|
kind: 'marker',
|
|
scope: 'self',
|
|
basis: 'flat',
|
|
value: 1,
|
|
probabilityPercent: -1,
|
|
},
|
|
];
|
|
expect(validateMechanicProgram(invalidOutcome)).toMatchObject({
|
|
ok: false,
|
|
errors: [
|
|
expect.stringContaining('probability must be from 0 through 100'),
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('structured mechanic runtime', () => {
|
|
it('advertises the authoritative weapon-hit target and headshot fields', () => {
|
|
expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({
|
|
description: expect.stringContaining('previous target'),
|
|
fields: [
|
|
{ path: 'targetId', label: 'Target ID', type: 'string' },
|
|
{
|
|
path: 'values.previousTargetId',
|
|
label: 'Previous target ID',
|
|
type: 'string',
|
|
},
|
|
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' },
|
|
{ path: 'values.critical', label: 'Critical hit', type: 'boolean' },
|
|
{
|
|
path: 'values.aiming',
|
|
label: 'Aiming down sights',
|
|
type: 'boolean',
|
|
},
|
|
{
|
|
path: 'values.behindCover',
|
|
label: 'Agent behind cover',
|
|
type: 'boolean',
|
|
},
|
|
{
|
|
path: 'values.distanceMeters',
|
|
label: 'Target distance',
|
|
type: 'number',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('advertises the optional non-negative integer proximity count without making snapshots strict', () => {
|
|
const definition = MECHANIC_EVENT_REGISTRY.proximity_snapshot;
|
|
const countField = definition.fields?.find(
|
|
({ path }) => path === 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
|
);
|
|
|
|
expect(definition.strictFields).not.toBe(true);
|
|
expect(countField).toEqual({
|
|
path: 'values.alliesWithin30MetersWithAgentGrantedBuff',
|
|
label: 'Allies within 30 meters with an Agent-granted buff',
|
|
type: 'number',
|
|
minimum: 0,
|
|
integer: true,
|
|
});
|
|
expect(countField?.required).not.toBe(true);
|
|
});
|
|
|
|
it('advertises imported support and equipment-use trigger events', () => {
|
|
expect(MECHANIC_EVENT_REGISTRY.ally_downed).toMatchObject({
|
|
key: 'ally_downed',
|
|
label: 'Ally downed',
|
|
});
|
|
expect(MECHANIC_EVENT_REGISTRY.agent_downed).toMatchObject({
|
|
key: 'agent_downed',
|
|
label: 'Agent downed',
|
|
});
|
|
expect(MECHANIC_EVENT_REGISTRY.ally_rescued).toMatchObject({
|
|
key: 'ally_rescued',
|
|
label: 'Ally rescued',
|
|
});
|
|
expect(MECHANIC_EVENT_REGISTRY.grenade_thrown).toMatchObject({
|
|
key: 'grenade_thrown',
|
|
label: 'Grenade thrown',
|
|
});
|
|
expect(MECHANIC_EVENT_REGISTRY.medical_kit_used).toMatchObject({
|
|
key: 'medical_kit_used',
|
|
label: 'Medical Kit used',
|
|
});
|
|
expect(MECHANIC_EVENT_REGISTRY.signature_ability_deployed).toMatchObject({
|
|
key: 'signature_ability_deployed',
|
|
label: 'Signature Ability deployed',
|
|
});
|
|
expect(MECHANIC_EVENT_REGISTRY.skill_multishot_triggered).toMatchObject({
|
|
key: 'skill_multishot_triggered',
|
|
label: 'Skill Multi-Shot triggered',
|
|
});
|
|
});
|
|
|
|
it('advertises the authoritative Extra Health state contract', () => {
|
|
expect(MECHANIC_EVENT_REGISTRY.extra_health_changed).toEqual({
|
|
key: 'extra_health_changed',
|
|
label: 'Extra Health changed',
|
|
description: expect.stringContaining('transition to zero'),
|
|
strictFields: true,
|
|
fields: [
|
|
{
|
|
path: 'values.hasExtraHealth',
|
|
label: 'Has Extra Health',
|
|
type: 'boolean',
|
|
required: true,
|
|
},
|
|
{
|
|
path: 'values.currentExtraHealth',
|
|
label: 'Current Extra Health',
|
|
type: 'number',
|
|
minimum: 0,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('gains one capped stack per second with Extra Health and clears all stacks on depletion', () => {
|
|
const skillIntensityId = 'skill-intensity';
|
|
const program: MechanicProgram = {
|
|
id: 'electrical-protection',
|
|
name: 'Electrical Protection',
|
|
pools: [
|
|
{
|
|
key: 'extra-health-active',
|
|
label: 'Extra Health active',
|
|
scope: 'agent',
|
|
initial: 0,
|
|
min: 0,
|
|
max: 1,
|
|
},
|
|
{
|
|
key: 'stacks',
|
|
label: 'Electrical Protection stacks',
|
|
scope: 'agent',
|
|
initial: 0,
|
|
min: 0,
|
|
max: 10,
|
|
bestCaseValue: 10,
|
|
},
|
|
],
|
|
clauses: [
|
|
{
|
|
id: 'extra-health-present',
|
|
when: 'extra_health_changed',
|
|
if: {
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.hasExtraHealth' },
|
|
operator: 'eq',
|
|
right: true,
|
|
},
|
|
reducer: { kind: 'none' },
|
|
mutations: [{ kind: 'set', pool: 'extra-health-active', value: 1 }],
|
|
},
|
|
{
|
|
id: 'extra-health-depleted',
|
|
when: 'extra_health_changed',
|
|
if: {
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.hasExtraHealth' },
|
|
operator: 'eq',
|
|
right: false,
|
|
},
|
|
reducer: { kind: 'none' },
|
|
mutations: [
|
|
{ kind: 'clear', pool: 'extra-health-active' },
|
|
{ kind: 'clear', pool: 'stacks' },
|
|
],
|
|
},
|
|
{
|
|
id: 'gain-stack',
|
|
when: 'tick',
|
|
if: {
|
|
kind: 'compare',
|
|
left: { source: 'pool', pool: 'extra-health-active' },
|
|
operator: 'eq',
|
|
right: 1,
|
|
},
|
|
reducer: { kind: 'periodic', intervalSeconds: 1 },
|
|
mutations: [{ kind: 'add', pool: 'stacks', value: 1 }],
|
|
outcomes: [
|
|
{
|
|
id: 'skill-intensity',
|
|
label: 'Skill Intensity',
|
|
phase: 'while_active',
|
|
kind: 'attribute_modifier',
|
|
scope: 'all_skills',
|
|
basis: 'flat',
|
|
attributeId: skillIntensityId,
|
|
value: 1.8,
|
|
statePool: 'stacks',
|
|
perStack: true,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const ticks = Array.from({ length: 13 }, (_, index): MechanicEvent => ({
|
|
kind: 'tick',
|
|
at: index,
|
|
}));
|
|
const capped = evaluateMechanicProgram(
|
|
program,
|
|
[
|
|
{
|
|
kind: 'extra_health_changed',
|
|
at: 0,
|
|
values: { hasExtraHealth: true, currentExtraHealth: 500 },
|
|
},
|
|
...ticks,
|
|
],
|
|
custom(12),
|
|
{
|
|
currentHealth: 1000,
|
|
maxHealth: 1000,
|
|
attributes: { [skillIntensityId]: 0 },
|
|
},
|
|
);
|
|
expect(capped.status).toBe('OK');
|
|
expect(capped.finalState).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ pool: 'stacks', value: 10 }),
|
|
]),
|
|
);
|
|
expect(capped.outcomes.at(-1)).toMatchObject({ value: 18, stateValue: 10 });
|
|
|
|
const depleted = evaluateMechanicProgram(
|
|
program,
|
|
[
|
|
{
|
|
kind: 'extra_health_changed',
|
|
at: 0,
|
|
values: { hasExtraHealth: true, currentExtraHealth: 500 },
|
|
},
|
|
{ kind: 'tick', at: 0 },
|
|
{ kind: 'tick', at: 1 },
|
|
{
|
|
kind: 'extra_health_changed',
|
|
at: 1.5,
|
|
values: { hasExtraHealth: true, currentExtraHealth: 100 },
|
|
},
|
|
{ kind: 'tick', at: 2 },
|
|
{
|
|
kind: 'extra_health_changed',
|
|
at: 2.5,
|
|
values: { hasExtraHealth: false, currentExtraHealth: 0 },
|
|
},
|
|
{ kind: 'tick', at: 3 },
|
|
],
|
|
custom(3),
|
|
{
|
|
currentHealth: 1000,
|
|
maxHealth: 1000,
|
|
attributes: { [skillIntensityId]: 0 },
|
|
},
|
|
);
|
|
expect(depleted.status).toBe('OK');
|
|
expect(depleted.finalState).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ pool: 'extra-health-active', value: 0 }),
|
|
expect.objectContaining({ pool: 'stacks', value: 0 }),
|
|
]),
|
|
);
|
|
expect(
|
|
depleted.emissions.filter(
|
|
(emission) => emission.clauseId === 'gain-stack',
|
|
),
|
|
).toHaveLength(2);
|
|
});
|
|
|
|
it('rejects malformed Extra Health state fields and inconsistent amounts', () => {
|
|
const program: MechanicProgram = {
|
|
id: 'extra-health-state',
|
|
name: 'Extra Health state',
|
|
clauses: [
|
|
{
|
|
id: 'state-change',
|
|
when: 'extra_health_changed',
|
|
if: {
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.hasExtraHealth' },
|
|
operator: 'eq',
|
|
right: true,
|
|
},
|
|
reducer: { kind: 'none' },
|
|
mutations: [{ kind: 'none' }],
|
|
},
|
|
],
|
|
};
|
|
for (const event of [
|
|
{ kind: 'extra_health_changed', at: 0, values: {} },
|
|
{
|
|
kind: 'extra_health_changed',
|
|
at: 0,
|
|
values: { hasExtraHealth: true, currentExtraHealth: -1 },
|
|
},
|
|
{
|
|
kind: 'extra_health_changed',
|
|
at: 0,
|
|
values: { hasExtraHealth: false, currentExtraHealth: 1 },
|
|
},
|
|
] as MechanicEvent[]) {
|
|
expect(evaluateMechanicProgram(program, [event], custom(1)).status).toBe(
|
|
'INVALID',
|
|
);
|
|
}
|
|
|
|
const unknownField = structuredClone(program);
|
|
unknownField.clauses[0].if = {
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.extraHealthActive' },
|
|
operator: 'eq',
|
|
right: true,
|
|
};
|
|
expect(validateMechanicProgram(unknownField)).toMatchObject({
|
|
ok: false,
|
|
errors: [
|
|
expect.stringContaining('is not defined for Extra Health changed'),
|
|
],
|
|
});
|
|
});
|
|
|
|
it('matches kills and assists only while the assigned ability is active', () => {
|
|
const weaponCriticalHitChance = 'weapon-critical-hit-chance';
|
|
const activeOutcome = {
|
|
id: 'tactical-link-critical-chance',
|
|
label: 'Weapon Critical Hit Chance',
|
|
phase: 'while_active' as const,
|
|
kind: 'attribute_modifier' as const,
|
|
scope: 'self' as const,
|
|
basis: 'flat' as const,
|
|
value: 10.2,
|
|
attributeId: weaponCriticalHitChance,
|
|
statePool: 'tactical-link-kill-bonus',
|
|
};
|
|
const activePredicate = {
|
|
kind: 'compare' as const,
|
|
left: {
|
|
source: 'event' as const,
|
|
path: 'values.assignedAbilityActive',
|
|
},
|
|
operator: 'eq' as const,
|
|
right: true,
|
|
};
|
|
const program: MechanicProgram = {
|
|
id: 'tactical-link-kill-bonus',
|
|
name: 'Tactical Link Kill Bonus',
|
|
pools: [
|
|
{
|
|
key: 'tactical-link-kill-bonus',
|
|
label: 'Tactical Link kill bonus',
|
|
scope: 'agent',
|
|
initial: 0,
|
|
min: 0,
|
|
max: 1,
|
|
expiresAfterSeconds: 10,
|
|
},
|
|
],
|
|
clauses: [
|
|
{
|
|
id: 'kill-during-tactical-link',
|
|
when: 'enemy_killed',
|
|
if: activePredicate,
|
|
reducer: { kind: 'none' },
|
|
mutations: [
|
|
{ kind: 'set', pool: 'tactical-link-kill-bonus', value: 1 },
|
|
],
|
|
outcomes: [activeOutcome],
|
|
},
|
|
{
|
|
id: 'assist-during-tactical-link',
|
|
when: 'assist',
|
|
if: activePredicate,
|
|
reducer: { kind: 'none' },
|
|
mutations: [
|
|
{ kind: 'set', pool: 'tactical-link-kill-bonus', value: 1 },
|
|
],
|
|
outcomes: [{ ...activeOutcome }],
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
|
|
const result = evaluateMechanicProgram(
|
|
program,
|
|
[
|
|
{
|
|
kind: 'enemy_killed',
|
|
at: 0,
|
|
values: { assignedAbilityActive: true },
|
|
},
|
|
{
|
|
kind: 'assist',
|
|
at: 4,
|
|
values: { assignedAbilityActive: false },
|
|
},
|
|
{ kind: 'enemy_killed', at: 6 },
|
|
{
|
|
kind: 'assist',
|
|
at: 10,
|
|
values: { assignedAbilityActive: true },
|
|
},
|
|
],
|
|
custom(19),
|
|
{
|
|
maxHealth: 1_000,
|
|
currentHealth: 1_000,
|
|
attributes: { [weaponCriticalHitChance]: 0 },
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe('OK');
|
|
expect(result.outcomes.map((outcome) => outcome.at)).toEqual([0, 10]);
|
|
expect(result.finalState).toContainEqual(
|
|
expect.objectContaining({
|
|
pool: 'tactical-link-kill-bonus',
|
|
value: 1,
|
|
expiresAt: 20,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('rejects invalid assigned-ability activity predicates and event values', () => {
|
|
const program: MechanicProgram = {
|
|
id: 'assigned-ability-activity',
|
|
name: 'Assigned ability activity',
|
|
clauses: [
|
|
{
|
|
id: 'kill',
|
|
when: 'enemy_killed',
|
|
if: {
|
|
kind: 'compare',
|
|
left: {
|
|
source: 'event',
|
|
path: 'values.assignedAbilityActive',
|
|
},
|
|
operator: 'eq',
|
|
right: true,
|
|
},
|
|
reducer: { kind: 'none' },
|
|
mutations: [],
|
|
},
|
|
],
|
|
};
|
|
const invalidPredicate = structuredClone(program);
|
|
invalidPredicate.clauses[0].if = {
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.assignedAbilityActive' },
|
|
operator: 'eq',
|
|
right: 'yes',
|
|
};
|
|
expect(validateMechanicProgram(invalidPredicate)).toMatchObject({
|
|
ok: false,
|
|
errors: [
|
|
expect.stringContaining('Assigned ability active with a boolean value'),
|
|
],
|
|
});
|
|
expect(
|
|
evaluateMechanicProgram(
|
|
program,
|
|
[
|
|
{
|
|
kind: 'enemy_killed',
|
|
at: 0,
|
|
values: { assignedAbilityActive: 'yes' },
|
|
} as unknown as MechanicEvent,
|
|
],
|
|
custom(1),
|
|
),
|
|
).toMatchObject({
|
|
status: 'INVALID',
|
|
errors: [
|
|
expect.stringContaining('Assigned ability active must be a boolean'),
|
|
],
|
|
});
|
|
});
|
|
|
|
it('executes imported talent trigger families as first-class events', () => {
|
|
const eventKinds: MechanicEventKind[] = [
|
|
'ally_downed',
|
|
'agent_downed',
|
|
'ally_rescued',
|
|
'grenade_thrown',
|
|
'medical_kit_used',
|
|
'signature_ability_deployed',
|
|
'skill_multishot_triggered',
|
|
];
|
|
const program: MechanicProgram = {
|
|
id: 'imported-talent-events',
|
|
name: 'Imported talent events',
|
|
clauses: eventKinds.map((when) => ({
|
|
id: when,
|
|
when,
|
|
reducer: { kind: 'none' },
|
|
mutations: [{ kind: 'none' }],
|
|
outcomes: [
|
|
{
|
|
id: `${when}-marker`,
|
|
label: `${when} marker`,
|
|
phase: 'after_event',
|
|
kind: 'marker',
|
|
scope: 'self',
|
|
basis: 'flat',
|
|
value: 1,
|
|
},
|
|
],
|
|
})),
|
|
};
|
|
|
|
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
|
|
const events: MechanicEvent[] = eventKinds.map((kind, index) => ({
|
|
kind,
|
|
at: index + 1,
|
|
}));
|
|
expect(
|
|
evaluateMechanicProgram(program, events, custom()).outcomes.map(
|
|
(outcome) => outcome.outcomeId,
|
|
),
|
|
).toEqual(eventKinds.map((kind) => `${kind}-marker`));
|
|
});
|
|
|
|
it('derives the previous weapon-hit target for target-switch mechanics', () => {
|
|
const program: MechanicProgram = {
|
|
id: 'target-switch-stacks',
|
|
name: 'Target Switch Stacks',
|
|
pools: [
|
|
{
|
|
key: 'stacks',
|
|
label: 'Stacks',
|
|
scope: 'agent',
|
|
initial: 0,
|
|
min: 0,
|
|
max: 100,
|
|
expiresAfterSeconds: 5,
|
|
},
|
|
],
|
|
clauses: [
|
|
{
|
|
id: 'halve-on-switch',
|
|
when: 'weapon_hit',
|
|
if: {
|
|
kind: 'all',
|
|
predicates: [
|
|
{
|
|
kind: 'exists',
|
|
value: { source: 'event', path: 'values.previousTargetId' },
|
|
},
|
|
{
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.previousTargetId' },
|
|
operator: 'ne',
|
|
right: { source: 'event', path: 'targetId' },
|
|
},
|
|
],
|
|
},
|
|
reducer: { kind: 'none' },
|
|
mutations: [
|
|
{
|
|
kind: 'set',
|
|
pool: 'stacks',
|
|
value: {
|
|
op: 'multiply',
|
|
left: { ref: { source: 'pool', pool: 'stacks' } },
|
|
right: 0.5,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'build-stacks',
|
|
when: 'weapon_hit',
|
|
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
|
reducer: { kind: 'none' },
|
|
mutations: [{ kind: 'add', pool: 'stacks', value: 1 }],
|
|
},
|
|
],
|
|
};
|
|
const result = evaluateMechanicProgram(
|
|
program,
|
|
[
|
|
{ kind: 'weapon_hit', at: 0, targetId: 'alpha' },
|
|
{ kind: 'weapon_hit', at: 1, targetId: 'alpha' },
|
|
{ kind: 'weapon_hit', at: 2, targetId: 'bravo' },
|
|
],
|
|
custom(3),
|
|
);
|
|
expect(result.status).toBe('OK');
|
|
expect(result.finalState).toContainEqual(
|
|
expect.objectContaining({
|
|
pool: 'stacks',
|
|
value: 2,
|
|
}),
|
|
);
|
|
expect(result.emissions.map((emission) => emission.clauseId)).toEqual([
|
|
'build-stacks',
|
|
'build-stacks',
|
|
'halve-on-switch',
|
|
'build-stacks',
|
|
]);
|
|
});
|
|
|
|
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('preserves an explicit zero clause weight', () => {
|
|
const result = evaluateMechanicProgram(
|
|
{
|
|
id: 'zero-weight',
|
|
name: 'Zero weight',
|
|
clauses: [
|
|
{
|
|
id: 'zero-weight-clause',
|
|
when: 'agent_hit',
|
|
reducer: { kind: 'none' },
|
|
weight: 0,
|
|
mutations: [],
|
|
},
|
|
],
|
|
},
|
|
[{ kind: 'agent_hit', at: 0 }],
|
|
custom(1),
|
|
);
|
|
expect(result.emissions).toHaveLength(1);
|
|
expect(result.emissions[0].weight).toBe(0);
|
|
expect(result.estimatedActivationCount).toBe(0);
|
|
});
|
|
|
|
it.each([0, 0.5])(
|
|
'keeps a weighted reload delay intact at weight %s',
|
|
(weight) => {
|
|
const result = evaluateMechanicProgram(
|
|
{
|
|
id: 'weighted-reload',
|
|
name: 'Weighted reload',
|
|
clauses: [
|
|
{
|
|
id: 'weighted-reload-clause',
|
|
when: 'weapon_hit',
|
|
reducer: { kind: 'none' },
|
|
weight,
|
|
mutations: [],
|
|
outcomes: [
|
|
{
|
|
id: 'reload',
|
|
label: 'Reload',
|
|
phase: 'on_event',
|
|
kind: 'weapon_reload',
|
|
scope: 'equipped_weapon',
|
|
value: 5,
|
|
basis: 'flat',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
[{ kind: 'weapon_hit', at: 1 }],
|
|
custom(10),
|
|
);
|
|
expect(result.outcomes).toHaveLength(1);
|
|
expect(result.outcomes[0]).toMatchObject({
|
|
at: 6,
|
|
weight,
|
|
resolvedValue: 5,
|
|
appliedValue: 5,
|
|
});
|
|
},
|
|
);
|
|
|
|
it('preserves a zero weight for maximum-mode passive outcomes', () => {
|
|
const result = evaluateMechanicProgram(
|
|
{
|
|
id: 'zero-weight-maximum',
|
|
name: 'Zero weight maximum',
|
|
clauses: [
|
|
{
|
|
id: 'zero-weight-maximum-clause',
|
|
label: 'Maximum passive',
|
|
when: 'weapon_hit',
|
|
reducer: { kind: 'none' },
|
|
weight: 0,
|
|
mutations: [],
|
|
outcomes: [
|
|
{
|
|
id: 'marker',
|
|
label: 'Passive marker',
|
|
phase: 'while_active',
|
|
kind: 'marker',
|
|
scope: 'self',
|
|
value: 1,
|
|
basis: 'flat',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
[],
|
|
{ mode: 'maximum', encounterSeconds: 10 },
|
|
);
|
|
expect(result.outcomes).toHaveLength(1);
|
|
expect(result.outcomes[0]).toMatchObject({ weight: 0, appliedValue: 0 });
|
|
});
|
|
|
|
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.status).toBe('OK');
|
|
expect(result.finalState[0].value).toBe(2);
|
|
const maximum = evaluateMechanicProgram(program, [], {
|
|
mode: 'maximum',
|
|
encounterSeconds: 30,
|
|
});
|
|
expect(maximum.finalState[0].value).toBe(3);
|
|
});
|
|
|
|
it.each([
|
|
{ count: 0, upgrades: 0, multiplier: 0 },
|
|
{ count: 3, upgrades: 0, multiplier: 3 },
|
|
{ count: 3, upgrades: 5, multiplier: 6 },
|
|
])(
|
|
'resolves Close-and-Personal-style Engineering for count $count and $upgrades OS upgrades',
|
|
({ count, upgrades, multiplier }) => {
|
|
const resolvedVariantValue = 7.25;
|
|
const result = evaluateMechanicProgram(
|
|
closeAndPersonalProgram(resolvedVariantValue),
|
|
[
|
|
{
|
|
kind: 'proximity_snapshot',
|
|
at: 0,
|
|
values: {
|
|
alliesWithin30MetersWithAgentGrantedBuff: count,
|
|
},
|
|
},
|
|
{ kind: 'tick', at: 1 },
|
|
],
|
|
custom(),
|
|
undefined,
|
|
{ protocolTalentUpgrades: upgrades },
|
|
);
|
|
|
|
expect(result.status).toBe('OK');
|
|
expect(result.finalState).toContainEqual(
|
|
expect.objectContaining({ pool: 'eligible-allies', value: count }),
|
|
);
|
|
expect(
|
|
result.outcomes.find(({ outcomeId }) => outcomeId === 'engineering'),
|
|
).toMatchObject({
|
|
value: resolvedVariantValue * multiplier,
|
|
resolvedValue: resolvedVariantValue * multiplier,
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{
|
|
count: '2',
|
|
error:
|
|
'Allies within 30 meters with an Agent-granted buff must be a number.',
|
|
},
|
|
{
|
|
count: -1,
|
|
error:
|
|
'Allies within 30 meters with an Agent-granted buff must be 0 or greater.',
|
|
},
|
|
{
|
|
count: 1.5,
|
|
error:
|
|
'Allies within 30 meters with an Agent-granted buff must be an integer.',
|
|
},
|
|
])('rejects invalid proximity ally count $count', ({ count, error }) => {
|
|
const result = evaluateMechanicProgram(
|
|
closeAndPersonalProgram(7.25),
|
|
[
|
|
{
|
|
kind: 'proximity_snapshot',
|
|
at: 0,
|
|
values: {
|
|
alliesWithin30MetersWithAgentGrantedBuff: count,
|
|
},
|
|
},
|
|
],
|
|
custom(),
|
|
undefined,
|
|
{ protocolTalentUpgrades: 0 },
|
|
);
|
|
|
|
expect(result.status).toBe('INVALID');
|
|
expect(result.errors).toContain(`Event 1 (Proximity snapshot) ${error}`);
|
|
});
|
|
|
|
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('shares an identical timed modifier outcome across kill and assist refresh clauses', () => {
|
|
const weaponDamage = {
|
|
id: 'pummel-weapon-damage',
|
|
label: 'Weapon Damage',
|
|
phase: 'while_active' as const,
|
|
kind: 'attribute_modifier' as const,
|
|
scope: 'equipped_weapon' as const,
|
|
basis: 'flat' as const,
|
|
value: 10,
|
|
attributeId: 'weapon-damage',
|
|
statePool: 'pummel-active',
|
|
};
|
|
const program: MechanicProgram = {
|
|
id: 'pummel-refresh',
|
|
name: 'Pummel Refresh',
|
|
pools: [
|
|
{
|
|
key: 'pummel-active',
|
|
label: 'Pummel active',
|
|
scope: 'agent',
|
|
initial: 0,
|
|
min: 0,
|
|
max: 1,
|
|
expiresAfterSeconds: 12,
|
|
},
|
|
],
|
|
clauses: [
|
|
{
|
|
id: 'activate-on-kill',
|
|
when: 'enemy_killed',
|
|
reducer: { kind: 'none' },
|
|
mutations: [{ kind: 'set', pool: 'pummel-active', value: 1 }],
|
|
outcomes: [weaponDamage],
|
|
},
|
|
{
|
|
id: 'refresh-on-assist',
|
|
when: 'assist',
|
|
reducer: { kind: 'none' },
|
|
mutations: [{ kind: 'set', pool: 'pummel-active', value: 1 }],
|
|
outcomes: [{ ...weaponDamage }],
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
|
|
const result = evaluateMechanicProgram(
|
|
program,
|
|
[
|
|
{ kind: 'enemy_killed', at: 1 },
|
|
{ kind: 'assist', at: 10 },
|
|
],
|
|
custom(20),
|
|
);
|
|
|
|
expect(result.status).toBe('OK');
|
|
expect(
|
|
result.outcomes.map(({ outcomeId, at, appliedValue }) => ({
|
|
outcomeId,
|
|
at,
|
|
appliedValue,
|
|
})),
|
|
).toEqual([
|
|
{ outcomeId: 'pummel-weapon-damage', at: 1, appliedValue: 10 },
|
|
{ outcomeId: 'pummel-weapon-damage', at: 10, appliedValue: 10 },
|
|
]);
|
|
expect(result.finalState).toContainEqual(
|
|
expect.objectContaining({
|
|
pool: 'pummel-active',
|
|
value: 1,
|
|
expiresAt: 22,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('rejects a shared outcome ID when its mechanical definitions conflict', () => {
|
|
const outcome = {
|
|
id: 'shared-damage',
|
|
label: 'Weapon Damage',
|
|
phase: 'on_event' as const,
|
|
kind: 'attribute_modifier' as const,
|
|
scope: 'equipped_weapon' as const,
|
|
basis: 'flat' as const,
|
|
value: 10,
|
|
attributeId: 'weapon-damage',
|
|
};
|
|
const program: MechanicProgram = {
|
|
id: 'conflicting-shared-outcome',
|
|
name: 'Conflicting Shared Outcome',
|
|
clauses: [
|
|
{
|
|
id: 'kill',
|
|
when: 'enemy_killed',
|
|
reducer: { kind: 'none' },
|
|
mutations: [{ kind: 'none' }],
|
|
outcomes: [outcome],
|
|
},
|
|
{
|
|
id: 'assist',
|
|
when: 'assist',
|
|
reducer: { kind: 'none' },
|
|
mutations: [{ kind: 'none' }],
|
|
outcomes: [{ ...outcome, value: 14 }],
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(validateMechanicProgram(program)).toEqual({
|
|
ok: false,
|
|
errors: ['Outcome "shared-damage" has conflicting definitions.'],
|
|
});
|
|
});
|
|
|
|
it('applies distance bonuses in full five-meter steps and caps the result', () => {
|
|
const distanceBonus = (
|
|
base: number,
|
|
increment: number,
|
|
maximum: number,
|
|
): NumericExpression => ({
|
|
op: 'min',
|
|
left: maximum,
|
|
right: {
|
|
op: 'add',
|
|
left: base,
|
|
right: {
|
|
op: 'multiply',
|
|
left: increment,
|
|
right: {
|
|
op: 'floor',
|
|
value: {
|
|
op: 'divide',
|
|
left: {
|
|
op: 'max',
|
|
left: {
|
|
ref: { source: 'event', path: 'values.distanceMeters' },
|
|
},
|
|
right: 0,
|
|
},
|
|
right: 5,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const program: MechanicProgram = {
|
|
id: 'long-distance-marking',
|
|
name: 'Long-Distance Marking',
|
|
clauses: [
|
|
{
|
|
id: 'headshot',
|
|
when: 'weapon_hit',
|
|
reducer: { kind: 'none' },
|
|
mutations: [],
|
|
outcomes: [
|
|
{
|
|
id: 'skill-damage',
|
|
label: 'Skill Damage',
|
|
phase: 'after_event',
|
|
kind: 'attribute_modifier',
|
|
scope: 'self',
|
|
attributeId: 'skill-damage',
|
|
basis: 'flat',
|
|
value: distanceBonus(7.5, 1.5, 18),
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
|
|
const result = evaluateMechanicProgram(
|
|
program,
|
|
[-1, 0, 4.99, 5, 9.99, 10, 34.99, 35, 100].map((distanceMeters, at) => ({
|
|
kind: 'weapon_hit' as const,
|
|
at,
|
|
values: { distanceMeters },
|
|
})),
|
|
custom(10),
|
|
);
|
|
|
|
expect(result.status).toBe('OK');
|
|
expect(result.outcomes.map((outcome) => outcome.resolvedValue)).toEqual([
|
|
7.5, 7.5, 7.5, 9, 9, 10.5, 16.5, 18, 18,
|
|
]);
|
|
expect(result.script).toContain(
|
|
'MIN(18, (7.5 + (1.5 * FLOOR((MAX(EVENT.values.distanceMeters, 0) / 5)))))',
|
|
);
|
|
});
|
|
|
|
it('supports an immediate equipped-weapon magazine refill', () => {
|
|
const program: MechanicProgram = {
|
|
id: 'equipped-magazine',
|
|
name: 'Equipped Magazine',
|
|
clauses: [
|
|
{
|
|
id: 'refill-equipped-weapon',
|
|
when: 'enemy_killed',
|
|
reducer: { kind: 'none' },
|
|
mutations: [{ kind: 'none' }],
|
|
outcomes: [
|
|
{
|
|
id: 'equipped-weapon-reload',
|
|
label: 'Refill equipped weapon',
|
|
phase: 'after_event',
|
|
kind: 'weapon_reload',
|
|
scope: 'equipped_weapon',
|
|
basis: 'flat',
|
|
value: 0,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = evaluateMechanicProgram(
|
|
program,
|
|
[{ kind: 'enemy_killed', at: 1 }],
|
|
custom(10),
|
|
);
|
|
|
|
expect(result.status).toBe('OK');
|
|
expect(result.outcomes).toEqual([
|
|
expect.objectContaining({
|
|
kind: 'weapon_reload',
|
|
scope: 'equipped_weapon',
|
|
at: 1,
|
|
value: 0,
|
|
}),
|
|
]);
|
|
expect(result.script).toContain('RELOAD EQUIPPED WEAPON AFTER 0s');
|
|
});
|
|
|
|
it('schedules an inactive-weapon reload and refreshes a pending delay', () => {
|
|
const program: MechanicProgram = {
|
|
id: 'backup-magazine',
|
|
name: 'Backup Magazine',
|
|
clauses: [
|
|
{
|
|
id: 'reload-inactive-weapon',
|
|
when: 'weapon_swapped',
|
|
reducer: { kind: 'none' },
|
|
mutations: [{ kind: 'none' }],
|
|
outcomes: [
|
|
{
|
|
id: 'inactive-weapon-reload',
|
|
label: 'Reload inactive weapon',
|
|
phase: 'after_event',
|
|
kind: 'weapon_reload',
|
|
scope: 'inactive_weapon',
|
|
basis: 'flat',
|
|
value: 4,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
const refreshed = evaluateMechanicProgram(
|
|
program,
|
|
[
|
|
{ kind: 'weapon_swapped', at: 1 },
|
|
{ kind: 'weapon_swapped', at: 3 },
|
|
],
|
|
custom(10),
|
|
);
|
|
expect(refreshed.status).toBe('OK');
|
|
expect(refreshed.emissions.map(({ at }) => at)).toEqual([1, 3]);
|
|
expect(refreshed.outcomes).toEqual([
|
|
expect.objectContaining({
|
|
kind: 'weapon_reload',
|
|
scope: 'inactive_weapon',
|
|
at: 7,
|
|
value: 4,
|
|
}),
|
|
]);
|
|
expect(refreshed.script).toContain('RELOAD INACTIVE WEAPON AFTER 4s');
|
|
|
|
const beyondEncounter = evaluateMechanicProgram(
|
|
program,
|
|
[{ kind: 'weapon_swapped', at: 8 }],
|
|
custom(10),
|
|
);
|
|
expect(beyondEncounter.outcomes).toEqual([]);
|
|
});
|
|
|
|
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('caps Signature Ability Charge at 1000 and scales grants by charge efficiency', () => {
|
|
const efficiencyAttributeId = 'signature-charge-efficiency';
|
|
const program: MechanicProgram = {
|
|
id: 'signature-charge',
|
|
name: 'Signature Charge',
|
|
clauses: [
|
|
{
|
|
id: 'grant-charge',
|
|
when: 'weapon_hit',
|
|
reducer: { kind: 'none' },
|
|
mutations: [],
|
|
outcomes: [
|
|
{
|
|
id: 'charge',
|
|
label: 'Signature Ability Charge',
|
|
phase: 'after_event',
|
|
kind: 'restoration',
|
|
scope: 'self',
|
|
resource: 'signature_ability_charge',
|
|
basis: 'flat',
|
|
value: {
|
|
op: 'multiply',
|
|
left: 70,
|
|
right: {
|
|
op: 'add',
|
|
left: 1,
|
|
right: {
|
|
op: 'divide',
|
|
left: {
|
|
ref: {
|
|
source: 'attribute',
|
|
attributeId: efficiencyAttributeId,
|
|
},
|
|
},
|
|
right: 100,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const result = evaluateMechanicProgram(
|
|
program,
|
|
[
|
|
{ kind: 'weapon_hit', at: 1 },
|
|
{ kind: 'weapon_hit', at: 2 },
|
|
],
|
|
custom(),
|
|
{
|
|
maxHealth: 100,
|
|
currentHealth: 100,
|
|
currentSignatureAbilityCharge: 900,
|
|
attributes: { [efficiencyAttributeId]: 50 },
|
|
},
|
|
);
|
|
expect(result.status).toBe('OK');
|
|
expect(result.outcomes.map((outcome) => outcome.appliedValue)).toEqual([
|
|
100, 0,
|
|
]);
|
|
expect(result.finalCombat?.currentSignatureAbilityCharge).toBe(1000);
|
|
expect(result.finalCombat?.maxSignatureAbilityCharge).toBeUndefined();
|
|
});
|
|
|
|
it('evaluates mutually exclusive current Health percentage bands at strict boundaries', () => {
|
|
const band = (
|
|
id: string,
|
|
minimum: number | undefined,
|
|
maximum: number,
|
|
value: number,
|
|
): MechanicProgram['clauses'][number] => ({
|
|
id,
|
|
when: 'tick',
|
|
if:
|
|
minimum === undefined
|
|
? {
|
|
kind: 'compare',
|
|
left: {
|
|
source: 'combat',
|
|
path: 'currentHealthPercent',
|
|
timing: 'current',
|
|
},
|
|
operator: 'lt',
|
|
right: maximum,
|
|
}
|
|
: {
|
|
kind: 'all',
|
|
predicates: [
|
|
{
|
|
kind: 'compare',
|
|
left: {
|
|
source: 'combat',
|
|
path: 'currentHealthPercent',
|
|
timing: 'current',
|
|
},
|
|
operator: 'gte',
|
|
right: minimum,
|
|
},
|
|
{
|
|
kind: 'compare',
|
|
left: {
|
|
source: 'combat',
|
|
path: 'currentHealthPercent',
|
|
timing: 'current',
|
|
},
|
|
operator: 'lt',
|
|
right: maximum,
|
|
},
|
|
],
|
|
},
|
|
reducer: { kind: 'none' },
|
|
mutations: [],
|
|
outcomes: [
|
|
{
|
|
id: `${id}-damage-reduction`,
|
|
label: 'Damage Reduction',
|
|
phase: 'while_active',
|
|
kind: 'attribute_modifier',
|
|
scope: 'self',
|
|
basis: 'flat',
|
|
attributeId: 'damage-reduction',
|
|
value,
|
|
},
|
|
],
|
|
});
|
|
const spirited: MechanicProgram = {
|
|
id: 'spirited',
|
|
name: 'Spirited',
|
|
clauses: [
|
|
band('critical', undefined, 25, 15),
|
|
band('damaged', 25, 50, 12),
|
|
band('wounded', 50, 75, 9),
|
|
],
|
|
};
|
|
const evaluateAt = (
|
|
healthPercent: number,
|
|
mode: 'custom' | 'maximum' = 'custom',
|
|
) =>
|
|
evaluateMechanicProgram(
|
|
spirited,
|
|
[{ kind: 'tick', at: 0 }],
|
|
mode === 'maximum'
|
|
? { mode: 'maximum', encounterSeconds: 1 }
|
|
: custom(1),
|
|
{ maxHealth: 1_000, currentHealth: healthPercent * 10 },
|
|
);
|
|
const valuesAt = (
|
|
healthPercent: number,
|
|
mode: 'custom' | 'maximum' = 'custom',
|
|
) =>
|
|
evaluateAt(healthPercent, mode).outcomes.map(
|
|
(outcome) => outcome.appliedValue,
|
|
);
|
|
|
|
expect(valuesAt(0)).toEqual([15]);
|
|
expect(valuesAt(24.99)).toEqual([15]);
|
|
expect(valuesAt(25)).toEqual([12]);
|
|
expect(valuesAt(49.99)).toEqual([12]);
|
|
expect(valuesAt(50)).toEqual([9]);
|
|
expect(valuesAt(74.99)).toEqual([9]);
|
|
expect(valuesAt(75)).toEqual([]);
|
|
expect(valuesAt(100)).toEqual([]);
|
|
expect(valuesAt(24, 'maximum')).toEqual([15]);
|
|
});
|
|
|
|
it('re-evaluates current Health percentage after deterministic combat-state updates', () => {
|
|
const program: MechanicProgram = {
|
|
id: 'live-health',
|
|
name: 'Live Health',
|
|
clauses: [
|
|
{
|
|
id: 'health-cost',
|
|
when: 'tick',
|
|
reducer: { kind: 'none' },
|
|
mutations: [],
|
|
outcomes: [
|
|
{
|
|
id: 'cost',
|
|
label: 'Health cost',
|
|
phase: 'on_event',
|
|
kind: 'resource_cost',
|
|
scope: 'self',
|
|
resource: 'health',
|
|
basis: 'flat',
|
|
value: 30,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'below-seventy-five',
|
|
when: 'tick',
|
|
if: {
|
|
kind: 'compare',
|
|
left: {
|
|
source: 'combat',
|
|
path: 'currentHealthPercent',
|
|
timing: 'current',
|
|
},
|
|
operator: 'lt',
|
|
right: 75,
|
|
},
|
|
reducer: { kind: 'none' },
|
|
mutations: [],
|
|
outcomes: [
|
|
{
|
|
id: 'reduction',
|
|
label: 'Damage Reduction',
|
|
phase: 'while_active',
|
|
kind: 'attribute_modifier',
|
|
scope: 'self',
|
|
attributeId: 'damage-reduction',
|
|
basis: 'flat',
|
|
value: 9,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const result = evaluateMechanicProgram(
|
|
program,
|
|
[{ kind: 'tick', at: 0 }],
|
|
custom(1),
|
|
{ maxHealth: 100, currentHealth: 100 },
|
|
);
|
|
expect(result.finalCombat?.currentHealth).toBe(70);
|
|
expect(result.outcomes.map((outcome) => outcome.outcomeId)).toEqual([
|
|
'cost',
|
|
'reduction',
|
|
]);
|
|
});
|
|
|
|
it('evaluates pre-event timing for the derived current Health percentage', () => {
|
|
const program: MechanicProgram = {
|
|
id: 'pre-event-health-percent',
|
|
name: 'Pre-event Health Percent',
|
|
clauses: [
|
|
{
|
|
id: 'full-health-before-event',
|
|
when: 'tick',
|
|
if: {
|
|
kind: 'compare',
|
|
left: {
|
|
source: 'combat',
|
|
path: 'currentHealthPercent',
|
|
timing: 'pre_event',
|
|
},
|
|
operator: 'gte',
|
|
right: 100,
|
|
},
|
|
reducer: { kind: 'none' },
|
|
mutations: [],
|
|
outcomes: [
|
|
{
|
|
id: 'matched',
|
|
label: 'Matched',
|
|
phase: 'on_event',
|
|
kind: 'marker',
|
|
scope: 'self',
|
|
basis: 'flat',
|
|
value: 1,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
expect(validateMechanicProgram(program)).toMatchObject({ ok: true });
|
|
expect(
|
|
evaluateMechanicProgram(program, [{ kind: 'tick', at: 0 }], custom(1), {
|
|
maxHealth: 100,
|
|
currentHealth: 100,
|
|
}).outcomes.map((outcome) => outcome.outcomeId),
|
|
).toEqual(['matched']);
|
|
});
|
|
|
|
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'),
|
|
);
|
|
});
|
|
|
|
it('preserves radius targeting on ally modifiers and enemy markers', () => {
|
|
const program: MechanicProgram = {
|
|
id: 'radius-targeting',
|
|
name: 'Radius Targeting',
|
|
clauses: [
|
|
{
|
|
id: 'kill',
|
|
when: 'enemy_killed',
|
|
reducer: { kind: 'none' },
|
|
mutations: [{ kind: 'none' }],
|
|
outcomes: [
|
|
{
|
|
id: 'healing',
|
|
label: 'Incoming Healing',
|
|
phase: 'on_event',
|
|
kind: 'attribute_modifier',
|
|
scope: 'allies',
|
|
attributeId: 'incoming-healing',
|
|
basis: 'flat',
|
|
value: 7,
|
|
targeting: {
|
|
kind: 'within_radius',
|
|
population: 'allies',
|
|
radiusMeters: 40,
|
|
},
|
|
},
|
|
{
|
|
id: 'taunt',
|
|
label: 'Taunt',
|
|
phase: 'on_event',
|
|
kind: 'marker',
|
|
scope: 'target',
|
|
basis: 'flat',
|
|
value: 1,
|
|
targeting: {
|
|
kind: 'within_radius',
|
|
population: 'enemies',
|
|
radiusMeters: 20,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const result = evaluateMechanicProgram(
|
|
program,
|
|
[{ kind: 'enemy_killed', at: 1 }],
|
|
custom(5),
|
|
);
|
|
expect(result.status).toBe('OK');
|
|
expect(result.outcomes.map((outcome) => outcome.targeting)).toEqual([
|
|
{ kind: 'within_radius', population: 'allies', radiusMeters: 40 },
|
|
{ kind: 'within_radius', population: 'enemies', radiusMeters: 20 },
|
|
]);
|
|
expect(result.script).toContain('TO ALLIES WITHIN 40m');
|
|
expect(result.script).toContain('TO ENEMIES WITHIN 20m');
|
|
});
|
|
|
|
it('applies aimed critical-hit area damage once to each valid enemy at or inside the radius', () => {
|
|
const program: MechanicProgram = {
|
|
id: 'aimed-critical-explosion',
|
|
name: 'Aimed Critical Explosion',
|
|
clauses: [
|
|
{
|
|
id: 'aimed-critical-hit',
|
|
when: 'weapon_hit',
|
|
if: {
|
|
kind: 'all',
|
|
predicates: [
|
|
{
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.critical' },
|
|
operator: 'eq',
|
|
right: true,
|
|
},
|
|
{
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.aiming' },
|
|
operator: 'eq',
|
|
right: true,
|
|
},
|
|
],
|
|
},
|
|
reducer: { kind: 'none' },
|
|
mutations: [],
|
|
outcomes: [
|
|
{
|
|
id: 'explosion',
|
|
label: 'Explosion',
|
|
phase: 'after_event',
|
|
kind: 'direct_damage',
|
|
scope: 'target',
|
|
basis: 'flat',
|
|
value: {
|
|
op: 'multiply',
|
|
left: 20,
|
|
right: {
|
|
ref: { source: 'attribute', attributeId: 'firepower' },
|
|
},
|
|
},
|
|
targeting: {
|
|
kind: 'event_target_area',
|
|
center: 'event_target',
|
|
validTargets: 'enemies',
|
|
radiusMeters: 5,
|
|
},
|
|
probabilityPercent: 100,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
const result = evaluateMechanicProgram(
|
|
program,
|
|
[
|
|
{
|
|
kind: 'weapon_hit',
|
|
at: 1,
|
|
targetId: 'enemy-b',
|
|
values: { critical: true, aiming: true },
|
|
areaTargetSample: {
|
|
center: 'event_target',
|
|
validTargets: [
|
|
{
|
|
entityId: 'enemy-c',
|
|
population: 'enemy',
|
|
distanceMeters: 5.001,
|
|
},
|
|
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
|
|
{ entityId: 'ally-a', population: 'ally', distanceMeters: 2 },
|
|
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
custom(5),
|
|
{ maxHealth: 1000, currentHealth: 1000, attributes: { firepower: 100 } },
|
|
);
|
|
|
|
expect(result.status).toBe('OK');
|
|
expect(result.outcomes.map((outcome) => outcome.targetId)).toEqual([
|
|
'enemy-a',
|
|
'enemy-b',
|
|
]);
|
|
expect(result.outcomes.map((outcome) => outcome.appliedValue)).toEqual([
|
|
2000, 2000,
|
|
]);
|
|
expect(
|
|
new Set(result.outcomes.map((outcome) => outcome.probabilityRoll)).size,
|
|
).toBe(1);
|
|
expect(result.outcomes[0].probabilityRoll).not.toBeNull();
|
|
expect(result.script).toContain(
|
|
'EXPLOSION CENTERED ON EVENT TARGET; APPLY TO VALID ENEMIES WITHIN 5m',
|
|
);
|
|
const maximum = evaluateMechanicProgram(
|
|
program,
|
|
[
|
|
{
|
|
kind: 'weapon_hit',
|
|
at: 1,
|
|
targetId: 'enemy-b',
|
|
values: { critical: true, aiming: true },
|
|
areaTargetSample: {
|
|
center: 'event_target',
|
|
validTargets: [
|
|
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
|
|
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
{ mode: 'maximum', encounterSeconds: 5 },
|
|
{ maxHealth: 1000, currentHealth: 1000, attributes: { firepower: 100 } },
|
|
);
|
|
expect(maximum.outcomes.map((outcome) => outcome.targetId)).toEqual([
|
|
'enemy-a',
|
|
'enemy-b',
|
|
]);
|
|
expect(
|
|
maximum.outcomes.every((outcome) => outcome.probabilityRoll === 0),
|
|
).toBe(true);
|
|
|
|
const skillProgram: MechanicProgram = {
|
|
...program,
|
|
id: 'skill-hit-explosion',
|
|
clauses: program.clauses.map((clause) => ({
|
|
...clause,
|
|
when: 'skill_hit',
|
|
if: undefined,
|
|
})),
|
|
};
|
|
expect(validateMechanicProgram(skillProgram)).toMatchObject({ ok: true });
|
|
expect(
|
|
evaluateMechanicProgram(
|
|
skillProgram,
|
|
[
|
|
{
|
|
kind: 'skill_hit',
|
|
at: 1,
|
|
targetId: 'enemy-b',
|
|
areaTargetSample: {
|
|
center: 'event_target',
|
|
validTargets: [
|
|
{ entityId: 'enemy-b', population: 'enemy', distanceMeters: 0 },
|
|
{ entityId: 'enemy-a', population: 'enemy', distanceMeters: 5 },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
custom(5),
|
|
{
|
|
maxHealth: 1000,
|
|
currentHealth: 1000,
|
|
attributes: { firepower: 100 },
|
|
},
|
|
).outcomes.map((outcome) => outcome.targetId),
|
|
).toEqual(['enemy-a', 'enemy-b']);
|
|
});
|
|
|
|
it('requires authoritative area evidence only when an area outcome activates', () => {
|
|
const program: MechanicProgram = {
|
|
id: 'area-evidence',
|
|
name: 'Area Evidence',
|
|
clauses: [
|
|
{
|
|
id: 'critical-hit',
|
|
when: 'weapon_hit',
|
|
if: {
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.critical' },
|
|
operator: 'eq',
|
|
right: true,
|
|
},
|
|
reducer: { kind: 'none' },
|
|
mutations: [],
|
|
outcomes: [
|
|
{
|
|
id: 'explosion',
|
|
label: 'Explosion',
|
|
phase: 'after_event',
|
|
kind: 'direct_damage',
|
|
scope: 'target',
|
|
basis: 'flat',
|
|
value: 1,
|
|
targeting: {
|
|
kind: 'event_target_area',
|
|
center: 'event_target',
|
|
validTargets: 'enemies',
|
|
radiusMeters: 5,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
expect(
|
|
evaluateMechanicProgram(
|
|
program,
|
|
[
|
|
{
|
|
kind: 'weapon_hit',
|
|
at: 1,
|
|
targetId: 'enemy',
|
|
values: { critical: false },
|
|
},
|
|
],
|
|
custom(5),
|
|
),
|
|
).toMatchObject({ status: 'OK', outcomes: [] });
|
|
expect(
|
|
evaluateMechanicProgram(
|
|
program,
|
|
[
|
|
{
|
|
kind: 'weapon_hit',
|
|
at: 1,
|
|
targetId: 'enemy',
|
|
values: { critical: true },
|
|
},
|
|
],
|
|
custom(5),
|
|
),
|
|
).toMatchObject({
|
|
status: 'INVALID',
|
|
errors: [
|
|
expect.stringContaining('authoritative event-target area sample'),
|
|
],
|
|
});
|
|
});
|
|
|
|
it('rejects malformed and duplicate area-target samples', () => {
|
|
const result = evaluateMechanicProgram(
|
|
{ id: 'sample-validation', name: 'Sample Validation', clauses: [] },
|
|
[
|
|
{
|
|
kind: 'weapon_hit',
|
|
at: 1,
|
|
targetId: 'enemy',
|
|
areaTargetSample: {
|
|
center: 'event_target',
|
|
validTargets: [
|
|
{ entityId: 'enemy', population: 'enemy', distanceMeters: 0 },
|
|
{ entityId: 'enemy', population: 'enemy', distanceMeters: 1 },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
custom(5),
|
|
);
|
|
expect(result).toMatchObject({
|
|
status: 'INVALID',
|
|
errors: [expect.stringContaining('duplicates entity "enemy"')],
|
|
});
|
|
|
|
const oversizedTargets = Array.from({ length: 257 }, (_, index) => ({
|
|
entityId: index === 0 ? 'enemy' : `enemy-${index}`,
|
|
population: 'enemy' as const,
|
|
distanceMeters: index === 0 ? 0 : 1,
|
|
}));
|
|
expect(
|
|
evaluateMechanicProgram(
|
|
{ id: 'sample-bound', name: 'Sample Bound', clauses: [] },
|
|
[
|
|
{
|
|
kind: 'weapon_hit',
|
|
at: 1,
|
|
targetId: 'enemy',
|
|
areaTargetSample: {
|
|
center: 'event_target',
|
|
validTargets: oversizedTargets,
|
|
},
|
|
},
|
|
],
|
|
custom(5),
|
|
),
|
|
).toMatchObject({
|
|
status: 'INVALID',
|
|
errors: [expect.stringContaining('cannot contain more than 256')],
|
|
});
|
|
});
|
|
});
|