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
+46 -1
View File
@@ -16,12 +16,15 @@ import {
CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
CommandRaceRelation,
CommandRaceVote,
CommandScienceCreate,
CommandScienceRemove,
CommandShipClassCreate,
CommandShipClassRemove,
PlanetProduction,
PlanetRouteLoadType,
Relation,
UserGamesOrder,
UserGamesOrderGetResponse,
UserGamesOrderResponse,
@@ -98,6 +101,19 @@ export interface RemoveScienceResultFixture extends CommandResultFixtureBase {
name: string;
}
export interface SetDiplomaticStanceResultFixture
extends CommandResultFixtureBase {
kind: "setDiplomaticStance";
acceptor: string;
relation: "WAR" | "PEACE";
}
export interface SetVoteRecipientResultFixture
extends CommandResultFixtureBase {
kind: "setVoteRecipient";
acceptor: string;
}
export type CommandResultFixture =
| PlanetRenameResultFixture
| SetProductionTypeResultFixture
@@ -106,7 +122,9 @@ export type CommandResultFixture =
| CreateShipClassResultFixture
| RemoveShipClassResultFixture
| CreateScienceResultFixture
| RemoveScienceResultFixture;
| RemoveScienceResultFixture
| SetDiplomaticStanceResultFixture
| SetVoteRecipientResultFixture;
export function buildOrderResponsePayload(
gameId: string,
@@ -255,6 +273,22 @@ function encodeItem(builder: Builder, c: CommandResultFixture): number {
payloadType = CommandPayload.CommandScienceRemove;
break;
}
case "setDiplomaticStance": {
const acceptorOffset = builder.createString(c.acceptor);
inner = CommandRaceRelation.createCommandRaceRelation(
builder,
acceptorOffset,
relationToFBS(c.relation),
);
payloadType = CommandPayload.CommandRaceRelation;
break;
}
case "setVoteRecipient": {
const acceptorOffset = builder.createString(c.acceptor);
inner = CommandRaceVote.createCommandRaceVote(builder, acceptorOffset);
payloadType = CommandPayload.CommandRaceVote;
break;
}
}
CommandItem.startCommandItem(builder);
CommandItem.addCmdId(builder, cmdIdOffset);
@@ -304,3 +338,14 @@ function cargoLoadTypeToFBS(
return PlanetRouteLoadType.EMP;
}
}
function relationToFBS(
value: SetDiplomaticStanceResultFixture["relation"],
): Relation {
switch (value) {
case "WAR":
return Relation.WAR;
case "PEACE":
return Relation.PEACE;
}
}
@@ -73,6 +73,15 @@ export interface ScienceFixture {
export interface PlayerFixture {
name: string;
drive?: number;
weapons?: number;
shields?: number;
cargo?: number;
population?: number;
industry?: number;
planets?: number;
relation?: "WAR" | "PEACE" | "-";
votes?: number;
extinct?: boolean;
}
export interface RouteEntryFixture {
@@ -98,6 +107,8 @@ export interface ReportFixture {
race?: string;
players?: PlayerFixture[];
routes?: RouteFixture[];
myVotes?: number;
myVoteFor?: string;
}
export function buildReportPayload(fixture: ReportFixture): Uint8Array {
@@ -202,9 +213,20 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
const playerOffsets = (fixture.players ?? []).map((p) => {
const name = builder.createString(p.name);
const relation =
p.relation === undefined ? null : builder.createString(p.relation);
Player.startPlayer(builder);
Player.addName(builder, name);
Player.addDrive(builder, p.drive ?? 1);
Player.addWeapons(builder, p.weapons ?? 0);
Player.addShields(builder, p.shields ?? 0);
Player.addCargo(builder, p.cargo ?? 0);
Player.addPopulation(builder, p.population ?? 0);
Player.addIndustry(builder, p.industry ?? 0);
Player.addPlanets(builder, p.planets ?? 0);
if (relation !== null) Player.addRelation(builder, relation);
Player.addVotes(builder, p.votes ?? 0);
Player.addExtinct(builder, p.extinct ?? false);
return Player.endPlayer(builder);
});
@@ -257,6 +279,10 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
: Report.createRouteVector(builder, routeOffsets);
const raceOffset =
fixture.race === undefined ? null : builder.createString(fixture.race);
const voteForOffset =
fixture.myVoteFor === undefined
? null
: builder.createString(fixture.myVoteFor);
const totalPlanets =
(fixture.localPlanets ?? []).length +
@@ -270,6 +296,8 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
Report.addHeight(builder, fixture.mapHeight ?? 4000);
Report.addPlanetCount(builder, totalPlanets);
if (raceOffset !== null) Report.addRace(builder, raceOffset);
if (fixture.myVotes !== undefined) Report.addVotes(builder, fixture.myVotes);
if (voteForOffset !== null) Report.addVoteFor(builder, voteForOffset);
if (playerVec !== null) Report.addPlayer(builder, playerVec);
if (localVec !== null) Report.addLocalPlanet(builder, localVec);
if (otherVec !== null) Report.addOtherPlanet(builder, otherVec);