673 lines
23 KiB
TypeScript
673 lines
23 KiB
TypeScript
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
|
import {
|
|
requireDataView,
|
|
requirePermission,
|
|
} from '$lib/server/auth/authorization';
|
|
import { getSessionUser } from '$lib/server/auth/session';
|
|
import { getDatabase } from '$lib/server/database';
|
|
import { getPendingEntities } from '$lib/server/data-moderation';
|
|
import { gameDataIconUrl } from '$lib/server/game-data-icons';
|
|
import { managedImageUrl } from '$lib/server/managed-images';
|
|
import {
|
|
getPagination,
|
|
getRegistrySearch,
|
|
getRegistrySort,
|
|
} from '$lib/server/pagination';
|
|
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 {
|
|
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,
|
|
SKILL_CHIP_SECONDARY_QUALITIES,
|
|
} from '$lib/skill-chips/profiles';
|
|
import {
|
|
getSkillChipPrimaryAttributeIds,
|
|
getSkillChipPrimaryProfiles,
|
|
getSkillChipSecondaryPool,
|
|
replaceSkillChipPrimaryProfiles,
|
|
replaceSkillChipSecondaryPool,
|
|
} from '$lib/server/skill-chip-profiles';
|
|
|
|
export { _saveSkillChipInTransaction } from './skillchip-save';
|
|
|
|
export const load: PageServerLoad = async ({ cookies, url }) => {
|
|
const user = await requireDataView(await getSessionUser(cookies));
|
|
const database = await getDatabase();
|
|
const requestedFilter = url.searchParams.get('filter') ?? '';
|
|
const filter = ['active', 'archived', 'configured', 'assigned'].includes(
|
|
requestedFilter,
|
|
)
|
|
? requestedFilter
|
|
: '';
|
|
const requestedItemId = url.searchParams.get('item')?.trim() ?? '';
|
|
const { search, pattern } = getRegistrySearch(url);
|
|
const sort = getRegistrySort(
|
|
url,
|
|
{
|
|
name: {
|
|
asc: 'sets.name COLLATE NOCASE ASC,sets.id ASC',
|
|
desc: 'sets.name COLLATE NOCASE DESC,sets.id DESC',
|
|
},
|
|
ability: {
|
|
asc: "COALESCE((SELECT ability.name FROM specialization_abilities ability WHERE ability.id=sets.specialization_ability_id),'') COLLATE NOCASE ASC,sets.name COLLATE NOCASE ASC",
|
|
desc: "COALESCE((SELECT ability.name FROM specialization_abilities ability WHERE ability.id=sets.specialization_ability_id),'') COLLATE NOCASE DESC,sets.name COLLATE NOCASE ASC",
|
|
},
|
|
twoPiece: {
|
|
asc: "COALESCE((SELECT attribute.name FROM skill_chip_two_piece_bonuses bonus JOIN stat_attributes attribute ON attribute.id=bonus.stat_attribute_id WHERE bonus.skill_chip_set_id=sets.id),'') COLLATE NOCASE ASC,sets.name COLLATE NOCASE ASC",
|
|
desc: "COALESCE((SELECT attribute.name FROM skill_chip_two_piece_bonuses bonus JOIN stat_attributes attribute ON attribute.id=bonus.stat_attribute_id WHERE bonus.skill_chip_set_id=sets.id),'') COLLATE NOCASE DESC,sets.name COLLATE NOCASE ASC",
|
|
},
|
|
talent: {
|
|
asc: "COALESCE((SELECT talent.name FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id),'') COLLATE NOCASE ASC,sets.name COLLATE NOCASE ASC",
|
|
desc: "COALESCE((SELECT talent.name FROM skill_chip_talents talent WHERE talent.skill_chip_set_id=sets.id),'') COLLATE NOCASE DESC,sets.name COLLATE NOCASE ASC",
|
|
},
|
|
rollPools: {
|
|
asc: 'sets.name COLLATE NOCASE ASC,sets.id ASC',
|
|
desc: 'sets.name COLLATE NOCASE DESC,sets.id DESC',
|
|
},
|
|
state: {
|
|
asc: 'sets.active ASC,sets.name COLLATE NOCASE ASC',
|
|
desc: 'sets.active DESC,sets.name COLLATE NOCASE ASC',
|
|
defaultDirection: 'desc',
|
|
},
|
|
},
|
|
'name',
|
|
);
|
|
const conditions = [
|
|
...(requestedItemId
|
|
? ['sets.id=?']
|
|
: filter === 'active'
|
|
? ['sets.active=1']
|
|
: filter === 'archived'
|
|
? ['sets.active=0']
|
|
: filter === 'assigned'
|
|
? ['sets.specialization_ability_id IS NOT NULL']
|
|
: filter === 'configured'
|
|
? [
|
|
'EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=sets.id)',
|
|
]
|
|
: []),
|
|
...(!requestedItemId && search
|
|
? ["sets.name LIKE ? ESCAPE '\\' COLLATE NOCASE"]
|
|
: []),
|
|
];
|
|
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
const args = [
|
|
...(requestedItemId ? [requestedItemId] : []),
|
|
...(!requestedItemId && search ? [pattern] : []),
|
|
];
|
|
const [
|
|
summary,
|
|
filtered,
|
|
pendingStats,
|
|
pendingSets,
|
|
pendingAbilities,
|
|
pendingSpecializations,
|
|
] = await Promise.all([
|
|
(
|
|
await database.prepare(`SELECT COUNT(*) AS total,
|
|
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
|
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
|
|
SUM(CASE WHEN specialization_ability_id IS NOT NULL THEN 1 ELSE 0 END) AS assigned,
|
|
SUM(CASE WHEN EXISTS (SELECT 1 FROM skill_chip_two_piece_bonuses bonus WHERE bonus.skill_chip_set_id=skill_chip_sets.id) THEN 1 ELSE 0 END) AS configured
|
|
FROM skill_chip_sets`)
|
|
).get(),
|
|
(
|
|
await database.prepare(
|
|
`SELECT COUNT(*) AS total FROM skill_chip_sets sets ${where}`,
|
|
)
|
|
).get(...args),
|
|
getPendingEntities(user.id, [
|
|
{
|
|
module: 'attributes',
|
|
action: 'save',
|
|
kind: 'stat',
|
|
fields: ['unit'],
|
|
},
|
|
]),
|
|
getPendingEntities(user.id, [
|
|
{
|
|
module: 'skillchips',
|
|
action: 'save',
|
|
kind: 'skillchip',
|
|
fields: [
|
|
'abilityId',
|
|
'twoPieceStatId',
|
|
'twoPieceMin',
|
|
'twoPieceMax',
|
|
'hasTalent',
|
|
'talentName',
|
|
'expectedTalentId',
|
|
'authoredSource',
|
|
'expectedSourceFingerprint',
|
|
'mechanicSelectionIntent',
|
|
],
|
|
},
|
|
]),
|
|
getPendingEntities(user.id, [
|
|
{
|
|
module: 'specializations',
|
|
action: 'saveAbility',
|
|
kind: 'ability',
|
|
fields: ['specializationId'],
|
|
},
|
|
]),
|
|
getPendingEntities(user.id, [
|
|
{
|
|
module: 'specializations',
|
|
action: 'saveSpecialization',
|
|
kind: 'specialization',
|
|
},
|
|
]),
|
|
]);
|
|
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
|
const [
|
|
sets,
|
|
twoPiece,
|
|
talents,
|
|
stats,
|
|
abilities,
|
|
specializations,
|
|
abilityChipCounts,
|
|
icons,
|
|
renders,
|
|
primaryProfiles,
|
|
secondaryPool,
|
|
primaryAttributeIds,
|
|
] = await Promise.all([
|
|
(
|
|
await database.prepare(
|
|
`SELECT sets.* FROM skill_chip_sets sets ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`,
|
|
)
|
|
).all(...args, pagination.perPage, pagination.offset),
|
|
(
|
|
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
`SELECT id,skill_chip_set_id,name,created_at,updated_at
|
|
FROM skill_chip_talents`,
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT id,name,specialization_id FROM specialization_abilities WHERE active=1 ORDER BY name COLLATE NOCASE',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
'SELECT id,name FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE',
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(`SELECT specialization_ability_id,COUNT(*) AS count FROM skill_chip_sets
|
|
WHERE active=1 AND specialization_ability_id IS NOT NULL GROUP BY specialization_ability_id`)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='skill_chip'",
|
|
)
|
|
).all(),
|
|
(
|
|
await database.prepare(
|
|
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='skill_chip_render'",
|
|
)
|
|
).all(),
|
|
getSkillChipPrimaryProfiles(database),
|
|
getSkillChipSecondaryPool(database),
|
|
getSkillChipPrimaryAttributeIds(database),
|
|
]);
|
|
const availableStats = [
|
|
...pendingStats.map((item) => ({
|
|
...item,
|
|
unit: String(item.unit ?? ''),
|
|
})),
|
|
...stats,
|
|
];
|
|
const specializationNames = new Map([
|
|
...pendingSpecializations.map(
|
|
(item) => [String(item.id), String(item.name)] as const,
|
|
),
|
|
...specializations.map(
|
|
(item) => [String(item.id), String(item.name)] as const,
|
|
),
|
|
]);
|
|
const loadedAbilities = [
|
|
...pendingAbilities.map((ability) => ({
|
|
...ability,
|
|
name: `${ability.name} · ${specializationNames.get(String(ability.specializationId)) ?? 'PENDING SPECIALIZATION'}`,
|
|
chipCount: 0,
|
|
})),
|
|
...abilities.map((ability) => ({
|
|
...ability,
|
|
name: `${ability.name} · ${specializationNames.get(String(ability.specialization_id)) ?? 'SPECIALIZATION'}`,
|
|
chipCount: Number(
|
|
abilityChipCounts.find(
|
|
(item) => item.specialization_ability_id === ability.id,
|
|
)?.count ?? 0,
|
|
),
|
|
})),
|
|
];
|
|
const currentPendingSets = pendingSets.filter(
|
|
(set) =>
|
|
Boolean(set.abilityId) &&
|
|
(String(set.hasTalent) !== '1' || Boolean(set.authoredSource)),
|
|
);
|
|
const pendingSetRows = requestedItemId
|
|
? currentPendingSets.filter((set) => set.id === requestedItemId)
|
|
: filter === 'archived'
|
|
? []
|
|
: currentPendingSets
|
|
.filter((set) => filter !== 'assigned' || Boolean(set.abilityId))
|
|
.filter(
|
|
(set) =>
|
|
!search ||
|
|
String(set.name)
|
|
.toLocaleLowerCase()
|
|
.includes(search.toLocaleLowerCase()),
|
|
)
|
|
.map((set) => ({
|
|
...set,
|
|
specialization_ability_id: set.abilityId,
|
|
iconUrl: '',
|
|
renderUrl: '',
|
|
twoPiece: {
|
|
stat_attribute_id: set.twoPieceStatId,
|
|
min_value: Number(set.twoPieceMin),
|
|
max_value: Number(set.twoPieceMax),
|
|
},
|
|
talent:
|
|
String(set.hasTalent) === '1'
|
|
? {
|
|
name: set.talentName,
|
|
authoredSource: (() => {
|
|
try {
|
|
return JSON.parse(String(set.authoredSource ?? 'null'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
})(),
|
|
expectedSourceFingerprint:
|
|
String(set.expectedSourceFingerprint ?? ''),
|
|
selectedRevision: null,
|
|
publicProjection: [],
|
|
mechanicSelectionIntent:
|
|
String(set.mechanicSelectionIntent ?? 'clear'),
|
|
}
|
|
: null,
|
|
}));
|
|
const configuredSets = await Promise.all(
|
|
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
|
|
? gameDataIconUrl('skill_chip', String(set.id), icon.updated_at)
|
|
: '',
|
|
renderUrl: (() => {
|
|
const render = renders.find(
|
|
(item) => item.source_entity_id === set.id,
|
|
);
|
|
return render
|
|
? managedImageUrl(String(render.id), render.updated_at)
|
|
: '';
|
|
})(),
|
|
twoPiece:
|
|
twoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null,
|
|
talent: talent
|
|
? {
|
|
...talent,
|
|
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,
|
|
};
|
|
}),
|
|
);
|
|
return {
|
|
sets: [...pendingSetRows, ...configuredSets],
|
|
stats: availableStats,
|
|
primaryProfiles: primaryAttributeIds.map(
|
|
(statId) =>
|
|
primaryProfiles.find((profile) => profile.statId === statId) ?? {
|
|
statId,
|
|
values: {},
|
|
},
|
|
),
|
|
primaryProfileBase: primaryProfiles,
|
|
secondaryPool,
|
|
abilities: loadedAbilities,
|
|
summary: {
|
|
active: Number(summary?.active ?? 0),
|
|
archived: Number(summary?.archived ?? 0),
|
|
configured: Number(summary?.configured ?? 0),
|
|
total: Number(summary?.total ?? 0) + currentPendingSets.length,
|
|
assigned: Number(summary?.assigned ?? 0) + currentPendingSets.length,
|
|
pending: currentPendingSets.length,
|
|
},
|
|
filter,
|
|
requestedItemId,
|
|
pagination,
|
|
search,
|
|
sort,
|
|
};
|
|
};
|
|
|
|
export const actions: Actions = {
|
|
save: async ({ request, cookies }) => {
|
|
const operator = await requirePermission(
|
|
await getSessionUser(cookies),
|
|
'data.manage',
|
|
);
|
|
const form = await request.formData();
|
|
const database = await getDatabase();
|
|
const journal = new MediaMutationJournal();
|
|
try {
|
|
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) {
|
|
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;
|
|
}
|
|
},
|
|
savePrimaryProfile: async ({ request, cookies }) => {
|
|
const operator = await requirePermission(
|
|
await getSessionUser(cookies),
|
|
'data.manage',
|
|
);
|
|
const database = await getDatabase();
|
|
const form = await request.formData();
|
|
const expectedStatIds = await getSkillChipPrimaryAttributeIds(database);
|
|
const normalized = normalizeSkillChipProfiles(
|
|
(() => {
|
|
try {
|
|
return JSON.parse(String(form.get('profiles') ?? ''));
|
|
} catch {
|
|
return null;
|
|
}
|
|
})(),
|
|
SKILL_CHIP_QUALITIES,
|
|
{ expectedStatIds, minimumOptions: 4 },
|
|
);
|
|
if (normalized.error)
|
|
return fail(400, { skillChipPrimaryProfileError: normalized.error });
|
|
await database.exec('BEGIN IMMEDIATE');
|
|
try {
|
|
const before = await getSkillChipPrimaryProfiles(database);
|
|
if (JSON.stringify(before) !== String(form.get('baseProfiles') ?? '')) {
|
|
await database.exec('ROLLBACK');
|
|
if (JSON.stringify(before) === JSON.stringify(normalized.profiles))
|
|
return {
|
|
skillChipPrimaryProfileSaved: true,
|
|
resultToken: randomUUID(),
|
|
};
|
|
return fail(409, {
|
|
skillChipPrimaryProfileError:
|
|
'The shared primary profile changed after this editor opened. Review the latest values and try again.',
|
|
});
|
|
}
|
|
await replaceSkillChipPrimaryProfiles(database, normalized.profiles);
|
|
await refreshAllFocusReadiness(database);
|
|
const after = await getSkillChipPrimaryProfiles(database);
|
|
await recordIsacAudit({
|
|
actorId: operator.id,
|
|
category: 'game_data',
|
|
action: 'skill_chip_primary_profile.updated',
|
|
entityId: 'skill-chip-primary-profile',
|
|
before,
|
|
after,
|
|
database,
|
|
});
|
|
await database.exec('COMMIT');
|
|
} catch (error) {
|
|
await database.exec('ROLLBACK');
|
|
throw error;
|
|
}
|
|
return { skillChipPrimaryProfileSaved: true, resultToken: randomUUID() };
|
|
},
|
|
saveSecondaryPool: async ({ request, cookies }) => {
|
|
const operator = await requirePermission(
|
|
await getSessionUser(cookies),
|
|
'data.manage',
|
|
);
|
|
const database = await getDatabase();
|
|
const form = await request.formData();
|
|
const normalized = normalizeSkillChipProfiles(
|
|
(() => {
|
|
try {
|
|
return JSON.parse(String(form.get('profiles') ?? ''));
|
|
} catch {
|
|
return null;
|
|
}
|
|
})(),
|
|
SKILL_CHIP_SECONDARY_QUALITIES,
|
|
{ minimumOptions: 2 },
|
|
);
|
|
if (normalized.error)
|
|
return fail(400, { skillChipSecondaryPoolError: normalized.error });
|
|
const statIds = normalized.profiles.map((profile) => profile.statId);
|
|
const validStats = await (
|
|
await database.prepare(
|
|
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`,
|
|
)
|
|
).get(...statIds);
|
|
if (Number(validStats?.count) !== statIds.length)
|
|
return fail(400, {
|
|
skillChipSecondaryPoolError:
|
|
'One or more selected Attributes are unavailable.',
|
|
});
|
|
await database.exec('BEGIN IMMEDIATE');
|
|
try {
|
|
const before = await getSkillChipSecondaryPool(database);
|
|
if (JSON.stringify(before) !== String(form.get('baseProfiles') ?? '')) {
|
|
await database.exec('ROLLBACK');
|
|
if (JSON.stringify(before) === JSON.stringify(normalized.profiles))
|
|
return {
|
|
skillChipSecondaryPoolSaved: true,
|
|
resultToken: randomUUID(),
|
|
};
|
|
return fail(409, {
|
|
skillChipSecondaryPoolError:
|
|
'The shared secondary pool changed after this editor opened. Review the latest values and try again.',
|
|
});
|
|
}
|
|
await replaceSkillChipSecondaryPool(database, normalized.profiles);
|
|
await refreshAllFocusReadiness(database);
|
|
const after = await getSkillChipSecondaryPool(database);
|
|
await recordIsacAudit({
|
|
actorId: operator.id,
|
|
category: 'game_data',
|
|
action: 'skill_chip_secondary_pool.updated',
|
|
entityId: 'skill-chip-secondary-pool',
|
|
before,
|
|
after,
|
|
database,
|
|
});
|
|
await database.exec('COMMIT');
|
|
} catch (error) {
|
|
await database.exec('ROLLBACK');
|
|
throw error;
|
|
}
|
|
return { skillChipSecondaryPoolSaved: true, resultToken: randomUUID() };
|
|
},
|
|
archive: async ({ request, cookies }) => {
|
|
const operator = await requirePermission(
|
|
await getSessionUser(cookies),
|
|
'data.manage',
|
|
);
|
|
const id = String((await request.formData()).get('id') ?? '');
|
|
const database = await getDatabase();
|
|
await database.exec('BEGIN IMMEDIATE');
|
|
try {
|
|
const before = await snapshot(database, id);
|
|
if (!before.record) {
|
|
await database.exec('ROLLBACK');
|
|
return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
|
}
|
|
await (
|
|
await database.prepare(
|
|
'UPDATE skill_chip_sets SET active=0,updated_at=? WHERE id=?',
|
|
)
|
|
).run(new Date().toISOString(), id);
|
|
await refreshAbilityFocuses(
|
|
database,
|
|
String(before.record.specialization_ability_id),
|
|
);
|
|
await recordIsacAudit({
|
|
actorId: operator.id,
|
|
category: 'game_data',
|
|
action: 'skill_chip.archived',
|
|
entityId: id,
|
|
before,
|
|
after: await snapshot(database, id),
|
|
database,
|
|
});
|
|
await database.exec('COMMIT');
|
|
} catch (error) {
|
|
await database.exec('ROLLBACK');
|
|
throw error;
|
|
}
|
|
return {
|
|
skillChipArchived: true,
|
|
skillChipId: id,
|
|
resultToken: randomUUID(),
|
|
};
|
|
},
|
|
unarchive: async ({ request, cookies }) => {
|
|
const operator = await requirePermission(
|
|
await getSessionUser(cookies),
|
|
'data.manage',
|
|
);
|
|
const id = String((await request.formData()).get('id') ?? '');
|
|
const database = await getDatabase();
|
|
await database.exec('BEGIN IMMEDIATE');
|
|
try {
|
|
const before = await snapshot(database, id);
|
|
if (!before.record) {
|
|
await database.exec('ROLLBACK');
|
|
return fail(404, { skillChipError: 'Skill Chip set not found.' });
|
|
}
|
|
const abilityId = String(before.record.specialization_ability_id);
|
|
if (
|
|
!(await (
|
|
await database.prepare(
|
|
'SELECT id FROM specialization_abilities WHERE id=? AND active=1',
|
|
)
|
|
).get(abilityId))
|
|
) {
|
|
await database.exec('ROLLBACK');
|
|
return fail(400, {
|
|
skillChipError:
|
|
'The assigned specialization ability must be active before restoring this Skill Chip.',
|
|
});
|
|
}
|
|
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)
|
|
)?.count ?? 0,
|
|
);
|
|
if (assignedCount >= 3) {
|
|
await database.exec('ROLLBACK');
|
|
return fail(409, {
|
|
skillChipError:
|
|
'That ability already has all three active Skill Chips assigned.',
|
|
});
|
|
}
|
|
await (
|
|
await database.prepare(
|
|
'UPDATE skill_chip_sets SET active=1,updated_at=? WHERE id=?',
|
|
)
|
|
).run(new Date().toISOString(), id);
|
|
await refreshAbilityFocuses(database, abilityId);
|
|
await recordIsacAudit({
|
|
actorId: operator.id,
|
|
category: 'game_data',
|
|
action: 'skill_chip.unarchived',
|
|
entityId: id,
|
|
before,
|
|
after: await snapshot(database, id),
|
|
database,
|
|
});
|
|
await database.exec('COMMIT');
|
|
} catch (error) {
|
|
await database.exec('ROLLBACK');
|
|
throw error;
|
|
}
|
|
return {
|
|
skillChipUnarchived: true,
|
|
skillChipId: id,
|
|
resultToken: randomUUID(),
|
|
};
|
|
},
|
|
};
|