feat: roomier BO2 companion with +/- rank stepping
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:
2026-10-08 21:50:19 +00:00
parent 8eec207f2d
commit 0ee9ed0abb
10 changed files with 1128 additions and 588 deletions

View File

@@ -2,33 +2,107 @@
import { base } from '$app/paths'; import { base } from '$app/paths';
import ChevronsUp from 'lucide-svelte/icons/chevrons-up'; import ChevronsUp from 'lucide-svelte/icons/chevrons-up';
import Award from 'lucide-svelte/icons/award'; 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); const master = $derived(game === 'black-ops-2' && prestige === 10 && level === 55);
</script> </script>
<div class="flex items-center gap-4" aria-label="Player level and prestige"> <svelte:element
<div class="flex items-center gap-2"> this={onedit ? 'button' : 'div'}
<ChevronsUp class="size-7 text-amber-500" aria-label="Level icon" role="img" /> type={onedit ? 'button' : undefined}
<div> class="badge"
<span class="block text-[9px] tracking-widest text-muted-foreground">LEVEL</span><strong class:editable={!!onedit}
class="text-sm tabular-nums">{level}</strong aria-label="Player level and prestige"
title={onedit ? 'Type an exact level and prestige' : undefined}
onclick={onedit}
role={onedit ? undefined : 'group'}
> >
</div> <span class="stat">
</div>
<div class="flex items-center gap-2">
{#if game === 'black-ops-2' && prestige > 0 && prestige <= 10} {#if game === 'black-ops-2' && prestige > 0 && prestige <= 10}
<img <img
src={`${base}/game-art/call-of-duty/ranks/black-ops-2-prestige-${master ? 11 : prestige}.webp`} src={`${base}/game-art/call-of-duty/ranks/black-ops-2-prestige-${master ? 11 : prestige}.webp`}
alt={master ? 'Prestige Master emblem' : `Prestige ${prestige} emblem`} alt={master ? 'Prestige Master emblem' : `Prestige ${prestige} emblem`}
width="36" width="48"
height="36" height="48"
class="size-9 object-contain" class="emblem"
/> />
{:else}<Award class="size-7 text-muted-foreground" aria-label="Prestige icon" role="img" />{/if} {:else}<Award class="icon text-muted-foreground" aria-label="Prestige icon" role="img" />{/if}
<div> <span
<span class="block text-[9px] tracking-widest text-muted-foreground">PRESTIGE</span><strong ><span class="label">PRESTIGE</span><strong
class="text-sm tabular-nums">{master ? 'Master' : prestige}</strong >{master ? 'Master' : prestige}{#if prestigeCap !== null && !master}<small
>/{prestigeCap}</small
>{/if}</strong
></span
> >
</div> </span>
</div> <span class="stat">
</div> <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>

View File

@@ -7,7 +7,8 @@
detail, detail,
hint = '', hint = '',
color = 'emerald', color = 'emerald',
compact = false compact = false,
hero = false
}: { }: {
label: string; label: string;
progress: ProgressCount; progress: ProgressCount;
@@ -15,6 +16,8 @@
hint?: string; hint?: string;
color?: 'emerald' | 'sky' | 'amber' | 'violet' | 'pink'; color?: 'emerald' | 'sky' | 'amber' | 'violet' | 'pink';
compact?: boolean; compact?: boolean;
/** Large dashboard stat; sizes follow the page's --u scale. */
hero?: boolean;
} = $props(); } = $props();
const percent = $derived(fraction(progress) * 100); const percent = $derived(fraction(progress) * 100);
// Keep tiny catalog gains visible without rounding unfinished progress up to 100%. // Keep tiny catalog gains visible without rounding unfinished progress up to 100%.
@@ -36,6 +39,24 @@
}; };
</script> </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="min-w-0 space-y-1" title={hint}>
<div class="flex items-baseline justify-between gap-2"> <div class="flex items-baseline justify-between gap-2">
<span class={compact ? 'truncate text-[10px] text-muted-foreground' : 'text-xs font-medium'} <span class={compact ? 'truncate text-[10px] text-muted-foreground' : 'text-xs font-medium'}
@@ -56,3 +77,51 @@
{detail ?? `${progress.value.toLocaleString()} / ${progress.total.toLocaleString()}`} {detail ?? `${progress.value.toLocaleString()} / ${progress.total.toLocaleString()}`}
</p>{/if} </p>{/if}
</div> </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>

