# `tools/local-dev/` — Galaxy local development stack

A docker-compose stack that brings up postgres + redis + mailpit +
backend + gateway so the UI Vite dev server (run on the host) can
talk to a real authenticated stack without any cloud dependency.

The stack is the recommended baseline for UI work that goes beyond
the mocked Playwright fixtures: every payload exercises the real
FlatBuffers wire, every authenticated call verifies the response
signature against the dev keypair, and every email passes through
Mailpit's web UI for inspection.

This stack is **not** a CI gate (the per-stage CI gate now lives on
`gitea.lan`; see project-level `CLAUDE.md`). It is also distinct from
the **long-lived dev environment** at
[`tools/dev-deploy/`](../dev-deploy/README.md), which is redeployed on
every merge into `development` and is reachable as
`https://www.galaxy.lan` / `https://api.galaxy.lan`. The three stacks
(`tools/local-dev/`, `tools/dev-deploy/`, and the fallback
`tools/local-ci/`) coexist on the same host because every name —
compose project, container, network, volume — is distinct.

## Bring it up

```sh
make -C tools/local-dev up
```

`up` builds the local-dev backend and gateway images on first run
(pulls postgres, redis, mailpit), waits for every service to report
healthy, and returns. Subsequent invocations reuse the built images.

After the stack is healthy:

```sh
pnpm -C ui/frontend dev
```

Open <http://localhost:5173> for the UI and
<http://localhost:8025> for Mailpit.

The first `make up` builds the engine image (`galaxy-engine:local-dev`)
from `game/Dockerfile`. Subsequent invocations skip the build when the
image already exists; force a rebuild with `docker rmi galaxy-engine:local-dev`
followed by `make build-engine`.

## Daily flow

```sh
make -C tools/local-dev up        # bring up (idempotent, fast on warm cache)
pnpm -C ui/frontend dev           # in another terminal
# ...edit UI, browse, repeat...
make -C tools/local-dev down      # stop containers, keep state
```

State persists in named Docker volumes between `up`/`down` cycles, so
games created on Tuesday survive into Wednesday. Wipe with
`make clean` when you want a fresh database.

## Logging in

Two paths coexist by default:

1. **Fixed dev code (fast).** `tools/local-dev/.env` ships
   `BACKEND_AUTH_DEV_FIXED_CODE=123456`. After requesting a code in
   the UI, type `123456` — `ConfirmEmailCode` accepts that literal
   in addition to the real bcrypt-verified code stored on the
   challenge row. The override emits a loud warning at backend boot
   and is rejected by the production env loader (`BACKEND_ENV` guard
   in `backend/internal/config`).
2. **Real Mailpit code.** Open <http://localhost:8025>, find the
   most recent message, copy the six-digit code, paste it into the
   UI. This exercises the full mail outbox path, including SMTP
   handoff and gomail TLS-mode handling.

To force the second path (no fast-bypass), edit
`tools/local-dev/.env` and clear `BACKEND_AUTH_DEV_FIXED_CODE`, then
`make rebuild` (or simply `docker compose up -d backend` to recreate
the backend with the new env).

## Auto-provisioned dev sandbox

`make up` provisions a private game called **Dev Sandbox** owned by
the dev user (default `dev@local.test`). The flow is implemented in
`backend/internal/devsandbox` and runs on every backend boot when
`BACKEND_DEV_SANDBOX_EMAIL` is non-empty in `tools/local-dev/.env`.

Bootstrap is idempotent — re-running `make up` after a `make down`
finds the existing user, dummy participants, game, and memberships
without creating duplicates. If a previous boot crashed mid-way
(game stuck in `enrollment_open` or `ready_to_start`), the next boot
resumes the lifecycle.

To log in straight into the sandbox:

1. `make -C tools/local-dev up`
2. `pnpm -C ui/frontend dev` (in another terminal)
3. Open <http://localhost:5173/login>, enter `dev@local.test`, then
   the dev code `123456`.
4. The lobby shows **Dev Sandbox** in *My Games*; click in.

To disable the bootstrap, clear `BACKEND_DEV_SANDBOX_EMAIL` in
`tools/local-dev/.env` and `docker compose up -d backend` (or
`make rebuild`). Existing users / games are not removed.

Terminal sandbox games — anything in `cancelled`, `finished`, or
`start_failed` — are deleted on every boot before find-or-create
runs. The cascade declared in `00001_init.sql` removes the
matching memberships, applications, invites, runtime records,
and player mappings in the same write, so the dev user's lobby
shows exactly one running tile at all times. Cancelling the
sandbox manually and running `docker compose restart backend`
(or `make rebuild`) yields a fresh game without leaving dead
tiles behind.

The bootstrap requires:
- `galaxy-engine:local-dev` Docker image (`make build-engine`).
- `BACKEND_DEV_SANDBOX_ENGINE_VERSION` parses as plain semver
  (`MAJOR.MINOR.PATCH`); the default `0.1.0` is what the bootstrap
  registers in the `engine_versions` row that points at the image.
- `BACKEND_DEV_SANDBOX_PLAYER_COUNT` ≥ 20 (the engine's minimum;
  19 deterministic dummies fill the slots so the single real user
  can start the game).
- A frozen turn schedule (`0 0 1 1 *` — once a year) so the visible
  game state stays at turn 1 until you explicitly progress it.

## Network map

