package controller import ( "maps" "math/rand/v2" "slices" "galaxy/calc" "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 // attacker maps an attacking group to every opponent group it is able to // destroy, with that pair's per-ship destruction probability (> 0). attacker map[int]map[int]float64 } 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 } defSg := c.ShipGroup(defIdx) if defSg.Number == 0 { return true } defSt := c.ShipGroupShipClass(defIdx) // The shot targets a single enemy ship, so the defending mass is the // per-ship full mass: a group's full mass spreads evenly across its // ships, hence FullMass / Number, not the whole group's mass. p := calc.DestructionProbability( c.ShipGroupShipClass(attIdx).Weapons.F(), c.ShipGroup(attIdx).TechLevel(game.TechWeapons).F(), defSt.Shields.F(), defSg.TechLevel(game.TechShields).F(), defSg.FullMass(defSt)/float64(defSg.Number), ) // 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), } 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.ObserverGroups[attIdx] = true if b.attacker[attIdx] == nil { b.attacker[attIdx] = make(map[int]float64) } 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) } return result } // SingleBattle resolves one battle ship by ship. In every round each still // living ship gets to fire, chosen in random order across all groups; a ship // fires all of its guns (Armament), each at a randomly chosen enemy ship it is // able to destroy. A ship destroyed before its turn comes does not fire. The // battle ends once no ship can damage any remaining enemy. func SingleBattle(c *Cache, b *Battle) { for { // Snapshot this round's shooters: one entry per living ship of every // group that still has destroyable opponents. shooters := make([]int, 0) for attIdx := range b.attacker { for range c.ShipGroup(attIdx).Number { shooters = append(shooters, attIdx) } } if len(shooters) == 0 { return } rand.Shuffle(len(shooters), func(i, j int) { shooters[i], shooters[j] = shooters[j], shooters[i] }) // fired counts, per group, how many of its ships have already shot // this round; a token beyond the group's current (post-casualty) ship // count belongs to a ship destroyed earlier in the round and is skipped. fired := make(map[int]uint) progressed := false for _, attIdx := range shooters { if fired[attIdx] >= c.ShipGroup(attIdx).Number { continue } fired[attIdx]++ for range c.ShipGroupShipClass(attIdx).Armament { defIdx, ok := c.pickTargetShip(b, attIdx) if !ok { break } progressed = true destroyed := destructionRoll(b.attacker[attIdx][defIdx]) b.Protocol = append(b.Protocol, BattleAction{ Attacker: attIdx, Defender: defIdx, Destroyed: destroyed, }) if destroyed { c.ShipGroupDestroyItem(defIdx) if c.ShipGroup(defIdx).Number == 0 { b.removeFromBattle(defIdx) } } } } // No shooter found a target this round: every remaining opponent is // out of reach, the battle is over. if !progressed { return } } } // pickTargetShip selects a random enemy ship for attacker group attIdx among // the groups it is able to destroy, weighted by each group's current ship // count so that every living enemy ship is equally likely to be hit. func (c *Cache) pickTargetShip(b *Battle, attIdx int) (int, bool) { opponents := b.attacker[attIdx] var total uint for defIdx := range opponents { total += c.ShipGroup(defIdx).Number } if total == 0 { return 0, false } r := rand.IntN(int(total)) for defIdx := range opponents { r -= int(c.ShipGroup(defIdx).Number) if r < 0 { return defIdx, true } } return 0, false } // removeFromBattle drops an eliminated group: it can no longer attack, and no // one can target it. Attackers left without any opponent are removed as well. func (b *Battle) removeFromBattle(groupIdx int) { delete(b.attacker, groupIdx) for attIdx := range b.attacker { delete(b.attacker[attIdx], groupIdx) if len(b.attacker[attIdx]) == 0 { delete(b.attacker, attIdx) } } } func destructionRoll(probability float64) bool { switch { case probability >= 1: return true case probability > 0: return rand.Float64() < probability default: panic("SingleBattle: probability unexpected: value <= 0") } }