chore: refactor structure
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
░░██████░░░░
|
||||
██░░░░░░██░░
|
||||
██░░░░░░██░░
|
||||
██░░░░░░██░░
|
||||
░░██████░░░░
|
||||
░░░░░░░░░░░░
|
||||
@@ -0,0 +1,6 @@
|
||||
████░░░░░░██
|
||||
██████░░████
|
||||
██████░░████
|
||||
██████░░████
|
||||
████░░░░░░██
|
||||
░░░░░░░░░░░░
|
||||
@@ -0,0 +1,12 @@
|
||||
░░░░░░██████████░░░░░░░░
|
||||
░░░░██████████████░░░░░░
|
||||
░░██████████████████░░░░
|
||||
██████████████████████░░
|
||||
██████████████████████░░
|
||||
██████████████████████░░
|
||||
██████████████████████░░
|
||||
██████████████████████░░
|
||||
░░██████████████████░░░░
|
||||
░░░░██████████████░░░░░░
|
||||
░░░░░░██████████░░░░░░░░
|
||||
░░░░░░░░░░░░░░░░░░░░░░░░
|
||||
@@ -0,0 +1,12 @@
|
||||
██░░░░░░░░░░░░░░░░░░██░░
|
||||
██░░░░░░░░░░░░░░░░░░██░░
|
||||
██░░░░░░░░░░░░░░░░░░██░░
|
||||
░░██░░░░░░░░░░░░░░██░░░░
|
||||
░░░░██░░░░░░░░░░██░░░░░░
|
||||
░░░░░░██████████░░░░░░░░
|
||||
░░░░░░░░░░░░░░░░░░░░░░░░
|
||||
░░░░░░██████████░░░░░░░░
|
||||
░░░░██░░░░░░░░░░██░░░░░░
|
||||
░░██░░░░░░░░░░░░░░██░░░░
|
||||
██░░░░░░░░░░░░░░░░░░██░░
|
||||
██░░░░░░░░░░░░░░░░░░██░░
|
||||
@@ -0,0 +1,15 @@
|
||||
░░░░░░░░░░██████████░░░░░░░░░░
|
||||
░░░░░░████░░░░░░░░░░████░░░░░░
|
||||
░░░░██░░░░░░░░░░░░░░░░░░██░░░░
|
||||
░░██░░░░░░░░░░░░░░░░░░░░░░██░░
|
||||
░░██░░░░░░░░░░░░░░░░░░░░░░██░░
|
||||
██░░░░░░░░░░░░░░░░░░░░░░░░░░██
|
||||
██░░░░░░░░░░░░░░░░░░░░░░░░░░██
|
||||
██░░░░░░░░░░░░░░░░░░░░░░░░░░██
|
||||
██░░░░░░░░░░░░░░░░░░░░░░░░░░██
|
||||
██░░░░░░░░░░░░░░░░░░░░░░░░░░██
|
||||
░░██░░░░░░░░░░░░░░░░░░░░░░██░░
|
||||
░░██░░░░░░░░░░░░░░░░░░░░░░██░░
|
||||
░░░░██░░░░░░░░░░░░░░░░░░██░░░░
|
||||
░░░░░░████░░░░░░░░░░████░░░░░░
|
||||
░░░░░░░░░░██████████░░░░░░░░░░
|
||||
@@ -0,0 +1,173 @@
|
||||
package bitmap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/bits"
|
||||
)
|
||||
|
||||
const intSize = 32
|
||||
|
||||
type Bitmap struct {
|
||||
width uint32
|
||||
height uint32
|
||||
bitVector []uint32
|
||||
}
|
||||
|
||||
func NewBitmap(width uint32, height uint32) Bitmap {
|
||||
return Bitmap{width: width, height: height, bitVector: make([]uint32, int(math.Ceil(float64(width*height)/intSize)))}
|
||||
}
|
||||
|
||||
func (p Bitmap) Set(x, y int) {
|
||||
boundX := (p.width + uint32(x)) % p.width
|
||||
boundY := (p.height + uint32(y)) % p.height
|
||||
p.set(boundX + boundY*p.width)
|
||||
}
|
||||
|
||||
func (p Bitmap) set(number uint32) {
|
||||
p.bitVector[number/intSize] |= (0b1 << (number % intSize))
|
||||
}
|
||||
|
||||
func (p Bitmap) IsSet(x, y int) bool {
|
||||
return p.isSet(uint32(x) + uint32(y)*p.width)
|
||||
}
|
||||
|
||||
func (p Bitmap) isSet(number uint32) bool {
|
||||
return p.bitVector[number/intSize]&(0b1<<(number%intSize)) > 0
|
||||
}
|
||||
|
||||
func (p Bitmap) FreeCount() (result int) {
|
||||
result = int(p.width) * int(p.height)
|
||||
for i := range p.bitVector {
|
||||
result -= bits.OnesCount32(p.bitVector[i])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (p Bitmap) GetFreeN(number int) (int, int, error) {
|
||||
if p.FreeCount() == 0 {
|
||||
return 0, 0, errors.New("no free pixels left")
|
||||
}
|
||||
fc := 0
|
||||
n := 0
|
||||
for ; n < int(p.width)*int(p.height); n++ {
|
||||
if p.isSet(uint32(n)) {
|
||||
continue
|
||||
}
|
||||
if fc == number {
|
||||
y := n / int(p.height)
|
||||
x := n - int(p.height)*y
|
||||
return x, y, nil
|
||||
}
|
||||
fc++
|
||||
}
|
||||
return 0, 0, fmt.Errorf("get free pixel: no such number=%d, max=%d", number, n)
|
||||
}
|
||||
|
||||
func (p Bitmap) SetFreeN(number int) error {
|
||||
if p.FreeCount() == 0 {
|
||||
return errors.New("no free pixels left")
|
||||
}
|
||||
fc := 0
|
||||
n := 0
|
||||
for ; n < int(p.width)*int(p.height); n++ {
|
||||
if p.isSet(uint32(n)) {
|
||||
continue
|
||||
}
|
||||
if fc == number {
|
||||
p.set(uint32(n))
|
||||
return nil
|
||||
}
|
||||
fc++
|
||||
}
|
||||
return fmt.Errorf("set free pixel: no such number=%d, max=%d", number, n)
|
||||
}
|
||||
|
||||
func (p Bitmap) Circle(x, y int, r float64, fill bool) {
|
||||
plotX := 0
|
||||
plotY := int(math.Ceil(r))
|
||||
delta := 3 - 2*plotY
|
||||
lastY := plotY
|
||||
for plotX <= plotY {
|
||||
p.octant(x, y, plotX, plotY)
|
||||
if fill && plotY < lastY {
|
||||
for lineX := 0; lineX < plotX; lineX++ {
|
||||
p.octant(x, y, lineX, plotY)
|
||||
}
|
||||
lastY = plotY
|
||||
}
|
||||
if delta < 0 {
|
||||
delta += 4*plotX + 6
|
||||
} else {
|
||||
delta += 4*(plotX-plotY) + 10
|
||||
plotY -= 1
|
||||
}
|
||||
plotX += 1
|
||||
}
|
||||
if !fill {
|
||||
return
|
||||
}
|
||||
for fillX := 0; fillX < plotX; fillX++ {
|
||||
for fillY := 0; fillY <= fillX; fillY++ {
|
||||
p.octant(x, y, fillX, fillY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p Bitmap) circleAdjacent(x, y int, r float64) {
|
||||
plotX := 0
|
||||
plotY := int(math.Ceil(r))
|
||||
delta := 1 - 2*plotY
|
||||
err := 0
|
||||
for plotX <= plotY {
|
||||
p.octant(x, y, plotX, plotY)
|
||||
err = 2*(delta+plotY) - 1
|
||||
if delta < 0 && err <= 0 {
|
||||
plotX += 1
|
||||
delta += 2*plotX + 1
|
||||
continue
|
||||
}
|
||||
if delta > 0 && err > 0 {
|
||||
plotY -= 1
|
||||
delta -= 2*plotY + 1
|
||||
continue
|
||||
}
|
||||
plotX += 1
|
||||
plotY -= 1
|
||||
delta += 2 * (plotX - plotY)
|
||||
}
|
||||
}
|
||||
|
||||
func (p Bitmap) octant(x, y int, plotX, plotY int) {
|
||||
p.Set(x+plotX, y+plotY)
|
||||
p.Set(x+plotX, y-plotY)
|
||||
p.Set(x-plotX, y+plotY)
|
||||
p.Set(x-plotX, y-plotY)
|
||||
p.Set(x+plotY, y+plotX)
|
||||
p.Set(x+plotY, y-plotX)
|
||||
p.Set(x-plotY, y+plotX)
|
||||
p.Set(x-plotY, y-plotX)
|
||||
}
|
||||
|
||||
func (p Bitmap) Clear() {
|
||||
for i := range p.bitVector {
|
||||
p.bitVector[i] &= 0
|
||||
}
|
||||
}
|
||||
|
||||
func (p Bitmap) String() string {
|
||||
px := map[bool]string{true: "██", false: "░░"}
|
||||
var result string
|
||||
cnt := 0
|
||||
for i := 0; i < len(p.bitVector); i++ {
|
||||
for bit := 0; bit < intSize && cnt < int(p.width*p.height); bit++ {
|
||||
result += px[p.bitVector[i]&(0b1<<bit) > 0]
|
||||
cnt++
|
||||
if cnt%int(p.width) == 0 {
|
||||
result += "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package bitmap_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/bitmap"
|
||||
)
|
||||
|
||||
func TestBitVectorSize(t *testing.T) {
|
||||
type testCase struct {
|
||||
width, height uint32
|
||||
expectSize int
|
||||
}
|
||||
for _, tc := range []testCase{
|
||||
{
|
||||
width: 10,
|
||||
height: 10,
|
||||
expectSize: 4,
|
||||
},
|
||||
{
|
||||
width: 1,
|
||||
height: 1,
|
||||
expectSize: 1,
|
||||
},
|
||||
{
|
||||
width: 32,
|
||||
height: 32,
|
||||
expectSize: 32,
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("w=%d h=%d s=%d", tc.width, tc.height, tc.expectSize), func(t *testing.T) {
|
||||
bm := bitmap.NewBitmap(tc.width, tc.height)
|
||||
l := len(bitmap.Value(bm))
|
||||
if tc.expectSize != l {
|
||||
t.Errorf("expected bitmap size: %d, got: %d", tc.expectSize, l)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPixel(t *testing.T) {
|
||||
type coord struct {
|
||||
x, y int
|
||||
}
|
||||
type testCase struct {
|
||||
width, height uint32
|
||||
pixels []coord
|
||||
bits []int
|
||||
}
|
||||
asMap := func(bits []int) map[int]bool {
|
||||
result := make(map[int]bool)
|
||||
for i := range bits {
|
||||
if _, ok := result[bits[i]]; ok {
|
||||
t.Fatalf("source bits duplicate at idx=%d", i)
|
||||
} else {
|
||||
result[bits[i]] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
asUint32 := func(v bool) uint32 { return map[bool]uint32{true: 1, false: 0}[v] }
|
||||
for i, tc := range []testCase{
|
||||
{
|
||||
width: 5,
|
||||
height: 5,
|
||||
pixels: []coord{{0, 0}},
|
||||
bits: []int{0},
|
||||
},
|
||||
{
|
||||
width: 5,
|
||||
height: 5,
|
||||
pixels: []coord{{1, 0}},
|
||||
bits: []int{1},
|
||||
},
|
||||
{
|
||||
width: 5,
|
||||
height: 5,
|
||||
pixels: []coord{{2, 0}},
|
||||
bits: []int{2},
|
||||
},
|
||||
{
|
||||
width: 5,
|
||||
height: 5,
|
||||
pixels: []coord{{0, 1}},
|
||||
bits: []int{5},
|
||||
},
|
||||
{
|
||||
width: 5,
|
||||
height: 5,
|
||||
pixels: []coord{{4, 4}},
|
||||
bits: []int{24},
|
||||
},
|
||||
{
|
||||
width: 8,
|
||||
height: 8,
|
||||
pixels: []coord{{7, 7}},
|
||||
bits: []int{63},
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("tc#%d", i), func(t *testing.T) {
|
||||
bm := bitmap.NewBitmap(tc.width, tc.height)
|
||||
for _, c := range tc.pixels {
|
||||
if bm.IsSet(c.x, c.y) {
|
||||
t.Errorf("expected pixel to be clear at x=%d y=%d", c.x, c.y)
|
||||
}
|
||||
bm.Set(c.x, c.y)
|
||||
if !bm.IsSet(c.x, c.y) {
|
||||
t.Errorf("expected pixel to be set at x=%d y=%d", c.x, c.y)
|
||||
}
|
||||
}
|
||||
bitVector := bitmap.Value(bm)
|
||||
bitNum := 0
|
||||
expected := asMap(tc.bits)
|
||||
for bi := range bitVector {
|
||||
for ; bitNum < (bi+1)*32; bitNum++ {
|
||||
if (bitVector[bi]>>(bitNum%32))&1 != asUint32(expected[bitNum]) {
|
||||
t.Errorf("expected: bit #%d to be %t, got %v", bitNum, expected[bitNum], uint32(1<<(bitNum%32)))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreeCount(t *testing.T) {
|
||||
bm := bitmap.NewBitmap(10, 10)
|
||||
type testCase struct {
|
||||
x, y, expect int
|
||||
}
|
||||
for _, tc := range []testCase{
|
||||
{0, 0, 99},
|
||||
{5, 5, 98},
|
||||
{9, 9, 97},
|
||||
{10, 10, 97},
|
||||
{15, 6, 96},
|
||||
{9, 9, 96},
|
||||
{3, 8, 95},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("x=%d,y=%d", tc.x, tc.y), func(t *testing.T) {
|
||||
bm.Set(tc.x, tc.y)
|
||||
count := bm.FreeCount()
|
||||
if tc.expect != count {
|
||||
t.Errorf("expected: %d, actual %d", tc.expect, count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetFreeN(t *testing.T) {
|
||||
bm := bitmap.NewBitmap(5, 5)
|
||||
type testCase struct {
|
||||
x, y, number int
|
||||
}
|
||||
for i, tc := range []testCase{
|
||||
{0, 0, 0},
|
||||
{1, 0, 0},
|
||||
{4, 0, 2},
|
||||
{2, 2, 9},
|
||||
{1, 1, 3},
|
||||
{3, 1, 4},
|
||||
{3, 2, 7},
|
||||
{4, 4, 17},
|
||||
{3, 0, 1},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("tc#%d", i), func(t *testing.T) {
|
||||
if x, y, err := bm.GetFreeN(tc.number); err != nil {
|
||||
t.Errorf("get free by number=%d: %s", tc.number, err)
|
||||
} else if tc.x != x || tc.y != y {
|
||||
t.Fatalf("expected: x=%d, y=%d by number=%d, got: x=%d, y=%d", tc.x, tc.y, tc.number, x, y)
|
||||
}
|
||||
if err := bm.SetFreeN(tc.number); err != nil {
|
||||
t.Errorf("set free by number=%d: %s", tc.number, err)
|
||||
} else if !bm.IsSet(tc.x, tc.y) {
|
||||
t.Errorf("expected to be set: free_number=%d @ x=%d,y=%d bitmap:\n%s", tc.number, tc.x, tc.y, bm)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
bm = bitmap.NewBitmap(2, 2)
|
||||
bm.Set(0, 0)
|
||||
bm.Set(1, 0)
|
||||
bm.Set(0, 1)
|
||||
if err := bm.SetFreeN(1); err == nil {
|
||||
t.Errorf("expected: error when free pixel number greater than free pixels count")
|
||||
}
|
||||
if _, _, err := bm.GetFreeN(1); err == nil {
|
||||
t.Errorf("expected: error when free pixel number greater than free pixels count")
|
||||
}
|
||||
bm.Set(1, 1)
|
||||
if err := bm.SetFreeN(0); err == nil {
|
||||
t.Errorf("expected: error when no free pixels left")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClear(t *testing.T) {
|
||||
var bm = bitmap.NewBitmap(10, 10)
|
||||
for range 50 {
|
||||
bm.Set(rand.Intn(10), rand.Intn(10))
|
||||
}
|
||||
var acc uint32
|
||||
for _, holder := range bitmap.Value(bm) {
|
||||
acc |= holder
|
||||
}
|
||||
if acc == 0 {
|
||||
t.Errorf("some pixels should be set")
|
||||
}
|
||||
bm.Clear()
|
||||
acc = 0
|
||||
for _, holder := range bitmap.Value(bm) {
|
||||
acc |= holder
|
||||
}
|
||||
if acc != 0 {
|
||||
t.Errorf("all pixels should be clear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircle(t *testing.T) {
|
||||
type testCase struct {
|
||||
x, y int
|
||||
r float64
|
||||
filled bool
|
||||
}
|
||||
for i, tc := range []testCase{
|
||||
{2, 2, 2, false},
|
||||
{0, 2, 2, true},
|
||||
{5, 5, 5, true},
|
||||
{5, 0, 5, false},
|
||||
{7, 7, 6.6, false},
|
||||
} {
|
||||
exampleFile := fmt.Sprintf("assets_test/circle_case_%02d.txt", i)
|
||||
t.Run(exampleFile, func(t *testing.T) {
|
||||
size := uint32(tc.r*2) + 2
|
||||
bm := bitmap.NewBitmap(size, size)
|
||||
bm.Circle(tc.x, tc.y, tc.r, tc.filled)
|
||||
b, err := os.ReadFile(exampleFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expect := strings.TrimSpace(string(b))
|
||||
if expect != strings.TrimSpace(bm.String()) {
|
||||
t.Errorf("expect:\n%s\ngot:\n%s\n", expect, bm)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package bitmap
|
||||
|
||||
import "slices"
|
||||
|
||||
func (p Bitmap) value() []uint32 {
|
||||
return slices.Clone(p.bitVector)
|
||||
}
|
||||
|
||||
var Value = (Bitmap).value
|
||||
@@ -0,0 +1,71 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/model/game"
|
||||
"github.com/iliadenisov/galaxy/internal/repo"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
// SaveState stores current game state updated between turns
|
||||
SaveState(game.Game) error
|
||||
|
||||
// LoadState retrieves game current state
|
||||
LoadState() (game.Game, error)
|
||||
}
|
||||
|
||||
type Ctrl struct {
|
||||
param Param
|
||||
Repo Repo
|
||||
}
|
||||
|
||||
type Param struct {
|
||||
StoragePath string
|
||||
}
|
||||
|
||||
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 (c *Ctrl) ExecuteInit(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()
|
||||
}
|
||||
|
||||
func (c *Ctrl) Execute(consumer func(Repo, game.Game)) error {
|
||||
if err := c.Repo.Lock(); err != nil {
|
||||
return fmt.Errorf("execute: lock failed: %s", err)
|
||||
}
|
||||
g, err := c.Repo.LoadState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
consumer(c.Repo, g)
|
||||
return c.Repo.Release()
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/iliadenisov/galaxy/internal/generator"
|
||||
"github.com/iliadenisov/galaxy/internal/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 newGameOnMap(r, races, m)
|
||||
}
|
||||
|
||||
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 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 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 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],
|
||||
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(uuid.Nil),
|
||||
))
|
||||
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(uuid.Nil),
|
||||
))
|
||||
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,
|
||||
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(uuid.Nil),
|
||||
))
|
||||
planetCount++
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
return g, 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{
|
||||
Owner: owner,
|
||||
PlanetReport: game.PlanetReport{
|
||||
UninhabitedPlanet: game.UninhabitedPlanet{
|
||||
UnidentifiedPlanet: game.UnidentifiedPlanet{
|
||||
X: x,
|
||||
Y: y,
|
||||
Number: num,
|
||||
},
|
||||
Size: size,
|
||||
Name: name,
|
||||
Resources: res,
|
||||
},
|
||||
Population: pop,
|
||||
Industry: ind,
|
||||
Production: prod,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package controller_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/iliadenisov/galaxy/internal/controller"
|
||||
"github.com/iliadenisov/galaxy/internal/model/game"
|
||||
"github.com/iliadenisov/galaxy/internal/repo"
|
||||
"github.com/iliadenisov/galaxy/internal/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 := controller.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")
|
||||
}
|
||||
assert.Equal(t, game.RelationWar, g.Race[r].Relations[i].Relation)
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package error
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
ErrDummy int = -1
|
||||
|
||||
ErrStorageFailure int = 1000
|
||||
ErrGameStateInvalid int = 2000
|
||||
|
||||
ErrDeleteShipTypeExistingGroup = 5000
|
||||
ErrDeleteShipTypePlanetProduction = 5001
|
||||
ErrDeleteSciencePlanetProduction = 5002
|
||||
ErrMergeShipTypeNotEqual = 5003
|
||||
ErrJoinFleetGroupNumberNotEnough = 5004
|
||||
)
|
||||
|
||||
const (
|
||||
ErrInputUnknownRace int = 3000 + iota
|
||||
ErrInputEntityTypeNameInvalid
|
||||
ErrInputEntityTypeNameDuplicate
|
||||
ErrInputEntityTypeNameEquality
|
||||
ErrInputEntityNotExists
|
||||
ErrInputEntityNotOwned
|
||||
ErrInputPlanetNumber
|
||||
ErrInputDriveValue
|
||||
ErrInputWeaponsValue
|
||||
ErrInputShieldsValue
|
||||
ErrInputCargoValue
|
||||
ErrInputShipTypeArmamentValue
|
||||
ErrInputShipTypeWeaponsAndArmamentValue
|
||||
ErrInputShipTypeZeroValues
|
||||
ErrInputScienceSumValues
|
||||
ErrInputProductionInvalid
|
||||
)
|
||||
|
||||
func GenericErrorText(code int) string {
|
||||
switch code {
|
||||
case ErrDummy:
|
||||
return "Dummy"
|
||||
case ErrStorageFailure:
|
||||
return "Storage failure"
|
||||
case ErrGameStateInvalid:
|
||||
return "Invalid game state"
|
||||
case ErrInputUnknownRace:
|
||||
return "Race name is unknown to this game"
|
||||
case ErrInputEntityTypeNameInvalid:
|
||||
return "Name has invalid length or symbols"
|
||||
case ErrInputEntityTypeNameDuplicate:
|
||||
return "Name already exists"
|
||||
case ErrInputEntityTypeNameEquality:
|
||||
return "Names should differ"
|
||||
case ErrInputEntityNotExists:
|
||||
return "Entity does not exists"
|
||||
case ErrInputEntityNotOwned:
|
||||
return "Entity is not owned"
|
||||
case ErrInputPlanetNumber:
|
||||
return "Invalid Planet number"
|
||||
case ErrInputDriveValue:
|
||||
return "Invalid Drive value"
|
||||
case ErrInputWeaponsValue:
|
||||
return "Invalid Weapons value"
|
||||
case ErrInputShieldsValue:
|
||||
return "Invalid Shields value"
|
||||
case ErrInputCargoValue:
|
||||
return "Invalid Cargo value"
|
||||
case ErrInputShipTypeArmamentValue:
|
||||
return "Invalid Armament value"
|
||||
case ErrInputShipTypeWeaponsAndArmamentValue:
|
||||
return "Invalid Armament or Weapons value"
|
||||
case ErrInputShipTypeZeroValues:
|
||||
return "Ship type values cannot be all zeros"
|
||||
case ErrDeleteShipTypeExistingGroup:
|
||||
return "Ship type exists in a Group"
|
||||
case ErrDeleteShipTypePlanetProduction:
|
||||
return "Ship type in production on the Planet"
|
||||
case ErrDeleteSciencePlanetProduction:
|
||||
return "Science in production on the Planet"
|
||||
case ErrInputScienceSumValues:
|
||||
return "Science proportions sum should be equal 1"
|
||||
case ErrInputProductionInvalid:
|
||||
return "Invalid Production type"
|
||||
case ErrMergeShipTypeNotEqual:
|
||||
return "Source and target ship types are not the same"
|
||||
case ErrJoinFleetGroupNumberNotEnough:
|
||||
return "Not enough ships in the group to join a fleet"
|
||||
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 := GenericErrorText(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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package error
|
||||
|
||||
func NewRaceUnknownError(arg ...any) error {
|
||||
return newGenericError(ErrInputUnknownRace, arg...)
|
||||
}
|
||||
|
||||
func NewEntityTypeNameValidationError(arg ...any) error {
|
||||
return newGenericError(ErrInputEntityTypeNameInvalid, arg...)
|
||||
}
|
||||
|
||||
func NewEntityTypeNameDuplicateError(arg ...any) error {
|
||||
return newGenericError(ErrInputEntityTypeNameDuplicate, arg...)
|
||||
}
|
||||
|
||||
func NewEntityTypeNameEqualityError(arg ...any) error {
|
||||
return newGenericError(ErrInputEntityTypeNameEquality, arg...)
|
||||
}
|
||||
|
||||
func NewEntityNotExistsError(arg ...any) error {
|
||||
return newGenericError(ErrInputEntityNotExists, arg...)
|
||||
}
|
||||
|
||||
func NewEntityNotOwnedError(arg ...any) error {
|
||||
return newGenericError(ErrInputEntityNotOwned, arg...)
|
||||
}
|
||||
|
||||
func NewPlanetNumberError(arg ...any) error {
|
||||
return newGenericError(ErrInputPlanetNumber, arg...)
|
||||
}
|
||||
|
||||
func NewDriveValueError(arg ...any) error {
|
||||
return newGenericError(ErrInputDriveValue, arg...)
|
||||
}
|
||||
|
||||
func NewWeaponsValueError(arg ...any) error {
|
||||
return newGenericError(ErrInputWeaponsValue, arg...)
|
||||
}
|
||||
|
||||
func NewShieldsValueError(arg ...any) error {
|
||||
return newGenericError(ErrInputShieldsValue, arg...)
|
||||
}
|
||||
|
||||
func NewCargoValueError(arg ...any) error {
|
||||
return newGenericError(ErrInputCargoValue, arg...)
|
||||
}
|
||||
|
||||
func NewShipTypeArmamentValueError(arg ...any) error {
|
||||
return newGenericError(ErrInputShipTypeArmamentValue, arg...)
|
||||
}
|
||||
|
||||
func NewShipTypeArmamentAndWeaponsValueError(arg ...any) error {
|
||||
return newGenericError(ErrInputShipTypeWeaponsAndArmamentValue, arg...)
|
||||
}
|
||||
|
||||
func NewShipTypeShipTypeZeroValuesError(arg ...any) error {
|
||||
return newGenericError(ErrInputShipTypeZeroValues, arg...)
|
||||
}
|
||||
|
||||
func NewScienceSumValuesError(arg ...any) error {
|
||||
return newGenericError(ErrInputScienceSumValues, arg...)
|
||||
}
|
||||
|
||||
func NewProductionInvalidError(arg ...any) error {
|
||||
return newGenericError(ErrInputProductionInvalid, arg...)
|
||||
}
|
||||
|
||||
func NewMergeShipTypeNotEqualError(arg ...any) error {
|
||||
return newGenericError(ErrMergeShipTypeNotEqual, arg...)
|
||||
}
|
||||
|
||||
func NewJoinFleetGroupNumberNotEnoughError(arg ...any) error {
|
||||
return newGenericError(ErrJoinFleetGroupNumberNotEnough, arg...)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package error
|
||||
|
||||
func NewRepoError(arg ...any) error {
|
||||
return newGenericError(ErrStorageFailure, arg...)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package error
|
||||
|
||||
func NewGameStateError(arg ...any) error {
|
||||
return newGenericError(ErrGameStateInvalid, arg...)
|
||||
}
|
||||
|
||||
func NewDeleteShipTypeExistingGroupError(arg ...any) error {
|
||||
return newGenericError(ErrDeleteShipTypeExistingGroup, arg...)
|
||||
}
|
||||
|
||||
func NewDeleteShipTypePlanetProductionError(arg ...any) error {
|
||||
return newGenericError(ErrDeleteShipTypePlanetProduction, arg...)
|
||||
}
|
||||
|
||||
func NewDeleteSciencePlanetProductionError(arg ...any) error {
|
||||
return newGenericError(ErrDeleteSciencePlanetProduction, arg...)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"github.com/iliadenisov/galaxy/internal/controller"
|
||||
"github.com/iliadenisov/galaxy/internal/model/game"
|
||||
)
|
||||
|
||||
func JoinEqualGroups(configure func(*controller.Param), race string) (err error) {
|
||||
control(configure, func(c *controller.Ctrl) {
|
||||
c.Execute(func(r controller.Repo, g game.Game) {
|
||||
err = joinEqualGroups(r, g, race)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func joinEqualGroups(r controller.Repo, g game.Game, race string) error {
|
||||
if err := g.JoinEqualGroups(race); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.SaveState(g)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package game_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/controller"
|
||||
"github.com/iliadenisov/galaxy/internal/game"
|
||||
mg "github.com/iliadenisov/galaxy/internal/model/game"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestJoinEqualGroups(t *testing.T) {
|
||||
g(t, func(p func(*controller.Param), g func() mg.Game) {
|
||||
err := game.JoinEqualGroups(p, "race_01")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"github.com/iliadenisov/galaxy/internal/controller"
|
||||
"github.com/iliadenisov/galaxy/internal/model/game"
|
||||
)
|
||||
|
||||
func RenamePlanet(configure func(*controller.Param), race string, number int, name string) (err error) {
|
||||
control(configure, func(c *controller.Ctrl) {
|
||||
c.Execute(func(r controller.Repo, g game.Game) {
|
||||
err = renamePlanet(r, g, race, number, name)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func renamePlanet(r controller.Repo, g game.Game, race string, number int, name string) error {
|
||||
if err := g.RenamePlanet(race, number, name); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.SaveState(g)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package game_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/controller"
|
||||
e "github.com/iliadenisov/galaxy/internal/error"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/iliadenisov/galaxy/internal/game"
|
||||
mg "github.com/iliadenisov/galaxy/internal/model/game"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRenamePlanet(t *testing.T) {
|
||||
g(t, func(p func(*controller.Param), g func() mg.Game) {
|
||||
cg := g()
|
||||
var number int
|
||||
var owner uuid.UUID
|
||||
for pl := range cg.Map.Planet {
|
||||
if cg.Map.Planet[pl].Owner != uuid.Nil {
|
||||
number = int(cg.Map.Planet[pl].Number)
|
||||
owner = cg.Map.Planet[pl].Owner
|
||||
break
|
||||
}
|
||||
}
|
||||
var race string
|
||||
for r := range cg.Race {
|
||||
if cg.Race[r].ID == owner {
|
||||
race = cg.Race[r].Name
|
||||
break
|
||||
}
|
||||
}
|
||||
newName := "Some-New-Name"
|
||||
err := game.RenamePlanet(p, race, number, newName)
|
||||
assert.NoError(t, err)
|
||||
cg = g()
|
||||
pi := slices.IndexFunc(cg.Map.Planet, func(pl mg.Planet) bool { return pl.Owner == owner && pl.Number == uint(number) })
|
||||
assert.GreaterOrEqual(t, pi, 0)
|
||||
assert.Equal(t, "Some-New-Name", cg.Map.Planet[pi].Name)
|
||||
|
||||
ri := slices.IndexFunc(cg.Race, func(r mg.Race) bool { return r.Name != race })
|
||||
assert.GreaterOrEqual(t, ri, 0)
|
||||
otherRace := cg.Race[ri].Name
|
||||
|
||||
err = game.RenamePlanet(p, unknownRaceName, number, newName) // TODO: test actual rip race
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputUnknownRace))
|
||||
err = game.RenamePlanet(p, race, number, "")
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputEntityTypeNameInvalid))
|
||||
err = game.RenamePlanet(p, race, -1, newName)
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputPlanetNumber))
|
||||
err = game.RenamePlanet(p, race, 100500, newName)
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputEntityNotExists))
|
||||
err = game.RenamePlanet(p, otherRace, number, newName)
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputEntityNotOwned))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"github.com/iliadenisov/galaxy/internal/controller"
|
||||
"github.com/iliadenisov/galaxy/internal/model/game"
|
||||
)
|
||||
|
||||
func PlanetProduction(configure func(*controller.Param), race string, planetNumber int, prodType, subject string) (err error) {
|
||||
control(configure, func(c *controller.Ctrl) {
|
||||
c.Execute(func(r controller.Repo, g game.Game) {
|
||||
err = planetProduction(r, g, race, planetNumber, prodType, subject)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func planetProduction(r controller.Repo, g game.Game, race string, planetNumber int, prodType, subject string) error {
|
||||
if err := g.PlanetProduction(race, planetNumber, prodType, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.SaveState(g)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"github.com/iliadenisov/galaxy/internal/controller"
|
||||
"github.com/iliadenisov/galaxy/internal/model/game"
|
||||
)
|
||||
|
||||
func CreateScience(configure func(*controller.Param), race, typeName string, drive, weapons, shields, cargo float64) (err error) {
|
||||
control(configure, func(c *controller.Ctrl) {
|
||||
c.Execute(func(r controller.Repo, g game.Game) {
|
||||
err = createScience(r, g, race, typeName, drive, weapons, shields, cargo)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func createScience(r controller.Repo, g game.Game, race, typeName string, d, w, s, c float64) error {
|
||||
if err := g.CreateScience(race, typeName, d, w, s, c); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.SaveState(g)
|
||||
}
|
||||
|
||||
func DeleteScience(configure func(*controller.Param), race, typeName string) (err error) {
|
||||
control(configure, func(c *controller.Ctrl) {
|
||||
c.Execute(func(r controller.Repo, g game.Game) {
|
||||
err = deleteScience(r, g, race, typeName)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func deleteScience(r controller.Repo, g game.Game, race, typeName string) error {
|
||||
if err := g.DeleteScience(race, typeName); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.SaveState(g)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package game_test
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/controller"
|
||||
|
||||
e "github.com/iliadenisov/galaxy/internal/error"
|
||||
"github.com/iliadenisov/galaxy/internal/game"
|
||||
mg "github.com/iliadenisov/galaxy/internal/model/game"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateScience(t *testing.T) {
|
||||
race := "race_01"
|
||||
typeName := "First Step"
|
||||
g(t, func(p func(*controller.Param), g func() mg.Game) {
|
||||
err := game.DeleteScience(p, race, typeName)
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputEntityNotExists))
|
||||
err = game.CreateScience(p, unknownRaceName, " "+typeName+" ", 1, 0, 0, 0) // TODO: test on dead race
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputUnknownRace))
|
||||
err = game.CreateScience(p, race, " "+typeName+" ", 1, 0, 0, 0)
|
||||
assert.NoError(t, err)
|
||||
sc, err := g().Sciences(race)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, sc, 1)
|
||||
assert.Equal(t, sc[0].Name, typeName)
|
||||
assert.Equal(t, sc[0].Drive, 1.)
|
||||
assert.Equal(t, sc[0].Weapons, 0.)
|
||||
assert.Equal(t, sc[0].Shields, 0.)
|
||||
assert.Equal(t, sc[0].Cargo, 0.)
|
||||
// TODO: test delete with existing ship group
|
||||
// TODO: test delete with planet production busy with science
|
||||
err = game.DeleteScience(p, unknownRaceName, typeName) // TODO: test with actial rip race
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputUnknownRace))
|
||||
err = game.DeleteScience(p, race, typeName)
|
||||
assert.NoError(t, err)
|
||||
sc, err = g().Sciences(race)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, sc, 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateScienceValidation(t *testing.T) {
|
||||
race := "race_01"
|
||||
typeName := "First Step"
|
||||
type tc struct {
|
||||
name string
|
||||
d, w, s, c float64
|
||||
err string
|
||||
}
|
||||
table := []tc{
|
||||
// correct values
|
||||
{typeName, 1, 0, 0, 0, ""},
|
||||
{typeName, 0.5, 0.5, 0, 0, ""},
|
||||
{typeName, 0.25, 0.25, 0.25, 0.25, ""},
|
||||
{typeName, 0.33, 0.33, 0.34, 0, ""},
|
||||
{typeName, 0, 0, 0.99, 0.01, ""},
|
||||
// incorrect values...
|
||||
{"", 1, 0, 0, 0, e.GenericErrorText(e.ErrInputEntityTypeNameInvalid)},
|
||||
{" ", 1, 0, 0, 0, e.GenericErrorText(e.ErrInputEntityTypeNameInvalid)},
|
||||
{typeName, 0, 0, 0, 0, e.GenericErrorText(e.ErrInputScienceSumValues)},
|
||||
// drive
|
||||
{typeName, -1, 0, 0, 0, e.GenericErrorText(e.ErrInputDriveValue)},
|
||||
{typeName, -1, 2, 0, 0, e.GenericErrorText(e.ErrInputDriveValue)},
|
||||
// weapons
|
||||
{typeName, 0, -1, 0, 0, e.GenericErrorText(e.ErrInputWeaponsValue)},
|
||||
{typeName, 2, -1, 0, 0, e.GenericErrorText(e.ErrInputWeaponsValue)},
|
||||
// shields
|
||||
{typeName, 0, 0, -1, 0, e.GenericErrorText(e.ErrInputShieldsValue)},
|
||||
{typeName, 0.5, 0.5, -1, 0.5, e.GenericErrorText(e.ErrInputShieldsValue)},
|
||||
// cargo
|
||||
{typeName, 0, 0, 0, -1, e.GenericErrorText(e.ErrInputCargoValue)},
|
||||
{typeName, 0, 1, 1, -1, e.GenericErrorText(e.ErrInputCargoValue)},
|
||||
}
|
||||
g(t, func(p func(*controller.Param), g func() mg.Game) {
|
||||
for i, tc := range table {
|
||||
if tc.err == "" {
|
||||
err := game.CreateScience(p, race, tc.name+strconv.Itoa(i), tc.d, tc.w, tc.s, tc.c)
|
||||
assert.NoError(t, err)
|
||||
err = game.CreateScience(p, race, tc.name+strconv.Itoa(i), tc.d, tc.w, tc.s, tc.c)
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputEntityTypeNameDuplicate))
|
||||
} else {
|
||||
err := game.CreateScience(p, race, tc.name, tc.d, tc.w, tc.s, tc.c)
|
||||
assert.ErrorContains(t, err, tc.err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"github.com/iliadenisov/galaxy/internal/controller"
|
||||
"github.com/iliadenisov/galaxy/internal/model/game"
|
||||
)
|
||||
|
||||
func CreateShipType(configure func(*controller.Param), race, typeName string, drive, weapons, shields, cargo float64, armament int) (err error) {
|
||||
control(configure, func(c *controller.Ctrl) {
|
||||
c.Execute(func(r controller.Repo, g game.Game) {
|
||||
err = createShipType(r, g, race, typeName, drive, weapons, shields, cargo, armament)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func createShipType(r controller.Repo, g game.Game, race, typeName string, d, w, s, c float64, a int) error {
|
||||
if err := g.CreateShipType(race, typeName, d, w, s, c, a); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.SaveState(g)
|
||||
}
|
||||
|
||||
func MergeShipType(configure func(*controller.Param), race, source, target string) (err error) {
|
||||
control(configure, func(c *controller.Ctrl) {
|
||||
c.Execute(func(r controller.Repo, g game.Game) {
|
||||
err = mergeShipType(r, g, race, source, target)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func mergeShipType(r controller.Repo, g game.Game, race, source, target string) error {
|
||||
if err := g.MergeShipType(race, source, target); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.SaveState(g)
|
||||
}
|
||||
|
||||
func DeleteShipType(configure func(*controller.Param), race, typeName string) (err error) {
|
||||
control(configure, func(c *controller.Ctrl) {
|
||||
c.Execute(func(r controller.Repo, g game.Game) {
|
||||
err = deleteShipType(r, g, race, typeName)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func deleteShipType(r controller.Repo, g game.Game, race, typeName string) error {
|
||||
if err := g.DeleteShipType(race, typeName); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.SaveState(g)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package game_test
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/controller"
|
||||
|
||||
e "github.com/iliadenisov/galaxy/internal/error"
|
||||
"github.com/iliadenisov/galaxy/internal/game"
|
||||
mg "github.com/iliadenisov/galaxy/internal/model/game"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateShipType(t *testing.T) {
|
||||
race := "race_01"
|
||||
typeName := "Drone"
|
||||
g(t, func(p func(*controller.Param), g func() mg.Game) {
|
||||
err := game.DeleteShipType(p, race, typeName)
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputEntityNotExists))
|
||||
err = game.CreateShipType(p, unknownRaceName, " "+typeName+" ", 1, 0, 0, 0, 0) // TODO: test on dead race
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputUnknownRace))
|
||||
err = game.CreateShipType(p, race, " "+typeName+" ", 1, 0, 0, 0, 0)
|
||||
assert.NoError(t, err)
|
||||
st, err := g().ShipTypes(race)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, st, 1)
|
||||
assert.Equal(t, st[0].Name, typeName)
|
||||
assert.Equal(t, st[0].Drive, 1.)
|
||||
assert.Equal(t, st[0].Weapons, 0.)
|
||||
assert.Equal(t, st[0].Shields, 0.)
|
||||
assert.Equal(t, st[0].Cargo, 0.)
|
||||
assert.Equal(t, st[0].Armament, uint(0))
|
||||
// TODO: test with existing ship group
|
||||
err = game.DeleteShipType(p, unknownRaceName, typeName) // TODO: test on dead race
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputUnknownRace))
|
||||
err = game.DeleteShipType(p, race, typeName)
|
||||
assert.NoError(t, err)
|
||||
st, err = g().ShipTypes(race)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, st, 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateShipTypeValidation(t *testing.T) {
|
||||
race := "race_01"
|
||||
typeName := "Drone"
|
||||
type tc struct {
|
||||
name string
|
||||
d, w, s, c float64
|
||||
a int
|
||||
err string
|
||||
}
|
||||
table := []tc{
|
||||
// correct values
|
||||
{typeName, 1, 0, 0, 0, 0, ""},
|
||||
{typeName, 1.1, 0, 0, 0, 0, ""},
|
||||
{typeName, 1, 1.2, 0, 0, 1, ""},
|
||||
{typeName, 1, 1.2, 2.5, 0, 1, ""},
|
||||
{typeName, 1, 0, 2.5, 7.7, 0, ""},
|
||||
// incorrect values...
|
||||
{"", 1, 0, 0, 0, 0, e.GenericErrorText(e.ErrInputEntityTypeNameInvalid)},
|
||||
{" ", 1, 0, 0, 0, 0, e.GenericErrorText(e.ErrInputEntityTypeNameInvalid)},
|
||||
{typeName, 0, 0, 0, 0, 0, e.GenericErrorText(e.ErrInputShipTypeZeroValues)},
|
||||
// drive
|
||||
{typeName, -1, 0, 0, 0, 0, e.GenericErrorText(e.ErrInputDriveValue)},
|
||||
{typeName, 0.5, 0, 0, 0, 0, e.GenericErrorText(e.ErrInputDriveValue)},
|
||||
// weapons
|
||||
{typeName, 0, -1, 0, 0, 0, e.GenericErrorText(e.ErrInputWeaponsValue)},
|
||||
{typeName, 0, 0.5, 0, 0, 0, e.GenericErrorText(e.ErrInputWeaponsValue)},
|
||||
// shields
|
||||
{typeName, 0, 0, -1, 0, 0, e.GenericErrorText(e.ErrInputShieldsValue)},
|
||||
{typeName, 0, 0, 0.5, 0, 0, e.GenericErrorText(e.ErrInputShieldsValue)},
|
||||
// cargo
|
||||
{typeName, 0, 0, 0, -1, 0, e.GenericErrorText(e.ErrInputCargoValue)},
|
||||
{typeName, 0, 0, 0, 0.5, 0, e.GenericErrorText(e.ErrInputCargoValue)},
|
||||
// armament (and weapons)
|
||||
{typeName, 0, 0, 0, 0, -1, e.GenericErrorText(e.ErrInputShipTypeArmamentValue)},
|
||||
{typeName, 0, 1, 0, 0, 0, e.GenericErrorText(e.ErrInputShipTypeWeaponsAndArmamentValue)},
|
||||
{typeName, 0, 0, 0, 0, 1, e.GenericErrorText(e.ErrInputShipTypeWeaponsAndArmamentValue)},
|
||||
}
|
||||
g(t, func(p func(*controller.Param), g func() mg.Game) {
|
||||
for i, tc := range table {
|
||||
if tc.err == "" {
|
||||
err := game.CreateShipType(p, race, tc.name+strconv.Itoa(i), tc.d, tc.w, tc.s, tc.c, tc.a)
|
||||
assert.NoError(t, err)
|
||||
err = game.CreateShipType(p, race, tc.name+strconv.Itoa(i), tc.d, tc.w, tc.s, tc.c, tc.a)
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputEntityTypeNameDuplicate))
|
||||
} else {
|
||||
err := game.CreateShipType(p, race, tc.name, tc.d, tc.w, tc.s, tc.c, tc.a)
|
||||
assert.ErrorContains(t, err, tc.err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMergeShipType(t *testing.T) {
|
||||
race := "race_01"
|
||||
g(t, func(p func(*controller.Param), g func() mg.Game) {
|
||||
err := game.CreateShipType(p, race, "Drone", 1, 0, 0, 0, 0)
|
||||
assert.NoError(t, err)
|
||||
err = game.CreateShipType(p, race, "Spy", 1, 0, 0, 0, 0)
|
||||
assert.NoError(t, err)
|
||||
err = game.CreateShipType(p, race, "Cruiser", 15, 15, 15, 0, 1)
|
||||
assert.NoError(t, err)
|
||||
err = game.MergeShipType(p, race, "Sky", "Drone")
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputEntityNotExists))
|
||||
err = game.MergeShipType(p, race, "Spy", "Freighter")
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputEntityNotExists))
|
||||
err = game.MergeShipType(p, race, "Spy", "Drone")
|
||||
assert.NoError(t, err)
|
||||
st, err := g().ShipTypes(race)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, st, 2)
|
||||
err = game.MergeShipType(p, race, "Drone", "Cruiser")
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrMergeShipTypeNotEqual))
|
||||
// TODO: test group/production changed
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"github.com/iliadenisov/galaxy/internal/controller"
|
||||
"github.com/iliadenisov/galaxy/internal/model/game"
|
||||
)
|
||||
|
||||
func DeclareWar(configure func(*controller.Param), from, to string) (err error) {
|
||||
control(configure, func(c *controller.Ctrl) {
|
||||
c.Execute(func(r controller.Repo, g game.Game) { err = updateRelation(r, g, from, to, game.RelationWar) })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func DeclarePeace(configure func(*controller.Param), from, to string) (err error) {
|
||||
control(configure, func(c *controller.Ctrl) {
|
||||
c.Execute(func(r controller.Repo, g game.Game) { err = updateRelation(r, g, from, to, game.RelationPeace) })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func updateRelation(r controller.Repo, g game.Game, hostRace, opponentRace string, rel game.Relation) error {
|
||||
if err := g.UpdateRelation(hostRace, opponentRace, rel); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.SaveState(g)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package game_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/controller"
|
||||
e "github.com/iliadenisov/galaxy/internal/error"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/game"
|
||||
mg "github.com/iliadenisov/galaxy/internal/model/game"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDeclarePeaceAndWarSingle(t *testing.T) {
|
||||
g(t, func(f func(*controller.Param), g func() mg.Game) {
|
||||
hostRace := "race_05"
|
||||
opponentRace := "race_01"
|
||||
|
||||
r, err := g().Relation(hostRace, opponentRace)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, mg.RelationWar, r.Relation)
|
||||
|
||||
r, err = g().Relation(unknownRaceName, opponentRace) // TODO: test on dead race
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputUnknownRace))
|
||||
r, err = g().Relation(hostRace, unknownRaceName) // TODO: test on dead race
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputUnknownRace))
|
||||
|
||||
assert.NoError(t, game.DeclarePeace(f, hostRace, opponentRace))
|
||||
r, err = g().Relation(hostRace, opponentRace)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, mg.RelationPeace, r.Relation)
|
||||
|
||||
assert.NoError(t, game.DeclareWar(f, hostRace, opponentRace))
|
||||
r, err = g().Relation(hostRace, opponentRace)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, mg.RelationWar, r.Relation)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeclarePeaceAndWarAll(t *testing.T) {
|
||||
g(t, func(f func(*controller.Param), g func() mg.Game) {
|
||||
hostRace := "race_07"
|
||||
|
||||
for i := range testRaceCount {
|
||||
opponentRace := raceNum(i)
|
||||
if opponentRace == hostRace {
|
||||
continue
|
||||
}
|
||||
r, err := g().Relation(hostRace, opponentRace)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, mg.RelationWar, r.Relation)
|
||||
}
|
||||
|
||||
assert.NoError(t, game.DeclarePeace(f, hostRace, hostRace))
|
||||
|
||||
for i := range testRaceCount {
|
||||
opponentRace := raceNum(i)
|
||||
if opponentRace == hostRace {
|
||||
continue
|
||||
}
|
||||
r, err := g().Relation(hostRace, opponentRace)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, mg.RelationPeace, r.Relation)
|
||||
}
|
||||
|
||||
assert.NoError(t, game.DeclareWar(f, hostRace, hostRace))
|
||||
|
||||
for i := range testRaceCount {
|
||||
opponentRace := raceNum(i)
|
||||
if opponentRace == hostRace {
|
||||
continue
|
||||
}
|
||||
r, err := g().Relation(hostRace, opponentRace)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, mg.RelationWar, r.Relation)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/iliadenisov/galaxy/internal/controller"
|
||||
"github.com/iliadenisov/galaxy/internal/model/game"
|
||||
)
|
||||
|
||||
func LoadState(configure func(*controller.Param)) (g game.Game, err error) {
|
||||
control(configure, func(c *controller.Ctrl) { c.ExecuteInit(func(r controller.Repo) { g, err = c.Repo.LoadState() }) })
|
||||
return
|
||||
}
|
||||
|
||||
func GenerateGame(configure func(*controller.Param), races []string) (gameID uuid.UUID, err error) {
|
||||
control(configure, func(c *controller.Ctrl) {
|
||||
c.ExecuteInit(func(r controller.Repo) { gameID, err = controller.NewGame(r, races) })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func control(configure func(*controller.Param), consumer func(*controller.Ctrl)) error {
|
||||
c, err := controller.NewController(configure)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
consumer(c)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package game_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/controller"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/game"
|
||||
mg "github.com/iliadenisov/galaxy/internal/model/game"
|
||||
"github.com/iliadenisov/galaxy/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const (
|
||||
testRaceCount = 20
|
||||
unknownRaceName = "Race_RIP"
|
||||
)
|
||||
|
||||
func raceNum(i int) string {
|
||||
return fmt.Sprintf("race_%02d", i)
|
||||
}
|
||||
|
||||
func TestComposeGame(t *testing.T) {
|
||||
g(t, func(p func(*controller.Param), g func() mg.Game) {
|
||||
_, err := game.GenerateGame(p, []string{"r1", "r2"})
|
||||
assert.Error(t, err)
|
||||
assert.ErrorContains(t, err, "state for turn 0 already saved")
|
||||
})
|
||||
}
|
||||
|
||||
func g(t *testing.T, f func(p func(*controller.Param), g func() mg.Game)) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
races := make([]string, testRaceCount)
|
||||
for i := range testRaceCount {
|
||||
races[i] = raceNum(i)
|
||||
}
|
||||
p := func(p *controller.Param) { p.StoragePath = root }
|
||||
_, err := game.GenerateGame(p, races)
|
||||
if err != nil {
|
||||
assert.FailNow(t, "g: ComposeGame", err)
|
||||
return
|
||||
}
|
||||
g := func() mg.Game {
|
||||
g, err := game.LoadState(p)
|
||||
if err != nil {
|
||||
assert.FailNow(t, "g: LoadState", err)
|
||||
return mg.Game{}
|
||||
}
|
||||
return g
|
||||
}
|
||||
f(p, g)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
func Generate(cfg ...func(*MapSetting)) (Map, error) {
|
||||
ms := DefaultMapSetting()
|
||||
for i := range cfg {
|
||||
cfg[i](&ms)
|
||||
}
|
||||
|
||||
size := ms.ExpectedSize()
|
||||
|
||||
m, err := NewMap(size, size, ms.Players)
|
||||
if err != nil {
|
||||
return Map{}, fmt.Errorf("%s: NewMap: %s", ms, err)
|
||||
}
|
||||
|
||||
freePlanets := ms.NobodysPlanets()
|
||||
|
||||
createPlanets := func(pc PlanetClass, ps PlanetSetting) error {
|
||||
return m.CreatePlanets(pc, ps.Number(freePlanets), float64(ps.MinDistanceHW), RandIFn(ps.MinSize, ps.MaxSize), RandIFn(ps.MinResource, ps.MaxResource))
|
||||
}
|
||||
|
||||
// 1. Place Giant planets
|
||||
if err := createPlanets(PlanetClassGiant, ms.GiantPlanets); err != nil {
|
||||
return Map{}, fmt.Errorf("%s: create giant planets: %s", ms, err)
|
||||
}
|
||||
|
||||
// 2. Place Big planets
|
||||
if err := createPlanets(PlanetClassBig, ms.BigPlanets); err != nil {
|
||||
return Map{}, fmt.Errorf("%s: create big planets: %s", ms, err)
|
||||
}
|
||||
|
||||
// 3. Place players' Home Worlds
|
||||
for player := 0; player < int(ms.Players); player++ {
|
||||
hwCoord, err := m.NewCoordinate(float64(ms.HWMinDistance))
|
||||
if err != nil {
|
||||
return Map{}, fmt.Errorf("%s: hw new_coordinate: %s", ms, err)
|
||||
}
|
||||
hwPlanet := NewPlanet(PlanetClassHW, hwCoord, ms.HWSize, ms.HWResources)
|
||||
m.HomePlanets[player] = PlanetarySystem{HW: hwPlanet, DW: make([]Planet, ms.DWCount)}
|
||||
for dw := 0; dw < int(ms.DWCount); dw++ {
|
||||
p := rand.Float64()*(float64(ms.DWMaxDistance)-float64(ms.DWMinDistance)) + float64(ms.DWMinDistance)
|
||||
phi := rand.Float64() * 360
|
||||
x := p * math.Cos(phi)
|
||||
y := p * math.Sin(phi)
|
||||
dwPlanet := NewPlanet(PlanetClassDW, Coordinate{hwCoord.X + x, hwCoord.Y + y}, ms.DWSize, ms.DWResources)
|
||||
m.HomePlanets[player].DW[dw] = dwPlanet
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Clear plotter and set dead zones around existing planets
|
||||
m.plotter.Clear()
|
||||
for i := range m.HomePlanets {
|
||||
m.plotter.MarkDeadZone(m.HomePlanets[i].HW.Position.X, m.HomePlanets[i].HW.Position.Y, ms.OthersMinDistance)
|
||||
for j := range m.HomePlanets[i].DW {
|
||||
m.plotter.MarkDeadZone(m.HomePlanets[i].DW[j].Position.X, m.HomePlanets[i].DW[j].Position.Y, ms.OthersMinDistance)
|
||||
}
|
||||
}
|
||||
for i := range m.FreePlanets {
|
||||
m.plotter.MarkDeadZone(m.FreePlanets[i].Position.X, m.FreePlanets[i].Position.Y, ms.OthersMinDistance)
|
||||
}
|
||||
|
||||
// 5. Place Normal planets
|
||||
if err := createPlanets(PlanetClassNormal, ms.NormalPlanets); err != nil {
|
||||
return Map{}, fmt.Errorf("%s: create normal planets: %s", ms, err)
|
||||
}
|
||||
|
||||
// 6. Place Rich planets
|
||||
if err := createPlanets(PlanetClassRich, ms.RichPlanets); err != nil {
|
||||
return Map{}, fmt.Errorf("%s: create rich planets: %s", ms, err)
|
||||
}
|
||||
|
||||
// 7. Place Asteroids
|
||||
if err := createPlanets(PlanetClassAsterioid, ms.Asterioids); err != nil {
|
||||
return Map{}, fmt.Errorf("%s: create asteroids: %s", ms, err)
|
||||
}
|
||||
|
||||
return *m, nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package generator_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/generator"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGenerator(t *testing.T) {
|
||||
maxPlayers := 20
|
||||
for players := 10; players <= maxPlayers; players++ {
|
||||
t.Run(fmt.Sprintf("%d_players", players), func(t *testing.T) {
|
||||
var s generator.MapSetting
|
||||
m, err := generator.Generate(func(ms *generator.MapSetting) {
|
||||
ms.Players = uint32(players)
|
||||
s = *ms
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %s", err)
|
||||
return
|
||||
}
|
||||
assert.Equal(t, players, len(m.HomePlanets), "hw-s count")
|
||||
for hw := range m.HomePlanets {
|
||||
assert.Equal(t, s.HWSize, m.HomePlanets[hw].HW.Size, "hw #%d: size", hw)
|
||||
assert.Equal(t, s.HWResources, m.HomePlanets[hw].HW.Resources, "hw #%d: resources", hw)
|
||||
assert.Equal(t, int(s.DWCount), len(m.HomePlanets[hw].DW), "hw #%d: dw-s count", hw)
|
||||
for dw := range m.HomePlanets[hw].DW {
|
||||
assert.Equal(t, s.DWSize, m.HomePlanets[hw].DW[dw].Size, "hw #%d dw #%d: size", hw, dw)
|
||||
assert.Equal(t, s.DWResources, m.HomePlanets[hw].DW[dw].Resources, "hw #%d dw #%d: resources", hw, dw)
|
||||
d := m.ShortDistance(m.HomePlanets[hw].HW.Position, m.HomePlanets[hw].DW[dw].Position)
|
||||
assert.LessOrEqualf(t, float64(s.DWMinDistance), d, "distance: HW[%.04f,%04f] <-> DW[%.04f,%04f]",
|
||||
m.HomePlanets[hw].HW.Position.X, m.HomePlanets[hw].HW.Position.Y,
|
||||
m.HomePlanets[hw].DW[dw].Position.X, m.HomePlanets[hw].DW[dw].Position.Y)
|
||||
assert.GreaterOrEqualf(t, float64(s.DWMaxDistance), d, "distance: HW[%.04f,%04f] <-> DW[%.04f,%04f]",
|
||||
m.HomePlanets[hw].HW.Position.X, m.HomePlanets[hw].HW.Position.Y,
|
||||
m.HomePlanets[hw].DW[dw].Position.X, m.HomePlanets[hw].DW[dw].Position.Y)
|
||||
}
|
||||
}
|
||||
assert.LessOrEqualf(t, int(s.NobodysPlanets()), len(m.FreePlanets), "free planets clount")
|
||||
freePlanetCount := make(map[generator.PlanetClass]int)
|
||||
for fp := range m.FreePlanets {
|
||||
ps := planetSettings(t, m.FreePlanets[fp].PlanetClass, s)
|
||||
testPlanetParameters(t, ps, m.FreePlanets[fp])
|
||||
if v, ok := freePlanetCount[m.FreePlanets[fp].PlanetClass]; !ok {
|
||||
freePlanetCount[m.FreePlanets[fp].PlanetClass] = 1
|
||||
} else {
|
||||
freePlanetCount[m.FreePlanets[fp].PlanetClass] = v + 1
|
||||
}
|
||||
if ps.MinDistanceHW > 0 {
|
||||
for hw := range m.HomePlanets {
|
||||
d := m.ShortDistance(m.HomePlanets[hw].HW.Position, m.FreePlanets[fp].Position)
|
||||
// FIXME:
|
||||
// Error: "20" is not less than or equal to "19.98697122994701"
|
||||
// Test: TestGenerator/24_players
|
||||
// Messages: distance: HW[44.4883,136.985727] <-> %!s(generator.PlanetClass=2)[38.9977,156.203728]
|
||||
//
|
||||
// Error: "10" is not less than or equal to "9.985592188977868"
|
||||
// Test: TestGenerator/33_players
|
||||
// Messages: distance: HW[231.7975,76.996315] <-> planet_class=3[237.7334,85.026044]
|
||||
assert.LessOrEqualf(t, float64(ps.MinDistanceHW), d, "distance: HW[%.04f,%04f] <-> planet_class=%v[%.04f,%04f]",
|
||||
m.HomePlanets[hw].HW.Position.X, m.HomePlanets[hw].HW.Position.Y,
|
||||
m.FreePlanets[fp].PlanetClass,
|
||||
m.FreePlanets[fp].Position.X, m.FreePlanets[fp].Position.Y)
|
||||
}
|
||||
}
|
||||
}
|
||||
for pc, num := range freePlanetCount {
|
||||
ps := planetSettings(t, pc, s)
|
||||
maxNum := ps.Number(s.NobodysPlanets())
|
||||
assert.Equalf(t, num, maxNum, "planet_class=%v ratio=%f of total %d", pc, ps.Ratio, s.NobodysPlanets())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testPlanetParameters(t *testing.T, s generator.PlanetSetting, p generator.Planet) {
|
||||
assert.LessOrEqualf(t, s.MinResource, p.Resources, "planet class=%s min resources", p.PlanetClass)
|
||||
assert.GreaterOrEqualf(t, s.MaxResource, p.Resources, "planet class=%s max resources", p.PlanetClass)
|
||||
assert.LessOrEqualf(t, s.MinSize, p.Size, "planet class=%s min size", p.PlanetClass)
|
||||
assert.GreaterOrEqualf(t, s.MaxSize, p.Size, "planet class=%s max size", p.PlanetClass)
|
||||
}
|
||||
|
||||
func planetSettings(t *testing.T, pc generator.PlanetClass, s generator.MapSetting) generator.PlanetSetting {
|
||||
switch pc {
|
||||
case generator.PlanetClassGiant:
|
||||
return s.GiantPlanets
|
||||
case generator.PlanetClassBig:
|
||||
return s.BigPlanets
|
||||
case generator.PlanetClassNormal:
|
||||
return s.NormalPlanets
|
||||
case generator.PlanetClassRich:
|
||||
return s.RichPlanets
|
||||
case generator.PlanetClassAsterioid:
|
||||
return s.Asterioids
|
||||
default:
|
||||
assert.FailNow(t, "unexpected planet class: %s", pc)
|
||||
return generator.PlanetSetting{}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenerator(b *testing.B) {
|
||||
i := 0
|
||||
for b.Loop() {
|
||||
i++
|
||||
b.Run(fmt.Sprintf("instance #%02d", i), func(b *testing.B) {
|
||||
_, err := generator.Generate()
|
||||
if err != nil {
|
||||
b.Error(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/generator/plotter"
|
||||
)
|
||||
|
||||
type Map struct {
|
||||
Width uint32
|
||||
Height uint32
|
||||
HomePlanets []PlanetarySystem
|
||||
FreePlanets []Planet
|
||||
plotter plotter.Plotter
|
||||
}
|
||||
|
||||
type Coordinate struct {
|
||||
X, Y float64
|
||||
}
|
||||
|
||||
func NewMap(width, height, players uint32) (*Map, error) {
|
||||
p, err := plotter.NewPlotter(width, height, defaultFactor)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewPlotter: %s", err)
|
||||
}
|
||||
return &Map{
|
||||
Width: width,
|
||||
Height: height,
|
||||
HomePlanets: make([]PlanetarySystem, players),
|
||||
plotter: p,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Map) CreatePlanets(pc PlanetClass, num int, deadZoneRadius float64, size, resources func() float64) error {
|
||||
for range num {
|
||||
coord, err := m.NewCoordinate(deadZoneRadius)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
planet := NewPlanet(pc, coord, size(), resources())
|
||||
m.AddPlanet(planet)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Map) AddPlanet(planet Planet) {
|
||||
m.FreePlanets = append(m.FreePlanets, planet)
|
||||
}
|
||||
|
||||
func (m Map) NewCoordinate(deadZoneRaduis float64) (Coordinate, error) {
|
||||
if x, y, err := m.plotter.RandomFreePoint(deadZoneRaduis); err != nil {
|
||||
return Coordinate{}, fmt.Errorf("NewCoordinate: RandomFreePoint: %s", err)
|
||||
} else {
|
||||
return Coordinate{X: x, Y: y}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m Map) ShortDistance(from, to Coordinate) float64 {
|
||||
dx := math.Abs(to.X - from.X)
|
||||
dy := math.Abs(to.Y - from.Y)
|
||||
if dx > float64(m.Width/2) {
|
||||
dx = float64(m.Width) - dx
|
||||
}
|
||||
if dy > float64(m.Height/2) {
|
||||
dy = float64(m.Height) - dy
|
||||
}
|
||||
return math.Sqrt(math.Pow(dx, 2) + math.Pow(dy, 2))
|
||||
}
|
||||
|
||||
// RandI returns a random float64 value between min and max
|
||||
func RandI(min, max float64) float64 {
|
||||
return min + rand.Float64()*(max-min)
|
||||
}
|
||||
|
||||
// RandIFn is a wrapper for the [RandI] func
|
||||
func RandIFn(min, max float64) func() float64 {
|
||||
return func() float64 {
|
||||
return RandI(min, max)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/number"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestShortDistance(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
w, h uint32
|
||||
x1, y1, x2, y2, d float64
|
||||
}{
|
||||
{10, 10, 0, 0, 5, 5, 7.071},
|
||||
{10, 10, 0, 0, 5.01, 5.01, 7.057},
|
||||
{10, 10, 2, 2, 8, 2, 4.},
|
||||
{10, 10, 8, 7, 1, 7, 3.},
|
||||
} {
|
||||
t.Run(fmt.Sprint(i), func(t *testing.T) {
|
||||
m := Map{Width: tc.w, Height: tc.h}
|
||||
d := m.ShortDistance(Coordinate{tc.x1, tc.y1}, Coordinate{tc.x2, tc.y2})
|
||||
assert.Equal(t, tc.d, number.Fixed3(d))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
type PlanetClass string
|
||||
|
||||
const (
|
||||
PlanetClassHW PlanetClass = "HW"
|
||||
PlanetClassDW PlanetClass = "DW"
|
||||
PlanetClassGiant PlanetClass = "Giant"
|
||||
PlanetClassBig PlanetClass = "Big"
|
||||
PlanetClassNormal PlanetClass = "Normal"
|
||||
PlanetClassRich PlanetClass = "Rich"
|
||||
PlanetClassAsterioid PlanetClass = "Asteroid"
|
||||
)
|
||||
|
||||
type Planet struct {
|
||||
PlanetClass PlanetClass
|
||||
Position Coordinate
|
||||
Size float64
|
||||
Resources float64 // Сырьё
|
||||
}
|
||||
|
||||
type PlanetarySystem struct {
|
||||
HW Planet
|
||||
DW []Planet
|
||||
}
|
||||
|
||||
func (p Planet) RandomName() string {
|
||||
return fmt.Sprintf("%s-%04d-%04d", p.PlanetClass, rand.Intn(1000), rand.Intn(1000))
|
||||
}
|
||||
|
||||
func NewPlanet(pc PlanetClass, c Coordinate, size, resources float64) Planet {
|
||||
return Planet{
|
||||
PlanetClass: pc,
|
||||
Position: c,
|
||||
Size: size,
|
||||
Resources: resources,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package generator_test
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
g "github.com/iliadenisov/galaxy/internal/generator"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPlanetRandomName(t *testing.T) {
|
||||
re, err := regexp.Compile(`^([a-zA-Z]+)-(\d{4})-(\d{4})$`)
|
||||
assert.NoError(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, pc := range []g.PlanetClass{g.PlanetClassHW, g.PlanetClassDW, g.PlanetClassGiant, g.PlanetClassBig, g.PlanetClassNormal, g.PlanetClassRich, g.PlanetClassAsterioid} {
|
||||
t.Run(string(pc), func(t *testing.T) {
|
||||
name := g.NewPlanet(pc, g.Coordinate{0, 0}, 0, 0).RandomName()
|
||||
g := re.FindStringSubmatch(name)
|
||||
assert.NotNilf(t, g, "cannot parse: %q", name)
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
assert.Equalf(t, 4, len(g), "regexp groups")
|
||||
assert.Equal(t, string(pc), g[1])
|
||||
assert.NotEqual(t, g[2], g[3])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package plotter
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/bitmap"
|
||||
)
|
||||
|
||||
type Plotter struct {
|
||||
factor float64
|
||||
clearFn func()
|
||||
circleFn func(x, y int, r float64)
|
||||
freeCountFn func() int
|
||||
freeNumberToCoordFn func(int) (int, int, error)
|
||||
}
|
||||
|
||||
func NewPlotter(width, height uint32, factor float64) (Plotter, error) {
|
||||
return NewBitmapPlotter(NewBitmap(width, height, factor), factor)
|
||||
}
|
||||
|
||||
func NewBitmap(width, height uint32, factor float64) bitmap.Bitmap {
|
||||
return bitmap.NewBitmap(AsPlotterSize(width, height, factor))
|
||||
}
|
||||
|
||||
func NewBitmapPlotter(bm bitmap.Bitmap, factor float64) (Plotter, error) {
|
||||
if factor > 1 || factor <= 0 {
|
||||
return Plotter{}, fmt.Errorf("factor should be: 0 > F <= 1")
|
||||
}
|
||||
return Plotter{
|
||||
factor: factor,
|
||||
clearFn: bm.Clear,
|
||||
circleFn: func(x, y int, r float64) { bm.Circle(x, y, r, true) },
|
||||
freeCountFn: bm.FreeCount,
|
||||
freeNumberToCoordFn: bm.GetFreeN,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p Plotter) RandomFreePoint(deadZoneRaduis float64) (float64, float64, error) {
|
||||
fsCount := p.freeCountFn()
|
||||
if fsCount == 0 {
|
||||
return 0, 0, errors.New("RandomFreePoint: no free space left")
|
||||
}
|
||||
next := rand.Intn(fsCount)
|
||||
x, y, err := p.freeNumberToCoordFn(next)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("RandomFreePoint: freeNumberToCoordFn: %s", err)
|
||||
}
|
||||
if deadZoneRaduis > 0 {
|
||||
p.plotDeadZone(x, y, deadZoneRaduis)
|
||||
}
|
||||
planetX := float64(x)*p.factor + rand.Float64()*p.factor
|
||||
planetY := float64(y)*p.factor + rand.Float64()*p.factor
|
||||
return planetX, planetY, nil
|
||||
}
|
||||
|
||||
func (p Plotter) MarkDeadZone(x, y float64, radius float64) {
|
||||
p.plotDeadZone(int(x/p.factor), int(y/p.factor), radius)
|
||||
}
|
||||
|
||||
func (p Plotter) plotDeadZone(x, y int, radius float64) {
|
||||
p.circleFn(x, y, radius/p.factor)
|
||||
}
|
||||
|
||||
func (p Plotter) Clear() { p.clearFn() }
|
||||
|
||||
func AsPlotterSize(width, height uint32, factor float64) (uint32, uint32) {
|
||||
if factor > 1 || factor <= 0 {
|
||||
return width, height
|
||||
}
|
||||
return uint32(math.Ceil(float64(width) / float64(factor))), uint32(math.Ceil(float64(height) / float64(factor)))
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package plotter_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/generator/plotter"
|
||||
)
|
||||
|
||||
func TestNewPlotter(t *testing.T) {
|
||||
_, err := plotter.NewPlotter(10, 10, 0)
|
||||
if err == nil {
|
||||
t.Error("expect: error when factor=0")
|
||||
}
|
||||
_, err = plotter.NewPlotter(10, 10, -0.01)
|
||||
if err == nil {
|
||||
t.Error("expect: error when factor<0")
|
||||
}
|
||||
_, err = plotter.NewPlotter(10, 10, 1.001)
|
||||
if err == nil {
|
||||
t.Error("expect: error when factor>1")
|
||||
}
|
||||
_, err = plotter.NewPlotter(10, 10, 1)
|
||||
if err != nil {
|
||||
t.Error("expect: no error when factor=1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsPlotterSize(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
w, h uint32
|
||||
f float64
|
||||
ew, eh uint32
|
||||
}{
|
||||
{10, 10, 0, 10, 10},
|
||||
{10, 10, -1, 10, 10},
|
||||
{10, 10, 1.1, 10, 10},
|
||||
{10, 20, 0.5, 20, 40},
|
||||
{30, 60, 0.3, 100, 200},
|
||||
{10, 20, 0.25, 40, 80},
|
||||
} {
|
||||
w, h := plotter.AsPlotterSize(tc.w, tc.h, tc.f)
|
||||
if tc.ew != w || tc.eh != h {
|
||||
t.Errorf("expect: w=%d h=%d, got: w=%d h=%d", tc.ew, tc.eh, w, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomFreePoint(t *testing.T) {
|
||||
var factor float64 = 0.25
|
||||
var w, h uint32 = 20, 20
|
||||
bm := plotter.NewBitmap(w, h, factor)
|
||||
p, err := plotter.NewBitmapPlotter(bm, factor) // 80x80
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
x, y, err := p.RandomFreePoint(3)
|
||||
if err != nil {
|
||||
t.Errorf("expect: no error getting random point, got: %s", err)
|
||||
}
|
||||
if x > float64(w) || y > float64(w) {
|
||||
t.Errorf("expect: point coordinates within map size %dx%d, got: x=%f y=%f", w, h, x, y)
|
||||
}
|
||||
|
||||
_, _, err = p.RandomFreePoint(0)
|
||||
if err != nil {
|
||||
t.Errorf("expect: no error when radius is zero, got: %s", err)
|
||||
}
|
||||
|
||||
_, _, err = p.RandomFreePoint(float64(w + h)) // guaranteed to mark whole area dead zone
|
||||
if err != nil {
|
||||
t.Errorf("expect: no error getting random point, got: %s", err)
|
||||
}
|
||||
|
||||
_, _, err = p.RandomFreePoint(1)
|
||||
if err == nil {
|
||||
t.Error("expect: error when no free space left, got: none")
|
||||
}
|
||||
|
||||
p.Clear()
|
||||
|
||||
_, _, err = p.RandomFreePoint(10)
|
||||
if err != nil {
|
||||
t.Errorf("expect: no error getting random point after clearing, got: %s", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package generator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
const defaultFactor float64 = 0.1
|
||||
|
||||
type MapSetting struct {
|
||||
Players uint32
|
||||
HWSize float64
|
||||
HWResources float64
|
||||
HWMinDistance uint32
|
||||
DWCount uint32
|
||||
DWSize float64
|
||||
DWResources float64
|
||||
DWMinDistance uint32
|
||||
DWMaxDistance uint32
|
||||
|
||||
GiantPlanets PlanetSetting
|
||||
BigPlanets PlanetSetting
|
||||
OthersMinDistance float64
|
||||
NormalPlanets PlanetSetting
|
||||
RichPlanets PlanetSetting
|
||||
Asterioids PlanetSetting
|
||||
}
|
||||
|
||||
func (ms MapSetting) String() string {
|
||||
return fmt.Sprintf("MapSetting[players=%d HWMinDistance=%d Size=%d]", ms.Players, ms.HWMinDistance, ms.ExpectedSize())
|
||||
}
|
||||
|
||||
func (ms MapSetting) ExpectedSize() uint32 {
|
||||
return uint32(math.Sqrt(float64(ms.Players)) * float64(ms.HWMinDistance) * 1.5)
|
||||
}
|
||||
|
||||
func (ms MapSetting) TotalPlanets() uint32 {
|
||||
return ms.Players * 10
|
||||
}
|
||||
|
||||
func (ms MapSetting) NobodysPlanets() uint32 {
|
||||
return ms.TotalPlanets() - ms.Players*(ms.DWCount+1)
|
||||
}
|
||||
|
||||
type PlanetSetting struct {
|
||||
MinDistanceHW uint32
|
||||
MinSize float64
|
||||
MaxSize float64
|
||||
MinResource float64
|
||||
MaxResource float64
|
||||
Ratio float64 // The proportion of the total number of free planets in the galaxy
|
||||
}
|
||||
|
||||
// Number of planets need to be placed within freePlanets amount
|
||||
func (ps PlanetSetting) Number(freePlanets uint32) int {
|
||||
return int(math.Ceil(float64(freePlanets) * ps.Ratio))
|
||||
}
|
||||
|
||||
func DefaultMapSetting() MapSetting {
|
||||
return MapSetting{
|
||||
Players: 25,
|
||||
HWSize: 1000,
|
||||
HWResources: 10,
|
||||
HWMinDistance: 30,
|
||||
DWCount: 2,
|
||||
DWSize: 500,
|
||||
DWResources: 10,
|
||||
DWMinDistance: 5,
|
||||
DWMaxDistance: 15,
|
||||
GiantPlanets: PlanetSetting{
|
||||
MinDistanceHW: 20,
|
||||
MinSize: 1500,
|
||||
MaxSize: 2500,
|
||||
MinResource: 0,
|
||||
MaxResource: 3,
|
||||
Ratio: 0.06,
|
||||
},
|
||||
BigPlanets: PlanetSetting{
|
||||
MinDistanceHW: 10,
|
||||
MinSize: 1000,
|
||||
MaxSize: 2000,
|
||||
MinResource: 1,
|
||||
MaxResource: 10,
|
||||
Ratio: 0.18,
|
||||
},
|
||||
OthersMinDistance: defaultFactor, // min. is 1 pixel on the plotter
|
||||
NormalPlanets: PlanetSetting{
|
||||
MinDistanceHW: 0,
|
||||
MinSize: 0,
|
||||
MaxSize: 1000,
|
||||
MinResource: 0,
|
||||
MaxResource: 10,
|
||||
Ratio: 0.5,
|
||||
},
|
||||
RichPlanets: PlanetSetting{
|
||||
MinDistanceHW: 0,
|
||||
MinSize: 0,
|
||||
MaxSize: 500,
|
||||
MinResource: 5,
|
||||
MaxResource: 25,
|
||||
Ratio: 0.18,
|
||||
},
|
||||
Asterioids: PlanetSetting{
|
||||
MinDistanceHW: 0,
|
||||
MinSize: 0,
|
||||
MaxSize: 0,
|
||||
MinResource: 0,
|
||||
MaxResource: 0,
|
||||
Ratio: 0.08,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"github.com/google/uuid"
|
||||
e "github.com/iliadenisov/galaxy/internal/error"
|
||||
)
|
||||
|
||||
type Fleet struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OwnerID uuid.UUID `json:"ownerId"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// TODO: Hello! Wanna know fleet's speed? Good. Implement & test this func first.
|
||||
func (g Game) FleetSpeed(fl Fleet) float64 {
|
||||
result := math.MaxFloat64
|
||||
for sg := range g.ShipGroups {
|
||||
if g.ShipGroups[sg].FleetID == nil || *g.ShipGroups[sg].FleetID != fl.ID {
|
||||
continue
|
||||
}
|
||||
st := g.mustShipType(g.ShipGroups[sg].TypeID)
|
||||
typeSpeed := g.ShipGroups[sg].Speed(st)
|
||||
if typeSpeed < result {
|
||||
result = typeSpeed
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (g Game) JoinShipGroupToFleet(raceName, fleetName string, group, count uint) error {
|
||||
ri, err := g.raceIndex(raceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.joinShipGroupToFleetInternal(ri, fleetName, group, count)
|
||||
}
|
||||
|
||||
func (g Game) joinShipGroupToFleetInternal(ri int, fleetName string, group, count uint) (err error) {
|
||||
name, ok := validateTypeName(fleetName)
|
||||
if !ok {
|
||||
return e.NewEntityTypeNameValidationError("%q", name)
|
||||
}
|
||||
sgi := -1
|
||||
var maxIndex uint
|
||||
for i, sg := range g.listShipGroups(ri) {
|
||||
if sgi < 0 && sg.Index == group {
|
||||
sgi = i
|
||||
}
|
||||
if sg.Index > maxIndex {
|
||||
maxIndex = sg.Index
|
||||
}
|
||||
}
|
||||
if sgi < 0 {
|
||||
return e.NewEntityNotExistsError("group #%d", group)
|
||||
}
|
||||
|
||||
if g.ShipGroups[sgi].Number < count {
|
||||
return e.NewJoinFleetGroupNumberNotEnoughError("%d<%d", g.ShipGroups[sgi].Number, count)
|
||||
}
|
||||
|
||||
fi := g.fleetIndex(ri, name)
|
||||
if fi < 0 {
|
||||
fi, err = g.createFleet(ri, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if g.ShipGroups[sgi].Number != count && count > 0 {
|
||||
newGroup := g.ShipGroups[sgi]
|
||||
newGroup.Number -= count
|
||||
g.ShipGroups[sgi].Number = count
|
||||
newGroup.Index = maxIndex + 1
|
||||
g.ShipGroups = append(g.ShipGroups, newGroup)
|
||||
}
|
||||
|
||||
g.ShipGroups[sgi].FleetID = &g.Fleets[fi].ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g Game) fleetIndex(ri int, name string) int {
|
||||
return slices.IndexFunc(g.Fleets, func(f Fleet) bool { return f.OwnerID == g.Race[ri].ID && f.Name == name })
|
||||
}
|
||||
|
||||
func (g Game) createFleet(ri int, name string) (int, error) {
|
||||
n, ok := validateTypeName(name)
|
||||
if !ok {
|
||||
return 0, e.NewEntityTypeNameValidationError("%q", n)
|
||||
}
|
||||
if fl := g.fleetIndex(ri, n); fl >= 0 {
|
||||
return 0, e.NewEntityTypeNameDuplicateError("fleet %w", g.Fleets[fl].Name)
|
||||
}
|
||||
g.Fleets = append(g.Fleets, Fleet{
|
||||
ID: uuid.New(),
|
||||
OwnerID: g.Race[ri].ID,
|
||||
Name: n,
|
||||
})
|
||||
return len(g.Fleets) - 1, nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
e "github.com/iliadenisov/galaxy/internal/error"
|
||||
)
|
||||
|
||||
type Game struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Age uint `json:"turn"` // Game's turn number
|
||||
Map Map `json:"map"`
|
||||
Race []Race `json:"races"`
|
||||
ShipGroups []ShipGroup `json:"shipGroup,omitempty"`
|
||||
Fleets []Fleet `json:"fleet,omitempty"`
|
||||
}
|
||||
|
||||
func (g Game) Votes(raceID uuid.UUID) float64 {
|
||||
// XXX: calculate [Race]Population once when loading Game from Storage?
|
||||
var pop float64
|
||||
for i := range g.Map.Planet {
|
||||
if g.Map.Planet[i].Owner == raceID {
|
||||
pop += g.Map.Planet[i].Population
|
||||
}
|
||||
}
|
||||
return pop / 1000.
|
||||
}
|
||||
|
||||
func (g Game) raceIndex(name string) (int, error) {
|
||||
i := slices.IndexFunc(g.Race, func(r Race) bool { return r.Name == name })
|
||||
if i < 0 {
|
||||
return i, e.NewRaceUnknownError(name)
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
func (g Game) UpdateRelation(race, opponent string, rel Relation) error {
|
||||
ri, err := g.raceIndex(race)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var other int
|
||||
if race == opponent {
|
||||
other = ri
|
||||
} else if other, err = g.raceIndex(opponent); err != nil {
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.updateRelationInternal(ri, other, rel)
|
||||
}
|
||||
|
||||
func (g Game) updateRelationInternal(ri, other int, rel Relation) error {
|
||||
for o := range g.Race[ri].Relations {
|
||||
switch {
|
||||
case ri == other:
|
||||
g.Race[ri].Relations[o].Relation = rel
|
||||
case g.Race[ri].Relations[o].RaceID == g.Race[other].ID:
|
||||
g.Race[ri].Relations[o].Relation = rel
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if ri != other {
|
||||
return e.NewGameStateError("UpdateRelation: opponent not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g Game) Relation(hostRace, opponentRace string) (RaceRelation, error) {
|
||||
ri, err := g.raceIndex(hostRace)
|
||||
if err != nil {
|
||||
return RaceRelation{}, err
|
||||
}
|
||||
other, err := g.raceIndex(opponentRace)
|
||||
if err != nil {
|
||||
return RaceRelation{}, err
|
||||
}
|
||||
return g.relationInternal(ri, other)
|
||||
}
|
||||
|
||||
func (g Game) relationInternal(ri, other int) (RaceRelation, error) {
|
||||
rel := slices.IndexFunc(g.Race[ri].Relations, func(r RaceRelation) bool { return r.RaceID == g.Race[other].ID })
|
||||
if rel < 0 {
|
||||
return RaceRelation{}, e.NewGameStateError("Relation: opponent not found")
|
||||
}
|
||||
return g.Race[ri].Relations[rel], nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// validateTypeName always return v without leading and trailing spaces
|
||||
func validateTypeName(v string) (string, bool) {
|
||||
s := strings.TrimSpace(v)
|
||||
if len(s) > 0 {
|
||||
return s, true
|
||||
}
|
||||
// TODO: special symbols
|
||||
return s, false
|
||||
}
|
||||
|
||||
func (g Game) MarshalBinary() (data []byte, err error) {
|
||||
return json.Marshal(&g)
|
||||
}
|
||||
|
||||
func (g *Game) UnmarshalBinary(data []byte) error {
|
||||
return json.Unmarshal(data, g)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package game
|
||||
|
||||
import "iter"
|
||||
|
||||
func (g *Game) CreateShips(ri int, shipTypeName string, planetNumber int, quantity int) error {
|
||||
return g.createShips(ri, shipTypeName, planetNumber, quantity)
|
||||
}
|
||||
|
||||
func (g Game) ListShipGroups(ri int) iter.Seq2[int, ShipGroup] {
|
||||
return g.listShipGroups(ri)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"iter"
|
||||
"maps"
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"github.com/google/uuid"
|
||||
e "github.com/iliadenisov/galaxy/internal/error"
|
||||
"github.com/iliadenisov/galaxy/internal/number"
|
||||
)
|
||||
|
||||
type CargoType string
|
||||
|
||||
const (
|
||||
// CargoNone CargoType = "-"
|
||||
CargoColonist CargoType = "COL" // Колонисты
|
||||
CargoMaterial CargoType = "MAT" // Сырьё
|
||||
CargoCapital CargoType = "CAP" // Промышленность
|
||||
)
|
||||
|
||||
type ShipGroup struct {
|
||||
Index uint `json:"index"` // Group index (ordered)
|
||||
OwnerID uuid.UUID `json:"ownerId"` // Race link
|
||||
TypeID uuid.UUID `json:"typeId"` // ShipType link
|
||||
FleetID *uuid.UUID `json:"fleetId,omitempty"` // ShipType link
|
||||
Number uint `json:"number"` // Number (quantity) ships of Type
|
||||
State string `json:"state"` // TODO: kinda enum: In_Orbit, In_Space, Transfer_State, Upgrade
|
||||
|
||||
CargoType *CargoType `json:"loadType,omitempty"`
|
||||
Load float64 `json:"load"` // Cargo loaded - "Масса груза"
|
||||
|
||||
Drive float64 `json:"drive"`
|
||||
Weapons float64 `json:"weapons"`
|
||||
Shields float64 `json:"shields"`
|
||||
Cargo float64 `json:"cargo"`
|
||||
|
||||
// TODO: append AND TEST: Destination, Origin, Range
|
||||
Destination uint `json:"destination"`
|
||||
}
|
||||
|
||||
func (sg ShipGroup) Equal(other ShipGroup) bool {
|
||||
return sg.OwnerID == other.OwnerID &&
|
||||
sg.TypeID == other.TypeID &&
|
||||
sg.FleetID == other.FleetID &&
|
||||
sg.Drive == other.Drive &&
|
||||
sg.Weapons == other.Weapons &&
|
||||
sg.Shields == other.Shields &&
|
||||
sg.Cargo == other.Cargo &&
|
||||
sg.CargoType == other.CargoType &&
|
||||
sg.Load == other.Load &&
|
||||
sg.State == other.State
|
||||
}
|
||||
|
||||
// Грузоподъёмность
|
||||
func (sg ShipGroup) CargoCapacity(st *ShipType) float64 {
|
||||
return sg.Cargo * (st.Cargo + (st.Cargo*st.Cargo)/20) * float64(sg.Number)
|
||||
}
|
||||
|
||||
// Масса перевозимого груза -
|
||||
// общее количество единиц груза, деленное на технологический уровень Грузоперевозок
|
||||
func (sg ShipGroup) CarryingMass() float64 {
|
||||
return sg.Load / sg.Cargo
|
||||
}
|
||||
|
||||
// Полная масса -
|
||||
// массу корабля самого по себе плюс масса перевозимого груза.
|
||||
func (sg ShipGroup) FullMass(st *ShipType) float64 {
|
||||
return st.EmptyMass() + sg.CarryingMass()
|
||||
}
|
||||
|
||||
// Эффективность двигателя -
|
||||
// равна мощности Двигателей, умноженной на технологический уровень блока Двигателей
|
||||
func (sg ShipGroup) DriveEffective(st *ShipType) float64 {
|
||||
return st.Drive * sg.Drive
|
||||
}
|
||||
|
||||
// Корабли перемещаются за один ход на количество световых лет, равное
|
||||
// эффективности двигателя, умноженной на 20 и деленной на "Полную массу" корабля.
|
||||
func (sg ShipGroup) Speed(st *ShipType) float64 {
|
||||
return sg.DriveEffective(st) * 20 / sg.FullMass(st)
|
||||
}
|
||||
|
||||
func (sg ShipGroup) UpgradeDriveCost(st *ShipType, drive float64) float64 {
|
||||
return (1 - sg.Drive/drive) * 10 * st.Drive
|
||||
}
|
||||
|
||||
// TODO: test on other values
|
||||
func (sg ShipGroup) UpgradeWeaponsCost(st *ShipType, weapons float64) float64 {
|
||||
return (1 - sg.Weapons/weapons) * 10 * st.WeaponsMass()
|
||||
}
|
||||
|
||||
func (sg ShipGroup) UpgradeShieldsCost(st *ShipType, shields float64) float64 {
|
||||
return (1 - sg.Shields/shields) * 10 * st.Shields
|
||||
}
|
||||
|
||||
func (sg ShipGroup) UpgradeCargoCost(st *ShipType, cargo float64) float64 {
|
||||
return (1 - sg.Cargo/cargo) * 10 * st.Cargo
|
||||
}
|
||||
|
||||
// Мощность бомбардировки
|
||||
// TODO: maybe rounding must be done only for display?
|
||||
func (sg ShipGroup) BombingPower(st *ShipType) float64 {
|
||||
// return math.Sqrt(sg.Type.Weapons * sg.Weapons)
|
||||
result := (math.Sqrt(st.Weapons*sg.Weapons)/10. + 1.) *
|
||||
st.Weapons *
|
||||
sg.Weapons *
|
||||
float64(st.Armament) *
|
||||
float64(sg.Number)
|
||||
return number.Fixed3(result)
|
||||
}
|
||||
|
||||
func (g *Game) JoinEqualGroups(raceName string) error {
|
||||
ri, err := g.raceIndex(raceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
g.joinEqualGroupsInternal(ri)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Game) joinEqualGroupsInternal(ri int) {
|
||||
shipGroups := slices.Collect(maps.Values(maps.Collect(g.listShipGroups(ri))))
|
||||
origin := len(shipGroups)
|
||||
if origin < 2 {
|
||||
return
|
||||
}
|
||||
for i := 0; i < len(shipGroups)-1; i++ {
|
||||
for j := len(shipGroups) - 1; j > i; j-- {
|
||||
if shipGroups[i].Equal(shipGroups[j]) {
|
||||
shipGroups[i].Index = maxUint(shipGroups[i].Index, shipGroups[j].Index)
|
||||
shipGroups[i].Number += shipGroups[j].Number
|
||||
shipGroups = append(shipGroups[:j], shipGroups[j+1:]...)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(shipGroups) == origin {
|
||||
return
|
||||
}
|
||||
g.ShipGroups = slices.DeleteFunc(g.ShipGroups, func(v ShipGroup) bool { return v.OwnerID == g.Race[ri].ID })
|
||||
g.ShipGroups = append(g.ShipGroups, shipGroups...)
|
||||
}
|
||||
|
||||
func (g *Game) createShips(ri int, shipTypeName string, planetNumber int, quantity int) error {
|
||||
st := slices.IndexFunc(g.Race[ri].ShipTypes, func(st ShipType) bool { return st.Name == shipTypeName })
|
||||
if st < 0 {
|
||||
return e.NewEntityNotExistsError("ship type %w", shipTypeName)
|
||||
}
|
||||
pl := slices.IndexFunc(g.Map.Planet, func(p Planet) bool { return p.Number == uint(planetNumber) })
|
||||
if pl < 0 {
|
||||
return e.NewEntityNotExistsError("planet #%d", planetNumber)
|
||||
}
|
||||
if g.Map.Planet[pl].Owner != g.Race[ri].ID {
|
||||
return e.NewEntityNotOwnedError("planet %#d", planetNumber)
|
||||
}
|
||||
|
||||
var maxIndex uint
|
||||
for _, sg := range g.listShipGroups(ri) {
|
||||
if sg.Index > maxIndex {
|
||||
maxIndex = sg.Index
|
||||
}
|
||||
}
|
||||
g.ShipGroups = append(g.ShipGroups, ShipGroup{
|
||||
Index: maxIndex + 1,
|
||||
OwnerID: g.Race[ri].ID,
|
||||
TypeID: g.Race[ri].ShipTypes[st].ID,
|
||||
Destination: g.Map.Planet[pl].Number,
|
||||
Number: uint(quantity),
|
||||
State: "In_Orbit",
|
||||
Drive: g.Race[ri].Drive,
|
||||
Weapons: g.Race[ri].Weapons,
|
||||
Shields: g.Race[ri].Shields,
|
||||
Cargo: g.Race[ri].Cargo,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g Game) listShipGroups(ri int) iter.Seq2[int, ShipGroup] {
|
||||
return func(yield func(int, ShipGroup) bool) {
|
||||
for sg := range g.ShipGroups {
|
||||
if g.ShipGroups[sg].OwnerID == g.Race[ri].ID {
|
||||
if !yield(sg, g.ShipGroups[sg]) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func maxUint(a, b uint) uint {
|
||||
if b > a {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package game_test
|
||||
|
||||
import (
|
||||
"iter"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/iliadenisov/galaxy/internal/controller"
|
||||
e "github.com/iliadenisov/galaxy/internal/error"
|
||||
"github.com/iliadenisov/galaxy/internal/model/game"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCargoCapacity(t *testing.T) {
|
||||
test := func(cargoSize float64, expectCapacity float64) {
|
||||
ship := game.ShipType{
|
||||
ShipTypeReport: game.ShipTypeReport{
|
||||
Drive: 1,
|
||||
Armament: 1,
|
||||
Weapons: 1,
|
||||
Shields: 1,
|
||||
Cargo: cargoSize,
|
||||
},
|
||||
}
|
||||
sg := game.ShipGroup{
|
||||
Number: 1,
|
||||
State: "In_Orbit",
|
||||
Drive: 1.5,
|
||||
Weapons: 1.1,
|
||||
Shields: 2.0,
|
||||
Cargo: 1.0,
|
||||
}
|
||||
assert.Equal(t, expectCapacity, sg.CargoCapacity(&ship))
|
||||
}
|
||||
test(1, 1.05)
|
||||
test(5, 6.25)
|
||||
test(10, 15)
|
||||
test(50, 175)
|
||||
test(100, 600)
|
||||
}
|
||||
|
||||
func TestCarryingAndFullMass(t *testing.T) {
|
||||
Freighter := &game.ShipType{
|
||||
ShipTypeReport: game.ShipTypeReport{
|
||||
Name: "Freighter",
|
||||
Drive: 8,
|
||||
Armament: 0,
|
||||
Weapons: 0,
|
||||
Shields: 2,
|
||||
Cargo: 10,
|
||||
},
|
||||
}
|
||||
sg := &game.ShipGroup{
|
||||
Number: 1,
|
||||
State: "In_Orbit",
|
||||
Drive: 1.0,
|
||||
Weapons: 1.0,
|
||||
Shields: 1.0,
|
||||
Cargo: 1.0,
|
||||
Load: 0.0,
|
||||
}
|
||||
em := Freighter.EmptyMass()
|
||||
assert.Equal(t, 0.0, sg.CarryingMass())
|
||||
assert.Equal(t, em, sg.FullMass(Freighter))
|
||||
|
||||
sg.Load = 10.0
|
||||
assert.Equal(t, 10.0, sg.CarryingMass())
|
||||
assert.Equal(t, em+10.0, sg.FullMass(Freighter))
|
||||
|
||||
sg.Cargo = 2.5
|
||||
assert.Equal(t, 4.0, sg.CarryingMass())
|
||||
assert.Equal(t, em+4.0, sg.FullMass(Freighter))
|
||||
}
|
||||
|
||||
func TestSpeed(t *testing.T) {
|
||||
Freighter := &game.ShipType{
|
||||
ShipTypeReport: game.ShipTypeReport{
|
||||
Name: "Freighter",
|
||||
Drive: 8,
|
||||
Armament: 0,
|
||||
Weapons: 0,
|
||||
Shields: 2,
|
||||
Cargo: 10,
|
||||
},
|
||||
}
|
||||
sg := &game.ShipGroup{
|
||||
Number: 1,
|
||||
State: "In_Orbit",
|
||||
Drive: 1.0,
|
||||
Weapons: 1.0,
|
||||
Shields: 1.0,
|
||||
Cargo: 1.0,
|
||||
Load: 0.0,
|
||||
}
|
||||
assert.Equal(t, 8.0, sg.Speed(Freighter))
|
||||
sg.Load = 5.0
|
||||
assert.Equal(t, 6.4, sg.Speed(Freighter))
|
||||
sg.Drive = 1.5
|
||||
assert.Equal(t, 9.6, sg.Speed(Freighter))
|
||||
sg.Load = 10
|
||||
sg.Cargo = 1.5
|
||||
assert.Equal(t, 9.0, sg.Speed(Freighter))
|
||||
}
|
||||
|
||||
func TestBombingPower(t *testing.T) {
|
||||
Gunship := game.ShipType{
|
||||
ShipTypeReport: game.ShipTypeReport{
|
||||
Drive: 60.0,
|
||||
Armament: 3,
|
||||
Weapons: 30.0,
|
||||
Shields: 100.0,
|
||||
Cargo: 0.0,
|
||||
},
|
||||
}
|
||||
sg := game.ShipGroup{
|
||||
Number: 1,
|
||||
State: "In_Orbit",
|
||||
Drive: 1.0,
|
||||
Weapons: 1.0,
|
||||
Shields: 1.0,
|
||||
Cargo: 1.0,
|
||||
}
|
||||
expectedBombingPower := 139.295
|
||||
result := sg.BombingPower(&Gunship)
|
||||
assert.Equal(t, expectedBombingPower, result)
|
||||
}
|
||||
|
||||
func TestUpgradeCost(t *testing.T) {
|
||||
Cruiser := game.ShipType{
|
||||
ShipTypeReport: game.ShipTypeReport{
|
||||
Name: "Cruiser",
|
||||
Drive: 15,
|
||||
Armament: 1,
|
||||
Weapons: 15,
|
||||
Shields: 15,
|
||||
Cargo: 0,
|
||||
},
|
||||
}
|
||||
|
||||
sg := game.ShipGroup{
|
||||
Number: 1,
|
||||
State: "In_Orbit",
|
||||
Drive: 1.0,
|
||||
Weapons: 1.0,
|
||||
Shields: 1.0,
|
||||
Cargo: 1.0,
|
||||
}
|
||||
upgradeCost := sg.UpgradeDriveCost(&Cruiser, 2.0) +
|
||||
sg.UpgradeWeaponsCost(&Cruiser, 2.0) +
|
||||
sg.UpgradeShieldsCost(&Cruiser, 2.0) +
|
||||
sg.UpgradeCargoCost(&Cruiser, 2.0)
|
||||
assert.Equal(t, 225., upgradeCost)
|
||||
}
|
||||
|
||||
func TestDriveEffective(t *testing.T) {
|
||||
tc := []struct {
|
||||
driveShipType float64
|
||||
driveTech float64
|
||||
expectDriveEffective float64
|
||||
}{
|
||||
{1, 1, 1},
|
||||
{1, 2, 2},
|
||||
{2, 1, 2},
|
||||
{0, 1, 0},
|
||||
{0, 1.5, 0},
|
||||
{0, 10, 0},
|
||||
{1.5, 1.5, 2.25},
|
||||
}
|
||||
for i := range tc {
|
||||
someShip := game.ShipType{
|
||||
ShipTypeReport: game.ShipTypeReport{
|
||||
Drive: tc[i].driveShipType,
|
||||
Armament: rand.UintN(30) + 1,
|
||||
Weapons: rand.Float64()*30 + 1,
|
||||
Shields: rand.Float64()*100 + 1,
|
||||
Cargo: rand.Float64()*20 + 1,
|
||||
},
|
||||
}
|
||||
sg := game.ShipGroup{
|
||||
Number: rand.UintN(4) + 1,
|
||||
State: "In_Orbit",
|
||||
Drive: tc[i].driveTech,
|
||||
Weapons: rand.Float64()*5 + 1,
|
||||
Shields: rand.Float64()*5 + 1,
|
||||
Cargo: rand.Float64()*5 + 1,
|
||||
}
|
||||
assert.Equal(t, tc[i].expectDriveEffective, sg.DriveEffective(&someShip))
|
||||
}
|
||||
}
|
||||
|
||||
func TestShipGroupEqual(t *testing.T) {
|
||||
fleetId := uuid.New()
|
||||
someUUID := uuid.New()
|
||||
mat := game.CargoMaterial
|
||||
cap := game.CargoCapital
|
||||
left := &game.ShipGroup{
|
||||
Index: 1,
|
||||
Number: 1,
|
||||
|
||||
OwnerID: uuid.New(),
|
||||
TypeID: uuid.New(),
|
||||
FleetID: &fleetId,
|
||||
State: "In_Orbit",
|
||||
CargoType: &mat,
|
||||
Load: 123.45,
|
||||
Drive: 1.0,
|
||||
Weapons: 1.0,
|
||||
Shields: 1.0,
|
||||
Cargo: 1.0,
|
||||
}
|
||||
|
||||
// essential properties
|
||||
right := *left
|
||||
assert.True(t, left.Equal(right))
|
||||
|
||||
left.OwnerID = someUUID
|
||||
assert.False(t, left.Equal(right))
|
||||
|
||||
right = *left
|
||||
left.TypeID = someUUID
|
||||
assert.False(t, left.Equal(right))
|
||||
|
||||
right = *left
|
||||
left.FleetID = &someUUID
|
||||
assert.False(t, left.Equal(right))
|
||||
|
||||
right = *left
|
||||
left.FleetID = nil
|
||||
assert.False(t, left.Equal(right))
|
||||
|
||||
right = *left
|
||||
left.State = "In_Space"
|
||||
assert.False(t, left.Equal(right))
|
||||
|
||||
right = *left
|
||||
left.CargoType = &cap
|
||||
assert.False(t, left.Equal(right))
|
||||
|
||||
right = *left
|
||||
left.CargoType = nil
|
||||
assert.False(t, left.Equal(right))
|
||||
|
||||
right = *left
|
||||
left.Load = 45.123
|
||||
assert.False(t, left.Equal(right))
|
||||
|
||||
right = *left
|
||||
left.Drive = 1.1
|
||||
assert.False(t, left.Equal(right))
|
||||
|
||||
right = *left
|
||||
left.Weapons = 1.1
|
||||
assert.False(t, left.Equal(right))
|
||||
|
||||
right = *left
|
||||
left.Shields = 1.1
|
||||
assert.False(t, left.Equal(right))
|
||||
|
||||
right = *left
|
||||
left.Cargo = 1.1
|
||||
assert.False(t, left.Equal(right))
|
||||
|
||||
// non-essential properties
|
||||
right = *left
|
||||
|
||||
left.Index = 2
|
||||
assert.True(t, left.Equal(right))
|
||||
left.Number = 5
|
||||
assert.True(t, left.Equal(right))
|
||||
}
|
||||
|
||||
func TestJoinEqualGroups(t *testing.T) {
|
||||
g := &game.Game{
|
||||
Race: make([]game.Race, 2),
|
||||
}
|
||||
raceIdx := 0
|
||||
g.Race[raceIdx] = game.Race{
|
||||
ID: uuid.New(),
|
||||
Name: "Race_0",
|
||||
Drive: 1.1,
|
||||
Weapons: 1.2,
|
||||
Shields: 1.3,
|
||||
Cargo: 1.4,
|
||||
}
|
||||
g.Race[1] = game.Race{
|
||||
ID: uuid.New(),
|
||||
Name: "Race_1",
|
||||
Drive: 2.1,
|
||||
Weapons: 2.2,
|
||||
Shields: 2.3,
|
||||
Cargo: 2.4,
|
||||
}
|
||||
g.Map = game.Map{
|
||||
Width: 10,
|
||||
Height: 10,
|
||||
Planet: make([]game.Planet, 3),
|
||||
}
|
||||
g.Map.Planet[0] = controller.NewPlanet(0, "Planet_0", g.Race[0].ID, 0, 0, 100, 0, 0, 0, game.ProductionNone.AsType(uuid.Nil))
|
||||
g.Map.Planet[1] = controller.NewPlanet(1, "Planet_1", g.Race[1].ID, 1, 1, 100, 0, 0, 0, game.ProductionNone.AsType(uuid.Nil))
|
||||
g.Map.Planet[2] = controller.NewPlanet(1, "Planet_2", g.Race[0].ID, 2, 2, 100, 0, 0, 0, game.ProductionNone.AsType(uuid.Nil))
|
||||
|
||||
err := g.CreateShipType("Race_0", "R0_Gunship", 60, 30, 100, 0, 3)
|
||||
assert.NoError(t, err)
|
||||
err = g.CreateShipType("Race_0", "R0_Freighter", 8, 0, 2, 10, 0)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = g.CreateShipType("Race_1", "R1_Gunship", 60, 30, 100, 0, 3)
|
||||
assert.NoError(t, err)
|
||||
err = g.CreateShipType("Race_1", "R1_Freighter", 8, 0, 2, 10, 0)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = g.CreateShips(raceIdx, "Freighter", 0, 2)
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputEntityNotExists))
|
||||
err = g.CreateShips(raceIdx, "R0_Gunship", 1, 2)
|
||||
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputEntityNotOwned))
|
||||
|
||||
err = g.CreateShips(raceIdx, "R0_Freighter", 0, 1) // 1 -> 2
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, collectGroups(g.ListShipGroups(raceIdx)), 1)
|
||||
|
||||
err = g.CreateShips(1, "R1_Freighter", 1, 1)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, collectGroups(g.ListShipGroups(1)), 1)
|
||||
|
||||
err = g.CreateShips(raceIdx, "R0_Freighter", 0, 6) // (2)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, collectGroups(g.ListShipGroups(raceIdx)), 2)
|
||||
|
||||
err = g.CreateShips(raceIdx, "R0_Gunship", 0, 2) // (3)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, collectGroups(g.ListShipGroups(raceIdx)), 3)
|
||||
|
||||
err = g.CreateShips(1, "R1_Gunship", 1, 1)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, collectGroups(g.ListShipGroups(1)), 2)
|
||||
|
||||
g.Race[raceIdx].Drive = 1.5
|
||||
err = g.CreateShips(raceIdx, "R0_Gunship", 0, 9) // 4 -> 6
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, collectGroups(g.ListShipGroups(raceIdx)), 4)
|
||||
err = g.CreateShips(raceIdx, "R0_Freighter", 0, 7) // 5 -> 7
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, collectGroups(g.ListShipGroups(raceIdx)), 5)
|
||||
err = g.CreateShips(raceIdx, "R0_Gunship", 0, 4) // (6)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, collectGroups(g.ListShipGroups(raceIdx)), 6)
|
||||
err = g.CreateShips(raceIdx, "R0_Freighter", 0, 4) // (7)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, collectGroups(g.ListShipGroups(raceIdx)), 7)
|
||||
|
||||
g.Race[1].Shields = 2.0
|
||||
err = g.CreateShips(1, "R1_Freighter", 1, 1)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, collectGroups(g.ListShipGroups(1)), 3)
|
||||
|
||||
err = g.JoinEqualGroups("Race_0")
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Len(t, collectGroups(g.ListShipGroups(1)), 3)
|
||||
assert.Len(t, collectGroups(g.ListShipGroups(raceIdx)), 4)
|
||||
|
||||
shipTypeID := func(ri int, name string) uuid.UUID {
|
||||
st := slices.IndexFunc(g.Race[ri].ShipTypes, func(v game.ShipType) bool { return v.Name == name })
|
||||
if st < 0 {
|
||||
t.Fatalf("ShipType not found: %s", name)
|
||||
return uuid.Nil
|
||||
}
|
||||
return g.Race[ri].ShipTypes[st].ID
|
||||
}
|
||||
|
||||
for _, sg := range g.ListShipGroups(raceIdx) {
|
||||
switch {
|
||||
case sg.TypeID == shipTypeID(raceIdx, "R0_Freighter") && sg.Drive == 1.1:
|
||||
assert.Equal(t, uint(7), sg.Number)
|
||||
assert.Equal(t, uint(2), sg.Index)
|
||||
case sg.TypeID == shipTypeID(raceIdx, "R0_Freighter") && sg.Drive == 1.5:
|
||||
assert.Equal(t, uint(11), sg.Number)
|
||||
assert.Equal(t, uint(7), sg.Index)
|
||||
case sg.TypeID == shipTypeID(raceIdx, "R0_Gunship") && sg.Drive == 1.1:
|
||||
assert.Equal(t, uint(2), sg.Number)
|
||||
assert.Equal(t, uint(3), sg.Index)
|
||||
case sg.TypeID == shipTypeID(raceIdx, "R0_Gunship") && sg.Drive == 1.5:
|
||||
assert.Equal(t, uint(13), sg.Number)
|
||||
assert.Equal(t, uint(6), sg.Index)
|
||||
default:
|
||||
t.Error("not all ship groups covered")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectGroups(i iter.Seq2[int, game.ShipGroup]) []game.ShipGroup {
|
||||
result := make([]game.ShipGroup, 0)
|
||||
for _, sg := range i {
|
||||
result = append(result, sg)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package game
|
||||
|
||||
type Map struct {
|
||||
Width uint32 `json:"width"`
|
||||
Height uint32 `json:"height"`
|
||||
Planet []Planet `json:"planets"`
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"github.com/google/uuid"
|
||||
e "github.com/iliadenisov/galaxy/internal/error"
|
||||
)
|
||||
|
||||
type UnidentifiedPlanet struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Number uint `json:"number"`
|
||||
}
|
||||
|
||||
type UninhabitedPlanet struct {
|
||||
UnidentifiedPlanet
|
||||
Size float64 `json:"size"`
|
||||
Name string `json:"name"`
|
||||
Resources float64 `json:"resources"` // R - Ресурсы / сырьё
|
||||
Capital float64 `json:"capital"` // CAP $ - Запасы промышленности
|
||||
Material float64 `json:"material"` // MAT M - Запасы ресурсов / сырья
|
||||
}
|
||||
|
||||
type PlanetReport struct {
|
||||
UninhabitedPlanet
|
||||
Industry float64 `json:"industry"` // I - Промышленность
|
||||
Population float64 `json:"population"` // P - Население
|
||||
Colonists float64 `json:"colonists"` // COL C - Количество колонистов
|
||||
Production ProductionType `json:"production"` // TODO: internal/report format
|
||||
// Параметр "L" - Свободный производственный потенциал
|
||||
}
|
||||
|
||||
type Planet struct {
|
||||
Owner uuid.UUID `json:"owner"`
|
||||
PlanetReport
|
||||
}
|
||||
|
||||
type PlanetReportForeign struct {
|
||||
RaceName string
|
||||
PlanetReport
|
||||
}
|
||||
|
||||
// Свободный производственный потенциал (L)
|
||||
// промышленность * 0.75 + население * 0.25
|
||||
// TODO: за вычетом затрат, расходуемых в течение хода на модернизацию кораблей
|
||||
func (p Planet) ProductionCapacity() float64 {
|
||||
return p.Industry*0.75 + p.Population*0.25
|
||||
}
|
||||
|
||||
// Производство промышленности
|
||||
// TODO: test on real values
|
||||
func (p *Planet) IncreaseIndustry() {
|
||||
prod := p.ProductionCapacity() / 5
|
||||
industryIncrement := math.Min(prod, 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
|
||||
}
|
||||
}
|
||||
|
||||
func (g Game) RenamePlanet(raceName string, planetNumber int, typeName string) error {
|
||||
ri, err := g.raceIndex(raceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.renamePlanetInternal(ri, planetNumber, typeName)
|
||||
}
|
||||
|
||||
func (g Game) renamePlanetInternal(ri int, number int, name string) error {
|
||||
n, ok := validateTypeName(name)
|
||||
if !ok {
|
||||
return e.NewEntityTypeNameValidationError("%q", n)
|
||||
}
|
||||
if number < 0 {
|
||||
return e.NewPlanetNumberError(number)
|
||||
}
|
||||
pl := slices.IndexFunc(g.Map.Planet, func(p Planet) bool { return p.Number == uint(number) })
|
||||
if pl < 0 {
|
||||
return e.NewEntityNotExistsError("planet #%d", number)
|
||||
}
|
||||
if g.Map.Planet[pl].Owner != g.Race[ri].ID {
|
||||
return e.NewEntityNotOwnedError("planet %#d", number)
|
||||
}
|
||||
g.Map.Planet[pl].Name = n
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/google/uuid"
|
||||
e "github.com/iliadenisov/galaxy/internal/error"
|
||||
)
|
||||
|
||||
type PlanetProduction string
|
||||
|
||||
const (
|
||||
ProductionNone PlanetProduction = "-"
|
||||
ProductionMaterial PlanetProduction = "MAT" // Сырьё
|
||||
ProductionCapital PlanetProduction = "CAP" // Промышленность
|
||||
|
||||
ResearchDrive PlanetProduction = "DRIVE"
|
||||
ResearchWeapons PlanetProduction = "WEAPONS"
|
||||
ResearchShields PlanetProduction = "SHIELDS"
|
||||
ResearchCargo PlanetProduction = "CARGO"
|
||||
|
||||
ResearchScience PlanetProduction = "SCIENCE"
|
||||
ProductionShip PlanetProduction = "SHIP"
|
||||
)
|
||||
|
||||
type ProductionType struct {
|
||||
Production PlanetProduction `json:"type"`
|
||||
SubjectID *uuid.UUID `json:"subjectId"`
|
||||
Progress *float64 `json:"progress"`
|
||||
}
|
||||
|
||||
func (p PlanetProduction) AsType(subject uuid.UUID) ProductionType {
|
||||
switch p {
|
||||
case ResearchScience, ProductionShip:
|
||||
return ProductionType{Production: p, SubjectID: &subject}
|
||||
default:
|
||||
return ProductionType{Production: p, SubjectID: nil}
|
||||
}
|
||||
}
|
||||
|
||||
func (g Game) PlanetProduction(raceName string, planetNumber int, prodType, subject string) error {
|
||||
ri, err := g.raceIndex(raceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var prod PlanetProduction
|
||||
switch PlanetProduction(prodType) {
|
||||
case ProductionMaterial:
|
||||
prod = ProductionMaterial
|
||||
case ProductionCapital:
|
||||
prod = ProductionCapital
|
||||
case ResearchDrive:
|
||||
prod = ResearchDrive
|
||||
case ResearchWeapons:
|
||||
prod = ResearchWeapons
|
||||
case ResearchShields:
|
||||
prod = ResearchShields
|
||||
case ResearchCargo:
|
||||
prod = ResearchCargo
|
||||
case ResearchScience:
|
||||
prod = ResearchScience
|
||||
case ProductionShip:
|
||||
prod = ProductionShip
|
||||
default:
|
||||
return e.NewProductionInvalidError(prodType)
|
||||
}
|
||||
return g.planetProductionInternal(ri, planetNumber, prod, subject)
|
||||
}
|
||||
|
||||
func (g Game) planetProductionInternal(ri int, number int, prod PlanetProduction, subj string) error {
|
||||
if number < 0 {
|
||||
return e.NewPlanetNumberError(number)
|
||||
}
|
||||
i := slices.IndexFunc(g.Map.Planet, func(p Planet) bool { return p.Number == uint(number) })
|
||||
if i < 0 {
|
||||
return e.NewEntityNotExistsError("planet #%d", number)
|
||||
}
|
||||
if g.Map.Planet[i].Owner != g.Race[ri].ID {
|
||||
return e.NewEntityNotOwnedError("planet %#d", number)
|
||||
}
|
||||
g.Map.Planet[i].Production.Progress = nil
|
||||
var subjectID *uuid.UUID
|
||||
if (prod == ResearchScience || prod == ProductionShip) && subj == "" {
|
||||
return e.NewEntityTypeNameValidationError("%s=%q", prod, subj)
|
||||
}
|
||||
if prod == ResearchScience {
|
||||
i := slices.IndexFunc(g.Race[ri].Sciences, func(s Science) bool { return s.Name == subj })
|
||||
if i < 0 {
|
||||
return e.NewEntityNotExistsError("science %w", subj)
|
||||
}
|
||||
subjectID = &g.Race[ri].Sciences[i].ID
|
||||
}
|
||||
if prod == ProductionShip {
|
||||
i := slices.IndexFunc(g.Race[ri].ShipTypes, func(st ShipType) bool { return st.Name == subj })
|
||||
if i < 0 {
|
||||
return e.NewEntityNotExistsError("ship type %w", subj)
|
||||
}
|
||||
if g.Map.Planet[i].Production.Production == ProductionShip &&
|
||||
g.Map.Planet[i].Production.SubjectID != nil &&
|
||||
*g.Map.Planet[i].Production.SubjectID == g.Race[ri].ShipTypes[i].ID {
|
||||
// Planet already produces this ship type, keeping progress intact
|
||||
return nil
|
||||
}
|
||||
subjectID = &g.Race[ri].ShipTypes[i].ID
|
||||
var progress float64 = 0.
|
||||
g.Map.Planet[i].Production.Progress = &progress
|
||||
}
|
||||
if g.Map.Planet[i].Production.Production == ProductionShip {
|
||||
if g.Map.Planet[i].Production.SubjectID == nil {
|
||||
return e.NewGameStateError("planet #%d produces ship but SubjectID is empty", g.Map.Planet[i].Number)
|
||||
}
|
||||
s := *g.Map.Planet[i].Production.SubjectID
|
||||
if g.Map.Planet[i].Production.Progress == nil {
|
||||
return e.NewGameStateError("planet #%d produces ship but Progress is empty", g.Map.Planet[i].Number)
|
||||
}
|
||||
progress := *g.Map.Planet[i].Production.Progress
|
||||
i := slices.IndexFunc(g.Race[ri].ShipTypes, func(st ShipType) bool { return st.ID == s })
|
||||
if i < 0 {
|
||||
return e.NewGameStateError("planet #%d produces ship but ShipType was not found for race %s", g.Map.Planet[i].Number, g.Race[ri].Name)
|
||||
}
|
||||
mat, _ := g.Race[ri].ShipTypes[i].ProductionCost()
|
||||
extra := mat * progress
|
||||
g.Map.Planet[i].Material += extra
|
||||
}
|
||||
g.Map.Planet[i].Production.Production = prod
|
||||
g.Map.Planet[i].Production.SubjectID = subjectID
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package game
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type Race struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Extinct bool `json:"extinct"`
|
||||
|
||||
Vote uuid.UUID `json:"vote"`
|
||||
Relations []RaceRelation `json:"relations"`
|
||||
|
||||
Drive float64 `json:"drive"`
|
||||
Weapons float64 `json:"weapons"`
|
||||
Shields float64 `json:"shields"`
|
||||
Cargo float64 `json:"cargo"`
|
||||
|
||||
Sciences []Science `json:"science,omitempty"`
|
||||
|
||||
ShipTypes []ShipType `json:"shipType,omitempty"`
|
||||
}
|
||||
|
||||
type Relation string
|
||||
|
||||
const (
|
||||
RelationWar Relation = "War"
|
||||
RelationPeace Relation = "Peace"
|
||||
)
|
||||
|
||||
type RaceRelation struct {
|
||||
RaceID uuid.UUID `json:"raceId"`
|
||||
Relation Relation `json:"relation"`
|
||||
}
|
||||
|
||||
func (r Race) FlightDistance() float64 {
|
||||
return r.Drive * 40
|
||||
}
|
||||
|
||||
func (r Race) VisibilityDistance() float64 {
|
||||
return r.Drive * 30
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package game
|
||||
|
||||
type Report struct {
|
||||
Width, Height uint32
|
||||
PlanetCount uint32 // do we need that?
|
||||
PlayersLeft uint32 // do we need that?
|
||||
|
||||
Votes float64
|
||||
VoteFor string
|
||||
|
||||
Statuses []PlayerStatus
|
||||
|
||||
Sciences []ScienceReport
|
||||
ForeignSciences []ScienceReportForeign
|
||||
|
||||
ShipTypes []ShipTypeReport
|
||||
ForeignShipTypes []ShipTypeReportForeign
|
||||
|
||||
Battles []any // TODO: tbd
|
||||
|
||||
Bombings []any // TODO: tbd
|
||||
|
||||
IncomingGroups []IncomingGroup
|
||||
|
||||
Planets []PlanetReport
|
||||
ForeignPlanets []PlanetReportForeign
|
||||
UninhabitedPlanets []UninhabitedPlanet
|
||||
UnidentifiedPlanets []UnidentifiedPlanet
|
||||
|
||||
ShipsInProduction []any // TODO: tbd
|
||||
|
||||
Routes []any // TODO: tbd
|
||||
|
||||
Fleets []any // TODO: tbd
|
||||
|
||||
ShipGroups []any // TODO: tbd
|
||||
|
||||
ForeignShipGroups []any // TODO: tbd
|
||||
|
||||
UnidentifiedGroups []any // TODO: tbd
|
||||
}
|
||||
|
||||
type IncomingGroup struct {
|
||||
SourcePlanetNumber uint
|
||||
TargetPlanetNumber uint
|
||||
Distance float64
|
||||
Speed float64
|
||||
Mass float64
|
||||
}
|
||||
|
||||
type ReportRelation struct {
|
||||
RaceName string
|
||||
Relation string
|
||||
}
|
||||
|
||||
type PlayerStatus struct {
|
||||
Name string
|
||||
Drive float64 `json:"drive"`
|
||||
Weapons float64 `json:"weapons"`
|
||||
Shields float64 `json:"shields"`
|
||||
Cargo float64 `json:"cargo"`
|
||||
Population float64
|
||||
Industry float64
|
||||
Planets uint16
|
||||
Relation ReportRelation
|
||||
Votes float64
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/google/uuid"
|
||||
e "github.com/iliadenisov/galaxy/internal/error"
|
||||
)
|
||||
|
||||
type Science struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ScienceReport
|
||||
}
|
||||
|
||||
type ScienceReportForeign struct {
|
||||
RaceName string
|
||||
ScienceReport
|
||||
}
|
||||
|
||||
type ScienceReport struct {
|
||||
Name string `json:"name"`
|
||||
Drive float64 `json:"drive"`
|
||||
Weapons float64 `json:"weapons"`
|
||||
Shields float64 `json:"shields"`
|
||||
Cargo float64 `json:"cargo"`
|
||||
}
|
||||
|
||||
func (g Game) Sciences(raceName string) ([]Science, error) {
|
||||
ri, err := g.raceIndex(raceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g.sciencesInternal(ri), nil
|
||||
}
|
||||
|
||||
func (g Game) sciencesInternal(ri int) []Science {
|
||||
return g.Race[ri].Sciences
|
||||
}
|
||||
|
||||
func (g Game) DeleteScience(raceName, typeName string) error {
|
||||
ri, err := g.raceIndex(raceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.deleteScienceInternal(ri, typeName)
|
||||
}
|
||||
|
||||
func (g Game) deleteScienceInternal(ri int, name string) error {
|
||||
sc := slices.IndexFunc(g.Race[ri].Sciences, func(s Science) bool { return s.Name == name })
|
||||
if sc < 0 {
|
||||
return e.NewEntityNotExistsError("science %w", name)
|
||||
}
|
||||
if pl := slices.IndexFunc(g.Map.Planet, func(p Planet) bool {
|
||||
return p.Production.Production == ResearchScience &&
|
||||
p.Production.SubjectID != nil &&
|
||||
*p.Production.SubjectID == g.Race[ri].Sciences[sc].ID
|
||||
}); pl >= 0 {
|
||||
return e.NewDeleteSciencePlanetProductionError(g.Map.Planet[pl].Name)
|
||||
}
|
||||
g.Race[ri].Sciences = append(g.Race[ri].Sciences[:sc], g.Race[ri].Sciences[sc+1:]...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g Game) CreateScience(raceName, typeName string, d, w, s, c float64) error {
|
||||
ri, err := g.raceIndex(raceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.createScienceInternal(ri, typeName, d, w, s, c)
|
||||
}
|
||||
|
||||
func (g Game) createScienceInternal(ri int, name string, d, w, s, c float64) error {
|
||||
n, ok := validateTypeName(name)
|
||||
if !ok {
|
||||
return e.NewEntityTypeNameValidationError("%q", n)
|
||||
}
|
||||
if sc := slices.IndexFunc(g.Race[ri].Sciences, func(s Science) bool { return s.Name == n }); sc >= 0 {
|
||||
return e.NewEntityTypeNameDuplicateError("science %w", g.Race[ri].Sciences[sc].Name)
|
||||
}
|
||||
if d < 0 {
|
||||
return e.NewDriveValueError(d)
|
||||
}
|
||||
if w < 0 {
|
||||
return e.NewWeaponsValueError(w)
|
||||
}
|
||||
if s < 0 {
|
||||
return e.NewShieldsValueError(s)
|
||||
}
|
||||
if c < 0 {
|
||||
return e.NewCargoValueError(c)
|
||||
}
|
||||
sum := d + w + s + c
|
||||
if sum != 1 {
|
||||
return e.NewScienceSumValuesError("D=%f W=%f S=%f C=%f sum=%f", d, w, s, c, sum)
|
||||
}
|
||||
g.Race[ri].Sciences = append(g.Race[ri].Sciences, Science{
|
||||
ID: uuid.New(),
|
||||
ScienceReport: ScienceReport{
|
||||
Name: n,
|
||||
Drive: d,
|
||||
Weapons: w,
|
||||
Shields: s,
|
||||
Cargo: c,
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package game
|
||||
|
||||
type GameParameter struct {
|
||||
Series string
|
||||
Players uint
|
||||
Public bool
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/google/uuid"
|
||||
e "github.com/iliadenisov/galaxy/internal/error"
|
||||
)
|
||||
|
||||
type ShipTypeReport struct {
|
||||
Name string `json:"name"`
|
||||
Drive float64 `json:"drive"`
|
||||
Armament uint `json:"armament"`
|
||||
Weapons float64 `json:"weapons"`
|
||||
Shields float64 `json:"shields"`
|
||||
Cargo float64 `json:"cargo"`
|
||||
}
|
||||
|
||||
type ShipType struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ShipTypeReport
|
||||
}
|
||||
|
||||
type ShipTypeReportForeign struct {
|
||||
RaceName string
|
||||
ShipTypeReport
|
||||
}
|
||||
|
||||
func (st ShipType) Equal(o ShipType) bool {
|
||||
return st.Drive == o.Drive &&
|
||||
st.Weapons == o.Weapons &&
|
||||
st.Armament == o.Armament &&
|
||||
st.Shields == o.Shields &&
|
||||
st.Cargo == o.Cargo
|
||||
}
|
||||
|
||||
func (st ShipType) EmptyMass() float64 {
|
||||
shipMass := st.Drive + st.Shields + st.Cargo + st.WeaponsMass()
|
||||
return shipMass
|
||||
}
|
||||
|
||||
func (st ShipType) WeaponsMass() float64 {
|
||||
if st.Armament == 0 || st.Weapons == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(st.Armament+1) * (st.Weapons / 2)
|
||||
}
|
||||
|
||||
// ProductionCost returns Material (MAT) and Population (POP) to produce this [ShipType]
|
||||
func (st ShipType) ProductionCost() (mat float64, pop float64) {
|
||||
mat = st.EmptyMass()
|
||||
pop = mat * 10
|
||||
return
|
||||
}
|
||||
|
||||
func (g Game) mustShipType(id uuid.UUID) *ShipType {
|
||||
for ri := range g.Race {
|
||||
if st := slices.IndexFunc(g.Race[ri].ShipTypes, func(st ShipType) bool { return st.ID == id }); st >= 0 {
|
||||
return &g.Race[ri].ShipTypes[st]
|
||||
}
|
||||
}
|
||||
panic(fmt.Sprintf("mustShipType: ShipType not found: %v", id))
|
||||
}
|
||||
|
||||
func (g Game) ShipTypes(raceName string) ([]ShipType, error) {
|
||||
ri, err := g.raceIndex(raceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g.shipTypesInternal(ri), nil
|
||||
}
|
||||
|
||||
func (g Game) shipTypesInternal(ri int) []ShipType {
|
||||
return g.Race[ri].ShipTypes
|
||||
}
|
||||
|
||||
func (g Game) DeleteShipType(raceName, typeName string) error {
|
||||
ri, err := g.raceIndex(raceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.deleteShipTypeInternal(ri, typeName)
|
||||
}
|
||||
|
||||
func (g Game) deleteShipTypeInternal(ri int, name string) error {
|
||||
st := slices.IndexFunc(g.Race[ri].ShipTypes, func(st ShipType) bool { return st.Name == name })
|
||||
if st < 0 {
|
||||
return e.NewEntityNotExistsError("ship type %w", name)
|
||||
}
|
||||
if pl := slices.IndexFunc(g.Map.Planet, func(p Planet) bool {
|
||||
return p.Production.Production == ProductionShip &&
|
||||
p.Production.SubjectID != nil &&
|
||||
g.Race[ri].ShipTypes[st].ID == *p.Production.SubjectID
|
||||
}); pl >= 0 {
|
||||
return e.NewDeleteShipTypePlanetProductionError(g.Map.Planet[pl].Name)
|
||||
}
|
||||
g.Race[ri].ShipTypes = append(g.Race[ri].ShipTypes[:st], g.Race[ri].ShipTypes[st+1:]...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g Game) CreateShipType(raceName, typeName string, d, w, s, c float64, a int) error {
|
||||
ri, err := g.raceIndex(raceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.createShipTypeInternal(ri, typeName, d, w, s, c, a)
|
||||
}
|
||||
|
||||
func (g Game) createShipTypeInternal(ri int, name string, d, w, s, c float64, a int) error {
|
||||
if err := checkShipTypeValues(d, w, s, c, a); err != nil {
|
||||
return err
|
||||
}
|
||||
n, ok := validateTypeName(name)
|
||||
if !ok {
|
||||
return e.NewEntityTypeNameValidationError("%q", n)
|
||||
}
|
||||
if st := slices.IndexFunc(g.Race[ri].ShipTypes, func(st ShipType) bool { return st.Name == name }); st >= 0 {
|
||||
return e.NewEntityTypeNameDuplicateError("ship type %w", g.Race[ri].ShipTypes[st].Name)
|
||||
}
|
||||
g.Race[ri].ShipTypes = append(g.Race[ri].ShipTypes, ShipType{
|
||||
ID: uuid.New(),
|
||||
ShipTypeReport: ShipTypeReport{
|
||||
Name: n,
|
||||
Drive: d,
|
||||
Weapons: w,
|
||||
Shields: s,
|
||||
Cargo: c,
|
||||
Armament: uint(a),
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g Game) MergeShipType(race, name, targetName string) error {
|
||||
ri, err := g.raceIndex(race)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.mergeShipTypeInternal(ri, name, targetName)
|
||||
}
|
||||
|
||||
func (g Game) mergeShipTypeInternal(ri int, name, targetName string) error {
|
||||
st := slices.IndexFunc(g.Race[ri].ShipTypes, func(st ShipType) bool { return st.Name == name })
|
||||
if st < 0 {
|
||||
return e.NewEntityNotExistsError("source ship type %w", name)
|
||||
}
|
||||
if name == targetName {
|
||||
return e.NewEntityTypeNameEqualityError("ship type %q", targetName)
|
||||
}
|
||||
tt := slices.IndexFunc(g.Race[ri].ShipTypes, func(st ShipType) bool { return st.Name == targetName })
|
||||
if tt < 0 {
|
||||
return e.NewEntityNotExistsError("target ship type %w", name)
|
||||
}
|
||||
if !g.Race[ri].ShipTypes[st].Equal(g.Race[ri].ShipTypes[tt]) {
|
||||
return e.NewMergeShipTypeNotEqualError()
|
||||
}
|
||||
|
||||
// switch planet productions to the new type
|
||||
for pl := range g.Map.Planet {
|
||||
if g.Map.Planet[pl].Owner == g.Race[ri].ID &&
|
||||
g.Map.Planet[pl].Production.Production == ProductionShip &&
|
||||
g.Map.Planet[pl].Production.SubjectID != nil &&
|
||||
*g.Map.Planet[pl].Production.SubjectID == g.Race[ri].ShipTypes[st].ID {
|
||||
g.Map.Planet[pl].Production.SubjectID = &g.Race[ri].ShipTypes[tt].ID
|
||||
}
|
||||
}
|
||||
|
||||
// switch ship groups to the new type
|
||||
for sg := range g.ShipGroups {
|
||||
if g.ShipGroups[sg].OwnerID == g.Race[ri].ID && g.ShipGroups[sg].TypeID == g.Race[ri].ShipTypes[st].ID {
|
||||
g.ShipGroups[sg].TypeID = g.Race[ri].ShipTypes[tt].ID
|
||||
}
|
||||
}
|
||||
|
||||
// remove the source type
|
||||
g.Race[ri].ShipTypes = append(g.Race[ri].ShipTypes[:st], g.Race[ri].ShipTypes[st+1:]...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkShipTypeValues(d, w, s, c float64, a int) error {
|
||||
if !checkShipTypeValueDWSC(d) {
|
||||
return e.NewDriveValueError(d)
|
||||
}
|
||||
if !checkShipTypeValueDWSC(w) {
|
||||
return e.NewWeaponsValueError(w)
|
||||
}
|
||||
if !checkShipTypeValueDWSC(s) {
|
||||
return e.NewShieldsValueError(s)
|
||||
}
|
||||
if !checkShipTypeValueDWSC(c) {
|
||||
return e.NewCargoValueError(s)
|
||||
}
|
||||
if a < 0 {
|
||||
return e.NewShipTypeArmamentValueError(a)
|
||||
}
|
||||
if (w == 0 && a > 0) || (a == 0 && w > 0) {
|
||||
return e.NewShipTypeArmamentAndWeaponsValueError("A=%d W=%.0f", a, w)
|
||||
}
|
||||
if d == 0 && w == 0 && s == 0 && c == 0 && a == 0 {
|
||||
return e.NewShipTypeShipTypeZeroValuesError()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkShipTypeValueDWSC(v float64) bool {
|
||||
return v == 0 || v >= 1
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package game_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/model/game"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestEmptyMass(t *testing.T) {
|
||||
Freighter := game.ShipType{
|
||||
ShipTypeReport: game.ShipTypeReport{
|
||||
Name: "Freighter",
|
||||
Drive: 8,
|
||||
Armament: 0,
|
||||
Weapons: 0,
|
||||
Shields: 2,
|
||||
Cargo: 10,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, 20., Freighter.EmptyMass())
|
||||
|
||||
Gunship := game.ShipType{
|
||||
ShipTypeReport: game.ShipTypeReport{
|
||||
Name: "Gunship",
|
||||
Drive: 4,
|
||||
Armament: 2,
|
||||
Weapons: 2,
|
||||
Shields: 4,
|
||||
Cargo: 0,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, 11., Gunship.EmptyMass())
|
||||
|
||||
Cruiser := game.ShipType{
|
||||
ShipTypeReport: game.ShipTypeReport{
|
||||
Name: "Cruiser",
|
||||
Drive: 15,
|
||||
Armament: 1,
|
||||
Weapons: 15,
|
||||
Shields: 15,
|
||||
Cargo: 0,
|
||||
},
|
||||
}
|
||||
assert.Equal(t, 45., Cruiser.EmptyMass())
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package number
|
||||
|
||||
import "math"
|
||||
|
||||
func Fixed3(num float64) float64 {
|
||||
return fixed(num, 3)
|
||||
}
|
||||
|
||||
func fixed(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))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package number
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFixed(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
precision int
|
||||
source, expected float64
|
||||
}{
|
||||
{3, 0, 0},
|
||||
{3, -1, -1},
|
||||
{3, 1.5, 1.5},
|
||||
{3, 2.25, 2.25},
|
||||
{3, 3.275, 3.275},
|
||||
{3, 4.0004, 4.000},
|
||||
{5, 5.000005, 5.00001},
|
||||
{4, -6.00004, -6.},
|
||||
{4, -6.00005, -6.0001},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%f", tc.source), func(t *testing.T) {
|
||||
if tc.precision == 3 {
|
||||
assert.Equal(t, tc.expected, Fixed3(tc.source))
|
||||
} else {
|
||||
assert.Equal(t, tc.expected, fixed(tc.source, tc.precision))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"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) Exists(path string) (bool, error) {
|
||||
return fileExists(filepath.Join(f.root, path))
|
||||
}
|
||||
|
||||
func (f *fs) Write(path string, v encoding.BinaryMarshaler) error {
|
||||
if v == nil {
|
||||
return errors.New("cant't marshal from nil object")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
data, err := v.MarshalBinary()
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal data: %s", err)
|
||||
}
|
||||
|
||||
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, v encoding.BinaryUnmarshaler) error {
|
||||
if v == nil {
|
||||
return errors.New("can't unmarshal to a nil object")
|
||||
}
|
||||
|
||||
if f.lock == nil {
|
||||
return errors.New("lock must be acquired before read")
|
||||
}
|
||||
|
||||
targetFilePath := filepath.Join(f.root, path)
|
||||
if targetFilePath == f.lockFilePath() {
|
||||
return errors.New("can't read from the lock file")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(targetFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading data file: %s", err)
|
||||
}
|
||||
|
||||
return v.UnmarshalBinary(data)
|
||||
}
|
||||
|
||||
func (f *fs) lockFilePath() string {
|
||||
return filepath.Join(f.root, lockFile)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewFileStorageSuccess(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
_, err := NewFileStorage(root)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestLock(t *testing.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")
|
||||
lockPath := filepath.Join(root, lockFile)
|
||||
assert.FileExists(t, lockPath, "lock file should be created")
|
||||
err = unlock()
|
||||
assert.NoError(t, err, "unlocking existing lock")
|
||||
assert.NoFileExists(t, lockPath, "lock file must be removed")
|
||||
}
|
||||
|
||||
func TestExist(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
|
||||
fileName := "some-file.ext"
|
||||
if err := os.WriteFile(filepath.Join(root, fileName), []byte{1, 2, 3, 4}, os.ModePerm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fs, err := NewFileStorage(root)
|
||||
assert.NoError(t, err, "create file storage")
|
||||
|
||||
exist, err := fs.Exists(fileName)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, exist)
|
||||
|
||||
exist, err = fs.Exists("random/path")
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, exist)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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) {
|
||||
sd := &sampleData{[]byte{0, 1, 2, 3}}
|
||||
err = fs.Write(tc.path, sd)
|
||||
if tc.err == "" {
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(root, tc.path), "the written file should exist")
|
||||
} else if tc.err != "" {
|
||||
assert.ErrorContains(t, err, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
err string
|
||||
}{
|
||||
{path: fileName},
|
||||
{path: "/" + fileName},
|
||||
{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) {
|
||||
err = fs.Read(tc.path, sd)
|
||||
if tc.err == "" {
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(root, tc.path), "the written file should exist")
|
||||
} else if tc.err != "" {
|
||||
assert.ErrorContains(t, err, tc.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
assert.NoError(t, unlock(), "unlocking existing lock")
|
||||
}
|
||||
|
||||
func TestWriteErrorWithoutLock(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
fs, err := NewFileStorage(root)
|
||||
assert.NoError(t, err, "create file storage")
|
||||
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.EqualError(t, err, "lock must be acquired before write")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"slices"
|
||||
)
|
||||
|
||||
const (
|
||||
nonWritableDir = "/usr/lib"
|
||||
)
|
||||
|
||||
type sampleData struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (sd *sampleData) UnmarshalBinary(data []byte) error {
|
||||
sd.data = slices.Clone(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sd sampleData) MarshalBinary() (data []byte, err error) {
|
||||
return sd.data, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/iliadenisov/galaxy/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPathExists(t *testing.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 := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
testDirExistsFunc(t, root, dirExists)
|
||||
}
|
||||
|
||||
func TestFileExists(t *testing.T) {
|
||||
root, cleanup := util.CreateWorkDir(t)
|
||||
defer cleanup()
|
||||
testFileExistsFunc(t, root, fileExists)
|
||||
}
|
||||
|
||||
func TestWritable(t *testing.T) {
|
||||
root, cleanup := util.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()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package repo
|
||||
|
||||
/*
|
||||
/state.json
|
||||
/000/state.json
|
||||
/000/race/{UUID}/order/001.json
|
||||
/000/race/{UUID}/report.json
|
||||
/000/battle/{planet_UUID}
|
||||
*/
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/iliadenisov/galaxy/internal/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.Exists(path)
|
||||
if err != nil {
|
||||
return NewStorageError(err)
|
||||
}
|
||||
if exist {
|
||||
return NewStateError(fmt.Sprintf("state for turn %d already saved", t))
|
||||
}
|
||||
if err := s.Write(path, g); err != nil {
|
||||
return NewStorageError(err)
|
||||
}
|
||||
// TODO: save reports
|
||||
for i := range g.Race {
|
||||
saveRace(s, g, i)
|
||||
}
|
||||
// TODO: save battles
|
||||
return saveState(s, g) // FIXME: either save it here, or in tre repo controller
|
||||
}
|
||||
|
||||
func saveRace(s Storage, g game.Game, i int) {
|
||||
|
||||
}
|
||||
|
||||
func (r *repo) SaveState(g game.Game) error {
|
||||
return saveState(r.s, g)
|
||||
}
|
||||
|
||||
func saveState(s Storage, g game.Game) error {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"errors"
|
||||
|
||||
e "github.com/iliadenisov/galaxy/internal/error"
|
||||
"github.com/iliadenisov/galaxy/internal/repo/fs"
|
||||
)
|
||||
|
||||
func NewStorageError(err error) error {
|
||||
return e.NewRepoError(err)
|
||||
}
|
||||
|
||||
func NewStateError(msg string) error {
|
||||
return e.NewGameStateError(msg)
|
||||
}
|
||||
|
||||
type Storage interface {
|
||||
Lock() (func() error, error)
|
||||
Exists(string) (bool, error)
|
||||
Write(string, encoding.BinaryMarshaler) error
|
||||
Read(string, encoding.BinaryUnmarshaler) error
|
||||
}
|
||||
|
||||
type repo struct {
|
||||
s Storage
|
||||
release func() error
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user