Files
galaxy-game/pkg/model/order/order.go
T
Ilia Denisov 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
fix(order): surface rejection reason, keep sync green, hydrate verdicts
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>
2026-05-29 11:42:27 +02:00

293 lines
9.6 KiB
Go

package order
import (
"encoding/json"
"github.com/google/uuid"
)
// MessageTypeUserGamesCommand is the authenticated gateway message type
// used to send a batch of in-game commands to the engine through
// `POST /api/v1/user/games/{game_id}/commands`. The signed payload is
// a FlatBuffers `order.UserGamesCommand`.
const MessageTypeUserGamesCommand = "user.games.command"
// MessageTypeUserGamesOrder is the authenticated gateway message type
// used to validate / store a batch of in-game orders through
// `POST /api/v1/user/games/{game_id}/orders`. The signed payload is a
// FlatBuffers `order.UserGamesOrder`.
const MessageTypeUserGamesOrder = "user.games.order"
// MessageTypeUserGamesOrderGet is the authenticated gateway message
// type used to read back the player's stored order for a given turn
// through `GET /api/v1/user/games/{game_id}/orders?turn=N`. The
// signed payload is a FlatBuffers `order.UserGamesOrderGet`.
const MessageTypeUserGamesOrderGet = "user.games.order.get"
// UserGamesCommand is the typed payload of MessageTypeUserGamesCommand.
// `GameID` selects the running engine container; `Commands` is the
// player command batch executed atomically by the engine. The `Actor`
// field present in the engine's JSON shape is rebuilt by backend from
// the runtime player mapping — clients never carry it.
type UserGamesCommand struct {
// GameID identifies the running game for this batch.
GameID uuid.UUID `json:"game_id"`
// Commands is the player command batch.
Commands []DecodableCommand `json:"cmd"`
}
// UserGamesOrder is the typed payload of MessageTypeUserGamesOrder.
// Mirrors `UserGamesCommand` plus an `UpdatedAt` field that lets the
// engine reject stale order submissions.
type UserGamesOrder struct {
// GameID identifies the running game for this batch.
GameID uuid.UUID `json:"game_id"`
// UpdatedAt is the client-side timestamp used for stale-order
// detection on the engine side.
UpdatedAt int64 `json:"updatedAt"`
// Commands is the player order batch.
Commands []DecodableCommand `json:"cmd"`
}
func (o UserGamesOrder) MarshalBinary() (data []byte, err error) {
return json.Marshal(&o)
}
func (o *UserGamesOrder) UnmarshalBinary(data []byte) error {
return json.Unmarshal(data, o)
}
// UserGamesOrderGet is the typed payload of
// MessageTypeUserGamesOrderGet. `Turn` is mandatory and must be
// non-negative; the caller pulls it from the lobby record at game
// boot. Backend rebinds the player from the runtime player mapping
// before forwarding to the engine.
type UserGamesOrderGet struct {
// GameID identifies the running game whose order is being
// read back.
GameID uuid.UUID `json:"game_id"`
// Turn selects the turn the stored order belongs to. Negative
// values are invalid.
Turn int `json:"turn"`
}
func AsCommand[E DecodableCommand](c DecodableCommand) (result E, ok bool) {
if v, ok := c.(E); ok {
return v, true
}
return
}
type CommandType string
const (
CommandTypeRaceQuit CommandType = "raceQuit"
CommandTypeRaceVote CommandType = "raceVote"
CommandTypeRaceRelation CommandType = "raceRelation"
CommandTypeShipClassCreate CommandType = "shipClassCreate"
CommandTypeShipClassMerge CommandType = "shipClassMerge"
CommandTypeShipClassRemove CommandType = "shipClassRemove"
CommandTypeShipGroupBreak CommandType = "shipGroupBreak"
CommandTypeShipGroupLoad CommandType = "shipGroupLoad"
CommandTypeShipGroupUnload CommandType = "shipGroupUnload"
CommandTypeShipGroupSend CommandType = "shipGroupSend"
CommandTypeShipGroupUpgrade CommandType = "shipGroupUpgrade"
CommandTypeShipGroupMerge CommandType = "shipGroupMerge"
CommandTypeShipGroupDismantle CommandType = "shipGroupDismantle"
CommandTypeShipGroupTransfer CommandType = "shipGroupTransfer"
CommandTypeShipGroupJoinFleet CommandType = "shipGroupJoinFleet"
CommandTypeFleetMerge CommandType = "fleetMerge"
CommandTypeFleetSend CommandType = "fleetSend"
CommandTypeScienceCreate CommandType = "scienceCreate"
CommandTypeScienceRemove CommandType = "scienceRemove"
CommandTypePlanetRename CommandType = "planetRename"
CommandTypePlanetProduce CommandType = "planetProduce"
CommandTypePlanetRouteSet CommandType = "planetRouteSet"
CommandTypePlanetRouteRemove CommandType = "planetRouteRemove"
)
func (ct CommandType) String() string {
return string(ct)
}
type DecodableCommand interface {
CommandID() string
CommandType() CommandType
}
type CommandMeta struct {
CmdType CommandType `json:"@type" binding:"notblank"`
CmdID string `json:"cmdId" binding:"required,uuid_rfc4122"`
CmdApplied *bool `json:"cmdApplied,omitempty"`
CmdErrCode *int `json:"cmdErrorCode,omitempty"`
CmdErrMsg *string `json:"cmdErrorMessage,omitempty"`
}
func (cm CommandMeta) CommandType() CommandType {
return cm.CmdType
}
func (cm CommandMeta) CommandID() string {
return cm.CmdID
}
// Result records the per-command outcome on the meta. errCode == 0 marks
// the command as applied and clears CmdErrMsg; non-zero records the
// rejection along with the human-readable engine message so clients can
// surface the reason without their own code-to-text catalog.
func (cm *CommandMeta) Result(errCode int, errMsg string) {
cm.CmdErrCode = &errCode
cm.CmdApplied = new(bool(errCode == 0))
if errCode == 0 {
cm.CmdErrMsg = nil
return
}
cm.CmdErrMsg = &errMsg
}
type CommandRaceQuit struct {
CommandMeta
}
type CommandRaceVote struct {
CommandMeta
Acceptor string `json:"acceptor" binding:"notblank,entity"`
}
type CommandRaceRelation struct {
CommandMeta
Acceptor string `json:"acceptor" binding:"notblank,entity"`
Relation string `json:"relation" binding:"oneof=WAR PEACE"`
}
type CommandShipClassCreate struct {
CommandMeta
Name string `json:"name" binding:"notblank,entity"`
Drive float64 `json:"drive" binding:"eq=0|gte=1"`
Armament int `json:"armament" binding:"ammoWeapons=Weapons"`
Weapons float64 `json:"weapons" binding:"ammoWeapons=Armament"`
Shields float64 `json:"shields" binding:"eq=0|gte=1"`
Cargo float64 `json:"cargo" binding:"eq=0|gte=1"`
}
type CommandShipClassMerge struct {
CommandMeta
Name string `json:"name" binding:"notblank,entity,nefield=Target"`
Target string `json:"target" binding:"notblank,entity,nefield=Name"`
}
type CommandShipClassRemove struct {
CommandMeta
Name string `json:"name" binding:"required,notblank,entity"`
}
type CommandShipGroupLoad struct {
CommandMeta
ID string `json:"id" binding:"required,uuid_rfc4122"`
Cargo string `json:"cargo" binding:"oneof=COL MAT CAP"`
Quantity float64 `json:"quantity" binding:"gte=0"`
}
type CommandShipGroupUnload struct {
CommandMeta
ID string `json:"id" binding:"required,uuid_rfc4122"`
Quantity float64 `json:"quantity" binding:"gte=0"`
}
type CommandShipGroupSend struct {
CommandMeta
ID string `json:"id" binding:"required,uuid_rfc4122"`
Destination int `json:"planetNumber" binding:"gte=0"`
}
type CommandShipGroupUpgrade struct {
CommandMeta
ID string `json:"id" binding:"required,uuid_rfc4122"`
Tech string `json:"tech" binding:"oneof=ALL DRIVE WEAPONS SHIELDS CARGO"`
Level float64 `json:"level" binding:"eq=0|gt=1"`
}
type CommandShipGroupMerge struct {
CommandMeta
}
type CommandShipGroupBreak struct {
CommandMeta
ID string `json:"id" binding:"uuid_rfc4122,nefield=NewID"`
NewID string `json:"newId" binding:"uuid_rfc4122,nefield=ID"`
Quantity int `json:"quantity" binding:"gte=0"`
}
type CommandShipGroupDismantle struct {
CommandMeta
ID string `json:"id" binding:"required,uuid_rfc4122"`
}
type CommandShipGroupTransfer struct {
CommandMeta
ID string `json:"id" binding:"required,uuid_rfc4122"`
Acceptor string `json:"acceptor" binding:"required,notblank,entity"`
}
type CommandShipGroupJoinFleet struct {
CommandMeta
ID string `json:"id" binding:"required,uuid_rfc4122"`
Name string `json:"name" binding:"required,notblank,entity"`
}
type CommandFleetMerge struct {
CommandMeta
Name string `json:"name" binding:"required,notblank,entity,nefield=Target"`
Target string `json:"target" binding:"required,notblank,entity,nefield=Name"`
}
type CommandFleetSend struct {
CommandMeta
Name string `json:"name" binding:"required,notblank,entity"`
Destination int `json:"planetNumber" binding:"gte=0"`
}
type CommandScienceCreate struct {
CommandMeta
Name string `json:"name" binding:"required,notblank,entity"`
Drive float64 `json:"drive" binding:"gte=0,lte=1"`
Weapons float64 `json:"weapons" binding:"gte=0,lte=1"`
Shields float64 `json:"shields" binding:"gte=0,lte=1"`
Cargo float64 `json:"cargo" binding:"gte=0,lte=1"`
}
type CommandScienceRemove struct {
CommandMeta
Name string `json:"name" binding:"required,notblank,entity"`
}
type CommandPlanetRename struct {
CommandMeta
Number int `json:"planetNumber" binding:"gte=0"`
Name string `json:"name" binding:"required,notblank,entity"`
}
type CommandPlanetProduce struct {
CommandMeta
Number int `json:"planetNumber" binding:"gte=0"`
Production string `json:"production" binding:"oneof=MAT CAP DRIVE WEAPONS SHIELDS CARGO SCIENCE SHIP"`
Subject string `json:"subject" binding:"subject=Production"`
}
type CommandPlanetRouteSet struct {
CommandMeta
Origin int `json:"fromPlanetNumber" binding:"gte=0,nefield=Destination"`
Destination int `json:"toPlanetNumber" binding:"gte=0,nefield=Origin"`
LoadType string `json:"loadType" binding:"oneof=MAT CAP COL EMP"`
}
type CommandPlanetRouteRemove struct {
CommandMeta
Origin int `json:"fromPlanetNumber" binding:"gte=0"`
LoadType string `json:"loadType" binding:"oneof=MAT CAP COL EMP"`
}