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
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>
569 lines
15 KiB
TypeScript
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 };
|
|
}
|
|
}
|