feat: build compact BO2 grind companion with cumulative ranks
Some checks failed
Test and deploy staging / staging (push) Failing after 2m50s

This commit is contained in:
2026-10-08 21:06:19 +00:00
parent c2ba8febb4
commit c2bc9050c2
39 changed files with 1499 additions and 447 deletions

View File

@@ -0,0 +1,34 @@
<script lang="ts">
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();
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">
{#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"
/>
{: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>

View File

@@ -2,7 +2,12 @@
import { base } from '$app/paths';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import artwork from '../../../data/cod-art.json';
let { game, name, icon }: { game: string; name: string; icon: string | null } = $props();
let {
game,
name,
icon,
compact = false
}: { game: string; name: string; icon: string | null; compact?: boolean } = $props();
const source = $derived(
artwork.find((a) => a.game === game && a.name.toLowerCase() === name.toLowerCase())
);
@@ -20,7 +25,7 @@
loading="lazy"
width="112"
height="64"
class="h-16 w-28 object-contain"
class={compact ? 'h-6 w-12 object-contain' : 'h-16 w-28 object-contain'}
/></Dialog.Trigger
>
<Dialog.Content class="sm:max-w-3xl"

View File

@@ -12,6 +12,7 @@
prestigeCap = 1000,
prestigeRequiresMaxLevel = false,
compact = false,
dense = false,
disabled,
onupdate,
onedit
@@ -24,6 +25,7 @@
prestigeCap?: number;
prestigeRequiresMaxLevel?: boolean;
compact?: boolean;
dense?: boolean;
disabled: boolean;
onupdate: (rank: { itemId: number; level: number; prestige: number }) => void;
onedit: () => void;
@@ -61,78 +63,117 @@
}
</script>
<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`}
{#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"
>
<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`}
<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
>
<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>
{: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

@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import catalog from '../../../data/call-of-duty.json';
import type { MasteryRules, Weapon } from './mastery';
import { playerRankLimits, weaponRankLimits } from './rank-limits';
import {
gameRanksProgress,
hasReachedMaxLevel,
rankProgress,
weaponFinished,
weaponLevelsProgress
} from './rank-progress';
const game = catalog.games.find((g) => g.slug === 'black-ops-2')!;
const rules = { ...game, classes: game.classes.map((c) => c.name) } as MasteryRules;
const m27: Weapon = { id: 1, name: 'M27', category: 'Assault Rifles', postLaunch: false };
const mp7: Weapon = { id: 2, name: 'MP7', category: 'Submachine Guns', postLaunch: false };
describe('BO2 rank progression', () => {
it('counts every level in each of three weapon runs', () => {
const limits = weaponRankLimits(rules, m27);
expect(rankProgress(limits)).toEqual({ value: 0, total: 51 });
for (let prestige = 0; prestige <= 2; prestige++) {
for (let level = 1; level <= 17; level++) {
expect(rankProgress(limits, { level, prestige })).toEqual({
value: prestige * 17 + level,
total: 51
});
}
}
});
it('weights the combined arsenal by levels and excludes unranked weapons', () => {
const rpg = { ...m27, id: 3, name: 'RPG', category: 'Launchers' };
const ranks = new Map([
[1, { level: 9, prestige: 1 }],
[2, { level: 8, prestige: 0 }]
]);
expect(weaponLevelsProgress(rules, [m27, mp7, rpg], ranks)).toEqual({ value: 34, total: 99 });
expect(gameRanksProgress(rules, [m27, mp7, rpg], ranks, { level: 20, prestige: 2 })).toEqual({
value: 164,
total: 704
});
});
it('uses class caps and individual exceptions', () => {
const expected: Record<string, number | null> = {
M27: 17,
MP7: 16,
'Vector K10': 16,
'Skorpion EVO': 16,
Peacekeeper: 16,
'DSR 50': 10,
Ballista: 11,
Crossbow: 6,
RPG: null,
'Combat Knife': null
};
for (const c of game.classes)
for (const name of [...c.weapons, ...('postLaunch' in c ? (c.postLaunch ?? []) : [])]) {
const limits = weaponRankLimits(rules, { name, category: c.name });
if (name in expected) expect(limits.maxLevel, name).toBe(expected[name]);
expect(limits.maxPrestige).toBe(2);
}
expect(playerRankLimits(rules).maxLevel).toBe(55);
expect(rankProgress(playerRankLimits(rules), { level: 55, prestige: 10 })).toEqual({
value: 605,
total: 605
});
});
it('keeps first-run attachment completion separate from all prestige runs', () => {
expect(hasReachedMaxLevel(rules, m27, { level: 17, prestige: 0 })).toBe(true);
expect(hasReachedMaxLevel(rules, m27, { level: 1, prestige: 1 })).toBe(true);
expect(weaponFinished(rules, m27, { level: 17, prestige: 0 }, () => 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);
});
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 });
expect(rank).toEqual({ level: 100, prestige: 999 });
expect(rankProgress(weaponRankLimits({ ...rules, rankCaps: undefined }, m27), rank)).toEqual({
value: 0,
total: 0
});
});
});

