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

383 lines
20 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 { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database';
import { recordIsacAudit } from '$lib/server/audit';
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { migrateLoadoutDraft } from '$lib/builder/loadout';
const operators = new Set([
'base_damage', 'flat_add', 'additive_percent', 'multiplicative_percent',
'expected_critical', 'expected_headshot', 'rate_of_fire', 'magazine_size',
'reload_speed', 'firing_cycle'
]);
const curves = new Set(['none', 'power', 'smoothstep']);
type StageInput = {
stageKey?: string;
label?: string;
operator?: string;
displayOrder?: number | string;
lockedPosition?: string | null;
};
type ProfileInput = {
statAttributeId?: string;
stageKey?: string;
weight?: number | string;
softCap?: number | string | null;
hardCap?: number | string | null;
curveType?: string;
curveParameter?: number | string | null;
weaponCategory?: string;
};
const clean = (value: unknown) => String(value ?? '').normalize('NFKC').trim();
const optionalNumber = (value: unknown) => {
if (value === '' || value === null || value === undefined) return null;
const number = Number(value);
return Number.isFinite(number) ? number : Number.NaN;
};
const parseArray = <T>(value: unknown): T[] => {
try {
const parsed = JSON.parse(String(value ?? '[]'));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
async function revisionSnapshot(database: Awaited<ReturnType<typeof getDatabase>>, id: string) {
return {
record: await (await database.prepare('SELECT * FROM calculation_revisions WHERE id=?')).get(id) ?? null,
stages: await (await database.prepare('SELECT * FROM calculation_stages WHERE revision_id=? ORDER BY display_order')).all(id),
profiles: await (await database.prepare(
'SELECT * FROM attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id'
)).all(id)
};
}
export const load: PageServerLoad = async ({ cookies, url }) => {
await requireDataView(await getSessionUser(cookies));
const database = await getDatabase();
const { search, pattern } = getRegistrySearch(url);
const where = search ? "WHERE calibration.name LIKE ? ESCAPE '\\' COLLATE NOCASE" : '';
const args = search ? [pattern] : [];
const total = Number((await (await database.prepare(
`SELECT COUNT(*) AS count FROM calculation_calibration_cases calibration ${where}`
)).get(...args))?.count ?? 0);
const pagination = getPagination(url, total);
const [revisions, stages, profiles, attributes, calibrationCases, calibrationSummary] = await Promise.all([
(await database.prepare('SELECT * FROM calculation_revisions ORDER BY version DESC')).all(),
(await database.prepare('SELECT * FROM calculation_stages ORDER BY revision_id,display_order')).all(),
(await database.prepare('SELECT * FROM attribute_calculation_profiles ORDER BY revision_id,stage_key,stat_attribute_id')).all(),
(await database.prepare('SELECT id,name,unit,type,category FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare(`SELECT calibration.*,COALESCE(profile.display_name,user.username,calibration.created_by,'UNKNOWN') AS creator_name
FROM calculation_calibration_cases calibration
LEFT JOIN users user ON user.id=calibration.created_by
LEFT JOIN user_profiles profile ON profile.user_id=user.id
${where} ORDER BY calibration.updated_at DESC LIMIT ? OFFSET ?`
)).all(...args, pagination.perPage, pagination.offset),
(await database.prepare(`SELECT COUNT(*) AS total,
SUM(CASE WHEN status='ready' THEN 1 ELSE 0 END) AS ready,
SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END) AS draft,
SUM(CASE WHEN predicted_json IS NOT NULL THEN 1 ELSE 0 END) AS predicted
FROM calculation_calibration_cases`)).get()
]);
return {
revisions: revisions.map((revision) => ({
...revision,
stages: stages.filter((stage) => stage.revision_id === revision.id),
profiles: profiles.filter((profile) => profile.revision_id === revision.id)
})),
attributes,
calibrationCases,
calibrationSummary,
pagination,
search
};
};
export const actions: Actions = {
saveRevision: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = clean(form.get('id'));
const name = clean(form.get('name'));
const notes = clean(form.get('notes')).slice(0, 4000);
const stageInputs = parseArray<StageInput>(form.get('stagesJson')).map((stage) => ({
stageKey: clean(stage.stageKey),
label: clean(stage.label),
operator: clean(stage.operator),
displayOrder: Number(stage.displayOrder),
lockedPosition: clean(stage.lockedPosition) || null
}));
const profileInputs = parseArray<ProfileInput>(form.get('profilesJson')).map((profile) => ({
statAttributeId: clean(profile.statAttributeId),
stageKey: clean(profile.stageKey),
weight: Number(profile.weight),
softCap: optionalNumber(profile.softCap),
hardCap: optionalNumber(profile.hardCap),
curveType: clean(profile.curveType) || 'none',
curveParameter: optionalNumber(profile.curveParameter),
weaponCategory: clean(profile.weaponCategory) || '*'
}));
if (name.length < 3 || name.length > 120) return fail(400, { revisionError: 'Revision names must be 3–120 characters.' });
if (stageInputs.length < 2 || stageInputs.length > 30
|| new Set(stageInputs.map((stage) => stage.stageKey)).size !== stageInputs.length
|| new Set(stageInputs.map((stage) => stage.displayOrder)).size !== stageInputs.length
|| stageInputs.some((stage) => !stage.stageKey || stage.label.length < 2 || !operators.has(stage.operator)
|| !Number.isInteger(stage.displayOrder) || stage.displayOrder < 1
|| (stage.lockedPosition !== null && !['first', 'last'].includes(stage.lockedPosition)))) {
return fail(400, { revisionError: 'Every calculation stage requires a unique key, order, label, and supported operator.' });
}
const ordered = [...stageInputs].sort((left, right) => left.displayOrder - right.displayOrder);
if (ordered[0].operator !== 'base_damage' || ordered[0].lockedPosition !== 'first'
|| ordered.at(-1)?.operator !== 'firing_cycle' || ordered.at(-1)?.lockedPosition !== 'last') {
return fail(400, { revisionError: 'Base damage must remain first and the firing cycle must remain last.' });
}
const stageKeys = new Set(stageInputs.map((stage) => stage.stageKey));
if (profileInputs.length > 2000 || profileInputs.some((profile) =>
!profile.statAttributeId || !stageKeys.has(profile.stageKey) || !Number.isFinite(profile.weight)
|| profile.weight < -100 || profile.weight > 100
|| Number.isNaN(profile.softCap) || Number.isNaN(profile.hardCap) || Number.isNaN(profile.curveParameter)
|| (profile.softCap !== null && profile.softCap < 0)
|| (profile.hardCap !== null && profile.hardCap < 0)
|| (profile.softCap !== null && profile.hardCap !== null && profile.hardCap < profile.softCap)
|| !curves.has(profile.curveType)
)) return fail(400, { revisionError: 'One or more Attribute calculation profiles are invalid.' });
const database = await getDatabase();
const attributeIds = [...new Set(profileInputs.map((profile) => profile.statAttributeId))];
if (attributeIds.length) {
const count = Number((await (await database.prepare(
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${attributeIds.map(() => '?').join(',')})`
)).get(...attributeIds))?.count ?? 0);
if (count !== attributeIds.length) return fail(400, { revisionError: 'One or more calculation Attributes are unavailable.' });
}
const existing = id ? await (await database.prepare('SELECT * FROM calculation_revisions WHERE id=?')).get(id) : null;
if (id && !existing) return fail(404, { revisionError: 'That calculation revision no longer exists.' });
if (existing?.status !== 'draft') return fail(409, { revisionError: 'Active and retired revisions are immutable. Create a new draft.' });
const revisionId = id || clean(form.get('_proposedEntityId')) || randomUUID();
const before = id ? await revisionSnapshot(database, revisionId) : null;
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
if (id) {
await (await database.prepare('UPDATE calculation_revisions SET name=?,notes=? WHERE id=?'))
.run(name, notes || null, revisionId);
} else {
const version = Number((await (await database.prepare('SELECT COALESCE(MAX(version),0)+1 AS version FROM calculation_revisions')).get())?.version ?? 1);
const basedOn = clean(form.get('basedOnRevisionId')) || null;
await (await database.prepare(`INSERT INTO calculation_revisions
(id,version,name,status,notes,based_on_revision_id,created_by,created_at)
VALUES(?,?,?,'draft',?,?,?,?)`))
.run(revisionId, version, name, notes || null, basedOn, operator.id, now);
}
await (await database.prepare('DELETE FROM calculation_stages WHERE revision_id=?')).run(revisionId);
for (const stage of ordered) {
await (await database.prepare(`INSERT INTO calculation_stages
(id,revision_id,stage_key,label,operator,display_order,locked_position) VALUES(?,?,?,?,?,?,?)`))
.run(randomUUID(), revisionId, stage.stageKey, stage.label, stage.operator, stage.displayOrder, stage.lockedPosition);
}
await (await database.prepare('DELETE FROM attribute_calculation_profiles WHERE revision_id=?')).run(revisionId);
for (const profile of profileInputs) {
await (await database.prepare(`INSERT INTO attribute_calculation_profiles
(id,revision_id,stat_attribute_id,stage_key,weight,soft_cap,hard_cap,curve_type,curve_parameter,weapon_category)
VALUES(?,?,?,?,?,?,?,?,?,?)`)).run(
randomUUID(), revisionId, profile.statAttributeId, profile.stageKey, profile.weight,
profile.softCap, profile.hardCap, profile.curveType, profile.curveParameter, profile.weaponCategory
);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
await recordIsacAudit({
actorId: operator.id,
category: 'calculation_configuration',
action: id ? 'calculation_revision.updated' : 'calculation_revision.created',
entityId: revisionId,
before,
after: await revisionSnapshot(database, revisionId)
});
return { revisionSaved: true, revisionMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
publishRevision: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'calculation.publish');
const form = await request.formData();
const id = clean(form.get('id'));
if (form.get('confirmation') !== 'activate-current-rules') {
return fail(400, { publishError: 'Confirm that all saved builds will use this revision.' });
}
const database = await getDatabase();
const candidate = await (await database.prepare("SELECT * FROM calculation_revisions WHERE id=? AND status='draft'")).get(id);
if (!candidate) return fail(404, { publishError: 'That draft revision is no longer available.' });
const before = {
active: await (await database.prepare("SELECT * FROM calculation_revisions WHERE status='active'")).get() ?? null,
candidate: await revisionSnapshot(database, id)
};
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
await (await database.prepare("UPDATE calculation_revisions SET status='retired' WHERE status='active'")).run();
await (await database.prepare(`UPDATE calculation_revisions SET status='active',published_by=?,published_at=? WHERE id=?`))
.run(operator.id, now, id);
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
await recordIsacAudit({
actorId: operator.id,
category: 'calculation_configuration',
action: 'calculation_revision.published',
entityId: id,
before,
after: { active: await revisionSnapshot(database, id) }
});
return { revisionPublished: true, resultToken: randomUUID() };
},
saveCalibration: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = clean(form.get('id'));
const name = clean(form.get('name'));
const status = clean(form.get('status')) || 'draft';
const snapshotText = String(form.get('loadoutSnapshot') ?? '').trim();
let snapshot: unknown;
try { snapshot = JSON.parse(snapshotText); } catch { return fail(400, { calibrationError: 'The loadout snapshot is not valid JSON.' }); }
const migrated = migrateLoadoutDraft(snapshot);
if (!migrated) return fail(400, { calibrationError: 'The loadout snapshot is not a recognized SHD.build loadout.' });
const observed = {
bodyHit: optionalNumber(form.get('bodyHit')),
criticalBodyHit: optionalNumber(form.get('criticalBodyHit')),
headshot: optionalNumber(form.get('headshot')),
criticalHeadshot: optionalNumber(form.get('criticalHeadshot')),
magazineSeconds: optionalNumber(form.get('magazineSeconds')),
reloadSeconds: optionalNumber(form.get('reloadSeconds')),
encounterDps: optionalNumber(form.get('encounterDps'))
};
if (name.length < 3 || name.length > 120 || !['draft', 'ready'].includes(status)) {
return fail(400, { calibrationError: 'Calibration cases require a 3–120 character name and valid state.' });
}
if (Object.values(observed).some((value) => Number.isNaN(value) || (value !== null && value < 0))
|| (observed.magazineSeconds !== null && observed.magazineSeconds <= 0)
|| (observed.reloadSeconds !== null && observed.reloadSeconds <= 0)) {
return fail(400, { calibrationError: 'Observed values must be valid non-negative measurements.' });
}
const core = [observed.bodyHit, observed.criticalBodyHit, observed.headshot, observed.criticalHeadshot, observed.magazineSeconds, observed.reloadSeconds];
if (status === 'ready' && core.some((value) => value === null)) {
return fail(400, { calibrationError: 'Ready cases require the four hit values, magazine time, and reload time.' });
}
const database = await getDatabase();
const caseId = id || clean(form.get('_proposedEntityId')) || randomUUID();
const before = id ? await (await database.prepare('SELECT * FROM calculation_calibration_cases WHERE id=?')).get(id) ?? null : null;
if (id && !before) return fail(404, { calibrationError: 'That calibration case no longer exists.' });
const now = new Date().toISOString();
const values = [
name, status, JSON.stringify(migrated), Number(form.get('selectedWeapon') ?? 0),
clean(form.get('gameVersion')) || null, clean(form.get('observedAt')) || null,
clean(form.get('notes')).slice(0, 4000) || null,
observed.bodyHit, observed.criticalBodyHit, observed.headshot, observed.criticalHeadshot,
observed.magazineSeconds, observed.reloadSeconds, observed.encounterDps,
clean(form.get('predictedJson')) || null
];
if (id) {
await (await database.prepare(`UPDATE calculation_calibration_cases SET
name=?,status=?,loadout_snapshot_json=?,selected_weapon=?,game_version=?,observed_at=?,notes=?,
body_hit=?,critical_body_hit=?,headshot=?,critical_headshot=?,magazine_seconds=?,reload_seconds=?,
encounter_dps=?,predicted_json=?,updated_at=? WHERE id=?`)).run(...values, now, caseId);
} else {
const activeRevision = await (await database.prepare("SELECT id FROM calculation_revisions WHERE status='active'")).get();
await (await database.prepare(`INSERT INTO calculation_calibration_cases
(id,name,status,loadout_snapshot_json,selected_weapon,game_version,observed_at,notes,
body_hit,critical_body_hit,headshot,critical_headshot,magazine_seconds,reload_seconds,
encounter_dps,predicted_json,calculation_revision_id,created_by,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`))
.run(caseId, ...values, activeRevision?.id ?? null, operator.id, now, now);
}
const after = await (await database.prepare('SELECT * FROM calculation_calibration_cases WHERE id=?')).get(caseId) ?? null;
await recordIsacAudit({
actorId: operator.id,
category: 'calculation_configuration',
action: id ? 'calibration_case.updated' : 'calibration_case.created',
entityId: caseId,
before,
after
});
return { calibrationSaved: true, calibrationMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
suggestRevision: async ({ cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const database = await getDatabase();
const cases = await (await database.prepare(
"SELECT * FROM calculation_calibration_cases WHERE status='ready' AND predicted_json IS NOT NULL"
)).all();
if (!cases.length) return fail(409, { suggestionError: 'Add at least one ready calibration case with a prediction before requesting suggestions.' });
const ratios: number[] = [];
for (const item of cases) {
try {
const predicted = JSON.parse(String(item.predicted_json));
const observed = Number(item.body_hit);
const modeled = Number(predicted.bodyHit ?? predicted.hitValues?.normal);
if (observed > 0 && modeled > 0) ratios.push(observed / modeled);
} catch {
continue;
}
}
if (!ratios.length) return fail(409, { suggestionError: 'Ready cases do not contain compatible body-hit predictions.' });
const scale = Math.min(3, Math.max(0.25, ratios.reduce((sum, value) => sum + value, 0) / ratios.length));
const active = await (await database.prepare("SELECT * FROM calculation_revisions WHERE status='active'")).get();
if (!active) return fail(409, { suggestionError: 'No active calculation revision is available.' });
const activeSnapshot = await revisionSnapshot(database, String(active.id));
const revisionId = randomUUID();
const version = Number((await (await database.prepare('SELECT COALESCE(MAX(version),0)+1 AS version FROM calculation_revisions')).get())?.version ?? 1);
const now = new Date().toISOString();
await database.exec('BEGIN');
try {
await (await database.prepare(`INSERT INTO calculation_revisions
(id,version,name,status,notes,based_on_revision_id,created_by,created_at)
VALUES(?,?,?,'draft',?,?,?,?)`)).run(
revisionId, version, `Calibration suggestion ${version}`,
`Suggested from ${ratios.length} ready case${ratios.length === 1 ? '' : 's'}. Body-hit scale ${scale.toFixed(4)}.`,
active.id, operator.id, now
);
for (const stage of activeSnapshot.stages) {
await (await database.prepare(`INSERT INTO calculation_stages
(id,revision_id,stage_key,label,operator,display_order,locked_position) VALUES(?,?,?,?,?,?,?)`))
.run(randomUUID(), revisionId, stage.stage_key, stage.label, stage.operator, stage.display_order, stage.locked_position);
}
for (const profile of activeSnapshot.profiles) {
const shouldScale = ['flat_add', 'additive_percent', 'multiplicative_percent'].includes(
String(activeSnapshot.stages.find((stage) => stage.stage_key === profile.stage_key)?.operator)
);
await (await database.prepare(`INSERT INTO attribute_calculation_profiles
(id,revision_id,stat_attribute_id,stage_key,weight,soft_cap,hard_cap,curve_type,curve_parameter,weapon_category)
VALUES(?,?,?,?,?,?,?,?,?,?)`)).run(
randomUUID(), revisionId, profile.stat_attribute_id, profile.stage_key,
Number(profile.weight) * (shouldScale ? scale : 1), profile.soft_cap, profile.hard_cap,
profile.curve_type, profile.curve_parameter, profile.weapon_category
);
}
await database.exec('COMMIT');
} catch (error) {
await database.exec('ROLLBACK');
throw error;
}
await recordIsacAudit({
actorId: operator.id,
category: 'calculation_configuration',
action: 'calculation_revision.suggested',
entityId: revisionId,
before: { activeRevisionId: active.id, readyCases: cases.map((item) => item.id) },
after: { ...(await revisionSnapshot(database, revisionId)), suggestedScale: scale }
});
return { suggestionSaved: true, suggestedRevisionId: revisionId, resultToken: randomUUID() };
}
};