Two problems showed up while trying to log into the long-lived dev
environment with the dev-fixed code `123456`:
1. `ConfirmEmailCode` checked the per-challenge attempts ceiling
*before* the dev-fixed-code override. A developer who burned past
`ChallengeMaxAttempts` on an existing un-consumed challenge (easy
to trigger when the throttle reuses one challenge_id) hit
`ErrTooManyAttempts` and the UI rendered "code expired or already
used" even though the fixed code was correct. Reorder so the
dev-fixed-code branch runs first and bypasses both the bcrypt
verify and the attempts gate. Production stays unaffected
because production loaders refuse to set `DevFixedCode`.
2. `dev-deploy.yaml` only fires on push to `development`, so the
matching docker-compose default change for
`BACKEND_AUTH_DEV_FIXED_CODE` could not reach the running stack
before this PR merged. Add `workflow_dispatch: {}` so a developer
can deploy any branch — typically a feature branch under review —
from the Gitea Actions UI without waiting for the merge.
Covered by a new `TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling`
integration test that burns through the ceiling with wrong codes
then proves the dev-fixed code still produces a session.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 28's in-game mail UI threads sent messages by the recipient
race name, so the bulk `/sent` endpoint now returns the same
`UserMailMessageDetail` shape as `/inbox` — single sends contribute
one row per message, broadcasts contribute one row per addressee
and the UI collapses them by `message_id` into a stand-alone item.
- `Store.ListSent` / `Service.ListSent` switched from `[]Message`
to `[]InboxEntry`. SQL grows an INNER JOIN with
`diplomail_recipients`.
- Handler emits `userMailMessageDetailWire` items; the deprecated
`userMailSentSummaryWire` is removed.
- `openapi.yaml`: `UserMailSentList.items` now reference
`UserMailMessageDetail`; the standalone `UserMailSentSummary`
schema is dropped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 28's in-game mail UI groups personal threads by the other
party's race. To support that without an extra membership-listing
RPC, the diplomail subsystem now:
- accepts `recipient_race_name` on `POST /messages` and
`POST /admin` (target=user) as an alternative to
`recipient_user_id`; the service resolves it via the existing
`Memberships.ListMembers(gameID, "active")` and rejects with
`forbidden` when the matching member is no longer active;
- snapshots `diplomail_messages.sender_race_name` at send time for
every player sender (admin / system rows stay NULL). The UI keys
per-race threading on this column.
Schema, openapi, README, and a focused e2e test for the new path
(happy path + dual / missing / unknown / kicked errors) land in
this commit; the gateway + UI legs follow in subsequent commits on
this branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the documentation gaps from the freshly-audited diplomail
implementation. FUNCTIONAL.md gains a §11 "Diplomatic mail" with
the full user-facing story across all five stages, mirrored into
FUNCTIONAL_ru.md as the project conventions require. A new
backend/docs/diplomail-translator-setup.md captures the
LibreTranslate operational recipe (Docker image, env wiring,
manual smoke test, troubleshooting). The package README gains a
"Multi-instance posture" note documenting the deliberate absence
of FOR UPDATE in the worker pickup query — single-instance is
safe today; multi-instance scaling will revisit the claim
mechanism.
Two small edge-case tests round things out: malformed
LibreTranslate response bodies (single string, short array,
empty array, missing field) must surface as errors so the worker
falls back instead of crashing; and an empty translation queue
must produce zero events on three consecutive Worker.Tick calls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Synchronous translation on read (Stage D) blocks the HTTP handler on
translator I/O. Stage E switches to "send moments-fast, deliver
when translated": recipients whose preferred_language differs from
the detected body_lang are inserted with available_at=NULL, and an
async worker turns them on once a LibreTranslate call materialises
the cache row (or fails terminally after 5 retries).
Schema delta on diplomail_recipients: available_at,
translation_attempts, next_translation_attempt_at, plus a snapshot
recipient_preferred_language so the worker queries do not need a
join. Read paths (ListInbox, GetMessage, UnreadCount) filter on
available_at IS NOT NULL. Push fan-out is moved from Service to the
worker so the recipient only sees the toast when the inbox row is
actually visible.
Translator backend is now a configurable choice: empty
BACKEND_DIPLOMAIL_TRANSLATOR_URL → noop (deliver original);
populated → LibreTranslate HTTP client. Per-attempt timeout, max
attempts, and worker interval all live in DiplomailConfig. The HTTP
client itself is unit-tested via httptest (happy path, BCP47
normalisation, unsupported pair, 5xx, identical src/dst, missing
URL); worker delivery + fallback paths are covered by the
testcontainers-backed e2e tests in diplomail_e2e_test.go.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the LangUndetermined placeholder with whatlanggo-backed
body detection on every send path, then adds a translation cache
keyed on (message_id, target_lang) populated lazily on the
per-message read endpoint. The noop translator that ships with
Stage D returns engine="noop", which the service treats as
"translation unavailable" — wiring a real backend (LibreTranslate
HTTP client is the documented next step) is a one-file swap.
GetMessage and ListInbox now accept a targetLang argument; the HTTP
layer resolves the caller's accounts.preferred_language and
forwards it. Inbox uses the cache only (never calls the
translator) so bulk reads stay fast under future SaaS backends.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes out the producer-side of the diplomail surface. Paid-tier
players can fan out one personal message to the rest of the active
roster (gated on entitlement_snapshots.is_paid). Site admins gain a
multi-game broadcast (POST /admin/mail/broadcast with `selected` /
`all_running` scopes) and the bulk-purge endpoint that wipes
diplomail rows tied to games finished more than N years ago. An
admin listing (GET /admin/mail/messages) rounds out the
observability surface.
EntitlementReader and GameLookup are new narrow deps wired from
`*user.Service` and `*lobby.Service` in cmd/backend/main; the lobby
service grows a one-off `ListFinishedGamesBefore` helper for the
cleanup path (the cache evicts terminal-state games so the cache
walk is not enough). Stage D will swap LangUndetermined for an
actual body-language detector and add the translation cache.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Item 7 of the spec wants game-state and membership-state changes to
land as durable inbox entries the affected players can re-read after
the fact — push alone times out of the 5-minute ring buffer. Stage B
adds the admin-kind send matrix (owner-driven via /user, site-admin
driven via /admin) plus the lobby lifecycle hooks: paused / cancelled
emit a broadcast system mail to active members, kick / ban emit a
single-recipient system mail to the affected user (which they keep
read access to even after the membership row is revoked, per item 8).
Migration relaxes diplomail_messages_kind_sender_chk so an owner
sending kind=admin keeps sender_kind=player; the new
LifecyclePublisher dep on lobby.Service is wired through a thin
adapter in cmd/backend/main, mirroring how lobby's notification
publisher is plumbed today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 28 of ui/PLAN.md needs a persistent player-to-player mail
channel; the existing `mail` package is a transactional email
outbox and the `notification` catalog is one-way platform events.
Stage A lands the schema (diplomail_messages / _recipients /
_translations), a single-recipient personal send/read/delete
service path, a `diplomail.message.received` push kind plumbed
through the notification pipeline, and an unread-counts endpoint
that drives the lobby badge. Admin / system mail, lifecycle hooks,
paid-tier broadcast, multi-game broadcast, bulk purge and language
detection / translation cache come in stages B–D.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Engine wire change: Report.battle switched from []uuid.UUID to
[]BattleSummary{id, planet, shots} so the map can place battle
markers without N extra fetches. FBS schema + generated Go/TS
regenerated; transcoder + report controller updated; openapi
adds the BattleSummary schema with a freeze test.
Backend gateway forwards engine GET /api/v1/battle/:turn/:uuid as
/api/v1/user/games/{game_id}/battles/{turn}/{battle_id} (handler
plus engineclient.FetchBattle, contract test stub, openapi spec).
UI:
- BattleViewer (lib/battle-player/) is a logically isolated SVG
radial scene that consumes a BattleReport prop. Planet at the
centre, races on the outer ring at equal angular spacing, race
clusters by (race, className) with <class>:<numLeft> labels;
observer groups (inBattle: false) are not drawn; eliminated
races drop out and survivors re-distribute on the next frame.
- Shot line per frame: red on destroyed, green otherwise; erased
on the next frame. Playback controls: play/pause + step ± +
rewind + 1x/2x/4x speed (400/200/100 ms per frame).
- Page wrapper (lib/active-view/battle.svelte) loads BattleReport
via api/battle-fetch.ts; synthetic-gameId prefix routes to a
fixture loader, otherwise REST through the gateway. Always-
visible <ol> text protocol satisfies the accessibility ask.
- section-battles.svelte links every battle UUID into the viewer.
- map/battle-markers.ts: yellow X cross of 2 LinePrim through the
corners of the planet's circumscribed square (stroke width
clamps from 1 px at 1 shot to 5 px at 100+ shots); bombing
marker is a stroke-only ring (yellow when damaged, red when
wiped). Wired into state-binding.ts; click handler dispatches
battle clicks to the viewer and bombing clicks to the matching
Reports row.
- i18n keys for the viewer in en + ru.
Docs: ui/docs/battle-viewer-ux.md, FUNCTIONAL.md §6.5 + ru
mirror, ui/PLAN.md Phase 27 decisions + deferred TODOs (push
event, richer class visuals, animated re-distribution).
Tests: Vitest unit (radial layout + timeline frame builder +
marker stroke formula + marker primitives), Playwright e2e for
the viewer (Reports link → viewer, playback step, not-found),
backend engineclient FetchBattle (200 / 404 / bad input), engine
openapi freezes (BattleReport, BattleReportGroup,
BattleActionReport, BattleSummary, Report.battle items).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend now owns the turn-cutoff and pause guards the order tab
relies on: the scheduler flips runtime_status between
generation_in_progress and running around every engine tick, a
failed tick auto-pauses the game through OnRuntimeSnapshot, and a
new game.paused notification kind fans out alongside
game.turn.ready. The user-games handlers reject submits with
HTTP 409 turn_already_closed or game_paused depending on the
runtime state.
UI delegates auto-sync to a new OrderQueue: offline detection,
single retry on reconnect, conflict / paused classification.
OrderDraftStore surfaces conflictBanner / pausedBanner runes,
clears them on local mutation or on a game.turn.ready push via
resetForNewTurn. The order tab renders the matching banners and
the new conflict per-row badge; i18n bundles cover en + ru.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TestBuildClientPushEventCoversCatalog required every catalog kind to
encode through a FlatBuffers `preMarshaledEvent`. game.turn.ready
intentionally rides on the JSON fallback because its payload is just
`{game_id, turn}` and the only consumer (Phase 24 UI handler) parses
JSON inline. Make the policy explicit through a jsonFriendlyKinds
allow-list so the test still asserts each kind is covered and a future
producer that picks the wrong encoding fails loudly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the gateway's signed SubscribeEvents stream end-to-end:
- backend: emit game.turn.ready from lobby.OnRuntimeSnapshot on every
current_turn advance, addressed to every active membership, push-only
channel, idempotency key turn-ready:<game_id>:<turn>;
- ui: single EventStream singleton replaces revocation-watcher.ts and
carries both per-event dispatch and revocation detection; toast
primitive (store + host) lives in lib/; GameStateStore gains
pendingTurn/markPendingTurn/advanceToPending and a persisted
lastViewedTurn so a return after multiple turns surfaces the same
"view now" affordance as a live push event;
- mandatory event-signature verification through ui/core
(verifyPayloadHash + verifyEvent), full-jitter exponential backoff
1s -> 30s on transient failure, signOut("revoked") on
Unauthenticated or clean end-of-stream;
- catalog and migration accept the new kind; tests cover producer
(testcontainers + capturing publisher), consumer (Vitest event
stream, toast, game-state extensions), and a Playwright e2e
delivering a signed frame to the live UI.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Previously a cancelled / finished / start_failed sandbox game would
hang in the dev user's lobby until manually cleaned up — `make up`
would create a new running game alongside it but the dead tiles
piled up. Now backend's `devsandbox.Bootstrap` deletes every
terminal sandbox game owned by the dev user before find-or-create
runs, so the lobby always shows exactly one running tile.
Schema: `runtime_records` and `player_mappings` gain
`ON DELETE CASCADE` on their `game_id` foreign keys so a single
`DELETE FROM games` cleans every referencing row in one write.
Pre-prod migration rule applies — change goes into
`00001_init.sql`, not a new migration.
API: `lobby.Service.DeleteGame` is the new destructive helper that
backs the bootstrap purge. It bypasses the cancel-cascade-notify
pipeline; production callers must stay on the regular lifecycle.
The dev-sandbox docs in `tools/local-dev/README.md` spell out the
new behaviour.
Tests:
- backend/internal/lobby/lobby_e2e_test.go gains
`TestDeleteGameCascadesEverything` proving CASCADE works
end-to-end against a real Postgres testcontainer.
- backend/internal/devsandbox keeps its existing terminal-status
contract test; the new `purgeTerminalSandboxGames` helper rides
on the same `terminalSandboxStatus` predicate.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wires the first end-to-end command through the full pipeline:
inspector rename action → local order draft → user.games.order
submit → optimistic overlay on map / inspector → server hydration
on cache miss via the new user.games.order.get message type.
Backend: GET /api/v1/user/games/{id}/orders forwards to engine
GET /api/v1/order. Gateway parses the engine PUT response into the
extended UserGamesOrderResponse FBS envelope and adds
executeUserGamesOrderGet for the read-back path. Frontend ports
ValidateTypeName to TS, lands the inline rename editor + Submit
button, and exposes a renderedReport context so consumers see the
overlay-applied snapshot.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the Phase 10 map stub with live planet rendering driven by
`user.games.report`, and wires the header turn counter to the same
data. Phase 11's frontend sits on a per-game `GameStateStore` that
lives in `lib/game-state.svelte.ts`: the in-game shell layout
instantiates one per game, exposes it through Svelte context, and
disposes it on remount. The store discovers the game's current turn
through `lobby.my.games.list`, fetches the matching report, and
exposes a TS-friendly snapshot to the header turn counter, the map
view, and the inspector / order / calculator tabs that later phases
will plug onto the same instance.
The pipeline forced one cross-stage decision: the user surface needs
the current turn number to know which report to fetch, but
`GameSummary` did not expose it. Phase 11 extends the lobby
catalogue (FB schema, transcoder, Go model, backend
gameSummaryWire, gateway decoders, openapi, TS bindings,
api/lobby.ts) with `current_turn:int32`. The data was already
tracked in backend's `RuntimeSnapshot.CurrentTurn`; surfacing it is
a wire change only. Two alternatives were rejected: a brand-new
`user.games.state` message (full wire-flow for one field) and
hard-coding `turn=0` (works for the dev sandbox, which never
advances past zero, but renders the initial state for any real
game). The change crosses Phase 8's already-shipped catalogue per
the project's "decisions baked back into the live plan" rule —
existing tests and fixtures are updated in the same patch.
The state binding lives in `map/state-binding.ts::reportToWorld`:
one Point primitive per planet across all four kinds (local /
other / uninhabited / unidentified) with distinct fill colours,
fill alphas, and point radii so the user can tell them apart at a
glance. The planet engine number is reused as the primitive id so
a hit-test result resolves directly to a planet without an extra
lookup table. Zero-planet reports yield a well-formed empty world;
malformed dimensions fall back to 1×1 so a bad report cannot crash
the renderer.
The map view's mount effect creates the renderer once and skips
re-mount on no-op refreshes (same turn, same wrap mode); a turn
change or wrap-mode flip disposes and recreates it. The renderer's
external API does not yet expose `setWorld`; Phase 24 / 34 will
extract it once high-frequency updates land. The store installs a
`visibilitychange` listener that calls `refresh()` when the tab
regains focus.
Wrap-mode preference uses `Cache` namespace `game-prefs`, key
`<gameId>/wrap-mode`, default `torus`. Phase 11 reads through
`store.wrapMode`; Phase 29 wires the toggle UI on top of
`setWrapMode`.
Tests: Vitest unit coverage for `reportToWorld` (every kind,
ids, styling, empty / zero-dimension edges, priority order) and
for the store lifecycle (init success, missing-membership error,
forbidden-result error, `setTurn`, wrap-mode persistence across
instances, `failBootstrap`). Playwright e2e mocks the gateway for
`lobby.my.games.list` and `user.games.report` and asserts the
live data path: turn counter shows the reported turn,
`active-view-map` flips to `data-status="ready"`, and
`data-planet-count` matches the fixture count. The zero-planet
regression and the missing-membership error path are covered.
Phase 11 status stays `pending` in `ui/PLAN.md` until the local-ci
run lands green; flipping to `done` follows in the next commit per
the per-stage CI gate in `CLAUDE.md`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three fixes around the dev sandbox end-to-end path. Each one was
flushed out by an actual login walkthrough after the previous
commit.
Backend bootstrap now treats `cancelled`, `finished`, and
`start_failed` as terminal: the per-boot find-or-create skips such
games and provisions a fresh one. Without this, a single bad
shutdown cascade leaves the developer staring at a dead lobby tile
forever (cancelled games don't transition back). Covered by
TestTerminalSandboxStatus.
Tools/local-dev: stop killing engine containers in `make down`. The
runtime treats the disappearance of an engine as a real failure
(cascading the lobby game to `cancelled`); leaving the container
running across `down/up` lets the runtime reconciler re-attach on
the next boot. The teardown happens only in `make clean`, where the
DB is wiped anyway. Compose now also exposes :9090 (authenticated
EdgeGateway listener) on the host so the Vite dev proxy can reach
the Connect-Web surface, and bumps the gateway anti-abuse limits
for `public_misc` so the same surface is not blanket-rejected with
413.
Ui/frontend: the lobby's `My Games` cards are now clickable only
for the playable statuses (`running`, `paused`, `finished`). All
other statuses render as disabled buttons so a click on a draft or
cancelled game no longer drops the user on a 404 — the in-game
view at /games/:id/* doesn't exist before Phase 10 and never makes
sense for a cancelled game. Vite proxy splits the dev targets so
`/api/*` continues to talk to the REST listener and
`/galaxy.gateway.v1.EdgeGateway/*` is routed to the Connect-Web
listener via VITE_DEV_GRPC_PROXY_TARGET (defaults to :9090).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a single zap.Info line after the membership-insertion loop so
the boot log explicitly shows how many participants the sandbox
provisioned. The number is fixed by config (PlayerCount) but
surfacing it in the log makes troubleshooting "why is the lobby
empty" cases (typo in the email, partial failure) faster than
querying the DB.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds backend/internal/devsandbox: an idempotent boot-time hook that,
when BACKEND_DEV_SANDBOX_EMAIL is set, ensures (1) the configured
engine_version row, (2) the real dev user, (3) PlayerCount-1
deterministic dummy users, (4) a private "Dev Sandbox" game with a
year-out turn schedule, (5) memberships for every participant via
the new lobby.Service.InsertMembershipDirect helper, (6) a drive of
the lifecycle to running. Re-running on a populated DB is a no-op;
partial states from earlier crashes are recovered.
tools/local-dev gains the matching env vars in .env, surfaces them
in compose, and acquires a `make build-engine` target that builds
galaxy-engine:local-dev from game/Dockerfile (a prerequisite of
`up`/`rebuild`). The compose game-state mount is changed from a
named volume to a host bind on /tmp/galaxy-game-state so backend's
bind-mount source for spawned engine containers resolves on the
docker daemon.
After `make -C tools/local-dev up`, login as dev@local.test with
the dev code 123456 and the Dev Sandbox already shows up in My
Games. Per-user behaviour for the same email survives a backend
restart.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`time.LoadLocation` is called from
backend/internal/server/handlers_public_auth.go:108 (confirm-email-code)
and backend/internal/user/account.go:218 (user.settings.update). Both
runtime images shipped today have no tzdata — production
backend/Dockerfile uses gcr.io/distroless/static-debian12:nonroot, and
local-dev tools/local-dev/backend.Dockerfile uses alpine:3.20 without
the optional tzdata apk — so the container-side binary resolves only
the no-data fallback (UTC and fixed offsets) and rejects every real
IANA zone with HTTP 400 `invalid_request: time_zone must be a valid
IANA zone`.
Adding `import _ "time/tzdata"` to backend's main is the idiomatic
Go fix: the binary embeds the IANA database, time.LoadLocation works
on every base image, no Dockerfile changes needed. Cost is ~800 KB
of binary growth — invisible next to the existing /usr/local/bin/backend
size and well below any container layer threshold.
The OpenAPI spec already documents the field as "IANA time-zone
identifier" (gateway/openapi.yaml:205, backend/openapi.yaml:2334)
and the UI sends Intl.DateTimeFormat().resolvedOptions().timeZone,
so neither the contract nor the client needs a change.
Why this slipped through: backend unit tests run as a host Go test
process (developer's tzdata covers them), Playwright tests mock the
gateway (backend never reached), and the integration suite — the only
layer that exercises the real backend container — uses
RegisterSession which hardcoded `time_zone="UTC"`. Switching that
default to "Europe/Berlin" makes every integration scenario that
enrols a pilot exercise the tzdata path, so the next regression
surfaces in the integration run instead of escaping into manual
smoke. (The integration suite is not in the per-PR workflow yet; that
gap is tracked separately.)
Verified end-to-end against `tools/local-dev`:
- Europe/Amsterdam, Asia/Tokyo, America/Los_Angeles → 200 +
device_session_id (was 400 before this patch).
- Mars/Olympus still → 400 (validation behaviour unchanged).
Host tests: backend/internal/{auth,user,config} green.
UI: pnpm test 14/14, CI=1 pnpm exec playwright test 44/44.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds tools/local-dev/ with postgres + redis + mailpit + backend +
gateway plus a Make wrapper, so `make -C tools/local-dev up` brings
the full authenticated stack online and `pnpm -C ui/frontend dev`
talks to it directly. The committed `.env.development` already
points at the stack and pins the matching gateway response public
key from the dev keypair under tools/local-dev/keys/.
The backend ships a new opt-in env, BACKEND_AUTH_DEV_FIXED_CODE
(`tools/local-dev/.env` defaults it to 123456). When set,
ConfirmEmailCode accepts that literal in addition to the real
bcrypt-verified code; SendEmailCode still queues a real email so
Mailpit captures the issued code at http://localhost:8025/, and
both paths coexist. The override is rejected as non-six-digit by
config validation and emits a loud warning at backend startup.
The local-dev Dockerfiles mirror backend/Dockerfile and
gateway/Dockerfile but switch the runtime stage to alpine so
docker-compose healthchecks can wget /healthz; the gateway
Dockerfile additionally copies ui/core/ into the build context
because gateway/go.mod's `replace galaxy/core => ../ui/core` is
required to compile the gateway main.
Smoke tested:
- `make -C tools/local-dev up` boots all five services to healthy.
- send-email-code + confirm-email-code with code=123456 returns a
device_session_id; a real code in Mailpit also redeems
successfully.
- `pnpm test` 14/14, `pnpm exec playwright test` 44/44.
- `go test ./backend/internal/config/...` green.
Docs: tools/local-dev/README.md, tools/local-dev/keys/README.md,
new "Local development stack" section in ui/docs/testing.md, and a
short pointer in ui/README.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a minimal Svelte 5 i18n primitive (`src/lib/i18n/`) backing the
login form, the layout blocker page, and the lobby placeholder.
SUPPORTED_LOCALES drives both the picker and the runtime lookup;
adding a language is a two-step change inside `src/lib/i18n/`.
Login form gains a globe-icon language dropdown (English / Русский
in their native names), defaulting to navigator.languages with `en`
as the fallback. Switching the locale re-renders the form in place;
on submit, the locale rides in the JSON body of `send-email-code`
because Safari/WebKit silently drops JS-set Accept-Language. Gateway
gains a body `locale` field that takes priority over the request
header for preferred-language resolution.
Email and code inputs disable browser autofill / suggestions
(`autocomplete=off` + `autocorrect=off` + `autocapitalize=off` +
`spellcheck=false`) so Keychain / address-book pickers and
remembered-value dropdowns no longer fire on focus.
Cross-cuts:
- backend & gateway openapi: clarify that body `locale` is honored.
- docs/FUNCTIONAL{,_ru}.md §1.2: document body-vs-header priority.
- gateway tests: body `locale` overrides Accept-Language; blank
body `locale` falls back to header.
- new ui/docs/i18n.md; cross-links from auth-flow.md and ui/README.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
TestServiceStartGameEndToEnd's httptest server had no handler for
/healthz, the path engineclient.Healthz probes after a runtime
container starts. Without it the runtime never transitions out of
starting state and the test fails on its 5s deadline. Surfaced by
introducing CI that runs the backend service tests outside the
integration harness.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>