fix(game): fight before departure and reorder the turn sequence #77

Merged
developer merged 2 commits from feature/game-turn-order-departures into development 2026-05-31 06:29:07 +00:00
11 changed files with 234 additions and 55 deletions
+28 -8
View File
@@ -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 {
+53
View File
@@ -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)
}
}
+11 -7
View File
@@ -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. Корабли бомбят вражеские планеты.
+9 -4
View File
@@ -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
}
@@ -181,10 +181,15 @@ 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 := 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()))
+34 -1
View File
@@ -208,7 +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())
assert.Equal(t, 4346.676567656759, c.MustPlanet(R0_Planet_0_num).Material.F())
// 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) {
+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 {
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]]) {
+12 -7
View File
@@ -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
@@ -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)
}
@@ -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
@@ -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)
}
+30 -27
View File
@@ -849,9 +849,12 @@ Freighter загруженный 15 ед. груза при технологии
находится с агрессором в состоянии мира, также вступит в сражение. Это вовсе
не означает, что у нападающих есть право первого выстрела, все сражающиеся
стороны находятся в абсолютно равных условиях ведения сражения, и первым
выстрелит тот, кто более удачлив. Отосланные с планеты вручную или по
маршруту корабли уже вошли в гиперпространство и участия в сражениях не
принимают (см. "Последовательность действий").
выстрелит тот, кто более удачлив. Корабли, которым отдан приказ на отлёт, а
также корабли, отправляемые по установленным грузовым маршрутам, к началу хода
ещё находятся на планете отправления и принимают участие в сражении у этой
планеты — в гиперпространство уходят лишь уцелевшие в нём корабли. Корабль,
уже находящийся в гиперпространстве, в сражениях не участвует вплоть до
прибытия на планету назначения (см. "Последовательность действий").
В каждом раунде сражения все корабли получают шанс выстрелить по противнику,
разумеется, если в этом же раунде его противники не были более удачливы и не
@@ -1080,57 +1083,57 @@ Freighter загруженный 15 ед. груза при технологии
Последовательность действий
~~~~~~~~~~~~~~~~~~~~~~~~~~~
После того, как получены приказы от всех рас, определено
производство, загружены товары, корабли вошли в гиперпространство и
т.д. происходит сам ход, т.е. следующая последовательность действий:
После того, как получены приказы от всех рас, происходит сам ход, т.е.
следующая последовательность действий:
- Корабли передаются новым владельцам.
- Корабли передаются новым владельцам.
- Расы, покинувшие игру, освобождаются от своего имущества.
- ------------------------------------ \
} "Выполнение отданных приказов (всех?)"
- Корабли разгружаются согласно отданным приказам. /
- Выполняются все отданные расами приказы. В частности, корабли разгружаются
согласно приказам, а кораблям может быть отдан приказ на отлёт: такие корабли
получают готовность к отлёту, но физически остаются на планете отправления.
- Корабли, где это возможно, объединяются в группы.
- Товары загружаются на корабли, находящиеся в начале грузовых
маршрутов.
- Враждующие корабли вступают в схватку у планет отправления. Корабли, которым
отдан приказ на отлёт, ещё стоят на планете и участвуют в этой схватке; в
гиперпространство уходят только уцелевшие.
- Корабли входят в гиперпространство.
- Товары загружаются на корабли, находящиеся в начале грузовых маршрутов
(после схватки — маршрутные транспорты сражаются незагруженными).
- Враждующие корабли вступают в схватку.
- Корабли пролетают сквозь гиперпространство.
- Корабли (готовые к отлёту и снаряжённые по маршрутам) входят в
гиперпространство и пролетают сквозь него.
- Корабли, где это возможно, объединяются в группы.
- Враждующие корабли вступают в схватку (после выхода из гиперпространства).
- Враждующие корабли вступают в схватку (после выхода из гиперпространства,
у планет назначения).
- Корабли бомбят вражеские планеты.
+ На планетах модернизируются корабли.
- На планетах модернизируются корабли.
- На планетах строятся корабли (с учётом производственного потенциала,
оставшегося от модернизации кораблей).
+ Корабли, где это возможно, объединяются в группы.
- Корабли, где это возможно, объединяются в группы.
- На планетах производится промышленность, добывается сырье,
разрабатываются новые технологии.
- На планетах производится промышленность, добывается сырьё, разрабатываются
новые технологии.
- Увеличивается население планет.
- Корабли разгружаются в конце грузовых маршрутов.
+ Выгруженные колонисты увеличивают население планеты (если население
планеты ниже её размера).
- Выгруженные колонисты увеличивают население планеты (если население планеты
ниже её размера).
- Накопленная и выгруженная промышленность увеличивает
производственный уровень планеты (если производственный уровень
планеты ниже уровня населения).
- Накопленная и выгруженная промышленность увеличивает производственный уровень
планеты (если производственный уровень планеты ниже уровня населения).
- Происходит отмена маршрутов, выходящих за зону полета кораблей.
- Происходит отмена маршрутов, выходящих за зону полёта кораблей.
- Происходит голосование.