View File

@@ -0,0 +1,55 @@
import { appliesTo, type MasteryRules, type Weapon } from './mastery';
import { playerRankLimits, weaponRankLimits, type RankLimits } from './rank-limits';
import { sumProgress, type ProgressCount } from './progress';
export type Rank = { level: number; prestige: number };
// A 17-level weapon with two prestiges has three runs: 51 level units.
// Unrecorded ranks contribute zero. Never rewrite saved historical values here.
export function rankProgress(limits: RankLimits, rank?: Rank | null): ProgressCount {
if (!limits.rankable || limits.maxLevel === null) return { value: 0, total: 0 };
const total = limits.maxLevel * (limits.maxPrestige + 1);
if (!rank) return { value: 0, total };
const prestige = Math.max(0, Math.min(limits.maxPrestige, rank.prestige));
const level = Math.max(1, Math.min(limits.maxLevel, rank.level));
return { value: prestige * limits.maxLevel + level, total };
}
export function weaponLevelsProgress(
rules: MasteryRules,
weapons: readonly Weapon[],
ranks: ReadonlyMap<number, Rank>
) {
return sumProgress(weapons.map((w) => rankProgress(weaponRankLimits(rules, w), ranks.get(w.id))));
}
export function gameRanksProgress(
rules: MasteryRules,
weapons: readonly Weapon[],
ranks: ReadonlyMap<number, Rank>,
player?: Rank | null
) {
return sumProgress([
weaponLevelsProgress(rules, weapons, ranks),
rankProgress(playerRankLimits(rules), player)
]);
}
export function hasReachedMaxLevel(rules: MasteryRules, weapon: Weapon, rank?: Rank) {
const cap = weaponRankLimits(rules, weapon).maxLevel;
return !!rank && (rank.prestige > 0 || (cap !== null && rank.level >= cap));
}
// Keep a completed first-run attachment milestone distinct from finishing all prestiges.
export function weaponFinished(
rules: MasteryRules,
weapon: Weapon,
rank: Rank | undefined,
isDone: (id: number, milestone: string) => boolean
) {
const levels = rankProgress(weaponRankLimits(rules, weapon), rank);
return (
(levels.total === 0 || levels.value === levels.total) &&
rules.milestones.filter((m) => appliesTo(m, weapon)).every((m) => isDone(weapon.id, m.key))
);
}

View File

