// Package gameinmem provides an in-memory ports.GameStore implementation // for service-level tests. It mirrors the behavioural contract of the // Redis-backed adapter in redisstate: it enforces game.Transition for // status updates, the ExpectedFrom CAS check, and the // StartedAt/FinishedAt side effects of the canonical status transitions. // // Production code never wires this adapter; it is test-only but exposed // as a regular (non _test.go) package so other service test packages can // import it. package gameinmem import ( "context" "errors" "fmt" "sort" "strings" "sync" "galaxy/lobby/internal/domain/common" "galaxy/lobby/internal/domain/game" "galaxy/lobby/internal/ports" ) // Store is a concurrency-safe in-memory implementation of ports.GameStore. // The zero value is not usable; call NewStore to construct. type Store struct { mu sync.Mutex records map[common.GameID]game.Game } // NewStore constructs one empty Store ready for use. func NewStore() *Store { return &Store{records: make(map[common.GameID]game.Game)} } // Save upserts record. It honors the contract stated by // ports.GameStore.Save: Save does not apply the domain transition gate but // validates the record. func (store *Store) Save(ctx context.Context, record game.Game) error { if store == nil { return errors.New("save game: nil store") } if ctx == nil { return errors.New("save game: nil context") } if err := record.Validate(); err != nil { return fmt.Errorf("save game: %w", err) } store.mu.Lock() defer store.mu.Unlock() store.records[record.GameID] = record return nil } // Get returns the record identified by gameID. It returns game.ErrNotFound // when no record exists. func (store *Store) Get(ctx context.Context, gameID common.GameID) (game.Game, error) { if store == nil { return game.Game{}, errors.New("get game: nil store") } if ctx == nil { return game.Game{}, errors.New("get game: nil context") } if err := gameID.Validate(); err != nil { return game.Game{}, fmt.Errorf("get game: %w", err) } store.mu.Lock() defer store.mu.Unlock() record, ok := store.records[gameID] if !ok { return game.Game{}, game.ErrNotFound } return record, nil } // CountByStatus returns the per-status game record count. Every status from // game.AllStatuses is present in the result, with zero values for empty // buckets, mirroring the Redis adapter contract. func (store *Store) CountByStatus(ctx context.Context) (map[game.Status]int, error) { if store == nil { return nil, errors.New("count games by status: nil store") } if ctx == nil { return nil, errors.New("count games by status: nil context") } store.mu.Lock() defer store.mu.Unlock() counts := make(map[game.Status]int, len(game.AllStatuses())) for _, status := range game.AllStatuses() { counts[status] = 0 } for _, record := range store.records { counts[record.Status]++ } return counts, nil } // GetByStatus returns every record whose Status equals status. The slice is // ordered by CreatedAt ascending to match the Redis adapter. func (store *Store) GetByStatus(ctx context.Context, status game.Status) ([]game.Game, error) { if store == nil { return nil, errors.New("get games by status: nil store") } if ctx == nil { return nil, errors.New("get games by status: nil context") } if !status.IsKnown() { return nil, fmt.Errorf("get games by status: status %q is unsupported", status) } store.mu.Lock() defer store.mu.Unlock() matching := make([]game.Game, 0, len(store.records)) for _, record := range store.records { if record.Status == status { matching = append(matching, record) } } sort.Slice(matching, func(i, j int) bool { return matching[i].CreatedAt.Before(matching[j].CreatedAt) }) return matching, nil } // GetByOwner returns every record whose OwnerUserID equals userID. The // slice is ordered by CreatedAt ascending to match the Redis adapter. func (store *Store) GetByOwner(ctx context.Context, userID string) ([]game.Game, error) { if store == nil { return nil, errors.New("get games by owner: nil store") } if ctx == nil { return nil, errors.New("get games by owner: nil context") } trimmed := strings.TrimSpace(userID) if trimmed == "" { return nil, fmt.Errorf("get games by owner: user id must not be empty") } store.mu.Lock() defer store.mu.Unlock() matching := make([]game.Game, 0, len(store.records)) for _, record := range store.records { if record.OwnerUserID == trimmed { matching = append(matching, record) } } sort.Slice(matching, func(i, j int) bool { return matching[i].CreatedAt.Before(matching[j].CreatedAt) }) return matching, nil } // UpdateStatus applies one status transition in a compare-and-swap fashion. // It returns an error from game.Transition for invalid triplets, returns // game.ErrNotFound for a missing record, and game.ErrConflict when the // current status differs from input.ExpectedFrom. func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateStatusInput) error { if store == nil { return errors.New("update game status: nil store") } if ctx == nil { return errors.New("update game status: nil context") } if err := input.Validate(); err != nil { return fmt.Errorf("update game status: %w", err) } if err := game.Transition(input.ExpectedFrom, input.To, input.Trigger); err != nil { return err } store.mu.Lock() defer store.mu.Unlock() record, ok := store.records[input.GameID] if !ok { return game.ErrNotFound } if record.Status != input.ExpectedFrom { return fmt.Errorf("update game status: %w", game.ErrConflict) } at := input.At.UTC() record.Status = input.To record.UpdatedAt = at if input.To == game.StatusRunning && record.StartedAt == nil { startedAt := at record.StartedAt = &startedAt } if input.To == game.StatusFinished && record.FinishedAt == nil { finishedAt := at record.FinishedAt = &finishedAt } store.records[input.GameID] = record return nil } // UpdateRuntimeSnapshot overwrites the denormalized runtime snapshot fields // on the record identified by input.GameID. It does not change the status // field. func (store *Store) UpdateRuntimeSnapshot(ctx context.Context, input ports.UpdateRuntimeSnapshotInput) error { if store == nil { return errors.New("update runtime snapshot: nil store") } if ctx == nil { return errors.New("update runtime snapshot: nil context") } if err := input.Validate(); err != nil { return fmt.Errorf("update runtime snapshot: %w", err) } store.mu.Lock() defer store.mu.Unlock() record, ok := store.records[input.GameID] if !ok { return game.ErrNotFound } record.RuntimeSnapshot = input.Snapshot record.UpdatedAt = input.At.UTC() store.records[input.GameID] = record return nil } // UpdateRuntimeBinding overwrites the runtime binding metadata on the // record identified by input.GameID. It does not change the status // field. uses this method from the runtimejobresult worker // after a successful container start. func (store *Store) UpdateRuntimeBinding(ctx context.Context, input ports.UpdateRuntimeBindingInput) error { if store == nil { return errors.New("update runtime binding: nil store") } if ctx == nil { return errors.New("update runtime binding: nil context") } if err := input.Validate(); err != nil { return fmt.Errorf("update runtime binding: %w", err) } store.mu.Lock() defer store.mu.Unlock() record, ok := store.records[input.GameID] if !ok { return game.ErrNotFound } binding := input.Binding record.RuntimeBinding = &binding record.UpdatedAt = input.At.UTC() store.records[input.GameID] = record return nil } // Ensure Store satisfies the ports.GameStore interface at compile time. var _ ports.GameStore = (*Store)(nil)