From 48b0056b49b660ba6c32780fd069821f06cca5eb Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 25 Apr 2026 23:20:55 +0200 Subject: [PATCH] feat: game lobby service --- .claude/settings.json | 13 + ARCHITECTURE.md | 160 +- CLAUDE.md | 63 + gateway/README.md | 3 + .../downstream/userservice/client_test.go | 7 +- go.work | 1 + go.work.sum | 1 + integration/README.md | 27 +- .../authsessionuser/authsession_user_test.go | 3 +- integration/authsessionuser/harness_test.go | 3 +- integration/gatewayuser/gateway_user_test.go | 9 +- .../internal/contracts/userv1/contract.go | 4 +- .../lobby_notification_test.go | 635 ++++++ .../race_name_intents_test.go | 198 ++ integration/lobbyuser/lobby_user_test.go | 325 +++ lobby/PLAN.md | 1443 +++++++++++++ lobby/README.md | 1269 +++++++++++ lobby/api/internal-openapi.yaml | 946 +++++++++ lobby/api/public-openapi.yaml | 1865 +++++++++++++++++ lobby/cmd/lobby/main.go | 46 + lobby/contract_openapi_test.go | 634 ++++++ lobby/docs/README.md | 18 + lobby/docs/examples.md | 195 ++ lobby/docs/flows.md | 196 ++ lobby/docs/runbook.md | 220 ++ lobby/docs/runtime.md | 163 ++ lobby/go.mod | 104 + lobby/go.sum | 229 ++ .../adapters/applicationstub/store.go | 200 ++ .../adapters/evaluationguardstub/store.go | 69 + lobby/internal/adapters/gamestub/store.go | 270 +++ .../internal/adapters/gamestub/store_test.go | 276 +++ .../adapters/gameturnstatsstub/store.go | 185 ++ .../adapters/gapactivationstub/store.go | 100 + lobby/internal/adapters/gmclient/client.go | 174 ++ .../internal/adapters/gmclient/client_test.go | 177 ++ .../internal/adapters/gmclientstub/client.go | 89 + lobby/internal/adapters/idgen/generator.go | 144 ++ .../internal/adapters/idgen/generator_test.go | 230 ++ .../adapters/intentpubstub/publisher.go | 79 + lobby/internal/adapters/invitestub/store.go | 209 ++ .../internal/adapters/membershipstub/store.go | 201 ++ .../adapters/metricsintentpub/publisher.go | 44 + .../metricsintentpub/publisher_test.go | 110 + .../adapters/metricsracenamedir/directory.go | 174 ++ .../metricsracenamedir/directory_test.go | 142 ++ .../adapters/racenameintents/publisher.go | 135 ++ .../racenameintents/publisher_test.go | 105 + .../adapters/racenamestub/directory.go | 598 ++++++ .../adapters/racenamestub/directory_test.go | 78 + .../adapters/redisstate/applicationstore.go | 277 +++ .../redisstate/applicationstore_test.go | 360 ++++ lobby/internal/adapters/redisstate/codecs.go | 172 ++ .../adapters/redisstate/codecs_application.go | 73 + .../redisstate/codecs_gameturnstats.go | 87 + .../adapters/redisstate/codecs_invite.go | 77 + .../adapters/redisstate/codecs_membership.go | 75 + .../adapters/redisstate/codecs_racename.go | 111 + lobby/internal/adapters/redisstate/doc.go | 10 + .../redisstate/evaluationguardstore.go | 95 + .../redisstate/evaluationguardstore_test.go | 77 + .../internal/adapters/redisstate/gamestore.go | 454 ++++ .../adapters/redisstate/gamestore_test.go | 557 +++++ .../adapters/redisstate/gameturnstatsstore.go | 294 +++ .../redisstate/gameturnstatsstore_test.go | 184 ++ .../adapters/redisstate/gapactivationstore.go | 108 + .../redisstate/gapactivationstore_test.go | 116 + .../adapters/redisstate/invitestore.go | 284 +++ .../adapters/redisstate/invitestore_test.go | 363 ++++ .../internal/adapters/redisstate/keyspace.go | 227 ++ .../adapters/redisstate/membershipstore.go | 317 +++ .../redisstate/membershipstore_test.go | 299 +++ .../adapters/redisstate/racenamedir.go | 1101 ++++++++++ .../adapters/redisstate/racenamedir_lua.go | 52 + .../adapters/redisstate/racenamedir_test.go | 244 +++ .../adapters/redisstate/streamlagprobe.go | 93 + .../redisstate/streamlagprobe_test.go | 102 + .../adapters/redisstate/streamoffsetstore.go | 78 + .../redisstate/streamoffsetstore_test.go | 65 + .../adapters/runtimemanager/publisher.go | 116 + .../adapters/runtimemanager/publisher_test.go | 110 + .../adapters/runtimemanagerstub/publisher.go | 92 + .../adapters/streamlagprobestub/probe.go | 61 + .../adapters/streamoffsetstub/store.go | 56 + .../adapters/userlifecycle/consumer.go | 287 +++ .../adapters/userlifecycle/consumer_test.go | 323 +++ .../adapters/userlifecyclestub/consumer.go | 79 + lobby/internal/adapters/userservice/client.go | 183 ++ .../adapters/userservice/client_test.go | 167 ++ .../adapters/userservicestub/service.go | 107 + lobby/internal/api/httpcommon/requestid.go | 83 + .../internal/api/httpcommon/requestid_test.go | 88 + .../internal/api/internalhttp/applications.go | 164 ++ lobby/internal/api/internalhttp/games.go | 453 ++++ lobby/internal/api/internalhttp/games_test.go | 317 +++ .../internal/api/internalhttp/memberships.go | 157 ++ .../internal/api/internalhttp/pause_resume.go | 80 + .../api/internalhttp/ready_to_start.go | 52 + lobby/internal/api/internalhttp/server.go | 367 ++++ .../internal/api/internalhttp/server_test.go | 155 ++ lobby/internal/api/internalhttp/start.go | 80 + lobby/internal/api/publichttp/applications.go | 222 ++ lobby/internal/api/publichttp/games.go | 521 +++++ lobby/internal/api/publichttp/games_test.go | 358 ++++ lobby/internal/api/publichttp/invites.go | 243 +++ lobby/internal/api/publichttp/memberships.go | 165 ++ lobby/internal/api/publichttp/mylists.go | 214 ++ lobby/internal/api/publichttp/pause_resume.go | 87 + lobby/internal/api/publichttp/racenames.go | 189 ++ .../internal/api/publichttp/racenames_test.go | 374 ++++ .../internal/api/publichttp/ready_to_start.go | 54 + lobby/internal/api/publichttp/server.go | 409 ++++ lobby/internal/api/publichttp/server_test.go | 155 ++ lobby/internal/api/publichttp/start.go | 87 + lobby/internal/app/app.go | 169 ++ lobby/internal/app/app_test.go | 173 ++ lobby/internal/app/bootstrap.go | 71 + lobby/internal/app/bootstrap_test.go | 72 + lobby/internal/app/runtime.go | 280 +++ lobby/internal/app/runtime_smoke_test.go | 159 ++ lobby/internal/app/runtime_test.go | 151 ++ lobby/internal/app/wiring.go | 785 +++++++ lobby/internal/config/config.go | 525 +++++ lobby/internal/config/config_test.go | 333 +++ lobby/internal/config/env.go | 213 ++ lobby/internal/config/validation.go | 88 + lobby/internal/domain/application/errors.go | 42 + lobby/internal/domain/application/model.go | 147 ++ lobby/internal/domain/application/status.go | 79 + lobby/internal/domain/common/ids.go | 123 ++ lobby/internal/domain/common/types.go | 8 + lobby/internal/domain/game/errors.go | 44 + lobby/internal/domain/game/model.go | 422 ++++ lobby/internal/domain/game/model_test.go | 234 +++ lobby/internal/domain/game/status.go | 251 +++ lobby/internal/domain/game/status_test.go | 177 ++ lobby/internal/domain/invite/errors.go | 41 + lobby/internal/domain/invite/model.go | 188 ++ lobby/internal/domain/invite/status.go | 88 + lobby/internal/domain/membership/errors.go | 42 + lobby/internal/domain/membership/model.go | 167 ++ lobby/internal/domain/membership/status.go | 80 + lobby/internal/domain/racename/policy.go | 102 + lobby/internal/domain/racename/policy_test.go | 188 ++ lobby/internal/domain/racename/types.go | 35 + lobby/internal/logging/context.go | 43 + lobby/internal/logging/context_test.go | 63 + lobby/internal/logging/logger.go | 45 + lobby/internal/ports/applicationstore.go | 90 + lobby/internal/ports/evaluationguardstore.go | 34 + lobby/internal/ports/gamestore.go | 169 ++ lobby/internal/ports/gameturnstatsstore.go | 138 ++ lobby/internal/ports/gapactivationstore.go | 26 + lobby/internal/ports/gmclient.go | 84 + lobby/internal/ports/idgenerator.go | 29 + lobby/internal/ports/intentpublisher.go | 22 + lobby/internal/ports/invitestore.go | 106 + lobby/internal/ports/membershipstore.go | 89 + lobby/internal/ports/racenamedir.go | 238 +++ lobby/internal/ports/racenamedirtest/suite.go | 773 +++++++ lobby/internal/ports/runtimemanager.go | 25 + lobby/internal/ports/streamlagprobe.go | 20 + lobby/internal/ports/streamoffsetstore.go | 20 + lobby/internal/ports/userlifecyclestream.go | 121 ++ lobby/internal/ports/userservice.go | 71 + .../service/approveapplication/service.go | 307 +++ .../approveapplication/service_test.go | 396 ++++ lobby/internal/service/blockmember/service.go | 204 ++ .../service/blockmember/service_test.go | 368 ++++ lobby/internal/service/cancelgame/service.go | 170 ++ .../service/cancelgame/service_test.go | 267 +++ .../service/capabilityevaluation/service.go | 410 ++++ .../capabilityevaluation/service_test.go | 308 +++ lobby/internal/service/creategame/service.go | 200 ++ .../service/creategame/service_test.go | 324 +++ .../internal/service/createinvite/service.go | 310 +++ .../service/createinvite/service_test.go | 362 ++++ .../internal/service/declineinvite/service.go | 154 ++ .../service/declineinvite/service_test.go | 160 ++ lobby/internal/service/getgame/service.go | 196 ++ .../internal/service/getgame/service_test.go | 410 ++++ lobby/internal/service/listgames/service.go | 270 +++ .../service/listgames/service_test.go | 302 +++ .../service/listmemberships/service.go | 167 ++ .../service/listmemberships/service_test.go | 243 +++ .../service/listmyapplications/service.go | 183 ++ .../listmyapplications/service_test.go | 193 ++ lobby/internal/service/listmygames/service.go | 175 ++ .../service/listmygames/service_test.go | 201 ++ .../internal/service/listmyinvites/service.go | 214 ++ .../service/listmyinvites/service_test.go | 236 +++ .../service/listmyracenames/service.go | 250 +++ .../service/listmyracenames/service_test.go | 302 +++ .../service/manualreadytostart/service.go | 193 ++ .../manualreadytostart/service_test.go | 250 +++ .../service/openenrollment/service.go | 151 ++ .../service/openenrollment/service_test.go | 229 ++ lobby/internal/service/pausegame/service.go | 156 ++ .../service/pausegame/service_test.go | 235 +++ .../internal/service/redeeminvite/service.go | 357 ++++ .../service/redeeminvite/service_test.go | 471 +++++ .../service/registerracename/service.go | 283 +++ .../service/registerracename/service_test.go | 409 ++++ .../service/rejectapplication/service.go | 216 ++ .../service/rejectapplication/service_test.go | 225 ++ .../internal/service/removemember/service.go | 259 +++ .../service/removemember/service_test.go | 425 ++++ lobby/internal/service/resumegame/service.go | 175 ++ .../service/resumegame/service_test.go | 284 +++ .../service/retrystartgame/service.go | 156 ++ .../service/retrystartgame/service_test.go | 132 ++ .../internal/service/revokeinvite/service.go | 167 ++ .../service/revokeinvite/service_test.go | 212 ++ .../service/shared/closeenrollment.go | 198 ++ .../service/shared/closeenrollment_test.go | 249 +++ lobby/internal/service/shared/page.go | 99 + lobby/internal/service/shared/page_test.go | 125 ++ lobby/internal/service/shared/roster.go | 33 + lobby/internal/service/shared/shared.go | 118 ++ lobby/internal/service/shared/shared_test.go | 85 + lobby/internal/service/startgame/service.go | 187 ++ .../service/startgame/service_test.go | 209 ++ .../service/submitapplication/service.go | 279 +++ .../service/submitapplication/service_test.go | 335 +++ lobby/internal/service/updategame/service.go | 235 +++ .../service/updategame/service_test.go | 307 +++ lobby/internal/telemetry/runtime.go | 781 +++++++ lobby/internal/telemetry/runtime_test.go | 264 +++ .../worker/enrollmentautomation/worker.go | 263 +++ .../enrollmentautomation/worker_test.go | 282 +++ lobby/internal/worker/gmevents/consumer.go | 579 +++++ .../internal/worker/gmevents/consumer_test.go | 470 +++++ .../worker/pendingregistration/worker.go | 162 ++ .../worker/pendingregistration/worker_test.go | 253 +++ .../worker/runtimejobresult/consumer.go | 564 +++++ .../worker/runtimejobresult/consumer_test.go | 372 ++++ lobby/internal/worker/userlifecycle/worker.go | 478 +++++ .../worker/userlifecycle/worker_test.go | 416 ++++ .../lobby.membership.blocked/en/subject.tmpl | 1 + .../lobby.membership.blocked/en/text.tmpl | 3 + .../en/subject.tmpl | 1 + .../lobby.race_name.registered/en/text.tmpl | 1 + .../en/subject.tmpl | 1 + .../en/text.tmpl | 4 + .../en/subject.tmpl | 1 + .../en/text.tmpl | 4 + notification/README.md | 26 +- notification/api/intents-asyncapi.yaml | 143 ++ notification/contract_asyncapi_test.go | 36 +- .../internal/api/intentstream/contract.go | 21 +- .../internal/service/publishpush/encoder.go | 59 + .../service/publishpush/encoder_test.go | 24 + notification/mail_template_contract_test.go | 6 +- .../producer_integration_contract_test.go | 30 + notification/push_payload_contract_test.go | 28 +- pkg/model/user/user.go | 13 +- pkg/notificationintent/intent.go | 51 +- pkg/notificationintent/intent_test.go | 62 + pkg/notificationintent/payloads.go | 66 + pkg/schema/fbs/notification.fbs | 16 + .../LobbyMembershipBlockedEvent.go | 82 + .../LobbyRaceNameRegisteredEvent.go | 60 + .../LobbyRaceNameRegistrationEligibleEvent.go | 86 + pkg/schema/fbs/user.fbs | 5 +- pkg/schema/fbs/user/AccountView.go | 77 +- pkg/schema/fbs/user/UpdateMyProfileRequest.go | 6 +- pkg/transcoder/notification.go | 179 ++ pkg/transcoder/notification_test.go | 34 + pkg/transcoder/user.go | 20 +- pkg/transcoder/user_test.go | 13 +- user/README.md | 124 +- user/docs/examples.md | 11 +- user/docs/flows.md | 11 +- user/docs/runbook.md | 9 +- user/docs/stage21-user-name-display-name.md | 111 + .../stage22-permanent-block-delete-user.md | 141 ++ user/internal/adapters/local/id_generator.go | 57 +- .../adapters/local/race_name_policy.go | 65 - .../adapters/local/race_name_policy_test.go | 72 - .../adapters/redis/domainevents/publisher.go | 5 +- .../redis/domainevents/publisher_test.go | 25 +- .../redis/lifecycleevents/publisher.go | 192 ++ .../redis/lifecycleevents/publisher_test.go | 154 ++ .../adapters/redis/userstore/admin_index.go | 12 + .../redis/userstore/admin_index_test.go | 58 + .../redis/userstore/admin_list_test.go | 12 +- .../adapters/redis/userstore/store.go | 348 +-- .../adapters/redis/userstore/store_test.go | 167 +- user/internal/adapters/redisstate/keyspace.go | 13 +- .../adapters/redisstate/keyspace_test.go | 4 +- .../api/internalhttp/admin_handler.go | 15 +- .../api/internalhttp/admin_handler_test.go | 28 +- user/internal/api/internalhttp/handler.go | 57 +- .../internal/api/internalhttp/handler_test.go | 76 +- user/internal/api/internalhttp/server.go | 28 +- user/internal/app/runtime.go | 45 +- user/internal/config/config.go | 56 +- user/internal/domain/account/model.go | 99 +- user/internal/domain/account/model_test.go | 133 +- user/internal/domain/common/types.go | 65 +- user/internal/domain/common/types_test.go | 44 +- user/internal/domain/policy/model.go | 20 +- user/internal/domain/policy/model_test.go | 44 + user/internal/ports/account_store.go | 89 +- user/internal/ports/auth_directory_store.go | 12 - .../internal/ports/domain_event_publishers.go | 16 +- user/internal/ports/errors.go | 8 +- user/internal/ports/id_generator.go | 9 +- user/internal/ports/race_name_policy.go | 14 - .../ports/user_lifecycle_publisher.go | 99 + user/internal/ports/user_list_store.go | 49 + .../service/accountdeletion/service.go | 243 +++ .../service/accountdeletion/service_test.go | 229 ++ user/internal/service/accountview/service.go | 15 +- user/internal/service/adminusers/service.go | 122 +- .../service/adminusers/service_test.go | 49 +- .../internal/service/authdirectory/service.go | 34 +- .../service/authdirectory/service_test.go | 59 +- .../service/entitlementsvc/service_test.go | 7 +- user/internal/service/geosync/service.go | 3 + user/internal/service/geosync/service_test.go | 12 +- .../service/lobbyeligibility/service.go | 78 +- .../service/lobbyeligibility/service_test.go | 85 +- .../service/policysvc/observability_test.go | 124 ++ user/internal/service/policysvc/service.go | 62 +- .../service/policysvc/service_test.go | 7 +- .../service/selfservice/observability_test.go | 21 +- user/internal/service/selfservice/service.go | 64 +- .../service/selfservice/service_test.go | 136 +- user/internal/service/shared/normalize.go | 26 +- user/internal/service/shared/race_name.go | 49 - user/internal/telemetry/runtime.go | 39 +- user/internal/telemetry/runtime_test.go | 4 +- user/openapi.yaml | 167 +- user/openapi_contract_test.go | 52 +- user/runtime_contract_test.go | 180 +- 336 files changed, 57074 insertions(+), 1418 deletions(-) create mode 100644 .claude/settings.json create mode 100644 CLAUDE.md create mode 100644 integration/lobbynotification/lobby_notification_test.go create mode 100644 integration/lobbynotification/race_name_intents_test.go create mode 100644 integration/lobbyuser/lobby_user_test.go create mode 100644 lobby/PLAN.md create mode 100644 lobby/README.md create mode 100644 lobby/api/internal-openapi.yaml create mode 100644 lobby/api/public-openapi.yaml create mode 100644 lobby/cmd/lobby/main.go create mode 100644 lobby/contract_openapi_test.go create mode 100644 lobby/docs/README.md create mode 100644 lobby/docs/examples.md create mode 100644 lobby/docs/flows.md create mode 100644 lobby/docs/runbook.md create mode 100644 lobby/docs/runtime.md create mode 100644 lobby/go.mod create mode 100644 lobby/go.sum create mode 100644 lobby/internal/adapters/applicationstub/store.go create mode 100644 lobby/internal/adapters/evaluationguardstub/store.go create mode 100644 lobby/internal/adapters/gamestub/store.go create mode 100644 lobby/internal/adapters/gamestub/store_test.go create mode 100644 lobby/internal/adapters/gameturnstatsstub/store.go create mode 100644 lobby/internal/adapters/gapactivationstub/store.go create mode 100644 lobby/internal/adapters/gmclient/client.go create mode 100644 lobby/internal/adapters/gmclient/client_test.go create mode 100644 lobby/internal/adapters/gmclientstub/client.go create mode 100644 lobby/internal/adapters/idgen/generator.go create mode 100644 lobby/internal/adapters/idgen/generator_test.go create mode 100644 lobby/internal/adapters/intentpubstub/publisher.go create mode 100644 lobby/internal/adapters/invitestub/store.go create mode 100644 lobby/internal/adapters/membershipstub/store.go create mode 100644 lobby/internal/adapters/metricsintentpub/publisher.go create mode 100644 lobby/internal/adapters/metricsintentpub/publisher_test.go create mode 100644 lobby/internal/adapters/metricsracenamedir/directory.go create mode 100644 lobby/internal/adapters/metricsracenamedir/directory_test.go create mode 100644 lobby/internal/adapters/racenameintents/publisher.go create mode 100644 lobby/internal/adapters/racenameintents/publisher_test.go create mode 100644 lobby/internal/adapters/racenamestub/directory.go create mode 100644 lobby/internal/adapters/racenamestub/directory_test.go create mode 100644 lobby/internal/adapters/redisstate/applicationstore.go create mode 100644 lobby/internal/adapters/redisstate/applicationstore_test.go create mode 100644 lobby/internal/adapters/redisstate/codecs.go create mode 100644 lobby/internal/adapters/redisstate/codecs_application.go create mode 100644 lobby/internal/adapters/redisstate/codecs_gameturnstats.go create mode 100644 lobby/internal/adapters/redisstate/codecs_invite.go create mode 100644 lobby/internal/adapters/redisstate/codecs_membership.go create mode 100644 lobby/internal/adapters/redisstate/codecs_racename.go create mode 100644 lobby/internal/adapters/redisstate/doc.go create mode 100644 lobby/internal/adapters/redisstate/evaluationguardstore.go create mode 100644 lobby/internal/adapters/redisstate/evaluationguardstore_test.go create mode 100644 lobby/internal/adapters/redisstate/gamestore.go create mode 100644 lobby/internal/adapters/redisstate/gamestore_test.go create mode 100644 lobby/internal/adapters/redisstate/gameturnstatsstore.go create mode 100644 lobby/internal/adapters/redisstate/gameturnstatsstore_test.go create mode 100644 lobby/internal/adapters/redisstate/gapactivationstore.go create mode 100644 lobby/internal/adapters/redisstate/gapactivationstore_test.go create mode 100644 lobby/internal/adapters/redisstate/invitestore.go create mode 100644 lobby/internal/adapters/redisstate/invitestore_test.go create mode 100644 lobby/internal/adapters/redisstate/keyspace.go create mode 100644 lobby/internal/adapters/redisstate/membershipstore.go create mode 100644 lobby/internal/adapters/redisstate/membershipstore_test.go create mode 100644 lobby/internal/adapters/redisstate/racenamedir.go create mode 100644 lobby/internal/adapters/redisstate/racenamedir_lua.go create mode 100644 lobby/internal/adapters/redisstate/racenamedir_test.go create mode 100644 lobby/internal/adapters/redisstate/streamlagprobe.go create mode 100644 lobby/internal/adapters/redisstate/streamlagprobe_test.go create mode 100644 lobby/internal/adapters/redisstate/streamoffsetstore.go create mode 100644 lobby/internal/adapters/redisstate/streamoffsetstore_test.go create mode 100644 lobby/internal/adapters/runtimemanager/publisher.go create mode 100644 lobby/internal/adapters/runtimemanager/publisher_test.go create mode 100644 lobby/internal/adapters/runtimemanagerstub/publisher.go create mode 100644 lobby/internal/adapters/streamlagprobestub/probe.go create mode 100644 lobby/internal/adapters/streamoffsetstub/store.go create mode 100644 lobby/internal/adapters/userlifecycle/consumer.go create mode 100644 lobby/internal/adapters/userlifecycle/consumer_test.go create mode 100644 lobby/internal/adapters/userlifecyclestub/consumer.go create mode 100644 lobby/internal/adapters/userservice/client.go create mode 100644 lobby/internal/adapters/userservice/client_test.go create mode 100644 lobby/internal/adapters/userservicestub/service.go create mode 100644 lobby/internal/api/httpcommon/requestid.go create mode 100644 lobby/internal/api/httpcommon/requestid_test.go create mode 100644 lobby/internal/api/internalhttp/applications.go create mode 100644 lobby/internal/api/internalhttp/games.go create mode 100644 lobby/internal/api/internalhttp/games_test.go create mode 100644 lobby/internal/api/internalhttp/memberships.go create mode 100644 lobby/internal/api/internalhttp/pause_resume.go create mode 100644 lobby/internal/api/internalhttp/ready_to_start.go create mode 100644 lobby/internal/api/internalhttp/server.go create mode 100644 lobby/internal/api/internalhttp/server_test.go create mode 100644 lobby/internal/api/internalhttp/start.go create mode 100644 lobby/internal/api/publichttp/applications.go create mode 100644 lobby/internal/api/publichttp/games.go create mode 100644 lobby/internal/api/publichttp/games_test.go create mode 100644 lobby/internal/api/publichttp/invites.go create mode 100644 lobby/internal/api/publichttp/memberships.go create mode 100644 lobby/internal/api/publichttp/mylists.go create mode 100644 lobby/internal/api/publichttp/pause_resume.go create mode 100644 lobby/internal/api/publichttp/racenames.go create mode 100644 lobby/internal/api/publichttp/racenames_test.go create mode 100644 lobby/internal/api/publichttp/ready_to_start.go create mode 100644 lobby/internal/api/publichttp/server.go create mode 100644 lobby/internal/api/publichttp/server_test.go create mode 100644 lobby/internal/api/publichttp/start.go create mode 100644 lobby/internal/app/app.go create mode 100644 lobby/internal/app/app_test.go create mode 100644 lobby/internal/app/bootstrap.go create mode 100644 lobby/internal/app/bootstrap_test.go create mode 100644 lobby/internal/app/runtime.go create mode 100644 lobby/internal/app/runtime_smoke_test.go create mode 100644 lobby/internal/app/runtime_test.go create mode 100644 lobby/internal/app/wiring.go create mode 100644 lobby/internal/config/config.go create mode 100644 lobby/internal/config/config_test.go create mode 100644 lobby/internal/config/env.go create mode 100644 lobby/internal/config/validation.go create mode 100644 lobby/internal/domain/application/errors.go create mode 100644 lobby/internal/domain/application/model.go create mode 100644 lobby/internal/domain/application/status.go create mode 100644 lobby/internal/domain/common/ids.go create mode 100644 lobby/internal/domain/common/types.go create mode 100644 lobby/internal/domain/game/errors.go create mode 100644 lobby/internal/domain/game/model.go create mode 100644 lobby/internal/domain/game/model_test.go create mode 100644 lobby/internal/domain/game/status.go create mode 100644 lobby/internal/domain/game/status_test.go create mode 100644 lobby/internal/domain/invite/errors.go create mode 100644 lobby/internal/domain/invite/model.go create mode 100644 lobby/internal/domain/invite/status.go create mode 100644 lobby/internal/domain/membership/errors.go create mode 100644 lobby/internal/domain/membership/model.go create mode 100644 lobby/internal/domain/membership/status.go create mode 100644 lobby/internal/domain/racename/policy.go create mode 100644 lobby/internal/domain/racename/policy_test.go create mode 100644 lobby/internal/domain/racename/types.go create mode 100644 lobby/internal/logging/context.go create mode 100644 lobby/internal/logging/context_test.go create mode 100644 lobby/internal/logging/logger.go create mode 100644 lobby/internal/ports/applicationstore.go create mode 100644 lobby/internal/ports/evaluationguardstore.go create mode 100644 lobby/internal/ports/gamestore.go create mode 100644 lobby/internal/ports/gameturnstatsstore.go create mode 100644 lobby/internal/ports/gapactivationstore.go create mode 100644 lobby/internal/ports/gmclient.go create mode 100644 lobby/internal/ports/idgenerator.go create mode 100644 lobby/internal/ports/intentpublisher.go create mode 100644 lobby/internal/ports/invitestore.go create mode 100644 lobby/internal/ports/membershipstore.go create mode 100644 lobby/internal/ports/racenamedir.go create mode 100644 lobby/internal/ports/racenamedirtest/suite.go create mode 100644 lobby/internal/ports/runtimemanager.go create mode 100644 lobby/internal/ports/streamlagprobe.go create mode 100644 lobby/internal/ports/streamoffsetstore.go create mode 100644 lobby/internal/ports/userlifecyclestream.go create mode 100644 lobby/internal/ports/userservice.go create mode 100644 lobby/internal/service/approveapplication/service.go create mode 100644 lobby/internal/service/approveapplication/service_test.go create mode 100644 lobby/internal/service/blockmember/service.go create mode 100644 lobby/internal/service/blockmember/service_test.go create mode 100644 lobby/internal/service/cancelgame/service.go create mode 100644 lobby/internal/service/cancelgame/service_test.go create mode 100644 lobby/internal/service/capabilityevaluation/service.go create mode 100644 lobby/internal/service/capabilityevaluation/service_test.go create mode 100644 lobby/internal/service/creategame/service.go create mode 100644 lobby/internal/service/creategame/service_test.go create mode 100644 lobby/internal/service/createinvite/service.go create mode 100644 lobby/internal/service/createinvite/service_test.go create mode 100644 lobby/internal/service/declineinvite/service.go create mode 100644 lobby/internal/service/declineinvite/service_test.go create mode 100644 lobby/internal/service/getgame/service.go create mode 100644 lobby/internal/service/getgame/service_test.go create mode 100644 lobby/internal/service/listgames/service.go create mode 100644 lobby/internal/service/listgames/service_test.go create mode 100644 lobby/internal/service/listmemberships/service.go create mode 100644 lobby/internal/service/listmemberships/service_test.go create mode 100644 lobby/internal/service/listmyapplications/service.go create mode 100644 lobby/internal/service/listmyapplications/service_test.go create mode 100644 lobby/internal/service/listmygames/service.go create mode 100644 lobby/internal/service/listmygames/service_test.go create mode 100644 lobby/internal/service/listmyinvites/service.go create mode 100644 lobby/internal/service/listmyinvites/service_test.go create mode 100644 lobby/internal/service/listmyracenames/service.go create mode 100644 lobby/internal/service/listmyracenames/service_test.go create mode 100644 lobby/internal/service/manualreadytostart/service.go create mode 100644 lobby/internal/service/manualreadytostart/service_test.go create mode 100644 lobby/internal/service/openenrollment/service.go create mode 100644 lobby/internal/service/openenrollment/service_test.go create mode 100644 lobby/internal/service/pausegame/service.go create mode 100644 lobby/internal/service/pausegame/service_test.go create mode 100644 lobby/internal/service/redeeminvite/service.go create mode 100644 lobby/internal/service/redeeminvite/service_test.go create mode 100644 lobby/internal/service/registerracename/service.go create mode 100644 lobby/internal/service/registerracename/service_test.go create mode 100644 lobby/internal/service/rejectapplication/service.go create mode 100644 lobby/internal/service/rejectapplication/service_test.go create mode 100644 lobby/internal/service/removemember/service.go create mode 100644 lobby/internal/service/removemember/service_test.go create mode 100644 lobby/internal/service/resumegame/service.go create mode 100644 lobby/internal/service/resumegame/service_test.go create mode 100644 lobby/internal/service/retrystartgame/service.go create mode 100644 lobby/internal/service/retrystartgame/service_test.go create mode 100644 lobby/internal/service/revokeinvite/service.go create mode 100644 lobby/internal/service/revokeinvite/service_test.go create mode 100644 lobby/internal/service/shared/closeenrollment.go create mode 100644 lobby/internal/service/shared/closeenrollment_test.go create mode 100644 lobby/internal/service/shared/page.go create mode 100644 lobby/internal/service/shared/page_test.go create mode 100644 lobby/internal/service/shared/roster.go create mode 100644 lobby/internal/service/shared/shared.go create mode 100644 lobby/internal/service/shared/shared_test.go create mode 100644 lobby/internal/service/startgame/service.go create mode 100644 lobby/internal/service/startgame/service_test.go create mode 100644 lobby/internal/service/submitapplication/service.go create mode 100644 lobby/internal/service/submitapplication/service_test.go create mode 100644 lobby/internal/service/updategame/service.go create mode 100644 lobby/internal/service/updategame/service_test.go create mode 100644 lobby/internal/telemetry/runtime.go create mode 100644 lobby/internal/telemetry/runtime_test.go create mode 100644 lobby/internal/worker/enrollmentautomation/worker.go create mode 100644 lobby/internal/worker/enrollmentautomation/worker_test.go create mode 100644 lobby/internal/worker/gmevents/consumer.go create mode 100644 lobby/internal/worker/gmevents/consumer_test.go create mode 100644 lobby/internal/worker/pendingregistration/worker.go create mode 100644 lobby/internal/worker/pendingregistration/worker_test.go create mode 100644 lobby/internal/worker/runtimejobresult/consumer.go create mode 100644 lobby/internal/worker/runtimejobresult/consumer_test.go create mode 100644 lobby/internal/worker/userlifecycle/worker.go create mode 100644 lobby/internal/worker/userlifecycle/worker_test.go create mode 100644 mail/templates/lobby.membership.blocked/en/subject.tmpl create mode 100644 mail/templates/lobby.membership.blocked/en/text.tmpl create mode 100644 mail/templates/lobby.race_name.registered/en/subject.tmpl create mode 100644 mail/templates/lobby.race_name.registered/en/text.tmpl create mode 100644 mail/templates/lobby.race_name.registration_denied/en/subject.tmpl create mode 100644 mail/templates/lobby.race_name.registration_denied/en/text.tmpl create mode 100644 mail/templates/lobby.race_name.registration_eligible/en/subject.tmpl create mode 100644 mail/templates/lobby.race_name.registration_eligible/en/text.tmpl create mode 100644 pkg/schema/fbs/notification/LobbyMembershipBlockedEvent.go create mode 100644 pkg/schema/fbs/notification/LobbyRaceNameRegisteredEvent.go create mode 100644 pkg/schema/fbs/notification/LobbyRaceNameRegistrationEligibleEvent.go create mode 100644 user/docs/stage21-user-name-display-name.md create mode 100644 user/docs/stage22-permanent-block-delete-user.md delete mode 100644 user/internal/adapters/local/race_name_policy.go delete mode 100644 user/internal/adapters/local/race_name_policy_test.go create mode 100644 user/internal/adapters/redis/lifecycleevents/publisher.go create mode 100644 user/internal/adapters/redis/lifecycleevents/publisher_test.go create mode 100644 user/internal/adapters/redis/userstore/admin_index_test.go delete mode 100644 user/internal/ports/race_name_policy.go create mode 100644 user/internal/ports/user_lifecycle_publisher.go create mode 100644 user/internal/service/accountdeletion/service.go create mode 100644 user/internal/service/accountdeletion/service_test.go delete mode 100644 user/internal/service/shared/race_name.go diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e645197 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "enabledPlugins": { + "gopls-lsp@claude-plugins-official": true, + "context7@claude-plugins-official": true + }, + "permissions": { + "defaultMode": "plan", + "allow": [ + "mcp__context7__resolve-library-id", + "mcp__context7__get-library-docs" + ] + } +} \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c1fa4fd..fe739e2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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-` 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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..46799cb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,63 @@ +# Galaxy Game — Project Conventions + +This repository hosts the Galaxy Game project. + +## Sources of truth + +- `ARCHITECTURE.md` — global architecture, project-wide rules + and links to the implemented services. +- `galaxy//README.md` - service conventions and agreements + for the implemented or planned to be implemented service. + +## Planning of service implementation and Implementing Plan + +- `galaxy//PLAN.md` — staged implementation plan for the service. + May be already complete and resides for historical reasons. +- `galaxy//docs/` — per-stage decision records + (one file per decision, re-organized after full implementation + of `PLAN.md`). + +## Decision records when implementing stages from PLAN.md + +- Stage-related discussion and decisions do NOT live in `README.md` or + `ARCHITECTURE.md`. Those files describe the current state, not the history. +- Each non-trivial decision gets its own `.md` under the module's `docs/`, + referenced from the relevant `README.md`. +- Any agreement reached during interactive planning that is not obvious from + the code must be captured — either as a decision record or as an entry in + the module's README. + +## Scope of PLAN.md changes + +The existing codebase of `galaxy/` may be modified or extended when a +plan stage requires it. All such changes must be covered by new or updated tests +and reflected in documentation when they affect documented behavior. + +## Documentation discipline + +- Code and docs are kept in sync. If an implementation changes behavior + described in a `.md` or `.yaml` file, update that file in the same patch. +- If existing docs are incomplete or wrong for behavior you are already + touching, fix them in the same patch. +- Do not silently remove commitments from `galaxy//README.md` + or `galaxy//docs/*.md`. When a rule changes, either update it + in place with the new agreement, or move the section to a more appropriate + doc with a reference kept. +- Cross-module impact: if a new agreement requires changes in + already-implemented modules, make those changes — code, tests, docs — in + the same patch, and record the new rule in `ARCHITECTURE.md`. + +## Dependencies + +- Before adding a new module, check its upstream repository for the latest + stable version and use that. +- When a well-maintained library clearly outperforms stdlib for a concrete + need, do not adopt it silently — propose a short list of 1+ candidates for + the user to pick. Default remains stdlib. + +## Language + +- All code, comments, identifiers, commit messages, docs, and filenames are + written in English. +- User-facing chat responses follow the Russian-translation rule from the + user-level `CLAUDE.md`. diff --git a/gateway/README.md b/gateway/README.md index ebfefc2..6c8e2f0 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -486,8 +486,11 @@ in v1 is exactly: - `lobby.application.submitted` - `lobby.membership.approved` - `lobby.membership.rejected` +- `lobby.membership.blocked` - `lobby.invite.created` - `lobby.invite.redeemed` +- `lobby.race_name.registration_eligible` +- `lobby.race_name.registered` `lobby.application.submitted` is published toward `Gateway` only for the private-game owner flow. The public-game variant is email-only. diff --git a/gateway/internal/downstream/userservice/client_test.go b/gateway/internal/downstream/userservice/client_test.go index a6cf871..3fcd083 100644 --- a/gateway/internal/downstream/userservice/client_test.go +++ b/gateway/internal/downstream/userservice/client_test.go @@ -98,7 +98,7 @@ func TestHTTPClientExecuteUpdateMyProfileProjectsConflict(t *testing.T) { body, err := io.ReadAll(request.Body) require.NoError(t, err) - require.JSONEq(t, `{"race_name":"Nova Prime"}`, string(body)) + require.JSONEq(t, `{"display_name":"NovaPrime"}`, string(body)) writer.WriteHeader(http.StatusConflict) require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{ @@ -111,7 +111,7 @@ func TestHTTPClientExecuteUpdateMyProfileProjectsConflict(t *testing.T) { defer server.Close() client := newTestHTTPClient(t, server) - payload, err := transcoder.UpdateMyProfileRequestToPayload(&usermodel.UpdateMyProfileRequest{RaceName: "Nova Prime"}) + payload, err := transcoder.UpdateMyProfileRequestToPayload(&usermodel.UpdateMyProfileRequest{DisplayName: "NovaPrime"}) require.NoError(t, err) result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{ @@ -338,7 +338,8 @@ func sampleAccountResponse() *usermodel.AccountResponse { Account: usermodel.Account{ UserID: "user-123", Email: "pilot@example.com", - RaceName: "Pilot Nova", + UserName: "player-abcdefgh", + DisplayName: "PilotNova", PreferredLanguage: "en", TimeZone: "Europe/Kaliningrad", DeclaredCountry: "DE", diff --git a/go.work b/go.work index e2b661f..a2825ab 100644 --- a/go.work +++ b/go.work @@ -6,6 +6,7 @@ use ( ./game ./gateway ./integration + ./lobby ./mail ./notification ./pkg/calc diff --git a/go.work.sum b/go.work.sum index 476f59f..4127c42 100644 --- a/go.work.sum +++ b/go.work.sum @@ -59,6 +59,7 @@ github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPO github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/timandy/routine v1.1.6/go.mod h1:kXslgIosdY8LW0byTyPnenDgn4/azt2euufAq9rK51w= github.com/urfave/cli/v2 v2.4.0/go.mod h1:NX9W0zmTvedE5oDoOMs2RTC8RvdK98NTYZE5LbaEYPg= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= diff --git a/integration/README.md b/integration/README.md index 0074c53..0cc150c 100644 --- a/integration/README.md +++ b/integration/README.md @@ -34,6 +34,11 @@ integration/ │ └── notification_mail_test.go ├── notificationuser/ │ └── notification_user_test.go +├── lobbyuser/ +│ └── lobby_user_test.go +├── lobbynotification/ +│ ├── lobby_notification_test.go +│ └── race_name_intents_test.go ├── go.mod ├── go.sum └── internal/ @@ -81,14 +86,24 @@ integration/ across real `Edge Gateway`, real `Auth / Session Service`, real `User Service`, and real `Mail Service`, including the regression that auth-code mail bypasses `notification:intents`. +- `lobbyuser` verifies the synchronous eligibility boundary between real + `Game Lobby` and real `User Service`, including the happy path, + permanent_block rejection, unknown user, and transient User Service + unavailability. +- `lobbynotification` verifies the producer side of `Game Lobby → + notification:intents`, covering all eleven `lobby.*` intent types from + applications, invites, member operations, runtime pause, cascade + membership block, and the three race-name intents emitted by capability + evaluation at game finish and by self-service registration. The current fast suites still use one isolated `miniredis` instance plus either real downstream processes or external stateful HTTP stubs where appropriate. `authsessionmail`, `gatewayauthsessionmail`, `notificationgateway`, -`notificationmail`, `notificationuser`, and `gatewayauthsessionusermail` are -the deliberate exceptions: they use one real Redis container through -`testcontainers-go`, because those boundaries must exercise real Redis stream, -persistence, or scheduling behavior. +`notificationmail`, `notificationuser`, `gatewayauthsessionusermail`, +`lobbyuser`, and `lobbynotification` are the deliberate exceptions: they use +one real Redis container through `testcontainers-go`, because those +boundaries must exercise real Redis stream, persistence, or scheduling +behavior. `authsessionmail` additionally contains one targeted SMTP-capture scenario for the real `smtp` provider path, while `gatewayauthsessionmail` keeps `Mail Service` in `stub` mode and extracts the confirmation code through the trusted @@ -110,6 +125,8 @@ go test ./notificationgateway/... go test ./notificationmail/... go test ./notificationuser/... go test ./gatewayauthsessionusermail/... +go test ./lobbyuser/... +go test ./lobbynotification/... ``` Useful regression commands after boundary changes: @@ -125,6 +142,8 @@ go test ./notificationgateway/... go test ./notificationmail/... go test ./notificationuser/... go test ./gatewayauthsessionusermail/... +go test ./lobbyuser/... +go test ./lobbynotification/... cd ../gateway && go test ./... cd ../authsession && go test ./... -run GatewayCompatibility cd ../user && go test ./... diff --git a/integration/authsessionuser/authsession_user_test.go b/integration/authsessionuser/authsession_user_test.go index 5aec97f..152d9f0 100644 --- a/integration/authsessionuser/authsession_user_test.go +++ b/integration/authsessionuser/authsession_user_test.go @@ -30,7 +30,8 @@ func TestAuthsessionUserBlackBoxConfirmCreatesUserWithForwardedRegistrationConte require.Equal(t, "en", account.User.PreferredLanguage) require.Equal(t, testTimeZone, account.User.TimeZone) require.True(t, strings.HasPrefix(account.User.UserID, "user-")) - require.True(t, strings.HasPrefix(account.User.RaceName, "player-")) + require.True(t, strings.HasPrefix(account.User.UserName, "player-")) + require.Empty(t, account.User.DisplayName) require.Equal(t, "free", account.User.Entitlement.PlanCode) require.False(t, account.User.Entitlement.IsPaid) require.Empty(t, account.User.ActiveSanctions) diff --git a/integration/authsessionuser/harness_test.go b/integration/authsessionuser/harness_test.go index fac157c..3936f89 100644 --- a/integration/authsessionuser/harness_test.go +++ b/integration/authsessionuser/harness_test.go @@ -333,7 +333,8 @@ type userLookupResponse struct { type accountView struct { UserID string `json:"user_id"` Email string `json:"email"` - RaceName string `json:"race_name"` + UserName string `json:"user_name"` + DisplayName string `json:"display_name,omitempty"` PreferredLanguage string `json:"preferred_language"` TimeZone string `json:"time_zone"` DeclaredCountry string `json:"declared_country,omitempty"` diff --git a/integration/gatewayuser/gateway_user_test.go b/integration/gatewayuser/gateway_user_test.go index 715cbc0..a137888 100644 --- a/integration/gatewayuser/gateway_user_test.go +++ b/integration/gatewayuser/gateway_user_test.go @@ -50,7 +50,7 @@ func TestGatewayUserUpdateMyProfileSuccess(t *testing.T) { clientPrivateKey := newClientPrivateKey("update-profile") h.seedGatewaySession(t, deviceSessionID, created.UserID, clientPrivateKey) - payload, err := contractsuserv1.EncodeUpdateMyProfileRequest("Nova Prime") + payload, err := contractsuserv1.EncodeUpdateMyProfileRequest("NovaPrime") require.NoError(t, err) response := h.executeCommand(t, deviceSessionID, requestID, contractsuserv1.MessageTypeUpdateMyProfile, payload, clientPrivateKey) @@ -58,10 +58,11 @@ func TestGatewayUserUpdateMyProfileSuccess(t *testing.T) { accountResponse, err := contractsuserv1.DecodeAccountResponse(response.GetPayloadBytes()) require.NoError(t, err) - require.Equal(t, "Nova Prime", accountResponse.Account.RaceName) + require.Equal(t, "NovaPrime", accountResponse.Account.DisplayName) + require.NotEmpty(t, accountResponse.Account.UserName) lookup := h.lookupUserByEmail(t, email) - require.Equal(t, "Nova Prime", lookup.User.RaceName) + require.Equal(t, "NovaPrime", lookup.User.DisplayName) } func TestGatewayUserUpdateMySettingsSuccess(t *testing.T) { @@ -108,7 +109,7 @@ func TestGatewayUserUpdateMyProfileConflict(t *testing.T) { clientPrivateKey := newClientPrivateKey("profile-conflict") h.seedGatewaySession(t, deviceSessionID, created.UserID, clientPrivateKey) - payload, err := contractsuserv1.EncodeUpdateMyProfileRequest("Blocked Nova") + payload, err := contractsuserv1.EncodeUpdateMyProfileRequest("BlockedNova") require.NoError(t, err) response := h.executeCommand(t, deviceSessionID, requestID, contractsuserv1.MessageTypeUpdateMyProfile, payload, clientPrivateKey) diff --git a/integration/internal/contracts/userv1/contract.go b/integration/internal/contracts/userv1/contract.go index 7147ad2..0103c1a 100644 --- a/integration/internal/contracts/userv1/contract.go +++ b/integration/internal/contracts/userv1/contract.go @@ -33,9 +33,9 @@ func EncodeGetMyAccountRequest() ([]byte, error) { // EncodeUpdateMyProfileRequest returns the FlatBuffers payload for one public // self-service profile mutation request. -func EncodeUpdateMyProfileRequest(raceName string) ([]byte, error) { +func EncodeUpdateMyProfileRequest(displayName string) ([]byte, error) { return transcoder.UpdateMyProfileRequestToPayload(&usermodel.UpdateMyProfileRequest{ - RaceName: raceName, + DisplayName: displayName, }) } diff --git a/integration/lobbynotification/lobby_notification_test.go b/integration/lobbynotification/lobby_notification_test.go new file mode 100644 index 0000000..2b72895 --- /dev/null +++ b/integration/lobbynotification/lobby_notification_test.go @@ -0,0 +1,635 @@ +// Package lobbynotification_test exercises Lobby's notification-intent +// publication boundary by booting Lobby + the real User Service against a +// Redis container and asserting on the contents of `notification:intents`. +// The Notification Service is intentionally NOT booted: the boundary under +// test is "Lobby produces correct intent envelopes onto the stream", +// independent of how the Notification Service consumes them. +package lobbynotification_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net/http" + "net/http/httptest" + "slices" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + + "galaxy/integration/internal/harness" + + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +const ( + notificationIntentsStream = "notification:intents" + userLifecycleStream = "user:lifecycle_events" + runtimeJobResultsStream = "runtime:job_results" + gmLobbyEventsStream = "gm:lobby_events" + intentTypeApplicationSubmitted = "lobby.application.submitted" + intentTypeMembershipApproved = "lobby.membership.approved" + intentTypeMembershipRejected = "lobby.membership.rejected" + intentTypeMembershipBlocked = "lobby.membership.blocked" + intentTypeInviteCreated = "lobby.invite.created" + intentTypeInviteRedeemed = "lobby.invite.redeemed" + intentTypeInviteExpired = "lobby.invite.expired" + intentTypeRuntimePausedAfter = "lobby.runtime_paused_after_start" + expectedProducer = "game_lobby" +) + +func TestApplicationFlowPublishesSubmittedApprovedRejected(t *testing.T) { + h := newLobbyNotificationHarness(t, gmAlwaysOK) + + applicantA := h.ensureUser(t, "applicantA@example.com") + applicantB := h.ensureUser(t, "applicantB@example.com") + + gameID := h.adminCreatePublicGame(t, "Application Galaxy", time.Now().Add(48*time.Hour).Unix()) + h.openEnrollment(t, gameID) + + appA := h.submitApplication(t, applicantA.UserID, gameID, "PilotAlpha") + h.adminApproveApplication(t, gameID, appA["application_id"].(string)) + + appB := h.submitApplication(t, applicantB.UserID, gameID, "PilotBeta") + h.adminRejectApplication(t, gameID, appB["application_id"].(string)) + + h.requireIntents(t, + expect(intentTypeApplicationSubmitted, "admin"), + expect(intentTypeApplicationSubmitted, "admin"), + expect(intentTypeMembershipApproved, applicantA.UserID), + expect(intentTypeMembershipRejected, applicantB.UserID), + ) +} + +func TestPrivateInviteLifecyclePublishesCreatedRedeemedExpired(t *testing.T) { + h := newLobbyNotificationHarness(t, gmAlwaysOK) + + owner := h.ensureUser(t, "owner@example.com") + inviteeA := h.ensureUser(t, "inviteeA@example.com") + inviteeB := h.ensureUser(t, "inviteeB@example.com") + + gameID := h.userCreatePrivateGame(t, owner.UserID, "Private Invite Galaxy", + time.Now().Add(48*time.Hour).Unix()) + h.userOpenEnrollment(t, owner.UserID, gameID) + + h.userCreateInvite(t, owner.UserID, gameID, inviteeA.UserID) + inviteB := h.userCreateInvite(t, owner.UserID, gameID, inviteeB.UserID) + _ = inviteB + + // Read invitee A's invite ID by listing their invites. + inviteAID := h.firstCreatedInviteID(t, inviteeA.UserID, gameID) + h.userRedeemInvite(t, inviteeA.UserID, gameID, inviteAID, "PilotPrivateA") + + // Close enrollment (min_players=1 satisfied by inviteeA's redeem). + // Invite B is still in `created` and must transition to `expired`. + h.userReadyToStart(t, owner.UserID, gameID) + + h.requireIntents(t, + expect(intentTypeInviteCreated, inviteeA.UserID), + expect(intentTypeInviteCreated, inviteeB.UserID), + expect(intentTypeInviteRedeemed, owner.UserID), + expect(intentTypeInviteExpired, owner.UserID), + ) +} + +func TestCascadeMembershipBlockedPublishesIntent(t *testing.T) { + h := newLobbyNotificationHarness(t, gmAlwaysOK) + + owner := h.ensureUser(t, "cascade-owner@example.com") + invitee := h.ensureUser(t, "cascade-invitee@example.com") + + gameID := h.userCreatePrivateGame(t, owner.UserID, "Cascade Galaxy", + time.Now().Add(48*time.Hour).Unix()) + h.userOpenEnrollment(t, owner.UserID, gameID) + h.userCreateInvite(t, owner.UserID, gameID, invitee.UserID) + + inviteID := h.firstCreatedInviteID(t, invitee.UserID, gameID) + h.userRedeemInvite(t, invitee.UserID, gameID, inviteID, "PilotCascade") + + h.publishUserLifecycleEvent(t, "user.lifecycle.permanent_blocked", invitee.UserID) + + h.requireIntents(t, + expect(intentTypeInviteCreated, invitee.UserID), + expect(intentTypeInviteRedeemed, owner.UserID), + expect(intentTypeMembershipBlocked, owner.UserID), + ) +} + +func TestRuntimePausedAfterStartPublishesAdminIntent(t *testing.T) { + gmRegisterFails := func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/register-runtime") { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"forced GM unavailability"}`)) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + } + + h := newLobbyNotificationHarness(t, gmRegisterFails) + + applicant := h.ensureUser(t, "starter@example.com") + + gameID := h.adminCreatePublicGame(t, "Runtime Pause Galaxy", + time.Now().Add(48*time.Hour).Unix()) + h.openEnrollment(t, gameID) + + app := h.submitApplication(t, applicant.UserID, gameID, "PilotPause") + h.adminApproveApplication(t, gameID, app["application_id"].(string)) + + h.adminReadyToStart(t, gameID) + h.adminStartGame(t, gameID) + + h.publishRuntimeJobSuccess(t, gameID) + + h.requireIntents(t, + expect(intentTypeApplicationSubmitted, "admin"), + expect(intentTypeMembershipApproved, applicant.UserID), + expect(intentTypeRuntimePausedAfter, "admin"), + ) +} + +type lobbyNotificationHarness struct { + redis *redis.Client + + userServiceURL string + lobbyPublicURL string + lobbyAdminURL string + + intentsStream string + lifecycleStream string + jobResultsStream string + gmEventsStream string + + gmStub *httptest.Server + + userServiceProcess *harness.Process + lobbyProcess *harness.Process +} + +type ensureByEmailResponse struct { + Outcome string `json:"outcome"` + UserID string `json:"user_id"` +} + +type expectedIntent struct { + NotificationType string + Recipient string // user_id, or "admin" for admin_email audience +} + +func expect(notificationType, recipient string) expectedIntent { + return expectedIntent{NotificationType: notificationType, Recipient: recipient} +} + +func gmAlwaysOK(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) +} + +var harnessSeq atomic.Int64 + +func newLobbyNotificationHarness(t *testing.T, gmHandler http.HandlerFunc) *lobbyNotificationHarness { + t.Helper() + + redisRuntime := harness.StartRedisContainer(t) + redisClient := redis.NewClient(&redis.Options{ + Addr: redisRuntime.Addr, + Protocol: 2, + DisableIdentity: true, + }) + t.Cleanup(func() { + require.NoError(t, redisClient.Close()) + }) + + gmStub := httptest.NewServer(http.HandlerFunc(gmHandler)) + t.Cleanup(gmStub.Close) + + userServiceAddr := harness.FreeTCPAddress(t) + lobbyPublicAddr := harness.FreeTCPAddress(t) + lobbyInternalAddr := harness.FreeTCPAddress(t) + + userServiceBinary := harness.BuildBinary(t, "userservice", "./user/cmd/userservice") + lobbyBinary := harness.BuildBinary(t, "lobby", "./lobby/cmd/lobby") + + userServiceProcess := harness.StartProcess(t, "userservice", userServiceBinary, map[string]string{ + "USERSERVICE_LOG_LEVEL": "info", + "USERSERVICE_INTERNAL_HTTP_ADDR": userServiceAddr, + "USERSERVICE_REDIS_ADDR": redisRuntime.Addr, + "OTEL_TRACES_EXPORTER": "none", + "OTEL_METRICS_EXPORTER": "none", + }) + waitForUserServiceReady(t, userServiceProcess, "http://"+userServiceAddr) + + // Use unique stream prefixes per test so concurrent runs do not bleed. + suffix := strconv.FormatInt(harnessSeq.Add(1), 10) + intentsStream := notificationIntentsStream + ":" + suffix + lifecycleStream := userLifecycleStream + ":" + suffix + jobResultsStream := runtimeJobResultsStream + ":" + suffix + gmEventsStream := gmLobbyEventsStream + ":" + suffix + + lobbyProcess := harness.StartProcess(t, "lobby", lobbyBinary, map[string]string{ + "LOBBY_LOG_LEVEL": "info", + "LOBBY_PUBLIC_HTTP_ADDR": lobbyPublicAddr, + "LOBBY_INTERNAL_HTTP_ADDR": lobbyInternalAddr, + "LOBBY_REDIS_ADDR": redisRuntime.Addr, + "LOBBY_USER_SERVICE_BASE_URL": "http://" + userServiceAddr, + "LOBBY_GM_BASE_URL": gmStub.URL, + "LOBBY_NOTIFICATION_INTENTS_STREAM": intentsStream, + "LOBBY_USER_LIFECYCLE_STREAM": lifecycleStream, + "LOBBY_RUNTIME_JOB_RESULTS_STREAM": jobResultsStream, + "LOBBY_GM_EVENTS_STREAM": gmEventsStream, + "LOBBY_RUNTIME_JOB_RESULTS_READ_BLOCK_TIMEOUT": "200ms", + "LOBBY_USER_LIFECYCLE_READ_BLOCK_TIMEOUT": "200ms", + "LOBBY_GM_EVENTS_READ_BLOCK_TIMEOUT": "200ms", + "OTEL_TRACES_EXPORTER": "none", + "OTEL_METRICS_EXPORTER": "none", + }) + harness.WaitForHTTPStatus(t, lobbyProcess, "http://"+lobbyInternalAddr+"/readyz", http.StatusOK) + + return &lobbyNotificationHarness{ + redis: redisClient, + userServiceURL: "http://" + userServiceAddr, + lobbyPublicURL: "http://" + lobbyPublicAddr, + lobbyAdminURL: "http://" + lobbyInternalAddr, + intentsStream: intentsStream, + lifecycleStream: lifecycleStream, + jobResultsStream: jobResultsStream, + gmEventsStream: gmEventsStream, + gmStub: gmStub, + userServiceProcess: userServiceProcess, + lobbyProcess: lobbyProcess, + } +} + +func (h *lobbyNotificationHarness) ensureUser(t *testing.T, email string) ensureByEmailResponse { + t.Helper() + + resp := postJSON(t, h.userServiceURL+"/api/v1/internal/users/ensure-by-email", map[string]any{ + "email": email, + "registration_context": map[string]string{ + "preferred_language": "en", + "time_zone": "Europe/Kaliningrad", + }, + }, nil) + var out ensureByEmailResponse + requireJSONStatus(t, resp, http.StatusOK, &out) + require.Equal(t, "created", out.Outcome) + require.NotEmpty(t, out.UserID) + return out +} + +func (h *lobbyNotificationHarness) adminCreatePublicGame(t *testing.T, name string, enrollmentEndsAt int64) string { + t.Helper() + return h.createGame(t, h.lobbyAdminURL+"/api/v1/lobby/games", "public", name, enrollmentEndsAt, nil) +} + +func (h *lobbyNotificationHarness) userCreatePrivateGame(t *testing.T, ownerUserID, name string, enrollmentEndsAt int64) string { + t.Helper() + return h.createGame(t, h.lobbyPublicURL+"/api/v1/lobby/games", "private", name, enrollmentEndsAt, + http.Header{"X-User-Id": []string{ownerUserID}}) +} + +func (h *lobbyNotificationHarness) createGame(t *testing.T, url, gameType, name string, enrollmentEndsAt int64, header http.Header) string { + t.Helper() + + resp := postJSON(t, url, map[string]any{ + "game_name": name, + "game_type": gameType, + "min_players": 1, + "max_players": 4, + "start_gap_hours": 6, + "start_gap_players": 1, + "enrollment_ends_at": enrollmentEndsAt, + "turn_schedule": "0 18 * * *", + "target_engine_version": "1.0.0", + }, header) + require.Equalf(t, http.StatusCreated, resp.StatusCode, "create %s game: %s", gameType, resp.Body) + + var record map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Body), &record)) + gameID, ok := record["game_id"].(string) + require.Truef(t, ok, "game_id missing: %s", resp.Body) + return gameID +} + +func (h *lobbyNotificationHarness) openEnrollment(t *testing.T, gameID string) { + t.Helper() + resp := postJSON(t, h.lobbyAdminURL+"/api/v1/lobby/games/"+gameID+"/open-enrollment", nil, nil) + require.Equalf(t, http.StatusOK, resp.StatusCode, "admin open enrollment: %s", resp.Body) +} + +func (h *lobbyNotificationHarness) userOpenEnrollment(t *testing.T, ownerUserID, gameID string) { + t.Helper() + resp := postJSON(t, h.lobbyPublicURL+"/api/v1/lobby/games/"+gameID+"/open-enrollment", nil, + http.Header{"X-User-Id": []string{ownerUserID}}) + require.Equalf(t, http.StatusOK, resp.StatusCode, "user open enrollment: %s", resp.Body) +} + +func (h *lobbyNotificationHarness) submitApplication(t *testing.T, userID, gameID, raceName string) map[string]any { + t.Helper() + resp := postJSON(t, h.lobbyPublicURL+"/api/v1/lobby/games/"+gameID+"/applications", + map[string]any{"race_name": raceName}, + http.Header{"X-User-Id": []string{userID}}) + require.Equalf(t, http.StatusCreated, resp.StatusCode, "submit application: %s", resp.Body) + var body map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Body), &body)) + return body +} + +func (h *lobbyNotificationHarness) adminApproveApplication(t *testing.T, gameID, applicationID string) { + t.Helper() + resp := postJSON(t, + h.lobbyAdminURL+"/api/v1/lobby/games/"+gameID+"/applications/"+applicationID+"/approve", + nil, nil) + require.Equalf(t, http.StatusOK, resp.StatusCode, "admin approve: %s", resp.Body) +} + +func (h *lobbyNotificationHarness) adminRejectApplication(t *testing.T, gameID, applicationID string) { + t.Helper() + resp := postJSON(t, + h.lobbyAdminURL+"/api/v1/lobby/games/"+gameID+"/applications/"+applicationID+"/reject", + nil, nil) + require.Equalf(t, http.StatusOK, resp.StatusCode, "admin reject: %s", resp.Body) +} + +func (h *lobbyNotificationHarness) userCreateInvite(t *testing.T, ownerUserID, gameID, inviteeUserID string) map[string]any { + t.Helper() + resp := postJSON(t, h.lobbyPublicURL+"/api/v1/lobby/games/"+gameID+"/invites", + map[string]any{"invitee_user_id": inviteeUserID}, + http.Header{"X-User-Id": []string{ownerUserID}}) + require.Equalf(t, http.StatusCreated, resp.StatusCode, "create invite: %s", resp.Body) + var body map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Body), &body)) + return body +} + +func (h *lobbyNotificationHarness) firstCreatedInviteID(t *testing.T, inviteeUserID, gameID string) string { + t.Helper() + req, err := http.NewRequest(http.MethodGet, h.lobbyPublicURL+"/api/v1/lobby/my/invites?status=created", nil) + require.NoError(t, err) + req.Header.Set("X-User-Id", inviteeUserID) + resp := doRequest(t, req) + require.Equalf(t, http.StatusOK, resp.StatusCode, "list my invites: %s", resp.Body) + + var body struct { + Items []struct { + InviteID string `json:"invite_id"` + GameID string `json:"game_id"` + } `json:"items"` + } + require.NoError(t, json.Unmarshal([]byte(resp.Body), &body)) + for _, item := range body.Items { + if item.GameID == gameID { + return item.InviteID + } + } + t.Fatalf("no invite found for invitee %s on game %s; body=%s", inviteeUserID, gameID, resp.Body) + return "" +} + +func (h *lobbyNotificationHarness) userRedeemInvite(t *testing.T, inviteeUserID, gameID, inviteID, raceName string) { + t.Helper() + resp := postJSON(t, + h.lobbyPublicURL+"/api/v1/lobby/games/"+gameID+"/invites/"+inviteID+"/redeem", + map[string]any{"race_name": raceName}, + http.Header{"X-User-Id": []string{inviteeUserID}}) + require.Equalf(t, http.StatusOK, resp.StatusCode, "redeem invite: %s", resp.Body) +} + +func (h *lobbyNotificationHarness) userReadyToStart(t *testing.T, ownerUserID, gameID string) { + t.Helper() + resp := postJSON(t, + h.lobbyPublicURL+"/api/v1/lobby/games/"+gameID+"/ready-to-start", + nil, + http.Header{"X-User-Id": []string{ownerUserID}}) + require.Equalf(t, http.StatusOK, resp.StatusCode, "user ready-to-start: %s", resp.Body) +} + +func (h *lobbyNotificationHarness) adminReadyToStart(t *testing.T, gameID string) { + t.Helper() + resp := postJSON(t, h.lobbyAdminURL+"/api/v1/lobby/games/"+gameID+"/ready-to-start", nil, nil) + require.Equalf(t, http.StatusOK, resp.StatusCode, "admin ready-to-start: %s", resp.Body) +} + +func (h *lobbyNotificationHarness) adminStartGame(t *testing.T, gameID string) { + t.Helper() + resp := postJSON(t, h.lobbyAdminURL+"/api/v1/lobby/games/"+gameID+"/start", nil, nil) + require.Equalf(t, http.StatusOK, resp.StatusCode, "admin start game: %s", resp.Body) +} + +func (h *lobbyNotificationHarness) publishUserLifecycleEvent(t *testing.T, eventType, userID string) { + t.Helper() + _, err := h.redis.XAdd(context.Background(), &redis.XAddArgs{ + Stream: h.lifecycleStream, + Values: map[string]any{ + "event_type": eventType, + "user_id": userID, + "occurred_at_ms": strconv.FormatInt(time.Now().UnixMilli(), 10), + "source": "user_admin", + "actor_type": "admin", + "actor_id": "admin-1", + "reason_code": "terminal_policy_violation", + }, + }).Result() + require.NoError(t, err) +} + +func (h *lobbyNotificationHarness) publishRuntimeJobSuccess(t *testing.T, gameID string) { + t.Helper() + _, err := h.redis.XAdd(context.Background(), &redis.XAddArgs{ + Stream: h.jobResultsStream, + Values: map[string]any{ + "game_id": gameID, + "outcome": "success", + "container_id": "container-" + gameID, + "engine_endpoint": "127.0.0.1:0", + }, + }).Result() + require.NoError(t, err) +} + +func (h *lobbyNotificationHarness) requireIntents(t *testing.T, want ...expectedIntent) { + t.Helper() + + want = append([]expectedIntent(nil), want...) + + require.Eventuallyf(t, func() bool { + entries, err := h.redis.XRange(context.Background(), h.intentsStream, "-", "+").Result() + if err != nil { + return false + } + published := decodePublishedIntents(t, entries) + return matchesAll(published, want) + }, 15*time.Second, 100*time.Millisecond, + "expected intents %+v not all observed on stream %s", want, h.intentsStream) + + entries, err := h.redis.XRange(context.Background(), h.intentsStream, "-", "+").Result() + require.NoError(t, err) + published := decodePublishedIntents(t, entries) + for _, p := range published { + require.Equal(t, expectedProducer, p.Producer, + "every published intent must declare producer=%q", expectedProducer) + } +} + +type publishedIntent struct { + NotificationType string + Producer string + AudienceKind string + RecipientUserIDs []string +} + +func decodePublishedIntents(t *testing.T, entries []redis.XMessage) []publishedIntent { + t.Helper() + + out := make([]publishedIntent, 0, len(entries)) + for _, entry := range entries { + notificationType, _ := entry.Values["notification_type"].(string) + producer, _ := entry.Values["producer"].(string) + audienceKind, _ := entry.Values["audience_kind"].(string) + recipientsJSON, _ := entry.Values["recipient_user_ids_json"].(string) + + var recipients []string + if recipientsJSON != "" { + require.NoError(t, json.Unmarshal([]byte(recipientsJSON), &recipients)) + } + + out = append(out, publishedIntent{ + NotificationType: notificationType, + Producer: producer, + AudienceKind: audienceKind, + RecipientUserIDs: recipients, + }) + } + return out +} + +func matchesAll(published []publishedIntent, want []expectedIntent) bool { + used := make([]bool, len(published)) + for _, w := range want { + matched := -1 + for i, p := range published { + if used[i] { + continue + } + if p.NotificationType != w.NotificationType { + continue + } + if w.Recipient == "admin" { + if p.AudienceKind == "admin_email" { + matched = i + break + } + continue + } + if slices.Contains(p.RecipientUserIDs, w.Recipient) { + matched = i + break + } + } + if matched < 0 { + return false + } + used[matched] = true + } + return true +} + +func waitForUserServiceReady(t *testing.T, process *harness.Process, baseURL string) { + t.Helper() + client := &http.Client{Timeout: 250 * time.Millisecond} + t.Cleanup(client.CloseIdleConnections) + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + req, err := http.NewRequest(http.MethodGet, baseURL+"/api/v1/internal/users/user-readiness-probe/exists", nil) + require.NoError(t, err) + response, err := client.Do(req) + if err == nil { + _, _ = io.Copy(io.Discard, response.Body) + response.Body.Close() + if response.StatusCode == http.StatusOK { + return + } + } + time.Sleep(25 * time.Millisecond) + } + t.Fatalf("wait for userservice readiness: timeout\n%s", process.Logs()) +} + +type httpResponse struct { + StatusCode int + Body string + Header http.Header +} + +func postJSON(t *testing.T, url string, body any, header http.Header) httpResponse { + t.Helper() + var reader io.Reader + if body != nil { + payload, err := json.Marshal(body) + require.NoError(t, err) + reader = bytes.NewReader(payload) + } + req, err := http.NewRequest(http.MethodPost, url, reader) + require.NoError(t, err) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + maps.Copy(req.Header, header) + return doRequest(t, req) +} + +func doRequest(t *testing.T, request *http.Request) httpResponse { + t.Helper() + client := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{DisableKeepAlives: true}, + } + t.Cleanup(client.CloseIdleConnections) + + response, err := client.Do(request) + require.NoError(t, err) + defer response.Body.Close() + + payload, err := io.ReadAll(response.Body) + require.NoError(t, err) + return httpResponse{ + StatusCode: response.StatusCode, + Body: string(payload), + Header: response.Header.Clone(), + } +} + +func requireJSONStatus(t *testing.T, response httpResponse, wantStatus int, target any) { + t.Helper() + require.Equalf(t, wantStatus, response.StatusCode, "unexpected status, body=%s", response.Body) + if target != nil { + require.NoError(t, decodeStrictJSON([]byte(response.Body), target)) + } +} + +func decodeStrictJSON(payload []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return errors.New("unexpected trailing JSON input") + } + return err + } + return nil +} + +// silenceUnused keeps fmt referenced by future debug formatting needs. +var _ = fmt.Sprintf diff --git a/integration/lobbynotification/race_name_intents_test.go b/integration/lobbynotification/race_name_intents_test.go new file mode 100644 index 0000000..56701b0 --- /dev/null +++ b/integration/lobbynotification/race_name_intents_test.go @@ -0,0 +1,198 @@ +// Race-name intent tests cover the three notification types Lobby emits +// across the capability-evaluation and self-service registration boundary: +// +// - lobby.race_name.registration_eligible — produced when a member's +// stats satisfy the capability rule at game finish; +// - lobby.race_name.registration_denied — produced when they do not; +// - lobby.race_name.registered — produced when the user converts the +// pending registration into a permanent registered name. +// +// The single test below drives a public game through start, publishes the +// `gm:lobby_events` snapshot and `game_finished` events directly to Redis, +// then performs the user-side registration call. Notification Service is +// not booted: the assertion target is the contents of `notification:intents`. +package lobbynotification_test + +import ( + "context" + "encoding/json" + "net/http" + "slices" + "strconv" + "testing" + "time" + + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +const ( + intentTypeRaceNameEligible = "lobby.race_name.registration_eligible" + intentTypeRaceNameDenied = "lobby.race_name.registration_denied" + intentTypeRaceNameRegistered = "lobby.race_name.registered" +) + +func TestRaceNameIntentsAcrossCapabilityAndRegistration(t *testing.T) { + h := newLobbyNotificationHarness(t, gmAlwaysOK) + + capableUser := h.ensureUser(t, "race-capable@example.com") + incapableUser := h.ensureUser(t, "race-incapable@example.com") + + gameID := h.adminCreatePublicGame(t, "Race Name Galaxy", + time.Now().Add(48*time.Hour).Unix()) + h.openEnrollment(t, gameID) + + capableApp := h.submitApplication(t, capableUser.UserID, gameID, "Capable") + h.adminApproveApplication(t, gameID, capableApp["application_id"].(string)) + incapableApp := h.submitApplication(t, incapableUser.UserID, gameID, "Incapable") + h.adminApproveApplication(t, gameID, incapableApp["application_id"].(string)) + + h.adminReadyToStart(t, gameID) + h.adminStartGame(t, gameID) + h.publishRuntimeJobSuccess(t, gameID) + + // Wait for runtime job result + GM register-runtime to flip the game + // to `running` before publishing GM stream events. Otherwise the + // `game_finished` transition guard in the gmevents consumer rejects + // the event for an unexpected status. + h.requireGameStatus(t, gameID, "running") + + // First snapshot freezes initial stats for both members. + h.publishGMSnapshotUpdate(t, gameID, []playerTurnStat{ + {UserID: capableUser.UserID, Planets: 1, Population: 100}, + {UserID: incapableUser.UserID, Planets: 1, Population: 100}, + }) + + // game_finished bumps capable user's stats above the initial values + // and leaves the incapable user unchanged. Capability rule is + // `max_planets > initial_planets AND max_population > initial_population`. + h.publishGMGameFinished(t, gameID, []playerTurnStat{ + {UserID: capableUser.UserID, Planets: 10, Population: 1000}, + {UserID: incapableUser.UserID, Planets: 1, Population: 100}, + }) + + // Capability evaluation runs asynchronously after the game_finished + // event is consumed. Wait for the registration_eligible intent to + // appear before attempting the user-side register call: the call only + // succeeds once the pending registration is recorded. + h.requireGameStatus(t, gameID, "finished") + h.waitForIntent(t, intentTypeRaceNameEligible, capableUser.UserID) + + h.userRegisterRaceName(t, capableUser.UserID, gameID, "Capable") + + h.requireIntents(t, + expect(intentTypeApplicationSubmitted, "admin"), + expect(intentTypeApplicationSubmitted, "admin"), + expect(intentTypeMembershipApproved, capableUser.UserID), + expect(intentTypeMembershipApproved, incapableUser.UserID), + expect(intentTypeRaceNameEligible, capableUser.UserID), + expect(intentTypeRaceNameDenied, incapableUser.UserID), + expect(intentTypeRaceNameRegistered, capableUser.UserID), + ) +} + +type playerTurnStat struct { + UserID string `json:"user_id"` + Planets int64 `json:"planets"` + Population int64 `json:"population"` + ShipsBuilt int64 `json:"ships_built"` +} + +func (h *lobbyNotificationHarness) publishGMSnapshotUpdate(t *testing.T, gameID string, stats []playerTurnStat) { + t.Helper() + payload, err := json.Marshal(stats) + require.NoError(t, err) + _, err = h.redis.XAdd(context.Background(), &redis.XAddArgs{ + Stream: h.gmEventsStream, + Values: map[string]any{ + "kind": "runtime_snapshot_update", + "game_id": gameID, + "current_turn": "1", + "runtime_status": "healthy", + "engine_health_summary": "ok", + "player_turn_stats": string(payload), + }, + }).Result() + require.NoError(t, err) +} + +func (h *lobbyNotificationHarness) publishGMGameFinished(t *testing.T, gameID string, stats []playerTurnStat) { + t.Helper() + payload, err := json.Marshal(stats) + require.NoError(t, err) + _, err = h.redis.XAdd(context.Background(), &redis.XAddArgs{ + Stream: h.gmEventsStream, + Values: map[string]any{ + "kind": "game_finished", + "game_id": gameID, + "finished_at_ms": strconv.FormatInt(time.Now().UnixMilli(), 10), + "current_turn": "10", + "runtime_status": "finished", + "engine_health_summary": "ok", + "player_turn_stats": string(payload), + }, + }).Result() + require.NoError(t, err) +} + +func (h *lobbyNotificationHarness) requireGameStatus(t *testing.T, gameID, want string) { + t.Helper() + require.Eventuallyf(t, func() bool { + req, err := http.NewRequest(http.MethodGet, + h.lobbyAdminURL+"/api/v1/internal/games/"+gameID, nil) + if err != nil { + return false + } + resp := doRequest(t, req) + if resp.StatusCode != http.StatusOK { + return false + } + var record map[string]any + if err := json.Unmarshal([]byte(resp.Body), &record); err != nil { + return false + } + status, _ := record["status"].(string) + return status == want + }, 15*time.Second, 100*time.Millisecond, + "game %s did not reach status %s", gameID, want) +} + +func (h *lobbyNotificationHarness) waitForIntent(t *testing.T, notificationType, recipient string) { + t.Helper() + require.Eventuallyf(t, func() bool { + entries, err := h.redis.XRange(context.Background(), h.intentsStream, "-", "+").Result() + if err != nil { + return false + } + published := decodePublishedIntents(t, entries) + for _, p := range published { + if p.NotificationType != notificationType { + continue + } + if recipient == "admin" { + if p.AudienceKind == "admin_email" { + return true + } + continue + } + if slices.Contains(p.RecipientUserIDs, recipient) { + return true + } + } + return false + }, 15*time.Second, 100*time.Millisecond, + "intent %s for %s not observed on stream %s", + notificationType, recipient, h.intentsStream) +} + +func (h *lobbyNotificationHarness) userRegisterRaceName(t *testing.T, userID, sourceGameID, raceName string) { + t.Helper() + resp := postJSON(t, + h.lobbyPublicURL+"/api/v1/lobby/race-names/register", + map[string]any{ + "race_name": raceName, + "source_game_id": sourceGameID, + }, + http.Header{"X-User-Id": []string{userID}}) + require.Equalf(t, http.StatusOK, resp.StatusCode, "register race name: %s", resp.Body) +} diff --git a/integration/lobbyuser/lobby_user_test.go b/integration/lobbyuser/lobby_user_test.go new file mode 100644 index 0000000..3c0ac56 --- /dev/null +++ b/integration/lobbyuser/lobby_user_test.go @@ -0,0 +1,325 @@ +// Package lobbyuser_test exercises the synchronous Lobby → User Service +// eligibility boundary by running both binaries in-process against a real +// Redis container. The Game Master client surface is satisfied by an +// inline httptest stub because the eligibility flow does not touch GM. +package lobbyuser_test + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "maps" + "net/http" + "net/http/httptest" + "testing" + "time" + + "galaxy/integration/internal/harness" + + "github.com/stretchr/testify/require" +) + +func TestEligibilityCapturedOnApplication(t *testing.T) { + h := newLobbyUserHarness(t) + + user := h.ensureUser(t, "happy@example.com") + gameID := h.adminCreatePublicGame(t, "Happy Path Galaxy", time.Now().Add(48*time.Hour).Unix()) + h.openEnrollment(t, gameID) + + app := h.submitApplicationExpectStatus(t, user.UserID, gameID, "PilotAurora", http.StatusCreated) + + require.NotEmpty(t, app["application_id"]) + require.Equal(t, gameID, app["game_id"]) + require.Equal(t, user.UserID, app["applicant_user_id"]) + require.Equal(t, "PilotAurora", app["race_name"]) + require.Equal(t, "submitted", app["status"]) +} + +func TestEligibilityRejectedForPermanentlyBlockedUser(t *testing.T) { + h := newLobbyUserHarness(t) + + user := h.ensureUser(t, "blocked@example.com") + h.applyPermanentBlock(t, user.UserID) + + gameID := h.adminCreatePublicGame(t, "Block Galaxy", time.Now().Add(48*time.Hour).Unix()) + h.openEnrollment(t, gameID) + + body := h.submitApplicationExpectStatus(t, user.UserID, gameID, "PilotEclipse", http.StatusUnprocessableEntity) + requireErrorCode(t, body, "eligibility_denied") +} + +func TestEligibilityRejectedForUnknownUser(t *testing.T) { + h := newLobbyUserHarness(t) + + gameID := h.adminCreatePublicGame(t, "Unknown Galaxy", time.Now().Add(48*time.Hour).Unix()) + h.openEnrollment(t, gameID) + + body := h.submitApplicationExpectStatus(t, "user-does-not-exist", gameID, "PilotPhantom", http.StatusUnprocessableEntity) + requireErrorCode(t, body, "eligibility_denied") +} + +func TestEligibilityFailsWhenUserServiceDown(t *testing.T) { + h := newLobbyUserHarness(t) + + user := h.ensureUser(t, "transient@example.com") + gameID := h.adminCreatePublicGame(t, "Transient Galaxy", time.Now().Add(48*time.Hour).Unix()) + h.openEnrollment(t, gameID) + + h.userServiceProcess.Stop(t) + + body := h.submitApplicationExpectStatus(t, user.UserID, gameID, "PilotOutage", http.StatusServiceUnavailable) + requireErrorCode(t, body, "service_unavailable") +} + +type lobbyUserHarness struct { + userServiceURL string + lobbyPublicURL string + lobbyAdminURL string + + gmStub *httptest.Server + + userServiceProcess *harness.Process + lobbyProcess *harness.Process +} + +type ensureByEmailResponse struct { + Outcome string `json:"outcome"` + UserID string `json:"user_id"` +} + +func newLobbyUserHarness(t *testing.T) *lobbyUserHarness { + t.Helper() + + redisRuntime := harness.StartRedisContainer(t) + + gmStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + t.Cleanup(gmStub.Close) + + userServiceAddr := harness.FreeTCPAddress(t) + lobbyPublicAddr := harness.FreeTCPAddress(t) + lobbyInternalAddr := harness.FreeTCPAddress(t) + + userServiceBinary := harness.BuildBinary(t, "userservice", "./user/cmd/userservice") + lobbyBinary := harness.BuildBinary(t, "lobby", "./lobby/cmd/lobby") + + userServiceProcess := harness.StartProcess(t, "userservice", userServiceBinary, map[string]string{ + "USERSERVICE_LOG_LEVEL": "info", + "USERSERVICE_INTERNAL_HTTP_ADDR": userServiceAddr, + "USERSERVICE_REDIS_ADDR": redisRuntime.Addr, + "OTEL_TRACES_EXPORTER": "none", + "OTEL_METRICS_EXPORTER": "none", + }) + waitForUserServiceReady(t, userServiceProcess, "http://"+userServiceAddr) + + lobbyProcess := harness.StartProcess(t, "lobby", lobbyBinary, map[string]string{ + "LOBBY_LOG_LEVEL": "info", + "LOBBY_PUBLIC_HTTP_ADDR": lobbyPublicAddr, + "LOBBY_INTERNAL_HTTP_ADDR": lobbyInternalAddr, + "LOBBY_REDIS_ADDR": redisRuntime.Addr, + "LOBBY_USER_SERVICE_BASE_URL": "http://" + userServiceAddr, + "LOBBY_GM_BASE_URL": gmStub.URL, + "OTEL_TRACES_EXPORTER": "none", + "OTEL_METRICS_EXPORTER": "none", + }) + harness.WaitForHTTPStatus(t, lobbyProcess, "http://"+lobbyInternalAddr+"/readyz", http.StatusOK) + + return &lobbyUserHarness{ + userServiceURL: "http://" + userServiceAddr, + lobbyPublicURL: "http://" + lobbyPublicAddr, + lobbyAdminURL: "http://" + lobbyInternalAddr, + gmStub: gmStub, + userServiceProcess: userServiceProcess, + lobbyProcess: lobbyProcess, + } +} + +func (h *lobbyUserHarness) ensureUser(t *testing.T, email string) ensureByEmailResponse { + t.Helper() + + resp := postJSON(t, h.userServiceURL+"/api/v1/internal/users/ensure-by-email", map[string]any{ + "email": email, + "registration_context": map[string]string{ + "preferred_language": "en", + "time_zone": "Europe/Kaliningrad", + }, + }, nil) + + var out ensureByEmailResponse + requireJSONStatus(t, resp, http.StatusOK, &out) + require.Equal(t, "created", out.Outcome) + require.NotEmpty(t, out.UserID) + return out +} + +func (h *lobbyUserHarness) applyPermanentBlock(t *testing.T, userID string) { + t.Helper() + + resp := postJSON(t, h.userServiceURL+"/api/v1/internal/users/"+userID+"/sanctions/apply", map[string]any{ + "sanction_code": "permanent_block", + "scope": "platform", + "reason_code": "terminal_policy_violation", + "actor": map[string]string{"type": "admin", "id": "admin-1"}, + "applied_at": time.Now().UTC().Format(time.RFC3339), + }, nil) + require.Equalf(t, http.StatusOK, resp.StatusCode, "apply permanent_block: %s", resp.Body) +} + +func (h *lobbyUserHarness) adminCreatePublicGame(t *testing.T, name string, enrollmentEndsAt int64) string { + t.Helper() + + resp := postJSON(t, h.lobbyAdminURL+"/api/v1/lobby/games", map[string]any{ + "game_name": name, + "game_type": "public", + "min_players": 2, + "max_players": 4, + "start_gap_hours": 6, + "start_gap_players": 1, + "enrollment_ends_at": enrollmentEndsAt, + "turn_schedule": "0 18 * * *", + "target_engine_version": "1.0.0", + }, nil) + require.Equalf(t, http.StatusCreated, resp.StatusCode, "admin create game: %s", resp.Body) + + var record map[string]any + require.NoError(t, json.Unmarshal([]byte(resp.Body), &record)) + gameID, ok := record["game_id"].(string) + require.True(t, ok, "game_id missing in admin create response: %s", resp.Body) + return gameID +} + +func (h *lobbyUserHarness) openEnrollment(t *testing.T, gameID string) { + t.Helper() + + resp := postJSON(t, h.lobbyAdminURL+"/api/v1/lobby/games/"+gameID+"/open-enrollment", nil, nil) + require.Equalf(t, http.StatusOK, resp.StatusCode, "open enrollment: %s", resp.Body) +} + +func (h *lobbyUserHarness) submitApplicationExpectStatus(t *testing.T, userID, gameID, raceName string, want int) map[string]any { + t.Helper() + + resp := postJSON(t, h.lobbyPublicURL+"/api/v1/lobby/games/"+gameID+"/applications", map[string]any{ + "race_name": raceName, + }, http.Header{"X-User-Id": []string{userID}}) + require.Equalf(t, want, resp.StatusCode, "submit application: %s", resp.Body) + + var body map[string]any + if resp.Body != "" { + require.NoError(t, json.Unmarshal([]byte(resp.Body), &body)) + } + return body +} + +func waitForUserServiceReady(t *testing.T, process *harness.Process, baseURL string) { + t.Helper() + + client := &http.Client{Timeout: 250 * time.Millisecond} + t.Cleanup(client.CloseIdleConnections) + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + req, err := http.NewRequest(http.MethodGet, baseURL+"/api/v1/internal/users/user-readiness-probe/exists", nil) + require.NoError(t, err) + + response, err := client.Do(req) + if err == nil { + _, _ = io.Copy(io.Discard, response.Body) + response.Body.Close() + if response.StatusCode == http.StatusOK { + return + } + } + + time.Sleep(25 * time.Millisecond) + } + + t.Fatalf("wait for userservice readiness: timeout\n%s", process.Logs()) +} + +type httpResponse struct { + StatusCode int + Body string + Header http.Header +} + +func postJSON(t *testing.T, url string, body any, header http.Header) httpResponse { + t.Helper() + + var reader io.Reader + if body != nil { + payload, err := json.Marshal(body) + require.NoError(t, err) + reader = bytes.NewReader(payload) + } + + req, err := http.NewRequest(http.MethodPost, url, reader) + require.NoError(t, err) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + maps.Copy(req.Header, header) + return doRequest(t, req) +} + +func doRequest(t *testing.T, request *http.Request) httpResponse { + t.Helper() + + client := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + DisableKeepAlives: true, + }, + } + t.Cleanup(client.CloseIdleConnections) + + response, err := client.Do(request) + require.NoError(t, err) + defer response.Body.Close() + + payload, err := io.ReadAll(response.Body) + require.NoError(t, err) + + return httpResponse{ + StatusCode: response.StatusCode, + Body: string(payload), + Header: response.Header.Clone(), + } +} + +func requireJSONStatus(t *testing.T, response httpResponse, wantStatus int, target any) { + t.Helper() + + require.Equalf(t, wantStatus, response.StatusCode, "unexpected status, body=%s", response.Body) + if target != nil { + require.NoError(t, decodeStrictJSON([]byte(response.Body), target)) + } +} + +func decodeStrictJSON(payload []byte, target any) error { + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return errors.New("unexpected trailing JSON input") + } + return err + } + + return nil +} + +func requireErrorCode(t *testing.T, body map[string]any, want string) { + t.Helper() + require.NotNil(t, body, "error response body must not be empty") + + envelope, ok := body["error"].(map[string]any) + require.Truef(t, ok, "expected error envelope, got %v", body) + require.Equalf(t, want, envelope["code"], "expected error code %q, got %v", want, envelope["code"]) +} diff --git a/lobby/PLAN.md b/lobby/PLAN.md new file mode 100644 index 0000000..f04360a --- /dev/null +++ b/lobby/PLAN.md @@ -0,0 +1,1443 @@ +# Game Lobby Service Implementation Plan + +This plan has been already implemented and stays here for historical reasons. + +It should NOT be threated as source of truth for service functionality. + +## Summary + +This plan builds `Game Lobby Service` as the platform source of truth for game +sessions, enrollment, membership, the game start sequence, and the +platform-wide `Race Name Directory` (RND). +It integrates synchronously with `User Service` for eligibility checks and +`Game Master` for runtime registration, and asynchronously with `Runtime Manager` +via Redis Streams for start jobs, with `Game Master` via Redis Streams for +runtime updates, and with `User Service` via Redis Streams for user lifecycle +events (permanent block, account deletion) that trigger cascade release of all +race names owned by the user. + +The RND supersedes the simple per-name reservation model of Stage 09. It owns +two kinds of bindings: + +- **registered** — platform-unique permanent names owned by one user, + count-bounded per tariff (`max_registered_race_names`); +- **reservation** — per-game holding that survives until the game finishes, + then either escalates to a 30-day `pending_registration` (when capability is + satisfied) or is released immediately. + +`User Service` simultaneously drops its single-valued `race_name` concept in +favour of two stable fields: `user_name` (immutable auto-generated handle) and +`display_name` (mutable free-form text). All anti-fraud canonicalization moves +from `User Service` into `Lobby RND`. + +## Global Rules + +- Keep platform game state strictly in `Game Lobby`; never duplicate it in + `Game Master` beyond the allowed denormalized snapshot. +- Preserve all status transition invariants; no transition fires without an + explicit allowed trigger. +- Keep the Race Name Directory behind a port interface from the first commit. +- RND is the sole platform source of truth for in-game `race_name` values. + `User Service` owns `user_name` (immutable handle) and `display_name` (free + text), never `race_name`. +- RND reservations are keyed by `(game_id, canonical_key)`. One user may hold + the same name simultaneously across multiple active games. A name is + considered taken for another user when any `registered`, active + `reservation`, or `pending_registration` by a different user exists on the + same canonical key. +- Canonical key logic (lowercase + frozen confusable-pair policy) lives in + `lobby/internal/domain/racename/policy.go`, not in `User Service`. +- Post-game capability is evaluated by `Lobby` at `game_finished`: + `capable = max_planets > initial_planets AND max_population > initial_population`. + Capable reservations are moved to `pending_registration` with + `eligible_until = finished_at + 30 days`; incapable reservations are + released immediately. +- Registration is user-initiated via `lobby.race_name.register`; it consumes + one tariff slot. Tariff downgrade never revokes existing registrations. +- Cascade release (`RND.ReleaseAllByUser`) runs when `Lobby` consumes a + `permanent_blocked` or `deleted` event from `user:lifecycle_events`. +- Never publish a notification intent after rolling back business state; always + publish after successful commit. +- Use synchronous internal REST only where the architecture document fixes a + synchronous interaction (`Game Lobby → User Service`, + `Game Lobby → Game Master` for registration). +- Use Redis Streams for all other cross-service propagation. +- Keep enrollment automation and pending-registration expiration idempotent; + a second tick over the same conditions must produce no side effects. +- Design Redis-backed stores behind port interfaces to keep a future SQL + migration and a future dedicated `Race Name Service` possible. Replacing the + RND adapter must require no domain or service changes. + +## Suggested Module Structure + +```text +lobby/ +├── cmd/ +│ └── lobby/ +│ └── main.go +│ +├── internal/ +│ ├── app/ +│ │ ├── runtime.go +│ │ ├── bootstrap.go +│ │ └── wiring.go +│ │ +│ ├── config/ +│ │ ├── config.go +│ │ ├── env.go +│ │ └── validation.go +│ │ +│ ├── domain/ +│ │ ├── game/ +│ │ │ ├── model.go +│ │ │ ├── status.go +│ │ │ ├── transitions.go +│ │ │ └── errors.go +│ │ ├── application/ +│ │ │ ├── model.go +│ │ │ ├── status.go +│ │ │ └── errors.go +│ │ ├── invite/ +│ │ │ ├── model.go +│ │ │ ├── status.go +│ │ │ └── errors.go +│ │ ├── membership/ +│ │ │ ├── model.go +│ │ │ ├── status.go +│ │ │ └── errors.go +│ │ └── common/ +│ │ ├── ids.go +│ │ └── types.go +│ │ +│ ├── domain/ +│ │ ├── racename/ +│ │ │ ├── policy.go +│ │ │ ├── policy_test.go +│ │ │ └── types.go +│ │ └── … (game, application, invite, membership, common as before) +│ │ +│ ├── ports/ +│ │ ├── gamestore.go +│ │ ├── applicationstore.go +│ │ ├── invitestore.go +│ │ ├── membershipstore.go +│ │ ├── racenamedir.go +│ │ ├── gameturnstatsstore.go +│ │ ├── userservice.go +│ │ ├── userlifecyclestream.go +│ │ ├── gmclient.go +│ │ └── runtimemanager.go +│ │ +│ ├── adapters/ +│ │ ├── redisstate/ +│ │ │ ├── gamestore.go +│ │ │ ├── applicationstore.go +│ │ │ ├── invitestore.go +│ │ │ ├── membershipstore.go +│ │ │ ├── racenamedir.go +│ │ │ └── gameturnstatsstore.go +│ │ ├── racenamestub/ +│ │ │ └── directory.go +│ │ ├── userservice/ +│ │ │ └── client.go +│ │ └── gmclient/ +│ │ └── client.go +│ │ +│ ├── service/ +│ │ ├── creategame/ +│ │ ├── updategame/ +│ │ ├── openenrollment/ +│ │ ├── cancelgame/ +│ │ ├── manualreadytostart/ +│ │ ├── startgame/ +│ │ ├── retrystartgame/ +│ │ ├── pausegame/ +│ │ ├── resumegame/ +│ │ ├── submitapplication/ +│ │ ├── approveapplication/ +│ │ ├── rejectapplication/ +│ │ ├── createinvite/ +│ │ ├── redeeminvite/ +│ │ ├── declineinvite/ +│ │ ├── revokeinvite/ +│ │ ├── removemember/ +│ │ ├── blockmember/ +│ │ ├── registerracename/ +│ │ ├── listmyracenames/ +│ │ ├── capabilityevaluation/ +│ │ ├── getgame/ +│ │ ├── listgames/ +│ │ └── listmemberships/ +│ │ +│ ├── worker/ +│ │ ├── enrollmentautomation/ +│ │ ├── runtimejobresult/ +│ │ ├── gmevents/ +│ │ ├── pendingregistration/ +│ │ └── userlifecycle/ +│ │ +│ ├── api/ +│ │ ├── publichttp/ +│ │ └── internalhttp/ +│ │ +│ ├── telemetry/ +│ └── logging/ +│ +├── api/ +│ ├── public-openapi.yaml +│ └── internal-openapi.yaml +│ +├── README.md +├── PLAN.md +└── go.mod +``` + +## ~~Stage 01.~~ Update ARCHITECTURE.md + +Status: implemented as part of the planning task that produced this file. + +Goal: + +- reconcile `ARCHITECTURE.md` with all decisions made during planning + +Tasks: + +- Replace the Lobby status model block: remove `enrollment_closed`, add `start_failed`. +- Add enrollment rules section documenting `min_players`, `max_players`, + `start_gap_hours`, `start_gap_players`, `enrollment_ends_at`, and the three + auto-transition paths. +- Update private game joining rule: redeeming an invite creates active membership + immediately without a separate owner-approval step. +- Add Race Name Directory section. +- Add `Game Master → Game Lobby` runtime snapshot stream to the fixed asynchronous + interactions list. + +Exit criteria: + +- `ARCHITECTURE.md` accurately reflects the status model, enrollment rules, Race + Name Directory policy, and GM→Lobby transport used throughout this plan and README. + +## ~~Stage 01R.~~ ARCHITECTURE.md — Race Name Directory expansion + +Status: implemented — see `lobby/docs/stage01R-architecture-rnd-expansion.md` +and the corresponding updates across §3 User Service, §7 Game Lobby, §8 Game +Master (new «Runtime snapshot publishing» subsection), §11 Billing Service, +and «Fixed asynchronous interactions» in `ARCHITECTURE.md`. + +Revision of Stage 01 for the two-tier Race Name Directory and the adjacent +`User Service` refactor. + +Tasks: + +- Rewrite §7 Race Name Directory section: registered vs reservation, canonical + key with confusable-pair policy hosted in Lobby, capability gating at game + finish, 30-day post-game registration window, cascade release on + `permanent_block` / `DeleteUser`. +- Update §3 User Service section: remove `race_name` from owned state; add + `user_name`, `display_name`, `permanent_block` sanction, `DeleteUser` + endpoint, `max_registered_race_names` in eligibility snapshot. +- Update §7 and §8: `runtime_snapshot_update` in `gm:lobby_events` carries + `player_turn_stats` (`planets`, `population`, `ships_built` per user); Lobby + maintains per-game/per-user stats aggregate. +- Update §11 Billing Service: tariff changes affect only *new* registrations. +- Add `User Service → Game Lobby` to «Fixed asynchronous interactions» as + `user:lifecycle_events` (permanent_blocked, deleted). + +Exit criteria: + +- `ARCHITECTURE.md` matches the locked RND design; no contradictions with + `lobby/README.md` or `user/README.md`. + +## ~~Stage 02.~~ Freeze Game Record Vocabulary + +Status: implemented — see `lobby/README.md` sections Game Record Model through +Enrollment Rules. + +Goal: + +- eliminate all ambiguity in the game entity before writing Go code + +Tasks: + +- Confirm all game record fields (names, types, validation rules) in `README.md`. +- Confirm the full status set and every allowed transition with its trigger. +- Confirm enrollment auto-transition logic (deadline, gap) in writing. +- Confirm field immutability rules: which fields are editable in which statuses. + +Exit criteria: + +- `lobby/README.md` sections Game Record Model, Status vocabulary, + Status transition table, and Enrollment Rules contain no unresolved questions. + +## ~~Stage 03.~~ Freeze Invite, Application, and Membership Vocabulary + +Status: implemented — see `lobby/README.md` sections Application Lifecycle, +Invite Lifecycle, Membership Model, and Race Name Directory. + +Goal: + +- lock the three participant entity schemas before writing persistence code + +Tasks: + +- Confirm all fields for `Application`, `Invite`, and `Membership` in `README.md`. +- Confirm state machines and allowed transitions for each entity. +- Confirm that public games use applications only and private games use invites only. +- Confirm Race Name Directory port interface signature and stub behavior. +- Confirm that `lobby.invite.revoked` and `lobby.invite.declined` produce no + notification in v1. + +Exit criteria: + +- `lobby/README.md` sections Application Lifecycle, Invite Lifecycle, Membership + Model, and Race Name Directory contain no unresolved questions. +- Notification intent shapes in `README.md` are consistent with the frozen catalog + in `notification/README.md`. + +## ~~Stage 03R.~~ README.md — Two-tier RND + stats + new APIs + +Status: implemented — see `lobby/docs/stage03R-readme-rnd-surface.md` and +`lobby/README.md` sections Race Name Directory, Membership Model, Runtime +Snapshot, Notification Contracts, Error Model, Configuration, Redis Logical +Model, and Observability. + +Revision of Stage 03. + +Tasks: + +- Rewrite §Race Name Directory: port interface with `Canonicalize`, `Check`, + `Reserve(game_id, user_id, race_name)`, `ReleaseReservation`, + `MarkPendingRegistration`, `ExpirePendingRegistrations`, `Register`, + `ListRegistered`, `ListPendingRegistrations`, `ListReservations`, + `ReleaseAllByUser`; new sentinel errors. +- Update §Membership Model to store `canonical_key` alongside `race_name`. +- Update §Runtime Snapshot: add `player_turn_stats` (initial + current + `planets`, `population`, `ships_built` per user). Lobby caches aggregates + under `lobby:game_turn_stats::`. +- Add §Race Name Registration flow: + - capability evaluation at `game_finished`; + - `pending_registration` window = 30 days; + - `lobby.race_name.register` message type with tariff + capability checks; + - fast-path self-service read `lobby.race_names.list`. +- Extend §Notification Contracts with + `lobby.race_name.registration_eligible`, `lobby.race_name.registered`, + `lobby.race_name.registration_denied` (optional). +- Extend §Error Model with `race_name_registration_quota_exceeded`, + `race_name_pending_window_expired`, `race_name_capability_not_met`, + `race_name_permanent_blocked`. +- Extend §Configuration: `LOBBY_RACE_NAME_EXPIRATION_INTERVAL` default `1h`, + `LOBBY_PENDING_REGISTRATION_TTL_HOURS` default `720`, + `LOBBY_USER_LIFECYCLE_STREAM` default `user:lifecycle_events`, + `LOBBY_RACE_NAME_DIRECTORY_BACKEND` default `redis`. +- Extend §Redis Logical Model with RND keys + (`lobby:race_names:registered:`, + `lobby:race_names:reservations::`, + `lobby:race_names:user_registered:`, + `lobby:race_names:user_reservations:`, + `lobby:race_names:pending_index`, + `lobby:race_names:canonical_lookup:`, + `lobby:game_turn_stats::`). +- Extend §Observability with `lobby.race_name.outcomes`, + `lobby.pending_registration.expirations`, + `lobby.user_lifecycle.cascade_releases`. + +Exit criteria: + +- `lobby/README.md` describes the full RND surface; downstream code stages can + reference it without further ambiguity. + +## ~~Stage 04.~~ Define OpenAPI Contracts + +Status: implemented — see `lobby/api/public-openapi.yaml`, +`lobby/api/internal-openapi.yaml`, and `lobby/docs/stage04-openapi-decisions.md`. + +Goal: + +- produce stable REST contract files before wiring HTTP handlers + +Tasks: + +- Add `lobby/api/public-openapi.yaml` covering all message types from the + message type catalog in `README.md`. +- Add `lobby/api/internal-openapi.yaml` covering GM registration and admin + endpoints. +- Freeze request and response shapes for all routes. +- Document authorization expectations per route (admin, owner, any member, etc.). + +Exit criteria: + +- both OpenAPI files are syntactically valid and cover every route in the message + type catalog. + +## ~~Stage 05.~~ Freeze Notification Intent Publishing Rules + +Status: implemented — see `lobby/README.md` Notification Contracts section and +the existing `galaxy/notificationintent` module. + +Goal: + +- confirm all notification triggers before service and worker code touches + `galaxy/notificationintent` + +Tasks: + +- Map every trigger in `README.md` Notification Contracts to the correct + constructor in `galaxy/notificationintent`. +- Confirm that `NewPublicLobbyApplicationSubmittedIntent` is the only path for + `lobby.application.submitted` in v1. +- Confirm `lobby.invite.expired` is published per-invite (not batched into one + intent). + +Exit criteria: + +- every notification trigger has an identified constructor in + `galaxy/notificationintent`; no new constructor is needed for v1. + +## ~~Stage 06.~~ Module Skeleton + +Status: implemented — see `lobby/cmd/lobby`, +`lobby/internal/{config,logging,telemetry,app,api/publichttp,api/internalhttp}`, +and `lobby/docs/stage06-skeleton-decisions.md`. + +Goal: + +- create the runnable service process with no business logic + +Tasks: + +- Add `go.mod` dependencies: `redis/go-redis/v9` with `redisotel`, + the `go.opentelemetry.io/otel` v1.43 stack with `otelhttp`, + `testcontainers-go` together with `modules/redis`, + `alicebob/miniredis/v2`, and `stretchr/testify`. The skeleton uses the + Go standard library `net/http`; no web framework is added. This mirrors + the dependency set used by `mail` and `notification`. +- Add `cmd/lobby/main.go` with signal handling and context propagation. +- Add `internal/config/` with env loading, validation, and `DefaultConfig()`. +- Add `internal/app/runtime.go`: Redis startup check, structured logger, + telemetry provider, graceful shutdown, composed through the generic + `app.Component` lifecycle in `internal/app/app.go` and helpers in + `internal/app/bootstrap.go`. +- Add `internal/api/publichttp/` and `internal/api/internalhttp/` routers + with `GET /healthz` and `GET /readyz` only. +- Wire both HTTP listeners in `app/runtime.go` through `app.New(...)`. + +Exit criteria: + +- `go build ./...` succeeds with no errors. +- `go test ./...` passes (no tests yet beyond smoke). +- process starts with a valid Redis address and serves `/healthz` on both ports. +- process exits cleanly on `SIGTERM`. + +## ~~Stage 07.~~ Game Domain Model and Redis Store + +Status: implemented — see `lobby/internal/domain/{common,game}`, +`lobby/internal/ports/gamestore.go`, `lobby/internal/adapters/redisstate/`, +and `lobby/docs/stage07-game-store-decisions.md`. + +Goal: + +- implement the game entity with status enforcement and Redis persistence + +Tasks: + +- Add `internal/domain/game/model.go`: all game fields, value types, constructor + `New(...)` that validates all required fields. +- Add `internal/domain/game/status.go`: `Status` type, all status constants, + `AllowedTransitions` map, `Transition(from, to, trigger)` function that returns + an error for invalid transitions. +- Add `internal/domain/game/errors.go`: sentinel and typed errors + (`ErrNotFound`, `ErrConflict`, `ErrInvalidTransition`). +- Add `internal/ports/gamestore.go`: port interface + (`Get`, `GetByStatus`, `Save`, `UpdateStatus`, `UpdateRuntimeSnapshot`). +- Add `internal/adapters/redisstate/gamestore.go`: Redis implementation using + JSON serialization. +- Add `internal/adapters/redisstate/gamestore_test.go`: tests using + `miniredis`; cover create, get, update, status transition, snapshot update. + +Exit criteria: + +- all game store tests pass with `go test ./... -race`. +- invalid status transitions return an error at the domain level without touching + the store. + +## ~~Stage 08.~~ Application, Invite, and Membership Stores + +Status: implemented — see +`lobby/internal/domain/{application,invite,membership}`, +`lobby/internal/ports/{applicationstore,invitestore,membershipstore}.go`, +`lobby/internal/adapters/redisstate/{codecs_application,codecs_invite,codecs_membership,applicationstore,invitestore,membershipstore}.go`, +and `lobby/docs/stage08-store-decisions.md`. + +Goal: + +- add Redis-backed persistence for the three participant entities + +Tasks: + +- Add domain packages: `internal/domain/application/`, `internal/domain/invite/`, + `internal/domain/membership/` each with `model.go`, `status.go`, `errors.go`. +- Add port interfaces: `internal/ports/applicationstore.go`, + `internal/ports/invitestore.go`, `internal/ports/membershipstore.go`. +- Add Redis adapters for each entity under `internal/adapters/redisstate/`. +- Add tests for each adapter using `miniredis`. +- Enforce single active application per user per game at the store level. + +Exit criteria: + +- all three entity types persist, load, and list correctly. +- `go test ./... -race` passes. + +## ~~Stage 09.~~ Race Name Directory Port and Stub + +Status: implemented — see `lobby/internal/ports/racenamedir.go`, +`lobby/internal/adapters/racenamestub/`, `lobby/internal/app/wiring.go`, +and `lobby/docs/stage09-racenamedir-decisions.md`. + +Goal: + +- wire the Race Name Directory abstraction from the start so no code ever + imports a concrete implementation directly + +Tasks: + +- Add `internal/ports/racenamedir.go`: `RaceNameDirectory` interface + (`Reserve`, `Release`, `Check`) with `ErrNameTaken` sentinel. +- Add `internal/adapters/racenamestub/directory.go`: in-memory `sync.Map` + implementation. +- Wire the stub in `internal/app/wiring.go`. +- Add unit tests for the stub covering reserve, release, check, and uniqueness + invariant. + +Exit criteria: + +- `racenamestub` tests pass. +- all future service code refers to `ports.RaceNameDirectory`; no direct + reference to `racenamestub` outside the wiring layer. + +## ~~Stage 09R.~~ Race Name Directory: two-tier model and Redis adapter + +Status: implemented — see `lobby/docs/stage09R-racenamedir-decisions.md`, +`lobby/internal/ports/racenamedir.go`, +`lobby/internal/adapters/redisstate/racenamedir.go` (with +`racenamedir_lua.go` and `codecs_racename.go`), +`lobby/internal/adapters/racenamestub/directory.go`, +`lobby/internal/ports/racenamedirtest/suite.go`, and the +`RaceNameDirectoryConfig` group wired through +`internal/config/{config,env,validation}.go` and `internal/app/wiring.go`. + +Replaces Stage 09's port and stub with the two-tier directory. Depends on +Stage 21 so the confusable-pair policy can be lifted out of `User Service` +without churn. + +Tasks: + +- Rewrite `lobby/internal/ports/racenamedir.go` under the new interface (see + Stage 03R) with sentinels `ErrNameTaken`, `ErrPendingExpired`, + `ErrPendingMissing`, `ErrInvalidName`, `ErrQuotaExceeded`. +- Add `lobby/internal/domain/racename/policy.go`: canonical key generation + (lowercase + frozen confusable-pair rules ported from + `user/internal/ports/race_name_policy.go`), `ValidateTypeName` integration + from `pkg/util`. +- Implement `lobby/internal/adapters/redisstate/racenamedir.go` atop the Redis + key layout in Stage 03R; tests use `miniredis`. +- Rewrite `lobby/internal/adapters/racenamestub/directory.go` against the new + interface so unit tests that do not need Redis stay fast. +- Wire adapter selection in `internal/app/wiring.go` via + `LOBBY_RACE_NAME_DIRECTORY_BACKEND` (`redis` default, `stub` for tests). +- Port the User Service `RaceNameReservation`/`RaceNamePolicy` tests and their + golden fixtures to `lobby/internal/domain/racename/`. + +Exit criteria: + +- Redis adapter and stub both pass the same behavioural test suite + (interface-level table tests). +- Idempotent `Reserve` by the same user under the same game returns nil. +- `Check` exposes `(taken, holder_user_id, kind)` consistent with Redis state. +- `MarkPendingRegistration` leaves the existing reservation accessible to + `ListPendingRegistrations` and to `ExpirePendingRegistrations`. +- `ReleaseAllByUser` clears every registered, reservation, and pending entry + for a user atomically (Lua or pipelined transaction). +- confusable-pair test fixtures from `user/internal/adapters/…` run in the new + package unchanged. + +## ~~Stage 10.~~ Game Creation and Draft Management + +Status: implemented — see `lobby/docs/stage10-game-lifecycle-decisions.md`, +`lobby/internal/service/{shared,creategame,updategame,openenrollment,cancelgame}`, +`lobby/internal/ports/idgenerator.go`, `lobby/internal/adapters/{idgen,gamestub}`, +and the extended `lobby/internal/api/{publichttp,internalhttp}` handlers. + +Goal: + +- implement the initial game lifecycle operations with no enrollment logic yet + +Tasks: + +- Add `internal/service/creategame/`: validate all game fields, create game in + `draft` status, store via `GameStore`. +- Add `internal/service/updategame/`: allow edits on `draft` and selected fields + on `enrollment_open`; reject all other statuses. +- Add `internal/service/openenrollment/`: `draft → enrollment_open` with + admin/owner authorization check. +- Add `internal/service/cancelgame/`: cancel from `draft`, `enrollment_open`, + `ready_to_start`, `start_failed`; reject from `starting`, `running`, `paused`. +- Wire all four service calls to routes on both HTTP ports. +- Add service-level tests (in-memory stores, no Redis). + +Exit criteria: + +- game creation, update, open-enrollment, and cancel all pass tests. +- unauthorized callers receive `forbidden`. +- invalid transition attempts return `conflict`. + +## ~~Stage 11.~~ Application Flow (Public Games) + +Status: implemented — see `lobby/docs/stage11-application-flow-decisions.md`, +`lobby/internal/service/{submitapplication,approveapplication,rejectapplication}`, +`lobby/internal/ports/{userservice,intentpublisher,gapactivationstore}.go`, +`lobby/internal/adapters/userservice/`, +`lobby/internal/adapters/redisstate/gapactivationstore.go`, +`lobby/internal/adapters/{applicationstub,membershipstub,gapactivationstub,userservicestub,intentpubstub}/`, +the `Membership.canonical_key` field across +`lobby/internal/domain/membership/model.go` and +`lobby/internal/adapters/redisstate/codecs_membership.go`, the +`NewApplicationID`/`NewMembershipID` extensions to +`lobby/internal/adapters/idgen/`, and the new application routes wired +through `lobby/internal/api/{publichttp,internalhttp}/applications.go`. + +Goal: + +- implement the full public-game application lifecycle + +Tasks: + +- Add `internal/ports/userservice.go`: `UserService` interface + (`GetEligibility(ctx, userID) (Eligibility, error)`). +- Add `internal/adapters/userservice/client.go`: HTTP client hitting + `GET /api/v1/internal/users/{user_id}/eligibility`. +- Add `internal/service/submitapplication/`: + - game type must be `public` and status `enrollment_open` + - call `UserService.GetEligibility`; fail if `can_join_game=false` + - call `RaceNameDirectory.Check(raceName, actorUserID)`; fail if name is taken + by another user (returns `name_taken`) or permanent-blocked + - create `Application{status: submitted, canonical_key}` + - publish `lobby.application.submitted` intent via `galaxy/notificationintent` +- Add `internal/service/approveapplication/`: + - call `RaceNameDirectory.Reserve(gameID, userID, raceName)`; idempotent + - create `Membership{status: active, canonical_key}` + - set application `status=approved` + - publish `lobby.membership.approved` intent + - trigger gap window open if `approved_count == max_players` +- Add `internal/service/rejectapplication/`: + - call `RaceNameDirectory.ReleaseReservation(gameID, userID, raceName)` — + safe no-op when no reservation exists for the pair + - set application `status=rejected` + - publish `lobby.membership.rejected` intent +- Wire routes. +- Add service tests with in-memory stores, stubbed `UserService`, and stub + `RaceNameDirectory`. + +Exit criteria: + +- all three application operations pass tests. +- eligibility denial surfaces as `eligibility_denied` error. +- name conflict surfaces as `name_taken` error. +- all three notifications are published in success paths. + +## ~~Stage 12.~~ Invite Flow (Private Games) + +Status: implemented — see `lobby/docs/stage12-invite-flow-decisions.md`, +`lobby/internal/service/{createinvite,redeeminvite,declineinvite,revokeinvite}`, +the new `NewInviteID` extension across +`lobby/internal/ports/idgenerator.go` and +`lobby/internal/adapters/idgen/`, the in-process +`lobby/internal/adapters/invitestub/` test adapter, and the four invite +routes wired through `lobby/internal/api/publichttp/invites.go`. + +Goal: + +- implement the full private-game invite lifecycle + +Tasks: + +- Add `internal/service/createinvite/`: + - game type must be `private`, status `enrollment_open` + - invitee must not have an active invite or active membership in the game + - create `Invite{status: created, expires_at: game.enrollment_ends_at}` + - publish `lobby.invite.created` intent +- Add `internal/service/redeeminvite/`: + - invite status must be `created`, game status `enrollment_open` + - call `RaceNameDirectory.Check(raceName, actorUserID)`; fail if name is taken + by another user + - call `RaceNameDirectory.Reserve(gameID, userID, raceName)` + - create `Membership{status: active, canonical_key}` + - set invite `status=redeemed` + - publish `lobby.invite.redeemed` intent to owner + - trigger gap window open if `approved_count == max_players` +- Add `internal/service/declineinvite/`: set `status=declined`; no notification. +- Add `internal/service/revokeinvite/`: set `status=revoked`; no notification. +- Wire routes. +- Add service tests. + +Exit criteria: + +- redeem creates active membership without a separate approval step. +- race name is reserved atomically before membership creation. +- `lobby.invite.created` and `lobby.invite.redeemed` are published. +- decline and revoke produce no notification. + +## ~~Stage 13.~~ Enrollment Automation Worker + +Status: implemented — see `lobby/docs/stage13-enrollment-automation-decisions.md`, +`lobby/internal/worker/enrollmentautomation/`, +`lobby/internal/service/manualreadytostart/`, +`lobby/internal/service/shared/closeenrollment.go`, the +`GapActivationStore.Get` extension across +`lobby/internal/ports/gapactivationstore.go`, +`lobby/internal/adapters/redisstate/gapactivationstore.go`, and +`lobby/internal/adapters/gapactivationstub/store.go`, plus the +`POST /api/v1/lobby/games/{game_id}/ready-to-start` routes wired on both +ports through `lobby/internal/api/{publichttp,internalhttp}/ready_to_start.go` +and the worker registration in `lobby/internal/app/{wiring,runtime}.go`. + +Goal: + +- implement all automatic enrollment-to-ready-to-start transitions + +Tasks: + +- Add `internal/worker/enrollmentautomation/worker.go`: + - periodic ticker with `LOBBY_ENROLLMENT_AUTOMATION_INTERVAL` (default `30s`) + - on each tick, load all games in `enrollment_open` status + - for each game check: + 1. deadline: `now >= enrollment_ends_at && approved_count >= min_players` + 2. gap exhaustion: gap window is open and (`now >= gap_activated_at + start_gap_hours` + or `approved_count >= max_players + start_gap_players`) + - on transition to `ready_to_start`: + - atomically expire all `created` invites for the game + - publish `lobby.invite.expired` intents (one per expired invite) +- Add `internal/service/manualreadytostart/`: + - admin/owner command + - require `approved_count >= min_players` + - same expiry and notification side effects as auto-transition +- Add gap window activation: when `approved_count` reaches `max_players`, record + `gap_activated_at` in Redis. +- Add tests using a fake clock; cover all three auto-transition paths and the + boundary condition where the deadline fires but `min_players` is not yet met. + +Exit criteria: + +- all three auto-transition paths are covered by tests. +- invite expiry on enrollment close is tested. +- the worker is idempotent: running twice over the same state produces no + duplicate transitions or notifications. + +## ~~Stage 14.~~ Game Start Flow + +Status: implemented — see `lobby/docs/stage14-game-start-flow-decisions.md`, +`lobby/internal/ports/{runtimemanager,gmclient,streamoffsetstore}.go`, +the `RuntimeBinding` field on `lobby/internal/domain/game/model.go` and +the new `GameStore.UpdateRuntimeBinding` port method, +`lobby/internal/adapters/{runtimemanager,gmclient,redisstate/streamoffsetstore.go,runtimemanagerstub,gmclientstub,streamoffsetstub}/`, +`lobby/internal/service/{startgame,retrystartgame}/`, +`lobby/internal/worker/runtimejobresult/`, the `LOBBY_RUNTIME_STOP_JOBS_STREAM` +env var, the public/internal `start` and `retry-start` HTTP routes, the +removal of the obsolete `register-runtime` endpoint from +`lobby/api/internal-openapi.yaml`, and the `runtime_binding` schema +addition on the `GameRecord` shape across both OpenAPI contracts. + +Goal: + +- implement the full start sequence spanning Runtime Manager and Game Master + +Tasks: + +- Add `internal/ports/runtimemanager.go`: `RuntimeManager` interface + (`PublishStartJob(ctx, gameID string) error`, + `PublishStopJob(ctx, gameID string) error`). +- Add Redis stream adapter for `RuntimeManager` (write-only; publishes to + `runtime:start_jobs`). +- Add `internal/ports/gmclient.go`: `GMClient` interface + (`RegisterGame(ctx, req RegisterGameRequest) error`). +- Add `internal/adapters/gmclient/client.go`: HTTP client for GM registration. +- Add `internal/service/startgame/`: + - validate `ready_to_start` + - set status → `starting` + - publish start job to `RuntimeManager` +- Add `internal/worker/runtimejobresult/consumer.go`: + - consume `runtime:job_results` stream + - on failure result: set status → `start_failed` + - on success result: + - persist `runtime_binding` metadata on game record + - call `GMClient.RegisterGame` synchronously + - on GM success: set status → `running`; set `started_at` + - on GM failure/timeout: set status → `paused`; publish + `lobby.runtime_paused_after_start` intent + - on metadata persistence failure before GM call: publish stop job to + `RuntimeManager`; set status → `start_failed` +- Add `internal/service/retrystartgame/`: `start_failed → ready_to_start`. +- Wire consumer in `app/runtime.go`. +- Add tests with stubbed `RuntimeManager` and `GMClient`; cover all four + outcome paths. + +Exit criteria: + +- success path: game reaches `running` after container start and GM registration. +- paused path: GM unavailability produces `paused` + admin notification. +- failure path: container failure produces `start_failed`. +- orphan container path: metadata failure triggers stop job before `start_failed`. +- all paths covered by `go test ./... -race`. + +## ~~Stage 14A.~~ Initial Player Stats Capture + +Status: implemented — see `lobby/docs/stage14A-game-turn-stats-decisions.md`, +`lobby/internal/ports/gameturnstatsstore.go`, +`lobby/internal/adapters/redisstate/{gameturnstatsstore,codecs_gameturnstats}.go`, +and `lobby/internal/adapters/gameturnstatsstub/`. + +Goal: + +- freeze per-user `initial_planets` / `initial_population` at the first + `runtime_snapshot_update` after `starting → running` + +Tasks: + +- Add `internal/ports/gameturnstatsstore.go`: `GameTurnStatsStore` with + `SaveInitial(ctx, gameID, stats []PlayerInitialStats) error`, + `UpdateMax(ctx, gameID, stats []PlayerObservedStats) error`, + `Load(ctx, gameID) (GameTurnStatsAggregate, error)`, + `Delete(ctx, gameID) error` (invoked after capability evaluation). +- Add `internal/adapters/redisstate/gameturnstatsstore.go` keyed under + `lobby:game_turn_stats::`; tests with `miniredis`. +- Extend the GM event DTO in `internal/worker/gmevents/` to decode + `player_turn_stats`. +- In the consumer, invoke `SaveInitial` once per game (no-op on subsequent + calls to preserve the first observation) and `UpdateMax` on every + `runtime_snapshot_update`. + +Exit criteria: + +- Initial stats do not change on subsequent snapshots. +- `UpdateMax` uses per-metric max semantics (never decreases). +- Idempotent replay of the GM stream produces the same aggregate. + +## ~~Stage 15.~~ GM Runtime Stream Consumer + +Status: implemented — see `lobby/docs/stage15-gm-events-consumer-decisions.md` +and `lobby/internal/worker/gmevents/consumer.go`. The consumer wires the +existing `LOBBY_GM_EVENTS_STREAM` and `LOBBY_GM_EVENTS_READ_BLOCK_TIMEOUT` +configuration through `lobby/internal/app/{wiring,runtime}.go` and hands +off to the Stage 15A capability evaluator on `game_finished`. + +Goal: + +- keep the denormalized runtime snapshot current using GM events and feed + Stage 14A stats + Stage 15A capability evaluation + +Tasks: + +- Add `internal/worker/gmevents/consumer.go`: + - consume `gm:lobby_events` stream + - on `runtime_snapshot_update` event: + - call `GameStore.UpdateRuntimeSnapshot` (turn, status, health) + - call `GameTurnStatsStore.SaveInitial` (first call only) and + `UpdateMax` using `player_turn_stats` (Stage 14A) + - on `game_finished` event: + - apply final snapshot; transition game to `finished`; set `finished_at` + - hand off to Stage 15A capability evaluator before acknowledging offset + - advance stream offset only after successful processing +- Add tests using `miniredis` with fake events; cover snapshot update, + game_finished, and replay idempotency. + +Exit criteria: + +- snapshot updates are applied without changing game status. +- `game_finished` transitions game to `finished`, sets `finished_at`, and + drives capability evaluation before offset advance. +- consumer restarts from the persisted offset without double-processing stats. + +## ~~Stage 15A.~~ Capability Evaluation at Game Finish + +Status: implemented — see `lobby/docs/stage15A-capability-evaluation-decisions.md`, +`lobby/internal/service/capabilityevaluation/service.go`, +`lobby/internal/ports/evaluationguardstore.go`, +`lobby/internal/adapters/redisstate/evaluationguardstore.go`, and +`lobby/internal/adapters/evaluationguardstub/`. Race-name notification +intents are wired through the `RaceNameIntents` port-shim and bound to +`capabilityevaluation.NoopRaceNameIntents{}` until Stage 24 lands the +real publisher. + +Goal: + +- decide per-member capability and resolve each active reservation into + `pending_registration` or immediate release when a game finishes + +Tasks: + +- Add `internal/service/capabilityevaluation/service.go`: + - input: finished game id, final stats aggregate from `GameTurnStatsStore` + - for each active membership: + - `capable = max_planets > initial_planets AND max_population > initial_population` + - capable ⇒ `RND.MarkPendingRegistration(gameID, userID, raceName, + finished_at + 30 days)` + intent + `lobby.race_name.registration_eligible` + - not capable ⇒ `RND.ReleaseReservation(gameID, userID, raceName)` + + (optional) intent `lobby.race_name.registration_denied` + - for `removed` / `blocked` memberships with outstanding reservations: + release immediately + - delete `GameTurnStatsStore` aggregate for the game after evaluation +- Hook the evaluator into `gmevents` consumer after `game_finished` processing. +- Tests for capable / not-capable / mixed rosters, and for idempotency on + replay. + +Exit criteria: + +- every `active` membership of a finished game produces exactly one RND + side effect (mark pending or release). +- replayed `game_finished` events do not mutate RND state after the first + successful evaluation (idempotency guard keyed on game id). +- intents publish only after the RND mutation commits. + +## ~~Stage 16.~~ Paused State Management + +Status: implemented — see `lobby/docs/stage16-paused-state-decisions.md`, +`lobby/internal/service/{pausegame,resumegame}/`, the `Ping` extension +on `lobby/internal/ports/gmclient.go` together with its real +(`lobby/internal/adapters/gmclient/client.go`) and stub +(`lobby/internal/adapters/gmclientstub/client.go`) implementations, +and the public/internal pause/resume handlers wired through +`lobby/internal/api/{publichttp,internalhttp}/pause_resume.go`, +`lobby/internal/app/{wiring,runtime}.go`. + +Goal: + +- implement voluntary pause and resume + +Tasks: + +- Add `internal/service/pausegame/`: + - actor must be admin or owner + - game must be `running` + - transition to `paused` +- Add `internal/service/resumegame/`: + - actor must be admin or owner + - game must be `paused` + - perform a synchronous GM liveness check (`GMClient.Ping` or equivalent) + - on GM reachable: transition to `running` + - on GM unreachable: return `service_unavailable`; game remains `paused` +- Wire routes. +- Add tests; cover GM-unreachable resume attempt. + +Exit criteria: + +- pause and resume operations enforce authorization and status invariants. +- resume does not transition to `running` when GM is unavailable. + +## ~~Stage 17.~~ Member Operations + +Status: implemented — see `lobby/docs/stage17-member-operations-decisions.md`, +`lobby/internal/service/{removemember,blockmember}`, the +`MembershipStore.Delete` extension across `lobby/internal/ports/membershipstore.go`, +`lobby/internal/adapters/redisstate/membershipstore.go`, and +`lobby/internal/adapters/membershipstub/store.go`, plus the public/internal +remove/block handlers wired through +`lobby/internal/api/{publichttp,internalhttp}/memberships.go` and +`lobby/internal/app/{wiring,runtime}.go`. + +Goal: + +- implement member removal and block + +Tasks: + +- Add `internal/service/removemember/`: + - before game start: drop membership; call + `RND.ReleaseReservation(gameID, userID, raceName)` + - after game start: set membership `status=removed`; keep the reservation + intact so `game_finished` evaluation decides its fate (Stage 15A) +- Add `internal/service/blockmember/`: + - set membership `status=blocked` + - race name reservation is preserved; Stage 15A releases it at + `game_finished` +- Wire routes. +- Add tests; cover pre-start and post-start removal semantics, including the + interaction with Stage 15A for post-start remove/block. + +Exit criteria: + +- removal before start releases the reservation immediately. +- removal/block after start keeps the reservation until `game_finished` and + Stage 15A releases it. + +## ~~Stage 17A.~~ Race Name Registration Service + +Status: implemented — see `lobby/docs/stage17A-race-name-registration-decisions.md`, +`lobby/internal/service/registerracename/`, +`lobby/internal/api/publichttp/racenames.go`, the +`writeErrorFromService` extension in +`lobby/internal/api/publichttp/games.go` (with the new +`shared.ErrSubjectNotFound` sentinel), the public OpenAPI surface +update in `lobby/api/public-openapi.yaml`, and the wiring through +`lobby/internal/app/{wiring,runtime}.go`. + +Goal: + +- let a player convert a `pending_registration` reservation into a permanent + registered race name + +Tasks: + +- Add `internal/service/registerracename/`: + - input: `{race_name, source_game_id}`; acting user from `X-User-ID` + - preconditions: + - canonical-key `pending_registration` exists for + `(source_game_id, user_id)` with `eligible_until > now` + - `UserService.GetEligibility` snapshot: active + `max_registered_race_names` > current registered count + (`0` denotes unlimited); `can_update_profile` is not required + - no `permanent_block` on the user + - commit: `RND.Register(source_game_id, user_id, race_name)`; emit intent + `lobby.race_name.registered` +- Wire route `POST /api/v1/lobby/race-names/register` on the public port. +- Tests: happy path, quota exceeded (`race_name_registration_quota_exceeded`), + pending expired (`race_name_pending_window_expired`), pending missing + (`subject_not_found`), permanent-blocked user (`forbidden`). + +Exit criteria: + +- Register call is atomic relative to RND reservation state. +- Quota logic matches the snapshot semantics (free=1, monthly=2, yearly=6, + lifetime=0 marker for unlimited). +- Intent emits only after successful commit. + +## ~~Stage 17B.~~ Pending Registration Expiration Worker + +Status: implemented — see `lobby/docs/stage17B-pending-registration-worker-decisions.md`, +`lobby/internal/worker/pendingregistration/`, the new +`PendingRegistrationConfig` group threaded through +`lobby/internal/config/{config,env,validation}.go`, and the worker +registration in `lobby/internal/app/{wiring,runtime}.go`. + +Goal: + +- release every `pending_registration` whose `eligible_until` has passed + +Tasks: + +- Add `internal/worker/pendingregistration/worker.go`: + - ticker with `LOBBY_RACE_NAME_EXPIRATION_INTERVAL` (default `1h`) + - call `RND.ExpirePendingRegistrations(now)` + - for each expired entry: release the reservation and increment + `lobby.pending_registration.expirations` + - no notification (informational only) +- Tests using a fake clock and `miniredis`: boundary exactly at `eligible_until`, + batch of mixed-age entries, idempotent second tick. + +Exit criteria: + +- running the worker twice over the same state produces no extra side + effects. +- Stage 17A users who act before expiration still succeed (no race with the + worker). + +## ~~Stage 17C.~~ Race Name Self-Service Reads + +Status: implemented — see `lobby/docs/stage17C-race-name-self-service-decisions.md`, +`lobby/internal/service/listmyracenames/`, the new +`GET /api/v1/lobby/my/race-names` route on +`lobby/internal/api/publichttp/racenames.go` with the +`Dependencies.ListMyRaceNames` field on +`lobby/internal/api/publichttp/server.go`, the wiring through +`lobby/internal/app/{wiring,runtime}.go`, the `MyRaceNamesResponse`/ +`PendingRaceName`/`RaceNameReservation` schemas added to +`lobby/api/public-openapi.yaml` (with the matching +`TestPublicSpecFreezesMyRaceNamesContract` in +`lobby/contract_openapi_test.go`), and the expanded +`lobby/README.md` §Race Name self-service section. + +Goal: + +- give the acting user a single view of their registered / pending / active + reservations + +Tasks: + +- Add `internal/service/listmyracenames/`: + - returns `{registered[], pending[], reservations[]}` + - `pending` carries `eligible_until_ms` and `source_game_id` + - `reservations` carries `game_id` and current `game_status` +- Wire `GET /api/v1/lobby/my/race-names`; update `public-openapi.yaml`. +- Visibility test: a user cannot read another user's RND state through this + endpoint. + +Exit criteria: + +- response shape matches `lobby/README.md` §Race Name self-service. +- operation avoids scanning the full RND (uses + `user_registered` / `user_reservations` indexes). + +## ~~Stage 18.~~ Query and Read APIs + +Status: implemented — see `lobby/docs/stage18-query-and-read-apis-decisions.md`, +the six new service packages +`lobby/internal/service/{getgame,listgames,listmemberships,listmygames, +listmyapplications,listmyinvites}/`, the shared pagination helper +`lobby/internal/service/shared/page.go`, the public-port handlers in +`lobby/internal/api/publichttp/{games,memberships,mylists}.go`, the +internal-port handlers in +`lobby/internal/api/internalhttp/{games,memberships}.go`, and the +wiring updates in `lobby/internal/app/{wiring,runtime}.go`. + +Goal: + +- implement all user-facing list and read operations with visibility enforcement + +Tasks: + +- Add `internal/service/getgame/`: + - enforce visibility rules: private game hidden from non-member non-owner users + - return runtime snapshot from denormalized fields +- Add `internal/service/listgames/`: + - public list: `enrollment_open`, `ready_to_start`, `running`, `finished` only + - authenticated user also sees their private game memberships +- Add `internal/service/listmemberships/`: + - admin, owner, or active member may list memberships of a game +- Wire `lobby.my_games.list`, `lobby.my_applications.list`, and + `lobby.my_invites.list` routes. +- Add tests for visibility rules. + +Exit criteria: + +- private game is not returned for non-member non-owner callers. +- public draft game is excluded from the public list. +- lists return correct entities for the authenticated user. + +## ~~Stage 19.~~ Observability + +Status: implemented — see `lobby/docs/stage19-observability-decisions.md`, +the extended `lobby/internal/telemetry/runtime.go` (15 instruments + 4 +observable gauges + `RegisterGauges`), the new `IntentPublisher` / +`RaceNameDirectory` metric decorators in +`lobby/internal/adapters/{metricsintentpub,metricsracenamedir}/`, the +`GameStore.CountByStatus` extension across `lobby/internal/ports/gamestore.go` +and the redisstate / gamestub adapters, the new +`lobby/internal/ports/streamlagprobe.go` port with the redisstate adapter +and stub, the `httpcommon.RequestID` middleware wired on both HTTP +listeners, the `logging.ContextAttrs` helper plus the +`trace_id` / `span_id` rename in `logging/logger.go`, and the +service / worker threading of `*telemetry.Runtime` through every +status-transition / outcome / cascade success path. + +Goal: + +- instrument the service for operational support + +Tasks: + +- Add counters and gauges listed in `README.md` Observability section using the + OpenTelemetry SDK. +- Add structured log fields for all key operations (transitions, notification + publishes, enrollment automation triggers, stream consumer events). +- Propagate `request_id` and `trace_id` through all service calls and into + structured logs where available. + +Exit criteria: + +- process exports all listed metrics when a real or stdout OTEL exporter is + configured. +- key operations produce log entries with stable field names. + +## ~~Stage 20.~~ Test Coverage and Documentation Alignment + +Status: implemented — see +`lobby/docs/stage20-test-coverage-and-doc-alignment-decisions.md`, +the new `integration/lobbyuser/` package (4 boundary tests against the +real `user/cmd/userservice` binary), the new `integration/lobbynotification/` +package (4 scenario tests covering 8 of 11 lobby `notification:intents` +producer types), the updated `LOBBY_PENDING_REGISTRATION_TTL_HOURS` +clarification at `lobby/README.md:1130-1136`, the +`/healthz`/`/readyz` realignment in +`lobby/api/internal-openapi.yaml:49,66`, and the new +`TestPublicSpecDeclaresAllRegisteredRoutes` / +`TestInternalSpecDeclaresAllRegisteredRoutes` route-table contract tests +in `lobby/contract_openapi_test.go`. `ARCHITECTURE.md` §7 and §10 were +spot-checked and required no edits. + +Goal: + +- close the loop across service tests, boundary tests, and documentation + +Tasks: + +- Verify all `README.md` claims against the implemented behavior. +- Add integration tests in the `integration/` module for: + - `Lobby → User Service` eligibility check boundary + - `Lobby → Notification Service` intent publication for all seven types +- Align `lobby/api/public-openapi.yaml` and `internal-openapi.yaml` with the + final implemented routes. +- Run `go test ./... -race -cover` across the lobby module. +- Verify `ARCHITECTURE.md` still matches the final implementation. + +Exit criteria: + +- `go test ./... -race` passes for the lobby module and the integration module. +- no contradictions between `lobby/README.md`, `ARCHITECTURE.md`, and implemented + behavior. + +## ~~Stage 21.~~ User Service: `user_name` + `display_name` refactor + +Status: implemented — see `user/docs/stage21-user-name-display-name.md`, +`lobby/internal/domain/racename/`, and the Gateway boundary rename across +`pkg/schema/fbs/user.fbs`, `pkg/transcoder/user.go`, `pkg/model/user`, and +the integration + gateway contract tests. + +Cross-service stage owned by `galaxy/user`. Must land before Stage 17A so the +eligibility snapshot carries `max_registered_race_names`. Can run in parallel +with Stages 09R and 10 once Stage 21.1–21.4 are complete. + +Tasks: + +- 21.1. Add `UserName` and `DisplayName` value types in + `user/internal/domain/common/types.go` (or an adjacent file). `UserName` + matches `player-` with suffix 8 characters from a confusable-free + alphanumeric alphabet; `DisplayName` delegates validation to + `pkg/util/string.go:ValidateTypeName` and tolerates empty strings. +- 21.2. Replace `RaceName` with `UserName` and add `DisplayName` on + `UserAccount` in `user/internal/domain/account/model.go`. Delete + `RaceNameReservation` and `RaceNameCanonicalKey` types. +- 21.3. Rename `IDGenerator.NewInitialRaceName` → `NewUserName`. Update its + implementation to use an 8-character confusable-free alphanumeric suffix + (`AppendRandomSuffix` pattern in `pkg/util/string.go` is a reference but + will need a new alphabet). Keep collision retries by store response; + increase the `ensureCreateRetryLimit` from `8` to `10`. +- 21.4. Delete `user/internal/ports/race_name_policy.go` and its adapters. + Move confusable-pair policy (including fixtures and tests) to + `lobby/internal/domain/racename/` — this feeds Stage 09R. +- 21.5. Update `authdirectory.Ensurer`: the ensure-by-email path creates + `UserName` via the renamed generator; `DisplayName` remains empty; no race + name reservation is created. +- 21.6. Update `selfservice.ProfileUpdater`: accept only `display_name`, + validate via `ValidateTypeName`. `user_name` is immutable and returned + read-only in the account view. +- 21.7. Extend `lobbyeligibility.SnapshotReader` to materialize + `max_registered_race_names` in `EffectiveLimits` (free=1, paid_monthly=2, + paid_yearly=6, paid_lifetime=0 marker) and to respect any user-specific + `LimitCodeMaxRegisteredRaceNames` override. +- 21.8. Extend `adminusers` list/search: exact + prefix filters by + `user_name` and `display_name`; update listing ordering if needed. +- 21.9. Update `user/internal/api/internalhttp/` handlers, `user/openapi.yaml`, + and contract tests (`openapi_contract_test.go`, `runtime_contract_test.go`). +- 21.10. Update `user/README.md` and `user/docs/` to reflect + `user_name`/`display_name`. Remove every reference to `race_name` in user + docs. +- 21.11. Update `integration/` cross-service tests (gateway scenarios, + auth/session wiring, lobby eligibility consumption). + +Exit criteria: + +- `go test ./... -race` passes for the user module and integration module. +- ensure-by-email returns only `user_id`, populating `user_name` and leaving + `display_name` empty. +- update-my-profile modifies only `display_name`. +- eligibility snapshot JSON carries `max_registered_race_names`. +- no source file in `galaxy/user` references `race_name` or + `RaceNameReservation` after the stage. + +## ~~Stage 22~~ — User Service: `permanent_block` + `DeleteUser` + +Cross-service stage owned by `galaxy/user`. Required before Stage 23. + +Tasks: + +- 22.1. Add `policy.SanctionCodePermanentBlock` to the supported catalog; + extend lobby-relevant filter so that the sanction always surfaces in the + eligibility snapshot; update `deriveEligibilityMarkers` so that an active + `permanent_block` collapses every `can_*` marker to `false`. +- 22.2. Add `policy.LimitCodeMaxRegisteredRaceNames` to the supported catalog + so admin overrides are possible. +- 22.3. Add `service/accountdeletion/` (new) and + `POST /api/v1/internal/users/{user_id}/delete` endpoint. + Soft-delete: mark `UserAccount.DeletedAt`; reject all subsequent auth, + self-service, admin-read, and lobby-eligibility operations with + `subject_not_found` for external callers; emit `user.lifecycle.deleted` + event. +- 22.4. Add `ports.UserLifecyclePublisher` and Redis stream + `user:lifecycle_events`. Emit: + - `user.lifecycle.permanent_blocked` on application of + `SanctionCodePermanentBlock` via `adminusers` path; + - `user.lifecycle.deleted` on successful `DeleteUser`. + Fields: `user_id`, `occurred_at_ms`, `actor`, `reason_code`. +- 22.5. Update `user/openapi.yaml`, handlers, and contract tests. + +Exit criteria: + +- permanent_block surfaces in the eligibility snapshot and drives all can_* + to false. +- DeleteUser is idempotent per `user_id`; a second call after soft-delete + returns `subject_not_found`. +- `user:lifecycle_events` receives exactly one event per state transition. + +## ~~Stage 23.~~ Lobby: `user:lifecycle_events` consumer + cascade release + +Status: implemented — see `lobby/docs/stage23-user-lifecycle-consumer-decisions.md`, +the new `lobby/internal/ports/userlifecyclestream.go`, +`lobby/internal/adapters/userlifecycle/consumer.go` (with the in-memory +`lobby/internal/adapters/userlifecyclestub/consumer.go` for tests), +`lobby/internal/worker/userlifecycle/worker.go`, the +`InviteStore.GetByInviter` and `GameStore.GetByOwner` extensions across +ports + redisstate + stubs, the new `game.TriggerExternalBlock` plus +`*/in-flight → cancelled` transitions in +`lobby/internal/domain/game/status.go`, the synchronous +`UserService.GetEligibility` guard added to +`lobby/internal/service/redeeminvite/service.go`, the `LOBBY_USER_LIFECYCLE_*` +configuration knobs in `lobby/internal/config/{config,env}.go`, the +worker + consumer wiring in `lobby/internal/app/{wiring,runtime}.go`, the +new `lobby.membership.blocked` notification type across +`pkg/notificationintent/{intent,payloads,intent_test}.go`, +`pkg/schema/fbs/notification.fbs` (with regenerated +`pkg/schema/fbs/notification/LobbyMembershipBlockedEvent.go`), +`pkg/transcoder/notification.go`, +`notification/api/intents-asyncapi.yaml`, +`notification/internal/{api/intentstream/contract,service/publishpush/encoder}.go`, +the contract-test fixtures in +`notification/{contract_asyncapi,producer_integration_contract,push_payload_contract,mail_template_contract}_test.go`, +the new mail templates under +`mail/templates/lobby.membership.blocked/en/`, and the README updates in +`lobby/README.md` (Notification Contracts, Cascade release, Status +transition table, Redis Logical Model, Observability) plus +`notification/README.md` and `gateway/README.md`. + +Tasks: + +- Add `internal/ports/userlifecyclestream.go`: `UserLifecycleConsumer` + abstraction with `Run(ctx) error` and `OnEvent(handler)`. +- Add `internal/adapters/userlifecycle/consumer.go`: Redis Streams consumer; + offset persisted at `lobby:stream_offsets:user_lifecycle`. +- Add `internal/worker/userlifecycle/worker.go`: + - on `user.lifecycle.permanent_blocked` or `user.lifecycle.deleted`: + - `RND.ReleaseAllByUser(user_id)`; + - mark every active `Membership` for the user as `blocked` with trigger + `external_block`; + - cancel every `submitted` application and every `created` invite owned or + addressed to the user; + - publish `lobby.membership.blocked` intents to private game owners where + applicable (reuse existing notification type or introduce + `lobby.user.permanent_blocked` — freeze choice in Stage 03R). +- Wire worker startup in `app/runtime.go`. +- Tests (`miniredis` + fake stream): full cascade, replay idempotency, partial + failure retry. + +Exit criteria: + +- A `permanent_blocked` event releases every RND entry for the user and + settles every lobby artefact atomically (per-entity operations OK; overall + consistency is eventual but within one event pass). +- Replaying the stream does not double-release. +- Offset advances only after full event handling. + +## ~~Stage 24.~~ Notification intent catalog additions + +Status: implemented — see `lobby/docs/stage24-race-name-intents-decisions.md`, +the new constants and constructors in +`pkg/notificationintent/{intent,payloads}.go` (with corresponding test +rows in `intent_test.go`), +the AsyncAPI extension in `notification/api/intents-asyncapi.yaml` and +contract-test fixture updates across +`notification/{contract_asyncapi,producer_integration_contract,mail_template_contract,push_payload_contract}_test.go`, +the new `LobbyRaceNameRegistrationEligibleEvent` and +`LobbyRaceNameRegisteredEvent` tables in +`pkg/schema/fbs/notification.fbs` with regenerated Go bindings, +the matching transcoder helpers in +`pkg/transcoder/notification.go`, the new switch arms in +`notification/internal/service/publishpush/encoder.go`, mail templates +under `mail/templates/lobby.race_name.{registration_eligible,registered,registration_denied}/en/`, +the gateway README push vocabulary update, the new +`lobby/internal/adapters/racenameintents/` adapter wired through +`lobby/internal/app/wiring.go` (replacing +`capabilityevaluation.NoopRaceNameIntents{}`), and the +`notification/internal/api/intentstream/contract.go` aliases. + +Tasks: + +- Extend `pkg/notificationintent/intent.go` vocabulary with + `NotificationTypeLobbyRaceNameRegistrationEligible`, + `NotificationTypeLobbyRaceNameRegistered`, and (optional) + `NotificationTypeLobbyRaceNameRegistrationDenied`. +- Update `ExpectedProducer`, `SupportsAudience`, `SupportsChannel`, and + `validatePayloadObject` accordingly. +- Extend `pkg/notificationintent/payloads.go` with: + - `LobbyRaceNameRegistrationEligiblePayload{GameID, GameName, RaceName, EligibleUntilMs}` + - `LobbyRaceNameRegisteredPayload{RaceName}` + - `LobbyRaceNameRegistrationDeniedPayload{GameID, GameName, RaceName, Reason}` + and matching `NewXxxIntent` constructors. +- Update `notification/api/intents-asyncapi.yaml` and related contract tests. + +Exit criteria: + +- `pkg/notificationintent` tests cover all new types end-to-end (build, + encode, decode, validate). +- AsyncAPI contract stays valid. +- Stage 15A and Stage 17A can publish intents via the new constructors. + +## Execution Order + +1. ~~Stage 21~~ — User Service refactor. +2. ~~Stage 22~~ — User Service `permanent_block` + `DeleteUser`. +3. ~~Stage 01R, 03R~~ — documentation alignment. +4. ~~Stage 09R~~ — RND port + Redis adapter. +5. ~~Stage 10.~~ +6. ~~Stage 11, 12~~ — updated race name flows. +7. ~~Stage 13~~, ~~14~~, ~~14A~~. +8. ~~Stage 15~~, ~~15A~~. +9. ~~Stage 16.~~ +10. ~~Stage 17.~~ +11. ~~Stage 24~~ — notification catalog (prerequisite for 15A/17A intents). +12. Stage ~~17A~~, ~~17B~~, ~~17C~~. +13. ~~Stage 18.~~ +14. ~~Stage 23~~ — user lifecycle consumer. +15. ~~Stage 19.~~ +16. ~~Stage 20.~~ + +## Final Acceptance Criteria + +The implementation is complete only when all of the following hold: + +- all status transition invariants are enforced; no unsupported transition can + be triggered by any API call +- enrollment automation handles all three auto-transition paths correctly and + idempotently +- Race Name Directory (two-tier) enforces platform-wide uniqueness across + registered names, active reservations, and pending registrations; canonical + key + confusable-pair policy applies to every path +- per-game reservations respect the "same user may hold the same name across + multiple active games" invariant +- initial `planets` / `population` are captured once per member per game from + the first post-start `runtime_snapshot_update` +- capability evaluation at `game_finished` resolves every active reservation + (pending if capable, released otherwise) atomically relative to the event + offset advance +- race name registration respects the tariff snapshot + (`max_registered_race_names`), the 30-day pending window, and idempotent + retry +- pending-registration expiration worker releases every expired entry without + double-releasing on restart +- `user:lifecycle_events` consumer cascades `permanent_blocked` and `deleted` + to full RND release, membership blocking, and application/invite + cancellation +- application and invite flows produce the correct notifications at each step +- game start flow handles runtime failure, GM unavailability, and metadata + persistence failure correctly +- GM runtime snapshot updates are applied durably from the stream and feed + the per-game stats aggregate +- game finish triggered by GM stream transitions the game correctly and + drives capability evaluation before offset advance +- denormalized runtime snapshot is always returned from the game record without + a round-trip to `Game Master` +- private game visibility rules are enforced at every list and read endpoint +- all configuration can be supplied via environment variables with documented + defaults +- User Service no longer owns a `race_name` concept; `user_name` and + `display_name` fully replace it, and ensure-by-email uses the renamed + generator +- `go test ./... -race` passes for the lobby module, the user module, the + `pkg/notificationintent` module, and the integration module diff --git a/lobby/README.md b/lobby/README.md new file mode 100644 index 0000000..8970539 --- /dev/null +++ b/lobby/README.md @@ -0,0 +1,1269 @@ +# Game Lobby Service + +`galaxy/lobby` owns platform-level metadata and lifecycle of game sessions. + +## References + +- [Public REST contract](api/public-openapi.yaml) +- [Internal REST contract](api/internal-openapi.yaml) +- [System architecture](../ARCHITECTURE.md) +- [Notification catalog](../notification/README.md) +- [User Service lobby eligibility](../user/README.md) +- [Service-local docs](docs/) + +## Purpose + +`Game Lobby Service` is the platform source of truth for game sessions as +platform entities — from creation through enrollment, start, runtime tracking, +and finish. It mediates all player participation actions and maintains the +roster state that `Game Master` may cache for runtime authorization. + +## Scope + +`Game Lobby` is the source of truth for: + +- opaque stable game identifiers in `game-*` form +- game metadata: name, description, type, owner, schedule, engine version +- platform-level game status from `draft` through `finished` or `cancelled` +- enrollment configuration: `min_players`, `max_players`, `start_gap_hours`, + `start_gap_players`, `enrollment_ends_at` +- applications and their approval or rejection status (public games) +- user-bound invitations and their lifecycle (private games) +- platform membership roster and participant status +- Race Name Directory state across all regular platform users: registered + race names (permanent ownership), per-game reservations, and 30-day + pending-registration windows +- per-game per-user `player_turn_stats` aggregate used at game finish for + capability evaluation +- denormalized runtime snapshot imported from `Game Master` +- user-facing lists: active games, pending applications, open invitations + +`Game Lobby` is not the source of truth for: + +- platform user identity or profile — owned by `User Service` +- device sessions or authentication state — owned by `Auth / Session Service` +- runtime container lifecycle or technical health — owned by `Runtime Manager` +- current turn, generation state, engine reachability — owned by `Game Master` +- full per-player game state — owned by the game engine container +- player-to-engine UUID mapping — owned by `Game Master` + +## Non-Goals + +- `Game Lobby` does not call game engine containers directly; all engine + interaction goes through `Game Master`. +- `Game Lobby` owns the Race Name Directory data in v1 (Redis adapter); the + contract is kept behind a port interface so a future dedicated + `Race Name Service` can replace the adapter without domain changes. +- `Game Lobby` does not compute notification audiences from roster data at + delivery time; notification intents carry explicit `recipient_user_id` values. +- `Game Lobby` does not apply sanctions or session-level access control; + `User Service` and `Auth / Session Service` remain authoritative for those. +- `Game Lobby` does not own billing or entitlement decisions; it reads the + current entitlement snapshot from `User Service`. + +## Position in the System + +```mermaid +flowchart LR + Gateway["Edge Gateway"] + Lobby["Game Lobby Service"] + User["User Service"] + GM["Game Master"] + Runtime["Runtime Manager"] + Notify["Notification Service"] + Redis["Redis\nKV + Streams"] + + Gateway --> Lobby + Lobby --> User + Lobby --> GM + Lobby --> Redis + Lobby --> Notify + GM --> Redis + Redis --> Lobby + Runtime --> Redis +``` + +`Gateway` routes authenticated platform-level commands to `Lobby` over trusted +REST. +`Lobby` reads user eligibility from `User Service` synchronously. +`Lobby` registers running games with `Game Master` synchronously at start. +`Lobby` submits start jobs to `Runtime Manager` and reads job results from a +dedicated Redis Stream. +`Game Master` publishes runtime events to a dedicated Redis Stream that `Lobby` +consumes asynchronously. +`Lobby` publishes notification intents to `notification:intents`. + +## Responsibility Boundaries + +`Game Lobby` is responsible for: + +- accepting and validating game creation and configuration commands +- opening and managing enrollment for public and private games +- validating user eligibility before accepting applications and invite redeems +- checking race name availability through the Race Name Directory port +- enforcing enrollment deadline and roster-size auto-transitions +- orchestrating the game start sequence with `Runtime Manager` and `Game Master` +- persisting game metadata atomically and removing orphaned containers when + metadata persistence fails +- maintaining the denormalized runtime snapshot for user-facing reads +- emitting notification intents for all participant lifecycle events +- enforcing visibility rules: private games are visible only to owner and members + +`Game Lobby` is not responsible for: + +- verifying authenticated transport signatures — handled by `Edge Gateway` +- checking session revocation state — handled by `Edge Gateway` and `Auth` +- email delivery — handled by `Mail Service` +- push delivery — handled by `Notification Service` and `Edge Gateway` +- container start and stop mechanics — handled by `Runtime Manager` +- per-turn player command routing — handled by `Game Master` + +## Runtime Surface + +The service starts two HTTP listeners and one Redis Stream consumer pipeline. + +### Listeners + +- public authenticated REST on `LOBBY_PUBLIC_HTTP_ADDR` with default `:8094` +- internal trusted REST on `LOBBY_INTERNAL_HTTP_ADDR` with default `:8095` + +### Background workers + +- enrollment automation ticker — checks enrollment deadlines and roster + thresholds at a configurable interval +- Runtime Manager result consumer — reads start-job results from a Redis Stream +- Game Master event consumer — reads runtime snapshot updates and game-finish + events from a dedicated Redis Stream + +### Startup dependencies + +- one reachable Redis deployment at `LOBBY_REDIS_ADDR` +- `User Service` reachable at `LOBBY_USER_SERVICE_BASE_URL` (startup check only; + runtime failures are surfaced as request errors, not boot failures) +- `Game Master` at `LOBBY_GM_BASE_URL` (same policy — startup check omitted; + unreachability at registration triggers the forced-pause path) + +### Probes + +- `GET /healthz` on both ports returns `{"status":"ok"}` +- `GET /readyz` on both ports returns `{"status":"ready"}` after successful + startup; no live Redis ping per request + +## Game Record Model + +### Fields + +| Field | Type | Notes | +| --- | --- | --- | +| `game_id` | string | opaque, stable, `game-*` form | +| `game_name` | string | human-readable; mutable in `draft` | +| `description` | string | optional; mutable in `draft` and `enrollment_open` | +| `game_type` | enum | `public` or `private` | +| `owner_user_id` | string | private games only; empty for public | +| `status` | enum | see status table below | +| `min_players` | int | minimum approved participants to proceed to start | +| `max_players` | int | target roster size that activates the gap window | +| `start_gap_hours` | int | hours of gap window after `max_players` is reached | +| `start_gap_players` | int | additional participants admitted during the gap | +| `enrollment_ends_at` | int64 | UTC Unix seconds; deadline for automatic enrollment close | +| `turn_schedule` | string | cron expression, e.g. `0 18 * * *`; passed to GM at registration | +| `target_engine_version` | string | semver of the engine to launch; passed to GM at registration | +| `created_at` | int64 | UTC Unix milliseconds | +| `updated_at` | int64 | UTC Unix milliseconds | +| `started_at` | int64 | UTC Unix milliseconds; set when status becomes `running` | +| `finished_at` | int64 | UTC Unix milliseconds; set when status becomes `finished` | +| `current_turn` | int | denormalized from GM; zero until running | +| `runtime_status` | string | denormalized from GM; empty until running | +| `engine_health_summary` | string | denormalized from GM; empty until running | +| `runtime_binding` | object? | non-null after successful container start; contains `container_id`, `engine_endpoint`, `runtime_job_id`, `bound_at` (Unix ms) | + +All fields set at creation are validated before the game record is persisted. +`game_name` is required and must be non-empty after trim. +`min_players`, `max_players`, `start_gap_hours`, `start_gap_players`, and +`enrollment_ends_at` are required positive integers with `min_players <= max_players`. +`turn_schedule` must be a valid five-field cron expression. +`target_engine_version` must be a non-empty semver string. + +### Status vocabulary + +| Status | Meaning | +| --- | --- | +| `draft` | Created; enrollment not yet open; editable | +| `enrollment_open` | Accepting applications (public) or invite redeems (private) | +| `ready_to_start` | Enrollment closed; start command accepted | +| `starting` | Start job submitted to Runtime Manager; awaiting result | +| `start_failed` | Container start or metadata persistence failed | +| `running` | Game engine container live; normal gameplay | +| `paused` | Platform-level pause; engine container may still be alive | +| `finished` | Game ended; record is terminal | +| `cancelled` | Cancelled before start; record is terminal | + +### Status transition table + +| From | To | Trigger | +| --- | --- | --- | +| `draft` | `enrollment_open` | explicit command from admin (public) or owner (private) | +| `enrollment_open` | `ready_to_start` | manual command when `approved_count >= min_players` | +| `enrollment_open` | `ready_to_start` | `enrollment_ends_at` reached and `approved_count >= min_players` | +| `enrollment_open` | `ready_to_start` | gap window exhausted (time or player count) | +| `ready_to_start` | `starting` | start command from admin (public) or owner (private) | +| `starting` | `running` | Runtime Manager confirms container; GM registration succeeds | +| `starting` | `paused` | Runtime Manager confirms container; GM registration fails (unavailable) | +| `starting` | `start_failed` | Runtime Manager reports container start failure | +| `start_failed` | `ready_to_start` | explicit retry command from admin or owner | +| `running` | `paused` | explicit pause command from admin or owner | +| `running` | `finished` | `game_finished` event from `Game Master` via Redis Stream | +| `paused` | `running` | explicit resume command from admin or owner | +| `paused` | `finished` | `game_finished` event from `Game Master` via Redis Stream | +| `draft` | `cancelled` | explicit cancel command from admin or owner | +| `enrollment_open` | `cancelled` | explicit cancel command from admin or owner | +| `ready_to_start` | `cancelled` | explicit cancel command from admin or owner | +| `start_failed` | `cancelled` | explicit cancel command from admin or owner | +| `draft` | `cancelled` | `external_block` cascade on owner permanent_block / DeleteUser | +| `enrollment_open` | `cancelled` | `external_block` cascade on owner permanent_block / DeleteUser | +| `ready_to_start` | `cancelled` | `external_block` cascade on owner permanent_block / DeleteUser | +| `start_failed` | `cancelled` | `external_block` cascade on owner permanent_block / DeleteUser | +| `starting` | `cancelled` | `external_block` cascade on owner permanent_block / DeleteUser | +| `running` | `cancelled` | `external_block` cascade on owner permanent_block / DeleteUser | +| `paused` | `cancelled` | `external_block` cascade on owner permanent_block / DeleteUser | + +Outside the `external_block` cascade, `running` and `paused` games cannot be +cancelled directly; use stop operations through `Game Master` and await the +`game_finished` event instead. The cascade publishes a stop-job to Runtime +Manager before applying the `external_block` transition for in-flight games. + +## Enrollment Rules + +`enrollment_open → ready_to_start` fires on the first of these conditions: + +### Manual close + +Admin (public game) or owner (private game) issues `lobby.game.ready_to_start` +when `approved_count >= min_players`. + +### Deadline + +Enrollment automation worker detects that `enrollment_ends_at` is in the past +and `approved_count >= min_players`. +If the deadline is reached but `approved_count < min_players`, the game remains +in `enrollment_open` — the transition does not fire until the player count +condition is also satisfied. + +### Gap exhaustion + +When `approved_count` reaches `max_players`, the gap window opens. +During the gap window: + +- new applications and invite redeems continue to be accepted up to + `max_players + start_gap_players` total approved participants +- the game does not automatically transition while the gap is open + +The transition fires when either: + +- `start_gap_hours` have elapsed since the gap window opened, or +- `approved_count` reaches `max_players + start_gap_players` + +### On enrollment close + +When any path transitions the game to `ready_to_start`: + +- all invites in `created` status transition to `expired` +- `lobby.invite.expired` notification intents are published for each expired invite + (recipient: private-game owner) +- no new applications are accepted in `ready_to_start` status + +## Application Lifecycle + +Applications are used for public games only. +Private games use the invite flow exclusively. + +### Submit + +An authenticated user submits `lobby.application.submit` with `race_name`. + +Pre-conditions checked synchronously: + +- game status is `enrollment_open` +- game type is `public` +- user has no existing non-rejected application to the same game +- `User Service` eligibility check confirms `can_join_game=true` +- `approved_count < max_players + start_gap_players` (or gap window not yet open) +- Race Name Directory confirms `race_name` is available for the applicant + +On success: + +- an `Application` record is created with `status=submitted` +- `lobby.application.submitted` intent published (`audience_kind=admin_email`) + with payload: `game_id`, `game_name`, `applicant_user_id`, `applicant_name` + +`applicant_name` in the notification payload equals the submitted `race_name`. + +### Approve + +Admin issues `lobby.application.approve`. + +Pre-conditions: + +- game is `enrollment_open` +- application is in `submitted` status +- `approved_count < max_players + start_gap_players` + +On success: + +- Race Name Directory reserves `race_name` for the applicant +- application `status` → `approved` +- `Membership` record created with `status=active` +- `lobby.membership.approved` intent published (recipient: applicant) + with payload: `game_id`, `game_name` +- gap window opens automatically if `approved_count` now equals `max_players` +- auto-transition to `ready_to_start` if gap exhaustion condition is immediately met + +### Reject + +Admin issues `lobby.application.reject`. + +Pre-conditions: + +- application is in `submitted` status + +On success: + +- application `status` → `rejected` +- any pending Race Name Directory reservation for the applicant is released +- `lobby.membership.rejected` intent published (recipient: applicant) + with payload: `game_id`, `game_name` + +### Application state machine + +``` +submitted → approved +submitted → rejected +``` + +Rejected applicants may re-apply while enrollment is open, subject to a single +active application constraint (at most one non-rejected application per user per +game). + +The single-active constraint is enforced at the persistence layer by the +`user_game_application` key (see Redis Logical Model). The key is created +atomically with the submitted application record, removed on rejection, and +preserved on approval. Service-layer code can rely on this invariant without +performing its own scan of `user_applications`. + +## Invite Lifecycle + +Invites are used for private games only. +Public games use the application flow exclusively. + +### Create + +Private-game owner issues `lobby.invite.create` with `invitee_user_id`. + +Pre-conditions: + +- game status is `enrollment_open` +- game type is `private` +- the invitee has no active invite or active membership in the game +- `approved_count < max_players + start_gap_players` + +On success: + +- `Invite` record created with `status=created` +- `expires_at` is set to `enrollment_ends_at` of the game +- `lobby.invite.created` intent published (recipient: invitee) + with payload: `game_id`, `game_name`, `inviter_user_id`, `inviter_name` + +`inviter_name` is the owner's race name if already a member of the game; +otherwise it is the owner's `user_id`. + +### Redeem + +The invited user issues `lobby.invite.redeem` with `race_name`. + +Pre-conditions: + +- invite status is `created` +- game is `enrollment_open` +- `approved_count < max_players + start_gap_players` +- inviter and invitee both exist and are not permanently blocked in + `User Service` +- Race Name Directory confirms `race_name` is available for the invitee + +On success: + +- Race Name Directory reserves `race_name` for the invitee +- invite `status` → `redeemed` +- `Membership` record created with `status=active` +- `lobby.invite.redeemed` intent published (recipient: private-game owner) + with payload: `game_id`, `game_name`, `invitee_user_id`, `invitee_name` +- gap window opens automatically if `approved_count` now equals `max_players` +- auto-transition to `ready_to_start` if gap exhaustion condition is immediately met + +The synchronous `User Service` check on both inviter and invitee enforces the +rule that an invite from or to a permanently blocked or deleted user behaves +as if it never existed, even before the asynchronous user-lifecycle cascade +has flipped the invite to `revoked`. Cascade-deleted accounts and +`permanent_block` sanctions surface as `subject_not_found`. + +### Decline + +The invited user issues `lobby.invite.decline`. + +Pre-conditions: + +- invite status is `created` + +On success: + +- invite `status` → `declined` +- no notification in v1 + +Declined users may receive a new invite from the owner while enrollment is open. + +### Revoke + +Owner issues `lobby.invite.revoke`. + +Pre-conditions: + +- invite status is `created` + +On success: + +- invite `status` → `revoked` +- no notification in v1 + +### Expire + +Pending invites (`status=created`) are transitioned to `expired` automatically +when the game moves to `ready_to_start`. + +`lobby.invite.expired` intent is published for each expired invite +(recipient: private-game owner) +with payload: `game_id`, `game_name`, `invitee_user_id`, `invitee_name`. + +### Invite state machine + +``` +created → redeemed +created → declined +created → revoked +created → expired +``` + +## Membership Model + +### Fields + +| Field | Type | Notes | +| --- | --- | --- | +| `membership_id` | string | opaque, stable | +| `game_id` | string | reference to game | +| `user_id` | string | reference to platform user | +| `race_name` | string | confirmed in-game name as submitted (original casing) | +| `canonical_key` | string | canonicalized key under which the RND reservation is held | +| `status` | enum | `active`, `removed`, `blocked` | +| `joined_at` | int64 | UTC Unix milliseconds | +| `removed_at` | int64 | UTC Unix milliseconds; set on remove or block | + +### Status vocabulary + +| Status | Meaning | +| --- | --- | +| `active` | Full participant; may send commands through `Game Master` | +| `removed` | Permanently removed; engine slot deactivated after game start | +| `blocked` | Platform-level block; engine slot retained but commands blocked | + +### Status transition table + +| From | To | Trigger | +| --- | --- | --- | +| `active` | `removed` | explicit remove command from admin or owner (post-start) | +| `active` | `blocked` | explicit block command from admin or owner | + +`removed` and `blocked` are terminal statuses. Pre-start remove drops the +membership record entirely rather than transitioning to `removed` +(see Removal rules below). + +### Removal rules + +Before game start: + +- remove drops membership and releases the race name reservation + +After game start: + +- `blocked`: the player cannot send commands; engine keeps the player slot +- `removed`: `Game Lobby` marks membership `removed`; `Game Master` must also + deactivate the player inside the engine; race name reservation remains until + game is finished + +This distinction is architectural and must remain explicit in all implementations. + +## Race Name Directory + +### Purpose + +`Race Name Directory` (RND) is the platform source of truth for all in-game +`race_name` values. It owns three levels of state per name: + +- **registered** — permanent user-owned names. Once registered, the name is + unavailable to any other user and cannot be released by the owner; only + `permanent_block` or `DeleteUser` on the owning account frees it. +- **reservation** — a per-game holding created when a participant joins + through application approval or invite redeem. Reservations are keyed by + `(game_id, canonical_key)`. One user may hold the same name in multiple + active games concurrently. +- **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. + +`User Service` does not store `race_name` values. It only exposes +`max_registered_race_names` in the eligibility snapshot and publishes +`user.lifecycle.permanent_blocked` / `user.lifecycle.deleted` events. + +### Canonical key + confusable-pair policy + +Every RND key is derived by +`racename.Canonicalize(raceName) (canonical string, err error)` living in +`lobby/internal/domain/racename/policy.go`: + +1. trim and validate the character set via `pkg/util/string.go:ValidateTypeName`; +2. lowercase Unicode fold; +3. apply the frozen confusable-pair replacement map (ported from the former + `user/internal/ports/race_name_policy.go`). + +A name is considered taken for the actor when the RND holds at least one +`registered`, active `reservation`, or `pending_registration` whose owner +differs from the actor on the same canonical key. + +### Port interface + +``` +type RaceNameDirectory interface { + Canonicalize(raceName string) (canonical string, err error) + + Check(ctx context.Context, raceName, actorUserID string) (Availability, error) + + Reserve(ctx context.Context, gameID, userID, raceName string) error + ReleaseReservation(ctx context.Context, gameID, userID, raceName string) error + + MarkPendingRegistration( + ctx context.Context, + gameID, userID, raceName string, + eligibleUntil time.Time, + ) error + ExpirePendingRegistrations(ctx context.Context, now time.Time) ([]ExpiredPending, error) + + Register(ctx context.Context, gameID, userID, raceName string) error + + ListRegistered(ctx context.Context, userID string) ([]RegisteredName, error) + ListPendingRegistrations(ctx context.Context, userID string) ([]PendingRegistration, error) + ListReservations(ctx context.Context, userID string) ([]Reservation, error) + + ReleaseAllByUser(ctx context.Context, userID string) error +} + +type Availability struct { + Taken bool + HolderUserID string // "" when available + Kind string // "registered" | "reservation" | "pending_registration" +} +``` + +Sentinel errors: `ErrNameTaken`, `ErrInvalidName`, `ErrPendingMissing`, +`ErrPendingExpired`, `ErrQuotaExceeded`. + +### v1 backends + +- **Redis** (`lobby/internal/adapters/redisstate/racenamedir.go`) — the + production adapter using the key layout in §Redis Logical Model. +- **Stub** (`lobby/internal/adapters/racenamestub/directory.go`) — in-process + implementation for unit tests that do not need Redis. Chosen by + `LOBBY_RACE_NAME_DIRECTORY_BACKEND=stub`. + +A future dedicated `Race Name Service` replaces the adapter without changing +the domain or service layer. + +### Reservation lifecycle and capability + +1. `approveapplication` / `redeeminvite` → `Reserve(game_id, user_id, + race_name)`. +2. `removemember` before start → `ReleaseReservation`. +3. `removemember` / `blockmember` after start → reservation kept; resolved at + `game_finished`. +4. On `game_finished` the capability evaluator runs per active membership: + - `capable = max_planets > initial_planets AND max_population > + initial_population`, using the per-game stats aggregate (see §Runtime + Snapshot); + - capable ⇒ `MarkPendingRegistration(..., finished_at + 30 days)` + + `lobby.race_name.registration_eligible`; + - not capable ⇒ `ReleaseReservation` + optional + `lobby.race_name.registration_denied`. +5. The pending-registration worker + (`LOBBY_RACE_NAME_EXPIRATION_INTERVAL`) releases expired entries. + +### Registration flow + +`lobby.race_name.register` → `POST /api/v1/lobby/race-names/register`: + +- actor is the authenticated user; +- body: `{race_name, source_game_id}`; +- preconditions: + - `pending_registration` exists for `(source_game_id, user_id, canonical_key)` + with `eligible_until > now`; + - `UserService.GetEligibility` snapshot: no `permanent_block`, + `current_registered_count < max_registered_race_names` (a snapshot value + of `0` denotes unlimited); +- commit: `RND.Register` atomically deletes the pending entry, creates a + registered entry, and publishes `lobby.race_name.registered`. + +Errors: `race_name_registration_quota_exceeded`, +`race_name_pending_window_expired`, `subject_not_found`, `forbidden`. + +### Self-service reads + +`lobby.race_names.list` → `GET /api/v1/lobby/my/race-names` returns the +acting user's `{registered[], pending[], reservations[]}` using the +`user_registered` / `user_reservations` indexes (no full scan). + +The response shape is fixed by `api/public-openapi.yaml` and carries: + +- `registered[]`: `canonical_key`, `race_name`, `source_game_id`, + `registered_at_ms`; +- `pending[]`: `canonical_key`, `race_name`, `source_game_id` (the + game whose capable finish promoted the reservation), + `reserved_at_ms`, `eligible_until_ms`; +- `reservations[]`: `canonical_key`, `race_name`, `game_id`, + `reserved_at_ms`, `game_status` (current `game.Status` of the + hosting game, joined on read). + +Each slice is sorted ascending by its time field with `canonical_key` +as the tie-breaker so the wire output is stable. The endpoint is +exclusively self-service: there is no `?user_id=` parameter and no +admin counterpart on the internal port. Visibility is enforced by the +`X-User-ID` header alone. + +### Cascade release + +`Game Lobby` consumes `user:lifecycle_events` through a dedicated worker. On +`user.lifecycle.permanent_blocked` or `user.lifecycle.deleted`: + +- `RND.ReleaseAllByUser(user_id)` clears every registered, reservation, and + pending entry owned by the user; +- every active membership held by the user transitions to `blocked`. For each + such membership in a third-party private game, a `lobby.membership.blocked` + intent is published to the game owner; +- every outstanding `submitted` application authored by the user is rejected; +- every `created` invite where the user is invitee or inviter transitions to + `revoked`; +- every non-terminal game owned by the user transitions to `cancelled` via + the `external_block` trigger. For in-flight games (`starting`, `running`, + `paused`) a stop-job is published to Runtime Manager before the status + transition. + +Synchronous guard: `lobby.invite.redeem` calls `UserService.GetEligibility` +for both the inviter and the invitee. If either party has been permanently +blocked or soft-deleted, the redeem fails with `subject_not_found`, matching +the «as if the invite never existed» semantic even before the cascade +flips the invite to `revoked`. + +### Retry and release semantics + +- `Reserve` is idempotent for the same holder under the same game. A second + call returns no error so that `approveapplication` and `redeeminvite` + retries after transient upstream failures stay safe. +- `ReleaseReservation` is a no-op when no reservation exists for the tuple + and also when the reservation belongs to a different user. Defensive + release paths (`rejectapplication`, `revokeinvite`, `declineinvite`) never + surface an error. +- `Register` is idempotent only for the same `(game_id, user_id, race_name)` + tuple — repeated calls after success return the same registered record + without consuming additional quota. +- `MarkPendingRegistration` is idempotent when called with the same + `eligible_until`; re-emitting it with a different timestamp returns + `ErrInvalidName`. + +## Game Start Flow + +The start sequence spans three services and must be treated as a distributed +transaction with explicit failure handling. + +```mermaid +sequenceDiagram + participant Admin as Admin or Private Owner + participant Lobby + participant Runtime + participant GM as Game Master + participant Redis + + Admin->>Lobby: lobby.game.start + Lobby->>Lobby: validate ready_to_start + roster + Lobby->>Lobby: status → starting + Lobby->>Redis: publish start job to runtime:start_jobs + Runtime->>Runtime: start container + Runtime->>Redis: publish result to runtime:job_results + + alt container start failed + Lobby->>Lobby: status → start_failed + else container started + Lobby->>Lobby: persist runtime binding + Lobby->>GM: POST /internal/games/{game_id}/register (sync) + alt GM registration success + GM-->>Lobby: 200 OK + Lobby->>Lobby: status → running; set started_at + else GM unavailable + GM-->>Lobby: error / timeout + Lobby->>Lobby: status → paused + Lobby->>Redis: publish lobby.runtime_paused_after_start intent + end + end +``` + +### Critical invariants + +- If the container starts but `Lobby` cannot persist the runtime binding metadata, + the start is a full failure: `Lobby` must issue a stop job to `Runtime Manager` + before setting `start_failed`. +- If metadata is persisted but `Game Master` is unavailable, the game must be + placed in `paused`, not in `start_failed`. The container is alive; only the + platform tracking is incomplete. +- No start job is accepted while the game is not in `ready_to_start`. +- Concurrent start attempts for the same game must be serialized; the second + attempt must fail if the first already moved the game to `starting`. + +## Paused State + +`Lobby.paused` is a platform-level pause, distinct from `Game Master` runtime +failure states. Two paths lead to `paused`: + +### Voluntary pause + +Admin or owner issues `lobby.game.pause` while the game is `running`. +Resume is issued with `lobby.game.resume`; `Lobby` performs a synchronous +liveness check against `Game Master` before transitioning back to `running`. + +### Forced pause (GM unavailable after start) + +If the game start sequence succeeds at the runtime layer but `Game Master` +registration fails, `Lobby` transitions to `paused` and publishes +`lobby.runtime_paused_after_start` to administrators. + +Administrators investigate, restore `Game Master`, and issue `lobby.game.resume` +through the internal admin surface. + +## Game Finish Flow + +`Game Master` publishes a `game_finished` event to the GM events Redis Stream +when the engine reports that the game has ended. + +`Lobby` consumes this event and, before advancing the stream offset: + +- transitions game status to `finished` +- sets `finished_at` to the event timestamp +- updates the denormalized runtime snapshot with the final values +- runs the capability evaluator against every `active` membership: + - `capable = max_planets > initial_planets AND max_population > + initial_population` from the per-member stats aggregate + - capable ⇒ `RND.MarkPendingRegistration(game_id, user_id, race_name, + finished_at + 30 days)` and publish + `lobby.race_name.registration_eligible` + - not capable ⇒ `RND.ReleaseReservation(game_id, user_id, race_name)` and + (optional) publish `lobby.race_name.registration_denied` +- resolves outstanding reservations on `removed` and `blocked` memberships by + calling `RND.ReleaseReservation` (post-start remove/block keeps the + reservation alive specifically so capability evaluation resolves it here) +- deletes the per-game stats aggregate + +The `game_finished` event from `Game Master` is the sole trigger for the +`finished` status. `Lobby` does not independently decide that a game is +finished. Capability evaluation must be idempotent: a replayed +`game_finished` event must not produce additional RND side effects or +notifications. + +## Runtime Snapshot + +`Game Lobby` stores a denormalized runtime snapshot on the game record to +prevent fan-out reads to `Game Master` on every user-facing list or detail +request, and aggregates per-member stats to support capability evaluation at +game finish. + +### Denormalized snapshot fields + +| Field | Source | +| --- | --- | +| `current_turn` | GM event `runtime_snapshot_update` | +| `runtime_status` | GM event `runtime_snapshot_update` | +| `engine_health_summary` | GM event `runtime_snapshot_update` | + +### Per-member stats aggregate + +Each `runtime_snapshot_update` carries a `player_turn_stats` array with one +entry per active member: `{user_id, planets, population, ships_built}`. +`Lobby` aggregates these in `lobby:game_turn_stats::` with +the shape +`{initial_planets, initial_population, initial_ships_built, max_planets, +max_population, max_ships_built}`. + +Rules: + +- `initial_*` values are frozen from the first event after + `starting → running`; later events must not change them. +- `max_*` values are maintained by max-semantic update; they never decrease. +- the aggregate is read once by the capability evaluator at `game_finished` + and then deleted. + +### Update mechanism + +`Game Master` publishes events to a dedicated Redis Stream consumed by `Lobby`: + +- `runtime_snapshot_update`: carries updated `current_turn`, `runtime_status`, + `engine_health_summary`, and `player_turn_stats`; `Lobby` applies a + compare-and-swap update on the game record plus a stats aggregate upsert. +- `game_finished`: carries final snapshot values and signals the finish + transition; capability evaluator (see §Game Finish Flow) runs before the + stream offset is advanced. + +`Lobby` does not expose the runtime snapshot update as an internal HTTP +endpoint. All snapshot updates are asynchronous and delivered through the +stream. + +## Public vs Private Game Rules + +### Public games + +- created and controlled by system administrators through the internal admin surface +- visible in the public game list when in `enrollment_open`, `ready_to_start`, + `running`, or `finished` status +- `draft` public games are not visible to non-admin users +- players join through the application flow; admission requires admin approval +- turn schedule and engine version are set by the administrator + +### Private games + +- created only by eligible paid users whose `User Service` eligibility snapshot + carries `can_create_private_game=true` and whose `max_owned_private_games` + limit allows it +- visible only to the owner and to users who have an active membership or a + non-expired invite +- `draft` private games are visible only to the owner +- players join through the invite flow; invite redemption creates active + membership immediately without further owner approval +- owner manages invites, turn schedule, and engine version + +## Owner-Admin Capabilities + +Private-game owners have a limited owner-admin capability set over their own +games only: + +- open enrollment (`draft` → `enrollment_open`) +- create and revoke invites +- manually close enrollment (`enrollment_open` → `ready_to_start`) +- start the game (`ready_to_start` → `starting`) +- pause and resume the game (`running` ↔ `paused`) +- retry start or cancel after `start_failed` +- remove or block members +- cancel the game (from `draft`, `enrollment_open`, `ready_to_start`, `start_failed`) + +Owners do not have system-admin power. +They cannot see or operate on other users' private games. +They cannot approve or reject applications (applications are public-game only). + +## Trusted Surfaces + +### Public authenticated REST (gateway-facing) + +All user-facing commands arrive through `Edge Gateway`. +Gateway verifies the authenticated session, transcodes the FlatBuffers command +to a trusted REST call, and forwards it to `Lobby` on the public port. + +Gateway enriches each request with the authenticated `user_id` via the +`X-User-ID` header. +`Lobby` must never derive the acting user from the request payload. + +#### Message type catalog + +| `message_type` | Method | Path | Actor | +| --- | --- | --- | --- | +| `lobby.game.create` | `POST` | `/api/v1/lobby/games` | admin (public), eligible user (private) | +| `lobby.game.update` | `PATCH` | `/api/v1/lobby/games/{game_id}` | admin or owner; draft only | +| `lobby.game.get` | `GET` | `/api/v1/lobby/games/{game_id}` | any authenticated user (visibility rules apply) | +| `lobby.games.list` | `GET` | `/api/v1/lobby/games` | any authenticated user | +| `lobby.game.open_enrollment` | `POST` | `/api/v1/lobby/games/{game_id}/open-enrollment` | admin or owner | +| `lobby.game.ready_to_start` | `POST` | `/api/v1/lobby/games/{game_id}/ready-to-start` | admin or owner | +| `lobby.game.start` | `POST` | `/api/v1/lobby/games/{game_id}/start` | admin or owner | +| `lobby.game.pause` | `POST` | `/api/v1/lobby/games/{game_id}/pause` | admin or owner | +| `lobby.game.resume` | `POST` | `/api/v1/lobby/games/{game_id}/resume` | admin or owner | +| `lobby.game.cancel` | `POST` | `/api/v1/lobby/games/{game_id}/cancel` | admin or owner | +| `lobby.game.retry_start` | `POST` | `/api/v1/lobby/games/{game_id}/retry-start` | admin or owner | +| `lobby.application.submit` | `POST` | `/api/v1/lobby/games/{game_id}/applications` | authenticated user | +| `lobby.application.approve` | `POST` | `/api/v1/lobby/games/{game_id}/applications/{application_id}/approve` | admin | +| `lobby.application.reject` | `POST` | `/api/v1/lobby/games/{game_id}/applications/{application_id}/reject` | admin | +| `lobby.invite.create` | `POST` | `/api/v1/lobby/games/{game_id}/invites` | private-game owner | +| `lobby.invite.redeem` | `POST` | `/api/v1/lobby/games/{game_id}/invites/{invite_id}/redeem` | invited user | +| `lobby.invite.decline` | `POST` | `/api/v1/lobby/games/{game_id}/invites/{invite_id}/decline` | invited user | +| `lobby.invite.revoke` | `POST` | `/api/v1/lobby/games/{game_id}/invites/{invite_id}/revoke` | private-game owner | +| `lobby.membership.remove` | `POST` | `/api/v1/lobby/games/{game_id}/memberships/{membership_id}/remove` | admin or owner | +| `lobby.membership.block` | `POST` | `/api/v1/lobby/games/{game_id}/memberships/{membership_id}/block` | admin or owner | +| `lobby.memberships.list` | `GET` | `/api/v1/lobby/games/{game_id}/memberships` | admin, owner, or active member | +| `lobby.my_games.list` | `GET` | `/api/v1/lobby/my/games` | authenticated user | +| `lobby.my_applications.list` | `GET` | `/api/v1/lobby/my/applications` | authenticated user | +| `lobby.my_invites.list` | `GET` | `/api/v1/lobby/my/invites` | authenticated user | +| `lobby.race_name.register` | `POST` | `/api/v1/lobby/race-names/register` | authenticated user | +| `lobby.race_names.list` | `GET` | `/api/v1/lobby/my/race-names` | authenticated user | + +### Internal trusted REST (internal-facing) + +The internal port is not reachable from the public internet. +It is used by `Game Master` for the synchronous registration call and by the +administrative backend for admin-only operations. + +Key internal endpoints: + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/api/v1/internal/games/{game_id}` | game detail read for GM/admin | +| `GET` | `/api/v1/internal/games/{game_id}/memberships` | full membership list for GM | +| `GET` | `/api/v1/internal/healthz` | health probe | +| `GET` | `/api/v1/internal/readyz` | readiness probe | + +Note: the registration call from Lobby to Game Master after a successful +container start is **outgoing** — Lobby calls +`POST /api/v1/internal/games/{game_id}/register-runtime` on Game Master's +internal port. Lobby does not expose an inbound `register-runtime` +endpoint. + +Admin-only operations (approve, reject, cancel, create public games, etc.) are +also exposed on the internal port and are intended to be called by `Admin Service` +after it enforces the system-admin role check at the gateway boundary. + +## User-Facing Lists + +### My active games + +Returns games where the authenticated user has an active membership and the game +status is `running` or `paused`. +Response includes the denormalized runtime snapshot. + +### My pending applications + +Returns applications submitted by the authenticated user with status `submitted`. +Includes game name and type for display. + +### My open invitations + +Returns invites addressed to the authenticated user with status `created`. +Includes game name, inviter name, and `expires_at`. + +### Public game list + +Paginated list of public games with status in +`enrollment_open`, `ready_to_start`, `running`, or `finished`. +Games in `draft` or `cancelled` are excluded. +Default order: `enrollment_open` and `ready_to_start` first, then `running`, then +`finished` (most recent first within each group). + +### Visibility rules + +- private `draft` games: visible only to the owner +- private non-draft games: visible only to the owner and users with active + membership or non-expired invite +- public `draft` games: visible only to system administrators +- public non-draft games: visible in the public list + +## Notification Contracts + +`Game Lobby` publishes normalized notification intents to `notification:intents` +using the `galaxy/notificationintent` producer module. + +| Trigger | `notification_type` | Audience | Channels | +| --- | --- | --- | --- | +| Application submitted (public game) | `lobby.application.submitted` | configured admin email list | `email` | +| Application approved | `lobby.membership.approved` | applicant user | `push+email` | +| Application rejected | `lobby.membership.rejected` | applicant user | `push+email` | +| Cascade membership block (`permanent_block`/`DeleteUser`) | `lobby.membership.blocked` | private-game owner | `push+email` | +| Invite created (private game) | `lobby.invite.created` | invited user | `push+email` | +| Invite redeemed (private game) | `lobby.invite.redeemed` | private-game owner | `push+email` | +| Invite expired (on enrollment close) | `lobby.invite.expired` | private-game owner | `email` | +| GM unavailable after start (forced pause) | `lobby.runtime_paused_after_start` | configured admin email list | `email` | +| Race name eligible for registration | `lobby.race_name.registration_eligible` | capable member | `push+email` | +| Race name successfully registered | `lobby.race_name.registered` | registering user | `push+email` | +| Race name registration denied (capability) | `lobby.race_name.registration_denied` | incapable member | `email` | + +Rules: + +- intents carry explicit `recipient_user_id` values; `Lobby` resolves recipients + before publishing rather than delegating audience resolution to `Notification Service` +- a failed intent publication is a notification degradation and must not roll back + already committed business state +- `lobby.invite.revoked` and `lobby.invite.declined` produce no notification in v1 +- `lobby.application.submitted` is published only for public games; the private-game + owner-targeting path defined in the notification catalog is reserved for future use + +## Domain Events + +`Game Lobby` publishes auxiliary post-commit domain events to the Redis stream +configured for lobby domain events. + +Frozen event types: + +- `lobby.game.created` +- `lobby.game.status_changed` +- `lobby.membership.activated` +- `lobby.membership.removed` +- `lobby.membership.blocked` + +Event rules: + +- events are post-commit only; they are not emitted on failed operations +- event envelopes carry `game_id`, optional `user_id`, occurrence timestamp, + new status (for `status_changed`), and optional trace correlation +- domain events are observability and downstream-read-model artifacts; + they must not carry full business state payloads + +## Error Model + +The trusted internal REST contract uses strict JSON error envelopes: + +```json +{ + "error": { + "code": "invalid_request", + "message": "request is invalid" + } +} +``` + +Stable error codes: + +- `invalid_request` — malformed input or failed validation +- `conflict` — state transition not allowed from current status +- `subject_not_found` — game, application, invite, membership, or pending + race-name registration not found +- `eligibility_denied` — user not eligible per `User Service` +- `name_taken` — `race_name` already registered, reserved, or pending for + another user +- `race_name_registration_quota_exceeded` — user's `max_registered_race_names` + slot is full +- `race_name_pending_window_expired` — the 30-day registration window has + passed for the pending entry +- `race_name_capability_not_met` — capability condition not satisfied at + game finish (reservation released) +- `race_name_permanent_blocked` — the user carries an active + `permanent_block` sanction +- `forbidden` — caller is not authorized for this operation on this game or + this race name +- `internal_error` — unexpected service error +- `service_unavailable` — upstream dependency unavailable + +## Configuration + +### Required + +- `LOBBY_REDIS_ADDR` +- `LOBBY_USER_SERVICE_BASE_URL` +- `LOBBY_GM_BASE_URL` + +### Configuration groups + +Process and logging: + +- `LOBBY_SHUTDOWN_TIMEOUT` with default `30s` +- `LOBBY_LOG_LEVEL` with default `info` + +Public HTTP: + +- `LOBBY_PUBLIC_HTTP_ADDR` with default `:8094` +- `LOBBY_PUBLIC_HTTP_READ_HEADER_TIMEOUT` with default `2s` +- `LOBBY_PUBLIC_HTTP_READ_TIMEOUT` with default `10s` +- `LOBBY_PUBLIC_HTTP_IDLE_TIMEOUT` with default `1m` + +Internal HTTP: + +- `LOBBY_INTERNAL_HTTP_ADDR` with default `:8095` +- `LOBBY_INTERNAL_HTTP_READ_HEADER_TIMEOUT` with default `2s` +- `LOBBY_INTERNAL_HTTP_READ_TIMEOUT` with default `10s` +- `LOBBY_INTERNAL_HTTP_IDLE_TIMEOUT` with default `1m` + +Redis connectivity: + +- `LOBBY_REDIS_USERNAME` +- `LOBBY_REDIS_PASSWORD` +- `LOBBY_REDIS_DB` +- `LOBBY_REDIS_TLS_ENABLED` +- `LOBBY_REDIS_OPERATION_TIMEOUT` with default `2s` + +Stream names: + +- `LOBBY_GM_EVENTS_STREAM` with default `gm:lobby_events` +- `LOBBY_GM_EVENTS_READ_BLOCK_TIMEOUT` with default `2s` +- `LOBBY_RUNTIME_START_JOBS_STREAM` with default `runtime:start_jobs` +- `LOBBY_RUNTIME_STOP_JOBS_STREAM` with default `runtime:stop_jobs` +- `LOBBY_RUNTIME_JOB_RESULTS_STREAM` with default `runtime:job_results` +- `LOBBY_RUNTIME_JOB_RESULTS_READ_BLOCK_TIMEOUT` with default `2s` +- `LOBBY_NOTIFICATION_INTENTS_STREAM` with default `notification:intents` + +Upstream clients: + +- `LOBBY_USER_SERVICE_TIMEOUT` with default `1s` +- `LOBBY_GM_TIMEOUT` with default `5s` + +Enrollment automation: + +- `LOBBY_ENROLLMENT_AUTOMATION_INTERVAL` with default `30s` + +Race Name Directory: + +- `LOBBY_RACE_NAME_DIRECTORY_BACKEND` with default `redis` + (alternate: `stub` for in-process tests) +- `LOBBY_RACE_NAME_EXPIRATION_INTERVAL` with default `1h` — pending + registration expiration worker tick + +The 30-day eligibility window for `pending_registration` entries is the +constant `service/capabilityevaluation.PendingRegistrationWindow`. It is +intentionally not operator-tunable today; the env var name +`LOBBY_PENDING_REGISTRATION_TTL_HOURS` is reserved for a future change. + +User lifecycle: + +- `LOBBY_USER_LIFECYCLE_STREAM` with default `user:lifecycle_events` +- `LOBBY_USER_LIFECYCLE_READ_BLOCK_TIMEOUT` with default `2s` + +OpenTelemetry: + +- standard `OTEL_*` variables +- `LOBBY_OTEL_STDOUT_TRACES_ENABLED` +- `LOBBY_OTEL_STDOUT_METRICS_ENABLED` + +## Redis Logical Model + +Storage rules: + +- durable records are stored as strict JSON blobs +- timestamps are stored in Unix milliseconds unless noted otherwise +- dynamic key segments are base64url-encoded + +### Key table + +| Logical artifact | Redis key | +| --- | --- | +| game record | `lobby:games:` | +| game index by status | `lobby:games_by_status:` (sorted set; score = created_at) | +| games by owner | `lobby:games_by_owner:` (set of game_ids; populated for private games on Save) | +| application record | `lobby:applications:` | +| applications by game | `lobby:game_applications:` (set of application_ids) | +| applications by user | `lobby:user_applications:` (set of application_ids) | +| active application per (user, game) | `lobby:user_game_application::` → `application_id` | +| invite record | `lobby:invites:` | +| invites by game | `lobby:game_invites:` (set of invite_ids) | +| invites by user (invitee) | `lobby:user_invites:` (set of invite_ids) | +| invites by inviter | `lobby:user_inviter_invites:` (set of invite_ids) | +| membership record | `lobby:memberships:` | +| memberships by game | `lobby:game_memberships:` (set of membership_ids) | +| memberships by user | `lobby:user_memberships:` (set of membership_ids) | +| registered race name | `lobby:race_names:registered:` → JSON `{user_id, race_name, source_game_id, registered_at}` | +| user → registered canonical keys | `lobby:race_names:user_registered:` (set of `canonical_key`) | +| per-game race name reservation | `lobby:race_names:reservations::` → JSON `{user_id, race_name, reserved_at, status ∈ reserved/pending_registration, eligible_until_ms?}` | +| user → reservations index | `lobby:race_names:user_reservations:` (set of `game_id:canonical_key`) | +| pending-registration expiry index | `lobby:race_names:pending_index` (sorted set; score = `eligible_until_ms`) | +| canonical-key lookup cache | `lobby:race_names:canonical_lookup:` → JSON `{kind, holder_user_id, game_id?}` | +| per-game per-user stats aggregate | `lobby:game_turn_stats::` → JSON aggregate | +| GM event stream offset | `lobby:stream_offsets:gm_events` | +| runtime job result offset | `lobby:stream_offsets:runtime_results` | +| user lifecycle stream offset | `lobby:stream_offsets:user_lifecycle` | +| gap window activation time | `lobby:gap_activated_at:` | + +### Frozen record fields + +| Record | Frozen fields | +| --- | --- | +| game record | all game fields listed in Game Record Model section | +| application record | `application_id`, `game_id`, `applicant_user_id`, `race_name`, `status`, `created_at`, `decided_at` | +| invite record | `invite_id`, `game_id`, `inviter_user_id`, `invitee_user_id`, `race_name` (set at redeem), `status`, `created_at`, `expires_at`, `decided_at` | +| membership record | all membership fields listed in Membership Model section | + +## Observability + +### Metrics + +- `lobby.game.transitions` — counter; attributes: `from_status`, `to_status`, `trigger` (`command`, `manual`, `deadline`, `gap`, `runtime_event`, `external_block`) +- `lobby.application.outcomes` — counter; attributes: `outcome` (`submitted`, `approved`, `rejected`) +- `lobby.invite.outcomes` — counter; attributes: `outcome` (`created`, `redeemed`, `declined`, `revoked`, `expired`) +- `lobby.membership.changes` — counter; attributes: `change` (`activated`, `removed`, `blocked`, `external_block`) +- `lobby.start_flow.outcomes` — counter; attributes: `outcome` (`running`, `paused`, `start_failed`) +- `lobby.notification.publish_attempts` — counter; attributes: `notification_type`, `result` (`ok`, `error`) +- `lobby.active_games` — observable gauge; attributes: `status` +- `lobby.enrollment_automation.checks` — counter; attributes: `result` (`no_op`, `transitioned`) +- `lobby.gm_events.oldest_unprocessed_age_ms` — observable gauge +- `lobby.runtime_results.oldest_unprocessed_age_ms` — observable gauge +- `lobby.user_lifecycle.oldest_unprocessed_age_ms` — observable gauge +- `lobby.race_name.outcomes` — counter; attributes: `outcome` (`reserved`, `reservation_released`, `pending_created`, `pending_released`, `registered`, `registered_released`) +- `lobby.pending_registration.expirations` — counter; attributes: `trigger` (`tick`, `manual`) +- `lobby.user_lifecycle.cascade_releases` — counter; attributes: `event` (`permanent_blocked`, `deleted`) +- `lobby.capability_evaluations` — counter; attributes: `result` (`capable`, `incapable`, `noop`) + +Metrics avoid high-cardinality attributes such as `game_id`, `user_id`, +`application_id`, `invite_id`, and `canonical_key`. + +### Structured log fields + +Key operations emit structured logs with these stable field names where applicable: + +- `game_id` +- `game_type` +- `game_status` +- `from_status` +- `to_status` +- `user_id` +- `application_id` +- `invite_id` +- `membership_id` +- `race_name` +- `canonical_key` +- `reservation_kind` (`reserved` / `pending_registration` / `registered`) +- `eligible_until_ms` +- `trigger` +- `lifecycle_event` +- `request_id` +- `trace_id` + +## Verification + +Focused service-local coverage verifies: + +- configuration loading and validation for all env var groups +- both HTTP listeners start and serve `/healthz` and `/readyz` +- game CRUD: create, update, get, list with correct field validation +- each status transition fires only from allowed source statuses +- enrollment automation: deadline trigger, gap trigger, manual trigger +- application flow: submit (eligibility check, race name check), approve, reject +- invite flow: create, redeem (auto-membership), decline, revoke, expire on enrollment close +- membership model: activate, remove, block with correct before/after-start semantics +- Race Name Directory (redis + stub adapters against the same suite): + canonicalization + confusable-pair policy, `Reserve`/`ReleaseReservation` + per-game semantics, `MarkPendingRegistration`/`ExpirePendingRegistrations` + window, `Register` idempotency + quota, `ReleaseAllByUser` cascade +- game start flow: success path (→ running), GM unavailable path (→ paused), + container failure path (→ start_failed), metadata persistence failure path + (container removed, → start_failed) +- GM event stream consumer: snapshot update (stats aggregate), + `game_finished` with capability evaluation +- user lifecycle stream consumer: `permanent_blocked` and `deleted` + cascade release + membership/application/invite settlement +- pending-registration expiration worker idempotency +- race name registration service: capability, tariff quota, pending window, + idempotent retry +- notification intent publication for all ten supported triggers +- visibility rules: private game hidden from non-member non-owner users +- error model: all stable codes returned for correct conditions + +Cross-service coverage verifies: + +- `Lobby → User Service` eligibility check compatibility (including the new + `max_registered_race_names` field) and failure handling +- `Lobby → Notification Service` intent publication for all lobby notification types +- `Lobby → Runtime Manager` start job publication and result consumption +- `Lobby → Game Master` synchronous registration call (success and failure) +- `User Service → Lobby` cascade flow: permanent_block or DeleteUser on a + user leads to full RND release + memberships blocked + applications/invites + cancelled diff --git a/lobby/api/internal-openapi.yaml b/lobby/api/internal-openapi.yaml new file mode 100644 index 0000000..9cb1eac --- /dev/null +++ b/lobby/api/internal-openapi.yaml @@ -0,0 +1,946 @@ +openapi: 3.0.3 +info: + title: Galaxy Game Lobby Service Internal REST API + version: v1 + description: | + This specification documents the internal trusted REST contract of + `galaxy/lobby` served on `LOBBY_INTERNAL_HTTP_ADDR` (default `:8095`). + + This port is not reachable from the public internet. Two caller classes + use it: + + **Game Master integration paths** (`/api/v1/internal/…`): + - `GET /api/v1/internal/games/{game_id}` — game detail read for + `Game Master` and internal tooling + - `GET /api/v1/internal/games/{game_id}/memberships` — full membership + list for `Game Master` authorization checks + + Note: Lobby calls Game Master synchronously after a successful + container start (outgoing). The `register-runtime` endpoint lives on + Game Master's surface, not on Lobby's. Lobby does not accept inbound + `register-runtime` requests. + + **Admin Service paths** (same `/api/v1/lobby/…` paths as the public port): + - `Admin Service` enforces the system-admin role check at the gateway + boundary before calling these endpoints + - `X-User-ID` is NOT present on calls from `Admin Service`; Lobby treats + all callers on this port as trusted and performs no user-level auth + + Transport rules: + - request bodies are strict JSON only; unknown fields are rejected + - error responses use `{ "error": { "code", "message" } }` + - stable error codes match the public contract: `invalid_request`, + `conflict`, `subject_not_found`, `forbidden`, `internal_error`, + and `service_unavailable` +servers: + - url: http://localhost:8095 + description: Default local internal listener for Game Lobby Service. +tags: + - name: GMIntegration + description: Game Master integration paths for runtime binding and membership reads. + - name: AdminGames + description: Admin-mirrored game lifecycle paths called by Admin Service. + - name: AdminApplications + description: Admin-mirrored application approval paths called by Admin Service. + - name: AdminMemberships + description: Admin-mirrored membership operation paths called by Admin Service. + - name: Probes + description: Health and readiness probes. +paths: + /healthz: + get: + tags: + - Probes + operationId: internalHealthz + summary: Internal listener health probe + responses: + "200": + description: Service is alive. + content: + application/json: + schema: + $ref: "#/components/schemas/ProbeResponse" + examples: + ok: + value: + status: ok + /readyz: + get: + tags: + - Probes + operationId: internalReadyz + summary: Internal listener readiness probe + responses: + "200": + description: Service is ready to serve traffic. + content: + application/json: + schema: + $ref: "#/components/schemas/ProbeResponse" + examples: + ready: + value: + status: ready + /api/v1/internal/games/{game_id}: + get: + tags: + - GMIntegration + operationId: internalGetGame + summary: Get one game record for Game Master or internal tooling + description: | + Returns the full game record without visibility restrictions. Intended + for use by `Game Master` and internal administrative tooling. + parameters: + - $ref: "#/components/parameters/GameIDPath" + responses: + "200": + description: Full game record. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/internal/games/{game_id}/memberships: + get: + tags: + - GMIntegration + operationId: internalListMemberships + summary: List all memberships of a game for Game Master + description: | + Returns all memberships of the game without visibility restrictions. + Intended for `Game Master` authorization checks during command routing. + Pagination applies. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/PageSize" + - $ref: "#/components/parameters/PageToken" + responses: + "200": + description: One page of membership records. + content: + application/json: + schema: + $ref: "#/components/schemas/MembershipListResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games: + post: + tags: + - AdminGames + operationId: adminCreateGame + summary: Create a new game record (admin) + description: | + Creates a new game record in `draft` status. Used by `Admin Service` + for public game creation. Lobby trusts the caller and does not enforce + a user-level eligibility check on this port. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateGameRequest" + responses: + "201": + description: Game record created in draft status. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "400": + $ref: "#/components/responses/InvalidRequestError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + get: + tags: + - AdminGames + operationId: adminListGames + summary: List games (admin, unrestricted) + description: | + Returns a paginated list of games without visibility restrictions. + Used by `Admin Service` for administrative oversight. + parameters: + - $ref: "#/components/parameters/PageSize" + - $ref: "#/components/parameters/PageToken" + responses: + "200": + description: One page of game records. + content: + application/json: + schema: + $ref: "#/components/schemas/GameListResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}: + get: + tags: + - AdminGames + operationId: adminGetGame + summary: Get one game record (admin, unrestricted) + parameters: + - $ref: "#/components/parameters/GameIDPath" + responses: + "200": + description: Full game record without visibility restrictions. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + patch: + tags: + - AdminGames + operationId: adminUpdateGame + summary: Update mutable fields of a game record (admin) + parameters: + - $ref: "#/components/parameters/GameIDPath" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateGameRequest" + responses: + "200": + description: Updated game record. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/open-enrollment: + post: + tags: + - AdminGames + operationId: adminOpenEnrollment + summary: Transition a draft game to enrollment_open (admin) + parameters: + - $ref: "#/components/parameters/GameIDPath" + responses: + "200": + description: Updated game record with status enrollment_open. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/ready-to-start: + post: + tags: + - AdminGames + operationId: adminManualReadyToStart + summary: Manually close enrollment and transition to ready_to_start (admin) + parameters: + - $ref: "#/components/parameters/GameIDPath" + responses: + "200": + description: Updated game record with status ready_to_start. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/start: + post: + tags: + - AdminGames + operationId: adminStartGame + summary: Initiate the game start sequence (admin) + parameters: + - $ref: "#/components/parameters/GameIDPath" + responses: + "200": + description: Updated game record with status starting. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/pause: + post: + tags: + - AdminGames + operationId: adminPauseGame + summary: Apply a platform-level pause to a running game (admin) + parameters: + - $ref: "#/components/parameters/GameIDPath" + responses: + "200": + description: Updated game record with status paused. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/resume: + post: + tags: + - AdminGames + operationId: adminResumeGame + summary: Resume a paused game (admin) + parameters: + - $ref: "#/components/parameters/GameIDPath" + responses: + "200": + description: Updated game record with status running. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/cancel: + post: + tags: + - AdminGames + operationId: adminCancelGame + summary: Cancel a game that has not yet started running (admin) + parameters: + - $ref: "#/components/parameters/GameIDPath" + responses: + "200": + description: Updated game record with status cancelled. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/retry-start: + post: + tags: + - AdminGames + operationId: adminRetryStart + summary: Retry a failed start attempt (admin) + parameters: + - $ref: "#/components/parameters/GameIDPath" + responses: + "200": + description: Updated game record with status ready_to_start. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/applications/{application_id}/approve: + post: + tags: + - AdminApplications + operationId: adminApproveApplication + summary: Approve a submitted application (admin) + description: | + Approves a submitted application, reserves the race name, and creates + an active membership. On success, `lobby.membership.approved` + notification intent is published to the applicant. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/ApplicationIDPath" + responses: + "200": + description: Active membership created for the approved applicant. + content: + application/json: + schema: + $ref: "#/components/schemas/MembershipRecord" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/applications/{application_id}/reject: + post: + tags: + - AdminApplications + operationId: adminRejectApplication + summary: Reject a submitted application (admin) + description: | + Rejects a submitted application and releases any pending race name + reservation. On success, `lobby.membership.rejected` notification + intent is published to the applicant. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/ApplicationIDPath" + responses: + "200": + description: Application record with status rejected. + content: + application/json: + schema: + $ref: "#/components/schemas/ApplicationRecord" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/memberships: + get: + tags: + - AdminMemberships + operationId: adminListMemberships + summary: List memberships of a game (admin, unrestricted) + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/PageSize" + - $ref: "#/components/parameters/PageToken" + responses: + "200": + description: One page of membership records. + content: + application/json: + schema: + $ref: "#/components/schemas/MembershipListResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/memberships/{membership_id}/remove: + post: + tags: + - AdminMemberships + operationId: adminRemoveMember + summary: Remove a member from a game (admin) + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/MembershipIDPath" + responses: + "200": + description: Updated membership record with status removed. + content: + application/json: + schema: + $ref: "#/components/schemas/MembershipRecord" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/memberships/{membership_id}/block: + post: + tags: + - AdminMemberships + operationId: adminBlockMember + summary: Apply a platform-level block to a member (admin) + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/MembershipIDPath" + responses: + "200": + description: Updated membership record with status blocked. + content: + application/json: + schema: + $ref: "#/components/schemas/MembershipRecord" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" +components: + parameters: + GameIDPath: + name: game_id + in: path + required: true + description: Opaque stable game identifier. + schema: + type: string + ApplicationIDPath: + name: application_id + in: path + required: true + description: Opaque stable application identifier. + schema: + type: string + MembershipIDPath: + name: membership_id + in: path + required: true + description: Opaque stable membership identifier. + schema: + type: string + PageSize: + name: page_size + in: query + required: false + description: Maximum number of items to return. Default is `50`; maximum is `200`. + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + PageToken: + name: page_token + in: query + required: false + description: Opaque continuation token returned as `next_page_token` in a previous response. + schema: + type: string + schemas: + GameRecord: + type: object + additionalProperties: false + required: + - game_id + - game_name + - game_type + - owner_user_id + - status + - min_players + - max_players + - start_gap_hours + - start_gap_players + - enrollment_ends_at + - turn_schedule + - target_engine_version + - created_at + - updated_at + - current_turn + - runtime_status + - engine_health_summary + properties: + game_id: + type: string + description: Opaque stable game identifier in game-* form. + game_name: + type: string + description: Human-readable game name; mutable in draft status. + description: + type: string + description: Optional game description; mutable in draft and enrollment_open. + game_type: + type: string + enum: + - public + - private + description: Game visibility and enrollment model. + owner_user_id: + type: string + description: Platform user identifier of the private-game owner; empty for public games. + status: + type: string + enum: + - draft + - enrollment_open + - ready_to_start + - starting + - start_failed + - running + - paused + - finished + - cancelled + description: Current platform-level lifecycle status. + min_players: + type: integer + description: Minimum approved participants required to proceed to start. + max_players: + type: integer + description: Target roster size that activates the gap window. + start_gap_hours: + type: integer + description: Hours of gap window after max_players is reached. + start_gap_players: + type: integer + description: Additional participants admitted during the gap window. + enrollment_ends_at: + type: integer + format: int64 + description: UTC Unix seconds; deadline for automatic enrollment close. + turn_schedule: + type: string + description: Five-field cron expression for scheduled turn generation. + target_engine_version: + type: string + description: Semver of the game engine to launch. + created_at: + type: integer + format: int64 + description: UTC Unix milliseconds; record creation timestamp. + updated_at: + type: integer + format: int64 + description: UTC Unix milliseconds; last mutation timestamp. + started_at: + type: integer + format: int64 + description: UTC Unix milliseconds; set when status becomes running. + finished_at: + type: integer + format: int64 + description: UTC Unix milliseconds; set when status becomes finished. + current_turn: + type: integer + description: Denormalized from Game Master; zero until the game is running. + runtime_status: + type: string + description: Denormalized from Game Master; empty until the game is running. + engine_health_summary: + type: string + description: Denormalized from Game Master; empty until the game is running. + runtime_binding: + $ref: "#/components/schemas/RuntimeBinding" + RuntimeBinding: + type: object + additionalProperties: false + description: | + Runtime binding metadata produced by Runtime Manager after a successful + container start. Set on the game record only after the start sequence + succeeds; absent before then. + required: + - container_id + - engine_endpoint + - runtime_job_id + - bound_at + properties: + container_id: + type: string + description: Engine container identifier assigned by Runtime Manager. + engine_endpoint: + type: string + description: Network address Game Master uses to reach the engine container. + runtime_job_id: + type: string + description: | + Source `runtime:job_results` Redis Stream message id (in `-` + form) that produced this binding. Used for incident investigation. + bound_at: + type: integer + format: int64 + description: UTC Unix milliseconds when the binding was persisted. + ApplicationRecord: + type: object + additionalProperties: false + required: + - application_id + - game_id + - applicant_user_id + - race_name + - status + - created_at + properties: + application_id: + type: string + game_id: + type: string + applicant_user_id: + type: string + race_name: + type: string + status: + type: string + enum: + - submitted + - approved + - rejected + created_at: + type: integer + format: int64 + decided_at: + type: integer + format: int64 + MembershipRecord: + type: object + additionalProperties: false + required: + - membership_id + - game_id + - user_id + - race_name + - status + - joined_at + properties: + membership_id: + type: string + game_id: + type: string + user_id: + type: string + race_name: + type: string + status: + type: string + enum: + - active + - removed + - blocked + joined_at: + type: integer + format: int64 + removed_at: + type: integer + format: int64 + CreateGameRequest: + type: object + additionalProperties: false + required: + - game_name + - game_type + - min_players + - max_players + - start_gap_hours + - start_gap_players + - enrollment_ends_at + - turn_schedule + - target_engine_version + properties: + game_name: + type: string + description: + type: string + game_type: + type: string + enum: + - public + - private + min_players: + type: integer + minimum: 1 + max_players: + type: integer + minimum: 1 + start_gap_hours: + type: integer + minimum: 0 + start_gap_players: + type: integer + minimum: 0 + enrollment_ends_at: + type: integer + format: int64 + turn_schedule: + type: string + target_engine_version: + type: string + UpdateGameRequest: + type: object + additionalProperties: false + properties: + game_name: + type: string + description: + type: string + min_players: + type: integer + minimum: 1 + max_players: + type: integer + minimum: 1 + start_gap_hours: + type: integer + minimum: 0 + start_gap_players: + type: integer + minimum: 0 + enrollment_ends_at: + type: integer + format: int64 + turn_schedule: + type: string + target_engine_version: + type: string + GameListResponse: + type: object + additionalProperties: false + required: + - items + properties: + items: + type: array + items: + $ref: "#/components/schemas/GameRecord" + next_page_token: + type: string + MembershipListResponse: + type: object + additionalProperties: false + required: + - items + properties: + items: + type: array + items: + $ref: "#/components/schemas/MembershipRecord" + next_page_token: + type: string + ProbeResponse: + type: object + additionalProperties: false + required: + - status + properties: + status: + type: string + ErrorResponse: + type: object + additionalProperties: false + required: + - error + properties: + error: + $ref: "#/components/schemas/ErrorBody" + ErrorBody: + type: object + additionalProperties: false + required: + - code + - message + properties: + code: + type: string + description: Stable internal API error code. + message: + type: string + description: Human-readable trusted error message. + responses: + InvalidRequestError: + description: Request validation failed. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + invalidRequest: + value: + error: + code: invalid_request + message: request is invalid + NotFoundError: + description: The requested game, application, or membership does not exist. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + notFound: + value: + error: + code: subject_not_found + message: resource not found + ConflictError: + description: The requested state transition is not allowed from the current status. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + conflict: + value: + error: + code: conflict + message: operation not allowed in current status + InternalError: + description: Unexpected internal service error. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + internal: + value: + error: + code: internal_error + message: internal server error + ServiceUnavailableError: + description: An upstream dependency is unavailable. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + unavailable: + value: + error: + code: service_unavailable + message: service is unavailable diff --git a/lobby/api/public-openapi.yaml b/lobby/api/public-openapi.yaml new file mode 100644 index 0000000..0f0cfd8 --- /dev/null +++ b/lobby/api/public-openapi.yaml @@ -0,0 +1,1865 @@ +openapi: 3.0.3 +info: + title: Galaxy Game Lobby Service Public REST API + version: v1 + description: | + This specification documents the public authenticated REST contract of + `galaxy/lobby` served on `LOBBY_PUBLIC_HTTP_ADDR` (default `:8094`). + + This port is reached exclusively through `Edge Gateway`. Gateway verifies + the authenticated session and injects the `X-User-ID` header before + forwarding every request. `Lobby` derives the acting user identity from + `X-User-ID` only and must never accept identity claims from request bodies. + + Scope: + - game lifecycle management (create, update, get, list) + - enrollment management (open, close, ready-to-start) + - start lifecycle (start, pause, resume, cancel, retry-start) + - application flow for public games + - invite flow for private games + - membership operations + - user-facing lists (my games, my applications, my invitations) + + This specification intentionally does not describe: + - the internal trusted REST contract (see `api/internal-openapi.yaml`) + - Redis Stream event contracts (see `README.md`) + - notification intent contracts (see `../notification/README.md`) + + Transport rules: + - request bodies are strict JSON only; unknown fields are rejected + - all authenticated routes require `X-User-ID` injected by `Edge Gateway` + - error responses use `{ "error": { "code", "message" } }` + - stable error codes are `invalid_request`, `conflict`, `subject_not_found`, + `eligibility_denied`, `name_taken`, + `race_name_pending_window_expired`, + `race_name_registration_quota_exceeded`, `forbidden`, + `internal_error`, and `service_unavailable` + - `eligibility_denied`, `name_taken`, + `race_name_pending_window_expired`, and + `race_name_registration_quota_exceeded` are returned as `422` +servers: + - url: http://localhost:8094 + description: Default local public listener for Game Lobby Service. +tags: + - name: Games + description: Game record CRUD and lifecycle queries. + - name: Enrollment + description: Enrollment management commands. + - name: Lifecycle + description: Start, pause, resume, cancel, and retry-start commands. + - name: Applications + description: Application flow for public games. + - name: Invites + description: Invite flow for private games. + - name: Memberships + description: Membership roster operations. + - name: MyLists + description: Authenticated-user personal list queries. + - name: RaceNames + description: Race Name Directory user-facing operations. + - name: Probes + description: Health and readiness probes. +paths: + /healthz: + get: + tags: + - Probes + operationId: publicHealthz + summary: Public listener health probe + responses: + "200": + description: Service is alive. + content: + application/json: + schema: + $ref: "#/components/schemas/ProbeResponse" + examples: + ok: + value: + status: ok + /readyz: + get: + tags: + - Probes + operationId: publicReadyz + summary: Public listener readiness probe + responses: + "200": + description: Service is ready to serve traffic. + content: + application/json: + schema: + $ref: "#/components/schemas/ProbeResponse" + examples: + ready: + value: + status: ready + /api/v1/lobby/games: + post: + tags: + - Games + operationId: createGame + summary: Create a new game record in draft status + description: | + Creates a new game record in `draft` status. + + Authorization: + - `game_type=public`: requires system-admin role enforced upstream by + `Admin Service`; public games created on the internal port only in + normal operation + - `game_type=private`: requires the acting user's eligibility snapshot + from `User Service` to carry `can_create_private_game=true` + parameters: + - $ref: "#/components/parameters/XUserID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateGameRequest" + responses: + "201": + description: Game record created in draft status. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "400": + $ref: "#/components/responses/InvalidRequestError" + "403": + $ref: "#/components/responses/ForbiddenError" + "422": + $ref: "#/components/responses/DomainPreconditionError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + get: + tags: + - Games + operationId: listGames + summary: List public games with deterministic pagination + description: | + Returns a paginated list of public games with status in + `enrollment_open`, `ready_to_start`, `running`, or `finished`. + + Games in `draft` or `cancelled` status are excluded from the public + list. Authenticated users also see private games where they hold an + active membership. + + Default order: `enrollment_open` and `ready_to_start` first, then + `running`, then `finished` (most recent first within each group). + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/PageSize" + - $ref: "#/components/parameters/PageToken" + responses: + "200": + description: One deterministic page of game summaries. + content: + application/json: + schema: + $ref: "#/components/schemas/GameListResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}: + get: + tags: + - Games + operationId: getGame + summary: Get one game record + description: | + Returns the full game record for the requested `game_id`. + + Visibility rules: + - private `draft` games: visible only to the owner + - private non-draft games: visible to the owner and users with an + active membership or a non-expired invite + - public `draft` games: visible only to system administrators + - public non-draft games: visible to any authenticated user + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/XUserID" + responses: + "200": + description: Full game record. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + patch: + tags: + - Games + operationId: updateGame + summary: Update mutable fields of a game record + description: | + Partially updates a game record. + + Only fields present in the request body are modified; absent fields + retain their current values. + + Editable in `draft` status: all fields in the request schema. + Editable in `enrollment_open` status: `description` only. + All fields are immutable in all other statuses. + + Authorization: system administrator or private-game owner. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/XUserID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateGameRequest" + responses: + "200": + description: Updated game record. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "400": + $ref: "#/components/responses/InvalidRequestError" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/open-enrollment: + post: + tags: + - Enrollment + operationId: openEnrollment + summary: Transition a draft game to enrollment_open + description: | + Transitions the game from `draft` to `enrollment_open`. + + Authorization: system administrator or private-game owner. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/XUserID" + responses: + "200": + description: Updated game record with status enrollment_open. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/ready-to-start: + post: + tags: + - Enrollment + operationId: manualReadyToStart + summary: Manually close enrollment and transition to ready_to_start + description: | + Manually closes enrollment and transitions the game from + `enrollment_open` to `ready_to_start`. + + Pre-condition: `approved_count >= min_players`. + + Side effects: all invites in `created` status are transitioned to + `expired`; `lobby.invite.expired` notification intents are published + for each expired invite. + + Authorization: system administrator or private-game owner. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/XUserID" + responses: + "200": + description: Updated game record with status ready_to_start. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/start: + post: + tags: + - Lifecycle + operationId: startGame + summary: Initiate the game start sequence + description: | + Transitions the game from `ready_to_start` to `starting` and publishes + a start job to `Runtime Manager`. + + The final outcome (`running`, `paused`, or `start_failed`) is determined + asynchronously by the `Runtime Manager` result consumer. + + Authorization: system administrator or private-game owner. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/XUserID" + responses: + "200": + description: Updated game record with status starting. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/pause: + post: + tags: + - Lifecycle + operationId: pauseGame + summary: Apply a platform-level pause to a running game + description: | + Transitions the game from `running` to `paused`. + + This is a platform-level pause distinct from `Game Master` runtime + failure states. The engine container may remain alive. + + Authorization: system administrator or private-game owner. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/XUserID" + responses: + "200": + description: Updated game record with status paused. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/resume: + post: + tags: + - Lifecycle + operationId: resumeGame + summary: Resume a paused game + description: | + Transitions the game from `paused` to `running`. + + A synchronous `Game Master` liveness check is performed before the + transition. If `Game Master` is unreachable, the game remains `paused` + and `503 service_unavailable` is returned. + + Authorization: system administrator or private-game owner. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/XUserID" + responses: + "200": + description: Updated game record with status running. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/cancel: + post: + tags: + - Lifecycle + operationId: cancelGame + summary: Cancel a game that has not yet started running + description: | + Cancels the game. Allowed source statuses: `draft`, `enrollment_open`, + `ready_to_start`, `start_failed`. Not allowed from `starting`, + `running`, or `paused`. + + Authorization: system administrator or private-game owner. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/XUserID" + responses: + "200": + description: Updated game record with status cancelled. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/retry-start: + post: + tags: + - Lifecycle + operationId: retryStart + summary: Retry a failed start attempt + description: | + Transitions the game from `start_failed` back to `ready_to_start`, + enabling a new start attempt. + + Authorization: system administrator or private-game owner. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/XUserID" + responses: + "200": + description: Updated game record with status ready_to_start. + content: + application/json: + schema: + $ref: "#/components/schemas/GameRecord" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/applications: + post: + tags: + - Applications + operationId: submitApplication + summary: Submit a join application for a public game + description: | + Creates a new application in `submitted` status for a public game. + + Pre-conditions checked synchronously: + - game status is `enrollment_open` and game type is `public` + - acting user has no existing non-rejected application to the same game + - `User Service` eligibility confirms `can_join_game=true` + - roster capacity allows additional applicants + - Race Name Directory confirms `race_name` is available for the acting user + + On success, `lobby.application.submitted` notification intent is + published to the configured admin email list. + + Authorization: any authenticated user. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/XUserID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SubmitApplicationRequest" + responses: + "201": + description: Application created in submitted status. + content: + application/json: + schema: + $ref: "#/components/schemas/ApplicationRecord" + "400": + $ref: "#/components/responses/InvalidRequestError" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "422": + $ref: "#/components/responses/DomainPreconditionError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/applications/{application_id}/approve: + post: + tags: + - Applications + operationId: approveApplication + summary: Approve a submitted application + description: | + Approves a submitted application, reserves the race name, and creates + an active membership for the applicant. + + Pre-conditions: game is `enrollment_open`; application is `submitted`; + roster capacity allows additional approved participants. + + On success, `lobby.membership.approved` notification intent is published + to the applicant. + + Authorization: system administrator. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/ApplicationIDPath" + - $ref: "#/components/parameters/XUserID" + responses: + "200": + description: Active membership created for the approved applicant. + content: + application/json: + schema: + $ref: "#/components/schemas/MembershipRecord" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/applications/{application_id}/reject: + post: + tags: + - Applications + operationId: rejectApplication + summary: Reject a submitted application + description: | + Rejects a submitted application and releases any pending race name + reservation held for the applicant. + + On success, `lobby.membership.rejected` notification intent is published + to the applicant. + + Authorization: system administrator. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/ApplicationIDPath" + - $ref: "#/components/parameters/XUserID" + responses: + "200": + description: Application record with status rejected. + content: + application/json: + schema: + $ref: "#/components/schemas/ApplicationRecord" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/invites: + post: + tags: + - Invites + operationId: createInvite + summary: Create an invite for a private game + description: | + Creates a new invite in `created` status for the specified invitee. + + Pre-conditions: game is `enrollment_open` and `private`; the invitee + has no active invite or active membership in the game; roster capacity + allows additional participants. + + `expires_at` is set to `enrollment_ends_at` of the game. + + On success, `lobby.invite.created` notification intent is published + to the invitee. + + Authorization: private-game owner. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/XUserID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateInviteRequest" + responses: + "201": + description: Invite record created in created status. + content: + application/json: + schema: + $ref: "#/components/schemas/InviteRecord" + "400": + $ref: "#/components/responses/InvalidRequestError" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/invites/{invite_id}/redeem: + post: + tags: + - Invites + operationId: redeemInvite + summary: Redeem an invite and join a private game + description: | + Redeems a `created` invite, reserves the chosen race name, and creates + an active membership immediately without a separate owner-approval step. + + Pre-conditions: invite status is `created`; game is `enrollment_open`; + roster capacity allows additional participants; Race Name Directory + confirms `race_name` is available for the acting user. + + On success, `lobby.invite.redeemed` notification intent is published + to the private-game owner. + + Authorization: the invited user (invitee_user_id must match X-User-ID). + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/InviteIDPath" + - $ref: "#/components/parameters/XUserID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RedeemInviteRequest" + responses: + "200": + description: Active membership created for the redeeming user. + content: + application/json: + schema: + $ref: "#/components/schemas/MembershipRecord" + "400": + $ref: "#/components/responses/InvalidRequestError" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "422": + $ref: "#/components/responses/DomainPreconditionError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/invites/{invite_id}/decline: + post: + tags: + - Invites + operationId: declineInvite + summary: Decline a received invite + description: | + Transitions a `created` invite to `declined`. No notification is + published in v1. + + Declined users may receive a new invite from the owner while enrollment + is open. + + Authorization: the invited user (invitee_user_id must match X-User-ID). + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/InviteIDPath" + - $ref: "#/components/parameters/XUserID" + responses: + "200": + description: Invite record with status declined. + content: + application/json: + schema: + $ref: "#/components/schemas/InviteRecord" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/invites/{invite_id}/revoke: + post: + tags: + - Invites + operationId: revokeInvite + summary: Revoke a sent invite + description: | + Transitions a `created` invite to `revoked`. No notification is + published in v1. + + Authorization: private-game owner. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/InviteIDPath" + - $ref: "#/components/parameters/XUserID" + responses: + "200": + description: Invite record with status revoked. + content: + application/json: + schema: + $ref: "#/components/schemas/InviteRecord" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/memberships: + get: + tags: + - Memberships + operationId: listMemberships + summary: List memberships of a game + description: | + Returns a paginated list of memberships for the game. + + Authorization: system administrator, game owner, or active member. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/PageSize" + - $ref: "#/components/parameters/PageToken" + responses: + "200": + description: One deterministic page of membership records. + content: + application/json: + schema: + $ref: "#/components/schemas/MembershipListResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/memberships/{membership_id}/remove: + post: + tags: + - Memberships + operationId: removeMember + summary: Remove a member from a game + description: | + Removes an active member. + + Before game start: drops the membership and releases the race name + reservation. + After game start: marks membership `removed`; `Game Master` must + deactivate the player slot; race name reservation is retained until + the game finishes. + + Authorization: system administrator or private-game owner. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/MembershipIDPath" + - $ref: "#/components/parameters/XUserID" + responses: + "200": + description: Updated membership record with status removed. + content: + application/json: + schema: + $ref: "#/components/schemas/MembershipRecord" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/games/{game_id}/memberships/{membership_id}/block: + post: + tags: + - Memberships + operationId: blockMember + summary: Apply a platform-level block to a member + description: | + Blocks an active member. The engine slot is retained but the member + cannot send commands through `Game Master`. Race name reservation is + preserved. + + Authorization: system administrator or private-game owner. + parameters: + - $ref: "#/components/parameters/GameIDPath" + - $ref: "#/components/parameters/MembershipIDPath" + - $ref: "#/components/parameters/XUserID" + responses: + "200": + description: Updated membership record with status blocked. + content: + application/json: + schema: + $ref: "#/components/schemas/MembershipRecord" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/my/games: + get: + tags: + - MyLists + operationId: listMyGames + summary: List active games for the authenticated user + description: | + Returns games where the authenticated user holds an active membership + and the game status is `running` or `paused`. Response includes the + denormalized runtime snapshot for each game. + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/PageSize" + - $ref: "#/components/parameters/PageToken" + responses: + "200": + description: One page of active game records including runtime snapshot. + content: + application/json: + schema: + $ref: "#/components/schemas/GameListResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/my/applications: + get: + tags: + - MyLists + operationId: listMyApplications + summary: List pending applications for the authenticated user + description: | + Returns applications submitted by the authenticated user with status + `submitted`. Each item includes game name and type for display. + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/PageSize" + - $ref: "#/components/parameters/PageToken" + responses: + "200": + description: One page of submitted application items. + content: + application/json: + schema: + $ref: "#/components/schemas/MyApplicationListResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/my/invites: + get: + tags: + - MyLists + operationId: listMyInvites + summary: List open invites addressed to the authenticated user + description: | + Returns invites addressed to the authenticated user with status + `created`. Each item includes game name, inviter name, and `expires_at`. + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/PageSize" + - $ref: "#/components/parameters/PageToken" + responses: + "200": + description: One page of open invite items. + content: + application/json: + schema: + $ref: "#/components/schemas/MyInviteListResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/my/race-names: + get: + tags: + - RaceNames + operationId: listMyRaceNames + summary: List the acting user's race-name directory entries + description: | + Returns the acting user's view of the Race Name Directory across + all three levels of binding: permanent registered names, + `pending_registration` entries waiting for the 30-day window to + elapse, and active per-game reservations. Each reservation + carries the current `game_status` of its hosting game so the UI + can render it next to the game state. The endpoint reads only + the `user_registered` and `user_reservations` indexes; it never + scans the full directory. + + The response is exclusively scoped to the caller. There is no + `?user_id=` parameter; admin-side cross-user reads are not + exposed by this route. + parameters: + - $ref: "#/components/parameters/XUserID" + responses: + "200": + description: Snapshot of the acting user's race-name bindings. + content: + application/json: + schema: + $ref: "#/components/schemas/MyRaceNamesResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "403": + $ref: "#/components/responses/ForbiddenError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" + /api/v1/lobby/race-names/register: + post: + tags: + - RaceNames + operationId: registerRaceName + summary: Convert a pending race-name registration into a permanent one + description: | + Converts the caller's `pending_registration` for + `(source_game_id, race_name)` into a permanent registered race + name. The pending entry must still be inside its 30-day window, + the caller must not carry an active `permanent_block`, and the + caller's `max_registered_race_names` allowance from the User + Service eligibility snapshot must permit the new registration + (a value of `0` denotes the unlimited lifetime tariff). + + The call is idempotent: a repeated request with the same body + returns the previously registered record without consuming any + additional quota slot. The notification intent + `lobby.race_name.registered` is emitted on every successful + return; consumers deduplicate using the stable idempotency key + `lobby.race_name.registered::`. + parameters: + - $ref: "#/components/parameters/XUserID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RegisterRaceNameRequest" + responses: + "200": + description: Race name successfully registered. + content: + application/json: + schema: + $ref: "#/components/schemas/RegisteredRaceName" + "400": + $ref: "#/components/responses/InvalidRequestError" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "422": + $ref: "#/components/responses/DomainPreconditionError" + "500": + $ref: "#/components/responses/InternalError" + "503": + $ref: "#/components/responses/ServiceUnavailableError" +components: + parameters: + XUserID: + name: X-User-ID + in: header + required: true + description: | + Authenticated platform user identifier injected by `Edge Gateway`. + `Lobby` derives the acting user identity exclusively from this header. + schema: + type: string + GameIDPath: + name: game_id + in: path + required: true + description: Opaque stable game identifier. + schema: + type: string + ApplicationIDPath: + name: application_id + in: path + required: true + description: Opaque stable application identifier. + schema: + type: string + InviteIDPath: + name: invite_id + in: path + required: true + description: Opaque stable invite identifier. + schema: + type: string + MembershipIDPath: + name: membership_id + in: path + required: true + description: Opaque stable membership identifier. + schema: + type: string + PageSize: + name: page_size + in: query + required: false + description: | + Maximum number of items to return. Default is `50`; maximum is `200`. + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + PageToken: + name: page_token + in: query + required: false + description: Opaque continuation token returned as `next_page_token` in a previous response. + schema: + type: string + schemas: + GameRecord: + type: object + additionalProperties: false + required: + - game_id + - game_name + - game_type + - owner_user_id + - status + - min_players + - max_players + - start_gap_hours + - start_gap_players + - enrollment_ends_at + - turn_schedule + - target_engine_version + - created_at + - updated_at + - current_turn + - runtime_status + - engine_health_summary + properties: + game_id: + type: string + description: Opaque stable game identifier in game-* form. + game_name: + type: string + description: Human-readable game name; mutable in draft status. + description: + type: string + description: Optional game description; mutable in draft and enrollment_open. + game_type: + type: string + enum: + - public + - private + description: Game visibility and enrollment model. + owner_user_id: + type: string + description: Platform user identifier of the private-game owner; empty for public games. + status: + type: string + enum: + - draft + - enrollment_open + - ready_to_start + - starting + - start_failed + - running + - paused + - finished + - cancelled + description: Current platform-level lifecycle status. + min_players: + type: integer + description: Minimum approved participants required to proceed to start. + max_players: + type: integer + description: Target roster size that activates the gap window. + start_gap_hours: + type: integer + description: Hours of gap window after max_players is reached. + start_gap_players: + type: integer + description: Additional participants admitted during the gap window. + enrollment_ends_at: + type: integer + format: int64 + description: UTC Unix seconds; deadline for automatic enrollment close. + turn_schedule: + type: string + description: Five-field cron expression for scheduled turn generation; passed to Game Master at registration. + target_engine_version: + type: string + description: Semver of the game engine to launch; passed to Game Master at registration. + created_at: + type: integer + format: int64 + description: UTC Unix milliseconds; record creation timestamp. + updated_at: + type: integer + format: int64 + description: UTC Unix milliseconds; last mutation timestamp. + started_at: + type: integer + format: int64 + description: UTC Unix milliseconds; set when status becomes running. + finished_at: + type: integer + format: int64 + description: UTC Unix milliseconds; set when status becomes finished. + current_turn: + type: integer + description: Denormalized from Game Master; zero until the game is running. + runtime_status: + type: string + description: Denormalized from Game Master; empty until the game is running. + engine_health_summary: + type: string + description: Denormalized from Game Master; empty until the game is running. + runtime_binding: + $ref: "#/components/schemas/RuntimeBinding" + RuntimeBinding: + type: object + additionalProperties: false + description: | + Runtime binding metadata persisted on the game record after a + successful container start. Absent before the start sequence + completes. + required: + - container_id + - engine_endpoint + - runtime_job_id + - bound_at + properties: + container_id: + type: string + description: Engine container identifier assigned by Runtime Manager. + engine_endpoint: + type: string + description: Network address Game Master uses to reach the engine container. + runtime_job_id: + type: string + description: | + Source `runtime:job_results` Redis Stream message id (in `-` + form) that produced this binding. + bound_at: + type: integer + format: int64 + description: UTC Unix milliseconds when the binding was persisted. + ApplicationRecord: + type: object + additionalProperties: false + required: + - application_id + - game_id + - applicant_user_id + - race_name + - status + - created_at + properties: + application_id: + type: string + description: Opaque stable application identifier. + game_id: + type: string + description: Identifier of the game this application belongs to. + applicant_user_id: + type: string + description: Platform user identifier of the applicant. + race_name: + type: string + description: Desired in-game name submitted with the application. + status: + type: string + enum: + - submitted + - approved + - rejected + description: Current application lifecycle status. + created_at: + type: integer + format: int64 + description: UTC Unix milliseconds; application submission timestamp. + decided_at: + type: integer + format: int64 + description: UTC Unix milliseconds; set when application is approved or rejected. + InviteRecord: + type: object + additionalProperties: false + required: + - invite_id + - game_id + - inviter_user_id + - invitee_user_id + - status + - created_at + - expires_at + properties: + invite_id: + type: string + description: Opaque stable invite identifier. + game_id: + type: string + description: Identifier of the game this invite belongs to. + inviter_user_id: + type: string + description: Platform user identifier of the game owner who created the invite. + invitee_user_id: + type: string + description: Platform user identifier of the invited user. + race_name: + type: string + description: In-game name chosen by the invitee at redeem time; absent until the invite is redeemed. + status: + type: string + enum: + - created + - redeemed + - declined + - revoked + - expired + description: Current invite lifecycle status. + created_at: + type: integer + format: int64 + description: UTC Unix milliseconds; invite creation timestamp. + expires_at: + type: integer + format: int64 + description: UTC Unix milliseconds; equals enrollment_ends_at of the game at creation time. + decided_at: + type: integer + format: int64 + description: UTC Unix milliseconds; set when invite is redeemed, declined, revoked, or expired. + MembershipRecord: + type: object + additionalProperties: false + required: + - membership_id + - game_id + - user_id + - race_name + - status + - joined_at + properties: + membership_id: + type: string + description: Opaque stable membership identifier. + game_id: + type: string + description: Identifier of the game this membership belongs to. + user_id: + type: string + description: Platform user identifier of the member. + race_name: + type: string + description: Confirmed in-game name; reserved in Race Name Directory. + status: + type: string + enum: + - active + - removed + - blocked + description: Current membership status. + joined_at: + type: integer + format: int64 + description: UTC Unix milliseconds; membership activation timestamp. + removed_at: + type: integer + format: int64 + description: UTC Unix milliseconds; set when membership is removed or blocked. + MyApplicationItem: + type: object + additionalProperties: false + required: + - application_id + - game_id + - applicant_user_id + - race_name + - status + - created_at + - game_name + - game_type + properties: + application_id: + type: string + game_id: + type: string + applicant_user_id: + type: string + race_name: + type: string + status: + type: string + enum: + - submitted + - approved + - rejected + created_at: + type: integer + format: int64 + decided_at: + type: integer + format: int64 + game_name: + type: string + description: Human-readable game name for display purposes. + game_type: + type: string + enum: + - public + - private + description: Game type for display purposes. + MyInviteItem: + type: object + additionalProperties: false + required: + - invite_id + - game_id + - inviter_user_id + - invitee_user_id + - status + - created_at + - expires_at + - game_name + - inviter_name + properties: + invite_id: + type: string + game_id: + type: string + inviter_user_id: + type: string + invitee_user_id: + type: string + race_name: + type: string + status: + type: string + enum: + - created + - redeemed + - declined + - revoked + - expired + created_at: + type: integer + format: int64 + expires_at: + type: integer + format: int64 + decided_at: + type: integer + format: int64 + game_name: + type: string + description: Human-readable game name for display purposes. + inviter_name: + type: string + description: Owner's race name if already a member of the game; otherwise the owner's user_id. + CreateGameRequest: + type: object + additionalProperties: false + required: + - game_name + - game_type + - min_players + - max_players + - start_gap_hours + - start_gap_players + - enrollment_ends_at + - turn_schedule + - target_engine_version + properties: + game_name: + type: string + description: Human-readable game name; must be non-empty after trim. + description: + type: string + description: Optional game description. + game_type: + type: string + enum: + - public + - private + description: Game visibility and enrollment model. + min_players: + type: integer + minimum: 1 + description: Minimum approved participants required to proceed to start; must be <= max_players. + max_players: + type: integer + minimum: 1 + description: Target roster size that activates the gap window; must be >= min_players. + start_gap_hours: + type: integer + minimum: 0 + description: Hours of gap window after max_players is reached. + start_gap_players: + type: integer + minimum: 0 + description: Additional participants admitted during the gap window. + enrollment_ends_at: + type: integer + format: int64 + description: UTC Unix seconds; deadline for automatic enrollment close; must be a positive integer. + turn_schedule: + type: string + description: Valid five-field cron expression for scheduled turn generation. + target_engine_version: + type: string + description: Non-empty semver string of the game engine to launch. + UpdateGameRequest: + type: object + additionalProperties: false + description: | + Partial update of a game record. Only fields present in the request body + are modified. `game_name`, `min_players`, `max_players`, + `start_gap_hours`, `start_gap_players`, `enrollment_ends_at`, + `turn_schedule`, and `target_engine_version` are mutable in `draft` + status only. `description` is additionally mutable in `enrollment_open` + status. + properties: + game_name: + type: string + description: + type: string + min_players: + type: integer + minimum: 1 + max_players: + type: integer + minimum: 1 + start_gap_hours: + type: integer + minimum: 0 + start_gap_players: + type: integer + minimum: 0 + enrollment_ends_at: + type: integer + format: int64 + turn_schedule: + type: string + target_engine_version: + type: string + SubmitApplicationRequest: + type: object + additionalProperties: false + required: + - race_name + properties: + race_name: + type: string + description: Desired in-game name; must be available in the Race Name Directory. + CreateInviteRequest: + type: object + additionalProperties: false + required: + - invitee_user_id + properties: + invitee_user_id: + type: string + description: Platform user identifier of the user to invite. + RedeemInviteRequest: + type: object + additionalProperties: false + required: + - race_name + properties: + race_name: + type: string + description: Desired in-game name; must be available in the Race Name Directory. + RegisterRaceNameRequest: + type: object + additionalProperties: false + required: + - race_name + - source_game_id + properties: + race_name: + type: string + description: | + Original-casing race name to register. Must match the + canonical key of an existing `pending_registration` owned by + the caller in `source_game_id`. + source_game_id: + type: string + description: | + Identifier of the finished game whose capable finish + produced the pending registration to convert. + RegisteredRaceName: + type: object + additionalProperties: false + required: + - canonical_key + - race_name + - source_game_id + - registered_at_ms + properties: + canonical_key: + type: string + description: | + Race Name Directory canonical key derived from the policy + (lowercase + frozen confusable-pair map). + race_name: + type: string + description: Original-casing display value owned by the caller. + source_game_id: + type: string + description: | + Game whose capable finish produced the pending registration + converted by this call. + registered_at_ms: + type: integer + format: int64 + description: | + UTC Unix milliseconds timestamp recorded by the directory + on the original commit. Idempotent retries return the same + value. + PendingRaceName: + type: object + additionalProperties: false + required: + - canonical_key + - race_name + - source_game_id + - eligible_until_ms + properties: + canonical_key: + type: string + description: | + Race Name Directory canonical key derived from the policy + (lowercase + frozen confusable-pair map). + race_name: + type: string + description: Original-casing display value held by the caller. + source_game_id: + type: string + description: | + Game whose capable finish produced this pending entry. + Use this value as `source_game_id` when calling + `lobby.race_name.register`. + reserved_at_ms: + type: integer + format: int64 + description: | + UTC Unix milliseconds timestamp of the original `Reserve` + call that became this pending entry. + eligible_until_ms: + type: integer + format: int64 + description: | + UTC Unix milliseconds deadline for converting the pending + entry into a registered race name. After this moment the + pending-registration expiration worker releases it. + RaceNameReservation: + type: object + additionalProperties: false + required: + - canonical_key + - race_name + - game_id + - game_status + properties: + canonical_key: + type: string + description: | + Race Name Directory canonical key derived from the policy + (lowercase + frozen confusable-pair map). + race_name: + type: string + description: Original-casing display value held by the caller. + game_id: + type: string + description: Game hosting the reservation. + reserved_at_ms: + type: integer + format: int64 + description: | + UTC Unix milliseconds timestamp of the `Reserve` call. + game_status: + type: string + description: | + Current `game.Status` of the hosting game. Empty when the + game record cannot be loaded (defensive only — this should + not occur in normal operation). + MyRaceNamesResponse: + type: object + additionalProperties: false + required: + - registered + - pending + - reservations + properties: + registered: + type: array + items: + $ref: "#/components/schemas/RegisteredRaceName" + pending: + type: array + items: + $ref: "#/components/schemas/PendingRaceName" + reservations: + type: array + items: + $ref: "#/components/schemas/RaceNameReservation" + GameListResponse: + type: object + additionalProperties: false + required: + - items + properties: + items: + type: array + items: + $ref: "#/components/schemas/GameRecord" + next_page_token: + type: string + description: Opaque continuation token; absent when no further pages exist. + MembershipListResponse: + type: object + additionalProperties: false + required: + - items + properties: + items: + type: array + items: + $ref: "#/components/schemas/MembershipRecord" + next_page_token: + type: string + description: Opaque continuation token; absent when no further pages exist. + MyApplicationListResponse: + type: object + additionalProperties: false + required: + - items + properties: + items: + type: array + items: + $ref: "#/components/schemas/MyApplicationItem" + next_page_token: + type: string + description: Opaque continuation token; absent when no further pages exist. + MyInviteListResponse: + type: object + additionalProperties: false + required: + - items + properties: + items: + type: array + items: + $ref: "#/components/schemas/MyInviteItem" + next_page_token: + type: string + description: Opaque continuation token; absent when no further pages exist. + ProbeResponse: + type: object + additionalProperties: false + required: + - status + properties: + status: + type: string + description: Stable probe outcome string. + ErrorResponse: + type: object + additionalProperties: false + required: + - error + properties: + error: + $ref: "#/components/schemas/ErrorBody" + ErrorBody: + type: object + additionalProperties: false + required: + - code + - message + properties: + code: + type: string + description: Stable internal API error code. + message: + type: string + description: Human-readable trusted error message. + responses: + InvalidRequestError: + description: Request validation failed. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + invalidRequest: + value: + error: + code: invalid_request + message: request is invalid + ForbiddenError: + description: Caller is not authorized for this operation on this resource. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + forbidden: + value: + error: + code: forbidden + message: access denied + NotFoundError: + description: The requested game, application, invite, or membership does not exist. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + notFound: + value: + error: + code: subject_not_found + message: resource not found + ConflictError: + description: The requested state transition is not allowed from the current status. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + conflict: + value: + error: + code: conflict + message: operation not allowed in current status + DomainPreconditionError: + description: | + A domain-level precondition was not met. Stable codes returned under + this response: + - `eligibility_denied` — user not eligible per User Service + - `name_taken` — race_name is already reserved by another user + - `race_name_pending_window_expired` — the 30-day pending + registration window has lapsed + - `race_name_registration_quota_exceeded` — caller exhausted their + tariff `max_registered_race_names` allowance + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + eligibilityDenied: + value: + error: + code: eligibility_denied + message: user is not eligible to join games + nameTaken: + value: + error: + code: name_taken + message: race name is already taken + raceNamePendingWindowExpired: + value: + error: + code: race_name_pending_window_expired + message: pending race-name registration window has expired + raceNameRegistrationQuotaExceeded: + value: + error: + code: race_name_registration_quota_exceeded + message: race name registration quota exceeded + InternalError: + description: Unexpected internal service error. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + internal: + value: + error: + code: internal_error + message: internal server error + ServiceUnavailableError: + description: An upstream dependency is unavailable. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + unavailable: + value: + error: + code: service_unavailable + message: service is unavailable diff --git a/lobby/cmd/lobby/main.go b/lobby/cmd/lobby/main.go new file mode 100644 index 0000000..0564fd3 --- /dev/null +++ b/lobby/cmd/lobby/main.go @@ -0,0 +1,46 @@ +// Binary lobby is the runnable Game Lobby Service process entrypoint. +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + + "galaxy/lobby/internal/app" + "galaxy/lobby/internal/config" + "galaxy/lobby/internal/logging" +) + +func main() { + if err := run(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "lobby: %v\n", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.LoadFromEnv() + if err != nil { + return err + } + + logger, err := logging.New(cfg.Logging.Level) + if err != nil { + return err + } + + rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + runtime, err := app.NewRuntime(rootCtx, cfg, logger) + if err != nil { + return err + } + defer func() { + _ = runtime.Close() + }() + + return runtime.Run(rootCtx) +} diff --git a/lobby/contract_openapi_test.go b/lobby/contract_openapi_test.go new file mode 100644 index 0000000..9e5f30c --- /dev/null +++ b/lobby/contract_openapi_test.go @@ -0,0 +1,634 @@ +package lobby + +import ( + "context" + "encoding/json" + "net/http" + "path/filepath" + "runtime" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/require" +) + +// TestPublicOpenAPISpecValidates loads public-openapi.yaml and verifies it +// is a syntactically valid OpenAPI 3.0 document. +func TestPublicOpenAPISpecValidates(t *testing.T) { + t.Parallel() + loadPublicSpec(t) +} + +// TestInternalOpenAPISpecValidates loads internal-openapi.yaml and verifies +// it is a syntactically valid OpenAPI 3.0 document. +func TestInternalOpenAPISpecValidates(t *testing.T) { + t.Parallel() + loadInternalSpec(t) +} + +// TestPublicSpecFreezesGameCreateContract verifies that the game-create +// operation has a stable operationId, correct request and response schema +// references, and the expected required fields on CreateGameRequest. +func TestPublicSpecFreezesGameCreateContract(t *testing.T) { + t.Parallel() + + doc := loadPublicSpec(t) + op := getOperation(t, doc, "/api/v1/lobby/games", http.MethodPost) + + require.Equal(t, "createGame", op.OperationID) + assertOperationParameterRefs(t, op, "#/components/parameters/XUserID") + assertSchemaRef(t, requestSchemaRef(t, op), "#/components/schemas/CreateGameRequest", "createGame request") + assertSchemaRef(t, responseSchemaRef(t, op, http.StatusCreated), "#/components/schemas/GameRecord", "createGame 201") + assertSchemaRef(t, responseSchemaRef(t, op, http.StatusBadRequest), "#/components/schemas/ErrorResponse", "createGame 400") + assertSchemaRef(t, responseSchemaRef(t, op, http.StatusForbidden), "#/components/schemas/ErrorResponse", "createGame 403") + assertSchemaRef(t, responseSchemaRef(t, op, http.StatusUnprocessableEntity), "#/components/schemas/ErrorResponse", "createGame 422") + + req := componentSchemaRef(t, doc, "CreateGameRequest") + assertRequiredFields(t, req, + "game_name", "game_type", + "min_players", "max_players", + "start_gap_hours", "start_gap_players", + "enrollment_ends_at", "turn_schedule", "target_engine_version", + ) +} + +// TestPublicSpecFreezesGameRecordSchema verifies that GameRecord carries the +// full frozen field set from README.md and that optional fields are not +// listed as required. +func TestPublicSpecFreezesGameRecordSchema(t *testing.T) { + t.Parallel() + + doc := loadPublicSpec(t) + schema := componentSchemaRef(t, doc, "GameRecord") + + assertRequiredFields(t, schema, + "game_id", "game_name", "game_type", "owner_user_id", "status", + "min_players", "max_players", "start_gap_hours", "start_gap_players", + "enrollment_ends_at", "turn_schedule", "target_engine_version", + "created_at", "updated_at", + "current_turn", "runtime_status", "engine_health_summary", + ) + + // Optional fields must be present in properties but not in required. + for _, opt := range []string{"description", "started_at", "finished_at"} { + require.Contains(t, schema.Value.Properties, opt, "GameRecord.%s must be in properties", opt) + } +} + +// TestPublicSpecFreezesStatusEnums verifies that the game_status enum in +// GameRecord contains the full frozen 9-value set. +func TestPublicSpecFreezesStatusEnums(t *testing.T) { + t.Parallel() + + doc := loadPublicSpec(t) + + assertStringEnum(t, componentSchemaRef(t, doc, "GameRecord"), "status", + "draft", "enrollment_open", "ready_to_start", "starting", + "start_failed", "running", "paused", "finished", "cancelled", + ) + assertStringEnum(t, componentSchemaRef(t, doc, "GameRecord"), "game_type", + "public", "private", + ) +} + +// TestPublicSpecFreezesGameLifecycleContracts verifies that every state +// transition command has the correct operationId and returns a GameRecord on +// success. +func TestPublicSpecFreezesGameLifecycleContracts(t *testing.T) { + t.Parallel() + + doc := loadPublicSpec(t) + + cases := []struct { + path string + operationID string + }{ + {"/api/v1/lobby/games/{game_id}/open-enrollment", "openEnrollment"}, + {"/api/v1/lobby/games/{game_id}/ready-to-start", "manualReadyToStart"}, + {"/api/v1/lobby/games/{game_id}/start", "startGame"}, + {"/api/v1/lobby/games/{game_id}/pause", "pauseGame"}, + {"/api/v1/lobby/games/{game_id}/resume", "resumeGame"}, + {"/api/v1/lobby/games/{game_id}/cancel", "cancelGame"}, + {"/api/v1/lobby/games/{game_id}/retry-start", "retryStart"}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.operationID, func(t *testing.T) { + t.Parallel() + op := getOperation(t, doc, tc.path, http.MethodPost) + require.Equal(t, tc.operationID, op.OperationID) + assertSchemaRef(t, responseSchemaRef(t, op, http.StatusOK), "#/components/schemas/GameRecord", + tc.operationID+" 200") + }) + } +} + +// TestPublicSpecFreezesApplicationContracts verifies the three application +// operations: submit, approve, and reject. +func TestPublicSpecFreezesApplicationContracts(t *testing.T) { + t.Parallel() + + doc := loadPublicSpec(t) + + submitOp := getOperation(t, doc, "/api/v1/lobby/games/{game_id}/applications", http.MethodPost) + require.Equal(t, "submitApplication", submitOp.OperationID) + assertSchemaRef(t, requestSchemaRef(t, submitOp), "#/components/schemas/SubmitApplicationRequest", "submit request") + assertSchemaRef(t, responseSchemaRef(t, submitOp, http.StatusCreated), "#/components/schemas/ApplicationRecord", "submit 201") + + req := componentSchemaRef(t, doc, "SubmitApplicationRequest") + assertRequiredFields(t, req, "race_name") + + approveOp := getOperation(t, doc, "/api/v1/lobby/games/{game_id}/applications/{application_id}/approve", http.MethodPost) + require.Equal(t, "approveApplication", approveOp.OperationID) + assertSchemaRef(t, responseSchemaRef(t, approveOp, http.StatusOK), "#/components/schemas/MembershipRecord", "approve 200") + + rejectOp := getOperation(t, doc, "/api/v1/lobby/games/{game_id}/applications/{application_id}/reject", http.MethodPost) + require.Equal(t, "rejectApplication", rejectOp.OperationID) + assertSchemaRef(t, responseSchemaRef(t, rejectOp, http.StatusOK), "#/components/schemas/ApplicationRecord", "reject 200") + + appRecord := componentSchemaRef(t, doc, "ApplicationRecord") + assertRequiredFields(t, appRecord, + "application_id", "game_id", "applicant_user_id", "race_name", "status", "created_at", + ) + assertStringEnum(t, appRecord, "status", "submitted", "approved", "rejected") +} + +// TestPublicSpecFreezesInviteContracts verifies the four invite operations: +// create, redeem, decline, and revoke. +func TestPublicSpecFreezesInviteContracts(t *testing.T) { + t.Parallel() + + doc := loadPublicSpec(t) + + createOp := getOperation(t, doc, "/api/v1/lobby/games/{game_id}/invites", http.MethodPost) + require.Equal(t, "createInvite", createOp.OperationID) + assertSchemaRef(t, requestSchemaRef(t, createOp), "#/components/schemas/CreateInviteRequest", "create request") + assertSchemaRef(t, responseSchemaRef(t, createOp, http.StatusCreated), "#/components/schemas/InviteRecord", "create 201") + + req := componentSchemaRef(t, doc, "CreateInviteRequest") + assertRequiredFields(t, req, "invitee_user_id") + + redeemOp := getOperation(t, doc, "/api/v1/lobby/games/{game_id}/invites/{invite_id}/redeem", http.MethodPost) + require.Equal(t, "redeemInvite", redeemOp.OperationID) + assertSchemaRef(t, requestSchemaRef(t, redeemOp), "#/components/schemas/RedeemInviteRequest", "redeem request") + assertSchemaRef(t, responseSchemaRef(t, redeemOp, http.StatusOK), "#/components/schemas/MembershipRecord", "redeem 200") + + declineOp := getOperation(t, doc, "/api/v1/lobby/games/{game_id}/invites/{invite_id}/decline", http.MethodPost) + require.Equal(t, "declineInvite", declineOp.OperationID) + assertSchemaRef(t, responseSchemaRef(t, declineOp, http.StatusOK), "#/components/schemas/InviteRecord", "decline 200") + + revokeOp := getOperation(t, doc, "/api/v1/lobby/games/{game_id}/invites/{invite_id}/revoke", http.MethodPost) + require.Equal(t, "revokeInvite", revokeOp.OperationID) + assertSchemaRef(t, responseSchemaRef(t, revokeOp, http.StatusOK), "#/components/schemas/InviteRecord", "revoke 200") + + inviteRecord := componentSchemaRef(t, doc, "InviteRecord") + assertRequiredFields(t, inviteRecord, + "invite_id", "game_id", "inviter_user_id", "invitee_user_id", "status", "created_at", "expires_at", + ) + assertStringEnum(t, inviteRecord, "status", "created", "redeemed", "declined", "revoked", "expired") + + // race_name is optional on InviteRecord (set only at redeem time). + require.Contains(t, inviteRecord.Value.Properties, "race_name", "InviteRecord.race_name must be in properties") +} + +// TestPublicSpecFreezesMembershipContracts verifies the membership list, +// remove, and block operations. +func TestPublicSpecFreezesMembershipContracts(t *testing.T) { + t.Parallel() + + doc := loadPublicSpec(t) + + listOp := getOperation(t, doc, "/api/v1/lobby/games/{game_id}/memberships", http.MethodGet) + require.Equal(t, "listMemberships", listOp.OperationID) + assertSchemaRef(t, responseSchemaRef(t, listOp, http.StatusOK), "#/components/schemas/MembershipListResponse", "list 200") + + removeOp := getOperation(t, doc, "/api/v1/lobby/games/{game_id}/memberships/{membership_id}/remove", http.MethodPost) + require.Equal(t, "removeMember", removeOp.OperationID) + assertSchemaRef(t, responseSchemaRef(t, removeOp, http.StatusOK), "#/components/schemas/MembershipRecord", "remove 200") + + blockOp := getOperation(t, doc, "/api/v1/lobby/games/{game_id}/memberships/{membership_id}/block", http.MethodPost) + require.Equal(t, "blockMember", blockOp.OperationID) + assertSchemaRef(t, responseSchemaRef(t, blockOp, http.StatusOK), "#/components/schemas/MembershipRecord", "block 200") + + memberRecord := componentSchemaRef(t, doc, "MembershipRecord") + assertRequiredFields(t, memberRecord, + "membership_id", "game_id", "user_id", "race_name", "status", "joined_at", + ) + assertStringEnum(t, memberRecord, "status", "active", "removed", "blocked") + + // removed_at is optional. + require.Contains(t, memberRecord.Value.Properties, "removed_at", "MembershipRecord.removed_at must be in properties") +} + +// TestPublicSpecFreezesMyListContracts verifies that the three user-facing +// list endpoints have correct operationIds, pagination parameters, and +// response schema references. +func TestPublicSpecFreezesMyListContracts(t *testing.T) { + t.Parallel() + + doc := loadPublicSpec(t) + + myGamesOp := getOperation(t, doc, "/api/v1/lobby/my/games", http.MethodGet) + require.Equal(t, "listMyGames", myGamesOp.OperationID) + assertSchemaRef(t, responseSchemaRef(t, myGamesOp, http.StatusOK), "#/components/schemas/GameListResponse", "my/games 200") + + myAppsOp := getOperation(t, doc, "/api/v1/lobby/my/applications", http.MethodGet) + require.Equal(t, "listMyApplications", myAppsOp.OperationID) + assertSchemaRef(t, responseSchemaRef(t, myAppsOp, http.StatusOK), "#/components/schemas/MyApplicationListResponse", "my/applications 200") + + myInvitesOp := getOperation(t, doc, "/api/v1/lobby/my/invites", http.MethodGet) + require.Equal(t, "listMyInvites", myInvitesOp.OperationID) + assertSchemaRef(t, responseSchemaRef(t, myInvitesOp, http.StatusOK), "#/components/schemas/MyInviteListResponse", "my/invites 200") + + myAppItem := componentSchemaRef(t, doc, "MyApplicationItem") + assertRequiredFields(t, myAppItem, + "application_id", "game_id", "applicant_user_id", "race_name", + "status", "created_at", "game_name", "game_type", + ) + + myInviteItem := componentSchemaRef(t, doc, "MyInviteItem") + assertRequiredFields(t, myInviteItem, + "invite_id", "game_id", "inviter_user_id", "invitee_user_id", + "status", "created_at", "expires_at", "game_name", "inviter_name", + ) +} + +// TestPublicSpecFreezesMyRaceNamesContract verifies that the +// self-service GET endpoint and its response schemas are wired with the +// frozen field set. +func TestPublicSpecFreezesMyRaceNamesContract(t *testing.T) { + t.Parallel() + + doc := loadPublicSpec(t) + + op := getOperation(t, doc, "/api/v1/lobby/my/race-names", http.MethodGet) + require.Equal(t, "listMyRaceNames", op.OperationID) + assertOperationParameterRefs(t, op, "#/components/parameters/XUserID") + assertSchemaRef(t, responseSchemaRef(t, op, http.StatusOK), + "#/components/schemas/MyRaceNamesResponse", "listMyRaceNames 200") + + resp := componentSchemaRef(t, doc, "MyRaceNamesResponse") + assertRequiredFields(t, resp, "registered", "pending", "reservations") + + pending := componentSchemaRef(t, doc, "PendingRaceName") + assertRequiredFields(t, pending, + "canonical_key", "race_name", "source_game_id", "eligible_until_ms") + require.Contains(t, pending.Value.Properties, "reserved_at_ms", + "PendingRaceName.reserved_at_ms must be in properties") + + reservation := componentSchemaRef(t, doc, "RaceNameReservation") + assertRequiredFields(t, reservation, + "canonical_key", "race_name", "game_id", "game_status") + require.Contains(t, reservation.Value.Properties, "reserved_at_ms", + "RaceNameReservation.reserved_at_ms must be in properties") +} + +// TestPublicSpecFreezesErrorExamples verifies that the component response +// examples use the stable error codes defined in README.md. +func TestPublicSpecFreezesErrorExamples(t *testing.T) { + t.Parallel() + + doc := loadPublicSpec(t) + + cases := []struct { + response string + example string + wantCode string + }{ + {"InvalidRequestError", "invalidRequest", "invalid_request"}, + {"ForbiddenError", "forbidden", "forbidden"}, + {"NotFoundError", "notFound", "subject_not_found"}, + {"ConflictError", "conflict", "conflict"}, + {"InternalError", "internal", "internal_error"}, + {"ServiceUnavailableError", "unavailable", "service_unavailable"}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.response, func(t *testing.T) { + t.Parallel() + + val := responseExampleValue(t, doc, tc.response, tc.example) + payload, err := json.Marshal(val) + require.NoError(t, err) + + var envelope struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + require.NoError(t, json.Unmarshal(payload, &envelope)) + require.Equal(t, tc.wantCode, envelope.Error.Code) + }) + } + + // DomainPreconditionError must contain both eligibility_denied and name_taken examples. + eligibilityVal := responseExampleValue(t, doc, "DomainPreconditionError", "eligibilityDenied") + eligibilityPayload, err := json.Marshal(eligibilityVal) + require.NoError(t, err) + require.Contains(t, string(eligibilityPayload), "eligibility_denied") + + nameTakenVal := responseExampleValue(t, doc, "DomainPreconditionError", "nameTaken") + nameTakenPayload, err := json.Marshal(nameTakenVal) + require.NoError(t, err) + require.Contains(t, string(nameTakenPayload), "name_taken") +} + +// TestInternalSpecFreezesGMReadContracts verifies the GM-facing read +// endpoints: internal game get and internal membership list. +func TestInternalSpecFreezesGMReadContracts(t *testing.T) { + t.Parallel() + + doc := loadInternalSpec(t) + + getOp := getOperation(t, doc, "/api/v1/internal/games/{game_id}", http.MethodGet) + require.Equal(t, "internalGetGame", getOp.OperationID) + assertSchemaRef(t, responseSchemaRef(t, getOp, http.StatusOK), "#/components/schemas/GameRecord", "internalGetGame 200") + + listOp := getOperation(t, doc, "/api/v1/internal/games/{game_id}/memberships", http.MethodGet) + require.Equal(t, "internalListMemberships", listOp.OperationID) + assertSchemaRef(t, responseSchemaRef(t, listOp, http.StatusOK), "#/components/schemas/MembershipListResponse", "internalListMemberships 200") +} + +// TestInternalSpecFreezesAdminMirroredRoutes verifies that a representative +// subset of admin-mirrored routes exist with the expected operationIds and +// response schemas. +func TestInternalSpecFreezesAdminMirroredRoutes(t *testing.T) { + t.Parallel() + + doc := loadInternalSpec(t) + + createOp := getOperation(t, doc, "/api/v1/lobby/games", http.MethodPost) + require.Equal(t, "adminCreateGame", createOp.OperationID) + assertSchemaRef(t, responseSchemaRef(t, createOp, http.StatusCreated), "#/components/schemas/GameRecord", "adminCreateGame 201") + + cancelOp := getOperation(t, doc, "/api/v1/lobby/games/{game_id}/cancel", http.MethodPost) + require.Equal(t, "adminCancelGame", cancelOp.OperationID) + assertSchemaRef(t, responseSchemaRef(t, cancelOp, http.StatusOK), "#/components/schemas/GameRecord", "adminCancelGame 200") + + approveOp := getOperation(t, doc, "/api/v1/lobby/games/{game_id}/applications/{application_id}/approve", http.MethodPost) + require.Equal(t, "adminApproveApplication", approveOp.OperationID) + assertSchemaRef(t, responseSchemaRef(t, approveOp, http.StatusOK), "#/components/schemas/MembershipRecord", "adminApproveApplication 200") + + rejectOp := getOperation(t, doc, "/api/v1/lobby/games/{game_id}/applications/{application_id}/reject", http.MethodPost) + require.Equal(t, "adminRejectApplication", rejectOp.OperationID) + assertSchemaRef(t, responseSchemaRef(t, rejectOp, http.StatusOK), "#/components/schemas/ApplicationRecord", "adminRejectApplication 200") +} + +// TestPublicSpecDeclaresAllRegisteredRoutes asserts that every HTTP route +// registered by lobby/internal/api/publichttp is declared in +// public-openapi.yaml. The route table mirrors the mux.HandleFunc calls +// in publichttp/{server,games,applications,invites,memberships,mylists, +// pause_resume,racenames,ready_to_start,start}.go and must be updated +// whenever a new public route is registered. +func TestPublicSpecDeclaresAllRegisteredRoutes(t *testing.T) { + t.Parallel() + + doc := loadPublicSpec(t) + + for _, r := range publicHTTPRoutes() { + t.Run(r.Method+" "+r.Path, func(t *testing.T) { + t.Parallel() + getOperation(t, doc, r.Path, r.Method) + }) + } +} + +// TestInternalSpecDeclaresAllRegisteredRoutes asserts that every HTTP route +// registered by lobby/internal/api/internalhttp is declared in +// internal-openapi.yaml. The route table mirrors the mux.HandleFunc calls +// in internalhttp/{server,games,applications,memberships,pause_resume, +// ready_to_start,start}.go and must be updated whenever a new internal +// route is registered. +func TestInternalSpecDeclaresAllRegisteredRoutes(t *testing.T) { + t.Parallel() + + doc := loadInternalSpec(t) + + for _, r := range internalHTTPRoutes() { + t.Run(r.Method+" "+r.Path, func(t *testing.T) { + t.Parallel() + getOperation(t, doc, r.Path, r.Method) + }) + } +} + +type httpRoute struct { + Method string + Path string +} + +func publicHTTPRoutes() []httpRoute { + return []httpRoute{ + {http.MethodGet, "/healthz"}, + {http.MethodGet, "/readyz"}, + {http.MethodPost, "/api/v1/lobby/games"}, + {http.MethodGet, "/api/v1/lobby/games"}, + {http.MethodGet, "/api/v1/lobby/games/{game_id}"}, + {http.MethodPatch, "/api/v1/lobby/games/{game_id}"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/open-enrollment"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/cancel"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/applications"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/applications/{application_id}/approve"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/applications/{application_id}/reject"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/invites"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/invites/{invite_id}/redeem"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/invites/{invite_id}/decline"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/invites/{invite_id}/revoke"}, + {http.MethodGet, "/api/v1/lobby/games/{game_id}/memberships"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/memberships/{membership_id}/remove"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/memberships/{membership_id}/block"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/pause"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/resume"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/ready-to-start"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/start"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/retry-start"}, + {http.MethodPost, "/api/v1/lobby/race-names/register"}, + {http.MethodGet, "/api/v1/lobby/my/games"}, + {http.MethodGet, "/api/v1/lobby/my/applications"}, + {http.MethodGet, "/api/v1/lobby/my/invites"}, + {http.MethodGet, "/api/v1/lobby/my/race-names"}, + } +} + +func internalHTTPRoutes() []httpRoute { + return []httpRoute{ + {http.MethodGet, "/healthz"}, + {http.MethodGet, "/readyz"}, + {http.MethodGet, "/api/v1/internal/games/{game_id}"}, + {http.MethodGet, "/api/v1/internal/games/{game_id}/memberships"}, + {http.MethodPost, "/api/v1/lobby/games"}, + {http.MethodGet, "/api/v1/lobby/games"}, + {http.MethodGet, "/api/v1/lobby/games/{game_id}"}, + {http.MethodPatch, "/api/v1/lobby/games/{game_id}"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/open-enrollment"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/cancel"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/applications/{application_id}/approve"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/applications/{application_id}/reject"}, + {http.MethodGet, "/api/v1/lobby/games/{game_id}/memberships"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/memberships/{membership_id}/remove"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/memberships/{membership_id}/block"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/pause"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/resume"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/ready-to-start"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/start"}, + {http.MethodPost, "/api/v1/lobby/games/{game_id}/retry-start"}, + } +} + +// loadPublicSpec loads and validates lobby/api/public-openapi.yaml relative +// to this test file. +func loadPublicSpec(t *testing.T) *openapi3.T { + t.Helper() + return loadSpec(t, filepath.Join("api", "public-openapi.yaml")) +} + +// loadInternalSpec loads and validates lobby/api/internal-openapi.yaml +// relative to this test file. +func loadInternalSpec(t *testing.T) *openapi3.T { + t.Helper() + return loadSpec(t, filepath.Join("api", "internal-openapi.yaml")) +} + +func loadSpec(t *testing.T, rel string) *openapi3.T { + t.Helper() + + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + require.FailNow(t, "runtime.Caller failed") + } + + specPath := filepath.Join(filepath.Dir(thisFile), rel) + loader := openapi3.NewLoader() + doc, err := loader.LoadFromFile(specPath) + if err != nil { + require.Failf(t, "test failed", "load spec %s: %v", specPath, err) + } + if doc == nil { + require.Failf(t, "test failed", "load spec %s: returned nil document", specPath) + } + if err := doc.Validate(context.Background()); err != nil { + require.Failf(t, "test failed", "validate spec %s: %v", specPath, err) + } + + return doc +} + +func getOperation(t *testing.T, doc *openapi3.T, path, method string) *openapi3.Operation { + t.Helper() + + if doc.Paths == nil { + require.FailNow(t, "spec is missing paths") + } + pathItem := doc.Paths.Value(path) + if pathItem == nil { + require.Failf(t, "test failed", "spec is missing path %s", path) + } + op := pathItem.GetOperation(method) + if op == nil { + require.Failf(t, "test failed", "spec is missing %s operation for path %s", method, path) + } + + return op +} + +func requestSchemaRef(t *testing.T, op *openapi3.Operation) *openapi3.SchemaRef { + t.Helper() + + if op.RequestBody == nil || op.RequestBody.Value == nil { + require.FailNow(t, "operation is missing request body") + } + mt := op.RequestBody.Value.Content.Get("application/json") + if mt == nil || mt.Schema == nil { + require.FailNow(t, "operation is missing application/json request schema") + } + + return mt.Schema +} + +func responseSchemaRef(t *testing.T, op *openapi3.Operation, status int) *openapi3.SchemaRef { + t.Helper() + + ref := op.Responses.Status(status) + if ref == nil || ref.Value == nil { + require.Failf(t, "test failed", "operation is missing %d response", status) + } + mt := ref.Value.Content.Get("application/json") + if mt == nil || mt.Schema == nil { + require.Failf(t, "test failed", "operation is missing application/json schema for %d response", status) + } + + return mt.Schema +} + +func componentSchemaRef(t *testing.T, doc *openapi3.T, name string) *openapi3.SchemaRef { + t.Helper() + + if doc.Components.Schemas == nil { + require.FailNow(t, "spec is missing component schemas") + } + ref := doc.Components.Schemas[name] + if ref == nil { + require.Failf(t, "test failed", "spec is missing component schema %s", name) + } + + return ref +} + +func responseExampleValue(t *testing.T, doc *openapi3.T, responseName, exampleName string) any { + t.Helper() + + ref := doc.Components.Responses[responseName] + if ref == nil || ref.Value == nil { + require.Failf(t, "test failed", "spec is missing component response %s", responseName) + } + mt := ref.Value.Content.Get("application/json") + if mt == nil { + require.Failf(t, "test failed", "response %s is missing application/json content", responseName) + } + exRef := mt.Examples[exampleName] + if exRef == nil || exRef.Value == nil { + require.Failf(t, "test failed", "response %s is missing example %s", responseName, exampleName) + } + + return exRef.Value.Value +} + +func assertSchemaRef(t *testing.T, schemaRef *openapi3.SchemaRef, want, name string) { + t.Helper() + require.NotNil(t, schemaRef, "%s schema ref", name) + require.Equal(t, want, schemaRef.Ref, "%s schema ref", name) +} + +func assertRequiredFields(t *testing.T, schemaRef *openapi3.SchemaRef, fields ...string) { + t.Helper() + require.NotNil(t, schemaRef) + require.ElementsMatch(t, fields, schemaRef.Value.Required) +} + +func assertStringEnum(t *testing.T, schemaRef *openapi3.SchemaRef, property string, values ...string) { + t.Helper() + require.NotNil(t, schemaRef) + + propRef := schemaRef.Value.Properties[property] + require.NotNil(t, propRef, "schema property %s", property) + + got := make([]string, 0, len(propRef.Value.Enum)) + for _, v := range propRef.Value.Enum { + got = append(got, v.(string)) + } + + require.ElementsMatch(t, values, got) +} + +func assertOperationParameterRefs(t *testing.T, op *openapi3.Operation, refs ...string) { + t.Helper() + + got := make([]string, 0, len(op.Parameters)) + for _, p := range op.Parameters { + got = append(got, p.Ref) + } + + require.ElementsMatch(t, refs, got) +} diff --git a/lobby/docs/README.md b/lobby/docs/README.md new file mode 100644 index 0000000..08a5dfa --- /dev/null +++ b/lobby/docs/README.md @@ -0,0 +1,18 @@ +# Game Lobby Docs + +This directory keeps service-local documentation that is too detailed for the +root architecture documents and too diagram-heavy for the module README. + +Sections: +- [Runtime and components](runtime.md) +- [Flows](flows.md) +- [Operator runbook](runbook.md) +- [Configuration and contract examples](examples.md) + +Primary references: +- `../README.md` — service scope, contracts, configuration, observability. +- `../api/public-openapi.yaml` — public REST contract. +- `../api/internal-openapi.yaml` — internal REST contract. +- `../../ARCHITECTURE.md` — workspace architecture (§7 Game Lobby). +- `../../notification/README.md` — notification intent catalog. +- `../../user/README.md` — User Service eligibility surface. diff --git a/lobby/docs/examples.md b/lobby/docs/examples.md new file mode 100644 index 0000000..532d9f4 --- /dev/null +++ b/lobby/docs/examples.md @@ -0,0 +1,195 @@ +# Configuration And Contract Examples + +The examples below are illustrative. Replace `localhost`, port numbers, IDs, +and timestamps with values that match the deployment under inspection. + +## Example `.env` + +A minimum-viable `LOBBY_*` set for a local run against a single Redis +container. The full list with defaults lives in `../README.md` §Configuration. + +```bash +LOBBY_REDIS_ADDR=127.0.0.1:6379 +LOBBY_USER_SERVICE_BASE_URL=http://127.0.0.1:8083 +LOBBY_GM_BASE_URL=http://127.0.0.1:8096 + +LOBBY_PUBLIC_HTTP_ADDR=:8094 +LOBBY_INTERNAL_HTTP_ADDR=:8095 + +LOBBY_LOG_LEVEL=info +LOBBY_SHUTDOWN_TIMEOUT=30s + +LOBBY_RACE_NAME_DIRECTORY_BACKEND=redis +LOBBY_ENROLLMENT_AUTOMATION_INTERVAL=30s +LOBBY_RACE_NAME_EXPIRATION_INTERVAL=1h + +OTEL_SERVICE_NAME=galaxy-lobby +OTEL_TRACES_EXPORTER=none +OTEL_METRICS_EXPORTER=none +LOBBY_OTEL_STDOUT_TRACES_ENABLED=false +LOBBY_OTEL_STDOUT_METRICS_ENABLED=false +``` + +## Public HTTP Examples + +The public listener trusts the `X-User-ID` header injected by Edge Gateway. +Direct calls during development can supply the header manually. + +### Submit an application to a public game + +```bash +curl -s -X POST \ + -H 'Content-Type: application/json' \ + -H 'X-User-ID: user-01HZ...' \ + http://localhost:8094/api/v1/lobby/games/game-01HZ.../applications \ + -d '{"race_name":"Aurora"}' +``` + +Response (`200 OK`): + +```json +{ + "application_id": "application-01HZ...", + "game_id": "game-01HZ...", + "user_id": "user-01HZ...", + "status": "submitted", + "created_at": 1714081234567 +} +``` + +### List my open invites + +```bash +curl -s \ + -H 'X-User-ID: user-01HZ...' \ + 'http://localhost:8094/api/v1/lobby/my/invites?page_size=50' +``` + +### Register a race name from a pending entry + +```bash +curl -s -X POST \ + -H 'Content-Type: application/json' \ + -H 'X-User-ID: user-01HZ...' \ + http://localhost:8094/api/v1/lobby/race-names/register \ + -d '{"race_name":"Aurora"}' +``` + +A `422` response with `error.code="race_name_pending_window_expired"` +indicates the 30-day window has elapsed and the user must enter a new game +to re-establish eligibility. + +## Internal HTTP Examples + +The internal listener admits the admin actor without `X-User-ID` and serves +GM-facing read paths. + +### Create a public game (admin) + +```bash +curl -s -X POST \ + -H 'Content-Type: application/json' \ + http://localhost:8095/api/v1/lobby/games \ + -d '{ + "game_name": "Spring Tournament", + "game_type": "public", + "min_players": 4, + "max_players": 12, + "start_gap_hours": 24, + "start_gap_players": 4, + "enrollment_ends_at": 1716673200, + "turn_schedule": "0 18 * * *", + "target_engine_version": "1.4.0" + }' +``` + +### Read a game record (Game Master) + +```bash +curl -s http://localhost:8095/api/v1/internal/games/game-01HZ... +``` + +### List memberships for a running game (Game Master) + +```bash +curl -s http://localhost:8095/api/v1/internal/games/game-01HZ.../memberships +``` + +## Redis Examples + +### Inspect a game record + +```bash +redis-cli GET lobby:games:game-01HZ... +``` + +The value is a strict JSON blob with the fields documented in +`../README.md` §Game Record Model. + +### Publish a runtime job result (Runtime Manager simulation) + +Runtime Manager would normally publish this. The shape matches the consumer +in `internal/worker/runtimejobresult/consumer.go`. + +```bash +redis-cli XADD runtime:job_results '*' \ + job_id 'runtime-job-01HZ...' \ + game_id 'game-01HZ...' \ + outcome 'success' \ + container_id 'container-7f...' \ + engine_endpoint '127.0.0.1:9100' \ + bound_at_ms 1714081239876 +``` + +### Publish a Game Master runtime snapshot update + +```bash +redis-cli XADD gm:lobby_events '*' \ + kind 'runtime_snapshot_update' \ + game_id 'game-01HZ...' \ + current_turn '12' \ + runtime_status 'healthy' \ + engine_health_summary 'ok' \ + player_turn_stats '[{"user_id":"user-01HZ...","planets":4,"population":900,"ships_built":17}]' +``` + +### Publish a game-finished event + +```bash +redis-cli XADD gm:lobby_events '*' \ + kind 'game_finished' \ + game_id 'game-01HZ...' \ + finished_at_ms 1714123456789 +``` + +### Inspect open enrollment games (sorted by created_at) + +```bash +redis-cli ZRANGE lobby:games_by_status:enrollment_open 0 -1 WITHSCORES +``` + +## Notification Intent Format + +Lobby produces every notification through `pkg/notificationintent` and +appends to `notification:intents` with plain `XADD`. A representative +intent for `lobby.application.submitted`: + +```bash +redis-cli XADD notification:intents '*' \ + envelope '{ + "type": "lobby.application.submitted", + "producer": "lobby", + "idempotency_key": "lobby.application.submitted:application-01HZ...", + "audience": {"kind": "admin_email", "email_address_kind": "lobby_application_submitted"}, + "payload": { + "game_id": "game-01HZ...", + "game_name": "Spring Tournament", + "applicant_user_id": "user-01HZ...", + "applicant_name": "Aurora" + } + }' +``` + +The exact field set per type is documented in `../../notification/README.md` +and frozen by the AsyncAPI spec under +`../../notification/api/intents-asyncapi.yaml`. diff --git a/lobby/docs/flows.md b/lobby/docs/flows.md new file mode 100644 index 0000000..a879ae0 --- /dev/null +++ b/lobby/docs/flows.md @@ -0,0 +1,196 @@ +# Flows + +This document collects the eight platform flows that span Game Lobby plus +its synchronous and asynchronous neighbours. Narrative descriptions of the +rules these flows enforce live in `../README.md`; the diagrams here focus on +the message order across the boundary. + +## Public Game Application + +```mermaid +sequenceDiagram + participant User + participant Gateway + participant Lobby as Lobby publichttp + participant UserSvc as User Service + participant Redis + participant Stream as notification:intents + + User->>Gateway: lobby.application.submit(game_id, race_name) + Gateway->>Lobby: POST /api/v1/lobby/games/{id}/applications + X-User-ID + Lobby->>UserSvc: GetEligibility(user_id) + UserSvc-->>Lobby: snapshot (entitlement, sanctions) + Lobby->>Redis: persist Application(submitted) + indexes + Lobby->>Stream: lobby.application.submitted (admin recipients) + Lobby-->>Gateway: 200 ApplicationRecord +``` + +Approval and rejection follow the same pattern, mutating the application +status to `approved`/`rejected` and emitting +`lobby.membership.approved`/`lobby.membership.rejected` to the applicant. + +## Private Game Invite + +```mermaid +sequenceDiagram + participant Owner + participant Invitee + participant Lobby + participant Redis + participant Stream as notification:intents + + Owner->>Lobby: lobby.invite.create(invitee_user_id) + Lobby->>Redis: persist Invite(created) + Lobby->>Stream: lobby.invite.created (recipient: invitee) + + Invitee->>Lobby: lobby.invite.redeem(race_name) + Lobby->>Lobby: User Service guard for inviter and invitee + Lobby->>Redis: RND.Reserve + Membership(active) + Invite(redeemed) + Lobby->>Stream: lobby.invite.redeemed (recipient: owner) +``` + +The owner-facing decline and revoke transitions persist the invite status +update and produce no notification in v1. + +## Enrollment Automation + +```mermaid +sequenceDiagram + participant Tick as Worker tick + participant Lobby + participant Redis + participant Stream as notification:intents + + Tick->>Lobby: enrollment automation cycle + Lobby->>Redis: load enrollment_open games + roster sizes + alt deadline reached or gap exhausted + Lobby->>Redis: status enrollment_open → ready_to_start (CAS) + Lobby->>Redis: pending invites → expired + Lobby->>Stream: lobby.invite.expired (per expired invite) + else still within window + Lobby-->>Tick: no-op + end +``` + +Manual `lobby.game.ready_to_start` from owner or admin runs the same close +pipeline synchronously without waiting for the next tick. + +## Game Start (happy path) + +```mermaid +sequenceDiagram + participant Actor as Owner or Admin + participant Lobby + participant Redis + participant RT as Runtime Manager + participant GM as Game Master + + Actor->>Lobby: lobby.game.start + Lobby->>Redis: status ready_to_start → starting (CAS) + Lobby->>Redis: XADD runtime:start_jobs + RT->>Redis: XADD runtime:job_results (success + container metadata) + Lobby->>Redis: persist runtime_binding on game record + Lobby->>GM: POST /internal/games/{id}/register-runtime + GM-->>Lobby: 200 OK + Lobby->>Redis: status starting → running; set started_at +``` + +If runtime metadata persistence fails, Lobby publishes a stop-job to remove +the orphan container before flipping the game to `start_failed`. + +## Game Start (GM unavailable) + +```mermaid +sequenceDiagram + participant Lobby + participant Redis + participant GM as Game Master + participant Stream as notification:intents + + Lobby->>GM: POST /internal/games/{id}/register-runtime + GM-->>Lobby: timeout / 5xx + Lobby->>Redis: status starting → paused (CAS) + Lobby->>Stream: lobby.runtime_paused_after_start (admin) + Note over Lobby,GM: Container stays alive; admin restarts GM
and issues lobby.game.resume. +``` + +## Game Finish + Capability Evaluation + +```mermaid +sequenceDiagram + participant GM as Game Master + participant Stream as gm:lobby_events + participant Lobby + participant Redis + participant Intents as notification:intents + + GM->>Stream: XADD runtime_snapshot_update (player_turn_stats) + Lobby->>Redis: UpdateMax for each member's stats aggregate + GM->>Stream: XADD game_finished + Lobby->>Redis: status running/paused → finished; finished_at = event_ts + Lobby->>Redis: capability evaluator runs per active membership + alt member capable + Lobby->>Redis: RND.MarkPendingRegistration(eligible_until = finished_at + 30d) + Lobby->>Intents: lobby.race_name.registration_eligible (recipient: user) + else not capable + Lobby->>Redis: RND.ReleaseReservation + Lobby->>Intents: lobby.race_name.registration_denied (optional) + end + Lobby->>Redis: ReleaseReservation for removed/blocked memberships + Lobby->>Redis: delete per-game stats aggregate +``` + +The evaluation guard `lobby:capability_evaluation:done:` makes a +replayed `game_finished` event a no-op. + +## Race Name Registration + +```mermaid +sequenceDiagram + participant User + participant Lobby + participant UserSvc as User Service + participant RND as Race Name Directory + participant Stream as notification:intents + + User->>Lobby: lobby.race_name.register(race_name) + Lobby->>UserSvc: GetEligibility (sanctions, max_registered_race_names) + UserSvc-->>Lobby: snapshot + Lobby->>RND: Register(game_id, user_id, race_name) + RND-->>Lobby: ok / ErrPendingExpired / ErrQuotaExceeded + alt success + Lobby->>Stream: lobby.race_name.registered (recipient: user) + Lobby-->>User: 200 RegisteredRaceName + else precondition failure + Lobby-->>User: 422 DomainPreconditionError + end +``` + +Registration consumes one tariff slot keyed by `(canonical_key, user_id)`; +tariff downgrade never revokes existing registrations. + +## Cascade Release on User Lifecycle Event + +```mermaid +sequenceDiagram + participant US as User Service + participant Stream as user:lifecycle_events + participant Lobby + participant RT as Runtime Manager + participant Intents as notification:intents + + US->>Stream: XADD permanent_blocked or deleted + Lobby->>Stream: XREAD (consumer) + Lobby->>Lobby: RND.ReleaseAllByUser + Lobby->>Lobby: memberships → blocked + lobby.membership.blocked per private game + Lobby->>Lobby: applications → rejected + Lobby->>Lobby: invites (addressed and inviter-side) → revoked + Lobby->>Lobby: owned non-terminal games → cancelled (external_block trigger) + Lobby->>RT: XADD runtime:stop_jobs for in-flight owned games + Lobby->>Intents: lobby.membership.blocked per affected membership + Lobby->>Stream: advance offset +``` + +Every step is idempotent at the store layer (`ErrConflict` from a CAS is +treated as «already done»); the consumer only advances the offset once the +handler returns nil. diff --git a/lobby/docs/runbook.md b/lobby/docs/runbook.md new file mode 100644 index 0000000..d9764f9 --- /dev/null +++ b/lobby/docs/runbook.md @@ -0,0 +1,220 @@ +# Operator Runbook + +This runbook covers the checks that matter most during startup, steady-state +readiness, shutdown, and the handful of recovery paths specific to Lobby. + +## Startup Checks + +Before starting the process, confirm: + +- `LOBBY_REDIS_ADDR` points to the Redis deployment used for state and the + five Lobby-related streams. +- `LOBBY_USER_SERVICE_BASE_URL` and `LOBBY_GM_BASE_URL` are reachable from + the network the Lobby pods run in. Lobby does not ping these at boot, + but transport failures against them will surface as request errors. +- Stream names match the producers/consumers Lobby integrates with: + - `LOBBY_GM_EVENTS_STREAM` (default `gm:lobby_events`) + - `LOBBY_RUNTIME_START_JOBS_STREAM` (default `runtime:start_jobs`) + - `LOBBY_RUNTIME_STOP_JOBS_STREAM` (default `runtime:stop_jobs`) + - `LOBBY_RUNTIME_JOB_RESULTS_STREAM` (default `runtime:job_results`) + - `LOBBY_USER_LIFECYCLE_STREAM` (default `user:lifecycle_events`) + - `LOBBY_NOTIFICATION_INTENTS_STREAM` (default `notification:intents`) +- `LOBBY_RACE_NAME_DIRECTORY_BACKEND` is `redis` for production; the + `stub` value is only for unit tests. + +At startup the process performs a bounded `PING` against Redis. Startup +fails fast if the ping fails. There are no liveness checks against User +Service or Game Master at boot; those are surfaced at request time. + +Expected listener state after a healthy start: + +- public HTTP is enabled on `LOBBY_PUBLIC_HTTP_ADDR` (default `:8094`); +- internal HTTP is enabled on `LOBBY_INTERNAL_HTTP_ADDR` (default `:8095`); +- both ports answer `GET /healthz` and `GET /readyz`. + +Expected log lines: + +- `lobby starting` from `cmd/lobby`; +- one `redis ping ok` line; +- one `public http listening` and one `internal http listening` line; +- one `worker started` line per background worker (six expected). + +## Readiness + +Use the probes according to what they actually guarantee: + +- `GET /healthz` confirms the listener is alive; +- `GET /readyz` confirms the runtime wiring completed and Redis was reachable + at boot. + +`/readyz` is process-local. It does not confirm: + +- ongoing Redis health after boot; +- User Service reachability; +- Game Master reachability; +- worker liveness. + +For a practical readiness check in production: + +1. confirm the process emitted the listener and worker startup logs; +2. check `GET /healthz` and `GET /readyz` on both ports; +3. verify `lobby.active_games` gauge is non-zero in the metrics backend after + the first traffic; +4. verify `lobby.gm_events.oldest_unprocessed_age_ms` is small or zero after + GM starts emitting events. + +## Shutdown + +The process handles `SIGINT` and `SIGTERM`. + +Shutdown behavior: + +- the per-component shutdown budget is controlled by `LOBBY_SHUTDOWN_TIMEOUT`; +- HTTP listeners drain in-flight requests before closing; +- background workers stop their `XREAD` loops and persist the latest offset; +- pending consumer offsets are flushed before exit. + +During planned restarts: + +1. send `SIGTERM`; +2. wait for the listener and component-stop logs; +3. expect any worker that was mid-cycle to retry from the persisted offset + on the next process start; +4. investigate only if shutdown exceeds `LOBBY_SHUTDOWN_TIMEOUT`. + +## Stuck `starting` Recovery + +A game that flips to `starting` but never completes one of the post-start +steps will stay in `starting` until manual recovery. + +Symptoms: + +- `lobby.active_games{status="starting"}` gauge non-zero for longer than the + expected start budget (Runtime Manager start time + GM register call); +- per-game logs show `start_job_published` but no `runtime_job_result` or + `register_runtime_outcome` follow-up. + +Recovery: + +1. Identify the affected `game_id` from the gauge labels or logs. +2. Inspect `runtime:job_results` for the `runtime_job_id` published by + Lobby. If absent, Runtime Manager never produced a result; resolve at + the runtime layer. +3. If the result exists with `success=true` but no GM call was made, retry + with the admin or owner command `lobby.game.retry_start`. +4. If the result exists with `success=false`, transition through the + `start_failed` path and use `lobby.game.cancel` or `retry_start` once + the underlying issue is resolved. +5. If the metadata persistence step failed, Lobby has already published a + stop-job and moved the game to `start_failed`. Confirm the orphan + container was removed by Runtime Manager. + +Lobby always re-accepts a `start` command on a game that is stuck in +`starting`: the first action is a CAS attempt, and a second `start` from a +re-issued admin command will progress the state machine. + +## Stuck Stream Offsets + +Three stream-lag gauges describe the consumer health: + +- `lobby.gm_events.oldest_unprocessed_age_ms` +- `lobby.runtime_results.oldest_unprocessed_age_ms` +- `lobby.user_lifecycle.oldest_unprocessed_age_ms` + +A persistently increasing gauge means the consumer is unable to advance. +Causes and triage: + +1. **Decoder rejects a malformed entry.** The consumer logs `malformed_event` + and advances the offset; this should not stall the stream. If the gauge + keeps climbing, there is a real handler error. +2. **Handler returns a non-nil error.** The consumer holds the offset and + retries on every cycle. Inspect the latest log lines to identify the + error class (Redis transient, RND store error, RuntimeManager publish + failure for cascade events). +3. **Process restart loop.** A crash before persisting the offset does not + advance progress. Check pod restart counts and `cmd/lobby` panics. + +After the underlying cause is fixed, the consumer resumes from the persisted +offset; no manual intervention to the offset key is required in normal +operation. If a corrupt entry must be skipped, advance +`lobby:stream_offsets: