feat: support extra health state mechanics
This commit is contained in:
@@ -62,6 +62,138 @@ describe('structured mechanic runtime', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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('executes imported talent trigger families as first-class events', () => {
|
||||
const eventKinds: MechanicEventKind[] = [
|
||||
'ally_downed',
|
||||
|
||||
Reference in New Issue
Block a user