spades 1.2.2

A popular four person card game implemented in Rust.
Documentation
# Spades Server

HTTP server for hosting concurrent multiplayer Spades games.

## Building and Running

```bash
cargo build --features server
cargo run --features server -- --port 3000
cargo run --features server -- --port 3000 --db games.sqlite
```

Default port is 3000. Override with `--port` or `PORT` env var.
SQLite persistence is optional. Enable with `--db <path>` or `DATABASE_URL` env var. Without it, games are in-memory only.

## API Reference

### Game Endpoints

#### POST /games

Create a new game with random player IDs.

```json
// Request
{"max_points": 500}

// Response 200
{"game_id": "<uuid>", "player_ids": ["<uuid>", "<uuid>", "<uuid>", "<uuid>"]}
```

#### GET /games

List all active game IDs.

```json
// Response 200
["<uuid>", "<uuid>"]
```

#### GET /games/:game_id

Get current game state.

```json
// Response 200
{
  "game_id": "<uuid>",
  "state": "Betting(0)",
  "team_a_score": 0,
  "team_b_score": 0,
  "team_a_bags": 0,
  "team_b_bags": 0,
  "current_player_id": "<uuid>",
  "player_names": [
    {"player_id": "<uuid>", "name": "Alice"},
    {"player_id": "<uuid>", "name": null},
    {"player_id": "<uuid>", "name": null},
    {"player_id": "<uuid>", "name": null}
  ],
  "timer_config": null,
  "player_clocks_ms": null,
  "active_player_clock_ms": null
}
```

#### DELETE /games/:game_id

Remove a game. Returns 204 on success, 404 if not found.

#### POST /games/:game_id/transition

Make a move.

```json
// Start
{"type": "start"}

// Bet
{"type": "bet", "amount": 3}

// Play card
{"type": "card", "card": {"suit": "Spade", "rank": "Ace"}}

// Response 200
{"success": true, "result": "Start"}
```

Suit values: `Club`, `Diamond`, `Heart`, `Spade`.
Rank values: `Two` through `Ten`, `Jack`, `Queen`, `King`, `Ace`.

#### GET /games/:game_id/players/:player_id/hand

Get a player's current hand.

```json
// Response 200
{"player_id": "<uuid>", "cards": [{"suit": "Spade", "rank": "Ace"}, ...]}
```

#### PUT /games/:game_id/players/:player_id/name

Set or clear a player's display name. Names must be 1-20 characters and pass profanity filter.

```json
// Request
{"name": "Alice"}

// Clear name
{"name": null}
```

Returns 204 on success.

#### GET /games/:game_id/ws

WebSocket subscription for real-time game state updates.

Query params: `player_id` (optional).

Sends current state on connect, then pushes `GameEvent` on every transition:

```json
{"StateChanged": { ... GameStateResponse ... }}
```

Or on abort:

```json
{"GameAborted": {"game_id": "<uuid>", "reason": "..."}}
```

### Matchmaking: Seek Queue

Auto-matches 4 players with identical `max_points`.

#### POST /matchmaking/seek

SSE endpoint. Adds player to seek queue and streams events until matched.

```json
// Request body
{"max_points": 500, "name": "Alice"}
```

Events:
- `queue_status` — position in queue
- `game_start` — matched, contains `MatchResult` with `game_id`, `player_id`, `player_ids`, `player_names`

Disconnecting removes the player from the queue.

#### GET /matchmaking/seeks

List seek queue summaries grouped by `max_points`.

```json
// Response 200
[{"max_points": 500, "waiting": 2}]
```

### Matchmaking: Lobbies

Manual join. Creator starts a lobby, others join. Game starts when 4 players are present.

#### POST /lobbies

SSE endpoint. Creates a lobby. Creator is the first player.

```json
// Request body
{"max_points": 500, "name": "Alice"}
```

Events:
- `lobby_update` — lobby state (players count, names)
- `game_start` — 4th player joined, contains `MatchResult`

#### GET /lobbies

List open lobbies.

```json
// Response 200
[{"lobby_id": "<uuid>", "max_points": 500, "players": 2, "player_names": ["Alice", null]}]
```

#### POST /lobbies/:lobby_id/join

SSE endpoint. Join an existing lobby.

```json
// Request body
{"name": "Bob"}
```

Same events as lobby creation.

