383 lines
20 KiB
TypeScript
383 lines
20 KiB
TypeScript
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() };
|
||
}
|
||
};
|