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 = (value: unknown): T[] => { try { const parsed = JSON.parse(String(value ?? '[]')); return Array.isArray(parsed) ? parsed : []; } catch { return []; } }; async function revisionSnapshot(database: Awaited>, 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(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(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() }; } };