---
phase: 06.2-gap-closure-d-50-d-53-uat-2026-05-13
plan: "05"
subsystem: server-room-join + client-e2e
tags: [fix, D-40, room-renderer, server, e2e, H1]
dependency_graph:
  requires: ["06.2-01"]
  provides: ["D-40-fix", "cli-08-floor-collision-gate"]
  affects: ["apps/server/src/RebnoRoom.ts", "apps/client/test/e2e/cli-08-floor-collision.e2e.test.ts"]
tech_stack:
  added: []
  patterns: ["unicast-on-join — server sends room_layout directly to joining client, not just on hot-reload"]
key_files:
  created:
    - apps/client/test/e2e/cli-08-floor-collision.e2e.test.ts
  modified:
    - apps/server/src/RebnoRoom.ts
decisions:
  - "D-40 root cause H1 confirmed: broadcastRoomLayout() only fired on RoomRegistry.onChange (hot-reload); freshly joined clients never received s2c.room_layout — RoomRenderer.renderNew never called, tilesOut=0 on staging"
  - "Fix: add sendRoomLayoutToClient(client) called from both fresh-join and grace-reconnect paths in onJoin"
  - "06.2-01 diagnostic block in RoomRenderer.ts retained — provides continuous staging regression gate (tilesIn/tilesOut/atlasHasTile1/atlasHasTside1 on window.__rebno)"
  - "e2e test placed at apps/client/test/e2e/ (not apps/client/e2e/ as in plan frontmatter — Glob confirmed correct project path)"
metrics:
  duration: "~25 min"
  completed: "2026-05-13"
  tasks_completed: 2
  files_modified: 1
  files_created: 1
---

# Phase 06.2 Plan 05: D-40 Floor Tiles Fix (H1) Summary

**One-liner:** Server unicast of room_layout on onJoin so RoomRenderer.renderNew fires for every joining client — fixes zero-tile staging render.

## Branch Chosen: H1 — Server Payload Never Delivered to Joining Client

### Hypothesis Selection Basis

The `<phase_context>` provided operator-confirmed evidence: `window.__rebno` snapshot taken with player active at `localPlayerX=188, localPlayerY=708` (moved from spawn 440,400 — scene fully alive) showed NONE of the five diagnostic fields (`tilesIn`, `tilesOut`, `atlasHasTile1`, `atlasHasTside1`, `roomLayoutTilesetIds`) that Plan 06.2-01 writes at the END of every `RoomRenderer.renderNew` call (unconditional, no env gate). This proved `renderNew` NEVER FIRED.

**Observed roomLayoutTilesetIds:** Empty (field absent from snapshot) — `tilesIn=0` confirms H1 (server never sent the layout to the client).

### Root Cause (exact locus)

`apps/server/src/RebnoRoom.ts` — `broadcastRoomLayout()` was the ONLY delivery path for `s2c.room_layout`. It fires only on `RoomRegistry.onChange` (file-system hot-reload events). A fresh client joining the room would join and wait forever for a `room_layout` event that only fired on next server-side file change.

**Payload before fix:** Client connected → joined room → `s2c.room_layout` never sent → `RoomRenderer.renderNew` never called → `tilesOut = 0` → no floor tiles, no TSide1 sprites.

**Payload after fix:** Client connected → joined room → `sendRoomLayoutToClient(client)` called in `onJoin` → `s2c.room_layout` delivered with `layout_bytes` (msgpackr-packed `Layout` from `RoomRegistry`) → `RoomRenderer.renderNew` fires → `tilesOut ≥ 400` (20×20 grid) → floor tiles + TSide1 sprites render.

## Files Modified

### apps/server/src/RebnoRoom.ts

Added `sendRoomLayoutToClient(client: Client): void` private method (lines ~196-221):
- Looks up `MVP_ROOM_ID` in `this.registry`
- Sends `S2C { type: 'room_layout', room_id, layout_rev, layout_bytes, manifest_sig }` via `client.send('s2c', encodeS2C(evt))`
- Logs warn on registry miss (graceful fallback — game remains playable but tiles absent)

