Document tournament live websockets

This commit is contained in:
Daniel Schädler 2026-04-24 16:05:02 +02:00
parent 3dcc9727a5
commit 4dc8198bc2
2 changed files with 287 additions and 0 deletions

View File

@ -32,6 +32,7 @@ Leader/follower tournament sync docs:
- [doc/leader_follower.md](doc/leader_follower.md)
- [doc/timer_api.md](doc/timer_api.md)
- [doc/beamer_live_updates.md](doc/beamer_live_updates.md)
- [doc/tournament_live_updates.md](doc/tournament_live_updates.md)
## Docker
[Registry](https://gitlab.com/turniere/turniere-backend/container_registry)

View File

@ -0,0 +1,286 @@
# Tournament Live Updates
Tournament and match live updates use Rails ActionCable at `/cable`.
Use these channels for website pages and beamer pages that need automatic updates when:
- match scores change
- match state changes
- match positions change
- group scores change
- stages change
- playoff matches are populated
- follower-sync imports update a read-only follower tournament
This is websocket push, not webhook callback.
## Transport
- websocket endpoint: `GET /cable`
- protocol: ActionCable
- no auth token is required for read subscriptions
- write operations still go through HTTP APIs
Frontend should:
1. Load initial state with HTTP.
2. Subscribe to relevant ActionCable channel.
3. Treat incoming websocket payloads as replacement snapshots.
4. Continue sending mutations through HTTP only.
## Channels
### TournamentChannel
Use this when page needs full tournament structure:
- stages
- groups
- group scores
- group matches
- playoff stages
- playoff matches
- teams and advancing flags
- timer fields
- team action lists
Subscription identifier:
```json
{
"channel": "TournamentChannel",
"tournament_id": 123
}
```
Payload:
```json
{
"type": "tournament.updated",
"tournament_id": 123,
"tournament": {
"id": 123,
"name": "Summer Cup",
"code": "abc123",
"public": true,
"description": "Example tournament",
"playoff_teams_amount": 4,
"instant_finalists_amount": 4,
"intermediate_round_participants_amount": 0,
"timestamp": null,
"timer_mode": null,
"owner_username": "owner",
"stages": [],
"teams": [],
"team_action_lists": []
}
}
```
The nested `tournament` object has same public shape as:
- `GET /tournaments/:id`
Frontend can replace cached tournament state with `payload.tournament`.
### TournamentMatchesChannel
Use this when page only needs match cards/list updates:
- match id
- position
- state
- teams
- score rows
Subscription identifier for all matches:
```json
{
"channel": "TournamentMatchesChannel",
"tournament_id": 123
}
```
Optional state filter:
```json
{
"channel": "TournamentMatchesChannel",
"tournament_id": 123,
"state": "in_progress"
}
```
Supported `state` values:
- omitted / `null`: all matches
- `upcoming`
- `single_team`
- `not_ready`
- `not_started`
- `in_progress`
- `finished`
- `undecided`
Payload:
```json
{
"type": "tournament_matches.updated",
"tournament_id": 123,
"state": null,
"matches": [
{
"id": 456,
"position": 0,
"state": "in_progress",
"teams": [
{
"id": 10,
"name": "Team A"
},
{
"id": 11,
"name": "Team B"
}
],
"match_scores": [
{
"id": 900,
"points": 12,
"hidden_points": 0,
"team": {
"id": 10,
"name": "Team A"
}
},
{
"id": 901,
"points": 8,
"hidden_points": 0,
"team": {
"id": 11,
"name": "Team B"
}
}
]
}
]
}
```
The `matches` array has same shape as:
- `GET /tournaments/:id/matches`
- `GET /tournaments/:id/matches?state=:state`
Frontend can replace cached match list for that filter with `payload.matches`.
## Initial Snapshot
Both channels transmit current snapshot immediately after subscription.
This means frontend can safely:
- fetch over HTTP first, then subscribe
- or subscribe and use first websocket message as live refresh
HTTP first is still recommended because it gives normal request/error/loading behavior.
## Broadcast Sources
Broadcasts happen after successful backend writes/imports.
Covered direct HTTP mutations:
- `PATCH /match_scores/:id`
- `PATCH /matches/:id`
- `PATCH /matches/:id/swap`
- `POST /groups/:group_id/matches`
- `PATCH /stages/:id`
Covered follower-sync mutation:
- `PATCH /tournaments/:id/sync_state`
Practical effects:
- score update broadcasts updated matches and updated group scores
- finishing group stage broadcasts new playoff stages/matches
- playoff match finish broadcasts populated next match
- follower tournament subscribers get same updates after source sync import
## Frontend Integration
Recommended tournament page flow:
1. Fetch `GET /tournaments/:id`.
2. Render tournament.
3. Subscribe to `TournamentChannel`.
4. On `tournament.updated`, replace tournament state with `payload.tournament`.
5. Keep writes as HTTP requests.
Recommended match-list/beamer flow:
1. Fetch `GET /tournaments/:id/matches` or state-filtered matches endpoint.
2. Render matches.
3. Subscribe to `TournamentMatchesChannel` with same `state` filter.
4. On `tournament_matches.updated`, replace that match list with `payload.matches`.
5. For multiple tabs/lists, use one subscription per filter you need.
Recommended update handling:
- do not patch a single nested field from websocket payload
- replace the full snapshot for that channel/filter
- use `match.id`, `team.id`, and `match_score.id` as stable keys
- tolerate duplicate payloads
- tolerate fast sequences like score update followed by match finish
## ActionCable Message Example
Raw subscribe message:
```json
{
"command": "subscribe",
"identifier": "{\"channel\":\"TournamentMatchesChannel\",\"tournament_id\":123,\"state\":\"in_progress\"}"
}
```
ActionCable wraps server payloads under `message`.
Client libraries usually unwrap this for you.
Raw received frame shape:
```json
{
"identifier": "{\"channel\":\"TournamentMatchesChannel\",\"tournament_id\":123,\"state\":\"in_progress\"}",
"message": {
"type": "tournament_matches.updated",
"tournament_id": 123,
"state": "in_progress",
"matches": []
}
}
```
## Relationship To Other Live Channels
Other specialized channels still exist:
- `TournamentTimersChannel`: timer-only payloads
- `TournamentBeamersChannel`: beamer display config payloads
- `TournamentTeamActionListsChannel`: team action list payloads
Use `TournamentChannel` when full tournament state is needed.
Use specialized channels when frontend wants smaller, focused payloads.
## Tests
Covered by:
- [spec/services/tournament_live_payload_spec.rb](../spec/services/tournament_live_payload_spec.rb)
- [spec/e2e/http/tournament_live_websocket_spec.rb](../spec/e2e/http/tournament_live_websocket_spec.rb)
- [spec/e2e/http/tournament_follow_sync_live_websocket_spec.rb](../spec/e2e/http/tournament_follow_sync_live_websocket_spec.rb)