b4abf90ec5
Per the documented turn order (game/rules.txt "Последовательность действий"), no ship should dodge the pre-departure battle by slipping into hyperspace. MakeTurn now runs merge -> battle -> load+launch routed groups -> fly -> merge -> battle, so: - ships ordered to depart (Launched) and ships being upgraded now take part in the pre-departure battle at their planet (CollectPlanetGroups / FilterBattleGroups); only survivors then enter hyperspace; - routed transports are loaded and launched AFTER that battle, so they fight empty and cannot escape it. A just-launched group has no stored hyperspace position, so moveShipGroup starts its first leg from the origin planet; the previous code read the nil launch coordinate and would panic. Because upgrading groups can now lose ships in the battle, the pending upgrade cost is recomputed from the group's current ship count instead of the value stored when the order was validated. Rules: reordered "Последовательность действий" and rewrote the combat note that ordered/routed ships skip the battle. Tests: launched-group move from origin, launched/upgrade groups taking part in battle, upgrade cost tracking ship losses. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
282 lines
7.9 KiB
Go
282 lines
7.9 KiB
Go
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() {
|
|
sg := c.ShipGroup(groupIndex)
|
|
var planetNumber uint
|
|
switch sg.State() {
|
|
case game.StateInOrbit, game.StateUpgrade:
|
|
planetNumber = sg.Destination
|
|
case game.StateLaunched:
|
|
// Ordered to depart but still physically at the origin planet, so
|
|
// it joins the pre-departure battle there; only survivors then
|
|
// enter hyperspace.
|
|
planetNumber = sg.StateInSpace.Origin
|
|
default:
|
|
continue
|
|
}
|
|
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 {
|
|
// Everything physically present at the planet fights: ships in orbit,
|
|
// ships being upgraded, and ships ordered to depart that have not yet
|
|
// entered hyperspace (Launched). Only ships already in hyperspace are
|
|
// out of reach.
|
|
switch c.ShipGroup(groupIndex).State() {
|
|
case game.StateInOrbit, game.StateUpgrade, game.StateLaunched:
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
})
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|