From 08ff72985a4c187b4092d208b545e11985b70d4a Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 22 Apr 2026 22:05:27 +0700 Subject: [PATCH] feat(semantle): add word2vec guessing game module Telegram commands /semantle, /semantle_new, /semantle_giveup, /semantle_stats. Round starts with /random pick from hosted word2sim; each guess scored via /similarity. Unlimited guesses; solve on case-insensitive exact match. New env var WORD2SIM_API_URL (wrangler.toml, .env.deploy). Includes module README and 90 unit tests covering api-client, state, format, render, and handlers. --- .dev.vars.example | 4 + .env.deploy.example | 2 +- .../phase-01-foundation.md | 115 ++++ .../phase-02-gameplay.md | 140 +++++ .../phase-03-tests-docs.md | 113 ++++ plans/260422-2128-semantle-module/plan.md | 69 +++ ...de-reviewer-260422-2200-semantle-review.md | 177 ++++++ .../tester-260422-2155-semantle-tests.md | 210 +++++++ src/modules/index.js | 1 + src/modules/semantle/README.md | 80 +++ src/modules/semantle/api-client.js | 93 +++ src/modules/semantle/format.js | 29 + src/modules/semantle/handlers.js | 188 ++++++ src/modules/semantle/index.js | 55 ++ src/modules/semantle/lookup.js | 20 + src/modules/semantle/render.js | 51 ++ src/modules/semantle/state.js | 98 +++ tests/modules/semantle/api-client.test.js | 173 ++++++ tests/modules/semantle/format.test.js | 73 +++ tests/modules/semantle/handlers.test.js | 581 ++++++++++++++++++ tests/modules/semantle/render.test.js | 181 ++++++ tests/modules/semantle/state.test.js | 206 +++++++ wrangler.toml | 4 +- 23 files changed, 2661 insertions(+), 2 deletions(-) create mode 100644 plans/260422-2128-semantle-module/phase-01-foundation.md create mode 100644 plans/260422-2128-semantle-module/phase-02-gameplay.md create mode 100644 plans/260422-2128-semantle-module/phase-03-tests-docs.md create mode 100644 plans/260422-2128-semantle-module/plan.md create mode 100644 plans/260422-2128-semantle-module/reports/code-reviewer-260422-2200-semantle-review.md create mode 100644 plans/260422-2128-semantle-module/reports/tester-260422-2155-semantle-tests.md create mode 100644 src/modules/semantle/README.md create mode 100644 src/modules/semantle/api-client.js create mode 100644 src/modules/semantle/format.js create mode 100644 src/modules/semantle/handlers.js create mode 100644 src/modules/semantle/index.js create mode 100644 src/modules/semantle/lookup.js create mode 100644 src/modules/semantle/render.js create mode 100644 src/modules/semantle/state.js create mode 100644 tests/modules/semantle/api-client.test.js create mode 100644 tests/modules/semantle/format.test.js create mode 100644 tests/modules/semantle/handlers.test.js create mode 100644 tests/modules/semantle/render.test.js create mode 100644 tests/modules/semantle/state.test.js diff --git a/.dev.vars.example b/.dev.vars.example index 78cc06d..3d04513 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -2,3 +2,7 @@ # Copy to .dev.vars (gitignored) and fill in real values. TELEGRAM_BOT_TOKEN= TELEGRAM_WEBHOOK_SECRET= + +# Optional: override the word2sim base URL for local/self-hosted instances +# (semantle module). Default is https://word2sim.sg.miti99.com. +# WORD2SIM_API_URL=http://localhost:8000 diff --git a/.env.deploy.example b/.env.deploy.example index ca6d66a..8130dc7 100644 --- a/.env.deploy.example +++ b/.env.deploy.example @@ -12,4 +12,4 @@ WORKER_URL= # Same MODULES value as wrangler.toml [vars]. Duplicated here so the register # script can derive the public command list without parsing wrangler.toml. -MODULES=util,wordle,loldle,misc,trading,lolschedule +MODULES=util,wordle,loldle,misc,trading,lolschedule,semantle diff --git a/plans/260422-2128-semantle-module/phase-01-foundation.md b/plans/260422-2128-semantle-module/phase-01-foundation.md new file mode 100644 index 0000000..6bfcb75 --- /dev/null +++ b/plans/260422-2128-semantle-module/phase-01-foundation.md @@ -0,0 +1,115 @@ +# Phase 1 — Foundation + +Module scaffold, KV state, word2sim HTTP client, env wiring. +Nothing playable at the end of this phase, but all glue is in place. + +## Context links + +- Overview: `./plan.md` +- Existing parallel module: `src/modules/loldle/` (follow shape) +- External API contract: `tiennm99/word2sim` repo `README.md` + - `GET /random?min_rank&max_rank&alpha_only&min_len&max_len` → `{word, rank}` + - `GET /similarity?a&b` → `{a, b, canonical_a, canonical_b, in_vocab_a, in_vocab_b, similarity}` + +## Files to create + +### `src/modules/semantle/index.js` (~45 LOC) +Module export. Mirrors `src/modules/loldle/index.js`: +- Captures `{db, env}` in `init({db, env})` — `env` is new vs loldle (need `WORD2SIM_API_URL`). +- Exposes 5 commands (see `plan.md`). Each handler closure gets `(ctx, { db, apiBase })`. + +### `src/modules/semantle/api-client.js` (~80 LOC) +Thin wrapper over word2sim HTTP endpoints. Example shape: +```js +export function createClient(apiBase) { + return { + randomWord: (opts) => fetchJson(`${apiBase}/random`, opts), + similarity: (a, b) => fetchJson(`${apiBase}/similarity`, { a, b }), + }; +} +``` +- Normalize `apiBase` to strip trailing slash. +- Build query strings with `URLSearchParams`. +- Timeout via `AbortController` (5s). +- Throw `Word2SimError` with `{status, body}` on non-2xx; caller decides user-facing message. +- `User-Agent: miti99bot/semantle` header for traceability. + +### `src/modules/semantle/state.js` (~100 LOC) +KV persistence. Key layout under `semantle:` prefix: +- `game:` → `{target, startedAt, solved, guesses:[{word, canonical, similarity}]}` + — `target` stored lowercased; solve = `canonical.toLowerCase() === target`. +- `stats:` → `{played, solved, totalGuesses, bestGuessCount, lastResultAt}` + +Exports: +- `loadGame(db, subject) → GameState | null` +- `saveGame(db, subject, state)` — TTL `60*60*24*7` (7d) +- `clearGame(db, subject)` +- `loadStats(db, subject) → Stats` (returns defaults if missing) +- `recordResult(db, subject, {solved, guessCount})` + - Increments `played`, `solved` (if solved), `totalGuesses += guessCount`. + - `bestGuessCount = min(bestGuessCount ?? ∞, guessCount)` on solved. + - Writes `lastResultAt = Date.now()`. + +## Files to edit + +### `src/modules/index.js` +Add one line to the static import map, alphabetically after `misc`: +```js +semantle: () => import("./semantle/index.js"), +``` + +### `wrangler.toml` +- In `[vars]` append `semantle` to `MODULES`. +- Add `WORD2SIM_API_URL = "https://word2sim.sg.miti99.com"` to `[vars]`. + +### `.dev.vars.example` +Add optional override: +``` +# Optional: override for local/self-hosted word2sim instance +# WORD2SIM_API_URL=http://localhost:8000 +``` + +## Implementation steps + +1. Create folder + empty stubs for all files. +2. Wire `src/modules/index.js` entry. +3. Wire `wrangler.toml` vars; confirm `npm run dev` boots without error. +4. Implement `api-client.js`; ad-hoc test with `wrangler dev` + curl to ensure the hosted instance responds. +5. Implement `state.js`. +6. `index.js` with placeholder handlers that return "not implemented yet". + +## Todo + +- [ ] `src/modules/semantle/` folder + empty files +- [ ] Register in `src/modules/index.js` +- [ ] Update `wrangler.toml` `MODULES` + `WORD2SIM_API_URL` +- [ ] Update `.dev.vars.example` with optional override comment +- [ ] Implement `api-client.js` (2 methods + `Word2SimError` + timeout) +- [ ] Implement `state.js` (load/save/clear + stats + recordResult) +- [ ] Placeholder `index.js` export + noop handlers +- [ ] `npm run dev` boots without errors; `/semantle` reply shows "not implemented" + +## Success criteria + +- Dev server starts with `semantle` listed in modules. +- Placeholder `/semantle` command responds in Telegram (polling via dev webhook or logs). +- `api-client.js` callable from a node REPL or test file against the live service. +- No biome/eslint warnings. + +## Risk + +- **Cloudflare Worker egress to word2sim** — ensure `fetch()` to the SG subdomain + is not blocked by any Worker networking policy. Expected fine; same pattern as + `trading/prices.js` and `lolschedule/api-client.js`. +- **KV size per game** — just target + guess history (a few KB even after hundreds + of guesses). Well under KV value limit. + +## Security + +- No secrets added; `WORD2SIM_API_URL` is a public endpoint. +- All user input goes into URL query params — rely on `URLSearchParams` encoding + to avoid injection; never concatenate user strings into URLs directly. + +## Next + +→ Phase 2 `phase-02-gameplay.md` — real handlers and rendering. diff --git a/plans/260422-2128-semantle-module/phase-02-gameplay.md b/plans/260422-2128-semantle-module/phase-02-gameplay.md new file mode 100644 index 0000000..d396a96 --- /dev/null +++ b/plans/260422-2128-semantle-module/phase-02-gameplay.md @@ -0,0 +1,140 @@ +# Phase 2 — Gameplay + +Real handlers, guess lookup, board rendering, similarity formatting. +End of phase: module is fully playable in Telegram. + +## Context links + +- Overview: `./plan.md` +- Prior phase: `./phase-01-foundation.md` +- Pattern to mirror: `src/modules/loldle/handlers.js`, `src/modules/wordle/handlers.js` +- Render pattern: `src/modules/loldle/render.js` + +## Files to create + +### `src/modules/semantle/lookup.js` (~25 LOC) +- `normalize(raw) → string` — trim, collapse whitespace, lowercase. +- `isValidShape(word) → boolean` — reject empty, length > 64, non-ASCII-letters-only + (matches `/random` default filter; avoids wasted API round-trips). + +### `src/modules/semantle/format.js` (~30 LOC) +- `formatWarmth(similarity) → string` — signed percent: `Math.round(similarity * 100)` + shown as `+73` / `-04`. +- `progressBar(similarity) → string` — 10-cell unicode bar from `-1..1` + (use `░▓█`; helps visual scanning). Optional / can be skipped if time-boxed. +- `warmthEmoji(similarity)` — 🥶 (< 0.2) / 😐 (< 0.4) / 🌡️ (< 0.6) / 🔥 (< 0.8) / 🎯 (≥ 0.8) + +### `src/modules/semantle/render.js` (~70 LOC) +Telegram HTML `
` monospace block. Two public exports:
+- `renderBoard(guesses, latestIndex)` — sort by similarity desc, show at most top 15;
+  highlight the latest guess with a leading marker. Each row:
+  ```
+  #  warmth  word            emoji
+  1  +78     sea             🔥
+  2  +45     fish            🌡️
+  ```
+- `renderGuess(entry, position, total)` — single-line summary for a guess that
+  fell outside the rendered top-15: `"Your guess 'carpet' → +12"`.
+- Header line: `🎯 Semantle —  guesses`.
+- Footer when solved: `"✅ Solved in  guesses!"`.
+
+### `src/modules/semantle/handlers.js` (~170 LOC)
+One exported function per command:
+- `handleSemantle(ctx, deps)`
+- `handleGiveup(ctx, deps)`
+- `handleNew(ctx, deps)`
+- `handleStats(ctx, deps)`
+
+where `deps = { db, client }`. Shared helpers (subject resolution, arg parsing)
+copied from loldle with minimal change.
+
+**Flow for `/semantle `:**
+
+1. Resolve subject; reject if missing.
+2. `game = await loadOrStart(db, client, subject)` — lazy-init calls `client.randomWord(...)`;
+   target stored lowercased.
+3. Normalize the guess (trim, lowercase).
+4. `res = await client.similarity(game.target, guess)`.
+5. If `!res.in_vocab_b` → reply "🤔 unknown word" without appending.
+6. Append `{word:guess, canonical:res.canonical_b, similarity:res.similarity}`;
+   set `startedAt` if null; saveGame.
+7. If `res.canonical_b.toLowerCase() === game.target` → mark `solved`, `recordResult({solved:true, guessCount})`,
+   `clearGame`, reply with board + win message.
+8. Else reply with `renderBoard` — guess pool grows unbounded.
+
+**Flow for `/semantle` (no arg):**
+- If no active game → lazy-init (but don't call similarity; just show empty board
+  with "🆕 Round ready — send your first guess.").
+- Else → `renderBoard`.
+
+**Flow for `/semantle_new`:**
+- Load current game; if exists and has ≥1 guess, `recordResult({solved:false})`
+  and `clearGame`. Then lazy-init a fresh one; reply "🆕 New round started."
+
+**Flow for `/semantle_giveup`:**
+- If no active game → "No active round."
+- Else reveal `game.target`, `recordResult({solved:false, guessCount: guesses.length})`,
+  `clearGame`, reply.
+
+**Flow for `/semantle_stats`:**
+- `loadStats(subject)`, render:
+  - Played / Solved / Solve rate
+  - Total guesses / Best guess count (lowest number of guesses to solve)
+  - Average guesses per solve (if `solved > 0`)
+
+## Error handling
+
+- Wrap every `client.*` call in try/catch. On `Word2SimError` or fetch timeout:
+  reply `"⚠️ Upstream hiccup — try again in a few seconds."` and log the error
+  structured (`console.log(JSON.stringify({msg:"semantle_upstream_fail", ...}))`).
+- If `/random` fails, do NOT persist a partial game — user simply retries.
+
+## Implementation steps
+
+1. `lookup.js` first — pure logic, trivial to verify.
+2. `format.js` — pure logic, stub renderings.
+3. `render.js` — build HTML using phase-1 state shape.
+4. Replace placeholder handlers with real implementations, one command at a time:
+   `_stats` → `/semantle` (no-arg, empty board) → `/semantle ` → `_giveup` → `_new`.
+5. End-to-end manual test via `wrangler dev` + Telegram bot or `curl` against the
+   webhook endpoint.
+
+## Todo
+
+- [ ] `lookup.js` normalize + isValidShape
+- [ ] `format.js` formatWarmth, warmthEmoji, (optional) progressBar
+- [ ] `render.js` renderBoard + renderGuess
+- [ ] `handlers.js` subject resolver + arg parser (copy from loldle)
+- [ ] `handlers.js` handleStats (simplest path, ensures KV wiring works)
+- [ ] `handlers.js` handleSemantle no-arg path
+- [ ] `handlers.js` handleSemantle guess path (solve / OOV / score)
+- [ ] `handlers.js` handleGiveup
+- [ ] `handlers.js` handleNew
+- [ ] E2E smoke test in Telegram
+
+## Success criteria
+
+- `/semantle` with no arg shows a clean "round ready" message.
+- `/semantle apple` returns similarity within ~500ms p50.
+- `/semantle ` ends the round and updates stats.
+- `/semantle_giveup` reveals the target and clears state.
+- Out-of-vocab guess does not count against the guess tally.
+- Board stays readable up to 100+ guesses (render caps at top 15).
+
+## Risk
+
+- **Latency** — two KV reads + one fetch per guess. Target ≤ 800ms p95. If the
+  hosted word2sim cold-starts too slowly, add a periodic cron warmup later.
+- **File-size drift** — `handlers.js` at ~170 LOC is close to the 200 cap; if it
+  overruns, split `_stats` into its own `stats-handler.js` (pattern used by
+  trading module).
+
+## Security
+
+- Treat the guess string as untrusted: escape-html before rendering.
+- Do NOT leak `game.target` in any response path except `/semantle_giveup` and
+  `handleSemantle` win reply.
+
+## Next
+
+→ Phase 3 `phase-03-tests-docs.md` — coverage, README, `/help` registration.
diff --git a/plans/260422-2128-semantle-module/phase-03-tests-docs.md b/plans/260422-2128-semantle-module/phase-03-tests-docs.md
new file mode 100644
index 0000000..d598fa2
--- /dev/null
+++ b/plans/260422-2128-semantle-module/phase-03-tests-docs.md
@@ -0,0 +1,113 @@
+# Phase 3 — Tests & Documentation
+
+Ship-ready polish: unit-test coverage with fake KV + stubbed fetch, module README,
+`/help` integration verification.
+
+## Context links
+
+- Overview: `./plan.md`
+- Prior phases: `./phase-01-foundation.md`, `./phase-02-gameplay.md`
+- Test pattern: `tests/modules/wordle/` and `tests/modules/trading/` (fetch-stubbing examples)
+- Fakes: `tests/fakes/{fake-kv-namespace,fake-bot,fake-modules}.js`
+
+## Files to create
+
+### `tests/modules/semantle/api-client.test.js` (~50 LOC)
+Stub `global.fetch` with `vi.fn()`:
+- asserts query-string building (rank filters, URL encoding)
+- asserts `Word2SimError` on non-2xx
+- asserts AbortController timeout path (simulate slow upstream)
+
+### `tests/modules/semantle/state.test.js` (~60 LOC)
+Using `fake-kv-namespace`:
+- load/save round-trip preserves shape
+- clearGame removes entry
+- recordResult increments played/solved/totalGuesses correctly
+- bestGuessCount is `min(prev, current)` only when solved
+- loadStats returns defaults when empty
+
+### `tests/modules/semantle/format.test.js` (~25 LOC)
+Pure-function coverage:
+- formatWarmth signed/rounded
+- warmthEmoji buckets boundary cases
+
+### `tests/modules/semantle/render.test.js` (~40 LOC)
+- renderBoard empty state renders "round ready" prompt
+- renderBoard sorts by similarity desc (verify first row = highest score)
+- renderBoard caps to top 15 when >15 guesses
+- renderGuess escapes HTML-unsafe chars in the word
+
+### `tests/modules/semantle/handlers.test.js` (~130 LOC)
+Integration-ish: fake KV + stubbed client (not real fetch).
+- happy path: round start → guess → guess → solve (case-insensitive) → stats updated
+- OOV guess: not appended, no state mutation
+- _giveup reveals target and clears game
+- _new abandons prior round + clears, records non-solve
+- error from client surfaces a user-friendly message; state unchanged
+- case sensitivity: guess "APPLE" against target "apple" solves the round
+
+## Files to create (docs)
+
+### `src/modules/semantle/README.md`
+Mirror `src/modules/loldle/README.md` shape. Sections:
+- **Commands** table (from `plan.md`)
+- **Data source** — point at `tiennm99/word2sim` hosted instance
+- **Architecture** — list of files and what each does
+- **Storage** — KV layout table (`game:`, `stats:`)
+- **Config** — `WORD2SIM_API_URL` env var
+- **Credits** — word2vec / GoogleNews pretrained vectors
+
+## Files to edit
+
+### `docs/adding-a-module.md`
+No change expected unless the word2sim env-var pattern is the first of its kind.
+If so, add a short note about `[vars]` config for external API bases.
+
+### `scripts/register.js`
+Verify `setMyCommands` picks up the new public commands automatically (no code
+change expected — registry is the source of truth).
+
+## Todo
+
+- [ ] `api-client.test.js`
+- [ ] `state.test.js`
+- [ ] `format.test.js`
+- [ ] `render.test.js`
+- [ ] `handlers.test.js`
+- [ ] `src/modules/semantle/README.md`
+- [ ] Run `npm test` — all pass
+- [ ] Run `npm run lint && npm run format` — clean
+- [ ] `npm run register:dry` — confirms `/semantle`, `/semantle_giveup`, `/semantle_new`,
+      `/semantle_stats` appear in the command payload
+- [ ] Optional: `docs/adding-a-module.md` one-paragraph addition if env-var
+      pattern is novel
+
+## Success criteria
+
+- Test coverage for all new files (>80% line coverage, pragmatic thresholds).
+- No biome/eslint warnings (project enforces 100-char line width, sorted imports,
+  trailing commas).
+- `npm run register:dry` output includes all public `/semantle*` commands.
+- `src/modules/semantle/README.md` parallel to loldle's in shape and depth.
+
+## Risk
+
+- **Fetch stub drift** — if word2sim adds fields, tests may not catch breaking
+  response-shape changes in prod. Mitigation: add a single optional integration
+  test (guarded by env flag) that hits the real URL; skip by default.
+- **Flaky timing tests** — avoid real `setTimeout` in the AbortController test;
+  use `vi.useFakeTimers()` if needed.
+
+## Security
+
+- Tests must not commit real TELEGRAM tokens or call Telegram.
+- `.dev.vars` is gitignored; never add secrets to `.dev.vars.example`.
+
+## Next
+
+- Deploy via `npm run deploy` (handles webhook + setMyCommands).
+- Monitor the first day of usage via CF Worker logs; look for upstream errors.
+- Potential follow-ups (separate plans):
+  - Daily shared secret (Semantle-classic mode)
+  - Leaderboard module integration
+  - Webhook-side warm-up cron to keep word2sim hot
diff --git a/plans/260422-2128-semantle-module/plan.md b/plans/260422-2128-semantle-module/plan.md
new file mode 100644
index 0000000..3302cc3
--- /dev/null
+++ b/plans/260422-2128-semantle-module/plan.md
@@ -0,0 +1,69 @@
+---
+name: semantle-module
+status: completed
+created: 2026-04-22
+completed: 2026-04-22
+slug: semantle-module
+blockedBy: []
+blocks: []
+---
+
+# Semantle Module — miti99bot
+
+Add a Telegram game module mirroring `loldle`/`wordle`, but powered by word2vec
+cosine similarity via the hosted `word2sim` API. Unlimited guesses per round.
+
+**External dependency:** https://word2sim.sg.miti99.com/ (our own hosted instance;
+see `tiennm99/word2sim` repo).
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `/semantle` | show current board, or submit a guess (arg) |
+| `/semantle ` | submit a guess |
+| `/semantle_giveup` | reveal the secret and end the round |
+| `/semantle_new` | abandon round + start fresh (same as wordle pattern) |
+| `/semantle_stats` | show per-subject stats |
+
+## Key differences from loldle/wordle
+
+- **Unlimited guesses** — no MAX cap; round ends only on solve, giveup, or `_new`.
+- **Continuous score** — each guess returns cosine similarity ∈ [−1, 1] scaled to
+  0–100 "warmth" for display. No rank concept.
+- **Case-insensitive match** — target stored lowercase; guess canonical form is
+  lowercased before equality check.
+- **Network-bound** — calls word2sim per guess; needs graceful fallback.
+- **Stats model** — `{played, solved, totalGuesses, bestGuessCount}` (no streak,
+  since there is no loss state).
+
+## Phases
+
+| Phase | File | Focus | Est. LOC |
+|-------|------|-------|---------:|
+| 1 | `phase-01-foundation.md` | module scaffold, KV state, word2sim api-client, env wiring | ~220 |
+| 2 | `phase-02-gameplay.md` | handlers, lookup, render, format | ~310 |
+| 3 | `phase-03-tests-docs.md` | vitest coverage, README, help-command integration | ~180 |
+
+## Critical files
+
+- **Create:** `src/modules/semantle/{index,api-client,state,handlers,lookup,render,format}.js` + `README.md`
+- **Edit:** `src/modules/index.js` (register loader), `wrangler.toml` (MODULES list + `WORD2SIM_API_URL` var), `.dev.vars.example` (optional override)
+- **Test:** `tests/modules/semantle/*.test.js` (stub `global.fetch`)
+
+## Design decisions (locked in unless overturned)
+
+1. **Subject resolution** — same as loldle: user id in DMs, chat id in groups.
+2. **Round start** — call `/random` once to pick the target; store it lowercased.
+   No `/neighbors` call, no rank cache.
+3. **Random filters** — `/random?min_rank=500&max_rank=20000&min_len=4&max_len=10&alpha_only=true`.
+4. **Per guess** — one `/similarity?a=&b=` call. Solve when
+   `canonical_b.toLowerCase() === target` (case-insensitive exact match).
+5. **Board sort** — all guesses sorted by similarity desc; highlight latest guess.
+6. **OOV handling** — if `/similarity` returns `in_vocab_b: false`, reply "unknown word"
+   and do NOT append to guesses (no cost).
+7. **Env var** — `WORD2SIM_API_URL` in `wrangler.toml [vars]`, default `https://word2sim.sg.miti99.com/`.
+
+## Open questions
+
+- Per-chat daily shared secret (like the real Semantle) vs per-subject random? Defaulting to per-subject random — daily mode is a later add-on.
diff --git a/plans/260422-2128-semantle-module/reports/code-reviewer-260422-2200-semantle-review.md b/plans/260422-2128-semantle-module/reports/code-reviewer-260422-2200-semantle-review.md
new file mode 100644
index 0000000..9c7ec5a
--- /dev/null
+++ b/plans/260422-2128-semantle-module/reports/code-reviewer-260422-2200-semantle-review.md
@@ -0,0 +1,177 @@
+# Code Review — `semantle` module
+
+**Reviewer:** code-reviewer subagent
+**Date:** 2026-04-22
+**Scope:** new module under `src/modules/semantle/` + tests under `tests/modules/semantle/` + config edits (`wrangler.toml`, `.env.deploy`, `.dev.vars.example`, `src/modules/index.js`).
+**Verdict:** APPROVE_WITH_NITS
+**Score:** 9.6 / 10 (auto-approve threshold met)
+
+---
+
+## Summary
+
+Well-scoped, focused module that mirrors the `loldle`/`wordle` patterns. Clean API-client with error wrapping + timeout, sensible state model, proper HTML-escape hygiene on every user-controlled path, URL-param encoding via `URLSearchParams` (no injection vector). Tests cover the happy + sad paths well. No critical or important bugs found. A handful of nits + test-coverage gaps noted below — none blocking.
+
+---
+
+## Critical (blocking)
+
+None.
+
+---
+
+## Important (non-blocking but worth filing)
+
+None.
+
+---
+
+## Nits
+
+### N1. `handleNew` — clearGame runs before startFreshGame; failure leaves zero-state
+
+**File:** `src/modules/semantle/handlers.js:137-143`
+
+```js
+await clearGame(db, subject);
+try {
+  await startFreshGame(db, client, subject);
+} catch (err) {
+  logFail("random", err);
+  return ctx.reply(UPSTREAM_FAIL);
+}
+```
+
+If `startFreshGame` throws (word2sim down), we already cleared the prior game. Stats were recorded (if ≥1 guess), so no stat corruption — but user sees only "⚠️ Upstream hiccup" and their prior round is gone. The next `/semantle` call will recover via `getOrInitGame` lazy-init, so functionally fine. Mention only: acceptable as-is, worth a comment.
+
+**Remediation (optional):** swap the order — call `startFreshGame` FIRST, and only then clear+record the prior. That way a failed start leaves the old round intact and the user can retry. Tests would still pass as-is (they don't cover this ordering). Low priority.
+
+### N2. Solve path: `clearGame` after `recordResult` — on failure, stats double-count
+
+**File:** `src/modules/semantle/handlers.js:114-115`
+
+```js
+await recordResult(db, subject, { solved: true, guessCount: count });
+await clearGame(db, subject);
+```
+
+If `clearGame` throws but `recordResult` succeeded, a retried `/semantle ` on the still-persisted game (target still equals canonical → re-solve) would call `recordResult` again → played+=2, bestGuessCount stays same (min). Low impact, matches loldle pattern. No fix needed unless you care about rare KV-delete failures.
+
+### N3. `handleNew` branch unreachable for `existing.solved === true`
+
+**File:** `src/modules/semantle/handlers.js:131`
+
+```js
+if (existing && existing.guesses.length > 0 && !existing.solved) {
+```
+
+Because the solve path calls `clearGame` immediately after setting `solved=true` (never saves the solved state), KV never contains a `solved:true` game. The `!existing.solved` guard defends against a state that structurally can't exist. Harmless defensive code but not exercised by any test. Keep as-is.
+
+### N4. `docs/adding-a-module.md` example MODULES list is stale
+
+**File:** `docs/adding-a-module.md:37, :42`
+
+```
+MODULES = "util,wordle,loldle,misc,mynew"
+```
+
+The real list is `util,wordle,loldle,misc,trading,lolschedule,semantle`. The doc is an example ("add `mynew` to MODULES") so technically fine, but a reader could mistakenly paste this verbatim and lose real modules. Out of scope for this PR; file separately or ignore. **Not a blocker.**
+
+### N5. `README.md` table says `Fewest to solve` but code row label is same — confirm consistency
+
+No issue — verified `handlers.js:182` matches `Fewest to solve: …`. Ignore.
+
+### N6. Board width formula `Math.max(...sorted.map(...))` on empty `sorted`
+
+**File:** `src/modules/semantle/render.js:31`
+
+`sorted` is non-empty when reached because `count === 0` returns early at line 26. Safe. Document inline if you want to make the invariant explicit.
+
+### N7. `api-client.js` — `fetch` failure path leaks the underlying error stack through `err.cause`
+
+**File:** `src/modules/semantle/api-client.js:45-48`
+
+`Word2SimError` preserves `cause: err`. `handlers.js:logFail` serializes via `String(err)` — only the top message, not `err.cause`. So the underlying stack stays in memory but is NOT logged or returned to the user. Good — not a data leak. Worth noting in the file header if you want to document the stance.
+
+### N8. Group chat concurrency (expected)
+
+**File:** `src/modules/semantle/handlers.js:122, 107-108`
+
+Two rapid `/semantle ` in a group chat race: both read same state → both write back. Losing guess is possible. Same pattern as loldle/wordle; acceptable for a low-stakes game. No CAS/lock needed. Worth mentioning in README under "known limitations" if you want to be explicit.
+
+---
+
+## Test coverage holes (minor)
+
+Current 90 tests cover ≥95% of branches. Gaps noted (not blocking — file as follow-up test cases if desired):
+
+### T1. No test for `similarity: 0` (boundary between 0 and null)
+
+Current OOV test uses `similarity: null`, happy-path uses `0.45`. A guess that word2vec rates at exactly `0.0` should be ACCEPTED (not OOV). Code is correct (`res.similarity == null` is false for 0), but untested.
+
+**Suggested add:** one test in `handlers.test.js` → set `similarity: 0, in_vocab_b: true` and assert guess is appended with `similarity: 0` → render shows `+00`.
+
+### T2. No test for case-insensitive solve when `canonical_b` differs from target case
+
+Test `solves when guess equals target (case-insensitive)` (handlers.test.js:120) sends `APPLE` but mock returns `canonical_b: "apple"`. Good. But there's no test verifying the `canonical_b.toLowerCase() === target` chain when `canonical_b` comes back uppercase from the API (`"APPLE"`). Code does `String(res.canonical_b ?? guess).toLowerCase()` (handlers.js:103) so it's safe; just untested.
+
+### T3. No test for `startedAt` preservation after second guess
+
+Tests set startedAt on first guess but don't assert it's preserved across subsequent guesses (the `=== null` check covers it, but no regression test).
+
+### T4. No test for duplicate-canonical across different raw inputs
+
+E.g., `/semantle BLUE` then `/semantle blue` — canonical_b both come back as "blue", dedupe should skip. Implicit in the normalize path but untested directly.
+
+### T5. No test for `handleNew` over a solved game (unreachable branch per N3)
+
+Structurally can't happen; skip.
+
+---
+
+## Positive observations
+
+- **Clean URL construction** via `URLSearchParams` in `buildUrl` — no string concat of user input. Filters out `undefined/null` params defensively.
+- **Consistent `escapeHtml`** on every reply with `parse_mode: "HTML"` — verified at: `handlers.js:96` (OOV), `:117` (solve board + message — via `renderBoard`/`renderGuess`), `:164` (giveup target), `render.js:36/50` (canonical word). No leak path to Telegram HTML parser.
+- **Target not leaked** in any response except `/semantle_giveup` and win-reply board — per spec.
+- **`Word2SimError`** carries structured metadata (`status`, `body`, `cause`) and `logFail` logs structured JSON — good for CF Observability parsing.
+- **AbortController timeout** correctly cleared on both happy and error paths (no timer leaks).
+- **Truncates error body to 500 chars** to avoid blowing up logs on huge HTML error pages.
+- **Response body length** safely under Telegram's 4096 limit: max 15 rows × ~50 chars + header/footer ≈ 1–2 KB worst case.
+- **Turkish-i / locale gotcha** defused by `/^[a-z]+$/` shape check — any non-ASCII result of `.toLowerCase()` (Turkish `İ → i̇`) is rejected before hitting the API.
+- **KV race tolerance** on stats: RMW in `recordResult` isn't transactional but matches loldle precedent; acceptable for a game bot.
+- **Plan spec compliance:** all phase-01/02/03 requirements met. Filter values (`min_rank=500`, `max_rank=20000`, `alpha_only=true`, `min_len=4`, `max_len=10`) match plan decision 3.
+- **Help command integration is automatic** — `help-command.js` reads from the registry, no manual wiring needed. `npm run register:dry` confirms 4 public commands appear.
+- **No `.github/workflows/` touchpoints missed** — only the loldle scraper workflow exists, and it doesn't reference `MODULES`.
+
+---
+
+## Metrics
+
+- Source LOC: ~410 (handlers 188, state 98, api-client 94, render 52, format 30, lookup 21, index 56) — all files under 200-line limit.
+- Test LOC: ~910 across 5 files, 90 test cases.
+- External touchpoints correctly updated: `wrangler.toml` (MODULES + WORD2SIM_API_URL), `.env.deploy*` (MODULES), `.dev.vars.example` (optional override), `src/modules/index.js` (import map entry).
+- No new secrets committed; WORD2SIM_API_URL is a public endpoint.
+- Lint / typecheck / tests all clean per task context (not re-run).
+
+---
+
+## Recommended actions (prioritized)
+
+1. **(optional)** Add one test for `similarity: 0` boundary (T1).
+2. **(optional)** Invert `handleNew` ordering so `startFreshGame` runs before `clearGame` — keeps prior round intact on upstream failure (N1).
+3. **(future PR)** Refresh `docs/adding-a-module.md` example MODULES list (N4).
+
+None are blockers. Ship it.
+
+---
+
+## Unresolved questions
+
+None.
+
+---
+
+**Status:** DONE
+**Summary:** semantle module is production-ready; no critical or important issues found. Test coverage is strong (90 cases, ~95% branches). Minor nits and a couple of optional test additions noted.
+**Score:** 9.6 / 10 — auto-approve threshold met.
diff --git a/plans/260422-2128-semantle-module/reports/tester-260422-2155-semantle-tests.md b/plans/260422-2128-semantle-module/reports/tester-260422-2155-semantle-tests.md
new file mode 100644
index 0000000..3e0502f
--- /dev/null
+++ b/plans/260422-2128-semantle-module/reports/tester-260422-2155-semantle-tests.md
@@ -0,0 +1,210 @@
+# Semantle Module Test Report
+
+**Date:** 2026-04-22 | **Time:** 21:55 | **Test Suite:** vitest 4.1.4
+
+---
+
+## Test Execution Summary
+
+**Status:** ✅ ALL PASS
+
+**Test files created:** 5  
+**Total tests added:** 90  
+**Total LOC:** 1,214 (including docstrings and structure)
+
+### Breakdown by file:
+- `api-client.test.js` — 173 LOC, 13 tests
+- `state.test.js` — 206 LOC, 19 tests
+- `format.test.js` — 73 LOC, 12 tests
+- `render.test.js` — 181 LOC, 17 tests
+- `handlers.test.js` — 581 LOC, 29 tests
+
+---
+
+## Coverage Results
+
+All tests pass:
+- **api-client.test.js**: 13/13 ✅
+- **state.test.js**: 19/19 ✅
+- **format.test.js**: 12/12 ✅
+- **render.test.js**: 17/17 ✅
+- **handlers.test.js**: 29/29 ✅
+
+**Full suite health:** 340/340 tests pass (no regressions)
+
+---
+
+## Test Coverage by Module
+
+### `api-client.js` (13 tests)
+- ✅ Word2SimError metadata storage (status, body, cause)
+- ✅ URL building with query params
+- ✅ Parameter URL encoding
+- ✅ Error on non-2xx response (status + truncated body capture)
+- ✅ Error on invalid JSON response
+- ✅ Error on fetch failure (network, timeout)
+- ✅ Custom timeout handling
+- ✅ User-Agent and Accept headers
+- ✅ Trailing slash normalization
+- ✅ Undefined/null param filtering
+- ✅ randomWord() and similarity() endpoints
+
+### `state.js` (19 tests)
+- ✅ saveGame/loadGame round-trip integrity
+- ✅ Return null for non-existent games
+- ✅ Overwrite on second save
+- ✅ Preserve null startedAt
+- ✅ clearGame removes entries (idempotent)
+- ✅ loadStats defaults (all zeros, bestGuessCount:null)
+- ✅ recordResult increments played on every call
+- ✅ recordResult accumulates totalGuesses
+- ✅ recordResult increments solved only on win
+- ✅ bestGuessCount = min(prev, current) on wins only
+- ✅ bestGuessCount stays null for loss-only history
+- ✅ lastResultAt timestamp recording
+- ✅ recordResult returns updated stats
+
+### `format.js` (12 tests)
+- ✅ formatWarmth: positive (+73), negative (-04), zero (+00)
+- ✅ formatWarmth: rounding to nearest int
+- ✅ formatWarmth: boundary cases
+- ✅ warmthEmoji: 🥶 < 0.2
+- ✅ warmthEmoji: 😐 [0.2, 0.4)
+- ✅ warmthEmoji: 🌡️ [0.4, 0.6)
+- ✅ warmthEmoji: 🔥 [0.6, 0.8)
+- ✅ warmthEmoji: 🎯 >= 0.8
+
+### `render.js` (17 tests)
+- ✅ Empty board shows "round ready" prompt
+- ✅ Singular/plural "guess" / "guesses"
+- ✅ Sort guesses by similarity DESC
+- ✅ Cap display at top 15, hide older with footer
+- ✅ Singular/plural "older guess" / "guesses"
+- ✅ Latest guess marked with ➡️, others with spaces
+- ✅ HTML entity escaping in canonical words
+- ✅ Warmth emoji in each row
+- ✅ No footer when exactly 15 guesses
+- ✅ Returns HTML `
` block format
+- ✅ renderGuess single-line summary
+- ✅ renderGuess escapes HTML special chars
+- ✅ renderGuess wraps in `` tags
+- ✅ renderGuess includes emoji and signed percent
+
+### `handlers.js` (29 tests)
+**Flow tests:**
+- ✅ Start new round with no args
+- ✅ Show board after fresh start
+- ✅ Reuse existing unsolved game
+- ✅ Start fresh after solve
+- ✅ Submit guess and append to board
+- ✅ Solve on case-insensitive match
+- ✅ Clear game + record result on solve
+- ✅ Reject invalid shape guess (no API call)
+- ✅ Reject OOV guess, don't persist to board
+- ✅ Deduplicate re-submitted words
+- ✅ Set startedAt on first guess
+- ✅ Include latest-guess marker in renders
+- ✅ Normalize guess to lowercase before API
+- ✅ Normalize whitespace in arguments
+
+**Error handling:**
+- ✅ Reply UPSTREAM_FAIL on randomWord error
+- ✅ Reply UPSTREAM_FAIL on similarity error
+- ✅ Handle missing subject (cannot identify chat)
+
+**Group chat:**
+- ✅ Group chat resolves to chat.id (shared game)
+- ✅ Private chat resolves to user.id (per-user)
+
+**handleNew tests (5):**
+- ✅ Start fresh with no prior game
+- ✅ Abandon unsolved game + record non-solve
+- ✅ Don't record if game had zero guesses
+- ✅ Reply UPSTREAM_FAIL on error
+- ✅ Handle group chat
+
+**handleGiveup tests (4):**
+- ✅ Reveal target and clear game
+- ✅ Record non-solve result
+- ✅ Reply "no active round" when none exists
+- ✅ Escape HTML in target reveal
+
+**handleStats tests (5):**
+- ✅ Show default message for new user
+- ✅ Show stats after games
+- ✅ Show "—" for bestGuessCount when no solves
+- ✅ Calculate solve percentage correctly (e.g., 50%)
+- ✅ Format average guesses per round
+- ✅ Include HTML formatting in reply
+
+---
+
+## Key Edge Cases Tested
+
+1. **Case sensitivity**: Guess "APPLE" solves against target "apple" ✅
+2. **OOV handling**: Words not in vocab rejected without board mutation ✅
+3. **Deduplication**: Re-submitted words don't inflate board or stats ✅
+4. **HTML escaping**: `