Phase 6a of go-port-cloud-run; first of 5 sub-cooks for Phase 6 loldle
variants. Implements binary right/wrong scoring (no attribute compare).
Per-subject keylock and math/rand.Intn applied from the start, lessons
from prior phase reviews. JS-wire-format decode test added per
code-review concern F#1, locking the migration contract. Helpers
(normalize/subjectFor/argAfterCommand) duplicated from classic loldle;
extraction earmarked for 6b prep.
Relax module name regex to accept hyphens, preparing for hyphenated
loldle variants (loldle-emoji, loldle-quote, etc.) ported from upstream
JS sources. Storage prefix delimiter ':' remains rejected. Telegram
command names use separate stricter regex (commandNameRe) and are
unaffected.
Completed Phase 5c: loldle module ported with full comparison engine,
keylock extracted for module reuse, and winRate rounding aligned with JS
behavior. Code review flagged and fixed render-alignment golden tests.
Updated phase status and plan progress tracking.
Ported loldle game module with full classic-mode comparison engine:
4 commands (3 public + /loldle_setmax private), 7-attribute comparison
(gender/species/range_type/resource/regions/positions/release_date) with
exact/multi/year scoring, 172-champion dictionary, and sticker pools by
outcome.
Fixed winRate display discrepancy: JS uses Math.round but Go was using
int(...) truncation. Applied math.Round in both loldle and wordle
handlers. Rendered output now matches expected percentages (e.g. 67%
instead of 66%).
Includes comparison/lookup/flavor/state/render golden tests, keylock
fan-out tests, and strict render-alignment validation.
Extracted per-subject RWMutex primitive into dedicated internal/keylock
package to allow reuse across game modules. Wordle previously held local
subjectLocks; loldle module (Phase 5c) would duplicate this type without
the extraction. Placing keylock as a peer to storage/telegram (not nested
under modules/) matches its cross-module scope.
Updated phase-05-port-simple-modules.md with completion status and
linked code-reviewer report documenting the two bugs fixed during
implementation (defaultRNG race, Get→mutate→Put logical race).
Updated plan.md to reflect Phase 5b completion in roadmap.
Phase 5b of go-port-cloud-run plan. Port 14855-word dictionary
(89 KB, byte-identical to JS source) and four wordle commands
(/wordle, /wordle_new, /wordle_giveup, /wordle_stats).
KV wire-format parity: GameState/Stats JSON match JS shape;
*int64 LastResultAt for null-value compatibility. Two real bugs
caught and fixed: (1) defaultRNG data race in handlers — switched
to math/rand.Intn (mutex-protected package-level); (2) Get→mutate→Put
logical race in groups — added per-subject sync.Mutex map to serialize
access. TTL deferred (Firestore has no expirationTtl equiv — Phase 11 GC).
Phase 5a of go-port-cloud-run plan: port first 2 of 4 modules (wordle/loldle
deferred to later phase). Port util.go, info.go, help.go, stickerid.go and
misc.go with tests. /help renders registry view; /info exposes chat/thread/
sender ids; /stickerid (private) returns bot-scoped file_ids; /ping writes
last_ping KV ms-epoch JSON for byte-parity, /mstats reads it, /fortytwo is
easter egg.
Registry-pointer-in-Deps required for /help to access module registry—pointer
captured at factory time, stable post-Build. Static factory catalog moved from
modules pkg to cmd/server to break import cycle. Code-review fixes applied in
same session: /info nil-deref guard, KV wire-format parity.
Updated phase-04-firestore-kv.md to reflect completion with all success
criteria validated. Updated plan.md to mark Phase 04 status as complete.
Recorded code-reviewer findings documenting security hardening, abstraction
quality, and emulator test coverage from Phase 04 implementation review.
Phase 02 (repo bootstrap, partial) and Phase 03 (module framework)
completed and reviewed. Cloud Run deployment deferred to Phase 01.
Updates plan progress, phase completion notes, and code review report
documenting security hardening and test coverage decisions.
Implements Phases 02 (partial) and 03 of the go-port-cloud-run plan.
Introduces module framework with per-module KV prefix isolation,
health check endpoint, request timeout protection, and comprehensive
test coverage. Cloud Run deployment deferred to Phase 01.
Security hardening: constant-time secret comparison, cron auth bridge,
and secrets stripped from dependency environment exports. Includes
Dockerfile, GitHub CI workflow (vet + race + build), and integration
tests for module lifecycle.
Roll classic loldle back to 8 (from 6) and emoji to 5 (from 4). The
/<module>_setmax override command stays — chats that want tighter limits
can opt in instead of having defaults forced on them.
Drop classic loldle from 8 → 6 (7-axis grid leaks too much per guess for 8
to feel earned) and emoji from 5 → 4 (3 emojis are usually unmistakable).
Add a hidden /<module>_setmax <n> command per loldle module so a chat can
override its own round length (1-10). Override stored at config:<subject>
in each module's KV; getMaxGuesses() falls back to the default when unset.
Roll classic loldle back to 8 (from 6) and emoji to 5 (from 4). The
/<module>_setmax override command stays — chats that want tighter limits
can opt in instead of having defaults forced on them.
Drop classic loldle from 8 → 6 (7-axis grid leaks too much per guess for 8
to feel earned) and emoji from 5 → 4 (3 emojis are usually unmistakable).
Add a hidden /<module>_setmax <n> command per loldle module so a chat can
override its own round length (1-10). Override stored at config:<subject>
in each module's KV; getMaxGuesses() falls back to the default when unset.
Removed reports that documented work already shipped and not tied to
any archived plan dir:
- docs-manager-260420-2151-documentation-audit.md (one-off doc audit)
- researcher-260421-0845-leaguepedia-api-verification.md (lolschedule research)
- researcher-260421-0909-leaguepedia-auth-token.md (lolschedule research)
The lolschedule module is in src/modules/lolschedule/ but never had a
discrete plan dir. Findings from the leaguepedia reports are reflected
in the live module code; the markdown is no longer load-bearing.
plans/reports/ now contains only the 6 Atlas migration reports for the
active plan.
Six research reports were sitting in plans/reports/ but tied to
already-archived plans. Move each under the matching archived plan
dir so the archive is self-contained and plans/reports/ only holds
reports for in-flight or unarchived work.
Moves
- researcher-260422-2329-semantle-api-alternatives.md
- researcher-260423-0025-bge-m3-cosine-calibration.md
- researcher-260423-1110-vietnamese-embeddings-semantle.md
→ plans/archive/260422-2128-semantle-module/reports/
- researcher-260424-2215-loldle-ability-splash-modes.md
- researcher-260424-2215-loldle-emoji-and-modes-overview.md
- researcher-260424-2215-loldle-quote-mode.md
→ plans/archive/260424-2215-loldle-new-modes/reports/ (new dir)
Stays in plans/reports/
- 6 Atlas migration reports (active plan)
- docs-manager-260420-2151-documentation-audit.md (general audit, no
discrete plan home)
- researcher-260421-0845-leaguepedia-api-verification.md
- researcher-260421-0909-leaguepedia-auth-token.md
(lolschedule research; no archived lolschedule plan exists)
No code changes.
Three feature plans (semantle, twentyq, loldle-new-modes) are
status:completed in their frontmatter and the corresponding modules
exist in src/modules/. Move them to plans/archive/ to keep the active
plans/ dir focused on in-flight work.
Atlas migration (260425-1945-mongodb-atlas-migration/plan.md): bump
status from `planning` to `code-complete` and annotate each phase row
with its commit SHA + whether operator action is still pending. Plan
stays in active plans/ until cutover lands or the Upstash standby
(phase-07-alt-pivot.md) executes.
No code changes. Tests, lint, register:dry unaffected (733 passing).
Operator-facing summary in the plan.md status note: 8 phases of
implementation are committed on dev (6f0b5ff..e2e3112). Outstanding
operator work: Atlas provisioning, real-cluster smoke tests,
backfill runs, soak, cutover stages, Stage 3 code cleanup.
Pre-execution prerequisites for the Phase 07 cutover. Stage 2 of the
cutover keeps DUAL_WRITE=0 for ~6 days; if anything regresses during
that window the operator MUST be able to roll back to KV/D1 with the
last N days of Mongo-only writes recovered. Pre-building these scripts
(per code-reviewer #4) eliminates "draft a backfill under outage
pressure" — the anti-pattern of writing untested code at 4am.
Reverse-backfill
- scripts/backfill-mongo-to-kv.js: full-scan Mongo collection per module,
PUT each doc back to CF KV via REST. expiresAt → expirationTtl (clamped
to 60s minimum per CF KV); already-expired docs are skipped (won't
resurrect dead state). 50 ops/sec throttle. --dry-run + --module flags.
- scripts/backfill-mongo-to-d1.js: full-scan trading_trades, build INSERT
SQL preserving legacy_id where present (round-trips D1 autoincrement IDs
preserved by phase-05 forward backfill). Sequential int generation for
any docs without legacy_id. Pipes through wrangler d1 execute.
- scripts/lib/migration-helpers.js: cfKvPut helper added.
Delete guard (debugger #12)
- scripts/wrangler-delete-guard.sh: interactive CONFIRM wrapper around
wrangler kv namespace delete + wrangler d1 delete. Exits 3 when stdin
is not a tty so it cannot run in CI. Documented: never run in CI.
package.json: backfill:mongo:kv[:dry] + backfill:mongo:d1[:dry] scripts
wired.
Tests: 697 → 733 (+36).
- 7 cfKvPut tests (REST URL, querystring, body, expiration_ttl param).
- 10 reverse-KV TTL math tests (expired sentinel, future seconds, no-TTL,
CF 60s minimum clamp).
- 9 reverse-D1 SQL construction tests (escaping, legacy_id preservation,
sequential generation).
Lint clean. No Worker code touched. Stage 1 cutover, 7-day soak,
snapshots, and Stage 3 cleanup (delete CFKVStore + simplify factories +
edit package.json deploy chain) remain operator-driven and will be
committed separately after binding deletion.
Code prerequisites for the Phase 06 cold-start soak gate. The 24-72h soak
itself is operator-run; this commit ships the instrumentation + analysis
tools needed to make the PROCEED-or-PIVOT decision.
Telemetry
- src/util/timing.js: startTiming(cmd) returns {mark, end} that emits a
structured cmd_timing log. takeColdFlag() returns {cold, isolateAgeMs}
using a module-scoped boolean — first request in an isolate is cold,
subsequent are warm. This replaces the originally-planned
isolate_age_ms < 200ms classifier (broken because Mongo cold-connect
itself is ~1500ms; cold requests would always bucket as warm —
code-reviewer #11).
- src/util/request-context.js: setLastCold/getLastCold shared state
bridges fetch-level cold detection into the dispatcher middleware
without a circular import.
- src/index.js: takeColdFlag at the top of fetch() emits a request log
and primes the request context for the dispatcher.
- src/modules/dispatcher.js: bot.use() middleware times every command.
Chosen over per-handler wrapping to preserve the existing identity
assertion in tests (handler === reg.allCommands.get(name).cmd.handler)
— single instrumentation point, no contract change.
Soak tools (operator-run)
- scripts/analyze-soak.js: parses CF Logs export (NDJSON or CSV), filters
cmd_timing events, computes p50/p95/p99 per (cmd, cold/warm). Counts
dual-write secondary failures, mongo connection errors, CPU-time
exceeded events. Writes markdown report.
- scripts/synthetic-burst.js: fires N parallel synthetic Telegram updates
at the deployed Worker URL with cache-busting tokens. Used for the
pre-deploy connection-cap stress test (debugger #2 — 20 parallel cold
requests, abort if Atlas peak > 60% of 500-conn cap).
- package.json: analyze:soak + burst:synthetic scripts wired.
Tests
- tests/util/timing.test.js: 8 tests — timing semantics, cold flag flip.
- tests/scripts/analyze-soak.test.js: 22 tests — percentile math, NDJSON
+ CSV parse, aggregation, markdown formatting.
Tests: 667 → 697 (+30). Lint clean.
Operator runbook for Phase 06 (NOT executed by this commit):
1. Verify telemetry live via wrangler tail.
2. Run synthetic burst test: npm run burst:synthetic -- --url <prod>
3. Configure Atlas + CF Observability email alerts.
4. 24h soak (extend to 72h on stop-conditions per phase plan).
5. Daily npm run verify:mongo.
6. npm run analyze:soak -- --input <cf-logs.json> → soak-decision.md.
7. PROCEED to Phase 07 if cold-start P95 ≤ 2.5 × BASELINE_COLD_PING_MS;
else execute phase-07-alt-pivot.md (Upstash standby).
Implements the KVStore interface against MongoDB Atlas with full behavioral
parity vs CFKVStore (null-on-missing, swallow-corrupt-JSON, idempotent delete,
throw-on-undefined-putJSON). Not wired into the request path yet — Phase 04
adds dual-write wrappers and factory routing.
- src/db/mongo-client.js: memoized MongoClient + getDb(env). On connect()
reject, nulls both client and connectPromise so next call retries cleanly
(regression-tested). Catches MongoServerSelectionError and emits a
structured warning before rethrow so callers can map to 503.
- src/db/mongo-kv-store.js: KVStore impl. get/getJSON filter on expiresAt
at read time to close the up-to-60s TTL-sweeper stale-read window vs
CFKVStore. list() returns keys WITH prefix preserved (parity — wrapper
in create-store.js:65 strips). Cursor pagination via sorted _id +
limit(N+1), NOT skip(). Lazy ensureIndex per (collection, isolate)
tracked in module-scope Set.
- src/db/mongo-list-cursor.js: extracted cursor encode/decode to keep
mongo-kv-store.js under 200 LOC.
- tests/fakes/fake-mongo.js: Map-backed fake covering the surface needed
by both Phase 02 (KVStore) and Phase 03 (MongoTradesStore).
- tests/db/mongo-kv-store.test.js: 26 tests, including TTL stale-read
regression (1s TTL + time advance), 2-level prefix list regression,
cursor pagination, connect-reject retry, MongoServerSelectionError
structured log.
Tests: 503 → 529 (+26). Lint clean.
Closes deferred phases 04 + 05 of loldle-new-modes plan.
- loldle-ability: 5 guesses, DDragon ability icon as photo. State pins
slot (P/Q/W/E/R) so the same icon shows every turn. Abilities pulled
from DDragon per-champion — same source loldle.net uses at runtime.
- loldle-splash: 4 guesses, random skin splash as photo. Skin pool
scraped from loldle.net bundle (var Ad=[…] — 172 champs × 1939 skins,
non-chroma, matches their splash mode exactly). URLs from Riot
DDragon CDN (no version segment, stable across patches).
- fetch-ddragon-data.js: extended to write all four JSONs in one run.
Shares a single DDragon per-champion fetch cycle (concurrency 10).
- Credits loldle.net + Riot Games in all loldle-family READMEs.
19 new tests (503 total). Lint clean. register:dry reports 12 loldle_*
commands with no conflicts.
Ship two new loldle-family modules mirroring loldle.net's non-classic
modes. Text-only MVP (ability/splash phases stay deferred).
- loldle-emoji: 5 guesses, emoji-sequence clue. Pool derived algorithmically
from classic's champions.json metadata (species/region/resource mapping
table) since loldle.net's bundle has no static emoji pool.
- loldle-quote: 6 guesses, lore-blurb clue. Pool seeded from Data Dragon
champion title + first lore sentence; champion name redacted to ___.
- scripts/fetch-ddragon-data.js: single generator for both JSONs.
- src/util/normalize-name.js: shared lookup helper; loldle/lookup.js
refactored to import it.
35 new tests (484 total passing). Lint clean.
Self-review of the prior cleanup commit caught one omission — src/types.js
(central JSDoc typedefs file: Env, Module, Command, Cron, …) was listed in
the top-level README but absent from docs/architecture.md's src/ tree.
Previously seeds carried hand-curated {category, target, initialHint}.
Now SEEDS is a flat string[] of keywords — at round-start, the model
generates {category, initialHint} on the fly. Benefits:
- adding a seed is trivial (just append a word)
- every round gets a fresh cryptic opener (varies across plays of the
same word)
- HINT STYLE rules apply to the opening hint too, so the initial clue
isn't a definitional giveaway
Implementation:
- prompts.buildStartRoundPrompt(target) — with good/bad examples
- ai-client.generateRoundStart(env, target) — same JSON-in-content
approach as judge(), with defensive fallbacks + redactSecret
- handlers.startFreshGame now async; surfaces roundstart errors via the
existing UPSTREAM_FAIL path
Tests: 449 pass (5 new for generateRoundStart, 1 for roundstart error path).
Production showed: Request timed out after 10000 ms / status 500.
grammY's webhookCallback defaults to 10s — fine for simple handlers but
too tight for twentyq's Workers AI call (Gemma 4 26B cold-starts can
easily exceed 10s). Raise to 25s, leaving 5s headroom under Cloudflare
Workers' 30s wall-clock cap.
Player feedback: hints were too clear — gave away the answer in one or two
turns because the model was leaning on "it is used for X" / category-word
phrasings.
Reworked the hint-style section of the system prompt to force the model
toward indirect, riddle-style, lateral facts. Added good/bad example pairs
(secret="organ") so the model has concrete contrast to pattern-match.
No schema change — tests unaffected (444 pass).
Gemma 4 likely rejects the flat "traditional" tools schema we were sending
(the docs use OpenAI-wrapped shape for this model) — causing env.AI.run to
throw and users to see the "AI service hiccup" reply every turn.
Switch to the universal approach:
- system prompt asks the model for a one-line JSON {is_guess, answer, hint}
- ai-client.extractText handles both Workers-AI and OpenAI response shapes
- parseJudgementJson walks brace-depth to extract JSON from stray prose /
accidental code fences
- logs twentyq_ai_throw / twentyq_ai_unparseable with preview on failure
so future issues surface in wrangler tail immediately
Tests: 7 new (parser + extractText); 444 total pass.
Uses phow2sim /neighbors. Filters out capitalized foreign place names
that leak in from the corpus (e.g. al-Qantara, Nam_Afrin) and requires
tokens look Vietnamese (diacritic or underscore compound) to dodge
pure-ASCII junk like "adiyeh". Samples 3 from the tail after skipping
the top 20% so the hint doesn't give away the answer.
Sigmoid was inherited from semantle where bge-m3's narrow cone (unrelated
pairs at 0.40-0.55) needed spreading. phow2sim cosines span 0.0-0.8
naturally, so a linear map is honest and free of magic constants. Kept
the emoji buckets — they already work well against raw percentages.
format.js was inherited from semantle (bge-m3 transformer) whose raw
cosines live in a narrow 0.4-0.55 band for unrelated words. phow2sim
runs on PhoW2V word2vec — related pairs sit at 0.3-0.5, synonyms at
0.55-0.80 — so the FLOOR=0.4 cutoff was dumping real signal (làng/đất
=0.38, làng/phố=0.38) to a displayed 0.
Retune: FLOOR=0.1, CENTER=0.4, SCALE=6. Now 0.38 → 39, 0.52 → 64, 0.80 → 93.