Files
shd.build/src/lib/server/conditional-effects.test.ts
T
jb 57068b61cd
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy staging / deploy (push) Successful in 1m3s
feat: expand ISAC content modeling
2026-08-03 01:23:20 -04:00

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,
},
});
});
});