From 4c14234afb41343889754dab1cb30e0e18a909c2 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 30 Jan 2026 18:57:43 +0300 Subject: [PATCH] feat: store battles and bombings --- internal/controller/battle.go | 13 +++- internal/controller/battle_transform.go | 80 ++++++++++++++++------- internal/controller/bombing.go | 29 ++------ internal/controller/controller.go | 8 ++- internal/controller/generate_game.go | 2 +- internal/controller/generate_game_test.go | 2 +- internal/controller/generate_turn.go | 38 +++++++++-- internal/controller/ship_group.go | 1 + internal/game/cmd_turn.go | 2 +- internal/model/game/battle.go | 32 --------- internal/model/game/game.go | 23 ++++++- internal/model/game/group.go | 7 ++ internal/model/report/battle.go | 47 +++++++++++++ internal/model/report/bombing.go | 19 ++++++ internal/repo/game.go | 72 +++++++++++++++++--- internal/router/handler/status.go | 2 +- 16 files changed, 274 insertions(+), 103 deletions(-) delete mode 100644 internal/model/game/battle.go create mode 100644 internal/model/report/battle.go create mode 100644 internal/model/report/bombing.go diff --git a/internal/controller/battle.go b/internal/controller/battle.go index 9ab81a4..ce8b27f 100644 --- a/internal/controller/battle.go +++ b/internal/controller/battle.go @@ -14,6 +14,7 @@ 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 @@ -22,7 +23,7 @@ type Battle struct { type BattleAction struct { Attacker int - Defenter int + Defender int Destroyed bool } @@ -94,6 +95,11 @@ func ProduceBattles(c *Cache) []*Battle { result := make([]*Battle, 0) + // TODO: check this behavior: + // Multiple battles on single planet shoul be produced as single battle too: + // A <--> B + // C <--> D + // where: A in peace with [C, D], B in peace with [C, D], and so on. for pl, observerGroups := range planetGroups { battleGroups := FilterBattleGroups(c, observerGroups) b := &Battle{ @@ -102,6 +108,9 @@ func ProduceBattles(c *Cache) []*Battle { 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] @@ -159,7 +168,7 @@ func SingleBattle(c *Cache, b *Battle) { b.Protocol = append(b.Protocol, BattleAction{ Attacker: attIdx, - Defenter: defIdx, + Defender: defIdx, Destroyed: destroyed, }) diff --git a/internal/controller/battle_transform.go b/internal/controller/battle_transform.go index afafb9b..f222330 100644 --- a/internal/controller/battle_transform.go +++ b/internal/controller/battle_transform.go @@ -1,46 +1,80 @@ package controller -import "github.com/iliadenisov/galaxy/internal/model/game" +import ( + "github.com/google/uuid" + "github.com/iliadenisov/galaxy/internal/model/game" + "github.com/iliadenisov/galaxy/internal/model/report" +) -func TransformBattle(c *Cache, b *Battle) *game.BattleReport { - r := &game.BattleReport{ +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]string), - Ships: make(map[int]string), - Protocol: make([]game.BattleActionReport, len(b.Protocol)), + Races: make(map[int]uuid.UUID), + Ships: make(map[int]report.BattleReportGroup), + Protocol: make([]report.BattleActionReport, len(b.Protocol)), } - cacheShipClass := make(map[string]int) - cacheRaceName := make(map[string]int) + cacheShipClass := make(map[uuid.UUID]int) + cacheRaceName := make(map[uuid.UUID]int) - cacher := func(shipClass string, cache map[string]int) int { - if v, ok := cache[shipClass]; ok { + addShipGroup := func(groupId int, inBattle bool) int { + shipClass := c.ShipGroupShipClass(groupId) + sg := c.ShipGroup(groupId) + itemNumber := len(r.Ships) + r.Ships[itemNumber] = report.BattleReportGroup{ + OwnerID: sg.OwnerID, + InBattle: inBattle, + Number: b.initialNumbers[groupId], + NumberLeft: sg.Number, + ClassName: shipClass.Name, + LoadType: sg.CargoString(), + LoadQuantity: sg.Load, + Drive: sg.TechLevel(game.TechDrive), + Weapons: sg.TechLevel(game.TechWeapons), + Shields: sg.TechLevel(game.TechShields), + Cargo: sg.TechLevel(game.TechCargo), + } + 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 { - itemNumber := len(r.Ships) - r.Ships[itemNumber] = shipClass - cache[shipClass] = itemNumber + 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] = 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), + 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 name, index := range cacheRaceName { - r.Races[index] = name - } - for name, index := range cacheShipClass { - r.Ships[index] = name + for sgi, inBattle := range b.observerGroups { + if !inBattle { + addShipGroup(sgi, false) + } } return r diff --git a/internal/controller/bombing.go b/internal/controller/bombing.go index b168747..b20d1fe 100644 --- a/internal/controller/bombing.go +++ b/internal/controller/bombing.go @@ -3,36 +3,17 @@ package controller import ( "github.com/google/uuid" "github.com/iliadenisov/galaxy/internal/model/game" + "github.com/iliadenisov/galaxy/internal/model/report" ) -type BombingReport struct { - Planets []BombingPlanetReport `json:"planets"` -} - -type BombingPlanetReport struct { - ID uuid.UUID `json:"id"` - Planet string `json:"name"` - Number uint `json:"number"` - Owner string `json:"owner"` - Attacker string `json:"attacker"` - Production string `json:"production"` - Industry float64 `json:"industry"` // I - Промышленность - Population float64 `json:"population"` // P - Население - Colonists float64 `json:"colonists"` // COL C - Количество колонистов - Capital float64 `json:"capital"` // CAP $ - Запасы промышленности - Material float64 `json:"material"` // MAT M - Запасы ресурсов / сырья - AttackPower float64 `json:"attack"` - Wiped bool `json:"wiped"` -} - -func (c *Cache) bombingReport(p *game.Planet, ri int, groups []int) BombingPlanetReport { +func (c *Cache) bombingReport(p *game.Planet, ri int, groups []int) report.BombingPlanetReport { attackPower := 0. for _, i := range groups { sg := c.ShipGroup(i) st := c.ShipGroupShipClass(i) attackPower += sg.BombingPower(st) } - r := &BombingPlanetReport{ + r := &report.BombingPlanetReport{ ID: uuid.New(), Planet: p.Name, Number: p.Number, @@ -51,8 +32,8 @@ func (c *Cache) bombingReport(p *game.Planet, ri int, groups []int) BombingPlane return *r } -func (c *Cache) ProduceBombings() []BombingPlanetReport { - report := make([]BombingPlanetReport, 0) +func (c *Cache) ProduceBombings() []report.BombingPlanetReport { + report := make([]report.BombingPlanetReport, 0) for pn, enemies := range c.collectBombingGroups() { p := c.MustPlanet(pn) for ri, groups := range enemies { diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 93b072f..d294091 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/iliadenisov/galaxy/internal/model/game" + "github.com/iliadenisov/galaxy/internal/model/report" "github.com/iliadenisov/galaxy/internal/repo" ) @@ -26,8 +27,11 @@ type Repo interface { // LoadStateSafe retrieves game current state without preliminary locking LoadStateSafe() (*game.Game, error) - // SaveBattle stores - SaveBattle(t uint, b *game.BattleReport) error + // SaveBattle stores a new battle protocol and battle meta data for turn t + SaveBattle(t uint, b *report.BattleReport, m *game.BattleMeta) error + + // SaveBombing stores all prodused bombings for turn t + SaveBombings(t uint, b []report.BombingPlanetReport) error } type Controller struct { diff --git a/internal/controller/generate_game.go b/internal/controller/generate_game.go index 44b8cfa..f380716 100644 --- a/internal/controller/generate_game.go +++ b/internal/controller/generate_game.go @@ -41,7 +41,7 @@ func buildGameOnMap(races []string, m generator.Map) (*game.Game, error) { } g := &game.Game{ ID: gameID, - Age: 0, + Turn: 0, Race: make([]game.Race, len(races)), } gameMap := &game.Map{ diff --git a/internal/controller/generate_game_test.go b/internal/controller/generate_game_test.go index cc37be6..2ec7a69 100644 --- a/internal/controller/generate_game_test.go +++ b/internal/controller/generate_game_test.go @@ -35,7 +35,7 @@ func TestNewGame(t *testing.T) { g, err := r.LoadState() assert.NoError(t, err) assert.Equal(t, gameID, g.ID) - assert.Equal(t, uint(0), g.Age) + assert.Equal(t, uint(0), g.Turn) assert.Equal(t, players, len(g.Race)) for r := range g.Race { diff --git a/internal/controller/generate_turn.go b/internal/controller/generate_turn.go index 9619f43..36c579a 100644 --- a/internal/controller/generate_turn.go +++ b/internal/controller/generate_turn.go @@ -2,12 +2,16 @@ package controller import ( // "github.com/iliadenisov/galaxy/internal/game/battle" + "maps" + "slices" + + "github.com/google/uuid" "github.com/iliadenisov/galaxy/internal/model/game" ) -func MakeTurn(c *Controller, r Repo, g *game.Game) error { +func MakeTurn(c *Controller, r Repo) error { // Next turn - g.Age += 1 + c.Cache.g.Turn += 1 // 01. Корабли, где это возможно, объединяются в группы. c.Cache.TurnMergeEqualShipGroups() @@ -28,7 +32,7 @@ func MakeTurn(c *Controller, r Repo, g *game.Game) error { battles = append(battles, ProduceBattles(c.Cache)...) // 07. Корабли бомбят вражеские планеты. - _ = c.Cache.ProduceBombings() + bombings := c.Cache.ProduceBombings() // 08. На планетах строятся корабли. // 09. Корабли, где это возможно, объединяются в группы. @@ -49,12 +53,33 @@ func MakeTurn(c *Controller, r Repo, g *game.Game) error { /*** Last steps ***/ + // Store bombings + if len(bombings) > 0 { + if err := r.SaveBombings(c.Cache.g.Turn, bombings); err != nil { + return err + } + } + // Store battles if len(battles) > 0 { + battleMeta := make([]game.BattleMeta, len(battles)) 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 { + 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 := r.SaveBattle(c.Cache.g.Turn, report, &battleMeta[i]); err != nil { return err } } @@ -68,5 +93,6 @@ func MakeTurn(c *Controller, r Repo, g *game.Game) error { // TODO: Store individual reports _ = winners + // [ ] monitor memory consumption at this point? return nil } diff --git a/internal/controller/ship_group.go b/internal/controller/ship_group.go index ebafd55..0817a75 100644 --- a/internal/controller/ship_group.go +++ b/internal/controller/ship_group.go @@ -105,6 +105,7 @@ func (c *Cache) ShipGroupOwnerRace(groupIndex int) *game.Race { func (c *Cache) ShipGroupNumber(i int, n uint) { c.validateShipGroupIndex(i) c.g.ShipGroups[i].Number = n + // FIXME: cargo load must be decreased proportionally } func (c *Cache) DeleteShipGroup(i int) { diff --git a/internal/game/cmd_turn.go b/internal/game/cmd_turn.go index c4567b6..ce2a40e 100644 --- a/internal/game/cmd_turn.go +++ b/internal/game/cmd_turn.go @@ -7,7 +7,7 @@ import ( 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) { controller.MakeTurn(c, r, g) }) + c.ExecuteGame(func(r controller.Repo, g *game.Game) { controller.MakeTurn(c, r) }) }) return } diff --git a/internal/model/game/battle.go b/internal/model/game/battle.go deleted file mode 100644 index 89183ab..0000000 --- a/internal/model/game/battle.go +++ /dev/null @@ -1,32 +0,0 @@ -package game - -import ( - "encoding/json" - - "github.com/google/uuid" -) - -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"` -} - -func (b BattleReport) MarshalBinary() (data []byte, err error) { - return json.Marshal(&b) -} - -func (b *BattleReport) UnmarshalBinary(data []byte) error { - return json.Unmarshal(data, b) -} diff --git a/internal/model/game/game.go b/internal/model/game/game.go index 6d5b594..6369e50 100644 --- a/internal/model/game/game.go +++ b/internal/model/game/game.go @@ -6,6 +6,7 @@ import ( "maps" "github.com/google/uuid" + "github.com/iliadenisov/galaxy/internal/model/report" ) type TechSet map[Tech]float64 @@ -26,7 +27,7 @@ func (ts TechSet) Set(t Tech, v float64) TechSet { type Game struct { ID uuid.UUID `json:"id"` - Age uint `json:"turn"` // Game's turn number + Turn uint `json:"turn"` Map Map `json:"map"` Race []Race `json:"races"` Votes float64 `json:"votes"` @@ -34,6 +35,18 @@ type Game struct { Fleets []Fleet `json:"fleet,omitempty"` } +type GameMeta struct { + Battles []BattleMeta `json:"battles,omitempty"` + Bombings []report.BombingPlanetReport `json:"bombings,omitempty"` +} + +type BattleMeta struct { + Turn uint `json:"turn"` + Planet uint `json:"planet"` + BattleID uuid.UUID `json:"battle_id"` + ObserverIDs []uuid.UUID `json:"observer_ids"` +} + // TODO: remove if not needed func (g Game) RaceVotes(raceID uuid.UUID) float64 { var result float64 @@ -52,3 +65,11 @@ func (g Game) MarshalBinary() (data []byte, err error) { func (g *Game) UnmarshalBinary(data []byte) error { return json.Unmarshal(data, g) } + +func (b GameMeta) MarshalBinary() (data []byte, err error) { + return json.Marshal(&b) +} + +func (b *GameMeta) UnmarshalBinary(data []byte) error { + return json.Unmarshal(data, b) +} diff --git a/internal/model/game/group.go b/internal/model/game/group.go index 12d9e14..739b5e4 100644 --- a/internal/model/game/group.go +++ b/internal/model/game/group.go @@ -233,3 +233,10 @@ func (sg ShipGroup) BombingPower(st *ShipType) float64 { float64(st.Armament) * float64(sg.Number) } + +func (sg ShipGroup) CargoString() string { + if sg.CargoType == nil { + return "-" + } + return sg.CargoType.String() +} diff --git a/internal/model/report/battle.go b/internal/model/report/battle.go new file mode 100644 index 0000000..26bc1f3 --- /dev/null +++ b/internal/model/report/battle.go @@ -0,0 +1,47 @@ +package report + +import ( + "encoding/json" + + "github.com/google/uuid" +) + +type BattleReport struct { + ID uuid.UUID `json:"id"` + Turn uint `json:"turn"` + Planet uint `json:"planet"` + PlanetName string `json:"planet_name"` + Races map[int]uuid.UUID `json:"races"` + Ships map[int]BattleReportGroup `json:"ships"` + Protocol []BattleActionReport `json:"protocol"` +} + +type BattleReportGroup struct { + OwnerID uuid.UUID `json:"ownerId"` + InBattle bool `json:"inBattle"` + Number uint `json:"num"` + NumberLeft uint `json:"numLeft"` + ClassName string `json:"className"` + LoadType string `json:"loadType"` + LoadQuantity float64 `json:"loadQuantity"` + Drive float64 `json:"drive"` + Weapons float64 `json:"wwapons"` + Shields float64 `json:"shields"` + Cargo float64 `json:"cargo"` +} + +type BattleActionReport struct { + Attacker int `json:"a"` + AttackerShipClass int `json:"sa"` + Defender int `json:"d"` + DefenderShipClass int `json:"sd"` + Destroyed bool `json:"x"` +} + +func (b BattleReport) MarshalBinary() (data []byte, err error) { + return json.Marshal(&b) +} + +func (b *BattleReport) UnmarshalBinary(data []byte) error { + return json.Unmarshal(data, b) +} diff --git a/internal/model/report/bombing.go b/internal/model/report/bombing.go new file mode 100644 index 0000000..b79f716 --- /dev/null +++ b/internal/model/report/bombing.go @@ -0,0 +1,19 @@ +package report + +import "github.com/google/uuid" + +type BombingPlanetReport struct { + ID uuid.UUID `json:"id"` + Planet string `json:"name"` + Number uint `json:"number"` + Owner string `json:"owner"` + Attacker string `json:"attacker"` + Production string `json:"production"` + Industry float64 `json:"industry"` // I - Промышленность + Population float64 `json:"population"` // P - Население + Colonists float64 `json:"colonists"` // COL C - Количество колонистов + Capital float64 `json:"capital"` // CAP $ - Запасы промышленности + Material float64 `json:"material"` // MAT M - Запасы ресурсов / сырья + AttackPower float64 `json:"attack"` + Wiped bool `json:"wiped"` +} diff --git a/internal/repo/game.go b/internal/repo/game.go index 0b74119..1fbeae3 100644 --- a/internal/repo/game.go +++ b/internal/repo/game.go @@ -1,20 +1,25 @@ package repo /* - /state.json + TODO: only state will be saved once (current, turn); meta and bombings are saved at turn generation and saved twice + /state.json /0001/state.json - /0001/race/{UUID}/report.json + /0001/meta.json + /0001/bombing.json /0001/battle/{UUID}.json + /0001/report/{UUID}.json */ import ( "fmt" "github.com/iliadenisov/galaxy/internal/model/game" + "github.com/iliadenisov/galaxy/internal/model/report" ) const ( statePath = "state.json" + metaPath = "meta.json" ) func (r *repo) SaveTurn(t uint, g *game.Game) error { @@ -65,7 +70,7 @@ func (r *repo) LoadStateSafe() (*game.Game, error) { } func loadState(s Storage, locked bool) (*game.Game, error) { - var g *game.Game = new(game.Game) + var result *game.Game = new(game.Game) path := statePath exist, err := s.Exists(path) if err != nil { @@ -75,22 +80,62 @@ func loadState(s Storage, locked bool) (*game.Game, error) { return nil, NewGameNotInitializedError() } if locked { - if err := s.Read(path, g); err != nil { + if err := s.Read(path, result); err != nil { return nil, NewStorageError(err) } } else { - if err := s.ReadSafe(path, g); err != nil { + if err := s.ReadSafe(path, result); err != nil { return nil, NewStorageError(err) } } - return g, nil + return result, nil } -func (r *repo) SaveBattle(t uint, b *game.BattleReport) error { - return saveBattle(r.s, t, b) +func loadMeta(s Storage) (*game.GameMeta, error) { + var result *game.GameMeta = new(game.GameMeta) + path := metaPath + exist, err := s.Exists(path) + if err != nil { + return nil, NewStorageError(err) + } + if !exist { + return result, nil + } + // TODO: create separate Read func for meta ops + if err := s.ReadSafe(path, result); err != nil { + return nil, NewStorageError(err) + } + return result, nil } -func saveBattle(s Storage, t uint, b *game.BattleReport) error { +func saveMeta(s Storage, t uint, gm *game.GameMeta) error { + // save turn's meta + path := fmt.Sprintf("%s/%s", turnDir(t), metaPath) + if err := s.Write(path, gm); err != nil { + return NewStorageError(err) + } + // also save as latest meta + path = metaPath + if err := s.Write(path, gm); err != nil { + return NewStorageError(err) + } + return nil +} + +func (r *repo) SaveBattle(t uint, b *report.BattleReport, m *game.BattleMeta) error { + meta, err := loadMeta(r.s) + if err != nil { + return err + } + err = saveBattle(r.s, t, b) + if err != nil { + return err + } + meta.Battles = append(meta.Battles, *m) + return saveMeta(r.s, t, meta) +} + +func saveBattle(s Storage, t uint, b *report.BattleReport) error { path := fmt.Sprintf("%s/battle/%s.json", turnDir(t), b.ID) exist, err := s.Exists(path) if err != nil { @@ -105,6 +150,15 @@ func saveBattle(s Storage, t uint, b *game.BattleReport) error { return nil } +func (r *repo) SaveBombings(t uint, b []report.BombingPlanetReport) error { + meta, err := loadMeta(r.s) + if err != nil { + return err + } + meta.Bombings = b + return saveMeta(r.s, t, meta) +} + func turnDir(t uint) string { return fmt.Sprintf("%04d", t) } diff --git a/internal/router/handler/status.go b/internal/router/handler/status.go index 8c439ca..eafd3f0 100644 --- a/internal/router/handler/status.go +++ b/internal/router/handler/status.go @@ -17,7 +17,7 @@ func StatusHandler(c *gin.Context, config controller.Config) { } c.JSON(http.StatusOK, rest.Status{ - Turn: g.Age, + Turn: g.Turn, Players: len(g.Race), }) }