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