ui/phase-14: auto-sync order draft + always GET on boot + header headline

Replaces the manual Submit button with an auto-sync pipeline driven
by `OrderDraftStore`: every successful add / remove / move
coalesces a `submitOrder` call so the engine always mirrors the
local draft. Removing the last command sends an empty cmd[] PUT —
the engine, repo, and rest model now accept that as a valid
"player cleared their draft" state.

`hydrateFromServer` is now invoked unconditionally on game boot so
a fresh device picks up the player's stored order, and the local
cache is overwritten by the server's view (server is the source of
truth).

Header replaces the static "race ?" + turn counter with a single
headline string `<race> @ <game>, turn <n>`, sourced from the
engine's Report.race + the lobby's GameSummary.gameName + the live
turn number, with a `?` fallback while any piece is loading.

Tests:
- engine: empty PUT round-trips, repo round-trips empty Commands
- order-draft: auto-sync sends full draft on every mutation,
  rejected response surfaces error sync status, rapid mutations
  coalesce, server hydration overwrites cache
- order-tab: per-row status flips through the auto-sync lifecycle,
  remove → empty cmd[] PUT, rejected → retry button
- inspector overlay: applied + valid + submitting all participate
  in the optimistic projection
- header: live race / game / turn rendering with fall-back

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-09 13:34:10 +02:00
parent 68d8607eaa
commit 229c43beb5
26 changed files with 1144 additions and 728 deletions
@@ -0,0 +1,240 @@
// Test helpers that fabricate `GalaxyClient` stand-ins for the
// auto-sync pipeline. Two flavours:
//
// - `recordingClient` — captures every `submitOrder` call and lets
// the test assert on the order of in-flight payloads. The
// outcome (`ok` / `rejected`) is settable per call so tests can
// simulate retry loops.
// - `fakeFetchClient` — wires a synthetic `user.games.order.get`
// response so `OrderDraftStore.hydrateFromServer` exercises the
// decoder against a populated FBS envelope.
//
// Both helpers live under `tests/helpers/` so they can be reused
// across `order-draft.test.ts`, `inspector-overlay.test.ts`, and
// future Phase 14+ specs.
import { Builder } from "flatbuffers";
import type { GalaxyClient } from "../../src/api/galaxy-client";
import { uuidToHiLo } from "../../src/api/game-state";
import { UUID } from "../../src/proto/galaxy/fbs/common";
import {
CommandItem,
CommandPayload,
CommandPlanetRename,
UserGamesOrder,
UserGamesOrderGetResponse,
UserGamesOrderResponse,
} from "../../src/proto/galaxy/fbs/order";
import type { OrderCommand } from "../../src/sync/order-types";
interface RecordedCall {
messageType: string;
commandIds: string[];
}
interface RecordingHandle {
client: GalaxyClient;
calls: RecordedCall[];
setOutcome(outcome: "ok" | "rejected"): void;
waitForCalls(n: number): Promise<void>;
waitForIdle(): Promise<void>;
}
/**
* recordingClient returns a fake GalaxyClient whose `executeCommand`
* decodes the in-flight UserGamesOrder, records the cmd_ids, and
* answers with a synthesised UserGamesOrderResponse where every
* cmdApplied is true (when outcome="ok") or false (when outcome=
* "rejected"). An optional `delayMs` simulates network latency so
* tests can exercise the coalescing path.
*/
export function recordingClient(
gameId: string,
initialOutcome: "ok" | "rejected",
options: { delayMs?: number } = {},
): RecordingHandle {
const calls: RecordedCall[] = [];
let outcome: "ok" | "rejected" = initialOutcome;
let inFlight = 0;
const waiters: (() => void)[] = [];
const client: GalaxyClient = {
async executeCommand(messageType: string, payload: Uint8Array) {
inFlight += 1;
try {
if (options.delayMs !== undefined) {
await new Promise<void>((resolve) =>
setTimeout(resolve, options.delayMs),
);
}
if (messageType === "user.games.order") {
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
new (await import("flatbuffers")).ByteBuffer(payload),
);
const length = decoded.commandsLength();
const commandIds: string[] = [];
for (let i = 0; i < length; i++) {
const item = decoded.commands(i);
if (item === null) continue;
const id = item.cmdId();
if (id !== null) commandIds.push(id);
}
calls.push({ messageType, commandIds });
if (outcome === "ok") {
return {
resultCode: "ok",
payloadBytes: encodeApplied(gameId, commandIds, true),
};
}
return {
resultCode: "invalid_request",
payloadBytes: new TextEncoder().encode(
JSON.stringify({
code: "validation_failed",
message: "rejected by fixture",
}),
),
};
}
throw new Error(`unexpected messageType ${messageType}`);
} finally {
inFlight -= 1;
if (inFlight === 0) {
while (waiters.length > 0) {
const wake = waiters.shift();
wake?.();
}
}
}
},
} as unknown as GalaxyClient;
return {
client,
calls,
setOutcome(next: "ok" | "rejected") {
outcome = next;
},
async waitForCalls(n: number) {
while (calls.length < n) {
await new Promise<void>((resolve) => setTimeout(resolve, 5));
}
},
async waitForIdle() {
if (inFlight === 0) return;
await new Promise<void>((resolve) => waiters.push(resolve));
},
};
}
/**
* fakeFetchClient returns a GalaxyClient stand-in whose
* `executeCommand` answers a single hard-coded
* UserGamesOrderGetResponse — enough for `hydrateFromServer` to
* decode a realistic payload without standing up a full mock
* gateway.
*/
export function fakeFetchClient(
gameId: string,
commands: OrderCommand[],
updatedAt: number,
found = true,
): { client: GalaxyClient } {
const client: GalaxyClient = {
async executeCommand(messageType: string) {
if (messageType !== "user.games.order.get") {
throw new Error(`unexpected messageType ${messageType}`);
}
return {
resultCode: "ok",
payloadBytes: encodeOrderGet(gameId, commands, updatedAt, found),
};
},
} as unknown as GalaxyClient;
return { client };
}
function encodeApplied(
gameId: string,
cmdIds: string[],
applied: boolean,
): Uint8Array {
const builder = new Builder(256);
const itemOffsets = cmdIds.map((id) => {
const cmdIdOffset = builder.createString(id);
const nameOffset = builder.createString("ignored");
const inner = CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(0),
nameOffset,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addCmdApplied(builder, applied);
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
CommandItem.addPayload(builder, inner);
return CommandItem.endCommandItem(builder);
});
const commandsVec = UserGamesOrderResponse.createCommandsVector(
builder,
itemOffsets,
);
const [hi, lo] = uuidToHiLo(gameId);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(Date.now()));
UserGamesOrderResponse.addCommands(builder, commandsVec);
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
builder.finish(offset);
return builder.asUint8Array();
}
function encodeOrderGet(
gameId: string,
commands: OrderCommand[],
updatedAt: number,
found: boolean,
): Uint8Array {
const builder = new Builder(256);
let orderOffset = 0;
if (found) {
const itemOffsets = commands.map((cmd) => {
if (cmd.kind !== "planetRename") {
throw new Error(`unsupported command kind ${cmd.kind}`);
}
const cmdIdOffset = builder.createString(cmd.id);
const nameOffset = builder.createString(cmd.name);
const inner = CommandPlanetRename.createCommandPlanetRename(
builder,
BigInt(cmd.planetNumber),
nameOffset,
);
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
CommandItem.addPayload(builder, inner);
return CommandItem.endCommandItem(builder);
});
const commandsVec = UserGamesOrder.createCommandsVector(builder, itemOffsets);
const [hi, lo] = uuidToHiLo(gameId);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrder.startUserGamesOrder(builder);
UserGamesOrder.addGameId(builder, gameIdOffset);
UserGamesOrder.addUpdatedAt(builder, BigInt(updatedAt));
UserGamesOrder.addCommands(builder, commandsVec);
orderOffset = UserGamesOrder.endUserGamesOrder(builder);
}
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
UserGamesOrderGetResponse.addFound(builder, found);
if (orderOffset !== 0) {
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
}
const offset =
UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder);
builder.finish(offset);
return builder.asUint8Array();
}