feat: roomier BO2 companion with +/- rank stepping
All checks were successful
Test and deploy staging / staging (push) Successful in 2m29s
All checks were successful
Test and deploy staging / staging (push) Successful in 2m29s
- Scale the COD page to the screen: larger gun art, text and progress ribbon, sized from one viewport unit so it fills a MacBook Pro 16" and big monitors and still fits 1280x720 without scrolling. - Five columns on wide screens and four below, picked by CSS, so the server-rendered page already has the final layout. - Replace the tabs/filters/overview rows with one toolbar of matching controls. - Player and weapon ranks step with + and -: level 55 + 1 is the next prestige at level 1, up to max prestige. Presses show at once, batch while a save is running, and stepRank does the arithmetic in SQL. - Clicking a rank still opens exact entry for big jumps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,33 +2,107 @@
|
||||
import { base } from '$app/paths';
|
||||
import ChevronsUp from 'lucide-svelte/icons/chevrons-up';
|
||||
import Award from 'lucide-svelte/icons/award';
|
||||
let { game, level, prestige }: { game: string; level: number; prestige: number } = $props();
|
||||
let {
|
||||
game,
|
||||
level,
|
||||
prestige,
|
||||
levelCap = null,
|
||||
prestigeCap = null,
|
||||
onedit
|
||||
}: {
|
||||
game: string;
|
||||
level: number;
|
||||
prestige: number;
|
||||
levelCap?: number | null;
|
||||
prestigeCap?: number | null;
|
||||
/** Makes the badge a button for typing an exact rank. */
|
||||
onedit?: () => void;
|
||||
} = $props();
|
||||
const master = $derived(game === 'black-ops-2' && prestige === 10 && level === 55);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-4" aria-label="Player level and prestige">
|
||||
<div class="flex items-center gap-2">
|
||||
<ChevronsUp class="size-7 text-amber-500" aria-label="Level icon" role="img" />
|
||||
<div>
|
||||
<span class="block text-[9px] tracking-widest text-muted-foreground">LEVEL</span><strong
|
||||
class="text-sm tabular-nums">{level}</strong
|
||||
<svelte:element
|
||||
this={onedit ? 'button' : 'div'}
|
||||
type={onedit ? 'button' : undefined}
|
||||
class="badge"
|
||||
class:editable={!!onedit}
|
||||
aria-label="Player level and prestige"
|
||||
title={onedit ? 'Type an exact level and prestige' : undefined}
|
||||
onclick={onedit}
|
||||
role={onedit ? undefined : 'group'}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="stat">
|
||||
{#if game === 'black-ops-2' && prestige > 0 && prestige <= 10}
|
||||
<img
|
||||
src={`${base}/game-art/call-of-duty/ranks/black-ops-2-prestige-${master ? 11 : prestige}.webp`}
|
||||
alt={master ? 'Prestige Master emblem' : `Prestige ${prestige} emblem`}
|
||||
width="36"
|
||||
height="36"
|
||||
class="size-9 object-contain"
|
||||
width="48"
|
||||
height="48"
|
||||
class="emblem"
|
||||
/>
|
||||
{:else}<Award class="size-7 text-muted-foreground" aria-label="Prestige icon" role="img" />{/if}
|
||||
<div>
|
||||
<span class="block text-[9px] tracking-widest text-muted-foreground">PRESTIGE</span><strong
|
||||
class="text-sm tabular-nums">{master ? 'Master' : prestige}</strong
|
||||
{:else}<Award class="icon text-muted-foreground" aria-label="Prestige icon" role="img" />{/if}
|
||||
<span
|
||||
><span class="label">PRESTIGE</span><strong
|
||||
>{master ? 'Master' : prestige}{#if prestigeCap !== null && !master}<small
|
||||
>/{prestigeCap}</small
|
||||
>{/if}</strong
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<span class="stat">
|
||||
<ChevronsUp class="icon text-amber-500" aria-label="Level icon" role="img" />
|
||||
<span
|
||||
><span class="label">LEVEL</span><strong
|
||||
>{level}{#if levelCap !== null}<small>/{levelCap}</small>{/if}</strong
|
||||
></span
|
||||
>
|
||||
</span>
|
||||
</svelte:element>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(18 * var(--u, 1px));
|
||||
border-radius: calc(10 * var(--u, 1px));
|
||||
padding: calc(2 * var(--u, 1px)) calc(8 * var(--u, 1px));
|
||||
text-align: left;
|
||||
}
|
||||
.editable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.editable:hover {
|
||||
background: color-mix(in srgb, #f59e0b 10%, transparent);
|
||||
}
|
||||
.editable:focus-visible {
|
||||
outline: 2px solid #f59e0b;
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(8 * var(--u, 1px));
|
||||
}
|
||||
.emblem,
|
||||
.stat :global(.icon) {
|
||||
width: calc(44 * var(--u, 1px));
|
||||
height: calc(44 * var(--u, 1px));
|
||||
object-fit: contain;
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
font-size: calc(11 * var(--u, 1px));
|
||||
letter-spacing: 0.14em;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
strong {
|
||||
display: block;
|
||||
font-size: calc(24 * var(--u, 1px));
|
||||
line-height: 1.05;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
small {
|
||||
font-size: 0.6em;
|
||||
font-weight: 500;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
detail,
|
||||
hint = '',
|
||||
color = 'emerald',
|
||||
compact = false
|
||||
compact = false,
|
||||
hero = false
|
||||
}: {
|
||||
label: string;
|
||||
progress: ProgressCount;
|
||||
@@ -15,6 +16,8 @@
|
||||
hint?: string;
|
||||
color?: 'emerald' | 'sky' | 'amber' | 'violet' | 'pink';
|
||||
compact?: boolean;
|
||||
/** Large dashboard stat; sizes follow the page's --u scale. */
|
||||
hero?: boolean;
|
||||
} = $props();
|
||||
const percent = $derived(fraction(progress) * 100);
|
||||
// Keep tiny catalog gains visible without rounding unfinished progress up to 100%.
|
||||
@@ -36,6 +39,24 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if hero}
|
||||
<div class="hero" title={hint}>
|
||||
<div class="hero-top">
|
||||
<span class="hero-label">{label}</span>
|
||||
<span class="hero-value">{display}<small>%</small>{percent === 100 ? ' ✓' : ''}</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={percent}
|
||||
max={100}
|
||||
aria-label={label}
|
||||
aria-valuetext={`${display}%${detail ? ` · ${detail}` : ''}`}
|
||||
class={`hero-bar ${colors[color]} [&_[data-slot=progress-indicator]]:duration-700 [&_[data-slot=progress-indicator]]:ease-out motion-reduce:[&_[data-slot=progress-indicator]]:transition-none`}
|
||||
/>
|
||||
<p class="hero-detail">
|
||||
{detail ?? `${progress.value.toLocaleString()} / ${progress.total.toLocaleString()}`}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="min-w-0 space-y-1" title={hint}>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class={compact ? 'truncate text-[10px] text-muted-foreground' : 'text-xs font-medium'}
|
||||
@@ -56,3 +77,51 @@
|
||||
{detail ?? `${progress.value.toLocaleString()} / ${progress.total.toLocaleString()}`}
|
||||
</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(6 * var(--u, 1px));
|
||||
}
|
||||
.hero-top {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.hero-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: calc(14 * var(--u, 1px));
|
||||
font-weight: 500;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.hero-value {
|
||||
flex-shrink: 0;
|
||||
font-size: calc(24 * var(--u, 1px));
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hero-value small {
|
||||
font-size: 0.6em;
|
||||
font-weight: 600;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.hero :global(.hero-bar) {
|
||||
height: calc(7 * var(--u, 1px));
|
||||
}
|
||||
.hero-detail {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: calc(12 * var(--u, 1px));
|
||||
color: var(--site-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
|
||||
167
src/lib/game-tracker/RankStepper.svelte
Normal file
167
src/lib/game-tracker/RankStepper.svelte
Normal file
@@ -0,0 +1,167 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { deserialize } from '$app/forms';
|
||||
import Minus from 'lucide-svelte/icons/minus';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
import type { RankLimits } from './rank-limits';
|
||||
import { maxUncappedLevel, rankProgress, stepRank, type Rank } from './rank-progress';
|
||||
|
||||
type SavedRank = Rank & { itemId: number | null };
|
||||
let {
|
||||
itemId,
|
||||
name,
|
||||
level,
|
||||
prestige,
|
||||
limits,
|
||||
disabled,
|
||||
large = false,
|
||||
onupdate,
|
||||
children
|
||||
}: {
|
||||
/** null steps the player rank. */
|
||||
itemId: number | null;
|
||||
name: string;
|
||||
level: number;
|
||||
prestige: number;
|
||||
limits: RankLimits;
|
||||
disabled: boolean;
|
||||
large?: boolean;
|
||||
onupdate: (rank: SavedRank) => void;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
let error = $state('');
|
||||
let status = $state('');
|
||||
// Presses show immediately; presses made while a save is running go out as one batch.
|
||||
let unsent = 0;
|
||||
let saving = false;
|
||||
let confirmed: Rank = { level: 1, prestige: 0 };
|
||||
|
||||
const progress = $derived(rankProgress(limits, { level, prestige }));
|
||||
const atTop = $derived(
|
||||
limits.maxLevel === null ? level >= maxUncappedLevel : progress.value >= progress.total
|
||||
);
|
||||
const atBottom = $derived(level <= 1 && (limits.maxLevel === null || prestige <= 0));
|
||||
|
||||
function press(delta: 1 | -1) {
|
||||
const next = stepRank(limits, { level, prestige }, delta);
|
||||
if (next.level === level && next.prestige === prestige) return;
|
||||
if (!saving && !unsent) confirmed = { level, prestige };
|
||||
unsent += delta;
|
||||
error = '';
|
||||
onupdate({ itemId, ...next });
|
||||
void save();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving || !unsent) return;
|
||||
saving = true;
|
||||
status = 'Saving…';
|
||||
// The server accepts at most 100 levels per request; any rest goes in the next one.
|
||||
const delta = Math.max(-100, Math.min(100, unsent));
|
||||
unsent -= delta;
|
||||
const body = new FormData();
|
||||
if (itemId !== null) body.set('itemId', String(itemId));
|
||||
body.set('delta', String(delta));
|
||||
try {
|
||||
const result = deserialize(
|
||||
await (await fetch('?/stepRank', { method: 'POST', body })).text()
|
||||
);
|
||||
if (result.type !== 'success')
|
||||
throw new Error(
|
||||
result.type === 'failure'
|
||||
? String(result.data?.message ?? 'Could not save.')
|
||||
: 'Could not save.'
|
||||
);
|
||||
const rank = result.data?.rank as SavedRank;
|
||||
confirmed = { level: rank.level, prestige: rank.prestige };
|
||||
} catch (e) {
|
||||
unsent = 0;
|
||||
error = e instanceof Error ? e.message : 'Could not save. Try again.';
|
||||
}
|
||||
saving = false;
|
||||
if (unsent) return save();
|
||||
status = error ? '' : 'Saved';
|
||||
onupdate({ itemId, ...confirmed });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="stepper" class:large>
|
||||
<button
|
||||
type="button"
|
||||
class="step"
|
||||
aria-label={`Level down ${name}`}
|
||||
disabled={disabled || atBottom}
|
||||
onclick={() => press(-1)}><Minus aria-hidden="true" /></button
|
||||
>
|
||||
{@render children?.()}
|
||||
<button
|
||||
type="button"
|
||||
class="step up"
|
||||
aria-label={`Level up ${name}`}
|
||||
disabled={disabled || atTop}
|
||||
onclick={() => press(1)}><Plus aria-hidden="true" /></button
|
||||
>
|
||||
<span role="status" class={error ? 'error' : 'sr-only'}>{error || status}</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stepper {
|
||||
--size: calc(30 * var(--u, 1px));
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: calc(4 * var(--u, 1px));
|
||||
}
|
||||
.large {
|
||||
--size: 40px;
|
||||
gap: 10px;
|
||||
}
|
||||
.step {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: calc(var(--size) / 4);
|
||||
background: var(--background);
|
||||
color: var(--site-text);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
.step :global(svg) {
|
||||
width: 55%;
|
||||
height: 55%;
|
||||
}
|
||||
.step.up {
|
||||
border-color: color-mix(in srgb, #f59e0b 55%, var(--site-border));
|
||||
background: color-mix(in srgb, #f59e0b 14%, var(--background));
|
||||
}
|
||||
.step:hover:not(:disabled) {
|
||||
border-color: #f59e0b;
|
||||
background: color-mix(in srgb, #f59e0b 26%, var(--background));
|
||||
}
|
||||
.step:focus-visible {
|
||||
outline: 2px solid #f59e0b;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.step:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.error {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
width: 13rem;
|
||||
border-radius: 6px;
|
||||
background: var(--destructive);
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,13 @@
|
||||
name,
|
||||
icon,
|
||||
compact = false
|
||||
}: { game: string; name: string; icon: string | null; compact?: boolean } = $props();
|
||||
}: {
|
||||
game: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
/** Fill the parent box instead of using a fixed thumbnail size. */
|
||||
compact?: boolean;
|
||||
} = $props();
|
||||
const source = $derived(
|
||||
artwork.find((a) => a.game === game && a.name.toLowerCase() === name.toLowerCase())
|
||||
);
|
||||
@@ -17,7 +23,9 @@
|
||||
{#if src}
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger
|
||||
class="shrink-0 overflow-hidden rounded-md border focus-visible:outline-2"
|
||||
class={compact
|
||||
? 'block h-full w-full cursor-zoom-in rounded-md focus-visible:outline-2'
|
||||
: 'shrink-0 overflow-hidden rounded-md border focus-visible:outline-2'}
|
||||
aria-label={`View ${name} artwork`}
|
||||
><img
|
||||
{src}
|
||||
@@ -25,7 +33,7 @@
|
||||
loading="lazy"
|
||||
width="112"
|
||||
height="64"
|
||||
class={compact ? 'h-6 w-12 object-contain' : 'h-16 w-28 object-contain'}
|
||||
class={compact ? 'h-full w-full object-contain' : 'h-28 w-56 object-contain'}
|
||||
/></Dialog.Trigger
|
||||
>
|
||||
<Dialog.Content class="sm:max-w-3xl"
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { deserialize } from '$app/forms';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import Minus from 'lucide-svelte/icons/minus';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
let {
|
||||
itemId,
|
||||
name,
|
||||
level,
|
||||
prestige,
|
||||
levelCap = null,
|
||||
prestigeCap = 1000,
|
||||
prestigeRequiresMaxLevel = false,
|
||||
compact = false,
|
||||
dense = false,
|
||||
disabled,
|
||||
onupdate,
|
||||
onedit
|
||||
}: {
|
||||
itemId: number;
|
||||
name: string;
|
||||
level: number;
|
||||
prestige: number;
|
||||
levelCap?: number | null;
|
||||
prestigeCap?: number;
|
||||
prestigeRequiresMaxLevel?: boolean;
|
||||
compact?: boolean;
|
||||
dense?: boolean;
|
||||
disabled: boolean;
|
||||
onupdate: (rank: { itemId: number; level: number; prestige: number }) => void;
|
||||
onedit: () => void;
|
||||
} = $props();
|
||||
let saving = $state(false);
|
||||
let error = $state('');
|
||||
let saved = $state(false);
|
||||
async function adjust(field: 'level' | 'prestige', delta: number) {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
error = '';
|
||||
saved = false;
|
||||
const body = new FormData();
|
||||
body.set('itemId', String(itemId));
|
||||
body.set('field', field);
|
||||
body.set('delta', String(delta));
|
||||
try {
|
||||
const result = deserialize(
|
||||
await (await fetch('?/adjustWeaponRank', { method: 'POST', body })).text()
|
||||
);
|
||||
if (result.type !== 'success')
|
||||
throw new Error(
|
||||
result.type === 'failure'
|
||||
? String(result.data?.message ?? 'Could not save.')
|
||||
: 'Could not save.'
|
||||
);
|
||||
const rank = result.data?.rank as { itemId: number; level: number; prestige: number };
|
||||
onupdate(rank);
|
||||
saved = true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Could not save. Try again.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if dense}
|
||||
<div class="relative flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-1 py-1 text-[11px] tabular-nums hover:bg-accent"
|
||||
aria-label={`Edit ${name} level and prestige`}
|
||||
disabled={disabled || saving}
|
||||
onclick={onedit}
|
||||
title="Edit level and prestige"
|
||||
>
|
||||
<span class="sr-only">LEVEL</span>{level}{#if levelCap}<span class="text-muted-foreground"
|
||||
>/{levelCap}</span
|
||||
>{/if}
|
||||
</button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="size-6"
|
||||
aria-label={`Level up ${name}`}
|
||||
disabled={disabled || saving || level >= (levelCap ?? 10000)}
|
||||
onclick={() => adjust('level', 1)}><Plus class="size-3" /></Button
|
||||
>
|
||||
<span
|
||||
role="status"
|
||||
class={error
|
||||
? 'absolute right-0 top-full z-20 w-52 rounded border bg-destructive p-2 text-xs text-white'
|
||||
: 'sr-only'}>{saving ? 'Saving…' : error || (saved ? 'Saved' : '')}</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-lg border bg-background p-1"
|
||||
aria-label={`${name} level controls`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={compact ? 'size-7' : 'size-10'}
|
||||
aria-label={`Level down ${name}`}
|
||||
disabled={disabled || saving || level <= 1}
|
||||
onclick={() => adjust('level', -1)}
|
||||
><Minus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class={compact
|
||||
? 'h-8 min-w-11 flex-col gap-0 px-1 tabular-nums'
|
||||
: 'h-10 min-w-16 flex-col gap-0 px-2 tabular-nums'}
|
||||
aria-label={`Edit ${name} level and prestige`}
|
||||
disabled={disabled || saving}
|
||||
onclick={onedit}
|
||||
><span
|
||||
class={compact
|
||||
? 'text-[8px] text-muted-foreground'
|
||||
: 'text-[10px] text-muted-foreground'}>LEVEL</span
|
||||
><span class={compact ? 'text-sm font-semibold' : 'text-lg font-semibold'}
|
||||
>{level}{#if levelCap}<span class="text-muted-foreground">/{levelCap}</span>{/if}</span
|
||||
></Button
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class={compact ? 'size-7' : 'size-10'}
|
||||
aria-label={`Level up ${name}`}
|
||||
disabled={disabled || saving || level >= (levelCap ?? 10000)}
|
||||
onclick={() => adjust('level', 1)}
|
||||
><Plus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-lg border bg-background p-1"
|
||||
aria-label={`${name} prestige controls`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={compact ? 'size-7' : 'size-10'}
|
||||
aria-label={`Prestige down ${name}`}
|
||||
disabled={disabled || saving || prestige <= 0}
|
||||
onclick={() => adjust('prestige', -1)}
|
||||
><Minus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
|
||||
>
|
||||
<span class="min-w-12 text-center tabular-nums"
|
||||
><span
|
||||
class={compact
|
||||
? 'block text-[8px] text-muted-foreground'
|
||||
: 'block text-[10px] text-muted-foreground'}>PRESTIGE</span
|
||||
><span class={compact ? 'text-sm font-semibold' : 'text-lg font-semibold'}
|
||||
>{prestige}{#if prestigeCap < 1000}<span class="text-muted-foreground"
|
||||
>/{prestigeCap}</span
|
||||
>{/if}</span
|
||||
></span
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class={compact ? 'size-7' : 'size-10'}
|
||||
aria-label={`Prestige up ${name}`}
|
||||
disabled={disabled ||
|
||||
saving ||
|
||||
prestige >= prestigeCap ||
|
||||
(prestigeRequiresMaxLevel && level < (levelCap ?? 10000))}
|
||||
onclick={() => adjust('prestige', 1)}
|
||||
><Plus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p class="min-h-3 text-[9px] text-muted-foreground" role="status">
|
||||
{saving ? 'Saving…' : error || (saved ? 'Saved' : 'Tap the level to edit both values')}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
gameRanksProgress,
|
||||
hasReachedMaxLevel,
|
||||
rankProgress,
|
||||
stepRank,
|
||||
weaponFinished,
|
||||
weaponLevelsProgress
|
||||
} from './rank-progress';
|
||||
@@ -72,6 +73,24 @@ describe('BO2 rank progression', () => {
|
||||
expect(weaponFinished(rules, m27, { level: 16, prestige: 2 }, () => true)).toBe(false);
|
||||
expect(weaponFinished(rules, m27, { level: 17, prestige: 2 }, () => true)).toBe(true);
|
||||
});
|
||||
it('steps level and prestige as one counter', () => {
|
||||
const player = playerRankLimits(rules);
|
||||
expect(stepRank(player, null, 1)).toEqual({ level: 2, prestige: 0 });
|
||||
expect(stepRank(player, null, -1)).toEqual({ level: 1, prestige: 0 });
|
||||
expect(stepRank(player, { level: 55, prestige: 0 }, 1)).toEqual({ level: 1, prestige: 1 });
|
||||
expect(stepRank(player, { level: 1, prestige: 1 }, -1)).toEqual({ level: 55, prestige: 0 });
|
||||
expect(stepRank(player, { level: 54, prestige: 10 }, 1)).toEqual({ level: 55, prestige: 10 });
|
||||
expect(stepRank(player, { level: 55, prestige: 10 }, 1)).toEqual({ level: 55, prestige: 10 });
|
||||
const m27Limits = weaponRankLimits(rules, m27);
|
||||
expect(stepRank(m27Limits, { level: 17, prestige: 1 }, 1)).toEqual({ level: 1, prestige: 2 });
|
||||
expect(stepRank(m27Limits, { level: 16, prestige: 0 }, 3)).toEqual({ level: 2, prestige: 1 });
|
||||
// Historic out-of-range prestiges count as max prestige instead of growing further.
|
||||
expect(stepRank(m27Limits, { level: 1, prestige: 4 }, -1)).toEqual({ level: 17, prestige: 1 });
|
||||
expect(stepRank(m27Limits, { level: 1, prestige: 4 }, 1)).toEqual({ level: 2, prestige: 2 });
|
||||
const uncapped = weaponRankLimits({ ...rules, rankCaps: undefined }, m27);
|
||||
expect(stepRank(uncapped, { level: 40, prestige: 3 }, 1)).toEqual({ level: 41, prestige: 3 });
|
||||
expect(stepRank(uncapped, { level: 1, prestige: 3 }, -1)).toEqual({ level: 1, prestige: 3 });
|
||||
});
|
||||
it('bounds historic out-of-range values without mutating saved data', () => {
|
||||
const rank = { level: 100, prestige: 999 };
|
||||
expect(rankProgress(weaponRankLimits(rules, m27), rank)).toEqual({ value: 51, total: 51 });
|
||||
|
||||
@@ -15,6 +15,23 @@ export function rankProgress(limits: RankLimits, rank?: Rank | null): ProgressCo
|
||||
return { value: prestige * limits.maxLevel + level, total };
|
||||
}
|
||||
|
||||
export const maxUncappedLevel = 10000;
|
||||
|
||||
// One + / − press. With known caps, level and prestige form one counter, so
|
||||
// level 55 + 1 is the next prestige at level 1, until max prestige at max level.
|
||||
// Without caps only the level moves; prestige is set explicitly instead.
|
||||
export function stepRank(limits: RankLimits, rank: Rank | null | undefined, delta: number): Rank {
|
||||
const current = rank ?? { level: 1, prestige: 0 };
|
||||
if (limits.maxLevel === null)
|
||||
return {
|
||||
level: Math.max(1, Math.min(maxUncappedLevel, current.level + delta)),
|
||||
prestige: current.prestige
|
||||
};
|
||||
const { value, total } = rankProgress(limits, current);
|
||||
const next = Math.max(1, Math.min(total, value + delta)) - 1;
|
||||
return { level: (next % limits.maxLevel) + 1, prestige: Math.floor(next / limits.maxLevel) };
|
||||
}
|
||||
|
||||
export function weaponLevelsProgress(
|
||||
rules: MasteryRules,
|
||||
weapons: readonly Weapon[],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { error, fail } from '@sveltejs/kit';
|
||||
import achievementArt from '../../../../../../data/cod-achievement-art.json';
|
||||
const achievementImages: Record<string, string> = achievementArt;
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
import type { AnyPgColumn } from 'drizzle-orm/pg-core';
|
||||
import { db } from '$lib/server/db';
|
||||
import {
|
||||
trackerGames,
|
||||
@@ -14,7 +15,8 @@ import {
|
||||
} from '$lib/server/db/schema';
|
||||
import { boundedInteger } from '$lib/game-tracker/journal';
|
||||
import { appliesTo } from '$lib/game-tracker/mastery';
|
||||
import { playerRankLimits, weaponRankLimits } from '$lib/game-tracker/rank-limits';
|
||||
import { playerRankLimits, weaponRankLimits, type RankLimits } from '$lib/game-tracker/rank-limits';
|
||||
import { maxUncappedLevel, stepRank } from '$lib/game-tracker/rank-progress';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
async function findGame(slug: string) {
|
||||
@@ -111,16 +113,45 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
};
|
||||
};
|
||||
|
||||
// SQL twin of stepRank: computed from the stored row, so taps from several devices never lose a level.
|
||||
function steppedRank(level: AnyPgColumn, prestige: AnyPgColumn, limits: RankLimits, delta: number) {
|
||||
if (limits.maxLevel === null)
|
||||
return { level: sql`greatest(1, least(${maxUncappedLevel}::int, ${level} + ${delta}::int))` };
|
||||
const cap = limits.maxLevel;
|
||||
const total = cap * (limits.maxPrestige + 1);
|
||||
const next = sql`(greatest(1, least(${total}::int,
|
||||
least(greatest(${prestige}, 0), ${limits.maxPrestige}::int) * ${cap}::int
|
||||
+ greatest(1, least(${level}, ${cap}::int)) + ${delta}::int)) - 1)`;
|
||||
return { level: sql`${next} % ${cap}::int + 1`, prestige: sql`${next} / ${cap}::int` };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
adjustWeaponRank: async ({ request, locals, params }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to save weapon progress.' });
|
||||
// One + / − press (or a few batched presses) on the player rank or a weapon rank.
|
||||
stepRank: async ({ request, locals, params }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to save your rank.' });
|
||||
const game = await findGame(params.slug);
|
||||
const form = await request.formData();
|
||||
const itemId = boundedInteger(form.get('itemId'), 1, 2147483647);
|
||||
const field = form.get('field');
|
||||
const delta = form.get('delta') === '1' ? 1 : form.get('delta') === '-1' ? -1 : null;
|
||||
if (!itemId || !['level', 'prestige'].includes(String(field)) || delta === null)
|
||||
return fail(400, { message: 'Invalid weapon adjustment.' });
|
||||
const rawDelta = String(form.get('delta') ?? '');
|
||||
const delta = /^-?\d{1,3}$/.test(rawDelta) ? Number(rawDelta) : 0;
|
||||
const itemId = form.get('itemId') ? boundedInteger(form.get('itemId'), 1, 2147483647) : null;
|
||||
if (!delta || Math.abs(delta) > 100 || (form.get('itemId') && !itemId))
|
||||
return fail(400, { message: 'Invalid rank change.' });
|
||||
const updatedAt = new Date();
|
||||
if (!itemId) {
|
||||
const limits = playerRankLimits(game.rules);
|
||||
const [rank] = await db
|
||||
.insert(trackerRanks)
|
||||
.values({ userId: locals.user.id, gameId: game.id, ...stepRank(limits, null, delta) })
|
||||
.onConflictDoUpdate({
|
||||
target: [trackerRanks.userId, trackerRanks.gameId],
|
||||
set: {
|
||||
...steppedRank(trackerRanks.level, trackerRanks.prestige, limits, delta),
|
||||
updatedAt
|
||||
}
|
||||
})
|
||||
.returning({ level: trackerRanks.level, prestige: trackerRanks.prestige });
|
||||
return { rank: { itemId: null, ...rank } };
|
||||
}
|
||||
const [item] = await db
|
||||
.select({ id: trackerItems.id, name: trackerItems.name, category: trackerItems.category })
|
||||
.from(trackerItems)
|
||||
@@ -137,52 +168,21 @@ export const actions = {
|
||||
return fail(400, {
|
||||
message: 'This weapon does not have a weapon-rank progression in this game.'
|
||||
});
|
||||
const maxLevel = limits.maxLevel ?? 10000;
|
||||
if (field === 'prestige' && delta > 0 && limits.canPrestigeOnlyAtMaxLevel) {
|
||||
const [current] = await db
|
||||
.select({ level: trackerWeaponRanks.level, prestige: trackerWeaponRanks.prestige })
|
||||
.from(trackerWeaponRanks)
|
||||
.where(
|
||||
and(eq(trackerWeaponRanks.userId, locals.user.id), eq(trackerWeaponRanks.itemId, itemId))
|
||||
);
|
||||
if (!current || current.level < maxLevel)
|
||||
return fail(400, { message: `Reach weapon level ${maxLevel} before increasing prestige.` });
|
||||
if (current.prestige >= limits.maxPrestige)
|
||||
return fail(400, { message: `This weapon is already at prestige ${limits.maxPrestige}.` });
|
||||
}
|
||||
// Arithmetic happens in Postgres, so simultaneous taps from different devices cannot lose a level.
|
||||
const [rank] = await db
|
||||
.insert(trackerWeaponRanks)
|
||||
.values({
|
||||
userId: locals.user.id,
|
||||
itemId,
|
||||
level: field === 'level' ? Math.max(1, Math.min(maxLevel, 1 + delta)) : 1,
|
||||
prestige: field === 'prestige' ? Math.max(0, delta) : 0
|
||||
})
|
||||
.values({ userId: locals.user.id, itemId, ...stepRank(limits, null, delta) })
|
||||
.onConflictDoUpdate({
|
||||
target: [trackerWeaponRanks.userId, trackerWeaponRanks.itemId],
|
||||
set: {
|
||||
...(field === 'level'
|
||||
? {
|
||||
level: sql`greatest(1, least(${maxLevel}, ${trackerWeaponRanks.level} + ${delta}))`
|
||||
...steppedRank(trackerWeaponRanks.level, trackerWeaponRanks.prestige, limits, delta),
|
||||
updatedAt
|
||||
}
|
||||
: {
|
||||
prestige: sql`greatest(0, least(${limits.maxPrestige}, ${trackerWeaponRanks.prestige} + ${delta}))`,
|
||||
...(delta > 0 ? { level: 1 } : {})
|
||||
}),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
setWhere:
|
||||
field === 'prestige' && delta > 0 && limits.canPrestigeOnlyAtMaxLevel
|
||||
? sql`${trackerWeaponRanks.level} >= ${maxLevel} and ${trackerWeaponRanks.prestige} < ${limits.maxPrestige}`
|
||||
: undefined
|
||||
})
|
||||
.returning({
|
||||
itemId: trackerWeaponRanks.itemId,
|
||||
level: trackerWeaponRanks.level,
|
||||
prestige: trackerWeaponRanks.prestige
|
||||
});
|
||||
if (!rank) return fail(400, { message: 'Rank changed. Refresh before prestiging again.' });
|
||||
return { rank };
|
||||
},
|
||||
saveRank: async ({ request, locals, params }) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -222,7 +222,7 @@ test('weapon/player ranks, challenge progress and osu goals/scores/training pers
|
||||
await sql`select c.id,c.target from tracker_challenges c join tracker_games g on c.game_id=g.id where g.slug='black-ops-3' and c.target>1 limit 1`;
|
||||
const cod = '/projects/game-tracker/call-of-duty/black-ops-3';
|
||||
await page.goto(cod);
|
||||
await page.getByRole('button', { name: 'Update my rank' }).click();
|
||||
await page.getByRole('button', { name: 'Player level and prestige' }).click();
|
||||
await page.getByLabel('Current level', { exact: true }).fill('42');
|
||||
await page.getByLabel('Prestige', { exact: true }).fill('3');
|
||||
await page.getByRole('button', { name: 'Save rank' }).click();
|
||||
@@ -537,7 +537,12 @@ test('live weapon controls save immediately, infer max level from prestige, and
|
||||
await expect(level).toHaveText(/LEVEL2/);
|
||||
await card.getByRole('button', { name: `Level down ${weapon.name}` }).click();
|
||||
await expect(level).toHaveText(/LEVEL1/);
|
||||
await card.getByRole('button', { name: `Prestige up ${weapon.name}` }).click();
|
||||
// Without known caps + / − only move the level; prestige is typed into the exact-rank dialog.
|
||||
await level.click();
|
||||
await page.getByLabel('Prestige', { exact: true }).fill('1');
|
||||
await page.getByRole('button', { name: 'Save rank' }).click();
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: `${weapon.name} camos and milestones` }).click();
|
||||
await expect(card).toContainText('Max level reached');
|
||||
await expect(card.getByRole('checkbox', { name: /max level/ })).toHaveCount(0);
|
||||
expect(
|
||||
@@ -547,38 +552,28 @@ test('live weapon controls save immediately, infer max level from prestige, and
|
||||
).toMatchObject({ level: 1, prestige: 1 });
|
||||
const concurrent = await Promise.all(
|
||||
Array.from({ length: 4 }, () =>
|
||||
action(page.request, cod + '?/adjustWeaponRank', {
|
||||
itemId: String(weapon.id),
|
||||
field: 'level',
|
||||
delta: '1'
|
||||
})
|
||||
action(page.request, cod + '?/stepRank', { itemId: String(weapon.id), delta: '1' })
|
||||
)
|
||||
);
|
||||
expect(concurrent.every((r) => r.status === 200)).toBe(true);
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: `${weapon.name} camos and milestones` }).click();
|
||||
await expect(level).toHaveText(/LEVEL5/);
|
||||
await page.route('**/*adjustWeaponRank', (route) => route.abort());
|
||||
await page.route('**/*stepRank', (route) => route.abort());
|
||||
await card.getByRole('button', { name: `Level up ${weapon.name}` }).click();
|
||||
await expect(card.getByRole('status')).toContainText(/fetch|save|network/i);
|
||||
await expect(level).toHaveText(/LEVEL5/);
|
||||
await page.unroute('**/*adjustWeaponRank');
|
||||
await page.unroute('**/*stepRank');
|
||||
await card.getByRole('button', { name: `Level up ${weapon.name}` }).click();
|
||||
await expect(level).toHaveText(/LEVEL6/);
|
||||
for (const delta of ['999', '0', '1.5', ''])
|
||||
expect(
|
||||
(
|
||||
await action(page.request, cod + '?/adjustWeaponRank', {
|
||||
itemId: String(weapon.id),
|
||||
field: 'level',
|
||||
delta: '999'
|
||||
})
|
||||
).status
|
||||
(await action(page.request, cod + '?/stepRank', { itemId: String(weapon.id), delta })).status
|
||||
).toBe(400);
|
||||
expect(
|
||||
(
|
||||
await action(page.request, '/projects/game-tracker/call-of-duty/wwii?/adjustWeaponRank', {
|
||||
await action(page.request, '/projects/game-tracker/call-of-duty/wwii?/stepRank', {
|
||||
itemId: String(weapon.id),
|
||||
field: 'level',
|
||||
delta: '1'
|
||||
})
|
||||
).status
|
||||
@@ -586,9 +581,8 @@ test('live weapon controls save immediately, infer max level from prestige, and
|
||||
const anonymous = await playwright.request.newContext({ baseURL: origin });
|
||||
expect(
|
||||
(
|
||||
await action(anonymous, cod + '?/adjustWeaponRank', {
|
||||
await action(anonymous, cod + '?/stepRank', {
|
||||
itemId: String(weapon.id),
|
||||
field: 'level',
|
||||
delta: '1'
|
||||
})
|
||||
).status
|
||||
@@ -911,9 +905,8 @@ test('BO2 armoury fits every gun, enforces caps and advances all rank progress w
|
||||
const overall = page.getByRole('progressbar', { name: 'Game completion', exact: true });
|
||||
await expect(levels).toHaveAttribute('aria-valuenow', '0');
|
||||
await card.getByRole('button', { name: 'Level up M27', exact: true }).click();
|
||||
await expect(card.getByRole('button', { name: 'Edit M27 level and prestige' })).toHaveText(
|
||||
'LEVEL2/17'
|
||||
);
|
||||
const m27Rank = card.getByRole('button', { name: 'Edit M27 level and prestige' });
|
||||
await expect(m27Rank).toHaveText('P0 2/17');
|
||||
const first = Number(await levels.getAttribute('aria-valuenow'));
|
||||
expect(first).toBeGreaterThan(0);
|
||||
const before = Number(await overall.getAttribute('aria-valuenow'));
|
||||
@@ -942,37 +935,51 @@ test('BO2 armoury fits every gun, enforces caps and advances all rank progress w
|
||||
})
|
||||
).status
|
||||
).toBe(400);
|
||||
expect(
|
||||
(
|
||||
await action(page.request, cod + '?/adjustWeaponRank', {
|
||||
itemId: String(m27.id),
|
||||
field: 'prestige',
|
||||
delta: '1'
|
||||
})
|
||||
).status
|
||||
).toBe(400);
|
||||
await action(page.request, cod + '?/saveRank', {
|
||||
itemId: String(m27.id),
|
||||
level: '17',
|
||||
prestige: '0'
|
||||
});
|
||||
// Presses from two devices at max level both count: level 17 → prestige 1 level 2.
|
||||
const simultaneous = await Promise.all(
|
||||
[1, 2].map(() =>
|
||||
action(page.request, cod + '?/adjustWeaponRank', {
|
||||
itemId: String(m27.id),
|
||||
field: 'prestige',
|
||||
delta: '1'
|
||||
})
|
||||
action(page.request, cod + '?/stepRank', { itemId: String(m27.id), delta: '1' })
|
||||
)
|
||||
);
|
||||
expect(simultaneous.filter((r) => r.status === 200)).toHaveLength(1);
|
||||
expect(simultaneous.every((r) => r.status === 200)).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await sql`select level,prestige from tracker_weapon_ranks where user_id=${userId} and item_id=${m27.id}`
|
||||
)[0]
|
||||
).toMatchObject({ level: 1, prestige: 1 });
|
||||
).toMatchObject({ level: 2, prestige: 1 });
|
||||
await page.reload();
|
||||
await expect(card).toContainText('18/51');
|
||||
await expect(m27Rank).toHaveText('P1 2/17');
|
||||
await card.getByRole('button', { name: 'Level down M27', exact: true }).click();
|
||||
await card.getByRole('button', { name: 'Level down M27', exact: true }).click();
|
||||
await expect(m27Rank).toHaveText('P0 17/17');
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(
|
||||
await sql`select level,prestige from tracker_weapon_ranks where user_id=${userId} and item_id=${m27.id}`
|
||||
)[0]
|
||||
)
|
||||
.toMatchObject({ level: 17, prestige: 0 });
|
||||
await card.getByRole('button', { name: 'Level up M27', exact: true }).click();
|
||||
await expect(m27Rank).toHaveText('P1 1/17');
|
||||
await action(page.request, cod + '?/saveRank', { level: '55', prestige: '0' });
|
||||
await page.reload();
|
||||
const badge = page.getByLabel('Player level and prestige');
|
||||
await page.getByRole('button', { name: 'Level up player rank', exact: true }).click();
|
||||
await expect(badge).toContainText('PRESTIGE1/10');
|
||||
await expect(badge).toContainText('LEVEL1/55');
|
||||
await page.getByRole('button', { name: 'Level down player rank', exact: true }).click();
|
||||
await expect(badge).toContainText('LEVEL55/55');
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await sql`select level,prestige from tracker_ranks where user_id=${userId}`)[0]
|
||||
)
|
||||
.toMatchObject({ level: 55, prestige: 0 });
|
||||
await action(page.request, cod + '?/saveRank', {
|
||||
itemId: String(m27.id),
|
||||
level: '17',
|
||||
@@ -980,7 +987,7 @@ test('BO2 armoury fits every gun, enforces caps and advances all rank progress w
|
||||
});
|
||||
await action(page.request, cod + '?/saveRank', { level: '42', prestige: '3' });
|
||||
await page.reload();
|
||||
await expect(card).toContainText('51/51');
|
||||
await expect(m27Rank).toHaveText('P2 17/17');
|
||||
await expect(card.getByRole('button', { name: 'Level up M27' })).toBeDisabled();
|
||||
await expect(page.getByRole('img', { name: 'Prestige 3 emblem' })).toBeVisible();
|
||||
const progress = await overall.getAttribute('aria-valuenow');
|
||||
|
||||
Reference in New Issue
Block a user