158 lines
17 KiB
Svelte
158 lines
17 KiB
Svelte
<script lang="ts">
|
||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||
import GameIcon from '$lib/components/GameIcon.svelte';
|
||
import type { GameIconName } from '$lib/game-icons';
|
||
|
||
let { data, form } = $props();
|
||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||
const qualityRow = (quality: string) => ({
|
||
quality, primaryLevel1Min: 0, primaryLevel1Max: 0, primaryLevel20Min: 0, primaryLevel20Max: 0
|
||
});
|
||
const blankSecondary = () => ({ statId: '', values: Object.fromEntries(data.qualities.map((quality: string) => [quality,{ min:0,max:0 }])) });
|
||
const blankEffect = () => ({ statId: '', values: Object.fromEntries(data.qualities.map((quality: string) => [quality, 0])) });
|
||
const blankExoticAttribute = () => ({ statId:'',level1Value:0,level20Value:0 });
|
||
const blank = () => ({
|
||
id: '', name: '', active: 1, slot_type: '', exotic_weapon_id: '',
|
||
compatibleCategories: [], qualityValues: data.qualities.map(qualityRow),
|
||
secondaryRanges: [blankSecondary(),blankSecondary()], effects: [blankEffect()], exoticAttributes:[blankExoticAttribute()]
|
||
});
|
||
let editing = $state<any | null>(null);
|
||
let levelPreview = $state(1);
|
||
let qualityPreview = $state('High-end');
|
||
let isExotic = $state(false);
|
||
let handledResultToken = $state('');
|
||
const categoryOptions = $derived(data.weaponCategories.map((category: any) => ({ value:category.id,label:category.name,icon:category.id as GameIconName,iconOnly:true })));
|
||
const slotOptions = $derived(data.slots.map((slot: any) => ({ value:slot.id,label:slot.name.toUpperCase() })));
|
||
const exoticWeapons = $derived(data.weapons.filter((weapon: any) => Number(weapon.is_exotic)));
|
||
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
|
||
const weapon = (id: string) => data.weapons.find((item: any) => item.id === id);
|
||
const previewRow = $derived(editing?.qualityValues.find((row: any) => row.quality === qualityPreview));
|
||
const interpolate = (start: unknown, end: unknown) => Number(start) + (Number(end) - Number(start)) * (levelPreview - 1) / 19;
|
||
const display = (value: unknown) => Number.isFinite(Number(value)) ? (Number.isInteger(Number(value)) ? String(Number(value)) : Number(value).toFixed(2).replace(/\.?0+$/,'')) : '—';
|
||
const edit = (item: any) => {
|
||
editing = {
|
||
...item,
|
||
compatibleCategories: [...item.compatibleCategories],
|
||
qualityValues: data.qualities.map((quality: string) => ({ ...qualityRow(quality), ...(item.qualityValues.find((row: any) => row.quality === quality) ?? {}) })),
|
||
secondaryRanges: item.secondaryRanges.length ? item.secondaryRanges.map((range: any) => ({ statId:range.statId,values:structuredClone(range.values) })) : [blankSecondary(),blankSecondary()],
|
||
exoticAttributes: item.exoticAttributes.length ? item.exoticAttributes.map((attribute: any)=>({...attribute})) : [blankExoticAttribute()],
|
||
effects: item.effects.length ? item.effects.map((effect: any) => ({ statId: effect.statId, values: { ...effect.values } })) : [blankEffect()]
|
||
};
|
||
levelPreview = 1;
|
||
qualityPreview = 'High-end';
|
||
isExotic = Boolean(item.exotic_weapon_id);
|
||
};
|
||
const setMode = (value: string) => {
|
||
isExotic = value === 'exotic';
|
||
if (isExotic) editing.compatibleCategories = [];
|
||
else editing.exotic_weapon_id = '';
|
||
};
|
||
const addEffect = () => { if (editing.effects.length < 2) editing.effects = [...editing.effects, blankEffect()]; };
|
||
const removeEffect = (index: number) => { if (editing.effects.length > 1) editing.effects = editing.effects.filter((_: any, itemIndex: number) => itemIndex !== index); };
|
||
const addSecondary = () => { if (editing.secondaryRanges.length < 50) editing.secondaryRanges = [...editing.secondaryRanges,blankSecondary()]; };
|
||
const removeSecondary = (index: number) => { if (editing.secondaryRanges.length > 2) editing.secondaryRanges = editing.secondaryRanges.filter((_: any,itemIndex: number) => itemIndex !== index); };
|
||
const addExoticAttribute = () => { if(editing.exoticAttributes.length<12) editing.exoticAttributes=[...editing.exoticAttributes,blankExoticAttribute()]; };
|
||
const removeExoticAttribute = (index:number) => { if(editing.exoticAttributes.length>1) editing.exoticAttributes=editing.exoticAttributes.filter((_:any,itemIndex:number)=>itemIndex!==index); };
|
||
$effect(() => {
|
||
if (form?.weaponModSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||
handledResultToken = form.resultToken;
|
||
editing = form.weaponModMode === 'add' ? blank() : null;
|
||
levelPreview = 1;
|
||
isExotic = false;
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<svelte:head><title>Weapon Mods — ISAC</title></svelte:head>
|
||
<div class="module-toolbar"><div><p class="eyebrow">ARMAMENT MODIFICATION REGISTRY</p><h2>WEAPON MODS</h2><p>Configure tier-aware slot compatibility, quality-scaled rolls, and fixed Mod Effects.</p></div><button class="primary" onclick={() => { editing = blank(); isExotic = false; }}>ADD WEAPON MOD <span>+</span></button></div>
|
||
|
||
<KpiFilters active={data.filter} items={[
|
||
{value:'',label:'TOTAL MODS',count:data.summary.total,description:'Show all definitions'},
|
||
{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to weapons'},
|
||
{value:'regular',label:'REGULAR',count:data.summary.regular,description:'Category-compatible mods'},
|
||
{value:'exotic',label:'EXOTIC',count:data.summary.exotic,description:'Locked to one exotic weapon'},
|
||
{value:'archived',label:'ARCHIVED',count:data.summary.archived,description:'Hidden definitions'}
|
||
]}/>
|
||
|
||
{#if editing}
|
||
<div class="dashboard-editor weapon-mod-editor">
|
||
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY WEAPON MOD' : 'NEW WEAPON MOD'}</p><h3>{editing.id ? editing.name : 'WEAPON MOD 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="isExotic" value={isExotic ? '1' : '0'}/>
|
||
<input type="hidden" name="qualityValues" value={JSON.stringify(editing.qualityValues)}/>
|
||
<input type="hidden" name="secondaryRanges" value={JSON.stringify(editing.secondaryRanges)}/>
|
||
<input type="hidden" name="effects" value={JSON.stringify(editing.effects)}/>
|
||
<input type="hidden" name="exoticAttributes" value={JSON.stringify(editing.exoticAttributes)}/>
|
||
<div class="weapon-mod-identity">
|
||
<label class="field weapon-mod-name">MOD NAME<input name="name" bind:value={editing.name} maxlength="100" required/></label>
|
||
<label class="field exotic-toggle weapon-mod-exotic-toggle">EXOTIC STATUS<span title="Exotic Weapon Mod"><input type="checkbox" checked={isExotic} onchange={(event)=>setMode(event.currentTarget.checked?'exotic':'regular')}/><GameIcon name="exotic" decorative/><b class="sr-only">EXOTIC WEAPON MOD</b></span></label>
|
||
{#if isExotic}<div class="weapon-mod-exotic-quality"><GameIcon name="exotic" decorative/><span><b>EXOTIC QUALITY</b><small>UNIQUE MOD · NO QUALITY VARIANTS OR RANDOM ROLLS</small></span></div>{:else}<div class="weapon-mod-primary-types"><span>PRIMARY ATTRIBUTE OPTIONS</span><div>{#each ['firepower','toughness','engineering'] as primary}<b><GameIcon name={primary as GameIconName} decorative/>{primary.toUpperCase()}</b>{/each}</div><small>Selected by the agent while configuring a build.</small></div>{/if}
|
||
<SegmentedChoice legend="WEAPON MOD SLOT" name="slotType" options={slotOptions} columns={8} bind:value={editing.slot_type} required showIndicator={false}/>
|
||
</div>
|
||
|
||
<div class="weapon-mod-compatibility">
|
||
<div>
|
||
<div class="selection-head"><div><span>{isExotic ? 'EXOTIC WEAPON LOCK' : 'WEAPON CATEGORY COMPATIBILITY'}</span><small>{isExotic ? 'PRE-EQUIPPED · CANNOT BE REMOVED' : 'SLOT TYPE AND CATEGORY MUST BOTH MATCH'}</small></div><b>{isExotic ? (editing.exotic_weapon_id ? 1 : 0) : editing.compatibleCategories.length}</b></div>
|
||
{#if isExotic}<SearchableAssignment items={exoticWeapons} fieldName="exoticWeaponId" mode="single" subjectLabel="EXOTIC WEAPON" searchPlaceholder="Search exotic weapons…" bind:value={editing.exotic_weapon_id}/>
|
||
{:else}<SegmentedChoice legend="COMPATIBLE WEAPON CATEGORIES" name="compatibleCategories" mode="checkbox" options={categoryOptions} bind:selected={editing.compatibleCategories} columns={6} showIndicator={false}/>{/if}
|
||
</div>
|
||
</div>
|
||
|
||
{#if !isExotic}
|
||
<section class="weapon-mod-quality-section">
|
||
<div class="selection-head"><div><span>QUALITY & LEVEL SCALING</span><small>PRIMARY ROLLS SCALE LINEARLY FROM LEVEL 1 TO 20 · SECONDARY RANGES VARY BY QUALITY</small></div><b>4 QUALITY PROFILES</b></div>
|
||
<div class="weapon-mod-quality-table">
|
||
<div class="weapon-mod-quality-row heading"><span>QUALITY</span><span>PRIMARY · LEVEL 1</span><span>PRIMARY · LEVEL 20</span></div>
|
||
{#each editing.qualityValues as row}
|
||
<div class="weapon-mod-quality-row">
|
||
<strong class={qualityClass(row.quality)}>{row.quality.toUpperCase()}</strong>
|
||
<div><input type="number" step="any" min="0" bind:value={row.primaryLevel1Min} aria-label={`${row.quality} primary level 1 minimum`} required/><i>–</i><input type="number" step="any" min="0" bind:value={row.primaryLevel1Max} aria-label={`${row.quality} primary level 1 maximum`} required/></div>
|
||
<div><input type="number" step="any" min="0" bind:value={row.primaryLevel20Min} aria-label={`${row.quality} primary level 20 minimum`} required/><i>–</i><input type="number" step="any" min="0" bind:value={row.primaryLevel20Max} aria-label={`${row.quality} primary level 20 maximum`} required/></div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
<div class="weapon-mod-preview">
|
||
<div><label>LEVEL PREVIEW <b>{levelPreview}</b><input type="range" min="1" max="20" step="1" bind:value={levelPreview}/></label><SegmentedChoice legend="QUALITY PREVIEW" name="qualityPreview" options={data.qualities.map((quality: string) => ({ value:quality,label:quality }))} bind:value={qualityPreview} showIndicator={false}/></div>
|
||
{#if previewRow}<div class="weapon-mod-preview-values"><span><div class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></div><small>SELECTED PRIMARY · LEVEL {levelPreview}</small><b>{display(interpolate(previewRow.primaryLevel1Min,previewRow.primaryLevel20Min))}–{display(interpolate(previewRow.primaryLevel1Max,previewRow.primaryLevel20Max))}</b></span><span><small>SECONDARY ATTRIBUTE POOL</small><b>{editing.secondaryRanges.length} OPTIONS</b></span></div>{/if}
|
||
</div>
|
||
</section>
|
||
|
||
<section class="weapon-mod-secondary-section">
|
||
<div class="selection-head"><div><span>SECONDARY ATTRIBUTE POOL</span><small>THE BUILDER SELECTS TWO DISTINCT ATTRIBUTES · EVERY OPTION REQUIRES A RANGE AT EACH QUALITY</small></div><button type="button" class="row-action" onclick={addSecondary} disabled={editing.secondaryRanges.length>=50}>ADD ATTRIBUTE +</button></div>
|
||
<div class="weapon-mod-secondary-options">
|
||
{#each editing.secondaryRanges as range,index}
|
||
<div><strong>{String(index+1).padStart(2,'0')}</strong><SearchableAssignment items={data.stats} mode="single" compact subjectLabel="POSSIBLE ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={editing.secondaryRanges.filter((_: any,itemIndex: number)=>itemIndex!==index).map((item: any)=>item.statId)} bind:value={range.statId}/>{#each data.qualities as quality}<label><span class={qualityClass(quality)}>{quality.toUpperCase()}</span><div><input type="number" step="any" bind:value={range.values[quality].min} aria-label={`${quality} ${stat(range.statId)?.name ?? 'attribute'} minimum`} required/><i>–</i><input type="number" step="any" bind:value={range.values[quality].max} aria-label={`${quality} ${stat(range.statId)?.name ?? 'attribute'} maximum`} required/></div></label>{/each}<button type="button" aria-label={`Remove secondary attribute option ${index+1}`} onclick={()=>removeSecondary(index)} disabled={editing.secondaryRanges.length<=2}>×</button></div>
|
||
{/each}
|
||
</div>
|
||
</section>
|
||
|
||
<section class="weapon-mod-effect-section">
|
||
<div class="selection-head"><div><span>MOD EFFECT</span><small>NO TRIGGER · FIXED ATTRIBUTE VALUES VARY BY QUALITY, NOT LEVEL</small></div><button type="button" class="row-action" onclick={addEffect} disabled={editing.effects.length >= 2}>ADD EFFECT +</button></div>
|
||
<div class="weapon-mod-effects">{#each editing.effects as effect, index}<div><strong>0{index+1}</strong><SearchableAssignment items={data.stats} mode="single" compact subjectLabel="EFFECT ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={editing.effects.filter((_: any,itemIndex: number) => itemIndex !== index).map((item: any) => item.statId)} bind:value={effect.statId}/>{#each data.qualities as quality}<label><span class={qualityClass(quality)}>{quality.toUpperCase()}</span><input type="number" step="any" bind:value={effect.values[quality]} required/></label>{/each}<button type="button" aria-label={`Remove Mod Effect ${index+1}`} onclick={() => removeEffect(index)} disabled={editing.effects.length === 1}>×</button></div>{/each}</div>
|
||
</section>
|
||
{:else}
|
||
<section class="weapon-mod-exotic-section">
|
||
<div class="selection-head"><div><span>FIXED EXOTIC ATTRIBUTES</span><small>NO RANDOM ROLLS OR QUALITY RANGES · VALUES SCALE LINEARLY FROM LEVEL 1 TO 20</small></div><button type="button" class="row-action" onclick={addExoticAttribute} disabled={editing.exoticAttributes.length>=12}>ADD ATTRIBUTE +</button></div>
|
||
<div class="weapon-mod-exotic-attributes">{#each editing.exoticAttributes as attribute,index}<div><strong>{String(index+1).padStart(2,'0')}</strong><SearchableAssignment items={data.stats} mode="single" compact subjectLabel="FIXED ATTRIBUTE" searchPlaceholder="Search attributes…" excludedIds={editing.exoticAttributes.filter((_:any,itemIndex:number)=>itemIndex!==index).map((item:any)=>item.statId)} bind:value={attribute.statId}/><label>LEVEL 1 VALUE<input type="number" step="any" bind:value={attribute.level1Value} required/></label><label>LEVEL 20 VALUE<input type="number" step="any" bind:value={attribute.level20Value} required/></label><button type="button" aria-label={`Remove exotic attribute ${index+1}`} onclick={()=>removeExoticAttribute(index)} disabled={editing.exoticAttributes.length===1}>×</button></div>{/each}</div>
|
||
<p class="weapon-mod-rule"><b>EXOTIC LOCK</b> This mod is permanently equipped on {weapon(editing.exotic_weapon_id)?.name ?? 'the selected exotic weapon'}. Its fixed attributes increase with mod level and cannot be rerolled.</p>
|
||
</section>
|
||
{/if}
|
||
{#if form?.weaponModError}<p class="error">{form.weaponModError}</p>{/if}
|
||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="moderation-action danger" formaction={Number(editing.active) ? '?/archive' : '?/unarchive'} name="id" value={editing.id}>{Number(editing.active) ? 'ARCHIVE WEAPON MOD' : 'UNARCHIVE WEAPON MOD'}</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE WEAPON MOD →</button></div></div>
|
||
</PendingForm>
|
||
</div>
|
||
{/if}
|
||
|
||
<RegistryPagination pagination={data.pagination} search={data.search}/>
|
||
<div class="dashboard-table weapon-mod-dashboard-table">
|
||
<div class="dashboard-row weapon-mod-row table-heading"><span>WEAPON MOD</span><span>SLOT</span><span>PRIMARY</span><span>SECONDARIES</span><span>COMPATIBILITY</span><span>MOD EFFECT</span><span>STATE</span><span></span></div>
|
||
{#each data.mods as item}
|
||
<div class="dashboard-row weapon-mod-row" class:archived={Number(item.active)!==1} class:pending-record={item.pending}><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div><span>{item.slot_type}</span><span class="weapon-mod-primary">{#if item.exotic_weapon_id}<GameIcon name="exotic" decorative/>FIXED{:else}<span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>ANY CORE{/if}</span><span>{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${item.secondaryRanges.length} POSSIBLE`}<small> · {item.exotic_weapon_id ? 'LEVEL SCALED' : 'SELECT 2 IN BUILDER'}</small></span><span>{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}</span><span>{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}</span><b class:pending={item.pending} class="state-dot">{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||
{:else}<div class="empty-dashboard"><strong>NO WEAPON MODS REGISTERED</strong><span>Add the first slot-compatible Weapon Mod definition.</span></div>{/each}
|
||
</div>
|