package controller import ( "errors" "fmt" "time" e "galaxy/error" "galaxy/game/internal/model/game" "github.com/google/uuid" "galaxy/model/order" "galaxy/model/report" "galaxy/game/internal/repo" ) // Service is the engine's application service: it owns persistence and exposes // the operations the HTTP handlers invoke. It is safe for concurrent use — // reads are lock-free and the writers that mutate the canonical state file // (init/turn/banish) are serialised at the router by a shared LimitMiddleware. type Service struct { repo *repo.Repo } // NewService opens the file-backed storage at storagePath and returns a ready // Service. The directory must already exist and be writable. func NewService(storagePath string) (*Service, error) { r, err := repo.NewFileRepo(storagePath) if err != nil { return nil, err } return &Service{repo: r}, nil } // GenerateGame initialises a fresh game in storage under the supplied // canonical gameID. The orchestrator must allocate gameID before the engine // container is started and pass it here as the request body of // POST /api/v1/admin/init. A zero UUID is rejected with ErrGameInitNilUUID; an // attempt to init on top of an existing state.json is rejected with // ErrGameAlreadyInit. func (s *Service) GenerateGame(gameID uuid.UUID, races []string) (game.State, error) { if gameID == uuid.Nil { return game.State{}, ErrGameInitNilUUID } if existing, loadErr := s.repo.LoadState(); loadErr == nil { return game.State{}, fmt.Errorf("%w: stored gameId=%s, requested=%s", ErrGameAlreadyInit, existing.ID, gameID) } else if !isGameNotInitialized(loadErr) { return game.State{}, fmt.Errorf("check existing state: %w", loadErr) } if _, err := NewGame(s.repo, gameID, races); err != nil { return game.State{}, err } return s.GameState() } // GenerateTurn advances the game by one turn (applying every stored order) and // returns the resulting game state. func (s *Service) GenerateTurn() (game.State, error) { if err := s.execute(func(_ uint, c *Controller) error { return c.MakeTurn() }); err != nil { return game.State{}, err } return s.GameState() } // isGameNotInitialized reports whether err is the engine's canonical // "no state.json on disk" signal returned by Repo.LoadState on a fresh // storage directory. func isGameNotInitialized(err error) bool { var ge *e.GenericError return errors.As(err, &ge) && ge.Code == e.ErrGameNotInitialized } // LoadReport returns the stored turn report for actor at the given turn. func (s *Service) LoadReport(actor string, turn uint) (r *report.Report, err error) { execErr := s.execute(func(_ uint, c *Controller) (exErr error) { id, exErr := c.RaceID(actor) if exErr == nil { r, exErr = s.repo.LoadReport(turn, id) } return }) err = errors.Join(err, execErr) return } // ValidateOrder validates cmd against a transient view of the current state, // records the per-command outcome on each command's meta, and stores the // resulting order for the current turn. Game-state rejections are reported per // command, not as a returned error. func (s *Service) ValidateOrder(actor string, cmd ...order.DecodableCommand) (o *order.UserGamesOrder, err error) { err = s.execute(func(t uint, c *Controller) error { id, err := c.RaceID(actor) if err != nil { return err } if err := c.ValidateOrder(actor, cmd...); err != nil { return err } o = &order.UserGamesOrder{ GameID: c.Cache.g.ID, UpdatedAt: time.Now().UTC().UnixMilli(), Commands: make([]order.DecodableCommand, len(cmd)), } copy(o.Commands, cmd) return s.repo.SaveOrder(t, id, o) }) if err != nil { return nil, err } return } // FetchOrder returns the order actor stored for the given turn. ok is false // when no order was ever stored. func (s *Service) FetchOrder(actor string, turn uint) (o *order.UserGamesOrder, ok bool, err error) { err = s.execute(func(_ uint, c *Controller) error { id, err := c.RaceID(actor) if err != nil { return err } o, ok, err = s.repo.LoadOrder(turn, id) return err }) if err != nil { return } return } // FetchBattle returns the battle report stored at turn under ID. exists is // false when no such battle was recorded. func (s *Service) FetchBattle(turn uint, ID uuid.UUID) (b *report.BattleReport, exists bool, err error) { err = s.execute(func(_ uint, c *Controller) error { b, exists, err = s.repo.LoadBattle(turn, ID) return err }) return } // BanishRace deactivates actor's race after a permanent platform removal and // persists the updated state. func (s *Service) BanishRace(actor string) error { return s.execute(func(_ uint, c *Controller) error { if err := c.RaceBanish(actor); err != nil { return err } return c.saveState() }) } // GameState loads the current state and projects it into the transport-facing // game.State summary (player roster with planet counts and population). func (s *Service) GameState() (game.State, error) { g, err := s.repo.LoadState() if err != nil { return game.State{}, err } result := &game.State{ ID: g.ID, Turn: g.Turn, Stage: g.Stage, Finished: g.Finished(), Players: make([]game.PlayerState, len(g.Race)), } planetCount := make(map[uuid.UUID]uint) population := make(map[uuid.UUID]game.Float) for i := range g.Map.Planet { p := &g.Map.Planet[i] if p.Owner == nil { continue } owner := *p.Owner planetCount[owner] += 1 population[owner] += p.Population } for i := range g.Race { r := &g.Race[i] result.Players[i].ID = r.ID result.Players[i].RaceName = r.Name result.Players[i].Planets = planetCount[r.ID] result.Players[i].Population = population[r.ID] result.Players[i].Extinct = r.Extinct } return *result, nil } // execute loads the current game state, wraps it in a Controller and runs // consumer against it. Reads and writes are lock-free; concurrent writers to // the state file (init/turn/banish) are serialised at the router by a shared // LimitMiddleware, so this helper holds no lock of its own. func (s *Service) execute(consumer func(uint, *Controller) error) error { g, err := s.repo.LoadState() if err != nil { return err } return consumer(g.Turn, &Controller{repo: s.repo, Cache: NewCache(g)}) } // Controller is the per-turn execution context: a loaded game state (Cache) // plus the repo it persists through. It carries the engine's game-logic // methods (in command.go, order.go, generate_turn.go, …). type Controller struct { repo *repo.Repo Cache *Cache } func (c *Controller) saveState() error { return c.repo.SaveLastState(c.Cache.g) }