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