From 66eeefc65de805402648711595258725c544b11b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 26 Sep 2025 20:54:34 +0300 Subject: [PATCH] create basic controller --- pkg/game/controller.go | 56 +++++++++++++++ pkg/game/controller_test.go | 25 +++++++ pkg/game/game.go | 134 ++++-------------------------------- pkg/game/generator.go | 126 +++++++++++++++++++++++++++++++++ pkg/game/generator_test.go | 29 ++++++++ pkg/model/game/game.go | 4 ++ pkg/repo/fs/fs.go | 2 +- pkg/repo/fs/fs_test.go | 34 ++++----- pkg/repo/fs/helper_test.go | 15 ---- pkg/repo/fs/util_test.go | 9 +-- pkg/repo/game.go | 29 +++++++- pkg/repo/repo.go | 40 ++++++++++- pkg/util/fs.go | 19 +++++ 13 files changed, 357 insertions(+), 165 deletions(-) create mode 100644 pkg/game/controller.go create mode 100644 pkg/game/controller_test.go create mode 100644 pkg/game/generator.go create mode 100644 pkg/game/generator_test.go create mode 100644 pkg/util/fs.go diff --git a/pkg/game/controller.go b/pkg/game/controller.go new file mode 100644 index 0000000..60bbe21 --- /dev/null +++ b/pkg/game/controller.go @@ -0,0 +1,56 @@ +package game + +import ( + "fmt" + + "github.com/google/uuid" + "github.com/iliadenisov/galaxy/pkg/repo" +) + +type ctrl struct { + param Param + repo Repo +} + +type Param struct { + StoragePath string +} + +func ComposeGame(configure func(*Param), races []string) (gameID uuid.UUID, err error) { + control(configure, func(c *ctrl) { c.execute(func(r Repo) { gameID, err = newGame(r, races) }) }) + return +} + +func newController(configure func(*Param)) (*ctrl, error) { + c := &Param{ + StoragePath: ".", + } + if configure != nil { + configure(c) + } + r, err := repo.NewFileRepo(c.StoragePath) + if err != nil { + return nil, err + } + return &ctrl{ + param: *c, + repo: r, + }, nil +} + +func control(configure func(*Param), consumer func(*ctrl)) error { + c, err := newController(configure) + if err != nil { + return err + } + consumer(c) + return nil +} + +func (c *ctrl) execute(consumer func(Repo)) error { + if err := c.repo.Lock(); err != nil { + return fmt.Errorf("execute: lock failed: %s", err) + } + consumer(c.repo) + return c.repo.Release() +} diff --git a/pkg/game/controller_test.go b/pkg/game/controller_test.go new file mode 100644 index 0000000..f865ebf --- /dev/null +++ b/pkg/game/controller_test.go @@ -0,0 +1,25 @@ +package game_test + +import ( + "fmt" + "testing" + + "github.com/iliadenisov/galaxy/pkg/game" + "github.com/iliadenisov/galaxy/pkg/util" + "github.com/stretchr/testify/assert" +) + +func TestComposeGame(t *testing.T) { + root, cleanup := util.CreateWorkDir(t) + defer cleanup() + players := 20 + races := make([]string, players) + for i := range players { + races[i] = fmt.Sprintf("race_%02d", i) + } + _, err := game.ComposeGame(func(p *game.Param) { p.StoragePath = root }, races) + assert.NoError(t, err) + _, err = game.ComposeGame(func(p *game.Param) { p.StoragePath = root }, races) + assert.Error(t, err) + assert.ErrorContains(t, err, "state for turn 0 already saved") +} diff --git a/pkg/game/game.go b/pkg/game/game.go index 5de43da..954d423 100644 --- a/pkg/game/game.go +++ b/pkg/game/game.go @@ -1,126 +1,20 @@ package game -import ( - "fmt" - "math/rand/v2" - - "github.com/google/uuid" - "github.com/iliadenisov/galaxy/pkg/generator" - "github.com/iliadenisov/galaxy/pkg/model/game" -) +import "github.com/iliadenisov/galaxy/pkg/model/game" 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 SaveTurn(uint, game.Game) error -} - -func NewGame(r Repo, races []string) (uuid.UUID, error) { - gameID, err := uuid.NewRandom() - if err != nil { - return uuid.Nil, fmt.Errorf("generate game uuid: %s", err) - } - m, err := generator.Generate(func(ms *generator.MapSetting) { - ms.Players = uint32(len(races)) - }) - if err != nil { - return uuid.Nil, fmt.Errorf("generate map: %s", err) - } - if len(races) != len(m.HomePlanets) { - return uuid.Nil, fmt.Errorf("generate map: wrong number of home planets: %d, expected: %d ", len(m.HomePlanets), len(races)) - } - g := &game.Game{ - ID: gameID, - Race: make([]game.Race, len(races)), - } - - gameMap := &game.Map{ - Width: m.Width, - Height: m.Height, - Planet: make([]game.Planet, 0), - } - var planetCount uint = 0 - for i := range races { - raceID, err := uuid.NewRandom() - if err != nil { - return uuid.Nil, fmt.Errorf("generate race uuid: %s", err) - } - g.Race[i] = game.Race{ - ID: raceID, - Name: races[i], - Vote: raceID, - Drive: 1, - Weapons: 1, - Shields: 1, - Cargo: 1, - } - gameMap.Planet = append(gameMap.Planet, newPlanet( - planetCount, - m.HomePlanets[i].HW.RandomName(), - raceID, - m.HomePlanets[i].HW.Position.X, - m.HomePlanets[i].HW.Position.Y, - m.HomePlanets[i].HW.Size, - m.HomePlanets[i].HW.Size, // HW's pop & ind = size - m.HomePlanets[i].HW.Size, - m.HomePlanets[i].HW.Resources, - game.ResearchDrive.AsType(""), - )) - planetCount++ - for dw := range m.HomePlanets[i].DW { - gameMap.Planet = append(gameMap.Planet, newPlanet( - planetCount, - m.HomePlanets[i].DW[dw].RandomName(), - raceID, - m.HomePlanets[i].DW[dw].Position.X, - m.HomePlanets[i].DW[dw].Position.Y, - m.HomePlanets[i].DW[dw].Size, - m.HomePlanets[i].DW[dw].Size, // DW's pop & ind = size - m.HomePlanets[i].DW[dw].Size, - m.HomePlanets[i].DW[dw].Resources, - game.ResearchDrive.AsType(""), - )) - planetCount++ - } - } - for i := range m.FreePlanets { - gameMap.Planet = append(gameMap.Planet, newPlanet( - planetCount, - m.FreePlanets[i].RandomName(), - uuid.Nil, - m.FreePlanets[i].Position.X, - m.FreePlanets[i].Position.Y, - m.FreePlanets[i].Size, - 0, - 0, - m.FreePlanets[i].Resources, - game.ProductionNone.AsType(""), - )) - planetCount++ - } - - // TODO: check code below actually works - rand.Shuffle(len(gameMap.Planet), func(i, j int) { - gameMap.Planet[i].Number, gameMap.Planet[j].Number = gameMap.Planet[j].Number, gameMap.Planet[i].Number - }) - - g.Map = *gameMap - - if err := r.SaveTurn(0, *g); err != nil { - return uuid.Nil, fmt.Errorf("persist: %s", err) - } - return g.ID, nil -} - -func newPlanet(num uint, name string, owner uuid.UUID, x, y, size, pop, ind, res float64, prod game.ProductionType) game.Planet { - return game.Planet{ - Name: name, - Number: num, - Owner: owner, - X: x, - Y: y, - Size: size, - Population: pop, - Industry: ind, - Resources: res, - Production: prod, - } + + // SaveState stores current game state updated between turns + SaveState(game.Game) error + + // LoadState retrieves game current state + LoadState() (game.Game, error) } diff --git a/pkg/game/generator.go b/pkg/game/generator.go new file mode 100644 index 0000000..a547326 --- /dev/null +++ b/pkg/game/generator.go @@ -0,0 +1,126 @@ +package game + +import ( + "fmt" + "math/rand/v2" + + "github.com/google/uuid" + "github.com/iliadenisov/galaxy/pkg/generator" + "github.com/iliadenisov/galaxy/pkg/model/game" +) + +func newGame(r Repo, races []string) (uuid.UUID, error) { + m, err := generator.Generate(func(ms *generator.MapSetting) { + ms.Players = uint32(len(races)) + }) + if err != nil { + return uuid.Nil, fmt.Errorf("generate map: %s", err) + } + return NewGameFromMap(r, races, m) +} + +func NewGameFromMap(r Repo, races []string, m generator.Map) (uuid.UUID, error) { + if len(races) != len(m.HomePlanets) { + return uuid.Nil, fmt.Errorf("generate map: wrong number of home planets: %d, expected: %d ", len(m.HomePlanets), len(races)) + } + gameID, err := uuid.NewRandom() + if err != nil { + return uuid.Nil, fmt.Errorf("generate game uuid: %s", err) + } + g := &game.Game{ + ID: gameID, + Race: make([]game.Race, len(races)), + } + + gameMap := &game.Map{ + Width: m.Width, + Height: m.Height, + Planet: make([]game.Planet, 0), + } + var planetCount uint = 0 + for i := range races { + raceID, err := uuid.NewRandom() + if err != nil { + return uuid.Nil, fmt.Errorf("generate race uuid: %s", err) + } + g.Race[i] = game.Race{ + ID: raceID, + Name: races[i], + Vote: raceID, + Drive: 1, + Weapons: 1, + Shields: 1, + Cargo: 1, + } + gameMap.Planet = append(gameMap.Planet, newPlanet( + planetCount, + m.HomePlanets[i].HW.RandomName(), + raceID, + m.HomePlanets[i].HW.Position.X, + m.HomePlanets[i].HW.Position.Y, + m.HomePlanets[i].HW.Size, + m.HomePlanets[i].HW.Size, // HW's pop & ind = size + m.HomePlanets[i].HW.Size, + m.HomePlanets[i].HW.Resources, + game.ResearchDrive.AsType(""), + )) + planetCount++ + for dw := range m.HomePlanets[i].DW { + gameMap.Planet = append(gameMap.Planet, newPlanet( + planetCount, + m.HomePlanets[i].DW[dw].RandomName(), + raceID, + m.HomePlanets[i].DW[dw].Position.X, + m.HomePlanets[i].DW[dw].Position.Y, + m.HomePlanets[i].DW[dw].Size, + m.HomePlanets[i].DW[dw].Size, // DW's pop & ind = size + m.HomePlanets[i].DW[dw].Size, + m.HomePlanets[i].DW[dw].Resources, + game.ResearchDrive.AsType(""), + )) + planetCount++ + } + } + for i := range m.FreePlanets { + gameMap.Planet = append(gameMap.Planet, newPlanet( + planetCount, + m.FreePlanets[i].RandomName(), + uuid.Nil, + m.FreePlanets[i].Position.X, + m.FreePlanets[i].Position.Y, + m.FreePlanets[i].Size, + 0, + 0, + m.FreePlanets[i].Resources, + game.ProductionNone.AsType(""), + )) + planetCount++ + } + + // TODO: check code below actually works + rand.Shuffle(len(gameMap.Planet), func(i, j int) { + gameMap.Planet[i].Number, gameMap.Planet[j].Number = gameMap.Planet[j].Number, gameMap.Planet[i].Number + }) + + g.Map = *gameMap + + if err := r.SaveTurn(0, *g); err != nil { + return uuid.Nil, fmt.Errorf("persist: %s", err) + } + return g.ID, nil +} + +func newPlanet(num uint, name string, owner uuid.UUID, x, y, size, pop, ind, res float64, prod game.ProductionType) game.Planet { + return game.Planet{ + Name: name, + Number: num, + Owner: owner, + X: x, + Y: y, + Size: size, + Population: pop, + Industry: ind, + Resources: res, + Production: prod, + } +} diff --git a/pkg/game/generator_test.go b/pkg/game/generator_test.go new file mode 100644 index 0000000..c16bada --- /dev/null +++ b/pkg/game/generator_test.go @@ -0,0 +1,29 @@ +package game + +import ( + "fmt" + "testing" + + "github.com/iliadenisov/galaxy/pkg/repo" + "github.com/iliadenisov/galaxy/pkg/util" + "github.com/stretchr/testify/assert" +) + +func TestNewGame(t *testing.T) { + root, cleanup := util.CreateWorkDir(t) + defer cleanup() + + r, err := repo.NewFileRepo(root) + assert.NoError(t, err) + + players := 20 + races := make([]string, players) + for i := range players { + races[i] = fmt.Sprintf("race_%02d", i) + } + assert.NoError(t, r.Lock()) + gameID, err := newGame(r, races) + assert.NoError(t, err) + assert.NoError(t, r.Release()) + _ = gameID +} diff --git a/pkg/model/game/game.go b/pkg/model/game/game.go index 44543f8..01d388e 100644 --- a/pkg/model/game/game.go +++ b/pkg/model/game/game.go @@ -27,3 +27,7 @@ func (g Game) Votes(raceID uuid.UUID) float64 { func (g Game) MarshalBinary() (data []byte, err error) { return json.Marshal(&g) } + +func (g Game) UnmarshalBinary(data []byte) error { + return json.Unmarshal(data, &g) +} diff --git a/pkg/repo/fs/fs.go b/pkg/repo/fs/fs.go index cf64c2a..f2df43b 100644 --- a/pkg/repo/fs/fs.go +++ b/pkg/repo/fs/fs.go @@ -75,7 +75,7 @@ func (f *fs) Lock() (func() error, error) { return unlock, nil } -func (f *fs) Exist(path string) (bool, error) { +func (f *fs) Exists(path string) (bool, error) { return fileExists(filepath.Join(f.root, path)) } diff --git a/pkg/repo/fs/fs_test.go b/pkg/repo/fs/fs_test.go index a0485cd..41258f0 100644 --- a/pkg/repo/fs/fs_test.go +++ b/pkg/repo/fs/fs_test.go @@ -6,35 +6,33 @@ import ( "strings" "testing" + "github.com/iliadenisov/galaxy/pkg/util" "github.com/stretchr/testify/assert" ) func TestNewFileStorageSuccess(t *testing.T) { - root, cleanup := createWorkDir(t) + root, cleanup := util.CreateWorkDir(t) defer cleanup() _, err := NewFileStorage(root) assert.NoError(t, err) } func TestLock(t *testing.T) { - root, cleanup := createWorkDir(t) + root, cleanup := util.CreateWorkDir(t) defer cleanup() fs, err := NewFileStorage(root) assert.NoError(t, err, "create file storage") unlock, err := fs.Lock() assert.NoError(t, err, "acquire lock") - exists, err := fileExists(filepath.Join(root, lockFile)) - assert.NoError(t, err, "check that the lock file should exist") - assert.True(t, exists, "lock file must exists") + lockPath := filepath.Join(root, lockFile) + assert.FileExists(t, lockPath, "lock file should be created") err = unlock() assert.NoError(t, err, "unlocking existing lock") - exists, err = fileExists(filepath.Join(root, lockFile)) - assert.NoError(t, err, "check that the lock file does not exist") - assert.False(t, exists, "lock file must be removed") + assert.NoFileExists(t, lockPath, "lock file must be removed") } func TestExist(t *testing.T) { - root, cleanup := createWorkDir(t) + root, cleanup := util.CreateWorkDir(t) defer cleanup() fileName := "some-file.ext" @@ -45,17 +43,17 @@ func TestExist(t *testing.T) { fs, err := NewFileStorage(root) assert.NoError(t, err, "create file storage") - exist, err := fs.Exist(fileName) + exist, err := fs.Exists(fileName) assert.NoError(t, err) assert.True(t, exist) - exist, err = fs.Exist("random/path") + exist, err = fs.Exists("random/path") assert.NoError(t, err) assert.False(t, exist) } func TestWrite(t *testing.T) { - root, cleanup := createWorkDir(t) + root, cleanup := util.CreateWorkDir(t) defer cleanup() fs, err := NewFileStorage(root) assert.NoError(t, err, "create file storage: %s", err) @@ -85,9 +83,7 @@ func TestWrite(t *testing.T) { if err != nil { assert.Fail(t, "not expecting an error", "write to file %s: %s", tc.path, err) } else { - exists, err := fileExists(filepath.Join(root, tc.path)) - assert.NoError(t, err, "check is written file exists") - assert.True(t, exists, "the written file should exist") + assert.FileExists(t, filepath.Join(root, tc.path), "the written file should exist") } } else if tc.err != "" { if err == nil { @@ -104,7 +100,7 @@ func TestWrite(t *testing.T) { } func TestRead(t *testing.T) { - root, cleanup := createWorkDir(t) + root, cleanup := util.CreateWorkDir(t) defer cleanup() fs, err := NewFileStorage(root) assert.NoError(t, err, "create file storage: %s", err) @@ -150,9 +146,7 @@ func TestRead(t *testing.T) { if err != nil { assert.Fail(t, "read: not expecting an error, got: "+err.Error()) } else { - exists, err := fileExists(filepath.Join(root, tc.path)) - assert.NoError(t, err, "check is written file exists") - assert.True(t, exists, "the written file should exist") + assert.FileExists(t, filepath.Join(root, tc.path), "the written file should exist") } } else if tc.err != "" { if err == nil { @@ -166,7 +160,7 @@ func TestRead(t *testing.T) { } func TestWriteErrorWithoutLock(t *testing.T) { - root, cleanup := createWorkDir(t) + root, cleanup := util.CreateWorkDir(t) defer cleanup() fs, err := NewFileStorage(root) assert.NoError(t, err, "create file storage") diff --git a/pkg/repo/fs/helper_test.go b/pkg/repo/fs/helper_test.go index a4c5f9e..58a2d4a 100644 --- a/pkg/repo/fs/helper_test.go +++ b/pkg/repo/fs/helper_test.go @@ -1,28 +1,13 @@ package fs import ( - "os" "slices" - "testing" ) const ( nonWritableDir = "/usr/lib" ) -func createWorkDir(t *testing.T) (string, func()) { - t.Helper() - dir, err := os.MkdirTemp("", "fs-test-workdir") - if err != nil { - t.Fatalf("create temp dir: %s", err) - } - return dir, func() { - if err := os.RemoveAll(dir); err != nil { - t.Fatalf("remove temp dir: %s", err) - } - } -} - type sampleData struct { data []byte } diff --git a/pkg/repo/fs/util_test.go b/pkg/repo/fs/util_test.go index dfff795..9de9452 100644 --- a/pkg/repo/fs/util_test.go +++ b/pkg/repo/fs/util_test.go @@ -6,30 +6,31 @@ import ( "testing" "github.com/google/uuid" + "github.com/iliadenisov/galaxy/pkg/util" "github.com/stretchr/testify/assert" ) func TestPathExists(t *testing.T) { - root, cleanup := createWorkDir(t) + root, cleanup := util.CreateWorkDir(t) defer cleanup() testDirExistsFunc(t, root, func(s string) (bool, error) { return pathExists(s, true) }) testFileExistsFunc(t, root, func(s string) (bool, error) { return pathExists(s, false) }) } func TestDirExists(t *testing.T) { - root, cleanup := createWorkDir(t) + root, cleanup := util.CreateWorkDir(t) defer cleanup() testDirExistsFunc(t, root, dirExists) } func TestFileExists(t *testing.T) { - root, cleanup := createWorkDir(t) + root, cleanup := util.CreateWorkDir(t) defer cleanup() testFileExistsFunc(t, root, fileExists) } func TestWritable(t *testing.T) { - root, cleanup := createWorkDir(t) + root, cleanup := util.CreateWorkDir(t) defer cleanup() ok, err := writable(root) assert.NoError(t, err, "directory writable check") diff --git a/pkg/repo/game.go b/pkg/repo/game.go index 6c069a2..06b3604 100644 --- a/pkg/repo/game.go +++ b/pkg/repo/game.go @@ -14,13 +14,17 @@ import ( "github.com/iliadenisov/galaxy/pkg/model/game" ) +const ( + statePath = "state.json" +) + func (r *repo) SaveTurn(t uint, g game.Game) error { return saveTurn(r.s, t, g) } func saveTurn(s Storage, t uint, g game.Game) error { path := fmt.Sprintf("%03d/state.json", t) - exist, err := s.Exist(path) + exist, err := s.Exists(path) if err != nil { return NewStorageError(err) } @@ -40,9 +44,28 @@ func (r *repo) SaveState(g game.Game) error { } func saveState(s Storage, g game.Game) error { - path := "state.json" - if err := s.Write(path, g); err != nil { + if err := s.Write(statePath, g); err != nil { return NewStorageError(err) } return nil } + +func (r *repo) LoadState() (game.Game, error) { + return loadState(r.s) +} + +func loadState(s Storage) (game.Game, error) { + var g game.Game + path := statePath + exist, err := s.Exists(path) + if err != nil { + return g, NewStorageError(err) + } + if !exist { + return g, NewStateError("latest state was never stored") + } + if err := s.Read(path, &g); err != nil { + return g, NewStorageError(err) + } + return g, nil +} diff --git a/pkg/repo/repo.go b/pkg/repo/repo.go index ee7fd7f..3d5b418 100644 --- a/pkg/repo/repo.go +++ b/pkg/repo/repo.go @@ -21,13 +21,14 @@ func NewStateError(msg string) error { type Storage interface { Lock() (func() error, error) - Exist(string) (bool, error) + Exists(string) (bool, error) Write(string, encoding.BinaryMarshaler) error Read(string, encoding.BinaryUnmarshaler) error } type repo struct { - s Storage + s Storage + release func() error } func NewRepo(s Storage) (*repo, error) { @@ -44,3 +45,38 @@ func NewFileRepo(path string) (*repo, error) { } return NewRepo(s) } + +func (r *repo) Lock() (err error) { + if r.s == nil { + return errors.New("storage is closed") + } + if r.release != nil { + return errors.New("storage already locked") + } + r.release, err = r.s.Lock() + if err != nil { + r.close() + return + } + return nil +} + +func (r *repo) Release() (err error) { + if r.s == nil { + return errors.New("storage is closed") + } + if r.release == nil { + return errors.New("storage was never locked") + } + err = r.release() + if err != nil { + return + } + r.close() + return nil +} + +func (r *repo) close() { + r.release = nil + r.s = nil +} diff --git a/pkg/util/fs.go b/pkg/util/fs.go new file mode 100644 index 0000000..5bdf790 --- /dev/null +++ b/pkg/util/fs.go @@ -0,0 +1,19 @@ +package util + +import ( + "os" + "testing" +) + +func CreateWorkDir(t *testing.T) (string, func()) { + t.Helper() + dir, err := os.MkdirTemp("", "fs-test-workdir") + if err != nil { + t.Fatalf("create temp dir: %s", err) + } + return dir, func() { + if err := os.RemoveAll(dir); err != nil { + t.Fatalf("remove temp dir: %s", err) + } + } +}