Metadata-Version: 2.4
Name: hexgame
Version: 0.2.0
Summary: FastAPI WebSocket Hex game server with matchmaking, plus reference clients
Author-email: Cahya Wirawan <cahya.wirawan@gmail.com>
License: MIT
Project-URL: Homepage, https://hexgame.codingdojo.ai
Project-URL: Source, https://github.com/cahya-wirawan/hexgame
Keywords: hex,game,board-game,websocket,fastapi,matchmaking
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Framework :: FastAPI
Classifier: Intended Audience :: Developers
Classifier: Topic :: Games/Entertainment :: Board Games
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: fastapi<0.100,>=0.95
Requires-Dist: uvicorn[standard]<0.23,>=0.22
Requires-Dist: websockets<12,>=11
Provides-Extra: redis
Requires-Dist: redis<6,>=4.5; extra == "redis"
Provides-Extra: postgres
Requires-Dist: sqlalchemy<3,>=2; extra == "postgres"
Requires-Dist: psycopg[binary]<4,>=3; extra == "postgres"
Provides-Extra: gui
Requires-Dist: pygame<3,>=2.5; extra == "gui"
Provides-Extra: dev
Requires-Dist: pytest<8,>=7; extra == "dev"
Requires-Dist: pytest-asyncio<0.22,>=0.21; extra == "dev"
Requires-Dist: httpx<0.25,>=0.24; extra == "dev"
Provides-Extra: all
Requires-Dist: hexgame[gui,postgres,redis]; extra == "all"

# Hex Game Server

FastAPI-based Hex game server with WebSocket matchmaking, authoritative game
state, win detection, a random-move test client, and a Vite/Tailwind/shadcn-ui
frontend with a landing page, operational overview dashboard, and statistics
leaderboard.

Packaged as `hexgame` on PyPI: `pip install hexgame` gives you the
`hexgame-server` command (the FastAPI server) and the `hexgame` command
(`random`, `play`, and `gui` clients).

The current implementation covers Phases 1-7 from `PLAN.md`:

- Fixed in-memory game slots.
- Board-size-aware matchmaking.
- Best-of match series with configurable odd series lengths.
- WebSocket real-time gameplay.
- Server-authoritative move validation and turn tracking.
- Hex win detection.
- `/` project landing page, `/docs` usage guide, `/overview` monitoring page,
  and `/statistics` model leaderboard.
- Model-driven clients, including a pygame GUI client for visual board output.
- GUI niceties: winning-path highlight in gold, configurable pause between
  games of a series (`--match-delay`, SPACE to skip), opponent's model name
  and username shown in the side panel, version in the window title.
- Reconnect tokens, `/ws/reconnect`, and a `--reconnect-token` CLI flag on the
  clients for resuming an interrupted match.
- Optional Redis-backed slot, game, session, and reconnect-token state.
- Optional PostgreSQL/SQLAlchemy completed-series history.

User accounts and ratings are intentionally not implemented yet.

## Requirements

- Python 3.10+ recommended.
- Node.js 20+ recommended only if you rebuild the overview frontend yourself.
- `pygame` (the `[gui]` extra) is required for the pygame GUI client.
- Redis is optional (the `[redis]` extra). The default backend is in-process
  memory for local development.
- PostgreSQL is optional (the `[postgres]` extra). Completed-series history is
  disabled unless `HEX_DATABASE_URL` is set.

## Installation

The project is packaged as `hexgame`. Installing it provides two console
commands: `hexgame-server` (the FastAPI server) and `hexgame` (the clients).

End users — install from PyPI:

```bash
pip install hexgame                 # server + random/model clients
pip install "hexgame[gui]"          # also the pygame GUI client
pip install "hexgame[all]"          # everything: redis, postgres, gui
```

Developers — editable install from a checkout:

```bash
python -m pip install -e ".[dev,all]"
# or, equivalently:
python -m pip install -r requirements.txt
```

Rebuilding the overview frontend (only if you change `frontend/`):

```bash
cd frontend
npm install
npm run build        # writes to src/hexgame/server/static/overview/
```

## Using The Hosted Server

The model clients default to the hosted arena:

```text
wss://hexgame.codingdojo.ai
```

