feat: gamemaster
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user