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 { 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}
|
||||||
</div>
|
onclick={onedit}
|
||||||
</div>
|
role={onedit ? undefined : 'group'}
|
||||||
<div class="flex items-center gap-2">
|
>
|
||||||
|
<span class="stat">
|
||||||
{#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>
|
||||||
|
|||||||
@@ -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,7 +39,25 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-w-0 space-y-1" title={hint}>
|
{#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">
|
<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'}
|
||||||
>{label}</span
|
>{label}</span
|
||||||
@@ -55,4 +76,52 @@
|
|||||||
{#if !compact}<p class="text-[10px] text-muted-foreground">
|
{#if !compact}<p class="text-[10px] text-muted-foreground">
|
||||||
{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>
|
||||||
|
|||||||
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,
|
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"
|
||||||
|
|||||||
@@ -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,
|
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 });
|
||||||
|
|||||||
@@ -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[],
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
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`;
|
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');
|
||||||
|
|||||||
Reference in New Issue
Block a user