Files
galaxy-game/game/internal/controller/battle_test.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

329 lines
13 KiB
Go

package controller_test
import (
"maps"
"slices"
"testing"
"galaxy/calc"
"galaxy/game/internal/controller"
"galaxy/game/internal/model/game"
"galaxy/model/report"
"github.com/stretchr/testify/assert"
)
var (
attacker = game.ShipType{
Name: "Attacker",
Drive: 8,
Armament: 1,
Weapons: 8,
Shields: 8,
Cargo: 0,
}
defender = game.ShipType{
Name: "Defender",
Drive: 1,
Armament: 1,
Weapons: 1,
Shields: 1,
Cargo: 0,
}
ship = game.ShipType{
Name: "Ship",
Drive: 10,
Armament: 1,
Weapons: 10,
Shields: 10,
Cargo: 0,
}
)
func TestDestructionProbability(t *testing.T) {
probability := calc.DestructionProbability(ship.Weapons.F(), 1, ship.Shields.F(), 1, ship.EmptyMass())
assert.Equal(t, .5, probability)
undefeatedShip := ship
undefeatedShip.Shields = 55
probability = calc.DestructionProbability(ship.Weapons.F(), 1, undefeatedShip.Shields.F(), 1, undefeatedShip.EmptyMass())
assert.LessOrEqual(t, probability, 0.)
disruptiveShip := ship
disruptiveShip.Weapons = 40
probability = calc.DestructionProbability(disruptiveShip.Weapons.F(), 1, ship.Shields.F(), 1, ship.EmptyMass())
assert.GreaterOrEqual(t, probability, 1.)
}
func TestEffectiveDefence(t *testing.T) {
assert.Equal(t, 10., calc.EffectiveDefence(ship.Shields.F(), 1, ship.EmptyMass()))
attackerEffectiveDefence := calc.EffectiveDefence(attacker.Shields.F(), 1, attacker.EmptyMass())
defenderEffectiveDefence := calc.EffectiveDefence(defender.Shields.F(), 1, defender.EmptyMass())
// attacker's effective shields must be 'just' 4 times greater than defender's
assert.InDelta(t, defenderEffectiveDefence*4, attackerEffectiveDefence, 0)
}
func TestCollectPlanetGroups(t *testing.T) {
c, _ := newCache()
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10)) // 1 #0
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 3)) // 2 #1
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 3)) // 3 #2
c.ShipGroup(2).StateInSpace = &InSpace // 3 #2 -> In_Space
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1)) // 4 #3
c.ShipGroup(3).Destination = R1_Planet_1_num // 4 #3 -> Planet_1
assert.NoError(t, c.CreateShips(Race_1_idx, Race_1_Gunship, R1_Planet_1_num, 15)) // 5 #4
c.ShipGroup(4).Destination = R0_Planet_0_num // 5 #4 -> Planet_0
planetGroups := controller.CollectPlanetGroups(c)
for pl := range planetGroups {
switch pl {
case R0_Planet_0_num:
assert.Equal(t, 3, len(planetGroups[pl]))
assert.Contains(t, planetGroups[pl], 0)
assert.Contains(t, planetGroups[pl], 1)
assert.Contains(t, planetGroups[pl], 4)
default:
assert.Fail(t, "planet #%d should not contain groups for battle", pl)
}
}
}
func TestFilterBattleOpponents(t *testing.T) {
c, _ := newCache()
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 1)) // 0
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1)) // 1
assert.NoError(t, c.CreateShips(Race_1_idx, Race_1_Gunship, R1_Planet_1_num, 15)) // 2
undefeatedShip := ship
undefeatedShip.Shields = 100
assert.NoError(t, c.ShipClassCreate(Race_1_idx, undefeatedShip.Name, undefeatedShip.Drive.F(), int(undefeatedShip.Armament), undefeatedShip.Weapons.F(), undefeatedShip.Shields.F(), undefeatedShip.Cargo.F()))
assert.NoError(t, c.CreateShips(Race_1_idx, undefeatedShip.Name, R1_Planet_1_num, 1)) // 3
cacheProbability := make(map[int]map[int]float64)
assert.False(t, controller.FilterBattleOpponents(c, 0, 2, cacheProbability))
assert.Contains(t, cacheProbability, 0)
assert.Contains(t, cacheProbability[0], 2)
// Group 2 holds 15 ships, but a shot targets a single ship, so the
// defending mass is the per-ship full mass (group mass / 15), which
// yields a far lower destruction probability than the pre-fix group-mass
// calculation (which read ~0.396).
assert.InDelta(t, 0.07064783, cacheProbability[0][2], 0.0000001)
assert.False(t, controller.FilterBattleOpponents(c, 2, 0, cacheProbability))
assert.Contains(t, cacheProbability, 2)
assert.Contains(t, cacheProbability[2], 0)
assert.InDelta(t, 0.495, cacheProbability[2][0], 0.0001)
// Test: same owner
assert.True(t, controller.FilterBattleOpponents(c, 0, 0, cacheProbability))
assert.True(t, controller.FilterBattleOpponents(c, 0, 1, cacheProbability))
assert.True(t, controller.FilterBattleOpponents(c, 1, 0, cacheProbability))
// Test: reace reations
assert.NoError(t, c.UpdateRelation(Race_0_idx, Race_1_idx, game.RelationPeace))
assert.True(t, controller.FilterBattleOpponents(c, 0, 2, cacheProbability))
assert.True(t, controller.FilterBattleOpponents(c, 2, 0, cacheProbability))
assert.NoError(t, c.UpdateRelation(Race_0_idx, Race_1_idx, game.RelationWar))
assert.LessOrEqual(t, calc.DestructionProbability(Cruiser.Weapons.F(), 1, undefeatedShip.Shields.F(), 1, undefeatedShip.EmptyMass()), 0.)
assert.True(t, controller.FilterBattleOpponents(c, 1, 3, cacheProbability))
assert.NotContains(t, cacheProbability[1], 3)
}
func TestProduceBattles(t *testing.T) {
c, g := newCache()
race_C_name, race_D_name := "Race_C", "Race_D"
race_C_idx, _ := c.AddRace(race_C_name)
race_D_idx, _ := c.AddRace(race_D_name)
assert.NoError(t, g.RaceRelation(Race_0.Name, Race_1.Name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(race_C_name, race_D_name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(race_D_name, race_C_name, game.RelationWar.String()))
assert.Equal(t, game.RelationPeace, c.Relation(Race_0_idx, race_C_idx))
assert.Equal(t, game.RelationPeace, c.Relation(Race_1_idx, race_C_idx))
assert.Equal(t, game.RelationPeace, c.Relation(Race_0_idx, race_D_idx))
assert.Equal(t, game.RelationPeace, c.Relation(Race_1_idx, race_D_idx))
// Race_0
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
// Race_1
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R0_Planet_0_num, 11)
// Race_C
assert.NoError(t, c.ShipClassCreate(race_C_idx, Cruiser.Name, Cruiser.Drive.F(), int(Cruiser.Armament), Cruiser.Weapons.F(), Cruiser.Shields.F(), Cruiser.Cargo.F()))
c.CreateShipsUnsafe_T(race_C_idx, c.MustShipClass(race_C_idx, Cruiser.Name).ID, R0_Planet_0_num, 12)
// Race_D
assert.NoError(t, c.ShipClassCreate(race_D_idx, Cruiser.Name, Cruiser.Drive.F(), int(Cruiser.Armament), Cruiser.Weapons.F(), Cruiser.Shields.F(), Cruiser.Cargo.F()))
c.CreateShipsUnsafe_T(race_D_idx, c.MustShipClass(race_D_idx, Cruiser.Name).ID, R0_Planet_0_num, 13)
battle := controller.ProduceBattles(c)
assert.Len(t, battle, 1)
b := battle[0]
assert.Equal(t, R0_Planet_0_num, b.Planet)
assert.Len(t, b.ObserverGroups, 4)
assert.Len(t, b.InitialNumbers, 4)
assert.ElementsMatch(t, slices.Collect(maps.Keys(b.ObserverGroups)), slices.Collect(maps.Keys(b.InitialNumbers)))
assert.Equal(t, 10, int(b.InitialNumbers[0]))
assert.Equal(t, 11, int(b.InitialNumbers[1]))
assert.Equal(t, 12, int(b.InitialNumbers[2]))
assert.Equal(t, 13, int(b.InitialNumbers[3]))
if c.ShipGroup(0).Number == 0 {
assert.Greater(t, c.ShipGroup(1).Number, uint(0))
} else {
assert.Zero(t, c.ShipGroup(1).Number)
}
if c.ShipGroup(2).Number == 0 {
assert.Greater(t, c.ShipGroup(3).Number, uint(0))
} else {
assert.Zero(t, c.ShipGroup(3).Number)
}
}
// TestTransformBattleAggregatesSameShipClass guards against the
// engine-side variant of the duplicate-class bug. Several ShipGroups
// of the same ShipClass.ID can take part in the same battle (arrivals
// from different planets, tech splits, etc.); they must collapse into
// a single BattleReportGroup with summed Number and NumberLeft. The
// pre-fix engine cached the first group's index and silently dropped
// every subsequent group's initial / survivor counts, which manifested
// downstream as more Destroyed shots in the protocol than the
// recorded initial roster could account for.
func TestTransformBattleAggregatesSameShipClass(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.RaceRelation(Race_0.Name, Race_1.Name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
// Two Race_0 groups of the SAME ship class (Race_0_Gunship) plus
// one Race_1 group of Race_1_Gunship — all parked on Planet_0
// (owned by Race_0; the Race_1 group lands there via the Unsafe
// helper that bypasses the ownership check). Group indices land
// at 0, 1, 2 in creation order.
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R0_Planet_0_num, 5)
// Simulate post-battle survivor counts: Group 0 ended the battle
// with 8 ships, Group 1 with 6. The aggregated BattleReportGroup
// must report NumberLeft = 8 + 6 = 14 (not just the last cached
// group's 6 — that's the regression).
c.ShipGroup(0).Number = 8
c.ShipGroup(1).Number = 6
b := &controller.Battle{
Planet: R0_Planet_0_num,
ObserverGroups: map[int]bool{0: true, 1: true, 2: true},
InitialNumbers: map[int]uint{0: 10, 1: 10, 2: 5},
// Protocol must reference every in-battle group at least once
// (otherwise TransformBattle won't register it through the
// `ship()` path). Two shots from Race_1 against each Race_0
// group hits both groupIds.
Protocol: []controller.BattleAction{
{Attacker: 2, Defender: 0, Destroyed: true},
{Attacker: 2, Defender: 1, Destroyed: true},
},
}
r := controller.TransformBattle(c, b)
// Two BattleReportGroup entries total: one merged Race_0_Gunship
// (groups 0 + 1) and one Race_1_Gunship. NOT three.
if got, want := len(r.Ships), 2; got != want {
t.Fatalf("len(r.Ships) = %d, want %d (duplicate ShipClass.ID must merge)", got, want)
}
var gunship0, gunship1 *report.BattleReportGroup
for i := range r.Ships {
grp := r.Ships[i]
switch grp.Race {
case Race_0.Name:
gunship0 = &grp
case Race_1.Name:
gunship1 = &grp
}
}
if gunship0 == nil || gunship1 == nil {
t.Fatalf("missing race entry: race0=%v race1=%v", gunship0, gunship1)
}
if gunship0.ClassName != Race_0_Gunship {
t.Errorf("race0.ClassName = %q, want %q", gunship0.ClassName, Race_0_Gunship)
}
if gunship0.Number != 20 {
t.Errorf("race0.Number = %d, want 20 (10+10)", gunship0.Number)
}
if gunship0.NumberLeft != 14 {
t.Errorf("race0.NumberLeft = %d, want 14 (8+6)", gunship0.NumberLeft)
}
if !gunship0.InBattle {
t.Errorf("race0.InBattle = false, want true (both source groups were in-battle)")
}
if gunship1.Number != 5 || gunship1.NumberLeft != 5 {
t.Errorf("race1 = (Number=%d, NumberLeft=%d), want (5, 5)",
gunship1.Number, gunship1.NumberLeft)
}
}
// TestDestructionRollDirection guards the corrected probability application:
// a shot destroys its target with probability p, not 1-p. The pre-fix code
// compared rand >= p, which inverted the rate (a near-certain hit became a
// near-certain miss).
func TestDestructionRollDirection(t *testing.T) {
const trials = 100000
for _, p := range []float64{0.1, 0.5, 0.9} {
hits := 0
for range trials {
if controller.DestructionRoll(p) {
hits++
}
}
rate := float64(hits) / float64(trials)
assert.InDelta(t, p, rate, 0.02, "destruction rate must track p=%.2f, not 1-p", p)
}
assert.True(t, controller.DestructionRoll(1.0))
assert.True(t, controller.DestructionRoll(1.5))
assert.Panics(t, func() { controller.DestructionRoll(0) })
assert.Panics(t, func() { controller.DestructionRoll(-0.1) })
}
// TestSingleBattleOneSidedWipe checks the per-ship model end to end: a group
// of armed ships that always destroys its target wipes a larger group of
// unarmed transports that cannot fire back, while every shot in the protocol
// is accounted for and the attacker takes no losses.
func TestSingleBattleOneSidedWipe(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.RaceRelation(Race_0.Name, Race_1.Name, game.RelationWar.String()))
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
// Killer's effective attack overwhelms the freighter's shields, so every
// shot destroys (probability >= 1).
assert.NoError(t, c.ShipClassCreate(Race_0_idx, "Killer", 10, 1, 40, 10, 0))
assert.NoError(t, c.CreateShips(Race_0_idx, "Killer", R0_Planet_0_num, 3)) // group 0
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Freighter).ID, R0_Planet_0_num, 5) // group 1
battles := controller.ProduceBattles(c)
assert.Len(t, battles, 1)
assert.Zero(t, c.ShipGroup(1).Number, "all unarmed transports must be destroyed")
assert.Equal(t, uint(3), c.ShipGroup(0).Number, "unarmed transports cannot retaliate")
kills := 0
for _, a := range battles[0].Protocol {
if a.Destroyed {
kills++
}
}
assert.Equal(t, 5, kills, "exactly the five transports are destroyed")
}