ui/phase-22: races table with stance toggle and vote slot

Adds the Races View in the in-game shell. The table lists every
non-extinct other race with tech levels (percent), totals,
planets, votes received, and a per-row WAR | PEACE segmented
control. A single vote-recipient slot above the table queues a
`CommandRaceVote`; per-row buttons queue `CommandRaceRelation`.
Both commands flow through the existing order draft store with
collapse-by-acceptor (stance) and singleton (vote) rules.

`GameReport` widens with `races`, `myVotes`, `myVoteFor`; the
decoder walks `report.player[]` once for the richer projection.
The optimistic overlay flips stance and vote target immediately;
`votesReceived`, `myVotes`, and the alliance summary stay
server-authoritative — alliance grouping and the 2/3 victory
check are tallied on the server at turn cutoff and explicitly
not surfaced client-side (`rules.txt` keeps foreign races'
outgoing vote targets private).

Includes Vitest component coverage of stance and vote
collapse rules + a Playwright e2e that drives both commands
through the dispatcher route and verifies the gateway saw the
expected `CommandRaceRelation` / `CommandRaceVote` payloads.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-11 01:52:23 +02:00
parent 7a7f2e4b98
commit 9111dd955a
18 changed files with 1714 additions and 47 deletions
+48
View File
@@ -16,6 +16,8 @@ import {
CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
CommandRaceRelation,
CommandRaceVote,
CommandScienceCreate,
CommandScienceRemove,
CommandShipClassCreate,
@@ -30,6 +32,7 @@ import {
CommandShipGroupUpgrade,
PlanetProduction,
PlanetRouteLoadType,
Relation,
ShipGroupCargo,
ShipGroupUpgradeTech,
UserGamesOrderGet,
@@ -39,6 +42,7 @@ import type {
CargoLoadType,
OrderCommand,
ProductionType,
Relation as RelationLiteral,
ShipGroupCargo as ShipGroupCargoLiteral,
ShipGroupUpgradeTech as ShipGroupUpgradeTechLiteral,
} from "./order-types";
@@ -354,6 +358,32 @@ function decodeCommand(item: CommandItemView): OrderCommand | null {
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})`,
@@ -469,6 +499,24 @@ export function shipGroupUpgradeTechFromFBS(
}
}
/**
* 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,