That means users can install the package, add or choose a model, and connect
directly without running their own FastAPI server:

```bash
pip install "hexgame[gui]"
hexgame play --model-name model_random --board-size 7
hexgame gui --model-name human --board-size 7
```

Open:

- Landing page: `https://hexgame.codingdojo.ai/`
- Documentation: `https://hexgame.codingdojo.ai/docs`
- Overview dashboard: `https://hexgame.codingdojo.ai/overview`
- Statistics leaderboard: `https://hexgame.codingdojo.ai/statistics`

## Running A Local Server

From the repository root:

```bash
hexgame-server --port 8000
```

Then open:

- Health: `http://127.0.0.1:8000/health`
- Slot state JSON: `http://127.0.0.1:8000/slots`
- Landing page: `http://127.0.0.1:8000/`
- Documentation: `http://127.0.0.1:8000/docs`
- Overview dashboard: `http://127.0.0.1:8000/overview`
- Statistics leaderboard: `http://127.0.0.1:8000/statistics`
- OpenAPI/Swagger UI: `http://127.0.0.1:8000/api/docs`

Point clients at the local server with `--server`:

```bash
hexgame play --model-name model_random --board-size 7 --server ws://localhost:8000
hexgame gui --model-name human --board-size 7 --server ws://localhost:8000
```

`hexgame-server` is a thin wrapper around `uvicorn hexgame.server.main:app`;
pass `--host`, `--port`, `--workers`, `--reload`, or `--log-level` as needed.
If WebSocket clients receive HTTP 404 on `/ws/matchmake`, reinstall the package
(`pip install -e ".[dev,all]"`) so `uvicorn[standard]`/`websockets` are present,
then restart the server.

## Docker Compose

Build and run the server with Redis and PostgreSQL:

```bash
docker compose up --build
```

Compose starts:

- `app`: FastAPI server on `http://127.0.0.1:8000`
- `redis`: active slot/game/session/reconnect-token state
- `postgres`: completed-series history through SQLAlchemy ORM

Stop the stack:

```bash
docker compose down
```

Remove persisted Redis/PostgreSQL data:

```bash
docker compose down -v
```

## Redis State Backend

By default, state is stored in memory:

```bash
hexgame-server --port 8000
```

To persist active slot state in Redis and share slot/game/session/reconnect
state between server processes, start Redis and run:

```bash
HEX_STATE_BACKEND=redis \
HEX_REDIS_URL=redis://127.0.0.1:6379/0 \
hexgame-server --port 8000
```

Redis stores active slots, board state, series score, public model names,
public usernames, connection status, and reconnect tokens. Raw WebSocket
objects are never stored in Redis; after a server restart, persisted players
are marked disconnected and can return through `/ws/reconnect` using their
reconnect token.

Use `HEX_REDIS_KEY_PREFIX` to isolate environments that share the same Redis
database.

## Database History

Completed series can be written through SQLAlchemy ORM to PostgreSQL. Set
`HEX_DATABASE_URL` before starting the server:

```bash
HEX_DATABASE_URL=postgresql+psycopg://hex:hex@127.0.0.1:5432/hexgame \
hexgame-server --port 8000
```

By default, `HEX_DATABASE_AUTO_CREATE=1` creates the `completed_series` table on
startup. Set `HEX_DATABASE_AUTO_CREATE=0` if migrations or external schema
management should own table creation.

The completed-series record stores slot id, board size, series length, winner,
score, public model names, public usernames, final board, and the final public
slot snapshot. It does not store reconnect tokens or WebSocket objects.

## Frontend

The landing page and overview dashboard live in `frontend/` and are built with:

- Vite
- React
- TypeScript
- Tailwind CSS
- shadcn/ui-style local components

During frontend development:

```bash
cd frontend
npm run dev
```

The Vite dev server proxies `/slots` and `/api/statistics` to
`http://127.0.0.1:8000`.

Build the production dashboard:

```bash
cd frontend
npm run build
```

The production build writes to `src/hexgame/server/static/overview/`. FastAPI serves the
landing page at `/`, the documentation page at `/docs`, the dashboard at
`/overview`, the leaderboard at `/statistics`, and assets from
`/overview/assets/...`.

