# Phase 06.4: cycle-5 gap-closure — Pattern Map

**Mapped:** 2026-05-15
**Files analyzed:** 24 new/modified files
**Analogs found:** 23 / 24

> **CRITICAL path corrections vs the prompt's `<files_to_map>` list:**
> - The prompt listed `apps/server/src/__test__/` and `apps/client/src/render/__test__/` for new unit tests. Those directories **do not exist** in this codebase.
> - Server unit/integ tests live at **`apps/server/test/*.test.ts`** (verified via `Glob`).
> - Client unit tests live at **`apps/client/src/__test__/*.test.ts`** (verified via `Glob`).
> - The planner MUST place new test files in those existing locations to match `vitest.config.ts` test-include globs. Do NOT create the `__test__/` directories the prompt suggested.

---

## File Classification

| New/Modified File (corrected path) | Role | Data Flow | Closest Analog | Match Quality |
|------------------------------------|------|-----------|----------------|---------------|
| `apps/client/src/render/legacy-origin.ts` (NEW, D-63 helper) | utility | transform (pure) | `apps/client/src/render/RoomCollision.ts` (pure data-in/data-out helper in render/) | role-match |
| `apps/client/src/__test__/legacy-origin.test.ts` (NEW, D-63 drift) | test | transform | `apps/client/src/__test__/room-collision-bottom-edge.test.ts` | role-match |
| `apps/server/src/layout-derive.ts` (NEW, D-62) | utility | transform (pure) | `apps/client/src/render/RoomCollision.ts:deriveWalkableGrid` (same data-shape pattern) | role-match |
| `apps/server/test/layout-derive.test.ts` (NEW, D-62 unit) | test | transform | `apps/client/src/__test__/room-collision-bottom-edge.test.ts` | role-match |
| `apps/server/test/d60-always-emit-layout.integ.test.ts` (NEW) | test | event-driven | `apps/server/test/rebno-room-replace-session.integ.test.ts` | role-match |
| `apps/server/test/d62-derived-collision.integ.test.ts` (NEW) | test | event-driven | `apps/server/test/rebno-room-replace-session.integ.test.ts` | role-match |
| `apps/server/test/d51c-eviction-trace.integ.test.ts` (NEW) | test | event-driven | `apps/server/test/rebno-room-replace-session.integ.test.ts` | exact |
| `apps/server/test/d58c-direction-broadcast.integ.test.ts` (NEW) | test | event-driven | `apps/server/test/rebno-room-replace-session.integ.test.ts` (re-uses s2c capture pattern) | role-match |
| `apps/client/src/__test__/player-renderer.teleport.test.ts` (NEW, D-55c) | test | event-driven | `apps/client/src/__test__/player-renderer-spawn-delay.test.ts` | exact |
| `apps/client/src/__test__/sprite-state-machine.teleport-gate.test.ts` (NEW, D-55d) | test | transform | `apps/client/src/__test__/sprite-state-machine.test.ts` + `player-renderer-spawn-delay.test.ts` | role-match |
| `apps/client/src/__test__/nameplate-stability.test.ts` (NEW, D-45d) | test | transform | `apps/client/src/__test__/nameplate.test.ts` | role-match |
| `apps/client/src/__test__/reconciler.test.ts` (MODIFIED, D-57b/D-58b regression cases) | test | transform | self (extend existing `describe` blocks) | exact |
| `apps/client/src/__test__/room-renderer.test.ts` (NEW, D-61 drop assertion) | test | event-driven | `apps/client/src/__test__/background-renderer.test.ts` (closest render-init test) | role-match |
| `apps/client/test/e2e/cli-08-resume-render.spec.ts` (NEW, D-60 — extend existing) | test | event-driven | `apps/client/test/e2e/cookie-reload.e2e.test.ts` | exact (extend) |
| `apps/client/test/e2e/cli-08-collision-boundary.spec.ts` (NEW, D-62 boundary) | test | event-driven | `apps/client/test/e2e/cli-08-floor-collision.e2e.test.ts` | exact |
| `apps/client/test/e2e/cli-08-dup-login.e2e.test.ts` (MODIFIED, D-51c no-banner-loop) | test | event-driven | self (extend) | exact |
| `apps/client/test/e2e/cli-08-direction-convergence.spec.ts` (NEW, D-58c) | test | event-driven | `apps/client/test/e2e/cli-08-nameplate.e2e.test.ts` (two-context pattern) | role-match |
| `apps/client/test/e2e/cli-08-snapshot-no-spawn-anim.spec.ts` (NEW, D-55d) | test | event-driven | `apps/client/test/e2e/cli-08-anim.e2e.test.ts` (frame-sample telemetry) + `cli-08-nameplate.e2e.test.ts` (two-context) | role-match |
| `apps/client/test/e2e/cli-08-nameplate-stable.spec.ts` (NEW, D-45d) | test | event-driven | `apps/client/test/e2e/cli-08-nameplate.e2e.test.ts` + `cli-08-nameplate-offset.e2e.test.ts` | role-match |
| `tooling/no-inline-origin.ts` OR pnpm script (NEW, Gate 5) | config | — | no analog (grep-script gate is new pattern) | — |
| `apps/server/src/RebnoRoom.ts` (MODIFIED) | service | event-driven | self (edit-in-place) | exact |
| `apps/server/rooms/mvp-room/000.json` (MODIFIED, D-61 drop) | data | — | self (edit-in-place) | exact |
| `apps/client/src/render/PlayerRenderer.ts` (MODIFIED) | render | event-driven | self (edit-in-place) | exact |
| `apps/client/src/render/RoomRenderer.ts` (MODIFIED, D-61 drop) | render | request-response | self (edit-in-place) | exact |
| `apps/client/src/render/RoomCollision.ts` (MODIFIED, D-62 consume) | render | transform | self (edit-in-place) | exact |
| `apps/client/src/render/Nameplate.ts` (D-45d candidate) | render | request-response | self (edit-in-place) | exact |
| `apps/client/src/render/SpriteStateMachine.ts` (D-58c root cause) | utility | transform (pure) | self (edit-in-place) | exact |
| `apps/client/src/scenes/GameScene.ts` (D-55d gating + D-58c apply) | scene | event-driven | self (edit-in-place) | exact |
| `packages/protocol/src/intents.ts` (D-62 + optional D-58c) | model | transform | self (edit-in-place) | exact |
| `CLAUDE.md` (D-63 doc section) | doc | — | self (edit-in-place) | exact |

---

## Pattern Assignments

### `apps/client/src/render/legacy-origin.ts` (NEW — D-63 helper)

**Analog:** `apps/client/src/render/RoomCollision.ts` — same "pure-data-in/pure-data-out helper that lives under `render/` to avoid premature `packages/*` promotion" rationale.

**Header banner pattern** (copy shape from `RoomCollision.ts:1-14`):
```typescript
// apps/client/src/render/legacy-origin.ts
// [impl->REQ-CLI-04] [impl->REQ-CLI-08]
// Plan 06.4 D-63 — permanent origin-convention mitigation for player-attached
// effects ported from legacy GameMaker.
//
// LEGACY  : Navi sprite origin (0, 0) = top-left (head).
// PHASER  : Navi sprite origin (0.5, 1) = bottom-center (feet).
//
// SOURCE: extracted/client-5-8/sprites/0000-NaviStandD/meta.json
//   width = 36, height = 48, bboxBottom = 46 (visible feet line; NOT 48).
//
// CLAUDE.md "Coordinate conventions" pins:
//   legacy_x = phaser_x - NAVI_WIDTH_PX/2   (= 18)
//   legacy_y = phaser_y - NAVI_VISIBLE_FEET_Y (= 46)
//
// Helper API contract (object-in, tuple-out — destructures into setOrigin):
//   const [ox, oy] = phaserOriginForLegacyPlayerAttached({...});
//   sprite.setOrigin(ox, oy);
```

