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); -
-
- -
- LEVEL{level} -
-
-
+ + {#if game === 'black-ops-2' && prestige > 0 && prestige <= 10} {master - {: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 @@