Files
shd.build/src/lib/server/conditional-effects.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

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