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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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[],
|
||||
|
||||
Reference in New Issue
Block a user