3626998a33
Eight ship-group operations land on the inspector behind a single inline-form panel: split, send, load, unload, modernize, dismantle, transfer, join fleet. Each action either appends a typed command to the local order draft or surfaces a tooltip explaining the disabled state. Partial-ship operations emit an implicit breakShipGroup command before the targeted action so the engine sees a clean (Break, Action) pair on the wire. `pkg/calc.BlockUpgradeCost` migrates from `game/internal/controller/ship_group_upgrade.go` so the calc bridge can wrap a pure pkg/calc formula; the controller now imports it. The bridge surfaces the function as `core.blockUpgradeCost`, which the inspector calls once per ship block to render the modernize cost preview. `GameReport.otherRaces` is decoded from the report's player block (non-extinct, ≠ self) and feeds the transfer-to-race picker. The planet inspector's stationed-ship rows become clickable for own groups so the actions panel is reachable from the standard click flow (the renderer continues to hide on-planet groups). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
267 lines
8.4 KiB
TypeScript
267 lines
8.4 KiB
TypeScript
// 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<Uint8Array> {
|
|
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<ReturnType<UserGamesOrder["commands"]>>,
|
|
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<OrderCommand> {
|
|
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);
|
|
});
|
|
});
|