From cc67364113d30286f749f7df8df3d5d75ce7b5dd Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 30 May 2026 23:57:27 +0200 Subject: [PATCH] fix(game): resolve battles ship by ship, matching the combat rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- game/internal/controller/battle.go | 161 +++++++++++------- game/internal/controller/battle_test.go | 57 ++++++- .../controller/controller_export_test.go | 4 + 3 files changed, 160 insertions(+), 62 deletions(-) diff --git a/game/internal/controller/battle.go b/game/internal/controller/battle.go index 1250fbb..90a1523 100644 --- a/game/internal/controller/battle.go +++ b/game/internal/controller/battle.go @@ -1,7 +1,6 @@ package controller import ( - "iter" "maps" "math/rand/v2" "slices" @@ -19,8 +18,9 @@ type Battle struct { InitialNumbers map[int]uint // Initial number of ships in the group Protocol []BattleAction - shipAmmo map[int]uint - attacker map[int]map[int]float64 // a group able to attack and destroy an opponent with some probability > 0 + // 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 { @@ -65,12 +65,20 @@ func FilterBattleOpponents(c *Cache, attIdx, defIdx int, cacheProbability map[in 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(), - c.ShipGroupShipClass(defIdx).Shields.F(), - c.ShipGroup(defIdx).TechLevel(game.TechShields).F(), - c.ShipGroup(defIdx).FullMass(c.ShipGroupShipClass(defIdx)), + 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 { @@ -108,7 +116,6 @@ func ProduceBattles(c *Cache) []*Battle { ObserverGroups: observerGroups, InitialNumbers: make(map[int]uint), attacker: make(map[int]map[int]float64), - shipAmmo: make(map[int]uint), } for sgi := range observerGroups { b.InitialNumbers[sgi] = c.ShipGroup(sgi).Number @@ -126,12 +133,11 @@ func ProduceBattles(c *Cache) []*Battle { return FilterBattleOpponents(c, attIdx, defIdx, cacheProbability) }) if len(opponents) > 0 { - b.shipAmmo[attIdx] = c.ShipGroupShipClass(attIdx).Armament b.ObserverGroups[attIdx] = true + if b.attacker[attIdx] == nil { + b.attacker[attIdx] = make(map[int]float64) + } 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.ObserverGroups[defIdx] = true } @@ -145,78 +151,111 @@ func ProduceBattles(c *Cache) []*Battle { } clear(b.attacker) - clear(b.shipAmmo) } 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) { - roundShooters := make(map[int]bool) - for len(b.attacker) > 0 { - // список участников раунда - clear(roundShooters) - for sgi := range b.attacker { - roundShooters[sgi] = true + 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] }) - for len(roundShooters) > 0 { - // attacke group id among round participants - attIdx := randomValue(maps.Keys(roundShooters)) - delete(roundShooters, attIdx) + // 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 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") + 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 { - // 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) - } + if c.ShipGroup(defIdx).Number == 0 { + b.removeFromBattle(defIdx) } } - - // 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 { - ids := slices.Collect(v) - return ids[rand.IntN(len(ids))] +// 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") + } } diff --git a/game/internal/controller/battle_test.go b/game/internal/controller/battle_test.go index 61ffeee..eb737c2 100644 --- a/game/internal/controller/battle_test.go +++ b/game/internal/controller/battle_test.go @@ -108,7 +108,11 @@ func TestFilterBattleOpponents(t *testing.T) { assert.False(t, controller.FilterBattleOpponents(c, 0, 2, cacheProbability)) assert.Contains(t, cacheProbability, 0) 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.Contains(t, cacheProbability, 2) assert.Contains(t, cacheProbability[2], 0) @@ -271,3 +275,54 @@ func TestTransformBattleAggregatesSameShipClass(t *testing.T) { 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") +} diff --git a/game/internal/controller/controller_export_test.go b/game/internal/controller/controller_export_test.go index 452afdc..6f49be7 100644 --- a/game/internal/controller/controller_export_test.go +++ b/game/internal/controller/controller_export_test.go @@ -149,3 +149,7 @@ func (c *Cache) WipeRace(ri int) { func (c *Cache) UnsafeDeleteShipGroup(sgi int) { c.unsafeDeleteShipGroup(sgi) } + +func DestructionRoll(probability float64) bool { + return destructionRoll(probability) +}