feat: add source-owned combat mechanic programs
This commit is contained in:
@@ -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.']
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user