## API

### HTTP

`GET /health`

Returns:

```json
{"status": "ok"}
```

`GET /slots`

Returns the current state of all slots. It is safe for clients and dashboards:
it does not include raw WebSocket objects or secrets.

Example:

```json
[
  {
    "slot_id": 1,
    "state": "full",
    "board_size": 11,
    "series_length": 3,
    "player_count": 2,
    "connected_player_count": 2,
    "players": [-1, 1],
    "player_models": {"-1": "model_alphazero", "1": "human"},
    "player_usernames": {"-1": "alice", "1": "bob"},
    "connected_players": [-1, 1],
    "disconnected_players": [],
    "current_turn": -1,
    "winner": null,
    "move_count": 8,
    "board": [[null, -1]],
    "wins_required": 2,
    "current_game_number": 1,
    "player_1_wins": 0,
    "player_2_wins": 0,
    "series_winner": null
  }
]
```

`GET /`

Serves the built landing page.

`GET /docs`

Serves the built project documentation page.

`GET /overview`

Serves the built dashboard.

`GET /statistics`

Serves the built model statistics and leaderboard page.

`GET /api/statistics`

Returns completed-series statistics. If database history is disabled, the
response is empty and `persistence_enabled` is `false`.

Example:

```json
{
  "persistence_enabled": true,
  "totals": {"matches": 12, "games": 31, "models": 4, "model_entries": 24},
  "leaderboard": [
    {
      "model_name": "model_alphazero",
      "username": "alice",
      "matches": 8,
      "wins": 6,
      "losses": 2,
      "games_won": 14,
      "games_lost": 7,
      "win_rate": 0.75
    }
  ],
  "board_sizes": {"7": 6, "11": 6},
  "series_lengths": {"1": 8, "3": 4},
  "recent_matches": []
}
```

### WebSocket

`/ws/matchmake?board_size=11&series_length=3`

Allowed board sizes:

```text
7, 9, 11, 13, 19
```

Allowed series lengths:

```text
1, 3, 5, 7, 9, 11, 13, 15
```

`series_length` defaults to `1`. The server only matches players who request
the same board size and the same series length.

Clients may include `model_name` and `username` to display non-secret model
labels and owner names in `/slots` and `/overview`:

```text
/ws/matchmake?board_size=11&series_length=3&model_name=model_alphazero&username=alice
```

`/ws/join-slot?slot_id=1`

Joins a specific waiting slot as `player_2`. The joining client inherits the
board size, series length, wins required, and current score rules already set
by `player_1` in that slot.

`/ws/reconnect?slot_id=1&token=<reconnect_token>`

Reconnects a player to a reserved seat after a temporary disconnect. The token
is issued only to that client in the `joined` payload. It is not exposed by
`/slots` or the overview dashboard.

## WebSocket Protocol

Every message uses this shape:

```json
{
  "type": "message_type",
  "payload": {}
}
```

### Client To Server

`hello`

```json
{
  "type": "hello",
  "payload": {
    "protocol_version": 1,
    "client_name": "hex-client"
  }
}
```

`move`

Clients send only coordinates. The server assigns the player identity from the
WebSocket connection.

```json
{
  "type": "move",
  "payload": {
    "q": 3,
    "r": 5
  }
}
```

`chat`

```json
{
  "type": "chat",
  "payload": {
    "message": "Good luck!"
  }
}
```

`resign`

```json
{
  "type": "resign",
  "payload": {}
}
```

`ping`

```json
{
  "type": "ping",
  "payload": {}
}
```

### Server To Client

`joined`

```json
{
  "type": "joined",
  "payload": {
    "slot_id": 1,
    "player": -1,
    "color": "red",
    "board_size": 11,
    "series_length": 3,
    "reconnect_token": "client-private-token",
    "protocol_version": 1
  }
}
```

Store `reconnect_token` client-side for the current match. Treat it like a
short-lived secret: it proves ownership of the reserved seat.

`reconnected`

