Compare commits

10 Commits

Author SHA1 Message Date
Ilia Denisov 81d8be08b2 phase 22 2026-05-11 11:38:40 +02:00
Ilia Denisov e2a4790f6c ui/phase-22: skip the no-op stance click in the races table
Clicking the already-active WAR/PEACE button still appended a
\`setDiplomaticStance\` whose \`relation\` matched the row's current
value. The engine would accept the duplicate harmlessly, but the
order tab inflates with rows that say nothing and every auto-sync
re-ships the redundant payload. Compare against the overlayed
stance (so a queued-but-not-applied change suppresses a re-click
that matches the *intended* state, not just the server snapshot)
and short-circuit when they agree. Mirrors the vote picker, which
already had the same guard.

vitest.config.ts: \`mergeConfig\` refuses callback-form base
configs, so resolve \`vite.config.ts\`'s callback with the test
context first and merge the plain object. Surfaced after the
\`loadEnv\` migration switched the root config to the callback
form.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 11:19:57 +02:00
Ilia Denisov c0382117b8 ui: read dev-server config from .env files and add VITE_DEV_HOST opt-in
`vite.config.ts` read `VITE_DEV_PROXY_TARGET` /
`VITE_DEV_GRPC_PROXY_TARGET` straight from `process.env`, so the
gateway-override knob only worked when the variable was exported in
the shell that ran `pnpm dev`. Per-developer `.env.development.local`
files (the documented way to override) were silently ignored by the
config: Vite auto-populates `import.meta.env` for client code from
those files, but the config itself runs in Node and has to call
`loadEnv` explicitly.

Switch the config to the function-form + `loadEnv` so every
`VITE_*` entry in any `.env*` file reaches both client code and the
config. Now adding `VITE_DEV_PROXY_TARGET=http://localhost:18080` to
`.env.development.local` actually retargets the proxy, no shell
gymnastics required.

While there, introduce `VITE_DEV_HOST` as an opt-in for wider
listener binding: unset (default) keeps Vite's loopback-only
behaviour; `true`/`1`/`yes` flips to "all interfaces" (`0.0.0.0` +
IPv6); any other string is passed through verbatim to pin a
specific LAN address. Useful when reaching the dev server through
SSH port forwarding, a VM, or a container needs a non-loopback
bind, and intentionally opt-in so an unattended `pnpm dev` on a
laptop never exposes the unauthenticated dev surface to the LAN by
accident.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 10:46:08 +02:00
Ilia Denisov 5867afd168 local-dev: parameterize host-port mappings via LOCAL_DEV_*_PORT
The compose stack hard-coded host ports (postgres 5433, redis 6380,
mailpit 8025, gateway REST 8080, gateway gRPC 9090) — fine for a
clean dev machine, painful when those ports collide with other
services on the same host (e.g. a `crowdsec` sitting on
127.0.0.1:8080 or a Prometheus instance on :9090).

Every host-port mapping is now `${LOCAL_DEV_*_PORT:-<old-default>}`,
so the defaults match prior behaviour for everyone and a per-host
override is a single environment variable away. `.env` carries the
overrides as commented-out lines so the customisation surface is
discoverable without grepping the compose file. README's
"Port 8080 already in use" troubleshooting entry now points at the
new variables and the optional `docker-compose.override.yml`
workflow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 10:23:42 +02:00
Ilia Denisov 9111dd955a ui/phase-22: races table with stance toggle and vote slot
Adds the Races View in the in-game shell. The table lists every
non-extinct other race with tech levels (percent), totals,
planets, votes received, and a per-row WAR | PEACE segmented
control. A single vote-recipient slot above the table queues a
`CommandRaceVote`; per-row buttons queue `CommandRaceRelation`.
Both commands flow through the existing order draft store with
collapse-by-acceptor (stance) and singleton (vote) rules.

`GameReport` widens with `races`, `myVotes`, `myVoteFor`; the
decoder walks `report.player[]` once for the richer projection.
The optimistic overlay flips stance and vote target immediately;
`votesReceived`, `myVotes`, and the alliance summary stay
server-authoritative — alliance grouping and the 2/3 victory
check are tallied on the server at turn cutoff and explicitly
not surfaced client-side (`rules.txt` keeps foreign races'
outgoing vote targets private).

Includes Vitest component coverage of stance and vote
collapse rules + a Playwright e2e that drives both commands
through the dispatcher route and verifies the gateway saw the
expected `CommandRaceRelation` / `CommandRaceVote` payloads.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 01:52:23 +02:00
Ilia Denisov 7a7f2e4b98 chore: claude settings 2026-05-11 01:10:32 +02:00
Ilia Denisov 9c29f03d66 ui/phase-21: make MapView's mounted flag reactive
The renderer-mount effect in `lib/active-view/map.svelte` reads
`mounted` to gate the runSerializedMount call, but the variable was
declared as a plain `let`, not `$state`. On the first navigation to
/map this is benign: the effect's first pass returns early (gameState
still hydrating, `report` null), and once `report` arrives the
effect re-fires — by which point `onMount` has already flipped
`mounted = true`.

On every subsequent return to /map the report is already loaded by
the long-lived gameState in the layout. The effect therefore makes
exactly one pass on the freshly-mounted component, gates on
`mounted === false` (the brand-new instance has not run `onMount`
yet), and never wakes up again because no tracked state changes
afterwards. Symptom: black canvas — fresh DOM, no mount-error
overlay, but Pixi never rebuilt the world on the new canvas.

Convert `mounted` to `$state(false)` so flipping it true inside
`onMount` triggers the effect's second pass, which now finds all
preconditions satisfied and proceeds to `runSerializedMount`. The
detailed lifecycle reasoning is preserved as a code comment so the
next reader can see why this one variable must be reactive.

Add tests/e2e/map-roundtrip.spec.ts: navigates /map → {report,
ship-class designer, science designer, mail} → /map for each
non-map view, then asserts the renderer republished primitives onto
the DEV `__galaxyDebug.getMapPrimitives()` surface. The pre-fix
build failed every variant; the patch lands all four green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 22:58:32 +02:00
Ilia Denisov 85ea6f413e local-dev: thread pkg/calc into the dockerfile build context
Commit 408097e ('feat: move func to calc package') moved a helper
into pkg/calc and made pkg/util/map.go import galaxy/calc, but the
local-dev backend / gateway Dockerfiles never picked up the new
module. The synthesised go.work has no replace directive for
galaxy/calc and the build context never copies pkg/calc, so any
backend / gateway image rebuild fails with

    galaxy/calc@v0.0.0: malformed module path "galaxy/calc": missing
    dot in first path element

Add the missing COPY, the matching `use ./pkg/calc` line, and the
`galaxy/calc v0.0.0 => ./pkg/calc` replace to both local-dev
Dockerfiles. The local-dev stack now rebuilds cleanly and the
auto-heal flow (prune-broken-engines + pre-bootstrap reconciler
tick) finishes by spawning a fresh engine container for the new
sandbox game.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 22:45:54 +02:00
Ilia Denisov ff53cc0ad3 local-dev: prune broken engines on rebuild + document one-time bake
`make rebuild` runs `compose build --no-cache backend gateway` plus
a fresh `up -d --wait`. It must therefore also reap any engine
container whose bind-mount source went away during host downtime,
otherwise the new backend image boots into a stack with the same
orphan that triggered the heal flow in the first place.

Also extend the troubleshooting note: pulling the heal-cycle fix
requires one explicit `make rebuild` so the backend image picks up
the pre-bootstrap reconciler tick. Without that, `make up` runs
the new Makefile target but the legacy backend cannot follow
through, and the developer is left staring at a `cancelled`
sandbox with no running replacement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 22:40:27 +02:00
Ilia Denisov edc9709bd6 local-dev: auto-recreate engine containers when bind-mount disappears
After a host reboot macOS clears /private/tmp, so the per-game
bind-mount source under /tmp/galaxy-game-state/<uuid> vanishes and
Docker refuses to restart the long-lived engine container under
`restart: unless-stopped`. The container then sits in `exited` state
and the dev sandbox is unreachable until the developer manually rms
it and runs `make up` twice.

Fix `make -C tools/local-dev up` to heal this in one cycle:

1. `prune-broken-engines` (new make target wired into `up`) walks
   every container labelled `galaxy-game-engine` and removes the ones
   not in `running` / `restarting` state. Healthy long-lived
   containers survive normal up/down cycles untouched.
2. The backend now runs a single reconciliation pass before the
   dev-sandbox bootstrap (`Reconciler().Tick(ctx)` in main.go).
   Without it, bootstrap would reuse the soon-to-be-cancelled game
   that the periodic ticker is about to mark `removed`. The pre-tick
   cascades the orphan runtime row through markRemoved → lobby
   cancel before bootstrap purges terminal sandbox games and creates
   a fresh one — so a single `make up` lands a working sandbox with
   a brand new state directory.

