fix(game): resolve battles ship by ship, matching the combat rules #76
@@ -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
|
||||||
|
if b.attacker[attIdx] == nil {
|
||||||
|
b.attacker[attIdx] = make(map[int]float64)
|
||||||
|
}
|
||||||
for _, defIdx := range opponents {
|
for _, defIdx := range opponents {
|
||||||
if _, ok := b.attacker[attIdx][defIdx]; !ok {
|
|
||||||
b.attacker[attIdx] = make(map[int]float64)
|
|
||||||
}
|
|
||||||
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if len(shooters) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rand.Shuffle(len(shooters), func(i, j int) { shooters[i], shooters[j] = shooters[j], shooters[i] })
|
||||||
|
|
||||||
for len(roundShooters) > 0 {
|
// fired counts, per group, how many of its ships have already shot
|
||||||
// attacke group id among round participants
|
// this round; a token beyond the group's current (post-casualty) ship
|
||||||
attIdx := randomValue(maps.Keys(roundShooters))
|
// count belongs to a ship destroyed earlier in the round and is skipped.
|
||||||
delete(roundShooters, attIdx)
|
fired := make(map[int]uint)
|
||||||
|
progressed := false
|
||||||
|
for _, attIdx := range shooters {
|
||||||
|
if fired[attIdx] >= c.ShipGroup(attIdx).Number {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fired[attIdx]++
|
||||||
|
|
||||||
for range b.shipAmmo[attIdx] {
|
for range c.ShipGroupShipClass(attIdx).Armament {
|
||||||
// defender group id among all attacker's opponents
|
defIdx, ok := c.pickTargetShip(b, attIdx)
|
||||||
defIdx := randomValue(maps.Keys(b.attacker[attIdx]))
|
if !ok {
|
||||||
|
break
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
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 {
|
b.removeFromBattle(defIdx)
|
||||||
// Eliminated group cant attack anyone
|
|
||||||
delete(b.attacker, defIdx)
|
|
||||||
delete(roundShooters, defIdx)
|
|
||||||
|
|
||||||
for attIdx := range b.attacker {
|
|
||||||
// Other attackers can't attack eliminated group anymore
|
|
||||||
delete(b.attacker[attIdx], defIdx)
|
|
||||||
|
|
||||||
if len(b.attacker[attIdx]) == 0 {
|
|
||||||
// Remove attacker if he lost all opponents
|
|
||||||
delete(b.attacker, attIdx)
|
|
||||||
delete(roundShooters, attIdx)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// When attacker has no more targets to shoot - break its ammo cycle
|
|
||||||
if len(b.attacker[attIdx]) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// No shooter found a target this round: every remaining opponent is
|
||||||
|
// out of reach, the battle is over.
|
||||||
|
if !progressed {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func randomValue(v iter.Seq[int]) int {
|
// pickTargetShip selects a random enemy ship for attacker group attIdx among
|
||||||
ids := slices.Collect(v)
|
// the groups it is able to destroy, weighted by each group's current ship
|
||||||
return ids[rand.IntN(len(ids))]
|
// 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user