cmd: send group

This commit is contained in:
Ilia Denisov
2026-01-04 21:43:16 +02:00
parent c6e1cb5cdf
commit a6093a1c29
12 changed files with 266 additions and 15 deletions
+6
View File
@@ -22,6 +22,8 @@ const (
ErrGiveawayGroupShipsTypeNotEqual = 5009 ErrGiveawayGroupShipsTypeNotEqual = 5009
ErrUpgradeGroupNumberNotEnough = 5010 ErrUpgradeGroupNumberNotEnough = 5010
ErrUpgradeInsufficientResources = 5011 ErrUpgradeInsufficientResources = 5011
ErrSendShipHasNoDrives = 5012
ErrSendUnreachableDestination = 5013
) )
const ( 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" return "The Group is already in upgrade state and can't be divided to a smaller group"
case ErrInputUpgradeTechLevelInsufficient: case ErrInputUpgradeTechLevelInsufficient:
return "Insifficient Tech level for requested upgrade" 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: default:
return fmt.Sprintf("Undescribed error with code %d", code) return fmt.Sprintf("Undescribed error with code %d", code)
} }
+8
View File
@@ -167,3 +167,11 @@ func NewUpgradeGroupBreakNotAllowedError(arg ...any) error {
func NewUpgradeTechLevelInsufficientError(arg ...any) error { func NewUpgradeTechLevelInsufficientError(arg ...any) error {
return newGenericError(ErrInputUpgradeTechLevelInsufficient, arg...) return newGenericError(ErrInputUpgradeTechLevelInsufficient, arg...)
} }
func NewSendShipHasNoDrivesError(arg ...any) error {
return newGenericError(ErrSendShipHasNoDrives, arg...)
}
func NewSendUnreachableDestinationError(arg ...any) error {
return newGenericError(ErrSendUnreachableDestination, arg...)
}
+2 -10
View File
@@ -2,10 +2,10 @@ package generator
import ( import (
"fmt" "fmt"
"math"
"math/rand" "math/rand"
"github.com/iliadenisov/galaxy/internal/generator/plotter" "github.com/iliadenisov/galaxy/internal/generator/plotter"
"github.com/iliadenisov/galaxy/internal/util"
) )
type Map struct { type Map struct {
@@ -58,15 +58,7 @@ func (m Map) NewCoordinate(deadZoneRaduis float64) (Coordinate, error) {
} }
func (m Map) ShortDistance(from, to Coordinate) float64 { func (m Map) ShortDistance(from, to Coordinate) float64 {
dx := math.Abs(to.X - from.X) return util.ShortDistance(m.Width, m.Height, from.X, from.Y, to.X, to.Y)
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))
} }
// RandI returns a random float64 value between min and max // RandI returns a random float64 value between min and max
+3 -2
View File
@@ -74,12 +74,13 @@ func newGame() *game.Game {
Race_1, Race_1,
}, },
Map: game.Map{ Map: game.Map{
Width: 10, Width: 1000,
Height: 10, Height: 1000,
Planet: []game.Planet{ 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(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(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(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)),
}, },
}, },
} }
+24 -2
View File
@@ -48,7 +48,10 @@ const (
type InSpace struct { type InSpace struct {
Origin uint `json:"origin"` Origin uint `json:"origin"`
X float64 `json:"x"`
Y float64 `json:"y"`
// zero is for Launched status // zero is for Launched status
// TODO: calculate range dynamically
Range float64 `json:"range"` 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 { func (sg ShipGroup) Equal(other ShipGroup) bool {
return sg.OwnerID == other.OwnerID && return sg.OwnerID == other.OwnerID &&
sg.TypeID == other.TypeID && sg.TypeID == other.TypeID &&
@@ -145,7 +159,7 @@ func (sg ShipGroup) Equal(other ShipGroup) bool {
sg.TechLevel(TechShields) == other.TechLevel(TechShields) && sg.TechLevel(TechShields) == other.TechLevel(TechShields) &&
sg.TechLevel(TechCargo) == other.TechLevel(TechCargo) && sg.TechLevel(TechCargo) == other.TechLevel(TechCargo) &&
sg.CargoType == other.CargoType && sg.CargoType == other.CargoType &&
sg.Load == other.Load && sg.Load/float64(sg.Number) == other.Load/float64(other.Number) &&
sg.State() == other.State() sg.State() == other.State()
} }
@@ -471,7 +485,6 @@ func (g *Game) loadCargoInternal(ri int, groupIndex uint, ct CargoType, ships ui
} }
*availableOnPlanet = *availableOnPlanet - toBeLoaded *availableOnPlanet = *availableOnPlanet - toBeLoaded
g.ShipGroups[sgi].Load += 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 { if g.ShipGroups[sgi].Load > 0 {
g.ShipGroups[sgi].CargoType = &ct 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 { func maxUint(a, b uint) uint {
if b > a { if b > a {
return b return b
+95
View File
@@ -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
}
+69
View File
@@ -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())
}
+3
View File
@@ -252,7 +252,10 @@ func TestShipGroupEqual(t *testing.T) {
left.Index = 2 left.Index = 2
assert.True(t, left.Equal(right)) assert.True(t, left.Equal(right))
// dirty hack to equalize loads
left.Number = 5 left.Number = 5
left.Load = right.Load / float64(right.Number) * float64(left.Number)
assert.True(t, left.Equal(right)) assert.True(t, left.Equal(right))
} }
+4
View File
@@ -5,3 +5,7 @@ type Map struct {
Height uint32 `json:"height"` Height uint32 `json:"height"`
Planet []Planet `json:"planets"` Planet []Planet `json:"planets"`
} }
func Destination(x1, y1, x2, y2 float64) float64 {
return 0
}
+8
View File
@@ -128,3 +128,11 @@ func (g Game) renamePlanetInternal(ri int, number int, name string) error {
g.Map.Planet[pl].Name = n g.Map.Planet[pl].Name = n
return nil 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
}
+16
View File
@@ -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))
}
+27
View File
@@ -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))
})
}
}