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
/*
TODO: Препроцессинг и сохранение приказов
Когда приказ (последовательность команд) поступает на сервер, игрок получает
уведомление о том, что его команды приняты к производству. Каждая команда из
приказа проверяется на корректность и получает отдельное подтверждение. Игрок
может послать любое количество приказов по своему усмотрению, однако, каждый
новый приказ отменяет предыдущий. Таким образом, можно исправить неверно
составленный приказ, но при этом необходимо повторить те команды, которые
были отданы верно. К счастью, программа-клиент помогает игроку не запутаться
в этом процессе и берёт на себя контроль за целостностью приказов.
!!! Убедиться, что раса не покинула игру.
При производстве хода раса может быть исключена по TTL=0.
В этом случае нужно игнорировать некоторые приказы, например, передачу ей кораблей.
*/
import (
"strings"
-1
View File
@@ -10,7 +10,6 @@ import (
)
func (c *Controller) MakeTurn() error {
if err := c.applyOrders(c.Cache.g.Turn); err != nil {
return err
}
+75 -16
View File
@@ -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
}
+17 -18
View File
@@ -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]
}
+27 -8
View File
@@ -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) {
+6 -88
View File
@@ -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) {
+3 -6
View File
@@ -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)
-3
View File
@@ -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:
-4
View File
@@ -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...)
}
+1 -1
View File
@@ -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 {
+7 -2
View File
@@ -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))
+1 -1
View File
@@ -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 {