565 lines
19 KiB
TypeScript
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() };
|
|
},
|
|
};
|