```json
{
  "type": "reconnected",
  "payload": {
    "slot_id": 1,
    "player": 1,
    "color": "blue",
    "board_size": 11,
    "series_length": 3,
    "protocol_version": 1,
    "slot": {
      "slot_id": 1,
      "state": "full",
      "connected_players": [-1, 1],
      "disconnected_players": [],
      "current_turn": -1,
      "move_count": 8,
      "board": [[0, -1]],
      "player_models": {"-1": "model_alphazero", "1": "human"},
      "player_usernames": {"-1": "alice", "1": "bob"},
      "current_game_number": 1,
      "player_1_wins": 0,
      "player_2_wins": 0,
      "wins_required": 2
    }
  }
}
```

The reference clients use `player_models` / `player_usernames` from this
snapshot to restore the **Opponent** label after a reconnect.

`waiting_for_opponent`

```json
{
  "type": "waiting_for_opponent",
  "payload": {
    "slot_id": 1,
    "board_size": 11
  }
}
```

`game_start`

```json
{
  "type": "game_start",
  "payload": {
    "slot_id": 1,
    "board_size": 11,
    "series_length": 3,
    "players": [-1, 1],
    "first_turn": -1,
    "current_game_number": 1,
    "player_1_wins": 0,
    "player_2_wins": 0,
    "wins_required": 2,
    "player_models": {"-1": "model_alphazero", "1": "human"},
    "player_usernames": {"-1": "alice", "1": "bob"}
  }
}
```

`player_models` and `player_usernames` are keyed by string player IDs (`"-1"`
and `"1"`). Empty objects (`{}`) mean the players are anonymous. Clients
should not assume both keys are present — match a side by its string key and
fall back to `None`. This payload is sent both at series start and at the
start of each subsequent game in a multi-game series.

`move`

```json
{
  "type": "move",
  "payload": {
    "player": -1,
    "q": 3,
    "r": 5,
    "next_turn": 1
  }
}
```

`move_rejected`

```json
{
  "type": "move_rejected",
  "payload": {
    "reason": "Not your turn"
  }
}
```

`game_over`

```json
{
  "type": "game_over",
  "payload": {
    "winner": -1,
    "reason": "connected_sides"
  }
}
```

`series_update`

Sent after each completed game in a series.

```json
{
  "type": "series_update",
  "payload": {
    "player_1_wins": 1,
    "player_2_wins": 0,
    "current_game_number": 2,
    "wins_required": 2,
    "series_length": 3
  }
}
```

`series_over`

Sent when a player reaches the required number of wins.

```json
{
  "type": "series_over",
  "payload": {
    "winner": -1,
    "player_1_wins": 2,
    "player_2_wins": 0,
    "wins_required": 2,
    "series_length": 3
  }
}
```

Other server messages:

- `pong`
- `chat`
- `error`
- `opponent_disconnected`
- `opponent_reconnected`

### Player IDs

The protocol uses numeric IDs everywhere a player appears:

```text
-1 = player_1 = red  = left-to-right
 1 = player_2 = blue = top-to-bottom
```

New clients should treat `-1` and `1` as the canonical protocol values. The
server still owns identity: clients do not send their player id in `move`
messages, and any extra `player` field in a move payload is ignored.

## Gameplay Rules

- The current `src/hexgame/server/config.py` maps `PLAYER_1 = -1` and `PLAYER_2 = 1`.
- `player_1` (`-1`, red) moves first.
- A game is one Hex board.
- A series is best-of `1`, `3`, `5`, `7`, `9`, `11`, `13`, or `15` games
  between the same players.
- The series ends as soon as a player reaches `ceil(series_length / 2)` wins.
- First turn alternates by game number: odd games start with `player_1` (`-1`),
  even games start with `player_2` (`1`).
- Coordinates are `(q, r)`.
- Board access is `board[r][q]`.
- `player_1` (`-1`, red) wins by connecting left to right.
- `player_2` (`1`, blue) wins by connecting top to bottom.
- Neighbors use the axial-like offsets:

```python
(+1, 0), (-1, 0), (0, +1), (0, -1), (+1, -1), (-1, +1)
```

The server rejects moves when:

- The game has not started.
- The game is paused while a disconnected opponent is inside the reconnect
  window.
- The game is already finished.
- It is not the sender's turn.
- Coordinates are outside the board.
- The target cell is occupied.
- The payload is malformed.

