Files
drinking-game/CLAUDE.md
gongch 6d1dd5d56c Initial commit: 酒桌派对 / 大话骰 multiplayer drinking-game hub
- server.py: zero-dependency stdlib backend (HTTP + WebSocket /ws), 大话骰 rule engine, server-driven bots
- live.html: real MVP frontend wired to the backend over WebSocket
- index.html/app.js/style.css: older static prototype
- deploy/: systemd unit + Nginx reverse proxy + Let's Encrypt setup.sh
- test_*.py: standalone HTTP / in-process test scripts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 10:05:10 +08:00

7.3 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

A mobile-first, multiplayer drinking-game hub (酒桌派对 / "Drinking Game Hub"). Players open a web page on their phone, enter a nickname, and create or join a 4-character room. The GAMES registry declares four games (大话骰 dice, 德州扑克 poker, 六张牌 six, 抓二游 catch), but only 大话骰 (Liar's Dice, game="dice") has real backend logic — the other three exist only as lobby cards and static prototype boards.

Running and testing

No build step, no dependencies — pure Python standard library + vanilla JS/CSS.

python3 server.py        # starts ThreadingHTTPServer on 0.0.0.0:8765

Then open http://127.0.0.1:8765/live.html (the real MVP). The server seeds one demo room on startup.

Tests are standalone scripts, run individually:

python3 test_flow.py     # HTTP test — requires server.py already running on :8765
python3 test_multi_play.py   # in-process test — imports server.py directly

Two test styles, distinguishable by their second line:

  • HTTP tests (base='http://127.0.0.1:8765') drive the live server over the API; start server.py first.
  • In-process tests (importlib.util.spec_from_file_location(...)) import server.py and call functions directly with no HTTP. Gotcha: these hardcode the path /var/minis/workspace/drinking-games-ui/server.py. That path predates this checkout (/home/xiaoyu/projects/drinking-games-ui); update the literal before running in-process tests here.

Tests assert by printing — there is no test runner or assertion framework. Read the printed output to judge pass/fail.

Architecture

server.py — the entire backend in one file. It is both the API server and the static file server (SimpleHTTPRequestHandler rooted at the repo dir). All state lives in the module-level rooms dict; nothing is persisted — restarting the server wipes every room. A room is a plain dict (see create_room) holding players, status (waiting/playing/result), currentCall, pendingAction, lastResult, history, and roundCalls.

The API is a flat list of POST endpoints in Handler.do_POST, plus GET /api/rooms and /api/room. Every mutation function returns the room, and the handler wraps it with public_room(room, viewer_id) before sending. public_room is the only serialization boundary — it controls per-viewer visibility via visible_dice (you see your own dice while playing; everyone's dice only on reveal). When adding fields the client needs, add them in public_room, not just on the room dict.

Errors are signaled by raising ValueError with a user-facing Chinese message; do_POST catches all exceptions and returns {"ok": False, "error": str(e)} with HTTP 400. Follow this pattern — validate and raise ValueError, don't return error dicts from game functions.

Frontends — two separate, unrelated UIs:

  • live.html is the real app: a single self-contained file (inline <script> + <style>) wired to the backend API. This is what server.py points users to.
  • index.html + app.js + style.css is an older static prototype with hardcoded mock data and no network calls. Don't confuse the two; changes to gameplay belong in live.html and server.py.

style.css is shared by both. live.html adds most of its own styling inline.

Realtime model (WebSocket)

live.html runs the live room over a single WebSocket to /ws. The WebSocket server is implemented in pure stdlib (RFC6455 handshake + framing — ws_handshake/ws_read_frame/ws_build_frame in server.py), so the project keeps its zero-dependency, python3 server.py-and-go property. The handshake is detected at the top of Handler.do_GET; once upgraded, the request thread hijacks the raw socket and runs ws_serve until the socket closes.

Client→server messages are {type, ...} (create/join/ready/start/call/open/split/steal/multi/respond/respondMulti/settings/lock/kick/addBot/leave/rooms), dispatched by handle_ws, which calls the same game functions the REST layer uses. After every mutation the server broadcasts public_room (per-viewer dice visibility) to all connections in that room via broadcast_room, plus a lobby rooms list to lobby connections. The client (live.html send/handleWS) fires an action and waits for the pushed room message — it does not optimistically re-render.

Bots are server-driven. After any gameplay-advancing action, handle_ws calls schedule_bots, which uses a threading.Timer (~0.9s) to run run_botsbot_turn (one decision per step), broadcasts, and re-schedules until it's a human's turn or the round ends. There is no client bot loop. Bot decisions use random (call vs. open, accept vs. counter).

Concurrency: state_lock (an RLock) guards rooms + ws_clients mutations across request threads and bot-timer threads; each connection has its own send_lock. The server runs with daemon_threads=True so long-lived WS threads don't block shutdown.

REST is retained (do_POST / GET /api/room//api/rooms) for the HTTP test scripts and as a fallback, and is not lock-guarded — REST and WS are not meant to drive the same room concurrently. PROJECT_DESIGN.md predates the WebSocket work; treat it as historical intent.

Note: some HTTP test scripts (test_flow.py, test_multiplayer.py) are stale and fail at start against current rules (they start with <2 players or an unready guest — both rejected by start_room); this is a test-data issue, not a server regression.

大话骰 rule engine (the core complexity)

The dice rules are non-obvious and spread across several small functions in server.py. Key invariants, enforced in call_dice / is_valid_raise / call_mode / point_rank:

  • 斋 (zhai) vs 飞 (fly) are the two call modes. In 斋 mode the point counts literally; in 飞 mode all 1s are wild and count toward any point (except point 1 itself). count_actual computes the real total for a call.
  • N-player floor: minimum opening call is min_count(room) = N "ones" (min_count = player count). Max is max_count = N×5 (five dice each).
  • Point ordering in 斋: 1 is the highest point, so the order is 2 < 3 < 4 < 5 < 6 < 1 (point_rank maps 17). Calling a literal 1 is always 斋, never 飞.
  • Breaking 斋 into 飞 ("破斋") requires doubling the count (count >= old.count * 2). This is checked explicitly in call_dice with a dedicated error.
  • Challenges (pendingAction): open_dice (开骰), split_dice (劈), steal_split (抢开), and the multi-target start_multi_direct (开/劈 two or three players). These set a pendingAction with a stage state machine resolved by respond_challenge (single target) or respond_multi (two-target). Stakes escalate through accept / decline / counter (反劈). Read the stage transitions before touching this — wait_caller_counterwait_opener_accept etc.

When changing rules, update the validation in call_dice, the predicted-next-call helper next_call, the AI's choices in bot_turn, and the diceRule object inside public_room (the client renders hints from it) together — they encode the same rules in different forms and drift silently if edited in isolation.