@@ -349,6 +349,7 @@
<footer
class="mt-16 border-t border-[var(--site-border)]"
class:tracker-footer={page.route.id === '/projects/game-tracker'}
class:cod-footer={page.route.id === '/projects/game-tracker/call-of-duty/[slug]'}
>
<div class="mx-auto max-w-6xl space-y-5 px-4 py-8 text-[var(--site-muted)] sm:px-6">
<div class="flex flex-wrap items-center justify-between gap-3 text-base">
@@ -382,6 +383,11 @@
</div>
<style>
@media (min-width: 1100px) and (min-height: 700px) {
.cod-footer {
display: none;
}
}
@media (min-width: 1024px) and (min-height: 650px) {
.tracker-footer {
margin-top: 0;

View File

@@ -12,6 +12,7 @@ import {
} from '$lib/server/db/schema';
import { loadPokemonProgress } from '$lib/server/tracker-progress';
import { challengeProgress, combinedProgress, sumProgress } from '$lib/game-tracker/progress';
import { gameRanksProgress, hasReachedMaxLevel } from '$lib/game-tracker/rank-progress';
import { summarizeGame } from '$lib/game-tracker/mastery';
import { chronologicalPokemon, pokemonRelease } from '$lib/game-tracker/game-art';
import type { PageServerLoad } from './$types';
@@ -53,13 +54,21 @@ async function callOfDutySummary(userId: string | undefined) {
: Promise.resolve([]),
userId
? db
.select({ itemId: trackerWeaponRanks.itemId, prestige: trackerWeaponRanks.prestige })
.select({
itemId: trackerWeaponRanks.itemId,
prestige: trackerWeaponRanks.prestige,
level: trackerWeaponRanks.level
})
.from(trackerWeaponRanks)
.where(eq(trackerWeaponRanks.userId, userId))
: Promise.resolve([]),
userId
? db
.select({ gameId: trackerRanks.gameId })
.select({
gameId: trackerRanks.gameId,
level: trackerRanks.level,
prestige: trackerRanks.prestige
})
.from(trackerRanks)
.where(eq(trackerRanks.userId, userId))
: Promise.resolve([]),
@@ -86,6 +95,11 @@ async function callOfDutySummary(userId: string | undefined) {
const byGame = Map.groupBy(items, (item) => item.gameId);
return gamesList.map((game) => {
const list = byGame.get(game.id) ?? [];
const weapons = list.filter((i) => i.kind === 'weapon');
const rankMap = new Map(ranks.map((r) => [r.itemId, r]));
for (const weapon of weapons)
if (hasReachedMaxLevel(game.rules, weapon, rankMap.get(weapon.id)))
done.add(`${weapon.id}:maxLevel`);
const summary = summarizeGame(
game.rules,
list.filter((item) => item.kind === 'weapon'),
@@ -98,13 +112,12 @@ async function callOfDutySummary(userId: string | undefined) {
challenges.filter((c) => c.gameId === game.id),
challengeRows
);
const ranksProgress = {
value:
ranks.filter((rank) =>
list.some((item) => item.kind === 'weapon' && item.id === rank.itemId)
).length + Number(playerRanks.some((rank) => rank.gameId === game.id)),
total: list.filter((item) => item.kind === 'weapon').length + 1
};
const ranksProgress = gameRanksProgress(
game.rules,
weapons,
rankMap,
playerRanks.find((r) => r.gameId === game.id)
);
return {
overall: combinedProgress([
weaponProgress,

View File

@@ -145,8 +145,10 @@ export const actions = {
.where(
and(eq(trackerWeaponRanks.userId, locals.user.id), eq(trackerWeaponRanks.itemId, itemId))
);
if (!current || current.level < maxLevel || current.prestige >= limits.maxPrestige)
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
@@ -169,13 +171,18 @@ export const actions = {
...(delta > 0 ? { level: 1 } : {})
}),
updatedAt: new Date()
}
},
setWhere:
field === 'prestige' && delta > 0 && limits.canPrestigeOnlyAtMaxLevel
? sql`${trackerWeaponRanks.level} >= ${maxLevel} and ${trackerWeaponRanks.prestige} < ${limits.maxPrestige}`
: undefined
})
.returning({
itemId: trackerWeaponRanks.itemId,
level: trackerWeaponRanks.level,
prestige: trackerWeaponRanks.prestige
});
if (!rank) return fail(400, { message: 'Rank changed. Refresh before prestiging again.' });
return { rank };
},
saveRank: async ({ request, locals, params }) => {

View File

@@ -1,8 +1,10 @@
import weaponArt from '../../../../../data/cod-art.json';
import rankArt from '../../../../../data/cod-rank-art.json';
import images from '../../../../../data/game-images.json';
import maps from '../../../../../data/pokemon-map-art.json';
export function load() {
const sources = new Map<string, { source: string; credit: string; count: number }>();
for (const image of images) {
for (const image of [...images, ...weaponArt, ...rankArt]) {
const existing = sources.get(image.source);
if (existing) existing.count++;
else sources.set(image.source, { source: image.source, credit: image.credit, count: 1 });
@@ -10,6 +12,6 @@ export function load() {
return {
sources: [...sources.values()],
maps: Object.values(maps).map(({ name, source, credit }) => ({ name, source, credit })),
total: images.length
total: images.length + weaponArt.length + rankArt.length
};
}