Support target-scoped trauma mechanics
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MECHANIC_EVENT_REGISTRY,
|
||||
evaluateMechanicProgram,
|
||||
formatMechanicProgram,
|
||||
validateMechanicProgram,
|
||||
@@ -14,6 +15,16 @@ 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'),
|
||||
fields: [
|
||||
{ path: 'targetId', label: 'Target ID', type: 'string' },
|
||||
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('models Superior Firepower as same-target sliding hits and one shared expiring stack pool', () => {
|
||||
const superiorFirepower: MechanicProgram = {
|
||||
id: 'superior-firepower',
|
||||
@@ -512,6 +523,151 @@ describe('structured mechanic runtime', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user