487 lines
14 KiB
TypeScript
487 lines
14 KiB
TypeScript
import { connect } from '@tursodatabase/database';
|
|
import { describe, expect, it } from 'vitest';
|
|
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
|
import {
|
|
conditionalRuleDraftFromRows,
|
|
loadConditionalRuleDraft,
|
|
saveConditionalRuleInTransaction,
|
|
} from './conditional-effects.js';
|
|
|
|
const rule = {
|
|
id: 'rule-1',
|
|
simulation_readiness: 'simulation_ready',
|
|
scenario_inputs_json: '[]',
|
|
};
|
|
|
|
const clause = {
|
|
id: 'clause-1',
|
|
rule_id: 'rule-1',
|
|
clause_key: 'hit',
|
|
label: 'Hit',
|
|
event_kind: 'weapon_hit',
|
|
conditions_json: '{"kind":"true"}',
|
|
reducer_json: '{"kind":"none"}',
|
|
mutations_json: '[]',
|
|
sort_order: 1,
|
|
active: 1,
|
|
};
|
|
|
|
const outcome = {
|
|
rule_id: 'rule-1',
|
|
clause_id: 'clause-1',
|
|
effect_key: 'damage',
|
|
outcome_key: 'Damage',
|
|
application_kind: 'active_modifier',
|
|
effect_scope: 'self',
|
|
stat_attribute_id: 'weapon-damage',
|
|
value_basis: 'flat',
|
|
value_scaling: 'flat',
|
|
damage_basis: null,
|
|
application_phase: 'while_active',
|
|
combat_timing: 'current',
|
|
resource_kind: null,
|
|
state_pool_id: null,
|
|
min_state_value: null,
|
|
max_state_value: null,
|
|
description_slot: 1,
|
|
value: 3,
|
|
value_expression_json: null,
|
|
};
|
|
|
|
describe('conditional mechanic persistence adapter', () => {
|
|
it('rejects divergent metadata across value variants', () => {
|
|
expect(() =>
|
|
conditionalRuleDraftFromRows(
|
|
rule,
|
|
[],
|
|
[clause],
|
|
[
|
|
{ ...outcome, variant_key: 'I' },
|
|
{
|
|
...outcome,
|
|
variant_key: 'II',
|
|
application_phase: 'after_event',
|
|
value: 5,
|
|
},
|
|
],
|
|
['I', 'II'],
|
|
),
|
|
).toThrow(/inconsistent application_phase metadata/);
|
|
});
|
|
|
|
it('loads consistent value variants into one outcome', () => {
|
|
const draft = conditionalRuleDraftFromRows(
|
|
rule,
|
|
[],
|
|
[clause],
|
|
[
|
|
{ ...outcome, variant_key: 'I' },
|
|
{ ...outcome, variant_key: 'II', value: 5 },
|
|
],
|
|
['I', 'II'],
|
|
);
|
|
|
|
expect(draft?.clauses[0].outcomes[0].variants).toEqual({
|
|
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 and direct event predicates 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, targeting_json TEXT, min_state_value REAL, max_state_value REAL,
|
|
probability_percent 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: [
|
|
{
|
|
key: 'marked-target',
|
|
label: 'Marked Target',
|
|
stateKind: 'target_mark',
|
|
scope: 'target',
|
|
initial: 0,
|
|
min: 0,
|
|
max: 1,
|
|
expiresAfterSecondsVariants: {
|
|
I: 12,
|
|
II: 18,
|
|
},
|
|
} as ConditionalMechanicDraftV1['pools'][number] & {
|
|
expiresAfterSecondsVariants: Record<string, number>;
|
|
},
|
|
],
|
|
clauses: [
|
|
{
|
|
key: 'weapon-kill',
|
|
label: 'Weapon kill',
|
|
event: 'enemy_killed',
|
|
procChancePercent: 29,
|
|
requiresAssignedAbilityActive: true,
|
|
if: {
|
|
kind: 'compare',
|
|
left: {
|
|
source: 'combat',
|
|
path: 'currentHealthPercent',
|
|
timing: 'current',
|
|
},
|
|
operator: 'lt',
|
|
right: 75,
|
|
},
|
|
reducer: { kind: 'none' },
|
|
mutations: [
|
|
{
|
|
kind: 'set',
|
|
pool: 'marked-target',
|
|
entity: { kind: 'event_target' },
|
|
value: 1,
|
|
},
|
|
],
|
|
outcomes: [
|
|
{
|
|
key: 'restore-health',
|
|
label: 'Restore Health',
|
|
phase: 'after_event',
|
|
kind: 'restoration',
|
|
scope: 'self',
|
|
resource: 'health',
|
|
basis: 'flat',
|
|
delivery: 'continuous',
|
|
durationSeconds: 3,
|
|
targeting: {
|
|
kind: 'within_radius',
|
|
population: 'allies',
|
|
radiusMeters: 40,
|
|
},
|
|
probabilityPercent: 29,
|
|
variants: {
|
|
I: { value: 240 },
|
|
II: { value: 336 },
|
|
},
|
|
},
|
|
{
|
|
key: 'target-detonation',
|
|
label: 'Target Detonation',
|
|
phase: 'after_event',
|
|
kind: 'direct_damage',
|
|
scope: 'target',
|
|
basis: 'flat',
|
|
statePool: 'marked-target',
|
|
perStack: true,
|
|
targeting: {
|
|
kind: 'event_target_area',
|
|
center: 'event_target',
|
|
validTargets: 'enemies',
|
|
radiusMeters: 5,
|
|
},
|
|
variants: {
|
|
I: {
|
|
value: 240,
|
|
expression: {
|
|
op: 'multiply',
|
|
left: 240,
|
|
right: {
|
|
op: 'divide',
|
|
left: {
|
|
ref: {
|
|
source: 'attribute',
|
|
attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e',
|
|
},
|
|
},
|
|
right: 100,
|
|
},
|
|
},
|
|
},
|
|
II: {
|
|
value: 336,
|
|
expression: {
|
|
op: 'multiply',
|
|
left: 336,
|
|
right: {
|
|
op: 'divide',
|
|
left: {
|
|
ref: {
|
|
source: 'attribute',
|
|
attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e',
|
|
},
|
|
},
|
|
right: 100,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: 'extra-health-state',
|
|
label: 'Extra Health state',
|
|
event: 'extra_health_changed',
|
|
if: {
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.hasExtraHealth' },
|
|
operator: 'eq',
|
|
right: true,
|
|
},
|
|
reducer: { kind: 'none' },
|
|
mutations: [],
|
|
outcomes: [],
|
|
},
|
|
{
|
|
key: 'tactical-link-assist',
|
|
label: 'Tactical Link assist',
|
|
event: 'assist',
|
|
if: {
|
|
kind: 'compare',
|
|
left: {
|
|
source: 'event',
|
|
path: 'values.assignedAbilityActive',
|
|
},
|
|
operator: 'eq',
|
|
right: true,
|
|
},
|
|
reducer: { kind: 'none' },
|
|
mutations: [],
|
|
outcomes: [],
|
|
},
|
|
],
|
|
},
|
|
);
|
|
|
|
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: 'instant', delivery_duration_seconds: null },
|
|
{ delivery_mode: 'continuous', delivery_duration_seconds: 3 },
|
|
{ delivery_mode: 'instant', delivery_duration_seconds: null },
|
|
]);
|
|
expect(
|
|
await (
|
|
await database.prepare(
|
|
'SELECT duration_seconds,parameters_json FROM conditional_effect_state_pools',
|
|
)
|
|
).get(),
|
|
).toEqual({
|
|
duration_seconds: null,
|
|
parameters_json: JSON.stringify({
|
|
expiresAfterSecondsVariants: {
|
|
I: 12,
|
|
II: 18,
|
|
},
|
|
}),
|
|
});
|
|
const loaded = await loadConditionalRuleDraft(
|
|
database,
|
|
'talent',
|
|
'predatory-aura',
|
|
['I', 'II'],
|
|
);
|
|
expect(loaded?.clauses[0].outcomes[0]).toMatchObject({
|
|
delivery: 'continuous',
|
|
durationSeconds: 3,
|
|
targeting: {
|
|
kind: 'within_radius',
|
|
population: 'allies',
|
|
radiusMeters: 40,
|
|
},
|
|
probabilityPercent: 29,
|
|
variants: {
|
|
I: { value: 240 },
|
|
II: { value: 336 },
|
|
},
|
|
});
|
|
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
|
|
phase: 'after_event',
|
|
kind: 'direct_damage',
|
|
scope: 'target',
|
|
targeting: {
|
|
kind: 'event_target_area',
|
|
center: 'event_target',
|
|
validTargets: 'enemies',
|
|
radiusMeters: 5,
|
|
},
|
|
});
|
|
expect(loaded?.clauses[0].if).toEqual({
|
|
kind: 'compare',
|
|
left: {
|
|
source: 'combat',
|
|
path: 'currentHealthPercent',
|
|
timing: 'current',
|
|
},
|
|
operator: 'lt',
|
|
right: 75,
|
|
});
|
|
expect(loaded?.clauses[0]).toMatchObject({
|
|
procChancePercent: 29,
|
|
requiresAssignedAbilityActive: true,
|
|
});
|
|
expect(loaded?.pools[0]).toMatchObject({
|
|
key: 'marked-target',
|
|
stateKind: 'target_mark',
|
|
scope: 'target',
|
|
expiresAfterSecondsVariants: {
|
|
I: 12,
|
|
II: 18,
|
|
},
|
|
});
|
|
expect(loaded?.clauses[0].mutations).toEqual([
|
|
{
|
|
kind: 'set',
|
|
pool: 'marked-target',
|
|
entity: { kind: 'event_target' },
|
|
value: 1,
|
|
},
|
|
]);
|
|
expect(loaded?.clauses[0].outcomes[1]).toMatchObject({
|
|
phase: 'after_event',
|
|
kind: 'direct_damage',
|
|
scope: 'target',
|
|
statePool: 'marked-target',
|
|
perStack: true,
|
|
variants: {
|
|
I: {
|
|
value: 240,
|
|
expression: expect.objectContaining({
|
|
right: expect.objectContaining({
|
|
left: {
|
|
ref: {
|
|
source: 'attribute',
|
|
attributeId: '82a95611-283b-4fd8-a6ed-3567a316f36e',
|
|
},
|
|
},
|
|
}),
|
|
}),
|
|
},
|
|
II: { value: 336 },
|
|
},
|
|
});
|
|
expect(loaded?.clauses[1]).toMatchObject({
|
|
event: 'extra_health_changed',
|
|
if: {
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.hasExtraHealth' },
|
|
operator: 'eq',
|
|
right: true,
|
|
},
|
|
});
|
|
expect(loaded?.clauses[2]).toMatchObject({
|
|
event: 'assist',
|
|
if: {
|
|
kind: 'compare',
|
|
left: { source: 'event', path: 'values.assignedAbilityActive' },
|
|
operator: 'eq',
|
|
right: true,
|
|
},
|
|
});
|
|
});
|
|
});
|