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
+13 -3
View File
@@ -357,7 +357,6 @@ CREATE TABLE IF NOT EXISTS gear (
name TEXT NOT NULL, name TEXT NOT NULL,
slot TEXT NOT NULL CHECK(slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')), slot TEXT NOT NULL CHECK(slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')),
tier INTEGER NOT NULL CHECK(tier BETWEEN 0 AND 2), tier INTEGER NOT NULL CHECK(tier BETWEEN 0 AND 2),
level INTEGER NOT NULL CHECK(level BETWEEN 1 AND 50),
base_health_min REAL NOT NULL CHECK(base_health_min >= 0), base_health_min REAL NOT NULL CHECK(base_health_min >= 0),
base_health_max REAL NOT NULL CHECK(base_health_max >= base_health_min), base_health_max REAL NOT NULL CHECK(base_health_max >= base_health_min),
base_armor_min REAL, base_armor_min REAL,
@@ -386,9 +385,9 @@ CREATE TABLE IF NOT EXISTS gear_tactical_augments (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
gear_slot TEXT NOT NULL CHECK(gear_slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')), gear_slot TEXT NOT NULL CHECK(gear_slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')),
augment_slot INTEGER NOT NULL CHECK(augment_slot BETWEEN 1 AND 4), augment_slot INTEGER NOT NULL CHECK(augment_slot BETWEEN 1 AND 4),
stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), effect_type TEXT NOT NULL CHECK(effect_type IN ('Custom', 'Base Percentage')),
stat_attribute_id TEXT REFERENCES stat_attributes(id),
value REAL NOT NULL, value REAL NOT NULL,
unlock_cost INTEGER NOT NULL CHECK(unlock_cost >= 0),
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
UNIQUE(gear_slot, augment_slot), UNIQUE(gear_slot, augment_slot),
@@ -396,8 +395,19 @@ CREATE TABLE IF NOT EXISTS gear_tactical_augments (
(gear_slot IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 4) (gear_slot IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 4)
OR OR
(gear_slot NOT IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 3) (gear_slot NOT IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 3)
),
CHECK(
(augment_slot = 1 AND effect_type = 'Custom' AND stat_attribute_id IS NOT NULL)
OR
(augment_slot > 1 AND effect_type = 'Base Percentage' AND stat_attribute_id IS NULL AND value > 0)
) )
); );
CREATE TABLE IF NOT EXISTS gear_tactical_augment_costs (
augment_id TEXT NOT NULL REFERENCES gear_tactical_augments(id) ON DELETE CASCADE,
gear_tier INTEGER NOT NULL CHECK(gear_tier BETWEEN 0 AND 2),
unlock_cost INTEGER NOT NULL CHECK(unlock_cost >= 0),
PRIMARY KEY(augment_id, gear_tier)
);
CREATE TABLE IF NOT EXISTS weapons ( CREATE TABLE IF NOT EXISTS weapons (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
+33 -5
View File
@@ -809,11 +809,20 @@ editor. Successful saves close the profile editor and return to the registry.
Gear Tactical Augment profiles use this pattern for all six gear types. Gear Tactical Augment profiles use this pattern for all six gear types.
Backpacks and Body Armor expose four required slots; Holsters, Gloves, Knee Backpacks and Body Armor expose four required slots; Holsters, Gloves, Knee
Pads, and Masks expose three. Every slot contains exactly one searchable Pads, and Masks expose three. Augment 01 contains one searchable Attribute,
Attribute relationship, one numeric effect value, and one non-negative one numeric effect value, and an unlock cost. Later slots expose only their
whole-number unlock cost. At narrow widths profile cards and augment editor unlock cost because their effects are fixed base-item upgrades: three-slot
cards reduce from multi-column grids to a single column while retaining profiles use +50% and +100%, while four-slot profiles use +40%, +70%, and
44-pixel interactive targets. +100%. Holsters, Masks, and Backpacks modify the equipped item's base Health;
Gloves, Knee Pads, and Body Armor modify that item's base Health and Armor.
These percentages never apply to the overall character total. Every augment
slot stores separate non-negative whole-number unlock costs for item Tiers 0,
1, and 2. Show all three costs together in registry summaries and in a compact
three-column cost fieldset within the editor; the builder shows only the cost
matching the equipped item's tier. Fixed upgrades
use a green inherited-value panel to distinguish governed data from editable
orange actions. At narrow widths profile cards and augment editor cards reduce
to a single column while retaining 44-pixel interactive targets.
### Loadout workbench ### Loadout workbench
@@ -837,3 +846,22 @@ the live totals immediately. Trigger-dependent talents remain explicitly
`CONDITIONAL` until the user enables `TRIGGER ACTIVE`; only then may their `CONDITIONAL` until the user enables `TRIGGER ACTIVE`; only then may their
effects enter combined totals. This textual distinction must remain clear effects enter combined totals. This textual distinction must remain clear
without relying on color. without relying on color.
Gear level is builder-instance state, never part of an ISAC Gear definition.
Each equipped piece stores its own level from 1–50 in the local or persisted
build. Until an authoritative level-scaling curve is represented in game data,
the workbench must state that level scaling is neutral rather than silently
inventing a formula; base Health and Armor remain constrained by their
canonical ranges.
### Registry relationship icon rails
When a table relationship contains a small bounded set of visually identified
records, show a compact icon rail instead of a numeric relationship count.
Rails use 30-pixel square cells with four-pixel spacing, uploaded transparent
icons, full accessible names, and native hover titles. Missing artwork falls
back to a short orange text mark rather than removing the relationship.
Use this pattern only for small bounded sets that remain scannable, such as the
two-to-four compatible Brand Sets on Gear. Long or unbounded relationships
continue to use counts, searchable inspection, or expanded detail views.
+77
View File
@@ -145,6 +145,83 @@ if (typeof iconDefinition?.sql === 'string' && !iconDefinition.sql.includes("'sk
await database.exec('ALTER TABLE game_data_icons_expanded RENAME TO game_data_icons'); await database.exec('ALTER TABLE game_data_icons_expanded RENAME TO game_data_icons');
} }
const gearAugmentColumns = await (await database.prepare("PRAGMA table_info('gear_tactical_augments')")).all();
if (gearAugmentColumns.length && !gearAugmentColumns.some((column) => column.name === 'effect_type')) {
console.log('Separating custom Gear Tactical Augments from fixed base-item percentage upgrades.');
await database.exec('ALTER TABLE gear_tactical_augments RENAME TO gear_tactical_augments_legacy');
await database.exec(`CREATE TABLE gear_tactical_augments (
id TEXT PRIMARY KEY,
gear_slot TEXT NOT NULL CHECK(gear_slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')),
augment_slot INTEGER NOT NULL CHECK(augment_slot BETWEEN 1 AND 4),
effect_type TEXT NOT NULL CHECK(effect_type IN ('Custom', 'Base Percentage')),
stat_attribute_id TEXT REFERENCES stat_attributes(id),
value REAL NOT NULL,
unlock_cost INTEGER NOT NULL CHECK(unlock_cost >= 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(gear_slot, augment_slot),
CHECK((gear_slot IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 4) OR (gear_slot NOT IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 3)),
CHECK((augment_slot = 1 AND effect_type = 'Custom' AND stat_attribute_id IS NOT NULL) OR (augment_slot > 1 AND effect_type = 'Base Percentage' AND stat_attribute_id IS NULL AND value > 0))
)`);
await database.exec(`INSERT INTO gear_tactical_augments
(id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,unlock_cost,created_at,updated_at)
SELECT id,gear_slot,augment_slot,
CASE WHEN augment_slot=1 THEN 'Custom' ELSE 'Base Percentage' END,
CASE WHEN augment_slot=1 THEN stat_attribute_id ELSE NULL END,
CASE
WHEN augment_slot=1 THEN value
WHEN gear_slot IN ('Backpack','Body Armor') AND augment_slot=2 THEN 40
WHEN gear_slot IN ('Backpack','Body Armor') AND augment_slot=3 THEN 70
WHEN gear_slot IN ('Backpack','Body Armor') AND augment_slot=4 THEN 100
WHEN augment_slot=2 THEN 50
ELSE 100
END,
unlock_cost,created_at,updated_at
FROM gear_tactical_augments_legacy`);
await database.exec('DROP TABLE gear_tactical_augments_legacy');
}
const tieredGearAugmentColumns = await (await database.prepare("PRAGMA table_info('gear_tactical_augments')")).all();
if (tieredGearAugmentColumns.some((column) => column.name === 'unlock_cost')) {
console.log('Separating Gear Tactical Augment unlock costs by item tier.');
await database.exec('DROP TABLE IF EXISTS gear_tactical_augment_costs');
await database.exec('ALTER TABLE gear_tactical_augments RENAME TO gear_tactical_augments_single_cost');
await database.exec(`CREATE TABLE gear_tactical_augments (
id TEXT PRIMARY KEY,
gear_slot TEXT NOT NULL CHECK(gear_slot IN ('Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask')),
augment_slot INTEGER NOT NULL CHECK(augment_slot BETWEEN 1 AND 4),
effect_type TEXT NOT NULL CHECK(effect_type IN ('Custom', 'Base Percentage')),
stat_attribute_id TEXT REFERENCES stat_attributes(id),
value REAL NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(gear_slot, augment_slot),
CHECK((gear_slot IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 4) OR (gear_slot NOT IN ('Backpack', 'Body Armor') AND augment_slot BETWEEN 1 AND 3)),
CHECK((augment_slot = 1 AND effect_type = 'Custom' AND stat_attribute_id IS NOT NULL) OR (augment_slot > 1 AND effect_type = 'Base Percentage' AND stat_attribute_id IS NULL AND value > 0))
)`);
await database.exec(`INSERT INTO gear_tactical_augments
(id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,created_at,updated_at)
SELECT id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,created_at,updated_at
FROM gear_tactical_augments_single_cost`);
await database.exec(`CREATE TABLE gear_tactical_augment_costs (
augment_id TEXT NOT NULL REFERENCES gear_tactical_augments(id) ON DELETE CASCADE,
gear_tier INTEGER NOT NULL CHECK(gear_tier BETWEEN 0 AND 2),
unlock_cost INTEGER NOT NULL CHECK(unlock_cost >= 0),
PRIMARY KEY(augment_id, gear_tier)
)`);
await database.exec(`INSERT INTO gear_tactical_augment_costs(augment_id,gear_tier,unlock_cost)
SELECT id,0,unlock_cost FROM gear_tactical_augments_single_cost
UNION ALL SELECT id,1,unlock_cost FROM gear_tactical_augments_single_cost
UNION ALL SELECT id,2,unlock_cost FROM gear_tactical_augments_single_cost`);
await database.exec('DROP TABLE gear_tactical_augments_single_cost');
}
const gearColumns = await (await database.prepare("PRAGMA table_info('gear')")).all();
if (gearColumns.some((column) => column.name === 'level')) {
console.log('Moving Gear level from canonical ISAC data to builder-instance state.');
await database.exec('ALTER TABLE gear DROP COLUMN level');
}
console.log(`Applied ${statements.length} schema statements to ${databasePath}`); console.log(`Applied ${statements.length} schema statements to ${databasePath}`);
if (!databaseAlreadyExisted) { if (!databaseAlreadyExisted) {
+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 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 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 ChipSelection = { setId: string; quality: 'Superior' | 'High-end'; primary: Roll; secondary: [Roll, Roll] };
export type ProtocolSelection = { itemId: string; level: number; talentUpgrades: number }; 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 gearSlots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'] as const;
export const blankRoll = (): Roll => ({ statId: '', value: 0 }); 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 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 blankChip = (): ChipSelection => ({ setId: '', quality: 'Superior', primary: blankRoll(), secondary: [blankRoll(), blankRoll()] });
export const createDraft = (): LoadoutDraft => ({ 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 { getPagination } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { getPendingEntities } from '$lib/server/data-moderation'; 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 slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']); const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
const augmentSlotCount = (slot: string) => slot === 'Backpack' || slot === 'Body Armor' ? 4 : 3; 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) { async function augmentProfileSnapshot(slot: string) {
const database = await getDatabase(); 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 }) => { 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)' : ''; : 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 filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)).get();
const pagination = getPagination(url, Number(filtered?.total ?? 0)); 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 * 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,brand_set_id FROM gear_brand_sets')).all(),
(await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).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 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 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_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 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, [ getPendingEntities(user.id, [
{ module: 'brands', action: 'save', kind: 'brand' }, { module: 'brands', action: 'save', kind: 'brand' },
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] } { 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)), 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)) 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 }))], talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))],
augmentProfiles: slots.map((slot) => ({ augmentProfiles: slots.map((slot) => ({
slot, slot,
requiredSlots: augmentSlotCount(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, stats,
summary, summary,
@@ -80,20 +96,24 @@ export const actions: Actions = {
const requiredSlots = augmentSlotCount(slot); const requiredSlots = augmentSlotCount(slot);
const augments = Array.from({ length: requiredSlots }, (_, index) => ({ const augments = Array.from({ length: requiredSlots }, (_, index) => ({
slot: index + 1, 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(), rawValue: String(form.get(`value${index + 1}`) ?? '').trim(),
rawUnlockCost: String(form.get(`unlockCost${index + 1}`) ?? '').trim(), value: index === 0 ? Number(form.get('value1')) : augmentPercentage(slot, index + 1),
value: Number(form.get(`value${index + 1}`)), costs: [0, 1, 2].map((tier) => ({
unlockCost: Number(form.get(`unlockCost${index + 1}`)) 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))) { if (!augments[0].statId || !augments[0].rawValue || !Number.isFinite(augments[0].value)) {
return fail(400, { augmentProfileError: 'Every tactical augment slot requires exactly one attribute and 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)) { 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 a non-negative whole-number unlock cost.' }); 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 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); 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.' }); if (Number(validStats?.count) !== statIds.length) return fail(400, { augmentProfileError: 'One or more selected attributes are unavailable.' });
const before = await augmentProfileSnapshot(slot); const before = await augmentProfileSnapshot(slot);
@@ -102,9 +122,13 @@ export const actions: Actions = {
try { try {
await (await database.prepare('DELETE FROM gear_tactical_augments WHERE gear_slot=?')).run(slot); await (await database.prepare('DELETE FROM gear_tactical_augments WHERE gear_slot=?')).run(slot);
for (const augment of augments) { for (const augment of augments) {
const augmentId = randomUUID();
await (await database.prepare(`INSERT INTO gear_tactical_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) (id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`)).run(randomUUID(), slot, augment.slot, augment.statId, augment.value, augment.unlockCost, now, now); 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'); await database.exec('COMMIT');
} catch (error) { } catch (error) {
@@ -128,12 +152,11 @@ export const actions: Actions = {
const name = String(form.get('name') ?? '').normalize('NFKC').trim(); const name = String(form.get('name') ?? '').normalize('NFKC').trim();
const slot = String(form.get('slot') ?? ''); const slot = String(form.get('slot') ?? '');
const tier = Number(form.get('tier')); const tier = Number(form.get('tier'));
const level = Number(form.get('level'));
const brandIds = [...new Set(form.getAll('brandIds').map(String))]; const brandIds = [...new Set(form.getAll('brandIds').map(String))];
const talentIds = [...new Set(form.getAll('talentIds').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 (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) { if (!slots.includes(slot) || !Number.isInteger(tier) || tier < 0 || tier > 2) {
return fail(400, { gearError: 'Choose a valid slot, tier, and level.' }); 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.' }); 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; 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 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; 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) { 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=?`)) 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, level, ...health, ...armor, now, id); .run(name, slot, tier, ...health, ...armor, now, id);
} else { } 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(?,?,?,?,?,?,?,?,?,?,?)`)) 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, level, ...health, ...armor, now, now); .run(gearId, name, slot, tier, ...health, ...armor, now, now);
} }
await (await database.prepare('DELETE FROM gear_brand_sets WHERE gear_id=?')).run(gearId); 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); 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 KpiFilters from '$lib/components/KpiFilters.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte'; import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
let { data, form } = $props(); 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 slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
const armoredSlots = ['Gloves', 'Knee Pads', 'Body Armor']; 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 editing = $state<Editor | null>(null);
let augmentEditing = $state<any | null>(null); let augmentEditing = $state<any | null>(null);
let handledResultToken = $state(''); 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 stat = (id: string) => data.stats.find((item) => item.id === id);
const format = (value: number | string, statId: string) => { const format = (value: number | string, statId: string) => {
const numeric = Number(value); const numeric = Number(value);
@@ -23,12 +24,24 @@
const edit = (item: Editor) => { editing = { ...item, brandIds: [...item.brandIds], talentIds: [...item.talentIds] }; }; const edit = (item: Editor) => { editing = { ...item, brandIds: [...item.brandIds], talentIds: [...item.talentIds] }; };
const gearTalents = (slot: string) => data.talents.filter((talent) => talent.category === slot); const gearTalents = (slot: string) => data.talents.filter((talent) => talent.category === slot);
const changeSlot = (slot: string) => { if (editing) editing = { ...editing, slot, talentIds: [] }; }; 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) => { const editAugments = (profile: any) => {
augmentEditing = { augmentEditing = {
...profile, ...profile,
augments: Array.from({ length: profile.requiredSlots }, (_, index) => { augments: Array.from({ length: profile.requiredSlots }, (_, index) => {
const augment = profile.augments.find((item: any) => Number(item.augment_slot) === index + 1); 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; editing = null;
@@ -53,7 +66,7 @@
{#each data.augmentProfiles as profile} {#each data.augmentProfiles as profile}
<button type="button" class:configured={profile.augments.length === profile.requiredSlots} onclick={() => editAugments(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> <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> </button>
{/each} {/each}
</div> </div>
@@ -64,14 +77,18 @@
<PendingForm action="?/saveAugmentProfile"> <PendingForm action="?/saveAugmentProfile">
<input type="hidden" name="slot" value={augmentEditing.slot}/> <input type="hidden" name="slot" value={augmentEditing.slot}/>
<input type="hidden" name="name" value={`${augmentEditing.slot} tactical augments`}/> <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"> <div class="gear-augment-slots">
{#each augmentEditing.augments as augment: any, index} {#each augmentEditing.augments as augment: any, index}
<section> <section>
<header><strong>TACTICAL AUGMENT 0{index + 1}</strong><span>SHARED SLOT</span></header> <header><strong>TACTICAL AUGMENT 0{index + 1}</strong><span>{index === 0 ? 'CUSTOM ATTRIBUTE' : 'FIXED BASE UPGRADE'}</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> {#if index === 0}
<label class="field">VALUE<input name={`value${index + 1}`} type="number" step="any" bind:value={augment.value} required/></label> <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">UNLOCK COST<input name={`unlockCost${index + 1}`} type="number" min="0" step="1" bind:value={augment.unlockCost} 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]} required/></label>{/each}</div></fieldset>
</section> </section>
{/each} {/each}
</div> </div>
@@ -90,7 +107,6 @@
<label class="field">NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label> <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">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">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>
<div class="gear-ranges"> <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> <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} {/if}
<RegistryPagination pagination={data.pagination}/> <RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table"> <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} {#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} {: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> </div>
+6 -2
View File
@@ -5,7 +5,7 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
const database = await getDatabase(); const database = await getDatabase();
const [ const [
attributes, gear, gearBrands, gearTalents, gearAugments, brands, brandEffects, attributes, gear, gearBrands, gearTalents, gearAugments, gearAugmentCosts, brands, brandEffects,
weapons, weaponRanges, weaponTalentOptions, weaponBonuses, weapons, weaponRanges, weaponTalentOptions, weaponBonuses,
talents, talentTiers, talentEffects, talentTemplates, talents, talentTiers, talentEffects, talentTemplates,
chipSets, chipTwoPiece, chipPrimary, chipSecondary, chipTalents, chipTalentEffects, 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_brand_sets')).all(),
(await database.prepare('SELECT * FROM gear_talents')).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_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_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 brand_set_effects ORDER BY pieces_required')).all(),
(await database.prepare('SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE')).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)), 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)) 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) => ({ brands: brands.map((brand) => ({
...brand, ...brand,
iconUrl: iconUrl('brand_set', brand.id), iconUrl: iconUrl('brand_set', brand.id),
+11 -6
View File
@@ -57,6 +57,7 @@
const configureGear = (slot: string, id: string) => { const configureGear = (slot: string, id: string) => {
const selection = draft.gear[slot], item = data.gear.find((entry) => entry.id === id); const selection = draft.gear[slot], item = data.gear.find((entry) => entry.id === id);
selection.itemId = id; selection.itemId = id;
selection.level = 50;
selection.brandId = item?.brandIds[0] ?? ''; selection.brandId = item?.brandIds[0] ?? '';
selection.talentId = item?.talentIds[0] ?? ''; selection.talentId = item?.talentIds[0] ?? '';
selection.health = Number(item?.base_health_min ?? 0); selection.health = Number(item?.base_health_min ?? 0);
@@ -110,11 +111,13 @@
for (const slot of gearSlots) { for (const slot of gearSlots) {
const selection = draft.gear[slot], item = gearItem(slot); const selection = draft.gear[slot], item = gearItem(slot);
if (!item) continue; if (!item) continue;
add(byName('Health'), selection.health, `${slot} base`); const enabledAugments = data.gearAugments.filter((entry) => entry.gear_slot === slot && selection.augmentSlots.includes(Number(entry.augment_slot)));
add(byName('Armor'), selection.armor, `${slot} base`); 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`); 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); 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}`); 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)) }); 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); const parsed = JSON.parse(stored);
if (parsed?.version === 1) { if (parsed?.version === 1) {
draft = parsed; 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 = ''; if (weaponItem(0) && weaponItem(1) && Number(weaponItem(0).is_exotic) && Number(weaponItem(1).is_exotic)) draft.weapons[1].itemId = '';
saved = true; saved = true;
} }
@@ -223,15 +227,16 @@
<section class="loadout-editor"> <section class="loadout-editor">
{#if activeParts[0] === 'gear'} {#if activeParts[0] === 'gear'}
{@const slot = activeParts[1]}{@const selection = draft.gear[slot]}{@const item = gearItem(slot)} {@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"/> <BuilderPicker items={data.gear.filter((entry) => entry.slot === slot)} bind:value={selection.itemId} onchange={(id) => configureGear(slot,id)} label="ITEM DEFINITION"/>
{#if item} {#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-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-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> <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)} {@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} {/if}
{:else if activeParts[0] === 'weapon'} {: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)} {@const index = Number(activeParts[1])}{@const selection = draft.weapons[index]}{@const item = weaponItem(index)}{@const other = weaponItem(index ? 0 : 1)}