diff --git a/backend/migrations/002_token_rewards.sql b/backend/migrations/002_token_rewards.sql new file mode 100644 index 0000000..5cce2f0 --- /dev/null +++ b/backend/migrations/002_token_rewards.sql @@ -0,0 +1,41 @@ +-- 002_token_rewards.sql +-- Persistent record of SNAKE tokens minted to players. +-- +-- T07 distributes a flat amount per claimed score (placeholder); T08 swaps +-- the conversion for a tiered/multiplier-driven calculation but reuses this +-- table unchanged. The `tier` column is a free-form label so T08 can store +-- 'bronze' / 'silver' / 'gold' / 'legendary' without a schema migration. +-- +-- Idempotency: `score_id` carries a UNIQUE constraint so the second claim +-- against the same score returns the existing row instead of inserting a +-- duplicate. Reward reasons unrelated to a single score (top-N bonuses) set +-- `score_id = NULL` and are not constrained by it. + +BEGIN; + +CREATE TABLE IF NOT EXISTS token_rewards ( + id BIGSERIAL PRIMARY KEY, + player_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- Nullable: leaderboard-bonus rewards have no single backing score. + score_id BIGINT REFERENCES scores(id) ON DELETE SET NULL, + -- SNAKE amount in nano-units (1 SNAKE = 1_000_000_000 nano). BIGINT so + -- huge legendary multipliers don't overflow a 32-bit integer. + amount_nano BIGINT NOT NULL CHECK (amount_nano >= 0), + reason TEXT NOT NULL CHECK (reason IN ('score','top1','top3','top10')), + -- Conversion-tier label. T07 writes 'flat'; T08 will overwrite with + -- bronze/silver/gold/legendary. Kept as TEXT so the set is open-ended. + tier TEXT NOT NULL DEFAULT 'flat', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Idempotent claim: at most one 'score' reward per scores.id row. Bonuses +-- (where score_id IS NULL) are not constrained by this index. +CREATE UNIQUE INDEX IF NOT EXISTS token_rewards_score_uniq + ON token_rewards (score_id) + WHERE score_id IS NOT NULL; + +-- "All my rewards" lookup path. +CREATE INDEX IF NOT EXISTS token_rewards_player_created_idx + ON token_rewards (player_id, created_at DESC); + +COMMIT; diff --git a/backend/package.json b/backend/package.json index e3c5ffa..d3caac4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,18 +8,22 @@ "dev": "tsx watch src/server.ts", "build": "tsc -p tsconfig.json", "start": "node dist/server.js", - "test": "node --test --import tsx src/**/*.test.ts", + "test": "node --test --import tsx 'src/**/*.test.ts'", "typecheck": "tsc --noEmit" }, "dependencies": { "@snake/shared": "*", "express": "^4.21.1", - "pg": "^8.13.1" + "pg": "^8.13.1", + "zod": "^3.23.8" }, "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^20.16.10", "@types/pg": "^8.11.10", + "@types/supertest": "^6.0.2", + "pg-mem": "^3.0.4", + "supertest": "^7.0.0", "tsx": "^4.19.1", "typescript": "^5.5.4" } diff --git a/backend/src/__tests__/conversion.test.ts b/backend/src/__tests__/conversion.test.ts new file mode 100644 index 0000000..30f3b49 --- /dev/null +++ b/backend/src/__tests__/conversion.test.ts @@ -0,0 +1,188 @@ +/** + * Unit tests for the score → SNAKE conversion module (T08). + * + * Pure-function tests — no DB, no HTTP. Covers each tier boundary, each + * position-bonus tier, and the env-loader fall-through behaviour. + */ + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { SNAKE_NANO_PER_TOKEN } from '@snake/shared' +import { + DEFAULT_CONVERSION_CONFIG, + computeReward, + loadConversionConfig, + type ConversionConfig, +} from '../rewards/conversion.js' + +// ---------- Tier boundaries ------------------------------------------------ + +test('tier: score 0 → bronze 0 SNAKE', () => { + const r = computeReward({ score: 0 }) + assert.equal(r.tierLabel, 'bronze') + assert.equal(r.totalAmount, 0) + assert.equal(r.amountNano, '0') +}) + +test('tier: score 99 → bronze ×1 = 99 SNAKE', () => { + const r = computeReward({ score: 99 }) + assert.equal(r.tierLabel, 'bronze') + assert.equal(r.baseAmount, 99) + assert.equal(r.tierBonus, 0) // 1× tier — no uplift over the raw score + assert.equal(r.totalAmount, 99) +}) + +test('tier: score 100 → silver ×1.5 = 150 SNAKE (boundary lower)', () => { + const r = computeReward({ score: 100 }) + assert.equal(r.tierLabel, 'silver') + assert.equal(r.baseAmount, 150) + assert.equal(r.tierBonus, 50) +}) + +test('tier: score 499 → silver ×1.5 = 748 SNAKE (boundary upper)', () => { + const r = computeReward({ score: 499 }) + assert.equal(r.tierLabel, 'silver') + assert.equal(r.baseAmount, Math.floor(499 * 1.5)) +}) + +test('tier: score 500 → gold ×2 = 1000 SNAKE (boundary)', () => { + const r = computeReward({ score: 500 }) + assert.equal(r.tierLabel, 'gold') + assert.equal(r.baseAmount, 1000) +}) + +test('tier: score 1999 → gold ×2 = 3998 SNAKE', () => { + const r = computeReward({ score: 1999 }) + assert.equal(r.tierLabel, 'gold') + assert.equal(r.baseAmount, 3998) +}) + +test('tier: score 2000 → legendary ×3 = 6000 SNAKE (boundary)', () => { + const r = computeReward({ score: 2000 }) + assert.equal(r.tierLabel, 'legendary') + assert.equal(r.baseAmount, 6000) +}) + +test('tier: huge score stays in legendary tier', () => { + const r = computeReward({ score: 1_000_000 }) + assert.equal(r.tierLabel, 'legendary') + assert.equal(r.baseAmount, 3_000_000) +}) + +test('tier: negative / NaN scores collapse to 0 / bronze', () => { + assert.equal(computeReward({ score: -50 }).totalAmount, 0) + assert.equal(computeReward({ score: Number.NaN }).totalAmount, 0) + assert.equal(computeReward({ score: Number.POSITIVE_INFINITY }).totalAmount, 0) +}) + +// ---------- Position bonuses ----------------------------------------------- + +test('position bonus: rank #1 → +100 SNAKE & reason "top1"', () => { + const r = computeReward({ score: 50, leaderboardPosition: 1 }) + assert.equal(r.positionBonus, 100) + assert.equal(r.totalAmount, 150) // 50 base + 100 bonus + assert.equal(r.reason, 'top1') +}) + +test('position bonus: rank #2 → +50 SNAKE & reason "top3"', () => { + const r = computeReward({ score: 50, leaderboardPosition: 2 }) + assert.equal(r.positionBonus, 50) + assert.equal(r.reason, 'top3') +}) + +test('position bonus: rank #3 → +50 SNAKE & reason "top3" (boundary)', () => { + const r = computeReward({ score: 50, leaderboardPosition: 3 }) + assert.equal(r.positionBonus, 50) + assert.equal(r.reason, 'top3') +}) + +test('position bonus: rank #4 → +25 SNAKE & reason "top10"', () => { + const r = computeReward({ score: 50, leaderboardPosition: 4 }) + assert.equal(r.positionBonus, 25) + assert.equal(r.reason, 'top10') +}) + +test('position bonus: rank #10 → +25 SNAKE (boundary)', () => { + const r = computeReward({ score: 50, leaderboardPosition: 10 }) + assert.equal(r.positionBonus, 25) + assert.equal(r.reason, 'top10') +}) + +test('position bonus: rank #11 → no bonus & reason "score"', () => { + const r = computeReward({ score: 50, leaderboardPosition: 11 }) + assert.equal(r.positionBonus, 0) + assert.equal(r.reason, 'score') +}) + +test('position bonus: undefined position → no bonus & reason "score"', () => { + const r = computeReward({ score: 50 }) + assert.equal(r.positionBonus, 0) + assert.equal(r.reason, 'score') +}) + +// ---------- Combined output shape ----------------------------------------- + +test('breakdown: includes tier line and (optional) position line', () => { + const noBonus = computeReward({ score: 99 }) + assert.equal(noBonus.breakdown.length, 1) + assert.match(noBonus.breakdown[0]!.label, /bronze/) + + const withBonus = computeReward({ score: 99, leaderboardPosition: 1 }) + assert.equal(withBonus.breakdown.length, 2) + assert.match(withBonus.breakdown[1]!.label, /position #1/) + assert.equal(withBonus.breakdown[1]!.snake, 100) +}) + +test('amountNano = totalAmount × 1e9', () => { + const r = computeReward({ score: 500, leaderboardPosition: 1 }) // 1000 + 100 = 1100 + assert.equal(r.totalAmount, 1100) + assert.equal(r.amountNano, String(BigInt(1100) * BigInt(SNAKE_NANO_PER_TOKEN))) +}) + +// ---------- Custom config -------------------------------------------------- + +test('custom config: replaces defaults wholesale', () => { + const cfg: ConversionConfig = { + tiers: [ + { label: 'flat', minScore: 0, multiplier: 1 }, + { label: 'epic', minScore: 1000, multiplier: 10 }, + ], + positionBonuses: [{ maxPosition: 1, snake: 5, reason: 'top1' }], + leaderboardBonuses: [{ position: 1, snake: 5 }], + } + const r = computeReward({ score: 1500, leaderboardPosition: 2, config: cfg }) + assert.equal(r.tierLabel, 'epic') + assert.equal(r.baseAmount, 15000) + assert.equal(r.positionBonus, 0) // rank 2 misses the only bonus tier + assert.equal(r.totalAmount, 15000) +}) + +// ---------- loadConversionConfig ------------------------------------------ + +test('loadConversionConfig: empty env returns defaults (same reference)', () => { + const cfg = loadConversionConfig({} as NodeJS.ProcessEnv) + assert.equal(cfg, DEFAULT_CONVERSION_CONFIG) +}) + +test('loadConversionConfig: partial JSON merges per top-level key', () => { + const raw = JSON.stringify({ + positionBonuses: [{ maxPosition: 1, snake: 9999, reason: 'top1' }], + }) + const cfg = loadConversionConfig({ SNAKE_REWARD_CONFIG_JSON: raw } as NodeJS.ProcessEnv) + assert.equal(cfg.positionBonuses[0]!.snake, 9999) + // Tiers untouched + assert.equal(cfg.tiers, DEFAULT_CONVERSION_CONFIG.tiers) +}) + +test('loadConversionConfig: malformed JSON falls back to defaults', () => { + const cfg = loadConversionConfig({ + SNAKE_REWARD_CONFIG_JSON: 'not-json', + } as NodeJS.ProcessEnv) + assert.equal(cfg, DEFAULT_CONVERSION_CONFIG) +}) + +test('loadConversionConfig: invalid tier shape falls back to defaults', () => { + const raw = JSON.stringify({ tiers: [] }) // empty tiers fails validation + const cfg = loadConversionConfig({ SNAKE_REWARD_CONFIG_JSON: raw } as NodeJS.ProcessEnv) + assert.equal(cfg, DEFAULT_CONVERSION_CONFIG) +}) diff --git a/backend/src/__tests__/leaderboard.test.ts b/backend/src/__tests__/leaderboard.test.ts new file mode 100644 index 0000000..e64e8db --- /dev/null +++ b/backend/src/__tests__/leaderboard.test.ts @@ -0,0 +1,141 @@ +/** + * Integration tests for the leaderboard router. + * + * Uses `pg-mem` to provide an in-memory PostgreSQL-compatible database, so + * the suite runs in CI without a real Postgres. The router is wired with the + * mem DB via the `db` option; nothing in the code path under test is mocked. + */ + +import { test, before } from 'node:test' +import assert from 'node:assert/strict' +import express from 'express' +import request from 'supertest' +import { newDb } from 'pg-mem' +import { leaderboardRouter } from '../routes/leaderboard.js' +import { runMigrations } from '../db.js' +import type { Db } from '../db.js' + +let app: express.Express + +before(async () => { + const mem = newDb({ autoCreateForeignKeyIndices: true }) + // pg-mem doesn't ship NOW()/jsonb-functions identically to PG; the + // defaults are good enough for our schema. + const adapter = mem.adapters.createPg() + const pool = new adapter.Pool() as unknown as Db + + // Apply real migrations (the same .sql files used in production). + await runMigrations(pool) + + app = express() + app.use(express.json()) + app.use('/api', leaderboardRouter({ db: pool })) +}) + +test('register -> submit -> leaderboard -> best round-trip', async () => { + // Register two players. + const reg1 = await request(app) + .post('/api/users/register') + .send({ player: 'alice' }) + .expect(201) + assert.equal(reg1.body.player, 'alice') + assert.ok(reg1.body.id, 'id present') + assert.ok(reg1.body.token && typeof reg1.body.token === 'string', 'token returned') + + const reg2 = await request(app) + .post('/api/users/register') + .send({ player: 'bob' }) + .expect(201) + + // Submit several scores. + await request(app) + .post('/api/scores') + .set('X-Player-Token', reg1.body.token) + .send({ score: 50 }) + .expect(201) + + const r2 = await request(app) + .post('/api/scores') + .set('X-Player-Token', reg1.body.token) + .send({ score: 120, meta: { boardSize: 20 } }) + .expect(201) + assert.equal(r2.body.entry.score, 120) + assert.equal(r2.body.bestScore, 120, 'best updated to 120') + + await request(app) + .post('/api/scores') + .set('X-Player-Token', reg2.body.token) + .send({ score: 80 }) + .expect(201) + + // Leaderboard should be 120 (alice), 80 (bob), 50 (alice). + const lb = await request(app).get('/api/leaderboard?limit=10').expect(200) + assert.equal(lb.body.entries.length, 3) + assert.equal(lb.body.entries[0].score, 120) + assert.equal(lb.body.entries[0].player, 'alice') + assert.equal(lb.body.entries[0].rank, 1) + assert.equal(lb.body.entries[1].score, 80) + assert.equal(lb.body.entries[2].score, 50) + assert.ok(lb.body.generatedAt, 'generatedAt set') + + // Best for alice should be 120. + const best = await request(app) + .get(`/api/users/${reg1.body.id}/best`) + .expect(200) + assert.equal(best.body.entry.score, 120) + assert.equal(best.body.entry.player, 'alice') +}) + +test('rejects score submit without token', async () => { + await request(app).post('/api/scores').send({ score: 10 }).expect(401) +}) + +test('rejects score submit with invalid token', async () => { + await request(app) + .post('/api/scores') + .set('X-Player-Token', 'definitely-not-a-real-token') + .send({ score: 10 }) + .expect(401) +}) + +test('rejects negative scores via zod validation', async () => { + const reg = await request(app) + .post('/api/users/register') + .send({ player: 'carol' }) + .expect(201) + const r = await request(app) + .post('/api/scores') + .set('X-Player-Token', reg.body.token) + .send({ score: -1 }) + .expect(400) + assert.equal(r.body.error, 'validation failed') +}) + +test('rejects malformed player handle on register', async () => { + await request(app).post('/api/users/register').send({ player: '' }).expect(400) + await request(app) + .post('/api/users/register') + .send({ player: 'has spaces' }) + .expect(400) +}) + +test('register is idempotent on case-insensitive handle', async () => { + const a = await request(app).post('/api/users/register').send({ player: 'Dave' }).expect(201) + const b = await request(app).post('/api/users/register').send({ player: 'dave' }).expect(201) + assert.equal(a.body.id, b.body.id, 'same user returned for case variant') +}) + +test('leaderboard limit is clamped to a sane range', async () => { + const tooBig = await request(app).get('/api/leaderboard?limit=10000').expect(400) + assert.equal(tooBig.body.error, 'validation failed') + const tooSmall = await request(app).get('/api/leaderboard?limit=0').expect(400) + assert.equal(tooSmall.body.error, 'validation failed') +}) + +test('users/:id/best returns 404 for unknown user', async () => { + await request(app).get('/api/users/9999999/best').expect(404) +}) + +test('users/:id/best rejects non-numeric ids', async () => { + await request(app).get('/api/users/not-a-number/best').expect(400) +}) diff --git a/backend/src/__tests__/rewards.test.ts b/backend/src/__tests__/rewards.test.ts new file mode 100644 index 0000000..e7e662e --- /dev/null +++ b/backend/src/__tests__/rewards.test.ts @@ -0,0 +1,201 @@ +/** + * Integration tests for the rewards router (T07). + * + * Same pg-mem + supertest pattern as the leaderboard suite. Each test gets + * a fresh in-memory DB so they can register identical handles without + * stepping on each other. + */ + +import { test } from 'node:test' +import assert from 'node:assert/strict' +import express from 'express' +import request from 'supertest' +import { newDb } from 'pg-mem' +import { leaderboardRouter } from '../routes/leaderboard.js' +import { rewardsRouter } from '../routes/rewards.js' +import { runMigrations, type Db } from '../db.js' +import { SNAKE_NANO_PER_TOKEN } from '@snake/shared' + +async function makeApp(): Promise { + const mem = newDb({ autoCreateForeignKeyIndices: true }) + const adapter = mem.adapters.createPg() + const pool = new adapter.Pool() as unknown as Db + await runMigrations(pool) + const app = express() + app.use(express.json()) + app.use('/api', leaderboardRouter({ db: pool })) + app.use('/api', rewardsRouter({ db: pool })) + return app +} + +async function registerAndScore(app: express.Express, player: string, score: number) { + const reg = await request(app).post('/api/users/register').send({ player }).expect(201) + const token = reg.body.token as string + const sub = await request(app) + .post('/api/scores') + .set('X-Player-Token', token) + .send({ score }) + .expect(201) + return { token, userId: reg.body.id as string, scoreId: sub.body.entry.id as string } +} + +test('claim mints tiered + position-bonus SNAKE per default config', async () => { + const app = await makeApp() + // Score 120 → silver tier (×1.5) → 180 base SNAKE. + // Alice is rank #1 (only score) → +100 top1 bonus → 280 total. + const { token, scoreId } = await registerAndScore(app, 'alice', 120) + + const claim = await request(app) + .post('/api/rewards/claim') + .set('X-Player-Token', token) + .send({ scoreId }) + .expect(201) + + assert.equal(claim.body.alreadyClaimed, false) + assert.equal(claim.body.reward.scoreId, String(scoreId)) + assert.equal(claim.body.reward.reason, 'top1') + assert.equal(claim.body.reward.tier, 'silver') + assert.equal(claim.body.reward.amountSnake, 280) + assert.equal(claim.body.reward.amountNano, String(BigInt(280) * BigInt(SNAKE_NANO_PER_TOKEN))) +}) + +test('claim is idempotent — second call returns the existing row', async () => { + const app = await makeApp() + const { token, scoreId } = await registerAndScore(app, 'alice', 75) + + const first = await request(app) + .post('/api/rewards/claim') + .set('X-Player-Token', token) + .send({ scoreId }) + .expect(201) + + const second = await request(app) + .post('/api/rewards/claim') + .set('X-Player-Token', token) + .send({ scoreId }) + .expect(200) + + assert.equal(second.body.alreadyClaimed, true) + assert.equal(second.body.reward.id, first.body.reward.id) + assert.equal(second.body.reward.amountNano, first.body.reward.amountNano) +}) + +test('claim rejects scores belonging to another player', async () => { + const app = await makeApp() + await registerAndScore(app, 'alice', 50) // alice exists + const bob = await registerAndScore(app, 'bob', 30) + + // alice tries to claim bob's score + const aliceReg = await request(app) + .post('/api/users/register') + .send({ player: 'alice' }) + .expect(201) + const aliceToken = aliceReg.body.token + + await request(app) + .post('/api/rewards/claim') + .set('X-Player-Token', aliceToken) + .send({ scoreId: bob.scoreId }) + .expect(403) +}) + +test('claim rejects unknown scoreId', async () => { + const app = await makeApp() + const { token } = await registerAndScore(app, 'alice', 10) + await request(app) + .post('/api/rewards/claim') + .set('X-Player-Token', token) + .send({ scoreId: '999999' }) + .expect(404) +}) + +test('claim requires X-Player-Token', async () => { + const app = await makeApp() + const { scoreId } = await registerAndScore(app, 'alice', 10) + await request(app).post('/api/rewards/claim').send({ scoreId }).expect(401) +}) + +test('claim validates body shape', async () => { + const app = await makeApp() + const { token } = await registerAndScore(app, 'alice', 10) + const r = await request(app) + .post('/api/rewards/claim') + .set('X-Player-Token', token) + .send({ scoreId: 'not-numeric' }) + .expect(400) + assert.equal(r.body.error, 'validation failed') +}) + +test('GET /api/rewards/me returns the player history with totals', async () => { + const app = await makeApp() + const a = await registerAndScore(app, 'alice', 100) + const b = await registerAndScore(app, 'alice', 50) // same player, second score + + await request(app) + .post('/api/rewards/claim') + .set('X-Player-Token', a.token) + .send({ scoreId: a.scoreId }) + .expect(201) + await request(app) + .post('/api/rewards/claim') + .set('X-Player-Token', b.token) + .send({ scoreId: b.scoreId }) + .expect(201) + + const me = await request(app) + .get('/api/rewards/me') + .set('X-Player-Token', a.token) + .expect(200) + assert.equal(me.body.rewards.length, 2) + // 100 → silver(×1.5)=150 + top1(+100) = 250 + // 50 → bronze(×1) = 50 + top3(+50) = 100 + // total = 350 + assert.equal(me.body.totalSnake, 350) + assert.equal(me.body.totalNano, String(BigInt(350) * BigInt(SNAKE_NANO_PER_TOKEN))) +}) + +test('GET /api/rewards/me requires X-Player-Token', async () => { + const app = await makeApp() + await request(app).get('/api/rewards/me').expect(401) +}) + +test('GET /api/rewards/leaderboard-bonuses returns 100/50/25 SNAKE per default config', async () => { + const app = await makeApp() + const r = await request(app).get('/api/rewards/leaderboard-bonuses').expect(200) + assert.equal(r.body.bonuses.length, 3) + assert.deepEqual( + r.body.bonuses.map((b: { position: number; amountSnake: number }) => [b.position, b.amountSnake]), + [ + [1, 100], + [2, 50], + [3, 25], + ], + ) +}) + +test('GET /api/rewards/config returns the active conversion config', async () => { + const app = await makeApp() + const r = await request(app).get('/api/rewards/config').expect(200) + assert.ok(Array.isArray(r.body.config.tiers)) + const labels = r.body.config.tiers.map((t: { label: string }) => t.label) + assert.deepEqual(labels, ['bronze', 'silver', 'gold', 'legendary']) + assert.equal(r.body.isDefault, true) +}) + +test('claim persists the chosen tier label on the row', async () => { + const app = await makeApp() + // Score 600 → gold tier (×2) = 1200 base, alice rank #1 → +100 → 1300. + const { token, scoreId } = await registerAndScore(app, 'alice', 600) + const claim = await request(app) + .post('/api/rewards/claim') + .set('X-Player-Token', token) + .send({ scoreId }) + .expect(201) + assert.equal(claim.body.reward.tier, 'gold') + assert.equal(claim.body.reward.reason, 'top1') + assert.equal(claim.body.reward.amountSnake, 1300) + + // Re-fetched via /me must have the same tier persisted. + const me = await request(app).get('/api/rewards/me').set('X-Player-Token', token).expect(200) + assert.equal(me.body.rewards[0].tier, 'gold') +}) diff --git a/backend/src/repo.ts b/backend/src/repo.ts new file mode 100644 index 0000000..12b62b4 --- /dev/null +++ b/backend/src/repo.ts @@ -0,0 +1,153 @@ +/** + * Data access layer. + * + * All SQL lives here. Routes call these helpers and never assemble SQL + * themselves. Every helper takes a `Db` so routes/tests can pick a connection. + */ + +import { randomBytes } from 'node:crypto' +import type { Db } from './db.js' + +export interface UserRow { + id: string + player: string + api_token: string +} + +export interface ScoreRow { + id: string + user_id: string + score: number + created_at: string // ISO-8601 + player: string +} + +/** + * Create a new user with a freshly minted API token, or return the existing + * one. We do a SELECT-then-INSERT instead of `ON CONFLICT (LOWER(player))` + * because expression-based unique indexes are well-supported by real + * PostgreSQL but not always by lightweight test adapters (pg-mem). The race + * window is harmless here — at worst two concurrent registrations for the + * same handle will conflict on the unique index and one will retry. + */ +export async function createUser(db: Db, player: string): Promise { + const existing = await db.query( + `SELECT id::text AS id, player, api_token + FROM users + WHERE LOWER(player) = LOWER($1)`, + [player], + ) + const found = existing.rows[0] + if (found) return found + + const token = randomBytes(24).toString('hex') + const insert = await db.query( + `INSERT INTO users (player, api_token) + VALUES ($1, $2) + RETURNING id::text AS id, player, api_token`, + [player, token], + ) + const row = insert.rows[0] + if (!row) throw new Error('failed to create user') + return row +} + +/** Resolve a user by their API token. Returns `null` if the token is unknown. */ +export async function findUserByToken(db: Db, token: string): Promise { + const result = await db.query( + `SELECT id::text AS id, player, api_token + FROM users + WHERE api_token = $1`, + [token], + ) + return result.rows[0] ?? null +} + +/** Resolve a user by id. */ +export async function findUserById(db: Db, id: string): Promise { + const result = await db.query( + `SELECT id::text AS id, player, api_token + FROM users + WHERE id = $1`, + [id], + ) + return result.rows[0] ?? null +} + +/** + * Record a score for the given user. Creates a session row alongside the + * score so we have a 1:1 audit trail even if no `/sessions` endpoint exists + * yet. + */ +export async function recordScore( + db: Db, + userId: string, + score: number, + meta: Record = {}, +): Promise { + const session = await db.query<{ id: string }>( + `INSERT INTO sessions (user_id, ended_at, final_score, meta) + VALUES ($1, NOW(), $2, $3::jsonb) + RETURNING id::text AS id`, + [userId, score, JSON.stringify(meta)], + ) + const sessionId = session.rows[0]?.id ?? null + + const inserted = await db.query( + `INSERT INTO scores (user_id, session_id, score) + VALUES ($1, $2, $3) + RETURNING id::text AS id, + user_id::text AS user_id, + score, + created_at, + (SELECT player FROM users WHERE id = $1) AS player`, + [userId, sessionId, score], + ) + const row = inserted.rows[0] + if (!row) throw new Error('failed to record score') + // Some adapters return Date objects for TIMESTAMPTZ; normalize to ISO. + return { ...row, created_at: toIso(row.created_at) } +} + +/** Fetch the top-N scores for the global leaderboard, newest tie-break wins ascending. */ +export async function topScores(db: Db, limit: number): Promise { + const result = await db.query( + `SELECT s.id::text AS id, + s.user_id::text AS user_id, + s.score AS score, + s.created_at AS created_at, + u.player AS player + FROM scores s + JOIN users u ON u.id = s.user_id + ORDER BY s.score DESC, s.created_at ASC + LIMIT $1`, + [limit], + ) + return result.rows.map((r) => ({ ...r, created_at: toIso(r.created_at) })) +} + +/** Fetch the all-time best score for a single user. Returns `null` if no scores. */ +export async function bestScoreForUser(db: Db, userId: string): Promise { + const result = await db.query( + `SELECT s.id::text AS id, + s.user_id::text AS user_id, + s.score AS score, + s.created_at AS created_at, + u.player AS player + FROM scores s + JOIN users u ON u.id = s.user_id + WHERE s.user_id = $1 + ORDER BY s.score DESC, s.created_at ASC + LIMIT 1`, + [userId], + ) + const row = result.rows[0] + if (!row) return null + return { ...row, created_at: toIso(row.created_at) } +} + +function toIso(value: unknown): string { + if (value instanceof Date) return value.toISOString() + if (typeof value === 'string') return value + return new Date(String(value)).toISOString() +} diff --git a/backend/src/rewards/conversion.ts b/backend/src/rewards/conversion.ts new file mode 100644 index 0000000..e504753 --- /dev/null +++ b/backend/src/rewards/conversion.ts @@ -0,0 +1,281 @@ +/** + * Score → SNAKE-token conversion (T08). + * + * Replaces the T07 `floor(score)` placeholder with a configurable, pure + * function that knows about: + * + * - **Tiers** — score ranges that multiply the base reward + * (e.g. bronze 1×, silver 1.5×, gold 2×, legendary 3×). + * - **Position bonuses** — additive flat SNAKE awarded for ranking inside + * the top-N at claim time (e.g. +100 for #1, +50 for top-3, +25 for top-10). + * + * The whole config lives in a `ConversionConfig` object exported via + * `DEFAULT_CONVERSION_CONFIG`. Operators can override at boot via the + * `SNAKE_REWARD_CONFIG_JSON` environment variable; the loader is in this + * module so the route layer doesn't have to know how config is sourced. + * + * The route still calls `computeReward({score, leaderboardPosition?})`; the + * shape is unchanged from T07 except that the return now includes a + * detailed breakdown for downstream UI/audit. + */ + +import { SNAKE_NANO_PER_TOKEN, type RewardReason } from '@snake/shared' + +// ---------- Config types --------------------------------------------------- + +export interface RewardTier { + /** Tier label persisted on the reward row and surfaced to the UI. */ + label: string + /** Inclusive lower bound (score >= minScore). */ + minScore: number + /** Multiplier applied to the base score → SNAKE conversion. */ + multiplier: number +} + +export interface PositionBonus { + /** + * Threshold (1-based). The bonus applies when the player's + * `leaderboardPosition <= maxPosition`. Process in order — the *first* + * matching bonus wins, so ordering most-restrictive first (1, 3, 10) is + * conventional. + */ + maxPosition: number + /** Flat SNAKE amount added on top of the tiered base. */ + snake: number + /** Reason persisted on the reward row when this bonus is in effect. */ + reason: RewardReason +} + +export interface ConversionConfig { + /** + * Tiers in *ascending* `minScore` order. The highest tier whose + * `minScore <= score` wins. Must contain at least one entry covering + * score 0 (i.e. minScore: 0). + */ + tiers: ReadonlyArray + /** Position bonuses, ordered most-restrictive first. */ + positionBonuses: ReadonlyArray + /** + * Top-N bonus amounts surfaced by `GET /api/rewards/leaderboard-bonuses`. + * Independent of `positionBonuses` so operators can publish a different + * "advertised" bonus structure if they wish; defaults match. + */ + leaderboardBonuses: ReadonlyArray<{ position: number; snake: number }> +} + +// ---------- Defaults ------------------------------------------------------- + +const DEFAULT_TIERS: ReadonlyArray = [ + { label: 'bronze', minScore: 0, multiplier: 1 }, + { label: 'silver', minScore: 100, multiplier: 1.5 }, + { label: 'gold', minScore: 500, multiplier: 2 }, + { label: 'legendary', minScore: 2000, multiplier: 3 }, +] + +const DEFAULT_POSITION_BONUSES: ReadonlyArray = [ + { maxPosition: 1, snake: 100, reason: 'top1' }, + { maxPosition: 3, snake: 50, reason: 'top3' }, + { maxPosition: 10, snake: 25, reason: 'top10' }, +] + +const DEFAULT_LEADERBOARD_BONUSES: ReadonlyArray<{ position: number; snake: number }> = [ + { position: 1, snake: 100 }, + { position: 2, snake: 50 }, + { position: 3, snake: 25 }, +] + +export const DEFAULT_CONVERSION_CONFIG: ConversionConfig = Object.freeze({ + tiers: DEFAULT_TIERS, + positionBonuses: DEFAULT_POSITION_BONUSES, + leaderboardBonuses: DEFAULT_LEADERBOARD_BONUSES, +}) + +// ---------- Loader --------------------------------------------------------- + +/** + * Read `SNAKE_REWARD_CONFIG_JSON` from the environment and merge it on top + * of the defaults. Missing or malformed JSON falls back silently — we log + * once to stderr and continue with defaults so a typo in deployment env + * doesn't bring the API down. + * + * The merge is *replace per top-level key* (not deep-merge): if you supply + * `tiers`, you supply *all* tiers. + */ +export function loadConversionConfig(env: NodeJS.ProcessEnv = process.env): ConversionConfig { + const raw = env.SNAKE_REWARD_CONFIG_JSON + if (!raw) return DEFAULT_CONVERSION_CONFIG + let parsed: Partial + try { + parsed = JSON.parse(raw) as Partial + } catch (e) { + // eslint-disable-next-line no-console + console.warn('[rewards] SNAKE_REWARD_CONFIG_JSON is not valid JSON; using defaults', e) + return DEFAULT_CONVERSION_CONFIG + } + const merged: ConversionConfig = { + tiers: parsed.tiers ?? DEFAULT_CONVERSION_CONFIG.tiers, + positionBonuses: parsed.positionBonuses ?? DEFAULT_CONVERSION_CONFIG.positionBonuses, + leaderboardBonuses: + parsed.leaderboardBonuses ?? DEFAULT_CONVERSION_CONFIG.leaderboardBonuses, + } + const validation = validateConfig(merged) + if (!validation.ok) { + // eslint-disable-next-line no-console + console.warn( + `[rewards] SNAKE_REWARD_CONFIG_JSON failed validation (${validation.error}); using defaults`, + ) + return DEFAULT_CONVERSION_CONFIG + } + return merged +} + +function validateConfig(cfg: ConversionConfig): { ok: true } | { ok: false; error: string } { + if (!Array.isArray(cfg.tiers) || cfg.tiers.length === 0) { + return { ok: false, error: 'tiers must be a non-empty array' } + } + const sorted = [...cfg.tiers].sort((a, b) => a.minScore - b.minScore) + if (sorted[0]!.minScore !== 0) { + return { ok: false, error: 'lowest tier must have minScore: 0' } + } + for (const t of cfg.tiers) { + if (typeof t.label !== 'string' || !t.label) return { ok: false, error: 'tier.label invalid' } + if (!Number.isFinite(t.minScore) || t.minScore < 0) + return { ok: false, error: 'tier.minScore invalid' } + if (!Number.isFinite(t.multiplier) || t.multiplier < 0) + return { ok: false, error: 'tier.multiplier invalid' } + } + return { ok: true } +} + +// ---------- Compute -------------------------------------------------------- + +export interface ComputeRewardInput { + score: number + /** Optional 1-based leaderboard position at claim time. */ + leaderboardPosition?: number + /** Override defaults; primarily for tests and the env-loaded config. */ + config?: ConversionConfig +} + +export interface ComputeRewardBreakdownItem { + label: string + snake: number + nano: string +} + +export interface ComputeRewardOutput { + /** Tier base × multiplier, in whole SNAKE. */ + baseAmount: number + /** Currently always equal to `baseAmount` minus the un-multiplied score. Useful for UI. */ + tierBonus: number + /** Flat SNAKE bonus from leaderboard position (0 if not in top-N). */ + positionBonus: number + /** baseAmount + positionBonus. */ + totalAmount: number + /** Selected tier label (e.g. 'bronze'). */ + tierLabel: string + /** Persisted reason — promoted to top1/top3/top10 when a bonus applies. */ + reason: RewardReason + /** UI-friendly itemised breakdown. */ + breakdown: ComputeRewardBreakdownItem[] + /** Authoritative on-wire amount in nano-units, as a string. */ + amountNano: string + /** Convenience alias for `tierLabel` — kept for symmetry with route layer. */ + tier: string +} + +/** + * Pure: compute the SNAKE reward for a single score claim. + * + * Algorithm: + * 1. Pick the highest tier whose `minScore <= score`. + * 2. baseAmount = floor(score * multiplier). + * 3. If `leaderboardPosition` is supplied, find the first matching + * `positionBonuses` entry and add its `snake` flat bonus. + * 4. amountNano = totalAmount * 1e9. + * + * Negative / non-finite scores collapse to 0; the schema CHECK constraint + * would reject them anyway, but defending here keeps callers honest. + */ +export function computeReward(input: ComputeRewardInput): ComputeRewardOutput { + const cfg = input.config ?? DEFAULT_CONVERSION_CONFIG + const safeScore = Number.isFinite(input.score) ? Math.max(0, Math.floor(input.score)) : 0 + + const tier = pickTier(cfg.tiers, safeScore) + const baseAmount = Math.floor(safeScore * tier.multiplier) + const tierBonus = baseAmount - safeScore // 0 for bronze (1×), >0 for higher tiers + + const positionMatch = + typeof input.leaderboardPosition === 'number' && input.leaderboardPosition > 0 + ? findPositionBonus(cfg.positionBonuses, input.leaderboardPosition) + : null + const positionBonus = positionMatch?.snake ?? 0 + + const totalAmount = baseAmount + positionBonus + const amountNano = (BigInt(totalAmount) * BigInt(SNAKE_NANO_PER_TOKEN)).toString() + + const breakdown: ComputeRewardBreakdownItem[] = [ + { + label: `${tier.label} tier (×${tier.multiplier})`, + snake: baseAmount, + nano: snakeToNano(baseAmount), + }, + ] + if (positionMatch) { + breakdown.push({ + label: `position #${input.leaderboardPosition} bonus`, + snake: positionMatch.snake, + nano: snakeToNano(positionMatch.snake), + }) + } + + return { + baseAmount, + tierBonus, + positionBonus, + totalAmount, + tierLabel: tier.label, + reason: positionMatch?.reason ?? 'score', + breakdown, + amountNano, + tier: tier.label, + } +} + +function pickTier(tiers: ReadonlyArray, score: number): RewardTier { + // Choose the highest minScore <= score. Iterating sorted-asc and keeping + // the last match is O(n) and avoids assuming the caller pre-sorted. + const sorted = [...tiers].sort((a, b) => a.minScore - b.minScore) + let chosen: RewardTier = sorted[0]! + for (const t of sorted) { + if (t.minScore <= score) chosen = t + else break + } + return chosen +} + +function findPositionBonus( + bonuses: ReadonlyArray, + position: number, +): PositionBonus | null { + // First matching bonus wins. Operators are expected to order + // most-restrictive first (1, 3, 10) so #1 doesn't accidentally pick up + // the top-3 bonus instead of the top-1 bonus. + const sorted = [...bonuses].sort((a, b) => a.maxPosition - b.maxPosition) + for (const b of sorted) { + if (position <= b.maxPosition) return b + } + return null +} + +function snakeToNano(snake: number): string { + return (BigInt(Math.max(0, Math.floor(snake))) * BigInt(SNAKE_NANO_PER_TOKEN)).toString() +} + +// ---------- Legacy export retained for compatibility ----------------------- + +/** + * Kept for compatibility with the T07 bonuses-endpoint code that imported + * this constant directly. Now reads from the active config. + */ +export const PLACEHOLDER_LEADERBOARD_BONUSES = DEFAULT_CONVERSION_CONFIG.leaderboardBonuses diff --git a/backend/src/rewards/repo.ts b/backend/src/rewards/repo.ts new file mode 100644 index 0000000..331d972 --- /dev/null +++ b/backend/src/rewards/repo.ts @@ -0,0 +1,164 @@ +/** + * Data access for `token_rewards`. + * + * Kept in its own file (rather than appended to `repo.ts`) so the rewards + * vertical stays self-contained: route + repo + conversion all live under + * `src/rewards/`. + */ + +import type { Db } from '../db.js' +import type { RewardReason } from '@snake/shared' + +export interface RewardRow { + id: string + player_id: string + score_id: string | null + amount_nano: string + reason: RewardReason + tier: string + created_at: string + /** Joined from `users.player`. */ + player: string +} + +/** Look up a 'score'-reason reward by score id. Returns `null` if not yet claimed. */ +export async function findRewardByScoreId(db: Db, scoreId: string): Promise { + const result = await db.query( + `SELECT r.id::text AS id, + r.player_id::text AS player_id, + r.score_id::text AS score_id, + r.amount_nano::text AS amount_nano, + r.reason AS reason, + r.tier AS tier, + r.created_at AS created_at, + u.player AS player + FROM token_rewards r + JOIN users u ON u.id = r.player_id + WHERE r.score_id = $1`, + [scoreId], + ) + const row = result.rows[0] + if (!row) return null + return normalize(row) +} + +/** Insert a new reward row. Caller is responsible for idempotency checks. */ +export async function insertReward( + db: Db, + args: { + playerId: string + scoreId: string | null + amountNano: string + reason: RewardReason + tier: string + }, +): Promise { + const result = await db.query( + `INSERT INTO token_rewards (player_id, score_id, amount_nano, reason, tier) + VALUES ($1, $2, $3, $4, $5) + RETURNING id::text AS id, + player_id::text AS player_id, + score_id::text AS score_id, + amount_nano::text AS amount_nano, + reason AS reason, + tier AS tier, + created_at AS created_at, + (SELECT player FROM users WHERE id = $1) AS player`, + [args.playerId, args.scoreId, args.amountNano, args.reason, args.tier], + ) + const row = result.rows[0] + if (!row) throw new Error('failed to insert token reward') + return normalize(row) +} + +/** All rewards for a single player, newest first. */ +export async function rewardsForPlayer(db: Db, playerId: string): Promise { + const result = await db.query( + `SELECT r.id::text AS id, + r.player_id::text AS player_id, + r.score_id::text AS score_id, + r.amount_nano::text AS amount_nano, + r.reason AS reason, + r.tier AS tier, + r.created_at AS created_at, + u.player AS player + FROM token_rewards r + JOIN users u ON u.id = r.player_id + WHERE r.player_id = $1 + ORDER BY r.created_at DESC, r.id DESC`, + [playerId], + ) + return result.rows.map(normalize) +} + +/** Resolve a score row by id. Returns `null` if not found. */ +export interface ScoreOwnerRow { + id: string + user_id: string + score: number +} +export async function findScoreById(db: Db, scoreId: string): Promise { + const result = await db.query( + `SELECT id::text AS id, + user_id::text AS user_id, + score AS score + FROM scores + WHERE id = $1`, + [scoreId], + ) + return result.rows[0] ?? null +} + +/** + * Compute the 1-based global leaderboard rank of a single score row. + * + * Tie-break matches the leaderboard read path (`score DESC, created_at ASC`): + * count rows that strictly beat us, plus rows tied on score that landed + * earlier, plus 1. + * + * Returns `null` if the score id doesn't exist. + * + * We do this in two queries instead of a single correlated subquery because + * pg-mem (used in tests) doesn't resolve outer aliases inside `(SELECT …)` + * subqueries. Two round-trips against an in-memory adapter is fine. + */ +export async function leaderboardPositionForScore( + db: Db, + scoreId: string, +): Promise { + const me = await db.query<{ score: number; created_at: string | Date }>( + `SELECT score, created_at FROM scores WHERE id = $1`, + [scoreId], + ) + const row = me.rows[0] + if (!row) return null + const ahead = await db.query<{ count: string | number }>( + `SELECT COUNT(*) AS count + FROM scores + WHERE score > $1 + OR (score = $1 AND created_at < $2)`, + [row.score, row.created_at], + ) + const aheadCount = Number(ahead.rows[0]?.count ?? 0) + return aheadCount + 1 +} + +function normalize(row: RewardRow): RewardRow { + return { + ...row, + // pg returns BIGINT as string already; pg-mem may return number. Force string. + amount_nano: + typeof row.amount_nano === 'string' ? row.amount_nano : String(row.amount_nano), + score_id: + row.score_id === null || row.score_id === undefined + ? null + : String(row.score_id), + created_at: toIso(row.created_at), + } +} + +function toIso(value: unknown): string { + if (value instanceof Date) return value.toISOString() + if (typeof value === 'string') return value + return new Date(String(value)).toISOString() +} diff --git a/backend/src/routes/leaderboard.ts b/backend/src/routes/leaderboard.ts new file mode 100644 index 0000000..d63f2e3 --- /dev/null +++ b/backend/src/routes/leaderboard.ts @@ -0,0 +1,193 @@ +/** + * Score / leaderboard / user routes. + * + * POST /api/users/register — create a user, returns API token + * POST /api/scores — submit a score (requires X-Player-Token) + * GET /api/leaderboard?limit=N — top-N scores (limit clamped 1..100, default 10) + * GET /api/users/:id/best — that user's best score + * + * Auth model: a single bearer token per user, sent as `X-Player-Token`. + * That's enough for a hobby leaderboard; promoting to JWT/refresh tokens + * is out of scope for T05. + */ + +import { Router, type Request, type Response, type NextFunction } from 'express' +import { z } from 'zod' +import type { + LeaderboardResponse, + RegisterUserResponse, + ScoreEntry, + SubmitScoreResponse, +} from '@snake/shared' +import { getDb, type Db } from '../db.js' +import { + bestScoreForUser, + createUser, + findUserById, + findUserByToken, + recordScore, + topScores, + type ScoreRow, +} from '../repo.js' + +// ---------- Validation schemas --------------------------------------------- + +// Player handle: 1-32 chars, alphanumeric + ._- . Keeps the leaderboard +// readable and avoids zero-width / control chars sneaking in. +const PlayerHandle = z + .string() + .trim() + .min(1, 'player handle must not be empty') + .max(32, 'player handle too long') + .regex(/^[A-Za-z0-9._-]+$/, 'player handle must be alphanumeric (._- allowed)') + +const RegisterBody = z.object({ player: PlayerHandle }) + +const SubmitBody = z.object({ + // `player` is accepted for parity with `SubmitScoreRequest` from + // @snake/shared but is *advisory* — the authoritative identity comes + // from the X-Player-Token header. We just sanity-check that it matches + // the token's user when both are supplied. + player: PlayerHandle.optional(), + score: z.number().int().nonnegative().max(1_000_000), + meta: z.record(z.unknown()).optional(), +}) + +const LeaderboardQuery = z.object({ + limit: z + .preprocess((v) => (v === undefined ? undefined : Number(v)), z.number().int().min(1).max(100)) + .optional(), +}) + +// ---------- Helpers --------------------------------------------------------- + +function rowToEntry(row: ScoreRow, rank?: number): ScoreEntry { + return { + id: row.id, + player: row.player, + score: row.score, + createdAt: row.created_at, + ...(rank !== undefined ? { rank } : {}), + } +} + +/** + * Resolve the authenticated user from the `X-Player-Token` header. + * Throws a 401-flavoured error if the header is missing or invalid. + */ +async function authenticate(db: Db, req: Request) { + const raw = req.header('x-player-token') + if (!raw || typeof raw !== 'string') { + const err: Error & { status?: number } = new Error('missing X-Player-Token header') + err.status = 401 + throw err + } + const user = await findUserByToken(db, raw.trim()) + if (!user) { + const err: Error & { status?: number } = new Error('invalid X-Player-Token') + err.status = 401 + throw err + } + return user +} + +// ---------- Router factory -------------------------------------------------- + +export interface LeaderboardRouterOptions { + /** Optional DB override (used by tests). Falls back to `getDb()`. */ + db?: Db +} + +export function leaderboardRouter(opts: LeaderboardRouterOptions = {}): Router { + const router = Router() + const db = async (): Promise => opts.db ?? (await getDb()) + + router.post('/users/register', async (req, res, next) => { + try { + const body = RegisterBody.parse(req.body) + const user = await createUser(await db(), body.player) + const out: RegisterUserResponse = { + id: user.id, + player: user.player, + token: user.api_token, + } + res.status(201).json(out) + } catch (e) { + next(e) + } + }) + + router.post('/scores', async (req, res, next) => { + try { + const body = SubmitBody.parse(req.body) + const conn = await db() + const user = await authenticate(conn, req) + if (body.player && body.player.toLowerCase() !== user.player.toLowerCase()) { + res.status(403).json({ error: 'player handle does not match token' }) + return + } + const row = await recordScore(conn, user.id, body.score, body.meta ?? {}) + const best = await bestScoreForUser(conn, user.id) + const out: SubmitScoreResponse = { + entry: rowToEntry(row), + bestScore: best?.score ?? row.score, + } + res.status(201).json(out) + } catch (e) { + next(e) + } + }) + + router.get('/leaderboard', async (req, res, next) => { + try { + const q = LeaderboardQuery.parse(req.query) + const limit = q.limit ?? 10 + const rows = await topScores(await db(), limit) + const out: LeaderboardResponse = { + entries: rows.map((r, i) => rowToEntry(r, i + 1)), + generatedAt: new Date().toISOString(), + } + res.json(out) + } catch (e) { + next(e) + } + }) + + router.get('/users/:id/best', async (req, res, next) => { + try { + const conn = await db() + const id = req.params.id + if (!id || !/^\d+$/.test(id)) { + res.status(400).json({ error: 'invalid user id' }) + return + } + const user = await findUserById(conn, id) + if (!user) { + res.status(404).json({ error: 'user not found' }) + return + } + const best = await bestScoreForUser(conn, id) + if (!best) { + res.status(404).json({ error: 'no scores recorded for user' }) + return + } + res.json({ entry: rowToEntry(best) }) + } catch (e) { + next(e) + } + }) + + // Centralised error handler for this router. Validation failures from + // zod become 400s; explicit `.status` on thrown errors is honoured. + router.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { + if (err instanceof z.ZodError) { + res.status(400).json({ error: 'validation failed', issues: err.issues }) + return + } + const status = (err as { status?: number })?.status ?? 500 + const message = err instanceof Error ? err.message : 'internal error' + res.status(status).json({ error: message }) + }) + + return router +} diff --git a/backend/src/routes/rewards.ts b/backend/src/routes/rewards.ts new file mode 100644 index 0000000..c26ea0e --- /dev/null +++ b/backend/src/routes/rewards.ts @@ -0,0 +1,235 @@ +/** + * Token-reward routes (T07). + * + * POST /api/rewards/claim — claim SNAKE for a score (idempotent) + * GET /api/rewards/me — list claimed rewards (auth) + * GET /api/rewards/leaderboard-bonuses — top-N bonus amounts (placeholder, public) + * + * Auth uses the same `X-Player-Token` bearer as the leaderboard router. + * + * The conversion math lives in `../rewards/conversion.ts` so T08 can swap + * the placeholder for a tiered implementation without touching this file. + */ + +import { Router, type Request, type Response, type NextFunction } from 'express' +import { z } from 'zod' +import type { + ClaimRewardResponse, + LeaderboardBonusEntry, + LeaderboardBonusesResponse, + MyRewardsResponse, + RewardEntry, +} from '@snake/shared' +import { SNAKE_NANO_PER_TOKEN } from '@snake/shared' +import { getDb, type Db } from '../db.js' +import { findUserByToken, type UserRow } from '../repo.js' +import { + findRewardByScoreId, + findScoreById, + insertReward, + leaderboardPositionForScore, + rewardsForPlayer, + type RewardRow, +} from '../rewards/repo.js' +import { + DEFAULT_CONVERSION_CONFIG, + computeReward, + loadConversionConfig, + type ConversionConfig, +} from '../rewards/conversion.js' + +// ---------- Validation ------------------------------------------------------ + +// Score IDs come back from the score-submit endpoint as numeric strings. +const ClaimBody = z.object({ + scoreId: z.union([ + z.string().regex(/^\d+$/, 'scoreId must be a numeric string'), + z.number().int().nonnegative(), + ]), +}) + +// ---------- Helpers --------------------------------------------------------- + +async function authenticate(db: Db, req: Request): Promise { + const raw = req.header('x-player-token') + if (!raw || typeof raw !== 'string') { + const err: Error & { status?: number } = new Error('missing X-Player-Token header') + err.status = 401 + throw err + } + const user = await findUserByToken(db, raw.trim()) + if (!user) { + const err: Error & { status?: number } = new Error('invalid X-Player-Token') + err.status = 401 + throw err + } + return user +} + +/** + * Convert a `RewardRow` into the wire-shape `RewardEntry`. We surface both + * `amountNano` (authoritative, string) and `amountSnake` (UI-friendly float + * derived from nano / 1e9 — fine for display, not for arithmetic). + */ +function rowToEntry(row: RewardRow): RewardEntry { + const nano = row.amount_nano + return { + id: row.id, + playerId: row.player_id, + player: row.player, + scoreId: row.score_id, + amountNano: nano, + amountSnake: nanoToSnake(nano), + reason: row.reason, + tier: row.tier, + createdAt: row.created_at, + } +} + +function nanoToSnake(nanoStr: string): number { + // Safe for any amount under ~9e15 SNAKE; well beyond any realistic claim. + const nano = BigInt(nanoStr) + const snake = nano / BigInt(SNAKE_NANO_PER_TOKEN) + return Number(snake) +} + +function snakeToNano(snake: number): string { + return (BigInt(Math.max(0, Math.floor(snake))) * BigInt(SNAKE_NANO_PER_TOKEN)).toString() +} + +function sumNano(entries: ReadonlyArray<{ amountNano: string }>): string { + return entries + .reduce((acc, e) => acc + BigInt(e.amountNano), BigInt(0)) + .toString() +} + +// ---------- Router factory -------------------------------------------------- + +export interface RewardsRouterOptions { + db?: Db + /** Override conversion config; defaults to env-loaded then defaults. */ + config?: ConversionConfig +} + +export function rewardsRouter(opts: RewardsRouterOptions = {}): Router { + const router = Router() + const db = async (): Promise => opts.db ?? (await getDb()) + // Resolve the active conversion config once at router boot. Tests pass an + // explicit override; production reads SNAKE_REWARD_CONFIG_JSON via the + // loader, which falls back to DEFAULT_CONVERSION_CONFIG if unset. + const config: ConversionConfig = opts.config ?? loadConversionConfig() + + router.post('/rewards/claim', async (req, res, next) => { + try { + const body = ClaimBody.parse(req.body) + const conn = await db() + const user = await authenticate(conn, req) + const scoreId = String(body.scoreId) + + const score = await findScoreById(conn, scoreId) + if (!score) { + res.status(404).json({ error: 'score not found' }) + return + } + if (String(score.user_id) !== String(user.id)) { + // Don't let Alice claim Bob's score. + res.status(403).json({ error: 'score belongs to another player' }) + return + } + + // Idempotent: if a reward already exists for this score, return it. + const existing = await findRewardByScoreId(conn, scoreId) + if (existing) { + const out: ClaimRewardResponse = { + reward: rowToEntry(existing), + alreadyClaimed: true, + } + res.status(200).json(out) + return + } + + // T08: look up the score's current leaderboard rank so position + // bonuses (top-1/top-3/top-10) can apply on top of the tiered base. + const leaderboardPosition = await leaderboardPositionForScore(conn, scoreId) + const { amountNano, tier, reason } = computeReward({ + score: score.score, + leaderboardPosition: leaderboardPosition ?? undefined, + config, + }) + const inserted = await insertReward(conn, { + playerId: user.id, + scoreId, + amountNano, + reason, + tier, + }) + const out: ClaimRewardResponse = { + reward: rowToEntry(inserted), + alreadyClaimed: false, + } + res.status(201).json(out) + } catch (e) { + next(e) + } + }) + + router.get('/rewards/me', async (req, res, next) => { + try { + const conn = await db() + const user = await authenticate(conn, req) + const rows = await rewardsForPlayer(conn, user.id) + const entries = rows.map(rowToEntry) + const totalNano = sumNano(entries) + const out: MyRewardsResponse = { + rewards: entries, + totalNano, + totalSnake: nanoToSnake(totalNano), + } + res.json(out) + } catch (e) { + next(e) + } + }) + + router.get('/rewards/leaderboard-bonuses', async (_req, res, next) => { + try { + const bonuses: LeaderboardBonusEntry[] = config.leaderboardBonuses.map((b) => ({ + position: b.position, + amountNano: snakeToNano(b.snake), + amountSnake: b.snake, + })) + const out: LeaderboardBonusesResponse = { bonuses } + res.json(out) + } catch (e) { + next(e) + } + }) + + /** + * T08: surface the active conversion config so the UI can display + * "next tier at X" hints and advertised position bonuses without + * hard-coding values. + */ + router.get('/rewards/config', async (_req, res, next) => { + try { + res.json({ + config, + isDefault: config === DEFAULT_CONVERSION_CONFIG, + }) + } catch (e) { + next(e) + } + }) + + router.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { + if (err instanceof z.ZodError) { + res.status(400).json({ error: 'validation failed', issues: err.issues }) + return + } + const status = (err as { status?: number })?.status ?? 500 + const message = err instanceof Error ? err.message : 'internal error' + res.status(status).json({ error: message }) + }) + + return router +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 1620a79..34e5986 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,4 +1,6 @@ import express from 'express' +import { leaderboardRouter } from './routes/leaderboard.js' +import { rewardsRouter } from './routes/rewards.js' const app = express() app.use(express.json()) @@ -7,6 +9,14 @@ app.get('/api/health', (_req, res) => { res.json({ status: 'ok', service: 'snake-backend' }) }) +// Score / leaderboard / user routes. Mounted under `/api` so the dev proxy +// in the frontend Vite config (`/api -> http://localhost:8787`) hits them +// without extra rewriting. +app.use('/api', leaderboardRouter()) + +// Token reward routes (T07). Same auth model (X-Player-Token header). +app.use('/api', rewardsRouter()) + const port = Number(process.env.PORT ?? 8787) if (process.env.NODE_ENV !== 'test') { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 39b6bc4..ce13cf7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { createInitialState, type GameState } from './game/state' import { keyToDirection, requestDirection, resetGame } from './game/movement' import { tick, withInitialFood } from './game/food' import { Board } from './ui/Board' +import { Leaderboard } from './leaderboard/Leaderboard' type Action = | { type: 'tick' } @@ -87,6 +88,7 @@ export default function App() {

Arrows / WASD to move · Space to pause/resume · R to reset

+ ) } diff --git a/frontend/src/leaderboard/Leaderboard.tsx b/frontend/src/leaderboard/Leaderboard.tsx new file mode 100644 index 0000000..bec384b --- /dev/null +++ b/frontend/src/leaderboard/Leaderboard.tsx @@ -0,0 +1,405 @@ +/** + * Leaderboard panel. + * + * Polls `GET /api/leaderboard` every `pollMs` (default 5s) and renders the + * top-N players in a table. Handles loading, error, and empty states; the + * polling interval is paused when the document tab is hidden so we don't + * burn server cycles on background tabs. + */ + +import { useCallback, useEffect, useRef, useState } from 'react' +import type { + ConversionConfig, + LeaderboardResponse, + RewardTierConfig, + ScoreEntry, +} from '@snake/shared' +import { + LeaderboardError, + claimReward, + fetchLeaderboard, + fetchRewardsConfig, + registerPlayer, +} from './api' + +const IDENTITY_STORAGE_KEY = 'snake.identity.v1' + +interface PlayerIdentity { + handle: string + token: string +} + +function loadIdentity(): PlayerIdentity | null { + try { + const raw = window.localStorage.getItem(IDENTITY_STORAGE_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) as Partial + if (typeof parsed.handle === 'string' && typeof parsed.token === 'string') { + return { handle: parsed.handle, token: parsed.token } + } + return null + } catch { + return null + } +} + +function saveIdentity(identity: PlayerIdentity): void { + try { + window.localStorage.setItem(IDENTITY_STORAGE_KEY, JSON.stringify(identity)) + } catch { + // localStorage disabled — claim button still works for the session. + } +} + +/** + * Pick the highest tier whose `minScore <= score`. Mirrors the server-side + * `pickTier` so the UI can label rows without a server round-trip per row. + */ +function pickTierForScore( + tiers: ReadonlyArray, + score: number, +): RewardTierConfig | null { + if (tiers.length === 0) return null + const sorted = [...tiers].sort((a, b) => a.minScore - b.minScore) + let chosen: RewardTierConfig = sorted[0]! + for (const t of sorted) { + if (t.minScore <= score) chosen = t + else break + } + return chosen +} + +export interface LeaderboardProps { + /** Number of entries to fetch and render. Default 10. */ + limit?: number + /** Poll interval in ms. Default 5000. Set to 0 to disable polling. */ + pollMs?: number +} + +type Status = 'loading' | 'ready' | 'error' + +export function Leaderboard({ limit = 10, pollMs = 5000 }: LeaderboardProps) { + const [entries, setEntries] = useState([]) + const [generatedAt, setGeneratedAt] = useState(null) + const [status, setStatus] = useState('loading') + const [error, setError] = useState(null) + + // Active reward conversion config, fetched once on mount. Used to label + // each row with its tier (bronze/silver/gold/legendary) without polling + // the server per row. + const [conversion, setConversion] = useState(null) + + // Player identity — persisted in localStorage. The claim button is only + // shown on rows whose `player` matches the saved handle (case-insensitive). + const [identity, setIdentity] = useState(() => loadIdentity()) + const [handleInput, setHandleInput] = useState('') + const [identityError, setIdentityError] = useState(null) + const [claimState, setClaimState] = useState<{ + scoreId: string + status: 'pending' | 'ok' | 'err' + message: string + } | null>(null) + + const onSaveIdentity = useCallback(async () => { + const handle = handleInput.trim() + if (!handle) return + setIdentityError(null) + try { + const reg = await registerPlayer(handle) + const next: PlayerIdentity = { handle: reg.player, token: reg.token } + saveIdentity(next) + setIdentity(next) + setHandleInput('') + } catch (e) { + setIdentityError(e instanceof Error ? e.message : 'failed to register') + } + }, [handleInput]) + + const onClaim = useCallback( + async (entry: ScoreEntry) => { + if (!identity) return + setClaimState({ scoreId: entry.id, status: 'pending', message: 'Claiming…' }) + try { + const res = await claimReward(entry.id, identity.token) + const verb = res.alreadyClaimed ? 'Already claimed' : 'Claimed' + setClaimState({ + scoreId: entry.id, + status: 'ok', + message: `${verb}: ${res.reward.amountSnake.toLocaleString()} SNAKE (${res.reward.tier})`, + }) + } catch (e) { + setClaimState({ + scoreId: entry.id, + status: 'err', + message: e instanceof Error ? e.message : 'claim failed', + }) + } + }, + [identity], + ) + + // Track the in-flight request so we can cancel it on unmount / next poll. + const abortRef = useRef(null) + + const refresh = useCallback(async () => { + abortRef.current?.abort() + const ctrl = new AbortController() + abortRef.current = ctrl + try { + const data: LeaderboardResponse = await fetchLeaderboard(limit, ctrl.signal) + // Don't clobber state if a newer request has superseded us. + if (ctrl.signal.aborted) return + setEntries(data.entries) + setGeneratedAt(data.generatedAt) + setStatus('ready') + setError(null) + } catch (e) { + if (ctrl.signal.aborted) return + const msg = + e instanceof LeaderboardError + ? e.message + : e instanceof Error + ? e.message + : 'failed to load leaderboard' + setError(msg) + // Keep the previous entries visible if we already had some, so a + // transient network blip doesn't blank the UI. + setStatus(entries.length > 0 ? 'ready' : 'error') + } + }, [limit, entries.length]) + + useEffect(() => { + // Initial load. + void refresh() + return () => abortRef.current?.abort() + }, [refresh]) + + // Fetch the conversion config once. Failures are non-fatal: rows simply + // skip the tier badge if we never get a config. + useEffect(() => { + const ctrl = new AbortController() + fetchRewardsConfig(ctrl.signal) + .then((res) => setConversion(res.config)) + .catch(() => { + /* silent — tier badges will be hidden */ + }) + return () => ctrl.abort() + }, []) + + useEffect(() => { + if (pollMs <= 0) return + let intervalId: number | undefined + const start = () => { + stop() + intervalId = window.setInterval(() => { + void refresh() + }, pollMs) + } + const stop = () => { + if (intervalId !== undefined) { + window.clearInterval(intervalId) + intervalId = undefined + } + } + const onVisibility = () => { + if (document.hidden) stop() + else { + void refresh() + start() + } + } + if (!document.hidden) start() + document.addEventListener('visibilitychange', onVisibility) + return () => { + stop() + document.removeEventListener('visibilitychange', onVisibility) + } + }, [pollMs, refresh]) + + return ( +
+
+

Leaderboard

+ +
+ + {status === 'loading' && entries.length === 0 && ( +

Loading top scores…

+ )} + + {status === 'error' && entries.length === 0 && ( +

+ {error ?? 'Unable to load leaderboard.'} +

+ )} + + {entries.length > 0 && ( + + + + + + + + + + + + {entries.map((entry, index) => { + const isMine = + identity !== null && + entry.player.toLowerCase() === identity.handle.toLowerCase() + const claimMsg = + claimState && claimState.scoreId === entry.id ? claimState : null + const tier = conversion + ? pickTierForScore(conversion.tiers, entry.score) + : null + return ( + + + + + + + + ) + })} + +
+ # + Player + Score + + When + + Reward +
{entry.rank ?? index + 1} + {entry.player} + {tier && ( + + {tier.label} + + )} + + {entry.score.toLocaleString()} + + {formatRelative(entry.createdAt)} + + {isMine ? ( + + ) : ( + + )} + {claimMsg && ( + + {claimMsg.message} + + )} +
+ )} + +
+ {identity ? ( +

+ Playing as {identity.handle}{' '} + +

+ ) : ( +
{ + e.preventDefault() + void onSaveIdentity() + }} + > + + setHandleInput(e.target.value)} + placeholder="alice" + maxLength={32} + /> + + {identityError && ( + + {identityError} + + )} +
+ )} +
+ + {error && entries.length > 0 && ( +

+ Last refresh failed: {error} +

+ )} + + {generatedAt && ( +

+ Updated {formatRelative(generatedAt)} +

+ )} +
+ ) +} + +/** Render an ISO-8601 timestamp as `Ns ago` / `Nm ago` / `Nh ago` / `Nd ago`. */ +function formatRelative(iso: string): string { + const then = Date.parse(iso) + if (Number.isNaN(then)) return iso + const seconds = Math.max(0, Math.round((Date.now() - then) / 1000)) + if (seconds < 60) return `${seconds}s ago` + const minutes = Math.round(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.round(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.round(hours / 24) + return `${days}d ago` +} diff --git a/frontend/src/leaderboard/api.ts b/frontend/src/leaderboard/api.ts new file mode 100644 index 0000000..8b619fc --- /dev/null +++ b/frontend/src/leaderboard/api.ts @@ -0,0 +1,101 @@ +/** + * Tiny `fetch` wrapper around the leaderboard endpoints from T05. + * + * Lives in its own module so the React component stays focused on rendering + * and so it's trivially mockable in any future tests. + */ + +import type { + ClaimRewardResponse, + LeaderboardResponse, + RegisterUserResponse, + RewardsConfigResponse, +} from '@snake/shared' + +export class LeaderboardError extends Error { + constructor(public readonly status: number, message: string) { + super(message) + this.name = 'LeaderboardError' + } +} + +/** + * Fetch the top-N entries from `/api/leaderboard`. + * + * @param limit Number of entries (server clamps to 1..100). + * @param signal AbortSignal so the caller can cancel in-flight requests when + * unmounting or starting a new poll. + */ +export async function fetchLeaderboard( + limit: number, + signal?: AbortSignal, +): Promise { + const url = `/api/leaderboard?limit=${encodeURIComponent(String(limit))}` + const res = await fetch(url, { signal }) + if (!res.ok) { + let message = `leaderboard request failed (${res.status})` + try { + const body = (await res.json()) as { error?: string } + if (body?.error) message = body.error + } catch { + // Body wasn't JSON — keep the default message. + } + throw new LeaderboardError(res.status, message) + } + return (await res.json()) as LeaderboardResponse +} + +/** Register (or look up) a player and obtain their bearer token. */ +export async function registerPlayer(handle: string): Promise { + const res = await fetch('/api/users/register', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ player: handle }), + }) + if (!res.ok) { + let message = `register failed (${res.status})` + try { + const body = (await res.json()) as { error?: string } + if (body?.error) message = body.error + } catch { + // ignore + } + throw new LeaderboardError(res.status, message) + } + return (await res.json()) as RegisterUserResponse +} + +/** Fetch the active conversion config (tiers, multipliers, bonuses). */ +export async function fetchRewardsConfig(signal?: AbortSignal): Promise { + const res = await fetch('/api/rewards/config', { signal }) + if (!res.ok) { + throw new LeaderboardError(res.status, `config request failed (${res.status})`) + } + return (await res.json()) as RewardsConfigResponse +} + +/** Claim the SNAKE reward for a single score. Idempotent server-side. */ +export async function claimReward( + scoreId: string, + token: string, +): Promise { + const res = await fetch('/api/rewards/claim', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-player-token': token, + }, + body: JSON.stringify({ scoreId }), + }) + if (!res.ok) { + let message = `claim failed (${res.status})` + try { + const body = (await res.json()) as { error?: string } + if (body?.error) message = body.error + } catch { + // ignore + } + throw new LeaderboardError(res.status, message) + } + return (await res.json()) as ClaimRewardResponse +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6e10ac1..9cff971 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -56,3 +56,269 @@ h1 { font-size: clamp(2rem, 6vw, 3rem); margin: 0 0 0.5rem; } border-radius: 50%; box-shadow: 0 0 8px rgba(255, 85, 119, 0.8); } + +/* Leaderboard ------------------------------------------------------------- */ + +.leaderboard { + margin: 2rem 0 1rem; + padding: 1rem 1.25rem 1.25rem; + background: #0f1830; + border: 1px solid #1f2c4a; + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); +} + +.leaderboard__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.leaderboard__header h2 { + margin: 0; + font-size: 1.1rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #7ad7ff; +} + +.leaderboard__refresh { + background: transparent; + border: 1px solid #2a3a64; + color: #cfe7ff; + padding: 0.25rem 0.7rem; + border-radius: 999px; + font-size: 0.78rem; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; +} + +.leaderboard__refresh:hover, +.leaderboard__refresh:focus-visible { + background: #18254a; + border-color: #3d5396; + outline: none; +} + +.leaderboard__table { + width: 100%; + border-collapse: collapse; + font-variant-numeric: tabular-nums; +} + +.leaderboard__table th, +.leaderboard__table td { + padding: 0.45rem 0.5rem; + border-bottom: 1px solid #182143; + text-align: left; +} + +.leaderboard__table th { + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #7f93b8; + font-weight: 600; +} + +.leaderboard__table tbody tr:last-child td { + border-bottom: none; +} + +.leaderboard__table tbody tr:nth-child(1) .leaderboard__player { + color: #ffd166; + font-weight: 600; +} + +.leaderboard__table tbody tr:nth-child(2) .leaderboard__player { + color: #d6e1ff; +} + +.leaderboard__table tbody tr:nth-child(3) .leaderboard__player { + color: #c39d7a; +} + +.leaderboard__rank { + width: 2.5rem; + color: #7f93b8; +} + +.leaderboard__score, +.leaderboard__when { + text-align: right; + white-space: nowrap; +} + +.leaderboard__score { + font-weight: 600; +} + +.leaderboard__when { + color: #7f93b8; + font-size: 0.85rem; +} + +.leaderboard__empty, +.leaderboard__error, +.leaderboard__warning, +.leaderboard__updated { + margin: 0.75rem 0 0; + font-size: 0.85rem; +} + +.leaderboard__empty { + color: #7f93b8; +} + +.leaderboard__error { + color: #ff8aa3; +} + +.leaderboard__warning { + color: #ffb86c; +} + +.leaderboard__updated { + color: #5a6a8c; + text-align: right; +} + +@media (max-width: 480px) { + .leaderboard { + padding: 0.75rem 0.75rem 1rem; + } + .leaderboard__when { + display: none; + } + .leaderboard__table th, + .leaderboard__table td { + padding: 0.4rem 0.35rem; + } +} + +/* ---------- Token rewards (T07) ----------------------------------------- */ +.leaderboard__reward { + text-align: right; + font-size: 0.85em; + white-space: nowrap; +} +.leaderboard__reward-dash { + color: #3a4358; +} +.leaderboard__claim { + background: #1f2b48; + color: #ffd479; + border: 1px solid #ffb86c; + border-radius: 4px; + padding: 0.2rem 0.55rem; + cursor: pointer; + font-size: 0.85em; + transition: background 0.15s ease; +} +.leaderboard__claim:hover:not(:disabled) { + background: #2a3a5c; +} +.leaderboard__claim:disabled { + opacity: 0.6; + cursor: progress; +} +.leaderboard__claim-msg { + display: inline-block; + margin-left: 0.5rem; + color: #8be9a0; + font-size: 0.8em; +} +.leaderboard__claim-msg--err { + color: #ff6b6b; +} +.leaderboard__identity { + margin-top: 0.75rem; + padding-top: 0.5rem; + border-top: 1px solid #1a2236; + font-size: 0.85em; + color: #8a9bbb; +} +.leaderboard__identity-form { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.4rem; +} +.leaderboard__identity-label { + color: #8a9bbb; +} +.leaderboard__identity-input { + background: #0a0f1c; + border: 1px solid #2a3a5c; + border-radius: 4px; + color: #e6ecf7; + padding: 0.25rem 0.5rem; + font-size: 0.9em; + min-width: 8rem; +} +.leaderboard__identity-save { + background: #2a3a5c; + color: #e6ecf7; + border: 1px solid #3a4a72; + border-radius: 4px; + padding: 0.25rem 0.65rem; + cursor: pointer; +} +.leaderboard__identity-save:hover { + background: #3a4a72; +} +.leaderboard__identity-error { + color: #ff6b6b; +} +.leaderboard__identity-status strong { + color: #ffd479; +} +.leaderboard__identity-clear { + background: none; + border: none; + color: #6a7a9a; + cursor: pointer; + text-decoration: underline; + font-size: inherit; + padding: 0 0.25rem; +} +.leaderboard__identity-clear:hover { + color: #e6ecf7; +} + +/* ---------- Tier badges (T08) -------------------------------------------- */ +.leaderboard__tier { + display: inline-block; + margin-left: 0.45rem; + padding: 0.05rem 0.4rem; + border-radius: 999px; + font-size: 0.7em; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + vertical-align: middle; + background: #1a2236; + color: #8a9bbb; + border: 1px solid #2a3a5c; +} +.leaderboard__tier--bronze { + background: #2c1d12; + color: #cd8c5c; + border-color: #cd8c5c; +} +.leaderboard__tier--silver { + background: #1c2230; + color: #c0c8d8; + border-color: #c0c8d8; +} +.leaderboard__tier--gold { + background: #2a230d; + color: #ffd479; + border-color: #ffd479; +} +.leaderboard__tier--legendary { + background: #2a0d2a; + color: #ff79c6; + border-color: #ff79c6; +} diff --git a/package-lock.json b/package-lock.json index 6adacf1..4a8f967 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,12 +22,16 @@ "dependencies": { "@snake/shared": "*", "express": "^4.21.1", - "pg": "^8.13.1" + "pg": "^8.13.1", + "zod": "^3.23.8" }, "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^20.16.10", "@types/pg": "^8.11.10", + "@types/supertest": "^6.0.2", + "pg-mem": "^3.0.4", + "supertest": "^7.0.0", "tsx": "^4.19.1", "typescript": "^5.5.4" } @@ -823,6 +827,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1258,6 +1285,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1298,6 +1332,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -1402,6 +1443,30 @@ "@types/node": "*" } }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1555,6 +1620,13 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1565,6 +1637,13 @@ "node": ">=12" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.29", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", @@ -1670,6 +1749,25 @@ "node": ">=8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1747,6 +1845,36 @@ "node": ">= 16" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -1790,6 +1918,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1825,6 +1960,34 @@ "node": ">=6" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1844,6 +2007,24 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "dev": true, + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1917,6 +2098,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -2065,6 +2262,13 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -2098,6 +2302,41 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2140,6 +2379,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true, + "license": "MIT" + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2199,6 +2445,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2211,6 +2470,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -2255,6 +2530,13 @@ "node": ">=0.10.0" } }, + "node_modules/immutable": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", + "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==", + "dev": true, + "license": "MIT" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2270,6 +2552,13 @@ "node": ">= 0.10" } }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2289,6 +2578,26 @@ "node": ">=6" } }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2302,6 +2611,16 @@ "node": ">=6" } }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2410,6 +2729,23 @@ "node": ">= 0.6" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2435,6 +2771,29 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -2451,6 +2810,16 @@ "dev": true, "license": "MIT" }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2463,6 +2832,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2475,6 +2854,16 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2556,6 +2945,85 @@ "node": ">=4.0.0" } }, + "node_modules/pg-mem": { + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/pg-mem/-/pg-mem-3.0.14.tgz", + "integrity": "sha512-G9m8OD0A+YS083smidSUJddTX2dEDPT8mRMG3sQGNiGfS/mkvAgd9Kf1/onD5633bFN7HcQK/Tn2x7qjBMFRUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "functional-red-black-tree": "^1.0.1", + "immutable": "^4.3.4", + "json-stable-stringify": "^1.0.1", + "lru-cache": "^6.0.0", + "moment": "^2.27.0", + "object-hash": "^2.0.3", + "pgsql-ast-parser": "^12.0.2" + }, + "peerDependencies": { + "@mikro-orm/core": ">=4.5.3", + "@mikro-orm/postgresql": ">=4.5.3", + "knex": ">=0.20", + "kysely": ">=0.26", + "pg-promise": ">=10.8.7", + "pg-server": "^0.1.5", + "postgres": "^3.4.4", + "slonik": ">=23.0.1", + "typeorm": ">=0.2.29" + }, + "peerDependenciesMeta": { + "@mikro-orm/core": { + "optional": true + }, + "@mikro-orm/postgresql": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mikro-orm": { + "optional": true + }, + "pg-promise": { + "optional": true + }, + "pg-server": { + "optional": true + }, + "postgres": { + "optional": true + }, + "slonik": { + "optional": true + }, + "typeorm": { + "optional": true + } + } + }, + "node_modules/pg-mem/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pg-mem/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, "node_modules/pg-pool": { "version": "3.13.0", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", @@ -2596,6 +3064,17 @@ "split2": "^4.1.0" } }, + "node_modules/pgsql-ast-parser": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/pgsql-ast-parser/-/pgsql-ast-parser-12.0.2.tgz", + "integrity": "sha512-1WWa96Sw6h4uv9GLw98EzH/+xoBTC8j2TwV/AMW3E+Ir/fHOu/jLLbj6kPiz3y2bGISTKNYvKWwHoqvQ5FLuAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "moo": "^0.5.1", + "nearley": "^2.19.5" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2699,6 +3178,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2758,6 +3258,16 @@ "node": ">=0.10.0" } }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, "node_modules/rollup": { "version": "4.60.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", @@ -2902,6 +3412,24 @@ "node": ">= 0.8.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -3029,6 +3557,65 @@ "dev": true, "license": "MIT" }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3789,6 +4376,13 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -3805,6 +4399,15 @@ "dev": true, "license": "ISC" }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "shared": { "name": "@snake/shared", "version": "0.1.0", diff --git a/shared/src/index.ts b/shared/src/index.ts index 3b11c69..0a0242c 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -13,9 +13,118 @@ export interface ScoreEntry { player: string score: number createdAt: string + /** 1-based position in the leaderboard response (optional, server-supplied). */ + rank?: number } export interface SubmitScoreRequest { player: string score: number + /** Optional client metadata persisted on the session row. */ + meta?: Record +} + +/** Response shape of `POST /api/scores`. */ +export interface SubmitScoreResponse { + entry: ScoreEntry + /** Player's all-time best score after this submission. */ + bestScore: number +} + +/** Response shape of `GET /api/leaderboard?limit=N`. */ +export interface LeaderboardResponse { + entries: ScoreEntry[] + /** When the server generated this response, ISO-8601. */ + generatedAt: string +} + +/** Response shape of `POST /api/users/register`. */ +export interface RegisterUserResponse { + id: string + player: string + /** Bearer token for `X-Player-Token` header on subsequent writes. */ + token: string +} + +// ---------- Token rewards (T07) -------------------------------------------- + +/** 1 SNAKE expressed in nano-units. Authoritative on-wire amounts use nano. */ +export const SNAKE_NANO_PER_TOKEN = 1_000_000_000 + +/** + * Why a token is awarded. + * - 'score' : per-game payout claimed via POST /api/rewards/claim + * - 'top1' / 'top3' / 'top10' : reserved for periodic leaderboard bonuses + * (T07 ships the placeholder amounts in the bonuses endpoint; payouts + * of these reasons land in a follow-up). + */ +export type RewardReason = 'score' | 'top1' | 'top3' | 'top10' + +/** A single SNAKE reward row, returned by claim and history endpoints. */ +export interface RewardEntry { + id: string + playerId: string + player: string + scoreId: string | null + /** Raw SNAKE amount in nano-units. String to avoid JS bigint precision loss. */ + amountNano: string + /** Convenience whole-SNAKE float; UI-friendly, not authoritative. */ + amountSnake: number + reason: RewardReason + /** Human-readable tier label (e.g. `'flat'`, `'bronze'`, `'gold'`). */ + tier: string + createdAt: string +} + +export interface ClaimRewardRequest { + scoreId: string +} + +export interface ClaimRewardResponse { + reward: RewardEntry + /** True when the reward already existed and was returned as-is (idempotent claim). */ + alreadyClaimed: boolean +} + +export interface MyRewardsResponse { + rewards: RewardEntry[] + /** Sum of `amountNano` as a decimal string. */ + totalNano: string + totalSnake: number +} + +export interface LeaderboardBonusEntry { + position: number + amountNano: string + amountSnake: number +} + +export interface LeaderboardBonusesResponse { + bonuses: LeaderboardBonusEntry[] +} + +// ---------- Conversion config (T08) ---------------------------------------- + +export interface RewardTierConfig { + label: string + minScore: number + multiplier: number +} + +export interface PositionBonusConfig { + maxPosition: number + snake: number + reason: RewardReason +} + +export interface ConversionConfig { + tiers: RewardTierConfig[] + positionBonuses: PositionBonusConfig[] + leaderboardBonuses: { position: number; snake: number }[] +} + +export interface RewardsConfigResponse { + config: ConversionConfig + /** True when the server is using the built-in defaults (no env override). */ + isDefault: boolean }