diff --git a/pkg/error/generic.go b/pkg/error/generic.go new file mode 100644 index 0000000..9de91f1 --- /dev/null +++ b/pkg/error/generic.go @@ -0,0 +1,76 @@ +package error + +import ( + "fmt" +) + +const ( + ErrDummy int = -1 + + ErrStorageFailure int = 1000 + ErrGameStateInvalid int = 2000 + + ErrInputUnknownHostRace int = 3000 + ErrInputUnknownOpponentRace int = 3001 +) + +func errorText(code int) string { + switch code { + case ErrDummy: + return "Dummy" + case ErrStorageFailure: + return "Storage failure" + case ErrGameStateInvalid: + return "Invalid game state" + case ErrInputUnknownHostRace: + return "Host race name is unknown to this game" + case ErrInputUnknownOpponentRace: + return "Opponent race name is unknown to this game" + default: + return fmt.Sprintf("Undescribed error with code %d", code) + } +} + +type GenericError struct { + code int + subject string + err error +} + +func (ge GenericError) Error() string { + msg := errorText(ge.code) + if ge.subject != "" { + msg += ": " + ge.subject + } + if ge.err != nil { + msg = fmt.Errorf("%s: %w", msg, ge.err).Error() + } + return msg +} + +func newGenericError(code int, arg ...any) error { + e := &GenericError{code: code} + if len(arg) > 0 { + i := 0 + switch arg[i].(type) { + case error: + e.err = arg[i].(error) + i += 1 + } + if len(arg) == i+2 { + e.subject = fmt.Sprintf(asString(arg[i]), arg[i+1:]...) + } else if len(arg) == i+1 { + e.subject = asString(arg[i]) + } + } + return *e +} + +func asString(v any) string { + switch s := v.(type) { + case string: + return s + default: + return fmt.Sprint(v) + } +} diff --git a/pkg/error/generic_test.go b/pkg/error/generic_test.go new file mode 100644 index 0000000..edda581 --- /dev/null +++ b/pkg/error/generic_test.go @@ -0,0 +1,27 @@ +package error + +import ( + "errors" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewGenericError(t *testing.T) { + for i, tc := range []struct { + arg []any + message string + }{ + {arg: []any{"Foo"}, message: "Dummy: Foo"}, + {arg: []any{"Foo%s", "Bar"}, message: "Dummy: FooBar"}, + {arg: []any{errors.New("Error")}, message: "Dummy: Error"}, + {arg: []any{errors.New("Error"), "Foo"}, message: "Dummy: Foo: Error"}, + {arg: []any{errors.New("Error"), "Foo%s", "Bar"}, message: "Dummy: FooBar: Error"}, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + err := newGenericError(ErrDummy, tc.arg...) + assert.EqualError(t, err, tc.message) + }) + } +} diff --git a/pkg/error/input.go b/pkg/error/input.go new file mode 100644 index 0000000..47c687b --- /dev/null +++ b/pkg/error/input.go @@ -0,0 +1,9 @@ +package error + +func NewHostRaceUnknownError(arg ...any) error { + return newGenericError(ErrInputUnknownHostRace, arg...) +} + +func NewOpponentRaceUnknownError(arg ...any) error { + return newGenericError(ErrInputUnknownOpponentRace, arg...) +} diff --git a/pkg/error/repo.go b/pkg/error/repo.go new file mode 100644 index 0000000..26c1f26 --- /dev/null +++ b/pkg/error/repo.go @@ -0,0 +1,5 @@ +package error + +func NewRepoError(arg ...any) error { + return newGenericError(ErrStorageFailure, arg...) +} diff --git a/pkg/error/state.go b/pkg/error/state.go new file mode 100644 index 0000000..845a0cb --- /dev/null +++ b/pkg/error/state.go @@ -0,0 +1,5 @@ +package error + +func NewGameStateError(arg ...any) error { + return newGenericError(ErrGameStateInvalid, arg...) +} diff --git a/pkg/game/command.go b/pkg/game/command.go new file mode 100644 index 0000000..b42b679 --- /dev/null +++ b/pkg/game/command.go @@ -0,0 +1,32 @@ +package game + +import "github.com/iliadenisov/galaxy/pkg/model/game" + +func DeclareWar(configure func(*Param), from, to string) (err error) { + control(configure, func(c *ctrl) { c.execute(func(r Repo) { err = updateRelation(r, from, to, game.RelationWar) }) }) + return +} + +func DeclarePeace(configure func(*Param), from, to string) (err error) { + control(configure, func(c *ctrl) { c.execute(func(r Repo) { err = updateRelation(r, from, to, game.RelationPeace) }) }) + return +} + +func updateRelation(r Repo, hostRace, opponentRace string, rel game.Relation) error { + g, err := r.LoadState() + if err != nil { + return err + } + hostID, err := g.HostRaceID(hostRace) + if err != nil { + return err + } + opponentID, err := g.OpponentRaceID(opponentRace) + if err != nil { + return err + } + if err := g.UpdateRelation(hostID, opponentID, rel); err != nil { + return err + } + return r.SaveState(g) +} diff --git a/pkg/game/command_test.go b/pkg/game/command_test.go new file mode 100644 index 0000000..819056c --- /dev/null +++ b/pkg/game/command_test.go @@ -0,0 +1,26 @@ +package game_test + +import ( + "fmt" + "testing" + + "github.com/iliadenisov/galaxy/pkg/game" + "github.com/iliadenisov/galaxy/pkg/util" + "github.com/stretchr/testify/assert" +) + +func TestRelation(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.DeclarePeace(func(p *game.Param) { p.StoragePath = root }, "race_05", "race_01") + assert.NoError(t, err) + // TODO: check relation state changed +} diff --git a/pkg/game/generator.go b/pkg/game/generator.go index 5113af7..3f4f9f3 100644 --- a/pkg/game/generator.go +++ b/pkg/game/generator.go @@ -3,6 +3,7 @@ package game import ( "fmt" "math/rand/v2" + "slices" "github.com/google/uuid" "github.com/iliadenisov/galaxy/pkg/generator" @@ -16,33 +17,45 @@ func newGame(r Repo, races []string) (uuid.UUID, error) { if err != nil { return uuid.Nil, fmt.Errorf("generate map: %s", err) } - return NewGameFromMap(r, races, m) + return newGameOnMap(r, races, m) } -func NewGameFromMap(r Repo, races []string, m generator.Map) (uuid.UUID, error) { +func newGameOnMap(r Repo, races []string, m generator.Map) (uuid.UUID, error) { + g, err := buildGameOnMap(races, m) + if err != nil { + return uuid.Nil, err + } + if err := r.SaveTurn(0, *g); err != nil { + return uuid.Nil, err + } + return g.ID, nil +} + +func buildGameOnMap(races []string, m generator.Map) (*game.Game, 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)) + return 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) + return 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 + relations := make([]game.RaceRelation, len(races)) for i := range races { raceID, err := uuid.NewRandom() if err != nil { - return uuid.Nil, fmt.Errorf("generate race uuid: %s", err) + return nil, fmt.Errorf("generate race uuid: %s", err) } + relations[i] = game.RaceRelation{RaceID: raceID, Relation: game.RelationWar} g.Race[i] = game.Race{ ID: raceID, Name: races[i], @@ -51,7 +64,6 @@ func NewGameFromMap(r Repo, races []string, m generator.Map) (uuid.UUID, error) Weapons: 1, Shields: 1, Cargo: 1, - // TODO: fill Relation } gameMap.Planet = append(gameMap.Planet, newPlanet( planetCount, @@ -82,6 +94,12 @@ func NewGameFromMap(r Repo, races []string, m generator.Map) (uuid.UUID, error) planetCount++ } } + for i := range g.Race { + rel := slices.Clone(relations) + ri := slices.IndexFunc(rel, func(a game.RaceRelation) bool { return a.RaceID == g.Race[i].ID }) + g.Race[i].Relations = append(rel[:ri], rel[ri+1:]...) + } + for i := range m.FreePlanets { gameMap.Planet = append(gameMap.Planet, newPlanet( planetCount, @@ -98,30 +116,13 @@ func NewGameFromMap(r Repo, races []string, m generator.Map) (uuid.UUID, error) 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 - gg := *g - - if err := r.SaveTurn(0, gg); err != nil { - return uuid.Nil, fmt.Errorf("save_turn: %s", err) - } - - // if err := r.SaveState(gg); err != nil { - // return uuid.Nil, fmt.Errorf("save_state: %s", err) - // } - - // TODO: save reports - // for i := range g.Race { - - // } - // TODO: save battles - - return g.ID, nil + return g, nil } func newPlanet(num uint, name string, owner uuid.UUID, x, y, size, pop, ind, res float64, prod game.ProductionType) game.Planet { diff --git a/pkg/game/generator_test.go b/pkg/game/generator_test.go index c16bada..87730e8 100644 --- a/pkg/game/generator_test.go +++ b/pkg/game/generator_test.go @@ -2,8 +2,10 @@ package game import ( "fmt" + "path/filepath" "testing" + "github.com/google/uuid" "github.com/iliadenisov/galaxy/pkg/repo" "github.com/iliadenisov/galaxy/pkg/util" "github.com/stretchr/testify/assert" @@ -24,6 +26,32 @@ func TestNewGame(t *testing.T) { assert.NoError(t, r.Lock()) gameID, err := newGame(r, races) assert.NoError(t, err) + + assert.FileExists(t, filepath.Join(root, "state.json")) + assert.FileExists(t, filepath.Join(root, "000/state.json")) + + g, err := r.LoadState() + assert.NoError(t, err) + assert.Equal(t, gameID, g.ID) + assert.Equal(t, uint(0), g.Age) + assert.Equal(t, players, len(g.Race)) + + for r := range g.Race { + assert.NotEqual(t, uuid.Nil, g.Race[r].ID) + assert.Equal(t, players-1, len(g.Race[r].Relations)) + for i := range g.Race[r].Relations { + assert.NotEqual(t, uuid.Nil, g.Race[r].Relations[i].RaceID) + if g.Race[r].Relations[i].RaceID == g.Race[r].ID { + assert.Fail(t, "race relation with itself") + } + } + } + + numShuffled := false + for i := range g.Map.Planet { + numShuffled = numShuffled || g.Map.Planet[i].Number != uint(i) + } + assert.True(t, numShuffled) + assert.NoError(t, r.Release()) - _ = gameID } diff --git a/pkg/model/command/command.go b/pkg/model/command/command.go new file mode 100644 index 0000000..820a173 --- /dev/null +++ b/pkg/model/command/command.go @@ -0,0 +1,15 @@ +package command + +type Command struct { + FromRace string +} + +type CommandAlly struct { + Command + ToRace string +} + +type CommandWar struct { + Command + ToRace string +} diff --git a/pkg/model/game/game.go b/pkg/model/game/game.go index 01d388e..51c0b35 100644 --- a/pkg/model/game/game.go +++ b/pkg/model/game/game.go @@ -4,6 +4,7 @@ import ( "encoding/json" "github.com/google/uuid" + e "github.com/iliadenisov/galaxy/pkg/error" ) type Game struct { @@ -24,10 +25,48 @@ func (g Game) Votes(raceID uuid.UUID) float64 { return pop / 1000. } +func (g Game) HostRaceID(name string) (uuid.UUID, error) { + if v, ok := g.raceID(name); ok { + return v, nil + } + return uuid.Nil, e.NewHostRaceUnknownError(name) +} + +func (g Game) OpponentRaceID(name string) (uuid.UUID, error) { + if v, ok := g.raceID(name); ok { + return v, nil + } + return uuid.Nil, e.NewOpponentRaceUnknownError(name) +} + +func (g Game) raceID(raceName string) (uuid.UUID, bool) { + for i := range g.Race { + if g.Race[i].Name == raceName { + return g.Race[i].ID, true + } + } + return uuid.Nil, false +} + +func (g Game) UpdateRelation(hostID, opponentID uuid.UUID, rel Relation) error { + for r := range g.Race { + if g.Race[r].ID == hostID { + for o := range g.Race[r].Relations { + if g.Race[r].Relations[o].RaceID == opponentID { + g.Race[r].Relations[o].Relation = rel + return nil + } + } + return e.NewGameStateError("UpdateRelation: opponent not found") + } + } + return e.NewGameStateError("UpdateRelation: host %v not found", hostID) +} + func (g Game) MarshalBinary() (data []byte, err error) { return json.Marshal(&g) } -func (g Game) UnmarshalBinary(data []byte) error { - return json.Unmarshal(data, &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 f2df43b..dec6340 100644 --- a/pkg/repo/fs/fs.go +++ b/pkg/repo/fs/fs.go @@ -165,8 +165,8 @@ func (f *fs) Read(path string, v encoding.BinaryUnmarshaler) error { return errors.New("can't unmarshal to a nil object") } - if f.lock != nil { - return errors.New("lock must be released before read") + if f.lock == nil { + return errors.New("lock must be acquired before read") } targetFilePath := filepath.Join(f.root, path) diff --git a/pkg/repo/fs/fs_test.go b/pkg/repo/fs/fs_test.go index 41258f0..152bd03 100644 --- a/pkg/repo/fs/fs_test.go +++ b/pkg/repo/fs/fs_test.go @@ -3,7 +3,6 @@ package fs import ( "os" "path/filepath" - "strings" "testing" "github.com/iliadenisov/galaxy/pkg/util" @@ -55,8 +54,10 @@ func TestExist(t *testing.T) { func TestWrite(t *testing.T) { root, cleanup := util.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) @@ -80,31 +81,31 @@ func TestWrite(t *testing.T) { sd := &sampleData{[]byte{0, 1, 2, 3}} err = fs.Write(tc.path, sd) if tc.err == "" { - if err != nil { - assert.Fail(t, "not expecting an error", "write to file %s: %s", tc.path, err) - } else { - assert.FileExists(t, filepath.Join(root, tc.path), "the written file should exist") - } + assert.NoError(t, err) + assert.FileExists(t, filepath.Join(root, tc.path), "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()) - } + assert.ErrorContains(t, err, tc.err) } }) } - err = unlock() - assert.NoError(t, err, "unlocking existing lock") + assert.NoError(t, unlock(), "unlocking existing lock") } func TestRead(t *testing.T) { root, cleanup := util.CreateWorkDir(t) defer cleanup() + + sd := new(sampleData) + fs, err := NewFileStorage(root) assert.NoError(t, err, "create file storage: %s", err) + assert.EqualError(t, fs.Read("some.file", sd), "lock must be acquired before read") + + 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) @@ -117,46 +118,26 @@ func TestRead(t *testing.T) { 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) - } - }() - } - sd := new(sampleData) err = fs.Read(tc.path, sd) if tc.err == "" { - if err != nil { - assert.Fail(t, "read: not expecting an error, got: "+err.Error()) - } else { - assert.FileExists(t, filepath.Join(root, tc.path), "the written file should exist") - } + assert.NoError(t, err) + assert.FileExists(t, filepath.Join(root, tc.path), "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()) - } + assert.ErrorContains(t, err, tc.err) } }) } + assert.NoError(t, unlock(), "unlocking existing lock") } func TestWriteErrorWithoutLock(t *testing.T) { @@ -167,7 +148,7 @@ func TestWriteErrorWithoutLock(t *testing.T) { sd := &sampleData{[]byte{0, 1, 2, 3}} err = fs.Write("some/path", sd) 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") + assert.EqualError(t, err, "lock must be acquired before write") } func TestNewFileStorageErrorNotExists(t *testing.T) { diff --git a/pkg/repo/repo.go b/pkg/repo/repo.go index 3d5b418..3824967 100644 --- a/pkg/repo/repo.go +++ b/pkg/repo/repo.go @@ -4,19 +4,16 @@ import ( "encoding" "errors" + e "github.com/iliadenisov/galaxy/pkg/error" "github.com/iliadenisov/galaxy/pkg/repo/fs" ) -type StorageError error - func NewStorageError(err error) error { - return StorageError(err) + return e.NewRepoError(err) } -type StateError error - func NewStateError(msg string) error { - return StateError(errors.New(msg)) + return e.NewGameStateError(msg) } type Storage interface {