From 4d733ae74103c1c45eff6268f41a24975f4aa038 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 23 Sep 2025 18:36:22 +0300 Subject: [PATCH] new game, fs repo layer --- go.mod | 12 +++ go.sum | 14 +++ pkg/game/game.go | 83 ++++++++++++++- pkg/generator/generator_test.go | 11 +- pkg/model/game/game.go | 9 ++ pkg/model/game/map.go | 8 ++ pkg/model/game/planet.go | 55 ++++++++++ pkg/model/game/production.go | 30 ++++++ pkg/model/game/race.go | 22 ++++ pkg/model/game/ship.go | 132 +++++++++++++++++++++++ pkg/repo/fs/fs.go | 164 +++++++++++++++++++++++++++++ pkg/repo/fs/fs_test.go | 178 ++++++++++++++++++++++++++++++++ pkg/repo/fs/helper_test.go | 23 +++++ pkg/repo/fs/util.go | 37 +++++++ pkg/repo/fs/util_test.go | 77 ++++++++++++++ pkg/repo/repo.go | 27 +++++ pkg/server/server.go | 22 ---- pkg/storage/storage.go | 30 ------ 18 files changed, 880 insertions(+), 54 deletions(-) create mode 100644 go.sum create mode 100644 pkg/model/game/game.go create mode 100644 pkg/model/game/map.go create mode 100644 pkg/model/game/planet.go create mode 100644 pkg/model/game/production.go create mode 100644 pkg/model/game/race.go create mode 100644 pkg/model/game/ship.go create mode 100644 pkg/repo/fs/fs.go create mode 100644 pkg/repo/fs/fs_test.go create mode 100644 pkg/repo/fs/helper_test.go create mode 100644 pkg/repo/fs/util.go create mode 100644 pkg/repo/fs/util_test.go create mode 100644 pkg/repo/repo.go delete mode 100644 pkg/server/server.go delete mode 100644 pkg/storage/storage.go diff --git a/go.mod b/go.mod index 87557e4..f766f9e 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,15 @@ module github.com/iliadenisov/galaxy go 1.25.0 + +require ( + github.com/google/uuid v1.6.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/sys v0.36.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d73550d --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/game/game.go b/pkg/game/game.go index 4228df4..7bc4b22 100644 --- a/pkg/game/game.go +++ b/pkg/game/game.go @@ -1,6 +1,87 @@ package game -import "math" +import ( + "fmt" + "math" + + "github.com/google/uuid" + "github.com/iliadenisov/galaxy/pkg/generator" + "github.com/iliadenisov/galaxy/pkg/model/game" +) + +type Repo interface { + Persist(game.Game) error +} + +func NewGame(r Repo, races []string) (uuid.UUID, error) { + id, err := uuid.NewRandom() + if err != nil { + return uuid.Nil, fmt.Errorf("generate 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: id, + Race: make([]game.Race, len(races)), + } + + gameMap := &game.Map{ + Width: m.Width, + Height: m.Height, + Planet: make([]game.Planet, 0), + } + for hw := range races { + g.Race[hw] = game.Race{ + Name: races[hw], + Votes: 1, // TODO: check with rules + VoteFor: races[hw], + Drive: 1, + Weapons: 1, + Shields: 1, + Cargo: 1, + } + gameMap.Planet = append(gameMap.Planet, game.Planet{ + Owner: races[hw], + X: m.HomePlanets[hw].HW.Position.X, + Y: m.HomePlanets[hw].HW.Position.Y, + Size: m.HomePlanets[hw].HW.Size, + Resources: m.HomePlanets[hw].HW.Resources, + Production: game.ProductionCapital.AsType(""), // TODO: check default production + }) + for dw := range m.HomePlanets[hw].DW { + gameMap.Planet = append(gameMap.Planet, game.Planet{ + X: m.HomePlanets[hw].DW[dw].Position.X, + Y: m.HomePlanets[hw].DW[dw].Position.Y, + Size: m.HomePlanets[hw].DW[dw].Size, + Resources: m.HomePlanets[hw].DW[dw].Resources, + Production: game.ProductionNone.AsType(""), + }) + } + } + for i := range m.FreePlanets { + gameMap.Planet = append(gameMap.Planet, game.Planet{ + X: m.FreePlanets[i].Position.X, + Y: m.FreePlanets[i].Position.Y, + Size: m.FreePlanets[i].Size, + Resources: m.FreePlanets[i].Resources, + Production: game.ProductionNone.AsType(""), + }) + } + + g.Map = *gameMap + + if err := r.Persist(*g); err != nil { + return uuid.Nil, fmt.Errorf("persist: %s", err) + } + return g.ID, nil +} func (r Race) FlightDistance() float64 { return r.Drive * 40 diff --git a/pkg/generator/generator_test.go b/pkg/generator/generator_test.go index 475b0ba..d318300 100644 --- a/pkg/generator/generator_test.go +++ b/pkg/generator/generator_test.go @@ -5,15 +5,24 @@ import ( "testing" "github.com/iliadenisov/galaxy/pkg/generator" + "github.com/stretchr/testify/assert" ) func TestGenerator(t *testing.T) { for players := 10; players <= 50; players++ { - _, err := generator.Generate(func(ms *generator.MapSetting) { ms.Players = uint32(players) }) + var s generator.MapSetting + m, err := generator.Generate(func(ms *generator.MapSetting) { + ms.Players = uint32(players) + s = *ms + }) if err != nil { t.Errorf("generate: %s", err) break } + assert.Equal(t, players, len(m.HomePlanets), "hw count") + for i := range m.HomePlanets { + assert.Equal(t, int(s.DWCount), len(m.HomePlanets[i].DW), "hw #%d: dw count", i) + } } } diff --git a/pkg/model/game/game.go b/pkg/model/game/game.go new file mode 100644 index 0000000..a7edf27 --- /dev/null +++ b/pkg/model/game/game.go @@ -0,0 +1,9 @@ +package game + +import "github.com/google/uuid" + +type Game struct { + ID uuid.UUID + Map Map + Race []Race +} diff --git a/pkg/model/game/map.go b/pkg/model/game/map.go new file mode 100644 index 0000000..7eb2b76 --- /dev/null +++ b/pkg/model/game/map.go @@ -0,0 +1,8 @@ +package game + +type Map struct { + Width uint32 + Height uint32 + + Planet []Planet +} diff --git a/pkg/model/game/planet.go b/pkg/model/game/planet.go new file mode 100644 index 0000000..6a8e25a --- /dev/null +++ b/pkg/model/game/planet.go @@ -0,0 +1,55 @@ +package game + +import "math" + +type Planet struct { + X, Y float32 + Size float32 + + Name string + Owner string + + Production ProductionType + Resources float32 // Сырьё + Industry float32 // Промышленность + Population float32 // Население + + Capital float32 // CAP $ - Запасы промышленности + Material float32 // MAT M - Запасы сырья + Colonists float32 // COL C - Количество колонистов + // Параметр "L" означает количество свободных производственных единиц. +} + +// Производственный потенциал (I) +// промышленность * 0.75 + население * 0.25 +func (p Planet) ProductionCapacity() float32 { + return p.Industry*0.75 + p.Population*0.25 +} + +// Производство промышленности +// TODO: test on real values +func (p *Planet) IncreaseIndustry() { + prod := p.ProductionCapacity() / 5 + industryIncrement := float32(math.Min(float64(prod), float64(p.Material))) + p.Industry += industryIncrement + if p.Industry > p.Population { + p.Industry = p.Population + p.Capital += p.Population - p.Industry + } +} + +// Производство материалов +// TODO: test on real values +func (p *Planet) IncreaseMaterial() { + p.Material += p.ProductionCapacity() * p.Industry +} + +// Автоматическое увеличение населения на каждом ходу +func (p *Planet) IncreasePopulation() { + p.Population *= 1.08 + var extraPopulation = p.Size - p.Population + if extraPopulation > 0 { + p.Colonists += extraPopulation / 8 + p.Population -= extraPopulation + } +} diff --git a/pkg/model/game/production.go b/pkg/model/game/production.go new file mode 100644 index 0000000..541883f --- /dev/null +++ b/pkg/model/game/production.go @@ -0,0 +1,30 @@ +package game + +type PlanetProduction string + +const ( + ProductionNone PlanetProduction = "NONE" + ProductionMaterial PlanetProduction = "MAT" + ProductionCapital PlanetProduction = "CAP" + ProductionDrive PlanetProduction = "DRIVE" + ProductionWeapons PlanetProduction = "WEAPONS" + ProductionShields PlanetProduction = "SHIELDS" + ProductionCargo PlanetProduction = "CARGO" + + ProductionScience PlanetProduction = "SCIENCE" + ProductionShip PlanetProduction = "SHIP" +) + +type ProductionType struct { + Production PlanetProduction + SubjectName string +} + +func (p PlanetProduction) AsType(subject string) ProductionType { + switch p { + case ProductionScience, ProductionShip: + return ProductionType{Production: p, SubjectName: subject} + default: + return ProductionType{Production: p} + } +} diff --git a/pkg/model/game/race.go b/pkg/model/game/race.go new file mode 100644 index 0000000..c02b16c --- /dev/null +++ b/pkg/model/game/race.go @@ -0,0 +1,22 @@ +package game + +type Race struct { + Name string + Killed bool + + Votes float32 + VoteFor string + + Drive float32 + Weapons float32 + Shields float32 + Cargo float32 +} + +func (r Race) FlightDistance() float32 { + return r.Drive * 40 +} + +func (r Race) VisibilityDistance() float32 { + return r.Drive * 30 +} diff --git a/pkg/model/game/ship.go b/pkg/model/game/ship.go new file mode 100644 index 0000000..b7d7b0c --- /dev/null +++ b/pkg/model/game/ship.go @@ -0,0 +1,132 @@ +package game + +import "math" + +type Ship struct { + TypeName string +} + +type ShipType struct { + Name string + Drive float64 // [0], [1...] + Armament uint + Weapons float64 // [0], [1...] + Shields float64 // [0], [1...] + Cargo float64 // [0], [1...] +} + +type ShipGroup struct { + Type ShipType + Number uint + State string // TODO: kinda enum: In_Orbit, In_Space, Transfer_State, Upgrade + Load float64 // Cargo loaded - "Масса груза" + Drive float64 + Weapons float64 + Shields float64 + Cargo float64 +} + +type Fleet struct { + ShipGroups []ShipGroup +} + +// TODO: test on real values +func (st ShipType) EmptyMass() float64 { + shipMass := st.DriveMass() + st.ShieldsMass() + st.CargoMass() + st.WeaponsMass() + return shipMass +} + +func (st ShipType) DriveMass() float64 { + return st.Drive +} + +func (st ShipType) ShieldsMass() float64 { + return st.Shields +} + +func (st ShipType) CargoMass() float64 { + return st.Cargo +} + +func (st ShipType) WeaponsMass() float64 { + return float64(st.Armament)*(st.Weapons/2) + st.Weapons/2 +} + +// Грузоподъёмность +func (sg ShipGroup) CargoCapacity() float64 { + return sg.Drive * (sg.Type.Cargo + (sg.Type.Cargo*sg.Type.Cargo)/20) +} + +// "Масса перевозимого груза" +func (sg ShipGroup) CarryingMass() float64 { + return sg.Load / sg.Cargo +} + +func (sg ShipGroup) FullMass() float64 { + return sg.Type.EmptyMass() + sg.CarryingMass() +} + +// "Эффективность двигателя" +// равна мощности Двигателей умноженной на текущий технологический уровень блока Двигателей +func (sg ShipGroup) DriveEffective() float64 { + return sg.Type.Drive * sg.Drive +} + +// TODO: test this +func (sg ShipGroup) Speed() float64 { + return sg.DriveEffective() * 20 / sg.FullMass() +} + +func (sg ShipGroup) UpgradeDriveCost(drive float64) float64 { + return (1 - sg.Drive/drive) * 10 * sg.Type.Drive +} + +// TODO: test on other values +func (sg ShipGroup) UpgradeWeaponsCost(weapons float64) float64 { + return (1 - sg.Weapons/weapons) * 10 * sg.Type.WeaponsMass() +} + +func (sg ShipGroup) UpgradeShieldsCost(shields float64) float64 { + return (1 - sg.Shields/shields) * 10 * sg.Type.Shields +} + +func (sg ShipGroup) UpgradeCargoCost(cargo float64) float64 { + return (1 - sg.Cargo/cargo) * 10 * sg.Type.Cargo +} + +// Мощность бомбардировки +// TODO: maybe rounding must be done only for display? +func (sg ShipGroup) BombingPower() float64 { + // return math.Sqrt(sg.Type.Weapons * sg.Weapons) + result := (math.Sqrt(sg.Type.Weapons*sg.Weapons)/10. + 1.) * + sg.Type.Weapons * + sg.Weapons * + float64(sg.Type.Armament) * + float64(sg.Number) + return toFixed3(result) +} + +// TODO: test this +func (fl Fleet) Speed() float64 { + result := math.MaxFloat64 + for _, sg := range fl.ShipGroups { + if sg.Speed() < result { + result = sg.Speed() + } + } + return result +} + +func toFixed3(num float64) float64 { + return toFixed(num, 3) +} + +// TODO: move to more common place +func toFixed(num float64, precision int) float64 { + output := math.Pow(10, float64(precision)) + return float64(round(num*output)) / output +} + +func round(num float64) int { + return int(num + math.Copysign(0.5, num)) +} diff --git a/pkg/repo/fs/fs.go b/pkg/repo/fs/fs.go new file mode 100644 index 0000000..7fc3ba8 --- /dev/null +++ b/pkg/repo/fs/fs.go @@ -0,0 +1,164 @@ +package fs + +import ( + "errors" + "fmt" + "math/big" + "os" + "path/filepath" + "time" +) + +const ( + defaultPerm = 0o644 + lockFile = ".lock" + oldFileSuffix = ".old" + newFileSuffix = ".new" +) + +type fs struct { + root string + lock *os.File +} + +func NewFileStorage(path string) (*fs, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("path %s invalid: %s", path, err) + } + if ok, err := dirExists(absPath); err != nil { + return nil, fmt.Errorf("check dir exist: %s", err) + } else if !ok { + return nil, errors.New("directory does not exist: " + absPath) + } + + if ok, err := writable(absPath); err != nil { + return nil, fmt.Errorf("check dir access: %s", err) + } else if !ok { + return nil, errors.New("directory should have read-write access: " + absPath) + } + + fs := &fs{ + root: path, + } + return fs, nil +} + +func (f *fs) Lock() (func() error, error) { + lockPath := f.lockFilePath() + exists, err := fileExists(lockPath) + if err != nil { + return nil, fmt.Errorf("check lock file exists: %s", err) + } + if exists { + return nil, errors.New("lock file already exists") + } + fd, err := os.OpenFile(lockPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return nil, fmt.Errorf("create lock file: %s", err) + } + f.lock = fd + unlock := func() error { + if err := f.lock.Close(); err != nil { + return fmt.Errorf("close lock file: %s", err) + } + if err := os.Remove(f.lock.Name()); err != nil { + return fmt.Errorf("remove lock file: %s", err) + } + f.lock = nil + return nil + } + if _, err := f.lock.Write(big.NewInt(time.Now().UnixMilli()).Bytes()); err != nil { + return nil, errors.Join(fmt.Errorf("write lock file: %s", err), unlock()) + } + return unlock, nil +} + +func (f *fs) Write(path string, data []byte) error { + if f.lock == nil { + return errors.New("lock must be acquired before write") + } + + targetFilePath := filepath.Join(f.root, path) + if targetFilePath == f.lockFilePath() { + return errors.New("can't write to the lock file") + } + + targetDir := filepath.Dir(targetFilePath) + if targetDir != f.root { + ok, err := dirExists(targetDir) + if err != nil { + return fmt.Errorf("check target dir exists: %s", err) + } + if !ok { + err := os.MkdirAll(targetDir, os.ModePerm) + if err != nil { + return fmt.Errorf("create target dirs: %s", err) + } + } + } + oldFilePath := targetFilePath + oldFileSuffix + + targetExists, err := fileExists(targetFilePath) + if err != nil { + return fmt.Errorf("check target file exists: %s", err) + } + if targetExists { + oldFileExists, err := fileExists(oldFilePath) + if err != nil { + return fmt.Errorf("check old file exists: %s", err) + } + if oldFileExists { + return fmt.Errorf("old file exists at: %s", oldFilePath) + } + } + + newFilePath := targetFilePath + newFileSuffix + newFileExists, err := fileExists(newFilePath) + if err != nil { + return fmt.Errorf("check new file exists: %s", err) + } + if newFileExists { + return fmt.Errorf("new file exists at: %s", oldFilePath) + } + + err = os.WriteFile(newFilePath, data, defaultPerm) + if err != nil { + return fmt.Errorf("write data to the new file: %s", err) + } + + if targetExists { + if err := os.Rename(targetFilePath, oldFilePath); err != nil { + return fmt.Errorf("rename target file to the old file: %s", err) + } + } + + if err := os.Rename(newFilePath, targetFilePath); err != nil { + return fmt.Errorf("rename new file to the target file: %s", err) + } + + if targetExists { + err := os.Remove(oldFilePath) + if err != nil { + return fmt.Errorf("remove old file: %s", err) + } + } + return nil +} + +func (f *fs) Read(path string) ([]byte, error) { + if f.lock != nil { + return nil, errors.New("lock must be released before read") + } + + targetFilePath := filepath.Join(f.root, path) + if targetFilePath == f.lockFilePath() { + return nil, errors.New("can't read from the lock file") + } + + return os.ReadFile(targetFilePath) +} + +func (f *fs) lockFilePath() string { + return filepath.Join(f.root, lockFile) +} diff --git a/pkg/repo/fs/fs_test.go b/pkg/repo/fs/fs_test.go new file mode 100644 index 0000000..848400f --- /dev/null +++ b/pkg/repo/fs/fs_test.go @@ -0,0 +1,178 @@ +package fs + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewFileStorageSuccess(t *testing.T) { + root, cleanup := createWorkDir(t) + defer cleanup() + _, err := NewFileStorage(root) + assert.NoError(t, err) +} + +func TestLock(t *testing.T) { + root, cleanup := 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") + 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") +} + +func TestWrite(t *testing.T) { + root, cleanup := createWorkDir(t) + defer cleanup() + fs, err := NewFileStorage(root) + assert.NoError(t, err, "create file storage: %s", err) + unlock, err := fs.Lock() + assert.NoError(t, err, "acquire lock: %s", err) + + dirName := "some-dir" + if err := os.Mkdir(filepath.Join(root, dirName), os.ModePerm); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + path string + err string + }{ + {path: "file-1.ext"}, + {path: "/dir/file-2.ext"}, + {path: "dir/subdir/file-3.ext"}, + {path: lockFile, err: "write to the lock file"}, + {path: dirName, err: "wrong type"}, + {path: "/" + dirName, err: "wrong type"}, + } { + t.Run(tc.path, func(t *testing.T) { + err = fs.Write(tc.path, []byte{0, 1, 2, 3}) + if tc.err == "" { + 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") + } + } else if tc.err != "" { + if err == nil { + assert.Fail(t, "expecting an error, got none", "write to file %s", tc.path) + } else { + assert.True(t, strings.Contains(err.Error(), tc.err), "expect: %q got: %q", tc.err, err.Error()) + } + } + }) + } + + err = unlock() + assert.NoError(t, err, "unlocking existing lock") +} + +func TestRead(t *testing.T) { + root, cleanup := createWorkDir(t) + defer cleanup() + fs, err := NewFileStorage(root) + assert.NoError(t, err, "create file storage: %s", err) + + dirName := "some-dir" + if err := os.Mkdir(filepath.Join(root, dirName), os.ModePerm); err != nil { + t.Fatal(err) + } + + fileName := "some-file.ext" + if err := os.WriteFile(filepath.Join(root, fileName), []byte{1, 2, 3, 4}, os.ModePerm); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + path string + lock bool + err string + }{ + {path: fileName}, + {path: "/" + fileName}, + {path: fileName, lock: true, err: "lock must be released"}, + {path: lockFile, err: "read from the lock file"}, + {path: "dir/subdir/file-3.ext", err: "no such file"}, + {path: lockFile, err: "read from the lock file"}, + {path: dirName, err: "is a directory"}, + } { + t.Run(tc.path, func(t *testing.T) { + if tc.lock { + unlock, err := fs.Lock() + if err != nil { + t.Fatalf("acquire lock: %s", err) + } + defer func() { + if err := unlock(); err != nil { + t.Fatalf("release lock: %s", err) + } + }() + } + _, err = fs.Read(tc.path) + if tc.err == "" { + 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") + } + } else if tc.err != "" { + if err == nil { + assert.Fail(t, "read: expecting an error, got none") + } else { + assert.True(t, strings.Contains(err.Error(), tc.err), "expect: %q got: %q", tc.err, err.Error()) + } + } + }) + } +} + +func TestWriteErrorWithoutLock(t *testing.T) { + root, cleanup := createWorkDir(t) + defer cleanup() + fs, err := NewFileStorage(root) + assert.NoError(t, err, "create file storage") + err = fs.Write("some/path", []byte{0, 1, 2, 3}) + assert.Error(t, err, "should return error when no lock acquired") + assert.True(t, strings.Contains(err.Error(), "lock must be acquired"), "should return missing lock error") +} + +func TestNewFileStorageErrorNotExists(t *testing.T) { + _, err := NewFileStorage(filepath.Join(os.TempDir(), "non-existent-dir")) + assert.Error(t, err) +} + +func TestNewFileStorageErrorNotADirectory(t *testing.T) { + f, err := os.CreateTemp("", "fs-test-file") + if err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + _, err = NewFileStorage(f.Name()) + assert.Error(t, err) + if err := os.Remove(f.Name()); err != nil { + t.Fatal(err) + } +} + +func TestNewFileStorageErrorNoAccess(t *testing.T) { + _, err := NewFileStorage(nonWritableDir) + assert.Error(t, err) +} diff --git a/pkg/repo/fs/helper_test.go b/pkg/repo/fs/helper_test.go new file mode 100644 index 0000000..46aca37 --- /dev/null +++ b/pkg/repo/fs/helper_test.go @@ -0,0 +1,23 @@ +package fs + +import ( + "os" + "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) + } + } +} diff --git a/pkg/repo/fs/util.go b/pkg/repo/fs/util.go new file mode 100644 index 0000000..d81b49e --- /dev/null +++ b/pkg/repo/fs/util.go @@ -0,0 +1,37 @@ +//go:build !windows + +// for windows builds func [writable] should be refactored +package fs + +import ( + "fmt" + "os" + + "golang.org/x/sys/unix" +) + +func dirExists(path string) (bool, error) { + return pathExists(path, true) +} + +func fileExists(path string) (bool, error) { + return pathExists(path, false) +} + +func pathExists(path string, isDir bool) (bool, error) { + if fi, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } else { + if isDir != fi.IsDir() { + return false, fmt.Errorf("wrong type: "+path+" mode=%s isDir=%t", fi.Mode(), isDir) + } + return true, nil + } +} + +func writable(filepath string) (bool, error) { + return unix.Access(filepath, unix.W_OK) == nil, nil +} diff --git a/pkg/repo/fs/util_test.go b/pkg/repo/fs/util_test.go new file mode 100644 index 0000000..dfff795 --- /dev/null +++ b/pkg/repo/fs/util_test.go @@ -0,0 +1,77 @@ +package fs + +import ( + "os" + "path/filepath" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestPathExists(t *testing.T) { + root, cleanup := 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) + defer cleanup() + testDirExistsFunc(t, root, dirExists) +} + +func TestFileExists(t *testing.T) { + root, cleanup := createWorkDir(t) + defer cleanup() + testFileExistsFunc(t, root, fileExists) +} + +func TestWritable(t *testing.T) { + root, cleanup := createWorkDir(t) + defer cleanup() + ok, err := writable(root) + assert.NoError(t, err, "directory writable check") + assert.True(t, ok, "directory should be writable") + + ok, err = writable(nonWritableDir) + assert.NoError(t, err, "system directory writable check") + assert.False(t, ok, "system directory should not be writable") +} + +func testDirExistsFunc(t *testing.T, root string, dirCheck func(string) (bool, error)) { + exists, err := dirCheck(root) + assert.NoError(t, err, "directory existence check") + assert.True(t, exists, "directory should exist") + nonExistentDir := filepath.Join(root, uuid.New().String()) + exists, err = dirCheck(nonExistentDir) + assert.NoError(t, err, "non-existent directory existence check") + assert.False(t, exists, "non-existent directory should not exist") +} + +func testFileExistsFunc(t *testing.T, root string, fileCheck func(string) (bool, error)) { + fpath := createTempFile(t, root) + exists, err := fileCheck(fpath) + assert.NoError(t, err, "file existence check") + assert.True(t, exists, "file should exist") + nonExistentFile := filepath.Join(root, uuid.New().String()) + exists, err = fileCheck(nonExistentFile) + assert.NoError(t, err, "non-existent file existence check") + assert.False(t, exists, "non-existent file should not exist") +} + +func createTempFile(t *testing.T, root string) string { + t.Helper() + + fd, err := os.CreateTemp(root, "a-file") + if err != nil { + assert.FailNow(t, "create temporary file", err) + return "" + } + if err := fd.Close(); err != nil { + assert.FailNow(t, "close temporary file", err) + return "" + } + return fd.Name() +} diff --git a/pkg/repo/repo.go b/pkg/repo/repo.go new file mode 100644 index 0000000..9574cb9 --- /dev/null +++ b/pkg/repo/repo.go @@ -0,0 +1,27 @@ +package repo + +import "github.com/iliadenisov/galaxy/pkg/repo/fs" + +type Storage interface { + Lock() (func() error, error) + Write(string, []byte) error +} + +type repo struct { + s Storage +} + +func NewRepo(s Storage) (*repo, error) { + r := &repo{ + s: s, + } + return r, nil +} + +func NewFileRepo(path string) (*repo, error) { + s, err := fs.NewFileStorage(path) + if err != nil { + return nil, err + } + return NewRepo(s) +} diff --git a/pkg/server/server.go b/pkg/server/server.go deleted file mode 100644 index 6c38ba0..0000000 --- a/pkg/server/server.go +++ /dev/null @@ -1,22 +0,0 @@ -package server - -import ( - "errors" - - "github.com/iliadenisov/galaxy/pkg/game" - "github.com/iliadenisov/galaxy/pkg/generator" - "github.com/iliadenisov/galaxy/pkg/storage" -) - -type Server struct { - storage storage.Storage -} - -func New(storage storage.Storage) Server { - return Server{storage: storage} -} - -func (s Server) CreateGame(gameParam game.GameParameter) (game.Game, error) { - _, _ = generator.Generate() - return game.Game{}, errors.New("not yet implemented") -} diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go deleted file mode 100644 index 43e39fb..0000000 --- a/pkg/storage/storage.go +++ /dev/null @@ -1,30 +0,0 @@ -package storage - -import "github.com/iliadenisov/galaxy/pkg/game" - -// games/ -// data.json - id, name, turn, schedule, status -// game123/ -// race//data.json - account_id, name, status, war/peace(?), last_order, etc. -// order///0.json - incoming orders -// turn/12/log/ - ? -// turn/12/order//0.json - processed orders -// turn/12/state/0.json - initital, contains for planet -// state/<1...N>.json - instant commands changes state -// turn/12/report/global.json -// report/.json -// turn/12/battle//.json - -type Storage interface { - GenerateRaceId() game.RaceIdentifier - GenerateGameId() game.GameIdentifier - - CreateGame(game.GameParameter) (game.Game, error) - ListGames() ([]game.GameIdentifier, error) - - LoadRace(game_id game.GameIdentifier, race_id game.RaceIdentifier) (game.Race, error) - SaveRace(game_id game.GameIdentifier, race game.Race) error - - LoadState(game_id game.GameIdentifier) (game.Game, error) - SaveState(game game.Game) error -}