Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions backend/migrations/002_token_rewards.sql
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 6 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
188 changes: 188 additions & 0 deletions backend/src/__tests__/conversion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* Unit tests for the score → SNAKE conversion module (T08).
*
* Pure-function tests — no DB, no HTTP. Covers each tier boundary, each
* position-bonus tier, and the env-loader fall-through behaviour.
*/

import { test } from 'node:test'
import assert from 'node:assert/strict'
import { SNAKE_NANO_PER_TOKEN } from '@snake/shared'
import {
DEFAULT_CONVERSION_CONFIG,
computeReward,
loadConversionConfig,
type ConversionConfig,
} from '../rewards/conversion.js'

// ---------- Tier boundaries ------------------------------------------------

test('tier: score 0 → bronze 0 SNAKE', () => {
const r = computeReward({ score: 0 })
assert.equal(r.tierLabel, 'bronze')
assert.equal(r.totalAmount, 0)
assert.equal(r.amountNano, '0')
})

test('tier: score 99 → bronze ×1 = 99 SNAKE', () => {
const r = computeReward({ score: 99 })
assert.equal(r.tierLabel, 'bronze')
assert.equal(r.baseAmount, 99)
assert.equal(r.tierBonus, 0) // 1× tier — no uplift over the raw score
assert.equal(r.totalAmount, 99)
})

test('tier: score 100 → silver ×1.5 = 150 SNAKE (boundary lower)', () => {
const r = computeReward({ score: 100 })
assert.equal(r.tierLabel, 'silver')
assert.equal(r.baseAmount, 150)
assert.equal(r.tierBonus, 50)
})

test('tier: score 499 → silver ×1.5 = 748 SNAKE (boundary upper)', () => {
const r = computeReward({ score: 499 })
assert.equal(r.tierLabel, 'silver')
assert.equal(r.baseAmount, Math.floor(499 * 1.5))
})

test('tier: score 500 → gold ×2 = 1000 SNAKE (boundary)', () => {
const r = computeReward({ score: 500 })
assert.equal(r.tierLabel, 'gold')
assert.equal(r.baseAmount, 1000)
})

test('tier: score 1999 → gold ×2 = 3998 SNAKE', () => {
const r = computeReward({ score: 1999 })
assert.equal(r.tierLabel, 'gold')
assert.equal(r.baseAmount, 3998)
})

test('tier: score 2000 → legendary ×3 = 6000 SNAKE (boundary)', () => {
const r = computeReward({ score: 2000 })
assert.equal(r.tierLabel, 'legendary')
assert.equal(r.baseAmount, 6000)
})

test('tier: huge score stays in legendary tier', () => {
const r = computeReward({ score: 1_000_000 })
assert.equal(r.tierLabel, 'legendary')
assert.equal(r.baseAmount, 3_000_000)
})

test('tier: negative / NaN scores collapse to 0 / bronze', () => {
assert.equal(computeReward({ score: -50 }).totalAmount, 0)
assert.equal(computeReward({ score: Number.NaN }).totalAmount, 0)
assert.equal(computeReward({ score: Number.POSITIVE_INFINITY }).totalAmount, 0)
})

// ---------- Position bonuses -----------------------------------------------

test('position bonus: rank #1 → +100 SNAKE & reason "top1"', () => {
const r = computeReward({ score: 50, leaderboardPosition: 1 })
assert.equal(r.positionBonus, 100)
assert.equal(r.totalAmount, 150) // 50 base + 100 bonus
assert.equal(r.reason, 'top1')
})

test('position bonus: rank #2 → +50 SNAKE & reason "top3"', () => {
const r = computeReward({ score: 50, leaderboardPosition: 2 })
assert.equal(r.positionBonus, 50)
assert.equal(r.reason, 'top3')
})

test('position bonus: rank #3 → +50 SNAKE & reason "top3" (boundary)', () => {
const r = computeReward({ score: 50, leaderboardPosition: 3 })
assert.equal(r.positionBonus, 50)
assert.equal(r.reason, 'top3')
})

