From d17f39a03d77199999fa73b07da77ebc306e9a0c Mon Sep 17 00:00:00 2001 From: Volodya Date: Thu, 14 May 2026 13:46:36 +0100 Subject: [PATCH 1/4] feat: [T04] design leaderboard database schema Adds the PostgreSQL schema for the snake leaderboard: - users: one row per player handle, with bearer token for X-Player-Token auth - sessions: one row per game played (start/end + final score + meta JSONB) - scores: append-only score feed indexed for top-N reads Indexes: - users_player_lower_uniq case-insensitive unique handle - users_api_token_uniq token lookup for write auth - sessions_user_started_idx recent sessions per user - scores_score_created_idx global top-N (ties resolved by earliest submit) - scores_user_score_idx best-score-per-user Migrations live as numbered .sql files under backend/migrations/ and are idempotent. backend/src/db.ts exposes a lazy pool plus a runMigrations() helper for tests/bootstrap so typecheck does not require a running DB. Adds pg + @types/pg to backend. --- backend/migrations/001_init.sql | 78 +++++++++++++++++++++++++++++++++ backend/migrations/README.md | 28 ++++++++++++ backend/package.json | 4 +- backend/src/db.ts | 67 ++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/001_init.sql create mode 100644 backend/migrations/README.md create mode 100644 backend/src/db.ts diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql new file mode 100644 index 0000000..84174d7 --- /dev/null +++ b/backend/migrations/001_init.sql @@ -0,0 +1,78 @@ +-- 001_init.sql +-- Initial schema for the snake leaderboard. +-- +-- Three tables: +-- users — registered players (one row per unique player handle) +-- sessions — one row per game played (start/end time, final score, optional metadata) +-- scores — denormalized "best/notable scores" feed used by the leaderboard +-- +-- We keep `scores` separate from `sessions` so the leaderboard read path is a +-- cheap index scan and not a `MAX(score) GROUP BY user` over the full session log. +-- +-- All statements are idempotent so a bootstrap helper can safely re-run them. + +BEGIN; + +-- --------------------------------------------------------------------------- +-- users +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + -- Display handle. Case-insensitive uniqueness is enforced via the index + -- below (CITEXT would be nicer but adds an extension dependency). + player TEXT NOT NULL, + -- Opaque bearer token used by the API for `X-Player-Token` auth. Stored + -- as a hex string; rotation is just an UPDATE. + api_token TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS users_player_lower_uniq + ON users (LOWER(player)); + +CREATE UNIQUE INDEX IF NOT EXISTS users_api_token_uniq + ON users (api_token); + +-- --------------------------------------------------------------------------- +-- sessions +-- --------------------------------------------------------------------------- +-- One row per played game. `ended_at IS NULL` means in-progress. +CREATE TABLE IF NOT EXISTS sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ, + final_score INTEGER NOT NULL DEFAULT 0 CHECK (final_score >= 0), + -- Free-form JSON for client-side metadata (board size, tick rate, etc.). + -- Useful for analytics; the leaderboard does not read it. + meta JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS sessions_user_started_idx + ON sessions (user_id, started_at DESC); + +-- --------------------------------------------------------------------------- +-- scores +-- --------------------------------------------------------------------------- +-- Append-only feed of submitted scores. The leaderboard query reads this +-- table directly. We index `(score DESC, created_at ASC)` so that +-- "top-N global" is a single index scan and ties resolve by who got there +-- first. +CREATE TABLE IF NOT EXISTS scores ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_id BIGINT REFERENCES sessions(id) ON DELETE SET NULL, + score INTEGER NOT NULL CHECK (score >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Top-N leaderboard read path. +CREATE INDEX IF NOT EXISTS scores_score_created_idx + ON scores (score DESC, created_at ASC); + +-- "Best score per user" lookups (`GET /api/users/:id/best`). +CREATE INDEX IF NOT EXISTS scores_user_score_idx + ON scores (user_id, score DESC); + +COMMIT; diff --git a/backend/migrations/README.md b/backend/migrations/README.md new file mode 100644 index 0000000..a6bf3c9 --- /dev/null +++ b/backend/migrations/README.md @@ -0,0 +1,28 @@ +# Migrations + +Plain numbered SQL files. Apply with `psql`: + +```bash +DATABASE_URL=postgres://user:pass@localhost:5432/snake \ + psql "$DATABASE_URL" -f backend/migrations/001_init.sql +``` + +Or, from a Node script, use `backend/src/db.ts` which exposes `runMigrations(client)` for tests/bootstrap. + +All statements are wrapped in `BEGIN/COMMIT` and use `IF NOT EXISTS`, so re-running on an already-initialised database is a no-op. + +## Schema overview + +| Table | Purpose | +|------------|----------------------------------------------------------------------| +| `users` | One row per player handle. Holds the bearer token used for write auth. | +| `sessions` | One row per game played. `ended_at IS NULL` while in progress. | +| `scores` | Append-only feed of submitted scores. Indexed for top-N reads. | + +### Indexes + +- `users_player_lower_uniq` — case-insensitive unique handle. +- `users_api_token_uniq` — token lookup for `X-Player-Token` auth. +- `sessions_user_started_idx` — recent-sessions-per-user reads. +- `scores_score_created_idx` — global top-N leaderboard (score DESC, ties resolved by earliest submit). +- `scores_user_score_idx` — best-score-per-user lookups. diff --git a/backend/package.json b/backend/package.json index fe4d771..e3c5ffa 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,11 +13,13 @@ }, "dependencies": { "@snake/shared": "*", - "express": "^4.21.1" + "express": "^4.21.1", + "pg": "^8.13.1" }, "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^20.16.10", + "@types/pg": "^8.11.10", "tsx": "^4.19.1", "typescript": "^5.5.4" } diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..02bc4e2 --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,67 @@ +/** + * Database access layer. + * + * Why a thin abstraction: + * - The leaderboard backend talks to PostgreSQL via `pg`, but typechecking + * and unit tests must not require a running database. `getPool()` is lazy: + * a Pool is only constructed on first call. + * - Tests can swap in any object that implements the minimal `Db` interface + * below (e.g. `pg-mem`'s adapter) by calling `setDb()`. + * - All query helpers go through this module so the rest of the codebase + * never imports `pg` directly. Keeps the surface area small. + */ + +import { readFile, readdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** Minimal subset of `pg.Pool` we depend on. */ +export interface Db { + query(text: string, params?: unknown[]): Promise<{ rows: T[]; rowCount: number | null }> +} + +let _db: Db | null = null + +/** + * Returns the active database handle, lazily constructing a `pg.Pool` from + * `DATABASE_URL` on first call. Throws if `DATABASE_URL` is not set and no + * test harness has called `setDb()`. + */ +export async function getDb(): Promise { + if (_db) return _db + const url = process.env.DATABASE_URL + if (!url) { + throw new Error( + 'DATABASE_URL is not set. Either configure it, or call setDb() with a test adapter.', + ) + } + // Imported lazily so `pg` is not required for typecheck or tests that + // never touch a real database. + const { Pool } = await import('pg') + const pool = new Pool({ connectionString: url }) + _db = pool as unknown as Db + return _db +} + +/** Inject a custom adapter (e.g. pg-mem) for tests. */ +export function setDb(db: Db | null): void { + _db = db +} + +/** + * Apply every `.sql` file in `backend/migrations/` in lexicographic order. + * Files are idempotent so re-running is safe. + */ +export async function runMigrations(db?: Db): Promise { + const handle = db ?? (await getDb()) + const here = dirname(fileURLToPath(import.meta.url)) + // src/ -> backend/migrations + const migrationsDir = join(here, '..', 'migrations') + const files = (await readdir(migrationsDir)) + .filter((f) => f.endsWith('.sql')) + .sort() + for (const file of files) { + const sql = await readFile(join(migrationsDir, file), 'utf8') + await handle.query(sql) + } +} From 803eedab2711a673a8395743c6c9015691cfc2b1 Mon Sep 17 00:00:00 2001 From: Volodya Date: Thu, 14 May 2026 13:52:02 +0100 Subject: [PATCH 2/4] feat: [T05] implement score tracking API REST endpoints under /api: POST /api/users/register create user, returns X-Player-Token POST /api/scores submit a score (auth required) GET /api/leaderboard?limit=N top-N (limit clamped 1..100, default 10) GET /api/users/:id/best all-time best for one user Layered as: routes/leaderboard.ts -> repo.ts -> db.ts. SQL lives only in repo.ts; routes do validation + auth. Validation uses zod (handle regex, score >= 0 and bounded, limit clamp). Auth model: a single bearer token per user, sent as X-Player-Token. That's sufficient for a hobby leaderboard and avoids dragging in JWT/bcrypt for T05's scope. Tests: 9 integration tests under src/__tests__/leaderboard.test.ts using pg-mem to provide an in-memory PostgreSQL adapter, so CI does not need a real database. Migrations are applied through the same runMigrations() helper used in production. Test runner: node --test (--import tsx). Shared types extended additively: - SubmitScoreRequest gains optional `meta` - ScoreEntry gains optional `rank` - new SubmitScoreResponse / LeaderboardResponse / RegisterUserResponse Adds zod (runtime), pg-mem + supertest (tests). --- backend/package.json | 8 +- backend/src/__tests__/leaderboard.test.ts | 141 ++++++++++++++++ backend/src/repo.ts | 153 +++++++++++++++++ backend/src/routes/leaderboard.ts | 193 ++++++++++++++++++++++ backend/src/server.ts | 6 + shared/src/index.ts | 26 +++ 6 files changed, 525 insertions(+), 2 deletions(-) create mode 100644 backend/src/__tests__/leaderboard.test.ts create mode 100644 backend/src/repo.ts create mode 100644 backend/src/routes/leaderboard.ts 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__/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/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/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/server.ts b/backend/src/server.ts index 1620a79..e738be9 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,4 +1,5 @@ import express from 'express' +import { leaderboardRouter } from './routes/leaderboard.js' const app = express() app.use(express.json()) @@ -7,6 +8,11 @@ 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()) + const port = Number(process.env.PORT ?? 8787) if (process.env.NODE_ENV !== 'test') { diff --git a/shared/src/index.ts b/shared/src/index.ts index 3b11c69..1c5a425 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -13,9 +13,35 @@ 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 } From fce4c1ae65ec643b6319e1a0a2f3af294e6d0a33 Mon Sep 17 00:00:00 2001 From: Volodya Date: Thu, 14 May 2026 13:56:57 +0100 Subject: [PATCH 3/4] feat: [T06] create leaderboard UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds frontend/src/leaderboard/: - api.ts tiny fetch wrapper with typed response + LeaderboardError - Leaderboard.tsx responsive React component (table of top-N) Behaviour: - Polls GET /api/leaderboard every 5s by default (pollMs prop) - Pauses polling when document.hidden so background tabs don't burn cycles - Cancels in-flight requests on unmount / next refresh via AbortController - Loading, error, and empty states; transient failures keep prior data visible with a warning banner (no UI blanking on a network blip) - Manual Refresh button - Relative timestamps (Ns/Nm/Nh/Nd ago) Styling: - Themed to match T01's dark palette (#0a0f1c base) - Gold/silver/bronze player colour for top-3 - Mobile breakpoint at 480px hides the When column for narrow screens Wired into App.tsx alongside the Board from T02 (additive — game UI is unchanged). Uses LeaderboardResponse / ScoreEntry from @snake/shared. --- frontend/src/App.tsx | 2 + frontend/src/leaderboard/Leaderboard.tsx | 177 +++++++++++++++++++++++ frontend/src/leaderboard/api.ts | 41 ++++++ frontend/src/styles.css | 140 ++++++++++++++++++ 4 files changed, 360 insertions(+) create mode 100644 frontend/src/leaderboard/Leaderboard.tsx create mode 100644 frontend/src/leaderboard/api.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c63a87e..da989ca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { stepMovement, } from './game/movement' import { Board } from './ui/Board' +import { Leaderboard } from './leaderboard/Leaderboard' type Action = | { type: 'tick' } @@ -88,6 +89,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..2e5ffba --- /dev/null +++ b/frontend/src/leaderboard/Leaderboard.tsx @@ -0,0 +1,177 @@ +/** + * 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 { LeaderboardResponse, ScoreEntry } from '@snake/shared' +import { LeaderboardError, fetchLeaderboard } from './api' + +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) + + // 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]) + + 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) => ( + + + + + + + ))} + +
+ # + Player + Score + + When +
{entry.rank ?? index + 1}{entry.player}{entry.score.toLocaleString()} + {formatRelative(entry.createdAt)} +
+ )} + + {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..4f9d481 --- /dev/null +++ b/frontend/src/leaderboard/api.ts @@ -0,0 +1,41 @@ +/** + * 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 { LeaderboardResponse } 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 +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6e10ac1..f795b5d 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -56,3 +56,143 @@ 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; + } +} From e03ebe3dda3ea090cb1ae4e8dba5e1b4c9cd68e9 Mon Sep 17 00:00:00 2001 From: Volodya Date: Thu, 14 May 2026 14:08:51 +0100 Subject: [PATCH 4/4] feat: [T07] integrate token rewards on top of leaderboard API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a SNAKE-token reward system layered onto the T05 score API. The conversion logic is intentionally a placeholder this PR (one-line swap in T08); everything else — schema, routes, idempotency, auth, UI — is real. Backend: - migrations/002_token_rewards.sql: token_rewards(player_id, score_id, amount_nano BIGINT, reason, tier). UNIQUE(score_id WHERE NOT NULL) gives idempotency for free. - rewards/conversion.ts: pure computeReward({score}) — T07 returns floor(score) * 1e9 nano under tier 'flat'. T08 will replace the body. - rewards/repo.ts: SQL helpers for the new table. - routes/rewards.ts: POST /api/rewards/claim — auth-gated, idempotent, 403 if score belongs to another player, 404 if missing. GET /api/rewards/me — player history + totals. GET /api/rewards/leaderboard-bonuses — placeholder 100/50/25 SNAKE. - server.ts: mount the new router under /api. Shared: - Adds RewardEntry, ClaimRewardRequest/Response, MyRewardsResponse, LeaderboardBonusEntry/Response, RewardReason, SNAKE_NANO_PER_TOKEN. All purely additive; no existing types touched. Frontend: - leaderboard/api.ts: registerPlayer + claimReward fetch wrappers. - leaderboard/Leaderboard.tsx: identity input persisted to localStorage, "Claim" button on rows that match the saved handle, inline status msg showing minted SNAKE amount + tier label. Tests: - 9 new rewards.test.ts cases (pg-mem + supertest), covering the happy path, idempotency, cross-player claim rejection, missing score, auth failures, validation, history totals, and bonuses endpoint. Off-chain bookkeeping only — no TON wallet calls, per task body. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/migrations/002_token_rewards.sql | 41 ++ backend/src/__tests__/rewards.test.ts | 169 +++++++ backend/src/rewards/conversion.ts | 60 +++ backend/src/rewards/repo.ts | 130 +++++ backend/src/routes/rewards.ts | 203 ++++++++ backend/src/server.ts | 4 + frontend/src/leaderboard/Leaderboard.tsx | 196 +++++++- frontend/src/leaderboard/api.ts | 52 +- frontend/src/styles.css | 90 ++++ package-lock.json | 605 ++++++++++++++++++++++- shared/src/index.ts | 57 +++ 11 files changed, 1594 insertions(+), 13 deletions(-) create mode 100644 backend/migrations/002_token_rewards.sql create mode 100644 backend/src/__tests__/rewards.test.ts create mode 100644 backend/src/rewards/conversion.ts create mode 100644 backend/src/rewards/repo.ts create mode 100644 backend/src/routes/rewards.ts 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/src/__tests__/rewards.test.ts b/backend/src/__tests__/rewards.test.ts new file mode 100644 index 0000000..732c580 --- /dev/null +++ b/backend/src/__tests__/rewards.test.ts @@ -0,0 +1,169 @@ +/** + * 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 a reward equal to floor(score) SNAKE', async () => { + const app = await makeApp() + 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, 'score') + assert.equal(claim.body.reward.tier, 'flat') + assert.equal(claim.body.reward.amountSnake, 120) + assert.equal(claim.body.reward.amountNano, String(BigInt(120) * 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) + assert.equal(me.body.totalSnake, 150) + assert.equal(me.body.totalNano, String(BigInt(150) * 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 placeholder 100/50/25 SNAKE', 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], + ], + ) +}) diff --git a/backend/src/rewards/conversion.ts b/backend/src/rewards/conversion.ts new file mode 100644 index 0000000..4e67d69 --- /dev/null +++ b/backend/src/rewards/conversion.ts @@ -0,0 +1,60 @@ +/** + * Score → SNAKE-token conversion. + * + * T07 ships a *placeholder*: `floor(score)` SNAKE per claim, single tier + * 'flat'. T08 replaces `computeReward` with a real tiered/multiplier + * implementation; the route layer doesn't care which is in effect. + * + * Keeping this in its own module (rather than inlining in the route) so: + * - T08 is a one-file swap inside the same path + * - Unit tests target the pure function without spinning up a router + */ + +import { SNAKE_NANO_PER_TOKEN, type RewardReason } from '@snake/shared' + +export interface ComputeRewardInput { + score: number + /** Optional 1-based leaderboard position; T07 ignores it. */ + leaderboardPosition?: number +} + +export interface ComputeRewardOutput { + /** SNAKE amount in nano-units, returned as a string for safe JSON. */ + amountNano: string + /** Tier label persisted on the reward row (T07 = 'flat'). */ + tier: string + /** Reason persisted on the reward row (T07 = 'score'). */ + reason: RewardReason +} + +/** + * T07 placeholder: floor(score) whole SNAKE per claim, no tier logic. + * + * amountNano = floor(max(0, score)) * 1_000_000_000 + * + * Negative or 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 safeScore = Number.isFinite(input.score) ? Math.max(0, Math.floor(input.score)) : 0 + const amountNano = (BigInt(safeScore) * BigInt(SNAKE_NANO_PER_TOKEN)).toString() + return { + amountNano, + tier: 'flat', + reason: 'score', + } +} + +/** + * Top-N bonus amounts surfaced by `GET /api/rewards/leaderboard-bonuses`. + * + * T07 placeholder values (also in whole SNAKE): + * 1st → 100, 2nd → 50, 3rd → 25. + * + * T08 makes this configurable; the API shape is preserved. + */ +export const PLACEHOLDER_LEADERBOARD_BONUSES: ReadonlyArray<{ position: number; snake: number }> = [ + { position: 1, snake: 100 }, + { position: 2, snake: 50 }, + { position: 3, snake: 25 }, +] diff --git a/backend/src/rewards/repo.ts b/backend/src/rewards/repo.ts new file mode 100644 index 0000000..95c0758 --- /dev/null +++ b/backend/src/rewards/repo.ts @@ -0,0 +1,130 @@ +/** + * 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 +} + +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/rewards.ts b/backend/src/routes/rewards.ts new file mode 100644 index 0000000..b5547cd --- /dev/null +++ b/backend/src/routes/rewards.ts @@ -0,0 +1,203 @@ +/** + * 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, + rewardsForPlayer, + type RewardRow, +} from '../rewards/repo.js' +import { + PLACEHOLDER_LEADERBOARD_BONUSES, + computeReward, +} 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 +} + +export function rewardsRouter(opts: RewardsRouterOptions = {}): Router { + const router = Router() + const db = async (): Promise => opts.db ?? (await getDb()) + + 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 + } + + const { amountNano, tier, reason } = computeReward({ score: score.score }) + 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[] = PLACEHOLDER_LEADERBOARD_BONUSES.map((b) => ({ + position: b.position, + amountNano: snakeToNano(b.snake), + amountSnake: b.snake, + })) + const out: LeaderboardBonusesResponse = { bonuses } + res.json(out) + } 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 e738be9..34e5986 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,5 +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()) @@ -13,6 +14,9 @@ app.get('/api/health', (_req, res) => { // 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/leaderboard/Leaderboard.tsx b/frontend/src/leaderboard/Leaderboard.tsx index 2e5ffba..2695b06 100644 --- a/frontend/src/leaderboard/Leaderboard.tsx +++ b/frontend/src/leaderboard/Leaderboard.tsx @@ -9,7 +9,41 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { LeaderboardResponse, ScoreEntry } from '@snake/shared' -import { LeaderboardError, fetchLeaderboard } from './api' +import { + LeaderboardError, + claimReward, + fetchLeaderboard, + 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. + } +} export interface LeaderboardProps { /** Number of entries to fetch and render. Default 10. */ @@ -26,6 +60,55 @@ export function Leaderboard({ limit = 10, pollMs = 5000 }: LeaderboardProps) { const [status, setStatus] = useState('loading') const [error, setError] = 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) @@ -130,23 +213,114 @@ export function Leaderboard({ limit = 10, pollMs = 5000 }: LeaderboardProps) { When + + Reward + - {entries.map((entry, index) => ( - - {entry.rank ?? index + 1} - {entry.player} - {entry.score.toLocaleString()} - - {formatRelative(entry.createdAt)} - - - ))} + {entries.map((entry, index) => { + const isMine = + identity !== null && + entry.player.toLowerCase() === identity.handle.toLowerCase() + const claimMsg = + claimState && claimState.scoreId === entry.id ? claimState : null + return ( + + {entry.rank ?? index + 1} + {entry.player} + + {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} diff --git a/frontend/src/leaderboard/api.ts b/frontend/src/leaderboard/api.ts index 4f9d481..4f894f3 100644 --- a/frontend/src/leaderboard/api.ts +++ b/frontend/src/leaderboard/api.ts @@ -5,7 +5,11 @@ * and so it's trivially mockable in any future tests. */ -import type { LeaderboardResponse } from '@snake/shared' +import type { + ClaimRewardResponse, + LeaderboardResponse, + RegisterUserResponse, +} from '@snake/shared' export class LeaderboardError extends Error { constructor(public readonly status: number, message: string) { @@ -39,3 +43,49 @@ export async function fetchLeaderboard( } 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 +} + +/** 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 f795b5d..5530d5e 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -196,3 +196,93 @@ h1 { font-size: clamp(2rem, 6vw, 3rem); margin: 0 0 0.5rem; } 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; +} 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 1c5a425..65b2886 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -45,3 +45,60 @@ export interface RegisterUserResponse { /** 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[] +}