import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { getDatabase } from '$lib/server/database'; import type { Database } from '@tursodatabase/database'; import { randomUUID } from 'node:crypto'; import { applyIconMutation, gameDataIconMetadata, GameDataIconValidationError, readIconMutation, } from '$lib/server/game-data-icons'; import { applyGameDataRender, readGameDataRenderMutation, ManagedImageValidationError, processManagedImage, } from '$lib/server/managed-images'; import { refreshAbilityFocuses, refreshSpecializationFocusReady, } from '$lib/server/specialization-readiness'; import { MECHANIC_RECIPE_REGISTRY } from '$lib/calculations/mechanic-recipes'; import { loadMechanicAuthoredSource, MechanicSourceFingerprintConflictError, mechanicAuthoredSourceSnapshot, } from '$lib/server/mechanic-authored-sources'; import { loadMechanicPublicProjection, mechanicPublicProjectionSnapshot, } from '$lib/server/mechanic-public-projection'; import { loadSelectedMechanicModelRevision, mechanicModelRevisionSnapshot, } from '$lib/server/mechanic-model-revisions'; import { parseExpectedOwnerUpdatedAt, parseExpectedSourceFingerprint, parseMechanicAuthoredSourcePayload, parseMechanicSelectionIntent, persistMechanicOwnerStateInTransaction, type MechanicOwnerSelection, } from '$lib/server/mechanic-owner-mutation'; import type { MechanicAuthoredSource } from '$lib/calculations/mechanic-authored-description'; import type { MediaMutationJournal } from '$lib/server/media-journal'; export class SkillChipFormValidationError extends Error { readonly status: number; constructor(status: number, message: string) { super(message); this.name = 'SkillChipFormValidationError'; this.status = status; } } export class SkillChipTalentIdentityConflictError extends SkillChipFormValidationError { constructor(expectedTalentId: string, currentTalentId: string) { super( 409, `The Skill Chip Talent changed after this editor was loaded (expected ${expectedTalentId || 'none'}, found ${currentTalentId || 'none'}). Reload the latest definition and try again.`, ); this.name = 'SkillChipTalentIdentityConflictError'; } } function parseExpectedTalentId(value: FormDataEntryValue | null): string { if (value === null) return ''; if ( typeof value !== 'string' || value !== value.trim() || value.length > 200 || /[\u0000-\u001f\u007f-\u009f]/u.test(value) ) throw new SkillChipFormValidationError( 400, 'The expected Skill Chip Talent identity is invalid.', ); return value; } /** Parses the canonical Skill Chip save form for direct saves and replay. */ export async function parseSkillChipForm( database: Database, form: FormData, actorId: string, mutationContext?: SkillChipMutationContext, ): Promise { const id = String(form.get('id') ?? '').trim(); let expectedOwnerUpdatedAt: string | null; try { expectedOwnerUpdatedAt = parseExpectedOwnerUpdatedAt( form.get('expectedOwnerUpdatedAt'), Boolean(id), ); } catch (error) { throw new SkillChipFormValidationError( 400, error instanceof Error ? error.message : 'The expected owner version is invalid.', ); } const existingSet = id ? await ( await database.prepare('SELECT updated_at FROM skill_chip_sets WHERE id=?') ).get(id) : null; if (id && !existingSet) throw new SkillChipFormValidationError(404, 'Skill Chip set not found.'); if (id && expectedOwnerUpdatedAt !== String(existingSet?.updated_at ?? '')) throw new SkillChipFormValidationError( 409, 'The Skill Chip set changed after this editor was loaded. Reload the latest definition and try again.', ); const expectedTalentId = parseExpectedTalentId(form.get('expectedTalentId')); const existingTalent = id ? await loadTalent(database, id) : null; const currentTalentId = String(existingTalent?.id ?? ''); // The child identity is the optimistic concurrency token for this owner. if (expectedTalentId !== currentTalentId) throw new SkillChipTalentIdentityConflictError( expectedTalentId, currentTalentId, ); let iconMutation; try { iconMutation = await readIconMutation(form); } catch (error) { if (error instanceof GameDataIconValidationError) throw new SkillChipFormValidationError(400, 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) throw new SkillChipFormValidationError(400, error.message); throw error; } const proposedEntityId = String(form.get('_proposedEntityId') ?? '').trim(); const name = String(form.get('name') ?? '').normalize('NFKC').trim(); const abilityId = String(form.get('abilityId') ?? '').trim(); const twoPieceStatId = String(form.get('twoPieceStatId') ?? '').trim(); 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 talentId = String(existingTalent?.id ?? randomUUID()); let expectedSourceFingerprint: string | null = null; if (hasTalent || existingTalent) try { expectedSourceFingerprint = parseExpectedSourceFingerprint( form.get('expectedSourceFingerprint'), ); } catch (error) { throw new SkillChipFormValidationError( 400, error instanceof Error ? error.message : 'The expected authored-source fingerprint is invalid.', ); } const invalid = (status: number, message: string): never => { throw new SkillChipFormValidationError(status, message); }; if (name.length < 2 || name.length > 80) invalid(400, 'Skill Chip names must be 2–80 characters.'); if (!abilityId) invalid(400, 'Assign this Skill Chip to a specialization ability.'); if (!twoPieceStatId || !Number.isFinite(twoPieceMin) || !Number.isFinite(twoPieceMax) || twoPieceMax < twoPieceMin) invalid(400, 'Configure a valid 2-piece stat range.'); if (existingTalent && !hasTalent && form.get('confirmTalentRemoval') !== 'REMOVE') invalid(400, 'Confirm removal of the existing 3-piece Talent and mechanic program.'); let authoredSource: MechanicAuthoredSource | null = null; let selection: MechanicOwnerSelection = { intent: 'clear' }; let activeStats: Array> = []; if (hasTalent) { if (talentName.length < 2 || talentName.length > 80) invalid(400, 'Talent names must be 2–80 characters.'); activeStats = await (await database.prepare('SELECT id,name FROM stat_attributes WHERE active=1')).all(); try { authoredSource = parseMechanicAuthoredSourcePayload( form.get('authoredSource'), { type: 'skill_chip_talent', id: talentId }, ); selection = parseMechanicSelectionIntent( form.get('mechanicSelectionIntent'), form.get('conditionalRule'), { variantKeys: ['minimum', 'maximum'], allowedAttributeIds: new Set(activeStats.map((row) => String(row.id))), allowedRecipeKeys: new Set(Object.keys(MECHANIC_RECIPE_REGISTRY)), programName: talentName, ownerType: 'skill_chip_talent', }, ); const authoredAttributeIds = [ ...new Set(authoredSource.variants.flatMap((variant) => variant.slots.map((slot) => slot.attributeId).filter((value): value is string => Boolean(value)), )), ]; if (authoredAttributeIds.some((attributeId) => !activeStats.some((row) => String(row.id) === attributeId))) throw new Error('One or more authored Attributes are unavailable.'); } catch (error) { invalid(400, error instanceof Error ? error.message : 'The authored Talent source is invalid.'); } } if (!(await (await database.prepare('SELECT id FROM specialization_abilities WHERE id=? AND active=1')).get(abilityId))) invalid(400, '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) invalid(409, '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')) invalid(409, 'That Skill Chip set already exists.'); if (hasTalent && await (await database.prepare('SELECT id FROM skill_chip_talents WHERE name=? COLLATE NOCASE AND id<>?')).get(talentName, talentId)) invalid(409, 'That Skill Chip talent name is already in use.'); const validStats = await (await database.prepare('SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id=?')).get(twoPieceStatId); if (Number(validStats?.count) !== 1) invalid(400, 'One or more selected stats are unavailable.'); return { id, proposedEntityId, name, abilityId, twoPieceStatId, twoPieceMin, twoPieceMax, hasTalent, talentName, talentId, expectedTalentId, expectedOwnerUpdatedAt, authoredSource, expectedSourceFingerprint, selection, confirmTalentRemoval: form.get('confirmTalentRemoval') === 'REMOVE', iconMutation, renderMutation, actorId, mutationContext, resolveAttributeLabel: (attributeId) => activeStats.find((row) => String(row.id) === attributeId)?.name as string | undefined, }; } const renderAuditMetadata = ( render: Record | 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; export async function refreshAllFocusReadiness( database: Awaited>, ) { const focuses = await ( await database.prepare('SELECT id FROM specialization_focuses') ).all(); for (const focus of focuses) await refreshSpecializationFocusReady(database, String(focus.id)); } export async function loadTalent(database: Database, setId: string) { return ( (await ( await database.prepare( `SELECT id,skill_chip_set_id,name,created_at,updated_at FROM skill_chip_talents WHERE skill_chip_set_id=?`, ) ).get(setId)) ?? null ); } async function mechanicStateSnapshot( database: Database, talentId: string | null, ) { if (!talentId) return null; const authored = await loadMechanicAuthoredSource( database, 'skill_chip_talent', talentId, ); if (!authored) return null; const [selectedRevision, projection] = await Promise.all([ loadSelectedMechanicModelRevision(database, authored.id), loadMechanicPublicProjection(database, authored.id), ]); return { authoredSource: mechanicAuthoredSourceSnapshot(authored), selectedRevision: mechanicModelRevisionSnapshot(selectedRevision), publicProjection: mechanicPublicProjectionSnapshot( selectedRevision, projection, ), }; } export async function snapshot(database: Database, id: string) { const talent = await loadTalent(database, id); const mechanic = await mechanicStateSnapshot( database, talent ? String(talent.id) : null, ); return { record: await auditRecord('skill_chip_sets', id, database), 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, mechanic, }; } export type SkillChipMutationContext = { origin?: 'isac' | 'proposal'; proposalId?: string; }; export type SkillChipSaveInput = { id: string; proposedEntityId: string; name: string; abilityId: string; twoPieceStatId: string; twoPieceMin: number; twoPieceMax: number; hasTalent: boolean; talentName: string; talentId: string; expectedTalentId: string; expectedOwnerUpdatedAt?: string | null; authoredSource: MechanicAuthoredSource | null; expectedSourceFingerprint: string | null; selection: MechanicOwnerSelection; confirmTalentRemoval: boolean; iconMutation: Awaited>; renderMutation: Awaited>; actorId: string; mutationContext?: SkillChipMutationContext; journal?: MediaMutationJournal; resolveAttributeLabel?: (attributeId: string) => string | null | undefined; }; export class SkillChipNotFoundError extends Error { constructor() { super('Skill Chip set not found.'); this.name = 'SkillChipNotFoundError'; } } /** * Persists a Skill Chip owner and all of its dependent state in the caller's * active transaction. Keeping the transaction outside this function lets Data * Oversight replay the exact same canonical save path. */ export async function saveSkillChipInTransaction( database: Database, input: SkillChipSaveInput, ): Promise<{ setId: string; mode: 'add' | 'edit' }> { const setId = input.id || input.proposedEntityId || randomUUID(); const before = input.id ? await snapshot(database, input.id) : null; if (input.id && !before?.record) throw new SkillChipNotFoundError(); if ( input.id && input.expectedOwnerUpdatedAt !== String(before?.record?.updated_at ?? '') ) throw new SkillChipFormValidationError( 409, 'The Skill Chip set changed after this editor was loaded. Reload the latest definition and try again.', ); const previousAbilityId = String( before?.record?.specialization_ability_id ?? '', ); const existingTalent = input.id ? await loadTalent(database, input.id) : null; const currentTalentId = String(existingTalent?.id ?? ''); // Recheck inside the write transaction before any owner or media writes. if (input.expectedTalentId !== currentTalentId) throw new SkillChipTalentIdentityConflictError( input.expectedTalentId, currentTalentId, ); const talentId = String(existingTalent?.id ?? input.talentId); if (input.hasTalent && !input.authoredSource) throw new Error('The authored description is required for a Talent.'); if ( input.authoredSource && (input.authoredSource.owner.type !== 'skill_chip_talent' || input.authoredSource.owner.id !== talentId) ) { throw new Error('The authored mechanic owner does not match the Skill Chip Talent.'); } if (existingTalent && !input.hasTalent && !input.confirmTalentRemoval) throw new Error( 'Confirm removal of the existing 3-piece Talent and mechanic program.', ); if (existingTalent && !input.hasTalent) { const currentSource = await loadMechanicAuthoredSource( database, 'skill_chip_talent', talentId, ); if (input.expectedSourceFingerprint !== (currentSource?.fingerprint ?? null)) throw new MechanicSourceFingerprintConflictError( input.expectedSourceFingerprint, currentSource?.fingerprint ?? null, ); } // The parser checks this for immediate form feedback, but this query on the // write transaction is authoritative for canonical callers and retries. 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(input.abilityId, setId) )?.count ?? 0, ); if (assignedCount >= 3) throw new Error('That ability already has all three active Skill Chips assigned.'); const now = new Date().toISOString(); if (input.id) { await ( await database.prepare( 'UPDATE skill_chip_sets SET name=?,specialization_ability_id=?,active=1,updated_at=? WHERE id=?', ) ).run(input.name, input.abilityId, now, input.id); } else { await ( await database.prepare( 'INSERT INTO skill_chip_sets(id,name,specialization_ability_id,created_at,updated_at) VALUES(?,?,?,?,?)', ) ).run(setId, input.name, input.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, input.twoPieceStatId, input.twoPieceMin, input.twoPieceMax, ); if (input.hasTalent && input.authoredSource) { 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,updated_at=excluded.updated_at`) ).run(talentId, setId, input.talentName, now, now); await persistMechanicOwnerStateInTransaction(database, { source: input.authoredSource, expectedSourceFingerprint: input.expectedSourceFingerprint, selection: input.selection, programName: input.talentName, origin: input.mutationContext?.origin ?? 'isac', actorId: input.actorId, resolveAttributeLabel: input.resolveAttributeLabel, }); } else if (existingTalent) { // Preserve the authored source, selection, projection, and immutable // revisions as append-only history. Public readers start from the Talent // identity row, so the detached mechanic cannot leak after this deletion. await ( await database.prepare('DELETE FROM skill_chip_talents WHERE id=?') ).run(String(existingTalent.id)); } await applyIconMutation( database, 'skill_chip', setId, input.iconMutation, input.actorId, input.journal, ); await applyGameDataRender( database, 'skill_chip', setId, input.name, input.renderMutation, input.actorId, input.journal, ); await refreshAbilityFocuses(database, input.abilityId); if (previousAbilityId && previousAbilityId !== input.abilityId) await refreshAbilityFocuses(database, previousAbilityId); const after = await snapshot(database, setId); await recordIsacAudit({ actorId: input.actorId, category: 'game_data', action: input.id ? 'skill_chip.updated' : 'skill_chip.created', entityId: setId, before, after: { ...after, mutationContext: { origin: input.mutationContext?.origin ?? 'isac', ...(input.mutationContext?.proposalId ? { proposalId: input.mutationContext.proposalId } : {}), }, }, database, }); return { setId, mode: input.id ? 'edit' : 'add' }; } /** Public for Data Oversight replay; SvelteKit page exports require an underscore prefix. */ export const _saveSkillChipInTransaction = saveSkillChipInTransaction;