2ca47eb4df
Backend now owns the turn-cutoff and pause guards the order tab relies on: the scheduler flips runtime_status between generation_in_progress and running around every engine tick, a failed tick auto-pauses the game through OnRuntimeSnapshot, and a new game.paused notification kind fans out alongside game.turn.ready. The user-games handlers reject submits with HTTP 409 turn_already_closed or game_paused depending on the runtime state. UI delegates auto-sync to a new OrderQueue: offline detection, single retry on reconnect, conflict / paused classification. OrderDraftStore surfaces conflictBanner / pausedBanner runes, clears them on local mutation or on a game.turn.ready push via resetForNewTurn. The order tab renders the matching banners and the new conflict per-row badge; i18n bundles cover en + ru. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
280 lines
8.4 KiB
TypeScript
280 lines
8.4 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[];
|
|
}
|
|
|
|
/**
|
|
* RecordingOutcome enumerates the synthetic server reactions a test
|
|
* can drive through `recordingClient.setOutcome`. Phase 25 adds the
|
|
* `turn_already_closed` and `game_paused` codes (the order-queue
|
|
* classifies them into `conflict` / `paused` outcomes) and `throw`
|
|
* which lets the test exercise the network-error branch of
|
|
* `OrderQueue.send`.
|
|
*/
|
|
export type RecordingOutcome =
|
|
| "ok"
|
|
| "rejected"
|
|
| "turn_already_closed"
|
|
| "game_paused"
|
|
| "throw";
|
|
|
|
interface RecordingHandle {
|
|
client: GalaxyClient;
|
|
calls: RecordedCall[];
|
|
setOutcome(outcome: RecordingOutcome): 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: RecordingOutcome,
|
|
options: { delayMs?: number } = {},
|
|
): RecordingHandle {
|
|
const calls: RecordedCall[] = [];
|
|
let outcome: RecordingOutcome = 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 });
|
|
switch (outcome) {
|
|
case "ok":
|
|
return {
|
|
resultCode: "ok",
|
|
payloadBytes: encodeApplied(gameId, commandIds, true),
|
|
};
|
|
case "turn_already_closed":
|
|
return {
|
|
resultCode: "turn_already_closed",
|
|
payloadBytes: new TextEncoder().encode(
|
|
JSON.stringify({
|
|
code: "turn_already_closed",
|
|
message: "turn closed before submit",
|
|
}),
|
|
),
|
|
};
|
|
case "game_paused":
|
|
return {
|
|
resultCode: "game_paused",
|
|
payloadBytes: new TextEncoder().encode(
|
|
JSON.stringify({
|
|
code: "game_paused",
|
|
message: "game is paused",
|
|
}),
|
|
),
|
|
};
|
|
case "throw":
|
|
throw new Error("network down");
|
|
default:
|
|
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: RecordingOutcome) {
|
|
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();
|
|
}
|