wip: battle

This commit is contained in:
Ilia Denisov
2026-01-13 18:53:17 +02:00
parent 4451850f22
commit 45c725a3ee
5 changed files with 197 additions and 30 deletions
+5 -1
View File
@@ -20,10 +20,14 @@ type Repo interface {
// SaveState stores current game state updated between turns // SaveState stores current game state updated between turns
SaveState(*game.Game) error SaveState(*game.Game) error
// LoadState retrieves game current state // LoadState retrieves game current state with required lock acquisition
LoadState() (*game.Game, error) LoadState() (*game.Game, error)
// LoadStateSafe retrieves game current state without preliminary locking
LoadStateSafe() (*game.Game, error) LoadStateSafe() (*game.Game, error)
// SaveBattle stores
SaveBattle(t uint, b *game.BattleReport) error
} }
type Controller struct { type Controller struct {
+66 -3
View File
@@ -1,13 +1,76 @@
package turn package turn
import "github.com/iliadenisov/galaxy/internal/model/game" import (
"fmt"
"github.com/iliadenisov/galaxy/internal/controller"
e "github.com/iliadenisov/galaxy/internal/error"
"github.com/iliadenisov/galaxy/internal/model/game"
)
func MakeTurn(r controller.Repo, g *game.Game) error {
// Next turn
g.Age += 1
func MakeTurn(g *game.Game) error {
// 01. Корабли, где это возможно, объединяются в группы. // 01. Корабли, где это возможно, объединяются в группы.
game.JoinEqualGroups(g) game.JoinEqualGroups(g)
// 02. Враждующие корабли вступают в схватку. // 02. Враждующие корабли вступают в схватку.
game.ProduceBattles(g) battles := game.ProduceBattles(g)
// 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 step: storing battles
if len(battles) > 0 {
for i := range battles {
br := TransformBattle(g, battles[i])
if err := r.SaveBattle(g.Age, br); err != nil {
return err
}
}
}
return nil return nil
} }
func TransformBattle(g *game.Game, b *game.Battle) *game.BattleReport {
p, ok := game.PlanetByNum(g, b.Planet)
if !ok {
panic(fmt.Sprintf("TransformBattle: no planet with number #%d", b.Planet))
}
r := &game.BattleReport{
ID: b.ID,
Planet: b.Planet,
PlanetName: p.Name,
Races: make(map[int]string),
Ships: make(map[int]string),
Protocol: make([]game.BattleActionReport, len(b.Protocol)),
}
cacheShipClass := make(map[string]int)
shipClass := func(shipClass string) int {
if v, ok := cacheShipClass[shipClass]; ok {
return v
} else {
l := len(r.Ships)
r.Ships[l] = shipClass
cacheShipClass[shipClass] = l
return l
}
}
for i := range b.Protocol {
r.Protocol[i] = game.BattleActionReport{
AttackerShipClass: shipClass(b.ShipClassName(b.Protocol[i].Attacker)),
DefenderShipClass: shipClass(b.ShipClassName(b.Protocol[i].Defenter)),
Destroyed: b.Protocol[i].Destroyed,
}
}
return r
}
+77 -17
View File
@@ -1,6 +1,7 @@
package game package game
import ( import (
"encoding/json"
"fmt" "fmt"
"maps" "maps"
"math" "math"
@@ -11,29 +12,67 @@ import (
) )
type Battle struct { type Battle struct {
ID uuid.UUID
Planet uint Planet uint
BattleReport BattleReport // True = In_Battle, False = Out_Battle
observerGroups map[int]bool
Protocol []BattleAction
shipAmmo map[int]uint shipAmmo map[int]uint
shipName map[int]string
attacker map[int]map[int]float64 // a group able to attack and destroy an opponent with some probability > 0 attacker map[int]map[int]float64 // a group able to attack and destroy an opponent with some probability > 0
} }
type BattleReport struct {
BattleAction []BattleAction
}
type BattleAction struct { type BattleAction struct {
Attacker int Attacker int
Defenter int Defenter int
Destroyed bool Destroyed bool
} }
func CollectPlanetGroups(g *Game, cacheShipGroupRaceID map[int]int, cacheShipClass map[int]*ShipType) map[uint][]int { func (b Battle) ShipClassName(groupIndex int) string {
planetGroup := make(map[uint][]int) if v, ok := b.shipName[groupIndex]; ok {
return v
} else {
panic(fmt.Sprintf("Battle.ShipClassName: no name stored for groupIndex=%d", groupIndex))
}
}
type BattleReport struct {
ID uuid.UUID `json:"id"`
Planet uint `json:"planet"`
PlanetName string `json:"planet_name"`
Races map[int]string `json:"races"`
Ships map[int]string `json:"ships"`
Protocol []BattleActionReport `json:"protocol"`
}
type BattleActionReport struct {
Attacker int `json:"r1"`
AttackerShipClass int `json:"s1"`
Defender int `json:"r2"`
DefenderShipClass int `json:"s2"`
Destroyed bool `json:"d"`
}
type ShipClassBattle struct {
ClassName string `json:"class"`
Tech TechSet `json:"tech"`
Number uint `json:"number"`
CargoType *CargoType `json:"loadType,omitempty"`
Quantity float64 `json:"quantity"`
Left uint `json:"left"`
}
func CollectPlanetGroups(g *Game, cacheShipGroupRaceID map[int]int, cacheShipClass map[int]*ShipType) map[uint]map[int]bool {
planetGroup := make(map[uint]map[int]bool)
for groupIndex := range g.ShipGroups { for groupIndex := range g.ShipGroups {
if g.ShipGroups[groupIndex].State() == StateInOrbit { state := g.ShipGroups[groupIndex].State()
if state == StateInOrbit || state == StateUpgrade {
planetNumber := g.ShipGroups[groupIndex].Destination planetNumber := g.ShipGroups[groupIndex].Destination
planetGroup[planetNumber] = append(planetGroup[planetNumber], groupIndex) if _, ok := planetGroup[planetNumber]; !ok {
planetGroup[planetNumber] = make(map[int]bool)
}
planetGroup[planetNumber][groupIndex] = false
if _, ok := cacheShipGroupRaceID[groupIndex]; !ok { if _, ok := cacheShipGroupRaceID[groupIndex]; !ok {
cacheShipGroupRaceID[groupIndex] = RaceIndex(g, g.ShipGroups[groupIndex].OwnerID) cacheShipGroupRaceID[groupIndex] = RaceIndex(g, g.ShipGroups[groupIndex].OwnerID)
@@ -57,6 +96,10 @@ func CollectPlanetGroups(g *Game, cacheShipGroupRaceID map[int]int, cacheShipCla
return planetGroup return planetGroup
} }
func FilterBattleGroups(g *Game, groups map[int]bool) []int {
return slices.DeleteFunc(slices.Collect(maps.Keys(groups)), func(groupIndex int) bool { return g.ShipGroups[groupIndex].State() != StateInOrbit })
}
func CacheRelations(g *Game, cacheShipGroupRaceID map[int]int) map[int]map[int]Relation { func CacheRelations(g *Game, cacheShipGroupRaceID map[int]int) map[int]map[int]Relation {
cache := make(map[int]map[int]Relation) cache := make(map[int]map[int]Relation)
ri := make(map[int]bool) ri := make(map[int]bool)
@@ -132,8 +175,8 @@ func ProduceBattles(g *Game) []*Battle {
clear(cacheProbability) clear(cacheProbability)
}() }()
planetGroup := CollectPlanetGroups(g, cacheShipGroupRaceID, cacheShipClass) planetGroups := CollectPlanetGroups(g, cacheShipGroupRaceID, cacheShipClass)
if len(planetGroup) == 0 { if len(planetGroups) == 0 {
return nil return nil
} }
@@ -144,35 +187,42 @@ func ProduceBattles(g *Game) []*Battle {
result := make([]*Battle, 0) result := make([]*Battle, 0)
for pl, groups := range planetGroup { for pl, observerGroups := range planetGroups {
battleGroups := FilterBattleGroups(g, observerGroups)
b := &Battle{ b := &Battle{
Planet: pl, Planet: pl,
observerGroups: observerGroups,
attacker: make(map[int]map[int]float64), attacker: make(map[int]map[int]float64),
shipAmmo: make(map[int]uint), shipAmmo: make(map[int]uint),
shipName: make(map[int]string),
} }
for i := range groups { for i := range battleGroups {
attIdx := groups[i] attIdx := battleGroups[i]
// Ships with no Ammo will never attack somebody // Ships with no Ammo will never attack somebody
if cacheShipClass[attIdx].Armament == 0 { if cacheShipClass[attIdx].Armament == 0 {
continue continue
} }
// TODO: remove slices.Clone? opponents := slices.DeleteFunc(slices.Clone(battleGroups), func(defIdx int) bool {
opponents := slices.DeleteFunc(slices.Clone(groups), func(defIdx int) bool {
return FilterBattleOpponents(g, attIdx, defIdx, cacheShipGroupRaceID, cacheRelation, cacheShipClass, cacheProbability) return FilterBattleOpponents(g, attIdx, defIdx, cacheShipGroupRaceID, cacheRelation, cacheShipClass, cacheProbability)
}) })
if len(opponents) > 0 { if len(opponents) > 0 {
b.shipAmmo[attIdx] = cacheShipClass[attIdx].Armament b.shipAmmo[attIdx] = cacheShipClass[attIdx].Armament
b.shipName[attIdx] = cacheShipClass[attIdx].Name
b.observerGroups[attIdx] = true
for _, defIdx := range opponents { for _, defIdx := range opponents {
b.attacker[attIdx][defIdx] = cacheProbability[attIdx][defIdx] b.attacker[attIdx][defIdx] = cacheProbability[attIdx][defIdx]
b.shipName[defIdx] = cacheShipClass[defIdx].Name
b.observerGroups[defIdx] = true
} }
} }
} }
if len(b.attacker) > 0 { if len(b.attacker) > 0 {
SingleBattle(g, b) SingleBattle(g, b)
b.ID = uuid.New()
result = append(result, b) result = append(result, b)
} }
@@ -203,7 +253,7 @@ func SingleBattle(g *Game, b *Battle) {
panic("SingleBattle: probability unexpected: value <= 0") panic("SingleBattle: probability unexpected: value <= 0")
} }
b.BattleReport.BattleAction = append(b.BattleReport.BattleAction, BattleAction{ b.Protocol = append(b.Protocol, BattleAction{
Attacker: attIdx, Attacker: attIdx,
Defenter: defIdx, Defenter: defIdx,
Destroyed: destroyed, Destroyed: destroyed,
@@ -220,6 +270,8 @@ func SingleBattle(g *Game, b *Battle) {
delete(b.attacker, attIdx) // Remove attacker if he lost all opponents delete(b.attacker, attIdx) // Remove attacker if he lost all opponents
} }
} }
// FIXME: удалять ShipGroups после генерирования пользовательского отчёта
// g.ShipGroups = append(g.ShipGroups[:defIdx], g.ShipGroups[defIdx+1:]...)
} }
if len(b.attacker) == 0 { if len(b.attacker) == 0 {
break break
@@ -245,3 +297,11 @@ func DestructionProbability(attWeapons, attWeaponsTech, defShields, defShiledsTe
func EffectiveDefence(defShields, defShiledsTech, defFullMass float64) float64 { func EffectiveDefence(defShields, defShiledsTech, defFullMass float64) float64 {
return defShields * defShiledsTech / math.Pow(defFullMass, 1./3.) * math.Pow(30., 1./3.) return defShields * defShiledsTech / math.Pow(defFullMass, 1./3.) * math.Pow(30., 1./3.)
} }
func (b BattleReport) MarshalBinary() (data []byte, err error) {
return json.Marshal(&b)
}
func (b *BattleReport) UnmarshalBinary(data []byte) error {
return json.Unmarshal(data, b)
}
+18
View File
@@ -1,6 +1,7 @@
package game_test package game_test
import ( import (
"slices"
"testing" "testing"
"github.com/iliadenisov/galaxy/internal/model/game" "github.com/iliadenisov/galaxy/internal/model/game"
@@ -161,3 +162,20 @@ func TestFilterBattleOpponents(t *testing.T) {
assert.True(t, game.FilterBattleOpponents(g, 1, 3, cacheShipGroupRaceID, cacheRelation, cacheShipClass, cacheProbability)) assert.True(t, game.FilterBattleOpponents(g, 1, 3, cacheShipGroupRaceID, cacheRelation, cacheShipClass, cacheProbability))
assert.NotContains(t, cacheProbability[1], 3) assert.NotContains(t, cacheProbability[1], 3)
} }
func TestSlicesDeleteFunc(t *testing.T) {
type Container struct {
S []int
}
c := Container{S: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}}
r1 := slices.DeleteFunc(c.S, func(e int) bool { return e%2 != 0 })
assert.Len(t, r1, 5)
for i := range r1 {
assert.Equal(t, i*2, r1[i], "elem #%d", i)
assert.Equal(t, i*2, c.S[i], "elem #%d", i)
}
assert.Len(t, c.S, 10)
for i := len(r1); i < len(c.S); i++ {
assert.Equal(t, 0, c.S[i], "elem #%d", i)
}
}
+27 -5
View File
@@ -2,10 +2,9 @@ package repo
/* /*
/state.json /state.json
/000/state.json /0001/state.json
/000/race/{UUID}/order/001.json /0001/race/{UUID}/report.json
/000/race/{UUID}/report.json /0001/battle/{UUID}.json
/000/battle/{planet_UUID}
*/ */
import ( import (
@@ -23,7 +22,7 @@ func (r *repo) SaveTurn(t uint, g *game.Game) error {
} }
func saveTurn(s Storage, t uint, g *game.Game) error { func saveTurn(s Storage, t uint, g *game.Game) error {
path := fmt.Sprintf("%03d/state.json", t) path := fmt.Sprintf("%s/state.json", turnDir(t))
exist, err := s.Exists(path) exist, err := s.Exists(path)
if err != nil { if err != nil {
return NewStorageError(err) return NewStorageError(err)
@@ -86,3 +85,26 @@ func loadState(s Storage, locked bool) (*game.Game, error) {
} }
return g, nil return g, nil
} }
func (r *repo) SaveBattle(t uint, b *game.BattleReport) error {
return saveBattle(r.s, t, b)
}
func saveBattle(s Storage, t uint, b *game.BattleReport) error {
path := fmt.Sprintf("%s/battle/%s.json", turnDir(t), b.ID)
exist, err := s.Exists(path)
if err != nil {
return NewStorageError(err)
}
if exist {
return NewStateError(fmt.Sprintf("battle %s for turn %d already has been saved", b.ID, t))
}
if err := s.Write(path, b); err != nil {
return NewStorageError(err)
}
return nil
}
func turnDir(t uint) string {
return fmt.Sprintf("%04d", t)
}