From 53b3cafbc431eda3bb2c2fd3c496638feb46652b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 31 May 2026 08:24:46 +0200 Subject: [PATCH] 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) {