Called from `onJoin` on BOTH code paths:
1. Fresh session path — after `sendChatHistoryBurst`, before `force_password_change` send
2. Grace-window reconnect path — after `sendChatHistoryBurst`, before `force_password_change` send (reconnect may have lost layout during grace window or layout hot-reloaded)

### apps/client/test/e2e/cli-08-floor-collision.e2e.test.ts (new)

Single test `'floor tiles render and block southern fall-through'`:
1. Login as `accountA` + wait `data-game-ready`
2. Poll `window.__rebno.tilesOut > 0` (direct D-40 render gate, timeout 15 s)
3. Record `spawnY` from `window.__rebno.localPlayerY`
4. Hold `KeyS` for 3 s
5. Assert `finalY > spawnY` (sprite moved south)
6. Assert `finalY < 850` (room height 800 px + 50 px tolerance — catches fall-through)

Tags: `// [int->REQ-CLI-06] [int->REQ-CLI-08]`

**Path note:** Plan frontmatter listed `apps/client/e2e/cli-08-floor-collision.spec.ts` but Glob confirmed the correct project path is `apps/client/test/e2e/*.e2e.test.ts`. Test placed at the correct location.

## Typecheck Results

- `pnpm --filter @rebno/server typecheck`: 283 pre-existing errors in `scripts/` and `test/` directories (missing node_modules in CI-isolated worktree); **zero new errors in `src/`** — confirmed by stash comparison.
- `pnpm --filter @rebno/client typecheck` (run from main repo): exits 0.

## 06.2-01 Diagnostic Block Retention Decision

The 06.2-01 diagnostic block in `RoomRenderer.ts:206-239` (`tilesIn`, `tilesOut`, `atlasHasTile1`, `atlasHasTside1`, `roomLayoutTilesetIds` on `window.__rebno`) is **retained** as a continuous staging gate. Rationale:

- Unconditional write (no env/MODE gate per RC1 fix) — always available in staging DevTools
- The new e2e test directly polls `tilesOut` — the diagnostic block is its data source
- Removing it would break the e2e assertion and lose the disambiguation capability for any future D-40 regression cycle

## Commits

| Task | Name | Commit | Files |
|------|------|--------|-------|
| 2 | Server H1 fix — sendRoomLayoutToClient on onJoin | `02ad290` | `apps/server/src/RebnoRoom.ts` |
| 3 | e2e regression gate | `2399320` | `apps/client/test/e2e/cli-08-floor-collision.e2e.test.ts` |

## Deviations from Plan

### Path deviation (plan frontmatter vs actual project layout)

- **Found during:** Task 3
- **Issue:** Plan frontmatter listed `apps/client/e2e/cli-08-floor-collision.spec.ts` as the target path. `apps/client/e2e/` does not exist; existing e2e tests live at `apps/client/test/e2e/*.e2e.test.ts`.
- **Fix:** Created file at correct path `apps/client/test/e2e/cli-08-floor-collision.e2e.test.ts` to match the existing project convention.
- **Rule:** Rule 3 (auto-fix blocking issue — wrong path would prevent test discovery).

## Known Stubs

None. The `sendRoomLayoutToClient` log.warn on registry miss is a graceful fallback, not a stub — the warning is operator-visible and the game is still playable (player spawns, moves, chats; only floor tiles are absent if registry is empty).

## Threat Flags

None. No new network endpoints, auth paths, or trust-boundary changes introduced. `sendRoomLayoutToClient` sends the same `layout_bytes` + `manifest_sig` payload that `broadcastRoomLayout` already sends — no new surface area.

## Self-Check: PASSED

| Item | Result |
|------|--------|
| `apps/server/src/RebnoRoom.ts` exists | FOUND |
| `apps/client/test/e2e/cli-08-floor-collision.e2e.test.ts` exists | FOUND |
| `06.2-05-SUMMARY.md` exists | FOUND |
| commit `02ad290` exists | FOUND |
| commit `2399320` exists | FOUND |
