From 37918bd4117c6f2e0ec6a8bde7ab2ccb7a8d3ec1 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 27 Jul 2026 21:43:48 -0400 Subject: [PATCH] Keep AI mechanic capabilities in sync --- docs/architecture.md | 8 ++ src/lib/calculations/mechanic-editor.test.ts | 16 +++ src/lib/calculations/mechanic-editor.ts | 75 +++++++++----- .../ai/mechanic-authoring-prompt.test.ts | 98 +++++++++++++++++++ .../server/ai/mechanic-authoring-prompt.ts | 60 +++++++----- 5 files changed, 207 insertions(+), 50 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a4114f7..8359fcc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 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 tools, and `store: false`. The application stores the bounded clarification history needed for the current drafting session. Requests use a fixed diff --git a/src/lib/calculations/mechanic-editor.test.ts b/src/lib/calculations/mechanic-editor.test.ts index f64be43..3574704 100644 --- a/src/lib/calculations/mechanic-editor.test.ts +++ b/src/lib/calculations/mechanic-editor.test.ts @@ -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).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', () => { const invalid = draft(); invalid.clauses[0].outcomes[0].variants = { diff --git a/src/lib/calculations/mechanic-editor.ts b/src/lib/calculations/mechanic-editor.ts index 85f8aff..d6eac96 100644 --- a/src/lib/calculations/mechanic-editor.ts +++ b/src/lib/calculations/mechanic-editor.ts @@ -85,30 +85,52 @@ const scalar = (value: unknown) => value === null || ['string', 'number', 'boolean'].includes(typeof value); const identifier = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/; const events = new Set(MECHANIC_EVENT_KINDS); -const phases = new Set(['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active']); -const outcomeKinds = new Set([ - 'attribute_modifier', - 'direct_damage', - 'restoration', - 'resource_cost', - 'marker' +export const CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES = { + required: ['key', 'label', 'phase', 'kind', 'scope', 'basis', 'variants'], + optional: [ + 'resource', + 'delivery', + 'durationSeconds', + 'combatTiming', + 'attributeId', + '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', + 'equipped_weapon', + 'target', + 'allies', + 'self_and_allies', + 'assigned_ability', + '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(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.phase); +const outcomeKinds = new Set( + CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.kind +); +const scopes = new Set(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.scope); +const bases = new Set(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.basis); +const resources = new Set(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.resource); +const deliveryModes = new Set(CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.delivery); +const outcomeFields = new Set([ + ...CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.required, + ...CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES.optional ]); -const scopes = new Set([ - 'self', - 'equipped_weapon', - 'target', - 'allies', - 'self_and_allies', - 'assigned_ability', - 'all_skills' -]); -const bases = new Set([ - 'flat', - 'max_health_percent', - 'current_health_percent', - 'weapon_base_percent' -]); -const resources = new Set(['health', 'armor', 'ammo']); const stateKinds = new Set([ 'flag', 'count', @@ -356,6 +378,11 @@ function parseOutcome( errors.push(`${path} must be an object.`); 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); if ( !outcomeKey @@ -387,7 +414,7 @@ function parseOutcome( errors.push(`${path} may only use a resource for restoration or resource cost.`); } const delivery = value.delivery ?? 'instant'; - if (delivery !== 'instant' && delivery !== 'continuous') { + if (!deliveryModes.has(String(delivery))) { errors.push(`${path} has an invalid delivery mode.`); } else if (delivery === 'continuous') { if (kind !== 'restoration') { diff --git a/src/lib/server/ai/mechanic-authoring-prompt.test.ts b/src/lib/server/ai/mechanic-authoring-prompt.test.ts index 2a514cb..ab802e3 100644 --- a/src/lib/server/ai/mechanic-authoring-prompt.test.ts +++ b/src/lib/server/ai/mechanic-authoring-prompt.test.ts @@ -1,7 +1,9 @@ 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'; @@ -58,4 +60,100 @@ describe('AI mechanic authoring prompt', () => { ])); 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); + }); }); diff --git a/src/lib/server/ai/mechanic-authoring-prompt.ts b/src/lib/server/ai/mechanic-authoring-prompt.ts index 97709fa..af93bd1 100644 --- a/src/lib/server/ai/mechanic-authoring-prompt.ts +++ b/src/lib/server/ai/mechanic-authoring-prompt.ts @@ -1,7 +1,8 @@ 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 = 3; +export const AI_MECHANIC_PROMPT_VERSION = 4; const mechanicsGrammar = { version: 1, @@ -106,31 +107,36 @@ const mechanicsGrammar = { clearSource: true } ], - outcome: { - required: ['key', 'label', 'phase', 'kind', 'scope', 'basis', 'variants'], - phase: ['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active'], - kind: ['attribute_modifier', 'direct_damage', 'restoration', 'resource_cost', 'marker'], - scope: [ - 'self', - 'equipped_weapon', - 'target', - 'allies', - 'self_and_allies', - 'assigned_ability', - 'all_skills' - ], - basis: ['flat', 'max_health_percent', 'current_health_percent', 'weapon_base_percent'], - resource: ['health', 'armor', 'ammo'], - optional: [ - 'resource', - 'combatTiming', - 'attributeId', - 'statePool', - 'perStack', - 'minStacks', - 'maxStacks', - 'descriptionSlot' - ] + outcome: CONDITIONAL_MECHANIC_OUTCOME_CAPABILITIES, + continuousRestorationOutcomeShape: { + key: 'restore-health', + label: 'Restore Health', + phase: 'after_event', + kind: 'restoration', + scope: 'self', + basis: 'flat', + resource: 'health', + delivery: 'continuous', + durationSeconds: 3, + variants: { + 'replace-with-each-required-variant-key': { + value: 240, + expression: { + op: 'multiply', + left: 240, + right: { + op: 'divide', + left: { + ref: { + source: 'attribute', + attributeId: 'replace-with-an-exact-catalog-attribute-id' + } + }, + right: 100 + } + } + } + } }, variantValue: { 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.', 'Live Attribute references must use {source:"attribute",attributeId:""}. 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%.', + '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.', 'Assigned-ability scope may be used only when allowAssignedAbility is true.', 'If cadence is not stated, ask rather than assuming a one-second interval.',