- 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>
7.3 KiB
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; startserver.pyfirst. - In-process tests (
importlib.util.spec_from_file_location(...)) importserver.pyand 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.htmlis the real app: a single self-contained file (inline<script>+<style>) wired to the backend API. This is whatserver.pypoints users to.index.html+app.js+style.cssis an older static prototype with hardcoded mock data and no network calls. Don't confuse the two; changes to gameplay belong inlive.htmlandserver.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_bots → bot_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 point1itself).count_actualcomputes the real total for a call. - N-player floor: minimum opening call is
min_count(room)= N "ones" (min_count= player count). Max ismax_count= N×5 (five dice each). - Point ordering in 斋:
1is the highest point, so the order is2 < 3 < 4 < 5 < 6 < 1(point_rankmaps1→7). Calling a literal1is always 斋, never 飞. - Breaking 斋 into 飞 ("破斋") requires doubling the count (
count >= old.count * 2). This is checked explicitly incall_dicewith a dedicated error. - Challenges (
pendingAction):open_dice(开骰),split_dice(劈),steal_split(抢开), and the multi-targetstart_multi_direct(开/劈 two or three players). These set apendingActionwith astagestate machine resolved byrespond_challenge(single target) orrespond_multi(two-target). Stakes escalate through accept / decline / counter (反劈). Read the stage transitions before touching this —wait_caller_counter→wait_opener_acceptetc.
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.