cmd: send fleet

This commit is contained in:
Ilia Denisov
2026-01-05 19:46:33 +02:00
parent a6093a1c29
commit 5f3a416abd
7 changed files with 271 additions and 38 deletions
+85 -22
View File
@@ -1,6 +1,7 @@
package game package game
import ( import (
"fmt"
"iter" "iter"
"math" "math"
"slices" "slices"
@@ -13,10 +14,50 @@ type Fleet struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"ownerId"` OwnerID uuid.UUID `json:"ownerId"`
Name string `json:"name"` Name string `json:"name"`
}
Destination uint `json:"destination"` func FleetState(g *Game, fleetID uuid.UUID) (ShipGroupState, *uint, *InSpace) {
Origin *uint `json:"origin,omitempty"` fi := slices.IndexFunc(g.Fleets, func(f Fleet) bool { return f.ID == fleetID })
Range *float64 `json:"range,omitempty"` if fi < 0 {
panic("FleetState: fleet id not found: " + fleetID.String())
}
ri := slices.IndexFunc(g.Race, func(r Race) bool { return r.ID == g.Fleets[fi].OwnerID })
if ri < 0 {
panic("FleetState: race id not found: " + g.Fleets[fi].OwnerID.String())
}
var state *ShipGroupState
var onPlanet *uint
var is *InSpace
for sg := range FleetGroups(g, ri, fi) {
if state == nil {
s := sg.State()
state = &s
if planet, ok := sg.OnPlanet(); ok {
onPlanet = &planet
}
is = sg.StateInSpace
continue
}
if *state != sg.State() {
panic(fmt.Sprintf("FleetState: one or more ships in race's %q fleet %q has different states", g.Race[ri].Name, g.Fleets[fi].Name))
}
if planet, ok := sg.OnPlanet(); ok && onPlanet != nil && *onPlanet != planet {
for sg := range FleetGroups(g, ri, fi) {
fmt.Println("group", sg.Index, "fleet", sg.FleetID, g.Fleets[fi].Name, "state", sg.State(), "on", sg.Destination)
}
panic(fmt.Sprintf("FleetState: one or more ships in race's %q fleet %q are on different planets: %d <> %d", g.Race[ri].Name, g.Fleets[fi].Name, *onPlanet, planet))
}
if (is == nil && sg.StateInSpace != nil) || (is != nil && sg.StateInSpace == nil) {
panic(fmt.Sprintf("FleetState: one or more ships in race's %q fleet %q on_planet and in_space at the same time", g.Race[ri].Name, g.Fleets[fi].Name))
}
if is != nil && sg.StateInSpace != nil && !is.Equal(*sg.StateInSpace) {
panic(fmt.Sprintf("FleetState: one or more ships in race's %q fleet %q has different is_space states", g.Race[ri].Name, g.Fleets[fi].Name))
}
}
if state == nil {
panic(fmt.Sprintf("FleetState: race's %q fleet %q has no ships", g.Race[ri].Name, g.Fleets[fi].Name))
}
return *state, onPlanet, is
} }
// TODO: Hello! Wanna know fleet's speed? Good. Implement & test this func first. // TODO: Hello! Wanna know fleet's speed? Good. Implement & test this func first.
@@ -51,7 +92,7 @@ func (g *Game) JoinFleets(raceName, fleetSourceName, fleetTargetName string) err
return g.joinFleetsInternal(ri, fleetSourceName, fleetTargetName) return g.joinFleetsInternal(ri, fleetSourceName, fleetTargetName)
} }
func (g *Game) joinShipGroupToFleetInternal(ri int, fleetName string, group, count uint) (err error) { func (g *Game) joinShipGroupToFleetInternal(ri int, fleetName string, groupIndex, quantity uint) (err error) {
name, ok := validateTypeName(fleetName) name, ok := validateTypeName(fleetName)
if !ok { if !ok {
return e.NewEntityTypeNameValidationError("%q", name) return e.NewEntityTypeNameValidationError("%q", name)
@@ -59,7 +100,7 @@ func (g *Game) joinShipGroupToFleetInternal(ri int, fleetName string, group, cou
sgi := -1 sgi := -1
var maxIndex uint var maxIndex uint
for i, sg := range g.listIndexShipGroups(ri) { for i, sg := range g.listIndexShipGroups(ri) {
if sgi < 0 && sg.Index == group { if sgi < 0 && sg.Index == groupIndex {
sgi = i sgi = i
} }
if sg.Index > maxIndex { if sg.Index > maxIndex {
@@ -67,34 +108,40 @@ func (g *Game) joinShipGroupToFleetInternal(ri int, fleetName string, group, cou
} }
} }
if sgi < 0 { if sgi < 0 {
return e.NewEntityNotExistsError("group #%d", group) return e.NewEntityNotExistsError("group #%d", groupIndex)
} }
if g.ShipGroups[sgi].State() != StateInOrbit { if g.ShipGroups[sgi].State() != StateInOrbit {
return e.NewShipsBusyError() return e.NewShipsBusyError()
} }
if g.ShipGroups[sgi].Number < count { if g.ShipGroups[sgi].Number < quantity {
return e.NewJoinFleetGroupNumberNotEnoughError("%d<%d", g.ShipGroups[sgi].Number, count) return e.NewJoinFleetGroupNumberNotEnoughError("%d<%d", g.ShipGroups[sgi].Number, quantity)
} }
fi := g.fleetIndex(ri, name) fi := g.fleetIndex(ri, name)
if fi < 0 { if fi < 0 {
fi, err = g.createFleet(ri, name, g.ShipGroups[sgi].Destination) fi, err = g.createFleet(ri, name)
if err != nil { if err != nil {
return err return err
} }
} else { } else {
if g.Fleets[fi].Destination != g.ShipGroups[sgi].Destination || g.Fleets[fi].Origin != nil || g.Fleets[fi].Range != nil { state, onPlanet, _ := FleetState(g, g.Fleets[fi].ID)
if state != StateInOrbit || *onPlanet != g.ShipGroups[sgi].Destination {
return e.NewShipsNotOnSamePlanetError("fleet: %s", fleetName) return e.NewShipsNotOnSamePlanetError("fleet: %s", fleetName)
} }
} }
// FIXME: if g.ShipGroups[sgi].FleetID != nil { // delete old fleet if empty, ALSO mind breaking group } // FIXME: if g.ShipGroups[sgi].FleetID != nil { // delete old fleet if empty, ALSO mind breaking group }
if count > 0 && g.ShipGroups[sgi].Number != count { if quantity > 0 && quantity < g.ShipGroups[sgi].Number {
// nsgi, err := g.breakGroupSafe(ri, groupIndex, quantity)
// if err != nil {
// return err
// }
// sgi = nsgi
newGroup := g.ShipGroups[sgi] newGroup := g.ShipGroups[sgi]
newGroup.Number -= count newGroup.Number -= quantity
g.ShipGroups[sgi].Number = count g.ShipGroups[sgi].Number = quantity
newGroup.Index = maxIndex + 1 newGroup.Index = maxIndex + 1
g.ShipGroups = append(g.ShipGroups, newGroup) g.ShipGroups = append(g.ShipGroups, newGroup)
} }
@@ -103,7 +150,7 @@ func (g *Game) joinShipGroupToFleetInternal(ri int, fleetName string, group, cou
return nil return nil
} }
func (g *Game) createFleet(ri int, name string, planetNumber uint) (int, error) { func (g *Game) createFleet(ri int, name string) (int, error) {
n, ok := validateTypeName(name) n, ok := validateTypeName(name)
if !ok { if !ok {
return 0, e.NewEntityTypeNameValidationError("%q", n) return 0, e.NewEntityTypeNameValidationError("%q", n)
@@ -111,12 +158,13 @@ func (g *Game) createFleet(ri int, name string, planetNumber uint) (int, error)
if fl := g.fleetIndex(ri, n); fl >= 0 { if fl := g.fleetIndex(ri, n); fl >= 0 {
return 0, e.NewEntityTypeNameDuplicateError("fleet %w", g.Fleets[fl].Name) return 0, e.NewEntityTypeNameDuplicateError("fleet %w", g.Fleets[fl].Name)
} }
g.Fleets = append(g.Fleets, Fleet{ fleets := slices.Clone(g.Fleets)
ID: uuid.New(), fleets = append(fleets, Fleet{
OwnerID: g.Race[ri].ID, ID: uuid.New(),
Name: n, OwnerID: g.Race[ri].ID,
Destination: planetNumber, Name: n,
}) })
g.Fleets = fleets
return len(g.Fleets) - 1, nil return len(g.Fleets) - 1, nil
} }
@@ -129,9 +177,9 @@ func (g *Game) joinFleetsInternal(ri int, fleetSourceName, fleetTargetName strin
if fiTarget < 0 { if fiTarget < 0 {
return e.NewEntityNotExistsError("target fleet %s", fleetTargetName) return e.NewEntityNotExistsError("target fleet %s", fleetTargetName)
} }
if g.Fleets[fiSource].Destination != g.Fleets[fiTarget].Destination || srcState, planet1, _ := FleetState(g, g.Fleets[fiSource].ID)
g.Fleets[fiSource].Origin != nil || g.Fleets[fiTarget].Origin != nil || tgtState, planet2, _ := FleetState(g, g.Fleets[fiTarget].ID)
g.Fleets[fiSource].Range != nil || g.Fleets[fiTarget].Range != nil { if srcState != StateInOrbit || srcState != tgtState || *planet1 != *planet2 {
return e.NewShipsNotOnSamePlanetError() return e.NewShipsNotOnSamePlanetError()
} }
for sgi, sg := range g.listIndexShipGroups(ri) { for sgi, sg := range g.listIndexShipGroups(ri) {
@@ -181,3 +229,18 @@ func (g Game) listIndexFleets(ri int) iter.Seq2[int, Fleet] {
} }
} }
} }
func FleetGroups(g *Game, ri, fi int) iter.Seq[ShipGroup] {
if len(g.Fleets) < fi+1 {
panic(fmt.Sprintf("FleetGroups: game fleets index %d invalid: len=%d", fi, len(g.Fleets)))
}
return func(yield func(ShipGroup) bool) {
for sg := range g.listShipGroups(ri) {
if sg.FleetID != nil && *sg.FleetID == g.Fleets[fi].ID {
if !yield(sg) {
break
}
}
}
}
}
+80
View File
@@ -0,0 +1,80 @@
package game
import (
"fmt"
"slices"
e "github.com/iliadenisov/galaxy/internal/error"
"github.com/iliadenisov/galaxy/internal/util"
)
func (g *Game) SendFleet(raceName, fleetName string, planetNumber uint) error {
ri, err := g.raceIndex(raceName)
if err != nil {
return err
}
fi := g.fleetIndex(ri, fleetName)
if fi < 0 {
return e.NewEntityNotExistsError("fleet %q", fleetName)
}
return g.sendFleetInternal(ri, fi, planetNumber)
}
func (g *Game) sendFleetInternal(ri, fi int, planetNumber uint) error {
state, sourcePlanet, _ := FleetState(g, g.Fleets[fi].ID)
if StateInOrbit != state && StateLaunched != state {
return e.NewShipsBusyError()
}
p1, ok := PlanetByNum(g, *sourcePlanet)
if !ok {
return e.NewGameStateError("source planet #%d does not exists", sourcePlanet)
}
p2, ok := PlanetByNum(g, planetNumber)
if !ok {
return e.NewEntityNotExistsError("destination planet #%d", planetNumber)
}
rangeToDestination := util.ShortDistance(g.Map.Width, g.Map.Height, p1.X, p1.Y, p2.X, p2.Y)
if rangeToDestination > g.Race[ri].FlightDistance() {
return e.NewSendUnreachableDestinationError("range=%.03f", rangeToDestination)
}
for sg := range FleetGroups(g, ri, fi) {
st, ok := ShipClass(g, ri, sg.TypeID)
if !ok {
return e.NewGameStateError("not found: ShipType ID=%v", sg.TypeID)
}
if st.DriveBlockMass() == 0 {
return e.NewSendShipHasNoDrivesError("Class=%s", st.Name)
}
}
if *sourcePlanet == planetNumber {
UnsendFleet(g, ri, fi)
return nil
}
LaunchFleet(g, ri, fi, planetNumber)
return nil
}
func LaunchFleet(g *Game, ri, fi int, destination uint) {
for sg := range FleetGroups(g, ri, fi) {
sgi := slices.IndexFunc(g.ShipGroups, func(s ShipGroup) bool { return sg.Index == s.Index })
if sgi < 0 {
panic(fmt.Sprintf("LauncgFleet: cannot find ship group index=%d", sg.Index))
}
g.ShipGroups[sgi] = LaunchShips(sg, destination)
}
}
func UnsendFleet(g *Game, ri, fi int) {
for sg := range FleetGroups(g, ri, fi) {
sgi := slices.IndexFunc(g.ShipGroups, func(s ShipGroup) bool { return sg.Index == s.Index })
if sgi < 0 {
panic(fmt.Sprintf("UnsendFleet: cannot find ship group index=%d", sg.Index))
}
g.ShipGroups[sgi] = UnsendShips(sg)
}
}
+73
View File
@@ -0,0 +1,73 @@
package game_test
import (
"slices"
"testing"
e "github.com/iliadenisov/galaxy/internal/error"
"github.com/iliadenisov/galaxy/internal/model/game"
"github.com/stretchr/testify/assert"
)
func TestSendFleet(t *testing.T) {
g := newGame()
// group #1 - in_orbit Planet_0
assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 1))
// group #2 - in_space (later)
assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 3))
// group #3 - in_orbit Planet_0, unmovable
g.CreateShipType(Race_0.Name, "Fortress", 0, 30, 100, 0, 50)
assert.NoError(t, g.CreateShips(Race_0_idx, "Fortress", R0_Planet_0_num, 1))
// group #4 - in_orbit Planet_0
assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 2))
// ensure race has no Fleets
assert.Len(t, slices.Collect(g.ListFleets(Race_0_idx)), 0)
fleetSending := "R0_Fleet_one"
fleetInSpace := "R0_Fleet_inSpace"
fleetUnmovable := "R0_Fleet_unmovable"
assert.NoError(t, g.JoinShipGroupToFleet(Race_0.Name, fleetSending, 1, 0))
assert.NoError(t, g.JoinShipGroupToFleet(Race_0.Name, fleetSending, 3, 0))
assert.NoError(t, g.JoinShipGroupToFleet(Race_0.Name, fleetInSpace, 2, 0))
assert.NoError(t, g.JoinShipGroupToFleet(Race_0.Name, fleetUnmovable, 3, 0))
// group #2 - in_space
g.ShipGroups[1].StateInSpace = &game.InSpace{Origin: 2, Range: 1.23}
assert.ErrorContains(t,
g.SendFleet("UnknownRace", fleetSending, 2),
e.GenericErrorText(e.ErrInputUnknownRace))
assert.ErrorContains(t,
g.SendFleet(Race_0.Name, "UnknownFleet", 2),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.SendFleet(Race_0.Name, fleetInSpace, 2),
e.GenericErrorText(e.ErrShipsBusy))
assert.ErrorContains(t,
g.SendFleet(Race_0.Name, fleetSending, 200),
e.GenericErrorText(e.ErrInputEntityNotExists))
assert.ErrorContains(t,
g.SendFleet(Race_0.Name, fleetSending, 3),
e.GenericErrorText(e.ErrSendUnreachableDestination))
assert.ErrorContains(t,
g.SendFleet(Race_0.Name, fleetUnmovable, 2),
e.GenericErrorText(e.ErrSendShipHasNoDrives))
assert.NoError(t, g.SendFleet(Race_0.Name, fleetSending, 2))
fi := slices.IndexFunc(slices.Collect(g.ListFleets(Race_0_idx)), func(f game.Fleet) bool { return f.Name == fleetSending })
state, _, _ := game.FleetState(g, g.Fleets[fi].ID)
assert.Equal(t, game.StateLaunched, state)
for sg := range game.FleetGroups(g, Race_0_idx, fi) {
assert.Equal(t, game.StateLaunched, sg.State())
}
assert.NoError(t, g.SendFleet(Race_0.Name, fleetSending, 0))
fi = slices.IndexFunc(slices.Collect(g.ListFleets(Race_0_idx)), func(f game.Fleet) bool { return f.Name == fleetSending })
state, _, _ = game.FleetState(g, g.Fleets[fi].ID)
assert.Equal(t, game.StateInOrbit, state)
for sg := range game.FleetGroups(g, Race_0_idx, fi) {
assert.Equal(t, game.StateInOrbit, sg.State())
}
}
+14 -14
View File
@@ -41,9 +41,8 @@ func TestJoinShipGroupToFleet(t *testing.T) {
gi := 0 gi := 0
assert.Len(t, fleets, 1) assert.Len(t, fleets, 1)
assert.Equal(t, fleets[0].Name, fleetOne) assert.Equal(t, fleets[0].Name, fleetOne)
assert.Equal(t, fleets[0].Destination, groups[gi].Destination) state, _, _ := game.FleetState(g, fleets[0].ID)
assert.Nil(t, fleets[0].Origin) assert.Equal(t, game.StateInOrbit, state)
assert.Nil(t, fleets[0].Range)
assert.NotNil(t, groups[gi].FleetID) assert.NotNil(t, groups[gi].FleetID)
assert.Equal(t, fleets[0].ID, *groups[gi].FleetID) assert.Equal(t, fleets[0].ID, *groups[gi].FleetID)
@@ -58,9 +57,8 @@ func TestJoinShipGroupToFleet(t *testing.T) {
gi = 1 gi = 1
assert.Len(t, fleets, 2) assert.Len(t, fleets, 2)
assert.Equal(t, fleets[1].Name, fleetTwo) assert.Equal(t, fleets[1].Name, fleetTwo)
assert.Equal(t, fleets[1].Destination, groups[gi].Destination) state, _, _ = game.FleetState(g, fleets[1].ID)
assert.Nil(t, fleets[1].Origin) assert.Equal(t, game.StateInOrbit, state)
assert.Nil(t, fleets[1].Range)
assert.NotNil(t, groups[gi].FleetID) assert.NotNil(t, groups[gi].FleetID)
assert.Equal(t, fleets[1].ID, *groups[gi].FleetID) assert.Equal(t, fleets[1].ID, *groups[gi].FleetID)
@@ -79,9 +77,8 @@ func TestJoinShipGroupToFleet(t *testing.T) {
groups = slices.Collect(g.ListShipGroups(Race_0_idx)) groups = slices.Collect(g.ListShipGroups(Race_0_idx))
assert.NotNil(t, groups[gi].FleetID) assert.NotNil(t, groups[gi].FleetID)
assert.Equal(t, fleets[0].ID, *groups[gi].FleetID) assert.Equal(t, fleets[0].ID, *groups[gi].FleetID)
assert.Equal(t, fleets[0].Destination, groups[gi].Destination) state, _, _ = game.FleetState(g, fleets[0].ID)
assert.Nil(t, fleets[0].Origin) assert.Equal(t, game.StateInOrbit, state)
assert.Nil(t, fleets[0].Range)
// group not In_Orbit // group not In_Orbit
assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 7)) assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 7))
@@ -96,20 +93,23 @@ func TestJoinShipGroupToFleet(t *testing.T) {
g.ShipGroups[gi].StateInSpace = nil g.ShipGroups[gi].StateInSpace = nil
// existing fleet not on the same planet or in_orbit // existing fleet not on the same planet or in_orbit
g.Fleets[0].Destination = R0_Planet_2_num g.ShipGroups[0].StateInSpace = &game.InSpace{
Origin: 2,
Range: 1,
}
g.ShipGroups[2].StateInSpace = g.ShipGroups[0].StateInSpace
assert.ErrorContains(t, assert.ErrorContains(t,
g.JoinShipGroupToFleet(Race_0.Name, fleetOne, g.ShipGroups[gi].Index, 0), g.JoinShipGroupToFleet(Race_0.Name, fleetOne, g.ShipGroups[gi].Index, 0),
e.GenericErrorText(e.ErrShipsNotOnSamePlanet)) e.GenericErrorText(e.ErrShipsNotOnSamePlanet))
g.Fleets[0].Destination = R0_Planet_0_num
} }
func TestJoinFleets(t *testing.T) { func TestJoinFleets(t *testing.T) {
g := newGame() g := newGame()
// creating ShipGroup at Planet_0 // creating ShipGroup #1 at Planet_0
assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 1)) // group #1 assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 1)) // group #1
// creating ShipGroup at Planet_2 // creating ShipGroup #2 at Planet_2
assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_2_num, 2)) // group #2 assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_2_num, 2)) // group #2
// creating ShipGroup at Planet_0 // creating ShipGroup #3 at Planet_0
assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 3)) // group #3 assert.NoError(t, g.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 3)) // group #3
// ensure race has no Fleets // ensure race has no Fleets
+8
View File
@@ -55,6 +55,14 @@ type InSpace struct {
Range float64 `json:"range"` Range float64 `json:"range"`
} }
func (is InSpace) Equal(other InSpace) bool {
return is.Origin == other.Origin && is.X == other.X && is.Y == other.Y
}
func (is InSpace) Launched() bool {
return is.Range == 0
}
type InUpgrade struct { type InUpgrade struct {
UpgradeTech []UpgradePreference `json:"preference"` UpgradeTech []UpgradePreference `json:"preference"`
} }
-2
View File
@@ -1,7 +1,6 @@
package game package game
import ( import (
"fmt"
"slices" "slices"
e "github.com/iliadenisov/galaxy/internal/error" e "github.com/iliadenisov/galaxy/internal/error"
@@ -69,7 +68,6 @@ func (g *Game) sendGroupInternal(ri int, groupIndex, planetNumber, quantity uint
} }
if sourcePlanet == planetNumber { if sourcePlanet == planetNumber {
fmt.Println("unsend: sgi=", sgi)
g.ShipGroups[sgi] = UnsendShips(g.ShipGroups[sgi]) g.ShipGroups[sgi] = UnsendShips(g.ShipGroups[sgi])
g.joinEqualGroupsInternal(ri) g.joinEqualGroupsInternal(ri)
return nil return nil
+11
View File
@@ -235,3 +235,14 @@ func checkShipTypeValues(d, w, s, c float64, a int) error {
func checkShipTypeValueDWSC(v float64) bool { func checkShipTypeValueDWSC(v float64) bool {
return v == 0 || v >= 1 return v == 0 || v >= 1
} }
func ShipClass(g *Game, ri int, classID uuid.UUID) (ShipType, bool) {
if len(g.Race) < ri+1 {
panic(fmt.Sprintf("ShipClass: game race index %d invalid: len=%d", ri, len(g.Race)))
}
sti := slices.IndexFunc(g.Race[ri].ShipTypes, func(st ShipType) bool { return st.ID == classID })
if sti < 0 {
return ShipType{}, false
}
return g.Race[ri].ShipTypes[sti], true
}