feat: game lobby service
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user