feat: persist authored mechanic revisions

This commit is contained in:
jb
2026-09-02 15:39:50 -04:00
parent 98f7f51e46
commit 7e392a1f37
56 changed files with 20720 additions and 3279 deletions
+94 -456
View File
@@ -6,20 +6,8 @@ import {
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { getPendingEntities } from '$lib/server/data-moderation';
import {
applyIconMutation,
gameDataIconMetadata,
gameDataIconUrl,
GameDataIconValidationError,
readIconMutation,
} from '$lib/server/game-data-icons';
import {
applyGameDataRender,
ManagedImageValidationError,
managedImageUrl,
processManagedImage,
readGameDataRenderMutation,
} from '$lib/server/managed-images';
import { gameDataIconUrl } from '$lib/server/game-data-icons';
import { managedImageUrl } from '$lib/server/managed-images';
import {
getPagination,
getRegistrySearch,
@@ -29,16 +17,28 @@ import {
refreshAbilityFocuses,
refreshSpecializationFocusReady,
} from '$lib/server/specialization-readiness';
import {
SkillChipFormValidationError,
parseSkillChipForm,
_saveSkillChipInTransaction,
SkillChipNotFoundError,
loadTalent,
refreshAllFocusReadiness,
snapshot,
} from './skillchip-save';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import {
conditionalRuleSnapshot,
loadConditionalRuleDraft,
parseConditionalRulePayload,
saveConditionalRuleInTransaction,
} from '$lib/server/conditional-effects';
import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes';
loadMechanicAuthoredSource,
MechanicSourceFingerprintConflictError,
} from '$lib/server/mechanic-authored-sources';
import {
loadSelectedMechanicModelRevision,
} from '$lib/server/mechanic-model-revisions';
import { loadMechanicPublicProjection } from '$lib/server/mechanic-public-projection';
import type { Database } from '@tursodatabase/database';
import { MediaMutationJournal } from '$lib/server/media-journal';
import {
normalizeSkillChipProfiles,
SKILL_CHIP_QUALITIES,
@@ -52,87 +52,7 @@ import {
replaceSkillChipSecondaryPool,
} from '$lib/server/skill-chip-profiles';
const mechanicOutcomes = (
rule: ReturnType<typeof parseConditionalRulePayload>,
) => rule.clauses.flatMap((clause) => clause.outcomes);
const descriptionOutcomes = (
rule: ReturnType<typeof parseConditionalRulePayload>,
) =>
mechanicOutcomes(rule)
.filter((outcome) => outcome.descriptionSlot)
.sort(
(left, right) =>
Number(left.descriptionSlot) - Number(right.descriptionSlot),
);
const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
render
? {
id: render.id,
name: render.name,
category: render.category,
tags_json: render.tags_json,
original_filename: render.original_filename,
byte_size: render.byte_size,
width: render.width,
height: render.height,
source_entity_type: render.source_entity_type,
source_entity_id: render.source_entity_id,
created_at: render.created_at,
updated_at: render.updated_at,
}
: null;
async function refreshAllFocusReadiness(
database: Awaited<ReturnType<typeof getDatabase>>,
) {
const focuses = await (
await database.prepare('SELECT id FROM specialization_focuses')
).all();
for (const focus of focuses)
await refreshSpecializationFocusReady(database, String(focus.id));
}
async function snapshot(id: string) {
const database = await getDatabase();
const talent = await (
await database.prepare(
'SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?',
)
).get(id);
return {
record: await auditRecord('skill_chip_sets', id),
icon: await gameDataIconMetadata(database, 'skill_chip', id),
render: renderAuditMetadata(
await (
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='skill_chip_render' AND source_entity_id=?",
)
).get(id),
),
twoPiece:
(await (
await database.prepare(
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
)
).get(id)) ?? null,
talent: talent ?? null,
talentEffects: talent
? await (
await database.prepare(
'SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order',
)
).all(talent.id)
: [],
conditional: talent
? await conditionalRuleSnapshot(
database,
'skill_chip_talent',
String(talent.id),
)
: null,
};
}
export { _saveSkillChipInTransaction } from './skillchip-save';
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
@@ -240,8 +160,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
'twoPieceMax',
'hasTalent',
'talentName',
'descriptionTemplate',
'conditionalRule',
'expectedTalentId',
'authoredSource',
'expectedSourceFingerprint',
'mechanicSelectionIntent',
],
},
]),
@@ -266,7 +188,6 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
sets,
twoPiece,
talents,
talentEffects,
stats,
abilities,
specializations,
@@ -285,10 +206,10 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_talent_effects ORDER BY sort_order',
`SELECT id,skill_chip_set_id,name,created_at,updated_at
FROM skill_chip_talents`,
)
).all(),
(
@@ -358,7 +279,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const currentPendingSets = pendingSets.filter(
(set) =>
Boolean(set.abilityId) &&
(String(set.hasTalent) !== '1' || Boolean(set.conditionalRule)),
(String(set.hasTalent) !== '1' || Boolean(set.authoredSource)),
);
const pendingSetRows = requestedItemId
? currentPendingSets.filter((set) => set.id === requestedItemId)
@@ -387,16 +308,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
String(set.hasTalent) === '1'
? {
name: set.talentName,
description_template: set.descriptionTemplate,
conditionalRule: (() => {
authoredSource: (() => {
try {
return JSON.parse(
String(set.conditionalRule ?? 'null'),
);
return JSON.parse(String(set.authoredSource ?? 'null'));
} catch {
return null;
}
})(),
expectedSourceFingerprint:
String(set.expectedSourceFingerprint ?? ''),
selectedRevision: null,
publicProjection: [],
mechanicSelectionIntent:
String(set.mechanicSelectionIntent ?? 'clear'),
}
: null,
}));
@@ -404,6 +328,19 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
sets.map(async (set) => {
const talent = talents.find((item) => item.skill_chip_set_id === set.id);
const icon = icons.find((item) => item.entity_id === set.id);
const authoredSource = talent
? await loadMechanicAuthoredSource(
database,
'skill_chip_talent',
String(talent.id),
)
: null;
const selectedRevision = authoredSource
? await loadSelectedMechanicModelRevision(database, authoredSource.id)
: null;
const publicProjection = authoredSource
? await loadMechanicPublicProjection(database, authoredSource.id)
: [];
return {
...set,
iconUrl: icon
@@ -422,15 +359,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
talent: talent
? {
...talent,
effects: talentEffects.filter(
(item) => item.talent_id === talent.id,
),
conditionalRule: await loadConditionalRuleDraft(
database,
'skill_chip_talent',
String(talent.id),
['minimum', 'maximum'],
),
authoredSource: authoredSource
? {
version: authoredSource.source.version,
template: authoredSource.source.template,
variants: authoredSource.source.variants,
}
: null,
expectedSourceFingerprint: authoredSource?.fingerprint ?? null,
selectedRevision,
publicProjection,
mechanicSelectionIntent: selectedRevision ? 'preserve' : 'clear',
}
: null,
};
@@ -472,340 +411,31 @@ export const actions: Actions = {
'data.manage',
);
const form = await request.formData();
let iconMutation;
try {
iconMutation = await readIconMutation(form);
} catch (error) {
if (error instanceof GameDataIconValidationError)
return fail(400, { skillChipError: error.message });
throw error;
}
const renderMutation = await readGameDataRenderMutation(form);
try {
if (renderMutation.file)
renderMutation.processed = await processManagedImage(
renderMutation.file,
false,
false,
true,
);
} catch (error) {
if (error instanceof ManagedImageValidationError)
return fail(400, { skillChipError: error.message });
throw error;
}
const id = String(form.get('id') ?? '');
const name = String(form.get('name') ?? '')
.normalize('NFKC')
.trim();
const abilityId = String(form.get('abilityId') ?? '');
const twoPieceStatId = String(form.get('twoPieceStatId') ?? '');
const twoPieceMin = Number(form.get('twoPieceMin'));
const twoPieceMax = Number(form.get('twoPieceMax'));
const hasTalent = form.get('hasTalent') === '1';
const talentName = String(form.get('talentName') ?? '')
.normalize('NFKC')
.trim();
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '')
.normalize('NFKC')
.trim();
const database = await getDatabase();
let conditionalRule: ReturnType<typeof parseConditionalRulePayload> | null =
null;
const journal = new MediaMutationJournal();
try {
if (hasTalent) {
const conditionalRuleRaw = form.get('conditionalRule');
const activeStats = await (
await database.prepare(
'SELECT id FROM stat_attributes WHERE active=1',
)
).all();
conditionalRule = parseConditionalRulePayload(
conditionalRuleRaw,
[
{ key: 'minimum', variantKey: 'minimum' },
{ key: 'maximum', variantKey: 'maximum' },
],
{
allowedAttributeIds: new Set(
activeStats.map((row) => String(row.id)),
),
allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)),
},
);
}
const save = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const input = await parseSkillChipForm(tx, form, operator.id);
return _saveSkillChipInTransaction(tx, { ...input, journal });
});
const result = await save.immediate();
journal.clear();
return {
skillChipSaved: true,
skillChipMode: result.mode,
resultToken: randomUUID(),
};
} catch (error) {
return fail(400, {
skillChipError:
error instanceof Error
? error.message
: 'The conditional mechanic is invalid.',
});
}
if (name.length < 2 || name.length > 80)
return fail(400, {
skillChipError: 'Skill Chip names must be 2–80 characters.',
});
if (!abilityId)
return fail(400, {
skillChipError: 'Assign this Skill Chip to a specialization ability.',
});
if (
!twoPieceStatId ||
!Number.isFinite(twoPieceMin) ||
!Number.isFinite(twoPieceMax) ||
twoPieceMax < twoPieceMin
)
return fail(400, {
skillChipError: 'Configure a valid 2-piece stat range.',
});
const outcomes = conditionalRule ? mechanicOutcomes(conditionalRule) : [];
const numberedOutcomes = conditionalRule
? descriptionOutcomes(conditionalRule)
: [];
const durationSeconds =
conditionalRule?.pools.find((pool) => pool.expiresAfterSeconds)
?.expiresAfterSeconds ?? null;
const maxStacks = conditionalRule?.pools.length
? Math.max(...conditionalRule.pools.map((pool) => pool.max ?? 1))
: null;
if (
hasTalent &&
(talentName.length < 2 ||
talentName.length > 80 ||
!descriptionTemplate ||
!conditionalRule?.clauses.length ||
!outcomes.length)
)
return fail(400, {
skillChipError:
'The 3-piece talent requires a name, description, mechanic branch, and outcome.',
});
if (
hasTalent &&
outcomes.some(
(outcome) =>
outcome.variants.maximum.value < outcome.variants.minimum.value,
)
)
return fail(400, {
skillChipError:
'Every talent outcome maximum must be at least its minimum.',
});
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map(
(match) => Number(match[1]),
);
if (
hasTalent &&
placeholders.some(
(index) =>
!numberedOutcomes.some(
(outcome) => outcome.descriptionSlot === index,
),
)
)
return fail(400, {
skillChipError:
'Description placeholders must reference configured numbered outcomes.',
});
if (
hasTalent &&
descriptionTemplate.includes('{duration}') &&
durationSeconds === null
)
return fail(400, {
skillChipError:
'The description uses {duration}, but no expiring state pool is defined.',
});
if (hasTalent && descriptionTemplate.includes('{max_stacks}') && !maxStacks)
return fail(400, {
skillChipError:
'The description uses {max_stacks}, but no state pool maximum is defined.',
});
if (
!(await (
await database.prepare(
'SELECT id FROM specialization_abilities WHERE id=? AND active=1',
)
).get(abilityId))
) {
return fail(400, {
skillChipError: 'Choose an active specialization ability.',
});
}
const assignedCount = Number(
(
await (
await database.prepare(`SELECT COUNT(*) AS count FROM skill_chip_sets
WHERE specialization_ability_id=? AND active=1 AND id<>?`)
).get(abilityId, id || 'new')
)?.count ?? 0,
);
if (assignedCount >= 3) {
return fail(409, {
skillChipError:
'That ability already has all three active Skill Chips assigned.',
});
}
if (
await (
await database.prepare(
'SELECT id FROM skill_chip_sets WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(name, id || 'new')
)
return fail(409, {
skillChipError: 'That Skill Chip set already exists.',
});
const existingTalent = id
? await (
await database.prepare(
'SELECT id FROM skill_chip_talents WHERE skill_chip_set_id=?',
)
).get(id)
: null;
if (
existingTalent &&
!hasTalent &&
form.get('confirmTalentRemoval') !== 'REMOVE'
)
return fail(400, {
skillChipError:
'Confirm removal of the existing 3-piece Talent and mechanic program.',
});
if (
hasTalent &&
(await (
await database.prepare(
'SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(talentName, existingTalent?.id ?? 'new'))
)
return fail(409, {
skillChipError: 'That Skill Chip talent name is already in use.',
});
const outcomeStatIds = outcomes
.map((effect) => effect.attributeId)
.filter((value): value is string => Boolean(value));
const allStatIds = [...new Set([twoPieceStatId, ...outcomeStatIds])];
const validStats = await (
await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${allStatIds.map(() => '?').join(',')})`,
)
).get(...allStatIds);
if (Number(validStats?.count) !== allStatIds.length)
return fail(400, {
skillChipError: 'One or more selected stats are unavailable.',
});
const setId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const talentId = String(existingTalent?.id ?? randomUUID());
const now = new Date().toISOString();
const before = id ? await snapshot(id) : null;
if (id && !before?.record)
return fail(404, { skillChipError: 'Skill Chip set not found.' });
const previousAbilityId = String(
before?.record?.specialization_ability_id ?? '',
);
await database.exec('BEGIN');
try {
if (id) {
await (
await database.prepare(
'UPDATE skill_chip_sets SET name=?,specialization_ability_id=?,active=1,updated_at=? WHERE id=?',
)
).run(name, abilityId, now, id);
} else
await (
await database.prepare(
'INSERT INTO skill_chip_sets(id,name,specialization_ability_id,created_at,updated_at) VALUES(?,?,?,?,?)',
)
).run(setId, name, abilityId, now, now);
await (
await database.prepare(`INSERT INTO skill_chip_two_piece_bonuses(id,skill_chip_set_id,stat_attribute_id,min_value,max_value) VALUES(?,?,?,?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,min_value=excluded.min_value,max_value=excluded.max_value`)
).run(randomUUID(), setId, twoPieceStatId, twoPieceMin, twoPieceMax);
if (hasTalent && conditionalRule) {
await (
await database.prepare(`INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
ON CONFLICT(skill_chip_set_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)
).run(talentId, setId, talentName, descriptionTemplate, now, now);
await (
await database.prepare(
'DELETE FROM skill_chip_talent_effects WHERE talent_id=?',
)
).run(talentId);
let compatibilitySortOrder = 0;
for (const outcome of numberedOutcomes) {
if (outcome.kind !== 'attribute_modifier' || !outcome.attributeId)
continue;
compatibilitySortOrder += 1;
await (
await database.prepare(
'INSERT INTO skill_chip_talent_effects(id,talent_id,stat_attribute_id,min_value,max_value,sort_order) VALUES(?,?,?,?,?,?)',
)
).run(
randomUUID(),
talentId,
outcome.attributeId,
outcome.variants.minimum.value,
outcome.variants.maximum.value,
compatibilitySortOrder,
);
}
await saveConditionalRuleInTransaction(
database,
'skill_chip_talent',
talentId,
conditionalRule,
);
} else if (existingTalent) {
await (
await database.prepare(
"DELETE FROM conditional_effect_rules WHERE source_type='skill_chip_talent' AND source_id=?",
)
).run(String(existingTalent.id));
await (
await database.prepare('DELETE FROM skill_chip_talents WHERE id=?')
).run(String(existingTalent.id));
}
await applyIconMutation(
database,
'skill_chip',
setId,
iconMutation,
operator.id,
);
await applyGameDataRender(
database,
'skill_chip',
setId,
name,
renderMutation,
operator.id,
);
await refreshAbilityFocuses(database, abilityId);
if (previousAbilityId && previousAbilityId !== abilityId)
await refreshAbilityFocuses(database, previousAbilityId);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'skill_chip.updated' : 'skill_chip.created',
entityId: setId,
before,
after: await snapshot(setId),
database,
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
await journal.rollback(error);
if (error instanceof SkillChipFormValidationError)
return fail(error.status, { skillChipError: error.message });
if (error instanceof MechanicSourceFingerprintConflictError)
return fail(409, { skillChipError: error.message });
if (error instanceof SkillChipNotFoundError)
return fail(404, { skillChipError: error.message });
throw error;
}
return {
skillChipSaved: true,
skillChipMode: id ? 'edit' : 'add',
resultToken: randomUUID(),
};
},
savePrimaryProfile: async ({ request, cookies }) => {
const operator = await requirePermission(
@@ -936,7 +566,7 @@ export const actions: Actions = {
const database = await getDatabase();
await database.exec('BEGIN IMMEDIATE');
try {
const before = await snapshot(id);
const before = await snapshot(database, id);
if (!before.record) {
await database.exec('ROLLBACK');
return fail(404, { skillChipError: 'Skill Chip set not found.' });
@@ -956,7 +586,7 @@ export const actions: Actions = {
action: 'skill_chip.archived',
entityId: id,
before,
after: await snapshot(id),
after: await snapshot(database, id),
database,
});
await database.exec('COMMIT');
@@ -964,7 +594,11 @@ export const actions: Actions = {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipArchived: true };
return {
skillChipArchived: true,
skillChipId: id,
resultToken: randomUUID(),
};
},
unarchive: async ({ request, cookies }) => {
const operator = await requirePermission(
@@ -975,7 +609,7 @@ export const actions: Actions = {
const database = await getDatabase();
await database.exec('BEGIN IMMEDIATE');
try {
const before = await snapshot(id);
const before = await snapshot(database, id);
if (!before.record) {
await database.exec('ROLLBACK');
return fail(404, { skillChipError: 'Skill Chip set not found.' });
@@ -1021,7 +655,7 @@ export const actions: Actions = {
action: 'skill_chip.unarchived',
entityId: id,
before,
after: await snapshot(id),
after: await snapshot(database, id),
database,
});
await database.exec('COMMIT');
@@ -1029,6 +663,10 @@ export const actions: Actions = {
await database.exec('ROLLBACK');
throw error;
}
return { skillChipUnarchived: true };
return {
skillChipUnarchived: true,
skillChipId: id,
resultToken: randomUUID(),
};
},
};