Files
galaxy-game/game/internal/controller/battle.go
T
Ilia Denisov cc67364113
Tests · Go / test (push) Successful in 2m2s
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m0s
fix(game): resolve battles ship by ship, matching the combat rules
The battle engine diverged from the documented combat model
(game/rules.txt "Сражения") in three ways:

- the destruction roll was inverted (rand >= p), so a near-certain hit
  destroyed its target only ~(1-p) of the time;
- a whole group fired as a single ship (Armament shots per round)
  regardless of its ship count, so fleet size never affected offence;
- the defending mass used the whole group's full mass instead of one
  target ship's, weakening grouped ships' shields by ~Number^(1/3).

SingleBattle now resolves ship by ship: every living ship fires once per
round in random order across all groups, each gun targets a random enemy
ship (weighted by group size), and the destruction roll matches the
documented probability. FilterBattleOpponents evaluates per-ship mass.

Also fixes opponent-map initialisation in ProduceBattles that kept only
an attacker's last opponent.

The rules already describe this model, so no documentation change is
needed. Tests: per-ship one-sided wipe, destruction-roll direction, and
the updated per-ship-mass probability expectation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:57:27 +02:00

262 lines
7.4 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() {
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")
}
}