diff --git a/internal/controller/command.go b/internal/controller/command.go index 6fb9610..f38a5fd 100644 --- a/internal/controller/command.go +++ b/internal/controller/command.go @@ -1,23 +1,5 @@ package controller -/* -TODO: Препроцессинг и сохранение приказов - -Когда приказ (последовательность команд) поступает на сервер, игрок получает -уведомление о том, что его команды приняты к производству. Каждая команда из -приказа проверяется на корректность и получает отдельное подтверждение. Игрок -может послать любое количество приказов по своему усмотрению, однако, каждый -новый приказ отменяет предыдущий. Таким образом, можно исправить неверно -составленный приказ, но при этом необходимо повторить те команды, которые -были отданы верно. К счастью, программа-клиент помогает игроку не запутаться -в этом процессе и берёт на себя контроль за целостностью приказов. - -!!! Убедиться, что раса не покинула игру. - -При производстве хода раса может быть исключена по TTL=0. -В этом случае нужно игнорировать некоторые приказы, например, передачу ей кораблей. -*/ - import ( "strings" diff --git a/internal/controller/generate_turn.go b/internal/controller/generate_turn.go index 6bd72ac..f1cb6bc 100644 --- a/internal/controller/generate_turn.go +++ b/internal/controller/generate_turn.go @@ -10,7 +10,6 @@ import ( ) func (c *Controller) MakeTurn() error { - if err := c.applyOrders(c.Cache.g.Turn); err != nil { return err } diff --git a/internal/controller/order.go b/internal/controller/order.go index 84dfd07..740074d 100644 --- a/internal/controller/order.go +++ b/internal/controller/order.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" e "github.com/iliadenisov/galaxy/internal/error" + "github.com/iliadenisov/galaxy/internal/model/game" "github.com/iliadenisov/galaxy/internal/model/order" ) @@ -108,11 +109,12 @@ func (c *Controller) applyCommand(actor string, cmd order.DecodableCommand) (err return } -// TODO: test commands ordering func (c *Controller) applyOrders(t uint) error { raceOrder := make(map[int][]order.DecodableCommand) - unloadGroups := make([]uuid.UUID, 0) - unloadQuantities := make([]float64, 0) + commandRace := make(map[string]string) + challenge := make(map[string]*order.CommandShipGroupUnload) + cmdApplied := make(map[string]bool) + for ri := range c.Cache.listRaceActingIdx() { o, ok, err := c.Repo.LoadOrder(t, c.Cache.g.Race[ri].ID) if err != nil { @@ -123,30 +125,33 @@ func (c *Controller) applyOrders(t uint) error { } raceOrder[ri] = o.Commands for i := range o.Commands { - if o.Commands[i].CommandType() == order.CommandTypeShipGroupUnload { - unloadCommand := o.Commands[i].(order.CommandShipGroupUnload) - unloadGroups = append(unloadGroups, uuid.MustParse(unloadCommand.ID)) - unloadQuantities = append(unloadQuantities, unloadCommand.Quantity) + commandRace[o.Commands[i].CommandID()] = c.Cache.g.Race[ri].Name + if v, ok := order.AsCommand[*order.CommandShipGroupUnload](o.Commands[i]); ok { + if _, ok := challenge[v.ID]; ok { + panic(fmt.Sprintf("unload command %s already cached", v.ID)) + } + if ok, err := c.shouldChallenge(v); err != nil { + return err + } else if ok { + challenge[v.ID] = v + } } } } - if err := c.Cache.shipGroupUnloadColonistChallenge(unloadGroups, unloadQuantities); err != nil { - return err + for _, cmdID := range c.challengeUnload(challenge) { + if err := c.applyCommand(commandRace[cmdID], challenge[cmdID]); err == nil { + cmdApplied[cmdID] = true + } } for ri := range raceOrder { for _, cmd := range raceOrder[ri] { - if cmd.CommandType() == order.CommandTypeShipGroupUnload { - // group unload commands execution should be failed at this point, - // otherwise produce no-errors - if v, ok := order.AsCommand[*order.CommandShipGroupUnload](cmd); ok { - v.Result(0) - } + if v, ok := cmdApplied[cmd.CommandID()]; ok && v { continue } // any command might fail due to challenged planets colonization - _ = c.applyCommand(c.Cache.g.Race[ri].Name, cmd) + _ = c.applyCommand(commandRace[cmd.CommandID()], cmd) } } @@ -158,3 +163,57 @@ func (c *Controller) applyOrders(t uint) error { return nil } + +func (c *Controller) shouldChallenge(cmd *order.CommandShipGroupUnload) (resut bool, err error) { + sgi, ok := c.Cache.shipGroupIndexByID(uuid.MustParse(cmd.ID)) + if !ok { + err = e.NewGameStateError("challenge group unload: group not found: %v", cmd.ID) + return + } + sg := c.Cache.ShipGroup(sgi) + pn, ok := sg.AtPlanet() + if !ok || sg.CargoType == nil { + return false, nil + } + p := c.Cache.MustPlanet(pn) + if p.Owned() || *sg.CargoType != game.CargoColonist { + return false, nil + } + + return true, nil +} + +func (c *Controller) challengeUnload(challenge map[string]*order.CommandShipGroupUnload) []string { + if len(challenge) == 0 { + return nil + } + planetRaceQuantity := make(map[uint]map[int]float64, 0) + raceCommand := make(map[uint]map[int][]string) + for cmdID, cmd := range challenge { + sgi, ok := c.Cache.shipGroupIndexByID(uuid.MustParse(cmd.ID)) + if !ok { + panic(fmt.Sprintf("challenge group unload: group not found: %v", cmd.ID)) + } + sg := c.Cache.ShipGroup(sgi) + ri := c.Cache.ShipGroupOwnerRaceIndex(sgi) + pn, ok := sg.AtPlanet() + if _, ok := raceCommand[pn]; !ok { + raceCommand[pn] = make(map[int][]string) + } + raceCommand[pn][ri] = append(raceCommand[pn][ri], cmdID) + if _, ok := planetRaceQuantity[pn]; !ok { + planetRaceQuantity[pn] = make(map[int]float64) + } + planetRaceQuantity[pn][ri] = planetRaceQuantity[pn][ri] + UnloadCargoRequest(float64(sg.Load), cmd.Quantity) + } + + result := make([]string, 0) + for pn := range planetRaceQuantity { + if len(planetRaceQuantity[pn]) < 2 { + continue + } + winner := MaxOrRandomLoadId(planetRaceQuantity[pn], func(ri int) float64 { return float64(c.Cache.g.Race[ri].Votes) }) + result = append(result, raceCommand[pn][winner]...) + } + return result +} diff --git a/internal/controller/route.go b/internal/controller/route.go index e289ea6..f8342a2 100644 --- a/internal/controller/route.go +++ b/internal/controller/route.go @@ -171,9 +171,6 @@ func (c *Cache) listRoutedSendGroupIds(pn uint) iter.Seq[int] { } // Невозможно лишь выгрузить колонистов на чужой планете. -/* -TODO: Очерёдность выгрузки согласно правилам -*/ func (c *Cache) TurnUnloadEnroutedGroups() { for i := range c.g.Map.Planet { p := &c.g.Map.Planet[i] @@ -245,7 +242,7 @@ func (c *Cache) selectColUnloadGroup(groups []int) (result iter.Seq[int]) { } // select winner to unload cargo - id := MaxOrRandomLoadId(loadByRace) + id := MaxOrRandomLoadId(loadByRace, func(ri int) float64 { return float64(c.g.Race[ri].Votes) }) result = slices.Values(groupByRace[id]) return @@ -277,23 +274,25 @@ func (c *Cache) listRoutedUnloadShipGroupIds(pn uint, routeType game.RouteType) } } -func MaxOrRandomLoadId(loadByRace map[int]float64) int { - if len(loadByRace) < 2 { +func MaxOrRandomLoadId(raceLoad map[int]float64, pop func(int) float64) int { + if len(raceLoad) < 2 { panic("loadByRace must contain at least 2 keys") } - IDs := slices.Collect(maps.Keys(loadByRace)) - slices.SortFunc(IDs, func(id1, id2 int) int { + raceIndex := slices.Collect(maps.Keys(raceLoad)) + slices.SortFunc(raceIndex, func(ria, rib int) int { return cmp.Or( - cmp.Compare(loadByRace[id2], loadByRace[id1]), + // maximum quantity of unloading colonists + cmp.Compare(raceLoad[rib], raceLoad[ria]), + + // maximum population of the race + cmp.Compare(pop(rib), pop(ria)), + + // Random winner + cmp.Compare(rand.Float64(), rand.Float64()), + + // in theoty, unreacheable option, but let's randomize again + cmp.Compare(rand.Float64(), rand.Float64()), ) }) - - // no single winner with highest load - if loadByRace[IDs[0]] == loadByRace[IDs[1]] { - // remove IDs which load less than maximum - IDs = slices.DeleteFunc(IDs, func(v int) bool { return loadByRace[v] < loadByRace[IDs[0]] }) - // IDs[0] will have random index - rand.Shuffle(len(IDs), func(i, j int) { IDs[i], IDs[j] = IDs[j], IDs[i] }) - } - return IDs[0] + return raceIndex[0] } diff --git a/internal/controller/route_test.go b/internal/controller/route_test.go index 431fe6b..114f4e3 100644 --- a/internal/controller/route_test.go +++ b/internal/controller/route_test.go @@ -320,26 +320,45 @@ func TestListRoutedUnloadShipGroupIds(t *testing.T) { func TestMaxOrRandomLoadId(t *testing.T) { IDtoLoad := make(map[int]float64) - assert.Panics(t, func() { controller.MaxOrRandomLoadId(IDtoLoad) }) + pop := func(ri int) float64 { + switch ri { + case 1: + return 0 + case 3: + return 0 + case 5: + return 9.99 + case 7: + return 10 + case 11: + return 10 + } + return 0 + } + + assert.Panics(t, func() { controller.MaxOrRandomLoadId(IDtoLoad, pop) }) IDtoLoad[1] = 100. - assert.Panics(t, func() { controller.MaxOrRandomLoadId(IDtoLoad) }) + assert.Panics(t, func() { controller.MaxOrRandomLoadId(IDtoLoad, pop) }) IDtoLoad[5] = 100.001 - assert.Equal(t, 5, controller.MaxOrRandomLoadId(IDtoLoad)) + assert.Equal(t, 5, controller.MaxOrRandomLoadId(IDtoLoad, pop)) IDtoLoad[3] = 100. - assert.NotContains(t, []int{1, 3}, controller.MaxOrRandomLoadId(IDtoLoad)) + assert.NotContains(t, []int{1, 3}, controller.MaxOrRandomLoadId(IDtoLoad, pop)) IDtoLoad[7] = 100.001 + assert.Equal(t, 7, controller.MaxOrRandomLoadId(IDtoLoad, pop)) + + IDtoLoad[11] = 100.001 rndCount := make(map[int]int) for range 100 { - id := controller.MaxOrRandomLoadId(IDtoLoad) - assert.NotContains(t, []int{1, 3}, id) - assert.Contains(t, []int{5, 7}, id) + id := controller.MaxOrRandomLoadId(IDtoLoad, pop) + assert.NotContains(t, []int{1, 3, 5}, id) + assert.Contains(t, []int{7, 11}, id) rndCount[id]++ } - assert.Greater(t, rndCount[5], 10) assert.Greater(t, rndCount[7], 10) + assert.Greater(t, rndCount[11], 10) } func TestSelectColUnloadGroup(t *testing.T) { diff --git a/internal/controller/ship_group.go b/internal/controller/ship_group.go index 27abc9c..c23db4c 100644 --- a/internal/controller/ship_group.go +++ b/internal/controller/ship_group.go @@ -5,7 +5,6 @@ import ( "fmt" "iter" "maps" - "math/rand/v2" "slices" "github.com/google/uuid" @@ -283,97 +282,16 @@ func (c *Cache) shipGroupUnload(ri int, groupID uuid.UUID, quantity float64) err return e.NewEntityNotOwnedError("planet #%d unload %v", p.Number, ct) } - toBeUnloaded := quantity - if quantity == 0 { - toBeUnloaded = float64(c.ShipGroup(sgi).Load) - } - if toBeUnloaded > float64(c.ShipGroup(sgi).Load) { - return e.NewCargoUnoadNotEnoughError("load: %.03f", c.ShipGroup(sgi).Load) - } - - c.unsafeUnloadCargo(sgi, toBeUnloaded) - + c.unsafeUnloadCargo(sgi, UnloadCargoRequest(float64(c.ShipGroup(sgi).Load), quantity)) return nil } -func (c *Cache) shipGroupUnloadColonistChallenge(groupIDs []uuid.UUID, quantites []float64) error { - if len(groupIDs) != len(quantites) { - e.NewGameStateError("challenge group unload: groups=%d quantities=%d", len(groupIDs), len(quantites)) +func UnloadCargoRequest(load, quantity float64) float64 { + result := quantity + if result == 0 || result > load { + result = load } - if len(groupIDs) == 0 { - return nil - } - type challenger struct { - ri int - sgi int - quantity float64 - } - challenge := make(map[uint]map[int][]challenger, 0) - for i, gid := range groupIDs { - sgi, ok := c.shipGroupIndexByID(gid) - if !ok { - return e.NewGameStateError("challenge group unload: group not found: %v", gid) - } - sg := c.ShipGroup(sgi) - pn, ok := sg.AtPlanet() - if !ok || sg.CargoType == nil { - continue - } - p := c.MustPlanet(pn) - ri := c.ShipGroupOwnerRaceIndex(sgi) - if p.Owned() || *sg.CargoType != game.CargoColonist { - if err := c.shipGroupUnload(ri, gid, quantites[i]); err != nil { - return err - } - } - if _, ok := challenge[pn]; !ok { - challenge[pn] = make(map[int][]challenger) - } - challenge[pn][ri] = append(challenge[pn][ri], challenger{ri: ri, sgi: sgi, quantity: quantites[i]}) - } - for pn := range challenge { - if len(challenge[pn]) < 2 { - for _, v := range challenge[pn] { - for _, ch := range v { - if err := c.shipGroupUnload(ch.ri, c.ShipGroup(ch.sgi).ID, ch.quantity); err != nil { - return err - } - } - } - continue - } - sum := make(map[int]float64) - races := slices.Collect(maps.Keys(challenge[pn])) - for ri, groups := range challenge[pn] { - for i := range groups { - sum[ri] = sum[ri] + groups[i].quantity - } - c.listProducingPlanets() - } - - slices.SortFunc(races, func(ria, rib int) int { - return cmp.Or( - // Наибольшее количество выгружаемых колонистов - cmp.Compare(sum[rib], sum[ria]), - - // Наибольшее количество населения расы (они же голоса) - cmp.Compare(c.g.Race[rib].Votes, c.g.Race[ria].Votes), - - // Случайный выбор претендента - cmp.Compare(rand.Float64(), rand.Float64()), - - // in theoty, unreacheable option, but let's randomize again - cmp.Compare(rand.Float64(), rand.Float64()), - ) - }) - - for _, ch := range challenge[pn][races[0]] { - if err := c.shipGroupUnload(ch.ri, c.ShipGroup(ch.sgi).ID, ch.quantity); err != nil { - return err - } - } - } - return nil + return result } func (c *Cache) shipGroupIndexByID(id uuid.UUID) (int, bool) { diff --git a/internal/controller/ship_group_test.go b/internal/controller/ship_group_test.go index 4012de7..d4a5528 100644 --- a/internal/controller/ship_group_test.go +++ b/internal/controller/ship_group_test.go @@ -447,11 +447,6 @@ func TestShipGroupUnload(t *testing.T) { assert.ErrorContains(t, g.ShipGroupUnload(Race_0.Name, c.ShipGroup(4).ID, 0), e.GenericErrorText(e.ErrInputEntityNotOwned)) - c.ShipGroup(0).CargoType = game.CargoColonist.Ref() - c.ShipGroup(0).Load = 100 - assert.ErrorContains(t, - g.ShipGroupUnload(Race_0.Name, c.ShipGroup(0).ID, 101), - e.GenericErrorText(e.ErrInputCargoUnoadNotEnough)) assert.Len(t, slices.Collect(c.RaceShipGroups(Race_0_idx)), 6) @@ -468,7 +463,9 @@ func TestShipGroupUnload(t *testing.T) { assert.Nil(t, c.ShipGroup(5).CargoType) // unload ALL - assert.NoError(t, g.ShipGroupUnload(Race_0.Name, c.ShipGroup(0).ID, 0)) + c.ShipGroup(0).CargoType = game.CargoColonist.Ref() + c.ShipGroup(0).Load = 100 + assert.NoError(t, g.ShipGroupUnload(Race_0.Name, c.ShipGroup(0).ID, 101)) assert.Equal(t, 100.0, number.Fixed3(c.MustPlanet(R0_Planet_0_num).Colonists.F())) assert.Equal(t, 0.0, number.Fixed3(c.ShipGroup(0).Load.F())) assert.Nil(t, c.ShipGroup(0).CargoType) diff --git a/internal/error/generic.go b/internal/error/generic.go index e3688ad..6de99f7 100644 --- a/internal/error/generic.go +++ b/internal/error/generic.go @@ -55,7 +55,6 @@ const ( ErrInputNoCargoBay ErrInputCargoLoadNoSpaceLeft ErrInputCargoUnloadEmpty - ErrInputCargoUnoadNotEnough ErrInputBreakGroupIllegalNumber ErrInputTechUnknown ErrInputTechInvalidMixing @@ -133,8 +132,6 @@ func GenericErrorText(code int) string { return "No space left on the ships to load cargo" case ErrInputCargoUnloadEmpty: return "Ships are not carrying any cargo" - case ErrInputCargoUnoadNotEnough: - return "Not enough cargo on the ships(s)" case ErrInputBreakGroupIllegalNumber: return "Illegal ships number to make new group" case ErrMergeShipTypeNotEqual: diff --git a/internal/error/input.go b/internal/error/input.go index 725e3c2..960aa67 100644 --- a/internal/error/input.go +++ b/internal/error/input.go @@ -100,10 +100,6 @@ func NewCargoUnloadEmptyError(arg ...any) error { return newGenericError(ErrInputCargoUnloadEmpty, arg...) } -func NewCargoUnoadNotEnoughError(arg ...any) error { - return newGenericError(ErrInputCargoUnoadNotEnough, arg...) -} - func NewBreakGroupIllegalNumberError(arg ...any) error { return newGenericError(ErrInputBreakGroupIllegalNumber, arg...) } diff --git a/internal/model/game/group.go b/internal/model/game/group.go index 02627ed..dfa44df 100644 --- a/internal/model/game/group.go +++ b/internal/model/game/group.go @@ -39,7 +39,7 @@ const ( StateLaunched ShipGroupState = "Launched" StateInSpace ShipGroupState = "In_Space" StateUpgrade ShipGroupState = "Upgrade" - StateTransfer ShipGroupState = "Transfer" // TODO: Группы будут передаваться мгновенно в начале производства хода + StateTransfer ShipGroupState = "Transfer" // [ ] Группы будут передаваться мгновенно в начале производства хода ) func (sgs ShipGroupState) String() string { diff --git a/internal/model/order/order.go b/internal/model/order/order.go index c7dae83..8e3db80 100644 --- a/internal/model/order/order.go +++ b/internal/model/order/order.go @@ -16,11 +16,11 @@ func (o *Order) UnmarshalBinary(data []byte) error { return json.Unmarshal(data, o) } -func AsCommand[E DecodableCommand](c DecodableCommand) (E, bool) { +func AsCommand[E DecodableCommand](c DecodableCommand) (result E, ok bool) { if v, ok := c.(E); ok { return v, true } - return *new(E), false + return } type CommandType string @@ -56,6 +56,7 @@ func (ct CommandType) String() string { } type DecodableCommand interface { + CommandID() string CommandType() CommandType } @@ -70,6 +71,10 @@ func (cm CommandMeta) CommandType() CommandType { return cm.CmdType } +func (cm CommandMeta) CommandID() string { + return cm.CmdID +} + func (cm *CommandMeta) Result(errCode int) { cm.CmdErrCode = &errCode cm.CmdApplied = new(bool(errCode == 0)) diff --git a/internal/model/report/planet.go b/internal/model/report/planet.go index 7b2d3aa..6bcebae 100644 --- a/internal/model/report/planet.go +++ b/internal/model/report/planet.go @@ -12,7 +12,7 @@ type LocalPlanet struct { Colonists Float `json:"colonists"` // COL C - Количество колонистов Production string `json:"production"` FreeIndustry Float `json:"freeInductry"` // Параметр "L" - Свободный производственный потенциал - // TODO: FreeIndustry - неактуальная информация, т.к. модернизация происходит в процессе производства хода + // [ ] FreeIndustry - неактуальная информация, т.к. модернизация происходит в процессе производства хода } type UninhabitedPlanet struct {