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
@@ -25,6 +25,7 @@ import type { Cache } from "../platform/store/index";
import type { GalaxyClient } from "../api/galaxy-client";
import { fetchOrder } from "./order-load";
import {
isRelation,
isShipGroupCargo,
isShipGroupUpgradeTech,
type CommandStatus,
@@ -193,6 +194,14 @@ export class OrderDraftStore {
* a newer entry for the same slot supersedes any prior
* `set` or `remove` for that slot. Different load-types or
* different sources coexist.
* - `setDiplomaticStance` collapses by `acceptor`: the engine
* tracks a single war/peace stance per opponent, so a newer
* entry supersedes any prior `setDiplomaticStance` for the
* same other race.
* - `setVoteRecipient` collapses singleton: per `rules.txt`
* each race controls a single vote slot, so a newer entry
* supersedes any prior `setVoteRecipient` regardless of the
* acceptor.
* - `planetRename` and `placeholder` append unconditionally;
* each rename is a distinct user-visible action.
*/
@@ -231,6 +240,29 @@ export class OrderDraftStore {
nextCommands.push(existing);
}
nextCommands.push(command);
} else if (command.kind === "setDiplomaticStance") {
nextCommands = [];
for (const existing of this.commands) {
if (
existing.kind === "setDiplomaticStance" &&
existing.acceptor === command.acceptor
) {
removed.push(existing.id);
continue;
}
nextCommands.push(existing);
}
nextCommands.push(command);
} else if (command.kind === "setVoteRecipient") {
nextCommands = [];
for (const existing of this.commands) {
if (existing.kind === "setVoteRecipient") {
removed.push(existing.id);
continue;
}
nextCommands.push(existing);
}
nextCommands.push(command);
} else {
nextCommands = [...this.commands, command];
}
@@ -602,6 +634,25 @@ function validateCommand(cmd: OrderCommand): CommandStatus {
if (!validateEntityName(cmd.name).ok) return "invalid";
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "setDiplomaticStance":
// `acceptor` is the opponent's race name; race names follow
// the same entity-name rules as planet/fleet names. The
// races-table view restricts the per-row picker to live
// `GameReport.races[]` entries, so a locally-valid name is
// always a real race. `relation` must be one of the two
// wire-stable values (`WAR` or `PEACE`); the FBS
// `UNKNOWN = 0` sentinel is never emitted.
if (!validateEntityName(cmd.acceptor).ok) return "invalid";
if (!isRelation(cmd.relation)) return "invalid";
return "valid";
case "setVoteRecipient":
// `acceptor` is the race the local player votes for. The
// engine accepts a self-vote as the neutral default
// (`controller/race.go`), so the table picker may include
// the local race as a valid choice. Local validation only
// guards the name shape.
if (!validateEntityName(cmd.acceptor).ok) return "invalid";
return "valid";
case "placeholder":
// Phase 12 placeholder entries are content-free and never
// transition out of `draft` — they are not submittable.