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
+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,
}
}