# Phase 06.6: UAT accounts, integer viewport scaling, chat region clamp, right-click menu — Research

**Researched:** 2026-05-16
**Domain:** Better-Auth account seeding (migration), Phaser 3.90 Scale Manager (manual integer zoom), DOM-overlay canvas-rect tracking, Phaser 3 pointer button discrimination
**Confidence:** HIGH

## Summary

Phase 06.6 stitches together four independent fixes whose substrate already exists in the codebase. The dominant risk is **drift against established conventions** rather than novel implementation:

1. **UAT seed** mirrors the *exact* row-write pattern from `legacy-login.ts` (raw SQL `INSERT INTO user … INSERT INTO account …` inside a single `db.transaction((tx) => …)`), reusing the shared `ARGON2_OPTS` from `apps/server/src/argon2-opts.ts`. The env-var convention is already locked by `.github/workflows/deploy-staging.yml` and `apps/client/test/e2e/fixtures.ts`: **`UAT_ACCOUNT_A` / `UAT_PASSWORD_A` / `UAT_ACCOUNT_B` / `UAT_PASSWORD_B`** — CONTEXT D-02's tentative `UAT_TEST_PASSWORD` should be **dropped in favour of the established pair**.
2. **Integer viewport scaling** uses Phaser 3.90's `Phaser.Scale.NONE` mode + `game.scale.setZoom(n)` API, with `window.addEventListener('resize')` driving the recompute. NONE is correct here (RESIZE expands the *base* render size and would invalidate the 640×480 lock from ADR 0008).
3. **Chat region clamp** reads `game.canvas.getBoundingClientRect()` and applies `left/top/width/height` to ChatHUD's root container, listening to `game.scale.on(Phaser.Scale.Events.RESIZE, …)` (canonical event signature confirmed against `/phaserjs/phaser` docs).
4. **Right-click menu** uses Phaser's *built-in* `pointer.rightButtonDown()` helper inside the existing `pointerdown` handler, plus `this.input.mouse.disableContextMenu()` for browser-suppression scoped to the Phaser canvas (resolves D-15's "scoped to game-root subtree" guidance — disableContextMenu only suppresses on the canvas, leaving chat-input + EscMenu DOM contextmenus intact for copy/paste).

**Primary recommendation:** Plan as four parallelizable tasks (one per scope item). All four can ship in a single phase commit because the surfaces don't overlap; verification gates are independent.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| UAT account seeding | API / Backend (`apps/server/scripts/`) | Database / Storage (writes `user` + `account` tables) | Account creation must live next to the existing migration runner; argon2id hashing is server-side per CLAUDE.md hard rule #2 |
| Argon2id hashing for seed | API / Backend | — | `argon2` npm pkg is Node-only (native module); must run inside `apps/server` workspace |
| Integer viewport scaling (Phaser config) | Browser / Client (`apps/client/src/main.ts`) | — | Pure renderer config; no server interaction |
| Resize event handling | Browser / Client | — | Window-scoped; `requestAnimationFrame` coalescing per D-13 |
| Chat HUD canvas-rect clamp | Browser / Client (`apps/client/src/ui/ChatHUD.ts`) | — | DOM overlay positioned against `game.canvas` rect; must NOT migrate into Phaser DOM container (ADR 0008 invariant) |
| Right-click handler | Browser / Client (`apps/client/src/scenes/GameScene.ts`) | — | Phaser scene-level input binding; ESC-key path untouched |
| Context-menu suppression | Browser / Client (Phaser canvas only via `disableContextMenu`) | — | Scoped to `game.canvas`, NOT global `document` — preserves copy/paste UX on chat input + EscMenu DOM children |
| Playwright credential injection | CI / Static (env-var) | Browser / Client (Playwright fixtures consume) | Credentials live in GitHub Actions secrets; fixtures already read `process.env.UAT_*` |

## User Constraints (from CONTEXT.md)

### Locked Decisions
**UAT Accounts**
- **D-01:** Seed `dunsen_uat` and `rebbie_uat` as **real** Better-Auth account rows via an **idempotent migration** in `apps/server/scripts/run-migrations.ts` chain. Migration is a no-op if rows already exist (check by `username` / `account_id`). Reason: Litestream-restore-safe, zero manual step in CI/staging deploy, survives volume wipes, parallels existing migration pattern.
- **D-02:** Password sourced from a single env var (e.g. `UAT_TEST_PASSWORD`) at migration runtime. Argon2id hash computed during migration. NEVER hardcode plaintext in the repo. Same env var injected into Playwright CI so both ends agree.  ⚠️ **Researcher amendment:** convention is **two env vars** `UAT_PASSWORD_A` + `UAT_PASSWORD_B` (already in CI workflow + Playwright fixtures); see Standard Stack §"Env-var naming".
- **D-03:** Playwright config + globalSetup switched from `dev-bypass:uat_a/uat_b` synthetic tokens to **real login** as `dunsen_uat` / `rebbie_uat`. Operator-driven UAT continues to use `uat_a` / `uat_b` dev-bypass — collision-free.
- **D-04:** Account flags: regular non-admin accounts. No special role bits. Username matches `account_id` (Better-Auth canonical lookup column). Display name set to `Dunsen` / `Rebbie` (capitalized). ⚠️ **Researcher amendment:** in this schema `account.account_id` is set to **`user.id`** (a UUID), NOT to the username — see Canonical Refs §"account.account_id semantics". The username/lookup column is `user.username` (UNIQUE).
- **D-05:** D-58c diagnostic gate at `RebnoRoom.ts:1206` is NOT auto-extended to the new accounts. If gated diagnostics ever return, operator accounts stay opted in; Playwright accounts stay opted out. ⚠️ **Researcher amendment:** the per-account log at `RebnoRoom.ts:1206` is STILL PRESENT post-29f1858 (the freeze fix removed the per-tick spike, not this branch). Listed in code-context as `event: 'd51c_player_motion_zeroed'`. **No action** required for 06.6 — the existing `uat_a` / `uat_b` literal list is the desired behaviour; do NOT add `dunsen_uat` / `rebbie_uat` to it.

