mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-11 03:13:56 +00:00
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:
1 parent
38fd5af60e
commit
434409443c
8 files changed
+317
-50
No files matched your search
+19
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user