272 lines
7.8 KiB
TypeScript
272 lines
7.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring';
|
|
import { parseConditionalMechanicDraft } from '$lib/calculations/mechanic-editor';
|
|
import {
|
|
AI_MECHANIC_AUTHORING_INSTRUCTIONS,
|
|
AI_MECHANIC_PROMPT_VERSION,
|
|
buildAiMechanicAuthoringInput
|
|
} from './mechanic-authoring-prompt';
|
|
|
|
const request: AiMechanicAuthoringRequest = {
|
|
context: {
|
|
sourceKind: 'talent',
|
|
sourceName: 'Predatory Aura',
|
|
sourceDescription: 'Restore Health based on the current Firepower Multiplier.'
|
|
},
|
|
currentDraft: {
|
|
version: 1,
|
|
simulationReadiness: 'captured_only',
|
|
scenarioInputs: [],
|
|
pools: [],
|
|
clauses: []
|
|
},
|
|
valueColumns: [
|
|
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
|
|
{ key: 'tierII', variantKey: 'II', label: 'TIER II' }
|
|
],
|
|
allowAssignedAbility: false,
|
|
allowProtocolUpgradeScaling: false,
|
|
transcript: []
|
|
};
|
|
|
|
describe('AI mechanic authoring prompt', () => {
|
|
it('advertises exact catalog Attribute references and displayed-percent semantics', () => {
|
|
const input = JSON.parse(buildAiMechanicAuthoringInput({
|
|
request,
|
|
attributes: [{
|
|
id: 'firepower-multiplier',
|
|
name: 'Firepower Multiplier',
|
|
description: null,
|
|
unit: '%',
|
|
type: null,
|
|
category: null,
|
|
calculationType: null
|
|
}],
|
|
events: [],
|
|
actions: [],
|
|
recipeKeys: []
|
|
}));
|
|
|
|
expect(input.canonicalCatalog.grammar.valueReferences).toContain('attribute');
|
|
expect(input.canonicalCatalog.grammar.referenceShapes).toContainEqual({
|
|
ref: {
|
|
source: 'attribute',
|
|
attributeId: 'replace-with-an-exact-catalog-attribute-id'
|
|
}
|
|
});
|
|
expect(input.examplesAndRules).toEqual(expect.arrayContaining([
|
|
expect.stringContaining('exact supplied catalog ID'),
|
|
expect.stringContaining('Divide that reference by 100')
|
|
]));
|
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain('Never invent an Attribute ID');
|
|
});
|
|
|
|
it('advertises continuous restoration as a supported outcome capability', () => {
|
|
const input = JSON.parse(buildAiMechanicAuthoringInput({
|
|
request,
|
|
attributes: [{
|
|
id: '1bce573c-80f7-4487-b1d3-c4dc598b5244',
|
|
name: 'Firepower Multiplier',
|
|
description: null,
|
|
unit: '%',
|
|
type: null,
|
|
category: null,
|
|
calculationType: null
|
|
}],
|
|
events: [{ key: 'enemy_killed', label: 'Enemy killed' }],
|
|
actions: [],
|
|
recipeKeys: []
|
|
}));
|
|
|
|
expect(AI_MECHANIC_PROMPT_VERSION).toBe(6);
|
|
expect(input.canonicalCatalog.grammar.outcome.optional).toEqual(
|
|
expect.arrayContaining(['delivery', 'durationSeconds'])
|
|
);
|
|
expect(input.canonicalCatalog.grammar.outcome.delivery).toEqual([
|
|
'instant',
|
|
'continuous'
|
|
]);
|
|
expect(input.canonicalCatalog.grammar.continuousRestorationOutcomeShape)
|
|
.toMatchObject({
|
|
kind: 'restoration',
|
|
resource: 'health',
|
|
delivery: 'continuous',
|
|
durationSeconds: 3,
|
|
variants: {
|
|
'replace-with-each-required-variant-key': {
|
|
value: 240,
|
|
expression: {
|
|
left: 240,
|
|
right: {
|
|
left: {
|
|
ref: {
|
|
source: 'attribute'
|
|
}
|
|
},
|
|
right: 100
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
expect(input.examplesAndRules).toEqual(expect.arrayContaining([
|
|
expect.stringContaining('Continuous Health restoration is supported'),
|
|
expect.stringContaining('not a per-second value'),
|
|
expect.stringContaining('Do not replace continuous delivery with a periodic reducer')
|
|
]));
|
|
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain(
|
|
'Prompt contract version: 6'
|
|
);
|
|
|
|
const advertised = structuredClone(
|
|
input.canonicalCatalog.grammar.continuousRestorationOutcomeShape
|
|
);
|
|
const tierI = JSON.parse(JSON.stringify(
|
|
advertised.variants['replace-with-each-required-variant-key']
|
|
).replaceAll(
|
|
'replace-with-an-exact-catalog-attribute-id',
|
|
'1bce573c-80f7-4487-b1d3-c4dc598b5244'
|
|
));
|
|
const tierII = structuredClone(tierI);
|
|
tierII.value = 336;
|
|
tierII.expression.left = 336;
|
|
advertised.variants = { I: tierI, II: tierII };
|
|
const parsed = parseConditionalMechanicDraft({
|
|
version: 1,
|
|
simulationReadiness: 'ready',
|
|
scenarioInputs: [],
|
|
pools: [],
|
|
clauses: [{
|
|
key: 'predator-aura',
|
|
label: 'Predator Aura',
|
|
event: 'enemy_killed',
|
|
if: {
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.distanceMeters' },
|
|
operator: 'lte',
|
|
right: 20
|
|
},
|
|
reducer: { kind: 'none' },
|
|
mutations: [],
|
|
outcomes: [advertised]
|
|
}]
|
|
}, {
|
|
variantKeys: ['I', 'II'],
|
|
allowedAttributeIds: new Set(['1bce573c-80f7-4487-b1d3-c4dc598b5244'])
|
|
});
|
|
expect(parsed.ok).toBe(true);
|
|
});
|
|
|
|
it('advertises authoritative current Health percentage with strict band semantics', () => {
|
|
const input = JSON.parse(buildAiMechanicAuthoringInput({
|
|
request,
|
|
attributes: [],
|
|
events: [{ key: 'tick', label: 'Simulation tick' }],
|
|
actions: [],
|
|
recipeKeys: ['health_threshold_bands']
|
|
}));
|
|
|
|
expect(input.canonicalCatalog.grammar.combatReferences.currentHealthPercent)
|
|
.toMatchObject({
|
|
label: 'Current Health percentage',
|
|
description: expect.stringContaining('0 through 100')
|
|
});
|
|
expect(input.canonicalCatalog.grammar.referenceShapes).toContainEqual({
|
|
ref: {
|
|
source: 'combat',
|
|
path: 'currentHealthPercent',
|
|
timing: 'current'
|
|
}
|
|
});
|
|
expect(input.examplesAndRules).toEqual(expect.arrayContaining([
|
|
expect.stringContaining('operator:"lt"'),
|
|
expect.stringContaining('<25, >=25 and <50, and >=50 and <75')
|
|
]));
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|