**Integer Viewport Scaling**
- **D-06:** Replace `scale.mode: Phaser.Scale.FIT` + `zoom: Phaser.Scale.MAX_ZOOM` with **manual integer zoom**: `scale.mode: Phaser.Scale.NONE` (or RESIZE — researcher to confirm which preserves canvas-pixel-perfect rendering without internal Phaser re-fits), then compute `zoom = Math.max(1, Math.floor(Math.min(winW/640, winH/480)))` and call `game.scale.setZoom(zoom)` at boot + on every `window.resize`. ⚠️ **Researcher confirms:** use `Phaser.Scale.NONE`. `RESIZE` would change the base internal render resolution, violating ADR 0008's 640×480 lock.
- **D-07:** Letterbox/pillarbox region = canvas backgroundColor `#0A0E1A` (UI-SPEC dominant). Centered via `autoCenter: CENTER_BOTH`.
- **D-08:** Floor minimum zoom = 1x (no sub-integer fallback if window is smaller than 640×480 — canvas just clips at the edges; acceptable per project's Chrome-desktop target).
- **D-09:** `roundPixels`, `pixelArt`, `antialias: false` invariants from `main.ts:32-34` MUST stay. NaN/zero defensive guard around the resize callback (e.g., headless test environments where `window.innerWidth = 0`).

**Chat Overlay Clamp**
- **D-10:** Track canvas via `game.canvas.getBoundingClientRect()` on (a) boot after Phaser READY, (b) `window.resize`, (c) the Phaser `SCALE_CHANGE` / `RESIZE` event the new manual-zoom code emits in D-06. Apply `left/top/width/height` to ChatHUD's root container (currently `position:fixed inset:0`). ⚠️ **Researcher amendment:** ChatHUD's current container is NOT `position:fixed inset:0` — it's `position: absolute; left: 16px; bottom: 16px; min-width: 320px; max-width: 560px` (per `apps/client/src/ui/ChatHUD.ts:84-98`). The clamp must reposition the existing absolute box so `left/bottom` are measured **relative to the canvas rect**, not the window. Suggested approach: switch container to `position:fixed`, set `left/top/width/height` to the canvas rect, and re-anchor the inner chat box to the bottom-left of THAT rect via `bottom: 16px` on the inner element.
- **D-11:** Maintain the HARD invariant from `ChatHUD.ts:7-14` and ADR 0008: ChatHUD stays under `#dom-overlay` (sibling of `#game-root`), NOT inside `#game-root`.
- **D-12:** Same tracker applies to any sibling overlay that should be canvas-bound. EscMenu stays centered via `transform: translate(-50%, -50%)` against its own `top:50%; left:50%` and is acceptable as full-window since it is a modal-style overlay — DO NOT clamp it.
- **D-13:** Tracker debounces nothing (canvas-rect reads are cheap) but coalesces multiple events fired within the same animation frame via `requestAnimationFrame`.

**Right-Click Menu**
- **D-14:** Replace `this.input.on('pointerdown', ...)` at `GameScene.ts:297` with a button-discriminating handler: open EscMenu only when `event.button === 2` (right-click). All existing D-34 suppression guards (escMenu open, pointer-lock, chat-mode, banner, force-reset) preserved.
- **D-15:** Add a `document.addEventListener('contextmenu', e => e.preventDefault())` registration **scoped to the game-root subtree** (NOT global — chat input / EscMenu DOM children must keep normal context menu so future copy-paste UX is not broken). Registration installed in GameScene `create()`, torn down in `shutdown()`. ⚠️ **Researcher amendment:** Phaser provides `this.input.mouse.disableContextMenu()` which suppresses contextmenu **only on `game.canvas`**, NOT on sibling DOM overlays. This is the *canonical* API and is automatically scope-correct (chat input + EscMenu live in `#dom-overlay`, not the canvas). Use it instead of a hand-rolled `document.addEventListener`. Lifecycle: `disableContextMenu()` returns `this` from `Phaser.Input.Mouse.MouseManager`; the listener is auto-removed on scene shutdown via Phaser's input plugin teardown.
- **D-16:** Left-click (`event.button === 0`) on the canvas = **no-op**, reserved for future Phase 7 click-to-walk / interact. NO placeholder comment in code.
- **D-17:** Middle-click and other buttons = no-op (no menu, no preventDefault). Wheel events untouched.
- **D-18:** Existing `06-HUMAN-UAT.md` / Playwright tests that simulate "click to open menu" must be updated to right-click (`page.click({ button: 'right' })` or `page.mouse.down({ button: 'right' })`).

### Claude's Discretion
- Migration file naming, exact env var name (likely `UAT_TEST_PASSWORD` — but researcher should grep existing CI workflow files for established convention first). **Researcher recommends:** **`UAT_PASSWORD_A` + `UAT_PASSWORD_B`** (already in deploy-staging.yml lines 326-328 + fixtures.ts lines 38-46). Migration file naming: `apps/server/scripts/seed-uat-accounts.ts`, called from `run-migrations.ts` AFTER `migrate(db, {migrationsFolder})` completes successfully.
- Whether to extract a small `useCanvasRectTracker` helper for D-10 or inline it in ChatHUD. Decide based on whether any other overlay would benefit (BannerReconnect at z=9999 is window-bound on purpose; ForceResetOverlay TBD). **Researcher recommends:** extract `apps/client/src/render/canvas-rect-tracker.ts` (sibling of `legacy-origin.ts`) — even if only ChatHUD consumes it now, unit-test isolation is much easier when the helper is pure (input = rect + zoom; output = `{left, top, width, height}` to apply).
- Exact event for re-clamping: `game.scale.on('resize', ...)` vs `window.addEventListener('resize', ...)` — pick whichever fires reliably after our manual `setZoom` call. **Researcher recommends:** `game.scale.on(Phaser.Scale.Events.RESIZE, …)` — the canonical event fires AFTER Phaser updates internal display size, so the rect read is consistent with what's rendered. The `window.addEventListener('resize')` handler in `main.ts:49` should call the new compute-and-setZoom function; `setZoom` internally triggers `Phaser.Scale.Events.RESIZE`; ChatHUD's tracker subscribes to that.

### Deferred Ideas (OUT OF SCOPE)
- **Settings panel** in EscMenu (currently disabled placeholder per `EscMenu.ts:97-118`) — already deferred to Phase 7.
- **Click-to-walk / pathfinding** on left-click — reserved by D-16; belongs in Phase 7 (likely PAR-* row).
- **Touch / mobile input** — out of scope (Chrome desktop only per project charter).
- **Per-account UAT pool growth** (more than 2 Playwright accounts for parallel-shard tests) — defer until Playwright actually shards.
- **Smoke flake** (`waitForGameReady` 15s timeout, 10/39 fails) — pre-existing CI timing issue tracked separately under Gen-9 intentions; if D-01 real-login adds latency, may surface here — researcher should call out. **Researcher flags:** D-01 adds ~100ms argon2id verify per login (`ARGON2_OPTS.memoryCost=131072, timeCost=3` — Fly shared-cpu-2x measured 100.5ms in Phase 5 HUMAN-UAT). Two logins ≈ 200ms additional latency before `data-game-ready`. **Unlikely** to push the 10/39 flake higher because the 15s timeout already has ~14.5s headroom; but if the flake worsens post-rollout, the link is plausible. Recommend **not** bumping `waitForGameReady` timeout in this phase — keep changes minimal; revisit if flake rate climbs.

## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| REQ-CLI-01 | `apps/client` is Vite + TypeScript + Phaser 3 bundle in Chrome | D-06..D-09 modify `apps/client/src/main.ts` Phaser config — direct REQ-CLI-01 surface |
| REQ-CLI-02 | Client connects via WSS binary frames + PROTOCOL_VERSION handshake | D-03 switches Playwright from dev-bypass synthetic tokens to real Better-Auth login — exercises this REQ end-to-end |
| REQ-CLI-03 | Login screen authenticates via server and transitions to game | D-03 real-login exercises this REQ; D-01 seed creates the accounts that the login flow consumes |
| REQ-CLI-05 | Chat HUD: send + receive with sender nameplate | D-10..D-13 reposition `apps/client/src/ui/ChatHUD.ts` — direct REQ-CLI-05 surface |
| REQ-CLI-06 | HiDPI rendering uses nearest-neighbor + integer scale | D-06..D-09 enforce integer zoom — direct REQ-CLI-06 surface; ADR 0008 already covers this REQ |
| REQ-CLI-08 | MVP GATE: two players join, move, and chat over deployed server | D-03 real-login + D-01 seed + D-18 Playwright update — exercises the cli-08 two-client smoke against real accounts |
| REQ-CLI-09 | Disconnect/reconnect within grace window restores room + character | Indirect — cli-08.e2e step (5) exercises this REQ and benefits from real accounts (more realistic session-token reconnect path) |
| REQ-DEP-01 | Migrator runs before server boot | D-01 seed script appended to `apps/server/scripts/run-migrations.ts` — directly extends REQ-DEP-01 surface; no new REQ row needed (researcher confirmed against `traceable-reqs.toml`) |

**No new REQ rows are needed.** The seed migration is a natural extension of REQ-DEP-01; tag the new seed code with `// [impl->REQ-DEP-01] [impl->REQ-CLI-08]` to record the dual provenance.

## Standard Stack

### Core (versions verified against `apps/server/package.json` + `apps/client/package.json` 2026-05-16)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `phaser` | 3.90.0 | Client game framework; Scale Manager API for D-06 | Already locked by project (ADR 0001) |
| `better-auth` | 1.6.9 | Account/session schema (`user`, `account` tables); the new seed inserts directly into these | Already locked by project (Phase 4 SRV-09) |
| `argon2` | 0.44.0 | Argon2id PHC hasher for D-01 seed migration | CLAUDE.md hard rule #2; OWASP-compliant; existing `ARGON2_OPTS` lives in `apps/server/src/argon2-opts.ts` |
| `better-sqlite3` | 12.9.0 | Synchronous SQLite driver — required for `db.transaction()` synchronous TXN semantics | Already locked by project |
| `drizzle-orm` | 0.45.2 | Schema typing; `sql` tag for raw INSERT used by `legacy-login.ts` pattern | Already locked by project |
| `@playwright/test` | 1.59.1 | E2E driver for two-client smoke + D-18 right-click update | Already locked by project |

### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `node:crypto` (built-in) | n/a | `randomUUID()` for `user.id` + `account.id` primary keys | Pattern from `legacy-login.ts:178-179` — DO NOT use Math.random (WR-02) |

### Env-var naming (D-02 amendment)
`.github/workflows/deploy-staging.yml:325-328` and `apps/client/test/e2e/fixtures.ts:31-48` already define:

| Variable | Default (local dev) | Source |
|----------|--------------------|--------|
| `UAT_ACCOUNT_A` | `alice` | GitHub Secret in CI |
| `UAT_PASSWORD_A` | `alicepass1234` | GitHub Secret in CI |
| `UAT_ACCOUNT_B` | `bob` | GitHub Secret in CI |
| `UAT_PASSWORD_B` | `bobpass1234` | GitHub Secret in CI |

**Seed migration MUST read these same variables.** If unset at migration time (local dev without secrets), the seed should:
- Use the username `UAT_ACCOUNT_A`/`UAT_ACCOUNT_B` to seed (default `dunsen_uat` / `rebbie_uat` if env unset; but recommended: derive username from env to keep CI-secret-driven flexibility)
- Skip with a log warning if either password is empty/unset (do NOT fail boot — local dev shouldn't require these secrets)

⚠️ **Decision deferred to planner:** should the username also be env-derived (`UAT_ACCOUNT_A=dunsen_uat`) or hard-coded in the seed migration with passwords from env? CONTEXT D-01 says hard-code usernames (`dunsen_uat` / `rebbie_uat`). The CI fixture *defaults* (`alice`/`bob`) become dead weight if the GitHub Secret `UAT_ACCOUNT_A` is set to `dunsen_uat`. **Recommended:** set `UAT_ACCOUNT_A=dunsen_uat` and `UAT_ACCOUNT_B=rebbie_uat` as GitHub Secrets at the same time the password secrets are added; the seed migration reads `UAT_ACCOUNT_A` (with default `dunsen_uat`) to keep CI-driven naming flexibility while satisfying D-04's display-name requirement (`Dunsen` / `Rebbie`).

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Raw SQL `tx.run(sql\`INSERT…\`)` (D-01 seed) | `auth.api.signUpEmail({body:…})` server-side | signUpEmail creates a session as a side effect (unwanted for seed); also doesn't accept `username` directly (post-update required). Raw SQL matches `legacy-login.ts` precedent exactly. |
| `Phaser.Scale.NONE` (D-06) | `Phaser.Scale.RESIZE` | RESIZE changes the *base* render resolution — would mean 640×480 is no longer locked, violating ADR 0008. NONE is correct. |
| `pointer.button === 2` (D-14) | `pointer.rightButtonDown()` | Both work in Phaser 3.90. `rightButtonDown()` is the canonical Phaser helper (verified via `/phaserjs/phaser` docs); use it for readability. |
| Hand-rolled `document.addEventListener('contextmenu', preventDefault)` (D-15) | `this.input.mouse.disableContextMenu()` | Phaser's helper auto-scopes to `game.canvas` (suppressing only canvas contextmenus, leaving DOM overlays' contextmenus intact) AND is auto-torn-down on scene shutdown. Strictly better. |

**Installation:** No new dependencies. All required libraries already present.

**Version verification:** Confirmed against `apps/server/package.json` (argon2 0.44.0, better-auth 1.6.9, better-sqlite3 12.9.0, drizzle-orm 0.45.2) and `apps/client/package.json` (phaser 3.90.0, @playwright/test 1.59.1) on 2026-05-16. No version drift.

## Architecture Patterns

### System Architecture Diagram

```
                       ┌──────────────────────────────────────┐
                       │  GitHub Actions: deploy-staging.yml  │
                       │  injects UAT_ACCOUNT_*/UAT_PASSWORD_*│
                       └─────────────┬────────────────────────┘
                                     │ env vars
              ┌──────────────────────┼──────────────────────┐
              ▼                                             ▼
  ┌───────────────────────┐                    ┌──────────────────────────┐
  │ apps/server (Fly.io)  │                    │ Playwright runner (CI)   │
  │                       │                    │                          │
  │ docker-entrypoint.sh  │                    │ test/e2e/fixtures.ts     │
  │   ↓                   │                    │   reads UAT_ACCOUNT_A/B  │
  │ run-migrations.ts     │                    │   reads UAT_PASSWORD_A/B │
  │   ↓ (D-01 new step)   │                    │   ↓                      │
  │ seed-uat-accounts.ts ─┼────► INSERT user   │ loginAs(page, account)   │
  │   • argon2id hash     │      INSERT account│   POSTs /api/auth/       │
  │   • db.transaction()  │      ON CONFLICT   │     sign-in/email        │
  │   • idempotent        │      DO NOTHING    │   ↓                      │
  │   ↓                   │                    │ GameScene WS join with   │
  │ node dist/index.js    │                    │   real session token     │
  │   ↓                   │                    │   (no dev-bypass)        │
  │ RebnoRoom.onAuth ─────┼───► Better-Auth    └──────────┬───────────────┘
  │   accepts real or         api.getSession              │
  │   dev-bypass tokens                                   │
  └───────────────────────┘                               │
                                                          ▼
                                            ┌──────────────────────────┐
                                            │ apps/client (Phaser 3.90)│
                                            │                          │
                                            │ main.ts:                 │
                                            │   D-06: Scale.NONE       │
                                            │   D-06: setZoom(floor(   │
                                            │     min(W/640, H/480)))  │
                                            │   ↓ on window.resize     │
                                            │                          │
                                            │ game.scale.on(RESIZE)    │
                                            │   ↓                      │
                                            │ ChatHUD.canvasRectTracker│
                                            │   reads getBoundingRect  │
                                            │   ↓ requestAnimationFrame│
                                            │   applies l/t/w/h to     │
                                            │   #chat-hud container    │
                                            │                          │
                                            │ GameScene:               │
                                            │   D-14: pointerdown(p):  │
                                            │     if p.rightButton…    │
                                            │   D-15: input.mouse      │
                                            │     .disableContextMenu()│
                                            └──────────────────────────┘
```

### Recommended Project Structure
```
apps/
├── server/
│   ├── scripts/
│   │   ├── run-migrations.ts        # existing; extend to call seed-uat-accounts at end
│   │   └── seed-uat-accounts.ts     # NEW — D-01 idempotent UAT seed
│   └── src/
│       ├── argon2-opts.ts           # existing; reused by seed
│       └── auth.ts                  # untouched
└── client/
    ├── index.html                   # untouched
    ├── src/
    │   ├── main.ts                  # D-06..D-09 Phaser config rewrite
    │   ├── render/
    │   │   ├── legacy-origin.ts     # existing sibling
    │   │   └── canvas-rect-tracker.ts  # NEW — D-10/D-13 helper (Claude's discretion: extracted)
    │   ├── scenes/
    │   │   └── GameScene.ts         # D-14/D-15 pointer-button check + disableContextMenu
    │   └── ui/
    │       └── ChatHUD.ts           # D-10/D-11 consume canvas-rect-tracker
    └── test/e2e/
        ├── fixtures.ts              # unchanged (already reads UAT_*) — but loginAs may need real-account validation
        └── cli-08.e2e.test.ts       # unchanged surface; runs against real accounts via fixtures
```

### Pattern 1: Idempotent UAT seed (raw SQL INSERT in a Drizzle transaction)
**What:** Mirror the `legacy-login.ts` row-write pattern verbatim — direct `INSERT INTO user … INSERT INTO account …` inside `db.transaction((tx) => …)`, with `ON CONFLICT … DO NOTHING` for idempotency.
**When to use:** Seed scripts that must be Litestream-restore-safe and runnable inside the existing migrator chain.
**Example (matches `legacy-login.ts:186-199` style):**

```ts
// Source: apps/server/src/legacy-login.ts:186-199 (verified pattern)
// + ARGON2_OPTS from apps/server/src/argon2-opts.ts:17-22
import { hash } from 'argon2';
import { randomUUID } from 'node:crypto';
import { sql } from 'drizzle-orm';
import { ARGON2_OPTS } from './argon2-opts.js';

async function seedOne(db: Db, username: string, password: string, displayName: string): Promise<'seeded' | 'exists' | 'skipped'> {
  if (!password) {
    log.warn({ username }, 'uat_seed_skipped_no_password');
    return 'skipped';
  }
  // Idempotency check — SELECT first, return early if present.
  const existing = (db as unknown as { $client: { prepare: (s: string) => { get: (...a: unknown[]) => unknown } } })
    .$client
    .prepare('SELECT id FROM user WHERE username = ?')
    .get(username) as { id: string } | undefined;
  if (existing) return 'exists';

  // Hash outside the TXN (argon2 is CPU-bound; better-sqlite3 transactions are synchronous).
  const passwordHash = await hash(password, ARGON2_OPTS);
  const userId = randomUUID();
  const accountId = randomUUID();
  const email = `${username}@uat.rebno.local`;  // Synthetic email; Better-Auth requires email NOT NULL.
  const now = Date.now();

  db.transaction((tx) => {
    tx.run(
      sql`INSERT INTO user (id, name, email, email_verified, username, role, force_reset, created_at, updated_at)
          VALUES (${userId}, ${displayName}, ${email}, 0, ${username}, 'player', 0, ${now}, ${now})
          ON CONFLICT(username) DO NOTHING`,
    );
    tx.run(
      sql`INSERT INTO account (id, account_id, provider_id, user_id, password, created_at, updated_at)
          VALUES (${accountId}, ${userId}, 'credential', ${userId}, ${passwordHash}, ${now}, ${now})
          ON CONFLICT DO NOTHING`,
    );
  });
  log.info({ username }, 'uat_account_seeded');
  return 'seeded';
}
```

### Pattern 2: Integer-zoom recompute (Phaser 3.90 Scale.NONE)
**What:** Set Scale.NONE so Phaser does no auto-scaling; compute the integer zoom on boot + resize and apply via `game.scale.setZoom(n)`.
**When to use:** Pixel-art games that need integer-multiple zoom against a locked base resolution (ADR 0008).
**Example:**

```ts
// Source: /phaserjs/phaser docs (Context7 §"Set fixed game size with NONE mode") + §"ScaleManager Core Methods"
// + locked params from apps/client/src/main.ts:32-42

const BASE_W = 640;
const BASE_H = 480;

function computeIntegerZoom(): number {
  // D-09 NaN/zero guard for headless test environments.
  const w = Number.isFinite(window.innerWidth) && window.innerWidth > 0 ? window.innerWidth : BASE_W;
  const h = Number.isFinite(window.innerHeight) && window.innerHeight > 0 ? window.innerHeight : BASE_H;
  return Math.max(1, Math.floor(Math.min(w / BASE_W, h / BASE_H)));
}

const game = new Phaser.Game({
  type: Phaser.AUTO,
  parent: 'game-root',
  banner: false,
  pixelArt: true,
  roundPixels: true,
  render: { pixelArt: true, antialias: false, roundPixels: true },
  scale: {
    mode: Phaser.Scale.NONE,           // D-06: manual zoom only
    autoCenter: Phaser.Scale.CENTER_BOTH, // D-07
    width: BASE_W,
    height: BASE_H,
    zoom: computeIntegerZoom(),         // D-06 initial
  },
  dom: { createContainer: true },
  scene: [BootScene, LoginScene, GameScene],
  backgroundColor: '#0A0E1A',
});

window.addEventListener('resize', () => {
  game.scale.setZoom(computeIntegerZoom());  // emits Phaser.Scale.Events.RESIZE internally
});
```

### Pattern 3: Canvas-rect tracker (D-10/D-13 — rAF coalesced)
**What:** Subscribe to `game.scale.on(Phaser.Scale.Events.RESIZE)` AND `window.addEventListener('resize')`; coalesce both into a single per-frame read of `game.canvas.getBoundingClientRect()`; apply `left/top/width/height` to a target HTMLElement.
**When to use:** Any DOM overlay that needs to track the live canvas rect (currently: ChatHUD only).
**Example:**

```ts
// apps/client/src/render/canvas-rect-tracker.ts (NEW)
// [impl->REQ-CLI-05] [impl->REQ-CLI-06]

export interface CanvasRectTrackerOptions {
  canvas: HTMLCanvasElement;
  target: HTMLElement;     // overlay container to reposition
  scale: Phaser.Scale.ScaleManager;
}

export function attachCanvasRectTracker(opts: CanvasRectTrackerOptions): () => void {
  let queued = false;
  const apply = (): void => {
    queued = false;
    const r = opts.canvas.getBoundingClientRect();
    // Defensive: NaN/zero guard (D-09 sibling for D-10).
    if (!Number.isFinite(r.left) || r.width <= 0 || r.height <= 0) return;
    Object.assign(opts.target.style, {
      position: 'fixed',
      left: `${r.left}px`,
      top: `${r.top}px`,
      width: `${r.width}px`,
      height: `${r.height}px`,
    });
  };
  const schedule = (): void => {
    if (queued) return;
    queued = true;
    requestAnimationFrame(apply);
  };
  opts.scale.on(Phaser.Scale.Events.RESIZE, schedule);
  window.addEventListener('resize', schedule);
  // Initial application (boot — after Phaser READY)
  schedule();
  // Tear-down
  return (): void => {
    opts.scale.off(Phaser.Scale.Events.RESIZE, schedule);
    window.removeEventListener('resize', schedule);
  };
}
```

ChatHUD then mounts under `#dom-overlay` as before, but `mount()` calls `attachCanvasRectTracker({canvas: game.canvas, target: this.el, scale: game.scale})` and stores the disposer for `unmount()`.

### Pattern 4: Right-click handler + Phaser context-menu suppression
**What:** Discriminate `pointer.button === 2` (or use `pointer.rightButtonDown()`) inside the existing `pointerdown` handler; call `this.input.mouse.disableContextMenu()` once in `create()`.
**When to use:** Any Phaser scene where right-click should open a menu without the browser's default context menu appearing.
**Example:**

```ts
// apps/client/src/scenes/GameScene.ts:297 — D-14/D-15 replacement
// Source: /phaserjs/phaser §"Handle Right-Clicks and Disable Context Menu"

create() {
  // ... existing code ...

  // D-15: suppress browser contextmenu on the Phaser canvas ONLY.
  // disableContextMenu attaches to game.canvas (not document) — DOM overlays
  // (chat input, EscMenu) retain their normal contextmenu for copy/paste UX.
  this.input.mouse?.disableContextMenu();

  // D-14: right-click opens EscMenu; left-click is a Phase 7 reservation no-op.
  this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => {
    if (!pointer.rightButtonDown()) return;   // D-14: ignore non-right-clicks
    // Existing D-34 suppression guards (preserved verbatim):
    if (this.escMenu?.isOpen()) return;
    if (typeof document !== 'undefined' && document.pointerLockElement) return;
    if (this.chatHud?.isChatModeActive?.()) return;
    if (this.chatHud?.isInputFocused?.()) return;
    if (this.banner?.isVisible?.()) return;
    if (this.forceReset?.isVisible?.()) return;
    this.escMenu?.open();
    this.inputDispatcher?.setFrozen(true);
  });
}
```

### Anti-Patterns to Avoid
- **Hand-rolling argon2id parameters** — always import `ARGON2_OPTS` from `apps/server/src/argon2-opts.ts`. Drift here silently weakens the seed against the legitimate sign-up path (WR-07).
- **Inserting into `user` without `account`** — Better-Auth requires the linked `account` row to allow sign-in. Both inserts MUST be in the same transaction.
- **Using `Phaser.Scale.RESIZE` for the manual-zoom path** — RESIZE changes the *base* internal render resolution, violating ADR 0008's 640×480 lock.
- **Mounting ChatHUD inside `#game-root`** — would couple chat text DPR to canvas zoom (HARD invariant violation; chat-hud test 0 enforces).
- **Listening to `contextmenu` on `document`** — globally suppressing breaks copy/paste on chat input and any future right-click menu inside EscMenu. Use `this.input.mouse.disableContextMenu()` (canvas-scoped).
- **Calling `argon2.hash()` inside `db.transaction((tx) => …)`** — better-sqlite3 transactions are synchronous; awaiting argon2 inside holds a write lock for ~100ms (lock-thrashing under concurrent CI runs). Hash first, then open the TXN.
- **`db.transaction((tx) => {…})` returning a Promise** — better-sqlite3 transactions MUST run synchronously. The pattern in `legacy-login.ts:187-199` is correct: the `await hash(…)` happens BEFORE the transaction call.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Argon2id hashing | Hand-tuned argon2 params | `import { ARGON2_OPTS } from './argon2-opts.js'` + `argon2.hash(pw, ARGON2_OPTS)` | Single source of truth (WR-07); OWASP-compliant params already measured for Fly shared-cpu-2x |
| UUID generation for `user.id`/`account.id` | `Math.random().toString(36)` etc. | `import { randomUUID } from 'node:crypto'` | Pattern from `legacy-login.ts:177-178` (WR-02 fix — Math.random reseed-on-import drift) |
| Integer-zoom math against the Phaser canvas | Custom CSS scale on the canvas element | `Phaser.Scale.NONE` + `game.scale.setZoom(n)` | Phaser's scale manager handles bounds + emits the canonical RESIZE event downstream consumers depend on |
| `getBoundingClientRect` debouncing | setTimeout-based debounce | `requestAnimationFrame` coalescing | Rect reads are already layout-flush-cheap; rAF aligns with the browser's paint loop and is the right granularity |
| Browser context-menu suppression | `document.addEventListener('contextmenu', preventDefault)` | `this.input.mouse.disableContextMenu()` | Auto-scoped to canvas; auto-torn-down on scene shutdown; canonical Phaser API |
| Pointer-button discrimination | Polling `event.button` from raw browser events | `pointer.rightButtonDown()` (Phaser helper) | Handles multi-button modifier state, also works correctly with Phaser's input plugin lifecycle |

**Key insight:** Every one of the four scope items has a canonical library API or in-repo precedent. There is **no novel logic** in this phase. Plan execution = "wire existing primitives correctly", verification = "confirm the wiring matches the canonical API contract."

## Runtime State Inventory

**Stored data:**
- `user` + `account` tables on the Fly.io staging volume `/data/rebno.db`. New rows for `dunsen_uat` / `rebbie_uat` are inserted by the seed; idempotency via `ON CONFLICT DO NOTHING` means re-runs are no-ops. Litestream backs the volume to Tigris; restore is safe.
- Existing `uat_a` / `uat_b` are **synthetic test-only account_ids** that exist ONLY in the dev-bypass code path at `RebnoRoom.ts:439-441` — they have NO database rows. New `dunsen_uat` / `rebbie_uat` are real DB rows. NO collision.

**Live service config:**
- GitHub Actions secrets: `UAT_ACCOUNT_A`, `UAT_PASSWORD_A`, `UAT_ACCOUNT_B`, `UAT_PASSWORD_B`, `STAGING_INVITE_TOKEN`. The first four are NOT yet known to be set in GitHub (researcher could not verify against the actual GH Secrets store from this session — `gh secret list -R Reavo End/rebno` would confirm). **Operator action required:** set these 4 secrets in GitHub Actions before merging this phase, OR the post-deploy smoke fails on real-login.
- Fly.io app config: must inject `UAT_PASSWORD_A` and `UAT_PASSWORD_B` into the **server** runtime so the seed migration can read them on container boot. Add to `apps/server/fly.staging.toml` `[env]` block? **No** — passwords in fly toml are committed to git. Use `flyctl secrets set -a rebno-staging UAT_PASSWORD_A=… UAT_PASSWORD_B=…` (NOT in toml).

**OS-registered state:** None.

**Secrets / env vars:**
- New secret store entries needed (GitHub Actions + Fly.io): `UAT_PASSWORD_A`, `UAT_PASSWORD_B` (passwords). Optional: `UAT_ACCOUNT_A`, `UAT_ACCOUNT_B` if hard-coding `dunsen_uat`/`rebbie_uat` directly in the seed script.

**Build artifacts:**
- `apps/server/dist/scripts/seed-uat-accounts.js` — new TypeScript compilation output. The `tsc -b` step in `pnpm --filter @rebno/server build` already picks up new files under `apps/server/scripts/`; verify by examining `apps/server/tsconfig.json` includes — researcher saw `apps/server/scripts/migrate-legacy-accounts.ts` compiles cleanly, so the convention is established.
- `apps/client/src/render/canvas-rect-tracker.ts` — Vite picks up new files automatically; no build-config change needed.

## Common Pitfalls

### Pitfall 1: Account row missing → silent sign-in failure
**What goes wrong:** Seed creates `user` row but skips `account` row (typo, divergence from legacy-login pattern). Better-Auth's email sign-in returns `403 invalid email/password` because there's no credential provider.
**Why it happens:** Two-table dependency is not obvious from Better-Auth's API surface; only the schema reveals it.
**How to avoid:** Insert both rows in the same `db.transaction((tx) => …)` call; integration-test the seed by calling `auth.api.signInEmail({...})` against the freshly-seeded row.
**Warning signs:** sign-in returns success on POST `/api/auth/sign-in/email` only if BOTH rows exist; Better-Auth logs `INVALID_PASSWORD` if `account.password` is null/missing.

### Pitfall 2: `Phaser.Scale.RESIZE` instead of `NONE`
**What goes wrong:** Planner picks RESIZE for D-06 thinking it's "more responsive". RESIZE changes the *base* render resolution to match the viewport, breaking the 640×480 lock from ADR 0008 — every sprite/room layout becomes wrong-scale.
**Why it happens:** RESIZE sounds more modern than NONE; Phaser docs describe RESIZE as "responsive".
**How to avoid:** Explicit comment in `main.ts` referencing ADR 0008. The 640×480 base resolution is the source of truth for every imported asset; only `zoom` may change.
**Warning signs:** Sprites scale weirdly with window size, area-name text drifts, mini-map (if any) becomes wrong-aspect.

### Pitfall 3: Layout-thrash from missing rAF coalescing
**What goes wrong:** `window.resize` fires N times during a single user drag, each call computes setZoom AND triggers the canvas-rect tracker; if tracker also re-fires Phaser RESIZE which re-fires tracker, we get redundant `getBoundingClientRect()` reads and DOM writes per frame.
**Why it happens:** Naive implementations subscribe to all events independently without coalescing.
**How to avoid:** Single `requestAnimationFrame` coalescing pattern in `canvas-rect-tracker.ts` (Pattern 3). Even if 10 events fire in one frame, only one rAF callback runs.
**Warning signs:** Resize feels jerky / chat repositions visibly behind cursor drag; CPU spike in DevTools during resize.

### Pitfall 4: Password printed to logs at migration time
**What goes wrong:** Seed script logs `{username, password}` for debug; passwords end up in pino structured logs → OTLP → Datadog forever.
**Why it happens:** Easy to add a `log.info({username, password}, '…')` line by mistake.
**How to avoid:** Match the `legacy-login.ts:200-203` pattern — log `{username, force_reset, algorithm}`, NEVER the password or hash. pino redact paths already cover `*.password` / `*.legacy_hash` but the seed migration runs at a different log layer; double-check.
**Warning signs:** grep the seed script for `password:` (colon) — any occurrence in a log call is a bug.

### Pitfall 5: Synthetic email enumeration
**What goes wrong:** Using `dunsen_uat@uat.rebno.local` as the synthetic email lets external attackers infer that `dunsen_uat` exists via Better-Auth sign-up endpoint (HTTP 422 "email already in use" vs 200).
**Why it happens:** Synthetic-email is the natural fix for Better-Auth's `email NOT NULL UNIQUE` constraint.
**How to avoid:** UAT accounts ARE intentionally known to the operator + CI; the username is published in CONTEXT.md. Enumeration is acceptable here — the threat model is "an attacker brute-forces dunsen_uat's password" which is mitigated by argon2id + the strong password the operator sets via `UAT_PASSWORD_A`. Document the acceptance in a code comment.
**Warning signs:** External pentesters might flag this; pre-empt by documenting that UAT accounts are public-knowledge by design.

### Pitfall 6: `disableContextMenu` AFTER scene swap loses scope
**What goes wrong:** Calling `disableContextMenu()` in `create()` works for the current scene but Phaser swaps the input plugin per-scene. If GameScene is shut down and re-created (logout/re-login cycle), the suppression is lost between teardown and re-`create()`.
**Why it happens:** Phaser's input plugin tears down at scene `SHUTDOWN`; the contextmenu listener is canvas-level, BUT attached via the plugin's lifecycle.
**How to avoid:** Call `disableContextMenu()` inside `create()`. Phaser re-installs it on re-entry. Verify with a manual test: log out, log back in, right-click — context menu must stay suppressed.
**Warning signs:** Right-click works once but shows the browser menu on second login attempt.

### Pitfall 7: `getBoundingClientRect` returns stale dimensions if read BEFORE Phaser RESIZE callback fires
**What goes wrong:** If `canvas-rect-tracker` subscribes to `window.resize` and reads the rect BEFORE Phaser processes the resize and applies setZoom, the rect is from the old layout.
**Why it happens:** Race between browser layout reflow and Phaser's deferred setZoom propagation.
**How to avoid:** Subscribe to `Phaser.Scale.Events.RESIZE` (fires AFTER Phaser updates display) — this is the canonical timing. The `window.resize` subscription is a belt-and-braces fallback for environments where Phaser doesn't auto-emit (it does in 3.90, but defensive).
**Warning signs:** Chat HUD appears one-frame-misaligned during a fast drag-resize.

## Code Examples

(See Architecture Patterns §1-4 above for verified pattern snippets with source citations.)

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `Phaser.Scale.FIT` + `MAX_ZOOM` + `autoRound:true` | `Phaser.Scale.NONE` + manual `setZoom(floor(min(W/640, H/480)))` | This phase (06.6) | Eliminates fractional zoom that slipped past `autoRound`; predictable layout for chat-rect clamp |
| ChatHUD `position:fixed inset:0` | ChatHUD container set to live canvas rect via `requestAnimationFrame`-coalesced tracker | This phase (06.6) | Chat overlay no longer paints over letterbox bars; visually aligns with the game viewport |
| Any-button pointerdown → EscMenu | `pointer.rightButtonDown()` only, with `disableContextMenu()` | This phase (06.6) | Reserves left-click for Phase 7 click-to-walk; prevents accidental menu pops from incidental clicks |
| `dev-bypass:uat_a`/`uat_b` synthetic tokens in Playwright | Real Better-Auth login as `dunsen_uat`/`rebbie_uat` | This phase (06.6) | Exercises the full login path in CI; operator-driven dev-bypass UAT can run concurrently without account-id collision |

**Deprecated/outdated:**
- `Phaser.Scale.MAX_ZOOM` for pixel-art games: Phaser 3.90's MAX_ZOOM is correct numerically but combines awkwardly with `Scale.FIT` to produce sub-integer scale factors in practice (the bug 06.6 fixes).

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | The `UAT_ACCOUNT_A`/`UAT_PASSWORD_A`/`UAT_ACCOUNT_B`/`UAT_PASSWORD_B` GitHub Secrets do NOT currently have values set in the rebno repo's Actions secrets store (only the names exist in the workflow YAML) | Standard Stack §Env-var naming | If wrong, the operator may already have set them with different password values and the migration's hash will not match; sign-in will fail. Pre-deploy check: `gh secret list -R …` |
| A2 | `apps/server/tsconfig.json` already includes `scripts/**/*.ts` (since `migrate-legacy-accounts.ts` and `run-migrations.ts` compile cleanly today) so a new `seed-uat-accounts.ts` will be picked up by `tsc -b` without config changes | Architecture §Recommended Project Structure | If wrong, `apps/server/dist/scripts/seed-uat-accounts.js` won't exist and the migrator import will fail at runtime |
| A3 | Better-Auth 1.6.9 will accept a sign-in request where `account.password` is an argon2id hash + the `verify` hook in `auth.ts:38-43` returns true. (verified by `auth.integ.test.ts` line 78 asserting `$argon2id$` hashes — strong evidence) | Pattern 1 | Low risk — covered by existing integration tests |
| A4 | `requestAnimationFrame` is available in the Playwright headless Chrome environment used by the e2e tests | Pattern 3 | Low risk — Playwright Chrome is real Chromium |
| A5 | `this.input.mouse` is non-null in headless contexts (research did not verify against the headless code path explicitly; Phaser may construct a no-op MouseManager when no mouse is attached). The example uses `this.input.mouse?.disableContextMenu()` with optional chaining as defensive guard | Pattern 4 | Low risk — operator and Playwright both run with mouse devices attached |
| A6 | Setting `UAT_ACCOUNT_A=dunsen_uat` as a GitHub Secret (overriding the `'alice'` default in `fixtures.ts:38`) is the planner's intended approach. CONTEXT D-04 mandates the literal username `dunsen_uat` so the secret must match. If the secret is left unset, fixtures fall back to `alice` and the Playwright tests will try to log in as `alice` (which the seed didn't create). | Standard Stack §Env-var naming | Medium risk — make the planner aware that BOTH password AND account-name secrets must be set OR the seed must use hard-coded usernames matching the fixture defaults |

