From a6093a1c29f5a3aef83b1654899de77761efedf4 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 4 Jan 2026 21:43:16 +0200 Subject: [PATCH] cmd: send group --- internal/error/generic.go | 6 ++ internal/error/input.go | 8 +++ internal/generator/map.go | 12 +--- internal/model/game/game_test.go | 5 +- internal/model/game/group.go | 28 +++++++- internal/model/game/group_send.go | 95 ++++++++++++++++++++++++++ internal/model/game/group_send_test.go | 69 +++++++++++++++++++ internal/model/game/group_test.go | 3 + internal/model/game/map.go | 4 ++ internal/model/game/planet.go | 8 +++ internal/util/map.go | 16 +++++ internal/util/map_test.go | 27 ++++++++ 12 files changed, 266 insertions(+), 15 deletions(-) create mode 100644 internal/model/game/group_send.go create mode 100644 internal/model/game/group_send_test.go create mode 100644 internal/util/map.go create mode 100644 internal/util/map_test.go diff --git a/internal/error/generic.go b/internal/error/generic.go index 3dad8ee..670d221 100644 --- a/internal/error/generic.go +++ b/internal/error/generic.go @@ -22,6 +22,8 @@ const ( ErrGiveawayGroupShipsTypeNotEqual = 5009 ErrUpgradeGroupNumberNotEnough = 5010 ErrUpgradeInsufficientResources = 5011 + ErrSendShipHasNoDrives = 5012 + ErrSendUnreachableDestination = 5013 ) const ( @@ -158,6 +160,10 @@ func GenericErrorText(code int) string { return "The Group is already in upgrade state and can't be divided to a smaller group" case ErrInputUpgradeTechLevelInsufficient: return "Insifficient Tech level for requested upgrade" + case ErrSendShipHasNoDrives: + return "One or more ships are not equipped with hyperdrive and cannot be moved" + case ErrSendUnreachableDestination: + return "Destination planet is too far for current Drive level" default: return fmt.Sprintf("Undescribed error with code %d", code) } diff --git a/internal/error/input.go b/internal/error/input.go index 2494b17..b889323 100644 --- a/internal/error/input.go +++ b/internal/error/input.go @@ -167,3 +167,11 @@ func NewUpgradeGroupBreakNotAllowedError(arg ...any) error { func NewUpgradeTechLevelInsufficientError(arg ...any) error { return newGenericError(ErrInputUpgradeTechLevelInsufficient, arg...) } + +func NewSendShipHasNoDrivesError(arg ...any) error { + return newGenericError(ErrSendShipHasNoDrives, arg...) +} + +func NewSendUnreachableDestinationError(arg ...any) error { + return newGenericError(ErrSendUnreachableDestination, arg...) +} diff --git a/internal/generator/map.go b/internal/generator/map.go index 86f25cc..ab6ce44 100644 --- a/internal/generator/map.go +++ b/internal/generator/map.go @@ -2,10 +2,10 @@ package generator import ( "fmt" - "math" "math/rand" "github.com/iliadenisov/galaxy/internal/generator/plotter" + "github.com/iliadenisov/galaxy/internal/util" ) type Map struct { @@ -58,15 +58,7 @@ func (m Map) NewCoordinate(deadZoneRaduis float64) (Coordinate, error) { } func (m Map) ShortDistance(from, to Coordinate) float64 { - dx := math.Abs(to.X - from.X) - dy := math.Abs(to.Y - from.Y) - if dx > float64(m.Width/2) { - dx = float64(m.Width) - dx - } - if dy > float64(m.Height/2) { - dy = float64(m.Height) - dy - } - return math.Sqrt(math.Pow(dx, 2) + math.Pow(dy, 2)) + return util.ShortDistance(m.Width, m.Height, from.X, from.Y, to.X, to.Y) } // RandI returns a random float64 value between min and max diff --git a/internal/model/game/game_test.go b/internal/model/game/game_test.go index be4ebae..836563e 100644 --- a/internal/model/game/game_test.go +++ b/internal/model/game/game_test.go @@ -74,12 +74,13 @@ func newGame() *game.Game { Race_1, }, Map: game.Map{ - Width: 10, - Height: 10, + Width: 1000, + Height: 1000, Planet: []game.Planet{ controller.NewPlanet(R0_Planet_0_num, "Planet_0", Race_0.ID, 0, 0, 100, 100, 100, 0, game.ProductionNone.AsType(uuid.Nil)), controller.NewPlanet(R1_Planet_1_num, "Planet_1", Race_1.ID, 1, 1, 100, 0, 0, 0, game.ProductionNone.AsType(uuid.Nil)), controller.NewPlanet(R0_Planet_2_num, "Planet_2", Race_0.ID, 2, 2, 100, 0, 0, 0, game.ProductionNone.AsType(uuid.Nil)), + controller.NewPlanet(3, "Planet_3", uuid.Nil, 500, 500, 100, 0, 0, 0, game.ProductionNone.AsType(uuid.Nil)), }, }, } diff --git a/internal/model/game/group.go b/internal/model/game/group.go index 2bff133..753a789 100644 --- a/internal/model/game/group.go +++ b/internal/model/game/group.go @@ -47,8 +47,11 @@ const ( ) type InSpace struct { - Origin uint `json:"origin"` + Origin uint `json:"origin"` + X float64 `json:"x"` + Y float64 `json:"y"` // zero is for Launched status + // TODO: calculate range dynamically Range float64 `json:"range"` } @@ -136,6 +139,17 @@ func (sg ShipGroup) State() ShipGroupState { } } +func (sg ShipGroup) OnPlanet() (uint, bool) { + switch sg.State() { + case StateInOrbit: + return sg.Destination, true + case StateLaunched: + return sg.StateInSpace.Origin, true + default: + return 0, false + } +} + func (sg ShipGroup) Equal(other ShipGroup) bool { return sg.OwnerID == other.OwnerID && sg.TypeID == other.TypeID && @@ -145,7 +159,7 @@ func (sg ShipGroup) Equal(other ShipGroup) bool { sg.TechLevel(TechShields) == other.TechLevel(TechShields) && sg.TechLevel(TechCargo) == other.TechLevel(TechCargo) && sg.CargoType == other.CargoType && - sg.Load == other.Load && + sg.Load/float64(sg.Number) == other.Load/float64(other.Number) && sg.State() == other.State() } @@ -471,7 +485,6 @@ func (g *Game) loadCargoInternal(ri int, groupIndex uint, ct CargoType, ships ui } *availableOnPlanet = *availableOnPlanet - toBeLoaded g.ShipGroups[sgi].Load += toBeLoaded - // fmt.Println("capacity:", capacity, "loaded:", g.ShipGroups[sgi].Load, "free:", capacity-g.ShipGroups[sgi].Load) if g.ShipGroups[sgi].Load > 0 { g.ShipGroups[sgi].CargoType = &ct } @@ -706,6 +719,15 @@ func (g Game) listIndexShipGroups(ri int) iter.Seq2[int, ShipGroup] { } } +func MustShipGroup(g *Game, ri int, index uint) ShipGroup { + for sg := range g.listShipGroups(ri) { + if sg.Index == index { + return sg + } + } + panic(fmt.Sprintf("race i=%d have no group i=%d", ri, index)) +} + func maxUint(a, b uint) uint { if b > a { return b diff --git a/internal/model/game/group_send.go b/internal/model/game/group_send.go new file mode 100644 index 0000000..be75c03 --- /dev/null +++ b/internal/model/game/group_send.go @@ -0,0 +1,95 @@ +package game + +import ( + "fmt" + "slices" + + e "github.com/iliadenisov/galaxy/internal/error" + "github.com/iliadenisov/galaxy/internal/util" +) + +func (g *Game) SendGroup(raceName string, groupIndex, planetNumber, quantity uint) error { + ri, err := g.raceIndex(raceName) + if err != nil { + return err + } + return g.sendGroupInternal(ri, groupIndex, planetNumber, quantity) +} + +func (g *Game) sendGroupInternal(ri int, groupIndex, planetNumber, quantity uint) error { + sgi := -1 + for i, sg := range g.listIndexShipGroups(ri) { + if sgi < 0 && sg.Index == groupIndex { + sgi = i + } + } + if sgi < 0 { + return e.NewEntityNotExistsError("group #%d", groupIndex) + } + + var sti int + if sti = slices.IndexFunc(g.Race[ri].ShipTypes, func(st ShipType) bool { return st.ID == g.ShipGroups[sgi].TypeID }); sti < 0 { + // hard to test, need manual game data invalidation + return e.NewGameStateError("not found: ShipType ID=%v", g.ShipGroups[sgi].TypeID) + } + st := g.Race[ri].ShipTypes[sti] + + if st.DriveBlockMass() == 0 { + return e.NewSendShipHasNoDrivesError() + } + + sourcePlanet, ok := g.ShipGroups[sgi].OnPlanet() + if !ok { + return e.NewShipsBusyError() + } + + if g.ShipGroups[sgi].Number < quantity { + return e.NewBeakGroupNumberNotEnoughError("%d<%d", g.ShipGroups[sgi].Number, quantity) + } + + p1, ok := PlanetByNum(g, sourcePlanet) + if !ok { + return e.NewGameStateError("source planet #%d does not exists", g.ShipGroups[sgi].Destination) + } + 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) + } + + if quantity > 0 && quantity < g.ShipGroups[sgi].Number { + nsgi, err := g.breakGroupSafe(ri, groupIndex, quantity) + if err != nil { + return err + } + sgi = nsgi + } + + if sourcePlanet == planetNumber { + fmt.Println("unsend: sgi=", sgi) + g.ShipGroups[sgi] = UnsendShips(g.ShipGroups[sgi]) + g.joinEqualGroupsInternal(ri) + return nil + } + + g.ShipGroups[sgi] = LaunchShips(g.ShipGroups[sgi], planetNumber) + + return nil +} + +func LaunchShips(sg ShipGroup, destination uint) ShipGroup { + sg.StateInSpace = &InSpace{ + Origin: sg.Destination, + } + sg.Destination = destination + return sg +} + +func UnsendShips(sg ShipGroup) ShipGroup { + sg.Destination = sg.StateInSpace.Origin + sg.StateInSpace = nil + return sg +} diff --git a/internal/model/game/group_send_test.go b/internal/model/game/group_send_test.go new file mode 100644 index 0000000..417accd --- /dev/null +++ b/internal/model/game/group_send_test.go @@ -0,0 +1,69 @@ +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 TestSendGroup(t *testing.T) { + g := newGame() + // group #1 - in_orbit, free to upgrade + assert.NoError(t, g.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 10)) + // group #2 - in_space + assert.NoError(t, g.CreateShips(Race_0_idx, ShipType_Cruiser, R0_Planet_0_num, 1)) + g.ShipGroups[1].StateInSpace = &game.InSpace{Origin: 2, Range: 1.23} + // group #3 - in_orbit, 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)) + + assert.ErrorContains(t, + g.SendGroup("UnknownRace", 1, 2, 0), + e.GenericErrorText(e.ErrInputUnknownRace)) + assert.ErrorContains(t, + g.SendGroup(Race_0.Name, 555, 2, 0), + e.GenericErrorText(e.ErrInputEntityNotExists)) + assert.ErrorContains(t, + g.SendGroup(Race_0.Name, 1, 222, 0), + e.GenericErrorText(e.ErrInputEntityNotExists)) + assert.ErrorContains(t, + g.SendGroup(Race_0.Name, 2, 1, 0), + e.GenericErrorText(e.ErrShipsBusy)) + assert.ErrorContains(t, + g.SendGroup(Race_0.Name, 3, 2, 0), + e.GenericErrorText(e.ErrSendShipHasNoDrives)) + assert.ErrorContains(t, + g.SendGroup(Race_0.Name, 1, 2, 100), + e.GenericErrorText(e.ErrBeakGroupNumberNotEnough)) + assert.ErrorContains(t, + g.SendGroup(Race_0.Name, 1, 3, 0), + e.GenericErrorText(e.ErrSendUnreachableDestination)) + + assert.NoError(t, g.SendGroup(Race_0.Name, 1, 2, 3)) // send 3 of 10 + assert.Len(t, slices.Collect(g.ListShipGroups(Race_0_idx)), 4) + assert.Equal(t, uint(7), g.ShipGroups[0].Number) + assert.Equal(t, game.StateInOrbit, g.ShipGroups[0].State()) + assert.Equal(t, uint(3), g.ShipGroups[3].Number) + assert.Equal(t, game.StateLaunched, g.ShipGroups[3].State()) + + assert.NoError(t, g.SendGroup(Race_0.Name, 4, 0, 2)) // un-send 2 of 3 + assert.Len(t, slices.Collect(g.ListShipGroups(Race_0_idx)), 4) + assert.Equal(t, uint(9), game.MustShipGroup(g, Race_0_idx, 5).Number) + assert.Equal(t, game.StateInOrbit, game.MustShipGroup(g, Race_0_idx, 5).State()) + assert.Equal(t, uint(1), game.MustShipGroup(g, Race_0_idx, 4).Number) + assert.Equal(t, game.StateLaunched, game.MustShipGroup(g, Race_0_idx, 4).State()) + + assert.NoError(t, g.SendGroup(Race_0.Name, 4, 0, 0)) // un-send the rest 1 + assert.Len(t, slices.Collect(g.ListShipGroups(Race_0_idx)), 3) + assert.Equal(t, uint(10), game.MustShipGroup(g, Race_0_idx, 5).Number) + assert.Equal(t, game.StateInOrbit, game.MustShipGroup(g, Race_0_idx, 5).State()) + + assert.NoError(t, g.SendGroup(Race_0.Name, 5, 2, 0)) + assert.Len(t, slices.Collect(g.ListShipGroups(Race_0_idx)), 3) + assert.Equal(t, uint(10), game.MustShipGroup(g, Race_0_idx, 5).Number) + assert.Equal(t, game.StateLaunched, game.MustShipGroup(g, Race_0_idx, 5).State()) +} diff --git a/internal/model/game/group_test.go b/internal/model/game/group_test.go index d074314..7bccb45 100644 --- a/internal/model/game/group_test.go +++ b/internal/model/game/group_test.go @@ -252,7 +252,10 @@ func TestShipGroupEqual(t *testing.T) { left.Index = 2 assert.True(t, left.Equal(right)) + + // dirty hack to equalize loads left.Number = 5 + left.Load = right.Load / float64(right.Number) * float64(left.Number) assert.True(t, left.Equal(right)) } diff --git a/internal/model/game/map.go b/internal/model/game/map.go index 867e842..eac342b 100644 --- a/internal/model/game/map.go +++ b/internal/model/game/map.go @@ -5,3 +5,7 @@ type Map struct { Height uint32 `json:"height"` Planet []Planet `json:"planets"` } + +func Destination(x1, y1, x2, y2 float64) float64 { + return 0 +} diff --git a/internal/model/game/planet.go b/internal/model/game/planet.go index 83b2ee0..3ba1f8c 100644 --- a/internal/model/game/planet.go +++ b/internal/model/game/planet.go @@ -128,3 +128,11 @@ func (g Game) renamePlanetInternal(ri int, number int, name string) error { g.Map.Planet[pl].Name = n return nil } + +func PlanetByNum(g *Game, number uint) (Planet, bool) { + pi := slices.IndexFunc(g.Map.Planet, func(p Planet) bool { return p.Number == number }) + if pi < 0 { + return Planet{}, false + } + return g.Map.Planet[pi], true +} diff --git a/internal/util/map.go b/internal/util/map.go new file mode 100644 index 0000000..af1d958 --- /dev/null +++ b/internal/util/map.go @@ -0,0 +1,16 @@ +package util + +import "math" + +func ShortDistance(w, h uint32, x1, y1, x2, y2 float64) float64 { + dx := math.Abs(x2 - x1) + dy := math.Abs(y2 - y1) + if dx > float64(w/2) { + dx = float64(h) - dx + } + if dy > float64(h/2) { + dy = float64(h) - dy + } + return math.Sqrt(math.Pow(dx, 2) + math.Pow(dy, 2)) + +} diff --git a/internal/util/map_test.go b/internal/util/map_test.go new file mode 100644 index 0000000..87ef438 --- /dev/null +++ b/internal/util/map_test.go @@ -0,0 +1,27 @@ +package util_test + +import ( + "fmt" + "testing" + + "github.com/iliadenisov/galaxy/internal/number" + "github.com/iliadenisov/galaxy/internal/util" + "github.com/stretchr/testify/assert" +) + +func TestShortDistance(t *testing.T) { + for i, tc := range []struct { + w, h uint32 + x1, y1, x2, y2, d float64 + }{ + {10, 10, 0, 0, 5, 5, 7.071}, + {10, 10, 0, 0, 5.01, 5.01, 7.057}, + {10, 10, 2, 2, 8, 2, 4.}, + {10, 10, 8, 7, 1, 7, 3.}, + } { + t.Run(fmt.Sprint(i), func(t *testing.T) { + d := util.ShortDistance(tc.w, tc.h, tc.x1, tc.y1, tc.x2, tc.y2) + assert.Equal(t, tc.d, number.Fixed3(d)) + }) + } +}