---
name: verify
description: Drive the Lost Cities web client in a real browser to observe a change working — model loading, a full match, the result card, records being written.
---

# Verifying the web client

The client is where a silent bug hides best. `npm test` and `tsc` were both green
while the app was announcing the wrong winner and dropping every game record on
the floor. Neither throws. Run it.

## Build and serve

```bash
cd web && npm run build
cd .. && tmux new-session -d -s websrv \
  "uv run python scripts/serve_web_with_logs.py --host 127.0.0.1 --port 5199 \
     --dist web/dist --output /tmp/verify-records.jsonl"
```

Serve the **built dist**, not the vite dev server — the deploy builds from dist,
and the model is a static asset whose path only resolves there. Point `--output`
at a scratch file so verification runs never touch `data/human-play/`.

## Drive it

Playwright is deliberately **not** a dependency: the `playwright` package downloads
~114MB of Chromium on install, and `npm ci` runs in the deploy workflow. Install it
for the run and uninstall after.

```bash
cd web
npm i -D playwright --no-fund --no-audit && npx playwright install chromium
# ... drive ...
npm uninstall playwright
```

## Selectors that actually work

Found by dumping the DOM; guessing at them wasted two runs.

| What | Selector |
|---|---|
| a hand card | `button.card:not([disabled])` |
| play onto an expedition | `button.lane__zone--mine.is-target` |
| discard | `button.lane__discard.is-target` |
| draw from deck | `button.deck-stack:not([disabled])` |
| which model loaded | `.score-plaque--rival small` |
| match progress | `.round-strip` |
| final scores | `.result-card` |

A turn is three clicks: pick a card, choose where it goes, then draw. The place and
draw targets only appear **after** the card is selected, and only the legal ones are
enabled — so click the card first, then query.

The rival answers on a 620ms timer plus inference; ~200ms of slack between plies is
enough. A full three-round match runs ~140 plies, so budget a few minutes.

## Worth driving

- **A whole match, not one round.** The round roll-over is where carry banks, and it
  is where the result card got it wrong.
- **Check the record actually saved.** A schema bump on the client silently 400s
  against `scripts/serve_web_with_logs.py` until its allowlist is updated too.
- **A stale save in localStorage.** Bump the key on a schema change; a half-migrated
  save is worse than a fresh deal.
- **The same seed twice.** In match mode the seed fixes all three deals and the coin
  flips, so a match is a pure function of it.
