feat(database): add public game data registries

This commit is contained in:
JB
2026-07-26 00:39:09 -04:00
parent ae6013d9d6
commit 317e5b39bd
12 changed files with 702 additions and 20 deletions
+62
View File
@@ -1183,6 +1183,68 @@ 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.
Public Gear cards use the same rail for compatible Brand Sets and available
Talents so players can identify actual options instead of reading a count.
Each icon retains its accessible name and hover title, and missing artwork uses
the shared placeholder without suppressing the relationship. Relationship
icons are buttons: activating one opens a modal quick view using the same card
facts as that record's public database category. The overlay closes through
its explicit close action, backdrop, or Escape key. Do not render an empty
Talent rail on item types that cannot equip Talents.
### Public game-database registries
The public Database is a player-facing reference surface, separate from the
ISAC editing workflow. Its index exposes eight durable categories: Gear,
Weapons, Weapon Mods, Gear Mods, OS Protocols, Talents, Brands, and Skill
Chips. Attributes are implementation vocabulary resolved into the facts and
effects shown on those records; do not promote Attributes to a public
top-level category.
Each index category is a fully interactive square registry card with a live
approved-record count, concise scope statement, visible hover/focus state, and
pressed feedback. A category page uses the shared compact category rail,
live name search, pagination, and a responsive card grid. Record cards use the
same hierarchy:
- a fixed icon cell and compact identity header;
- small textual badges for quality, tier, or exceptional state;
- bordered fact cells for primary values;
- relationship lines and tier descriptions only when applicable.
When a record represents an ordered progression rather than independent facts,
use the stacked fact treatment. Brand Set bonuses are shown vertically in
two-, three-, then four-piece order; do not add a redundant “3/3 bonuses”
badge when all three rows are already visible.
Record cards may end with contextual actions that connect related public
registries. Actions use the compact orange tactical-button treatment and
encode their destination filter in the URL. Brand cards provide a “find Gear”
action that opens the Gear registry with that Brand selected; the same action
must remain available in Brand quick views.
Cards display canonical active data only. Attribute references must be
resolved to readable names, values, and units instead of exposing IDs or raw
JSON. Missing uploaded art uses the standard missing-item placeholder.
Desktop layouts use three record columns, intermediate layouts use two, and
narrow layouts use one. The eight-category rail reduces from eight to four to
two columns. Preserve keyboard focus, pressed feedback, reduced-motion
behavior, and readable textual state in every layout.
Category-specific discovery uses the shared tactical filter matrix. Filters
apply immediately, remain encoded in the URL, reset pagination, and compose
with name search. Multiple selections inside one facet are inclusive (`OR`);
active facet groups combine restrictively (`AND`). For example, selecting two
Brands and four Talents returns Gear matching any selected Brand and any
selected Talent. Never implement multi-select facets as an implicit match-all
within the same group.
Use segmented icon choices for bounded visual vocabularies of six or fewer,
such as Gear slots. Use the matching searchable multi-select list for growing
sets such as Brands and Talents. Always expose selected counts, a single clear
action, `aria-pressed` state, keyboard focus, and explicit text explaining the
combination rule. At intermediate width, the bounded facet spans the row above
two searchable facets; on narrow screens all facet groups stack vertically.
### Build persistence and sharing
+9
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
<script lang="ts">
import { databaseCategories } from '$lib/database/categories';
let { active = '' }: { active?: string } = $props();
</script>
<nav class="database-category-nav" aria-label="Game database categories">
{#each databaseCategories as category}
<a class:active={active === category.slug} aria-current={active === category.slug ? 'page' : undefined} href={`/database/${category.slug}`}>
<small>{category.code}</small><span>{category.label}</span><b>›</b>
</a>
{/each}
</nav>
@@ -0,0 +1,56 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import GameIcon from './GameIcon.svelte';
import ItemIcon from './ItemIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
type Option = { value: string; label: string; iconUrl?: string; gameIcon?: GameIconName };
type Group = { key: string; label: string; kind: 'segmented' | 'searchable'; options: Option[]; selected: string[] };
let { groups }: { groups: Group[] } = $props();
let queries = $state<Record<string, string>>({});
const change = (key: string, value: string) => {
const params = new URLSearchParams(page.url.searchParams);
const selected = params.getAll(key);
params.delete(key);
for (const entry of selected.includes(value) ? selected.filter((entry) => entry !== value) : [...selected, value]) params.append(key, entry);
params.delete('page');
void goto(`${page.url.pathname}?${params}`, { keepFocus: true, noScroll: true });
};
const clear = () => {
const params = new URLSearchParams(page.url.searchParams);
for (const group of groups) params.delete(group.key);
params.delete('page');
void goto(`${page.url.pathname}?${params}`, { noScroll: true });
};
const visible = (group: Group) => {
const query = (queries[group.key] ?? '').normalize('NFKC').trim().toLocaleLowerCase();
return group.options.filter((option) => !query || option.label.toLocaleLowerCase().includes(query));
};
const selectedCount = $derived(groups.reduce((total, group) => total + group.selected.length, 0));
</script>
<section class="database-facet-panel" aria-label="Database filters">
<header><div><small>TACTICAL FILTER MATRIX</small><strong>MATCH ANY WITHIN A GROUP · MATCH EVERY ACTIVE GROUP</strong></div>{#if selectedCount}<button type="button" onclick={clear}>CLEAR {selectedCount} FILTER{selectedCount === 1 ? '' : 'S'}</button>{/if}</header>
<div class="database-facet-groups">
{#each groups as group}
<fieldset class:searchable={group.kind === 'searchable'} class="database-facet-group">
<legend>{group.label}<span>{group.selected.length ? `${group.selected.length} ACTIVE` : 'ANY'}</span></legend>
{#if group.kind === 'searchable'}
<label class="database-facet-search"><span class="sr-only">Search {group.label}</span><input type="search" value={queries[group.key] ?? ''} oninput={(event) => queries[group.key] = event.currentTarget.value} placeholder={`FILTER ${group.label}`}/></label>
{/if}
<div>
{#each visible(group) as option}
<button type="button" class:selected={group.selected.includes(option.value)} aria-pressed={group.selected.includes(option.value)} onclick={() => change(group.key, option.value)}>
{#if option.gameIcon}<GameIcon name={option.gameIcon} decorative/>{:else if option.iconUrl !== undefined}<ItemIcon src={option.iconUrl} alt=""/>{:else}<i aria-hidden="true"></i>{/if}
<span>{option.label}</span>
</button>
{:else}
<small>NO MATCHING OPTIONS</small>
{/each}
</div>
</fieldset>
{/each}
</div>
</section>
@@ -0,0 +1,41 @@
<script lang="ts">
import GameIcon from './GameIcon.svelte';
import ItemIcon from './ItemIcon.svelte';
import ItemIconRail from './ItemIconRail.svelte';
import type { GameIconName } from '$lib/game-icons';
import type { DatabaseItem } from '$lib/database/items';
let {
item
}: {
item: DatabaseItem & { gameIcon?: GameIconName }
} = $props();
</script>
<article class="database-item-card">
<header>
<div class="database-item-icon">
{#if item.gameIcon}<GameIcon name={item.gameIcon} decorative/>{:else}<ItemIcon src={item.iconUrl} alt=""/>{/if}
</div>
<div><small>{item.kicker}</small><h2>{item.name}</h2></div>
</header>
{#if item.badges?.length}<div class="database-item-badges">{#each item.badges as badge}<span class={badge.tone ?? ''}>{badge.label}</span>{/each}</div>{/if}
<div class:stacked={item.factLayout === 'stack'} class="database-item-facts">
{#each item.facts as fact}<div><span>{fact.label}</span><b>{fact.value}</b></div>{/each}
</div>
{#if item.lines?.length}
<div class="database-item-lines">{#each item.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
{/if}
{#if item.iconGroups?.length}
<div class="database-item-icon-groups">
{#each item.iconGroups as group}
<div><span>{group.label}</span><ItemIconRail label={group.label} items={group.items} emptyLabel={group.emptyLabel}/></div>
{/each}
</div>
{/if}
{#if item.description}<p>{item.description}</p>{/if}
{#if item.secondaryDescription}<p class="secondary-description">{item.secondaryDescription}</p>{/if}
{#if item.actions?.length}
<footer class="database-item-actions">{#each item.actions as action}<a href={action.href}>{action.label} →</a>{/each}</footer>
{/if}
</article>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import ItemIcon from './ItemIcon.svelte';
import type { DatabaseRelatedItem } from '$lib/database/items';
let {
label,
items,
emptyLabel = 'NONE ASSIGNED'
}: {
label: string;
items: DatabaseRelatedItem[];
emptyLabel?: string;
} = $props();
let selected = $state<DatabaseRelatedItem | null>(null);
</script>
<svelte:window onkeydown={(event) => { if (event.key === 'Escape') selected = null; }}/>
<div class="item-icon-rail" aria-label={`${label}: ${items.length ? items.map((item) => item.name).join(', ') : emptyLabel}`}>
{#if items.length}
{#each items as item}
<button type="button" title={`View ${item.name}`} aria-label={`View ${item.name} details`} onclick={() => selected = item}>
<ItemIcon src={item.iconUrl} alt=""/>
</button>
{/each}
{:else}
<small>{emptyLabel}</small>
{/if}
</div>
{#if selected?.preview}
<div class="database-preview-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) selected = null; }}>
<div class="database-preview-dialog" role="dialog" aria-modal="true" aria-labelledby={`database-preview-${selected.id}`}>
<header><div><small>DATABASE QUICK VIEW</small><strong id={`database-preview-${selected.id}`}>{selected.preview.name}</strong></div><button type="button" aria-label="Close details" onclick={() => selected = null}>×</button></header>
<article class="database-item-card">
<header>
<div class="database-item-icon"><ItemIcon src={selected.preview.iconUrl} alt=""/></div>
<div><small>{selected.preview.kicker}</small><h2>{selected.preview.name}</h2></div>
</header>
{#if selected.preview.badges?.length}<div class="database-item-badges">{#each selected.preview.badges as badge}<span class={badge.tone ?? ''}>{badge.label}</span>{/each}</div>{/if}
<div class:stacked={selected.preview.factLayout === 'stack'} class="database-item-facts">{#each selected.preview.facts as fact}<div><span>{fact.label}</span><b>{fact.value}</b></div>{/each}</div>
{#if selected.preview.lines?.length}<div class="database-item-lines">{#each selected.preview.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>{/if}
{#if selected.preview.description}<p>{selected.preview.description}</p>{/if}
{#if selected.preview.secondaryDescription}<p class="secondary-description">{selected.preview.secondaryDescription}</p>{/if}
{#if selected.preview.actions?.length}<footer class="database-item-actions">{#each selected.preview.actions as action}<a href={action.href}>{action.label} →</a>{/each}</footer>{/if}
</article>
</div>
</div>
{/if}
+14
View File
@@ -0,0 +1,14 @@
export const databaseCategories = [
{ slug: 'gear', label: 'Gear', code: 'GEAR', description: 'Equipment slots, base health and armor, tiers, Brand Sets, and talents.' },
{ slug: 'weapons', label: 'Weapons', code: 'WPN', description: 'Weapon families, damage types, tier ranges, handling, talents, and bonuses.' },
{ slug: 'weapon-mods', label: 'Weapon Mods', code: 'WMOD', description: 'Slot and weapon compatibility, quality rolls, secondary pools, and Mod Effects.' },
{ slug: 'gear-mods', label: 'Gear Mods', code: 'GMOD', description: 'Quality-scaled primary rolls and selectable secondary attribute pools.' },
{ slug: 'os-protocols', label: 'OS Protocols', code: 'OSPR', description: 'Quality, core profile, primary effect, and unique upgradeable talent.' },
{ slug: 'talents', label: 'Talents', code: 'TLNT', description: 'Equipment and weapon talents with tiered descriptions and combat mechanics.' },
{ slug: 'brands', label: 'Brands', code: 'BRND', description: 'Brand Set identities and their two-, three-, and four-piece bonuses.' },
{ slug: 'skill-chips', label: 'Skill Chips', code: 'CHIP', description: 'Set bonuses, primary and secondary pools, and quality-scaled unique talents.' }
] as const;
export type DatabaseCategorySlug = typeof databaseCategories[number]['slug'];
export const databaseCategory = (slug: string) => databaseCategories.find((category) => category.slug === slug);
+31
View File
@@ -0,0 +1,31 @@
export type DatabaseBadge = { label: string; tone?: string };
export type DatabaseFact = { label: string; value: string };
export type DatabaseItem = {
id: string;
name: string;
kicker: string;
iconUrl?: string;
gameIcon?: string;
badges?: DatabaseBadge[];
facts: DatabaseFact[];
factLayout?: 'grid' | 'stack';
actions?: Array<{ label: string; href: string }>;
lines?: DatabaseFact[];
iconGroups?: DatabaseIconGroup[];
description?: string;
secondaryDescription?: string;
};
export type DatabaseRelatedItem = {
id: string;
name: string;
iconUrl?: string;
preview?: DatabaseItem;
};
export type DatabaseIconGroup = {
label: string;
items: DatabaseRelatedItem[];
emptyLabel?: string;
};
+14 -14
View File
@@ -1,23 +1,23 @@
import { getDatabase } from '$lib/server/database';
import { databaseCategories } from '$lib/database/categories';
import type { PageServerLoad } from './$types';
const registries = [
{ key: 'attributes', label: 'Attributes', table: 'stat_attributes', detail: 'Stats, units, source ranges, caps, and calculation types.' },
{ key: 'talents', label: 'Talents', table: 'talents', detail: 'Tiered effects, triggers, durations, cooldowns, and stacks.' },
{ key: 'brands', label: 'Brand Sets', table: 'brand_sets', detail: 'Two-, three-, and four-piece equipment bonuses.' },
{ key: 'gear', label: 'Gear', table: 'gear', detail: 'Equipment slots, base ranges, brands, talents, and tiers.' },
{ key: 'weapons', label: 'Weapons', table: 'weapons', detail: 'Weapon families, damage ranges, talents, upgrades, and bonuses.' },
{ key: 'weaponmods', label: 'Weapon Mods', table: 'weapon_mods', detail: 'Compatibility, quality rolls, secondary pools, and effects.' },
{ key: 'gearmods', label: 'Gear Mods', table: 'gear_mods', detail: 'Primary scaling and secondary attribute pools by quality.' },
{ key: 'protocols', label: 'OS Protocols', table: 'os_protocols', detail: 'Primary effects, shared profiles, and unique talents.' },
{ key: 'skillchips', label: 'Skill Chips', table: 'skill_chip_sets', detail: 'Quality-scaled set bonuses, rolls, and chip talents.' }
] as const;
const tables = {
gear: 'gear',
weapons: 'weapons',
'weapon-mods': 'weapon_mods',
'gear-mods': 'gear_mods',
'os-protocols': 'os_protocols',
talents: 'talents',
brands: 'brand_sets',
'skill-chips': 'skill_chip_sets'
} as const;
export const load: PageServerLoad = async () => {
const database = await getDatabase();
const counts = await Promise.all(registries.map(async (registry) => {
const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM ${registry.table} WHERE active=1`)).get();
return { ...registry, count: Number(row?.count ?? 0) };
const counts = await Promise.all(databaseCategories.map(async (category) => {
const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM ${tables[category.slug]} WHERE active=1`)).get();
return { ...category, count: Number(row?.count ?? 0) };
}));
return {
registries: counts,
+6 -6
View File
@@ -13,17 +13,17 @@
<div class="catalogue-count"><strong>{data.total}</strong><span>ACTIVE RECORDS</span><a class="primary" href="/isac">CONTRIBUTE DATA →</a></div>
</header>
<section class="community-database-intro">
<div><span>DATABASE STATUS</span><strong>PUBLIC INDEX ONLINE</strong><small>Detailed public record pages will arrive incrementally. Current counts reflect active, approved data.</small></div>
<div><span>DATABASE STATUS</span><strong>PUBLIC INDEX ONLINE</strong><small>Select a registry to inspect active, approved records and their resolved gameplay attributes.</small></div>
<div><span>DATA STEWARDSHIP</span><strong>COMMUNITY + OVERSIGHT</strong><small>Registered agents can inspect source data and propose verified corrections through ISAC.</small></div>
</section>
<section class="database-registry-grid">
{#each data.registries as registry, index}
<article>
<header><small>{String(index + 1).padStart(2, '0')} // {registry.key.toUpperCase()}</small><b>{registry.count}</b></header>
<a href={`/database/${registry.slug}`}>
<header><small>{String(index + 1).padStart(2, '0')} // {registry.code}</small><b>{registry.count}</b></header>
<h2>{registry.label}</h2>
<p>{registry.detail}</p>
<span>{registry.count ? 'ACTIVE DATA AVAILABLE' : 'AWAITING CONTRIBUTIONS'}</span>
</article>
<p>{registry.description}</p>
<span>{registry.count ? 'OPEN ACTIVE DATA →' : 'AWAITING CONTRIBUTIONS →'}</span>
</a>
{/each}
</section>
</main>
@@ -0,0 +1,376 @@
import { databaseCategory } from '$lib/database/categories';
import type { GameIconName } from '$lib/game-icons';
import { gameDataIconUrl, type GameDataIconType } from '$lib/server/game-data-icons';
import { getDatabase } from '$lib/server/database';
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
const number = (value: unknown) => {
const numeric = Number(value);
return Number.isFinite(numeric) ? (Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2).replace(/\.?0+$/, '')) : '—';
};
const valueWithUnit = (value: unknown, unit: unknown) => `${number(value)}${unit ? (unit === '%' ? '%' : ` ${unit}`) : ''}`;
const range = (min: unknown, max: unknown, unit: unknown = '') => `${valueWithUnit(min, unit)}–${valueWithUnit(max, unit)}`;
const list = (value: unknown, fallback = 'NONE') => typeof value === 'string' && value ? value : fallback;
const gearIcon = (slot: unknown) => String(slot).toLowerCase().replaceAll(' ', '_') as GameIconName;
const weaponIcon = (category: unknown) => String(category).toLowerCase();
const iconUrl = (type: GameDataIconType, id: unknown, updatedAt: unknown) =>
updatedAt ? gameDataIconUrl(type, String(id), updatedAt) : '';
export const load: PageServerLoad = async ({ params, url }) => {
const category = databaseCategory(params.category);
if (!category) error(404, 'Database category not found');
const database = await getDatabase();
const { search, pattern } = getRegistrySearch(url);
const searchArgs = search ? [pattern] : [];
const searchClause = search ? " AND item.name LIKE ? ESCAPE '\\' COLLATE NOCASE" : '';
let queryArgs: unknown[] = searchArgs;
let filters: Array<{
key: string;
label: string;
kind: 'segmented' | 'searchable';
selected: string[];
options: Array<{ value: string; label: string; iconUrl?: string; gameIcon?: GameIconName }>;
}> = [];
let countSql = '';
let rowsSql = '';
switch (category.slug) {
case 'gear': {
const [brandRows, talentRows] = await Promise.all([
(await database.prepare(`SELECT brands.id,brands.name,icons.updated_at FROM brand_sets brands
LEFT JOIN game_data_icons icons ON icons.entity_type='brand_set' AND icons.entity_id=brands.id
WHERE brands.active=1 ORDER BY brands.name COLLATE NOCASE`)).all(),
(await database.prepare(`SELECT talents.id,talents.name,icons.updated_at FROM talents
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
WHERE talents.active=1 AND talents.category IN ('Backpack','Body Armor','Vest')
ORDER BY talents.name COLLATE NOCASE`)).all()
]);
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
const validBrands = new Set(brandRows.map((row) => String(row.id)));
const validTalents = new Set(talentRows.map((row) => String(row.id)));
const selectedSlots = url.searchParams.getAll('slot').filter((value) => slots.includes(value));
const selectedBrands = url.searchParams.getAll('brand').filter((value) => validBrands.has(value));
const selectedTalents = url.searchParams.getAll('talent').filter((value) => validTalents.has(value));
const conditions = ['item.active=1'];
queryArgs = [];
if (search) {
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
queryArgs.push(pattern);
}
if (selectedSlots.length) {
conditions.push(`item.slot IN (${selectedSlots.map(() => '?').join(',')})`);
queryArgs.push(...selectedSlots);
}
if (selectedBrands.length) {
conditions.push(`EXISTS (SELECT 1 FROM gear_brand_sets filter_brands
WHERE filter_brands.gear_id=item.id AND filter_brands.brand_set_id IN (${selectedBrands.map(() => '?').join(',')}))`);
queryArgs.push(...selectedBrands);
}
if (selectedTalents.length) {
conditions.push(`EXISTS (SELECT 1 FROM gear_talents filter_talents
WHERE filter_talents.gear_id=item.id AND filter_talents.talent_id IN (${selectedTalents.map(() => '?').join(',')}))`);
queryArgs.push(...selectedTalents);
}
const where = conditions.join(' AND ');
countSql = `SELECT COUNT(*) AS total FROM gear item WHERE ${where}`;
rowsSql = `SELECT item.* FROM gear item WHERE ${where}
ORDER BY item.slot,item.tier,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
filters = [
{
key: 'slot', label: 'GEAR SLOT', kind: 'segmented', selected: selectedSlots,
options: slots.map((slot) => ({ value: slot, label: slot, gameIcon: gearIcon(slot) }))
},
{
key: 'brand', label: 'BRAND SETS', kind: 'searchable', selected: selectedBrands,
options: brandRows.map((row) => ({
value: String(row.id), label: String(row.name),
iconUrl: iconUrl('brand_set', row.id, row.updated_at)
}))
},
{
key: 'talent', label: 'TALENTS', kind: 'searchable', selected: selectedTalents,
options: talentRows.map((row) => ({
value: String(row.id), label: String(row.name),
iconUrl: iconUrl('talent', row.id, row.updated_at)
}))
}
];
break;
}
case 'weapons':
countSql = `SELECT COUNT(*) AS total FROM weapons item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,
native.damage_min,native.damage_max,native.reference_level,
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=1) AS talent_1_count,
(SELECT COUNT(*) FROM weapon_talent_options options WHERE options.weapon_id=item.id AND options.talent_slot=2) AS talent_2_count,
(SELECT COUNT(*) FROM weapon_base_attribute_bonuses bonuses WHERE bonuses.weapon_id=item.id) AS bonus_count
FROM weapons item
LEFT JOIN weapon_tier_ranges native ON native.weapon_id=item.id AND native.tier=item.native_tier
WHERE item.active=1${searchClause}
ORDER BY item.is_exotic DESC,item.category,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
break;
case 'weapon-mods':
countSql = `SELECT COUNT(*) AS total FROM weapon_mods item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,weapons.name AS exotic_weapon,
GROUP_CONCAT(DISTINCT categories.weapon_category) AS categories,
(SELECT COUNT(*) FROM weapon_mod_secondary_options secondary WHERE secondary.weapon_mod_id=item.id) AS secondary_count,
(SELECT COUNT(*) FROM weapon_mod_effects effects WHERE effects.weapon_mod_id=item.id) AS effect_count
FROM weapon_mods item
LEFT JOIN weapons ON weapons.id=item.exotic_weapon_id
LEFT JOIN weapon_mod_compatible_categories categories ON categories.weapon_mod_id=item.id
WHERE item.active=1${searchClause}
GROUP BY item.id ORDER BY item.slot_type,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
break;
case 'gear-mods':
countSql = `SELECT COUNT(*) AS total FROM gear_mods item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,
(SELECT COUNT(*) FROM gear_mod_secondary_options secondary WHERE secondary.gear_mod_id=item.id) AS secondary_count,
(SELECT MIN(primary_level_1_min) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_1_floor,
(SELECT MAX(primary_level_20_max) FROM gear_mod_quality_values quality WHERE quality.gear_mod_id=item.id) AS level_20_ceiling
FROM gear_mods item WHERE item.active=1${searchClause}
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
break;
case 'os-protocols':
countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,attributes.name AS primary_stat,attributes.unit AS primary_unit,
primary_effect.base_value,talents.name AS talent_name,icons.updated_at AS icon_updated_at
FROM os_protocols item
LEFT JOIN os_protocol_primary_effects primary_effect ON primary_effect.protocol_id=item.id
LEFT JOIN stat_attributes attributes ON attributes.id=primary_effect.stat_attribute_id
LEFT JOIN os_protocol_talents talents ON talents.protocol_id=item.id
LEFT JOIN game_data_icons icons ON icons.entity_type='os_protocol' AND icons.entity_id=item.id
WHERE item.active=1${searchClause}
ORDER BY item.quality,item.primary_attribute_type,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
break;
case 'talents':
countSql = `SELECT COUNT(*) AS total FROM talents item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=item.id AND tiers.tier='I') AS tier_i,
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=item.id AND tiers.tier='II') AS tier_ii,
(SELECT slot FROM weapon_talent_slots slots WHERE slots.talent_id=item.id) AS weapon_slot,
(SELECT scope FROM talent_weapon_scopes scopes WHERE scopes.talent_id=item.id) AS weapon_scope,
icons.updated_at AS icon_updated_at
FROM talents item
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=item.id
WHERE item.active=1${searchClause}
ORDER BY item.category,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
break;
case 'brands':
countSql = `SELECT COUNT(*) AS total FROM brand_sets item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,icons.updated_at AS icon_updated_at
FROM brand_sets item
LEFT JOIN game_data_icons icons ON icons.entity_type='brand_set' AND icons.entity_id=item.id
WHERE item.active=1${searchClause}
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
break;
case 'skill-chips':
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
talents.name AS talent_name,
(SELECT COUNT(*) FROM skill_chip_primary_options options WHERE options.skill_chip_set_id=item.id) AS primary_count,
(SELECT COUNT(*) FROM skill_chip_secondary_options options WHERE options.skill_chip_set_id=item.id) AS secondary_count,
icons.updated_at AS icon_updated_at
FROM skill_chip_sets item
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
LEFT JOIN stat_attributes attributes ON attributes.id=bonus.stat_attribute_id
LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id
LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id
WHERE item.active=1${searchClause}
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
break;
}
const total = Number((await (await database.prepare(countSql)).get(...queryArgs))?.total ?? 0);
const pagination = getPagination(url, total);
const rows = await (await database.prepare(rowsSql)).all(...queryArgs, pagination.perPage, pagination.offset);
const gearRelations = category.slug === 'gear' && rows.length
? await (await database.prepare(`SELECT relation.*,icons.updated_at
FROM (
SELECT links.gear_id,'brand' AS kind,brands.id,brands.name,
NULL AS category,NULL AS cooldown_seconds,NULL AS duration_seconds,NULL AS max_stacks,
NULL AS tier_i,NULL AS tier_ii
FROM gear_brand_sets links JOIN brand_sets brands ON brands.id=links.brand_set_id
WHERE brands.active=1
UNION ALL
SELECT links.gear_id,'talent' AS kind,talents.id,talents.name,
talents.category,talents.cooldown_seconds,talents.duration_seconds,talents.max_stacks,
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i,
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='II') AS tier_ii
FROM gear_talents links JOIN talents ON talents.id=links.talent_id
WHERE talents.active=1
) relation
LEFT JOIN game_data_icons icons ON icons.entity_id=relation.id
AND icons.entity_type=CASE relation.kind WHEN 'brand' THEN 'brand_set' ELSE 'talent' END
WHERE relation.gear_id IN (${rows.map(() => '?').join(',')})
ORDER BY relation.kind,relation.name COLLATE NOCASE`)).all(...rows.map((row) => row.id))
: [];
const gearBrandEffects = category.slug === 'gear' && gearRelations.some((relation) => relation.kind === 'brand')
? await (await database.prepare(`SELECT effects.brand_set_id,effects.pieces_required,effects.value,
attributes.name,attributes.unit FROM brand_set_effects effects
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
WHERE effects.brand_set_id IN (${gearRelations.filter((relation) => relation.kind === 'brand').map(() => '?').join(',')})
ORDER BY effects.pieces_required`)).all(...gearRelations.filter((relation) => relation.kind === 'brand').map((relation) => relation.id))
: [];
const brandEffects = category.slug === 'brands' && rows.length
? await (await database.prepare(`SELECT effects.brand_set_id,effects.pieces_required,effects.value,
attributes.name,attributes.unit FROM brand_set_effects effects
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
WHERE effects.brand_set_id IN (${rows.map(() => '?').join(',')}) ORDER BY effects.pieces_required`)).all(...rows.map((row) => row.id))
: [];
const items = rows.map((row) => {
switch (category.slug) {
case 'gear':
{
const relations = gearRelations.filter((relation) => relation.gear_id === row.id);
const relatedItems = (kind: 'brand' | 'talent', type: GameDataIconType) => relations
.filter((relation) => relation.kind === kind)
.map((relation) => {
const relationIcon = iconUrl(type, relation.id, relation.updated_at);
const effects = kind === 'brand' ? gearBrandEffects.filter((effect) => effect.brand_set_id === relation.id) : [];
return {
id: String(relation.id),
name: String(relation.name),
iconUrl: relationIcon,
preview: kind === 'brand' ? {
id: String(relation.id), name: String(relation.name), kicker: 'BRAND SET // EQUIPMENT',
iconUrl: relationIcon,
factLayout: 'stack' as const,
actions: [{ label: 'FIND GEAR WITH THIS BRAND', href: `/database/gear?brand=${encodeURIComponent(String(relation.id))}` }],
facts: effects.map((effect) => ({
label: `${effect.pieces_required} PIECES`,
value: `${effect.name} · +${valueWithUnit(effect.value,effect.unit)}`
}))
} : {
id: String(relation.id), name: String(relation.name), kicker: `${relation.category} // GEAR TALENT`,
iconUrl: relationIcon,
badges: [
...(relation.cooldown_seconds ? [{ label: `${number(relation.cooldown_seconds)}s COOLDOWN` }] : []),
...(relation.duration_seconds ? [{ label: `${number(relation.duration_seconds)}s DURATION` }] : []),
...(relation.max_stacks ? [{ label: `${relation.max_stacks} STACKS` }] : [])
],
facts: [
{ label: 'TIER I', value: relation.tier_i ? 'CONFIGURED' : 'MISSING' },
{ label: 'TIER II', value: relation.tier_ii ? 'CONFIGURED' : 'MISSING' }
],
description: relation.tier_i ? `TIER I · ${relation.tier_i}` : 'Tier I description is not configured.',
secondaryDescription: relation.tier_ii ? `TIER II · ${relation.tier_ii}` : 'Tier II description is not configured.'
}
};
});
const iconGroups = [
{ label: 'BRAND OPTIONS', items: relatedItems('brand', 'brand_set'), emptyLabel: 'NO BRANDS ASSIGNED' },
...(['Backpack', 'Body Armor'].includes(String(row.slot))
? [{ label: 'AVAILABLE TALENTS', items: relatedItems('talent', 'talent'), emptyLabel: 'NO TALENTS ASSIGNED' }]
: [])
];
return {
id: String(row.id), name: String(row.name), kicker: `${row.slot} // T${row.tier}`,
gameIcon: gearIcon(row.slot),
badges: [{ label: `T${row.tier}` }, { label: `${Number(row.tier) === 0 ? 1 : 2} MOD SLOT${Number(row.tier) === 0 ? '' : 'S'}` }],
facts: [
{ label: 'BASE HEALTH', value: range(row.base_health_min,row.base_health_max) },
...(row.base_armor_min === null ? [] : [{ label: 'BASE ARMOR', value: range(row.base_armor_min,row.base_armor_max) }])
],
iconGroups
};
}
case 'weapons':
return {
id: String(row.id), name: String(row.name), kicker: `${row.category} // ${row.damage_type}`,
gameIcon: weaponIcon(row.category),
badges: [{ label: `T${row.native_tier}` }, ...(Number(row.is_exotic) ? [{ label: 'EXOTIC', tone: 'exotic' }] : [])],
facts: [
{ label: 'DAMAGE RANGE', value: row.damage_min === null ? 'NOT CONFIGURED' : `${range(row.damage_min,row.damage_max)} · L${row.reference_level}` },
{ label: 'RATE / MAG', value: `${number(row.rpm)} RPM · ${number(row.magazine)} MAG` },
{ label: 'RELOAD', value: valueWithUnit(row.reload_speed,'s') },
{ label: 'ACCURACY / STABILITY', value: `${number(row.accuracy)} / ${number(row.stability)}` }
],
lines: [
{ label: 'TALENT POOLS', value: `${row.talent_1_count} TOP · ${row.talent_2_count} BOTTOM` },
{ label: 'BASE BONUSES', value: String(row.bonus_count) }
]
};
case 'weapon-mods':
return {
id: String(row.id), name: String(row.name), kicker: `${row.slot_type} // WEAPON MOD`,
badges: row.exotic_weapon_id ? [{ label: 'EXOTIC', tone: 'exotic' }] : [{ label: 'STANDARD → HIGH-END' }],
facts: [
{ label: 'LEVELS', value: '01–20' },
{ label: 'SECONDARY POOL', value: row.exotic_weapon_id ? 'FIXED ATTRIBUTES' : `${row.secondary_count} OPTIONS` },
{ label: 'MOD EFFECTS', value: row.exotic_weapon_id ? 'FIXED' : String(row.effect_count) }
],
lines: [{ label: row.exotic_weapon_id ? 'ASSIGNED WEAPON' : 'WEAPON CATEGORIES', value: row.exotic_weapon_id ? list(row.exotic_weapon) : list(row.categories).toUpperCase() }]
};
case 'gear-mods':
return {
id: String(row.id), name: String(row.name), kicker: 'GEAR MOD // STANDARD QUALITIES',
badges: [{ label: 'STANDARD → HIGH-END' }],
facts: [
{ label: 'LEVELS', value: '01–20' },
{ label: 'PRIMARY OPTIONS', value: '5' },
{ label: 'SECONDARY POOL', value: `${row.secondary_count} OPTIONS` }
],
lines: [{ label: 'OBSERVED PRIMARY ENVELOPE', value: `${number(row.level_1_floor)} → ${number(row.level_20_ceiling)}` }]
};
case 'os-protocols':
return {
id: String(row.id), name: String(row.name), kicker: `${row.primary_attribute_type} // OS PROTOCOL`,
iconUrl: iconUrl('os_protocol',row.id,row.icon_updated_at),
badges: [{ label: String(row.quality), tone: String(row.quality).toLowerCase().replace('-','') }],
facts: [
{ label: 'LEVELS', value: '01–50' },
{ label: 'PRIMARY EFFECT', value: row.primary_stat ? `${row.primary_stat} · ${valueWithUnit(row.base_value,row.primary_unit)}` : 'NOT CONFIGURED' },
{ label: 'TALENT UPGRADES', value: '0–5' }
],
lines: [{ label: 'UNIQUE TALENT', value: list(row.talent_name,'NOT CONFIGURED') }]
};
case 'talents':
return {
id: String(row.id), name: String(row.name), kicker: `${row.category}${row.weapon_slot ? ` // SLOT ${row.weapon_slot}` : ''}`,
iconUrl: iconUrl('talent',row.id,row.icon_updated_at),
badges: [
...(row.weapon_scope === 'Exotic' ? [{ label: 'EXOTIC', tone: 'exotic' }] : []),
...(row.cooldown_seconds ? [{ label: `${number(row.cooldown_seconds)}s COOLDOWN` }] : []),
...(row.duration_seconds ? [{ label: `${number(row.duration_seconds)}s DURATION` }] : []),
...(row.max_stacks ? [{ label: `${row.max_stacks} STACKS` }] : [])
],
facts: [
{ label: 'TIER I', value: row.tier_i ? 'CONFIGURED' : 'MISSING' },
{ label: 'TIER II', value: row.tier_ii ? 'CONFIGURED' : 'MISSING' }
],
description: row.tier_i ? `TIER I · ${row.tier_i}` : 'Tier I description is not configured.',
secondaryDescription: row.tier_ii ? `TIER II · ${row.tier_ii}` : 'Tier II description is not configured.'
};
case 'brands': {
const effects = brandEffects.filter((effect) => effect.brand_set_id === row.id);
return {
id: String(row.id), name: String(row.name), kicker: 'BRAND SET // EQUIPMENT',
iconUrl: iconUrl('brand_set',row.id,row.icon_updated_at),
factLayout: 'stack' as const,
actions: [{ label: 'FIND GEAR WITH THIS BRAND', href: `/database/gear?brand=${encodeURIComponent(String(row.id))}` }],
facts: effects.map((effect) => ({
label: `${effect.pieces_required} PIECES`,
value: `${effect.name} · +${valueWithUnit(effect.value,effect.unit)}`
}))
};
}
case 'skill-chips':
return {
id: String(row.id), name: String(row.name), kicker: 'SKILL CHIP SET // 2–3 PIECES',
iconUrl: iconUrl('skill_chip',row.id,row.icon_updated_at),
badges: [{ label: 'SUPERIOR' }, { label: 'HIGH-END' }],
facts: [
{ label: '2-PIECE BONUS', value: row.bonus_stat ? `${row.bonus_stat} · ${range(row.min_value,row.max_value,row.bonus_unit)}` : 'NOT CONFIGURED' },
{ label: 'PRIMARY POOL', value: `${row.primary_count} OPTIONS` },
{ label: 'SECONDARY POOL', value: `${row.secondary_count} OPTIONS` }
],
lines: [{ label: '3-PIECE TALENT', value: list(row.talent_name,'NOT CONFIGURED') }]
};
}
});
return { category, items, pagination, search, filters };
};
@@ -0,0 +1,32 @@
<script lang="ts">
import DatabaseCategoryNav from '$lib/components/DatabaseCategoryNav.svelte';
import DatabaseItemCard from '$lib/components/DatabaseItemCard.svelte';
import DatabaseFacetFilters from '$lib/components/DatabaseFacetFilters.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
let { data } = $props();
</script>
<svelte:head>
<title>{data.category.label} database — SHD.build</title>
<meta name="description" content={`${data.category.description} Community-maintained data for The Division Resurgence.`}/>
</svelte:head>
<main class="page database-category-page">
<header class="database-category-command">
<div><p class="eyebrow"><span></span> ISAC PUBLIC ARCHIVE / {data.category.code}</p><h1>{data.category.label}</h1><p>{data.category.description}</p></div>
<div><strong>{data.pagination.total}</strong><span>ACTIVE RECORDS</span><a href="/database">DATABASE INDEX →</a></div>
</header>
<DatabaseCategoryNav active={data.category.slug}/>
{#if data.filters?.length}<DatabaseFacetFilters groups={data.filters}/>{/if}
<RegistryPagination pagination={data.pagination} search={data.search} searchPlaceholder={`SEARCH ${data.category.label.toUpperCase()} BY NAME`}/>
<section class="database-item-grid">
{#each data.items as item}
<DatabaseItemCard item={item as any}/>
{:else}
<div class="database-empty">
<span>◇</span><strong>{data.search ? 'NO MATCHING RECORDS' : 'NO ACTIVE RECORDS'}</strong>
<p>{data.search ? `No ${data.category.label.toLocaleLowerCase()} match “${data.search}”.` : `This registry is awaiting approved ${data.category.label.toLocaleLowerCase()} data.`}</p>
</div>
{/each}
</section>
</main>