feat: add source-owned combat mechanic programs

This commit is contained in:
JB
2026-07-27 13:35:00 -04:00
parent 5a67b252aa
commit 4eb754e7c8
62 changed files with 14815 additions and 905 deletions
+392
View File
@@ -0,0 +1,392 @@
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 === 'marker') return 'marker';
if (outcome.resource === 'armor') return 'restore_armor';
if (outcome.resource === 'ammo') return 'restore_ammo';
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 === 'marker') return 'marker';
return 'restoration';
};
const outcomeResource = (row: OutcomeRow): MechanicResource | undefined => {
if (row.resource_kind === 'health' || row.resource_kind === 'armor' || row.resource_kind === 'ammo') {
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';
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',
'state_pool_id',
'min_state_value',
'max_state_value',
'description_slot'
] as const;
const comparableMetadataValue = (value: unknown) => 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])
);
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 => ({
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) })
}));
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 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);
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' } : {}),
...(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) }
: {}),
...(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 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',
'{}',
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.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,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.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)
};
}