// Package livenessreply implements the Lobby-facing liveness service- // layer answer owned by Game Master. It is driven by Game Lobby // resuming a paused game through // `GET /api/v1/internal/games/{game_id}/liveness` and reflects GM's // own view of the runtime without ever calling the engine. // // Lifecycle and failure-mode semantics follow `gamemaster/README.md // §Liveness reply`. The 200 / status="" response on // `runtime_not_found` is the Stage 17 D5 decision recorded in // `gamemaster/docs/stage17-admin-operations.md`. package livenessreply import ( "context" "errors" "fmt" "log/slog" "strings" "galaxy/gamemaster/internal/domain/runtime" "galaxy/gamemaster/internal/ports" ) // Input stores the per-call arguments for one liveness reply. type Input struct { // GameID identifies the runtime to inspect. GameID string } // Validate reports whether input carries the structural invariants the // service requires before any store is touched. func (input Input) Validate() error { if strings.TrimSpace(input.GameID) == "" { return fmt.Errorf("game id must not be empty") } return nil } // Result stores the deterministic outcome of one Handle call. The // endpoint always answers 200; the result fields populate the JSON // body. ErrorCode / ErrorMessage are reserved for handler-side error // envelopes and are never set by Handle on a successful read. type Result struct { // Ready is true when the runtime exists and is in `running`. Ready bool // Status carries the observed runtime status. Empty when the // runtime record does not exist (Stage 17 D5). Status runtime.Status } // Dependencies groups the collaborators required by Service. type Dependencies struct { // RuntimeRecords supplies the runtime status read. RuntimeRecords ports.RuntimeRecordStore // Logger records structured service-level events. Defaults to // `slog.Default()` when nil. Logger *slog.Logger } // Service executes the liveness reply lookup. type Service struct { runtimeRecords ports.RuntimeRecordStore logger *slog.Logger } // NewService constructs one Service from deps. func NewService(deps Dependencies) (*Service, error) { if deps.RuntimeRecords == nil { return nil, errors.New("new liveness reply service: nil runtime records") } logger := deps.Logger if logger == nil { logger = slog.Default() } logger = logger.With("service", "gamemaster.livenessreply") return &Service{ runtimeRecords: deps.RuntimeRecords, logger: logger, }, nil } // Handle executes one liveness reply lookup. The Go-level error return // is reserved for non-business failures: nil context, nil receiver, // invalid input (so the handler can answer `invalid_request`), or a // store read failure (so the handler can answer `service_unavailable`). // `runtime.ErrNotFound` is intentionally absorbed into Result with // `Ready=false` and an empty status. func (service *Service) Handle(ctx context.Context, input Input) (Result, error) { if service == nil { return Result{}, errors.New("liveness reply: nil service") } if ctx == nil { return Result{}, errors.New("liveness reply: nil context") } if err := input.Validate(); err != nil { return Result{}, fmt.Errorf("%s: %w", ErrorCodeInvalidRequest, err) } record, err := service.runtimeRecords.Get(ctx, input.GameID) switch { case err == nil: return Result{ Ready: record.Status == runtime.StatusRunning, Status: record.Status, }, nil case errors.Is(err, runtime.ErrNotFound): return Result{Ready: false, Status: ""}, nil default: return Result{}, fmt.Errorf("%s: get runtime record: %w", ErrorCodeServiceUnavailable, err) } }