feat(scoring): add region-relative scoring ladder and per-level board crediting

Implement region-scaled scoring: base ladder (0-5 points) adjusts thresholds
proportionally to the picked region's bbox diagonal. Leaderboards credited
separately using their own region's ladder, preventing country-round guesses
from earning inflated district-board points. Score and per-level points
returned in gameResult.bands and gameResult.levels.
This commit is contained in:
tiennm99 committed 2026-08-31 22:07:41 +07:00
1 parent 38fd5af60e
commit 434409443c
8 files changed
+317 -50

No files matched your search

+19 -2
View File
@@ -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
+10 -2
View File
@@ -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
+22 -4
View File
@@ -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,
+53 -11
View File
@@ -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;
}
+93 -31
View File
@@ -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<Object>} 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<Object>} 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<Object>} 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<Object>} 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.
+47
View File
@@ -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', () => {
+35
View File
@@ -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 })
+38
View File
@@ -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) => {