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