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
+148 -7
View File
@@ -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,
};
}
+41 -2
View File
@@ -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;
@@ -0,0 +1,453 @@
<!--
Phase 22 races table. Lists every non-extinct other race with the
local player's per-row stance toggle (WAR / PEACE — two segmented
buttons, the active stance highlighted) and a single vote slot
above the table. Both controls dispatch through the per-game
`OrderDraftStore` (context), so the optimistic overlay flips
immediately and the auto-sync pipeline drives the server in the
background.
The alliance graph and the 2/3 victory check are NOT computed
here: `rules.txt` keeps each race's outgoing vote target private
(only the votes a race RECEIVED in the last tally and the local
player's own pick are observable), and the acceptance criterion
"vote counts match server state byte-for-byte" rules out
client-side recomputation. The sub-header explains this explicitly
so the player knows where the win condition lives.
The component sits inside the active-view slot owned by
`routes/games/[id]/+layout.svelte`, so it inherits the per-game
`OrderDraftStore` and `RenderedReportSource` through context. No
data fetching is performed here — the layout is responsible.
-->
<script lang="ts">
import { getContext } from "svelte";
import type { ReportOtherRace } from "../../api/game-state";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
RENDERED_REPORT_CONTEXT_KEY,
type RenderedReportSource,
} from "$lib/rendered-report.svelte";
import {
ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore,
} from "../../sync/order-draft.svelte";
import type { Relation } from "../../sync/order-types";
type SortColumn =
| "name"
| "drive"
| "weapons"
| "shields"
| "cargo"
| "population"
| "industry"
| "planets"
| "votesReceived";
type SortDirection = "asc" | "desc";
const COLUMN_LABELS: Record<SortColumn, TranslationKey> = {
name: "game.table.races.column.name",
drive: "game.table.races.column.drive",
weapons: "game.table.races.column.weapons",
shields: "game.table.races.column.shields",
cargo: "game.table.races.column.cargo",
population: "game.table.races.column.population",
industry: "game.table.races.column.industry",
planets: "game.table.races.column.planets",
votesReceived: "game.table.races.column.votes",
};
const COLUMNS: readonly SortColumn[] = [
"name",
"drive",
"weapons",
"shields",
"cargo",
"population",
"industry",
"planets",
"votesReceived",
];
const rendered = getContext<RenderedReportSource | undefined>(
RENDERED_REPORT_CONTEXT_KEY,
);
const draft = getContext<OrderDraftStore | undefined>(
ORDER_DRAFT_CONTEXT_KEY,
);
let sortColumn: SortColumn = $state("name");
let sortDirection: SortDirection = $state("asc");
let filter: string = $state("");
const races = $derived<ReportOtherRace[]>(rendered?.report?.races ?? []);
const myVotes = $derived<number>(rendered?.report?.myVotes ?? 0);
const myVoteFor = $derived<string>(rendered?.report?.myVoteFor ?? "");
const reportLoaded = $derived(
rendered?.report !== null && rendered?.report !== undefined,
);
const filtered = $derived.by(() => {
const needle = filter.trim().toLowerCase();
if (needle === "") return races;
return races.filter((r) => r.name.toLowerCase().includes(needle));
});
const sorted = $derived.by(() => {
const list = [...filtered];
const dir = sortDirection === "asc" ? 1 : -1;
list.sort((a, b) => {
if (sortColumn === "name") {
return a.name.localeCompare(b.name) * dir;
}
return (a[sortColumn] - b[sortColumn]) * dir;
});
return list;
});
function toggleSort(column: SortColumn): void {
if (sortColumn === column) {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
return;
}
sortColumn = column;
sortDirection = "asc";
}
function ariaSort(column: SortColumn): "ascending" | "descending" | "none" {
if (sortColumn !== column) return "none";
return sortDirection === "asc" ? "ascending" : "descending";
}
// Render a fraction in `[0, 1]` as a one-decimal percent
// (`0.225` → `"22.5"`). The conversion is value-only — no `%`
// suffix — so the column header carries the unit. Matches the
// sciences-table convention.
function formatPercent(fraction: number): string {
return (fraction * 100).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
}
function formatCount(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
function formatVotes(value: number): string {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
}
async function setStance(acceptor: string, relation: Relation): Promise<void> {
if (draft === undefined) return;
await draft.add({
kind: "setDiplomaticStance",
id: crypto.randomUUID(),
acceptor,
relation,
});
}
async function pickVote(event: Event): Promise<void> {
if (draft === undefined) return;
const select = event.currentTarget as HTMLSelectElement;
const acceptor = select.value;
if (acceptor === "") return;
if (acceptor === myVoteFor) return;
await draft.add({
kind: "setVoteRecipient",
id: crypto.randomUUID(),
acceptor,
});
}
</script>
<section
class="active-view"
data-testid="active-view-table"
data-entity="races"
>
<header>
<h2>{i18n.t("game.table.races.title")}</h2>
<div class="summary">
<span class="summary-cell" data-testid="races-my-votes">
<span class="summary-label">
{i18n.t("game.table.races.votes.mine")}:
</span>
<span class="summary-value">{formatVotes(myVotes)}</span>
</span>
<label class="summary-cell vote-picker">
<span class="summary-label">
{i18n.t("game.table.races.votes.target")}:
</span>
<select
data-testid="races-vote-target"
value={myVoteFor}
disabled={!reportLoaded || races.length === 0}
onchange={pickVote}
>
<option value="" disabled>
{i18n.t("game.table.races.votes.target_placeholder")}
</option>
{#each races as r (r.name)}
<option value={r.name}>{r.name}</option>
{/each}
</select>
</label>
</div>
<p class="note" data-testid="races-alliance-note">
{i18n.t("game.table.races.note.alliance_server_side")}
</p>
<div class="controls">
<input
type="search"
class="filter"
data-testid="races-filter"
placeholder={i18n.t("game.table.races.filter.placeholder")}
bind:value={filter}
/>
</div>
</header>
{#if !reportLoaded}
<p class="status" data-testid="races-loading">
{i18n.t("game.table.races.loading")}
</p>
{:else if races.length === 0}
<p class="status" data-testid="races-empty">
{i18n.t("game.table.races.empty")}
</p>
{:else}
<table class="grid" data-testid="races-table">
<thead>
<tr>
{#each COLUMNS as column (column)}
<th aria-sort={ariaSort(column)}>
<button
type="button"
class="sort"
data-testid="races-column-{column}"
onclick={() => toggleSort(column)}
>
{i18n.t(COLUMN_LABELS[column])}
{#if sortColumn === column}
<span class="sort-indicator" aria-hidden="true">
{sortDirection === "asc" ? "▲" : "▼"}
</span>
{/if}
</button>
</th>
{/each}
<th>{i18n.t("game.table.races.column.relation")}</th>
</tr>
</thead>
<tbody>
{#each sorted as r (r.name)}
<tr data-testid="races-row" data-name={r.name}>
<td data-testid="races-cell-name">{r.name}</td>
<td data-testid="races-cell-drive">{formatPercent(r.drive)}</td>
<td data-testid="races-cell-weapons">
{formatPercent(r.weapons)}
</td>
<td data-testid="races-cell-shields">
{formatPercent(r.shields)}
</td>
<td data-testid="races-cell-cargo">{formatPercent(r.cargo)}</td>
<td data-testid="races-cell-population">
{formatCount(r.population)}
</td>
<td data-testid="races-cell-industry">
{formatCount(r.industry)}
</td>
<td data-testid="races-cell-planets">{formatCount(r.planets)}</td>
<td data-testid="races-cell-votes">
{formatVotes(r.votesReceived)}
</td>
<td>
<div
class="stance"
role="group"
aria-label={i18n.t("game.table.races.column.relation")}
>
<button
type="button"
class="stance-button war"
class:active={r.relation === "WAR"}
aria-pressed={r.relation === "WAR"}
data-testid="races-stance-war"
onclick={() => void setStance(r.name, "WAR")}
>
{i18n.t("game.table.races.action.war")}
</button>
<button
type="button"
class="stance-button peace"
class:active={r.relation === "PEACE"}
aria-pressed={r.relation === "PEACE"}
data-testid="races-stance-peace"
onclick={() => void setStance(r.name, "PEACE")}
>
{i18n.t("game.table.races.action.peace")}
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 1rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
header h2 {
margin: 0;
font-size: 1.1rem;
}
.summary {
display: flex;
gap: 1.25rem;
align-items: center;
flex-wrap: wrap;
font-size: 0.9rem;
}
.summary-cell {
display: inline-flex;
gap: 0.4rem;
align-items: baseline;
}
.summary-label {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.summary-value {
color: #e8eaf6;
font-variant-numeric: tabular-nums;
}
.vote-picker select {
font: inherit;
padding: 0.2rem 0.4rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
}
.vote-picker select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.note {
margin: 0;
color: #889;
font-size: 0.8rem;
line-height: 1.35;
}
.controls {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.filter {
font: inherit;
padding: 0.3rem 0.5rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
flex: 1 1 12rem;
min-width: 8rem;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
font-size: 0.9rem;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
.sort {
font: inherit;
font-size: inherit;
text-transform: inherit;
letter-spacing: inherit;
color: inherit;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
display: inline-flex;
gap: 0.3rem;
align-items: baseline;
}
.sort-indicator {
font-size: 0.7em;
}
.stance {
display: inline-flex;
gap: 0.25rem;
}
.stance-button {
font: inherit;
font-size: 0.8rem;
letter-spacing: 0.05em;
padding: 0.2rem 0.55rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.stance-button:hover {
color: #e8eaf6;
}
.stance-button.war.active {
background: #4a1010;
color: #ffcaca;
border-color: #8a3030;
}
.stance-button.peace.active {
background: #103a1a;
color: #c8f2cf;
border-color: #2f7a45;
}
</style>
+10 -7
View File
@@ -1,17 +1,18 @@
<!--
Active-view router for the per-entity tables. Phase 17 lights up
the ship-classes table; Phase 21 lights up the sciences table; the
remaining slugs (planets, ship-groups, fleets, races) keep the
Phase 10 stub copy until their respective phases land. The wrapper
preserves `data-testid="active-view-table"` and
`data-entity={entity}` for every branch (each leaf component
mirrors them) so the navigation e2e specs (`game-shell.spec.ts`,
`view-menu`) keep matching.
the ship-classes table; Phase 21 lights up the sciences table;
Phase 22 lights up the races table; the remaining slugs (planets,
ship-groups, fleets) keep the Phase 10 stub copy until their
respective phases land. The wrapper preserves
`data-testid="active-view-table"` and `data-entity={entity}` for
every branch (each leaf component mirrors them) so the navigation
e2e specs (`game-shell.spec.ts`, `view-menu`) keep matching.
-->
<script lang="ts">
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import TableShipClasses from "./table-ship-classes.svelte";
import TableSciences from "./table-sciences.svelte";
import TableRaces from "./table-races.svelte";
type Props = { entity: string };
let { entity }: Props = $props();
@@ -26,6 +27,8 @@ mirrors them) so the navigation e2e specs (`game-shell.spec.ts`,
<TableShipClasses />
{:else if entity === "sciences"}
<TableSciences />
{:else if entity === "races"}
<TableRaces />
{:else}
<section
class="active-view"
+23
View File
@@ -204,6 +204,8 @@ const en = {
"game.sidebar.order.label.ship_group_dismantle": "dismantle group {group}",
"game.sidebar.order.label.ship_group_transfer": "transfer group {group} → {acceptor}",
"game.sidebar.order.label.ship_group_join_fleet": "assign group {group} → fleet {fleet}",
"game.sidebar.order.label.race_relation": "declare {relation} on {acceptor}",
"game.sidebar.order.label.race_vote": "give my votes to {acceptor}",
"game.table.ship_classes.title": "ship classes",
"game.table.ship_classes.column.name": "name",
"game.table.ship_classes.column.drive": "drive",
@@ -297,6 +299,27 @@ const en = {
"game.designer.science.invalid.cargo_value": "cargo % must be in [0, 100]",
"game.designer.science.invalid.sum_not_hundred": "the four percentages must sum to exactly 100",
"game.table.races.title": "races",
"game.table.races.loading": "loading races…",
"game.table.races.empty": "no other races known yet",
"game.table.races.filter.placeholder": "filter by name",
"game.table.races.column.name": "name",
"game.table.races.column.drive": "drive %",
"game.table.races.column.weapons": "weapons %",
"game.table.races.column.shields": "shields %",
"game.table.races.column.cargo": "cargo %",
"game.table.races.column.population": "population",
"game.table.races.column.industry": "production",
"game.table.races.column.planets": "planets",
"game.table.races.column.votes": "votes received",
"game.table.races.column.relation": "stance",
"game.table.races.action.war": "WAR",
"game.table.races.action.peace": "PEACE",
"game.table.races.votes.mine": "my votes",
"game.table.races.votes.target": "I vote for",
"game.table.races.votes.target_placeholder": "— select a race —",
"game.table.races.note.alliance_server_side": "alliances and the 2/3 victory are tallied by the server at turn cutoff; this table shows only my outgoing vote and the votes each race received in the last tally",
"game.inspector.ship_group.kind.local": "your group",
"game.inspector.ship_group.kind.other": "other race group",
"game.inspector.ship_group.kind.incoming": "incoming group",
+23
View File
@@ -205,6 +205,8 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.order.label.ship_group_dismantle": "разобрать группу {group}",
"game.sidebar.order.label.ship_group_transfer": "передать группу {group} → {acceptor}",
"game.sidebar.order.label.ship_group_join_fleet": "включить группу {group} → флот {fleet}",
"game.sidebar.order.label.race_relation": "объявить {relation} расе {acceptor}",
"game.sidebar.order.label.race_vote": "отдать голоса расе {acceptor}",
"game.table.ship_classes.title": "классы кораблей",
"game.table.ship_classes.column.name": "название",
"game.table.ship_classes.column.drive": "двигатель",
@@ -298,6 +300,27 @@ const ru: Record<keyof typeof en, string> = {
"game.designer.science.invalid.cargo_value": "трюм % должен быть в [0, 100]",
"game.designer.science.invalid.sum_not_hundred": "сумма четырёх процентов должна быть ровно 100",
"game.table.races.title": "расы",
"game.table.races.loading": "загрузка рас…",
"game.table.races.empty": "других рас пока не видно",
"game.table.races.filter.placeholder": "фильтр по имени",
"game.table.races.column.name": "имя",
"game.table.races.column.drive": "двигатель %",
"game.table.races.column.weapons": "оружие %",
"game.table.races.column.shields": "защита %",
"game.table.races.column.cargo": "трюм %",
"game.table.races.column.population": "население",
"game.table.races.column.industry": "производство",
"game.table.races.column.planets": "планет",
"game.table.races.column.votes": "получено голосов",
"game.table.races.column.relation": "отношение",
"game.table.races.action.war": "ВОЙНА",
"game.table.races.action.peace": "МИР",
"game.table.races.votes.mine": "мои голоса",
"game.table.races.votes.target": "голосую за",
"game.table.races.votes.target_placeholder": "— выберите расу —",
"game.table.races.note.alliance_server_side": "альянсы и победу 2/3 подсчитывает сервер при просчёте хода; в этой таблице видно лишь мой исходящий голос и количество голосов, полученных каждой расой в прошлой раздаче",
"game.inspector.ship_group.kind.local": "ваша группа",
"game.inspector.ship_group.kind.other": "группа другой расы",
"game.inspector.ship_group.kind.incoming": "входящая группа",
@@ -126,6 +126,15 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
group: shortGroupId(cmd.groupId),
fleet: cmd.name,
});
case "setDiplomaticStance":
return i18n.t("game.sidebar.order.label.race_relation", {
relation: cmd.relation,
acceptor: cmd.acceptor,
});
case "setVoteRecipient":
return i18n.t("game.sidebar.order.label.race_vote", {
acceptor: cmd.acceptor,
});
}
}
@@ -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[],