added documentation

This commit is contained in:
2026-10-23 20:11:23 +02:00
parent 0913adaca5
commit 498df86962
10 changed files with 1285 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
# Client ↔ Server Protocol
Communication uses **ASP.NET Core SignalR** over WebSockets. SignalR falls back to Server-Sent Events or long polling if WebSockets are unavailable. The payload format is JSON with **camelCase** property names. Enums are sent as **strings**.
- Hub URL: `<server>/hub`. The path is configurable through `HUB_PATH`, and the website finds the URL through `SERVER_URL` (see [Deployment](deployment.md)).
- Server implementation: `actionBlocks_server/src/ActionBlocks.Server/Hubs/GameHub.cs`
- DTO definitions: `actionBlocks_server/src/ActionBlocks.Server/Dtos/Dtos.cs`
- Client implementation: `actionBlocks_website/src/net/hubClient.ts`
- Client types (mirror of the DTOs): `actionBlocks_website/src/net/types.ts`
> When you change a DTO on the server, update `types.ts` in the website as well. Nothing generates it automatically.
## Client → server (hub methods)
| Method | Arguments | Returns | Notes |
|---|---|---|---|
| `CreateLobby` | `name: string` | `JoinResult` | Creates a lobby and joins it as host |
| `JoinLobby` | `code: string, name: string` | `JoinResult` | The code is case-insensitive |
| `LeaveLobby` | – | – | Also forfeits a running match |
| `StartGame` | – | – | Host only; fails while a match is running |
| `Input` | `action: InputAction` | – | Sent with `send` (fire-and-forget) |
| `SelectTarget` | `targetId: string` | – | Player id of the item target, or your own id |
| `GetItems` | – | `ItemInfo[]` | Lists all server items (id + name). Currently unused by the website |
`InputAction` is one of `MoveLeft`, `MoveRight`, `RotateCw`, `RotateCcw`, `SoftDropStart`, `SoftDropStop`, `HardDrop`, `Hold`, `UseItem`.
**Soft drop** is a state: the client sends `SoftDropStart` on key down and `SoftDropStop` on key up. Every other action is a single event. Left/right auto-repeat is generated by the client.
### Errors
Failed calls throw a `HubException`. The client receives its message, and `hubClient.ts` extracts the text after `HubException: `. Possible messages:
| Message | Cause |
|---|---|
| `You are already in a lobby.` | `CreateLobby` / `JoinLobby` while already joined |
| `Lobby not found.` | Wrong code, or the lobby was deleted |
| `Lobby is full.` | 8 players already |
| `You are not in a lobby.` | `StartGame` without a lobby |
| `Only the host can start the game.` | `StartGame` from a non-host |
| `A game is already running.` | `StartGame` during a match |
`Input` and `SelectTarget` from a connection that is not in a lobby are silently ignored.
## Server → client messages
| Message | Payload | Sent when |
|---|---|---|
| `LobbyUpdated` | `Lobby` | Another player joined or left, the host changed, a match started (`phase: "playing"`) or a match ended and results are available (`phase: "waiting"`) |
| `MatchState` | `MatchState` | During a match: ≈30 Hz, plus immediately after any input or event, and for 4 s after the match finished |
The player who calls `CreateLobby` / `JoinLobby` gets the lobby as the method's return value. Only the *other* players receive `LobbyUpdated` for that join.
## Lobby flow
```mermaid
sequenceDiagram
participant A as Host browser
participant S as Server
participant B as Guest browser
A->>S: CreateLobby("Alice")
S-->>A: JoinResult { playerId, lobby{code:"MFHZVM"} }
B->>S: JoinLobby("mfhzvm", "Bob")
S-->>B: JoinResult
S-->>A: LobbyUpdated (2 players)
A->>S: StartGame()
S-->>A: LobbyUpdated (phase: playing)
S-->>B: LobbyUpdated (phase: playing)
loop match running
A->>S: Input / SelectTarget
B->>S: Input / SelectTarget
S-->>A: MatchState
S-->>B: MatchState
end
Note over S: phase finished → 4 s results
S-->>A: LobbyUpdated (phase: waiting, lastResults)
S-->>B: LobbyUpdated (phase: waiting, lastResults)
```
## Payloads
### `JoinResult`
```json
{ "playerId": "ade86f694a81", "lobby": { /* Lobby */ } }
```
`playerId` identifies *you* inside this lobby. The client needs it to find its own entry in `MatchState.players`.
### `Lobby`
```json
{
"code": "MFHZVM",
"hostId": "ade86f694a81",
"phase": "waiting", // "waiting" | "playing"
"maxPlayers": 8,
"players": [
{ "id": "ade86f694a81", "name": "Alice", "isHost": true },
{ "id": "3466ff69d433", "name": "Bob", "isHost": false }
],
"lastResults": [ // null before the first match
{ "id": "ade86f694a81", "name": "Alice", "place": 1, "score": 1250, "lines": 12 }
]
}
```
### `MatchState`
```json
{
"phase": "running", // "countdown" | "running" | "finished"
"countdown": 0, // seconds left in the countdown
"winnerId": null, // set when finished
"width": 10,
"height": 20, // visible rows
"players": [ /* PlayerState, in match order */ ],
"events": [ /* MatchEvent, only those since the previous snapshot */ ]
}
```
### `PlayerState`
```json
{
"id": "ade86f694a81",
"name": "Alice",
"alive": true,
"place": null, // set when eliminated, and for the winner at the end
"board": "0000000000…3330000000", // width*height chars, row by row, top to bottom
"items": [ { "x": 2, "y": 19, "itemId": "nuke" } ],
"active": {
"type": 7,
"cells": [[4,-1],[3,0],[4,0],[5,0]],
"itemIndex": 2, // which of the 4 cells carries the item, -1 if none
"itemId": "swap"
},
"ghost": [[4,17],[3,18],[4,18],[5,18]],
"hold": { "type": 3, "itemId": null, "itemIndex": -1 },
"holdUsed": false,
"next": [ { "type": 4, "itemId": null, "itemIndex": -1 }, … ],
"inventory": ["add_line", "blind"], // oldest first; inventory[0] is used by F
"score": 1250,
"lines": 12,
"level": 2,
"pendingGarbage": 3, // incoming lines waiting to rise
"invisibleRemaining": 0 // seconds of Blackout left (> 0 = hide stack + ghost)
}
```
**Board encoding.** One character per cell: `'0'` is empty, `'1'`–`'7'` are piece colors (I, J, L, O, S, Z, T), `'8'` is garbage. Index = `y * width + x`, where y = 0 is the **top visible row**.
**Coordinates.** All `x, y` values are in visible coordinates. Active-piece cells can have **negative y** while the piece is still above the visible area. Clients skip those cells when drawing.
**Piece type numbers** are `PieceType` values: `1=I, 2=J, 3=L, 4=O, 5=S, 6=Z, 7=T`. They double as the color index in `theme/colors.ts`.
For eliminated players, `active` and `ghost` are `null`.
### `MatchEvent`
All events use one flat shape. Fields that don't apply are `null`.
| `type` | Fields | Meaning |
|---|---|---|
| `itemUsed` | `targetId`, `itemId`, `itemName` | An item hit `targetId`. **The user is intentionally not included.** |
| `eliminated` | `playerId`, `place` | A player topped out or left |
The selected item **target is never part of any broadcast**, so players cannot tell who is targeting them.
## Writing a test client
Any SignalR client can talk to the server. A minimal Node.js example:
```js
const signalR = require('@microsoft/signalr');
const c = new signalR.HubConnectionBuilder().withUrl('http://localhost:5080/hub').build();
c.on('LobbyUpdated', (lobby) => { console.log('lobby', lobby.phase); }); // handlers must not return a value
c.on('MatchState', (state) => { /* inspect state.players / state.events */ });
await c.start();
const { playerId, lobby } = await c.invoke('JoinLobby', 'ABC123', 'Bot');
c.send('Input', 'HardDrop');
```
> SignalR's JavaScript client treats a handler's **return value** as a "client result". Write handlers as blocks (`(x) => { … }`), not expressions (`(x) => state = x`). Otherwise the client logs `Result given for '…' method but server is not expecting a result`.