Keep AI mechanic capabilities in sync

This commit is contained in:
JB
2026-07-27 21:43:48 -04:00
parent 568e46cd05
commit 37918bd411
5 changed files with 207 additions and 50 deletions
+8
View File
@@ -483,6 +483,14 @@ descriptions, and calculation metadata. The client cannot replace the system
instructions, choose arbitrary models, attach files, add tools, or add external instructions, choose arbitrary models, attach files, add tools, or add external
URLs. URLs.
The deterministic draft parser owns the canonical outcome-capability manifest.
The AI context serializes that manifest instead of maintaining a second outcome
grammar. The parser rejects outcome fields absent from the manifest. Contract
tests build a draft from each advertised capability example and pass it through
the parser, so CI blocks a prompt that advertises unsupported fields. A mechanic
grammar change must update the manifest and example, pass the round-trip test,
and increment the prompt contract version before release.
The OpenAI integration uses the Responses API with Structured Outputs, no The OpenAI integration uses the Responses API with Structured Outputs, no
tools, and `store: false`. The application stores the bounded clarification tools, and `store: false`. The application stores the bounded clarification
history needed for the current drafting session. Requests use a fixed history needed for the current drafting session. Requests use a fixed
@@ -263,6 +263,22 @@ describe('conditional mechanic authoring payload', () => {
} }
}); });
it('rejects outcome fields that are absent from the canonical capability manifest', () => {
const unsupported = draft();
(unsupported.clauses[0].outcomes[0] as unknown as Record<string, unknown>).cadence =
'continuous';
const result = parseConditionalMechanicDraft(unsupported, {
variantKeys: ['I', 'II'],
allowedAttributeIds: new Set(['weapon-damage'])
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.errors).toContain(
'Clause 1 outcome 1 contains unsupported field "cadence".'
);
}
});
it('requires the exact configured variants and returns validation errors without throwing', () => { it('requires the exact configured variants and returns validation errors without throwing', () => {
const invalid = draft(); const invalid = draft();
invalid.clauses[0].outcomes[0].variants = { invalid.clauses[0].outcomes[0].variants = {
+44 -17
View File
@@ -85,15 +85,23 @@ const scalar = (value: unknown) =>
value === null || ['string', 'number', 'boolean'].includes(typeof value); value === null || ['string', 'number', 'boolean'].includes(typeof value);
const identifier = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/; const identifier = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
const events = new Set<string>(MECHANIC_EVENT_KINDS); const events = new Set<string>(MECHANIC_EVENT_KINDS);
const phases = new Set<OutcomePhase>(['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active']); export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = {
const outcomeKinds = new Set<MechanicOutcome['kind']>([ required: ['key', 'label', 'phase', 'kind', 'scope', 'basis', 'variants'],
'attribute_modifier', optional: [
'direct_damage', 'resource',
'restoration', 'delivery',
'resource_cost', 'durationSeconds',
'marker' 'combatTiming',
]); 'attributeId',
const scopes = new Set<MechanicOutcomeScope>([ 'statePool',
'perStack',
'minStacks',
'maxStacks',
'descriptionSlot'
],
phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'],
kind: ['attribute_modifier', 'direct_damage', 'restoration', 'resource_cost', 'marker'],
scope: [
'self', 'self',
'equipped_weapon', 'equipped_weapon',
'target', 'target',
@@ -101,14 +109,28 @@ const scopes = new Set<MechanicOutcomeScope>([
'self_and_allies', 'self_and_allies',
'assigned_ability', 'assigned_ability',
'all_skills' 'all_skills'
],
basis: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'],
resource: ['health', 'armor', 'ammo'],
delivery: ['instant', 'continuous'],
deliverySemantics: {
instant: 'The full resolved value applies at activation.',
continuous:
'Valid only for restoration. The value is the total restoration, delivered at a constant rate across durationSeconds. Each activation creates an independent stream; overlapping streams add and overheal is lost when it occurs.'
}
} as const;
const phases = new Set<OutcomePhase>(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.phase);
const outcomeKinds = new Set<MechanicOutcome['kind']>(
CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.kind
);
const scopes = new Set<MechanicOutcomeScope>(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.scope);
const bases = new Set<OutcomeBasis>(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.basis);
const resources = new Set<MechanicResource>(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.resource);
const deliveryModes = new Set<string>(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.delivery);
const outcomeFields = new Set<string>([
...CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.required,
...CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.optional
]); ]);
const bases = new Set<OutcomeBasis>([
'flat',
'max_health_percent',
'current_health_percent',
'weapon_base_percent'
]);
const resources = new Set<MechanicResource>(['health', 'armor', 'ammo']);
const stateKinds = new Set<MechanicStateKind>([ const stateKinds = new Set<MechanicStateKind>([
'flag', 'flag',
'count', 'count',
@@ -356,6 +378,11 @@ function parseOutcome(
errors.push(`${path} must be an object.`); errors.push(`${path} must be an object.`);
return null; return null;
} }
for (const property of Object.keys(value)) {
if (!outcomeFields.has(property)) {
errors.push(`${path} contains unsupported field "${property}".`);
}
}
const outcomeKey = key(value.key, `${path} key`, errors); const outcomeKey = key(value.key, `${path} key`, errors);
if ( if (
!outcomeKey !outcomeKey
@@ -387,7 +414,7 @@ function parseOutcome(
errors.push(`${path} may only use a resource for restoration or resource cost.`); errors.push(`${path} may only use a resource for restoration or resource cost.`);
} }
const delivery = value.delivery ?? 'instant'; const delivery = value.delivery ?? 'instant';
if (delivery !== 'instant' && delivery !== 'continuous') { if (!deliveryModes.has(String(delivery))) {
errors.push(`${path} has an invalid delivery mode.`); errors.push(`${path} has an invalid delivery mode.`);
} else if (delivery === 'continuous') { } else if (delivery === 'continuous') {
if (kind !== 'restoration') { if (kind !== 'restoration') {
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring'; import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring';
import { parseConditionalMechanicDraft } from '$lib/calculations/mechanic-editor';
import { import {
AI_MECHANIC_AUTHORING_INSTRUCTIONS, AI_MECHANIC_AUTHORING_INSTRUCTIONS,
AI_MECHANIC_PROMPT_VERSION,
buildAiMechanicAuthoringInput buildAiMechanicAuthoringInput
} from './mechanic-authoring-prompt'; } from './mechanic-authoring-prompt';
@@ -58,4 +60,100 @@ describe('AI mechanic authoring prompt', () => {
])); ]));
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain('Never invent an Attribute ID'); 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(4);
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: 4'
);
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);
});
}); });
+34 -26
View File
@@ -1,7 +1,8 @@
import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring'; 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'; import type { AiMechanicAttribute } from './mechanic-authoring-context';
export const AI_MECHANIC_PROMPT_VERSION = 3; export const AI_MECHANIC_PROMPT_VERSION = 4;
const mechanicsGrammar = { const mechanicsGrammar = {
version: 1, version: 1,
@@ -106,31 +107,36 @@ const mechanicsGrammar = {
clearSource: true clearSource: true
} }
], ],
outcome: { outcome: CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES,
required: ['key', 'label', 'phase', 'kind', 'scope', 'basis', 'variants'], continuousRestorationOutcomeShape: {
phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'], key: 'restore-health',
kind: ['attribute_modifier', 'direct_damage', 'restoration', 'resource_cost', 'marker'], label: 'Restore Health',
scope: [ phase: 'after_event',
'self', kind: 'restoration',
'equipped_weapon', scope: 'self',
'target', basis: 'flat',
'allies', resource: 'health',
'self_and_allies', delivery: 'continuous',
'assigned_ability', durationSeconds: 3,
'all_skills' variants: {
], 'replace-with-each-required-variant-key': {
basis: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'], value: 240,
resource: ['health', 'armor', 'ammo'], expression: {
optional: [ op: 'multiply',
'resource', left: 240,
'combatTiming', right: {
'attributeId', op: 'divide',
'statePool', left: {
'perStack', ref: {
'minStacks', source: 'attribute',
'maxStacks', attributeId: 'replace-with-an-exact-catalog-attribute-id'
'descriptionSlot' }
] },
right: 100
}
}
}
}
}, },
variantValue: { variantValue: {
required: ['value'], required: ['value'],
@@ -233,6 +239,8 @@ export function buildAiMechanicAuthoringInput(input: {
'Percent values use the numeric percentage shown in game, such as 3 for +3%, rather than 0.03.', 'Percent values use the numeric percentage shown in game, such as 3 for +3%, rather than 0.03.',
'Live Attribute references must use {source:"attribute",attributeId:"<exact supplied catalog ID>"}. Never use an Attribute name or invent an ID.', 'Live Attribute references must use {source:"attribute",attributeId:"<exact supplied catalog ID>"}. Never use an Attribute name or invent an ID.',
'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%.', '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.',
'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.', 'OS Protocol upgrade scaling may use equipment.protocolTalentUpgrades only when allowProtocolUpgradeScaling is true.',
'Assigned-ability scope may be used only when allowAssignedAbility 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.', 'If cadence is not stated, ask rather than assuming a one-second interval.',