Support target-scoped trauma mechanics

This commit is contained in:
JB
2026-07-27 22:20:52 -04:00
parent 7e78d75f85
commit 0b3be3587b
20 changed files with 1032 additions and 93 deletions
@@ -78,7 +78,7 @@ describe('AI mechanic authoring prompt', () => {
recipeKeys: []
}));
expect(AI_MECHANIC_PROMPT_VERSION).toBe(4);
expect(AI_MECHANIC_PROMPT_VERSION).toBe(5);
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
expect.arrayContaining(['delivery', 'durationSeconds'])
);
@@ -115,7 +115,7 @@ describe('AI mechanic authoring prompt', () => {
expect.stringContaining('Do not replace continuous delivery with a periodic reducer')
]));
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
'Prompt contract version: 4'
'Prompt contract version: 5'
);
const advertised = structuredClone(
@@ -156,4 +156,89 @@ describe('AI mechanic authoring prompt', () => {
});
expect(parsed.ok).toBe(true);
});
it('advertises Precise Trauma through target expiry without inventing target tick iteration', () => {
const skillDamageId = 'skill-damage-catalog-id';
const input = JSON.parse(buildAiMechanicAuthoringInput({
request: {
...request,
context: {
...request.context,
sourceName: 'Precise Trauma',
sourceDescription:
'Hits apply a debuff for 1 second, up to 10 stacks. Body shots add 1 stack and headshots add 2 stacks.'
}
},
attributes: [{
id: skillDamageId,
name: 'Skill Damage',
description: null,
unit: null,
type: null,
category: null,
calculationType: null
}],
events: [{ key: 'weapon_hit', label: 'Weapon hit' }],
actions: [
{ key: 'body_shot', label: 'Body shot' },
{ key: 'headshot', label: 'Headshot' }
],
recipeKeys: ['body_shot', 'headshot']
}));
const advertised = structuredClone(input.canonicalCatalog.grammar.preciseTraumaShape);
const outcome = advertised.clauses[0].outcomes[0];
const baseVariant = JSON.parse(JSON.stringify(
outcome.variants['replace-with-each-required-variant-key']
).replaceAll(
'replace-with-the-exact-skill-damage-catalog-id',
skillDamageId
));
outcome.variants = {
I: structuredClone(baseVariant),
II: structuredClone(baseVariant)
};
expect(advertised.pools[0]).toMatchObject({
scope: 'target',
max: 10,
expiresAfterSeconds: 1
});
expect(advertised.clauses[0].mutations[0]).toEqual({
kind: 'add',
pool: 'trauma',
entity: { kind: 'event_target' },
value: 1
});
expect(advertised.clauses[1].mutations[0]).toMatchObject({ value: 2 });
expect(outcome).toMatchObject({
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
statePool: 'trauma',
perStack: true,
variants: {
I: {
expression: {
ref: {
source: 'attribute',
attributeId: skillDamageId
}
}
}
}
});
expect(input.examplesAndRules).toEqual(expect.arrayContaining([
expect.stringContaining('uses the entity previous state'),
expect.stringContaining('without inventing generic repeated target ticks')
]));
expect(JSON.stringify(input.canonicalCatalog.grammar.preciseTraumaShape))
.not.toContain('entitiesFromPool');
const parsed = parseConditionalMechanicDraft(advertised, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set([skillDamageId]),
allowedRecipeKeys: new Set(['body_shot', 'headshot'])
});
expect(parsed.ok).toBe(true);
});
});
+106 -2
View File
@@ -2,7 +2,7 @@ import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring';
import { CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES } from '$lib/calculations/mechanic-editor';
import type { AiMechanicAttribute } from './mechanic-authoring-context';
export const AI_MECHANIC_PROMPT_VERSION = 4;
export const AI_MECHANIC_PROMPT_VERSION = 5;
const mechanicsGrammar = {
version: 1,
@@ -19,7 +19,9 @@ const mechanicsGrammar = {
scope: ['agent', 'target'],
optional: ['min', 'max', 'bestCaseValue', 'expiresAfterSeconds'],
expirationSemantics:
'expiresAfterSeconds is one shared pool timer. Every write to the pool resets that timer for the entire pool, and expiry resets the full pool to its initial value.'
'expiresAfterSeconds is one timer per pool entity. Every write resets that entity timer, and expiry resets only that entity to the pool initial value.',
targetScopeSemantics:
'A target-scoped pool stores one independent value and expiry timer per resolved entity. Use mutation.entity event_target when a weapon-hit mechanic binds state to the enemy that was hit.'
},
clause: {
required: ['key', 'event', 'if', 'reducer', 'mutations', 'outcomes'],
@@ -94,6 +96,12 @@ const mechanicsGrammar = {
mutationShapes: [
{ kind: 'none' },
{ kind: 'add', pool: 'stacks', value: 1 },
{
kind: 'add',
pool: 'target-stacks',
entity: { kind: 'event_target' },
value: 1
},
{ kind: 'set', pool: 'active', value: 1 },
{ kind: 'consume', pool: 'stacks', value: 1 },
{ kind: 'derive', pool: 'nearby-enemies', value: { ref: { source: 'reducer' } } },
@@ -138,6 +146,98 @@ const mechanicsGrammar = {
}
}
},
preciseTraumaShape: {
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [{
key: 'trauma',
label: 'Trauma',
stateKind: 'stack',
scope: 'target',
initial: 0,
min: 0,
max: 10,
bestCaseValue: 10,
expiresAfterSeconds: 1
}],
clauses: [
{
key: 'body-shot-trauma',
label: 'Body shot adds one Trauma stack',
recipeKey: 'body_shot',
event: '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: false
}
]
},
reducer: { kind: 'none' },
mutations: [{
kind: 'add',
pool: 'trauma',
entity: { kind: 'event_target' },
value: 1
}],
outcomes: [{
key: 'trauma-skill-damage',
label: 'Trauma Skill Damage',
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
statePool: 'trauma',
perStack: true,
variants: {
'replace-with-each-required-variant-key': {
value: 0,
expression: {
ref: {
source: 'attribute',
attributeId: 'replace-with-the-exact-skill-damage-catalog-id'
}
}
}
}
}]
},
{
key: 'headshot-trauma',
label: 'Headshot adds two Trauma stacks',
recipeKey: 'headshot',
event: '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: true
}
]
},
reducer: { kind: 'none' },
mutations: [{
kind: 'add',
pool: 'trauma',
entity: { kind: 'event_target' },
value: 2
}],
outcomes: []
}
]
},
variantValue: {
required: ['value'],
optional: ['expression']
@@ -241,6 +341,10 @@ export function buildAiMechanicAuthoringInput(input: {
'An Attribute whose catalog unit is % resolves to its displayed percentage number, such as 20 for 20%. Divide that reference by 100 when wording applies it as a percentage multiplier, such as 240 × 20%.',
'Continuous Health restoration is supported. Use kind:"restoration", resource:"health", delivery:"continuous", and a positive durationSeconds. The variant expression resolves the total restoration across that duration, not a per-second value. Do not replace continuous delivery with a periodic reducer.',
'Each continuous-restoration activation creates an independent constant-rate stream. Overlapping streams add, and restoration above the resource maximum is lost when it occurs.',
'A target-scoped pool owns an independent value and expiry timer for each entity. Bind hit-created target state with entity:{kind:"event_target"}; do not collapse different enemies into one Agent-scoped pool.',
'Writing a target-scoped pool refreshes only that target entity timer. An on_exit outcome runs when that entity expires and uses the entity previous state for stack bands and perStack multiplication, even though the stored pool value has already reset.',
'When an outcome value is exactly a live Attribute amount, use the Attribute reference directly as the variant expression. Divide by 100 only when a displayed percentage is being used as a multiplier.',
'Precise Trauma is representable as one target-scoped stack pool with a one-second expiry: body shots add one stack, headshots add two, and one on_exit direct-damage outcome uses the expiring target previous stack count. This models the stated one-second debuff without inventing generic repeated target ticks.',
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
'Assigned-ability scope may be used only when allowAssignedAbility is true.',
'If cadence is not stated, ask rather than assuming a one-second interval.',
+97 -3
View File
@@ -182,14 +182,27 @@ describe('conditional mechanic persistence adapter', () => {
version: 1,
simulationReadiness: 'ready',
scenarioInputs: [],
pools: [],
pools: [{
key: 'marked-target',
label: 'Marked Target',
stateKind: 'target_mark',
scope: 'target',
initial: 0,
min: 0,
max: 1
}],
clauses: [{
key: 'weapon-kill',
label: 'Weapon kill',
event: 'enemy_killed',
if: { kind: 'true' },
reducer: { kind: 'none' },
mutations: [],
mutations: [{
kind: 'set',
pool: 'marked-target',
entity: { kind: 'event_target' },
value: 1
}],
outcomes: [{
key: 'restore-health',
label: 'Restore Health',
@@ -204,6 +217,51 @@ describe('conditional mechanic persistence adapter', () => {
I: { value: 240 },
II: { value: 336 }
}
}, {
key: 'target-detonation',
label: 'Target Detonation',
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
basis: 'flat',
statePool: 'marked-target',
perStack: true,
variants: {
I: {
value: 240,
expression: {
op: 'multiply',
left: 240,
right: {
op: 'divide',
left: {
ref: {
source: 'attribute',
attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e'
}
},
right: 100
}
}
},
II: {
value: 336,
expression: {
op: 'multiply',
left: 336,
right: {
op: 'divide',
left: {
ref: {
source: 'attribute',
attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e'
}
},
right: 100
}
}
}
}
}]
}]
});
@@ -213,7 +271,9 @@ describe('conditional mechanic persistence adapter', () => {
)).all();
expect(saved).toEqual([
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 },
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 }
{ delivery_mode: 'instant', delivery_duration_seconds: null },
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 },
{ delivery_mode: 'instant', delivery_duration_seconds: null }
]);
const loaded = await loadConditionalRuleDraft(
database,
@@ -229,5 +289,39 @@ describe('conditional mechanic persistence adapter', () => {
II: { value: 336 }
}
});
expect(loaded?.pools[0]).toMatchObject({
key: 'marked-target',
stateKind: 'target_mark',
scope: 'target'
});
expect(loaded?.clauses[0].mutations).toEqual([{
kind: 'set',
pool: 'marked-target',
entity: { kind: 'event_target' },
value: 1
}]);
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
phase: 'on_exit',
kind: 'direct_damage',
scope: 'target',
statePool: 'marked-target',
perStack: true,
variants: {
I: {
value: 240,
expression: expect.objectContaining({
right: expect.objectContaining({
left: {
ref: {
source: 'attribute',
attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e'
}
}
})
})
},
II: { value: 336 }
}
});
});
});