546 lines
19 KiB
TypeScript
546 lines
19 KiB
TypeScript
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;
|
||
|