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 (
"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 {
+29 -1
View File
@@ -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
}
+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"
"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)
}
+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")
}
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)
+19 -38
View File
@@ -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) {
+3 -6
View File
@@ -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 {