// Package removemember implements the `lobby.membership.remove` message // type. Before the game has reached the running engine slot the // membership record is dropped from the store and the race name // reservation is released; after the engine slot exists the membership // transitions to `removed` and the reservation is preserved so the // capability evaluator at game finish can resolve it. package removemember import ( "context" "errors" "fmt" "log/slog" "time" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/domain/membership" "galaxy/lobby/internal/logging" "galaxy/lobby/internal/ports" "galaxy/lobby/internal/service/shared" "galaxy/lobby/internal/telemetry" ) // preStartStatuses enumerates the game statuses in which a membership // may exist but the engine container slot does not. Removing in these // statuses drops the membership outright and releases the reservation. var preStartStatuses = map[game.Status]struct{}{ game.StatusEnrollmentOpen: {}, game.StatusReadyToStart: {}, game.StatusStarting: {}, game.StatusStartFailed: {}, } // postStartStatuses enumerates the game statuses in which the engine // container slot is alive. Removing in these statuses transitions the // membership to `removed` and keeps the reservation until the // capability evaluator runs at `game_finished`. var postStartStatuses = map[game.Status]struct{}{ game.StatusRunning: {}, game.StatusPaused: {}, } // Service executes the remove-member use case. type Service struct { games ports.GameStore memberships ports.MembershipStore directory ports.RaceNameDirectory clock func() time.Time logger *slog.Logger telemetry *telemetry.Runtime } // Dependencies groups the collaborators used by Service. type Dependencies struct { // Games loads the game record so the service can branch on // game.Status and authorize the actor against the owner. Games ports.GameStore // Memberships persists the post-start status transition and the // pre-start drop. Memberships ports.MembershipStore // Directory releases the race name reservation in the pre-start // branch. The post-start branch never invokes Directory. Directory ports.RaceNameDirectory // Clock supplies the wall-clock used for the RemovedAt stamp. // Defaults to time.Now when nil. Clock func() time.Time // Logger records structured service-level events. Defaults to // slog.Default when nil. Logger *slog.Logger // Telemetry records the `lobby.membership.changes` counter on each // successful removal. Optional; nil disables metric emission. Telemetry *telemetry.Runtime } // NewService constructs one Service with deps. func NewService(deps Dependencies) (*Service, error) { switch { case deps.Games == nil: return nil, errors.New("new remove member service: nil game store") case deps.Memberships == nil: return nil, errors.New("new remove member service: nil membership store") case deps.Directory == nil: return nil, errors.New("new remove member service: nil race name directory") } clock := deps.Clock if clock == nil { clock = time.Now } logger := deps.Logger if logger == nil { logger = slog.Default() } return &Service{ games: deps.Games, memberships: deps.Memberships, directory: deps.Directory, clock: clock, logger: logger.With("service", "lobby.removemember"), telemetry: deps.Telemetry, }, nil } // Input stores the arguments required to remove one membership. type Input struct { // Actor identifies the caller. Actor shared.Actor // GameID identifies the game referenced by the request path; it // must match the loaded membership's GameID. GameID common.GameID // MembershipID identifies the target membership. MembershipID common.MembershipID } // Handle authorizes the actor, asserts the membership belongs to the // game, branches on the game status to either drop or transition the // membership, and returns the post-removal record. The pre-start // branch returns a synthesized record with status=removed because the // underlying primary record has been deleted from the store. func (service *Service) Handle(ctx context.Context, input Input) (membership.Membership, error) { if service == nil { return membership.Membership{}, errors.New("remove member: nil service") } if ctx == nil { return membership.Membership{}, errors.New("remove member: nil context") } if err := input.Actor.Validate(); err != nil { return membership.Membership{}, fmt.Errorf("remove member: actor: %w", err) } if err := input.GameID.Validate(); err != nil { return membership.Membership{}, fmt.Errorf("remove member: %w", err) } if err := input.MembershipID.Validate(); err != nil { return membership.Membership{}, fmt.Errorf("remove member: %w", err) } member, err := service.memberships.Get(ctx, input.MembershipID) if err != nil { return membership.Membership{}, fmt.Errorf("remove member: %w", err) } if member.GameID != input.GameID { return membership.Membership{}, fmt.Errorf( "remove member: membership %q does not belong to game %q: %w", member.MembershipID, input.GameID, membership.ErrNotFound, ) } gameRecord, err := service.games.Get(ctx, member.GameID) if err != nil { return membership.Membership{}, fmt.Errorf("remove member: %w", err) } if err := authorize(input.Actor, gameRecord); err != nil { return membership.Membership{}, err } if member.Status != membership.StatusActive { return membership.Membership{}, fmt.Errorf( "remove member: status %q is not %q: %w", member.Status, membership.StatusActive, membership.ErrConflict, ) } _, preStart := preStartStatuses[gameRecord.Status] _, postStart := postStartStatuses[gameRecord.Status] if !preStart && !postStart { return membership.Membership{}, fmt.Errorf( "remove member: game status %q does not allow remove: %w", gameRecord.Status, game.ErrConflict, ) } now := service.clock().UTC() if postStart { if err := service.memberships.UpdateStatus(ctx, ports.UpdateMembershipStatusInput{ MembershipID: member.MembershipID, ExpectedFrom: membership.StatusActive, To: membership.StatusRemoved, At: now, }); err != nil { return membership.Membership{}, fmt.Errorf("remove member: %w", err) } service.telemetry.RecordMembershipChange(ctx, "removed") updated, err := service.memberships.Get(ctx, member.MembershipID) if err != nil { return membership.Membership{}, fmt.Errorf("remove member: %w", err) } logArgs := []any{ "game_id", gameRecord.GameID.String(), "game_status", string(gameRecord.Status), "membership_id", member.MembershipID.String(), "user_id", member.UserID, "trigger", "post_start", "actor_kind", string(input.Actor.Kind), } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) service.logger.InfoContext(ctx, "member removed (post-start)", logArgs...) return updated, nil } if err := service.memberships.Delete(ctx, member.MembershipID); err != nil { return membership.Membership{}, fmt.Errorf("remove member: %w", err) } if err := service.directory.ReleaseReservation( ctx, gameRecord.GameID.String(), member.UserID, member.RaceName, ); err != nil { // The directory contract states ReleaseReservation is a no-op // for missing / mismatched / invalid records, so a non-nil // error here is unexpected. Log and proceed — the membership // record is already gone. service.logger.WarnContext(ctx, "release reservation on pre-start remove", "membership_id", member.MembershipID.String(), "err", err.Error(), ) } service.telemetry.RecordMembershipChange(ctx, "removed") logArgs := []any{ "game_id", gameRecord.GameID.String(), "game_status", string(gameRecord.Status), "membership_id", member.MembershipID.String(), "user_id", member.UserID, "trigger", "pre_start", "actor_kind", string(input.Actor.Kind), } logArgs = append(logArgs, logging.ContextAttrs(ctx)...) service.logger.InfoContext(ctx, "member removed (pre-start drop)", logArgs...) removedAt := now synthesized := member synthesized.Status = membership.StatusRemoved synthesized.RemovedAt = &removedAt return synthesized, nil } // authorize enforces admin OR private-owner access to the record. func authorize(actor shared.Actor, record game.Game) error { if actor.IsAdmin() { return nil } if record.GameType == game.GameTypePrivate && actor.UserID == record.OwnerUserID { return nil } return fmt.Errorf("%w: actor is not authorized to remove members from game %q", shared.ErrForbidden, record.GameID.String()) }