ui: plan 01-27 done #1
+64
-24
@@ -2391,44 +2391,84 @@ Targeted tests:
|
|||||||
via the Research sub-row, delete it
|
via the Research sub-row, delete it
|
||||||
(`tests/e2e/sciences.spec.ts`).
|
(`tests/e2e/sciences.spec.ts`).
|
||||||
|
|
||||||
## Phase 22. Races View — War/Peace Toggle and Votes
|
## ~~Phase 22. Races View — War/Peace Toggle and Votes~~
|
||||||
|
|
||||||
Status: pending.
|
Status: done.
|
||||||
|
|
||||||
Goal: list other races with their visible stats, expose war/peace
|
Goal: list other races with their visible stats, expose the war/peace
|
||||||
toggle and the voting UI.
|
toggle, and the voting UI.
|
||||||
|
|
||||||
Artifacts:
|
Artifacts:
|
||||||
|
|
||||||
- `ui/frontend/src/routes/games/[id]/table/races/+page.svelte` table
|
- `ui/frontend/src/lib/active-view/table-races.svelte` table mounted
|
||||||
with one row per race, including name, tech levels, total
|
by the dispatcher in
|
||||||
population, total production, planet count, war-or-peace from this
|
`ui/frontend/src/lib/active-view/table.svelte` (same pattern as
|
||||||
race's perspective, votes received. The race list itself is read
|
Phase 21's sciences table). One row per non-extinct other race
|
||||||
from `GameReport.otherRaces` (introduced in Phase 20 for the
|
carrying name, tech levels (drive / weapons / shields / cargo as
|
||||||
ship-group transfer-to-race picker); the table view widens the
|
percent), total population, total production (engine `industry`),
|
||||||
per-race shape (tech / population / production / planet count /
|
planet count, votes received, and the local player's stance
|
||||||
votes / relation) by walking `report.player[]` directly when those
|
toward that race. The richer per-race projection
|
||||||
fields are needed
|
(`GameReport.races: ReportOtherRace[]`) is decoded in
|
||||||
- per-row toggle for declaring war or peace (adds
|
`ui/frontend/src/api/game-state.ts` by walking `report.player[]`
|
||||||
`SetDiplomaticStance` command)
|
once and surfacing the row alongside the existing `otherRaces:
|
||||||
- voting control: a single slot for `give my votes to <race>` (adds
|
string[]` (which keeps backing the ship-group transfer picker from
|
||||||
`SetVoteRecipient` command)
|
Phase 20)
|
||||||
- alliance summary panel showing the current vote graph and any
|
- per-row segmented `WAR | PEACE` control. The active stance is
|
||||||
alliance reaching ≥ 2/3 of total votes
|
highlighted (`aria-pressed=true` + contrast colour); the inactive
|
||||||
|
button queues `setDiplomaticStance` (engine `CommandRaceRelation`).
|
||||||
|
The displayed stance is the local player's relation toward the
|
||||||
|
named race (`rules.txt` "(R) Ваше отношение к указанной расе, но
|
||||||
|
не наоборот") — not the other way round
|
||||||
|
- voting control: a single `<select>` populated with `races[].name`,
|
||||||
|
changing it queues `setVoteRecipient` (engine `CommandRaceVote`).
|
||||||
|
Disabled when the local player is the only non-extinct race. A
|
||||||
|
read-only `myVotes` total renders next to the picker
|
||||||
|
- explanatory note in the page header: alliance grouping and the 2/3
|
||||||
|
victory check are tallied on the server at turn cutoff and are
|
||||||
|
NOT projected on the client. The report carries each race's votes
|
||||||
|
received (`Player.votes`) and the local player's outgoing vote
|
||||||
|
(`Report.vote_for`), but foreign races' outgoing votes are
|
||||||
|
intentionally private, so a client-side vote graph would be
|
||||||
|
partial. The acceptance criterion "vote counts match server state
|
||||||
|
byte-for-byte" forbids a local recomputation
|
||||||
|
|
||||||
|
Cross-stack notes:
|
||||||
|
|
||||||
|
- No backend / wire changes. `CommandRaceRelation`,
|
||||||
|
`CommandRaceVote`, `Player.relation`, `Player.votes`,
|
||||||
|
`Report.votes`, and `Report.vote_for` already carry every datum
|
||||||
|
this stage needs
|
||||||
|
- TS draft store
|
||||||
|
(`ui/frontend/src/sync/order-draft.svelte.ts`) gains two collapse
|
||||||
|
rules: `setDiplomaticStance` collapses by `acceptor` (one stance
|
||||||
|
intent per opponent); `setVoteRecipient` collapses singleton (a
|
||||||
|
single outgoing vote slot per `rules.txt:1066`)
|
||||||
|
- The optimistic overlay (`applyOrderOverlay`) flips
|
||||||
|
`races[i].relation` and `myVoteFor` immediately so the controls
|
||||||
|
reflect the queued intent without waiting for the auto-sync
|
||||||
|
round-trip. `votesReceived`, `myVotes`, and the alliance state
|
||||||
|
stay server-authoritative
|
||||||
|
|
||||||
Dependencies: Phase 14.
|
Dependencies: Phase 14.
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
||||||
- the user can toggle war / peace and change vote recipient;
|
- the user can toggle war / peace and change vote recipient;
|
||||||
- the alliance summary updates after a server roundtrip;
|
- the per-row stance and the "I vote for" picker reflect the
|
||||||
- vote counts match server state byte-for-byte.
|
queued intent immediately (optimistic overlay) and resolve to
|
||||||
|
`applied` in the sidebar order tab after the auto-sync round-trip;
|
||||||
|
- vote counts match server state byte-for-byte (no client tally).
|
||||||
|
|
||||||
Targeted tests:
|
Targeted tests:
|
||||||
|
|
||||||
- Vitest component tests for the alliance summary on canonical fixtures
|
- Vitest component test
|
||||||
(chain of votes, fork, win condition);
|
(`ui/frontend/tests/table-races.test.ts`) covering: render rows
|
||||||
- Playwright e2e: change diplomatic stance and vote, submit, confirm.
|
from a canonical fixture, filter, sort flip, stance click +
|
||||||
|
collapse-by-acceptor, vote pick + singleton collapse, empty state;
|
||||||
|
- Playwright e2e (`ui/frontend/tests/e2e/races.spec.ts`): open the
|
||||||
|
races table, toggle one row's stance, change the vote recipient,
|
||||||
|
observe both commands as `applied` in the sidebar order tab and
|
||||||
|
verify the decoded gateway payload.
|
||||||
|
|
||||||
## Phase 23. Reports View — Current Turn Sections
|
## Phase 23. Reports View — Current Turn Sections
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,13 @@ import type {
|
|||||||
CommandStatus,
|
CommandStatus,
|
||||||
OrderCommand,
|
OrderCommand,
|
||||||
ProductionType,
|
ProductionType,
|
||||||
|
Relation,
|
||||||
|
} from "../sync/order-types";
|
||||||
|
import {
|
||||||
|
CARGO_LOAD_TYPE_VALUES,
|
||||||
|
isCargoLoadType,
|
||||||
|
isRelation,
|
||||||
} from "../sync/order-types";
|
} from "../sync/order-types";
|
||||||
import { CARGO_LOAD_TYPE_VALUES, isCargoLoadType } from "../sync/order-types";
|
|
||||||
|
|
||||||
const MESSAGE_TYPE = "user.games.report";
|
const MESSAGE_TYPE = "user.games.report";
|
||||||
|
|
||||||
@@ -239,6 +244,42 @@ export interface ReportLocalFleet {
|
|||||||
state: string;
|
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 {
|
export interface GameReport {
|
||||||
turn: number;
|
turn: number;
|
||||||
mapWidth: number;
|
mapWidth: number;
|
||||||
@@ -314,12 +355,40 @@ export interface GameReport {
|
|||||||
* `report.player[]` block in the FBS report (each `Player` row
|
* `report.player[]` block in the FBS report (each `Player` row
|
||||||
* carries an `extinct` flag). The ship-group inspector consumes
|
* carries an `extinct` flag). The ship-group inspector consumes
|
||||||
* this list for the "transfer to race" picker; Phase 22's Races
|
* this list for the "transfer to race" picker; Phase 22's Races
|
||||||
* View reuses the same field so the read shape is stable across
|
* View also uses it for the vote-recipient picker so the read
|
||||||
* stages. Empty when the report has no `player` block (boot
|
* shape stays stable across stages. Empty when the report has no
|
||||||
* state, history-mode snapshots) or when the local player is the
|
* `player` block (boot state, history-mode snapshots) or when the
|
||||||
* only non-extinct race.
|
* local player is the only non-extinct race.
|
||||||
*/
|
*/
|
||||||
otherRaces: string[];
|
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(
|
export async function fetchGameReport(
|
||||||
@@ -467,6 +536,7 @@ function decodeReport(report: Report): GameReport {
|
|||||||
const routes = decodeReportRoutes(report);
|
const routes = decodeReportRoutes(report);
|
||||||
const localTech = findLocalPlayerTech(report, raceName);
|
const localTech = findLocalPlayerTech(report, raceName);
|
||||||
const otherRaces = collectOtherRaces(report, raceName);
|
const otherRaces = collectOtherRaces(report, raceName);
|
||||||
|
const races = collectOtherRaceRows(report, raceName);
|
||||||
const localShipGroups = decodeLocalShipGroups(report);
|
const localShipGroups = decodeLocalShipGroups(report);
|
||||||
const otherShipGroups = decodeOtherShipGroups(report);
|
const otherShipGroups = decodeOtherShipGroups(report);
|
||||||
const incomingShipGroups = decodeIncomingShipGroups(report);
|
const incomingShipGroups = decodeIncomingShipGroups(report);
|
||||||
@@ -493,6 +563,9 @@ function decodeReport(report: Report): GameReport {
|
|||||||
unidentifiedShipGroups,
|
unidentifiedShipGroups,
|
||||||
localFleets,
|
localFleets,
|
||||||
otherRaces,
|
otherRaces,
|
||||||
|
races,
|
||||||
|
myVotes: report.votes(),
|
||||||
|
myVoteFor: report.voteFor() ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,7 +847,7 @@ function findLocalPlayerTech(
|
|||||||
* the alphabetically-sorted names of every non-extinct race other
|
* the alphabetically-sorted names of every non-extinct race other
|
||||||
* than the local player. Used by `GameReport.otherRaces` to back the
|
* than the local player. Used by `GameReport.otherRaces` to back the
|
||||||
* ship-group inspector's transfer-to-race picker (Phase 20) and 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[] {
|
function collectOtherRaces(report: Report, raceName: string): string[] {
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
@@ -790,6 +863,46 @@ function collectOtherRaces(report: Report, raceName: string): string[] {
|
|||||||
return out;
|
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
|
* uuidToHiLo splits the canonical 36-character UUID string
|
||||||
* (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian
|
* (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian
|
||||||
@@ -841,6 +954,8 @@ export function applyOrderOverlay(
|
|||||||
let mutatedRoutes: ReportRoute[] | null = null;
|
let mutatedRoutes: ReportRoute[] | null = null;
|
||||||
let mutatedShipClass: ShipClassSummary[] | null = null;
|
let mutatedShipClass: ShipClassSummary[] | null = null;
|
||||||
let mutatedScience: ScienceSummary[] | null = null;
|
let mutatedScience: ScienceSummary[] | null = null;
|
||||||
|
let mutatedRaces: ReportOtherRace[] | null = null;
|
||||||
|
let mutatedVoteFor: string | null = null;
|
||||||
for (const cmd of commands) {
|
for (const cmd of commands) {
|
||||||
const status = statuses[cmd.id];
|
const status = statuses[cmd.id];
|
||||||
if (
|
if (
|
||||||
@@ -964,12 +1079,36 @@ export function applyOrderOverlay(
|
|||||||
mutatedScience.splice(idx, 1);
|
mutatedScience.splice(idx, 1);
|
||||||
continue;
|
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 (
|
if (
|
||||||
mutatedPlanets === null &&
|
mutatedPlanets === null &&
|
||||||
mutatedRoutes === null &&
|
mutatedRoutes === null &&
|
||||||
mutatedShipClass === null &&
|
mutatedShipClass === null &&
|
||||||
mutatedScience === null
|
mutatedScience === null &&
|
||||||
|
mutatedRaces === null &&
|
||||||
|
mutatedVoteFor === null
|
||||||
) {
|
) {
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
@@ -984,6 +1123,8 @@ export function applyOrderOverlay(
|
|||||||
// `localScience.find`, …) fault and the active view blanks.
|
// `localScience.find`, …) fault and the active view blanks.
|
||||||
localShipClass: mutatedShipClass ?? report.localShipClass ?? [],
|
localShipClass: mutatedShipClass ?? report.localShipClass ?? [],
|
||||||
localScience: mutatedScience ?? report.localScience ?? [],
|
localScience: mutatedScience ?? report.localScience ?? [],
|
||||||
|
races: mutatedRaces ?? report.races ?? [],
|
||||||
|
myVoteFor: mutatedVoteFor ?? report.myVoteFor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
ReportIncomingShipGroup,
|
ReportIncomingShipGroup,
|
||||||
ReportLocalFleet,
|
ReportLocalFleet,
|
||||||
ReportLocalShipGroup,
|
ReportLocalShipGroup,
|
||||||
|
ReportOtherRace,
|
||||||
ReportOtherShipGroup,
|
ReportOtherShipGroup,
|
||||||
ReportPlanet,
|
ReportPlanet,
|
||||||
ReportRoute,
|
ReportRoute,
|
||||||
@@ -31,8 +32,8 @@ import type {
|
|||||||
ShipClassSummary,
|
ShipClassSummary,
|
||||||
ShipGroupTech,
|
ShipGroupTech,
|
||||||
} from "./game-state";
|
} from "./game-state";
|
||||||
import type { CargoLoadType } from "../sync/order-types";
|
import type { CargoLoadType, Relation } from "../sync/order-types";
|
||||||
import { isCargoLoadType } from "../sync/order-types";
|
import { isCargoLoadType, isRelation } from "../sync/order-types";
|
||||||
|
|
||||||
export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-";
|
export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-";
|
||||||
|
|
||||||
@@ -103,6 +104,11 @@ interface SyntheticPlayer {
|
|||||||
weapons: number;
|
weapons: number;
|
||||||
shields: number;
|
shields: number;
|
||||||
cargo: number;
|
cargo: number;
|
||||||
|
population?: number;
|
||||||
|
industry?: number;
|
||||||
|
planets?: number;
|
||||||
|
relation?: string;
|
||||||
|
votes?: number;
|
||||||
extinct?: boolean;
|
extinct?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +165,8 @@ interface SyntheticReportRoot {
|
|||||||
mapHeight?: number;
|
mapHeight?: number;
|
||||||
mapPlanets?: number;
|
mapPlanets?: number;
|
||||||
race?: string;
|
race?: string;
|
||||||
|
votes?: number;
|
||||||
|
voteFor?: string;
|
||||||
player?: SyntheticPlayer[];
|
player?: SyntheticPlayer[];
|
||||||
localPlanet?: SyntheticPlanet[];
|
localPlanet?: SyntheticPlanet[];
|
||||||
otherPlanet?: SyntheticPlanet[];
|
otherPlanet?: SyntheticPlanet[];
|
||||||
@@ -290,6 +298,9 @@ function decodeSyntheticReport(json: unknown): GameReport {
|
|||||||
unidentifiedShipGroups,
|
unidentifiedShipGroups,
|
||||||
localFleets,
|
localFleets,
|
||||||
otherRaces: collectOtherRacesFromSynthetic(root, race),
|
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;
|
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 {
|
function toShipGroupTech(raw: Record<string, number> | undefined): ShipGroupTech {
|
||||||
const out: ShipGroupTech = { drive: 0, weapons: 0, shields: 0, cargo: 0 };
|
const out: ShipGroupTech = { drive: 0, weapons: 0, shields: 0, cargo: 0 };
|
||||||
if (raw === undefined || raw === null) return out;
|
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>
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
<!--
|
<!--
|
||||||
Active-view router for the per-entity tables. Phase 17 lights up
|
Active-view router for the per-entity tables. Phase 17 lights up
|
||||||
the ship-classes table; Phase 21 lights up the sciences table; the
|
the ship-classes table; Phase 21 lights up the sciences table;
|
||||||
remaining slugs (planets, ship-groups, fleets, races) keep the
|
Phase 22 lights up the races table; the remaining slugs (planets,
|
||||||
Phase 10 stub copy until their respective phases land. The wrapper
|
ship-groups, fleets) keep the Phase 10 stub copy until their
|
||||||
preserves `data-testid="active-view-table"` and
|
respective phases land. The wrapper preserves
|
||||||
`data-entity={entity}` for every branch (each leaf component
|
`data-testid="active-view-table"` and `data-entity={entity}` for
|
||||||
mirrors them) so the navigation e2e specs (`game-shell.spec.ts`,
|
every branch (each leaf component mirrors them) so the navigation
|
||||||
`view-menu`) keep matching.
|
e2e specs (`game-shell.spec.ts`, `view-menu`) keep matching.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
import TableShipClasses from "./table-ship-classes.svelte";
|
import TableShipClasses from "./table-ship-classes.svelte";
|
||||||
import TableSciences from "./table-sciences.svelte";
|
import TableSciences from "./table-sciences.svelte";
|
||||||
|
import TableRaces from "./table-races.svelte";
|
||||||
|
|
||||||
type Props = { entity: string };
|
type Props = { entity: string };
|
||||||
let { entity }: Props = $props();
|
let { entity }: Props = $props();
|
||||||
@@ -26,6 +27,8 @@ mirrors them) so the navigation e2e specs (`game-shell.spec.ts`,
|
|||||||
<TableShipClasses />
|
<TableShipClasses />
|
||||||
{:else if entity === "sciences"}
|
{:else if entity === "sciences"}
|
||||||
<TableSciences />
|
<TableSciences />
|
||||||
|
{:else if entity === "races"}
|
||||||
|
<TableRaces />
|
||||||
{:else}
|
{:else}
|
||||||
<section
|
<section
|
||||||
class="active-view"
|
class="active-view"
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ const en = {
|
|||||||
"game.sidebar.order.label.ship_group_dismantle": "dismantle group {group}",
|
"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_transfer": "transfer group {group} → {acceptor}",
|
||||||
"game.sidebar.order.label.ship_group_join_fleet": "assign group {group} → fleet {fleet}",
|
"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.title": "ship classes",
|
||||||
"game.table.ship_classes.column.name": "name",
|
"game.table.ship_classes.column.name": "name",
|
||||||
"game.table.ship_classes.column.drive": "drive",
|
"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.cargo_value": "cargo % must be in [0, 100]",
|
||||||
"game.designer.science.invalid.sum_not_hundred": "the four percentages must sum to exactly 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.local": "your group",
|
||||||
"game.inspector.ship_group.kind.other": "other race group",
|
"game.inspector.ship_group.kind.other": "other race group",
|
||||||
"game.inspector.ship_group.kind.incoming": "incoming group",
|
"game.inspector.ship_group.kind.incoming": "incoming group",
|
||||||
|
|||||||
@@ -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_dismantle": "разобрать группу {group}",
|
||||||
"game.sidebar.order.label.ship_group_transfer": "передать группу {group} → {acceptor}",
|
"game.sidebar.order.label.ship_group_transfer": "передать группу {group} → {acceptor}",
|
||||||
"game.sidebar.order.label.ship_group_join_fleet": "включить группу {group} → флот {fleet}",
|
"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.title": "классы кораблей",
|
||||||
"game.table.ship_classes.column.name": "название",
|
"game.table.ship_classes.column.name": "название",
|
||||||
"game.table.ship_classes.column.drive": "двигатель",
|
"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.cargo_value": "трюм % должен быть в [0, 100]",
|
||||||
"game.designer.science.invalid.sum_not_hundred": "сумма четырёх процентов должна быть ровно 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.local": "ваша группа",
|
||||||
"game.inspector.ship_group.kind.other": "группа другой расы",
|
"game.inspector.ship_group.kind.other": "группа другой расы",
|
||||||
"game.inspector.ship_group.kind.incoming": "входящая группа",
|
"game.inspector.ship_group.kind.incoming": "входящая группа",
|
||||||
|
|||||||
@@ -126,6 +126,15 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
|
|||||||
group: shortGroupId(cmd.groupId),
|
group: shortGroupId(cmd.groupId),
|
||||||
fleet: cmd.name,
|
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 type { GalaxyClient } from "../api/galaxy-client";
|
||||||
import { fetchOrder } from "./order-load";
|
import { fetchOrder } from "./order-load";
|
||||||
import {
|
import {
|
||||||
|
isRelation,
|
||||||
isShipGroupCargo,
|
isShipGroupCargo,
|
||||||
isShipGroupUpgradeTech,
|
isShipGroupUpgradeTech,
|
||||||
type CommandStatus,
|
type CommandStatus,
|
||||||
@@ -193,6 +194,14 @@ export class OrderDraftStore {
|
|||||||
* a newer entry for the same slot supersedes any prior
|
* a newer entry for the same slot supersedes any prior
|
||||||
* `set` or `remove` for that slot. Different load-types or
|
* `set` or `remove` for that slot. Different load-types or
|
||||||
* different sources coexist.
|
* 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;
|
* - `planetRename` and `placeholder` append unconditionally;
|
||||||
* each rename is a distinct user-visible action.
|
* each rename is a distinct user-visible action.
|
||||||
*/
|
*/
|
||||||
@@ -231,6 +240,29 @@ export class OrderDraftStore {
|
|||||||
nextCommands.push(existing);
|
nextCommands.push(existing);
|
||||||
}
|
}
|
||||||
nextCommands.push(command);
|
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 {
|
} else {
|
||||||
nextCommands = [...this.commands, command];
|
nextCommands = [...this.commands, command];
|
||||||
}
|
}
|
||||||
@@ -602,6 +634,25 @@ function validateCommand(cmd: OrderCommand): CommandStatus {
|
|||||||
if (!validateEntityName(cmd.name).ok) return "invalid";
|
if (!validateEntityName(cmd.name).ok) return "invalid";
|
||||||
if (!isUuid(cmd.groupId)) return "invalid";
|
if (!isUuid(cmd.groupId)) return "invalid";
|
||||||
return "valid";
|
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":
|
case "placeholder":
|
||||||
// Phase 12 placeholder entries are content-free and never
|
// Phase 12 placeholder entries are content-free and never
|
||||||
// transition out of `draft` — they are not submittable.
|
// transition out of `draft` — they are not submittable.
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
CommandPlanetRename,
|
CommandPlanetRename,
|
||||||
CommandPlanetRouteRemove,
|
CommandPlanetRouteRemove,
|
||||||
CommandPlanetRouteSet,
|
CommandPlanetRouteSet,
|
||||||
|
CommandRaceRelation,
|
||||||
|
CommandRaceVote,
|
||||||
CommandScienceCreate,
|
CommandScienceCreate,
|
||||||
CommandScienceRemove,
|
CommandScienceRemove,
|
||||||
CommandShipClassCreate,
|
CommandShipClassCreate,
|
||||||
@@ -30,6 +32,7 @@ import {
|
|||||||
CommandShipGroupUpgrade,
|
CommandShipGroupUpgrade,
|
||||||
PlanetProduction,
|
PlanetProduction,
|
||||||
PlanetRouteLoadType,
|
PlanetRouteLoadType,
|
||||||
|
Relation,
|
||||||
ShipGroupCargo,
|
ShipGroupCargo,
|
||||||
ShipGroupUpgradeTech,
|
ShipGroupUpgradeTech,
|
||||||
UserGamesOrderGet,
|
UserGamesOrderGet,
|
||||||
@@ -39,6 +42,7 @@ import type {
|
|||||||
CargoLoadType,
|
CargoLoadType,
|
||||||
OrderCommand,
|
OrderCommand,
|
||||||
ProductionType,
|
ProductionType,
|
||||||
|
Relation as RelationLiteral,
|
||||||
ShipGroupCargo as ShipGroupCargoLiteral,
|
ShipGroupCargo as ShipGroupCargoLiteral,
|
||||||
ShipGroupUpgradeTech as ShipGroupUpgradeTechLiteral,
|
ShipGroupUpgradeTech as ShipGroupUpgradeTechLiteral,
|
||||||
} from "./order-types";
|
} from "./order-types";
|
||||||
@@ -354,6 +358,32 @@ function decodeCommand(item: CommandItemView): OrderCommand | null {
|
|||||||
name: inner.name() ?? "",
|
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:
|
default:
|
||||||
console.warn(
|
console.warn(
|
||||||
`fetchOrder: skipping unknown command kind (payloadType=${payloadType})`,
|
`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(
|
function decodeError(
|
||||||
payload: Uint8Array,
|
payload: Uint8Array,
|
||||||
resultCode: string,
|
resultCode: string,
|
||||||
|
|||||||
@@ -409,6 +409,76 @@ export interface JoinFleetShipGroupCommand {
|
|||||||
readonly name: string;
|
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
|
* OrderCommand is the discriminated union of every command shape the
|
||||||
* local order draft can hold. The `kind` field is the discriminator;
|
* local order draft can hold. The `kind` field is the discriminator;
|
||||||
@@ -432,7 +502,9 @@ export type OrderCommand =
|
|||||||
| UpgradeShipGroupCommand
|
| UpgradeShipGroupCommand
|
||||||
| DismantleShipGroupCommand
|
| DismantleShipGroupCommand
|
||||||
| TransferShipGroupCommand
|
| TransferShipGroupCommand
|
||||||
| JoinFleetShipGroupCommand;
|
| JoinFleetShipGroupCommand
|
||||||
|
| SetDiplomaticStanceCommand
|
||||||
|
| SetVoteRecipientCommand;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PRODUCTION_TYPE_VALUES is the canonical tuple of `ProductionType`
|
* PRODUCTION_TYPE_VALUES is the canonical tuple of `ProductionType`
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
CommandPlanetRename,
|
CommandPlanetRename,
|
||||||
CommandPlanetRouteRemove,
|
CommandPlanetRouteRemove,
|
||||||
CommandPlanetRouteSet,
|
CommandPlanetRouteSet,
|
||||||
|
CommandRaceRelation,
|
||||||
|
CommandRaceVote,
|
||||||
CommandScienceCreate,
|
CommandScienceCreate,
|
||||||
CommandScienceRemove,
|
CommandScienceRemove,
|
||||||
CommandShipClassCreate,
|
CommandShipClassCreate,
|
||||||
@@ -45,6 +47,7 @@ import {
|
|||||||
CommandShipGroupUpgrade,
|
CommandShipGroupUpgrade,
|
||||||
PlanetProduction,
|
PlanetProduction,
|
||||||
PlanetRouteLoadType,
|
PlanetRouteLoadType,
|
||||||
|
Relation,
|
||||||
ShipGroupCargo,
|
ShipGroupCargo,
|
||||||
ShipGroupUpgradeTech,
|
ShipGroupUpgradeTech,
|
||||||
UserGamesOrder,
|
UserGamesOrder,
|
||||||
@@ -54,6 +57,7 @@ import type {
|
|||||||
CargoLoadType,
|
CargoLoadType,
|
||||||
OrderCommand,
|
OrderCommand,
|
||||||
ProductionType,
|
ProductionType,
|
||||||
|
Relation as RelationLiteral,
|
||||||
ShipGroupCargo as ShipGroupCargoLiteral,
|
ShipGroupCargo as ShipGroupCargoLiteral,
|
||||||
ShipGroupUpgradeTech as ShipGroupUpgradeTechLiteral,
|
ShipGroupUpgradeTech as ShipGroupUpgradeTechLiteral,
|
||||||
} from "./order-types";
|
} from "./order-types";
|
||||||
@@ -365,6 +369,29 @@ function encodeCommandPayload(
|
|||||||
payloadOffset: offset,
|
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":
|
case "placeholder":
|
||||||
throw new SubmitError(
|
throw new SubmitError(
|
||||||
"invalid_request",
|
"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(
|
function decodeOrderResponse(
|
||||||
payload: Uint8Array,
|
payload: Uint8Array,
|
||||||
commands: OrderCommand[],
|
commands: OrderCommand[],
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ import {
|
|||||||
CommandPlanetRename,
|
CommandPlanetRename,
|
||||||
CommandPlanetRouteRemove,
|
CommandPlanetRouteRemove,
|
||||||
CommandPlanetRouteSet,
|
CommandPlanetRouteSet,
|
||||||
|
CommandRaceRelation,
|
||||||
|
CommandRaceVote,
|
||||||
CommandScienceCreate,
|
CommandScienceCreate,
|
||||||
CommandScienceRemove,
|
CommandScienceRemove,
|
||||||
CommandShipClassCreate,
|
CommandShipClassCreate,
|
||||||
CommandShipClassRemove,
|
CommandShipClassRemove,
|
||||||
PlanetProduction,
|
PlanetProduction,
|
||||||
PlanetRouteLoadType,
|
PlanetRouteLoadType,
|
||||||
|
Relation,
|
||||||
UserGamesOrder,
|
UserGamesOrder,
|
||||||
UserGamesOrderGetResponse,
|
UserGamesOrderGetResponse,
|
||||||
UserGamesOrderResponse,
|
UserGamesOrderResponse,
|
||||||
@@ -98,6 +101,19 @@ export interface RemoveScienceResultFixture extends CommandResultFixtureBase {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SetDiplomaticStanceResultFixture
|
||||||
|
extends CommandResultFixtureBase {
|
||||||
|
kind: "setDiplomaticStance";
|
||||||
|
acceptor: string;
|
||||||
|
relation: "WAR" | "PEACE";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetVoteRecipientResultFixture
|
||||||
|
extends CommandResultFixtureBase {
|
||||||
|
kind: "setVoteRecipient";
|
||||||
|
acceptor: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type CommandResultFixture =
|
export type CommandResultFixture =
|
||||||
| PlanetRenameResultFixture
|
| PlanetRenameResultFixture
|
||||||
| SetProductionTypeResultFixture
|
| SetProductionTypeResultFixture
|
||||||
@@ -106,7 +122,9 @@ export type CommandResultFixture =
|
|||||||
| CreateShipClassResultFixture
|
| CreateShipClassResultFixture
|
||||||
| RemoveShipClassResultFixture
|
| RemoveShipClassResultFixture
|
||||||
| CreateScienceResultFixture
|
| CreateScienceResultFixture
|
||||||
| RemoveScienceResultFixture;
|
| RemoveScienceResultFixture
|
||||||
|
| SetDiplomaticStanceResultFixture
|
||||||
|
| SetVoteRecipientResultFixture;
|
||||||
|
|
||||||
export function buildOrderResponsePayload(
|
export function buildOrderResponsePayload(
|
||||||
gameId: string,
|
gameId: string,
|
||||||
@@ -255,6 +273,22 @@ function encodeItem(builder: Builder, c: CommandResultFixture): number {
|
|||||||
payloadType = CommandPayload.CommandScienceRemove;
|
payloadType = CommandPayload.CommandScienceRemove;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "setDiplomaticStance": {
|
||||||
|
const acceptorOffset = builder.createString(c.acceptor);
|
||||||
|
inner = CommandRaceRelation.createCommandRaceRelation(
|
||||||
|
builder,
|
||||||
|
acceptorOffset,
|
||||||
|
relationToFBS(c.relation),
|
||||||
|
);
|
||||||
|
payloadType = CommandPayload.CommandRaceRelation;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "setVoteRecipient": {
|
||||||
|
const acceptorOffset = builder.createString(c.acceptor);
|
||||||
|
inner = CommandRaceVote.createCommandRaceVote(builder, acceptorOffset);
|
||||||
|
payloadType = CommandPayload.CommandRaceVote;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
CommandItem.startCommandItem(builder);
|
CommandItem.startCommandItem(builder);
|
||||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||||
@@ -304,3 +338,14 @@ function cargoLoadTypeToFBS(
|
|||||||
return PlanetRouteLoadType.EMP;
|
return PlanetRouteLoadType.EMP;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function relationToFBS(
|
||||||
|
value: SetDiplomaticStanceResultFixture["relation"],
|
||||||
|
): Relation {
|
||||||
|
switch (value) {
|
||||||
|
case "WAR":
|
||||||
|
return Relation.WAR;
|
||||||
|
case "PEACE":
|
||||||
|
return Relation.PEACE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,6 +73,15 @@ export interface ScienceFixture {
|
|||||||
export interface PlayerFixture {
|
export interface PlayerFixture {
|
||||||
name: string;
|
name: string;
|
||||||
drive?: number;
|
drive?: number;
|
||||||
|
weapons?: number;
|
||||||
|
shields?: number;
|
||||||
|
cargo?: number;
|
||||||
|
population?: number;
|
||||||
|
industry?: number;
|
||||||
|
planets?: number;
|
||||||
|
relation?: "WAR" | "PEACE" | "-";
|
||||||
|
votes?: number;
|
||||||
|
extinct?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RouteEntryFixture {
|
export interface RouteEntryFixture {
|
||||||
@@ -98,6 +107,8 @@ export interface ReportFixture {
|
|||||||
race?: string;
|
race?: string;
|
||||||
players?: PlayerFixture[];
|
players?: PlayerFixture[];
|
||||||
routes?: RouteFixture[];
|
routes?: RouteFixture[];
|
||||||
|
myVotes?: number;
|
||||||
|
myVoteFor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
||||||
@@ -202,9 +213,20 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
|||||||
|
|
||||||
const playerOffsets = (fixture.players ?? []).map((p) => {
|
const playerOffsets = (fixture.players ?? []).map((p) => {
|
||||||
const name = builder.createString(p.name);
|
const name = builder.createString(p.name);
|
||||||
|
const relation =
|
||||||
|
p.relation === undefined ? null : builder.createString(p.relation);
|
||||||
Player.startPlayer(builder);
|
Player.startPlayer(builder);
|
||||||
Player.addName(builder, name);
|
Player.addName(builder, name);
|
||||||
Player.addDrive(builder, p.drive ?? 1);
|
Player.addDrive(builder, p.drive ?? 1);
|
||||||
|
Player.addWeapons(builder, p.weapons ?? 0);
|
||||||
|
Player.addShields(builder, p.shields ?? 0);
|
||||||
|
Player.addCargo(builder, p.cargo ?? 0);
|
||||||
|
Player.addPopulation(builder, p.population ?? 0);
|
||||||
|
Player.addIndustry(builder, p.industry ?? 0);
|
||||||
|
Player.addPlanets(builder, p.planets ?? 0);
|
||||||
|
if (relation !== null) Player.addRelation(builder, relation);
|
||||||
|
Player.addVotes(builder, p.votes ?? 0);
|
||||||
|
Player.addExtinct(builder, p.extinct ?? false);
|
||||||
return Player.endPlayer(builder);
|
return Player.endPlayer(builder);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -257,6 +279,10 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
|||||||
: Report.createRouteVector(builder, routeOffsets);
|
: Report.createRouteVector(builder, routeOffsets);
|
||||||
const raceOffset =
|
const raceOffset =
|
||||||
fixture.race === undefined ? null : builder.createString(fixture.race);
|
fixture.race === undefined ? null : builder.createString(fixture.race);
|
||||||
|
const voteForOffset =
|
||||||
|
fixture.myVoteFor === undefined
|
||||||
|
? null
|
||||||
|
: builder.createString(fixture.myVoteFor);
|
||||||
|
|
||||||
const totalPlanets =
|
const totalPlanets =
|
||||||
(fixture.localPlanets ?? []).length +
|
(fixture.localPlanets ?? []).length +
|
||||||
@@ -270,6 +296,8 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
|
|||||||
Report.addHeight(builder, fixture.mapHeight ?? 4000);
|
Report.addHeight(builder, fixture.mapHeight ?? 4000);
|
||||||
Report.addPlanetCount(builder, totalPlanets);
|
Report.addPlanetCount(builder, totalPlanets);
|
||||||
if (raceOffset !== null) Report.addRace(builder, raceOffset);
|
if (raceOffset !== null) Report.addRace(builder, raceOffset);
|
||||||
|
if (fixture.myVotes !== undefined) Report.addVotes(builder, fixture.myVotes);
|
||||||
|
if (voteForOffset !== null) Report.addVoteFor(builder, voteForOffset);
|
||||||
if (playerVec !== null) Report.addPlayer(builder, playerVec);
|
if (playerVec !== null) Report.addPlayer(builder, playerVec);
|
||||||
if (localVec !== null) Report.addLocalPlanet(builder, localVec);
|
if (localVec !== null) Report.addLocalPlanet(builder, localVec);
|
||||||
if (otherVec !== null) Report.addOtherPlanet(builder, otherVec);
|
if (otherVec !== null) Report.addOtherPlanet(builder, otherVec);
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
// Phase 22 end-to-end coverage for the Races View. Boots an
|
||||||
|
// authenticated session, mocks the gateway with three non-extinct
|
||||||
|
// other races (mixed WAR/PEACE), navigates to the races table, then:
|
||||||
|
//
|
||||||
|
// 1. flips one row's stance from PEACE to WAR — observes the
|
||||||
|
// submitted order envelope decoded as `CommandRaceRelation`,
|
||||||
|
// with the expected `acceptor` + `relation`;
|
||||||
|
// 2. changes the vote recipient — observes the submitted order
|
||||||
|
// envelope decoded as `CommandRaceVote`;
|
||||||
|
// 3. after the auto-sync round-trip both rows show as `applied`
|
||||||
|
// in the sidebar order tab.
|
||||||
|
|
||||||
|
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
|
||||||
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
import { ByteBuffer } from "flatbuffers";
|
||||||
|
|
||||||
|
import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb";
|
||||||
|
import { UUID } from "../../src/proto/galaxy/fbs/common";
|
||||||
|
import {
|
||||||
|
CommandPayload,
|
||||||
|
CommandRaceRelation,
|
||||||
|
CommandRaceVote,
|
||||||
|
Relation,
|
||||||
|
UserGamesOrder,
|
||||||
|
UserGamesOrderGet,
|
||||||
|
} from "../../src/proto/galaxy/fbs/order";
|
||||||
|
import { GameReportRequest } from "../../src/proto/galaxy/fbs/report";
|
||||||
|
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
|
||||||
|
import {
|
||||||
|
buildMyGamesListPayload,
|
||||||
|
type GameFixture,
|
||||||
|
} from "./fixtures/lobby-fbs";
|
||||||
|
import { buildReportPayload } from "./fixtures/report-fbs";
|
||||||
|
import {
|
||||||
|
buildOrderGetResponsePayload,
|
||||||
|
buildOrderResponsePayload,
|
||||||
|
type CommandResultFixture,
|
||||||
|
} from "./fixtures/order-fbs";
|
||||||
|
|
||||||
|
const SESSION_ID = "phase-22-races-session";
|
||||||
|
const GAME_ID = "22222222-2222-2222-2222-222222222222";
|
||||||
|
|
||||||
|
interface MockHandle {
|
||||||
|
get lastStance(): { acceptor: string; relation: "WAR" | "PEACE" } | null;
|
||||||
|
get lastVote(): { acceptor: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockGateway(page: Page): Promise<MockHandle> {
|
||||||
|
const game: GameFixture = {
|
||||||
|
gameId: GAME_ID,
|
||||||
|
gameName: "Phase 22 Game",
|
||||||
|
gameType: "private",
|
||||||
|
status: "running",
|
||||||
|
ownerUserId: "user-1",
|
||||||
|
minPlayers: 2,
|
||||||
|
maxPlayers: 8,
|
||||||
|
enrollmentEndsAtMs: BigInt(Date.now() + 86_400_000),
|
||||||
|
createdAtMs: BigInt(Date.now() - 86_400_000),
|
||||||
|
updatedAtMs: BigInt(Date.now()),
|
||||||
|
currentTurn: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let storedOrder: CommandResultFixture[] = [];
|
||||||
|
let lastStance: MockHandle["lastStance"] = null;
|
||||||
|
let lastVote: MockHandle["lastVote"] = null;
|
||||||
|
|
||||||
|
await page.route(
|
||||||
|
"**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand",
|
||||||
|
async (route) => {
|
||||||
|
const reqText = route.request().postData();
|
||||||
|
if (reqText === null) {
|
||||||
|
await route.fulfill({ status: 400 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const req = fromJson(
|
||||||
|
ExecuteCommandRequestSchema,
|
||||||
|
JSON.parse(reqText) as JsonValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
let resultCode = "ok";
|
||||||
|
let payload: Uint8Array;
|
||||||
|
switch (req.messageType) {
|
||||||
|
case "lobby.my.games.list":
|
||||||
|
payload = buildMyGamesListPayload([game]);
|
||||||
|
break;
|
||||||
|
case "user.games.report": {
|
||||||
|
GameReportRequest.getRootAsGameReportRequest(
|
||||||
|
new ByteBuffer(req.payloadBytes),
|
||||||
|
).gameId(new UUID());
|
||||||
|
payload = buildReportPayload({
|
||||||
|
turn: 1,
|
||||||
|
mapWidth: 4000,
|
||||||
|
mapHeight: 4000,
|
||||||
|
race: "Earthlings",
|
||||||
|
myVotes: 4,
|
||||||
|
myVoteFor: "Earthlings",
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
name: "Earthlings",
|
||||||
|
drive: 1,
|
||||||
|
weapons: 1,
|
||||||
|
shields: 1,
|
||||||
|
cargo: 1,
|
||||||
|
population: 4000,
|
||||||
|
industry: 3000,
|
||||||
|
planets: 2,
|
||||||
|
relation: "-",
|
||||||
|
votes: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Andori",
|
||||||
|
drive: 1,
|
||||||
|
weapons: 1,
|
||||||
|
shields: 1,
|
||||||
|
cargo: 1,
|
||||||
|
population: 3000,
|
||||||
|
industry: 2500,
|
||||||
|
planets: 2,
|
||||||
|
relation: "PEACE",
|
||||||
|
votes: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bajori",
|
||||||
|
drive: 1,
|
||||||
|
weapons: 1,
|
||||||
|
shields: 1,
|
||||||
|
cargo: 1,
|
||||||
|
population: 2000,
|
||||||
|
industry: 1500,
|
||||||
|
planets: 1,
|
||||||
|
relation: "PEACE",
|
||||||
|
votes: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Cardassian",
|
||||||
|
drive: 1,
|
||||||
|
weapons: 1,
|
||||||
|
shields: 1,
|
||||||
|
cargo: 1,
|
||||||
|
population: 1000,
|
||||||
|
industry: 800,
|
||||||
|
planets: 1,
|
||||||
|
relation: "WAR",
|
||||||
|
votes: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
localPlanets: [
|
||||||
|
{
|
||||||
|
number: 1,
|
||||||
|
name: "Earth",
|
||||||
|
x: 2000,
|
||||||
|
y: 2000,
|
||||||
|
size: 1000,
|
||||||
|
resources: 5,
|
||||||
|
population: 4000,
|
||||||
|
industry: 3000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "user.games.order": {
|
||||||
|
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
|
||||||
|
new ByteBuffer(req.payloadBytes),
|
||||||
|
);
|
||||||
|
const length = decoded.commandsLength();
|
||||||
|
const fixtures: CommandResultFixture[] = [];
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const item = decoded.commands(i);
|
||||||
|
if (item === null) continue;
|
||||||
|
const cmdId = item.cmdId() ?? "";
|
||||||
|
const payloadType = item.payloadType();
|
||||||
|
if (payloadType === CommandPayload.CommandRaceRelation) {
|
||||||
|
const inner = new CommandRaceRelation();
|
||||||
|
item.payload(inner);
|
||||||
|
const relation =
|
||||||
|
inner.relation() === Relation.WAR ? "WAR" : "PEACE";
|
||||||
|
lastStance = {
|
||||||
|
acceptor: inner.acceptor() ?? "",
|
||||||
|
relation,
|
||||||
|
};
|
||||||
|
fixtures.push({
|
||||||
|
kind: "setDiplomaticStance",
|
||||||
|
cmdId,
|
||||||
|
acceptor: lastStance.acceptor,
|
||||||
|
relation,
|
||||||
|
applied: true,
|
||||||
|
errorCode: null,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (payloadType === CommandPayload.CommandRaceVote) {
|
||||||
|
const inner = new CommandRaceVote();
|
||||||
|
item.payload(inner);
|
||||||
|
lastVote = { acceptor: inner.acceptor() ?? "" };
|
||||||
|
fixtures.push({
|
||||||
|
kind: "setVoteRecipient",
|
||||||
|
cmdId,
|
||||||
|
acceptor: lastVote.acceptor,
|
||||||
|
applied: true,
|
||||||
|
errorCode: null,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
storedOrder = fixtures;
|
||||||
|
payload = buildOrderResponsePayload(GAME_ID, fixtures, Date.now());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "user.games.order.get": {
|
||||||
|
UserGamesOrderGet.getRootAsUserGamesOrderGet(
|
||||||
|
new ByteBuffer(req.payloadBytes),
|
||||||
|
);
|
||||||
|
payload = buildOrderGetResponsePayload(
|
||||||
|
GAME_ID,
|
||||||
|
storedOrder,
|
||||||
|
Date.now(),
|
||||||
|
storedOrder.length > 0,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
resultCode = "internal_error";
|
||||||
|
payload = new Uint8Array();
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await forgeExecuteCommandResponseJson({
|
||||||
|
requestId: req.requestId,
|
||||||
|
timestampMs: BigInt(Date.now()),
|
||||||
|
resultCode,
|
||||||
|
payloadBytes: payload,
|
||||||
|
});
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.route(
|
||||||
|
"**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents",
|
||||||
|
async () => {
|
||||||
|
await new Promise<void>(() => {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get lastStance() {
|
||||||
|
return lastStance;
|
||||||
|
},
|
||||||
|
get lastVote() {
|
||||||
|
return lastVote;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootSession(page: Page): Promise<void> {
|
||||||
|
await page.goto("/__debug/store");
|
||||||
|
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
|
||||||
|
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
|
||||||
|
await page.evaluate(() => window.__galaxyDebug!.clearSession());
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
|
||||||
|
SESSION_ID,
|
||||||
|
);
|
||||||
|
await page.evaluate(
|
||||||
|
(gameId) => window.__galaxyDebug!.clearOrderDraft(gameId),
|
||||||
|
GAME_ID,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("toggle stance and pick a vote target via the races table", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(
|
||||||
|
testInfo.project.name.startsWith("chromium-mobile"),
|
||||||
|
"phase 22 spec covers desktop layout; mobile inherits the same store",
|
||||||
|
);
|
||||||
|
|
||||||
|
const handle = await mockGateway(page);
|
||||||
|
await bootSession(page);
|
||||||
|
await page.goto(`/games/${GAME_ID}/table/races`);
|
||||||
|
|
||||||
|
const tableHost = page.getByTestId("active-view-table");
|
||||||
|
await expect(tableHost).toBeVisible();
|
||||||
|
await expect(page.getByTestId("races-table")).toBeVisible();
|
||||||
|
|
||||||
|
// Flip Andori from PEACE to WAR through the per-row segmented
|
||||||
|
// control. The optimistic overlay flips the buttons immediately;
|
||||||
|
// the auto-sync round-trip echoes back as applied.
|
||||||
|
const andoriRow = page.locator(
|
||||||
|
'[data-testid="races-row"][data-name="Andori"]',
|
||||||
|
);
|
||||||
|
const andoriWar = andoriRow.getByTestId("races-stance-war");
|
||||||
|
await andoriWar.click();
|
||||||
|
await expect(andoriWar).toHaveAttribute("aria-pressed", "true");
|
||||||
|
|
||||||
|
// Pick Andori as the vote target.
|
||||||
|
await page.getByTestId("races-vote-target").selectOption("Andori");
|
||||||
|
|
||||||
|
// Both commands appear in the sidebar order tab as applied.
|
||||||
|
await page.getByTestId("sidebar-tab-order").click();
|
||||||
|
const orderTool = page.getByTestId("sidebar-tool-order");
|
||||||
|
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
||||||
|
"applied",
|
||||||
|
);
|
||||||
|
await expect(orderTool.getByTestId("order-command-status-1")).toHaveText(
|
||||||
|
"applied",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The gateway saw both commands with the expected payloads.
|
||||||
|
expect(handle.lastStance?.acceptor).toBe("Andori");
|
||||||
|
expect(handle.lastStance?.relation).toBe("WAR");
|
||||||
|
expect(handle.lastVote?.acceptor).toBe("Andori");
|
||||||
|
});
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
// EMPTY_SHIP_GROUPS supplies empty arrays for the ancillary report
|
// EMPTY_SHIP_GROUPS supplies empty arrays / zero defaults for the
|
||||||
// fields added in Phase 19 (ship-groups + fleets) and Phase 21
|
// ancillary report fields added in Phase 19 (ship-groups + fleets),
|
||||||
// (sciences). Test fixtures spread it into their report objects so
|
// Phase 21 (sciences), and Phase 22 (races / diplomacy / voting).
|
||||||
// the fixture body still focuses on the fields under test, without
|
// Test fixtures spread it into their report objects so the fixture
|
||||||
// forcing every spec to enumerate the full GameReport surface.
|
// body still focuses on the fields under test, without forcing
|
||||||
|
// every spec to enumerate the full GameReport surface.
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ReportIncomingShipGroup,
|
ReportIncomingShipGroup,
|
||||||
ReportLocalFleet,
|
ReportLocalFleet,
|
||||||
ReportLocalShipGroup,
|
ReportLocalShipGroup,
|
||||||
|
ReportOtherRace,
|
||||||
ReportOtherShipGroup,
|
ReportOtherShipGroup,
|
||||||
ReportUnidentifiedShipGroup,
|
ReportUnidentifiedShipGroup,
|
||||||
ScienceSummary,
|
ScienceSummary,
|
||||||
@@ -21,6 +23,9 @@ export const EMPTY_SHIP_GROUPS: {
|
|||||||
localFleets: ReportLocalFleet[];
|
localFleets: ReportLocalFleet[];
|
||||||
otherRaces: string[];
|
otherRaces: string[];
|
||||||
localScience: ScienceSummary[];
|
localScience: ScienceSummary[];
|
||||||
|
races: ReportOtherRace[];
|
||||||
|
myVotes: number;
|
||||||
|
myVoteFor: string;
|
||||||
} = {
|
} = {
|
||||||
localShipGroups: [],
|
localShipGroups: [],
|
||||||
otherShipGroups: [],
|
otherShipGroups: [],
|
||||||
@@ -29,4 +34,7 @@ export const EMPTY_SHIP_GROUPS: {
|
|||||||
localFleets: [],
|
localFleets: [],
|
||||||
otherRaces: [],
|
otherRaces: [],
|
||||||
localScience: [],
|
localScience: [],
|
||||||
|
races: [],
|
||||||
|
myVotes: 0,
|
||||||
|
myVoteFor: "",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ function makeReport(
|
|||||||
unidentifiedShipGroups: [],
|
unidentifiedShipGroups: [],
|
||||||
localFleets: [],
|
localFleets: [],
|
||||||
otherRaces: [],
|
otherRaces: [],
|
||||||
|
races: [],
|
||||||
|
myVotes: 0,
|
||||||
|
myVoteFor: "",
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
// Vitest coverage for the Phase 22 races table active view. The
|
||||||
|
// component renders against a synthetic `RenderedReportSource` (no
|
||||||
|
// live `GameStateStore`) and a real `OrderDraftStore` (so the per-row
|
||||||
|
// stance toggle and the vote picker exercise the `add` path and the
|
||||||
|
// IndexedDB persistence end-to-end). The render path also flows
|
||||||
|
// through `applyOrderOverlay`, so the optimistic flips made by the
|
||||||
|
// component must keep the test fixture's report intact: each test
|
||||||
|
// passes the *raw* report and the helper recomputes the overlay on
|
||||||
|
// every snapshot.
|
||||||
|
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import "fake-indexeddb/auto";
|
||||||
|
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||||
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
test,
|
||||||
|
vi,
|
||||||
|
} from "vitest";
|
||||||
|
|
||||||
|
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||||
|
import {
|
||||||
|
applyOrderOverlay,
|
||||||
|
type GameReport,
|
||||||
|
type ReportOtherRace,
|
||||||
|
} from "../src/api/game-state";
|
||||||
|
import {
|
||||||
|
ORDER_DRAFT_CONTEXT_KEY,
|
||||||
|
OrderDraftStore,
|
||||||
|
} from "../src/sync/order-draft.svelte";
|
||||||
|
import { RENDERED_REPORT_CONTEXT_KEY } from "../src/lib/rendered-report.svelte";
|
||||||
|
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||||
|
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
||||||
|
import type { Cache } from "../src/platform/store/index";
|
||||||
|
import type { IDBPDatabase } from "idb";
|
||||||
|
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
|
||||||
|
|
||||||
|
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||||
|
|
||||||
|
const pageMock = vi.hoisted(() => ({
|
||||||
|
url: new URL("http://localhost/games/g1/table/races"),
|
||||||
|
params: { id: "g1" } as Record<string, string>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const gotoMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("$app/state", () => ({
|
||||||
|
page: pageMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("$app/navigation", () => ({
|
||||||
|
goto: gotoMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import TableRaces from "../src/lib/active-view/table-races.svelte";
|
||||||
|
|
||||||
|
let db: IDBPDatabase<GalaxyDB>;
|
||||||
|
let dbName: string;
|
||||||
|
let cache: Cache;
|
||||||
|
let draft: OrderDraftStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dbName = `galaxy-table-races-${crypto.randomUUID()}`;
|
||||||
|
db = await openGalaxyDB(dbName);
|
||||||
|
cache = new IDBCache(db);
|
||||||
|
draft = new OrderDraftStore();
|
||||||
|
await draft.init({ cache, gameId: GAME_ID });
|
||||||
|
i18n.resetForTests("en");
|
||||||
|
pageMock.params = { id: "g1" };
|
||||||
|
gotoMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
draft.dispose();
|
||||||
|
db.close();
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const req = indexedDB.deleteDatabase(dbName);
|
||||||
|
req.onsuccess = () => resolve();
|
||||||
|
req.onerror = () => resolve();
|
||||||
|
req.onblocked = () => resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function race(
|
||||||
|
overrides: Partial<ReportOtherRace> & Pick<ReportOtherRace, "name">,
|
||||||
|
): ReportOtherRace {
|
||||||
|
return {
|
||||||
|
drive: 0,
|
||||||
|
weapons: 0,
|
||||||
|
shields: 0,
|
||||||
|
cargo: 0,
|
||||||
|
population: 0,
|
||||||
|
industry: 0,
|
||||||
|
planets: 0,
|
||||||
|
relation: "PEACE",
|
||||||
|
votesReceived: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeReport(
|
||||||
|
races: ReportOtherRace[],
|
||||||
|
opts: { myVotes?: number; myVoteFor?: string } = {},
|
||||||
|
): GameReport {
|
||||||
|
const baseEmpty = {
|
||||||
|
...EMPTY_SHIP_GROUPS,
|
||||||
|
races,
|
||||||
|
myVotes: opts.myVotes ?? 0,
|
||||||
|
myVoteFor: opts.myVoteFor ?? "",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
turn: 1,
|
||||||
|
mapWidth: 1000,
|
||||||
|
mapHeight: 1000,
|
||||||
|
planetCount: 0,
|
||||||
|
planets: [],
|
||||||
|
race: "Self",
|
||||||
|
localShipClass: [],
|
||||||
|
routes: [],
|
||||||
|
localPlayerDrive: 0,
|
||||||
|
localPlayerWeapons: 0,
|
||||||
|
localPlayerShields: 0,
|
||||||
|
localPlayerCargo: 0,
|
||||||
|
...baseEmpty,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountTable(report: GameReport | null) {
|
||||||
|
const renderedReport = {
|
||||||
|
get report() {
|
||||||
|
if (report === null) return null;
|
||||||
|
return applyOrderOverlay(report, draft.commands, draft.statuses);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const context = new Map<unknown, unknown>([
|
||||||
|
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
||||||
|
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
|
||||||
|
]);
|
||||||
|
return render(TableRaces, { context });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("races table", () => {
|
||||||
|
test("renders a loading placeholder before the report lands", () => {
|
||||||
|
const ui = mountTable(null);
|
||||||
|
expect(ui.getByTestId("races-loading")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders an empty placeholder when no other races are known", () => {
|
||||||
|
const ui = mountTable(makeReport([]));
|
||||||
|
expect(ui.getByTestId("races-empty")).toBeInTheDocument();
|
||||||
|
// vote picker stays mounted but disabled
|
||||||
|
expect(ui.getByTestId("races-vote-target")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders one row per race with all ten columns populated", () => {
|
||||||
|
const ui = mountTable(
|
||||||
|
makeReport([
|
||||||
|
race({
|
||||||
|
name: "Andori",
|
||||||
|
drive: 0.25,
|
||||||
|
weapons: 0.5,
|
||||||
|
shields: 0.75,
|
||||||
|
cargo: 1.0,
|
||||||
|
population: 12345,
|
||||||
|
industry: 6789,
|
||||||
|
planets: 4,
|
||||||
|
relation: "WAR",
|
||||||
|
votesReceived: 3.5,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const rows = ui.getAllByTestId("races-row");
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toHaveAttribute("data-name", "Andori");
|
||||||
|
expect(ui.getByTestId("races-cell-name")).toHaveTextContent("Andori");
|
||||||
|
expect(ui.getByTestId("races-cell-drive")).toHaveTextContent("25");
|
||||||
|
expect(ui.getByTestId("races-cell-weapons")).toHaveTextContent("50");
|
||||||
|
expect(ui.getByTestId("races-cell-shields")).toHaveTextContent("75");
|
||||||
|
expect(ui.getByTestId("races-cell-cargo")).toHaveTextContent("100");
|
||||||
|
expect(ui.getByTestId("races-cell-population")).toHaveTextContent(
|
||||||
|
/12[,\s]345/,
|
||||||
|
);
|
||||||
|
expect(ui.getByTestId("races-cell-industry")).toHaveTextContent(
|
||||||
|
/6[,\s]?789/,
|
||||||
|
);
|
||||||
|
expect(ui.getByTestId("races-cell-planets")).toHaveTextContent("4");
|
||||||
|
expect(ui.getByTestId("races-cell-votes")).toHaveTextContent("3.5");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filters rows by case-insensitive name match", async () => {
|
||||||
|
const ui = mountTable(
|
||||||
|
makeReport([
|
||||||
|
race({ name: "Alpha" }),
|
||||||
|
race({ name: "Beta" }),
|
||||||
|
race({ name: "Gamma" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await fireEvent.input(ui.getByTestId("races-filter"), {
|
||||||
|
target: { value: "PH" },
|
||||||
|
});
|
||||||
|
const rows = ui.getAllByTestId("races-row");
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toHaveAttribute("data-name", "Alpha");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("toggles sort direction when the same column is clicked twice", async () => {
|
||||||
|
const ui = mountTable(
|
||||||
|
makeReport([
|
||||||
|
race({ name: "Alpha", votesReceived: 1 }),
|
||||||
|
race({ name: "Beta", votesReceived: 5 }),
|
||||||
|
race({ name: "Gamma", votesReceived: 3 }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const header = ui.getByTestId("races-column-votesReceived");
|
||||||
|
await fireEvent.click(header);
|
||||||
|
let names = ui
|
||||||
|
.getAllByTestId("races-row")
|
||||||
|
.map((row) => row.getAttribute("data-name"));
|
||||||
|
expect(names).toEqual(["Alpha", "Gamma", "Beta"]);
|
||||||
|
await fireEvent.click(header);
|
||||||
|
names = ui
|
||||||
|
.getAllByTestId("races-row")
|
||||||
|
.map((row) => row.getAttribute("data-name"));
|
||||||
|
expect(names).toEqual(["Beta", "Gamma", "Alpha"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking PEACE on a WAR row appends setDiplomaticStance and flips the overlay", async () => {
|
||||||
|
const ui = mountTable(
|
||||||
|
makeReport([race({ name: "Andori", relation: "WAR" })]),
|
||||||
|
);
|
||||||
|
const peaceButton = ui.getByTestId("races-stance-peace");
|
||||||
|
await fireEvent.click(peaceButton);
|
||||||
|
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||||
|
const cmd = draft.commands[0]!;
|
||||||
|
if (cmd.kind !== "setDiplomaticStance") {
|
||||||
|
throw new Error("wrong kind");
|
||||||
|
}
|
||||||
|
expect(cmd.acceptor).toBe("Andori");
|
||||||
|
expect(cmd.relation).toBe("PEACE");
|
||||||
|
// After overlay the WAR button loses its `aria-pressed=true`.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(ui.getByTestId("races-stance-war")).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
expect(ui.getByTestId("races-stance-peace")).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a second stance click for the same race collapses on acceptor", async () => {
|
||||||
|
const ui = mountTable(
|
||||||
|
makeReport([race({ name: "Andori", relation: "WAR" })]),
|
||||||
|
);
|
||||||
|
await fireEvent.click(ui.getByTestId("races-stance-peace"));
|
||||||
|
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||||
|
const firstId = draft.commands[0]!.id;
|
||||||
|
await fireEvent.click(ui.getByTestId("races-stance-war"));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(draft.commands).toHaveLength(1);
|
||||||
|
});
|
||||||
|
const cmd = draft.commands[0]!;
|
||||||
|
if (cmd.kind !== "setDiplomaticStance") {
|
||||||
|
throw new Error("wrong kind");
|
||||||
|
}
|
||||||
|
expect(cmd.id).not.toBe(firstId);
|
||||||
|
expect(cmd.relation).toBe("WAR");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("changing the vote picker appends setVoteRecipient", async () => {
|
||||||
|
const ui = mountTable(
|
||||||
|
makeReport(
|
||||||
|
[race({ name: "Andori" }), race({ name: "Bajori" })],
|
||||||
|
{ myVoteFor: "Andori" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await fireEvent.change(ui.getByTestId("races-vote-target"), {
|
||||||
|
target: { value: "Bajori" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||||
|
const cmd = draft.commands[0]!;
|
||||||
|
if (cmd.kind !== "setVoteRecipient") {
|
||||||
|
throw new Error("wrong kind");
|
||||||
|
}
|
||||||
|
expect(cmd.acceptor).toBe("Bajori");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a second vote pick collapses singleton regardless of target", async () => {
|
||||||
|
const ui = mountTable(
|
||||||
|
makeReport(
|
||||||
|
[
|
||||||
|
race({ name: "Andori" }),
|
||||||
|
race({ name: "Bajori" }),
|
||||||
|
race({ name: "Cardassian" }),
|
||||||
|
],
|
||||||
|
{ myVoteFor: "Andori" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const select = ui.getByTestId("races-vote-target");
|
||||||
|
await fireEvent.change(select, { target: { value: "Bajori" } });
|
||||||
|
await waitFor(() => expect(draft.commands).toHaveLength(1));
|
||||||
|
await fireEvent.change(select, { target: { value: "Cardassian" } });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(draft.commands).toHaveLength(1);
|
||||||
|
});
|
||||||
|
const cmd = draft.commands[0]!;
|
||||||
|
if (cmd.kind !== "setVoteRecipient") {
|
||||||
|
throw new Error("wrong kind");
|
||||||
|
}
|
||||||
|
expect(cmd.acceptor).toBe("Cardassian");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("my votes summary reads from the report", () => {
|
||||||
|
const ui = mountTable(
|
||||||
|
makeReport([race({ name: "Andori" })], { myVotes: 7.5 }),
|
||||||
|
);
|
||||||
|
expect(ui.getByTestId("races-my-votes")).toHaveTextContent("7.5");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user