feat: gamemaster

This commit is contained in:
Ilia Denisov
2026-05-03 07:59:03 +02:00
committed by GitHub
parent a7cee15115
commit 3e2622757e
229 changed files with 41521 additions and 1098 deletions
+177
View File
@@ -0,0 +1,177 @@
---
stage: 06
title: Contract files and contract tests
---
# Stage 06 — Contract files and contract tests
This decision record captures the non-obvious choices made while
producing the machine-readable contracts for `Game Master`:
[`../api/internal-openapi.yaml`](../api/internal-openapi.yaml),
[`../api/runtime-events-asyncapi.yaml`](../api/runtime-events-asyncapi.yaml),
and the matching contract tests in the `gamemaster` package.
## Context
[`../PLAN.md` Stage 06](../PLAN.md) freezes the GM REST and event
contracts before any handler is written, so later stages have a target
spec. The plan enumerates the 20 internal REST `operationId` values and
the two `gm:lobby_events` message types and asks contract tests to
fail loudly if anything drifts.
Three decisions were not derivable from `../README.md` or
[`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) and required a
deliberate choice while writing the YAML.
## Decision 1 — Two messages and two send operations on one channel
`gm:lobby_events` carries two distinct message types — a recurring
`runtime_snapshot_update` and a terminal `game_finished`. The AsyncAPI
3.1.0 surface encodes them as **two separate messages on one channel
with one `send` operation per message**:
```yaml
channels:
lobbyEvents:
address: gm:lobby_events
messages:
runtimeSnapshotUpdate: { $ref: '#/components/messages/RuntimeSnapshotUpdate' }
gameFinished: { $ref: '#/components/messages/GameFinished' }
operations:
publishRuntimeSnapshotUpdate: { action: send, ... }
publishGameFinished: { action: send, ... }
```
The `notification:intents` contract uses a single message with
`allOf`-conditional discriminator branches; the `runtime:health_events`
contract uses a single message with a `oneOf` `details` field. Both
patterns work when most fields are shared and only one variant slot
differs.
For `gm:lobby_events` the two payloads share only `event_type`,
`game_id`, `runtime_status`, and `player_turn_stats[]`. The remaining
fields (`current_turn`, `engine_health_summary`, `occurred_at_ms` on
the snapshot vs `final_turn_number`, `finished_at_ms` on the finish
event) have no overlap, and their semantics differ — the snapshot is
recurring, the finish event is terminal. Two messages reflect this
asymmetry directly and keep each payload schema closed without
needing per-variant `if/then` rules.
Alternatives considered:
- **One message with `allOf` discriminator** — rejected: would force
every shared field to be optional at the envelope level and
re-required inside each `if/then` branch, doubling the schema size
and complicating the contract test. The notification spec accepts
this cost because it has 18 message types and the payload-shape
asymmetry is the whole point; here it's two types with no field
overlap.
- **Two channels** — rejected: would require Game Lobby to subscribe
to two streams, breaking the cadence guarantees in `../README.md`
§Async Stream Contracts ("snapshot transitions and finish are
ordered relative to each other on the same stream").
## Decision 2 — `event_type` is a required schema-level `const`
[`../PLAN.md` Stage 06](../PLAN.md) lists the "frozen field set per
message" without naming `event_type`. The implementation pins
`event_type` as a required schema property with a `const` value:
```yaml
RuntimeSnapshotUpdatePayload:
required: [event_type, ...]
properties:
event_type: { type: string, const: runtime_snapshot_update }
```
Reasons:
1. The wire payload must carry a discriminator; consumers (Game Lobby)
dispatch on `event_type` after `XREAD`. Omitting it from the schema
would require Game Master to inject the value at publish time
without spec backing.
2. `const` at the schema level lets the contract test assert the
discriminator value, which is the only meaningful check Stage 06
asks for ("`event_type` discriminator values"). Asserting only the
message component name without the on-wire `event_type` would not
protect consumers from a misconfigured publisher.
3. `rtmanager/api/runtime-health-asyncapi.yaml` already uses
`event_type` as a schema-level enum-typed discriminator; treating
`gm:lobby_events` the same way keeps the patterns consistent for a
reader cross-walking the two specs.
Alternatives considered:
- **Leave `event_type` out of the spec and produce it only at the
publish-side adapter** — rejected: hides the discriminator from the
contract test, which then cannot fail when the publisher renames or
drops it.
- **Encode discrimination through AsyncAPI message names alone**
(relying on `header.X-Message-Type` or similar) — rejected: Redis
Streams have no message-headers concept; everything travels in the
payload field set.
## Decision 3 — `additionalProperties: true` on engine pass-through schemas
Three internal REST operations forward engine-owned payloads without
modification:
- `internalExecuteCommands``POST /api/v1/command` on the engine
- `internalPutOrders``PUT /api/v1/order` on the engine
- `internalGetReport``GET /api/v1/report` on the engine
Their request and response bodies use `additionalProperties: true`:
```yaml
ExecuteCommandsRequest:
type: object
additionalProperties: true
required: [commands]
properties:
commands:
type: array
items: { type: object, additionalProperties: true }
```
Game Master does not own the shape of these payloads — `galaxy/game/openapi.yaml`
is the source of truth — and freezing them in the GM contract would
turn every engine-side schema bump into a coordinated GM release. The
same reasoning applies to `EngineVersion.options`, which is a
free-form `jsonb` document Game Master stores verbatim.
To prevent the open-by-default flag from spreading by accident, the
contract test
[`../contract_openapi_test.go`](../contract_openapi_test.go) maintains
two explicit allowlists:
- `gmOwnedClosedSchemas` — every schema for which Game Master owns
the wire shape; the test asserts each one closes with
`additionalProperties: false`.
- `engineOwnedPassthroughSchemas` — the five pass-through schemas
(request and response bodies of the three hot-path operations); the
test asserts each one keeps `additionalProperties: true`.
Adding a new GM schema requires registering it in
`gmOwnedClosedSchemas`; the test fails loudly if it isn't.
Alternatives considered:
- **Close the pass-through schemas with `additionalProperties: false`
and hand-mirror every engine field** — rejected: `galaxy/game` and
`galaxy/gamemaster` would have to release in lockstep; even cosmetic
field renames in the engine would break Edge Gateway routing.
- **Rely on a `// pass-through` comment in the YAML alone** — rejected:
comments do not survive automated reformatters and provide no
test-time signal.
## References
- [`../PLAN.md` Stage 06](../PLAN.md)
- [`../README.md` §Hot Path](../README.md), [`../README.md` §Async Stream Contracts](../README.md)
- [`../api/internal-openapi.yaml`](../api/internal-openapi.yaml)
- [`../api/runtime-events-asyncapi.yaml`](../api/runtime-events-asyncapi.yaml)
- [`../contract_openapi_test.go`](../contract_openapi_test.go)
- [`../contract_asyncapi_test.go`](../contract_asyncapi_test.go)
- [`../../lobby/contract_openapi_test.go`](../../lobby/contract_openapi_test.go) — OpenAPI test pattern reused here.
- [`../../notification/contract_asyncapi_test.go`](../../notification/contract_asyncapi_test.go) — YAML walker pattern reused here.
- [`../../rtmanager/api/runtime-health-asyncapi.yaml`](../../rtmanager/api/runtime-health-asyncapi.yaml) — `event_type` const precedent.