diff --git a/src/lib/game-tracker/PlayerRankBadge.svelte b/src/lib/game-tracker/PlayerRankBadge.svelte
index 757a859..b9ad5a5 100644
--- a/src/lib/game-tracker/PlayerRankBadge.svelte
+++ b/src/lib/game-tracker/PlayerRankBadge.svelte
@@ -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);
-
-
-
+
+
{#if game === 'black-ops-2' && prestige > 0 && prestige <= 10}
- {:else}{/if}
-
- PRESTIGE{master ? 'Master' : prestige}
-
-
-
+ {:else}{/if}
+ PRESTIGE{master ? 'Master' : prestige}{#if prestigeCap !== null && !master}/{prestigeCap}{/if}
+
+
+
+ LEVEL{level}{#if levelCap !== null}/{levelCap}{/if}
+
+
+
+
diff --git a/src/lib/game-tracker/ProgressMeter.svelte b/src/lib/game-tracker/ProgressMeter.svelte
index f7bfa16..fc4575b 100644
--- a/src/lib/game-tracker/ProgressMeter.svelte
+++ b/src/lib/game-tracker/ProgressMeter.svelte
@@ -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 @@
};
-
-
- {label}
- {display}%{percent === 100 ? ' ✓' : ''}
-
-
- {#if !compact}
+{#if hero}
+
+
+ {label}
+ {display}%{percent === 100 ? ' ✓' : ''}
+
+
+
{detail ?? `${progress.value.toLocaleString()} / ${progress.total.toLocaleString()}`}
-
{/if}
-
+
+
+{:else}
+
+
+ {label}
+ {display}%{percent === 100 ? ' ✓' : ''}
+
+
+ {#if !compact}
+ {detail ?? `${progress.value.toLocaleString()} / ${progress.total.toLocaleString()}`}
+
{/if}
+
+{/if}
+
+
diff --git a/src/lib/game-tracker/RankStepper.svelte b/src/lib/game-tracker/RankStepper.svelte
new file mode 100644
index 0000000..2e1362b
--- /dev/null
+++ b/src/lib/game-tracker/RankStepper.svelte
@@ -0,0 +1,167 @@
+
+
+
+
+ {@render children?.()}
+
+
{error || status}
+
+
+
diff --git a/src/lib/game-tracker/WeaponArtwork.svelte b/src/lib/game-tracker/WeaponArtwork.svelte
index 04c81b1..c112748 100644
--- a/src/lib/game-tracker/WeaponArtwork.svelte
+++ b/src/lib/game-tracker/WeaponArtwork.svelte
@@ -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}
- 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;
- }
- }
-
-
-{#if dense}
-
-
-
-
{saving ? 'Saving…' : error || (saved ? 'Saved' : '')}
-
-{:else}
-
-
-
-
-
-
-
-
-
-
PRESTIGE{prestige}{#if prestigeCap < 1000}/{prestigeCap}{/if}
-
-
-
-
- {saving ? 'Saving…' : error || (saved ? 'Saved' : 'Tap the level to edit both values')}
-
-
-{/if}
diff --git a/src/lib/game-tracker/rank-progress.test.ts b/src/lib/game-tracker/rank-progress.test.ts
index 1f88c27..4a6cbd2 100644
--- a/src/lib/game-tracker/rank-progress.test.ts
+++ b/src/lib/game-tracker/rank-progress.test.ts
@@ -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 });
diff --git a/src/lib/game-tracker/rank-progress.ts b/src/lib/game-tracker/rank-progress.ts
index 9d659fd..ef4d09d 100644
--- a/src/lib/game-tracker/rank-progress.ts
+++ b/src/lib/game-tracker/rank-progress.ts
@@ -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[],
diff --git a/src/routes/projects/game-tracker/call-of-duty/[slug]/+page.server.ts b/src/routes/projects/game-tracker/call-of-duty/[slug]/+page.server.ts
index d0dc4cc..aa371da 100644
--- a/src/routes/projects/game-tracker/call-of-duty/[slug]/+page.server.ts
+++ b/src/routes/projects/game-tracker/call-of-duty/[slug]/+page.server.ts
@@ -2,6 +2,7 @@ import { error, fail } from '@sveltejs/kit';
import achievementArt from '../../../../../../data/cod-achievement-art.json';
const achievementImages: Record = achievementArt;
import { and, asc, eq, sql } from 'drizzle-orm';
+import type { AnyPgColumn } from 'drizzle-orm/pg-core';
import { db } from '$lib/server/db';
import {
trackerGames,
@@ -14,7 +15,8 @@ import {
} from '$lib/server/db/schema';
import { boundedInteger } from '$lib/game-tracker/journal';
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';
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 = {
- adjustWeaponRank: async ({ request, locals, params }) => {
- if (!locals.user) return fail(401, { message: 'Sign in to save weapon progress.' });
+ // One + / − press (or a few batched presses) on the player rank or a weapon rank.
+ 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 form = await request.formData();
- const itemId = boundedInteger(form.get('itemId'), 1, 2147483647);
- const field = form.get('field');
- const delta = form.get('delta') === '1' ? 1 : form.get('delta') === '-1' ? -1 : null;
- if (!itemId || !['level', 'prestige'].includes(String(field)) || delta === null)
- return fail(400, { message: 'Invalid weapon adjustment.' });
+ const rawDelta = String(form.get('delta') ?? '');
+ const delta = /^-?\d{1,3}$/.test(rawDelta) ? Number(rawDelta) : 0;
+ const itemId = form.get('itemId') ? boundedInteger(form.get('itemId'), 1, 2147483647) : null;
+ if (!delta || Math.abs(delta) > 100 || (form.get('itemId') && !itemId))
+ 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
.select({ id: trackerItems.id, name: trackerItems.name, category: trackerItems.category })
.from(trackerItems)
@@ -137,52 +168,21 @@ export const actions = {
return fail(400, {
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
.insert(trackerWeaponRanks)
- .values({
- 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
- })
+ .values({ userId: locals.user.id, itemId, ...stepRank(limits, null, delta) })
.onConflictDoUpdate({
target: [trackerWeaponRanks.userId, trackerWeaponRanks.itemId],
set: {
- ...(field === 'level'
- ? {
- 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
+ ...steppedRank(trackerWeaponRanks.level, trackerWeaponRanks.prestige, limits, delta),
+ updatedAt
+ }
})
.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 }) => {
diff --git a/src/routes/projects/game-tracker/call-of-duty/[slug]/+page.svelte b/src/routes/projects/game-tracker/call-of-duty/[slug]/+page.svelte
index 9f24e14..4c35140 100644
--- a/src/routes/projects/game-tracker/call-of-duty/[slug]/+page.svelte
+++ b/src/routes/projects/game-tracker/call-of-duty/[slug]/+page.svelte
@@ -1,5 +1,5 @@