Files
galaxy-game/game/internal/controller/planet.go
T
Ilia Denisov 53b3cafbc4
Tests · Go / test (push) Successful in 2m7s
Tests · Go / test (pull_request) Successful in 2m10s
Tests · Integration / integration (pull_request) Successful in 1m41s
fix(game): charge a ship upgrade against production only once
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) <noreply@anthropic.com>
2026-05-31 08:24:46 +02:00

296 lines
8.6 KiB
Go

package controller
import (
"fmt"
"iter"
"slices"
"galaxy/calc"
"galaxy/util"
e "galaxy/error"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
func (c *Cache) PlanetRename(ri int, number int, name string) error {
n, ok := util.ValidateTypeName(name)
if !ok {
return e.NewEntityTypeNameValidationError("%q", n)
}
if number < 0 {
return e.NewPlanetNumberError(number)
}
p, ok := c.Planet(uint(number))
if !ok {
return e.NewEntityNotExistsError("planet #%d", number)
}
if !p.OwnedBy(c.g.Race[ri].ID) {
return e.NewEntityNotOwnedError("planet #%d", number)
}
c.g.Map.Planet[c.MustPlanetIndex(p.Number)].Name = n
return nil
}
func (c *Cache) PlanetProduce(ri int, number int, prod game.ProductionType, subj string) error {
c.validateRaceIndex(ri)
if number < 0 {
return e.NewPlanetNumberError(number)
}
p, ok := c.Planet(uint(number))
if !ok {
return e.NewEntityNotExistsError("planet #%d", number)
}
if !p.OwnedBy(c.g.Race[ri].ID) {
return e.NewEntityNotOwnedError("planet #%d", number)
}
var subjectID *uuid.UUID
if prod == game.ResearchScience || prod == game.ProductionShip {
if _, ok := util.ValidateTypeName(subj); !ok {
return e.NewEntityTypeNameValidationError("%s=%q", prod, subj)
}
}
if prod == game.ResearchScience {
i := slices.IndexFunc(c.g.Race[ri].Sciences, func(s game.Science) bool { return s.Name == subj })
if i < 0 {
return e.NewEntityNotExistsError("science %q", subj)
}
subjectID = &c.g.Race[ri].Sciences[i].ID
}
if prod == game.ProductionShip {
st, _, ok := c.ShipClass(ri, subj)
if !ok {
return e.NewEntityNotExistsError("ship type %q", subj)
}
if p.Production.Type == game.ProductionShip &&
p.Production.SubjectID != nil &&
*p.Production.SubjectID == st.ID {
// Planet already produces this ship type, keeping progress intact
return nil
}
subjectID = &st.ID
}
if p.Production.Type == game.ProductionShip && (prod != game.ProductionShip || *subjectID != *p.Production.SubjectID) {
p.ReleaseMaterial(c.MustShipType(ri, *p.Production.SubjectID).EmptyMass())
} else if prod == game.ProductionShip {
// new ship class to produce; otherwise we must have been returned from the func earlier
p.Production.Progress = new(game.Float)
p.Production.ProdUsed = new(game.Float)
}
if prod != game.ProductionShip {
p.Production.Progress = nil
p.Production.ProdUsed = nil
}
p.Production.Type = prod
p.Production.SubjectID = subjectID
return nil
}
func (c *Cache) PlanetProductionDisplayName(pn uint) string {
p := c.MustPlanet(pn)
if !p.Owned() {
return "-"
}
ri := c.RaceIndex(*p.Owner)
switch pt := p.Production.Type; pt {
case game.ResearchDrive:
return "Drive"
case game.ResearchWeapons:
return "Weapons"
case game.ResearchShields:
return "Shields"
case game.ResearchCargo:
return "Cargo"
case game.ProductionMaterial:
return "Material"
case game.ProductionCapital:
return "Capital"
case game.ProductionShip:
return c.MustShipType(ri, *p.Production.SubjectID).Name
case game.ResearchScience:
i := slices.IndexFunc(c.g.Race[ri].Sciences, func(sc game.Science) bool { return sc.ID == *p.Production.SubjectID })
if i < 0 {
panic("researching science not found")
}
return c.g.Race[ri].Sciences[i].Name
default:
return string(pt)
}
}
func (c *Cache) Planet(planetNumber uint) (*game.Planet, bool) {
if c.cachePlanetByPlanetNumber == nil {
c.cachePlanetByPlanetNumber = make(map[uint]*game.Planet)
for p := range c.g.Map.Planet {
c.cachePlanetByPlanetNumber[c.g.Map.Planet[p].Number] = &c.g.Map.Planet[p]
}
}
if v, ok := c.cachePlanetByPlanetNumber[planetNumber]; ok {
return v, true
} else {
return nil, false
}
}
func (c *Cache) MustPlanet(pn uint) *game.Planet {
if v, ok := c.Planet(pn); ok {
return v
} else {
panic(fmt.Sprintf("planet not found by number=%d", pn))
}
}
func (c *Cache) MustPlanetIndex(pn uint) int {
if idx := slices.IndexFunc(c.g.Map.Planet, func(p game.Planet) bool { return p.Number == pn }); idx < 0 {
panic(fmt.Sprintf("planet not found by number=%d", pn))
} else {
return idx
}
}
// Свободный "Производственный Потенциал" (L)
// промышленность * 0.75 + население * 0.25
// за вычетом затрат, расходуемых в течение хода на модернизацию кораблей
func (c *Cache) PlanetProductionCapacity(planetNumber uint) float64 {
p := c.MustPlanet(planetNumber)
var busyResources float64
for sg := range c.shipGroupsInUpgrade(p.Number) {
busyResources += c.upgradeCostNow(sg)
}
return p.ProductionCapacity() - busyResources
}
func (c *Cache) TurnPlanetProductions() {
for sgi := range c.ShipGroupsIndex() {
sg := c.ShipGroup(sgi)
// cancel upgrade for groups on wiped planets
if sg.State() == game.StateUpgrade && !c.MustPlanet(sg.Destination).Owned() {
sg.StateUpgrade = nil
}
}
for pn := range c.listProducingPlanets() {
p := c.MustPlanet(pn)
ri := c.RaceIndex(*p.Owner)
r := &c.g.Race[ri]
// 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 {
for i := range sg.StateUpgrade.UpgradeTech {
sg.Tech = sg.Tech.Set(sg.StateUpgrade.UpgradeTech[i].Tech, util.Fixed3(sg.StateUpgrade.UpgradeTech[i].Level.F()))
}
productionAvailable -= cost
}
sg.StateUpgrade = nil
}
switch pt := p.Production.Type; pt {
case game.ProductionShip:
st := c.MustShipType(ri, *p.Production.SubjectID)
if ships := ProduceShip(p, productionAvailable, st.EmptyMass()); ships > 0 {
c.unsafeCreateShips(ri, st.ID, p.Number, ships)
}
case game.ResearchScience:
sc := c.mustScience(ri, *p.Production.SubjectID)
ResearchTech(r, productionAvailable, sc.Drive.F(), sc.Weapons.F(), sc.Shields.F(), sc.Cargo.F())
case game.ResearchDrive:
ResearchTech(r, productionAvailable, 1., 0, 0, 0)
case game.ResearchWeapons:
ResearchTech(r, productionAvailable, 0, 1., 0, 0)
case game.ResearchShields:
ResearchTech(r, productionAvailable, 0, 0, 1., 0)
case game.ResearchCargo:
ResearchTech(r, productionAvailable, 0, 0, 0, 1.)
case game.ProductionMaterial:
p.ProduceMaterial(productionAvailable)
case game.ProductionCapital:
p.ProduceIndustry(productionAvailable)
default:
panic(fmt.Sprintf("unprocessed production type: '%v' for planet: #%d owner=%v", pt, pn, p.Owner))
}
// last step: increase population / colonists
p.ProducePopulation()
}
c.TurnMergeEqualShipGroups()
}
// listProducingPlanets iterates over all inhabited planet numbers with defined production type.
// Planets producing ships guaranteed to be iterated first for correct turn actions order.
func (c *Cache) listProducingPlanets() iter.Seq[uint] {
ordered := make([]int, 0)
for i := range c.g.Map.Planet {
if !c.g.Map.Planet[i].Owned() || c.g.Map.Planet[i].Production.Type == game.ProductionNone {
continue
}
ordered = append(ordered, i)
}
slices.SortFunc(ordered, func(l, r int) int {
if c.g.Map.Planet[l].Production.Type == game.ProductionShip && c.g.Map.Planet[r].Production.Type != game.ProductionShip {
return -1
}
if c.g.Map.Planet[l].Production.Type != game.ProductionShip && c.g.Map.Planet[r].Production.Type == game.ProductionShip {
return 1
}
return 0
})
return func(yield func(uint) bool) {
for _, i := range ordered {
if !yield(c.g.Map.Planet[i].Number) {
return
}
}
}
}
// Internal funcs
func (c *Cache) putPopulation(pn uint, v float64) {
c.MustPlanet(pn).Pop(v)
}
func (c *Cache) putColonists(pn uint, v float64) {
c.MustPlanet(pn).Col(v)
}
func (c *Cache) putMaterial(pn uint, v float64) {
c.MustPlanet(pn).Mat(v)
}
// ProduceShip returns number of ships with shipMass planet p can produce in one turn
func ProduceShip(p *game.Planet, productionAvailable, shipMass float64) uint {
if productionAvailable <= 0 {
return 0
}
ships, materialLeft, productionUsed, progress := calc.ProduceShipsInTurn(
productionAvailable,
float64(p.Material),
float64(p.Resources),
shipMass,
)
p.Mat(materialLeft)
pval := game.F(progress)
if p.Production.Progress != nil {
pval += *p.Production.Progress
}
p.Production.Progress = &pval
used := game.F(productionUsed)
p.Production.ProdUsed = &used
return ships
}