feat: build compact BO2 grind companion with cumulative ranks
Some checks failed
Test and deploy staging / staging (push) Failing after 2m50s
Some checks failed
Test and deploy staging / staging (push) Failing after 2m50s
This commit is contained in:
34
src/lib/game-tracker/PlayerRankBadge.svelte
Normal file
34
src/lib/game-tracker/PlayerRankBadge.svelte
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
84
src/lib/game-tracker/rank-progress.test.ts
Normal file
84
src/lib/game-tracker/rank-progress.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
55
src/lib/game-tracker/rank-progress.ts
Normal file
55
src/lib/game-tracker/rank-progress.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user