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
@@ -0,0 +1,545 @@
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<SkillChipSaveInput> {
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<Record<string, unknown>> = [];
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<string, unknown> | 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<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));
}
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<ReturnType<typeof readIconMutation>>;
renderMutation: Awaited<ReturnType<typeof readGameDataRenderMutation>>;
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;