229c43beb5
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>
241 lines
7.3 KiB
TypeScript
241 lines
7.3 KiB
TypeScript
// 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();
|
|
}
|