# WebSocket Schema — `/ws/races/{race_id}`

Live race updates via WebSocket. Clients connect to a race room and receive
real-time state, odds updates, and result notifications.

---

## Connection Handshake

Connect to the WebSocket endpoint:

```
ws://<host>:<port>/ws/races/{race_id}
```

- **race_id** (int, path param) — Race identifier from the database.
- The server validates the race exists. If not found, the connection is
  closed with **code 4004**.
- On success, the server accepts the connection and immediately sends an
  `initial_state` message.

### JavaScript

```js
const ws = new WebSocket('ws://localhost:8000/ws/races/12345');

ws.onopen = () => {
    console.log('Connected');
    // Subscribe to updates
    ws.send(JSON.stringify({ type: 'subscribe', race_id: 12345 }));
};

ws.onmessage = (event) => {
    const msg = JSON.parse(event.data);
    console.log('Received:', msg.type, msg);
};

ws.onclose = (event) => {
    console.log('Disconnected:', event.code, event.reason);
};
```

---

## Message Types

### Client → Server

#### `subscribe`

Subscribe to race updates (sent after connection is established).

| Field     | Type   | Required | Description                   |
|-----------|--------|----------|-------------------------------|
| `type`    | string | yes      | Must be `"subscribe"`         |
| `race_id` | int    | yes      | Race ID to subscribe to       |

```json
{
    "type": "subscribe",
    "race_id": 12345
}
```

The server responds with a `subscribed` message confirming the subscription.

---

### Server → Client

#### `subscribed`

Confirms the client's subscription was registered.

```json
{
    "type": "subscribed",
    "race_id": 12345
}
```

| Field     | Type   | Description                   |
|-----------|--------|-------------------------------|
| `type`    | string | Always `"subscribed"`         |
| `race_id` | int    | The race ID subscribed to     |

---

#### `initial_state`

Sent immediately after connection is accepted. Contains full race details
and list of starters.

```json
{
    "type": "initial_state",
    "race_id": 12345,
    "data": {
        "race": {
            "id": 12345,
            "meeting_id": "NZ_20260501_AUK",
            "race_number": 7,
            "distance_m": 1980,
            "start_type": "mobile",
            "gait": "pace",
            "weather": "fine",
            "track_condition": "good",
            "race_datetime": "2026-05-01T19:30:00+12:00",
            "venue": "Addington",
            "meeting_date": "2026-05-01"
        },
        "starters": [
            {
                "id": 9876,
                "horse_id": 42,
                "horse_name": "Some Delight",
                "driver_id": 101,
                "driver_name": "Ricky May",
                "trainer_id": 201,
                "trainer_name": "Mark Purdon",
                "runner_number": 3,
                "barrier": 3,
                "handicap_m": null,
                "placing": null,
                "did_not_finish": false
            }
        ],
        "starter_count": 1
    }
}
```

| Field                        | Type   | Description                     |
|------------------------------|--------|---------------------------------|
| `type`                       | string | Always `"initial_state"`        |
| `race_id`                    | int    | Race ID                         |
| `data.race.id`               | int    | Race ID                         |
| `data.race.meeting_id`       | string | Meeting identifier              |
| `data.race.race_number`      | int    | Race number within the meeting  |
| `data.race.distance_m`       | int    | Race distance in metres         |
| `data.race.start_type`       | string | `mobile`, `standing`, or null   |
| `data.race.gait`             | string | `pace`, `trot`, or null         |
| `data.race.weather`          | string | Weather condition or null       |
| `data.race.track_condition`  | string | Track rating or null            |
| `data.race.race_datetime`    | string | ISO 8601 datetime or null       |
| `data.race.venue`            | string | Venue name or null              |
| `data.race.meeting_date`     | string | ISO 8601 date or null           |
| `data.starters[]`            | array  | List of starter objects         |
| `data.starters[].id`         | int    | Starter ID                      |
| `data.starters[].horse_id`   | int    | Horse ID                        |
| `data.starters[].horse_name` | string | Horse name or null              |
| `data.starters[].driver_id`  | int    | Driver ID or null               |
| `data.starters[].driver_name`| string | Driver name or null             |
| `data.starters[].trainer_id` | int    | Trainer ID or null              |
| `data.starters[].trainer_name`| string| Trainer name or null            |
| `data.starters[].runner_number`| int  | Saddlecloth number or null      |
| `data.starters[].barrier`    | int    | Starting barrier or null        |
| `data.starters[].handicap_m` | int    | Handicap in metres or null      |
| `data.starters[].placing`    | int    | Final placing or null           |
| `data.starters[].did_not_finish`| bool | DNF flag                    |
| `data.starter_count`         | int    | Total number of starters        |

