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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user