Refine Gear augments and builder levels

This commit is contained in:
JB
2026-07-25 17:41:10 -04:00
parent e5f0414a8e
commit 1465a8c9b3
9 changed files with 220 additions and 52 deletions
+5
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,5 +1,5 @@
export type Roll = { statId: string; value: number };
export type GearSelection = { itemId: string; brandId: string; talentId: string; health: number; armor: number; rolls: Roll[]; augmentSlots: number[] };
export type GearSelection = { itemId: string; level: number; brandId: string; talentId: string; health: number; armor: number; rolls: Roll[]; augmentSlots: number[] };
export type WeaponSelection = { itemId: string; tier: number; damage: number; firepower: number; toughness: number; engineering: number; talent1Id: string; talent2Id: string };
export type ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] };
export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number };
@@ -15,7 +15,7 @@ export type LoadoutDraft = {
export const gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const;
export const blankRoll = (): Roll => ({ statId: '', value: 0 });
export const blankGear = (): GearSelection => ({ itemId: '', brandId: '', talentId: '', health: 0, armor: 0, rolls: [blankRoll()], augmentSlots: [] });
export const blankGear = (): GearSelection => ({ itemId: '', level: 50, brandId: '', talentId: '', health: 0, armor: 0, rolls: [blankRoll()], augmentSlots: [] });
export const blankWeapon = (): WeaponSelection => ({ itemId: '', tier: 0, damage: 0, firepower: 0, toughness: 0, engineering: 0, talent1Id: '', talent2Id: '' });
export const blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] });
export const createDraft = (): LoadoutDraft => ({
+45 -22
View File
@@ -7,14 +7,22 @@ import type { Actions, PageServerLoad } from './$types';
import { getPagination } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { getPendingEntities } from '$lib/server/data-moderation';
import { gameDataIconUrl } from '$lib/server/game-data-icons';
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
const augmentSlotCount = (slot: string) => slot === 'Backpack' || slot === 'Body Armor' ? 4 : 3;
const augmentPercentage = (slot: string, augmentSlot: number) =>
slot === 'Backpack' || slot === 'Body Armor'
? [0, 0, 40, 70, 100][augmentSlot]
: [0, 0, 50, 100][augmentSlot];
async function augmentProfileSnapshot(slot: string) {
const database = await getDatabase();
return (await database.prepare('SELECT * FROM gear_tactical_augments WHERE gear_slot=? ORDER BY augment_slot')).all(slot);
const augments = await (await database.prepare('SELECT * FROM gear_tactical_augments WHERE gear_slot=? ORDER BY augment_slot')).all(slot);
const costs = await (await database.prepare(`SELECT cost.* FROM gear_tactical_augment_costs cost
JOIN gear_tactical_augments augment ON augment.id=cost.augment_id WHERE augment.gear_slot=? ORDER BY augment.augment_slot,cost.gear_tier`)).all(slot);
return augments.map((augment) => ({ ...augment, costs: costs.filter((cost) => cost.augment_id === augment.id) }));
}
export const load: PageServerLoad = async ({ cookies, url }) => {
@@ -29,14 +37,16 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
: filter === 'talented' ? 'WHERE EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)' : '';
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)).get();
const pagination = getPagination(url, Number(filtered?.total ?? 0));
const [gear, brandLinks, talentLinks, brands, talents, augmentRows, stats, pending] = await Promise.all([
const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons, pending] = await Promise.all([
(await database.prepare(`SELECT * FROM gear ${where} ORDER BY active DESC, slot, name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
(await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(),
(await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(),
(await database.prepare('SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare(`SELECT id,name,category FROM talents WHERE active=1 AND category IN ('Backpack','Vest','Body Armor') ORDER BY name COLLATE NOCASE`)).all(),
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
(await database.prepare('SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier')).all(),
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all(),
getPendingEntities(user.id, [
{ module: 'brands', action: 'save', kind: 'brand' },
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] }
@@ -50,12 +60,18 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
brandIds: brandLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)),
talentIds: talentLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id))
})),
brands: [...pendingBrands, ...brands],
brands: [...pendingBrands, ...brands.map((brand) => {
const icon = brandIcons.find((item) => item.entity_id === brand.id);
return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '' };
})],
talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))],
augmentProfiles: slots.map((slot) => ({
slot,
requiredSlots: augmentSlotCount(slot),
augments: augmentRows.filter((augment) => augment.gear_slot === slot)
augments: augmentRows.filter((augment) => augment.gear_slot === slot).map((augment) => ({
...augment,
costs: augmentCosts.filter((cost) => cost.augment_id === augment.id)
}))
})),
stats,
summary,
@@ -80,20 +96,24 @@ export const actions: Actions = {
const requiredSlots = augmentSlotCount(slot);
const augments = Array.from({ length: requiredSlots }, (_, index) => ({
slot: index + 1,
statId: String(form.get(`statId${index + 1}`) ?? ''),
effectType: index === 0 ? 'Custom' : 'Base Percentage',
statId: index === 0 ? String(form.get('statId1') ?? '') : '',
rawValue: String(form.get(`value${index + 1}`) ?? '').trim(),
rawUnlockCost: String(form.get(`unlockCost${index + 1}`) ?? '').trim(),
value: Number(form.get(`value${index + 1}`)),
unlockCost: Number(form.get(`unlockCost${index + 1}`))
value: index === 0 ? Number(form.get('value1')) : augmentPercentage(slot, index + 1),
costs: [0, 1, 2].map((tier) => ({
tier,
raw: String(form.get(`unlockCost${index + 1}Tier${tier}`) ?? '').trim(),
value: Number(form.get(`unlockCost${index + 1}Tier${tier}`))
}))
}));
if (augments.some((augment) => !augment.statId || !augment.rawValue || !Number.isFinite(augment.value))) {
return fail(400, { augmentProfileError: 'Every tactical augment slot requires exactly one attribute and value.' });
if (!augments[0].statId || !augments[0].rawValue || !Number.isFinite(augments[0].value)) {
return fail(400, { augmentProfileError: 'Tactical Augment 01 requires one custom attribute and value.' });
}
if (augments.some((augment) => !augment.rawUnlockCost || !Number.isInteger(augment.unlockCost) || augment.unlockCost < 0)) {
return fail(400, { augmentProfileError: 'Every tactical augment slot requires a non-negative whole-number unlock cost.' });
if (augments.some((augment) => augment.costs.some((cost) => !cost.raw || !Number.isInteger(cost.value) || cost.value < 0))) {
return fail(400, { augmentProfileError: 'Every tactical augment slot requires non-negative whole-number unlock costs for Tiers 0, 1, and 2.' });
}
const database = await getDatabase();
const statIds = [...new Set(augments.map((augment) => augment.statId))];
const statIds = [augments[0].statId];
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
if (Number(validStats?.count) !== statIds.length) return fail(400, { augmentProfileError: 'One or more selected attributes are unavailable.' });
const before = await augmentProfileSnapshot(slot);
@@ -102,9 +122,13 @@ export const actions: Actions = {
try {
await (await database.prepare('DELETE FROM gear_tactical_augments WHERE gear_slot=?')).run(slot);
for (const augment of augments) {
const augmentId = randomUUID();
await (await database.prepare(`INSERT INTO gear_tactical_augments
(id,gear_slot,augment_slot,stat_attribute_id,value,unlock_cost,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`)).run(randomUUID(), slot, augment.slot, augment.statId, augment.value, augment.unlockCost, now, now);
(id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`)).run(augmentId, slot, augment.slot, augment.effectType, augment.statId || null, augment.value, now, now);
for (const cost of augment.costs) {
await (await database.prepare('INSERT INTO gear_tactical_augment_costs(augment_id,gear_tier,unlock_cost) VALUES(?,?,?)')).run(augmentId, cost.tier, cost.value);
}
}
await database.exec('COMMIT');
} catch (error) {
@@ -128,12 +152,11 @@ export const actions: Actions = {
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const slot = String(form.get('slot') ?? '');
const tier = Number(form.get('tier'));
const level = Number(form.get('level'));
const brandIds = [...new Set(form.getAll('brandIds').map(String))];
const talentIds = [...new Set(form.getAll('talentIds').map(String))];
if (name.length < 2 || name.length > 80) return fail(400, { gearError: 'Gear names must be 2–80 characters.' });
if (!slots.includes(slot) || !Number.isInteger(tier) || tier < 0 || tier > 2 || !Number.isInteger(level) || level < 1 || level > 50) {
return fail(400, { gearError: 'Choose a valid slot, tier, and level.' });
if (!slots.includes(slot) || !Number.isInteger(tier) || tier < 0 || tier > 2) {
return fail(400, { gearError: 'Choose a valid slot and tier.' });
}
if (brandIds.length < 2) return fail(400, { gearError: 'Gear must be available from at least two brand sets.' });
const talentCategory = slot === 'Backpack' ? 'Backpack' : slot === 'Body Armor' ? 'Vest' : null;
@@ -160,11 +183,11 @@ export const actions: Actions = {
const gearId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString();
const before = id ? { record: await auditRecord('gear', id), brandIds: await (await database.prepare('SELECT brand_set_id FROM gear_brand_sets WHERE gear_id=?')).all(id), talentIds: await (await database.prepare('SELECT talent_id FROM gear_talents WHERE gear_id=?')).all(id) } : null;
if (id) {
await (await database.prepare(`UPDATE gear SET name=?,slot=?,tier=?,level=?,base_health_min=?,base_health_max=?,base_armor_min=?,base_armor_max=?,active=1,updated_at=? WHERE id=?`))
.run(name, slot, tier, level, ...health, ...armor, now, id);
await (await database.prepare(`UPDATE gear SET name=?,slot=?,tier=?,base_health_min=?,base_health_max=?,base_armor_min=?,base_armor_max=?,active=1,updated_at=? WHERE id=?`))
.run(name, slot, tier, ...health, ...armor, now, id);
} else {
await (await database.prepare(`INSERT INTO gear(id,name,slot,tier,level,base_health_min,base_health_max,base_armor_min,base_armor_max,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`))
.run(gearId, name, slot, tier, level, ...health, ...armor, now, now);
await (await database.prepare(`INSERT INTO gear(id,name,slot,tier,base_health_min,base_health_max,base_armor_min,base_armor_max,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`))
.run(gearId, name, slot, tier, ...health, ...armor, now, now);
}
await (await database.prepare('DELETE FROM gear_brand_sets WHERE gear_id=?')).run(gearId);
for (const brandId of brandIds) await (await database.prepare('INSERT INTO gear_brand_sets(gear_id,brand_set_id) VALUES(?,?)')).run(gearId, brandId);
+28 -12
View File
@@ -4,13 +4,14 @@
import KpiFilters from '$lib/components/KpiFilters.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
let { data, form } = $props();
type Editor = { id: string; name: string; slot: string; tier: number; level: number; base_health_min: number; base_health_max: number; base_armor_min: number | null; base_armor_max: number | null; brandIds: string[]; talentIds: string[] };
type Editor = { id: string; name: string; slot: string; tier: number; base_health_min: number; base_health_max: number; base_armor_min: number | null; base_armor_max: number | null; brandIds: string[]; talentIds: string[] };
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
const armoredSlots = ['Gloves', 'Knee Pads', 'Body Armor'];
const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: 0, level: 1, base_health_min: 0, base_health_max: 0, base_armor_min: null, base_armor_max: null, brandIds: [], talentIds: [] });
const blank = (): Editor => ({ id: '', name: '', slot: 'Holster', tier: 0, base_health_min: 0, base_health_max: 0, base_armor_min: null, base_armor_max: null, brandIds: [], talentIds: [] });
let editing = $state<Editor | null>(null);
let augmentEditing = $state<any | null>(null);
let handledResultToken = $state('');
const compatibleBrands = (ids: string[]) => ids.map((id) => data.brands.find((brand) => brand.id === id)).filter(Boolean);
const stat = (id: string) => data.stats.find((item) => item.id === id);
const format = (value: number | string, statId: string) => {
const numeric = Number(value);
@@ -23,12 +24,24 @@
const edit = (item: Editor) => { editing = { ...item, brandIds: [...item.brandIds], talentIds: [...item.talentIds] }; };
const gearTalents = (slot: string) => data.talents.filter((talent) => talent.category === slot);
const changeSlot = (slot: string) => { if (editing) editing = { ...editing, slot, talentIds: [] }; };
const baseTargets = (slot: string) => ['Gloves', 'Knee Pads', 'Body Armor'].includes(slot) ? 'BASE HEALTH + ARMOR' : 'BASE HEALTH';
const fixedPercentage = (slot: string, augmentSlot: number) => ['Backpack', 'Body Armor'].includes(slot)
? [0, 0, 40, 70, 100][augmentSlot]
: [0, 0, 50, 100][augmentSlot];
const editAugments = (profile: any) => {
augmentEditing = {
...profile,
augments: Array.from({ length: profile.requiredSlots }, (_, index) => {
const augment = profile.augments.find((item: any) => Number(item.augment_slot) === index + 1);
return augment ? { statId: augment.stat_attribute_id, value: augment.value, unlockCost: augment.unlock_cost } : { statId: '', value: '', unlockCost: '' };
return augment
? {
effectType: augment.effect_type, statId: augment.stat_attribute_id ?? '', value: augment.value,
costs: Object.fromEntries([0,1,2].map((tier) => [tier, augment.costs.find((cost: any) => Number(cost.gear_tier) === tier)?.unlock_cost ?? '']))
}
: {
effectType: index === 0 ? 'Custom' : 'Base Percentage', statId: '',
value: index === 0 ? '' : fixedPercentage(profile.slot, index + 1), costs: { 0: '', 1: '', 2: '' }
};
})
};
editing = null;
@@ -53,7 +66,7 @@
{#each data.augmentProfiles as profile}
<button type="button" class:configured={profile.augments.length === profile.requiredSlots} onclick={() => editAugments(profile)}>
<header><div><strong>{profile.slot}</strong><small>{profile.requiredSlots} AUGMENT SLOTS</small></div><span>{profile.augments.length === profile.requiredSlots ? 'CONFIGURED' : 'REQUIRED'}</span></header>
<div>{#each Array(profile.requiredSlots) as _, index}{@const augment = profile.augments.find((item) => Number(item.augment_slot) === index + 1)}<span><small>0{index + 1}</small><b>{augment ? `${stat(augment.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(augment.value, augment.stat_attribute_id)}` : 'NOT SET'}</b><em>{augment ? `${augment.unlock_cost} COST` : '—'}</em></span>{/each}</div>
<div>{#each Array(profile.requiredSlots) as _, index}{@const augment = profile.augments.find((item) => Number(item.augment_slot) === index + 1)}<span><small>0{index + 1}</small><b>{index === 0 ? (augment ? `${stat(augment.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(augment.value, augment.stat_attribute_id)}` : 'CUSTOM NOT SET') : `+${fixedPercentage(profile.slot,index + 1)}% ${baseTargets(profile.slot)}`}</b><em>{augment ? [0,1,2].map((tier) => `T${tier} ${augment.costs.find((cost: any) => Number(cost.gear_tier) === tier)?.unlock_cost ?? '—'}`).join(' · ') : 'TIER COSTS NOT SET'}</em></span>{/each}</div>
</button>
{/each}
</div>
@@ -64,14 +77,18 @@
<PendingForm action="?/saveAugmentProfile">
<input type="hidden" name="slot" value={augmentEditing.slot}/>
<input type="hidden" name="name" value={`${augmentEditing.slot} tactical augments`}/>
<p class="console-note">These {augmentEditing.requiredSlots} augment slots apply to every {augmentEditing.slot} item. Each slot grants exactly one attribute and unlocks at its configured tactical-augment cost.</p>
<p class="console-note">Augment 01 grants a custom attribute. Later slots increase {baseTargets(augmentEditing.slot).toLocaleLowerCase()} on the equipped item itself, before those item values enter agent totals.</p>
<div class="gear-augment-slots">
{#each augmentEditing.augments as augment: any, index}
<section>
<header><strong>TACTICAL AUGMENT 0{index + 1}</strong><span>SHARED SLOT</span></header>
<div class="field"><span>ATTRIBUTE</span><SearchableAssignment items={data.stats} fieldName={`statId${index + 1}`} mode="single" compact subjectLabel={`AUGMENT ${index + 1} ATTRIBUTE`} searchPlaceholder="Search attributes…" bind:value={augment.statId}/></div>
<label class="field">VALUE<input name={`value${index + 1}`} type="number" step="any" bind:value={augment.value} required/></label>
<label class="field">UNLOCK COST<input name={`unlockCost${index + 1}`} type="number" min="0" step="1" bind:value={augment.unlockCost} required/></label>
<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} 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]} required/></label>{/each}</div></fieldset>
</section>
{/each}
</div>
@@ -90,7 +107,6 @@
<label class="field">NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
<label class="field">SLOT<select name="slot" value={editing.slot} onchange={(event) => changeSlot(event.currentTarget.value)}>{#each slots as slot}<option>{slot}</option>{/each}</select></label>
<label class="field">TIER<select name="tier" bind:value={editing.tier}><option value={0}>0</option><option value={1}>1</option><option value={2}>2</option></select></label>
<label class="field">LEVEL<input name="level" type="number" min="1" max="50" step="1" bind:value={editing.level} required /></label>
</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} required /></label><label class="field">MAXIMUM<input name="healthMax" type="number" min="0" step="any" bind:value={editing.base_health_max} required /></label></div>
@@ -111,8 +127,8 @@
{/if}
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table">
<div class="dashboard-row gear-row table-heading"><span>GEAR</span><span>SLOT</span><span>PROGRESSION</span><span>BASE RANGES</span><span>BRAND SETS</span><span>STATE</span><span></span></div>
<div class="dashboard-row gear-row table-heading"><span>GEAR</span><span>SLOT</span><span>TIER</span><span>BASE RANGES</span><span>BRAND SETS</span><span>STATE</span><span></span></div>
{#each data.gear as item}
<div class="dashboard-row gear-row" class:archived={Number(item.active) !== 1}><div><strong>{item.name}</strong><small>{item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}</small></div><span>{item.slot}</span><span>T{item.tier} · LVL {item.level}</span><span>HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null}<small> · AR {item.base_armor_min}–{item.base_armor_max}</small>{/if}</span><span title={brandNames(item.brandIds)}>{item.brandIds.length} COMPATIBLE</span><b class="state-dot">{Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(item)}>EDIT</button></div>
<div class="dashboard-row gear-row" class:archived={Number(item.active) !== 1}><div><strong>{item.name}</strong><small>{item.talentIds.length ? `${item.talentIds.length} TALENT${item.talentIds.length === 1 ? '' : 'S'}` : item.id.slice(0, 8).toUpperCase()}</small></div><span>{item.slot}</span><span>T{item.tier}</span><span>HP {item.base_health_min}–{item.base_health_max}{#if item.base_armor_min !== null}<small> · AR {item.base_armor_min}–{item.base_armor_max}</small>{/if}</span><div class="registry-icon-rail" aria-label={`Compatible Brand Sets: ${brandNames(item.brandIds)}`}>{#each compatibleBrands(item.brandIds) as brand}<span title={brand.name}>{#if brand.iconUrl}<img src={brand.iconUrl} alt={brand.name}/>{:else}<i aria-hidden="true">{brand.name.slice(0,2).toUpperCase()}</i><span class="sr-only">{brand.name}</span>{/if}</span>{/each}</div><b class="state-dot">{Number(item.active) === 1 ? 'ACTIVE' : 'ARCHIVED'}</b><button onclick={() => edit(item)}>EDIT</button></div>
{:else}<div class="empty-dashboard"><strong>NO GEAR REGISTERED</strong><span>Add the first gear definition after configuring at least two brand sets.</span></div>{/each}
</div>
+6 -2
View File
@@ -5,7 +5,7 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const database = await getDatabase();
const [
attributes, gear, gearBrands, gearTalents, gearAugments, brands, brandEffects,
attributes, gear, gearBrands, gearTalents, gearAugments, gearAugmentCosts, brands, brandEffects,
weapons, weaponRanges, weaponTalentOptions, weaponBonuses,
talents, talentTiers, talentEffects, talentTemplates,
chipSets, chipTwoPiece, chipPrimary, chipSecondary, chipTalents, chipTalentEffects,
@@ -17,6 +17,7 @@ export const load: PageServerLoad = async () => {
(await database.prepare('SELECT * FROM gear_brand_sets')).all(),
(await database.prepare('SELECT * FROM gear_talents')).all(),
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
(await database.prepare('SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier')).all(),
(await database.prepare('SELECT * FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await database.prepare('SELECT * FROM brand_set_effects ORDER BY pieces_required')).all(),
(await database.prepare('SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE')).all(),
@@ -61,7 +62,10 @@ export const load: PageServerLoad = async () => {
brandIds: gearBrands.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)),
talentIds: gearTalents.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id))
})),
gearAugments,
gearAugments: gearAugments.map((augment) => ({
...augment,
costs: gearAugmentCosts.filter((cost) => cost.augment_id === augment.id)
})),
brands: brands.map((brand) => ({
...brand,
iconUrl: iconUrl('brand_set', brand.id),
+11 -6
View File
@@ -57,6 +57,7 @@
const configureGear = (slot: string, id: string) => {
const selection = draft.gear[slot], item = data.gear.find((entry) => entry.id === id);
selection.itemId = id;
selection.level = 50;
selection.brandId = item?.brandIds[0] ?? '';
selection.talentId = item?.talentIds[0] ?? '';
selection.health = Number(item?.base_health_min ?? 0);
@@ -110,11 +111,13 @@
for (const slot of gearSlots) {
const selection = draft.gear[slot], item = gearItem(slot);
if (!item) continue;
add(byName('Health'), selection.health, `${slot} base`);
add(byName('Armor'), selection.armor, `${slot} base`);
const enabledAugments = data.gearAugments.filter((entry) => entry.gear_slot === slot && selection.augmentSlots.includes(Number(entry.augment_slot)));
const basePercentage = enabledAugments.filter((entry) => entry.effect_type === 'Base Percentage').reduce((total, entry) => total + Number(entry.value), 0);
add(byName('Health'), selection.health * (1 + basePercentage / 100), `${slot} base`);
if (item.base_armor_min !== null) add(byName('Armor'), selection.armor * (1 + basePercentage / 100), `${slot} base`);
for (const roll of selection.rolls) add(roll.statId, roll.value, `${slot} roll`);
if (selection.brandId) brandCounts.set(selection.brandId, (brandCounts.get(selection.brandId) ?? 0) + 1);
for (const augment of data.gearAugments.filter((entry) => entry.gear_slot === slot && selection.augmentSlots.includes(Number(entry.augment_slot)))) {
for (const augment of enabledAugments.filter((entry) => entry.effect_type === 'Custom')) {
add(augment.stat_attribute_id, augment.value, `${slot} augment ${augment.augment_slot}`);
}
if (selection.talentId) equippedTalents.push({ key: `gear:${slot}:${selection.talentId}`, id: selection.talentId, tier: Number(item.tier), source: slot, description: talentDescription(selection.talentId, Number(item.tier)) });
@@ -195,6 +198,7 @@
const parsed = JSON.parse(stored);
if (parsed?.version === 1) {
draft = parsed;
for (const slot of gearSlots) if (!Number.isInteger(draft.gear[slot].level)) draft.gear[slot].level = 50;
if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
saved = true;
}
@@ -223,15 +227,16 @@
<section class="loadout-editor">
{#if activeParts[0] === 'gear'}
{@const slot = activeParts[1]}{@const selection = draft.gear[slot]}{@const item = gearItem(slot)}
<div class="loadout-editor-head"><div><span>GEAR SLOT</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `TIER ${item.tier} · LEVEL ${item.level}` : 'UNASSIGNED'}</b></div>
<div class="loadout-editor-head"><div><span>GEAR SLOT</span><h2>{slot.toUpperCase()}</h2></div><b>{item ? `TIER ${item.tier} · BUILD LEVEL ${selection.level}` : 'UNASSIGNED'}</b></div>
<BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="ITEM DEFINITION"/>
{#if item}
<div class="builder-field-grid"><label>BRAND SET<select value={selection.brandId} onchange={(event) => selection.brandId = event.currentTarget.value}>{#each item.brandIds as id}<option value={id}>{brand(id)?.name}</option>{/each}</select></label>{#if item.talentIds.length}<label>EQUIPPED TALENT<select bind:value={selection.talentId}>{#each item.talentIds as id}<option value={id}>{talent(id)?.name}</option>{/each}</select></label>{/if}</div>
<div class="builder-field-grid"><label>GEAR LEVEL <span class="builder-field-range">01–50</span><input type="number" min="1" max="50" bind:value={selection.level} onblur={() => selection.level = clamp(selection.level,1,50)}/></label><label>BRAND SET<select value={selection.brandId} onchange={(event) => selection.brandId = event.currentTarget.value}>{#each item.brandIds as id}<option value={id}>{brand(id)?.name}</option>{/each}</select></label>{#if item.talentIds.length}<label>EQUIPPED TALENT<select bind:value={selection.talentId}>{#each item.talentIds as id}<option value={id}>{talent(id)?.name}</option>{/each}</select></label>{/if}</div>
<p class="builder-scaling-note">Gear level belongs to this build instance. Base-range level scaling will remain neutral until the authoritative game scaling curve is configured.</p>
<div class="builder-range-grid"><label>BASE HEALTH <span>{item.base_health_min}–{item.base_health_max}</span><input type="number" min={item.base_health_min} max={item.base_health_max} bind:value={selection.health} onblur={() => selection.health = clamp(selection.health,item.base_health_min,item.base_health_max)}/></label>{#if item.base_armor_min !== null}<label>BASE ARMOR <span>{item.base_armor_min}–{item.base_armor_max}</span><input type="number" min={item.base_armor_min} max={item.base_armor_max} bind:value={selection.armor} onblur={() => selection.armor = clamp(selection.armor,item.base_armor_min,item.base_armor_max)}/></label>{/if}</div>
<div class="builder-section-title"><div><span>ROLLED ATTRIBUTES</span><small>VALUES ARE LIMITED TO GEAR RANGES</small></div><button onclick={() => selection.rolls = [...selection.rolls, blankRoll()]} disabled={selection.rolls.length >= 6}>ADD ATTRIBUTE +</button></div>
<div class="builder-rolls">{#each selection.rolls as roll, index}<div><BuilderPicker items={gearAttributes} bind:value={roll.statId} onchange={(id) => setRollStat(roll,id,'gear')} label={`ATTRIBUTE ${index + 1}`}/><label>VALUE<input type="number" value={roll.value} min={attribute(roll.statId)?.gear_min ?? 0} max={attribute(roll.statId)?.gear_max ?? 0} onchange={(event) => roll.value = clamp(event.currentTarget.value,attribute(roll.statId)?.gear_min,attribute(roll.statId)?.gear_max)}/></label><button aria-label={`Remove attribute ${index + 1}`} onclick={() => selection.rolls = selection.rolls.filter((_, itemIndex) => itemIndex !== index)}>×</button></div>{/each}</div>
{@const augments = data.gearAugments.filter((entry) => entry.gear_slot === slot)}
{#if augments.length}<div class="builder-section-title"><div><span>TACTICAL AUGMENTS</span><small>ENABLE COLLECTED UPGRADES</small></div><b>{selection.augmentSlots.length} / {augments.length} ACTIVE</b></div><div class="builder-augment-list">{#each augments as augment}<label><input type="checkbox" checked={selection.augmentSlots.includes(Number(augment.augment_slot))} onchange={() => selection.augmentSlots = selection.augmentSlots.includes(Number(augment.augment_slot)) ? selection.augmentSlots.filter((value) => value !== Number(augment.augment_slot)) : [...selection.augmentSlots,Number(augment.augment_slot)]}/><span><b>AUGMENT 0{augment.augment_slot}</b><small>{attribute(augment.stat_attribute_id)?.name} · {unitValue(augment.value,augment.stat_attribute_id)}</small></span><em>{augment.unlock_cost} COST</em></label>{/each}</div>{/if}
{#if augments.length}<div class="builder-section-title"><div><span>TACTICAL AUGMENTS</span><small>ENABLE COLLECTED UPGRADES · BASE BONUSES APPLY TO THIS ITEM ONLY</small></div><b>{selection.augmentSlots.length} / {augments.length} ACTIVE</b></div><div class="builder-augment-list">{#each augments as augment}<label><input type="checkbox" checked={selection.augmentSlots.includes(Number(augment.augment_slot))} onchange={() => selection.augmentSlots = selection.augmentSlots.includes(Number(augment.augment_slot)) ? selection.augmentSlots.filter((value) => value !== Number(augment.augment_slot)) : [...selection.augmentSlots,Number(augment.augment_slot)]}/><span><b>AUGMENT 0{augment.augment_slot}</b><small>{augment.effect_type === 'Custom' ? `${attribute(augment.stat_attribute_id)?.name} · ${unitValue(augment.value,augment.stat_attribute_id)}` : `+${augment.value}% BASE HEALTH${item.base_armor_min !== null ? ' + ARMOR' : ''}`}</small></span><em>{augment.costs.find((cost: any) => Number(cost.gear_tier) === Number(item.tier))?.unlock_cost ?? '—'} COST · T{item.tier}</em></label>{/each}</div>{/if}
{/if}
{:else if activeParts[0] === 'weapon'}
{@const index = Number(activeParts[1])}{@const selection = draft.weapons[index]}{@const item = weaponItem(index)}{@const other = weaponItem(index ? 0 : 1)}