fs storage

This commit is contained in:
Ilia Denisov
2026-03-13 21:07:23 +02:00
committed by GitHub
parent 43039a79bf
commit 9ade76e21d
117 changed files with 1734 additions and 176 deletions
+232
View File
@@ -0,0 +1,232 @@
package controller
import (
"iter"
"maps"
"math"
"math/rand/v2"
"slices"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
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))]
}
+185
View File
@@ -0,0 +1,185 @@
package controller_test
import (
"maps"
"slices"
"testing"
"galaxy/game/internal/controller"
"galaxy/game/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
}
+113
View File
@@ -0,0 +1,113 @@
package controller
import (
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
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
}
+143
View File
@@ -0,0 +1,143 @@
package controller_test
import (
"testing"
"galaxy/game/internal/controller"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
"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)
}
}
}
+105
View File
@@ -0,0 +1,105 @@
package controller
import (
"fmt"
"slices"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
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
}
+260
View File
@@ -0,0 +1,260 @@
package controller
import (
"strings"
e "galaxy/error"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
// 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)
}
+249
View File
@@ -0,0 +1,249 @@
package controller
import (
"errors"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
"galaxy/model/order"
"galaxy/model/report"
"galaxy/game/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,151 @@
package controller
import (
"iter"
e "galaxy/error"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
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)
}
+151
View File
@@ -0,0 +1,151 @@
package controller_test
import (
"fmt"
"galaxy/game/internal/controller"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
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
}
+323
View File
@@ -0,0 +1,323 @@
package controller
import (
"fmt"
"iter"
"math"
"slices"
"galaxy/util"
e "galaxy/error"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
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"
"galaxy/game/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,90 @@
package controller_test
import (
"slices"
"testing"
e "galaxy/error"
"galaxy/game/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())
}
}
+191
View File
@@ -0,0 +1,191 @@
package controller_test
import (
"math"
"slices"
"testing"
e "galaxy/error"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
"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)
}
+148
View File
@@ -0,0 +1,148 @@
package controller
import (
"fmt"
"math/rand/v2"
"slices"
"galaxy/game/internal/generator"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
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,70 @@
package controller_test
import (
"fmt"
"path/filepath"
"strings"
"testing"
"galaxy/util"
"galaxy/game/internal/controller"
"galaxy/game/internal/model/game"
"galaxy/game/internal/repo"
"github.com/google/uuid"
"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())
}
+140
View File
@@ -0,0 +1,140 @@
package controller
import (
"maps"
"slices"
"galaxy/model/report"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
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
}
+222
View File
@@ -0,0 +1,222 @@
package controller
import (
"errors"
"fmt"
"galaxy/model/order"
e "galaxy/error"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
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
}
+310
View File
@@ -0,0 +1,310 @@
package controller
import (
"fmt"
"iter"
"slices"
"galaxy/util"
e "galaxy/error"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
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
}
+424
View File
@@ -0,0 +1,424 @@
package controller_test
import (
"slices"
"testing"
"galaxy/util"
e "galaxy/error"
"galaxy/game/internal/controller"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
"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"
"galaxy/game/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
}
}
}
}
+92
View File
@@ -0,0 +1,92 @@
package controller_test
import (
"testing"
e "galaxy/error"
"galaxy/game/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)
}
+777
View File
@@ -0,0 +1,777 @@
package controller
import (
"cmp"
"fmt"
"iter"
"slices"
mr "galaxy/model/report"
"galaxy/util"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
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"
"galaxy/game/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"
"galaxy/game/internal/controller"
"galaxy/game/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)
}
+101
View File
@@ -0,0 +1,101 @@
package controller
import (
"fmt"
"slices"
"galaxy/util"
e "galaxy/error"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
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]
}
+138
View File
@@ -0,0 +1,138 @@
package controller_test
import (
"testing"
e "galaxy/error"
"galaxy/game/internal/controller"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
"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))
}
+190
View File
@@ -0,0 +1,190 @@
package controller
import (
"fmt"
"iter"
"slices"
"galaxy/util"
e "galaxy/error"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
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
}
+150
View File
@@ -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))
}
+567
View File
@@ -0,0 +1,567 @@
package controller
import (
"cmp"
"fmt"
"iter"
"maps"
"slices"
"galaxy/util"
e "galaxy/error"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
// 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"
"galaxy/game/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,47 @@
package controller_test
import (
"slices"
"testing"
"galaxy/game/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())
}
}
+104
View File
@@ -0,0 +1,104 @@
package controller
import (
"galaxy/util"
e "galaxy/error"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
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,64 @@
package controller_test
import (
"testing"
e "galaxy/error"
"github.com/google/uuid"
"galaxy/game/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())
}
+569
View File
@@ -0,0 +1,569 @@
package controller_test
import (
"fmt"
"slices"
"strings"
"testing"
"galaxy/util"
e "galaxy/error"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
"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,225 @@
package controller
import (
"math"
"slices"
"strings"
e "galaxy/error"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
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,178 @@
package controller_test
import (
"testing"
e "galaxy/error"
"galaxy/game/internal/controller"
"galaxy/game/internal/model/game"
g "galaxy/game/internal/model/game"
"github.com/google/uuid"
"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))
}
+234
View File
@@ -0,0 +1,234 @@
package controller
import (
"cmp"
"fmt"
"maps"
"math/big"
"slices"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
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
}
+301
View File
@@ -0,0 +1,301 @@
package controller_test
import (
"testing"
"galaxy/game/internal/controller"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
"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})
}