Files
ActionBlock/doc/server.md
T
2026-10-24 20:32:37 +02:00

199 lines
12 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Server (`actionBlocks_server/`)
ASP.NET Core 10 solution `ActionBlocks.Server.sln` with three projects:
```
actionBlocks_server/
├── src/ActionBlocks.Game/ # game rules – no web dependencies
│ ├── Core/
│ │ ├── PieceType.cs # enum I,J,L,O,S,Z,T (value = color 1-7) + CellColor
│ │ ├── Cell.cs # Cell(Color, ItemId?) and Point(X, Y)
│ │ ├── PieceDefinitions.cs # shapes + derived rotation states
│ │ ├── Piece.cs # PieceSpec (shape + item) and ActivePiece (spec + position)
│ │ ├── Board.cs # grid of locked cells, line clears, garbage, swap
│ │ ├── RotationSystem.cs # IRotationSystem + SuperRotationSystem (kick tables)
│ │ └── Randomizer.cs # IRandomizer + SevenBagRandomizer
│ ├── Garbage/GarbageCalculator.cs # ClearInfo, IGarbageCalculator, IScoreCalculator
│ ├── Targeting/GarbageTargeter.cs # IGarbageTargeter + CyclingGarbageTargeter
│ ├── Items/
│ │ ├── IItem.cs # IItem + ItemContext
│ │ ├── Items.cs # the five item implementations
│ │ └── ItemRegistry.cs # ItemRegistry (list of items) + ItemSpawner
│ ├── GameSettings.cs # all tunable numbers
│ ├── PlayerGame.cs # one player's simulation + InputAction enum
│ └── Match.cs # one round: players, garbage/item routing, winner
├── src/ActionBlocks.Server/ # web host
│ ├── Program.cs # DI, CORS, SignalR, endpoints, config
│ ├── Hubs/GameHub.cs # SignalR API for clients
│ ├── Lobbies/Lobby.cs # one lobby (players, host, current match)
│ ├── Lobbies/LobbyManager.cs # all lobbies, codes, connection→lobby mapping
│ ├── Lobbies/MatchRunner.cs # BackgroundService: 60 Hz tick + broadcast
│ ├── Dtos/Dtos.cs # records sent over the wire
│ ├── Dtos/SnapshotMapper.cs # game objects → DTOs
│ └── appsettings.json
└── tests/ActionBlocks.Game.Tests/ # xUnit tests
```
---
## ActionBlocks.Game – the rules library
### Coordinates
- **x** grows to the right, **y** grows **downwards**.
- `Board` has `Height = VisibleRows + BufferRows` rows (40 by default).
- Row `0` is the top of the hidden buffer. The visible field is rows `BufferRows … Height-1`, which is 20–39 by default.
- Piece shapes are offsets inside a square bounding box (2×2, 3×3 or 4×4). `ActivePiece.X/Y` is the board position of the box's top-left corner.
- Network DTOs convert to **visible coordinates** (`y - BufferRows`), so clients see y = 0 at the top of the visible field. Active-piece cells can have negative y while they are still in the buffer.
### Cells and pieces
```csharp
readonly record struct Cell(byte Color, string? ItemId = null) // Color 0 = empty, 1-7 pieces, 8 garbage
sealed record PieceSpec(PieceType Type, IReadOnlyList<string?>? Items) // what sits in the queue; Items[i] = item in mino i
sealed record ActivePiece(PieceSpec Spec, int X, int Y, int Rotation) // a spec placed on the board
```
The item belongs to a **mino index** (0–3), not to a position. `PieceDefinitions` computes every rotation state by rotating the spawn shape mino by mino, so mino *i* is always the same physical cell of the piece. That keeps the item attached to the right block through every rotation. The website's preview shapes (`src/render/shapes.ts`) use the same mino order.
### `Board`
Holds the rows as `List<Cell[]>`, which makes shifting rows cheap. Main operations:
| Method | Purpose |
|---|---|
| `Fits(piece)` / `IsBlocked(x, y)` | Collision checks. Outside the board counts as blocked. |
| `Place(piece)` | Writes the piece's cells, including its item, into the grid |
| `FullRows()` / `ClearRows(rows)` | Line clear. Returns the item ids of removed cells |
| `AddGarbage(count, hole)` | Pushes rows in from the bottom. Returns `true` when blocks overflow the top (top out) |
| `RemoveBottomRow()`, `Clear()` | Used by items |
| `SwapContents(other)` | Exchanges the row lists of two boards (Switch item) |
### `PlayerGame` – one player's field
This is the heart of the simulation. Its lifecycle:
```mermaid
stateDiagram-v2
[*] --> Created: new PlayerGame(...)
Created --> Playing: Start() → fill queue, spawn
Playing --> Playing: Update(dt, now) / HandleInput(action)
Playing --> ToppedOut: TopOut() (block out, lock out, overflow, forfeit)
ToppedOut --> [*]
```
- **`Update(dt, now)`** is called every tick. It applies gravity (a fractional row counter `_gravityProgress`) and runs the lock-delay timer. Then it locks if needed.
- **`HandleInput(action)`** handles movement, rotation, soft-drop on/off, hard drop and hold. `UseItem` is ignored here, because using an item needs a target. `Match` handles it.
- **`Lock()`** is the central routine. It detects spins, places the piece, checks lock-out, clears rows and collects items. It also updates combo, back-to-back, score and level. Then it calculates the attack, cancels pending garbage and queues the remainder in `_outgoingAttacks`. If no line was cleared, it applies pending garbage. Finally it spawns the next piece.
- **Garbage in:** `QueueGarbage(lines)` adds to the pending list. **Garbage out:** `DrainOutgoingAttacks()` is read by `Match`.
- **Item hooks:** `InsertGarbageNow`, `RemoveBottomLine`, `ClearBoard`, `SwapBoards`, `InvisibleUntil`. After any board change under the active piece, `ResolveActiveOverlap()` pushes the piece up until it fits. If it can't fit anywhere, the player tops out.
- **Inventory:** `AddItem` (capped), `TakeFirstItem` (FIFO).
Dependencies are injected through the constructor (`IRandomizer`, `ItemSpawner`, `IRotationSystem`, `IGarbageCalculator`, `IScoreCalculator`, `Random`), so tests can control every source of randomness. See `tests/.../TestHelpers.cs`.
### `Match` – one round
- **Constructor.** It creates one `PlayerGame` per player. All of them share one piece seed, so everyone gets the same sequence. Each player gets its own `Random` for items and garbage holes. The match starts in `Countdown`, or directly in `Running` when `CountdownSeconds == 0`, which tests use.
- **`Update(dt)`** advances the countdown, or updates all players and then runs `AfterStateChange()`.
- **`HandleInput(playerId, action)`** is ignored unless the match is running and the player is alive. `UseItem` is resolved here. Then `AfterStateChange()` runs.
- **`SelectTarget(playerId, targetId)`** stores the target on the `PlayerGame`. An invalid or dead target becomes the player themself.
- **`Forfeit(playerId)`** is used when a player disconnects.
- **`AfterStateChange()`** routes every outgoing attack through the `IGarbageTargeter`, assigns places to newly eliminated players, resets targets that point at dead players, and detects the end of the match.
- **Events.** `ItemUsedEvent(TargetId, ItemId, ItemName)` and `PlayerEliminatedEvent(PlayerId, Place)` are collected and drained once per tick by the host.
### Items
```csharp
public interface IItem
{
string Id { get; } // stable id shared with the frontend ("add_line", ...)
string DisplayName { get; } // shown in the flash
int SpawnWeight => 1; // relative spawn probability
void Apply(ItemContext context);
}
public sealed record ItemContext(PlayerGame User, PlayerGame Target, Random Random, double Now);
```
- `ItemRegistry.Default` lists all active items. `ItemRegistry.PickRandom` chooses an item weighted by `SpawnWeight`.
- `ItemSpawner.Decorate(type)` turns a raw piece type into a `PieceSpec`. Each mino rolls `ItemSpawnChance` on its own, so a piece can carry 0–4 items.
See [Extending → Add an item](extending.md#add-a-new-item).
### `GameSettings`
`GameSettings` is an immutable record with `init` properties, one per tunable number. See [Game rules](game-rules.md) for what each one does. The host binds it from the configuration section `Game`, so you can override any property with an environment variable named `Game__<PropertyName>`, e.g. `Game__ItemSpawnChance=0.25`.
---
## ActionBlocks.Server – the host
### `Program.cs`
- Reads the configuration: `ALLOWED_ORIGINS`, `HUB_PATH` (default `/hub`), `PATH_BASE` and the `Game` section.
- **CORS.** It allows the listed origins, or any origin when the list is empty, and allows credentials, which SignalR needs.
- **SignalR** uses JSON with `JsonStringEnumConverter`, so `InputAction` travels as a string like `"HardDrop"`.
- **Singletons:** `GameSettings` and `LobbyManager`. Hosted service: `MatchRunner`.
- **Endpoints:** `GET /health` returns `{"status":"ok"}`, and the hub is mapped at `HUB_PATH`.
- `UsePathBase(PATH_BASE)` lets the app accept requests that still carry a proxy prefix. Requests without the prefix work too.
### `LobbyManager`
- `_lobbies` maps lobby codes to `Lobby` objects. `_connectionLobby` maps each SignalR connection id to a lobby code. One connection can be in at most one lobby.
- **Codes** are 6 characters from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789`. Ambiguous characters (0/O, 1/I) are excluded, and codes come from a cryptographically secure RNG. Joining is case-insensitive.
- **Names** are trimmed, control characters are removed, and they are cut to 16 characters. An empty name becomes `Player N`.
- **Player ids** are random 12-hex-character strings. They are deliberately **not** the connection id, so a connection id never leaks to other clients.
- `Leave` removes the player, and deletes the lobby once it is empty.
- Failures throw a `LobbyException`. The hub turns it into a `HubException` whose message reaches the client.
### `Lobby`
- `Players` and `HostId` are tracked; the first player is the host. If the host leaves, the next player becomes host.
- `MaxPlayers = 8`.
- `StartMatch(settings)` creates a `Match` from the current players. `EndMatch()` stores `LastResults` and clears `Match`.
- `Dirty` is set by input so that the next tick broadcasts immediately.
- `Remove(player)` also forfeits the player in a running match.
- `ToDto()` builds the `LobbyDto`.
### `GameHub`
The client API. It is documented in detail in [Protocol](protocol.md). Every method that touches a lobby locks `lobby.Sync`. `OnDisconnectedAsync` treats a disconnect exactly like `LeaveLobby`.
### `MatchRunner`
A `BackgroundService` with a `PeriodicTimer` at 60 Hz. Every tick:
1. Measures the real elapsed time and clamps it to at most 0.1 s, so a stall doesn't cause huge jumps.
2. For each lobby with a match, inside the lock: `match.Update(delta)`, then `DrainEvents()`.
3. Builds a snapshot when any of these holds: it is an even tick (≈30 Hz baseline), the lobby is dirty (input happened), or events are present. Building a snapshot whenever events exist means no event is ever dropped.
4. Once the match is finished, it waits `ResultsDelay` (4 s), then calls `lobby.EndMatch()` and broadcasts a `LobbyUpdated`.
5. Sends happen outside the lock, fire-and-forget.
Exceptions inside one lobby are logged and do not affect other lobbies.
### DTO mapping
`SnapshotMapper` converts a `Match` into a `MatchStateDto`:
- Only the visible rows are sent.
- The board becomes a compact string with one character per cell.
- Item cells are listed separately.
- The active piece and the ghost are omitted for eliminated players.
The format is in [Protocol](protocol.md).
---
## Tests
`tests/ActionBlocks.Game.Tests` (xUnit) covers:
| File | Covers |
|---|---|
| `CoreTests.cs` | Rotation states, wall kicks, bag randomizer, row clearing with items, garbage insertion |
| `PlayerGameTests.cs` | Spawn and gravity, line clears collecting items (from the board and from the piece), hold, pending garbage, attack size, top out, inventory cap |
| `ItemAndMatchTests.cs` | Each item's effect, Switch overlap resolution, Blackout duration, item use routed through `Match` with events, winner and places, target fallback, garbage cycling, countdown |
`TestHelpers.CreatePlayer(...)` builds a `PlayerGame` with a fixed piece sequence (`FixedRandomizer`) and a configurable item chance. `FillRow(board, y, holes...)` prepares board states.
Run them with `dotnet test` in `actionBlocks_server/`. The Docker build also runs them, so a failing test fails the image build.