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>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user