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.
+48
View File
@@ -16,6 +16,8 @@ import {
CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
CommandRaceRelation,
CommandRaceVote,
CommandScienceCreate,
CommandScienceRemove,
CommandShipClassCreate,
@@ -30,6 +32,7 @@ import {
CommandShipGroupUpgrade,
PlanetProduction,
PlanetRouteLoadType,
Relation,
ShipGroupCargo,
ShipGroupUpgradeTech,
UserGamesOrderGet,
@@ -39,6 +42,7 @@ import type {
CargoLoadType,
OrderCommand,
ProductionType,
Relation as RelationLiteral,
ShipGroupCargo as ShipGroupCargoLiteral,
ShipGroupUpgradeTech as ShipGroupUpgradeTechLiteral,
} from "./order-types";
@@ -354,6 +358,32 @@ function decodeCommand(item: CommandItemView): OrderCommand | null {
name: inner.name() ?? "",
};
}
case CommandPayload.CommandRaceRelation: {
const inner = new CommandRaceRelation();
item.payload(inner);
const relation = relationFromFBS(inner.relation());
if (relation === null) {
console.warn(
`fetchOrder: skipping CommandRaceRelation with unknown relation enum (${inner.relation()})`,
);
return null;
}
return {
kind: "setDiplomaticStance",
id,
acceptor: inner.acceptor() ?? "",
relation,
};
}
case CommandPayload.CommandRaceVote: {
const inner = new CommandRaceVote();
item.payload(inner);
return {
kind: "setVoteRecipient",
id,
acceptor: inner.acceptor() ?? "",
};
}
default:
console.warn(
`fetchOrder: skipping unknown command kind (payloadType=${payloadType})`,
@@ -469,6 +499,24 @@ export function shipGroupUpgradeTechFromFBS(
}
}
/**
* relationFromFBS reverses `relationToFBS` from `submit.ts`.
* `Relation.UNKNOWN` and any out-of-band value yield `null` so the
* caller drops the entry rather than fabricating a synthetic stance.
*/
export function relationFromFBS(value: Relation): RelationLiteral | null {
switch (value) {
case Relation.WAR:
return "WAR";
case Relation.PEACE:
return "PEACE";
case Relation.UNKNOWN:
return null;
default:
return null;
}
}
function decodeError(
payload: Uint8Array,
resultCode: string,
+73 -1
View File
@@ -409,6 +409,76 @@ export interface JoinFleetShipGroupCommand {
readonly name: string;
}
/**
* Relation mirrors the engine `Relation` enum
* (`pkg/schema/fbs/order.fbs`). Two wire-stable values: `WAR` (the
* local player declares hostilities toward the named race) and
* `PEACE` (the local player declares peaceful relations). The engine
* stores relations per-actor and asymmetrically — race A can be at
* war with race B while race B is at peace with race A
* (`game/internal/controller/race.go.UpdateRelation`). The FBS
* `UNKNOWN = 0` sentinel is never emitted by the client.
*/
export type Relation = "WAR" | "PEACE";
/**
* RELATION_VALUES is the canonical tuple of `Relation` literals.
* Used by validators and by the FBS converters in `submit.ts` and
* `order-load.ts` to narrow incoming strings.
*/
export const RELATION_VALUES = [
"WAR",
"PEACE",
] as const satisfies readonly Relation[];
/**
* isRelation narrows an arbitrary string to the `Relation` union.
* The decoder uses this when reading back a server-stored command
* whose `relation` arrived as a generic string.
*/
export function isRelation(value: string): value is Relation {
return (RELATION_VALUES as readonly string[]).includes(value);
}
/**
* SetDiplomaticStanceCommand declares the local player's relation
* (war or peace) toward another race. Mirrors the engine
* `CommandRaceRelation` (`pkg/schema/fbs/order.fbs`,
* `game/internal/controller/command.go.RaceRelation`). The relation
* is unilateral; the targeted race keeps its own opinion of us.
*
* Phase 22 carries a collapse-by-`acceptor` rule: a newer entry
* supersedes any prior `setDiplomaticStance` for the same opponent,
* so the draft holds at most one stance intent per other race.
*/
export interface SetDiplomaticStanceCommand {
readonly kind: "setDiplomaticStance";
readonly id: string;
readonly acceptor: string;
readonly relation: Relation;
}
/**
* SetVoteRecipientCommand binds the local player's single vote slot
* to a race. Mirrors the engine `CommandRaceVote`
* (`pkg/schema/fbs/order.fbs`,
* `game/internal/controller/command.go.RaceVote`). The engine
* tallies votes at turn cutoff (`rules.txt` "Процесс голосования");
* between turns the player can change their pick freely. The
* acceptor may be the local race itself — the engine treats
* self-vote as the neutral default and re-applies it whenever a
* voted-for race goes extinct (`controller/race.go`).
*
* Phase 22 carries a singleton collapse rule: a newer entry replaces
* any prior `setVoteRecipient`, regardless of target — the player
* has only one outgoing vote slot.
*/
export interface SetVoteRecipientCommand {
readonly kind: "setVoteRecipient";
readonly id: string;
readonly acceptor: string;
}
/**
* OrderCommand is the discriminated union of every command shape the
* local order draft can hold. The `kind` field is the discriminator;
@@ -432,7 +502,9 @@ export type OrderCommand =
| UpgradeShipGroupCommand
| DismantleShipGroupCommand
| TransferShipGroupCommand
| JoinFleetShipGroupCommand;
| JoinFleetShipGroupCommand
| SetDiplomaticStanceCommand
| SetVoteRecipientCommand;
/**
* PRODUCTION_TYPE_VALUES is the canonical tuple of `ProductionType`
+42
View File
@@ -31,6 +31,8 @@ import {
CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
CommandRaceRelation,
CommandRaceVote,
CommandScienceCreate,
CommandScienceRemove,
CommandShipClassCreate,
@@ -45,6 +47,7 @@ import {
CommandShipGroupUpgrade,
PlanetProduction,
PlanetRouteLoadType,
Relation,
ShipGroupCargo,
ShipGroupUpgradeTech,
UserGamesOrder,
@@ -54,6 +57,7 @@ import type {
CargoLoadType,
OrderCommand,
ProductionType,
Relation as RelationLiteral,
ShipGroupCargo as ShipGroupCargoLiteral,
ShipGroupUpgradeTech as ShipGroupUpgradeTechLiteral,
} from "./order-types";
@@ -365,6 +369,29 @@ function encodeCommandPayload(
payloadOffset: offset,
};
}
case "setDiplomaticStance": {
const acceptorOffset = builder.createString(cmd.acceptor);
const offset = CommandRaceRelation.createCommandRaceRelation(
builder,
acceptorOffset,
relationToFBS(cmd.relation),
);
return {
payloadType: CommandPayload.CommandRaceRelation,
payloadOffset: offset,
};
}
case "setVoteRecipient": {
const acceptorOffset = builder.createString(cmd.acceptor);
const offset = CommandRaceVote.createCommandRaceVote(
builder,
acceptorOffset,
);
return {
payloadType: CommandPayload.CommandRaceVote,
payloadOffset: offset,
};
}
case "placeholder":
throw new SubmitError(
"invalid_request",
@@ -463,6 +490,21 @@ export function shipGroupUpgradeTechToFBS(
}
}
/**
* relationToFBS converts the wire-stable `Relation` literal to the
* FlatBuffers enum value. Mirrors `pkg/transcoder/order.go`. The FBS
* enum carries an `UNKNOWN` zero default; the encoder always emits
* one of the two real values (`WAR` or `PEACE`).
*/
export function relationToFBS(value: RelationLiteral): Relation {
switch (value) {
case "WAR":
return Relation.WAR;
case "PEACE":
return Relation.PEACE;
}
}
function decodeOrderResponse(
payload: Uint8Array,
commands: OrderCommand[],