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

259 lines
22 KiB
Svelte
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.
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
import { createDraft } from '$lib/builder/loadout';
let { data, form } = $props();
type StageEditor = {
stageKey: string;
label: string;
operator: string;
displayOrder: number;
lockedPosition: string | null;
};
type ProfileEditor = {
statAttributeId: string;
stageKey: string;
weight: number | string;
softCap: number | string;
hardCap: number | string;
curveType: string;
curveParameter: number | string;
weaponCategory: string;
};
type RevisionEditor = {
id: string;
basedOnRevisionId: string;
name: string;
notes: string;
stages: StageEditor[];
profiles: ProfileEditor[];
};
type CalibrationEditor = {
id: string;
name: string;
status: string;
loadoutSnapshot: string;
selectedWeapon: string;
gameVersion: string;
observedAt: string;
notes: string;
bodyHit: number | string;
criticalBodyHit: number | string;
headshot: number | string;
criticalHeadshot: number | string;
magazineSeconds: number | string;
reloadSeconds: number | string;
encounterDps: number | string;
predictedJson: string;
};
const canPublish = $derived(data.permissions?.includes('*') || data.permissions?.includes('calculation.publish'));
const activeRevision = $derived(data.revisions.find((revision: any) => revision.status === 'active'));
const stageItems = (revision: RevisionEditor) => revision.stages.map((stage) => ({
id: stage.stageKey,
name: stage.label,
category: stage.operator.replaceAll('_', ' ').toUpperCase()
}));
const curveOptions = [
{ value: 'none', label: 'NONE' },
{ value: 'power', label: 'POWER' },
{ value: 'smoothstep', label: 'SMOOTHSTEP' }
];
const stateOptions = [{ value: 'draft', label: 'DRAFT' }, { value: 'ready', label: 'READY' }];
const weaponOptions = [{ value: '0', label: 'PRIMARY' }, { value: '1', label: 'SECONDARY' }];
const revisionFrom = (revision: any, edit = false): RevisionEditor => ({
id: edit ? String(revision?.id ?? '') : '',
basedOnRevisionId: edit ? String(revision?.based_on_revision_id ?? '') : String(revision?.id ?? ''),
name: edit ? String(revision?.name ?? '') : `Combat model ${Number(revision?.version ?? 0) + 1}`,
notes: edit ? String(revision?.notes ?? '') : `Based on revision ${revision?.version ?? 1}.`,
stages: (revision?.stages ?? []).map((stage: any) => ({
stageKey: String(stage.stage_key),
label: String(stage.label),
operator: String(stage.operator),
displayOrder: Number(stage.display_order),
lockedPosition: stage.locked_position ? String(stage.locked_position) : null
})),
profiles: (revision?.profiles ?? []).map((profile: any) => ({
statAttributeId: String(profile.stat_attribute_id),
stageKey: String(profile.stage_key),
weight: Number(profile.weight),
softCap: profile.soft_cap ?? '',
hardCap: profile.hard_cap ?? '',
curveType: String(profile.curve_type),
curveParameter: profile.curve_parameter ?? '',
weaponCategory: String(profile.weapon_category ?? '*')
}))
});
const blankCalibration = (): CalibrationEditor => ({
id: '', name: '', status: 'draft', loadoutSnapshot: JSON.stringify(createDraft(), null, 2),
selectedWeapon: '0', gameVersion: '', observedAt: '', notes: '', bodyHit: '',
criticalBodyHit: '', headshot: '', criticalHeadshot: '', magazineSeconds: '',
reloadSeconds: '', encounterDps: '', predictedJson: ''
});
const calibrationFrom = (item: any): CalibrationEditor => ({
id: String(item.id), name: String(item.name), status: String(item.status),
loadoutSnapshot: JSON.stringify(JSON.parse(String(item.loadout_snapshot_json)), null, 2),
selectedWeapon: String(item.selected_weapon), gameVersion: String(item.game_version ?? ''),
observedAt: String(item.observed_at ?? ''), notes: String(item.notes ?? ''),
bodyHit: item.body_hit ?? '', criticalBodyHit: item.critical_body_hit ?? '',
headshot: item.headshot ?? '', criticalHeadshot: item.critical_headshot ?? '',
magazineSeconds: item.magazine_seconds ?? '', reloadSeconds: item.reload_seconds ?? '',
encounterDps: item.encounter_dps ?? '', predictedJson: String(item.predicted_json ?? '')
});
let revisionEditing = $state<RevisionEditor | null>(null);
let calibrationEditing = $state<CalibrationEditor | null>(null);
let publishingId = $state('');
let publishConfirmed = $state(false);
let handledToken = $state('');
function addProfile() {
if (!revisionEditing) return;
revisionEditing.profiles = [...revisionEditing.profiles, {
statAttributeId: '', stageKey: revisionEditing.stages.find((stage) => stage.operator === 'additive_percent')?.stageKey ?? '',
weight: 1, softCap: '', hardCap: '', curveType: 'none', curveParameter: '', weaponCategory: '*'
}];
}
function moveStage(index: number, direction: -1 | 1) {
if (!revisionEditing) return;
const target = index + direction;
const stages = [...revisionEditing.stages].sort((left, right) => left.displayOrder - right.displayOrder);
if (index < 0 || target < 0 || target >= stages.length || stages[index].lockedPosition || stages[target].lockedPosition) return;
[stages[index], stages[target]] = [stages[target], stages[index]];
revisionEditing.stages = stages.map((stage, stageIndex) => ({ ...stage, displayOrder: stageIndex + 1 }));
}
$effect(() => {
const token = String(form?.resultToken ?? '');
if (!token || token === handledToken) return;
handledToken = token;
if (form?.revisionSaved) revisionEditing = form.revisionMode === 'add' ? revisionFrom(activeRevision) : null;
if (form?.calibrationSaved) calibrationEditing = form.calibrationMode === 'add' ? blankCalibration() : null;
if (form?.revisionPublished) {
publishingId = '';
publishConfirmed = false;
}
});
</script>
<svelte:head><title>Combat calculations — ISAC</title></svelte:head>
<div class="module-toolbar">
<div><p class="eyebrow">MODELED DAMAGE + EMPIRICAL CALIBRATION</p><h2>COMBAT CALCULATIONS</h2><p>Configure typed calculation stages, Attribute bindings, and field observations used by the loadout builder.</p></div>
<div class="calculation-actions"><button class="secondary" onclick={() => calibrationEditing = blankCalibration()}>ADD CALIBRATION CASE +</button><button class="primary" onclick={() => revisionEditing = revisionFrom(activeRevision)}>NEW REVISION +</button></div>
</div>
<div class="calculation-kpis">
<div><span>ACTIVE REVISION</span><b>{activeRevision ? `R${activeRevision.version}` : 'NONE'}</b><small>{activeRevision?.name ?? 'NO ACTIVE RULES'}</small></div>
<div><span>ATTRIBUTE PROFILES</span><b>{activeRevision?.profiles.length ?? 0}</b><small>TYPED BINDINGS</small></div>
<div><span>READY CASES</span><b>{Number(data.calibrationSummary?.ready ?? 0)}</b><small>{Number(data.calibrationSummary?.predicted ?? 0)} WITH PREDICTIONS</small></div>
</div>
{#if revisionEditing}
<div class="dashboard-editor calculation-revision-editor">
<div class="editor-title"><div><p class="eyebrow">{revisionEditing.id ? 'MODIFY DRAFT REVISION' : 'NEW DRAFT REVISION'}</p><h3>{revisionEditing.name || 'CALCULATION RULE SET'}</h3></div><button aria-label="Close revision editor" onclick={() => revisionEditing = null}>×</button></div>
<PendingForm action="?/saveRevision">
<input type="hidden" name="id" value={revisionEditing.id}/>
<input type="hidden" name="basedOnRevisionId" value={revisionEditing.basedOnRevisionId}/>
<input type="hidden" name="stagesJson" value={JSON.stringify(revisionEditing.stages)}/>
<input type="hidden" name="profilesJson" value={JSON.stringify(revisionEditing.profiles)}/>
<div class="calculation-revision-identity"><label class="field isac-record-name">REVISION NAME<input name="name" bind:value={revisionEditing.name} maxlength="120" required/></label><label class="field">NOTES<textarea name="notes" bind:value={revisionEditing.notes} maxlength="4000"></textarea></label></div>
<div class="selection-head"><div><span>ORDERED CALCULATION STAGES</span><small>BASE AND FIRING-CYCLE POSITIONS ARE LOCKED</small></div><b>{revisionEditing.stages.length} STAGES</b></div>
<div class="calculation-stage-list">{#each [...revisionEditing.stages].sort((left, right) => left.displayOrder - right.displayOrder) as stage, index}
<div class:locked={stage.lockedPosition}><b>{String(stage.displayOrder).padStart(2, '0')}</b><label class="field">LABEL<input bind:value={stage.label} required/></label><span>{stage.operator.replaceAll('_', ' ').toUpperCase()}</span><em>{stage.lockedPosition?.toUpperCase() ?? 'ORDERED'}</em><div class="stage-order-actions"><button type="button" class="secondary" aria-label={`Move ${stage.label} earlier`} disabled={Boolean(stage.lockedPosition) || index === 1} onclick={() => moveStage(index,-1)}>↑</button><button type="button" class="secondary" aria-label={`Move ${stage.label} later`} disabled={Boolean(stage.lockedPosition) || index === revisionEditing!.stages.length - 2} onclick={() => moveStage(index,1)}>↓</button></div>
</div>{/each}</div>
<div class="selection-head"><div><span>ATTRIBUTE CALCULATION PROFILES</span><small>WEIGHT, CAPS, AND SAFE CURVES</small></div><button type="button" class="secondary" onclick={addProfile}>ADD PROFILE +</button></div>
<div class="calculation-profile-list">{#each revisionEditing.profiles as profile, index}
<section>
<header><span>PROFILE {String(index + 1).padStart(2, '0')}</span><button type="button" class="moderation-action danger" onclick={() => revisionEditing && (revisionEditing.profiles = revisionEditing.profiles.filter((_, itemIndex) => itemIndex !== index))}>REMOVE</button></header>
<div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.attributes} mode="single" compact fieldName={`profile-attribute-${index}`} subjectLabel="ATTRIBUTE" searchPlaceholder="Search Attributes…" bind:value={profile.statAttributeId}/></div>
<div class="field"><span>CALCULATION STAGE</span><SearchableAssignment items={stageItems(revisionEditing)} mode="single" compact fieldName={`profile-stage-${index}`} subjectLabel="STAGE" searchPlaceholder="Search stages…" bind:value={profile.stageKey}/></div>
<div class="calculation-profile-values">
<label class="field">WEIGHT<input type="number" min="-100" max="100" step="any" bind:value={profile.weight} required/></label>
<label class="field">SOFT CAP<input type="number" min="0" step="any" bind:value={profile.softCap} placeholder="NONE"/></label>
<label class="field">HARD CAP<input type="number" min="0" step="any" bind:value={profile.hardCap} placeholder="NONE"/></label>
<label class="field">CURVE PARAMETER<input type="number" min="0.01" step="any" bind:value={profile.curveParameter} disabled={profile.curveType === 'none'} placeholder="1"/></label>
</div>
<SegmentedChoice legend="CURVE" name={`profile-curve-${index}`} options={curveOptions} bind:value={profile.curveType}/>
</section>
{/each}</div>
{#if form?.revisionError}<p class="error">{form.revisionError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => revisionEditing = null}>CANCEL</button><button class="primary">SAVE DRAFT REVISION →</button></div>
</PendingForm>
</div>
{/if}
{#snippet calibrationEditor()}
{#if calibrationEditing}
<div class="dashboard-editor calibration-editor">
<div class="editor-title"><div><p class="eyebrow">{calibrationEditing.id ? 'MODIFY CALIBRATION CASE' : 'NEW CALIBRATION CASE'}</p><h3>{calibrationEditing.name || 'FIELD OBSERVATION'}</h3></div><button aria-label="Close calibration editor" onclick={() => calibrationEditing = null}>×</button></div>
<PendingForm action="?/saveCalibration">
<input type="hidden" name="id" value={calibrationEditing.id}/>
<div class="calibration-identity"><label class="field isac-record-name">CASE NAME<input name="name" bind:value={calibrationEditing.name} maxlength="120" required/></label><SegmentedChoice legend="CASE STATE" name="status" options={stateOptions} bind:value={calibrationEditing.status}/><SegmentedChoice legend="MEASURED WEAPON" name="selectedWeapon" options={weaponOptions} bind:value={calibrationEditing.selectedWeapon}/><label class="field">GAME VERSION<input name="gameVersion" bind:value={calibrationEditing.gameVersion}/></label><label class="field">OBSERVED DATE<input name="observedAt" type="date" bind:value={calibrationEditing.observedAt}/></label></div>
<label class="field calibration-snapshot">LOADOUT SNAPSHOT · JSON<textarea name="loadoutSnapshot" bind:value={calibrationEditing.loadoutSnapshot} required spellcheck="false"></textarea></label>
<div class="calibration-observations">
<label class="field">BODY HIT<input name="bodyHit" type="number" min="0" step="any" bind:value={calibrationEditing.bodyHit}/></label>
<label class="field">CRITICAL BODY<input name="criticalBodyHit" type="number" min="0" step="any" bind:value={calibrationEditing.criticalBodyHit}/></label>
<label class="field">HEADSHOT<input name="headshot" type="number" min="0" step="any" bind:value={calibrationEditing.headshot}/></label>
<label class="field">CRITICAL HEADSHOT<input name="criticalHeadshot" type="number" min="0" step="any" bind:value={calibrationEditing.criticalHeadshot}/></label>
<label class="field">MAGAZINE TIME · SECONDS<input name="magazineSeconds" type="number" min="0.01" step="any" bind:value={calibrationEditing.magazineSeconds}/></label>
<label class="field">RELOAD TIME · SECONDS<input name="reloadSeconds" type="number" min="0.01" step="any" bind:value={calibrationEditing.reloadSeconds}/></label>
<label class="field">ENCOUNTER DPS · OPTIONAL<input name="encounterDps" type="number" min="0" step="any" bind:value={calibrationEditing.encounterDps}/></label>
</div>
<label class="field">NOTES<textarea name="notes" bind:value={calibrationEditing.notes} maxlength="4000"></textarea></label>
<label class="field calibration-prediction">PREDICTION SNAPSHOT · OPTIONAL JSON<textarea name="predictedJson" bind:value={calibrationEditing.predictedJson} spellcheck="false"></textarea></label>
{#if form?.calibrationError}<p class="error">{form.calibrationError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => calibrationEditing = null}>CANCEL</button><button class="primary">SAVE CALIBRATION CASE →</button></div>
</PendingForm>
</div>
{/if}
{/snippet}
{#if calibrationEditing && !calibrationEditing.id}
{@render calibrationEditor()}
{/if}
<div class="calculation-revision-registry">
<div class="selection-head"><div><span>CALCULATION REVISIONS</span><small>SAVED BUILDS USE THE ACTIVE REVISION</small></div><PendingForm action="?/suggestRevision">{#snippet children(pending)}<button class="secondary" disabled={pending}>{pending ? 'ANALYZING…' : 'GENERATE ASSISTED SUGGESTION'}</button>{/snippet}</PendingForm></div>
{#if form?.suggestionError}<p class="error">{form.suggestionError}</p>{/if}
{#each data.revisions as revision}
<article class:active={revision.status === 'active'}>
<header><div><span>REVISION {revision.version}</span><h3>{revision.name}</h3></div><b>{revision.status.toUpperCase()}</b></header>
<p>{revision.notes || 'No revision notes.'}</p>
<footer><span>{revision.stages.length} STAGES · {revision.profiles.length} ATTRIBUTE PROFILES</span><div>{#if revision.status === 'draft'}<button class="secondary" onclick={() => revisionEditing = revisionFrom(revision, true)}>EDIT</button>{#if canPublish}<button class="primary" onclick={() => { publishingId = revision.id; publishConfirmed = false; }}>REVIEW + ACTIVATE</button>{/if}{/if}</div></footer>
{#if publishingId === revision.id}
<div class="publish-confirmation">
<div><strong>ACTIVATE REVISION {revision.version}?</strong><span>Every saved and shared build will recalculate using these rules.</span></div>
<label><input type="checkbox" bind:checked={publishConfirmed}/> I understand this changes current builder results.</label>
<PendingForm action="?/publishRevision"><input type="hidden" name="id" value={revision.id}/><input type="hidden" name="confirmation" value="activate-current-rules"/><div><button type="button" class="secondary" onclick={() => publishingId = ''}>CANCEL</button><button class="primary" disabled={!publishConfirmed}>ACTIVATE REVISION →</button></div></PendingForm>
</div>
{/if}
</article>
{/each}
</div>
<div class="selection-head"><div><span>CALIBRATION REGISTRY</span><small>OBSERVED GAME VALUES + MODEL COMPARISON</small></div><b>{Number(data.calibrationSummary?.total ?? 0)} CASES</b></div>
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table">
<div class="dashboard-row calibration-row table-heading"><SortableColumnHeader label="CASE" column="name" sort={data.sort}/><SortableColumnHeader label="WEAPON" column="weapon" sort={data.sort}/><SortableColumnHeader label="OBSERVATIONS" column="observations" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="PREDICTION" column="prediction" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="STATE" column="state" sort={data.sort}/><span></span></div>
{#each data.calibrationCases as item}
<div class="dashboard-row calibration-row" class:registry-row-editing={String(calibrationEditing?.id ?? '') === String(item.id)}><div><strong>{item.name}</strong><small>{item.game_version || 'GAME VERSION NOT RECORDED'} · {item.creator_name}</small></div><span>{Number(item.selected_weapon) === 0 ? 'PRIMARY' : 'SECONDARY'}</span><span>{[item.body_hit,item.critical_body_hit,item.headshot,item.critical_headshot,item.magazine_seconds,item.reload_seconds].filter((value) => value !== null).length} / 6 CORE</span><span>{item.predicted_json ? 'AVAILABLE' : 'INSUFFICIENT DATA'}</span><b class:configured={item.status === 'ready'} class="state-dot">{item.status.toUpperCase()}</b><button aria-expanded={String(calibrationEditing?.id ?? '') === String(item.id)} aria-controls={`calibration-editor-${item.id}`} onclick={() => calibrationEditing = calibrationFrom(item)}>EDIT →</button></div>
{#if String(calibrationEditing?.id ?? '') === String(item.id)}
<RegistryInlineEditor id={`calibration-editor-${item.id}`} label={`Edit ${item.name}`}>
{@render calibrationEditor()}
</RegistryInlineEditor>
{/if}
{/each}
</div>
<style>
.module-toolbar>div:first-child{min-width:0;padding-right:12px}.calculation-actions{flex:0 0 auto}.calculation-actions,.calculation-revision-registry article footer div{display:flex;gap:7px;align-items:center}.calculation-kpis{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px;margin-bottom:9px}.calculation-kpis>div{display:grid;gap:2px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.calculation-kpis span,.calculation-kpis small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.calculation-kpis b{color:var(--shd-orange);font-size:22px}.calculation-revision-identity,.calibration-identity{display:grid;grid-template-columns:1.2fr 1.8fr;gap:8px}.calculation-stage-list{display:grid;gap:4px}.calculation-stage-list>div{display:grid;grid-template-columns:42px minmax(220px,1fr) minmax(170px,.7fr) 90px 76px;gap:7px;align-items:center;padding:6px;border:1px solid var(--shd-border);background:#0d1011}.calculation-stage-list>div>b{color:var(--shd-orange);text-align:center}.calculation-stage-list>div>span,.calculation-stage-list>div>em{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.stage-order-actions{display:grid;grid-template-columns:1fr 1fr;gap:3px}.stage-order-actions button{min-width:0;height:32px;padding:0}.calculation-profile-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.calculation-profile-list>section{display:grid;gap:7px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.calculation-profile-list header{display:flex;justify-content:space-between;align-items:center}.calculation-profile-values{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.calibration-identity{grid-template-columns:1.4fr 1fr 1fr 1fr 1fr}.calibration-snapshot textarea{min-height:180px;font-family:var(--shd-font-mono)}.calibration-prediction textarea{min-height:90px;font-family:var(--shd-font-mono)}.calibration-observations{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.calculation-revision-registry{display:grid;gap:7px;margin:10px 0}.calculation-revision-registry>article{border:1px solid var(--shd-border);border-left:2px solid var(--shd-border);background:var(--shd-surface)}.calculation-revision-registry>article.active{border-left-color:var(--shd-orange)}.calculation-revision-registry article>header,.calculation-revision-registry article>footer{display:flex;justify-content:space-between;gap:8px;align-items:center;padding:9px}.calculation-revision-registry article>header{border-bottom:1px solid var(--shd-border)}.calculation-revision-registry h3,.calculation-revision-registry p{margin:0}.calculation-revision-registry article>p{padding:9px;color:var(--shd-text-muted)}.calculation-revision-registry article>footer{border-top:1px solid var(--shd-border);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.publish-confirmation{display:grid;grid-template-columns:1fr auto auto;gap:12px;align-items:center;padding:10px;border-top:1px solid var(--shd-orange);background:#1b160d}.publish-confirmation>div{display:grid}.publish-confirmation label{font-size:var(--shd-type-supporting)}.calibration-row{grid-template-columns:minmax(230px,1.4fr) .5fr .65fr .8fr .55fr 70px}
@media(max-width:1000px){.calculation-profile-list{grid-template-columns:1fr}.calibration-identity,.calibration-observations{grid-template-columns:repeat(2,minmax(0,1fr))}.publish-confirmation{grid-template-columns:1fr}.calibration-row{grid-template-columns:1fr .5fr .6fr}.calibration-row>*:nth-child(4),.calibration-row>*:nth-child(5){display:none}}
@media(max-width:650px){.calculation-actions{display:grid}.calculation-kpis{grid-template-columns:1fr}.calculation-revision-identity,.calibration-identity,.calibration-observations,.calculation-stage-list>div,.calculation-profile-values{grid-template-columns:1fr}.calculation-stage-list>div>b{text-align:left}.calculation-revision-registry article>header,.calculation-revision-registry article>footer{align-items:start;display:grid}.calibration-row{grid-template-columns:1fr 70px}.calibration-row>*:nth-child(2),.calibration-row>*:nth-child(3){display:none}}
</style>