View 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>

View File

@@ -7,7 +7,13 @@
name, name,
icon, icon,
compact = false 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( const source = $derived(
artwork.find((a) => a.game === game && a.name.toLowerCase() === name.toLowerCase()) artwork.find((a) => a.game === game && a.name.toLowerCase() === name.toLowerCase())
); );
@@ -17,7 +23,9 @@
{#if src} {#if src}
<Dialog.Root> <Dialog.Root>
<Dialog.Trigger <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`} aria-label={`View ${name} artwork`}
><img ><img
{src} {src}
@@ -25,7 +33,7 @@
loading="lazy" loading="lazy"
width="112" width="112"
height="64" 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.Trigger
> >
<Dialog.Content class="sm:max-w-3xl" <Dialog.Content class="sm:max-w-3xl"

View File

@@ -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}

View File

@@ -6,6 +6,7 @@ import {
gameRanksProgress, gameRanksProgress,
hasReachedMaxLevel, hasReachedMaxLevel,
rankProgress, rankProgress,
stepRank,
weaponFinished, weaponFinished,
weaponLevelsProgress weaponLevelsProgress
} from './rank-progress'; } 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: 16, prestige: 2 }, () => true)).toBe(false);
expect(weaponFinished(rules, m27, { level: 17, prestige: 2 }, () => true)).toBe(true); 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', () => { it('bounds historic out-of-range values without mutating saved data', () => {
const rank = { level: 100, prestige: 999 }; const rank = { level: 100, prestige: 999 };
expect(rankProgress(weaponRankLimits(rules, m27), rank)).toEqual({ value: 51, total: 51 }); expect(rankProgress(weaponRankLimits(rules, m27), rank)).toEqual({ value: 51, total: 51 });

View File

@@ -15,6 +15,23 @@ export function rankProgress(limits: RankLimits, rank?: Rank | null): ProgressCo
return { value: prestige * limits.maxLevel + level, total }; 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( export function weaponLevelsProgress(
rules: MasteryRules, rules: MasteryRules,
weapons: readonly Weapon[], weapons: readonly Weapon[],

View File

@@ -2,6 +2,7 @@ import { error, fail } from '@sveltejs/kit';
import achievementArt from '../../../../../../data/cod-achievement-art.json'; import achievementArt from '../../../../../../data/cod-achievement-art.json';
const achievementImages: Record<string, string> = achievementArt; const achievementImages: Record<string, string> = achievementArt;
import { and, asc, eq, sql } from 'drizzle-orm'; import { and, asc, eq, sql } from 'drizzle-orm';
import type { AnyPgColumn } from 'drizzle-orm/pg-core';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { import {
trackerGames, trackerGames,
@@ -14,7 +15,8 @@ import {
} from '$lib/server/db/schema'; } from '$lib/server/db/schema';
import { boundedInteger } from '$lib/game-tracker/journal'; import { boundedInteger } from '$lib/game-tracker/journal';
import { appliesTo } from '$lib/game-tracker/mastery'; 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'; import type { Actions, PageServerLoad } from './$types';
async function findGame(slug: string) { 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 = { export const actions = {
adjustWeaponRank: async ({ request, locals, params }) => { // One + / − press (or a few batched presses) on the player rank or a weapon rank.
if (!locals.user) return fail(401, { message: 'Sign in to save weapon progress.' }); 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 game = await findGame(params.slug);
const form = await request.formData(); const form = await request.formData();
const itemId = boundedInteger(form.get('itemId'), 1, 2147483647); const rawDelta = String(form.get('delta') ?? '');
const field = form.get('field'); const delta = /^-?\d{1,3}$/.test(rawDelta) ? Number(rawDelta) : 0;
const delta = form.get('delta') === '1' ? 1 : form.get('delta') === '-1' ? -1 : null; const itemId = form.get('itemId') ? boundedInteger(form.get('itemId'), 1, 2147483647) : null;
if (!itemId || !['level', 'prestige'].includes(String(field)) || delta === null) if (!delta || Math.abs(delta) > 100 || (form.get('itemId') && !itemId))
return fail(400, { message: 'Invalid weapon adjustment.' }); 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 const [item] = await db
.select({ id: trackerItems.id, name: trackerItems.name, category: trackerItems.category }) .select({ id: trackerItems.id, name: trackerItems.name, category: trackerItems.category })
.from(trackerItems) .from(trackerItems)
@@ -137,52 +168,21 @@ export const actions = {
return fail(400, { return fail(400, {
message: 'This weapon does not have a weapon-rank progression in this game.' 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 const [rank] = await db
.insert(trackerWeaponRanks) .insert(trackerWeaponRanks)
.values({ .values({ userId: locals.user.id, itemId, ...stepRank(limits, null, delta) })
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
})
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [trackerWeaponRanks.userId, trackerWeaponRanks.itemId], target: [trackerWeaponRanks.userId, trackerWeaponRanks.itemId],
set: { set: {
...(field === 'level' ...steppedRank(trackerWeaponRanks.level, trackerWeaponRanks.prestige, limits, delta),
? { updatedAt
level: sql`greatest(1, least(${maxLevel}, ${trackerWeaponRanks.level} + ${delta}))`
} }
: {
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({ .returning({
itemId: trackerWeaponRanks.itemId, itemId: trackerWeaponRanks.itemId,
level: trackerWeaponRanks.level, level: trackerWeaponRanks.level,
prestige: trackerWeaponRanks.prestige prestige: trackerWeaponRanks.prestige
}); });
if (!rank) return fail(400, { message: 'Rank changed. Refresh before prestiging again.' });
return { rank }; return { rank };
}, },
saveRank: async ({ request, locals, params }) => { saveRank: async ({ request, locals, params }) => {

View File

@@ -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`; 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'; const cod = '/projects/game-tracker/call-of-duty/black-ops-3';
await page.goto(cod); 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('Current level', { exact: true }).fill('42');
await page.getByLabel('Prestige', { exact: true }).fill('3'); await page.getByLabel('Prestige', { exact: true }).fill('3');
await page.getByRole('button', { name: 'Save rank' }).click(); 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 expect(level).toHaveText(/LEVEL2/);
await card.getByRole('button', { name: `Level down ${weapon.name}` }).click(); await card.getByRole('button', { name: `Level down ${weapon.name}` }).click();
await expect(level).toHaveText(/LEVEL1/); 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).toContainText('Max level reached');
await expect(card.getByRole('checkbox', { name: /max level/ })).toHaveCount(0); await expect(card.getByRole('checkbox', { name: /max level/ })).toHaveCount(0);
expect( expect(
@@ -547,38 +552,28 @@ test('live weapon controls save immediately, infer max level from prestige, and
).toMatchObject({ level: 1, prestige: 1 }); ).toMatchObject({ level: 1, prestige: 1 });
const concurrent = await Promise.all( const concurrent = await Promise.all(
Array.from({ length: 4 }, () => Array.from({ length: 4 }, () =>
action(page.request, cod + '?/adjustWeaponRank', { action(page.request, cod + '?/stepRank', { itemId: String(weapon.id), delta: '1' })
itemId: String(weapon.id),
field: 'level',
delta: '1'
})
) )
); );
expect(concurrent.every((r) => r.status === 200)).toBe(true); expect(concurrent.every((r) => r.status === 200)).toBe(true);
await page.reload(); await page.reload();
await page.getByRole('button', { name: `${weapon.name} camos and milestones` }).click(); await page.getByRole('button', { name: `${weapon.name} camos and milestones` }).click();
await expect(level).toHaveText(/LEVEL5/); 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 card.getByRole('button', { name: `Level up ${weapon.name}` }).click();
await expect(card.getByRole('status')).toContainText(/fetch|save|network/i); await expect(card.getByRole('status')).toContainText(/fetch|save|network/i);
await expect(level).toHaveText(/LEVEL5/); await expect(level).toHaveText(/LEVEL5/);
await page.unroute('**/*adjustWeaponRank'); await page.unroute('**/*stepRank');
await card.getByRole('button', { name: `Level up ${weapon.name}` }).click(); await card.getByRole('button', { name: `Level up ${weapon.name}` }).click();
await expect(level).toHaveText(/LEVEL6/); await expect(level).toHaveText(/LEVEL6/);
for (const delta of ['999', '0', '1.5', ''])
expect( expect(
( (await action(page.request, cod + '?/stepRank', { itemId: String(weapon.id), delta })).status
await action(page.request, cod + '?/adjustWeaponRank', {
itemId: String(weapon.id),
field: 'level',
delta: '999'
})
).status
).toBe(400); ).toBe(400);
expect( 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), itemId: String(weapon.id),
field: 'level',
delta: '1' delta: '1'
}) })
).status ).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 }); const anonymous = await playwright.request.newContext({ baseURL: origin });
expect( expect(
( (
await action(anonymous, cod + '?/adjustWeaponRank', { await action(anonymous, cod + '?/stepRank', {
itemId: String(weapon.id), itemId: String(weapon.id),
field: 'level',
delta: '1' delta: '1'
}) })
).status ).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 }); const overall = page.getByRole('progressbar', { name: 'Game completion', exact: true });
await expect(levels).toHaveAttribute('aria-valuenow', '0'); await expect(levels).toHaveAttribute('aria-valuenow', '0');
await card.getByRole('button', { name: 'Level up M27', exact: true }).click(); await card.getByRole('button', { name: 'Level up M27', exact: true }).click();
await expect(card.getByRole('button', { name: 'Edit M27 level and prestige' })).toHaveText( const m27Rank = card.getByRole('button', { name: 'Edit M27 level and prestige' });
'LEVEL2/17' await expect(m27Rank).toHaveText('P0 2/17');
);
const first = Number(await levels.getAttribute('aria-valuenow')); const first = Number(await levels.getAttribute('aria-valuenow'));
expect(first).toBeGreaterThan(0); expect(first).toBeGreaterThan(0);
const before = Number(await overall.getAttribute('aria-valuenow')); 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 ).status
).toBe(400); ).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', { await action(page.request, cod + '?/saveRank', {
itemId: String(m27.id), itemId: String(m27.id),
level: '17', level: '17',
prestige: '0' prestige: '0'
}); });
// Presses from two devices at max level both count: level 17 → prestige 1 level 2.
const simultaneous = await Promise.all( const simultaneous = await Promise.all(
[1, 2].map(() => [1, 2].map(() =>
action(page.request, cod + '?/adjustWeaponRank', { action(page.request, cod + '?/stepRank', { itemId: String(m27.id), delta: '1' })
itemId: String(m27.id),
field: 'prestige',
delta: '1'
})
) )
); );
expect(simultaneous.filter((r) => r.status === 200)).toHaveLength(1); expect(simultaneous.every((r) => r.status === 200)).toBe(true);
expect( expect(
( (
await sql`select level,prestige from tracker_weapon_ranks where user_id=${userId} and item_id=${m27.id}` await sql`select level,prestige from tracker_weapon_ranks where user_id=${userId} and item_id=${m27.id}`
)[0] )[0]
).toMatchObject({ level: 1, prestige: 1 }); ).toMatchObject({ level: 2, prestige: 1 });
await page.reload(); 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', { await action(page.request, cod + '?/saveRank', {
itemId: String(m27.id), itemId: String(m27.id),
level: '17', 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 action(page.request, cod + '?/saveRank', { level: '42', prestige: '3' });
await page.reload(); 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(card.getByRole('button', { name: 'Level up M27' })).toBeDisabled();
await expect(page.getByRole('img', { name: 'Prestige 3 emblem' })).toBeVisible(); await expect(page.getByRole('img', { name: 'Prestige 3 emblem' })).toBeVisible();
const progress = await overall.getAttribute('aria-valuenow'); const progress = await overall.getAttribute('aria-valuenow');