feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
+151 -9
View File
@@ -71,6 +71,11 @@ For downstream business services, the current default trusted transport is
strict REST/JSON. Gateway may therefore authenticate and verify one external
FlatBuffers command, then transcode it to one trusted downstream REST call.
When forwarding an authenticated command to a downstream service, `Edge Gateway`
enriches the REST call with the `X-User-ID` header carrying the verified platform
user identifier. Downstream services derive the acting user identity exclusively
from this header and must never accept identity claims from request body fields.
The public auth contract is:
* `send-email-code(email) -> challenge_id`
@@ -142,6 +147,8 @@ flowchart LR
Lobby --> Runtime
Lobby --> Redis
User --> Lobby
GM --> Lobby
GM --> Runtime
GM --> Redis
@@ -242,10 +249,20 @@ business data.
It is the source of truth for:
* `user_id` of regular platform users;
* regular-user profile fields and editable user settings;
* current tariff/entitlement state;
* user-specific limits and platform sanctions;
* latest effective `declared_country`.
* `user_name` — immutable auto-generated unique platform handle in
`player-<suffix>` form; never used as foreign key in other models;
* `display_name` — mutable free-text user-editable label validated through
`pkg/util/string.go:ValidateTypeName`; not required to be unique; default
empty for new accounts;
* editable user settings (`preferred_language`, `time_zone`);
* current tariff/entitlement state including `max_registered_race_names`;
* user-specific limits and platform sanctions (including
`permanent_block` and `max_registered_race_names` override limits);
* latest effective `declared_country`;
* soft-delete state via `DeleteUser`.
`User Service` does not own in-game `race_name` values; those live in
`Game Lobby` Race Name Directory.
System-administrator identity remains outside this service and belongs to the
later `Admin Service`. Trusted administrative reads and mutations against
@@ -270,6 +287,16 @@ Architectural rules fixed for this service:
of scope.
* `User Service` stores only the current effective `declared_country`; review
workflow and history belong to `Geo Profile Service`.
* `User Service` does not own in-game `race_name` values. All in-game name
state (registered, reserved, pending registration) lives in the Game Lobby
Race Name Directory. The only identity strings owned by `User Service` are
`user_name` (immutable) and `display_name` (mutable, non-unique).
* `permanent_block` is a dedicated sanction code that collapses every
`can_*` eligibility marker to false and triggers RND cascade release via
the `user:lifecycle_events` stream.
* `DeleteUser` is a trusted internal endpoint that soft-deletes the account,
rejects all subsequent operations with `subject_not_found`, and triggers
the same RND cascade release.
* During the current auth-registration rollout, `Auth / Session Service`
passes a preferred-language candidate derived from public
`Accept-Language`, falling back to `en` when no supported value is
@@ -349,7 +376,7 @@ Its job is to:
System administrators can view and operate on all games, including private ones.
## 7. Game Lobby Service
## 7. [Game Lobby Service](lobby/README.md)
`Game Lobby` owns platform-level metadata and lifecycle of game sessions as platform entities.
@@ -379,6 +406,11 @@ It also stores a denormalized runtime snapshot for convenience, at least:
* `runtime_status`;
* `engine_health_summary`.
Additionally, `Game Lobby` aggregates per-member game statistics from
`player_turn_stats` carried on each `runtime_snapshot_update` event: current
and running-max of `planets`, `population`, and `ships_built`. The aggregate
is retained from game start until capability evaluation at `game_finished`.
This prevents user-facing list/read flows from fan-out requests into `Game Master`.
### Lobby status model
@@ -387,9 +419,9 @@ Minimum platform-level status set:
* `draft`
* `enrollment_open`
* `enrollment_closed`
* `ready_to_start`
* `starting`
* `start_failed`
* `running`
* `paused`
* `finished`
@@ -397,6 +429,32 @@ Minimum platform-level status set:
`Lobby.paused` is a business/platform pause, distinct from engine/runtime failure states.
`start_failed` indicates that the runtime container could not be started or that
metadata persistence failed after a successful container start.
From `start_failed` an admin or owner may retry (→ `ready_to_start`) or cancel (→ `cancelled`).
### Enrollment rules
Each game stores three enrollment configuration fields set at creation:
* `min_players` — minimum approved participants required before the game may start.
* `max_players` — target roster size that activates the gap admission window.
* `start_gap_hours` — hours to keep enrollment open after `max_players` is reached.
* `start_gap_players` — additional players admitted during the gap window.
* `enrollment_ends_at` — UTC Unix timestamp at which enrollment closes automatically.
Transition from `enrollment_open` to `ready_to_start` occurs via one of three paths:
1. **Manual**: an admin (public game) or owner (private game) issues a close-enrollment
command when `approved_count >= min_players`.
2. **Deadline**: `enrollment_ends_at` is reached and `approved_count >= min_players`.
3. **Gap exhaustion**: `approved_count >= max_players` activates a gap window of
`start_gap_hours` during which up to `start_gap_players` additional participants
may join; the transition fires when the gap window expires or
`approved_count >= max_players + start_gap_players`.
All pending invites transition to `expired` when the game moves to `ready_to_start`.
### Membership rules
* `User Service` owns users of the platform as identities.
@@ -417,11 +475,65 @@ Private games:
* can be created only by eligible paid users;
* visible only to their owner and to invited users whose invitation is bound
to a concrete `user_id` and later accepted;
* joining uses a user-bound invite plus owner approval;
* joining uses a user-bound invite; accepting the invite immediately creates active
membership without a separate owner-approval step;
* invite lifecycle belongs entirely to `Game Lobby`.
Private-party owners get a limited owner-admin capability set, not full system admin power.
### Race Name Directory
`Race Name Directory` (RND) is the platform source of truth for in-game player
names (`race_name`). It is owned by `Game Lobby` in v1 and is scheduled to move
to a dedicated `Race Name Service` later without changing the domain or
service-layer logic.
RND owns three levels of state per name:
- **registered** — platform-unique permanent names owned by one regular user.
A registered name cannot be transferred, released, or renamed; the only path
back to availability is `permanent_block` or `DeleteUser` on the owning
account. The number of registered names a user can hold is bounded by the
current tariff (`max_registered_race_names` in the `User Service` eligibility
snapshot): `free=1`, `paid_monthly=2`, `paid_yearly=6`,
`paid_lifetime=unlimited`. Tariff downgrade never revokes existing
registrations; it only constrains new ones.
- **reservation** — per-game binding created when a participant joins a game
through application approval or invite redeem. The reservation key is
`(game_id, canonical_key)`. One user may hold the same name simultaneously
across multiple active games. A reservation survives until the game
finishes, then either becomes a `pending_registration` (see below) or is
released.
- **pending_registration** — a reservation that survived a capable finish and
is now waiting up to 30 days for the owner to upgrade it into a registered
name via `lobby.race_name.register`. Expiration releases the binding.
**Canonical key** — RND uses a canonical key (lowercase + frozen
confusable-pair policy) to enforce uniqueness. A name is considered taken for
another user when any `registered`, active `reservation`, or
`pending_registration` with a different `user_id` exists under the same
canonical key. The confusable-pair policy lives in Lobby
(`lobby/internal/domain/racename/policy.go`).
**Capability gating** — at `game_finished` `Game Lobby` evaluates per-member
capability: `capable = max_planets > initial_planets AND max_population >
initial_population`, computed from the `player_turn_stats` stream published by
`Game Master`. Capable reservations transition to `pending_registration` with
`eligible_until = finished_at + 30 days`; non-capable reservations are
released immediately.
**Registration** — a user initiates registration via `lobby.race_name.register`
inside the 30-day window. Registration succeeds only when the user is still
eligible (no `permanent_block`, tariff slot available) and the pending entry
is still within its window. Expired pending entries are released by a
background worker.
**Cascade release**`User Service` publishes
`user.lifecycle.permanent_blocked` and `user.lifecycle.deleted` events to
`user:lifecycle_events`. `Game Lobby` consumes this stream and calls
`RND.ReleaseAllByUser(user_id)` atomically with membership/application/invite
cancellations for the affected user.
## 8. Game Master
`Game Master` owns runtime and operational metadata of already running games.
@@ -486,6 +598,24 @@ It triggers turn generation according to the game schedule.
If a manual “force next turn” is executed, the next scheduled turn slot must be skipped so that players still get at least one full normal schedule interval before the following generated turn.
### Runtime snapshot publishing
`Game Master` publishes runtime updates to the `gm:lobby_events` Redis Stream
consumed by `Game Lobby`. Events include:
* `runtime_snapshot_update` — carries the current `current_turn`,
`runtime_status`, `engine_health_summary`, and a `player_turn_stats` array
with one entry per active member (`user_id`, `planets`, `population`,
`ships_built`). `Game Lobby` maintains a per-game per-user stats aggregate
from these events for capability evaluation at game finish.
* `game_finished` — carries the final snapshot values and triggers the
platform status transition plus Race Name Directory capability evaluation
inside `Game Lobby`.
`Game Master` does not retain the aggregate; it only publishes the per-turn
observation. `Game Lobby` is responsible for holding initial values and
running maxima across the lifetime of the game.
### Runtime/engine finish flow
When the engine determines that a game is finished:
@@ -595,6 +725,11 @@ When introduced, it will:
`User Service` remains the source of truth for current entitlement used by the rest of the platform.
Billing-driven tariff changes alter only the headroom for *new* registered
race names: tariff downgrade never revokes already registered names. The
affected ceiling is materialized as `max_registered_race_names` in the
eligibility snapshot consumed by `Game Lobby`.
## Data Ownership Summary
```mermaid
@@ -608,9 +743,9 @@ flowchart TD
N["Notification Service"]
M["Mail Service"]
U -->|"regular users, profile/settings, tariffs, limits, sanctions, current declared_country"| X1["Platform user identity"]
U -->|"regular users, user_name/display_name, settings, tariffs, limits, sanctions, declared_country, soft-delete"| X1["Platform user identity"]
A -->|"challenges, device sessions, revoke/block state"| X2["Auth/session state"]
L -->|"game metadata, invites, applications, membership, roster"| X3["Platform game records"]
L -->|"game metadata, invites, applications, membership, roster, race names (registered/reservations/pending)"| X3["Platform game records"]
G -->|"runtime state, current turn, engine health, engine mapping, engine version registry"| X4["Running-game state"]
R -->|"container execution and technical runtime control"| X5["Container runtime"]
P -->|"observed country, usual_connection_country, review state, declared_country history"| X6["Geo state"]
@@ -646,6 +781,13 @@ The platform uses one simple rule:
* `Lobby -> Runtime Manager` runtime jobs;
* `Game Master -> Runtime Manager` runtime jobs;
* all event-bus propagation;
* `Game Master -> Game Lobby` runtime snapshot updates (including
`player_turn_stats` for capability aggregation) and game-finish events
through a dedicated Redis Stream consumed by `Game Lobby`;
* `User Service -> Game Lobby` user lifecycle events
(`user.lifecycle.permanent_blocked`, `user.lifecycle.deleted`) through the
`user:lifecycle_events` Redis Stream, consumed by `Game Lobby` to cascade
RND release and membership/application/invite cancellation;
* `Game Master -> Notification Service` notification intents through
`notification:intents`;
* `Game Lobby -> Notification Service` notification intents through