## Reconnect Behavior

When a player disconnects, the server keeps the slot, game board, series
score, and seat assignment in memory for `RECONNECT_TIMEOUT_SECONDS` from
`src/hexgame/server/config.py`. The remaining player receives
`opponent_disconnected` and the match is paused. During the pause, moves are
rejected with `Game paused for reconnect`.

### Getting the token

When a client connects via `/ws/matchmake` or `/ws/join-slot`, the server's
`joined` payload includes a `reconnect_token` that's specific to that seat.
Both reference clients (`hexgame play` and `hexgame gui`) print it to stdout
on first join:

```text
reconnect: slot 3 token a1b2c3d4e5
```

The token is also written to the JSONL replay log next to the `joined` event.
Treat it like a short-lived secret — it's never exposed by `/slots` or the
overview dashboard.

### Reconnecting

Either CLI flag form works:

```bash
hexgame play --slot-id 3 --reconnect-token a1b2c3d4e5
hexgame gui  --slot-id 3 --reconnect-token a1b2c3d4e5
```

Both route to `/ws/reconnect?slot_id=3&token=a1b2c3d4e5`. The raw URL also
works for custom clients:

```text
ws://127.0.0.1:8000/ws/reconnect?slot_id=3&token=a1b2c3d4e5
```

If the token is valid and the reconnect timeout has not expired, the server
sends `reconnected` with the current public slot snapshot (board, current
turn, score, `player_models`, `player_usernames`) and notifies the opponent
with `opponent_reconnected`. The GUI restores the full game state — including
the opponent panel row — from that snapshot. If the timeout expires first, the
slot is reset and the remaining player is notified and closed.

`--reconnect-token` without `--slot-id` is rejected with a clear error.

## Clients

The model and GUI clients default to `wss://hexgame.codingdojo.ai`. Use
`--server ws://localhost:8000` only when you are running your own local server.

### Random Client

The reference random client plays uniformly random legal moves. It is useful
for smoke testing matchmaking, gameplay, and win detection.

Run two clients in separate terminals:

```bash
hexgame random --board-size 11 --seed 1
hexgame random --board-size 11 --seed 2
```

Useful options:

```bash
hexgame random \
  --server wss://hexgame.codingdojo.ai \
  --board-size 11 \
  --series-length 3 \
  --seed 42 \
  --move-delay 0.1
```

There is also a helper script:

```bash
bash src/hexgame/client/run_pair.sh
```

### Model Client

`hexgame play` (module `hexgame.client.model_client`) loads a model module
dynamically. The module must export:

```python
def agent(board, action_set):
    ...
```

`--model-name` accepts any of four forms, resolved in this order:

1. A **filesystem path** (`./examples/model_dqn.py`, `/abs/path.py`, or any
   value containing `/` or ending in `.py`). Loaded directly with
   `importlib.util`, so no `sys.path` / `PYTHONPATH` setup is needed. This is
   the most reliable form for unpackaged models.
2. A **bundled model** name: `hexgame.client.models.<NAME>` — currently
   `model_random` and `model_first`.
3. A **top-level module** name on `sys.path` — drop `my_agent.py` in your
   working directory and pass `--model-name my_agent`.
4. `examples.<NAME>` — convenience fallback for repo checkouts run from the
   project root with `PYTHONPATH=.`, so `--model-name model_dqn` finds
   `examples/model_dqn.py`.

A `ModuleNotFoundError` raised *inside* a resolved module (e.g. a missing
`torch` import in the model file) is **not** swallowed — it propagates with
its original message so the real cause is visible.

```python
# my_agent.py  (in your current working directory)
from random import choice


def agent(board, action_set):
    # board contains 0, -1, and 1
    # action_set contains legal (row, col) moves
    return choice(list(action_set))
```

```bash
hexgame play --model-name my_agent --board-size 7
hexgame gui  --model-name my_agent --board-size 7
hexgame play --model-name ./my_agent.py --board-size 7      # explicit file path
hexgame play --model-name my_agent --server ws://localhost:8000
```

The model-facing board used by the WebSocket clients follows the server
protocol convention:

