Support target-scoped trauma mechanics
This commit is contained in:
@@ -43,7 +43,7 @@ const shot = (
|
||||
|
||||
describe('trigger action registry', () => {
|
||||
it('owns every action and validates discriminated parameters', () => {
|
||||
expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(15);
|
||||
expect(Object.keys(TRIGGER_ACTION_REGISTRY)).toHaveLength(16);
|
||||
expect(validateTriggerConfiguration('same_target_hits_within_window', {
|
||||
hitsRequired: 9,
|
||||
windowSeconds: 3
|
||||
@@ -197,6 +197,18 @@ describe('evaluateConditionalProgram', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an explicit non-headshot hit as a body shot', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'body_shot',
|
||||
params: {}
|
||||
}), [
|
||||
shot(1, 'alpha', true, false),
|
||||
shot(2, 'alpha', true, true),
|
||||
shot(3, 'alpha', false, false)
|
||||
], { mode: 'encounter', encounterSeconds: 5 });
|
||||
expect(result.activations.map((activation) => activation.at)).toEqual([1]);
|
||||
});
|
||||
|
||||
it('refreshes one shared duration and clears every stack on expiry', () => {
|
||||
const result = evaluateConditionalProgram(program({
|
||||
action: 'damage_taken',
|
||||
|
||||
@@ -6,6 +6,7 @@ export const TRIGGER_ACTION_KEYS = [
|
||||
'while_firing',
|
||||
'health_threshold_bands',
|
||||
'headshot',
|
||||
'body_shot',
|
||||
'headshot_kill',
|
||||
'weapon_kill',
|
||||
'target_hit_within_distance',
|
||||
@@ -52,6 +53,7 @@ export type TriggerParamsByAction = {
|
||||
while_firing: EmptyTriggerParams;
|
||||
health_threshold_bands: HealthThresholdParams;
|
||||
headshot: EmptyTriggerParams;
|
||||
body_shot: EmptyTriggerParams;
|
||||
headshot_kill: EmptyTriggerParams;
|
||||
weapon_kill: EmptyTriggerParams;
|
||||
target_hit_within_distance: DistanceParams;
|
||||
@@ -374,6 +376,12 @@ export const TRIGGER_ACTION_REGISTRY: Readonly<Record<TriggerActionKey, TriggerA
|
||||
validate: validateEmpty,
|
||||
summarize: () => 'Land a headshot.'
|
||||
},
|
||||
body_shot: {
|
||||
key: 'body_shot',
|
||||
label: 'Body shot',
|
||||
validate: validateEmpty,
|
||||
summarize: () => 'Land a non-headshot weapon hit.'
|
||||
},
|
||||
headshot_kill: {
|
||||
key: 'headshot_kill',
|
||||
label: 'Headshot kill',
|
||||
@@ -611,6 +619,10 @@ function eventCandidates(
|
||||
return events
|
||||
.filter((event): event is ShotEvent => event.kind === 'shot' && event.hit && event.headshot === true)
|
||||
.map((event) => ({ at: event.at, outcomeKey: null }));
|
||||
case 'body_shot':
|
||||
return events
|
||||
.filter((event): event is ShotEvent => event.kind === 'shot' && event.hit && event.headshot === false)
|
||||
.map((event) => ({ at: event.at, outcomeKey: null }));
|
||||
case 'headshot_kill':
|
||||
return events
|
||||
.filter((event): event is EnemyKilledEvent => event.kind === 'enemy_killed' && event.headshot === true)
|
||||
|
||||
@@ -54,6 +54,41 @@ const draft = (): ConditionalMechanicDraftV1 => ({
|
||||
});
|
||||
|
||||
describe('conditional mechanic authoring payload', () => {
|
||||
it('accepts an empty captured-only program for deferred modeling', () => {
|
||||
const partial: ConditionalMechanicDraftV1 = {
|
||||
version: 1,
|
||||
simulationReadiness: 'captured_only',
|
||||
scenarioInputs: [],
|
||||
pools: [],
|
||||
clauses: []
|
||||
};
|
||||
const result = parseConditionalMechanicDraft(partial, {
|
||||
variantKeys: ['I', 'II']
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.draft).toEqual(partial);
|
||||
expect(result.value.programs.I.clauses).toEqual([]);
|
||||
expect(result.value.programs.II.clauses).toEqual([]);
|
||||
});
|
||||
|
||||
it('requires a branch after a partial program is marked calculation-ready', () => {
|
||||
const partial: ConditionalMechanicDraftV1 = {
|
||||
version: 1,
|
||||
simulationReadiness: 'ready',
|
||||
scenarioInputs: [],
|
||||
pools: [],
|
||||
clauses: []
|
||||
};
|
||||
const result = parseConditionalMechanicDraft(partial, {
|
||||
variantKeys: ['I', 'II']
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors).toContain('A simulation-ready mechanic requires at least one clause.');
|
||||
}
|
||||
});
|
||||
|
||||
it('builds one validated runtime program per exact source variant', () => {
|
||||
const result = parseConditionalMechanicDraft(draft(), {
|
||||
variantKeys: ['I', 'II'],
|
||||
@@ -263,6 +298,98 @@ describe('conditional mechanic authoring payload', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves target entity mutations and state-scaled exit outcomes', () => {
|
||||
const targeted: ConditionalMechanicDraftV1 = {
|
||||
version: 1,
|
||||
simulationReadiness: 'ready',
|
||||
scenarioInputs: [],
|
||||
pools: [{
|
||||
key: 'marked-target',
|
||||
label: 'Marked Target',
|
||||
stateKind: 'target_mark',
|
||||
scope: 'target',
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 1
|
||||
}],
|
||||
clauses: [{
|
||||
key: 'detonate-mark',
|
||||
event: 'enemy_killed',
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [{
|
||||
kind: 'set',
|
||||
pool: 'marked-target',
|
||||
entity: { kind: 'event_target' },
|
||||
value: 1
|
||||
}],
|
||||
outcomes: [{
|
||||
key: 'target-damage',
|
||||
label: 'Target Damage',
|
||||
phase: 'on_exit',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
basis: 'flat',
|
||||
statePool: 'marked-target',
|
||||
perStack: true,
|
||||
variants: {
|
||||
base: {
|
||||
value: 240,
|
||||
expression: {
|
||||
op: 'multiply',
|
||||
left: 240,
|
||||
right: {
|
||||
op: 'divide',
|
||||
left: {
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: 'firepower-multiplier'
|
||||
}
|
||||
},
|
||||
right: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
};
|
||||
const result = parseConditionalMechanicDraft(targeted, {
|
||||
variantKeys: ['base'],
|
||||
allowedAttributeIds: new Set(['firepower-multiplier'])
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.draft).toMatchObject(targeted);
|
||||
expect(result.value.programs.base).toMatchObject({
|
||||
pools: [{ key: 'marked-target', scope: 'target' }],
|
||||
clauses: [{
|
||||
mutations: [{
|
||||
kind: 'set',
|
||||
pool: 'marked-target',
|
||||
entity: { kind: 'event_target' },
|
||||
value: 1
|
||||
}],
|
||||
outcomes: [{
|
||||
phase: 'on_exit',
|
||||
kind: 'direct_damage',
|
||||
statePool: 'marked-target',
|
||||
perStack: true,
|
||||
value: {
|
||||
right: {
|
||||
left: {
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: 'firepower-multiplier'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects outcome fields that are absent from the canonical capability manifest', () => {
|
||||
const unsupported = draft();
|
||||
(unsupported.clauses[0].outcomes[0] as unknown as Record<string, unknown>).cadence =
|
||||
|
||||
@@ -215,6 +215,113 @@ describe('conditional-effect persistence adapter', () => {
|
||||
expect(result.errors).toContain('Outcome 1 continuous delivery requires a positive duration.');
|
||||
});
|
||||
|
||||
it('preserves target pool mutations, exit outcomes, and live Attribute references', () => {
|
||||
const firepowerMultiplierId = '82a95611-283b-4fd8-a6ed-3567a316f36e';
|
||||
const result = adaptConditionalEffectProgram({
|
||||
rule: rule(),
|
||||
statePools: [pool({
|
||||
state_key: 'marked-target',
|
||||
label: 'Marked Target',
|
||||
state_kind: 'target_mark',
|
||||
state_scope: 'target',
|
||||
maximum_value: 1,
|
||||
best_case_value: 1
|
||||
})],
|
||||
clauses: [clause({
|
||||
trigger_id: null,
|
||||
event_kind: 'enemy_killed',
|
||||
parameters_json: '{}',
|
||||
mutations_json: JSON.stringify([{
|
||||
kind: 'set',
|
||||
pool: 'marked-target',
|
||||
entity: { kind: 'event_target' },
|
||||
value: 1
|
||||
}])
|
||||
})],
|
||||
outcomes: [outcome({
|
||||
effect_key: 'marked-target-detonation',
|
||||
application_kind: 'direct_damage',
|
||||
effect_scope: 'target',
|
||||
stat_attribute_id: null,
|
||||
value: 240,
|
||||
value_expression_json: JSON.stringify({
|
||||
op: 'multiply',
|
||||
left: 240,
|
||||
right: {
|
||||
op: 'divide',
|
||||
left: {
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: firepowerMultiplierId
|
||||
}
|
||||
},
|
||||
right: 100
|
||||
}
|
||||
}),
|
||||
value_scaling: 'per_state_unit',
|
||||
damage_basis: 'flat',
|
||||
application_phase: 'on_exit',
|
||||
min_state_value: null
|
||||
})],
|
||||
triggers: []
|
||||
}, { name: 'Target Detonation', variantKey: 'I' });
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.program?.pools?.[0]).toMatchObject({
|
||||
key: 'marked-target',
|
||||
scope: 'target'
|
||||
});
|
||||
expect(result.program?.clauses[0]).toMatchObject({
|
||||
mutations: [{
|
||||
kind: 'set',
|
||||
pool: 'marked-target',
|
||||
entity: { kind: 'event_target' },
|
||||
value: 1
|
||||
}],
|
||||
outcomes: [{
|
||||
phase: 'on_exit',
|
||||
kind: 'direct_damage',
|
||||
scope: 'target',
|
||||
statePool: 'marked-target',
|
||||
perStack: true,
|
||||
value: {
|
||||
op: 'multiply',
|
||||
left: 240,
|
||||
right: {
|
||||
op: 'divide',
|
||||
left: {
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: firepowerMultiplierId
|
||||
}
|
||||
},
|
||||
right: 100
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed mutation entity selectors from persisted JSON', () => {
|
||||
const result = adaptConditionalEffectProgram({
|
||||
rule: rule(),
|
||||
statePools: [pool({ state_scope: 'target' })],
|
||||
clauses: [clause({
|
||||
mutations_json: JSON.stringify([{
|
||||
kind: 'set',
|
||||
pool: 'effect',
|
||||
entity: { kind: 'event_field', path: '' },
|
||||
value: 1
|
||||
}])
|
||||
})],
|
||||
outcomes: [outcome()],
|
||||
triggers: [{ id: 'trigger-1', action_key: 'same_target_hits_within_window' }]
|
||||
}, { variantKey: 'I' });
|
||||
|
||||
expect(result.status).toBe('INVALID');
|
||||
expect(result.errors).toContain('Clause 1 mutations must be an array of valid state mutations.');
|
||||
});
|
||||
|
||||
it('adapts the Happy Survival branches, explicit cadence, health timing, and upgrade references', () => {
|
||||
const upgradeScale = {
|
||||
op: 'add',
|
||||
|
||||
@@ -174,20 +174,43 @@ function requiredNumber(value: DatabaseValue, path: string, errors: string[]) {
|
||||
return result ?? 0;
|
||||
}
|
||||
|
||||
function isEntitySelector(value: unknown) {
|
||||
if (!object(value) || typeof value.kind !== 'string') return false;
|
||||
if (value.kind === 'agent' || value.kind === 'event_actor' || value.kind === 'event_target') {
|
||||
return true;
|
||||
}
|
||||
return value.kind === 'event_field'
|
||||
&& typeof value.path === 'string'
|
||||
&& Boolean(value.path.trim());
|
||||
}
|
||||
|
||||
function isValueReference(value: unknown) {
|
||||
if (!object(value) || typeof value.source !== 'string') return false;
|
||||
if (value.source === 'event' || value.source === 'equipment') {
|
||||
return typeof value.path === 'string' && Boolean(value.path.trim());
|
||||
}
|
||||
if (value.source === 'combat') {
|
||||
return typeof value.path === 'string'
|
||||
&& Boolean(value.path.trim())
|
||||
&& (value.timing === undefined || value.timing === 'pre_event' || value.timing === 'current');
|
||||
}
|
||||
if (value.source === 'attribute') {
|
||||
return typeof value.attributeId === 'string' && Boolean(value.attributeId.trim());
|
||||
}
|
||||
if (value.source === 'scenario') {
|
||||
return typeof value.key === 'string' && Boolean(value.key.trim());
|
||||
}
|
||||
if (value.source === 'reducer') return true;
|
||||
return value.source === 'pool'
|
||||
&& typeof value.pool === 'string'
|
||||
&& Boolean(value.pool.trim())
|
||||
&& (value.entity === undefined || isEntitySelector(value.entity));
|
||||
}
|
||||
|
||||
function isNumericExpression(value: unknown): value is NumericExpression {
|
||||
if (finite(value)) return true;
|
||||
if (!object(value)) return false;
|
||||
if ('ref' in value) {
|
||||
if (!object(value.ref) || typeof value.ref.source !== 'string') return false;
|
||||
const source = value.ref.source;
|
||||
if (source === 'event' || source === 'combat' || source === 'equipment') {
|
||||
return typeof value.ref.path === 'string';
|
||||
}
|
||||
if (source === 'scenario') return typeof value.ref.key === 'string';
|
||||
if (source === 'reducer') return true;
|
||||
if (source === 'pool') return typeof value.ref.pool === 'string';
|
||||
return false;
|
||||
}
|
||||
if ('ref' in value) return isValueReference(value.ref);
|
||||
if (value.op === 'clamp') {
|
||||
return isNumericExpression(value.value)
|
||||
&& isNumericExpression(value.min)
|
||||
@@ -233,14 +256,20 @@ function isReducer(value: unknown): value is Reducer {
|
||||
function isMutation(value: unknown): value is ClauseMutation {
|
||||
if (!object(value) || typeof value.kind !== 'string') return false;
|
||||
if (value.kind === 'none') return true;
|
||||
if (value.kind === 'clear') return typeof value.pool === 'string';
|
||||
if (value.kind === 'clear') {
|
||||
return typeof value.pool === 'string'
|
||||
&& (value.entity === undefined || isEntitySelector(value.entity));
|
||||
}
|
||||
if (value.kind === 'transfer') {
|
||||
return typeof value.pool === 'string' && object(value.from) && object(value.to)
|
||||
return typeof value.pool === 'string'
|
||||
&& isEntitySelector(value.from)
|
||||
&& isEntitySelector(value.to)
|
||||
&& (value.value === undefined || value.value === 'all' || isNumericExpression(value.value));
|
||||
}
|
||||
return ['add', 'set', 'consume', 'derive'].includes(value.kind)
|
||||
&& typeof value.pool === 'string'
|
||||
&& isNumericExpression(value.value);
|
||||
&& isNumericExpression(value.value)
|
||||
&& (value.entity === undefined || isEntitySelector(value.entity));
|
||||
}
|
||||
|
||||
function parseScenarioInputs(
|
||||
|
||||
@@ -25,6 +25,7 @@ const paramsByAction: Record<TriggerActionKey, unknown> = {
|
||||
]
|
||||
},
|
||||
headshot: {},
|
||||
body_shot: {},
|
||||
headshot_kill: {},
|
||||
weapon_kill: {},
|
||||
target_hit_within_distance: { meters: 15 },
|
||||
@@ -96,6 +97,27 @@ describe('mechanic recipe registry', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('maps head and body shots to authoritative weapon-hit predicates', () => {
|
||||
for (const [action, headshot] of [['headshot', true], ['body_shot', false]] as const) {
|
||||
expect(seedFor(action).clauses[0]).toMatchObject({
|
||||
when: 'weapon_hit',
|
||||
if: {
|
||||
kind: 'all',
|
||||
predicates: [
|
||||
{ kind: 'exists', value: { source: 'event', path: 'targetId' } },
|
||||
{ kind: 'exists', value: { source: 'event', path: 'values.headshot' } },
|
||||
{
|
||||
kind: 'compare',
|
||||
left: { source: 'event', path: 'values.headshot' },
|
||||
operator: 'eq',
|
||||
right: headshot
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('models firing triggers as explicit lifecycle branches', () => {
|
||||
const continuous = seedFor('continuous_firing_duration');
|
||||
expect(continuous.pools).toEqual([expect.objectContaining({ key: 'firing-active', max: 1 })]);
|
||||
|
||||
@@ -251,7 +251,23 @@ export const MECHANIC_RECIPE_REGISTRY: Readonly<MechanicRecipeRegistry> = {
|
||||
headshot: {
|
||||
key: 'headshot',
|
||||
createSeed: (_params) => supported({ clauses: [directClause('headshot', 'weapon_hit', {
|
||||
kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true
|
||||
kind: 'all',
|
||||
predicates: [
|
||||
targetExists,
|
||||
{ kind: 'exists', value: eventValue('headshot') },
|
||||
{ kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: true }
|
||||
]
|
||||
})] })
|
||||
},
|
||||
body_shot: {
|
||||
key: 'body_shot',
|
||||
createSeed: (_params) => supported({ clauses: [directClause('body-shot', 'weapon_hit', {
|
||||
kind: 'all',
|
||||
predicates: [
|
||||
targetExists,
|
||||
{ kind: 'exists', value: eventValue('headshot') },
|
||||
{ kind: 'compare', left: eventValue('headshot'), operator: 'eq', right: false }
|
||||
]
|
||||
})] })
|
||||
},
|
||||
headshot_kill: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,6 +25,11 @@ export type MechanicEventDefinition = {
|
||||
key: MechanicEventKind;
|
||||
label: string;
|
||||
description: string;
|
||||
fields?: readonly {
|
||||
path: string;
|
||||
label: string;
|
||||
type: 'boolean' | 'number' | 'string';
|
||||
}[];
|
||||
};
|
||||
|
||||
export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, MechanicEventDefinition>> = {
|
||||
@@ -36,7 +41,11 @@ export const MECHANIC_EVENT_REGISTRY: Readonly<Record<MechanicEventKind, Mechani
|
||||
weapon_hit: {
|
||||
key: 'weapon_hit',
|
||||
label: 'Weapon hit',
|
||||
description: 'A weapon attack connects with a target. Critical and distance details belong in event values.'
|
||||
description: 'A weapon attack connects with a target. The headshot boolean distinguishes headshots from body shots.',
|
||||
fields: [
|
||||
{ path: 'targetId', label: 'Target ID', type: 'string' },
|
||||
{ path: 'values.headshot', label: 'Headshot', type: 'boolean' }
|
||||
]
|
||||
},
|
||||
skill_deployed: {
|
||||
key: 'skill_deployed',
|
||||
@@ -326,6 +335,7 @@ export type MechanicOutcomeEmission = {
|
||||
clauseId: string;
|
||||
outcomeId: string;
|
||||
at: number;
|
||||
targetId: string | null;
|
||||
phase: OutcomePhase;
|
||||
kind: MechanicOutcome['kind'];
|
||||
scope: MechanicOutcome['scope'];
|
||||
@@ -401,6 +411,7 @@ type EvaluationContext = {
|
||||
type ContinuousDelivery = {
|
||||
clauseId: string;
|
||||
outcomeId: string;
|
||||
targetId: string | null;
|
||||
phase: OutcomePhase;
|
||||
scope: MechanicOutcomeScope;
|
||||
resource: MechanicResource;
|
||||
@@ -895,14 +906,22 @@ function emitOutcomes(
|
||||
continuousDeliveries?: ContinuousDelivery[]
|
||||
): MechanicOutcomeEmission[] {
|
||||
const emissions: MechanicOutcomeEmission[] = [];
|
||||
const stateValues = (outcome: MechanicOutcome) => {
|
||||
const current = outcomeState(outcome, context);
|
||||
const previous = outcome.statePool ? before.get(outcome.statePool) ?? current : current;
|
||||
return {
|
||||
current,
|
||||
previous,
|
||||
effective: outcome.phase === 'on_exit' ? previous : current
|
||||
};
|
||||
};
|
||||
const eligible = (clause.outcomes ?? []).filter((outcome) => {
|
||||
if (phase === 'before' && outcome.phase !== 'on_event') return false;
|
||||
if (phase === 'after' && outcome.phase === 'on_event') return false;
|
||||
const current = outcomeState(outcome, context);
|
||||
const previous = outcome.statePool ? before.get(outcome.statePool) ?? current : current;
|
||||
const { current, previous, effective } = stateValues(outcome);
|
||||
if (!phaseMatches(outcome.phase, previous, current)) return false;
|
||||
if (outcome.minStacks !== undefined && current < outcome.minStacks) return false;
|
||||
if (outcome.maxStacks !== undefined && current > outcome.maxStacks) return false;
|
||||
if (outcome.minStacks !== undefined && effective < outcome.minStacks) return false;
|
||||
if (outcome.maxStacks !== undefined && effective > outcome.maxStacks) return false;
|
||||
return true;
|
||||
}).sort((left, right) => {
|
||||
const priority = (outcome: MechanicOutcome) =>
|
||||
@@ -910,7 +929,7 @@ function emitOutcomes(
|
||||
return priority(left) - priority(right);
|
||||
});
|
||||
for (const outcome of eligible) {
|
||||
const current = outcomeState(outcome, context);
|
||||
const { effective: current } = stateValues(outcome);
|
||||
const baseValue = numericValue(outcome.value, context);
|
||||
const value = outcome.perStack ? baseValue * current : baseValue;
|
||||
const resolvedValue = resolveOutcomeValue(outcome, value, context);
|
||||
@@ -925,6 +944,7 @@ function emitOutcomes(
|
||||
continuousDeliveries?.push({
|
||||
clauseId: clause.id,
|
||||
outcomeId: outcome.id,
|
||||
targetId: context.event.targetId ?? null,
|
||||
phase: outcome.phase,
|
||||
scope: outcome.scope,
|
||||
resource: outcomeResource(outcome),
|
||||
@@ -957,6 +977,7 @@ function emitOutcomes(
|
||||
clauseId: clause.id,
|
||||
outcomeId: outcome.id,
|
||||
at,
|
||||
targetId: context.event.targetId ?? null,
|
||||
phase: outcome.phase,
|
||||
kind: outcome.kind,
|
||||
scope: outcome.scope,
|
||||
@@ -1007,6 +1028,7 @@ function advanceContinuousDeliveries(
|
||||
clauseId: delivery.clauseId,
|
||||
outcomeId: delivery.outcomeId,
|
||||
at: through,
|
||||
targetId: delivery.targetId,
|
||||
phase: delivery.phase,
|
||||
kind: 'restoration',
|
||||
scope: delivery.scope,
|
||||
|
||||
Reference in New Issue
Block a user