271 lines
7.6 KiB
Go
271 lines
7.6 KiB
Go
// 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)
|