## Open Questions (RESOLVED)

1. **Should the seed use env-derived usernames or hard-coded `dunsen_uat`/`rebbie_uat`?**
   - **RESOLVED:** hard-code `dunsen_uat`/`rebbie_uat` literals in the seed. Confirmed by CONTEXT R-01 + R-02 (post-research user ratification 2026-05-16): the new accounts are operator-manual-UAT-only and never touched by Playwright fixtures. No env-derived flexibility needed; no GitHub secret alignment needed.

2. **Should `account.account_id` be set to `user.id` (UUID) or to `username`?**
   - **RESOLVED:** `account.account_id = user.id` (UUID via `randomUUID()`), matching `legacy-login.ts:194` verbatim. Confirmed by CONTEXT R-03 (post-research user ratification 2026-05-16). `user.username` UNIQUE is the canonical Better-Auth lookup column.

3. **Operator action — set GitHub + Fly secrets BEFORE merging.**
   - **RESOLVED — SUPERSEDED by R-01.** No env vars, no GitHub secrets, no Fly secrets are required. Per CONTEXT R-01 (2026-05-16 ratification): the seed migration generates a strong random password via `crypto.randomBytes(24).toString('base64url')`, argon2id-hashes it, and prints the plaintext to stdout EXACTLY ONCE for operator capture on first run. Subsequent runs no-op. Operator stores the captured plaintext in their personal password manager. The "Operator-action items" listed in §Environment Availability below are obsolete and should be IGNORED by the planner (kept here for revision history).

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node.js | All build/test steps | ✓ | v25.8.2 local (target v22 in CI per Dockerfile + workflow) | — |
| pnpm | Workspace install | ✓ | 10.33.2 | — |
| argon2 npm pkg | Seed migration | ✓ | 0.44.0 (in apps/server/package.json) | — |
| better-sqlite3 | Seed migration | ✓ | 12.9.0 | — |
| Phaser 3.90 | Client scaling rewrite | ✓ | 3.90.0 | — |
| @playwright/test | E2E update | ✓ | 1.59.1 | — |
| Fly.io flyctl | Setting secrets | ✓ (in CI; operator's local install unverified) | — | Operator can use Fly dashboard UI |

**Missing dependencies with no fallback:** None — all required tooling is present.

**Missing dependencies with fallback:** None.

**Operator-action items (not "dependencies" but prerequisites):**
- ~~GitHub Secrets~~ / ~~Fly.io secrets~~ — **OBSOLETE per CONTEXT R-01.** No secrets are required. Operator captures the stdout-printed plaintext on first migration run and stores it in their personal password manager. See Open Questions §3 (RESOLVED).

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework — server | Vitest 4.1.5 (`apps/server/package.json:55`) |
| Framework — client | Vitest 3.2.4 + Playwright 1.59.1 (`apps/client/package.json:33,27`) |
| Config file — server | `apps/server/vitest.config.ts` (implicit via package.json scripts) |
| Config file — client | `apps/client/vitest.config.ts` + `apps/client/playwright.config.ts` |
| Quick run command (server unit) | `pnpm --filter @rebno/server test` |
| Quick run command (server integ) | `pnpm --filter @rebno/server test:integration` |
| Quick run command (client unit) | `pnpm --filter @rebno/client test` |
| Quick run command (client e2e local) | `pnpm --filter @rebno/client test:e2e` |
| Full suite command | `pnpm -r test` (then `pnpm --filter @rebno/server test:full` for integ + `pnpm --filter @rebno/client test:e2e`) |

### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| REQ-DEP-01 (extension) | Seed migration creates `dunsen_uat` row idempotently | integration | `pnpm --filter @rebno/server vitest run test/seed-uat.integ.test.ts -t "idempotent"` | ❌ Wave 0 |
| REQ-DEP-01 (extension) | Seed migration produces an argon2id hash sign-in can verify | integration | `pnpm --filter @rebno/server vitest run test/seed-uat.integ.test.ts -t "sign-in roundtrip"` | ❌ Wave 0 |
| REQ-DEP-01 (extension) | Seed migration is a no-op on second run | integration | `pnpm --filter @rebno/server vitest run test/seed-uat.integ.test.ts -t "no-op on re-run"` | ❌ Wave 0 |
| REQ-CLI-01 / REQ-CLI-06 | Integer-zoom math: window 1920×1080 → zoom 2, 800×600 → zoom 1, 0×0 → zoom 1 (defensive) | unit | `pnpm --filter @rebno/client vitest run src/__test__/integer-zoom.test.ts` | ❌ Wave 0 |
| REQ-CLI-05 | canvas-rect-tracker applies `left/top/width/height` from `getBoundingClientRect`-mock + coalesces multiple events via rAF | unit | `pnpm --filter @rebno/client vitest run src/__test__/canvas-rect-tracker.test.ts` | ❌ Wave 0 |
| REQ-CLI-05 / REQ-CLI-06 | ChatHUD repositions to canvas rect on resize (jsdom-driven) | unit | `pnpm --filter @rebno/client vitest run src/__test__/chat-hud.test.ts -t "canvas-rect clamp"` | ✅ (extend existing) |
| REQ-CLI-02 / REQ-CLI-03 | EscMenu opens on right-click ONLY (not left-click) | unit | `pnpm --filter @rebno/client vitest run src/__test__/esc-menu.test.ts -t "right-click"` | ✅ (extend existing) |
| REQ-CLI-02 / REQ-CLI-03 | Right-click on canvas does NOT trigger browser contextmenu | unit (jsdom) | `pnpm --filter @rebno/client vitest run src/__test__/game-scene.test.ts -t "disableContextMenu"` | ❌ Wave 0 (or extend esc-menu.test.ts) |
| REQ-CLI-08 | Two-client smoke logs in with real Better-Auth accounts (no dev-bypass) | e2e | `pnpm --filter @rebno/client test:e2e -- -g "CLI-08 hard milestone"` (post-seed; against local server with UAT_PASSWORD_* set) | ✅ (cli-08.e2e.test.ts — flips by env, no test source change) |
| REQ-CLI-08 | Two-client smoke against deployed staging | e2e (CI only) | CI step: `Playwright CLI-08 two-client smoke (post-deploy)` in `deploy-staging.yml:320-329` | ✅ (CI workflow) |
| Manual UAT | Operator opens app on three window sizes (1920×1080, 1280×720, 800×600); confirms integer zoom + chat clamps to canvas + right-click opens EscMenu | manual | document in `apps/client/06-HUMAN-UAT.md` (existing file) | ✅ (extend existing) |

### Sampling Rate
- **Per task commit:** `pnpm --filter @rebno/server test && pnpm --filter @rebno/client test` (unit + jsdom — sub-30s)
- **Per wave merge:** `pnpm --filter @rebno/server test:integration && pnpm --filter @rebno/client test:e2e` (integ + local Playwright — 2-3 min)
- **Phase gate:** Full suite green (`pnpm -r test`); cli-08 e2e GREEN against local + post-deploy GREEN against staging before `/gsd-verify-work`

### Wave 0 Gaps
- [ ] `apps/server/test/seed-uat.integ.test.ts` — covers REQ-DEP-01 extension (3 sub-tests: idempotency, sign-in roundtrip, no-op re-run)
- [ ] `apps/client/src/__test__/integer-zoom.test.ts` — covers REQ-CLI-01 / REQ-CLI-06 (pure-function `computeIntegerZoom(w, h)` test — mock `window.innerWidth`/`innerHeight`)
- [ ] `apps/client/src/__test__/canvas-rect-tracker.test.ts` — covers REQ-CLI-05 (mock `canvas.getBoundingClientRect`, fire `resize` events, assert single-rAF coalesced apply)
- [ ] Extend `apps/client/src/__test__/chat-hud.test.ts` — add "ChatHUD repositions on canvas-rect change" test
- [ ] Extend `apps/client/src/__test__/esc-menu.test.ts` — replace left-click assertions with right-click; add suppression-on-left-click assertion
- [ ] Extend `apps/client/06-HUMAN-UAT.md` — three-window-size manual smoke (operator)
- [ ] No framework install needed; all test infrastructure already present.

## Security Domain

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes (D-01..D-04 seed creates auth credentials) | Better-Auth 1.6.9 + argon2id (existing); `ARGON2_OPTS` from shared module |
| V3 Session Management | yes (D-03 Playwright now uses real Better-Auth sessions) | Better-Auth bearer plugin + 30-day sliding session (existing in `auth.ts:60`) |
| V4 Access Control | no (UAT accounts are non-admin per D-04) | — |
| V5 Input Validation | yes (D-01 password from env — validate length ≥ 8) | Better-Auth client signUp.email enforces min 8 chars; seed should add a defensive guard pre-hash |
| V6 Cryptography | yes (argon2id hashing) | NEVER hand-roll; use `argon2` npm pkg 0.44.0 + shared `ARGON2_OPTS` |
| V8 Data Protection | yes (passwords in env / secrets store) | GitHub Actions Secrets + Fly.io secrets (NOT in fly.toml [env] block) |

### Known Threat Patterns for {Better-Auth + Playwright + Phaser DOM-overlay}

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Password committed to repo | Information Disclosure | Env-var sourcing + lint check `lint:vite-env` already wired; add seed-script audit: no `password = "..."` literal in source |
| Password leaked to logs | Information Disclosure | Match `legacy-login.ts` log pattern — log username + outcome only, NEVER the password/hash. pino redact paths cover `*.password` but the seed runs at pre-pino-init layer — manual discipline required |
| UAT account credentials brute-forced over the open net | STRIDE-E (Elevation) | argon2id with OWASP params (already locked at memory=128MiB, time=3, parallelism=4 — ≥200ms per attempt on Fly shared-cpu-2x) + strong password generated for the secret (operator responsibility) |
| Synthetic-email enumeration of UAT usernames | Information Disclosure | Accepted risk — UAT usernames are public-by-design (in CONTEXT.md). Document explicitly. Real users use `legacyEmail()` HMAC fingerprinting (existing) so production users are NOT enumerable. |
| ContextMenu suppression too broad → breaks chat copy/paste | Usability / Functional | Use `this.input.mouse.disableContextMenu()` (canvas-scoped, NOT document-scoped) — auto-correct by Phaser API choice |
| Right-click handler intercepted by overlays needing right-click | Functional | None currently. ChatHUD + EscMenu DOM elements are NOT canvas children — their contextmenu handlers (none today) would still fire because Phaser's disableContextMenu only suppresses on canvas |
| Migration TXN rollback leaves DB in inconsistent state | T-04-10-04 sibling | `db.transaction((tx) => …)` is atomic in better-sqlite3 (immediate mode); ON CONFLICT DO NOTHING means partial-insert is impossible |

## Project Constraints (from CLAUDE.md)

| Constraint | Source | Impact on This Phase |
|-----------|--------|---------------------|
| TypeScript strict mode everywhere | §Conventions | All new files (`seed-uat-accounts.ts`, `canvas-rect-tracker.ts`) compile under strict |
| Per-plan atomic commits with REQ-tag references | §Conventions | Each task = one commit; commit message references the REQ IDs touched |
| Traceable-reqs tagging `[<impl>->REQ-ID]`, `[<unit>->REQ-ID]`, `[<int>->REQ-ID]` in source comments | §Requirements Traceability | Seed script: `[impl->REQ-DEP-01] [impl->REQ-CLI-08]`. canvas-rect-tracker: `[impl->REQ-CLI-05] [impl->REQ-CLI-06]`. main.ts changes: `[impl->REQ-CLI-01] [impl->REQ-CLI-06]`. GameScene right-click: `[impl->REQ-CLI-02] [impl->REQ-CLI-03]`. |
| Argon2id from packet 1; never plaintext | §Hard Rules #2 | Seed must hash with `ARGON2_OPTS` from shared module |
| 640×480 viewport LOCKED | §Extracted Constants | D-06 MUST keep base=640×480; only `zoom` may change. Use `Scale.NONE`, NOT `RESIZE`. |
| `#dom-overlay` ChatHUD/EscMenu mount HARD invariant | ChatHUD.ts:7-14 + ADR 0008 | D-10/D-11 reposition the existing container; do NOT migrate it into `#game-root` |
| Server-authoritative; never trust client positions | §Hard Rules #1 | Phase doesn't touch the wire — no impact |
| ADR 0008 base resolution lock | §Reference | D-06 directly implements the integer-zoom intent described in ADR 0008; consider amending the ADR to note that 06.6 replaced FIT+MAX_ZOOM with NONE+manual setZoom |

## Sources

### Primary (HIGH confidence)
- Context7 `/phaserjs/phaser` — fetched 2026-05-16:
  - §"Set fixed game size with NONE mode in Phaser" — confirms Scale.NONE semantics
  - §"ScaleManager Core Methods" — setZoom, resize, refresh, setGameSize signatures
  - §"Phaser Scale Manager Events" — `Phaser.Scale.Events.RESIZE` is the canonical event ('resize' string value)
  - §"Pointer API Reference" — `pointer.rightButtonDown()` is the canonical right-click predicate
  - §"Handle Right-Clicks and Disable Context Menu" — `this.input.mouse.disableContextMenu()` is the canonical suppressor
- Context7 `/better-auth/better-auth` — fetched 2026-05-16:
  - §"Perform Server-Side Email Sign Up and Sign In with Better Auth" — confirms server-side signUpEmail signature
  - §"auth.api.signUpEmail()" — confirmed Response/Headers shape (informs Pitfall 1 reasoning)
- `apps/server/src/legacy-login.ts:177-247` — canonical row-write pattern for `user` + `account` insertion using `db.transaction((tx) => tx.run(sql\`INSERT…\`))`
- `apps/server/src/argon2-opts.ts:17-22` — `ARGON2_OPTS` single source of truth
- `packages/db/src/auth-tables.ts:4-91` — Better-Auth schema definitions for `user` / `account` / `session` / `verification` tables
- `apps/client/test/e2e/fixtures.ts:31-48` — env-var convention `UAT_ACCOUNT_A/B` + `UAT_PASSWORD_A/B`
- `.github/workflows/deploy-staging.yml:325-329` — CI secret injection
- `docs/adr/0008-canvas-base-resolution.md` — 640×480 base resolution lock + DOM-overlay rationale
- `apps/server/src/RebnoRoom.ts:420-448` — dev-bypass code path (uat_a/uat_b stay), :1206 — gated diagnostic (NOT in scope)
- `apps/client/src/main.ts:27-51` — current Phaser config + resize handler
- `apps/client/src/scenes/GameScene.ts:290-306` — current pointerdown + D-34 guards
- `apps/client/src/ui/ChatHUD.ts:80-153` — current ChatHUD mount + style; reposition target

### Secondary (MEDIUM confidence)
- WebSearch — none required; Context7 covered all framework-API questions.

### Tertiary (LOW confidence)
- A1 (GitHub Secret presence): assumed unset; operator MUST verify before merge.

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — every library and version verified against package.json files in the working tree
- Architecture: HIGH — patterns directly mirror existing in-repo precedents (legacy-login.ts, fixtures.ts, deploy-staging.yml)
- Pitfalls: HIGH — derived from concrete code reading + Context7 docs; no speculation
- Env-var naming: HIGH — convention is locked by existing CI workflow + Playwright fixtures
- Phaser 3.90 API: HIGH — verified against Context7 `/phaserjs/phaser` (the canonical Phaser docs source)

**Research date:** 2026-05-16
**Valid until:** 2026-06-15 (30 days for stable APIs; Phaser 3.90 + Better-Auth 1.6.9 + argon2 0.44.0 are all stable)
