Adding skills & isac talent updates

This commit is contained in:
jb
2026-07-29 20:56:22 -04:00
parent f1b473ffa8
commit 65a4987cad
68 changed files with 5486 additions and 569 deletions
+632 -3
View File
@@ -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');
});
});