231 lines
20 KiB
Svelte
231 lines
20 KiB
Svelte
<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 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';
|
||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
|
||
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||
|
||
let { data, form } = $props();
|
||
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 talentValueColumns = [{ key: 'base', variantKey: 'base', label: 'BASE' }];
|
||
const blankRule = (): ConditionalMechanicDraftV1 => ({
|
||
version: 1,
|
||
simulationReadiness: 'captured_only',
|
||
scenarioInputs: [],
|
||
pools: [],
|
||
clauses: []
|
||
});
|
||
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
|
||
const blank = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, hasTalent: false, talentOpen: false, 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('');
|
||
let handledErrorForm = $state<unknown>(null);
|
||
let iconResetToken = $state(0);
|
||
let renderResetToken = $state(0);
|
||
let openedRequestedItem = $state(false);
|
||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||
const iconName = (type: string): GameIconName => type.toLowerCase() as GameIconName;
|
||
const qualityOptions = qualities.map((quality) => ({ value: quality, label: quality, className: qualityClass(quality) }));
|
||
const attributeTypeOptions = attributeTypes.map((type) => ({ value: type, label: type, icon: iconName(type) }));
|
||
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 previewOutcomes = () => editing.talent.conditionalRule.clauses
|
||
.flatMap((clause: any) => clause.outcomes)
|
||
.filter((outcome: any) => outcome.descriptionSlot)
|
||
.sort((left: any, right: any) => left.descriptionSlot - right.descriptionSlot);
|
||
const talentPreview = (upgrades: number) => editing.talent.description_template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
|
||
const duration = editing.talent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
|
||
const maxStacks = editing.talent.conditionalRule.pools.length ? Math.max(...editing.talent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
|
||
if (expression === 'duration') return duration == null ? placeholder : `${duration} seconds`;
|
||
if (expression === 'max_stacks') return maxStacks == null ? placeholder : `${maxStacks} stacks`;
|
||
const outcome = previewOutcomes().find((item: any) => item.descriptionSlot === Number(expression));
|
||
const baseValue = outcome?.variants?.base?.value;
|
||
const scalesWithUpgrades = JSON.stringify(outcome?.variants?.base?.expression ?? {})
|
||
.includes('protocolTalentUpgrades');
|
||
return outcome && baseValue !== '' && baseValue !== null && baseValue !== undefined
|
||
? format(scalesWithUpgrades ? upgradeValue(baseValue, upgrades) : baseValue, outcome.attributeId)
|
||
: placeholder;
|
||
});
|
||
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: '' },
|
||
hasTalent: Boolean(protocol.talent),
|
||
talentOpen: false,
|
||
talent: protocol.talent ? {
|
||
...protocol.talent,
|
||
conditionalRule: protocol.talent.conditionalRule ?? blankRule()
|
||
} : blankTalent()
|
||
};
|
||
profileEditing = null;
|
||
talentUpgrade = 0;
|
||
protocolLevel = 1;
|
||
};
|
||
$effect(() => {
|
||
if (!openedRequestedItem && data.requestedItemId) {
|
||
const requested = data.protocols.find((item: any) => item.id === data.requestedItemId);
|
||
if (requested) edit(requested);
|
||
openedRequestedItem = true;
|
||
}
|
||
if (form?.profileSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||
handledResultToken = form.resultToken;
|
||
profileEditing = null;
|
||
}
|
||
if (form?.protocolSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||
handledResultToken = form.resultToken;
|
||
if (form.protocolMode === 'add') {
|
||
iconResetToken += 1;
|
||
renderResetToken += 1;
|
||
}
|
||
editing = form.protocolMode === 'add' ? blank() : null;
|
||
talentUpgrade = 0;
|
||
protocolLevel = 1;
|
||
}
|
||
if (form?.protocolError && form !== handledErrorForm) {
|
||
handledErrorForm = form;
|
||
if (editing?.hasTalent) editing.talentOpen = true;
|
||
}
|
||
});
|
||
</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'}
|
||
]}/>
|
||
|
||
{#snippet protocolEditor()}
|
||
{#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" enctype="multipart/form-data">
|
||
<input type="hidden" name="id" value={editing.id}/>
|
||
<input type="hidden" name="hasTalent" value={editing.hasTalent ? '1' : '0'}/>
|
||
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)} />
|
||
<div class="protocol-identity-grid">
|
||
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="OS PROTOCOL RENDER" resetToken={renderResetToken}/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
|
||
<div class="protocol-classification-stack">
|
||
<SegmentedChoice legend="QUALITY" name="quality" options={qualityOptions} bind:value={editing.quality}/>
|
||
<SegmentedChoice legend="TYPE" name="attributeType" options={attributeTypeOptions} bind:value={editing.primary_attribute_type}/>
|
||
</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"><div class="field"><span>PRIMARY STAT</span><SearchableAssignment items={data.stats} fieldName="primaryStatId" mode="single" compact subjectLabel="PRIMARY ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={editing.primaryEffect.stat_attribute_id}/></div><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>OPTIONAL OWNED TALENT · FIVE LINEAR UPGRADES FOR MARKED VALUES</small></div><SegmentedChoice legend="TALENT ASSIGNMENT" name="talent-assignment" options={[{value:'0',label:'NOT ASSIGNED'},{value:'1',label:'ASSIGNED'}]} value={editing.hasTalent ? '1' : '0'} onchange={(values) => { editing.hasTalent = values[0] === '1'; if (editing.hasTalent) editing.talentOpen = true; }}/></div>
|
||
{#if editing.hasTalent}
|
||
<details class="modeling-disclosure protocol-talent-disclosure" bind:open={editing.talentOpen}>
|
||
<summary><span><b>{editing.talent.name || 'TALENT WORKSPACE'}</b><small>{previewOutcomes().length} PROTOCOL OUTCOME{previewOutcomes().length === 1 ? '' : 'S'} · {editing.talent.conditionalRule.simulationReadiness === 'captured_only' ? 'CAPTURE ONLY' : 'MODELED'}</small></span><strong>{editing.talentOpen ? 'COLLAPSE' : 'EXPAND'}</strong></summary>
|
||
<div class="modeling-content">
|
||
<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× SCALABLE VALUES' : `${Math.round((1 + talentUpgrade / 5) * 100)}% SCALABLE VALUES`}</span><p>{talentPreview(talentUpgrade) || 'Talent description preview'}</p></div></div>
|
||
<MechanicProgramEditor
|
||
attributes={data.stats}
|
||
valueColumns={talentValueColumns}
|
||
idPrefix={`protocol-talent-${editing.talent.id || 'new'}`}
|
||
aiContext={{
|
||
sourceKind: 'os_protocol_talent',
|
||
sourceName: editing?.talent?.name ?? '',
|
||
sourceDescription: editing?.talent?.description_template ?? ''
|
||
}}
|
||
allowProtocolUpgradeScaling
|
||
bind:rule={editing.talent.conditionalRule}
|
||
/>
|
||
</div>
|
||
</details>
|
||
{:else}
|
||
<p class="console-note">This OS Protocol has no Talent. Its primary and inherited secondary Attributes remain available.</p>
|
||
{#if editing.talent.id}<label class="protocol-talent-removal"><input type="checkbox" name="confirmTalentRemoval" value="REMOVE" required/> CONFIRM REMOVAL OF THE EXISTING TALENT AND MECHANIC PROGRAM</label>{/if}
|
||
{/if}
|
||
</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}>SAVE PROTOCOL →</button></div></div>
|
||
</PendingForm>
|
||
</div>
|
||
{/if}
|
||
{/snippet}
|
||
{#if editing && !editing.id}{@render protocolEditor()}{/if}
|
||
|
||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||
<div class="dashboard-table protocol-dashboard-table">
|
||
<div class="dashboard-row protocol-row table-heading"><SortableColumnHeader label="OS PROTOCOL" column="name" sort={data.sort}/><SortableColumnHeader label="QUALITY" column="quality" sort={data.sort}/><SortableColumnHeader label="TYPE" column="type" sort={data.sort}/><SortableColumnHeader label="UNIQUE PRIMARY" column="primary" sort={data.sort}/><SortableColumnHeader label="UNIQUE TALENT" column="talent" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||
{#each data.protocols as protocol}
|
||
<div class="dashboard-row protocol-row" class:archived={Number(protocol.active) !== 1} class:pending-record={protocol.pending} class:registry-row-editing={editing?.id === protocol.id}><div class="registry-icon-identity"><ItemIcon src={protocol.iconUrl} alt=""/><div><strong>{protocol.name}</strong><small>{protocol.pending ? `PROPOSED BY ${protocol.proposerName}` : 'LEVEL 1–50 · PRIMARY 2× MAX'}</small></div></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 ASSIGNED'}</strong><small>{#if protocol.talent}<span class="mini-diamonds">◆◆◆◆◆</span> {protocol.talent.conditionalRule?.clauses?.length || protocol.pending ? 'MODELED · ' : 'CAPTURE ONLY · '}5 UPGRADES{:else}OPTIONAL{/if}</small></div><b class:pending={protocol.pending} class="state-dot">{protocol.pending ? 'PENDING' : Number(protocol.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if protocol.pending}<span></span>{:else}<button aria-expanded={editing?.id === protocol.id} aria-controls={`protocol-editor-${protocol.id}`} onclick={() => edit(protocol)}>EDIT</button>{/if}</div>
|
||
{#if editing?.id === protocol.id}<RegistryInlineEditor id={`protocol-editor-${protocol.id}`} label={`Edit ${protocol.name}`}>{@render protocolEditor()}</RegistryInlineEditor>{/if}
|
||
{: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>
|