feat(database): refine weapon registry cards
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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>
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
Reference in New Issue
Block a user