723885e74e
Tests · UI / test (push) Has been cancelled
Tests · Go / test (push) Successful in 2m3s
Tests · Go / test (pull_request) Successful in 2m5s
Tests · Integration / integration (pull_request) Successful in 1m44s
Tests · UI / test (pull_request) Failing after 4m28s
Three issues surfaced once the per-command rejection from the previous commit actually reached the UI: 1. Sync banner falsely red. `OrderDraftStore.runSync` flipped `syncStatus = "error"` whenever any command was rejected and advertised a Retry button. A per-command rejection is a player-correctable state — the round trip succeeded, the engine just refused that command — so the retry can't help. Keep `syncStatus = "synced"` on `success`; the red row highlight is the visible cue. 2. Rejection reason missing. Add `cmd_error_message: string` to `CommandItem` in `pkg/schema/fbs/order.fbs` (appended last to preserve existing slot offsets) and regenerate the Go + TS stubs for that one type. Plumb the message through `CommandMeta`, `Controller.applyCommand`'s `m.Result(code, message)` call, the Go transcoder, the UI decoders in `submit.ts` / `order-load.ts`, and the `OrderDraftStore.errorMessages` map. `order-tab.svelte` renders it as an italic danger-coloured line under rejected commands, with new CSS for `.error-reason`. 3. Verdict lost on navigation. `order-load.ts.decodeCommand` never read `cmdApplied`/`cmdErrorCode`, so `hydrateFromServer` fell back to a blanket "applied" status — a previously-rejected command came back green after a lobby → game round trip. Extend the fetch decoder to populate `statuses`/`errorCodes`/ `errorMessages` maps and have `hydrateFromServer` use them. Engine-side persistence already records the verdict on disk — verified against the live `0000/order/<id>.json`. `flatbuffers@25` elides default-int8/int64 fields on write; the Go transcoder force-slots `cmd_applied=false` / `cmd_error_code=0` already, the new test fixtures flip `builder.forceDefaults(true)` to mirror that behaviour so the round trip survives. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
234 lines
8.4 KiB
Go
234 lines
8.4 KiB
Go
package controller
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
|
|
"galaxy/model/order"
|
|
|
|
e "galaxy/error"
|
|
|
|
"galaxy/game/internal/model/game"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// ValidateOrder applies every command in the order against a transient
|
|
// view of the engine state, records the per-command outcome in each
|
|
// command's CommandMeta via applyCommand, and reports only order-level
|
|
// structural errors as the function return. Per-command rejections are
|
|
// surfaced through CommandMeta.Result so the caller can persist and
|
|
// forward them as `cmdApplied`/`cmdErrorCode` in the response body.
|
|
func (c *Controller) ValidateOrder(actor string, commands ...order.DecodableCommand) error {
|
|
for i := range commands {
|
|
if _, ok := commands[i].(*order.CommandRaceQuit); ok && i != len(commands)-1 {
|
|
return e.NewQuitCommandFollowedByCommandError()
|
|
}
|
|
// applyCommand never returns a non-GenericError outside of
|
|
// programmer-error panics; the per-command code, if any, is
|
|
// already recorded on the command's meta and must not abort
|
|
// validation of the remaining commands in this order.
|
|
_ = c.applyCommand(actor, commands[i])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Controller) applyCommand(actor string, cmd order.DecodableCommand) (err error) {
|
|
var m *order.CommandMeta
|
|
if v, ok := order.AsCommand[*order.CommandRaceQuit](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.RaceQuit(actor)
|
|
} else if v, ok := order.AsCommand[*order.CommandRaceVote](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.RaceVote(actor, v.Acceptor)
|
|
} else if v, ok := order.AsCommand[*order.CommandRaceRelation](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.RaceRelation(actor, v.Acceptor, v.Relation)
|
|
} else if v, ok := order.AsCommand[*order.CommandShipClassCreate](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.ShipClassCreate(actor, v.Name, v.Drive, int(v.Armament), v.Weapons, v.Shields, v.Cargo)
|
|
} else if v, ok := order.AsCommand[*order.CommandShipClassMerge](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.ShipClassMerge(actor, v.Name, v.Target)
|
|
} else if v, ok := order.AsCommand[*order.CommandShipClassRemove](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.ShipClassRemove(actor, v.Name)
|
|
} else if v, ok := order.AsCommand[*order.CommandShipGroupLoad](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.ShipGroupLoad(actor, uuid.MustParse(v.ID), v.Cargo, v.Quantity)
|
|
} else if v, ok := order.AsCommand[*order.CommandShipGroupUnload](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.ShipGroupUnload(actor, uuid.MustParse(v.ID), v.Quantity)
|
|
} else if v, ok := order.AsCommand[*order.CommandShipGroupSend](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.ShipGroupSend(actor, uuid.MustParse(v.ID), uint(v.Destination))
|
|
} else if v, ok := order.AsCommand[*order.CommandShipGroupUpgrade](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.ShipGroupUpgrade(actor, uuid.MustParse(v.ID), v.Tech, v.Level)
|
|
} else if v, ok := order.AsCommand[*order.CommandShipGroupMerge](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.ShipGroupMerge(actor)
|
|
} else if v, ok := order.AsCommand[*order.CommandShipGroupBreak](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.ShipGroupBreak(actor, uuid.MustParse(v.ID), uuid.MustParse(v.NewID), uint(v.Quantity))
|
|
} else if v, ok := order.AsCommand[*order.CommandShipGroupDismantle](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.ShipGroupDismantle(actor, uuid.MustParse(v.ID))
|
|
} else if v, ok := order.AsCommand[*order.CommandShipGroupTransfer](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.ShipGroupTransfer(actor, v.Acceptor, uuid.MustParse(v.ID))
|
|
} else if v, ok := order.AsCommand[*order.CommandShipGroupJoinFleet](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.ShipGroupJoinFleet(actor, v.Name, uuid.MustParse(v.ID))
|
|
} else if v, ok := order.AsCommand[*order.CommandFleetMerge](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.FleetMerge(actor, v.Name, v.Target)
|
|
} else if v, ok := order.AsCommand[*order.CommandFleetSend](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.FleetSend(actor, v.Name, uint(v.Destination))
|
|
} else if v, ok := order.AsCommand[*order.CommandScienceCreate](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.ScienceCreate(actor, v.Name, v.Drive, v.Weapons, v.Shields, v.Cargo)
|
|
} else if v, ok := order.AsCommand[*order.CommandScienceRemove](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.ScienceRemove(actor, v.Name)
|
|
} else if v, ok := order.AsCommand[*order.CommandPlanetRename](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.PlanetRename(actor, v.Number, v.Name)
|
|
} else if v, ok := order.AsCommand[*order.CommandPlanetProduce](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.PlanetProduce(actor, v.Number, v.Production, v.Subject)
|
|
} else if v, ok := order.AsCommand[*order.CommandPlanetRouteSet](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.PlanetRouteSet(actor, v.LoadType, uint(v.Origin), uint(v.Destination))
|
|
} else if v, ok := order.AsCommand[*order.CommandPlanetRouteRemove](cmd); ok {
|
|
m = &v.CommandMeta
|
|
err = c.PlanetRouteRemove(actor, v.LoadType, uint(v.Origin))
|
|
} else {
|
|
return e.NewUnrecognizedCommandError(cmd.CommandType().String())
|
|
}
|
|
|
|
if ge, ok := errors.AsType[*e.GenericError](err); ok {
|
|
m.Result(ge.Code, ge.Error())
|
|
} else if err != nil {
|
|
panic(fmt.Errorf("error applying command has unknown origin: %w", err))
|
|
} else {
|
|
m.Result(0, "")
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func (c *Controller) applyOrders(t uint) error {
|
|
raceOrder := make(map[int][]order.DecodableCommand)
|
|
raceOrderUpdated := make(map[int]int64)
|
|
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 {
|
|
return err
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
raceOrder[ri] = o.Commands
|
|
raceOrderUpdated[ri] = o.UpdatedAt
|
|
for i := range o.Commands {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 v, ok := cmdApplied[cmd.CommandID()]; ok && v {
|
|
continue
|
|
}
|
|
// any command might fail due to challenged planets colonization
|
|
_ = c.applyCommand(commandRace[cmd.CommandID()], cmd)
|
|
}
|
|
// re-save order to persist possible changed commands result outcome
|
|
if err := c.Repo.SaveOrder(t, c.Cache.g.Race[ri].ID, &order.UserGamesOrder{
|
|
GameID: c.Cache.g.ID,
|
|
UpdatedAt: raceOrderUpdated[ri],
|
|
Commands: raceOrder[ri],
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|