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
+41 -2
View File
@@ -23,6 +23,7 @@ import type {
ReportIncomingShipGroup,
ReportLocalFleet,
ReportLocalShipGroup,
ReportOtherRace,
ReportOtherShipGroup,
ReportPlanet,
ReportRoute,
@@ -31,8 +32,8 @@ import type {
ShipClassSummary,
ShipGroupTech,
} from "./game-state";
import type { CargoLoadType } from "../sync/order-types";
import { isCargoLoadType } from "../sync/order-types";
import type { CargoLoadType, Relation } from "../sync/order-types";
import { isCargoLoadType, isRelation } from "../sync/order-types";
export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-";
@@ -103,6 +104,11 @@ interface SyntheticPlayer {
weapons: number;
shields: number;
cargo: number;
population?: number;
industry?: number;
planets?: number;
relation?: string;
votes?: number;
extinct?: boolean;
}
@@ -159,6 +165,8 @@ interface SyntheticReportRoot {
mapHeight?: number;
mapPlanets?: number;
race?: string;
votes?: number;
voteFor?: string;
player?: SyntheticPlayer[];
localPlanet?: SyntheticPlanet[];
otherPlanet?: SyntheticPlanet[];
@@ -290,6 +298,9 @@ function decodeSyntheticReport(json: unknown): GameReport {
unidentifiedShipGroups,
localFleets,
otherRaces: collectOtherRacesFromSynthetic(root, race),
races: collectOtherRaceRowsFromSynthetic(root, race),
myVotes: numOr0(root.votes),
myVoteFor: typeof root.voteFor === "string" ? root.voteFor : "",
};
}
@@ -308,6 +319,34 @@ function collectOtherRacesFromSynthetic(
return out;
}
function collectOtherRaceRowsFromSynthetic(
root: SyntheticReportRoot,
raceName: string,
): ReportOtherRace[] {
const out: ReportOtherRace[] = [];
for (const player of root.player ?? []) {
if (player.extinct === true) continue;
const name = typeof player.name === "string" ? player.name : "";
if (name === "" || name === raceName) continue;
const wire = typeof player.relation === "string" ? player.relation : "";
const relation: Relation = isRelation(wire) ? wire : "PEACE";
out.push({
name,
drive: numOr0(player.drive),
weapons: numOr0(player.weapons),
shields: numOr0(player.shields),
cargo: numOr0(player.cargo),
population: numOr0(player.population),
industry: numOr0(player.industry),
planets: Math.trunc(numOr0(player.planets)),
relation,
votesReceived: numOr0(player.votes),
});
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function toShipGroupTech(raw: Record<string, number> | undefined): ShipGroupTech {
const out: ShipGroupTech = { drive: 0, weapons: 0, shields: 0, cargo: 0 };
if (raw === undefined || raw === null) return out;