Keep AI mechanic capabilities in sync
This commit is contained in:
@@ -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 = {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
Reference in New Issue
Block a user