166 lines
16 KiB
Svelte
166 lines
16 KiB
Svelte
<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>
|