```
host                                            compose network "galaxy-local-dev-net"
  ┌────────────────────────────────┐              ┌──────────────────────────────┐
  │ browser    localhost:5173      │── pnpm dev (Vite, host) ──┐                  │
  │           ↳ /api/*  proxied ───┼──────────────────────────▶│ gateway:8080     │
  │           ↳ /galaxy.gateway... ┼──────────────────────────▶│                  │
  │ browser    localhost:8025      │─────────────────────────▶│ mailpit:8025      │
  │ psql       localhost:5433      │─────────────────────────▶│ postgres:5432     │
  │ redis-cli  localhost:6380      │─────────────────────────▶│ redis:6379        │
  └────────────────────────────────┘              │  ↳ backend:8080  (HTTP)        │
                                                  │  ↳ backend:8081  (gRPC push)   │
                                                  │  ↳ mailpit:1025  (SMTP in)     │
                                                  └────────────────────────────────┘
```

Vite's dev server proxies `/api` and `/galaxy.gateway.v1.EdgeGateway`
to the gateway, so every browser request stays same-origin (no CORS
preflight). The gateway is therefore reachable only through Vite at
<http://localhost:5173>, not at <http://localhost:8080> from the
browser tab. Direct curl/wget against <http://localhost:8080> still
works for diagnostic probes — only the browser-side requests are
proxied.

Mailpit (8025), postgres (5433), and redis (6380) remain directly
reachable for diagnostics (`make psql`, `redis-cli -h localhost -p
6380 -a galaxy-dev`).

To point the proxy at a non-local gateway, run
`VITE_DEV_PROXY_TARGET=http://gateway.host:8080 pnpm -C ui/frontend dev`
— no compose changes needed.

## Refreshing after Go-side changes

`make up` reuses any pre-built images and, by default, only rebuilds
the engine image (`build-engine`) when the tag is missing. Touching
backend or gateway code (handlers, routes, transcoders, model
constants) **does not** trigger a rebuild on its own — the next
`docker compose up -d` will reattach to the stale image and the
new behaviour silently disappears. After any change under
`backend/`, `gateway/`, `pkg/`, or the FBS schemas, force a
rebuild:

```sh
make -C tools/local-dev rebuild
```

`rebuild` runs `compose build --no-cache backend gateway` followed
by `up -d --wait`, so the next request through the stack hits the
new code. Engine code lives in a separate image — touch the engine
and run `make stop-engines` plus `docker rmi galaxy-engine:local-dev`
before `make up` (or `make build-engine`) so per-game containers
respawn from the freshly built layers.

## Make targets

```text
make up             Bring up the stack (build engine + compose images if needed) and wait for health
make rebuild        Rebuild the backend / gateway images (ignores cache)
make build-engine   Build galaxy-engine:local-dev from game/Dockerfile (no-op if image already present)
make down           Stop containers, keep volumes
make clean          Stop and wipe volumes (postgres + game-state)
make logs           Tail every service's logs
make logs-backend   Tail backend only
make logs-gateway   Tail gateway only
make logs-mail      Tail mailpit only
make psql           Open a psql shell as galaxy@galaxy_backend
make status         docker compose ps
```

## Files

- `docker-compose.yml` — five services: postgres, redis, mailpit,
  backend, gateway, plus shared network and volumes.
- `backend.Dockerfile`, `gateway.Dockerfile` — local-dev runtime
  images built on alpine (so `wget` is available for the compose
  healthchecks). The build stage mirrors `backend/Dockerfile` and
  `gateway/Dockerfile` exactly.
- `Makefile` — wrapper over `docker compose` that keeps the muscle
  memory close to `tools/local-ci/`'s Makefile.
- `.env` — committed defaults for the compose `${VAR:-}`
  expansions. Edit per-developer or override via your shell.
- `keys/gateway-response.pem`, `keys/gateway-response.pub` — dev-only
  Ed25519 keypair used by the gateway for response signing. Pairs
  with the `VITE_GATEWAY_RESPONSE_PUBLIC_KEY` value in
  `ui/frontend/.env.development`. See `keys/README.md` before
  rotating.
- `keys/regenerate.go` — one-shot Go helper that regenerates the
  pair and prints the new base64 public key.

## Troubleshooting

- **Lobby shows "no games yet" after `make clean && make up`** —
  the browser still holds a keypair + device session bound to the
  user_id from the previous DB. The new user has the same email
  (`dev@local.test`) but a fresh user_id, so the old keypair
  authenticates against a session row that no longer exists or
  points at the wrong account. Open the page in an incognito
  window, or wipe site data for `localhost:5173` (DevTools →
  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 `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
  `backend/Dockerfile` / `gateway/Dockerfile`.
- **Gateway exits at boot with "redis: …"** — the redis container is
  still bootstrapping. `make up --wait` waits for healthchecks; if
  it times out, increase `start_period` in the gateway service or
  inspect `make logs-redis`.
- **Login form rejects every code** — confirm
  `BACKEND_AUTH_DEV_FIXED_CODE` is set in `tools/local-dev/.env` and
  the backend has been recreated since the last edit
  (`docker compose up -d backend`). Real Mailpit codes work
  regardless.
- **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** (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

- `tools/local-ci/` — Gitea + Actions runner, replays
  `.gitea/workflows/*` against a pushed branch. Different stack,
  different purpose; coexists with local-dev on the same machine.
- `integration/testenv/` — testcontainers harness used by
  `make -C integration integration`. Uses the same images
  (`backend/Dockerfile`, `gateway/Dockerfile`) at production
  defaults; do not confuse with this local-dev stack, which carries
  alpine-runtime images for ergonomics and the dev-mode auth
  override.
