Files
shd.build/src/routes/isac/skillchips/skillchip-save.ts
T

546 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;