// Vitest round-trip coverage for the eight Phase 20 ship-group // command shapes. The encoder lives in `sync/submit.ts`; the // decoder lives in `sync/order-load.ts`. We capture the request // bytes the encoder produces, re-emit them inside a // `UserGamesOrderGetResponse` envelope, and feed that to // `fetchOrder`. The decoded command must match the original — any // drift between encoder and decoder fails here first. import { Builder, ByteBuffer } from "flatbuffers"; import { describe, expect, test, vi } from "vitest"; 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, CommandShipGroupBreak, CommandShipGroupDismantle, CommandShipGroupJoinFleet, CommandShipGroupLoad, CommandShipGroupSend, CommandShipGroupTransfer, CommandShipGroupUnload, CommandShipGroupUpgrade, UserGamesOrder, UserGamesOrderGetResponse, UserGamesOrderResponse, } from "../src/proto/galaxy/fbs/order"; import { fetchOrder } from "../src/sync/order-load"; import { submitOrder } from "../src/sync/submit"; import type { OrderCommand } from "../src/sync/order-types"; const GAME_ID = "11111111-2222-3333-4444-555555555555"; const GROUP_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; function mockClient( executeCommand: ( messageType: string, payload: Uint8Array, ) => Promise<{ resultCode: string; payloadBytes: Uint8Array }>, ): GalaxyClient { return { executeCommand } as unknown as GalaxyClient; } // captureRequestBytes runs submitOrder against a mock that records // the outgoing payload, then returns those bytes (which are a valid // `UserGamesOrder` envelope). async function captureRequestBytes(cmds: OrderCommand[]): Promise { let captured: Uint8Array | null = null; const exec = vi.fn(async (_msg: string, payload: Uint8Array) => { captured = payload; const builder = new Builder(64); const [hi, lo] = uuidToHiLo(GAME_ID); const gameIdOffset = UUID.createUUID(builder, hi, lo); UserGamesOrderResponse.startUserGamesOrderResponse(builder); UserGamesOrderResponse.addGameId(builder, gameIdOffset); UserGamesOrderResponse.addUpdatedAt(builder, BigInt(0)); const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder); builder.finish(offset); return { resultCode: "ok", payloadBytes: builder.asUint8Array() }; }); const result = await submitOrder(mockClient(exec), GAME_ID, cmds); expect(result.ok).toBe(true); expect(captured).not.toBeNull(); return captured!; } // wrapAsGetResponse rebuilds the captured `UserGamesOrder` inside a // `UserGamesOrderGetResponse` envelope by walking each // `CommandItem`, copying its identity fields, and re-packing each // payload through `unpack().pack(builder)` — the FBS-generated // helper that round-trips a typed table into a fresh builder. function wrapAsGetResponse(orderBytes: Uint8Array): Uint8Array { const order = UserGamesOrder.getRootAsUserGamesOrder( new ByteBuffer(orderBytes), ); const builder = new Builder(256); const itemOffsets: number[] = []; for (let i = 0; i < order.commandsLength(); i++) { const item = order.commands(i); if (item === null) continue; const cmdIdOffset = builder.createString(item.cmdId() ?? ""); const payloadType = item.payloadType(); const payloadOffset = packPayload(builder, item, payloadType); CommandItem.startCommandItem(builder); CommandItem.addCmdId(builder, cmdIdOffset); CommandItem.addPayloadType(builder, payloadType); CommandItem.addPayload(builder, payloadOffset); itemOffsets.push(CommandItem.endCommandItem(builder)); } const commandsVec = UserGamesOrder.createCommandsVector(builder, itemOffsets); const [hi, lo] = uuidToHiLo(GAME_ID); const gameIdOffset = UUID.createUUID(builder, hi, lo); UserGamesOrder.startUserGamesOrder(builder); UserGamesOrder.addGameId(builder, gameIdOffset); UserGamesOrder.addUpdatedAt(builder, order.updatedAt()); UserGamesOrder.addCommands(builder, commandsVec); const orderOffset = UserGamesOrder.endUserGamesOrder(builder); UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder); UserGamesOrderGetResponse.addFound(builder, true); UserGamesOrderGetResponse.addOrder(builder, orderOffset); const resOffset = UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder); builder.finish(resOffset); return builder.asUint8Array(); } function packPayload( builder: Builder, item: NonNullable>, payloadType: CommandPayload, ): number { switch (payloadType) { case CommandPayload.CommandShipGroupBreak: { const inner = new CommandShipGroupBreak(); item.payload(inner); return inner.unpack().pack(builder); } case CommandPayload.CommandShipGroupSend: { const inner = new CommandShipGroupSend(); item.payload(inner); return inner.unpack().pack(builder); } case CommandPayload.CommandShipGroupLoad: { const inner = new CommandShipGroupLoad(); item.payload(inner); return inner.unpack().pack(builder); } case CommandPayload.CommandShipGroupUnload: { const inner = new CommandShipGroupUnload(); item.payload(inner); return inner.unpack().pack(builder); } case CommandPayload.CommandShipGroupUpgrade: { const inner = new CommandShipGroupUpgrade(); item.payload(inner); return inner.unpack().pack(builder); } case CommandPayload.CommandShipGroupDismantle: { const inner = new CommandShipGroupDismantle(); item.payload(inner); return inner.unpack().pack(builder); } case CommandPayload.CommandShipGroupTransfer: { const inner = new CommandShipGroupTransfer(); item.payload(inner); return inner.unpack().pack(builder); } case CommandPayload.CommandShipGroupJoinFleet: { const inner = new CommandShipGroupJoinFleet(); item.payload(inner); return inner.unpack().pack(builder); } default: throw new Error(`unsupported payload type ${payloadType}`); } } async function roundTrip(cmd: OrderCommand): Promise { const requestBytes = await captureRequestBytes([cmd]); const responseBytes = wrapAsGetResponse(requestBytes); const exec = vi.fn(async () => ({ resultCode: "ok", payloadBytes: responseBytes, })); const result = await fetchOrder(mockClient(exec), GAME_ID, 0); expect(result.commands).toHaveLength(1); return result.commands[0]!; } describe("submit + order-load round-trip — ship-group commands", () => { test("breakShipGroup", async () => { const cmd: OrderCommand = { kind: "breakShipGroup", id: crypto.randomUUID(), groupId: GROUP_ID, newGroupId: "11112222-3333-4444-5555-666677778888", quantity: 3, }; expect(await roundTrip(cmd)).toEqual(cmd); }); test("sendShipGroup", async () => { const cmd: OrderCommand = { kind: "sendShipGroup", id: crypto.randomUUID(), groupId: GROUP_ID, destinationPlanetNumber: 42, }; expect(await roundTrip(cmd)).toEqual(cmd); }); test("loadShipGroup", async () => { const cmd: OrderCommand = { kind: "loadShipGroup", id: crypto.randomUUID(), groupId: GROUP_ID, cargo: "MAT", quantity: 12.5, }; expect(await roundTrip(cmd)).toEqual(cmd); }); test("unloadShipGroup", async () => { const cmd: OrderCommand = { kind: "unloadShipGroup", id: crypto.randomUUID(), groupId: GROUP_ID, quantity: 6.5, }; expect(await roundTrip(cmd)).toEqual(cmd); }); test("upgradeShipGroup ALL", async () => { const cmd: OrderCommand = { kind: "upgradeShipGroup", id: crypto.randomUUID(), groupId: GROUP_ID, tech: "ALL", level: 0, }; expect(await roundTrip(cmd)).toEqual(cmd); }); test("upgradeShipGroup DRIVE level 1.5", async () => { const cmd: OrderCommand = { kind: "upgradeShipGroup", id: crypto.randomUUID(), groupId: GROUP_ID, tech: "DRIVE", level: 1.5, }; expect(await roundTrip(cmd)).toEqual(cmd); }); test("dismantleShipGroup", async () => { const cmd: OrderCommand = { kind: "dismantleShipGroup", id: crypto.randomUUID(), groupId: GROUP_ID, }; expect(await roundTrip(cmd)).toEqual(cmd); }); test("transferShipGroup", async () => { const cmd: OrderCommand = { kind: "transferShipGroup", id: crypto.randomUUID(), groupId: GROUP_ID, acceptor: "Aliens", }; expect(await roundTrip(cmd)).toEqual(cmd); }); test("joinFleetShipGroup", async () => { const cmd: OrderCommand = { kind: "joinFleetShipGroup", id: crypto.randomUUID(), groupId: GROUP_ID, name: "Vanguard", }; expect(await roundTrip(cmd)).toEqual(cmd); }); });