fix(game): fight before departure and reorder the turn sequence
Tests · Go / test (push) Successful in 1m58s
Tests · Integration / integration (pull_request) Successful in 1m50s
Tests · Go / test (pull_request) Successful in 2m5s

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) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-31 00:25:46 +02:00
parent 5e86ca9999
commit b4abf90ec5
11 changed files with 198 additions and 53 deletions
+28 -8
View File
@@ -32,14 +32,23 @@ type BattleAction struct {
func CollectPlanetGroups(c *Cache) map[uint]map[int]bool { func CollectPlanetGroups(c *Cache) map[uint]map[int]bool {
planetGroup := make(map[uint]map[int]bool) planetGroup := make(map[uint]map[int]bool)
for groupIndex := range c.ShipGroupsIndex() { for groupIndex := range c.ShipGroupsIndex() {
state := c.ShipGroup(groupIndex).State() sg := c.ShipGroup(groupIndex)
if state == game.StateInOrbit || state == game.StateUpgrade { var planetNumber uint
planetNumber := c.ShipGroup(groupIndex).Destination switch sg.State() {
if _, ok := planetGroup[planetNumber]; !ok { case game.StateInOrbit, game.StateUpgrade:
planetGroup[planetNumber] = make(map[int]bool) planetNumber = sg.Destination
} case game.StateLaunched:
planetGroup[planetNumber][groupIndex] = false // 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 { for pl := range planetGroup {
if len(planetGroup[pl]) < 2 { 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 { 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 { func FilterBattleOpponents(c *Cache, attIdx, defIdx int, cacheProbability map[int]map[int]float64) bool {
+53
View File
@@ -326,3 +326,56 @@ func TestSingleBattleOneSidedWipe(t *testing.T) {
} }
assert.Equal(t, 5, kills, "exactly the five transports are destroyed") 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)
}
}
+11 -7
View File
@@ -20,25 +20,29 @@ func (c *Controller) MakeTurn() error {
c.Cache.g.Turn += 1 c.Cache.g.Turn += 1
c.Cache.g.Stage = 0 c.Cache.g.Stage = 0
// 01. Вышедшие расы удаляются из списка участвующих рас перед началом просчета очередного хода // 01. Вышедшие расы удаляются из списка участвующих рас перед началом просчёта очередного хода.
c.Cache.TurnWipeExtinctRaces() c.Cache.TurnWipeExtinctRaces()
// 02. Товары загружаются на корабли, находящиеся в начале грузовых маршрутов, и корабли входят в гиперпространство (но ещё не полетели) // 02. Корабли, где это возможно, объединяются в группы (до боя и до отправки по маршрутам).
c.Cache.SendRoutedGroups()
// 03. Корабли, где это возможно, объединяются в группы.
c.Cache.TurnMergeEqualShipGroups() c.Cache.TurnMergeEqualShipGroups()
// 04. Враждующие корабли вступают в схватку. // 03. Враждующие корабли вступают в схватку у планеты отправления. Корабли, которым отдан
// приказ на отлёт (статус Launched), ещё стоят на планете и участвуют в бою; в
// гиперпространство уходят только уцелевшие — так нельзя уклониться от боя.
battles := ProduceBattles(c.Cache) battles := ProduceBattles(c.Cache)
// 04. Товары загружаются на корабли в начале грузовых маршрутов, и эти корабли входят в
// гиперпространство. Загрузка после боя: маршрутные транспорты сражаются пустыми и не
// могут уклониться от боя, скрывшись в гиперпространстве.
c.Cache.SendRoutedGroups()
// 05. Корабли пролетают сквозь гиперпространство. // 05. Корабли пролетают сквозь гиперпространство.
c.Cache.MoveShipGroups() c.Cache.MoveShipGroups()
// 06. Корабли, где это возможно, объединяются в группы. // 06. Корабли, где это возможно, объединяются в группы.
c.Cache.TurnMergeEqualShipGroups() c.Cache.TurnMergeEqualShipGroups()
// 07. Враждующие корабли снова вступают в схватку (это происходит после выхода из гиперпространства). // 07. Враждующие корабли снова вступают в схватку (после выхода из гиперпространства).
battles = append(battles, ProduceBattles(c.Cache)...) battles = append(battles, ProduceBattles(c.Cache)...)
// 08. Корабли бомбят вражеские планеты. // 08. Корабли бомбят вражеские планеты.
+2 -2
View File
@@ -162,7 +162,7 @@ func (c *Cache) PlanetProductionCapacity(planetNumber uint) float64 {
p := c.MustPlanet(planetNumber) p := c.MustPlanet(planetNumber)
var busyResources float64 var busyResources float64
for sg := range c.shipGroupsInUpgrade(p.Number) { for sg := range c.shipGroupsInUpgrade(p.Number) {
busyResources += sg.StateUpgrade.Cost() busyResources += c.upgradeCostNow(sg)
} }
return p.ProductionCapacity() - busyResources return p.ProductionCapacity() - busyResources
} }
@@ -184,7 +184,7 @@ func (c *Cache) TurnPlanetProductions() {
// upgrade groups and return to in_orbit state // upgrade groups and return to in_orbit state
productionAvailable := c.PlanetProductionCapacity(pn) productionAvailable := c.PlanetProductionCapacity(pn)
for sg := range c.shipGroupsInUpgrade(p.Number) { for sg := range c.shipGroupsInUpgrade(p.Number) {
cost := sg.StateUpgrade.Cost() cost := c.upgradeCostNow(sg)
if productionAvailable >= cost { if productionAvailable >= cost {
for i := range sg.StateUpgrade.UpgradeTech { for i := range sg.StateUpgrade.UpgradeTech {
sg.Tech = sg.Tech.Set(sg.StateUpgrade.UpgradeTech[i].Tech, util.Fixed3(sg.StateUpgrade.UpgradeTech[i].Level.F())) sg.Tech = sg.Tech.Set(sg.StateUpgrade.UpgradeTech[i].Tech, util.Fixed3(sg.StateUpgrade.UpgradeTech[i].Level.F()))
+5 -1
View File
@@ -208,7 +208,11 @@ func TestProduceShips(t *testing.T) {
assert.Equal(t, game.StateInOrbit, c.ShipGroup(0).State()) assert.Equal(t, game.StateInOrbit, c.ShipGroup(0).State())
assert.Equal(t, 1.5, c.ShipGroup(0).TechLevel(game.TechDrive).F()) 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) { func TestProduceShip(t *testing.T) {
+1 -1
View File
@@ -491,7 +491,7 @@ func (c *Cache) shipGroupsInUpgrade(planetNumber uint) iter.Seq[*game.ShipGroup]
} }
} }
slices.SortFunc(result, func(a, b int) int { 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 { for i := range result {
if !yield(&c.g.ShipGroups[result[i]]) { if !yield(&c.g.ShipGroups[result[i]]) {
+12 -7
View File
@@ -34,15 +34,20 @@ func (c *Cache) MoveShipGroups() {
func (c *Cache) moveShipGroup(i int, delta float64) { func (c *Cache) moveShipGroup(i int, delta float64) {
sg := c.ShipGroup(i) sg := c.ShipGroup(i)
originX, originY, ok := sg.Coord() var originX, originY float64
if !ok { switch sg.State() {
panic(fmt.Sprintf("ship group state invalid: %v", 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) destPlanet := c.MustPlanet(sg.Destination)
arrived := false x, y, arrived := util.NextTravelCoord(c.g.Map.Width, c.g.Map.Height, originX, originY, destPlanet.X.F(), destPlanet.Y.F(), delta)
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)
fx, fy := game.F(x), game.F(y) fx, fy := game.F(x), game.F(y)
sg.StateInSpace.X = &fx sg.StateInSpace.X = &fx
sg.StateInSpace.Y = &fy sg.StateInSpace.Y = &fy
@@ -45,3 +45,20 @@ func TestListMoveableGroupIds(t *testing.T) {
assert.NotEqual(t, game.StateTransfer, sg.State()) 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)
}
@@ -196,6 +196,24 @@ func FutureUpgradeLevel(raceLevel, groupLevel, limit float64) float64 {
return target 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 { 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 { if v <= 0 || st.BlockMass(tech) == 0 || sg.TechLevel(tech).F() >= v {
return sg return sg
@@ -170,3 +170,24 @@ func TestShipGroupUpgrade(t *testing.T) {
g.ShipGroupUpgrade(Race_0.Name, c.ShipGroup(3).ID, "DRIVE", 1.3), g.ShipGroupUpgrade(Race_0.Name, c.ShipGroup(3).ID, "DRIVE", 1.3),
e.GenericErrorText(e.ErrShipsBusy)) 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)
}
+30 -27
View File
@@ -849,9 +849,12 @@ Freighter загруженный 15 ед. груза при технологии
находится с агрессором в состоянии мира, также вступит в сражение. Это вовсе находится с агрессором в состоянии мира, также вступит в сражение. Это вовсе
не означает, что у нападающих есть право первого выстрела, все сражающиеся не означает, что у нападающих есть право первого выстрела, все сражающиеся
стороны находятся в абсолютно равных условиях ведения сражения, и первым стороны находятся в абсолютно равных условиях ведения сражения, и первым
выстрелит тот, кто более удачлив. Отосланные с планеты вручную или по выстрелит тот, кто более удачлив. Корабли, которым отдан приказ на отлёт, а
маршруту корабли уже вошли в гиперпространство и участия в сражениях не также корабли, отправляемые по установленным грузовым маршрутам, к началу хода
принимают (см. "Последовательность действий"). ещё находятся на планете отправления и принимают участие в сражении у этой
планеты — в гиперпространство уходят лишь уцелевшие в нём корабли. Корабль,
уже находящийся в гиперпространстве, в сражениях не участвует вплоть до
прибытия на планету назначения (см. "Последовательность действий").
В каждом раунде сражения все корабли получают шанс выстрелить по противнику, В каждом раунде сражения все корабли получают шанс выстрелить по противнику,
разумеется, если в этом же раунде его противники не были более удачливы и не разумеется, если в этом же раунде его противники не были более удачливы и не
@@ -1080,57 +1083,57 @@ Freighter загруженный 15 ед. груза при технологии
Последовательность действий Последовательность действий
~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
После того, как получены приказы от всех рас, определено После того, как получены приказы от всех рас, происходит сам ход, т.е.
производство, загружены товары, корабли вошли в гиперпространство и следующая последовательность действий:
т.д. происходит сам ход, т.е. следующая последовательность действий:
- Корабли передаются новым владельцам. - Корабли передаются новым владельцам.
- Расы, покинувшие игру, освобождаются от своего имущества. - Расы, покинувшие игру, освобождаются от своего имущества.
- ------------------------------------ \ - Выполняются все отданные расами приказы. В частности, корабли разгружаются
} "Выполнение отданных приказов (всех?)" согласно приказам, а кораблям может быть отдан приказ на отлёт: такие корабли
- Корабли разгружаются согласно отданным приказам. / получают готовность к отлёту, но физически остаются на планете отправления.
- Корабли, где это возможно, объединяются в группы. - Корабли, где это возможно, объединяются в группы.
- Товары загружаются на корабли, находящиеся в начале грузовых - Враждующие корабли вступают в схватку у планет отправления. Корабли, которым
маршрутов. отдан приказ на отлёт, ещё стоят на планете и участвуют в этой схватке; в
гиперпространство уходят только уцелевшие.
- Корабли входят в гиперпространство. - Товары загружаются на корабли, находящиеся в начале грузовых маршрутов
(после схватки — маршрутные транспорты сражаются незагруженными).
- Враждующие корабли вступают в схватку. - Корабли (готовые к отлёту и снаряжённые по маршрутам) входят в
гиперпространство и пролетают сквозь него.
- Корабли пролетают сквозь гиперпространство.
- Корабли, где это возможно, объединяются в группы. - Корабли, где это возможно, объединяются в группы.
- Враждующие корабли вступают в схватку (после выхода из гиперпространства). - Враждующие корабли вступают в схватку (после выхода из гиперпространства,
у планет назначения).
- Корабли бомбят вражеские планеты. - Корабли бомбят вражеские планеты.
+ На планетах модернизируются корабли. - На планетах модернизируются корабли.
- На планетах строятся корабли (с учётом производственного потенциала, - На планетах строятся корабли (с учётом производственного потенциала,
оставшегося от модернизации кораблей). оставшегося от модернизации кораблей).
+ Корабли, где это возможно, объединяются в группы. - Корабли, где это возможно, объединяются в группы.
- На планетах производится промышленность, добывается сырье, - На планетах производится промышленность, добывается сырьё, разрабатываются
разрабатываются новые технологии. новые технологии.
- Увеличивается население планет. - Увеличивается население планет.
- Корабли разгружаются в конце грузовых маршрутов. - Корабли разгружаются в конце грузовых маршрутов.
+ Выгруженные колонисты увеличивают население планеты (если население - Выгруженные колонисты увеличивают население планеты (если население планеты
планеты ниже её размера). ниже её размера).
- Накопленная и выгруженная промышленность увеличивает - Накопленная и выгруженная промышленность увеличивает производственный уровень
производственный уровень планеты (если производственный уровень планеты (если производственный уровень планеты ниже уровня населения).
планеты ниже уровня населения).
- Происходит отмена маршрутов, выходящих за зону полета кораблей. - Происходит отмена маршрутов, выходящих за зону полёта кораблей.
- Происходит голосование. - Происходит голосование.