```text
0  = empty
-1 = red / model player 1
1  = blue / model player 2
```

The `action_set` passed to the model uses `(row, col)` coordinates. The client
converts model output back to the server protocol shape `{q, r}`.
Before sending a move, the client verifies that the model returned an integer
`(row, col)` pair that is present in `action_set`. If a model returns an
occupied cell, an out-of-bounds cell, a scalar, or coordinates in `{q, r}`
order by mistake, the client stops with a clear model move error instead of
sending an illegal move to the server.

Model clients also export a small JSONL replay log by default under
`replays/`. The log records server messages, applied moves, model
choices, rejected moves, terminal events, and board snapshots around model
decisions. Use `--replay-log off` to disable it or `--replay-log path.jsonl` to
choose an explicit export path.

Run two model clients:

```bash
hexgame play --model-name model_random --board-size 7 --series-length 1
hexgame play --model-name model_first --board-size 7 --series-length 1
```

To keep the same slot after a completed series and wait for another opponent,
add `--keep-slot`:

```bash
hexgame play --model-name model_alphazero --board-size 7 --keep-slot
```

To join a specific waiting slot, add `--slot-id`. The client inherits the slot's
board size and series length from the server:

```bash
hexgame play --model-name model_random --slot-id 3
```

To **resume an interrupted match**, pass `--slot-id` together with
`--reconnect-token`. The token is printed to stdout on the first `joined`
message (look for a line like `reconnect: slot 3 token a1b2c3d4e5`) and is
also recorded in the replay log:

```bash
hexgame play --slot-id 3 --reconnect-token a1b2c3d4e5
```

The combination routes to `/ws/reconnect?slot_id=...&token=...` instead of the
normal matchmaking endpoint. `--reconnect-token` without `--slot-id` is
rejected with a clear error.

Bundled example models (in `hexgame.client.models`):

- `model_random`
- `model_first`

The repository's `examples/` directory holds heavier, optional ML models
(`model_alphazero`, `model_dqn`, ...) plus their weights and a C++ MCTS
extension. Those are not part of the installed package; run them from a repo
checkout with `examples/` on `PYTHONPATH`.

### Pygame GUI Model Client

`hexgame gui` (module `hexgame.client.gui_client`, needs the `[gui]` extra) is
the graphical version of the model client. The window title shows the package
version (`Hex Client v<version>`). The side panel shows:

- **Status** — current state / countdown
- **Model** — your model name
- **Opponent** — the opposing player's `model_name` and `username`
  (server-supplied via the `game_start` / `reconnected` payload)
- **Slot**, **Game**, **Score**, **Moves**, **Replay**
- **Players** rows for `player_1` (red, left↔right) and `player_2` (blue,
  top↔bottom), with a chip on the side whose turn it is
- **Last move** coordinates

On the board, the last move gets a yellow ring, and when a game ends the
**winning path** is highlighted with a thick gold border on the cells that
connect the two goal edges (computed locally with the same BFS the server
uses).

When playing a multi-game series, the GUI **pauses on the final board between
games** so you can see the result before it resets. The pause is
`--match-delay` seconds (default `3.0`, set to `0` to disable) and the status
line counts down (`"Next match in 1.7s — SPACE to skip"`). Press **SPACE** at
any time to skip ahead.

Run it with:

```bash
hexgame gui \
  --model-name model_random \
  --server wss://hexgame.codingdojo.ai \
  --board-size 7 \
  --series-length 3 \
  --match-delay 3 \
  --seed 42 \
  --move-delay 0.1 \
  --replay-log auto
```

Close the GUI with `Esc`, `Q`, or the window close button. Press **SPACE**
during an inter-match pause to start the next game immediately.

To play as a human from the GUI, use `--model-name human`. When it is your
turn, click an empty Hex cell to send the move:

```bash
hexgame gui --model-name human --board-size 7
```

To join a specific waiting slot from the GUI, add `--slot-id`. The GUI ignores
its local `--board-size` and `--series-length` for gameplay after joining and
uses the settings reported by the server:

```bash
hexgame gui --model-name human --slot-id 3
```

