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
@@ -153,6 +153,10 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
function statusOf(cmd: OrderCommand): CommandStatus {
return draft?.statuses[cmd.id] ?? "draft";
}
function errorMessageOf(cmd: OrderCommand): string | null {
return draft?.errorMessages[cmd.id] ?? null;
}
</script>
<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">
{#each draft.commands as cmd, index (cmd.id)}
{@const status = statusOf(cmd)}
{@const errorReason = errorMessageOf(cmd)}
<li
class="command status-{status}"
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}">
{describe(cmd)}
</span>
{#if status === "rejected" && errorReason !== null}
<span
class="error-reason"
data-testid="order-command-error-{index}"
>
{errorReason}
</span>
{/if}
<span
class="sr-only"
data-testid="order-command-status-{index}"
@@ -314,6 +327,16 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
min-width: 0;
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 {
margin: 0 0 0.5rem;
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;
}
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) {
builder.startObject(5);
builder.startObject(6);
}
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) {
builder.addFieldInt8(1, +cmdApplied, null);
builder.addFieldInt8(1, +cmdApplied, 0);
}
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) {
@@ -99,13 +106,17 @@ static addPayload(builder:flatbuffers.Builder, payloadOffset:flatbuffers.Offset)
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 {
const offset = builder.endObject();
builder.requiredField(offset, 12) // payload
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.addCmdId(builder, cmdIdOffset);
if (cmdApplied !== null)
@@ -114,6 +125,7 @@ static createCommandItem(builder:flatbuffers.Builder, cmdIdOffset:flatbuffers.Of
CommandItem.addCmdErrorCode(builder, cmdErrorCode);
CommandItem.addPayloadType(builder, payloadType);
CommandItem.addPayload(builder, payloadOffset);
CommandItem.addCmdErrorMessage(builder, cmdErrorMessageOffset);
return CommandItem.endCommandItem(builder);
}
@@ -127,7 +139,8 @@ unpack(): CommandItemT {
const temp = unionToCommandPayload(this.payloadType(), this.payload.bind(this));
if(temp === null) { return null; }
return temp.unpack()
})()
})(),
this.cmdErrorMessage()
);
}
@@ -142,6 +155,7 @@ unpackTo(_o: CommandItemT): void {
if(temp === null) { return null; }
return temp.unpack()
})();
_o.cmdErrorMessage = this.cmdErrorMessage();
}
}
@@ -151,20 +165,23 @@ constructor(
public cmdApplied: boolean|null = null,
public cmdErrorCode: bigint|null = null,
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 {
const cmdId = (this.cmdId !== null ? builder.createString(this.cmdId!) : 0);
const payload = builder.createObjectOffset(this.payload);
const cmdErrorMessage = (this.cmdErrorMessage !== null ? builder.createString(this.cmdErrorMessage!) : 0);
return CommandItem.createCommandItem(builder,
cmdId,
this.cmdApplied,
this.cmdErrorCode,
this.payloadType,
payload
payload,
cmdErrorMessage
);
}
}
+54 -17
View File
@@ -95,6 +95,15 @@ export interface PausedBanner {
export class OrderDraftStore {
commands: OrderCommand[] = $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);
status: Status = $state("idle");
error: string | null = $state(null);
@@ -261,16 +270,29 @@ export class OrderDraftStore {
this.commands = fetched.commands;
this.updatedAt = fetched.updatedAt;
this.recomputeStatuses();
// Server-fetched commands echo cmdApplied=true for entries
// that survived previous turns; keep them as `applied` so
// the overlay continues to project them on the inspector.
// The engine echoes per-command `cmdApplied`/`cmdErrorCode`
// /`cmdErrorMessage` on every stored order entry. Hydrate
// 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 nextErrors: Record<string, string> = {};
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";
}
const msg = fetched.errorMessages.get(cmd.id);
if (msg !== null && msg !== undefined && msg !== "") {
nextErrors[cmd.id] = msg;
}
}
this.statuses = next;
this.errorMessages = nextErrors;
await this.persist();
if ((this.syncStatus as SyncStatus) !== "paused") {
this.syncStatus = "synced";
@@ -384,11 +406,15 @@ export class OrderDraftStore {
}
this.commands = nextCommands;
const nextStatuses = { ...this.statuses };
const nextErrors = { ...this.errorMessages };
for (const id of removed) {
delete nextStatuses[id];
delete nextErrors[id];
}
nextStatuses[command.id] = validateCommand(command);
delete nextErrors[command.id];
this.statuses = nextStatuses;
this.errorMessages = nextErrors;
await this.persist();
this.scheduleSync();
}
@@ -408,8 +434,11 @@ export class OrderDraftStore {
this.clearConflictForMutation();
this.commands = next;
const nextStatuses = { ...this.statuses };
const nextErrors = { ...this.errorMessages };
delete nextStatuses[id];
delete nextErrors[id];
this.statuses = nextStatuses;
this.errorMessages = nextErrors;
await this.persist();
this.scheduleSync();
}
@@ -484,6 +513,7 @@ export class OrderDraftStore {
if (this.status !== "ready") return;
this.commands = [];
this.statuses = {};
this.errorMessages = {};
this.updatedAt = 0;
this.conflictBanner = null;
this.pausedBanner = null;
@@ -571,20 +601,18 @@ export class OrderDraftStore {
this.applyResultsInternal(
outcome.result.results,
outcome.result.updatedAt,
outcome.result.errorMessages,
);
// Even with `result.ok === true` an individual
// command may have been rejected by the engine
// (e.g. validation passed transcoders but failed
// the in-game rule). Surface that as an error in
// the sync bar so the player notices and can fix
// or remove the offending command.
const anyRejected = Array.from(
outcome.result.results.values(),
).some((s) => s === "rejected");
this.syncStatus = anyRejected ? "error" : "synced";
this.syncError = anyRejected
? "engine rejected one or more commands"
: null;
// A `success` outcome means the gateway → backend →
// engine round trip completed cleanly, even when
// individual commands were rejected by the in-game
// rules. Per-command rejection is a player-fixable
// state surfaced via the row's `rejected` highlight
// and the inline reason; the sync bar stays green so
// the banner + Retry button are reserved for genuine
// transport / engine failures the auto-retry can fix.
this.syncStatus = "synced";
this.syncError = null;
break;
}
case "rejected": {
@@ -669,9 +697,11 @@ export class OrderDraftStore {
private applyResultsInternal(
results: Map<string, CommandStatus>,
updatedAt: number,
errorMessages?: Map<string, string | null>,
): void {
const liveIds = new Set(this.commands.map((cmd) => cmd.id));
const next = { ...this.statuses };
const nextErrors = { ...this.errorMessages };
for (const [id, status] of results.entries()) {
// Drop verdicts for commands the user removed while 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.
if (!liveIds.has(id)) continue;
next[id] = status;
const msg = errorMessages?.get(id) ?? null;
if (msg !== null && msg !== "") {
nextErrors[id] = msg;
} else {
delete nextErrors[id];
}
}
this.statuses = next;
this.errorMessages = nextErrors;
this.updatedAt = updatedAt;
}
+32 -1
View File
@@ -63,6 +63,17 @@ export class OrderLoadError extends Error {
export interface FetchedOrder {
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;
}
@@ -119,7 +130,13 @@ function decodeResponse(payload: Uint8Array): FetchedOrder {
const buffer = new ByteBuffer(payload);
const response = UserGamesOrderGetResponse.getRootAsUserGamesOrderGetResponse(buffer);
if (!response.found()) {
return { commands: [], updatedAt: 0 };
return {
commands: [],
statuses: new Map(),
errorCodes: new Map(),
errorMessages: new Map(),
updatedAt: 0,
};
}
const order = response.order();
if (order === null) {
@@ -130,6 +147,9 @@ function decodeResponse(payload: Uint8Array): FetchedOrder {
);
}
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();
for (let i = 0; i < length; i++) {
const item = order.commands(i);
@@ -137,9 +157,20 @@ function decodeResponse(payload: Uint8Array): FetchedOrder {
const cmd = decodeCommand(item);
if (cmd === null) continue;
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 {
commands,
statuses,
errorCodes,
errorMessages,
updatedAt: Number(order.updatedAt()),
};
}
+10 -3
View File
@@ -82,6 +82,7 @@ export interface SubmitSuccess {
ok: true;
results: Map<string, CommandOutcome>;
errorCodes: Map<string, number | null>;
errorMessages: Map<string, string | null>;
updatedAt: number;
}
@@ -511,6 +512,7 @@ function decodeOrderResponse(
): SubmitSuccess {
const results = new Map<string, CommandOutcome>();
const errorCodes = new Map<string, number | null>();
const errorMessages = new Map<string, string | null>();
let updatedAt = 0;
if (payload.length === 0) {
@@ -518,8 +520,9 @@ function decodeOrderResponse(
for (const cmd of commands) {
results.set(cmd.id, "applied");
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);
@@ -531,8 +534,9 @@ function decodeOrderResponse(
for (const cmd of commands) {
results.set(cmd.id, "applied");
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++) {
@@ -542,8 +546,10 @@ function decodeOrderResponse(
if (cmdId === null) continue;
const applied = item.cmdApplied();
const errorCode = item.cmdErrorCode();
const errorMessage = item.cmdErrorMessage();
results.set(cmdId, applied === false ? "rejected" : "applied");
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
@@ -552,10 +558,11 @@ function decodeOrderResponse(
if (!results.has(cmd.id)) {
results.set(cmd.id, "applied");
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(