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
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<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'}
|
||||
>
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{: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
|
||||
>
|
||||
</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,23 +39,89 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<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'}
|
||||
>{label}</span
|
||||
>
|
||||
<span class={`shrink-0 font-semibold tabular-nums ${compact ? 'text-xs' : 'text-sm'}`}
|
||||
>{display}%{percent === 100 ? ' ✓' : ''}</span
|
||||
>
|
||||
</div>
|
||||
<Progress
|
||||
value={percent}
|
||||
max={100}
|
||||
aria-label={label}
|
||||
aria-valuetext={`${display}%${detail ? ` · ${detail}` : ''}`}
|
||||
class={`h-1.5 ${colors[color]} [&_[data-slot=progress-indicator]]:duration-700 [&_[data-slot=progress-indicator]]:ease-out motion-reduce:[&_[data-slot=progress-indicator]]:transition-none`}
|
||||
/>
|
||||
{#if !compact}<p class="text-[10px] text-muted-foreground">
|
||||
{#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>{/if}
|
||||
</div>
|
||||
</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'}
|
||||
>{label}</span
|
||||
>
|
||||
<span class={`shrink-0 font-semibold tabular-nums ${compact ? 'text-xs' : 'text-sm'}`}
|
||||
>{display}%{percent === 100 ? ' ✓' : ''}</span
|
||||
>
|
||||
</div>
|
||||
<Progress
|
||||
value={percent}
|
||||
max={100}
|
||||
aria-label={label}
|
||||
aria-valuetext={`${display}%${detail ? ` · ${detail}` : ''}`}
|
||||
class={`h-1.5 ${colors[color]} [&_[data-slot=progress-indicator]]:duration-700 [&_[data-slot=progress-indicator]]:ease-out motion-reduce:[&_[data-slot=progress-indicator]]:transition-none`}
|
||||
/>
|
||||
{#if !compact}<p class="text-[10px] text-muted-foreground">
|
||||
{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[],
|
||||
|
||||
Reference in New Issue
Block a user