15d35f6f1f
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>
383 lines
10 KiB
Go
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
|
|
}
|