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,6 +2,7 @@ import { error, fail } from '@sveltejs/kit';
|
||||
import achievementArt from '../../../../../../data/cod-achievement-art.json';
|
||||
const achievementImages: Record<string, string> = 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 }) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user