support multi-module (#4)

* add multimodule
* re-package modules
This commit is contained in:
Ilia Denisov
2026-02-22 08:57:19 +02:00
committed by GitHub
parent 9e36d7151e
commit 8f982278d2
132 changed files with 317 additions and 191 deletions
@@ -0,0 +1,6 @@
░░██████░░░░
██░░░░░░██░░
██░░░░░░██░░
██░░░░░░██░░
░░██████░░░░
░░░░░░░░░░░░
@@ -0,0 +1,6 @@
████░░░░░░██
██████░░████
██████░░████
██████░░████
████░░░░░░██
░░░░░░░░░░░░
@@ -0,0 +1,12 @@
░░░░░░██████████░░░░░░░░
░░░░██████████████░░░░░░
░░██████████████████░░░░
██████████████████████░░
██████████████████████░░
██████████████████████░░
██████████████████████░░
██████████████████████░░
░░██████████████████░░░░
░░░░██████████████░░░░░░
░░░░░░██████████░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░
@@ -0,0 +1,12 @@
██░░░░░░░░░░░░░░░░░░██░░
██░░░░░░░░░░░░░░░░░░██░░
██░░░░░░░░░░░░░░░░░░██░░
░░██░░░░░░░░░░░░░░██░░░░
░░░░██░░░░░░░░░░██░░░░░░
░░░░░░██████████░░░░░░░░
░░░░░░░░░░░░░░░░░░░░░░░░
░░░░░░██████████░░░░░░░░
░░░░██░░░░░░░░░░██░░░░░░
░░██░░░░░░░░░░░░░░██░░░░
██░░░░░░░░░░░░░░░░░░██░░
██░░░░░░░░░░░░░░░░░░██░░
@@ -0,0 +1,15 @@
░░░░░░░░░░██████████░░░░░░░░░░
░░░░░░████░░░░░░░░░░████░░░░░░
░░░░██░░░░░░░░░░░░░░░░░░██░░░░
░░██░░░░░░░░░░░░░░░░░░░░░░██░░
░░██░░░░░░░░░░░░░░░░░░░░░░██░░
██░░░░░░░░░░░░░░░░░░░░░░░░░░██
██░░░░░░░░░░░░░░░░░░░░░░░░░░██
██░░░░░░░░░░░░░░░░░░░░░░░░░░██
██░░░░░░░░░░░░░░░░░░░░░░░░░░██
██░░░░░░░░░░░░░░░░░░░░░░░░░░██
░░██░░░░░░░░░░░░░░░░░░░░░░██░░
░░██░░░░░░░░░░░░░░░░░░░░░░██░░
░░░░██░░░░░░░░░░░░░░░░░░██░░░░
░░░░░░████░░░░░░░░░░████░░░░░░
░░░░░░░░░░██████████░░░░░░░░░░
+173
View File
@@ -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
}
+249
View File
@@ -0,0 +1,249 @@
package bitmap_test
import (
"fmt"
"math/rand"
"os"
"strings"
"testing"
"github.com/iliadenisov/galaxy/server/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)
}
})
}
}
+9
View File
@@ -0,0 +1,9 @@
package bitmap
import "slices"
func (p Bitmap) value() []uint32 {
return slices.Clone(p.bitVector)
}
var Value = (Bitmap).value
+231
View File
@@ -0,0 +1,231 @@
package controller
import (
"iter"
"maps"
"math"
"math/rand/v2"
"slices"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
type Battle struct {
ID uuid.UUID
Planet uint
ObserverGroups map[int]bool // True = In_Battle, False = Out_Battle
InitialNumbers map[int]uint // Initial number of ships in the group
Protocol []BattleAction
shipAmmo map[int]uint
attacker map[int]map[int]float64 // a group able to attack and destroy an opponent with some probability > 0
}
type BattleAction struct {
Attacker int
Defender int
Destroyed bool
}
func CollectPlanetGroups(c *Cache) map[uint]map[int]bool {
planetGroup := make(map[uint]map[int]bool)
for groupIndex := range c.ShipGroupsIndex() {
state := c.ShipGroup(groupIndex).State()
if state == game.StateInOrbit || state == game.StateUpgrade {
planetNumber := c.ShipGroup(groupIndex).Destination
if _, ok := planetGroup[planetNumber]; !ok {
planetGroup[planetNumber] = make(map[int]bool)
}
planetGroup[planetNumber][groupIndex] = false
}
}
for pl := range planetGroup {
if len(planetGroup[pl]) < 2 {
delete(planetGroup, pl)
}
}
return planetGroup
}
func FilterBattleGroups(c *Cache, groups map[int]bool) []int {
return slices.DeleteFunc(slices.Collect(maps.Keys(groups)), func(groupIndex int) bool { return c.ShipGroup(groupIndex).State() != game.StateInOrbit })
}
func FilterBattleOpponents(c *Cache, attIdx, defIdx int, cacheProbability map[int]map[int]float64) bool {
// Same Race's groups can't attack themselves
if attIdx == defIdx || c.ShipGroupOwnerRaceIndex(attIdx) == c.ShipGroupOwnerRaceIndex(defIdx) {
return true
}
// If any opponent has War relation to another, both will stay in battle
if c.Relation(c.ShipGroupOwnerRaceIndex(attIdx), c.ShipGroupOwnerRaceIndex(defIdx)) == game.RelationPeace &&
c.Relation(c.ShipGroupOwnerRaceIndex(defIdx), c.ShipGroupOwnerRaceIndex(attIdx)) == game.RelationPeace {
return true
}
p := DestructionProbability(
c.ShipGroupShipClass(attIdx).Weapons.F(),
c.ShipGroup(attIdx).TechLevel(game.TechWeapons).F(),
c.ShipGroupShipClass(defIdx).Shields.F(),
c.ShipGroup(defIdx).TechLevel(game.TechShields).F(),
c.ShipGroup(defIdx).FullMass(c.ShipGroupShipClass(defIdx)),
)
// Exclude opponent's group which cannot be probably destroyed
if p <= 0 {
return true
}
if _, ok := cacheProbability[attIdx]; !ok {
cacheProbability[attIdx] = make(map[int]float64)
}
cacheProbability[attIdx][defIdx] = p
return false
}
func ProduceBattles(c *Cache) []*Battle {
cacheProbability := make(map[int]map[int]float64)
defer func() { clear(cacheProbability) }()
planetGroups := CollectPlanetGroups(c)
if len(planetGroups) == 0 {
return nil
}
result := make([]*Battle, 0)
// Multiple battles on single planet shoul be produced as single battle:
// A <--> B
// C <--> D
// where: [A] and [B] are mutual enemies, as well [C] and [D]
for pl, observerGroups := range planetGroups {
battleGroups := FilterBattleGroups(c, observerGroups)
b := &Battle{
Planet: pl,
ObserverGroups: observerGroups,
InitialNumbers: make(map[int]uint),
attacker: make(map[int]map[int]float64),
shipAmmo: make(map[int]uint),
}
for sgi := range observerGroups {
b.InitialNumbers[sgi] = c.ShipGroup(sgi).Number
}
for i := range battleGroups {
attIdx := battleGroups[i]
// Ships with no Ammo will never attack somebody
if c.ShipGroupShipClass(attIdx).Armament == 0 {
continue
}
opponents := slices.DeleteFunc(slices.Clone(battleGroups), func(defIdx int) bool {
return FilterBattleOpponents(c, attIdx, defIdx, cacheProbability)
})
if len(opponents) > 0 {
b.shipAmmo[attIdx] = c.ShipGroupShipClass(attIdx).Armament
b.ObserverGroups[attIdx] = true
for _, defIdx := range opponents {
if _, ok := b.attacker[attIdx][defIdx]; !ok {
b.attacker[attIdx] = make(map[int]float64)
}
b.attacker[attIdx][defIdx] = cacheProbability[attIdx][defIdx]
b.ObserverGroups[defIdx] = true
}
}
}
if len(b.attacker) > 0 {
SingleBattle(c, b)
b.ID = uuid.New()
result = append(result, b)
}
clear(b.attacker)
clear(b.shipAmmo)
}
return result
}
func SingleBattle(c *Cache, b *Battle) {
roundShooters := make(map[int]bool)
for len(b.attacker) > 0 {
// список участников раунда
clear(roundShooters)
for sgi := range b.attacker {
roundShooters[sgi] = true
}
for len(roundShooters) > 0 {
// attacke group id among round participants
attIdx := randomValue(maps.Keys(roundShooters))
delete(roundShooters, attIdx)
for range b.shipAmmo[attIdx] {
// defender group id among all attacker's opponents
defIdx := randomValue(maps.Keys(b.attacker[attIdx]))
destroyed := false
probability := b.attacker[attIdx][defIdx]
switch {
case probability >= 1:
destroyed = true
case probability > 0:
destroyed = rand.Float64() >= probability
default:
panic("SingleBattle: probability unexpected: value <= 0")
}
b.Protocol = append(b.Protocol, BattleAction{
Attacker: attIdx,
Defender: defIdx,
Destroyed: destroyed,
})
if destroyed {
c.ShipGroupDestroyItem(defIdx)
}
if c.ShipGroup(defIdx).Number == 0 {
// Eliminated group cant attack anyone
delete(b.attacker, defIdx)
delete(roundShooters, defIdx)
for attIdx := range b.attacker {
// Other attackers can't attack eliminated group anymore
delete(b.attacker[attIdx], defIdx)
if len(b.attacker[attIdx]) == 0 {
// Remove attacker if he lost all opponents
delete(b.attacker, attIdx)
delete(roundShooters, attIdx)
}
}
}
// When attacker has no more targets to shoot - break its ammo cycle
if len(b.attacker[attIdx]) == 0 {
break
}
}
}
}
}
func DestructionProbability(attWeapons, attWeaponsTech, defShields, defShiledsTech, defFullMass float64) float64 {
effAttack := attWeapons * attWeaponsTech
effDefence := EffectiveDefence(defShields, defShiledsTech, defFullMass)
return (math.Log10(effAttack/effDefence)/math.Log10(4) + 1) / 2
}
func EffectiveDefence(defShields, defShiledsTech, defFullMass float64) float64 {
return defShields * defShiledsTech / math.Pow(defFullMass, 1./3.) * math.Pow(30., 1./3.)
}
func randomValue(v iter.Seq[int]) int {
ids := slices.Collect(v)
return ids[rand.IntN(len(ids))]
}
+184
View File
@@ -0,0 +1,184 @@
package controller_test
import (
"maps"
"slices"
"testing"
"github.com/iliadenisov/galaxy/server/internal/controller"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
var (
attacker = game.ShipType{
Name: "Attacker",
Drive: 8,
Armament: 1,
Weapons: 8,
Shields: 8,
Cargo: 0,
}
defender = game.ShipType{
Name: "Defender",
Drive: 1,
Armament: 1,
Weapons: 1,
Shields: 1,
Cargo: 0,
}
ship = game.ShipType{
Name: "Ship",
Drive: 10,
Armament: 1,
Weapons: 10,
Shields: 10,
Cargo: 0,
}
)
func TestDestructionProbability(t *testing.T) {
probability := controller.DestructionProbability(ship.Weapons.F(), 1, ship.Shields.F(), 1, ship.EmptyMass())
assert.Equal(t, .5, probability)
undefeatedShip := ship
undefeatedShip.Shields = 55
probability = controller.DestructionProbability(ship.Weapons.F(), 1, undefeatedShip.Shields.F(), 1, undefeatedShip.EmptyMass())
assert.LessOrEqual(t, probability, 0.)
disruptiveShip := ship
disruptiveShip.Weapons = 40
probability = controller.DestructionProbability(disruptiveShip.Weapons.F(), 1, ship.Shields.F(), 1, ship.EmptyMass())
assert.GreaterOrEqual(t, probability, 1.)
}
func TestEffectiveDefence(t *testing.T) {
assert.Equal(t, 10., controller.EffectiveDefence(ship.Shields.F(), 1, ship.EmptyMass()))
attackerEffectiveDefence := controller.EffectiveDefence(attacker.Shields.F(), 1, attacker.EmptyMass())
defenderEffectiveDefence := controller.EffectiveDefence(defender.Shields.F(), 1, defender.EmptyMass())
// attacker's effective shields must be 'just' 4 times greater than defender's
assert.InDelta(t, defenderEffectiveDefence*4, attackerEffectiveDefence, 0)
}
func TestCollectPlanetGroups(t *testing.T) {
c, _ := newCache()
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10)) // 1 #0
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 3)) // 2 #1
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 3)) // 3 #2
c.ShipGroup(2).StateInSpace = &InSpace // 3 #2 -> In_Space
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1)) // 4 #3
c.ShipGroup(3).Destination = R1_Planet_1_num // 4 #3 -> Planet_1
assert.NoError(t, c.CreateShips(Race_1_idx, Race_1_Gunship, R1_Planet_1_num, 15)) // 5 #4
c.ShipGroup(4).Destination = R0_Planet_0_num // 5 #4 -> Planet_0
planetGroups := controller.CollectPlanetGroups(c)
for pl := range planetGroups {
switch pl {
case R0_Planet_0_num:
assert.Equal(t, 3, len(planetGroups[pl]))
assert.Contains(t, planetGroups[pl], 0)
assert.Contains(t, planetGroups[pl], 1)
assert.Contains(t, planetGroups[pl], 4)
default:
assert.Fail(t, "planet #%d should not contain groups for battle", pl)
}
}
}
func TestFilterBattleOpponents(t *testing.T) {
c, _ := newCache()
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 1)) // 0
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1)) // 1
assert.NoError(t, c.CreateShips(Race_1_idx, Race_1_Gunship, R1_Planet_1_num, 15)) // 2
undefeatedShip := ship
undefeatedShip.Shields = 100
assert.NoError(t, c.ShipClassCreate(Race_1_idx, undefeatedShip.Name, undefeatedShip.Drive.F(), int(undefeatedShip.Armament), undefeatedShip.Weapons.F(), undefeatedShip.Shields.F(), undefeatedShip.Cargo.F()))
assert.NoError(t, c.CreateShips(Race_1_idx, undefeatedShip.Name, R1_Planet_1_num, 1)) // 3
cacheProbability := make(map[int]map[int]float64)
assert.False(t, controller.FilterBattleOpponents(c, 0, 2, cacheProbability))
assert.Contains(t, cacheProbability, 0)
assert.Contains(t, cacheProbability[0], 2)
assert.InDelta(t, 0.396222, cacheProbability[0][2], 0.0000001)
assert.False(t, controller.FilterBattleOpponents(c, 2, 0, cacheProbability))
assert.Contains(t, cacheProbability, 2)
assert.Contains(t, cacheProbability[2], 0)
assert.InDelta(t, 0.495, cacheProbability[2][0], 0.0001)
// Test: same owner
assert.True(t, controller.FilterBattleOpponents(c, 0, 0, cacheProbability))
assert.True(t, controller.FilterBattleOpponents(c, 0, 1, cacheProbability))
assert.True(t, controller.FilterBattleOpponents(c, 1, 0, cacheProbability))
// Test: reace reations
assert.NoError(t, c.UpdateRelation(Race_0_idx, Race_1_idx, game.RelationPeace))
assert.True(t, controller.FilterBattleOpponents(c, 0, 2, cacheProbability))
assert.True(t, controller.FilterBattleOpponents(c, 2, 0, cacheProbability))
assert.NoError(t, c.UpdateRelation(Race_0_idx, Race_1_idx, game.RelationWar))
assert.LessOrEqual(t, controller.DestructionProbability(Cruiser.Weapons.F(), 1, undefeatedShip.Shields.F(), 1, undefeatedShip.EmptyMass()), 0.)
assert.True(t, controller.FilterBattleOpponents(c, 1, 3, cacheProbability))
assert.NotContains(t, cacheProbability[1], 3)
}
func TestProduceBattles(t *testing.T) {
c, g := newCache()
race_C_name, race_D_name := "Race_C", "Race_D"
race_C_idx, _ := c.AddRace(race_C_name)
race_D_idx, _ := c.AddRace(race_D_name)
assert.NoError(t, g.RaceRelation(Race_0.Name, Race_1.Name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(race_C_name, race_D_name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(race_D_name, race_C_name, game.RelationWar.String()))
assert.Equal(t, game.RelationPeace, c.Relation(Race_0_idx, race_C_idx))
assert.Equal(t, game.RelationPeace, c.Relation(Race_1_idx, race_C_idx))
assert.Equal(t, game.RelationPeace, c.Relation(Race_0_idx, race_D_idx))
assert.Equal(t, game.RelationPeace, c.Relation(Race_1_idx, race_D_idx))
// Race_0
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
// Race_1
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R0_Planet_0_num, 11)
// Race_C
assert.NoError(t, c.ShipClassCreate(race_C_idx, Cruiser.Name, Cruiser.Drive.F(), int(Cruiser.Armament), Cruiser.Weapons.F(), Cruiser.Shields.F(), Cruiser.Cargo.F()))
c.CreateShipsUnsafe_T(race_C_idx, c.MustShipClass(race_C_idx, Cruiser.Name).ID, R0_Planet_0_num, 12)
// Race_D
assert.NoError(t, c.ShipClassCreate(race_D_idx, Cruiser.Name, Cruiser.Drive.F(), int(Cruiser.Armament), Cruiser.Weapons.F(), Cruiser.Shields.F(), Cruiser.Cargo.F()))
c.CreateShipsUnsafe_T(race_D_idx, c.MustShipClass(race_D_idx, Cruiser.Name).ID, R0_Planet_0_num, 13)
battle := controller.ProduceBattles(c)
assert.Len(t, battle, 1)
b := battle[0]
assert.Equal(t, R0_Planet_0_num, b.Planet)
assert.Len(t, b.ObserverGroups, 4)
assert.Len(t, b.InitialNumbers, 4)
assert.ElementsMatch(t, slices.Collect(maps.Keys(b.ObserverGroups)), slices.Collect(maps.Keys(b.InitialNumbers)))
assert.Equal(t, 10, int(b.InitialNumbers[0]))
assert.Equal(t, 11, int(b.InitialNumbers[1]))
assert.Equal(t, 12, int(b.InitialNumbers[2]))
assert.Equal(t, 13, int(b.InitialNumbers[3]))
if c.ShipGroup(0).Number == 0 {
assert.Greater(t, c.ShipGroup(1).Number, uint(0))
} else {
assert.Zero(t, c.ShipGroup(1).Number)
}
if c.ShipGroup(2).Number == 0 {
assert.Greater(t, c.ShipGroup(3).Number, uint(0))
} else {
assert.Zero(t, c.ShipGroup(3).Number)
}
}
@@ -0,0 +1,81 @@
package controller
import (
"galaxy/model/report"
"github.com/google/uuid"
)
func TransformBattle(c *Cache, b *Battle) *report.BattleReport {
r := &report.BattleReport{
ID: b.ID,
Planet: b.Planet,
PlanetName: c.MustPlanet(b.Planet).Name,
Races: make(map[int]uuid.UUID),
Ships: make(map[int]report.BattleReportGroup),
Protocol: make([]report.BattleActionReport, len(b.Protocol)),
}
cacheShipClass := make(map[uuid.UUID]int)
cacheRaceName := make(map[uuid.UUID]int)
addShipGroup := func(groupId int, inBattle bool) int {
shipClass := c.ShipGroupShipClass(groupId)
sg := c.ShipGroup(groupId)
itemNumber := len(r.Ships)
bg := &report.BattleReportGroup{
Race: c.g.Race[c.RaceIndex(sg.OwnerID)].Name,
InBattle: inBattle,
Number: b.InitialNumbers[groupId],
NumberLeft: sg.Number,
ClassName: shipClass.Name,
LoadType: sg.CargoString(),
LoadQuantity: report.F(sg.Load.F()),
}
for t, v := range sg.Tech {
bg.Tech[t.String()] = report.F(v.F())
}
r.Ships[itemNumber] = *bg
cacheShipClass[shipClass.ID] = itemNumber
return itemNumber
}
ship := func(groupId int) int {
shipClass := c.ShipGroupShipClass(groupId)
if v, ok := cacheShipClass[shipClass.ID]; ok {
return v
} else {
return addShipGroup(groupId, true)
}
}
race := func(groupId int) int {
race := c.ShipGroupOwnerRace(groupId)
if v, ok := cacheRaceName[race.ID]; ok {
return v
} else {
itemNumber := len(r.Races)
r.Races[itemNumber] = race.ID
cacheRaceName[race.ID] = itemNumber
return itemNumber
}
}
for i := range b.Protocol {
r.Protocol[i] = report.BattleActionReport{
Attacker: race(b.Protocol[i].Attacker),
AttackerShipClass: ship(b.Protocol[i].Attacker),
Defender: race(b.Protocol[i].Defender),
DefenderShipClass: ship(b.Protocol[i].Defender),
Destroyed: b.Protocol[i].Destroyed,
}
}
for sgi, inBattle := range b.ObserverGroups {
if !inBattle {
addShipGroup(sgi, false)
}
}
return r
}
+112
View File
@@ -0,0 +1,112 @@
package controller
import (
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
func (c *Cache) ProduceBombings() []*game.Bombing {
report := make([]*game.Bombing, 0)
for pn, enemies := range c.collectBombingGroups() {
p := c.MustPlanet(pn)
if !p.Owned() {
continue
}
for ri, groups := range enemies {
br := c.bombingReport(p, ri, groups)
report = append(report, br)
if br.Wiped {
break
}
}
if p.Population == 0 {
p.Free()
} else {
// Если на планете остались также и колонисты, то они превращаются в население,
// а накопленная промышленность возмещает потери производства.
p.UnpackColonists()
p.UnpackCapital()
}
}
return report
}
func (c *Cache) bombingReport(p *game.Planet, ri int, groups []int) *game.Bombing {
attackPower := 0.
for _, i := range groups {
sg := c.ShipGroup(i)
st := c.ShipGroupShipClass(i)
attackPower += sg.BombingPower(st)
}
r := &game.Bombing{
ID: uuid.New(),
PlanetOwnedID: *p.Owner,
Planet: p.Name,
Number: p.Number,
Owner: c.g.Race[c.RaceIndex(*p.Owner)].Name,
Attacker: c.g.Race[ri].Name,
Production: c.PlanetProductionDisplayName(p.Number),
Industry: p.Industry,
Population: p.Population,
Colonists: p.Colonists,
Capital: p.Capital,
Material: p.Material,
AttackPower: game.F(attackPower),
}
bombPlanet(p, attackPower)
r.Wiped = p.Population == 0
return r
}
func bombPlanet(p *game.Planet, power float64) {
// Уничтожается население и колонисты в количестве равном [суммарной] мощности бомбардировки
if power > p.Population.F() {
p.Pop(0)
} else {
p.Pop(p.Population.F() - power)
}
if power > p.Colonists.F() {
p.Col(0)
} else {
p.Col(p.Colonists.F() - power)
}
// Такое же количество промышленности превращается в сырье
if power > p.Industry.F() {
p.Mat(p.Material.F() + p.Industry.F())
p.Ind(0)
} else {
p.Mat(p.Material.F() + power)
p.Ind(p.Industry.F() - power)
}
}
// [planet_num] -> [enemy_race_id] -> []group_id
func (c *Cache) collectBombingGroups() map[uint]map[int][]int {
result := make(map[uint]map[int][]int)
for i := range c.ShipGroupsIndex() {
sg := c.ShipGroup(i)
if sg.State() != game.StateInOrbit {
continue
}
st := c.ShipGroupShipClass(i)
if st.WeaponsBlockMass() == 0 {
continue
}
p := c.MustPlanet(sg.Destination)
if p.OwnedBy(sg.OwnerID) || !p.Owned() {
continue
}
r1 := c.RaceIndex(sg.OwnerID)
r2 := c.RaceIndex(*p.Owner)
if c.Relation(r1, r2) == game.RelationPeace {
continue
}
// add result
if _, ok := result[p.Number]; !ok {
result[p.Number] = make(map[int][]int)
}
result[p.Number][r1] = append(result[p.Number][r1], i)
}
return result
}
+142
View File
@@ -0,0 +1,142 @@
package controller_test
import (
"testing"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/controller"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestBombPlanet(t *testing.T) {
id := uuid.New()
p := controller.NewPlanet(0, "Planet_0", &id, 1, 1, 1000, 300, 200, 10, game.ResearchDrive.AsType(uuid.Nil))
(&p).Colonists = 100.
assert.Equal(t, 0., p.Material.F())
controller.BombPlanet(&p, 55.)
assert.Equal(t, 245., p.Population.F())
assert.Equal(t, 45., p.Colonists.F())
assert.Equal(t, 145., p.Industry.F())
assert.Equal(t, 55., p.Material.F())
controller.BombPlanet(&p, 56.)
assert.Equal(t, 189., p.Population.F())
assert.Equal(t, 0., p.Colonists.F())
assert.Equal(t, 89., p.Industry.F())
assert.Equal(t, 111., p.Material.F())
controller.BombPlanet(&p, 200.)
assert.Equal(t, 0., p.Population.F())
assert.Equal(t, 0., p.Colonists.F())
assert.Equal(t, 0., p.Industry.F())
assert.Equal(t, 200., p.Material.F())
}
func TestCollectBombingGroups(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.RaceRelation(Race_0.Name, Race_1.Name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
// 1: idx = 0 / Ready to bomb: Race_1/Planet_1
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 2)) // bombs
c.ShipGroup(0).Destination = R1_Planet_1_num
// 2: idx = 1 / Ready to bomb: Race_0/Planet_2
assert.NoError(t, c.CreateShips(Race_1_idx, Race_1_Gunship, R1_Planet_1_num, 3)) // bombs
c.ShipGroup(1).Destination = R0_Planet_2_num
// 3: idx = 2 / In_Space
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 1))
c.ShipGroup(2).StateInSpace = &InSpace
// 4: idx = 3 / Has no Ammo
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 1))
c.ShipGroup(3).Destination = R1_Planet_1_num
// 5: idx = 4 / On it's own planet
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 1))
// 6: idx = 5 / On uninhabited planet
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 2))
c.ShipGroup(5).Destination = Uninhabited_Planet_3_num
bg := c.CollectBombingGroups()
assert.Len(t, bg, 2)
assert.Contains(t, bg, R1_Planet_1_num)
assert.Contains(t, bg, R0_Planet_2_num)
assert.Len(t, bg[R1_Planet_1_num], 1)
assert.Contains(t, bg[R1_Planet_1_num], Race_0_idx)
assert.Len(t, bg[R0_Planet_2_num], 1)
assert.Contains(t, bg[R0_Planet_2_num], Race_1_idx)
assert.Len(t, bg[R1_Planet_1_num][Race_0_idx], 1)
assert.Equal(t, 0, bg[R1_Planet_1_num][Race_0_idx][0])
assert.Len(t, bg[R0_Planet_2_num][Race_1_idx], 1)
assert.Equal(t, 1, bg[R0_Planet_2_num][Race_1_idx][0])
// remove bombings from Race_1
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationPeace.String()))
bg = c.CollectBombingGroups()
assert.Len(t, bg, 1)
assert.Contains(t, bg, R1_Planet_1_num)
assert.Len(t, bg[R1_Planet_1_num], 1)
assert.Contains(t, bg[R1_Planet_1_num], Race_0_idx)
assert.Len(t, bg[R1_Planet_1_num][Race_0_idx], 1)
assert.Equal(t, 0, bg[R1_Planet_1_num][Race_0_idx][0])
}
func TestProduceBombings(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.RaceRelation(Race_0.Name, Race_1.Name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
// 1: idx = 0 / Bombs on: Race_1/Planet_1
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 3))
c.ShipGroup(0).Destination = R1_Planet_1_num
// 2: idx = 1 / Bombs on: Race_1/Planet_1
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 7))
c.ShipGroup(1).Destination = R1_Planet_1_num
// 3: idx = 2 / Bombs on: Race_0/Planet_2
assert.NoError(t, c.CreateShips(Race_1_idx, Race_1_Gunship, R1_Planet_1_num, 1))
c.ShipGroup(2).Destination = R0_Planet_2_num
c.MustPlanet(R0_Planet_2_num).Population = 500
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "CAP", R0_Planet_2_num, R0_Planet_0_num))
assert.NotEmpty(t, c.MustPlanet(R0_Planet_2_num).Route)
assert.NoError(t, g.PlanetRouteSet(Race_1.Name, "EMP", R1_Planet_1_num, R0_Planet_2_num))
assert.NotEmpty(t, c.MustPlanet(R1_Planet_1_num).Route)
reports := c.ProduceBombings()
assert.Len(t, reports, 2)
for _, b := range reports {
assert.NotEqual(t, uuid.Nil, b.ID)
switch pn := b.Number; pn {
case R1_Planet_1_num:
assert.Equal(t, Race_1.Name, b.Owner)
assert.Equal(t, Race_0.Name, b.Attacker)
assert.InDelta(t, 697.857, b.AttackPower.F(), 0.0003)
assert.True(t, b.Wiped)
assert.False(t, c.MustPlanet(pn).Owned())
assert.Empty(t, c.MustPlanet(pn).Route)
assert.Equal(t, 0., c.MustPlanet(pn).Population.F())
case R0_Planet_2_num:
assert.Equal(t, Race_0.Name, b.Owner)
assert.Equal(t, Race_1.Name, b.Attacker)
assert.InDelta(t, 358.856, b.AttackPower.F(), 0.0001)
assert.False(t, b.Wiped)
assert.True(t, c.MustPlanet(pn).OwnedBy(Race_0_ID))
assert.NotEmpty(t, c.MustPlanet(pn).Route)
assert.InDelta(t, 500.-358.85596, c.MustPlanet(pn).Population.F(), 0.000001)
}
}
}
+104
View File
@@ -0,0 +1,104 @@
package controller
import (
"fmt"
"slices"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
type Cache struct {
g *game.Game
cacheRaceIndexByID map[uuid.UUID]int
cacheFleetIndexByID map[uuid.UUID]int
cacheRaceIndexByShipGroupIndex map[int]int
cacheShipClassByShipGroupIndex map[int]*game.ShipType
cachePlanetByPlanetNumber map[uint]*game.Planet
cacheRelation map[int]map[int]game.Relation
}
func NewCache(g *game.Game) *Cache {
if g == nil {
panic("NewCache: nil Game passed")
}
c := &Cache{
g: g,
}
return c
}
func (c *Cache) StageCommand() {
c.g.Stage++
}
func (c Cache) Stage() uint {
return c.g.Stage
}
func (c *Cache) ShipGroupShipClass(groupIndex int) *game.ShipType {
if len(c.cacheShipClassByShipGroupIndex) == 0 {
c.cacheShipsAndGroups()
}
c.validateShipGroupIndex(groupIndex)
if v, ok := c.cacheShipClassByShipGroupIndex[groupIndex]; ok {
return v
} else {
panic(fmt.Sprintf("ShipClassByShipGroupIndex: group not found by index=%v", groupIndex))
}
}
func (c *Cache) RaceIndex(ID uuid.UUID) int {
if c.cacheRaceIndexByID == nil {
c.cacheRaceIndexByID = make(map[uuid.UUID]int)
for i := range c.listRaceIdx() {
c.cacheRaceIndexByID[c.g.Race[i].ID] = i
}
}
if v, ok := c.cacheRaceIndexByID[ID]; ok {
return v
} else {
panic(fmt.Sprintf("RaceIndex: race not found by ID=%v", ID))
}
}
func (c *Cache) cacheShipsAndGroups() {
if c.cacheRaceIndexByShipGroupIndex != nil {
clear(c.cacheRaceIndexByShipGroupIndex)
} else {
c.cacheRaceIndexByShipGroupIndex = make(map[int]int)
}
if c.cacheShipClassByShipGroupIndex != nil {
clear(c.cacheShipClassByShipGroupIndex)
} else {
c.cacheShipClassByShipGroupIndex = make(map[int]*game.ShipType)
}
for sgi := range c.g.ShipGroups {
ri := c.RaceIndex(c.g.ShipGroups[sgi].OwnerID)
c.cacheRaceIndexByShipGroupIndex[sgi] = ri
sci, ok := ShipClassIndex(c.g, ri, c.g.ShipGroups[sgi].TypeID)
if !ok {
panic(fmt.Sprintf("CollectPlanetGroups: ship class not found for race=%q group=%v", c.g.Race[ri].Name, c.g.ShipGroups[sgi].ID))
}
c.cacheShipClassByShipGroupIndex[sgi] = &c.g.Race[ri].ShipTypes[sci]
}
}
func (c *Cache) invalidateShipGroupCache() {
clear(c.cacheRaceIndexByShipGroupIndex)
clear(c.cacheShipClassByShipGroupIndex)
}
func (c *Cache) invalidateFleetCache() {
clear(c.cacheFleetIndexByID)
}
// Helpers
func ShipClassIndex(g *game.Game, ri int, classID uuid.UUID) (int, bool) {
if len(g.Race) < ri+1 {
panic(fmt.Sprintf("ShipClass: game race index %d invalid: len=%d", ri, len(g.Race)))
}
sti := slices.IndexFunc(g.Race[ri].ShipTypes, func(st game.ShipType) bool { return st.ID == classID })
return sti, sti >= 0
}
+259
View File
@@ -0,0 +1,259 @@
package controller
import (
"strings"
e "galaxy/error"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
// RaceID returns ID of race with given actor's name or error when race not found or extinct
func (c Controller) RaceID(actor string) (uuid.UUID, error) {
ri, err := c.Cache.validRace(actor)
if err != nil {
return uuid.Nil, err
}
return c.Cache.g.Race[ri].ID, nil
}
func (c Controller) RaceQuit(actor string) error {
ri, err := c.Cache.validRace(actor)
if err != nil {
return err
}
c.Cache.g.Race[ri].TTL = 3
return nil
}
func (c Controller) RaceVote(actor, acceptor string) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
rec, err := c.Cache.validRace(acceptor)
if err != nil {
return err
}
c.Cache.g.Race[ri].VoteFor = c.Cache.g.Race[rec].ID
return nil
}
func (c Controller) RaceRelation(actor, acceptor string, v string) error {
rel, ok := game.ParseRelation(v)
if !ok {
return e.NewUnknownRelationError(v)
}
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
other, err := c.Cache.validRace(acceptor)
if err != nil {
return err
}
return c.Cache.UpdateRelation(ri, other, rel)
}
func (c *Controller) ShipClassCreate(actor, typeName string, drive float64, ammo int, weapons, shileds, cargo float64) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
return c.Cache.ShipClassCreate(ri, typeName, drive, ammo, weapons, shileds, cargo)
}
func (c *Controller) ShipClassMerge(actor, name, targetName string) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
return c.Cache.shipClassMerge(ri, name, targetName)
}
func (c *Controller) ShipClassRemove(actor, typeName string) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
return c.Cache.shipClassRemove(ri, typeName)
}
func (c *Controller) ShipGroupLoad(actor string, groupID uuid.UUID, cargoType string, quantity float64) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
ct, ok := game.CargoTypeSet[strings.ToLower(cargoType)]
if !ok {
return e.NewCargoTypeInvalidError(cargoType)
}
return c.Cache.shipGroupLoad(ri, groupID, ct, quantity)
}
func (c *Controller) ShipGroupUnload(actor string, groupID uuid.UUID, quantity float64) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
return c.Cache.shipGroupUnload(ri, groupID, quantity)
}
func (c *Controller) ShipGroupSend(actor string, groupID uuid.UUID, planetNumber uint) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
return c.Cache.shipGroupSend(ri, groupID, planetNumber)
}
func (c *Controller) ShipGroupUpgrade(actor string, groupID uuid.UUID, techInput string, limitLevel float64) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
return c.Cache.shipGroupUpgrade(ri, groupID, techInput, limitLevel)
}
func (c *Controller) ShipGroupMerge(actor string) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
c.Cache.shipGroupMerge(ri)
return nil
}
func (c *Controller) ShipGroupBreak(actor string, groupID, newID uuid.UUID, quantity uint) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
return c.Cache.ShipGroupBreak(ri, groupID, newID, quantity)
}
func (c *Controller) ShipGroupDismantle(actor string, groupID uuid.UUID) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
return c.Cache.shipGroupDismantle(ri, groupID)
}
func (c *Controller) ShipGroupTransfer(actor, acceptor string, groupID uuid.UUID) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
riAccept, err := c.Cache.validRace(acceptor)
if err != nil {
return err
}
return c.Cache.shipGroupTransfer(ri, riAccept, groupID)
}
func (c *Controller) ShipGroupJoinFleet(actor, fleetName string, groupID uuid.UUID) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
return c.Cache.ShipGroupJoinFleet(ri, fleetName, groupID)
}
func (c *Controller) FleetMerge(actor, fleetSourceName, fleetTargetName string) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
return c.Cache.fleetMerge(ri, fleetSourceName, fleetTargetName)
}
func (c *Controller) FleetSend(actor, fleetName string, planetNumber uint) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
fi, ok := c.Cache.fleetIndex(ri, fleetName)
if !ok {
return e.NewEntityNotExistsError("fleet %q", fleetName)
}
return c.Cache.FleetSend(ri, fi, planetNumber)
}
func (c *Controller) ScienceCreate(actor, typeName string, drive, weapons, shields, cargo float64) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
return c.Cache.ScienceCreate(ri, typeName, drive, weapons, shields, cargo)
}
func (c *Controller) ScienceRemove(actor, typeName string) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
return c.Cache.ScienceRemove(ri, typeName)
}
func (c *Controller) PlanetRename(actor string, planetNumber int, name string) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
return c.Cache.PlanetRename(ri, planetNumber, name)
}
func (c *Controller) PlanetProduce(actor string, planetNumber int, prodType, subject string) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
var prod game.ProductionType
switch game.ProductionType(strings.ToUpper(prodType)) {
case game.ProductionMaterial:
prod = game.ProductionMaterial
case game.ProductionCapital:
prod = game.ProductionCapital
case game.ResearchDrive:
prod = game.ResearchDrive
case game.ResearchWeapons:
prod = game.ResearchWeapons
case game.ResearchShields:
prod = game.ResearchShields
case game.ResearchCargo:
prod = game.ResearchCargo
case game.ResearchScience:
prod = game.ResearchScience
case game.ProductionShip:
prod = game.ProductionShip
default:
return e.NewProductionInvalidError(prodType)
}
return c.Cache.PlanetProduce(ri, planetNumber, prod, subject)
}
func (c *Controller) PlanetRouteSet(actor, loadType string, origin, destination uint) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
rt, ok := game.RouteTypeSet[strings.ToLower(loadType)]
if !ok {
return e.NewCargoTypeInvalidError(loadType)
}
return c.Cache.PlanetRouteSet(ri, rt, origin, destination)
}
func (c *Controller) PlanetRouteRemove(actor, loadType string, origin uint) error {
ri, err := c.Cache.validActor(actor)
if err != nil {
return err
}
rt, ok := game.RouteTypeSet[strings.ToLower(loadType)]
if !ok {
return e.NewCargoTypeInvalidError(loadType)
}
return c.Cache.PlanetRouteRemove(ri, rt, origin)
}
+248
View File
@@ -0,0 +1,248 @@
package controller
import (
"errors"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"galaxy/model/order"
"galaxy/model/report"
"github.com/iliadenisov/galaxy/server/internal/repo"
)
type Configurer func(*Param)
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
SaveNewTurn(uint, *game.Game) error
// SaveState stores current game state updated between turns
SaveLastState(*game.Game) error
// LoadState retrieves game current state with required lock acquisition
LoadState() (*game.Game, error)
// LoadStateSafe retrieves game current state without preliminary locking
LoadStateSafe() (*game.Game, error)
// SaveBattle stores a new battle protocol and battle meta data for turn t
SaveBattle(uint, *report.BattleReport, *game.BattleMeta) error
// SaveBombing stores all prodused bombings for turn t
SaveBombings(uint, []*game.Bombing) error
// SaveReport stores latest report for a race
SaveReport(uint, *report.Report) error
// LoadReport loads report for specific turn and player id
LoadReport(uint, uuid.UUID) (*report.Report, error)
// SaveOrder stores order for given turn
SaveOrder(uint, uuid.UUID, *order.Order) error
// LoadOrder loads order for specific turn and player id
LoadOrder(uint, uuid.UUID) (*order.Order, bool, error)
}
type Ctrl interface {
ValidateOrder(actor string, cmd ...order.DecodableCommand) error
// remove below funcs if /command api will be deleted
RaceID(actor string) (uuid.UUID, error)
RaceQuit(actor string) error
RaceVote(actor, acceptor string) error
RaceRelation(actor, acceptor string, rel string) error
ShipClassCreate(actor, typeName string, drive float64, ammo int, weapons, shileds, cargo float64) error
ShipClassMerge(actor, name, targetName string) error
ShipClassRemove(actor, typeName string) error
ShipGroupLoad(actor string, groupID uuid.UUID, cargoType string, quantity float64) error
ShipGroupUnload(actor string, groupID uuid.UUID, quantity float64) error
ShipGroupSend(actor string, groupID uuid.UUID, planetNumber uint) error
ShipGroupUpgrade(actor string, groupID uuid.UUID, techInput string, limitLevel float64) error
ShipGroupBreak(actor string, groupID, newID uuid.UUID, quantity uint) error
ShipGroupMerge(actor string) error
ShipGroupDismantle(actor string, groupID uuid.UUID) error
ShipGroupTransfer(actor, acceptor string, groupID uuid.UUID) error
ShipGroupJoinFleet(actor, fleetName string, groupID uuid.UUID) error
FleetMerge(actor, fleetSourceName, fleetTargetName string) error
FleetSend(actor, fleetName string, planetNumber uint) error
ScienceCreate(actor, typeName string, drive, weapons, shields, cargo float64) error
ScienceRemove(actor, typeName string) error
PlanetRename(actor string, planetNumber int, typeName string) error
PlanetProduce(actor string, planetNumber int, prodType, subject string) error
PlanetRouteSet(actor, loadType string, origin, destination uint) error
PlanetRouteRemove(actor, loadType string, origin uint) error
}
func GenerateGame(configure func(*Param), races []string) (s game.State, err error) {
ec, err := NewRepoController(configure)
if err != nil {
return game.State{}, err
}
if err = ec.Repo.Lock(); err != nil {
return
}
defer func() {
err = errors.Join(err, ec.Repo.Release())
if err == nil {
s, err = GameState(configure)
}
}()
_, err = NewGame(ec.Repo, races)
return
}
func GenerateTurn(configure func(*Param)) (err error) {
ec, err := NewRepoController(configure)
if err != nil {
return err
}
err = ec.executeLocked(func(c *Controller) error { return c.MakeTurn() })
return
}
func ExecuteCommand(configure func(*Param), consumer func(c Ctrl) error) (err error) {
ec, err := NewRepoController(configure)
if err != nil {
return err
}
return ec.executeCommand(func(c *Controller) error { return consumer(c) })
}
func ValidateOrder(configure func(*Param), actor string, cmd ...order.DecodableCommand) (err error) {
ec, err := NewRepoController(configure)
if err != nil {
return err
}
return ec.validateOrder(actor, cmd...)
}
func GameState(configure func(*Param)) (s game.State, err error) {
ec, err := NewRepoController(configure)
if err != nil {
return game.State{}, err
}
g, err := ec.Repo.LoadStateSafe()
if err != nil {
return game.State{}, err
}
result := &game.State{
ID: g.ID,
Turn: g.Turn,
Stage: g.Stage,
Players: make([]game.PlayerState, len(g.Race)),
}
for i := range g.Race {
r := &g.Race[i]
result.Players[i].ID = r.ID
result.Players[i].Name = r.Name
result.Players[i].Extinct = r.Extinct
}
return *result, nil
}
type RepoController struct {
Repo Repo
}
func NewRepoController(config Configurer) (*RepoController, error) {
c := &Param{
StoragePath: ".",
}
if config != nil {
config(c)
}
r, err := repo.NewFileRepo(c.StoragePath)
if err != nil {
return nil, err
}
return &RepoController{
Repo: r,
}, nil
}
func (ec *RepoController) NewGameController(g *game.Game) *Controller {
return &Controller{
RepoController: ec,
Cache: NewCache(g),
}
}
func (ec *RepoController) validateOrder(actor string, cmd ...order.DecodableCommand) (err error) {
return ec.executeSafe(func(t uint, c *Controller) error {
id, err := c.RaceID(actor)
if err != nil {
return err
}
err = c.ValidateOrder(actor, cmd...)
if err != nil {
return err
}
o := &order.Order{Commands: make([]order.DecodableCommand, len(cmd))}
copy(o.Commands, cmd)
return ec.Repo.SaveOrder(t, id, o)
})
}
func (ec *RepoController) executeCommand(consumer func(*Controller) error) (err error) {
return ec.executeLocked(func(c *Controller) error {
err = consumer(c)
if err == nil {
c.Cache.StageCommand()
err = c.saveState()
}
return err
})
}
func (ec *RepoController) executeSafe(consumer func(uint, *Controller) error) (err error) {
g, err := ec.Repo.LoadStateSafe()
if err != nil {
return err
}
err = consumer(g.Turn, ec.NewGameController(g))
return
}
func (ec *RepoController) executeLocked(consumer func(*Controller) error) (err error) {
if err := ec.Repo.Lock(); err != nil {
return err
}
defer func() {
err = errors.Join(err, ec.Repo.Release())
}()
g, err := ec.Repo.LoadState()
if err != nil {
return err
}
err = consumer(ec.NewGameController(g))
return
}
func (c *Controller) saveState() error {
return c.Repo.SaveLastState(c.Cache.g)
}
type Controller struct {
*RepoController
Cache *Cache
}
type Param struct {
StoragePath string
}
@@ -0,0 +1,150 @@
package controller
import (
"iter"
e "galaxy/error"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
func (c *Cache) CreateShips(ri int, shipTypeName string, planetNumber uint, quantity int) error {
class, _, ok := c.ShipClass(ri, shipTypeName)
if !ok {
return e.NewEntityNotExistsError("ship class q", shipTypeName)
}
p, ok := c.Planet(planetNumber)
if !ok {
return e.NewEntityNotExistsError("planet #%d", planetNumber)
}
if !p.OwnedBy(c.g.Race[ri].ID) {
return e.NewEntityNotOwnedError("planet #%d", planetNumber)
}
c.unsafeCreateShips(ri, class.ID, p.Number, uint(quantity))
return nil
}
func (c *Cache) AddRace(n string) (int, uuid.UUID) {
id := uuid.New()
r := &game.Race{
ID: id,
VoteFor: id,
Name: n,
Tech: game.NewTechSet(),
Relations: make([]game.RaceRelation, len(c.g.Race)),
}
c.g.Race = append(c.g.Race, *r)
for i := range c.listRaceIdx() {
if c.g.Race[i].ID != id {
c.g.Race[i].Relations = append(c.g.Race[i].Relations, game.RaceRelation{RaceID: id, Relation: game.RelationPeace})
continue
}
for j := range c.g.Race[i].Relations {
c.g.Race[i].Relations[j].RaceID = c.g.Race[j].ID
c.g.Race[i].Relations[j].Relation = game.RelationPeace
}
}
return len(c.g.Race) - 1, id
}
func (c *Cache) Race(i int) *game.Race {
c.validateRaceIndex(i)
return &c.g.Race[i]
}
func (c *Cache) RaceShipGroups(ri int) iter.Seq[*game.ShipGroup] {
return c.listShipGroups(ri)
}
func (c *Cache) RaceScience(ri int) []game.Science {
return c.raceScience(ri)
}
func (c *Cache) ListFleets(ri int) iter.Seq[*game.Fleet] {
return c.listFleets(ri)
}
func (c *Cache) MustFleetID(ri int, name string) uuid.UUID {
for f := range c.listFleets(ri) {
if f.Name == name {
return f.ID
}
}
panic("fleet not found")
}
func (c *Cache) MustShipClass(ri int, name string) *game.ShipType {
st, _, ok := c.ShipClass(ri, name)
if !ok {
panic("ship class not found")
}
return st
}
func (c *Cache) PutPopulation(pn uint, v float64) {
c.putPopulation(pn, v)
}
func (c *Cache) PutColonists(pn uint, v float64) {
c.putColonists(pn, v)
}
func (c *Cache) PutMaterial(pn uint, v float64) {
c.putMaterial(pn, v)
}
func (c *Cache) RaceTechLevel(ri int, t game.Tech, v float64) {
c.raceTechLevel(ri, t, v)
}
func (c *Cache) ListRoutedSendGroupIds(pn uint) iter.Seq[int] {
return c.listRoutedSendGroupIds(pn)
}
func (c *Cache) ListRoutedUnloadShipGroupIds(pn uint, rt game.RouteType) iter.Seq[int] {
return c.listRoutedUnloadShipGroupIds(pn, rt)
}
func (c *Cache) SelectColUnloadGroup(groups []int) (result iter.Seq[int]) {
return c.selectColUnloadGroup(groups)
}
func (c *Cache) ListMoveableGroupIds() iter.Seq[int] {
return c.listMoveableGroupIds()
}
func (c *Cache) CollectBombingGroups() map[uint]map[int][]int {
return c.collectBombingGroups()
}
func BombPlanet(p *game.Planet, power float64) {
bombPlanet(p, power)
}
func (c *Cache) ListProducingPlanets() iter.Seq[uint] {
return c.listProducingPlanets()
}
func (c *Cache) VotesByRace() map[int]float64 {
return c.votesByRace()
}
func VotingWinners(calc []*VoteGroup, gameVotes float64) []int {
return votingWinners(calc, gameVotes)
}
func (c *Cache) CreateShipsUnsafe_T(ri int, classID uuid.UUID, planet uint, quantity uint) int {
return c.unsafeCreateShips(ri, classID, planet, quantity)
}
func (c *Cache) WipeRace(ri int) {
c.wipeRace(ri)
}
func (c *Cache) UnsafeDeleteShipGroup(sgi int) {
c.unsafeDeleteShipGroup(sgi)
}
@@ -0,0 +1,150 @@
package controller_test
import (
"fmt"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/controller"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
var (
Race_0 = game.Race{
ID: Race_0_ID,
VoteFor: Race_0_ID,
Name: "Race_0",
TTL: 10,
Tech: map[game.Tech]game.Float{
game.TechDrive: 1.1,
game.TechWeapons: 1.2,
game.TechShields: 1.3,
game.TechCargo: 1.4,
},
Relations: []game.RaceRelation{
{RaceID: Race_1_ID, Relation: game.RelationWar},
{RaceID: Race_2_ID, Relation: game.RelationWar},
},
}
Race_1 = game.Race{
ID: Race_1_ID,
VoteFor: Race_1_ID,
Name: "Race_1",
TTL: 10,
Tech: map[game.Tech]game.Float{
game.TechDrive: 2.1,
game.TechWeapons: 2.2,
game.TechShields: 2.3,
game.TechCargo: 2.4,
},
Relations: []game.RaceRelation{
{RaceID: Race_0_ID, Relation: game.RelationPeace},
{RaceID: Race_2_ID, Relation: game.RelationPeace},
},
}
Race_Extinct = game.Race{
ID: Race_2_ID,
VoteFor: Race_2_ID,
Name: "Race_Extinct",
Extinct: true,
TTL: 0,
Tech: map[game.Tech]game.Float{
game.TechDrive: 3.1,
game.TechWeapons: 3.2,
game.TechShields: 3.3,
game.TechCargo: 3.4,
},
Relations: []game.RaceRelation{
{RaceID: Race_0_ID, Relation: game.RelationPeace},
{RaceID: Race_1_ID, Relation: game.RelationWar},
},
}
Race_0_ID = uuid.New()
Race_0_idx = 0
Race_0_Gunship = "R0_Gunship"
Race_0_Freighter = "R0_Freighter"
R0_Planet_0_num uint = 0
R0_Planet_2_num uint = 2
Race_0_Gunship_idx = 0
Race_0_Freighter_idx = 1
Race_0_Cruiser_idx = 2
Race_1_ID = uuid.New()
Race_1_idx = 1
Race_1_Gunship = "R1_Gunship"
Race_1_Freighter = "R1_Freighter"
R1_Planet_1_num uint = 1
Race_1_Gunship_idx = 0
Race_1_Freighter_idx = 1
Race_1_Cruiser_idx = 2
Race_2_ID = uuid.New()
Uninhabited_Planet_3_num uint = 3
Uninhabited_Planet_4_num uint = 4
ShipType_Cruiser = "Cruiser"
Cruiser = game.ShipType{
Name: "Cruiser",
Drive: 15,
Armament: 1,
Weapons: 15,
Shields: 15,
Cargo: 0,
}
BadEntityName = "_Bad_entitty_Name"
UnknownRace = "UnknownRace"
InSpace = game.InSpace{Origin: 2, X: floatRef(1.23), Y: floatRef(1.23)}
)
func assertNoError(err error) {
if err != nil {
panic(fmt.Sprintf("init assertion failed: %v", err))
}
}
func newGame() *game.Game {
g := &game.Game{
Race: []game.Race{
Race_0,
Race_1,
Race_Extinct,
},
Map: game.Map{
Width: 1000,
Height: 1000,
Planet: []game.Planet{
controller.NewPlanet(R0_Planet_0_num, "Planet_0", &Race_0.ID, 1, 1, 100, 100, 100, 0, game.ProductionCapital.AsType(uuid.Nil)),
controller.NewPlanet(R1_Planet_1_num, "Planet_1", &Race_1.ID, 2, 2, 100, 0, 0, 0, game.ProductionCapital.AsType(uuid.Nil)),
controller.NewPlanet(R0_Planet_2_num, "Planet_2", &Race_0.ID, 3, 3, 100, 0, 0, 0, game.ProductionCapital.AsType(uuid.Nil)),
controller.NewPlanet(Uninhabited_Planet_3_num, "Planet_3", &uuid.Nil, 500, 500, 100, 0, 0, 0, game.ProductionNone.AsType(uuid.Nil)),
controller.NewPlanet(Uninhabited_Planet_4_num, "Planet_4", nil, 10, 10, 500, 0, 0, 10, game.ProductionNone.AsType(uuid.Nil)),
},
},
}
return g
}
func newCache() (*controller.Cache, *controller.Controller) {
ctl := &controller.Controller{
RepoController: nil,
Cache: controller.NewCache(newGame()),
}
assertNoError(ctl.Cache.ShipClassCreate(Race_0_idx, Race_0_Gunship, 60, 3, 30, 100, 0))
assertNoError(ctl.Cache.ShipClassCreate(Race_0_idx, Race_0_Freighter, 8, 0, 0, 2, 10))
assertNoError(ctl.Cache.ShipClassCreate(Race_0_idx, ShipType_Cruiser, Cruiser.Drive.F(), int(Cruiser.Armament), Cruiser.Weapons.F(), Cruiser.Shields.F(), Cruiser.Cargo.F()))
assertNoError(ctl.Cache.ShipClassCreate(Race_1_idx, Race_1_Gunship, 60, 3, 30, 100, 0))
assertNoError(ctl.Cache.ShipClassCreate(Race_1_idx, Race_1_Freighter, 8, 0, 0, 2, 10))
assertNoError(ctl.Cache.ShipClassCreate(Race_1_idx, ShipType_Cruiser, 15, 2, 15, 15, 0)) // same name - different type (why.)
return ctl.Cache, ctl
}
func floatRef(v float64) *game.Float {
f := game.Float(v)
return &f
}
+322
View File
@@ -0,0 +1,322 @@
package controller
import (
"fmt"
"iter"
"math"
"slices"
"galaxy/util"
e "galaxy/error"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
var fleetStateNil = game.ShipGroupState("-")
type FleetState struct {
State game.ShipGroupState
Destination uint
InSpace func() (game.InSpace, bool)
AtPlanet func() (uint, bool)
}
func (fs *FleetState) inSpace() bool {
_, ok := fs.InSpace()
return ok
}
func (fs FleetState) AtSamePlanet(other FleetState) bool {
pn1, ok := fs.AtPlanet()
if !ok {
return false
}
pn2, ok := other.AtPlanet()
if !ok {
return false
}
return pn1 == pn2
}
func (c *Cache) FleetState(fleetID uuid.UUID) FleetState {
fi := c.MustFleetIndex(fleetID)
ri := c.RaceIndex(c.g.Fleets[fi].OwnerID)
fs := &FleetState{
State: fleetStateNil,
InSpace: func() (game.InSpace, bool) { return game.InSpace{}, false },
AtPlanet: func() (uint, bool) { return 0, false },
}
for sgi := range c.FleetGroupIdx(ri, fi) {
sg := c.ShipGroup(sgi)
if fs.State == fleetStateNil {
fs.State = sg.State()
fs.Destination = sg.Destination
if pn, ok := sg.AtPlanet(); ok {
fs.AtPlanet = func() (uint, bool) { return pn, ok }
} else if sg.StateInSpace != nil {
fs.InSpace = func() (game.InSpace, bool) { return *sg.StateInSpace, true }
}
continue
}
if fs.State != sg.State() {
panic(fmt.Sprintf("FleetState: one or more ships in race's %q fleet %q has different states", c.g.Race[ri].Name, c.g.Fleets[fi].Name))
}
if fs.Destination != sg.Destination {
panic(fmt.Sprintf("FleetState: one or more ships in race's %q fleet %q has different destination", c.g.Race[ri].Name, c.g.Fleets[fi].Name))
}
if planet, ok := sg.AtPlanet(); ok {
if onPlanet, ok := fs.AtPlanet(); ok && onPlanet != planet {
panic(fmt.Sprintf("FleetState: one or more ships in race's %q fleet %q are on different planets: %d <> %d", c.g.Race[ri].Name, c.g.Fleets[fi].Name, onPlanet, planet))
}
}
if (!fs.inSpace() && sg.StateInSpace != nil) || (fs.inSpace() && sg.StateInSpace == nil) {
panic(fmt.Sprintf("FleetState: one or more ships in race's %q fleet %q on_planet and in_space at the same time", c.g.Race[ri].Name, c.g.Fleets[fi].Name))
}
if is, ok := fs.InSpace(); ok && sg.StateInSpace != nil && !is.Equal(*sg.StateInSpace) {
panic(fmt.Sprintf("FleetState: one or more ships in race's %q fleet %q has different is_space states", c.g.Race[ri].Name, c.g.Fleets[fi].Name))
}
}
if fs.State == fleetStateNil {
panic(fmt.Sprintf("FleetState: race's %q fleet %q has no ships", c.g.Race[ri].Name, c.g.Fleets[fi].Name))
}
return *fs
}
func (c *Cache) FleetSpeedAndMass(fi int) (float64, float64) {
c.validateFleetIndex(fi)
speed := math.MaxFloat64
mass := 0.
for sgi := range c.ShipGroupsIndex() {
if c.ShipGroup(sgi).FleetID == nil || *c.ShipGroup(sgi).FleetID != c.g.Fleets[fi].ID {
continue
}
sg := c.ShipGroup(sgi)
st := c.ShipGroupShipClass(sgi)
typeSpeed := sg.Speed(st)
if typeSpeed < speed {
speed = typeSpeed
}
mass += sg.FullMass(st)
}
return speed, mass
}
func (c *Cache) ShipGroupJoinFleet(ri int, fleetName string, groupID uuid.UUID) (err error) {
c.validateRaceIndex(ri)
name, ok := util.ValidateTypeName(fleetName)
if !ok {
return e.NewEntityTypeNameValidationError("%q", name)
}
sgi, ok := c.raceShipGroupIndex(ri, groupID)
if !ok {
return e.NewEntityNotExistsError("group %s", groupID)
}
if state := c.ShipGroup(sgi).State(); state != game.StateInOrbit {
return e.NewShipsBusyError("state: %s", state)
}
var oldFleetID *uuid.UUID
if c.ShipGroup(sgi).FleetID != nil {
fID := *c.ShipGroup(sgi).FleetID
oldFleetID = &fID
}
fi, ok := c.fleetIndex(ri, name)
if !ok {
fi, err = c.createFleet(ri, name)
if err != nil {
return err
}
} else {
fleetState := c.FleetState(c.g.Fleets[fi].ID)
if onPlanet, ok := fleetState.AtPlanet(); (ok && onPlanet != c.ShipGroup(sgi).Destination) || fleetState.State != game.StateInOrbit {
return e.NewShipsNotOnSamePlanetError("fleet: %s", fleetName)
}
}
c.internalShipGroupJoinFleet(sgi, c.g.Fleets[fi].ID)
if oldFleetID != nil {
keepOldFleet := false
for sg := range c.listShipGroups(ri) {
if sg.FleetID != nil && *sg.FleetID == *oldFleetID {
keepOldFleet = true
break
}
}
if !keepOldFleet {
oldFleetIndex, ok := c.FleetIndex(*oldFleetID)
if !ok {
return e.NewGameStateError("old fleet index not found by ID=%v", *oldFleetID)
}
if err := c.deleteFleet(ri, c.g.Fleets[oldFleetIndex].Name); err != nil {
return err
}
}
}
return nil
}
func (c *Cache) fleetMerge(ri int, fleetSourceName, fleetTargetName string) (err error) {
fiSource, ok := c.fleetIndex(ri, fleetSourceName)
if !ok {
return e.NewEntityNotExistsError("source fleet %s", fleetSourceName)
}
fiTarget, ok := c.fleetIndex(ri, fleetTargetName)
if !ok {
return e.NewEntityNotExistsError("target fleet %s", fleetTargetName)
}
stateSrc := c.FleetState(c.g.Fleets[fiSource].ID)
stateDst := c.FleetState(c.g.Fleets[fiTarget].ID)
if !stateSrc.AtSamePlanet(stateDst) {
return e.NewShipsNotOnSamePlanetError()
}
for sg := range c.listShipGroups(ri) {
if sg.FleetID != nil && *sg.FleetID == c.g.Fleets[fiSource].ID {
sg.FleetID = &c.g.Fleets[fiTarget].ID
}
}
return c.deleteFleet(ri, fleetSourceName)
}
func (c *Cache) createFleet(ri int, name string) (int, error) {
c.validateRaceIndex(ri)
n, ok := util.ValidateTypeName(name)
if !ok {
return 0, e.NewEntityTypeNameValidationError("%q", n)
}
if _, ok := c.fleetIndex(ri, n); ok {
return 0, e.NewEntityDuplicateIdentifierError("fleet %q", n)
}
fleets := slices.Clone(c.g.Fleets)
fleets = append(fleets, game.Fleet{
ID: uuid.New(),
OwnerID: c.g.Race[ri].ID,
Name: n,
})
c.g.Fleets = fleets
i := len(c.g.Fleets) - 1
if c.cacheFleetIndexByID != nil {
c.cacheFleetIndexByID[c.g.Fleets[i].ID] = i
}
return i, nil
}
func (c *Cache) deleteFleet(ri int, name string) error {
fi, ok := c.fleetIndex(ri, name)
if !ok {
return e.NewEntityNotExistsError("fleet %s", name)
}
for sg := range c.listShipGroups(ri) {
if sg.FleetID != nil && *(sg.FleetID) == c.g.Fleets[fi].ID {
return e.NewEntityInUseError("fleet %s: race %s, group #%d", name, c.g.Race[ri].Name, sg.Number)
}
}
c.unsafeDeleteFleet(fi)
return nil
}
func (c *Cache) unsafeDeleteFleet(fi int) {
c.validateFleetIndex(fi)
c.g.Fleets = append(c.g.Fleets[:fi], c.g.Fleets[fi+1:]...)
c.invalidateFleetCache()
}
// Internal funcs
func (c *Cache) FleetIndex(ID uuid.UUID) (int, bool) {
if len(c.cacheFleetIndexByID) == 0 {
c.cacheFleetIndex()
}
if v, ok := c.cacheFleetIndexByID[ID]; ok {
return v, true
} else {
return -1, false
}
}
func (c *Cache) cacheFleetIndex() {
if c.cacheFleetIndexByID != nil {
clear(c.cacheFleetIndexByID)
} else {
c.cacheFleetIndexByID = make(map[uuid.UUID]int)
}
for i := range c.g.Fleets {
c.cacheFleetIndexByID[c.g.Fleets[i].ID] = i
}
}
func (c *Cache) MustFleetIndex(ID uuid.UUID) int {
if v, ok := c.FleetIndex(ID); ok {
return v
} else {
panic(fmt.Sprintf("fleet not found by ID=%v", ID))
}
}
func (c *Cache) FleetGroupIdx(ri, fi int) iter.Seq[int] {
c.validateRaceIndex(ri)
c.validateFleetIndex(fi)
return func(yield func(int) bool) {
for sgi := range c.listShipGroupIdx(ri) {
sg := c.ShipGroup(sgi)
if sg.FleetID != nil && *sg.FleetID == c.g.Fleets[fi].ID {
if !yield(sgi) {
break
}
}
}
}
}
func (c *Cache) fleetGroupIds(ri, fi int) iter.Seq[int] {
c.validateRaceIndex(ri)
c.validateFleetIndex(fi)
return func(yield func(int) bool) {
for i := range c.ShipGroupsIndex() {
sg := c.ShipGroup(i)
if c.g.Race[ri].ID != sg.OwnerID {
continue
}
if sg.FleetID == nil || c.MustFleetIndex(*sg.FleetID) != fi {
continue
}
if !yield(i) {
return
}
}
}
}
func (c *Cache) listFleets(ri int) iter.Seq[*game.Fleet] {
c.validateRaceIndex(ri)
return func(yield func(*game.Fleet) bool) {
for i := range c.g.Fleets {
if c.g.Fleets[i].OwnerID == c.g.Race[ri].ID {
if !yield(&c.g.Fleets[i]) {
return
}
}
}
}
}
func (c *Cache) fleetIndex(ri int, name string) (int, bool) {
c.validateRaceIndex(ri)
if i := slices.IndexFunc(c.g.Fleets, func(f game.Fleet) bool { return f.OwnerID == c.g.Race[ri].ID && f.Name == name }); i < 0 {
return -1, false
} else {
return i, true
}
}
func (c *Cache) validateFleetIndex(i int) {
if i >= len(c.g.Fleets) {
panic(fmt.Sprintf("fleet index out of range: %d >= %d", i, len(c.g.Fleets)))
}
}
+64
View File
@@ -0,0 +1,64 @@
package controller
import (
"galaxy/util"
e "galaxy/error"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
func (c *Cache) FleetSend(ri, fi int, planetNumber uint) error {
c.validateRaceIndex(ri)
c.validateFleetIndex(fi)
fleetState := c.FleetState(c.g.Fleets[fi].ID)
sourcePlanet, ok := fleetState.AtPlanet()
if !ok || game.StateInOrbit != fleetState.State && game.StateLaunched != fleetState.State {
return e.NewShipsBusyError("state: %s", fleetState.State)
}
p1, ok := c.Planet(sourcePlanet)
if !ok {
return e.NewGameStateError("source planet #%d does not exists", sourcePlanet)
}
p2, ok := c.Planet(planetNumber)
if !ok {
return e.NewEntityNotExistsError("destination planet #%d", planetNumber)
}
rangeToDestination := util.ShortDistance(c.g.Map.Width, c.g.Map.Height, p1.X.F(), p1.Y.F(), p2.X.F(), p2.Y.F())
if rangeToDestination > c.g.Race[ri].FlightDistance() {
return e.NewSendUnreachableDestinationError("range=%.03f", rangeToDestination)
}
for sgi := range c.FleetGroupIdx(ri, fi) {
st := c.MustShipType(ri, c.ShipGroup(sgi).TypeID)
if st.DriveBlockMass() == 0 {
return e.NewSendShipHasNoDrivesError("Class=%s", st.Name)
}
}
if sourcePlanet == planetNumber {
c.UnsendFleet(ri, fi)
return nil
}
c.LaunchFleet(ri, fi, planetNumber)
return nil
}
func (c *Cache) LaunchFleet(ri, fi int, destination uint) {
c.validateRaceIndex(ri)
c.validateFleetIndex(fi)
for sgi := range c.FleetGroupIdx(ri, fi) {
c.LaunchShips(sgi, destination)
}
}
func (c *Cache) UnsendFleet(ri, fi int) {
c.validateRaceIndex(ri)
c.validateFleetIndex(fi)
for sgi := range c.FleetGroupIdx(ri, fi) {
c.UnsendShips(sgi)
}
}
@@ -0,0 +1,89 @@
package controller_test
import (
"slices"
"testing"
e "galaxy/error"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestFleetSend(t *testing.T) {
c, g := newCache()
// group #1 - in_orbit Planet_0
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 1))
// group #2 - in_space (later)
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 3))
// group #3 - in_orbit Planet_0, unmovable
g.ShipClassCreate(Race_0.Name, "Fortress", 0, 50, 30, 100, 0)
assert.NoError(t, c.CreateShips(Race_0_idx, "Fortress", R0_Planet_0_num, 1))
// group #4 - in_orbit Planet_0
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 2))
// ensure race has no Fleets
assert.Len(t, slices.Collect(c.ListFleets(Race_0_idx)), 0)
fleetSending := "R0_Fleet_one"
fleetInSpace := "R0_Fleet_inSpace"
fleetUnmovable := "R0_Fleet_unmovable"
fleetUnmovable2 := "R0_Fleet_unmovable2"
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleetSending, c.ShipGroup(0).ID))
assert.Len(t, slices.Collect(c.ListFleets(Race_0_idx)), 1)
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleetSending, c.ShipGroup(2).ID))
assert.Len(t, slices.Collect(c.ListFleets(Race_0_idx)), 1)
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleetInSpace, c.ShipGroup(1).ID))
assert.Len(t, slices.Collect(c.ListFleets(Race_0_idx)), 2)
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleetUnmovable, c.ShipGroup(2).ID))
assert.Len(t, slices.Collect(c.ListFleets(Race_0_idx)), 3)
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleetUnmovable2, c.ShipGroup(3).ID))
assert.Len(t, slices.Collect(c.ListFleets(Race_0_idx)), 4)
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleetUnmovable, c.ShipGroup(3).ID))
assert.Len(t, slices.Collect(c.ListFleets(Race_0_idx)), 3)
// group #2 - in_space
c.ShipGroup(1).StateInSpace = &InSpace
assert.ErrorContains(t,
g.FleetSend(UnknownRace, fleetSending, 2),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.FleetSend(Race_Extinct.Name, fleetSending, 2),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.FleetSend(Race_0.Name, "UnknownFleet", 2),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.FleetSend(Race_0.Name, fleetInSpace, 2),
e.GenericErrorText(e.ErrShipsBusy))
assert.ErrorContains(t,
g.FleetSend(Race_0.Name, fleetSending, 200),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.FleetSend(Race_0.Name, fleetSending, 3),
e.GenericErrorText(e.ErrSendUnreachableDestination))
assert.ErrorContains(t,
g.FleetSend(Race_0.Name, fleetUnmovable, 2),
e.GenericErrorText(e.ErrSendShipHasNoDrives))
assert.NoError(t, g.FleetSend(Race_0.Name, fleetSending, 2))
fleetState := c.FleetState(c.MustFleetID(Race_0_idx, fleetSending))
assert.Equal(t, game.StateLaunched, fleetState.State)
for sgi := range c.FleetGroupIdx(Race_0_idx, c.MustFleetIndex(c.MustFleetID(Race_0_idx, fleetSending))) {
assert.Equal(t, game.StateLaunched, c.ShipGroup(sgi).State())
}
assert.NoError(t, g.FleetSend(Race_0.Name, fleetSending, 0))
fleetState = c.FleetState(c.MustFleetID(Race_0_idx, fleetSending))
assert.Equal(t, game.StateInOrbit, fleetState.State)
for sgi := range c.FleetGroupIdx(Race_0_idx, c.MustFleetIndex(c.MustFleetID(Race_0_idx, fleetSending))) {
assert.Equal(t, game.StateInOrbit, c.ShipGroup(sgi).State())
}
}
+190
View File
@@ -0,0 +1,190 @@
package controller_test
import (
"math"
"slices"
"testing"
e "galaxy/error"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestShipGroupJoinFleet(t *testing.T) {
c, g := newCache()
groupIndex := uuid.Nil
fleetOne := "R0_Fleet_one"
fleetTwo := "R0_Fleet_two"
assert.ErrorContains(t,
g.ShipGroupJoinFleet(Race_0.Name, BadEntityName, groupIndex),
e.GenericErrorText(e.ErrInputEntityTypeNameInvalid))
assert.ErrorContains(t,
g.ShipGroupJoinFleet(Race_0.Name, "Unnamed", groupIndex),
e.GenericErrorText(e.ErrInputEntityNotExists))
// creating ShipGroup
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 5))
groupIndex = c.ShipGroup(0).ID
assert.ErrorContains(t,
g.ShipGroupJoinFleet(UnknownRace, fleetOne, groupIndex),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupJoinFleet(Race_Extinct.Name, fleetOne, groupIndex),
e.GenericErrorText(e.ErrRaceExinct))
// ensure race has no Fleets
assert.Len(t, slices.Collect(c.ListFleets(Race_0_idx)), 0)
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleetOne, groupIndex))
fleets := slices.Collect(c.ListFleets(Race_0_idx))
groups := slices.Collect(c.RaceShipGroups(Race_0_idx))
assert.Len(t, groups, 1)
gi := 0
assert.Len(t, fleets, 1)
assert.Equal(t, fleets[0].Name, fleetOne)
fleetState := c.FleetState(fleets[0].ID)
assert.Equal(t, game.StateInOrbit, fleetState.State)
assert.NotNil(t, groups[gi].FleetID)
assert.Equal(t, fleets[0].ID, *groups[gi].FleetID)
// create another ShipGroup
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 3))
groupIndex = c.ShipGroup(1).ID
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleetTwo, groupIndex))
fleets = slices.Collect(c.ListFleets(Race_0_idx))
groups = slices.Collect(c.RaceShipGroups(Race_0_idx))
assert.Len(t, groups, 2)
assert.Len(t, fleets, 2)
assert.Equal(t, fleets[1].Name, fleetTwo)
fleetState = c.FleetState(fleets[1].ID)
assert.Equal(t, game.StateInOrbit, fleetState.State)
gi = 1
assert.Len(t, groups, 2)
assert.NotNil(t, groups[gi].FleetID)
assert.Equal(t, fleets[1].ID, *groups[gi].FleetID)
assert.Equal(t, uint(3), groups[gi].Number)
groupIndex = groups[gi].ID
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleetOne, groupIndex))
fleets = slices.Collect(c.ListFleets(Race_0_idx))
assert.Len(t, fleets, 1)
groups = slices.Collect(c.RaceShipGroups(Race_0_idx))
assert.NotNil(t, groups[gi].FleetID)
assert.Equal(t, fleets[0].ID, *groups[gi].FleetID)
fleetState = c.FleetState(fleets[0].ID)
assert.Equal(t, game.StateInOrbit, fleetState.State)
// group not In_Orbit
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 7))
gi = 2
c.ShipGroup(gi).StateInSpace = &InSpace
assert.ErrorContains(t,
g.ShipGroupJoinFleet(Race_0.Name, fleetOne, c.ShipGroup(gi).ID),
e.GenericErrorText(e.ErrShipsBusy))
c.ShipGroup(gi).StateInSpace = nil
// existing fleet not on the same planet or in_orbit
c.ShipGroup(0).StateInSpace = &InSpace
c.ShipGroup(1).StateInSpace = c.ShipGroup(0).StateInSpace
assert.ErrorContains(t,
g.ShipGroupJoinFleet(Race_0.Name, fleetOne, c.ShipGroup(gi).ID),
e.GenericErrorText(e.ErrShipsNotOnSamePlanet))
}
func TestFleetMerge(t *testing.T) {
c, g := newCache()
// creating ShipGroup #1 at Planet_0
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 1)) // group #1
// creating ShipGroup #2 at Planet_2
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_2_num, 2)) // group #2
// creating ShipGroup #3 at Planet_0
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 3)) // group #3
// ensure race has no Fleets
assert.Len(t, slices.Collect(c.ListFleets(Race_0_idx)), 0)
fleetOnPlanet2 := "R0_Fleet_On_Planet_2"
fleetSourceOne := "R0_Fleet_one"
fleetTargetTwo := "R0_Fleet_two"
assert.ErrorContains(t,
g.FleetMerge(Race_0.Name, fleetSourceOne, fleetTargetTwo),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.FleetMerge(UnknownRace, fleetSourceOne, fleetTargetTwo),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.FleetMerge(Race_Extinct.Name, fleetSourceOne, fleetTargetTwo),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ShipGroupJoinFleet(UnknownRace, fleetSourceOne, c.ShipGroup(0).ID),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupJoinFleet(Race_Extinct.Name, fleetSourceOne, c.ShipGroup(0).ID),
e.GenericErrorText(e.ErrRaceExinct))
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleetSourceOne, c.ShipGroup(0).ID))
assert.ErrorContains(t,
g.FleetMerge(Race_0.Name, fleetSourceOne, fleetTargetTwo),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleetTargetTwo, c.ShipGroup(2).ID))
assert.NoError(t, g.FleetMerge(Race_0.Name, fleetSourceOne, fleetTargetTwo))
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleetOnPlanet2, c.ShipGroup(1).ID))
assert.ErrorContains(t,
g.FleetMerge(Race_0.Name, fleetOnPlanet2, fleetTargetTwo),
e.GenericErrorText(e.ErrShipsNotOnSamePlanet))
}
func TestFleetSpeedAndMass(t *testing.T) {
c, g := newCache()
c.MustPlanet(R0_Planet_0_num).Material = 100.
c.MustPlanet(R0_Planet_0_num).Capital = 100.
fleet := "Fleet"
var speed, mass float64
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 3)) // 1
s := c.ShipGroup(0).Speed(c.MustShipClass(Race_0_idx, Race_0_Gunship))
m := c.ShipGroup(0).FullMass(c.MustShipClass(Race_0_idx, Race_0_Gunship))
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 5)) // 2
assert.NoError(t, g.ShipGroupLoad(Race_0.Name, c.ShipGroup(1).ID, "MAT", 10.))
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 7)) // 3
assert.NoError(t, g.ShipGroupLoad(Race_0.Name, c.ShipGroup(2).ID, "CAP", 10.))
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleet, c.ShipGroup(0).ID))
fleetIndex := 0
speed, mass = c.FleetSpeedAndMass(fleetIndex)
assert.Equal(t, s, speed)
assert.Equal(t, m, mass)
s = math.Min(s, c.ShipGroup(1).Speed(c.MustShipClass(Race_0_idx, Race_0_Freighter)))
m += c.ShipGroup(1).FullMass(c.MustShipClass(Race_0_idx, Race_0_Freighter))
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleet, c.ShipGroup(1).ID))
speed, mass = c.FleetSpeedAndMass(fleetIndex)
assert.Equal(t, s, speed)
assert.Equal(t, m, mass)
s = math.Min(s, c.ShipGroup(2).Speed(c.MustShipClass(Race_0_idx, Race_0_Freighter)))
m += c.ShipGroup(2).FullMass(c.MustShipClass(Race_0_idx, Race_0_Freighter))
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleet, c.ShipGroup(2).ID))
speed, mass = c.FleetSpeedAndMass(fleetIndex)
assert.Equal(t, s, speed)
assert.Equal(t, m, mass)
}
+147
View File
@@ -0,0 +1,147 @@
package controller
import (
"fmt"
"math/rand/v2"
"slices"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/generator"
"github.com/iliadenisov/galaxy/server/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.SaveNewTurn(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,
Turn: 0,
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],
VoteFor: raceID,
TTL: 10,
Tech: game.NewTechSet(),
}
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)
selfIdx := slices.IndexFunc(rel, func(a game.RaceRelation) bool { return a.RaceID == g.Race[i].ID })
g.Race[i].Relations = append(rel[:selfIdx], rel[selfIdx+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
})
for i := range gameMap.Planet {
g.Votes = g.Votes.Add(gameMap.Planet[i].Votes())
}
g.Map = *gameMap
return g, nil
}
func NewPlanet(num uint, name string, owner *uuid.UUID, x, y, size, pop, ind, res float64, prod game.Production) game.Planet {
if owner != nil && *owner == uuid.Nil {
owner = nil
}
return game.Planet{
Owner: owner,
X: game.F(x),
Y: game.F(y),
Number: num,
Size: game.F(size),
Name: name,
Resources: game.F(res),
Population: game.F(pop),
Industry: game.F(ind),
Production: prod,
}
}
@@ -0,0 +1,69 @@
package controller_test
import (
"fmt"
"path/filepath"
"strings"
"testing"
"galaxy/util"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/controller"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/iliadenisov/galaxy/server/internal/repo"
"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, "0000/state.json"))
g, err := r.LoadState()
assert.NoError(t, err)
assert.Equal(t, gameID, g.ID)
assert.Equal(t, uint(0), g.Turn)
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))
assert.Equal(t, uint(10), g.Race[r].TTL)
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 {
p := &g.Map.Planet[i]
if strings.HasPrefix(p.Name, "HW") || strings.HasPrefix(p.Name, "DW") {
assert.True(t, p.Owned())
assert.NotNil(t, p.Owner)
assert.NotEqual(t, uuid.Nil, *p.Owner)
}
numShuffled = numShuffled || p.Number != uint(i)
}
assert.True(t, numShuffled)
assert.NoError(t, r.Release())
}
+139
View File
@@ -0,0 +1,139 @@
package controller
import (
"maps"
"slices"
"galaxy/model/report"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
func (c *Controller) MakeTurn() error {
if err := c.applyOrders(c.Cache.g.Turn); err != nil {
return err
}
// Next turn
c.Cache.g.Turn += 1
c.Cache.g.Stage = 0
// 01. Вышедшие расы удаляются из списка участвующих рас перед началом просчета очередного хода
c.Cache.TurnWipeExtinctRaces()
// 02. Товары загружаются на корабли, находящиеся в начале грузовых маршрутов, и корабли входят в гиперпространство (но ещё не полетели)
c.Cache.SendRoutedGroups()
// 03. Корабли, где это возможно, объединяются в группы.
c.Cache.TurnMergeEqualShipGroups()
// 04. Враждующие корабли вступают в схватку.
battles := ProduceBattles(c.Cache)
// 05. Корабли пролетают сквозь гиперпространство.
c.Cache.MoveShipGroups()
// 06. Корабли, где это возможно, объединяются в группы.
c.Cache.TurnMergeEqualShipGroups()
// 07. Враждующие корабли снова вступают в схватку (это происходит после выхода из гиперпространства).
battles = append(battles, ProduceBattles(c.Cache)...)
// 08. Корабли бомбят вражеские планеты.
bombings := c.Cache.ProduceBombings()
// 09. На планетах строятся корабли.
// 10. Корабли, где это возможно, объединяются в группы.
// 11. На планетах производится промышленность, добывается сырье, разрабатываются новые технологии.
// 12. Увеличивается население планет.
c.Cache.TurnPlanetProductions()
// 13. Товары выгружаются в конце грузовых маршрутов.
// 14. Выгруженные колонисты увеличивают население планеты (если население планеты ниже её размера).
// 15. Накопленная и выгруженная промышленность увеличивает производственный уровень планеты (если производственный уровень планеты ниже уровня населения).
c.Cache.TurnUnloadEnroutedGroups()
// 16. Происходит отмена маршрутов, выходящих за зону полета кораблей.
c.Cache.RemoveUnreachableRoutes()
// 17. Происходит голосование.
winners := c.Cache.TurnCalculateVotes()
c.Cache.TurnAcceptWinners(winners)
/*** Last steps ***/
// Store bombings
bombingReport := make([]*report.Bombing, len(bombings))
if len(bombings) > 0 {
if err := c.Repo.SaveBombings(c.Cache.g.Turn, bombings); err != nil {
return err
}
for i := range bombings {
bombingReport[i].Planet = bombings[i].Planet
bombingReport[i].PlanetOwnedID = bombings[i].PlanetOwnedID
bombingReport[i].Number = bombings[i].Number
bombingReport[i].Owner = bombings[i].Owner
bombingReport[i].Attacker = bombings[i].Attacker
bombingReport[i].Production = bombings[i].Production
bombingReport[i].Industry = report.F(bombings[i].Industry.F())
bombingReport[i].Population = report.F(bombings[i].Population.F())
bombingReport[i].Colonists = report.F(bombings[i].Colonists.F())
bombingReport[i].Capital = report.F(bombings[i].Capital.F())
bombingReport[i].Material = report.F(bombings[i].Material.F())
bombingReport[i].AttackPower = report.F(bombings[i].AttackPower.F())
bombingReport[i].Wiped = bombings[i].Wiped
}
}
// Store battles
battleReport := make([]*report.BattleReport, len(battles))
if len(battles) > 0 {
battleMeta := make([]game.BattleMeta, len(battles))
for i := range battles {
b := battles[i]
observers := make(map[uuid.UUID]bool)
for sgi := range b.ObserverGroups {
observers[c.Cache.ShipGroup(sgi).OwnerID] = true
}
battleMeta[i] = game.BattleMeta{
Turn: c.Cache.g.Turn,
Planet: b.Planet,
BattleID: b.ID,
ObserverIDs: slices.Collect(maps.Keys(observers)),
}
report := TransformBattle(c.Cache, b)
if err := c.Repo.SaveBattle(c.Cache.g.Turn, report, &battleMeta[i]); err != nil {
return err
}
battleReport[i] = report
}
}
// Remove killed ship groups
c.Cache.DeleteKilledShipGroups()
// Store game state for the new turn and 'current' state as well
if err := c.Repo.SaveNewTurn(c.Cache.g.Turn, c.Cache.g); err != nil {
return err
}
for rep := range c.Cache.Report(c.Cache.g.Turn, battleReport, bombingReport) {
if err := c.Repo.SaveReport(c.Cache.g.Turn, rep); err != nil {
return err
}
}
for i := range c.Cache.g.Race {
if c.Cache.g.Race[i].Extinct {
continue
}
c.Cache.g.Race[i].TTL -= 1
}
// [ ] monitor memory consumption at this point?
return nil
}
+221
View File
@@ -0,0 +1,221 @@
package controller
import (
"errors"
"fmt"
"galaxy/model/order"
e "galaxy/error"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
func (c *Controller) ValidateOrder(actor string, commands ...order.DecodableCommand) (err error) {
for i := range commands {
if _, ok := commands[i].(order.CommandRaceQuit); ok && i != len(commands)-1 {
err = e.NewQuitCommandFollowedByCommandError()
}
if err != nil {
return err
}
err = errors.Join(err, c.applyCommand(actor, commands[i]))
}
return
}
func (c *Controller) applyCommand(actor string, cmd order.DecodableCommand) (err error) {
var m *order.CommandMeta
if v, ok := order.AsCommand[*order.CommandRaceQuit](cmd); ok {
m = &v.CommandMeta
err = c.RaceQuit(actor)
} else if v, ok := order.AsCommand[*order.CommandRaceVote](cmd); ok {
m = &v.CommandMeta
err = c.RaceVote(actor, v.Acceptor)
} else if v, ok := order.AsCommand[*order.CommandRaceRelation](cmd); ok {
m = &v.CommandMeta
err = c.RaceRelation(actor, v.Acceptor, v.Relation)
} else if v, ok := order.AsCommand[*order.CommandShipClassCreate](cmd); ok {
m = &v.CommandMeta
err = c.ShipClassCreate(actor, v.Name, v.Drive, int(v.Armament), v.Weapons, v.Shields, v.Cargo)
} else if v, ok := order.AsCommand[*order.CommandShipClassMerge](cmd); ok {
m = &v.CommandMeta
err = c.ShipClassMerge(actor, v.Name, v.Target)
} else if v, ok := order.AsCommand[*order.CommandShipClassRemove](cmd); ok {
m = &v.CommandMeta
err = c.ShipClassRemove(actor, v.Name)
} else if v, ok := order.AsCommand[*order.CommandShipGroupLoad](cmd); ok {
m = &v.CommandMeta
err = c.ShipGroupLoad(actor, uuid.MustParse(v.ID), v.Cargo, v.Quantity)
} else if v, ok := order.AsCommand[*order.CommandShipGroupUnload](cmd); ok {
m = &v.CommandMeta
err = c.ShipGroupUnload(actor, uuid.MustParse(v.ID), v.Quantity)
} else if v, ok := order.AsCommand[*order.CommandShipGroupSend](cmd); ok {
m = &v.CommandMeta
err = c.ShipGroupSend(actor, uuid.MustParse(v.ID), uint(v.Destination))
} else if v, ok := order.AsCommand[*order.CommandShipGroupUpgrade](cmd); ok {
m = &v.CommandMeta
err = c.ShipGroupUpgrade(actor, uuid.MustParse(v.ID), v.Tech, v.Level)
} else if v, ok := order.AsCommand[*order.CommandShipGroupMerge](cmd); ok {
m = &v.CommandMeta
err = c.ShipGroupMerge(actor)
} else if v, ok := order.AsCommand[*order.CommandShipGroupBreak](cmd); ok {
m = &v.CommandMeta
err = c.ShipGroupBreak(actor, uuid.MustParse(v.ID), uuid.MustParse(v.NewID), uint(v.Quantity))
} else if v, ok := order.AsCommand[*order.CommandShipGroupDismantle](cmd); ok {
m = &v.CommandMeta
err = c.ShipGroupDismantle(actor, uuid.MustParse(v.ID))
} else if v, ok := order.AsCommand[*order.CommandShipGroupTransfer](cmd); ok {
m = &v.CommandMeta
err = c.ShipGroupTransfer(actor, v.Acceptor, uuid.MustParse(v.ID))
} else if v, ok := order.AsCommand[*order.CommandShipGroupJoinFleet](cmd); ok {
m = &v.CommandMeta
err = c.ShipGroupJoinFleet(actor, v.Name, uuid.MustParse(v.ID))
} else if v, ok := order.AsCommand[*order.CommandFleetMerge](cmd); ok {
m = &v.CommandMeta
err = c.FleetMerge(actor, v.Name, v.Target)
} else if v, ok := order.AsCommand[*order.CommandFleetSend](cmd); ok {
m = &v.CommandMeta
err = c.FleetSend(actor, v.Name, uint(v.Destination))
} else if v, ok := order.AsCommand[*order.CommandScienceCreate](cmd); ok {
m = &v.CommandMeta
err = c.ScienceCreate(actor, v.Name, v.Drive, v.Weapons, v.Shields, v.Cargo)
} else if v, ok := order.AsCommand[*order.CommandScienceRemove](cmd); ok {
m = &v.CommandMeta
err = c.ScienceRemove(actor, v.Name)
} else if v, ok := order.AsCommand[*order.CommandPlanetRename](cmd); ok {
m = &v.CommandMeta
err = c.PlanetRename(actor, v.Number, v.Name)
} else if v, ok := order.AsCommand[*order.CommandPlanetProduce](cmd); ok {
m = &v.CommandMeta
err = c.PlanetProduce(actor, v.Number, v.Production, v.Subject)
} else if v, ok := order.AsCommand[*order.CommandPlanetRouteSet](cmd); ok {
m = &v.CommandMeta
err = c.PlanetRouteSet(actor, v.LoadType, uint(v.Origin), uint(v.Destination))
} else if v, ok := order.AsCommand[*order.CommandPlanetRouteRemove](cmd); ok {
m = &v.CommandMeta
err = c.PlanetRouteRemove(actor, v.LoadType, uint(v.Origin))
} else {
return e.NewUnrecognizedCommandError(cmd.CommandType().String())
}
if ge, ok := errors.AsType[*e.GenericError](err); ok {
m.Result(ge.Code)
} else if err != nil {
panic(fmt.Errorf("error applying command has unknown origin: %w", err))
} else {
m.Result(0)
}
return
}
func (c *Controller) applyOrders(t uint) error {
raceOrder := make(map[int][]order.DecodableCommand)
commandRace := make(map[string]string)
challenge := make(map[string]*order.CommandShipGroupUnload)
cmdApplied := make(map[string]bool)
for ri := range c.Cache.listRaceActingIdx() {
o, ok, err := c.Repo.LoadOrder(t, c.Cache.g.Race[ri].ID)
if err != nil {
return err
}
if !ok {
continue
}
raceOrder[ri] = o.Commands
for i := range o.Commands {
commandRace[o.Commands[i].CommandID()] = c.Cache.g.Race[ri].Name
if v, ok := order.AsCommand[*order.CommandShipGroupUnload](o.Commands[i]); ok {
if _, ok := challenge[v.ID]; ok {
panic(fmt.Sprintf("unload command %s already cached", v.ID))
}
if ok, err := c.shouldChallenge(v); err != nil {
return err
} else if ok {
challenge[v.ID] = v
}
}
}
}
for _, cmdID := range c.challengeUnload(challenge) {
if err := c.applyCommand(commandRace[cmdID], challenge[cmdID]); err == nil {
cmdApplied[cmdID] = true
}
}
for ri := range raceOrder {
for _, cmd := range raceOrder[ri] {
if v, ok := cmdApplied[cmd.CommandID()]; ok && v {
continue
}
// any command might fail due to challenged planets colonization
_ = c.applyCommand(commandRace[cmd.CommandID()], cmd)
}
}
for ri := range c.Cache.listRaceActingIdx() {
if err := c.Repo.SaveOrder(t, c.Cache.g.Race[ri].ID, &order.Order{Commands: raceOrder[ri]}); err != nil {
return err
}
}
return nil
}
func (c *Controller) shouldChallenge(cmd *order.CommandShipGroupUnload) (resut bool, err error) {
sgi, ok := c.Cache.shipGroupIndexByID(uuid.MustParse(cmd.ID))
if !ok {
err = e.NewGameStateError("challenge group unload: group not found: %v", cmd.ID)
return
}
sg := c.Cache.ShipGroup(sgi)
pn, ok := sg.AtPlanet()
if !ok || sg.CargoType == nil {
return false, nil
}
p := c.Cache.MustPlanet(pn)
if p.Owned() || *sg.CargoType != game.CargoColonist {
return false, nil
}
return true, nil
}
func (c *Controller) challengeUnload(challenge map[string]*order.CommandShipGroupUnload) []string {
if len(challenge) == 0 {
return nil
}
planetRaceQuantity := make(map[uint]map[int]float64, 0)
raceCommand := make(map[uint]map[int][]string)
for cmdID, cmd := range challenge {
sgi, ok := c.Cache.shipGroupIndexByID(uuid.MustParse(cmd.ID))
if !ok {
panic(fmt.Sprintf("challenge group unload: group not found: %v", cmd.ID))
}
sg := c.Cache.ShipGroup(sgi)
ri := c.Cache.ShipGroupOwnerRaceIndex(sgi)
pn, ok := sg.AtPlanet()
if _, ok := raceCommand[pn]; !ok {
raceCommand[pn] = make(map[int][]string)
}
raceCommand[pn][ri] = append(raceCommand[pn][ri], cmdID)
if _, ok := planetRaceQuantity[pn]; !ok {
planetRaceQuantity[pn] = make(map[int]float64)
}
planetRaceQuantity[pn][ri] = planetRaceQuantity[pn][ri] + UnloadCargoRequest(float64(sg.Load), cmd.Quantity)
}
result := make([]string, 0)
for pn := range planetRaceQuantity {
if len(planetRaceQuantity[pn]) < 2 {
continue
}
winner := MaxOrRandomLoadId(planetRaceQuantity[pn], func(ri int) float64 { return float64(c.Cache.g.Race[ri].Votes) })
result = append(result, raceCommand[pn][winner]...)
}
return result
}
+309
View File
@@ -0,0 +1,309 @@
package controller
import (
"fmt"
"iter"
"slices"
"galaxy/util"
e "galaxy/error"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
func (c *Cache) PlanetRename(ri int, number int, name string) error {
n, ok := util.ValidateTypeName(name)
if !ok {
return e.NewEntityTypeNameValidationError("%q", n)
}
if number < 0 {
return e.NewPlanetNumberError(number)
}
p, ok := c.Planet(uint(number))
if !ok {
return e.NewEntityNotExistsError("planet #%d", number)
}
if !p.OwnedBy(c.g.Race[ri].ID) {
return e.NewEntityNotOwnedError("planet #%d", number)
}
c.g.Map.Planet[c.MustPlanetIndex(p.Number)].Name = n
return nil
}
func (c *Cache) PlanetProduce(ri int, number int, prod game.ProductionType, subj string) error {
c.validateRaceIndex(ri)
if number < 0 {
return e.NewPlanetNumberError(number)
}
p, ok := c.Planet(uint(number))
if !ok {
return e.NewEntityNotExistsError("planet #%d", number)
}
if !p.OwnedBy(c.g.Race[ri].ID) {
return e.NewEntityNotOwnedError("planet #%d", number)
}
var subjectID *uuid.UUID
if prod == game.ResearchScience || prod == game.ProductionShip {
if _, ok := util.ValidateTypeName(subj); !ok {
return e.NewEntityTypeNameValidationError("%s=%q", prod, subj)
}
}
if prod == game.ResearchScience {
i := slices.IndexFunc(c.g.Race[ri].Sciences, func(s game.Science) bool { return s.Name == subj })
if i < 0 {
return e.NewEntityNotExistsError("science %q", subj)
}
subjectID = &c.g.Race[ri].Sciences[i].ID
}
if prod == game.ProductionShip {
st, _, ok := c.ShipClass(ri, subj)
if !ok {
return e.NewEntityNotExistsError("ship type %q", subj)
}
if p.Production.Type == game.ProductionShip &&
p.Production.SubjectID != nil &&
*p.Production.SubjectID == st.ID {
// Planet already produces this ship type, keeping progress intact
return nil
}
subjectID = &st.ID
}
if p.Production.Type == game.ProductionShip && (prod != game.ProductionShip || *subjectID != *p.Production.SubjectID) {
p.ReleaseMaterial(c.MustShipType(ri, *p.Production.SubjectID).EmptyMass())
} else if prod == game.ProductionShip {
// new ship class to produce; otherwise we must have been returned from the func earlier
p.Production.Progress = new(game.Float)
p.Production.ProdUsed = new(game.Float)
}
if prod != game.ProductionShip {
p.Production.Progress = nil
p.Production.ProdUsed = nil
}
p.Production.Type = prod
p.Production.SubjectID = subjectID
return nil
}
func (c *Cache) PlanetProductionDisplayName(pn uint) string {
p := c.MustPlanet(pn)
if !p.Owned() {
return "-"
}
ri := c.RaceIndex(*p.Owner)
switch pt := p.Production.Type; pt {
case game.ResearchDrive:
return "Drive"
case game.ResearchWeapons:
return "Weapons"
case game.ResearchShields:
return "Shields"
case game.ResearchCargo:
return "Cargo"
case game.ProductionMaterial:
return "Material"
case game.ProductionCapital:
return "Capital"
case game.ProductionShip:
return c.MustShipType(ri, *p.Production.SubjectID).Name
case game.ResearchScience:
i := slices.IndexFunc(c.g.Race[ri].Sciences, func(sc game.Science) bool { return sc.ID == *p.Production.SubjectID })
if i < 0 {
panic("researching science not found")
}
return c.g.Race[ri].Sciences[i].Name
default:
return string(pt)
}
}
func (c *Cache) Planet(planetNumber uint) (*game.Planet, bool) {
if c.cachePlanetByPlanetNumber == nil {
c.cachePlanetByPlanetNumber = make(map[uint]*game.Planet)
for p := range c.g.Map.Planet {
c.cachePlanetByPlanetNumber[c.g.Map.Planet[p].Number] = &c.g.Map.Planet[p]
}
}
if v, ok := c.cachePlanetByPlanetNumber[planetNumber]; ok {
return v, true
} else {
return nil, false
}
}
func (c *Cache) MustPlanet(pn uint) *game.Planet {
if v, ok := c.Planet(pn); ok {
return v
} else {
panic(fmt.Sprintf("planet not found by number=%d", pn))
}
}
func (c *Cache) MustPlanetIndex(pn uint) int {
if idx := slices.IndexFunc(c.g.Map.Planet, func(p game.Planet) bool { return p.Number == pn }); idx < 0 {
panic(fmt.Sprintf("planet not found by number=%d", pn))
} else {
return idx
}
}
// Свободный "Производственный Потенциал" (L)
// промышленность * 0.75 + население * 0.25
// за вычетом затрат, расходуемых в течение хода на модернизацию кораблей
func (c *Cache) PlanetProductionCapacity(planetNumber uint) float64 {
p := c.MustPlanet(planetNumber)
var busyResources float64
for sg := range c.shipGroupsInUpgrade(p.Number) {
busyResources += sg.StateUpgrade.Cost()
}
return p.ProductionCapacity() - busyResources
}
func (c *Cache) TurnPlanetProductions() {
for sgi := range c.ShipGroupsIndex() {
sg := c.ShipGroup(sgi)
// cancel upgrade for groups on wiped planets
if sg.State() == game.StateUpgrade && !c.MustPlanet(sg.Destination).Owned() {
sg.StateUpgrade = nil
}
}
for pn := range c.listProducingPlanets() {
p := c.MustPlanet(pn)
ri := c.RaceIndex(*p.Owner)
r := &c.g.Race[ri]
// upgrade groups and return to in_orbit state
productionAvailable := c.PlanetProductionCapacity(pn)
for sg := range c.shipGroupsInUpgrade(p.Number) {
cost := sg.StateUpgrade.Cost()
if productionAvailable >= cost {
for i := range sg.StateUpgrade.UpgradeTech {
sg.Tech = sg.Tech.Set(sg.StateUpgrade.UpgradeTech[i].Tech, util.Fixed3(sg.StateUpgrade.UpgradeTech[i].Level.F()))
}
productionAvailable -= cost
}
sg.StateUpgrade = nil
}
switch pt := p.Production.Type; pt {
case game.ProductionShip:
st := c.MustShipType(ri, *p.Production.SubjectID)
if ships := ProduceShip(p, productionAvailable, st.EmptyMass()); ships > 0 {
c.unsafeCreateShips(ri, st.ID, p.Number, ships)
}
case game.ResearchScience:
sc := c.mustScience(ri, *p.Production.SubjectID)
ResearchTech(r, productionAvailable, sc.Drive.F(), sc.Weapons.F(), sc.Shields.F(), sc.Cargo.F())
case game.ResearchDrive:
ResearchTech(r, productionAvailable, 1., 0, 0, 0)
case game.ResearchWeapons:
ResearchTech(r, productionAvailable, 0, 1., 0, 0)
case game.ResearchShields:
ResearchTech(r, productionAvailable, 0, 0, 1., 0)
case game.ResearchCargo:
ResearchTech(r, productionAvailable, 0, 0, 0, 1.)
case game.ProductionMaterial:
p.ProduceMaterial(productionAvailable)
case game.ProductionCapital:
p.ProduceIndustry(productionAvailable)
default:
panic(fmt.Sprintf("unprocessed production type: '%v' for planet: #%d owner=%v", pt, pn, p.Owner))
}
// last step: increase population / colonists
p.ProducePopulation()
}
c.TurnMergeEqualShipGroups()
}
// listProducingPlanets iterates over all inhabited planet numbers with defined production type.
// Planets producing ships guaranteed to be iterated first for correct turn actions order.
func (c *Cache) listProducingPlanets() iter.Seq[uint] {
ordered := make([]int, 0)
for i := range c.g.Map.Planet {
if !c.g.Map.Planet[i].Owned() || c.g.Map.Planet[i].Production.Type == game.ProductionNone {
continue
}
ordered = append(ordered, i)
}
slices.SortFunc(ordered, func(l, r int) int {
if c.g.Map.Planet[l].Production.Type == game.ProductionShip && c.g.Map.Planet[r].Production.Type != game.ProductionShip {
return -1
}
if c.g.Map.Planet[l].Production.Type != game.ProductionShip && c.g.Map.Planet[r].Production.Type == game.ProductionShip {
return 1
}
return 0
})
return func(yield func(uint) bool) {
for _, i := range ordered {
if !yield(c.g.Map.Planet[i].Number) {
return
}
}
}
}
// Internal funcs
func (c *Cache) putPopulation(pn uint, v float64) {
c.MustPlanet(pn).Pop(v)
}
func (c *Cache) putColonists(pn uint, v float64) {
c.MustPlanet(pn).Col(v)
}
func (c *Cache) putMaterial(pn uint, v float64) {
c.MustPlanet(pn).Mat(v)
}
func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint {
if productionAvailable <= 0 {
return 0
}
ships := uint(0)
pa := productionAvailable
PRODcost := ShipProductionCost(shipMass)
var MATneed, MATfarm, totalCost float64
for {
MATneed = shipMass - float64(p.Material)
if MATneed < 0 {
MATneed = 0
}
MATfarm = MATneed / float64(p.Resources)
totalCost = PRODcost + MATfarm
// fmt.Printf("PRODcost: %3.03f MATcost: %3.03f MAThave: %3.03f MATneed: %3.03f MATfarm: %3.03f total: %3.03f \n",
// PRODcost, shipMass, float64(p.Material), MATneed, MATfarm, totalCost)
if pa < totalCost {
progress := pa / totalCost
pval := game.F(progress)
if p.Production.Progress != nil {
pval += *p.Production.Progress
}
p.Production.Progress = &pval
fval := game.F(pa)
p.Production.ProdUsed = &fval
// fmt.Println("pa", pa, "progress", progress, "MAT:", progress*shipMass)
return ships
} else {
pa -= totalCost
p.Mat(float64(p.Material) - shipMass + MATneed)
ships += 1
}
}
}
func ShipProductionCost(shipMass float64) float64 {
return shipMass * 10.
}
func ShipMaterialCost(shipMass, planetResource float64) float64 {
return shipMass / planetResource
}
+423
View File
@@ -0,0 +1,423 @@
package controller_test
import (
"slices"
"testing"
"galaxy/util"
e "galaxy/error"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/controller"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestPlanetRename(t *testing.T) {
c, g := newCache()
assert.Equal(t, "Planet_0", c.MustPlanet(R0_Planet_0_num).Name)
assert.NoError(t, g.PlanetRename(Race_0.Name, int(R0_Planet_0_num), "Home_World"))
assert.Equal(t, "Home_World", c.MustPlanet(R0_Planet_0_num).Name)
assert.ErrorContains(t,
g.PlanetRename(UnknownRace, int(R0_Planet_0_num), "Home_World"),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.PlanetRename(Race_Extinct.Name, int(R0_Planet_0_num), "Home_World"),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.PlanetRename(Race_0.Name, -1, "Home_World"),
e.GenericErrorText(e.ErrInputPlanetNumber))
assert.ErrorContains(t,
g.PlanetRename(Race_0.Name, 500, "Home_World"),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.PlanetRename(Race_0.Name, int(R1_Planet_1_num), "Home_World"),
e.GenericErrorText(e.ErrInputEntityNotOwned))
}
func TestPlanetProduce(t *testing.T) {
c, g := newCache()
scienceName := "Drive_Shields"
assert.NoError(t, g.ScienceCreate(Race_0.Name, scienceName, 0.4, 0, 0.6, 0))
assert.Len(t, c.RaceScience(Race_0_idx), 1)
scID := c.RaceScience(Race_0_idx)[0].ID
assert.Equal(t, "-", c.PlanetProductionDisplayName(3))
pn := int(R0_Planet_0_num)
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "MAT", ""))
assert.Equal(t, game.ProductionMaterial, c.MustPlanet(R0_Planet_0_num).Production.Type)
assert.Nil(t, c.MustPlanet(R0_Planet_0_num).Production.SubjectID)
assert.Nil(t, c.MustPlanet(R0_Planet_0_num).Production.Progress)
assert.Equal(t, "Material", c.PlanetProductionDisplayName(R0_Planet_0_num))
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "CAP", ""))
assert.Equal(t, game.ProductionCapital, c.MustPlanet(R0_Planet_0_num).Production.Type)
assert.Nil(t, c.MustPlanet(R0_Planet_0_num).Production.SubjectID)
assert.Nil(t, c.MustPlanet(R0_Planet_0_num).Production.Progress)
assert.Equal(t, "Capital", c.PlanetProductionDisplayName(R0_Planet_0_num))
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "Weapons", "500"))
assert.Equal(t, game.ResearchWeapons, c.MustPlanet(R0_Planet_0_num).Production.Type)
assert.Nil(t, c.MustPlanet(R0_Planet_0_num).Production.SubjectID)
assert.Nil(t, c.MustPlanet(R0_Planet_0_num).Production.Progress)
assert.Equal(t, "Weapons", c.PlanetProductionDisplayName(R0_Planet_0_num))
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "cargo", ""))
assert.Equal(t, game.ResearchCargo, c.MustPlanet(R0_Planet_0_num).Production.Type)
assert.Nil(t, c.MustPlanet(R0_Planet_0_num).Production.SubjectID)
assert.Nil(t, c.MustPlanet(R0_Planet_0_num).Production.Progress)
assert.Equal(t, "Cargo", c.PlanetProductionDisplayName(R0_Planet_0_num))
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "SHIELDS", scienceName))
assert.Equal(t, game.ResearchShields, c.MustPlanet(R0_Planet_0_num).Production.Type)
assert.Nil(t, c.MustPlanet(R0_Planet_0_num).Production.SubjectID)
assert.Nil(t, c.MustPlanet(R0_Planet_0_num).Production.Progress)
assert.Equal(t, "Shields", c.PlanetProductionDisplayName(R0_Planet_0_num))
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "DrivE", ""))
assert.Equal(t, game.ResearchDrive, c.MustPlanet(R0_Planet_0_num).Production.Type)
assert.Nil(t, c.MustPlanet(R0_Planet_0_num).Production.SubjectID)
assert.Nil(t, c.MustPlanet(R0_Planet_0_num).Production.Progress)
assert.Equal(t, "Drive", c.PlanetProductionDisplayName(R0_Planet_0_num))
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "Science", scienceName))
assert.Equal(t, game.ResearchScience, c.MustPlanet(R0_Planet_0_num).Production.Type)
assert.Nil(t, c.MustPlanet(R0_Planet_0_num).Production.Progress)
assert.NotNil(t, c.MustPlanet(R0_Planet_0_num).Production.SubjectID)
assert.Equal(t, scID, *c.MustPlanet(R0_Planet_0_num).Production.SubjectID)
assert.Equal(t, scienceName, c.PlanetProductionDisplayName(R0_Planet_0_num))
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "SHIP", Race_0_Gunship))
assert.Equal(t, game.ProductionShip, c.MustPlanet(R0_Planet_0_num).Production.Type)
assert.NotNil(t, c.MustPlanet(R0_Planet_0_num).Production.Progress)
assert.NotNil(t, c.MustPlanet(R0_Planet_0_num).Production.SubjectID)
st := c.MustShipClass(Race_0_idx, Race_0_Gunship)
assert.Equal(t, st.ID, *c.MustPlanet(R0_Planet_0_num).Production.SubjectID)
assert.Equal(t, st.Name, c.PlanetProductionDisplayName(R0_Planet_0_num))
pn = int(R0_Planet_2_num)
assert.ErrorContains(t,
g.PlanetProduce(UnknownRace, pn, "DRIVE", ""),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.PlanetProduce(Race_Extinct.Name, pn, "DRIVE", ""),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.PlanetProduce(Race_0.Name, pn, "Hyperdrive", ""),
e.GenericErrorText(e.ErrInputProductionInvalid))
assert.ErrorContains(t,
g.PlanetProduce(Race_0.Name, -1, "DRIVE", ""),
e.GenericErrorText(e.ErrInputPlanetNumber))
assert.ErrorContains(t,
g.PlanetProduce(Race_0.Name, 500, "DRIVE", ""),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.PlanetProduce(Race_0.Name, int(R1_Planet_1_num), "DRIVE", ""),
e.GenericErrorText(e.ErrInputEntityNotOwned))
assert.ErrorContains(t,
g.PlanetProduce(Race_0.Name, pn, "Science", ""),
e.GenericErrorText(e.ErrInputEntityTypeNameInvalid))
assert.ErrorContains(t,
g.PlanetProduce(Race_0.Name, pn, "SHIP", ""),
e.GenericErrorText(e.ErrInputEntityTypeNameInvalid))
assert.ErrorContains(t,
g.PlanetProduce(Race_0.Name, pn, "Science", "Winning"),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.PlanetProduce(Race_0.Name, pn, "SHIP", "Drone"),
e.GenericErrorText(e.ErrInputEntityNotExists))
}
func TestPlanetProductionCapacity(t *testing.T) {
c, _ := newCache()
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1))
assert.Equal(t, 100., c.PlanetProductionCapacity(R0_Planet_0_num))
c.UpgradeShipGroup(0, game.TechDrive, 1.6)
assert.Equal(t, 53.125, c.PlanetProductionCapacity(R0_Planet_0_num))
}
func TestProduceShips(t *testing.T) {
c, g := newCache()
pn := int(R0_Planet_0_num)
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "SHIP", Race_0_Gunship))
assert.Equal(t, game.ProductionShip, c.MustPlanet(R0_Planet_0_num).Production.Type)
assert.NotNil(t, c.MustPlanet(R0_Planet_0_num).Production.Progress)
assert.NotNil(t, c.MustPlanet(R0_Planet_0_num).Production.SubjectID)
st := c.MustShipClass(Race_0_idx, Race_0_Gunship)
assert.Equal(t, st.ID, *c.MustPlanet(R0_Planet_0_num).Production.SubjectID)
c.MustPlanet(R0_Planet_0_num).Size = 1000.
c.MustPlanet(R0_Planet_0_num).Population = 1000.
c.MustPlanet(R0_Planet_0_num).Industry = 1000.
c.MustPlanet(R0_Planet_0_num).Resources = 10.
shipMass := st.EmptyMass()
c.TurnPlanetProductions()
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 0)
assert.NotNil(t, c.MustPlanet(R0_Planet_0_num).Production.Progress)
progress := *c.MustPlanet(R0_Planet_0_num).Production.Progress
assert.InDelta(t, 0.45, progress.F(), 0.001)
assert.NoError(t, c.ShipClassCreate(Race_0_idx, "Drone", 1, 0, 0, 0, 0))
assert.NoError(t, c.CreateShips(Race_0_idx, "Drone", uint(pn), 7))
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 1)
assert.Equal(t, uint(7), c.ShipGroup(0).Number)
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "SHIP", "Drone"))
assert.InDelta(t, shipMass*progress.F(), c.MustPlanet(R0_Planet_0_num).Material.F(), 0.00001) // 99.(0099) material build
c.MustPlanet(R0_Planet_0_num).Material = 0
c.MustPlanet(R0_Planet_0_num).Colonists = 0
c.TurnPlanetProductions()
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 1)
assert.Equal(t, uint(106), c.ShipGroup(0).Number)
progress = *c.MustPlanet(R0_Planet_0_num).Production.Progress
assert.InDelta(t, 0.0099, progress.F(), 0.00001) // 1.(0099) drones with no CAP on planet
//
// groups is upgrade state
//
assert.NoError(t, g.PlanetProduce(Race_0.Name, int(R0_Planet_0_num), "MAT", ""))
assert.NoError(t, g.PlanetProduce(Race_0.Name, int(R0_Planet_2_num), "CAP", ""))
assert.NoError(t, c.CreateShips(Race_0_idx, "Drone", R0_Planet_2_num, 5))
c.MustPlanet(R0_Planet_2_num).Resources = 5
c.MustPlanet(R0_Planet_2_num).Population = 100
c.MustPlanet(R0_Planet_2_num).Industry = 100
c.RaceTechLevel(Race_0_idx, game.TechDrive, 1.5)
assert.NoError(t, g.ShipGroupUpgrade(Race_0.Name, c.ShipGroup(1).ID, "Drive", 0))
assert.NoError(t, g.ShipGroupUpgrade(Race_0.Name, c.ShipGroup(0).ID, "Drive", 0))
assert.Equal(t, game.StateUpgrade, c.ShipGroup(0).State())
assert.Equal(t, game.StateUpgrade, c.ShipGroup(1).State())
c.MustPlanet(R0_Planet_2_num).Free() // wipe planet as battle result
c.TurnPlanetProductions()
assert.Equal(t, game.StateInOrbit, c.ShipGroup(1).State())
assert.Equal(t, 1.1, c.ShipGroup(1).TechLevel(game.TechDrive).F())
assert.Equal(t, game.StateInOrbit, c.ShipGroup(0).State())
assert.Equal(t, 1.5, c.ShipGroup(0).TechLevel(game.TechDrive).F())
assert.Equal(t, 4346.676567656759, c.MustPlanet(R0_Planet_0_num).Material.F())
}
func TestProduceShip(t *testing.T) {
Drone := game.ShipType{
Name: "Drone",
Drive: 1,
Armament: 0,
Weapons: 0,
Shields: 0,
Cargo: 0,
}
BattleShip := game.ShipType{
Name: "BattleShip",
Drive: 25,
Armament: 1,
Weapons: 30,
Shields: 35,
Cargo: 0,
}
TestShipCargo1 := game.ShipType{
Name: "Cargo1",
Drive: 30.18,
Armament: 0,
Weapons: 0.,
Shields: 0.,
Cargo: 19.,
}
TestShipDROCOLZ := game.ShipType{
Name: "DROCOLZ",
Drive: 11.32,
Armament: 0,
Weapons: 0,
Shields: 0,
Cargo: 1,
}
TestShipElephant := game.ShipType{
Name: "ElEphant",
Drive: 80,
Armament: 30,
Weapons: 50,
Shields: 100,
Cargo: 0,
}
id := uuid.New()
var r uint
hw := controller.NewPlanet(0, "Planet_0", &id, 1, 1, 1000, 1000, 1000, 10, game.ProductionShip.AsType(uuid.Nil))
//
// documented data
//
r = controller.ProduceShip(&hw, hw.ProductionCapacity(), Drone.EmptyMass())
assert.Equal(t, uint(99), r)
assert.InDelta(t, 0.0099, (*hw.Production.Progress).F(), 0.000001)
assert.Equal(t, 0.009900990099, (*hw.Production.Progress).F()) // 0.0099 % = 99.0099 mass production per turn
(&hw).Production = game.ProductionShip.AsType(uuid.Nil)
(&hw).Material = 100. // no material deficit
r = controller.ProduceShip(&hw, hw.ProductionCapacity(), Drone.EmptyMass())
assert.Equal(t, uint(100), r)
assert.Equal(t, 0., (*hw.Production.Progress).F())
assert.Equal(t, 0., hw.Material.F())
(&hw).Production = game.ProductionShip.AsType(uuid.Nil)
(&hw).Material = 0.
r = controller.ProduceShip(&hw, hw.ProductionCapacity(), BattleShip.EmptyMass())
assert.Equal(t, uint(1), r)
assert.InDelta(t, 0.1, (*hw.Production.Progress).F(), 0.001)
(&hw).Production = game.ProductionShip.AsType(uuid.Nil)
(&hw).Material = 900. // no material deficit
r = controller.ProduceShip(&hw, hw.ProductionCapacity(), BattleShip.EmptyMass())
assert.Equal(t, uint(1), r)
assert.Equal(t, util.Fixed12(1./9.), (*hw.Production.Progress).F())
//
// real report data
//
dw1 := controller.NewPlanet(0, "DW2", &id, 1, 1, 500, 500, 500, 10, game.ProductionShip.AsType(uuid.Nil))
dw2 := controller.NewPlanet(0, "DW1", &id, 1, 1, 500, 500, 500, 10, game.ProductionShip.AsType(uuid.Nil))
(&dw1).Material = 0.0
r = controller.ProduceShip(&dw1, dw1.ProductionCapacity(), TestShipDROCOLZ.EmptyMass())
assert.Equal(t, uint(4), r)
assert.Equal(t, 2.272, (*dw1.Production.ProdUsed).F())
(&dw2).Material = 0.0
r = controller.ProduceShip(&dw2, dw2.ProductionCapacity(), TestShipCargo1.EmptyMass())
assert.Equal(t, uint(1), r)
assert.Equal(t, 3.282, (*dw2.Production.ProdUsed).F())
// production stopped and extra MAT released
(&dw2).ReleaseMaterial(TestShipCargo1.EmptyMass())
assert.Equal(t, 0.32495049505, (&dw2).Material.F()) // from report: 0.32
// building new ship with extra MAT
r = controller.ProduceShip(&dw2, dw2.ProductionCapacity(), TestShipDROCOLZ.EmptyMass())
assert.Equal(t, uint(4), r)
assert.Equal(t, 2.304495049505, (*dw2.Production.ProdUsed).F()) // from report: 2.3
//
// insufficient production capacity to produce single ship at one turn
//
assert.Greater(t, TestShipElephant.EmptyMass(), 100.)
// one turn
(&hw).Production = game.ProductionShip.AsType(uuid.Nil)
(&hw).Material = 0.
r = controller.ProduceShip(&hw, hw.ProductionCapacity(), TestShipElephant.EmptyMass())
assert.Equal(t, uint(0), r)
assert.Equal(t, 0., (&hw).Material.F())
assert.InDelta(t, 0.1, (*hw.Production.Progress).F(), 0.01)
(&hw).ReleaseMaterial(TestShipElephant.EmptyMass())
assert.Equal(t, 99.009900990099, (&hw).Material.F())
// two turns
(&hw).Production = game.ProductionShip.AsType(uuid.Nil)
(&hw).Material = 0.
r = controller.ProduceShip(&hw, hw.ProductionCapacity(), TestShipElephant.EmptyMass())
assert.Equal(t, uint(0), r)
assert.Equal(t, 0., (&hw).Material.F())
assert.InDelta(t, 0.1, (*hw.Production.Progress).F(), 0.01)
r = controller.ProduceShip(&hw, hw.ProductionCapacity(), TestShipElephant.EmptyMass())
assert.Equal(t, uint(0), r)
assert.Equal(t, 0., (&hw).Material.F())
assert.InDelta(t, 0.2, (*hw.Production.Progress).F(), 0.01)
(&hw).ReleaseMaterial(TestShipElephant.EmptyMass())
assert.Equal(t, 198.019801980198, (&hw).Material.F())
}
func TestListProducingPlanets(t *testing.T) {
c, g := newCache()
c.MustPlanet(0).Production = game.ProductionNone.AsType(uuid.Nil)
c.MustPlanet(1).Production = game.ProductionNone.AsType(uuid.Nil)
c.MustPlanet(2).Production = game.ProductionNone.AsType(uuid.Nil)
planets := slices.Collect(c.ListProducingPlanets())
assert.Len(t, planets, 0)
assert.NoError(t, g.PlanetProduce(Race_0.Name, int(R0_Planet_0_num), "CAP", ""))
planets = slices.Collect(c.ListProducingPlanets())
assert.Len(t, planets, 1)
assert.Equal(t, R0_Planet_0_num, c.MustPlanet(planets[0]).Number)
assert.NoError(t, g.PlanetProduce(Race_0.Name, int(R0_Planet_2_num), "SHIP", Race_0_Gunship))
planets = slices.Collect(c.ListProducingPlanets())
assert.Len(t, planets, 2)
assert.Equal(t, R0_Planet_2_num, c.MustPlanet(planets[0]).Number)
assert.Equal(t, R0_Planet_0_num, c.MustPlanet(planets[1]).Number)
}
func TestTurnPlanetProductions(t *testing.T) {
c, g := newCache()
assert.NoError(t, c.ShipClassCreate(Race_0_idx, "Drone", 1, 0, 0, 0, 0))
assert.NoError(t, g.ScienceCreate(Race_0.Name, "Equality", 0.25, 0.25, 0.25, 0.25))
c.MustPlanet(R0_Planet_0_num).Resources = 10.
c.MustPlanet(R0_Planet_0_num).Size = 1000.
c.MustPlanet(R0_Planet_0_num).Population = 1000.
c.MustPlanet(R0_Planet_0_num).Industry = 1000.
pn := int(R0_Planet_0_num)
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "CAP", ""))
assert.Equal(t, 0.0, c.MustPlanet(R0_Planet_0_num).Capital.F())
assert.Equal(t, 0.0, c.MustPlanet(R0_Planet_0_num).Colonists.F())
c.TurnPlanetProductions()
assert.InDelta(t, 196., c.MustPlanet(R0_Planet_0_num).Capital.F(), 0.1)
assert.Equal(t, 10.0, c.MustPlanet(R0_Planet_0_num).Colonists.F())
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "MAT", ""))
assert.Equal(t, 0.0, c.MustPlanet(R0_Planet_0_num).Material.F())
c.TurnPlanetProductions()
assert.Equal(t, 10000., c.MustPlanet(R0_Planet_0_num).Material.F())
assert.InDelta(t, 20.0, c.MustPlanet(R0_Planet_0_num).Colonists.F(), 0.000001)
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "DRIVE", ""))
assert.Equal(t, 1.1, c.Race(Race_0_idx).TechLevel(game.TechDrive))
c.TurnPlanetProductions()
assert.Equal(t, 1.3, c.Race(Race_0_idx).TechLevel(game.TechDrive))
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "WEAPONS", ""))
assert.Equal(t, 1.2, c.Race(Race_0_idx).TechLevel(game.TechWeapons))
c.TurnPlanetProductions()
assert.Equal(t, 1.4, c.Race(Race_0_idx).TechLevel(game.TechWeapons))
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "SHIELDS", ""))
assert.Equal(t, 1.3, c.Race(Race_0_idx).TechLevel(game.TechShields))
c.TurnPlanetProductions()
assert.Equal(t, 1.5, c.Race(Race_0_idx).TechLevel(game.TechShields))
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "CARGO", ""))
assert.Equal(t, 1.4, c.Race(Race_0_idx).TechLevel(game.TechCargo))
c.TurnPlanetProductions()
assert.Equal(t, 1.6, c.Race(Race_0_idx).TechLevel(game.TechCargo))
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "SCIENCE", "Equality"))
c.TurnPlanetProductions()
assert.Equal(t, 1.35, c.Race(Race_0_idx).TechLevel(game.TechDrive))
assert.Equal(t, 1.45, c.Race(Race_0_idx).TechLevel(game.TechWeapons))
assert.Equal(t, 1.55, c.Race(Race_0_idx).TechLevel(game.TechShields))
assert.Equal(t, 1.65, c.Race(Race_0_idx).TechLevel(game.TechCargo))
assert.NoError(t, g.PlanetProduce(Race_0.Name, pn, "SHIP", "Drone"))
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 0)
c.TurnPlanetProductions()
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 1)
assert.Equal(t, uint(100), c.ShipGroup(0).Number)
}
+178
View File
@@ -0,0 +1,178 @@
package controller
import (
"fmt"
"iter"
"slices"
e "galaxy/error"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
func (c *Cache) Relation(r1, r2 int) game.Relation {
if c.cacheRelation == nil {
c.cacheRelation = make(map[int]map[int]game.Relation)
for r1 := range c.listRaceActingIdx() {
for r2 := range c.listRaceActingIdx() {
if r1 == r2 {
continue
}
rel := slices.IndexFunc(c.g.Race[r1].Relations, func(r game.RaceRelation) bool { return r.RaceID == c.g.Race[r2].ID })
if rel < 0 {
panic(fmt.Sprintf("Relation: opponent not found idx=%d", r2))
}
c.updateRelationCache(r1, r2, c.g.Race[r1].Relations[rel].Relation)
}
}
}
if _, ok := c.cacheRelation[r1]; !ok {
panic(fmt.Sprintf("Relation: no left race idx=%d", r1))
}
if v, ok := c.cacheRelation[r1][r2]; !ok {
panic(fmt.Sprintf("Relation: no right race idx=%d", r2))
} else {
return v
}
}
func (c *Cache) updateRelationCache(r1, r2 int, rel game.Relation) {
if r1 == r2 {
return
}
if c.cacheRelation == nil {
c.cacheRelation = make(map[int]map[int]game.Relation)
}
if _, ok := c.cacheRelation[r1]; !ok {
c.cacheRelation[r1] = make(map[int]game.Relation)
}
c.cacheRelation[r1][r2] = rel
}
func (c *Cache) Voted(ri int) int {
c.validateRaceIndex(ri)
return c.RaceIndex(c.g.Race[ri].VoteFor)
}
func (c *Cache) UpdateRelation(ri, other int, rel game.Relation) (err error) {
defer func() {
if err == nil && c.cacheRelation != nil {
c.updateRelationCache(ri, other, rel)
}
}()
for o := range c.g.Race[ri].Relations {
switch {
case ri == other:
c.g.Race[ri].Relations[o].Relation = rel
case c.g.Race[ri].Relations[o].RaceID == c.g.Race[other].ID:
c.g.Race[ri].Relations[o].Relation = rel
return nil
}
}
if ri != other {
err = e.NewGameStateError("UpdateRelation: opponent not found")
}
return
}
func (c *Cache) validateRaceIndex(i int) {
if i >= len(c.g.Race) {
panic(fmt.Sprintf("race index out of range: %d >= %d", i, len(c.g.Race)))
}
}
func (c *Cache) validActor(name string) (int, error) {
i, err := c.validRace(name)
if err != nil {
return -1, err
}
c.g.Race[i].TTL = 10
return i, nil
}
// validRace returns index of race with given name or error when race not found or extinct
func (c *Cache) validRace(name string) (int, error) {
i, err := c.raceIndex(name)
if err != nil {
return -1, err
}
if c.g.Race[i].Extinct {
return -1, e.NewRaceExinctError(name)
}
return i, nil
}
func (c *Cache) raceIndex(name string) (int, error) {
i := slices.IndexFunc(c.g.Race, func(r game.Race) bool { return r.Name == name })
if i < 0 {
return i, e.NewRaceUnknownError(name)
}
return i, nil
}
func (c *Cache) raceTechLevel(ri int, t game.Tech, v float64) {
c.validateRaceIndex(ri)
c.g.Race[ri].Tech = c.g.Race[ri].Tech.Set(t, v)
}
func (c *Cache) TurnWipeExtinctRaces() {
for i := range c.listRaceActingIdx() {
if c.g.Race[i].TTL == 0 {
c.wipeRace(i)
}
}
}
func (c *Cache) wipeRace(ri int) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
c.g.ShipGroups = slices.DeleteFunc(c.g.ShipGroups, func(v game.ShipGroup) bool { return v.OwnerID == r.ID })
c.g.Fleets = slices.DeleteFunc(c.g.Fleets, func(v game.Fleet) bool { return v.OwnerID == r.ID })
clear(r.ShipTypes)
clear(r.Sciences)
for i := range c.g.Map.Planet {
p := &c.g.Map.Planet[i]
if p.Owner != nil && *p.Owner != r.ID {
continue
}
p.Wipe()
}
for i := range c.listRaceActingIdx() {
if i == ri {
continue
}
if c.g.Race[i].VoteFor == r.ID {
c.g.Race[i].VoteFor = c.g.Race[i].ID
}
}
r.Votes = 0
r.VoteFor = r.ID
r.Extinct = true
r.TTL = 0
c.invalidateFleetCache()
c.invalidateShipGroupCache()
}
func (c *Cache) listRaceActingIdx() iter.Seq[int] {
return func(yield func(int) bool) {
for i := range c.listRaceIdx() {
if c.g.Race[i].Extinct {
continue
}
if !yield(i) {
return
}
}
}
}
func (c *Cache) listRaceIdx() iter.Seq[int] {
return func(yield func(int) bool) {
for i := range c.g.Race {
if !yield(i) {
return
}
}
}
}
+91
View File
@@ -0,0 +1,91 @@
package controller_test
import (
"testing"
e "galaxy/error"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestRaceVote(t *testing.T) {
c, g := newCache()
assert.Equal(t, c.Voted(Race_0_idx), Race_0_idx)
assert.Equal(t, c.Voted(Race_1_idx), Race_1_idx)
assert.NoError(t, g.RaceVote(Race_0.Name, Race_1.Name))
assert.Equal(t, Race_1_idx, c.Voted(Race_0_idx))
assert.Equal(t, Race_1_idx, c.Voted(Race_1_idx))
assert.ErrorContains(t,
g.RaceVote(UnknownRace, Race_1.Name),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.RaceVote(Race_0.Name, UnknownRace),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.RaceVote(Race_0.Name, Race_Extinct.Name),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.RaceVote(Race_Extinct.Name, Race_1.Name),
e.GenericErrorText(e.ErrRaceExinct))
}
func TestRaceRelation(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.RaceRelation(Race_0.Name, Race_1.Name, "war"))
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, "PEACE"))
assert.Equal(t, game.RelationWar, c.Relation(Race_0_idx, Race_1_idx))
assert.Equal(t, game.RelationPeace, c.Relation(Race_1_idx, Race_0_idx))
assert.ErrorContains(t,
g.RaceRelation(Race_0.Name, Race_1.Name, "Wojna"),
e.GenericErrorText(e.ErrInputUnknownRelation))
assert.ErrorContains(t,
g.RaceRelation(Race_0.Name, UnknownRace, "War"),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.RaceRelation(UnknownRace, Race_0.Name, "Peace"),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.RaceRelation(Race_0.Name, Race_Extinct.Name, "War"),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.RaceRelation(Race_Extinct.Name, Race_0.Name, "War"),
e.GenericErrorText(e.ErrRaceExinct))
}
func TestRaceQuit(t *testing.T) {
c, g := newCache()
assert.ErrorContains(t,
g.RaceQuit(UnknownRace),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.RaceQuit(Race_Extinct.Name),
e.GenericErrorText(e.ErrRaceExinct))
assert.NoError(t, g.RaceQuit(Race_0.Name))
assert.Equal(t, 3, int(c.Race(Race_0_idx).TTL))
}
func TestRaceID(t *testing.T) {
c, g := newCache()
c.Race(Race_0_idx).TTL = 9
_, err := g.RaceID(UnknownRace)
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputUnknownRace))
_, err = g.RaceID(Race_Extinct.Name)
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrRaceExinct))
id, err := g.RaceID(Race_0.Name)
assert.NoError(t, err)
assert.Equal(t, Race_0_ID, id)
}
+776
View File
@@ -0,0 +1,776 @@
package controller
import (
"cmp"
"fmt"
"iter"
"slices"
mr "galaxy/model/report"
"galaxy/util"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
func (c *Cache) Report(t uint, battles []*mr.BattleReport, bombings []*mr.Bombing) iter.Seq[*mr.Report] {
report := c.InitReport(t)
return func(yield func(*mr.Report) bool) {
for i := range c.listRaceActingIdx() {
c.ReportRace(i, report, battles, bombings)
if !yield(report) {
break
}
}
}
}
func (c *Cache) InitReport(t uint) *mr.Report {
report := &mr.Report{
Turn: t,
Width: c.g.Map.Width,
Height: c.g.Map.Height,
PlanetCount: uint32(len(c.g.Map.Planet)),
Player: make([]mr.Player, len(c.g.Race)),
LocalScience: make([]mr.Science, 0, 10),
OtherScience: make([]mr.OtherScience, 0, 10),
LocalShipClass: make([]mr.ShipClass, 0, 20),
OtherShipClass: make([]mr.OthersShipClass, 0, 50),
Battle: make([]uuid.UUID, 0, 10),
Bombing: make([]*mr.Bombing, 0, 10),
IncomingGroup: make([]mr.IncomingGroup, 0, 10),
OnPlanetGroupCache: make(map[uint][]int),
InSpaceGroupRangeCache: make(map[int]map[uint]float64),
}
sumVote, sumPop, sumInd := make(map[int]float64), make(map[int]float64), make(map[int]float64)
planets := make(map[int]uint16)
for pi := range c.g.Map.Planet {
p := &c.g.Map.Planet[pi]
if !p.Owned() {
continue
}
ri := c.RaceIndex(*p.Owner)
sumPop[ri] += p.Population.F()
sumInd[ri] += p.Industry.F()
planets[ri] = planets[ri] + 1
}
for ri := range c.listRaceIdx() {
r := &c.g.Race[ri]
rr := &report.Player[ri]
rr.ID = r.ID
rr.Name = r.Name
rr.Extinct = r.Extinct
rr.Drive = mr.F(r.TechLevel(game.TechDrive))
rr.Weapons = mr.F(r.TechLevel(game.TechWeapons))
rr.Shields = mr.F(r.TechLevel(game.TechShields))
rr.Cargo = mr.F(r.TechLevel(game.TechCargo))
rr.Planets = planets[ri]
rr.Population = mr.F(sumPop[ri])
rr.Industry = mr.F(sumInd[ri])
// give voices by race index
if vi := slices.IndexFunc(c.g.Race, func(v game.Race) bool { return r.VoteFor == v.ID }); vi < 0 {
panic(fmt.Sprintf("voting for unknown race, id=%v", r.VoteFor))
} else {
sumVote[vi] += r.Votes.F()
dest := &report.Player[vi]
dest.Votes = mr.F(sumVote[vi])
}
}
slices.SortFunc(report.Player, func(a, b mr.Player) int { return cmp.Compare(a.Name, b.Name) })
for sgi := range c.g.ShipGroups {
sg := &c.g.ShipGroups[sgi]
if sg.State() == game.StateInSpace {
// pre-calculate distances from in_space ship groups to every planet
if _, ok := report.InSpaceGroupRangeCache[sgi]; !ok {
report.InSpaceGroupRangeCache[sgi] = make(map[uint]float64)
}
for pi := range c.g.Map.Planet {
p2 := &c.g.Map.Planet[pi]
distance := util.ShortDistance(c.g.Map.Width, c.g.Map.Height, sg.StateInSpace.X.F(), sg.StateInSpace.Y.F(), p2.X.F(), p2.Y.F())
report.InSpaceGroupRangeCache[sgi][p2.Number] = distance
}
} else {
// collect all orbiting ship groups by planet
report.OnPlanetGroupCache[sg.Destination] = append(report.OnPlanetGroupCache[sg.Destination], sgi)
}
}
return report
}
func (c *Cache) ReportRace(ri int, rep *mr.Report, battles []*mr.BattleReport, bombings []*mr.Bombing) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
rep.Race = r.Name
rep.RaceID = r.ID
// votes based on population
rep.Votes = mr.F(r.Votes.F())
// relations
for i := range r.Relations {
rii := slices.IndexFunc(rep.Player, func(v mr.Player) bool { return v.ID == r.Relations[i].RaceID })
if rii < 0 {
panic(fmt.Sprintf("opponent race for relation not found, id=%v", r.Relations[i].RaceID))
}
rep.Player[rii].Relation = r.Relations[i].Relation.String()
}
// self-relation is undefined
if i := slices.IndexFunc(rep.Player, func(v mr.Player) bool { return v.ID == r.ID }); i < 0 {
panic(fmt.Sprintf("race not found in report, id=%v", r.ID))
} else {
rep.Player[i].Relation = "-"
}
// sciences
c.ReportLocalScience(ri, rep)
c.ReportOtherScience(ri, rep)
// ship classes
c.ReportLocalShipClass(ri, rep)
c.ReportOtherShipClass(ri, rep)
// battles
c.ReportBattle(ri, rep, battles)
// bombings
c.ReportBombing(ri, rep, bombings)
// incoming groups
c.ReportIncomingGroup(ri, rep)
// player's planets
c.ReportLocalPlanet(ri, rep)
// ships in production
c.ReportShipProduction(ri, rep)
// cargo routes
c.ReportRoute(ri, rep)
// others' planets
c.ReportOtherPlanet(ri, rep)
// uninhabited planets
c.ReportUninhabitedPlanet(ri, rep)
// unidentified planets
c.ReportUnidentifiedPlanet(ri, rep)
// fleets
c.ReportLocalFleet(ri, rep)
// player's groups
c.ReportLocalGroup(ri, rep)
// others' groups
c.ReportOtherGroup(ri, rep)
// unidentified groups
c.ReportUnidentifiedGroup(ri, rep)
}
func (c *Cache) ReportLocalScience(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
clear(rep.LocalScience)
for i := range r.Sciences {
sliceIndexValidate(&rep.LocalScience, i)
rep.LocalScience[i].Name = r.Sciences[i].Name
rep.LocalScience[i].Drive = mr.F(r.Sciences[i].Drive.F())
rep.LocalScience[i].Weapons = mr.F(r.Sciences[i].Weapons.F())
rep.LocalScience[i].Shields = mr.F(r.Sciences[i].Shields.F())
rep.LocalScience[i].Cargo = mr.F(r.Sciences[i].Cargo.F())
}
slices.SortFunc(rep.LocalScience, func(a, b mr.Science) int { return cmp.Compare(a.Name, b.Name) })
}
func (c *Cache) ReportOtherScience(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
clear(rep.OtherScience)
i := 0
for sg := range c.listShipGroups(ri) {
if sg.State() != game.StateInOrbit {
continue
}
p := c.MustPlanet(sg.Destination)
if !p.Owned() || p.OwnedBy(r.ID) || p.Production.Type != game.ResearchScience {
continue
}
ownerIdx := c.RaceIndex(*p.Owner)
owner := &c.g.Race[ownerIdx]
sc := c.mustScience(ownerIdx, *p.Production.SubjectID)
sliceIndexValidate(&rep.OtherScience, i)
rep.OtherScience[i].Name = owner.Name
rep.OtherScience[i].Drive = mr.F(sc.Drive.F())
rep.OtherScience[i].Weapons = mr.F(sc.Weapons.F())
rep.OtherScience[i].Shields = mr.F(sc.Shields.F())
rep.OtherScience[i].Cargo = mr.F(sc.Cargo.F())
i++
}
slices.SortFunc(rep.OtherScience, func(a, b mr.OtherScience) int {
return cmp.Or(cmp.Compare(a.Race, b.Race), cmp.Compare(a.Name, b.Name))
})
}
func (c *Cache) ReportLocalShipClass(ri int, report *mr.Report) {
c.validateRaceIndex(ri)
clear(report.LocalShipClass)
i := 0
for st := range c.ListShipTypes(ri) {
sliceIndexValidate(&report.LocalShipClass, i)
report.LocalShipClass[i].Name = st.Name
report.LocalShipClass[i].Drive = mr.F(st.Drive.F())
report.LocalShipClass[i].Armament = st.Armament
report.LocalShipClass[i].Weapons = mr.F(st.Weapons.F())
report.LocalShipClass[i].Shields = mr.F(st.Shields.F())
report.LocalShipClass[i].Cargo = mr.F(st.Cargo.F())
report.LocalShipClass[i].Mass = mr.F(st.EmptyMass())
i++
}
slices.SortFunc(report.LocalShipClass, func(a, b mr.ShipClass) int { return cmp.Compare(a.Name, b.Name) })
}
func (c *Cache) ReportOtherShipClass(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
clear(rep.OtherShipClass)
i := 0
used := make(map[uuid.UUID]map[string]bool)
skip := func(ownerID uuid.UUID, className string) bool {
if ownerID == r.ID {
return true
}
if _, ok := used[ownerID]; ok {
if _, ok := used[ownerID][className]; ok {
return true
}
} else {
used[ownerID] = make(map[string]bool)
}
used[ownerID][className] = true
return false
}
// add visible ship classes from battles
// for bi := range battle {
// for si := range battle[bi].Ships {
// g := battle[bi].Ships[si]
// if skip(g.OwnerID, g.ClassName) {
// continue
// }
// sliceIndexValidate(&rep.OtherShipClass, i)
// rep.OtherShipClass[i].Race = c.g.Race[c.RaceIndex(g.OwnerID)].Name
// rep.OtherShipClass[i].Name = g.ClassName
// rep.OtherShipClass[i].Drive = g.DriveTech
// rep.OtherShipClass[i].Armament = g.ClassArmament
// rep.OtherShipClass[i].Weapons = g.WeaponsTech
// rep.OtherShipClass[i].Shields = g.ShieldsTech
// rep.OtherShipClass[i].Cargo = g.CargoTech
// rep.OtherShipClass[i].Mass = g.ClassMass
// i++
// }
// }
// add visible ships from owned and observed planets
for pn := range rep.OnPlanetGroupCache {
p := c.MustPlanet(pn)
if p.OwnedBy(r.ID) ||
slices.IndexFunc(rep.OnPlanetGroupCache[pn], func(sgi int) bool { return c.ShipGroup(sgi).OwnerID == r.ID }) >= 0 {
for _, sgi := range rep.OnPlanetGroupCache[pn] {
sg := c.ShipGroup(sgi)
st := c.ShipGroupShipClass(sgi)
if skip(sg.OwnerID, st.Name) {
continue
}
sliceIndexValidate(&rep.OtherShipClass, i)
rep.OtherShipClass[i].Race = c.g.Race[c.RaceIndex(sg.OwnerID)].Name
rep.OtherShipClass[i].Name = st.Name
rep.OtherShipClass[i].Drive = mr.F(st.Drive.F())
rep.OtherShipClass[i].Armament = st.Armament
rep.OtherShipClass[i].Weapons = mr.F(st.Weapons.F())
rep.OtherShipClass[i].Shields = mr.F(st.Shields.F())
rep.OtherShipClass[i].Cargo = mr.F(st.Cargo.F())
rep.OtherShipClass[i].Mass = mr.F(st.EmptyMass())
i++
}
}
}
slices.SortFunc(rep.OtherShipClass, func(a, b mr.OthersShipClass) int {
return cmp.Or(cmp.Compare(a.Race, b.Race), cmp.Compare(a.Name, b.Name))
})
}
func (c *Cache) ReportBattle(ri int, rep *mr.Report, br []*mr.BattleReport) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
clear(rep.Battle)
i := 0
for bi := range br {
visible := false
for k := range br[bi].Races {
visible = visible || br[bi].Races[k] == r.ID
}
if !visible {
continue
}
sliceIndexValidate(&rep.Battle, i)
rep.Battle[i] = br[bi].ID
i++
}
}
func (c *Cache) ReportBombing(ri int, rep *mr.Report, bombing []*mr.Bombing) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
clear(rep.Bombing)
i := 0
for bi := range bombing {
pn := bombing[bi].Number
visible := bombing[bi].PlanetOwnedID == r.ID // planet may be bombed and wiped
for _, sgi := range rep.OnPlanetGroupCache[pn] {
sg := c.ShipGroup(sgi)
visible = visible || (sg.OwnerID == r.ID && sg.Destination == pn)
}
if !visible {
continue
}
sliceIndexValidate(&rep.Bombing, i)
rep.Bombing[i] = bombing[bi]
i++
}
slices.SortFunc(rep.Bombing, func(a, b *mr.Bombing) int {
return cmp.Or(cmp.Compare(a.Number, b.Number), boolCompare(a.Wiped, b.Wiped))
})
}
func (c *Cache) ReportIncomingGroup(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
clear(rep.IncomingGroup)
i := 0
for sgi := range c.ShipGroupsIndex() {
sg := c.ShipGroup(sgi)
st := c.ShipGroupShipClass(sgi)
if sg.OwnerID == r.ID || sg.State() != game.StateInSpace {
continue
}
p1 := c.MustPlanet(sg.StateInSpace.Origin)
p2 := c.MustPlanet(sg.Destination)
if !p2.OwnedBy(r.ID) {
continue
}
distance := util.ShortDistance(c.g.Map.Width, c.g.Map.Height, p1.X.F(), p1.Y.F(), p2.X.F(), p2.Y.F())
var speed, mass float64
if sg.FleetID != nil {
speed, mass = c.FleetSpeedAndMass(c.MustFleetIndex(*sg.FleetID))
} else {
speed, mass = sg.Speed(st), sg.FullMass(st)
}
sliceIndexValidate(&rep.IncomingGroup, i)
rep.IncomingGroup[i].Origin = sg.StateInSpace.Origin
rep.IncomingGroup[i].Destination = sg.Destination
rep.IncomingGroup[i].Distance = mr.F(distance)
rep.IncomingGroup[i].Speed = mr.F(speed)
rep.IncomingGroup[i].Mass = mr.F(mass)
i++
}
}
func (c *Cache) ReportLocalPlanet(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
clear(rep.LocalPlanet)
i := 0
for pi := range c.g.Map.Planet {
p := &c.g.Map.Planet[pi]
if !p.OwnedBy(r.ID) {
continue
}
sliceIndexValidate(&rep.LocalPlanet, i)
c.localPlanet(&rep.LocalPlanet[i], p)
// rep.LocalPlanet[i].UnidentifiedPlanet.Number = p.Number
// rep.LocalPlanet[i].UnidentifiedPlanet.X = mr.F(p.X.F())
// rep.LocalPlanet[i].UnidentifiedPlanet.Y = mr.F(p.Y.F())
// rep.LocalPlanet[i].UninhabitedPlanet.Size = mr.F(p.Size.F())
// rep.LocalPlanet[i].UninhabitedPlanet.Name = p.Name
// rep.LocalPlanet[i].UninhabitedPlanet.Resources = mr.F(p.Resources.F())
// rep.LocalPlanet[i].UninhabitedPlanet.Capital = mr.F(p.Capital.F())
// rep.LocalPlanet[i].UninhabitedPlanet.Material = mr.F(p.Material.F())
// rep.LocalPlanet[i].Industry = mr.F(p.Industry.F())
// rep.LocalPlanet[i].Population = mr.F(p.Population.F())
// rep.LocalPlanet[i].Colonists = mr.F(p.Colonists.F())
// rep.LocalPlanet[i].Production = c.PlanetProductionDisplayName(p.Number)
// rep.LocalPlanet[i].FreeIndustry = mr.F(p.ProductionCapacity())
// for _, sgi := range rep.PlanetGroupsCache[p.Number] {
// sg := c.ShipGroup(sgi)
// if sg.StateUpgrade == nil {
// break
// }
// // between-turn report: ships upgrading on the planet decreases free indistrial potential
// rep.LocalPlanet[i].FreeIndustry -= mr.F(sg.StateUpgrade.Cost())
// }
i++
}
}
func (c *Cache) ReportOtherPlanet(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
clear(rep.OtherPlanet)
i := 0
for sg := range c.listShipGroups(ri) {
if sg.State() != game.StateInOrbit {
continue
}
p := c.MustPlanet(sg.Destination)
if !p.Owned() || p.OwnedBy(r.ID) {
continue
}
sliceIndexValidate(&rep.OtherPlanet, i)
c.localPlanet(&rep.OtherPlanet[i].LocalPlanet, p)
rep.OtherPlanet[i].Owner = c.g.Race[c.RaceIndex(*p.Owner)].Name
i++
}
}
func (c *Cache) ReportUninhabitedPlanet(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
clear(rep.UninhabitedPlanet)
i := 0
for sg := range c.listShipGroups(ri) {
if sg.State() != game.StateInOrbit {
continue
}
p := c.MustPlanet(sg.Destination)
if p.Owned() {
continue
}
sliceIndexValidate(&rep.UninhabitedPlanet, i)
uninhabitedPlanet(&rep.UninhabitedPlanet[i], p)
i++
}
}
func (c *Cache) ReportUnidentifiedPlanet(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
clear(rep.UnidentifiedPlanet)
i := 0
for pi := range c.g.Map.Planet {
p := &c.g.Map.Planet[pi]
// skip player's owned planets
if p.OwnedBy(r.ID) {
continue
}
// skip planets where player's group are orbiting
if slices.IndexFunc(rep.OnPlanetGroupCache[p.Number], func(sgi int) bool { return c.ShipGroup(sgi).OwnerID == r.ID }) >= 0 {
continue
}
sliceIndexValidate(&rep.UnidentifiedPlanet, i)
unidentifiedPlanet(&rep.UnidentifiedPlanet[i], p)
i++
}
}
func (c *Cache) ReportShipProduction(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
clear(rep.ShipProduction)
i := 0
for pi := range c.g.Map.Planet {
p := &c.g.Map.Planet[pi]
if !p.OwnedBy(r.ID) || p.Production.Type != game.ProductionShip {
continue
}
st := c.MustShipType(ri, *p.Production.SubjectID)
sliceIndexValidate(&rep.ShipProduction, i)
rep.ShipProduction[pi].Planet = p.Number
rep.ShipProduction[pi].Class = st.Name
rep.ShipProduction[pi].Cost = mr.F(ShipProductionCost(st.EmptyMass()))
rep.ShipProduction[pi].Free = mr.F(c.PlanetProductionCapacity(p.Number))
rep.ShipProduction[pi].ProdUsed = mr.F((*p.Production.ProdUsed).F())
rep.ShipProduction[pi].Percent = mr.F((*p.Production.Progress).F())
i++
}
}
func (c *Cache) ReportRoute(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
clear(rep.Route)
i := 0
for pi := range c.g.Map.Planet {
p := &c.g.Map.Planet[pi]
if !p.OwnedBy(r.ID) || len(p.Route) == 0 {
continue
}
sliceIndexValidate(&rep.Route, i)
rep.Route[i].Planet = p.Number
// rep.Route[i].Route = make(map[uint]string)
for rt, dest := range p.Route {
rep.Route[i].Route[dest] = rt.String()
}
i++
}
}
func (c *Cache) ReportLocalFleet(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
clear(rep.LocalFleet)
i := 0
for fl := range c.listFleets(ri) {
fi := c.MustFleetIndex(fl.ID)
gid := slices.Collect(c.fleetGroupIds(ri, fi))
if len(gid) == 0 {
continue
}
speed, _ := c.FleetSpeedAndMass(fi)
fleetState := c.FleetState(fl.ID)
sliceIndexValidate(&rep.LocalFleet, i)
rep.LocalFleet[i].Name = fl.Name
rep.LocalFleet[i].Groups = uint(len(gid))
rep.LocalFleet[i].Speed = mr.F(speed)
rep.LocalFleet[i].State = fleetState.State.String()
rep.LocalFleet[i].Destination = fleetState.Destination
if inSpace, ok := fleetState.InSpace(); ok {
rep.LocalFleet[i].Origin = &inSpace.Origin
p2 := c.MustPlanet(rep.LocalFleet[i].Destination)
rangeToDestination := mr.F(util.ShortDistance(c.g.Map.Width, c.g.Map.Height, inSpace.X.F(), inSpace.Y.F(), p2.X.F(), p2.Y.F()))
rep.LocalFleet[i].Range = &rangeToDestination
}
i++
}
}
func (c *Cache) ReportLocalGroup(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
clear(rep.LocalGroup)
i := 0
for sg := range c.listShipGroups(ri) {
sliceIndexValidate(&rep.LocalGroup, i)
st := c.MustShipType(ri, sg.TypeID)
c.otherGroup(&rep.LocalGroup[i].OtherGroup, sg, st)
rep.LocalGroup[i].ID = sg.ID
rep.LocalGroup[i].State = sg.State().String()
if sg.FleetID != nil {
rep.LocalGroup[i].Fleet = &c.g.Fleets[c.MustFleetIndex(*sg.FleetID)].Name
}
// rep.LocalGroup[i].Number = sg.Number
// rep.LocalGroup[i].Class = st.Name
// // rep.LocalGroup[i].Tech = make(map[string]mr.Float)
// for t, v := range sg.Tech {
// rep.LocalGroup[i].Tech[t.String()] = mr.F(v)
// }
// rep.LocalGroup[i].Cargo = sg.CargoString()
// rep.LocalGroup[i].Load = mr.F(sg.Load.F())
// rep.LocalGroup[i].Destination = sg.Destination
// if sg.State() == game.StateInSpace {
// rep.LocalGroup[i].Origin = &sg.StateInSpace.Origin
// p2 := c.MustPlanet(rep.LocalGroup[i].Destination)
// rangeToDestination := mr.F(util.ShortDistance(c.g.Map.Width, c.g.Map.Height, sg.StateInSpace.X.F(), sg.StateInSpace.Y.F(), p2.X.F(), p2.Y.F()))
// rep.LocalGroup[i].Range = &rangeToDestination
// }
// rep.LocalGroup[i].Speed = mr.F(sg.Speed(st))
// rep.LocalGroup[i].Mass = mr.F(st.EmptyMass())
i++
}
}
func (c *Cache) ReportOtherGroup(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
clear(rep.OtherGroup)
used := make(map[int]bool)
skip := func(sgi int) bool {
if c.ShipGroup(sgi).OwnerID == r.ID {
return true
}
if _, ok := used[sgi]; ok {
return true
}
used[sgi] = true
return false
}
i := 0
// visible groups from owned and observed planets
for pn := range rep.OnPlanetGroupCache {
p := c.MustPlanet(pn)
if p.OwnedBy(r.ID) ||
slices.IndexFunc(rep.OnPlanetGroupCache[pn], func(sgi int) bool { return c.ShipGroup(sgi).OwnerID == r.ID }) >= 0 {
for _, sgi := range rep.OnPlanetGroupCache[pn] {
sg := c.ShipGroup(sgi)
st := c.ShipGroupShipClass(sgi)
if skip(sgi) {
continue
}
sliceIndexValidate(&rep.OtherGroup, i)
c.otherGroup(&rep.OtherGroup[i], sg, st)
i++
}
}
}
}
func (c *Cache) ReportUnidentifiedGroup(ri int, rep *mr.Report) {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
flightDistance := r.FlightDistance()
clear(rep.UnidentifiedGroup)
i := 0
for sgi := range rep.InSpaceGroupRangeCache {
sg := c.ShipGroup(sgi)
if sg.OwnerID == rep.RaceID {
continue
}
if sg.StateInSpace == nil {
panic(fmt.Sprintf("pre-calculated distance group not in space: i=%d", sgi))
}
for pi := range c.g.Map.Planet {
p := &c.g.Map.Planet[pi]
if !p.OwnedBy(r.ID) {
continue
}
if v, ok := rep.InSpaceGroupRangeCache[sgi][p.Number]; !ok {
panic(fmt.Sprintf("distance cache not pre-calculated: i=%d p=#%d", sgi, p.Number))
} else if v <= flightDistance {
sliceIndexValidate(&rep.UnidentifiedGroup, i)
rep.UnidentifiedGroup[i].X = mr.F(sg.StateInSpace.X.F())
rep.UnidentifiedGroup[i].Y = mr.F(sg.StateInSpace.Y.F())
i++
}
}
}
}
func (c *Cache) otherGroup(v *mr.OtherGroup, sg *game.ShipGroup, st *game.ShipType) {
v.Number = sg.Number
v.Class = st.Name
// rep.LocalGroup[i].Tech = make(map[string]mr.Float)
for t, val := range sg.Tech {
v.Tech[t.String()] = mr.F(val.F())
}
v.Cargo = sg.CargoString()
v.Load = mr.F(sg.Load.F())
v.Destination = sg.Destination
if sg.State() == game.StateInSpace {
v.Origin = &sg.StateInSpace.Origin
p2 := c.MustPlanet(v.Destination)
rangeToDestination := mr.F(util.ShortDistance(c.g.Map.Width, c.g.Map.Height, sg.StateInSpace.X.F(), sg.StateInSpace.Y.F(), p2.X.F(), p2.Y.F()))
v.Range = &rangeToDestination
}
v.Speed = mr.F(sg.Speed(st))
v.Mass = mr.F(st.EmptyMass())
}
func (c *Cache) localPlanet(v *mr.LocalPlanet, p *game.Planet) {
uninhabitedPlanet(&v.UninhabitedPlanet, p)
v.Industry = mr.F(p.Industry.F())
v.Population = mr.F(p.Population.F())
v.Colonists = mr.F(p.Colonists.F())
v.Production = c.PlanetProductionDisplayName(p.Number)
// between-turn report: ships upgrading on the planet decreases free indistrial potential
v.FreeIndustry = mr.F(c.PlanetProductionCapacity(p.Number))
}
func uninhabitedPlanet(v *mr.UninhabitedPlanet, p *game.Planet) {
unidentifiedPlanet(&v.UnidentifiedPlanet, p)
v.Size = mr.F(p.Size.F())
v.Name = p.Name
v.Resources = mr.F(p.Resources.F())
v.Capital = mr.F(p.Capital.F())
v.Material = mr.F(p.Material.F())
}
func unidentifiedPlanet(v *mr.UnidentifiedPlanet, p *game.Planet) {
v.Number = p.Number
v.X = mr.F(p.X.F())
v.Y = mr.F(p.Y.F())
}
func sliceIndexValidate[S ~[]E, E any](s *S, i int) {
if cap(*s) < i+1 {
*s = slices.Grow(*s, 10)
}
if len(*s) < i+1 {
*s = (*s)[:i+1]
}
}
func boolCompare(a, b bool) int {
if a == b {
return 0
}
if a == false {
return -1
}
return 1
}
+88
View File
@@ -0,0 +1,88 @@
package controller_test
import (
"testing"
"galaxy/model/report"
"github.com/stretchr/testify/assert"
)
func TestReportRace(t *testing.T) {
c, _ := newCache()
c.TurnCalculateVotes()
rep := c.InitReport(2)
assert.Equal(t, 2, int(rep.Turn))
c.ReportRace(Race_0_idx, rep, nil, nil)
assert.Equal(t, Race_0.Name, rep.Race)
assert.Equal(t, Race_0.ID, rep.RaceID)
assert.Equal(t, 0.1, float64(rep.Votes))
for i := range rep.Player {
p := &rep.Player[i]
switch p.ID {
case Race_0_ID:
assert.Equal(t, Race_0.Name, p.Name)
assert.Equal(t, 1.1, float64(p.Drive))
assert.Equal(t, 1.2, float64(p.Weapons))
assert.Equal(t, 1.3, float64(p.Shields))
assert.Equal(t, 1.4, float64(p.Cargo))
assert.Equal(t, 100., float64(p.Population))
assert.Equal(t, 100., float64(p.Industry))
assert.Equal(t, 2, int(p.Planets))
assert.Equal(t, 0.1, float64(p.Votes))
assert.Equal(t, "-", p.Relation)
case Race_1_ID:
assert.Equal(t, Race_1.Name, p.Name)
assert.Equal(t, 2.1, float64(p.Drive))
assert.Equal(t, 2.2, float64(p.Weapons))
assert.Equal(t, 2.3, float64(p.Shields))
assert.Equal(t, 2.4, float64(p.Cargo))
assert.Equal(t, 0., float64(p.Population))
assert.Equal(t, 0., float64(p.Industry))
assert.Equal(t, 1, int(p.Planets))
assert.Equal(t, 0., float64(p.Votes))
assert.Equal(t, "WAR", p.Relation)
}
}
}
func TestReportLocalShipClass(t *testing.T) {
c, _ := newCache()
r := &report.Report{}
assert.Len(t, r.LocalShipClass, 0)
c.ReportLocalShipClass(Race_0_idx, r)
assert.Len(t, r.LocalShipClass, 3)
for i := range r.LocalShipClass {
assert.NotEmpty(t, r.LocalShipClass[i].Name)
switch n := r.LocalShipClass[i].Name; n {
case Cruiser.Name:
assert.Equal(t, report.F(Cruiser.Drive.F()), r.LocalShipClass[i].Drive)
assert.Equal(t, Cruiser.Armament, r.LocalShipClass[i].Armament)
assert.Equal(t, report.F(Cruiser.Weapons.F()), r.LocalShipClass[i].Weapons)
assert.Equal(t, report.F(Cruiser.Shields.F()), r.LocalShipClass[i].Shields)
assert.Equal(t, report.F(Cruiser.Cargo.F()), r.LocalShipClass[i].Cargo)
case Race_0_Gunship:
assert.Equal(t, report.F(60.), r.LocalShipClass[i].Drive)
assert.Equal(t, uint(3), r.LocalShipClass[i].Armament)
assert.Equal(t, report.F(30.), r.LocalShipClass[i].Weapons)
assert.Equal(t, report.F(100.), r.LocalShipClass[i].Shields)
assert.Equal(t, report.F(0.), r.LocalShipClass[i].Cargo)
case Race_0_Freighter:
assert.Equal(t, report.F(8.), r.LocalShipClass[i].Drive)
assert.Equal(t, uint(0), r.LocalShipClass[i].Armament)
assert.Equal(t, report.F(0.), r.LocalShipClass[i].Weapons)
assert.Equal(t, report.F(2.), r.LocalShipClass[i].Shields)
assert.Equal(t, report.F(10.), r.LocalShipClass[i].Cargo)
default:
assert.Failf(t, "unexpected ship class", "name=%s", n)
}
}
}
+300
View File
@@ -0,0 +1,300 @@
package controller
import (
"cmp"
"iter"
"maps"
"math"
"math/rand/v2"
"slices"
"galaxy/util"
e "galaxy/error"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
func (c *Cache) PlanetRouteSet(ri int, rt game.RouteType, origin, destination uint) error {
c.validateRaceIndex(ri)
p1, ok := c.Planet(origin)
if !ok {
return e.NewEntityNotExistsError("origin planet #%d", origin)
}
if !p1.OwnedBy(c.g.Race[ri].ID) {
return e.NewEntityNotOwnedError("planet #%d", origin)
}
p2, ok := c.Planet(destination)
if !ok {
return e.NewEntityNotExistsError("destination planet #%d", destination)
}
rangeToDestination := util.ShortDistance(c.g.Map.Width, c.g.Map.Height, p1.X.F(), p1.Y.F(), p2.X.F(), p2.Y.F())
if rangeToDestination > c.g.Race[ri].FlightDistance() {
return e.NewSendUnreachableDestinationError("range=%.03f max=%.03f", rangeToDestination, c.g.Race[ri].FlightDistance())
}
c.SetPlanetRoute(rt, origin, destination)
return nil
}
func (c *Cache) PlanetRouteRemove(ri int, rt game.RouteType, origin uint) error {
c.validateRaceIndex(ri)
p1, ok := c.Planet(origin)
if !ok {
return e.NewEntityNotExistsError("origin planet #%d", origin)
}
if !p1.OwnedBy(c.g.Race[ri].ID) {
return e.NewEntityNotOwnedError("planet #%d", origin)
}
c.RemovePlanetRoute(rt, origin)
return nil
}
func (c *Cache) SetPlanetRoute(rt game.RouteType, origin, destination uint) {
pi := c.MustPlanetIndex(origin)
if c.g.Map.Planet[pi].Route == nil {
c.g.Map.Planet[pi].Route = make(map[game.RouteType]uint)
}
c.g.Map.Planet[pi].Route[rt] = destination
}
func (c *Cache) RemovePlanetRoute(rt game.RouteType, origin uint) {
pi := c.MustPlanetIndex(origin)
if c.g.Map.Planet[pi].Route != nil {
delete(c.g.Map.Planet[pi].Route, rt)
}
}
func (c *Cache) SendRoutedGroups() {
for pi := range c.g.Map.Planet {
if len(c.g.Map.Planet[pi].Route) == 0 {
continue
}
groups := slices.Collect(c.listRoutedSendGroupIds(c.g.Map.Planet[pi].Number))
if len(groups) == 0 {
continue
}
sortGroups := func(g []int) {
// sort groups by largest CargoCapacity
slices.SortFunc(g, func(l, r int) int {
return cmp.Or(
cmp.Compare(c.ShipGroup(r).CargoCapacity(c.ShipGroupShipClass(r)), c.ShipGroup(l).CargoCapacity(c.ShipGroupShipClass(l))),
cmp.Compare(l, r))
})
}
reorderGroups := func(g []int) []int {
g = slices.DeleteFunc(g, func(i int) bool { return c.ShipGroup(i).State() != game.StateInOrbit })
sortGroups(g)
return g
}
sortGroups(groups)
p := c.MustPlanet(c.g.Map.Planet[pi].Number)
// COL -> CAP -> MAT -> EMPTY
for _, rt := range []game.RouteType{game.RouteColonist, game.RouteCapital, game.RouteMaterial, game.RouteEmpty} {
dest, ok := c.g.Map.Planet[pi].Route[rt]
if !ok {
continue
}
var res *game.Float
var ct game.CargoType
switch rt {
case game.RouteColonist:
res = &p.Colonists
ct = game.CargoColonist
case game.RouteCapital:
res = &p.Capital
ct = game.CargoCapital
case game.RouteMaterial:
res = &p.Material
ct = game.CargoMaterial
case game.RouteEmpty:
// empty routes launched immediately so the're not required to be loaded
for _, sgi := range groups {
c.LaunchShips(sgi, dest)
}
groups = reorderGroups(groups)
continue
default:
continue
}
for res != nil && *res > 0 && len(groups) > 0 {
sgi := groups[0]
sg := c.ShipGroup(sgi)
st := c.ShipGroupShipClass(sgi)
ships := sg.Number
sgCapacity := sg.CargoCapacity(st)
toLoad := (*res).F()
if toLoad > sgCapacity {
toLoad = sgCapacity
} else if maxShips := uint(math.Ceil(toLoad / (sgCapacity / float64(ships)))); maxShips < ships {
newGroupIdx := c.unsafeBreakGroup(c.RaceIndex(sg.OwnerID), sgi, maxShips)
sgi = newGroupIdx
sg = c.ShipGroup(newGroupIdx)
}
// decrease planet resource
*res = (*res).Add(-toLoad)
// load group
sg.Load = sg.Load.Add(toLoad)
sg.CargoType = &ct
c.LaunchShips(sgi, dest)
groups = reorderGroups(groups)
}
}
}
}
func (c *Cache) listRoutedSendGroupIds(pn uint) iter.Seq[int] {
return func(yield func(int) bool) {
p := c.MustPlanet(pn)
for i := range c.ShipGroupsIndex() {
sg := c.ShipGroup(i)
st := c.ShipGroupShipClass(i)
if !p.OwnedBy(sg.OwnerID) || // Planet must be owned by ships owner
sg.FleetID != nil || // Ships must not be part of a Fleet
sg.State() != game.StateInOrbit || // Ships must be only In_Orbit state
st.CargoBlockMass() == 0 || // Ship Class must have Cargo bays
sg.Load != 0 || // Ships must not be loaded for enrouting
sg.Destination != p.Number {
continue
}
if !yield(i) {
return
}
}
}
}
// Невозможно лишь выгрузить колонистов на чужой планете.
func (c *Cache) TurnUnloadEnroutedGroups() {
for i := range c.g.Map.Planet {
p := &c.g.Map.Planet[i]
c.doUnload(c.unloadRoutedColonists(p.Number, c.listRoutedUnloadShipGroupIds(p.Number, game.RouteColonist)))
for _, rt := range []game.RouteType{game.RouteMaterial, game.RouteCapital} {
c.doUnload(c.listRoutedUnloadShipGroupIds(p.Number, rt))
}
p.UnpackColonists()
p.UnpackCapital()
}
}
func (c *Cache) RemoveUnreachableRoutes() {
for i := range c.g.Map.Planet {
p1 := &c.g.Map.Planet[i]
if !p1.Owned() {
continue
}
ri := c.RaceIndex(*p1.Owner)
for rt, destination := range p1.Route {
p2 := c.MustPlanet(destination)
rangeToDestination := util.ShortDistance(c.g.Map.Width, c.g.Map.Height, p1.X.F(), p1.Y.F(), p2.X.F(), p2.Y.F())
if rangeToDestination > c.g.Race[ri].FlightDistance() {
delete(p1.Route, rt)
}
}
}
}
func (c *Cache) doUnload(groups iter.Seq[int]) {
for sgi := range groups {
c.unsafeUnloadCargo(sgi, c.ShipGroup(sgi).Load.F())
}
}
func (c *Cache) unloadRoutedColonists(pn uint, groups iter.Seq[int]) iter.Seq[int] {
p := c.MustPlanet(pn)
gr := slices.Collect(groups)
if !p.Owned() {
return c.selectColUnloadGroup(gr)
}
return func(yield func(int) bool) {
for _, sgi := range gr {
sg := c.ShipGroup(sgi)
if !p.OwnedBy(sg.OwnerID) {
continue
}
if !yield(sgi) {
return
}
}
}
}
func (c *Cache) selectColUnloadGroup(groups []int) (result iter.Seq[int]) {
groupByRace := make(map[int][]int)
loadByRace := make(map[int]float64)
for _, i := range groups {
sg := c.ShipGroup(i)
ri := c.RaceIndex(sg.OwnerID)
groupByRace[ri] = append(groupByRace[ri], i)
loadByRace[ri] += sg.Load.F()
}
if len(loadByRace) < 2 {
// only one race has to unload cargo
result = slices.Values(groups)
return
}
// select winner to unload cargo
id := MaxOrRandomLoadId(loadByRace, func(ri int) float64 { return float64(c.g.Race[ri].Votes) })
result = slices.Values(groupByRace[id])
return
}
func (c *Cache) listRoutedUnloadShipGroupIds(pn uint, routeType game.RouteType) iter.Seq[int] {
return func(yield func(int) bool) {
yielded := make(map[int]bool)
for i := range c.g.Map.Planet {
for rt, dest := range c.g.Map.Planet[i].Route {
if dest != pn || rt != routeType {
continue
}
for i := range c.ShipGroupsIndex() {
sg := c.ShipGroup(i)
if _, ok := yielded[i]; ok || sg.FleetID != nil || sg.CargoType == nil || sg.Load == 0. || sg.State() != game.StateInOrbit || sg.Destination != dest {
continue
}
if v, ok := game.RouteToCargo[rt]; !ok || v != *sg.CargoType {
continue
}
if !yield(i) {
return
}
yielded[i] = true
}
}
}
}
}
func MaxOrRandomLoadId(raceLoad map[int]float64, pop func(int) float64) int {
if len(raceLoad) < 2 {
panic("loadByRace must contain at least 2 keys")
}
raceIndex := slices.Collect(maps.Keys(raceLoad))
slices.SortFunc(raceIndex, func(ria, rib int) int {
return cmp.Or(
// maximum quantity of unloading colonists
cmp.Compare(raceLoad[rib], raceLoad[ria]),
// maximum population of the race
cmp.Compare(pop(rib), pop(ria)),
// Random winner
cmp.Compare(rand.Float64(), rand.Float64()),
// in theoty, unreacheable option, but let's randomize again
cmp.Compare(rand.Float64(), rand.Float64()),
)
})
return raceIndex[0]
}
+461
View File
@@ -0,0 +1,461 @@
package controller_test
import (
"slices"
"testing"
e "galaxy/error"
"github.com/iliadenisov/galaxy/server/internal/controller"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestPlanetRouteSet(t *testing.T) {
c, g := newCache()
assert.NotContains(t, c.MustPlanet(0).Route, game.RouteMaterial)
assert.NotContains(t, c.MustPlanet(0).Route, game.RouteCapital)
assert.NotContains(t, c.MustPlanet(0).Route, game.RouteColonist)
assert.NotContains(t, c.MustPlanet(0).Route, game.RouteEmpty)
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "COL", 0, 2))
assert.NotContains(t, c.MustPlanet(0).Route, game.RouteMaterial)
assert.NotContains(t, c.MustPlanet(0).Route, game.RouteCapital)
assert.Contains(t, c.MustPlanet(0).Route, game.RouteColonist)
assert.NotContains(t, c.MustPlanet(0).Route, game.RouteEmpty)
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "MAT", 0, 2))
assert.Contains(t, c.MustPlanet(0).Route, game.RouteMaterial)
assert.NotContains(t, c.MustPlanet(0).Route, game.RouteCapital)
assert.Contains(t, c.MustPlanet(0).Route, game.RouteColonist)
assert.NotContains(t, c.MustPlanet(0).Route, game.RouteEmpty)
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "CAP", 0, 2))
assert.Contains(t, c.MustPlanet(0).Route, game.RouteMaterial)
assert.Contains(t, c.MustPlanet(0).Route, game.RouteCapital)
assert.Contains(t, c.MustPlanet(0).Route, game.RouteColonist)
assert.NotContains(t, c.MustPlanet(0).Route, game.RouteEmpty)
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "EMP", 0, 2))
assert.Contains(t, c.MustPlanet(0).Route, game.RouteMaterial)
assert.Contains(t, c.MustPlanet(0).Route, game.RouteCapital)
assert.Contains(t, c.MustPlanet(0).Route, game.RouteColonist)
assert.Contains(t, c.MustPlanet(0).Route, game.RouteEmpty)
assert.ErrorContains(t,
g.PlanetRouteSet(UnknownRace, "COL", 0, 2),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.PlanetRouteSet(Race_Extinct.Name, "COL", 0, 2),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.PlanetRouteSet(Race_0.Name, "IND", 0, 2),
e.GenericErrorText(e.ErrInputCargoTypeInvalid))
assert.ErrorContains(t,
g.PlanetRouteSet(Race_0.Name, "COL", 500, 2),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.PlanetRouteSet(Race_0.Name, "COL", 1, 2),
e.GenericErrorText(e.ErrInputEntityNotOwned))
assert.ErrorContains(t,
g.PlanetRouteSet(Race_0.Name, "COL", 0, 3),
e.GenericErrorText(e.ErrSendUnreachableDestination))
}
func TestPlanetRouteRemove(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "COL", 0, 2))
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "CAP", 0, 2))
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "EMP", 2, 0))
assert.Contains(t, c.MustPlanet(0).Route, game.RouteColonist)
assert.Contains(t, c.MustPlanet(0).Route, game.RouteCapital)
assert.Contains(t, c.MustPlanet(2).Route, game.RouteEmpty)
assert.NoError(t, g.PlanetRouteRemove(Race_0.Name, "COL", 0))
assert.NotContains(t, c.MustPlanet(0).Route, game.RouteColonist)
assert.Contains(t, c.MustPlanet(0).Route, game.RouteCapital)
assert.NoError(t, g.PlanetRouteRemove(Race_0.Name, "EMP", 2))
assert.NotContains(t, c.MustPlanet(2).Route, game.RouteEmpty)
assert.ErrorContains(t,
g.PlanetRouteRemove(UnknownRace, "COL", 0),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.PlanetRouteRemove(Race_Extinct.Name, "COL", 0),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.PlanetRouteRemove(Race_0.Name, "IND", 0),
e.GenericErrorText(e.ErrInputCargoTypeInvalid))
assert.ErrorContains(t,
g.PlanetRouteRemove(Race_0.Name, "COL", 500),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.PlanetRouteRemove(Race_0.Name, "COL", 1),
e.GenericErrorText(e.ErrInputEntityNotOwned))
}
func TestListRoutedSendGroupIds(t *testing.T) {
c, g := newCache()
// 1: idx = 0 / Ready to load
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10))
// 2: idx = 1 / Has no cargo bay
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 1))
// 3: idx = 2 / In_Space
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 7))
c.ShipGroup(2).StateInSpace = &InSpace
// 4: idx = 3 / loaded with COL
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 11))
c.ShipGroup(3).CargoType = game.CargoColonist.Ref()
c.ShipGroup(3).Load = 1.234
// Foreign group -> idx 1
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10))
assert.NoError(t, g.ShipGroupTransfer(Race_0.Name, Race_1.Name, c.ShipGroup(4).ID))
// 5: idx = 4 / Part of the Fleet
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10))
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, "Fleet", c.ShipGroup(5).ID))
planet_0_groups := slices.Collect(c.ListRoutedSendGroupIds(0))
assert.Len(t, planet_0_groups, 1)
for _, i := range planet_0_groups {
sg := c.ShipGroup(i)
st := c.ShipGroupShipClass(i)
assert.Equal(t, Race_0_ID, sg.OwnerID)
assert.Greater(t, sg.CargoCapacity(st), 0.)
assert.Equal(t, game.StateInOrbit, sg.State())
assert.Equal(t, 0., sg.Load.F())
assert.Nil(t, sg.FleetID)
}
}
func TestEnrouteGroups_SplitGroup(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "COL", R0_Planet_0_num, R0_Planet_2_num))
c.MustPlanet(R0_Planet_0_num).Colonists = 65
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 5)) // 21.0 per Ship
assert.Equal(t, 105., c.ShipGroup(0).CargoCapacity(c.ShipGroupShipClass(0)))
c.SendRoutedGroups()
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 2)
assert.Equal(t, game.StateInOrbit, c.ShipGroup(0).State())
assert.Equal(t, uint(1), c.ShipGroup(0).Number)
assert.Equal(t, 0., c.ShipGroup(0).Load.F())
assert.Equal(t, game.StateLaunched, c.ShipGroup(1).State())
assert.Equal(t, uint(4), c.ShipGroup(1).Number)
assert.Equal(t, 65., c.ShipGroup(1).Load.F())
assert.Equal(t, 0., c.MustPlanet(R0_Planet_0_num).Colonists.F())
}
func TestEnrouteGroups_GroupSorting(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "COL", R0_Planet_0_num, R0_Planet_2_num))
c.MustPlanet(R0_Planet_0_num).Colonists = 100
// 0: idx = 1
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 4)) // 21.0 per Ship
assert.Equal(t, 84., c.ShipGroup(0).CargoCapacity(c.ShipGroupShipClass(0)))
// 1: idx = 2
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 5)) // 21.0 per Ship
assert.Equal(t, 105., c.ShipGroup(1).CargoCapacity(c.ShipGroupShipClass(1)))
c.SendRoutedGroups()
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 2)
assert.Equal(t, game.StateInOrbit, c.ShipGroup(0).State())
assert.Equal(t, game.StateLaunched, c.ShipGroup(1).State())
assert.Equal(t, 100., c.ShipGroup(1).Load.F())
assert.Equal(t, 0., c.MustPlanet(R0_Planet_0_num).Colonists.F())
}
func TestEnrouteGroups_LaunchOrder(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "COL", R0_Planet_0_num, R0_Planet_2_num))
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "CAP", R0_Planet_0_num, R0_Planet_2_num))
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "MAT", R0_Planet_0_num, R0_Planet_2_num))
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "EMP", R0_Planet_0_num, R1_Planet_1_num))
c.MustPlanet(R0_Planet_0_num).Colonists = 150
c.MustPlanet(R0_Planet_0_num).Capital = 100
c.MustPlanet(R0_Planet_0_num).Material = 20
// 0: idx = 1 (105 COL) ->
// 3: idx = 4 ( 45 COL)
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 5))
assert.Equal(t, 105., c.ShipGroup(0).CargoCapacity(c.ShipGroupShipClass(0)))
// 1: idx = 2 (In_Orbit) ->
// 4: idx = 5 (20 MAT)
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 5))
assert.Equal(t, 105., c.ShipGroup(1).CargoCapacity(c.ShipGroupShipClass(1)))
// 2: idx = 3 (100 CAP)
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 5))
assert.Equal(t, 105., c.ShipGroup(2).CargoCapacity(c.ShipGroupShipClass(2)))
c.SendRoutedGroups()
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 5)
// full load of COL
sgi := 0
assert.Equal(t, game.StateLaunched, c.ShipGroup(sgi).State())
assert.Equal(t, R0_Planet_2_num, c.ShipGroup(sgi).Destination)
assert.Equal(t, 105., c.ShipGroup(sgi).Load.F())
assert.NotNil(t, c.ShipGroup(sgi).CargoType)
assert.Equal(t, game.CargoColonist, *c.ShipGroup(sgi).CargoType)
assert.Equal(t, uint(5), c.ShipGroup(sgi).Number)
// rest of COL
sgi = 3
assert.Equal(t, game.StateLaunched, c.ShipGroup(sgi).State())
assert.Equal(t, R0_Planet_2_num, c.ShipGroup(sgi).Destination)
assert.Equal(t, 45., c.ShipGroup(sgi).Load.F())
assert.NotNil(t, c.ShipGroup(sgi).CargoType)
assert.Equal(t, game.CargoColonist, *c.ShipGroup(sgi).CargoType)
assert.Equal(t, uint(3), c.ShipGroup(sgi).Number)
// full load of CAP
sgi = 2
assert.Equal(t, game.StateLaunched, c.ShipGroup(sgi).State())
assert.Equal(t, R0_Planet_2_num, c.ShipGroup(sgi).Destination)
assert.Equal(t, 100., c.ShipGroup(sgi).Load.F())
assert.NotNil(t, c.ShipGroup(sgi).CargoType)
assert.Equal(t, game.CargoCapital, *c.ShipGroup(sgi).CargoType)
assert.Equal(t, uint(5), c.ShipGroup(sgi).Number)
// partial load of MAT
sgi = 4
assert.Equal(t, game.StateLaunched, c.ShipGroup(sgi).State())
assert.Equal(t, R0_Planet_2_num, c.ShipGroup(sgi).Destination)
assert.Equal(t, 20., c.ShipGroup(sgi).Load.F())
assert.NotNil(t, c.ShipGroup(sgi).CargoType)
assert.Equal(t, game.CargoMaterial, *c.ShipGroup(sgi).CargoType)
assert.Equal(t, uint(1), c.ShipGroup(sgi).Number)
// empty / on_planet
sgi = 1
assert.Equal(t, game.StateLaunched, c.ShipGroup(sgi).State())
assert.Equal(t, R1_Planet_1_num, c.ShipGroup(sgi).Destination)
assert.Equal(t, 0., c.ShipGroup(sgi).Load.F())
assert.Nil(t, c.ShipGroup(sgi).CargoType)
assert.Equal(t, uint(1), c.ShipGroup(sgi).Number)
}
func TestListRoutedUnloadShipGroupIds(t *testing.T) {
c, g := newCache()
// 1: idx = 0 / Empty cargo
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10))
c.ShipGroup(0).CargoType = game.CargoColonist.Ref()
c.ShipGroup(0).Load = 0.
// 2: idx = 1 / Has no cargo bay
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 1))
// 3: idx = 2 / In_Space
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 7))
c.ShipGroup(2).StateInSpace = &InSpace
// 4: idx = 3 / loaded with COL
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 11))
c.ShipGroup(3).CargoType = game.CargoColonist.Ref()
c.ShipGroup(3).Load = 1.234
// 5: idx = 4 / Part of the Fleet
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10))
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, "Fleet", c.ShipGroup(4).ID))
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "COL", R0_Planet_0_num, R0_Planet_2_num))
for _, rt := range game.RouteTypeSet {
groups := slices.Collect(c.ListRoutedUnloadShipGroupIds(R0_Planet_2_num, rt))
assert.Len(t, groups, 0, "route: %v", rt)
groups = slices.Collect(c.ListRoutedUnloadShipGroupIds(R0_Planet_0_num, rt))
assert.Len(t, groups, 0, "route: %v", rt)
}
// double route from different planets - must not double group ids
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "COL", R0_Planet_2_num, R0_Planet_0_num))
assert.NoError(t, g.PlanetRouteSet(Race_1.Name, "COL", R1_Planet_1_num, R0_Planet_0_num))
// 6: idx = 5 / loaded with CAP
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 11))
c.ShipGroup(5).CargoType = game.CargoCapital.Ref()
c.ShipGroup(5).Load = 1.234
groups := slices.Collect(c.ListRoutedUnloadShipGroupIds(R0_Planet_0_num, game.RouteColonist))
assert.Len(t, groups, 1)
for _, sgi := range groups {
assert.Greater(t, c.ShipGroup(sgi).Load, 0.)
assert.Equal(t, game.StateInOrbit, c.ShipGroup(sgi).State())
assert.Equal(t, R0_Planet_0_num, c.ShipGroup(sgi).Destination)
}
groups = slices.Collect(c.ListRoutedUnloadShipGroupIds(R0_Planet_0_num, game.RouteMaterial))
assert.Len(t, groups, 0)
groups = slices.Collect(c.ListRoutedUnloadShipGroupIds(R0_Planet_0_num, game.RouteCapital))
assert.Len(t, groups, 0)
}
func TestMaxOrRandomLoadId(t *testing.T) {
IDtoLoad := make(map[int]float64)
pop := func(ri int) float64 {
switch ri {
case 1:
return 0
case 3:
return 0
case 5:
return 9.99
case 7:
return 10
case 11:
return 10
}
return 0
}
assert.Panics(t, func() { controller.MaxOrRandomLoadId(IDtoLoad, pop) })
IDtoLoad[1] = 100.
assert.Panics(t, func() { controller.MaxOrRandomLoadId(IDtoLoad, pop) })
IDtoLoad[5] = 100.001
assert.Equal(t, 5, controller.MaxOrRandomLoadId(IDtoLoad, pop))
IDtoLoad[3] = 100.
assert.NotContains(t, []int{1, 3}, controller.MaxOrRandomLoadId(IDtoLoad, pop))
IDtoLoad[7] = 100.001
assert.Equal(t, 7, controller.MaxOrRandomLoadId(IDtoLoad, pop))
IDtoLoad[11] = 100.001
rndCount := make(map[int]int)
for range 100 {
id := controller.MaxOrRandomLoadId(IDtoLoad, pop)
assert.NotContains(t, []int{1, 3, 5}, id)
assert.Contains(t, []int{7, 11}, id)
rndCount[id]++
}
assert.Greater(t, rndCount[7], 10)
assert.Greater(t, rndCount[11], 10)
}
func TestSelectColUnloadGroup(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "COL", R0_Planet_2_num, R0_Planet_0_num))
assert.NoError(t, g.PlanetRouteSet(Race_1.Name, "COL", R1_Planet_1_num, R0_Planet_0_num))
// 1: idx = 0 / Loaded COL
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10))
c.ShipGroup(0).CargoType = game.CargoColonist.Ref()
c.ShipGroup(0).Load = 7.
// 2: idx = 1 / Loaded COL
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 5))
c.ShipGroup(1).CargoType = game.CargoColonist.Ref()
c.ShipGroup(1).Load = 5.
groups := slices.Collect(c.ListRoutedUnloadShipGroupIds(R0_Planet_0_num, game.RouteColonist))
assert.Len(t, groups, 2)
unloadGroups := slices.Collect(c.SelectColUnloadGroup(groups))
assert.ElementsMatch(t, groups, unloadGroups)
// 3: idx = 2 / Loaded COL - another race, winner
assert.NoError(t, c.CreateShips(Race_1_idx, Race_1_Freighter, R1_Planet_1_num, 10))
c.ShipGroup(2).Destination = R0_Planet_0_num
c.ShipGroup(2).CargoType = game.CargoColonist.Ref()
c.ShipGroup(2).Load = 12.1
groups = slices.Collect(c.ListRoutedUnloadShipGroupIds(R0_Planet_0_num, game.RouteColonist))
assert.Len(t, groups, 3)
unloadGroups = slices.Collect(c.SelectColUnloadGroup(groups))
assert.Equal(t, 2, unloadGroups[0])
}
func TestTurnUnloadEnroutedGroups(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "MAT", R0_Planet_2_num, R0_Planet_0_num))
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "CAP", R0_Planet_2_num, R0_Planet_0_num))
assert.NoError(t, g.PlanetRouteSet(Race_1.Name, "COL", R1_Planet_1_num, R0_Planet_0_num))
assert.NoError(t, g.PlanetRouteSet(Race_1.Name, "COL", R1_Planet_1_num, Uninhabited_Planet_4_num))
// 1: idx = 0 / Loaded MAT
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10))
c.ShipGroup(0).CargoType = game.CargoMaterial.Ref()
c.ShipGroup(0).Load = 222.
// 2: idx = 1 / Loaded CAP
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10))
c.ShipGroup(1).CargoType = game.CargoCapital.Ref()
c.ShipGroup(1).Load = 11.
// 3: idx = 2 / Loaded COL - on empty planet
assert.NoError(t, c.CreateShips(Race_1_idx, Race_1_Freighter, R1_Planet_1_num, 10))
c.ShipGroup(2).Destination = Uninhabited_Planet_4_num
c.ShipGroup(2).CargoType = game.CargoColonist.Ref()
c.ShipGroup(2).Load = 12.1
// 4: idx = 3 / Loaded COL - on inhabited planet
assert.NoError(t, c.CreateShips(Race_1_idx, Race_1_Freighter, R1_Planet_1_num, 10))
c.ShipGroup(3).Destination = R0_Planet_0_num
c.ShipGroup(3).CargoType = game.CargoColonist.Ref()
c.ShipGroup(3).Load = 17.3
c.TurnUnloadEnroutedGroups()
assert.Equal(t, 0., c.ShipGroup(0).Load.F())
assert.Equal(t, 222., c.MustPlanet(R0_Planet_0_num).Material.F())
assert.Equal(t, 0., c.ShipGroup(1).Load.F())
assert.Equal(t, 11., c.MustPlanet(R0_Planet_0_num).Capital.F())
assert.Equal(t, 0., c.ShipGroup(2).Load.F())
assert.Equal(t, 96.8, c.MustPlanet(Uninhabited_Planet_4_num).Population.F())
assert.True(t, c.MustPlanet(Uninhabited_Planet_4_num).OwnedBy(Race_1_ID))
assert.Equal(t, game.ProductionCapital, c.MustPlanet(Uninhabited_Planet_4_num).Production.Type)
assert.Equal(t, 17.3, c.ShipGroup(3).Load.F())
}
func TestRemoveUnreachableRoutes(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "MAT", R0_Planet_2_num, R0_Planet_0_num))
assert.NoError(t, g.PlanetRouteSet(Race_0.Name, "CAP", R0_Planet_2_num, R0_Planet_0_num))
assert.NoError(t, g.PlanetRouteSet(Race_1.Name, "COL", R1_Planet_1_num, R0_Planet_0_num))
assert.NoError(t, g.PlanetRouteSet(Race_1.Name, "CAP", R1_Planet_1_num, Uninhabited_Planet_4_num))
assert.Error(t, g.PlanetRouteSet(Race_0.Name, "COL", R0_Planet_2_num, Uninhabited_Planet_3_num))
c.MustPlanet(R0_Planet_2_num).Route[game.RouteColonist] = Uninhabited_Planet_3_num
c.RemoveUnreachableRoutes()
assert.NotContains(t, c.MustPlanet(R0_Planet_2_num).Route, game.RouteColonist)
assert.Contains(t, c.MustPlanet(R0_Planet_2_num).Route, game.RouteMaterial)
assert.Contains(t, c.MustPlanet(R0_Planet_2_num).Route, game.RouteCapital)
assert.Contains(t, c.MustPlanet(R1_Planet_1_num).Route, game.RouteColonist)
assert.Contains(t, c.MustPlanet(R1_Planet_1_num).Route, game.RouteCapital)
}
+100
View File
@@ -0,0 +1,100 @@
package controller
import (
"fmt"
"slices"
"galaxy/util"
e "galaxy/error"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
func (c *Cache) ScienceCreate(ri int, name string, drive, weapons, shileds, cargo float64) error {
c.validateRaceIndex(ri)
n, ok := util.ValidateTypeName(name)
if !ok {
return e.NewEntityTypeNameValidationError("%q", n)
}
if sc := slices.IndexFunc(c.g.Race[ri].Sciences, func(s game.Science) bool { return s.Name == n }); sc >= 0 {
return e.NewEntityDuplicateIdentifierError("science %q", c.g.Race[ri].Sciences[sc].Name)
}
if drive < 0 {
return e.NewDriveValueError(drive)
}
if weapons < 0 {
return e.NewWeaponsValueError(weapons)
}
if shileds < 0 {
return e.NewShieldsValueError(shileds)
}
if cargo < 0 {
return e.NewCargoValueError(cargo)
}
sum := drive + weapons + shileds + cargo
if sum != 1 {
return e.NewScienceSumValuesError("D=%f W=%f S=%f C=%f sum=%f", drive, weapons, shileds, cargo, sum)
}
c.g.Race[ri].Sciences = append(c.g.Race[ri].Sciences, game.Science{
ID: uuid.New(),
Name: n,
Drive: game.Float(drive),
Weapons: game.Float(weapons),
Shields: game.Float(shileds),
Cargo: game.Float(cargo),
})
return nil
}
func (c *Cache) ScienceRemove(ri int, name string) error {
c.validateRaceIndex(ri)
sc := slices.IndexFunc(c.g.Race[ri].Sciences, func(s game.Science) bool { return s.Name == name })
if sc < 0 {
return e.NewEntityNotExistsError("science %q", name)
}
if pl := slices.IndexFunc(c.g.Map.Planet, func(p game.Planet) bool {
return p.Production.Type == game.ResearchScience &&
p.Production.SubjectID != nil &&
*p.Production.SubjectID == c.g.Race[ri].Sciences[sc].ID
}); pl >= 0 {
return e.NewDeleteSciencePlanetProductionError(c.g.Map.Planet[pl].Name)
}
c.g.Race[ri].Sciences = append(c.g.Race[ri].Sciences[:sc], c.g.Race[ri].Sciences[sc+1:]...)
return nil
}
func ResearchTech(r *game.Race, freeProduction float64, drive, weapons, shields, cargo float64) {
increment := freeProduction / 5000.
if drive > 0 {
r.Tech = r.Tech.Set(game.TechDrive, r.Tech.Value(game.TechDrive)+increment*drive)
}
if weapons > 0 {
r.Tech = r.Tech.Set(game.TechWeapons, r.Tech.Value(game.TechWeapons)+increment*weapons)
}
if shields > 0 {
r.Tech = r.Tech.Set(game.TechShields, r.Tech.Value(game.TechShields)+increment*shields)
}
if cargo > 0 {
r.Tech = r.Tech.Set(game.TechCargo, r.Tech.Value(game.TechCargo)+increment*cargo)
}
}
// Internal func
func (c *Cache) raceScience(ri int) []game.Science {
c.validateRaceIndex(ri)
return c.g.Race[ri].Sciences
}
func (c *Cache) mustScience(ri int, id uuid.UUID) *game.Science {
c.validateRaceIndex(ri)
r := &c.g.Race[ri]
i := slices.IndexFunc(r.Sciences, func(s game.Science) bool { return s.ID == id })
if i < 0 {
panic(fmt.Sprintf("science not found for race=%q id=%v", r.Name, id))
}
return &c.g.Race[ri].Sciences[i]
}
+137
View File
@@ -0,0 +1,137 @@
package controller_test
import (
"testing"
e "galaxy/error"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/controller"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestScienceCreate(t *testing.T) {
c, g := newCache()
first := "Drive_Shields"
second := "Hyperdrive"
assert.Len(t, c.RaceScience(Race_0_idx), 0)
assert.NoError(t, g.ScienceCreate(Race_0.Name, first, 0.4, 0, 0.6, 0))
assert.Len(t, c.RaceScience(Race_0_idx), 1)
sc := c.RaceScience(Race_0_idx)[0]
assert.NoError(t, uuid.Validate(sc.ID.String()))
assert.Equal(t, first, sc.Name)
assert.Equal(t, 0.4, sc.Drive.F())
assert.Equal(t, 0., sc.Weapons.F())
assert.Equal(t, 0.6, sc.Shields.F())
assert.Equal(t, 0., sc.Cargo.F())
assert.ErrorContains(t,
g.ScienceCreate(UnknownRace, second, 0.4, 0, 0.6, 0),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ScienceCreate(Race_Extinct.Name, second, 0.4, 0, 0.6, 0),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ScienceCreate(Race_0.Name, BadEntityName, 0.4, 0, 0.6, 0),
e.GenericErrorText(e.ErrInputEntityTypeNameInvalid))
assert.ErrorContains(t,
g.ScienceCreate(Race_0.Name, first, 0.4, 0, 0.6, 0),
e.GenericErrorText(e.ErrInputNewEntityDuplicateIdentifier))
assert.ErrorContains(t,
g.ScienceCreate(Race_0.Name, second, -0.1, 0, 1.1, 0),
e.GenericErrorText(e.ErrInputDriveValue))
assert.ErrorContains(t,
g.ScienceCreate(Race_0.Name, second, 1.5, -0.5, 0, 0),
e.GenericErrorText(e.ErrInputWeaponsValue))
assert.ErrorContains(t,
g.ScienceCreate(Race_0.Name, second, 1.3, 0, -0.3, 0),
e.GenericErrorText(e.ErrInputShieldsValue))
assert.ErrorContains(t,
g.ScienceCreate(Race_0.Name, second, 0, 1.07, 0, -0.07),
e.GenericErrorText(e.ErrInputCargoValue))
assert.ErrorContains(t,
g.ScienceCreate(Race_0.Name, second, 0.26, 0.25, 0.25, 0.25),
e.GenericErrorText(e.ErrInputScienceSumValues))
assert.ErrorContains(t,
g.ScienceCreate(Race_0.Name, second, 0.25, 0.26, 0.25, 0.25),
e.GenericErrorText(e.ErrInputScienceSumValues))
assert.ErrorContains(t,
g.ScienceCreate(Race_0.Name, second, 0.25, 0.25, 0.26, 0.25),
e.GenericErrorText(e.ErrInputScienceSumValues))
assert.ErrorContains(t,
g.ScienceCreate(Race_0.Name, second, 0.25, 0.25, 0.25, 0.26),
e.GenericErrorText(e.ErrInputScienceSumValues))
assert.NoError(t, g.ScienceCreate(Race_0.Name, second, 0.25, 0.25, 0.25, 0.25))
assert.Len(t, c.RaceScience(Race_0_idx), 2)
sc = c.RaceScience(Race_0_idx)[1]
assert.NoError(t, uuid.Validate(sc.ID.String()))
assert.Equal(t, second, sc.Name)
assert.Equal(t, 0.25, sc.Drive.F())
assert.Equal(t, 0.25, sc.Weapons.F())
assert.Equal(t, 0.25, sc.Shields.F())
assert.Equal(t, 0.25, sc.Cargo.F())
}
func TestScienceRemove(t *testing.T) {
c, g := newCache()
first := "Drive_Shields"
second := "Hyperdrive"
assert.Len(t, c.RaceScience(Race_0_idx), 0)
assert.NoError(t, g.ScienceCreate(Race_0.Name, first, 0.4, 0, 0.6, 0))
assert.NoError(t, g.ScienceCreate(Race_0.Name, second, 0.25, 0.25, 0.25, 0.25))
assert.Len(t, c.RaceScience(Race_0_idx), 2)
assert.NoError(t, g.ScienceRemove(Race_0.Name, first))
assert.Len(t, c.RaceScience(Race_0_idx), 1)
g.PlanetProduce(Race_0.Name, int(R0_Planet_0_num), "SCIENCE", second)
assert.ErrorContains(t,
g.ScienceRemove(UnknownRace, second),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ScienceRemove(Race_Extinct.Name, second),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ScienceRemove(Race_0.Name, first),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.ScienceRemove(Race_0.Name, second),
e.GenericErrorText(e.ErrDeleteSciencePlanetProduction))
}
func TestResearchTech(t *testing.T) {
r := Race_0
rr := &r
assert.Equal(t, 1.1, rr.Tech.Value(game.TechDrive))
assert.Equal(t, 1.2, rr.Tech.Value(game.TechWeapons))
assert.Equal(t, 1.3, rr.Tech.Value(game.TechShields))
assert.Equal(t, 1.4, rr.Tech.Value(game.TechCargo))
controller.ResearchTech(rr, 500, 1.0, 0.0, 0.0, 0.0)
assert.InDelta(t, 1.2, rr.Tech.Value(game.TechDrive), 0.000001)
assert.Equal(t, 1.2, rr.Tech.Value(game.TechWeapons))
assert.Equal(t, 1.3, rr.Tech.Value(game.TechShields))
assert.Equal(t, 1.4, rr.Tech.Value(game.TechCargo))
controller.ResearchTech(rr, 500, 0.0, 0.5, 0.0, 0.5)
assert.InDelta(t, 1.2, rr.Tech.Value(game.TechDrive), 0.000001)
assert.Equal(t, 1.25, rr.Tech.Value(game.TechWeapons))
assert.Equal(t, 1.3, rr.Tech.Value(game.TechShields))
assert.Equal(t, 1.45, rr.Tech.Value(game.TechCargo))
controller.ResearchTech(rr, 500, 0.5, 0.0, 0.5, 0.0)
assert.InDelta(t, 1.25, rr.Tech.Value(game.TechDrive), 0.000001)
assert.Equal(t, 1.25, rr.Tech.Value(game.TechWeapons))
assert.Equal(t, 1.35, rr.Tech.Value(game.TechShields))
assert.Equal(t, 1.45, rr.Tech.Value(game.TechCargo))
controller.ResearchTech(rr, 1000, 0.0, 1.0, 0.0, 0.0)
assert.InDelta(t, 1.25, rr.Tech.Value(game.TechDrive), 0.000001)
assert.Equal(t, 1.45, rr.Tech.Value(game.TechWeapons))
assert.Equal(t, 1.35, rr.Tech.Value(game.TechShields))
assert.Equal(t, 1.45, rr.Tech.Value(game.TechCargo))
}
+189
View File
@@ -0,0 +1,189 @@
package controller
import (
"fmt"
"iter"
"slices"
"galaxy/util"
e "galaxy/error"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
func (c *Cache) ShipClassCreate(ri int, typeName string, drive float64, ammo int, weapons, shileds, cargo float64) error {
c.validateRaceIndex(ri)
if err := validateShipTypeValues(drive, ammo, weapons, shileds, cargo); err != nil {
return err
}
n, ok := util.ValidateTypeName(typeName)
if !ok {
return e.NewEntityTypeNameValidationError("%q", n)
}
if st := slices.IndexFunc(c.g.Race[ri].ShipTypes, func(st game.ShipType) bool { return st.Name == typeName }); st >= 0 {
return e.NewEntityDuplicateIdentifierError("ship class %q", c.g.Race[ri].ShipTypes[st].Name)
}
c.g.Race[ri].ShipTypes = append(c.g.Race[ri].ShipTypes, game.ShipType{
ID: uuid.New(),
Name: n,
Drive: game.Float(drive),
Armament: uint(ammo),
Weapons: game.Float(weapons),
Shields: game.Float(shileds),
Cargo: game.Float(cargo),
})
c.invalidateShipGroupCache()
c.invalidateFleetCache()
return nil
}
func (c *Cache) shipClassMerge(ri int, sourceName, targetName string) error {
c.validateRaceIndex(ri)
sourceClass, sti, ok := c.ShipClass(ri, sourceName)
if !ok {
return e.NewEntityNotExistsError("source ship type %q", sourceName)
}
targetClass, _, ok := c.ShipClass(ri, targetName)
if !ok {
return e.NewEntityNotExistsError("target ship type %q", sourceName)
}
if sourceClass.Name == targetClass.Name {
return e.NewEntityTypeNameEqualityError("ship type %q", targetName)
}
if !sourceClass.Equal(*targetClass) {
return e.NewMergeShipTypeNotEqualError()
}
// switch planet productions to the new type
for pl := range c.g.Map.Planet {
if c.g.Map.Planet[pl].OwnedBy(c.g.Race[ri].ID) &&
c.g.Map.Planet[pl].Production.Type == game.ProductionShip &&
c.g.Map.Planet[pl].Production.SubjectID != nil &&
*c.g.Map.Planet[pl].Production.SubjectID == sourceClass.ID {
c.g.Map.Planet[pl].Production.SubjectID = &targetClass.ID
}
}
// switch ship groups to the new type
for sg := range c.g.ShipGroups {
if c.g.ShipGroups[sg].OwnerID == c.g.Race[ri].ID && c.g.ShipGroups[sg].TypeID == sourceClass.ID {
c.g.ShipGroups[sg].TypeID = targetClass.ID
}
}
// remove the source type
c.g.Race[ri].ShipTypes = append(c.g.Race[ri].ShipTypes[:sti], c.g.Race[ri].ShipTypes[sti+1:]...)
c.invalidateShipGroupCache()
c.invalidateFleetCache()
return nil
}
func (c *Cache) shipClassRemove(ri int, name string) error {
c.validateRaceIndex(ri)
st, i, ok := c.ShipClass(ri, name)
if !ok {
return e.NewEntityNotExistsError("ship type %q", name)
}
if pl := slices.IndexFunc(c.g.Map.Planet, func(p game.Planet) bool {
return p.Production.Type == game.ProductionShip &&
p.Production.SubjectID != nil &&
st.ID == *p.Production.SubjectID
}); pl >= 0 {
return e.NewDeleteShipTypePlanetProductionError(c.g.Map.Planet[pl].Name)
}
for sg := range c.listShipGroups(ri) {
if sg.TypeID == st.ID {
return e.NewDeleteShipTypeExistingGroupError("group: %s", sg.ID)
}
}
c.g.Race[ri].ShipTypes = append(c.g.Race[ri].ShipTypes[:i], c.g.Race[ri].ShipTypes[i+1:]...)
c.invalidateShipGroupCache()
c.invalidateFleetCache()
return nil
}
// ShipTypes used for tests only
func (c *Cache) ShipTypes(ri int) []*game.ShipType {
c.validateRaceIndex(ri)
result := make([]*game.ShipType, len(c.g.Race[ri].ShipTypes))
for i := range c.g.Race[ri].ShipTypes {
result[i] = &c.g.Race[ri].ShipTypes[i]
}
return result
}
func (c *Cache) ListShipTypes(ri int) iter.Seq[*game.ShipType] {
return func(yield func(*game.ShipType) bool) {
for i := range c.g.Race[ri].ShipTypes {
if !yield(&c.g.Race[ri].ShipTypes[i]) {
return
}
}
}
}
func (c *Cache) ShipClass(ri int, name string) (*game.ShipType, int, bool) {
i := slices.IndexFunc(c.g.Race[ri].ShipTypes, func(st game.ShipType) bool { return st.Name == name })
if i < 0 {
return nil, -1, false
}
return &c.g.Race[ri].ShipTypes[i], i, true
}
func (c *Cache) ShipType(ri int, ID uuid.UUID) (*game.ShipType, bool) {
c.validateRaceIndex(ri)
for i := range c.g.Race[ri].ShipTypes {
if c.g.Race[ri].ShipTypes[i].ID == ID {
return &c.g.Race[ri].ShipTypes[i], true
}
}
return nil, false
}
func (c *Cache) MustShipType(ri int, ID uuid.UUID) *game.ShipType {
if v, ok := c.ShipType(ri, ID); ok {
return v
}
panic(fmt.Sprintf("ship class not found: race_idx=%d id=%v", ri, ID))
}
func validateShipTypeValues(d float64, a int, w, s, c float64) 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,150 @@
package controller_test
import (
"slices"
"strconv"
"testing"
e "galaxy/error"
"github.com/stretchr/testify/assert"
)
func TestShipClassCreate(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.ShipClassCreate(Race_0.Name, "Random", 1, 3, 5, 4, 2))
ships := slices.Collect(c.ListShipTypes(Race_0_idx))
assert.Len(t, ships, 4)
st := ships[3]
assert.Equal(t, 1., float64(st.Drive))
assert.Equal(t, 3, int(st.Armament))
assert.Equal(t, 5., float64(st.Weapons))
assert.Equal(t, 4., float64(st.Shields))
assert.Equal(t, 2., float64(st.Cargo))
assert.ErrorContains(t,
g.ShipClassCreate(Race_0.Name, Race_0_Gunship, 1, 0, 0, 0, 0),
e.GenericErrorText(e.ErrInputNewEntityDuplicateIdentifier))
assert.ErrorContains(t,
g.ShipClassCreate(UnknownRace, "Drone", 1, 0, 0, 0, 0),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipClassCreate(Race_Extinct.Name, "Drone", 1, 0, 0, 0, 0),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ShipClassCreate(Race_0.Name, BadEntityName, 1, 0, 0, 0, 0),
e.GenericErrorText(e.ErrInputEntityTypeNameInvalid))
}
func TestCreateShipTypeValidation(t *testing.T) {
race := Race_0.Name
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)},
}
for i, tc := range table {
_, g := newCache()
if tc.err == "" {
err := g.ShipClassCreate(race, tc.name+strconv.Itoa(i), tc.d, tc.a, tc.w, tc.s, tc.c)
assert.NoError(t, err)
err = g.ShipClassCreate(race, tc.name+strconv.Itoa(i), tc.d, tc.a, tc.w, tc.s, tc.c)
assert.ErrorContains(t, err, e.GenericErrorText(e.ErrInputNewEntityDuplicateIdentifier))
} else {
err := g.ShipClassCreate(race, tc.name, tc.d, tc.a, tc.w, tc.s, tc.c)
assert.ErrorContains(t, err, tc.err)
}
}
}
func TestShipClassMerge(t *testing.T) {
c, g := newCache()
assert.Len(t, c.ShipTypes(Race_0_idx), 3)
assert.NoError(t, g.ShipClassCreate(Race_0.Name, "Drone", 1, 0, 0, 0, 0))
assert.Len(t, c.ShipTypes(Race_0_idx), 4)
assert.NoError(t, g.ShipClassCreate(Race_0.Name, "Spy", 1, 0, 0, 0, 0))
assert.Len(t, c.ShipTypes(Race_0_idx), 5)
assert.NoError(t, g.ShipClassCreate(Race_0.Name, "Surfer", 15, 15, 15, 0, 1))
assert.Len(t, c.ShipTypes(Race_0_idx), 6)
assert.ErrorContains(t,
g.ShipClassMerge(Race_0.Name, "Sky", "Drone"),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.ShipClassMerge(Race_0.Name, "Spy", "Elephant"),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.ShipClassMerge(Race_Extinct.Name, "Spy", "Drone"),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ShipClassMerge(Race_0.Name, "Spy", "Spy"),
e.GenericErrorText(e.ErrInputEntityTypeNameEquality))
assert.NoError(t, g.ShipClassMerge(Race_0.Name, "Spy", "Drone"))
assert.Len(t, c.ShipTypes(Race_0_idx), 5)
assert.ErrorContains(t,
g.ShipClassMerge(Race_0.Name, "Drone", "Surfer"),
e.GenericErrorText(e.ErrMergeShipTypeNotEqual))
}
func TestShipClassRemove(t *testing.T) {
c, g := newCache()
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 1))
assert.NoError(t, g.ShipClassCreate(Race_0.Name, "Drone", 1, 0, 0, 0, 0))
g.PlanetProduce(Race_0.Name, int(R0_Planet_0_num), "SHIP", "Drone")
assert.ErrorContains(t,
g.ShipClassRemove(UnknownRace, Race_0_Freighter),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipClassRemove(Race_Extinct.Name, Race_0_Freighter),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ShipClassRemove(Race_0.Name, "Elephant"),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.ShipClassRemove(Race_0.Name, "Drone"),
e.GenericErrorText(e.ErrDeleteShipTypePlanetProduction))
assert.NoError(t, g.ShipClassRemove(Race_0.Name, Race_0_Freighter))
assert.ErrorContains(t,
g.ShipClassRemove(Race_0.Name, Race_0_Gunship),
e.GenericErrorText(e.ErrDeleteShipTypeExistingGroup))
}
+566
View File
@@ -0,0 +1,566 @@
package controller
import (
"cmp"
"fmt"
"iter"
"maps"
"slices"
"galaxy/util"
e "galaxy/error"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
// ShipGroup is a proxy func, nothing to cache
func (c *Cache) ShipGroup(groupIndex int) *game.ShipGroup {
c.validateShipGroupIndex(groupIndex)
return &c.g.ShipGroups[groupIndex]
}
func (c *Cache) internalShipGroupJoinFleet(groupIndex int, fID uuid.UUID) {
c.validateShipGroupIndex(groupIndex)
c.g.ShipGroups[groupIndex].FleetID = &fID
c.invalidateFleetCache()
}
func (c *Cache) ShipGroupShipsNumber(groupIndex int, number uint) {
c.validateShipGroupIndex(groupIndex)
if c.g.ShipGroups[groupIndex].Number > 0 {
c.g.ShipGroups[groupIndex].Load = game.F(c.g.ShipGroups[groupIndex].Load.F() / float64(c.g.ShipGroups[groupIndex].Number) * float64(number))
}
c.g.ShipGroups[groupIndex].Number = number
}
func (c *Cache) ShipGroupsIndex() iter.Seq[int] {
return func(yield func(int) bool) {
for i := range c.g.ShipGroups {
if !yield(i) {
return
}
}
}
}
func (c *Cache) ShipGroupOwnerRaceIndex(groupIndex int) int {
c.validateShipGroupIndex(groupIndex)
if len(c.cacheRaceIndexByShipGroupIndex) == 0 {
c.cacheShipsAndGroups()
}
if v, ok := c.cacheRaceIndexByShipGroupIndex[groupIndex]; ok {
return v
} else {
panic(fmt.Sprintf("ShipGroupRace: group not found by index=%v", groupIndex))
}
}
func (c *Cache) ShipGroupOwnerRace(groupIndex int) *game.Race {
return &c.g.Race[c.ShipGroupOwnerRaceIndex(groupIndex)]
}
func (c *Cache) ShipGroupDestroyItem(i int) {
c.validateShipGroupIndex(i)
sg := &c.g.ShipGroups[i]
if sg.Number == 0 {
panic("group has no ships")
}
sg.Load = game.F(sg.Load.F() / float64(sg.Number) * float64(sg.Number-1))
sg.Number -= 1
}
func (c *Cache) DeleteKilledShipGroups() {
keepFleet := make(map[uuid.UUID]bool, len(c.g.Fleets))
for sgi := len(c.g.ShipGroups) - 1; sgi >= 0; sgi-- {
if c.g.ShipGroups[sgi].FleetID != nil {
id := *c.g.ShipGroups[sgi].FleetID
keepFleet[id] = keepFleet[id] || c.g.ShipGroups[sgi].Number > 0
}
if c.g.ShipGroups[sgi].Number == 0 {
c.g.ShipGroups = append(c.g.ShipGroups[:sgi], c.g.ShipGroups[sgi+1:]...)
}
}
c.invalidateShipGroupCache()
for id, keep := range keepFleet {
if keep {
continue
}
c.unsafeDeleteFleet(c.MustFleetIndex(id))
}
}
func (c *Cache) TurnMergeEqualShipGroups() {
for i := range c.listRaceActingIdx() {
c.transferPendingGroups(i)
c.shipGroupMerge(i)
}
}
func (c *Cache) transferPendingGroups(ri int) {
c.validateRaceIndex(ri)
for sg := range c.listShipGroups(ri) {
if sg.State() == game.StateTransfer {
sg.StateTransfer = false
}
}
}
// shipGroupMerge merges several equal ship groups into one
func (c *Cache) shipGroupMerge(ri int) {
c.validateRaceIndex(ri)
raceGroups := make([]game.ShipGroup, 0)
for sg := range c.listShipGroups(ri) {
raceGroups = append(raceGroups, *sg)
}
origin := len(raceGroups)
if origin < 2 {
return
}
for i := 0; i < len(raceGroups)-1; i++ {
for j := len(raceGroups) - 1; j > i; j-- {
if raceGroups[i].Equal(raceGroups[j]) {
raceGroups[i].ID = raceGroups[j].ID // resulting group will have latest ID
raceGroups[i].Number += raceGroups[j].Number
raceGroups = append(raceGroups[:j], raceGroups[j+1:]...)
}
}
}
if len(raceGroups) == origin {
return
}
toDelete := make([]int, 0)
for i := range c.ShipGroupsIndex() {
if c.ShipGroup(i).OwnerID == c.g.Race[ri].ID {
toDelete = append(toDelete, i)
}
}
slices.Sort(toDelete)
slices.Reverse(toDelete)
for _, sgi := range toDelete {
c.unsafeDeleteShipGroup(sgi)
}
for i := range raceGroups {
c.appendShipGroup(ri, &raceGroups[i])
}
}
func (c *Cache) shipGroupDismantle(ri int, groupIndex uuid.UUID) error {
c.validateRaceIndex(ri)
sgi, ok := c.raceShipGroupIndex(ri, groupIndex)
if !ok {
return e.NewEntityNotExistsError("group #%d", groupIndex)
}
if state := c.ShipGroup(sgi).State(); state != game.StateInOrbit {
return e.NewShipsBusyError("state: %s", state)
}
pl, ok := c.Planet(c.ShipGroup(sgi).Destination)
if !ok {
return e.NewGameStateError("planet #%d", c.ShipGroup(sgi).Destination)
}
p := *pl
st := c.ShipGroupShipClass(sgi)
if c.ShipGroup(sgi).CargoType != nil {
ct := *c.ShipGroup(sgi).CargoType
load := c.ShipGroup(sgi).Load.F()
switch ct {
case game.CargoColonist:
if p.OwnedBy(c.g.Race[ri].ID) {
p = game.UnloadColonists(p, load)
}
case game.CargoMaterial:
p.Material = p.Material.Add(load)
case game.CargoCapital:
p.Capital = p.Capital.Add(load)
}
}
p.Material = p.Material.Add(c.ShipGroup(sgi).EmptyMass(st))
c.unsafeDeleteShipGroup(sgi)
c.g.Map.Planet[c.MustPlanetIndex(p.Number)] = p
return nil
}
// Корабль может нести только один тип груза одновременно.
// Возможные типы груза - это колонисты, сырье и промышленность.
// Груз может быть доставлен на борт корабля с Вашей или не занятой планеты, на которой он имеется.
// Указанное количество груза равномерно распределяется между всеми кораблями группы.
func (c *Cache) shipGroupLoad(ri int, groupID uuid.UUID, ct game.CargoType, quantity float64) error {
c.validateRaceIndex(ri)
sgi, ok := c.raceShipGroupIndex(ri, groupID)
if !ok {
return e.NewEntityNotExistsError("group %s", groupID)
}
if state := c.ShipGroup(sgi).State(); state != game.StateInOrbit {
return e.NewShipsBusyError("state: %s", state)
}
p, ok := c.Planet(c.ShipGroup(sgi).Destination)
if !ok {
return e.NewGameStateError("planet #%d", c.ShipGroup(sgi).Destination)
}
if p.Owned() && !p.OwnedBy(c.g.Race[ri].ID) {
return e.NewEntityNotOwnedError("planet #%d", p.Number)
}
st := c.ShipGroupShipClass(sgi)
if st.Cargo < 1 {
return e.NewNoCargoBayError("ship_type %q", st.Name)
}
if c.ShipGroup(sgi).CargoType != nil && *c.ShipGroup(sgi).CargoType != ct {
return e.NewCargoLoadNotEqualError("cargo: %v", *c.ShipGroup(sgi).CargoType)
}
capacity := c.ShipGroup(sgi).CargoCapacity(st)
freeShipGroupCargoLoad := capacity - float64(c.ShipGroup(sgi).Load)
if freeShipGroupCargoLoad == 0 {
return e.NewCargoLoadNoSpaceLeftError()
}
var availableOnPlanet *game.Float
switch ct {
case game.CargoMaterial:
availableOnPlanet = &p.Material
case game.CargoCapital:
availableOnPlanet = &p.Capital
case game.CargoColonist:
availableOnPlanet = &p.Colonists
default:
return e.NewGameStateError("CargoType not accepted: %v", ct)
}
if quantity > float64(*availableOnPlanet) || *availableOnPlanet == 0 {
return e.NewCargoLoadNotEnoughError("planet: #%d, %s=%.03f", p.Number, ct, *availableOnPlanet)
}
toBeLoaded := quantity
if quantity == 0 {
toBeLoaded = float64(*availableOnPlanet)
}
if toBeLoaded > freeShipGroupCargoLoad {
toBeLoaded = freeShipGroupCargoLoad
}
*availableOnPlanet = (*availableOnPlanet).Add(-toBeLoaded)
c.ShipGroup(sgi).Load = c.ShipGroup(sgi).Load.Add(toBeLoaded)
if c.ShipGroup(sgi).Load > 0 {
c.ShipGroup(sgi).CargoType = &ct
}
return nil
}
// Промышленность и Сырье могут быть выгружены на любой планете.
// Колонисты могут быть высажены только на планеты, принадлежащие Вам или на необитаемые планеты.
func (c *Cache) shipGroupUnload(ri int, groupID uuid.UUID, quantity float64) error {
c.validateRaceIndex(ri)
sgi, ok := c.raceShipGroupIndex(ri, groupID)
if !ok {
return e.NewEntityNotExistsError("group %s", groupID)
}
if state := c.ShipGroup(sgi).State(); state != game.StateInOrbit {
return e.NewShipsBusyError("state: %s", state)
}
st := c.ShipGroupShipClass(sgi)
if st.Cargo < 1 {
return e.NewNoCargoBayError("ship_type %q", st.Name)
}
if c.ShipGroup(sgi).CargoType == nil || c.ShipGroup(sgi).Load == 0 {
return e.NewCargoUnloadEmptyError()
}
ct := *c.ShipGroup(sgi).CargoType
p := c.MustPlanet(c.ShipGroup(sgi).Destination)
if ct == game.CargoColonist && p.Owned() && !p.OwnedBy(c.g.Race[ri].ID) {
return e.NewEntityNotOwnedError("planet #%d unload %v", p.Number, ct)
}
c.unsafeUnloadCargo(sgi, UnloadCargoRequest(float64(c.ShipGroup(sgi).Load), quantity))
return nil
}
func UnloadCargoRequest(load, quantity float64) float64 {
result := quantity
if result == 0 || result > load {
result = load
}
return result
}
func (c *Cache) shipGroupIndexByID(id uuid.UUID) (int, bool) {
for sgi := range c.g.ShipGroups {
if c.g.ShipGroups[sgi].ID == id {
return sgi, true
}
}
return -1, false
}
func (c *Cache) unsafeUnloadCargo(sgi int, q float64) {
if q <= 0 {
return
}
if st := c.ShipGroup(sgi).State(); st != game.StateInOrbit {
panic(fmt.Sprintf("invalid group state: %v", st))
}
c.validateShipGroupIndex(sgi)
p := c.MustPlanet(c.ShipGroup(sgi).Destination)
ct := *c.ShipGroup(sgi).CargoType
var availableOnPlanet *game.Float
switch ct {
case game.CargoColonist:
availableOnPlanet = &p.Colonists
if !p.Owned() {
p.Own(c.ShipGroup(sgi).OwnerID)
p.Production = game.ProductionCapital.AsType(uuid.Nil)
}
case game.CargoMaterial:
availableOnPlanet = &p.Material
case game.CargoCapital:
availableOnPlanet = &p.Capital
}
*availableOnPlanet = (*availableOnPlanet).Add(q)
c.ShipGroup(sgi).Load = c.ShipGroup(sgi).Load.Add(-q)
if c.ShipGroup(sgi).Load == 0 {
c.ShipGroup(sgi).CargoType = nil
}
p.UnpackColonists()
p.UnpackCapital()
}
func (c *Cache) shipGroupTransfer(ri, riAccept int, groupID uuid.UUID) (err error) {
c.validateRaceIndex(ri)
if ri == riAccept {
return e.NewSameRaceError(c.g.Race[riAccept].Name)
}
sgi, ok := c.raceShipGroupIndex(ri, groupID)
if !ok {
return e.NewEntityNotExistsError("group %s", groupID)
}
sg := c.ShipGroup(sgi)
state := sg.State()
if state == game.StateTransfer {
return e.NewShipsBusyError("state: %s", state)
}
st := c.ShipGroupShipClass(sgi)
var stAcc int
var name = st.Name
if stAcc = slices.IndexFunc(c.g.Race[riAccept].ShipTypes, func(v game.ShipType) bool { return v.Name == st.Name }); stAcc >= 0 &&
!st.Equal(c.g.Race[riAccept].ShipTypes[stAcc]) {
name = util.AppendRandomSuffix(name)
}
if stAcc < 0 || name != st.Name {
err = c.ShipClassCreate(riAccept,
name,
st.Drive.F(),
int(st.Armament),
st.Weapons.F(),
st.Shields.F(),
st.Cargo.F())
if err != nil {
return err
}
stAcc = len(c.g.Race[riAccept].ShipTypes) - 1
}
newGroup := *(sg)
newGroup.ID = uuid.New()
newGroup.TypeID = c.g.Race[riAccept].ShipTypes[stAcc].ID
newGroup.Tech = maps.Clone(sg.Tech)
if state == game.StateLaunched {
newGroup.StateTransfer = true
}
c.appendShipGroup(riAccept, &newGroup)
c.unsafeDeleteShipGroup(sgi)
return nil
}
func (c *Cache) ShipGroupBreak(ri int, groupID, newID uuid.UUID, quantity uint) (err error) {
c.validateRaceIndex(ri)
sgi, ok := c.raceShipGroupIndex(ri, groupID)
if !ok {
return e.NewEntityNotExistsError("group %s", groupID)
}
for sgi := range c.g.ShipGroups {
if c.g.ShipGroups[sgi].ID == newID {
return e.NewEntityDuplicateIdentifierError("group %s", newID)
}
}
if state := c.ShipGroup(sgi).State(); state != game.StateInOrbit {
return e.NewShipsBusyError()
}
if c.ShipGroup(sgi).Number < quantity {
return e.NewBeakGroupNumberNotEnoughError("%d<%d", c.ShipGroup(sgi).Number, quantity)
}
if quantity > 0 && quantity < c.ShipGroup(sgi).Number {
if sgi, err = c.breakGroup(ri, groupID, quantity); err != nil {
return
}
}
c.ShipGroup(sgi).FleetID = nil
return nil
}
func (c *Cache) breakGroup(ri int, groupID uuid.UUID, newGroupShips uint) (int, error) {
c.validateRaceIndex(ri)
sgi, ok := c.raceShipGroupIndex(ri, groupID)
if !ok {
return -1, e.NewEntityNotExistsError("group %s", groupID)
}
if c.ShipGroup(sgi).Number < newGroupShips {
return -1, e.NewBreakGroupIllegalNumberError("group=%s ships: %d -> %d", c.ShipGroup(sgi).ID, c.ShipGroup(sgi).Number, newGroupShips)
}
return c.unsafeBreakGroup(ri, sgi, newGroupShips), nil
}
func (c *Cache) unsafeBreakGroup(ri, sgi int, newGroupShips uint) int {
newGroup := *c.ShipGroup(sgi)
if c.ShipGroup(sgi).CargoType != nil {
newGroup.Load = game.F(float64(c.ShipGroup(sgi).Load) / float64(c.ShipGroup(sgi).Number) * float64(newGroupShips))
}
newGroup.Number = newGroupShips
c.ShipGroupShipsNumber(sgi, c.ShipGroup(sgi).Number-newGroup.Number)
newGroup.FleetID = nil
return c.appendShipGroup(ri, &newGroup)
}
// Internal funcs
func (c *Cache) raceShipGroupIndex(ri int, id uuid.UUID) (int, bool) {
c.validateRaceIndex(ri)
for i := range c.ShipGroupsIndex() {
if c.ShipGroupOwnerRaceIndex(i) == ri && c.ShipGroup(i).ID == id {
return i, true
}
}
return -1, false
}
func (c *Cache) listShipGroupIdx(ri int) iter.Seq[int] {
c.validateRaceIndex(ri)
return func(yield func(int) bool) {
for i := range c.g.ShipGroups {
if ri == c.ShipGroupOwnerRaceIndex(i) {
if !yield(i) {
return
}
}
}
}
}
func (c *Cache) listShipGroups(ri int) iter.Seq[*game.ShipGroup] {
c.validateRaceIndex(ri)
return func(yield func(*game.ShipGroup) bool) {
for sgi := range c.listShipGroupIdx(ri) {
if !yield(c.ShipGroup(sgi)) {
return
}
}
}
}
func (c *Cache) shipGroupsInUpgrade(planetNumber uint) iter.Seq[*game.ShipGroup] {
return func(yield func(*game.ShipGroup) bool) {
result := make([]int, 0)
for sg := range c.g.ShipGroups {
// number checked for further sanity after battles
if c.g.ShipGroups[sg].Number > 0 && c.g.ShipGroups[sg].Destination == planetNumber && c.g.ShipGroups[sg].State() == game.StateUpgrade {
result = append(result, sg)
}
}
slices.SortFunc(result, func(a, b int) int {
return cmp.Compare(c.g.ShipGroups[b].StateUpgrade.Cost(), c.g.ShipGroups[a].StateUpgrade.Cost())
})
for i := range result {
if !yield(&c.g.ShipGroups[result[i]]) {
return
}
}
}
}
func (c *Cache) unsafeDeleteShipGroup(sgi int) {
c.validateShipGroupIndex(sgi)
sg := c.ShipGroup(sgi)
if sg.FleetID != nil {
fi := c.MustFleetIndex(*sg.FleetID)
fleetGroups := slices.Collect(c.fleetGroupIds(c.RaceIndex(sg.OwnerID), fi))
if len(fleetGroups) == 1 {
// remove fleet when deleting last group in the fleet
c.unsafeDeleteFleet(fi)
}
}
c.g.ShipGroups = append(c.g.ShipGroups[:sgi], c.g.ShipGroups[sgi+1:]...)
c.invalidateShipGroupCache()
}
func (c *Cache) validateShipGroupIndex(i int) {
if i >= len(c.g.ShipGroups) {
panic(fmt.Sprintf("group index out of range: %d >= %d", i, len(c.g.ShipGroups)))
}
}
func (c *Cache) unsafeCreateShips(ri int, classID uuid.UUID, planet uint, quantity uint) int {
st := c.MustShipType(ri, classID)
level := func(t game.Tech) float64 {
if t == game.TechDrive && st.DriveBlockMass() > 0 {
return util.Fixed3(c.g.Race[ri].TechLevel(game.TechDrive))
}
if t == game.TechWeapons && st.WeaponsBlockMass() > 0 {
return util.Fixed3(c.g.Race[ri].TechLevel(game.TechWeapons))
}
if t == game.TechShields && st.ShieldsBlockMass() > 0 {
return util.Fixed3(c.g.Race[ri].TechLevel(game.TechShields))
}
if t == game.TechCargo && st.CargoBlockMass() > 0 {
return util.Fixed3(c.g.Race[ri].TechLevel(game.TechCargo))
}
return 0
}
return c.appendShipGroup(ri, &game.ShipGroup{
OwnerID: c.g.Race[ri].ID,
TypeID: classID,
Destination: planet,
Number: uint(quantity),
Tech: map[game.Tech]game.Float{
game.TechDrive: game.F(level(game.TechDrive)),
game.TechWeapons: game.F(level(game.TechWeapons)),
game.TechShields: game.F(level(game.TechShields)),
game.TechCargo: game.F(level(game.TechCargo)),
},
})
}
func (c *Cache) appendShipGroup(ri int, sg *game.ShipGroup) int {
c.validateRaceIndex(ri)
sg.ID = uuid.New()
sg.OwnerID = c.g.Race[ri].ID
sg.FleetID = nil
c.g.ShipGroups = append(c.g.ShipGroups, *sg)
i := len(c.g.ShipGroups) - 1
c.invalidateShipGroupCache()
return i
}
@@ -0,0 +1,67 @@
package controller
import (
"fmt"
"iter"
"galaxy/util"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
func (c *Cache) MoveShipGroups() {
moved := make(map[int]bool)
for i := range c.listMoveableGroupIds() {
if v, ok := moved[i]; ok && v {
continue
}
sg := c.ShipGroup(i)
if sg.FleetID != nil {
fi := c.MustFleetIndex(*sg.FleetID)
delta, _ := c.FleetSpeedAndMass(fi)
for fgi := range c.fleetGroupIds(c.RaceIndex(sg.OwnerID), c.MustFleetIndex(*sg.FleetID)) {
c.moveShipGroup(fgi, delta)
moved[fgi] = true
}
continue
}
c.moveShipGroup(i, sg.Speed(c.ShipGroupShipClass(i)))
moved[i] = true
}
}
func (c *Cache) moveShipGroup(i int, delta float64) {
sg := c.ShipGroup(i)
originX, originY, ok := sg.Coord()
if !ok {
panic(fmt.Sprintf("ship group state invalid: %v", sg.State()))
}
destPlanet := c.MustPlanet(sg.Destination)
arrived := false
var x, y float64
x, y, arrived =
util.NextTravelCoord(c.g.Map.Width, c.g.Map.Height, originX, originY, destPlanet.X.F(), destPlanet.Y.F(), delta)
fx, fy := game.F(x), game.F(y)
sg.StateInSpace.X = &fx
sg.StateInSpace.Y = &fy
if arrived {
sg.StateInSpace = nil
}
}
func (c *Cache) listMoveableGroupIds() iter.Seq[int] {
return func(yield func(int) bool) {
for i := range c.ShipGroupsIndex() {
sg := c.ShipGroup(i)
state := sg.State()
if !(state == game.StateInOrbit || state == game.StateLaunched || state == game.StateInSpace) {
continue
}
if !yield(i) {
return
}
}
}
}
@@ -0,0 +1,46 @@
package controller_test
import (
"slices"
"testing"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestListMoveableGroupIds(t *testing.T) {
c, g := newCache()
// 1: idx = 0 / [v] Non-Fleet group
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10))
// 2: idx = 1 / [v] In-Fleet group
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 1))
// 3: idx = 2 / [v] In-Fleet group
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10))
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, "Fleet", c.ShipGroup(1).ID))
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, "Fleet", c.ShipGroup(2).ID))
// 4: idx = 3 / [v] In_Space
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 7))
c.ShipGroup(3).StateInSpace = &InSpace
// 5: idx = 4 / [x] In_Upgrage
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 7))
c.ShipGroup(4).StateUpgrade = &game.InUpgrade{
UpgradeTech: []game.UpgradePreference{},
}
// 6: idx = 5 / [v] Just launched group
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10))
assert.NoError(t, g.ShipGroupSend(Race_0.Name, c.ShipGroup(5).ID, R0_Planet_2_num))
movableGroups := slices.Collect(c.ListMoveableGroupIds())
assert.Len(t, movableGroups, 5)
for _, i := range movableGroups {
sg := c.ShipGroup(i)
assert.NotEqual(t, game.StateUpgrade, sg.State())
assert.NotEqual(t, game.StateTransfer, sg.State())
}
}
@@ -0,0 +1,103 @@
package controller
import (
"galaxy/util"
e "galaxy/error"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
func (c *Cache) shipGroupSend(ri int, groupID uuid.UUID, planetNumber uint) error {
c.validateRaceIndex(ri)
sgi, ok := c.raceShipGroupIndex(ri, groupID)
if !ok {
return e.NewEntityNotExistsError("group %s", groupID)
}
st := c.ShipGroupShipClass(sgi)
pcount := 0
for i := range c.g.Map.Planet {
if c.g.Map.Planet[i].OwnedBy(c.g.Race[ri].ID) {
pcount++
break
}
}
if pcount == 0 {
return e.NewSendShipOwnerHasNoPlanetsError()
}
if st.DriveBlockMass() == 0 {
return e.NewSendShipHasNoDrivesError()
}
sourcePlanet, ok := c.ShipGroup(sgi).AtPlanet()
if !ok {
return e.NewShipsBusyError("state: %s", c.ShipGroup(sgi).State())
}
p1, ok := c.Planet(sourcePlanet)
if !ok {
return e.NewGameStateError("source planet #%d does not exists", sourcePlanet)
}
p2, ok := c.Planet(planetNumber)
if !ok {
return e.NewEntityNotExistsError("destination planet #%d", planetNumber)
}
rangeToDestination := util.ShortDistance(c.g.Map.Width, c.g.Map.Height, p1.X.F(), p1.Y.F(), p2.X.F(), p2.Y.F())
if rangeToDestination > c.g.Race[ri].FlightDistance() {
return e.NewSendUnreachableDestinationError("range=%.03f", rangeToDestination)
}
if p1.Number == p2.Number {
c.UnsendShips(sgi)
c.shipGroupMerge(ri)
return nil
}
c.LaunchShips(sgi, planetNumber)
return nil
}
func (c *Cache) LaunchShips(sgi int, destination uint) *game.ShipGroup {
sg := c.ShipGroup(sgi)
var p *game.Planet
switch sg.State() {
case game.StateInOrbit:
p = c.MustPlanet(sg.Destination)
case game.StateLaunched:
p = c.MustPlanet(sg.StateInSpace.Origin)
default:
panic("state invalid")
}
c.g.ShipGroups[sgi] = LaunchShips(*sg, destination, p.X.F(), p.Y.F())
return &c.g.ShipGroups[sgi]
}
func (c *Cache) UnsendShips(sgi int) *game.ShipGroup {
sg := c.ShipGroup(sgi)
if sg.State() != game.StateLaunched {
panic("state invalid")
}
c.g.ShipGroups[sgi] = UnsendShips(*sg)
return &c.g.ShipGroups[sgi]
}
func LaunchShips(sg game.ShipGroup, destination uint, originX, originY float64) game.ShipGroup {
sg.StateInSpace = &game.InSpace{
Origin: sg.Destination,
X: nil,
Y: nil,
}
sg.Destination = destination
return sg
}
func UnsendShips(sg game.ShipGroup) game.ShipGroup {
sg.Destination = sg.StateInSpace.Origin
sg.StateInSpace = nil
return sg
}
@@ -0,0 +1,63 @@
package controller_test
import (
"testing"
e "galaxy/error"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestShipGroupSend(t *testing.T) {
c, g := newCache()
// group #1 - in_orbit, free to upgrade
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 10))
// group #2 - in_space
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1))
c.ShipGroup(1).StateInSpace = &InSpace
// group #3 - in_orbit, unmovable
g.ShipClassCreate(Race_0.Name, "Fortress", 0, 50, 30, 100, 0)
assert.NoError(t, c.CreateShips(Race_0_idx, "Fortress", R0_Planet_0_num, 1))
shiplessRace := "Shipless"
ri, _ := c.AddRace(shiplessRace)
assert.NoError(t, c.ShipClassCreate(ri, "Drone", 1, 0, 0, 0, 0))
sgi := c.CreateShipsUnsafe_T(ri, c.MustShipClass(ri, "Drone").ID, R0_Planet_0_num, 1)
assert.ErrorContains(t,
g.ShipGroupSend(UnknownRace, c.ShipGroup(0).ID, 2),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupSend(Race_Extinct.Name, c.ShipGroup(0).ID, 2),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ShipGroupSend(Race_0.Name, uuid.New(), 2),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.ShipGroupSend(Race_0.Name, c.ShipGroup(0).ID, 222),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.ShipGroupSend(Race_0.Name, c.ShipGroup(1).ID, 1),
e.GenericErrorText(e.ErrShipsBusy))
assert.ErrorContains(t,
g.ShipGroupSend(shiplessRace, c.ShipGroup(sgi).ID, 2),
e.GenericErrorText(e.ErrSendShipOwnerHasNoPlanets))
assert.ErrorContains(t,
g.ShipGroupSend(Race_0.Name, c.ShipGroup(2).ID, 2),
e.GenericErrorText(e.ErrSendShipHasNoDrives))
assert.ErrorContains(t,
g.ShipGroupSend(Race_0.Name, c.ShipGroup(0).ID, 3),
e.GenericErrorText(e.ErrSendUnreachableDestination))
assert.NoError(t, g.ShipGroupSend(Race_0.Name, c.ShipGroup(0).ID, R0_Planet_2_num)) // send #0
assert.Equal(t, game.StateLaunched, c.ShipGroup(0).State())
assert.NotNil(t, c.ShipGroup(0).StateInSpace)
assert.Nil(t, c.ShipGroup(0).StateInSpace.X)
assert.Nil(t, c.ShipGroup(0).StateInSpace.Y)
assert.NoError(t, g.ShipGroupSend(Race_0.Name, c.ShipGroup(0).ID, R0_Planet_0_num)) // un-send #0
assert.Equal(t, game.StateInOrbit, c.ShipGroup(0).State())
}
@@ -0,0 +1,568 @@
package controller_test
import (
"fmt"
"slices"
"strings"
"testing"
"galaxy/util"
e "galaxy/error"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestCreateShips(t *testing.T) {
c, _ := newCache()
assert.ErrorContains(t,
c.CreateShips(Race_0_idx, "Unknown_Ship_Type", R0_Planet_0_num, 2),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
c.CreateShips(Race_0_idx, Race_0_Gunship, R1_Planet_1_num, 2),
e.GenericErrorText(e.ErrInputEntityNotOwned))
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 1))
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 1)
assert.NoError(t, c.CreateShips(Race_1_idx, Race_1_Freighter, R1_Planet_1_num, 1))
assert.Len(t, slices.Collect(c.RaceShipGroups(1)), 1)
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 6))
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 2)
assert.NoError(t, c.CreateShips(Race_1_idx, Race_1_Gunship, R1_Planet_1_num, 1))
assert.Len(t, slices.Collect(c.RaceShipGroups(1)), 2)
}
func TestUnsafeCreateShips(t *testing.T) {
c, _ := newCache()
r := c.Race(Race_0_idx)
r.Tech = r.Tech.Set(game.TechDrive, 1.001)
r.Tech = r.Tech.Set(game.TechWeapons, 2.999)
r.Tech = r.Tech.Set(game.TechShields, 3.1003)
r.Tech = r.Tech.Set(game.TechCargo, 4.0005001)
sgi := c.CreateShipsUnsafe_T(Race_0_idx, c.MustShipClass(Race_0_idx, Race_0_Freighter).ID, R0_Planet_0_num, 1)
sg := c.ShipGroup(sgi)
assert.Equal(t, 1.001, sg.Tech.Value(game.TechDrive))
assert.Equal(t, 0., sg.Tech.Value(game.TechWeapons))
assert.Equal(t, 3.100, sg.Tech.Value(game.TechShields))
assert.Equal(t, 4.001, sg.Tech.Value(game.TechCargo))
sgi = c.CreateShipsUnsafe_T(Race_0_idx, c.MustShipClass(Race_0_idx, Race_0_Gunship).ID, R0_Planet_0_num, 1)
sg = c.ShipGroup(sgi)
assert.Equal(t, 1.001, sg.Tech.Value(game.TechDrive))
assert.Equal(t, 2.999, sg.Tech.Value(game.TechWeapons))
assert.Equal(t, 3.100, sg.Tech.Value(game.TechShields))
assert.Equal(t, 0., sg.Tech.Value(game.TechCargo))
}
func TestShipGroupMerge(t *testing.T) {
c, g := newCache()
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 1)) // 1 -> 2
assert.NoError(t, c.CreateShips(Race_1_idx, Race_1_Freighter, R1_Planet_1_num, 1))
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 6)) // (2)
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 2)) // (3)
assert.NoError(t, c.CreateShips(Race_1_idx, Race_1_Gunship, R1_Planet_1_num, 1))
c.RaceTechLevel(Race_0_idx, game.TechDrive, 1.5)
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 9)) // 4 -> 6
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 7)) // 5 -> 7
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 4)) // (6)
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 4)) // (7)
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 7)
c.RaceTechLevel(Race_1_idx, game.TechShields, 2.0)
assert.Equal(t, 2.0, c.Race(Race_1_idx).Tech[game.TechShields].F())
assert.NoError(t, c.CreateShips(1, Race_1_Freighter, R1_Planet_1_num, 1))
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_1_idx)), 3)
assert.ErrorContains(t,
g.ShipGroupMerge(UnknownRace),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupMerge(Race_Extinct.Name),
e.GenericErrorText(e.ErrRaceExinct))
assert.NoError(t, g.ShipGroupMerge(Race_0.Name))
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_1_idx)), 3)
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 4)
shipTypeID := func(ri int, name string) uuid.UUID {
class, _, ok := c.ShipClass(ri, name)
if !ok {
t.Fatalf("ship_class not found: %s", name)
return uuid.Nil
}
return class.ID
}
for sg := range c.RaceShipGroups(Race_0_idx) {
switch {
case sg.TypeID == shipTypeID(Race_0_idx, Race_0_Freighter) && sg.TechLevel(game.TechDrive) == 1.1:
assert.Equal(t, uint(7), sg.Number)
// assert.Equal(t, uint(1), sg.Index)
case sg.TypeID == shipTypeID(Race_0_idx, Race_0_Freighter) && sg.TechLevel(game.TechDrive) == 1.5:
assert.Equal(t, uint(11), sg.Number)
// assert.Equal(t, uint(4), sg.Index)
case sg.TypeID == shipTypeID(Race_0_idx, Race_0_Gunship) && sg.TechLevel(game.TechDrive) == 1.1:
assert.Equal(t, uint(2), sg.Number)
// assert.Equal(t, uint(2), sg.Index)
case sg.TypeID == shipTypeID(Race_0_idx, Race_0_Gunship) && sg.TechLevel(game.TechDrive) == 1.5:
assert.Equal(t, uint(13), sg.Number)
// assert.Equal(t, uint(3), sg.Index)
default:
t.Error("not all ship groups covered")
}
}
}
func TestShipGroupBreak(t *testing.T) {
c, g := newCache()
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 13)) // group #1 (0)
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 7)) // group #2 (1) - In_Space
c.ShipGroup(1).StateInSpace = &InSpace
fleet := "R0_Fleet"
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, fleet, c.ShipGroup(0).ID))
assert.ErrorContains(t,
g.ShipGroupBreak(UnknownRace, c.ShipGroup(0).ID, uuid.New(), 1),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupBreak(Race_Extinct.Name, c.ShipGroup(0).ID, uuid.New(), 1),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ShipGroupBreak(Race_0.Name, uuid.New(), uuid.New(), 1),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.ShipGroupBreak(Race_0.Name, c.ShipGroup(0).ID, c.ShipGroup(0).ID, 1),
e.GenericErrorText(e.ErrInputNewEntityDuplicateIdentifier))
assert.ErrorContains(t,
g.ShipGroupBreak(Race_0.Name, c.ShipGroup(0).ID, uuid.New(), 17),
e.GenericErrorText(e.ErrBeakGroupNumberNotEnough))
assert.ErrorContains(t,
g.ShipGroupBreak(Race_0.Name, c.ShipGroup(1).ID, uuid.New(), 1),
e.GenericErrorText(e.ErrShipsBusy))
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 2)
assert.Len(t, slices.Collect(c.ListFleets(Race_0_idx)), 1)
// group #1 -> group #3 (5 new, 8 left)
assert.NoError(t, c.ShipGroupBreak(Race_0_idx, c.ShipGroup(0).ID, uuid.New(), 5)) // group #3 (2)
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 3)
assert.Equal(t, uint(8), c.ShipGroup(0).Number)
assert.NotNil(t, c.ShipGroup(0).FleetID)
assert.Equal(t, uint(5), c.ShipGroup(2).Number)
// assert.Equal(t, uint(3), c.ShipGroup(2).Index)
assert.Nil(t, c.ShipGroup(2).FleetID)
assert.Nil(t, c.ShipGroup(2).CargoType)
// group #1 -> group #4 (2 new, 6 left)
c.ShipGroup(0).CargoType = game.CargoColonist.Ref()
c.ShipGroup(0).Load = 32.8 // 8 ships
assert.NoError(t, c.ShipGroupBreak(Race_0_idx, c.ShipGroup(0).ID, uuid.New(), 2)) // group #4 (3)
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 4)
assert.Equal(t, uint(6), c.ShipGroup(0).Number)
assert.NotNil(t, c.ShipGroup(0).FleetID)
assert.Equal(t, uint(2), c.ShipGroup(3).Number)
// assert.Equal(t, uint(4), c.ShipGroup(3).Index)
assert.Nil(t, c.ShipGroup(3).FleetID)
assert.NoError(t, c.ShipGroupJoinFleet(Race_0_idx, fleet, c.ShipGroup(3).ID))
assert.NotNil(t, c.ShipGroup(3).FleetID)
assert.Equal(t, game.CargoColonist.Ref(), c.ShipGroup(0).CargoType)
assert.Equal(t, 24.6, util.Fixed3(c.ShipGroup(0).Load.F()))
assert.Equal(t, game.CargoColonist.Ref(), c.ShipGroup(3).CargoType)
assert.Equal(t, 8.2, util.Fixed3(c.ShipGroup(3).Load.F()))
// group #1 -> MAX 6 off the fleet
assert.NoError(t, g.ShipGroupBreak(Race_0.Name, c.ShipGroup(0).ID, uuid.New(), 6)) // group #1 (0)
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 4)
assert.Equal(t, uint(6), c.ShipGroup(0).Number)
assert.Nil(t, c.ShipGroup(0).FleetID)
// group #4 -> ALL off the fleet
assert.NoError(t, g.ShipGroupBreak(Race_0.Name, c.ShipGroup(3).ID, uuid.New(), 0)) // group #1 (0)
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 4)
assert.Equal(t, uint(2), c.ShipGroup(3).Number)
assert.Nil(t, c.ShipGroup(3).FleetID)
}
func TestShipGroupTransfer(t *testing.T) {
c, g := newCache()
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 11)) // group #1 (0)
assert.NoError(t, c.CreateShips(Race_1_idx, ShipType_Cruiser, R1_Planet_1_num, 23)) // group #2 (1)
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 17)) // group #3 (2) - In_Space
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, "R0_Fleet", c.ShipGroup(2).ID))
assert.NotNil(t, c.ShipGroup(2).FleetID)
c.ShipGroup(2).StateInSpace = &InSpace
c.ShipGroup(2).CargoType = game.CargoMaterial.Ref()
c.ShipGroup(2).Load = 1.234
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 2)
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_1_idx)), 1)
assert.ErrorContains(t,
g.ShipGroupTransfer(UnknownRace, Race_1.Name, c.ShipGroup(1).ID),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupTransfer(Race_0.Name, UnknownRace, c.ShipGroup(1).ID),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupTransfer(Race_0.Name, Race_Extinct.Name, c.ShipGroup(1).ID),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ShipGroupTransfer(Race_Extinct.Name, Race_1.Name, c.ShipGroup(1).ID),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ShipGroupTransfer(Race_0.Name, Race_0.Name, c.ShipGroup(1).ID),
e.GenericErrorText(e.ErrInputSameRace))
assert.ErrorContains(t,
g.ShipGroupTransfer(Race_0.Name, Race_1.Name, uuid.New()),
e.GenericErrorText(e.ErrInputEntityNotExists))
orig := *c.ShipGroup(2)
assert.NoError(t, g.ShipGroupTransfer(Race_0.Name, Race_1.Name, c.ShipGroup(2).ID)) // group #2 (3)
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 1)
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_1_idx)), 2)
newSg := c.ShipGroup(2)
assert.Equal(t, c.MustShipClass(Race_1_idx, Race_0_Gunship).Name, c.MustShipClass(Race_0_idx, Race_0_Gunship).Name)
assert.Equal(t, c.MustShipClass(Race_1_idx, Race_0_Gunship).Drive, c.MustShipClass(Race_0_idx, Race_0_Gunship).Drive)
assert.Equal(t, c.MustShipClass(Race_1_idx, Race_0_Gunship).Weapons, c.MustShipClass(Race_0_idx, Race_0_Gunship).Weapons)
assert.Equal(t, c.MustShipClass(Race_1_idx, Race_0_Gunship).Shields, c.MustShipClass(Race_0_idx, Race_0_Gunship).Shields)
assert.Equal(t, c.MustShipClass(Race_1_idx, Race_0_Gunship).Cargo, c.MustShipClass(Race_0_idx, Race_0_Gunship).Cargo)
assert.Equal(t, c.MustShipClass(Race_1_idx, Race_0_Gunship).Armament, c.MustShipClass(Race_0_idx, Race_0_Gunship).Armament)
assert.Equal(t, orig.State(), newSg.State())
assert.Equal(t, orig.CargoType, newSg.CargoType)
assert.Equal(t, orig.Load, newSg.Load)
assert.Equal(t, orig.TechLevel(game.TechDrive), newSg.TechLevel(game.TechDrive))
assert.Equal(t, orig.TechLevel(game.TechWeapons), newSg.TechLevel(game.TechWeapons))
assert.Equal(t, orig.TechLevel(game.TechShields), newSg.TechLevel(game.TechShields))
assert.Equal(t, orig.TechLevel(game.TechCargo), newSg.TechLevel(game.TechCargo))
assert.Equal(t, orig.Destination, newSg.Destination)
assert.Equal(t, orig.StateInSpace, newSg.StateInSpace)
assert.Equal(t, orig.StateUpgrade, newSg.StateUpgrade)
assert.Equal(t, orig.StateTransfer, newSg.StateTransfer)
assert.Equal(t, newSg.TypeID, c.MustShipClass(Race_1_idx, Race_0_Gunship).ID)
assert.Equal(t, orig.Number, newSg.Number)
assert.Equal(t, Race_1_ID, newSg.OwnerID)
assert.Nil(t, newSg.FleetID)
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 1))
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 2)
sg := c.ShipGroup(3)
assert.Equal(t, game.StateInOrbit, sg.State())
assert.NoError(t, g.ShipGroupSend(Race_0.Name, sg.ID, R0_Planet_2_num))
assert.Equal(t, game.StateLaunched, sg.State())
assert.Equal(t, sg.OwnerID, Race_0_ID)
assert.NoError(t, g.ShipGroupTransfer(Race_0.Name, Race_1.Name, sg.ID))
sg = c.ShipGroup(3)
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 1)
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_1_idx)), 3)
assert.Equal(t, game.StateTransfer, sg.State())
assert.Equal(t, sg.OwnerID, Race_1_ID)
assert.ErrorContains(t,
g.ShipGroupTransfer(Race_1.Name, Race_0.Name, sg.ID),
e.GenericErrorText(e.ErrShipsBusy))
// transfer ship class with existing name
originalName := c.MustShipClass(Race_0_idx, ShipType_Cruiser).Name
assert.NoError(t, g.ShipGroupTransfer(Race_0.Name, Race_1.Name, c.ShipGroup(0).ID))
var s *game.ShipType
for st := range c.ListShipTypes(Race_1_idx) {
if strings.HasPrefix(st.Name, originalName) && st.Name != originalName {
s = st
}
}
assert.NotNil(t, s)
assert.Greater(t, len(s.Name), len(originalName))
}
func TestShipGroupLoad(t *testing.T) {
c, g := newCache()
// 1: idx = 0 / Ready to load
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 11))
// 2: idx = 1 / Has no cargo bay
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 1))
// 3: idx = 2 / In_Space
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 7))
c.ShipGroup(2).StateInSpace = &InSpace
// 4: idx = 3 / loaded with COL
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 11))
c.ShipGroup(3).CargoType = game.CargoColonist.Ref()
c.ShipGroup(3).Load = 1.234
// 5: idx = 4 / on foreign planet
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 11))
c.ShipGroup(4).Destination = R1_Planet_1_num
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 5)
// tests
assert.ErrorContains(t,
g.ShipGroupLoad(UnknownRace, c.ShipGroup(0).ID, game.CargoMaterial.String(), 0),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupLoad(Race_Extinct.Name, c.ShipGroup(0).ID, game.CargoMaterial.String(), 0),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ShipGroupLoad(Race_0.Name, c.ShipGroup(0).ID, "GOLD", 0),
e.GenericErrorText(e.ErrInputCargoTypeInvalid))
assert.ErrorContains(t,
g.ShipGroupLoad(Race_0.Name, uuid.New(), game.CargoMaterial.String(), 0),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.ShipGroupLoad(Race_0.Name, c.ShipGroup(2).ID, game.CargoMaterial.String(), 0),
e.GenericErrorText(e.ErrShipsBusy))
assert.ErrorContains(t,
g.ShipGroupLoad(Race_0.Name, c.ShipGroup(4).ID, game.CargoMaterial.String(), 0),
e.GenericErrorText(e.ErrInputEntityNotOwned))
assert.ErrorContains(t,
g.ShipGroupLoad(Race_0.Name, c.ShipGroup(1).ID, game.CargoMaterial.String(), 0),
e.GenericErrorText(e.ErrInputNoCargoBay))
assert.ErrorContains(t,
g.ShipGroupLoad(Race_0.Name, c.ShipGroup(3).ID, game.CargoMaterial.String(), 0),
e.GenericErrorText(e.ErrInputCargoLoadNotEqual))
// initial planet is empty
assert.ErrorContains(t,
g.ShipGroupLoad(Race_0.Name, c.ShipGroup(0).ID, game.CargoMaterial.String(), 0),
e.GenericErrorText(e.ErrInputCargoLoadNotEnough))
// add cargo to planet
c.PutMaterial(R0_Planet_0_num, 100)
// not enough on the planet
assert.ErrorContains(t,
g.ShipGroupLoad(Race_0.Name, c.ShipGroup(0).ID, game.CargoMaterial.String(), 101),
e.GenericErrorText(e.ErrInputCargoLoadNotEnough))
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 5)
// load maximum
assert.NoError(t, g.ShipGroupLoad(Race_0.Name, c.ShipGroup(0).ID, game.CargoMaterial.String(), 0))
assert.Equal(t, 0.0, c.MustPlanet(R0_Planet_0_num).Material.F())
assert.Equal(t, game.CargoMaterial.Ref(), c.ShipGroup(0).CargoType)
assert.Equal(t, 100.0, c.ShipGroup(0).Load.F())
assert.NoError(t, g.ShipGroupUnload(Race_0.Name, c.ShipGroup(0).ID, 0))
// load limited
assert.NoError(t, g.ShipGroupLoad(Race_0.Name, c.ShipGroup(0).ID, game.CargoMaterial.String(), 18))
assert.Equal(t, 82.0, c.MustPlanet(R0_Planet_0_num).Material.F())
assert.Equal(t, game.CargoMaterial.Ref(), c.ShipGroup(0).CargoType)
assert.Equal(t, 18.0, c.ShipGroup(0).Load.F())
assert.NoError(t, g.ShipGroupUnload(Race_0.Name, c.ShipGroup(0).ID, 0))
// add cargo to planet
c.PutMaterial(R0_Planet_0_num, 100)
// loading all available cargo
assert.NoError(t, g.ShipGroupLoad(Race_0.Name, c.ShipGroup(0).ID, game.CargoMaterial.String(), 0))
assert.Equal(t, 0.0, c.MustPlanet(R0_Planet_0_num).Material.F())
assert.Equal(t, 100.0, c.ShipGroup(0).Load.F()) // free: 131.0
assert.Equal(t, game.CargoMaterial.Ref(), c.ShipGroup(0).CargoType)
// add cargo to planet
c.PutMaterial(R0_Planet_0_num, 200)
assert.NoError(t, g.ShipGroupLoad(Race_0.Name, c.ShipGroup(0).ID, game.CargoMaterial.String(), 31))
assert.Equal(t, 169.0, c.MustPlanet(R0_Planet_0_num).Material.F())
assert.Equal(t, 131.0, c.ShipGroup(0).Load.F()) // free: 100.0
assert.Equal(t, game.CargoMaterial.Ref(), c.ShipGroup(0).CargoType)
// load to maximum cargo space left
assert.NoError(t, g.ShipGroupLoad(Race_0.Name, c.ShipGroup(0).ID, game.CargoMaterial.String(), 0))
assert.Equal(t, 69.0, c.MustPlanet(R0_Planet_0_num).Material.F())
assert.Equal(t, 231.0, c.ShipGroup(0).Load.F()) // free: 0.0
assert.Equal(t, game.CargoMaterial.Ref(), c.ShipGroup(0).CargoType)
// ship group is full
assert.ErrorContains(t,
g.ShipGroupLoad(Race_0.Name, c.ShipGroup(0).ID, game.CargoMaterial.String(), 0),
e.GenericErrorText(e.ErrInputCargoLoadNoSpaceLeft))
}
func TestShipGroupUnload(t *testing.T) {
c, g := newCache()
// 1: idx = 0 / empty
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10))
// 2: idx = 1 / Has no cargo bay
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 1))
// 3: idx = 2 / In_Space
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 7))
c.ShipGroup(2).StateInSpace = &InSpace
// 4: idx = 3 / loaded with COL
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 11))
c.ShipGroup(3).CargoType = game.CargoColonist.Ref()
c.ShipGroup(3).Load = 1.234
// 5: idx = 4 / on foreign planet / loaded with COL
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 11))
c.ShipGroup(4).Destination = R1_Planet_1_num
c.ShipGroup(4).CargoType = game.CargoColonist.Ref()
c.ShipGroup(4).Load = 1.234
// 6: idx = 5 / on foreign planet / loaded with MAT
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 11))
c.ShipGroup(5).Destination = R1_Planet_1_num
c.ShipGroup(5).CargoType = game.CargoMaterial.Ref()
c.ShipGroup(5).Load = 100.0
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 6)
// tests
assert.ErrorContains(t,
g.ShipGroupUnload(UnknownRace, c.ShipGroup(0).ID, 0),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupUnload(Race_Extinct.Name, c.ShipGroup(0).ID, 0),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ShipGroupUnload(Race_0.Name, uuid.New(), 0),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.ShipGroupUnload(Race_0.Name, c.ShipGroup(2).ID, 0),
e.GenericErrorText(e.ErrShipsBusy))
assert.ErrorContains(t,
g.ShipGroupUnload(Race_0.Name, c.ShipGroup(1).ID, 0),
e.GenericErrorText(e.ErrInputNoCargoBay))
assert.ErrorContains(t,
g.ShipGroupUnload(Race_0.Name, c.ShipGroup(0).ID, 0),
e.GenericErrorText(e.ErrInputCargoUnloadEmpty))
assert.ErrorContains(t,
g.ShipGroupUnload(Race_0.Name, c.ShipGroup(4).ID, 0),
e.GenericErrorText(e.ErrInputEntityNotOwned))
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 6)
// unload MAT on foreign planet / limited
assert.NoError(t, g.ShipGroupUnload(Race_0.Name, c.ShipGroup(5).ID, 20.1))
assert.Equal(t, 20.1, util.Fixed3(c.MustPlanet(R1_Planet_1_num).Material.F()))
assert.Equal(t, game.CargoMaterial.Ref(), c.ShipGroup(5).CargoType)
assert.Equal(t, 79.9, util.Fixed3(c.ShipGroup(5).Load.F()))
// unload MAT on foreign planet / ALL
assert.NoError(t, g.ShipGroupUnload(Race_0.Name, c.ShipGroup(5).ID, 0))
assert.Equal(t, 100.0, util.Fixed3(c.MustPlanet(R1_Planet_1_num).Material.F()))
assert.Equal(t, 0.0, util.Fixed3(c.ShipGroup(5).Load.F()))
assert.Nil(t, c.ShipGroup(5).CargoType)
// unload ALL
c.ShipGroup(0).CargoType = game.CargoColonist.Ref()
c.ShipGroup(0).Load = 100
assert.NoError(t, g.ShipGroupUnload(Race_0.Name, c.ShipGroup(0).ID, 101))
assert.Equal(t, 100.0, util.Fixed3(c.MustPlanet(R0_Planet_0_num).Colonists.F()))
assert.Equal(t, 0.0, util.Fixed3(c.ShipGroup(0).Load.F()))
assert.Nil(t, c.ShipGroup(0).CargoType)
}
func TestShipGroupDismantle(t *testing.T) {
c, g := newCache()
// 1: idx = 0 / empty
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10))
// 2: idx = 1 / In_Space
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 7))
c.ShipGroup(1).StateInSpace = &InSpace
// 3: idx = 2 / loaded with COL
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10))
c.ShipGroup(2).CargoType = game.CargoColonist.Ref()
c.ShipGroup(2).Load = 80.0
// 4: idx = 3 / on foreign planet / loaded with MAT
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 11))
c.ShipGroup(3).Destination = R1_Planet_1_num
c.ShipGroup(3).CargoType = game.CargoMaterial.Ref()
c.ShipGroup(3).Load = 100.0
// 5: idx = 4 / on foreign planet / loaded with COL
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 11))
c.ShipGroup(4).Destination = R1_Planet_1_num
c.ShipGroup(4).CargoType = game.CargoColonist.Ref()
c.ShipGroup(4).Load = 2.345
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 5)
// tests
assert.ErrorContains(t,
g.ShipGroupDismantle(UnknownRace, c.ShipGroup(0).ID),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupDismantle(Race_Extinct.Name, c.ShipGroup(0).ID),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ShipGroupDismantle(Race_0.Name, uuid.New()),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.ShipGroupDismantle(Race_0.Name, c.ShipGroup(1).ID),
e.GenericErrorText(e.ErrShipsBusy))
groupEmptyMass := c.ShipGroup(4).EmptyMass(c.MustShipClass(Race_0_idx, Race_0_Freighter))
planetMAT := c.MustPlanet(R1_Planet_1_num).Material.F()
planetCOL := c.MustPlanet(R1_Planet_1_num).Colonists.F()
assert.NoError(t, g.ShipGroupDismantle(Race_0.Name, c.ShipGroup(4).ID))
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 4)
assert.Equal(t, planetMAT+groupEmptyMass, c.MustPlanet(R1_Planet_1_num).Material.F())
assert.Equal(t, planetCOL, c.MustPlanet(R1_Planet_1_num).Colonists.F())
groupEmptyMass = c.ShipGroup(3).EmptyMass(c.MustShipClass(Race_0_idx, Race_0_Freighter))
groupLoadMAT := c.ShipGroup(3).Load.F()
planetMAT = c.MustPlanet(R1_Planet_1_num).Material.F()
assert.NoError(t, g.ShipGroupDismantle(Race_0.Name, c.ShipGroup(3).ID))
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 3)
assert.Equal(t, planetMAT+groupEmptyMass+groupLoadMAT, c.MustPlanet(R1_Planet_1_num).Material.F())
}
func TestShipGroupDestroyItem(t *testing.T) {
c, _ := newCache()
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10))
c.ShipGroup(0).CargoType = game.CargoColonist.Ref()
c.ShipGroup(0).Load = 100.0
for c.ShipGroup(0).Number > 0 {
c.ShipGroupDestroyItem(0)
assert.Equal(t, float64(c.ShipGroup(0).Number)*10, c.ShipGroup(0).Load.F())
}
}
func TestState(t *testing.T) {
assert.Equal(t, "In_Orbit", fmt.Sprintf("%s", game.StateInOrbit))
}
func TestUnsafeDeleteShipGroup(t *testing.T) {
c, g := newCache()
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 3)) // 0
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_2_num, 5)) // 1
assert.NoError(t, g.ShipGroupJoinFleet(Race_0.Name, "Fleet", c.ShipGroup(0).ID))
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_2_num, 7)) // 2
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 3)
c.UnsafeDeleteShipGroup(1)
assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 2)
assert.Equal(t, uint(3), c.ShipGroup(0).Number)
assert.Equal(t, uint(7), c.ShipGroup(1).Number)
}
@@ -0,0 +1,224 @@
package controller
import (
"math"
"slices"
"strings"
e "galaxy/error"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
func (c *Cache) shipGroupUpgrade(ri int, groupID uuid.UUID, techInput string, limitLevel float64) error {
c.validateRaceIndex(ri)
sgi, ok := c.raceShipGroupIndex(ri, groupID)
if !ok {
return e.NewEntityNotExistsError("group %s", groupID)
}
st := c.ShipGroupShipClass(sgi)
sg := c.ShipGroup(sgi)
if state := sg.State(); state != game.StateInOrbit {
return e.NewShipsBusyError("state: %s", state)
}
p := c.MustPlanet(sg.Destination)
if p.Owned() && !p.OwnedBy(c.g.Race[ri].ID) {
return e.NewEntityNotOwnedError("planet #%d for upgrade group %s", p.Number, groupID)
}
upgradeValidTech := map[string]game.Tech{
strings.ToLower(game.TechDrive.String()): game.TechDrive,
strings.ToLower(game.TechWeapons.String()): game.TechWeapons,
strings.ToLower(game.TechShields.String()): game.TechShields,
strings.ToLower(game.TechCargo.String()): game.TechCargo,
strings.ToLower(game.TechAll.String()): game.TechAll,
}
techRequest, ok := upgradeValidTech[strings.ToLower(techInput)]
if !ok {
return e.NewTechUnknownError(techInput)
}
var blockMasses map[game.Tech]float64 = map[game.Tech]float64{
game.TechDrive: st.DriveBlockMass(),
game.TechWeapons: st.WeaponsBlockMass(),
game.TechShields: st.ShieldsBlockMass(),
game.TechCargo: st.CargoBlockMass(),
}
switch {
case techRequest != game.TechAll && blockMasses[techRequest] == 0:
return e.NewUpgradeShipTechNotUsedError()
case techRequest == game.TechAll && limitLevel != 0:
return e.NewUpgradeParameterNotAllowedError("tech=%s max_level=%f", techRequest.String(), limitLevel)
}
targetLevel := make(map[game.Tech]float64)
var sumLevels float64
for _, tech := range []game.Tech{game.TechDrive, game.TechWeapons, game.TechShields, game.TechCargo} {
if techRequest == game.TechAll || tech == techRequest {
if c.g.Race[ri].TechLevel(tech) < limitLevel {
return e.NewUpgradeTechLevelInsufficientError("%s=%.03f < %.03f", tech.String(), c.g.Race[ri].TechLevel(tech), limitLevel)
}
targetLevel[tech] = FutureUpgradeLevel(c.g.Race[ri].TechLevel(tech), sg.TechLevel(tech).F(), limitLevel)
} else {
targetLevel[tech] = CurrentUpgradingLevel(sg, tech)
}
sumLevels += targetLevel[tech]
}
productionCapacity := c.PlanetProductionCapacity(p.Number)
uc := GroupUpgradeCost(sg, *st, targetLevel[game.TechDrive], targetLevel[game.TechWeapons], targetLevel[game.TechShields], targetLevel[game.TechCargo])
costForShip := uc.UpgradeCost(1)
if costForShip == 0 {
return e.NewUpgradeShipsAlreadyUpToDateError("%#v", targetLevel)
}
shipsToUpgrade := sg.Number
maxUpgradableShips := uc.UpgradeMaxShips(productionCapacity)
/*
1. считаем стоимость модернизации одного корабля
2. считаем сколько кораблей можно модернизировать
3. если не хватает даже на 1 корабль, ограничиваемся одним кораблём и пересчитываем коэффициент пропорционально массе блоков
4. иначе, считаем истинное количество кораблей с учётом ограничения maxShips
*/
blockMassSum := st.EmptyMass()
coef := productionCapacity / costForShip
if maxUpgradableShips == 0 {
if limitLevel > 0 {
return e.NewUpgradeInsufficientResourcesError("ship cost=%.03f L=%.03f", costForShip, productionCapacity)
}
sumLevels = sumLevels * coef
for tech := range targetLevel {
if blockMasses[tech] > 0 {
proportional := sumLevels * (blockMasses[tech] / blockMassSum)
targetLevel[tech] = proportional
}
}
maxUpgradableShips = 1
} else if maxUpgradableShips > shipsToUpgrade {
maxUpgradableShips = shipsToUpgrade
}
// sanity check
uc = GroupUpgradeCost(sg, *st, targetLevel[game.TechDrive], targetLevel[game.TechWeapons], targetLevel[game.TechShields], targetLevel[game.TechCargo])
costForGroup := uc.UpgradeCost(maxUpgradableShips)
if costForGroup > productionCapacity {
e.NewGameStateError("cost recalculation: coef=%f cost(%d)=%f L=%f", coef, maxUpgradableShips, costForGroup, productionCapacity)
}
// break group if needed
if maxUpgradableShips < sg.Number {
nsgi, err := c.breakGroup(ri, groupID, maxUpgradableShips)
if err != nil {
return err
}
sgi = nsgi
}
// finally, fill group upgrade prefs
for tech := range targetLevel {
if targetLevel[tech] > 0 {
c.UpgradeShipGroup(sgi, tech, targetLevel[tech])
}
}
return nil
}
func (c *Cache) UpgradeShipGroup(sgi int, tech game.Tech, v float64) {
sg := *(c.ShipGroup(sgi))
st := c.ShipGroupShipClass(sgi)
c.g.ShipGroups[sgi] = UpgradeGroupPreference(sg, *st, tech, v)
}
// helpers
type UpgradeCalc struct {
Cost map[game.Tech]float64
}
func (uc UpgradeCalc) UpgradeCost(ships uint) float64 {
var sum float64
for _, v := range uc.Cost {
sum += v
}
return sum * float64(ships)
}
func (uc UpgradeCalc) UpgradeMaxShips(resources float64) uint {
return uint(math.Floor(resources / uc.UpgradeCost(1)))
}
func BlockUpgradeCost(blockMass, currentBlockTech, targetBlockTech float64) float64 {
if blockMass == 0 || targetBlockTech <= currentBlockTech {
return 0
}
return (1 - currentBlockTech/targetBlockTech) * 10 * blockMass
}
func GroupUpgradeCost(sg *game.ShipGroup, st game.ShipType, drive, weapons, shields, cargo float64) UpgradeCalc {
uc := &UpgradeCalc{Cost: make(map[game.Tech]float64)}
if drive > 0 {
uc.Cost[game.TechDrive] = BlockUpgradeCost(st.DriveBlockMass(), sg.TechLevel(game.TechDrive).F(), drive)
}
if weapons > 0 {
uc.Cost[game.TechWeapons] = BlockUpgradeCost(st.WeaponsBlockMass(), sg.TechLevel(game.TechWeapons).F(), weapons)
}
if shields > 0 {
uc.Cost[game.TechShields] = BlockUpgradeCost(st.ShieldsBlockMass(), sg.TechLevel(game.TechShields).F(), shields)
}
if cargo > 0 {
uc.Cost[game.TechCargo] = BlockUpgradeCost(st.CargoBlockMass(), sg.TechLevel(game.TechCargo).F(), cargo)
}
return *uc
}
func CurrentUpgradingLevel(sg *game.ShipGroup, tech game.Tech) float64 {
if sg.StateUpgrade == nil {
return 0
}
ti := slices.IndexFunc(sg.StateUpgrade.UpgradeTech, func(pref game.UpgradePreference) bool { return pref.Tech == tech })
if ti >= 0 {
return sg.StateUpgrade.UpgradeTech[ti].Level.F()
}
return 0
}
func FutureUpgradeLevel(raceLevel, groupLevel, limit float64) float64 {
target := limit
if target == 0 || target > raceLevel {
target = raceLevel
}
if groupLevel == target {
return 0
}
return target
}
func UpgradeGroupPreference(sg game.ShipGroup, st game.ShipType, tech game.Tech, v float64) game.ShipGroup {
if v <= 0 || st.BlockMass(tech) == 0 || sg.TechLevel(tech).F() >= v {
return sg
}
var su game.InUpgrade
if sg.StateUpgrade != nil {
su = *sg.StateUpgrade
} else {
su = game.InUpgrade{UpgradeTech: []game.UpgradePreference{}}
}
ti := slices.IndexFunc(su.UpgradeTech, func(pref game.UpgradePreference) bool { return pref.Tech == tech })
if ti < 0 {
su.UpgradeTech = append(su.UpgradeTech, game.UpgradePreference{Tech: tech})
ti = len(su.UpgradeTech) - 1
}
su.UpgradeTech[ti].Level = game.F(v)
su.UpgradeTech[ti].Cost = game.F(BlockUpgradeCost(st.BlockMass(tech), sg.TechLevel(tech).F(), v) * float64(sg.Number))
sg.StateUpgrade = &su
return sg
}
@@ -0,0 +1,177 @@
package controller_test
import (
"testing"
e "galaxy/error"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/controller"
"github.com/iliadenisov/galaxy/server/internal/model/game"
g "github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestBlockUpgradeCost(t *testing.T) {
assert.Equal(t, 00.0, controller.BlockUpgradeCost(1, 1.0, 1.0))
assert.Equal(t, 25.0, controller.BlockUpgradeCost(5, 1.0, 2.0))
assert.Equal(t, 50.0, controller.BlockUpgradeCost(10, 1.0, 2.0))
}
func TestGroupUpgradeCost(t *testing.T) {
sg := &g.ShipGroup{
Tech: map[g.Tech]g.Float{
g.TechDrive: 1.0,
g.TechWeapons: 1.0,
g.TechShields: 1.0,
g.TechCargo: 1.0,
},
Number: 1,
}
assert.Equal(t, 225.0, controller.GroupUpgradeCost(sg, Cruiser, 2.0, 2.0, 2.0, 2.0).UpgradeCost(1))
}
func TestUpgradeMaxShips(t *testing.T) {
sg := &g.ShipGroup{
Tech: map[g.Tech]g.Float{
g.TechDrive: 1.0,
g.TechWeapons: 1.0,
g.TechShields: 1.0,
g.TechCargo: 1.0,
},
Number: 10,
}
uc := controller.GroupUpgradeCost(sg, Cruiser, 2.0, 2.0, 2.0, 2.0)
assert.Equal(t, uint(4), uc.UpgradeMaxShips(1000))
}
func TestCurrentUpgradingLevel(t *testing.T) {
sg := &g.ShipGroup{
StateUpgrade: nil,
}
assert.Equal(t, 0.0, controller.CurrentUpgradingLevel(sg, g.TechDrive))
assert.Equal(t, 0.0, controller.CurrentUpgradingLevel(sg, g.TechWeapons))
assert.Equal(t, 0.0, controller.CurrentUpgradingLevel(sg, g.TechShields))
assert.Equal(t, 0.0, controller.CurrentUpgradingLevel(sg, g.TechCargo))
sg.StateUpgrade = &g.InUpgrade{
UpgradeTech: []g.UpgradePreference{
{Tech: g.TechDrive, Level: 1.5, Cost: 100.1},
},
}
assert.Equal(t, 1.5, controller.CurrentUpgradingLevel(sg, g.TechDrive))
assert.Equal(t, 0.0, controller.CurrentUpgradingLevel(sg, g.TechWeapons))
assert.Equal(t, 0.0, controller.CurrentUpgradingLevel(sg, g.TechShields))
assert.Equal(t, 0.0, controller.CurrentUpgradingLevel(sg, g.TechCargo))
sg.StateUpgrade.UpgradeTech = append(sg.StateUpgrade.UpgradeTech, g.UpgradePreference{Tech: g.TechCargo, Level: 2.2, Cost: 200.2})
assert.Equal(t, 1.5, controller.CurrentUpgradingLevel(sg, g.TechDrive))
assert.Equal(t, 0.0, controller.CurrentUpgradingLevel(sg, g.TechWeapons))
assert.Equal(t, 0.0, controller.CurrentUpgradingLevel(sg, g.TechShields))
assert.Equal(t, 2.2, controller.CurrentUpgradingLevel(sg, g.TechCargo))
}
func TestFutureUpgradeLevel(t *testing.T) {
assert.Equal(t, 0.0, controller.FutureUpgradeLevel(2.0, 2.0, 2.0))
assert.Equal(t, 0.0, controller.FutureUpgradeLevel(2.0, 2.0, 3.0))
assert.Equal(t, 1.5, controller.FutureUpgradeLevel(1.5, 2.0, 3.0))
assert.Equal(t, 2.0, controller.FutureUpgradeLevel(2.5, 1.0, 2.0))
assert.Equal(t, 2.5, controller.FutureUpgradeLevel(2.5, 1.0, 0.0))
}
func TestUpgradeGroupPreference(t *testing.T) {
sg := g.ShipGroup{
Number: 4,
Tech: g.TechSet{
g.TechDrive: 1.0,
g.TechWeapons: 1.0,
g.TechShields: 1.0,
g.TechCargo: 1.0,
},
}
assert.Nil(t, sg.StateUpgrade)
sg = controller.UpgradeGroupPreference(sg, Cruiser, g.TechDrive, 0)
assert.Nil(t, sg.StateUpgrade)
sg = controller.UpgradeGroupPreference(sg, Cruiser, g.TechDrive, 2.0)
assert.NotNil(t, sg.StateUpgrade)
assert.Equal(t, 300., sg.StateUpgrade.TechCost(g.TechDrive))
assert.Equal(t, 300., sg.StateUpgrade.Cost())
sg = controller.UpgradeGroupPreference(sg, Cruiser, g.TechWeapons, 2.0)
assert.NotNil(t, sg.StateUpgrade)
assert.Equal(t, 300., sg.StateUpgrade.TechCost(g.TechWeapons))
assert.Equal(t, 600., sg.StateUpgrade.Cost())
sg = controller.UpgradeGroupPreference(sg, Cruiser, g.TechShields, 2.0)
assert.NotNil(t, sg.StateUpgrade)
assert.Equal(t, 300., sg.StateUpgrade.TechCost(g.TechShields))
assert.Equal(t, 900., sg.StateUpgrade.Cost())
sg = controller.UpgradeGroupPreference(sg, Cruiser, g.TechCargo, 2.0)
assert.NotNil(t, sg.StateUpgrade)
assert.Equal(t, 0., sg.StateUpgrade.TechCost(g.TechCargo))
assert.Equal(t, 900., sg.StateUpgrade.Cost())
}
func TestShipGroupUpgrade(t *testing.T) {
c, g := newCache()
// group #1 - in_orbit, free to upgrade
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 10))
// group #2 - in_space
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1))
c.ShipGroup(1).StateInSpace = &InSpace
// group #3 - in_orbit, foreign planet
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1))
c.ShipGroup(2).Destination = R1_Planet_1_num
assert.ErrorContains(t,
g.ShipGroupUpgrade(UnknownRace, c.ShipGroup(0).ID, "DRIVE", 0),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.ShipGroupUpgrade(Race_Extinct.Name, c.ShipGroup(0).ID, "DRIVE", 0),
e.GenericErrorText(e.ErrRaceExinct))
assert.ErrorContains(t,
g.ShipGroupUpgrade(Race_0.Name, uuid.New(), "DRIVE", 0),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.ShipGroupUpgrade(Race_0.Name, c.ShipGroup(1).ID, "DRIVE", 0),
e.GenericErrorText(e.ErrShipsBusy))
assert.ErrorContains(t,
g.ShipGroupUpgrade(Race_0.Name, c.ShipGroup(2).ID, "DRIVE", 0),
e.GenericErrorText(e.ErrInputEntityNotOwned))
assert.ErrorContains(t,
g.ShipGroupUpgrade(Race_0.Name, c.ShipGroup(0).ID, "GUN", 0),
e.GenericErrorText(e.ErrInputTechUnknown))
assert.ErrorContains(t,
g.ShipGroupUpgrade(Race_0.Name, c.ShipGroup(0).ID, "CARGO", 0),
e.GenericErrorText(e.ErrInputUpgradeShipTechNotUsed))
assert.ErrorContains(t,
g.ShipGroupUpgrade(Race_0.Name, c.ShipGroup(0).ID, "ALL", 2.0),
e.GenericErrorText(e.ErrInputUpgradeParameterNotAllowed))
assert.ErrorContains(t,
g.ShipGroupUpgrade(Race_0.Name, c.ShipGroup(0).ID, "DRIVE", 2.0),
e.GenericErrorText(e.ErrInputUpgradeTechLevelInsufficient))
assert.ErrorContains(t,
g.ShipGroupUpgrade(Race_0.Name, c.ShipGroup(0).ID, "DRIVE", 1.1),
e.GenericErrorText(e.ErrInputUpgradeShipsAlreadyUpToDate))
c.RaceTechLevel(Race_0_idx, game.TechDrive, 10.0)
assert.Equal(t, 10.0, c.Race(Race_0_idx).TechLevel(game.TechDrive))
assert.ErrorContains(t,
g.ShipGroupUpgrade(Race_0.Name, c.ShipGroup(0).ID, "DRIVE", 10.0),
e.GenericErrorText(e.ErrUpgradeInsufficientResources))
assert.NoError(t, g.ShipGroupUpgrade(Race_0.Name, c.ShipGroup(0).ID, "DRIVE", 1.3))
assert.Equal(t, game.StateInOrbit, c.ShipGroup(0).State())
assert.Equal(t, uint(6), c.ShipGroup(0).Number)
assert.Equal(t, game.StateUpgrade, c.ShipGroup(3).State())
assert.Equal(t, uint(4), c.ShipGroup(3).Number)
assert.NotNil(t, c.ShipGroup(3).StateUpgrade)
assert.Equal(t, 1.3, c.ShipGroup(3).StateUpgrade.UpgradeTech[0].Level.F())
assert.Equal(t, "DRIVE", c.ShipGroup(3).StateUpgrade.UpgradeTech[0].Tech.String())
assert.ErrorContains(t,
g.ShipGroupUpgrade(Race_0.Name, c.ShipGroup(3).ID, "DRIVE", 1.3),
e.GenericErrorText(e.ErrShipsBusy))
}
+233
View File
@@ -0,0 +1,233 @@
package controller
import (
"cmp"
"fmt"
"maps"
"math/big"
"slices"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
type VoteGroup struct {
RaceIndex []int
Sum float64
}
type VoteNode struct {
ID int
Ally bool
Next *VoteNode
}
func (n VoteNode) String() string {
lh, rh := " ", "."
if n.Ally {
lh, rh = "{", "}"
}
return fmt.Sprintf("%s%d%s", lh, n.ID, rh)
}
func (c *Cache) TurnAcceptWinners(v []int) {
if c.g.Finished() {
panic("game is already has its winner(s)")
}
if len(v) == 0 {
return
}
for _, ri := range v {
c.g.Winner = append(c.g.Winner, c.g.Race[ri].ID)
}
}
func (c *Cache) TurnCalculateVotes() []int {
raceVotes := c.votesByRace()
calc := GroupVotes(raceVotes, VotingGraph(c.g.Race, c.RaceIndex))
c.g.Votes = 0
for ri, votes := range raceVotes {
v := game.F(votes)
c.g.Race[ri].Votes = v
c.g.Votes += v
}
return votingWinners(calc, c.g.Votes.F())
}
func VotingGraph(races []game.Race, raceIndex func(uuid.UUID) int) map[int]*VoteNode {
nodes := make(map[int]*VoteNode, len(races))
for ri := range races {
if races[ri].Extinct {
continue
}
r := &races[ri]
if _, ok := nodes[ri]; !ok {
nodes[ri] = &VoteNode{
ID: ri,
}
}
if r.VoteFor != r.ID {
vid := raceIndex(r.VoteFor)
if !races[vid].Extinct {
if _, ok := nodes[vid]; !ok {
nodes[vid] = &VoteNode{
ID: vid,
}
}
nodes[ri].Next = nodes[vid]
}
}
}
return nodes
}
func (c *Cache) votesByRace() map[int]float64 {
result := make(map[int]float64)
for i := range c.g.Map.Planet {
p := &c.g.Map.Planet[i]
if !p.Owned() {
continue
}
ri := c.RaceIndex(*p.Owner)
planetVotes := p.Votes()
result[ri] += planetVotes
}
return result
}
func GroupVotes(raceVotes map[int]float64, nodes map[int]*VoteNode) []*VoteGroup {
votes := maps.Clone(raceVotes)
result := make([]*VoteGroup, 0)
chains := VotingChains(nodes)
chainingRaces := make(map[int]bool)
for i := range chains {
chain := chains[i]
if len(chain) == 0 {
panic("voters chain is empty")
}
vg := &VoteGroup{}
for j := range chain {
node := &chain[j]
if node.Ally || j == len(chain)-1 {
vg.RaceIndex = append(vg.RaceIndex, node.ID)
}
vg.Sum += votes[node.ID]
votes[node.ID] = 0
chainingRaces[node.ID] = true
}
// find a non-ally group (single race) which already have its votes and merge with a new VoteGroup instead of adding to result
if i := slices.IndexFunc(result, func(v *VoteGroup) bool { return len(v.RaceIndex) == 1 && v.RaceIndex[0] == vg.RaceIndex[0] }); i >= 0 && len(vg.RaceIndex) == 1 {
result[i].Sum += vg.Sum
} else {
result = append(result, vg)
}
}
for ri, votes := range votes {
if _, ok := chainingRaces[ri]; !ok && votes > 0 {
result = append(result, &VoteGroup{RaceIndex: []int{ri}, Sum: votes})
}
}
return result
}
func VotingChains(nodes map[int]*VoteNode) [][]VoteNode {
visited := make(map[int]bool)
result := make([][]VoteNode, 0)
raceIds := slices.Collect(maps.Keys(nodes))
slices.Sort(raceIds)
for _, rid := range raceIds {
n := nodes[rid]
if v, ok := visited[n.ID]; (ok && v) || n.Next == nil {
continue
}
slow, fast := n, n
cycled := false
var cycleBound *VoteNode
for slow != nil && fast != nil && fast.Next != nil {
slow = slow.Next
fast = fast.Next.Next
if slow == fast {
slow = n
for slow != fast {
slow = slow.Next
fast = fast.Next
}
cycled = true
cycleBound = slow
break
}
}
var current *VoteNode
if cycled && !visited[slow.ID] {
result = append(result, make([]VoteNode, 0))
result[len(result)-1] = append(result[len(result)-1], VoteNode{ID: slow.ID, Ally: true})
visited[slow.ID] = true
current = slow.Next
for current != slow {
visited[current.ID] = true
result[len(result)-1] = append(result[len(result)-1], VoteNode{ID: current.ID, Ally: true})
current = current.Next
}
if n == slow {
continue
}
}
current = n
var finish *VoteNode
if cycleBound != nil {
if cycleBound == current.Next {
finish = current
} else {
finish = cycleBound
}
} else {
finish = nil
}
if finish != current {
result = append(result, make([]VoteNode, 0))
}
for current != finish {
visited[current.ID] = current.ID != n.ID && current.Next != nil
result[len(result)-1] = append(result[len(result)-1], VoteNode{ID: current.ID, Ally: false})
current = current.Next
}
}
return result
}
func votingWinners(calc []*VoteGroup, sumVotes float64) []int {
slices.SortFunc(calc, func(a, b *VoteGroup) int { return cmp.Compare(b.Sum, a.Sum) })
topVoter := calc[0]
maxVotes := &big.Rat{}
maxVotes.SetFloat64(topVoter.Sum)
winVotes := &big.Rat{}
winVotes.SetFloat64(sumVotes)
winVotes = winVotes.Mul(winVotes, big.NewRat(2, 3))
if maxVotes.Cmp(winVotes) >= 0 {
return topVoter.RaceIndex
}
return nil
}
+300
View File
@@ -0,0 +1,300 @@
package controller_test
import (
"testing"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/controller"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestVotesByRace(t *testing.T) {
c, _ := newCache()
c.MustPlanet(R0_Planet_0_num).Size = 450.
c.MustPlanet(R0_Planet_0_num).Population = 450.
c.MustPlanet(R1_Planet_1_num).Size = 900.
c.MustPlanet(R1_Planet_1_num).Population = 900.
c.MustPlanet(R0_Planet_2_num).Size = 330.
c.MustPlanet(R0_Planet_2_num).Population = 330.
vbr := c.VotesByRace()
assert.Len(t, vbr, 2)
assert.Contains(t, vbr, Race_0_idx)
assert.Equal(t, 0.78, vbr[Race_0_idx])
assert.Contains(t, vbr, Race_1_idx)
assert.Equal(t, 0.9, vbr[Race_1_idx])
}
func prepareRaces() ([]game.Race, func(u uuid.UUID) int) {
races := make([]game.Race, 20)
raceIndex := make(map[uuid.UUID]int)
for i := range len(races) {
races[i].ID = uuid.New()
races[i].VoteFor = races[i].ID
raceIndex[races[i].ID] = i
}
// 0 -> 1 -> 2 -> 3
// ^ |
// 5 -> 6 -> 4 <--'
races[0].VoteFor = races[1].ID
races[1].VoteFor = races[2].ID
races[2].VoteFor = races[3].ID
races[3].VoteFor = races[4].ID
races[4].VoteFor = races[2].ID
races[5].VoteFor = races[6].ID
races[6].VoteFor = races[4].ID
// 7 -> 10 -> 11
// ^
// 8 -> 9
races[7].VoteFor = races[10].ID
races[10].VoteFor = races[11].ID
races[8].VoteFor = races[9].ID
races[9].VoteFor = races[10].ID
// 12 -> 13
// 13 -> 12
races[12].VoteFor = races[13].ID
races[13].VoteFor = races[12].ID
// 14 -> 15 -> 16
// ^ |
// 17 <--'
races[14].VoteFor = races[15].ID
races[15].VoteFor = races[16].ID
races[16].VoteFor = races[17].ID
races[17].VoteFor = races[15].ID
// 19 -> 13
races[19].VoteFor = races[13].ID
return races, func(u uuid.UUID) int { return raceIndex[u] }
}
func TestVotingGraph(t *testing.T) {
races, raceIndex := prepareRaces()
voteNodes := controller.VotingGraph(races, raceIndex)
assert.Len(t, voteNodes, len(races))
for i := range voteNodes {
n := voteNodes[i]
switch i {
case 0:
assert.Equal(t, voteNodes[1], n.Next)
case 1:
assert.Equal(t, voteNodes[2], n.Next)
case 2:
assert.Equal(t, voteNodes[3], n.Next)
case 3:
assert.Equal(t, voteNodes[4], n.Next)
case 4:
assert.Equal(t, voteNodes[2], n.Next)
case 5:
assert.Equal(t, voteNodes[6], n.Next)
case 6:
assert.Equal(t, voteNodes[4], n.Next)
case 7:
assert.Equal(t, voteNodes[10], n.Next)
case 8:
assert.Equal(t, voteNodes[9], n.Next)
case 9:
assert.Equal(t, voteNodes[10], n.Next)
case 10:
assert.Equal(t, voteNodes[11], n.Next)
case 11:
assert.Nil(t, n.Next)
case 12:
assert.Equal(t, voteNodes[13], n.Next)
case 13:
assert.Equal(t, voteNodes[12], n.Next)
case 14:
assert.Equal(t, voteNodes[15], n.Next)
case 15:
assert.Equal(t, voteNodes[16], n.Next)
case 16:
assert.Equal(t, voteNodes[17], n.Next)
case 17:
assert.Equal(t, voteNodes[15], n.Next)
case 18:
assert.Nil(t, n.Next)
case 19:
assert.Equal(t, voteNodes[13], n.Next)
}
}
}
func TestVotingChains(t *testing.T) {
races, raceIndex := prepareRaces()
nodes := controller.VotingGraph(races, raceIndex)
vc := controller.VotingChains(nodes)
assert.Len(t, vc, 7)
for i := range vc {
n := vc[i]
switch i {
case 0:
assert.Len(t, n, 3)
assert.Equal(t, 2, n[0].ID)
assert.Equal(t, 3, n[1].ID)
assert.Equal(t, 4, n[2].ID)
assert.True(t, n[0].Ally)
assert.True(t, n[1].Ally)
assert.True(t, n[2].Ally)
case 1:
assert.Len(t, n, 2)
assert.Equal(t, 0, n[0].ID)
assert.Equal(t, 1, n[1].ID)
assert.False(t, n[0].Ally)
assert.False(t, n[1].Ally)
case 2:
assert.Len(t, n, 2)
assert.Equal(t, 5, n[0].ID)
assert.Equal(t, 6, n[1].ID)
assert.False(t, n[0].Ally)
assert.False(t, n[1].Ally)
case 3:
assert.Len(t, n, 3)
assert.Equal(t, 7, n[0].ID)
assert.Equal(t, 10, n[1].ID)
assert.Equal(t, 11, n[2].ID)
assert.False(t, n[0].Ally)
assert.False(t, n[1].Ally)
assert.False(t, n[2].Ally)
case 4:
assert.Len(t, n, 4)
assert.Equal(t, 8, n[0].ID)
assert.Equal(t, 9, n[1].ID)
assert.Equal(t, 10, n[2].ID)
assert.Equal(t, 11, n[3].ID)
assert.False(t, n[0].Ally)
assert.False(t, n[1].Ally)
assert.False(t, n[2].Ally)
assert.False(t, n[3].Ally)
case 5:
assert.Len(t, n, 2)
assert.Equal(t, 12, n[0].ID)
assert.Equal(t, 13, n[1].ID)
assert.True(t, n[0].Ally)
assert.True(t, n[1].Ally)
case 6:
assert.Len(t, n, 3)
assert.Equal(t, 15, n[0].ID)
assert.Equal(t, 16, n[1].ID)
assert.Equal(t, 17, n[2].ID)
assert.True(t, n[0].Ally)
assert.True(t, n[1].Ally)
assert.True(t, n[2].Ally)
}
}
}
func TestGroupVotes(t *testing.T) {
races, raceIndex := prepareRaces()
raceVotes := make(map[int]float64)
// [1] = 0.24
raceVotes[0] = 0.11
raceVotes[1] = 0.13
// [2,3,4] = 0.69
raceVotes[2] = 0.22
raceVotes[3] = 0.23
raceVotes[4] = 0.24
// [6] = 0.71
raceVotes[5] = 0.35
raceVotes[6] = 0.36
// [11] = 0.843
raceVotes[7] = 0.41
raceVotes[9] = 0.42
raceVotes[10] = 0.013
// [12,13] = 0.52
raceVotes[12] = 0.52
raceVotes[13] = 0.
// [14] = 1.04
raceVotes[14] = 1.04
// [15,16,17] = 2.49
raceVotes[15] = 1.15
raceVotes[16] = 0.16
raceVotes[17] = 1.18
// [18] = 3.18
raceVotes[18] = 3.18
// [19] = 0.019
raceVotes[19] = 0.019
calc := controller.GroupVotes(raceVotes, controller.VotingGraph(races, raceIndex))
assert.Len(t, calc, 9)
for i := range calc {
vg := calc[i]
switch i {
case 0:
assert.ElementsMatch(t, []int{2, 3, 4}, vg.RaceIndex)
assert.Equal(t, 0.69, vg.Sum)
case 4:
assert.ElementsMatch(t, []int{12, 13}, vg.RaceIndex)
assert.Equal(t, 0.52, vg.Sum)
case 5:
assert.ElementsMatch(t, []int{15, 16, 17}, vg.RaceIndex)
assert.InDelta(t, 2.49, vg.Sum, 0.001)
default:
assert.Len(t, vg.RaceIndex, 1)
switch ri := vg.RaceIndex[0]; ri {
case 1:
assert.Equal(t, 0.24, vg.Sum)
case 6:
assert.Equal(t, 0.71, vg.Sum)
case 11:
assert.Equal(t, 0.843, vg.Sum)
case 14:
assert.Equal(t, 1.04, vg.Sum)
case 18:
assert.Equal(t, 3.18, vg.Sum)
case 19:
assert.Equal(t, 0.019, vg.Sum)
default:
assert.Failf(t, "unexpected group", "id=%v sum=%f", vg.RaceIndex, vg.Sum)
}
}
}
}
func TestVotingWinners(t *testing.T) {
gameVotes := 100.0
var vg []*controller.VoteGroup
var winners []int
vg = []*controller.VoteGroup{
{Sum: 4.0, RaceIndex: []int{0}},
{Sum: 66.65, RaceIndex: []int{1, 2}},
{Sum: 5.0, RaceIndex: []int{3}},
{Sum: 25.0, RaceIndex: []int{4, 5, 6}},
}
winners = controller.VotingWinners(vg, gameVotes)
assert.Len(t, winners, 0)
vg = []*controller.VoteGroup{
{Sum: 4.0, RaceIndex: []int{0}},
{Sum: 66.666666666666666, RaceIndex: []int{1, 2}},
{Sum: 5.0, RaceIndex: []int{3}},
{Sum: 22.0, RaceIndex: []int{4, 5, 6}},
}
winners = controller.VotingWinners(vg, gameVotes)
assert.ElementsMatch(t, winners, []int{1, 2})
vg = []*controller.VoteGroup{
{Sum: 4.0, RaceIndex: []int{0}},
{Sum: 3.33, RaceIndex: []int{1, 2}},
{Sum: 66.67, RaceIndex: []int{3}},
{Sum: 25.0, RaceIndex: []int{4, 5, 6}},
}
winners = controller.VotingWinners(vg, gameVotes)
assert.ElementsMatch(t, winners, []int{3})
}
+84
View File
@@ -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
}
+106
View File
@@ -0,0 +1,106 @@
package generator_test
import (
"fmt"
"testing"
"github.com/iliadenisov/galaxy/server/internal/generator"
"github.com/stretchr/testify/assert"
)
func TestGenerator(t *testing.T) {
maxPlayers := 30
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)
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)
}
})
}
}
+75
View File
@@ -0,0 +1,75 @@
package generator
import (
"fmt"
"math/rand"
"galaxy/util"
"github.com/iliadenisov/galaxy/server/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 {
return util.ShortDistance(m.Width, m.Height, from.X, from.Y, to.X, to.Y)
}
// 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)
}
}
+28
View File
@@ -0,0 +1,28 @@
package generator
import (
"fmt"
"testing"
"galaxy/util"
"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, util.Fixed3(d))
})
}
}
+43
View File
@@ -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,
}
}
+30
View File
@@ -0,0 +1,30 @@
package generator_test
import (
"regexp"
"testing"
g "github.com/iliadenisov/galaxy/server/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,76 @@
package plotter
import (
"errors"
"fmt"
"math"
"math/rand"
"github.com/iliadenisov/galaxy/server/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) {
// Adding extra 'pixel' to avoid radius became less than deadZoneRaduis
// after division by factor due to floating-point operations specifics
p.circleFn(x, y, (radius+p.factor)/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/server/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)
}
}
+112
View File
@@ -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 // Rules: [0.1, 20]
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.1,
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.1,
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,
},
}
}
+20
View File
@@ -0,0 +1,20 @@
package game
import "github.com/google/uuid"
type Bombing struct {
ID uuid.UUID `json:"-"`
PlanetOwnedID uuid.UUID `json:"-"` // for the report filtering
Planet string `json:"name"`
Number uint `json:"number"`
Owner string `json:"owner"`
Attacker string `json:"attacker"`
Production string `json:"production"`
Industry Float `json:"industry"` // I - Промышленность
Population Float `json:"population"` // P - Население
Colonists Float `json:"colonists"` // COL C - Количество колонистов
Capital Float `json:"capital"` // CAP $ - Запасы промышленности
Material Float `json:"material"` // MAT M - Запасы ресурсов / сырья
AttackPower Float `json:"attack"`
Wiped bool `json:"wiped"`
}
+9
View File
@@ -0,0 +1,9 @@
package game
import "github.com/google/uuid"
type Fleet struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"ownerId"`
Name string `json:"name"`
}
+94
View File
@@ -0,0 +1,94 @@
package game
import (
"encoding/json"
"fmt"
"maps"
"galaxy/util"
"github.com/google/uuid"
)
type Float float64
func F(v float64) Float {
return Float(v)
}
func (f Float) Add(v float64) Float {
return f + F(v)
}
func (f Float) F() float64 {
return util.Fixed12(float64(f))
}
type TechSet map[Tech]Float
func (ts TechSet) Value(t Tech) float64 {
if v, ok := ts[t]; ok {
return v.F()
} else {
panic(fmt.Sprintf("TechSet: Value: %s's value not set", t.String()))
}
}
func (ts TechSet) Set(t Tech, v float64) TechSet {
m := maps.Clone(ts)
m[t] = F(v)
return m
}
func NewTechSet() TechSet {
return TechSet{
TechDrive: 1.,
TechWeapons: 1.,
TechShields: 1.,
TechCargo: 1.,
}
}
type Game struct {
ID uuid.UUID `json:"id"`
Turn uint `json:"turn"`
Stage uint `json:"stage"`
Map Map `json:"map"`
Race []Race `json:"races"`
Votes Float `json:"votes"`
ShipGroups []ShipGroup `json:"shipGroup,omitempty"`
Fleets []Fleet `json:"fleet,omitempty"`
Winner []uuid.UUID `json:"winner,omitempty"`
}
func (g Game) Finished() bool {
return len(g.Winner) > 0
}
type GameMeta struct {
Battles []BattleMeta `json:"battles,omitempty"`
Bombings []Bombing `json:"bombings,omitempty"`
}
type BattleMeta struct {
Turn uint `json:"turn"`
Planet uint `json:"planet"`
BattleID uuid.UUID `json:"battle_id"`
ObserverIDs []uuid.UUID `json:"observer_ids"`
}
func (g Game) MarshalBinary() (data []byte, err error) {
return json.Marshal(&g)
}
func (g *Game) UnmarshalBinary(data []byte) error {
return json.Unmarshal(data, g)
}
func (gm GameMeta) MarshalBinary() (data []byte, err error) {
return json.Marshal(&gm)
}
func (gm *GameMeta) UnmarshalBinary(data []byte) error {
return json.Unmarshal(data, gm)
}
+54
View File
@@ -0,0 +1,54 @@
package game_test
import (
"testing"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestTechSet(t *testing.T) {
s := ts.Set(game.TechDrive, 10.5)
assert.Equal(t, 1.1, ts.Value(game.TechDrive))
assert.Equal(t, 1.2, ts.Value(game.TechWeapons))
assert.Equal(t, 1.3, ts.Value(game.TechShields))
assert.Equal(t, 1.4, ts.Value(game.TechCargo))
assert.Equal(t, 10.5, s.Value(game.TechDrive))
assert.Equal(t, 1.2, s.Value(game.TechWeapons))
assert.Equal(t, 1.3, s.Value(game.TechShields))
assert.Equal(t, 1.4, s.Value(game.TechCargo))
s = s.Set(game.TechWeapons, 5.7)
assert.Equal(t, 1.1, ts.Value(game.TechDrive))
assert.Equal(t, 1.2, ts.Value(game.TechWeapons))
assert.Equal(t, 1.3, ts.Value(game.TechShields))
assert.Equal(t, 1.4, ts.Value(game.TechCargo))
assert.Equal(t, 10.5, s.Value(game.TechDrive))
assert.Equal(t, 5.7, s.Value(game.TechWeapons))
assert.Equal(t, 1.3, s.Value(game.TechShields))
assert.Equal(t, 1.4, s.Value(game.TechCargo))
s = s.Set(game.TechShields, 2.13)
assert.Equal(t, 1.1, ts.Value(game.TechDrive))
assert.Equal(t, 1.2, ts.Value(game.TechWeapons))
assert.Equal(t, 1.3, ts.Value(game.TechShields))
assert.Equal(t, 1.4, ts.Value(game.TechCargo))
assert.Equal(t, 10.5, s.Value(game.TechDrive))
assert.Equal(t, 5.7, s.Value(game.TechWeapons))
assert.Equal(t, 2.13, s.Value(game.TechShields))
assert.Equal(t, 1.4, s.Value(game.TechCargo))
s = s.Set(game.TechCargo, 3.1415926)
assert.Equal(t, 1.1, ts.Value(game.TechDrive))
assert.Equal(t, 1.2, ts.Value(game.TechWeapons))
assert.Equal(t, 1.3, ts.Value(game.TechShields))
assert.Equal(t, 1.4, ts.Value(game.TechCargo))
assert.Equal(t, 10.5, s.Value(game.TechDrive))
assert.Equal(t, 5.7, s.Value(game.TechWeapons))
assert.Equal(t, 2.13, s.Value(game.TechShields))
assert.Equal(t, 3.1415926, s.Value(game.TechCargo))
}
+233
View File
@@ -0,0 +1,233 @@
package game
import (
"fmt"
"math"
"strings"
"github.com/google/uuid"
)
type CargoType string
const (
CargoColonist CargoType = "COL" // Колонисты
CargoMaterial CargoType = "MAT" // Сырьё
CargoCapital CargoType = "CAP" // Промышленность
)
var (
CargoTypeSet map[string]CargoType = map[string]CargoType{
strings.ToLower(CargoColonist.String()): CargoColonist,
strings.ToLower(CargoMaterial.String()): CargoMaterial,
strings.ToLower(CargoCapital.String()): CargoCapital,
}
)
func (ct CargoType) Ref() *CargoType {
return &ct
}
func (ct CargoType) String() string {
return string(ct)
}
type ShipGroupState string
const (
StateInOrbit ShipGroupState = "In_Orbit"
StateLaunched ShipGroupState = "Launched"
StateInSpace ShipGroupState = "In_Space"
StateUpgrade ShipGroupState = "Upgrade"
StateTransfer ShipGroupState = "Transfer" // [ ] Группы будут передаваться мгновенно в начале производства хода
)
func (sgs ShipGroupState) String() string {
return string(sgs)
}
type InSpace struct {
// X, Y are nil for Launched state
Origin uint `json:"origin"`
X *Float `json:"x,omitempty"`
Y *Float `json:"y,omitempty"`
}
func (is InSpace) Equal(other InSpace) bool {
return is.Origin == other.Origin && is.X == other.X && is.Y == other.Y
}
func (is InSpace) Launched() bool {
if (is.X == nil && is.Y != nil) || (is.X != nil && is.Y == nil) {
panic("group in space state invalid: one of coordinate is not set")
}
return is.X == nil && is.Y == is.X
}
type InUpgrade struct {
UpgradeTech []UpgradePreference `json:"preference"`
}
func (iu InUpgrade) Cost() float64 {
var sum float64
for i := range iu.UpgradeTech {
sum += iu.UpgradeTech[i].Cost.F()
}
return sum
}
func (iu InUpgrade) TechCost(t Tech) float64 {
for i := range iu.UpgradeTech {
if iu.UpgradeTech[i].Tech == t {
return iu.UpgradeTech[i].Cost.F()
}
}
return 0.
}
type UpgradePreference struct {
Tech Tech `json:"tech"`
Level Float `json:"level"`
Cost Float `json:"cost"`
}
type Tech string
const (
TechAll Tech = "ALL"
TechDrive Tech = "DRIVE"
TechWeapons Tech = "WEAPONS"
TechShields Tech = "SHIELDS"
TechCargo Tech = "CARGO"
)
func (t Tech) String() string {
return string(t)
}
type ShipGroup struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"ownerId"` // Race reference
TypeID uuid.UUID `json:"typeId"` // ShipType reference
FleetID *uuid.UUID `json:"fleetId,omitempty"` // Fleet reference
Number uint `json:"number"` // Number (quantity) ships of specific ShipType
CargoType *CargoType `json:"loadType,omitempty"` //
Load Float `json:"load"` // Cargo loaded - "Масса груза"
Tech TechSet `json:"tech"` //
Destination uint `json:"destination"` //
StateInSpace *InSpace `json:"inSpace,omitempty"` //
StateUpgrade *InUpgrade `json:"upgrade,omitempty"` //
StateTransfer bool `json:"transfer,omitempty"` //
}
func (sg ShipGroup) TechLevel(t Tech) Float {
return F(sg.Tech.Value(t))
}
func (sg *ShipGroup) SetTechLevel(t Tech, v float64) {
sg.Tech = sg.Tech.Set(t, v)
}
func (sg ShipGroup) State() ShipGroupState {
switch {
case sg.StateInSpace == nil && sg.StateUpgrade == nil:
return StateInOrbit
case sg.StateInSpace != nil && sg.StateUpgrade == nil:
if !sg.StateInSpace.Launched() {
return StateInSpace
}
if sg.StateTransfer {
return StateTransfer
}
return StateLaunched
case sg.StateUpgrade != nil && sg.StateInSpace == nil:
return StateUpgrade
default:
panic(fmt.Sprintf("ambigous group state: in_space=%#v upgrage=%#v", sg.StateInSpace, sg.StateUpgrade))
}
}
func (sg ShipGroup) AtPlanet() (uint, bool) {
switch sg.State() {
case StateInOrbit:
return sg.Destination, true
case StateLaunched:
return sg.StateInSpace.Origin, true
default:
return 0, false
}
}
func (sg ShipGroup) Coord() (float64, float64, bool) {
state := sg.State()
if state == StateInSpace || state == StateLaunched {
return sg.StateInSpace.X.F(), sg.StateInSpace.Y.F(), true
}
return 0, 0, false
}
func (sg ShipGroup) Equal(other ShipGroup) bool {
return sg.OwnerID == other.OwnerID &&
sg.TypeID == other.TypeID &&
sg.FleetID == other.FleetID &&
sg.TechLevel(TechDrive) == other.TechLevel(TechDrive) &&
sg.TechLevel(TechWeapons) == other.TechLevel(TechWeapons) &&
sg.TechLevel(TechShields) == other.TechLevel(TechShields) &&
sg.TechLevel(TechCargo) == other.TechLevel(TechCargo) &&
sg.CargoType == other.CargoType &&
(sg.Load/F(float64(sg.Number))).F() == (other.Load/F(float64(other.Number))).F() &&
sg.State() == other.State()
}
// Грузоподъёмность
func (sg ShipGroup) CargoCapacity(st *ShipType) float64 {
return sg.TechLevel(TechCargo).F() * (st.Cargo.F() + (st.Cargo.F()*st.Cargo.F())/20) * float64(sg.Number)
}
// Масса перевозимого груза -
// общее количество единиц груза, деленное на технологический уровень Грузоперевозок
func (sg ShipGroup) CarryingMass() float64 {
if sg.Load.F() == 0 {
return 0
}
return sg.Load.F() / sg.TechLevel(TechCargo).F()
}
// Масса группы без учёта груза
func (sg ShipGroup) EmptyMass(st *ShipType) float64 {
return st.EmptyMass() * float64(sg.Number)
}
// Полная масса -
// массу корабля самого по себе плюс масса перевозимого груза
func (sg ShipGroup) FullMass(st *ShipType) float64 {
return sg.EmptyMass(st) + sg.CarryingMass()
}
// Эффективность двигателя -
// равна мощности Двигателей, умноженной на технологический уровень блока Двигателей
func (sg ShipGroup) DriveEffective(st *ShipType) float64 {
return st.Drive.F() * sg.TechLevel(TechDrive).F()
}
// Корабли перемещаются за один ход на количество световых лет, равное
// эффективности двигателя, умноженной на 20 и деленной на "Полную массу" корабля
func (sg ShipGroup) Speed(st *ShipType) float64 {
return sg.DriveEffective(st) * 20 / sg.FullMass(st)
}
// Мощность бомбардировки
func (sg ShipGroup) BombingPower(st *ShipType) float64 {
return (math.Sqrt(st.Weapons.F()*sg.TechLevel(TechWeapons).F())/10. + 1.) *
st.Weapons.F() *
sg.TechLevel(TechWeapons).F() *
float64(st.Armament) *
float64(sg.Number)
}
func (sg ShipGroup) CargoString() string {
if sg.CargoType == nil {
return "-"
}
return sg.CargoType.String()
}
+252
View File
@@ -0,0 +1,252 @@
package game_test
import (
"math/rand/v2"
"testing"
"galaxy/util"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestCargoCapacity(t *testing.T) {
test := func(cargoSize float64, expectCapacity float64) {
ship := game.ShipType{
Drive: 1,
Armament: 1,
Weapons: 1,
Shields: 1,
Cargo: game.F(cargoSize),
}
sg := game.ShipGroup{
Number: 1,
Tech: map[game.Tech]game.Float{
game.TechDrive: game.F(1.5),
game.TechWeapons: game.F(1.1),
game.TechShields: game.F(2.0),
game.TechCargo: game.F(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{
Name: "Freighter",
Drive: 8,
Armament: 0,
Weapons: 0,
Shields: 2,
Cargo: 10,
}
sg := &game.ShipGroup{
Number: 1,
Tech: map[game.Tech]game.Float{
game.TechDrive: game.F(1.0),
game.TechWeapons: game.F(1.0),
game.TechShields: game.F(1.0),
game.TechCargo: game.F(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.SetTechLevel(game.TechCargo, 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{
Name: "Freighter",
Drive: 8,
Armament: 0,
Weapons: 0,
Shields: 2,
Cargo: 10,
}
sg := &game.ShipGroup{
Number: 1,
Tech: map[game.Tech]game.Float{
game.TechDrive: game.F(1.0),
game.TechWeapons: game.F(1.0),
game.TechShields: game.F(1.0),
game.TechCargo: game.F(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.SetTechLevel(game.TechDrive, 1.5)
assert.Equal(t, 9.6, sg.Speed(Freighter))
sg.Load = 10
sg.SetTechLevel(game.TechCargo, 1.5)
assert.Equal(t, 9.0, sg.Speed(Freighter))
}
func TestBombingPower(t *testing.T) {
BattleStation := game.ShipType{
Name: "Battle_Station",
Drive: 60.0,
Armament: 3,
Weapons: 30.0,
Shields: 100.0,
Cargo: 0.0,
}
sg := game.ShipGroup{
Number: 1,
Tech: map[game.Tech]game.Float{
game.TechDrive: game.F(1.0),
game.TechWeapons: game.F(1.0),
game.TechShields: game.F(1.0),
game.TechCargo: game.F(1.0),
},
}
assert.Equal(t, 139.295, util.Fixed3(sg.BombingPower(&BattleStation)))
sg.Number = 2
assert.Equal(t, 278.590, util.Fixed3(sg.BombingPower(&BattleStation)))
}
func TestDriveEffective(t *testing.T) {
tc := []struct {
driveShipType game.Float
driveTech game.Float
expectDriveEffective game.Float
}{
{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{
Drive: tc[i].driveShipType,
Armament: rand.UintN(30) + 1,
Weapons: game.F(rand.Float64()*30 + 1),
Shields: game.F(rand.Float64()*100 + 1),
Cargo: game.F(rand.Float64()*20 + 1),
}
sg := game.ShipGroup{
Number: rand.UintN(4) + 1,
Tech: map[game.Tech]game.Float{
game.TechDrive: tc[i].driveTech,
game.TechWeapons: game.F(rand.Float64()*5 + 1),
game.TechShields: game.F(rand.Float64()*5 + 1),
game.TechCargo: game.F(rand.Float64()*5 + 1),
},
}
assert.Equal(t, tc[i].expectDriveEffective.F(), sg.DriveEffective(&someShip))
}
}
func TestShipGroupEqual(t *testing.T) {
fleetId := uuid.New()
someUUID := uuid.New()
mat := game.CargoMaterial
cap := game.CargoCapital
left := &game.ShipGroup{
ID: uuid.New(),
Number: 1,
OwnerID: uuid.New(),
TypeID: uuid.New(),
FleetID: &fleetId,
CargoType: &mat,
Load: 123.45,
Tech: map[game.Tech]game.Float{
game.TechDrive: 1.0,
game.TechWeapons: 1.0,
game.TechShields: 1.0,
game.TechCargo: 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
coord := game.Float(1)
left.StateInSpace = &game.InSpace{
Origin: 1,
X: &coord,
Y: &coord,
}
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.SetTechLevel(game.TechDrive, 1.1)
assert.Equal(t, 1.1, left.TechLevel(game.TechDrive).F())
assert.False(t, left.Equal(right))
right = *left
left.SetTechLevel(game.TechWeapons, 1.1)
assert.Equal(t, 1.1, left.TechLevel(game.TechWeapons).F())
assert.False(t, left.Equal(right))
right = *left
left.SetTechLevel(game.TechShields, 1.1)
assert.Equal(t, 1.1, left.TechLevel(game.TechShields).F())
assert.False(t, left.Equal(right))
right = *left
left.SetTechLevel(game.TechCargo, 1.1)
assert.Equal(t, 1.1, left.TechLevel(game.TechCargo).F())
assert.False(t, left.Equal(right))
// non-essential properties
right = *left
left.ID = uuid.New()
assert.True(t, left.Equal(right))
// dirty hack to equalize loads
left.Number = 5
left.Load = game.F(float64(right.Load) / float64(right.Number) * float64(left.Number))
assert.True(t, left.Equal(right))
}
+7
View File
@@ -0,0 +1,7 @@
package game
type Map struct {
Width uint32 `json:"width"`
Height uint32 `json:"height"`
Planet []Planet `json:"planets"`
}
+167
View File
@@ -0,0 +1,167 @@
package game
import (
"github.com/google/uuid"
)
type Planet struct {
X Float `json:"x"`
Y Float `json:"y"`
Number uint `json:"number"`
Size Float `json:"size"`
Name string `json:"name"`
Owner *uuid.UUID `json:"owner,omitempty"`
Resources Float `json:"resources"` // R - Ресурсы
Capital Float `json:"capital"` // CAP $ - Запасы промышленности
Material Float `json:"material"` // MAT M - Запасы ресурсов / сырьё
Industry Float `json:"industry"` // I - Промышленность
Population Float `json:"population"` // P - Население
Colonists Float `json:"colonists"` // COL C - Количество колонистов
Production Production `json:"production"`
Route map[RouteType]uint `json:"route"`
}
func (p *Planet) Own(v uuid.UUID) {
if v == uuid.Nil {
p.Free()
return
}
if p.Owner == nil || *p.Owner != v {
p.Production = ProductionCapital.AsType(uuid.Nil)
}
p.Owner = &v
}
func (p *Planet) Free() {
p.Owner = nil
p.Production = ProductionNone.AsType(uuid.Nil)
p.Colonists = 0.
p.Population = 0.
clear(p.Route)
}
func (p *Planet) Wipe() {
p.Free()
p.Industry = 0
p.Capital = 0
}
func (p Planet) Owned() bool {
return p.Owner != nil && *p.Owner != uuid.Nil
}
func (p Planet) OwnedBy(v uuid.UUID) bool {
if !p.Owned() {
return false
}
return *p.Owner == v
}
func (p *Planet) Mat(v float64) {
p.Material = F(v)
}
func (p *Planet) Pop(v float64) {
p.Population = F(v)
}
func (p *Planet) Col(v float64) {
p.Colonists = F(v)
}
func (p *Planet) Ind(v float64) {
p.Industry = F(v)
}
func (p *Planet) Cap(v float64) {
p.Capital = F(v)
}
func (p Planet) Votes() float64 {
return p.Population.F() / 1000.
}
// Производственный потенциал без учёта модернизации кораблей
func (p Planet) ProductionCapacity() float64 {
return PlanetProduction(p.Industry.F(), p.Population.F())
}
func PlanetProduction(industry, population float64) float64 {
return industry*0.75 + population*0.25
}
func (p *Planet) ReleaseMaterial(shipMass float64) {
if p.Production.Type != ProductionShip || p.Production.Progress == nil {
panic("planet is not producing any ships")
}
p.Material = p.Material.Add(ProducedMaterial(shipMass, float64(*p.Production.Progress)))
p.Production.Progress = new(Float)
p.Production.ProdUsed = new(Float)
}
func ProducedMaterial(shipMass, progress float64) float64 {
return shipMass * progress
}
// Производство промышленности
func (p *Planet) ProduceIndustry(freeProduction float64) {
prod := freeProduction / 5
if float64(p.Material) < prod {
prod = (freeProduction + float64(p.Material/p.Resources)) / (5. + 1./float64(p.Resources))
p.Material = 0.
} else {
p.Material = p.Material.Add(-prod)
}
p.Industry = p.Industry.Add(prod)
if p.Industry > p.Population {
p.Capital += p.Industry - p.Population
p.Industry = p.Population
}
}
// Производство материалов
func (p *Planet) ProduceMaterial(freeProduction float64) {
p.Material = p.Material.Add(freeProduction * p.Resources.F())
}
// Автоматическое увеличение населения на каждом ходу
func (p *Planet) ProducePopulation() {
p.Population *= 1.08
if p.Population > p.Size {
p.Colonists += (p.Population - p.Size) / 8.
p.Population = p.Size
}
}
func (p *Planet) UnpackCapital() {
if p.Capital == 0 {
return
}
deficit := p.Population - p.Industry
if deficit > p.Capital {
deficit = p.Capital
}
p.Capital -= deficit
p.Industry += deficit
}
func (p *Planet) UnpackColonists() {
if p.Colonists == 0 {
return
}
deficit := (p.Size - p.Population) / 8
if deficit > p.Colonists {
deficit = p.Colonists
}
p.Colonists -= deficit
p.Population += deficit * 8
}
func UnloadColonists(p Planet, v float64) Planet {
p.Pop(p.Population.F() + v*8)
if p.Population > p.Size {
p.Col(p.Colonists.F() + (p.Population.F()-p.Size.F())/8.)
p.Pop(p.Size.F())
}
return p
}
+151
View File
@@ -0,0 +1,151 @@
package game_test
import (
"testing"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestPlanetProduction(t *testing.T) {
assert.Equal(t, 1000., game.PlanetProduction(1000., 1000.))
assert.Equal(t, 625., game.PlanetProduction(500., 1000.))
assert.Equal(t, 750., game.PlanetProduction(1000., 0.))
assert.Equal(t, 250., game.PlanetProduction(0., 1000.))
}
func TestProduceIndustry(t *testing.T) {
HW := &game.Planet{
Size: 1000,
Resources: 10,
Population: 1000,
Industry: 1000,
}
DW := &game.Planet{
Size: 500,
Resources: 10,
Population: 500,
Industry: 500,
}
HW.ProduceIndustry(HW.ProductionCapacity())
assert.InDelta(t, 196.078, HW.Capital.F(), 0.0005)
HW.Capital = 0
HW.Material = 200
HW.ProduceIndustry(HW.ProductionCapacity())
assert.Equal(t, 200., HW.Capital.F())
assert.Equal(t, 0., HW.Material.F())
DW.ProduceIndustry(DW.ProductionCapacity())
assert.InDelta(t, 98.039, DW.Capital.F(), 0.0003)
DW.Capital = 0
DW.Material = 100
DW.ProduceIndustry(DW.ProductionCapacity())
assert.Equal(t, 100., DW.Capital.F())
assert.Equal(t, 0., DW.Material.F())
}
func TestProduceMaterial(t *testing.T) {
HW := &game.Planet{
Size: 1000,
Resources: 10,
Population: 1000,
Industry: 1000,
}
assert.Equal(t, 0., HW.Material.F())
HW.ProduceMaterial(HW.ProductionCapacity())
assert.Equal(t, 10000., HW.Material.F())
HW.Industry = 500
HW.Population = 500
HW.ProduceMaterial(HW.ProductionCapacity())
assert.Equal(t, 15000., HW.Material.F())
HW.Population = 1000
HW.ProduceMaterial(HW.ProductionCapacity())
assert.Equal(t, 21250., HW.Material.F())
}
func TestUnpackCapital(t *testing.T) {
HW := &game.Planet{
Size: 1000,
Resources: 10,
Population: 1000,
Industry: 1000,
}
assert.Equal(t, 0., HW.Capital.F())
HW.UnpackCapital()
assert.Equal(t, 1000., HW.Industry.F())
assert.Equal(t, 0., HW.Capital.F())
HW.Capital = 123.
HW.UnpackCapital()
assert.Equal(t, 1000., HW.Industry.F())
assert.Equal(t, 123., HW.Capital.F())
HW.Industry = 987.
HW.UnpackCapital()
assert.Equal(t, 1000., HW.Industry.F())
assert.Equal(t, 110., HW.Capital.F())
HW.Population = 876.
HW.Industry = 800.
HW.UnpackCapital()
assert.Equal(t, 876., HW.Population.F())
assert.Equal(t, 876., HW.Industry.F())
assert.Equal(t, 34., HW.Capital.F())
}
func TestUnpackColonists(t *testing.T) {
HW := &game.Planet{
Size: 1000,
Resources: 10,
Population: 1000,
Industry: 1000,
}
assert.Equal(t, 0., HW.Colonists.F())
HW.UnpackColonists()
assert.Equal(t, 1000., HW.Population.F())
assert.Equal(t, 0., HW.Colonists.F())
HW.Colonists = 1.05
HW.UnpackColonists()
assert.Equal(t, 1000., HW.Population.F())
assert.Equal(t, 1.05, HW.Colonists.F())
HW.Population = 996.0
HW.UnpackColonists()
assert.Equal(t, 1000., HW.Population.F())
assert.Equal(t, 0.55, HW.Colonists.F())
HW.Population = 0.0
HW.UnpackColonists()
assert.Equal(t, 4.4, HW.Population.F())
assert.Equal(t, 0., HW.Colonists.F())
}
func TestProducePopulation(t *testing.T) {
HW := &game.Planet{
Size: 1000,
Resources: 10,
Population: 500,
Industry: 1000,
}
assert.Equal(t, 500., HW.Population.F())
assert.Equal(t, 0., HW.Colonists.F())
HW.ProducePopulation()
assert.Equal(t, 540., HW.Population.F())
assert.Equal(t, 0., HW.Colonists.F())
HW.Population = 1000.
HW.ProducePopulation()
assert.Equal(t, 1000., HW.Population.F())
assert.Equal(t, 10., HW.Colonists.F())
}
+37
View File
@@ -0,0 +1,37 @@
package game
import (
"github.com/google/uuid"
)
type ProductionType string
const (
ProductionNone ProductionType = "-"
ProductionMaterial ProductionType = "MAT" // Сырьё
ProductionCapital ProductionType = "CAP" // Промышленность
ResearchDrive ProductionType = "DRIVE"
ResearchWeapons ProductionType = "WEAPONS"
ResearchShields ProductionType = "SHIELDS"
ResearchCargo ProductionType = "CARGO"
ResearchScience ProductionType = "SCIENCE"
ProductionShip ProductionType = "SHIP"
)
type Production struct {
Type ProductionType `json:"type"`
SubjectID *uuid.UUID `json:"subjectId,omitempty"`
Progress *Float `json:"progress,omitempty"`
ProdUsed *Float `json:"prodUsed,omitempty"`
}
func (p ProductionType) AsType(subject uuid.UUID) Production {
switch p {
case ResearchScience, ProductionShip:
return Production{Type: p, SubjectID: &subject}
default:
return Production{Type: p, SubjectID: nil}
}
}
+62
View File
@@ -0,0 +1,62 @@
package game
import (
"strings"
"github.com/google/uuid"
)
type Relation string
const (
RelationWar Relation = "WAR"
RelationPeace Relation = "PEACE"
)
var (
relationSet = map[string]Relation{
strings.ToLower(RelationWar.String()): RelationWar,
strings.ToLower(RelationPeace.String()): RelationPeace,
}
)
type Race struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
TTL uint `json:"ttl"`
Extinct bool `json:"extinct"`
Votes Float `json:"votes"`
VoteFor uuid.UUID `json:"voteFor"`
Relations []RaceRelation `json:"relations"`
Tech TechSet `json:"tech"`
Sciences []Science `json:"science,omitempty"`
ShipTypes []ShipType `json:"shipType,omitempty"`
}
func ParseRelation(v string) (Relation, bool) {
if v, ok := relationSet[strings.ToLower(v)]; ok {
return v, ok
}
return Relation(""), false
}
func (r Relation) String() string {
return string(r)
}
type RaceRelation struct {
RaceID uuid.UUID `json:"raceId"`
Relation Relation `json:"relation"`
}
func (r Race) TechLevel(t Tech) float64 {
return r.Tech.Value(t)
}
func (r Race) FlightDistance() float64 {
return r.TechLevel(TechDrive) * 40
}
func (r Race) VisibilityDistance() float64 {
return r.TechLevel(TechDrive) * 30
}
+35
View File
@@ -0,0 +1,35 @@
package game_test
import (
"testing"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
var (
ts = game.TechSet{
game.TechDrive: 1.1,
game.TechWeapons: 1.2,
game.TechShields: 1.3,
game.TechCargo: 1.4,
}
r = game.Race{
Tech: ts,
}
)
func TestTechLevel(t *testing.T) {
assert.Equal(t, 1.1, r.TechLevel(game.TechDrive))
assert.Equal(t, 1.2, r.TechLevel(game.TechWeapons))
assert.Equal(t, 1.3, r.TechLevel(game.TechShields))
assert.Equal(t, 1.4, r.TechLevel(game.TechCargo))
}
func TestFlightDistance(t *testing.T) {
assert.Equal(t, 44., r.FlightDistance())
}
func TestVisibilityDistance(t *testing.T) {
assert.Equal(t, 33., r.VisibilityDistance())
}
+34
View File
@@ -0,0 +1,34 @@
package game
import "strings"
type RouteType string
const (
RouteMaterial RouteType = "MAT" // Сырьё
RouteCapital RouteType = "CAP" // Промышленность
RouteColonist RouteType = "COL" // Колонисты
RouteEmpty RouteType = "EMP" // Пустые корабли
)
var (
RouteTypeSet map[string]RouteType = map[string]RouteType{
strings.ToLower(RouteMaterial.String()): RouteMaterial,
strings.ToLower(RouteCapital.String()): RouteCapital,
strings.ToLower(RouteColonist.String()): RouteColonist,
strings.ToLower(RouteEmpty.String()): RouteEmpty,
}
RouteToCargo map[RouteType]CargoType = map[RouteType]CargoType{
RouteColonist: CargoColonist,
RouteCapital: CargoCapital,
RouteMaterial: CargoMaterial,
}
)
func (rt RouteType) Ref() *RouteType {
return &rt
}
func (rt RouteType) String() string {
return string(rt)
}
+14
View File
@@ -0,0 +1,14 @@
package game
import (
"github.com/google/uuid"
)
type Science struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Drive Float `json:"drive"`
Weapons Float `json:"weapons"`
Shields Float `json:"shields"`
Cargo Float `json:"cargo"`
}
+64
View File
@@ -0,0 +1,64 @@
package game
import (
"fmt"
"github.com/google/uuid"
)
type ShipType struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Drive Float `json:"drive"`
Armament uint `json:"armament"`
Weapons Float `json:"weapons"`
Shields Float `json:"shields"`
Cargo Float `json:"cargo"`
}
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) BlockMass(t Tech) float64 {
switch t {
case TechDrive:
return st.DriveBlockMass()
case TechWeapons:
return st.WeaponsBlockMass()
case TechShields:
return st.ShieldsBlockMass()
case TechCargo:
return st.CargoBlockMass()
default:
panic("BlockMass: unexpectec tech: " + t.String())
}
}
func (st ShipType) DriveBlockMass() float64 {
return st.Drive.F()
}
func (st ShipType) WeaponsBlockMass() float64 {
if (st.Armament == 0 && st.Weapons != 0) || (st.Armament != 0 && st.Weapons == 0) {
panic(fmt.Sprintf("ship class invalid design: A=%d W=%.03f", st.Armament, st.Weapons))
}
return float64(st.Armament+1) * (st.Weapons.F() / 2)
}
func (st ShipType) ShieldsBlockMass() float64 {
return st.Shields.F()
}
func (st ShipType) CargoBlockMass() float64 {
return st.Cargo.F()
}
func (st ShipType) EmptyMass() float64 {
shipMass := st.DriveBlockMass() + st.ShieldsBlockMass() + st.CargoBlockMass() + st.WeaponsBlockMass()
return shipMass
}
+40
View File
@@ -0,0 +1,40 @@
package game_test
import (
"testing"
"github.com/iliadenisov/galaxy/server/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestEmptyMass(t *testing.T) {
Freighter := game.ShipType{
Name: "Freighter",
Drive: 8,
Armament: 0,
Weapons: 0,
Shields: 2,
Cargo: 10,
}
assert.Equal(t, 20., Freighter.EmptyMass())
Gunship := game.ShipType{
Name: "Gunship",
Drive: 4,
Armament: 2,
Weapons: 2,
Shields: 4,
Cargo: 0,
}
assert.Equal(t, 11., Gunship.EmptyMass())
Cruiser := game.ShipType{
Name: "Cruiser",
Drive: 15,
Armament: 1,
Weapons: 15,
Shields: 15,
Cargo: 0,
}
assert.Equal(t, 45., Cruiser.EmptyMass())
}
+16
View File
@@ -0,0 +1,16 @@
package game
import "github.com/google/uuid"
type State struct {
ID uuid.UUID
Turn uint
Stage uint
Players []PlayerState
}
type PlayerState struct {
ID uuid.UUID
Name string
Extinct bool
}
+220
View File
@@ -0,0 +1,220 @@
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) WriteSafe(path string, v encoding.BinaryMarshaler) error {
if v == nil {
return errors.New("cant't marshal from nil object")
}
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) Write(path string, v encoding.BinaryMarshaler) error {
if f.lock == nil {
return errors.New("lock must be acquired before write")
}
return f.WriteSafe(path, v)
}
func (f *fs) Read(path string, v encoding.BinaryUnmarshaler) error {
if f.lock == nil {
return errors.New("lock must be acquired before read")
}
return f.readUnsafe(path, v)
}
func (f *fs) ReadSafe(path string, v encoding.BinaryUnmarshaler) error {
if f.lock != nil {
timeout := time.NewTimer(time.Millisecond * 100)
checker := time.NewTicker(time.Millisecond)
out:
for {
select {
case <-checker.C:
if f.lock == nil {
checker.Stop()
timeout.Stop()
break out
}
case <-timeout.C:
checker.Stop()
return errors.New("timeout waiting for lock release")
}
}
}
return f.readUnsafe(path, v)
}
// readUnsafe reads the file contents without locking mechanism in mind.
// Using readUnsafe directly may cause errors if file being written at the moment.
func (f *fs) readUnsafe(file string, v encoding.BinaryUnmarshaler) error {
if v == nil {
return errors.New("can't unmarshal to a nil object")
}
targetFilePath := filepath.Join(f.root, file)
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)
}
+178
View File
@@ -0,0 +1,178 @@
package fs
import (
"os"
"path/filepath"
"testing"
"galaxy/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)
}
+22
View File
@@ -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
}
+37
View File
@@ -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
}
+79
View File
@@ -0,0 +1,79 @@
package fs
import (
"os"
"path/filepath"
"testing"
"galaxy/util"
"github.com/google/uuid"
"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()
}
+340
View File
@@ -0,0 +1,340 @@
package repo
/*
/state.json
/0001/state.json
/0001/meta.json
/0000/order/{UUID}.json
/0001/bombing.json
/0001/battle/{UUID}.json
/0001/report/{UUID}.json
*/
import (
"encoding/json"
"errors"
"fmt"
"galaxy/model/order"
"galaxy/model/report"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
const (
statePath = "state.json"
metaPath = "meta.json"
)
type storedOrder struct {
Commands []json.RawMessage `json:"cmd"`
}
func (o storedOrder) MarshalBinary() (data []byte, err error) {
return json.Marshal(&o)
}
func (o *storedOrder) UnmarshalBinary(data []byte) error {
return json.Unmarshal(data, o)
}
func (r *repo) SaveNewTurn(t uint, g *game.Game) error {
return saveNewTurn(r.s, t, g)
}
func saveNewTurn(s Storage, t uint, g *game.Game) error {
path := fmt.Sprintf("%s/state.json", TurnDir(t))
exist, err := s.Exists(path)
if err != nil {
return NewStorageError(err)
}
if exist {
return NewStateError(fmt.Sprintf("turn %d already saved at %s", t, path))
}
if err := s.Write(path, g); err != nil {
return NewStorageError(err)
}
return saveLastState(s, g)
}
func (r *repo) SaveLastState(g *game.Game) error {
return saveLastState(r.s, g)
}
func saveLastState(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, true)
}
func (r *repo) LoadStateSafe() (*game.Game, error) {
return loadState(r.s, false)
}
func loadState(s Storage, locked bool) (*game.Game, error) {
var result *game.Game = new(game.Game)
path := statePath
exist, err := s.Exists(path)
if err != nil {
return nil, NewStorageError(err)
}
if !exist {
return nil, NewGameNotInitializedError()
}
if locked {
if err := s.Read(path, result); err != nil {
return nil, NewStorageError(err)
}
} else {
if err := s.ReadSafe(path, result); err != nil {
return nil, NewStorageError(err)
}
}
return result, nil
}
func loadMeta(s Storage) (*game.GameMeta, error) {
var result *game.GameMeta = new(game.GameMeta)
path := metaPath
exist, err := s.Exists(path)
if err != nil {
return nil, NewStorageError(err)
}
if !exist {
return result, nil
}
if err := s.ReadSafe(path, result); err != nil {
return nil, NewStorageError(err)
}
return result, nil
}
func saveMeta(s Storage, t uint, gm *game.GameMeta) error {
// save turn's meta
path := fmt.Sprintf("%s/%s", TurnDir(t), metaPath)
if err := s.Write(path, gm); err != nil {
return NewStorageError(err)
}
// also save as latest meta
path = metaPath
if err := s.Write(path, gm); err != nil {
return NewStorageError(err)
}
return nil
}
func (r *repo) SaveBattle(t uint, b *report.BattleReport, m *game.BattleMeta) error {
meta, err := loadMeta(r.s)
if err != nil {
return err
}
err = saveBattle(r.s, t, b)
if err != nil {
return err
}
meta.Battles = append(meta.Battles, *m)
return saveMeta(r.s, t, meta)
}
func saveBattle(s Storage, t uint, b *report.BattleReport) error {
path := fmt.Sprintf("%s/battle/%s.json", TurnDir(t), b.ID.String())
exist, err := s.Exists(path)
if err != nil {
return NewStorageError(err)
}
if exist {
return NewStateError(fmt.Sprintf("battle %v for turn %d already has been saved", b.ID, t))
}
if err := s.Write(path, b); err != nil {
return NewStorageError(err)
}
return nil
}
func (r *repo) SaveBombings(t uint, b []*game.Bombing) error {
meta, err := loadMeta(r.s)
if err != nil {
return err
}
for i := range b {
meta.Bombings = append(meta.Bombings, *b[i])
}
return saveMeta(r.s, t, meta)
}
func (r *repo) SaveReport(t uint, rep *report.Report) error {
return saveReport(r.s, t, rep)
}
func saveReport(s Storage, t uint, v *report.Report) error {
path := ReportDir(t, v.RaceID)
if err := s.Write(path, v); err != nil {
return NewStorageError(err)
}
return nil
}
func (r *repo) LoadReport(t uint, id uuid.UUID) (*report.Report, error) {
return loadReport(r.s, t, id)
}
func loadReport(s Storage, t uint, id uuid.UUID) (*report.Report, error) {
path := ReportDir(t, id)
result := new(report.Report)
exist, err := s.Exists(path)
if err != nil {
return nil, NewStorageError(err)
}
if !exist {
return nil, NewReportNotFoundError()
}
if err := s.ReadSafe(path, result); err != nil {
return nil, NewStorageError(err)
}
return result, nil
}
func (r *repo) SaveOrder(t uint, id uuid.UUID, o *order.Order) error {
return saveOrder(r.s, t, id, o)
}
func saveOrder(s Storage, t uint, id uuid.UUID, o *order.Order) error {
path := OrderDir(t, id)
if err := s.WriteSafe(path, o); err != nil {
return NewStorageError(err)
}
return nil
}
func (r *repo) LoadOrder(t uint, id uuid.UUID) (*order.Order, bool, error) {
return loadOrder(r.s, t, id)
}
func loadOrder(s Storage, t uint, id uuid.UUID) (*order.Order, bool, error) {
path := OrderDir(t, id)
exist, err := s.Exists(path)
if err != nil {
return nil, false, NewStorageError(err)
}
if !exist {
return nil, false, nil
}
cmd := new(storedOrder)
if err := s.ReadSafe(path, cmd); err != nil {
return nil, false, NewStorageError(err)
}
result := &order.Order{Commands: make([]order.DecodableCommand, len(cmd.Commands))}
if len(cmd.Commands) == 0 {
return nil, false, errors.New("no commands were stored")
}
for i := range cmd.Commands {
command, err := ParseOrder(cmd.Commands[i], nil)
if err != nil {
return nil, false, err
}
result.Commands[i] = command
}
return result, true, nil
}
// Helper funcs
func OrderDir(t uint, id uuid.UUID) string {
return fmt.Sprintf("%s/order/%s.json", TurnDir(t), id.String())
}
func ReportDir(t uint, id uuid.UUID) string {
return fmt.Sprintf("%s/report/%s.json", TurnDir(t), id.String())
}
func TurnDir(t uint) string {
return fmt.Sprintf("%04d", t)
}
func ParseOrder(c json.RawMessage, validator func(order.DecodableCommand) error) (order.DecodableCommand, error) {
meta := new(order.CommandMeta)
if err := json.Unmarshal(c, meta); err != nil {
return nil, err
}
switch t := meta.CmdType; t {
case order.CommandTypeRaceQuit:
return decodeCommand(c, new(order.CommandRaceQuit), validator)
case order.CommandTypeRaceVote:
return decodeCommand(c, new(order.CommandRaceVote), validator)
case order.CommandTypeRaceRelation:
return decodeCommand(c, new(order.CommandRaceRelation), validator)
case order.CommandTypeShipClassCreate:
return decodeCommand(c, new(order.CommandShipClassCreate), validator)
case order.CommandTypeShipClassMerge:
return decodeCommand(c, new(order.CommandShipClassMerge), validator)
case order.CommandTypeShipClassRemove:
return decodeCommand(c, new(order.CommandShipClassRemove), validator)
case order.CommandTypeShipGroupBreak:
return decodeCommand(c, new(order.CommandShipGroupBreak), validator)
case order.CommandTypeShipGroupLoad:
return decodeCommand(c, new(order.CommandShipGroupLoad), validator)
case order.CommandTypeShipGroupUnload:
return decodeCommand(c, new(order.CommandShipGroupUnload), validator)
case order.CommandTypeShipGroupSend:
return decodeCommand(c, new(order.CommandShipGroupSend), validator)
case order.CommandTypeShipGroupUpgrade:
return decodeCommand(c, new(order.CommandShipGroupUpgrade), validator)
case order.CommandTypeShipGroupMerge:
return decodeCommand(c, new(order.CommandShipGroupMerge), validator)
case order.CommandTypeShipGroupDismantle:
return decodeCommand(c, new(order.CommandShipGroupDismantle), validator)
case order.CommandTypeShipGroupTransfer:
return decodeCommand(c, new(order.CommandShipGroupTransfer), validator)
case order.CommandTypeShipGroupJoinFleet:
return decodeCommand(c, new(order.CommandShipGroupJoinFleet), validator)
case order.CommandTypeFleetMerge:
return decodeCommand(c, new(order.CommandFleetMerge), validator)
case order.CommandTypeFleetSend:
return decodeCommand(c, new(order.CommandFleetSend), validator)
case order.CommandTypeScienceCreate:
return decodeCommand(c, new(order.CommandScienceCreate), validator)
case order.CommandTypeScienceRemove:
return decodeCommand(c, new(order.CommandScienceRemove), validator)
case order.CommandTypePlanetRename:
return decodeCommand(c, new(order.CommandPlanetRename), validator)
case order.CommandTypePlanetProduce:
return decodeCommand(c, new(order.CommandPlanetProduce), validator)
case order.CommandTypePlanetRouteSet:
return decodeCommand(c, new(order.CommandPlanetRouteSet), validator)
case order.CommandTypePlanetRouteRemove:
return decodeCommand(c, new(order.CommandPlanetRouteRemove), validator)
default:
return nil, fmt.Errorf("unknown comman type: %s", t)
}
}
func decodeCommand(m json.RawMessage, c order.DecodableCommand, validator func(order.DecodableCommand) error) (order.DecodableCommand, error) {
v, err := unmarshallCommand(m, c)
if err != nil {
return nil, err
}
if validator != nil {
err = validator(v)
}
if err != nil {
return nil, err
}
return v, nil
}
func unmarshallCommand[T order.DecodableCommand](c json.RawMessage, v T) (T, error) {
if err := json.Unmarshal(c, v); err != nil {
return v, err
}
return v, nil
}
+90
View File
@@ -0,0 +1,90 @@
package repo
import (
"encoding"
"errors"
e "galaxy/error"
"github.com/iliadenisov/galaxy/server/internal/repo/fs"
)
func NewStorageError(err error) error {
return e.NewRepoError(err)
}
func NewGameNotInitializedError() error {
return e.NewGameNotInitializedError()
}
func NewReportNotFoundError() error {
return e.NewReportNotFoundError()
}
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
WriteSafe(string, encoding.BinaryMarshaler) error
Read(string, encoding.BinaryUnmarshaler) error
ReadSafe(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
}
+15
View File
@@ -0,0 +1,15 @@
package repo
import (
"galaxy/model/order"
"github.com/google/uuid"
)
func LoadOrder_T(s Storage, t uint, id uuid.UUID) (*order.Order, bool, error) {
return loadOrder(s, t, id)
}
func SaveOrder_T(s Storage, t uint, id uuid.UUID, o *order.Order) error {
return saveOrder(s, t, id, o)
}
+120
View File
@@ -0,0 +1,120 @@
package repo_test
import (
"path/filepath"
"testing"
"galaxy/model/order"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/repo"
"github.com/iliadenisov/galaxy/server/internal/repo/fs"
"github.com/stretchr/testify/assert"
)
func TestSaveOrder(t *testing.T) {
root := t.ArtifactDir()
s, err := fs.NewFileStorage(root)
assert.NoError(t, err)
id := uuid.New()
o := &order.Order{
Commands: []order.DecodableCommand{
&order.CommandRaceVote{
CommandMeta: order.CommandMeta{
CmdType: order.CommandTypeRaceVote,
CmdID: uuid.New().String(),
},
Acceptor: "Race_acc",
},
&order.CommandShipClassCreate{
CommandMeta: order.CommandMeta{
CmdType: order.CommandTypeShipClassCreate,
CmdID: uuid.New().String(),
},
Name: "Fighter",
Drive: 20.5,
Armament: 5,
Weapons: 20,
Shields: 15.5,
Cargo: 0,
},
&order.CommandShipGroupMerge{
CommandMeta: order.CommandMeta{
CmdType: order.CommandTypeShipGroupMerge,
CmdID: uuid.New().String(),
},
},
&order.CommandShipClassCreate{
CommandMeta: order.CommandMeta{
CmdType: order.CommandTypeShipClassCreate,
CmdID: uuid.New().String(),
},
Name: "Freighter",
Drive: 30.33,
Armament: 1,
Weapons: 1,
Shields: 10.1,
Cargo: 0,
},
&order.CommandRaceQuit{
CommandMeta: order.CommandMeta{
CmdType: order.CommandTypeRaceQuit,
CmdID: uuid.New().String(),
},
},
},
}
var turn uint = 2
for i := range o.Commands {
if v, ok := order.AsCommand[*order.CommandRaceVote](o.Commands[i]); ok {
m := &v.CommandMeta
m.Result(0)
} else if v, ok := order.AsCommand[*order.CommandRaceQuit](o.Commands[i]); ok {
v.Result(10)
} else if v, ok := order.AsCommand[*order.CommandShipClassCreate](o.Commands[i]); ok {
m := &v.CommandMeta
m.Result(33)
} else if v, ok := order.AsCommand[*order.CommandShipGroupMerge](o.Commands[i]); ok {
v.Result(0)
}
}
assert.NoError(t, repo.SaveOrder_T(s, turn, id, o))
assert.FileExists(t, filepath.Join(root, repo.OrderDir(turn, id)))
LoadOrderTest(t, s, root, turn, id, o)
}
func LoadOrderTest(t *testing.T, s repo.Storage, root string, turn uint, id uuid.UUID, expected *order.Order) {
o, ok, err := repo.LoadOrder_T(s, turn, id)
assert.NoError(t, err)
assert.True(t, ok)
assert.Len(t, o.Commands, 5)
assert.ElementsMatch(t, expected.Commands, o.Commands)
CommandResultTest(t, o)
}
func CommandResultTest(t *testing.T, o *order.Order) {
assert.NotEmpty(t, o.Commands)
for i := range o.Commands {
if v, ok := order.AsCommand[*order.CommandRaceVote](o.Commands[i]); ok {
assert.NotNil(t, v.CmdApplied)
assert.True(t, *v.CmdApplied)
assert.Equal(t, 0, *v.CmdErrCode)
} else if v, ok := order.AsCommand[*order.CommandRaceQuit](o.Commands[i]); ok {
assert.NotNil(t, v.CmdApplied)
assert.False(t, *v.CmdApplied)
assert.Equal(t, 10, *v.CmdErrCode)
} else if v, ok := order.AsCommand[*order.CommandShipClassCreate](o.Commands[i]); ok {
assert.NotNil(t, v.CmdApplied)
assert.False(t, *v.CmdApplied)
assert.Equal(t, 33, *v.CmdErrCode)
} else if v, ok := order.AsCommand[*order.CommandShipGroupMerge](o.Commands[i]); ok {
assert.NotNil(t, v.CmdApplied)
assert.True(t, *v.CmdApplied)
assert.Equal(t, 0, *v.CmdErrCode)
}
}
}
+942
View File
@@ -0,0 +1,942 @@
package router_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"galaxy/model/order"
"galaxy/model/rest"
"github.com/stretchr/testify/assert"
)
func TestCommandRaceQuit(t *testing.T) {
r := setupRouter()
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandRaceQuit{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeRaceQuit},
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, commandNoErrorsStatus, w.Code, w.Body)
// error: actor not set
payload.Actor = ""
w = httptest.NewRecorder()
req, _ = http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
payload.Actor = " "
w = httptest.NewRecorder()
req, _ = http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
// unrecognized command type
payload.Commands = []json.RawMessage{
encodeCommand(&order.CommandRaceQuit{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandType("-unknown-")},
}),
}
w = httptest.NewRecorder()
req, _ = http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
// error: no commands
payload = &rest.Command{
Actor: commandDefaultActor,
}
w = httptest.NewRecorder()
req, _ = http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
}
func TestCommandRaceVote(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
acceptor string
}{
{commandNoErrorsStatus, "Valid request", "AnotherRace"},
{http.StatusBadRequest, "Empty acceptor", ""},
{http.StatusBadRequest, "Blank acceptor", " "},
{http.StatusBadRequest, "Invalid acceptor", "Race_👽"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandRaceVote{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeRaceVote},
Acceptor: tc.acceptor,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandRaceRelation(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
relation string
acceptor string
}{
{commandNoErrorsStatus, "Valid request 1", "WAR", "Opponent"},
{commandNoErrorsStatus, "Valid request 2", "PEACE", "Opponent"},
{http.StatusBadRequest, "Empty relation", "", "Opponent"},
{http.StatusBadRequest, "Blank relation", " ", "Opponent"},
{http.StatusBadRequest, "Invalid relation", "Woina", "Opponent"},
{http.StatusBadRequest, "Empty acceptor", "WAR", ""},
{http.StatusBadRequest, "Blank acceptor", "WAR", " "},
{http.StatusBadRequest, "Invalid acceptor", "PEACE", "Race_👽"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandRaceRelation{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeRaceRelation},
Acceptor: tc.acceptor,
Relation: tc.relation,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandShipClassCreate(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
D float64
A int
W, S, C float64
name string
expectStatus int
description string
}{
{1, 0, 0, 0, 0, "Drone", commandNoErrorsStatus, "Simple Drone"},
{1, 1, 1, 0, 0, "Drone", commandNoErrorsStatus, "Armed Drone"},
{1, 0, 0, 1, 0, "Drone", commandNoErrorsStatus, "Shielded Drone"},
{1, 0, 0, 0, 1, "Drone", commandNoErrorsStatus, "Carrying Drone"},
{1, 0, 0, 0, 0, "", http.StatusBadRequest, "Empty name"},
{1, 0, 0, 0, 0, " ", http.StatusBadRequest, "Blank name"},
{1, 0, 0, 0, 0, "Drone🚀", http.StatusBadRequest, "Invalid name"},
{-0.5, 0, 0, 0, 0, "Drone", http.StatusBadRequest, "Drive less than 0"},
{0.9, 0, 0, 0, 0, "Drone", http.StatusBadRequest, "Drive less than 1"},
{1, 1, 0, 0, 0, "Drone", http.StatusBadRequest, "Ammo without Weapons"},
{1, 0, 1, 0, 0, "Drone", http.StatusBadRequest, "Weapons without Ammo"},
{1, -1, 1, 0, 0, "Drone", http.StatusBadRequest, "Ammo less than 0"},
{1, 1, 0.9, 0, 0, "Drone", http.StatusBadRequest, "Weapons less than 1"},
{1, 1, -0.5, 0, 0, "Drone", http.StatusBadRequest, "Weapons less than 0"},
{1, 0, 0, -0.5, 0, "Drone", http.StatusBadRequest, "Shields less than 0"},
{1, 0, 0, 0.9, 0, "Drone", http.StatusBadRequest, "Shields less than 1"},
{1, 0, 0, 0, -0.5, "Drone", http.StatusBadRequest, "Cargo less than 0"},
{1, 0, 0, 0, 0.9, "Drone", http.StatusBadRequest, "Cargo less than 1"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipClassCreate{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipClassCreate},
Name: tc.name,
Drive: tc.D,
Armament: tc.A,
Weapons: tc.W,
Shields: tc.S,
Cargo: tc.C,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandShipClassMerge(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
name string
target string
}{
{commandNoErrorsStatus, "Valid request", "Drone", "Spy"},
{http.StatusBadRequest, "Empty name", "", "Spy"},
{http.StatusBadRequest, "Blank name", " ", "Spy"},
{http.StatusBadRequest, "Invalid name", "Drone🚀", "Spy"},
{http.StatusBadRequest, "Empty name", "Drone", " "},
{http.StatusBadRequest, "Blank name", "Drone", " "},
{http.StatusBadRequest, "Invalid name", "Drone", "Spy🚀"},
{http.StatusBadRequest, "Equal names", "Drone", "Drone"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipClassMerge{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipClassMerge},
Name: tc.name,
Target: tc.target,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandShipClassRemove(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
name string
}{
{commandNoErrorsStatus, "Valid request", "Drone"},
{http.StatusBadRequest, "Empty name", ""},
{http.StatusBadRequest, "Blank name", " "},
{http.StatusBadRequest, "Invalid name", "Drone🚀"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipClassRemove{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipClassRemove},
Name: tc.name,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandShipGroupBreak(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
id string
newId string
quantity int
}{
{commandNoErrorsStatus, "Valid request #1", validId1, validId2, 1},
{commandNoErrorsStatus, "Valid request #2", validId1, validId2, 0},
{http.StatusBadRequest, "Negative quantity", validId1, validId2, -1},
{http.StatusBadRequest, "Empty id", "", validId2, 1},
{http.StatusBadRequest, "Invalid id", invalidId, validId2, 1},
{http.StatusBadRequest, "Empty newId", validId1, "", 1},
{http.StatusBadRequest, "Invalid newId", validId1, invalidId, 1},
{http.StatusBadRequest, "Equal id and newId", validId1, validId1, 1},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupBreak{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupBreak},
ID: tc.id,
NewID: tc.newId,
Quantity: tc.quantity,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandShipGroupLoad(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
id string
cargo string
quantity float64
}{
{commandNoErrorsStatus, "Valid request #1", validId1, "COL", 0},
{commandNoErrorsStatus, "Valid request #2", validId1, "MAT", 1},
{commandNoErrorsStatus, "Valid request #2", validId1, "CAP", 2},
{http.StatusBadRequest, "Invalid quantity", validId1, "COL", -0.5},
{http.StatusBadRequest, "Empty cargo", validId1, "", 1},
{http.StatusBadRequest, "Invalid cargo", validId1, "IND", 1},
{http.StatusBadRequest, "Empty id", "", "COL", 1},
{http.StatusBadRequest, "Invalid id", invalidId, "COL", 1},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupLoad{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupLoad},
ID: tc.id,
Cargo: tc.cargo,
Quantity: tc.quantity,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandShipGroupUnload(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
id string
quantity float64
}{
{commandNoErrorsStatus, "Valid request #1", validId1, 0},
{commandNoErrorsStatus, "Valid request #2", validId1, 1},
{http.StatusBadRequest, "Invalid quantity", validId1, -0.5},
{http.StatusBadRequest, "Empty id", "", 1},
{http.StatusBadRequest, "Invalid id", invalidId, 1},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupUnload{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupUnload},
ID: tc.id,
Quantity: tc.quantity,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandShipGroupSend(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
id string
destination int
}{
{commandNoErrorsStatus, "Valid request #1", validId1, 0},
{commandNoErrorsStatus, "Valid request #1", validId1, 1},
{http.StatusBadRequest, "Invalid destination", validId1, -1},
{http.StatusBadRequest, "Empty id", "", 1},
{http.StatusBadRequest, "Invalid id", invalidId, 1},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupSend{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupSend},
ID: tc.id,
Destination: tc.destination,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandShipGroupUpgrade(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
id string
tech string
level float64
}{
{commandNoErrorsStatus, "Valid request #1", validId1, "ALL", 0},
{commandNoErrorsStatus, "Valid request #1", validId1, "DRIVE", 1.1},
{commandNoErrorsStatus, "Valid request #1", validId1, "WEAPONS", 2.1},
{commandNoErrorsStatus, "Valid request #1", validId1, "SHIELDS", 3.1},
{commandNoErrorsStatus, "Valid request #1", validId1, "CARGO", 4.1},
{http.StatusBadRequest, "Negative level", validId1, "DRIVE", -0.5},
{http.StatusBadRequest, "Invalid level 0.5", validId1, "DRIVE", 0.5},
{http.StatusBadRequest, "Invalid level 1.0", validId1, "DRIVE", 1.0},
{http.StatusBadRequest, "Empty id", "", "ALL", 0},
{http.StatusBadRequest, "Invalid id", invalidId, "ALL", 0},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupUpgrade{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupUpgrade},
ID: tc.id,
Tech: tc.tech,
Level: tc.level,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandShipGroupMerge(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
}{
{commandNoErrorsStatus, "Valid request"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupMerge{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupMerge},
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandShipGroupDismantle(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
id string
}{
{commandNoErrorsStatus, "Valid request", validId1},
{http.StatusBadRequest, "Empty id", ""},
{http.StatusBadRequest, "Invalid id", invalidId},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupDismantle{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupDismantle},
ID: tc.id,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandShipGroupTransfer(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
id string
acceptor string
}{
{commandNoErrorsStatus, "Valid request", validId1, "AnotherRace"},
{http.StatusBadRequest, "Blank id", "", "AnotherRace"},
{http.StatusBadRequest, "Invalid id", invalidId, "AnotherRace"},
{http.StatusBadRequest, "Empty acceptor", validId1, ""},
{http.StatusBadRequest, "Blank acceptor", validId1, " "},
{http.StatusBadRequest, "Invalid acceptor", validId1, "Race_👽"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupTransfer{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupTransfer},
ID: tc.id,
Acceptor: tc.acceptor,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandShipGroupJoinFleet(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
id string
name string
}{
{commandNoErrorsStatus, "Valid request", validId1, "AnotherRace"},
{http.StatusBadRequest, "Blank id", "", "AnotherRace"},
{http.StatusBadRequest, "Invalid id", invalidId, "AnotherRace"},
{http.StatusBadRequest, "Empty name", validId1, ""},
{http.StatusBadRequest, "Blank name", validId1, " "},
{http.StatusBadRequest, "Invalid name", validId1, "Fleet_🚢"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupJoinFleet{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupJoinFleet},
ID: tc.id,
Name: tc.name,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandFleetMerge(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
name string
target string
}{
{commandNoErrorsStatus, "Valid request", "Fleet", "Bomber"},
{http.StatusBadRequest, "Empty name", "", "Bomber"},
{http.StatusBadRequest, "Invalid name", "Fleet_🚢", "Bomber"},
{http.StatusBadRequest, "Empty target", "Fleet", ""},
{http.StatusBadRequest, "Invalid target", "Fleet", "Bomber_🚢"},
{http.StatusBadRequest, "Equal name and target", "Fleet", "Fleet"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandFleetMerge{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeFleetMerge},
Name: tc.name,
Target: tc.target,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandFleetSend(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
name string
destination int
}{
{commandNoErrorsStatus, "Valid request #1", "Fleet", 0},
{commandNoErrorsStatus, "Valid request #2", "Fleet", 1},
{http.StatusBadRequest, "Invalid destination", "Fleet", -1},
{http.StatusBadRequest, "Empty name", "", 1},
{http.StatusBadRequest, "Invalid name", "Fleet_🚢", 1},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandFleetSend{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeFleetSend},
Name: tc.name,
Destination: tc.destination,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandScienceCreate(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
D, W, S, C float64
name string
}{
{commandNoErrorsStatus, "Valid request", 0.25, 0.25, 0.25, 0.25, "Science"},
{http.StatusBadRequest, "Empty name", 0.25, 0.25, 0.25, 0.25, ""},
{http.StatusBadRequest, "Invalid name", 0.25, 0.25, 0.25, 0.25, "Science🧪"},
{http.StatusBadRequest, "Negative drive", -.5, 0.25, 0.25, 0.25, "Science"},
{http.StatusBadRequest, "Negative weapons", 0.25, -.5, 0.25, 0.25, "Science"},
{http.StatusBadRequest, "Negative shields", 0.25, 0.25, -.5, 0.25, "Science"},
{http.StatusBadRequest, "Negative cargo", 0.25, 0.25, 0.25, -.5, "Science"},
{http.StatusBadRequest, "Too big drive", 1.1, 0.25, 0.25, 0.25, "Science"},
{http.StatusBadRequest, "Too big weapons", 0.25, 1.05, 0.25, 0.25, "Science"},
{http.StatusBadRequest, "Too big shields", 0.25, 0.25, 1.5, 0.25, "Science"},
{http.StatusBadRequest, "Too big cargo", 0.25, 0.25, 0.25, 1.01, "Science"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandScienceCreate{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeScienceCreate},
Name: tc.name,
Drive: tc.D,
Weapons: tc.W,
Shields: tc.S,
Cargo: tc.C,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandScienceRemove(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
name string
}{
{commandNoErrorsStatus, "Valid request", "Drone"},
{http.StatusBadRequest, "Empty name", ""},
{http.StatusBadRequest, "Blank name", " "},
{http.StatusBadRequest, "Invalid name", "Science🧪"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandScienceRemove{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeScienceRemove},
Name: tc.name,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandPlanetRename(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
number int
name string
}{
{commandNoErrorsStatus, "Valid request #1", 0, "HW"},
{commandNoErrorsStatus, "Valid request #2", 1, "HW"},
{http.StatusBadRequest, "Invalid number", -1, "HW"},
{http.StatusBadRequest, "Empty name", 1, ""},
{http.StatusBadRequest, "Blank name", 1, " "},
{http.StatusBadRequest, "Invalid name", 1, "Planet🪐"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandPlanetRename{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypePlanetRename},
Number: tc.number,
Name: tc.name,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandPlanetProduce(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
number int
production, subject string
}{
{commandNoErrorsStatus, "Valid request MAT", 0, "MAT", ""},
{commandNoErrorsStatus, "Valid request CAP", 1, "CAP", ""},
{commandNoErrorsStatus, "Valid request DRIVE", 2, "DRIVE", ""},
{commandNoErrorsStatus, "Valid request WEAPONS", 3, "WEAPONS", ""},
{commandNoErrorsStatus, "Valid request SHIELDS", 4, "SHIELDS", ""},
{commandNoErrorsStatus, "Valid request CARGO", 5, "CARGO", ""},
{commandNoErrorsStatus, "Valid request SCIENCE", 6, "SCIENCE", "Science"},
{commandNoErrorsStatus, "Valid request SHIP", 7, "SHIP", "Ship"},
{http.StatusBadRequest, "Empty production", 0, "", ""},
{http.StatusBadRequest, "Invalid production", 0, "IND", ""},
{http.StatusBadRequest, "Invalid planet", -1, "DRIVE", ""},
{http.StatusBadRequest, "Empty science subject", 6, "SCIENCE", ""},
{http.StatusBadRequest, "Invalid science subject", 6, "SCIENCE", "Science🧪"},
{http.StatusBadRequest, "Empty ship subject", 6, "SHIP", ""},
{http.StatusBadRequest, "Invalid ship subject", 6, "SHIP", "Ship🚀"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandPlanetProduce{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypePlanetProduce},
Number: tc.number,
Production: tc.production,
Subject: tc.subject,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandPlanetRouteSet(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
origin, destination int
loadType string
}{
{commandNoErrorsStatus, "Valid request MAT", 1, 0, "MAT"},
{commandNoErrorsStatus, "Valid request CAP", 0, 1, "CAP"},
{commandNoErrorsStatus, "Valid request COL", 1, 2, "COL"},
{commandNoErrorsStatus, "Valid request EMP", 3, 0, "EMP"},
{http.StatusBadRequest, "Empty loadType", 0, 1, ""},
{http.StatusBadRequest, "Invalid loadType", 0, 1, "IND"},
{http.StatusBadRequest, "Invalid origin", -1, 1, "MAT"},
{http.StatusBadRequest, "Invalid destination", 1, -1, "MAT"},
{http.StatusBadRequest, "Origin equals destination", 1, 1, "COL"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandPlanetRouteSet{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypePlanetRouteSet},
Origin: tc.origin,
Destination: tc.destination,
LoadType: tc.loadType,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestCommandPlanetRouteRemove(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
origin int
loadType string
}{
{commandNoErrorsStatus, "Valid request MAT", 0, "MAT"},
{commandNoErrorsStatus, "Valid request CAP", 1, "CAP"},
{commandNoErrorsStatus, "Valid request COL", 2, "COL"},
{commandNoErrorsStatus, "Valid request EMP", 0, "EMP"},
{http.StatusBadRequest, "Empty loadType", 1, ""},
{http.StatusBadRequest, "Invalid loadType", 1, "IND"},
{http.StatusBadRequest, "Invalid origin", -1, "MAT"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandPlanetRouteRemove{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypePlanetRouteRemove},
Origin: tc.origin,
LoadType: tc.loadType,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestMultipleCommands(t *testing.T) {
e := newExecutor()
r := setupRouterExecutor(e)
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandRaceRelation{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeRaceRelation},
Acceptor: "Opponent",
Relation: "PEACE",
}),
encodeCommand(&order.CommandRaceVote{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeRaceVote},
Acceptor: "Opponent",
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiCommandPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, commandNoErrorsStatus, w.Code, w.Body)
assert.Equal(t, 2, e.(*dummyExecutor).CommandsExecuted)
}
+342
View File
@@ -0,0 +1,342 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/controller"
"galaxy/model/order"
"galaxy/model/rest"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
)
func CommandHandler(c *gin.Context, executor CommandExecutor) {
var cmd rest.Command
if errorResponse(c, c.ShouldBindJSON(&cmd)) {
return
}
commands := make([]Command, len(cmd.Commands))
for i := range cmd.Commands {
command, err := parseCommand(cmd.Actor, cmd.Commands[i])
if errorResponse(c, err) {
return
}
commands[i] = command
}
if len(commands) == 0 {
errorResponse(c, errors.New("no commands given"))
return
}
if errorResponse(c, executor.Execute(commands...)) {
return
}
c.Status(http.StatusNoContent)
}
func parseCommand(actor string, c json.RawMessage) (Command, error) {
meta := new(order.CommandMeta)
if err := json.Unmarshal(c, meta); err != nil {
return nil, err
}
switch t := meta.CmdType; t {
case order.CommandTypeRaceQuit:
return commandRaceQuit(actor)
case order.CommandTypeRaceVote:
return commandRaceVote(actor, c)
case order.CommandTypeRaceRelation:
return commandRaceRelation(actor, c)
case order.CommandTypeShipClassCreate:
return commandShipClassCreate(actor, c)
case order.CommandTypeShipClassMerge:
return commandShipClassMerge(actor, c)
case order.CommandTypeShipClassRemove:
return commandShipClassRemove(actor, c)
case order.CommandTypeShipGroupBreak:
return commandShipGroupBreak(actor, c)
case order.CommandTypeShipGroupLoad:
return commandShipGroupLoad(actor, c)
case order.CommandTypeShipGroupUnload:
return commandShipGroupUnload(actor, c)
case order.CommandTypeShipGroupSend:
return commandShipGroupSend(actor, c)
case order.CommandTypeShipGroupUpgrade:
return commandShipGroupUpgrade(actor, c)
case order.CommandTypeShipGroupMerge:
return commandShipGroupMerge(actor, c)
case order.CommandTypeShipGroupDismantle:
return commandShipGroupDismantle(actor, c)
case order.CommandTypeShipGroupTransfer:
return commandShipGroupTransfer(actor, c)
case order.CommandTypeShipGroupJoinFleet:
return commandShipGroupJoinFleet(actor, c)
case order.CommandTypeFleetMerge:
return commandFleetMerge(actor, c)
case order.CommandTypeFleetSend:
return commandFleetSend(actor, c)
case order.CommandTypeScienceCreate:
return commandScienceCreate(actor, c)
case order.CommandTypeScienceRemove:
return commandScienceRemove(actor, c)
case order.CommandTypePlanetRename:
return commandPlanetRename(actor, c)
case order.CommandTypePlanetProduce:
return commandPlanetProduce(actor, c)
case order.CommandTypePlanetRouteSet:
return commandPlanetRouteSet(actor, c)
case order.CommandTypePlanetRouteRemove:
return commandPlanetRouteRemove(actor, c)
default:
return nil, fmt.Errorf("unknown comman type: %s", t)
}
}
func commandRaceQuit(actor string) (Command, error) {
return func(c controller.Ctrl) error { return c.RaceQuit(actor) }, nil
}
func commandRaceVote(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandRaceVote)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.RaceVote(actor, v.Acceptor)
}, nil
}
}
func commandRaceRelation(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandRaceRelation)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.RaceRelation(actor, v.Acceptor, v.Relation)
}, nil
}
}
func commandShipClassCreate(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipClassCreate)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipClassCreate(actor, v.Name, v.Drive, int(v.Armament), v.Weapons, v.Shields, v.Cargo)
}, nil
}
}
func commandShipClassMerge(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipClassMerge)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipClassMerge(actor, v.Name, v.Target)
}, nil
}
}
func commandShipClassRemove(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipClassRemove)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipClassRemove(actor, v.Name)
}, nil
}
}
func commandShipGroupBreak(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipGroupBreak)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipGroupBreak(actor, uuid.MustParse(v.ID), uuid.MustParse(v.NewID), uint(v.Quantity))
}, nil
}
}
func commandShipGroupLoad(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipGroupLoad)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipGroupLoad(actor, uuid.MustParse(v.ID), v.Cargo, v.Quantity)
}, nil
}
}
func commandShipGroupUnload(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipGroupUnload)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipGroupUnload(actor, uuid.MustParse(v.ID), v.Quantity)
}, nil
}
}
func commandShipGroupSend(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipGroupSend)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipGroupSend(actor, uuid.MustParse(v.ID), uint(v.Destination))
}, nil
}
}
func commandShipGroupUpgrade(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipGroupUpgrade)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipGroupUpgrade(actor, uuid.MustParse(v.ID), v.Tech, v.Level)
}, nil
}
}
func commandShipGroupMerge(actor string, c json.RawMessage) (Command, error) {
return func(c controller.Ctrl) error {
return c.ShipGroupMerge(actor)
}, nil
}
func commandShipGroupDismantle(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipGroupDismantle)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipGroupDismantle(actor, uuid.MustParse(v.ID))
}, nil
}
}
func commandShipGroupTransfer(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipGroupTransfer)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipGroupTransfer(actor, v.Acceptor, uuid.MustParse(v.ID))
}, nil
}
}
func commandShipGroupJoinFleet(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandShipGroupJoinFleet)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ShipGroupJoinFleet(actor, v.Name, uuid.MustParse(v.ID))
}, nil
}
}
func commandFleetMerge(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandFleetMerge)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.FleetMerge(actor, v.Name, v.Target)
}, nil
}
}
func commandFleetSend(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandFleetSend)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.FleetSend(actor, v.Name, uint(v.Destination))
}, nil
}
}
func commandScienceCreate(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandScienceCreate)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ScienceCreate(actor, v.Name, v.Drive, v.Weapons, v.Shields, v.Cargo)
}, nil
}
}
func commandScienceRemove(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandScienceRemove)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.ScienceRemove(actor, v.Name)
}, nil
}
}
func commandPlanetRename(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandPlanetRename)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.PlanetRename(actor, v.Number, v.Name)
}, nil
}
}
func commandPlanetProduce(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandPlanetProduce)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.PlanetProduce(actor, v.Number, v.Production, v.Subject)
}, nil
}
}
func commandPlanetRouteSet(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandPlanetRouteSet)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.PlanetRouteSet(actor, v.LoadType, uint(v.Origin), uint(v.Destination))
}, nil
}
}
func commandPlanetRouteRemove(actor string, c json.RawMessage) (Command, error) {
if v, err := unmarshallCommand(c, new(order.CommandPlanetRouteRemove)); err != nil {
return nil, err
} else {
return func(c controller.Ctrl) error {
return c.PlanetRouteRemove(actor, v.LoadType, uint(v.Origin))
}, nil
}
}
// Helpers
func unmarshallCommand[T order.DecodableCommand](c json.RawMessage, v T) (T, error) {
if err := json.Unmarshal(c, v); err != nil {
return v, err
}
if err := validateCommand(v); err != nil {
return v, err
}
return v, nil
}
func validateCommand(v order.DecodableCommand) error {
if ve, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := ve.Struct(v); err != nil {
return err
}
}
return nil
}
+123
View File
@@ -0,0 +1,123 @@
package handler
import (
"errors"
"net/http"
"os"
"galaxy/model/order"
"galaxy/model/rest"
e "galaxy/error"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/iliadenisov/galaxy/server/internal/controller"
"github.com/iliadenisov/galaxy/server/internal/model/game"
)
type CommandExecutor interface {
GenerateGame([]string) (rest.StateResponse, error)
GenerateTurn() (rest.StateResponse, error)
GameState() (rest.StateResponse, error)
Execute(cmd ...Command) error
ValidateOrder(actor string, cmd ...order.DecodableCommand) error
}
type Command func(controller.Ctrl) error
type executor struct {
cfg controller.Configurer
}
func initConfig() controller.Configurer {
return func(p *controller.Param) {
p.StoragePath = os.Getenv("STORAGE_PATH")
}
}
func NewDefaultExecutor() CommandExecutor {
return NewDefaultConfigExecutor(initConfig())
}
func NewDefaultConfigExecutor(configurer controller.Configurer) CommandExecutor {
return &executor{cfg: configurer}
}
func (e *executor) Execute(cmd ...Command) error {
return controller.ExecuteCommand(e.cfg, func(c controller.Ctrl) error {
for i := range cmd {
if err := cmd[i](c); err != nil {
return err
}
}
return nil
})
}
func (e *executor) ValidateOrder(actor string, cmd ...order.DecodableCommand) error {
return controller.ValidateOrder(e.cfg, actor, cmd...)
}
func (e *executor) GenerateGame(races []string) (rest.StateResponse, error) {
s, err := controller.GenerateGame(e.cfg, races)
if err != nil {
return rest.StateResponse{}, err
}
return stateResponse(s), nil
}
func (e *executor) GenerateTurn() (rest.StateResponse, error) {
err := controller.GenerateTurn(e.cfg)
if err != nil {
return rest.StateResponse{}, err
}
return e.GameState()
}
func (e *executor) GameState() (rest.StateResponse, error) {
s, err := controller.GameState(e.cfg)
if err != nil {
return rest.StateResponse{}, err
}
return stateResponse(s), nil
}
func stateResponse(s game.State) rest.StateResponse {
result := &rest.StateResponse{
ID: s.ID,
Turn: s.Turn,
Stage: s.Stage,
Players: make([]rest.PlayerState, len(s.Players)),
}
for i := range s.Players {
result.Players[i].ID = s.Players[i].ID
result.Players[i].Name = s.Players[i].Name
result.Players[i].Extinct = s.Players[i].Extinct
}
return *result
}
func errorResponse(c *gin.Context, err error) bool {
if err == nil {
return false
}
if v, ok := err.(validator.ValidationErrors); ok {
c.JSON(http.StatusBadRequest, gin.H{"error": v.Error()})
return true
}
if ge, ok := errors.AsType[*e.GenericError](err); ok {
switch ge.Code {
case e.ErrGameNotInitialized:
c.Status(http.StatusNotImplemented)
default:
c.JSON(http.StatusInternalServerError, gin.H{"generic_error": ge.Error(), "code": ge.Code})
}
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return true
}
+28
View File
@@ -0,0 +1,28 @@
package handler
import (
"net/http"
"galaxy/model/rest"
"github.com/gin-gonic/gin"
)
func InitHandler(c *gin.Context, executor CommandExecutor) {
var init rest.Init
if errorResponse(c, c.ShouldBindJSON(&init)) {
return
}
races := make([]string, len(init.Races))
for i := range init.Races {
races[i] = init.Races[i].Name
}
s, err := executor.GenerateGame(races)
if errorResponse(c, err) {
return
}
c.JSON(http.StatusCreated, s)
}
+38
View File
@@ -0,0 +1,38 @@
package handler
import (
"errors"
"net/http"
"galaxy/model/order"
"galaxy/model/rest"
"github.com/gin-gonic/gin"
"github.com/iliadenisov/galaxy/server/internal/repo"
)
func OrderHandler(c *gin.Context, executor CommandExecutor) {
var cmd rest.Command
if errorResponse(c, c.ShouldBindJSON(&cmd)) {
return
}
commands := make([]order.DecodableCommand, len(cmd.Commands))
for i := range cmd.Commands {
command, err := repo.ParseOrder(cmd.Commands[i], validateCommand)
if errorResponse(c, err) {
return
}
commands[i] = command
}
if len(commands) == 0 {
errorResponse(c, errors.New("no commands given"))
return
}
if errorResponse(c, executor.ValidateOrder(cmd.Actor, commands...)) {
return
}
c.Status(http.StatusNoContent)
}
+17
View File
@@ -0,0 +1,17 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
func StatusHandler(c *gin.Context, executor CommandExecutor) {
state, err := executor.GameState()
if errorResponse(c, err) {
return
}
c.JSON(http.StatusOK, state)
}
+17
View File
@@ -0,0 +1,17 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
)
func TurnHandler(c *gin.Context, executor CommandExecutor) {
state, err := executor.GenerateTurn()
if errorResponse(c, err) {
return
}
c.JSON(http.StatusOK, state)
}
+48
View File
@@ -0,0 +1,48 @@
package router_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"galaxy/model/rest"
"galaxy/util"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/controller"
"github.com/iliadenisov/galaxy/server/internal/router"
"github.com/iliadenisov/galaxy/server/internal/router/handler"
"github.com/stretchr/testify/assert"
)
func TestInit(t *testing.T) {
root, cleanup := util.CreateWorkDir(t)
defer cleanup()
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root }))
payload := generateInitRequest(10)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/init", asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code, w.Body)
var initResponse rest.StateResponse
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &initResponse))
assert.NoError(t, uuid.Validate(initResponse.ID.String()))
assert.NotEqual(t, uuid.Nil, uuid.MustParse(initResponse.ID.String()))
}
func TestInitValidators(t *testing.T) {
r := setupRouter()
payload := generateInitRequest(9)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/init", asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
}
+28
View File
@@ -0,0 +1,28 @@
package router
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// LimitMiddleware limits number of concurrent connections using a buffered channel with limit spaces
func LimitMiddleware(limit int) gin.HandlerFunc {
if limit <= 0 {
panic("limit must be greater than 0")
}
semaphore := make(chan bool, limit)
t := time.NewTimer(time.Millisecond * 100)
return func(c *gin.Context) {
t.Reset(time.Millisecond * 100)
select {
case semaphore <- true:
c.Next()
<-semaphore
case <-t.C:
c.Status(http.StatusGatewayTimeout)
}
}
}
+942
View File
@@ -0,0 +1,942 @@
package router_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"galaxy/model/order"
"galaxy/model/rest"
"github.com/stretchr/testify/assert"
)
func TestOrderRaceQuit(t *testing.T) {
r := setupRouter()
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandRaceQuit{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeRaceQuit},
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, commandNoErrorsStatus, w.Code, w.Body)
// error: actor not set
payload.Actor = ""
w = httptest.NewRecorder()
req, _ = http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
payload.Actor = " "
w = httptest.NewRecorder()
req, _ = http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
// unrecognized command type
payload.Commands = []json.RawMessage{
encodeCommand(&order.CommandRaceQuit{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandType("-unknown-")},
}),
}
w = httptest.NewRecorder()
req, _ = http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
// error: no commands
payload = &rest.Command{
Actor: commandDefaultActor,
}
w = httptest.NewRecorder()
req, _ = http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
}
func TestOrderRaceVote(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
acceptor string
}{
{commandNoErrorsStatus, "Valid request", "AnotherRace"},
{http.StatusBadRequest, "Empty acceptor", ""},
{http.StatusBadRequest, "Blank acceptor", " "},
{http.StatusBadRequest, "Invalid acceptor", "Race_👽"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandRaceVote{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeRaceVote},
Acceptor: tc.acceptor,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderRaceRelation(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
relation string
acceptor string
}{
{commandNoErrorsStatus, "Valid request 1", "WAR", "Opponent"},
{commandNoErrorsStatus, "Valid request 2", "PEACE", "Opponent"},
{http.StatusBadRequest, "Empty relation", "", "Opponent"},
{http.StatusBadRequest, "Blank relation", " ", "Opponent"},
{http.StatusBadRequest, "Invalid relation", "Woina", "Opponent"},
{http.StatusBadRequest, "Empty acceptor", "WAR", ""},
{http.StatusBadRequest, "Blank acceptor", "WAR", " "},
{http.StatusBadRequest, "Invalid acceptor", "PEACE", "Race_👽"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandRaceRelation{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeRaceRelation},
Acceptor: tc.acceptor,
Relation: tc.relation,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderShipClassCreate(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
D float64
A int
W, S, C float64
name string
expectStatus int
description string
}{
{1, 0, 0, 0, 0, "Drone", commandNoErrorsStatus, "Simple Drone"},
{1, 1, 1, 0, 0, "Drone", commandNoErrorsStatus, "Armed Drone"},
{1, 0, 0, 1, 0, "Drone", commandNoErrorsStatus, "Shielded Drone"},
{1, 0, 0, 0, 1, "Drone", commandNoErrorsStatus, "Carrying Drone"},
{1, 0, 0, 0, 0, "", http.StatusBadRequest, "Empty name"},
{1, 0, 0, 0, 0, " ", http.StatusBadRequest, "Blank name"},
{1, 0, 0, 0, 0, "Drone🚀", http.StatusBadRequest, "Invalid name"},
{-0.5, 0, 0, 0, 0, "Drone", http.StatusBadRequest, "Drive less than 0"},
{0.9, 0, 0, 0, 0, "Drone", http.StatusBadRequest, "Drive less than 1"},
{1, 1, 0, 0, 0, "Drone", http.StatusBadRequest, "Ammo without Weapons"},
{1, 0, 1, 0, 0, "Drone", http.StatusBadRequest, "Weapons without Ammo"},
{1, -1, 1, 0, 0, "Drone", http.StatusBadRequest, "Ammo less than 0"},
{1, 1, 0.9, 0, 0, "Drone", http.StatusBadRequest, "Weapons less than 1"},
{1, 1, -0.5, 0, 0, "Drone", http.StatusBadRequest, "Weapons less than 0"},
{1, 0, 0, -0.5, 0, "Drone", http.StatusBadRequest, "Shields less than 0"},
{1, 0, 0, 0.9, 0, "Drone", http.StatusBadRequest, "Shields less than 1"},
{1, 0, 0, 0, -0.5, "Drone", http.StatusBadRequest, "Cargo less than 0"},
{1, 0, 0, 0, 0.9, "Drone", http.StatusBadRequest, "Cargo less than 1"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipClassCreate{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipClassCreate},
Name: tc.name,
Drive: tc.D,
Armament: tc.A,
Weapons: tc.W,
Shields: tc.S,
Cargo: tc.C,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderShipClassMerge(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
name string
target string
}{
{commandNoErrorsStatus, "Valid request", "Drone", "Spy"},
{http.StatusBadRequest, "Empty name", "", "Spy"},
{http.StatusBadRequest, "Blank name", " ", "Spy"},
{http.StatusBadRequest, "Invalid name", "Drone🚀", "Spy"},
{http.StatusBadRequest, "Empty name", "Drone", " "},
{http.StatusBadRequest, "Blank name", "Drone", " "},
{http.StatusBadRequest, "Invalid name", "Drone", "Spy🚀"},
{http.StatusBadRequest, "Equal names", "Drone", "Drone"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipClassMerge{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipClassMerge},
Name: tc.name,
Target: tc.target,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderShipClassRemove(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
name string
}{
{commandNoErrorsStatus, "Valid request", "Drone"},
{http.StatusBadRequest, "Empty name", ""},
{http.StatusBadRequest, "Blank name", " "},
{http.StatusBadRequest, "Invalid name", "Drone🚀"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipClassRemove{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipClassRemove},
Name: tc.name,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderShipGroupBreak(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
id string
newId string
quantity int
}{
{commandNoErrorsStatus, "Valid request #1", validId1, validId2, 1},
{commandNoErrorsStatus, "Valid request #2", validId1, validId2, 0},
{http.StatusBadRequest, "Negative quantity", validId1, validId2, -1},
{http.StatusBadRequest, "Empty id", "", validId2, 1},
{http.StatusBadRequest, "Invalid id", invalidId, validId2, 1},
{http.StatusBadRequest, "Empty newId", validId1, "", 1},
{http.StatusBadRequest, "Invalid newId", validId1, invalidId, 1},
{http.StatusBadRequest, "Equal id and newId", validId1, validId1, 1},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupBreak{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupBreak},
ID: tc.id,
NewID: tc.newId,
Quantity: tc.quantity,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderShipGroupLoad(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
id string
cargo string
quantity float64
}{
{commandNoErrorsStatus, "Valid request #1", validId1, "COL", 0},
{commandNoErrorsStatus, "Valid request #2", validId1, "MAT", 1},
{commandNoErrorsStatus, "Valid request #2", validId1, "CAP", 2},
{http.StatusBadRequest, "Invalid quantity", validId1, "COL", -0.5},
{http.StatusBadRequest, "Empty cargo", validId1, "", 1},
{http.StatusBadRequest, "Invalid cargo", validId1, "IND", 1},
{http.StatusBadRequest, "Empty id", "", "COL", 1},
{http.StatusBadRequest, "Invalid id", invalidId, "COL", 1},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupLoad{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupLoad},
ID: tc.id,
Cargo: tc.cargo,
Quantity: tc.quantity,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderShipGroupUnload(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
id string
quantity float64
}{
{commandNoErrorsStatus, "Valid request #1", validId1, 0},
{commandNoErrorsStatus, "Valid request #2", validId1, 1},
{http.StatusBadRequest, "Invalid quantity", validId1, -0.5},
{http.StatusBadRequest, "Empty id", "", 1},
{http.StatusBadRequest, "Invalid id", invalidId, 1},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupUnload{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupUnload},
ID: tc.id,
Quantity: tc.quantity,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderShipGroupSend(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
id string
destination int
}{
{commandNoErrorsStatus, "Valid request #1", validId1, 0},
{commandNoErrorsStatus, "Valid request #1", validId1, 1},
{http.StatusBadRequest, "Invalid destination", validId1, -1},
{http.StatusBadRequest, "Empty id", "", 1},
{http.StatusBadRequest, "Invalid id", invalidId, 1},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupSend{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupSend},
ID: tc.id,
Destination: tc.destination,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderShipGroupUpgrade(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
id string
tech string
level float64
}{
{commandNoErrorsStatus, "Valid request #1", validId1, "ALL", 0},
{commandNoErrorsStatus, "Valid request #1", validId1, "DRIVE", 1.1},
{commandNoErrorsStatus, "Valid request #1", validId1, "WEAPONS", 2.1},
{commandNoErrorsStatus, "Valid request #1", validId1, "SHIELDS", 3.1},
{commandNoErrorsStatus, "Valid request #1", validId1, "CARGO", 4.1},
{http.StatusBadRequest, "Negative level", validId1, "DRIVE", -0.5},
{http.StatusBadRequest, "Invalid level 0.5", validId1, "DRIVE", 0.5},
{http.StatusBadRequest, "Invalid level 1.0", validId1, "DRIVE", 1.0},
{http.StatusBadRequest, "Empty id", "", "ALL", 0},
{http.StatusBadRequest, "Invalid id", invalidId, "ALL", 0},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupUpgrade{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupUpgrade},
ID: tc.id,
Tech: tc.tech,
Level: tc.level,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderShipGroupMerge(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
}{
{commandNoErrorsStatus, "Valid request"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupMerge{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupMerge},
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderShipGroupDismantle(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
id string
}{
{commandNoErrorsStatus, "Valid request", validId1},
{http.StatusBadRequest, "Empty id", ""},
{http.StatusBadRequest, "Invalid id", invalidId},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupDismantle{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupDismantle},
ID: tc.id,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderShipGroupTransfer(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
id string
acceptor string
}{
{commandNoErrorsStatus, "Valid request", validId1, "AnotherRace"},
{http.StatusBadRequest, "Blank id", "", "AnotherRace"},
{http.StatusBadRequest, "Invalid id", invalidId, "AnotherRace"},
{http.StatusBadRequest, "Empty acceptor", validId1, ""},
{http.StatusBadRequest, "Blank acceptor", validId1, " "},
{http.StatusBadRequest, "Invalid acceptor", validId1, "Race_👽"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupTransfer{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupTransfer},
ID: tc.id,
Acceptor: tc.acceptor,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderShipGroupJoinFleet(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
id string
name string
}{
{commandNoErrorsStatus, "Valid request", validId1, "AnotherRace"},
{http.StatusBadRequest, "Blank id", "", "AnotherRace"},
{http.StatusBadRequest, "Invalid id", invalidId, "AnotherRace"},
{http.StatusBadRequest, "Empty name", validId1, ""},
{http.StatusBadRequest, "Blank name", validId1, " "},
{http.StatusBadRequest, "Invalid name", validId1, "Fleet_🚢"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandShipGroupJoinFleet{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeShipGroupJoinFleet},
ID: tc.id,
Name: tc.name,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderFleetMerge(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
name string
target string
}{
{commandNoErrorsStatus, "Valid request", "Fleet", "Bomber"},
{http.StatusBadRequest, "Empty name", "", "Bomber"},
{http.StatusBadRequest, "Invalid name", "Fleet_🚢", "Bomber"},
{http.StatusBadRequest, "Empty target", "Fleet", ""},
{http.StatusBadRequest, "Invalid target", "Fleet", "Bomber_🚢"},
{http.StatusBadRequest, "Equal name and target", "Fleet", "Fleet"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandFleetMerge{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeFleetMerge},
Name: tc.name,
Target: tc.target,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderFleetSend(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
name string
destination int
}{
{commandNoErrorsStatus, "Valid request #1", "Fleet", 0},
{commandNoErrorsStatus, "Valid request #2", "Fleet", 1},
{http.StatusBadRequest, "Invalid destination", "Fleet", -1},
{http.StatusBadRequest, "Empty name", "", 1},
{http.StatusBadRequest, "Invalid name", "Fleet_🚢", 1},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandFleetSend{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeFleetSend},
Name: tc.name,
Destination: tc.destination,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderScienceCreate(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
D, W, S, C float64
name string
}{
{commandNoErrorsStatus, "Valid request", 0.25, 0.25, 0.25, 0.25, "Science"},
{http.StatusBadRequest, "Empty name", 0.25, 0.25, 0.25, 0.25, ""},
{http.StatusBadRequest, "Invalid name", 0.25, 0.25, 0.25, 0.25, "Science🧪"},
{http.StatusBadRequest, "Negative drive", -.5, 0.25, 0.25, 0.25, "Science"},
{http.StatusBadRequest, "Negative weapons", 0.25, -.5, 0.25, 0.25, "Science"},
{http.StatusBadRequest, "Negative shields", 0.25, 0.25, -.5, 0.25, "Science"},
{http.StatusBadRequest, "Negative cargo", 0.25, 0.25, 0.25, -.5, "Science"},
{http.StatusBadRequest, "Too big drive", 1.1, 0.25, 0.25, 0.25, "Science"},
{http.StatusBadRequest, "Too big weapons", 0.25, 1.05, 0.25, 0.25, "Science"},
{http.StatusBadRequest, "Too big shields", 0.25, 0.25, 1.5, 0.25, "Science"},
{http.StatusBadRequest, "Too big cargo", 0.25, 0.25, 0.25, 1.01, "Science"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandScienceCreate{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeScienceCreate},
Name: tc.name,
Drive: tc.D,
Weapons: tc.W,
Shields: tc.S,
Cargo: tc.C,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderScienceRemove(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
name string
}{
{commandNoErrorsStatus, "Valid request", "Drone"},
{http.StatusBadRequest, "Empty name", ""},
{http.StatusBadRequest, "Blank name", " "},
{http.StatusBadRequest, "Invalid name", "Science🧪"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandScienceRemove{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeScienceRemove},
Name: tc.name,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderPlanetRename(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
number int
name string
}{
{commandNoErrorsStatus, "Valid request #1", 0, "HW"},
{commandNoErrorsStatus, "Valid request #2", 1, "HW"},
{http.StatusBadRequest, "Invalid number", -1, "HW"},
{http.StatusBadRequest, "Empty name", 1, ""},
{http.StatusBadRequest, "Blank name", 1, " "},
{http.StatusBadRequest, "Invalid name", 1, "Planet🪐"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandPlanetRename{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypePlanetRename},
Number: tc.number,
Name: tc.name,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderPlanetProduce(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
number int
production, subject string
}{
{commandNoErrorsStatus, "Valid request MAT", 0, "MAT", ""},
{commandNoErrorsStatus, "Valid request CAP", 1, "CAP", ""},
{commandNoErrorsStatus, "Valid request DRIVE", 2, "DRIVE", ""},
{commandNoErrorsStatus, "Valid request WEAPONS", 3, "WEAPONS", ""},
{commandNoErrorsStatus, "Valid request SHIELDS", 4, "SHIELDS", ""},
{commandNoErrorsStatus, "Valid request CARGO", 5, "CARGO", ""},
{commandNoErrorsStatus, "Valid request SCIENCE", 6, "SCIENCE", "Science"},
{commandNoErrorsStatus, "Valid request SHIP", 7, "SHIP", "Ship"},
{http.StatusBadRequest, "Empty production", 0, "", ""},
{http.StatusBadRequest, "Invalid production", 0, "IND", ""},
{http.StatusBadRequest, "Invalid planet", -1, "DRIVE", ""},
{http.StatusBadRequest, "Empty science subject", 6, "SCIENCE", ""},
{http.StatusBadRequest, "Invalid science subject", 6, "SCIENCE", "Science🧪"},
{http.StatusBadRequest, "Empty ship subject", 6, "SHIP", ""},
{http.StatusBadRequest, "Invalid ship subject", 6, "SHIP", "Ship🚀"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandPlanetProduce{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypePlanetProduce},
Number: tc.number,
Production: tc.production,
Subject: tc.subject,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderPlanetRouteSet(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
origin, destination int
loadType string
}{
{commandNoErrorsStatus, "Valid request MAT", 1, 0, "MAT"},
{commandNoErrorsStatus, "Valid request CAP", 0, 1, "CAP"},
{commandNoErrorsStatus, "Valid request COL", 1, 2, "COL"},
{commandNoErrorsStatus, "Valid request EMP", 3, 0, "EMP"},
{http.StatusBadRequest, "Empty loadType", 0, 1, ""},
{http.StatusBadRequest, "Invalid loadType", 0, 1, "IND"},
{http.StatusBadRequest, "Invalid origin", -1, 1, "MAT"},
{http.StatusBadRequest, "Invalid destination", 1, -1, "MAT"},
{http.StatusBadRequest, "Origin equals destination", 1, 1, "COL"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandPlanetRouteSet{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypePlanetRouteSet},
Origin: tc.origin,
Destination: tc.destination,
LoadType: tc.loadType,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestOrderPlanetRouteRemove(t *testing.T) {
r := setupRouter()
for _, tc := range []struct {
expectStatus int
description string
origin int
loadType string
}{
{commandNoErrorsStatus, "Valid request MAT", 0, "MAT"},
{commandNoErrorsStatus, "Valid request CAP", 1, "CAP"},
{commandNoErrorsStatus, "Valid request COL", 2, "COL"},
{commandNoErrorsStatus, "Valid request EMP", 0, "EMP"},
{http.StatusBadRequest, "Empty loadType", 1, ""},
{http.StatusBadRequest, "Invalid loadType", 1, "IND"},
{http.StatusBadRequest, "Invalid origin", -1, "MAT"},
} {
t.Run(tc.description, func(t *testing.T) {
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandPlanetRouteRemove{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypePlanetRouteRemove},
Origin: tc.origin,
LoadType: tc.loadType,
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, tc.expectStatus, w.Code, w.Body)
})
}
}
func TestMultipleCommandOrder(t *testing.T) {
e := newExecutor()
r := setupRouterExecutor(e)
payload := &rest.Command{
Actor: commandDefaultActor,
Commands: []json.RawMessage{
encodeCommand(&order.CommandRaceRelation{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeRaceRelation},
Acceptor: "Opponent",
Relation: "PEACE",
}),
encodeCommand(&order.CommandRaceVote{
CommandMeta: order.CommandMeta{CmdID: id(), CmdType: order.CommandTypeRaceVote},
Acceptor: "Opponent",
}),
},
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, commandNoErrorsStatus, w.Code, w.Body)
assert.Equal(t, 2, e.(*dummyExecutor).CommandsExecuted)
}
+91
View File
@@ -0,0 +1,91 @@
package router
import (
"fmt"
"io"
"net/http"
"os"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"github.com/iliadenisov/galaxy/server/internal/router/handler"
)
const (
ISO8601 = "2006-01-02 15:04:05.0 -07:00"
)
type Router struct {
r *gin.Engine
executor handler.CommandExecutor
}
func (r Router) Run() error {
return r.r.Run()
}
func NewRouter() Router {
gin.SetMode(gin.ReleaseMode)
return NewRouterExecutor(handler.NewDefaultExecutor())
}
func NewRouterExecutor(executor handler.CommandExecutor) Router {
return Router{r: setupRouter(executor)}
}
func setupRouter(executor handler.CommandExecutor) *gin.Engine {
r := gin.New()
// Logger middleware will write the logs to gin.DefaultWriter even if you set with GIN_MODE=release.
logConfig := &gin.LoggerConfig{Formatter: logFormatter}
if gin.Mode() != gin.DebugMode {
logConfig.Output = io.Discard
}
r.Use(gin.LoggerWithConfig(*logConfig))
// Recovery middleware recovers from any panics and writes a 500 if there was one.
r.Use(gin.CustomRecovery(recoveryHandler))
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("notblank", notBlankStringValidator); err != nil {
panic(err)
}
if err := v.RegisterValidation("ammoWeapons", armamentWithWeaponsValidator); err != nil {
panic(err)
}
if err := v.RegisterValidation("entity", entityNameStringValidator); err != nil {
panic(err)
}
if err := v.RegisterValidation("subject", productionTypeStringValidator); err != nil {
panic(err)
}
}
groupV1 := r.Group("/api/v1")
groupV1.GET("/status", func(ctx *gin.Context) { handler.StatusHandler(ctx, executor) })
groupV1.POST("/init", func(ctx *gin.Context) { handler.InitHandler(ctx, executor) })
groupV1.PUT("/command", LimitMiddleware(1), func(ctx *gin.Context) { handler.CommandHandler(ctx, executor) })
groupV1.PUT("/order", func(ctx *gin.Context) { handler.OrderHandler(ctx, executor) })
groupV1.PUT("/turn", func(ctx *gin.Context) { handler.TurnHandler(ctx, executor) })
return r
}
func logFormatter(param gin.LogFormatterParams) string {
return fmt.Sprintf("[%s] \"%s %s %s %d %s\"\n",
param.TimeStamp.Format(ISO8601),
param.Method,
param.Path,
param.Request.Proto,
param.StatusCode,
param.Latency,
)
}
func recoveryHandler(c *gin.Context, recovered any) {
if err, ok := recovered.(string); ok {
fmt.Fprintf(os.Stderr, "recovered: %s", err)
}
c.AbortWithStatus(http.StatusInternalServerError)
}
@@ -0,0 +1,11 @@
package router
import (
"github.com/gin-gonic/gin"
"github.com/iliadenisov/galaxy/server/internal/router/handler"
)
func SetupRouter(e handler.CommandExecutor) *gin.Engine {
gin.SetMode(gin.TestMode)
return setupRouter(e)
}
@@ -0,0 +1,75 @@
package router_test
import (
"encoding/json"
"net/http"
"galaxy/model/order"
"galaxy/model/rest"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/router"
"github.com/iliadenisov/galaxy/server/internal/router/handler"
)
var (
commandNoErrorsStatus = http.StatusNoContent
commandDefaultActor = "Gorlum"
apiCommandMethod = "PUT"
apiCommandPath = "/api/v1/command"
apiOrderPath = "/api/v1/order"
validId1 = id()
validId2 = id()
invalidId = "fd091c69-5976-4775-b2f9-7ba77735afb"
)
func id() string {
return uuid.New().String()
}
type dummyExecutor struct {
CommandsExecuted int
}
func (e *dummyExecutor) ValidateOrder(actor string, cmd ...order.DecodableCommand) error {
e.CommandsExecuted = len(cmd)
return nil
}
func (e *dummyExecutor) Execute(command ...handler.Command) error {
e.CommandsExecuted = len(command)
return nil
}
func (e *dummyExecutor) GenerateGame(races []string) (rest.StateResponse, error) {
return rest.StateResponse{}, nil
}
func (e *dummyExecutor) GenerateTurn() (rest.StateResponse, error) {
return rest.StateResponse{}, nil
}
func (e *dummyExecutor) GameState() (rest.StateResponse, error) {
return rest.StateResponse{}, nil
}
func setupRouter() *gin.Engine {
return setupRouterExecutor(newExecutor())
}
func setupRouterExecutor(e handler.CommandExecutor) *gin.Engine {
return router.SetupRouter(e)
}
func newExecutor() handler.CommandExecutor {
return &dummyExecutor{}
}
func encodeCommand(cmd any) json.RawMessage {
v, err := json.Marshal(cmd)
if err != nil {
panic(err)
}
return v
}
+82
View File
@@ -0,0 +1,82 @@
package router_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"galaxy/model/rest"
"github.com/gin-gonic/gin"
"github.com/iliadenisov/galaxy/server/internal/router"
"github.com/stretchr/testify/assert"
)
func TestLimitConnections(t *testing.T) {
r := limitTestingRouter()
wg := sync.WaitGroup{}
lock := sync.WaitGroup{}
lock.Add(1)
for range 1000 {
wg.Go(func() {
w := httptest.NewRecorder()
lock.Wait()
req, _ := http.NewRequest("GET", "/limited", nil)
r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code, w.Body)
})
}
lock.Done()
wg.Wait()
}
func asBody(body any) *strings.Reader {
commandJson, _ := json.Marshal(body)
return strings.NewReader(string(commandJson))
}
func limitTestingRouter() *gin.Engine {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
counter := atomic.Int32{}
r.GET("/limited",
// limiting all ingoing connections
router.LimitMiddleware(1),
// storing counter value and testing increment after executing Next handlers
func(c *gin.Context) {
expected := counter.Load() + 1
c.Next()
current := counter.Load()
if current != expected {
c.String(http.StatusConflict, "expected: %d, got: %d", expected, current)
}
},
// increment counter
func(c *gin.Context) {
counter.Add(1)
c.Status(http.StatusOK)
})
return r
}
func generateInitRequest(races int) rest.Init {
request := rest.Init{
Races: make([]rest.Race, races),
}
for i := range request.Races {
request.Races[i] = rest.Race{Name: raceName(i)}
}
return request
}
func raceName(i int) string {
return fmt.Sprintf("Race_%02d", i)
}
+55
View File
@@ -0,0 +1,55 @@
package router_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"galaxy/model/rest"
"galaxy/util"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/controller"
"github.com/iliadenisov/galaxy/server/internal/router"
"github.com/iliadenisov/galaxy/server/internal/router/handler"
"github.com/stretchr/testify/assert"
)
func TestGetStatus(t *testing.T) {
root, cleanup := util.CreateWorkDir(t)
defer cleanup()
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root }))
payload := generateInitRequest(10)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/init", asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code, w.Body)
var initResponse rest.StateResponse
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &initResponse))
assert.NoError(t, uuid.Validate(initResponse.ID.String()))
assert.NotEqual(t, uuid.Nil, uuid.MustParse(initResponse.ID.String()))
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/api/v1/status", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, w.Body)
var stateResponse rest.StateResponse
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &stateResponse))
assert.NoError(t, uuid.Validate(stateResponse.ID.String()))
assert.Equal(t, initResponse.ID, stateResponse.ID)
assert.Equal(t, uint(0), stateResponse.Turn)
assert.Equal(t, uint(0), stateResponse.Stage)
assert.Len(t, stateResponse.Players, 10)
for i := range stateResponse.Players {
assert.NoError(t, uuid.Validate(stateResponse.Players[i].ID.String()))
assert.Equal(t, raceName(i), stateResponse.Players[i].Name)
assert.False(t, stateResponse.Players[i].Extinct)
}
}
+69
View File
@@ -0,0 +1,69 @@
package router_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"galaxy/model/rest"
"galaxy/util"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/server/internal/controller"
"github.com/iliadenisov/galaxy/server/internal/router"
"github.com/iliadenisov/galaxy/server/internal/router/handler"
"github.com/stretchr/testify/assert"
)
func TestGetTurn(t *testing.T) {
root, cleanup := util.CreateWorkDir(t)
defer cleanup()
r := router.SetupRouter(handler.NewDefaultConfigExecutor(func(p *controller.Param) { p.StoragePath = root }))
// create game
payload := generateInitRequest(10)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/init", asBody(payload))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code, w.Body)
var initResponse rest.StateResponse
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &initResponse))
assert.NoError(t, uuid.Validate(initResponse.ID.String()))
assert.NotEqual(t, uuid.Nil, uuid.MustParse(initResponse.ID.String()))
assert.Equal(t, uint(0), initResponse.Turn)
assert.Equal(t, uint(0), initResponse.Stage)
// generate next turn
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/api/v1/turn", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, w.Body)
var turnResponse rest.StateResponse
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &turnResponse))
assert.NoError(t, uuid.Validate(turnResponse.ID.String()))
assert.Equal(t, initResponse.ID, turnResponse.ID)
assert.Equal(t, uint(1), turnResponse.Turn)
assert.Equal(t, uint(0), turnResponse.Stage)
// validate status
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/api/v1/status", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, w.Body)
var stateResponse rest.StateResponse
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &stateResponse))
assert.Equal(t, initResponse.ID, stateResponse.ID)
assert.Equal(t, uint(1), stateResponse.Turn)
assert.Equal(t, uint(0), stateResponse.Stage)
}
+66
View File
@@ -0,0 +1,66 @@
package router
import (
"strings"
"galaxy/util"
"github.com/go-playground/validator/v10"
)
var notBlankStringValidator validator.Func = func(fl validator.FieldLevel) bool {
s, ok := fl.Field().Interface().(string)
if ok {
if len(strings.TrimSpace(s)) == 0 {
return false
}
}
return true
}
var entityNameStringValidator validator.Func = func(fl validator.FieldLevel) bool {
s, ok := fl.Field().Interface().(string)
if ok {
if _, ok := util.ValidateTypeName(s); !ok {
return false
}
}
return true
}
var productionTypeStringValidator validator.Func = func(fl validator.FieldLevel) bool {
v, ok := fl.Field().Interface().(string)
if ok {
f := fl.Parent().FieldByName(fl.Param())
if f.String() == "SHIP" || f.String() == "SCIENCE" {
if _, ok := util.ValidateTypeName(v); !ok {
return false
}
}
}
return true
}
var armamentWithWeaponsValidator validator.Func = func(fl validator.FieldLevel) bool {
var v, compareTo float64
f := fl.Parent().FieldByName(fl.Param())
if f.CanFloat() {
compareTo = f.Float()
} else if f.CanInt() {
compareTo = float64(f.Int())
} else {
return false
}
if fl.Field().CanFloat() {
v = fl.Field().Float()
} else if fl.Field().CanInt() {
v = float64(fl.Field().Int())
} else {
return false
}
return (v == 0 && compareTo == 0) || (v >= 1 && compareTo >= 1)
}