#### DELETE /lobbies/:lobby_id

Delete a lobby. Only the creator can delete.

```json
// Request body
{"creator_id": "<uuid>"}
```

Returns 204 on success.

### Challenges

Seat-specific invitations. Creator picks a seat, shares join links for remaining seats. Game starts when all 4 seats are filled.

#### POST /challenges

SSE endpoint. Creates a challenge.

Request body is a `ChallengeConfig`:

```json
{
  "max_points": 500,
  "timer_config": {"initial_time_secs": 300, "increment_secs": 5},
  "creator_seat": "A",
  "creator_name": "Alice",
  "expiry_secs": 86400
}
```

All fields except `max_points` are optional. `creator_seat` defaults to no seat (observer-created challenge). `expiry_secs` defaults to 86400 (1 day).

Events:
- `challenge_created` — includes `challenge_id`, `seats`, `join_urls`, `expires_at_epoch_secs`
- `seat_update` — someone joined or left
- `game_start` — all seats filled, contains `MatchResult`
- `cancelled` — expired or creator cancelled

#### GET /challenges

List open challenges.

```json
// Response 200
[{"challenge_id": "<uuid>", "max_points": 500, "seats_filled": 2, "seats": [...]}]
```

#### GET /challenges/:challenge_id

Get challenge status.

```json
// Response 200
{
  "challenge_id": "<uuid>",
  "max_points": 500,
  "timer_config": null,
  "seats": [{"seat": "A", "player_id": "<uuid>", "name": "Alice"}, null, null, null],
  "status": "Open",
  "expires_at_epoch_secs": 1234567890
}
```

Status values: `Open`, `Started` (with `game_id`), `Cancelled`, `Expired`.

#### POST /challenges/:challenge_id/join/:seat

SSE endpoint. Join a specific seat (A, B, C, or D).

```json
// Request body (optional)
{"name": "Bob"}
```

Events: `seat_update`, `game_start`, `cancelled`.

Disconnecting vacates the seat.

#### DELETE /challenges/:challenge_id

Cancel a challenge. Only the creator can cancel.

```json
// Request body
{"creator_id": "<uuid>"}
```

Returns 204 on success, 403 if not creator, 404 if not found.

## Timers

Games can be created with Fischer increment timers via `timer_config` on challenge creation.

- `initial_time_secs` — starting clock per player
- `increment_secs` — seconds added after each move

Behavior on timeout:
- First round of betting: game aborts
- Later betting rounds: auto-bet 1
- Trick play: auto-play a random legal card

Clock state is included in `GameStateResponse` as `player_clocks_ms` and `active_player_clock_ms`.

## SQLite Persistence

When started with `--db`, the server:
- Creates a `games` table if it does not exist
- Loads all persisted games on startup
- Saves game state on every transition
- Removes game data on deletion

Schema:
```sql
CREATE TABLE games (
    id TEXT PRIMARY KEY,
    state TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
```

## Using GameManager in Rust Code

```rust
use spades::game_manager::GameManager;
use spades::GameTransition;

let manager = GameManager::new(); // in-memory
// or: let manager = GameManager::with_db("games.sqlite").unwrap();

let response = manager.create_game(500, None).unwrap();
let game_id = response.game_id;

manager.make_transition(game_id, GameTransition::Start).unwrap();

let state = manager.get_game_state(game_id).unwrap();
let hand = manager.get_hand(game_id, response.player_ids[0]).unwrap();
```

## Architecture

- **GameManager** — thread-safe concurrent game storage with broadcast channels for state events
- **Matchmaker** — seek queue (auto-match) and lobby (manual join) systems
- **ChallengeManager** — seat-based invitations with expiry
- **SqliteStore** — optional persistence layer
- **REST/SSE/WebSocket** — Axum-based HTTP server with CORS support
- **Drop Guards** — SSE handlers clean up seeks, lobbies, and challenge seats on client disconnect

## Dependencies (server feature only)

- `tokio` — async runtime
- `axum` — web framework (with WebSocket support)
- `tower` / `tower-http` — middleware, CORS
- `tokio-stream` / `futures-util` / `async-stream` — SSE streaming
- `rusqlite` — SQLite (bundled)
- `rustrict` — profanity filter for player names
- `serde` / `serde_json` — serialization (always included, not feature-gated)