From 741a5f726b746513f4143775f359bfec84b6b8fc Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 16 Jan 2026 22:20:27 +0200 Subject: [PATCH] feat: enroute groups --- internal/controller/controller_export_test.go | 4 + internal/controller/controller_test.go | 11 ++ .../{generate.go => generate_game.go} | 0 ...generate_test.go => generate_game_test.go} | 0 internal/controller/generate_turn.go | 10 +- internal/controller/route.go | 106 +++++++++++ internal/controller/route_test.go | 171 ++++++++++++++++++ internal/controller/ship_group.go | 6 +- 8 files changed, 299 insertions(+), 9 deletions(-) rename internal/controller/{generate.go => generate_game.go} (100%) rename internal/controller/{generate_test.go => generate_game_test.go} (100%) diff --git a/internal/controller/controller_export_test.go b/internal/controller/controller_export_test.go index 8dc378b..cce052f 100644 --- a/internal/controller/controller_export_test.go +++ b/internal/controller/controller_export_test.go @@ -66,3 +66,7 @@ func (c *Cache) PutMaterial(pn uint, v float64) { func (c *Cache) RaceTechLevel(ri int, t game.Tech, v float64) { c.raceTechLevel(ri, t, v) } + +func (c *Cache) ListRouteEligibleGroupIds(pn uint) iter.Seq[int] { + return c.listRouteEligibleGroupIds(pn) +} diff --git a/internal/controller/controller_test.go b/internal/controller/controller_test.go index 02be5a4..dda64e2 100644 --- a/internal/controller/controller_test.go +++ b/internal/controller/controller_test.go @@ -2,10 +2,13 @@ package controller_test import ( "fmt" + "slices" + "testing" "github.com/google/uuid" "github.com/iliadenisov/galaxy/internal/controller" "github.com/iliadenisov/galaxy/internal/model/game" + "github.com/stretchr/testify/assert" ) var ( @@ -67,6 +70,14 @@ var ( } ) +// [ ] Delete this fake test +func TestSlicesDelete(t *testing.T) { + sl := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} + assert.Len(t, sl, 10) + sl = slices.DeleteFunc(sl, func(v int) bool { return v%2 == 0 }) + assert.Len(t, sl, 5) +} + func assertNoError(err error) { if err != nil { panic(fmt.Sprintf("init assertion failed: %v", err)) diff --git a/internal/controller/generate.go b/internal/controller/generate_game.go similarity index 100% rename from internal/controller/generate.go rename to internal/controller/generate_game.go diff --git a/internal/controller/generate_test.go b/internal/controller/generate_game_test.go similarity index 100% rename from internal/controller/generate_test.go rename to internal/controller/generate_game_test.go diff --git a/internal/controller/generate_turn.go b/internal/controller/generate_turn.go index cb694ef..cf84638 100644 --- a/internal/controller/generate_turn.go +++ b/internal/controller/generate_turn.go @@ -1,8 +1,6 @@ package controller import ( - // "github.com/iliadenisov/galaxy/internal/controller" - e "github.com/iliadenisov/galaxy/internal/error" // "github.com/iliadenisov/galaxy/internal/game/battle" "github.com/iliadenisov/galaxy/internal/model/game" ) @@ -17,12 +15,8 @@ func MakeTurn(c *Controller, r Repo, g *game.Game) error { // 02. Враждующие корабли вступают в схватку. battles := ProduceBattles(c.Cache) - // Internal control: after battles there are can't be groups with no ships left - for i := range g.ShipGroups { - if g.ShipGroups[i].Number == 0 { - return e.NewGameStateError("") - } - } + // 03. Товары загружаются на корабли, находящиеся в начале грузовых маршрутов, и корабли входят в гиперпространство. + c.Cache.EnrouteGroups() /*** Last steps ***/ diff --git a/internal/controller/route.go b/internal/controller/route.go index 10cf808..86771b6 100644 --- a/internal/controller/route.go +++ b/internal/controller/route.go @@ -1,6 +1,11 @@ package controller import ( + "cmp" + "iter" + "math" + "slices" + e "github.com/iliadenisov/galaxy/internal/error" "github.com/iliadenisov/galaxy/internal/model/game" "github.com/iliadenisov/galaxy/internal/util" @@ -82,3 +87,104 @@ func (c *Cache) RemovePlanetRoute(rt game.RouteType, origin uint) { delete(c.g.Map.Planet[pi].Route, rt) } } + +// TODO: NOT IN THIS FUNC: remove routes if planet became uninhabited +func (c *Cache) EnrouteGroups() { + for pi := range c.g.Map.Planet { + if len(c.g.Map.Planet[pi].Route) == 0 { + continue + } + groups := slices.Collect(c.listRouteEligibleGroupIds(c.g.Map.Planet[pi].Number)) + if len(groups) == 0 { + continue + } + + sortGroups := func(g []int) { + // sort groups by largest CargoCapacity + slices.SortFunc(g, func(l, r int) int { + return cmp.Or(cmp.Compare(c.ShipGroup(r).CargoCapacity(c.ShipGroupShipClass(r)), + c.ShipGroup(l).CargoCapacity(c.ShipGroupShipClass(l))), + cmp.Compare(l, r)) + }) + + } + reorderGroups := func(g []int) []int { + g = slices.DeleteFunc(g, func(i int) bool { return c.ShipGroup(i).State() != game.StateInOrbit }) + sortGroups(g) + return g + } + + sortGroups(groups) + + p := c.MustPlanet(c.g.Map.Planet[pi].Number) + + // COL -> CAP -> MAT -> EMPTY + for _, rt := range []game.RouteType{game.RouteColonist, game.RouteCapital, game.RouteMaterial, game.RouteEmpty} { + dest, ok := c.g.Map.Planet[pi].Route[rt] + if !ok { + continue + } + var res *float64 + var ct game.CargoType + switch rt { + case game.RouteColonist: + res = &p.Colonists + ct = game.CargoColonist + case game.RouteCapital: + res = &p.Capital + ct = game.CargoCapital + case game.RouteMaterial: + res = &p.Material + ct = game.CargoMaterial + default: + for _, sgi := range groups { + c.LaunchShips(c.ShipGroup(sgi), dest) + } + groups = reorderGroups(groups) + continue + } + for res != nil && *res > 0 && len(groups) > 0 { + sgi := groups[0] + sg := c.ShipGroup(sgi) + st := c.ShipGroupShipClass(sgi) + ships := sg.Number + sgCapacity := sg.CargoCapacity(st) + toLoad := *res + if toLoad > sgCapacity { + toLoad = sgCapacity + } else if maxShips := uint(math.Ceil(toLoad / (sgCapacity / float64(ships)))); maxShips < ships { + newGroupIdx := c.breakGroupUnsafe(c.RaceIndex(sg.OwnerID), sgi, maxShips) + sg = c.ShipGroup(newGroupIdx) + } + // decrease planet resource + *res = *res - toLoad + // load group + sg.Load += toLoad + sg.CargoType = &ct + c.LaunchShips(sg, dest) + groups = reorderGroups(groups) + } + } + } +} + +func (c *Cache) listRouteEligibleGroupIds(pn uint) iter.Seq[int] { + return func(yield func(int) bool) { + p := c.MustPlanet(pn) + for i := range c.ShipGroupsIndex() { + sg := c.ShipGroup(i) + st := c.ShipGroupShipClass(i) + if sg.OwnerID != p.Owner || // Planet must be owned by ships owner + sg.FleetID != nil || // Ships must not be part of a Fleet + sg.State() != game.StateInOrbit || // Ships must be only In_Orbit state + st.CargoBlockMass() == 0 || // Ship Class must have Cargo bays + sg.Load != 0 || // Ships must not be loaded for enrouting + sg.Destination != p.Number { + continue + } + if !yield(i) { + return + } + } + } +} diff --git a/internal/controller/route_test.go b/internal/controller/route_test.go index 6b18062..22b68e5 100644 --- a/internal/controller/route_test.go +++ b/internal/controller/route_test.go @@ -1,6 +1,7 @@ package controller_test import ( + "slices" "testing" e "github.com/iliadenisov/galaxy/internal/error" @@ -89,3 +90,173 @@ func TestRemoveRoute(t *testing.T) { g.RemoveRoute(Race_0.Name, "COL", 1), e.GenericErrorText(e.ErrInputEntityNotOwned)) } + +func TestListRouteEligibleGroupIds(t *testing.T) { + c, g := newCache() + + // 1: idx = 0 / Ready to load + assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10)) + + // 2: idx = 1 / Has no cargo bay + assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Gunship, R0_Planet_0_num, 1)) + + // 3: idx = 2 / In_Space + assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 7)) + c.ShipGroup(2).StateInSpace = &game.InSpace{ + Origin: 2, + Range: 31.337, + } + + // 4: idx = 3 / loaded with COL + assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 11)) + c.ShipGroup(3).CargoType = game.CargoColonist.Ref() + c.ShipGroup(3).Load = 1.234 + + // Foreign group -> idx 1 + assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10)) + assert.NoError(t, g.GiveawayGroup(Race_0.Name, Race_1.Name, 5, 0)) + + // 5: idx = 4 / Part of the Fleet + assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 10)) + assert.NoError(t, g.JoinShipGroupToFleet(Race_0.Name, "Fleet", 5, 0)) + + planet_0_groups := slices.Collect(c.ListRouteEligibleGroupIds(0)) + assert.Len(t, planet_0_groups, 1) + for _, i := range planet_0_groups { + sg := c.ShipGroup(i) + st := c.ShipGroupShipClass(i) + assert.Equal(t, Race_0_ID, sg.OwnerID) + assert.Greater(t, sg.CargoCapacity(st), 0.) + assert.Equal(t, game.StateInOrbit, sg.State()) + assert.Equal(t, 0., sg.Load) + assert.Nil(t, sg.FleetID) + } +} + +func TestEnrouteGroups_SplitGroup(t *testing.T) { + c, g := newCache() + + assert.NoError(t, g.SetRoute(Race_0.Name, "COL", R0_Planet_0_num, R0_Planet_2_num)) + // assert.NoError(t, g.SetRoute(Race_0.Name, "MAT", R0_Planet_0_num, R0_Planet_2_num)) + // assert.NoError(t, g.SetRoute(Race_0.Name, "CAP", R0_Planet_0_num, R0_Planet_2_num)) + // assert.NoError(t, g.SetRoute(Race_0.Name, "EMP", R0_Planet_2_num, R1_Planet_1_num)) + + c.MustPlanet(R0_Planet_0_num).Colonists = 65 + + assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 5)) // 21.0 per Ship + assert.Equal(t, 105., c.ShipGroup(0).CargoCapacity(c.ShipGroupShipClass(0))) + + c.EnrouteGroups() + + assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 2) + assert.Equal(t, game.StateInOrbit, c.ShipGroup(0).State()) + assert.Equal(t, uint(1), c.ShipGroup(0).Number) + assert.Equal(t, 0., c.ShipGroup(0).Load) + assert.Equal(t, game.StateLaunched, c.ShipGroup(1).State()) + assert.Equal(t, uint(4), c.ShipGroup(1).Number) + assert.Equal(t, 65., c.ShipGroup(1).Load) + assert.Equal(t, 0., c.MustPlanet(R0_Planet_0_num).Colonists) +} + +func TestEnrouteGroups_GroupSorting(t *testing.T) { + c, g := newCache() + + assert.NoError(t, g.SetRoute(Race_0.Name, "COL", R0_Planet_0_num, R0_Planet_2_num)) + // assert.NoError(t, g.SetRoute(Race_0.Name, "MAT", R0_Planet_0_num, R0_Planet_2_num)) + // assert.NoError(t, g.SetRoute(Race_0.Name, "CAP", R0_Planet_0_num, R0_Planet_2_num)) + // assert.NoError(t, g.SetRoute(Race_0.Name, "EMP", R0_Planet_2_num, R1_Planet_1_num)) + + c.MustPlanet(R0_Planet_0_num).Colonists = 100 + + // 0: idx = 1 + assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 4)) // 21.0 per Ship + assert.Equal(t, 84., c.ShipGroup(0).CargoCapacity(c.ShipGroupShipClass(0))) + + // 1: idx = 2 + assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 5)) // 21.0 per Ship + assert.Equal(t, 105., c.ShipGroup(1).CargoCapacity(c.ShipGroupShipClass(1))) + + c.EnrouteGroups() + + assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 2) + assert.Equal(t, game.StateInOrbit, c.ShipGroup(0).State()) + assert.Equal(t, game.StateLaunched, c.ShipGroup(1).State()) + + assert.Equal(t, 100., c.ShipGroup(1).Load) + assert.Equal(t, 0., c.MustPlanet(R0_Planet_0_num).Colonists) +} + +func TestEnrouteGroups_LaunchOrder(t *testing.T) { + c, g := newCache() + + assert.NoError(t, g.SetRoute(Race_0.Name, "COL", R0_Planet_0_num, R0_Planet_2_num)) + assert.NoError(t, g.SetRoute(Race_0.Name, "CAP", R0_Planet_0_num, R0_Planet_2_num)) + assert.NoError(t, g.SetRoute(Race_0.Name, "MAT", R0_Planet_0_num, R0_Planet_2_num)) + assert.NoError(t, g.SetRoute(Race_0.Name, "EMP", R0_Planet_0_num, R1_Planet_1_num)) + + c.MustPlanet(R0_Planet_0_num).Colonists = 150 + c.MustPlanet(R0_Planet_0_num).Capital = 100 + c.MustPlanet(R0_Planet_0_num).Material = 20 + + // 0: idx = 1 (105 COL) -> + // 3: idx = 4 ( 45 COL) + assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 5)) + assert.Equal(t, 105., c.ShipGroup(0).CargoCapacity(c.ShipGroupShipClass(0))) + + // 1: idx = 2 (In_Orbit) -> + // 4: idx = 5 (20 MAT) + assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 5)) + assert.Equal(t, 105., c.ShipGroup(1).CargoCapacity(c.ShipGroupShipClass(1))) + + // 2: idx = 3 (100 CAP) + assert.NoError(t, c.CreateShips(Race_0_idx, Race_0_Freighter, R0_Planet_0_num, 5)) + assert.Equal(t, 105., c.ShipGroup(2).CargoCapacity(c.ShipGroupShipClass(2))) + + c.EnrouteGroups() + + assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 5) + + // full load of COL + sgi := 0 + assert.Equal(t, game.StateLaunched, c.ShipGroup(sgi).State()) + assert.Equal(t, R0_Planet_2_num, c.ShipGroup(sgi).Destination) + assert.Equal(t, 105., c.ShipGroup(sgi).Load) + assert.NotNil(t, c.ShipGroup(sgi).CargoType) + assert.Equal(t, game.CargoColonist, *c.ShipGroup(sgi).CargoType) + assert.Equal(t, uint(5), c.ShipGroup(sgi).Number) + + // rest of COL + sgi = 3 + assert.Equal(t, game.StateLaunched, c.ShipGroup(sgi).State()) + assert.Equal(t, R0_Planet_2_num, c.ShipGroup(sgi).Destination) + assert.Equal(t, 45., c.ShipGroup(sgi).Load) + assert.NotNil(t, c.ShipGroup(sgi).CargoType) + assert.Equal(t, game.CargoColonist, *c.ShipGroup(sgi).CargoType) + assert.Equal(t, uint(3), c.ShipGroup(sgi).Number) + + // full load of CAP + sgi = 2 + assert.Equal(t, game.StateLaunched, c.ShipGroup(sgi).State()) + assert.Equal(t, R0_Planet_2_num, c.ShipGroup(sgi).Destination) + assert.Equal(t, 100., c.ShipGroup(sgi).Load) + assert.NotNil(t, c.ShipGroup(sgi).CargoType) + assert.Equal(t, game.CargoCapital, *c.ShipGroup(sgi).CargoType) + assert.Equal(t, uint(5), c.ShipGroup(sgi).Number) + + // partial load of MAT + sgi = 4 + assert.Equal(t, game.StateLaunched, c.ShipGroup(sgi).State()) + assert.Equal(t, R0_Planet_2_num, c.ShipGroup(sgi).Destination) + assert.Equal(t, 20., c.ShipGroup(sgi).Load) + assert.NotNil(t, c.ShipGroup(sgi).CargoType) + assert.Equal(t, game.CargoMaterial, *c.ShipGroup(sgi).CargoType) + assert.Equal(t, uint(1), c.ShipGroup(sgi).Number) + + // empty / on_planet + sgi = 1 + assert.Equal(t, game.StateLaunched, c.ShipGroup(sgi).State()) + assert.Equal(t, R1_Planet_1_num, c.ShipGroup(sgi).Destination) + assert.Equal(t, 0., c.ShipGroup(sgi).Load) + assert.Nil(t, c.ShipGroup(sgi).CargoType) + assert.Equal(t, uint(1), c.ShipGroup(sgi).Number) +} diff --git a/internal/controller/ship_group.go b/internal/controller/ship_group.go index 30dffc4..65ae52f 100644 --- a/internal/controller/ship_group.go +++ b/internal/controller/ship_group.go @@ -504,6 +504,10 @@ func (c *Cache) breakGroupSafe(ri int, groupIndex uint, newGroupShips uint) (int if c.ShipGroup(sgi).Number < newGroupShips { return -1, e.NewBreakGroupIllegalNumberError("group #%d ships: %d -> %d", c.ShipGroup(sgi).Index, c.ShipGroup(sgi).Number, newGroupShips) } + return c.breakGroupUnsafe(ri, sgi, newGroupShips), nil +} + +func (c *Cache) breakGroupUnsafe(ri, sgi int, newGroupShips uint) int { newGroup := *c.ShipGroup(sgi) if c.ShipGroup(sgi).CargoType != nil { newGroup.Load = c.ShipGroup(sgi).Load / float64(c.ShipGroup(sgi).Number) * float64(newGroupShips) @@ -511,7 +515,7 @@ func (c *Cache) breakGroupSafe(ri int, groupIndex uint, newGroupShips uint) (int newGroup.Number = newGroupShips c.ShipGroupShipsNumber(sgi, c.ShipGroup(sgi).Number-newGroup.Number) newGroup.FleetID = nil - return c.appendShipGroup(ri, &newGroup), nil + return c.appendShipGroup(ri, &newGroup) } // Internal funcs