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; type PoolRow = Record; type ClauseRow = Record; type OutcomeRow = Record; const parseJson = (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; allowedRecipeKeys?: ReadonlySet; 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>( 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, } : {}), }; }); const poolKeyById = new Map( poolRows.map((row) => [String(row.id), String(row.state_key)]), ); const rowsByClause = new Map(); 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>( row.recipe_parameters_json, {}, ); const grouped = new Map(); 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( 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( 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(); for (const [index, pool] of draft.pools.entries()) { const expiresAfterSecondsVariants = ( pool as ConditionalMechanicPoolDraft & { expiresAfterSecondsVariants?: Record; } ).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), }; }