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
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user