Refine weapon progression and icon controls

This commit is contained in:
JB
2026-07-25 18:37:02 -04:00
parent cef3e6e78b
commit 92cda8a576
16 changed files with 69 additions and 25 deletions
+5 -1
View File
@@ -30,6 +30,10 @@
{ href: '/admin/metrics', label: 'System metrics', code: 'MTRX', permission: 'metrics.view' }
];
const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission)));
const selectNumericValue = (event: FocusEvent) => {
const input = event.target;
if (input instanceof HTMLInputElement && input.type === 'number' && !input.disabled) input.select();
};
</script>
<main class="admin-page">
@@ -55,7 +59,7 @@
{/if}
<div class="sidebar-foot"><span>ISAC NODE</span><strong>NYC-01</strong><small>Access policy synchronized</small></div>
</aside>
<section class="admin-content" class:data-readonly={dataReadOnly} onsubmit={(event) => {
<section class="admin-content" class:data-readonly={dataReadOnly} onfocusin={selectNumericValue} onsubmit={(event) => {
if (dataReadOnly && (event.target as HTMLFormElement).method.toLowerCase() === 'post') {
event.preventDefault();
readOnlyBlocked = true;
+4 -4
View File
@@ -90,11 +90,11 @@
<header><strong>TACTICAL AUGMENT 0{index + 1}</strong><span>{index === 0 ? 'CUSTOM ATTRIBUTE' : 'FIXED BASE UPGRADE'}</span></header>
{#if index === 0}
<div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.stats} fieldName="statId1" mode="single" compact subjectLabel="AUGMENT 1 ATTRIBUTE" searchPlaceholder="Search attributes…" bind:value={augment.statId}/></div>
<label class="field">VALUE<input name="value1" type="number" step="any" bind:value={augment.value} onfocus={(event) => event.currentTarget.select()} required/></label>
<label class="field">VALUE<input name="value1" type="number" step="any" bind:value={augment.value} required/></label>
{:else}
<div class="gear-fixed-augment"><span>{baseTargets(augmentEditing.slot)}</span><b>+{augment.value}%</b><small>APPLIES TO THIS ITEM'S CONFIGURED BASE VALUE</small></div>
{/if}
<fieldset class="gear-tier-costs"><legend>UNLOCK COST BY ITEM TIER</legend><div>{#each [0,1,2] as tier}<label class="field">TIER {tier}<input name={`unlockCost${index + 1}Tier${tier}`} type="number" min="0" step="1" bind:value={augment.costs[tier]} onfocus={(event) => event.currentTarget.select()} required/></label>{/each}</div></fieldset>
<fieldset class="gear-tier-costs"><legend>UNLOCK COST BY ITEM TIER</legend><div>{#each [0,1,2] as tier}<label class="field">TIER {tier}<input name={`unlockCost${index + 1}Tier${tier}`} type="number" min="0" step="1" bind:value={augment.costs[tier]} required/></label>{/each}</div></fieldset>
</section>
{/each}
</div>
@@ -115,8 +115,8 @@
<SegmentedChoice legend="TIER" name="tier" options={tierOptions} bind:value={editing.tier} showIndicator={false} required/>
</div>
<div class="gear-ranges">
<div class="effect-band range-band"><strong>HP<small>BASE HEALTH</small></strong><label class="field">MINIMUM<input name="healthMin" type="number" min="0" step="any" bind:value={editing.base_health_min} onfocus={(event) => event.currentTarget.select()} required /></label><label class="field">MAXIMUM<input name="healthMax" type="number" min="0" step="any" bind:value={editing.base_health_max} onfocus={(event) => event.currentTarget.select()} required /></label></div>
<div class="effect-band range-band" class:unavailable={!armoredSlots.includes(editing.slot)}><strong>AR<small>BASE ARMOR</small></strong><label class="field">MINIMUM<input name="armorMin" type="number" min="0" step="any" bind:value={editing.base_armor_min} onfocus={(event) => event.currentTarget.select()} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label><label class="field">MAXIMUM<input name="armorMax" type="number" min="0" step="any" bind:value={editing.base_armor_max} onfocus={(event) => event.currentTarget.select()} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label></div>
<div class="effect-band range-band"><strong>HP<small>BASE HEALTH</small></strong><label class="field">MINIMUM<input name="healthMin" type="number" min="0" step="any" bind:value={editing.base_health_min} required /></label><label class="field">MAXIMUM<input name="healthMax" type="number" min="0" step="any" bind:value={editing.base_health_max} required /></label></div>
<div class="effect-band range-band" class:unavailable={!armoredSlots.includes(editing.slot)}><strong>AR<small>BASE ARMOR</small></strong><label class="field">MINIMUM<input name="armorMin" type="number" min="0" step="any" bind:value={editing.base_armor_min} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label><label class="field">MAXIMUM<input name="armorMax" type="number" min="0" step="any" bind:value={editing.base_armor_max} disabled={!armoredSlots.includes(editing.slot)} required={armoredSlots.includes(editing.slot)} /></label></div>
</div>
<div class="selection-head"><div><span>COMPATIBLE BRAND SETS</span><small>SELECT AT LEAST TWO</small></div><b>{editing.brandIds.length} SELECTED</b></div>
<SearchableAssignment items={data.brands} fieldName="brandIds" subjectLabel="BRAND SETS" searchPlaceholder="Search by brand set name…" emptySelectionLabel="NO BRAND SETS ASSIGNED" bind:selected={editing.brandIds}/>
+7 -6
View File
@@ -10,7 +10,7 @@ import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconV
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const damageTypes = ['Blast', 'Pierce', 'Shredding'];
const rangeNames = ['damage', 'firepower', 'toughness', 'engineering'] as const;
const rangeNames = ['damage', 'core'] as const;
type ExoticEffect = { statId: string; valueI: number; valueII: number };
const mechanicValue = (value: number, singular: string) => `${value} ${value === 1 ? singular : `${singular}s`}`;
@@ -202,17 +202,18 @@ export const actions: Actions = {
const tiers = [];
try {
for (let tier = 0; tier <= 2; tier++) {
for (let tier = nativeTier; tier <= 2; tier++) {
const core = readRange(form, tier, 'core');
tiers.push({
tier,
damage: readRange(form, tier, 'damage'),
firepower: readRange(form, tier, 'firepower'),
toughness: readRange(form, tier, 'toughness'),
engineering: readRange(form, tier, 'engineering')
firepower: core,
toughness: core,
engineering: core
});
}
} catch {
return fail(400, { weaponError: 'Every tier range requires a non-negative minimum and a maximum greater than or equal to it.' });
return fail(400, { weaponError: 'Every available tier requires valid Damage and shared Core ranges.' });
}
const augmentEffects = [];
+14 -12
View File
@@ -9,13 +9,12 @@
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
let { data, form } = $props();
const categories = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
const categoryOptions = categories.map((value) => ({ value, label: value }));
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 ranges = [['damage', 'DMG', 'DAMAGE'], ['firepower', 'FP', 'FIREPOWER'], ['toughness', 'TGH', 'TOUGHNESS'], ['engineering', 'ENG', 'ENGINEERING']] as const;
const iconName = (value: string): GameIconName => value.toLowerCase() as GameIconName;
const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value) }));
const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0, firepower_min: 0, firepower_max: 0, toughness_min: 0, toughness_max: 0, engineering_min: 0, engineering_max: 0 });
const damageTypeOptions = damageTypes.map((value) => ({ value, label: value, icon: iconName(value), iconOnly: true }));
const blankTier = (tier: number) => ({ tier, damage_min: 0, damage_max: 0, core_min: 0, core_max: 0 });
const blankExoticTalent = () => ({ id: '', name: '', iconUrl: '', cooldown_seconds: '', duration_seconds: '', max_stacks: '', descriptionTemplate: '', triggerIds: [], effects: [{ statId: '', valueI: '', valueII: '' }] });
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)], 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);
@@ -23,7 +22,10 @@
let iconResetToken = $state(0);
let openedRequestedWeapon = $state(false);
const edit = (weapon: any) => {
const tiers = [0, 1, 2].map((tier) => ({ ...blankTier(tier), ...(weapon.tiers.find((item: any) => Number(item.tier) === tier) ?? {}) }));
const tiers = [0, 1, 2].map((tier) => {
const saved = weapon.tiers.find((item: any) => Number(item.tier) === tier);
return { ...blankTier(tier), ...saved, core_min: saved?.firepower_min ?? 0, core_max: saved?.firepower_max ?? 0 };
});
editing = { ...weapon, native_tier: String(weapon.native_tier), tiers, 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, triggerIds: [...weapon.exoticTalent.triggerIds], effects: weapon.exoticTalent.effects.map((effect: any) => ({ ...effect })) } : blankExoticTalent() };
};
const tierAt = (tier: number) => editing.tiers.find((item: any) => Number(item.tier) === tier);
@@ -95,7 +97,7 @@
<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} showIndicator={false} required/>
<SegmentedChoice legend="DAMAGE TYPE" name="damageType" options={damageTypeOptions} bind:value={editing.damage_type} required/>
<label class="field exotic-toggle">EXOTIC STATUS<span><input name="isExotic" type="checkbox" value="1" checked={Number(editing.is_exotic) === 1} onchange={(event) => setExotic(event.currentTarget.checked)}/> EXOTIC WEAPON</span></label>
<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}/>
@@ -130,10 +132,10 @@
<div class="selection-head"><div><span>TIER PROGRESSION</span><small>DAMAGE AND CORE ATTRIBUTE RANGES</small></div><b>3 TIERS REQUIRED</b></div>
<div class="weapon-tier-grid">
{#each [0, 1, 2] as tier}
<fieldset class="weapon-tier-card"><legend>TIER {tier}</legend>
{#each ranges as range}
<div class="weapon-range-row"><strong>{#if range[0] !== 'damage'}<GameIcon name={iconName(range[0])} decorative/>{/if}<span>{range[1]}<small>{range[2]}</small></span></strong><label>MIN<input name={`tier${tier}${range[0]}Min`} type="number" min="0" step="any" value={tierAt(tier)[`${range[0]}_min`]} oninput={(event) => tierAt(tier)[`${range[0]}_min`] = event.currentTarget.valueAsNumber} required/></label><label>MAX<input name={`tier${tier}${range[0]}Max`} type="number" min="0" step="any" value={tierAt(tier)[`${range[0]}_max`]} oninput={(event) => tierAt(tier)[`${range[0]}_max`] = event.currentTarget.valueAsNumber} required/></label></div>
{/each}
<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-range-row core-range-row"><strong><span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span><span>CORE<small>FP · TGH · ENG</small></span></strong><label>MIN<input name={`tier${tier}coreMin`} type="number" min="0" step="any" value={tierAt(tier).core_min} oninput={(event) => tierAt(tier).core_min = event.currentTarget.valueAsNumber} required/></label><label>MAX<input name={`tier${tier}coreMax`} type="number" min="0" step="any" value={tierAt(tier).core_max} oninput={(event) => tierAt(tier).core_max = event.currentTarget.valueAsNumber} required/></label></div>
</fieldset>
{/each}
</div>
@@ -151,7 +153,7 @@
{#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="danger" formaction="?/archive" name="id" value={editing.id}>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>
<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>
{/if}
@@ -160,6 +162,6 @@
<div class="dashboard-table weapon-dashboard-table">
<div class="dashboard-row weapon-row table-heading"><span>WEAPON</span><span>CLASS</span><span>FIXED ATTRIBUTES</span><span>TIER RANGE</span><span>TALENT POOLS</span><span>AUGMENTS</span><span>STATE</span><span></span></div>
{#each data.weapons as weapon}
<div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1}><div><strong>{weapon.name}</strong><small class="weapon-damage-label"><GameIcon name={iconName(weapon.damage_type)} decorative/>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES</small></div><span>{weapon.category} · T{weapon.native_tier}</span><span>{weapon.rpm} RPM · {weapon.magazine} MAG<small> · {weapon.reload_speed} RELOAD</small></span><span>{weapon.tiers.length}/3 CONFIGURED</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="state-dot">{Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(weapon)}>EDIT</button></div>
<div class="dashboard-row weapon-row" class:archived={Number(weapon.active) !== 1}><div><strong>{weapon.name}</strong><small class="weapon-damage-label"><GameIcon name={iconName(weapon.damage_type)} decorative/>{Number(weapon.is_exotic) ? 'EXOTIC · ' : ''}{weapon.damage_type.toUpperCase()} DAMAGE · {weapon.baseBonuses.length} BASE BONUSES</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</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="state-dot">{Number(weapon.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(weapon)}>EDIT</button></div>
{: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>