Support live attribute restoration mechanics
This commit is contained in:
@@ -784,6 +784,140 @@ describe('structured mechanic runtime', () => {
|
||||
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', () => {
|
||||
@@ -866,6 +1000,37 @@ describe('mechanic validation and pseudo-script', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user