From a01f39e4a7fe35e90d6fc389664d79396bec4be3 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 31 May 2026 08:47:45 +0200 Subject: [PATCH] fix(game): bomb in descending power order, collapse industry on wipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- game/internal/controller/bombing.go | 31 ++++++++++--- game/internal/controller/bombing_test.go | 56 ++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/game/internal/controller/bombing.go b/game/internal/controller/bombing.go index 85376ca..fbdaf3f 100644 --- a/game/internal/controller/bombing.go +++ b/game/internal/controller/bombing.go @@ -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, diff --git a/game/internal/controller/bombing_test.go b/game/internal/controller/bombing_test.go index a0f9e86..a5ef274 100644 --- a/game/internal/controller/bombing_test.go +++ b/game/internal/controller/bombing_test.go @@ -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") +} -- 2.52.0