Compare commits

..

2 Commits

Author SHA1 Message Date
developer 200236369f Merge pull request 'fix(game): bomb in descending power order, collapse industry on wipe' (#79) from feature/game-bombing-order into development
Deploy · Dev / deploy (push) Successful in 48s
Tests · Go / test (push) Successful in 2m11s
Tests · Integration / integration (push) Successful in 1m43s
2026-05-31 06:59:44 +00:00
Ilia Denisov a01f39e4a7 fix(game): bomb in descending power order, collapse industry on wipe
Tests · Go / test (push) Successful in 1m57s
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 2m2s
Per the rules ("Бомбардировка планет"), a planet is bombed from the
strongest attacking power downwards, and a planet bombed to extinction
keeps its material and capital stockpiles but loses its working industry.

ProduceBombings now sorts attacking races by total bombing power
(descending) instead of iterating the attacker map in random order, and
on a wipe zeroes the planet's industry (Free already keeps capital and
material). bombingPower is extracted as a shared helper.

The rules already describe both, so no documentation change. Tests:
bombing order by power, and industry collapse with capital/material kept
on a wipe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 08:47:45 +02:00
2 changed files with 80 additions and 7 deletions
+24 -7
View File
@@ -1,6 +1,10 @@
package controller
import (
"cmp"
"maps"
"slices"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
@@ -13,8 +17,14 @@ func (c *Cache) ProduceBombings() []*game.Bombing {
if !p.Owned() {
continue
}
for ri, groups := range enemies {
br := c.bombingReport(p, ri, groups)
// The planet is hit by all attacking races at once, accounted from the
// strongest bombing power downwards, until no population remains.
attackers := slices.Collect(maps.Keys(enemies))
slices.SortFunc(attackers, func(a, b int) int {
return cmp.Compare(c.bombingPower(enemies[b]), c.bombingPower(enemies[a]))
})
for _, ri := range attackers {
br := c.bombingReport(p, ri, enemies[ri])
report = append(report, br)
if br.Wiped {
break
@@ -22,7 +32,11 @@ func (c *Cache) ProduceBombings() []*game.Bombing {
}
if p.Population == 0 {
// Wiped out: the planet turns uninhabited and its industry
// collapses, but the material and capital stockpiles survive for
// whoever colonises it next (rules "Бомбардировка планет").
p.Free()
p.Ind(0)
} else {
// Если на планете остались также и колонисты, то они превращаются в население,
// а накопленная промышленность возмещает потери производства.
@@ -33,13 +47,16 @@ func (c *Cache) ProduceBombings() []*game.Bombing {
return report
}
func (c *Cache) bombingReport(p *game.Planet, ri int, groups []int) *game.Bombing {
attackPower := 0.
func (c *Cache) bombingPower(groups []int) float64 {
var power float64
for _, i := range groups {
sg := c.ShipGroup(i)
st := c.ShipGroupShipClass(i)
attackPower += sg.BombingPower(st)
power += c.ShipGroup(i).BombingPower(c.ShipGroupShipClass(i))
}
return power
}
func (c *Cache) bombingReport(p *game.Planet, ri int, groups []int) *game.Bombing {
attackPower := c.bombingPower(groups)
r := &game.Bombing{
ID: uuid.New(),
PlanetOwnedID: *p.Owner,
+56
View File
@@ -141,3 +141,59 @@ func TestProduceBombings(t *testing.T) {
}
}
}
// TestBombingOrderByPower checks that attacking races are accounted from the
// strongest bombing power downwards (the report order), not in the random map
// iteration order the engine used before.
func TestBombingOrderByPower(t *testing.T) {
c, g := newCache()
assert.NoError(t, g.RaceRelation(Race_1.Name, Race_0.Name, game.RelationWar.String()))
weakIdx, _ := c.AddRace("Weakling")
assert.NoError(t, c.UpdateRelation(weakIdx, Race_0_idx, game.RelationWar))
assert.NoError(t, c.ShipClassCreate(weakIdx, "Pebble", 1, 1, 1, 1, 0))
// Planet_0 (Race_0) survives both attacks.
c.MustPlanet(R0_Planet_0_num).Population = 1000
// Strong: one Race_1 gunship (~358.9 power); weak: one Pebble (~1.1 power).
c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R0_Planet_0_num, 1)
c.CreateShipsUnsafe_T(weakIdx, c.MustShipClass(weakIdx, "Pebble").ID, R0_Planet_0_num, 1)
reports := c.ProduceBombings()
assert.Len(t, reports, 2)
assert.Equal(t, Race_1.Name, reports[0].Attacker, "strongest attacker comes first")
assert.Equal(t, "Weakling", reports[1].Attacker)
assert.Greater(t, reports[0].AttackPower.F(), reports[1].AttackPower.F())
}
// TestBombingWipeZeroesIndustry checks that a planet bombed to extinction loses
// its industry but keeps its material and capital stockpiles for the next
// colonist (rules "Бомбардировка планет").
func TestBombingWipeZeroesIndustry(t *testing.T) {
c, _ := newCache()
bomberIdx, _ := c.AddRace("Bomber")
assert.NoError(t, c.UpdateRelation(bomberIdx, Race_0_idx, game.RelationWar))
// Bombing power ~106.5 (W=60, A=1, weapons tech 1.0): wipes pop 50 while
// only partly converting industry, so the leftover industry is observable.
assert.NoError(t, c.ShipClassCreate(bomberIdx, "Reaper", 1, 1, 60, 1, 0))
p := c.MustPlanet(R0_Planet_0_num)
p.Population = 50
p.Industry = 200
p.Capital = 30
p.Material = 20
p.Colonists = 0
c.CreateShipsUnsafe_T(bomberIdx, c.MustShipClass(bomberIdx, "Reaper").ID, R0_Planet_0_num, 1)
reports := c.ProduceBombings()
assert.Len(t, reports, 1)
assert.True(t, reports[0].Wiped)
pl := c.MustPlanet(R0_Planet_0_num)
assert.False(t, pl.Owned())
assert.Equal(t, 0., pl.Population.F())
assert.Equal(t, 0., pl.Industry.F(), "industry collapses on wipe")
assert.Equal(t, 30., pl.Capital.F(), "capital stockpile survives")
assert.InDelta(t, 126.476, pl.Material.F(), 0.01, "material keeps the converted industry")
}