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>
382 lines
16 KiB
Go
382 lines
16 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")
|
|
}
|
|
|
|
// TestCollectPlanetGroupsIncludesLaunchedAndUpgrade checks that every group
|
|
// physically at a planet — in orbit, being upgraded, or ordered to depart but
|
|
// not yet flown (Launched) — is collected for, and kept in, the battle.
|
|
func TestCollectPlanetGroupsIncludesLaunchedAndUpgrade(t *testing.T) {
|
|
c, _ := newCache()
|
|
// group 0: in orbit at Planet_0
|
|
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 1))
|
|
// group 1: ordered to depart Planet_0 (Launched), still physically there
|
|
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1))
|
|
c.ShipGroup(1).StateInSpace = &game.InSpace{Origin: R0_Planet_0_num}
|
|
c.ShipGroup(1).Destination = R0_Planet_2_num
|
|
assert.Equal(t, game.StateLaunched, c.ShipGroup(1).State())
|
|
// group 2: being upgraded at Planet_0
|
|
assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1))
|
|
c.ShipGroup(2).StateUpgrade = &game.InUpgrade{UpgradeTech: []game.UpgradePreference{{Tech: game.TechDrive, Level: 2.0, Cost: 100}}}
|
|
assert.Equal(t, game.StateUpgrade, c.ShipGroup(2).State())
|
|
|
|
pg := controller.CollectPlanetGroups(c)
|
|
assert.Contains(t, pg, R0_Planet_0_num)
|
|
assert.Len(t, pg[R0_Planet_0_num], 3)
|
|
for _, idx := range []int{0, 1, 2} {
|
|
assert.Contains(t, pg[R0_Planet_0_num], idx)
|
|
}
|
|
battleGroups := controller.FilterBattleGroups(c, pg[R0_Planet_0_num])
|
|
assert.Len(t, battleGroups, 3)
|
|
}
|
|
|
|
// TestProduceBattlesLaunchedFightsAtOrigin checks that a group ordered to
|
|
// depart (Launched) still fights the pre-departure battle at its origin
|
|
// planet, rather than escaping into hyperspace before the fight.
|
|
func TestProduceBattlesLaunchedFightsAtOrigin(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()))
|
|
|
|
// Race_0: armed group in orbit at Planet_0.
|
|
assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10))
|
|
// Race_1: armed group ordered to depart Planet_0 (Launched), still there.
|
|
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R0_Planet_0_num, 10)
|
|
c.ShipGroup(1).StateInSpace = &game.InSpace{Origin: R0_Planet_0_num}
|
|
c.ShipGroup(1).Destination = R0_Planet_2_num
|
|
assert.Equal(t, game.StateLaunched, c.ShipGroup(1).State())
|
|
|
|
battles := controller.ProduceBattles(c)
|
|
assert.Len(t, battles, 1)
|
|
assert.True(t, battles[0].ObserverGroups[1], "launched group must be marked in-battle")
|
|
if c.ShipGroup(0).Number == 0 {
|
|
assert.Greater(t, c.ShipGroup(1).Number, uint(0))
|
|
} else {
|
|
assert.Zero(t, c.ShipGroup(1).Number)
|
|
}
|
|
}
|