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>
This commit is contained in:
70
CLAUDE.md
Normal file
70
CLAUDE.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user