Underground 2 Revived
Play Need for Speed: Underground 2 (2004) in Chrome and Firefox with WebGPU graphics, Web Audio, up to 120 fps widescreen, controller support, free roam and Online races with friends. Fan project, not
翻译准备中
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗A Vietnamese multiplayer card game (Tiến Lên Miền Nam, 2–4 players) delivered as a responsive, installable Next.js PWA with a NestJS backend.
All coin values are virtual game points. There are no deposits, withdrawals, payments, or real-world prize redemption. Adding any of these requires a separate legal, compliance, and security review first.
Status: Phases 1–8 and the rules/rounds/spectators/invitations/statistics enhancement (docs/ENHANCEMENT_AUDIT.md). Phases 1–8 (foundation, UI and PWA, accounts, game engine, multiplayer, wallet and rewards, history and administration, quality and deployment) are complete. Deployment and operations: docs/DEPLOYMENT.md; free hosting without a server: docs/FREE_HOSTING.md. See docs/IMPLEMENTATION_CHECKLIST.md.
.
├── apps/
│ ├── web/ Next.js 16 (App Router) + React 19 + Tailwind CSS 4 — the PWA client
│ └── server/ NestJS 11 — REST API and Socket.IO notifications, both under /api/v1
│ ├── prisma/ Prisma schema, migrations, seed (PostgreSQL)
│ └── src/ config · common · auth · users · rooms · game · matchmaking · realtime
├── packages/
│ └── shared/ Zod schemas, error codes, lifecycle state machines, constants and
│ the game engine, shared by web and server (CommonJS in dist/)
├── docs/ GAME_RULES.md (rule specification), IMPLEMENTATION_CHECKLIST.md
├── scripts/ embedded-postgres.mjs — Docker-less PostgreSQL for local dev
├── deploy/ Production stack for one server (Caddy, Compose, setup and deploy scripts)
├── docker-compose.yml PostgreSQL 17 + Redis 7 for local development
└── .github/workflows/ CI: format, lint, typecheck, test, migrate, seed, build; Deploy
Boundaries:
@tienlen/shared is the single source of truth for API contracts (Zod schemas), error
codes plus Vietnamese messages, and the room/match lifecycle transition tables. The Prisma
enums are tested against these tables (apps/server/test/schema-contract.test.ts).packages/shared/src/game/ (see Game rules and engine),
never in React components.| Area | Choice |
|---|---|
| Runtime | Node.js ≥ 20.19, pnpm 10 workspaces |
| Web | Next.js 16, React 19, Tailwind CSS 4, Zod 4 |
| Server | NestJS 11 (Express 5), nestjs-pino, helmet, ioredis 5 |
| Data | PostgreSQL 17, Prisma 6.19, Redis 7 |
| Quality | TypeScript 5.9 (strict), ESLint 9, Prettier 3, Vitest 3 |
Version notes (deliberate pins):
eslint-config-next depends on eslint-plugin-react and
eslint-plugin-import, which do not yet declare ESLint 10 support.emitDecoratorMetadata.apps/server/prisma/, not at the repository root as the original plan
sketched: the ORM is a server concern, and this keeps client generation and the seed's
module resolution straightforward under pnpm.node -v)npm i -g pnpm@10 or corepack enable)pnpm services:up (recommended)pnpm db:embedded runs a real PostgreSQL from npm binaries (dev only).
There is no equivalent Redis fallback; install Redis (or Memurai on Windows) separately.
Without Redis the API still starts, but /health/ready reports degraded (HTTP 503).pnpm install
cp .env.example .env # adjust if needed; never commit .env
pnpm services:up # or, without Docker: pnpm db:embedded (separate terminal)
pnpm db:deploy # apply migrations (use db:migrate when changing the schema)
pnpm db:seed # reference data: default rule preset, achievements
pnpm dev # web on :3000, API on :4000
Then open:
/health/ready — API probesAll variables live in one root .env (see .env.example). The server validates
its environment with Zod at startup and refuses to boot when it is invalid. The error lists
variable names, never their values.
| Variable | Used by | Required | Default | Notes |
|---|---|---|---|---|
DATABASE_URL | server, Prisma | yes | — | postgresql://… |
REDIS_URL | server | no | redis://localhost:6379 | redis:// or rediss:// |
PORT | server | no | 4000 | |
WEB_ORIGIN | server | no | http://localhost:3000 | Comma-separated CORS allow-list (http/https) |
LOG_LEVEL | server | no | info | silent … trace |
NEXT_PUBLIC_API_URL | web (browser) | no | http://localhost:4000/api/v1 | Inlined at build time |
API_INTERNAL_URL | web (server) | no | NEXT_PUBLIC_API_URL | API base URL from inside the network |
API_PROXY_ORIGIN | web (build+run) | no | — | Proxy /api/v1 to this API origin (Vercel) |
NEXT_PUBLIC_UI_PREVIEW | web (build) | no | false | Mock data on signed-in screens; never deploy |
POSTGRES_* | docker compose | no | see .env.example | Must match DATABASE_URL |
TRUST_PROXY | server | no | 0 | Reverse proxies whose X-Forwarded-For counts |
WEB_APP_URL | server | no | first WEB_ORIGIN | Base of links in emails |
API_PUBLIC_URL | server | no | http://localhost:PORT/api/v1 | Base of avatar URLs |
Email. With SMTP_URL set, emails go through SMTP. Without it, development writes each
email as a text file to MAIL_OUTBOX_DIR (apps/server/.local/mail under pnpm dev), so you
can open verification and reset links. In production without SMTP nothing is sent, and a
warning is logged at startup. Links are never written to the logs.
Cookies across web and API. The browser must send tl_session to both the API and the
web server (which checks the session before rendering signed-in pages). This works when both
run on the same host (as in development), or on sibling subdomains with
COOKIE_DOMAIN=<parent domain>. Web push/VAPID and Google OAuth credentials will be documented
when those features are built.
| Command | What it does |
|---|---|
pnpm dev | Build shared, then run shared (watch), server (watch), web (dev) |
pnpm build | Production build of all packages (topological order) |
pnpm check | format:check + lint + typecheck + test |
pnpm lint | ESLint (type-aware for server/shared; Next rules for web) |
pnpm typecheck | tsc --noEmit everywhere (web runs next typegen first) |
pnpm test | Vitest in every package |
pnpm format | Prettier write |
pnpm db:migrate | prisma migrate dev (create and apply migrations in development) |
pnpm db:deploy | prisma migrate deploy (apply committed migrations; CI/production) |
pnpm db:seed | Idempotent seed of reference data |
pnpm db:backfill | Idempotent: per-round statistics and penalty records of older rounds |
pnpm db:studio | Prisma Studio |
pnpm db:embedded | Docker-less PostgreSQL (dev only, data in .local/pgdata) |
pnpm services:up | Start PostgreSQL + Redis containers |
pnpm app:up / pnpm app:down | Build and run the production images (PostgreSQL, Redis, migrations, API, web) |
pnpm load-test | Concurrent full matches against a running API (see docs/DEPLOYMENT.md) |
pnpm test:integration | API + real PostgreSQL (database <name>_test, created and emptied) |
pnpm db:set-role <email> ADMIN | Grant (or with PLAYER, remove) the admin role; audited |
pnpm --filter @tienlen/web test:e2e | Playwright: production build in preview mode, 3 viewports |
pnpm --filter @tienlen/web test:e2e:auth |
Production start: pnpm --filter @tienlen/server start (runs dist/main.js) and
pnpm --filter @tienlen/web start.
Playwright needs Chromium once: pnpm --filter @tienlen/web exec playwright install chromium.
Set E2E_SCREENSHOT_DIR to save screenshots of the main screens for visual review.
Normally the signed-in screens need a real account (Phase 3). Rooms and matches arrive in
Phase 5, so to work on those screens before then, build or start the web app with
NEXT_PUBLIC_UI_PREVIEW=true. UI preview mode skips sign-in and uses the in-memory mock
in apps/web/lib/dev-preview/, with a visible "preview" banner on every screen. The login and
registration forms always call the real API, even in preview mode. The mock does not use
the game engine, and its bots only play single cards; real matches (without preview mode)
use the server and the full rules.
PWA behaviour:
/manifest.webmanifest (standalone, starts at /lobby), 192/512 px and
maskable icons, and an Apple touch icon./sw.js, versioned by the build ID (BUILD_ID, otherwise the git
commit). Hashed static assets are cache-first. Page navigations are network-only and fall
back to a pre-cached /offline page. HTML and API responses are never cached, because
signed-in pages contain private data.beforeinstallprompt (Chrome, Edge, Android) get an
install button. iOS/iPadOS Safari gets "Share → Add to Home Screen" instructions. Other
browsers show nothing.The rule specification is docs/GAME_RULES.md. It settles every
regional variant: card and suit order, combinations, which bombs beat what, the opening rule,
passing, how the lead moves after a player finishes, how a match ends, instant wins,
leftover penalties and the timeout action. Configurable rules are a GameRules object
validated by gameRulesSchema. pnpm db:seed stores the default as preset default
version 2. Presets are never edited in place; a match copies its preset into
matches.ruleSnapshot.
The engine (@tienlen/shared, packages/shared/src/game/) is pure TypeScript with no
framework, clock or I/O. The server runs it as the authority; the web client uses the
classifier only to label the selected cards and disable "Đánh" for a non-combination. The
server decides everything else.
| Module | Functions |
|---|---|
deck.ts | createDeck, shuffleDeck (CSPRNG by default), dealCards, createSeededRandom (tests) |
cards.ts | sortCards, compareCards, cardStrength |
combinations.ts | classifyCombination, validateCombination, canBeatCombination, listCombinations |
hand-analysis.ts | detectInstantWin, scoreLeftoverHand |
engine.ts | startMatch, determineOpeningPlayer, applyPlay, applyPass, applyTimeout, |
advanceTurn, resolveTrick, legalPlays, determineWinner, calculateMatchResult |
Actions return { ok: true, state, events } or { ok: false, violation }. They never
mutate their input. GAME_VIOLATION_ERROR_CODES maps each violation to an API error code.
Event types match the MatchEventType database enum. Match states are plain JSON, ready for
matches.stateSnapshot.
Tests: pnpm --filter @tienlen/shared test. Besides rule-by-rule cases, the suite checks
every 2- and 3-card selection against an independent definition and compares
listCombinations with the full power set of random 13-card hands. It also plays 900 seeded
random matches across three rule sets, checking card conservation, turn order, pass lockout,
immutability and placements after every action.
Every route is under /api/v1.
Every response carries an x-request-id header. A well-formed incoming ID
([A-Za-z0-9_-]{8,64}) is propagated; otherwise a UUID is generated. The same ID appears
in the server logs and in error bodies.
Errors use one envelope (apiErrorSchema in @tienlen/shared):
{
"error": {
"code": "NOT_FOUND",
"message": "Không tìm thấy nội dung yêu cầu.",
"requestId": "…"
}
}
Clients branch on code. Messages are Vietnamese and never contain stack traces or
framework internals. Validation errors add details: [{ path, message }].
| Method | Path | Auth | Response |
|---|---|---|---|
| GET | /api/v1/health/live | none | 200 { status: "ok", uptimeSeconds } |
| GET | /api/v1/health/ready | none | 200/503 { status: "ok"|"degraded", checks: { database, redis } } |
Each dependency check returns { status: "up" | "down", latencyMs } and times out after 2 s.
Failure details are logged server-side only. The full REST and WebSocket contracts are
documented here as each phase implements them.
tl_session cookie: 32 random bytes,
HttpOnly, SameSite=Lax, Secure in production. The sessions table stores only the
SHA-256 hash. Every request is checked against the database, so logout, password reset and
suspension take effect immediately. Sessions slide: SESSION_TTL_DAYS of inactivity, and
at most 3× that in total. There is no refresh token, and the web app never sees or stores
the token.@Public().
@Roles('ADMIN') is enforced on the server.SameSite=Lax, state-changing requests that carry an Origin header must
come from WEB_ORIGIN, and Sec-Fetch-Site: cross-site is rejected.429 RATE_LIMITED with Retry-After.proxy.ts sends visitors without a session cookie to
/login?next=…. app/(game)/layout.tsx then verifies the session with GET /auth/me.
Rooms, matches, rewards, leaderboard and notifications return FEATURE_UNAVAILABLE
("Sắp ra mắt") until Phases 5–7.| Method | Path | Auth | Success | Errors |
|---|---|---|---|---|
| POST | /auth/register | none | 201 { user } + cookie | 400 VALIDATION_ERROR, 409 EMAIL_ALREADY_EXISTS, 429 |
| POST | /auth/login | none | 200 { user } + cookie | 401 INVALID_CREDENTIALS, 403 ACCOUNT_SUSPENDED, 429 |
| POST | /auth/logout | none | 204 (clears the cookie) | — |
| POST | /auth/logout-all | session | 204 (revokes every session) | 401 |
| GET | /auth/me | session | 200 { user } | 401 UNAUTHENTICATED/SESSION_EXPIRED, 403 ACCOUNT_SUSPENDED |
| POST | /auth/forgot-password | none | 202 { message } (always) | 400, 429 |
| POST | /auth/reset-password | none | 200 { message } | 400 INVALID_TOKEN, 429 |
| POST | /auth/verify-email | none | 200 { message } | 400 INVALID_TOKEN, 429 |
| POST | /auth/resend-verification | session | 202 { message } | 401, 429 |
| GET | /users/me | session | 200 CurrentUserSummary | 401 |
| PATCH | /users/me | session | 200 CurrentUserSummary | 400, 401 |
| GET | /users/me/stats | session | 200 UserStats | 401 |
| PUT | /users/me/avatar | session |
Paths are relative to /api/v1. { user } is AuthUser (id, email, displayName, role, emailVerified); schemas live in @tienlen/shared. Avatar uploads are multipart
(avatar field). The server decodes each one and re-encodes it as a 256×256 WebP, so only
real images survive. Suspending a user requires a reason, revokes their sessions, and is
written to admin_audit_logs. Admins cannot suspend themselves. The first admin is created
from the command line: pnpm db:set-role <email> ADMIN.
Commands are REST; Socket.IO only notifies. Every command gets an HTTP status, the CSRF
origin check and a rate limit. Notifications never carry private data. They name what changed
(room:updated { roomId }, game:updated { roomId, matchId, stateVersion },
rooms:changed, matchmaking:updated { status }, user:updated), and each client refetches
its own view. The contract is packages/shared/src/contracts/realtime.ts. The socket is
served at /api/v1/socket.io; the handshake must carry a valid session cookie and, when an
Origin header is present, an allowed origin. After a reconnect the web app refetches
everything, because notifications sent while it was offline are lost.
| Method | Path | Success | Typical errors |
|---|---|---|---|
| GET | /rooms?maxEntryFee&onlyWithSeats | RoomSummary[] (public) | — |
| GET | /rooms/options | RoomOptions | — |
| POST | /rooms | 201 { roomId } | 400, 409 ALREADY_IN_ROOM; idempotent per requestId |
| POST | /rooms/join-by-code | { roomId } | 404 ROOM_NOT_FOUND, 403 INVALID_ROOM_PASSWORD, 409 ROOM_FULL |
| GET | /rooms/:roomId | RoomDetail | 403 (private room, not a member), 404 |
| POST | /rooms/:roomId/join | { roomId } | as join-by-code, 409 ROOM_ALREADY_STARTED |
| PUT | /rooms/:roomId/ready | RoomDetail | 403, 409 |
| PATCH | /rooms/:roomId/settings | RoomDetail (host only) | 400, 403, 409 |
| POST | /rooms/:roomId/start | RoomDetail (host only) | 403, 409 ROOM_NOT_READY |
| POST | /rooms/:roomId/rematch | { roomId } (new room) | 403 (not a player), 409 ROOM_ALREADY_STARTED, ALREADY_IN_ROOM |
| POST | /rooms/:roomId/leave | 204 | 409 ROOM_ALREADY_STARTED |
| GET | /rooms/:roomId/game | PlayerGameView | 403 (not a player), 404 |
| POST | /rooms/:roomId/game/play | PlayerGameView | 409 NOT_YOUR_TURN, , ; |
How the server keeps play authoritative:
SELECT … FOR UPDATE), so concurrent requests apply one at a time on any instance.
Status changes follow the shared transition tables.stateVersion it was decided on (otherwise
STALE_STATE) and carries an actionId. A retried actionId returns the current view and
changes nothing. The engine validates turn, ownership, combination and comparison.matches.stateSnapshot,
and every event in match_events. The DEALT event holds the full deal for audit; it is
never sent to clients. Each player's view contains only their own hand.RECONNECT_GRACE_SECONDS. In a match the player keeps
the seat and the timeout action plays for them until they return; nobody is awarded a win
or coins because of it. Reconnecting simply refetches the authoritative view.ROOM_IDLE_MINUTES. When a match finishes, the
placements and statistics are stored, the room becomes FINISHED and its seats are freed.Scaling. Several API instances can run side by side: actions and room changes use row
locks, the quick-match queue is a PostgreSQL table claimed under an advisory lock, and turn
deadlines are swept by every instance. Set SOCKET_IO_REDIS_ADAPTER=true (with REDIS_URL)
so notifications and presence reach every instance, and use sticky sessions for /api/
(Socket.IO long-polling). Details: docs/DEPLOYMENT.md, "Scaling".
The policy is docs/ECONOMY.md: an append-only ledger with per-user idempotency keys, entry fees charged at match start, a 50/30/20/0 % payout of the whole pool, settlement exactly once in the transaction that ends the match, full refunds for aborted matches, a daily reward on Vietnam days with a 7-day streak, and achievement rewards. Coins are virtual points only.
| Method | Path | Success | Errors |
|---|---|---|---|
| GET | /wallet | { balance, totalEarned, totalSpent } | 401 |
| GET | /wallet/transactions?cursor&limit | { items, nextCursor }, newest first | 400, 401 |
| GET | /rewards/daily | DailyRewardStatus | 401 |
| POST | /rewards/daily/claim | { amount, balance } | 409 ACTION_ALREADY_PROCESSED, 429 |
Balances only change through LedgerService (apps/server/src/wallet): one conditional
UPDATE … WHERE balance + amount >= 0 plus one ledger row, in the caller's transaction, so
concurrent debits cannot overspend. WalletService.reconcile checks a wallet against its
ledger. The web app shows the balance, totals and history on /wallet.
Match history (/history, /history/:matchId) lists only the caller's own finished or
aborted matches, newest first, with cursor pages. Placements and coinDelta are the settled
values (they equal the ledger rows for that match). The timeline contains public moves only:
plays (played cards are public), passes, timeouts, tricks, finishes and the end. The deal is
never included, and an abort reason is for administrators only. Anyone else asking for a
match gets 404.
Leaderboard (/leaderboard?metric&period&offset&limit): wins, winRate and played,
for the current week (from Monday 00:00 Vietnam time) or all time. Only finished matches of
active accounts count; aborted matches never do. Win rate needs at least 5 matches in the
week or 10 overall and is compared as exact fractions. Ties: fewer matches for wins, more
wins for winRate and played; players still equal share a rank (1, 1, 3). Results are
public (no emails), computed on the server and cached for a minute per board.
Notifications (/notifications, POST /notifications/:id/read, POST /notifications/read-all): match results and cancellations, the daily reward being available
(created once per Vietnam day when the list is opened), security events (password reset,
sign-out everywhere) and administrator notices. Each has a de-duplication key, so repeated
events create one notification. New ones are announced over Socket.IO
(notification:new). Web push is not implemented (optional in the specification).
Reports (POST /reports): a player can report another player from a match they both
played; a second open report for the same player and match is not duplicated.
Admin (/admin in the web app, /api/v1/admin/*, role ADMIN checked on the server for
every route; non-admins get a 404 page). Every change requires a reason and is written to
admin_audit_logs in the same transaction as the change:
| Area | What administrators can do |
|---|---|
| Overview | Users, open rooms, matches, coins in circulation, timeouts, flagged matches, reports, health |
| Users | Search by name, email or ID; see account, matches and ledger; suspend or reactivate; revoke sessions; adjust coins (a new ADMIN_ADJUSTMENT ledger row, idempotent per request, never below zero); send a notice |
| Matches | Filter, see the full event log including the deal, flag for review, abort a running match with full refunds |
| Reports | Review and mark resolved or dismissed (once) |
| Presets | Add a new rule preset version (rules validated against the spec) and activate it; old versions are never edited |
| Audit | Browse every administrative action with actor, target, reason and IP address |
Administrators cannot change finished match results or edit ledger rows. Daily reward values
and the payout table are code constants documented in docs/ECONOMY.md; changing
them is a reviewed deployment, not an admin setting. The first administrator is created with
pnpm db:set-role <email> ADMIN.
Audit and plan: docs/ENHANCEMENT_AUDIT.md. Rules: rule spec v4 in docs/GAME_RULES.md (§11 penalties, §11a round score, §16 presets, §20 series, §21 spectators).
Room rules and options. The host picks a rule set (Miền Nam chuẩn, Thi đấu nghiêm ngặt, Vui
vẻ, or a regional variant) and may edit any configurable rule before the start; everyone can read
the rules in the waiting room. Rooms take 2–4 seats (maxPlayers) and may allow spectators.
Changing anything resets readiness. The host can remove a player or spectator (they cannot come
back to that room) and close the room.
Series (multi-round matches). A room can start a series that ends after N rounds, at a target score, or when the host ends it. Rules are fixed when round 1 starts; each round is its own room and match; standings are the sum of the settled round scores.
Spectators. Spectators get the server's public projection only (GET …/game/spectate): card
counts, never hands, until the round has finished. They receive the same payload-free Socket.IO
notifications through a per-room channel and cannot act.
Invitations. Seated players invite by name (single-use direct invitation with an in-app
notification) or create a link (/invite/<token>, 256-bit token, only its hash is stored,
reusable for 24 hours). Accepting seats the player through the normal seat rules but skips the
room password. Pending invitations are cancelled when the room starts or closes.
Statistics, leaderboards, achievements. Settlement stores per-round statistics on
match_players (round score, penalty points, cards played, chops made/received, 2s chopped,
2s left) and the penalty records; statistics, streaks and leaderboards are derived from them.
Rounds settled before this change are filled in by pnpm db:backfill (idempotent; the release
jobs run it after the seed). Profiles can be made private.
Event log. match_events and match_penalties are append-only (a database trigger refuses
UPDATE and DELETE); every event carries the state version after it; disconnects and reconnects
during play are logged.
| Method | Path | Success | Notes |
|---|---|---|---|
| POST | /rooms (extra fields) | 201 { roomId } | maxPlayers, allowSpectators, rules, series |
| PATCH | /rooms/:roomId/settings (extra) | RoomDetail | maxPlayers, allowSpectators, rules (locked in series) |
| POST | /rooms/:roomId/remove-member | RoomDetail | host only, before the start |
| POST | /rooms/:roomId/close | 204 | host only, before the start |
| POST | /rooms/:roomId/spectate | { roomId } | 403 SPECTATORS_NOT_ALLOWED, INVALID_ROOM_PASSWORD |
| POST | /rooms/spectate-by-code | { roomId } | as above |
| POST | /rooms/:roomId/spectate/leave | 204 | |
| GET | /rooms/:roomId/game/spectate | SpectatorGameView | spectators only |
| GET | /rooms/:roomId/game/events?after | TimelineEvent[] | players and spectators; never the deal |
| POST | /rooms/:roomId/invitations | 201 CreatedInvitation | { inviteeId } or {} for a link; seated players only |
| GET | /rooms/:roomId/invitations | { items } | seated players |
| GET | /invitations | { items } | received |
| POST | /invitations/:id/accept · reject | { roomId } · Invitation | invitee only; 409 INVITATION_UNAVAILABLE |
Web: rule editor (create room, room settings), waiting-room host controls, invite modal,
/invitations, /invite/<token>, spectator screen, series standings and /series/<id>,
statistics dashboard and achievements on /profile, public profiles at /players/<id>, the
extended leaderboard. The UI preview mode simulates rules, removal, statistics and achievements;
spectating, invitations and series need real accounts and report "Sắp ra mắt" there.
apps/server/prisma/schema.prisma defines all entities:
users, profiles, OAuth accounts, sessions, email-verification and password-reset tokens,
wallets, the append-only wallet ledger, rooms, seats, matches, match players, match events,
reward claims, achievements, notifications, chat, reports, the admin audit log, and versioned
game-rule presets.
Integrity is enforced in the database, not only in application code:
room_players.userId); seat
and placement per match; ledger idempotency key per user; reward claim per
user/type/period; match event sequence and action ID per match.balanceAfter; non-zero ledger amounts; seats 0–3; placements 1–4; four-player rooms;
non-negative fees.onDelete: Restrict, and users are soft-deleted.degraded. The Redis code path is covered by the stubbed e2e tests only.docker CI job; locally, the web image's standalone server was run and checked without
Docker.pnpm db:embedded with Ctrl+C. If the process is killed instead, stop the
orphaned server with pg_ctl -D .local/pgdata stop (the binary is under
node_modules/.pnpm/@embedded-postgres+windows-x64*/…/native/bin).Environment variable not found: DATABASE_URL when running Prisma directly: use the
pnpm db:* scripts. They load the root .env through dotenv-cli.character … has no equivalent in encoding "WIN1252": the PostgreSQL cluster was
created with a non-UTF-8 encoding. The embedded script forces UTF-8; for an existing bad
cluster, delete .local/pgdata and restart pnpm db:embedded.EPERM … rename during pnpm install on Windows: antivirus or the indexer locked a
file; re-run pnpm install.SESSION_TTL_DAYS | server | no | 30 | Idle timeout; absolute limit is 3× |
COOKIE_DOMAIN | server | no | host-only | Parent domain if web/API are on subdomains |
INITIAL_COIN_BONUS | server | no | 10000 | Coins credited once at registration |
SMTP_URL | server | prod | — | smtp:// or smtps://; see below |
MAIL_FROM | server | no | Tiến Lên Online <no-reply@…> |
MAIL_OUTBOX_DIR | server | no | .local/mail | Dev outbox (relative to the API's directory) |
ROOM_IDLE_MINUTES | server | no | 30 | Unstarted rooms expire after this idle time |
RECONNECT_GRACE_SECONDS | server | no | 60 | Waiting-room seat kept after a disconnect |
Playwright against the real API on ports 3200/4100 (<name>_e2e database) |
pnpm --filter @tienlen/web icons | Regenerate the PWA PNG icons from apps/web/assets/logo.svg |
200 CurrentUserSummary |
400 (not PNG/JPEG/WebP), 413 (> 2 MB), 401 |
| DELETE | /users/me/avatar | session | 200 CurrentUserSummary | 401 |
| GET | /avatars/:file | none | 200 image/webp (immutable) | 404 |
| PATCH | /admin/users/:userId/status | ADMIN | 200 { userId, status, revokedSessions } | 403 FORBIDDEN, 404, 400 |
STALE_STATEMATCH_FINISHED400 INVALID_COMBINATION| POST | /rooms/:roomId/game/pass | PlayerGameView | 409 PASS_NOT_ALLOWED, as play |
| GET | /rooms/:roomId/game/result | MatchResult | 403, 404 (not finished) |
| GET | /matchmaking | MatchmakingStatus | — |
| POST | /matchmaking { entryFee } | 201 MatchmakingStatus | 400, 409 ALREADY_IN_ROOM |
| DELETE | /matchmaking | MatchmakingStatus | — |
| POST | /invitations/:id/cancel | Invitation | inviter or host |
| GET | /invitations/link/:token | Invitation | 404 INVALID_TOKEN |
| POST | /invitations/link/:token/accept | { roomId } | 409 INVITATION_UNAVAILABLE |
| GET | /series/:seriesId | SeriesDetail | participants only (others 404) |
| POST | /series/:seriesId/finish | SeriesDetail | host only, between rounds |
| GET | /users/me/statistics | PlayerStatistics | round-based |
| GET | /users/me/achievements | AchievementItem[] |
| PATCH | /users/me/privacy | PublicProfile | { profileVisibility } |
| GET | /users/:userId/profile | PublicProfile | statistics hidden for private profiles |
| GET | /users/search?q | PublicPlayer[] | display names, for invitations |
| GET | /leaderboard?metric&period&search | Leaderboard | metrics: points, wins, winRate, played, bestStreak, chops, lowPenalty; periods: today, week, month, all |