Merge pull request 'fix(game): resolve battles ship by ship, matching the combat rules' (#76) from feature/game-combat-correctness into development
Deploy · Dev / deploy (push) Successful in 47s
Tests · Go / test (push) Successful in 2m3s
Tests · Integration / integration (push) Successful in 1m46s

This commit was merged in pull request #76.
This commit is contained in:
2026-05-30 22:06:44 +00:00
3 changed files with 160 additions and 62 deletions
+97 -58
View File
@@ -1,7 +1,6 @@
package controller package controller
import ( import (
"iter"
"maps" "maps"
"math/rand/v2" "math/rand/v2"
"slices" "slices"
@@ -19,8 +18,9 @@ type Battle struct {
InitialNumbers map[int]uint // Initial number of ships in the group InitialNumbers map[int]uint // Initial number of ships in the group
Protocol []BattleAction Protocol []BattleAction
shipAmmo map[int]uint // attacker maps an attacking group to every opponent group it is able to
attacker map[int]map[int]float64 // a group able to attack and destroy an opponent with some probability > 0 // destroy, with that pair's per-ship destruction probability (> 0).
attacker map[int]map[int]float64
} }
type BattleAction struct { type BattleAction struct {
@@ -65,12 +65,20 @@ func FilterBattleOpponents(c *Cache, attIdx, defIdx int, cacheProbability map[in
return true 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( p := calc.DestructionProbability(
c.ShipGroupShipClass(attIdx).Weapons.F(), c.ShipGroupShipClass(attIdx).Weapons.F(),
c.ShipGroup(attIdx).TechLevel(game.TechWeapons).F(), c.ShipGroup(attIdx).TechLevel(game.TechWeapons).F(),
c.ShipGroupShipClass(defIdx).Shields.F(), defSt.Shields.F(),
c.ShipGroup(defIdx).TechLevel(game.TechShields).F(), defSg.TechLevel(game.TechShields).F(),
c.ShipGroup(defIdx).FullMass(c.ShipGroupShipClass(defIdx)), defSg.FullMass(defSt)/float64(defSg.Number),
) )
// Exclude opponent's group which cannot be probably destroyed // Exclude opponent's group which cannot be probably destroyed
if p <= 0 { if p <= 0 {
@@ -108,7 +116,6 @@ func ProduceBattles(c *Cache) []*Battle {
ObserverGroups: observerGroups, ObserverGroups: observerGroups,
InitialNumbers: make(map[int]uint), InitialNumbers: make(map[int]uint),
attacker: make(map[int]map[int]float64), attacker: make(map[int]map[int]float64),
shipAmmo: make(map[int]uint),
} }
for sgi := range observerGroups { for sgi := range observerGroups {
b.InitialNumbers[sgi] = c.ShipGroup(sgi).Number b.InitialNumbers[sgi] = c.ShipGroup(sgi).Number
@@ -126,12 +133,11 @@ func ProduceBattles(c *Cache) []*Battle {
return FilterBattleOpponents(c, attIdx, defIdx, cacheProbability) return FilterBattleOpponents(c, attIdx, defIdx, cacheProbability)
}) })
if len(opponents) > 0 { if len(opponents) > 0 {
b.shipAmmo[attIdx] = c.ShipGroupShipClass(attIdx).Armament
b.ObserverGroups[attIdx] = true b.ObserverGroups[attIdx] = true
for _, defIdx := range opponents { if b.attacker[attIdx] == nil {
if _, ok := b.attacker[attIdx][defIdx]; !ok {
b.attacker[attIdx] = make(map[int]float64) b.attacker[attIdx] = make(map[int]float64)
} }
for _, defIdx := range opponents {
b.attacker[attIdx][defIdx] = cacheProbability[attIdx][defIdx] b.attacker[attIdx][defIdx] = cacheProbability[attIdx][defIdx]
b.ObserverGroups[defIdx] = true b.ObserverGroups[defIdx] = true
} }
@@ -145,78 +151,111 @@ func ProduceBattles(c *Cache) []*Battle {
} }
clear(b.attacker) clear(b.attacker)
clear(b.shipAmmo)
} }
return result 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) { func SingleBattle(c *Cache, b *Battle) {
roundShooters := make(map[int]bool) for {
for len(b.attacker) > 0 { // Snapshot this round's shooters: one entry per living ship of every
// список участников раунда // group that still has destroyable opponents.
clear(roundShooters) shooters := make([]int, 0)
for sgi := range b.attacker { for attIdx := range b.attacker {
roundShooters[sgi] = true for range c.ShipGroup(attIdx).Number {
shooters = append(shooters, attIdx)
} }
for len(roundShooters) > 0 {
// attacke group id among round participants
attIdx := randomValue(maps.Keys(roundShooters))
delete(roundShooters, attIdx)
for range b.shipAmmo[attIdx] {
// defender group id among all attacker's opponents
defIdx := randomValue(maps.Keys(b.attacker[attIdx]))
destroyed := false
probability := b.attacker[attIdx][defIdx]
switch {
case probability >= 1:
destroyed = true
case probability > 0:
destroyed = rand.Float64() >= probability
default:
panic("SingleBattle: probability unexpected: value <= 0")
} }
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{ b.Protocol = append(b.Protocol, BattleAction{
Attacker: attIdx, Attacker: attIdx,
Defender: defIdx, Defender: defIdx,
Destroyed: destroyed, Destroyed: destroyed,
}) })
if destroyed { if destroyed {
c.ShipGroupDestroyItem(defIdx) c.ShipGroupDestroyItem(defIdx)
}
if c.ShipGroup(defIdx).Number == 0 { if c.ShipGroup(defIdx).Number == 0 {
// Eliminated group cant attack anyone b.removeFromBattle(defIdx)
delete(b.attacker, defIdx) }
delete(roundShooters, 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 { for attIdx := range b.attacker {
// Other attackers can't attack eliminated group anymore delete(b.attacker[attIdx], groupIdx)
delete(b.attacker[attIdx], defIdx)
if len(b.attacker[attIdx]) == 0 { if len(b.attacker[attIdx]) == 0 {
// Remove attacker if he lost all opponents
delete(b.attacker, attIdx) delete(b.attacker, attIdx)
delete(roundShooters, attIdx)
} }
} }
} }
// When attacker has no more targets to shoot - break its ammo cycle func destructionRoll(probability float64) bool {
if len(b.attacker[attIdx]) == 0 { switch {
break case probability >= 1:
return true
case probability > 0:
return rand.Float64() < probability
default:
panic("SingleBattle: probability unexpected: value <= 0")
} }
} }
}
}
}
func randomValue(v iter.Seq[int]) int {
ids := slices.Collect(v)
return ids[rand.IntN(len(ids))]
}
+56 -1
View File
@@ -108,7 +108,11 @@ func TestFilterBattleOpponents(t *testing.T) {
assert.False(t, controller.FilterBattleOpponents(c, 0, 2, cacheProbability)) assert.False(t, controller.FilterBattleOpponents(c, 0, 2, cacheProbability))
assert.Contains(t, cacheProbability, 0) assert.Contains(t, cacheProbability, 0)
assert.Contains(t, cacheProbability[0], 2) assert.Contains(t, cacheProbability[0], 2)
assert.InDelta(t, 0.396222, cacheProbability[0][2], 0.0000001) // 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.False(t, controller.FilterBattleOpponents(c, 2, 0, cacheProbability))
assert.Contains(t, cacheProbability, 2) assert.Contains(t, cacheProbability, 2)
assert.Contains(t, cacheProbability[2], 0) assert.Contains(t, cacheProbability[2], 0)
@@ -271,3 +275,54 @@ func TestTransformBattleAggregatesSameShipClass(t *testing.T) {
gunship1.Number, gunship1.NumberLeft) 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")
}
@@ -149,3 +149,7 @@ func (c *Cache) WipeRace(ri int) {
func (c *Cache) UnsafeDeleteShipGroup(sgi int) { func (c *Cache) UnsafeDeleteShipGroup(sgi int) {
c.unsafeDeleteShipGroup(sgi) c.unsafeDeleteShipGroup(sgi)
} }
func DestructionRoll(probability float64) bool {
return destructionRoll(probability)
}