feat: add community publishing and news syndication
Validate and deploy production / validate (push) Successful in 23s
Validate and deploy staging / validate (push) Successful in 50s
Validate and deploy production / deploy (push) Failing after 40s
Validate and deploy staging / deploy (push) Failing after 7s

This commit is contained in:
jb
2026-08-01 21:42:09 -04:00
parent b47e2588e9
commit dd84dff055
288 changed files with 83254 additions and 38351 deletions
+516 -367
View File
@@ -1,19 +1,23 @@
import {
parseConditionalMechanicDraft,
type ConditionalMechanicClauseDraft,
type ConditionalMechanicDraftV1,
type ConditionalMechanicOutcomeDraft,
type ConditionalMechanicPoolDraft
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 {
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'
'talent',
'os_protocol_talent',
'skill_chip_talent',
'specialization_tree_rank',
] as const;
export type ConditionalSourceType = (typeof sourceTypes)[number];
@@ -25,252 +29,360 @@ 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;
}
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';
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';
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;
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',
'state_pool_id',
'min_state_value',
'max_state_value',
'description_slot'
'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',
'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;
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.`
);
}
}
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;
} = {}
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;
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[]
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 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.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
};
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 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.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[]
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);
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,
);
}
/**
@@ -279,18 +391,22 @@ export async function loadConditionalRuleDraft(
* source record and append-only audit event.
*/
export async function saveConditionalRuleInTransaction(
database: Database,
sourceType: ConditionalSourceType,
sourceId: string,
draft: ConditionalMechanicDraftV1
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
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,?,?,?,?)
@@ -299,150 +415,183 @@ export async function saveConditionalRuleInTransaction(
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);
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
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
);
}
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
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
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,delivery_mode,delivery_duration_seconds,
targeting_json,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.minStacks ?? null,
outcome.maxStacks ?? null,
outcome.descriptionSlot ?? null,
outcomeSortOrder,
now,
now
);
}
}
}
return ruleId;
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.minStacks ?? null,
outcome.maxStacks ?? null,
outcome.descriptionSlot ?? null,
outcomeSortOrder,
now,
now,
);
}
}
}
return ruleId;
}
export async function conditionalRuleSnapshot(
database: Database,
sourceType: ConditionalSourceType,
sourceId: string
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)
};
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),
};
}