Support live attribute restoration mechanics
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring';
|
||||
import {
|
||||
AI_MECHANIC_AUTHORING_INSTRUCTIONS,
|
||||
buildAiMechanicAuthoringInput
|
||||
} from './mechanic-authoring-prompt';
|
||||
|
||||
const request: AiMechanicAuthoringRequest = {
|
||||
context: {
|
||||
sourceKind: 'talent',
|
||||
sourceName: 'Predatory Aura',
|
||||
sourceDescription: 'Restore Health based on the current Firepower Multiplier.'
|
||||
},
|
||||
currentDraft: {
|
||||
version: 1,
|
||||
simulationReadiness: 'captured_only',
|
||||
scenarioInputs: [],
|
||||
pools: [],
|
||||
clauses: []
|
||||
},
|
||||
valueColumns: [
|
||||
{ key: 'tierI', variantKey: 'I', label: 'TIER I' },
|
||||
{ key: 'tierII', variantKey: 'II', label: 'TIER II' }
|
||||
],
|
||||
allowAssignedAbility: false,
|
||||
allowProtocolUpgradeScaling: false,
|
||||
transcript: []
|
||||
};
|
||||
|
||||
describe('AI mechanic authoring prompt', () => {
|
||||
it('advertises exact catalog Attribute references and displayed-percent semantics', () => {
|
||||
const input = JSON.parse(buildAiMechanicAuthoringInput({
|
||||
request,
|
||||
attributes: [{
|
||||
id: 'firepower-multiplier',
|
||||
name: 'Firepower Multiplier',
|
||||
description: null,
|
||||
unit: '%',
|
||||
type: null,
|
||||
category: null,
|
||||
calculationType: null
|
||||
}],
|
||||
events: [],
|
||||
actions: [],
|
||||
recipeKeys: []
|
||||
}));
|
||||
|
||||
expect(input.canonicalCatalog.grammar.valueReferences).toContain('attribute');
|
||||
expect(input.canonicalCatalog.grammar.referenceShapes).toContainEqual({
|
||||
ref: {
|
||||
source: 'attribute',
|
||||
attributeId: 'replace-with-an-exact-catalog-attribute-id'
|
||||
}
|
||||
});
|
||||
expect(input.examplesAndRules).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('exact supplied catalog ID'),
|
||||
expect.stringContaining('Divide that reference by 100')
|
||||
]));
|
||||
expect(AI_MECHANIC_AUTHORING_INSTRUCTIONS).toContain('Never invent an Attribute ID');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AiMechanicAuthoringRequest } from '$lib/ai/mechanic-authoring';
|
||||
import type { AiMechanicAttribute } from './mechanic-authoring-context';
|
||||
|
||||
export const AI_MECHANIC_PROMPT_VERSION = 2;
|
||||
export const AI_MECHANIC_PROMPT_VERSION = 3;
|
||||
|
||||
const mechanicsGrammar = {
|
||||
version: 1,
|
||||
@@ -39,13 +39,14 @@ const mechanicsGrammar = {
|
||||
],
|
||||
mutationKinds: ['none', 'add', 'set', 'consume', 'derive', 'clear', 'transfer'],
|
||||
entitySelectors: ['agent', 'event_actor', 'event_target', 'event_field'],
|
||||
valueReferences: ['event', 'scenario', 'combat', 'equipment', 'reducer', 'pool'],
|
||||
valueReferences: ['event', 'scenario', 'combat', 'equipment', 'attribute', 'reducer', 'pool'],
|
||||
numericExpressionOperators: ['add', 'subtract', 'multiply', 'divide', 'min', 'max', 'clamp'],
|
||||
referenceShapes: [
|
||||
{ ref: { source: 'event', path: 'values.distanceMeters' } },
|
||||
{ ref: { source: 'scenario', key: 'input-key' } },
|
||||
{ ref: { source: 'combat', path: 'currentHealth', timing: 'current' } },
|
||||
{ ref: { source: 'equipment', path: 'protocolTalentUpgrades' } },
|
||||
{ ref: { source: 'attribute', attributeId: 'replace-with-an-exact-catalog-attribute-id' } },
|
||||
{ ref: { source: 'reducer' } },
|
||||
{ ref: { source: 'pool', pool: 'pool-key', entity: { kind: 'agent' } } }
|
||||
],
|
||||
@@ -230,6 +231,8 @@ export function buildAiMechanicAuthoringInput(input: {
|
||||
},
|
||||
examplesAndRules: [
|
||||
'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.',
|
||||
'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%.',
|
||||
'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.',
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { connect } from '@tursodatabase/database';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { conditionalRuleDraftFromRows } from './conditional-effects.js';
|
||||
import {
|
||||
conditionalRuleDraftFromRows,
|
||||
loadConditionalRuleDraft,
|
||||
saveConditionalRuleInTransaction
|
||||
} from './conditional-effects.js';
|
||||
|
||||
const rule = {
|
||||
id: 'rule-1',
|
||||
@@ -72,5 +77,157 @@ describe('conditional mechanic persistence adapter', () => {
|
||||
I: { value: 3 },
|
||||
II: { value: 5 }
|
||||
});
|
||||
expect(draft?.clauses[0].outcomes[0]).not.toHaveProperty('delivery');
|
||||
expect(draft?.clauses[0].outcomes[0]).not.toHaveProperty('durationSeconds');
|
||||
});
|
||||
|
||||
it('loads continuous delivery metadata and normalizes legacy instant variants', () => {
|
||||
const continuous = conditionalRuleDraftFromRows(
|
||||
rule,
|
||||
[],
|
||||
[clause],
|
||||
[
|
||||
{
|
||||
...outcome,
|
||||
application_kind: 'restore_health',
|
||||
stat_attribute_id: null,
|
||||
resource_kind: 'health',
|
||||
delivery_mode: 'continuous',
|
||||
delivery_duration_seconds: 3,
|
||||
variant_key: 'I'
|
||||
},
|
||||
{
|
||||
...outcome,
|
||||
application_kind: 'restore_health',
|
||||
stat_attribute_id: null,
|
||||
resource_kind: 'health',
|
||||
delivery_mode: 'continuous',
|
||||
delivery_duration_seconds: 3,
|
||||
variant_key: 'II'
|
||||
}
|
||||
],
|
||||
['I', 'II']
|
||||
);
|
||||
expect(continuous?.clauses[0].outcomes[0]).toMatchObject({
|
||||
delivery: 'continuous',
|
||||
durationSeconds: 3
|
||||
});
|
||||
|
||||
expect(() => conditionalRuleDraftFromRows(
|
||||
rule,
|
||||
[],
|
||||
[clause],
|
||||
[
|
||||
{ ...outcome, variant_key: 'I' },
|
||||
{ ...outcome, variant_key: 'II', delivery_mode: 'instant' }
|
||||
],
|
||||
['I', 'II']
|
||||
)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects divergent continuous-delivery durations across variants', () => {
|
||||
expect(() => conditionalRuleDraftFromRows(
|
||||
rule,
|
||||
[],
|
||||
[clause],
|
||||
[
|
||||
{
|
||||
...outcome,
|
||||
variant_key: 'I',
|
||||
delivery_mode: 'continuous',
|
||||
delivery_duration_seconds: 3
|
||||
},
|
||||
{
|
||||
...outcome,
|
||||
variant_key: 'II',
|
||||
delivery_mode: 'continuous',
|
||||
delivery_duration_seconds: 4
|
||||
}
|
||||
],
|
||||
['I', 'II']
|
||||
)).toThrow(/inconsistent delivery_duration_seconds metadata/);
|
||||
});
|
||||
|
||||
it('round-trips continuous delivery through normalized rows', async () => {
|
||||
const database = await connect(':memory:');
|
||||
await database.exec(`CREATE TABLE conditional_effect_rules (
|
||||
id TEXT PRIMARY KEY, source_type TEXT, source_id TEXT, configured INTEGER,
|
||||
script_version INTEGER, simulation_readiness TEXT, scenario_inputs_json TEXT,
|
||||
created_at TEXT, updated_at TEXT, UNIQUE(source_type,source_id)
|
||||
)`);
|
||||
await database.exec(`CREATE TABLE conditional_effect_state_pools (
|
||||
id TEXT PRIMARY KEY, rule_id TEXT, state_key TEXT, label TEXT, state_kind TEXT,
|
||||
state_scope TEXT, initial_value REAL, minimum_value REAL, maximum_value REAL,
|
||||
best_case_value REAL, duration_seconds REAL, expiry_policy TEXT, refresh_policy TEXT,
|
||||
parameters_json TEXT, sort_order INTEGER, created_at TEXT, updated_at TEXT
|
||||
)`);
|
||||
await database.exec(`CREATE TABLE conditional_effect_clauses (
|
||||
id TEXT PRIMARY KEY, rule_id TEXT, clause_key TEXT, label TEXT, event_kind TEXT,
|
||||
recipe_key TEXT, recipe_parameters_json TEXT, conditions_json TEXT, reducer_json TEXT,
|
||||
weight_expression_json TEXT, emission_kind TEXT, state_pool_id TEXT, mutations_json TEXT,
|
||||
cooldown_seconds REAL, output_branch TEXT, sort_order INTEGER, active INTEGER,
|
||||
created_at TEXT, updated_at TEXT
|
||||
)`);
|
||||
await database.exec(`CREATE TABLE conditional_effect_outcomes (
|
||||
id TEXT PRIMARY KEY, rule_id TEXT, clause_id TEXT, state_pool_id TEXT, effect_key TEXT,
|
||||
variant_key TEXT, outcome_key TEXT, application_kind TEXT, effect_scope TEXT,
|
||||
stat_attribute_id TEXT, value REAL, value_expression_json TEXT, value_basis TEXT,
|
||||
value_scaling TEXT, unit TEXT, damage_basis TEXT, application_phase TEXT,
|
||||
combat_timing TEXT, resource_kind TEXT, delivery_mode TEXT,
|
||||
delivery_duration_seconds REAL, min_state_value REAL, max_state_value REAL,
|
||||
description_slot INTEGER, sort_order INTEGER, created_at TEXT, updated_at TEXT
|
||||
)`);
|
||||
|
||||
await saveConditionalRuleInTransaction(database, 'talent', 'predatory-aura', {
|
||||
version: 1,
|
||||
simulationReadiness: 'ready',
|
||||
scenarioInputs: [],
|
||||
pools: [],
|
||||
clauses: [{
|
||||
key: 'weapon-kill',
|
||||
label: 'Weapon kill',
|
||||
event: 'enemy_killed',
|
||||
if: { kind: 'true' },
|
||||
reducer: { kind: 'none' },
|
||||
mutations: [],
|
||||
outcomes: [{
|
||||
key: 'restore-health',
|
||||
label: 'Restore Health',
|
||||
phase: 'after_event',
|
||||
kind: 'restoration',
|
||||
scope: 'self',
|
||||
resource: 'health',
|
||||
basis: 'flat',
|
||||
delivery: 'continuous',
|
||||
durationSeconds: 3,
|
||||
variants: {
|
||||
I: { value: 240 },
|
||||
II: { value: 336 }
|
||||
}
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
const saved = await (await database.prepare(
|
||||
'SELECT delivery_mode,delivery_duration_seconds FROM conditional_effect_outcomes ORDER BY variant_key'
|
||||
)).all();
|
||||
expect(saved).toEqual([
|
||||
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 },
|
||||
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 }
|
||||
]);
|
||||
const loaded = await loadConditionalRuleDraft(
|
||||
database,
|
||||
'talent',
|
||||
'predatory-aura',
|
||||
['I', 'II']
|
||||
);
|
||||
expect(loaded?.clauses[0].outcomes[0]).toMatchObject({
|
||||
delivery: 'continuous',
|
||||
durationSeconds: 3,
|
||||
variants: {
|
||||
I: { value: 240 },
|
||||
II: { value: 336 }
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,19 +72,24 @@ const outcomeMetadataColumns = [
|
||||
'application_phase',
|
||||
'combat_timing',
|
||||
'resource_kind',
|
||||
'delivery_mode',
|
||||
'delivery_duration_seconds',
|
||||
'state_pool_id',
|
||||
'min_state_value',
|
||||
'max_state_value',
|
||||
'description_slot'
|
||||
] as const;
|
||||
|
||||
const comparableMetadataValue = (value: unknown) => value === undefined ? null : value;
|
||||
const comparableMetadataValue = (column: typeof outcomeMetadataColumns[number], value: unknown) => {
|
||||
if (column === 'delivery_mode') return value === undefined || value === null ? 'instant' : value;
|
||||
return value === undefined ? null : value;
|
||||
};
|
||||
|
||||
function assertVariantMetadataConsistent(effectKey: string, variants: OutcomeRow[]) {
|
||||
const first = variants[0];
|
||||
for (const variant of variants.slice(1)) {
|
||||
const mismatch = outcomeMetadataColumns.find((column) =>
|
||||
comparableMetadataValue(variant[column]) !== comparableMetadataValue(first[column])
|
||||
comparableMetadataValue(column, variant[column]) !== comparableMetadataValue(column, first[column])
|
||||
);
|
||||
if (mismatch) {
|
||||
throw new Error(
|
||||
@@ -156,6 +161,22 @@ export function conditionalRuleDraftFromRows(
|
||||
assertVariantMetadataConsistent(key, variants);
|
||||
const first = variants[0];
|
||||
const resource = outcomeResource(first);
|
||||
const delivery = first.delivery_mode === undefined || first.delivery_mode === null
|
||||
? 'instant'
|
||||
: String(first.delivery_mode);
|
||||
const durationSeconds = first.delivery_duration_seconds === undefined
|
||||
|| first.delivery_duration_seconds === null
|
||||
? null
|
||||
: Number(first.delivery_duration_seconds);
|
||||
if (delivery !== 'instant' && delivery !== 'continuous') {
|
||||
throw new Error(`Conditional outcome "${key}" has an unsupported delivery mode.`);
|
||||
}
|
||||
if (delivery === 'continuous' && (!Number.isFinite(durationSeconds) || Number(durationSeconds) <= 0)) {
|
||||
throw new Error(`Conditional outcome "${key}" continuous delivery requires a positive duration.`);
|
||||
}
|
||||
if (delivery === 'instant' && durationSeconds !== null) {
|
||||
throw new Error(`Conditional outcome "${key}" instant delivery cannot set a duration.`);
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label: String(first.outcome_key || key),
|
||||
@@ -165,6 +186,9 @@ export function conditionalRuleDraftFromRows(
|
||||
basis: String(first.value_basis) as ConditionalMechanicOutcomeDraft['basis'],
|
||||
...(resource ? { resource } : {}),
|
||||
...(first.combat_timing ? { combatTiming: String(first.combat_timing) as 'pre_event' | 'current' } : {}),
|
||||
...(delivery === 'continuous'
|
||||
? { delivery, durationSeconds: durationSeconds as number }
|
||||
: {}),
|
||||
...(first.stat_attribute_id ? { attributeId: String(first.stat_attribute_id) } : {}),
|
||||
...(first.state_pool_id && poolKeyById.has(String(first.state_pool_id))
|
||||
? { statePool: poolKeyById.get(String(first.state_pool_id)) }
|
||||
@@ -331,9 +355,10 @@ export async function saveConditionalRuleInTransaction(
|
||||
(id,rule_id,clause_id,state_pool_id,effect_key,variant_key,outcome_key,
|
||||
application_kind,effect_scope,stat_attribute_id,value,value_expression_json,
|
||||
value_basis,value_scaling,unit,damage_basis,application_phase,combat_timing,
|
||||
resource_kind,min_state_value,max_state_value,description_slot,sort_order,
|
||||
resource_kind,delivery_mode,delivery_duration_seconds,
|
||||
min_state_value,max_state_value,description_slot,sort_order,
|
||||
created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||
randomUUID(),
|
||||
ruleId,
|
||||
clauseId,
|
||||
@@ -355,6 +380,8 @@ export async function saveConditionalRuleInTransaction(
|
||||
outcome.phase,
|
||||
outcome.combatTiming ?? 'current',
|
||||
outcome.resource ?? null,
|
||||
outcome.delivery ?? 'instant',
|
||||
outcome.durationSeconds ?? null,
|
||||
outcome.minStacks ?? null,
|
||||
outcome.maxStacks ?? null,
|
||||
outcome.descriptionSlot ?? null,
|
||||
|
||||
Reference in New Issue
Block a user