From 629b513c9152134e5f9b74eb342812069ab65092 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 10 Oct 2026 09:29:48 +0700 Subject: [PATCH] feat(games): move /wordle and /loldle to the web games and add /loldledaily /wordle and /loldle now send the wordle and loldle BotFather games in an unlimited mode: each player gets their own word or champion, saved after every guess, with a New game button. Cards the bot did not record as unlimited, including the daily cards, play the daily puzzle. The helper commands keep working on the player's round. /loldledaily mirrors /wordledaily: one champion per day at 07:00 ICT, subscribe and unsubscribe, the morning recap and the live group results. The daily and unlimited machinery is shared in util/guessgame, the old in-chat wordle module is removed, and a one-time startup migration carries each player's stats and unfinished round over without changing old data. --- .env.example | 12 +- README.md | 9 +- cmd/server/command_menu_test.go | 9 + cmd/server/main.go | 43 +- cmd/server/main_test.go | 55 ++ compose.yml | 3 +- docs/deploy-coolify-selfhosted.md | 7 +- docs/loldle.md | 235 +++++++ docs/wordle.md | 140 ++++ docs/wordledaily.md | 53 +- internal/modules/loldle/api_test.go | 450 +++++++++++++ internal/modules/loldle/callback.go | 43 ++ internal/modules/loldle/champions.go | 25 +- internal/modules/loldle/command.go | 287 ++++++++ internal/modules/loldle/command_test.go | 254 +++++++ internal/modules/loldle/data/champions.json | 344 ++++++++++ internal/modules/loldle/handlers.go | 298 -------- internal/modules/loldle/handlers_test.go | 183 ----- internal/modules/loldle/helpers_test.go | 260 +++++++ internal/modules/loldle/http_api.go | 407 +++++++++++ internal/modules/loldle/loldle.go | 319 ++++++++- internal/modules/loldle/migrate.go | 134 ++++ internal/modules/loldle/migrate_test.go | 151 +++++ internal/modules/loldle/rules.go | 142 ++++ internal/modules/loldle/state.go | 124 +--- internal/modules/loldle/state_test.go | 160 ----- internal/modules/loldle/web/app.css | 282 ++++++++ internal/modules/loldle/web/app.js | 635 ++++++++++++++++++ internal/modules/loldle/web/index.html | 85 +++ internal/modules/loldle/web/web.go | 10 + internal/modules/util/guessgame/cards.go | 195 ++++++ internal/modules/util/guessgame/claims.go | 115 ++++ internal/modules/util/guessgame/daily.go | 132 ++++ .../modules/util/guessgame/daily_commands.go | 130 ++++ internal/modules/util/guessgame/daily_play.go | 334 +++++++++ .../guessgame/daily_puzzle.go} | 65 +- .../modules/util/guessgame/daily_recap.go | 168 +++++ .../guessgame/daily_store.go} | 124 ++-- .../guessgame/daily_summary.go} | 140 ++-- .../modules/util/guessgame/guessgame_test.go | 351 ++++++++++ internal/modules/util/guessgame/migrate.go | 71 ++ internal/modules/util/guessgame/rounds.go | 317 +++++++++ internal/modules/util/guessgame/rules.go | 67 ++ .../modules/util/htmlgame/htmlgame_test.go | 17 + internal/modules/util/htmlgame/http.go | 11 +- internal/modules/wordle/compare.go | 24 - internal/modules/wordle/compare_test.go | 90 --- internal/modules/wordle/handlers.go | 235 ------- internal/modules/wordle/handlers_test.go | 216 ------ internal/modules/wordle/lookup.go | 26 - internal/modules/wordle/lookup_test.go | 49 -- internal/modules/wordle/pick_random.go | 26 - internal/modules/wordle/pick_random_test.go | 51 -- internal/modules/wordle/render.go | 29 - internal/modules/wordle/state.go | 125 ---- internal/modules/wordle/state_test.go | 151 ----- internal/modules/wordle/wordle.go | 48 -- internal/modules/wordle/words.go | 7 - internal/modules/wordle/words_test.go | 24 - internal/modules/wordledaily/callback.go | 70 +- internal/modules/wordledaily/callback_test.go | 28 +- internal/modules/wordledaily/command.go | 270 +++++--- internal/modules/wordledaily/helpers_test.go | 21 +- internal/modules/wordledaily/http_api.go | 223 ++++-- internal/modules/wordledaily/migrate.go | 142 ++++ .../modules/wordledaily/migrate_mongo_test.go | 38 ++ internal/modules/wordledaily/play.go | 369 ---------- internal/modules/wordledaily/play_test.go | 51 +- internal/modules/wordledaily/puzzle_test.go | 33 +- internal/modules/wordledaily/recap.go | 169 ----- internal/modules/wordledaily/recap_test.go | 41 +- internal/modules/wordledaily/rules.go | 71 ++ internal/modules/wordledaily/score_test.go | 3 +- internal/modules/wordledaily/summary_test.go | 72 +- internal/modules/wordledaily/token.go | 66 -- .../modules/wordledaily/unlimited_test.go | 571 ++++++++++++++++ internal/modules/wordledaily/web/app.css | 3 + internal/modules/wordledaily/web/app.js | 95 ++- internal/modules/wordledaily/web/index.html | 25 +- internal/modules/wordledaily/wordledaily.go | 206 +++--- 80 files changed, 7965 insertions(+), 3099 deletions(-) create mode 100644 docs/loldle.md create mode 100644 docs/wordle.md create mode 100644 internal/modules/loldle/api_test.go create mode 100644 internal/modules/loldle/callback.go create mode 100644 internal/modules/loldle/command.go create mode 100644 internal/modules/loldle/command_test.go delete mode 100644 internal/modules/loldle/handlers.go delete mode 100644 internal/modules/loldle/handlers_test.go create mode 100644 internal/modules/loldle/helpers_test.go create mode 100644 internal/modules/loldle/http_api.go create mode 100644 internal/modules/loldle/migrate.go create mode 100644 internal/modules/loldle/migrate_test.go create mode 100644 internal/modules/loldle/rules.go create mode 100644 internal/modules/loldle/web/app.css create mode 100644 internal/modules/loldle/web/app.js create mode 100644 internal/modules/loldle/web/index.html create mode 100644 internal/modules/loldle/web/web.go create mode 100644 internal/modules/util/guessgame/cards.go create mode 100644 internal/modules/util/guessgame/claims.go create mode 100644 internal/modules/util/guessgame/daily.go create mode 100644 internal/modules/util/guessgame/daily_commands.go create mode 100644 internal/modules/util/guessgame/daily_play.go rename internal/modules/{wordledaily/puzzle.go => util/guessgame/daily_puzzle.go} (59%) create mode 100644 internal/modules/util/guessgame/daily_recap.go rename internal/modules/{wordledaily/store.go => util/guessgame/daily_store.go} (62%) rename internal/modules/{wordledaily/summary.go => util/guessgame/daily_summary.go} (66%) create mode 100644 internal/modules/util/guessgame/guessgame_test.go create mode 100644 internal/modules/util/guessgame/migrate.go create mode 100644 internal/modules/util/guessgame/rounds.go create mode 100644 internal/modules/util/guessgame/rules.go delete mode 100644 internal/modules/wordle/compare.go delete mode 100644 internal/modules/wordle/compare_test.go delete mode 100644 internal/modules/wordle/handlers.go delete mode 100644 internal/modules/wordle/handlers_test.go delete mode 100644 internal/modules/wordle/lookup.go delete mode 100644 internal/modules/wordle/lookup_test.go delete mode 100644 internal/modules/wordle/pick_random.go delete mode 100644 internal/modules/wordle/pick_random_test.go delete mode 100644 internal/modules/wordle/render.go delete mode 100644 internal/modules/wordle/state.go delete mode 100644 internal/modules/wordle/state_test.go delete mode 100644 internal/modules/wordle/wordle.go delete mode 100644 internal/modules/wordle/words.go delete mode 100644 internal/modules/wordle/words_test.go create mode 100644 internal/modules/wordledaily/migrate.go create mode 100644 internal/modules/wordledaily/migrate_mongo_test.go delete mode 100644 internal/modules/wordledaily/play.go delete mode 100644 internal/modules/wordledaily/recap.go create mode 100644 internal/modules/wordledaily/rules.go delete mode 100644 internal/modules/wordledaily/token.go create mode 100644 internal/modules/wordledaily/unlimited_test.go diff --git a/.env.example b/.env.example index 2a98c85..a276319 100644 --- a/.env.example +++ b/.env.example @@ -14,8 +14,9 @@ MONGO_DATABASE=tiennm99bot # ============================ Operational ========================= # Comma-separated module list. Empty = load every module, including any module # added later — so list them explicitly when a deployment should only gain a new -# module deliberately. -MODULES=util,misc,random,amlich,wordle,loldle,noitu,lol,stock,gold,coin,stats,monkeyd,sticker,alias,blacklist,weather +# module deliberately. wordledaily serves /wordle too; the retired name wordle +# still loads it. +MODULES=util,misc,random,amlich,wordledaily,loldle,noitu,lol,stock,gold,coin,stats,monkeyd,sticker,alias,blacklist,weather # Telegram user id for owner-only commands. OWNER_ID= # Comma-separated admin Telegram user ids. @@ -69,6 +70,13 @@ NOITU_GAME_SECRET= # docs/wordledaily.md. Secret. WORDLEDAILY_GAME_SECRET= +# Optional secret (at least 32 bytes) for the loldle game: it signs game links +# and keys the LoLdle Daily champion order. Unset = derived from +# TELEGRAM_BOT_TOKEN. A value shorter than 32 bytes disables the web game; the +# in-chat /loldle commands keep working. Served under +# /games/loldle/. See docs/loldle.md. Secret. +LOLDLE_GAME_SECRET= + # ====================== Leave UNSET on self-host ================== # Defaults are correct for self-host: # KV_PROVIDER — auto-selects mongodb because MONGO_URL is set diff --git a/README.md b/README.md index 767a837..529dafa 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,9 @@ Atlas via long polling and an in-process cron scheduler. | `misc` | `/ping`, `/ping_stats` (admin), `/ff` (admin), `/xlt1`, `/giaxang` (Petrolimex retail fuel prices), `/the_answer` (owner), `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers | | `random` | Pick one comma-separated option at random: `/random` (text), `/wheelofnames` (wheel GIF), `/gacha` (card-pack wish MP4; options are 5★ by default, prefix `4*` or `3*`), `/genshin` (unlisted; the same wish as a Genshin-style meteor). The animations use the bundled [renderer](renderer/README.md) service | | `amlich` | Vietnamese lunar calendar: `/amlich` (dương lịch → âm lịch, defaults to today), `/duonglich` (âm lịch → dương lịch, `nhuan` flag for leap months); dates accept `d`, `d/m`, or `d/m/yyyy` — missing parts fill from today in the input's calendar. Years 1800–2199 only | -| `wordle` | Daily Wordle game: `/wordle [word]`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` | -| `wordledaily` | Wordle Daily HTML5 game: one shared puzzle a day for everyone, new at 07:00 ICT. `/wordledaily` sends the card; Play opens a board page this bot serves, progress and stats are kept per player, and a win goes to Telegram's high-score table. In a group the bot keeps one live results message per day with spoiler-free colour grids. `/wordledaily_subscribe` / `/wordledaily_unsubscribe` toggle a 07:00 ICT push of the new card plus yesterday's group results. Needs `GAME_BASE_URL`. See [docs/wordledaily.md](docs/wordledaily.md) | +| `wordledaily` | Wordle as an HTML5 game, in two modes behind the BotFather game `wordle`. **Unlimited:** `/wordle` sends a card for the player's own random rounds (New game on the page starts the next); `/wordle [word]`, `/wordle_new`, `/wordle_giveup` and `/wordle_stats` play and read the same round from the chat. **Wordle Daily:** one shared puzzle a day for everyone, new at 07:00 ICT; `/wordledaily` sends the card, progress and stats are kept per player, and a win goes to Telegram's high-score table. In a group the bot keeps one live results message per day with spoiler-free colour grids. `/wordledaily_subscribe` / `/wordledaily_unsubscribe` toggle a 07:00 ICT push of the new card plus yesterday's group results. The page needs `GAME_BASE_URL`; without it `/wordle` plays in the chat. See [docs/wordle.md](docs/wordle.md) and [docs/wordledaily.md](docs/wordledaily.md) | | `noitu` | Nối từ HTML5 game against the other members of a group (`/noitu`, groups only: everyone who presses Play on that card joins one room, last player standing wins; in a private chat it points to `/noitubot`), or vs the bot (`/noitubot`, its own `noitubot` BotFather game); `/noitutop` shows the group's leaderboard of room games (wins, best game, games played). Needs `GAME_BASE_URL`. Play opens a page this bot serves; words are checked against a Vietnamese dictionary derived from [Wiktionary tiếng Việt](https://vi.wiktionary.org/) (CC BY-SA 4.0, see [its attribution](internal/modules/noitu/dict/data/ATTRIBUTION.md)), and scores go to Telegram's high-score table. See [docs/noitu-game.md](docs/noitu-game.md) | -| `loldle` | League-of-Legends "guess the champion": `/loldle [champion]`, `/loldle_giveup`, `/loldle_stats`, `/loldle_setmax` (owner) | +| `loldle` | League of Legends "guess the champion" from seven attributes, as an HTML5 game behind the BotFather game `loldle`. **Unlimited:** `/loldle` sends a card for the player's own random rounds; `/loldle [champion]`, `/loldle_giveup`, `/loldle_stats` play and read the same round from the chat, and `/loldle_setmax` (owner) sets a chat's round length. **LoLdle Daily:** one shared champion a day, new at 07:00 ICT, like Wordle Daily: `/loldledaily`, live spoiler-free group results, `/loldledaily_subscribe` / `/loldledaily_unsubscribe` for the 07:00 push. The page needs `GAME_BASE_URL`; without it `/loldle` plays in the chat. See [docs/loldle.md](docs/loldle.md) | | `lol` | Pro-match schedule (`/lol [date]`, `/lol_tomorrow`, `/lol_this_week`, `/lol_next_week`), per-chat digest opt-in (`/lol_subscribe`, `/lol_unsubscribe`) + daily push at 08:00 ICT | | `stock` | VN-stocks paper trading: `/stock_price`, `/stock_info`, `/stock_events`, `/stock_topup`, `/stock_buy`, `/stock_sell`, `/stock_cash_dividend`, `/stock_share_dividend`, `/stock_portfolio` | | `gold` | Gold paper trading (VNAppMob SJC buy/sell VND/luong): `/gold_price`, `/gold_topup`, `/gold_buy`, `/gold_sell`, `/gold_portfolio` | @@ -234,7 +233,7 @@ all. ``` cmd/server/ entrypoint (long polling + in-process cron + HTTP server) -internal/server/ HTTP routes (/ health, plus module routes such as /games/noitu/ and /games/wordledaily/; cron has no HTTP route) +internal/server/ HTTP routes (/ health, plus module routes such as /games/noitu/, /games/wordledaily/ and /games/loldle/; cron has no HTTP route) internal/telegram/ Telegram long-polling bot wrapper internal/cron/ in-process cron scheduler (UTC) internal/deploynotify/ startup DM to the owner with the deployed commit @@ -254,7 +253,9 @@ compose.yml Coolify self-host stack (bot + renderer services) - [Blacklist](docs/blacklist.md) - [Sticker pack](docs/sticker-packs.md) - [Nối từ game](docs/noitu-game.md) +- [Wordle (unlimited)](docs/wordle.md) - [Wordle Daily](docs/wordledaily.md) +- [LoLdle](docs/loldle.md) - [Lunar calendar algorithm and known issues](docs/amlich-known-issues.md) - [Agent and contributor rules](AGENTS.md) diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index 8bf820d..302d703 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -71,6 +71,8 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { "gacha": "", "lol": "[date]", "loldle": "[champion]", + "loldle_giveup": "", + "loldle_stats": "", "monkeyd_crawl": " [font_size]", "monkeyd_tags": "", "noitu": "", @@ -107,10 +109,17 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { "tth": "[target...]", "wheelofnames": "", "wordle": "[word]", + "wordle_new": "", + "wordle_giveup": "", + "wordle_stats": "", "wordledaily": "", "wordledaily_subscribe": "", "wordledaily_unsubscribe": "", + + "loldledaily": "", + "loldledaily_subscribe": "", + "loldledaily_unsubscribe": "", } menu := botCommandMenu(reg) diff --git a/cmd/server/main.go b/cmd/server/main.go index 26ad3a3..0a9fad0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -41,7 +41,6 @@ import ( "github.com/tiennm99/tiennm99bot/internal/modules/stock" "github.com/tiennm99/tiennm99bot/internal/modules/util" "github.com/tiennm99/tiennm99bot/internal/modules/weather" - "github.com/tiennm99/tiennm99bot/internal/modules/wordle" "github.com/tiennm99/tiennm99bot/internal/modules/wordledaily" "github.com/tiennm99/tiennm99bot/internal/server" "github.com/tiennm99/tiennm99bot/internal/storage" @@ -101,10 +100,9 @@ func factories() map[string]modules.Factory { "random": random.New, "amlich": amlich.New, "monkeyd": monkeyd.New, - "wordle": wordle.New, wordledaily.ShortName: wordledaily.New, noitu.ShortName: noitu.New, - "loldle": loldle.New, + loldle.ShortName: loldle.New, lol.CollectionName: lol.New, coin.CollectionName: coin.New, "gold": gold.New, @@ -152,6 +150,15 @@ func main() { if err := lol.InitStore(rootCtx, provider.Collection(lol.CollectionName)); err != nil { log.Fatal("lol storage init failed", "err", err) } + // The game migrations run whatever MODULES says, like the stats one, so + // a module enabled later still finds its players carried over. + if err := wordledaily.InitStore(rootCtx, provider.Collection(wordledaily.LegacyShortName), + provider.Collection(wordledaily.ShortName), provider.Collection(systemstate.CollectionName)); err != nil { + log.Fatal("wordledaily storage init failed", "err", err) + } + if err := loldle.InitStore(rootCtx, provider.Collection(loldle.ShortName), provider.Collection(systemstate.CollectionName)); err != nil { + log.Fatal("loldle storage init failed", "err", err) + } b, err := telegram.NewBot(cfg.TelegramBotToken) if err != nil { @@ -384,7 +391,7 @@ func loadConfig() config { TelegramBotToken: envMap["TELEGRAM_BOT_TOKEN"], SourceCommit: envMap["SOURCE_COMMIT"], BotUsername: strings.TrimPrefix(strings.TrimSpace(envMap["BOT_USERNAME"]), "@"), - Modules: splitCSV(envMap["MODULES"]), + Modules: normalizeModules(splitCSV(envMap["MODULES"])), BotOwnerID: parseInt64(envMap["OWNER_ID"]), AdminUserIDs: parseInt64Set(envMap["ADMIN_IDS"]), KVProvider: envMap["KV_PROVIDER"], @@ -408,6 +415,34 @@ func splitCSV(s string) []string { return out } +// moduleAliases maps retired catalog keys to the module that took over +// their commands, so a MODULES value written before the change keeps +// loading them. +var moduleAliases = map[string]string{ + wordledaily.LegacyShortName: wordledaily.ShortName, +} + +// normalizeModules replaces retired module names in a MODULES list with +// their successors, dropping duplicates that creates. +func normalizeModules(names []string) []string { + out := make([]string, 0, len(names)) + seen := map[string]bool{} + for _, name := range names { + if to, ok := moduleAliases[name]; ok { + log.Warn("MODULES names a retired module; loading its successor", "module", name, "successor", to) + name = to + } + if !seen[name] { + seen[name] = true + out = append(out, name) + } + } + if len(out) == 0 { + return nil + } + return out +} + // parseInt64 returns 0 (the "unset" sentinel) when s is empty or invalid. // Telegram user IDs are positive int64 so 0 is unambiguously "no value". func parseInt64(s string) int64 { diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index 0f4039a..9a4bf2d 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -146,6 +146,61 @@ func TestFactoriesRegistersWordleDailyDisabledByDefault(t *testing.T) { } } +// loldle is in the catalog and, with GAME_BASE_URL unset, registers its +// unlimited and daily commands and its game, but no route and no cron. +func TestFactoriesRegistersLoldleDisabledByDefault(t *testing.T) { + t.Setenv("GAME_BASE_URL", "") + t.Setenv("LOLDLE_GAME_SECRET", "") + reg, err := modules.Build([]string{"loldle"}, factories(), storage.NewMemoryProvider(), modules.BuildOptions{}) + if err != nil { + t.Fatalf("Build loldle: %v", err) + } + for _, name := range []string{"loldle", "loldle_giveup", "loldle_stats", "loldle_setmax", "loldledaily", "loldledaily_subscribe", "loldledaily_unsubscribe"} { + if _, ok := reg.AllCommands[name]; !ok { + t.Fatalf("missing command %s", name) + } + } + if routes := serverRoutes(reg); len(routes) != 0 { + t.Fatalf("disabled game exposes routes: %+v", routes) + } + for _, m := range reg.Modules { + if len(m.Crons) != 0 { + t.Fatalf("disabled game registers crons: %+v", m.Crons) + } + } +} + +// The in-chat wordle module was folded into wordledaily: a MODULES value +// naming it loads wordledaily, once, with every /wordle command. +func TestNormalizeModules_MapsRetiredWordleToWordleDaily(t *testing.T) { + for in, want := range map[string]string{ + "wordle": "wordledaily", + "util,wordle,loldle": "util wordledaily loldle", + "wordle,wordledaily,misc": "wordledaily misc", + "wordledaily,wordle": "wordledaily", + } { + if got := strings.Join(normalizeModules(splitCSV(in)), " "); got != want { + t.Errorf("normalizeModules(%q) = %q, want %q", in, got, want) + } + } + if normalizeModules(nil) != nil { + t.Fatal("empty MODULES must stay empty: it loads every module") + } + t.Setenv("GAME_BASE_URL", "") + reg, err := modules.Build(normalizeModules([]string{"wordle"}), factories(), storage.NewMemoryProvider(), modules.BuildOptions{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + for _, name := range []string{"wordle", "wordle_new", "wordle_giveup", "wordle_stats", "wordledaily"} { + if _, ok := reg.AllCommands[name]; !ok { + t.Fatalf("missing command %s", name) + } + } + if _, ok := factories()["wordle"]; ok { + t.Fatal("the retired wordle module is still in the catalog") + } +} + // An empty MODULES loads every module, so a command name that collides with an // existing module surfaces here as a test failure rather than as a startup // crash on deploy. diff --git a/compose.yml b/compose.yml index 50e29a0..486127a 100644 --- a/compose.yml +++ b/compose.yml @@ -25,9 +25,10 @@ services: # GOLD_VNAPP_API_KEY: ${GOLD_VNAPP_API_KEY:-} # VNAppMob key — SECRET; empty = fetched and cached in Mongo # BOT_USERNAME: ${BOT_USERNAME:-} # bot username without "@"; empty = getMe at startup # STICKER_PACK_NAME: ${STICKER_PACK_NAME:-} # /addsticker set; empty = stickers_by_ - # GAME_BASE_URL: ${GAME_BASE_URL:-} # public https base routed to this service's :8080; empty = noitu and wordledaily games disabled + # GAME_BASE_URL: ${GAME_BASE_URL:-} # public https base routed to this service's :8080; empty = noitu, wordledaily and loldle web games disabled # NOITU_GAME_SECRET: ${NOITU_GAME_SECRET:-} # >= 32 bytes, signs game links — SECRET; empty = derived from the bot token # WORDLEDAILY_GAME_SECRET: ${WORDLEDAILY_GAME_SECRET:-} # >= 32 bytes, signs wordledaily links and keys its answers — SECRET; empty = derived from the bot token + # LOLDLE_GAME_SECRET: ${LOLDLE_GAME_SECRET:-} # >= 32 bytes, signs loldle links and keys its daily champions — SECRET; empty = derived from the bot token # LOG_LEVEL: ${LOG_LEVEL:-info} # debug|info|warn|error # --- Fixed by this stack (not Coolify settings) --- diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md index 4dd5c23..483e928 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -39,15 +39,16 @@ Copy [`.env.example`](../.env.example) → `.env` (gitignored) and fill in. | `TELEGRAM_BOT_TOKEN` | ✅ | from @BotFather; startup fails without it | | `MONGO_URL` | ✅ | Atlas SRV string **incl. credentials** — secret, never logged | | `MONGO_DATABASE` | ✅ | e.g. `tiennm99bot` | -| `MODULES` | optional | CSV; empty = all modules, including any added later | +| `MODULES` | optional | CSV; empty = all modules, including any added later. The retired `wordle` name loads `wordledaily`, which now serves `/wordle` | | `OWNER_ID` | optional | Telegram user id for owner-only commands, the deploy DM, and the `/addsticker` pack owner. Unset = owner-only commands are denied and `/addsticker` refuses | | `ADMIN_IDS` | optional | CSV of Telegram user ids for admin-only commands | | `BOT_USERNAME` | optional | the bot's Telegram username, without `@`; unset = asked from Telegram (`getMe`) once at startup. Used for the default sticker pack name and the lol User-Agent | | `STICKER_PACK_NAME` | optional | set `/addsticker` writes to; default `stickers_by_`. See [sticker packs](sticker-packs.md) | | `LOL_PANDASCORE_TOKEN` | optional | PandaScore API token for the lol module (free tier) — secret, never logged; without it every `/lol*` fetch fails (stale cache may still serve briefly) | -| `GAME_BASE_URL` | optional | public `https://` base routed to the bot's `:8080`, e.g. `https://noitu.example.com`; unset or invalid = the `noitu` and `wordledaily` games are disabled. See [the noitu game](noitu-game.md) and [Wordle Daily](wordledaily.md) | +| `GAME_BASE_URL` | optional | public `https://` base routed to the bot's `:8080`, e.g. `https://noitu.example.com`; unset or invalid = the `noitu`, `wordledaily` and `loldle` web games are disabled. See [the noitu game](noitu-game.md), [Wordle Daily](wordledaily.md) and [LoLdle](loldle.md) | | `NOITU_GAME_SECRET` | optional | at least 32 bytes; signs game links — secret. Unset = derived from `TELEGRAM_BOT_TOKEN`, so rotating the token invalidates open game links | | `WORDLEDAILY_GAME_SECRET` | optional | at least 32 bytes; signs `wordledaily` game links and keys its daily answer order — secret. Unset = derived from `TELEGRAM_BOT_TOKEN`. Puzzles already started keep their stored answer when it changes. See [Wordle Daily](wordledaily.md) | +| `LOLDLE_GAME_SECRET` | optional | at least 32 bytes; signs `loldle` game links and keys its daily champion order — secret. Unset = derived from `TELEGRAM_BOT_TOKEN`. See [LoLdle](loldle.md) | | `RENDERER_URL` | leave unset | base URL of the animation renderer; fixed by `compose.yml` to the bundled renderer (`http://renderer:3000`), so a Coolify value is ignored | | `LOG_LEVEL` | optional | `debug`, `info` (default), `warn`, or `error`; logs are JSON on stdout | | `GOLD_VNAPP_API_KEY` | optional | VNAppMob key — secret; empty = the gold module fetches one and caches it in MongoDB | @@ -176,7 +177,7 @@ MP4, with the same text fallback. is enabled:** attach a domain to the `bot` service with port 8080 (in Coolify, `https://noitu.example.com:8080`), and set `GAME_BASE_URL` to that domain without the port. Coolify's proxy terminates TLS and forwards to the - container's 8080; only `/games/noitu/`, `/games/wordledaily/` and the health text are served there. + container's 8080; only `/games/noitu/`, `/games/wordledaily/`, `/games/loldle/` and the health text are served there. Never attach a domain to the `renderer` service. 4. **Exactly one replica.** Telegram permits only one `getUpdates` consumer per bot token; a second poller gets HTTP 409, and a second in-process scheduler diff --git a/docs/loldle.md b/docs/loldle.md new file mode 100644 index 0000000..ca64259 --- /dev/null +++ b/docs/loldle.md @@ -0,0 +1,235 @@ +# LoLdle + +`loldle` is a League of Legends "guess the champion" game, after loldle.net's +classic mode. Each guess is scored on seven attributes of the champion, and +the colours say how close it is. It runs as a Telegram HTML5 game behind the +one BotFather game `loldle`, in two modes: + +- **Unlimited:** each player has their own rounds, each with a random + champion. `/loldle` sends the card; the page's **New game** starts the next + round. The chat commands play the same rounds. +- **LoLdle Daily:** one shared champion a day for everyone, new at 07:00 ICT, + like [Wordle Daily](wordledaily.md): live group results, a 07:00 recap and + push, and a high score on the card. + +The two modes share the engine in `internal/modules/util/guessgame` with +[Wordle](wordle.md) and [Wordle Daily](wordledaily.md). + +## Commands + +| Command | What it does | +|---|---| +| `/loldle` | Sends an unlimited card. Without the web game it shows the caller's board in the chat instead | +| `/loldle ` | Plays the guess on the caller's round: the attribute rows, then `Guess n/max.`, or a sticker, a flavour line, the time and the streak at the end | +| `/loldle_giveup` | Ends the caller's round as a loss, with the give-up sticker, and reveals the champion | +| `/loldle_stats` | The caller's unlimited stats | +| `/loldle_setmax <1-10>` | Owner only. Sets the length of unlimited rounds started in this chat from now on | +| `/loldledaily` | Sends today's LoLdle Daily card | +| `/loldledaily_subscribe` | Opts the chat or forum topic into the 07:00 ICT push | +| `/loldledaily_unsubscribe` | Opts it out | + +Champion names are matched loosely: case, spaces and punctuation do not +matter (`kaisa` is Kai'Sa), and a unique prefix is enough (`aat` is Aatrox). +An ambiguous prefix, an unknown name and a champion already guessed this +round are refused without using a guess. + +Each player has one unlimited round, the same in every chat, and the page and +the chat commands play it together. In a group every member plays their own +round. When a round is over, the next `/loldle` or `/loldle ` starts +a new one, as the in-chat game always did. Channels are refused, and so are +anonymous group admins and posts sent on behalf of a chat, as for +[Wordle](wordle.md#commands). + +## Setup + +1. **BotFather:** create a game with `/newgame` and the short name `loldle`. +2. **Public URL:** the same `GAME_BASE_URL` as the other games. The page is + served at `/games/loldle/`. +3. **Environment:** `LOLDLE_GAME_SECRET` is optional and must be at least 32 + bytes. It derives the link-signing key (`tiennm99bot/loldle/token/v1`) and + the daily answer key (`tiennm99bot/loldle/answer/v1`). Unset, the bot token + is the root key. +4. Keep `loldle` in `MODULES`, or leave `MODULES` empty. + +Without `GAME_BASE_URL`, a long enough secret or storage, the web game is +disabled: `/loldle` and its helpers still play in the chat, `/loldledaily` +and `/loldledaily_subscribe` answer "LoLdle isn't configured on this server.", +Play shows the same alert, and no route or cron exists. +`/loldledaily_unsubscribe` always works. + +## The comparison + +| Column | Compared | Colours | +|---|---|---| +| Gender | exact | green or red | +| Species | set | green when equal, yellow when they overlap, red otherwise | +| Range type | set | as above | +| Resource | exact | green or red | +| Region(s) | set | as above | +| Position(s) | set | as above | +| Release year | year | green when equal; red with ↑ when the answer is newer, ↓ when older | + +The server stores each guess as the champion's name and seven marks in that +order: `c` correct, `p` partial, `w` wrong, and for the year `u` (answer +newer) or `d` (answer older). The page and the chat boards rebuild the cells +from the marks and the guessed champion's own values, so the answer's values +never leave the server. + +## The page + +Plain HTML, CSS and JavaScript embedded in the binary, no build step, under +the same strict CSP as the other games plus one image origin, +`https://ddragon.leagueoflegends.com`, for champion icons. It is dark, +mobile-first and works in Telegram's in-app browsers. + +- **Header:** "LoLdle", the mode (`Daily #N · date` or `Unlimited · Round N`), + the player, and a guess counter. +- **Search:** an ARIA combobox. Typing lists up to 8 champions, those whose + name starts with the text first, then those that contain it, without the + ones already guessed; matching ignores case, spaces and punctuation. Arrow + keys move, Enter or a tap guesses, Escape or a tap outside closes. The list + comes from `champions.json`: names and icon ids, nothing else. If it fails + to load, the page retries with backoff (1 s doubling to 30 s, and at once + on the next keystroke), says so on Enter, and searches the open query + again when it arrives. +- **Board:** a Champion column with icon and name, then the seven columns, + newest guess first. On a narrow screen it scrolls sideways with the + Champion column pinned, and cells shrink at 768 and 480 pixels. A new row's + cells appear one after another (80 ms apart), except under + `prefers-reduced-motion`. +- **Icons:** `https://ddragon.leagueoflegends.com/cdn/img/champion/tiles/_0.jpg`. + An icon that fails to load is replaced by the champion's initial. +- **Screen readers:** each cell is labelled with its column, value, result + and, for the year, which way the answer lies; a new guess is announced the + same way. +- **End panel:** "You got it!" with the guess count, or "Game Over" with the + champion's icon, name and title; then the stats and guess distribution. + Daily adds a countdown to 07:00 ICT, **Share** and **Copy result**; + unlimited adds **New game** and a pointer to `/loldledaily`. +- **New game** in the header asks for a second tap when the round has + guesses. + +## Unlimited rounds + +- **Length:** a round allows 8 guesses unless the chat it starts in has a + `/loldle_setmax` value (1–10). A round keeps the length it started with. + - From a command, the chat is the command's chat (a group, or the private + chat). + - From the page (its first round, or **New game**), the chat is the card's. + An inline card uses 8. +- **New game** and an abandoned round follow [unlimited Wordle](wordle.md#rounds): + giving up a round with a guess counts as a loss and the page shows its + champion, an unguessed round is replaced, and a stale round number starts + nothing. +- **Stats:** played, wins, current streak (consecutive wins), best streak and + the guess distribution (up to 10). +- **No high score:** unlimited wins are not reported with `setGameScore`; a + round's length can differ by chat, so the scores would not compare. +- **Data refresh:** a round whose champion was removed from the data is + replaced without counting. + +Which card opens which mode works as for Wordle: `/loldle` records its cards +(`ucard::`, refreshed once a day when played), and every other +card, `/loldledaily`, the push, an inline share, a card unplayed for 30 days or +a forwarded copy of a `/loldle` card, opens the daily champion. The cron `loldle_unlimited_cards` (`40 20 * * *` +UTC) forgets old records. See [unlimited Wordle](wordle.md#which-card-opens-which-mode). + +## LoLdle Daily + +Everything works as in [Wordle Daily](wordledaily.md), with these values: + +- **Puzzle numbering:** puzzle #1 is 9 October 2026, the same epoch as Wordle + Daily, so both games share their day number. +- **Answers:** the champions sorted by name, walked in a keyed permutation per + cycle. The first resolution of a day pins its champion as `puzzle:`, so + adding champions or changing the secret only affects days not started yet. + If a data refresh removes a champion that is already pinned, that day stays + playable: its attributes are unknown, so every guess scores 🟥 in every + column and the board ends in a loss that reveals the name (naming the + champion still wins, but the page's search no longer lists it). +- **Guesses:** 8. A win scores `9 − guesses` with `setGameScore`. +- **Live results:** one message per group topic and day, with one row of 7 + squares per guess (🟩 correct, 🟨 partial, 🟥 wrong). No champion is named. +- **Push:** the cron `loldledaily_daily_push` (`0 0 * * *` UTC). The recap + opens with yesterday's champion, then the group streak (wins only), then the + finishers grouped by guess count: + +``` +LoLdle Daily #11 — yesterday's results +Champion: Ahri +🔥 Your group is on a 3 day streak! +👑 2/8: Alice, Dan +5/8: Eve +X/8: Bob +``` + +- **Share text:** `LoLdle Daily #11 2/8` and the square rows. + +## HTTP API + +Routes under `/games/loldle/`: + +| Route | Body | Answer | +|---|---|---| +| `GET /`, `app.js`, `app.css` | | the page | +| `GET champions.json` | | `[{"name","id"}]`, cached for an hour | +| `POST api/state` | `{"token"}` | view | +| `POST api/guess` | `{"token","num"}` (daily) or `{"token","seq"}` (unlimited), plus `"name"` | view | +| `POST api/new` | `{"token","seq"}` | the next unlimited round; `403 bad_mode` on a daily card | + +The view has `mode`, `player`, `max`, `columns` (`{key, label}` ×7), +`guesses` and `status`; daily adds `num`, `date` and `next_at`, unlimited adds +`seq` and `gave_up`, and after `api/new` gives up a round with guesses, +`abandoned` (`{name, id, title}`) names its champion. Each guess is `{name, id, marks, cells}`, a cell being +`{key, value, result, dir}` with the guessed champion's own value. `answer` +(`{name, id, title}`), `stats` and the daily `share` appear only once the game +is over. + +Errors are `{"error","message"}`: `400 bad_request`, `401 bad_token` / +`expired`, `403 bad_mode`, `409 new_puzzle` / `new_round`, `422 unknown` / +`ambiguous` / `duplicate` / `finished`, `429 rate_limited` (60 requests a +minute per player). + +## Storage + +All in the `loldle` collection: + +| Key | Holds | +|---|---| +| `config:` | the chat's `/loldle_setmax` value | +| `uround:`, `ustats:`, `ucard::` | unlimited rounds, stats and cards | +| `puzzle:`, `play::`, `dstats:`, `cday:…`, `cstreak:…`, `subscribers`, `daily_push:last_date` | LoLdle Daily, as Wordle Daily's keys; the stats prefix is `dstats:` because `stats:` holds the in-chat game's old stats | +| `game:`, `stats:` | the in-chat game before unlimited mode; read once by the migration, never written | + +## Carrying over the in-chat game + +At startup `loldle.InitStore` copies the in-chat game's players into +unlimited mode, once (marker `loldle:legacy-unlimited` in the `system` +collection), whatever `MODULES` says: + +- `stats:` becomes the player's unlimited stats (`streak` → current, + `bestStreak` → best); the distribution starts at zero. +- `game:`, a round still being played (the old game deleted finished + ones), becomes the player's current round, with its frozen length and start + time. Its colours are rebuilt from today's champion data; a guess naming a + champion no longer in the data is dropped, and a round whose answer is gone + is not carried. +- Group rounds and group stats (negative subjects) belong to no single player + and are not carried. +- Nothing already there is overwritten, and the old documents stay, so a + rollback is a redeploy. + +## Champion data + +`internal/modules/loldle/data/champions.json` holds 172 champions in +loldle.net's schema (gender, positions, species, resource, range type, +regions, release date). `id` (the Data Dragon key, which names the icon, e.g. +`MonkeyKing` for Wukong) and `title` were added by name from +`tiennm99/loldle`'s Data Dragon scrape. Data Dragon's tile files are +case-sensitive and one does not match its key: Fiddlesticks' tile is +`FiddleSticks_0.jpg`, so `tileIDs` in `champions.go` overrides that `id` at +load. Tests check that every champion has a unique, letters-only `id` and a +title, and that the override applies. + +Locke, in that scrape but not here, is not added yet: this data needs his +species, positions and release date, which the scrape does not have. diff --git a/docs/wordle.md b/docs/wordle.md new file mode 100644 index 0000000..85da0d0 --- /dev/null +++ b/docs/wordle.md @@ -0,0 +1,140 @@ +# Wordle (unlimited) + +Unlimited Wordle is the second mode of the `wordledaily` module's HTML5 game. +Each player plays their own rounds, each with a random English five-letter +word, as many as they like. It shares the BotFather game `wordle`, the page +and the word lists with [Wordle Daily](wordledaily.md); only the card decides +which mode Play opens. + +## Commands + +| Command | What it does | +|---|---| +| `/wordle` | Sends an unlimited card. Play opens the caller's current round. Without the web game (`GAME_BASE_URL` unset) it shows the round's board in the chat instead | +| `/wordle ` | Plays the guess on the caller's round and replies with the coloured row, `Guess n/6.`, or the result and streak at the end | +| `/wordle_new` | Gives up the caller's round and starts a fresh one, then sends a new card. Without the web game it confirms in the chat instead | +| `/wordle_giveup` | Ends the caller's round as a loss and reveals the word. On a finished round it only repeats the answer | +| `/wordle_stats` | The caller's unlimited stats: played, wins and win %, current and best streak | + +Each player has one round, the same one in every chat: the page and the chat +commands play it together, so a guess typed in the chat shows on the page and +the other way round. In a group every member plays their own round; nobody +shares a board. Channels are refused, and so are anonymous group admins and +posts sent on behalf of a chat: Telegram sends those from one shared stand-in +account in every group, so they would all share one round. + +The commands kept their names from the retired in-chat `wordle` module, so +command stats need no migration. + +## Rounds + +- **Starting:** a player's first round starts the first time they open a card + or use a command. The word is drawn at random from the answer list + (`wordlist/data/answers.txt`), never the word of the round it replaces. A + guess may be any word in `wordlist/data/words.txt`, as in the daily game. +- **Guesses:** six. Marks and validation are the daily game's: the server + colours every guess and the answer reaches the page only once the round is + over. +- **Clock:** a round's start time is its first guess, so opening the page + costs nothing. +- **New game:** the page's **New game** button (in the header and on the end + panel) and `/wordle_new` give up the round being played and start the next. + - Giving up a round with at least one guess counts as a loss. The page + shows the given-up round's word in a toast, as `/wordle_new` does. + - A round nobody guessed on is just replaced, with no loss. The old in-chat + `/wordle_new` counted that as a loss too. + - The header button asks for a second tap ("Give up this round?") when the + round has guesses. + - The page sends the round number it shows. A stale number, such as a double + click, returns the current round instead of starting another. +- **Stats:** played, wins, current streak (consecutive wins) and best streak, + plus the guess distribution, shown on the end panel. They are separate from + the daily stats. +- **No high score:** an unlimited win never calls `setGameScore`, so each + card's high-score table stays the daily leaderboard. There is no share text + either; the end panel points to `/wordledaily` for the shared word. + +## Which card opens which mode + +`/wordle` and `/wordle_new` record the card they send as `ucard::` +in the module's collection. Play looks the pressed card up: + +- A recorded card opens unlimited mode. The token carries `"md":"u"`. +- Anything else opens the daily puzzle: `/wordledaily` cards, the 07:00 push, + cards sent before unlimited mode existed, inline shares (`?game=wordle` + links, which have no chat message to record), cards whose record expired, + and forwarded copies of a `/wordle` card. A forwarded copy is a new message + with its own chat and message id, and a forward does not say which message + it copied, so the bot cannot tell an unlimited card from a daily one; it + plays daily. Send `/wordle` in that chat for your own round. + Tokens without `md` are daily, so links signed before the change still work. +- If the lookup itself fails, Play answers with an alert instead of silently + opening the daily puzzle. +- If a card cannot be recorded after it was sent, the bot deletes it and says + so, because it would otherwise play daily. + +A record is refreshed at most once a day when its card is played. The cron +`wordle_unlimited_cards` (`35 20 * * *` UTC, 03:35 ICT) forgets cards nobody +played for 30 days; such a card then plays daily. + +## Carrying over the in-chat game + +The in-chat `wordle` module was folded into `wordledaily`. At startup, +`wordledaily.InitStore` copies its players from the old `wordle` collection, +once: + +- `stats:` becomes the player's unlimited stats (`played`, `wins`, + `streak` → current, `bestStreak` → best, `lastResultAt`). The old games + recorded no guess distribution, so it starts at zero. +- An unfinished `game:` becomes the player's current round, with its + guesses and colours. +- **Group data is not carried.** The old game kept one shared round and one + set of stats per group chat (a negative id), which belong to no single + player, so those documents stay where they are, unused. +- Nothing is overwritten: a player who already has unlimited stats or a round + keeps them. +- The old documents are not changed or deleted, so rolling back is only a + redeploy of the old binary. +- A completed marker `wordledaily:legacy-wordle-unlimited` in the `system` + collection makes later boots skip it. It runs whatever `MODULES` says, like + the stats migration. + +A `MODULES` value that still names `wordle` loads `wordledaily` in its place +(with a warning in the log), so a deployment that listed the old module keeps +its `/wordle` commands. + +## HTTP API + +Unlimited mode uses the daily game's routes under `/games/wordledaily/`; the +token decides the mode. See [Wordle Daily › HTTP API](wordledaily.md#http-api) +for the shared parts. + +| Route | Body | Answer | +|---|---|---| +| `api/state` | `{"token"}` | the current round, started if the player has none | +| `api/guess` | `{"token","seq","word"}` | the round after the guess | +| `api/new` | `{"token","seq"}` | the next round; `403 bad_mode` on a daily card | + +The unlimited view has `mode: "unlimited"`, `seq` (the round number), +`player`, `max`, `len`, `guesses`, `status` and `gave_up`. `answer` and +`stats` appear only once the round is over. After `api/new` gives up a round +with guesses, `abandoned` holds that round's word; `num`, `date`, `next_at` and +`share` are absent. A guess on another round than the current one answers +`409 new_round`; a guess on a finished round answers `422 finished`. + +## Storage + +In the `wordledaily` collection: + +| Key | Holds | +|---|---| +| `uround:` | the player's current round: number, word, guesses with marks, status, whether they gave up, guess budget, start and finish time | +| `ustats:` | played, wins, current and best streak, distribution, last finish, last round counted | +| `ucard::` | an unlimited card and when it was last played | + +A round is saved first and is the record; the stats then count it once, by +its round number, and a failed stats write is repaired by the player's next +request. The page and the chat commands take the same per-player lock. + +The engine (rounds, cards, tokens and the daily mode) lives in +`internal/modules/util/guessgame` and is shared with [LoLdle](loldle.md). diff --git a/docs/wordledaily.md b/docs/wordledaily.md index d443dc9..b304671 100644 --- a/docs/wordledaily.md +++ b/docs/wordledaily.md @@ -5,7 +5,11 @@ Activity. Everyone plays the same English five-letter word each day. A new puzzle starts at 07:00 ICT (00:00 UTC), and the puzzle number goes up by one each day. -It has three commands: +The same module and BotFather game also run [unlimited Wordle](wordle.md): +`/wordle` sends a card that opens the player's own random rounds. Play +decides the mode from the card; see [Card modes](#card-modes). + +The daily game has three commands: - `/wordledaily` sends the game card in any chat, private or group, and in a forum topic it stays in that topic. Pressing **Play** opens a board page that @@ -23,8 +27,28 @@ Anyone in a chat may subscribe or unsubscribe, the same as `/lol_subscribe` and `/thuyvan_subscribe`. Channels are refused, because Telegram does not allow games in channels. -The classic `/wordle` text game is a separate module. The two share their word -data and scoring through `internal/modules/wordle/wordlist`. +The word data and scoring live in `internal/modules/wordle/wordlist`. The +daily engine described here (puzzles, progress, stats, live results, recap +and push) lives in `internal/modules/util/guessgame` and is shared with +[LoLdle Daily](loldle.md). + +## Card modes + +Both modes use the one BotFather game `wordle`, so a card does not say which +mode it is. `/wordle` and `/wordle_new` record each card they send; Play on a +recorded card opens unlimited mode, and every other card opens today's +puzzle: + +- `/wordledaily` cards and the 07:00 push, +- every card sent before unlimited mode existed, +- inline shares (`t.me/?game=wordle`), which have no chat message to + record, +- `/wordle` cards nobody played for 30 days, whose record expired, +- forwarded copies of a `/wordle` card, which the bot cannot trace back to + their record. + +A failed lookup answers Play with an alert rather than opening the wrong +mode. See [unlimited Wordle](wordle.md#which-card-opens-which-mode). ## Setup @@ -42,7 +66,8 @@ data and scoring through `internal/modules/wordle/wordlist`. When the secret is unset, the bot token is the root key. Because the labels are separate, a noitu link never opens this game. -4. Keep `wordledaily` in `MODULES`, or leave `MODULES` empty. +4. Keep `wordledaily` in `MODULES`, or leave `MODULES` empty. A `MODULES` + value that still names the retired `wordle` module loads `wordledaily`. The game is disabled when any of these holds: @@ -55,6 +80,8 @@ A disabled game behaves like this: - `/wordledaily` and `/wordledaily_subscribe` answer "Wordle Daily isn't configured on this server." `/wordledaily_unsubscribe` still removes a subscription. +- `/wordle` shows the unlimited round's board in the chat instead of a card; + the other `/wordle` commands keep working in the chat. - Play shows the same text as an alert. - No `/games/wordledaily/` route exists and the daily push is not registered. @@ -89,7 +116,8 @@ A disabled game behaves like this: handles duplicate letters the NYT way. The page never receives a word list, and it never receives the answer until that player's game is over. - **Progress:** each player's board is saved after every guess, per puzzle. A - restart, a reload, or opening any other card shows the same board. + restart, a reload, or opening any other daily card (see + [Card modes](#card-modes)) shows the same board. - **One game a day:** each player gets one game per day across all chats. A finished puzzle can't be replayed. - **Stats:** each player's stats cover every chat. They are played, win %, @@ -230,9 +258,7 @@ What else the push sends depends on the day before: - **Nobody played:** the recap is just the title and the answer. - **Private chats:** a private chat always gets just the card. -**Group streak:** the number of consecutive puzzles on which at least one -player listed in that group topic finished, win or lose. Streak lines appear -only from 2 days up. +Streak lines appear only from 2 days up. Nothing else is posted. There is no separate "X is playing" message and no message per guess; the live results message covers both. @@ -252,10 +278,11 @@ through `internal/modules/util/htmlgame`. |---|---|---| | `api/state` | `{"token"}` | view | | `api/guess` | `{"token","num","word"}` | view | +| `api/new` | `{"token","seq"}` | unlimited cards only; see [unlimited Wordle](wordle.md#http-api) | -The view has these fields: +The token decides the mode. The daily view has these fields: -- `num`, `date`, `player`, `max` (6), `len` (5) +- `mode` (`daily`), `num`, `date`, `player`, `max` (6), `len` (5) - `guesses`: a list of `{word, marks}`. `marks` has one letter per position: `c` correct, `p` present elsewhere, `w` wrong. - `status`: `playing`, `won` or `lost` @@ -269,9 +296,10 @@ Errors are `{"error","message"}`: |---|---| | 400 | `bad_request` | | 401 | `bad_token`, `expired` | -| 409 | `new_puzzle` | +| 403 | `bad_mode` (`api/new` on a daily card) | +| 409 | `new_puzzle`, `new_round` (unlimited) | | 422 | `length`, `unknown`, `finished` | -| 429 | `rate_limited`: 60 requests a minute per player, both routes together | +| 429 | `rate_limited`: 60 requests a minute per player, every route together | A Play token expires after 6 hours and is not tied to a day. @@ -288,6 +316,7 @@ Everything is in the module's collection, under separate key prefixes. | `cstreak:::` | the group topic's streak and the last puzzle it counted | | `subscribers` | the subscribed chats and topics | | `daily_push:last_date` | the last puzzle number pushed | +| `uround:`, `ustats:`, `ucard:` | unlimited mode; see [unlimited Wordle](wordle.md#storage) | Each player's board and stats are written under a per-player lock. The board is the record and is written first. A stats update is skipped for a puzzle diff --git a/internal/modules/loldle/api_test.go b/internal/modules/loldle/api_test.go new file mode 100644 index 0000000..c741eab --- /dev/null +++ b/internal/modules/loldle/api_test.go @@ -0,0 +1,450 @@ +package loldle + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" + "time" + + "github.com/tiennm99/tiennm99bot/internal/modules/loldle/web" + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" +) + +func urlUnescape(t *testing.T, s string) string { + t.Helper() + u, err := url.QueryUnescape(s) + if err != nil { + t.Fatal(err) + } + return u +} + +func TestRules_MarksArrowsAndRefusals(t *testing.T) { + r := rules{champions: loadChampions()} + for guess, want := range map[string]string{"Akali": "cwwwccu", "wukong": "wcwccwc", "kaisa": "cwccwwd", "Ahri": "ccccccc"} { + g, err := r.Judge(guess, "Ahri", nil) + if err != nil || g.Marks != want { + t.Errorf("%s vs Ahri = %+v, %v; want %s", guess, g, err, want) + } + } + if g, _ := r.Judge("jinx", "Kai'Sa", nil); g.Word != "Jinx" || g.Marks != "cpccwcu" { + t.Errorf("Jinx vs Kai'Sa = %+v", g) + } + for input, want := range map[string]error{"zzz": errUnknownChampion, "ka": errAmbiguous, "": errUnknownChampion} { + if _, err := r.Judge(input, "Ahri", nil); err != want { + t.Errorf("Judge(%q) = %v, want %v", input, err, want) + } + } + if _, err := r.Judge("akali", "Ahri", []guessgame.Guess{{Word: "Akali"}}); err != errDuplicate { + t.Errorf("duplicate = %v", err) + } + if _, err := r.Judge("akali", "Gone", nil); err != errTargetGone { + t.Errorf("gone = %v", err) + } + if got := guessgame.EmojiRow(r, "cpwudcw"); got != "🟩🟨🟥🟥🟥🟩🟥" { + t.Errorf("emoji = %q", got) + } +} + +func TestChampions_EveryRowHasAnIconID(t *testing.T) { + seen := map[string]bool{} + idRe := regexp.MustCompile(`^[A-Za-z]+$`) + for _, c := range loadChampions() { + if !idRe.MatchString(c.ID) || seen[c.ID] || c.Title == "" { + t.Errorf("%s: id %q title %q", c.ChampionName, c.ID, c.Title) + } + seen[c.ID] = true + } +} + +// Data Dragon tile file names are case-sensitive and do not always match the +// champion key: Fiddlesticks' tile is FiddleSticks_0.jpg. +func TestChampions_IconIDsNameTheTileFile(t *testing.T) { + ids := map[string]string{} + for _, c := range loadChampions() { + ids[c.ChampionName] = c.ID + } + for name, want := range map[string]string{"Fiddlesticks": "FiddleSticks", "Wukong": "MonkeyKing", "Ahri": "Ahri"} { + if ids[name] != want { + t.Errorf("%s icon id = %q, want %q", name, ids[name], want) + } + } +} + +func TestAPI_ChampionListCarriesNamesAndIDsOnly(t *testing.T) { + h := newHarness(t) + rec := httptest.NewRecorder() + h.handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, routePrefix+"champions.json", nil)) + if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Type"), "application/json") { + t.Fatalf("champions.json: %d %v", rec.Code, rec.Header()) + } + var list []map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil || len(list) != len(loadChampions()) { + t.Fatalf("list = %d, %v", len(list), err) + } + for _, c := range list { + if len(c) != 2 || c["name"] == "" || c["id"] == "" { + t.Fatalf("entry %v", c) + } + } + if list[0]["name"] != "Aatrox" || !strings.Contains(rec.Body.String(), `{"name":"Wukong","id":"MonkeyKing"}`) { + t.Fatalf("list starts %v", list[0]) + } +} + +func TestAPI_HeadersAllowDataDragonImagesOnly(t *testing.T) { + h := newHarness(t) + for path, ctype := range map[string]string{"": "text/html", "app.js": "text/javascript", "app.css": "text/css"} { + rec := httptest.NewRecorder() + h.handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, routePrefix+path, nil)) + csp := rec.Header().Get("Content-Security-Policy") + if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Type"), ctype) || + !strings.Contains(csp, "img-src 'self' data: https://ddragon.leagueoflegends.com;") || !strings.Contains(csp, "connect-src 'self'") { + t.Errorf("GET %q: %d %v", path, rec.Code, rec.Header()) + } + } + if r := h.state("garbage"); r.code != http.StatusUnauthorized || r.err != "bad_token" { + t.Fatalf("bad token = %d %s", r.code, r.body) + } +} + +var ( + inlineScriptRe = regexp.MustCompile(`(?is)]*)>(.*?)`) + urlRe = regexp.MustCompile(`https?://[^'"\s)]+`) + inlineHandlerRe = regexp.MustCompile(`\son[a-z]+=`) +) + +// The page runs under the strict CSP: no inline script, style or handler, +// and it reaches no host but its own and Data Dragon's icons. +func TestWebAssets_FitTheCSP(t *testing.T) { + index, _ := web.FS.ReadFile("index.html") + for _, m := range inlineScriptRe.FindAllStringSubmatch(string(index), -1) { + if strings.TrimSpace(m[2]) != "" || !strings.Contains(m[1], "src=") { + t.Errorf("inline script breaks the CSP: %q", m[0]) + } + } + if strings.Contains(string(index), " style=") || strings.Contains(string(index), "/loldle <champion>.", len(st.Round.Guesses), st.Round.MaxGuesses) + return chathelper.ReplyHTML(ctx, b, msg, header+"\n\n"+renderBoard(s.boardOf(st.Round))) + } + + st, err := s.rounds.Guess(ctx, uid, 0, arg, maxGuesses) + if errors.Is(err, guessgame.ErrFinished) { + // The last round is over: this guess opens the next one. + if _, err = s.rounds.New(ctx, uid, st.Round.Seq, maxGuesses); err == nil { + st, err = s.rounds.Guess(ctx, uid, 0, arg, maxGuesses) + } + } + switch { + case errors.Is(err, errAmbiguous): + return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Ambiguous champion %q. Type the full champion name.", arg)) + case errors.Is(err, errUnknownChampion): + return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Champion not found: %q.", arg)) + case errors.Is(err, errDuplicate): + name := arg + if c, _ := findChampionMatch(s.cfg.champions, arg); c != nil { + name = c.ChampionName + } + return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf( + "🔁 %s was already guessed this round — try another champion.", html.EscapeString(name))) + case errors.Is(err, errTargetGone): + // champions.json was refreshed and the answer is gone: drop the + // round without counting it. + if _, err := s.rounds.Replace(ctx, uid, maxGuesses); err != nil { + return err + } + return chathelper.ReplyHTML(ctx, b, msg, "Champion data was updated since this round started. "+newRoundHint) + case err != nil: + return err + } + + rd := st.Round + last := rd.Guesses[len(rd.Guesses)-1] + rendered := renderGuess(last.Word, s.rowsOf(last)) + champ := html.EscapeString(rd.Target) + switch rd.Status { + case guessgame.StatusWon: + trySendSticker(ctx, b, msg, winStickers) + return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf( + "%s\n\n🎉 %s %s\n⏱ %s · 🔥 Streak: %d (%d/%d)\n%s", + rendered, attemptFlavor(len(rd.Guesses), rd.MaxGuesses), champ, formatDuration(rd.FinishedAt-rd.StartedAt), + st.Stats.CurStreak, len(rd.Guesses), rd.MaxGuesses, newRoundHint)) + case guessgame.StatusLost: + trySendSticker(ctx, b, msg, loseStickers) + return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf( + "%s\n\n❌ Out of guesses. Answer was %s.\n%s", rendered, champ, newRoundHint)) + } + return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf("%s\n\nGuess %d/%d.", rendered, len(rd.Guesses), rd.MaxGuesses)) +} + +// handleGiveup is /loldle_giveup: it ends the caller's round as a loss and +// reveals the answer. +func (s *service) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + uid, ok := s.player(ctx, b, msg) + if !ok { + return nil + } + st, err := s.rounds.GiveUp(ctx, uid) + switch { + case errors.Is(err, guessgame.ErrNoRound): + return chathelper.ReplyHTML(ctx, b, msg, "No active round. "+newRoundHint) + case err != nil: + return err + case !st.Ended: + return chathelper.ReplyHTML(ctx, b, msg, "No active round. "+newRoundHint) + } + trySendSticker(ctx, b, msg, giveupStickers) + return chathelper.ReplyHTML(ctx, b, msg, + fmt.Sprintf("🏳️ Answer was %s.\n%s", html.EscapeString(st.Round.Target), newRoundHint)) +} + +// handleStats is /loldle_stats: the caller's unlimited stats. +func (s *service) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + uid, ok := s.player(ctx, b, msg) + if !ok { + return nil + } + st, err := s.rounds.LoadStats(ctx, uid) + if err != nil { + return err + } + return chathelper.Reply(ctx, b, msg, fmt.Sprintf( + "📊 LoLdle stats for %s\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d", + guessgame.DisplayName(msg.From.FirstName, msg.From.LastName), + st.Played, st.Wins, chathelper.WinRate(st.Wins, st.Played), st.CurStreak, st.MaxStreak)) +} + +// handleSetMax is /loldle_setmax — owner-only (VisibilityPrivate). It +// sets the length (1..MaxGuessesCap) of the unlimited rounds started in this +// chat from now on, by command or from a card sent here; a round keeps the +// length it started with, and daily puzzles always allow MaxGuesses. +func (s *service) handleSetMax(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + subject := chathelper.SubjectFor(msg) + if subject == "" { + return chathelper.Reply(ctx, b, msg, "Cannot identify chat.") + } + if s.settings == nil { + return chathelper.Reply(ctx, b, msg, msgNoStore) + } + n, err := strconv.Atoi(chathelper.ArgAfterCommand(msg.Text)) + if err != nil || !validMaxGuesses(n) { + return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Usage: /loldle_setmax <1-%d>", MaxGuessesCap)) + } + if err := setMaxGuesses(ctx, s.settings, subject, n); err != nil { + return err + } + return chathelper.Reply(ctx, b, msg, fmt.Sprintf("✅ Loldle max guesses set to %d (applies to the next round).", n)) +} + +// sendCard sends the recorded unlimited card into the command's chat. +func (s *service) sendCard(ctx context.Context, b *bot.Bot, msg *models.Message) error { + if err := s.cards.SendCard(ctx, b, msg, GameShortName); err != nil { + _ = chathelper.Reply(ctx, b, msg, msgSendFail) + return err + } + return nil +} + +// rowsOf rebuilds a stored guess's comparison rows for the chat board from +// its marks and the guessed champion's own values. A champion removed from +// the data since keeps its marks with blank values. +func (s *service) rowsOf(g guessgame.Guess) []AttributeRow { + champ := findChampionByExactName(s.cfg.champions, g.Word) + rows := make([]AttributeRow, len(classicAttributes)) + for i, attr := range classicAttributes { + row := attr + row.GuessValue = attrDisplay(champ, attr) + if i < len(g.Marks) { + row.Result, row.Direction = markResult(g.Marks[i]) + } else { + row.Result = ResultWrong + } + rows[i] = row + } + return rows +} + +// boardOf is a round's guesses as chat board entries. +func (s *service) boardOf(rd guessgame.Round) []boardEntry { + out := make([]boardEntry, len(rd.Guesses)) + for i, g := range rd.Guesses { + out[i] = boardEntry{Champion: g.Word, Results: s.rowsOf(g)} + } + return out +} + +// attrDisplay is a champion's own value of one attribute, as the boards +// show it; "—" for an unknown champion. +func attrDisplay(c *Champion, attr AttributeRow) string { + if c == nil { + return "—" + } + v := attrValue(c, attr.Key) + if attr.Type == attrYear { + return yearOrPlaceholder(parseYear(asString(v))) + } + return formatValue(v) +} + +// trySendSticker sends a sticker, swallowing errors. A bad/expired file_id +// must never block the text reply that carries the round outcome. +// +// Threads MessageThreadID so stickers post in the same forum-supergroup topic +// as the inbound command (omission posts to General — same bug class as the +// Reply helper). +func trySendSticker(ctx context.Context, b *bot.Bot, msg *models.Message, pool []string) { + id := pickSticker(pool) + if id == "" || msg == nil { + return + } + _, _ = b.SendSticker(ctx, &bot.SendStickerParams{ + ChatID: msg.Chat.ID, + MessageThreadID: msg.MessageThreadID, + Sticker: &models.InputFileString{Data: id}, + }) +} diff --git a/internal/modules/loldle/command_test.go b/internal/modules/loldle/command_test.go new file mode 100644 index 0000000..94bb870 --- /dev/null +++ b/internal/modules/loldle/command_test.go @@ -0,0 +1,254 @@ +package loldle + +import ( + "context" + "strings" + "testing" + + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/tiennm99bot/internal/modules" + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" + "github.com/tiennm99/tiennm99bot/internal/storage" + "github.com/tiennm99/tiennm99bot/internal/testutil" +) + +// chatOnly is the module without the web game: in-chat play only. +func chatOnly(h *harness) *service { + return newService(config{store: h.coll, now: h.clock.now, champions: loadChampions(), pick: testPick}) +} + +func TestModule_Registration(t *testing.T) { + mod := newService(config{champions: loadChampions()}).module() + var names []string + for _, c := range mod.Commands { + names = append(names, c.Name) + want := modules.VisibilityPublic + if c.Name == "loldle_setmax" { + want = modules.VisibilityPrivate + } + if c.Visibility != want || c.Description == "" { + t.Errorf("command %+v", c) + } + } + if strings.Join(names, " ") != "loldle loldle_giveup loldle_stats loldle_setmax loldledaily loldledaily_subscribe loldledaily_unsubscribe" { + t.Fatalf("commands = %v", names) + } + if mod.Commands[0].Parameters != "[champion]" { + t.Fatalf("parameters = %q", mod.Commands[0].Parameters) + } + if len(mod.Games) != 1 || mod.Games[0].ShortName != "loldle" || len(mod.HTTP) != 0 || len(mod.Crons) != 0 { + t.Fatalf("disabled module: %+v", mod) + } + h := newHarness(t) + mod = h.svc.module() + if mod.HTTP[0].Pattern != "/games/loldle/" || mod.Crons[0].Name != "loldledaily_daily_push" || mod.Crons[0].Schedule != "0 0 * * *" || + mod.Crons[1].Name != "loldle_unlimited_cards" || mod.Crons[1].Schedule != "40 20 * * *" { + t.Fatalf("enabled module: %+v %+v", mod.HTTP, mod.Crons) + } +} + +func TestNew_FromEnv(t *testing.T) { + t.Setenv("GAME_BASE_URL", "https://game.example/") + t.Setenv(secretEnv, "") + rb := testutil.NewRecordingBot(t) + if mod := New(modules.Deps{Bot: rb.Bot, Store: storage.NewMemoryProvider().Collection(ShortName)}); len(mod.HTTP) != 1 { + t.Fatalf("routes = %+v", mod.HTTP) + } + if mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection(ShortName)}); len(mod.HTTP) != 0 { + t.Fatal("enabled without a bot or secret") + } + t.Setenv(secretEnv, "short") + if mod := New(modules.Deps{Bot: rb.Bot, Store: storage.NewMemoryProvider().Collection(ShortName)}); len(mod.HTTP) != 0 { + t.Fatal("enabled with a short secret") + } +} + +func TestLoldle_NoArgSendsARecordedCard(t *testing.T) { + h := newHarness(t) + rb := install(t, h.svc.module(), 0) + send(t, rb, groupChat, 1, "/loldle") + games := sentMethod(rb, "sendGame") + if len(games) != 1 || games[0].Form["game_short_name"] != GameShortName || games[0].ChatID() != "-100" { + t.Fatalf("sent %+v", rb.Sent()) + } + docs, _ := storage.Typed[guessgame.CardRecord](h.coll).Scan(context.Background(), "ucard:") + if len(docs) != 1 || docs[0].Val.ChatID != groupChat { + t.Fatalf("cards = %+v", docs) + } + // Play on it opens unlimited mode; any other card plays daily. + rb.Bot.ProcessUpdate(context.Background(), testutil.NewGameCallback(1, groupChat, docs[0].Val.MessageID, GameShortName)) + answers := sentMethod(rb, "answerCallbackQuery") + if len(answers) != 1 || !strings.HasPrefix(answers[0].Form["url"], testBase+routePrefix+"?t=") { + t.Fatalf("answers = %+v", answers) + } + tok := strings.TrimPrefix(answers[0].Form["url"], testBase+routePrefix+"?t=") + if c, err := h.svc.verifyToken(urlUnescape(t, tok), h.clock.now()); err != nil || !c.Unlimited() { + t.Fatalf("claims = %+v, %v", c, err) + } + rb.Reset() + rb.Bot.ProcessUpdate(context.Background(), testutil.NewGameCallback(1, groupChat, 999, GameShortName)) + tok = strings.TrimPrefix(sentMethod(rb, "answerCallbackQuery")[0].Form["url"], testBase+routePrefix+"?t=") + if c, _ := h.svc.verifyToken(urlUnescape(t, tok), h.clock.now()); c.Unlimited() { + t.Fatalf("unrecorded card opened unlimited: %+v", c) + } +} + +func TestLoldle_NoArgWithoutTheWebGameShowsTheBoard(t *testing.T) { + h := newHarness(t) + rb := install(t, chatOnly(h).module(), 0) + if got := send(t, rb, 1, 1, "/loldle"); !strings.Contains(got, "Guess 0/8") || !strings.Contains(got, "No guesses yet") { + t.Fatalf("board = %q", got) + } + send(t, rb, 1, 1, "/loldle akali") + if got := send(t, rb, 1, 1, "/loldle"); !strings.Contains(got, "Guess 1/8") || !strings.Contains(got, "AKALI") || !strings.Contains(got, "2010 ⬆️") { + t.Fatalf("board = %q", got) + } + if len(sentMethod(rb, "sendGame")) != 0 { + t.Fatal("disabled game sent a card") + } +} + +func TestLoldle_GuessesPlayTheCallersRound(t *testing.T) { + h := newHarness(t) + rb := install(t, h.svc.module(), 0) + if got := send(t, rb, groupChat, 1, "/loldle akali"); !strings.Contains(got, "AKALI") || !strings.HasSuffix(got, "Guess 1/8.") { + t.Fatalf("guess = %q", got) + } + if got := send(t, rb, groupChat, 1, "/loldle zzzz"); got != `Champion not found: "zzzz".` { + t.Fatalf("unknown = %q", got) + } + if got := send(t, rb, groupChat, 1, "/loldle ka"); got != `Ambiguous champion "ka". Type the full champion name.` { + t.Fatalf("ambiguous = %q", got) + } + if got := send(t, rb, groupChat, 1, "/loldle AKALI"); !strings.Contains(got, "Akali was already guessed") { + t.Fatalf("duplicate = %q", got) + } + // Bob in the same group has his own round. + got := send(t, rb, groupChat, 2, "/loldle ahri") + if !strings.Contains(got, "🎉 First try! Ahri") || !strings.Contains(got, "🔥 Streak: 1 (1/8)") || len(sentMethod(rb, "sendSticker")) != 1 { + t.Fatalf("bob = %q", got) + } + // The page and the chat share Alice's round. + h.roundGuess(h.token(1, groupChat, 5, "Alice", true), 1, "Wukong") + got = send(t, rb, groupChat, 1, "/loldle ahri") + if !strings.Contains(got, "🎉") || !strings.Contains(got, "(3/8)") { + t.Fatalf("alice = %q", got) + } + // After a finish the next guess opens the next round (Jinx). + if got := send(t, rb, groupChat, 1, "/loldle ahri"); !strings.HasSuffix(got, "Guess 1/8.") { + t.Fatalf("next round = %q", got) + } +} + +func TestLoldle_LossAndGiveup(t *testing.T) { + h := newHarness(t) + rb := install(t, h.svc.module(), 7) + send(t, rb, 7, 7, "/loldle_setmax 2") + if got := send(t, rb, 7, 7, "/loldle_giveup"); !strings.Contains(got, "No active round") { + t.Fatalf("no round = %q", got) + } + send(t, rb, 7, 7, "/loldle akali") + got := send(t, rb, 7, 7, "/loldle wukong") + if !strings.Contains(got, "❌ Out of guesses. Answer was Ahri.") || len(sentMethod(rb, "sendSticker")) != 1 { + t.Fatalf("loss = %q", got) + } + if got := send(t, rb, 7, 7, "/loldle_giveup"); !strings.Contains(got, "No active round") { + t.Fatalf("giveup after a loss = %q", got) + } + send(t, rb, 7, 7, "/loldle akali") // round 2: Jinx + if got := send(t, rb, 7, 7, "/loldle_giveup"); !strings.HasPrefix(got, "🏳️ Answer was Jinx.") || len(sentMethod(rb, "sendSticker")) != 1 { + t.Fatalf("giveup = %q", got) + } + if got := send(t, rb, 7, 7, "/loldle_stats"); got != "📊 LoLdle stats for Test\nPlayed: 2\nWins: 0 (0%)\nCurrent streak: 0\nBest streak: 0" { + t.Fatalf("stats = %q", got) + } +} + +func TestLoldleSetMax_AppliesToRoundsStartedInThatChatOnly(t *testing.T) { + h := newHarness(t) + ctx := context.Background() + rb := install(t, h.svc.module(), 7) + send(t, rb, groupChat, 1, "/loldle akali") // starts with the default 8 + if got := send(t, rb, groupChat, 7, "/loldle_setmax 3"); got != "✅ Loldle max guesses set to 3 (applies to the next round)." { + t.Fatalf("setmax = %q", got) + } + if got := send(t, rb, groupChat, 1, "/loldle wukong"); !strings.HasSuffix(got, "Guess 2/8.") { + t.Fatalf("running round = %q", got) + } + send(t, rb, groupChat, 1, "/loldle_giveup") + if got := send(t, rb, groupChat, 1, "/loldle akali"); !strings.HasSuffix(got, "Guess 1/3.") { + t.Fatalf("next round = %q", got) + } + // A card from the group starts rounds of 3 too; a private card keeps 8. + if r := h.post("new", map[string]any{"token": h.token(1, groupChat, 5, "A", true), "seq": 2}); r.v.Max != 3 { + t.Fatalf("group card round = %s", r.body) + } + if r := h.state(h.token(2, 2, 5, "B", true)); r.v.Max != 8 { + t.Fatalf("private card round = %s", r.body) + } + for _, arg := range []string{"0", "11", "x"} { + if got := send(t, rb, groupChat, 7, "/loldle_setmax "+arg); got != "Usage: /loldle_setmax <1-10>" { + t.Errorf("setmax %s = %q", arg, got) + } + } + // Only the owner may run it. + rb.Reset() + rb.Bot.ProcessUpdate(ctx, testutil.NewGroupMessage(groupChat, 1, "/loldle_setmax 5")) + if n, _ := getMaxGuesses(ctx, h.svc.settings, "-100"); n != 3 { + t.Fatalf("non-owner changed setmax to %d", n) + } +} + +func TestLoldle_ChannelRefused(t *testing.T) { + h := newHarness(t) + rb := install(t, h.svc.module(), 0) + for _, cmd := range []string{"/loldle", "/loldle_giveup", "/loldle_stats"} { + rb.Reset() + rb.Bot.ProcessUpdate(context.Background(), testutil.NewChannelMessage(-300, cmd)) + if got := rb.LastSent().Text(); got != msgChannel { + t.Errorf("%s = %q", cmd, got) + } + } + rb.Reset() + rb.Bot.ProcessUpdate(context.Background(), testutil.NewChannelMessage(-300, "/loldledaily")) + if got := rb.LastSent().Text(); got != msgDailyChannel { + t.Errorf("/loldledaily = %q", got) + } +} + +// Anonymous admins and posts on behalf of a chat arrive from one shared +// stand-in bot id, so they are refused instead of sharing one round. +func TestLoldle_RefusesStandInSenders(t *testing.T) { + h := newHarness(t) + rb := install(t, h.svc.module(), 0) + for _, cmd := range []string{"/loldle Ahri", "/loldle_giveup", "/loldle_stats"} { + rb.Reset() + u := testutil.NewGroupMessage(-100, 136817688, cmd) + u.Message.From.IsBot = true + u.Message.SenderChat = &models.Chat{ID: -1001, Type: models.ChatTypeChannel} + rb.Bot.ProcessUpdate(context.Background(), u) + if got := rb.LastSent().Text(); got != msgNotYourself { + t.Errorf("%s on behalf of a channel = %q", cmd, got) + } + } +} + +// A round whose answer left the data is replaced without counting. +func TestLoldle_TargetGoneStartsANewRound(t *testing.T) { + h := newHarness(t) + ctx := context.Background() + rounds := storage.Typed[guessgame.Round](h.coll) + if err := rounds.Put(ctx, guessgame.RoundKey(1), guessgame.Round{Seq: 4, Target: "Nobody", Guesses: []guessgame.Guess{}, Status: guessgame.StatusPlaying, MaxGuesses: 8}); err != nil { + t.Fatal(err) + } + rb := install(t, h.svc.module(), 0) + if got := send(t, rb, 1, 1, "/loldle ahri"); !strings.HasPrefix(got, "Champion data was updated since this round started.") { + t.Fatalf("reply = %q", got) + } + rd, _, _ := rounds.Get(ctx, guessgame.RoundKey(1)) + st, _ := h.svc.rounds.LoadStats(ctx, 1) + if rd.Seq != 5 || rd.Target != "Ahri" || st.Played != 0 { + t.Fatalf("round %+v stats %+v", rd, st) + } +} diff --git a/internal/modules/loldle/data/champions.json b/internal/modules/loldle/data/champions.json index 61a8b33..d5f6aa2 100644 --- a/internal/modules/loldle/data/champions.json +++ b/internal/modules/loldle/data/champions.json @@ -1,6 +1,8 @@ [ { "championName": "Aatrox", + "id": "Aatrox", + "title": "the Darkin Blade", "gender": "Male", "positions": [ "Top" @@ -20,6 +22,8 @@ }, { "championName": "Ahri", + "id": "Ahri", + "title": "the Nine-Tailed Fox", "gender": "Female", "positions": [ "Middle" @@ -38,6 +42,8 @@ }, { "championName": "Akali", + "id": "Akali", + "title": "the Rogue Assassin", "gender": "Female", "positions": [ "Middle" @@ -56,6 +62,8 @@ }, { "championName": "Akshan", + "id": "Akshan", + "title": "the Rogue Sentinel", "gender": "Male", "positions": [ "Middle" @@ -74,6 +82,8 @@ }, { "championName": "Alistar", + "id": "Alistar", + "title": "the Minotaur", "gender": "Male", "positions": [ "Support" @@ -93,6 +103,8 @@ }, { "championName": "Ambessa", + "id": "Ambessa", + "title": "Matriarch of War", "gender": "Female", "positions": [ "Top" @@ -112,6 +124,8 @@ }, { "championName": "Amumu", + "id": "Amumu", + "title": "the Sad Mummy", "gender": "Male", "positions": [ "Jungle" @@ -131,6 +145,8 @@ }, { "championName": "Anivia", + "id": "Anivia", + "title": "the Cryophoenix", "gender": "Female", "positions": [ "Middle" @@ -150,6 +166,8 @@ }, { "championName": "Annie", + "id": "Annie", + "title": "the Dark Child", "gender": "Female", "positions": [ "Middle" @@ -170,6 +188,8 @@ }, { "championName": "Aphelios", + "id": "Aphelios", + "title": "the Weapon of the Faithful", "gender": "Male", "positions": [ "Bottom" @@ -189,6 +209,8 @@ }, { "championName": "Ashe", + "id": "Ashe", + "title": "the Frost Archer", "gender": "Female", "positions": [ "Bottom" @@ -208,6 +230,8 @@ }, { "championName": "Aurelion Sol", + "id": "AurelionSol", + "title": "The Star Forger", "gender": "Male", "positions": [ "Middle" @@ -228,6 +252,8 @@ }, { "championName": "Aurora", + "id": "Aurora", + "title": "the Witch Between Worlds", "gender": "Female", "positions": [ "Middle" @@ -246,6 +272,8 @@ }, { "championName": "Azir", + "id": "Azir", + "title": "the Emperor of the Sands", "gender": "Male", "positions": [ "Middle" @@ -264,6 +292,8 @@ }, { "championName": "Bard", + "id": "Bard", + "title": "the Wandering Caretaker", "gender": "Male", "positions": [ "Support" @@ -282,6 +312,8 @@ }, { "championName": "Bel'Veth", + "id": "Belveth", + "title": "the Empress of the Void", "gender": "Female", "positions": [ "Jungle" @@ -300,6 +332,8 @@ }, { "championName": "Blitzcrank", + "id": "Blitzcrank", + "title": "the Great Steam Golem", "gender": "Other", "positions": [ "Support" @@ -318,6 +352,8 @@ }, { "championName": "Brand", + "id": "Brand", + "title": "the Burning Vengeance", "gender": "Male", "positions": [ "Support" @@ -338,6 +374,8 @@ }, { "championName": "Braum", + "id": "Braum", + "title": "the Heart of the Freljord", "gender": "Male", "positions": [ "Support" @@ -357,6 +395,8 @@ }, { "championName": "Briar", + "id": "Briar", + "title": "the Restrained Hunger", "gender": "Female", "positions": [ "Jungle" @@ -375,6 +415,8 @@ }, { "championName": "Caitlyn", + "id": "Caitlyn", + "title": "the Sheriff of Piltover", "gender": "Female", "positions": [ "Bottom" @@ -393,6 +435,8 @@ }, { "championName": "Camille", + "id": "Camille", + "title": "the Steel Shadow", "gender": "Female", "positions": [ "Top" @@ -412,6 +456,8 @@ }, { "championName": "Cassiopeia", + "id": "Cassiopeia", + "title": "the Serpent's Embrace", "gender": "Female", "positions": [ "Top", @@ -433,6 +479,8 @@ }, { "championName": "Cho'Gath", + "id": "Chogath", + "title": "the Terror of the Void", "gender": "Male", "positions": [ "Top" @@ -451,6 +499,8 @@ }, { "championName": "Corki", + "id": "Corki", + "title": "the Daring Bombardier", "gender": "Male", "positions": [ "Bottom" @@ -470,6 +520,8 @@ }, { "championName": "Darius", + "id": "Darius", + "title": "the Hand of Noxus", "gender": "Male", "positions": [ "Top" @@ -488,6 +540,8 @@ }, { "championName": "Diana", + "id": "Diana", + "title": "Scorn of the Moon", "gender": "Female", "positions": [ "Jungle", @@ -508,6 +562,8 @@ }, { "championName": "Dr. Mundo", + "id": "DrMundo", + "title": "the Madman of Zaun", "gender": "Male", "positions": [ "Top", @@ -528,6 +584,8 @@ }, { "championName": "Draven", + "id": "Draven", + "title": "the Glorious Executioner", "gender": "Male", "positions": [ "Bottom" @@ -546,6 +604,8 @@ }, { "championName": "Ekko", + "id": "Ekko", + "title": "the Boy Who Shattered Time", "gender": "Male", "positions": [ "Jungle", @@ -565,6 +625,8 @@ }, { "championName": "Elise", + "id": "Elise", + "title": "the Spider Queen", "gender": "Female", "positions": [ "Jungle", @@ -587,6 +649,8 @@ }, { "championName": "Evelynn", + "id": "Evelynn", + "title": "Agony's Embrace", "gender": "Female", "positions": [ "Jungle" @@ -606,6 +670,8 @@ }, { "championName": "Ezreal", + "id": "Ezreal", + "title": "the Prodigal Explorer", "gender": "Male", "positions": [ "Bottom" @@ -625,6 +691,8 @@ }, { "championName": "Fiddlesticks", + "id": "Fiddlesticks", + "title": "the Ancient Fear", "gender": "Other", "positions": [ "Jungle" @@ -644,6 +712,8 @@ }, { "championName": "Fiora", + "id": "Fiora", + "title": "the Grand Duelist", "gender": "Female", "positions": [ "Top" @@ -662,6 +732,8 @@ }, { "championName": "Fizz", + "id": "Fizz", + "title": "the Tidal Trickster", "gender": "Male", "positions": [ "Middle" @@ -680,6 +752,8 @@ }, { "championName": "Galio", + "id": "Galio", + "title": "the Colossus", "gender": "Male", "positions": [ "Middle" @@ -698,6 +772,8 @@ }, { "championName": "Gangplank", + "id": "Gangplank", + "title": "the Saltwater Scourge", "gender": "Male", "positions": [ "Top" @@ -716,6 +792,8 @@ }, { "championName": "Garen", + "id": "Garen", + "title": "The Might of Demacia", "gender": "Male", "positions": [ "Top" @@ -734,6 +812,8 @@ }, { "championName": "Gnar", + "id": "Gnar", + "title": "the Missing Link", "gender": "Male", "positions": [ "Top" @@ -753,6 +833,8 @@ }, { "championName": "Gragas", + "id": "Gragas", + "title": "the Rabble Rouser", "gender": "Male", "positions": [ "Top", @@ -773,6 +855,8 @@ }, { "championName": "Graves", + "id": "Graves", + "title": "the Outlaw", "gender": "Male", "positions": [ "Jungle" @@ -791,6 +875,8 @@ }, { "championName": "Gwen", + "id": "Gwen", + "title": "The Hallowed Seamstress", "gender": "Female", "positions": [ "Top", @@ -812,6 +898,8 @@ }, { "championName": "Hecarim", + "id": "Hecarim", + "title": "the Shadow of War", "gender": "Male", "positions": [ "Jungle" @@ -831,6 +919,8 @@ }, { "championName": "Heimerdinger", + "id": "Heimerdinger", + "title": "the Revered Inventor", "gender": "Male", "positions": [ "Top", @@ -850,6 +940,8 @@ }, { "championName": "Hwei", + "id": "Hwei", + "title": "the Visionary", "gender": "Male", "positions": [ "Middle" @@ -869,6 +961,8 @@ }, { "championName": "Illaoi", + "id": "Illaoi", + "title": "the Kraken Priestess", "gender": "Female", "positions": [ "Top" @@ -888,6 +982,8 @@ }, { "championName": "Irelia", + "id": "Irelia", + "title": "the Blade Dancer", "gender": "Female", "positions": [ "Top", @@ -908,6 +1004,8 @@ }, { "championName": "Ivern", + "id": "Ivern", + "title": "the Green Father", "gender": "Male", "positions": [ "Jungle" @@ -928,6 +1026,8 @@ }, { "championName": "Janna", + "id": "Janna", + "title": "the Storm's Fury", "gender": "Female", "positions": [ "Support" @@ -948,6 +1048,8 @@ }, { "championName": "Jarvan IV", + "id": "JarvanIV", + "title": "the Exemplar of Demacia", "gender": "Male", "positions": [ "Jungle" @@ -966,6 +1068,8 @@ }, { "championName": "Jax", + "id": "Jax", + "title": "Grandmaster at Arms", "gender": "Male", "positions": [ "Top", @@ -987,6 +1091,8 @@ }, { "championName": "Jayce", + "id": "Jayce", + "title": "the Defender of Tomorrow", "gender": "Male", "positions": [ "Top", @@ -1007,6 +1113,8 @@ }, { "championName": "Jhin", + "id": "Jhin", + "title": "the Virtuoso", "gender": "Male", "positions": [ "Bottom" @@ -1026,6 +1134,8 @@ }, { "championName": "Jinx", + "id": "Jinx", + "title": "the Loose Cannon", "gender": "Female", "positions": [ "Bottom" @@ -1045,6 +1155,8 @@ }, { "championName": "K'Sante", + "id": "KSante", + "title": "the Pride of Nazumah", "gender": "Male", "positions": [ "Top" @@ -1063,6 +1175,8 @@ }, { "championName": "Kai'Sa", + "id": "Kaisa", + "title": "Daughter of the Void", "gender": "Female", "positions": [ "Bottom" @@ -1083,6 +1197,8 @@ }, { "championName": "Kalista", + "id": "Kalista", + "title": "the Spear of Vengeance", "gender": "Female", "positions": [ "Bottom" @@ -1102,6 +1218,8 @@ }, { "championName": "Karma", + "id": "Karma", + "title": "the Enlightened One", "gender": "Female", "positions": [ "Support" @@ -1121,6 +1239,8 @@ }, { "championName": "Karthus", + "id": "Karthus", + "title": "the Deathsinger", "gender": "Male", "positions": [ "Jungle" @@ -1140,6 +1260,8 @@ }, { "championName": "Kassadin", + "id": "Kassadin", + "title": "the Void Walker", "gender": "Male", "positions": [ "Middle" @@ -1160,6 +1282,8 @@ }, { "championName": "Katarina", + "id": "Katarina", + "title": "the Sinister Blade", "gender": "Female", "positions": [ "Middle" @@ -1178,6 +1302,8 @@ }, { "championName": "Kayle", + "id": "Kayle", + "title": "the Righteous", "gender": "Female", "positions": [ "Top" @@ -1200,6 +1326,8 @@ }, { "championName": "Kayn", + "id": "Kayn", + "title": "the Shadow Reaper", "gender": "Male", "positions": [ "Jungle" @@ -1223,6 +1351,8 @@ }, { "championName": "Kennen", + "id": "Kennen", + "title": "the Heart of the Tempest", "gender": "Male", "positions": [ "Top", @@ -1242,6 +1372,8 @@ }, { "championName": "Kha'Zix", + "id": "Khazix", + "title": "the Voidreaver", "gender": "Male", "positions": [ "Jungle" @@ -1260,6 +1392,8 @@ }, { "championName": "Kindred", + "id": "Kindred", + "title": "The Eternal Hunters", "gender": "Other", "positions": [ "Jungle" @@ -1279,6 +1413,8 @@ }, { "championName": "Kled", + "id": "Kled", + "title": "the Cantankerous Cavalier", "gender": "Male", "positions": [ "Top" @@ -1297,6 +1433,8 @@ }, { "championName": "Kog'Maw", + "id": "KogMaw", + "title": "the Mouth of the Abyss", "gender": "Male", "positions": [ "Bottom" @@ -1315,6 +1453,8 @@ }, { "championName": "LeBlanc", + "id": "Leblanc", + "title": "the Deceiver", "gender": "Female", "positions": [ "Middle" @@ -1334,6 +1474,8 @@ }, { "championName": "Lee Sin", + "id": "LeeSin", + "title": "the Blind Monk", "gender": "Male", "positions": [ "Jungle" @@ -1353,6 +1495,8 @@ }, { "championName": "Leona", + "id": "Leona", + "title": "the Radiant Dawn", "gender": "Female", "positions": [ "Support" @@ -1372,6 +1516,8 @@ }, { "championName": "Lillia", + "id": "Lillia", + "title": "the Bashful Bloom", "gender": "Female", "positions": [ "Jungle" @@ -1390,6 +1536,8 @@ }, { "championName": "Lissandra", + "id": "Lissandra", + "title": "the Ice Witch", "gender": "Female", "positions": [ "Middle" @@ -1409,6 +1557,8 @@ }, { "championName": "Lucian", + "id": "Lucian", + "title": "the Purifier", "gender": "Male", "positions": [ "Bottom" @@ -1428,6 +1578,8 @@ }, { "championName": "Lulu", + "id": "Lulu", + "title": "the Fae Sorceress", "gender": "Female", "positions": [ "Support" @@ -1446,6 +1598,8 @@ }, { "championName": "Lux", + "id": "Lux", + "title": "the Lady of Luminosity", "gender": "Female", "positions": [ "Middle", @@ -1466,6 +1620,8 @@ }, { "championName": "Malphite", + "id": "Malphite", + "title": "Shard of the Monolith", "gender": "Male", "positions": [ "Top" @@ -1485,6 +1641,8 @@ }, { "championName": "Malzahar", + "id": "Malzahar", + "title": "the Prophet of the Void", "gender": "Male", "positions": [ "Middle" @@ -1505,6 +1663,8 @@ }, { "championName": "Maokai", + "id": "Maokai", + "title": "the Twisted Treant", "gender": "Male", "positions": [ "Support" @@ -1523,6 +1683,8 @@ }, { "championName": "Master Yi", + "id": "MasterYi", + "title": "the Wuju Bladesman", "gender": "Male", "positions": [ "Jungle" @@ -1542,6 +1704,8 @@ }, { "championName": "Mel", + "id": "Mel", + "title": "the Soul's Reflection", "gender": "Female", "positions": [ "Middle", @@ -1563,6 +1727,8 @@ }, { "championName": "Milio", + "id": "Milio", + "title": "The Gentle Flame", "gender": "Male", "positions": [ "Support" @@ -1582,6 +1748,8 @@ }, { "championName": "Miss Fortune", + "id": "MissFortune", + "title": "the Bounty Hunter", "gender": "Female", "positions": [ "Bottom" @@ -1600,6 +1768,8 @@ }, { "championName": "Mordekaiser", + "id": "Mordekaiser", + "title": "the Iron Revenant", "gender": "Male", "positions": [ "Top" @@ -1619,6 +1789,8 @@ }, { "championName": "Morgana", + "id": "Morgana", + "title": "the Fallen", "gender": "Female", "positions": [ "Middle", @@ -1641,6 +1813,8 @@ }, { "championName": "Naafiri", + "id": "Naafiri", + "title": "the Hound of a Hundred Bites", "gender": "Female", "positions": [ "Jungle", @@ -1661,6 +1835,8 @@ }, { "championName": "Nami", + "id": "Nami", + "title": "the Tidecaller", "gender": "Female", "positions": [ "Support" @@ -1680,6 +1856,8 @@ }, { "championName": "Nasus", + "id": "Nasus", + "title": "the Curator of the Sands", "gender": "Male", "positions": [ "Top" @@ -1698,6 +1876,8 @@ }, { "championName": "Nautilus", + "id": "Nautilus", + "title": "the Titan of the Depths", "gender": "Male", "positions": [ "Support" @@ -1716,6 +1896,8 @@ }, { "championName": "Neeko", + "id": "Neeko", + "title": "the Curious Chameleon", "gender": "Female", "positions": [ "Support" @@ -1734,6 +1916,8 @@ }, { "championName": "Nidalee", + "id": "Nidalee", + "title": "the Bestial Huntress", "gender": "Female", "positions": [ "Jungle" @@ -1754,6 +1938,8 @@ }, { "championName": "Nilah", + "id": "Nilah", + "title": "the Joy Unbound", "gender": "Female", "positions": [ "Bottom" @@ -1773,6 +1959,8 @@ }, { "championName": "Nocturne", + "id": "Nocturne", + "title": "the Eternal Nightmare", "gender": "Male", "positions": [ "Jungle" @@ -1792,6 +1980,8 @@ }, { "championName": "Nunu & Willump", + "id": "Nunu", + "title": "the Boy and His Yeti", "gender": "Male", "positions": [ "Jungle" @@ -1811,6 +2001,8 @@ }, { "championName": "Olaf", + "id": "Olaf", + "title": "the Berserker", "gender": "Male", "positions": [ "Top" @@ -1830,6 +2022,8 @@ }, { "championName": "Orianna", + "id": "Orianna", + "title": "the Lady of Clockwork", "gender": "Female", "positions": [ "Middle" @@ -1848,6 +2042,8 @@ }, { "championName": "Ornn", + "id": "Ornn", + "title": "The Fire below the Mountain", "gender": "Male", "positions": [ "Top" @@ -1867,6 +2063,8 @@ }, { "championName": "Pantheon", + "id": "Pantheon", + "title": "the Unbreakable Spear", "gender": "Male", "positions": [ "Top", @@ -1888,6 +2086,8 @@ }, { "championName": "Poppy", + "id": "Poppy", + "title": "Keeper of the Hammer", "gender": "Female", "positions": [ "Top", @@ -1907,6 +2107,8 @@ }, { "championName": "Pyke", + "id": "Pyke", + "title": "the Bloodharbor Ripper", "gender": "Male", "positions": [ "Support" @@ -1925,6 +2127,8 @@ }, { "championName": "Qiyana", + "id": "Qiyana", + "title": "Empress of the Elements", "gender": "Female", "positions": [ "Jungle", @@ -1945,6 +2149,8 @@ }, { "championName": "Quinn", + "id": "Quinn", + "title": "Demacia's Wings", "gender": "Female", "positions": [ "Top" @@ -1963,6 +2169,8 @@ }, { "championName": "Rakan", + "id": "Rakan", + "title": "The Charmer", "gender": "Male", "positions": [ "Support" @@ -1981,6 +2189,8 @@ }, { "championName": "Rammus", + "id": "Rammus", + "title": "the Armordillo", "gender": "Male", "positions": [ "Jungle" @@ -1999,6 +2209,8 @@ }, { "championName": "Rek'Sai", + "id": "RekSai", + "title": "the Void Burrower", "gender": "Female", "positions": [ "Jungle" @@ -2018,6 +2230,8 @@ }, { "championName": "Rell", + "id": "Rell", + "title": "the Iron Maiden", "gender": "Female", "positions": [ "Support" @@ -2038,6 +2252,8 @@ }, { "championName": "Renata Glasc", + "id": "Renata", + "title": "the Chem-Baroness", "gender": "Female", "positions": [ "Support" @@ -2057,6 +2273,8 @@ }, { "championName": "Renekton", + "id": "Renekton", + "title": "the Butcher of the Sands", "gender": "Male", "positions": [ "Top" @@ -2075,6 +2293,8 @@ }, { "championName": "Rengar", + "id": "Rengar", + "title": "the Pridestalker", "gender": "Male", "positions": [ "Jungle" @@ -2094,6 +2314,8 @@ }, { "championName": "Riven", + "id": "Riven", + "title": "the Exile", "gender": "Female", "positions": [ "Top" @@ -2113,6 +2335,8 @@ }, { "championName": "Rumble", + "id": "Rumble", + "title": "the Mechanized Menace", "gender": "Male", "positions": [ "Top" @@ -2131,6 +2355,8 @@ }, { "championName": "Ryze", + "id": "Ryze", + "title": "the Rune Mage", "gender": "Male", "positions": [ "Middle" @@ -2150,6 +2376,8 @@ }, { "championName": "Samira", + "id": "Samira", + "title": "the Desert Rose", "gender": "Female", "positions": [ "Bottom" @@ -2169,6 +2397,8 @@ }, { "championName": "Sejuani", + "id": "Sejuani", + "title": "Fury of the North", "gender": "Female", "positions": [ "Jungle" @@ -2188,6 +2418,8 @@ }, { "championName": "Senna", + "id": "Senna", + "title": "the Redeemer", "gender": "Female", "positions": [ "Support" @@ -2207,6 +2439,8 @@ }, { "championName": "Seraphine", + "id": "Seraphine", + "title": "the Starry-Eyed Songstress", "gender": "Female", "positions": [ "Support" @@ -2227,6 +2461,8 @@ }, { "championName": "Sett", + "id": "Sett", + "title": "the Boss", "gender": "Male", "positions": [ "Top" @@ -2246,6 +2482,8 @@ }, { "championName": "Shaco", + "id": "Shaco", + "title": "the Demon Jester", "gender": "Male", "positions": [ "Jungle", @@ -2265,6 +2503,8 @@ }, { "championName": "Shen", + "id": "Shen", + "title": "the Eye of Twilight", "gender": "Male", "positions": [ "Top", @@ -2285,6 +2525,8 @@ }, { "championName": "Shyvana", + "id": "Shyvana", + "title": "the Half-Dragon", "gender": "Female", "positions": [ "Jungle" @@ -2304,6 +2546,8 @@ }, { "championName": "Singed", + "id": "Singed", + "title": "the Mad Chemist", "gender": "Male", "positions": [ "Top" @@ -2324,6 +2568,8 @@ }, { "championName": "Sion", + "id": "Sion", + "title": "The Undead Juggernaut", "gender": "Male", "positions": [ "Top" @@ -2342,6 +2588,8 @@ }, { "championName": "Sivir", + "id": "Sivir", + "title": "the Battle Mistress", "gender": "Female", "positions": [ "Bottom" @@ -2360,6 +2608,8 @@ }, { "championName": "Skarner", + "id": "Skarner", + "title": "the Primordial Sovereign", "gender": "Male", "positions": [ "Jungle" @@ -2378,6 +2628,8 @@ }, { "championName": "Smolder", + "id": "Smolder", + "title": "the Fiery Fledgling", "gender": "Male", "positions": [ "Bottom" @@ -2397,6 +2649,8 @@ }, { "championName": "Sona", + "id": "Sona", + "title": "Maven of the Strings", "gender": "Female", "positions": [ "Support" @@ -2417,6 +2671,8 @@ }, { "championName": "Soraka", + "id": "Soraka", + "title": "the Starchild", "gender": "Female", "positions": [ "Support" @@ -2436,6 +2692,8 @@ }, { "championName": "Swain", + "id": "Swain", + "title": "the Noxian Grand General", "gender": "Male", "positions": [ "Support" @@ -2455,6 +2713,8 @@ }, { "championName": "Sylas", + "id": "Sylas", + "title": "the Unshackled", "gender": "Male", "positions": [ "Jungle", @@ -2476,6 +2736,8 @@ }, { "championName": "Syndra", + "id": "Syndra", + "title": "the Dark Sovereign", "gender": "Female", "positions": [ "Middle" @@ -2495,6 +2757,8 @@ }, { "championName": "Tahm Kench", + "id": "TahmKench", + "title": "The River King", "gender": "Male", "positions": [ "Top", @@ -2516,6 +2780,8 @@ }, { "championName": "Taliyah", + "id": "Taliyah", + "title": "the Stoneweaver", "gender": "Female", "positions": [ "Middle" @@ -2535,6 +2801,8 @@ }, { "championName": "Talon", + "id": "Talon", + "title": "the Blade's Shadow", "gender": "Male", "positions": [ "Jungle", @@ -2554,6 +2822,8 @@ }, { "championName": "Taric", + "id": "Taric", + "title": "the Shield of Valoran", "gender": "Male", "positions": [ "Support" @@ -2574,6 +2844,8 @@ }, { "championName": "Teemo", + "id": "Teemo", + "title": "the Swift Scout", "gender": "Male", "positions": [ "Top" @@ -2592,6 +2864,8 @@ }, { "championName": "Thresh", + "id": "Thresh", + "title": "the Chain Warden", "gender": "Male", "positions": [ "Support" @@ -2610,6 +2884,8 @@ }, { "championName": "Tristana", + "id": "Tristana", + "title": "the Yordle Gunner", "gender": "Female", "positions": [ "Bottom" @@ -2628,6 +2904,8 @@ }, { "championName": "Trundle", + "id": "Trundle", + "title": "the Troll King", "gender": "Male", "positions": [ "Top", @@ -2648,6 +2926,8 @@ }, { "championName": "Tryndamere", + "id": "Tryndamere", + "title": "the Barbarian King", "gender": "Male", "positions": [ "Top" @@ -2667,6 +2947,8 @@ }, { "championName": "Twisted Fate", + "id": "TwistedFate", + "title": "the Card Master", "gender": "Male", "positions": [ "Middle" @@ -2686,6 +2968,8 @@ }, { "championName": "Twitch", + "id": "Twitch", + "title": "the Plague Rat", "gender": "Male", "positions": [ "Bottom" @@ -2705,6 +2989,8 @@ }, { "championName": "Udyr", + "id": "Udyr", + "title": "the Spirit Walker", "gender": "Male", "positions": [ "Jungle" @@ -2725,6 +3011,8 @@ }, { "championName": "Urgot", + "id": "Urgot", + "title": "the Dreadnought", "gender": "Male", "positions": [ "Top" @@ -2746,6 +3034,8 @@ }, { "championName": "Varus", + "id": "Varus", + "title": "the Arrow of Retribution", "gender": "Male", "positions": [ "Bottom" @@ -2767,6 +3057,8 @@ }, { "championName": "Vayne", + "id": "Vayne", + "title": "the Night Hunter", "gender": "Female", "positions": [ "Top", @@ -2786,6 +3078,8 @@ }, { "championName": "Veigar", + "id": "Veigar", + "title": "the Tiny Master of Evil", "gender": "Male", "positions": [ "Middle" @@ -2806,6 +3100,8 @@ }, { "championName": "Vel'Koz", + "id": "Velkoz", + "title": "the Eye of the Void", "gender": "Male", "positions": [ "Middle", @@ -2825,6 +3121,8 @@ }, { "championName": "Vex", + "id": "Vex", + "title": "the Gloomist", "gender": "Female", "positions": [ "Middle" @@ -2844,6 +3142,8 @@ }, { "championName": "Vi", + "id": "Vi", + "title": "the Piltover Enforcer", "gender": "Female", "positions": [ "Jungle" @@ -2863,6 +3163,8 @@ }, { "championName": "Viego", + "id": "Viego", + "title": "The Ruined King", "gender": "Male", "positions": [ "Jungle" @@ -2882,6 +3184,8 @@ }, { "championName": "Viktor", + "id": "Viktor", + "title": "the Herald of the Arcane", "gender": "Male", "positions": [ "Middle" @@ -2902,6 +3206,8 @@ }, { "championName": "Vladimir", + "id": "Vladimir", + "title": "the Crimson Reaper", "gender": "Male", "positions": [ "Top", @@ -2924,6 +3230,8 @@ }, { "championName": "Volibear", + "id": "Volibear", + "title": "the Relentless Storm", "gender": "Male", "positions": [ "Top", @@ -2944,6 +3252,8 @@ }, { "championName": "Warwick", + "id": "Warwick", + "title": "the Uncaged Wrath of Zaun", "gender": "Male", "positions": [ "Jungle" @@ -2964,6 +3274,8 @@ }, { "championName": "Wukong", + "id": "MonkeyKing", + "title": "the Monkey King", "gender": "Male", "positions": [ "Jungle" @@ -2982,6 +3294,8 @@ }, { "championName": "Xayah", + "id": "Xayah", + "title": "the Rebel", "gender": "Female", "positions": [ "Bottom" @@ -3000,6 +3314,8 @@ }, { "championName": "Xerath", + "id": "Xerath", + "title": "the Magus Ascendant", "gender": "Male", "positions": [ "Middle", @@ -3019,6 +3335,8 @@ }, { "championName": "Xin Zhao", + "id": "XinZhao", + "title": "the Seneschal of Demacia", "gender": "Male", "positions": [ "Jungle" @@ -3039,6 +3357,8 @@ }, { "championName": "Yasuo", + "id": "Yasuo", + "title": "the Unforgiven", "gender": "Male", "positions": [ "Top", @@ -3059,6 +3379,8 @@ }, { "championName": "Yone", + "id": "Yone", + "title": "the Unforgotten", "gender": "Male", "positions": [ "Top", @@ -3079,6 +3401,8 @@ }, { "championName": "Yorick", + "id": "Yorick", + "title": "Shepherd of Souls", "gender": "Male", "positions": [ "Top" @@ -3098,6 +3422,8 @@ }, { "championName": "Yunara", + "id": "Yunara", + "title": "the Unbroken Faith", "gender": "Female", "positions": [ "Bottom" @@ -3117,6 +3443,8 @@ }, { "championName": "Yuumi", + "id": "Yuumi", + "title": "the Magical Cat", "gender": "Female", "positions": [ "Support" @@ -3136,6 +3464,8 @@ }, { "championName": "Zaahen", + "id": "Zaahen", + "title": "The Unsundered", "gender": "Male", "positions": [ "Top" @@ -3156,6 +3486,8 @@ }, { "championName": "Zac", + "id": "Zac", + "title": "the Secret Weapon", "gender": "Male", "positions": [ "Jungle" @@ -3174,6 +3506,8 @@ }, { "championName": "Zed", + "id": "Zed", + "title": "the Master of Shadows", "gender": "Male", "positions": [ "Jungle", @@ -3194,6 +3528,8 @@ }, { "championName": "Zeri", + "id": "Zeri", + "title": "The Spark of Zaun", "gender": "Female", "positions": [ "Bottom" @@ -3213,6 +3549,8 @@ }, { "championName": "Ziggs", + "id": "Ziggs", + "title": "the Hexplosives Expert", "gender": "Male", "positions": [ "Middle", @@ -3232,6 +3570,8 @@ }, { "championName": "Zilean", + "id": "Zilean", + "title": "the Chronokeeper", "gender": "Male", "positions": [ "Support" @@ -3253,6 +3593,8 @@ }, { "championName": "Zoe", + "id": "Zoe", + "title": "the Aspect of Twilight", "gender": "Female", "positions": [ "Middle" @@ -3272,6 +3614,8 @@ }, { "championName": "Zyra", + "id": "Zyra", + "title": "Rise of the Thorns", "gender": "Female", "positions": [ "Support" diff --git a/internal/modules/loldle/handlers.go b/internal/modules/loldle/handlers.go deleted file mode 100644 index 8fb7142..0000000 --- a/internal/modules/loldle/handlers.go +++ /dev/null @@ -1,298 +0,0 @@ -package loldle - -import ( - "context" - "fmt" - "html" - "math/rand" - "strconv" - - "github.com/go-telegram/bot" - "github.com/go-telegram/bot/models" - - "github.com/tiennm99/tiennm99bot/internal/keylock" - "github.com/tiennm99/tiennm99bot/internal/modules/util/chathelper" -) - -const newRoundHint = "🆕 Send /loldle or /loldle <champion> to start a new round." - -// state captures everything a loldle handler needs at runtime. Built once -// per Factory call and shared across the four command closures. -type state struct { - games GameStore - stats StatsStore - cfg ConfigStore - champions []Champion - locks keylock.Map // serialises Get→mutate→Put per subject -} - -// pickRandomChampion uses math/rand's mutex-protected globals so concurrent -// /loldle handlers don't race on a shared *rand.Rand. -func (s *state) pickRandomChampion() *Champion { - return &s.champions[rand.Intn(len(s.champions))] -} - -func (s *state) findByName(name string) *Champion { - return findChampionByExactName(s.champions, name) -} - -// rehydrateGuesses recomputes board rows from the stored championNames. -// Guesses naming champions removed from champions.json since the round -// started are skipped, so a data refresh never breaks an active round. A -// missing target yields an empty board; handleLoldle clears that round on -// the next guess. -func (s *state) rehydrateGuesses(g *gameState) []boardEntry { - target := s.findByName(g.Target) - if target == nil { - return nil - } - out := make([]boardEntry, 0, len(g.Guesses)) - for _, name := range g.Guesses { - guess := s.findByName(name) - if guess == nil { - continue - } - out = append(out, boardEntry{Champion: name, Results: CompareChampions(guess, target)}) - } - return out -} - -// startFreshGame writes a new round with no startedAt clock yet. -func (s *state) startFreshGame(ctx context.Context, subject string, maxGuesses int) (*gameState, error) { - target := s.pickRandomChampion() - g := &gameState{ - Target: target.ChampionName, - Guesses: []string{}, - StartedAt: nil, - MaxGuesses: normalizeMaxGuesses(maxGuesses), - } - if err := saveGame(ctx, s.games, subject, g); err != nil { - return nil, err - } - return g, nil -} - -// getOrInitGame loads the active round; if absent (or the saved round has -// already exhausted its budget — defensive), starts a fresh one. -func (s *state) getOrInitGame(ctx context.Context, subject string, maxGuesses int) (*gameState, error) { - existing, err := loadGame(ctx, s.games, subject) - if err != nil { - return nil, err - } - if existing != nil { - roundMax := existing.roundMaxGuesses() - if len(existing.Guesses) < roundMax { - if existing.MaxGuesses != roundMax { - existing.MaxGuesses = roundMax - if err := saveGame(ctx, s.games, subject, existing); err != nil { - return nil, err - } - } - return existing, nil - } - } - return s.startFreshGame(ctx, subject, maxGuesses) -} - -// trySendSticker sends a sticker, swallowing errors. A bad/expired file_id -// must never block the text reply that carries the round outcome. -// -// Threads MessageThreadID so stickers post in the same forum-supergroup topic -// as the inbound command (omission posts to General — same bug class as the -// Reply helper). -func trySendSticker(ctx context.Context, b *bot.Bot, msg *models.Message, pool []string) { - id := pickSticker(pool) - if id == "" || msg == nil { - return - } - _, _ = b.SendSticker(ctx, &bot.SendStickerParams{ - ChatID: msg.Chat.ID, - MessageThreadID: msg.MessageThreadID, - Sticker: &models.InputFileString{Data: id}, - }) -} - -// handleLoldle is /loldle [champion] — show board if no arg, else submit guess. -func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Update) error { - msg := update.Message - if msg == nil { - return nil - } - subject := chathelper.SubjectFor(msg) - if subject == "" { - return chathelper.Reply(ctx, b, msg, "Cannot identify chat.") - } - defer s.locks.Acquire(subject)() - arg := chathelper.ArgAfterCommand(msg.Text) - - maxGuesses, err := getMaxGuesses(ctx, s.cfg, subject) - if err != nil { - return err - } - game, err := s.getOrInitGame(ctx, subject, maxGuesses) - if err != nil { - return err - } - roundMax := game.roundMaxGuesses() - - if arg == "" { - header := fmt.Sprintf("Guess %d/%d. Use /loldle <champion>.", - len(game.Guesses), roundMax) - board := renderBoard(s.rehydrateGuesses(game)) - return chathelper.ReplyHTML(ctx, b, msg, header+"\n\n"+board) - } - - guess, ambiguous := findChampionMatch(s.champions, arg) - if ambiguous { - return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Ambiguous champion %q. Type the full champion name.", arg)) - } - if guess == nil { - return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Champion not found: %q.", arg)) - } - - for _, prior := range game.Guesses { - if prior == guess.ChampionName { - return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf( - "🔁 %s was already guessed this round — try another champion.", - html.EscapeString(guess.ChampionName))) - } - } - - target := s.findByName(game.Target) - if target == nil { - // champions.json was refreshed and the target is gone. Drop the - // round so the next /loldle starts cleanly. - if err := clearGame(ctx, s.games, subject); err != nil { - return err - } - return chathelper.ReplyHTML(ctx, b, msg, - "Champion data was updated since this round started. "+newRoundHint) - } - - results := CompareChampions(guess, target) - if game.StartedAt == nil { - now := chathelper.NowMillis() - game.StartedAt = &now - } - game.Guesses = append(game.Guesses, guess.ChampionName) - won := guess.ChampionName == target.ChampionName - - rendered := renderGuess(guess.ChampionName, results) - champ := html.EscapeString(target.ChampionName) - elapsed := formatDuration(chathelper.NowMillis() - *game.StartedAt) - - switch { - case won: - st, err := recordResult(ctx, s.stats, subject, true) - if err != nil { - return err - } - if err := clearGame(ctx, s.games, subject); err != nil { - return err - } - trySendSticker(ctx, b, msg, winStickers) - flavor := attemptFlavor(len(game.Guesses), roundMax) - return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf( - "%s\n\n🎉 %s %s\n⏱ %s · 🔥 Streak: %d (%d/%d)\n%s", - rendered, flavor, champ, elapsed, st.Streak, len(game.Guesses), roundMax, newRoundHint)) - - case len(game.Guesses) >= roundMax: - if _, err := recordResult(ctx, s.stats, subject, false); err != nil { - return err - } - if err := clearGame(ctx, s.games, subject); err != nil { - return err - } - trySendSticker(ctx, b, msg, loseStickers) - return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf( - "%s\n\n❌ Out of guesses. Answer was %s.\n%s", rendered, champ, newRoundHint)) - - default: - if err := saveGame(ctx, s.games, subject, game); err != nil { - return err - } - return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf( - "%s\n\nGuess %d/%d.", rendered, len(game.Guesses), roundMax)) - } -} - -// handleGiveup is /loldle_giveup — reveal answer + clear round. -func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error { - msg := update.Message - if msg == nil { - return nil - } - subject := chathelper.SubjectFor(msg) - if subject == "" { - return chathelper.Reply(ctx, b, msg, "Cannot identify chat.") - } - defer s.locks.Acquire(subject)() - - existing, err := loadGame(ctx, s.games, subject) - if err != nil { - return err - } - if existing == nil { - return chathelper.ReplyHTML(ctx, b, msg, "No active round. "+newRoundHint) - } - if _, err := recordResult(ctx, s.stats, subject, false); err != nil { - return err - } - target := s.findByName(existing.Target) - if err := clearGame(ctx, s.games, subject); err != nil { - return err - } - trySendSticker(ctx, b, msg, giveupStickers) - answer := existing.Target - if target != nil { - answer = target.ChampionName - } - return chathelper.ReplyHTML(ctx, b, msg, - fmt.Sprintf("🏳️ Answer was %s.\n%s", html.EscapeString(answer), newRoundHint)) -} - -// handleStats is /loldle_stats — lifetime score. -func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error { - msg := update.Message - if msg == nil { - return nil - } - subject := chathelper.SubjectFor(msg) - if subject == "" { - return chathelper.Reply(ctx, b, msg, "Cannot identify chat.") - } - st, err := loadStats(ctx, s.stats, subject) - if err != nil { - return err - } - scope := "group" - if msg.Chat.Type == models.ChatTypePrivate { - scope = "your" - } - return chathelper.Reply(ctx, b, msg, fmt.Sprintf( - "📊 Loldle %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d", - scope, st.Played, st.Wins, chathelper.WinRate(st.Wins, st.Played), st.Streak, st.BestStreak)) -} - -// handleSetMax is /loldle_setmax — owner-only (VisibilityPrivate), sets -// the per-subject MaxGuesses override (1..MaxGuessesCap). Takes effect on the -// next round because an active round keeps its frozen MaxGuesses. -func (s *state) handleSetMax(ctx context.Context, b *bot.Bot, update *models.Update) error { - msg := update.Message - if msg == nil { - return nil - } - subject := chathelper.SubjectFor(msg) - if subject == "" { - return chathelper.Reply(ctx, b, msg, "Cannot identify chat.") - } - arg := chathelper.ArgAfterCommand(msg.Text) - n, err := strconv.Atoi(arg) - if err != nil || n < 1 || n > MaxGuessesCap { - return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Usage: /loldle_setmax <1-%d>", MaxGuessesCap)) - } - if err := setMaxGuesses(ctx, s.cfg, subject, n); err != nil { - return err - } - return chathelper.Reply(ctx, b, msg, fmt.Sprintf("✅ Loldle max guesses set to %d (applies to the next round).", n)) -} diff --git a/internal/modules/loldle/handlers_test.go b/internal/modules/loldle/handlers_test.go deleted file mode 100644 index 2594151..0000000 --- a/internal/modules/loldle/handlers_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package loldle - -import ( - "context" - "strings" - "testing" - - "github.com/tiennm99/tiennm99bot/internal/modules" - "github.com/tiennm99/tiennm99bot/internal/storage" - "github.com/tiennm99/tiennm99bot/internal/testutil" -) - -// installLoldle wires the loldle module + auth (owner gates /loldle_setmax, -// which is private). seedTarget pre-seeds a game so guess outcomes are -// deterministic without hooking math/rand. -func installLoldle(t *testing.T, ownerID int64, seedSubject, seedTarget string) (*testutil.RecordingBot, GameStore, ConfigStore) { - t.Helper() - rb := testutil.NewRecordingBot(t) - col := storage.NewMemoryProvider().Collection("loldle") - games := storage.Typed[gameState](col) - cfg := storage.Typed[roundConfig](col) - mod := New(modules.Deps{Store: col}) - reg := &modules.Registry{ - Modules: []modules.Module{{Name: "loldle", Commands: mod.Commands}}, - AllCommands: map[string]modules.Command{}, - } - for _, c := range mod.Commands { - reg.AllCommands[c.Name] = c - } - modules.Install(rb.Bot, reg, modules.Auth{BotOwnerID: ownerID}) - - if seedTarget != "" { - g := &gameState{Target: seedTarget, Guesses: []string{}} - if err := saveGame(context.Background(), games, seedSubject, g); err != nil { - t.Fatalf("seed game: %v", err) - } - } - return rb, games, cfg -} - -func TestLoldle_NoArgShowsBoard(t *testing.T) { - rb, _, _ := installLoldle(t, 0, "1", "Aatrox") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "Guess 0/") { - t.Errorf("/loldle no-arg reply missing 'Guess 0/...': %q", got) - } -} - -func TestLoldle_Win(t *testing.T) { - rb, _, _ := installLoldle(t, 0, "1", "Aatrox") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle aatrox")) - - got := rb.LastSent().Text() - if !strings.Contains(strings.ToLower(got), "aatrox") { - t.Errorf("win reply missing 'Aatrox': %q", got) - } - if !strings.Contains(got, "🎉") { - t.Errorf("win reply missing celebration emoji: %q", got) - } -} - -func TestLoldle_UnknownChampion(t *testing.T) { - rb, _, _ := installLoldle(t, 0, "1", "Aatrox") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle ZilbeanZ")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "Champion not found") { - t.Errorf("unknown champion reject: %q", got) - } -} - -func TestLoldle_AmbiguousChampionPrefix(t *testing.T) { - rb, _, _ := installLoldle(t, 0, "1", "Aatrox") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle A")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "Ambiguous champion") || !strings.Contains(got, "full champion name") { - t.Errorf("ambiguous champion reject: %q", got) - } -} - -func TestLoldle_DuplicateGuessRejected(t *testing.T) { - rb, _, _ := installLoldle(t, 0, "1", "Aatrox") - // First guess: Ahri (not the target — round continues). - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle ahri")) - rb.Reset() - // Second guess: Ahri again — duplicate path. - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle ahri")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "already guessed") { - t.Errorf("duplicate-guess reply: %q", got) - } -} - -func TestLoldleGiveup_RevealsAnswer(t *testing.T) { - rb, _, _ := installLoldle(t, 0, "1", "Aatrox") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_giveup")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "Aatrox") { - t.Errorf("/loldle_giveup should reveal Aatrox: %q", got) - } -} - -func TestLoldleGiveup_NoActiveRound(t *testing.T) { - rb, _, _ := installLoldle(t, 0, "", "") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_giveup")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "No active round") { - t.Errorf("/loldle_giveup with no round: %q", got) - } -} - -func TestLoldleStats_Empty(t *testing.T) { - rb, _, _ := installLoldle(t, 0, "", "") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_stats")) - - got := rb.LastSent().Text() - for _, want := range []string{"Played: 0", "Wins: 0 (0%)", "Best streak: 0"} { - if !strings.Contains(got, want) { - t.Errorf("/loldle_stats empty missing %q; got %q", want, got) - } - } -} - -func TestLoldleSetMax_OwnerSucceeds(t *testing.T) { - rb, _, cfg := installLoldle(t, 999, "", "") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_setmax 5")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "max guesses set to 5") { - t.Errorf("/loldle_setmax reply: %q", got) - } - c, _, err := cfg.Get(context.Background(), configKey("999")) - if err != nil { - t.Fatalf("expected config persisted: %v", err) - } - if c.MaxGuesses != 5 { - t.Errorf("MaxGuesses persisted = %d, want 5", c.MaxGuesses) - } -} - -func TestLoldleSetMax_AppliesNextRoundOnly(t *testing.T) { - rb, _, _ := installLoldle(t, 999, "999", "Aatrox") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_setmax 1")) - - rb.Reset() - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle")) - if got := rb.LastSent().Text(); !strings.Contains(got, "Guess 0/8") { - t.Fatalf("active round should keep default max after setmax: %q", got) - } - - rb.Reset() - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_giveup")) - rb.Reset() - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle")) - if got := rb.LastSent().Text(); !strings.Contains(got, "Guess 0/1") { - t.Fatalf("new round should use changed max: %q", got) - } -} - -func TestLoldleSetMax_DeniedToNonOwner(t *testing.T) { - rb, _, _ := installLoldle(t, 999, "", "") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/loldle_setmax 5")) - - if calls := rb.Sent(); len(calls) != 0 { - t.Errorf("non-owner /loldle_setmax replied: %+v", calls) - } -} - -func TestLoldleSetMax_RejectsOutOfRange(t *testing.T) { - rb, _, _ := installLoldle(t, 999, "", "") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_setmax 99")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "Usage:") { - t.Errorf("out-of-range setmax should show usage; got %q", got) - } -} diff --git a/internal/modules/loldle/helpers_test.go b/internal/modules/loldle/helpers_test.go new file mode 100644 index 0000000..09c9bbf --- /dev/null +++ b/internal/modules/loldle/helpers_test.go @@ -0,0 +1,260 @@ +package loldle + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/tiennm99bot/internal/modules" + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" + "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" + "github.com/tiennm99/tiennm99bot/internal/storage" + "github.com/tiennm99/tiennm99bot/internal/testutil" +) + +var ( + testRoot = []byte("0123456789abcdef0123456789abcdef") + testStart = guessgame.Epoch.Add(12 * time.Hour) // 19:00 ICT on puzzle #1's day + testBase = "https://game.example" + editStub = `{"message_id":1,"date":0,"chat":{"id":-100,"type":"supergroup"}}` + groupChat int64 = -100 +) + +type fakeClock struct { + mu sync.Mutex + t time.Time +} + +func (c *fakeClock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *fakeClock) advance(d time.Duration) { + c.mu.Lock() + c.t = c.t.Add(d) + c.mu.Unlock() +} + +type reportCall struct { + addr htmlgame.Address + user int64 + score int +} + +type fakeReporter struct { + mu sync.Mutex + calls []reportCall +} + +func (r *fakeReporter) Report(_ context.Context, a htmlgame.Address, userID int64, score int) error { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, reportCall{a, userID, score}) + return nil +} + +func (r *fakeReporter) snapshot() []reportCall { + r.mu.Lock() + defer r.mu.Unlock() + return append([]reportCall(nil), r.calls...) +} + +// fakeScheduler holds summary flushes until the test runs them. +type fakeScheduler struct { + mu sync.Mutex + fns []func() +} + +func (f *fakeScheduler) schedule(_ time.Duration, fn func()) { + f.mu.Lock() + defer f.mu.Unlock() + f.fns = append(f.fns, fn) +} + +func (f *fakeScheduler) run() { + for { + f.mu.Lock() + if len(f.fns) == 0 { + f.mu.Unlock() + return + } + fn := f.fns[0] + f.fns = f.fns[1:] + f.mu.Unlock() + fn() + } +} + +// testPick makes unlimited rounds deterministic: Ahri, then Jinx, then Ahri +// again. +func testPick(prev string) string { + if prev == "Ahri" { + return "Jinx" + } + return "Ahri" +} + +type harness struct { + t *testing.T + svc *service + clock *fakeClock + rep *fakeReporter + rb *testutil.RecordingBot + sched *fakeScheduler + coll storage.Collection + handler http.Handler +} + +func testConfig(clock *fakeClock, coll storage.Collection, rep guessgame.ScoreReporter, api guessgame.TelegramAPI, sched *fakeScheduler) config { + return config{ + baseURL: testBase, + rootKey: testRoot, + now: clock.now, + store: coll, + reporter: rep, + api: api, + schedule: sched.schedule, + async: func(f func()) { f() }, + champions: loadChampions(), + pick: testPick, + } +} + +func newHarness(t *testing.T) *harness { + t.Helper() + h := &harness{ + t: t, + clock: &fakeClock{t: testStart}, + rep: &fakeReporter{}, + rb: testutil.NewRecordingBot(t), + sched: &fakeScheduler{}, + coll: storage.NewMemoryProvider().Collection(ShortName), + } + h.rb.StubMethod("editMessageText", editStub) + h.svc = newService(testConfig(h.clock, h.coll, h.rep, h.rb.Bot, h.sched)) + mod := h.svc.module() + if len(mod.HTTP) != 1 || len(mod.Crons) != 2 { + t.Fatalf("enabled module: routes %d crons %d", len(mod.HTTP), len(mod.Crons)) + } + h.handler = mod.HTTP[0].Handler + return h +} + +// pinDaily makes champion the answer of daily puzzle num. +func (h *harness) pinDaily(num int, champion string) { + h.t.Helper() + if err := h.svc.Puzzles.Put(context.Background(), guessgame.PuzzleKey(num), guessgame.PuzzleDoc{Num: num, Answer: champion}); err != nil { + h.t.Fatal(err) + } +} + +// token signs a token for user on card msg in chat; unlimited marks a +// recorded /loldle card. +func (h *harness) token(user, chat int64, msg int, name string, unlimited bool) string { + h.t.Helper() + c := guessgame.Claims{UserID: user, Name: name, ChatID: chat, MessageID: msg, Expiry: h.clock.now().Add(guessgame.TokenTTL).Unix()} + if unlimited { + c.Mode = guessgame.ModeUnlimited + } + tok, err := h.svc.signToken(c) + if err != nil { + h.t.Fatal(err) + } + return tok +} + +type result struct { + code int + body string + v view + err string +} + +func (h *harness) post(path string, body any) result { + h.t.Helper() + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, routePrefix+"api/"+path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.handler.ServeHTTP(rec, req) + out, _ := io.ReadAll(rec.Result().Body) + r := result{code: rec.Code, body: string(out)} + if r.code == http.StatusOK { + if err := json.Unmarshal(out, &r.v); err != nil { + h.t.Fatalf("decode %q: %v", out, err) + } + } else { + var e struct { + Code string `json:"error"` + } + _ = json.Unmarshal(out, &e) + r.err = e.Code + } + return r +} + +func (h *harness) state(tok string) result { return h.post("state", map[string]any{"token": tok}) } + +func (h *harness) dailyGuess(tok string, num int, name string) result { + return h.post("guess", map[string]any{"token": tok, "num": num, "name": name}) +} + +func (h *harness) roundGuess(tok string, seq int, name string) result { + return h.post("guess", map[string]any{"token": tok, "seq": seq, "name": name}) +} + +// install registers mod through the real registry and dispatcher, with +// owner as the bot owner. +func install(t *testing.T, mod modules.Module, owner int64) *testutil.RecordingBot { + t.Helper() + rb := testutil.NewRecordingBot(t) + reg, err := modules.Build([]string{ShortName}, map[string]modules.Factory{ + ShortName: func(modules.Deps) modules.Module { return mod }, + }, storage.NewMemoryProvider(), modules.BuildOptions{}) + if err != nil { + t.Fatalf("Build: %v", err) + } + modules.Install(rb.Bot, reg, modules.Auth{BotOwnerID: owner}) + return rb +} + +// send runs one command from user in chat (a group when chat < 0) and +// returns the bot's last text reply. +func send(t *testing.T, rb *testutil.RecordingBot, chat, user int64, text string) string { + t.Helper() + rb.Reset() + var u *models.Update + if chat < 0 { + u = testutil.NewGroupMessage(chat, user, text) + } else { + u = testutil.NewPrivateMessage(user, text) + } + rb.Bot.ProcessUpdate(context.Background(), u) + sent := rb.Sent() + for i := len(sent) - 1; i >= 0; i-- { + if sent[i].Method == "sendMessage" { + return sent[i].Text() + } + } + return "" +} + +func sentMethod(rb *testutil.RecordingBot, method string) []testutil.SentCall { + var out []testutil.SentCall + for _, c := range rb.Sent() { + if c.Method == method { + out = append(out, c) + } + } + return out +} diff --git a/internal/modules/loldle/http_api.go b/internal/modules/loldle/http_api.go new file mode 100644 index 0000000..28d130b --- /dev/null +++ b/internal/modules/loldle/http_api.go @@ -0,0 +1,407 @@ +package loldle + +import ( + "context" + "errors" + "net/http" + "strconv" + "time" + + "github.com/tiennm99/tiennm99bot/internal/log" + "github.com/tiennm99/tiennm99bot/internal/modules/loldle/web" + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" + "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" +) + +// requestsPerMinute caps one player's API calls, every endpoint together. +const requestsPerMinute = 60 + +// maxNameBytes caps a guessed champion name in a request. +const maxNameBytes = 64 + +// apiError is the JSON error body every API failure returns. +type apiError struct { + status int + Code string `json:"error"` + Message string `json:"message"` +} + +var ( + errBadRequestAPI = apiError{http.StatusBadRequest, "bad_request", "Bad request."} + errBadTokenAPI = apiError{http.StatusUnauthorized, "bad_token", "This game link is not valid. Open the game again from the chat."} + errExpiredAPI = apiError{http.StatusUnauthorized, "expired", "This game link has expired. Open the game again from the chat."} + errBadModeAPI = apiError{http.StatusForbidden, "bad_mode", "This card plays the daily champion. Send /loldle for unlimited rounds."} + errNewPuzzleAPI = apiError{http.StatusConflict, "new_puzzle", "A new daily champion is up."} + errNewRoundAPI = apiError{http.StatusConflict, "new_round", "A new round has started."} + errDataChangedAPI = apiError{http.StatusConflict, "new_round", "Champion data changed, so a new round has started."} + errUnknownAPI = apiError{http.StatusUnprocessableEntity, "unknown", "Champion not found."} + errAmbiguousAPI = apiError{http.StatusUnprocessableEntity, "ambiguous", "Several champions match. Pick one from the list."} + errDuplicateAPI = apiError{http.StatusUnprocessableEntity, "duplicate", "You already guessed that champion."} + errFinishedAPI = apiError{http.StatusUnprocessableEntity, "finished", "You have already finished today's champion."} + errRoundFinishedAPI = apiError{http.StatusUnprocessableEntity, "finished", "This round is over. Start a new game."} + errTooFastAPI = apiError{http.StatusTooManyRequests, "rate_limited", "Too many requests. Wait a moment and try again."} + errInternalAPI = apiError{http.StatusInternalServerError, "internal", "Something went wrong. Try again later."} +) + +// Page modes, as the view reports them. +const ( + modeDaily = "daily" + modeUnlimited = "unlimited" +) + +// cellView is one attribute of a guess: the guessed champion's own value +// and how it compares with the answer. It never carries the answer's value. +type cellView struct { + Key string `json:"key"` + Value string `json:"value"` + Result string `json:"result"` // correct | partial | wrong + Dir string `json:"dir,omitempty"` // up | down, on a wrong release year +} + +// rowView is one guess on the board. +type rowView struct { + Name string `json:"name"` + ID string `json:"id"` + Marks string `json:"marks"` + Cells []cellView `json:"cells"` +} + +// champView is the answer, shown once the game is over. +type champView struct { + Name string `json:"name"` + ID string `json:"id"` + Title string `json:"title,omitempty"` +} + +// columnView names one attribute column. +type columnView struct { + Key string `json:"key"` + Label string `json:"label"` +} + +// statsView is the player's record in the card's mode, shown once the game +// is over. +type statsView struct { + Played int `json:"played"` + WinPct int `json:"win_pct"` + Cur int `json:"cur"` + Max int `json:"max"` + Dist []int `json:"dist"` +} + +// view is the state the page renders. The answer, the stats and the share +// text are present only once the game is over. A daily view carries the +// puzzle's number, date and the next puzzle's start; an unlimited one the +// round's number. +type view struct { + Mode string `json:"mode"` + Num int `json:"num,omitempty"` + Date string `json:"date,omitempty"` + Seq int `json:"seq,omitempty"` + Player string `json:"player"` + Max int `json:"max"` + Columns []columnView `json:"columns"` + Guesses []rowView `json:"guesses"` + Status string `json:"status"` + GaveUp bool `json:"gave_up,omitempty"` + Answer *champView `json:"answer,omitempty"` + Stats *statsView `json:"stats,omitempty"` + NextAt int64 `json:"next_at,omitempty"` + Share string `json:"share,omitempty"` + // Abandoned is the champion of the unlimited round a New game gave up + // as a loss, so the page can show it before the next round. + Abandoned *champView `json:"abandoned,omitempty"` +} + +type stateRequest struct { + Token string `json:"token"` +} + +// guessRequest is a guess on the board the page shows: Num names a daily +// puzzle, Seq an unlimited round. +type guessRequest struct { + Token string `json:"token"` + Num int `json:"num"` + Seq int `json:"seq"` + Name string `json:"name"` +} + +// newRequest starts the next unlimited round after round Seq. +type newRequest struct { + Token string `json:"token"` + Seq int `json:"seq"` +} + +// handler serves the page, its assets, the champion list and the JSON API +// under routePrefix. Images may come from Data Dragon, which serves the +// champion icons, and from nowhere else. +func (s *service) handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET "+routePrefix+"{$}", htmlgame.ServeAsset(web.FS, "index.html")) + mux.HandleFunc("GET "+routePrefix+"app.js", htmlgame.ServeAsset(web.FS, "app.js")) + mux.HandleFunc("GET "+routePrefix+"app.css", htmlgame.ServeAsset(web.FS, "app.css")) + mux.HandleFunc("GET "+routePrefix+"champions.json", s.serveChampions) + mux.HandleFunc("POST "+routePrefix+"api/state", s.apiState) + mux.HandleFunc("POST "+routePrefix+"api/guess", s.apiGuess) + mux.HandleFunc("POST "+routePrefix+"api/new", s.apiNew) + return htmlgame.SecurityHeaders(routePrefix, mux, ddragonOrigin) +} + +// serveChampions is the search list: every champion's name and icon id, +// nothing that hints at an answer. +func (s *service) serveChampions(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=3600") + _, _ = w.Write(s.championList) +} + +func writeError(w http.ResponseWriter, e apiError) { htmlgame.WriteJSON(w, e.status, e) } + +// authorize verifies the token and the player's request budget, writing +// the error itself when either fails. +func (s *service) authorize(w http.ResponseWriter, token string, now time.Time) (guessgame.Claims, bool) { + c, err := s.verifyToken(token, now) + switch { + case errors.Is(err, htmlgame.ErrTokenExpired): + writeError(w, errExpiredAPI) + return guessgame.Claims{}, false + case err != nil: + writeError(w, errBadTokenAPI) + return guessgame.Claims{}, false + } + if !s.limiter.Allow(c.UserID, now) { + writeError(w, errTooFastAPI) + return guessgame.Claims{}, false + } + return c, true +} + +// cardMax is the length of an unlimited round started from the token's +// card: its chat's /loldle_setmax, or the default for an inline card. +func (s *service) cardMax(ctx context.Context, c guessgame.Claims) (int, error) { + if c.InlineID != "" { + return MaxGuesses, nil + } + return s.maxFor(ctx, strconv.FormatInt(c.ChatID, 10)) +} + +func (s *service) apiState(w http.ResponseWriter, r *http.Request) { + var req stateRequest + if htmlgame.DecodeJSON(w, r, &req) != nil { + writeError(w, errBadRequestAPI) + return + } + c, ok := s.authorize(w, req.Token, s.cfg.now()) + if !ok { + return + } + ctx := r.Context() + if c.Unlimited() { + maxGuesses, err := s.cardMax(ctx, c) + var st guessgame.RoundState + if err == nil { + st, err = s.rounds.Load(ctx, c.UserID, maxGuesses) + } + s.writeRound(w, c, st, err) + return + } + o, err := s.LoadState(ctx, c) + if err != nil { + log.Error("loldle state failed", "err", err) + writeError(w, errInternalAPI) + return + } + htmlgame.WriteJSON(w, http.StatusOK, s.view(c, o)) +} + +func (s *service) apiGuess(w http.ResponseWriter, r *http.Request) { + var req guessRequest + if htmlgame.DecodeJSON(w, r, &req) != nil || len(req.Name) > maxNameBytes { + writeError(w, errBadRequestAPI) + return + } + c, ok := s.authorize(w, req.Token, s.cfg.now()) + if !ok { + return + } + ctx := r.Context() + if c.Unlimited() { + maxGuesses, err := s.cardMax(ctx, c) + var st guessgame.RoundState + if err == nil { + st, err = s.rounds.Guess(ctx, c.UserID, req.Seq, req.Name, maxGuesses) + } + if errors.Is(err, errTargetGone) { + // The round's answer left the data: replace it, uncounted. + if _, err = s.rounds.Replace(ctx, c.UserID, maxGuesses); err == nil { + writeError(w, errDataChangedAPI) + return + } + } + s.writeRound(w, c, st, err) + return + } + o, err := s.SubmitGuess(ctx, c, req.Num, req.Name) + switch { + case errors.Is(err, guessgame.ErrNewPuzzle): + writeError(w, errNewPuzzleAPI) + case errors.Is(err, guessgame.ErrFinished): + writeError(w, errFinishedAPI) + case guessError(w, err): + case err != nil: + log.Error("loldle guess failed", "err", err) + writeError(w, errInternalAPI) + default: + htmlgame.WriteJSON(w, http.StatusOK, s.view(c, o)) + } +} + +// apiNew starts the player's next unlimited round, giving up the current +// one. A stale seq answers with the current round, so a double click +// starts one round. +func (s *service) apiNew(w http.ResponseWriter, r *http.Request) { + var req newRequest + if htmlgame.DecodeJSON(w, r, &req) != nil || req.Seq < 1 { + writeError(w, errBadRequestAPI) + return + } + c, ok := s.authorize(w, req.Token, s.cfg.now()) + if !ok { + return + } + if !c.Unlimited() { + writeError(w, errBadModeAPI) + return + } + maxGuesses, err := s.cardMax(r.Context(), c) + var st guessgame.RoundState + if err == nil { + st, err = s.rounds.New(r.Context(), c.UserID, req.Seq, maxGuesses) + } + s.writeRound(w, c, st, err) +} + +// guessError writes the API error of a refused guess and reports whether +// err was one. +func guessError(w http.ResponseWriter, err error) bool { + switch { + case errors.Is(err, errUnknownChampion): + writeError(w, errUnknownAPI) + case errors.Is(err, errAmbiguous): + writeError(w, errAmbiguousAPI) + case errors.Is(err, errDuplicate): + writeError(w, errDuplicateAPI) + default: + return false + } + return true +} + +// writeRound answers an unlimited request. +func (s *service) writeRound(w http.ResponseWriter, c guessgame.Claims, st guessgame.RoundState, err error) { + switch { + case errors.Is(err, guessgame.ErrNewRound): + writeError(w, errNewRoundAPI) + case errors.Is(err, guessgame.ErrFinished): + writeError(w, errRoundFinishedAPI) + case guessError(w, err): + case err != nil: + log.Error("loldle unlimited request failed", "err", err) + writeError(w, errInternalAPI) + default: + htmlgame.WriteJSON(w, http.StatusOK, s.roundView(c, st)) + } +} + +// view renders a daily game for the page. The answer leaves the server +// only once the game is over. +func (s *service) view(c guessgame.Claims, o guessgame.Outcome) view { + v := view{ + Mode: modeDaily, + Num: o.Num, + Date: s.PuzzleDate(o.Num), + Player: c.Name, + Max: MaxGuesses, + Columns: columns(), + Guesses: s.rowViews(o.P.Guesses), + Status: o.P.Status, + NextAt: s.PuzzleStart(o.Num + 1).Unix(), + } + if v.Status == "" { + v.Status = guessgame.StatusPlaying + } + if !o.P.Finished() { + return v + } + v.Answer = s.champView(o.Answer) + st := o.Stats + v.Stats = statsOf(st.Played, st.Wins, guessgame.DisplayStreak(st, o.Num), st.MaxStreak, st.Dist) + v.Share = s.ShareText(o.P) + return v +} + +// roundView renders an unlimited round for the page. The answer leaves the +// server only once the round is over. +func (s *service) roundView(c guessgame.Claims, st guessgame.RoundState) view { + rd := st.Round + v := view{ + Mode: modeUnlimited, + Seq: rd.Seq, + Player: c.Name, + Max: rd.MaxGuesses, + Columns: columns(), + Guesses: s.rowViews(rd.Guesses), + Status: rd.Status, + GaveUp: rd.GaveUp, + } + if st.Abandoned != nil { + v.Abandoned = s.champView(st.Abandoned.Target) + } + if !rd.Finished() { + return v + } + v.Answer = s.champView(rd.Target) + v.Stats = statsOf(st.Stats.Played, st.Stats.Wins, st.Stats.CurStreak, st.Stats.MaxStreak, st.Stats.Dist) + return v +} + +func columns() []columnView { + out := make([]columnView, len(classicAttributes)) + for i, a := range classicAttributes { + out[i] = columnView{Key: a.Key, Label: a.Label} + } + return out +} + +// rowViews renders the guesses newest last, as stored; the page orders +// them. +func (s *service) rowViews(gs []guessgame.Guess) []rowView { + out := make([]rowView, len(gs)) + for i, g := range gs { + row := rowView{Name: g.Word, Marks: g.Marks, Cells: make([]cellView, 0, len(classicAttributes))} + if c := findChampionByExactName(s.cfg.champions, g.Word); c != nil { + row.ID = c.ID + } + for _, r := range s.rowsOf(g) { + row.Cells = append(row.Cells, cellView{Key: r.Key, Value: r.GuessValue, Result: r.Result, Dir: r.Direction}) + } + out[i] = row + } + return out +} + +func (s *service) champView(name string) *champView { + v := &champView{Name: name} + if c := findChampionByExactName(s.cfg.champions, name); c != nil { + v.ID, v.Title = c.ID, c.Title + } + return v +} + +func statsOf(played, wins, cur, maxStreak int, dist []int) *statsView { + pct := 0 + if played > 0 { + pct = (wins*100 + played/2) / played + } + return &statsView{Played: played, WinPct: pct, Cur: cur, Max: maxStreak, Dist: dist} +} diff --git a/internal/modules/loldle/loldle.go b/internal/modules/loldle/loldle.go index 5a85445..e785c5d 100644 --- a/internal/modules/loldle/loldle.go +++ b/internal/modules/loldle/loldle.go @@ -1,46 +1,297 @@ +// Package loldle is LoLdle, the League of Legends champion guessing game, in +// two modes behind the one BotFather game "loldle": +// +// - unlimited: /loldle sends a card that opens the player's own round +// with a random champion; the page's New game button starts another. +// /loldle , /loldle_giveup and /loldle_stats play and read +// the same round from the chat, with stickers and flavour lines, and +// /loldle_setmax sets a chat's round length. +// - LoLdle Daily: one shared champion a day from 07:00 ICT, like Wordle +// Daily: /loldledaily, the live spoiler-free group results, the 07:00 +// recap and push, and setGameScore on a win. +// +// Each guess is scored on seven attributes (gender, species, range type, +// resource, regions, positions, release year). Play picks the mode from the +// card: /loldle cards are recorded, and any other card plays daily. The +// server owns the game; the answer reaches the page only once that game is +// over. See docs/loldle.md. package loldle import ( + "cmp" + "encoding/json" + "math/rand/v2" + "os" + "slices" + "time" + + "github.com/tiennm99/tiennm99bot/internal/log" "github.com/tiennm99/tiennm99bot/internal/modules" + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" + "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" "github.com/tiennm99/tiennm99bot/internal/storage" ) -// New is the loldle module Factory. Loads champions.json once at construction -// and shares the parsed slice + per-subject lock map across all handlers. +const ( + // ShortName is the module's catalog key and the unlimited command name, + // so its routes live under /games/loldle/. + ShortName = "loldle" + // GameShortName is the BotFather game the cards send and Play answers. + GameShortName = "loldle" + + dailyCommand = "loldledaily" + subscribeCommand = dailyCommand + "_subscribe" + unsubscribeCommand = dailyCommand + "_unsubscribe" + + secretEnv = "LOLDLE_GAME_SECRET" //nolint:gosec // G101: an env var name, not a credential + + // minSecretBytes is the shortest LOLDLE_GAME_SECRET accepted. + minSecretBytes = 32 + // tokenKeyLabel and answerKeyLabel derive the two keys from the root + // secret, apart from every other game's. + tokenKeyLabel = "tiennm99bot/loldle/token/v1" //nolint:gosec // G101: a public derivation label + answerKeyLabel = "tiennm99bot/loldle/answer/v1" //nolint:gosec // G101: a public derivation label + + routePrefix = "/games/" + ShortName + "/" + + // dailyStatsPrefix keeps daily stats apart from the in-chat game's + // legacy "stats:" documents in the same collection. + dailyStatsPrefix = "dstats:" + + // dailyPushCronName and cardsCronName must be unique across all + // modules' crons. + dailyPushCronName = "loldledaily_daily_push" + cardsCronName = "loldle_unlimited_cards" + // cardsSchedule runs the unlimited card cleanup at 03:40 ICT. + cardsSchedule = "40 20 * * *" + + // ddragonOrigin serves the champion icons the page shows. + ddragonOrigin = "https://ddragon.leagueoflegends.com" +) + +// config is everything New reads from the environment, injectable in tests. +type config struct { + baseURL string // public https base without trailing slash; "" disables + rootKey []byte // derives the token and answer keys; empty disables + now func() time.Time + store storage.Collection // nil disables + reporter guessgame.ScoreReporter // nil disables + api guessgame.TelegramAPI // nil skips the live group summary + schedule func(d time.Duration, f func()) // runs the summary flush; time.AfterFunc by default + async func(f func()) // runs a score report; a goroutine by default + champions []Champion + epoch time.Time // daily puzzle #1 starts here; zero means the shared epoch + pick func(prev string) string // an unlimited round's champion; random by default +} + +// New builds the module from the environment. The web game is enabled only +// when GAME_BASE_URL is a valid https URL, a key is available and storage +// exists; the in-chat /loldle commands need storage only. func New(deps modules.Deps) modules.Module { - s := &state{ - games: storage.Typed[gameState](deps.Store), - stats: storage.Typed[stats](deps.Store), - cfg: storage.Typed[roundConfig](deps.Store), + cfg := config{ + baseURL: htmlgame.ParseBaseURL(os.Getenv(htmlgame.BaseURLEnv), ShortName), + rootKey: rootKey(os.Getenv(secretEnv), deps), + now: time.Now, + store: deps.Store, + schedule: func(d time.Duration, f func()) { time.AfterFunc(d, f) }, champions: loadChampions(), } - return modules.Module{ - Commands: []modules.Command{ - { - Name: "loldle", - Visibility: modules.VisibilityPublic, - Description: "Classic loldle — guess the current champion", - Parameters: "[champion]", - Handler: s.handleLoldle, - }, - { - Name: "loldle_giveup", - Visibility: modules.VisibilityPublic, - Description: "Reveal the current loldle answer", - Handler: s.handleGiveup, - }, - { - Name: "loldle_stats", - Visibility: modules.VisibilityPublic, - Description: "Show your loldle stats (wins, streak)", - Handler: s.handleStats, - }, - { - Name: "loldle_setmax", - Visibility: modules.VisibilityPrivate, - Description: "Override max guesses per round (1-10) for this chat", - Handler: s.handleSetMax, - }, - }, + if deps.Bot != nil { + cfg.reporter = guessgame.BotReporter{B: deps.Bot} + cfg.api = deps.Bot + } + return newService(cfg).module() +} + +// rootKey returns LOLDLE_GAME_SECRET when set, otherwise the bot token. A +// secret that is set but too short disables the game rather than silently +// falling back. +func rootKey(secret string, deps modules.Deps) []byte { + if secret != "" { + if len(secret) < minSecretBytes { + log.Warn("LOLDLE_GAME_SECRET is shorter than 32 bytes; loldle game disabled") + return nil + } + return []byte(secret) + } + if deps.Bot == nil || deps.Bot.Token() == "" { + return nil + } + return []byte(deps.Bot.Token()) +} + +// service is the module's runtime state. The daily mode is embedded, so +// its stores and methods read as the service's own. +type service struct { + *guessgame.Daily + cfg config + tokenKey []byte + answerKey []byte + rules rules + rounds *guessgame.Rounds // nil without storage + cards *guessgame.Cards // nil without storage + settings ConfigStore // nil without storage + limiter *htmlgame.Limiter + // championList is the public champion list the page searches: names + // and icon ids only, never an attribute. + championList []byte +} + +func newService(cfg config) *service { + if cfg.now == nil { + cfg.now = time.Now + } + names := make([]string, len(cfg.champions)) + for i, c := range cfg.champions { + names[i] = c.ChampionName + } + slices.Sort(names) + if cfg.pick == nil { + cfg.pick = randomPick(names) + } + svc := &service{ + cfg: cfg, + tokenKey: htmlgame.DeriveKey(cfg.rootKey, tokenKeyLabel), + answerKey: htmlgame.DeriveKey(cfg.rootKey, answerKeyLabel), + rules: rules{champions: cfg.champions}, + limiter: &htmlgame.Limiter{Per: requestsPerMinute, Window: time.Minute}, + championList: championList(cfg.champions), + } + // The daily answers are the champions sorted by name, so the order + // depends on the key alone; adding a champion changes only days that + // have not been pinned yet. + svc.Daily = guessgame.NewDaily(guessgame.DailyConfig{ + Rules: rules{champions: cfg.champions, keepGone: true}, + Answers: names, + AnswerKey: svc.answerKey, + Store: cfg.store, + StatsPrefix: dailyStatsPrefix, + Epoch: cfg.epoch, + Now: cfg.now, + Schedule: cfg.schedule, + Async: cfg.async, + Reporter: cfg.reporter, + API: cfg.api, + GameShortName: GameShortName, + LogName: ShortName, + AnswerPrefix: "Champion: ", + }) + if len(names) > 0 { + svc.rounds = guessgame.NewRounds(guessgame.RoundsConfig{ + Store: cfg.store, + Rules: svc.rules, + Pick: cfg.pick, + DistLen: MaxGuessesCap, + Now: cfg.now, + }) + } + svc.cards = guessgame.NewCards(cfg.store, cfg.now, ShortName) + if cfg.store != nil { + svc.settings = storage.Typed[roundConfig](cfg.store) + } + if svc.enabled() { + log.Info("loldle game enabled", "champions", len(names)) + } + return svc +} + +// randomPick draws an unlimited round's champion, never the champion of the +// round it replaces. +func randomPick(names []string) func(prev string) string { + return func(prev string) string { + for { + n := names[rand.IntN(len(names))] + if n != prev || len(names) < 2 { + return n + } + } } } + +// championEntry is one champion in the page's search list. +type championEntry struct { + Name string `json:"name"` + ID string `json:"id"` +} + +func championList(champions []Champion) []byte { + list := make([]championEntry, len(champions)) + for i, c := range champions { + list[i] = championEntry{Name: c.ChampionName, ID: c.ID} + } + slices.SortFunc(list, func(a, b championEntry) int { return cmp.Compare(a.Name, b.Name) }) + raw, err := json.Marshal(list) + if err != nil { + panic("loldle: encode champion list: " + err.Error()) + } + return raw +} + +// enabled reports whether Play can open the game. +func (s *service) enabled() bool { + return s.cfg.baseURL != "" && len(s.tokenKey) > 0 && len(s.answerKey) > 0 && + s.cfg.store != nil && s.cfg.reporter != nil && len(s.cfg.champions) > 0 +} + +// module describes the service to the registry. Routes and crons exist only +// when the game is enabled, so a disabled game exposes nothing over HTTP and +// pushes nothing. +func (s *service) module() modules.Module { + daily := guessgame.DailyCommands{ + Daily: s.Daily, + Enabled: s.enabled, + Texts: guessgame.DailyTexts{Channel: msgDailyChannel, Disabled: msgDisabled, SendFail: msgDailySendFail}, + } + mod := modules.Module{ + Commands: []modules.Command{{ + Name: ShortName, + Visibility: modules.VisibilityPublic, + Description: "Play unlimited LoLdle; with a champion, guess in your round", + Parameters: "[champion]", + Handler: s.handleLoldle, + }, { + Name: "loldle_giveup", + Visibility: modules.VisibilityPublic, + Description: "Reveal the answer of your unlimited LoLdle round", + Handler: s.handleGiveup, + }, { + Name: "loldle_stats", + Visibility: modules.VisibilityPublic, + Description: "Show your unlimited LoLdle stats", + Handler: s.handleStats, + }, { + Name: "loldle_setmax", + Visibility: modules.VisibilityPrivate, + Description: "Set max guesses (1-10) for unlimited rounds started in this chat", + Handler: s.handleSetMax, + }, { + Name: dailyCommand, + Visibility: modules.VisibilityPublic, + Description: "Play today's LoLdle Daily champion", + Handler: daily.HandleCard, + }, { + Name: subscribeCommand, + Visibility: modules.VisibilityPublic, + Description: "Get the LoLdle Daily puzzle and group results at 07:00 ICT", + Handler: daily.HandleSubscribe, + }, { + Name: unsubscribeCommand, + Visibility: modules.VisibilityPublic, + Description: "Stop the daily LoLdle Daily push", + Handler: daily.HandleUnsubscribe, + }}, + Games: []modules.Game{{ + ShortName: GameShortName, + Visibility: modules.VisibilityPublic, + Handler: s.handlePlay, + }}, + } + if s.enabled() { + mod.HTTP = []modules.Route{{Pattern: routePrefix, Handler: s.handler()}} + mod.Crons = []modules.Cron{ + {Name: dailyPushCronName, Schedule: guessgame.DailyPushSchedule, Handler: s.PushHandler}, + {Name: cardsCronName, Schedule: cardsSchedule, Handler: s.cards.Cleanup}, + } + } + return mod +} diff --git a/internal/modules/loldle/migrate.go b/internal/modules/loldle/migrate.go new file mode 100644 index 0000000..45f472e --- /dev/null +++ b/internal/modules/loldle/migrate.go @@ -0,0 +1,134 @@ +package loldle + +import ( + "context" + "fmt" + + "github.com/tiennm99/tiennm99bot/internal/log" + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" + "github.com/tiennm99/tiennm99bot/internal/storage" +) + +// legacyMigrationKey marks, in the system collection, that the in-chat +// game's players were carried into unlimited mode. +const legacyMigrationKey = "loldle:legacy-unlimited" + +// legacyGame and legacyStats are the documents the in-chat game kept before +// unlimited mode, under "game:" and "stats:" in this +// module's collection. A subject is a user id in a private chat and a +// group's chat id (negative) in a group, where the whole group shared one +// round. A game was deleted when it ended, so every stored one is active. +type legacyGame struct { + Target string `json:"target" bson:"target"` + Guesses []string `json:"guesses" bson:"guesses"` + StartedAt *int64 `json:"startedAt" bson:"startedAt"` + MaxGuesses int `json:"maxGuesses,omitempty" bson:"maxGuesses,omitempty"` +} + +type legacyStats struct { + Played int `json:"played" bson:"played"` + Wins int `json:"wins" bson:"wins"` + Streak int `json:"streak" bson:"streak"` + BestStreak int `json:"bestStreak" bson:"bestStreak"` +} + +// InitStore carries the in-chat game's players into unlimited mode, once: +// each player's stats become their unlimited stats and their round in play +// becomes their current round, with its marks rebuilt from today's data. +// Group rounds and group stats belong to no single player and are left +// alone. Legacy documents are never changed or deleted and a target that +// already exists is never overwritten, so a rerun after a failed boot +// changes nothing that was carried already; a completed marker in the +// system collection makes later boots a no-op. +func InitStore(ctx context.Context, coll, systemColl storage.Collection) error { + champions := loadChampions() + return guessgame.RunOnce(ctx, systemColl, legacyMigrationKey, func() (int, error) { + stats, err := migrateLegacyStats(ctx, coll) + if err != nil { + return 0, err + } + rounds, err := migrateLegacyRounds(ctx, coll, champions) + if err != nil { + return 0, err + } + log.Info("loldle migrated legacy players", "stats", stats, "rounds", rounds) + return stats + rounds, nil + }) +} + +func migrateLegacyStats(ctx context.Context, coll storage.Collection) (int, error) { + from := storage.Typed[legacyStats](coll) + to := storage.Typed[guessgame.RoundStats](coll) + keys, err := from.List(ctx, "stats:") + if err != nil { + return 0, fmt.Errorf("loldle migration list stats: %w", err) + } + n := 0 + for _, key := range keys { + uid, ok := guessgame.LegacyUserID(key, "stats:") + if !ok { + continue + } + old, _, err := from.Get(ctx, key) + if err != nil { + return n, fmt.Errorf("loldle migration get %s: %w", key, err) + } + st := guessgame.RoundStats{Played: old.Played, Wins: old.Wins, CurStreak: old.Streak, MaxStreak: old.BestStreak, + Dist: make([]int, MaxGuessesCap)} + added, err := guessgame.PutIfMissing(ctx, to, guessgame.RoundStatsKey(uid), st) + if err != nil { + return n, fmt.Errorf("loldle migration put stats %d: %w", uid, err) + } + if added { + n++ + } + } + return n, nil +} + +func migrateLegacyRounds(ctx context.Context, coll storage.Collection, champions []Champion) (int, error) { + from := storage.Typed[legacyGame](coll) + to := storage.Typed[guessgame.Round](coll) + keys, err := from.List(ctx, "game:") + if err != nil { + return 0, fmt.Errorf("loldle migration list games: %w", err) + } + n := 0 + for _, key := range keys { + uid, ok := guessgame.LegacyUserID(key, "game:") + if !ok { + continue + } + old, _, err := from.Get(ctx, key) + if err != nil { + return n, fmt.Errorf("loldle migration get %s: %w", key, err) + } + target := findChampionByExactName(champions, old.Target) + maxGuesses := normalizeMaxGuesses(old.MaxGuesses) + // A round whose answer left the data cannot be scored, and an + // exhausted one is over; the in-chat game started afresh on both. + if target == nil || len(old.Guesses) >= maxGuesses { + continue + } + rd := guessgame.Round{Seq: 1, Target: target.ChampionName, Guesses: []guessgame.Guess{}, Status: guessgame.StatusPlaying, + MaxGuesses: maxGuesses} + for _, name := range old.Guesses { + // A guess naming a champion since removed is skipped, as the + // in-chat board did. + if g := findChampionByExactName(champions, name); g != nil { + rd.Guesses = append(rd.Guesses, guessgame.Guess{Word: g.ChampionName, Marks: marksOf(CompareChampions(g, target))}) + } + } + if old.StartedAt != nil && len(rd.Guesses) > 0 { + rd.StartedAt = *old.StartedAt + } + added, err := guessgame.PutIfMissing(ctx, to, guessgame.RoundKey(uid), rd) + if err != nil { + return n, fmt.Errorf("loldle migration put round %d: %w", uid, err) + } + if added { + n++ + } + } + return n, nil +} diff --git a/internal/modules/loldle/migrate_test.go b/internal/modules/loldle/migrate_test.go new file mode 100644 index 0000000..c80f43e --- /dev/null +++ b/internal/modules/loldle/migrate_test.go @@ -0,0 +1,151 @@ +package loldle + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" + "github.com/tiennm99/tiennm99bot/internal/storage" + "github.com/tiennm99/tiennm99bot/internal/testutil/mongotest" +) + +var mongoTests mongotest.Manager + +func TestMain(m *testing.M) { + os.Exit(mongoTests.Run(m)) +} + +// legacyLoldle seeds the in-chat game's documents. +func legacyLoldle(t *testing.T, coll storage.Collection) { + t.Helper() + ctx := context.Background() + stats := storage.Typed[legacyStats](coll) + for key, st := range map[string]legacyStats{ + "stats:1": {Played: 9, Wins: 6, Streak: 2, BestStreak: 4}, + "stats:2": {Played: 3}, + "stats:-100": {Played: 30, Wins: 20}, + } { + if err := stats.Put(ctx, key, st); err != nil { + t.Fatal(err) + } + } + at := int64(1_700_000_000_000) + games := storage.Typed[legacyGame](coll) + for key, g := range map[string]legacyGame{ + "game:1": {Target: "Ahri", Guesses: []string{"Akali", "Removed", "Kai'Sa"}, StartedAt: &at, MaxGuesses: 5}, + "game:3": {Target: "Gone", Guesses: []string{"Akali"}}, + "game:4": {Target: "Ahri", Guesses: []string{}}, + "game:5": {Target: "Ahri", Guesses: []string{"Akali", "Jinx"}, MaxGuesses: 2}, + "game:-100": {Target: "Ahri", Guesses: []string{"Akali"}}, + } { + if err := games.Put(ctx, key, g); err != nil { + t.Fatal(err) + } + } + if err := storage.Typed[roundConfig](coll).Put(ctx, configKey("-100"), roundConfig{MaxGuesses: 4}); err != nil { + t.Fatal(err) + } +} + +// assertLegacyMigration runs the migration twice over provider and checks +// what it carried over. +func assertLegacyMigration(t *testing.T, provider storage.Provider) { + t.Helper() + ctx := context.Background() + coll, system := provider.Collection(ShortName), provider.Collection("system") + legacyLoldle(t, coll) + ustats := storage.Typed[guessgame.RoundStats](coll) + if err := ustats.Put(ctx, guessgame.RoundStatsKey(2), guessgame.RoundStats{Played: 1, Wins: 1, LastSeq: 1}); err != nil { + t.Fatal(err) + } + for range 2 { + if err := InitStore(ctx, coll, system); err != nil { + t.Fatal(err) + } + } + st, _, err := ustats.Get(ctx, guessgame.RoundStatsKey(1)) + if err != nil || st.Played != 9 || st.Wins != 6 || st.CurStreak != 2 || st.MaxStreak != 4 || len(st.Dist) != MaxGuessesCap { + t.Fatalf("player 1 stats = %+v, %v", st, err) + } + if st, _, _ := ustats.Get(ctx, guessgame.RoundStatsKey(2)); st.Played != 1 { + t.Fatalf("existing stats overwritten: %+v", st) + } + rounds := storage.Typed[guessgame.Round](coll) + keys, _ := rounds.List(ctx, "u") + if got := strings.Join(keys, " "); got != "uround:1 uround:4 ustats:1 ustats:2" { + t.Fatalf("migrated keys = %s", got) + } + rd, _, err := rounds.Get(ctx, guessgame.RoundKey(1)) + if err != nil || rd.Seq != 1 || rd.Target != "Ahri" || rd.MaxGuesses != 5 || rd.StartedAt != 1_700_000_000_000 || rd.Status != guessgame.StatusPlaying || + len(rd.Guesses) != 2 || rd.Guesses[0] != (guessgame.Guess{Word: "Akali", Marks: "cwwwccu"}) || rd.Guesses[1].Marks != "cwccwwd" { + t.Fatalf("player 1 round = %+v, %v", rd, err) + } + if rd, _, _ := rounds.Get(ctx, guessgame.RoundKey(4)); rd.MaxGuesses != MaxGuesses || rd.StartedAt != 0 { + t.Fatalf("legacy default round = %+v", rd) + } + if rec, _, err := storage.Typed[struct { + Status string `json:"status" bson:"status"` + Count int64 `json:"count" bson:"count"` + }](system).Get(ctx, legacyMigrationKey); err != nil || rec.Status != "complete" || rec.Count != 3 { + t.Fatalf("marker = %+v, %v", rec, err) + } + // Legacy documents and the setmax config are kept as they were. + if keys, _ := storage.Typed[legacyStats](coll).List(ctx, "stats:"); len(keys) != 3 { + t.Fatalf("legacy stats = %v", keys) + } + if keys, _ := storage.Typed[legacyGame](coll).List(ctx, "game:"); len(keys) != 5 { + t.Fatalf("legacy games = %v", keys) + } + if n, _ := getMaxGuesses(ctx, storage.Typed[roundConfig](coll), "-100"); n != 4 { + t.Fatalf("setmax = %d", n) + } +} + +func TestInitStore_CarriesLegacyPlayersOnce(t *testing.T) { + assertLegacyMigration(t, storage.NewMemoryProvider()) +} + +// The migrated round and stats carry on through the page. +func TestInitStore_MigratedRoundPlaysOn(t *testing.T) { + h := newHarness(t) + legacyLoldle(t, h.coll) + if err := InitStore(context.Background(), h.coll, storage.NewMemoryProvider().Collection("system")); err != nil { + t.Fatal(err) + } + tok := h.token(1, 1, 5, "A", true) + if r := h.state(tok); len(r.v.Guesses) != 2 || r.v.Max != 5 { + t.Fatalf("migrated round = %s", r.body) + } + r := h.roundGuess(tok, 1, "Ahri") + if s := r.v.Stats; s == nil || s.Played != 10 || s.Cur != 3 || s.Max != 4 || s.Dist[2] != 1 { + t.Fatalf("after the migrated round = %s", r.body) + } + // Daily stats never read the legacy documents. + if st, err := h.svc.LoadStats(context.Background(), 1); err != nil || st.Played != 0 { + t.Fatalf("daily stats = %+v, %v", st, err) + } +} + +// The migration runs against MongoDB in production, where documents go +// through BSON rather than JSON. +func TestInitStore_MongoCarriesLegacyPlayersOnce(t *testing.T) { + uri := mongoTests.URI(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + client, err := storage.NewMongoClient(ctx, uri) + if err != nil { + t.Fatalf("NewMongoClient: %v", err) + } + db := client.Database(fmt.Sprintf("tiennm99bot_loldle_test_%d", time.Now().UnixNano())) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = db.Drop(ctx) + _ = client.Disconnect(ctx) + }) + assertLegacyMigration(t, storage.NewMongoProvider(db)) +} diff --git a/internal/modules/loldle/rules.go b/internal/modules/loldle/rules.go new file mode 100644 index 0000000..928b4d3 --- /dev/null +++ b/internal/modules/loldle/rules.go @@ -0,0 +1,142 @@ +package loldle + +import ( + "errors" + "strings" + + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" +) + +// Rejections of a guess, mapped to API errors in http_api.go and to chat +// replies in command.go. +var ( + errUnknownChampion = errors.New("loldle: champion not found") + errAmbiguous = errors.New("loldle: ambiguous champion") + errDuplicate = errors.New("loldle: champion already guessed") + // errTargetGone is a round whose answer left champions.json after a + // data refresh; it can no longer be scored. + errTargetGone = errors.New("loldle: answer no longer in the data") +) + +// Marks are one byte per classic attribute, in classicAttributes order: +// c (correct), p (partial), w (wrong), and for a wrong release year u (the +// answer is later) or d (earlier). A year that cannot be compared is w. +const ( + markCorrect = 'c' + markPartial = 'p' + markWrong = 'w' + markUp = 'u' + markDown = 'd' +) + +// rules is LoLdle for the guessgame engine. A guess's Word is the +// champion's canonical name. +type rules struct { + champions []Champion + // keepGone scores a guess against a pinned answer that left + // champions.json instead of refusing it with errTargetGone. The daily + // mode sets it: a pinned day cannot be re-picked, so its board must stay + // playable. Unlimited rounds leave it unset and replace the round. + keepGone bool +} + +func (rules) Label() string { return "LoLdle Daily" } +func (rules) MaxGuesses() int { return MaxGuesses } + +// Judge resolves input to one champion, refuses a repeat and scores it +// against answer. +func (r rules) Judge(input, answer string, prior []guessgame.Guess) (guessgame.Guess, error) { + target := findChampionByExactName(r.champions, answer) + if target == nil && r.keepGone { + return r.judgeGone(input, answer, prior) + } + guess, ambiguous := findChampionMatch(r.champions, input) + switch { + case ambiguous: + return guessgame.Guess{}, errAmbiguous + case guess == nil: + return guessgame.Guess{}, errUnknownChampion + } + for _, p := range prior { + if p.Word == guess.ChampionName { + return guessgame.Guess{}, errDuplicate + } + } + if target == nil { + return guessgame.Guess{}, errTargetGone + } + return guessgame.Guess{Word: guess.ChampionName, Marks: marksOf(CompareChampions(guess, target))}, nil +} + +// judgeGone scores a guess against an answer that is no longer in the data. +// Its attributes are unknown, so every other champion scores wrong in every +// column; naming the answer itself still wins. +func (r rules) judgeGone(input, answer string, prior []guessgame.Guess) (guessgame.Guess, error) { + word := answer + if normalizeName(input) != normalizeName(answer) { + guess, ambiguous := findChampionMatch(r.champions, input) + switch { + case ambiguous: + return guessgame.Guess{}, errAmbiguous + case guess == nil: + return guessgame.Guess{}, errUnknownChampion + } + word = guess.ChampionName + } + for _, p := range prior { + if p.Word == word { + return guessgame.Guess{}, errDuplicate + } + } + if word == answer { + return guessgame.Guess{Word: word, Marks: strings.Repeat(string(rune(markCorrect)), len(classicAttributes))}, nil + } + return guessgame.Guess{Word: word, Marks: strings.Repeat(string(rune(markWrong)), len(classicAttributes))}, nil +} + +func (rules) Marker(mark byte) string { + switch mark { + case markCorrect: + return "🟩" + case markPartial: + return "🟨" + } + return "🟥" +} + +func (rules) AnswerText(answer string) string { return answer } + +// marksOf encodes a comparison as marks. +func marksOf(rows []AttributeRow) string { + var b strings.Builder + for _, r := range rows { + switch { + case r.Result == ResultCorrect: + b.WriteByte(markCorrect) + case r.Result == ResultPartial: + b.WriteByte(markPartial) + case r.Direction == "up": + b.WriteByte(markUp) + case r.Direction == "down": + b.WriteByte(markDown) + default: + b.WriteByte(markWrong) + } + } + return b.String() +} + +// markResult splits one mark into the comparison result and year arrow. +func markResult(mark byte) (result, direction string) { + switch mark { + case markCorrect: + return ResultCorrect, "" + case markPartial: + return ResultPartial, "" + case markUp: + return ResultWrong, "up" + case markDown: + return ResultWrong, "down" + } + return ResultWrong, "" +} diff --git a/internal/modules/loldle/state.go b/internal/modules/loldle/state.go index 47fd88f..e79c139 100644 --- a/internal/modules/loldle/state.go +++ b/internal/modules/loldle/state.go @@ -8,56 +8,25 @@ import ( "github.com/tiennm99/tiennm99bot/internal/storage" ) -// MaxGuesses is the default round length. Per-subject overrides land via -// /loldle_setmax (capped by MaxGuessesCap). +// MaxGuesses is the default unlimited round length and the fixed daily one. +// /loldle_setmax overrides the unlimited length per chat, up to +// MaxGuessesCap. const ( MaxGuesses = 8 MaxGuessesCap = 10 ) -// gameState is the per-subject record. -// -// StartedAt is *int64 because the timer doesn't start until the first guess; -// using time.Time would marshal as "0001-01-01T00:00:00Z" instead of null -// and lose that distinction. -// -// Guesses is just championNames; comparison rows are recomputed at render -// time against current champions.json so a data refresh updates -// historical board displays without migrating saved rounds. -type gameState struct { - Target string `json:"target" bson:"target"` - Guesses []string `json:"guesses" bson:"guesses"` - StartedAt *int64 `json:"startedAt" bson:"startedAt"` // ms-since-epoch | null - MaxGuesses int `json:"maxGuesses,omitempty" bson:"maxGuesses,omitempty"` // frozen round budget -} - -// stats lifetime score. No LastResultAt field by design (differs from -// wordle's Stats — loldle only ever needed running streaks, not "last -// played at"). -type stats struct { - Played int `json:"played" bson:"played"` - Wins int `json:"wins" bson:"wins"` - Streak int `json:"streak" bson:"streak"` - BestStreak int `json:"bestStreak" bson:"bestStreak"` -} - -// roundConfig stores the per-subject MaxGuesses override. Stored only when -// /loldle_setmax has been run; the absence of this record means "use default". +// roundConfig stores a chat's unlimited round length. Stored only when +// /loldle_setmax has been run; the absence of this record means "use +// default". The subject is the chat id in a group and the user id in a +// private chat. type roundConfig struct { MaxGuesses int `json:"maxGuesses" bson:"maxGuesses"` } -// GameStore is the loldle module's typed game store. -type GameStore = storage.DocStore[gameState] - -// StatsStore is the loldle module's typed stats store. -type StatsStore = storage.DocStore[stats] - // ConfigStore is the loldle module's typed config store. type ConfigStore = storage.DocStore[roundConfig] -func gameKey(subject string) string { return "game:" + subject } -func statsKey(subject string) string { return "stats:" + subject } func configKey(subject string) string { return "config:" + subject } func validMaxGuesses(n int) bool { @@ -71,78 +40,6 @@ func normalizeMaxGuesses(n int) int { return MaxGuesses } -func (g *gameState) roundMaxGuesses() int { - if g == nil { - return MaxGuesses - } - return normalizeMaxGuesses(g.MaxGuesses) -} - -// loadGame returns the active round, or (nil, nil) if none exists. -func loadGame(ctx context.Context, games GameStore, subject string) (*gameState, error) { - g, _, err := games.Get(ctx, gameKey(subject)) - switch { - case err == nil: - return &g, nil - case errors.Is(err, storage.ErrNotFound): - return nil, nil - default: - return nil, fmt.Errorf("loldle loadGame: %w", err) - } -} - -func saveGame(ctx context.Context, games GameStore, subject string, g *gameState) error { - if err := games.Put(ctx, gameKey(subject), *g); err != nil { - return fmt.Errorf("loldle saveGame: %w", err) - } - return nil -} - -// clearGame removes the round so the next /loldle starts fresh. Used after -// win / loss / giveup; the new round's timer should start on the player's -// next interaction, not at the moment the previous round ended. -func clearGame(ctx context.Context, games GameStore, subject string) error { - if err := games.Delete(ctx, gameKey(subject)); err != nil { - return fmt.Errorf("loldle clearGame: %w", err) - } - return nil -} - -// loadStats returns lifetime score; missing → fresh-zero record so callers -// never need a nil check. -func loadStats(ctx context.Context, st StatsStore, subject string) (*stats, error) { - s, _, err := st.Get(ctx, statsKey(subject)) - switch { - case err == nil: - return &s, nil - case errors.Is(err, storage.ErrNotFound): - return &stats{}, nil - default: - return nil, fmt.Errorf("loldle loadStats: %w", err) - } -} - -func recordResult(ctx context.Context, st StatsStore, subject string, won bool) (*stats, error) { - s, err := loadStats(ctx, st, subject) - if err != nil { - return nil, err - } - s.Played++ - if won { - s.Wins++ - s.Streak++ - if s.Streak > s.BestStreak { - s.BestStreak = s.Streak - } - } else { - s.Streak = 0 - } - if err := st.Put(ctx, statsKey(subject), *s); err != nil { - return nil, fmt.Errorf("loldle recordResult: %w", err) - } - return s, nil -} - // getMaxGuesses returns the effective round length: the per-subject override // if set and in range, otherwise MaxGuesses. Out-of-range values are // silently ignored — better to serve the default than 500 the user. @@ -154,15 +51,12 @@ func getMaxGuesses(ctx context.Context, cfg ConfigStore, subject string) (int, e } return 0, fmt.Errorf("loldle getMaxGuesses: %w", err) } - if c.MaxGuesses < 1 || c.MaxGuesses > MaxGuessesCap { - return MaxGuesses, nil - } - return c.MaxGuesses, nil + return normalizeMaxGuesses(c.MaxGuesses), nil } // setMaxGuesses validates and persists the per-subject override. func setMaxGuesses(ctx context.Context, cfg ConfigStore, subject string, n int) error { - if n < 1 || n > MaxGuessesCap { + if !validMaxGuesses(n) { return fmt.Errorf("loldle: maxGuesses must be in [1, %d], got %d", MaxGuessesCap, n) } if err := cfg.Put(ctx, configKey(subject), roundConfig{MaxGuesses: n}); err != nil { diff --git a/internal/modules/loldle/state_test.go b/internal/modules/loldle/state_test.go index 4fb6518..80dc930 100644 --- a/internal/modules/loldle/state_test.go +++ b/internal/modules/loldle/state_test.go @@ -2,92 +2,15 @@ package loldle import ( "context" - "encoding/json" "testing" "github.com/tiennm99/tiennm99bot/internal/storage" ) -func newLoldleGames() GameStore { - return storage.Typed[gameState](storage.NewMemoryProvider().Collection("loldle")) -} - -func newLoldleStats() StatsStore { - return storage.Typed[stats](storage.NewMemoryProvider().Collection("loldle")) -} - func newLoldleConfig() ConfigStore { return storage.Typed[roundConfig](storage.NewMemoryProvider().Collection("loldle")) } -func TestGameState_StartedAtNullByDefault(t *testing.T) { - g := gameState{Target: "Aatrox", Guesses: []string{}} - b, err := json.Marshal(g) - if err != nil { - t.Fatal(err) - } - want := `{"target":"Aatrox","guesses":[],"startedAt":null}` - if string(b) != want { - t.Errorf("marshal:\ngot %s\nwant %s", b, want) - } -} - -func TestGameState_StartedAtAsNumber(t *testing.T) { - at := int64(1700000000000) - g := gameState{Target: "Aatrox", Guesses: []string{"Ahri"}, StartedAt: &at} - b, _ := json.Marshal(g) - want := `{"target":"Aatrox","guesses":["Ahri"],"startedAt":1700000000000}` - if string(b) != want { - t.Errorf("marshal:\ngot %s\nwant %s", b, want) - } -} - -func TestGameState_MaxGuessesOmittedWhenUnset(t *testing.T) { - g := gameState{Target: "Aatrox", Guesses: []string{}, MaxGuesses: 0} - b, err := json.Marshal(g) - if err != nil { - t.Fatal(err) - } - if string(b) != `{"target":"Aatrox","guesses":[],"startedAt":null}` { - t.Errorf("marshal:\ngot %s", b) - } -} - -func TestStats_NoLastResultAtField(t *testing.T) { - // loldle's stats schema deliberately differs from wordle's — no - // lastResultAt field. Lock that, since adding the field would silently - // change the on-disk shape for every existing player. - b, _ := json.Marshal(stats{}) - want := `{"played":0,"wins":0,"streak":0,"bestStreak":0}` - if string(b) != want { - t.Errorf("marshal:\ngot %s\nwant %s", b, want) - } -} - -func TestRecordResult_WinAndLossSequence(t *testing.T) { - ctx := context.Background() - st := newLoldleStats() - - s, err := recordResult(ctx, st, "u1", true) - if err != nil { - t.Fatal(err) - } - if s.Played != 1 || s.Wins != 1 || s.Streak != 1 || s.BestStreak != 1 { - t.Errorf("first win: %+v", s) - } - s, _ = recordResult(ctx, st, "u1", true) - if s.Streak != 2 || s.BestStreak != 2 { - t.Errorf("two wins: %+v", s) - } - s, _ = recordResult(ctx, st, "u1", false) - if s.Streak != 0 { - t.Errorf("loss should reset streak, got %d", s.Streak) - } - if s.BestStreak != 2 { - t.Errorf("best streak should persist, got %d", s.BestStreak) - } -} - func TestGetMaxGuesses_DefaultsAndOverrides(t *testing.T) { ctx := context.Background() cfg := newLoldleConfig() @@ -133,86 +56,3 @@ func TestGetMaxGuesses_OutOfRangeIgnored(t *testing.T) { t.Errorf("out-of-range config → %d, want %d (default)", n, MaxGuesses) } } - -func TestGetOrInitGame_ExistingRoundKeepsStoredMaxGuesses(t *testing.T) { - ctx := context.Background() - games := newLoldleGames() - s := &state{games: games} - if err := saveGame(ctx, games, "u1", &gameState{ - Target: "Aatrox", - Guesses: []string{"Ahri"}, - MaxGuesses: 8, - }); err != nil { - t.Fatal(err) - } - - got, err := s.getOrInitGame(ctx, "u1", 1) - if err != nil { - t.Fatal(err) - } - if got.MaxGuesses != 8 || len(got.Guesses) != 1 { - t.Fatalf("existing round changed by new config: %+v", got) - } -} - -func TestGetOrInitGame_LegacyRoundLocksDefaultMaxGuesses(t *testing.T) { - ctx := context.Background() - games := newLoldleGames() - s := &state{games: games} - if err := saveGame(ctx, games, "u1", &gameState{ - Target: "Aatrox", - Guesses: []string{"Ahri"}, - }); err != nil { - t.Fatal(err) - } - - got, err := s.getOrInitGame(ctx, "u1", 1) - if err != nil { - t.Fatal(err) - } - if got.MaxGuesses != MaxGuesses { - t.Fatalf("legacy round MaxGuesses = %d, want default %d", got.MaxGuesses, MaxGuesses) - } - loaded, err := loadGame(ctx, games, "u1") - if err != nil { - t.Fatal(err) - } - if loaded == nil || loaded.MaxGuesses != MaxGuesses { - t.Fatalf("legacy round was not persisted with default max: %+v", loaded) - } -} - -func TestLoadGame_MissingReturnsNil(t *testing.T) { - g, err := loadGame(context.Background(), newLoldleGames(), "nobody") - if err != nil { - t.Errorf("missing should not error: %v", err) - } - if g != nil { - t.Errorf("expected nil, got %+v", g) - } -} - -func TestSaveLoadClearGame_RoundTrip(t *testing.T) { - ctx := context.Background() - games := newLoldleGames() - at := int64(42) - want := &gameState{Target: "Aatrox", Guesses: []string{"Ahri"}, StartedAt: &at} - if err := saveGame(ctx, games, "u1", want); err != nil { - t.Fatal(err) - } - got, err := loadGame(ctx, games, "u1") - if err != nil || got == nil { - t.Fatalf("loadGame: %v, %v", got, err) - } - if got.Target != "Aatrox" || len(got.Guesses) != 1 || got.StartedAt == nil || *got.StartedAt != 42 { - t.Errorf("round-trip mismatch: %+v", got) - } - - if err := clearGame(ctx, games, "u1"); err != nil { - t.Fatal(err) - } - got, _ = loadGame(ctx, games, "u1") - if got != nil { - t.Errorf("after clearGame, expected nil, got %+v", got) - } -} diff --git a/internal/modules/loldle/web/app.css b/internal/modules/loldle/web/app.css new file mode 100644 index 0000000..e9ac67f --- /dev/null +++ b/internal/modules/loldle/web/app.css @@ -0,0 +1,282 @@ +:root { + --bg: #1a1a2e; + --surface: #16213e; + --surface-hover: #1f2d50; + --header: #2a2a4a; + --text: #e0e0e0; + --muted: #8a8a9a; + /* Column labels are small text on --header: #a8a8b8 keeps 5.9:1 (AA). */ + --head-text: #a8a8b8; + --correct: #4ecb71; + --partial: #f4b35e; + --wrong: #d85b5b; + --on-mark: #1a1a2e; + --input-bg: #0f3460; + --input-border: #533483; + --accent: #533483; + --accent-text: #c9b8ff; + --radius: 10px; + --name-col: 130px; + --cell: 90px; + --icon: 40px; +} + +* { box-sizing: border-box; } +[hidden] { display: none !important; } + +html, body { margin: 0; padding: 0; } + +body { + min-height: 100vh; + background: var(--bg); + color: var(--text); + font: 16px/1.4 "Segoe UI", system-ui, -apple-system, Roboto, "Helvetica Neue", Arial, sans-serif; + -webkit-text-size-adjust: 100%; + touch-action: manipulation; +} + +.app { + max-width: 900px; + margin: 0 auto; + padding: 12px 12px calc(16px + env(safe-area-inset-bottom)); + display: flex; + flex-direction: column; + gap: 12px; +} + +.top { text-align: center; } +.logo { margin: 0; font-size: 2rem; font-weight: 800; letter-spacing: .02em; } +.logo .accent { color: var(--accent-text); } +.mode { margin: 2px 0 0; font-weight: 600; } +.muted { color: var(--muted); } +.top .muted { margin: 0; font-size: .85rem; } +.lead { margin-top: 0; font-size: 1.05rem; } +code { font-size: .9em; } + +.card { + background: var(--surface); + border: 1px solid var(--header); + border-radius: var(--radius); + padding: 16px; +} + +button { + font: inherit; + border-radius: 8px; + border: 1px solid var(--input-border); + background: var(--surface); + color: var(--text); + padding: 10px 16px; + min-height: 44px; + cursor: pointer; +} +button:disabled { opacity: .6; cursor: default; } +button.primary { background: var(--accent); border-color: var(--accent); font-weight: 600; } +button.ghost { min-height: 36px; padding: 6px 12px; font-size: .9rem; } +button.ghost.confirm { border-color: var(--wrong); color: var(--wrong); } + +#screen-game { display: flex; flex-direction: column; gap: 12px; } + +.bar { display: flex; align-items: center; justify-content: center; gap: 12px; min-height: 36px; } +.counter { margin: 0; color: var(--muted); font-variant-numeric: tabular-nums; } + +/* Search: the combobox and its listbox. */ +.search { position: relative; width: 100%; max-width: 420px; margin: 0 auto; } +.search input { + width: 100%; + padding: 12px 14px; + border-radius: 8px; + border: 2px solid var(--input-border); + background: var(--input-bg); + color: var(--text); + font: inherit; + outline: none; +} +.search input:focus { border-color: var(--accent-text); } +.search input:disabled { opacity: .5; } +.search input::placeholder { color: var(--muted); } +.options { + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 30; + list-style: none; + margin: 0; + padding: 0; + max-height: 320px; + overflow-y: auto; + background: var(--surface); + border: 1px solid var(--input-border); + border-top: 0; + border-radius: 0 0 8px 8px; +} +.options li { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + min-height: 48px; + cursor: pointer; +} +.options li[aria-selected="true"], .options li:hover { background: var(--surface-hover); } + +/* A champion icon, or its initial when the image cannot load. */ +.icon { + flex: none; + width: var(--icon); + height: var(--icon); + border-radius: 6px; + object-fit: cover; + background: var(--header); +} +.icon.badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: var(--accent-text); +} +.options .icon { --icon: 36px; } + +.legend { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 6px 16px; + margin: 0; + padding: 0; + list-style: none; + font-size: .8rem; + color: var(--muted); +} +.legend li { display: inline-flex; align-items: center; gap: 6px; } +.swatch { width: 14px; height: 14px; border-radius: 3px; display: inline-block; } +.swatch.correct { background: var(--correct); } +.swatch.partial { background: var(--partial); } +.swatch.wrong { background: var(--wrong); } +.arrows { font-weight: 700; color: var(--text); } + +/* Board: a header row, then guesses newest first. It scrolls sideways on + a narrow screen with the champion column pinned. */ +.board-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; padding-bottom: 4px; } +.board { display: flex; flex-direction: column; gap: 4px; min-width: fit-content; margin: 0 auto; } +.grow { + display: grid; + grid-template-columns: var(--name-col) repeat(7, var(--cell)); + gap: 4px; +} +.cell { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + min-height: 56px; + padding: 6px 4px; + border-radius: 4px; + font-size: .8rem; + font-weight: 500; + overflow-wrap: anywhere; +} +.head .cell { + min-height: 0; + background: var(--header); + color: var(--head-text); + font-size: .7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .04em; +} +.cell.name { + position: sticky; + left: 0; + z-index: 2; + justify-content: flex-start; + gap: 8px; + padding-left: 6px; + background: var(--surface); + text-align: left; + box-shadow: 4px 0 0 var(--bg); +} +.head .cell.name { background: var(--header); } +.cell.correct { background: var(--correct); color: var(--on-mark); } +.cell.partial { background: var(--partial); color: var(--on-mark); } +.cell.wrong { background: var(--wrong); color: #fff; } +.cell .dir { margin-left: 4px; font-size: 1.1em; font-weight: 700; } +.grow.fresh .cell { opacity: 0; animation: cell-reveal .3s ease forwards; } + +.end { text-align: center; } +.end h2 { margin: 0 0 8px; font-size: 1.5rem; } +.end h2.won { color: var(--correct); } +.end h2.lost { color: var(--wrong); } +.end h3 { font-size: .85rem; margin: 16px 0 8px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } +.answer { display: flex; align-items: center; justify-content: center; gap: 12px; } +.answer p { margin: 0; color: var(--muted); } +.answer strong { color: var(--text); } +.answer .icon { --icon: 64px; border-radius: 10px; } +.answer .title { display: block; font-size: .85rem; } + +.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; } +.stats strong { display: block; font-size: 1.6rem; font-weight: 600; } +.stats span { font-size: .72rem; color: var(--muted); } + +.dist { list-style: none; margin: 0 auto; padding: 0; max-width: 360px; display: flex; flex-direction: column; gap: 4px; } +.dist li { display: flex; align-items: center; gap: 6px; font-size: .85rem; } +.dist .n { width: 1.4em; text-align: right; } +.dist .bar { min-width: 8%; padding: 2px 6px; background: var(--header); text-align: right; font-weight: 700; } +.dist li.today .bar { background: var(--correct); color: var(--on-mark); } + +.next { margin: 16px 0 8px; } +.next strong { font-variant-numeric: tabular-nums; } +#next-btn { display: block; margin: 0 auto 8px; } +.actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 12px; } +.hint { margin: 12px 0 0; font-size: .85rem; } + +.toast { + position: fixed; + left: 50%; + top: 16px; + transform: translateX(-50%); + max-width: calc(100% - 32px); + margin: 0; + padding: 10px 16px; + border-radius: 6px; + background: var(--text); + color: var(--bg); + font-weight: 600; + text-align: center; + z-index: 40; +} +.offscreen { position: fixed; top: -1000px; left: 0; opacity: 0; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 768px) { + :root { --name-col: 100px; --cell: 72px; --icon: 32px; } + .cell { font-size: .7rem; min-height: 48px; padding: 4px 2px; } +} + +@media (max-width: 480px) { + :root { --name-col: 84px; --cell: 60px; --icon: 28px; } + .app { padding-left: 8px; padding-right: 8px; } + .logo { font-size: 1.6rem; } + .cell.name { gap: 4px; padding-left: 4px; font-size: .65rem; } +} + +@keyframes cell-reveal { + from { opacity: 0; transform: scale(.8); } + to { opacity: 1; transform: scale(1); } +} + +@media (prefers-reduced-motion: reduce) { + .grow.fresh .cell { opacity: 1; animation: none; } +} diff --git a/internal/modules/loldle/web/app.js b/internal/modules/loldle/web/app.js new file mode 100644 index 0000000..78eb7e2 --- /dev/null +++ b/internal/modules/loldle/web/app.js @@ -0,0 +1,635 @@ +// LoLdle page. Plain JavaScript, no build step. The server owns the game: +// this page only sends guesses and renders the rows it gets back. It never +// knows the answer, or any champion's attributes, until the game is over. +(function () { + 'use strict'; + + /** + * @typedef {{name: string, id: string}} Champ + * @typedef {{key: string, value: string, result: 'correct'|'partial'|'wrong', dir?: 'up'|'down'}} Cell + * @typedef {{name: string, id: string, marks: string, cells: Cell[]}} Row + * @typedef {{key: string, label: string}} Column + * @typedef {{played: number, win_pct: number, cur: number, max: number, dist: number[]}} StatsView + * @typedef {{name: string, id: string, title?: string}} Answer + * @typedef {{ + * mode: 'daily'|'unlimited', num?: number, date?: string, seq?: number, + * player: string, max: number, columns: Column[], guesses: Row[], + * status: 'playing'|'won'|'lost', gave_up?: boolean, answer?: Answer, + * stats?: StatsView, next_at?: number, share?: string, abandoned?: Answer + * }} View + * @typedef {{code: string, message: string}} ApiError + */ + + var TOKEN_KEY = 'loldle-token'; + var ICON_BASE = 'https://ddragon.leagueoflegends.com/cdn/img/champion/tiles/'; + var MAX_OPTIONS = 8; + var STAGGER_MS = 80; + var CONFIRM_MS = 4000; + var CHAMPIONS_RETRY_MS = 1000; + var CHAMPIONS_RETRY_MAX_MS = 30000; + + /** @param {string} id @returns {HTMLElement} */ + function $(id) { + var el = document.getElementById(id); + if (!el) throw new Error('missing #' + id); + return el; + } + + var input = /** @type {HTMLInputElement} */ ($('search-input')); + var list = $('search-list'); + var board = $('board'); + var toast = $('toast'); + var endBox = $('end'); + var newTop = /** @type {HTMLButtonElement} */ ($('new-top')); + + /** @type {View|null} */ + var view = null; + /** @type {Champ[]} */ + var champions = []; + var championsLoading = false; + var championsTries = 0; + /** @type {number|undefined} */ + var championsTimer; + /** @type {Champ[]} */ + var matches = []; + var active = -1; + var busy = false; + var toastTimer = 0; + var countdownTimer = 0; + var confirmTimer = 0; + /** Guesses already shown, so only a new row animates. */ + var shownGuesses = -1; + var shownKey = ''; + + var token = readToken(); + + /** + * Takes the signed token from the URL, then strips it from the address bar + * so copying or sharing the page does not leak it. sessionStorage keeps it + * for a reload; the page still works when storage is blocked. + * @returns {string} + */ + function readToken() { + var fromURL = new URLSearchParams(window.location.search).get('t') || ''; + if (fromURL) { + try { window.sessionStorage.setItem(TOKEN_KEY, fromURL); } catch (e) { /* storage blocked */ } + try { + window.history.replaceState(null, '', window.location.pathname + window.location.hash); + } catch (e) { /* history API unavailable */ } + return fromURL; + } + try { return window.sessionStorage.getItem(TOKEN_KEY) || ''; } catch (e) { return ''; } + } + + /** + * @param {string} path + * @param {Object} body + * @returns {Promise} + */ + function api(path, body) { + return fetch('api/' + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + credentials: 'omit', + cache: 'no-store' + }).then(function (res) { + return res.json().catch(function () { return {}; }).then(function (data) { + if (!res.ok) { + throw { code: data.error || 'http_' + res.status, message: data.message || 'Something went wrong. Try again later.' }; + } + return data; + }); + }, function () { + throw { code: 'network', message: 'No connection to the server. Check your network and try again.' }; + }); + } + + /** @returns {Promise} rejects when the list cannot be loaded */ + function fetchChampions() { + return fetch('champions.json', { credentials: 'omit' }).then(function (res) { + if (!res.ok) throw new Error('champions.json: ' + res.status); + return res.json(); + }).then(function (list) { + if (!Array.isArray(list) || !list.length) throw new Error('champions.json: empty'); + return list; + }); + } + + /** + * Loads the champion list the search needs, retrying with backoff on a + * flaky network. A query typed meanwhile is searched again once it lands. + */ + function loadChampions() { + if (championsLoading || champions.length) return; + championsLoading = true; + clearTimeout(championsTimer); + fetchChampions().then(function (list) { + championsLoading = false; + championsTries = 0; + champions = list; + if (document.activeElement === input && input.value.trim()) { + matches = search(input.value); + active = -1; + renderList(); + } + }, function () { + championsLoading = false; + championsTries++; + championsTimer = window.setTimeout(loadChampions, Math.min(CHAMPIONS_RETRY_MS * Math.pow(2, championsTries - 1), CHAMPIONS_RETRY_MAX_MS)); + }); + } + + /** @param {string} text @param {number} [ms] 0 keeps it shown */ + function say(text, ms) { + toast.textContent = text; + toast.hidden = false; + clearTimeout(toastTimer); + if (ms !== 0) { + toastTimer = window.setTimeout(function () { toast.hidden = true; }, ms || 2200); + } + } + + /** @param {string} s @returns {string} lowercase letters and digits only */ + function norm(s) { + return s.toLowerCase().replace(/[^a-z0-9]/g, ''); + } + + /** + * A champion's icon from Data Dragon, replaced by its initial when the + * image cannot load. The handler is attached here: the CSP forbids inline + * event attributes. + * @param {string} id @param {string} name @returns {HTMLElement} + */ + function icon(id, name) { + var badge = function () { + var b = document.createElement('span'); + b.className = 'icon badge'; + b.setAttribute('aria-hidden', 'true'); + b.textContent = name.charAt(0).toUpperCase(); + return b; + }; + if (!id) return badge(); + var img = document.createElement('img'); + img.className = 'icon'; + img.alt = ''; + img.loading = 'lazy'; + img.decoding = 'async'; + img.referrerPolicy = 'no-referrer'; + img.addEventListener('error', function () { + if (img.parentNode) img.parentNode.replaceChild(badge(), img); + }); + img.src = ICON_BASE + encodeURIComponent(id) + '_0.jpg'; + return img; + } + + // ---- Search ---- + + /** @param {string} query @returns {Champ[]} */ + function search(query) { + var q = norm(query); + if (!q || !view) return []; + var guessed = {}; + view.guesses.forEach(function (g) { guessed[g.name] = true; }); + var prefix = []; + var inside = []; + champions.forEach(function (c) { + if (guessed[c.name]) return; + var n = norm(c.name); + var at = n.indexOf(q); + if (at === 0) prefix.push(c); + else if (at > 0) inside.push(c); + }); + return prefix.concat(inside).slice(0, MAX_OPTIONS); + } + + function closeList() { + list.hidden = true; + list.textContent = ''; + input.setAttribute('aria-expanded', 'false'); + input.removeAttribute('aria-activedescendant'); + matches = []; + active = -1; + } + + function renderList() { + list.textContent = ''; + if (!matches.length) { + closeList(); + return; + } + matches.forEach(function (c, i) { + var li = document.createElement('li'); + li.id = 'champion-option-' + i; + li.setAttribute('role', 'option'); + li.setAttribute('aria-selected', i === active ? 'true' : 'false'); + li.appendChild(icon(c.id, c.name)); + var label = document.createElement('span'); + label.textContent = c.name; + li.appendChild(label); + // mousedown keeps the input focused, so a tap picks the option. + li.addEventListener('mousedown', function (ev) { ev.preventDefault(); }); + li.addEventListener('click', function () { pick(c); }); + list.appendChild(li); + }); + list.hidden = false; + input.setAttribute('aria-expanded', 'true'); + if (active >= 0) { + input.setAttribute('aria-activedescendant', 'champion-option-' + active); + var el = list.children[active]; + if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' }); + } else { + input.removeAttribute('aria-activedescendant'); + } + } + + /** @param {Champ} c */ + function pick(c) { + input.value = ''; + closeList(); + submit(c.name); + } + + input.addEventListener('input', function () { + if (!champions.length) { + // Typing retries a failed load at once instead of waiting out the backoff. + clearTimeout(championsTimer); + loadChampions(); + } + matches = search(input.value); + active = -1; + renderList(); + }); + + input.addEventListener('keydown', function (ev) { + if (ev.key === 'ArrowDown' && matches.length) { + ev.preventDefault(); + active = Math.min(active + 1, matches.length - 1); + renderList(); + } else if (ev.key === 'ArrowUp' && matches.length) { + ev.preventDefault(); + active = Math.max(active - 1, 0); + renderList(); + } else if (ev.key === 'Enter') { + ev.preventDefault(); + if (active >= 0 && matches[active]) { + pick(matches[active]); + } else if (matches.length === 1 || (matches.length && norm(matches[0].name) === norm(input.value))) { + pick(matches[0]); + } else if (input.value.trim() && !champions.length) { + say(championsTries ? "Couldn't load the champion list. Retrying…" : 'Loading champions…'); + } else if (input.value.trim()) { + say(matches.length ? 'Pick a champion from the list.' : 'No champion matches.'); + } + } else if (ev.key === 'Escape') { + closeList(); + } + }); + + document.addEventListener('mousedown', function (ev) { + var t = /** @type {Node} */ (ev.target); + if (!list.hidden && t !== input && !list.contains(t)) closeList(); + }); + + // ---- Board ---- + + /** @param {string} cls @param {string} text @returns {HTMLElement} */ + function cell(cls, text) { + var d = document.createElement('div'); + d.className = 'cell' + (cls ? ' ' + cls : ''); + d.setAttribute('role', 'cell'); + if (text) d.textContent = text; + return d; + } + + /** + * A cell as words for screen readers: its column, the guessed value, the + * result and, for the year, which way the answer lies. + * @param {Cell} c @param {Column[]} columns @returns {string} + */ + function cellWords(c, columns) { + var col = columns.filter(function (x) { return x.key === c.key; })[0]; + var words = (col ? col.label + ': ' : '') + c.value + ', ' + c.result; + if (c.dir) words += c.dir === 'up' ? ', the answer is newer' : ', the answer is older'; + return words; + } + + /** @param {Row} g @param {boolean} fresh @returns {HTMLElement} */ + function rowEl(g, fresh) { + var r = document.createElement('div'); + r.className = 'grow' + (fresh ? ' fresh' : ''); + r.setAttribute('role', 'row'); + var name = cell('name', ''); + name.setAttribute('role', 'rowheader'); + name.appendChild(icon(g.id, g.name)); + var label = document.createElement('span'); + label.textContent = g.name; + name.appendChild(label); + var cells = [name]; + g.cells.forEach(function (c) { + var el = cell(c.result, c.value); + if (c.dir) { + var arrow = document.createElement('span'); + arrow.className = 'dir'; + arrow.setAttribute('aria-hidden', 'true'); + arrow.textContent = c.dir === 'up' ? '↑' : '↓'; + el.appendChild(arrow); + } + el.setAttribute('aria-label', cellWords(c, view ? view.columns : [])); + cells.push(el); + }); + cells.forEach(function (el, i) { + if (fresh) el.style.animationDelay = (i * STAGGER_MS) + 'ms'; + r.appendChild(el); + }); + return r; + } + + function renderBoard() { + if (!view) return; + var key = view.mode + ':' + (view.num || view.seq); + var fresh = key === shownKey && view.guesses.length === shownGuesses + 1; + board.textContent = ''; + if (view.guesses.length) { + var head = document.createElement('div'); + head.className = 'grow head'; + head.setAttribute('role', 'row'); + var first = cell('name', 'Champion'); + first.setAttribute('role', 'columnheader'); + head.appendChild(first); + view.columns.forEach(function (c) { + var h = cell('', c.label); + h.setAttribute('role', 'columnheader'); + head.appendChild(h); + }); + board.appendChild(head); + } + for (var i = view.guesses.length - 1; i >= 0; i--) { + board.appendChild(rowEl(view.guesses[i], fresh && i === view.guesses.length - 1)); + } + shownKey = key; + shownGuesses = view.guesses.length; + } + + function render() { + if (!view) return; + var daily = view.mode === 'daily'; + $('mode').textContent = daily ? 'Daily #' + view.num + ' · ' + view.date : 'Unlimited · Round ' + view.seq; + $('player').textContent = view.player || ''; + $('counter').textContent = view.guesses.length + ' / ' + view.max + ' guesses'; + var playing = view.status === 'playing'; + input.disabled = !playing || busy; + input.placeholder = playing ? 'Type a champion name...' : 'Game over'; + if (!playing) closeList(); + newTop.hidden = daily; + resetConfirm(); + renderBoard(); + renderEnd(); + } + + // ---- End of game ---- + + function renderEnd() { + if (!view || view.status === 'playing' || !view.answer || !view.stats) { + endBox.hidden = true; + clearInterval(countdownTimer); + return; + } + var won = view.status === 'won'; + var title = $('end-title'); + title.textContent = won ? 'You got it!' : 'Game Over'; + title.className = won ? 'won' : 'lost'; + var ans = view.answer; + var iconBox = $('end-icon'); + iconBox.textContent = ''; + iconBox.appendChild(icon(ans.id, ans.name)); + var text = $('end-text'); + text.textContent = ''; + var strong = document.createElement('strong'); + strong.textContent = ans.name; + if (won) { + var n = view.guesses.length; + text.appendChild(document.createTextNode('You found ')); + text.appendChild(strong); + text.appendChild(document.createTextNode(' in ' + n + ' guess' + (n > 1 ? 'es' : '') + '!')); + } else { + text.appendChild(document.createTextNode(view.gave_up ? 'You gave up. The champion was ' : 'The champion was ')); + text.appendChild(strong); + } + if (ans.title) { + var sub = document.createElement('span'); + sub.className = 'title'; + sub.textContent = ans.title; + text.appendChild(sub); + } + var st = view.stats; + $('st-played').textContent = String(st.played); + $('st-pct').textContent = String(st.win_pct); + $('st-cur').textContent = String(st.cur); + $('st-max').textContent = String(st.max); + renderDist(st, won); + var daily = view.mode === 'daily'; + $('end-daily').hidden = !daily; + $('end-unlimited').hidden = daily; + $('daily-hint').hidden = daily; + if (daily) { + updateShare(); + tick(); + clearInterval(countdownTimer); + countdownTimer = window.setInterval(tick, 1000); + } else { + clearInterval(countdownTimer); + } + endBox.hidden = false; + } + + /** @param {StatsView} st @param {boolean} won */ + function renderDist(st, won) { + var dist = $('dist'); + dist.textContent = ''; + // Rows up to the round's budget, or further when an older, longer round + // won there. + var rows = view ? view.max : st.dist.length; + st.dist.forEach(function (n, i) { if (n > 0) rows = Math.max(rows, i + 1); }); + rows = Math.min(rows, st.dist.length); + var top = Math.max.apply(null, st.dist.concat([1])); + for (var i = 0; i < rows; i++) { + var li = document.createElement('li'); + if (won && view && i === view.guesses.length - 1) li.className = 'today'; + var label = document.createElement('span'); + label.className = 'n'; + label.textContent = String(i + 1); + var bar = document.createElement('span'); + bar.className = 'bar'; + bar.textContent = String(st.dist[i]); + bar.style.width = Math.max(8, Math.round(st.dist[i] / top * 100)) + '%'; + li.appendChild(label); + li.appendChild(bar); + dist.appendChild(li); + } + } + + /** + * Shows Share when Telegram's games.js is loaded. It loads async, so this + * runs again on window load in case the results showed first. + */ + function updateShare() { + $('share-btn').hidden = !(window.TelegramGameProxy && typeof window.TelegramGameProxy.shareScore === 'function'); + } + + /** Counts down to the next daily champion; at zero it offers to load it. */ + function tick() { + if (!view || !view.next_at) return; + var left = Math.max(0, Math.floor(view.next_at - Date.now() / 1000)); + var h = Math.floor(left / 3600); + var m = Math.floor(left % 3600 / 60); + var s = left % 60; + $('countdown').textContent = pad(h) + ':' + pad(m) + ':' + pad(s); + $('next-btn').hidden = left > 0; + } + + /** @param {number} n */ + function pad(n) { return (n < 10 ? '0' : '') + n; } + + // ---- Requests ---- + + /** @param {View} v */ + function apply(v) { + if (view && v.guesses.length > view.guesses.length && v.mode === view.mode) { + var g = v.guesses[v.guesses.length - 1]; + $('announce').textContent = g.name + ': ' + g.cells.map(function (c) { return cellWords(c, v.columns); }).join('; '); + } + view = v; + render(); + } + + /** @param {ApiError} err */ + function fail(err) { + switch (err.code) { + case 'new_puzzle': + case 'new_round': + say(err.message, 3000); + load(); + return; + case 'finished': + load(); + return; + case 'bad_token': + case 'expired': + say('Open the game again from the chat.', 0); + return; + } + say(err.message, 3000); + } + + function load() { + if (busy) return; + busy = true; + api('state', { token: token }).then(function (v) { + busy = false; + $('screen-game').hidden = false; + apply(v); + }, function (err) { + busy = false; + $('screen-game').hidden = false; + fail(err); + }); + } + + /** @param {string} name */ + function submit(name) { + if (busy || !view || view.status !== 'playing') return; + busy = true; + input.disabled = true; + var body = { token: token, name: name }; + if (view.mode === 'daily') body.num = view.num; else body.seq = view.seq; + api('guess', body).then(function (v) { + busy = false; + apply(v); + if (v.status === 'won') say('You got it!', 2000); + if (v.status === 'playing') input.focus(); + }, function (err) { + busy = false; + if (view) input.disabled = view.status !== 'playing'; + fail(err); + }); + } + + function newGame() { + if (busy || !view || view.mode !== 'unlimited') return; + busy = true; + resetConfirm(); + api('new', { token: token, seq: view.seq }).then(function (v) { + busy = false; + endBox.hidden = true; + apply(v); + window.scrollTo(0, 0); + if (v.abandoned) say('Round given up. The champion was ' + v.abandoned.name + '.', 4000); + }, function (err) { + busy = false; + fail(err); + }); + } + + /** The header button asks once before giving up a round with guesses. */ + function resetConfirm() { + clearTimeout(confirmTimer); + newTop.classList.remove('confirm'); + newTop.textContent = 'New game'; + } + + newTop.addEventListener('click', function () { + if (!view) return; + var risky = view.status === 'playing' && view.guesses.length > 0; + if (risky && !newTop.classList.contains('confirm')) { + newTop.classList.add('confirm'); + newTop.textContent = 'Give up this round?'; + clearTimeout(confirmTimer); + confirmTimer = window.setTimeout(resetConfirm, CONFIRM_MS); + return; + } + newGame(); + }); + $('new-end').addEventListener('click', newGame); + + /** Copies text, falling back to a hidden textarea where the clipboard API is blocked. */ + function copy(text) { + var done = function () { say('Copied results to clipboard'); }; + var fallback = function () { + var ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.className = 'offscreen'; + document.body.appendChild(ta); + ta.select(); + var ok = false; + try { ok = document.execCommand('copy'); } catch (e) { ok = false; } + document.body.removeChild(ta); + if (ok) done(); else say('Copy failed. Select and copy the result by hand.', 3000); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(done, fallback); + } else { + fallback(); + } + } + + $('share-btn').addEventListener('click', function () { + if (window.TelegramGameProxy) window.TelegramGameProxy.shareScore(); + }); + $('copy-btn').addEventListener('click', function () { + if (view && view.share) copy(view.share); + }); + $('next-btn').addEventListener('click', function () { + endBox.hidden = true; + load(); + }); + window.addEventListener('load', updateShare); + + if (!token) { + $('screen-missing').hidden = false; + } else { + loadChampions(); + load(); + } +}()); diff --git a/internal/modules/loldle/web/index.html b/internal/modules/loldle/web/index.html new file mode 100644 index 0000000..908888d --- /dev/null +++ b/internal/modules/loldle/web/index.html @@ -0,0 +1,85 @@ + + + + + + + +LoLdle + + + +
+
+

LoLdle

+

+

+
+ + + + + + +

+
+ + + + diff --git a/internal/modules/loldle/web/web.go b/internal/modules/loldle/web/web.go new file mode 100644 index 0000000..72191f3 --- /dev/null +++ b/internal/modules/loldle/web/web.go @@ -0,0 +1,10 @@ +// Package web holds the LoLdle page: plain HTML, CSS and JavaScript with no +// build step, embedded into the binary. +package web + +import "embed" + +// FS holds index.html, app.css and app.js. +// +//go:embed index.html app.css app.js +var FS embed.FS diff --git a/internal/modules/util/guessgame/cards.go b/internal/modules/util/guessgame/cards.go new file mode 100644 index 0000000..4889679 --- /dev/null +++ b/internal/modules/util/guessgame/cards.go @@ -0,0 +1,195 @@ +package guessgame + +import ( + "context" + "errors" + "net/url" + "strconv" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/tiennm99bot/internal/log" + "github.com/tiennm99/tiennm99bot/internal/modules" + "github.com/tiennm99/tiennm99bot/internal/storage" +) + +const ( + cardKeyPrefix = "ucard:" + // CardTTL is how long a card keeps opening unlimited mode after its + // last Play press; after that the cleanup forgets it and it plays daily. + CardTTL = 30 * 24 * time.Hour + // cardTouchEvery throttles the Play-time refresh of a card's age to + // one write a day. + cardTouchEvery = 24 * time.Hour +) + +// CardRecord is a game message that opens unlimited mode. Every other card +// of the game, the daily ones, the 07:00 push, an inline share and a card +// whose record expired, plays the daily puzzle. +type CardRecord struct { + ChatID int64 `json:"chat_id" bson:"chat_id"` + MessageID int `json:"message_id" bson:"message_id"` + ThreadID int `json:"thread_id,omitempty" bson:"thread_id,omitempty"` + TouchedAt int64 `json:"touched_at" bson:"touched_at"` // unix seconds of creation or a recent Play +} + +// Cards stores a game's unlimited card records in the game's collection. +type Cards struct { + store storage.DocStore[CardRecord] + now func() time.Time + name string +} + +// NewCards returns the card records over coll, or nil without storage. +// name prefixes log lines. +func NewCards(coll storage.Collection, now func() time.Time, name string) *Cards { + if coll == nil { + return nil + } + if now == nil { + now = time.Now + } + return &Cards{store: storage.Typed[CardRecord](coll), now: now, name: name} +} + +func cardKey(chatID int64, messageID int) string { + return cardKeyPrefix + strconv.FormatInt(chatID, 10) + ":" + strconv.Itoa(messageID) +} + +// Record marks the chat message as an unlimited card. +func (c *Cards) Record(ctx context.Context, chatID int64, messageID, threadID int) error { + if c == nil { + return errors.New("guessgame: no storage for cards") + } + rec := CardRecord{ChatID: chatID, MessageID: messageID, ThreadID: threadID, TouchedAt: c.now().Unix()} + return c.store.Put(ctx, cardKey(chatID, messageID), rec) +} + +// Lookup reports whether the chat message is an unlimited card, and +// refreshes its age at most once a day so a card in use never expires. +func (c *Cards) Lookup(ctx context.Context, chatID int64, messageID int) (CardRecord, bool, error) { + if c == nil { + return CardRecord{}, false, nil + } + key := cardKey(chatID, messageID) + rec, _, err := c.store.Get(ctx, key) + if errors.Is(err, storage.ErrNotFound) { + return CardRecord{}, false, nil + } + if err != nil { + return CardRecord{}, false, err + } + now := c.now().Unix() + if now-rec.TouchedAt >= int64(cardTouchEvery/time.Second) { + rec.TouchedAt = now + if err := c.store.Put(ctx, key, rec); err != nil { + log.Warn(c.name+" card refresh failed", "err", err) + } + } + return rec, true, nil +} + +// Cleanup forgets cards nobody played for CardTTL. It is a cron handler. +func (c *Cards) Cleanup(ctx context.Context, _ modules.Deps) error { + if c == nil { + return nil + } + docs, err := c.store.Scan(ctx, cardKeyPrefix) + if err != nil { + return err + } + cutoff := c.now().Add(-CardTTL).Unix() + var errs []error + for _, d := range docs { + if d.Val.TouchedAt < cutoff { + if err := c.store.Delete(ctx, d.ID); err != nil && !errors.Is(err, storage.ErrNotFound) { + errs = append(errs, err) + } + } + } + return errors.Join(errs...) +} + +// SendCard sends the BotFather game into the message's chat and topic and +// records it as an unlimited card. A card that could not be recorded would +// silently play daily, so it is deleted again and the error returned. +func (c *Cards) SendCard(ctx context.Context, b *bot.Bot, msg *models.Message, game string) error { + thread := TopicOf(msg) + sent, err := b.SendGame(ctx, &bot.SendGameParams{ + ChatID: msg.Chat.ID, + MessageThreadID: thread, + GameShorName: game, // the library's field name is misspelled + }) + if err != nil { + return err + } + if err := c.Record(ctx, msg.Chat.ID, sent.ID, thread); err != nil { + if _, derr := b.DeleteMessage(ctx, &bot.DeleteMessageParams{ChatID: msg.Chat.ID, MessageID: sent.ID}); derr != nil { + log.Warn(c.nameOr("guessgame")+" delete unrecorded card failed", "err", derr) + } + return err + } + return nil +} + +func (c *Cards) nameOr(def string) string { + if c == nil || c.name == "" { + return def + } + return c.name +} + +// PlayParams configures HandlePlay for one game. +type PlayParams struct { + Enabled bool + Cards *Cards + Now time.Time + TokenKey []byte + // PageURL is the page's absolute URL; the token goes in its ?t=. + PageURL string + MsgDisabled string + MsgNoTarget string + MsgCardLookup string + LogName string +} + +// HandlePlay answers a Play press with the game URL carrying a signed token, +// or with an alert when the game cannot open. A press on a recorded card +// opens unlimited mode; any other card plays daily. Every path answers the +// query, so the client never keeps spinning. +func HandlePlay(ctx context.Context, b *bot.Bot, q *models.CallbackQuery, p PlayParams) error { + if !p.Enabled { + return AnswerAlert(ctx, b, q.ID, p.MsgDisabled) + } + c, form := PlayClaims(q) + if form == "" { + return AnswerAlert(ctx, b, q.ID, p.MsgNoTarget) + } + // An inline card has no chat message to record, so it is always daily. + if c.InlineID == "" { + _, ok, err := p.Cards.Lookup(ctx, c.ChatID, c.MessageID) + if err != nil { + // Never fall back to daily silently: the player pressed an + // unlimited card. + _ = AnswerAlert(ctx, b, q.ID, p.MsgCardLookup) + return err + } + if ok { + c.Mode = ModeUnlimited + } + } + c.Expiry = p.Now.Add(TokenTTL).Unix() + token, err := SignToken(p.TokenKey, c) + if err != nil { + _ = AnswerAlert(ctx, b, q.ID, p.MsgDisabled) + return err + } + log.Debug(p.LogName+" play", "address", form, "mode", c.Mode) + _, err = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ + CallbackQueryID: q.ID, + URL: p.PageURL + "?t=" + url.QueryEscape(token), + }) + return err +} diff --git a/internal/modules/util/guessgame/claims.go b/internal/modules/util/guessgame/claims.go new file mode 100644 index 0000000..a507407 --- /dev/null +++ b/internal/modules/util/guessgame/claims.go @@ -0,0 +1,115 @@ +package guessgame + +import ( + "context" + "strings" + "time" + "unicode/utf8" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" +) + +// TokenTTL bounds how long a Play link stays usable. The token is not tied +// to a puzzle or a round: the page always plays the one current at request +// time. +const TokenTTL = 6 * time.Hour + +// MaxNameRunes caps the display name carried in a token. +const MaxNameRunes = 64 + +// Card modes a token carries. The empty mode is daily, so every token +// signed before unlimited mode existed still opens the daily puzzle. +const ( + ModeDaily = "" + ModeUnlimited = "u" +) + +// Claims is what a game token asserts: who pressed Play, on which card, in +// which mode, until when. Exactly one address is set: ChatID+MessageID for +// a card the bot sent into a chat (ThreadID is its forum topic), or +// InlineID for one sent via the bot. +type Claims struct { + UserID int64 `json:"u"` + Name string `json:"n,omitempty"` + ChatID int64 `json:"c,omitempty"` + MessageID int `json:"m,omitempty"` + InlineID string `json:"i,omitempty"` + ThreadID int `json:"t,omitempty"` + Expiry int64 `json:"e"` + Mode string `json:"md,omitempty"` +} + +// Valid requires a player, an expiry, a known mode and exactly one card +// address. +func (c Claims) Valid() bool { + if c.UserID <= 0 || c.Expiry <= 0 || (c.Mode != ModeDaily && c.Mode != ModeUnlimited) { + return false + } + chat := c.ChatID != 0 && c.MessageID != 0 + inline := c.InlineID != "" + if c.ThreadID != 0 && !chat { + return false + } + return chat != inline +} + +// ExpiresAt is the unix second the token stops working. +func (c Claims) ExpiresAt() int64 { return c.Expiry } + +// Unlimited reports whether the token opens the player's unlimited round. +func (c Claims) Unlimited() bool { return c.Mode == ModeUnlimited } + +// SignToken signs c with key. +func SignToken(key []byte, c Claims) (string, error) { return htmlgame.Sign(key, c) } + +// VerifyToken checks token's signature and expiry under key. +func VerifyToken(key []byte, token string, now time.Time) (Claims, error) { + var c Claims + if err := htmlgame.Verify(key, token, now, &c); err != nil { + return Claims{}, err + } + return c, nil +} + +// DisplayName joins a Telegram first and last name within MaxNameRunes. +func DisplayName(first, last string) string { + name := strings.TrimSpace(strings.TrimSpace(first) + " " + strings.TrimSpace(last)) + name = strings.Join(strings.Fields(name), " ") + if utf8.RuneCountInString(name) > MaxNameRunes { + name = string([]rune(name)[:MaxNameRunes]) + } + return name +} + +// PlayClaims addresses the card: the message itself when the bot can see +// it (with its forum topic), the inaccessible message's chat and id +// otherwise, and the inline message id for a card sent via the bot. form is +// "" when none is present. +func PlayClaims(q *models.CallbackQuery) (Claims, string) { + c := Claims{UserID: q.From.ID, Name: DisplayName(q.From.FirstName, q.From.LastName)} + switch { + case q.Message.Message != nil: + m := q.Message.Message + c.ChatID, c.MessageID = m.Chat.ID, m.ID + if m.IsTopicMessage { + c.ThreadID = m.MessageThreadID + } + return c, "message" + case q.Message.InaccessibleMessage != nil: + c.ChatID, c.MessageID = q.Message.InaccessibleMessage.Chat.ID, q.Message.InaccessibleMessage.MessageID + return c, "inaccessible_message" + case q.InlineMessageID != "": + c.InlineID = q.InlineMessageID + return c, "inline_message" + } + return c, "" +} + +// AnswerAlert answers a callback query with an alert. +func AnswerAlert(ctx context.Context, b *bot.Bot, id, text string) error { + _, err := b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: id, Text: text, ShowAlert: true}) + return err +} diff --git a/internal/modules/util/guessgame/daily.go b/internal/modules/util/guessgame/daily.go new file mode 100644 index 0000000..c907935 --- /dev/null +++ b/internal/modules/util/guessgame/daily.go @@ -0,0 +1,132 @@ +package guessgame + +import ( + "context" + "sync" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/tiennm99bot/internal/keylock" + "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" + "github.com/tiennm99/tiennm99bot/internal/modules/util/subscription" + "github.com/tiennm99/tiennm99bot/internal/storage" +) + +// TelegramAPI is the subset of *bot.Bot the summary and the daily push use. +type TelegramAPI interface { + SendMessage(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) + EditMessageText(ctx context.Context, params *bot.EditMessageTextParams) (*models.Message, error) + SendGame(ctx context.Context, params *bot.SendGameParams) (*models.Message, error) +} + +// ScoreReporter records a won game's score on the card it was played from. +type ScoreReporter interface { + Report(ctx context.Context, a htmlgame.Address, userID int64, score int) error +} + +// BotReporter reports scores through the bot with setGameScore. +type BotReporter struct{ B *bot.Bot } + +// Report implements ScoreReporter. +func (r BotReporter) Report(ctx context.Context, a htmlgame.Address, userID int64, score int) error { + return htmlgame.ReportScore(ctx, r.B, a, userID, score) +} + +// DailyConfig is one game's daily mode. +type DailyConfig struct { + Rules Rules + Answers []string // the answer pool; each cycle walks a keyed permutation of it + AnswerKey []byte // keys the answer order + Store storage.Collection + // StatsPrefix is the key prefix of players' daily stats; "stats:" when + // empty. A game whose collection already uses "stats:" sets another. + StatsPrefix string + Epoch time.Time // puzzle #1 starts here; zero means Epoch + Now func() time.Time + Schedule func(d time.Duration, f func()) // runs the summary flush; time.AfterFunc by default + Async func(f func()) // runs a score report; a goroutine by default + Reporter ScoreReporter // nil skips score reports + API TelegramAPI // nil skips the live group summary + // GameShortName is the BotFather game the 07:00 push sends. + GameShortName string + // LogName prefixes log lines, e.g. "wordledaily". + LogName string + // AnswerPrefix starts the recap's answer line; "Answer: " when empty. + AnswerPrefix string +} + +// Daily is a game's daily mode: everyone plays the same answer, a new one +// every day at 07:00 ICT. Its stores are exported for the game's own tests +// and migrations. +type Daily struct { + cfg DailyConfig + epoch time.Time + + Puzzles storage.DocStore[PuzzleDoc] + Plays storage.DocStore[Progress] + Stats storage.DocStore[UserStats] + ChatDays storage.DocStore[ChatDay] + Streaks storage.DocStore[ChatStreak] + Subscribers subscription.Store + PushDate subscription.DayStore + + SubscribersMu sync.Mutex + // Locks serialises one player's daily requests and one chat topic's + // summary updates. + Locks keylock.Map + + perms permCache + cache puzzleCache + flush flusher +} + +// NewDaily builds a game's daily mode. Without a store it only answers the +// questions that need none; the game reports itself disabled. +func NewDaily(cfg DailyConfig) *Daily { + if cfg.Now == nil { + cfg.Now = time.Now + } + if cfg.Schedule == nil { + cfg.Schedule = func(d time.Duration, f func()) { time.AfterFunc(d, f) } + } + if cfg.StatsPrefix == "" { + cfg.StatsPrefix = "stats:" + } + if cfg.AnswerPrefix == "" { + cfg.AnswerPrefix = "Answer: " + } + d := &Daily{cfg: cfg, epoch: Epoch} + if !cfg.Epoch.IsZero() { + d.epoch = cfg.Epoch + } + if cfg.Store != nil { + d.Puzzles = storage.Typed[PuzzleDoc](cfg.Store) + d.Plays = storage.Typed[Progress](cfg.Store) + d.Stats = storage.Typed[UserStats](cfg.Store) + d.ChatDays = storage.Typed[ChatDay](cfg.Store) + d.Streaks = storage.Typed[ChatStreak](cfg.Store) + d.Subscribers = storage.Typed[subscription.Doc](cfg.Store) + d.PushDate = storage.Typed[subscription.DayDoc](cfg.Store) + } + return d +} + +// Rules returns the game's rules. +func (d *Daily) Rules() Rules { return d.cfg.Rules } + +// MaxGuesses is the daily guess budget. +func (d *Daily) MaxGuesses() int { return d.cfg.Rules.MaxGuesses() } + +// Score is what a daily win reports with setGameScore: fewer guesses rank +// higher. +func (d *Daily) Score(guesses int) int { return d.MaxGuesses() + 1 - guesses } + +func (d *Daily) async(f func()) { + if d.cfg.Async != nil { + d.cfg.Async(f) + return + } + go f() +} diff --git a/internal/modules/util/guessgame/daily_commands.go b/internal/modules/util/guessgame/daily_commands.go new file mode 100644 index 0000000..7296eef --- /dev/null +++ b/internal/modules/util/guessgame/daily_commands.go @@ -0,0 +1,130 @@ +package guessgame + +import ( + "context" + "strings" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/tiennm99bot/internal/modules/util/chathelper" + "github.com/tiennm99/tiennm99bot/internal/modules/util/subscription" +) + +// DailyTexts are a daily game's command replies. +type DailyTexts struct { + Channel string // the game cannot be played in a channel + Disabled string // the game is not configured + SendFail string // the card could not be sent +} + +// DailyCommands are the handlers of a daily game's three commands: send +// today's card, subscribe to the 07:00 push, unsubscribe. enabled reports +// whether Play can open the game. +type DailyCommands struct { + Daily *Daily + Enabled func() bool + Texts DailyTexts +} + +// HandleCard sends the BotFather game in the same chat and topic. No reply +// markup: Telegram then adds the Play button itself, which is the button a +// game message requires first. The card is not recorded, so it plays daily. +func (dc DailyCommands) HandleCard(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + if msg.Chat.Type == models.ChatTypeChannel { + return chathelper.Reply(ctx, b, msg, dc.Texts.Channel) + } + if !dc.Enabled() { + return chathelper.Reply(ctx, b, msg, dc.Texts.Disabled) + } + if _, err := b.SendGame(ctx, &bot.SendGameParams{ + ChatID: msg.Chat.ID, + MessageThreadID: TopicOf(msg), + GameShorName: dc.Daily.cfg.GameShortName, // the library's field name is misspelled + }); err != nil { + _ = chathelper.Reply(ctx, b, msg, dc.Texts.SendFail) + return err + } + return nil +} + +// HandleSubscribe opts the chat topic into the 07:00 ICT push. Anyone in +// the chat may subscribe, like the other daily pushes. +func (dc DailyCommands) HandleSubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + if msg.Chat.Type == models.ChatTypeChannel { + return chathelper.Reply(ctx, b, msg, dc.Texts.Channel) + } + // The push cron exists only when the game is enabled, so a + // subscription would never be served. + if !dc.Enabled() { + return chathelper.Reply(ctx, b, msg, dc.Texts.Disabled) + } + d := dc.Daily + thread := TopicOf(msg) + d.SubscribersMu.Lock() + defer d.SubscribersMu.Unlock() + added, err := subscription.Add(ctx, d.Subscribers, msg.Chat.ID, thread) + if err != nil { + return err + } + scope := ScopeOf(thread) + if added { + return chathelper.Reply(ctx, b, msg, + "✅ Subscribed "+scope+" to "+d.cfg.Rules.Label()+": a new puzzle and yesterday's group results every day at 07:00 ICT.\n"+ + "If you block the bot, you'll be auto-unsubscribed on the next push.") + } + return chathelper.Reply(ctx, b, msg, "Already subscribed in "+scope+".") +} + +// HandleUnsubscribe opts the chat topic out. It works while the game is +// disabled too, so a chat can always leave. +func (dc DailyCommands) HandleUnsubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + d := dc.Daily + if d.Subscribers == nil { + return chathelper.Reply(ctx, b, msg, dc.Texts.Disabled) + } + thread := TopicOf(msg) + d.SubscribersMu.Lock() + defer d.SubscribersMu.Unlock() + removed, err := subscription.Remove(ctx, d.Subscribers, msg.Chat.ID, thread) + if err != nil { + return err + } + scope := ScopeOf(thread) + if removed { + return chathelper.Reply(ctx, b, msg, "Unsubscribed "+scope+".") + } + return chathelper.Reply(ctx, b, msg, strings.ToUpper(scope[:1])+scope[1:]+" wasn't subscribed.") +} + +// TopicOf is the message's forum topic, or 0 for the whole chat. Telegram +// also gives a reply chain in an ordinary supergroup a thread id; +// IsTopicMessage marks a real topic. Plays key the group results the same +// way (PlayClaims), so a subscription made by replying still gets its +// chat's recap and streak. +func TopicOf(msg *models.Message) int { + if !msg.IsTopicMessage { + return 0 + } + return msg.MessageThreadID +} + +// ScopeOf names a subscription's scope in replies. +func ScopeOf(thread int) string { + if thread != 0 { + return "this topic" + } + return "this chat" +} diff --git a/internal/modules/util/guessgame/daily_play.go b/internal/modules/util/guessgame/daily_play.go new file mode 100644 index 0000000..c8872fe --- /dev/null +++ b/internal/modules/util/guessgame/daily_play.go @@ -0,0 +1,334 @@ +package guessgame + +import ( + "context" + "slices" + "strconv" + "time" + + "github.com/tiennm99/tiennm99bot/internal/log" + "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" +) + +const reportTimeout = 10 * time.Second + +// Outcome is a player's daily game after a request, with what the request +// changed. +type Outcome struct { + Num int + Answer string + P Progress + Stats UserStats // set once the game is over + + guessed bool // this request added a guess + ended bool // this request finished the game + joined bool // this request added the card's chat to the player's chats + report bool // this request must report the win on the card +} + +// cardKeyOf names the card a token was issued for, so a win is reported on +// each card once. +func cardKeyOf(c Claims) string { + if c.InlineID != "" { + return "i:" + c.InlineID + } + return "c:" + strconv.FormatInt(c.ChatID, 10) + ":" + strconv.Itoa(c.MessageID) +} + +func userLockKey(userID int64) string { return "u:" + strconv.FormatInt(userID, 10) } + +// SubmitGuess plays input on today's puzzle for the token's player. num is +// the puzzle the page shows; a stale one is refused so a board never mixes +// two days. Rules.Judge errors pass through unchanged. +func (d *Daily) SubmitGuess(ctx context.Context, c Claims, num int, input string) (Outcome, error) { + now := d.cfg.Now() + today := d.PuzzleNum(now) + if num != today { + return Outcome{}, ErrNewPuzzle + } + answer, err := d.ResolvePuzzle(ctx, today) + if err != nil { + return Outcome{}, err + } + o, err := d.guessLocked(ctx, c, today, answer, input, now) + if err != nil { + if o.ended { + // The refused guess repaired a finished board's stats; its + // group bookkeeping still has to run. + d.afterPlay(c, o) + } + return o, err + } + d.afterPlay(c, o) + return o, nil +} + +func (d *Daily) guessLocked(ctx context.Context, c Claims, num int, answer, input string, now time.Time) (Outcome, error) { + defer d.Locks.Acquire(userLockKey(c.UserID))() + p, err := d.LoadProgress(ctx, num, c.UserID) + if err != nil { + return Outcome{}, err + } + if p.Finished() { + // The board is the record: a finish whose stats write failed is + // folded into the stats now, before the guess is refused. + o := Outcome{Num: num, Answer: answer, P: p} + if o.Stats, o.ended, err = d.finishStats(ctx, &p); err != nil { + return Outcome{}, err + } + return o, ErrFinished + } + g, err := d.cfg.Rules.Judge(input, answer, p.Guesses) + if err != nil { + return Outcome{}, err + } + o := Outcome{Num: num, Answer: answer, guessed: true} + p.Name = c.Name + p.Guesses = append(p.Guesses, g) + o.joined = d.joinLocally(&p, c) + if g.Word == answer || len(p.Guesses) >= d.MaxGuesses() { + p.Status, p.FinishedAt, o.ended = StatusLost, now.UnixMilli(), true + if g.Word == answer { + p.Status = StatusWon + o.report = needsReport(p, c) + } + } + if err := d.Plays.Put(ctx, PlayKey(num, c.UserID), p); err != nil { + return Outcome{}, err + } + if o.ended { + // The board is saved first and is the record. If this stats write + // fails, the next state load or guess folds the finished board in; + // the LastNum guard keeps it from counting twice. + if o.Stats, _, err = d.finishStats(ctx, &p); err != nil { + return Outcome{}, err + } + } + o.P = p + return o, nil +} + +// LoadState returns today's game for the token's player. Opening a card +// also puts a player who already guessed into that card's group results, +// and reports a win on a card it was not reported on yet. +func (d *Daily) LoadState(ctx context.Context, c Claims) (Outcome, error) { + today := d.PuzzleNum(d.cfg.Now()) + answer, err := d.ResolvePuzzle(ctx, today) + if err != nil { + return Outcome{}, err + } + o, err := d.stateLocked(ctx, c, today, answer) + if err != nil { + return o, err + } + d.afterPlay(c, o) + return o, nil +} + +func (d *Daily) stateLocked(ctx context.Context, c Claims, num int, answer string) (Outcome, error) { + defer d.Locks.Acquire(userLockKey(c.UserID))() + p, err := d.LoadProgress(ctx, num, c.UserID) + if err != nil { + return Outcome{}, err + } + o := Outcome{Num: num, Answer: answer} + if len(p.Guesses) > 0 { + o.joined = d.joinLocally(&p, c) + } + if p.Status == StatusWon { + o.report = needsReport(p, c) + } + if o.joined { + if c.Name != "" { + p.Name = c.Name + } + if err := d.Plays.Put(ctx, PlayKey(num, c.UserID), p); err != nil { + return Outcome{}, err + } + } + if p.Finished() { + // Folds in a finished board whose stats write failed; a no-op + // otherwise. + if o.Stats, o.ended, err = d.finishStats(ctx, &p); err != nil { + return Outcome{}, err + } + } + o.P = p + return o, nil +} + +// joinLocally adds the card's group topic to the player's chats. Only group +// chats (negative IDs) have a results message; a private chat or an inline +// card has none. +func (d *Daily) joinLocally(p *Progress, c Claims) bool { + if c.InlineID != "" || c.ChatID >= 0 || p.joinedChat(c.ChatID, c.ThreadID) || len(p.Chats) >= maxChats { + return false + } + p.Chats = append(p.Chats, ChatRef{ChatID: c.ChatID, ThreadID: c.ThreadID, CardID: c.MessageID}) + return true +} + +// needsReport reports whether the win still has to be reported on the +// token's card. A card is recorded in Reported only once Telegram accepted +// the score, so a failed report is retried on the next state load. +func needsReport(p Progress, c Claims) bool { + return !slices.Contains(p.Reported, cardKeyOf(c)) && len(p.Reported) < maxReported +} + +// recordReported notes that the win of puzzle num is on the token's card. +func (d *Daily) recordReported(ctx context.Context, c Claims, num int) error { + defer d.Locks.Acquire(userLockKey(c.UserID))() + p, err := d.LoadProgress(ctx, num, c.UserID) + if err != nil || p.Status != StatusWon || !needsReport(p, c) { + return err + } + p.Reported = append(p.Reported, cardKeyOf(c)) + return d.Plays.Put(ctx, PlayKey(num, c.UserID), p) +} + +// finishStats folds the finished game into the player's stats, once per +// puzzle, and reports whether this call folded it. Caller holds the +// player's lock. +func (d *Daily) finishStats(ctx context.Context, p *Progress) (UserStats, bool, error) { + st, err := d.LoadStats(ctx, p.UserID) + if err != nil { + return UserStats{}, false, err + } + if st.LastNum >= p.Num { + return st, false, nil + } + st.Played++ + if p.Status == StatusWon { + st.Wins++ + st.Dist[len(p.Guesses)-1]++ + if st.LastWinNum == p.Num-1 { + st.CurStreak++ + } else { + st.CurStreak = 1 + } + st.MaxStreak = max(st.MaxStreak, st.CurStreak) + st.LastWinNum = p.Num + } else { + st.CurStreak = 0 + } + st.LastNum = p.Num + if err := d.Stats.Put(ctx, d.StatsKey(p.UserID), st); err != nil { + return UserStats{}, false, err + } + return st, true, nil +} + +// afterPlay runs a request's side effects once the player's lock is +// released: the group results bookkeeping, the live summary and the score +// report. Failures are logged; the player's game is already saved. +func (d *Daily) afterPlay(c Claims, o Outcome) { + ctx, cancel := context.WithTimeout(context.Background(), reportTimeout) + defer cancel() + if o.joined { + ref := o.P.Chats[len(o.P.Chats)-1] + if err := d.addChatPlayer(ctx, o.Num, ref, c.UserID, o.P.Name); err != nil { + log.Warn(d.cfg.LogName+" chat join failed", "err", err) + } + if o.P.Status == StatusWon && !o.ended { + d.bumpStreak(ctx, o.Num, ref) + } + } + if o.ended && o.P.Status == StatusWon { + for _, ref := range o.P.Chats { + d.bumpStreak(ctx, o.Num, ref) + } + } + if o.guessed || o.joined || o.ended { + for _, ref := range o.P.Chats { + d.TouchSummary(o.Num, ref.ChatID, ref.ThreadID) + } + } + if o.report { + d.reportWin(c, o.Num, len(o.P.Guesses)) + } +} + +// reportWin sets the score on the card in the background, then records the +// card as reported. BOT_SCORE_NOT_MODIFIED (the score does not beat the +// player's best there) counts as reported; any other failure leaves the +// card unrecorded, so the next state load from it tries again. +func (d *Daily) reportWin(c Claims, num, guesses int) { + if d.cfg.Reporter == nil { + return + } + addr := htmlgame.Address{ChatID: c.ChatID, MessageID: c.MessageID, InlineID: c.InlineID} + d.async(func() { + ctx, cancel := context.WithTimeout(context.Background(), reportTimeout) + defer cancel() + err := d.cfg.Reporter.Report(ctx, addr, c.UserID, d.Score(guesses)) + if err != nil && !htmlgame.ScoreNotModified(err) { + log.Warn(d.cfg.LogName+" score report failed", "err", err) + return + } + if err := d.recordReported(ctx, c, num); err != nil { + log.Warn(d.cfg.LogName+" score report record failed", "err", err) + } + }) +} + +// addChatPlayer lists the player in the chat topic's day. +func (d *Daily) addChatPlayer(ctx context.Context, num int, ref ChatRef, userID int64, name string) error { + return updateVersioned(ctx, d.ChatDays, ChatDayKey(num, ref.ChatID, ref.ThreadID), func(cd *ChatDay, found bool) bool { + changed := !found + if !found { + *cd = ChatDay{Num: num, ChatID: ref.ChatID, ThreadID: ref.ThreadID, Players: []ChatPlayer{}} + } + if cd.CardID == 0 && ref.CardID != 0 { + cd.CardID, changed = ref.CardID, true + } + if !slices.ContainsFunc(cd.Players, func(p ChatPlayer) bool { return p.UserID == userID }) && len(cd.Players) < maxChatPlayers { + cd.Players, changed = append(cd.Players, ChatPlayer{UserID: userID, Name: name}), true + } + return changed + }) +} + +// bumpStreak counts puzzle num toward the chat topic's streak, once. +func (d *Daily) bumpStreak(ctx context.Context, num int, ref ChatRef) { + err := updateVersioned(ctx, d.Streaks, ChatStreakKey(ref.ChatID, ref.ThreadID), func(st *ChatStreak, _ bool) bool { + switch { + case st.LastNum >= num: + return false + case st.LastNum == num-1: + st.Streak++ + default: + st.Streak = 1 + } + st.LastNum = num + return true + }) + if err != nil { + log.Warn(d.cfg.LogName+" group streak update failed", "err", err) + } +} + +// DisplayStreak is the current streak as shown on puzzle today: a streak +// whose last win is older than yesterday is over. +func DisplayStreak(st UserStats, today int) int { + if st.LastWinNum < today-1 { + return 0 + } + return st.CurStreak +} + +// ResultLine is "3/6" for a win, "X/6" for a loss. +func (d *Daily) ResultLine(p Progress) string { + if p.Status == StatusWon { + return strconv.Itoa(len(p.Guesses)) + "/" + strconv.Itoa(d.MaxGuesses()) + } + return "X/" + strconv.Itoa(d.MaxGuesses()) +} + +// ShareText is the NYT-style spoiler-free result of a finished game. +func (d *Daily) ShareText(p Progress) string { + text := d.cfg.Rules.Label() + " #" + strconv.Itoa(p.Num) + " " + d.ResultLine(p) + "\n" + for _, g := range p.Guesses { + text += "\n" + EmojiRow(d.cfg.Rules, g.Marks) + } + return text +} diff --git a/internal/modules/wordledaily/puzzle.go b/internal/modules/util/guessgame/daily_puzzle.go similarity index 59% rename from internal/modules/wordledaily/puzzle.go rename to internal/modules/util/guessgame/daily_puzzle.go index 542e709..2ae2ef2 100644 --- a/internal/modules/wordledaily/puzzle.go +++ b/internal/modules/util/guessgame/daily_puzzle.go @@ -1,4 +1,4 @@ -package wordledaily +package guessgame import ( "context" @@ -20,43 +20,44 @@ const ( maxCachedPuzzles = 4 ) -// epoch is the start of puzzle #1: 07:00 ICT on 9 October 2026, which is +// Epoch is the start of puzzle #1: 07:00 ICT on 9 October 2026, which is // 00:00 UTC, so every puzzle day runs 07:00 to 07:00 ICT and the cron at -// 00:00 UTC pushes exactly the puzzle a player then gets. -var epoch = time.Date(2026, time.October, 9, 0, 0, 0, 0, time.UTC) +// 00:00 UTC pushes exactly the puzzle a player then gets. Every daily game +// numbers its puzzles from it. +var Epoch = time.Date(2026, time.October, 9, 0, 0, 0, 0, time.UTC) -// ict is Vietnam time (UTC+7, no DST), used only to print a puzzle's date. -var ict = time.FixedZone("ICT", 7*60*60) +// ICT is Vietnam time (UTC+7, no DST), used only to print a puzzle's date. +var ICT = time.FixedZone("ICT", 7*60*60) -// puzzleNum is the number of the puzzle playing at now: 1 on the epoch day, +// PuzzleNum is the number of the puzzle playing at now: 1 on the epoch day, // then one more each day. Times before the epoch play #1. -func (s *service) puzzleNum(now time.Time) int { - d := now.Sub(s.epoch) - if d < 0 { +func (d *Daily) PuzzleNum(now time.Time) int { + since := now.Sub(d.epoch) + if since < 0 { return 1 } - return int(d/dayLength) + 1 + return int(since/dayLength) + 1 } -// puzzleStart is when puzzle num starts; the next one starts a day later. -func (s *service) puzzleStart(num int) time.Time { - return s.epoch.Add(time.Duration(num-1) * dayLength) +// PuzzleStart is when puzzle num starts; the next one starts a day later. +func (d *Daily) PuzzleStart(num int) time.Time { + return d.epoch.Add(time.Duration(num-1) * dayLength) } -// puzzleDate is the ICT calendar date puzzle num belongs to. -func (s *service) puzzleDate(num int) string { - return s.puzzleStart(num).In(ict).Format(time.DateOnly) +// PuzzleDate is the ICT calendar date puzzle num belongs to. +func (d *Daily) PuzzleDate(num int) string { + return d.PuzzleStart(num).In(ICT).Format(time.DateOnly) } -// answerFor computes puzzle num's answer from the answer key: each cycle of +// AnswerFor computes puzzle num's answer from the answer key: each cycle of // len(answers) days walks one keyed permutation of the list, so no answer -// repeats within a cycle (about six years) and the order cannot be guessed -// without the key. Persisted puzzles take precedence; see resolvePuzzle. -func (s *service) answerFor(num int) string { - n := len(s.cfg.answers) +// repeats within a cycle and the order cannot be guessed without the key. +// Persisted puzzles take precedence; see ResolvePuzzle. +func (d *Daily) AnswerFor(num int) string { + n := len(d.cfg.Answers) idx := num - 1 cycle, pos := idx/n, idx%n - return s.cfg.answers[s.perms.get(s.answerKey, cycle, n)[pos]] + return d.cfg.Answers[d.perms.get(d.cfg.AnswerKey, cycle, n)[pos]] } // permCache keeps the permutation of the current cycle. @@ -116,27 +117,27 @@ func (c *puzzleCache) put(num int, answer string) { } } -// resolvePuzzle returns puzzle num's answer. The first resolution persists +// ResolvePuzzle returns puzzle num's answer. The first resolution persists // it, and every later one reads it back, so changing the secret or the // answer list never changes a puzzle that has already started. -func (s *service) resolvePuzzle(ctx context.Context, num int) (string, error) { - if a, ok := s.cache.get(num); ok { +func (d *Daily) ResolvePuzzle(ctx context.Context, num int) (string, error) { + if a, ok := d.cache.get(num); ok { return a, nil } - key := puzzleKey(num) + key := PuzzleKey(num) for range writeRetries { - doc, _, err := s.puzzles.Get(ctx, key) + doc, _, err := d.Puzzles.Get(ctx, key) if err == nil { - s.cache.put(num, doc.Answer) + d.cache.put(num, doc.Answer) return doc.Answer, nil } if !errors.Is(err, storage.ErrNotFound) { return "", err } - doc = puzzleDoc{Num: num, Answer: s.answerFor(num), CreatedAt: s.cfg.now().UnixMilli()} - err = s.puzzles.PutVersioned(ctx, key, 0, doc) + doc = PuzzleDoc{Num: num, Answer: d.AnswerFor(num), CreatedAt: d.cfg.Now().UnixMilli()} + err = d.Puzzles.PutVersioned(ctx, key, 0, doc) if err == nil { - s.cache.put(num, doc.Answer) + d.cache.put(num, doc.Answer) return doc.Answer, nil } if !errors.Is(err, storage.ErrConflict) { diff --git a/internal/modules/util/guessgame/daily_recap.go b/internal/modules/util/guessgame/daily_recap.go new file mode 100644 index 0000000..7b57944 --- /dev/null +++ b/internal/modules/util/guessgame/daily_recap.go @@ -0,0 +1,168 @@ +package guessgame + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/go-telegram/bot" + + "github.com/tiennm99/tiennm99bot/internal/log" + "github.com/tiennm99/tiennm99bot/internal/modules" + "github.com/tiennm99/tiennm99bot/internal/modules/util/subscription" + "github.com/tiennm99/tiennm99bot/internal/storage" +) + +const ( + // DailyPushSchedule is UTC; 00:00 UTC is 07:00 ICT, when a puzzle + // starts. + DailyPushSchedule = "0 0 * * *" + // lastPushDateKey records the number of the last puzzle pushed, so a + // second trigger on the same day sends nothing. + lastPushDateKey = "daily_push:last_date" + // maxLineUnits caps one recap line, so even every result line fits in + // one message. + maxLineUnits = 500 +) + +// PushHandler is the daily push cron entry point. +func (d *Daily) PushHandler(ctx context.Context, deps modules.Deps) error { + if deps.Bot == nil { + return errors.New(d.cfg.LogName + " daily push: deps.Bot is nil (BuildOptions.Bot not wired)") + } + return d.RunDailyPush(ctx, deps.Bot) +} + +// RunDailyPush sends each subscriber yesterday's group results (when there +// is something to say) and then today's card, once per puzzle. +func (d *Daily) RunDailyPush(ctx context.Context, api TelegramAPI) error { + name := d.cfg.LogName + num := d.PuzzleNum(d.cfg.Now()) + // Pin today's answer now, before anyone plays. + if _, err := d.ResolvePuzzle(ctx, num); err != nil { + return fmt.Errorf("%s daily push: resolve puzzle: %w", name, err) + } + d.PruneDays(ctx, num) + subs, err := subscription.List(ctx, d.Subscribers) + if err != nil { + return fmt.Errorf("%s daily push: list subscribers: %w", name, err) + } + if len(subs) == 0 { + log.Info(name + " daily push: no subscribers, skipping") + return nil + } + won, err := subscription.ClaimDay(ctx, d.PushDate, lastPushDateKey, strconv.Itoa(num)) + if err != nil { + return fmt.Errorf("%s daily push: claim day: %w", name, err) + } + if !won { + log.Info(name+" daily push: already pushed, skipping", "puzzle", num) + return nil + } + res, err := subscription.FanoutFunc(ctx, name+" daily", d.Subscribers, &d.SubscribersMu, subs, func(ctx context.Context, sub subscription.Subscriber) error { + return d.pushTo(ctx, api, num, sub) + }) + if err != nil { + return err + } + log.Info(name+" daily push complete", "puzzle", num, "subscribers", len(subs), + "sent", res.Sent, "failed", res.Failed, "pruned", res.Pruned, "throttled", res.Throttled) + return nil +} + +// pushTo sends one subscriber its recap and card. A recap that fails to send +// skips the card, so FanoutFunc classifies that error and prunes a dead chat. +func (d *Daily) pushTo(ctx context.Context, api TelegramAPI, num int, sub subscription.Subscriber) error { + if num > 1 { + text, err := d.RenderRecap(ctx, num-1, sub.ChatID, sub.ThreadID) + if err != nil { + log.Warn(d.cfg.LogName+" recap render failed", "err", err) + } + if text != "" { + if _, err := api.SendMessage(ctx, &bot.SendMessageParams{ChatID: sub.ChatID, MessageThreadID: sub.ThreadID, Text: text}); err != nil { + return err + } + } + } + _, err := api.SendGame(ctx, &bot.SendGameParams{ChatID: sub.ChatID, MessageThreadID: sub.ThreadID, GameShorName: d.cfg.GameShortName}) + return err +} + +// RenderRecap is puzzle num's results in a group chat topic: the answer +// first, then the group streak, which only a win keeps going, then the +// finishers grouped by guess count with the best line crowned. It is "" in +// a private chat. +func (d *Daily) RenderRecap(ctx context.Context, num int, chatID int64, threadID int) (string, error) { + if chatID >= 0 { + return "", nil + } + answer, err := d.ResolvePuzzle(ctx, num) + if err != nil { + return "", err + } + cd, _, err := d.ChatDays.Get(ctx, ChatDayKey(num, chatID, threadID)) + if err != nil && !errors.Is(err, storage.ErrNotFound) { + return "", err + } + results, err := d.chatResults(ctx, cd) + if err != nil { + return "", err + } + finished := results[:0] + for _, r := range results { + if r.p.Finished() { + finished = append(finished, r) + } + } + st, err := d.ChatStreakAt(ctx, chatID, threadID) + if err != nil { + return "", err + } + label := d.cfg.Rules.Label() + " #" + strconv.Itoa(num) + head := label + " — yesterday's results\n" + d.cfg.AnswerPrefix + d.cfg.Rules.AnswerText(answer) + "\n" + // The recap runs after puzzle num closed, so the streak survives only if + // someone in the group solved num itself. + switch { + case st.LastNum >= num && st.Streak >= minStreakShown: + head += "🔥 Your group is on a " + strconv.Itoa(st.Streak) + " day streak!\n" + case st.LastNum == num-1 && st.Streak >= minStreakShown: + head += "Nobody solved it. Group streak reset.\n" + } + if len(finished) == 0 { + return strings.TrimSuffix(head, "\n"), nil + } + sortResults(finished) + var lines []string + for i := 0; i < len(finished); { + j := i + line := d.ResultLine(finished[i].p) + for j < len(finished) && d.ResultLine(finished[j].p) == line { + j++ + } + prefix := line + ": " + if i == 0 && finished[i].p.Status == StatusWon { + prefix = "👑 " + prefix + } + lines = append(lines, prefix+nameList(finished[i:j])) + i = j + } + return JoinLimited(head, lines, "\n"), nil +} + +// nameList joins the players' names, ending with "+N more" past +// maxLineUnits. +func nameList(rs []chatResult) string { + var b strings.Builder + for i, r := range rs { + if i > 0 && UTF16Len(b.String())+UTF16Len(r.name) > maxLineUnits { + return b.String() + ", +" + strconv.Itoa(len(rs)-i) + " more" + } + if i > 0 { + b.WriteString(", ") + } + b.WriteString(r.name) + } + return b.String() +} diff --git a/internal/modules/wordledaily/store.go b/internal/modules/util/guessgame/daily_store.go similarity index 62% rename from internal/modules/wordledaily/store.go rename to internal/modules/util/guessgame/daily_store.go index 6ebbabb..e98d336 100644 --- a/internal/modules/wordledaily/store.go +++ b/internal/modules/util/guessgame/daily_store.go @@ -1,4 +1,4 @@ -package wordledaily +package guessgame import ( "context" @@ -11,16 +11,11 @@ import ( "github.com/tiennm99/tiennm99bot/internal/storage" ) -// Every document lives in the module's one collection under its own key +// Every document lives in the game's one collection under its own key // prefix. JSON and BSON names match because the memory store round-trips // through JSON and MongoDB through BSON. const ( - statusPlaying = "playing" - statusWon = "won" - statusLost = "lost" - - maxGuesses = 6 // maxReported bounds the cards a player's win is reported on per day. maxReported = 32 // maxChats bounds the chats one player's daily result appears in. @@ -31,51 +26,45 @@ const ( writeRetries = 5 ) -// puzzleDoc pins one day's answer once it has been resolved. -type puzzleDoc struct { +// PuzzleDoc pins one day's answer once it has been resolved. +type PuzzleDoc struct { Num int `json:"num" bson:"num"` Answer string `json:"answer" bson:"answer"` CreatedAt int64 `json:"created_at" bson:"created_at"` } -// guess is one submitted word and its marks: one letter per position, c -// (correct), p (present elsewhere) or w (wrong). -type guess struct { - Word string `json:"word" bson:"word"` - Marks string `json:"marks" bson:"marks"` -} - -// chatRef is a group chat (and forum topic) a player's result appears in, +// ChatRef is a group chat (and forum topic) a player's result appears in, // with the card they last played from there, which the live summary replies // to. -type chatRef struct { +type ChatRef struct { ChatID int64 `json:"chat_id" bson:"chat_id"` ThreadID int `json:"thread_id,omitempty" bson:"thread_id,omitempty"` CardID int `json:"card_id,omitempty" bson:"card_id,omitempty"` } -// progress is one player's game on one puzzle. -type progress struct { +// Progress is one player's game on one puzzle. +type Progress struct { Num int `json:"num" bson:"num"` UserID int64 `json:"user_id" bson:"user_id"` Name string `json:"name" bson:"name"` - Guesses []guess `json:"guesses" bson:"guesses"` + Guesses []Guess `json:"guesses" bson:"guesses"` Status string `json:"status" bson:"status"` FinishedAt int64 `json:"finished_at,omitempty" bson:"finished_at,omitempty"` // unix ms Reported []string `json:"reported,omitempty" bson:"reported,omitempty"` // card keys the win was reported on - Chats []chatRef `json:"chats,omitempty" bson:"chats,omitempty"` + Chats []ChatRef `json:"chats,omitempty" bson:"chats,omitempty"` } -func (p *progress) finished() bool { return p.Status == statusWon || p.Status == statusLost } +// Finished reports whether the game is won or lost. +func (p *Progress) Finished() bool { return p.Status == StatusWon || p.Status == StatusLost } // joinedChat reports whether the player's result already appears in the // chat's topic. -func (p *progress) joinedChat(chatID int64, threadID int) bool { - return slices.ContainsFunc(p.Chats, func(c chatRef) bool { return c.ChatID == chatID && c.ThreadID == threadID }) +func (p *Progress) joinedChat(chatID int64, threadID int) bool { + return slices.ContainsFunc(p.Chats, func(c ChatRef) bool { return c.ChatID == chatID && c.ThreadID == threadID }) } -// userStats is a player's record across every puzzle. -type userStats struct { +// UserStats is a player's record across every puzzle. +type UserStats struct { Played int `json:"played" bson:"played"` Wins int `json:"wins" bson:"wins"` CurStreak int `json:"cur_streak" bson:"cur_streak"` @@ -85,48 +74,53 @@ type userStats struct { LastWinNum int `json:"last_win_num" bson:"last_win_num"` } -// chatPlayer is one player listed in a chat's day. -type chatPlayer struct { +// ChatPlayer is one player listed in a chat's day. +type ChatPlayer struct { UserID int64 `json:"user_id" bson:"user_id"` Name string `json:"name" bson:"name"` } -// chatDay is who played one puzzle in one chat topic, plus the live +// ChatDay is who played one puzzle in one chat topic, plus the live // summary message the bot keeps there. -type chatDay struct { +type ChatDay struct { Num int `json:"num" bson:"num"` ChatID int64 `json:"chat_id" bson:"chat_id"` ThreadID int `json:"thread_id,omitempty" bson:"thread_id,omitempty"` - Players []chatPlayer `json:"players" bson:"players"` + Players []ChatPlayer `json:"players" bson:"players"` CardID int `json:"card_id,omitempty" bson:"card_id,omitempty"` // the card the summary replies to MsgID int `json:"msg_id,omitempty" bson:"msg_id,omitempty"` MsgSentAt int64 `json:"msg_sent_at,omitempty" bson:"msg_sent_at,omitempty"` // unix ms } -// chatStreak counts the consecutive puzzles on which at least one player in -// a chat topic solved the word; a loss does not count. LastNum is the latest -// such puzzle. -type chatStreak struct { +// ChatStreak counts the consecutive puzzles on which at least one player in +// a chat topic found the answer; a loss does not count. LastNum is the +// latest such puzzle. +type ChatStreak struct { Streak int `json:"streak" bson:"streak"` LastNum int `json:"last_num" bson:"last_num"` } // liveAt is the streak as of puzzle num: a streak whose last puzzle is // older than yesterday has been broken. -func (c chatStreak) liveAt(num int) int { +func (c ChatStreak) liveAt(num int) int { if c.LastNum >= num-1 { return c.Streak } return 0 } -func puzzleKey(num int) string { return "puzzle:" + strconv.Itoa(num) } +// PuzzleKey is the key of puzzle num's pinned answer. +func PuzzleKey(num int) string { return "puzzle:" + strconv.Itoa(num) } -func playKey(num int, userID int64) string { +// PlayKey is the key of a player's game on puzzle num. +func PlayKey(num int, userID int64) string { return "play:" + strconv.Itoa(num) + ":" + strconv.FormatInt(userID, 10) } -func statsKey(userID int64) string { return "stats:" + strconv.FormatInt(userID, 10) } +// StatsKey is the key of a player's daily stats. +func (d *Daily) StatsKey(userID int64) string { + return d.cfg.StatsPrefix + strconv.FormatInt(userID, 10) +} // chatTopic is "::"; the trailing colon keeps chat -1 from // matching chat -100 in a prefix. @@ -134,34 +128,38 @@ func chatTopic(chatID int64, threadID int) string { return strconv.FormatInt(chatID, 10) + ":" + strconv.Itoa(threadID) + ":" } -func chatDayKey(num int, chatID int64, threadID int) string { +// ChatDayKey is the key of who played puzzle num in a chat topic. +func ChatDayKey(num int, chatID int64, threadID int) string { return "cday:" + strconv.Itoa(num) + ":" + chatTopic(chatID, threadID) } -func chatStreakKey(chatID int64, threadID int) string { +// ChatStreakKey is the key of a chat topic's win streak. +func ChatStreakKey(chatID int64, threadID int) string { return "cstreak:" + chatTopic(chatID, threadID) } -// loadProgress returns the player's game on puzzle num, or a fresh one. -func (s *service) loadProgress(ctx context.Context, num int, userID int64) (progress, error) { - p, _, err := s.plays.Get(ctx, playKey(num, userID)) +// LoadProgress returns the player's game on puzzle num, or a fresh one. +func (d *Daily) LoadProgress(ctx context.Context, num int, userID int64) (Progress, error) { + p, _, err := d.Plays.Get(ctx, PlayKey(num, userID)) switch { case err == nil: return p, nil case errors.Is(err, storage.ErrNotFound): - return progress{Num: num, UserID: userID, Guesses: []guess{}, Status: statusPlaying}, nil + return Progress{Num: num, UserID: userID, Guesses: []Guess{}, Status: StatusPlaying}, nil default: - return progress{}, err + return Progress{}, err } } -func (s *service) loadStats(ctx context.Context, userID int64) (userStats, error) { - st, _, err := s.stats.Get(ctx, statsKey(userID)) +// LoadStats returns the player's daily stats, zero when they never played. +func (d *Daily) LoadStats(ctx context.Context, userID int64) (UserStats, error) { + n := d.MaxGuesses() + st, _, err := d.Stats.Get(ctx, d.StatsKey(userID)) if errors.Is(err, storage.ErrNotFound) { - return userStats{Dist: make([]int, maxGuesses)}, nil + return UserStats{Dist: make([]int, n)}, nil } - if len(st.Dist) != maxGuesses { - st.Dist = append(st.Dist, make([]int, maxGuesses)...)[:maxGuesses] + if len(st.Dist) != n { + st.Dist = append(st.Dist, make([]int, n)...)[:n] } return st, err } @@ -190,43 +188,43 @@ func updateVersioned[T any](ctx context.Context, store storage.DocStore[T], key return storage.ErrConflict } -// dayStore is the part of a DocStore that pruneDays needs. +// dayStore is the part of a DocStore that PruneDays needs. type dayStore interface { List(ctx context.Context, prefix string) ([]string, error) Delete(ctx context.Context, id string) error } -// pruneDays deletes the per-puzzle documents (pinned answers, boards and +// PruneDays deletes the per-puzzle documents (pinned answers, boards and // chat days) older than yesterday's puzzle. Nothing reads them once the // 07:00 recap of the day after has run, and without this they would pile up // forever, in RAM with the memory store. Stats and group streaks are kept. // Failures are logged; the next day's run retries them. -func (s *service) pruneDays(ctx context.Context, today int) { +func (d *Daily) PruneDays(ctx context.Context, today int) { stores := []struct { prefix string store dayStore - }{{"puzzle:", s.puzzles}, {"play:", s.plays}, {"cday:", s.chatDays}} + }{{"puzzle:", d.Puzzles}, {"play:", d.Plays}, {"cday:", d.ChatDays}} deleted := 0 - for _, d := range stores { - keys, err := d.store.List(ctx, d.prefix) + for _, ds := range stores { + keys, err := ds.store.List(ctx, ds.prefix) if err != nil { - log.Warn("wordledaily prune list failed", "prefix", d.prefix, "err", err) + log.Warn(d.cfg.LogName+" prune list failed", "prefix", ds.prefix, "err", err) continue } for _, key := range keys { - num, ok := keyNum(key, d.prefix) + num, ok := keyNum(key, ds.prefix) if !ok || num >= today-1 { continue } - if err := d.store.Delete(ctx, key); err != nil { - log.Warn("wordledaily prune delete failed", "key", key, "err", err) + if err := ds.store.Delete(ctx, key); err != nil { + log.Warn(d.cfg.LogName+" prune delete failed", "key", key, "err", err) continue } deleted++ } } if deleted > 0 { - log.Info("wordledaily pruned old puzzle days", "deleted", deleted, "today", today) + log.Info(d.cfg.LogName+" pruned old puzzle days", "deleted", deleted, "today", today) } } diff --git a/internal/modules/wordledaily/summary.go b/internal/modules/util/guessgame/daily_summary.go similarity index 66% rename from internal/modules/wordledaily/summary.go rename to internal/modules/util/guessgame/daily_summary.go index 35d94e2..204595e 100644 --- a/internal/modules/wordledaily/summary.go +++ b/internal/modules/util/guessgame/daily_summary.go @@ -1,4 +1,4 @@ -package wordledaily +package guessgame import ( "cmp" @@ -19,23 +19,23 @@ import ( ) const ( - // summaryInterval is the shortest gap between two updates of one + // SummaryInterval is the shortest gap between two updates of one // chat's live results message. - summaryInterval = 5 * time.Second - // resendInterval is the shortest gap between two new results messages + SummaryInterval = 5 * time.Second + // ResendInterval is the shortest gap between two new results messages // for one chat's day, when the old one was deleted. - resendInterval = 10 * time.Minute + ResendInterval = 10 * time.Minute // maxTextUnits keeps a message under Telegram's 4096 UTF-16 unit cap, // leaving room for the "+N more" line. maxTextUnits = 4000 // minStreakShown hides a one-day "streak". minStreakShown = 2 - // maxSummaryRetries bounds the retries of one failed summary update. - maxSummaryRetries = 5 + // MaxSummaryRetries bounds the retries of one failed summary update. + MaxSummaryRetries = 5 ) // flusher coalesces live summary updates: at most one is pending per chat -// day, and two never run closer than summaryInterval. It is in-memory; a +// day, and two never run closer than SummaryInterval. It is in-memory; a // restart only delays the next update, because the message is always // rendered from the store. type flusher struct { @@ -50,15 +50,15 @@ type flushState struct { retries int // failed updates in a row } -// touchSummary schedules an update of the live results message of the chat +// TouchSummary schedules an update of the live results message of the chat // topic's day. Only group chats (negative IDs) get one. -func (s *service) touchSummary(num int, chatID int64, threadID int) { - if s.cfg.api == nil || chatID >= 0 { +func (d *Daily) TouchSummary(num int, chatID int64, threadID int) { + if d.cfg.API == nil || chatID >= 0 { return } - key := chatDayKey(num, chatID, threadID) - now := s.cfg.now() - f := &s.flush + key := ChatDayKey(num, chatID, threadID) + now := d.cfg.Now() + f := &d.flush f.mu.Lock() if f.state == nil { f.state = map[string]*flushState{} @@ -78,28 +78,28 @@ func (s *service) touchSummary(num int, chatID int64, threadID int) { return } st.scheduled = true - delay := max(summaryInterval-now.Sub(st.last), 0) + delay := max(SummaryInterval-now.Sub(st.last), 0) if st.last.IsZero() { delay = 0 } f.mu.Unlock() - s.cfg.schedule(delay, func() { s.flushSummary(num, chatID, threadID) }) + d.cfg.Schedule(delay, func() { d.flushSummary(num, chatID, threadID) }) } // flushSummary brings the chat topic's live results message up to date and // schedules a bounded retry when that failed for a reason that may pass (a // 429, a timeout, a 5xx, a store error) or when a deleted message may not be // sent again yet. -func (s *service) flushSummary(num int, chatID int64, threadID int) { - key := chatDayKey(num, chatID, threadID) - f := &s.flush +func (d *Daily) flushSummary(num int, chatID int64, threadID int) { + key := ChatDayKey(num, chatID, threadID) + f := &d.flush f.mu.Lock() if st := f.state[key]; st != nil { - st.scheduled, st.last = false, s.cfg.now() + st.scheduled, st.last = false, d.cfg.Now() } f.mu.Unlock() - retry, after := s.syncSummary(key, chatID, threadID) + retry, after := d.syncSummary(key, chatID, threadID) f.mu.Lock() if f.state == nil { @@ -107,7 +107,7 @@ func (s *service) flushSummary(num int, chatID int64, threadID int) { } st := f.state[key] if st == nil { - st = &flushState{num: num, last: s.cfg.now()} + st = &flushState{num: num, last: d.cfg.Now()} f.state[key] = st } if !retry { @@ -120,58 +120,58 @@ func (s *service) flushSummary(num int, chatID int64, threadID int) { f.mu.Unlock() return } - if st.retries >= maxSummaryRetries { + if st.retries >= MaxSummaryRetries { st.retries = 0 f.mu.Unlock() - log.Warn("wordledaily summary update abandoned", "puzzle", num, "chat", chatID) + log.Warn(d.cfg.LogName+" summary update abandoned", "puzzle", num, "chat", chatID) return } - delay := max(after, summaryInterval< 0 { + if wait := ResendInterval - now.Sub(time.UnixMilli(cd.MsgSentAt)); wait > 0 { // Deleted too recently to send again: try once the interval // has passed, so the final state still reaches the chat. return true, wait @@ -186,17 +186,17 @@ func (s *service) syncSummary(key string, chatID int64, threadID int) (bool, tim if cd.CardID != 0 { params.ReplyParameters = &models.ReplyParameters{MessageID: cd.CardID, AllowSendingWithoutReply: true} } - msg, err := s.cfg.api.SendMessage(ctx, params) + msg, err := d.cfg.API.SendMessage(ctx, params) if err != nil { - log.Warn("wordledaily summary send failed", "err", err) + log.Warn(d.cfg.LogName+" summary send failed", "err", err) return retryAfter(err) } - err = updateVersioned(ctx, s.chatDays, key, func(v *chatDay, found bool) bool { + err = updateVersioned(ctx, d.ChatDays, key, func(v *ChatDay, found bool) bool { v.MsgID, v.MsgSentAt = msg.ID, now.UnixMilli() return found }) if err != nil { - log.Warn("wordledaily summary id save failed", "err", err) + log.Warn(d.cfg.LogName+" summary id save failed", "err", err) } return false, 0 } @@ -232,16 +232,16 @@ func messageGone(err error) bool { // chatResult is one player's game as a chat's results show it. type chatResult struct { name string - p progress + p Progress } // chatResults loads the day's game of every player listed in cd who made a // guess. Players are few per chat and this runs off the request path, so // one read per player is fine. -func (s *service) chatResults(ctx context.Context, cd chatDay) ([]chatResult, error) { +func (d *Daily) chatResults(ctx context.Context, cd ChatDay) ([]chatResult, error) { out := make([]chatResult, 0, len(cd.Players)) for _, pl := range cd.Players { - p, _, err := s.plays.Get(ctx, playKey(cd.Num, pl.UserID)) + p, _, err := d.Plays.Get(ctx, PlayKey(cd.Num, pl.UserID)) if errors.Is(err, storage.ErrNotFound) { continue } @@ -265,9 +265,9 @@ func (s *service) chatResults(ctx context.Context, cd chatDay) ([]chatResult, er func sortResults(rs []chatResult) { rank := func(r chatResult) int { switch r.p.Status { - case statusWon: + case StatusWon: return 0 - case statusLost: + case StatusLost: return 1 } return 2 @@ -277,45 +277,38 @@ func sortResults(rs []chatResult) { return c } switch a.p.Status { - case statusWon: + case StatusWon: return cmp.Or(cmp.Compare(len(a.p.Guesses), len(b.p.Guesses)), cmp.Compare(a.p.FinishedAt, b.p.FinishedAt)) - case statusLost: + case StatusLost: return cmp.Compare(a.p.FinishedAt, b.p.FinishedAt) } return cmp.Compare(len(b.p.Guesses), len(a.p.Guesses)) }) } -// resultLine is "3/6" for a win, "X/6" for a loss. -func resultLine(p progress) string { - if p.Status == statusWon { - return strconv.Itoa(len(p.Guesses)) + "/" + strconv.Itoa(maxGuesses) - } - return "X/" + strconv.Itoa(maxGuesses) -} - -func (s *service) chatStreakAt(ctx context.Context, chatID int64, threadID int) (chatStreak, error) { - st, _, err := s.streaks.Get(ctx, chatStreakKey(chatID, threadID)) +// ChatStreakAt is the chat topic's win streak record. +func (d *Daily) ChatStreakAt(ctx context.Context, chatID int64, threadID int) (ChatStreak, error) { + st, _, err := d.Streaks.Get(ctx, ChatStreakKey(chatID, threadID)) if errors.Is(err, storage.ErrNotFound) { - return chatStreak{}, nil + return ChatStreak{}, nil } return st, err } -// renderLive is the live results message: every player's colour grid, +// RenderLive is the live results message: every player's colour grid, // never a letter. It returns "" when nobody in the chat has guessed yet. -func (s *service) renderLive(ctx context.Context, cd chatDay) (string, error) { - results, err := s.chatResults(ctx, cd) +func (d *Daily) RenderLive(ctx context.Context, cd ChatDay) (string, error) { + results, err := d.chatResults(ctx, cd) if err != nil || len(results) == 0 { return "", err } sortResults(results) - st, err := s.chatStreakAt(ctx, cd.ChatID, cd.ThreadID) + st, err := d.ChatStreakAt(ctx, cd.ChatID, cd.ThreadID) if err != nil { return "", err } var head strings.Builder - head.WriteString("Wordle Daily #" + strconv.Itoa(cd.Num) + " · live results\n") + head.WriteString(d.cfg.Rules.Label() + " #" + strconv.Itoa(cd.Num) + " · live results\n") if n := st.liveAt(cd.Num); n >= minStreakShown { head.WriteString("🔥 Group streak: " + strconv.Itoa(n) + " days\n") } @@ -323,22 +316,22 @@ func (s *service) renderLive(ctx context.Context, cd chatDay) (string, error) { for i, r := range results { var b strings.Builder b.WriteString(r.name + " ") - if r.p.finished() { - b.WriteString(resultLine(r.p)) + if r.p.Finished() { + b.WriteString(d.ResultLine(r.p)) } else { - b.WriteString("playing " + strconv.Itoa(len(r.p.Guesses)) + "/" + strconv.Itoa(maxGuesses)) + b.WriteString("playing " + strconv.Itoa(len(r.p.Guesses)) + "/" + strconv.Itoa(d.MaxGuesses())) } for _, g := range r.p.Guesses { - b.WriteString("\n" + emojiRow(g.Marks)) + b.WriteString("\n" + EmojiRow(d.cfg.Rules, g.Marks)) } blocks[i] = b.String() } - return joinLimited(head.String()+"\n", blocks, "\n"), nil + return JoinLimited(head.String()+"\n", blocks, "\n"), nil } -// joinLimited appends parts to head, separated by sep, while the text stays +// JoinLimited appends parts to head, separated by sep, while the text stays // under maxTextUnits UTF-16 units, then says how many were left out. -func joinLimited(head string, parts []string, sep string) string { +func JoinLimited(head string, parts []string, sep string) string { text := head for i, p := range parts { next := text @@ -346,7 +339,7 @@ func joinLimited(head string, parts []string, sep string) string { next += sep } next += p - if utf16Len(next) > maxTextUnits { + if UTF16Len(next) > maxTextUnits { return text + sep + "+" + strconv.Itoa(len(parts)-i) + " more" } text = next @@ -354,4 +347,5 @@ func joinLimited(head string, parts []string, sep string) string { return text } -func utf16Len(s string) int { return len(utf16.Encode([]rune(s))) } +// UTF16Len counts s in UTF-16 units, the unit of Telegram's length limits. +func UTF16Len(s string) int { return len(utf16.Encode([]rune(s))) } diff --git a/internal/modules/util/guessgame/guessgame_test.go b/internal/modules/util/guessgame/guessgame_test.go new file mode 100644 index 0000000..b9746d8 --- /dev/null +++ b/internal/modules/util/guessgame/guessgame_test.go @@ -0,0 +1,351 @@ +package guessgame + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/tiennm99/tiennm99bot/internal/modules" + "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" + "github.com/tiennm99/tiennm99bot/internal/storage" + "github.com/tiennm99/tiennm99bot/internal/systemstate" +) + +// fakeRules plays single letters: a guess must be one lowercase letter, and +// its mark is c when it is the answer, u when the answer is later in the +// alphabet, d when earlier. +type fakeRules struct{ max int } + +var errNotALetter = errors.New("not a letter") + +func (fakeRules) Label() string { return "Letter Daily" } +func (r fakeRules) MaxGuesses() int { return r.max } + +func (fakeRules) Judge(input, answer string, prior []Guess) (Guess, error) { + if len(input) != 1 || input[0] < 'a' || input[0] > 'z' { + return Guess{}, errNotALetter + } + for _, g := range prior { + if g.Word == input { + return Guess{}, errors.New("repeated") + } + } + switch { + case input == answer: + return Guess{Word: input, Marks: "c"}, nil + case input < answer: + return Guess{Word: input, Marks: "u"}, nil + } + return Guess{Word: input, Marks: "d"}, nil +} + +func (fakeRules) Marker(mark byte) string { + if mark == 'c' { + return "🟩" + } + return "🟥" +} + +func (fakeRules) AnswerText(answer string) string { return strings.ToUpper(answer) } + +type clock struct { + mu sync.Mutex + t time.Time +} + +func (c *clock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *clock) advance(d time.Duration) { + c.mu.Lock() + c.t = c.t.Add(d) + c.mu.Unlock() +} + +func newRounds(t *testing.T, clk *clock) *Rounds { + t.Helper() + targets := map[string]string{"": "m", "m": "q", "q": "m"} + return NewRounds(RoundsConfig{ + Store: storage.NewMemoryProvider().Collection("letters"), + Rules: fakeRules{}, + Pick: func(prev string) string { return targets[prev] }, + DistLen: 4, + Now: clk.now, + }) +} + +func TestRounds_LoadStartsTheFirstRoundLazily(t *testing.T) { + clk := &clock{t: Epoch} + r := newRounds(t, clk) + ctx := context.Background() + if _, err := r.GiveUp(ctx, 1); !errors.Is(err, ErrNoRound) { + t.Fatalf("give up without a round = %v", err) + } + s, err := r.Load(ctx, 1, 3) + if err != nil || s.Round.Seq != 1 || s.Round.Target != "m" || s.Round.MaxGuesses != 3 || s.Round.Status != StatusPlaying || + s.Round.StartedAt != 0 || len(s.Stats.Dist) != 4 { + t.Fatalf("first load = %+v, %v", s, err) + } + // The budget is frozen when the round starts. + if s, _ := r.Load(ctx, 1, 9); s.Round.MaxGuesses != 3 || s.Round.Seq != 1 { + t.Fatalf("reload = %+v", s.Round) + } + if NewRounds(RoundsConfig{}) != nil { + t.Fatal("rounds without storage") + } +} + +func TestRounds_WinLossStreakAndDistribution(t *testing.T) { + clk := &clock{t: Epoch} + r := newRounds(t, clk) + ctx := context.Background() + s, err := r.Guess(ctx, 1, 1, "a", 3) + if err != nil || len(s.Round.Guesses) != 1 || s.Round.Guesses[0].Marks != "u" || s.Round.StartedAt != Epoch.UnixMilli() || s.Ended { + t.Fatalf("first guess = %+v, %v", s, err) + } + if _, err := r.Guess(ctx, 1, 1, "a", 3); err == nil || err.Error() != "repeated" { + t.Fatalf("judge error = %v", err) + } + clk.advance(time.Minute) + s, err = r.Guess(ctx, 1, 1, "m", 3) + if err != nil || !s.Ended || s.Round.Status != StatusWon || s.Round.FinishedAt != Epoch.Add(time.Minute).UnixMilli() || + s.Stats.Played != 1 || s.Stats.Wins != 1 || s.Stats.CurStreak != 1 || s.Stats.Dist[1] != 1 || s.Stats.LastSeq != 1 { + t.Fatalf("win = %+v, %v", s, err) + } + if _, err := r.Guess(ctx, 1, 1, "b", 3); !errors.Is(err, ErrFinished) { + t.Fatalf("guess after the win = %v", err) + } + s, _ = r.New(ctx, 1, 1, 3) + if s.Round.Seq != 2 || s.Round.Target != "q" || s.Abandoned != nil { + t.Fatalf("new after a win = %+v", s) + } + // Seq 0 plays the current round; three misses lose it. + for _, l := range []string{"a", "b", "c"} { + s, err = r.Guess(ctx, 1, 0, l, 3) + if err != nil { + t.Fatal(err) + } + } + if !s.Ended || s.Round.Status != StatusLost || s.Round.GaveUp || s.Stats.Played != 2 || s.Stats.CurStreak != 0 || s.Stats.MaxStreak != 1 { + t.Fatalf("loss = %+v", s) + } + if _, err := r.Guess(ctx, 1, 1, "q", 3); !errors.Is(err, ErrNewRound) { + t.Fatalf("stale seq = %v", err) + } + // Another player is untouched. + if st, _ := r.LoadStats(ctx, 2); st.Played != 0 { + t.Fatalf("player 2 = %+v", st) + } +} + +func TestRounds_NewAndGiveUp(t *testing.T) { + r := newRounds(t, &clock{t: Epoch}) + ctx := context.Background() + r.Load(ctx, 1, 5) + // Nobody guessed: replaced, no loss. + s, err := r.New(ctx, 1, 1, 5) + if err != nil || s.Round.Seq != 2 || s.Abandoned != nil || s.Stats.Played != 0 { + t.Fatalf("new on a fresh round = %+v, %v", s, err) + } + // A stale seq, such as a double click, starts nothing. + if s, _ := r.New(ctx, 1, 1, 5); s.Round.Seq != 2 { + t.Fatalf("stale new = %+v", s.Round) + } + r.Guess(ctx, 1, 2, "a", 5) + s, _ = r.New(ctx, 1, 2, 5) + if s.Round.Seq != 3 || s.Abandoned == nil || s.Abandoned.Target != "q" || !s.Abandoned.GaveUp || s.Stats.Played != 1 || s.Stats.Wins != 0 { + t.Fatalf("abandon = %+v", s) + } + s, err = r.GiveUp(ctx, 1) + if err != nil || !s.Ended || !s.Round.GaveUp || s.Round.Status != StatusLost || s.Stats.Played != 2 { + t.Fatalf("give up = %+v, %v", s, err) + } + s, _ = r.GiveUp(ctx, 1) + if s.Ended || s.Stats.Played != 2 { + t.Fatalf("second give up = %+v", s) + } + s, _ = r.Replace(ctx, 1, 5) + if s.Round.Seq != 4 || s.Stats.Played != 2 { + t.Fatalf("replace = %+v", s) + } +} + +// flakyStats fails the next Put. +type flakyStats struct { + storage.DocStore[RoundStats] + fail bool +} + +func (f *flakyStats) Put(ctx context.Context, id string, v RoundStats) error { + if f.fail { + f.fail = false + return errors.New("store down") + } + return f.DocStore.Put(ctx, id, v) +} + +// A round saved as finished whose stats write failed counts on the next +// request, once. +func TestRounds_FailedStatsWriteIsRepairedOnce(t *testing.T) { + r := newRounds(t, &clock{t: Epoch}) + ctx := context.Background() + flaky := &flakyStats{DocStore: r.stats} + r.stats = flaky + flaky.fail = true + if _, err := r.Guess(ctx, 1, 1, "m", 3); err == nil { + t.Fatal("stats write failure hidden") + } + for range 2 { + s, err := r.Load(ctx, 1, 3) + if err != nil || s.Round.Status != StatusWon || s.Stats.Played != 1 || s.Stats.Wins != 1 { + t.Fatalf("repaired = %+v, %v", s, err) + } + } +} + +func TestRounds_ConcurrentGuessesKeepOneBoard(t *testing.T) { + r := newRounds(t, &clock{t: Epoch}) + ctx := context.Background() + var wg sync.WaitGroup + for _, l := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = r.Guess(ctx, 1, 0, l, 10) + }() + } + wg.Wait() + s, err := r.Load(ctx, 1, 10) + if err != nil || len(s.Round.Guesses) != 8 { + t.Fatalf("board = %+v, %v", s.Round, err) + } +} + +func TestCards_RecordLookupTouchAndCleanup(t *testing.T) { + clk := &clock{t: Epoch} + c := NewCards(storage.NewMemoryProvider().Collection("letters"), clk.now, "letters") + ctx := context.Background() + if _, ok, err := c.Lookup(ctx, -1, 5); ok || err != nil { + t.Fatalf("unrecorded = %v, %v", ok, err) + } + if err := c.Record(ctx, -1, 5, 7); err != nil { + t.Fatal(err) + } + if err := c.Record(ctx, -1, 6, 0); err != nil { + t.Fatal(err) + } + // Card 5 is played every 20 days, so it never expires; card 6 is not. + for range 3 { + clk.advance(20 * 24 * time.Hour) + if rec, ok, err := c.Lookup(ctx, -1, 5); !ok || err != nil || rec.ThreadID != 7 { + t.Fatalf("lookup = %+v %v %v", rec, ok, err) + } + if err := c.Cleanup(ctx, modules.Deps{}); err != nil { + t.Fatal(err) + } + } + if _, ok, _ := c.Lookup(ctx, -1, 5); !ok { + t.Fatal("a card in use expired") + } + if _, ok, _ := c.Lookup(ctx, -1, 6); ok { + t.Fatal("an idle card outlived the TTL") + } + var none *Cards + if _, ok, err := none.Lookup(ctx, 1, 1); ok || err != nil || none.Record(ctx, 1, 1, 0) == nil || none.Cleanup(ctx, modules.Deps{}) != nil { + t.Fatal("nil cards") + } +} + +func TestDaily_FakeRulesLabelMarkersAndStatsPrefix(t *testing.T) { + clk := &clock{t: Epoch.Add(time.Hour)} + coll := storage.NewMemoryProvider().Collection("letters") + var reports []int + d := NewDaily(DailyConfig{ + Rules: fakeRules{max: 4}, Answers: []string{"m"}, AnswerKey: []byte("k"), Store: coll, + StatsPrefix: "dstats:", Now: clk.now, Async: func(f func()) { f() }, + Reporter: reporterFunc(func(_ htmlgame.Address, _ int64, score int) { reports = append(reports, score) }), + LogName: "letters", AnswerPrefix: "Letter: ", + }) + ctx := context.Background() + c := Claims{UserID: 1, Name: "Ann", ChatID: -5, MessageID: 9, Expiry: 1} + if _, err := d.SubmitGuess(ctx, c, 1, "1"); !errors.Is(err, errNotALetter) { + t.Fatalf("judge error = %v", err) + } + if _, err := d.SubmitGuess(ctx, c, 2, "a"); !errors.Is(err, ErrNewPuzzle) { + t.Fatalf("stale puzzle = %v", err) + } + d.SubmitGuess(ctx, c, 1, "a") + o, err := d.SubmitGuess(ctx, c, 1, "m") + if err != nil || o.P.Status != StatusWon || o.Stats.Dist[1] != 1 || len(o.Stats.Dist) != 4 { + t.Fatalf("win = %+v, %v", o, err) + } + if len(reports) != 1 || reports[0] != 3 { + t.Fatalf("score = %v, want 4+1-2", reports) + } + if _, _, err := d.Stats.Get(ctx, "dstats:1"); err != nil { + t.Fatalf("stats key: %v", err) + } + if got := d.ShareText(o.P); got != "Letter Daily #1 2/4\n\n🟥\n🟩" { + t.Fatalf("share = %q", got) + } + cd, _, _ := d.ChatDays.Get(ctx, ChatDayKey(1, -5, 0)) + live, _ := d.RenderLive(ctx, cd) + if live != "Letter Daily #1 · live results\n\nAnn 2/4\n🟥\n🟩" { + t.Fatalf("live = %q", live) + } + clk.advance(24 * time.Hour) + recap, err := d.RenderRecap(ctx, 1, -5, 0) + if err != nil || recap != "Letter Daily #1 — yesterday's results\nLetter: M\n👑 2/4: Ann" { + t.Fatalf("recap = %q, %v", recap, err) + } +} + +type reporterFunc func(a htmlgame.Address, userID int64, score int) + +func (f reporterFunc) Report(_ context.Context, a htmlgame.Address, userID int64, score int) error { + f(a, userID, score) + return nil +} + +func TestMigrationHelpers(t *testing.T) { + ctx := context.Background() + p := storage.NewMemoryProvider() + for key, want := range map[string]int64{"stats:7": 7, "stats:-100": 0, "stats:x": 0} { + uid, ok := LegacyUserID(key, "stats:") + if (want > 0) != ok || (ok && uid != want) { + t.Errorf("LegacyUserID(%q) = %d, %v", key, uid, ok) + } + } + store := storage.Typed[RoundStats](p.Collection("letters")) + if added, err := PutIfMissing(ctx, store, "k", RoundStats{Played: 1}); !added || err != nil { + t.Fatalf("first put = %v, %v", added, err) + } + if added, err := PutIfMissing(ctx, store, "k", RoundStats{Played: 2}); added || err != nil { + t.Fatalf("second put = %v, %v", added, err) + } + if st, _, _ := store.Get(ctx, "k"); st.Played != 1 { + t.Fatalf("overwritten: %+v", st) + } + runs := 0 + migrate := func() (int, error) { runs++; return 4, nil } + fail := func() (int, error) { runs++; return 0, errors.New("boom") } + sys := p.Collection("system") + if err := RunOnce(ctx, sys, "m", fail); err == nil { + t.Fatal("failure hidden") + } + for range 2 { + if err := RunOnce(ctx, sys, "m", migrate); err != nil { + t.Fatal(err) + } + } + rec, ok, _ := systemstate.New(sys).Get(ctx, "m") + if runs != 2 || !ok || rec.Status != "complete" || rec.Count != 4 { + t.Fatalf("runs %d, marker %+v", runs, rec) + } +} diff --git a/internal/modules/util/guessgame/migrate.go b/internal/modules/util/guessgame/migrate.go new file mode 100644 index 0000000..e5e6256 --- /dev/null +++ b/internal/modules/util/guessgame/migrate.go @@ -0,0 +1,71 @@ +package guessgame + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/tiennm99/tiennm99bot/internal/storage" + "github.com/tiennm99/tiennm99bot/internal/systemstate" +) + +// RunOnce runs a one-time startup migration guarded by a marker under key +// in the system collection: a completed marker makes it a no-op, and the +// marker is written only once migrate succeeds, recording how many +// documents it wrote. migrate must itself be safe to run again, since a +// failure before the marker reruns it on the next boot. +func RunOnce(ctx context.Context, systemColl storage.Collection, key string, migrate func() (int, error)) error { + state := systemstate.New(systemColl) + if rec, ok, err := state.Get(ctx, key); err != nil { + return fmt.Errorf("migration marker %s: %w", key, err) + } else if ok && rec.Status == "complete" { + return nil + } + n, err := migrate() + if err != nil { + return err + } + now := time.Now().UTC().UnixMilli() + if err := state.Put(ctx, key, systemstate.Record{ + Kind: "migration", + Name: key, + Status: "complete", + Count: int64(n), + CompletedAt: now, + UpdatedAt: now, + }); err != nil { + return fmt.Errorf("migration marker put %s: %w", key, err) + } + return nil +} + +// LegacyUserID reads the subject of a legacy in-chat game key +// "". Those games keyed a private chat by the user id and +// a group by its (negative) chat id, a round the whole group shared; only a +// positive subject is a player. +func LegacyUserID(key, prefix string) (int64, bool) { + uid, err := strconv.ParseInt(strings.TrimPrefix(key, prefix), 10, 64) + return uid, err == nil && uid > 0 +} + +// PutIfMissing writes v under key unless a document is already there, and +// reports whether it wrote. +func PutIfMissing[T any](ctx context.Context, store storage.DocStore[T], key string, v T) (bool, error) { + _, _, err := store.Get(ctx, key) + switch { + case err == nil: + return false, nil + case !errors.Is(err, storage.ErrNotFound): + return false, err + } + if err := store.PutVersioned(ctx, key, 0, v); err != nil { + if errors.Is(err, storage.ErrConflict) { + return false, nil + } + return false, err + } + return true, nil +} diff --git a/internal/modules/util/guessgame/rounds.go b/internal/modules/util/guessgame/rounds.go new file mode 100644 index 0000000..fbe59ad --- /dev/null +++ b/internal/modules/util/guessgame/rounds.go @@ -0,0 +1,317 @@ +package guessgame + +import ( + "context" + "errors" + "strconv" + "time" + + "github.com/tiennm99/tiennm99bot/internal/keylock" + "github.com/tiennm99/tiennm99bot/internal/storage" +) + +// Unlimited mode: every player has their own current round with a random +// answer, kept server-side under "uround:", and their own stats under +// "ustats:". A round is replaced, never appended to, so one document a +// player is all it stores. + +// Round is a player's current unlimited round. +type Round struct { + Seq int `json:"seq" bson:"seq"` // 1 for the player's first round, then one more each round + Target string `json:"target" bson:"target"` + Guesses []Guess `json:"guesses" bson:"guesses"` + Status string `json:"status" bson:"status"` + GaveUp bool `json:"gave_up,omitempty" bson:"gave_up,omitempty"` + MaxGuesses int `json:"max_guesses" bson:"max_guesses"` // frozen when the round starts + StartedAt int64 `json:"started_at,omitempty" bson:"started_at,omitempty"` // unix ms of the first guess + FinishedAt int64 `json:"finished_at,omitempty" bson:"finished_at,omitempty"` +} + +// Finished reports whether the round is won or lost. +func (r *Round) Finished() bool { return r.Status == StatusWon || r.Status == StatusLost } + +// RoundStats is a player's unlimited record. A streak counts consecutive +// wins. LastSeq is the last round folded in, so a finished round counts +// once even when its stats write is retried. +type RoundStats struct { + Played int `json:"played" bson:"played"` + Wins int `json:"wins" bson:"wins"` + CurStreak int `json:"cur_streak" bson:"cur_streak"` + MaxStreak int `json:"max_streak" bson:"max_streak"` + Dist []int `json:"dist" bson:"dist"` // wins by guess count, index 0 = 1 guess + LastAt int64 `json:"last_at,omitempty" bson:"last_at,omitempty"` + LastSeq int `json:"last_seq" bson:"last_seq"` +} + +// RoundsConfig is one game's unlimited mode. +type RoundsConfig struct { + Store storage.Collection + Rules Rules // Judge scores the guesses + // Pick returns a random answer for a new round; prev is the answer of + // the round it replaces, "" for a player's first. + Pick func(prev string) string + // DistLen sizes the stats' guess distribution: the largest guess + // budget a round can have. + DistLen int + Now func() time.Time +} + +// RoundState is a player's round after a request. +type RoundState struct { + Round Round + Stats RoundStats + // Ended reports that this request finished the round. + Ended bool + // Abandoned is the round a New request gave up, set only when that + // counted as a loss. + Abandoned *Round +} + +// Rounds is a game's unlimited mode. Every method holds the player's lock, +// so the page and the chat commands never interleave on one round. +type Rounds struct { + cfg RoundsConfig + rounds storage.DocStore[Round] + stats storage.DocStore[RoundStats] + locks keylock.Map +} + +// NewRounds builds a game's unlimited mode, or nil without storage. +func NewRounds(cfg RoundsConfig) *Rounds { + if cfg.Store == nil { + return nil + } + if cfg.Now == nil { + cfg.Now = time.Now + } + return &Rounds{ + cfg: cfg, + rounds: storage.Typed[Round](cfg.Store), + stats: storage.Typed[RoundStats](cfg.Store), + } +} + +// RoundKey is the key of a player's current round. +func RoundKey(userID int64) string { return "uround:" + strconv.FormatInt(userID, 10) } + +// RoundStatsKey is the key of a player's unlimited stats. +func RoundStatsKey(userID int64) string { return "ustats:" + strconv.FormatInt(userID, 10) } + +func roundLockKey(userID int64) string { return "r:" + strconv.FormatInt(userID, 10) } + +// load returns the player's round, found=false when they never had one. +func (r *Rounds) load(ctx context.Context, userID int64) (Round, bool, error) { + rd, _, err := r.rounds.Get(ctx, RoundKey(userID)) + switch { + case err == nil: + return rd, true, nil + case errors.Is(err, storage.ErrNotFound): + return Round{}, false, nil + default: + return Round{}, false, err + } +} + +// LoadStats returns the player's unlimited stats, zero when they never +// finished a round. +func (r *Rounds) LoadStats(ctx context.Context, userID int64) (RoundStats, error) { + st, _, err := r.stats.Get(ctx, RoundStatsKey(userID)) + if errors.Is(err, storage.ErrNotFound) { + st, err = RoundStats{}, nil + } + if len(st.Dist) != r.cfg.DistLen { + st.Dist = append(st.Dist, make([]int, r.cfg.DistLen)...)[:r.cfg.DistLen] + } + return st, err +} + +// start writes a fresh round after prev (zero for the first) and returns it. +func (r *Rounds) start(ctx context.Context, userID int64, prev Round, maxGuesses int) (Round, error) { + rd := Round{Seq: prev.Seq + 1, Target: r.cfg.Pick(prev.Target), Guesses: []Guess{}, Status: StatusPlaying, MaxGuesses: maxGuesses} + if err := r.rounds.Put(ctx, RoundKey(userID), rd); err != nil { + return Round{}, err + } + return rd, nil +} + +// current returns the player's round, starting their first one when there +// is none, with their stats. A finished round whose stats write failed is +// folded in now. Caller holds the player's lock. +func (r *Rounds) current(ctx context.Context, userID int64, maxGuesses int) (RoundState, error) { + rd, found, err := r.load(ctx, userID) + if err != nil { + return RoundState{}, err + } + if !found { + if rd, err = r.start(ctx, userID, Round{}, maxGuesses); err != nil { + return RoundState{}, err + } + } + st, err := r.settle(ctx, userID, rd) + if err != nil { + return RoundState{}, err + } + return RoundState{Round: rd, Stats: st}, nil +} + +// settle folds a finished round into the stats, once. Caller holds the +// player's lock. +func (r *Rounds) settle(ctx context.Context, userID int64, rd Round) (RoundStats, error) { + st, err := r.LoadStats(ctx, userID) + if err != nil || !rd.Finished() || st.LastSeq >= rd.Seq { + return st, err + } + st.Played++ + if rd.Status == StatusWon { + st.Wins++ + if n := len(rd.Guesses); n >= 1 && n <= len(st.Dist) { + st.Dist[n-1]++ + } + st.CurStreak++ + st.MaxStreak = max(st.MaxStreak, st.CurStreak) + } else { + st.CurStreak = 0 + } + st.LastSeq, st.LastAt = rd.Seq, rd.FinishedAt + if err := r.stats.Put(ctx, RoundStatsKey(userID), st); err != nil { + return RoundStats{}, err + } + return st, nil +} + +// finish ends the round as status and saves it, then folds it into the +// stats. The round is saved first and is the record: a failed stats write +// is repaired by the next request. +func (r *Rounds) finish(ctx context.Context, userID int64, rd *Round, status string) (RoundStats, error) { + rd.Status, rd.FinishedAt = status, r.cfg.Now().UnixMilli() + if err := r.rounds.Put(ctx, RoundKey(userID), *rd); err != nil { + return RoundStats{}, err + } + return r.settle(ctx, userID, *rd) +} + +// Load returns the player's current round, finished or not, starting their +// first one with maxGuesses when they have none. +func (r *Rounds) Load(ctx context.Context, userID int64, maxGuesses int) (RoundState, error) { + defer r.locks.Acquire(roundLockKey(userID))() + return r.current(ctx, userID, maxGuesses) +} + +// Guess plays input on the player's current round. seq is the round the +// caller shows; a stale one is refused so a board never mixes two rounds, +// and 0 plays whatever round is current. A player without a round gets one +// with maxGuesses. Rules.Judge errors pass through unchanged. +func (r *Rounds) Guess(ctx context.Context, userID int64, seq int, input string, maxGuesses int) (RoundState, error) { + defer r.locks.Acquire(roundLockKey(userID))() + s, err := r.current(ctx, userID, maxGuesses) + if err != nil { + return RoundState{}, err + } + rd := s.Round + if seq != 0 && seq != rd.Seq { + return s, ErrNewRound + } + if rd.Finished() { + return s, ErrFinished + } + g, err := r.cfg.Rules.Judge(input, rd.Target, rd.Guesses) + if err != nil { + return s, err + } + now := r.cfg.Now().UnixMilli() + if rd.StartedAt == 0 { + rd.StartedAt = now + } + rd.Guesses = append(rd.Guesses, g) + if g.Word == rd.Target || len(rd.Guesses) >= rd.MaxGuesses { + status := StatusLost + if g.Word == rd.Target { + status = StatusWon + } + st, err := r.finish(ctx, userID, &rd, status) + if err != nil { + return RoundState{}, err + } + return RoundState{Round: rd, Stats: st, Ended: true}, nil + } + if err := r.rounds.Put(ctx, RoundKey(userID), rd); err != nil { + return RoundState{}, err + } + return RoundState{Round: rd, Stats: s.Stats}, nil +} + +// New starts the player's next round with maxGuesses. seq is the round the +// caller shows: a stale one returns the current round unchanged, so a +// double click starts one round, and 0 always starts one. A round still +// being played is given up first; that counts as a loss once it has a +// guess, and a round nobody guessed on is just replaced. +func (r *Rounds) New(ctx context.Context, userID int64, seq, maxGuesses int) (RoundState, error) { + defer r.locks.Acquire(roundLockKey(userID))() + s, err := r.current(ctx, userID, maxGuesses) + if err != nil { + return RoundState{}, err + } + prev := s.Round + if seq != 0 && seq != prev.Seq { + return s, nil + } + var abandoned *Round + if !prev.Finished() && len(prev.Guesses) > 0 { + prev.GaveUp = true + if s.Stats, err = r.finish(ctx, userID, &prev, StatusLost); err != nil { + return RoundState{}, err + } + abandoned = &prev + } + rd, err := r.start(ctx, userID, prev, maxGuesses) + if err != nil { + return RoundState{}, err + } + return RoundState{Round: rd, Stats: s.Stats, Abandoned: abandoned}, nil +} + +// GiveUp ends the player's round being played as a loss. On a finished +// round it changes nothing and reports Ended false; without a round it +// returns ErrNoRound. +func (r *Rounds) GiveUp(ctx context.Context, userID int64) (RoundState, error) { + defer r.locks.Acquire(roundLockKey(userID))() + rd, found, err := r.load(ctx, userID) + if err != nil { + return RoundState{}, err + } + if !found { + return RoundState{}, ErrNoRound + } + st, err := r.settle(ctx, userID, rd) + if err != nil { + return RoundState{}, err + } + if rd.Finished() { + return RoundState{Round: rd, Stats: st}, nil + } + rd.GaveUp = true + if st, err = r.finish(ctx, userID, &rd, StatusLost); err != nil { + return RoundState{}, err + } + return RoundState{Round: rd, Stats: st, Ended: true}, nil +} + +// Replace starts the player's next round without counting the current one, +// for a round that can no longer be played, such as one whose answer left +// the game's data. +func (r *Rounds) Replace(ctx context.Context, userID int64, maxGuesses int) (RoundState, error) { + defer r.locks.Acquire(roundLockKey(userID))() + prev, _, err := r.load(ctx, userID) + if err != nil { + return RoundState{}, err + } + rd, err := r.start(ctx, userID, prev, maxGuesses) + if err != nil { + return RoundState{}, err + } + st, err := r.LoadStats(ctx, userID) + if err != nil { + return RoundState{}, err + } + return RoundState{Round: rd, Stats: st}, nil +} diff --git a/internal/modules/util/guessgame/rules.go b/internal/modules/util/guessgame/rules.go new file mode 100644 index 0000000..c6ce2e1 --- /dev/null +++ b/internal/modules/util/guessgame/rules.go @@ -0,0 +1,67 @@ +// Package guessgame is the shared engine of the bot's HTML5 guessing games, +// Wordle and LoLdle. Each game supplies its Rules; this package runs the two +// modes a game card can open: +// +// - daily (Daily): one shared answer a day from 07:00 ICT, server-side +// progress, NYT-style stats, a live per-group results message, the +// 07:00 recap and push, and setGameScore on a win; +// - unlimited (Rounds): each player's own random answer, a new round on +// demand, and separate stats. +// +// Cards records the game messages that open unlimited mode; any other card +// plays daily. Claims is the signed token a Play press hands the page. +package guessgame + +import ( + "errors" + "strings" +) + +// Game statuses, shared by daily progress and unlimited rounds. +const ( + StatusPlaying = "playing" + StatusWon = "won" + StatusLost = "lost" +) + +// Rejections of a request, mapped to API errors by each game. Rules.Judge +// adds its own game-specific ones. +var ( + ErrNewPuzzle = errors.New("guessgame: the puzzle changed") + ErrFinished = errors.New("guessgame: the game is finished") + ErrNewRound = errors.New("guessgame: the round changed") + ErrNoRound = errors.New("guessgame: no round") +) + +// Guess is one submitted guess and its marks, one byte per position or +// attribute. The stored and served form; JSON and BSON names match. +type Guess struct { + Word string `json:"word" bson:"word"` + Marks string `json:"marks" bson:"marks"` +} + +// Rules is what a game plugs into the engine. +type Rules interface { + // Label names the daily game in chat messages, e.g. "Wordle Daily". + Label() string + // MaxGuesses is the daily game's guess budget; a daily win scores + // MaxGuesses+1-guesses. + MaxGuesses() int + // Judge scores input against answer, given the guesses already on the + // board. It returns the canonical guess, which equals answer on a win, + // or a game-specific error that refuses the input without recording it. + Judge(input, answer string, prior []Guess) (Guess, error) + // Marker is the spoiler-free square shown for one mark. + Marker(mark byte) string + // AnswerText is the answer as chat messages show it. + AnswerText(answer string) string +} + +// EmojiRow renders a guess's marks as its spoiler-free row of squares. +func EmojiRow(r Rules, marks string) string { + var b strings.Builder + for i := 0; i < len(marks); i++ { + b.WriteString(r.Marker(marks[i])) + } + return b.String() +} diff --git a/internal/modules/util/htmlgame/htmlgame_test.go b/internal/modules/util/htmlgame/htmlgame_test.go index 1ca8960..a5c2986 100644 --- a/internal/modules/util/htmlgame/htmlgame_test.go +++ b/internal/modules/util/htmlgame/htmlgame_test.go @@ -137,6 +137,23 @@ func TestSecurityHeadersAndAssets(t *testing.T) { } } +func TestSecurityHeaders_ExtraImageSources(t *testing.T) { + ok := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + csp := func(h http.Handler) string { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/games/x/", nil)) + return rec.Header().Get("Content-Security-Policy") + } + const want = "default-src 'self'; script-src 'self' https://telegram.org; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'" + if got := csp(SecurityHeaders("/games/x/", ok)); got != want { + t.Fatalf("default CSP = %q", got) + } + got := csp(SecurityHeaders("/games/x/", ok, "https://cdn.example")) + if got != strings.Replace(want, "img-src 'self' data:", "img-src 'self' data: https://cdn.example", 1) { + t.Fatalf("CSP with an image source = %q", got) + } +} + func TestDecodeJSON(t *testing.T) { type req struct { Token string `json:"token"` diff --git a/internal/modules/util/htmlgame/http.go b/internal/modules/util/htmlgame/http.go index cd32441..027fb2e 100644 --- a/internal/modules/util/htmlgame/http.go +++ b/internal/modules/util/htmlgame/http.go @@ -41,13 +41,18 @@ func ParseBaseURL(raw, game string) string { // SecurityHeaders applies to every response under a game's route prefix. The // token travels in the page URL, so no referrer may leak it. Telegram Web // embeds games in an iframe, so framing is deliberately not restricted. API -// answers under prefix+"api/" are never cached. -func SecurityHeaders(prefix string, next http.Handler) http.Handler { +// answers under prefix+"api/" are never cached. imgSrc adds image origins to +// the policy, for a page that shows pictures from a CDN; images still come +// from nowhere else. +func SecurityHeaders(prefix string, next http.Handler, imgSrc ...string) http.Handler { + img := strings.Join(append([]string{"'self'", "data:"}, imgSrc...), " ") + csp := "default-src 'self'; script-src 'self' https://telegram.org; style-src 'self'; img-src " + img + + "; connect-src 'self'; base-uri 'none'; form-action 'none'" return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("Referrer-Policy", "no-referrer") h.Set("X-Content-Type-Options", "nosniff") - h.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://telegram.org; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; form-action 'none'") + h.Set("Content-Security-Policy", csp) if strings.HasPrefix(r.URL.Path, prefix+"api/") { h.Set("Cache-Control", "no-store") } diff --git a/internal/modules/wordle/compare.go b/internal/modules/wordle/compare.go deleted file mode 100644 index 11db32e..0000000 --- a/internal/modules/wordle/compare.go +++ /dev/null @@ -1,24 +0,0 @@ -// Package wordle implements the classic 5-letter word-guess game, scored -// letter-by-letter green/yellow/grey. The word data and scoring live in the -// wordlist subpackage, shared with wordledaily. -package wordle - -import "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" - -// WordLength is wordle's fixed 5. -const WordLength = wordlist.WordLength - -// Letter result categories stored in LetterScore.Result. The values are part -// of the stored game document's shape, so changing one orphans saved rounds. -const ( - ResultCorrect = wordlist.ResultCorrect - ResultPartial = wordlist.ResultPartial - ResultWrong = wordlist.ResultWrong -) - -// LetterScore is one scored letter, stored per guess (nested in GameState). -type LetterScore = wordlist.LetterScore - -// CompareWords scores guess against the answer letter-by-letter, handling -// duplicate letters the NYT way. See wordlist.Compare. -func CompareWords(guess, answer string) []LetterScore { return wordlist.Compare(guess, answer) } diff --git a/internal/modules/wordle/compare_test.go b/internal/modules/wordle/compare_test.go deleted file mode 100644 index d2cae2b..0000000 --- a/internal/modules/wordle/compare_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package wordle - -import ( - "strings" - "testing" -) - -// resultsLetters joins the .Result fields so test expectations stay readable. -func resultsLetters(rs []LetterScore) string { - out := make([]string, len(rs)) - for i, r := range rs { - out[i] = r.Result - } - return strings.Join(out, ",") -} - -func TestCompareWords_AllCorrect(t *testing.T) { - r := CompareWords("crane", "crane") - if got, want := resultsLetters(r), "correct,correct,correct,correct,correct"; got != want { - t.Errorf("got %s, want %s", got, want) - } -} - -func TestCompareWords_AllWrong(t *testing.T) { - r := CompareWords("abcde", "fghij") - if got, want := resultsLetters(r), "wrong,wrong,wrong,wrong,wrong"; got != want { - t.Errorf("got %s, want %s", got, want) - } -} - -func TestCompareWords_CorrectOverPartial(t *testing.T) { - // guess "slate" vs target "shale" → s correct, l partial, a correct, - // t wrong, e correct - r := CompareWords("slate", "shale") - if got, want := resultsLetters(r), "correct,partial,correct,wrong,correct"; got != want { - t.Errorf("got %s, want %s", got, want) - } -} - -func TestCompareWords_DuplicateExcessLettersWrong(t *testing.T) { - // target "abbey", guess "babes": b@0 partial, a@1 partial, b@2 correct, - // e@3 correct, s@4 wrong - r := CompareWords("babes", "abbey") - if got, want := resultsLetters(r), "partial,partial,correct,correct,wrong"; got != want { - t.Errorf("got %s, want %s", got, want) - } -} - -func TestCompareWords_DuplicateGuessSingleTarget(t *testing.T) { - // target "abide", guess "aahed": a@0 correct, a@1 wrong (pool exhausted), - // h wrong, e@3 partial, d@4 partial - r := CompareWords("aahed", "abide") - if got, want := resultsLetters(r), "correct,wrong,wrong,partial,partial"; got != want { - t.Errorf("got %s, want %s", got, want) - } -} - -func TestCompareWords_DuplicatesBothSides(t *testing.T) { - // target "lever", guess "ebbed": - // pass1: e@3=e correct (consume e). pool=[l,e,v,r] - // pass2: e@0 partial (consume remaining e); b@1 wrong; b@2 wrong; d@4 wrong - r := CompareWords("ebbed", "lever") - if got, want := resultsLetters(r), "partial,wrong,wrong,correct,wrong"; got != want { - t.Errorf("got %s, want %s", got, want) - } -} - -func TestCompareWords_AllSameTargetExhaustsPool(t *testing.T) { - // target "aaaaa", guess "aabbb" → both 'a's are positional matches; the - // remaining b's find nothing in the pool (already empty), so they're wrong. - // Locks the "pool exhausted before pass 2" branch. - r := CompareWords("aabbb", "aaaaa") - if got, want := resultsLetters(r), "correct,correct,wrong,wrong,wrong"; got != want { - t.Errorf("got %s, want %s", got, want) - } -} - -func TestCompareWords_PreservesGuessLetters(t *testing.T) { - r := CompareWords("crane", "cloud") - letters := make([]string, len(r)) - for i, x := range r { - letters[i] = x.Letter - } - want := []string{"c", "r", "a", "n", "e"} - for i := range want { - if letters[i] != want[i] { - t.Errorf("letter[%d] = %s, want %s", i, letters[i], want[i]) - } - } -} diff --git a/internal/modules/wordle/handlers.go b/internal/modules/wordle/handlers.go deleted file mode 100644 index 6bc88f0..0000000 --- a/internal/modules/wordle/handlers.go +++ /dev/null @@ -1,235 +0,0 @@ -package wordle - -import ( - "context" - "fmt" - "strings" - - "github.com/go-telegram/bot" - "github.com/go-telegram/bot/models" - - "github.com/tiennm99/tiennm99bot/internal/keylock" - "github.com/tiennm99/tiennm99bot/internal/modules/util/chathelper" -) - -// state captures everything a wordle command needs at handler-time. Built -// once in New and shared across all four handlers via closure. -type state struct { - games GameStore - stats StatsStore - words []string - set map[string]struct{} - locks keylock.Map // per-subject mutex; serialises Get→mutate→Put -} - -// rejectMessage maps a validation failure into the user-facing reply. -func rejectMessage(reason rejectReason) string { - switch reason { - case reasonEmpty: - return fmt.Sprintf("Please provide a %d-letter word.", WordLength) - case reasonLength: - return fmt.Sprintf("Word must be exactly %d letters.", WordLength) - default: - return "Not in the word list." - } -} - -// startFresh writes a brand-new round and returns it. Errors propagate to -// the caller (not swallowed — a store failure means subsequent ops will lie). -func (s *state) startFresh(ctx context.Context, subject string) (*GameState, error) { - target, err := pickRandom(s.words, nil) - if err != nil { - return nil, fmt.Errorf("wordle startFresh: %w", err) - } - g := &GameState{ - Target: target, - Guesses: []GuessRecord{}, - Solved: false, - Giveup: false, - StartedAt: chathelper.NowMillis(), - } - if err := saveGame(ctx, s.games, subject, g); err != nil { - return nil, err - } - return g, nil -} - -func (s *state) getOrInit(ctx context.Context, subject string) (*GameState, error) { - g, err := loadGame(ctx, s.games, subject) - if err != nil { - return nil, err - } - if g != nil { - return g, nil - } - return s.startFresh(ctx, subject) -} - -// handleWordle is /wordle [word] — show board if no arg, else submit a guess. -func (s *state) handleWordle(ctx context.Context, b *bot.Bot, update *models.Update) error { - msg := update.Message - if msg == nil { - return nil - } - subject := chathelper.SubjectFor(msg) - if subject == "" { - return chathelper.Reply(ctx, b, msg, "Cannot identify chat.") - } - defer s.locks.Acquire(subject)() - arg := chathelper.ArgAfterCommand(msg.Text) - - g, err := s.getOrInit(ctx, subject) - if err != nil { - return err - } - - if arg == "" { - var header string - switch { - case g.Solved: - header = fmt.Sprintf("🎉 Solved in %d/%d. /wordle_new for another.", len(g.Guesses), MaxGuesses) - case g.Giveup: - header = fmt.Sprintf("🏳️ Gave up. Answer was %s. /wordle_new for another.", strings.ToUpper(g.Target)) - default: - header = fmt.Sprintf("Guess %d/%d. Use `/wordle `.", len(g.Guesses), MaxGuesses) - } - return chathelper.Reply(ctx, b, msg, header+"\n\n"+renderBoard(g.Guesses)) - } - - if isFinished(g) { - return chathelper.Reply(ctx, b, msg, - fmt.Sprintf("Current round is over. Use /wordle_new to start another. Answer was %s.", strings.ToUpper(g.Target))) - } - - v := validateGuess(s.set, arg) - if !v.OK { - return chathelper.Reply(ctx, b, msg, rejectMessage(v.Reason)) - } - - results := CompareWords(v.Word, g.Target) - g.Guesses = append(g.Guesses, GuessRecord{Word: v.Word, Results: results}) - won := v.Word == g.Target - if won { - g.Solved = true - } - if err := saveGame(ctx, s.games, subject, g); err != nil { - return err - } - - rendered := renderGuess(v.Word, results) - switch { - case won: - st, err := recordResult(ctx, s.stats, subject, true, chathelper.NowMillis()) - if err != nil { - return err - } - return chathelper.Reply(ctx, b, msg, fmt.Sprintf("%s\n\n🎉 Solved in %d/%d! Streak: %d. /wordle_new for another.", - rendered, len(g.Guesses), MaxGuesses, st.Streak)) - case len(g.Guesses) >= MaxGuesses: - if _, err := recordResult(ctx, s.stats, subject, false, chathelper.NowMillis()); err != nil { - return err - } - return chathelper.Reply(ctx, b, msg, fmt.Sprintf("%s\n\n❌ Out of guesses. Answer was %s. /wordle_new to retry.", - rendered, strings.ToUpper(g.Target))) - default: - return chathelper.Reply(ctx, b, msg, fmt.Sprintf("%s\n\nGuess %d/%d.", rendered, len(g.Guesses), MaxGuesses)) - } -} - -// handleNew is /wordle_new — abandons any in-progress round (counts as -// giveup → stats hit) and starts fresh. -func (s *state) handleNew(ctx context.Context, b *bot.Bot, update *models.Update) error { - msg := update.Message - if msg == nil { - return nil - } - subject := chathelper.SubjectFor(msg) - if subject == "" { - return chathelper.Reply(ctx, b, msg, "Cannot identify chat.") - } - defer s.locks.Acquire(subject)() - - prelude := "" - prior, err := loadGame(ctx, s.games, subject) - if err != nil { - return err - } - if prior != nil && !isFinished(prior) { - if _, err := recordResult(ctx, s.stats, subject, false, chathelper.NowMillis()); err != nil { - return err - } - prelude = fmt.Sprintf("🏳️ Previous round abandoned (auto-giveup). Answer was %s.\n\n", - strings.ToUpper(prior.Target)) - } - - if _, err := s.startFresh(ctx, subject); err != nil { - return err - } - return chathelper.Reply(ctx, b, msg, prelude+"🆕 New round started. Use `/wordle ` to guess.") -} - -// handleGiveup is /wordle_giveup — reveals answer for the current round. -// Calls on a finished round (solved, given up, or out of guesses) echo the -// answer without touching stats. -func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error { - msg := update.Message - if msg == nil { - return nil - } - subject := chathelper.SubjectFor(msg) - if subject == "" { - return chathelper.Reply(ctx, b, msg, "Cannot identify chat.") - } - defer s.locks.Acquire(subject)() - g, err := loadGame(ctx, s.games, subject) - if err != nil { - return err - } - if g == nil { - return chathelper.Reply(ctx, b, msg, "No active round. /wordle_new to start one.") - } - if g.Solved { - return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Already solved — %s.", strings.ToUpper(g.Target))) - } - if g.Giveup { - return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Already gave up — %s.", strings.ToUpper(g.Target))) - } - // A round lost by running out of guesses already recorded its loss in - // handleWordle; giving up on it must not count a second one. - if isFinished(g) { - return chathelper.Reply(ctx, b, msg, - fmt.Sprintf("Current round is over. Use /wordle_new to start another. Answer was %s.", strings.ToUpper(g.Target))) - } - g.Giveup = true - if err := saveGame(ctx, s.games, subject, g); err != nil { - return err - } - if _, err := recordResult(ctx, s.stats, subject, false, chathelper.NowMillis()); err != nil { - return err - } - return chathelper.Reply(ctx, b, msg, fmt.Sprintf("🏳️ Answer was %s. /wordle_new for another.", strings.ToUpper(g.Target))) -} - -// handleStats is /wordle_stats — shows lifetime score for the subject. -func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error { - msg := update.Message - if msg == nil { - return nil - } - subject := chathelper.SubjectFor(msg) - if subject == "" { - return chathelper.Reply(ctx, b, msg, "Cannot identify chat.") - } - st, err := loadStats(ctx, s.stats, subject) - if err != nil { - return err - } - scope := "group" - if msg.Chat.Type == models.ChatTypePrivate { - scope = "your" - } - return chathelper.Reply(ctx, b, msg, fmt.Sprintf( - "📊 Wordle %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d", - scope, st.Played, st.Wins, chathelper.WinRate(st.Wins, st.Played), st.Streak, st.BestStreak, - )) -} diff --git a/internal/modules/wordle/handlers_test.go b/internal/modules/wordle/handlers_test.go deleted file mode 100644 index ffb506e..0000000 --- a/internal/modules/wordle/handlers_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package wordle - -import ( - "context" - "strings" - "testing" - - "github.com/tiennm99/tiennm99bot/internal/modules" - "github.com/tiennm99/tiennm99bot/internal/storage" - "github.com/tiennm99/tiennm99bot/internal/testutil" -) - -// installWordle wires the wordle module to a recording bot with an -// in-memory store. seedTarget pre-seeds a game with the supplied target -// (skip dictionary randomness for deterministic tests). Empty seedTarget -// leaves the store blank so handlers spin up a fresh round on first call. -func installWordle(t *testing.T, ownerID int64, seedSubject, seedTarget string) (*testutil.RecordingBot, GameStore) { - t.Helper() - rb := testutil.NewRecordingBot(t) - col := storage.NewMemoryProvider().Collection("wordle") - games := storage.Typed[GameState](col) - mod := New(modules.Deps{Store: col}) - reg := &modules.Registry{ - Modules: []modules.Module{{Name: "wordle", Commands: mod.Commands}}, - AllCommands: map[string]modules.Command{}, - } - for _, c := range mod.Commands { - reg.AllCommands[c.Name] = c - } - modules.Install(rb.Bot, reg, modules.Auth{BotOwnerID: ownerID}) - - if seedTarget != "" { - g := &GameState{Target: seedTarget, Guesses: []GuessRecord{}, StartedAt: 1} - if err := saveGame(context.Background(), games, seedSubject, g); err != nil { - t.Fatalf("seed game: %v", err) - } - } - return rb, games -} - -func TestWordle_NoArgShowsBoard(t *testing.T) { - rb, _ := installWordle(t, 0, "1", "crane") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordle")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "Guess 0/6") { - t.Errorf("/wordle empty-arg reply missing 'Guess 0/6': %q", got) - } -} - -func TestWordle_Win(t *testing.T) { - rb, _ := installWordle(t, 0, "1", "crane") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordle crane")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "Solved in 1/6") { - t.Errorf("win reply missing 'Solved in 1/6': %q", got) - } - if !strings.Contains(got, "Streak: 1") { - t.Errorf("win reply missing 'Streak: 1': %q", got) - } -} - -func TestWordle_InvalidWord_TooShort(t *testing.T) { - rb, _ := installWordle(t, 0, "1", "crane") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordle hi")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "5 letters") { - t.Errorf("short-word reject missing length hint: %q", got) - } -} - -func TestWordle_InvalidWord_NotInDict(t *testing.T) { - rb, _ := installWordle(t, 0, "1", "crane") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordle qqqqq")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "Not in the word list") { - t.Errorf("dict-miss reject wrong text: %q", got) - } -} - -func TestWordle_PartialGuessShowsProgress(t *testing.T) { - rb, _ := installWordle(t, 0, "1", "crane") - // "crate" shares 4 letters with "crane" — valid 5-letter dictionary word. - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordle crate")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "Guess 1/6") { - t.Errorf("partial-guess reply missing 'Guess 1/6': %q", got) - } -} - -func TestWordleNew_StartsFreshRound(t *testing.T) { - rb, _ := installWordle(t, 0, "1", "crane") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordle_new")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "New round started") { - t.Errorf("/wordle_new reply: %q", got) - } - // Previous round was active (no guesses yet) → auto-giveup prelude expected. - if !strings.Contains(got, "Previous round abandoned") { - t.Errorf("/wordle_new should announce auto-giveup of prior active round; got %q", got) - } -} - -func TestWordleGiveup_RevealsAnswer(t *testing.T) { - rb, _ := installWordle(t, 0, "1", "crane") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordle_giveup")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "CRANE") { - t.Errorf("/wordle_giveup should reveal CRANE; got %q", got) - } -} - -func TestWordleGiveup_NoActiveRoundDoesNotStartOrRecordLoss(t *testing.T) { - rb, games := installWordle(t, 0, "", "") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordle_giveup")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "No active round") { - t.Fatalf("/wordle_giveup with no round: %q", got) - } - g, err := loadGame(context.Background(), games, "1") - if err != nil { - t.Fatalf("loadGame: %v", err) - } - if g != nil { - t.Fatalf("giveup created game: %+v", g) - } - - rb.Reset() - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordle_stats")) - if got := rb.LastSent().Text(); !strings.Contains(got, "Played: 0") { - t.Fatalf("empty giveup recorded stats: %q", got) - } -} - -func TestWordleStats_Empty(t *testing.T) { - rb, _ := installWordle(t, 0, "", "") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordle_stats")) - - got := rb.LastSent().Text() - for _, want := range []string{"Played: 0", "Wins: 0 (0%)", "Best streak: 0"} { - if !strings.Contains(got, want) { - t.Errorf("/wordle_stats missing %q; got %q", want, got) - } - } -} - -func TestWordleStats_AfterWin(t *testing.T) { - rb, _ := installWordle(t, 0, "1", "crane") - // Win: produces 1 played, 1 win, streak 1. - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordle crane")) - rb.Reset() - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordle_stats")) - - got := rb.LastSent().Text() - for _, want := range []string{"Played: 1", "Wins: 1 (100%)", "Current streak: 1"} { - if !strings.Contains(got, want) { - t.Errorf("/wordle_stats post-win missing %q; got %q", want, got) - } - } -} - -// Group chats key by chat id, so two different users both writing /wordle -// in the same group must mutate the same game. -func TestWordle_GroupSubjectIsChatID(t *testing.T) { - rb, games := installWordle(t, 0, "-100", "crane") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewGroupMessage(-100, 7, "/wordle crate")) - - // After one guess in chat -100, subject "-100" should have a guess - // recorded; subject "7" should have no game. - gChat, err := loadGame(context.Background(), games, "-100") - if err != nil { - t.Fatalf("expected game at subject=-100: %v", err) - } - if gChat == nil || len(gChat.Guesses) != 1 { - t.Errorf("group game guesses = %v, want 1", gChat) - } - - gUser, err := loadGame(context.Background(), games, "7") - if err != nil { - t.Fatalf("loadGame for user subject: %v", err) - } - if gUser != nil { - t.Errorf("user-keyed game leaked despite group context: %+v", gUser) - } -} - -// Running out of guesses already records the loss, so a later /wordle_giveup -// on that round must not count a second one. -func TestWordleGiveup_OutOfGuessesDoesNotRecordSecondLoss(t *testing.T) { - rb, games := installWordle(t, 0, "", "") - g := &GameState{Target: "crane", StartedAt: 1} - for range MaxGuesses { - g.Guesses = append(g.Guesses, GuessRecord{Word: "slate", Results: CompareWords("slate", "crane")}) - } - if err := saveGame(context.Background(), games, "1", g); err != nil { - t.Fatalf("seed game: %v", err) - } - - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordle_giveup")) - if got := rb.LastSent().Text(); !strings.Contains(got, "Current round is over") || !strings.Contains(got, "CRANE") { - t.Fatalf("/wordle_giveup on exhausted round: %q", got) - } - - rb.Reset() - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/wordle_stats")) - if got := rb.LastSent().Text(); !strings.Contains(got, "Played: 0") { - t.Fatalf("giveup on exhausted round recorded stats: %q", got) - } -} diff --git a/internal/modules/wordle/lookup.go b/internal/modules/wordle/lookup.go deleted file mode 100644 index 8f4c121..0000000 --- a/internal/modules/wordle/lookup.go +++ /dev/null @@ -1,26 +0,0 @@ -package wordle - -import "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" - -// normalizeWord lowercases input and strips anything outside a-z. -func normalizeWord(input string) string { return wordlist.Normalize(input) } - -// rejectReason classifies why validateGuess returned not-ok. rejectMessage in -// handlers.go maps each reason to its user-facing reply. -type rejectReason = wordlist.Reason - -const ( - reasonEmpty = wordlist.ReasonEmpty - reasonLength = wordlist.ReasonLength - reasonUnknown = wordlist.ReasonUnknown -) - -// guessResult is the validateGuess outcome; Word is always the normalized -// input. -type guessResult = wordlist.Result - -// validateGuess normalizes input then checks length + dictionary membership. -// Reasons in priority order: empty > length > unknown. -func validateGuess(dict map[string]struct{}, input string) guessResult { - return wordlist.Validate(dict, input) -} diff --git a/internal/modules/wordle/lookup_test.go b/internal/modules/wordle/lookup_test.go deleted file mode 100644 index 87a6faa..0000000 --- a/internal/modules/wordle/lookup_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package wordle - -import "testing" - -func TestNormalizeWord(t *testing.T) { - cases := map[string]string{ - "": "", - "crane": "crane", - "CRANE": "crane", - " crane ": "crane", - "c-r-a-n-e": "crane", - "héllo": "hllo", // strips non a-z bytes, including both UTF-8 bytes of é - "!@#$%": "", - "42 crane": "crane", - } - for in, want := range cases { - if got := normalizeWord(in); got != want { - t.Errorf("normalizeWord(%q) = %q, want %q", in, got, want) - } - } -} - -func TestValidateGuess(t *testing.T) { - dict := map[string]struct{}{"crane": {}, "shale": {}} - - type want struct { - ok bool - reason rejectReason - word string - } - cases := []struct { - input string - want want - }{ - {"", want{ok: false, reason: reasonEmpty, word: ""}}, - {"!!!", want{ok: false, reason: reasonEmpty, word: ""}}, - {"cat", want{ok: false, reason: reasonLength, word: "cat"}}, - {"craning", want{ok: false, reason: reasonLength, word: "craning"}}, - {"there", want{ok: false, reason: reasonUnknown, word: "there"}}, - {"crane", want{ok: true, word: "crane"}}, - {" CRANE!", want{ok: true, word: "crane"}}, // normalization survives - } - for _, c := range cases { - got := validateGuess(dict, c.input) - if got.OK != c.want.ok || got.Reason != c.want.reason || got.Word != c.want.word { - t.Errorf("validateGuess(%q) = %+v, want %+v", c.input, got, c.want) - } - } -} diff --git a/internal/modules/wordle/pick_random.go b/internal/modules/wordle/pick_random.go deleted file mode 100644 index 0bf84b5..0000000 --- a/internal/modules/wordle/pick_random.go +++ /dev/null @@ -1,26 +0,0 @@ -package wordle - -import ( - "errors" - "math/rand" -) - -// errEmptyWordList is returned by pickRandom when the dictionary is empty. -// startFresh propagates it so the handler fails instead of saving a round -// with no answer. -var errEmptyWordList = errors.New("wordle: word list is empty") - -// pickRandom returns a uniformly random word. rng allows tests to inject a deterministic source. When rng is nil we fall -// through to math/rand's package-level Intn, which IS goroutine-safe via an -// internal mutex on the global Source — important because the bot dispatcher -// runs each Telegram update in its own goroutine and concurrent /wordle_new -// calls would otherwise race on a shared *rand.Rand. -func pickRandom(words []string, rng *rand.Rand) (string, error) { - if len(words) == 0 { - return "", errEmptyWordList - } - if rng != nil { - return words[rng.Intn(len(words))], nil - } - return words[rand.Intn(len(words))], nil -} diff --git a/internal/modules/wordle/pick_random_test.go b/internal/modules/wordle/pick_random_test.go deleted file mode 100644 index 3a10e94..0000000 --- a/internal/modules/wordle/pick_random_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package wordle - -import ( - "math/rand" - "sync" - "testing" -) - -func TestPickRandom_UsesInjectedRNG(t *testing.T) { - rng := rand.New(rand.NewSource(1)) - words := []string{"a", "b", "c", "d", "e"} - first, err := pickRandom(words, rng) - if err != nil { - t.Fatal(err) - } - // Re-seed with same source → same sequence; lock determinism. - rng = rand.New(rand.NewSource(1)) - again, _ := pickRandom(words, rng) - if first != again { - t.Errorf("seeded RNG should be deterministic: %s vs %s", first, again) - } -} - -func TestPickRandom_EmptyErrors(t *testing.T) { - if _, err := pickRandom(nil, nil); err == nil { - t.Error("expected error for empty list") - } -} - -// TestPickRandom_NilRNGIsRaceFree exercises the production path (rng==nil) -// from many goroutines under -race. A regression to a non-thread-safe RNG -// would flag here. Cheap insurance for the hot handler path. -func TestPickRandom_NilRNGIsRaceFree(t *testing.T) { - words := []string{"a", "b", "c", "d", "e"} - const goroutines = 64 - const itersEach = 50 - - var wg sync.WaitGroup - wg.Add(goroutines) - for i := 0; i < goroutines; i++ { - go func() { - defer wg.Done() - for j := 0; j < itersEach; j++ { - if _, err := pickRandom(words, nil); err != nil { - t.Errorf("pickRandom: %v", err) - } - } - }() - } - wg.Wait() -} diff --git a/internal/modules/wordle/render.go b/internal/modules/wordle/render.go deleted file mode 100644 index c1bd215..0000000 --- a/internal/modules/wordle/render.go +++ /dev/null @@ -1,29 +0,0 @@ -package wordle - -import ( - "strings" - - "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" -) - -// renderGuess formats one guess as the NYT share-pattern: word on one line, -// emoji marker row below. -// -// CRANE -// 🟩🟨⬜🟩🟩 -func renderGuess(word string, results []LetterScore) string { - return strings.ToUpper(word) + "\n" + wordlist.EmojiRow(results) -} - -// renderBoard joins all prior guesses, blank-line separated. Used when a -// player asks for `/wordle` mid-round. -func renderBoard(guesses []GuessRecord) string { - if len(guesses) == 0 { - return "No guesses yet. Reply with `/wordle `." - } - rows := make([]string, len(guesses)) - for i, g := range guesses { - rows[i] = renderGuess(g.Word, g.Results) - } - return strings.Join(rows, "\n\n") -} diff --git a/internal/modules/wordle/state.go b/internal/modules/wordle/state.go deleted file mode 100644 index f59720e..0000000 --- a/internal/modules/wordle/state.go +++ /dev/null @@ -1,125 +0,0 @@ -package wordle - -import ( - "context" - "errors" - "fmt" - - "github.com/tiennm99/tiennm99bot/internal/storage" -) - -// MaxGuesses is the standard wordle round length. -const MaxGuesses = 6 - -// Note: the store has no native per-document TTL — saved games linger -// until manually cleaned. Out of scope today; could be added via a sweep -// cron if storage cost ever matters. - -// GuessRecord is one entry in a game's history: -// -// { "word": "crane", "results": [{"letter":"c","result":"correct"}, ...] } -type GuessRecord struct { - Word string `json:"word" bson:"word"` - Results []LetterScore `json:"results" bson:"results"` -} - -// GameState is the per-subject record for an in-progress (or finished) round. -// -// `giveup` is always emitted (initialized to false on every fresh round). Do NOT -// add omitempty — the field is part of the stored document's shape, so -// emitting it unconditionally keeps already-saved games self-describing -// when inspected via raw dumps. -type GameState struct { - Target string `json:"target" bson:"target"` - Guesses []GuessRecord `json:"guesses" bson:"guesses"` - Solved bool `json:"solved" bson:"solved"` - Giveup bool `json:"giveup" bson:"giveup"` - StartedAt int64 `json:"startedAt" bson:"startedAt"` // ms-since-epoch (chathelper.NowMillis) -} - -// Stats is the lifetime score record. lastResultAt is *int64 so an unplayed -// account marshals as `"lastResultAt": null` — distinguishes "never played" -// from "played at epoch zero". -type Stats struct { - Played int `json:"played" bson:"played"` - Wins int `json:"wins" bson:"wins"` - Streak int `json:"streak" bson:"streak"` - BestStreak int `json:"bestStreak" bson:"bestStreak"` - LastResultAt *int64 `json:"lastResultAt" bson:"lastResultAt"` // ms-since-epoch | null -} - -// GameStore is the wordle module's typed game store. -type GameStore = storage.DocStore[GameState] - -// StatsStore is the wordle module's typed stats store. -type StatsStore = storage.DocStore[Stats] - -func gameKey(subject string) string { return "game:" + subject } -func statsKey(subject string) string { return "stats:" + subject } - -// loadGame returns the active round, or (nil, nil) if none exists. -func loadGame(ctx context.Context, games GameStore, subject string) (*GameState, error) { - g, _, err := games.Get(ctx, gameKey(subject)) - switch { - case err == nil: - return &g, nil - case errors.Is(err, storage.ErrNotFound): - return nil, nil - default: - return nil, fmt.Errorf("wordle loadGame: %w", err) - } -} - -// saveGame writes the round. -func saveGame(ctx context.Context, games GameStore, subject string, g *GameState) error { - if err := games.Put(ctx, gameKey(subject), *g); err != nil { - return fmt.Errorf("wordle saveGame: %w", err) - } - return nil -} - -// loadStats returns lifetime stats; missing → fresh-zero record (with -// LastResultAt=nil) so callers never need a nil check. -func loadStats(ctx context.Context, stats StatsStore, subject string) (*Stats, error) { - s, _, err := stats.Get(ctx, statsKey(subject)) - switch { - case err == nil: - return &s, nil - case errors.Is(err, storage.ErrNotFound): - return &Stats{}, nil - default: - return nil, fmt.Errorf("wordle loadStats: %w", err) - } -} - -// recordResult bumps stats with the round outcome (won true → win + streak, -// false → reset streak). Returns the updated stats so callers can show the -// new streak in the win message. -func recordResult(ctx context.Context, stats StatsStore, subject string, won bool, nowMillis int64) (*Stats, error) { - s, err := loadStats(ctx, stats, subject) - if err != nil { - return nil, err - } - s.Played++ - if won { - s.Wins++ - s.Streak++ - if s.Streak > s.BestStreak { - s.BestStreak = s.Streak - } - } else { - s.Streak = 0 - } - now := nowMillis - s.LastResultAt = &now - if err := stats.Put(ctx, statsKey(subject), *s); err != nil { - return nil, fmt.Errorf("wordle recordResult: %w", err) - } - return s, nil -} - -// isFinished is true when the round can no longer accept guesses: solved, -// gave up, or out of guesses. -func isFinished(g *GameState) bool { - return g.Solved || g.Giveup || len(g.Guesses) >= MaxGuesses -} diff --git a/internal/modules/wordle/state_test.go b/internal/modules/wordle/state_test.go deleted file mode 100644 index cb4eebd..0000000 --- a/internal/modules/wordle/state_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package wordle - -import ( - "context" - "encoding/json" - "testing" - - "github.com/tiennm99/tiennm99bot/internal/storage" -) - -func newWordleGames() GameStore { - return storage.Typed[GameState](storage.NewMemoryProvider().Collection("wordle")) -} - -func newWordleStats() StatsStore { - return storage.Typed[Stats](storage.NewMemoryProvider().Collection("wordle")) -} - -func TestStats_DefaultLastResultAtIsNull(t *testing.T) { - // Go's *int64 must marshal as null when nil, so unplayed accounts emit - // `"lastResultAt": null` and the field stays distinguishable from - // "played at ms-epoch 0". - s := Stats{} - b, err := json.Marshal(s) - if err != nil { - t.Fatal(err) - } - got := string(b) - want := `{"played":0,"wins":0,"streak":0,"bestStreak":0,"lastResultAt":null}` - if got != want { - t.Errorf("Stats marshal:\ngot %s\nwant %s", got, want) - } -} - -func TestStats_WithResultMarshalsAsNumber(t *testing.T) { - at := int64(1700000000000) - s := Stats{Played: 1, Wins: 1, Streak: 1, BestStreak: 1, LastResultAt: &at} - b, _ := json.Marshal(s) - want := `{"played":1,"wins":1,"streak":1,"bestStreak":1,"lastResultAt":1700000000000}` - if string(b) != want { - t.Errorf("Stats marshal:\ngot %s\nwant %s", b, want) - } -} - -func TestGameState_JSONShapeIsStable(t *testing.T) { - g := GameState{ - Target: "crane", - Guesses: []GuessRecord{ - {Word: "slate", Results: []LetterScore{ - {Letter: "s", Result: ResultCorrect}, - {Letter: "l", Result: ResultPartial}, - {Letter: "a", Result: ResultCorrect}, - {Letter: "t", Result: ResultWrong}, - {Letter: "e", Result: ResultCorrect}, - }}, - }, - Solved: false, - Giveup: false, - StartedAt: 1700000000000, - } - b, err := json.Marshal(g) - if err != nil { - t.Fatal(err) - } - want := `{"target":"crane","guesses":[{"word":"slate","results":[{"letter":"s","result":"correct"},{"letter":"l","result":"partial"},{"letter":"a","result":"correct"},{"letter":"t","result":"wrong"},{"letter":"e","result":"correct"}]}],"solved":false,"giveup":false,"startedAt":1700000000000}` - if string(b) != want { - t.Errorf("GameState marshal:\ngot %s\nwant %s", b, want) - } -} - -func TestRecordResult_WinIncrementsStreak(t *testing.T) { - ctx := context.Background() - stats := newWordleStats() - - s, err := recordResult(ctx, stats, "u1", true, 100) - if err != nil { - t.Fatalf("recordResult: %v", err) - } - if s.Played != 1 || s.Wins != 1 || s.Streak != 1 || s.BestStreak != 1 { - t.Errorf("first win: %+v", s) - } - if s.LastResultAt == nil || *s.LastResultAt != 100 { - t.Errorf("LastResultAt = %v, want *=100", s.LastResultAt) - } - - // Second win bumps streak; bestStreak follows. - s, _ = recordResult(ctx, stats, "u1", true, 200) - if s.Streak != 2 || s.BestStreak != 2 { - t.Errorf("two wins: streak=%d best=%d", s.Streak, s.BestStreak) - } -} - -func TestRecordResult_LossResetsStreakKeepsBest(t *testing.T) { - ctx := context.Background() - stats := newWordleStats() - - _, _ = recordResult(ctx, stats, "u1", true, 100) - _, _ = recordResult(ctx, stats, "u1", true, 200) - s, _ := recordResult(ctx, stats, "u1", false, 300) - if s.Streak != 0 { - t.Errorf("loss should reset streak, got %d", s.Streak) - } - if s.BestStreak != 2 { - t.Errorf("best streak should persist, got %d", s.BestStreak) - } - if s.Played != 3 || s.Wins != 2 { - t.Errorf("counters: %+v", s) - } -} - -func TestLoadGame_MissingReturnsNil(t *testing.T) { - g, err := loadGame(context.Background(), newWordleGames(), "nobody") - if err != nil { - t.Errorf("missing game should not error: %v", err) - } - if g != nil { - t.Errorf("expected nil game, got %+v", g) - } -} - -func TestSaveGame_RoundTrip(t *testing.T) { - ctx := context.Background() - games := newWordleGames() - want := &GameState{Target: "crane", Guesses: []GuessRecord{}, StartedAt: 42} - if err := saveGame(ctx, games, "u1", want); err != nil { - t.Fatal(err) - } - got, err := loadGame(ctx, games, "u1") - if err != nil || got == nil { - t.Fatalf("loadGame: got=%v err=%v", got, err) - } - if got.Target != "crane" || got.StartedAt != 42 { - t.Errorf("round-trip mismatch: %+v", got) - } -} - -func TestIsFinished(t *testing.T) { - if !isFinished(&GameState{Solved: true}) { - t.Error("solved should be finished") - } - if !isFinished(&GameState{Giveup: true}) { - t.Error("giveup should be finished") - } - full := &GameState{Guesses: make([]GuessRecord, MaxGuesses)} - if !isFinished(full) { - t.Error("max-guesses should be finished") - } - if isFinished(&GameState{Guesses: make([]GuessRecord, MaxGuesses-1)}) { - t.Error("under-max not finished") - } -} diff --git a/internal/modules/wordle/wordle.go b/internal/modules/wordle/wordle.go deleted file mode 100644 index 1043d9f..0000000 --- a/internal/modules/wordle/wordle.go +++ /dev/null @@ -1,48 +0,0 @@ -package wordle - -import ( - "github.com/tiennm99/tiennm99bot/internal/modules" - "github.com/tiennm99/tiennm99bot/internal/storage" -) - -// New is the wordle module Factory. Loads the embedded dictionary once, -// captures the per-module typed stores via closure, and registers all four commands. -func New(deps modules.Deps) modules.Module { - words, set := loadWords() - s := &state{ - games: storage.Typed[GameState](deps.Store), - stats: storage.Typed[Stats](deps.Store), - words: words, - set: set, - } - - return modules.Module{ - Commands: []modules.Command{ - { - Name: "wordle", - Visibility: modules.VisibilityPublic, - Description: "Classic wordle — guess the 5-letter word", - Parameters: "[word]", - Handler: s.handleWordle, - }, - { - Name: "wordle_new", - Visibility: modules.VisibilityPublic, - Description: "Start a new round (auto-gives-up any in-progress one)", - Handler: s.handleNew, - }, - { - Name: "wordle_giveup", - Visibility: modules.VisibilityPublic, - Description: "Reveal the current wordle answer", - Handler: s.handleGiveup, - }, - { - Name: "wordle_stats", - Visibility: modules.VisibilityPublic, - Description: "Show your wordle stats (wins, streak)", - Handler: s.handleStats, - }, - }, - } -} diff --git a/internal/modules/wordle/words.go b/internal/modules/wordle/words.go deleted file mode 100644 index 4939078..0000000 --- a/internal/modules/wordle/words.go +++ /dev/null @@ -1,7 +0,0 @@ -package wordle - -import "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" - -// loadWords parses the embedded dictionary into a slice plus a membership -// set; a malformed line panics at startup. See wordlist.Load. -func loadWords() ([]string, map[string]struct{}) { return wordlist.Load() } diff --git a/internal/modules/wordle/words_test.go b/internal/modules/wordle/words_test.go deleted file mode 100644 index 5da72b6..0000000 --- a/internal/modules/wordle/words_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package wordle - -import "testing" - -// TestLoadWords_EmbeddedDictIsValid asserts the embedded data file: every -// entry is exactly 5 lowercase a-z, count is plausible, and a known word -// is present. Cheap insurance against a bad regen of words.txt. -func TestLoadWords_EmbeddedDictIsValid(t *testing.T) { - words, set := loadWords() - - if len(words) < 14000 || len(words) > 15000 { - t.Errorf("word count = %d, want ~14855", len(words)) - } - if len(words) != len(set) { - t.Errorf("words/set length mismatch: %d vs %d (duplicates?)", len(words), len(set)) - } - - // Spot-check known words from the dracos list. - for _, sample := range []string{"crane", "abase", "zymic"} { - if _, ok := set[sample]; !ok { - t.Errorf("expected %q in dict", sample) - } - } -} diff --git a/internal/modules/wordledaily/callback.go b/internal/modules/wordledaily/callback.go index 2fef078..c3ff3d1 100644 --- a/internal/modules/wordledaily/callback.go +++ b/internal/modules/wordledaily/callback.go @@ -2,70 +2,42 @@ package wordledaily import ( "context" - "net/url" + "time" "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" - "github.com/tiennm99/tiennm99bot/internal/log" + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" ) const ( msgDisabled = "Wordle Daily isn't configured on this server." msgNoGameTarget = "Couldn't find the game message. Send /wordledaily to play." + msgCardLookup = "Couldn't open the game right now. Try again later." ) -// handlePlay answers a Play press with the game URL carrying a signed token, -// or with an alert when the game cannot open. Every path answers the query, -// so the client never keeps spinning. +// handlePlay answers a Play press with the game URL carrying a signed token. +// A /wordle card opens the player's unlimited round; every other card, +// /wordledaily, the 07:00 push, an inline share or an expired record, +// opens today's daily puzzle. func (s *service) handlePlay(ctx context.Context, b *bot.Bot, update *models.Update) error { - q := update.CallbackQuery - if !s.enabled() { - return answerAlert(ctx, b, q.ID, msgDisabled) - } - c, form := playClaims(q) - if form == "" { - return answerAlert(ctx, b, q.ID, msgNoGameTarget) - } - c.Expiry = s.cfg.now().Add(tokenTTL).Unix() - token, err := s.signToken(c) - if err != nil { - _ = answerAlert(ctx, b, q.ID, msgDisabled) - return err - } - log.Debug("wordledaily play", "address", form) - _, err = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{ - CallbackQueryID: q.ID, - URL: s.cfg.baseURL + routePrefix + "?t=" + url.QueryEscape(token), + return guessgame.HandlePlay(ctx, b, update.CallbackQuery, guessgame.PlayParams{ + Enabled: s.enabled(), + Cards: s.cards, + Now: s.cfg.now(), + TokenKey: s.tokenKey, + PageURL: s.cfg.baseURL + routePrefix, + MsgDisabled: msgDisabled, + MsgNoTarget: msgNoGameTarget, + MsgCardLookup: msgCardLookup, + LogName: ShortName, }) - return err } -// playClaims addresses the card: the message itself when the bot can see -// it (with its forum topic), the inaccessible message's chat and id -// otherwise, and the inline message id for a card sent via the bot. form is -// "" when none is present. -func playClaims(q *models.CallbackQuery) (claims, string) { - c := claims{UserID: q.From.ID, Name: displayName(q.From.FirstName, q.From.LastName)} - switch { - case q.Message.Message != nil: - m := q.Message.Message - c.ChatID, c.MessageID = m.Chat.ID, m.ID - if m.IsTopicMessage { - c.ThreadID = m.MessageThreadID - } - return c, "message" - case q.Message.InaccessibleMessage != nil: - c.ChatID, c.MessageID = q.Message.InaccessibleMessage.Chat.ID, q.Message.InaccessibleMessage.MessageID - return c, "inaccessible_message" - case q.InlineMessageID != "": - c.InlineID = q.InlineMessageID - return c, "inline_message" - } - return c, "" +func (s *service) signToken(c guessgame.Claims) (string, error) { + return guessgame.SignToken(s.tokenKey, c) } -func answerAlert(ctx context.Context, b *bot.Bot, id, text string) error { - _, err := b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: id, Text: text, ShowAlert: true}) - return err +func (s *service) verifyToken(token string, now time.Time) (guessgame.Claims, error) { + return guessgame.VerifyToken(s.tokenKey, token, now) } diff --git a/internal/modules/wordledaily/callback_test.go b/internal/modules/wordledaily/callback_test.go index 56ec1b6..b5b2a86 100644 --- a/internal/modules/wordledaily/callback_test.go +++ b/internal/modules/wordledaily/callback_test.go @@ -10,6 +10,7 @@ import ( "github.com/go-telegram/bot/models" "github.com/tiennm99/tiennm99bot/internal/modules" + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" "github.com/tiennm99/tiennm99bot/internal/modules/util/subscription" "github.com/tiennm99/tiennm99bot/internal/storage" "github.com/tiennm99/tiennm99bot/internal/testutil" @@ -43,11 +44,15 @@ func TestModule_Registration(t *testing.T) { var names []string for _, c := range mod.Commands { names = append(names, c.Name) - if c.Visibility != modules.VisibilityPublic || c.Description == "" || c.Parameters != "" { + wantParams := "" + if c.Name == "wordle" { + wantParams = "[word]" + } + if c.Visibility != modules.VisibilityPublic || c.Description == "" || c.Parameters != wantParams { t.Errorf("command %+v is not a described public command", c) } } - if strings.Join(names, " ") != "wordledaily wordledaily_subscribe wordledaily_unsubscribe" { + if strings.Join(names, " ") != "wordle wordle_new wordle_giveup wordle_stats wordledaily wordledaily_subscribe wordledaily_unsubscribe" { t.Fatalf("commands = %v", names) } if len(mod.Games) != 1 || mod.Games[0].ShortName != "wordle" { @@ -57,7 +62,8 @@ func TestModule_Registration(t *testing.T) { t.Fatalf("disabled module exposes routes %v / crons %v", mod.HTTP, mod.Crons) } h := newHarness(t) - if mod := h.svc.module(); mod.HTTP[0].Pattern != "/games/wordledaily/" || mod.Crons[0].Name != "wordledaily_daily_push" || mod.Crons[0].Schedule != "0 0 * * *" { + if mod := h.svc.module(); mod.HTTP[0].Pattern != "/games/wordledaily/" || mod.Crons[0].Name != "wordledaily_daily_push" || mod.Crons[0].Schedule != "0 0 * * *" || + mod.Crons[1].Name != "wordle_unlimited_cards" || mod.Crons[1].Schedule != "35 20 * * *" { t.Fatalf("enabled module: %+v %+v", mod.HTTP, mod.Crons) } } @@ -91,7 +97,7 @@ func TestPlay_DisabledAnswersAlert(t *testing.T) { } } -func decodeURLToken(t *testing.T, h *harness, raw string) claims { +func decodeURLToken(t *testing.T, h *harness, raw string) guessgame.Claims { t.Helper() u, err := url.Parse(raw) if err != nil || !strings.HasPrefix(raw, testBase+routePrefix+"?t=") { @@ -117,7 +123,7 @@ func TestPlay_AnswersSignedURLForEveryCardForm(t *testing.T) { if c.UserID != 42 || c.ChatID != groupChat || c.MessageID != 9 || c.ThreadID != 77 || c.Name != "Test Nguyen" || c.InlineID != "" { t.Fatalf("topic claims = %+v", c) } - if c.Expiry != h.clock.now().Add(tokenTTL).Unix() { + if c.Expiry != h.clock.now().Add(guessgame.TokenTTL).Unix() { t.Fatalf("expiry = %d", c.Expiry) } @@ -146,7 +152,7 @@ func TestPlay_AnswersSignedURLForEveryCardForm(t *testing.T) { } func TestClaims_Valid(t *testing.T) { - for name, c := range map[string]claims{ + for name, c := range map[string]guessgame.Claims{ "no user": {ChatID: 1, MessageID: 1, Expiry: 1}, "no expiry": {UserID: 1, ChatID: 1, MessageID: 1}, "no address": {UserID: 1, Expiry: 1}, @@ -157,10 +163,10 @@ func TestClaims_Valid(t *testing.T) { t.Errorf("%s: valid", name) } } - if !(claims{UserID: 1, InlineID: "x", Expiry: 1}).Valid() || !(claims{UserID: 1, ChatID: -1, MessageID: 2, ThreadID: 3, Expiry: 1}).Valid() { + if !(guessgame.Claims{UserID: 1, InlineID: "x", Expiry: 1}).Valid() || !(guessgame.Claims{UserID: 1, ChatID: -1, MessageID: 2, ThreadID: 3, Expiry: 1}).Valid() { t.Fatal("good claims refused") } - if got := displayName(strings.Repeat("a", 70), "b"); len([]rune(got)) != maxNameRunes { + if got := guessgame.DisplayName(strings.Repeat("a", 70), "b"); len([]rune(got)) != guessgame.MaxNameRunes { t.Fatalf("name not truncated: %d", len(got)) } } @@ -264,7 +270,7 @@ func TestSubscribe_ReplyChainIsTheWholeChat(t *testing.T) { } playGroupDay(t, h, 0) h.nextDay() - if err := h.svc.runDailyPush(ctx, h.rb.Bot); err != nil { + if err := h.svc.RunDailyPush(ctx, h.rb.Bot); err != nil { t.Fatal(err) } sends := sentMethod(h.rb, "sendMessage") @@ -290,10 +296,10 @@ func TestSubscribe_DisabledGameRefuses(t *testing.T) { if got := rb.LastSent().Text(); got != msgDisabled { t.Fatalf("subscribe = %q", got) } - if subs, err := subscription.List(ctx, svc.subscribers); err != nil || len(subs) != 0 { + if subs, err := subscription.List(ctx, svc.Subscribers); err != nil || len(subs) != 0 { t.Fatalf("subscribers = %v, %v", subs, err) } - if _, err := subscription.Add(ctx, svc.subscribers, groupChat, 0); err != nil { + if _, err := subscription.Add(ctx, svc.Subscribers, groupChat, 0); err != nil { t.Fatal(err) } rb.Bot.ProcessUpdate(ctx, testutil.NewSupergroupMessage(groupChat, 1, "/wordledaily_unsubscribe")) diff --git a/internal/modules/wordledaily/command.go b/internal/modules/wordledaily/command.go index 22ab44c..6fca29e 100644 --- a/internal/modules/wordledaily/command.go +++ b/internal/modules/wordledaily/command.go @@ -2,116 +2,228 @@ package wordledaily import ( "context" + "errors" + "fmt" "strings" "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" "github.com/tiennm99/tiennm99bot/internal/modules/util/chathelper" - "github.com/tiennm99/tiennm99bot/internal/modules/util/subscription" + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" ) const ( msgChannel = "Wordle Daily can't be played in channels." msgSendGameFail = "Couldn't send Wordle Daily. Try again later." + + msgWordleChannel = "Wordle can't be played in channels." + msgWordleSendFail = "Couldn't send Wordle. Try again later." + msgNoStore = "Wordle isn't available on this server." + msgNoPlayer = "Cannot identify the player." + msgNotYourself = "Post as yourself to play: anonymous admins and posts on behalf of a chat have no round of their own." + msgNewHint = "🆕 New round started. Use `/wordle ` to guess." ) -// handleCommand (/wordledaily) sends the BotFather game in the same chat and -// topic. No reply markup: Telegram then adds the Play button itself, which -// is the button a game message requires first. -func (s *service) handleCommand(ctx context.Context, b *bot.Bot, update *models.Update) error { +// The /wordle commands play the caller's unlimited round, the one the +// /wordle card opens: each player has their own, in groups too. + +// player returns the caller, replying itself when the command cannot be +// played here. +func (s *service) player(ctx context.Context, b *bot.Bot, msg *models.Message) (int64, bool) { + switch { + case msg.Chat.Type == models.ChatTypeChannel: + _ = chathelper.Reply(ctx, b, msg, msgWordleChannel) + case s.rounds == nil: + _ = chathelper.Reply(ctx, b, msg, msgNoStore) + case msg.From == nil || msg.From.ID <= 0: + _ = chathelper.Reply(ctx, b, msg, msgNoPlayer) + case msg.From.IsBot || msg.SenderChat != nil: + // Telegram's stand-in senders (GroupAnonymousBot, Channel_Bot) share + // one id across every chat, so they would share one round. + _ = chathelper.Reply(ctx, b, msg, msgNotYourself) + default: + return msg.From.ID, true + } + return 0, false +} + +// handleWordle is /wordle [word]. Without a word it sends the unlimited +// card, or shows the round's board when the web game is not configured; +// with one it plays that guess on the caller's round. +func (s *service) handleWordle(ctx context.Context, b *bot.Bot, update *models.Update) error { msg := update.Message if msg == nil { return nil } - if msg.Chat.Type == models.ChatTypeChannel { - return chathelper.Reply(ctx, b, msg, msgChannel) + uid, ok := s.player(ctx, b, msg) + if !ok { + return nil + } + arg := chathelper.ArgAfterCommand(msg.Text) + if arg == "" { + if s.enabled() { + return s.sendCard(ctx, b, msg) + } + st, err := s.rounds.Load(ctx, uid, maxGuesses) + if err != nil { + return err + } + return chathelper.Reply(ctx, b, msg, boardHeader(st.Round)+"\n\n"+renderBoard(st.Round.Guesses)) + } + st, err := s.rounds.Guess(ctx, uid, 0, arg, maxGuesses) + switch { + case errors.Is(err, guessgame.ErrFinished): + return chathelper.Reply(ctx, b, msg, roundOver(st.Round)) + case errors.Is(err, errWordEmpty): + return chathelper.Reply(ctx, b, msg, "Please provide a 5-letter word.") + case errors.Is(err, errWordLength): + return chathelper.Reply(ctx, b, msg, "Word must be exactly 5 letters.") + case errors.Is(err, errWordUnknown): + return chathelper.Reply(ctx, b, msg, "Not in the word list.") + case err != nil: + return err + } + rd := st.Round + last := rd.Guesses[len(rd.Guesses)-1] + text := renderGuess(last.Word, last.Marks) + "\n\n" + switch rd.Status { + case guessgame.StatusWon: + text += fmt.Sprintf("🎉 Solved in %d/%d! Streak: %d. /wordle_new for another.", len(rd.Guesses), rd.MaxGuesses, st.Stats.CurStreak) + case guessgame.StatusLost: + text += fmt.Sprintf("❌ Out of guesses. Answer was %s. /wordle_new to retry.", strings.ToUpper(rd.Target)) + default: + text += fmt.Sprintf("Guess %d/%d.", len(rd.Guesses), rd.MaxGuesses) + } + return chathelper.Reply(ctx, b, msg, text) +} + +// handleNew is /wordle_new: it gives up the caller's round (a loss once it +// has a guess), starts a fresh one and sends its card. +func (s *service) handleNew(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + uid, ok := s.player(ctx, b, msg) + if !ok { + return nil + } + st, err := s.rounds.New(ctx, uid, 0, maxGuesses) + if err != nil { + return err + } + prelude := "" + if st.Abandoned != nil { + prelude = fmt.Sprintf("🏳️ Previous round abandoned (auto-giveup). Answer was %s.", strings.ToUpper(st.Abandoned.Target)) } if !s.enabled() { - return chathelper.Reply(ctx, b, msg, msgDisabled) + if prelude != "" { + prelude += "\n\n" + } + return chathelper.Reply(ctx, b, msg, prelude+msgNewHint) } - if _, err := b.SendGame(ctx, &bot.SendGameParams{ - ChatID: msg.Chat.ID, - MessageThreadID: topicOf(msg), - GameShorName: GameShortName, // the library's field name is misspelled - }); err != nil { - _ = chathelper.Reply(ctx, b, msg, msgSendGameFail) + if prelude != "" { + if err := chathelper.Reply(ctx, b, msg, prelude); err != nil { + return err + } + } + return s.sendCard(ctx, b, msg) +} + +// handleGiveup is /wordle_giveup: it ends the caller's round as a loss and +// reveals the answer. On a finished round it only echoes the answer. +func (s *service) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + uid, ok := s.player(ctx, b, msg) + if !ok { + return nil + } + st, err := s.rounds.GiveUp(ctx, uid) + switch { + case errors.Is(err, guessgame.ErrNoRound): + return chathelper.Reply(ctx, b, msg, "No active round. /wordle_new to start one.") + case err != nil: + return err + } + answer := strings.ToUpper(st.Round.Target) + switch { + case st.Ended: + return chathelper.Reply(ctx, b, msg, fmt.Sprintf("🏳️ Answer was %s. /wordle_new for another.", answer)) + case st.Round.Status == guessgame.StatusWon: + return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Already solved — %s.", answer)) + case st.Round.GaveUp: + return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Already gave up — %s.", answer)) + } + return chathelper.Reply(ctx, b, msg, roundOver(st.Round)) +} + +// handleStats is /wordle_stats: the caller's unlimited stats. +func (s *service) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + uid, ok := s.player(ctx, b, msg) + if !ok { + return nil + } + st, err := s.rounds.LoadStats(ctx, uid) + if err != nil { + return err + } + return chathelper.Reply(ctx, b, msg, fmt.Sprintf( + "📊 Wordle stats for %s\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d", + guessgame.DisplayName(msg.From.FirstName, msg.From.LastName), + st.Played, st.Wins, chathelper.WinRate(st.Wins, st.Played), st.CurStreak, st.MaxStreak)) +} + +// sendCard sends the recorded unlimited card into the command's chat. +func (s *service) sendCard(ctx context.Context, b *bot.Bot, msg *models.Message) error { + if err := s.cards.SendCard(ctx, b, msg, GameShortName); err != nil { + _ = chathelper.Reply(ctx, b, msg, msgWordleSendFail) return err } return nil } -// handleSubscribe (/wordledaily_subscribe) opts the chat topic into the -// 07:00 ICT push. Anyone in the chat may subscribe, like the other daily -// pushes. -func (s *service) handleSubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error { - msg := update.Message - if msg == nil { - return nil - } - if msg.Chat.Type == models.ChatTypeChannel { - return chathelper.Reply(ctx, b, msg, msgChannel) - } - // The push cron exists only when the game is enabled, so a - // subscription would never be served. - if !s.enabled() { - return chathelper.Reply(ctx, b, msg, msgDisabled) - } - thread := topicOf(msg) - s.subscribersMu.Lock() - defer s.subscribersMu.Unlock() - added, err := subscription.Add(ctx, s.subscribers, msg.Chat.ID, thread) - if err != nil { - return err - } - scope := scopeOf(thread) - if added { - return chathelper.Reply(ctx, b, msg, - "✅ Subscribed "+scope+" to Wordle Daily: a new puzzle and yesterday's group results every day at 07:00 ICT.\n"+ - "If you block the bot, you'll be auto-unsubscribed on the next push.") - } - return chathelper.Reply(ctx, b, msg, "Already subscribed in "+scope+".") +func roundOver(rd guessgame.Round) string { + return fmt.Sprintf("Current round is over. Use /wordle_new to start another. Answer was %s.", strings.ToUpper(rd.Target)) } -// handleUnsubscribe (/wordledaily_unsubscribe) opts the chat topic out. It -// works while the game is disabled too, so a chat can always leave. -func (s *service) handleUnsubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error { - msg := update.Message - if msg == nil { - return nil +// boardHeader is the status line above the in-chat board. +func boardHeader(rd guessgame.Round) string { + switch { + case rd.Status == guessgame.StatusWon: + return fmt.Sprintf("🎉 Solved in %d/%d. /wordle_new for another.", len(rd.Guesses), rd.MaxGuesses) + case rd.GaveUp: + return fmt.Sprintf("🏳️ Gave up. Answer was %s. /wordle_new for another.", strings.ToUpper(rd.Target)) + case rd.Status == guessgame.StatusLost: + return fmt.Sprintf("❌ Out of guesses. Answer was %s. /wordle_new to retry.", strings.ToUpper(rd.Target)) } - if s.subscribers == nil { - return chathelper.Reply(ctx, b, msg, msgDisabled) - } - thread := topicOf(msg) - s.subscribersMu.Lock() - defer s.subscribersMu.Unlock() - removed, err := subscription.Remove(ctx, s.subscribers, msg.Chat.ID, thread) - if err != nil { - return err - } - scope := scopeOf(thread) - if removed { - return chathelper.Reply(ctx, b, msg, "Unsubscribed "+scope+".") - } - return chathelper.Reply(ctx, b, msg, strings.ToUpper(scope[:1])+scope[1:]+" wasn't subscribed.") + return fmt.Sprintf("Guess %d/%d. Use `/wordle `.", len(rd.Guesses), rd.MaxGuesses) } -// topicOf is the message's forum topic, or 0 for the whole chat. Telegram -// also gives a reply chain in an ordinary supergroup a thread id; -// IsTopicMessage marks a real topic. Plays key the group results the same -// way (playClaims), so a subscription made by replying still gets its -// chat's recap and streak. -func topicOf(msg *models.Message) int { - if !msg.IsTopicMessage { - return 0 - } - return msg.MessageThreadID +// renderGuess formats one guess as the NYT share pattern: the word on one +// line, its emoji row below. +// +// CRANE +// 🟩🟨⬜🟩🟩 +func renderGuess(word, marks string) string { + return strings.ToUpper(word) + "\n" + emojiRow(marks) } -func scopeOf(thread int) string { - if thread != 0 { - return "this topic" +// renderBoard joins the round's guesses, blank-line separated. +func renderBoard(guesses []guessgame.Guess) string { + if len(guesses) == 0 { + return "No guesses yet. Reply with `/wordle `." } - return "this chat" + rows := make([]string, len(guesses)) + for i, g := range guesses { + rows[i] = renderGuess(g.Word, g.Marks) + } + return strings.Join(rows, "\n\n") } diff --git a/internal/modules/wordledaily/helpers_test.go b/internal/modules/wordledaily/helpers_test.go index 2291579..523a07e 100644 --- a/internal/modules/wordledaily/helpers_test.go +++ b/internal/modules/wordledaily/helpers_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" "github.com/tiennm99/tiennm99bot/internal/storage" @@ -20,7 +21,7 @@ var ( testRoot = []byte("0123456789abcdef0123456789abcdef") testAnswers = wordlist.Answers() _, testDict = wordlist.Load() - testStart = epoch.Add(12 * time.Hour) // 19:00 ICT on puzzle #1's day + testStart = guessgame.Epoch.Add(12 * time.Hour) // 19:00 ICT on puzzle #1's day testBase = "https://game.example" editStub = `{"message_id":1,"date":0,"chat":{"id":-100,"type":"supergroup"}}` groupChat int64 = -100 @@ -128,7 +129,7 @@ type harness struct { handler http.Handler } -func testConfig(clock *fakeClock, coll storage.Collection, rep scoreReporter, api telegramAPI, sched *fakeScheduler) config { +func testConfig(clock *fakeClock, coll storage.Collection, rep guessgame.ScoreReporter, api guessgame.TelegramAPI, sched *fakeScheduler) config { return config{ baseURL: testBase, rootKey: testRoot, @@ -140,9 +141,19 @@ func testConfig(clock *fakeClock, coll storage.Collection, rep scoreReporter, ap async: func(f func()) { f() }, answers: testAnswers, dict: testDict, + pick: testPick, } } +// testPick makes unlimited rounds deterministic: crane, then slate, then +// crane again. +func testPick(prev string) string { + if prev == "crane" { + return "slate" + } + return "crane" +} + func newHarness(t *testing.T) *harness { t.Helper() h := &harness{ @@ -156,7 +167,7 @@ func newHarness(t *testing.T) *harness { h.rb.StubMethod("editMessageText", editStub) h.svc = newService(testConfig(h.clock, h.coll, h.rep, h.rb.Bot, h.sched)) mod := h.svc.module() - if len(mod.HTTP) != 1 || len(mod.Crons) != 1 { + if len(mod.HTTP) != 1 || len(mod.Crons) != 2 { t.Fatalf("enabled module: routes %d crons %d", len(mod.HTTP), len(mod.Crons)) } h.handler = mod.HTTP[0].Handler @@ -172,7 +183,7 @@ func (h *harness) restart() { // tokenFor signs a token for user on card msg in chat (thread is its topic). func (h *harness) tokenFor(user, chat int64, msg, thread int, name string) string { h.t.Helper() - tok, err := h.svc.signToken(claims{UserID: user, Name: name, ChatID: chat, MessageID: msg, ThreadID: thread, Expiry: h.clock.now().Add(tokenTTL).Unix()}) + tok, err := h.svc.signToken(guessgame.Claims{UserID: user, Name: name, ChatID: chat, MessageID: msg, ThreadID: thread, Expiry: h.clock.now().Add(guessgame.TokenTTL).Unix()}) if err != nil { h.t.Fatal(err) } @@ -184,7 +195,7 @@ func (h *harness) dmToken(user int64) string { return h.tokenFor(user, user, 5, func (h *harness) answer() string { h.t.Helper() - a, err := h.svc.resolvePuzzle(context.Background(), h.svc.puzzleNum(h.clock.now())) + a, err := h.svc.ResolvePuzzle(context.Background(), h.svc.PuzzleNum(h.clock.now())) if err != nil { h.t.Fatal(err) } diff --git a/internal/modules/wordledaily/http_api.go b/internal/modules/wordledaily/http_api.go index 077433f..72c0c56 100644 --- a/internal/modules/wordledaily/http_api.go +++ b/internal/modules/wordledaily/http_api.go @@ -3,17 +3,17 @@ package wordledaily import ( "errors" "net/http" - "strconv" "strings" "time" "github.com/tiennm99/tiennm99bot/internal/log" + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" "github.com/tiennm99/tiennm99bot/internal/modules/wordledaily/web" ) -// requestsPerMinute caps one player's API calls, both endpoints together. +// requestsPerMinute caps one player's API calls, every endpoint together. const requestsPerMinute = 60 // apiError is the JSON error body every API failure returns. @@ -24,15 +24,24 @@ type apiError struct { } var ( - errBadRequestAPI = apiError{http.StatusBadRequest, "bad_request", "Bad request."} - errBadTokenAPI = apiError{http.StatusUnauthorized, "bad_token", "This game link is not valid. Open the game again from the chat."} - errExpiredAPI = apiError{http.StatusUnauthorized, "expired", "This game link has expired. Open the game again from the chat."} - errNewPuzzleAPI = apiError{http.StatusConflict, "new_puzzle", "A new puzzle has started."} - errLengthAPI = apiError{http.StatusUnprocessableEntity, "length", "Word must be exactly 5 letters."} - errUnknownAPI = apiError{http.StatusUnprocessableEntity, "unknown", "Not in the word list."} - errFinishedAPI = apiError{http.StatusUnprocessableEntity, "finished", "You have already finished today's puzzle."} - errTooFastAPI = apiError{http.StatusTooManyRequests, "rate_limited", "Too many requests. Wait a moment and try again."} - errInternalAPI = apiError{http.StatusInternalServerError, "internal", "Something went wrong. Try again later."} + errBadRequestAPI = apiError{http.StatusBadRequest, "bad_request", "Bad request."} + errBadTokenAPI = apiError{http.StatusUnauthorized, "bad_token", "This game link is not valid. Open the game again from the chat."} + errExpiredAPI = apiError{http.StatusUnauthorized, "expired", "This game link has expired. Open the game again from the chat."} + errBadModeAPI = apiError{http.StatusForbidden, "bad_mode", "This card plays the daily puzzle. Send /wordle for unlimited rounds."} + errNewPuzzleAPI = apiError{http.StatusConflict, "new_puzzle", "A new puzzle has started."} + errNewRoundAPI = apiError{http.StatusConflict, "new_round", "A new round has started."} + errLengthAPI = apiError{http.StatusUnprocessableEntity, "length", "Word must be exactly 5 letters."} + errUnknownAPI = apiError{http.StatusUnprocessableEntity, "unknown", "Not in the word list."} + errFinishedAPI = apiError{http.StatusUnprocessableEntity, "finished", "You have already finished today's puzzle."} + errRoundFinishedAPI = apiError{http.StatusUnprocessableEntity, "finished", "This round is over. Start a new game."} + errTooFastAPI = apiError{http.StatusTooManyRequests, "rate_limited", "Too many requests. Wait a moment and try again."} + errInternalAPI = apiError{http.StatusInternalServerError, "internal", "Something went wrong. Try again later."} +) + +// Page modes, as the view reports them. +const ( + modeDaily = "daily" + modeUnlimited = "unlimited" ) // guessView is one row of the board: the word and its c/p/w marks. @@ -41,7 +50,8 @@ type guessView struct { Marks string `json:"marks"` } -// statsView is the player's record, shown once today's game is over. +// statsView is the player's record in the card's mode, shown once the game +// is over. type statsView struct { Played int `json:"played"` WinPct int `json:"win_pct"` @@ -51,31 +61,48 @@ type statsView struct { } // view is the state the page renders. The answer, the stats and the share -// text are present only once the game is over. +// text are present only once the game is over. A daily view carries the +// puzzle's number, date and the next puzzle's start; an unlimited one the +// round's number. type view struct { - Num int `json:"num"` - Date string `json:"date"` + Mode string `json:"mode"` + Num int `json:"num,omitempty"` + Date string `json:"date,omitempty"` + Seq int `json:"seq,omitempty"` Player string `json:"player"` Max int `json:"max"` Len int `json:"len"` Guesses []guessView `json:"guesses"` Status string `json:"status"` + GaveUp bool `json:"gave_up,omitempty"` Answer string `json:"answer,omitempty"` Stats *statsView `json:"stats,omitempty"` - NextAt int64 `json:"next_at"` + NextAt int64 `json:"next_at,omitempty"` Share string `json:"share,omitempty"` + // Abandoned is the answer of the unlimited round a New game gave up as + // a loss, so the page can show it before the next round. + Abandoned string `json:"abandoned,omitempty"` } type stateRequest struct { Token string `json:"token"` } +// guessRequest is a guess on the board the page shows: Num names a daily +// puzzle, Seq an unlimited round. type guessRequest struct { Token string `json:"token"` Num int `json:"num"` + Seq int `json:"seq"` Word string `json:"word"` } +// newRequest starts the next unlimited round after round Seq. +type newRequest struct { + Token string `json:"token"` + Seq int `json:"seq"` +} + // handler serves the page, its assets and the JSON API under routePrefix. func (s *service) handler() http.Handler { mux := http.NewServeMux() @@ -84,6 +111,7 @@ func (s *service) handler() http.Handler { mux.HandleFunc("GET "+routePrefix+"app.css", htmlgame.ServeAsset(web.FS, "app.css")) mux.HandleFunc("POST "+routePrefix+"api/state", s.apiState) mux.HandleFunc("POST "+routePrefix+"api/guess", s.apiGuess) + mux.HandleFunc("POST "+routePrefix+"api/new", s.apiNew) return htmlgame.SecurityHeaders(routePrefix, mux) } @@ -91,19 +119,19 @@ func writeError(w http.ResponseWriter, e apiError) { htmlgame.WriteJSON(w, e.sta // authorize verifies the token and the player's request budget, writing // the error itself when either fails. -func (s *service) authorize(w http.ResponseWriter, token string, now time.Time) (claims, bool) { +func (s *service) authorize(w http.ResponseWriter, token string, now time.Time) (guessgame.Claims, bool) { c, err := s.verifyToken(token, now) switch { case errors.Is(err, htmlgame.ErrTokenExpired): writeError(w, errExpiredAPI) - return claims{}, false + return guessgame.Claims{}, false case err != nil: writeError(w, errBadTokenAPI) - return claims{}, false + return guessgame.Claims{}, false } if !s.limiter.Allow(c.UserID, now) { writeError(w, errTooFastAPI) - return claims{}, false + return guessgame.Claims{}, false } return c, true } @@ -118,7 +146,12 @@ func (s *service) apiState(w http.ResponseWriter, r *http.Request) { if !ok { return } - o, err := s.loadState(r.Context(), c) + if c.Unlimited() { + st, err := s.rounds.Load(r.Context(), c.UserID, maxGuesses) + s.writeRound(w, c, st, err) + return + } + o, err := s.LoadState(r.Context(), c) if err != nil { log.Error("wordledaily state failed", "err", err) writeError(w, errInternalAPI) @@ -137,16 +170,18 @@ func (s *service) apiGuess(w http.ResponseWriter, r *http.Request) { if !ok { return } - o, err := s.submitGuess(r.Context(), c, req.Num, req.Word) + if c.Unlimited() { + st, err := s.rounds.Guess(r.Context(), c.UserID, req.Seq, req.Word, maxGuesses) + s.writeRound(w, c, st, err) + return + } + o, err := s.SubmitGuess(r.Context(), c, req.Num, req.Word) switch { - case errors.Is(err, errNewPuzzle): + case errors.Is(err, guessgame.ErrNewPuzzle): writeError(w, errNewPuzzleAPI) - case errors.Is(err, errWordLength): - writeError(w, errLengthAPI) - case errors.Is(err, errWordUnknown): - writeError(w, errUnknownAPI) - case errors.Is(err, errFinished): + case errors.Is(err, guessgame.ErrFinished): writeError(w, errFinishedAPI) + case wordError(w, err): case err != nil: log.Error("wordledaily guess failed", "err", err) writeError(w, errInternalAPI) @@ -155,45 +190,121 @@ func (s *service) apiGuess(w http.ResponseWriter, r *http.Request) { } } -// view renders a game for the page. The answer leaves the server only once -// the game is over. -func (s *service) view(c claims, o outcome) view { +// apiNew starts the player's next unlimited round, giving up the current +// one. A stale seq answers with the current round, so a double click +// starts one round. +func (s *service) apiNew(w http.ResponseWriter, r *http.Request) { + var req newRequest + if htmlgame.DecodeJSON(w, r, &req) != nil || req.Seq < 1 { + writeError(w, errBadRequestAPI) + return + } + c, ok := s.authorize(w, req.Token, s.cfg.now()) + if !ok { + return + } + if !c.Unlimited() { + writeError(w, errBadModeAPI) + return + } + st, err := s.rounds.New(r.Context(), c.UserID, req.Seq, maxGuesses) + s.writeRound(w, c, st, err) +} + +// wordError writes the API error of a refused word and reports whether err +// was one. +func wordError(w http.ResponseWriter, err error) bool { + switch { + case errors.Is(err, errWordEmpty), errors.Is(err, errWordLength): + writeError(w, errLengthAPI) + case errors.Is(err, errWordUnknown): + writeError(w, errUnknownAPI) + default: + return false + } + return true +} + +// writeRound answers an unlimited request. +func (s *service) writeRound(w http.ResponseWriter, c guessgame.Claims, st guessgame.RoundState, err error) { + switch { + case errors.Is(err, guessgame.ErrNewRound): + writeError(w, errNewRoundAPI) + case errors.Is(err, guessgame.ErrFinished): + writeError(w, errRoundFinishedAPI) + case wordError(w, err): + case err != nil: + log.Error("wordledaily unlimited request failed", "err", err) + writeError(w, errInternalAPI) + default: + htmlgame.WriteJSON(w, http.StatusOK, roundView(c, st)) + } +} + +// view renders a daily game for the page. The answer leaves the server +// only once the game is over. +func (s *service) view(c guessgame.Claims, o guessgame.Outcome) view { v := view{ - Num: o.num, - Date: s.puzzleDate(o.num), + Mode: modeDaily, + Num: o.Num, + Date: s.PuzzleDate(o.Num), Player: c.Name, Max: maxGuesses, Len: wordlist.WordLength, - Guesses: make([]guessView, len(o.p.Guesses)), - Status: o.p.Status, - NextAt: s.puzzleStart(o.num + 1).Unix(), + Guesses: guessViews(o.P.Guesses), + Status: o.P.Status, + NextAt: s.PuzzleStart(o.Num + 1).Unix(), } if v.Status == "" { - v.Status = statusPlaying + v.Status = guessgame.StatusPlaying } - for i, g := range o.p.Guesses { - v.Guesses[i] = guessView{Word: strings.ToUpper(g.Word), Marks: g.Marks} - } - if !o.p.finished() { + if !o.P.Finished() { return v } - v.Answer = strings.ToUpper(o.answer) - st := o.stats - pct := 0 - if st.Played > 0 { - pct = (st.Wins*100 + st.Played/2) / st.Played - } - v.Stats = &statsView{Played: st.Played, WinPct: pct, Cur: displayStreak(st, o.num), Max: st.MaxStreak, Dist: st.Dist} - v.Share = shareText(o.p) + v.Answer = strings.ToUpper(o.Answer) + st := o.Stats + v.Stats = statsOf(st.Played, st.Wins, guessgame.DisplayStreak(st, o.Num), st.MaxStreak, st.Dist) + v.Share = s.ShareText(o.P) return v } -// shareText is the NYT-style spoiler-free result. -func shareText(p progress) string { - var b strings.Builder - b.WriteString("Wordle Daily #" + strconv.Itoa(p.Num) + " " + resultLine(p) + "\n") - for _, g := range p.Guesses { - b.WriteString("\n" + emojiRow(g.Marks)) +// roundView renders an unlimited round for the page. The answer leaves the +// server only once the round is over. +func roundView(c guessgame.Claims, st guessgame.RoundState) view { + rd := st.Round + v := view{ + Mode: modeUnlimited, + Seq: rd.Seq, + Player: c.Name, + Max: rd.MaxGuesses, + Len: wordlist.WordLength, + Guesses: guessViews(rd.Guesses), + Status: rd.Status, + GaveUp: rd.GaveUp, } - return b.String() + if st.Abandoned != nil { + v.Abandoned = strings.ToUpper(st.Abandoned.Target) + } + if !rd.Finished() { + return v + } + v.Answer = strings.ToUpper(rd.Target) + v.Stats = statsOf(st.Stats.Played, st.Stats.Wins, st.Stats.CurStreak, st.Stats.MaxStreak, st.Stats.Dist) + return v +} + +func guessViews(gs []guessgame.Guess) []guessView { + out := make([]guessView, len(gs)) + for i, g := range gs { + out[i] = guessView{Word: strings.ToUpper(g.Word), Marks: g.Marks} + } + return out +} + +func statsOf(played, wins, cur, maxStreak int, dist []int) *statsView { + pct := 0 + if played > 0 { + pct = (wins*100 + played/2) / played + } + return &statsView{Played: played, WinPct: pct, Cur: cur, Max: maxStreak, Dist: dist} } diff --git a/internal/modules/wordledaily/migrate.go b/internal/modules/wordledaily/migrate.go new file mode 100644 index 0000000..717d2f2 --- /dev/null +++ b/internal/modules/wordledaily/migrate.go @@ -0,0 +1,142 @@ +package wordledaily + +import ( + "context" + "fmt" + + "github.com/tiennm99/tiennm99bot/internal/log" + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" + "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" + "github.com/tiennm99/tiennm99bot/internal/storage" +) + +// legacyMigrationKey marks, in the system collection, that the in-chat +// wordle module's players were carried into unlimited mode. +const legacyMigrationKey = "wordledaily:legacy-wordle-unlimited" + +// legacyGame and legacyStats are the documents the retired in-chat wordle +// module kept in its "wordle" collection, under "game:" and +// "stats:". A subject is a user id in a private chat and a group's +// chat id (negative) in a group, where the whole group shared one round. +type legacyGame struct { + Target string `json:"target" bson:"target"` + Guesses []legacyGuess `json:"guesses" bson:"guesses"` + Solved bool `json:"solved" bson:"solved"` + Giveup bool `json:"giveup" bson:"giveup"` + StartedAt int64 `json:"startedAt" bson:"startedAt"` // unix ms +} + +type legacyGuess struct { + Word string `json:"word" bson:"word"` + Results []wordlist.LetterScore `json:"results" bson:"results"` +} + +type legacyStats struct { + Played int `json:"played" bson:"played"` + Wins int `json:"wins" bson:"wins"` + Streak int `json:"streak" bson:"streak"` + BestStreak int `json:"bestStreak" bson:"bestStreak"` + LastResultAt *int64 `json:"lastResultAt" bson:"lastResultAt"` +} + +// InitStore carries the in-chat wordle module's players into unlimited +// mode, once: each player's stats become their unlimited stats and an +// unfinished round becomes their current round. Group rounds and group +// stats belong to no single player and are left alone. Legacy documents +// are never changed or deleted and a target that already exists is never +// overwritten, so a rerun after a failed boot changes nothing that was +// carried already; a completed marker in the system collection makes later +// boots a no-op. +func InitStore(ctx context.Context, legacyColl, coll, systemColl storage.Collection) error { + return guessgame.RunOnce(ctx, systemColl, legacyMigrationKey, func() (int, error) { + stats, err := migrateLegacyStats(ctx, legacyColl, coll) + if err != nil { + return 0, err + } + rounds, err := migrateLegacyRounds(ctx, legacyColl, coll) + if err != nil { + return 0, err + } + log.Info("wordledaily migrated legacy wordle players", "stats", stats, "rounds", rounds) + return stats + rounds, nil + }) +} + +func migrateLegacyStats(ctx context.Context, legacyColl, coll storage.Collection) (int, error) { + from := storage.Typed[legacyStats](legacyColl) + to := storage.Typed[guessgame.RoundStats](coll) + keys, err := from.List(ctx, "stats:") + if err != nil { + return 0, fmt.Errorf("wordledaily migration list stats: %w", err) + } + n := 0 + for _, key := range keys { + uid, ok := guessgame.LegacyUserID(key, "stats:") + if !ok { + continue + } + old, _, err := from.Get(ctx, key) + if err != nil { + return n, fmt.Errorf("wordledaily migration get %s: %w", key, err) + } + st := guessgame.RoundStats{ + Played: old.Played, + Wins: old.Wins, + CurStreak: old.Streak, + MaxStreak: old.BestStreak, + Dist: make([]int, maxGuesses), + } + if old.LastResultAt != nil { + st.LastAt = *old.LastResultAt + } + added, err := guessgame.PutIfMissing(ctx, to, guessgame.RoundStatsKey(uid), st) + if err != nil { + return n, fmt.Errorf("wordledaily migration put stats %d: %w", uid, err) + } + if added { + n++ + } + } + return n, nil +} + +func migrateLegacyRounds(ctx context.Context, legacyColl, coll storage.Collection) (int, error) { + from := storage.Typed[legacyGame](legacyColl) + to := storage.Typed[guessgame.Round](coll) + keys, err := from.List(ctx, "game:") + if err != nil { + return 0, fmt.Errorf("wordledaily migration list games: %w", err) + } + n := 0 + for _, key := range keys { + uid, ok := guessgame.LegacyUserID(key, "game:") + if !ok { + continue + } + old, _, err := from.Get(ctx, key) + if err != nil { + return n, fmt.Errorf("wordledaily migration get %s: %w", key, err) + } + // A finished round already counted in the stats; only one still + // being played carries over. + if old.Solved || old.Giveup || len(old.Guesses) >= maxGuesses || old.Target == "" { + continue + } + rd := guessgame.Round{Seq: 1, Target: old.Target, Guesses: []guessgame.Guess{}, Status: guessgame.StatusPlaying, MaxGuesses: maxGuesses} + if len(old.Guesses) > 0 { + // A round's clock starts at its first guess. + rd.StartedAt = old.StartedAt + } + for _, g := range old.Guesses { + rd.Guesses = append(rd.Guesses, guessgame.Guess{Word: g.Word, Marks: marksOf(g.Results)}) + } + added, err := guessgame.PutIfMissing(ctx, to, guessgame.RoundKey(uid), rd) + if err != nil { + return n, fmt.Errorf("wordledaily migration put round %d: %w", uid, err) + } + if added { + n++ + } + } + return n, nil +} diff --git a/internal/modules/wordledaily/migrate_mongo_test.go b/internal/modules/wordledaily/migrate_mongo_test.go new file mode 100644 index 0000000..d9ed5f4 --- /dev/null +++ b/internal/modules/wordledaily/migrate_mongo_test.go @@ -0,0 +1,38 @@ +package wordledaily + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/tiennm99/tiennm99bot/internal/storage" + "github.com/tiennm99/tiennm99bot/internal/testutil/mongotest" +) + +var mongoTests mongotest.Manager + +func TestMain(m *testing.M) { + os.Exit(mongoTests.Run(m)) +} + +// The migration runs against MongoDB in production, where documents go +// through BSON rather than JSON. +func TestInitStore_MongoCarriesLegacyWordlePlayersOnce(t *testing.T) { + uri := mongoTests.URI(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + client, err := storage.NewMongoClient(ctx, uri) + if err != nil { + t.Fatalf("NewMongoClient: %v", err) + } + db := client.Database(fmt.Sprintf("tiennm99bot_wordledaily_test_%d", time.Now().UnixNano())) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = db.Drop(ctx) + _ = client.Disconnect(ctx) + }) + assertLegacyMigration(t, storage.NewMongoProvider(db)) +} diff --git a/internal/modules/wordledaily/play.go b/internal/modules/wordledaily/play.go deleted file mode 100644 index cb80ce1..0000000 --- a/internal/modules/wordledaily/play.go +++ /dev/null @@ -1,369 +0,0 @@ -package wordledaily - -import ( - "context" - "errors" - "slices" - "strconv" - "strings" - "time" - - "github.com/tiennm99/tiennm99bot/internal/log" - "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" - "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" -) - -const reportTimeout = 10 * time.Second - -// Rejections of a guess, mapped to API errors in http_api.go. -var ( - errNewPuzzle = errors.New("wordledaily: the puzzle changed") - errFinished = errors.New("wordledaily: today's puzzle is finished") - errWordLength = errors.New("wordledaily: word must be 5 letters") - errWordUnknown = errors.New("wordledaily: not in the word list") -) - -// outcome is a player's game after a request, with what the request changed. -type outcome struct { - num int - answer string - p progress - stats userStats // set once the game is over - guessed bool // this request added a guess - ended bool // this request finished the game - joined bool // this request added the card's chat to the player's chats - report bool // this request must report the win on the card -} - -// cardKey names the card a token was issued for, so a win is reported on -// each card once. -func cardKey(c claims) string { - if c.InlineID != "" { - return "i:" + c.InlineID - } - return "c:" + strconv.FormatInt(c.ChatID, 10) + ":" + strconv.Itoa(c.MessageID) -} - -func userLockKey(userID int64) string { return "u:" + strconv.FormatInt(userID, 10) } - -// marksOf encodes scored letters as c/p/w, the stored and served form. -func marksOf(scores []wordlist.LetterScore) string { - var b strings.Builder - for _, sc := range scores { - switch sc.Result { - case wordlist.ResultCorrect: - b.WriteByte('c') - case wordlist.ResultPartial: - b.WriteByte('p') - default: - b.WriteByte('w') - } - } - return b.String() -} - -// emojiRow renders stored marks as the spoiler-free share row. -func emojiRow(marks string) string { - var b strings.Builder - for i := 0; i < len(marks); i++ { - switch marks[i] { - case 'c': - b.WriteString(wordlist.Marker(wordlist.ResultCorrect)) - case 'p': - b.WriteString(wordlist.Marker(wordlist.ResultPartial)) - default: - b.WriteString(wordlist.Marker(wordlist.ResultWrong)) - } - } - return b.String() -} - -// score is what a win reports with setGameScore: fewer guesses rank higher. -func score(guesses int) int { return maxGuesses + 1 - guesses } - -// submitGuess plays word on today's puzzle for the token's player. num is -// the puzzle the page shows; a stale one is refused so a board never mixes -// two days. -func (s *service) submitGuess(ctx context.Context, c claims, num int, word string) (outcome, error) { - now := s.cfg.now() - today := s.puzzleNum(now) - if num != today { - return outcome{}, errNewPuzzle - } - answer, err := s.resolvePuzzle(ctx, today) - if err != nil { - return outcome{}, err - } - o, err := s.guessLocked(ctx, c, today, answer, word, now) - if err != nil { - if o.ended { - // The refused guess repaired a finished board's stats; its - // group bookkeeping still has to run. - s.afterPlay(c, o) - } - return o, err - } - s.afterPlay(c, o) - return o, nil -} - -func (s *service) guessLocked(ctx context.Context, c claims, num int, answer, word string, now time.Time) (outcome, error) { - defer s.locks.Acquire(userLockKey(c.UserID))() - p, err := s.loadProgress(ctx, num, c.UserID) - if err != nil { - return outcome{}, err - } - if p.finished() { - // The board is the record: a finish whose stats write failed is - // folded into the stats now, before the guess is refused. - o := outcome{num: num, answer: answer, p: p} - if o.stats, o.ended, err = s.finishStats(ctx, &p); err != nil { - return outcome{}, err - } - return o, errFinished - } - v := wordlist.Validate(s.cfg.dict, word) - switch { - case v.Reason == wordlist.ReasonUnknown: - return outcome{}, errWordUnknown - case !v.OK: - return outcome{}, errWordLength - } - o := outcome{num: num, answer: answer, guessed: true} - p.Name = c.Name - p.Guesses = append(p.Guesses, guess{Word: v.Word, Marks: marksOf(wordlist.Compare(v.Word, answer))}) - o.joined = s.joinLocally(&p, c) - if v.Word == answer || len(p.Guesses) >= maxGuesses { - p.Status, p.FinishedAt, o.ended = statusLost, now.UnixMilli(), true - if v.Word == answer { - p.Status = statusWon - o.report = needsReport(p, c) - } - } - if err := s.plays.Put(ctx, playKey(num, c.UserID), p); err != nil { - return outcome{}, err - } - if o.ended { - // The board is saved first and is the record. If this stats write - // fails, the next state load or guess folds the finished board in; - // the LastNum guard keeps it from counting twice. - if o.stats, _, err = s.finishStats(ctx, &p); err != nil { - return outcome{}, err - } - } - o.p = p - return o, nil -} - -// loadState returns today's game for the token's player. Opening a card -// also puts a player who already guessed into that card's group results, -// and reports a win on a card it was not reported on yet. -func (s *service) loadState(ctx context.Context, c claims) (outcome, error) { - today := s.puzzleNum(s.cfg.now()) - answer, err := s.resolvePuzzle(ctx, today) - if err != nil { - return outcome{}, err - } - o, err := s.stateLocked(ctx, c, today, answer) - if err != nil { - return o, err - } - s.afterPlay(c, o) - return o, nil -} - -func (s *service) stateLocked(ctx context.Context, c claims, num int, answer string) (outcome, error) { - defer s.locks.Acquire(userLockKey(c.UserID))() - p, err := s.loadProgress(ctx, num, c.UserID) - if err != nil { - return outcome{}, err - } - o := outcome{num: num, answer: answer} - if len(p.Guesses) > 0 { - o.joined = s.joinLocally(&p, c) - } - if p.Status == statusWon { - o.report = needsReport(p, c) - } - if o.joined { - if c.Name != "" { - p.Name = c.Name - } - if err := s.plays.Put(ctx, playKey(num, c.UserID), p); err != nil { - return outcome{}, err - } - } - if p.finished() { - // Folds in a finished board whose stats write failed; a no-op - // otherwise. - if o.stats, o.ended, err = s.finishStats(ctx, &p); err != nil { - return outcome{}, err - } - } - o.p = p - return o, nil -} - -// joinLocally adds the card's group topic to the player's chats. Only group -// chats (negative IDs) have a results message; a private chat or an inline -// card has none. -func (s *service) joinLocally(p *progress, c claims) bool { - if c.InlineID != "" || c.ChatID >= 0 || p.joinedChat(c.ChatID, c.ThreadID) || len(p.Chats) >= maxChats { - return false - } - p.Chats = append(p.Chats, chatRef{ChatID: c.ChatID, ThreadID: c.ThreadID, CardID: c.MessageID}) - return true -} - -// needsReport reports whether the win still has to be reported on the -// token's card. A card is recorded in Reported only once Telegram accepted -// the score, so a failed report is retried on the next state load. -func needsReport(p progress, c claims) bool { - return !slices.Contains(p.Reported, cardKey(c)) && len(p.Reported) < maxReported -} - -// recordReported notes that the win of puzzle num is on the token's card. -func (s *service) recordReported(ctx context.Context, c claims, num int) error { - defer s.locks.Acquire(userLockKey(c.UserID))() - p, err := s.loadProgress(ctx, num, c.UserID) - if err != nil || p.Status != statusWon || !needsReport(p, c) { - return err - } - p.Reported = append(p.Reported, cardKey(c)) - return s.plays.Put(ctx, playKey(num, c.UserID), p) -} - -// finishStats folds the finished game into the player's stats, once per -// puzzle, and reports whether this call folded it. Caller holds the -// player's lock. -func (s *service) finishStats(ctx context.Context, p *progress) (userStats, bool, error) { - st, err := s.loadStats(ctx, p.UserID) - if err != nil { - return userStats{}, false, err - } - if st.LastNum >= p.Num { - return st, false, nil - } - st.Played++ - if p.Status == statusWon { - st.Wins++ - st.Dist[len(p.Guesses)-1]++ - if st.LastWinNum == p.Num-1 { - st.CurStreak++ - } else { - st.CurStreak = 1 - } - st.MaxStreak = max(st.MaxStreak, st.CurStreak) - st.LastWinNum = p.Num - } else { - st.CurStreak = 0 - } - st.LastNum = p.Num - if err := s.stats.Put(ctx, statsKey(p.UserID), st); err != nil { - return userStats{}, false, err - } - return st, true, nil -} - -// afterPlay runs a request's side effects once the player's lock is -// released: the group results bookkeeping, the live summary and the score -// report. Failures are logged; the player's game is already saved. -func (s *service) afterPlay(c claims, o outcome) { - ctx, cancel := context.WithTimeout(context.Background(), reportTimeout) - defer cancel() - if o.joined { - ref := o.p.Chats[len(o.p.Chats)-1] - if err := s.addChatPlayer(ctx, o.num, ref, c.UserID, o.p.Name); err != nil { - log.Warn("wordledaily chat join failed", "err", err) - } - if o.p.Status == statusWon && !o.ended { - s.bumpStreak(ctx, o.num, ref) - } - } - if o.ended && o.p.Status == statusWon { - for _, ref := range o.p.Chats { - s.bumpStreak(ctx, o.num, ref) - } - } - if o.guessed || o.joined || o.ended { - for _, ref := range o.p.Chats { - s.touchSummary(o.num, ref.ChatID, ref.ThreadID) - } - } - if o.report { - s.reportWin(c, o.num, len(o.p.Guesses)) - } -} - -// reportWin sets the score on the card in the background, then records the -// card as reported. BOT_SCORE_NOT_MODIFIED (the score does not beat the -// player's best there) counts as reported; any other failure leaves the -// card unrecorded, so the next state load from it tries again. -func (s *service) reportWin(c claims, num, guesses int) { - addr := htmlgame.Address{ChatID: c.ChatID, MessageID: c.MessageID, InlineID: c.InlineID} - s.async(func() { - ctx, cancel := context.WithTimeout(context.Background(), reportTimeout) - defer cancel() - err := s.cfg.reporter.Report(ctx, addr, c.UserID, score(guesses)) - if err != nil && !htmlgame.ScoreNotModified(err) { - log.Warn("wordledaily score report failed", "err", err) - return - } - if err := s.recordReported(ctx, c, num); err != nil { - log.Warn("wordledaily score report record failed", "err", err) - } - }) -} - -func (s *service) async(f func()) { - if s.cfg.async != nil { - s.cfg.async(f) - return - } - go f() -} - -// addChatPlayer lists the player in the chat topic's day. -func (s *service) addChatPlayer(ctx context.Context, num int, ref chatRef, userID int64, name string) error { - return updateVersioned(ctx, s.chatDays, chatDayKey(num, ref.ChatID, ref.ThreadID), func(cd *chatDay, found bool) bool { - changed := !found - if !found { - *cd = chatDay{Num: num, ChatID: ref.ChatID, ThreadID: ref.ThreadID, Players: []chatPlayer{}} - } - if cd.CardID == 0 && ref.CardID != 0 { - cd.CardID, changed = ref.CardID, true - } - if !slices.ContainsFunc(cd.Players, func(p chatPlayer) bool { return p.UserID == userID }) && len(cd.Players) < maxChatPlayers { - cd.Players, changed = append(cd.Players, chatPlayer{UserID: userID, Name: name}), true - } - return changed - }) -} - -// bumpStreak counts puzzle num toward the chat topic's streak, once. -func (s *service) bumpStreak(ctx context.Context, num int, ref chatRef) { - err := updateVersioned(ctx, s.streaks, chatStreakKey(ref.ChatID, ref.ThreadID), func(st *chatStreak, _ bool) bool { - switch { - case st.LastNum >= num: - return false - case st.LastNum == num-1: - st.Streak++ - default: - st.Streak = 1 - } - st.LastNum = num - return true - }) - if err != nil { - log.Warn("wordledaily group streak update failed", "err", err) - } -} - -// displayStreak is the current streak as shown on puzzle today: a streak -// whose last win is older than yesterday is over. -func displayStreak(st userStats, today int) int { - if st.LastWinNum < today-1 { - return 0 - } - return st.CurStreak -} diff --git a/internal/modules/wordledaily/play_test.go b/internal/modules/wordledaily/play_test.go index 323b4c6..5a45a35 100644 --- a/internal/modules/wordledaily/play_test.go +++ b/internal/modules/wordledaily/play_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" "github.com/tiennm99/tiennm99bot/internal/storage" ) @@ -18,10 +19,10 @@ func TestAPI_WinUpdatesStatsAndHidesTheAnswerUntilTheEnd(t *testing.T) { tok := h.dmToken(1) ans := h.answer() v := h.state(tok) - if v.Num != 1 || v.Date != "2026-10-09" || v.Status != statusPlaying || len(v.Guesses) != 0 || v.Max != 6 || v.Len != 5 || v.Player != "Alice" { + if v.Num != 1 || v.Date != "2026-10-09" || v.Status != guessgame.StatusPlaying || len(v.Guesses) != 0 || v.Max != 6 || v.Len != 5 || v.Player != "Alice" { t.Fatalf("fresh state = %+v", v) } - if v.NextAt != epoch.Add(24*time.Hour).Unix() { + if v.NextAt != guessgame.Epoch.Add(24*time.Hour).Unix() { t.Fatalf("next_at = %d", v.NextAt) } for _, w := range h.wrong(2) { @@ -32,7 +33,7 @@ func TestAPI_WinUpdatesStatsAndHidesTheAnswerUntilTheEnd(t *testing.T) { } } v = h.guess(tok, 1, strings.ToUpper(ans)) - if v.Status != statusWon || v.Answer != strings.ToUpper(ans) || len(v.Guesses) != 3 || v.Guesses[2].Marks != "ccccc" { + if v.Status != guessgame.StatusWon || v.Answer != strings.ToUpper(ans) || len(v.Guesses) != 3 || v.Guesses[2].Marks != "ccccc" { t.Fatalf("win = %+v", v) } if st := v.Stats; st == nil || st.Played != 1 || st.WinPct != 100 || st.Cur != 1 || st.Max != 1 || st.Dist[2] != 1 { @@ -45,7 +46,7 @@ func TestAPI_WinUpdatesStatsAndHidesTheAnswerUntilTheEnd(t *testing.T) { if code := h.guessErr(tok, 1, h.wrong(1)[0], http.StatusUnprocessableEntity); code != "finished" { t.Fatalf("guess after win = %s", code) } - if v := h.state(tok); v.Status != statusWon || len(v.Guesses) != 3 || v.Answer == "" { + if v := h.state(tok); v.Status != guessgame.StatusWon || len(v.Guesses) != 3 || v.Answer == "" { t.Fatalf("reopened = %+v", v) } } @@ -57,7 +58,7 @@ func TestAPI_LossAfterSixGuesses(t *testing.T) { for _, w := range h.wrong(6) { v = h.guess(tok, 1, w) } - if v.Status != statusLost || v.Answer != strings.ToUpper(h.answer()) || len(v.Guesses) != 6 { + if v.Status != guessgame.StatusLost || v.Answer != strings.ToUpper(h.answer()) || len(v.Guesses) != 6 { t.Fatalf("loss = %+v", v) } if st := v.Stats; st == nil || st.Played != 1 || st.WinPct != 0 || st.Cur != 0 || st.Dist[5] != 0 { @@ -88,7 +89,7 @@ func TestAPI_DuplicateLetterMarks(t *testing.T) { h := newHarness(t) ctx := context.Background() // Pin puzzle #1 to a word with a doubled letter. - if err := storage.Typed[puzzleDoc](h.coll).Put(ctx, puzzleKey(1), puzzleDoc{Num: 1, Answer: "abbey"}); err != nil { + if err := storage.Typed[guessgame.PuzzleDoc](h.coll).Put(ctx, guessgame.PuzzleKey(1), guessgame.PuzzleDoc{Num: 1, Answer: "abbey"}); err != nil { t.Fatal(err) } v := h.guess(h.dmToken(1), 1, "babes") @@ -107,7 +108,7 @@ func TestAPI_StaleBoardGetsNewPuzzleAfterRollover(t *testing.T) { t.Fatalf("stale guess = %s", code) } v := h.state(tok) - if v.Num != 2 || len(v.Guesses) != 0 || v.Status != statusPlaying || v.Date != "2026-10-10" { + if v.Num != 2 || len(v.Guesses) != 0 || v.Status != guessgame.StatusPlaying || v.Date != "2026-10-10" { t.Fatalf("new day = %+v", v) } } @@ -128,11 +129,11 @@ func TestStats_StreakContinuesBreaksAndDisplaysZero(t *testing.T) { t.Fatalf("day 2 = %+v", v.Stats) } h.clock.advance(48 * time.Hour) // day 3 missed - st, err := h.svc.loadStats(context.Background(), 1) + st, err := h.svc.LoadStats(context.Background(), 1) if err != nil { t.Fatal(err) } - if got := displayStreak(st, h.svc.puzzleNum(h.clock.now())); got != 0 { + if got := guessgame.DisplayStreak(st, h.svc.PuzzleNum(h.clock.now())); got != 0 { t.Fatalf("streak after a missed day shows %d", got) } v = h.win(1) @@ -151,11 +152,11 @@ func TestStats_StreakContinuesBreaksAndDisplaysZero(t *testing.T) { // flakyPlays fails the next Put. type flakyPlays struct { - storage.DocStore[progress] + storage.DocStore[guessgame.Progress] fail bool } -func (f *flakyPlays) Put(ctx context.Context, id string, v progress) error { +func (f *flakyPlays) Put(ctx context.Context, id string, v guessgame.Progress) error { if f.fail { f.fail = false return errors.New("store down") @@ -165,8 +166,8 @@ func (f *flakyPlays) Put(ctx context.Context, id string, v progress) error { func TestFinish_FailedProgressWriteDoesNotDoubleCount(t *testing.T) { h := newHarness(t) - flaky := &flakyPlays{DocStore: h.svc.plays} - h.svc.plays = flaky + flaky := &flakyPlays{DocStore: h.svc.Plays} + h.svc.Plays = flaky tok := h.dmToken(1) h.guess(tok, 1, h.wrong(1)[0]) flaky.fail = true @@ -175,7 +176,7 @@ func TestFinish_FailedProgressWriteDoesNotDoubleCount(t *testing.T) { t.Fatalf("failed write = %d", rec.Code) } v := h.guess(tok, 1, h.answer()) - if v.Status != statusWon || v.Stats.Played != 1 || v.Stats.Dist[1] != 1 { + if v.Status != guessgame.StatusWon || v.Stats.Played != 1 || v.Stats.Dist[1] != 1 { t.Fatalf("retried finish = %+v %+v", v, v.Stats) } } @@ -184,8 +185,8 @@ func TestFinish_FailedProgressWriteDoesNotDoubleCount(t *testing.T) { // can play on with other words and the stats follow the board. func TestFinish_FailedBoardWriteThenOtherWordsKeepsStatsInStep(t *testing.T) { h := newHarness(t) - flaky := &flakyPlays{DocStore: h.svc.plays} - h.svc.plays = flaky + flaky := &flakyPlays{DocStore: h.svc.Plays} + h.svc.Plays = flaky tok := h.dmToken(1) words := h.wrong(6) h.guess(tok, 1, words[0]) @@ -197,7 +198,7 @@ func TestFinish_FailedBoardWriteThenOtherWordsKeepsStatsInStep(t *testing.T) { for _, w := range words[1:] { v = h.guess(tok, 1, w) } - if v.Status != statusLost || len(v.Guesses) != 6 { + if v.Status != guessgame.StatusLost || len(v.Guesses) != 6 { t.Fatalf("board = %+v", v) } if st := v.Stats; st.Played != 1 || st.WinPct != 0 || st.Cur != 0 || st.Max != 0 || slices.Max(st.Dist) != 0 { @@ -207,11 +208,11 @@ func TestFinish_FailedBoardWriteThenOtherWordsKeepsStatsInStep(t *testing.T) { // flakyStats fails the next Put. type flakyStats struct { - storage.DocStore[userStats] + storage.DocStore[guessgame.UserStats] fail bool } -func (f *flakyStats) Put(ctx context.Context, id string, v userStats) error { +func (f *flakyStats) Put(ctx context.Context, id string, v guessgame.UserStats) error { if f.fail { f.fail = false return errors.New("store down") @@ -223,8 +224,8 @@ func (f *flakyStats) Put(ctx context.Context, id string, v userStats) error { // the next load, once, with the group bookkeeping that finishing runs. func TestFinish_FailedStatsWriteIsRepairedFromTheBoard(t *testing.T) { h := newHarness(t) - flaky := &flakyStats{DocStore: h.svc.stats} - h.svc.stats = flaky + flaky := &flakyStats{DocStore: h.svc.Stats} + h.svc.Stats = flaky tok := h.tokenFor(1, groupChat, 7, 0, "Alice") h.guess(tok, 1, h.wrong(1)[0]) flaky.fail = true @@ -237,7 +238,7 @@ func TestFinish_FailedStatsWriteIsRepairedFromTheBoard(t *testing.T) { t.Fatalf("guess after finish = %s", code) } v := h.state(tok) - if v.Status != statusWon || len(v.Guesses) != 2 { + if v.Status != guessgame.StatusWon || len(v.Guesses) != 2 { t.Fatalf("board = %+v", v) } if st := v.Stats; st == nil || st.Played != 1 || st.WinPct != 100 || st.Cur != 1 || st.Dist[1] != 1 { @@ -246,7 +247,7 @@ func TestFinish_FailedStatsWriteIsRepairedFromTheBoard(t *testing.T) { if v = h.state(tok); v.Stats.Played != 1 { t.Fatalf("counted twice: %+v", v.Stats) } - if st, _ := h.svc.chatStreakAt(context.Background(), groupChat, 0); st.Streak != 1 || st.LastNum != 1 { + if st, _ := h.svc.ChatStreakAt(context.Background(), groupChat, 0); st.Streak != 1 || st.LastNum != 1 { t.Fatalf("group streak = %+v", st) } if calls := h.rep.snapshot(); len(calls) != 1 || calls[0].score != 5 { @@ -270,7 +271,7 @@ func TestAPI_TokenErrors(t *testing.T) { h := newHarness(t) // A token signed with noitu's key derivation never verifies here. noituKey := htmlgame.DeriveKey(testRoot, "tiennm99bot/noitu/token/v1") - noituTok, _ := htmlgame.Sign(noituKey, claims{UserID: 1, ChatID: 1, MessageID: 1, Expiry: h.clock.now().Add(time.Hour).Unix()}) + noituTok, _ := htmlgame.Sign(noituKey, guessgame.Claims{UserID: 1, ChatID: 1, MessageID: 1, Expiry: h.clock.now().Add(time.Hour).Unix()}) for name, tok := range map[string]string{"noitu key": noituTok, "garbage": "abc.def", "empty": ""} { rec := h.post("state", `{"token":"`+tok+`"}`) if rec.Code != http.StatusUnauthorized || errorCode(t, rec) != "bad_token" { @@ -278,7 +279,7 @@ func TestAPI_TokenErrors(t *testing.T) { } } tok := h.dmToken(1) - h.clock.advance(tokenTTL) + h.clock.advance(guessgame.TokenTTL) rec := h.post("state", `{"token":"`+tok+`"}`) if rec.Code != http.StatusUnauthorized || errorCode(t, rec) != "expired" { t.Fatalf("expired: %d %s", rec.Code, rec.Body.String()) diff --git a/internal/modules/wordledaily/puzzle_test.go b/internal/modules/wordledaily/puzzle_test.go index 5a23346..5b99a4a 100644 --- a/internal/modules/wordledaily/puzzle_test.go +++ b/internal/modules/wordledaily/puzzle_test.go @@ -7,13 +7,16 @@ import ( "testing" "time" + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" "github.com/tiennm99/tiennm99bot/internal/storage" ) func TestPuzzleNum_RollsOverAt0700ICT(t *testing.T) { h := newHarness(t) s := h.svc - at := func(y int, m time.Month, d, hh, mm, ss int) time.Time { return time.Date(y, m, d, hh, mm, ss, 0, ict) } + at := func(y int, m time.Month, d, hh, mm, ss int) time.Time { + return time.Date(y, m, d, hh, mm, ss, 0, guessgame.ICT) + } cases := []struct { at time.Time want int @@ -23,17 +26,17 @@ func TestPuzzleNum_RollsOverAt0700ICT(t *testing.T) { {at(2026, 10, 10, 7, 0, 0), 2}, {at(2026, 10, 11, 0, 30, 0), 2}, {at(2026, 10, 19, 7, 0, 0), 11}, - {at(2026, 1, 1, 0, 0, 0), 1}, // before the epoch + {at(2026, 1, 1, 0, 0, 0), 1}, // before the guessgame.Epoch } for _, c := range cases { - if got := s.puzzleNum(c.at); got != c.want { + if got := s.PuzzleNum(c.at); got != c.want { t.Errorf("puzzleNum(%s) = %d, want %d", c.at, got, c.want) } } - if got := s.puzzleDate(2); got != "2026-10-10" { + if got := s.PuzzleDate(2); got != "2026-10-10" { t.Errorf("puzzleDate(2) = %s", got) } - if got := s.puzzleStart(2); !got.Equal(at(2026, 10, 10, 7, 0, 0)) { + if got := s.PuzzleStart(2); !got.Equal(at(2026, 10, 10, 7, 0, 0)) { t.Errorf("puzzleStart(2) = %s", got) } } @@ -44,7 +47,7 @@ func TestAnswerFor_KeyedPermutationWithoutRepeats(t *testing.T) { seen := make(map[string]bool, n) var first []string for num := 1; num <= n; num++ { - a := h.svc.answerFor(num) + a := h.svc.AnswerFor(num) if seen[a] { t.Fatalf("answer %q repeats within the first cycle (puzzle %d)", a, num) } @@ -66,8 +69,8 @@ func TestAnswerFor_KeyedPermutationWithoutRepeats(t *testing.T) { diff := newService(other) var sameSeq, diffSeq []string for num := 1; num <= 10; num++ { - sameSeq = append(sameSeq, same.answerFor(num)) - diffSeq = append(diffSeq, diff.answerFor(num)) + sameSeq = append(sameSeq, same.AnswerFor(num)) + diffSeq = append(diffSeq, diff.AnswerFor(num)) } if !slices.Equal(sameSeq, first) { t.Fatal("same key, different sequence") @@ -76,7 +79,7 @@ func TestAnswerFor_KeyedPermutationWithoutRepeats(t *testing.T) { t.Fatal("different key, same sequence") } // The next cycle is another permutation. - if h.svc.answerFor(n+1) == h.svc.answerFor(1) && h.svc.answerFor(n+2) == h.svc.answerFor(2) { + if h.svc.AnswerFor(n+1) == h.svc.AnswerFor(1) && h.svc.AnswerFor(n+2) == h.svc.AnswerFor(2) { t.Fatal("second cycle repeats the first") } } @@ -84,20 +87,20 @@ func TestAnswerFor_KeyedPermutationWithoutRepeats(t *testing.T) { func TestResolvePuzzle_StoredAnswerWinsOverKeyChange(t *testing.T) { h := newHarness(t) ctx := context.Background() - first, err := h.svc.resolvePuzzle(ctx, 1) - if err != nil || first != h.svc.answerFor(1) { + first, err := h.svc.ResolvePuzzle(ctx, 1) + if err != nil || first != h.svc.AnswerFor(1) { t.Fatalf("resolve = %q, %v", first, err) } cfg := testConfig(h.clock, h.coll, h.rep, nil, h.sched) cfg.rootKey = []byte("another-root-key-another-root-key") rotated := newService(cfg) - if rotated.answerFor(1) == first { + if rotated.AnswerFor(1) == first { t.Skip("rotated key happens to pick the same answer") } - if got, err := rotated.resolvePuzzle(ctx, 1); err != nil || got != first { + if got, err := rotated.ResolvePuzzle(ctx, 1); err != nil || got != first { t.Fatalf("after key change = %q, %v; want stored %q", got, err, first) } - doc, _, err := storage.Typed[puzzleDoc](h.coll).Get(ctx, puzzleKey(1)) + doc, _, err := storage.Typed[guessgame.PuzzleDoc](h.coll).Get(ctx, guessgame.PuzzleKey(1)) if err != nil || doc.Num != 1 || doc.Answer != first || doc.CreatedAt == 0 { t.Fatalf("stored puzzle = %+v, %v", doc, err) } @@ -113,7 +116,7 @@ func TestResolvePuzzle_ConcurrentFirstResolutionAgrees(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - a, err := svc.resolvePuzzle(context.Background(), 3) + a, err := svc.ResolvePuzzle(context.Background(), 3) if err != nil { t.Error(err) } diff --git a/internal/modules/wordledaily/recap.go b/internal/modules/wordledaily/recap.go deleted file mode 100644 index 5a35758..0000000 --- a/internal/modules/wordledaily/recap.go +++ /dev/null @@ -1,169 +0,0 @@ -package wordledaily - -import ( - "context" - "errors" - "fmt" - "strconv" - "strings" - - "github.com/go-telegram/bot" - - "github.com/tiennm99/tiennm99bot/internal/log" - "github.com/tiennm99/tiennm99bot/internal/modules" - "github.com/tiennm99/tiennm99bot/internal/modules/util/subscription" - "github.com/tiennm99/tiennm99bot/internal/storage" -) - -const ( - // dailyPushCronName must be unique across all modules' crons. - dailyPushCronName = "wordledaily_daily_push" - // dailyPushSchedule is UTC; 00:00 UTC is 07:00 ICT, when a puzzle starts. - dailyPushSchedule = "0 0 * * *" - // lastPushDateKey records the number of the last puzzle pushed, so a - // second trigger on the same day sends nothing. - lastPushDateKey = "daily_push:last_date" -) - -// dailyPushHandler is the cron entry point. -func (s *service) dailyPushHandler(ctx context.Context, deps modules.Deps) error { - if deps.Bot == nil { - return errors.New("wordledaily daily push: deps.Bot is nil (BuildOptions.Bot not wired)") - } - return s.runDailyPush(ctx, deps.Bot) -} - -// runDailyPush sends each subscriber yesterday's group results (when there -// is something to say) and then today's card, once per puzzle. -func (s *service) runDailyPush(ctx context.Context, api telegramAPI) error { - num := s.puzzleNum(s.cfg.now()) - // Pin today's answer now, before anyone plays. - if _, err := s.resolvePuzzle(ctx, num); err != nil { - return fmt.Errorf("wordledaily daily push: resolve puzzle: %w", err) - } - s.pruneDays(ctx, num) - subs, err := subscription.List(ctx, s.subscribers) - if err != nil { - return fmt.Errorf("wordledaily daily push: list subscribers: %w", err) - } - if len(subs) == 0 { - log.Info("wordledaily daily push: no subscribers, skipping") - return nil - } - won, err := subscription.ClaimDay(ctx, s.pushDate, lastPushDateKey, strconv.Itoa(num)) - if err != nil { - return fmt.Errorf("wordledaily daily push: claim day: %w", err) - } - if !won { - log.Info("wordledaily daily push: already pushed, skipping", "puzzle", num) - return nil - } - res, err := subscription.FanoutFunc(ctx, "wordledaily daily", s.subscribers, &s.subscribersMu, subs, func(ctx context.Context, sub subscription.Subscriber) error { - return s.pushTo(ctx, api, num, sub) - }) - if err != nil { - return err - } - log.Info("wordledaily daily push complete", "puzzle", num, "subscribers", len(subs), - "sent", res.Sent, "failed", res.Failed, "pruned", res.Pruned, "throttled", res.Throttled) - return nil -} - -// pushTo sends one subscriber its recap and card. A recap that fails to send -// skips the card, so FanoutFunc classifies that error and prunes a dead chat. -func (s *service) pushTo(ctx context.Context, api telegramAPI, num int, sub subscription.Subscriber) error { - if num > 1 { - text, err := s.renderRecap(ctx, num-1, sub.ChatID, sub.ThreadID) - if err != nil { - log.Warn("wordledaily recap render failed", "err", err) - } - if text != "" { - if _, err := api.SendMessage(ctx, &bot.SendMessageParams{ChatID: sub.ChatID, MessageThreadID: sub.ThreadID, Text: text}); err != nil { - return err - } - } - } - _, err := api.SendGame(ctx, &bot.SendGameParams{ChatID: sub.ChatID, MessageThreadID: sub.ThreadID, GameShorName: GameShortName}) - return err -} - -// renderRecap is puzzle num's results in a group chat topic: the answer -// first, then the finishers grouped by guess count with the best line -// crowned, and the group streak, which only a win keeps going. It is "" in a -// private chat. -func (s *service) renderRecap(ctx context.Context, num int, chatID int64, threadID int) (string, error) { - if chatID >= 0 { - return "", nil - } - answer, err := s.resolvePuzzle(ctx, num) - if err != nil { - return "", err - } - cd, _, err := s.chatDays.Get(ctx, chatDayKey(num, chatID, threadID)) - if err != nil && !errors.Is(err, storage.ErrNotFound) { - return "", err - } - results, err := s.chatResults(ctx, cd) - if err != nil { - return "", err - } - finished := results[:0] - for _, r := range results { - if r.p.finished() { - finished = append(finished, r) - } - } - st, err := s.chatStreakAt(ctx, chatID, threadID) - if err != nil { - return "", err - } - label := "Wordle Daily #" + strconv.Itoa(num) - head := label + " — yesterday's results\nAnswer: " + strings.ToUpper(answer) + "\n" - // The recap runs after puzzle num closed, so the streak survives only if - // someone in the group solved num itself. - switch { - case st.LastNum >= num && st.Streak >= minStreakShown: - head += "🔥 Your group is on a " + strconv.Itoa(st.Streak) + " day streak!\n" - case st.LastNum == num-1 && st.Streak >= minStreakShown: - head += "Nobody solved it. Group streak reset.\n" - } - if len(finished) == 0 { - return strings.TrimSuffix(head, "\n"), nil - } - sortResults(finished) - var lines []string - for i := 0; i < len(finished); { - j := i - line := resultLine(finished[i].p) - for j < len(finished) && resultLine(finished[j].p) == line { - j++ - } - prefix := line + ": " - if i == 0 && finished[i].p.Status == statusWon { - prefix = "👑 " + prefix - } - lines = append(lines, prefix+nameList(finished[i:j])) - i = j - } - return joinLimited(head, lines, "\n"), nil -} - -// maxLineUnits caps one recap line, so even all seven result lines fit in -// one message. -const maxLineUnits = 500 - -// nameList joins the players' names, ending with "+N more" past -// maxLineUnits. -func nameList(rs []chatResult) string { - var b strings.Builder - for i, r := range rs { - if i > 0 && utf16Len(b.String())+utf16Len(r.name) > maxLineUnits { - return b.String() + ", +" + strconv.Itoa(len(rs)-i) + " more" - } - if i > 0 { - b.WriteString(", ") - } - b.WriteString(r.name) - } - return b.String() -} diff --git a/internal/modules/wordledaily/recap_test.go b/internal/modules/wordledaily/recap_test.go index f22aa75..49b9a4e 100644 --- a/internal/modules/wordledaily/recap_test.go +++ b/internal/modules/wordledaily/recap_test.go @@ -7,12 +7,13 @@ import ( "time" "github.com/tiennm99/tiennm99bot/internal/modules" + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" "github.com/tiennm99/tiennm99bot/internal/modules/util/subscription" ) func mustList(t *testing.T, h *harness) []subscription.Subscriber { t.Helper() - subs, err := subscription.List(context.Background(), h.svc.subscribers) + subs, err := subscription.List(context.Background(), h.svc.Subscribers) if err != nil { t.Fatal(err) } @@ -21,7 +22,7 @@ func mustList(t *testing.T, h *harness) []subscription.Subscriber { func subscribe(t *testing.T, h *harness, chat int64, thread int) { t.Helper() - if _, err := subscription.Add(context.Background(), h.svc.subscribers, chat, thread); err != nil { + if _, err := subscription.Add(context.Background(), h.svc.Subscribers, chat, thread); err != nil { t.Fatal(err) } } @@ -54,14 +55,14 @@ func TestPush_RecapThenCardWithCrownTiesAndLoss(t *testing.T) { playGroupDay(t, h, 77) subscribe(t, h, groupChat, 77) h.nextDay() - if err := h.svc.runDailyPush(context.Background(), h.rb.Bot); err != nil { + if err := h.svc.RunDailyPush(context.Background(), h.rb.Bot); err != nil { t.Fatal(err) } sent := h.rb.Sent() if len(sent) != 2 || sent[0].Method != "sendMessage" || sent[1].Method != "sendGame" { t.Fatalf("sent %+v", sent) } - want := "Wordle Daily #1 — yesterday's results\nAnswer: " + strings.ToUpper(h.svc.answerFor(1)) + "\n👑 2/6: Alice, Dan\n3/6: Eve\nX/6: Bob" + want := "Wordle Daily #1 — yesterday's results\nAnswer: " + strings.ToUpper(h.svc.AnswerFor(1)) + "\n👑 2/6: Alice, Dan\n3/6: Eve\nX/6: Bob" if got := sent[0].Text(); got != want { t.Fatalf("recap:\n%s\nwant:\n%s", got, want) } @@ -75,7 +76,7 @@ func TestPush_RecapThenCardWithCrownTiesAndLoss(t *testing.T) { } // The same puzzle is never pushed twice. h.rb.Reset() - if err := h.svc.runDailyPush(context.Background(), h.rb.Bot); err != nil || len(h.rb.Sent()) != 0 { + if err := h.svc.RunDailyPush(context.Background(), h.rb.Bot); err != nil || len(h.rb.Sent()) != 0 { t.Fatalf("second run sent %+v, %v", h.rb.Sent(), err) } } @@ -91,29 +92,29 @@ func TestPush_StreakLineAndReset(t *testing.T) { h.guess(h.tokenFor(1, groupChat, 8, 0, "Alice"), 2, h.answer()) h.nextDay() h.rb.Reset() - if err := h.svc.runDailyPush(ctx, h.rb.Bot); err != nil { + if err := h.svc.RunDailyPush(ctx, h.rb.Bot); err != nil { t.Fatal(err) } - if got := h.rb.Sent()[0].Text(); got != "Wordle Daily #2 — yesterday's results\nAnswer: "+strings.ToUpper(h.svc.answerFor(2))+"\n🔥 Your group is on a 2 day streak!\n👑 1/6: Alice" { + if got := h.rb.Sent()[0].Text(); got != "Wordle Daily #2 — yesterday's results\nAnswer: "+strings.ToUpper(h.svc.AnswerFor(2))+"\n🔥 Your group is on a 2 day streak!\n👑 1/6: Alice" { t.Fatalf("streak recap = %q", got) } // Nobody plays #3: the next push says the streak ended, then sends the card. h.nextDay() h.rb.Reset() - if err := h.svc.runDailyPush(ctx, h.rb.Bot); err != nil { + if err := h.svc.RunDailyPush(ctx, h.rb.Bot); err != nil { t.Fatal(err) } sent := h.rb.Sent() - if len(sent) != 2 || sent[0].Text() != "Wordle Daily #3 — yesterday's results\nAnswer: "+strings.ToUpper(h.svc.answerFor(3))+"\nNobody solved it. Group streak reset." || sent[1].Method != "sendGame" { + if len(sent) != 2 || sent[0].Text() != "Wordle Daily #3 — yesterday's results\nAnswer: "+strings.ToUpper(h.svc.AnswerFor(3))+"\nNobody solved it. Group streak reset." || sent[1].Method != "sendGame" { t.Fatalf("reset push = %+v", sent) } // The day after, with no streak left, the recap is just the answer. h.nextDay() h.rb.Reset() - if err := h.svc.runDailyPush(ctx, h.rb.Bot); err != nil { + if err := h.svc.RunDailyPush(ctx, h.rb.Bot); err != nil { t.Fatal(err) } - if sent := h.rb.Sent(); len(sent) != 2 || sent[0].Text() != "Wordle Daily #4 — yesterday's results\nAnswer: "+strings.ToUpper(h.svc.answerFor(4)) || sent[1].Method != "sendGame" { + if sent := h.rb.Sent(); len(sent) != 2 || sent[0].Text() != "Wordle Daily #4 — yesterday's results\nAnswer: "+strings.ToUpper(h.svc.AnswerFor(4)) || sent[1].Method != "sendGame" { t.Fatalf("quiet push = %+v", sent) } } @@ -133,10 +134,10 @@ func TestPush_LossBreaksTheGroupStreak(t *testing.T) { } h.nextDay() h.rb.Reset() - if err := h.svc.runDailyPush(ctx, h.rb.Bot); err != nil { + if err := h.svc.RunDailyPush(ctx, h.rb.Bot); err != nil { t.Fatal(err) } - want := "Wordle Daily #3 — yesterday's results\nAnswer: " + strings.ToUpper(h.svc.answerFor(3)) + "\nNobody solved it. Group streak reset.\nX/6: Alice" + want := "Wordle Daily #3 — yesterday's results\nAnswer: " + strings.ToUpper(h.svc.AnswerFor(3)) + "\nNobody solved it. Group streak reset.\nX/6: Alice" if got := h.rb.Sent()[0].Text(); got != want { t.Fatalf("loss recap = %q, want %q", got, want) } @@ -148,7 +149,7 @@ func TestPush_PrivateSubscriberGetsCardOnly(t *testing.T) { subscribe(t, h, 5, 0) h.nextDay() h.rb.Reset() - if err := h.svc.runDailyPush(context.Background(), h.rb.Bot); err != nil { + if err := h.svc.RunDailyPush(context.Background(), h.rb.Bot); err != nil { t.Fatal(err) } if sent := h.rb.Sent(); len(sent) != 1 || sent[0].Method != "sendGame" || sent[0].ChatID() != "5" { @@ -163,7 +164,7 @@ func TestPush_BlockedRecapPrunesAndSkipsTheCard(t *testing.T) { subscribe(t, h, 9, 0) h.nextDay() h.rb.FailMethodCode("sendMessage", 403, "Forbidden: bot was kicked from the supergroup chat; bot is not a member of the supergroup chat") - if err := h.svc.runDailyPush(context.Background(), h.rb.Bot); err != nil { + if err := h.svc.RunDailyPush(context.Background(), h.rb.Bot); err != nil { t.Fatal(err) } games := sentMethod(h.rb, "sendGame") @@ -177,14 +178,14 @@ func TestPush_BlockedRecapPrunesAndSkipsTheCard(t *testing.T) { func TestPush_NoSubscribersAndNoBot(t *testing.T) { h := newHarness(t) - if err := h.svc.runDailyPush(context.Background(), h.rb.Bot); err != nil || len(h.rb.Sent()) != 0 { + if err := h.svc.RunDailyPush(context.Background(), h.rb.Bot); err != nil || len(h.rb.Sent()) != 0 { t.Fatalf("no subscribers: %+v, %v", h.rb.Sent(), err) } - if err := h.svc.dailyPushHandler(context.Background(), modules.Deps{}); err == nil { + if err := h.svc.PushHandler(context.Background(), modules.Deps{}); err == nil { t.Fatal("push without a bot succeeded") } // The push pins the day's answer even with nobody subscribed. - if _, _, err := h.svc.puzzles.Get(context.Background(), puzzleKey(1)); err != nil { + if _, _, err := h.svc.Puzzles.Get(context.Background(), guessgame.PuzzleKey(1)); err != nil { t.Fatalf("puzzle not pinned: %v", err) } } @@ -201,10 +202,10 @@ func TestPush_PrunesOldPuzzleDays(t *testing.T) { h.nextDay() } } - if err := h.svc.runDailyPush(ctx, h.rb.Bot); err != nil { + if err := h.svc.RunDailyPush(ctx, h.rb.Bot); err != nil { t.Fatal(err) } - keys, err := h.svc.plays.List(ctx, "") + keys, err := h.svc.Plays.List(ctx, "") if err != nil { t.Fatal(err) } diff --git a/internal/modules/wordledaily/rules.go b/internal/modules/wordledaily/rules.go new file mode 100644 index 0000000..01c70fe --- /dev/null +++ b/internal/modules/wordledaily/rules.go @@ -0,0 +1,71 @@ +package wordledaily + +import ( + "errors" + "strings" + + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" + "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" +) + +// Rejections of a guess, mapped to API errors in http_api.go and to chat +// replies in command.go. +var ( + errWordEmpty = errors.New("wordledaily: no word") + errWordLength = errors.New("wordledaily: word must be 5 letters") + errWordUnknown = errors.New("wordledaily: not in the word list") +) + +// rules is Wordle for the guessgame engine. Marks are one byte per letter: +// c (correct), p (present elsewhere) or w (wrong). +type rules struct { + dict map[string]struct{} +} + +func (rules) Label() string { return "Wordle Daily" } +func (rules) MaxGuesses() int { return maxGuesses } + +// Judge accepts a dictionary word and scores it against answer. +func (r rules) Judge(input, answer string, _ []guessgame.Guess) (guessgame.Guess, error) { + v := wordlist.Validate(r.dict, input) + switch v.Reason { + case wordlist.ReasonEmpty: + return guessgame.Guess{}, errWordEmpty + case wordlist.ReasonLength: + return guessgame.Guess{}, errWordLength + case wordlist.ReasonUnknown: + return guessgame.Guess{}, errWordUnknown + } + return guessgame.Guess{Word: v.Word, Marks: marksOf(wordlist.Compare(v.Word, answer))}, nil +} + +func (rules) Marker(mark byte) string { + switch mark { + case 'c': + return wordlist.Marker(wordlist.ResultCorrect) + case 'p': + return wordlist.Marker(wordlist.ResultPartial) + } + return wordlist.Marker(wordlist.ResultWrong) +} + +func (rules) AnswerText(answer string) string { return strings.ToUpper(answer) } + +// marksOf encodes scored letters as c/p/w, the stored and served form. +func marksOf(scores []wordlist.LetterScore) string { + var b strings.Builder + for _, sc := range scores { + switch sc.Result { + case wordlist.ResultCorrect: + b.WriteByte('c') + case wordlist.ResultPartial: + b.WriteByte('p') + default: + b.WriteByte('w') + } + } + return b.String() +} + +// emojiRow renders stored marks as the spoiler-free share row. +func emojiRow(marks string) string { return guessgame.EmojiRow(rules{}, marks) } diff --git a/internal/modules/wordledaily/score_test.go b/internal/modules/wordledaily/score_test.go index 3dd27d7..c6d075f 100644 --- a/internal/modules/wordledaily/score_test.go +++ b/internal/modules/wordledaily/score_test.go @@ -4,6 +4,7 @@ import ( "errors" "testing" + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" ) @@ -26,7 +27,7 @@ func TestScore_WinReportsSevenMinusGuessesOncePerCard(t *testing.T) { other := h.tokenFor(1, -200, 9, 0, "Alice") h.state(other) h.state(other) - inline, _ := h.svc.signToken(claims{UserID: 1, InlineID: "BAAAInline", Expiry: h.clock.now().Add(tokenTTL).Unix()}) + inline, _ := h.svc.signToken(guessgame.Claims{UserID: 1, InlineID: "BAAAInline", Expiry: h.clock.now().Add(guessgame.TokenTTL).Unix()}) h.state(inline) calls = h.rep.snapshot() if len(calls) != 3 || calls[1].addr != (htmlgame.Address{ChatID: -200, MessageID: 9}) || calls[1].score != 4 || diff --git a/internal/modules/wordledaily/summary_test.go b/internal/modules/wordledaily/summary_test.go index b4d427c..8673639 100644 --- a/internal/modules/wordledaily/summary_test.go +++ b/internal/modules/wordledaily/summary_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" "time" + + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" ) func TestSummary_FirstJoinRepliesLaterGuessesEditOnce(t *testing.T) { @@ -37,7 +39,7 @@ func TestSummary_FirstJoinRepliesLaterGuessesEditOnce(t *testing.T) { h.guess(alice, 1, w) } h.guess(h.tokenFor(2, groupChat, 7, 77, "Bob"), 1, words[4]) - if h.sched.pending() != 1 || h.sched.delays[0] != summaryInterval-time.Second { + if h.sched.pending() != 1 || h.sched.delays[0] != guessgame.SummaryInterval-time.Second { t.Fatalf("coalesced: pending %d delays %v", h.sched.pending(), h.sched.delays) } h.sched.run() @@ -58,7 +60,7 @@ func TestSummary_DeletedMessageIsResentAtMostEveryTenMinutes(t *testing.T) { h.sched.run() h.rb.FailMethodCode("editMessageText", 400, "Bad Request: message is not modified: specified new message content is the same") - h.clock.advance(summaryInterval) + h.clock.advance(guessgame.SummaryInterval) h.guess(tok, 1, words[1]) h.sched.run() if n := len(sentMethod(h.rb, "sendMessage")); n != 1 { @@ -66,19 +68,19 @@ func TestSummary_DeletedMessageIsResentAtMostEveryTenMinutes(t *testing.T) { } h.rb.FailMethodCode("editMessageText", 400, "Bad Request: message to edit not found") - h.clock.advance(summaryInterval) + h.clock.advance(guessgame.SummaryInterval) h.guess(tok, 1, words[2]) h.sched.run() if n := len(sentMethod(h.rb, "sendMessage")); n != 1 { t.Fatalf("resent within ten minutes: %d sends", n) } - h.clock.advance(resendInterval) + h.clock.advance(guessgame.ResendInterval) h.guess(tok, 1, words[3]) h.sched.run() if n := len(sentMethod(h.rb, "sendMessage")); n != 2 { t.Fatalf("not resent after ten minutes: %d sends", n) } - cd, _, err := h.svc.chatDays.Get(context.Background(), chatDayKey(1, groupChat, 0)) + cd, _, err := h.svc.ChatDays.Get(context.Background(), guessgame.ChatDayKey(1, groupChat, 0)) if err != nil || cd.MsgID != 2 { t.Fatalf("stored summary id = %+v, %v", cd, err) } @@ -87,13 +89,13 @@ func TestSummary_DeletedMessageIsResentAtMostEveryTenMinutes(t *testing.T) { func TestSummary_NoneInPrivateChatsOrInline(t *testing.T) { h := newHarness(t) h.guess(h.dmToken(1), 1, h.wrong(1)[0]) - inline, _ := h.svc.signToken(claims{UserID: 2, InlineID: "BAAA", Expiry: h.clock.now().Add(tokenTTL).Unix()}) + inline, _ := h.svc.signToken(guessgame.Claims{UserID: 2, InlineID: "BAAA", Expiry: h.clock.now().Add(guessgame.TokenTTL).Unix()}) h.guess(inline, 1, h.wrong(1)[0]) h.sched.run() if len(h.rb.Sent()) != 0 { t.Fatalf("sent %+v", h.rb.Sent()) } - if keys, _ := h.svc.chatDays.List(context.Background(), "cday:"); len(keys) != 0 { + if keys, _ := h.svc.ChatDays.List(context.Background(), "cday:"); len(keys) != 0 { t.Fatalf("chat days %v", keys) } } @@ -107,7 +109,7 @@ func TestSummary_PlayerFinishedElsewhereJoinsOnOpen(t *testing.T) { if len(sends) != 1 || !strings.Contains(sends[0].Text(), "Alice 1/6\n🟩🟩🟩🟩🟩") { t.Fatalf("sends = %+v", sends) } - st, _ := h.svc.chatStreakAt(context.Background(), groupChat, 0) + st, _ := h.svc.ChatStreakAt(context.Background(), groupChat, 0) if st.Streak != 1 || st.LastNum != 1 { t.Fatalf("group streak = %+v", st) } @@ -119,28 +121,28 @@ func TestRenderLive_OrderGridsAndTruncation(t *testing.T) { h := newHarness(t) ctx := context.Background() put := func(user int64, name, status string, finishedAt int64, marks ...string) { - p := progress{Num: 1, UserID: user, Name: name, Status: status, FinishedAt: finishedAt} + p := guessgame.Progress{Num: 1, UserID: user, Name: name, Status: status, FinishedAt: finishedAt} for _, m := range marks { - p.Guesses = append(p.Guesses, guess{Word: "crane", Marks: m}) + p.Guesses = append(p.Guesses, guessgame.Guess{Word: "crane", Marks: m}) } - if err := h.svc.plays.Put(ctx, playKey(1, user), p); err != nil { + if err := h.svc.Plays.Put(ctx, guessgame.PlayKey(1, user), p); err != nil { t.Fatal(err) } } - put(1, "Carol", statusPlaying, 0, "wwpww", "wccww") - put(2, "Bob", statusLost, 50, "wwwww", "wwwww", "wwwww", "wwwww", "wwwww", "pwwww") - put(3, "Alice", statusWon, 90, "pwwpw", "ccccc") - put(4, "Dan", statusWon, 40, "wpwww", "wpcww", "ccccc") - put(5, "Eve", statusWon, 30, "wpwww", "wpcww", "ccccc") - put(6, "Frank", statusPlaying, 0, "wwwww") - if err := h.svc.streaks.Put(ctx, chatStreakKey(groupChat, 0), chatStreak{Streak: 5, LastNum: 1}); err != nil { + put(1, "Carol", guessgame.StatusPlaying, 0, "wwpww", "wccww") + put(2, "Bob", guessgame.StatusLost, 50, "wwwww", "wwwww", "wwwww", "wwwww", "wwwww", "pwwww") + put(3, "Alice", guessgame.StatusWon, 90, "pwwpw", "ccccc") + put(4, "Dan", guessgame.StatusWon, 40, "wpwww", "wpcww", "ccccc") + put(5, "Eve", guessgame.StatusWon, 30, "wpwww", "wpcww", "ccccc") + put(6, "Frank", guessgame.StatusPlaying, 0, "wwwww") + if err := h.svc.Streaks.Put(ctx, guessgame.ChatStreakKey(groupChat, 0), guessgame.ChatStreak{Streak: 5, LastNum: 1}); err != nil { t.Fatal(err) } - cd := chatDay{Num: 1, ChatID: groupChat} + cd := guessgame.ChatDay{Num: 1, ChatID: groupChat} for _, u := range []int64{1, 2, 3, 4, 5, 6, 99} { // 99 never guessed - cd.Players = append(cd.Players, chatPlayer{UserID: u, Name: "p" + strconv.FormatInt(u, 10)}) + cd.Players = append(cd.Players, guessgame.ChatPlayer{UserID: u, Name: "p" + strconv.FormatInt(u, 10)}) } - text, err := h.svc.renderLive(ctx, cd) + text, err := h.svc.RenderLive(ctx, cd) if err != nil { t.Fatal(err) } @@ -163,12 +165,12 @@ func TestRenderLive_OrderGridsAndTruncation(t *testing.T) { // Many players: the message stops under Telegram's limit. cd.Players = nil for u := int64(100); u < 160; u++ { - put(u, strings.Repeat("N", 60), statusLost, u, "wwwww", "wwwww", "wwwww", "wwwww", "wwwww", "wwwww") - cd.Players = append(cd.Players, chatPlayer{UserID: u}) + put(u, strings.Repeat("N", 60), guessgame.StatusLost, u, "wwwww", "wwwww", "wwwww", "wwwww", "wwwww", "wwwww") + cd.Players = append(cd.Players, guessgame.ChatPlayer{UserID: u}) } - text, _ = h.svc.renderLive(ctx, cd) - if utf16Len(text) > 4096 || !regexp.MustCompile(`\n\+\d+ more$`).MatchString(text) { - t.Fatalf("truncated text: %d units, tail %q", utf16Len(text), text[len(text)-20:]) + text, _ = h.svc.RenderLive(ctx, cd) + if guessgame.UTF16Len(text) > 4096 || !regexp.MustCompile(`\n\+\d+ more$`).MatchString(text) { + t.Fatalf("truncated text: %d units, tail %q", guessgame.UTF16Len(text), text[len(text)-20:]) } } @@ -182,7 +184,7 @@ func TestSummary_TransientFailureIsRetried(t *testing.T) { h.sched.run() h.rb.FailMethod("editMessageText", 429, `{"ok":false,"error_code":429,"description":"Too Many Requests: retry after 30","parameters":{"retry_after":30}}`) - h.clock.advance(summaryInterval) + h.clock.advance(guessgame.SummaryInterval) h.guess(tok, 1, h.answer()) h.sched.run1() if h.sched.pending() != 1 || h.sched.delays[0] != 30*time.Second { @@ -190,7 +192,7 @@ func TestSummary_TransientFailureIsRetried(t *testing.T) { } h.rb.FailMethod("editMessageText", 502, "") h.sched.run1() - if h.sched.pending() != 1 || h.sched.delays[0] != 2*summaryInterval { + if h.sched.pending() != 1 || h.sched.delays[0] != 2*guessgame.SummaryInterval { t.Fatalf("after 5xx: pending %d delays %v", h.sched.pending(), h.sched.delays) } h.rb.ClearFailure("editMessageText") @@ -202,16 +204,16 @@ func TestSummary_TransientFailureIsRetried(t *testing.T) { // A refused request is not retried, and retries stop after a bound. h.rb.FailMethodCode("editMessageText", 403, "Forbidden: bot was kicked from the supergroup chat") - h.svc.touchSummary(1, groupChat, 0) + h.svc.TouchSummary(1, groupChat, 0) h.sched.run1() if h.sched.pending() != 0 { t.Fatalf("forbidden retried: %v", h.sched.delays) } h.rb.FailMethod("editMessageText", 500, "") - h.clock.advance(summaryInterval) - h.svc.touchSummary(1, groupChat, 0) + h.clock.advance(guessgame.SummaryInterval) + h.svc.TouchSummary(1, groupChat, 0) h.sched.run() - if n := len(sentMethod(h.rb, "editMessageText")) - len(edits) - 1; n != 1+maxSummaryRetries { + if n := len(sentMethod(h.rb, "editMessageText")) - len(edits) - 1; n != 1+guessgame.MaxSummaryRetries { t.Fatalf("bounded retries: %d edits", n) } } @@ -227,10 +229,10 @@ func TestSummary_DeletedMessageIsResentWhenTheIntervalEnds(t *testing.T) { h.clock.advance(time.Minute) h.guess(tok, 1, h.answer()) h.sched.run1() - if h.sched.pending() != 1 || h.sched.delays[0] != resendInterval-time.Minute { + if h.sched.pending() != 1 || h.sched.delays[0] != guessgame.ResendInterval-time.Minute { t.Fatalf("deferred resend: pending %d delays %v", h.sched.pending(), h.sched.delays) } - h.clock.advance(resendInterval) + h.clock.advance(guessgame.ResendInterval) h.sched.run() sends := sentMethod(h.rb, "sendMessage") if len(sends) != 2 || !strings.Contains(sends[1].Text(), "Alice 2/6") { @@ -248,7 +250,7 @@ func TestSummary_LockKeyIsPerTopicNotPerDay(t *testing.T) { h.nextDay() } // One user lock and one summary lock, whatever the number of days. - if n := h.svc.locks.Len(); n != 2 { + if n := h.svc.Locks.Len(); n != 2 { t.Fatalf("lock keys = %d, want 2", n) } } diff --git a/internal/modules/wordledaily/token.go b/internal/modules/wordledaily/token.go deleted file mode 100644 index 26e648a..0000000 --- a/internal/modules/wordledaily/token.go +++ /dev/null @@ -1,66 +0,0 @@ -package wordledaily - -import ( - "strings" - "time" - "unicode/utf8" - - "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" -) - -// tokenTTL bounds how long a Play link stays usable. The token is not tied -// to a puzzle: the page always plays the puzzle current at request time. -const tokenTTL = 6 * time.Hour - -// maxNameRunes caps the display name carried in a token. -const maxNameRunes = 64 - -// claims is what a game token asserts: who pressed Play, on which card, -// until when. Exactly one address is set: ChatID+MessageID for a card the -// bot sent into a chat (ThreadID is its forum topic), or InlineID for one -// sent via the bot. -type claims struct { - UserID int64 `json:"u"` - Name string `json:"n,omitempty"` - ChatID int64 `json:"c,omitempty"` - MessageID int `json:"m,omitempty"` - InlineID string `json:"i,omitempty"` - ThreadID int `json:"t,omitempty"` - Expiry int64 `json:"e"` -} - -// Valid requires a player, an expiry and exactly one card address. -func (c claims) Valid() bool { - if c.UserID <= 0 || c.Expiry <= 0 { - return false - } - chat := c.ChatID != 0 && c.MessageID != 0 - inline := c.InlineID != "" - if c.ThreadID != 0 && !chat { - return false - } - return chat != inline -} - -// ExpiresAt is the unix second the token stops working. -func (c claims) ExpiresAt() int64 { return c.Expiry } - -func (s *service) signToken(c claims) (string, error) { return htmlgame.Sign(s.tokenKey, c) } - -func (s *service) verifyToken(token string, now time.Time) (claims, error) { - var c claims - if err := htmlgame.Verify(s.tokenKey, token, now, &c); err != nil { - return claims{}, err - } - return c, nil -} - -// displayName joins a Telegram first and last name within maxNameRunes. -func displayName(first, last string) string { - name := strings.TrimSpace(strings.TrimSpace(first) + " " + strings.TrimSpace(last)) - name = strings.Join(strings.Fields(name), " ") - if utf8.RuneCountInString(name) > maxNameRunes { - name = string([]rune(name)[:maxNameRunes]) - } - return name -} diff --git a/internal/modules/wordledaily/unlimited_test.go b/internal/modules/wordledaily/unlimited_test.go new file mode 100644 index 0000000..4f4c00b --- /dev/null +++ b/internal/modules/wordledaily/unlimited_test.go @@ -0,0 +1,571 @@ +package wordledaily + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" + "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" + "github.com/tiennm99/tiennm99bot/internal/storage" + "github.com/tiennm99/tiennm99bot/internal/testutil" +) + +// unlimitedToken signs a token for user on a recorded /wordle card. +func (h *harness) unlimitedToken(user, chat int64, msg int, name string) string { + h.t.Helper() + tok, err := h.svc.signToken(guessgame.Claims{UserID: user, Name: name, ChatID: chat, MessageID: msg, + Mode: guessgame.ModeUnlimited, Expiry: h.clock.now().Add(guessgame.TokenTTL).Unix()}) + if err != nil { + h.t.Fatal(err) + } + return tok +} + +// uguess posts an unlimited guess and returns the raw response. +func (h *harness) uguess(tok string, seq int, word string) *httpResult { + h.t.Helper() + raw, _ := json.Marshal(map[string]any{"token": tok, "seq": seq, "word": word}) + return newResult(h.t, h.post("guess", string(raw))) +} + +func (h *harness) unew(tok string, seq int) *httpResult { + h.t.Helper() + raw, _ := json.Marshal(map[string]any{"token": tok, "seq": seq}) + return newResult(h.t, h.post("new", string(raw))) +} + +func (h *harness) ustate(tok string) *httpResult { + h.t.Helper() + return newResult(h.t, h.post("state", `{"token":"`+tok+`"}`)) +} + +type httpResult struct { + code int + body string + v view + err string +} + +func newResult(t *testing.T, rec interface { + Result() *http.Response +}) *httpResult { + t.Helper() + res := rec.Result() + defer func() { _ = res.Body.Close() }() + var b strings.Builder + buf := make([]byte, 4096) + for { + n, err := res.Body.Read(buf) + b.Write(buf[:n]) + if err != nil { + break + } + } + r := &httpResult{code: res.StatusCode, body: b.String()} + if r.code == http.StatusOK { + if err := json.Unmarshal([]byte(r.body), &r.v); err != nil { + t.Fatalf("decode %q: %v", r.body, err) + } + } else { + var e struct { + Code string `json:"error"` + } + _ = json.Unmarshal([]byte(r.body), &e) + r.err = e.Code + } + return r +} + +func TestUnlimited_RoundLifecycleNeverLeaksTheAnswer(t *testing.T) { + h := newHarness(t) + tok := h.unlimitedToken(1, groupChat, 50, "Alice") + r := h.ustate(tok) + v := r.v + if r.code != http.StatusOK || v.Mode != "unlimited" || v.Seq != 1 || v.Max != 6 || v.Len != 5 || v.Status != guessgame.StatusPlaying || + v.Num != 0 || v.NextAt != 0 || v.Answer != "" || v.Stats != nil || v.Share != "" || v.Player != "Alice" { + t.Fatalf("fresh round = %d %+v", r.code, v) + } + if strings.Contains(strings.ToLower(r.body), "crane") { + t.Fatalf("state leaks the target: %s", r.body) + } + r = h.uguess(tok, 1, "slate") + if r.code != http.StatusOK || len(r.v.Guesses) != 1 || r.v.Guesses[0].Word != "SLATE" || r.v.Guesses[0].Marks != "wwcwc" { + t.Fatalf("guess = %d %s", r.code, r.body) + } + if strings.Contains(strings.ToLower(r.body), "crane") || strings.Contains(r.body, `"answer"`) || strings.Contains(r.body, `"stats"`) { + t.Fatalf("mid-round answer leaked: %s", r.body) + } + if r = h.uguess(tok, 1, "zzzzz"); r.code != http.StatusUnprocessableEntity || r.err != "unknown" { + t.Fatalf("unknown word = %d %s", r.code, r.body) + } + r = h.uguess(tok, 1, "CRANE") + if r.v.Status != guessgame.StatusWon || r.v.Answer != "CRANE" || r.v.Stats == nil || r.v.Stats.Played != 1 || r.v.Stats.WinPct != 100 || + r.v.Stats.Cur != 1 || r.v.Stats.Dist[1] != 1 || r.v.Share != "" { + t.Fatalf("win = %s", r.body) + } + if r = h.uguess(tok, 1, "slate"); r.code != http.StatusUnprocessableEntity || r.err != "finished" { + t.Fatalf("guess after the win = %d %s", r.code, r.body) + } + // New game, then a double click with the old seq starts nothing more. + if r = h.unew(tok, 1); r.v.Seq != 2 || r.v.Status != guessgame.StatusPlaying || len(r.v.Guesses) != 0 || r.v.Answer != "" { + t.Fatalf("new = %s", r.body) + } + if r = h.unew(tok, 1); r.v.Seq != 2 { + t.Fatalf("stale new = %s", r.body) + } + if r = h.uguess(tok, 1, "slate"); r.code != http.StatusConflict || r.err != "new_round" { + t.Fatalf("stale guess = %d %s", r.code, r.body) + } + // The win is never reported: scores stay a daily leaderboard. + if n := len(h.rep.snapshot()); n != 0 { + t.Fatalf("unlimited win reported %d times", n) + } + // Daily is untouched by unlimited play. + if v := h.state(h.tokenFor(1, groupChat, 7, 0, "Alice")); v.Mode != "daily" || v.Num != 1 || len(v.Guesses) != 0 { + t.Fatalf("daily = %+v", v) + } +} + +func TestUnlimited_NewGameCountsALossOnlyAfterAGuess(t *testing.T) { + h := newHarness(t) + ctx := context.Background() + tok := h.unlimitedToken(1, 1, 50, "Alice") + h.ustate(tok) + if r := h.unew(tok, 1); r.v.Seq != 2 || r.v.Abandoned != "" { + t.Fatalf("new = %s", r.body) + } + if st, _ := h.svc.rounds.LoadStats(ctx, 1); st.Played != 0 { + t.Fatalf("an unguessed round counted: %+v", st) + } + h.uguess(tok, 2, "crane") // round 2 is slate + r := h.unew(tok, 2) + // The page shows the given-up round's word, as /wordle_new does. + if r.v.Seq != 3 || r.v.Abandoned != "SLATE" || r.v.Answer != "" { + t.Fatalf("new = %s", r.body) + } + if st, _ := h.svc.rounds.LoadStats(ctx, 1); st.Played != 1 || st.Wins != 0 || st.CurStreak != 0 { + t.Fatalf("abandoned round = %+v", st) + } +} + +func TestUnlimited_EachPlayerHasTheirOwnRound(t *testing.T) { + h := newHarness(t) + alice := h.unlimitedToken(1, groupChat, 50, "Alice") + bob := h.unlimitedToken(2, groupChat, 50, "Bob") + h.uguess(alice, 1, "slate") + h.uguess(alice, 1, "crane") + if r := h.ustate(bob); r.v.Seq != 1 || len(r.v.Guesses) != 0 || r.v.Status != guessgame.StatusPlaying { + t.Fatalf("bob = %s", r.body) + } + // Nothing about unlimited play reaches the group's daily results. + h.sched.run() + if n := len(sentMethod(h.rb, "sendMessage")); n != 0 { + t.Fatalf("unlimited play posted %d results messages", n) + } +} + +func TestUnlimited_NewOnADailyCardIsRefused(t *testing.T) { + h := newHarness(t) + if r := h.unew(h.dmToken(1), 1); r.code != http.StatusForbidden || r.err != "bad_mode" { + t.Fatalf("new on a daily card = %d %s", r.code, r.body) + } + if r := h.unew(h.unlimitedToken(1, 1, 5, "A"), 0); r.code != http.StatusBadRequest { + t.Fatalf("new without a seq = %d", r.code) + } +} + +func TestClaims_OldTokensDecodeAsDaily(t *testing.T) { + h := newHarness(t) + // A token signed before the mode existed carries no "md". + old, _ := json.Marshal(map[string]any{"u": 1, "c": 5, "m": 9, "e": h.clock.now().Add(guessgame.TokenTTL).Unix()}) + var c guessgame.Claims + if err := json.Unmarshal(old, &c); err != nil || c.Unlimited() || !c.Valid() { + t.Fatalf("old claims = %+v, %v", c, err) + } + if (guessgame.Claims{UserID: 1, InlineID: "x", Expiry: 1, Mode: "x"}).Valid() { + t.Fatal("unknown mode accepted") + } +} + +// lastCard returns the newest recorded unlimited card in chat. +func lastCard(t *testing.T, h *harness, chat int64) int { + t.Helper() + docs, err := storage.Typed[guessgame.CardRecord](h.coll).Scan(context.Background(), "ucard:") + if err != nil { + t.Fatal(err) + } + id := 0 + for _, d := range docs { + if d.Val.ChatID == chat { + id = max(id, d.Val.MessageID) + } + } + if id == 0 { + t.Fatalf("no card recorded in chat %d", chat) + } + return id +} + +func cardCount(t *testing.T, h *harness) int { + t.Helper() + keys, err := storage.Typed[guessgame.CardRecord](h.coll).List(context.Background(), "ucard:") + if err != nil { + t.Fatal(err) + } + return len(keys) +} + +func TestPlay_RecordedWordleCardOpensUnlimitedEverythingElseDaily(t *testing.T) { + h := newHarness(t) + rb := install(t, h.svc.module()) + ctx := context.Background() + rb.Bot.ProcessUpdate(ctx, testutil.NewSupergroupMessage(groupChat, 1, "/wordle")) + games := sentMethod(rb, "sendGame") + if len(games) != 1 || games[0].Form["game_short_name"] != GameShortName || games[0].ChatID() != "-100" { + t.Fatalf("sent %+v", rb.Sent()) + } + card := lastCard(t, h, groupChat) + rb.Bot.ProcessUpdate(ctx, testutil.NewGameCallback(42, groupChat, card, GameShortName)) + if c := decodeURLToken(t, h, answerForm(t, rb)["url"]); !c.Unlimited() || c.ChatID != groupChat || c.MessageID != card { + t.Fatalf("recorded card claims = %+v", c) + } + // /wordledaily cards are not recorded, so they play daily, like a card + // sent before unlimited mode existed and an inline share. + rb.Bot.ProcessUpdate(ctx, testutil.NewSupergroupMessage(groupChat, 1, "/wordledaily")) + if n := cardCount(t, h); n != 1 || len(sentMethod(rb, "sendGame")) != 2 { + t.Fatalf("daily card recorded: %d records", n) + } + rb.Bot.ProcessUpdate(ctx, testutil.NewGameCallback(42, groupChat, card+1, GameShortName)) + if c := decodeURLToken(t, h, answerForm(t, rb)["url"]); c.Unlimited() { + t.Fatalf("old card claims = %+v", c) + } + rb.Bot.ProcessUpdate(ctx, testutil.NewInlineGameCallback(42, "BAAA", GameShortName)) + if c := decodeURLToken(t, h, answerForm(t, rb)["url"]); c.Unlimited() || c.InlineID != "BAAA" { + t.Fatalf("inline claims = %+v", c) + } + // A private chat's /wordle card is recorded too. + rb.Bot.ProcessUpdate(ctx, testutil.NewPrivateMessage(7, "/wordle")) + dm := lastCard(t, h, 7) + rb.Bot.ProcessUpdate(ctx, testutil.NewGameCallback(7, 7, dm, GameShortName)) + if c := decodeURLToken(t, h, answerForm(t, rb)["url"]); !c.Unlimited() { + t.Fatalf("dm card claims = %+v", c) + } +} + +func TestPlay_CardLookupFailureAlertsInsteadOfPlayingDaily(t *testing.T) { + h := newHarness(t) + h.svc.cards = guessgame.NewCards(storage.NewMemoryProvider().Collection("Bad Name!"), h.clock.now, ShortName) + rb := install(t, h.svc.module()) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewGameCallback(42, groupChat, 3, GameShortName)) + if f := answerForm(t, rb); f["text"] != msgCardLookup || f["show_alert"] != "true" || f["url"] != "" { + t.Fatalf("answer = %v", f) + } +} + +func TestCommand_UnrecordedCardIsDeleted(t *testing.T) { + h := newHarness(t) + h.svc.cards = guessgame.NewCards(storage.NewMemoryProvider().Collection("Bad Name!"), h.clock.now, ShortName) + rb := install(t, h.svc.module()) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewSupergroupMessage(groupChat, 1, "/wordle")) + if len(sentMethod(rb, "sendGame")) != 1 || len(sentMethod(rb, "deleteMessage")) != 1 || rb.LastSent().Text() != msgWordleSendFail { + t.Fatalf("sent %+v", rb.Sent()) + } +} + +// send runs one command from user in chat (a group when chat < 0) and +// returns the bot's last text reply. +func send(t *testing.T, rb *testutil.RecordingBot, chat, user int64, text string) string { + t.Helper() + rb.Reset() + var u *models.Update + if chat < 0 { + u = testutil.NewGroupMessage(chat, user, text) + } else { + u = testutil.NewPrivateMessage(user, text) + } + rb.Bot.ProcessUpdate(context.Background(), u) + for i := len(rb.Sent()) - 1; i >= 0; i-- { + if c := rb.Sent()[i]; c.Method == "sendMessage" { + return c.Text() + } + } + return "" +} + +func TestCommand_WordleGuessesPlayTheCallersRound(t *testing.T) { + h := newHarness(t) + rb := install(t, h.svc.module()) + if got := send(t, rb, groupChat, 1, "/wordle slate"); got != "SLATE\n⬜⬜🟩⬜🟩\n\nGuess 1/6." { + t.Fatalf("guess = %q", got) + } + // Bob in the same group plays his own round. + if got := send(t, rb, groupChat, 2, "/wordle crane"); !strings.Contains(got, "🎉 Solved in 1/6! Streak: 1.") { + t.Fatalf("bob = %q", got) + } + for word, want := range map[string]string{"abc": "Word must be exactly 5 letters.", "zzzzz": "Not in the word list.", "!!!": "Please provide a 5-letter word."} { + if got := send(t, rb, groupChat, 1, "/wordle "+word); got != want { + t.Errorf("/wordle %s = %q, want %q", word, got, want) + } + } + // The page and the chat share the round. + h.uguess(h.unlimitedToken(1, groupChat, 50, "Alice"), 1, "abbey") + if got := send(t, rb, groupChat, 1, "/wordle crane"); !strings.Contains(got, "🎉 Solved in 3/6! Streak: 1.") { + t.Fatalf("alice = %q", got) + } + if got := send(t, rb, groupChat, 1, "/wordle crane"); got != "Current round is over. Use /wordle_new to start another. Answer was CRANE." { + t.Fatalf("after the win = %q", got) + } +} + +func TestCommand_WordleWithoutWordSendsTheCardOrShowsTheBoard(t *testing.T) { + h := newHarness(t) + rb := install(t, h.svc.module()) + send(t, rb, 1, 1, "/wordle") + if len(sentMethod(rb, "sendGame")) != 1 { + t.Fatalf("enabled /wordle sent %+v", rb.Sent()) + } + // Without the web game the board is shown in the chat. + svc := newService(config{store: h.coll, now: h.clock.now, answers: testAnswers, dict: testDict, pick: testPick}) + rb = install(t, svc.module()) + if got := send(t, rb, 1, 1, "/wordle"); got != "Guess 0/6. Use `/wordle `.\n\nNo guesses yet. Reply with `/wordle `." { + t.Fatalf("board = %q", got) + } + send(t, rb, 1, 1, "/wordle slate") + if got := send(t, rb, 1, 1, "/wordle"); got != "Guess 1/6. Use `/wordle `.\n\nSLATE\n⬜⬜🟩⬜🟩" { + t.Fatalf("board = %q", got) + } + if len(sentMethod(rb, "sendGame")) != 0 { + t.Fatal("disabled game sent a card") + } +} + +func TestCommand_WordleNewGivesUpAndSendsACard(t *testing.T) { + h := newHarness(t) + rb := install(t, h.svc.module()) + ctx := context.Background() + send(t, rb, groupChat, 1, "/wordle slate") + rb.Reset() + rb.Bot.ProcessUpdate(ctx, testutil.NewGroupMessage(groupChat, 1, "/wordle_new")) + sent := rb.Sent() + if len(sent) != 2 || sent[0].Text() != "🏳️ Previous round abandoned (auto-giveup). Answer was CRANE." || sent[1].Method != "sendGame" { + t.Fatalf("sent %+v", sent) + } + if cardCount(t, h) != 1 { + t.Fatal("/wordle_new card not recorded") + } + st, _ := h.svc.rounds.LoadStats(ctx, 1) + if st.Played != 1 || st.Wins != 0 { + t.Fatalf("stats = %+v", st) + } + // A round nobody guessed on is replaced without a word or a loss. + rb.Reset() + rb.Bot.ProcessUpdate(ctx, testutil.NewGroupMessage(groupChat, 1, "/wordle_new")) + if sent := rb.Sent(); len(sent) != 1 || sent[0].Method != "sendGame" { + t.Fatalf("sent %+v", sent) + } + if st, _ := h.svc.rounds.LoadStats(ctx, 1); st.Played != 1 { + t.Fatalf("stats = %+v", st) + } + // Without the web game it says so in the chat. + svc := newService(config{store: h.coll, now: h.clock.now, answers: testAnswers, dict: testDict, pick: testPick}) + rb = install(t, svc.module()) + if got := send(t, rb, 3, 3, "/wordle_new"); got != msgNewHint { + t.Fatalf("disabled /wordle_new = %q", got) + } +} + +func TestCommand_WordleGiveupAndStats(t *testing.T) { + h := newHarness(t) + rb := install(t, h.svc.module()) + if got := send(t, rb, 1, 1, "/wordle_giveup"); got != "No active round. /wordle_new to start one." { + t.Fatalf("no round = %q", got) + } + if got := send(t, rb, 1, 1, "/wordle_stats"); !strings.Contains(got, "Played: 0\nWins: 0 (0%)") { + t.Fatalf("empty stats = %q", got) + } + send(t, rb, 1, 1, "/wordle slate") + if got := send(t, rb, 1, 1, "/wordle_giveup"); got != "🏳️ Answer was CRANE. /wordle_new for another." { + t.Fatalf("giveup = %q", got) + } + if got := send(t, rb, 1, 1, "/wordle_giveup"); got != "Already gave up — CRANE." { + t.Fatalf("again = %q", got) + } + // Round 2 (slate) is won, round 3 (crane) lost on guesses. + send(t, rb, 1, 1, "/wordle_new") + send(t, rb, 1, 1, "/wordle slate") + if got := send(t, rb, 1, 1, "/wordle_giveup"); got != "Already solved — SLATE." { + t.Fatalf("after a win = %q", got) + } + send(t, rb, 1, 1, "/wordle_new") + for _, w := range []string{"abbey", "slate", "zebra", "pious", "fjord", "night"} { + send(t, rb, 1, 1, "/wordle "+w) + } + if got := send(t, rb, 1, 1, "/wordle_giveup"); got != "Current round is over. Use /wordle_new to start another. Answer was CRANE." { + t.Fatalf("after a loss = %q", got) + } + got := send(t, rb, 1, 1, "/wordle_stats") + if got != "📊 Wordle stats for Test\nPlayed: 3\nWins: 1 (33%)\nCurrent streak: 0\nBest streak: 1" { + t.Fatalf("stats = %q", got) + } + // Each player has their own stats, in groups too. + if got := send(t, rb, groupChat, 2, "/wordle_stats"); !strings.Contains(got, "Played: 0") { + t.Fatalf("other player's stats = %q", got) + } +} + +func TestCommand_WordleInAChannelIsRefused(t *testing.T) { + h := newHarness(t) + rb := install(t, h.svc.module()) + for _, cmd := range []string{"/wordle", "/wordle_new", "/wordle_giveup", "/wordle_stats"} { + rb.Reset() + rb.Bot.ProcessUpdate(context.Background(), testutil.NewChannelMessage(-300, cmd)) + if got := rb.LastSent().Text(); got != msgWordleChannel { + t.Errorf("%s in a channel = %q", cmd, got) + } + } +} + +// Anonymous admins and posts on behalf of a chat arrive from one shared +// stand-in bot id, so they are refused instead of sharing one round. +func TestCommand_WordleRefusesStandInSenders(t *testing.T) { + h := newHarness(t) + rb := install(t, h.svc.module()) + for _, cmd := range []string{"/wordle crane", "/wordle_new", "/wordle_giveup", "/wordle_stats"} { + rb.Reset() + u := testutil.NewGroupMessage(-100, 1087968824, cmd) + u.Message.From.IsBot = true + u.Message.SenderChat = &models.Chat{ID: -100, Type: models.ChatTypeSupergroup} + rb.Bot.ProcessUpdate(context.Background(), u) + if got := rb.LastSent().Text(); got != msgNotYourself { + t.Errorf("%s from an anonymous admin = %q", cmd, got) + } + } +} + +// legacyWordle seeds the retired wordle module's collection. +func legacyWordle(t *testing.T, coll storage.Collection) { + t.Helper() + ctx := context.Background() + stats := storage.Typed[legacyStats](coll) + at := int64(1_700_000_000_000) + for key, st := range map[string]legacyStats{ + "stats:1": {Played: 10, Wins: 7, Streak: 3, BestStreak: 5, LastResultAt: &at}, + "stats:2": {Played: 4, Wins: 1}, + "stats:-100": {Played: 50, Wins: 40, Streak: 9, BestStreak: 9}, + } { + if err := stats.Put(ctx, key, st); err != nil { + t.Fatal(err) + } + } + games := storage.Typed[legacyGame](coll) + game := func(target string, solved, giveup bool, words ...string) legacyGame { + g := legacyGame{Target: target, Guesses: []legacyGuess{}, Solved: solved, Giveup: giveup, StartedAt: 123} + for _, w := range words { + g.Guesses = append(g.Guesses, legacyGuess{Word: w, Results: wordlist.Compare(w, target)}) + } + return g + } + for key, g := range map[string]legacyGame{ + "game:1": game("crane", false, false, "slate", "abbey"), + "game:3": game("crane", true, false, "crane"), + "game:4": game("crane", false, true), + "game:5": game("crane", false, false), + "game:-100": game("crane", false, false, "slate"), + } { + if err := games.Put(ctx, key, g); err != nil { + t.Fatal(err) + } + } +} + +// assertLegacyMigration runs the migration twice over provider and checks +// what it carried over. +func assertLegacyMigration(t *testing.T, provider storage.Provider) { + t.Helper() + ctx := context.Background() + legacy, coll, system := provider.Collection(LegacyShortName), provider.Collection(ShortName), provider.Collection("system") + legacyWordle(t, legacy) + // Player 2 already has unlimited stats: they are kept. + ustats := storage.Typed[guessgame.RoundStats](coll) + if err := ustats.Put(ctx, guessgame.RoundStatsKey(2), guessgame.RoundStats{Played: 1, Wins: 1, Dist: []int{1, 0, 0, 0, 0, 0}, LastSeq: 1}); err != nil { + t.Fatal(err) + } + for range 2 { + if err := InitStore(ctx, legacy, coll, system); err != nil { + t.Fatal(err) + } + } + st, _, err := ustats.Get(ctx, guessgame.RoundStatsKey(1)) + if err != nil || st.Played != 10 || st.Wins != 7 || st.CurStreak != 3 || st.MaxStreak != 5 || len(st.Dist) != 6 || st.LastAt != 1_700_000_000_000 { + t.Fatalf("player 1 stats = %+v, %v", st, err) + } + if st, _, _ := ustats.Get(ctx, guessgame.RoundStatsKey(2)); st.Played != 1 { + t.Fatalf("existing stats overwritten: %+v", st) + } + keys, _ := storage.Typed[guessgame.Round](coll).List(ctx, "u") + if got := strings.Join(keys, " "); got != "uround:1 uround:5 ustats:1 ustats:2" { + t.Fatalf("migrated keys = %s", got) + } + rd, _, err := storage.Typed[guessgame.Round](coll).Get(ctx, guessgame.RoundKey(1)) + if err != nil || rd.Seq != 1 || rd.Target != "crane" || rd.Status != guessgame.StatusPlaying || rd.MaxGuesses != 6 || rd.StartedAt != 123 || + len(rd.Guesses) != 2 || rd.Guesses[0] != (guessgame.Guess{Word: "slate", Marks: "wwcwc"}) { + t.Fatalf("player 1 round = %+v, %v", rd, err) + } + if rd, _, _ := storage.Typed[guessgame.Round](coll).Get(ctx, guessgame.RoundKey(5)); rd.StartedAt != 0 { + t.Fatalf("an unguessed round has a clock: %+v", rd) + } + rec, _, err := storage.Typed[struct { + Status string `json:"status" bson:"status"` + Count int64 `json:"count" bson:"count"` + }](system).Get(ctx, legacyMigrationKey) + if err != nil || rec.Status != "complete" || rec.Count != 3 { + t.Fatalf("marker = %+v, %v", rec, err) + } + // Legacy documents are kept as they were. + if keys, _ := storage.Typed[legacyStats](legacy).List(ctx, ""); len(keys) != 8 { + t.Fatalf("legacy keys = %v", keys) + } +} + +func TestInitStore_CarriesLegacyWordlePlayersOnce(t *testing.T) { + assertLegacyMigration(t, storage.NewMemoryProvider()) +} + +// After the marker, legacy data written later is not carried. +func TestInitStore_CompletedMarkerSkips(t *testing.T) { + p := storage.NewMemoryProvider() + ctx := context.Background() + if err := InitStore(ctx, p.Collection(LegacyShortName), p.Collection(ShortName), p.Collection("system")); err != nil { + t.Fatal(err) + } + legacyWordle(t, p.Collection(LegacyShortName)) + if err := InitStore(ctx, p.Collection(LegacyShortName), p.Collection(ShortName), p.Collection("system")); err != nil { + t.Fatal(err) + } + if keys, _ := storage.Typed[guessgame.Round](p.Collection(ShortName)).List(ctx, ""); len(keys) != 0 { + t.Fatalf("migrated after the marker: %v", keys) + } +} + +// The migrated stats carry on: the next win continues the streak. +func TestInitStore_MigratedStatsContinue(t *testing.T) { + h := newHarness(t) + p := storage.NewMemoryProvider() + legacyWordle(t, p.Collection(LegacyShortName)) + if err := InitStore(context.Background(), p.Collection(LegacyShortName), h.coll, p.Collection("system")); err != nil { + t.Fatal(err) + } + tok := h.unlimitedToken(1, 1, 5, "A") + if r := h.ustate(tok); len(r.v.Guesses) != 2 { + t.Fatalf("migrated round = %s", r.body) + } + r := h.uguess(tok, 1, "crane") + if s := r.v.Stats; s == nil || s.Played != 11 || s.Cur != 4 || s.Max != 5 || s.Dist[2] != 1 || s.WinPct != 73 { + t.Fatalf("stats after the migrated round = %s", r.body) + } +} diff --git a/internal/modules/wordledaily/web/app.css b/internal/modules/wordledaily/web/app.css index 5f6cf4e..56e4f54 100644 --- a/internal/modules/wordledaily/web/app.css +++ b/internal/modules/wordledaily/web/app.css @@ -104,6 +104,8 @@ button:disabled { opacity: .6; cursor: default; } button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); font-weight: 600; } button.icon { min-width: 40px; min-height: 40px; padding: 4px 8px; font-size: 1.15rem; line-height: 1; } button.icon[aria-pressed="true"] { background: var(--key); } +button.new-top { min-height: 40px; padding: 4px 10px; font-size: .85rem; } +button.new-top.confirm { border-color: var(--absent); background: var(--key); } #screen-game { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; gap: 8px; } .board-wrap { flex: 1 1 0; min-height: 0; display: flex; align-items: center; justify-content: center; overflow: hidden; } @@ -217,6 +219,7 @@ button.icon[aria-pressed="true"] { background: var(--key); } .next strong { font-variant-numeric: tabular-nums; } #next-btn { display: block; margin: 0 auto 8px; } .actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; } +.hint { text-align: center; font-size: .85rem; margin: 12px 0 0; } .offscreen { position: fixed; top: -1000px; left: 0; opacity: 0; } .sr-only { position: absolute; diff --git a/internal/modules/wordledaily/web/app.js b/internal/modules/wordledaily/web/app.js index caa8e40..e4b2206 100644 --- a/internal/modules/wordledaily/web/app.js +++ b/internal/modules/wordledaily/web/app.js @@ -1,6 +1,7 @@ -// Wordle Daily page. Plain JavaScript, no build step. The server owns the -// game: this page only sends guesses and renders the state it gets back. It -// never knows the answer until the game is over. +// Wordle page, for both modes the card can open: the daily puzzle and the +// player's unlimited rounds. Plain JavaScript, no build step. The server owns +// the game: this page only sends guesses and renders the state it gets back. +// It never knows the answer until the game is over. (function () { 'use strict'; @@ -8,9 +9,10 @@ * @typedef {{word: string, marks: string}} GuessView * @typedef {{played: number, win_pct: number, cur: number, max: number, dist: number[]}} StatsView * @typedef {{ - * num: number, date: string, player: string, max: number, len: number, - * guesses: GuessView[], status: 'playing'|'won'|'lost', answer?: string, - * stats?: StatsView, next_at: number, share?: string + * mode: 'daily'|'unlimited', num?: number, date?: string, seq?: number, + * player: string, max: number, len: number, guesses: GuessView[], + * status: 'playing'|'won'|'lost', gave_up?: boolean, answer?: string, + * stats?: StatsView, next_at?: number, share?: string, abandoned?: string * }} View * @typedef {{code: string, message: string}} ApiError */ @@ -31,6 +33,8 @@ var MAX_TILE = 62; var MIN_TILE = 24; var WIN_WORDS = ['Genius', 'Magnificent', 'Impressive', 'Splendid', 'Great', 'Phew']; + /** How long the header's New game waits for the second tap. */ + var CONFIRM_MS = 4000; /** @param {string} id @returns {HTMLElement} */ function $(id) { @@ -45,6 +49,7 @@ var endBox = $('end'); var statsBtn = $('stats-btn'); var contrastBtn = $('contrast-btn'); + var newTop = /** @type {HTMLButtonElement} */ ($('new-top')); /** @type {View|null} */ var view = null; @@ -52,6 +57,7 @@ var busy = false; var toastTimer = 0; var countdownTimer = 0; + var confirmTimer = 0; /** The row whose marks are being revealed, and when that ends. */ var revealRow = -1; var revealUntil = 0; @@ -190,6 +196,11 @@ /** @param {number} r @returns {HTMLElement} */ function rowEl(r) { return /** @type {HTMLElement} */ (board.children[r]); } + /** @param {View} v @returns {string} which board v is: a puzzle or a round */ + function boardKey(v) { + return v.mode === 'unlimited' ? 'u' + v.seq : 'd' + v.num; + } + /** * Paints the board, the typed word and the keyboard colours. The row * being revealed keeps its flip classes until the reveal ends, so typing @@ -198,8 +209,13 @@ */ function render() { if (!view) return; - $('num').textContent = '#' + view.num; - $('subtitle').textContent = view.date + (view.player ? ' · ' + view.player : ''); + var daily = view.mode !== 'unlimited'; + $('title').textContent = daily ? 'Wordle Daily' : 'Wordle'; + $('num').textContent = daily ? '#' + view.num : ''; + var where = daily ? view.date : 'Unlimited · Round ' + view.seq; + $('subtitle').textContent = where + (view.player ? ' · ' + view.player : ''); + newTop.hidden = daily; + resetConfirm(); var revealing = Date.now() < revealUntil; var best = {}; for (var r = 0; r < ROWS; r++) { @@ -258,7 +274,7 @@ * @param {boolean} fromGuess v answers a guess, so its new row flips */ function apply(v, fromGuess) { - if (view && view.num !== v.num) typed = ''; + if (view && boardKey(view) !== boardKey(v)) typed = ''; var fresh = fromGuess && v.status !== 'playing'; revealRow = -1; revealUntil = 0; @@ -284,7 +300,9 @@ function showEnd() { if (!view || !view.stats) return; var won = view.status === 'won'; - $('end-title').textContent = won ? 'Solved in ' + view.guesses.length + '/' + view.max + '!' : 'Better luck tomorrow'; + var daily = view.mode !== 'unlimited'; + var lost = daily ? 'Better luck tomorrow' : view.gave_up ? 'Round given up' : 'Better luck next round'; + $('end-title').textContent = won ? 'Solved in ' + view.guesses.length + '/' + view.max + '!' : lost; $('end-answer').textContent = view.answer || ''; var st = view.stats; $('st-played').textContent = String(st.played); @@ -308,11 +326,15 @@ li.appendChild(bar); dist.appendChild(li); }); - updateShare(); + $('end-daily').hidden = !daily; + $('end-unlimited').hidden = daily; endBox.hidden = false; - tick(); clearInterval(countdownTimer); - countdownTimer = window.setInterval(tick, 1000); + if (daily) { + updateShare(); + tick(); + countdownTimer = window.setInterval(tick, 1000); + } } /** @@ -325,7 +347,7 @@ /** Counts down to the next puzzle; at zero it offers to load it. */ function tick() { - if (!view) return; + if (!view || !view.next_at) return; var left = Math.max(0, Math.floor(view.next_at - Date.now() / 1000)); var h = Math.floor(left / 3600); var m = Math.floor(left % 3600 / 60); @@ -349,7 +371,8 @@ function fail(err) { switch (err.code) { case 'new_puzzle': - say('A new puzzle has started.', 2500); + case 'new_round': + say(err.message, 2500); load(); return; case 'finished': @@ -392,7 +415,9 @@ return; } busy = true; - api('guess', { token: token, num: view.num, word: typed }).then(function (v) { + var body = { token: token, word: typed }; + if (view.mode === 'unlimited') body.seq = view.seq; else body.num = view.num; + api('guess', body).then(function (v) { busy = false; typed = ''; apply(v, true); @@ -402,6 +427,43 @@ }); } + /** Starts the next unlimited round, giving up this one. */ + function newGame() { + if (busy || !view || view.mode !== 'unlimited') return; + busy = true; + resetConfirm(); + api('new', { token: token, seq: view.seq }).then(function (v) { + busy = false; + typed = ''; + endBox.hidden = true; + apply(v, false); + if (v.abandoned) say('Round given up. The word was ' + v.abandoned + '.', 4000); + }, function (err) { + busy = false; + fail(err); + }); + } + + /** The header button asks once before giving up a round with guesses. */ + function resetConfirm() { + clearTimeout(confirmTimer); + newTop.classList.remove('confirm'); + newTop.textContent = 'New game'; + } + + newTop.addEventListener('click', function () { + if (!view) return; + var risky = view.status === 'playing' && view.guesses.length > 0; + if (risky && !newTop.classList.contains('confirm')) { + newTop.classList.add('confirm'); + newTop.textContent = 'Give up this round?'; + clearTimeout(confirmTimer); + confirmTimer = window.setTimeout(resetConfirm, CONFIRM_MS); + return; + } + newGame(); + }); + /** @param {string} key a letter, 'enter' or 'back' */ function press(key) { if (busy || !view || view.status !== 'playing') return; @@ -477,6 +539,7 @@ $('copy-btn').addEventListener('click', function () { if (view && view.share) copy(view.share); }); + $('new-end').addEventListener('click', newGame); $('next-btn').addEventListener('click', function () { endBox.hidden = true; load(); diff --git a/internal/modules/wordledaily/web/index.html b/internal/modules/wordledaily/web/index.html index 55f6a36..54f1b8f 100644 --- a/internal/modules/wordledaily/web/index.html +++ b/internal/modules/wordledaily/web/index.html @@ -5,17 +5,18 @@ -Wordle Daily +Wordle
-

Wordle Daily

+

Wordle

+
@@ -23,7 +24,7 @@ diff --git a/internal/modules/wordledaily/wordledaily.go b/internal/modules/wordledaily/wordledaily.go index 2a949c2..bfbbe71 100644 --- a/internal/modules/wordledaily/wordledaily.go +++ b/internal/modules/wordledaily/wordledaily.go @@ -1,38 +1,44 @@ -// Package wordledaily is the Wordle Daily Telegram HTML5 game: one shared -// English five-letter puzzle a day, like NYT Wordle. /wordledaily sends the -// BotFather game; Play opens a board page this bot serves. A new puzzle -// starts every day at 07:00 ICT (00:00 UTC). +// Package wordledaily is the Wordle Telegram HTML5 game in both of its +// modes, behind the one BotFather game "wordle": // -// The server owns the game. The page only sends guesses; the answer, the -// colours, each player's progress and stats live server-side, and the answer -// reaches the page only once that player's game is over. In a group the bot -// keeps one live results message per chat and day with spoiler-free colour -// grids, and subscribed chats get the day's card plus yesterday's group -// results at 07:00 ICT. See docs/wordledaily.md. +// - Wordle Daily: one shared English five-letter puzzle a day, like NYT +// Wordle. /wordledaily sends the card; a new puzzle starts every day at +// 07:00 ICT (00:00 UTC). In a group the bot keeps one live results +// message per chat and day with spoiler-free colour grids, and +// subscribed chats get the day's card plus yesterday's group results +// at 07:00 ICT. +// - Unlimited: /wordle sends a card that opens the player's own round +// with a random word; the page's New game button starts another. +// /wordle , /wordle_new, /wordle_giveup and /wordle_stats play +// and read the same round from the chat. +// +// Play picks the mode from the card: /wordle cards are recorded, and any +// other card plays daily. The server owns the game. The page only sends +// guesses; the answer, the colours, each player's progress and stats live +// server-side, and the answer reaches the page only once that game is over. +// See docs/wordledaily.md and docs/wordle.md. package wordledaily import ( - "context" + "math/rand/v2" "os" - "sync" "time" - "github.com/go-telegram/bot" - "github.com/go-telegram/bot/models" - - "github.com/tiennm99/tiennm99bot/internal/keylock" "github.com/tiennm99/tiennm99bot/internal/log" "github.com/tiennm99/tiennm99bot/internal/modules" + "github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame" "github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame" - "github.com/tiennm99/tiennm99bot/internal/modules/util/subscription" "github.com/tiennm99/tiennm99bot/internal/modules/wordle/wordlist" "github.com/tiennm99/tiennm99bot/internal/storage" ) const ( - // ShortName is the module's catalog key and command name, so its routes - // live under /games/wordledaily/. + // ShortName is the module's catalog key and the daily command name, so + // its routes live under /games/wordledaily/. ShortName = "wordledaily" + // LegacyShortName is the catalog key of the retired in-chat wordle + // module, whose commands and data this module took over. + LegacyShortName = "wordle" // GameShortName is the BotFather game the cards send and Play answers. GameShortName = "wordle" @@ -50,43 +56,37 @@ const ( answerKeyLabel = "tiennm99bot/wordledaily/answer/v1" //nolint:gosec // G101: a public derivation label routePrefix = "/games/" + ShortName + "/" + + // maxGuesses is the guess budget of both modes. + maxGuesses = 6 + + // dailyPushCronName and cardsCronName must be unique across all + // modules' crons. + dailyPushCronName = "wordledaily_daily_push" + cardsCronName = "wordle_unlimited_cards" + // cardsSchedule runs the unlimited card cleanup at 03:35 ICT. + cardsSchedule = "35 20 * * *" ) -// telegramAPI is the subset of *bot.Bot the summary and the daily push use. -type telegramAPI interface { - SendMessage(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error) - EditMessageText(ctx context.Context, params *bot.EditMessageTextParams) (*models.Message, error) - SendGame(ctx context.Context, params *bot.SendGameParams) (*models.Message, error) -} - -// scoreReporter records a won game's score on the card it was played from. -type scoreReporter interface { - Report(ctx context.Context, a htmlgame.Address, userID int64, score int) error -} - -type botReporter struct{ b *bot.Bot } - -func (r botReporter) Report(ctx context.Context, a htmlgame.Address, userID int64, score int) error { - return htmlgame.ReportScore(ctx, r.b, a, userID, score) -} - // config is everything New reads from the environment, injectable in tests. type config struct { baseURL string // public https base without trailing slash; "" disables rootKey []byte // derives the token and answer keys; empty disables now func() time.Time store storage.Collection // nil disables - reporter scoreReporter // nil disables - api telegramAPI // nil skips the live group summary + reporter guessgame.ScoreReporter // nil disables + api guessgame.TelegramAPI // nil skips the live group summary schedule func(d time.Duration, f func()) // runs the summary flush; time.AfterFunc by default async func(f func()) // runs a score report; a goroutine by default answers []string dict map[string]struct{} - epoch time.Time // puzzle #1 starts here; zero means the package epoch + epoch time.Time // puzzle #1 starts here; zero means the shared epoch + pick func(prev string) string // an unlimited round's word; random from answers by default } -// New builds the module from the environment. The game is enabled only when -// GAME_BASE_URL is a valid https URL, a key is available and storage exists. +// New builds the module from the environment. The web game is enabled only +// when GAME_BASE_URL is a valid https URL, a key is available and storage +// exists; the in-chat /wordle commands need storage only. func New(deps modules.Deps) modules.Module { _, dict := wordlist.Load() cfg := config{ @@ -99,7 +99,7 @@ func New(deps modules.Deps) modules.Module { dict: dict, } if deps.Bot != nil { - cfg.reporter = botReporter{b: deps.Bot} + cfg.reporter = guessgame.BotReporter{B: deps.Bot} cfg.api = deps.Bot } return newService(cfg).module() @@ -122,87 +122,128 @@ func rootKey(secret string, deps modules.Deps) []byte { return []byte(deps.Bot.Token()) } -// service is the module's runtime state. +// service is the module's runtime state. The daily mode is embedded, so +// its stores and methods read as the service's own. type service struct { + *guessgame.Daily cfg config tokenKey []byte answerKey []byte - epoch time.Time - - puzzles storage.DocStore[puzzleDoc] - plays storage.DocStore[progress] - stats storage.DocStore[userStats] - chatDays storage.DocStore[chatDay] - streaks storage.DocStore[chatStreak] - subscribers subscription.Store - pushDate subscription.DayStore - - subscribersMu sync.Mutex - locks keylock.Map - limiter *htmlgame.Limiter - perms permCache - cache puzzleCache - flush flusher + rules rules + rounds *guessgame.Rounds // nil without storage + cards *guessgame.Cards // nil without storage + limiter *htmlgame.Limiter } func newService(cfg config) *service { if cfg.now == nil { cfg.now = time.Now } - if cfg.schedule == nil { - cfg.schedule = func(d time.Duration, f func()) { time.AfterFunc(d, f) } + if cfg.pick == nil { + cfg.pick = randomPick(cfg.answers) } svc := &service{ cfg: cfg, tokenKey: htmlgame.DeriveKey(cfg.rootKey, tokenKeyLabel), answerKey: htmlgame.DeriveKey(cfg.rootKey, answerKeyLabel), - epoch: epoch, + rules: rules{dict: cfg.dict}, limiter: &htmlgame.Limiter{Per: requestsPerMinute, Window: time.Minute}, } - if !cfg.epoch.IsZero() { - svc.epoch = cfg.epoch - } - if cfg.store != nil { - svc.puzzles = storage.Typed[puzzleDoc](cfg.store) - svc.plays = storage.Typed[progress](cfg.store) - svc.stats = storage.Typed[userStats](cfg.store) - svc.chatDays = storage.Typed[chatDay](cfg.store) - svc.streaks = storage.Typed[chatStreak](cfg.store) - svc.subscribers = storage.Typed[subscription.Doc](cfg.store) - svc.pushDate = storage.Typed[subscription.DayDoc](cfg.store) + svc.Daily = guessgame.NewDaily(guessgame.DailyConfig{ + Rules: svc.rules, + Answers: cfg.answers, + AnswerKey: svc.answerKey, + Store: cfg.store, + Epoch: cfg.epoch, + Now: cfg.now, + Schedule: cfg.schedule, + Async: cfg.async, + Reporter: cfg.reporter, + API: cfg.api, + GameShortName: GameShortName, + LogName: ShortName, + }) + if len(cfg.answers) > 0 { + svc.rounds = guessgame.NewRounds(guessgame.RoundsConfig{ + Store: cfg.store, + Rules: svc.rules, + Pick: cfg.pick, + DistLen: maxGuesses, + Now: cfg.now, + }) } + svc.cards = guessgame.NewCards(cfg.store, cfg.now, ShortName) if svc.enabled() { log.Info("wordledaily game enabled", "answers", len(cfg.answers), "words", len(cfg.dict)) } return svc } +// randomPick draws an unlimited round's word from answers, never the word +// of the round it replaces. +func randomPick(answers []string) func(prev string) string { + return func(prev string) string { + for { + w := answers[rand.IntN(len(answers))] + if w != prev || len(answers) < 2 { + return w + } + } + } +} + // enabled reports whether Play can open the game. func (s *service) enabled() bool { return s.cfg.baseURL != "" && len(s.tokenKey) > 0 && len(s.answerKey) > 0 && s.cfg.store != nil && s.cfg.reporter != nil && len(s.cfg.answers) > 0 && len(s.cfg.dict) > 0 } -// module describes the service to the registry. Routes and the daily push -// exist only when the game is enabled, so a disabled game exposes nothing -// over HTTP and pushes nothing. +// module describes the service to the registry. Routes and crons exist only +// when the game is enabled, so a disabled game exposes nothing over HTTP and +// pushes nothing. func (s *service) module() modules.Module { + daily := guessgame.DailyCommands{ + Daily: s.Daily, + Enabled: s.enabled, + Texts: guessgame.DailyTexts{Channel: msgChannel, Disabled: msgDisabled, SendFail: msgSendGameFail}, + } mod := modules.Module{ Commands: []modules.Command{{ + Name: LegacyShortName, + Visibility: modules.VisibilityPublic, + Description: "Play unlimited Wordle; with a word, guess in your round", + Parameters: "[word]", + Handler: s.handleWordle, + }, { + Name: "wordle_new", + Visibility: modules.VisibilityPublic, + Description: "Start a new unlimited Wordle round (gives up the current one)", + Handler: s.handleNew, + }, { + Name: "wordle_giveup", + Visibility: modules.VisibilityPublic, + Description: "Reveal the answer of your unlimited Wordle round", + Handler: s.handleGiveup, + }, { + Name: "wordle_stats", + Visibility: modules.VisibilityPublic, + Description: "Show your unlimited Wordle stats", + Handler: s.handleStats, + }, { Name: ShortName, Visibility: modules.VisibilityPublic, Description: "Play today's Wordle Daily puzzle", - Handler: s.handleCommand, + Handler: daily.HandleCard, }, { Name: subscribeCommand, Visibility: modules.VisibilityPublic, Description: "Get the Wordle Daily puzzle and group results at 07:00 ICT", - Handler: s.handleSubscribe, + Handler: daily.HandleSubscribe, }, { Name: unsubscribeCommand, Visibility: modules.VisibilityPublic, Description: "Stop the daily Wordle Daily push", - Handler: s.handleUnsubscribe, + Handler: daily.HandleUnsubscribe, }}, Games: []modules.Game{{ ShortName: GameShortName, @@ -212,7 +253,10 @@ func (s *service) module() modules.Module { } if s.enabled() { mod.HTTP = []modules.Route{{Pattern: routePrefix, Handler: s.handler()}} - mod.Crons = []modules.Cron{{Name: dailyPushCronName, Schedule: dailyPushSchedule, Handler: s.dailyPushHandler}} + mod.Crons = []modules.Cron{ + {Name: dailyPushCronName, Schedule: guessgame.DailyPushSchedule, Handler: s.PushHandler}, + {Name: cardsCronName, Schedule: cardsSchedule, Handler: s.cards.Cleanup}, + } } return mod }