Add shared gear tactical augment profiles
This commit is contained in:
@@ -10,6 +10,12 @@ import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
@@ -23,12 +29,14 @@ 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, pending] = await Promise.all([
|
||||
const [gear, brandLinks, talentLinks, brands, talents, augmentRows, stats, 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 id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
getPendingEntities(user.id, [
|
||||
{ module: 'brands', action: 'save', kind: 'brand' },
|
||||
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] }
|
||||
@@ -44,6 +52,12 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
})),
|
||||
brands: [...pendingBrands, ...brands],
|
||||
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)
|
||||
})),
|
||||
stats,
|
||||
summary,
|
||||
pagination,
|
||||
filter
|
||||
@@ -58,6 +72,55 @@ const readRange = (form: FormData, prefix: string) => {
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
saveAugmentProfile: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const slot = String(form.get('slot') ?? '');
|
||||
if (!slots.includes(slot)) return fail(400, { augmentProfileError: 'Choose a valid gear slot.' });
|
||||
const requiredSlots = augmentSlotCount(slot);
|
||||
const augments = Array.from({ length: requiredSlots }, (_, index) => ({
|
||||
slot: index + 1,
|
||||
statId: String(form.get(`statId${index + 1}`) ?? ''),
|
||||
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}`))
|
||||
}));
|
||||
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.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.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const statIds = [...new Set(augments.map((augment) => augment.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);
|
||||
const now = new Date().toISOString();
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
await (await database.prepare('DELETE FROM gear_tactical_augments WHERE gear_slot=?')).run(slot);
|
||||
for (const augment of augments) {
|
||||
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);
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: before.length ? 'gear_augment_profile.updated' : 'gear_augment_profile.created',
|
||||
entityId: slot,
|
||||
before,
|
||||
after: await augmentProfileSnapshot(slot)
|
||||
});
|
||||
return { augmentProfileSaved: true, resultToken: randomUUID() };
|
||||
},
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
|
||||
@@ -9,12 +9,35 @@
|
||||
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: [] });
|
||||
let editing = $state<Editor | null>(null);
|
||||
let augmentEditing = $state<any | null>(null);
|
||||
let handledResultToken = $state('');
|
||||
const stat = (id: string) => data.stats.find((item) => item.id === id);
|
||||
const format = (value: number | string, statId: string) => {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return '—';
|
||||
const rendered = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '');
|
||||
const unit = stat(statId)?.unit ?? '';
|
||||
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
|
||||
};
|
||||
const brandNames = (ids: string[]) => ids.map((id) => data.brands.find((brand) => brand.id === id)?.name ?? 'Archived brand').join(', ');
|
||||
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 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: '' };
|
||||
})
|
||||
};
|
||||
editing = null;
|
||||
};
|
||||
$effect(() => {
|
||||
if (form?.augmentProfileSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
augmentEditing = null;
|
||||
}
|
||||
if (form?.gearSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
editing = form.gearMode === 'add' ? blank() : null;
|
||||
@@ -23,7 +46,40 @@
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Gear — SHD.build admin</title></svelte:head>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT DEFINITION REGISTRY</p><h2>GEAR</h2><p>Configure slot, progression, base survivability ranges, and compatible brands.</p></div><button class="primary" onclick={() => editing = blank()}>ADD GEAR <span>+</span></button></div>
|
||||
<div class="module-toolbar"><div><p class="eyebrow">EQUIPMENT DEFINITION REGISTRY</p><h2>GEAR</h2><p>Configure shared tactical augments, slot progression, survivability, brands, and talents.</p></div><button class="primary" onclick={() => { editing = blank(); augmentEditing = null; }}>ADD GEAR <span>+</span></button></div>
|
||||
<details class="gear-augment-registry">
|
||||
<summary class="selection-head"><div><span>SHARED TACTICAL AUGMENT PROFILES</span><small>ONE PROFILE PER GEAR TYPE · REUSED BY EVERY MATCHING ITEM</small></div><div><b>{data.augmentProfiles.filter((profile) => profile.augments.length === profile.requiredSlots).length} / 6 CONFIGURED</b><i aria-hidden="true">+</i></div></summary>
|
||||
<div class="gear-augment-profile-grid">
|
||||
{#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>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
{#if augmentEditing}
|
||||
<div class="dashboard-editor gear-augment-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">SHARED TACTICAL AUGMENTS</p><h3>{augmentEditing.slot.toUpperCase()} PROFILE</h3></div><button aria-label="Close tactical augment editor" onclick={() => augmentEditing = null}>×</button></div>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{#if form?.augmentProfileError}<p class="error">{form.augmentProfileError}</p>{/if}
|
||||
<div class="form-actions"><button type="button" class="secondary" onclick={() => augmentEditing = null}>CANCEL</button><button class="primary">SAVE SHARED PROFILE →</button></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
<KpiFilters active={data.filter} items={[{value:'',label:'TOTAL GEAR',count:data.summary.total,description:'Show all definitions'},{value:'active',label:'ACTIVE',count:data.summary.active,description:'Available to builder'},{value:'armored',label:'ARMOR-BEARING',count:data.summary.armored,description:'Gloves, knees, and body armor'},{value:'talented',label:'TALENT-BEARING',count:data.summary.talented,description:'Backpacks and body armor'}]}/>
|
||||
{#if editing}
|
||||
<div class="dashboard-editor">
|
||||
|
||||
Reference in New Issue
Block a user