627 lines
21 KiB
TypeScript
627 lines
21 KiB
TypeScript
import {
|
|
parseConditionalMechanicDraft,
|
|
type ConditionalMechanicClauseDraft,
|
|
type ConditionalMechanicDraftV1,
|
|
type ConditionalMechanicOutcomeDraft,
|
|
type ConditionalMechanicPoolDraft,
|
|
} from '$lib/calculations/mechanic-editor';
|
|
import type {
|
|
MechanicOutcome,
|
|
MechanicResource,
|
|
NumericExpression,
|
|
} from '$lib/calculations/mechanic';
|
|
import type { Database } from '@tursodatabase/database';
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
const sourceTypes = [
|
|
'talent',
|
|
'os_protocol_talent',
|
|
'skill_chip_talent',
|
|
'specialization_tree_rank',
|
|
] as const;
|
|
|
|
export type ConditionalSourceType = (typeof sourceTypes)[number];
|
|
export type ConditionalValueColumn = { key: string; variantKey: string };
|
|
|
|
type RuleRow = Record<string, unknown>;
|
|
type PoolRow = Record<string, unknown>;
|
|
type ClauseRow = Record<string, unknown>;
|
|
type OutcomeRow = Record<string, unknown>;
|
|
|
|
const parseJson = <T>(value: unknown, fallback: T): T => {
|
|
try {
|
|
const parsed = JSON.parse(String(value ?? ''));
|
|
return parsed === null || parsed === undefined ? fallback : (parsed as T);
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
};
|
|
|
|
const applicationKind = (outcome: ConditionalMechanicOutcomeDraft) => {
|
|
if (outcome.kind === 'attribute_modifier') return 'active_modifier';
|
|
if (outcome.kind === 'direct_damage') return 'direct_damage';
|
|
if (outcome.kind === 'resource_cost') return 'resource_cost';
|
|
if (outcome.kind === 'weapon_reload') return 'weapon_reload';
|
|
if (outcome.kind === 'marker') return 'marker';
|
|
if (outcome.resource === 'armor') return 'restore_armor';
|
|
if (outcome.resource === 'ammo') return 'restore_ammo';
|
|
if (outcome.resource === 'signature_ability_charge')
|
|
return 'restore_signature_ability_charge';
|
|
return 'restore_health';
|
|
};
|
|
|
|
const outcomeKind = (row: OutcomeRow): MechanicOutcome['kind'] => {
|
|
if (row.application_kind === 'active_modifier') return 'attribute_modifier';
|
|
if (row.application_kind === 'direct_damage') return 'direct_damage';
|
|
if (row.application_kind === 'resource_cost') return 'resource_cost';
|
|
if (row.application_kind === 'weapon_reload') return 'weapon_reload';
|
|
if (row.application_kind === 'marker') return 'marker';
|
|
return 'restoration';
|
|
};
|
|
|
|
const outcomeResource = (row: OutcomeRow): MechanicResource | undefined => {
|
|
if (
|
|
row.resource_kind === 'health' ||
|
|
row.resource_kind === 'armor' ||
|
|
row.resource_kind === 'ammo' ||
|
|
row.resource_kind === 'signature_ability_charge'
|
|
) {
|
|
return row.resource_kind;
|
|
}
|
|
if (row.application_kind === 'restore_health') return 'health';
|
|
if (row.application_kind === 'restore_armor') return 'armor';
|
|
if (row.application_kind === 'restore_ammo') return 'ammo';
|
|
if (row.application_kind === 'restore_signature_ability_charge')
|
|
return 'signature_ability_charge';
|
|
return undefined;
|
|
};
|
|
|
|
const outcomeMetadataColumns = [
|
|
'outcome_key',
|
|
'application_kind',
|
|
'effect_scope',
|
|
'stat_attribute_id',
|
|
'value_basis',
|
|
'value_scaling',
|
|
'damage_basis',
|
|
'application_phase',
|
|
'combat_timing',
|
|
'resource_kind',
|
|
'delivery_mode',
|
|
'delivery_duration_seconds',
|
|
'targeting_json',
|
|
'probability_percent',
|
|
'state_pool_id',
|
|
'min_state_value',
|
|
'max_state_value',
|
|
'description_slot',
|
|
] as const;
|
|
|
|
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(column, variant[column]) !==
|
|
comparableMetadataValue(column, first[column]),
|
|
);
|
|
if (mismatch) {
|
|
throw new Error(
|
|
`Conditional outcome "${effectKey}" has inconsistent ${mismatch} metadata across value variants.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function parseConditionalRulePayload(
|
|
raw: FormDataEntryValue | string | null,
|
|
valueColumns: ConditionalValueColumn[],
|
|
options: {
|
|
allowedAttributeIds?: ReadonlySet<string>;
|
|
allowedRecipeKeys?: ReadonlySet<string>;
|
|
programId?: string;
|
|
programName?: string;
|
|
} = {},
|
|
) {
|
|
const result = parseConditionalMechanicDraft(raw, {
|
|
variantKeys: valueColumns.map((column) => column.variantKey),
|
|
allowedAttributeIds: options.allowedAttributeIds,
|
|
allowedRecipeKeys: options.allowedRecipeKeys,
|
|
programId: options.programId,
|
|
programName: options.programName,
|
|
});
|
|
if (!result.ok) throw new Error(result.errors.join(' '));
|
|
return result.value.draft;
|
|
}
|
|
|
|
export function conditionalRuleDraftFromRows(
|
|
rule: RuleRow | null | undefined,
|
|
poolRows: PoolRow[],
|
|
clauseRows: ClauseRow[],
|
|
outcomeRows: OutcomeRow[],
|
|
variantKeys: readonly string[],
|
|
): ConditionalMechanicDraftV1 | null {
|
|
if (!rule) return null;
|
|
const pools = poolRows
|
|
.filter((row) => row.rule_id === rule.id)
|
|
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
|
|
.map((row): ConditionalMechanicPoolDraft => {
|
|
const parameters = parseJson<Record<string, unknown>>(
|
|
row.parameters_json,
|
|
{},
|
|
);
|
|
const expiresAfterSecondsVariants =
|
|
parameters.expiresAfterSecondsVariants;
|
|
return {
|
|
key: String(row.state_key),
|
|
label: String(row.label),
|
|
stateKind: String(
|
|
row.state_kind,
|
|
) as ConditionalMechanicPoolDraft['stateKind'],
|
|
scope: String(row.state_scope) as ConditionalMechanicPoolDraft['scope'],
|
|
initial: Number(row.initial_value),
|
|
...(row.minimum_value === null || row.minimum_value === undefined
|
|
? {}
|
|
: { min: Number(row.minimum_value) }),
|
|
...(row.maximum_value === null || row.maximum_value === undefined
|
|
? {}
|
|
: { max: Number(row.maximum_value) }),
|
|
...(row.best_case_value === null || row.best_case_value === undefined
|
|
? {}
|
|
: { bestCaseValue: Number(row.best_case_value) }),
|
|
...(row.duration_seconds === null || row.duration_seconds === undefined
|
|
? {}
|
|
: { expiresAfterSeconds: Number(row.duration_seconds) }),
|
|
...(expiresAfterSecondsVariants &&
|
|
typeof expiresAfterSecondsVariants === 'object' &&
|
|
!Array.isArray(expiresAfterSecondsVariants)
|
|
? {
|
|
expiresAfterSecondsVariants:
|
|
expiresAfterSecondsVariants as Record<string, number>,
|
|
}
|
|
: {}),
|
|
};
|
|
});
|
|
const poolKeyById = new Map(
|
|
poolRows.map((row) => [String(row.id), String(row.state_key)]),
|
|
);
|
|
const rowsByClause = new Map<string, OutcomeRow[]>();
|
|
for (const row of outcomeRows.filter((item) => item.rule_id === rule.id)) {
|
|
const key = String(row.clause_id ?? '');
|
|
rowsByClause.set(key, [...(rowsByClause.get(key) ?? []), row]);
|
|
}
|
|
const clauses = clauseRows
|
|
.filter((row) => row.rule_id === rule.id && Number(row.active ?? 1) === 1)
|
|
.sort((left, right) => Number(left.sort_order) - Number(right.sort_order))
|
|
.map((row): ConditionalMechanicClauseDraft => {
|
|
const parameters = parseJson<Record<string, unknown>>(
|
|
row.recipe_parameters_json,
|
|
{},
|
|
);
|
|
const grouped = new Map<string, OutcomeRow[]>();
|
|
for (const outcome of rowsByClause.get(String(row.id)) ?? []) {
|
|
const key = String(outcome.effect_key);
|
|
grouped.set(key, [...(grouped.get(key) ?? []), outcome]);
|
|
}
|
|
const outcomes = [...grouped.entries()].map(
|
|
([key, variants]): ConditionalMechanicOutcomeDraft => {
|
|
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),
|
|
phase: String(
|
|
first.application_phase,
|
|
) as ConditionalMechanicOutcomeDraft['phase'],
|
|
kind: outcomeKind(first),
|
|
scope: String(
|
|
first.effect_scope,
|
|
) as ConditionalMechanicOutcomeDraft['scope'],
|
|
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.targeting_json
|
|
? { targeting: parseJson(first.targeting_json, undefined) }
|
|
: {}),
|
|
...(first.probability_percent === null ||
|
|
first.probability_percent === undefined
|
|
? {}
|
|
: { probabilityPercent: Number(first.probability_percent) }),
|
|
...(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)) }
|
|
: {}),
|
|
perStack: first.value_scaling === 'per_state_unit',
|
|
...(first.min_state_value === null ||
|
|
first.min_state_value === undefined
|
|
? {}
|
|
: { minStacks: Number(first.min_state_value) }),
|
|
...(first.max_state_value === null ||
|
|
first.max_state_value === undefined
|
|
? {}
|
|
: { maxStacks: Number(first.max_state_value) }),
|
|
...(first.description_slot === null ||
|
|
first.description_slot === undefined
|
|
? {}
|
|
: { descriptionSlot: Number(first.description_slot) }),
|
|
variants: Object.fromEntries(
|
|
variantKeys.map((variant) => {
|
|
const variantRow = variants.find(
|
|
(candidate) => candidate.variant_key === variant,
|
|
);
|
|
return [
|
|
variant,
|
|
{
|
|
value: Number(variantRow?.value ?? 0),
|
|
...(variantRow?.value_expression_json
|
|
? {
|
|
expression: parseJson<NumericExpression>(
|
|
variantRow.value_expression_json,
|
|
Number(variantRow.value ?? 0),
|
|
),
|
|
}
|
|
: {}),
|
|
},
|
|
];
|
|
}),
|
|
),
|
|
};
|
|
},
|
|
);
|
|
return {
|
|
key: String(row.clause_key),
|
|
label: String(row.label),
|
|
event: String(
|
|
row.event_kind,
|
|
) as ConditionalMechanicClauseDraft['event'],
|
|
...(row.recipe_key ? { recipeKey: String(row.recipe_key) } : {}),
|
|
if: parseJson(row.conditions_json, { kind: 'true' }),
|
|
reducer: parseJson(row.reducer_json, { kind: 'none' }),
|
|
...(row.weight_expression_json
|
|
? {
|
|
weight: parseJson<NumericExpression>(
|
|
row.weight_expression_json,
|
|
1,
|
|
),
|
|
}
|
|
: {}),
|
|
...(typeof parameters.procChancePercent === 'number'
|
|
? { procChancePercent: parameters.procChancePercent }
|
|
: {}),
|
|
...(typeof parameters.requiresAssignedAbilityActive === 'boolean'
|
|
? {
|
|
requiresAssignedAbilityActive:
|
|
parameters.requiresAssignedAbilityActive,
|
|
}
|
|
: {}),
|
|
...(row.cooldown_seconds === null || row.cooldown_seconds === undefined
|
|
? {}
|
|
: { cooldownSeconds: Number(row.cooldown_seconds) }),
|
|
mutations: parseJson(row.mutations_json, []),
|
|
outcomes,
|
|
};
|
|
});
|
|
return {
|
|
version: 1,
|
|
simulationReadiness: String(
|
|
rule.simulation_readiness ?? 'captured_only',
|
|
) as ConditionalMechanicDraftV1['simulationReadiness'],
|
|
scenarioInputs: parseJson(rule.scenario_inputs_json, []),
|
|
pools,
|
|
clauses,
|
|
};
|
|
}
|
|
|
|
export async function loadConditionalRuleDraft(
|
|
database: Database,
|
|
sourceType: ConditionalSourceType,
|
|
sourceId: string,
|
|
variantKeys: readonly string[],
|
|
) {
|
|
const rule = await (
|
|
await database.prepare(
|
|
'SELECT * FROM conditional_effect_rules WHERE source_type=? AND source_id=?',
|
|
)
|
|
).get(sourceType, sourceId);
|
|
if (!rule) return null;
|
|
const [pools, clauses, outcomes] = await Promise.all([
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM conditional_effect_state_pools WHERE rule_id=? ORDER BY sort_order',
|
|
)
|
|
).all(rule.id),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM conditional_effect_clauses WHERE rule_id=? ORDER BY sort_order',
|
|
)
|
|
).all(rule.id),
|
|
(
|
|
await database.prepare(
|
|
'SELECT * FROM conditional_effect_outcomes WHERE rule_id=? ORDER BY sort_order,variant_key',
|
|
)
|
|
).all(rule.id),
|
|
]);
|
|
return conditionalRuleDraftFromRows(
|
|
rule,
|
|
pools,
|
|
clauses,
|
|
outcomes,
|
|
variantKeys,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Replaces one source-owned mechanic program inside the caller's active transaction.
|
|
* Every ISAC source mutation must include this write in the same transaction as its
|
|
* source record and append-only audit event.
|
|
*/
|
|
export async function saveConditionalRuleInTransaction(
|
|
database: Database,
|
|
sourceType: ConditionalSourceType,
|
|
sourceId: string,
|
|
draft: ConditionalMechanicDraftV1,
|
|
) {
|
|
if (!sourceTypes.includes(sourceType))
|
|
throw new Error('Unsupported conditional source.');
|
|
const existing = await (
|
|
await database.prepare(
|
|
'SELECT id,created_at FROM conditional_effect_rules WHERE source_type=? AND source_id=?',
|
|
)
|
|
).get(sourceType, sourceId);
|
|
const now = new Date().toISOString();
|
|
const ruleId = String(existing?.id ?? randomUUID());
|
|
await (
|
|
await database.prepare(`INSERT INTO conditional_effect_rules
|
|
(id,source_type,source_id,configured,script_version,simulation_readiness,
|
|
scenario_inputs_json,created_at,updated_at)
|
|
VALUES(?,?,?,1,1,?,?,?,?)
|
|
ON CONFLICT(source_type,source_id) DO UPDATE SET
|
|
configured=1,
|
|
script_version=1,
|
|
simulation_readiness=excluded.simulation_readiness,
|
|
scenario_inputs_json=excluded.scenario_inputs_json,
|
|
updated_at=excluded.updated_at`)
|
|
).run(
|
|
ruleId,
|
|
sourceType,
|
|
sourceId,
|
|
draft.simulationReadiness,
|
|
JSON.stringify(draft.scenarioInputs),
|
|
String(existing?.created_at ?? now),
|
|
now,
|
|
);
|
|
await (
|
|
await database.prepare(
|
|
'DELETE FROM conditional_effect_outcomes WHERE rule_id=?',
|
|
)
|
|
).run(ruleId);
|
|
await (
|
|
await database.prepare(
|
|
'DELETE FROM conditional_effect_clauses WHERE rule_id=?',
|
|
)
|
|
).run(ruleId);
|
|
await (
|
|
await database.prepare(
|
|
'DELETE FROM conditional_effect_state_pools WHERE rule_id=?',
|
|
)
|
|
).run(ruleId);
|
|
|
|
const poolIds = new Map<string, string>();
|
|
for (const [index, pool] of draft.pools.entries()) {
|
|
const expiresAfterSecondsVariants = (
|
|
pool as ConditionalMechanicPoolDraft & {
|
|
expiresAfterSecondsVariants?: Record<string, number>;
|
|
}
|
|
).expiresAfterSecondsVariants;
|
|
const poolId = randomUUID();
|
|
poolIds.set(pool.key, poolId);
|
|
await (
|
|
await database.prepare(`INSERT INTO conditional_effect_state_pools
|
|
(id,rule_id,state_key,label,state_kind,state_scope,initial_value,minimum_value,
|
|
maximum_value,best_case_value,duration_seconds,expiry_policy,refresh_policy,
|
|
parameters_json,sort_order,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
).run(
|
|
poolId,
|
|
ruleId,
|
|
pool.key,
|
|
pool.label,
|
|
pool.stateKind,
|
|
pool.scope,
|
|
pool.initial,
|
|
pool.min ?? null,
|
|
pool.max ?? Math.max(1, pool.bestCaseValue ?? pool.initial ?? 1),
|
|
pool.bestCaseValue ?? null,
|
|
pool.expiresAfterSeconds ?? null,
|
|
'clear_all',
|
|
'refresh',
|
|
JSON.stringify(
|
|
expiresAfterSecondsVariants ? { expiresAfterSecondsVariants } : {},
|
|
),
|
|
index + 1,
|
|
now,
|
|
now,
|
|
);
|
|
}
|
|
|
|
let outcomeSortOrder = 0;
|
|
for (const [clauseIndex, clause] of draft.clauses.entries()) {
|
|
const clauseId = randomUUID();
|
|
await (
|
|
await database.prepare(`INSERT INTO conditional_effect_clauses
|
|
(id,rule_id,clause_key,label,event_kind,recipe_key,recipe_parameters_json,
|
|
conditions_json,reducer_json,weight_expression_json,emission_kind,state_pool_id,
|
|
mutations_json,cooldown_seconds,output_branch,sort_order,active,created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
).run(
|
|
clauseId,
|
|
ruleId,
|
|
clause.key,
|
|
clause.label || clause.key,
|
|
clause.event,
|
|
clause.recipeKey ?? null,
|
|
JSON.stringify({
|
|
...(clause.procChancePercent === undefined
|
|
? {}
|
|
: { procChancePercent: clause.procChancePercent }),
|
|
...(clause.requiresAssignedAbilityActive === undefined
|
|
? {}
|
|
: {
|
|
requiresAssignedAbilityActive:
|
|
clause.requiresAssignedAbilityActive,
|
|
}),
|
|
}),
|
|
JSON.stringify(clause.if ?? { kind: 'true' }),
|
|
JSON.stringify(clause.reducer),
|
|
clause.weight === undefined ? null : JSON.stringify(clause.weight),
|
|
'each_match',
|
|
null,
|
|
JSON.stringify(clause.mutations),
|
|
clause.cooldownSeconds ?? null,
|
|
'active',
|
|
clauseIndex + 1,
|
|
1,
|
|
now,
|
|
now,
|
|
);
|
|
for (const [outcomeIndex, outcome] of clause.outcomes.entries()) {
|
|
for (const [variantKey, variant] of Object.entries(outcome.variants)) {
|
|
outcomeSortOrder += 1;
|
|
await (
|
|
await database.prepare(`INSERT INTO conditional_effect_outcomes
|
|
(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,delivery_mode,delivery_duration_seconds,
|
|
targeting_json,probability_percent,min_state_value,max_state_value,description_slot,sort_order,
|
|
created_at,updated_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
).run(
|
|
randomUUID(),
|
|
ruleId,
|
|
clauseId,
|
|
outcome.statePool ? (poolIds.get(outcome.statePool) ?? null) : null,
|
|
outcome.key,
|
|
variantKey,
|
|
outcome.label,
|
|
applicationKind(outcome),
|
|
outcome.scope,
|
|
outcome.attributeId ?? null,
|
|
variant.value,
|
|
variant.expression === undefined
|
|
? null
|
|
: JSON.stringify(variant.expression),
|
|
outcome.basis,
|
|
outcome.perStack === false ? 'flat' : 'per_state_unit',
|
|
null,
|
|
outcome.kind === 'direct_damage'
|
|
? outcome.basis === 'weapon_base_percent'
|
|
? 'weapon_base_percent'
|
|
: 'flat'
|
|
: null,
|
|
outcome.phase,
|
|
outcome.combatTiming ?? 'current',
|
|
outcome.resource ?? null,
|
|
outcome.delivery ?? 'instant',
|
|
outcome.durationSeconds ?? null,
|
|
outcome.targeting === undefined
|
|
? null
|
|
: JSON.stringify(outcome.targeting),
|
|
outcome.probabilityPercent ?? null,
|
|
outcome.minStacks ?? null,
|
|
outcome.maxStacks ?? null,
|
|
outcome.descriptionSlot ?? null,
|
|
outcomeSortOrder,
|
|
now,
|
|
now,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return ruleId;
|
|
}
|
|
|
|
export async function conditionalRuleSnapshot(
|
|
database: Database,
|
|
sourceType: ConditionalSourceType,
|
|
sourceId: string,
|
|
) {
|
|
const rule = await (
|
|
await database.prepare(
|
|
'SELECT * FROM conditional_effect_rules WHERE source_type=? AND source_id=?',
|
|
)
|
|
).get(sourceType, sourceId);
|
|
if (!rule) return null;
|
|
return {
|
|
rule,
|
|
statePools: await (
|
|
await database.prepare(
|
|
'SELECT * FROM conditional_effect_state_pools WHERE rule_id=? ORDER BY sort_order',
|
|
)
|
|
).all(rule.id),
|
|
clauses: await (
|
|
await database.prepare(
|
|
'SELECT * FROM conditional_effect_clauses WHERE rule_id=? ORDER BY sort_order',
|
|
)
|
|
).all(rule.id),
|
|
outcomes: await (
|
|
await database.prepare(
|
|
'SELECT * FROM conditional_effect_outcomes WHERE rule_id=? ORDER BY variant_key,outcome_key,sort_order',
|
|
)
|
|
).all(rule.id),
|
|
};
|
|
}
|