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