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.
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).
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.
Doantu now mirrors semantle's pre-Workers-AI shape: a thin fetch wrapper
around /random + /similarity on https://phow2sim.sg.miti99.com (overridable
via PHOW2SIM_API_URL). Drops the local Viet22K wordlist + build script —
the service owns vocabulary now. Promotes commands from protected to
public so they show up in Telegram's native / menu.
BGE embeddings occupy a narrow cone in vector space, so raw cosine of
two unrelated words already sits at ~0.40-0.55. Displaying `raw * 100`
made every random guess read as 40-70% warm, which defeated the warmth
UX.
format.js now applies a normalized sigmoid (FLOOR 0.40, CENTER 0.60,
SCALE 8) to remap raw cosine → displayed 0-100. Unrelated pairs drop
to ≤30, loose relation lands around 40-55, clear synonyms hit 85+, and
exact match stays at 100. Emoji buckets were rebased onto the calibrated
score; formatWarmth lost its sign column (calibrated output is always
non-negative).
render.js rounds once and feeds the integer to both formatWarmth and
warmthEmoji so the display value and bucket stay in sync.
Constants are empirical — retune if swapping to a non-BGE model.
Aligns semantle with doantu so both modules share one Workers AI model.
bge-m3 is multilingual and cheaper (1075 N/M input tokens vs 1841 N/M)
and produces 1024-dim vectors. Updates the api-client default, test
fake-vector dimensions, README, index.js doc comment, and the
wrangler.toml [ai] binding comment (Neurons/day budget recomputed).
Now that both modules run on Workers AI embeddings, drop the legacy
Word2SimError alias, the unused wordlist helpers (getLine, LINE_COUNT,
pickFromPool), and every comment/README section still describing the
removed ConceptNet backend. Fix the bge-small doc typo in semantle/index.js
and align the semantle api-client test fake-vector dim with the real
384-dim output.
Mirror the semantle migration but with @cf/baai/bge-m3 — BAAI's
multilingual embedding model — because the English-only BGE variants
can't produce meaningful Vietnamese vectors (their tokenizer shreds
diacritics into noisy byte-level subwords).
bge-m3 is trained across 194 languages incl. Vietnamese and is
actually cheaper in Neurons (1,075 vs 1,841 per M tokens for
bge-small-en-v1.5). Vocab check reuses the local Viet22K wordlist as
an in-memory Set — O(1) OOV detection, no upstream call.
Also add a test file for the module (mirrors semantle coverage plus
Vietnamese-specific cases: diacritics, multi-syllable compounds).
ConceptNet (api.conceptnet.io) was returning sustained 502s, breaking
every guess with an "Upstream hiccup" reply. Replace with env.AI.run
on @cf/baai/bge-small-en-v1.5 and score guesses by computing cosine
similarity locally against the target vector.
The local google-10k wordlist doubles as the in/out-of-vocabulary set,
so OOV detection is an O(1) Set.has() with no upstream call. The
similarity() response shape is unchanged, so handlers/render/state
stay as-is.
Free on the Workers Free plan: 10k Neurons/day cap, ~0.0037 Neurons
per 2-word guess → ~2.7M guesses/day headroom for this bot.
ConceptNet provides a free public /relatedness endpoint (returns cosine-like
[-1, 1]) and /c/en/{term} for vocabulary check. No random-word endpoint, so
we ship a curated local target pool in wordlist.js (~250 words) and verify
each pick via the concept endpoint with a fallback to an unverified pick.
Each guess now makes two parallel ConceptNet calls (concept + relatedness)
instead of a single word2sim call. Slightly higher latency but zero hosting
cost and no dependency on the self-hosted word2sim instance.
- api-client.js rewritten; UpstreamError replaces Word2SimError (aliased
for backwards compat with older imports).
- wordlist.js added (curated target pool + pickFromPool).
- handlers.js: drops RANDOM_FILTERS (no filtering needed; pool is curated).
- index.js: drops WORD2SIM_API_URL env var; ConceptNet base hardcoded.
- wrangler.toml + .dev.vars.example: drop WORD2SIM_API_URL.
- api-client tests rewritten for ConceptNet shape; total tests 336 → 341.
Giveup already auto-starts a fresh round on next /semantle, so /semantle_new
was redundant. Duplicate guesses now match loldle's behavior: reply with
"🔁 already guessed" and skip the similarity API call (fast-path dedup
against prior word or canonical, with a post-API fallback for different
inputs that canonicalize to the same token).
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.
Broaden `npm run format` / `npm run lint` to biome's full scan (`.`)
instead of a fixed src/tests/scripts list, so root-level files and any
new top-level directories stay formatted. Drop the stale ignore entry
for the deleted champions-data.js.
Column headers now match loldle.net's classic-mode grid verbatim:
Range → Range type, Region → Region(s), Lane → Position(s),
Year → Release year. The champion row header becomes Champion (was
Name). Data field names already matched; only labels diverged.
KV payload cleanup:
- drop lastResultAt from stats (never read)
- drop solved/giveup flags from game state (round is immediately
replaced after finish, making the flags transient noise)
- skip redundant saveGame on winning/giveup/out-of-guesses paths;
startFreshGame overwrites anyway
Code cleanup:
- delete daily.js + daily.test.js (pickDaily/todayUtc were speculative
"future use" — only pickRandom was wired in, inlined into handlers)
- drop the dead switch default in compare.js
- trim file preambles across the module
Docs: rewrite README around current behavior with loldle.net as the
sole data source; update scraper header to match the raw schema.
Drop the in-scraper normalization step — champions.json now mirrors the
exact shape emitted by loldle.net's JS bundle. Records use _id,
championId, championName, arrays for positions/species/regions/
range_type, "Male"/"Female"/"Other" gender strings, and a full
YYYY-MM-DD release_date.
Comparison is schema-aware: multi-value keys accept arrays directly,
the year axis parses YYYY out of the ISO date, and exact compares stay
case-insensitive.
loldle.net's JS bundle ships the complete set of classic-mode axes in
plaintext, so ddragon merging is no longer needed. Scraper now produces
the final schema directly.
Schema changes: drop title, skinCount, image, and genre (ddragon-only).
Replace genre (class tags like Fighter/Mage) with species (Human/Darkin/
Vastayan) — the axis loldle.net actually uses. Promote region to a
multi-value field so multi-region champions compare correctly.
Handlers no longer show "Name — Title" on win/giveup.
- Send a random sticker from WIN/LOSE/GIVEUP pools before the text reply
(errors swallowed so a rotten file_id never blocks the message).
- Win message includes attempt-count flavor ("First try!" / "Sharp!" /
"Close call!" / "Phew — last one!") and elapsed solve time.
- Lose and giveup messages now also include the champion's title.
- Extract stickers.js and flavor.js so handlers.js stays at ~200 LoC.
Reply to any sticker with /stickerid to get its file_id and
file_unique_id. Collects IDs for hard-coded sticker pools in other
modules (upcoming loldle win/lose/giveup reactions).
Private visibility keeps the command out of /help and the Telegram
slash menu.
- Remove /loldle_new; finished rounds (solve/giveup/out-of-guesses)
immediately roll into a fresh round.
- Render guesses as an HTML <pre> monospace table with auto-widthed
label column and a 🎯 Name row (uppercase champion name).
- Year direction uses ⬆️ / ⬇️.
The TCBS `apipubaws.tcbs.com.vn` host returns HTTP 404/500 for every
request, so every ticker resolved as "Unknown stock ticker" and /trade_buy
was unusable. Switch price + symbol resolution to the KBS public endpoint
that vnstock currently defaults to (`kbbuddywts.kbsec.com.vn/iis-server/
investment/stocks/{TICKER}/data_day`). KBS needs no auth, returns JSON,
and is Worker-compatible.
- `prices.fetchStockPrice` now queries KBS with a 14-day lookback window
(covers weekends/holidays) and drops the TCBS-specific ×1000 scaling;
KBS returns real VND.
- `symbols.resolveSymbol` delegates to `fetchStockPrice` for existence
checks — empty `data_day` means unknown ticker.
- Update test fetch stubs to match the `kbsec` host and KBS response
shape (`{ symbol, data_day: [{ c }] }`).
Users expect to scan a single region's upcoming week rather than a daily
fixture board. Reverses the nesting: /lolschedule_week now lists each
major league as a top-level section, then italicised ICT date headers
with that league's matches beneath.
- handlers.js header now reflects fan-out to subscribers, not a single chat.
- README "Time zone" references the correct command names and gains a
Subscribers section; Files section lists subscribers.js.
- formatEventLine's showLeague option is dead in production (renderToday
and renderWeek always group under a league header), so drop it and the
test that covered only the option toggle.
Replaces the single LOLSCHEDULE_CHAT_ID env var with a KV-backed
subscriber list. New commands /lolschedule_subscribe and
/lolschedule_unsubscribe let each chat opt in/out. The cron now fans
out to every subscriber via Promise.allSettled so one blocked chat
cannot break the others.
- Commands renamed: /lol_today → /lolschedule_today, /lol_week → /lolschedule_week.
- Today view groups events under a league header per section.
- Week view nests leagues under each ICT day.
- LEAGUE_ORDER gives tier-1 tournaments priority (Worlds / MSI / First Stand,
then LCK / LPL / LEC / LCS, etc).
- New cron "0 1 * * *" (08:00 ICT) pushes today's major-league schedule to
LOLSCHEDULE_CHAT_ID via the Telegram Bot API. Skips cleanly when chat id
or token is missing, or when today has no major-league matches.
Leaguepedia's anonymous IP rate limit is too aggressive for a bot even
from CF Worker egress (~1–2 req/min), and authenticated Fandom tokens
don't lift it. Switching to the lolesports.com getSchedule endpoint —
the same data feed powering the official site — removes the limit and
provides richer fields: state (unstarted/inProgress/completed), per-team
result.gameWins and outcome, league metadata, bestOf strategy.
Handlers simplify back to cache-first (120 s fresh / 1 h stale fallback)
with no cron needed. Results are filtered to major leagues (LCK, LPL,
LEC, LCS, worlds, msi, first_stand, LCP, CBLOL, EMEA Masters) to keep
the week view under Telegram's 4096-char message limit.
Swaps the best-effort console.warn for JSON log lines emitted via
console.log so Workers Observability + wrangler tail surface the real
cause (HTTP status, API error info, or non-JSON body) when /lol_today
and /lol_week fall into the error branch.
New module exposing /lol_today and /lol_week commands, backed by the
Leaguepedia Cargo API (MatchSchedule table). Renders scores for
played/live matches and ICT times for scheduled ones. Caches range
queries in KV (60s today, 300s week) with stale-fallback on fetch error.
Code fixes:
- trading/handlers + stats-handler: guard ctx.from?.id to prevent
cross-user state corruption when channel posts or inline queries lack
a sender
- trading/prices + trading/symbols: encodeURIComponent on ticker before
interpolating into TCBS API URLs
- trading/stats-handler: parallelize per-stock price fetches with
Promise.allSettled so N-stock portfolios don't stack serial latency
- loldle/handlers: guard target champion lookup against champions.json
refresh drift — start a fresh round or fall back to the stored id
- wordle + loldle: explicitly initialize giveup:false in startFreshGame
for stable state shape
- wordle/lookup: fix stale JSDoc that claimed null return
- biome: ignore auto-generated champions.json / champions-data.js /
words-data.js
- Apply formatter to src/index.js, loldle/handlers.js imports, and
loldle/compare.test.js (previously red)
Docs refresh:
- README: 105+ tests -> 200+; wordle/loldle described as real modules
- architecture: module tree updated, test count 105 -> 200, runtime
~500ms -> ~2s, stub list narrowed to misc only
- codebase-summary: module table rewritten (wordle/loldle now Complete
with real command lists and KV schema); test coverage table updated
- loldle/README: full rewrite matching the current implementation
(was describing the original stub)
- New docs/development-roadmap.md tracking upcoming features
(daily-mode for wordle + loldle, crypto/gold/forex trading, shared
picker util, handler-level tests, coverage reporting, staging env)
Tests: 200/200 passing. Lint: clean.
Replaces the wordle stub with a full implementation mirroring the loldle
module layout: compare/lookup/daily/render/state/handlers/index split,
per-subject KV state, standard 6 guesses, two-pass duplicate-letter
marking.
Commands: /wordle, /wordle_new, /wordle_giveup, /wordle_stats.
Word list (14,855 entries) sourced from dracos's gist
(https://gist.github.com/dracos/dd0668f281e685bad51479e5acaadb93) and
bundled via scripts/build-wordle-data.js. Credits in module README and
generated file headers.
Dispatcher test updated for the new command count (12 → 13).
* build(deps): bump vite and vitest
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) to 8.0.8 and updates ancestor dependency [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies need to be updated together.
Updates `vite` from 5.4.21 to 8.0.8
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.8/packages/vite)
Updates `vitest` from 2.1.9 to 4.1.4
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest)
---
updated-dependencies:
- dependency-name: vite
dependency-version: 8.0.8
dependency-type: indirect
- dependency-name: vitest
dependency-version: 4.1.4
dependency-type: direct:development
...
Signed-off-by: dependabot[bot] <support@github.com>
* feat(loldle): share game state per-chat in groups
Groups and supergroups now share one daily puzzle + one stats counter
across all members. Private chats remain per-user.
- state.js: renamed key arg from userId to subject (user|chat id)
- handlers.js: getSubject(ctx) picks user id in DM, chat id in groups
- /loldle_stats labels scope as "your" vs "group" accordingly
* feat(loldle): add /loldle_new + switch to self-paced rounds
- /loldle_new starts a new random round. If the previous round is not
solved/given-up, it's recorded as a loss (auto-giveup) before rerolling.
- Drop daily-seeded targets: each round picks a uniformly-random champion
(pickRandom in daily.js; pickDaily kept for future use).
- state.js: one active round per subject (no date in key). TTL raised to
7 days; streak = consecutive wins (round-based, not date-based).
- Register /loldle_new in module index; now 8 public loldle commands.
- Tests: add pickRandom cases; bump expected command count to 12.
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: tiennm99 <tiennm99@outlook.com>
Adds loldle module with classic-mode champion guessing. Ports comparison
logic from tiennm99/loldle (lib/classic-mode.js) and bundles champion
data from tiennm99/loldle-data. Adds GH Actions workflow that re-syncs
champions.json on cross-repo dispatch from loldle-data.
- Three public commands: /loldle, /loldle_giveup, /loldle_stats
- Per-user daily state + streak stats in KV (3-day TTL on games)
- champions-data.js wrapper sidesteps Node 24 / esbuild disagreement on
JSON import attributes; generator script + npm run build:loldle-data
- register script now tolerates missing .env.deploy (env-file-if-exists)
so Workers Builds can inject env vars directly
- fix(scripts): escape stray */ in migrate.js docstring that broke node
- 16 new unit tests (compare, daily, lookup); dispatcher test updated
for the new command set
- trading_trades table (migration 0001) persists every buy/sell via optional onTrade callback
- /history [n] command shows caller's last N trades (default 10, max 50), HTML-escaped
- daily cron at 0 17 * * * trims to 1000/user + 10000/global via FIFO delete
- persistence failure logs but does not fail the trade reply
Portfolio schema now uses meta object: { currency, assets, meta: { invested } }.
Migrates old totalvnd field automatically on load. The meta object provides
a clean place for future per-user metadata without polluting the top level.
Replace hardcoded 9-symbol registry with dynamic TCBS-based resolution.
Any VN stock ticker is now resolved on first use and cached in KV
permanently. Portfolio flattened from 4 category maps to single assets
map with automatic migration of old format. Crypto, gold, and currency
exchange disabled with "coming soon" message.
Replace hardcoded 0.5% spread with live buy/sell rates from BIDV bank
API. Buying USD uses bank's sell rate (higher), selling USD uses bank's
buy rate (lower). Reply shows both rates and actual spread percentage.
Topup now only accepts VND — users must convert to get other currencies.
Convert uses a 0.5% bid/ask spread: buying USD costs more VND (ask),
selling USD back gives less VND (bid). Simulates real forex behavior.
Paper trading system with 5 commands (trade_topup, trade_buy,
trade_sell, trade_convert, trade_stats). Supports VN stocks via TCBS,
crypto via CoinGecko, forex via ER-API, and gold via PAX Gold proxy.
Per-user portfolio stored in KV with 60s price caching. 54 new tests.
grammY-based bot with a module plugin system loaded from the MODULES env
var. Three command visibility levels (public/protected/private) share a
unified command namespace with conflict detection at registry build.
- 4 initial modules (util, wordle, loldle, misc); util fully implemented,
others are stubs proving the plugin system end-to-end
- util: /info (chat/thread/sender ids) + /help (pure renderer over the
registry, HTML parse mode, escapes user-influenced strings)
- KVStore interface with CFKVStore and a per-module prefixing factory;
getJSON/putJSON convenience helpers; other backends drop in via one file
- Webhook at POST /webhook with secret-token validation via grammY's
webhookCallback; no admin HTTP surface
- Post-deploy register script (npm run deploy = wrangler deploy && node
--env-file=.env.deploy scripts/register.js) for setWebhook and
setMyCommands; --dry-run flag for preview
- 56 vitest unit tests across 7 suites covering registry, db wrapper,
dispatcher, help renderer, validators, and HTML escaper
- biome for lint + format; phased implementation plan under plans/