test('position bonus: rank #4 → +25 SNAKE & reason "top10"', () => {
const r = computeReward({ score: 50, leaderboardPosition: 4 })
assert.equal(r.positionBonus, 25)
assert.equal(r.reason, 'top10')
})

test('position bonus: rank #10 → +25 SNAKE (boundary)', () => {
const r = computeReward({ score: 50, leaderboardPosition: 10 })
assert.equal(r.positionBonus, 25)
assert.equal(r.reason, 'top10')
})

test('position bonus: rank #11 → no bonus & reason "score"', () => {
const r = computeReward({ score: 50, leaderboardPosition: 11 })
assert.equal(r.positionBonus, 0)
assert.equal(r.reason, 'score')
})

test('position bonus: undefined position → no bonus & reason "score"', () => {
const r = computeReward({ score: 50 })
assert.equal(r.positionBonus, 0)
assert.equal(r.reason, 'score')
})

// ---------- Combined output shape -----------------------------------------

test('breakdown: includes tier line and (optional) position line', () => {
const noBonus = computeReward({ score: 99 })
assert.equal(noBonus.breakdown.length, 1)
assert.match(noBonus.breakdown[0]!.label, /bronze/)

const withBonus = computeReward({ score: 99, leaderboardPosition: 1 })
assert.equal(withBonus.breakdown.length, 2)
assert.match(withBonus.breakdown[1]!.label, /position #1/)
assert.equal(withBonus.breakdown[1]!.snake, 100)
})

test('amountNano = totalAmount × 1e9', () => {
const r = computeReward({ score: 500, leaderboardPosition: 1 }) // 1000 + 100 = 1100
assert.equal(r.totalAmount, 1100)
assert.equal(r.amountNano, String(BigInt(1100) * BigInt(SNAKE_NANO_PER_TOKEN)))
})

// ---------- Custom config --------------------------------------------------

test('custom config: replaces defaults wholesale', () => {
const cfg: ConversionConfig = {
tiers: [
{ label: 'flat', minScore: 0, multiplier: 1 },
{ label: 'epic', minScore: 1000, multiplier: 10 },
],
positionBonuses: [{ maxPosition: 1, snake: 5, reason: 'top1' }],
leaderboardBonuses: [{ position: 1, snake: 5 }],
}
const r = computeReward({ score: 1500, leaderboardPosition: 2, config: cfg })
assert.equal(r.tierLabel, 'epic')
assert.equal(r.baseAmount, 15000)
assert.equal(r.positionBonus, 0) // rank 2 misses the only bonus tier
assert.equal(r.totalAmount, 15000)
})

// ---------- loadConversionConfig ------------------------------------------

test('loadConversionConfig: empty env returns defaults (same reference)', () => {
const cfg = loadConversionConfig({} as NodeJS.ProcessEnv)
assert.equal(cfg, DEFAULT_CONVERSION_CONFIG)
})

test('loadConversionConfig: partial JSON merges per top-level key', () => {
const raw = JSON.stringify({
positionBonuses: [{ maxPosition: 1, snake: 9999, reason: 'top1' }],
})
const cfg = loadConversionConfig({ SNAKE_REWARD_CONFIG_JSON: raw } as NodeJS.ProcessEnv)
assert.equal(cfg.positionBonuses[0]!.snake, 9999)
// Tiers untouched
assert.equal(cfg.tiers, DEFAULT_CONVERSION_CONFIG.tiers)
})

test('loadConversionConfig: malformed JSON falls back to defaults', () => {
const cfg = loadConversionConfig({
SNAKE_REWARD_CONFIG_JSON: 'not-json',
} as NodeJS.ProcessEnv)
assert.equal(cfg, DEFAULT_CONVERSION_CONFIG)
})

test('loadConversionConfig: invalid tier shape falls back to defaults', () => {
const raw = JSON.stringify({ tiers: [] }) // empty tiers fails validation
const cfg = loadConversionConfig({ SNAKE_REWARD_CONFIG_JSON: raw } as NodeJS.ProcessEnv)
assert.equal(cfg, DEFAULT_CONVERSION_CONFIG)
})
141 changes: 141 additions & 0 deletions backend/src/__tests__/leaderboard.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading