refactor: battle at controller

This commit is contained in:
Ilia Denisov
2026-01-14 14:40:04 +02:00
parent 004529cdd3
commit 1bfc9242af
16 changed files with 583 additions and 546 deletions
-194
View File
@@ -1,194 +0,0 @@
package battle
import (
"maps"
"math"
"math/rand/v2"
"slices"
"github.com/google/uuid"
"github.com/iliadenisov/galaxy/internal/controller"
"github.com/iliadenisov/galaxy/internal/model/game"
)
type Battle struct {
ID uuid.UUID
Planet uint
observerGroups map[int]bool // True = In_Battle, False = Out_Battle
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
Defenter int
Destroyed bool
}
func CollectPlanetGroups(c *controller.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 *controller.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 *controller.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,
c.ShipGroup(attIdx).TechLevel(game.TechWeapons),
c.ShipGroupShipClass(defIdx).Shields,
c.ShipGroup(defIdx).TechLevel(game.TechShields),
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 *controller.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)
for pl, observerGroups := range planetGroups {
battleGroups := FilterBattleGroups(c, observerGroups)
b := &Battle{
Planet: pl,
observerGroups: observerGroups,
attacker: make(map[int]map[int]float64),
shipAmmo: make(map[int]uint),
}
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 {
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 *controller.Cache, b *Battle) {
for len(b.attacker) > 0 {
attackers := slices.Collect(maps.Keys(b.attacker))
attIdx := attackers[rand.IntN(len(attackers))]
for range b.shipAmmo[attIdx] {
defenders := slices.Collect(maps.Keys(b.attacker[attIdx]))
defIdx := defenders[rand.IntN(len(defenders))]
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,
Defenter: defIdx,
Destroyed: destroyed,
})
if destroyed {
c.ShipGroupNumber(defIdx, c.ShipGroup(defIdx).Number-1)
}
if c.ShipGroup(defIdx).Number == 0 {
delete(b.attacker, defIdx) // Eliminated group cant attack anyone
for attIdx := range b.attacker {
delete(b.attacker[attIdx], defIdx) // Attackers can't attack eliminated group anymore
if len(b.attacker[attIdx]) == 0 {
delete(b.attacker, attIdx) // Remove attacker if he lost all opponents
}
}
}
if len(b.attacker) == 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.)
}
-67
View File
@@ -1,67 +0,0 @@
package battle_test
import (
"testing"
"github.com/iliadenisov/galaxy/internal/game/battle"
"github.com/iliadenisov/galaxy/internal/model/game"
"github.com/stretchr/testify/assert"
)
var (
attacker = game.ShipType{
ShipTypeReport: game.ShipTypeReport{
Name: "Attacker",
Drive: 8,
Armament: 1,
Weapons: 8,
Shields: 8,
Cargo: 0,
},
}
defender = game.ShipType{
ShipTypeReport: game.ShipTypeReport{
Name: "Defender",
Drive: 1,
Armament: 1,
Weapons: 1,
Shields: 1,
Cargo: 0,
},
}
ship = game.ShipType{
ShipTypeReport: game.ShipTypeReport{
Name: "Ship",
Drive: 10,
Armament: 1,
Weapons: 10,
Shields: 10,
Cargo: 0,
},
}
)
func TestDestructionProbability(t *testing.T) {
probability := battle.DestructionProbability(ship.Weapons, 1, ship.Shields, 1, ship.EmptyMass())
assert.Equal(t, .5, probability)
undefeatedShip := ship
undefeatedShip.Shields = 55
probability = battle.DestructionProbability(ship.Weapons, 1, undefeatedShip.Shields, 1, undefeatedShip.EmptyMass())
assert.LessOrEqual(t, probability, 0.)
disruptiveShip := ship
disruptiveShip.Weapons = 40
probability = battle.DestructionProbability(disruptiveShip.Weapons, 1, ship.Shields, 1, ship.EmptyMass())
assert.GreaterOrEqual(t, probability, 1.)
}
func TestEffectiveDefence(t *testing.T) {
assert.Equal(t, 10., battle.EffectiveDefence(ship.Shields, 1, ship.EmptyMass()))
attackerEffectiveDefence := battle.EffectiveDefence(attacker.Shields, 1, attacker.EmptyMass())
defenderEffectiveDefence := battle.EffectiveDefence(defender.Shields, 1, defender.EmptyMass())
// attacker's effective shields must be 'just' 4 times greater than defender's
assert.InDelta(t, defenderEffectiveDefence*4, attackerEffectiveDefence, 0)
}
+1 -2
View File
@@ -2,13 +2,12 @@ package game
import (
"github.com/iliadenisov/galaxy/internal/controller"
"github.com/iliadenisov/galaxy/internal/game/turn"
"github.com/iliadenisov/galaxy/internal/model/game"
)
func MakeTurn(configure func(*controller.Param), race string, number int, name string) (err error) {
control(configure, func(c *controller.Controller) {
c.ExecuteGame(func(r controller.Repo, g *game.Game) { turn.MakeTurn(c, r, g) })
c.ExecuteGame(func(r controller.Repo, g *game.Game) { controller.MakeTurn(c, r, g) })
})
return
}
+1 -1
View File
@@ -46,7 +46,7 @@ func g(t *testing.T, f func(p func(*controller.Param), g func() *mg.Game)) {
g, err := game.LoadState(p)
if err != nil {
assert.FailNow(t, "g: LoadState", err)
return nil // mg.Game{}
return nil
}
return g
}
-51
View File
@@ -1,51 +0,0 @@
package turn
import (
"github.com/iliadenisov/galaxy/internal/controller"
"github.com/iliadenisov/galaxy/internal/game/battle"
"github.com/iliadenisov/galaxy/internal/model/game"
)
func TransformBattle(c *controller.Cache, b *battle.Battle) *game.BattleReport {
r := &game.BattleReport{
ID: b.ID,
Planet: b.Planet,
PlanetName: c.Planet(b.Planet).Name,
Races: make(map[int]string),
Ships: make(map[int]string),
Protocol: make([]game.BattleActionReport, len(b.Protocol)),
}
cacheShipClass := make(map[string]int)
cacheRaceName := make(map[string]int)
cacher := func(shipClass string, cache map[string]int) int {
if v, ok := cache[shipClass]; ok {
return v
} else {
itemNumber := len(r.Ships)
r.Ships[itemNumber] = shipClass
cache[shipClass] = itemNumber
return itemNumber
}
}
for i := range b.Protocol {
r.Protocol[i] = game.BattleActionReport{
Attacker: cacher(c.ShipGroupOwnerRace(b.Protocol[i].Attacker).Name, cacheRaceName),
AttackerShipClass: cacher(c.ShipGroupShipClass(b.Protocol[i].Attacker).Name, cacheShipClass),
Defender: cacher(c.ShipGroupOwnerRace(b.Protocol[i].Defenter).Name, cacheRaceName),
DefenderShipClass: cacher(c.ShipGroupShipClass(b.Protocol[i].Defenter).Name, cacheShipClass),
Destroyed: b.Protocol[i].Destroyed,
}
}
for name, index := range cacheRaceName {
r.Races[index] = name
}
for name, index := range cacheShipClass {
r.Ships[index] = name
}
return r
}
-47
View File
@@ -1,47 +0,0 @@
package turn
import (
"github.com/iliadenisov/galaxy/internal/controller"
e "github.com/iliadenisov/galaxy/internal/error"
"github.com/iliadenisov/galaxy/internal/game/battle"
"github.com/iliadenisov/galaxy/internal/model/game"
)
func MakeTurn(c *controller.Controller, r controller.Repo, g *game.Game) error {
// Next turn
g.Age += 1
// 01. Корабли, где это возможно, объединяются в группы.
game.JoinEqualGroups(g)
// 02. Враждующие корабли вступают в схватку.
battles := battle.ProduceBattles(c.Cache)
// Internal control: after battles there are can't be groups with no ships left
for i := range g.ShipGroups {
if g.ShipGroups[i].Number == 0 {
return e.NewGameStateError("")
}
}
/*** Last steps ***/
// Store battles
if len(battles) > 0 {
for i := range battles {
// TODO: add In_Battle / Out_Battle participants?
br := TransformBattle(c.Cache, battles[i])
if err := r.SaveBattle(g.Age, br); err != nil {
return err
}
}
}
// Remove killed ship groups
c.Cache.DeleteKilledShipGroups()
// TODO: Store game state
// TODO: Store individual reports
return nil
}