# Phase 03 — Gameplay Handlers + Render ## Context links - Plan overview: `./plan.md` - Foundation pieces: `./phase-01-foundation.md` - AI client: `./phase-02-ai-client.md` - Handler pattern reference: `src/modules/doantu/handlers.js` - Render reference: `src/modules/doantu/render.js` - Subject resolution + grammY ctx: `src/modules/loldle/handlers.js` ## Overview - **Priority:** P1 - **Status:** planned - **Description:** Wire all four commands (`/twentyq`, `/twentyq_giveup`, `/twentyq_stats`, plus the implicit ask/guess flow via `/twentyq `) to the seeds + state + AI client. Build the renderer for board snapshots and per-turn replies. Manage round lifecycle: start → answer turns → solve/giveup. ## Key insights - grammY `ctx.match` holds the slash-command argument string (everything after `/twentyq`). Empty `ctx.match` → board view OR start fresh round. - Subject = user id in DMs (`ctx.chat.type === "private"`), chat id otherwise. Mirror `doantu/handlers.js` resolver. - Auto-start rule: `/twentyq` with no args AND no active game → start a round. With args → submit input (start a round first if none). - After a `solved` round: next `/twentyq` (any form) clears + starts fresh. - Use Telegram HTML mode for output (matches loldle/doantu). ## Requirements ### Functional - `/twentyq` (no args) — show board if active, else start a round and show intro line + initial hint. - `/twentyq ` — validate input → if invalid, reply with rephrase hint (no state mutation, no AI call). If valid, call `judge`, append turn, reply with `yes/no + hint`. If `is_guess && answer==="yes"`, mark solved, record stats, reveal secret, congratulate. - `/twentyq_giveup` — if active round, reveal secret + record loss; clear game key. Idempotent if no active round (replies "no active round"). - `/twentyq_stats` — render `{played, solved, totalTurns, bestTurnCount}`. - Repeat-question detection: simple lowercased exact-text dedup against prior turns. If repeat → reply `🔁 already asked` and skip AI call (no count). ### Non-functional - Each handler ≤80 LOC. - HTML escape all user-rendered text via existing `src/util/escape-html.js`. - Surface `UpstreamError` as a friendly "AI service hiccup, try again" reply. - Each file ≤200 LOC. ## Architecture ``` src/modules/twentyq/ ├── handlers.js # handleTwentyq, handleGiveup, handleStats — the entry points ├── render.js # formatBoard, formatTurnReply, formatGiveup, formatStats, formatIntro └── index.js # full module export with all four commands wired ``` ``` ctx ──► handleTwentyq ──► loadGame │ │ ├── empty arg ─┴── present? show board : start round (intro) │ └── arg present ─► validateQuestion → judge → save turn → reply ``` ## Related code files ### Create - `src/modules/twentyq/handlers.js` - `src/modules/twentyq/render.js` ### Edit - `src/modules/twentyq/index.js` — replace phase-1 stub with real commands array. ## Implementation steps 1. Create `src/modules/twentyq/render.js`: - `formatIntro(state)` → `"🎯 I'm thinking of a .\nHint: "`. - `formatTurnReply({ answer, hint, isGuess, solved, target, turnCount })`: - solve win → `"🎉 Correct! It was {target}. Solved in {turnCount} questions."` - guess miss → `"❌ No. Hint: {hint}"` - regular yes → `"✅ Yes. Hint: {hint}"` - regular no → `"❌ No. Hint: {hint}"` - `formatBoard(state)` — initial hint + numbered list of past Q/A in `
`.
   - `formatGiveup(state)` — `"🏳️ Gave up. The answer was {target}."`
   - `formatStats(stats)` — terse multi-line summary.
   - All target/text values HTML-escaped.
2. Create `src/modules/twentyq/handlers.js`:
   - `resolveSubject(ctx)` — same as doantu (private → user id, else chat id).
   - `handleTwentyq(ctx, { db, env })`:
     - Subject = resolveSubject.
     - `state = await loadGame(db, subject)`.
     - If state and `state.solved` → clearGame + treat as no game.
     - If no state and no `ctx.match` → start a round (call `getRandomSeed`,
       build state, save, reply `formatIntro`).
     - If no state and `ctx.match` → start round THEN process input as turn.
     - If state and no `ctx.match` → reply `formatBoard(state)`.
     - If state and `ctx.match` → process turn (see below).
   - Process-turn block:
     - `validateQuestion(text)` → on fail reply with reason.
     - Repeat-text check against `state.turns[].text` (lowercased) → reply
       `🔁 already asked`.
     - `await judge(env, state, text)`. Catch `UpstreamError` → friendly reply.
     - Append turn to `state.turns`. If `result.is_guess && result.answer === "yes"`:
       set `state.solved = true`; recordResult({solved:true, turnCount: turns.length}); clearGame.
     - Else save updated state.
     - Reply with `formatTurnReply(...)`.
   - `handleGiveup(ctx, { db })`:
     - Load game; if none → "no active round".
     - Else → recordResult({solved:false, turnCount}); reveal target;
       clearGame; reply `formatGiveup(state)`.
   - `handleStats(ctx, { db })` — load + render.
3. Replace `src/modules/twentyq/index.js`:
   - Mirror doantu shape: closure-scoped `db` set in `init`, plus `env` passed
     through to handlers (because we need `env.AI`). Two options:
       - **Option A (cleaner):** capture `env` in `init` alongside `db` and
         hand both to handlers.
       - **Option B:** pass `env` as `ctx.env` (grammY already exposes it via
         the worker handler binding). Confirm at impl time; if not exposed,
         use Option A.
   - Register 4 commands: `twentyq`, `twentyq_giveup`, `twentyq_stats`, plus a
     hidden alias if useful (skip for now per YAGNI).
4. Manual smoke test in `wrangler dev` (use ngrok / cloudflared tunnel + a
   throwaway test bot) — verify start, ask, guess-correct, giveup paths.

## Todo list

- [ ] `render.js` — all five formatters with HTML escape
- [ ] `handlers.js` — three handlers, subject resolver, repeat dedup
- [ ] `index.js` — full module export with `init({ db, env })` capture
- [ ] Confirm `env` propagation pattern (capture-in-init vs ctx.env)
- [ ] Manual smoke test happy path + giveup + repeat input
- [ ] `npm run lint` clean

## Success criteria

- Manual flow works end-to-end through Telegram.
- `is_guess && yes` ends round and records solve.
- `/twentyq_giveup` ends round, reveals, records loss.
- Repeat input does NOT increment turn count and does NOT call AI.
- Open-ended question ("what is it?") gets the validator's rephrase reply.
- KV state persists across cold starts (verified by waiting >30s between turns).

## Risk assessment

- **`env` propagation** — modules currently capture only `db` in `init`.
  Doantu/semantle capture `env` only enough to read URL config at init time,
  not for per-request AI calls. Need to capture the full `env` ref or change
  the dispatcher contract. **Decision: capture in `init` closure** — least
  invasive, no framework change.
- **Race condition on rapid double-send** — two near-simultaneous `/twentyq`
  questions could both load state, both write — last writer wins. Acceptable
  for v1 (KV is eventually consistent anyway; users notice nothing in normal
  pacing).
- **AI hint may leak the secret** despite system-prompt instructions.
  Mitigation: post-process hint to redact case-insensitive substring of
  `target`. Add as a defensive filter in `formatTurnReply` (cheap, ~3 lines).

## Security considerations

- All user-controlled strings (input text, target, hint) HTML-escaped before
  rendering.
- No KV keys derived from raw user text — only subject id + literal prefix.
- Secret-leak filter on hints (see Risk above).

## Next steps

→ Phase 04 — vitest coverage, README, help-command verification.