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
@@ -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', () => {
const invalid = draft();
invalid.clauses[0].outcomes[0].variants = {
+51 -24
View File
@@ -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<string>(MECHANIC_EVENT_KINDS);
const phases = new Set<OutcomePhase>(['on_event', 'after_event', 'on_enter', 'on_exit', 'while_active']);
const outcomeKinds = new Set<MechanicOutcome['kind']>([
'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<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 scopes = new Set<MechanicOutcomeScope>([
'self',
'equipped_weapon',
'target',
'allies',
'self_and_allies',
'assigned_ability',
'all_skills'
]);
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>([
'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') {