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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user