Ally/War commands

This commit is contained in:
Ilia Denisov
2025-09-28 22:43:27 +03:00
parent 6510676237
commit 128d6862a7
14 changed files with 315 additions and 74 deletions
+76
View File
@@ -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)
}
}
+27
View File
@@ -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)
})
}
}
+9
View File
@@ -0,0 +1,9 @@
package error
func NewHostRaceUnknownError(arg ...any) error {
return newGenericError(ErrInputUnknownHostRace, arg...)
}
func NewOpponentRaceUnknownError(arg ...any) error {
return newGenericError(ErrInputUnknownOpponentRace, arg...)
}
+5
View File
@@ -0,0 +1,5 @@
package error
func NewRepoError(arg ...any) error {
return newGenericError(ErrStorageFailure, arg...)
}
+5
View File
@@ -0,0 +1,5 @@
package error
func NewGameStateError(arg ...any) error {
return newGenericError(ErrGameStateInvalid, arg...)
}
+32
View File
@@ -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)
}
+26
View File
@@ -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
}
+26 -25
View File
@@ -3,6 +3,7 @@ package game
import ( import (
"fmt" "fmt"
"math/rand/v2" "math/rand/v2"
"slices"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/iliadenisov/galaxy/pkg/generator" "github.com/iliadenisov/galaxy/pkg/generator"
@@ -16,33 +17,45 @@ func newGame(r Repo, races []string) (uuid.UUID, error) {
if err != nil { if err != nil {
return uuid.Nil, fmt.Errorf("generate map: %s", err) 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) { 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() gameID, err := uuid.NewRandom()
if err != nil { 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{ g := &game.Game{
ID: gameID, ID: gameID,
Race: make([]game.Race, len(races)), Race: make([]game.Race, len(races)),
} }
gameMap := &game.Map{ gameMap := &game.Map{
Width: m.Width, Width: m.Width,
Height: m.Height, Height: m.Height,
Planet: make([]game.Planet, 0), Planet: make([]game.Planet, 0),
} }
var planetCount uint = 0 var planetCount uint = 0
relations := make([]game.RaceRelation, len(races))
for i := range races { for i := range races {
raceID, err := uuid.NewRandom() raceID, err := uuid.NewRandom()
if err != nil { 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{ g.Race[i] = game.Race{
ID: raceID, ID: raceID,
Name: races[i], Name: races[i],
@@ -51,7 +64,6 @@ func NewGameFromMap(r Repo, races []string, m generator.Map) (uuid.UUID, error)
Weapons: 1, Weapons: 1,
Shields: 1, Shields: 1,
Cargo: 1, Cargo: 1,
// TODO: fill Relation
} }
gameMap.Planet = append(gameMap.Planet, newPlanet( gameMap.Planet = append(gameMap.Planet, newPlanet(
planetCount, planetCount,
@@ -82,6 +94,12 @@ func NewGameFromMap(r Repo, races []string, m generator.Map) (uuid.UUID, error)
planetCount++ 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 { for i := range m.FreePlanets {
gameMap.Planet = append(gameMap.Planet, newPlanet( gameMap.Planet = append(gameMap.Planet, newPlanet(
planetCount, planetCount,
@@ -98,30 +116,13 @@ func NewGameFromMap(r Repo, races []string, m generator.Map) (uuid.UUID, error)
planetCount++ planetCount++
} }
// TODO: check code below actually works
rand.Shuffle(len(gameMap.Planet), func(i, j int) { 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 gameMap.Planet[i].Number, gameMap.Planet[j].Number = gameMap.Planet[j].Number, gameMap.Planet[i].Number
}) })
g.Map = *gameMap g.Map = *gameMap
gg := *g return g, nil
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
} }
func newPlanet(num uint, name string, owner uuid.UUID, x, y, size, pop, ind, res float64, prod game.ProductionType) game.Planet { func newPlanet(num uint, name string, owner uuid.UUID, x, y, size, pop, ind, res float64, prod game.ProductionType) game.Planet {
+30 -2
View File
@@ -2,8 +2,10 @@ package game
import ( import (
"fmt" "fmt"
"path/filepath"
"testing" "testing"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/pkg/repo" "github.com/iliadenisov/galaxy/pkg/repo"
"github.com/iliadenisov/galaxy/pkg/util" "github.com/iliadenisov/galaxy/pkg/util"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -24,6 +26,32 @@ func TestNewGame(t *testing.T) {
assert.NoError(t, r.Lock()) assert.NoError(t, r.Lock())
gameID, err := newGame(r, races) gameID, err := newGame(r, races)
assert.NoError(t, err) assert.NoError(t, err)
assert.NoError(t, r.Release())
_ = gameID 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())
} }
+15
View File
@@ -0,0 +1,15 @@
package command
type Command struct {
FromRace string
}
type CommandAlly struct {
Command
ToRace string
}
type CommandWar struct {
Command
ToRace string
}
+41 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"github.com/google/uuid" "github.com/google/uuid"
e "github.com/iliadenisov/galaxy/pkg/error"
) )
type Game struct { type Game struct {
@@ -24,10 +25,48 @@ func (g Game) Votes(raceID uuid.UUID) float64 {
return pop / 1000. 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) { func (g Game) MarshalBinary() (data []byte, err error) {
return json.Marshal(&g) return json.Marshal(&g)
} }
func (g Game) UnmarshalBinary(data []byte) error { func (g *Game) UnmarshalBinary(data []byte) error {
return json.Unmarshal(data, &g) return json.Unmarshal(data, g)
} }
+2 -2
View File
@@ -165,8 +165,8 @@ func (f *fs) Read(path string, v encoding.BinaryUnmarshaler) error {
return errors.New("can't unmarshal to a nil object") return errors.New("can't unmarshal to a nil object")
} }
if f.lock != nil { if f.lock == nil {
return errors.New("lock must be released before read") return errors.New("lock must be acquired before read")
} }
targetFilePath := filepath.Join(f.root, path) targetFilePath := filepath.Join(f.root, path)
+17 -36
View File
@@ -3,7 +3,6 @@ package fs
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/iliadenisov/galaxy/pkg/util" "github.com/iliadenisov/galaxy/pkg/util"
@@ -55,8 +54,10 @@ func TestExist(t *testing.T) {
func TestWrite(t *testing.T) { func TestWrite(t *testing.T) {
root, cleanup := util.CreateWorkDir(t) root, cleanup := util.CreateWorkDir(t)
defer cleanup() defer cleanup()
fs, err := NewFileStorage(root) fs, err := NewFileStorage(root)
assert.NoError(t, err, "create file storage: %s", err) assert.NoError(t, err, "create file storage: %s", err)
unlock, err := fs.Lock() unlock, err := fs.Lock()
assert.NoError(t, err, "acquire lock: %s", err) assert.NoError(t, err, "acquire lock: %s", err)
@@ -80,31 +81,31 @@ func TestWrite(t *testing.T) {
sd := &sampleData{[]byte{0, 1, 2, 3}} sd := &sampleData{[]byte{0, 1, 2, 3}}
err = fs.Write(tc.path, sd) err = fs.Write(tc.path, sd)
if tc.err == "" { if tc.err == "" {
if err != nil { assert.NoError(t, err)
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.FileExists(t, filepath.Join(root, tc.path), "the written file should exist")
}
} else if tc.err != "" { } else if tc.err != "" {
if err == nil { assert.ErrorContains(t, err, tc.err)
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, unlock(), "unlocking existing lock")
assert.NoError(t, err, "unlocking existing lock")
} }
func TestRead(t *testing.T) { func TestRead(t *testing.T) {
root, cleanup := util.CreateWorkDir(t) root, cleanup := util.CreateWorkDir(t)
defer cleanup() defer cleanup()
sd := new(sampleData)
fs, err := NewFileStorage(root) fs, err := NewFileStorage(root)
assert.NoError(t, err, "create file storage: %s", err) 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" dirName := "some-dir"
if err := os.Mkdir(filepath.Join(root, dirName), os.ModePerm); err != nil { if err := os.Mkdir(filepath.Join(root, dirName), os.ModePerm); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -117,46 +118,26 @@ func TestRead(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
path string path string
lock bool
err string err string
}{ }{
{path: fileName}, {path: fileName},
{path: "/" + fileName}, {path: "/" + fileName},
{path: fileName, lock: true, err: "lock must be released"},
{path: lockFile, err: "read from the lock file"}, {path: lockFile, err: "read from the lock file"},
{path: "dir/subdir/file-3.ext", err: "no such file"}, {path: "dir/subdir/file-3.ext", err: "no such file"},
{path: lockFile, err: "read from the lock file"}, {path: lockFile, err: "read from the lock file"},
{path: dirName, err: "is a directory"}, {path: dirName, err: "is a directory"},
} { } {
t.Run(tc.path, func(t *testing.T) { 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) err = fs.Read(tc.path, sd)
if tc.err == "" { if tc.err == "" {
if err != nil { assert.NoError(t, err)
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.FileExists(t, filepath.Join(root, tc.path), "the written file should exist")
}
} else if tc.err != "" { } else if tc.err != "" {
if err == nil { assert.ErrorContains(t, err, tc.err)
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.NoError(t, unlock(), "unlocking existing lock")
} }
func TestWriteErrorWithoutLock(t *testing.T) { func TestWriteErrorWithoutLock(t *testing.T) {
@@ -167,7 +148,7 @@ func TestWriteErrorWithoutLock(t *testing.T) {
sd := &sampleData{[]byte{0, 1, 2, 3}} sd := &sampleData{[]byte{0, 1, 2, 3}}
err = fs.Write("some/path", sd) err = fs.Write("some/path", sd)
assert.Error(t, err, "should return error when no lock acquired") 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) { func TestNewFileStorageErrorNotExists(t *testing.T) {
+3 -6
View File
@@ -4,19 +4,16 @@ import (
"encoding" "encoding"
"errors" "errors"
e "github.com/iliadenisov/galaxy/pkg/error"
"github.com/iliadenisov/galaxy/pkg/repo/fs" "github.com/iliadenisov/galaxy/pkg/repo/fs"
) )
type StorageError error
func NewStorageError(err error) error { func NewStorageError(err error) error {
return StorageError(err) return e.NewRepoError(err)
} }
type StateError error
func NewStateError(msg string) error { func NewStateError(msg string) error {
return StateError(errors.New(msg)) return e.NewGameStateError(msg)
} }
type Storage interface { type Storage interface {