**Exports:**
```typescript
export const NAVI_WIDTH_PX = 36;
export const NAVI_VISIBLE_FEET_Y = 46;

export function phaserOriginForLegacyPlayerAttached(spec: {
  legacyOriginX: number;
  legacyOriginY: number;
  width: number;
  height: number;
}): [originX: number, originY: number] {
  return [
    (spec.legacyOriginX + NAVI_WIDTH_PX / 2) / spec.width,
    (spec.legacyOriginY + NAVI_VISIBLE_FEET_Y) / spec.height,
  ];
}
```

---

### `apps/client/src/render/PlayerRenderer.ts` — D-55c refactor (edit-in-place)

**Analog:** self, lines 538-622 (`startTeleportAnim`).

**Current inline math** (PlayerRenderer.ts:611-613, to be replaced):
```typescript
const originX = variant.legacyOriginX / variant.width;
const originY =
  (variant.legacyOriginY + NAVI_HEIGHT_PX) / variant.height;
```

**Replacement** — import + helper call:
```typescript
import { phaserOriginForLegacyPlayerAttached } from './legacy-origin.js';
// ... inside startTeleportAnim, replace lines 611-613:
const [originX, originY] = phaserOriginForLegacyPlayerAttached({
  legacyOriginX: variant.legacyOriginX,
  legacyOriginY: variant.legacyOriginY,
  width: variant.width,
  height: variant.height,
});
```

