Files
shd.build/src/routes/isac/gearmods/+page.server.ts
T
jb 57068b61cd
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy staging / deploy (push) Successful in 1m3s
feat: expand ISAC content modeling
2026-08-03 01:23:20 -04:00

460 lines
14 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 {
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 {
getPagination,
getRegistrySearch,
getRegistrySort,
} from '$lib/server/pagination';
import {
applyGameDataRender,
ManagedImageValidationError,
managedImageUrl,
processManagedImage,
readGameDataRenderMutation,
} from '$lib/server/managed-images';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import {
MOD_QUALITIES,
normalizeModSecondaryPool,
} from '$lib/mods/secondary-pool';
import {
getModSecondaryPool,
replaceModSecondaryPool,
} from '$lib/server/mod-secondary-pools';
const qualities = MOD_QUALITIES;
type QualityValue = {
quality: string;
primaryLevel1Min: number;
primaryLevel1Max: number;
primaryLevel20Min: number;
primaryLevel20Max: number;
};
function parseJson<T>(value: FormDataEntryValue | null, fallback: T): T {
try {
return JSON.parse(String(value ?? '')) as T;
} catch {
return fallback;
}
}
async function snapshot(id: string) {
const database = await getDatabase();
return {
record: await auditRecord('gear_mods', id),
qualityValues: await (
await database.prepare(
'SELECT * FROM gear_mod_quality_values WHERE gear_mod_id=? ORDER BY quality',
)
).all(id),
render:
(await (
await database.prepare(
"SELECT * FROM managed_images WHERE source_entity_type='gear_mod_render' AND source_entity_id=?",
)
).get(id)) ?? null,
};
}
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', 'pending', 'archived'].includes(requestedFilter)
? requestedFilter
: '';
const requestedItemId = url.searchParams.get('item')?.trim() ?? '';
const { search, pattern } = getRegistrySearch(url);
const sort = getRegistrySort(
url,
{
name: {
asc: 'name COLLATE NOCASE ASC,id ASC',
desc: 'name COLLATE NOCASE DESC,id DESC',
},
state: {
asc: 'active ASC,name COLLATE NOCASE ASC',
desc: 'active DESC,name COLLATE NOCASE ASC',
defaultDirection: 'desc',
},
},
'name',
);
const conditions = [
...(requestedItemId
? ['id=?']
: filter === 'active'
? ['active=1']
: filter === 'archived'
? ['active=0']
: filter === 'pending'
? ['1=0']
: []),
...(!requestedItemId && search
? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"]
: []),
];
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const args = [
...(requestedItemId ? [requestedItemId] : []),
...(!requestedItemId && search ? [pattern] : []),
];
const [summary, filtered, pendingStats, pendingMods] = 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 FROM gear_mods`)
).get(),
(
await database.prepare(`SELECT COUNT(*) AS total FROM gear_mods ${where}`)
).get(...args),
getPendingEntities(user.id, [
{
module: 'attributes',
action: 'save',
kind: 'attribute',
fields: ['unit'],
},
]),
getPendingEntities(user.id, [
{
module: 'gearmods',
action: 'save',
kind: 'gearmod',
fields: ['qualityValues'],
},
]),
]);
const pendingRows = requestedItemId
? pendingMods.filter((item) => item.id === requestedItemId)
: filter === 'archived' || filter === 'active'
? []
: pendingMods.filter(
(item) =>
!search ||
item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()),
);
const pagination = getPagination(
url,
Number(filtered?.total ?? 0) + pendingRows.length,
);
const pendingOnPage =
pagination.page === 1 ? pendingRows.slice(0, pagination.perPage) : [];
const databaseOffset = Math.max(0, pagination.offset - pendingRows.length);
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
const [mods, qualityRows, stats, renders, secondaryPool] = await Promise.all([
databaseLimit
? (
await database.prepare(`SELECT * FROM gear_mods ${where}
ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)
).all(...args, databaseLimit, databaseOffset)
: [],
(await database.prepare('SELECT * FROM gear_mod_quality_values')).all(),
(
await database.prepare(
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_mod_render'",
)
).all(),
getModSecondaryPool(database, 'gear'),
]);
const parsedPending = pendingOnPage.map((item) => ({
...item,
qualityValues: parseJson(String(item.qualityValues), []),
}));
return {
mods: [
...parsedPending,
...mods.map((mod) => ({
...mod,
qualityValues: qualityRows
.filter((row) => row.gear_mod_id === mod.id)
.map((row) => ({
quality: row.quality,
primaryLevel1Min: Number(row.primary_level_1_min),
primaryLevel1Max: Number(row.primary_level_1_max),
primaryLevel20Min: Number(row.primary_level_20_min),
primaryLevel20Max: Number(row.primary_level_20_max),
})),
renderUrl: (() => {
const render = renders.find(
(item) => item.source_entity_id === mod.id,
);
return render
? managedImageUrl(String(render.id), render.updated_at)
: '';
})(),
})),
],
secondaryPool,
stats: [...pendingStats, ...stats],
qualities,
summary: {
...summary,
total: Number(summary?.total ?? 0) + pendingMods.length,
pending: pendingMods.length,
},
filter,
requestedItemId,
pagination,
sort,
search,
};
};
export const actions: Actions = {
saveSecondaryPool: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const form = await request.formData();
if (String(form.get('id') ?? '') !== 'gear-mod-secondary-pool') {
return fail(400, {
gearModSecondaryPoolError:
'The shared Gear Mod pool target is invalid.',
});
}
const normalized = normalizeModSecondaryPool(
parseJson<unknown>(form.get('secondaryRanges'), null),
);
if (normalized.error) {
return fail(400, { gearModSecondaryPoolError: normalized.error });
}
const database = await getDatabase();
const statIds = normalized.pool.map((range) => range.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, {
gearModSecondaryPoolError:
'One or more selected Attributes are unavailable.',
});
}
await database.exec('BEGIN IMMEDIATE');
try {
const before = await getModSecondaryPool(database, 'gear');
await replaceModSecondaryPool(database, 'gear', normalized.pool);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'gear_mod_secondary_pool.updated',
entityId: 'gear-mod-secondary-pool',
before,
after: await getModSecondaryPool(database, 'gear'),
database,
});
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
return {
gearModSecondaryPoolSaved: true,
gearModSecondaryPoolMode: 'edit',
resultToken: randomUUID(),
};
},
save: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const form = await request.formData();
const id = String(form.get('id') ?? '');
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, { gearModError: error.message });
throw error;
}
const name = String(form.get('name') ?? '')
.normalize('NFKC')
.trim();
if (form.has('secondaryRanges')) {
return fail(400, {
gearModError:
'Secondary Attributes must be submitted through the shared pool editor.',
});
}
const qualityValues = parseJson<QualityValue[]>(
form.get('qualityValues'),
[],
);
if (name.length < 2 || name.length > 100)
return fail(400, {
gearModError: 'Gear Mod names must be 2–100 characters.',
});
if (
qualityValues.length !== qualities.length ||
new Set(qualityValues.map((row) => row.quality)).size !==
qualities.length ||
qualityValues.some(
(row) =>
!qualities.includes(row.quality as (typeof qualities)[number]) ||
[
row.primaryLevel1Min,
row.primaryLevel1Max,
row.primaryLevel20Min,
row.primaryLevel20Max,
].some((value) => !Number.isFinite(Number(value))) ||
Number(row.primaryLevel1Max) < Number(row.primaryLevel1Min) ||
Number(row.primaryLevel20Max) < Number(row.primaryLevel20Min) ||
Number(row.primaryLevel20Min) < Number(row.primaryLevel1Min) ||
Number(row.primaryLevel20Max) < Number(row.primaryLevel1Max),
)
) {
return fail(400, {
gearModError:
'Configure valid primary Level 1 and Level 20 ranges for all four qualities.',
});
}
const database = await getDatabase();
if (
await (
await database.prepare(
'SELECT id FROM gear_mods WHERE name=? COLLATE NOCASE AND id<>?',
)
).get(name, id || 'new')
) {
return fail(409, {
gearModError: 'That Gear Mod name is already registered.',
});
}
const modId =
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await snapshot(id) : null;
if (id && !before?.record)
return fail(404, { gearModError: 'Gear Mod not found.' });
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
if (id)
await (
await database.prepare(
'UPDATE gear_mods SET name=?,active=1,updated_at=? WHERE id=?',
)
).run(name, now, id);
else
await (
await database.prepare(
'INSERT INTO gear_mods(id,name,created_at,updated_at) VALUES(?,?,?,?)',
)
).run(modId, name, now, now);
await (
await database.prepare(
'DELETE FROM gear_mod_quality_values WHERE gear_mod_id=?',
)
).run(modId);
for (const row of qualityValues)
await (
await database.prepare(`INSERT INTO gear_mod_quality_values
(gear_mod_id,quality,primary_level_1_min,primary_level_1_max,primary_level_20_min,primary_level_20_max)
VALUES(?,?,?,?,?,?)`)
).run(
modId,
row.quality,
row.primaryLevel1Min,
row.primaryLevel1Max,
row.primaryLevel20Min,
row.primaryLevel20Max,
);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
await applyGameDataRender(
database,
'gear_mod',
modId,
name,
renderMutation,
operator.id,
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: id ? 'gear_mod.updated' : 'gear_mod.created',
entityId: modId,
before,
after: await snapshot(modId),
});
return {
gearModSaved: true,
gearModMode: id ? 'edit' : 'add',
resultToken: randomUUID(),
};
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '');
const before = await snapshot(id);
if (!before.record)
return fail(404, { gearModError: 'Gear Mod not found.' });
await (
await (
await getDatabase()
).prepare('UPDATE gear_mods SET active=0,updated_at=? WHERE id=?')
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'gear_mod.archived',
entityId: id,
before,
after: await snapshot(id),
});
return { gearModArchived: true };
},
unarchive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '');
const before = await snapshot(id);
if (!before.record)
return fail(404, { gearModError: 'Gear Mod not found.' });
await (
await (
await getDatabase()
).prepare('UPDATE gear_mods SET active=1,updated_at=? WHERE id=?')
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'gear_mod.unarchived',
entityId: id,
before,
after: await snapshot(id),
});
return { gearModUnarchived: true };
},
};