feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
@@ -0,0 +1,198 @@
package shared
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/domain/game"
"galaxy/lobby/internal/domain/invite"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/telemetry"
"galaxy/notificationintent"
)
// CloseEnrollmentDeps groups the collaborators that CloseEnrollment
// requires. The struct is reused by manualreadytostart and the
// enrollmentautomation worker so the enrollment-close pipeline lives in a
// single place.
type CloseEnrollmentDeps struct {
// Games mediates the authoritative game status transition.
Games ports.GameStore
// Invites is scanned for created records that must transition to
// expired alongside the close.
Invites ports.InviteStore
// Intents publishes lobby.invite.expired notifications, one per
// expired record. Failures are best-effort and do not roll back the
// already-committed status transitions.
Intents ports.IntentPublisher
// Logger receives best-effort warnings for invite-CAS conflicts and
// notification-publish failures. CloseEnrollment falls back to
// slog.Default when nil.
Logger *slog.Logger
// Telemetry records `lobby.game.transitions` once per close and
// `lobby.invite.outcomes` once per expired invite. Optional; nil
// disables metric emission.
Telemetry *telemetry.Runtime
}
// CloseEnrollment performs the enrollment_open → ready_to_start transition
// for gameID using trigger and at, then expires every still-created invite
// attached to the game, publishing one lobby.invite.expired intent per
// expired record. The post-transition game snapshot is returned.
//
// Failures of the game status transition (game.ErrConflict,
// game.ErrInvalidTransition, store technical errors) are returned to the
// caller so manualreadytostart can map them to HTTP responses and the
// worker can log and continue. Failures of individual invite CAS updates
// or intent publishes are logged and ignored — they are notification
// degradations, not authoritative state changes.
func CloseEnrollment(
ctx context.Context,
deps CloseEnrollmentDeps,
gameID common.GameID,
trigger game.Trigger,
at time.Time,
) (game.Game, error) {
if ctx == nil {
return game.Game{}, errors.New("close enrollment: nil context")
}
if deps.Games == nil {
return game.Game{}, errors.New("close enrollment: nil game store")
}
if deps.Invites == nil {
return game.Game{}, errors.New("close enrollment: nil invite store")
}
if deps.Intents == nil {
return game.Game{}, errors.New("close enrollment: nil intent publisher")
}
if err := gameID.Validate(); err != nil {
return game.Game{}, fmt.Errorf("close enrollment: %w", err)
}
if !trigger.IsKnown() {
return game.Game{}, fmt.Errorf("close enrollment: trigger %q is unsupported", trigger)
}
if at.IsZero() {
return game.Game{}, errors.New("close enrollment: at must not be zero")
}
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
if err := deps.Games.UpdateStatus(ctx, ports.UpdateStatusInput{
GameID: gameID,
ExpectedFrom: game.StatusEnrollmentOpen,
To: game.StatusReadyToStart,
Trigger: trigger,
At: at,
}); err != nil {
return game.Game{}, fmt.Errorf("close enrollment: %w", err)
}
deps.Telemetry.RecordGameTransition(ctx,
string(game.StatusEnrollmentOpen),
string(game.StatusReadyToStart),
string(trigger),
)
updated, err := deps.Games.Get(ctx, gameID)
if err != nil {
return game.Game{}, fmt.Errorf("close enrollment: %w", err)
}
expireCreatedInvites(ctx, deps, logger, updated, at)
return updated, nil
}
// expireCreatedInvites scans the invite store for the game and pushes
// every still-created record to the expired terminal status, publishing a
// per-invite lobby.invite.expired intent on success. All errors are
// logged and swallowed: an expired-but-unnotified invite is acceptable
// degradation while the authoritative game status is already advanced.
func expireCreatedInvites(
ctx context.Context,
deps CloseEnrollmentDeps,
logger *slog.Logger,
gameRecord game.Game,
at time.Time,
) {
invites, err := deps.Invites.GetByGame(ctx, gameRecord.GameID)
if err != nil {
logger.WarnContext(ctx, "list invites on enrollment close",
"game_id", gameRecord.GameID.String(),
"err", err.Error(),
)
return
}
for _, record := range invites {
if record.Status != invite.StatusCreated {
continue
}
if err := deps.Invites.UpdateStatus(ctx, ports.UpdateInviteStatusInput{
InviteID: record.InviteID,
ExpectedFrom: invite.StatusCreated,
To: invite.StatusExpired,
At: at,
}); err != nil {
logger.WarnContext(ctx, "expire invite on enrollment close",
"game_id", gameRecord.GameID.String(),
"invite_id", record.InviteID.String(),
"err", err.Error(),
)
continue
}
deps.Telemetry.RecordInviteOutcome(ctx, "expired")
intent, err := notificationintent.NewLobbyInviteExpiredIntent(
notificationintent.Metadata{
IdempotencyKey: "lobby.invite.expired:" + record.InviteID.String(),
OccurredAt: at,
},
gameRecord.OwnerUserID,
notificationintent.LobbyInviteExpiredPayload{
GameID: gameRecord.GameID.String(),
GameName: gameRecord.GameName,
InviteeUserID: record.InviteeUserID,
InviteeName: inviteeDisplayName(record),
},
)
if err != nil {
logger.ErrorContext(ctx, "build invite expired intent",
"invite_id", record.InviteID.String(),
"err", err.Error(),
)
continue
}
if _, publishErr := deps.Intents.Publish(ctx, intent); publishErr != nil {
logger.WarnContext(ctx, "publish invite expired intent",
"invite_id", record.InviteID.String(),
"err", publishErr.Error(),
)
}
}
}
// inviteeDisplayName returns the invitee display name carried by the
// expired-invite notification. The invitee never redeemed (RaceName is
// empty for created invites) so the user id stands in as the readable
// label, matching the createinvite fallback for absent inviter
// memberships.
func inviteeDisplayName(record invite.Invite) string {
if record.RaceName != "" {
return record.RaceName
}
return record.InviteeUserID
}