601970b028
Three-stage refactor of the game-engine plumbing (game logic untouched): Stage 1 — lock-free persistence + admin serialisation. Remove the file lock from repo/fs (the .lock file, the Read/Write-vs-*Safe duality and the dead ReadSafe polling) and replace the two-step rename with a single atomic rename so concurrent reads are torn-free without a lock. Serialise the state-mutating admin writers (init/turn/banish) with one shared router LimitMiddleware, rewritten to block on the request context instead of a racy shared 100ms timer. Stage 2 — remove the obsolete immediate-command path end to end. Players submit through PUT /api/v1/order; the legacy PUT /api/v1/command path is deleted across game (route, handler, 24 command factories, Ctrl), backend (Commands handler/route, engineclient.ExecuteCommands), gateway (dispatch + executeUserGamesCommand + routing entry), the FlatBuffers/model contract (UserGamesCommand[Response]) and transcoder, plus every affected OpenAPI/README/FUNCTIONAL/ARCHITECTURE doc. The integration proxy test is converted to the order path. Stage 3 — flatten the REST->engine wrapper. Replace the executor adapter, the controller package functions and RepoController with one concrete controller.Service; drop the single-implementation Repo and Storage interfaces (repo.Repo / fs.FS are now concrete). Handlers depend on a thin handler.Engine seam and own the domain->REST projection; storage is resolved once at startup instead of per request. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
109 lines
3.7 KiB
Go
109 lines
3.7 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"galaxy/model/order"
|
|
"galaxy/model/report"
|
|
"galaxy/model/rest"
|
|
|
|
e "galaxy/error"
|
|
|
|
"galaxy/game/internal/model/game"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/go-playground/validator/v10"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Engine is the set of operations the HTTP handlers invoke on the game engine.
|
|
// Its sole production implementation is *controller.Service; the interface
|
|
// exists so the transport layer can be exercised against a lightweight fake
|
|
// without standing up real storage. Methods return domain types — handlers own
|
|
// the projection into the REST wire shapes.
|
|
type Engine interface {
|
|
GenerateGame(gameID uuid.UUID, races []string) (game.State, error)
|
|
GenerateTurn() (game.State, error)
|
|
GameState() (game.State, error)
|
|
BanishRace(actor string) error
|
|
LoadReport(actor string, turn uint) (*report.Report, error)
|
|
ValidateOrder(actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error)
|
|
FetchOrder(actor string, turn uint) (*order.UserGamesOrder, bool, error)
|
|
FetchBattle(turn uint, ID uuid.UUID) (*report.BattleReport, bool, error)
|
|
}
|
|
|
|
// ResolveStoragePath returns the engine storage path resolved from
|
|
// STORAGE_PATH (preferred, historical name) or GAME_STATE_PATH (canonical
|
|
// name written by Runtime Manager). It returns an error when neither
|
|
// variable is set; callers are expected to fail fast at startup.
|
|
func ResolveStoragePath() (string, error) {
|
|
if v := strings.TrimSpace(os.Getenv("STORAGE_PATH")); v != "" {
|
|
return v, nil
|
|
}
|
|
if v := strings.TrimSpace(os.Getenv("GAME_STATE_PATH")); v != "" {
|
|
return v, nil
|
|
}
|
|
return "", errors.New("storage path is not set: provide STORAGE_PATH or GAME_STATE_PATH")
|
|
}
|
|
|
|
// stateResponse projects the engine's domain game.State into the REST
|
|
// StateResponse wire shape.
|
|
func stateResponse(s game.State) rest.StateResponse {
|
|
result := &rest.StateResponse{
|
|
ID: s.ID,
|
|
Turn: s.Turn,
|
|
Stage: s.Stage,
|
|
Finished: s.Finished,
|
|
Players: make([]rest.PlayerState, len(s.Players)),
|
|
}
|
|
for i := range s.Players {
|
|
result.Players[i].ID = s.Players[i].ID
|
|
result.Players[i].RaceName = s.Players[i].RaceName
|
|
result.Players[i].Planets = s.Players[i].Planets
|
|
result.Players[i].Population = s.Players[i].Population.F()
|
|
result.Players[i].Extinct = s.Players[i].Extinct
|
|
}
|
|
return *result
|
|
}
|
|
|
|
// errorResponse renders err onto c and reports whether the caller
|
|
// should stop further processing. The HTTP status is selected by the
|
|
// GenericError shelf (see pkg/error for the taxonomy):
|
|
//
|
|
// - validator.ValidationErrors (request struct binding) → 400 with
|
|
// {"error": ...}.
|
|
// - GenericError, ErrGameNotInitialized → 501 with no body.
|
|
// - GenericError on the internal shelf (1xxx) → 500 with
|
|
// {"generic_error", "code"}.
|
|
// - GenericError on the input-validation shelf (2xxx) or the
|
|
// game-state shelf (3xxx) → 400 with {"generic_error", "code"}.
|
|
// - everything else (non-GenericError) → 500 with {"error": ...}.
|
|
func errorResponse(c *gin.Context, err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
|
|
if v, ok := err.(validator.ValidationErrors); ok {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": v.Error()})
|
|
return true
|
|
}
|
|
|
|
if ge, ok := errors.AsType[*e.GenericError](err); ok {
|
|
switch {
|
|
case ge.Code == e.ErrGameNotInitialized:
|
|
c.Status(http.StatusNotImplemented)
|
|
case e.IsInputCode(ge.Code), e.IsGameStateCode(ge.Code):
|
|
c.JSON(http.StatusBadRequest, gin.H{"generic_error": ge.Error(), "code": ge.Code})
|
|
default:
|
|
c.JSON(http.StatusInternalServerError, gin.H{"generic_error": ge.Error(), "code": ge.Code})
|
|
}
|
|
} else {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
}
|
|
|
|
return true
|
|
}
|