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

191 lines
20 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 SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
import IconUploadField from '$lib/components/IconUploadField.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 categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const categoryOptions = categories.map((value) => ({ value, label: value, icon: value.toLowerCase() as GameIconName, iconOnly: true }));
const tierOptions = [0, 1, 2].map((value) => ({ value: String(value), label: `T${value}` }));
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName;
const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value), iconOnly: true }));
const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0 });
const blankConditionalRule = (): ConditionalMechanicDraftV1 => ({
version:1,simulationReadiness:'captured_only',scenarioInputs:[],pools:[],
clauses:[{key:'primary',label:'Primary',event:'weapon_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'equipped_weapon',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{I:{value:0},II:{value:0}}}]}]
});
const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', descriptionTemplate: '', conditionalRule: blankConditionalRule() });
const blank = () => ({ id: '', name: '', category: 'AR', is_exotic: 0, native_tier: '0', damage_type: 'Pierce', rpm: 0, magazine: 0, reload_speed: 0, accuracy: 0, stability: 0, exotic_talent_id: '', exoticTalent: blankExoticTalent(), active: 1, tiers: [blankTier(0), blankTier(1), blankTier(2)], modSlots: [0,1,2].map((tier) => ({ tier, slots: [] })), talent1Ids: [], talent2Ids: [], augmentEffects: [{ stat_attribute_id: '', value: 0, sort_order: 1 }], baseBonuses: [{ stat_attribute_id: '', value: 0, sort_order: 1 }] });
let editing = $state<any | null>(null);
let handledResultToken = $state('');
let iconResetToken = $state(0);
let openedRequestedWeapon = $state(false);
const edit = (weapon: any) => {
const tiers = [0, 1, 2].map((tier) => {
const saved = weapon.tiers.find((item: any) => Number(item.tier) === tier);
return { ...blankTier(tier), ...saved };
});
const modSlots = [0,1,2].map((tier) => ({ tier, slots: [...(weapon.modSlots?.find((row: any) => Number(row.tier) === tier)?.slots ?? [])] }));
editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, modSlots, talent1Ids: [...weapon.talent1Ids], talent2Ids: [...weapon.talent2Ids], augmentEffects: weapon.augmentEffects.map((effect: any) => ({ ...effect })), baseBonuses: weapon.baseBonuses.map((effect: any) => ({ ...effect })), exoticTalent: weapon.exoticTalent ? { ...weapon.exoticTalent, conditionalRule: weapon.exoticTalent.conditionalRule ?? blankConditionalRule() } : blankExoticTalent() };
};
const tierAt = (tier: number) => editing.tiers.find((item: any) => Number(item.tier) === tier);
const modSlotsAt = (tier: number) => editing.modSlots.find((item: any) => Number(item.tier) === tier);
const statLabel = (id: string) => data.stats.find((stat) => stat.id === id)?.name ?? 'Unknown attribute';
const talentLabel = (id: string) => [...data.talent1Options, ...data.talent2Options].find((talent) => talent.id === id)?.name ?? 'Unknown talent';
const setCategory = (category: string) => {
if (!editing) return;
editing.category = category;
};
const setExotic = (isExotic: boolean) => {
if (!editing) return;
editing.is_exotic = isExotic ? 1 : 0;
if (isExotic && !editing.exoticTalent) editing.exoticTalent = blankExoticTalent();
};
const mechanicValue = (value: string | number, singular: string) => `${value} ${Number(value) === 1 ? singular : `${singular}s`}`;
const exoticOutcomeValue = (effect: any, tier: 'I' | 'II') => {
const value = effect?.variants?.[tier]?.value;
if (value === '' || value === null || value === undefined) return '';
if (effect.kind !== 'attribute_modifier') return String(value);
const stat = data.stats.find((item) => item.id === effect.attributeId);
const unit = stat?.unit === 'flat' ? '' : stat?.unit ?? '';
return unit === 'Shots' ? `${value} ${unit}` : `${value}${unit}`;
};
const exoticPreview = (tier: 'I' | 'II') => editing.exoticTalent.descriptionTemplate.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder: string, expression: string) => {
const duration = editing.exoticTalent.conditionalRule.pools.find((pool: any) => pool.expiresAfterSeconds)?.expiresAfterSeconds;
const maxStacks = editing.exoticTalent.conditionalRule.pools.length ? Math.max(...editing.exoticTalent.conditionalRule.pools.map((pool: any) => pool.max ?? 1)) : null;
if (expression === 'duration') return duration == null ? placeholder : mechanicValue(duration, 'second');
if (expression === 'max_stacks') return maxStacks == null ? placeholder : mechanicValue(maxStacks, 'stack');
const available = editing.exoticTalent.conditionalRule.clauses.flatMap((clause: any) => clause.outcomes);
const value = exoticOutcomeValue(available.find((outcome: any) => outcome.descriptionSlot === Number(expression)), tier);
return value || placeholder;
});
const addAugmentEffect = () => {
if (editing && editing.augmentEffects.length < 2) editing.augmentEffects = [...editing.augmentEffects, { stat_attribute_id: '', value: 0, sort_order: 2 }];
};
const removeAugmentEffect = (index: number) => {
if (editing && index > 0) editing.augmentEffects = editing.augmentEffects.filter((_: any, effectIndex: number) => effectIndex !== index);
};
const addBaseBonus = () => {
if (editing && editing.baseBonuses.length < 12) editing.baseBonuses = [...editing.baseBonuses, { stat_attribute_id: '', value: 0, sort_order: editing.baseBonuses.length + 1 }];
};
const removeBaseBonus = (index: number) => {
if (editing && editing.baseBonuses.length > 1) editing.baseBonuses = editing.baseBonuses.filter((_: any, bonusIndex: number) => bonusIndex !== index);
};
$effect(() => {
if (!openedRequestedWeapon && data.requestedWeaponId) {
const requested = data.weapons.find((weapon) => weapon.id === data.requestedWeaponId);
if (requested) edit(requested);
openedRequestedWeapon = true;
}
if (form?.weaponSaved && form.resultToken && form.resultToken !== handledResultToken) {
handledResultToken = form.resultToken;
if (form.weaponMode === 'add') iconResetToken += 1;
editing = form.weaponMode === 'add' ? blank() : null;
}
});
</script>
<svelte:head><title>Weapons — SHD.build admin</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">ARMAMENT DEFINITION REGISTRY</p><h2>WEAPONS</h2><p>Configure fixed handling, tier progression, talent pools, and tactical augmentations.</p></div><button class="primary" onclick={() => editing = blank()}>ADD WEAPON <span>+</span></button></div>
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL WEAPONS',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:'Non-exotic weapons'},{value:'exotic',label:'EXOTIC',count:data.summary.exotic,description:'Exotic weapons'}]}/>
{#snippet weaponEditor()}
<div class="dashboard-editor weapon-editor">
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY WEAPON' : 'NEW WEAPON'}</p><h3>{editing.id ? editing.name : 'WEAPON 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="modSlots" value={JSON.stringify(editing.modSlots)}/>
<div class="weapon-identity-grid">
<label class="field weapon-name">NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label>
<SegmentedChoice legend="CATEGORY" name="category" options={categoryOptions} bind:value={editing.category} onchange={(values) => setCategory(values[0])} required/>
<SegmentedChoice legend="NATIVE TIER" name="nativeTier" options={tierOptions} bind:value={editing.native_tier} required/>
<SegmentedChoice legend="DAMAGE TYPE" name="damageType" options={damageTypeOptions} bind:value={editing.damage_type} required/>
<label class="field exotic-toggle">EXOTIC STATUS<span title="Exotic weapon"><input name="isExotic" type="checkbox" value="1" checked={Number(editing.is_exotic) === 1} onchange={(event) => setExotic(event.currentTarget.checked)}/><GameIcon name="exotic" decorative/><b class="sr-only">EXOTIC WEAPON</b></span></label>
</div>
{#if Number(editing.is_exotic)}
<input type="hidden" name="exoticTalentId" value={editing.exoticTalent.id}/>
<input type="hidden" name="exoticConditionalRule" value={JSON.stringify(editing.exoticTalent.conditionalRule)}/>
<section class="weapon-exotic-talent">
<div class="selection-head"><div><span>DEDICATED EXOTIC TALENT</span><small>OWNED EXCLUSIVELY BY THIS WEAPON · TIER II VALUES REQUIRED</small></div><b>{editing.exoticTalent.id ? 'LINKED RECORD' : 'NEW RECORD'}</b></div>
<div class="exotic-icon-identity"><IconUploadField currentUrl={editing.exoticTalent.iconUrl} label="EXOTIC TALENT ICON" resetToken={iconResetToken}/><div class="form-grid exotic-talent-identity"><label class="field">TALENT NAME<input name="exoticTalentName" bind:value={editing.exoticTalent.name} maxlength="80" required/></label><label class="field">SHARED DESCRIPTION TEMPLATE<textarea name="exoticDescriptionTemplate" bind:value={editing.exoticTalent.descriptionTemplate} placeholder={'Hits grant +{1} for {duration}…'} required></textarea><small>Use numbered expressions for effect values and named expressions for duration and stacks.</small></label></div></div>
<p class="talent-template-note">DESCRIPTION EXPRESSIONS · {'{duration}'} and {'{max_stacks}'} render the defined mechanics. Cooldown is displayed separately as a standardized Talent Cooldown line.</p>
<div class="tier-preview"><div><span>TIER I PREVIEW</span><p>{exoticPreview('I') || 'Description preview'}</p></div><div><span>TIER II PREVIEW</span><p>{exoticPreview('II') || 'Description preview'}</p></div></div>
<MechanicProgramEditor
attributes={data.stats}
valueColumns={[{key:'tierI',variantKey:'I',label:'TIER I'},{key:'tierII',variantKey:'II',label:'TIER II'}]}
idPrefix={`weapon-exotic-${editing.exoticTalent.id || 'new'}`}
bind:rule={editing.exoticTalent.conditionalRule}
/>
</section>
{/if}
<div class="selection-head"><div><span>FIXED WEAPON ATTRIBUTES</span><small>UNCHANGED ACROSS TIERS</small></div><b>BASE HANDLING</b></div>
<div class="weapon-fixed-grid">
<label class="field">RPM<input name="rpm" type="number" min="0" step="1" bind:value={editing.rpm} required/></label>
<label class="field">MAGAZINE<input name="magazine" type="number" min="0" step="1" bind:value={editing.magazine} required/></label>
<label class="field">RELOAD SPEED<input name="reloadSpeed" type="number" min="0" step="any" bind:value={editing.reload_speed} required/></label>
<label class="field">ACCURACY<input name="accuracy" type="number" min="0" step="any" bind:value={editing.accuracy} required/></label>
<label class="field">STABILITY<input name="stability" type="number" min="0" step="any" bind:value={editing.stability} required/></label>
</div>
<div class="selection-head"><div><span>BASE ATTRIBUTE BONUSES</span><small>FIXED BONUSES PROVIDED BY THIS WEAPON</small></div><button type="button" class="row-action" onclick={addBaseBonus} disabled={editing.baseBonuses.length >= 12}>ADD BONUS +</button></div>
<input type="hidden" name="baseBonusCount" value={editing.baseBonuses.length}/>
<div class="weapon-base-bonuses">
{#each editing.baseBonuses as bonus: any, index}
<div class="base-bonus-row"><strong>{String(index + 1).padStart(2, '0')}</strong><div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.stats} fieldName={`baseBonusStat${index + 1}`} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={bonus.stat_attribute_id}/></div><label class="field">VALUE<input name={`baseBonusValue${index + 1}`} type="number" step="any" bind:value={bonus.value} required/></label><button type="button" aria-label={`Remove base bonus ${index + 1}`} onclick={() => removeBaseBonus(index)} disabled={editing.baseBonuses.length === 1}>×</button></div>
{/each}
</div>
<div class="selection-head"><div><span>TIER PROGRESSION</span><small>DAMAGE RANGES · CORE ATTRIBUTES USE GLOBAL CONFIGURATION</small></div><b>3 TIERS REQUIRED</b></div>
<div class="weapon-tier-grid">
{#each [0, 1, 2] as tier}
{@const slotConfig = modSlotsAt(tier)}
<fieldset class="weapon-tier-card" class:unavailable={tier < Number(editing.native_tier)} disabled={tier < Number(editing.native_tier)}><legend>TIER {tier}</legend>
{#if tier < Number(editing.native_tier)}<p class="tier-unavailable">BELOW NATIVE TIER · NOT AVAILABLE</p>{/if}
<div class="weapon-range-row"><strong><GameIcon name={iconName(editing.damage_type)} decorative/><span>DMG<small>{editing.damage_type.toUpperCase()} DAMAGE</small></span></strong><label>MIN<input name={`tier${tier}damageMin`} type="number" min="0" step="any" value={tierAt(tier).damage_min} oninput={(event) => tierAt(tier).damage_min = event.currentTarget.valueAsNumber} required/></label><label>MAX<input name={`tier${tier}damageMax`} type="number" min="0" step="any" value={tierAt(tier).damage_max} oninput={(event) => tierAt(tier).damage_max = event.currentTarget.valueAsNumber} required/></label></div>
<div class="weapon-mod-slot-config"><div class="selection-head"><div><span>WEAPON MOD SLOTS</span><small>{tier === 0 ? 'EXACTLY FOUR SLOTS' : 'EXACTLY FIVE SLOTS'}</small></div><b>{slotConfig.slots.length} / {tier === 0 ? 4 : 5}</b></div><SearchableAssignment items={data.modSlotOptions} subjectLabel="MOD SLOTS" searchPlaceholder="Search slot types…" bind:selected={slotConfig.slots}/></div>
</fieldset>
{/each}
</div>
<div class="weapon-talent-pools">
<div><div class="selection-head"><div><span>TALENT 1 · TOP POOL</span><small>GLOBALLY CLASSIFIED FOR FIRST SLOT</small></div><b>{editing.talent1Ids.length}</b></div><SearchableAssignment items={data.talent1Options} fieldName="talent1Ids" bind:selected={editing.talent1Ids}/></div>
<div><div class="selection-head"><div><span>TALENT 2 · BOTTOM POOL</span><small>GLOBALLY CLASSIFIED FOR SECOND SLOT</small></div><b>{editing.talent2Ids.length}</b></div><SearchableAssignment items={data.talent2Options} fieldName="talent2Ids" bind:selected={editing.talent2Ids}/></div>
</div>
<div class="selection-head"><div><span>TACTICAL AUGMENTATION 01</span><small>ONE OR TWO UNIQUE ATTRIBUTE UPGRADES</small></div><button type="button" class="row-action" onclick={addAugmentEffect} disabled={editing.augmentEffects.length >= 2}>ADD EFFECT +</button></div>
<div class="weapon-augment-effects">
{#each editing.augmentEffects as effect: any, index}
<div class="augment-effect"><strong>0{index + 1}</strong><div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.stats} fieldName={`augmentStat${index + 1}`} mode="single" compact subjectLabel="ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={effect.stat_attribute_id}/></div><label class="field">VALUE<input name={`augmentValue${index + 1}`} type="number" min="0" step="any" bind:value={effect.value} required/></label>{#if index > 0}<button type="button" aria-label="Remove second augmentation effect" onclick={() => removeAugmentEffect(index)}>×</button>{/if}</div>
{/each}
</div>
<div class="augment-sequence"><div><b>02</b><span>+20% DAMAGE</span></div><div><b>03</b><span>+40% DAMAGE</span></div><div><b>04</b><span>+60% DAMAGE</span></div><div><b>05</b><span>+80% DAMAGE</span></div>{#if Number(editing.is_exotic)}<div class="exotic-step"><b>06</b><span>EXOTIC TALENT → TIER II</span></div>{/if}</div>
{#if data.talent1Options.length === 0 || data.talent2Options.length === 0}<p class="error">Create and classify at least one active Talent 1 and Talent 2 weapon talent before adding weapons.</p>{/if}
{#if data.stats.length === 0}<p class="error">Create an active attribute before configuring tactical augmentation 01.</p>{/if}
{#if form?.weaponError}<p class="error">{form.weaponError}</p>{/if}
<div class="form-actions split-actions"><div>{#if editing.id}<button class="moderation-action danger weapon-archive-action" formaction="?/archive" name="id" value={editing.id}><b aria-hidden="true">⌁</b> ARCHIVE WEAPON</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.talent1Options.length === 0 || data.talent2Options.length === 0 || data.stats.length === 0}>SAVE WEAPON →</button></div></div>
</PendingForm>
</div>
{/snippet}
{#if editing && !editing.id}
{@render weaponEditor()}
{/if}
<RegistryPagination pagination={data.pagination} search={data.search}/>
<div class="dashboard-table weapon-dashboard-table">
<div class="dashboard-row weapon-row table-heading"><SortableColumnHeader label="WEAPON" column="name" sort={data.sort}/><SortableColumnHeader label="CLASS" column="class" sort={data.sort}/><SortableColumnHeader label="FIXED ATTRIBUTES" column="fixed" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="TIER RANGE" column="tiers" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="TALENT POOLS" column="talents" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="AUGMENTS" column="augments" sort={data.sort} defaultDirection="desc"/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
{#each data.weapons as weapon}
<div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1} class:pending-record={weapon.pending} class:registry-row-editing={editing?.id === weapon.id}><div><strong>{weapon.name}</strong><small class="weapon-damage-label">{#if weapon.pending}PROPOSED BY {weapon.proposerName}{:else}<GameIcon name={iconName(weapon.damage_type)} decorative/>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES{/if}</small></div><span class="weapon-category-label">{#if Number(weapon.is_exotic)}<GameIcon name="exotic" decorative/>{/if}<GameIcon name={iconName(weapon.category)} decorative/><span>{weapon.category} · T{weapon.native_tier}</span></span><span>{weapon.rpm} RPM · {weapon.magazine} MAG<small> · {weapon.reload_speed} RELOAD</small></span><span>{weapon.tiers.length}/{3 - Number(weapon.native_tier)} CONFIGURED<small> · {weapon.modSlots.reduce((total: number,row: any) => total + row.slots.length,0)} MOD SLOTS</small></span><span title={`T1: ${weapon.talent1Ids.map(talentLabel).join(', ')} · T2: ${weapon.talent2Ids.map(talentLabel).join(', ')}`}>{weapon.talent1Ids.length} / {weapon.talent2Ids.length}</span><span title={weapon.augmentEffects.map((effect: any) => `${statLabel(String(effect.stat_attribute_id))} +${effect.value}`).join(', ')}>{Number(weapon.is_exotic) ? '6' : '5'} SLOTS</span><b class:pending={weapon.pending} class="state-dot">{weapon.pending ? 'PENDING' : Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b>{#if weapon.pending}<span></span>{:else}<button onclick={() => edit(weapon)} aria-expanded={editing?.id === weapon.id} aria-controls={`weapon-editor-${weapon.id}`}>EDIT</button>{/if}</div>
{#if editing?.id === weapon.id}
<RegistryInlineEditor id={`weapon-editor-${weapon.id}`} label={`Edit ${weapon.name}`}>
{@render weaponEditor()}
</RegistryInlineEditor>
{/if}
{:else}<div class="empty-dashboard"><strong>NO WEAPONS REGISTERED</strong><span>Add active weapon talents and attributes, then create the first armament definition.</span></div>{/each}
</div>