feat: cargo unload challenge

This commit is contained in:
Ilia Denisov
2026-02-21 09:57:02 +02:00
parent 233c9ebc2a
commit 9e36d7151e
12 changed files with 137 additions and 166 deletions
-18
View File
@@ -1,23 +1,5 @@
package controller package controller
/*
TODO: Препроцессинг и сохранение приказов
Когда приказ (последовательность команд) поступает на сервер, игрок получает
уведомление о том, что его команды приняты к производству. Каждая команда из
приказа проверяется на корректность и получает отдельное подтверждение. Игрок
может послать любое количество приказов по своему усмотрению, однако, каждый
новый приказ отменяет предыдущий. Таким образом, можно исправить неверно
составленный приказ, но при этом необходимо повторить те команды, которые
были отданы верно. К счастью, программа-клиент помогает игроку не запутаться
в этом процессе и берёт на себя контроль за целостностью приказов.
!!! Убедиться, что раса не покинула игру.
При производстве хода раса может быть исключена по TTL=0.
В этом случае нужно игнорировать некоторые приказы, например, передачу ей кораблей.
*/
import ( import (
"strings" "strings"
-1
View File
@@ -10,7 +10,6 @@ import (
) )
func (c *Controller) MakeTurn() error { func (c *Controller) MakeTurn() error {
if err := c.applyOrders(c.Cache.g.Turn); err != nil { if err := c.applyOrders(c.Cache.g.Turn); err != nil {
return err return err
} }
+75 -16
View File
@@ -6,6 +6,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
e "github.com/iliadenisov/galaxy/internal/error" e "github.com/iliadenisov/galaxy/internal/error"
"github.com/iliadenisov/galaxy/internal/model/game"
"github.com/iliadenisov/galaxy/internal/model/order" "github.com/iliadenisov/galaxy/internal/model/order"
) )
@@ -108,11 +109,12 @@ func (c *Controller) applyCommand(actor string, cmd order.DecodableCommand) (err
return return
} }
// TODO: test commands ordering
func (c *Controller) applyOrders(t uint) error { func (c *Controller) applyOrders(t uint) error {
raceOrder := make(map[int][]order.DecodableCommand) raceOrder := make(map[int][]order.DecodableCommand)
unloadGroups := make([]uuid.UUID, 0) commandRace := make(map[string]string)
unloadQuantities := make([]float64, 0) challenge := make(map[string]*order.CommandShipGroupUnload)
cmdApplied := make(map[string]bool)
for ri := range c.Cache.listRaceActingIdx() { for ri := range c.Cache.listRaceActingIdx() {
o, ok, err := c.Repo.LoadOrder(t, c.Cache.g.Race[ri].ID) o, ok, err := c.Repo.LoadOrder(t, c.Cache.g.Race[ri].ID)
if err != nil { if err != nil {
@@ -123,30 +125,33 @@ func (c *Controller) applyOrders(t uint) error {
} }
raceOrder[ri] = o.Commands raceOrder[ri] = o.Commands
for i := range o.Commands { for i := range o.Commands {
if o.Commands[i].CommandType() == order.CommandTypeShipGroupUnload { commandRace[o.Commands[i].CommandID()] = c.Cache.g.Race[ri].Name
unloadCommand := o.Commands[i].(order.CommandShipGroupUnload) if v, ok := order.AsCommand[*order.CommandShipGroupUnload](o.Commands[i]); ok {
unloadGroups = append(unloadGroups, uuid.MustParse(unloadCommand.ID)) if _, ok := challenge[v.ID]; ok {
unloadQuantities = append(unloadQuantities, unloadCommand.Quantity) 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 { for _, cmdID := range c.challengeUnload(challenge) {
return err if err := c.applyCommand(commandRace[cmdID], challenge[cmdID]); err == nil {
cmdApplied[cmdID] = true
}
} }
for ri := range raceOrder { for ri := range raceOrder {
for _, cmd := range raceOrder[ri] { for _, cmd := range raceOrder[ri] {
if cmd.CommandType() == order.CommandTypeShipGroupUnload { if v, ok := cmdApplied[cmd.CommandID()]; ok && v {
// 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)
}
continue continue
} }
// any command might fail due to challenged planets colonization // 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 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
}
+17 -18
View File
@@ -171,9 +171,6 @@ func (c *Cache) listRoutedSendGroupIds(pn uint) iter.Seq[int] {
} }
// Невозможно лишь выгрузить колонистов на чужой планете. // Невозможно лишь выгрузить колонистов на чужой планете.
/*
TODO: Очерёдность выгрузки согласно правилам
*/
func (c *Cache) TurnUnloadEnroutedGroups() { func (c *Cache) TurnUnloadEnroutedGroups() {
for i := range c.g.Map.Planet { for i := range c.g.Map.Planet {
p := &c.g.Map.Planet[i] 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 // 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]) result = slices.Values(groupByRace[id])
return return
@@ -277,23 +274,25 @@ func (c *Cache) listRoutedUnloadShipGroupIds(pn uint, routeType game.RouteType)
} }
} }
func MaxOrRandomLoadId(loadByRace map[int]float64) int { func MaxOrRandomLoadId(raceLoad map[int]float64, pop func(int) float64) int {
if len(loadByRace) < 2 { if len(raceLoad) < 2 {
panic("loadByRace must contain at least 2 keys") panic("loadByRace must contain at least 2 keys")
} }
IDs := slices.Collect(maps.Keys(loadByRace)) raceIndex := slices.Collect(maps.Keys(raceLoad))
slices.SortFunc(IDs, func(id1, id2 int) int { slices.SortFunc(raceIndex, func(ria, rib int) int {
return cmp.Or( 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()),
) )
}) })
return raceIndex[0]
// 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]
} }
+27 -8
View File
@@ -320,26 +320,45 @@ func TestListRoutedUnloadShipGroupIds(t *testing.T) {
func TestMaxOrRandomLoadId(t *testing.T) { func TestMaxOrRandomLoadId(t *testing.T) {
IDtoLoad := make(map[int]float64) 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. IDtoLoad[1] = 100.
assert.Panics(t, func() { controller.MaxOrRandomLoadId(IDtoLoad) }) assert.Panics(t, func() { controller.MaxOrRandomLoadId(IDtoLoad, pop) })
IDtoLoad[5] = 100.001 IDtoLoad[5] = 100.001
assert.Equal(t, 5, controller.MaxOrRandomLoadId(IDtoLoad)) assert.Equal(t, 5, controller.MaxOrRandomLoadId(IDtoLoad, pop))
IDtoLoad[3] = 100. 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 IDtoLoad[7] = 100.001
assert.Equal(t, 7, controller.MaxOrRandomLoadId(IDtoLoad, pop))
IDtoLoad[11] = 100.001
rndCount := make(map[int]int) rndCount := make(map[int]int)
for range 100 { for range 100 {
id := controller.MaxOrRandomLoadId(IDtoLoad) id := controller.MaxOrRandomLoadId(IDtoLoad, pop)
assert.NotContains(t, []int{1, 3}, id) assert.NotContains(t, []int{1, 3, 5}, id)
assert.Contains(t, []int{5, 7}, id) assert.Contains(t, []int{7, 11}, id)
rndCount[id]++ rndCount[id]++
} }
assert.Greater(t, rndCount[5], 10)
assert.Greater(t, rndCount[7], 10) assert.Greater(t, rndCount[7], 10)
assert.Greater(t, rndCount[11], 10)
} }
func TestSelectColUnloadGroup(t *testing.T) { func TestSelectColUnloadGroup(t *testing.T) {
+6 -88
View File
@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"iter" "iter"
"maps" "maps"
"math/rand/v2"
"slices" "slices"
"github.com/google/uuid" "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) return e.NewEntityNotOwnedError("planet #%d unload %v", p.Number, ct)
} }
toBeUnloaded := quantity c.unsafeUnloadCargo(sgi, UnloadCargoRequest(float64(c.ShipGroup(sgi).Load), 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)
return nil return nil
} }
func (c *Cache) shipGroupUnloadColonistChallenge(groupIDs []uuid.UUID, quantites []float64) error { func UnloadCargoRequest(load, quantity float64) float64 {
if len(groupIDs) != len(quantites) { result := quantity
e.NewGameStateError("challenge group unload: groups=%d quantities=%d", len(groupIDs), len(quantites)) if result == 0 || result > load {
result = load
} }
if len(groupIDs) == 0 { return result
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
} }
func (c *Cache) shipGroupIndexByID(id uuid.UUID) (int, bool) { func (c *Cache) shipGroupIndexByID(id uuid.UUID) (int, bool) {
+3 -6
View File
@@ -447,11 +447,6 @@ func TestShipGroupUnload(t *testing.T) {
assert.ErrorContains(t, assert.ErrorContains(t,
g.ShipGroupUnload(Race_0.Name, c.ShipGroup(4).ID, 0), g.ShipGroupUnload(Race_0.Name, c.ShipGroup(4).ID, 0),
e.GenericErrorText(e.ErrInputEntityNotOwned)) 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) 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) assert.Nil(t, c.ShipGroup(5).CargoType)
// unload ALL // 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, 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.Equal(t, 0.0, number.Fixed3(c.ShipGroup(0).Load.F()))
assert.Nil(t, c.ShipGroup(0).CargoType) assert.Nil(t, c.ShipGroup(0).CargoType)
-3
View File
@@ -55,7 +55,6 @@ const (
ErrInputNoCargoBay ErrInputNoCargoBay
ErrInputCargoLoadNoSpaceLeft ErrInputCargoLoadNoSpaceLeft
ErrInputCargoUnloadEmpty ErrInputCargoUnloadEmpty
ErrInputCargoUnoadNotEnough
ErrInputBreakGroupIllegalNumber ErrInputBreakGroupIllegalNumber
ErrInputTechUnknown ErrInputTechUnknown
ErrInputTechInvalidMixing ErrInputTechInvalidMixing
@@ -133,8 +132,6 @@ func GenericErrorText(code int) string {
return "No space left on the ships to load cargo" return "No space left on the ships to load cargo"
case ErrInputCargoUnloadEmpty: case ErrInputCargoUnloadEmpty:
return "Ships are not carrying any cargo" return "Ships are not carrying any cargo"
case ErrInputCargoUnoadNotEnough:
return "Not enough cargo on the ships(s)"
case ErrInputBreakGroupIllegalNumber: case ErrInputBreakGroupIllegalNumber:
return "Illegal ships number to make new group" return "Illegal ships number to make new group"
case ErrMergeShipTypeNotEqual: case ErrMergeShipTypeNotEqual:
-4
View File
@@ -100,10 +100,6 @@ func NewCargoUnloadEmptyError(arg ...any) error {
return newGenericError(ErrInputCargoUnloadEmpty, arg...) return newGenericError(ErrInputCargoUnloadEmpty, arg...)
} }
func NewCargoUnoadNotEnoughError(arg ...any) error {
return newGenericError(ErrInputCargoUnoadNotEnough, arg...)
}
func NewBreakGroupIllegalNumberError(arg ...any) error { func NewBreakGroupIllegalNumberError(arg ...any) error {
return newGenericError(ErrInputBreakGroupIllegalNumber, arg...) return newGenericError(ErrInputBreakGroupIllegalNumber, arg...)
} }
+1 -1
View File
@@ -39,7 +39,7 @@ const (
StateLaunched ShipGroupState = "Launched" StateLaunched ShipGroupState = "Launched"
StateInSpace ShipGroupState = "In_Space" StateInSpace ShipGroupState = "In_Space"
StateUpgrade ShipGroupState = "Upgrade" StateUpgrade ShipGroupState = "Upgrade"
StateTransfer ShipGroupState = "Transfer" // TODO: Группы будут передаваться мгновенно в начале производства хода StateTransfer ShipGroupState = "Transfer" // [ ] Группы будут передаваться мгновенно в начале производства хода
) )
func (sgs ShipGroupState) String() string { func (sgs ShipGroupState) String() string {
+7 -2
View File
@@ -16,11 +16,11 @@ func (o *Order) UnmarshalBinary(data []byte) error {
return json.Unmarshal(data, o) 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 { if v, ok := c.(E); ok {
return v, true return v, true
} }
return *new(E), false return
} }
type CommandType string type CommandType string
@@ -56,6 +56,7 @@ func (ct CommandType) String() string {
} }
type DecodableCommand interface { type DecodableCommand interface {
CommandID() string
CommandType() CommandType CommandType() CommandType
} }
@@ -70,6 +71,10 @@ func (cm CommandMeta) CommandType() CommandType {
return cm.CmdType return cm.CmdType
} }
func (cm CommandMeta) CommandID() string {
return cm.CmdID
}
func (cm *CommandMeta) Result(errCode int) { func (cm *CommandMeta) Result(errCode int) {
cm.CmdErrCode = &errCode cm.CmdErrCode = &errCode
cm.CmdApplied = new(bool(errCode == 0)) cm.CmdApplied = new(bool(errCode == 0))
+1 -1
View File
@@ -12,7 +12,7 @@ type LocalPlanet struct {
Colonists Float `json:"colonists"` // COL C - Количество колонистов Colonists Float `json:"colonists"` // COL C - Количество колонистов
Production string `json:"production"` Production string `json:"production"`
FreeIndustry Float `json:"freeInductry"` // Параметр "L" - Свободный производственный потенциал FreeIndustry Float `json:"freeInductry"` // Параметр "L" - Свободный производственный потенциал
// TODO: FreeIndustry - неактуальная информация, т.к. модернизация происходит в процессе производства хода // [ ] FreeIndustry - неактуальная информация, т.к. модернизация происходит в процессе производства хода
} }
type UninhabitedPlanet struct { type UninhabitedPlanet struct {