fix(game): #59 — per-command rejection on PUT /api/v1/order #71

Merged
developer merged 4 commits from feature/issue-59-invalid-order-per-command into development 2026-05-29 10:18:15 +00:00
17 changed files with 404 additions and 40 deletions
Showing only changes of commit 723885e74e - Show all commits
+2 -2
View File
@@ -109,11 +109,11 @@ func (c *Controller) applyCommand(actor string, cmd order.DecodableCommand) (err
} }
if ge, ok := errors.AsType[*e.GenericError](err); ok { if ge, ok := errors.AsType[*e.GenericError](err); ok {
m.Result(ge.Code) m.Result(ge.Code, ge.Error())
} else if err != nil { } else if err != nil {
panic(fmt.Errorf("error applying command has unknown origin: %w", err)) panic(fmt.Errorf("error applying command has unknown origin: %w", err))
} else { } else {
m.Result(0) m.Result(0, "")
} }
return return
+4 -4
View File
@@ -75,14 +75,14 @@ func TestSaveOrder(t *testing.T) {
for i := range o.Commands { for i := range o.Commands {
if v, ok := order.AsCommand[*order.CommandRaceVote](o.Commands[i]); ok { if v, ok := order.AsCommand[*order.CommandRaceVote](o.Commands[i]); ok {
m := &v.CommandMeta m := &v.CommandMeta
m.Result(0) m.Result(0, "")
} else if v, ok := order.AsCommand[*order.CommandRaceQuit](o.Commands[i]); ok { } else if v, ok := order.AsCommand[*order.CommandRaceQuit](o.Commands[i]); ok {
v.Result(10) v.Result(10, "race quit failed")
} else if v, ok := order.AsCommand[*order.CommandShipClassCreate](o.Commands[i]); ok { } else if v, ok := order.AsCommand[*order.CommandShipClassCreate](o.Commands[i]); ok {
m := &v.CommandMeta m := &v.CommandMeta
m.Result(33) m.Result(33, "ship class create failed")
} else if v, ok := order.AsCommand[*order.CommandShipGroupMerge](o.Commands[i]); ok { } else if v, ok := order.AsCommand[*order.CommandShipGroupMerge](o.Commands[i]); ok {
v.Result(0) v.Result(0, "")
} }
} }
+10
View File
@@ -520,6 +520,16 @@ components:
`0` when the command was applied, a non-zero `GenericError` `0` when the command was applied, a non-zero `GenericError`
code (shelves `2xxx`/`3xxx` in `pkg/error/generic.go`) when code (shelves `2xxx`/`3xxx` in `pkg/error/generic.go`) when
the command was rejected. Omitted on requests. the command was rejected. Omitted on requests.
cmdErrorMessage:
type: string
description: |
Per-command rejection reason, formatted by the engine's
`GenericError.Error()` (e.g.
`Entity does not exists: ship type "Drone"`). Set alongside
`cmdApplied=false`/`cmdErrorCode!=0`, omitted when the
command was applied. Provided so clients can surface the
specific reason without keeping their own code → text
catalog in sync with the engine.
CommandType: CommandType:
type: string type: string
description: Discriminator identifying the game command variant carried in a `cmd` element. description: Discriminator identifying the game command variant carried in a `cmd` element.
+11 -1
View File
@@ -124,6 +124,7 @@ type CommandMeta struct {
CmdID string `json:"cmdId" binding:"required,uuid_rfc4122"` CmdID string `json:"cmdId" binding:"required,uuid_rfc4122"`
CmdApplied *bool `json:"cmdApplied,omitempty"` CmdApplied *bool `json:"cmdApplied,omitempty"`
CmdErrCode *int `json:"cmdErrorCode,omitempty"` CmdErrCode *int `json:"cmdErrorCode,omitempty"`
CmdErrMsg *string `json:"cmdErrorMessage,omitempty"`
} }
func (cm CommandMeta) CommandType() CommandType { func (cm CommandMeta) CommandType() CommandType {
@@ -134,9 +135,18 @@ func (cm CommandMeta) CommandID() string {
return cm.CmdID 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.CmdErrCode = &errCode
cm.CmdApplied = new(bool(errCode == 0)) cm.CmdApplied = new(bool(errCode == 0))
if errCode == 0 {
cm.CmdErrMsg = nil
return
}
cm.CmdErrMsg = &errMsg
} }
type CommandRaceQuit struct { type CommandRaceQuit struct {
+6
View File
@@ -193,6 +193,12 @@ table CommandItem {
cmd_applied: bool = null; cmd_applied: bool = null;
cmd_error_code: int64 = null; cmd_error_code: int64 = null;
payload: CommandPayload (required); 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 // 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 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) { func CommandItemStart(builder *flatbuffers.Builder) {
builder.StartObject(5) builder.StartObject(6)
} }
func CommandItemAddCmdId(builder *flatbuffers.Builder, cmdId flatbuffers.UOffsetT) { func CommandItemAddCmdId(builder *flatbuffers.Builder, cmdId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(cmdId), 0) 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) { func CommandItemAddPayload(builder *flatbuffers.Builder, payload flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(payload), 0) 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 { func CommandItemEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+23
View File
@@ -125,6 +125,7 @@ type encodedCommand struct {
cmdID string cmdID string
cmdApplied *bool cmdApplied *bool
cmdErrCode *int cmdErrCode *int
cmdErrMsg *string
payloadType fbs.CommandPayload payloadType fbs.CommandPayload
payloadOffset flatbuffers.UOffsetT payloadOffset flatbuffers.UOffsetT
} }
@@ -404,6 +405,7 @@ func encodedCommandFromMeta(meta model.CommandMeta, payloadType fbs.CommandPaylo
cmdID: meta.CmdID, cmdID: meta.CmdID,
cmdApplied: cloneBoolPointer(meta.CmdApplied), cmdApplied: cloneBoolPointer(meta.CmdApplied),
cmdErrCode: cloneIntPointer(meta.CmdErrCode), cmdErrCode: cloneIntPointer(meta.CmdErrCode),
cmdErrMsg: cloneStringPointer(meta.CmdErrMsg),
payloadType: payloadType, payloadType: payloadType,
payloadOffset: payloadOffset, payloadOffset: payloadOffset,
} }
@@ -423,6 +425,11 @@ func decodeOrderCommand(flatCommand *fbs.CommandItem, index int) (model.Decodabl
commandMeta.CmdErrCode = &decodedCmdErrCode commandMeta.CmdErrCode = &decodedCmdErrCode
} }
if cmdErrMsg := flatCommand.CmdErrorMessage(); cmdErrMsg != nil {
decodedCmdErrMsg := string(cmdErrMsg)
commandMeta.CmdErrMsg = &decodedCmdErrMsg
}
payloadType := flatCommand.PayloadType() payloadType := flatCommand.PayloadType()
if payloadType == fbs.CommandPayloadNONE { if payloadType == fbs.CommandPayloadNONE {
return nil, fmt.Errorf("decode order command %d: payload type is NONE", index) return nil, fmt.Errorf("decode order command %d: payload type is NONE", index)
@@ -915,6 +922,15 @@ func cloneIntPointer(value *int) *int {
return &cloned return &cloned
} }
func cloneStringPointer(value *string) *string {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
// UserGamesCommandToPayload converts model.UserGamesCommand to // UserGamesCommandToPayload converts model.UserGamesCommand to
// FlatBuffers bytes suitable for the authenticated gateway transport. // FlatBuffers bytes suitable for the authenticated gateway transport.
// `GameID` is required. // `GameID` is required.
@@ -1293,6 +1309,10 @@ func encodeCommandItemVector(builder *flatbuffers.Builder, commands []model.Deco
return 0, fmt.Errorf("encode %s: %w", opLabel, err) return 0, fmt.Errorf("encode %s: %w", opLabel, err)
} }
cmdID := builder.CreateString(encoded.cmdID) cmdID := builder.CreateString(encoded.cmdID)
var cmdErrMsg flatbuffers.UOffsetT
if encoded.cmdErrMsg != nil {
cmdErrMsg = builder.CreateString(*encoded.cmdErrMsg)
}
fbs.CommandItemStart(builder) fbs.CommandItemStart(builder)
fbs.CommandItemAddCmdId(builder, cmdID) fbs.CommandItemAddCmdId(builder, cmdID)
if encoded.cmdApplied != nil { if encoded.cmdApplied != nil {
@@ -1303,6 +1323,9 @@ func encodeCommandItemVector(builder *flatbuffers.Builder, commands []model.Deco
} }
fbs.CommandItemAddPayloadType(builder, encoded.payloadType) fbs.CommandItemAddPayloadType(builder, encoded.payloadType)
fbs.CommandItemAddPayload(builder, encoded.payloadOffset) fbs.CommandItemAddPayload(builder, encoded.payloadOffset)
if encoded.cmdErrMsg != nil {
fbs.CommandItemAddCmdErrorMessage(builder, cmdErrMsg)
}
offsets[i] = fbs.CommandItemEnd(builder) offsets[i] = fbs.CommandItemEnd(builder)
} }
if len(offsets) == 0 { if len(offsets) == 0 {
+7 -1
View File
@@ -94,6 +94,7 @@ func TestUserGamesOrderResponsePayloadRoundTrip(t *testing.T) {
applied := true applied := true
rejected := false rejected := false
errCode := 7 errCode := 7
errMsg := "rename failed: planet does not exist"
source := &model.UserGamesOrder{ source := &model.UserGamesOrder{
GameID: uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), GameID: uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
UpdatedAt: 99, UpdatedAt: 99,
@@ -104,7 +105,7 @@ func TestUserGamesOrderResponsePayloadRoundTrip(t *testing.T) {
Name: "alpha", Name: "alpha",
}, },
&model.CommandPlanetRename{ &model.CommandPlanetRename{
CommandMeta: commandMeta("cmd-2", model.CommandTypePlanetRename, &rejected, &errCode), CommandMeta: commandMetaWithMsg("cmd-2", model.CommandTypePlanetRename, &rejected, &errCode, &errMsg),
Number: 6, Number: 6,
Name: "beta", Name: "beta",
}, },
@@ -254,10 +255,15 @@ func TestInt64ToInt(t *testing.T) {
} }
func commandMeta(id string, cmdType model.CommandType, applied *bool, errCode *int) model.CommandMeta { 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{ return model.CommandMeta{
CmdType: cmdType, CmdType: cmdType,
CmdID: id, CmdID: id,
CmdApplied: applied, CmdApplied: applied,
CmdErrCode: errCode, CmdErrCode: errCode,
CmdErrMsg: errMsg,
} }
} }
@@ -153,6 +153,10 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
function statusOf(cmd: OrderCommand): CommandStatus { function statusOf(cmd: OrderCommand): CommandStatus {
return draft?.statuses[cmd.id] ?? "draft"; return draft?.statuses[cmd.id] ?? "draft";
} }
function errorMessageOf(cmd: OrderCommand): string | null {
return draft?.errorMessages[cmd.id] ?? null;
}
</script> </script>
<section class="tool" data-testid="sidebar-tool-order"> <section class="tool" data-testid="sidebar-tool-order">
@@ -191,6 +195,7 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
<ol class="commands" data-testid="order-list"> <ol class="commands" data-testid="order-list">
{#each draft.commands as cmd, index (cmd.id)} {#each draft.commands as cmd, index (cmd.id)}
{@const status = statusOf(cmd)} {@const status = statusOf(cmd)}
{@const errorReason = errorMessageOf(cmd)}
<li <li
class="command status-{status}" class="command status-{status}"
data-testid="order-command-{index}" data-testid="order-command-{index}"
@@ -200,6 +205,14 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
<span class="label" data-testid="order-command-label-{index}"> <span class="label" data-testid="order-command-label-{index}">
{describe(cmd)} {describe(cmd)}
</span> </span>
{#if status === "rejected" && errorReason !== null}
<span
class="error-reason"
data-testid="order-command-error-{index}"
>
{errorReason}
</span>
{/if}
<span <span
class="sr-only" class="sr-only"
data-testid="order-command-status-{index}" data-testid="order-command-status-{index}"
@@ -314,6 +327,16 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
min-width: 0; min-width: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.error-reason {
grid-column: 2;
min-width: 0;
overflow-wrap: anywhere;
margin-top: 0.1rem;
color: var(--color-danger);
font-size: 0.75rem;
font-style: italic;
line-height: 1.25;
}
.banner { .banner {
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
@@ -75,8 +75,15 @@ payload<T extends flatbuffers.Table>(obj:any):any|null {
return offset ? this.bb!.__union(obj, this.bb_pos + offset) : null; return offset ? this.bb!.__union(obj, this.bb_pos + offset) : null;
} }
cmdErrorMessage():string|null
cmdErrorMessage(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
cmdErrorMessage(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startCommandItem(builder:flatbuffers.Builder) { static startCommandItem(builder:flatbuffers.Builder) {
builder.startObject(5); builder.startObject(6);
} }
static addCmdId(builder:flatbuffers.Builder, cmdIdOffset:flatbuffers.Offset) { static addCmdId(builder:flatbuffers.Builder, cmdIdOffset:flatbuffers.Offset) {
@@ -84,11 +91,11 @@ static addCmdId(builder:flatbuffers.Builder, cmdIdOffset:flatbuffers.Offset) {
} }
static addCmdApplied(builder:flatbuffers.Builder, cmdApplied:boolean) { static addCmdApplied(builder:flatbuffers.Builder, cmdApplied:boolean) {
builder.addFieldInt8(1, +cmdApplied, null); builder.addFieldInt8(1, +cmdApplied, 0);
} }
static addCmdErrorCode(builder:flatbuffers.Builder, cmdErrorCode:bigint) { static addCmdErrorCode(builder:flatbuffers.Builder, cmdErrorCode:bigint) {
builder.addFieldInt64(2, cmdErrorCode, null); builder.addFieldInt64(2, cmdErrorCode, BigInt(0));
} }
static addPayloadType(builder:flatbuffers.Builder, payloadType:CommandPayload) { static addPayloadType(builder:flatbuffers.Builder, payloadType:CommandPayload) {
@@ -99,13 +106,17 @@ static addPayload(builder:flatbuffers.Builder, payloadOffset:flatbuffers.Offset)
builder.addFieldOffset(4, payloadOffset, 0); builder.addFieldOffset(4, payloadOffset, 0);
} }
static addCmdErrorMessage(builder:flatbuffers.Builder, cmdErrorMessageOffset:flatbuffers.Offset) {
builder.addFieldOffset(5, cmdErrorMessageOffset, 0);
}
static endCommandItem(builder:flatbuffers.Builder):flatbuffers.Offset { static endCommandItem(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
builder.requiredField(offset, 12) // payload builder.requiredField(offset, 12) // payload
return offset; return offset;
} }
static createCommandItem(builder:flatbuffers.Builder, cmdIdOffset:flatbuffers.Offset, cmdApplied:boolean|null, cmdErrorCode:bigint|null, payloadType:CommandPayload, payloadOffset:flatbuffers.Offset):flatbuffers.Offset { static createCommandItem(builder:flatbuffers.Builder, cmdIdOffset:flatbuffers.Offset, cmdApplied:boolean|null, cmdErrorCode:bigint|null, payloadType:CommandPayload, payloadOffset:flatbuffers.Offset, cmdErrorMessageOffset:flatbuffers.Offset):flatbuffers.Offset {
CommandItem.startCommandItem(builder); CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset); CommandItem.addCmdId(builder, cmdIdOffset);
if (cmdApplied !== null) if (cmdApplied !== null)
@@ -114,6 +125,7 @@ static createCommandItem(builder:flatbuffers.Builder, cmdIdOffset:flatbuffers.Of
CommandItem.addCmdErrorCode(builder, cmdErrorCode); CommandItem.addCmdErrorCode(builder, cmdErrorCode);
CommandItem.addPayloadType(builder, payloadType); CommandItem.addPayloadType(builder, payloadType);
CommandItem.addPayload(builder, payloadOffset); CommandItem.addPayload(builder, payloadOffset);
CommandItem.addCmdErrorMessage(builder, cmdErrorMessageOffset);
return CommandItem.endCommandItem(builder); return CommandItem.endCommandItem(builder);
} }
@@ -127,7 +139,8 @@ unpack(): CommandItemT {
const temp = unionToCommandPayload(this.payloadType(), this.payload.bind(this)); const temp = unionToCommandPayload(this.payloadType(), this.payload.bind(this));
if(temp === null) { return null; } if(temp === null) { return null; }
return temp.unpack() return temp.unpack()
})() })(),
this.cmdErrorMessage()
); );
} }
@@ -142,6 +155,7 @@ unpackTo(_o: CommandItemT): void {
if(temp === null) { return null; } if(temp === null) { return null; }
return temp.unpack() return temp.unpack()
})(); })();
_o.cmdErrorMessage = this.cmdErrorMessage();
} }
} }
@@ -151,20 +165,23 @@ constructor(
public cmdApplied: boolean|null = null, public cmdApplied: boolean|null = null,
public cmdErrorCode: bigint|null = null, public cmdErrorCode: bigint|null = null,
public payloadType: CommandPayload = CommandPayload.NONE, public payloadType: CommandPayload = CommandPayload.NONE,
public payload: CommandFleetMergeT|CommandFleetSendT|CommandPlanetProduceT|CommandPlanetRenameT|CommandPlanetRouteRemoveT|CommandPlanetRouteSetT|CommandRaceQuitT|CommandRaceRelationT|CommandRaceVoteT|CommandScienceCreateT|CommandScienceRemoveT|CommandShipClassCreateT|CommandShipClassMergeT|CommandShipClassRemoveT|CommandShipGroupBreakT|CommandShipGroupDismantleT|CommandShipGroupJoinFleetT|CommandShipGroupLoadT|CommandShipGroupMergeT|CommandShipGroupSendT|CommandShipGroupTransferT|CommandShipGroupUnloadT|CommandShipGroupUpgradeT|null = null public payload: CommandFleetMergeT|CommandFleetSendT|CommandPlanetProduceT|CommandPlanetRenameT|CommandPlanetRouteRemoveT|CommandPlanetRouteSetT|CommandRaceQuitT|CommandRaceRelationT|CommandRaceVoteT|CommandScienceCreateT|CommandScienceRemoveT|CommandShipClassCreateT|CommandShipClassMergeT|CommandShipClassRemoveT|CommandShipGroupBreakT|CommandShipGroupDismantleT|CommandShipGroupJoinFleetT|CommandShipGroupLoadT|CommandShipGroupMergeT|CommandShipGroupSendT|CommandShipGroupTransferT|CommandShipGroupUnloadT|CommandShipGroupUpgradeT|null = null,
public cmdErrorMessage: string|Uint8Array|null = null
){} ){}
pack(builder:flatbuffers.Builder): flatbuffers.Offset { pack(builder:flatbuffers.Builder): flatbuffers.Offset {
const cmdId = (this.cmdId !== null ? builder.createString(this.cmdId!) : 0); const cmdId = (this.cmdId !== null ? builder.createString(this.cmdId!) : 0);
const payload = builder.createObjectOffset(this.payload); const payload = builder.createObjectOffset(this.payload);
const cmdErrorMessage = (this.cmdErrorMessage !== null ? builder.createString(this.cmdErrorMessage!) : 0);
return CommandItem.createCommandItem(builder, return CommandItem.createCommandItem(builder,
cmdId, cmdId,
this.cmdApplied, this.cmdApplied,
this.cmdErrorCode, this.cmdErrorCode,
this.payloadType, this.payloadType,
payload payload,
cmdErrorMessage
); );
} }
} }
+54 -17
View File
@@ -95,6 +95,15 @@ export interface PausedBanner {
export class OrderDraftStore { export class OrderDraftStore {
commands: OrderCommand[] = $state([]); commands: OrderCommand[] = $state([]);
statuses: Record<string, CommandStatus> = $state({}); statuses: Record<string, CommandStatus> = $state({});
/**
* errorMessages carries the engine-formatted rejection reason for
* each rejected command, keyed by `cmdId`. Populated from
* `submitOrder` responses and from the server-stored order on
* `hydrateFromServer`. Cleared when a command is re-mutated or
* removed. The order tab renders the message inline on rejected
* rows so the player can act without inspecting the network log.
*/
errorMessages: Record<string, string> = $state({});
updatedAt = $state(0); updatedAt = $state(0);
status: Status = $state("idle"); status: Status = $state("idle");
error: string | null = $state(null); error: string | null = $state(null);
@@ -261,16 +270,29 @@ export class OrderDraftStore {
this.commands = fetched.commands; this.commands = fetched.commands;
this.updatedAt = fetched.updatedAt; this.updatedAt = fetched.updatedAt;
this.recomputeStatuses(); this.recomputeStatuses();
// Server-fetched commands echo cmdApplied=true for entries // The engine echoes per-command `cmdApplied`/`cmdErrorCode`
// that survived previous turns; keep them as `applied` so // /`cmdErrorMessage` on every stored order entry. Hydrate
// the overlay continues to project them on the inspector. // the in-memory status + error maps from the fetched
// snapshot so a returning player sees the same per-command
// verdict (and rejection reason) the previous submission
// produced, instead of a synthetic blanket `applied` derived
// from the local cache.
const next = { ...this.statuses }; const next = { ...this.statuses };
const nextErrors: Record<string, string> = {};
for (const cmd of this.commands) { for (const cmd of this.commands) {
if (next[cmd.id] === "valid") { const status = fetched.statuses.get(cmd.id);
if (status !== undefined && next[cmd.id] !== "invalid") {
next[cmd.id] = status;
} else if (next[cmd.id] === "valid") {
next[cmd.id] = "applied"; next[cmd.id] = "applied";
} }
const msg = fetched.errorMessages.get(cmd.id);
if (msg !== null && msg !== undefined && msg !== "") {
nextErrors[cmd.id] = msg;
}
} }
this.statuses = next; this.statuses = next;
this.errorMessages = nextErrors;
await this.persist(); await this.persist();
if ((this.syncStatus as SyncStatus) !== "paused") { if ((this.syncStatus as SyncStatus) !== "paused") {
this.syncStatus = "synced"; this.syncStatus = "synced";
@@ -384,11 +406,15 @@ export class OrderDraftStore {
} }
this.commands = nextCommands; this.commands = nextCommands;
const nextStatuses = { ...this.statuses }; const nextStatuses = { ...this.statuses };
const nextErrors = { ...this.errorMessages };
for (const id of removed) { for (const id of removed) {
delete nextStatuses[id]; delete nextStatuses[id];
delete nextErrors[id];
} }
nextStatuses[command.id] = validateCommand(command); nextStatuses[command.id] = validateCommand(command);
delete nextErrors[command.id];
this.statuses = nextStatuses; this.statuses = nextStatuses;
this.errorMessages = nextErrors;
await this.persist(); await this.persist();
this.scheduleSync(); this.scheduleSync();
} }
@@ -408,8 +434,11 @@ export class OrderDraftStore {
this.clearConflictForMutation(); this.clearConflictForMutation();
this.commands = next; this.commands = next;
const nextStatuses = { ...this.statuses }; const nextStatuses = { ...this.statuses };
const nextErrors = { ...this.errorMessages };
delete nextStatuses[id]; delete nextStatuses[id];
delete nextErrors[id];
this.statuses = nextStatuses; this.statuses = nextStatuses;
this.errorMessages = nextErrors;
await this.persist(); await this.persist();
this.scheduleSync(); this.scheduleSync();
} }
@@ -484,6 +513,7 @@ export class OrderDraftStore {
if (this.status !== "ready") return; if (this.status !== "ready") return;
this.commands = []; this.commands = [];
this.statuses = {}; this.statuses = {};
this.errorMessages = {};
this.updatedAt = 0; this.updatedAt = 0;
this.conflictBanner = null; this.conflictBanner = null;
this.pausedBanner = null; this.pausedBanner = null;
@@ -571,20 +601,18 @@ export class OrderDraftStore {
this.applyResultsInternal( this.applyResultsInternal(
outcome.result.results, outcome.result.results,
outcome.result.updatedAt, outcome.result.updatedAt,
outcome.result.errorMessages,
); );
// Even with `result.ok === true` an individual // A `success` outcome means the gateway → backend →
// command may have been rejected by the engine // engine round trip completed cleanly, even when
// (e.g. validation passed transcoders but failed // individual commands were rejected by the in-game
// the in-game rule). Surface that as an error in // rules. Per-command rejection is a player-fixable
// the sync bar so the player notices and can fix // state surfaced via the row's `rejected` highlight
// or remove the offending command. // and the inline reason; the sync bar stays green so
const anyRejected = Array.from( // the banner + Retry button are reserved for genuine
outcome.result.results.values(), // transport / engine failures the auto-retry can fix.
).some((s) => s === "rejected"); this.syncStatus = "synced";
this.syncStatus = anyRejected ? "error" : "synced"; this.syncError = null;
this.syncError = anyRejected
? "engine rejected one or more commands"
: null;
break; break;
} }
case "rejected": { case "rejected": {
@@ -669,9 +697,11 @@ export class OrderDraftStore {
private applyResultsInternal( private applyResultsInternal(
results: Map<string, CommandStatus>, results: Map<string, CommandStatus>,
updatedAt: number, updatedAt: number,
errorMessages?: Map<string, string | null>,
): void { ): void {
const liveIds = new Set(this.commands.map((cmd) => cmd.id)); const liveIds = new Set(this.commands.map((cmd) => cmd.id));
const next = { ...this.statuses }; const next = { ...this.statuses };
const nextErrors = { ...this.errorMessages };
for (const [id, status] of results.entries()) { for (const [id, status] of results.entries()) {
// Drop verdicts for commands the user removed while the // Drop verdicts for commands the user removed while the
// request was in flight — they are no longer in the // request was in flight — they are no longer in the
@@ -679,8 +709,15 @@ export class OrderDraftStore {
// confuse the order tab and the overlay. // confuse the order tab and the overlay.
if (!liveIds.has(id)) continue; if (!liveIds.has(id)) continue;
next[id] = status; next[id] = status;
const msg = errorMessages?.get(id) ?? null;
if (msg !== null && msg !== "") {
nextErrors[id] = msg;
} else {
delete nextErrors[id];
}
} }
this.statuses = next; this.statuses = next;
this.errorMessages = nextErrors;
this.updatedAt = updatedAt; this.updatedAt = updatedAt;
} }
+32 -1
View File
@@ -63,6 +63,17 @@ export class OrderLoadError extends Error {
export interface FetchedOrder { export interface FetchedOrder {
commands: OrderCommand[]; commands: OrderCommand[];
// Per-command status keyed by cmdId. Populated from the engine's
// stored order so a returning player sees the same per-command
// verdict (applied / rejected) the previous submission produced —
// not a synthetic "applied" derived from the local cache.
statuses: Map<string, "applied" | "rejected">;
// Per-command engine-formatted error code/message, keyed by cmdId.
// Both maps carry an entry for every loaded command; the value is
// null when the command was applied (no error). The message lets
// the UI surface the rejection reason without a code → text catalog.
errorCodes: Map<string, number | null>;
errorMessages: Map<string, string | null>;
updatedAt: number; updatedAt: number;
} }
@@ -119,7 +130,13 @@ function decodeResponse(payload: Uint8Array): FetchedOrder {
const buffer = new ByteBuffer(payload); const buffer = new ByteBuffer(payload);
const response = UserGamesOrderGetResponse.getRootAsUserGamesOrderGetResponse(buffer); const response = UserGamesOrderGetResponse.getRootAsUserGamesOrderGetResponse(buffer);
if (!response.found()) { if (!response.found()) {
return { commands: [], updatedAt: 0 }; return {
commands: [],
statuses: new Map(),
errorCodes: new Map(),
errorMessages: new Map(),
updatedAt: 0,
};
} }
const order = response.order(); const order = response.order();
if (order === null) { if (order === null) {
@@ -130,6 +147,9 @@ function decodeResponse(payload: Uint8Array): FetchedOrder {
); );
} }
const commands: OrderCommand[] = []; const commands: OrderCommand[] = [];
const statuses = new Map<string, "applied" | "rejected">();
const errorCodes = new Map<string, number | null>();
const errorMessages = new Map<string, string | null>();
const length = order.commandsLength(); const length = order.commandsLength();
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
const item = order.commands(i); const item = order.commands(i);
@@ -137,9 +157,20 @@ function decodeResponse(payload: Uint8Array): FetchedOrder {
const cmd = decodeCommand(item); const cmd = decodeCommand(item);
if (cmd === null) continue; if (cmd === null) continue;
commands.push(cmd); commands.push(cmd);
// The engine echoes `cmd_applied = false` only when the order
// was rejected per-command; missing / true both mean applied.
const applied = item.cmdApplied();
statuses.set(cmd.id, applied === false ? "rejected" : "applied");
const code = item.cmdErrorCode();
errorCodes.set(cmd.id, code === null ? null : Number(code));
const msg = item.cmdErrorMessage();
errorMessages.set(cmd.id, msg === null ? null : msg);
} }
return { return {
commands, commands,
statuses,
errorCodes,
errorMessages,
updatedAt: Number(order.updatedAt()), updatedAt: Number(order.updatedAt()),
}; };
} }
+10 -3
View File
@@ -82,6 +82,7 @@ export interface SubmitSuccess {
ok: true; ok: true;
results: Map<string, CommandOutcome>; results: Map<string, CommandOutcome>;
errorCodes: Map<string, number | null>; errorCodes: Map<string, number | null>;
errorMessages: Map<string, string | null>;
updatedAt: number; updatedAt: number;
} }
@@ -511,6 +512,7 @@ function decodeOrderResponse(
): SubmitSuccess { ): SubmitSuccess {
const results = new Map<string, CommandOutcome>(); const results = new Map<string, CommandOutcome>();
const errorCodes = new Map<string, number | null>(); const errorCodes = new Map<string, number | null>();
const errorMessages = new Map<string, string | null>();
let updatedAt = 0; let updatedAt = 0;
if (payload.length === 0) { if (payload.length === 0) {
@@ -518,8 +520,9 @@ function decodeOrderResponse(
for (const cmd of commands) { for (const cmd of commands) {
results.set(cmd.id, "applied"); results.set(cmd.id, "applied");
errorCodes.set(cmd.id, null); errorCodes.set(cmd.id, null);
errorMessages.set(cmd.id, null);
} }
return { ok: true, results, errorCodes, updatedAt }; return { ok: true, results, errorCodes, errorMessages, updatedAt };
} }
const buffer = new ByteBuffer(payload); const buffer = new ByteBuffer(payload);
@@ -531,8 +534,9 @@ function decodeOrderResponse(
for (const cmd of commands) { for (const cmd of commands) {
results.set(cmd.id, "applied"); results.set(cmd.id, "applied");
errorCodes.set(cmd.id, null); errorCodes.set(cmd.id, null);
errorMessages.set(cmd.id, null);
} }
return { ok: true, results, errorCodes, updatedAt }; return { ok: true, results, errorCodes, errorMessages, updatedAt };
} }
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
@@ -542,8 +546,10 @@ function decodeOrderResponse(
if (cmdId === null) continue; if (cmdId === null) continue;
const applied = item.cmdApplied(); const applied = item.cmdApplied();
const errorCode = item.cmdErrorCode(); const errorCode = item.cmdErrorCode();
const errorMessage = item.cmdErrorMessage();
results.set(cmdId, applied === false ? "rejected" : "applied"); results.set(cmdId, applied === false ? "rejected" : "applied");
errorCodes.set(cmdId, errorCode === null ? null : Number(errorCode)); errorCodes.set(cmdId, errorCode === null ? null : Number(errorCode));
errorMessages.set(cmdId, errorMessage === null ? null : errorMessage);
} }
// Defensive: any submitted command not echoed back falls back to // Defensive: any submitted command not echoed back falls back to
@@ -552,10 +558,11 @@ function decodeOrderResponse(
if (!results.has(cmd.id)) { if (!results.has(cmd.id)) {
results.set(cmd.id, "applied"); results.set(cmd.id, "applied");
errorCodes.set(cmd.id, null); errorCodes.set(cmd.id, null);
errorMessages.set(cmd.id, null);
} }
} }
return { ok: true, results, errorCodes, updatedAt }; return { ok: true, results, errorCodes, errorMessages, updatedAt };
} }
function decodeError( function decodeError(
+40 -1
View File
@@ -174,11 +174,25 @@ export function recordingClient(
* decode a realistic payload without standing up a full mock * decode a realistic payload without standing up a full mock
* gateway. * gateway.
*/ */
/**
* FakeFetchStatus carries the per-command result fields the engine
* would echo on `user.games.order.get` (cmdApplied / cmdErrorCode /
* cmdErrorMessage). The helper omits each set only when the
* corresponding pointer is `undefined`, mimicking the engine's
* `omitempty` semantics.
*/
export interface FakeFetchStatus {
applied?: boolean;
errorCode?: number;
errorMessage?: string;
}
export function fakeFetchClient( export function fakeFetchClient(
gameId: string, gameId: string,
commands: OrderCommand[], commands: OrderCommand[],
updatedAt: number, updatedAt: number,
found = true, found = true,
statuses?: Record<string, FakeFetchStatus>,
): { client: GalaxyClient } { ): { client: GalaxyClient } {
const client: GalaxyClient = { const client: GalaxyClient = {
async executeCommand(messageType: string) { async executeCommand(messageType: string) {
@@ -187,7 +201,7 @@ export function fakeFetchClient(
} }
return { return {
resultCode: "ok", resultCode: "ok",
payloadBytes: encodeOrderGet(gameId, commands, updatedAt, found), payloadBytes: encodeOrderGet(gameId, commands, updatedAt, found, statuses ?? {}),
}; };
}, },
} as unknown as GalaxyClient; } as unknown as GalaxyClient;
@@ -200,6 +214,10 @@ function encodeApplied(
applied: boolean, applied: boolean,
): Uint8Array { ): Uint8Array {
const builder = new Builder(256); const builder = new Builder(256);
// See `encodeOrderGet` — flatbuffers@25 elides `cmd_applied=false`
// against its int8 default; mirror the Go transcoder's force-slot
// behaviour so the boolean survives the round trip in tests.
builder.forceDefaults(true);
const itemOffsets = cmdIds.map((id) => { const itemOffsets = cmdIds.map((id) => {
const cmdIdOffset = builder.createString(id); const cmdIdOffset = builder.createString(id);
const nameOffset = builder.createString("ignored"); const nameOffset = builder.createString("ignored");
@@ -235,8 +253,15 @@ function encodeOrderGet(
commands: OrderCommand[], commands: OrderCommand[],
updatedAt: number, updatedAt: number,
found: boolean, found: boolean,
statuses: Record<string, FakeFetchStatus>,
): Uint8Array { ): Uint8Array {
const builder = new Builder(256); const builder = new Builder(256);
// flatbuffers@25 elides fields equal to their generated default; the
// Go transcoder works around this via explicit `Slot()` calls.
// Mirror that behaviour here so `cmd_applied=false` and
// `cmd_error_code=0` round-trip through the helper instead of being
// silently dropped.
builder.forceDefaults(true);
let orderOffset = 0; let orderOffset = 0;
if (found) { if (found) {
@@ -246,6 +271,11 @@ function encodeOrderGet(
} }
const cmdIdOffset = builder.createString(cmd.id); const cmdIdOffset = builder.createString(cmd.id);
const nameOffset = builder.createString(cmd.name); const nameOffset = builder.createString(cmd.name);
const status = statuses[cmd.id] ?? {};
const errMsgOffset =
status.errorMessage !== undefined
? builder.createString(status.errorMessage)
: 0;
const inner = CommandPlanetRename.createCommandPlanetRename( const inner = CommandPlanetRename.createCommandPlanetRename(
builder, builder,
BigInt(cmd.planetNumber), BigInt(cmd.planetNumber),
@@ -253,8 +283,17 @@ function encodeOrderGet(
); );
CommandItem.startCommandItem(builder); CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset); CommandItem.addCmdId(builder, cmdIdOffset);
if (status.applied !== undefined) {
CommandItem.addCmdApplied(builder, status.applied);
}
if (status.errorCode !== undefined) {
CommandItem.addCmdErrorCode(builder, BigInt(status.errorCode));
}
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename); CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
CommandItem.addPayload(builder, inner); CommandItem.addPayload(builder, inner);
if (errMsgOffset !== 0) {
CommandItem.addCmdErrorMessage(builder, errMsgOffset);
}
return CommandItem.endCommandItem(builder); return CommandItem.endCommandItem(builder);
}); });
const commandsVec = UserGamesOrder.createCommandsVector(builder, itemOffsets); const commandsVec = UserGamesOrder.createCommandsVector(builder, itemOffsets);
+126 -1
View File
@@ -10,12 +10,13 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import "fake-indexeddb/auto"; import "fake-indexeddb/auto";
import { waitFor } from "@testing-library/svelte"; import { waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { IDBPDatabase } from "idb"; import type { IDBPDatabase } from "idb";
import { IDBCache } from "../src/platform/store/idb-cache"; import { IDBCache } from "../src/platform/store/idb-cache";
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb"; import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
import type { Cache } from "../src/platform/store/index"; import type { Cache } from "../src/platform/store/index";
import type { GalaxyClient } from "../src/api/galaxy-client";
import { OrderDraftStore } from "../src/sync/order-draft.svelte"; import { OrderDraftStore } from "../src/sync/order-draft.svelte";
import type { OrderCommand } from "../src/sync/order-types"; import type { OrderCommand } from "../src/sync/order-types";
@@ -448,6 +449,51 @@ describe("OrderDraftStore", () => {
store.dispose(); store.dispose();
}); });
test("hydrateFromServer preserves per-command rejected status and error message", async () => {
const { fakeFetchClient } = await import("./helpers/fake-order-client");
const { client } = fakeFetchClient(
GAME_ID,
[
{
kind: "planetRename",
id: "ok-1",
planetNumber: 7,
name: "OkTown",
},
{
kind: "planetRename",
id: "bad-1",
planetNumber: 8,
name: "Doomed",
},
],
42,
true,
{
"ok-1": { applied: true, errorCode: 0 },
"bad-1": {
applied: false,
errorCode: 3003,
errorMessage: 'Entity does not exists: planet #99',
},
},
);
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
await store.hydrateFromServer({ client, turn: 5 });
expect(store.commands).toHaveLength(2);
expect(store.statuses["ok-1"]).toBe("applied");
expect(store.statuses["bad-1"]).toBe("rejected");
expect(store.errorMessages["ok-1"]).toBeUndefined();
expect(store.errorMessages["bad-1"]).toBe(
"Entity does not exists: planet #99",
);
expect(store.syncStatus).toBe("synced");
store.dispose();
});
test("hydrate empties the local cache when server returns found=false", async () => { test("hydrate empties the local cache when server returns found=false", async () => {
// First seed a local draft. // First seed a local draft.
const seeded = new OrderDraftStore(); const seeded = new OrderDraftStore();
@@ -555,6 +601,85 @@ describe("OrderDraftStore auto-sync", () => {
store.dispose(); store.dispose();
}); });
test("per-command rejection in a successful response keeps syncStatus 'synced'", async () => {
// Per-command rejection means the round trip succeeded — only
// individual commands were rejected by the in-game rules — so
// the sync bar must stay green. A blanket `syncStatus = "error"`
// + Retry button only fits genuine transport / engine failures.
const { Builder } = await import("flatbuffers");
const fbs = await import("../src/proto/galaxy/fbs/order");
const common = await import("../src/proto/galaxy/fbs/common");
const { uuidToHiLo } = await import("../src/api/game-state");
const cmd = {
kind: "planetRename" as const,
id: "rej-1",
planetNumber: 9,
name: "Doomed",
};
const exec = vi.fn(async () => {
const builder = new Builder(256);
builder.forceDefaults(true);
const cmdIdOffset = builder.createString(cmd.id);
const nameOffset = builder.createString("ignored");
const errMsg = builder.createString(
"Entity does not exists: planet #99",
);
const inner = fbs.CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(0),
nameOffset,
);
fbs.CommandItem.startCommandItem(builder);
fbs.CommandItem.addCmdId(builder, cmdIdOffset);
fbs.CommandItem.addCmdApplied(builder, false);
fbs.CommandItem.addCmdErrorCode(builder, BigInt(3003));
fbs.CommandItem.addPayloadType(
builder,
fbs.CommandPayload.CommandPlanetRename,
);
fbs.CommandItem.addPayload(builder, inner);
fbs.CommandItem.addCmdErrorMessage(builder, errMsg);
const item = fbs.CommandItem.endCommandItem(builder);
const commandsVec = fbs.UserGamesOrderResponse.createCommandsVector(
builder,
[item],
);
const [hi, lo] = uuidToHiLo(GAME_ID);
const gameIdOffset = common.UUID.createUUID(builder, hi, lo);
fbs.UserGamesOrderResponse.startUserGamesOrderResponse(builder);
fbs.UserGamesOrderResponse.addGameId(builder, gameIdOffset);
fbs.UserGamesOrderResponse.addUpdatedAt(builder, BigInt(99));
fbs.UserGamesOrderResponse.addCommands(builder, commandsVec);
const offset = fbs.UserGamesOrderResponse.endUserGamesOrderResponse(
builder,
);
builder.finish(offset);
return {
resultCode: "ok",
payloadBytes: builder.asUint8Array(),
};
});
const client = { executeCommand: exec } as unknown as GalaxyClient;
const store = new OrderDraftStore();
await store.init({ cache, gameId: GAME_ID });
store.bindClient(client);
await store.add(cmd);
await waitFor(() => {
expect(exec).toHaveBeenCalled();
expect(store.statuses[cmd.id]).toBe("rejected");
});
expect(store.syncStatus).toBe("synced");
expect(store.syncError).toBeNull();
expect(store.errorMessages[cmd.id]).toBe(
"Entity does not exists: planet #99",
);
store.dispose();
});
test("non-ok response marks every in-flight command as rejected", async () => { test("non-ok response marks every in-flight command as rejected", async () => {
const { recordingClient } = await import("./helpers/fake-order-client"); const { recordingClient } = await import("./helpers/fake-order-client");
const handle = recordingClient(GAME_ID, "rejected"); const handle = recordingClient(GAME_ID, "rejected");
+1
View File
@@ -69,6 +69,7 @@ function success(updatedAt = 1): SubmitSuccess {
ok: true, ok: true,
results: new Map([["id", "applied"]]), results: new Map([["id", "applied"]]),
errorCodes: new Map([["id", null]]), errorCodes: new Map([["id", null]]),
errorMessages: new Map([["id", null]]),
updatedAt, updatedAt,
}; };
} }
+19 -1
View File
@@ -40,13 +40,28 @@ function mockClient(
} }
function buildResponse( function buildResponse(
commands: { id: string; applied: boolean | null; errorCode: number | null }[], commands: {
id: string;
applied: boolean | null;
errorCode: number | null;
errorMessage?: string | null;
}[],
updatedAt: number, updatedAt: number,
): Uint8Array { ): Uint8Array {
const builder = new Builder(256); const builder = new Builder(256);
// flatbuffers@25 skips fields equal to their generated default when
// writing — the Go transcoder works around this via explicit
// `Slot()` calls. This fixture stands in for the engine + gateway,
// so we flip `forceDefaults` to keep `cmd_applied=false` (=== int8
// default 0) and `cmd_error_code=0` from being silently elided.
builder.forceDefaults(true);
const itemOffsets = commands.map((c) => { const itemOffsets = commands.map((c) => {
const cmdIdOffset = builder.createString(c.id); const cmdIdOffset = builder.createString(c.id);
const nameOffset = builder.createString("ignored"); const nameOffset = builder.createString("ignored");
const errMsgOffset =
c.errorMessage !== undefined && c.errorMessage !== null
? builder.createString(c.errorMessage)
: 0;
const payloadOffset = CommandPlanetRename.createCommandPlanetRename( const payloadOffset = CommandPlanetRename.createCommandPlanetRename(
builder, builder,
BigInt(0), BigInt(0),
@@ -60,6 +75,9 @@ function buildResponse(
} }
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename); CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
CommandItem.addPayload(builder, payloadOffset); CommandItem.addPayload(builder, payloadOffset);
if (errMsgOffset !== 0) {
CommandItem.addCmdErrorMessage(builder, errMsgOffset);
}
return CommandItem.endCommandItem(builder); return CommandItem.endCommandItem(builder);
}); });
const commandsVec = UserGamesOrderResponse.createCommandsVector(builder, itemOffsets); const commandsVec = UserGamesOrderResponse.createCommandsVector(builder, itemOffsets);