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 ед. груза при технологии Последовательность действий ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - После того, как получены приказы от всех рас, определено -производство, загружены товары, корабли вошли в гиперпространство и -т.д. происходит сам ход, т.е. следующая последовательность действий: + После того, как получены приказы от всех рас, происходит сам ход, т.е. +следующая последовательность действий: -- Корабли передаются новым владельцам. +- Корабли передаются новым владельцам. - Расы, покинувшие игру, освобождаются от своего имущества. -- ------------------------------------ \ - } "Выполнение отданных приказов (всех?)" -- Корабли разгружаются согласно отданным приказам. / +- Выполняются все отданные расами приказы. В частности, корабли разгружаются + согласно приказам, а кораблям может быть отдан приказ на отлёт: такие корабли + получают готовность к отлёту, но физически остаются на планете отправления. - Корабли, где это возможно, объединяются в группы. -- Товары загружаются на корабли, находящиеся в начале грузовых - маршрутов. +- Враждующие корабли вступают в схватку у планет отправления. Корабли, которым + отдан приказ на отлёт, ещё стоят на планете и участвуют в этой схватке; в + гиперпространство уходят только уцелевшие. -- Корабли входят в гиперпространство. +- Товары загружаются на корабли, находящиеся в начале грузовых маршрутов + (после схватки — маршрутные транспорты сражаются незагруженными). -- Враждующие корабли вступают в схватку. - -- Корабли пролетают сквозь гиперпространство. +- Корабли (готовые к отлёту и снаряжённые по маршрутам) входят в + гиперпространство и пролетают сквозь него. - Корабли, где это возможно, объединяются в группы. -- Враждующие корабли вступают в схватку (после выхода из гиперпространства). +- Враждующие корабли вступают в схватку (после выхода из гиперпространства, + у планет назначения). - Корабли бомбят вражеские планеты. -+ На планетах модернизируются корабли. +- На планетах модернизируются корабли. - На планетах строятся корабли (с учётом производственного потенциала, оставшегося от модернизации кораблей). -+ Корабли, где это возможно, объединяются в группы. +- Корабли, где это возможно, объединяются в группы. -- На планетах производится промышленность, добывается сырье, - разрабатываются новые технологии. +- На планетах производится промышленность, добывается сырьё, разрабатываются + новые технологии. - Увеличивается население планет. - Корабли разгружаются в конце грузовых маршрутов. -+ Выгруженные колонисты увеличивают население планеты (если население - планеты ниже её размера). +- Выгруженные колонисты увеличивают население планеты (если население планеты + ниже её размера). -- Накопленная и выгруженная промышленность увеличивает - производственный уровень планеты (если производственный уровень - планеты ниже уровня населения). +- Накопленная и выгруженная промышленность увеличивает производственный уровень + планеты (если производственный уровень планеты ниже уровня населения). -- Происходит отмена маршрутов, выходящих за зону полета кораблей. +- Происходит отмена маршрутов, выходящих за зону полёта кораблей. - Происходит голосование.