New Game's quick game gains an explicit opponent selector — 🤖 AI (default)
or 👤 Random player. AI starts a game seated with a pooled robot that joins
and moves at once: no per-move timeout (a 7-day inactivity loss reusing the
turn-timeout sweeper), chat/nudge disabled, no statistics, the opponent shown
as 🤖 everywhere. The random path (disguised robot) is unchanged.
Driven by one game flag (games.vs_ai), set only on AI-started games so the
disguised path is never revealed; Matchmaker.StartVsAI seats the robot
directly (no open pool); the robot replies event-driven via the game service's
after-commit/after-create hook. Wire: vs_ai on EnqueueRequest + GameView.
Opening a game from the lobby for the first time this session showed a brief
loading flash, and every open showed a two-step rack->board jump: the saved
draft (pending composition) was fetched separately and applied only after the
board had already painted the full rack.
Both stem from the full state and the draft not being available synchronously at
first paint. Cache the draft alongside view+history (CachedGame.draft), make
applyDraft take the already-fetched JSON so it runs synchronously, and fetch the
draft in the same Promise.all as state+history. setCachedGame preserves the
cached draft when the delta path omits it and clears it on a committed move
(mirroring the server). A new preload module warms the per-game cache (state,
history, draft) for the lobby's ongoing games with bounded concurrency, so
opening any of them is instant.
Tests: gamecache (preserve/clear/setCachedDraft) and preload (warm/skip) units;
existing draft-restore e2e still green.