feat: game lobby service
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
// Package gamestub provides an in-memory ports.GameStore implementation for
|
||||
// service-level tests. The stub 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 stub; it is test-only but exposed as a
|
||||
// regular (non _test.go) package so other service test packages can import
|
||||
// it.
|
||||
package gamestub
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user