fix(order): surface rejection reason, keep sync green, hydrate verdicts
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>
This commit is contained in:
Ilia Denisov
2026-05-29 11:42:27 +02:00
parent e038ea6154
commit 723885e74e
17 changed files with 404 additions and 40 deletions
+11 -1
View File
@@ -124,6 +124,7 @@ type CommandMeta struct {
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 {
@@ -134,9 +135,18 @@ func (cm CommandMeta) CommandID() string {
return cm.CmdID
}
func (cm *CommandMeta) Result(errCode int) {
// 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 {
+6
View File
@@ -193,6 +193,12 @@ table CommandItem {
cmd_applied: bool = null;
cmd_error_code: int64 = null;
payload: CommandPayload (required);
// Human-readable failure reason returned by the engine when
// `cmd_applied = false`. Appended after `payload` to preserve the
// wire offsets of existing slots (FBS field IDs are allocated in
// declaration order, so inserting in the middle would shift every
// later slot). Omitted on requests and on applied commands.
cmd_error_message: string;
}
// UserGamesCommand is the signed-gRPC request payload for
+12 -1
View File
@@ -96,8 +96,16 @@ func (rcv *CommandItem) Payload(obj *flatbuffers.Table) bool {
return false
}
func (rcv *CommandItem) CmdErrorMessage() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func CommandItemStart(builder *flatbuffers.Builder) {
builder.StartObject(5)
builder.StartObject(6)
}
func CommandItemAddCmdId(builder *flatbuffers.Builder, cmdId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(cmdId), 0)
@@ -116,6 +124,9 @@ func CommandItemAddPayloadType(builder *flatbuffers.Builder, payloadType Command
func CommandItemAddPayload(builder *flatbuffers.Builder, payload flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(payload), 0)
}
func CommandItemAddCmdErrorMessage(builder *flatbuffers.Builder, cmdErrorMessage flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(cmdErrorMessage), 0)
}
func CommandItemEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+23
View File
@@ -125,6 +125,7 @@ type encodedCommand struct {
cmdID string
cmdApplied *bool
cmdErrCode *int
cmdErrMsg *string
payloadType fbs.CommandPayload
payloadOffset flatbuffers.UOffsetT
}
@@ -404,6 +405,7 @@ func encodedCommandFromMeta(meta model.CommandMeta, payloadType fbs.CommandPaylo
cmdID: meta.CmdID,
cmdApplied: cloneBoolPointer(meta.CmdApplied),
cmdErrCode: cloneIntPointer(meta.CmdErrCode),
cmdErrMsg: cloneStringPointer(meta.CmdErrMsg),
payloadType: payloadType,
payloadOffset: payloadOffset,
}
@@ -423,6 +425,11 @@ func decodeOrderCommand(flatCommand *fbs.CommandItem, index int) (model.Decodabl
commandMeta.CmdErrCode = &decodedCmdErrCode
}
if cmdErrMsg := flatCommand.CmdErrorMessage(); cmdErrMsg != nil {
decodedCmdErrMsg := string(cmdErrMsg)
commandMeta.CmdErrMsg = &decodedCmdErrMsg
}
payloadType := flatCommand.PayloadType()
if payloadType == fbs.CommandPayloadNONE {
return nil, fmt.Errorf("decode order command %d: payload type is NONE", index)
@@ -915,6 +922,15 @@ func cloneIntPointer(value *int) *int {
return &cloned
}
func cloneStringPointer(value *string) *string {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
// UserGamesCommandToPayload converts model.UserGamesCommand to
// FlatBuffers bytes suitable for the authenticated gateway transport.
// `GameID` is required.
@@ -1293,6 +1309,10 @@ func encodeCommandItemVector(builder *flatbuffers.Builder, commands []model.Deco
return 0, fmt.Errorf("encode %s: %w", opLabel, err)
}
cmdID := builder.CreateString(encoded.cmdID)
var cmdErrMsg flatbuffers.UOffsetT
if encoded.cmdErrMsg != nil {
cmdErrMsg = builder.CreateString(*encoded.cmdErrMsg)
}
fbs.CommandItemStart(builder)
fbs.CommandItemAddCmdId(builder, cmdID)
if encoded.cmdApplied != nil {
@@ -1303,6 +1323,9 @@ func encodeCommandItemVector(builder *flatbuffers.Builder, commands []model.Deco
}
fbs.CommandItemAddPayloadType(builder, encoded.payloadType)
fbs.CommandItemAddPayload(builder, encoded.payloadOffset)
if encoded.cmdErrMsg != nil {
fbs.CommandItemAddCmdErrorMessage(builder, cmdErrMsg)
}
offsets[i] = fbs.CommandItemEnd(builder)
}
if len(offsets) == 0 {
+7 -1
View File
@@ -94,6 +94,7 @@ func TestUserGamesOrderResponsePayloadRoundTrip(t *testing.T) {
applied := true
rejected := false
errCode := 7
errMsg := "rename failed: planet does not exist"
source := &model.UserGamesOrder{
GameID: uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
UpdatedAt: 99,
@@ -104,7 +105,7 @@ func TestUserGamesOrderResponsePayloadRoundTrip(t *testing.T) {
Name: "alpha",
},
&model.CommandPlanetRename{
CommandMeta: commandMeta("cmd-2", model.CommandTypePlanetRename, &rejected, &errCode),
CommandMeta: commandMetaWithMsg("cmd-2", model.CommandTypePlanetRename, &rejected, &errCode, &errMsg),
Number: 6,
Name: "beta",
},
@@ -254,10 +255,15 @@ func TestInt64ToInt(t *testing.T) {
}
func commandMeta(id string, cmdType model.CommandType, applied *bool, errCode *int) model.CommandMeta {
return commandMetaWithMsg(id, cmdType, applied, errCode, nil)
}
func commandMetaWithMsg(id string, cmdType model.CommandType, applied *bool, errCode *int, errMsg *string) model.CommandMeta {
return model.CommandMeta{
CmdType: cmdType,
CmdID: id,
CmdApplied: applied,
CmdErrCode: errCode,
CmdErrMsg: errMsg,
}
}