Skip to content

feat: [T08] add configurable score → SNAKE conversion#18

Open
pewpewgogo wants to merge 8 commits into
agntdev:mainfrom
pewpewgogo:feat/T08-token-conversion
Open

feat: [T08] add configurable score → SNAKE conversion#18
pewpewgogo wants to merge 8 commits into
agntdev:mainfrom
pewpewgogo:feat/T08-token-conversion

Conversation

@pewpewgogo
Copy link
Copy Markdown
Contributor

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)

Score range Tier Multiplier
0-99 bronze ×1
100-499 silver ×1.5
500-1999 gold ×2
2000+ legendary ×3

Position bonuses (additive on top of tiered base)

  • 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? }) returning { baseAmount, tierBonus, positionBonus, totalAmount, tierLabel, reason, breakdown[], amountNano, tier }. DEFAULT_CONVERSION_CONFIG exposes the defaults; loadConversionConfig(env) reads SNAKE_REWARD_CONFIG_JSON and falls back gracefully on malformed input.
  • rewards/repo.ts — adds leaderboardPositionForScore(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, runs computeReward with the active config, and persists the chosen tier label on token_rewards.tier. New GET /api/rewards/config returns the active config (with isDefault). /leaderboard-bonuses now reads from the config.

Shared

Purely additive types: ConversionConfig, RewardTierConfig, PositionBonusConfig, RewardsConfigResponse. No breaking changes.

Frontend

  • leaderboard/api.tsfetchRewardsConfig wrapper.
  • 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

Test plan

  • npm run typecheck — clean
  • npm run build — clean
  • npm test --workspace @snake/backend — 43/43 pass

🤖 Generated with Claude Code

pewpewgogo and others added 8 commits May 14, 2026 13:46
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).
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.
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>
@agnt-platform agnt-platform Bot added agnt:approved PR passed automated validation. agnt:manual-review PR is borderline — awaiting admin verdict. and removed agnt:approved PR passed automated validation. agnt:manual-review PR is borderline — awaiting admin verdict. labels May 14, 2026
@agnt-platform
Copy link
Copy Markdown
Contributor

agnt-platform Bot commented May 15, 2026

✅ Revalidation passed. An admin will merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agnt:approved PR passed automated validation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant