Files
galaxy-game/ui/frontend/src/sync/order-load.ts
T
Ilia Denisov 723885e74e
Tests · UI / test (push) Has been cancelled
Tests · Go / test (push) Successful in 2m3s
Tests · Go / test (pull_request) Successful in 2m5s
Tests · Integration / integration (pull_request) Successful in 1m44s
Tests · UI / test (pull_request) Failing after 4m28s
fix(order): surface rejection reason, keep sync green, hydrate verdicts
Three issues surfaced once the per-command rejection from the previous
commit actually reached the UI:

1. Sync banner falsely red. `OrderDraftStore.runSync` flipped
   `syncStatus = "error"` whenever any command was rejected and
   advertised a Retry button. A per-command rejection is a
   player-correctable state — the round trip succeeded, the engine
   just refused that command — so the retry can't help. Keep
   `syncStatus = "synced"` on `success`; the red row highlight is
   the visible cue.

2. Rejection reason missing. Add `cmd_error_message: string` to
   `CommandItem` in `pkg/schema/fbs/order.fbs` (appended last to
   preserve existing slot offsets) and regenerate the Go + TS stubs
   for that one type. Plumb the message through `CommandMeta`,
   `Controller.applyCommand`'s `m.Result(code, message)` call, the
   Go transcoder, the UI decoders in `submit.ts` /  `order-load.ts`,
   and the `OrderDraftStore.errorMessages` map. `order-tab.svelte`
   renders it as an italic danger-coloured line under rejected
   commands, with new CSS for `.error-reason`.

3. Verdict lost on navigation. `order-load.ts.decodeCommand` never
   read `cmdApplied`/`cmdErrorCode`, so `hydrateFromServer` fell
   back to a blanket "applied" status — a previously-rejected
   command came back green after a lobby → game round trip. Extend
   the fetch decoder to populate `statuses`/`errorCodes`/
   `errorMessages` maps and have `hydrateFromServer` use them.
   Engine-side persistence already records the verdict on disk —
   verified against the live `0000/order/<id>.json`.

`flatbuffers@25` elides default-int8/int64 fields on write; the Go
transcoder force-slots `cmd_applied=false` / `cmd_error_code=0`
already, the new test fixtures flip `builder.forceDefaults(true)` to
mirror that behaviour so the round trip survives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 11:42:27 +02:00

569 lines
15 KiB
TypeScript

