Files
galaxy-game/game/internal/controller/controller.go
T
Ilia Denisov 15d35f6f1f
Tests · Go / test (push) Successful in 1m57s
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m0s
feat(game): canonical gameId in POST /api/v1/admin/init
Engine no longer mints its own game UUID. The orchestrator (backend)
generates the game UUID at game-create time and passes it in the
admin/init request body as the required `gameId` field, so the value
that names the engine container and host bind-mount directory also
ends up inside the engine's state.json.

The engine rejects the zero UUID with 400 and any init that conflicts
with an existing state.json with 409 (a second init on the same gameId
is also a conflict; full idempotency is not part of the contract).

Updates rest.InitRequest, openapi.yaml (schema + 409 response),
controller.GenerateGame/NewGame/buildGameOnMap signatures, the engine
HTTP handler/executor, the backend runtime worker, and the relevant
unit and contract tests. Documentation in game/README.md,
docs/ARCHITECTURE.md, backend/README.md, and backend/docs/{runtime,flows}.md
is updated in the same patch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 13:13:31 +02:00

383 lines
10 KiB
Go

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"
)
type Configurer func(*Param)
type Repo interface {
// Lock must be called before any repository operations
Lock() error
// Release must be called after first and only repository operation
Release() error
// SaveTurn stores just generated new turn
SaveNewTurn(uint, *game.Game) error
// SaveState stores current game state updated between turns
SaveLastState(*game.Game) error
// LoadState retrieves game current state with required lock acquisition
LoadState() (*game.Game, error)
// LoadStateSafe retrieves game current state without preliminary locking
LoadStateSafe() (*game.Game, error)
// SaveBattle stores a new battle protocol and battle meta data for turn t
SaveBattle(uint, *report.BattleReport, *game.BattleMeta) error
// LoadBattle reads battle's protocol for turn t and battle id.
// Returns false if battle with such id was never stored at turn t
LoadBattle(t uint, id uuid.UUID) (*report.BattleReport, bool, error)
// SaveBombing stores all prodused bombings for turn t
SaveBombings(uint, []*game.Bombing) error
// SaveReport stores latest report for a race
SaveReport(uint, *report.Report) error
// LoadReport loads report for specific turn and player id
LoadReport(uint, uuid.UUID) (*report.Report, error)
// SaveOrder stores order for given turn
SaveOrder(uint, uuid.UUID, *order.UserGamesOrder) error
// LoadOrder loads order for specific turn and player id
LoadOrder(uint, uuid.UUID) (*order.UserGamesOrder, bool, error)
}
type Ctrl interface {
ValidateOrder(actor string, cmd ...order.DecodableCommand) error
// remove below funcs if /command api will be deleted
RaceID(actor string) (uuid.UUID, error)
RaceQuit(actor string) error
RaceVote(actor, acceptor string) error
RaceRelation(actor, acceptor string, rel string) error
ShipClassCreate(actor, typeName string, drive float64, ammo int, weapons, shileds, cargo float64) error
ShipClassMerge(actor, name, targetName string) error
ShipClassRemove(actor, typeName string) error
ShipGroupLoad(actor string, groupID uuid.UUID, cargoType string, quantity float64) error
ShipGroupUnload(actor string, groupID uuid.UUID, quantity float64) error
ShipGroupSend(actor string, groupID uuid.UUID, planetNumber uint) error
ShipGroupUpgrade(actor string, groupID uuid.UUID, techInput string, limitLevel float64) error
ShipGroupBreak(actor string, groupID, newID uuid.UUID, quantity uint) error
ShipGroupMerge(actor string) error
ShipGroupDismantle(actor string, groupID uuid.UUID) error
ShipGroupTransfer(actor, acceptor string, groupID uuid.UUID) error
ShipGroupJoinFleet(actor, fleetName string, groupID uuid.UUID) error
FleetMerge(actor, fleetSourceName, fleetTargetName string) error
FleetSend(actor, fleetName string, planetNumber uint) error
ScienceCreate(actor, typeName string, drive, weapons, shields, cargo float64) error
ScienceRemove(actor, typeName string) error
PlanetRename(actor string, planetNumber int, typeName string) error
PlanetProduce(actor string, planetNumber int, prodType, subject string) error
PlanetRouteSet(actor, loadType string, origin, destination uint) error
PlanetRouteRemove(actor, loadType string, origin uint) error
}
// 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 GenerateGame(configure func(*Param), gameID uuid.UUID, races []string) (s game.State, err error) {
if gameID == uuid.Nil {
return game.State{}, ErrGameInitNilUUID
}
ec, err := NewRepoController(configure)
if err != nil {
return game.State{}, err
}
if err = ec.Repo.Lock(); err != nil {
return
}
defer func() {
err = errors.Join(err, ec.Repo.Release())
if err == nil {
s, err = GameState(configure)
}
}()
if existing, loadErr := ec.Repo.LoadState(); loadErr == nil {
err = fmt.Errorf("%w: stored gameId=%s, requested=%s", ErrGameAlreadyInit, existing.ID, gameID)
return
} else if !isGameNotInitialized(loadErr) {
err = fmt.Errorf("check existing state: %w", loadErr)
return
}
_, err = NewGame(ec.Repo, gameID, races)
return
}
// 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
}
func GenerateTurn(configure func(*Param)) (err error) {
ec, err := NewRepoController(configure)
if err != nil {
return err
}
err = ec.executeLocked(func(c *Controller) error { return c.MakeTurn() })
return
}
func LoadReport(configure func(*Param), actor string, turn uint) (*report.Report, error) {
ec, err := NewRepoController(configure)
if err != nil {
return nil, err
}
return ec.loadReport(actor, turn)
}
func ExecuteCommand(configure func(*Param), consumer func(c Ctrl) error) (err error) {
ec, err := NewRepoController(configure)
if err != nil {
return err
}
return ec.executeCommand(func(c *Controller) error { return consumer(c) })
}
func ValidateOrder(configure func(*Param), actor string, cmd ...order.DecodableCommand) (*order.UserGamesOrder, error) {
ec, err := NewRepoController(configure)
if err != nil {
return nil, err
}
return ec.validateOrder(actor, cmd...)
}
func FetchOrder(configure func(*Param), actor string, turn uint) (order *order.UserGamesOrder, ok bool, err error) {
ec, err := NewRepoController(configure)
if err != nil {
return nil, false, err
}
return ec.fetchOrder(actor, turn)
}
func FetchBattle(configure func(*Param), turn uint, ID uuid.UUID) (b *report.BattleReport, exists bool, err error) {
ec, err := NewRepoController(configure)
if err != nil {
return nil, false, err
}
return ec.fetchBattle(turn, ID)
}
func BanishRace(configure func(*Param), actor string) error {
ec, err := NewRepoController(configure)
if err != nil {
return err
}
return ec.banishRace(actor)
}
func GameState(configure func(*Param)) (s game.State, err error) {
ec, err := NewRepoController(configure)
if err != nil {
return game.State{}, err
}
g, err := ec.Repo.LoadStateSafe()
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
}
type RepoController struct {
Repo Repo
}
func NewRepoController(config Configurer) (*RepoController, error) {
c := &Param{
StoragePath: ".",
}
if config != nil {
config(c)
}
r, err := repo.NewFileRepo(c.StoragePath)
if err != nil {
return nil, err
}
return &RepoController{
Repo: r,
}, nil
}
func (ec *RepoController) NewGameController(g *game.Game) *Controller {
return &Controller{
RepoController: ec,
Cache: NewCache(g),
}
}
func (ec *RepoController) validateOrder(actor string, cmd ...order.DecodableCommand) (o *order.UserGamesOrder, err error) {
err = ec.executeSafe(func(t uint, c *Controller) error {
id, err := c.RaceID(actor)
if err != nil {
return err
}
err = c.ValidateOrder(actor, cmd...)
if 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 ec.Repo.SaveOrder(t, id, o)
})
if err != nil {
return nil, err
}
return
}
func (ec *RepoController) fetchOrder(actor string, turn uint) (order *order.UserGamesOrder, ok bool, err error) {
err = ec.executeSafe(func(t uint, c *Controller) error {
id, err := c.RaceID(actor)
if err != nil {
return err
}
order, ok, err = ec.Repo.LoadOrder(turn, id)
return err
})
if err != nil {
return
}
return
}
func (ec *RepoController) fetchBattle(turn uint, ID uuid.UUID) (order *report.BattleReport, exists bool, err error) {
err = ec.executeSafe(func(t uint, c *Controller) error {
order, exists, err = ec.Repo.LoadBattle(turn, ID)
return err
})
return
}
func (ec *RepoController) loadReport(actor string, turn uint) (r *report.Report, err error) {
execErr := ec.executeSafe(func(t uint, c *Controller) (exErr error) {
id, exErr := c.RaceID(actor)
if exErr == nil {
r, exErr = ec.Repo.LoadReport(turn, id)
}
return
})
err = errors.Join(err, execErr)
return
}
func (ec *RepoController) executeCommand(consumer func(*Controller) error) (err error) {
return ec.executeLocked(func(c *Controller) error {
err = consumer(c)
if err == nil {
c.Cache.StageCommand()
err = c.saveState()
}
return err
})
}
func (ec *RepoController) banishRace(actor string) (err error) {
return ec.executeLocked(func(c *Controller) error {
err = c.RaceBanish(actor)
if err != nil {
return err
}
return c.saveState()
})
}
func (ec *RepoController) executeSafe(consumer func(uint, *Controller) error) (err error) {
g, err := ec.Repo.LoadStateSafe()
if err != nil {
return err
}
err = consumer(g.Turn, ec.NewGameController(g))
return
}
func (ec *RepoController) executeLocked(consumer func(*Controller) error) (err error) {
if err := ec.Repo.Lock(); err != nil {
return err
}
defer func() {
err = errors.Join(err, ec.Repo.Release())
}()
g, err := ec.Repo.LoadState()
if err != nil {
return err
}
err = consumer(ec.NewGameController(g))
return
}
func (c *Controller) saveState() error {
return c.Repo.SaveLastState(c.Cache.g)
}
type Controller struct {
*RepoController
Cache *Cache
}
type Param struct {
StoragePath string
}