feat: add source-owned combat mechanic programs

This commit is contained in:
JB
2026-07-27 13:35:00 -04:00
parent 5a67b252aa
commit 4eb754e7c8
62 changed files with 14815 additions and 905 deletions
@@ -0,0 +1,203 @@
import { describe, expect, it } from 'vitest';
import {
parseConditionalMechanicDraft,
type ConditionalMechanicDraftV1
} from './mechanic-editor.js';
const draft = (): ConditionalMechanicDraftV1 => ({
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [{
key: 'effect',
label: 'Effect',
stateKind: 'stack',
scope: 'agent',
initial: 0,
min: 0,
max: 10,
bestCaseValue: 10,
expiresAfterSeconds: 10
}],
clauses: [{
key: 'gain-effect',
label: 'Gain Effect',
event: 'weapon_hit',
recipeKey: 'same_target_hits_within_window',
if: { kind: 'exists', value: { source: 'event', path: 'targetId' } },
reducer: {
kind: 'sliding_count',
threshold: 9,
windowSeconds: 3,
groupBy: { source: 'event', path: 'targetId' }
},
cooldownSeconds: 2,
weight: 1,
mutations: [{ kind: 'add', pool: 'effect', value: 1 }],
outcomes: [{
key: 'weapon-damage',
label: 'Weapon Damage',
phase: 'while_active',
kind: 'attribute_modifier',
scope: 'equipped_weapon',
basis: 'flat',
attributeId: 'weapon-damage',
statePool: 'effect',
perStack: true,
descriptionSlot: 1,
variants: {
I: { value: 3 },
II: { value: 4.2 }
}
}]
}]
});
describe('conditional mechanic authoring payload', () => {
it('builds one validated runtime program per exact source variant', () => {
const result = parseConditionalMechanicDraft(draft(), {
variantKeys: ['I', 'II'],
programId: 'superior-firepower',
programName: 'Superior Firepower',
allowedAttributeIds: new Set(['weapon-damage']),
allowedRecipeKeys: new Set(['same_target_hits_within_window'])
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(Object.keys(result.value.programs)).toEqual(['I', 'II']);
expect(result.value.programs.I.clauses[0]).toMatchObject({
id: 'gain-effect',
when: 'weapon_hit',
recipeKey: 'same_target_hits_within_window',
cooldownSeconds: 2,
weight: 1,
outcomes: [{
id: 'weapon-damage',
scope: 'equipped_weapon',
value: 3
}]
});
expect(result.value.programs.II.clauses[0].outcomes?.[0].value).toBe(4.2);
});
it('preserves Happy Survival expressions, explicit cadence readiness, resources, and combat timing', () => {
const happy: ConditionalMechanicDraftV1 = {
version: 1,
simulationReadiness: 'requires_assumptions',
scenarioInputs: [{
key: 'restoration-events-per-minute',
label: 'Restoration events per minute',
kind: 'number',
readiness: 'requires_assumption',
min: 0
}],
pools: [{
key: 'nearby-enemies',
label: 'Nearby Enemies',
stateKind: 'count',
scope: 'agent',
initial: 0,
min: 0,
max: 20,
bestCaseValue: 5
}],
clauses: [{
key: 'shot-health-exchange',
event: 'shot_fired',
reducer: { kind: 'none' },
mutations: [{ kind: 'none' }],
outcomes: [
{
key: 'health-cost',
label: 'Health Cost',
phase: 'on_event',
kind: 'resource_cost',
resource: 'health',
scope: 'self',
basis: 'max_health_percent',
variants: { base: { value: 2 } }
},
{
key: 'bonus-damage',
label: 'Current Health Bonus Damage',
phase: 'on_event',
kind: 'direct_damage',
scope: 'target',
basis: 'current_health_percent',
combatTiming: 'current',
variants: {
base: {
value: 0.6,
expression: {
op: 'multiply',
left: 0.6,
right: {
op: 'add',
left: 1,
right: {
op: 'divide',
left: {
ref: {
source: 'equipment',
path: 'protocolTalentUpgrades'
}
},
right: 5
}
}
}
}
}
}
]
}]
};
const result = parseConditionalMechanicDraft(happy, {
variantKeys: ['base'],
programName: 'Happy Survival'
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value.programs.base.clauses[0].outcomes).toEqual([
expect.objectContaining({ kind: 'resource_cost', resource: 'health', value: 2 }),
expect.objectContaining({
kind: 'direct_damage',
combatTiming: 'current',
value: expect.objectContaining({
right: expect.objectContaining({
right: expect.objectContaining({
left: {
ref: {
source: 'equipment',
path: 'protocolTalentUpgrades'
}
}
})
})
})
})
]);
});
it('requires the exact configured variants and returns validation errors without throwing', () => {
const invalid = draft();
invalid.clauses[0].outcomes[0].variants = {
I: { value: 3 },
legacy: { value: 99 }
};
const result = parseConditionalMechanicDraft(invalid, { variantKeys: ['I', 'II'] });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors).toContain(
'Clause 1 outcome 1 must define exactly these variants: I, II.'
);
}
expect(() => parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] }))
.not.toThrow();
expect(parseConditionalMechanicDraft('{not-json', { variantKeys: ['base'] }))
.toEqual({
ok: false,
errors: ['The conditional mechanic contains malformed JSON.']
});
});
});