feat(database): refine weapon registry cards

This commit is contained in:
JB
2026-07-26 01:09:22 -04:00
parent 81784f6f02
commit 143a30a23a
7 changed files with 168 additions and 10 deletions
+36
View File
@@ -1217,6 +1217,13 @@ 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” two-, three-, then four-piece order; do not add a redundant “3/3 bonuses”
badge when all three rows are already visible. badge when all three rows are already visible.
Weapon cards also use stacked facts. Their level-50 Damage range is the primary
fact: a taller orange-railed row with enlarged values and the Damage Type in
the label. Public damage is calculated from the stored level-1 endpoints using
the same `weaponBaseRange` curve as the builder; never label the configured
base endpoints as level-50 values. Weapon Talent 1 and Talent 2 pools use
separate clickable icon rails and the standard Talent quick view.
Record cards may end with contextual actions that connect related public Record cards may end with contextual actions that connect related public
registries. Actions use the compact orange tactical-button treatment and registries. Actions use the compact orange tactical-button treatment and
encode their destination filter in the URL. Brand cards provide a “find Gear” encode their destination filter in the URL. Brand cards provide a “find Gear”
@@ -1253,6 +1260,13 @@ the talent name is unknown. Brand facets similarly index the names of all
set-bonus Attributes, allowing players to search by the desired mechanic set-bonus Attributes, allowing players to search by the desired mechanic
without exposing that metadata as extra visual clutter. without exposing that metadata as extra visual clutter.
Public database command headers are content-sized and must explicitly override
the global 76-pixel site-header rule. Their eyebrow, title, description, count,
and index action remain inside the bordered command surface at every scale.
Children use `min-width: 0`, long titles may wrap, and category navigation
tracks use zero-minimum grid columns so translated or enlarged text cannot
force horizontal page overflow.
### Build persistence and sharing ### Build persistence and sharing
The builder distinguishes device persistence from account persistence. `SAVE The builder distinguishes device persistence from account persistence. `SAVE
@@ -1335,3 +1349,25 @@ into per-item storage. A later contributor replaces it through the normal icon
upload proposal, preserving the canonical record and audit history. The upload upload proposal, preserving the canonical record and audit history. The upload
itself retains all transparent-format, size, SVG-safety, focus, drag/drop, and itself retains all transparent-format, size, SVG-safety, focus, drag/drop, and
paste validation requirements. paste validation requirements.
### Public database performance facts
Use the shared `DatabaseFacts` renderer for registry-card performance data and
quick views. Weapon identity headers keep the native tier beside the category
identity as a compact textual `T0`, `T1`, or `T2` marker. Place tier and the
smaller damage-type icon in a dedicated top-right status cluster; do not
interrupt the readable `category // damage type` metadata with icons. Reserve
the emphasized first fact for the weapon's damage range, pair it with the
canonical damage-type icon, and render the calculated level as a subdued
annotation rather than part of the primary label. Public level-50 damage must
be calculated from the stored level-1 endpoints with `weaponBaseRange`; never
relabel raw source values.
Represent bounded handling values such as Accuracy and Stability with compact
horizontal meters. Always retain the numeric value and an accessible
`value out of 100` label so the graphic does not carry meaning alone. Use the
stable sequence `label → meter → exact value`, with the exact number aligned in
a dedicated column to the right of the graphic. Clamp display values to
`0–100`, use orange only for the filled measure, and keep the unfilled track
neutral. Stack meter labels and tracks on narrow cards rather than shrinking
them below a reliably readable width.
+3
View File
File diff suppressed because one or more lines are too long
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import GameIcon from './GameIcon.svelte';
import type { DatabaseFact } from '$lib/database/items';
let {
facts,
layout = 'grid'
}: {
facts: DatabaseFact[];
layout?: 'grid' | 'stack';
} = $props();
const meterValue = (value: number) => Math.max(0, Math.min(100, value));
</script>
<div class:stacked={layout === 'stack'} class="database-item-facts">
{#each facts as fact}
<div class:primary={fact.emphasis === 'primary'}>
<span>{fact.label}</span>
<div class="database-fact-value">
{#if fact.gameIcon}<GameIcon name={fact.gameIcon}/>{/if}
<b>{fact.value}</b>
{#if fact.note}<small>{fact.note}</small>{/if}
</div>
{#if fact.meters?.length}
<div class="database-stat-meters">
{#each fact.meters as meter}
<div>
<i>{meter.label}</i>
<progress max="100" value={meterValue(meter.value)} aria-label={`${meter.label}: ${meterValue(meter.value)} out of 100`}></progress>
<output aria-label={`${meter.label} exact value`}>{meterValue(meter.value)}</output>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
+12 -4
View File
@@ -2,6 +2,7 @@
import GameIcon from './GameIcon.svelte'; import GameIcon from './GameIcon.svelte';
import ItemIcon from './ItemIcon.svelte'; import ItemIcon from './ItemIcon.svelte';
import ItemIconRail from './ItemIconRail.svelte'; import ItemIconRail from './ItemIconRail.svelte';
import DatabaseFacts from './DatabaseFacts.svelte';
import type { GameIconName } from '$lib/game-icons'; import type { GameIconName } from '$lib/game-icons';
import type { DatabaseItem } from '$lib/database/items'; import type { DatabaseItem } from '$lib/database/items';
@@ -17,12 +18,19 @@
<div class="database-item-icon"> <div class="database-item-icon">
{#if item.gameIcon}<GameIcon name={item.gameIcon} decorative/>{:else}<ItemIcon src={item.iconUrl} alt=""/>{/if} {#if item.gameIcon}<GameIcon name={item.gameIcon} decorative/>{:else}<ItemIcon src={item.iconUrl} alt=""/>{/if}
</div> </div>
<div><small>{item.kicker}</small><h2>{item.name}</h2></div> <div class="database-item-identity">
<small>{item.kicker}</small>
<h2>{item.name}</h2>
</div>
{#if item.headerBadge || item.headerMetaIcon}
<div class="database-header-status">
{#if item.headerMetaIcon}<GameIcon name={item.headerMetaIcon}/>{/if}
{#if item.headerBadge}<span class="database-header-badge">{item.headerBadge}</span>{/if}
</div>
{/if}
</header> </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} {#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"> <DatabaseFacts facts={item.facts} layout={item.factLayout}/>
{#each item.facts as fact}<div><span>{fact.label}</span><b>{fact.value}</b></div>{/each}
</div>
{#if item.lines?.length} {#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> <div class="database-item-lines">{#each item.lines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
{/if} {/if}
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import ItemIcon from './ItemIcon.svelte'; import ItemIcon from './ItemIcon.svelte';
import DatabaseFacts from './DatabaseFacts.svelte';
import type { DatabaseRelatedItem } from '$lib/database/items'; import type { DatabaseRelatedItem } from '$lib/database/items';
let { let {
@@ -38,7 +39,7 @@
<div><small>{selected.preview.kicker}</small><h2>{selected.preview.name}</h2></div> <div><small>{selected.preview.kicker}</small><h2>{selected.preview.name}</h2></div>
</header> </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} {#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> <DatabaseFacts facts={selected.preview.facts} layout={selected.preview.factLayout}/>
{#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.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.description}<p>{selected.preview.description}</p>{/if}
{#if selected.preview.secondaryDescription}<p class="secondary-description">{selected.preview.secondaryDescription}</p>{/if} {#if selected.preview.secondaryDescription}<p class="secondary-description">{selected.preview.secondaryDescription}</p>{/if}
+13 -1
View File
@@ -1,5 +1,15 @@
import type { GameIconName } from '$lib/game-icons';
export type DatabaseBadge = { label: string; tone?: string }; export type DatabaseBadge = { label: string; tone?: string };
export type DatabaseFact = { label: string; value: string }; export type DatabaseMeter = { label: string; value: number };
export type DatabaseFact = {
label: string;
value: string;
emphasis?: 'primary';
gameIcon?: GameIconName;
note?: string;
meters?: DatabaseMeter[];
};
export type DatabaseItem = { export type DatabaseItem = {
id: string; id: string;
@@ -7,6 +17,8 @@ export type DatabaseItem = {
kicker: string; kicker: string;
iconUrl?: string; iconUrl?: string;
gameIcon?: string; gameIcon?: string;
headerBadge?: string;
headerMetaIcon?: GameIconName;
badges?: DatabaseBadge[]; badges?: DatabaseBadge[];
facts: DatabaseFact[]; facts: DatabaseFact[];
factLayout?: 'grid' | 'stack'; factLayout?: 'grid' | 'stack';
+64 -4
View File
@@ -1,5 +1,6 @@
import { databaseCategory } from '$lib/database/categories'; import { databaseCategory } from '$lib/database/categories';
import type { GameIconName } from '$lib/game-icons'; import type { GameIconName } from '$lib/game-icons';
import { weaponBaseRange } from '$lib/builder/loadout';
import { gameDataIconUrl, type GameDataIconType } from '$lib/server/game-data-icons'; import { gameDataIconUrl, type GameDataIconType } from '$lib/server/game-data-icons';
import { getDatabase } from '$lib/server/database'; import { getDatabase } from '$lib/server/database';
import { getPagination, getRegistrySearch } from '$lib/server/pagination'; import { getPagination, getRegistrySearch } from '$lib/server/pagination';
@@ -247,6 +248,17 @@ export const load: PageServerLoad = async ({ params, url }) => {
const total = Number((await (await database.prepare(countSql)).get(...queryArgs))?.total ?? 0); const total = Number((await (await database.prepare(countSql)).get(...queryArgs))?.total ?? 0);
const pagination = getPagination(url, total); const pagination = getPagination(url, total);
const rows = await (await database.prepare(rowsSql)).all(...queryArgs, pagination.perPage, pagination.offset); const rows = await (await database.prepare(rowsSql)).all(...queryArgs, pagination.perPage, pagination.offset);
const weaponRelations = category.slug === 'weapons' && rows.length
? await (await database.prepare(`SELECT options.weapon_id,options.talent_slot,talents.*,
(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,
icons.updated_at AS icon_updated_at
FROM weapon_talent_options options
JOIN talents ON talents.id=options.talent_id AND talents.active=1
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
WHERE options.weapon_id IN (${rows.map(() => '?').join(',')})
ORDER BY options.talent_slot,talents.name COLLATE NOCASE`)).all(...rows.map((row) => row.id))
: [];
const gearRelations = category.slug === 'gear' && rows.length const gearRelations = category.slug === 'gear' && rows.length
? await (await database.prepare(`SELECT relation.*,icons.updated_at ? await (await database.prepare(`SELECT relation.*,icons.updated_at
FROM ( FROM (
@@ -340,21 +352,69 @@ export const load: PageServerLoad = async ({ params, url }) => {
}; };
} }
case 'weapons': case 'weapons':
{
const relatedTalents = weaponRelations.filter((relation) => relation.weapon_id === row.id);
const talentItems = (slot: number) => relatedTalents
.filter((relation) => Number(relation.talent_slot) === slot)
.map((relation) => {
const talentIcon = iconUrl('talent', relation.id, relation.icon_updated_at);
return {
id: String(relation.id),
name: String(relation.name),
iconUrl: talentIcon,
preview: {
id: String(relation.id), name: String(relation.name), kicker: `WEAPON TALENT // SLOT ${slot}`,
iconUrl: talentIcon,
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 level50Damage = row.damage_min === null ? null : weaponBaseRange(row.damage_min, row.damage_max, 50);
return { return {
id: String(row.id), name: String(row.name), kicker: `${row.category} // ${row.damage_type}`, id: String(row.id), name: String(row.name), kicker: `${row.category} // ${row.damage_type}`,
gameIcon: weaponIcon(row.category), gameIcon: weaponIcon(row.category),
badges: [{ label: `T${row.native_tier}` }, ...(Number(row.is_exotic) ? [{ label: 'EXOTIC', tone: 'exotic' }] : [])], headerBadge: `T${row.native_tier}`,
headerMetaIcon: weaponIcon(row.damage_type),
badges: Number(row.is_exotic) ? [{ label: 'EXOTIC', tone: 'exotic' }] : [],
factLayout: 'stack' as const,
facts: [ facts: [
{ label: 'DAMAGE RANGE', value: row.damage_min === null ? 'NOT CONFIGURED' : `${range(row.damage_min,row.damage_max)} · L${row.reference_level}` }, {
label: 'DAMAGE',
value: level50Damage ? `${level50Damage.min.toLocaleString('en-US')}–${level50Damage.max.toLocaleString('en-US')}` : 'NOT CONFIGURED',
emphasis: 'primary' as const,
gameIcon: weaponIcon(row.damage_type),
note: 'AT LEVEL 50'
},
{ label: 'RATE / MAG', value: `${number(row.rpm)} RPM · ${number(row.magazine)} MAG` }, { label: 'RATE / MAG', value: `${number(row.rpm)} RPM · ${number(row.magazine)} MAG` },
{ label: 'RELOAD', value: valueWithUnit(row.reload_speed,'s') }, { label: 'RELOAD', value: valueWithUnit(row.reload_speed,'s') },
{ label: 'ACCURACY / STABILITY', value: `${number(row.accuracy)} / ${number(row.stability)}` } {
label: 'HANDLING',
value: '',
meters: [
{ label: 'ACCURACY', value: Number(row.accuracy) || 0 },
{ label: 'STABILITY', value: Number(row.stability) || 0 }
]
}
], ],
lines: [ lines: [
{ label: 'TALENT POOLS', value: `${row.talent_1_count} TOP · ${row.talent_2_count} BOTTOM` },
{ label: 'BASE BONUSES', value: String(row.bonus_count) } { label: 'BASE BONUSES', value: String(row.bonus_count) }
],
iconGroups: [
{ label: 'TALENT 1 POOL', items: talentItems(1), emptyLabel: 'NO TALENTS ASSIGNED' },
{ label: 'TALENT 2 POOL', items: talentItems(2), emptyLabel: 'NO TALENTS ASSIGNED' }
] ]
}; };
}
case 'weapon-mods': case 'weapon-mods':
return { return {
id: String(row.id), name: String(row.name), kicker: `${row.slot_type} // WEAPON MOD`, id: String(row.id), name: String(row.name), kicker: `${row.slot_type} // WEAPON MOD`,