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
+148 -7
View File
@@ -51,8 +51,13 @@ import type {
CommandStatus,
OrderCommand,
ProductionType,
Relation,
} from "../sync/order-types";
import {
CARGO_LOAD_TYPE_VALUES,
isCargoLoadType,
isRelation,
} from "../sync/order-types";
import { CARGO_LOAD_TYPE_VALUES, isCargoLoadType } from "../sync/order-types";
const MESSAGE_TYPE = "user.games.report";
@@ -239,6 +244,42 @@ export interface ReportLocalFleet {
state: string;
}
/**
* ReportOtherRace is the per-other-race projection rendered by the
* Phase 22 Races View. The fields mirror `report.fbs:Player` row-by-
* row, with `relation` narrowed to the wire-stable `Relation` union
* (the engine emits a `"-"` sentinel for the self row, which never
* appears in `GameReport.races` because self is filtered out by
* `decodeReport`). Tech values are float fractions — the table
* renders them through the same `formatPercent` helper the sciences
* table uses.
*
* `relation` reflects the local player's stance TOWARD this race,
* not the other way around (`rules.txt` line 1162). Per the engine
* (`controller/race.go.UpdateRelation`) the relation is stored
* unilaterally — race A can be at war with race B while race B is
* at peace with race A.
*
* `votesReceived` is the count of votes this race received in the
* last turn cutoff tally (`Player.votes` on the wire). The total
* game votes equal the sum of every non-extinct row's
* `votesReceived`, since every race always votes for someone
* (`controller/race.go` initialises `r.VoteFor = r.ID` on creation
* and reassigns to self on extinction of the voted-for race).
*/
export interface ReportOtherRace {
name: string;
drive: number;
weapons: number;
shields: number;
cargo: number;
population: number;
industry: number;
planets: number;
relation: Relation;
votesReceived: number;
}
export interface GameReport {
turn: number;
mapWidth: number;
@@ -314,12 +355,40 @@ export interface GameReport {
* `report.player[]` block in the FBS report (each `Player` row
* carries an `extinct` flag). The ship-group inspector consumes
* this list for the "transfer to race" picker; Phase 22's Races
* View reuses the same field so the read shape is stable across
* stages. Empty when the report has no `player` block (boot
* state, history-mode snapshots) or when the local player is the
* only non-extinct race.
* View also uses it for the vote-recipient picker so the read
* shape stays stable across stages. Empty when the report has no
* `player` block (boot state, history-mode snapshots) or when the
* local player is the only non-extinct race.
*/
otherRaces: string[];
/**
* races is the richer per-other-race projection Phase 22 added
* for the Races View table — same population (non-extinct, self
* excluded, alphabetical) as `otherRaces`, but with each row
* carrying tech levels, totals, planet count, the local player's
* stance toward that race, and the race's votes received. Rows
* with an unknown wire `relation` (anything other than `WAR` or
* `PEACE`) default to `PEACE` so the table never blanks out the
* toggle on an engine schema bump; the same row continues to
* appear in the table.
*/
races: ReportOtherRace[];
/**
* myVotes is the local player's total vote weight in the current
* report, read from `Report.votes` (the engine assigns one vote
* per 1000 population, see `rules.txt:1060`). Zero when the
* report has not been produced yet.
*/
myVotes: number;
/**
* myVoteFor is the race the local player currently votes for,
* read from `Report.vote_for`. Empty string when no value has
* been recorded yet (boot state) or when the engine emitted an
* empty string. The engine's default initial state is each race
* voting for itself (`controller/race.go`), so a stable game's
* report always carries a non-empty value.
*/
myVoteFor: string;
}
export async function fetchGameReport(
@@ -467,6 +536,7 @@ function decodeReport(report: Report): GameReport {
const routes = decodeReportRoutes(report);
const localTech = findLocalPlayerTech(report, raceName);
const otherRaces = collectOtherRaces(report, raceName);
const races = collectOtherRaceRows(report, raceName);
const localShipGroups = decodeLocalShipGroups(report);
const otherShipGroups = decodeOtherShipGroups(report);
const incomingShipGroups = decodeIncomingShipGroups(report);
@@ -493,6 +563,9 @@ function decodeReport(report: Report): GameReport {
unidentifiedShipGroups,
localFleets,
otherRaces,
races,
myVotes: report.votes(),
myVoteFor: report.voteFor() ?? "",
};
}
@@ -774,7 +847,7 @@ function findLocalPlayerTech(
* the alphabetically-sorted names of every non-extinct race other
* than the local player. Used by `GameReport.otherRaces` to back the
* ship-group inspector's transfer-to-race picker (Phase 20) and the
* Races View list (Phase 22).
* Races View vote-recipient picker (Phase 22).
*/
function collectOtherRaces(report: Report, raceName: string): string[] {
const out: string[] = [];
@@ -790,6 +863,46 @@ function collectOtherRaces(report: Report, raceName: string): string[] {
return out;
}
/**
* collectOtherRaceRows walks the `report.player[]` block and returns
* the richer per-race projection consumed by the Phase 22 Races
* View. Same filter as `collectOtherRaces` (non-extinct, named,
* self excluded), same alphabetical sort. The engine emits
* `Player.relation = "-"` on the self row only — that row is
* filtered out, so a non-`"WAR"`/`"PEACE"` value here would mean a
* schema bump; we fall back to `"PEACE"` and keep the row visible
* rather than dropping it silently.
*/
function collectOtherRaceRows(
report: Report,
raceName: string,
): ReportOtherRace[] {
const out: ReportOtherRace[] = [];
for (let i = 0; i < report.playerLength(); i++) {
const player = report.player(i);
if (player === null) continue;
if (player.extinct()) continue;
const name = player.name() ?? "";
if (name === "" || name === raceName) continue;
const wire = player.relation() ?? "";
const relation: Relation = isRelation(wire) ? wire : "PEACE";
out.push({
name,
drive: player.drive(),
weapons: player.weapons(),
shields: player.shields(),
cargo: player.cargo(),
population: player.population(),
industry: player.industry(),
planets: player.planets(),
relation,
votesReceived: player.votes(),
});
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
/**
* uuidToHiLo splits the canonical 36-character UUID string
* (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian
@@ -841,6 +954,8 @@ export function applyOrderOverlay(
let mutatedRoutes: ReportRoute[] | null = null;
let mutatedShipClass: ShipClassSummary[] | null = null;
let mutatedScience: ScienceSummary[] | null = null;
let mutatedRaces: ReportOtherRace[] | null = null;
let mutatedVoteFor: string | null = null;
for (const cmd of commands) {
const status = statuses[cmd.id];
if (
@@ -964,12 +1079,36 @@ export function applyOrderOverlay(
mutatedScience.splice(idx, 1);
continue;
}
if (cmd.kind === "setDiplomaticStance") {
if (mutatedRaces === null) {
// `?? []` mirrors the per-branch HMR guard pattern: a
// running `gameState.report` produced before Phase 22's
// shape bump may not carry `races` yet — preserve a
// well-defined array on the way out so downstream
// `$derived` blocks (`races.map`, `races.find`, …)
// never fault on `undefined`.
mutatedRaces = [...(report.races ?? [])];
}
const idx = mutatedRaces.findIndex((r) => r.name === cmd.acceptor);
if (idx < 0) continue;
mutatedRaces[idx] = {
...mutatedRaces[idx]!,
relation: cmd.relation,
};
continue;
}
if (cmd.kind === "setVoteRecipient") {
mutatedVoteFor = cmd.acceptor;
continue;
}
}
if (
mutatedPlanets === null &&
mutatedRoutes === null &&
mutatedShipClass === null &&
mutatedScience === null
mutatedScience === null &&
mutatedRaces === null &&
mutatedVoteFor === null
) {
return report;
}
@@ -984,6 +1123,8 @@ export function applyOrderOverlay(
// `localScience.find`, …) fault and the active view blanks.
localShipClass: mutatedShipClass ?? report.localShipClass ?? [],
localScience: mutatedScience ?? report.localScience ?? [],
races: mutatedRaces ?? report.races ?? [],
myVoteFor: mutatedVoteFor ?? report.myVoteFor,
};
}
+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;