From b4abf90ec53d13c39c88b8a47a31706fdc075e94 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 31 May 2026 00:25:46 +0200 Subject: [PATCH 1/2] fix(game): fight before departure and reorder the turn sequence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the documented turn order (game/rules.txt "Последовательность действий"), no ship should dodge the pre-departure battle by slipping into hyperspace. MakeTurn now runs merge -> battle -> load+launch routed groups -> fly -> merge -> battle, so: - ships ordered to depart (Launched) and ships being upgraded now take part in the pre-departure battle at their planet (CollectPlanetGroups / FilterBattleGroups); only survivors then enter hyperspace; - routed transports are loaded and launched AFTER that battle, so they fight empty and cannot escape it. A just-launched group has no stored hyperspace position, so moveShipGroup starts its first leg from the origin planet; the previous code read the nil launch coordinate and would panic. Because upgrading groups can now lose ships in the battle, the pending upgrade cost is recomputed from the group's current ship count instead of the value stored when the order was validated. Rules: reordered "Последовательность действий" and rewrote the combat note that ordered/routed ships skip the battle. Tests: launched-group move from origin, launched/upgrade groups taking part in battle, upgrade cost tracking ship losses. Co-Authored-By: Claude Opus 4.8 (1M context) --- game/internal/controller/battle.go | 36 +++++++++--- game/internal/controller/battle_test.go | 53 +++++++++++++++++ game/internal/controller/generate_turn.go | 18 +++--- game/internal/controller/planet.go | 4 +- game/internal/controller/planet_test.go | 6 +- game/internal/controller/ship_group.go | 2 +- game/internal/controller/ship_group_move.go | 19 ++++--- .../controller/ship_group_move_test.go | 17 ++++++ .../internal/controller/ship_group_upgrade.go | 18 ++++++ .../controller/ship_group_upgrade_test.go | 21 +++++++ game/rules.txt | 57 ++++++++++--------- 11 files changed, 198 insertions(+), 53 deletions(-) diff --git a/game/internal/controller/battle.go b/game/internal/controller/battle.go index 90a1523..f33b8fb 100644 --- a/game/internal/controller/battle.go +++ b/game/internal/controller/battle.go @@ -32,14 +32,23 @@ type BattleAction struct { func CollectPlanetGroups(c *Cache) map[uint]map[int]bool { planetGroup := make(map[uint]map[int]bool) for groupIndex := range c.ShipGroupsIndex() { - state := c.ShipGroup(groupIndex).State() - if state == game.StateInOrbit || state == game.StateUpgrade { - planetNumber := c.ShipGroup(groupIndex).Destination - if _, ok := planetGroup[planetNumber]; !ok { - planetGroup[planetNumber] = make(map[int]bool) - } - planetGroup[planetNumber][groupIndex] = false + sg := c.ShipGroup(groupIndex) + var planetNumber uint + switch sg.State() { + case game.StateInOrbit, game.StateUpgrade: + planetNumber = sg.Destination + case game.StateLaunched: + // Ordered to depart but still physically at the origin planet, so + // it joins the pre-departure battle there; only survivors then + // enter hyperspace. + planetNumber = sg.StateInSpace.Origin + default: + continue } + if _, ok := planetGroup[planetNumber]; !ok { + planetGroup[planetNumber] = make(map[int]bool) + } + planetGroup[planetNumber][groupIndex] = false } for pl := range planetGroup { if len(planetGroup[pl]) < 2 { @@ -50,7 +59,18 @@ func CollectPlanetGroups(c *Cache) map[uint]map[int]bool { } func FilterBattleGroups(c *Cache, groups map[int]bool) []int { - return slices.DeleteFunc(slices.Collect(maps.Keys(groups)), func(groupIndex int) bool { return c.ShipGroup(groupIndex).State() != game.StateInOrbit }) + return slices.DeleteFunc(slices.Collect(maps.Keys(groups)), func(groupIndex int) bool { + // Everything physically present at the planet fights: ships in orbit, + // ships being upgraded, and ships ordered to depart that have not yet + // entered hyperspace (Launched). Only ships already in hyperspace are + // out of reach. + switch c.ShipGroup(groupIndex).State() { + case game.StateInOrbit, game.StateUpgrade, game.StateLaunched: + return false + default: + return true + } + }) } func FilterBattleOpponents(c *Cache, attIdx, defIdx int, cacheProbability map[int]map[int]float64) bool { diff --git a/game/internal/controller/battle_test.go b/game/internal/controller/battle_test.go index eb737c2..a945e45 100644 --- a/game/internal/controller/battle_test.go +++ b/game/internal/controller/battle_test.go @@ -326,3 +326,56 @@ func TestSingleBattleOneSidedWipe(t *testing.T) { } assert.Equal(t, 5, kills, "exactly the five transports are destroyed") } + +// TestCollectPlanetGroupsIncludesLaunchedAndUpgrade checks that every group +// physically at a planet — in orbit, being upgraded, or ordered to depart but +// not yet flown (Launched) — is collected for, and kept in, the battle. +func TestCollectPlanetGroupsIncludesLaunchedAndUpgrade(t *testing.T) { + c, _ := newCache() + // group 0: in orbit at Planet_0 + assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 1)) + // group 1: ordered to depart Planet_0 (Launched), still physically there + assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1)) + c.ShipGroup(1).StateInSpace = &game.InSpace{Origin: R0_Planet_0_num} + c.ShipGroup(1).Destination = R0_Planet_2_num + assert.Equal(t, game.StateLaunched, c.ShipGroup(1).State()) + // group 2: being upgraded at Planet_0 + assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1)) + c.ShipGroup(2).StateUpgrade = &game.InUpgrade{UpgradeTech: []game.UpgradePreference{{Tech: game.TechDrive, Level: 2.0, Cost: 100}}} + assert.Equal(t, game.StateUpgrade, c.ShipGroup(2).State()) + + pg := controller.CollectPlanetGroups(c) + assert.Contains(t, pg, R0_Planet_0_num) + assert.Len(t, pg[R0_Planet_0_num], 3) + for _, idx := range []int{0, 1, 2} { + assert.Contains(t, pg[R0_Planet_0_num], idx) + } + battleGroups := controller.FilterBattleGroups(c, pg[R0_Planet_0_num]) + assert.Len(t, battleGroups, 3) +} + +// TestProduceBattlesLaunchedFightsAtOrigin checks that a group ordered to +// depart (Launched) still fights the pre-departure battle at its origin +// planet, rather than escaping into hyperspace before the fight. +func TestProduceBattlesLaunchedFightsAtOrigin(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())) + + // Race_0: armed group in orbit at Planet_0. + assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 10)) + // Race_1: armed group ordered to depart Planet_0 (Launched), still there. + c.CreateShipsUnsafe_T(Race_1_idx, c.MustShipClass(Race_1_idx, Race_1_Gunship).ID, R0_Planet_0_num, 10) + c.ShipGroup(1).StateInSpace = &game.InSpace{Origin: R0_Planet_0_num} + c.ShipGroup(1).Destination = R0_Planet_2_num + assert.Equal(t, game.StateLaunched, c.ShipGroup(1).State()) + + battles := controller.ProduceBattles(c) + assert.Len(t, battles, 1) + assert.True(t, battles[0].ObserverGroups[1], "launched group must be marked in-battle") + if c.ShipGroup(0).Number == 0 { + assert.Greater(t, c.ShipGroup(1).Number, uint(0)) + } else { + assert.Zero(t, c.ShipGroup(1).Number) + } +} diff --git a/game/internal/controller/generate_turn.go b/game/internal/controller/generate_turn.go index 79896c3..3935bf7 100644 --- a/game/internal/controller/generate_turn.go +++ b/game/internal/controller/generate_turn.go @@ -20,25 +20,29 @@ func (c *Controller) MakeTurn() error { c.Cache.g.Turn += 1 c.Cache.g.Stage = 0 - // 01. Вышедшие расы удаляются из списка участвующих рас перед началом просчета очередного хода + // 01. Вышедшие расы удаляются из списка участвующих рас перед началом просчёта очередного хода. c.Cache.TurnWipeExtinctRaces() - // 02. Товары загружаются на корабли, находящиеся в начале грузовых маршрутов, и корабли входят в гиперпространство (но ещё не полетели) - c.Cache.SendRoutedGroups() - - // 03. Корабли, где это возможно, объединяются в группы. + // 02. Корабли, где это возможно, объединяются в группы (до боя и до отправки по маршрутам). c.Cache.TurnMergeEqualShipGroups() - // 04. Враждующие корабли вступают в схватку. + // 03. Враждующие корабли вступают в схватку у планеты отправления. Корабли, которым отдан + // приказ на отлёт (статус Launched), ещё стоят на планете и участвуют в бою; в + // гиперпространство уходят только уцелевшие — так нельзя уклониться от боя. battles := ProduceBattles(c.Cache) + // 04. Товары загружаются на корабли в начале грузовых маршрутов, и эти корабли входят в + // гиперпространство. Загрузка после боя: маршрутные транспорты сражаются пустыми и не + // могут уклониться от боя, скрывшись в гиперпространстве. + c.Cache.SendRoutedGroups() + // 05. Корабли пролетают сквозь гиперпространство. c.Cache.MoveShipGroups() // 06. Корабли, где это возможно, объединяются в группы. c.Cache.TurnMergeEqualShipGroups() - // 07. Враждующие корабли снова вступают в схватку (это происходит после выхода из гиперпространства). + // 07. Враждующие корабли снова вступают в схватку (после выхода из гиперпространства). battles = append(battles, ProduceBattles(c.Cache)...) // 08. Корабли бомбят вражеские планеты. diff --git a/game/internal/controller/planet.go b/game/internal/controller/planet.go index 19af652..0f7693f 100644 --- a/game/internal/controller/planet.go +++ b/game/internal/controller/planet.go @@ -162,7 +162,7 @@ func (c *Cache) PlanetProductionCapacity(planetNumber uint) float64 { p := c.MustPlanet(planetNumber) var busyResources float64 for sg := range c.shipGroupsInUpgrade(p.Number) { - busyResources += sg.StateUpgrade.Cost() + busyResources += c.upgradeCostNow(sg) } return p.ProductionCapacity() - busyResources } @@ -184,7 +184,7 @@ func (c *Cache) TurnPlanetProductions() { // upgrade groups and return to in_orbit state productionAvailable := c.PlanetProductionCapacity(pn) for sg := range c.shipGroupsInUpgrade(p.Number) { - cost := sg.StateUpgrade.Cost() + cost := c.upgradeCostNow(sg) if productionAvailable >= cost { for i := range sg.StateUpgrade.UpgradeTech { sg.Tech = sg.Tech.Set(sg.StateUpgrade.UpgradeTech[i].Tech, util.Fixed3(sg.StateUpgrade.UpgradeTech[i].Level.F())) diff --git a/game/internal/controller/planet_test.go b/game/internal/controller/planet_test.go index ae2cdf0..a522561 100644 --- a/game/internal/controller/planet_test.go +++ b/game/internal/controller/planet_test.go @@ -208,7 +208,11 @@ func TestProduceShips(t *testing.T) { assert.Equal(t, game.StateInOrbit, c.ShipGroup(0).State()) assert.Equal(t, 1.5, c.ShipGroup(0).TechLevel(game.TechDrive).F()) - assert.Equal(t, 4346.676567656759, c.MustPlanet(R0_Planet_0_num).Material.F()) + // Upgrade cost is now recomputed from the group's current ship count + // (calc raw float) rather than read from the Fixed12-rounded stored value, + // which shifts Material at the 12th decimal — far below the 3-decimal + // report precision, so an InDelta check is the robust expectation here. + assert.InDelta(t, 4346.6766, c.MustPlanet(R0_Planet_0_num).Material.F(), 0.001) } func TestProduceShip(t *testing.T) { diff --git a/game/internal/controller/ship_group.go b/game/internal/controller/ship_group.go index 126bf13..52f3770 100644 --- a/game/internal/controller/ship_group.go +++ b/game/internal/controller/ship_group.go @@ -491,7 +491,7 @@ func (c *Cache) shipGroupsInUpgrade(planetNumber uint) iter.Seq[*game.ShipGroup] } } slices.SortFunc(result, func(a, b int) int { - return cmp.Compare(c.g.ShipGroups[b].StateUpgrade.Cost(), c.g.ShipGroups[a].StateUpgrade.Cost()) + return cmp.Compare(c.upgradeCostNow(&c.g.ShipGroups[b]), c.upgradeCostNow(&c.g.ShipGroups[a])) }) for i := range result { if !yield(&c.g.ShipGroups[result[i]]) { diff --git a/game/internal/controller/ship_group_move.go b/game/internal/controller/ship_group_move.go index 7d21279..d7ed7b4 100644 --- a/game/internal/controller/ship_group_move.go +++ b/game/internal/controller/ship_group_move.go @@ -34,15 +34,20 @@ func (c *Cache) MoveShipGroups() { func (c *Cache) moveShipGroup(i int, delta float64) { sg := c.ShipGroup(i) - originX, originY, ok := sg.Coord() - if !ok { - panic(fmt.Sprintf("ship group state invalid: %v", sg.State())) + var originX, originY float64 + switch sg.State() { + case game.StateLaunched: + // Just launched: the group is still at its origin planet and has not + // stored a hyperspace position yet, so the first leg starts there. + origin := c.MustPlanet(sg.StateInSpace.Origin) + originX, originY = origin.X.F(), origin.Y.F() + case game.StateInSpace: + originX, originY = sg.StateInSpace.X.F(), sg.StateInSpace.Y.F() + default: + panic(fmt.Sprintf("ship group state invalid for move: %v", sg.State())) } destPlanet := c.MustPlanet(sg.Destination) - arrived := false - var x, y float64 - x, y, arrived = - util.NextTravelCoord(c.g.Map.Width, c.g.Map.Height, originX, originY, destPlanet.X.F(), destPlanet.Y.F(), delta) + x, y, arrived := util.NextTravelCoord(c.g.Map.Width, c.g.Map.Height, originX, originY, destPlanet.X.F(), destPlanet.Y.F(), delta) fx, fy := game.F(x), game.F(y) sg.StateInSpace.X = &fx sg.StateInSpace.Y = &fy diff --git a/game/internal/controller/ship_group_move_test.go b/game/internal/controller/ship_group_move_test.go index 4e4bacf..815c1b3 100644 --- a/game/internal/controller/ship_group_move_test.go +++ b/game/internal/controller/ship_group_move_test.go @@ -45,3 +45,20 @@ func TestListMoveableGroupIds(t *testing.T) { assert.NotEqual(t, game.StateTransfer, sg.State()) } } + +// TestMoveLaunchedGroupFromOrigin guards the launched-coordinate fix: a group +// just sent by an order is Launched with no stored hyperspace position, so its +// first leg must start from the origin planet. The pre-fix code dereferenced +// the nil launch coordinate and panicked. +func TestMoveLaunchedGroupFromOrigin(t *testing.T) { + c, g := newCache() + assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1)) + assert.NoError(t, g.ShipGroupSend(Race_0.Name, c.ShipGroup(0).ID, R0_Planet_2_num)) + assert.Equal(t, game.StateLaunched, c.ShipGroup(0).State()) + + // Must not panic on the nil launch coordinate. Planet_0 (1,1) -> Planet_2 + // (3,3) is ~2.83 ly; a Cruiser covers it in one turn and arrives. + c.MoveShipGroups() + assert.Equal(t, game.StateInOrbit, c.ShipGroup(0).State()) + assert.Equal(t, R0_Planet_2_num, c.ShipGroup(0).Destination) +} diff --git a/game/internal/controller/ship_group_upgrade.go b/game/internal/controller/ship_group_upgrade.go index 0ec3593..46a8ecd 100644 --- a/game/internal/controller/ship_group_upgrade.go +++ b/game/internal/controller/ship_group_upgrade.go @@ -196,6 +196,24 @@ func FutureUpgradeLevel(raceLevel, groupLevel, limit float64) float64 { return target } +// upgradeCostNow returns the production cost to apply group sg's pending +// upgrade to its CURRENT ship count. The cost stored on StateUpgrade is fixed +// when the order is validated; if the group has since lost ships (for example +// in the pre-departure battle, now that upgrading groups take part in it), the +// stored total is stale, so the cost is recomputed from the stored target +// levels, the group's current tech, and its current ship count. +func (c *Cache) upgradeCostNow(sg *game.ShipGroup) float64 { + if sg.StateUpgrade == nil { + return 0 + } + st := c.MustShipType(c.RaceIndex(sg.OwnerID), sg.TypeID) + var perShip float64 + for _, pref := range sg.StateUpgrade.UpgradeTech { + perShip += calc.BlockUpgradeCost(st.BlockMass(pref.Tech), sg.TechLevel(pref.Tech).F(), pref.Level.F()) + } + return perShip * float64(sg.Number) +} + func UpgradeGroupPreference(sg game.ShipGroup, st game.ShipType, tech game.Tech, v float64) game.ShipGroup { if v <= 0 || st.BlockMass(tech) == 0 || sg.TechLevel(tech).F() >= v { return sg diff --git a/game/internal/controller/ship_group_upgrade_test.go b/game/internal/controller/ship_group_upgrade_test.go index c30c6be..b90d539 100644 --- a/game/internal/controller/ship_group_upgrade_test.go +++ b/game/internal/controller/ship_group_upgrade_test.go @@ -170,3 +170,24 @@ func TestShipGroupUpgrade(t *testing.T) { g.ShipGroupUpgrade(Race_0.Name, c.ShipGroup(3).ID, "DRIVE", 1.3), e.GenericErrorText(e.ErrShipsBusy)) } + +// TestUpgradeCostTracksShipLosses checks that the production reserved for a +// pending upgrade follows the group's CURRENT ship count. Upgrading groups now +// take part in the pre-departure battle and may lose ships, which would leave +// the cost stored at order time stale; the cost is recomputed instead. +func TestUpgradeCostTracksShipLosses(t *testing.T) { + c, _ := newCache() + assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 4)) + // Pending drive upgrade with a deliberately stale stored cost. + c.ShipGroup(0).StateUpgrade = &game.InUpgrade{ + UpgradeTech: []game.UpgradePreference{{Tech: game.TechDrive, Level: 2.0, Cost: 9999}}, + } + before := c.PlanetProductionCapacity(R0_Planet_0_num) + // Two ships lost, as in the pre-departure battle. + c.ShipGroupDestroyItem(0) + c.ShipGroupDestroyItem(0) + after := c.PlanetProductionCapacity(R0_Planet_0_num) + // Fewer ships reserve less production. With the stale stored cost this + // would be unchanged. + assert.Greater(t, after, before) +} diff --git a/game/rules.txt b/game/rules.txt index 362cdb9..740838f 100644 --- a/game/rules.txt +++ b/game/rules.txt @@ -849,9 +849,12 @@ Freighter загруженный 15 ед. груза при технологии находится с агрессором в состоянии мира, также вступит в сражение. Это вовсе не означает, что у нападающих есть право первого выстрела, все сражающиеся стороны находятся в абсолютно равных условиях ведения сражения, и первым -выстрелит тот, кто более удачлив. Отосланные с планеты вручную или по -маршруту корабли уже вошли в гиперпространство и участия в сражениях не -принимают (см. "Последовательность действий"). +выстрелит тот, кто более удачлив. Корабли, которым отдан приказ на отлёт, а +также корабли, отправляемые по установленным грузовым маршрутам, к началу хода +ещё находятся на планете отправления и принимают участие в сражении у этой +планеты — в гиперпространство уходят лишь уцелевшие в нём корабли. Корабль, +уже находящийся в гиперпространстве, в сражениях не участвует вплоть до +прибытия на планету назначения (см. "Последовательность действий"). В каждом раунде сражения все корабли получают шанс выстрелить по противнику, разумеется, если в этом же раунде его противники не были более удачливы и не @@ -1080,57 +1083,57 @@ Freighter загруженный 15 ед. груза при технологии Последовательность действий ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - После того, как получены приказы от всех рас, определено -производство, загружены товары, корабли вошли в гиперпространство и -т.д. происходит сам ход, т.е. следующая последовательность действий: + После того, как получены приказы от всех рас, происходит сам ход, т.е. +следующая последовательность действий: -- Корабли передаются новым владельцам. +- Корабли передаются новым владельцам. - Расы, покинувшие игру, освобождаются от своего имущества. -- ------------------------------------ \ - } "Выполнение отданных приказов (всех?)" -- Корабли разгружаются согласно отданным приказам. / +- Выполняются все отданные расами приказы. В частности, корабли разгружаются + согласно приказам, а кораблям может быть отдан приказ на отлёт: такие корабли + получают готовность к отлёту, но физически остаются на планете отправления. - Корабли, где это возможно, объединяются в группы. -- Товары загружаются на корабли, находящиеся в начале грузовых - маршрутов. +- Враждующие корабли вступают в схватку у планет отправления. Корабли, которым + отдан приказ на отлёт, ещё стоят на планете и участвуют в этой схватке; в + гиперпространство уходят только уцелевшие. -- Корабли входят в гиперпространство. +- Товары загружаются на корабли, находящиеся в начале грузовых маршрутов + (после схватки — маршрутные транспорты сражаются незагруженными). -- Враждующие корабли вступают в схватку. - -- Корабли пролетают сквозь гиперпространство. +- Корабли (готовые к отлёту и снаряжённые по маршрутам) входят в + гиперпространство и пролетают сквозь него. - Корабли, где это возможно, объединяются в группы. -- Враждующие корабли вступают в схватку (после выхода из гиперпространства). +- Враждующие корабли вступают в схватку (после выхода из гиперпространства, + у планет назначения). - Корабли бомбят вражеские планеты. -+ На планетах модернизируются корабли. +- На планетах модернизируются корабли. - На планетах строятся корабли (с учётом производственного потенциала, оставшегося от модернизации кораблей). -+ Корабли, где это возможно, объединяются в группы. +- Корабли, где это возможно, объединяются в группы. -- На планетах производится промышленность, добывается сырье, - разрабатываются новые технологии. +- На планетах производится промышленность, добывается сырьё, разрабатываются + новые технологии. - Увеличивается население планет. - Корабли разгружаются в конце грузовых маршрутов. -+ Выгруженные колонисты увеличивают население планеты (если население - планеты ниже её размера). +- Выгруженные колонисты увеличивают население планеты (если население планеты + ниже её размера). -- Накопленная и выгруженная промышленность увеличивает - производственный уровень планеты (если производственный уровень - планеты ниже уровня населения). +- Накопленная и выгруженная промышленность увеличивает производственный уровень + планеты (если производственный уровень планеты ниже уровня населения). -- Происходит отмена маршрутов, выходящих за зону полета кораблей. +- Происходит отмена маршрутов, выходящих за зону полёта кораблей. - Происходит голосование. -- 2.52.0 From 53b3cafbc431eda3bb2c2fd3c496638feb46652b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 31 May 2026 08:24:46 +0200 Subject: [PATCH 2/2] fix(game): charge a ship upgrade against production only once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TurnPlanetProductions started its production budget from PlanetProductionCapacity, which already subtracts the reserved upgrade cost, and then subtracted each applied upgrade's cost again in the apply loop — charging every applied upgrade twice. That both starved the planet's build/research budget and could skip upgrades that were in fact affordable. The budget now starts from the planet's full production potential and the apply loop deducts each upgrade once; PlanetProductionCapacity stays the report's net-of-upgrades "free L". Test: TestUpgradeDoesNotDoubleChargeProduction; the TestProduceShips MAT expectation is updated to the once-charged value. Co-Authored-By: Claude Opus 4.8 (1M context) --- game/internal/controller/planet.go | 9 ++++-- game/internal/controller/planet_test.go | 39 +++++++++++++++++++++---- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/game/internal/controller/planet.go b/game/internal/controller/planet.go index 0f7693f..ed25d90 100644 --- a/game/internal/controller/planet.go +++ b/game/internal/controller/planet.go @@ -181,8 +181,13 @@ func (c *Cache) TurnPlanetProductions() { ri := c.RaceIndex(*p.Owner) r := &c.g.Race[ri] - // upgrade groups and return to in_orbit state - productionAvailable := c.PlanetProductionCapacity(pn) + // Upgrade groups (most expensive first) and return them to the + // in-orbit state, paying for each upgrade once out of the planet's + // full production potential; whatever remains feeds this turn's + // production below. Starting from PlanetProductionCapacity here would + // have charged every applied upgrade twice, since that helper already + // nets out the reserved upgrade cost for the report. + productionAvailable := p.ProductionCapacity() for sg := range c.shipGroupsInUpgrade(p.Number) { cost := c.upgradeCostNow(sg) if productionAvailable >= cost { diff --git a/game/internal/controller/planet_test.go b/game/internal/controller/planet_test.go index a522561..2e06ee6 100644 --- a/game/internal/controller/planet_test.go +++ b/game/internal/controller/planet_test.go @@ -208,11 +208,40 @@ func TestProduceShips(t *testing.T) { assert.Equal(t, game.StateInOrbit, c.ShipGroup(0).State()) assert.Equal(t, 1.5, c.ShipGroup(0).TechLevel(game.TechDrive).F()) - // Upgrade cost is now recomputed from the group's current ship count - // (calc raw float) rather than read from the Fixed12-rounded stored value, - // which shifts Material at the 12th decimal — far below the 3-decimal - // report precision, so an InDelta check is the robust expectation here. - assert.InDelta(t, 4346.6766, c.MustPlanet(R0_Planet_0_num).Material.F(), 0.001) + // The pending upgrade is now charged once (not twice) against the planet's + // production potential, so MAT production keeps the budget it previously + // lost to the double charge (the pre-fix value here was ~4346.68). + assert.InDelta(t, 7173.3432, c.MustPlanet(R0_Planet_0_num).Material.F(), 0.001) +} + +// TestUpgradeDoesNotDoubleChargeProduction guards that a pending upgrade is +// paid for once out of the planet's production potential, leaving the rest for +// the turn's production. The pre-fix code subtracted the upgrade cost twice +// (PlanetProductionCapacity already nets it out for the report, and the apply +// loop netted it again), which both starved production and could skip +// affordable upgrades. +func TestUpgradeDoesNotDoubleChargeProduction(t *testing.T) { + c, _ := newCache() + p := c.MustPlanet(R0_Planet_0_num) + p.Population = 1000 + p.Industry = 1000 // ProductionCapacity = 1000*0.75 + 1000*0.25 = 1000 + p.Resources = 1 // material produced == leftover production budget + p.Colonists = 0 + p.Material = 0 + assert.NoError(t, c.PlanetProduce(Race_0_idx, int(R0_Planet_0_num), game.ProductionMaterial, "")) + + // One Cruiser with a pending drive upgrade 1.1 -> 2.0: + // block cost = (1 - 1.1/2.0) * 10 * 15 = 67.5 for the single ship. + assert.NoError(t, c.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1)) + c.ShipGroup(0).StateUpgrade = &game.InUpgrade{ + UpgradeTech: []game.UpgradePreference{{Tech: game.TechDrive, Level: 2.0}}, + } + + c.TurnPlanetProductions() + + assert.InDelta(t, 2.0, c.ShipGroup(0).TechLevel(game.TechDrive).F(), 0.0001) + // 1000 - 67.5 = 932.5; the pre-fix double charge would have left 865. + assert.InDelta(t, 932.5, c.MustPlanet(R0_Planet_0_num).Material.F(), 0.01) } func TestProduceShip(t *testing.T) { -- 2.52.0