Adding skills & isac talent updates
This commit is contained in:
@@ -5,7 +5,9 @@ import {
|
||||
formatMechanicProgram,
|
||||
validateMechanicProgram,
|
||||
type MechanicEvent,
|
||||
type MechanicProgram
|
||||
type MechanicEventKind,
|
||||
type MechanicProgram,
|
||||
type NumericExpression
|
||||
} from './mechanic.js';
|
||||
|
||||
const custom = (encounterSeconds = 30, inputs: Record<string, number | boolean> = {}) => ({
|
||||
@@ -17,14 +19,153 @@ const custom = (encounterSeconds = 30, inputs: Record<string, number | boolean>
|
||||
describe('structured mechanic runtime', () => {
|
||||
it('advertises the authoritative weapon-hit target and headshot fields', () => {
|
||||
expect(MECHANIC_EVENT_REGISTRY.weapon_hit).toMatchObject({
|
||||
description: expect.stringContaining('headshot boolean'),
|
||||
description: expect.stringContaining('previous target'),
|
||||
fields: [
|
||||
{ path: 'targetId', label: 'Target ID', type: 'string' },
|
||||
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' }
|
||||
{ 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.behindCover', label: 'Agent behind cover', type: 'boolean' },
|
||||
{ path: 'values.distanceMeters', label: 'Target distance', type: 'number' }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
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('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',
|
||||
@@ -1077,6 +1218,242 @@ describe('structured mechanic runtime', () => {
|
||||
});
|
||||
|
||||
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',
|
||||
@@ -1187,6 +1564,213 @@ describe('mechanic validation and pseudo-script', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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('rejects pre-event timing for the derived current Health percentage', () => {
|
||||
const invalid: MechanicProgram = {
|
||||
id: 'invalid-health-percent',
|
||||
name: 'Invalid Health Percent',
|
||||
clauses: [{
|
||||
id: 'invalid',
|
||||
when: 'tick',
|
||||
if: {
|
||||
kind: 'compare',
|
||||
left: { source: 'combat', path: 'currentHealthPercent', timing: 'pre_event' },
|
||||
operator: 'lt',
|
||||
right: 50
|
||||
},
|
||||
reducer: { kind: 'none' },
|
||||
mutations: []
|
||||
}]
|
||||
};
|
||||
const result = validateMechanicProgram(invalid);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('current Health percentage must use current timing')
|
||||
]));
|
||||
}
|
||||
});
|
||||
|
||||
it('formats nested conditions and state operations deterministically', () => {
|
||||
const program: MechanicProgram = {
|
||||
id: 'format',
|
||||
@@ -1221,4 +1805,49 @@ describe('mechanic validation and pseudo-script', () => {
|
||||
'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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user