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

565 lines
19 KiB
TypeScript

import { 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 {
getPagination,
getRegistrySearch,
getRegistrySort,
} from '$lib/server/pagination';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { gameDataIconUrl } from '$lib/server/game-data-icons';
import { managedImageUrl } from '$lib/server/managed-images';
import { getPendingEntities } from '$lib/server/data-moderation';
import {
MechanicSourceFingerprintConflictError,
} from '$lib/server/mechanic-authored-sources';
import {
ProtocolFormValidationError,
ProtocolNotFoundError,
loadProtocolTalentMechanic,
parseProtocolForm,
protocolSnapshot,
saveProtocolInTransaction,
} from './protocol-save';
import {
getOsProtocolProfileAttributeIds,
type OsProtocolAttributeType,
} from '$lib/server/os-protocol-profiles';
import type { Database } from '@tursodatabase/database';
import { MediaMutationJournal } from '$lib/server/media-journal';
// SvelteKit permits underscored exports from route modules. Keep this alias
// available to Data Oversight and transaction-focused tests without exposing a
// non-route page export.
export {
saveProtocolInTransaction as _saveProtocolInTransaction,
} from './protocol-save';
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
const secondaryRoles = ['Core', 'Damage', 'Health'];
async function profileSnapshot(id: string, suppliedDatabase?: Database) {
const database = suppliedDatabase ?? (await getDatabase());
return {
record:
(await (
await database.prepare(
'SELECT * FROM os_protocol_secondary_profiles WHERE id=?',
)
).get(id)) ?? null,
effects: await (
await database.prepare(
'SELECT * FROM os_protocol_secondary_effects WHERE profile_id=? ORDER BY effect_role',
)
).all(id),
};
}
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 = [
...qualities.map((quality) => quality.toLowerCase()),
...attributeTypes.map((type) => type.toLowerCase()),
'active',
'archived',
].includes(requestedFilter)
? requestedFilter
: '';
const requestedItemId = url.searchParams.get('item')?.trim() ?? '';
const conditions: string[] = [];
const args: unknown[] = [];
const { search, pattern } = getRegistrySearch(url);
const sort = getRegistrySort(
url,
{
name: {
asc: 'name COLLATE NOCASE ASC,id ASC',
desc: 'name COLLATE NOCASE DESC,id DESC',
},
quality: {
asc: "CASE quality WHEN 'Standard' THEN 1 WHEN 'Specialized' THEN 2 WHEN 'Superior' THEN 3 WHEN 'High-end' THEN 4 ELSE 5 END ASC,name COLLATE NOCASE ASC",
desc: "CASE quality WHEN 'Standard' THEN 1 WHEN 'Specialized' THEN 2 WHEN 'Superior' THEN 3 WHEN 'High-end' THEN 4 ELSE 5 END DESC,name COLLATE NOCASE ASC",
},
type: {
asc: 'primary_attribute_type COLLATE NOCASE ASC,name COLLATE NOCASE ASC',
desc: 'primary_attribute_type COLLATE NOCASE DESC,name COLLATE NOCASE ASC',
},
primary: {
asc: "COALESCE((SELECT attribute.name FROM os_protocol_primary_effects effect JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id WHERE effect.protocol_id=os_protocols.id),'') COLLATE NOCASE ASC,name COLLATE NOCASE ASC",
desc: "COALESCE((SELECT attribute.name FROM os_protocol_primary_effects effect JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id WHERE effect.protocol_id=os_protocols.id),'') COLLATE NOCASE DESC,name COLLATE NOCASE ASC",
},
talent: {
asc: "COALESCE((SELECT talent.name FROM os_protocol_talents talent WHERE talent.protocol_id=os_protocols.id),'') COLLATE NOCASE ASC,name COLLATE NOCASE ASC",
desc: "COALESCE((SELECT talent.name FROM os_protocol_talents talent WHERE talent.protocol_id=os_protocols.id),'') COLLATE NOCASE DESC,name COLLATE NOCASE ASC",
},
state: {
asc: 'active ASC,name COLLATE NOCASE ASC',
desc: 'active DESC,name COLLATE NOCASE ASC',
defaultDirection: 'desc',
},
},
'name',
);
if (requestedItemId) {
conditions.push('id=?');
args.push(requestedItemId);
} else if (filter === 'active' || filter === 'archived')
conditions.push(`active=${filter === 'active' ? 1 : 0}`);
else if (qualities.some((quality) => quality.toLowerCase() === filter)) {
conditions.push('quality=?');
args.push(qualities.find((quality) => quality.toLowerCase() === filter));
} else if (attributeTypes.some((type) => type.toLowerCase() === filter)) {
conditions.push('primary_attribute_type=?');
args.push(attributeTypes.find((type) => type.toLowerCase() === filter));
}
if (!requestedItemId && search) {
conditions.push("name LIKE ? ESCAPE '\\' COLLATE NOCASE");
args.push(pattern);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const [summary, filtered] = 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 quality='Standard' THEN 1 ELSE 0 END) AS standard,
SUM(CASE WHEN quality='Specialized' THEN 1 ELSE 0 END) AS specialized,
SUM(CASE WHEN quality='Superior' THEN 1 ELSE 0 END) AS superior,
SUM(CASE WHEN quality='High-end' THEN 1 ELSE 0 END) AS high_end
FROM os_protocols`)
).get(),
(
await database.prepare(
`SELECT COUNT(*) AS total FROM os_protocols ${where}`,
)
).get(...args),
]);
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [
protocols,
primaryEffects,
profiles,
secondaryEffects,
talents,
stats,
icons,
renders,
pending,
] = await Promise.all([
(
await database.prepare(
`SELECT * FROM os_protocols ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`,
)
).all(...args, pagination.perPage, pagination.offset),
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
(
await database.prepare(
'SELECT * FROM os_protocol_secondary_profiles ORDER BY quality,primary_attribute_type',
)
).all(),
(
await database.prepare(
'SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role',
)
).all(),
(
await database.prepare(
'SELECT id,protocol_id,name,created_at,updated_at FROM os_protocol_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 entity_id,updated_at FROM game_data_icons WHERE entity_type='os_protocol'",
)
).all(),
(
await database.prepare(
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='os_protocol_render'",
)
).all(),
getPendingEntities(user.id, [
{
module: 'attributes',
action: 'save',
kind: 'attribute',
fields: ['unit'],
},
{
module: 'protocols',
action: 'save',
kind: 'protocol',
fields: [
'quality',
'attributeType',
'primaryStatId',
'primaryBaseValue',
'hasTalent',
'talentName',
'expectedTalentId',
'authoredSource',
'expectedSourceFingerprint',
'mechanicSelectionIntent',
],
},
]),
]);
const configuredProfiles = profiles.map((profile) => ({
...profile,
effects: secondaryEffects.filter(
(effect) => effect.profile_id === profile.id,
),
}));
const talentMechanics = new Map(
await Promise.all(
talents.map(async (talent) => [
String(talent.id),
await loadProtocolTalentMechanic(database, String(talent.id)),
] as const),
),
);
const pendingProtocols = pending
.filter((item) => item.kind === 'protocol')
.filter((item) =>
requestedItemId
? item.id === requestedItemId
: filter === 'archived'
? false
: qualities.some((quality) => quality.toLowerCase() === filter)
? String(item.quality).toLowerCase() === filter
: attributeTypes.some((type) => type.toLowerCase() === filter)
? String(item.attributeType).toLowerCase() === filter
: true,
)
.filter(
(item) =>
requestedItemId ||
!search ||
String(item.name)
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase()),
)
.map((protocol) => ({
...protocol,
iconUrl: '',
renderUrl: '',
primary_attribute_type: protocol.attributeType,
primaryEffect: {
stat_attribute_id: protocol.primaryStatId,
base_value: Number(protocol.primaryBaseValue),
},
secondaryProfile:
configuredProfiles.find(
(profile) =>
profile.quality === protocol.quality &&
profile.primary_attribute_type === protocol.attributeType,
) ?? null,
talent:
protocol.hasTalent === '1'
? {
name: protocol.talentName,
authoredSource: null,
expectedSourceFingerprint: null,
selectedRevision: null,
modelDraft: null,
}
: null,
}));
const configuredProtocols = await Promise.all(
protocols.map(async (protocol) => {
const talent = talents.find((item) => item.protocol_id === protocol.id);
return {
...protocol,
iconUrl: (() => {
const icon = icons.find((item) => item.entity_id === protocol.id);
return icon
? gameDataIconUrl(
'os_protocol',
String(protocol.id),
icon.updated_at,
)
: '';
})(),
renderUrl: (() => {
const render = renders.find(
(item) => item.source_entity_id === protocol.id,
);
return render
? managedImageUrl(String(render.id), render.updated_at)
: '';
})(),
primaryEffect:
primaryEffects.find((item) => item.protocol_id === protocol.id) ??
null,
secondaryProfile:
configuredProfiles.find(
(profile) =>
profile.quality === protocol.quality &&
profile.primary_attribute_type ===
protocol.primary_attribute_type,
) ?? null,
talent: talent
? {
...talent,
authoredSource:
talentMechanics.get(String(talent.id))?.authoredSource.source ??
null,
expectedSourceFingerprint:
talentMechanics.get(String(talent.id))?.authoredSource
.fingerprint ?? null,
selectedRevision:
talentMechanics.get(String(talent.id))?.selectedRevision ?? null,
publicProjection:
talentMechanics.get(String(talent.id))?.projection ?? [],
}
: null,
};
}),
);
return {
protocols: [...pendingProtocols, ...configuredProtocols],
profiles: qualities.flatMap((quality) =>
attributeTypes.map(
(attributeType) =>
configuredProfiles.find(
(profile) =>
profile.quality === quality &&
profile.primary_attribute_type === attributeType,
) ?? {
id: '',
quality,
primary_attribute_type: attributeType,
effects: [],
},
),
),
stats: [...pending.filter((item) => item.kind === 'attribute'), ...stats],
summary: {
...summary,
total:
Number(summary?.total ?? 0) +
pending.filter((item) => item.kind === 'protocol').length,
pending: pending.filter((item) => item.kind === 'protocol').length,
},
filter,
requestedItemId,
pagination,
search,
sort,
};
};
export const actions: Actions = {
saveProfile: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const form = await request.formData();
const quality = String(form.get('quality') ?? '');
const attributeType = String(form.get('attributeType') ?? '');
if (!qualities.includes(quality) || !attributeTypes.includes(attributeType))
return fail(400, {
profileError: 'Choose a valid quality and attribute type.',
});
const database = await getDatabase();
const attributeIds = await getOsProtocolProfileAttributeIds(
database,
attributeType as OsProtocolAttributeType,
);
if (Object.values(attributeIds).some((id) => !id))
return fail(400, {
profileError:
'Create the required active core, damage, and Health Attributes before configuring this profile.',
});
const effects = secondaryRoles.map((role) => {
const rawValue = form.get(`value${role}`);
return {
role,
statId: attributeIds[role as keyof typeof attributeIds] ?? '',
value:
typeof rawValue === 'string' && rawValue.trim()
? Number(rawValue)
: Number.NaN,
};
});
if (
effects.some(
(effect) =>
!effect.statId || !Number.isFinite(effect.value) || effect.value < 0,
)
)
return fail(400, {
profileError:
'Every shared secondary attribute requires a non-negative value.',
});
const saveProfile = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const existing = await (
await tx.prepare(
'SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?',
)
).get(quality, attributeType);
const profileId = String(existing?.id ?? randomUUID());
const now = new Date().toISOString();
const before = existing ? await profileSnapshot(profileId, tx) : null;
if (existing)
await (
await tx.prepare(
'UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?',
)
).run(now, profileId);
else
await (
await tx.prepare(
'INSERT INTO os_protocol_secondary_profiles(id,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?)',
)
).run(profileId, quality, attributeType, now, now);
await (
await tx.prepare(
'DELETE FROM os_protocol_secondary_effects WHERE profile_id=?',
)
).run(profileId);
for (const effect of effects)
await (
await tx.prepare(
'INSERT INTO os_protocol_secondary_effects(id,profile_id,effect_role,stat_attribute_id,value) VALUES(?,?,?,?,?)',
)
).run(
randomUUID(),
profileId,
effect.role,
effect.statId,
effect.value,
);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: existing
? 'os_protocol_profile.updated'
: 'os_protocol_profile.created',
entityId: profileId,
before,
after: await profileSnapshot(profileId, tx),
database: tx,
});
});
await saveProfile.immediate();
return { profileSaved: true, resultToken: randomUUID() };
},
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 saveProtocol = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const input = await parseProtocolForm(tx, form, operator.id);
return saveProtocolInTransaction(tx, { ...input, journal });
});
const result = await saveProtocol.immediate();
journal.clear();
return {
protocolSaved: true,
protocolMode: result.mode,
resultToken: randomUUID(),
};
} catch (error) {
await journal.rollback(error);
if (error instanceof ProtocolFormValidationError)
return fail(error.status, { protocolError: error.message });
if (error instanceof MechanicSourceFingerprintConflictError)
return fail(409, { protocolError: error.message });
if (error instanceof ProtocolNotFoundError)
return fail(404, { protocolError: error.message });
throw error;
}
},
archive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '').trim();
const database = await getDatabase();
const archiveProtocol = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const before = await protocolSnapshot(id, tx);
if (!before.record) throw new ProtocolNotFoundError();
await (
await tx.prepare(
'UPDATE os_protocols SET active=0,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'os_protocol.archived',
entityId: id,
before,
after: await protocolSnapshot(id, tx),
database: tx,
});
});
try {
await archiveProtocol.immediate();
} catch (error) {
if (error instanceof ProtocolNotFoundError)
return fail(404, { protocolError: error.message });
throw error;
}
return { protocolArchived: true, protocolId: id, resultToken: randomUUID() };
},
unarchive: async ({ request, cookies }) => {
const operator = await requirePermission(
await getSessionUser(cookies),
'data.manage',
);
const id = String((await request.formData()).get('id') ?? '').trim();
const database = await getDatabase();
const unarchiveProtocol = database.transactionAsync(async (transaction) => {
const tx = transaction as unknown as Database;
const before = await protocolSnapshot(id, tx);
if (!before.record) throw new ProtocolNotFoundError();
await (
await tx.prepare(
'UPDATE os_protocols SET active=1,updated_at=? WHERE id=?',
)
).run(new Date().toISOString(), id);
await recordIsacAudit({
actorId: operator.id,
category: 'game_data',
action: 'os_protocol.unarchived',
entityId: id,
before,
after: await protocolSnapshot(id, tx),
database: tx,
});
});
try {
await unarchiveProtocol.immediate();
} catch (error) {
if (error instanceof ProtocolNotFoundError)
return fail(404, { protocolError: error.message });
throw error;
}
return { protocolUnarchived: true, protocolId: id, resultToken: randomUUID() };
},
};