Files
galaxy-game/lobby/docs/flows.md
T
2026-04-25 23:20:55 +02:00

6.7 KiB

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

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

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

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)

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)

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<br/>and issues lobby.game.resume.

Game Finish + Capability Evaluation

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:<game_id> makes a replayed game_finished event a no-op.

Race Name Registration

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

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.