---

#### `odds_update`

Broadcast when new market odds are available (simulated every 5–10 seconds).

```json
{
    "type": "odds_update",
    "race_id": 12345,
    "timestamp": "2026-05-01T19:35:22.123456+00:00",
    "odds": [
        { "horse_id": 42, "odds": 3.50 },
        { "horse_id": 43, "odds": 8.20 }
    ]
}
```

| Field           | Type   | Description                        |
|-----------------|--------|------------------------------------|
| `type`          | string | Always `"odds_update"`             |
| `race_id`       | int    | Race ID                            |
| `timestamp`     | string | ISO 8601 timestamp of the update   |
| `odds`          | array  | Array of horse → odds mappings     |
| `odds[].horse_id` | int  | Horse ID                           |
| `odds[].odds`   | float  | Decimal win odds                   |

---

#### `result_update`

Broadcast when the race finishes and official results are available.

```json
{
    "type": "result_update",
    "race_id": 12345,
    "timestamp": "2026-05-01T19:36:00.000000+00:00",
    "results": [
        { "horse_id": 42, "placing": 1, "finished": true },
        { "horse_id": 43, "placing": 2, "finished": true },
        { "horse_id": 44, "placing": 3, "finished": true }
    ]
}
```

| Field              | Type   | Description                        |
|--------------------|--------|------------------------------------|
| `type`             | string | Always `"result_update"`           |
| `race_id`          | int    | Race ID                            |
| `timestamp`        | string | ISO 8601 timestamp                 |
| `results`          | array  | Array of result entries            |
| `results[].horse_id` | int  | Horse ID                           |
| `results[].placing`  | int  | Final placing (1 = winner)        |
| `results[].finished` | bool | Whether the horse finished         |

---

## Error Codes

| Code | Reason                  | Description                                  |
|------|-------------------------|----------------------------------------------|
| 4004 | Race not found          | The specified `race_id` does not exist.      |
| 1011 | Internal server error   | An unexpected error occurred on the server.  |

When a connection is closed with an error code, the `reason` string contains
a human-readable description.

---

## Implementation Notes

- The server is defined in `apps/backend/api/websocket.py` with a
  `ConnectionManager` class that manages rooms per race.
- Current odds updates are simulated for demonstration. In production, these
  would be replaced by a live data feed.
- A background `simulate_race_updates` task starts when the first client
  connects and stops when the last client disconnects.
- The simulation broadcasts odds updates every 5–10 seconds for ~60 seconds,
  then sends a final result update.

### Python (using `websockets`)

```python
import asyncio
import json
import websockets

async def listen():
    uri = "ws://localhost:8000/ws/races/12345"
    async with websockets.connect(uri) as ws:
        # Subscribe
        await ws.send(json.dumps({"type": "subscribe", "race_id": 12345}))
        async for message in ws:
            data = json.loads(message)
            print(f"[{data['type']}]", data)

asyncio.run(listen())
```

---

## Schema Reference

The following Pydantic models define the message schemas (see
`apps/backend/api/websocket.py`):

| Class                   | Message Type      | Direction         |
|-------------------------|-------------------|-------------------|
| `WSMessage`             | — (incoming)      | client → server   |
| `InitialStateMessage`   | `initial_state`   | server → client   |
| `OddsUpdateMessage`     | `odds_update`     | server → client   |
| `ResultUpdateMessage`   | `result_update`   | server → client   |
