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
3 changed files with 40 additions and 4 deletions
Showing only changes of commit 2ffd7527a6 - Show all commits
@@ -34,6 +34,10 @@ interface CommandResultFixtureBase {
cmdId: string; cmdId: string;
applied: boolean | null; applied: boolean | null;
errorCode: number | null; errorCode: number | null;
// Optional engine-formatted rejection reason; mirrors the
// `cmd_error_message` field added in the same patch as
// `cmdErrorCode`'s shelf renumber. Omit on applied commands.
errorMessage?: string | null;
} }
export interface PlanetRenameResultFixture extends CommandResultFixtureBase { export interface PlanetRenameResultFixture extends CommandResultFixtureBase {
@@ -132,6 +136,14 @@ export function buildOrderResponsePayload(
updatedAt: number, updatedAt: number,
): Uint8Array { ): Uint8Array {
const builder = new Builder(256); const builder = new Builder(256);
// flatbuffers@25 elides any field equal to its generated default;
// for `cmd_applied: bool = null` the default is int8 0, so the
// false case (per-command rejection) would silently disappear from
// the encoded payload. The production Go transcoder uses explicit
// `Slot()` calls that force the write, and the unit-test helpers
// in `tests/helpers/fake-order-client.ts` flip `forceDefaults`;
// these e2e fixtures need the same toggle.
builder.forceDefaults(true);
const itemOffsets = commands.map((c) => encodeItem(builder, c)); const itemOffsets = commands.map((c) => encodeItem(builder, c));
const commandsVec = UserGamesOrderResponse.createCommandsVector( const commandsVec = UserGamesOrderResponse.createCommandsVector(
builder, builder,
@@ -155,6 +167,11 @@ export function buildOrderGetResponsePayload(
found = true, found = true,
): Uint8Array { ): Uint8Array {
const builder = new Builder(256); const builder = new Builder(256);
// See `buildOrderResponsePayload` — the GET response path needs
// the same `forceDefaults` toggle so a stored per-command rejection
// (cmd_applied=false / cmd_error_code=0) survives the round trip
// to the UI's `hydrateFromServer`.
builder.forceDefaults(true);
let orderOffset = 0; let orderOffset = 0;
if (found) { if (found) {
@@ -290,6 +307,10 @@ function encodeItem(builder: Builder, c: CommandResultFixture): number {
break; break;
} }
} }
const errMsgOffset =
c.errorMessage !== undefined && c.errorMessage !== null
? builder.createString(c.errorMessage)
: 0;
CommandItem.startCommandItem(builder); CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset); CommandItem.addCmdId(builder, cmdIdOffset);
if (c.applied !== null) CommandItem.addCmdApplied(builder, c.applied); if (c.applied !== null) CommandItem.addCmdApplied(builder, c.applied);
@@ -298,6 +319,9 @@ function encodeItem(builder: Builder, c: CommandResultFixture): number {
} }
CommandItem.addPayloadType(builder, payloadType); CommandItem.addPayloadType(builder, payloadType);
CommandItem.addPayload(builder, inner); CommandItem.addPayload(builder, inner);
if (errMsgOffset !== 0) {
CommandItem.addCmdErrorMessage(builder, errMsgOffset);
}
return CommandItem.endCommandItem(builder); return CommandItem.endCommandItem(builder);
} }
+11 -3
View File
@@ -137,6 +137,9 @@ async function mockGateway(page: Page, opts: MockOpts): Promise<MockHandle> {
name: submittedName, name: submittedName,
applied, applied,
errorCode: applied ? null : 1, errorCode: applied ? null : 1,
errorMessage: applied
? null
: "Entity does not exists: planet #99",
}); });
} }
if (opts.submitOutcome === "applied") { if (opts.submitOutcome === "applied") {
@@ -320,14 +323,19 @@ test("rejected submit keeps the old name and surfaces the failure", async ({
const orderTool = page.getByTestId("sidebar-tool-order"); const orderTool = page.getByTestId("sidebar-tool-order");
// The auto-sync pipeline reaches the server immediately after // The auto-sync pipeline reaches the server immediately after
// the inline confirm; the rejected verdict surfaces through the // the inline confirm. Per-command rejection is a player-correctable
// per-row status badge and the sync bar. // state: the round trip succeeded, the engine just refused this
// command, so the sync bar stays `synced` while the rejected row
// surfaces the engine-formatted reason for the player.
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText( await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
"rejected", "rejected",
); );
await expect(orderTool.getByTestId("order-command-error-0")).toHaveText(
"Entity does not exists: planet #99",
);
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute( await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status", "data-sync-status",
"error", "synced",
); );
await page.getByTestId("sidebar-tab-inspector").click(); await page.getByTestId("sidebar-tab-inspector").click();
+5 -1
View File
@@ -167,6 +167,9 @@ async function mockGateway(page: Page, opts: MockOpts): Promise<MockHandle> {
cargo: lastCreate.cargo, cargo: lastCreate.cargo,
applied, applied,
errorCode: applied ? null : 1, errorCode: applied ? null : 1,
errorMessage: applied
? null
: "Entity already exists: ship type \"Drone\"",
}); });
continue; continue;
} }
@@ -392,9 +395,10 @@ test("rejected createShipClass keeps the table empty and surfaces the failure",
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText( await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
"rejected", "rejected",
); );
await expect(orderTool.getByTestId("order-command-error-0")).toBeVisible();
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute( await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
"data-sync-status", "data-sync-status",
"error", "synced",
); );
// Switch sidebar back to inspector so the active-view (table) // Switch sidebar back to inspector so the active-view (table)