diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 41b9ec6..4192ee9 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -22,6 +22,8 @@ type Repo interface { // LoadState retrieves game current state LoadState() (game.Game, error) + + LoadStateSafe() (game.Game, error) } type Controller struct { @@ -50,7 +52,7 @@ func NewController(configure func(*Param)) (*Controller, error) { }, nil } -func (c *Controller) ExecuteInit(consumer func(Repo)) error { +func (c *Controller) ExecuteState(consumer func(Repo)) error { if err := c.Repo.Lock(); err != nil { return fmt.Errorf("execute: lock failed: %s", err) } @@ -58,7 +60,7 @@ func (c *Controller) ExecuteInit(consumer func(Repo)) error { return c.Repo.Release() } -func (c *Controller) Execute(consumer func(Repo, game.Game)) error { +func (c *Controller) ExecuteGame(consumer func(Repo, game.Game)) error { if err := c.Repo.Lock(); err != nil { return fmt.Errorf("execute: lock failed: %s", err) } diff --git a/internal/error/generic.go b/internal/error/generic.go index 670d221..ec7bc24 100644 --- a/internal/error/generic.go +++ b/internal/error/generic.go @@ -5,10 +5,13 @@ import ( ) const ( - ErrDummy int = -1 + ErrStorageFailure int = 1000 + iota + ErrGameNotInitialized + ErrGameStateInvalid +) - ErrStorageFailure int = 1000 - ErrGameStateInvalid int = 2000 +const ( + ErrDummy int = -1 ErrDeleteShipTypeExistingGroup = 5000 ErrDeleteShipTypePlanetProduction = 5001 @@ -68,6 +71,8 @@ func GenericErrorText(code int) string { return "Dummy" case ErrStorageFailure: return "Storage failure" + case ErrGameNotInitialized: + return "Game not yet initialized" case ErrGameStateInvalid: return "Invalid game state" case ErrInputUnknownRace: @@ -170,13 +175,13 @@ func GenericErrorText(code int) string { } type GenericError struct { - code int + Code int subject string err error } func (ge GenericError) Error() string { - msg := GenericErrorText(ge.code) + msg := GenericErrorText(ge.Code) if ge.subject != "" { msg += ": " + ge.subject } @@ -187,7 +192,7 @@ func (ge GenericError) Error() string { } func newGenericError(code int, arg ...any) error { - e := &GenericError{code: code} + e := &GenericError{Code: code} if len(arg) > 0 { i := 0 switch arg[i].(type) { diff --git a/internal/error/state.go b/internal/error/state.go index 7483687..b5778ff 100644 --- a/internal/error/state.go +++ b/internal/error/state.go @@ -1,5 +1,9 @@ package error +func NewGameNotInitializedError(arg ...any) error { + return newGenericError(ErrGameNotInitialized, arg...) +} + func NewGameStateError(arg ...any) error { return newGenericError(ErrGameStateInvalid, arg...) } diff --git a/internal/game/cmd_group.go b/internal/game/cmd_group.go index 5c41e0c..5766659 100644 --- a/internal/game/cmd_group.go +++ b/internal/game/cmd_group.go @@ -7,7 +7,7 @@ import ( func JoinEqualGroups(configure func(*controller.Param), race string) (err error) { control(configure, func(c *controller.Controller) { - c.Execute(func(r controller.Repo, g game.Game) { + c.ExecuteGame(func(r controller.Repo, g game.Game) { err = joinEqualGroups(r, g, race) }) }) diff --git a/internal/game/cmd_planet.go b/internal/game/cmd_planet.go index 4f91498..08255a5 100644 --- a/internal/game/cmd_planet.go +++ b/internal/game/cmd_planet.go @@ -7,7 +7,7 @@ import ( func RenamePlanet(configure func(*controller.Param), race string, number int, name string) (err error) { control(configure, func(c *controller.Controller) { - c.Execute(func(r controller.Repo, g game.Game) { + c.ExecuteGame(func(r controller.Repo, g game.Game) { err = renamePlanet(r, g, race, number, name) }) }) diff --git a/internal/game/cmd_production.go b/internal/game/cmd_production.go index 5608c65..e34488f 100644 --- a/internal/game/cmd_production.go +++ b/internal/game/cmd_production.go @@ -7,7 +7,7 @@ import ( func PlanetProduction(configure func(*controller.Param), race string, planetNumber int, prodType, subject string) (err error) { control(configure, func(c *controller.Controller) { - c.Execute(func(r controller.Repo, g game.Game) { + c.ExecuteGame(func(r controller.Repo, g game.Game) { err = planetProduction(r, g, race, planetNumber, prodType, subject) }) }) diff --git a/internal/game/cmd_science.go b/internal/game/cmd_science.go index b40feac..ca6802a 100644 --- a/internal/game/cmd_science.go +++ b/internal/game/cmd_science.go @@ -7,7 +7,7 @@ import ( func CreateScience(configure func(*controller.Param), race, typeName string, drive, weapons, shields, cargo float64) (err error) { control(configure, func(c *controller.Controller) { - c.Execute(func(r controller.Repo, g game.Game) { + c.ExecuteGame(func(r controller.Repo, g game.Game) { err = createScience(r, g, race, typeName, drive, weapons, shields, cargo) }) }) @@ -23,7 +23,7 @@ func createScience(r controller.Repo, g game.Game, race, typeName string, d, w, func DeleteScience(configure func(*controller.Param), race, typeName string) (err error) { control(configure, func(c *controller.Controller) { - c.Execute(func(r controller.Repo, g game.Game) { + c.ExecuteGame(func(r controller.Repo, g game.Game) { err = deleteScience(r, g, race, typeName) }) }) diff --git a/internal/game/cmd_ship_type.go b/internal/game/cmd_ship_type.go index 742df7f..b4ec2ab 100644 --- a/internal/game/cmd_ship_type.go +++ b/internal/game/cmd_ship_type.go @@ -7,7 +7,7 @@ import ( func CreateShipType(configure func(*controller.Param), race, typeName string, drive, weapons, shields, cargo float64, armament int) (err error) { control(configure, func(c *controller.Controller) { - c.Execute(func(r controller.Repo, g game.Game) { + c.ExecuteGame(func(r controller.Repo, g game.Game) { err = createShipType(r, g, race, typeName, drive, weapons, shields, cargo, armament) }) }) @@ -23,7 +23,7 @@ func createShipType(r controller.Repo, g game.Game, race, typeName string, d, w, func MergeShipType(configure func(*controller.Param), race, source, target string) (err error) { control(configure, func(c *controller.Controller) { - c.Execute(func(r controller.Repo, g game.Game) { + c.ExecuteGame(func(r controller.Repo, g game.Game) { err = mergeShipType(r, g, race, source, target) }) }) @@ -39,7 +39,7 @@ func mergeShipType(r controller.Repo, g game.Game, race, source, target string) func DeleteShipType(configure func(*controller.Param), race, typeName string) (err error) { control(configure, func(c *controller.Controller) { - c.Execute(func(r controller.Repo, g game.Game) { + c.ExecuteGame(func(r controller.Repo, g game.Game) { err = deleteShipType(r, g, race, typeName) }) }) diff --git a/internal/game/cmd_war_peace.go b/internal/game/cmd_war_peace.go index c9654cc..753a960 100644 --- a/internal/game/cmd_war_peace.go +++ b/internal/game/cmd_war_peace.go @@ -7,14 +7,14 @@ import ( func DeclareWar(configure func(*controller.Param), from, to string) (err error) { control(configure, func(c *controller.Controller) { - c.Execute(func(r controller.Repo, g game.Game) { err = updateRelation(r, g, from, to, game.RelationWar) }) + c.ExecuteGame(func(r controller.Repo, g game.Game) { err = updateRelation(r, g, from, to, game.RelationWar) }) }) return } func DeclarePeace(configure func(*controller.Param), from, to string) (err error) { control(configure, func(c *controller.Controller) { - c.Execute(func(r controller.Repo, g game.Game) { err = updateRelation(r, g, from, to, game.RelationPeace) }) + c.ExecuteGame(func(r controller.Repo, g game.Game) { err = updateRelation(r, g, from, to, game.RelationPeace) }) }) return } diff --git a/internal/game/controller.go b/internal/game/controller.go index 9dd923a..6af9ce1 100644 --- a/internal/game/controller.go +++ b/internal/game/controller.go @@ -6,14 +6,23 @@ import ( "github.com/iliadenisov/galaxy/internal/model/game" ) -func LoadState(configure func(*controller.Param)) (g game.Game, err error) { - control(configure, func(c *controller.Controller) { c.ExecuteInit(func(r controller.Repo) { g, err = c.Repo.LoadState() }) }) +func GenerateGame(configure func(*controller.Param), races []string) (gameID uuid.UUID, err error) { + control(configure, func(c *controller.Controller) { + c.ExecuteState(func(r controller.Repo) { gameID, err = controller.NewGame(r, races) }) + }) return } -func GenerateGame(configure func(*controller.Param), races []string) (gameID uuid.UUID, err error) { +// LoadState used for lock-safe loading game state and may be called concurrently. +func LoadState(configure func(*controller.Param)) (g game.Game, err error) { + control(configure, func(c *controller.Controller) { g, err = c.Repo.LoadStateSafe() }) + return +} + +// TODO: command for loading report by players (MUST be limited by router) +func LoadReport(configure func(*controller.Param)) (g game.Game, err error) { control(configure, func(c *controller.Controller) { - c.ExecuteInit(func(r controller.Repo) { gameID, err = controller.NewGame(r, races) }) + c.ExecuteState(func(r controller.Repo) { g, err = c.Repo.LoadState() }) }) return } diff --git a/internal/model/game/ship.go b/internal/model/game/ship.go index f06ba0a..f7635d9 100644 --- a/internal/model/game/ship.go +++ b/internal/model/game/ship.go @@ -122,6 +122,11 @@ func (g Game) deleteShipTypeInternal(ri int, name string) error { }); pl >= 0 { return e.NewDeleteShipTypePlanetProductionError(g.Map.Planet[pl].Name) } + for sg := range g.listShipGroups(ri) { + if sg.TypeID == g.Race[ri].ShipTypes[st].ID { + return e.NewDeleteShipTypeExistingGroupError("group: %v", sg.Index) + } + } g.Race[ri].ShipTypes = append(g.Race[ri].ShipTypes[:st], g.Race[ri].ShipTypes[st+1:]...) return nil } diff --git a/internal/model/rest/status.go b/internal/model/rest/status.go new file mode 100644 index 0000000..4d24b22 --- /dev/null +++ b/internal/model/rest/status.go @@ -0,0 +1,6 @@ +package rest + +type Status struct { + Turn uint `json:"turn"` + Players int `json:"players"` +} diff --git a/internal/repo/fs/fs.go b/internal/repo/fs/fs.go index dec6340..f392325 100644 --- a/internal/repo/fs/fs.go +++ b/internal/repo/fs/fs.go @@ -161,15 +161,44 @@ func (f *fs) Write(path string, v encoding.BinaryMarshaler) error { } func (f *fs) Read(path string, v encoding.BinaryUnmarshaler) error { - if v == nil { - return errors.New("can't unmarshal to a nil object") - } - if f.lock == nil { return errors.New("lock must be acquired before read") } - targetFilePath := filepath.Join(f.root, path) + return f.readUnsafe(path, v) +} + +func (f *fs) ReadSafe(path string, v encoding.BinaryUnmarshaler) error { + if f.lock != nil { + timeout := time.NewTimer(time.Millisecond * 100) + checker := time.NewTicker(time.Millisecond) + out: + for { + select { + case <-checker.C: + if f.lock == nil { + checker.Stop() + timeout.Stop() + break out + } + case <-timeout.C: + checker.Stop() + return errors.New("lock acquired, timeout waiting for release") + } + } + } + + return f.readUnsafe(path, v) +} + +// readUnsafe reads the file contents without locking mechanism in mind. +// Using readUnsafe directly may cause errors if file being written at the moment. +func (f *fs) readUnsafe(file string, v encoding.BinaryUnmarshaler) error { + if v == nil { + return errors.New("can't unmarshal to a nil object") + } + + targetFilePath := filepath.Join(f.root, file) if targetFilePath == f.lockFilePath() { return errors.New("can't read from the lock file") } diff --git a/internal/repo/game.go b/internal/repo/game.go index 37273e6..04313b4 100644 --- a/internal/repo/game.go +++ b/internal/repo/game.go @@ -58,10 +58,14 @@ func saveState(s Storage, g game.Game) error { } func (r *repo) LoadState() (game.Game, error) { - return loadState(r.s) + return loadState(r.s, true) } -func loadState(s Storage) (game.Game, error) { +func (r *repo) LoadStateSafe() (game.Game, error) { + return loadState(r.s, false) +} + +func loadState(s Storage, locked bool) (game.Game, error) { var g game.Game path := statePath exist, err := s.Exists(path) @@ -69,10 +73,16 @@ func loadState(s Storage) (game.Game, error) { return g, NewStorageError(err) } if !exist { - return g, NewStateError("latest state was never stored") + return g, NewGameNotInitializedError() } - if err := s.Read(path, &g); err != nil { - return g, NewStorageError(err) + if locked { + if err := s.Read(path, &g); err != nil { + return g, NewStorageError(err) + } + } else { + if err := s.ReadSafe(path, &g); err != nil { + return g, NewStorageError(err) + } } return g, nil } diff --git a/internal/repo/repo.go b/internal/repo/repo.go index 29d9bf5..58319a8 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -12,6 +12,10 @@ func NewStorageError(err error) error { return e.NewRepoError(err) } +func NewGameNotInitializedError() error { + return e.NewGameNotInitializedError() +} + func NewStateError(msg string) error { return e.NewGameStateError(msg) } @@ -21,6 +25,7 @@ type Storage interface { Exists(string) (bool, error) Write(string, encoding.BinaryMarshaler) error Read(string, encoding.BinaryUnmarshaler) error + ReadSafe(string, encoding.BinaryUnmarshaler) error } type repo struct { diff --git a/internal/router/command.go b/internal/router/command.go index 4a8ce4c..83e83d5 100644 --- a/internal/router/command.go +++ b/internal/router/command.go @@ -7,7 +7,6 @@ import ( "github.com/iliadenisov/galaxy/internal/game" "github.com/gin-gonic/gin" - "github.com/iliadenisov/galaxy/internal/controller" "github.com/iliadenisov/galaxy/internal/model/rest" ) @@ -26,18 +25,15 @@ func CommandHandler(c *gin.Context, executor Executor) { case err == nil: c.Status(http.StatusOK) case errors.Is(err, ErrCommandNotProcessed): - c.Status(http.StatusInternalServerError) + c.Status(http.StatusInternalServerError) // TODO: add error text? default: - // TODO: separate bad value errors and game state errors + // TODO: separate invalid input and game state errors c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) } } func Execute(cmd rest.Command) error { - p := func(p *controller.Param) { - // TODO: initialize base controller settings - p.StoragePath = "" - } + p := param() switch { case cmd.DeclareWar != nil: return game.DeclareWar(p, cmd.Race, cmd.DeclareWar.Opponent) diff --git a/internal/router/router.go b/internal/router/router.go index 5595b2f..845ec61 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1,12 +1,30 @@ package router import ( + "os" + "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" "github.com/go-playground/validator/v10" + "github.com/iliadenisov/galaxy/internal/controller" "github.com/iliadenisov/galaxy/internal/model/rest" ) +var ( + StoragePath string +) + +func init() { + StoragePath = os.Getenv("STORAGE_PATH") +} + +func param() func(*controller.Param) { + return func(p *controller.Param) { + // TODO: initialize base controller settings + p.StoragePath = StoragePath + } +} + type Executor func(rest.Command) error type Router struct { @@ -35,6 +53,7 @@ func setupRouter(executor Executor) *gin.Engine { v.RegisterValidation("notblank", notBlankStringValidator) } + r.GET("/api/v1/status", StatusHandler) r.PUT("/api/v1/command", LimitMiddleware(1), func(ctx *gin.Context) { CommandHandler(ctx, executor) }) return r } diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 733301f..204697a 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -29,7 +29,7 @@ func TestRouter(t *testing.T) { req, _ := http.NewRequest("PUT", "/api/v1/command", cmdBody(exampleCommand)) r.ServeHTTP(w, req) - assert.Equal(t, 200, w.Code, w.Body) + assert.Equal(t, http.StatusOK, w.Code, w.Body) // error: notblank validator exampleCommand.Race = "" diff --git a/internal/router/status.go b/internal/router/status.go new file mode 100644 index 0000000..3c5b25c --- /dev/null +++ b/internal/router/status.go @@ -0,0 +1,37 @@ +package router + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + e "github.com/iliadenisov/galaxy/internal/error" + "github.com/iliadenisov/galaxy/internal/game" + "github.com/iliadenisov/galaxy/internal/model/rest" +) + +func StatusHandler(c *gin.Context) { + g, err := game.LoadState(param()) + + if err == nil { + c.JSON(http.StatusOK, rest.Status{ + Turn: g.Age, + Players: len(g.Race), + }) + return + } + + var ge = new(e.GenericError) + + if errors.As(err, ge) { + switch ge.Code { + case e.ErrGameNotInitialized: + c.Status(http.StatusNotImplemented) + default: + c.JSON(http.StatusInternalServerError, gin.H{"generic_error": ge.Error(), "code": ge.Code}) + } + return + } + + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) +} diff --git a/internal/router/status_test.go b/internal/router/status_test.go new file mode 100644 index 0000000..7f20d7d --- /dev/null +++ b/internal/router/status_test.go @@ -0,0 +1,21 @@ +package router_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/iliadenisov/galaxy/internal/router" + "github.com/stretchr/testify/assert" +) + +func TestGetStatus(t *testing.T) { + r := router.SetupRouter() + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/v1/status", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotImplemented, w.Code, w.Body) + +}