README troubleshooting section documents the symptom and the
recovery so the bind-mount-source error message is greppable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 22:27:31 +02:00
32 changed files with 2228 additions and 130 deletions
+9 -14
View File
@@ -1,8 +1,10 @@
{
"permissions": {
"allow": [],
"defaultMode": "default"
},
"sandbox": {
"network": {
"allowLocalBinding": true,
"allowUnixSockets": ["/Users/id/.colima/default/docker.sock"],
"allowedDomains": [
"github.com",
"registry.npmjs.org",
@@ -11,18 +13,11 @@
"docker.io",
"gcr.io",
"*.golang.org"
]
],
"allowUnixSockets": [
"/var/run/docker.sock"
],
"allowLocalBinding": true
}
},
"enabledPlugins": {
"gopls-lsp@claude-plugins-official": true,
"context7@claude-plugins-official": true
},
"permissions": {
"defaultMode": "plan",
"allow": [
"mcp__context7__resolve-library-id",
"mcp__context7__get-library-docs"
]
}
}
+6
View File
@@ -2,6 +2,12 @@
.vscode/
artifacts/.claude/scheduled_tasks.lock
# Per-developer Claude Code overrides. The committed
# `.claude/settings.json` holds the shared project defaults;
# `settings.local.json` is each developer's local override
# (looser permissions, disabled sandbox) and must not be staged.
.claude/settings.local.json
# Per-developer Vite dotenv overrides. The committed
# `ui/frontend/.env.development` ships sane defaults for the
# `tools/local-dev/` stack; `.local` siblings stay personal and
+15
View File
@@ -266,6 +266,21 @@ func run(ctx context.Context) (err error) {
)
runtimeGateway.svc = runtimeSvc
// Run a single reconciliation pass before the dev-sandbox
// bootstrap so any runtime row pointing at a vanished engine
// container (host reboot wiped /tmp/galaxy-game-state/<uuid>;
// `tools/local-dev`'s `prune-broken-engines` target reaped the
// husk) is already cascaded through `markRemoved` → lobby
// `cancelled` by the time the bootstrap walks the sandbox list.
// Without this pre-tick the bootstrap would reuse the
// soon-to-be-cancelled game and force the developer into a
// second `make up` cycle to land a healthy sandbox. Failures are
// non-fatal: the periodic ticker started later catches up, and
// the worst case degrades to the legacy two-cycle recovery.
if err := runtimeSvc.Reconciler().Tick(ctx); err != nil {
logger.Warn("pre-bootstrap reconciler tick failed", zap.Error(err))
}
if err := devsandbox.Bootstrap(ctx, devsandbox.Deps{
Users: userSvc,
Lobby: lobbySvc,
@@ -0,0 +1,16 @@
# Local-only override: this developer's host already runs another
# Gitea instance bound to 0.0.0.0:3000 and 0.0.0.0:2222, so the
# default port mappings in docker-compose.yml conflict. Remap the
# local-ci Gitea to 13000 (HTTP) and 12222 (SSH) on the host. The
# in-network ports stay 3000 / 22 — runners and workflow containers
# keep reaching Gitea by hostname through the compose network.
#
# This file is intentionally NOT committed to the repo; it captures
# per-host port allocation. Use `make -C tools/local-ci push` only
# after pointing the `local-gitea` git remote at the override port.
services:
gitea:
ports: !override
- "13000:3000"
- "12222:22"
+15
View File
@@ -2,6 +2,21 @@
# file reads these via ${VAR:-} expansions; override per-developer by
# editing this file (it is committed only with the project defaults).
# Host-port mappings for the stack. The compose file reads each as
# ${LOCAL_DEV_*_PORT:-<default>}, so leaving them blank or removing
# the lines below keeps the defaults shown next to each entry. Set
# a non-default value when the default collides with something else
# on the host (a system Postgres, a Prometheus instance on :9090,
# a `crowdsec` sitting on :8080, etc.). The Vite dev server in
# ui/frontend reads the gateway REST address from
# VITE_DEV_PROXY_TARGET — point it at the same port (typically via
# ui/frontend/.env.local).
#LOCAL_DEV_POSTGRES_PORT=5433
#LOCAL_DEV_REDIS_PORT=6380
#LOCAL_DEV_MAILPIT_PORT=8025
LOCAL_DEV_GATEWAY_REST_PORT=18080
LOCAL_DEV_GATEWAY_GRPC_PORT=19090
# Six-digit decimal accepted by ConfirmEmailCode in addition to the
# real bcrypt-verified code. Leave the value blank to disable the
# override and force every login through Mailpit.
+32 -3
View File
@@ -1,4 +1,4 @@
.PHONY: help up down logs status rebuild clean psql logs-backend logs-gateway logs-mail build-engine stop-engines wait
.PHONY: help up down logs status rebuild clean psql logs-backend logs-gateway logs-mail build-engine stop-engines prune-broken-engines wait
.DEFAULT_GOAL := help
@@ -17,6 +17,7 @@ help:
@echo " make rebuild Force rebuild of backend / gateway images and bring up"
@echo " make build-engine Build the engine image $(ENGINE_IMAGE) used by the dev sandbox"
@echo " make stop-engines Stop and remove only the per-game engine containers"
@echo " make prune-broken-engines Remove non-running engine containers Docker can't heal (run inside 'up')"
@echo " make clean Stop everything (incl. engines) and wipe volumes + game state"
@echo " make logs Tail all logs"
@echo " make logs-backend Tail only the backend logs"
@@ -32,10 +33,10 @@ help:
@echo "Default login for the auto-provisioned dev sandbox: dev@local.test"
@echo "(see BACKEND_DEV_SANDBOX_EMAIL in .env). Login code: 123456."
up: build-engine
up: build-engine prune-broken-engines
$(COMPOSE) up -d --wait
rebuild: build-engine
rebuild: build-engine prune-broken-engines
$(COMPOSE) build --no-cache backend gateway
$(COMPOSE) up -d --wait
@@ -70,6 +71,34 @@ stop-engines:
docker rm -f $$ids >/dev/null; \
fi
# Remove engine containers Docker can no longer heal on its own.
# After a host reboot, the per-game bind-mount source under
# /tmp/galaxy-game-state/<uuid> may have been wiped (macOS clears
# /private/tmp on reboot), so `restart: unless-stopped` cannot
# revive the container — Docker refuses to start it with a missing
# bind-mount source and leaves it stuck in `exited` / `created`
# state. This target prunes the husks before `compose up`; the
# backend's pre-bootstrap reconciler tick (`backend/cmd/backend/main.go`)
# then cascades the orphan runtime row to `removed`, the lobby
# cancels the game, and the dev-sandbox bootstrap purges the
# cancelled tile and provisions a fresh sandbox in the same
# `make up` cycle. Healthy `running` / `restarting` containers are
# left intact so a long-lived sandbox survives normal up/down
# cycles.
prune-broken-engines:
@ids=""; \
for cid in $$(docker ps -aq --filter label=$(ENGINE_LABEL) 2>/dev/null); do \
state=$$(docker inspect -f '{{.State.Status}}' $$cid 2>/dev/null); \
case "$$state" in \
running|restarting) ;; \
*) ids="$$ids $$cid";; \
esac; \
done; \
if [ -n "$$ids" ]; then \
echo "removing non-running engine containers (post-reboot cleanup):$$ids"; \
docker rm -f $$ids >/dev/null; \
fi
logs:
$(COMPOSE) logs -f --tail=100
+43 -6
View File
@@ -223,9 +223,35 @@ make status docker compose ps
Application → Storage → Clear site data) and log in again.
- **`make down` leaves a `galaxy-game-…` container behind** — fixed
in this Makefile: `make down` and `make clean` now stop spawned
engine containers via the `org.opencontainers.image.title=
galaxy-game-engine` label. To stop them by hand without touching
engine containers via the `galaxy.backend=1` label. To stop them by
hand without touching
the rest of the stack, `make stop-engines`.
- **Engine container exits with `bind source path does not exist:
/tmp/galaxy-game-state/<uuid>` after a host reboot** — macOS clears
`/private/tmp` on reboot, so the per-game state directory the
long-lived engine container bind-mounts is gone and Docker refuses
to restart it under `restart: unless-stopped`. `make up` auto-heals
this in one cycle: `prune-broken-engines` (runs as part of `up`)
removes every engine container that is not in `running` /
`restarting` state, the backend's pre-bootstrap reconciler tick
cascades the orphan runtime row to `removed`, the lobby cancels
the matching sandbox game, and the dev-sandbox bootstrap purges
the cancelled tile and provisions a fresh sandbox with a brand
new state directory. To run the cleanup by hand without restarting
the rest of the stack, `make prune-broken-engines`.
The cycle relies on the backend image carrying the pre-bootstrap
reconciler tick (`backend/cmd/backend/main.go`). `make up` reuses
the cached image, so after pulling this commit the first time you
must `make rebuild` once to bake the fix in. Future `make up`
cycles will heal in one shot.
If after the heal cycle the lobby still shows only a `cancelled`
sandbox tile and no running game, the running backend image
predates the pre-bootstrap reconciler tick — the periodic ticker
cancels the orphan after bootstrap has already returned, leaving
the lobby in the half-baked state. `make rebuild` recreates the
image and then `make up` lands a fresh sandbox.
- **`make up` reports a build error mentioning `pkg/cronutil`** —
upstream module list drifted; copy any new `pkg/<name>/` line into
the local-dev `backend.Dockerfile` / `gateway.Dockerfile` to match
@@ -242,10 +268,21 @@ make status docker compose ps
- **UI talks to old gateway**: Vite caches `import.meta.env` at boot.
Restart `pnpm dev` after editing
`ui/frontend/.env.development.local`.
- **Port 8080 already in use** — stop the conflicting service or
edit the host-side mapping in `docker-compose.yml` (gateway's
`ports:` entry) plus the matching `VITE_GATEWAY_BASE_URL` in
`ui/frontend/.env.development.local`.
- **Port 8080 already in use** (or any other host-port in the
stack — postgres `5433`, redis `6380`, mailpit `8025`, gateway
REST `8080`, gateway gRPC `9090`) — each host-port mapping in
`docker-compose.yml` is parameterised through
`LOCAL_DEV_*_PORT` with the listed values as defaults. Set a
non-conflicting value either by uncommenting / editing the entry
in `tools/local-dev/.env`, by exporting the variable in your
shell, or by dropping a local override into a
`tools/local-dev/docker-compose.override.yml` (compose
auto-merges that file and it stays untracked by git). When
moving the gateway REST port off `8080`, also point the Vite dev
server at the new host port via
`VITE_DEV_PROXY_TARGET=http://localhost:<port>` in
`ui/frontend/.env.development.local` (or exported per
`pnpm dev` invocation).
## Relationship to other infrastructure
+3
View File
@@ -13,6 +13,7 @@ FROM golang:1.26.2-alpine AS builder
WORKDIR /src
ENV CGO_ENABLED=0 GOFLAGS=-trimpath
COPY pkg/calc/ ./pkg/calc/
COPY pkg/cronutil/ ./pkg/cronutil/
COPY pkg/error/ ./pkg/error/
COPY pkg/geoip/ ./pkg/geoip/
@@ -28,6 +29,7 @@ go 1.26.2
use (
./backend
./pkg/calc
./pkg/cronutil
./pkg/error
./pkg/geoip
@@ -39,6 +41,7 @@ use (
)
replace (
galaxy/calc v0.0.0 => ./pkg/calc
galaxy/cronutil v0.0.0 => ./pkg/cronutil
galaxy/error v0.0.0 => ./pkg/error
galaxy/geoip v0.0.0 => ./pkg/geoip
+5 -5
View File
@@ -29,7 +29,7 @@ services:
POSTGRES_PASSWORD: galaxy
POSTGRES_DB: galaxy_backend
ports:
- "5433:5432"
- "${LOCAL_DEV_POSTGRES_PORT:-5433}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
@@ -54,7 +54,7 @@ services:
- --save
- ""
ports:
- "6380:6379"
- "${LOCAL_DEV_REDIS_PORT:-6380}:6379"
networks:
- galaxy-net
healthcheck:
@@ -69,7 +69,7 @@ services:
container_name: galaxy-local-dev-mailpit
restart: unless-stopped
ports:
- "8025:8025"
- "${LOCAL_DEV_MAILPIT_PORT:-8025}:8025"
networks:
- galaxy-net
healthcheck:
@@ -186,11 +186,11 @@ services:
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_REQUESTS: "10000"
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_MESSAGE_CLASS_RATE_LIMIT_BURST: "1000"
ports:
- "8080:8080"
- "${LOCAL_DEV_GATEWAY_REST_PORT:-8080}:8080"
# Authenticated EdgeGateway connect-web/gRPC listener. The
# browser reaches it via the Vite dev proxy in
# ui/frontend/vite.config.ts.
- "9090:9090"
- "${LOCAL_DEV_GATEWAY_GRPC_PORT:-9090}:9090"
volumes:
- ./keys/gateway-response.pem:/run/secrets/gateway-response.pem:ro
networks:
+3
View File
@@ -11,6 +11,7 @@ FROM golang:1.26.2-alpine AS builder
WORKDIR /src
ENV CGO_ENABLED=0 GOFLAGS=-trimpath
COPY pkg/calc/ ./pkg/calc/
COPY pkg/cronutil/ ./pkg/cronutil/
COPY pkg/error/ ./pkg/error/
COPY pkg/geoip/ ./pkg/geoip/
@@ -30,6 +31,7 @@ go 1.26.2
use (
./backend
./gateway
./pkg/calc
./pkg/cronutil
./pkg/error
./pkg/geoip
@@ -43,6 +45,7 @@ use (
)
replace (
galaxy/calc v0.0.0 => ./pkg/calc
galaxy/cronutil v0.0.0 => ./pkg/cronutil
galaxy/error v0.0.0 => ./pkg/error
galaxy/geoip v0.0.0 => ./pkg/geoip
+64 -24
View File
@@ -2391,44 +2391,84 @@ Targeted tests:
via the Research sub-row, delete it
(`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
toggle and the voting UI.
Goal: list other races with their visible stats, expose the war/peace
toggle, and the voting UI.
Artifacts:
- `ui/frontend/src/routes/games/[id]/table/races/+page.svelte` table
with one row per race, including name, tech levels, total
population, total production, planet count, war-or-peace from this
race's perspective, votes received. The race list itself is read
from `GameReport.otherRaces` (introduced in Phase 20 for the
ship-group transfer-to-race picker); the table view widens the
per-race shape (tech / population / production / planet count /
votes / relation) by walking `report.player[]` directly when those
fields are needed
- per-row toggle for declaring war or peace (adds
`SetDiplomaticStance` command)
- voting control: a single slot for `give my votes to <race>` (adds
`SetVoteRecipient` command)
- alliance summary panel showing the current vote graph and any
alliance reaching ≥ 2/3 of total votes
- `ui/frontend/src/lib/active-view/table-races.svelte` table mounted
by the dispatcher in
`ui/frontend/src/lib/active-view/table.svelte` (same pattern as
Phase 21's sciences table). One row per non-extinct other race
carrying name, tech levels (drive / weapons / shields / cargo as
percent), total population, total production (engine `industry`),
planet count, votes received, and the local player's stance
toward that race. The richer per-race projection
(`GameReport.races: ReportOtherRace[]`) is decoded in
`ui/frontend/src/api/game-state.ts` by walking `report.player[]`
once and surfacing the row alongside the existing `otherRaces:
string[]` (which keeps backing the ship-group transfer picker from
Phase 20)
- per-row segmented `WAR | PEACE` control. The active stance is
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.
Acceptance criteria:
- the user can toggle war / peace and change vote recipient;
- the alliance summary updates after a server roundtrip;
- vote counts match server state byte-for-byte.
- the per-row stance and the "I vote for" picker reflect the
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:
- Vitest component tests for the alliance summary on canonical fixtures
(chain of votes, fork, win condition);
- Playwright e2e: change diplomatic stance and vote, submit, confirm.
- Vitest component test
(`ui/frontend/tests/table-races.test.ts`) covering: render rows
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
+148 -7
View File
@@ -51,8 +51,13 @@ import type {
CommandStatus,
OrderCommand,
ProductionType,
Relation,
} from "../sync/order-types";
import {
CARGO_LOAD_TYPE_VALUES,
isCargoLoadType,
isRelation,
} from "../sync/order-types";
import { CARGO_LOAD_TYPE_VALUES, isCargoLoadType } from "../sync/order-types";
const MESSAGE_TYPE = "user.games.report";
@@ -239,6 +244,42 @@ export interface ReportLocalFleet {
state: string;
}
/**
* ReportOtherRace is the per-other-race projection rendered by the
* Phase 22 Races View. The fields mirror `report.fbs:Player` row-by-
* row, with `relation` narrowed to the wire-stable `Relation` union
* (the engine emits a `"-"` sentinel for the self row, which never
* appears in `GameReport.races` because self is filtered out by
* `decodeReport`). Tech values are float fractions — the table
* renders them through the same `formatPercent` helper the sciences
* table uses.
*
* `relation` reflects the local player's stance TOWARD this race,
* not the other way around (`rules.txt` line 1162). Per the engine
* (`controller/race.go.UpdateRelation`) the relation is stored
* unilaterally — race A can be at war with race B while race B is
* at peace with race A.
*
* `votesReceived` is the count of votes this race received in the
* last turn cutoff tally (`Player.votes` on the wire). The total
* game votes equal the sum of every non-extinct row's
* `votesReceived`, since every race always votes for someone
* (`controller/race.go` initialises `r.VoteFor = r.ID` on creation
* and reassigns to self on extinction of the voted-for race).
*/
export interface ReportOtherRace {
name: string;
drive: number;
weapons: number;
shields: number;
cargo: number;
population: number;
industry: number;
planets: number;
relation: Relation;
votesReceived: number;
}
export interface GameReport {
turn: number;
mapWidth: number;
@@ -314,12 +355,40 @@ export interface GameReport {
* `report.player[]` block in the FBS report (each `Player` row
* carries an `extinct` flag). The ship-group inspector consumes
* this list for the "transfer to race" picker; Phase 22's Races
* View reuses the same field so the read shape is stable across
* stages. Empty when the report has no `player` block (boot
* state, history-mode snapshots) or when the local player is the
* only non-extinct race.
* View also uses it for the vote-recipient picker so the read
* shape stays stable across stages. Empty when the report has no
* `player` block (boot state, history-mode snapshots) or when the
* local player is the only non-extinct race.
*/
otherRaces: string[];
/**
* races is the richer per-other-race projection Phase 22 added
* for the Races View table — same population (non-extinct, self
* excluded, alphabetical) as `otherRaces`, but with each row
* carrying tech levels, totals, planet count, the local player's
* stance toward that race, and the race's votes received. Rows
* with an unknown wire `relation` (anything other than `WAR` or
* `PEACE`) default to `PEACE` so the table never blanks out the
* toggle on an engine schema bump; the same row continues to
* appear in the table.
*/
races: ReportOtherRace[];
/**
* myVotes is the local player's total vote weight in the current
* report, read from `Report.votes` (the engine assigns one vote
* per 1000 population, see `rules.txt:1060`). Zero when the
* report has not been produced yet.
*/
myVotes: number;
/**
* myVoteFor is the race the local player currently votes for,
* read from `Report.vote_for`. Empty string when no value has
* been recorded yet (boot state) or when the engine emitted an
* empty string. The engine's default initial state is each race
* voting for itself (`controller/race.go`), so a stable game's
* report always carries a non-empty value.
*/
myVoteFor: string;
}
export async function fetchGameReport(
@@ -467,6 +536,7 @@ function decodeReport(report: Report): GameReport {
const routes = decodeReportRoutes(report);
const localTech = findLocalPlayerTech(report, raceName);
const otherRaces = collectOtherRaces(report, raceName);
const races = collectOtherRaceRows(report, raceName);
const localShipGroups = decodeLocalShipGroups(report);
const otherShipGroups = decodeOtherShipGroups(report);
const incomingShipGroups = decodeIncomingShipGroups(report);
@@ -493,6 +563,9 @@ function decodeReport(report: Report): GameReport {
unidentifiedShipGroups,
localFleets,
otherRaces,
races,
myVotes: report.votes(),
myVoteFor: report.voteFor() ?? "",
};
}
@@ -774,7 +847,7 @@ function findLocalPlayerTech(
* the alphabetically-sorted names of every non-extinct race other
* than the local player. Used by `GameReport.otherRaces` to back the
* ship-group inspector's transfer-to-race picker (Phase 20) and the
* Races View list (Phase 22).
* Races View vote-recipient picker (Phase 22).
*/
function collectOtherRaces(report: Report, raceName: string): string[] {
const out: string[] = [];
@@ -790,6 +863,46 @@ function collectOtherRaces(report: Report, raceName: string): string[] {
return out;
}
/**
* collectOtherRaceRows walks the `report.player[]` block and returns
* the richer per-race projection consumed by the Phase 22 Races
* View. Same filter as `collectOtherRaces` (non-extinct, named,
* self excluded), same alphabetical sort. The engine emits
* `Player.relation = "-"` on the self row only — that row is
* filtered out, so a non-`"WAR"`/`"PEACE"` value here would mean a
* schema bump; we fall back to `"PEACE"` and keep the row visible
* rather than dropping it silently.
*/
function collectOtherRaceRows(
report: Report,
raceName: string,
): ReportOtherRace[] {
const out: ReportOtherRace[] = [];
for (let i = 0; i < report.playerLength(); i++) {
const player = report.player(i);
if (player === null) continue;
if (player.extinct()) continue;
const name = player.name() ?? "";
if (name === "" || name === raceName) continue;
const wire = player.relation() ?? "";
const relation: Relation = isRelation(wire) ? wire : "PEACE";
out.push({
name,
drive: player.drive(),
weapons: player.weapons(),
shields: player.shields(),
cargo: player.cargo(),
population: player.population(),
industry: player.industry(),
planets: player.planets(),
relation,
votesReceived: player.votes(),
});
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
/**
* uuidToHiLo splits the canonical 36-character UUID string
* (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) into the two big-endian
@@ -841,6 +954,8 @@ export function applyOrderOverlay(
let mutatedRoutes: ReportRoute[] | null = null;
let mutatedShipClass: ShipClassSummary[] | null = null;
let mutatedScience: ScienceSummary[] | null = null;
let mutatedRaces: ReportOtherRace[] | null = null;
let mutatedVoteFor: string | null = null;
for (const cmd of commands) {
const status = statuses[cmd.id];
if (
@@ -964,12 +1079,36 @@ export function applyOrderOverlay(
mutatedScience.splice(idx, 1);
continue;
}
if (cmd.kind === "setDiplomaticStance") {
if (mutatedRaces === null) {
// `?? []` mirrors the per-branch HMR guard pattern: a
// running `gameState.report` produced before Phase 22's
// shape bump may not carry `races` yet — preserve a
// well-defined array on the way out so downstream
// `$derived` blocks (`races.map`, `races.find`, …)
// never fault on `undefined`.
mutatedRaces = [...(report.races ?? [])];
}
const idx = mutatedRaces.findIndex((r) => r.name === cmd.acceptor);
if (idx < 0) continue;
mutatedRaces[idx] = {
...mutatedRaces[idx]!,
relation: cmd.relation,
};
continue;
}
if (cmd.kind === "setVoteRecipient") {
mutatedVoteFor = cmd.acceptor;
continue;
}
}
if (
mutatedPlanets === null &&
mutatedRoutes === null &&
mutatedShipClass === null &&
mutatedScience === null
mutatedScience === null &&
mutatedRaces === null &&
mutatedVoteFor === null
) {
return report;
}
@@ -984,6 +1123,8 @@ export function applyOrderOverlay(
// `localScience.find`, …) fault and the active view blanks.
localShipClass: mutatedShipClass ?? report.localShipClass ?? [],
localScience: mutatedScience ?? report.localScience ?? [],
races: mutatedRaces ?? report.races ?? [],
myVoteFor: mutatedVoteFor ?? report.myVoteFor,
};
}
+41 -2
View File
@@ -23,6 +23,7 @@ import type {
ReportIncomingShipGroup,
ReportLocalFleet,
ReportLocalShipGroup,
ReportOtherRace,
ReportOtherShipGroup,
ReportPlanet,
ReportRoute,
@@ -31,8 +32,8 @@ import type {
ShipClassSummary,
ShipGroupTech,
} from "./game-state";
import type { CargoLoadType } from "../sync/order-types";
import { isCargoLoadType } from "../sync/order-types";
import type { CargoLoadType, Relation } from "../sync/order-types";
import { isCargoLoadType, isRelation } from "../sync/order-types";
export const SYNTHETIC_GAME_ID_PREFIX = "synthetic-";
@@ -103,6 +104,11 @@ interface SyntheticPlayer {
weapons: number;
shields: number;
cargo: number;
population?: number;
industry?: number;
planets?: number;
relation?: string;
votes?: number;
extinct?: boolean;
}
@@ -159,6 +165,8 @@ interface SyntheticReportRoot {
mapHeight?: number;
mapPlanets?: number;
race?: string;
votes?: number;
voteFor?: string;
player?: SyntheticPlayer[];
localPlanet?: SyntheticPlanet[];
otherPlanet?: SyntheticPlanet[];
@@ -290,6 +298,9 @@ function decodeSyntheticReport(json: unknown): GameReport {
unidentifiedShipGroups,
localFleets,
otherRaces: collectOtherRacesFromSynthetic(root, race),
races: collectOtherRaceRowsFromSynthetic(root, race),
myVotes: numOr0(root.votes),
myVoteFor: typeof root.voteFor === "string" ? root.voteFor : "",
};
}
@@ -308,6 +319,34 @@ function collectOtherRacesFromSynthetic(
return out;
}
function collectOtherRaceRowsFromSynthetic(
root: SyntheticReportRoot,
raceName: string,
): ReportOtherRace[] {
const out: ReportOtherRace[] = [];
for (const player of root.player ?? []) {
if (player.extinct === true) continue;
const name = typeof player.name === "string" ? player.name : "";
if (name === "" || name === raceName) continue;
const wire = typeof player.relation === "string" ? player.relation : "";
const relation: Relation = isRelation(wire) ? wire : "PEACE";
out.push({
name,
drive: numOr0(player.drive),
weapons: numOr0(player.weapons),
shields: numOr0(player.shields),
cargo: numOr0(player.cargo),
population: numOr0(player.population),
industry: numOr0(player.industry),
planets: Math.trunc(numOr0(player.planets)),
relation,
votesReceived: numOr0(player.votes),
});
}
out.sort((a, b) => a.name.localeCompare(b.name));
return out;
}
function toShipGroupTech(raw: Record<string, number> | undefined): ShipGroupTech {
const out: ShipGroupTech = { drive: 0, weapons: 0, shields: 0, cargo: 0 };
if (raw === undefined || raw === null) return out;
+11 -1
View File
@@ -96,7 +96,17 @@ preference the store already manages.
let detachClick: (() => void) | null = null;
let detachDebugProviders: (() => void) | null = null;
let detachDebugSurface: (() => void) | null = null;
let mounted = false;
// `mounted` must be `$state` so the renderer-mount effect re-runs
// once `onMount` flips it true. On the first map navigation the
// effect's initial pass returns early (gameState is still hydrating
// → `report` is null), and the subsequent server-driven `report`
// transition re-fires the effect after `onMount` has already
// completed. On a second navigation back to /map the report is
// already loaded — without reactivity here the effect's first
// pass would gate on `mounted === false`, and there would be no
// later state change to wake it up. The visible symptom is a
// black canvas (renderer never re-mounted on the new DOM).
let mounted = $state(false);
// Mount serialization. The `$effect` may re-fire while the
// async `mountRenderer` is mid-flight (e.g. report transitions
// from null → populated → overlay-mutated during boot). Without
@@ -0,0 +1,462 @@
<!--
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;
// No-op when the row already reflects the requested stance — the
// engine would accept the duplicate, but queueing a wire entry
// that re-states the current state inflates the order tab and
// the auto-sync envelope for nothing. The current stance reads
// off the overlay (`races[i].relation`), so a queued-but-not-
// applied stance change correctly suppresses a duplicate click
// that matches the *queued* intent, not just the server snapshot.
const current = races.find((r) => r.name === acceptor)?.relation;
if (current === relation) return;
await draft.add({
kind: "setDiplomaticStance",
id: crypto.randomUUID(),
acceptor,
relation,
});
}
async function pickVote(event: Event): Promise<void> {
if (draft === undefined) return;
const select = event.currentTarget as HTMLSelectElement;
const acceptor = select.value;
if (acceptor === "") return;
if (acceptor === myVoteFor) return;
await draft.add({
kind: "setVoteRecipient",
id: crypto.randomUUID(),
acceptor,
});
}
</script>
<section
class="active-view"
data-testid="active-view-table"
data-entity="races"
>
<header>
<h2>{i18n.t("game.table.races.title")}</h2>
<div class="summary">
<span class="summary-cell" data-testid="races-my-votes">
<span class="summary-label">
{i18n.t("game.table.races.votes.mine")}:
</span>
<span class="summary-value">{formatVotes(myVotes)}</span>
</span>
<label class="summary-cell vote-picker">
<span class="summary-label">
{i18n.t("game.table.races.votes.target")}:
</span>
<select
data-testid="races-vote-target"
value={myVoteFor}
disabled={!reportLoaded || races.length === 0}
onchange={pickVote}
>
<option value="" disabled>
{i18n.t("game.table.races.votes.target_placeholder")}
</option>
{#each races as r (r.name)}
<option value={r.name}>{r.name}</option>
{/each}
</select>
</label>
</div>
<p class="note" data-testid="races-alliance-note">
{i18n.t("game.table.races.note.alliance_server_side")}
</p>
<div class="controls">
<input
type="search"
class="filter"
data-testid="races-filter"
placeholder={i18n.t("game.table.races.filter.placeholder")}
bind:value={filter}
/>
</div>
</header>
{#if !reportLoaded}
<p class="status" data-testid="races-loading">
{i18n.t("game.table.races.loading")}
</p>
{:else if races.length === 0}
<p class="status" data-testid="races-empty">
{i18n.t("game.table.races.empty")}
</p>
{:else}
<table class="grid" data-testid="races-table">
<thead>
<tr>
{#each COLUMNS as column (column)}
<th aria-sort={ariaSort(column)}>
<button
type="button"
class="sort"
data-testid="races-column-{column}"
onclick={() => toggleSort(column)}
>
{i18n.t(COLUMN_LABELS[column])}
{#if sortColumn === column}
<span class="sort-indicator" aria-hidden="true">
{sortDirection === "asc" ? "▲" : "▼"}
</span>
{/if}
</button>
</th>
{/each}
<th>{i18n.t("game.table.races.column.relation")}</th>
</tr>
</thead>
<tbody>
{#each sorted as r (r.name)}
<tr data-testid="races-row" data-name={r.name}>
<td data-testid="races-cell-name">{r.name}</td>
<td data-testid="races-cell-drive">{formatPercent(r.drive)}</td>
<td data-testid="races-cell-weapons">
{formatPercent(r.weapons)}
</td>
<td data-testid="races-cell-shields">
{formatPercent(r.shields)}
</td>
<td data-testid="races-cell-cargo">{formatPercent(r.cargo)}</td>
<td data-testid="races-cell-population">
{formatCount(r.population)}
</td>
<td data-testid="races-cell-industry">
{formatCount(r.industry)}
</td>
<td data-testid="races-cell-planets">{formatCount(r.planets)}</td>
<td data-testid="races-cell-votes">
{formatVotes(r.votesReceived)}
</td>
<td>
<div
class="stance"
role="group"
aria-label={i18n.t("game.table.races.column.relation")}
>
<button
type="button"
class="stance-button war"
class:active={r.relation === "WAR"}
aria-pressed={r.relation === "WAR"}
data-testid="races-stance-war"
onclick={() => void setStance(r.name, "WAR")}
>
{i18n.t("game.table.races.action.war")}
</button>
<button
type="button"
class="stance-button peace"
class:active={r.relation === "PEACE"}
aria-pressed={r.relation === "PEACE"}
data-testid="races-stance-peace"
onclick={() => void setStance(r.name, "PEACE")}
>
{i18n.t("game.table.races.action.peace")}
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
.active-view {
padding: 1.5rem;
font-family: system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 1rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
header h2 {
margin: 0;
font-size: 1.1rem;
}
.summary {
display: flex;
gap: 1.25rem;
align-items: center;
flex-wrap: wrap;
font-size: 0.9rem;
}
.summary-cell {
display: inline-flex;
gap: 0.4rem;
align-items: baseline;
}
.summary-label {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.summary-value {
color: #e8eaf6;
font-variant-numeric: tabular-nums;
}
.vote-picker select {
font: inherit;
padding: 0.2rem 0.4rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
}
.vote-picker select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.note {
margin: 0;
color: #889;
font-size: 0.8rem;
line-height: 1.35;
}
.controls {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.filter {
font: inherit;
padding: 0.3rem 0.5rem;
background: #0a0e1a;
color: #e8eaf6;
border: 1px solid #2a3150;
border-radius: 3px;
flex: 1 1 12rem;
min-width: 8rem;
}
.status {
margin: 0;
color: #888;
font-size: 0.9rem;
}
.grid {
border-collapse: collapse;
width: 100%;
font-variant-numeric: tabular-nums;
}
.grid th,
.grid td {
padding: 0.4rem 0.6rem;
text-align: left;
border-bottom: 1px solid #1c2240;
font-size: 0.9rem;
}
.grid th {
color: #aab;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.grid tbody tr:hover {
background: #11172a;
}
.sort {
font: inherit;
font-size: inherit;
text-transform: inherit;
letter-spacing: inherit;
color: inherit;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
display: inline-flex;
gap: 0.3rem;
align-items: baseline;
}
.sort-indicator {
font-size: 0.7em;
}
.stance {
display: inline-flex;
gap: 0.25rem;
}
.stance-button {
font: inherit;
font-size: 0.8rem;
letter-spacing: 0.05em;
padding: 0.2rem 0.55rem;
background: transparent;
color: #aab;
border: 1px solid #2a3150;
border-radius: 3px;
cursor: pointer;
}
.stance-button:hover {
color: #e8eaf6;
}
.stance-button.war.active {
background: #4a1010;
color: #ffcaca;
border-color: #8a3030;
}
.stance-button.peace.active {
background: #103a1a;
color: #c8f2cf;
border-color: #2f7a45;
}
</style>
+10 -7
View File
@@ -1,17 +1,18 @@
<!--
Active-view router for the per-entity tables. Phase 17 lights up
the ship-classes table; Phase 21 lights up the sciences table; the
remaining slugs (planets, ship-groups, fleets, races) keep the
Phase 10 stub copy until their respective phases land. The wrapper
preserves `data-testid="active-view-table"` and
`data-entity={entity}` for every branch (each leaf component
mirrors them) so the navigation e2e specs (`game-shell.spec.ts`,
`view-menu`) keep matching.
the ship-classes table; Phase 21 lights up the sciences table;
Phase 22 lights up the races table; the remaining slugs (planets,
ship-groups, fleets) keep the Phase 10 stub copy until their
respective phases land. The wrapper preserves
`data-testid="active-view-table"` and `data-entity={entity}` for
every branch (each leaf component mirrors them) so the navigation
e2e specs (`game-shell.spec.ts`, `view-menu`) keep matching.
-->
<script lang="ts">
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import TableShipClasses from "./table-ship-classes.svelte";
import TableSciences from "./table-sciences.svelte";
import TableRaces from "./table-races.svelte";
type Props = { entity: string };
let { entity }: Props = $props();
@@ -26,6 +27,8 @@ mirrors them) so the navigation e2e specs (`game-shell.spec.ts`,
<TableShipClasses />
{:else if entity === "sciences"}
<TableSciences />
{:else if entity === "races"}
<TableRaces />
{:else}
<section
class="active-view"
+23
View File
@@ -204,6 +204,8 @@ const en = {
"game.sidebar.order.label.ship_group_dismantle": "dismantle group {group}",
"game.sidebar.order.label.ship_group_transfer": "transfer group {group} → {acceptor}",
"game.sidebar.order.label.ship_group_join_fleet": "assign group {group} → fleet {fleet}",
"game.sidebar.order.label.race_relation": "declare {relation} on {acceptor}",
"game.sidebar.order.label.race_vote": "give my votes to {acceptor}",
"game.table.ship_classes.title": "ship classes",
"game.table.ship_classes.column.name": "name",
"game.table.ship_classes.column.drive": "drive",
@@ -297,6 +299,27 @@ const en = {
"game.designer.science.invalid.cargo_value": "cargo % must be in [0, 100]",
"game.designer.science.invalid.sum_not_hundred": "the four percentages must sum to exactly 100",
"game.table.races.title": "races",
"game.table.races.loading": "loading races…",
"game.table.races.empty": "no other races known yet",
"game.table.races.filter.placeholder": "filter by name",
"game.table.races.column.name": "name",
"game.table.races.column.drive": "drive %",
"game.table.races.column.weapons": "weapons %",
"game.table.races.column.shields": "shields %",
"game.table.races.column.cargo": "cargo %",
"game.table.races.column.population": "population",
"game.table.races.column.industry": "production",
"game.table.races.column.planets": "planets",
"game.table.races.column.votes": "votes received",
"game.table.races.column.relation": "stance",
"game.table.races.action.war": "WAR",
"game.table.races.action.peace": "PEACE",
"game.table.races.votes.mine": "my votes",
"game.table.races.votes.target": "I vote for",
"game.table.races.votes.target_placeholder": "— select a race —",
"game.table.races.note.alliance_server_side": "alliances and the 2/3 victory are tallied by the server at turn cutoff; this table shows only my outgoing vote and the votes each race received in the last tally",
"game.inspector.ship_group.kind.local": "your group",
"game.inspector.ship_group.kind.other": "other race group",
"game.inspector.ship_group.kind.incoming": "incoming group",
+23
View File
@@ -205,6 +205,8 @@ const ru: Record<keyof typeof en, string> = {
"game.sidebar.order.label.ship_group_dismantle": "разобрать группу {group}",
"game.sidebar.order.label.ship_group_transfer": "передать группу {group} → {acceptor}",
"game.sidebar.order.label.ship_group_join_fleet": "включить группу {group} → флот {fleet}",
"game.sidebar.order.label.race_relation": "объявить {relation} расе {acceptor}",
"game.sidebar.order.label.race_vote": "отдать голоса расе {acceptor}",
"game.table.ship_classes.title": "классы кораблей",
"game.table.ship_classes.column.name": "название",
"game.table.ship_classes.column.drive": "двигатель",
@@ -298,6 +300,27 @@ const ru: Record<keyof typeof en, string> = {
"game.designer.science.invalid.cargo_value": "трюм % должен быть в [0, 100]",
"game.designer.science.invalid.sum_not_hundred": "сумма четырёх процентов должна быть ровно 100",
"game.table.races.title": "расы",
"game.table.races.loading": "загрузка рас…",
"game.table.races.empty": "других рас пока не видно",
"game.table.races.filter.placeholder": "фильтр по имени",
"game.table.races.column.name": "имя",
"game.table.races.column.drive": "двигатель %",
"game.table.races.column.weapons": "оружие %",
"game.table.races.column.shields": "защита %",
"game.table.races.column.cargo": "трюм %",
"game.table.races.column.population": "население",
"game.table.races.column.industry": "производство",
"game.table.races.column.planets": "планет",
"game.table.races.column.votes": "получено голосов",
"game.table.races.column.relation": "отношение",
"game.table.races.action.war": "ВОЙНА",
"game.table.races.action.peace": "МИР",
"game.table.races.votes.mine": "мои голоса",
"game.table.races.votes.target": "голосую за",
"game.table.races.votes.target_placeholder": "— выберите расу —",
"game.table.races.note.alliance_server_side": "альянсы и победу 2/3 подсчитывает сервер при просчёте хода; в этой таблице видно лишь мой исходящий голос и количество голосов, полученных каждой расой в прошлой раздаче",
"game.inspector.ship_group.kind.local": "ваша группа",
"game.inspector.ship_group.kind.other": "группа другой расы",
"game.inspector.ship_group.kind.incoming": "входящая группа",
@@ -126,6 +126,15 @@ Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
group: shortGroupId(cmd.groupId),
fleet: cmd.name,
});
case "setDiplomaticStance":
return i18n.t("game.sidebar.order.label.race_relation", {
relation: cmd.relation,
acceptor: cmd.acceptor,
});
case "setVoteRecipient":
return i18n.t("game.sidebar.order.label.race_vote", {
acceptor: cmd.acceptor,
});
}
}
@@ -25,6 +25,7 @@ import type { Cache } from "../platform/store/index";
import type { GalaxyClient } from "../api/galaxy-client";
import { fetchOrder } from "./order-load";
import {
isRelation,
isShipGroupCargo,
isShipGroupUpgradeTech,
type CommandStatus,
@@ -193,6 +194,14 @@ export class OrderDraftStore {
* a newer entry for the same slot supersedes any prior
* `set` or `remove` for that slot. Different load-types or
* different sources coexist.
* - `setDiplomaticStance` collapses by `acceptor`: the engine
* tracks a single war/peace stance per opponent, so a newer
* entry supersedes any prior `setDiplomaticStance` for the
* same other race.
* - `setVoteRecipient` collapses singleton: per `rules.txt`
* each race controls a single vote slot, so a newer entry
* supersedes any prior `setVoteRecipient` regardless of the
* acceptor.
* - `planetRename` and `placeholder` append unconditionally;
* each rename is a distinct user-visible action.
*/
@@ -231,6 +240,29 @@ export class OrderDraftStore {
nextCommands.push(existing);
}
nextCommands.push(command);
} else if (command.kind === "setDiplomaticStance") {
nextCommands = [];
for (const existing of this.commands) {
if (
existing.kind === "setDiplomaticStance" &&
existing.acceptor === command.acceptor
) {
removed.push(existing.id);
continue;
}
nextCommands.push(existing);
}
nextCommands.push(command);
} else if (command.kind === "setVoteRecipient") {
nextCommands = [];
for (const existing of this.commands) {
if (existing.kind === "setVoteRecipient") {
removed.push(existing.id);
continue;
}
nextCommands.push(existing);
}
nextCommands.push(command);
} else {
nextCommands = [...this.commands, command];
}
@@ -602,6 +634,25 @@ function validateCommand(cmd: OrderCommand): CommandStatus {
if (!validateEntityName(cmd.name).ok) return "invalid";
if (!isUuid(cmd.groupId)) return "invalid";
return "valid";
case "setDiplomaticStance":
// `acceptor` is the opponent's race name; race names follow
// the same entity-name rules as planet/fleet names. The
// races-table view restricts the per-row picker to live
// `GameReport.races[]` entries, so a locally-valid name is
// always a real race. `relation` must be one of the two
// wire-stable values (`WAR` or `PEACE`); the FBS
// `UNKNOWN = 0` sentinel is never emitted.
if (!validateEntityName(cmd.acceptor).ok) return "invalid";
if (!isRelation(cmd.relation)) return "invalid";
return "valid";
case "setVoteRecipient":
// `acceptor` is the race the local player votes for. The
// engine accepts a self-vote as the neutral default
// (`controller/race.go`), so the table picker may include
// the local race as a valid choice. Local validation only
// guards the name shape.
if (!validateEntityName(cmd.acceptor).ok) return "invalid";
return "valid";
case "placeholder":
// Phase 12 placeholder entries are content-free and never
// transition out of `draft` — they are not submittable.
+48
View File
@@ -16,6 +16,8 @@ import {
CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
CommandRaceRelation,
CommandRaceVote,
CommandScienceCreate,
CommandScienceRemove,
CommandShipClassCreate,
@@ -30,6 +32,7 @@ import {
CommandShipGroupUpgrade,
PlanetProduction,
PlanetRouteLoadType,
Relation,
ShipGroupCargo,
ShipGroupUpgradeTech,
UserGamesOrderGet,
@@ -39,6 +42,7 @@ import type {
CargoLoadType,
OrderCommand,
ProductionType,
Relation as RelationLiteral,
ShipGroupCargo as ShipGroupCargoLiteral,
ShipGroupUpgradeTech as ShipGroupUpgradeTechLiteral,
} from "./order-types";
@@ -354,6 +358,32 @@ function decodeCommand(item: CommandItemView): OrderCommand | null {
name: inner.name() ?? "",
};
}
case CommandPayload.CommandRaceRelation: {
const inner = new CommandRaceRelation();
item.payload(inner);
const relation = relationFromFBS(inner.relation());
if (relation === null) {
console.warn(
`fetchOrder: skipping CommandRaceRelation with unknown relation enum (${inner.relation()})`,
);
return null;
}
return {
kind: "setDiplomaticStance",
id,
acceptor: inner.acceptor() ?? "",
relation,
};
}
case CommandPayload.CommandRaceVote: {
const inner = new CommandRaceVote();
item.payload(inner);
return {
kind: "setVoteRecipient",
id,
acceptor: inner.acceptor() ?? "",
};
}
default:
console.warn(
`fetchOrder: skipping unknown command kind (payloadType=${payloadType})`,
@@ -469,6 +499,24 @@ export function shipGroupUpgradeTechFromFBS(
}
}
/**
* relationFromFBS reverses `relationToFBS` from `submit.ts`.
* `Relation.UNKNOWN` and any out-of-band value yield `null` so the
* caller drops the entry rather than fabricating a synthetic stance.
*/
export function relationFromFBS(value: Relation): RelationLiteral | null {
switch (value) {
case Relation.WAR:
return "WAR";
case Relation.PEACE:
return "PEACE";
case Relation.UNKNOWN:
return null;
default:
return null;
}
}
function decodeError(
payload: Uint8Array,
resultCode: string,
+73 -1
View File
@@ -409,6 +409,76 @@ export interface JoinFleetShipGroupCommand {
readonly name: string;
}
/**
* Relation mirrors the engine `Relation` enum
* (`pkg/schema/fbs/order.fbs`). Two wire-stable values: `WAR` (the
* local player declares hostilities toward the named race) and
* `PEACE` (the local player declares peaceful relations). The engine
* stores relations per-actor and asymmetrically — race A can be at
* war with race B while race B is at peace with race A
* (`game/internal/controller/race.go.UpdateRelation`). The FBS
* `UNKNOWN = 0` sentinel is never emitted by the client.
*/
export type Relation = "WAR" | "PEACE";
/**
* RELATION_VALUES is the canonical tuple of `Relation` literals.
* Used by validators and by the FBS converters in `submit.ts` and
* `order-load.ts` to narrow incoming strings.
*/
export const RELATION_VALUES = [
"WAR",
"PEACE",
] as const satisfies readonly Relation[];
/**
* isRelation narrows an arbitrary string to the `Relation` union.
* The decoder uses this when reading back a server-stored command
* whose `relation` arrived as a generic string.
*/
export function isRelation(value: string): value is Relation {
return (RELATION_VALUES as readonly string[]).includes(value);
}
/**
* SetDiplomaticStanceCommand declares the local player's relation
* (war or peace) toward another race. Mirrors the engine
* `CommandRaceRelation` (`pkg/schema/fbs/order.fbs`,
* `game/internal/controller/command.go.RaceRelation`). The relation
* is unilateral; the targeted race keeps its own opinion of us.
*
* Phase 22 carries a collapse-by-`acceptor` rule: a newer entry
* supersedes any prior `setDiplomaticStance` for the same opponent,
* so the draft holds at most one stance intent per other race.
*/
export interface SetDiplomaticStanceCommand {
readonly kind: "setDiplomaticStance";
readonly id: string;
readonly acceptor: string;
readonly relation: Relation;
}
/**
* SetVoteRecipientCommand binds the local player's single vote slot
* to a race. Mirrors the engine `CommandRaceVote`
* (`pkg/schema/fbs/order.fbs`,
* `game/internal/controller/command.go.RaceVote`). The engine
* tallies votes at turn cutoff (`rules.txt` "Процесс голосования");
* between turns the player can change their pick freely. The
* acceptor may be the local race itself — the engine treats
* self-vote as the neutral default and re-applies it whenever a
* voted-for race goes extinct (`controller/race.go`).
*
* Phase 22 carries a singleton collapse rule: a newer entry replaces
* any prior `setVoteRecipient`, regardless of target — the player
* has only one outgoing vote slot.
*/
export interface SetVoteRecipientCommand {
readonly kind: "setVoteRecipient";
readonly id: string;
readonly acceptor: string;
}
/**
* OrderCommand is the discriminated union of every command shape the
* local order draft can hold. The `kind` field is the discriminator;
@@ -432,7 +502,9 @@ export type OrderCommand =
| UpgradeShipGroupCommand
| DismantleShipGroupCommand
| TransferShipGroupCommand
| JoinFleetShipGroupCommand;
| JoinFleetShipGroupCommand
| SetDiplomaticStanceCommand
| SetVoteRecipientCommand;
/**
* PRODUCTION_TYPE_VALUES is the canonical tuple of `ProductionType`
+42
View File
@@ -31,6 +31,8 @@ import {
CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
CommandRaceRelation,
CommandRaceVote,
CommandScienceCreate,
CommandScienceRemove,
CommandShipClassCreate,
@@ -45,6 +47,7 @@ import {
CommandShipGroupUpgrade,
PlanetProduction,
PlanetRouteLoadType,
Relation,
ShipGroupCargo,
ShipGroupUpgradeTech,
UserGamesOrder,
@@ -54,6 +57,7 @@ import type {
CargoLoadType,
OrderCommand,
ProductionType,
Relation as RelationLiteral,
ShipGroupCargo as ShipGroupCargoLiteral,
ShipGroupUpgradeTech as ShipGroupUpgradeTechLiteral,
} from "./order-types";
@@ -365,6 +369,29 @@ function encodeCommandPayload(
payloadOffset: offset,
};
}
case "setDiplomaticStance": {
const acceptorOffset = builder.createString(cmd.acceptor);
const offset = CommandRaceRelation.createCommandRaceRelation(
builder,
acceptorOffset,
relationToFBS(cmd.relation),
);
return {
payloadType: CommandPayload.CommandRaceRelation,
payloadOffset: offset,
};
}
case "setVoteRecipient": {
const acceptorOffset = builder.createString(cmd.acceptor);
const offset = CommandRaceVote.createCommandRaceVote(
builder,
acceptorOffset,
);
return {
payloadType: CommandPayload.CommandRaceVote,
payloadOffset: offset,
};
}
case "placeholder":
throw new SubmitError(
"invalid_request",
@@ -463,6 +490,21 @@ export function shipGroupUpgradeTechToFBS(
}
}
/**
* relationToFBS converts the wire-stable `Relation` literal to the
* FlatBuffers enum value. Mirrors `pkg/transcoder/order.go`. The FBS
* enum carries an `UNKNOWN` zero default; the encoder always emits
* one of the two real values (`WAR` or `PEACE`).
*/
export function relationToFBS(value: RelationLiteral): Relation {
switch (value) {
case "WAR":
return Relation.WAR;
case "PEACE":
return Relation.PEACE;
}
}
function decodeOrderResponse(
payload: Uint8Array,
commands: OrderCommand[],
+46 -1
View File
@@ -16,12 +16,15 @@ import {
CommandPlanetRename,
CommandPlanetRouteRemove,
CommandPlanetRouteSet,
CommandRaceRelation,
CommandRaceVote,
CommandScienceCreate,
CommandScienceRemove,
CommandShipClassCreate,
CommandShipClassRemove,
PlanetProduction,
PlanetRouteLoadType,
Relation,
UserGamesOrder,
UserGamesOrderGetResponse,
UserGamesOrderResponse,
@@ -98,6 +101,19 @@ export interface RemoveScienceResultFixture extends CommandResultFixtureBase {
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 =
| PlanetRenameResultFixture
| SetProductionTypeResultFixture
@@ -106,7 +122,9 @@ export type CommandResultFixture =
| CreateShipClassResultFixture
| RemoveShipClassResultFixture
| CreateScienceResultFixture
| RemoveScienceResultFixture;
| RemoveScienceResultFixture
| SetDiplomaticStanceResultFixture
| SetVoteRecipientResultFixture;
export function buildOrderResponsePayload(
gameId: string,
@@ -255,6 +273,22 @@ function encodeItem(builder: Builder, c: CommandResultFixture): number {
payloadType = CommandPayload.CommandScienceRemove;
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.addCmdId(builder, cmdIdOffset);
@@ -304,3 +338,14 @@ function cargoLoadTypeToFBS(
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 {
name: string;
drive?: number;
weapons?: number;
shields?: number;
cargo?: number;
population?: number;
industry?: number;
planets?: number;
relation?: "WAR" | "PEACE" | "-";
votes?: number;
extinct?: boolean;
}
export interface RouteEntryFixture {
@@ -98,6 +107,8 @@ export interface ReportFixture {
race?: string;
players?: PlayerFixture[];
routes?: RouteFixture[];
myVotes?: number;
myVoteFor?: string;
}
export function buildReportPayload(fixture: ReportFixture): Uint8Array {
@@ -202,9 +213,20 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
const playerOffsets = (fixture.players ?? []).map((p) => {
const name = builder.createString(p.name);
const relation =
p.relation === undefined ? null : builder.createString(p.relation);
Player.startPlayer(builder);
Player.addName(builder, name);
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);
});
@@ -257,6 +279,10 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
: Report.createRouteVector(builder, routeOffsets);
const raceOffset =
fixture.race === undefined ? null : builder.createString(fixture.race);
const voteForOffset =
fixture.myVoteFor === undefined
? null
: builder.createString(fixture.myVoteFor);
const totalPlanets =
(fixture.localPlanets ?? []).length +
@@ -270,6 +296,8 @@ export function buildReportPayload(fixture: ReportFixture): Uint8Array {
Report.addHeight(builder, fixture.mapHeight ?? 4000);
Report.addPlanetCount(builder, totalPlanets);
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 (localVec !== null) Report.addLocalPlanet(builder, localVec);
if (otherVec !== null) Report.addOtherPlanet(builder, otherVec);
+234
View File
@@ -0,0 +1,234 @@
// Regression for the user-reported "black canvas after returning to
// /map via the dropdown menu". Walks every non-map view the menu
// exposes, returns to /map through the same menu, and asserts the
// renderer mounts cleanly: status `ready`, no mount-error overlay,
// no console errors, AND the canvas pixel buffer is non-empty (a
// fully-black screen would mean the renderer mounted but never
// drew anything onto the new canvas).
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 { 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,
} from "./fixtures/order-fbs";
const SESSION_ID = "phase-21-map-roundtrip-session";
const GAME_ID = "21212121-cafe-cafe-cafe-cafecafecaff";
async function mockGateway(page: Page): Promise<void> {
const game: GameFixture = {
gameId: GAME_ID,
gameName: "Map Roundtrip",
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,
};
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",
players: [{ name: "Earthlings", drive: 1 }],
localPlanets: [
{
number: 1,
name: "Earth",
x: 2000,
y: 2000,
size: 1000,
resources: 5,
population: 800,
industry: 600,
},
],
});
break;
}
case "user.games.order":
payload = buildOrderResponsePayload(GAME_ID, [], Date.now());
break;
case "user.games.order.get":
UserGamesOrderGet.getRootAsUserGamesOrderGet(
new ByteBuffer(req.payloadBytes),
);
payload = buildOrderGetResponsePayload(GAME_ID, [], Date.now(), false);
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>(() => {});
},
);
}
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,
);
}
// Reads the renderer's primitive count off the DEV-only debug
// surface (`window.__galaxyDebug.getMapPrimitives`, installed by
// `lib/active-view/map.svelte` on mount). A healthy renderer
// surfaces every world primitive — at least one planet point in
// this fixture. An empty list means the renderer re-mounted but
// never bound the new world snapshot, which is the user-reported
// "black canvas" symptom: the canvas DOM is fresh but Pixi never
// rebuilt the primitive graphics on it.
async function readPrimitiveCount(page: Page): Promise<number> {
return page.evaluate(() => {
const surface = (window as unknown as {
__galaxyDebug?: {
getMapPrimitives?: () => readonly unknown[];
};
}).__galaxyDebug;
const prims = surface?.getMapPrimitives?.();
if (prims === undefined) return -1;
return prims.length;
});
}
const NON_MAP_VIEWS: ReadonlyArray<{ label: string; testid: string }> = [
{ label: "report", testid: "view-menu-item-report" },
{ label: "designer-ship-class", testid: "view-menu-item-designer-ship-class" },
{ label: "designer-science", testid: "view-menu-item-designer-science" },
{ label: "mail", testid: "view-menu-item-mail" },
];
for (const view of NON_MAP_VIEWS) {
test(`map → ${view.label} → map keeps the renderer alive`, async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"desktop layout only; mobile reuses the same store",
);
const consoleErrors: string[] = [];
page.on("pageerror", (err) =>
consoleErrors.push(`pageerror: ${err.message}`),
);
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push(`console.error: ${msg.text()}`);
}
});
await mockGateway(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await expect(page.getByTestId("map-mount-error")).toHaveCount(0);
await expect
.poll(() => readPrimitiveCount(page), {
message: "first /map mount should publish primitives onto the debug surface",
timeout: 3000,
})
.toBeGreaterThan(0);
// Navigate via the dropdown to the non-map view.
await page.getByTestId("view-menu-trigger").click();
await page.getByTestId(view.testid).click();
await expect(
page.getByTestId(`active-view-${view.label}`),
).toBeVisible();
// Navigate back to /map via the same dropdown.
await page.getByTestId("view-menu-trigger").click();
await page.getByTestId("view-menu-item-map").click();
await expect(page.getByTestId("active-view-map")).toBeVisible();
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await expect(page.getByTestId("map-mount-error")).toHaveCount(0);
// The renderer must rebind primitives after the round-trip.
// An empty list here is the "black canvas" symptom — the
// canvas DOM is fresh, no mount-error overlay, but Pixi
// never repopulated the world.
await expect
.poll(() => readPrimitiveCount(page), {
message: `renderer published no primitives after returning from ${view.label} to /map`,
timeout: 3000,
})
.toBeGreaterThan(0);
expect(consoleErrors, consoleErrors.join("\n")).toEqual([]);
});
}
+316
View File
@@ -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");
});
+13 -5
View File
@@ -1,13 +1,15 @@
// EMPTY_SHIP_GROUPS supplies empty arrays for the ancillary report
// fields added in Phase 19 (ship-groups + fleets) and Phase 21
// (sciences). Test fixtures spread it into their report objects so
// the fixture body still focuses on the fields under test, without
// forcing every spec to enumerate the full GameReport surface.
// EMPTY_SHIP_GROUPS supplies empty arrays / zero defaults for the
// ancillary report fields added in Phase 19 (ship-groups + fleets),
// Phase 21 (sciences), and Phase 22 (races / diplomacy / voting).
// Test fixtures spread it into their report objects so the fixture
// body still focuses on the fields under test, without forcing
// every spec to enumerate the full GameReport surface.
import type {
ReportIncomingShipGroup,
ReportLocalFleet,
ReportLocalShipGroup,
ReportOtherRace,
ReportOtherShipGroup,
ReportUnidentifiedShipGroup,
ScienceSummary,
@@ -21,6 +23,9 @@ export const EMPTY_SHIP_GROUPS: {
localFleets: ReportLocalFleet[];
otherRaces: string[];
localScience: ScienceSummary[];
races: ReportOtherRace[];
myVotes: number;
myVoteFor: string;
} = {
localShipGroups: [],
otherShipGroups: [],
@@ -29,4 +34,7 @@ export const EMPTY_SHIP_GROUPS: {
localFleets: [],
otherRaces: [],
localScience: [],
races: [],
myVotes: 0,
myVoteFor: "",
};
@@ -69,6 +69,9 @@ function makeReport(
unidentifiedShipGroups: [],
localFleets: [],
otherRaces: [],
races: [],
myVotes: 0,
myVoteFor: "",
...overrides,
};
}
+335
View File
@@ -0,0 +1,335 @@
// 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 the already-active stance is a no-op (no command queued)", async () => {
const ui = mountTable(
makeReport([race({ name: "Andori", relation: "WAR" })]),
);
await fireEvent.click(ui.getByTestId("races-stance-war"));
// Give the async handler one microtask to settle, then assert
// the draft remained empty — the click matched the current
// stance, so nothing should land in the order queue.
await Promise.resolve();
expect(draft.commands).toHaveLength(0);
});
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");
});
});
+64 -23
View File
@@ -1,5 +1,5 @@
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
import { defineConfig, loadEnv } from "vite";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
@@ -10,30 +10,69 @@ const pkg = JSON.parse(
),
) as { version: string };
// Default upstream gateway addresses used by the dev proxy. Override
// by pointing `VITE_DEV_PROXY_TARGET` (REST surface) and
// `VITE_DEV_GRPC_PROXY_TARGET` (Connect-Web surface) at a different
// gateway when working with a remote stack instead of
// `tools/local-dev/`. In production the two surfaces sit behind a
// single host; the split here exists only because local-dev runs the
// REST listener on :8080 and the authenticated Connect-Web listener
// on :9090.
const DEV_PROXY_TARGET =
process.env.VITE_DEV_PROXY_TARGET ?? "http://localhost:8080";
const DEV_GRPC_PROXY_TARGET =
process.env.VITE_DEV_GRPC_PROXY_TARGET ?? "http://localhost:9090";
// Parse the VITE_DEV_HOST override into the shape `server.host`
// expects. `""` / `"false"` keeps Vite's safe default (loopback
// only); `"true"` / `"1"` flips to "all interfaces" (`0.0.0.0` plus
// IPv6); any other string is passed through verbatim so a developer
// can pin a single LAN address (e.g. `"192.168.1.5"`). Returning
// `undefined` lets Vite stay on its built-in default.
function parseDevHost(raw: string | undefined): string | boolean | undefined {
if (raw === undefined || raw === "") return undefined;
const normalised = raw.toLowerCase();
if (normalised === "true" || normalised === "1" || normalised === "yes") {
return true;
}
if (normalised === "false" || normalised === "0" || normalised === "no") {
return false;
}
return raw;
}
export default defineConfig({
export default defineConfig(({ mode }) => {
// `loadEnv("", ...)` matches every `.env*` entry regardless of
// the customary `VITE_` prefix so the config sees the same view
// that client code sees via `import.meta.env`. Without this
// `process.env` would carry only the shell's exports, and
// per-developer files like `.env.development.local` would
// silently miss the config — every override would have to be
// passed on the `pnpm dev` command line.
const env = loadEnv(mode, process.cwd(), "");
// Default upstream gateway addresses used by the dev proxy.
// Override by pointing `VITE_DEV_PROXY_TARGET` (REST surface)
// and `VITE_DEV_GRPC_PROXY_TARGET` (Connect-Web surface) at a
// different gateway when working with a remote stack instead of
// `tools/local-dev/`. In production the two surfaces sit behind
// a single host; the split here exists only because local-dev
// runs the REST listener on :8080 and the authenticated
// Connect-Web listener on :9090.
const DEV_PROXY_TARGET =
env.VITE_DEV_PROXY_TARGET || "http://localhost:8080";
const DEV_GRPC_PROXY_TARGET =
env.VITE_DEV_GRPC_PROXY_TARGET || "http://localhost:9090";
// `VITE_DEV_HOST` opts the dev server into wider listener
// binding. Default stays at Vite's safe loopback-only behaviour
// so an unattended `pnpm dev` on someone's laptop never exposes
// the unauthenticated dev surface to the LAN by accident. Set
// the value in `.env.development.local` (untracked) when
// reaching the server through SSH port forwarding, a VM, or a
// container needs a non-loopback bind.
const devHost = parseDevHost(env.VITE_DEV_HOST);
return {
plugins: [sveltekit()],
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
server: {
// Same-origin proxy so the browser sees only `localhost:5173`
// and never trips a cross-origin preflight against the
// gateway's REST + Connect-Web surfaces. Production deployments
// serve the UI and the gateway behind a single host, so the
// proxy is purely a dev-time convenience.
...(devHost !== undefined ? { host: devHost } : {}),
// Same-origin proxy so the browser sees only
// `localhost:5173` and never trips a cross-origin
// preflight against the gateway's REST + Connect-Web
// surfaces. Production deployments serve the UI and the
// gateway behind a single host, so the proxy is purely a
// dev-time convenience.
proxy: {
"/api": {
target: DEV_PROXY_TARGET,
@@ -42,11 +81,13 @@ export default defineConfig({
"/galaxy.gateway.v1.EdgeGateway": {
target: DEV_GRPC_PROXY_TARGET,
changeOrigin: false,
// Connect-Web server-streaming (`SubscribeEvents`) uses
// chunked HTTP responses; http-proxy passes them through
// transparently as long as buffering stays off, which is
// the default.
// Connect-Web server-streaming
// (`SubscribeEvents`) uses chunked HTTP
// responses; http-proxy passes them through
// transparently as long as buffering stays off,
// which is the default.
},
},
},
};
});
+11 -5
View File
@@ -1,9 +1,15 @@
import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config";
export default mergeConfig(
viteConfig,
defineConfig({
// `vite.config.ts` exports a `defineConfig(({ mode }) => …)` callback
// so the dev server can load `.env*` files through Vite's `loadEnv`.
// `mergeConfig` does not accept callback-form configs, so resolve the
// callback here with the same context Vitest would supply, then hand
// the plain object to the merge.
export default defineConfig(async (ctx) => {
const resolved =
typeof viteConfig === "function" ? await viteConfig(ctx) : viteConfig;
return mergeConfig(resolved, {
resolve: {
// Force the browser entry of Svelte so `mount` is available in jsdom.
conditions: ["browser"],
@@ -14,5 +20,5 @@ export default mergeConfig(
globals: true,
setupFiles: ["./tests/setup.ts"],
},
}),
);
});
});