To keep the same slot after a completed series and wait for another opponent,
add `--keep-slot`. The server resets the series score, keeps the player as
`player_1` in that slot, and moves the slot back to `waiting`:

```bash
hexgame gui --model-name human --board-size 7 --keep-slot
```

To **resume an interrupted match**, the GUI accepts the same
`--slot-id` + `--reconnect-token` combination as `hexgame play`. The token is
printed to stdout on the first `joined` message and recorded in the replay
log; on reconnect the GUI restores the full board, turn, score, and opponent
label from the server's snapshot:

```bash
hexgame gui --slot-id 3 --reconnect-token a1b2c3d4e5
```

## Tests

Install the dev + optional dependencies, then run pytest from the repo root
(`pyproject.toml` puts `src/` on the path, so no install is strictly required,
but the database/redis tests need those extras):

```bash
python -m pip install -e ".[dev,all]"
python -m pytest
```

Run frontend build verification:

```bash
cd frontend
npm run build
```

The current test suite covers:

- Slot assignment and reset behavior.
- Board-size-aware matchmaking.
- Series-length-aware matchmaking and best-of scoring.
- Protocol validation.
- Move validation and turn order.
- Hex win detection.
- WebSocket matchmaking and gameplay.
- Overview endpoint serving.

## Project Layout

```text
pyproject.toml             Package metadata, dependencies, console entry points
MANIFEST.in                Extra files to include in the source distribution

src/hexgame/
  server/                  -> console command: hexgame-server
    __main__.py            CLI wrapper around uvicorn (hexgame.server.main:app)
    main.py                FastAPI routes and global SlotManager
    config.py              Slot, board-size, protocol, and player constants
    models.py              GameSlot, PlayerConnection, SlotAssignment, HexGameState
    protocol.py            Message parsing and message factories
    slots.py               SlotManager and slot lifecycle
    redis_slots.py         Optional Redis-backed SlotManager
    database.py            SQLAlchemy ORM models and completed-series repository
    game.py                Move validation and win detection
    websocket_manager.py   WebSocket receive loop and gameplay handling
    static/overview/       Built Vite dashboard (shipped in the wheel)
  client/                  -> console command: hexgame {random,play,gui}
    __main__.py            Subcommand dispatcher
    random_client.py       Random-move client          (hexgame random)
    model_client.py        Model-driven client         (hexgame play)
    gui_client.py          Pygame model/human client   (hexgame gui, [gui] extra)
    client_safety.py       Model-output validation and JSONL replay logging
    hex_engine.py          Local Hex engine used by ML models
    models/                Bundled example model agents (model_random, model_first)
    run_pair.sh            Launches two random clients against a local server

frontend/
  src/                     Vite React overview source
  vite.config.ts           Builds into src/hexgame/server/static/overview/

examples/                  Heavy/optional ML extras (not part of the package):
  model_alphazero.py, model_dqn*.py, *.pt weights, hex_mcts.cpp, setup_mcts.py

tests/
  test_*.py                Unit and integration tests
```

## Operational Notes

- The default `memory` backend is single-process only.
- Set `HEX_STATE_BACKEND=redis` before running `hexgame-server --workers N`
  (N > 1). Redis stores shared slot/game/session state, while WebSocket
  connections remain attached to the worker that accepted them.
- `/overview` is an operational/debug dashboard. Protect or disable it before
  exposing this service beyond a trusted local network.
- On disconnect, the slot is held for the reconnect timeout. Clients using
  `--keep-slot` can keep a slot after a finished series or after an opponent
  disconnects.

## Troubleshooting

### `/`, `/docs`, or `/overview` is blank

Rebuild the frontend:

```bash
cd frontend
npm run build
```

The built `index.html` must reference assets under `/overview/assets/...`.

### Random client gets HTTP 404

Reinstall the package (so `uvicorn[standard]`/`websockets` are present) and
restart the server:

```bash
python -m pip install -e ".[dev,all]"
hexgame-server --port 8000
```

This usually means the server was started before WebSocket support was
available, or another app is listening on port 8000.

### Slot state looks stale

With the memory backend, restart the server. With Redis, inspect or clear keys
under `HEX_REDIS_KEY_PREFIX` if you intentionally want to reset persisted slot
state.
