feat: persist authored mechanic revisions
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user