// Reads back the player's stored order for the current turn through
// `user.games.order.get`. Used by `OrderDraftStore` only when the
// local cache row is absent (fresh install, cleared storage, or a
// brand-new device): the local draft is the source of truth, so a
// present-but-empty cache row means "no commands" and is honoured
// over the server snapshot.
import { Builder, ByteBuffer } from "flatbuffers";
import type { GalaxyClient } from "../api/galaxy-client";
import { uuidToHiLo } from "../api/game-state";
import { UUID } from "../proto/galaxy/fbs/common";
import {
CommandPayload,
CommandPlanetProduce,
CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
CommandRaceRelation,
CommandRaceVote,
CommandScienceCreate,
CommandScienceRemove,
CommandShipClassCreate,
CommandShipClassRemove,
CommandShipGroupBreak,
CommandShipGroupDismantle,
CommandShipGroupJoinFleet,
CommandShipGroupLoad,
CommandShipGroupSend,
CommandShipGroupTransfer,
CommandShipGroupUnload,
CommandShipGroupUpgrade,
PlanetProduction,
PlanetRouteLoadType,
Relation,
ShipGroupCargo,
ShipGroupUpgradeTech,
UserGamesOrderGet,
UserGamesOrderGetResponse,
} from "../proto/galaxy/fbs/order";
import type {
CargoLoadType,
OrderCommand,
ProductionType,
Relation as RelationLiteral,
ShipGroupCargo as ShipGroupCargoLiteral,
ShipGroupUpgradeTech as ShipGroupUpgradeTechLiteral,
} from "./order-types";
const MESSAGE_TYPE = "user.games.order.get";
export class OrderLoadError extends Error {
readonly resultCode: string;
readonly code: string;
constructor(resultCode: string, code: string, message: string) {
super(message);
this.name = "OrderLoadError";
this.resultCode = resultCode;
this.code = code;
}
}
export interface FetchedOrder {
commands: OrderCommand[];
// Per-command status keyed by cmdId. Populated from the engine's
// stored order so a returning player sees the same per-command
// verdict (applied / rejected) the previous submission produced —
// not a synthetic "applied" derived from the local cache.
statuses: Map<string, "applied" | "rejected">;
// Per-command engine-formatted error code/message, keyed by cmdId.
// Both maps carry an entry for every loaded command; the value is
// null when the command was applied (no error). The message lets
// the UI surface the rejection reason without a code → text catalog.
errorCodes: Map<string, number | null>;
errorMessages: Map<string, string | null>;
updatedAt: number;
}
/**
* fetchOrder issues `user.games.order.get` for the given game and
* turn, decodes the response, and returns the typed draft. A
* `found = false` answer (no order stored on the server) surfaces as
* an empty `commands` array — the caller treats this as a clean
* draft. Unknown command kinds in the response are skipped with a
* console warning so a backend-side schema bump never silently
* corrupts the local draft.
*/
export async function fetchOrder(
client: GalaxyClient,
gameId: string,
turn: number,
): Promise<FetchedOrder> {
if (turn < 0) {
throw new OrderLoadError(
"invalid_request",
"invalid_request",
`turn must be non-negative, got ${turn}`,
);
}
const payload = buildRequest(gameId, turn);
const result = await client.executeCommand(MESSAGE_TYPE, payload);
if (result.resultCode !== "ok") {
const { code, message } = decodeError(result.payloadBytes, result.resultCode);
throw new OrderLoadError(result.resultCode, code, message);
}
return decodeResponse(result.payloadBytes);
}
function buildRequest(gameId: string, turn: number): Uint8Array {
const builder = new Builder(64);
const [hi, lo] = uuidToHiLo(gameId);
const gameIdOffset = UUID.createUUID(builder, hi, lo);
UserGamesOrderGet.startUserGamesOrderGet(builder);
UserGamesOrderGet.addGameId(builder, gameIdOffset);
UserGamesOrderGet.addTurn(builder, BigInt(turn));
const offset = UserGamesOrderGet.endUserGamesOrderGet(builder);
builder.finish(offset);
return builder.asUint8Array();
}
function decodeResponse(payload: Uint8Array): FetchedOrder {
if (payload.length === 0) {
throw new OrderLoadError(
"internal_error",
"internal_error",
"empty user.games.order.get payload",
);
}
const buffer = new ByteBuffer(payload);
const response = UserGamesOrderGetResponse.getRootAsUserGamesOrderGetResponse(buffer);
if (!response.found()) {
return {
commands: [],
statuses: new Map(),
errorCodes: new Map(),
errorMessages: new Map(),
updatedAt: 0,
};
}
const order = response.order();
if (order === null) {
throw new OrderLoadError(
"internal_error",
"internal_error",
"order missing while found=true",
);
}
const commands: OrderCommand[] = [];
const statuses = new Map<string, "applied" | "rejected">();
const errorCodes = new Map<string, number | null>();
const errorMessages = new Map<string, string | null>();
const length = order.commandsLength();
for (let i = 0; i < length; i++) {
const item = order.commands(i);
if (item === null) continue;
const cmd = decodeCommand(item);
if (cmd === null) continue;
commands.push(cmd);
// The engine echoes `cmd_applied = false` only when the order
// was rejected per-command; missing / true both mean applied.
const applied = item.cmdApplied();
statuses.set(cmd.id, applied === false ? "rejected" : "applied");
const code = item.cmdErrorCode();
errorCodes.set(cmd.id, code === null ? null : Number(code));
const msg = item.cmdErrorMessage();
errorMessages.set(cmd.id, msg === null ? null : msg);
}
return {
commands,
statuses,
errorCodes,
errorMessages,
updatedAt: Number(order.updatedAt()),
};
}
type CommandItemView = NonNullable<
ReturnType<NonNullable<ReturnType<UserGamesOrderGetResponse["order"]>>["commands"]>
>;
function decodeCommand(item: CommandItemView): OrderCommand | null {
if (item === null) return null;
const id = item.cmdId();
if (id === null) return null;
const payloadType = item.payloadType();
switch (payloadType) {
case CommandPayload.CommandPlanetRename: {
const inner = new CommandPlanetRename();
item.payload(inner);
return {
kind: "planetRename",
id,
planetNumber: Number(inner.number()),
name: inner.name() ?? "",
};
}
case CommandPayload.CommandPlanetProduce: {
const inner = new CommandPlanetProduce();
item.payload(inner);
const productionType = productionTypeFromFBS(inner.production());
if (productionType === null) {
console.warn(
`fetchOrder: skipping CommandPlanetProduce with unknown production enum (${inner.production()})`,
);
return null;
}
return {
kind: "setProductionType",
id,
planetNumber: Number(inner.number()),
productionType,
subject: inner.subject() ?? "",
};
}
case CommandPayload.CommandPlanetRouteSet: {
const inner = new CommandPlanetRouteSet();
item.payload(inner);
const loadType = cargoLoadTypeFromFBS(inner.loadType());
if (loadType === null) {
console.warn(
`fetchOrder: skipping CommandPlanetRouteSet with unknown load_type enum (${inner.loadType()})`,
);
return null;
}
return {
kind: "setCargoRoute",
id,
sourcePlanetNumber: Number(inner.origin()),
destinationPlanetNumber: Number(inner.destination()),
loadType,
};
}
case CommandPayload.CommandPlanetRouteRemove: {
const inner = new CommandPlanetRouteRemove();
item.payload(inner);
const loadType = cargoLoadTypeFromFBS(inner.loadType());
if (loadType === null) {
console.warn(
`fetchOrder: skipping CommandPlanetRouteRemove with unknown load_type enum (${inner.loadType()})`,
);
return null;
}
return {
kind: "removeCargoRoute",
id,
sourcePlanetNumber: Number(inner.origin()),
loadType,
};
}
case CommandPayload.CommandShipClassCreate: {
const inner = new CommandShipClassCreate();
item.payload(inner);
return {
kind: "createShipClass",
id,
name: inner.name() ?? "",
drive: inner.drive(),
armament: Number(inner.armament()),
weapons: inner.weapons(),
shields: inner.shields(),
cargo: inner.cargo(),
};
}
case CommandPayload.CommandShipClassRemove: {
const inner = new CommandShipClassRemove();
item.payload(inner);
return {
kind: "removeShipClass",
id,
name: inner.name() ?? "",
};
}
case CommandPayload.CommandScienceCreate: {
const inner = new CommandScienceCreate();
item.payload(inner);
return {
kind: "createScience",
id,
name: inner.name() ?? "",
drive: inner.drive(),
weapons: inner.weapons(),
shields: inner.shields(),
cargo: inner.cargo(),
};
}
case CommandPayload.CommandScienceRemove: {
const inner = new CommandScienceRemove();
item.payload(inner);
return {
kind: "removeScience",
id,
name: inner.name() ?? "",
};
}
case CommandPayload.CommandShipGroupBreak: {
const inner = new CommandShipGroupBreak();
item.payload(inner);
return {
kind: "breakShipGroup",
id,
groupId: inner.id() ?? "",
newGroupId: inner.newId() ?? "",
quantity: Number(inner.quantity()),
};
}
case CommandPayload.CommandShipGroupSend: {
const inner = new CommandShipGroupSend();
item.payload(inner);
return {
kind: "sendShipGroup",
id,
groupId: inner.id() ?? "",
destinationPlanetNumber: Number(inner.destination()),
};
}
case CommandPayload.CommandShipGroupLoad: {
const inner = new CommandShipGroupLoad();
item.payload(inner);
const cargo = shipGroupCargoFromFBS(inner.cargo());
if (cargo === null) {
console.warn(
`fetchOrder: skipping CommandShipGroupLoad with unknown cargo enum (${inner.cargo()})`,
);
return null;
}
return {
kind: "loadShipGroup",
id,
groupId: inner.id() ?? "",
cargo,
quantity: inner.quantity(),
};
}
case CommandPayload.CommandShipGroupUnload: {
const inner = new CommandShipGroupUnload();
item.payload(inner);
return {
kind: "unloadShipGroup",
id,
groupId: inner.id() ?? "",
quantity: inner.quantity(),
};
}
case CommandPayload.CommandShipGroupUpgrade: {
const inner = new CommandShipGroupUpgrade();
item.payload(inner);
const tech = shipGroupUpgradeTechFromFBS(inner.tech());
if (tech === null) {
console.warn(
`fetchOrder: skipping CommandShipGroupUpgrade with unknown tech enum (${inner.tech()})`,
);
return null;
}
return {
kind: "upgradeShipGroup",
id,
groupId: inner.id() ?? "",
tech,
level: inner.level(),
};
}
case CommandPayload.CommandShipGroupDismantle: {
const inner = new CommandShipGroupDismantle();
item.payload(inner);
return {
kind: "dismantleShipGroup",
id,
groupId: inner.id() ?? "",
};
}
case CommandPayload.CommandShipGroupTransfer: {
const inner = new CommandShipGroupTransfer();
item.payload(inner);
return {
kind: "transferShipGroup",
id,
groupId: inner.id() ?? "",
acceptor: inner.acceptor() ?? "",
};
}
case CommandPayload.CommandShipGroupJoinFleet: {
const inner = new CommandShipGroupJoinFleet();
item.payload(inner);
return {
kind: "joinFleetShipGroup",
id,
groupId: inner.id() ?? "",
name: inner.name() ?? "",
};
}
case CommandPayload.CommandRaceRelation: {
const inner = new CommandRaceRelation();
item.payload(inner);
const relation = relationFromFBS(inner.relation());
if (relation === null) {
console.warn(
`fetchOrder: skipping CommandRaceRelation with unknown relation enum (${inner.relation()})`,
);
return null;
}
return {
kind: "setDiplomaticStance",
id,
acceptor: inner.acceptor() ?? "",
relation,
};
}
case CommandPayload.CommandRaceVote: {
const inner = new CommandRaceVote();
item.payload(inner);
return {
kind: "setVoteRecipient",
id,
acceptor: inner.acceptor() ?? "",
};
}
default:
console.warn(
`fetchOrder: skipping unknown command kind (payloadType=${payloadType})`,
);
return null;
}
}
/**
* productionTypeFromFBS reverses `productionTypeToFBS` from
* `submit.ts`. `PlanetProduction.UNKNOWN` and any out-of-band value
* yield `null` so the caller drops the entry instead of fabricating a
* synthetic kind.
*/
export function productionTypeFromFBS(
value: PlanetProduction,
): ProductionType | null {
switch (value) {
case PlanetProduction.MAT:
return "MAT";
case PlanetProduction.CAP:
return "CAP";
case PlanetProduction.DRIVE:
return "DRIVE";
case PlanetProduction.WEAPONS:
return "WEAPONS";
case PlanetProduction.SHIELDS:
return "SHIELDS";
case PlanetProduction.CARGO:
return "CARGO";
case PlanetProduction.SCIENCE:
return "SCIENCE";
case PlanetProduction.SHIP:
return "SHIP";
case PlanetProduction.UNKNOWN:
return null;
default:
return null;
}
}
/**
* cargoLoadTypeFromFBS reverses `cargoLoadTypeToFBS` from
* `submit.ts`. `PlanetRouteLoadType.UNKNOWN` and any out-of-band
* value yield `null` so the caller drops the entry rather than
* fabricating a synthetic load type.
*/
export function cargoLoadTypeFromFBS(
value: PlanetRouteLoadType,
): CargoLoadType | null {
switch (value) {
case PlanetRouteLoadType.COL:
return "COL";
case PlanetRouteLoadType.CAP:
return "CAP";
case PlanetRouteLoadType.MAT:
return "MAT";
case PlanetRouteLoadType.EMP:
return "EMP";
case PlanetRouteLoadType.UNKNOWN:
return null;
default:
return null;
}
}
/**
* shipGroupCargoFromFBS reverses `shipGroupCargoToFBS` from
* `submit.ts`. `ShipGroupCargo.UNKNOWN` and any out-of-band value
* yield `null` so the caller drops the entry rather than
* fabricating a synthetic cargo type.
*/
export function shipGroupCargoFromFBS(
value: ShipGroupCargo,
): ShipGroupCargoLiteral | null {
switch (value) {
case ShipGroupCargo.COL:
return "COL";
case ShipGroupCargo.CAP:
return "CAP";
case ShipGroupCargo.MAT:
return "MAT";
case ShipGroupCargo.UNKNOWN:
return null;
default:
return null;
}
}
/**
* shipGroupUpgradeTechFromFBS reverses `shipGroupUpgradeTechToFBS`
* from `submit.ts`. `ShipGroupUpgradeTech.UNKNOWN` and any
* out-of-band value yield `null`.
*/
export function shipGroupUpgradeTechFromFBS(
value: ShipGroupUpgradeTech,
): ShipGroupUpgradeTechLiteral | null {
switch (value) {
case ShipGroupUpgradeTech.ALL:
return "ALL";
case ShipGroupUpgradeTech.DRIVE:
return "DRIVE";
case ShipGroupUpgradeTech.WEAPONS:
return "WEAPONS";
case ShipGroupUpgradeTech.SHIELDS:
return "SHIELDS";
case ShipGroupUpgradeTech.CARGO:
return "CARGO";
case ShipGroupUpgradeTech.UNKNOWN:
return null;
default:
return null;
}
}
/**
* relationFromFBS reverses `relationToFBS` from `submit.ts`.
* `Relation.UNKNOWN` and any out-of-band value yield `null` so the
* caller drops the entry rather than fabricating a synthetic stance.
*/
export function relationFromFBS(value: Relation): RelationLiteral | null {
switch (value) {
case Relation.WAR:
return "WAR";
case Relation.PEACE:
return "PEACE";
case Relation.UNKNOWN:
return null;
default:
return null;
}
}
function decodeError(
payload: Uint8Array,
resultCode: string,
): { code: string; message: string } {
if (payload.length === 0) {
return { code: resultCode, message: resultCode };
}
try {
const text = new TextDecoder().decode(payload);
const parsed = JSON.parse(text) as { code?: string; message?: string };
return {
code: typeof parsed.code === "string" ? parsed.code : resultCode,
message: typeof parsed.message === "string" ? parsed.message : text,
};
} catch {
return { code: resultCode, message: resultCode };
}
}