feat: [T08] add configurable score → SNAKE conversion#18
Open
pewpewgogo wants to merge 8 commits into
Open
Conversation
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.
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).
…d-ui # Conflicts: # backend/package.json
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.
# Conflicts: # backend/package.json
…at/T07-token-rewards
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) <noreply@anthropic.com>
…on bonuses)
Replaces the T07 placeholder `floor(score)` with a real conversion engine:
- Tiers (default):
0-99 bronze ×1
100-499 silver ×1.5
500-1999 gold ×2
2000+ legendary ×3
- Position bonuses (additive):
rank 1 +100 SNAKE reason 'top1'
rank 2-3 +50 SNAKE reason 'top3'
rank 4-10 +25 SNAKE reason 'top10'
Backend
- rewards/conversion.ts: pure `computeReward({score, leaderboardPosition?, config?})`
returns `{baseAmount, tierBonus, positionBonus, totalAmount, tierLabel,
reason, breakdown[], amountNano, tier}`. Negative/NaN scores collapse to 0.
- DEFAULT_CONVERSION_CONFIG and `loadConversionConfig(env)` allow operators
to override tiers and bonuses via SNAKE_REWARD_CONFIG_JSON. Malformed
or invalid JSON logs once and falls back to defaults.
- rewards/repo.ts: adds `leaderboardPositionForScore(scoreId)` (two-query
impl — pg-mem doesn't resolve correlated subquery aliases).
- routes/rewards.ts: claim now looks up the score's current global rank,
runs `computeReward` with the active config, and persists the chosen
tier label on `token_rewards.tier`. New `GET /api/rewards/config`
surfaces the active config (with `isDefault` flag) so the UI can render
"next tier at X" hints. /leaderboard-bonuses now reads from the config.
Shared
- Adds ConversionConfig, RewardTierConfig, PositionBonusConfig,
RewardsConfigResponse — purely additive.
Frontend
- leaderboard/api.ts: fetchRewardsConfig() wrapper.
- leaderboard/Leaderboard.tsx: fetches the config once and renders a small
coloured tier badge (bronze/silver/gold/legendary) next to each player.
Pure-CSS, follows the existing dark palette.
Tests
- 21 new pure-function tests in conversion.test.ts: each tier boundary
(0/99/100/499/500/1999/2000/huge/negative/NaN), each position-bonus
threshold (agntdev#1/agntdev#2/agntdev#3/agntdev#4/agntdev#10/agntdev#11/undefined), breakdown shape, custom
config, and the env-loader fall-through behaviour (empty env, partial
JSON, malformed JSON, invalid tier shape).
- rewards.test.ts updated to reflect new totals and adds checks for
GET /api/rewards/config and tier-label persistence on the row.
- 43/43 backend tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
|
✅ Revalidation passed. An admin will merge. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the T07
floor(score)placeholder with a real conversion engine: tiered multipliers, additive top-N position bonuses, and an env-overridable config object. Stacks on top of T07 (#17).Tiers (defaults)
Position bonuses (additive on top of tiered base)
top1top3top10Backend
rewards/conversion.ts— purecomputeReward({ score, leaderboardPosition?, config? })returning{ baseAmount, tierBonus, positionBonus, totalAmount, tierLabel, reason, breakdown[], amountNano, tier }.DEFAULT_CONVERSION_CONFIGexposes the defaults;loadConversionConfig(env)readsSNAKE_REWARD_CONFIG_JSONand falls back gracefully on malformed input.rewards/repo.ts— addsleaderboardPositionForScore(scoreId). (Two-query implementation because pg-mem doesn't resolve correlated subquery aliases.)routes/rewards.ts— claim now looks up the score's current global rank, runscomputeRewardwith the active config, and persists the chosen tier label ontoken_rewards.tier. NewGET /api/rewards/configreturns the active config (withisDefault)./leaderboard-bonusesnow reads from the config.Shared
Purely additive types:
ConversionConfig,RewardTierConfig,PositionBonusConfig,RewardsConfigResponse. No breaking changes.Frontend
leaderboard/api.ts—fetchRewardsConfigwrapper.leaderboard/Leaderboard.tsx— fetches the config once, renders a small coloured tier badge (bronze/silver/gold/legendary) next to each player handle. CSS follows the existing dark palette.Tests
conversion.test.tscovering each tier boundary (0/99/100/499/500/1999/2000/huge/negative/NaN), each position-bonus threshold ([T01] Initialize Project Structure #1/[T02] Implement Snake Movement #2/[T03] Add Food and Collision Logic #3/[T04] Design Leaderboard Database #4/feat: [T01] scaffold TypeScript monorepo (frontend + backend + shared) #10/feat: [T04] design leaderboard database schema #11/undefined), breakdown shape, custom config, and env-loader fall-through (empty/partial/malformed JSON, invalid tier shape).rewards.test.tsupdated to reflect new totals; new cases forGET /api/rewards/configand tier-label persistence on the row.Test plan
npm run typecheck— cleannpm run build— cleannpm test --workspace @snake/backend— 43/43 pass🤖 Generated with Claude Code