Files
galaxy-game/game/internal/controller/ship_group.go
T
Ilia Denisov b4abf90ec5
Tests · Go / test (push) Successful in 1m58s
Tests · Integration / integration (pull_request) Successful in 1m50s
Tests · Go / test (pull_request) Successful in 2m5s
fix(game): fight before departure and reorder the turn sequence
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>
2026-05-31 00:25:46 +02:00

568 lines
16 KiB
Go

package controller
import (
"cmp"
"fmt"
"iter"
"maps"
"slices"
"galaxy/util"
e "galaxy/error"
"galaxy/game/internal/model/game"
"github.com/google/uuid"
)
// ShipGroup is a proxy func, nothing to cache
func (c *Cache) ShipGroup(groupIndex int) *game.ShipGroup {
c.validateShipGroupIndex(groupIndex)
return &c.g.ShipGroups[groupIndex]
}
func (c *Cache) internalShipGroupJoinFleet(groupIndex int, fID uuid.UUID) {
c.validateShipGroupIndex(groupIndex)
c.g.ShipGroups[groupIndex].FleetID = &fID
c.invalidateFleetCache()
}
func (c *Cache) ShipGroupShipsNumber(groupIndex int, number uint) {
c.validateShipGroupIndex(groupIndex)
if c.g.ShipGroups[groupIndex].Number > 0 {
c.g.ShipGroups[groupIndex].Load = game.F(c.g.ShipGroups[groupIndex].Load.F() / float64(c.g.ShipGroups[groupIndex].Number) * float64(number))
}
c.g.ShipGroups[groupIndex].Number = number
}
func (c *Cache) ShipGroupsIndex() iter.Seq[int] {
return func(yield func(int) bool) {
for i := range c.g.ShipGroups {
if !yield(i) {
return
}
}
}
}
func (c *Cache) ShipGroupOwnerRaceIndex(groupIndex int) int {
c.validateShipGroupIndex(groupIndex)
if len(c.cacheRaceIndexByShipGroupIndex) == 0 {
c.cacheShipsAndGroups()
}
if v, ok := c.cacheRaceIndexByShipGroupIndex[groupIndex]; ok {
return v
} else {
panic(fmt.Sprintf("ShipGroupRace: group not found by index=%v", groupIndex))
}
}
func (c *Cache) ShipGroupOwnerRace(groupIndex int) *game.Race {
return &c.g.Race[c.ShipGroupOwnerRaceIndex(groupIndex)]
}
func (c *Cache) ShipGroupDestroyItem(i int) {
c.validateShipGroupIndex(i)
sg := &c.g.ShipGroups[i]
if sg.Number == 0 {
panic("group has no ships")
}
sg.Load = game.F(sg.Load.F() / float64(sg.Number) * float64(sg.Number-1))
sg.Number -= 1
}
func (c *Cache) DeleteKilledShipGroups() {
keepFleet := make(map[uuid.UUID]bool, len(c.g.Fleets))
for sgi := len(c.g.ShipGroups) - 1; sgi >= 0; sgi-- {
if c.g.ShipGroups[sgi].FleetID != nil {
id := *c.g.ShipGroups[sgi].FleetID
keepFleet[id] = keepFleet[id] || c.g.ShipGroups[sgi].Number > 0
}
if c.g.ShipGroups[sgi].Number == 0 {
c.g.ShipGroups = append(c.g.ShipGroups[:sgi], c.g.ShipGroups[sgi+1:]...)
}
}
c.invalidateShipGroupCache()
for id, keep := range keepFleet {
if keep {
continue
}
c.unsafeDeleteFleet(c.MustFleetIndex(id))
}
}
func (c *Cache) TurnMergeEqualShipGroups() {
for i := range c.listRaceActingIdx() {
c.transferPendingGroups(i)
c.shipGroupMerge(i)
}
}
func (c *Cache) transferPendingGroups(ri int) {
c.validateRaceIndex(ri)
for sg := range c.listShipGroups(ri) {
if sg.State() == game.StateTransfer {
sg.StateTransfer = false
}
}
}
// shipGroupMerge merges several equal ship groups into one
func (c *Cache) shipGroupMerge(ri int) {
c.validateRaceIndex(ri)
raceGroups := make([]game.ShipGroup, 0)
for sg := range c.listShipGroups(ri) {
raceGroups = append(raceGroups, *sg)
}
origin := len(raceGroups)
if origin < 2 {
return
}
for i := 0; i < len(raceGroups)-1; i++ {
for j := len(raceGroups) - 1; j > i; j-- {
if raceGroups[i].Equal(raceGroups[j]) {
raceGroups[i].ID = raceGroups[j].ID // resulting group will have latest ID
raceGroups[i].Number += raceGroups[j].Number
raceGroups = append(raceGroups[:j], raceGroups[j+1:]...)
}
}
}
if len(raceGroups) == origin {
return
}
toDelete := make([]int, 0)
for i := range c.ShipGroupsIndex() {
if c.ShipGroup(i).OwnerID == c.g.Race[ri].ID {
toDelete = append(toDelete, i)
}
}
slices.Sort(toDelete)
slices.Reverse(toDelete)
for _, sgi := range toDelete {
c.unsafeDeleteShipGroup(sgi)
}
for i := range raceGroups {
c.appendShipGroup(ri, &raceGroups[i])
}
}
func (c *Cache) shipGroupDismantle(ri int, groupIndex uuid.UUID) error {
c.validateRaceIndex(ri)
sgi, ok := c.raceShipGroupIndex(ri, groupIndex)
if !ok {
return e.NewEntityNotExistsError("group #%d", groupIndex)
}
if state := c.ShipGroup(sgi).State(); state != game.StateInOrbit {
return e.NewShipsBusyError("state: %s", state)
}
pl, ok := c.Planet(c.ShipGroup(sgi).Destination)
if !ok {
return e.NewGameStateError("planet #%d", c.ShipGroup(sgi).Destination)
}
p := *pl
st := c.ShipGroupShipClass(sgi)
if c.ShipGroup(sgi).CargoType != nil {
ct := *c.ShipGroup(sgi).CargoType
load := c.ShipGroup(sgi).Load.F()
switch ct {
case game.CargoColonist:
if p.OwnedBy(c.g.Race[ri].ID) {
p = game.UnloadColonists(p, load)
}
case game.CargoMaterial:
p.Material = p.Material.Add(load)
case game.CargoCapital:
p.Capital = p.Capital.Add(load)
}
}
p.Material = p.Material.Add(c.ShipGroup(sgi).EmptyMass(st))
c.unsafeDeleteShipGroup(sgi)
c.g.Map.Planet[c.MustPlanetIndex(p.Number)] = p
return nil
}
// Корабль может нести только один тип груза одновременно.
// Возможные типы груза - это колонисты, сырье и промышленность.
// Груз может быть доставлен на борт корабля с Вашей или не занятой планеты, на которой он имеется.
// Указанное количество груза равномерно распределяется между всеми кораблями группы.
func (c *Cache) shipGroupLoad(ri int, groupID uuid.UUID, ct game.CargoType, quantity float64) error {
c.validateRaceIndex(ri)
sgi, ok := c.raceShipGroupIndex(ri, groupID)
if !ok {
return e.NewEntityNotExistsError("group %s", groupID)
}
if state := c.ShipGroup(sgi).State(); state != game.StateInOrbit {
return e.NewShipsBusyError("state: %s", state)
}
p, ok := c.Planet(c.ShipGroup(sgi).Destination)
if !ok {
return e.NewGameStateError("planet #%d", c.ShipGroup(sgi).Destination)
}
if p.Owned() && !p.OwnedBy(c.g.Race[ri].ID) {
return e.NewEntityNotOwnedError("planet #%d", p.Number)
}
st := c.ShipGroupShipClass(sgi)
if st.Cargo < 1 {
return e.NewNoCargoBayError("ship_type %q", st.Name)
}
if c.ShipGroup(sgi).CargoType != nil && *c.ShipGroup(sgi).CargoType != ct {
return e.NewCargoLoadNotEqualError("cargo: %v", *c.ShipGroup(sgi).CargoType)
}
capacity := c.ShipGroup(sgi).CargoCapacity(st)
freeShipGroupCargoLoad := capacity - float64(c.ShipGroup(sgi).Load)
if freeShipGroupCargoLoad == 0 {
return e.NewCargoLoadNoSpaceLeftError()
}
var availableOnPlanet *game.Float
switch ct {
case game.CargoMaterial:
availableOnPlanet = &p.Material
case game.CargoCapital:
availableOnPlanet = &p.Capital
case game.CargoColonist:
availableOnPlanet = &p.Colonists
default:
return e.NewGameStateError("CargoType not accepted: %v", ct)
}
if quantity > float64(*availableOnPlanet) || *availableOnPlanet == 0 {
return e.NewCargoLoadNotEnoughError("planet: #%d, %s=%.03f", p.Number, ct, *availableOnPlanet)
}
toBeLoaded := quantity
if quantity == 0 {
toBeLoaded = float64(*availableOnPlanet)
}
if toBeLoaded > freeShipGroupCargoLoad {
toBeLoaded = freeShipGroupCargoLoad
}
*availableOnPlanet = (*availableOnPlanet).Add(-toBeLoaded)
c.ShipGroup(sgi).Load = c.ShipGroup(sgi).Load.Add(toBeLoaded)
if c.ShipGroup(sgi).Load > 0 {
c.ShipGroup(sgi).CargoType = &ct
}
return nil
}
// Промышленность и Сырье могут быть выгружены на любой планете.
// Колонисты могут быть высажены только на планеты, принадлежащие Вам или на необитаемые планеты.
func (c *Cache) shipGroupUnload(ri int, groupID uuid.UUID, quantity float64) error {
c.validateRaceIndex(ri)
sgi, ok := c.raceShipGroupIndex(ri, groupID)
if !ok {
return e.NewEntityNotExistsError("group %s", groupID)
}
if state := c.ShipGroup(sgi).State(); state != game.StateInOrbit {
return e.NewShipsBusyError("state: %s", state)
}
st := c.ShipGroupShipClass(sgi)
if st.Cargo < 1 {
return e.NewNoCargoBayError("ship_type %q", st.Name)
}
if c.ShipGroup(sgi).CargoType == nil || c.ShipGroup(sgi).Load == 0 {
return e.NewCargoUnloadEmptyError()
}
ct := *c.ShipGroup(sgi).CargoType
p := c.MustPlanet(c.ShipGroup(sgi).Destination)
if ct == game.CargoColonist && p.Owned() && !p.OwnedBy(c.g.Race[ri].ID) {
return e.NewEntityNotOwnedError("planet #%d unload %v", p.Number, ct)
}
c.unsafeUnloadCargo(sgi, UnloadCargoRequest(float64(c.ShipGroup(sgi).Load), quantity))
return nil
}
func UnloadCargoRequest(load, quantity float64) float64 {
result := quantity
if result == 0 || result > load {
result = load
}
return result
}
func (c *Cache) shipGroupIndexByID(id uuid.UUID) (int, bool) {
for sgi := range c.g.ShipGroups {
if c.g.ShipGroups[sgi].ID == id {
return sgi, true
}
}
return -1, false
}
func (c *Cache) unsafeUnloadCargo(sgi int, q float64) {
if q <= 0 {
return
}
if st := c.ShipGroup(sgi).State(); st != game.StateInOrbit {
panic(fmt.Sprintf("invalid group state: %v", st))
}
c.validateShipGroupIndex(sgi)
p := c.MustPlanet(c.ShipGroup(sgi).Destination)
ct := *c.ShipGroup(sgi).CargoType
var availableOnPlanet *game.Float
switch ct {
case game.CargoColonist:
availableOnPlanet = &p.Colonists
if !p.Owned() {
p.Own(c.ShipGroup(sgi).OwnerID)
p.Production = game.ProductionCapital.AsType(uuid.Nil)
}
case game.CargoMaterial:
availableOnPlanet = &p.Material
case game.CargoCapital:
availableOnPlanet = &p.Capital
}
*availableOnPlanet = (*availableOnPlanet).Add(q)
c.ShipGroup(sgi).Load = c.ShipGroup(sgi).Load.Add(-q)
if c.ShipGroup(sgi).Load == 0 {
c.ShipGroup(sgi).CargoType = nil
}
p.UnpackColonists()
p.UnpackCapital()
}
func (c *Cache) shipGroupTransfer(ri, riAccept int, groupID uuid.UUID) (err error) {
c.validateRaceIndex(ri)
if ri == riAccept {
return e.NewSameRaceError(c.g.Race[riAccept].Name)
}
sgi, ok := c.raceShipGroupIndex(ri, groupID)
if !ok {
return e.NewEntityNotExistsError("group %s", groupID)
}
sg := c.ShipGroup(sgi)
state := sg.State()
if state == game.StateTransfer {
return e.NewShipsBusyError("state: %s", state)
}
st := c.ShipGroupShipClass(sgi)
var stAcc int
var name = st.Name
if stAcc = slices.IndexFunc(c.g.Race[riAccept].ShipTypes, func(v game.ShipType) bool { return v.Name == st.Name }); stAcc >= 0 &&
!st.Equal(c.g.Race[riAccept].ShipTypes[stAcc]) {
name = util.AppendRandomSuffix(name)
}
if stAcc < 0 || name != st.Name {
err = c.ShipClassCreate(riAccept,
name,
st.Drive.F(),
int(st.Armament),
st.Weapons.F(),
st.Shields.F(),
st.Cargo.F())
if err != nil {
return err
}
stAcc = len(c.g.Race[riAccept].ShipTypes) - 1
}
newGroup := *(sg)
newGroup.ID = uuid.New()
newGroup.TypeID = c.g.Race[riAccept].ShipTypes[stAcc].ID
newGroup.Tech = maps.Clone(sg.Tech)
if state == game.StateLaunched {
newGroup.StateTransfer = true
}
c.appendShipGroup(riAccept, &newGroup)
c.unsafeDeleteShipGroup(sgi)
return nil
}
func (c *Cache) ShipGroupBreak(ri int, groupID, newID uuid.UUID, quantity uint) (err error) {
c.validateRaceIndex(ri)
sgi, ok := c.raceShipGroupIndex(ri, groupID)
if !ok {
return e.NewEntityNotExistsError("group %s", groupID)
}
for sgi := range c.g.ShipGroups {
if c.g.ShipGroups[sgi].ID == newID {
return e.NewEntityDuplicateIdentifierError("group %s", newID)
}
}
if state := c.ShipGroup(sgi).State(); state != game.StateInOrbit {
return e.NewShipsBusyError()
}
if c.ShipGroup(sgi).Number < quantity {
return e.NewBreakGroupNumberNotEnoughError("%d<%d", c.ShipGroup(sgi).Number, quantity)
}
if quantity > 0 && quantity < c.ShipGroup(sgi).Number {
if sgi, err = c.breakGroup(ri, groupID, quantity); err != nil {
return
}
}
c.ShipGroup(sgi).FleetID = nil
return nil
}
func (c *Cache) breakGroup(ri int, groupID uuid.UUID, newGroupShips uint) (int, error) {
c.validateRaceIndex(ri)
sgi, ok := c.raceShipGroupIndex(ri, groupID)
if !ok {
return -1, e.NewEntityNotExistsError("group %s", groupID)
}
if c.ShipGroup(sgi).Number < newGroupShips {
return -1, e.NewBreakGroupIllegalNumberError("group=%s ships: %d -> %d", c.ShipGroup(sgi).ID, c.ShipGroup(sgi).Number, newGroupShips)
}
return c.unsafeBreakGroup(ri, sgi, newGroupShips), nil
}
func (c *Cache) unsafeBreakGroup(ri, sgi int, newGroupShips uint) int {
newGroup := *c.ShipGroup(sgi)
if c.ShipGroup(sgi).CargoType != nil {
newGroup.Load = game.F(float64(c.ShipGroup(sgi).Load) / float64(c.ShipGroup(sgi).Number) * float64(newGroupShips))
}
newGroup.Number = newGroupShips
c.ShipGroupShipsNumber(sgi, c.ShipGroup(sgi).Number-newGroup.Number)
newGroup.FleetID = nil
return c.appendShipGroup(ri, &newGroup)
}
// Internal funcs
func (c *Cache) raceShipGroupIndex(ri int, id uuid.UUID) (int, bool) {
c.validateRaceIndex(ri)
for i := range c.ShipGroupsIndex() {
if c.ShipGroupOwnerRaceIndex(i) == ri && c.ShipGroup(i).ID == id {
return i, true
}
}
return -1, false
}
func (c *Cache) listShipGroupIdx(ri int) iter.Seq[int] {
c.validateRaceIndex(ri)
return func(yield func(int) bool) {
for i := range c.g.ShipGroups {
if ri == c.ShipGroupOwnerRaceIndex(i) {
if !yield(i) {
return
}
}
}
}
}
func (c *Cache) listShipGroups(ri int) iter.Seq[*game.ShipGroup] {
c.validateRaceIndex(ri)
return func(yield func(*game.ShipGroup) bool) {
for sgi := range c.listShipGroupIdx(ri) {
if !yield(c.ShipGroup(sgi)) {
return
}
}
}
}
func (c *Cache) shipGroupsInUpgrade(planetNumber uint) iter.Seq[*game.ShipGroup] {
return func(yield func(*game.ShipGroup) bool) {
result := make([]int, 0)
for sg := range c.g.ShipGroups {
// number checked for further sanity after battles
if c.g.ShipGroups[sg].Number > 0 && c.g.ShipGroups[sg].Destination == planetNumber && c.g.ShipGroups[sg].State() == game.StateUpgrade {
result = append(result, sg)
}
}
slices.SortFunc(result, func(a, b int) int {
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]]) {
return
}
}
}
}
func (c *Cache) unsafeDeleteShipGroup(sgi int) {
c.validateShipGroupIndex(sgi)
sg := c.ShipGroup(sgi)
if sg.FleetID != nil {
fi := c.MustFleetIndex(*sg.FleetID)
fleetGroups := slices.Collect(c.fleetGroupIds(c.RaceIndex(sg.OwnerID), fi))
if len(fleetGroups) == 1 {
// remove fleet when deleting last group in the fleet
c.unsafeDeleteFleet(fi)
}
}
c.g.ShipGroups = append(c.g.ShipGroups[:sgi], c.g.ShipGroups[sgi+1:]...)
c.invalidateShipGroupCache()
}
func (c *Cache) validateShipGroupIndex(i int) {
if i >= len(c.g.ShipGroups) {
panic(fmt.Sprintf("group index out of range: %d >= %d", i, len(c.g.ShipGroups)))
}
}
func (c *Cache) unsafeCreateShips(ri int, classID uuid.UUID, planet uint, quantity uint) int {
st := c.MustShipType(ri, classID)
level := func(t game.Tech) float64 {
if t == game.TechDrive && st.DriveBlockMass() > 0 {
return util.Fixed3(c.g.Race[ri].TechLevel(game.TechDrive))
}
if t == game.TechWeapons && st.WeaponsBlockMass() > 0 {
return util.Fixed3(c.g.Race[ri].TechLevel(game.TechWeapons))
}
if t == game.TechShields && st.ShieldsBlockMass() > 0 {
return util.Fixed3(c.g.Race[ri].TechLevel(game.TechShields))
}
if t == game.TechCargo && st.CargoBlockMass() > 0 {
return util.Fixed3(c.g.Race[ri].TechLevel(game.TechCargo))
}
return 0
}
return c.appendShipGroup(ri, &game.ShipGroup{
OwnerID: c.g.Race[ri].ID,
TypeID: classID,
Destination: planet,
Number: uint(quantity),
Tech: map[game.Tech]game.Float{
game.TechDrive: game.F(level(game.TechDrive)),
game.TechWeapons: game.F(level(game.TechWeapons)),
game.TechShields: game.F(level(game.TechShields)),
game.TechCargo: game.F(level(game.TechCargo)),
},
})
}
func (c *Cache) appendShipGroup(ri int, sg *game.ShipGroup) int {
c.validateRaceIndex(ri)
sg.ID = uuid.New()
sg.OwnerID = c.g.Race[ri].ID
sg.FleetID = nil
c.g.ShipGroups = append(c.g.ShipGroups, *sg)
i := len(c.g.ShipGroups) - 1
c.invalidateShipGroupCache()
return i
}