Files
shd.build/src/routes/admin/protocols/+page.svelte
T

166 lines
16 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 RegistryPagination from '$lib/components/RegistryPagination.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
let { data, form } = $props();
type TalentEffect = { stat_attribute_id: string; base_value: number | string };
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
const secondaryRoles = ['Core', 'Damage', 'Health'];
const secondaryNames = (type: string): Record<string, string> => ({
Core: type,
Damage: type === 'Engineering' ? 'Skill Damage' : 'All Weapon Damage',
Health: 'Health'
});
const blankTalent = () => ({ id: '', name: '', description_template: '', triggerIds: [], effects: [{ stat_attribute_id: '', base_value: '' }] });
const blank = () => ({ id: '', name: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() });
let editing = $state<any | null>(null);
let profileEditing = $state<any | null>(null);
let talentUpgrade = $state(0);
let protocolLevel = $state(1);
let handledResultToken = $state('');
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
const iconName = (type: string): GameIconName => type.toLowerCase() as GameIconName;
const stat = (id: string) => data.stats.find((item) => item.id === id);
const format = (value: number | string, statId: string) => {
const numeric = Number(value);
const unit = stat(statId)?.unit ?? '';
if (!Number.isFinite(numeric)) return '—';
const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '');
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
};
const levelValue = (base: number | string, level: number) => Number(base) * (1 + (level - 1) / 49);
const upgradeValue = (base: number | string, upgrades: number) => Number(base) * (1 + upgrades / 5);
const profileFor = (quality: string, type: string) => data.profiles.find((profile) => profile.quality === quality && profile.primary_attribute_type === type);
const profileEffect = (profile: any, role: string) => profile.effects.find((effect: any) => effect.effect_role === role);
const configuredProfile = () => profileFor(editing.quality, editing.primary_attribute_type)?.effects.length === 3;
const talentPreview = (upgrades: number) => editing.talent.description_template.replace(/\{(\d+)\}/g, (placeholder: string, rawIndex: string) => {
const effect = editing.talent.effects[Number(rawIndex) - 1];
return effect ? format(upgradeValue(effect.base_value, upgrades), effect.stat_attribute_id) : placeholder;
});
const addTalentEffect = () => {
if (editing.talent.effects.length < 12) editing.talent.effects = [...editing.talent.effects, { stat_attribute_id: '', base_value: '' }];
};
const removeTalentEffect = (index: number) => {
if (editing.talent.effects.length > 1) editing.talent.effects = editing.talent.effects.filter((_: TalentEffect, effectIndex: number) => effectIndex !== index);
};
const editProfile = (profile: any) => {
profileEditing = {
...profile,
values: Object.fromEntries(secondaryRoles.map((role) => [role, profileEffect(profile, role)?.value ?? '']))
};
editing = null;
};
const edit = (protocol: any) => {
editing = {
...protocol,
primaryEffect: protocol.primaryEffect ? { ...protocol.primaryEffect } : { stat_attribute_id: '', base_value: '' },
talent: protocol.talent ? {
...protocol.talent,
triggerIds: [...protocol.talent.triggerIds],
effects: protocol.talent.effects.map((effect: TalentEffect) => ({ ...effect }))
} : blankTalent()
};
profileEditing = null;
talentUpgrade = 0;
protocolLevel = 1;
};
$effect(() => {
if (form?.profileSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
profileEditing = null;
}
if (form?.protocolSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
editing = form.protocolMode === 'add' ? blank() : null;
talentUpgrade = 0;
protocolLevel = 1;
}
});
</script>
<svelte:head><title>OS Protocols — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">OPERATING SYSTEM MODIFICATION REGISTRY</p><h2>OS PROTOCOLS</h2><p>Configure shared secondary profiles, unique level-scaled primary stats, and five-stage talents.</p></div><button class="primary" onclick={() => { editing = blank(); profileEditing = null; }}>ADD OS PROTOCOL <span>+</span></button></div>
<details class="protocol-profile-registry">
<summary class="selection-head"><div><span>SHARED ATTRIBUTE PROFILES</span><small>ONE PROFILE PER QUALITY + ATTRIBUTE TYPE · REUSED BY EVERY MATCHING PROTOCOL</small></div><div><b>{data.profiles.filter((profile) => profile.effects.length === 3).length} / 12 CONFIGURED</b><i aria-hidden="true">+</i></div></summary>
<div class="protocol-profile-grid">
{#each data.profiles as profile}
<button type="button" class:configured={profile.effects.length === 3} onclick={() => editProfile(profile)}>
<header><GameIcon name={iconName(profile.primary_attribute_type)} decorative/><div><b class={qualityClass(profile.quality)}>{profile.quality}</b><strong>{profile.primary_attribute_type}</strong></div><span>{profile.effects.length === 3 ? 'CONFIGURED' : 'REQUIRED'}</span></header>
<div>{#each secondaryRoles as role}{@const effect = profileEffect(profile, role)}<span><small>{secondaryNames(profile.primary_attribute_type)[role]}</small><b>{effect ? format(effect.value, effect.stat_attribute_id) : '—'}</b></span>{/each}</div>
</button>
{/each}
</div>
</details>
{#if profileEditing}
<div class="dashboard-editor protocol-profile-editor">
<div class="editor-title"><div><p class="eyebrow">SHARED PROFILE</p><h3>{profileEditing.quality.toUpperCase()} · {profileEditing.primary_attribute_type.toUpperCase()}</h3></div><button aria-label="Close profile editor" onclick={() => profileEditing = null}>×</button></div>
<PendingForm action="?/saveProfile">
<input type="hidden" name="quality" value={profileEditing.quality}/>
<input type="hidden" name="attributeType" value={profileEditing.primary_attribute_type}/>
<p class="console-note">These values apply to every {profileEditing.quality} {profileEditing.primary_attribute_type} OS Protocol. They do not scale with an individual protocol’s level.</p>
<div class="shared-secondary-editor">{#each secondaryRoles as role}<label class="field"><span>{secondaryNames(profileEditing.primary_attribute_type)[role]}</span><input name={`value${role}`} type="number" step="any" min="0" bind:value={profileEditing.values[role]} required/></label>{/each}</div>
{#if form?.profileError}<p class="error">{form.profileError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => profileEditing = null}>CANCEL</button><button class="primary">SAVE SHARED PROFILE →</button></div>
</PendingForm>
</div>
{/if}
<KpiFilters active={data.filter} items={[
{value:'',label:'TOTAL PROTOCOLS',count:data.summary.total,description:'Show all definitions'},
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},
{value:'standard',label:'STANDARD',count:data.summary.standard,description:'Standard quality'},
{value:'specialized',label:'SPECIALIZED',count:data.summary.specialized,description:'Specialized quality'},
{value:'superior',label:'SUPERIOR',count:data.summary.superior,description:'Superior quality'},
{value:'high-end',label:'HIGH-END',count:data.summary.high_end,description:'High-end quality'}
]}/>
{#if editing}
<div class="dashboard-editor protocol-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY PROTOCOL' : 'NEW PROTOCOL'}</p><h3>{editing.id ? editing.name : 'OS PROTOCOL DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save">
<input type="hidden" name="id" value={editing.id}/>
<input type="hidden" name="talentEffects" value={JSON.stringify(editing.talent.effects.map((effect: TalentEffect) => ({ statId: effect.stat_attribute_id, baseValue: effect.base_value })))} />
<div class="protocol-identity-grid">
<label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label>
<div class="protocol-classification-stack">
<fieldset class="field protocol-quality-picker"><legend>QUALITY</legend><div>{#each qualities as quality}<label><input type="radio" name="quality" value={quality} bind:group={editing.quality}/><span class={qualityClass(quality)}><i></i><b>{quality}</b></span></label>{/each}</div></fieldset>
<fieldset class="field protocol-type-picker"><legend>TYPE</legend><div>{#each attributeTypes as type}<label><input type="radio" name="attributeType" value={type} bind:group={editing.primary_attribute_type}/><span><GameIcon name={iconName(type)} decorative/><b>{type}</b></span></label>{/each}</div></fieldset>
</div>
</div>
{@const selectedProfile = profileFor(editing.quality, editing.primary_attribute_type)}
<div class="selected-secondary-profile" class:unconfigured={!configuredProfile()}><header><span>INHERITED PROFILE</span><b>{editing.quality.toUpperCase()} · {editing.primary_attribute_type.toUpperCase()}</b></header>{#if configuredProfile()}<div>{#each secondaryRoles as role}{@const effect = profileEffect(selectedProfile, role)}<span><small>{secondaryNames(editing.primary_attribute_type)[role]}</small><b>{format(effect.value, effect.stat_attribute_id)}</b></span>{/each}</div>{:else}<p>Configure this quality and type combination in Shared Secondary Attribute Profiles before saving.</p>{/if}</div>
<div class="selection-head"><div><span>UNIQUE PRIMARY ATTRIBUTE</span><small>LEVEL 1 BASE VALUE · LINEAR TO 2× AT LEVEL 50</small></div><b>LEVEL {protocolLevel}</b></div>
<div class="protocol-level-preview"><input aria-label="Preview protocol level" type="range" min="1" max="50" step="1" bind:value={protocolLevel}/><div><span>01</span><i style={`width:${((protocolLevel - 1) / 49) * 100}%`}></i><span>50</span></div></div>
<div class="protocol-primary-editor"><label class="field">PRIMARY STAT<select name="primaryStatId" bind:value={editing.primaryEffect.stat_attribute_id} required><option value="" disabled>SELECT ATTRIBUTE</option>{#each data.stats as item}<option value={item.id}>{item.name} ({item.unit || 'no unit'})</option>{/each}</select></label><label class="field">LEVEL 1 BASE VALUE<input name="primaryBaseValue" type="number" step="any" min="0" bind:value={editing.primaryEffect.base_value} required/></label><div><span>LEVEL {protocolLevel} PREVIEW</span><b>{format(levelValue(editing.primaryEffect.base_value, protocolLevel), editing.primaryEffect.stat_attribute_id)}</b><small>LEVEL 50 · {format(levelValue(editing.primaryEffect.base_value, 50), editing.primaryEffect.stat_attribute_id)}</small></div></div>
<section class="protocol-talent">
<div class="selection-head"><div><span>UNIQUE PROTOCOL TALENT</span><small>OWNED EXCLUSIVELY BY THIS PROTOCOL · FIVE LINEAR UPGRADES</small></div><b>{editing.talent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
<div class="protocol-talent-identity"><label class="field">TALENT NAME<input name="talentName" bind:value={editing.talent.name} maxlength="80" required/></label><label class="field">DESCRIPTION TEMPLATE<textarea name="descriptionTemplate" bind:value={editing.talent.description_template} placeholder="While active, gain +{1} damage…" required></textarea><small>Use {'{1}'}, {'{2}'}, and so on for effect values and units.</small></label></div>
<div class="protocol-upgrade-preview"><div class="diamond-control" role="group" aria-label="Preview talent upgrade level"><button type="button" class:active={talentUpgrade === 0} aria-pressed={talentUpgrade === 0} onclick={() => talentUpgrade = 0}>BASE</button>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrade >= upgrade} aria-label={`Preview talent at upgrade ${upgrade}`} aria-pressed={talentUpgrade === upgrade} onclick={() => talentUpgrade = upgrade}><span>◆</span><small>{upgrade}</small></button>{/each}</div><div><span>UPGRADE {talentUpgrade} / 5 · {talentUpgrade === 5 ? '2× EFFECT VALUE' : `${Math.round((1 + talentUpgrade / 5) * 100)}% EFFECT VALUE`}</span><p>{talentPreview(talentUpgrade) || 'Talent description preview'}</p></div></div>
<div class="protocol-talent-grid">
<div><div class="selection-head"><div><span>ACTIVATION TRIGGERS</span><small>REUSABLE TRIGGER DEFINITIONS</small></div><b>{editing.talent.triggerIds.length}</b></div><SearchableAssignment items={data.triggers} fieldName="triggerIds" bind:selected={editing.talent.triggerIds}/></div>
<div><div class="selection-head"><div><span>TALENT EFFECTS</span><small>BASE VALUE AT ZERO UPGRADES</small></div><button type="button" class="row-action" onclick={addTalentEffect} disabled={editing.talent.effects.length >= 12}>ADD EFFECT +</button></div><div class="protocol-talent-effects">{#each editing.talent.effects as effect, index}<div><strong>{index + 1}</strong><select bind:value={effect.stat_attribute_id} required><option value="" disabled>SELECT ATTRIBUTE</option>{#each data.stats as item}<option value={item.id}>{item.name} ({item.unit || 'no unit'})</option>{/each}</select><input type="number" step="any" bind:value={effect.base_value} placeholder="BASE VALUE" required/><span>{format(upgradeValue(effect.base_value, 5), effect.stat_attribute_id)} AT +5</span><button type="button" aria-label={`Remove talent effect ${index + 1}`} onclick={() => removeTalentEffect(index)} disabled={editing.talent.effects.length === 1}>×</button></div>{/each}</div></div>
</div>
</section>
{#if form?.protocolError}<p class="error">{form.protocolError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE PROTOCOL</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={!configuredProfile() || data.stats.length === 0 || data.triggers.length === 0}>SAVE PROTOCOL →</button></div></div>
</PendingForm>
</div>
{/if}
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table protocol-dashboard-table">
<div class="dashboard-row protocol-row table-heading"><span>OS PROTOCOL</span><span>QUALITY</span><span>TYPE</span><span>UNIQUE PRIMARY</span><span>UNIQUE TALENT</span><span>STATE</span><span></span></div>
{#each data.protocols as protocol}
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1}><div><strong>{protocol.name}</strong><small>LEVEL 1–50 · PRIMARY 2× MAX</small></div><span><b class={`quality-chip ${qualityClass(protocol.quality)}`}>{protocol.quality}</b></span><span class="protocol-type-label"><GameIcon name={iconName(protocol.primary_attribute_type)} decorative/>{protocol.primary_attribute_type}</span><span>{protocol.primaryEffect ? `${stat(protocol.primaryEffect.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(protocol.primaryEffect.base_value, protocol.primaryEffect.stat_attribute_id)} → ${format(levelValue(protocol.primaryEffect.base_value, 50), protocol.primaryEffect.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{protocol.talent?.name ?? 'NOT CONFIGURED'}</strong><small><span class="mini-diamonds">◆◆◆◆◆</span> 5 UPGRADES · 2× MAX</small></div><b class="state-dot">{Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(protocol)}>EDIT</button></div>
{:else}<div class="empty-dashboard"><strong>NO OS PROTOCOLS REGISTERED</strong><span>Configure shared secondary profiles, then add the first protocol.</span></div>{/each}
</div>