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
+99 -25
View File
@@ -13,15 +13,24 @@ requests to this service's internal REST API.
- opaque regular-user identifiers in `user-*` form
- exact-after-trim login e-mail addresses
- current race name and editable self-service settings
- current entitlement snapshot
- active sanctions and active user-specific limits
- `user_name` — immutable auto-generated unique platform handle in
`player-<suffix>` form
- `display_name` — mutable free-text user label validated by
`pkg/util/string.go:ValidateTypeName`, not required to be unique, empty by
default
- editable self-service settings (`preferred_language`, `time_zone`)
- current entitlement snapshot including `max_registered_race_names`
- active sanctions (including `permanent_block`) and active user-specific
limits (including `max_registered_race_names` overrides)
- current effective `declared_country`
- soft-delete state via `DeleteUser`
`User Service` is not the source of truth for:
- system-administrator identity
- device sessions, challenges, or client public keys
- in-game `race_name` values or their uniqueness — those live in the Game
Lobby Race Name Directory
- declared-country review workflow or history
- edge authentication, request signing, or replay protection
@@ -48,15 +57,18 @@ The internal REST surface is split into five stable groups:
- `GeoIntegration`
- synchronize current effective `declared_country`
- `AdminUsers`
- lookups by `user_id`, exact-after-trim `email`, and exact `race_name`
- lookups by `user_id`, exact-after-trim `email`, exact `user_name`, and
exact or prefix `display_name`
- deterministic filtered listing
- explicit entitlement, sanction, and limit commands
- `DeleteUser` soft-delete command
The public authenticated gateway boundary currently exposes exactly three
self-service message types:
- `user.account.get`
- `user.profile.update`
- `user.profile.update` — payload carries `display_name` only; the prior
`race_name` payload field is removed and rejected if present
- `user.settings.update`
Externally these commands use authenticated gRPC plus FlatBuffers payloads.
@@ -66,25 +78,35 @@ Internally gateway calls:
- `POST /api/v1/internal/users/{user_id}/profile`
- `POST /api/v1/internal/users/{user_id}/settings`
Additional trusted internal operations:
- `POST /api/v1/internal/users/{user_id}/delete` — soft-delete
(`DeleteUser`); intended to be called only by `Admin Service`. Idempotent
per `user_id`; a second call after soft-delete returns
`404 subject_not_found` for external reads but keeps the deleted record
for audit.
Gateway must derive `user_id` from authenticated session context only. The
client payload never carries user identity for this boundary.
## Identity And Lookup Rules
- User IDs are opaque stable identifiers generated by `User Service`.
- New users receive generated default race names in `player-*` form until the
user replaces them.
- User IDs are opaque stable identifiers generated by `User Service` and are
the only identifier permitted as a foreign key from other models.
- Every new user receives an auto-generated `user_name` in `player-<suffix>`
form. The suffix is 8 characters drawn from a confusable-free alphanumeric
alphabet. `user_name` is immutable after creation; collisions are resolved
by retry during create (limit 10 attempts).
- `display_name` starts empty for new accounts. Self-service may change it
via `UpdateMyProfile`; validation delegates to
`pkg/util/string.go:ValidateTypeName`; uniqueness is not enforced.
- E-mail semantics are exact-after-trim.
- The service trims surrounding whitespace.
- The service does not lowercase, canonicalize, or alias-normalize e-mail
values.
- Exact lookup by e-mail uses the trimmed stored value.
- Race-name lookup is exact by stored value.
- Race-name uniqueness is not exact-string-only.
- Stored casing is preserved.
- Uniqueness is enforced by a canonical reservation key.
- The canonical policy is case-insensitive and includes the frozen
anti-fraud confusable-pair rules used by the race-name policy adapter.
- `user_name` lookup is exact by stored value; `display_name` supports exact
and prefix lookups.
## Auth-Facing Contract
@@ -128,8 +150,10 @@ Auth-facing blocking semantics:
Self-service reads and writes operate on one shared account aggregate:
- immutable:
- `user_name`
- profile:
- `race_name`
- `display_name`
- settings:
- `preferred_language`
- `time_zone`
@@ -144,6 +168,7 @@ Self-service writes return the refreshed full account aggregate.
Forbidden self-service mutations:
- e-mail change
- `user_name` change
- direct `declared_country` change
- direct entitlement mutation
- direct sanction mutation
@@ -152,14 +177,17 @@ Forbidden self-service mutations:
Current write rules:
- `UpdateMyProfile`
- changes only `race_name`
- changes only `display_name`
- rejects unsupported or unknown fields
- returns the current aggregate unchanged on no-op rename
- returns the current aggregate unchanged when the incoming value equals
the stored one
- `UpdateMySettings`
- changes only `preferred_language` and `time_zone`
- rejects unsupported or unknown fields
- active `profile_update_block` sanction blocks both profile and settings
writes with `409 conflict`
- active `permanent_block` sanction blocks every self-service read and write
with `409 conflict` and surfaces in admin reads
## Validation Rules
@@ -170,12 +198,25 @@ Current write rules:
- keep the trimmed exact value
- do not lowercase or canonicalize
### Race Name
### user_name
- validate non-empty user-facing name
- preserve accepted casing in storage and reads
- enforce uniqueness through canonical reservation
- reject conflicts as `409 conflict`
- auto-generated server-side in `player-<suffix>` form
- suffix = 8 characters drawn from a confusable-free alphanumeric alphabet
- uniqueness enforced at store-layer; conflicts resolved by retry during
ensure-by-email (limit 10 attempts)
- immutable after creation; any attempt to mutate is a logic error and
returns `500 internal_error`
### display_name
- validated through `pkg/util/string.go:ValidateTypeName`
- empty value is accepted and rendered as no display name in downstream
consumers
- casing and script preserved as submitted
- not required to be unique
Note: in-game `race_name` values are owned by the Game Lobby Race Name
Directory and are not validated, stored, or reserved by `User Service`.
### preferred_language
@@ -218,12 +259,17 @@ Supported sanction codes:
- `private_game_manage_block`
- `game_join_block`
- `profile_update_block`
- `permanent_block` — terminal state; collapses every `can_*` eligibility
marker to `false`; triggers RND cascade release in `Game Lobby` through
`user:lifecycle_events`
Supported user-specific limit codes:
- `max_owned_private_games`
- `max_pending_public_applications`
- `max_active_game_memberships`
- `max_registered_race_names` — overrides the tariff default for the RND
registered-name quota
Rules:
@@ -256,6 +302,14 @@ Current markers:
- `can_join_game`
- `can_update_profile`
Additional materialized fields:
- `max_registered_race_names` — tariff-derived quota for the Game Lobby Race
Name Directory: `free → 1`, `paid_monthly → 2`, `paid_yearly → 6`,
`paid_lifetime → 0` (unlimited marker). A user-specific
`max_registered_race_names` limit override, when active, replaces the
tariff value.
## declared_country Ownership Split
Ownership is intentionally split:
@@ -285,7 +339,8 @@ Lookups:
- by `user_id`
- by exact-after-trim `email`
- by exact `race_name`
- by exact `user_name`
- by exact or prefix `display_name`
Listing rules:
@@ -302,9 +357,12 @@ Listing filters include:
- paid/free state
- paid expiry bounds
- current `declared_country`
- active sanction code
- active limit code
- active sanction code (including `permanent_block`)
- active limit code (including `max_registered_race_names`)
- derived eligibility markers
- `user_name` exact
- `display_name` exact or prefix
- `deleted` flag (soft-deleted accounts excluded by default)
## Domain Events
@@ -322,6 +380,20 @@ Frozen event types:
The current effective declared-country sync remains externally observable as
`user.declared_country.changed`.
### User lifecycle stream
Separately from the shared domain-events stream, `User Service` publishes to
a dedicated Redis stream `user:lifecycle_events` consumed by `Game Lobby`
for Race Name Directory cascade release. Event types:
- `user.lifecycle.permanent_blocked` — emitted when
`SanctionCodePermanentBlock` becomes active on a user
- `user.lifecycle.deleted` — emitted when `DeleteUser` succeeds
Event envelopes carry `user_id`, `occurred_at_ms`, mutation source, optional
`reason_code`, and actor metadata. Delivery is at-least-once; consumers
must be idempotent.
Event rules:
- events are post-commit only
@@ -377,4 +449,6 @@ gateway `UNAVAILABLE`, not business results.
- [Internal REST contract](openapi.yaml)
- [Service docs index](docs/README.md)
- [Stage 21 decisions](docs/stage21-user-name-display-name.md)
- [Stage 22 decisions](docs/stage22-permanent-block-delete-user.md)
- [System architecture](../ARCHITECTURE.md)