**Cleanup:** delete the now-unused `const NAVI_HEIGHT_PX = 48;` at line 585 (it was the wrong constant — visible-feet line is 46, not sprite-rect height 48). Delete the multi-line comment at lines 547-555 explaining the old math (replaced by helper's source-cited comment).

**RESEARCH §Pattern 2 verified values (variant.height = 100, NOT 64 as the prompt scope-hint suggested):**
- `originX = (15 + 18) / 64 = 33/64 ≈ 0.515625`
- `originY = (42 + 46) / 100 = 0.88`

---

### `apps/server/src/layout-derive.ts` (NEW — D-62)

**Analog:** `apps/client/src/render/RoomCollision.ts:deriveWalkableGrid` (lines 35-63) — same shape "pure helper that reads a layout, returns derived bounds".

**Imports pattern** (copy shape from `RoomCollision.ts`):
```typescript
// apps/server/src/layout-derive.ts
// [impl->REQ-SRV-03] [impl->REQ-SRV-14] [impl->REQ-CLI-06]
// Plan 06.4 D-62 — server-runtime derivation of {collision_polys, room_size}
// from layout.tiles[] + tile_w/tile_h + width_tiles/height_tiles.
//
// MVP rooms ship with uniform floors; derived polys are the outer rectangle
// boundary as four wall-thick rectangles. Mixed-walkable / abyss support is
// Phase 7 (requires per-tile collision attribution beyond tiles[]).
//
// SOURCE: RESEARCH §Pattern 3 + apps/server/rooms/mvp-lobby/000.json reference
// shape (HAS collision_polys + room_size; mvp-room/000.json lacks both).

import type { Layout } from '@rebno/protocol';

export interface DerivedBounds {
  collision_polys: Array<Array<{ x: number; y: number }>>;
  room_size: { w: number; h: number };
}

export function deriveLayoutBounds(layout: Layout): DerivedBounds {
  if ('room_id' in layout) {
    const w = layout.width_tiles * layout.tile_w;
    const h = layout.height_tiles * layout.tile_h;
    const tw = layout.tile_w;
    const th = layout.tile_h;
    return {
      collision_polys: [
        [{ x: 0, y: 0 },     { x: w, y: 0 },     { x: w, y: th },    { x: 0, y: th }],
        [{ x: 0, y: h - th },{ x: w, y: h - th },{ x: w, y: h },     { x: 0, y: h }],
        [{ x: 0, y: 0 },     { x: tw, y: 0 },    { x: tw, y: h },    { x: 0, y: h }],
        [{ x: w - tw, y: 0 },{ x: w, y: 0 },     { x: w, y: h },     { x: w - tw, y: h }],
      ],
      room_size: { w, h },
    };
  }
  return {
    collision_polys: layout.collision_polys ?? [],
    room_size: layout.room_size,
  };
}
```

---

### `apps/server/src/RebnoRoom.ts` — D-60 + D-62 + D-51c telemetry + D-58c facing broadcast (edit-in-place)

**Analog:** self.

**D-60 — unconditional layout emit on onJoin** (lines 396-426 — RECONNECT branch already calls `sendRoomLayoutToClient(client)` at line 418; the FRESH branch at 429+ never does — that is the bug):

```typescript
// AFTER the fresh-session player setup block (after this.state.players.set at line 455):
// [impl->REQ-CLI-06] [impl->REQ-CLI-08]
// D-60 fix: ALWAYS emit room_layout regardless of reconnect-vs-fresh.
// Cycle-4 reconnect branch already did this (line 418); fresh-join did not,
// so cookie-resume + fresh-auth UAT showed missing floor tiles on first session.
this.sendRoomLayoutToClient(client);
```

**D-62 — wire `deriveLayoutBounds` at onCreate / registry.onChange** (around line 197-211 where `this.registry.onChange(onChange)` registers):

```typescript
import { deriveLayoutBounds } from './layout-derive.js';

// In onCreate, replace the hardcoded mvpRoomLayout default at line 138-141:
private mvpRoomLayout: {
  collision_polys: Array<Array<{ x: number; y: number }>>;
  room_size: { w: number; h: number };
} = { collision_polys: [], room_size: { w: 1000, h: 1000 } };  // placeholder until first scan

// In the registry.onChange callback (line 198-200):
const onChange = (room_id: string, layout: LoadedLayout): void => {
  if (room_id === MVP_ROOM_ID) {
    // [impl->REQ-SRV-03] [impl->REQ-SRV-14] D-62: derive bounds at registry load.
    this.mvpRoomLayout = deriveLayoutBounds(layout.layout);
  }
  this.broadcastRoomLayout(room_id, layout);
};
```

**D-62 — broadcast derived fields in `s2c.room_layout`** (extend `broadcastRoomLayout` lines 214-223 AND `sendRoomLayoutToClient` lines 237-275). Per RESEARCH §Open Question 1, add SEPARATE envelope fields (NOT re-pack into layout_bytes; that would invalidate `manifest_sig`):

```typescript
// packages/protocol/src/intents.ts — extend S2C room_layout envelope:
// (add to the room_layout discriminated union member):
//   derived_collision_polys?: Array<Array<{x: number; y: number}>>;
//   derived_room_size?: { w: number; h: number };

// RebnoRoom.ts broadcastRoomLayout:
private broadcastRoomLayout(room_id: string, layout: LoadedLayout): void {
  const derived = deriveLayoutBounds(layout.layout);
  const evt: S2C = {
    type: 'room_layout',
    room_id,
    layout_rev: layout.layout_rev,
    layout_bytes: layout.layoutBytes,
    manifest_sig: layout.manifest_sig,
    // D-62 derived fields (server-authoritative; client trusts these over local re-derivation):
    derived_collision_polys: derived.collision_polys,
    derived_room_size: derived.room_size,
  };
  this.broadcast('s2c', encodeS2C(evt));
}
```

**D-51c — eviction telemetry extension** (extend existing pino events at lines 494-503, 535-543, 555-579):

```typescript
// [impl->REQ-SRV-03] [impl->REQ-CLI-08]
// D-51c spike: extend existing d51_eviction event family with per-iteration
// timing + connection-state snapshots. Operator reads pino tail on staging.
log.info(
  {
    event: 'd51c_eviction_iter',
    step: 'before_priorClient_leave',
    iteration: i,
    priorSessionId,
    priorClientFound: !!priorClient,
    priorClientConnState: priorClient?.state ?? 'unknown',
    elapsedMs: Date.now() - evictionStartMs,
    ts: Date.now(),
  },
  'D-51c eviction iter pre-leave',
);
```

**D-58c — broadcast facing in PlayerState** (extend `applyToColyseusState` at line 855-879 with a derived `facing` write). RESEARCH §Don't Hand-Roll mandates server-authoritative facing per REQ-SRV-03. The simplest path: derive facing in `applyToColyseusState` from `(p.vx, p.vy, prior facing)` and broadcast as a new `@type("string") facing` field on `PlayerState`.

```typescript
// PlayerState schema (apps/server/src/state.ts or wherever the @colyseus/schema is declared):
@type("string") facing: string = 'D';  // 'D' | 'DR' | 'R' | 'UR' | 'U' | 'UL' | 'L' | 'DL'

// applyToColyseusState extension:
import { deriveFacing, type Direction } from '@rebno-client/sprite-state-machine'; // OR duplicate the function server-side
// Better: extract deriveFacing into @rebno/game-logic so both sides import the same pure function.
p.facing = deriveFacing(p.vx, p.vy, (p.facing as Direction) ?? 'D');
```

---

### `apps/client/src/render/RoomCollision.ts` — D-62 consume server polys (edit-in-place)

**Analog:** self.

**Add a new entry point** (server-broadcast collision_polys + room_size supersede the local `deriveWalkableGrid` for collision; the walkable-grid is still used for `tsideCount` rendering):

```typescript
// [impl->REQ-CLI-06] [impl->REQ-SRV-03]
// D-62: when the server-broadcast room_layout includes derived_collision_polys
// (new field per Plan 06.4), consume those directly. The local deriveWalkableGrid
// is preserved for rendering-side tile-edge probes (Plan 06.1-05) but no longer
// drives step() collision.
export function fromServerDerived(
  collision_polys: Array<Array<{x: number; y: number}>>,
  room_size: { w: number; h: number },
): { collision_polys: typeof collision_polys; room_size: typeof room_size } {
  return { collision_polys, room_size };
}
```

---

### `apps/client/src/scenes/GameScene.ts` — D-55d gating predicate + D-58c remote-facing apply (edit-in-place)

**Analog:** self.

**D-55d — initial-snapshot flag** (insert after `this.events.once(Phaser.Scenes.Events.SHUTDOWN, ...)` around line 304):

```typescript
// [impl->REQ-CLI-04] [impl->REQ-CLI-08]
// D-55d: gate TeleIn anim for remote players on "fresh-join during this session",
// NOT "first time we see this player". Initial-snapshot batch suppresses anim;
// subsequent onRemoteAdd fires it.
//
// SOURCE: RESEARCH §Pitfall 3 — Date.now() clock-skew rejected; use a flag flipped
// after the first microtask post-onJoin completes. Colyseus 0.17 delivers all
// initial state-sync onAdd callbacks SYNCHRONOUSLY before returning control to
// the event loop (Assumption A1; test-validate via the new
// sprite-state-machine.teleport-gate.test.ts).
private inInitialSnapshot = true;
```

**D-55d — flip flag in `onLocalJoin`** (the first onLocalJoin = the moment we know initial state-sync completed):
```typescript
private onLocalJoin(player: PlayerSnapshotShape): void {
  // ... existing logic ...
  // D-55d: flip the gating flag AFTER initial state-sync batch completes.
  queueMicrotask(() => { this.inInitialSnapshot = false; });
}
```

**D-55d — pass `playTeleportIn` flag through `onRemoteAdd`** (line 574-583):
```typescript
private onRemoteAdd(player: PlayerSnapshotShape, sid: string): void {
  this.playerRenderer?.addRemote(
    sid,
    player.name ?? sid,
    player.x,
    player.y,
    { playTeleportIn: !this.inInitialSnapshot },  // D-55d
  );
  this.publishRemotePlayers();
  this.markGameReady();
}
```

(Planner: extend `PlayerRenderer.addRemote` signature at line 403 to accept the optional `{ playTeleportIn?: boolean }` 5th arg; the unconditional `this.playTeleportIn(entry)` call at line 432 becomes `if (opts?.playTeleportIn !== false) this.playTeleportIn(entry);`.)

**D-58c — consume server-broadcast facing** (in `onRemoteSnapshot` line 585-598 or `updateRemote`):
```typescript
private onRemoteSnapshot(player: PlayerSnapshotShape, sid: string): void {
  this.playerRenderer?.updateRemote(sid, player.x, player.y);
  // [impl->REQ-CLI-04] [impl->REQ-SRV-03]
  // D-58c: trust server-broadcast facing; do NOT re-derive client-side.
  if (typeof player.facing === 'string') {
    this.playerRenderer?.setRemoteFacing(sid, player.facing as Direction);
  }
  // ... rest unchanged ...
}
```

---

### `apps/client/src/render/SpriteStateMachine.ts` — D-58c root cause (edit-in-place)

**Analog:** self, lines 112-140 (`deriveFacing`).

**Decision per RESEARCH §Pattern (server-broadcast facing chosen):** `deriveFacing` stays for the LOCAL predicted player path. For REMOTE players, `PlayerRenderer.onSimulationTickRemote` (line 344) currently passes `r.facing` (per-renderer-entry state) to `deriveFrame`. After D-58c lands, REMOTE `r.facing` is overwritten by the server-broadcast value before `deriveFrame` is called.

**No code change inside `deriveFacing` itself.** The change is at the call site (`PlayerRenderer.onSimulationTickRemote` and `GameScene.onRemoteSnapshot`).

If `deriveFacing` needs to move to `@rebno/game-logic` for shared server+client use:
```typescript
// packages/game-logic/src/sprite-state-machine.ts (NEW or extend)
// Move `deriveFacing` here verbatim; re-export from apps/client/src/render/SpriteStateMachine.ts
// for backward-compat with existing imports.
```

---

### `apps/client/src/render/Nameplate.ts` — D-45d residual fix candidate (edit-in-place)

**Analog:** self — line 125 already has `Math.round` (cycle-3 ship).

**RESEARCH §Summary verified A3:** The residual flicker is upstream in **PlayerRenderer.ts:357-360** — the `lerpFactor = 0.35` produces fractional `smoothedY` that boundary-flickers between rounded `textY` values. Fix candidate (apply in `PlayerRenderer.onSimulationTickRemote`, NOT in `Nameplate.follow`):

```typescript
// apps/client/src/render/PlayerRenderer.ts onSimulationTickRemote (line 357-360):
// BEFORE:
const lerpFactor = 0.35;
const smoothedX = r.sprite.x + (x - r.sprite.x) * lerpFactor;
const smoothedY = r.sprite.y + (y - r.sprite.y) * lerpFactor;
r.sprite.setPosition(smoothedX, smoothedY);

// AFTER — integer-snap the sprite position pre-render (kills nameplate flicker source):
// [impl->REQ-CLI-04] [impl->REQ-CLI-07] [impl->REQ-CLI-08]
// D-45d: round smoothed values to integers to prevent sub-pixel oscillation
// across the Math.round() boundary inside Nameplate.follow(). The 0.35 lerp
// shipped in 06.3 produces fractional Y that boundary-flickers.
const lerpFactor = 0.35;
const smoothedX = Math.round(r.sprite.x + (x - r.sprite.x) * lerpFactor);
const smoothedY = Math.round(r.sprite.y + (y - r.sprite.y) * lerpFactor);
r.sprite.setPosition(smoothedX, smoothedY);
```

**SPIKE FIRST per CONTEXT:** Confirm via `window.__rebno.firstRemoteNameplateHistory` capture on staging (the ring buffer is already unconditional, lines 375-393); only then ship the integer-snap fix.

---

### `apps/server/rooms/mvp-room/000.json` — D-61 drop wall_border (edit-in-place)

**Analog:** self — line 1970-1979 wall_border block.

**Action:** delete the `"wall_border": { ... }` block at lines 1970-1979. Manifest signature regenerates (use `pnpm sign-rooms` or the equivalent room-key tooling).

**Verification:** `apps/server/rooms/mvp-lobby/000.json` retains `wall_border` (Phase 7 legacy compat — schema stays `.optional()`).

---

### `apps/client/src/render/RoomRenderer.ts` — D-61 drop render block (edit-in-place)

**Analog:** self — lines 273-311 (`if (layout.wall_border) { ... }` block).

**Action:** delete the entire `if (layout.wall_border)` block (lines 273-311). The walkable-grid + D-62 broadcast supersede it. Comments at lines 270-272 explaining the legacy fallback also go.

**Optional follow-up:** the field `this.wallRects: Phaser.GameObjects.Rectangle[]` may become dead; planner reviews and removes if no other producer pushes to it.

---

### `packages/protocol/src/intents.ts` — D-62 (+ D-58c) schema (edit-in-place)

**Analog:** self — `newLayoutSchema` at lines 158-196.

**D-62 — newLayoutSchema additions** (insert before the closing `})`):
```typescript
// [impl->REQ-CLI-06] [impl->REQ-SRV-14]
// D-62: optional derived bounds. NOT authored in mvp-room/000.json; server
// derives at runtime via deriveLayoutBounds() and broadcasts via s2c.room_layout
// envelope fields (derived_collision_polys, derived_room_size below). Kept on
// the layout schema as optional for forward-compat with compile-time codification
// in Phase 7.
collision_polys: z.array(
  z.array(z.object({ x: z.number(), y: z.number() })),
).optional(),
room_size: z.object({
  w: z.number().int().positive(),
  h: z.number().int().positive(),
}).optional(),
```

**D-62 — extend `S2C` room_layout discriminated-union member:**
```typescript
// search for the room_layout member of the S2C union; add the two optional fields:
{
  type: 'room_layout',
  room_id: roomIdSchema,
  layout_rev: z.string(),
  layout_bytes: z.instanceof(Uint8Array),
  manifest_sig: z.instanceof(Uint8Array),
  // D-62 additions:
  derived_collision_polys: z.array(z.array(z.object({ x: z.number(), y: z.number() }))).optional(),
  derived_room_size: z.object({ w: z.number().int().positive(), h: z.number().int().positive() }).optional(),
}
```

**D-58c — PlayerState facing field** (if planner picks server-broadcast path per RESEARCH default): add `facing: z.enum(['D','DR','R','UR','U','UL','L','DL']).optional()` to the player snapshot schema.

**Pitfall 4:** `newLayoutSchema` is `z.object({...})` WITHOUT `.strict()` (verified by Read of intents.ts:158-196 — there is no `.strict()` call, unlike `legacyLayoutSchema` at line 153). Optional additions are wire-safe.

**PROTOCOL_VERSION:** NO bump. RESEARCH §Pitfall 2 + §Pattern 3 confirm wire-backward-compat for optional additions. Do NOT touch `packages/protocol/src/version.ts` or its two test literals (`state.test.ts:11-12`, `colyseus-client.test.ts:131,141`).

---

### `CLAUDE.md` — D-63 "Coordinate conventions" section (edit-in-place)

**Analog:** self — the "Extracted Constants" section (search for "load-bearing" near the top of CLAUDE.md).

**Insertion location:** immediately AFTER the "Extracted Constants" section, BEFORE "Hard Rules".

**Section content:**
```markdown
## Coordinate Conventions (D-63 — Plan 06.4)

The legacy GameMaker engine and our Phaser renderer use different sprite-origin
conventions. Player-attached effects (TeleIn, TeleOut, HexportIn/Out, ncol*,
jokershell, watching) must convert between them.

| Engine | Navi sprite origin | Effect placement |
|--------|---------------------|------------------|
| Legacy GM (5.3a) | `(0, 0)` = top-left (head) | effect drawn at legacy `(x, y)` = Navi top-left |
| Phaser (REBNO)   | `(0.5, 1)` = bottom-center (feet) | sprite.x = Navi center, sprite.y = visible feet line (NOT sprite-rect bottom) |

**Convention shift pin:**
- `legacy_x = phaser_x - NAVI_WIDTH_PX/2 = phaser_x - 18`
- `legacy_y = phaser_y - NAVI_VISIBLE_FEET_Y = phaser_y - 46`

`NAVI_VISIBLE_FEET_Y = 46` is the bbox-bottom of NaviStandD, NOT 48 (sprite-rect
height). The two-pixel difference (rows 46-47 in the sprite rect) is the
transparent foot-shadow region below the visible feet — including those rows
in the conversion places effects two pixels too low (operator UAT 2026-05-14).

**Canonical entry:** `apps/client/src/render/legacy-origin.ts` exports
`phaserOriginForLegacyPlayerAttached({legacyOriginX, legacyOriginY, width, height}) → [originX, originY]`.
All player-attached effect ports MUST use this helper. Inline origin math in
`apps/client/src/render/` is rejected by code review (HARD gate 5).

**Source of truth:** `extracted/client-5-8/sprites/0000-NaviStandD/meta.json`
(width=36, height=48, bboxBottom=46). The atlas-mvp.json frame metadata is
asserted against these constants by the unit test
`apps/client/src/__test__/legacy-origin.test.ts` (drift-detection — fails CI
if atlas regeneration produces different Navi metrics).
```

**Add a doc traceability tag at the section end:**
```markdown
[doc->REQ-CLI-04] [doc->REQ-CLI-08]
```

---

### `tooling/no-inline-origin.ts` OR pnpm script (NEW — Gate 5 helper-API audit)

**Analog:** no direct analog; closest is `pnpm trace:check` (`traceable-reqs trace`) at package level.

**Two implementation options:**

**Option A — pnpm script in root `package.json`** (cheapest, fits gate's "run before merge" cadence):
```json
"scripts": {
  "gate:no-inline-origin": "node -e \"const {execSync} = require('child_process'); try { const out = execSync('git grep -nE \\\"setOrigin\\\\(\\\\s*[0-9]+\\\\s*/\\\\s*[a-zA-Z_]+\\\\.(width|height)\\\" apps/client/src/render/', {encoding:'utf8'}); if (out.trim()) { console.error('FAIL: inline origin math found in render/:'); console.error(out); process.exit(1);} } catch(e) { if (e.status === 1) { console.log('OK: no inline origin math'); process.exit(0); } throw e; }\""
}
```

**Option B — `tooling/no-inline-origin.ts` runnable via `tsx`** (cleaner if planner wants typed test logic):
```typescript
#!/usr/bin/env tsx
// tooling/no-inline-origin.ts
// [impl->REQ-CLI-04] [impl->REQ-CLI-08]
// Plan 06.4 Gate 5 — reject inline origin math in apps/client/src/render/.
// RESEARCH §Code Examples "Helper-API audit grep".
//
// Rejected pattern: setOrigin(<digits> / <ident>.width|height, ...)
// Allowed pattern : phaserOriginForLegacyPlayerAttached(...)

import { execSync } from 'node:child_process';

const BAD_PATTERN =
  'setOrigin\\(\\s*[0-9]+\\s*/\\s*[a-zA-Z_]+\\.(width|height)';
try {
  const out = execSync(
    `git grep -nE "${BAD_PATTERN}" apps/client/src/render/`,
    { encoding: 'utf8' },
  );
  if (out.trim()) {
    console.error('FAIL: inline origin math found in render/:');
    console.error(out);
    process.exit(1);
  }
} catch (e: unknown) {
  // git grep exits 1 when there are NO matches — that's success.
  if ((e as { status?: number }).status === 1) {
    console.log('OK: no inline origin math');
    process.exit(0);
  }
  throw e;
}
```

**Recommendation:** Option A (single line in `package.json`). Add to the `gsd-verify-work` checklist.

---

## Test File Pattern Assignments

### `apps/client/src/__test__/legacy-origin.test.ts` (NEW — D-63 drift detection)

**Analog:** `apps/client/src/__test__/room-collision-bottom-edge.test.ts` — `describe`/`it` against a pure helper from `apps/client/src/render/`.

**Header banner:**
```typescript
// apps/client/src/__test__/legacy-origin.test.ts
// [unit->REQ-CLI-04] [unit->REQ-CLI-08]
// Plan 06.4 D-63 drift detection: if the atlas regenerates with different Navi
// metrics, fail this unit test loudly so the convention shift can be re-derived.
//
// SOURCE: extracted/client-5-8/sprites/0000-NaviStandD/meta.json — width=36,
// height=48, bboxBottom=46.
```

**Test body** (from RESEARCH §Code Examples):
```typescript
import { describe, it, expect } from 'vitest';
import {
  NAVI_WIDTH_PX,
  NAVI_VISIBLE_FEET_Y,
  phaserOriginForLegacyPlayerAttached,
} from '../render/legacy-origin.js';
import atlas from '../../public/atlas-mvp.json' assert { type: 'json' };
import naviMeta from '../../../../extracted/client-5-8/sprites/0000-NaviStandD/meta.json' assert { type: 'json' };

describe('legacy-origin D-63 drift detection', () => {
  it('NaviStandD atlas frame width === NAVI_WIDTH_PX (36)', () => {
    const frame = atlas.frames['0000-NaviStandD_000'].frame;
    expect(frame.w).toBe(NAVI_WIDTH_PX);
  });
  it('NaviStandD atlas frame height === 48 (sprite-rect)', () => {
    const frame = atlas.frames['0000-NaviStandD_000'].frame;
    expect(frame.h).toBe(48);
  });
  it('NaviStandD meta.bboxBottom === NAVI_VISIBLE_FEET_Y (46)', () => {
    expect(naviMeta.bboxBottom).toBe(NAVI_VISIBLE_FEET_Y);
  });
  it('phaserOriginForLegacyPlayerAttached agrees with TeleIn variant', () => {
    const [ox, oy] = phaserOriginForLegacyPlayerAttached({
      legacyOriginX: 15, legacyOriginY: 42, width: 64, height: 100,
    });
    expect(ox).toBeCloseTo(33 / 64, 5);
    expect(oy).toBeCloseTo(88 / 100, 5);
  });
});
```

---

### `apps/server/test/layout-derive.test.ts` (NEW — D-62 unit)

**Analog:** `apps/client/src/__test__/room-collision-bottom-edge.test.ts` (same pattern: pure helper + structural fixtures + perimeter-correctness asserts).

**Header banner:**
```typescript
// apps/server/test/layout-derive.test.ts
// [unit->REQ-SRV-14] [unit->REQ-CLI-06]
// Plan 06.4 D-62 — deriveLayoutBounds unit tests.
// SOURCE: apps/server/rooms/mvp-room/000.json — width_tiles=20, height_tiles=20,
//         tile_w=44, tile_h=40. Tile pitch is 44×40 per CLAUDE.md.
```

**Test body** (mvp-room fixture):
```typescript
import { describe, it, expect } from 'vitest';
import { deriveLayoutBounds } from '../src/layout-derive.js';

const MVP_ROOM_FIXTURE = {
  room_id: 'mvp-room',
  version: 1,
  width_tiles: 20,
  height_tiles: 20,
  tile_w: 44,
  tile_h: 40,
  viewport: { w: 640, h: 480, hBorder: 0, vBorder: 0 },
  tiles: [],
  spawn_points: [],
} as const;

describe('deriveLayoutBounds — mvp-room (uniform floor)', () => {
  it('room_size derived from width_tiles*tile_w × height_tiles*tile_h (880×800)', () => {
    const d = deriveLayoutBounds(MVP_ROOM_FIXTURE);
    expect(d.room_size).toEqual({ w: 880, h: 800 });
  });
  it('emits 4 outer-wall rectangle polys (top, bottom, left, right)', () => {
    const d = deriveLayoutBounds(MVP_ROOM_FIXTURE);
    expect(d.collision_polys).toHaveLength(4);
  });
  it('top wall poly spans full width at thickness=tile_h (40)', () => {
    const d = deriveLayoutBounds(MVP_ROOM_FIXTURE);
    const top = d.collision_polys[0];
    expect(top).toEqual([
      { x: 0, y: 0 }, { x: 880, y: 0 }, { x: 880, y: 40 }, { x: 0, y: 40 },
    ]);
  });
  // ... bottom, left, right ...
});
```

---

### `apps/server/test/d51c-eviction-trace.integ.test.ts` (NEW — D-51c spike test)

**Analog:** `apps/server/test/rebno-room-replace-session.integ.test.ts` — already exercises the dup-login eviction path with `dev-bypass:<account_id>` deterministic accounts and s2c message capture.

**Header banner:**
```typescript
// apps/server/test/d51c-eviction-trace.integ.test.ts
// [int->REQ-SRV-03] [int->REQ-CLI-08]
// Plan 06.4 D-51c — spike trace test. Asserts that during the dup-login
// eviction window, the server emits the new d51c_eviction_iter pino events
// at each step (before_priorClient_leave, state_deleted, force_reset_sent,
// prior_left). Uses an in-process pino sink to capture log records.
//
// SOURCE: apps/server/test/rebno-room-replace-session.integ.test.ts — joinAs
// helper + dev-bypass account fixture pattern. Test-utils spawnServer pattern.
```

**Body shape:** copy joinAs helper from `rebno-room-replace-session.integ.test.ts:42-90` verbatim; add an in-process pino capture sink (the project's `apps/server/src/log.ts` exports the logger; tests inject a transport at boot). Assertions check for log records with `event: 'd51c_eviction_iter'`.

---

### `apps/server/test/d60-always-emit-layout.integ.test.ts` (NEW)

**Analog:** `apps/server/test/rebno-room-replace-session.integ.test.ts` (s2c capture pattern at lines 63-69).

**Test body summary:**
1. Join as `uat_a` (fresh session) → assert s2c message stream includes `{ type: 'room_layout', room_id: 'mvp-room', ... }` within 2 s.
2. Close the room cleanly.
3. Join as `uat_a` again (reconnect-after-leave path) → assert second join ALSO receives `room_layout` event.

Use the same `messages.push(decodeS2C(raw))` pattern as `rebno-room-replace-session.integ.test.ts:63-69`. Both `isReconnect` (line 396) and `fresh-session` (line 429) branches must emit.

---

### `apps/server/test/d62-derived-collision.integ.test.ts` (NEW)

**Analog:** `apps/server/test/rebno-room-replace-session.integ.test.ts` + `apps/client/test/e2e/cli-08-floor-collision.e2e.test.ts` (boundary expectation pattern at lines 66-77).

**Test body summary:**
1. spawnServer; joinAs `uat_a`.
2. Send `c2s.input` with `{ axis_y: 1 }` (down) for N ticks (enough to reach y > 800).
3. Read `state.players.get(sid).y` (via the room's broadcast `s2c` or via test-injected accessor) and assert it stops at the derived boundary (~ 760, depending on NAVI_MASK feet offset; copy the boundary math from `cli-08-floor-collision.e2e.test.ts:74-76`).

**Critical:** the test must run AFTER D-62 derive is wired into `onCreate`; if the registry-loaded mvp-room doesn't run derive, `step()` falls into the pre-fix no-op.

---

### `apps/server/test/d58c-direction-broadcast.integ.test.ts` (NEW)

**Analog:** `apps/server/test/rebno-room-replace-session.integ.test.ts` (s2c message capture + multiple JoinedClient pattern).

**Test body summary:**
1. joinAs `uat_a` AND `uat_b`.
2. uat_a sends `c2s.input` with axis_x=1 (right) for 5 ticks, then axis_x=0 (stop).
3. uat_b's s2c state-patch stream is observed; the PlayerState for uat_a must show `facing === 'R'` (or 'DR', depending on residual vy).
4. After 10 idle ticks, uat_a's `facing` must remain 'R' (NOT reset to 'D').

This is the structural lock-in for the server-broadcast facing fix.

---

### `apps/client/src/__test__/player-renderer.teleport.test.ts` (NEW — D-55c)

**Analog:** `apps/client/src/__test__/player-renderer-spawn-delay.test.ts` — same `vi.mock('phaser')` + Nameplate stub + fakeScene factory pattern.

**Test body summary:**
1. Reuse the fakeScene factory verbatim (lines 56-90 of player-renderer-spawn-delay.test.ts).
2. Construct PlayerRenderer; call `ensureLocal()` to create local player; call `playTeleportIn(state)` (or expose `startTeleportAnim` via a test helper).
3. Assert `scene.add.sprite(...).setOrigin(originX, originY)` was called with:
   - `originX ≈ 33/64 = 0.515625`
   - `originY ≈ 88/100 = 0.88`
4. (Optional) Negative assertion: `setOrigin` NOT called with the pre-fix `originX = 15/64 = 0.234`.

---

### `apps/client/src/__test__/sprite-state-machine.teleport-gate.test.ts` (NEW — D-55d)

**Analog:** `apps/client/src/__test__/sprite-state-machine.test.ts` + `apps/client/src/__test__/player-renderer-spawn-delay.test.ts`.

**Test body summary:**
1. Construct GameScene with mocked PlayerRenderer (spy on `addRemote` calls).
2. Simulate initial state-sync: emit `onRemoteAdd` for 2 pre-existing remote players BEFORE the microtask flip.
3. Assert `playerRenderer.addRemote` was called with `{ playTeleportIn: false }` for both.
4. `await new Promise(r => queueMicrotask(r));` to flip `inInitialSnapshot`.
5. Emit a third `onRemoteAdd` for a fresh-joining player.
6. Assert `playerRenderer.addRemote` was called with `{ playTeleportIn: true }` for the third.

**Tests RESEARCH Assumption A1** — if Colyseus 0.17 doesn't deliver initial onAdd synchronously, this test fails fast.

---

### `apps/client/src/__test__/nameplate-stability.test.ts` (NEW — D-45d)

**Analog:** `apps/client/src/__test__/nameplate.test.ts` (Phaser stub pattern at lines 19-26) + the lerp-loop logic.

**Test body summary:**
1. Stub Phaser Text and Sprite.
2. Construct PlayerRenderer with a single remote entry.
3. Call `onSimulationTickRemote(sid, vx, vy, targetX, targetY)` 60 times with `targetY` oscillating ±0.5 px (or with a slow-monotonic motion path).
4. Capture `firstRemoteNameplateHistory[].nameplate_y` and assert the integer-Y delta count across the 60 samples is ≤ N (the bound the operator agrees on; ≤ 5 flips per second of motion is a reasonable acceptance ceiling).
5. Post-fix (integer-snap on smoothedX/smoothedY): assert NO flips during constant-vy motion.

---

### `apps/client/src/__test__/reconciler.test.ts` (MODIFIED — D-57b + D-58b regression cases)

**Analog:** self — extend existing `describe('ReconcileEngine', ...)` block at line 52.

**D-57b regression case** (add new `it` block):
```typescript
it('D-57b regression: tweenTo adapter is reachable; lerp-branch is exercised under threshold', () => {
  const m = mockAdapter(100, 100);
  const eng = new ReconcileEngine(prediction, m.adapter, () => FLAT_LAYOUT);
  // Divergence of 10 px < 22 → lerp branch.
  eng.onServerSnapshot({ x: 110, y: 100, vx: 0, vy: 0, last_input_seq: 0 });
  // SOURCE: cycle-2 D-57b — tweenTo MUST be called on lerp branch.
  // The post-06.3 fix replaced the no-op stub at GameScene.ts:511; this test
  // pins the contract so a future regression to the stub fails fast.
  expect(m.tweenTo).toHaveBeenCalledTimes(1);
});
```

**D-58b regression case** (add new `it` block):
```typescript
it('D-58b regression: idle (vx=0, vy=0) snapshot converges sprite position to server within 1 call', () => {
  const m = mockAdapter(150, 150);
  const eng = new ReconcileEngine(prediction, m.adapter, () => FLAT_LAYOUT);
  // Local sprite is at (150, 150); server says (100, 100) with vx=vy=0.
  // Divergence is 50√2 ≈ 70.7 px > 22 → hard snap branch.
  eng.onServerSnapshot({ x: 100, y: 100, vx: 0, vy: 0, last_input_seq: 0 });
  // SOURCE: cycle-2 D-58b — pre-fix the idle-state path skipped reconciliation
  // when vx===0 && vy===0, leaving position desynced. Post-fix: hard snap fires.
  expect(m.setPosition).toHaveBeenCalledTimes(1);
  const [sx, sy] = m.setPosition.mock.calls[0]!;
  expect(sx).toBeCloseTo(100, 5);
  expect(sy).toBeCloseTo(100, 5);
});
```

---

### `apps/client/src/__test__/room-renderer.test.ts` (NEW — D-61 wall_border drop assertion)

**Analog:** `apps/client/src/__test__/background-renderer.test.ts` (closest sibling — render-init test with Phaser stub).

**Test body summary:**
1. Stub Phaser Scene + `scene.add.rectangle`.
2. Construct RoomRenderer; call `renderNew(mvpRoomLayoutFixture)` where the fixture has NO `wall_border` key (post-D-61 drop).
3. Assert `scene.add.rectangle` is NOT called for wall-border edges.
4. (Optional) Second case: layout WITH `wall_border` (legacy mvp-lobby shape). Pre-D-61 drop this would render 4 rects; post-drop the block is removed entirely, so this case also produces 0 rects. Update planner: this second case may become a Phase 7 regression test if mvp-lobby still uses wall_border there.

---

### `apps/client/test/e2e/cli-08-resume-render.spec.ts` — extend `cookie-reload.e2e.test.ts` (D-60)

**Analog:** `apps/client/test/e2e/cookie-reload.e2e.test.ts` (already does reload+game-ready; just needs a `tilesOut > 0` assertion).

**Add to the existing test body after line 36 `await expect(page.locator('canvas[data-game-ready="true"]')).toBeVisible({ timeout: 5_000 });`:**

```typescript
// [int->REQ-CLI-06] [int->REQ-CLI-08]
// D-60: cookie-resume onJoin MUST emit room_layout. Pre-fix the floor tiles
// only rendered after explicit logout-and-fresh-login.
// SOURCE: Plan 06.4 D-60 decision — sendRoomLayoutToClient(client) unconditional in onJoin.
await expect
  .poll(
    async () => page.evaluate(
      () => (window as unknown as { __rebno?: { tilesOut?: number } }).__rebno?.tilesOut ?? 0,
    ),
    { message: 'tilesOut must be > 0 on cookie-resume', timeout: 10_000 },
  )
  .toBeGreaterThan(0);
```

(Per RESEARCH §Pitfall 6, the existing `cookie-reload.e2e.test.ts` does NOT assert `tilesOut`; planner extends the existing file.)

---

### `apps/client/test/e2e/cli-08-collision-boundary.spec.ts` (NEW — D-62)

**Analog:** `apps/client/test/e2e/cli-08-floor-collision.e2e.test.ts` (boundary expectation pattern at lines 66-99 already exists; the new test asserts the boundary holds AFTER D-62 server-runtime derivation lands).

**Test body summary:** Copy `cli-08-floor-collision.e2e.test.ts` verbatim. Update the SOURCE comment to cite "D-62 derived collision_polys: server-runtime derivation; post-fix expected boundary unchanged at y < 800 / x < 880 etc." The test passes pre- AND post-fix; the value lies in pinning the boundary across the derive-helper refactor.

---

### `apps/client/test/e2e/cli-08-dup-login.e2e.test.ts` — extend (D-51c)

**Analog:** self.

**Add new assertion after line 39:**
```typescript
// [int->REQ-SRV-03] [int->REQ-CLI-08]
// D-51c: no looping "reconnected" banner on the EVICTED tab. UAT 2026-05-15
// captured both tabs freeze + looping banner ~5s cadence on staging 91f954e.
// Post-fix: pageA1 reaches LoginScene (asserted above) and STAYS there.
// Verify no reconnect-banner DOM element re-shows within 15s.
await page.waitForTimeout(15_000);
const bannerCount = await pageA1.evaluate(
  () => document.querySelectorAll('[data-reconnect-banner]').length,
);
expect(bannerCount).toBe(0);
```

(Planner: confirms the reconnect-banner element selector; `[data-reconnect-banner]` is a placeholder — verify in `apps/client/src/ui/` or `apps/client/src/scenes/`.)

---

### `apps/client/test/e2e/cli-08-direction-convergence.spec.ts` (NEW — D-58c)

**Analog:** `apps/client/test/e2e/cli-08-nameplate.e2e.test.ts` — two-context pattern at lines 30-33 (ctxA, ctxB, accountA, accountB, both reach GameScene).

**Test body summary:**
1. Two contexts (ctxA, ctxB), both login + waitForGameReady.
2. From pageA, press KeyD for 1s (walk right). Release.
3. Wait 1s (idle on stop).
4. From pageB perspective, read `window.__rebno.firstRemoteDirection` (the spike telemetry from `publishDirectionTelemetry` shipped under D-58c spike).
5. Assert `firstRemoteDirection === 'R'` (matches A's local facing after walk-right + stop).
6. From pageA, read `window.__rebno.localDirection` — assert also `'R'`. Both viewers agree.

---

### `apps/client/test/e2e/cli-08-snapshot-no-spawn-anim.spec.ts` (NEW — D-55d)

**Analog:** `cli-08-nameplate.e2e.test.ts` (two-context) + `cli-08-anim.e2e.test.ts` (frame-sample telemetry).

**Test body summary:**
1. ctxA: log in as accountA, reach GameScene. accountA stands idle.
2. ctxB: log in as accountB, reach GameScene. accountB is the NEW joining client.
3. From pageB perspective, sample `window.__rebno.firstRemoteFrame` (or equivalent) every 50ms for 1s.
4. Assert NO `TeleIn` frame keys observed for the pre-existing accountA — only `0000-NaviStandD_000` (or whatever standing frame).
5. NEGATIVE: from pageA perspective, B's onRemoteAdd happens AFTER A's initial state-sync — so B's TeleIn DOES fire on A's screen. Assert that case in a second `it` block.

---

### `apps/client/test/e2e/cli-08-nameplate-stable.spec.ts` (NEW — D-45d)

**Analog:** `cli-08-nameplate.e2e.test.ts` + `cli-08-nameplate-offset.e2e.test.ts`.

**Test body summary:**
1. Two-context: ctxA + ctxB both reach GameScene.
2. From pageA: press KeyD continuously for 30s.
3. From pageB perspective, poll `window.__rebno.firstRemoteNameplateHistory` over the 30s window.
4. Compute integer-Y flips across the captured history.
5. Assert flips per second ≤ N (operator-set ceiling; HARD gate 3 wants the SOURCE cite). Per RESEARCH §Code Examples, expect post-fix flips ≈ 0 during constant-velocity motion.

---

## Shared Patterns

### `window.__rebno` unconditional publish

**Source:** `apps/client/src/prediction/reconciler.ts` lines 68-98 + `apps/client/src/render/PlayerRenderer.ts:375-393` (firstRemoteNameplateHistory ring buffer) + `apps/client/src/render/RoomRenderer.ts:254-268` (window pattern).

**Apply to:** ALL new diagnostic surfaces (D-51c `lastEvictionEvents`, D-58c `localDirection` / `firstRemoteDirection`, D-45d extended `firstRemoteNameplateHistory` fields).

```typescript
// Pattern A — globalThis (browser + worker):
if (typeof globalThis !== 'undefined') {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const g = globalThis as any;
  g.__rebno = { ...(g.__rebno ?? {}), fieldName: value };
}
// Pattern B — window (browser-only):
if (typeof window !== 'undefined') {
  (window as unknown as Record<string, unknown>).__rebno = {
    ...((window as unknown as Record<string, Record<string, unknown>>).__rebno ?? {}),
    fieldName: value,
  };
}
// RULE (CARRY FROM 06.3): NO env gate. RC1 commit cd47745 proved staging silences gated hooks.
```

### Server-side pino structured-event log

**Source:** `apps/server/src/RebnoRoom.ts` lines 494-503, 535-579 (existing `d51_eviction` events).

**Apply to:** D-51c (`d51c_eviction_iter`), D-58c (`d58c_player_axes`), D-60 (`d60_layout_emit` if added). All events follow the shape `{ event: 'name', step: 'phase', ...details, ts: Date.now() }, 'human-readable message'`.

```typescript
log.info(
  {
    event: 'd51c_eviction_iter',  // event family + cycle marker
    step: 'before_priorClient_leave',
    iteration: i,
    priorSessionId,
    elapsedMs: Date.now() - evictionStartMs,
    ts: Date.now(),
  },
  'D-51c eviction iter pre-leave',
);
```

### vitest mock-Phaser pattern for render tests

**Source:** `apps/client/src/__test__/nameplate.test.ts` lines 19-26 + `apps/client/src/__test__/player-renderer-spawn-delay.test.ts` lines 23-90.

**Apply to:** D-55c (`player-renderer.teleport.test.ts`), D-45d (`nameplate-stability.test.ts`), D-55d (`sprite-state-machine.teleport-gate.test.ts`), D-61 (`room-renderer.test.ts`).

```typescript
import { describe, it, expect, vi } from 'vitest';

vi.mock('phaser', () => ({
  default: {
    Scene: class {},
    GameObjects: { Text: class {}, Sprite: class {}, Rectangle: class {} },
    Scenes: { Events: { SHUTDOWN: 'shutdown' } },
  },
}));

vi.mock('../render/Nameplate.js', () => ({
  Nameplate: class {
    follow = vi.fn();
    dispose = vi.fn();
    destroy = vi.fn();
  },
}));

import { PlayerRenderer } from '../render/PlayerRenderer.js';
```

### Server integ-test joinAs / dev-bypass account fixture

**Source:** `apps/server/test/rebno-room-replace-session.integ.test.ts` lines 42-90 (joinAs helper); `apps/server/test/rebno-room-onleave.integ.test.ts` lines 49-68 (joinClient minimal variant).

**Apply to:** D-51c, D-58c, D-60, D-62 integ tests.

```typescript
async function joinAs(s: BootedServer, account_id: string): Promise<JoinedClient> {
  const { Client } = await import('@colyseus/sdk');
  const client = new Client(`ws://127.0.0.1:${s.port}`);
  const room = await client.joinOrCreate<unknown>('rebno', {
    protocol_version: PROTOCOL_VERSION,
    session_token: `dev-bypass:${account_id}`,
  });
  const messages: S2C[] = [];
  let leaveCode: number | undefined;
  room.onMessage('s2c', (raw: Uint8Array) => { try { messages.push(decodeS2C(raw)); } catch {} });
  room.onLeave((code: number) => { leaveCode = code; });
  return { room, sessionId: room.sessionId, messages, get leaveCode() { return leaveCode; }, close: async (consented = true) => { try { await room.leave(consented); } catch {} } };
}
```

### Playwright two-context fixture for dup-login / remote-presence tests

**Source:** `apps/client/test/e2e/cli-08-dup-login.e2e.test.ts:16-43` (same account, two contexts) + `apps/client/test/e2e/cli-08-nameplate.e2e.test.ts:30-33` (accountA + accountB, two contexts).

**Apply to:** D-58c direction-convergence, D-45d nameplate-stable, D-55d snapshot-no-spawn-anim, D-51c extension.

```typescript
test('...', async ({ browser, accountA, accountB, inviteSuffix }) => {
  const ctxA = await browser.newContext();
  const ctxB = await browser.newContext();
  const a = await ctxA.newPage();
  const b = await ctxB.newPage();
  try {
    await loginAs(a, accountA, inviteSuffix);
    await loginAs(b, accountB, inviteSuffix);
    await waitForGameReady(a);
    await waitForGameReady(b);
    // ... assertions on a + b __rebno surfaces
  } finally {
    await ctxA.close();
    await ctxB.close();
  }
});
```

### Playwright canonical-ref cite per assertion (HARD gate 3)

**Source:** RESEARCH §user_constraints HARD gate 3; example pattern in `cli-08-floor-collision.e2e.test.ts:72-76`.

**Apply to:** EVERY new Playwright assertion in 06.4.

```typescript
// SOURCE: tile_h=40, height_tiles=20 -> room_height=800. NAVI_MASK.bottom=-2
// blocks before row 19. CLAUDE.md "Extracted Constants".
expect(value).toBeLessThan(800);
```

### Atomic per-plan commit + REQ-ID tag pattern

**Source:** CLAUDE.md "Traceability tag contract" + 06.3 plan-commit shape.

**Apply to:** All new code/docs/tests in 06.4. Tags live in comments (TS/MD) and reference the REQ IDs from `<traceability>` (REQ-CLI-04, REQ-CLI-06, REQ-CLI-07, REQ-CLI-08, REQ-SRV-03, REQ-SRV-14).

```typescript
// In TypeScript: top of file or above the relevant block
// [impl->REQ-SRV-03] [impl->REQ-SRV-14] [impl->REQ-CLI-06]

// In tests:
// [unit->REQ-SRV-14] [unit->REQ-CLI-06]
// [int->REQ-SRV-03] [int->REQ-CLI-08]
```

```markdown
<!-- In markdown: any line -->
[doc->REQ-CLI-04] [doc->REQ-CLI-08]
```

---

## No Analog Found

| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `tooling/no-inline-origin.ts` (or pnpm script) | config | — | No existing helper-API-audit gate in the repo. The pattern is a grep-based CI guard; closest sibling is `pnpm trace:check` (`traceable-reqs trace`). Planner implements from RESEARCH §Code Examples "Helper-API audit grep" verbatim. |

---

## Key Observations For Planner

1. **Directory corrections (CRITICAL):** The prompt suggested `apps/server/src/__test__/` and `apps/client/src/render/__test__/`. Those do NOT exist. Server tests → `apps/server/test/*.test.ts`. Client render-unit tests → `apps/client/src/__test__/*.test.ts`. Confirmed via two `Glob` calls.

2. **TeleIn variant height = 100, NOT 64.** PlayerRenderer.ts:570-571 directly verified. The CONTEXT scope-hint of `height=64` produces the wrong expected acceptance values. Use 100 in all D-55c + D-63 tests (RESEARCH Assumption A4 confirmed by direct read).

3. **`Math.round` is already in `Nameplate.follow`** (line 125). The D-45d residual fix lives in **`PlayerRenderer.ts:357-360` (smoothedX/smoothedY pre-round)**, NOT in Nameplate. RESEARCH Assumption A3 confirmed pending spike capture.

4. **`window.__rebno.firstRemoteNameplateHistory` ring buffer is already unconditional** (PlayerRenderer.ts:375-393). D-45d spike does NOT need to "republish the buffer"; it needs to read the buffer on a FRESH staging UAT motion capture.

5. **D-58c default = server-broadcast facing.** RESEARCH §Don't Hand-Roll mandates this per REQ-SRV-03; the alternative (client-side derive-identically) is fragile. Plan defaults to schema extension + `PlayerState.facing` field; operator can override at plan-check.

6. **D-62 broadcast encoding: separate envelope fields, NOT re-pack into `layout_bytes`.** Re-pack would invalidate `manifest_sig`. RESEARCH §Open Question 1 spec'd `derived_collision_polys` + `derived_room_size` on the `s2c.room_layout` envelope.

7. **PROTOCOL_VERSION NO bump** for D-62 (optional schema additions are wire-backward-compat) AND no bump for D-58c if the new `facing` field is `@type("string")` with default `'D'` (Colyseus 0.17 schema-tolerates unknown-field decode). Per Pitfall 2, planner MUST verify by running `pnpm --filter @rebno/server test:integ` after schema add; if a bump IS required, update `packages/protocol/test/state.test.ts:11-12` AND `apps/client/src/__test__/colyseus-client.test.ts:131,141`.

8. **D-55d gating predicate: `inInitialSnapshot` flag flipped via `queueMicrotask`** (NOT `Date.now()`). RESEARCH §Pitfall 3 explicit reject.

9. **D-60 fresh-session branch missing call:** `RebnoRoom.ts:396-426` reconnect branch ALREADY calls `sendRoomLayoutToClient(client)` at line 418. The fresh branch starting at line 429 does NOT. D-60 fix is a single-line addition in the fresh branch (after `this.state.players.set(...)` at line 455 and after the eviction block at line 481+).

10. **D-61 RoomRenderer drop is 38 lines** (lines 273-311 of RoomRenderer.ts — entire `if (layout.wall_border)` block including comments). `this.wallRects` array becomes dead; planner reviews for full cleanup.

11. **D-63 helper signature locked:** object-in, tuple-out. The tuple destructures cleanly into `setOrigin(ox, oy)`. Do NOT change to single-object return or named-tuple — operator confirmed the destructure pattern at discuss-phase.

12. **Gate 5 grep**: implementing as a `package.json` script is simplest; the script runs via `pnpm gate:no-inline-origin` and exit-codes correctly. Add to the `/gsd-verify-work` checklist for 06.4.

---

## Metadata

**Analog search scope:** `apps/client/src/render/`, `apps/client/src/scenes/`, `apps/client/src/__test__/`, `apps/client/test/e2e/`, `apps/server/src/`, `apps/server/test/`, `apps/server/rooms/`, `packages/protocol/src/`, `packages/game-logic/src/`, `extracted/client-5-8/sprites/0000-NaviStandD/`.

**Files read for pattern extraction:** 22 (PlayerRenderer.ts × 3 ranges, Nameplate.ts, RoomCollision.ts, RoomRenderer.ts, SpriteStateMachine.ts, GameScene.ts × 3 ranges, RebnoRoom.ts × 5 ranges, RoomRegistry.ts, intents.ts, reconciler.test.ts, nameplate.test.ts, player-renderer-spawn-delay.test.ts, room-collision-bottom-edge.test.ts, cookie-reload.e2e.test.ts, cli-08-dup-login.e2e.test.ts, cli-08-floor-collision.e2e.test.ts, cli-08-anim.e2e.test.ts, cli-08-nameplate.e2e.test.ts, rebno-room-replace-session.integ.test.ts, rebno-room-onleave.integ.test.ts).

**Pattern extraction date:** 2026-05-15
