diff --git a/docs/features.md b/docs/features.md index 9dca705..efb256b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -44,13 +44,30 @@ using Turf.js ## Scoring System -Distance-based points (0-5 scale): +Distance-based points (0-5 scale), region-relative. The base ladder (used +as-is only by regions whose bbox diagonal is at or under the 10km reference — +about a third of the districts; every larger region stretches it): - **0-50m**: 5 points - **50m-100m**: 4 points - **100m-200m**: 3 points - **200m-500m**: 2 points - **500m-1km**: 1 point -- **1km+**: 0 points +- **beyond**: 0 points + +Playing a larger region stretches every threshold in proportion to the picked +region's bbox diagonal (reference: 10km, `bandsForBbox` in `src/lib/game.js`), +so a country round is not a guaranteed string of zeros. The ladder a round was +scored against is returned in `gameResult.bands` and shown on the result +dialog. Scores earned before this change stay on the boards under the old +absolute ladder. + +The headline score uses the picked region's ladder; the leaderboards do not. +Each board is credited from the raw distance against its own region's ladder +(`submitRoundScore` in `src/lib/leaderboard.js`): a 2km miss on a country round +earns country points on the Vietnam board and nothing on the district board. +Without this, a country round would buy district-board points at country +precision. Each level's added points are returned as `points` on its +`gameResult.levels` entry. ## Leaderboards - **Rollup fan-out**: one guess credits the district its panorama sat in, then diff --git a/docs/game-flow.md b/docs/game-flow.md index 28aa79f..833e08b 100644 --- a/docs/game-flow.md +++ b/docs/game-flow.md @@ -51,13 +51,21 @@ written, so a replay or a concurrent submit scores exactly once ### 7. Scoring System -Distance-based points (0-5 scale): +Distance-based points (0-5 scale). The headline score is graded against a +ladder scaled to the size of the region the player picked; base ladder +(exact only for regions at or under the 10km reference diagonal): - **0-50m**: 5 points - **50m-100m**: 4 points - **100m-200m**: 3 points - **200m-500m**: 2 points - **500m-1km**: 1 point -- **1km+**: 0 points +- **beyond**: 0 points + +A larger region multiplies every threshold by its bbox diagonal over the 10km +reference (see `bandsForBbox` in `src/lib/game.js`). Leaderboards are credited +separately: each board grades the raw distance against its own region's ladder +(`submitRoundScore` in `src/lib/leaderboard.js`), so the headline score and a +board's added points can differ. ### 8. Results Display - Show calculated distance between guess and actual location diff --git a/src/app/api/guess/route.js b/src/app/api/guess/route.js index f5135cd..f104fe3 100644 --- a/src/app/api/guess/route.js +++ b/src/app/api/guess/route.js @@ -1,8 +1,9 @@ import { NextResponse } from 'next/server'; -import { submitScore, submitDistanceRecord } from '../../../lib/leaderboard.js'; +import { submitRoundScore, submitDistanceRecord } from '../../../lib/leaderboard.js'; import { getGameSession, deleteGameSession } from '../../../lib/session.js'; -import { calculateDistance, calculateScore } from '../../../lib/game.js'; +import { calculateDistance, calculateScore, bandsForBbox, SCORE_BANDS } from '../../../lib/game.js'; import { publicRegion } from '../../../lib/region-request.js'; +import { getRegion, isRegion } from '../../../lib/regions.js'; export async function POST(request) { try { @@ -59,8 +60,18 @@ export async function POST(request) { numTargetLat, numTargetLng ); + // Score against the ladder stretched to the region the player CHOSE, not + // the district the panorama resolved to: picking the whole country means + // guessing over 331,000 km2, and the district-scale ladder would zero + // almost every honest guess. The picked region is not a secret, so this + // reveals nothing. + const pickedCode = session.pickedRegion ?? session.cityCode ?? null; + const scoreBands = pickedCode && isRegion(pickedCode) + ? bandsForBbox(getRegion(pickedCode).bbox) + : SCORE_BANDS; + // Calculate score based on distance (server-side) - const finalScore = calculateScore(distance); + const finalScore = calculateScore(distance, scoreBands); // The region the panorama was actually in, resolved server-side when the // round was created. Never read from the request: a client that could name @@ -87,7 +98,11 @@ export async function POST(request) { }, { status: 400 }); } - const leaderboardResult = await submitScore(username.trim(), finalScore, scoringRegion); + // Boards are credited per level from the raw distance, each against its + // own regional ladder -- NOT with finalScore, which is graded on the + // picked region's ladder and would let a country round buy district-board + // points at country precision. + const leaderboardResult = await submitRoundScore(username.trim(), distance, scoringRegion); const distanceResult = await submitDistanceRecord(username.trim(), distance, scoringRegion); // Log the submission for anti-cheat monitoring @@ -107,6 +122,9 @@ export async function POST(request) { gameResult: { distance, score: finalScore, + // The ladder this round was scored against, so the reveal can show how + // close the guess needed to be for each point value. + bands: scoreBands, // One entry per level credited, outermost last. The client renders // these directly rather than a fixed global/city pair. levels: leaderboardResult.levels, diff --git a/src/lib/game.js b/src/lib/game.js index 6759a19..37fa5e1 100644 --- a/src/lib/game.js +++ b/src/lib/game.js @@ -17,20 +17,62 @@ export function calculateDistance(lat1, lon1, lat2, lon2) { } } -// Distance ceiling in meters -> points. The single scoring ladder; anything +// Distance ceiling in meters -> points. The single BASE ladder; anything // that bands a result (colors, labels, the scoring table on the home page) -// should derive from calculateScore rather than re-typing these thresholds. -export const SCORE_BANDS = [ - { maxMeters: 50, points: 5 }, - { maxMeters: 100, points: 4 }, - { maxMeters: 200, points: 3 }, - { maxMeters: 500, points: 2 }, - { maxMeters: 1000, points: 1 }, -]; +// should derive from it or from calculateScore rather than re-typing the +// thresholds. +// +// These are the DISTRICT-scale bands. A round played over a province or the +// whole country is scored against the same ladder stretched by the region's +// size -- see bandsForBbox -- because 1km is a bullseye across 331,000 km2 +// and a guaranteed miss would make every country round score zero. +// Frozen because bandsForBbox hands this exact array out by identity, on the +// server and in client renders alike: one in-place sort or mutation anywhere +// would silently rewrite the base ladder for the whole process. +export const SCORE_BANDS = Object.freeze([ + Object.freeze({ maxMeters: 50, points: 5 }), + Object.freeze({ maxMeters: 100, points: 4 }), + Object.freeze({ maxMeters: 200, points: 3 }), + Object.freeze({ maxMeters: 500, points: 2 }), + Object.freeze({ maxMeters: 1000, points: 1 }), +]); + +// The bbox diagonal of a typical district. A region this size or smaller keeps +// the base ladder unchanged; larger regions stretch it proportionally. +export const REFERENCE_DIAGONAL_METERS = 10_000; + +/** + * The scoring ladder stretched to a region of the given size. + * @param {number} diagonalMeters The region's bbox diagonal in meters. + * @returns {{maxMeters: number, points: number}[]} Scaled bands. + */ +export function bandsForDiagonal(diagonalMeters) { + // A non-numeric diagonal falls back to the base ladder rather than + // producing NaN thresholds that serialize to null. + const factor = Number.isFinite(diagonalMeters) + ? Math.max(1, diagonalMeters / REFERENCE_DIAGONAL_METERS) + : 1; + return SCORE_BANDS.map((band) => ({ + maxMeters: Math.round(band.maxMeters * factor), + points: band.points, + })); +} + +/** + * The scoring ladder for a region, from its bbox. + * @param {number[]|null|undefined} bbox [west, south, east, north], or absent. + * @returns {{maxMeters: number, points: number}[]} Scaled bands; the base + * district ladder when the region has no bbox. + */ +export function bandsForBbox(bbox) { + if (!bbox) return SCORE_BANDS; + const diagonal = calculateDistance(bbox[1], bbox[0], bbox[3], bbox[2]); + return bandsForDiagonal(diagonal); +} // Calculate score based on distance (0-5 points scale) -export function calculateScore(distance) { - const band = SCORE_BANDS.find((entry) => distance <= entry.maxMeters); +export function calculateScore(distance, bands = SCORE_BANDS) { + const band = bands.find((entry) => distance <= entry.maxMeters); return band ? band.points : 0; } diff --git a/src/lib/leaderboard.js b/src/lib/leaderboard.js index 0a4f358..1801738 100644 --- a/src/lib/leaderboard.js +++ b/src/lib/leaderboard.js @@ -8,6 +8,7 @@ import { zRemRangeByRank, } from './upstash.js'; import { ancestorsOf, getRegion, isRegion, COUNTRY_CODE } from './regions.js'; +import { calculateScore, bandsForBbox } from './game.js'; // Leaderboard logical key constants (prefix is applied inside the adapter). // @@ -130,14 +131,14 @@ export async function getLeaderboard(regionCode = null, limit = 100, type = 'sco * Add a score to one region's board and return the new total and rank. * @param {Object} h Upstash handle. * @param {string} regionCode Region code. - * @param {number} score Points to add. + * @param {number} points Points to add at this level. * @param {string} username Player. * @returns {Promise} Level result. */ -async function creditScore(h, regionCode, score, username) { +async function creditScore(h, regionCode, points, username) { const key = getRegionLeaderboardKey(regionCode); const existing = await zScore(h, key, username); - const total = (existing || 0) + score; + const total = (existing || 0) + points; await zAdd(h, key, total, username); // Trim to the top MAX_LEADERBOARD_SIZE. The set is ascending, so drop the @@ -153,6 +154,8 @@ async function creditScore(h, regionCode, score, username) { code: regionCode, name: getRegion(regionCode).name, username, + // What this round added at this level. + points, score: trimmed ? null : Number(total), rank: trimmed ? null : rank + 1, trimmed, @@ -160,12 +163,49 @@ async function creditScore(h, regionCode, score, username) { } /** - * Submit a score to a region and every region above it. + * Credit every level above a region, each by its own point value. + * @param {Object} h Upstash handle. + * @param {string} username Trimmed player name. + * @param {string} regionCode Leaf region the panorama was in. + * @param {Function} pointsFor Region code -> points to add at that level. + * @returns {Promise} Per-level results with named aliases. + */ +async function fanOutScore(h, username, regionCode, pointsFor) { + // In parallel: the levels are independent keys and no level reads another's + // state, so serialising them would add two round trips of latency to every + // guess for nothing. The four calls WITHIN a level stay ordered. + const levels = await Promise.all( + ancestorsOf(regionCode).map((code) => creditScore(h, code, pointsFor(code), username)) + ); + + // Named aliases alongside the array: the chain is district -> province -> + // country for a leaf, but only province -> country when a panorama fell + // outside every district polygon, so callers cannot index by position. + const byLevel = (level) => + levels.find((entry) => getRegion(entry.code).level === level) ?? null; + + return { + success: true, + levels, + district: byLevel('district'), + province: byLevel('province'), + global: byLevel('country'), + // `city` is the pre-tree name for the province level. Kept so /api/guess + // keeps reporting a rank until the API surface moves to `levels`. + city: byLevel('province'), + }; +} + +/** + * Submit a known point value to a region and every region above it. * - * Fans out over the ancestor chain, so a guess in District 7 credits the - * district, Ho Chi Minh, and Vietnam by the same amount. + * Every level is credited by the same amount. NOT for round scoring: the game + * route goes through submitRoundScore, which grades each board by its own + * regional ladder -- a flat fan-out here is exactly the country-round + * asymmetry that change removed. This primitive exists for tests and manual + * backfills that already hold a per-board point value. * @param {string} username Player username. - * @param {number} score Score achieved (0-5). + * @param {number} score Points to add (0-5). * @param {string} regionCode Region the panorama was in. * @returns {Promise} Per-level results. */ @@ -178,32 +218,11 @@ export async function submitScore(username, score, regionCode) { } requireRegion(regionCode); - const trimmedUsername = username.trim(); const numScore = Number(score); - - // In parallel: the levels are independent keys and no level reads another's - // state, so serialising them would add two round trips of latency to every - // guess for nothing. The four calls WITHIN a level stay ordered. - const levels = await Promise.all( - ancestorsOf(regionCode).map((code) => creditScore(h, code, numScore, trimmedUsername)) - ); - - // Named aliases alongside the array: the chain is district -> province -> - // country for a leaf, but only province -> country when a panorama fell - // outside every district polygon, so callers cannot index by position. - const byLevel = (level) => - levels.find((entry) => getRegion(entry.code).level === level) ?? null; - + const result = await fanOutScore(h, username.trim(), regionCode, () => numScore); return { - success: true, - levels, - district: byLevel('district'), - province: byLevel('province'), - global: byLevel('country'), - // `city` is the pre-tree name for the province level. Kept so /api/guess - // keeps reporting a rank until the API surface moves to `levels`. - city: byLevel('province'), - message: `Score added at ${levels.length} levels (+${numScore})`, + ...result, + message: `Score added at ${result.levels.length} levels (+${numScore})`, }; } catch (error) { console.error('Error submitting score:', error); @@ -211,6 +230,49 @@ export async function submitScore(username, score, regionCode) { } } +/** + * Score one round's distance onto every board above a region, each board by + * its own regional ladder. + * + * A 2km miss is a poor district guess but an excellent country one, so each + * level converts the distance with its own bbox-scaled bands: the district + * board only pays for district precision, however wide a region the player + * picked. This is what keeps every board's points meaning one thing. + * @param {string} username Player username. + * @param {number} distance Distance achieved in metres. + * @param {string} regionCode Region the panorama was in. + * @returns {Promise} Per-level results, each with the points added. + */ +export async function submitRoundScore(username, distance, regionCode) { + try { + const h = getUpstash(); + + if (!username || distance === undefined || !regionCode) { + throw new Error('Missing required fields: username, distance, regionCode'); + } + requireRegion(regionCode); + + // Rejected, not coerced: Number(null) and Number('') are 0, and a + // 0-metre distance is a maximum-score fan-out to every board. + if (typeof distance !== 'number' || !Number.isFinite(distance) || distance < 0) { + throw new Error(`Invalid distance: ${distance}`); + } + const numDistance = distance; + const result = await fanOutScore(h, username.trim(), regionCode, (code) => + calculateScore(numDistance, bandsForBbox(getRegion(code).bbox)) + ); + return { + ...result, + message: `Score added at ${result.levels.length} levels (${result.levels + .map((level) => `+${level.points}`) + .join(', ')})`, + }; + } catch (error) { + console.error('Error submitting round score:', error); + throw new Error(error.message || 'Failed to submit round score'); + } +} + /** * Add one distance record to a region's board. * @param {Object} h Upstash handle. diff --git a/tests/game.test.js b/tests/game.test.js index 7764eab..7ed4248 100644 --- a/tests/game.test.js +++ b/tests/game.test.js @@ -1,9 +1,13 @@ import { describe, it, expect } from 'vitest'; import { + SCORE_BANDS, + bandsForBbox, + bandsForDiagonal, calculateDistance, calculateScore, formatDistance, } from '../src/lib/game.js'; +import { getRegion } from '../src/lib/regions.js'; describe('calculateScore', () => { // The bands are the whole scoring rule, so each boundary is pinned on both @@ -24,6 +28,49 @@ describe('calculateScore', () => { ])('scores %dm as %d', (distance, expected) => { expect(calculateScore(distance)).toBe(expected); }); + + it('scores against a supplied ladder', () => { + const doubled = bandsForDiagonal(20_000); + expect(calculateScore(150, doubled)).toBe(4); + expect(calculateScore(150)).toBe(3); + }); +}); + +describe('bandsForDiagonal', () => { + it('keeps the base ladder for a district-sized region or smaller', () => { + expect(bandsForDiagonal(5_000)).toEqual(SCORE_BANDS); + expect(bandsForDiagonal(10_000)).toEqual(SCORE_BANDS); + }); + + it('stretches thresholds proportionally, preserving points', () => { + expect(bandsForDiagonal(20_000)).toEqual([ + { maxMeters: 100, points: 5 }, + { maxMeters: 200, points: 4 }, + { maxMeters: 400, points: 3 }, + { maxMeters: 1000, points: 2 }, + { maxMeters: 2000, points: 1 }, + ]); + }); +}); + +describe('bandsForBbox', () => { + it('falls back to the base ladder without a bbox', () => { + expect(bandsForBbox(null)).toBe(SCORE_BANDS); + expect(bandsForBbox(undefined)).toBe(SCORE_BANDS); + }); + + it('widens the ladder for the whole country', () => { + // A 1km miss must not be a zero across 331,000 km2 -- that is the whole + // point of scaling. The exact figure tracks the generated bbox, so pin + // the property, not the number. + const bands = bandsForBbox(getRegion('VN').bbox); + expect(bands[0].maxMeters).toBeGreaterThan(SCORE_BANDS[0].maxMeters * 50); + expect(bands.map((band) => band.points)).toEqual([5, 4, 3, 2, 1]); + // Still an ordered ladder. + for (let i = 1; i < bands.length; i += 1) { + expect(bands[i].maxMeters).toBeGreaterThan(bands[i - 1].maxMeters); + } + }); }); describe('calculateDistance', () => { diff --git a/tests/guess-route.test.js b/tests/guess-route.test.js index 911fcf0..77af332 100644 --- a/tests/guess-route.test.js +++ b/tests/guess-route.test.js @@ -7,6 +7,8 @@ vi.mock('@upstash/redis', async (importOriginal) => { import { POST } from '../src/app/api/guess/route.js'; import { storeGameSession } from '../src/lib/session.js'; import { getLeaderboard } from '../src/lib/leaderboard.js'; +import { bandsForBbox, calculateDistance, calculateScore } from '../src/lib/game.js'; +import { getRegion } from '../src/lib/regions.js'; import { resetStore, storedKeys } from './redis-harness.js'; // Scoring reads the region from the session and nowhere else. This is the @@ -135,6 +137,39 @@ describe('POST /api/guess', () => { expect(await getLeaderboard('TPHCM-Q7', 100, 'distance')).toHaveLength(1); }); + it('scores against the ladder of the PICKED region, not the district', async () => { + // The session was created for a province round, so the district-scale + // ladder does not apply: a miss of a couple of kilometres across a whole + // province is a good guess, not a zero. + await seedSession('s7'); + const guessLat = HCMC.lat + 0.02; // roughly 2.2km north + const body = await ( + await guess({ username: 'mai', sessionId: 's7', guessLat, guessLng: HCMC.lng }) + ).json(); + + const expectedBands = bandsForBbox(getRegion('TPHCM').bbox); + const distance = calculateDistance(guessLat, HCMC.lng, HCMC.lat, HCMC.lng); + expect(body.gameResult.bands).toEqual(expectedBands); + expect(body.gameResult.score).toBe(calculateScore(distance, expectedBands)); + // The property the change exists for: the district ladder would zero this. + expect(body.gameResult.score).toBeGreaterThan(0); + expect(calculateScore(distance)).toBe(0); + + // Each BOARD is credited by its own ladder, not the picked region's: the + // 2.2km miss earns country points on the country board and nothing on the + // district board, so a wide round cannot buy narrow-board points. + const districtPoints = calculateScore(distance, bandsForBbox(getRegion('TPHCM-Q7').bbox)); + const countryPoints = calculateScore(distance, bandsForBbox(getRegion('VN').bbox)); + expect(countryPoints).toBeGreaterThan(districtPoints); + const perLevel = Object.fromEntries( + body.gameResult.levels.map((level) => [level.code, level.points]) + ); + expect(perLevel['TPHCM-Q7']).toBe(districtPoints); + expect(perLevel.VN).toBe(countryPoints); + expect((await getLeaderboard('VN'))[0].score).toBe(countryPoints); + expect((await getLeaderboard('TPHCM-Q7'))[0].score).toBe(districtPoints); + }); + it('rejects an expired or unknown session', async () => { const body = await ( await guess({ username: 'mai', sessionId: 'gone', guessLat: 10, guessLng: 106 }) diff --git a/tests/leaderboard.test.js b/tests/leaderboard.test.js index ba36a62..407e73a 100644 --- a/tests/leaderboard.test.js +++ b/tests/leaderboard.test.js @@ -7,6 +7,7 @@ vi.mock('@upstash/redis', async (importOriginal) => { import { getLeaderboard, submitScore, + submitRoundScore, submitDistanceRecord, } from '../src/lib/leaderboard.js'; import { resetStore, storedKeys } from './redis-harness.js'; @@ -244,6 +245,42 @@ describe('fan-out up the region tree', () => { expect(keys).not.toContain('vngeoguessr:leaderboard:city:vn'); }); + it('scores a round per level, each board by its own ladder', async () => { + // 2.2km off in District 7: nothing on the district board, full points on + // the country board. One flat number at every level is exactly the + // asymmetry submitRoundScore exists to remove. + const result = await submitRoundScore('mai', 2200, 'TPHCM-Q7'); + + // Literal expectations, not a recomputation through bandsForBbox -- that + // would repeat the production expression and could never fail. These pin + // the generated tree's actual ladders; a boundary rebuild that moves them + // SHOULD fail here and be re-pinned deliberately. + expect(result.levels.map((l) => [l.code, l.points])).toEqual([ + ['TPHCM-Q7', 0], + ['TPHCM', 2], + ['VN', 5], + ]); + expect(result.message).toBe('Score added at 3 levels (+0, +2, +5)'); + expect((await getLeaderboard('VN'))[0].score).toBe(5); + expect((await getLeaderboard('TPHCM-Q7'))[0].score).toBe(0); + }); + + it('scores a perfect round at full points on every level', async () => { + const result = await submitRoundScore('mai', 0, 'TPHCM-Q7'); + expect(result.levels.map((l) => l.points)).toEqual([5, 5, 5]); + }); + + it.each([null, '', 'abc', -1, Infinity])( + 'rejects %j as a distance instead of paying full points for it', + async (distance) => { + // Number(null) and Number('') are 0, and 0 metres is a maximum-score + // fan-out -- absent input must throw, not top every board. + await expect(submitRoundScore('mai', distance, 'TPHCM-Q7')).rejects.toThrow( + /Missing required fields|Invalid distance/ + ); + } + ); + it('fans distance records out with one shared id', async () => { const result = await submitDistanceRecord('mai', 250, 'TPHCM-Q7'); expect(result.levels.map((l) => l.code)).toEqual(['TPHCM-Q7', 'TPHCM', 'VN']); @@ -263,6 +300,7 @@ describe('region validation', () => { it.each([ ['submitScore', () => submitScore('mai', 3, 'NOPE')], + ['submitRoundScore', () => submitRoundScore('mai', 100, 'NOPE')], ['submitDistanceRecord', () => submitDistanceRecord('mai', 100, 'NOPE')], ['getLeaderboard', () => getLeaderboard('NOPE')], ])('%s rejects an unknown region', async (_name, call) => {