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

71 lines
7.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```bash
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:
```bash
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_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 `1`s 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 `1``7`). 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_counter``wait_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.