Files
shd.build/src/routes/isac/skillchips/+page.server.ts
T

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(),
};
},
};