Support target-scoped trauma mechanics
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user