feat: add community publishing and news syndication
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user