mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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.
This commit is contained in:
1 parent
bc8a7d8869
commit
629b513c91
80 files changed
+7965
-3099
No files matched your search
+10
-2
@@ -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
|
||||
# <GAME_BASE_URL>/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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -71,6 +71,8 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
|
||||
"gacha": "<option,...>",
|
||||
"lol": "[date]",
|
||||
"loldle": "[champion]",
|
||||
"loldle_giveup": "",
|
||||
"loldle_stats": "",
|
||||
"monkeyd_crawl": "<url> [font_size]",
|
||||
"monkeyd_tags": "<url>",
|
||||
"noitu": "",
|
||||
@@ -107,10 +109,17 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
|
||||
"tth": "[target...]",
|
||||
"wheelofnames": "<option,...>",
|
||||
"wordle": "[word]",
|
||||
"wordle_new": "",
|
||||
"wordle_giveup": "",
|
||||
"wordle_stats": "",
|
||||
|
||||
"wordledaily": "",
|
||||
"wordledaily_subscribe": "",
|
||||
"wordledaily_unsubscribe": "",
|
||||
|
||||
"loldledaily": "",
|
||||
"loldledaily_subscribe": "",
|
||||
"loldledaily_unsubscribe": "",
|
||||
}
|
||||
|
||||
menu := botCommandMenu(reg)
|
||||
|
||||
+39
-4
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-1
@@ -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_<bot username>
|
||||
# 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) ---
|
||||
|
||||
@@ -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_<bot username>`. 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
|
||||
|
||||
+235
@@ -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 <champion>` | 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 <champion>` 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 `<GAME_BASE_URL>/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/<id>_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:<chat>:<message>`, 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:<n>`, 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:<chat>` | the chat's `/loldle_setmax` value |
|
||||
| `uround:<user>`, `ustats:<user>`, `ucard:<chat>:<message>` | unlimited rounds, stats and cards |
|
||||
| `puzzle:<n>`, `play:<n>:<user>`, `dstats:<user>`, `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:<subject>`, `stats:<subject>` | 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:<user>` becomes the player's unlimited stats (`streak` → current,
|
||||
`bestStreak` → best); the distribution starts at zero.
|
||||
- `game:<user>`, 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.
|
||||
+140
@@ -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 <word>` | 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:<chat>:<message>`
|
||||
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:<user>` 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:<user>` 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:<user>` | the player's current round: number, word, guesses with marks, status, whether they gave up, guess budget, start and finish time |
|
||||
| `ustats:<user>` | played, wins, current and best streak, distribution, last finish, last round counted |
|
||||
| `ucard:<chat>:<message>` | 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).
|
||||
+41
-12
@@ -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/<bot>?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:<chat>:<thread>:` | 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
|
||||
|
||||
@@ -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)<script\b([^>]*)>(.*?)</script>`)
|
||||
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), "<style") || inlineHandlerRe.Match(index) {
|
||||
t.Error("inline style or handler breaks the CSP")
|
||||
}
|
||||
js, _ := web.FS.ReadFile("app.js")
|
||||
for _, u := range urlRe.FindAllString(string(js), -1) {
|
||||
if !strings.HasPrefix(u, ddragonOrigin+"/") {
|
||||
t.Errorf("app.js reaches %s", u)
|
||||
}
|
||||
}
|
||||
// No champion attributes ship to the page.
|
||||
for _, leak := range []string{"Vastayan", "Manaless", "Shurima", "release_date"} {
|
||||
if strings.Contains(string(js), leak) {
|
||||
t.Errorf("app.js carries %q", leak)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The board's column labels are small text, so they need WCAG AA's 4.5:1
|
||||
// against the header background.
|
||||
func TestWebAssets_ColumnLabelsMeetAAContrast(t *testing.T) {
|
||||
css, err := web.FS.ReadFile("app.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tokens := map[string]string{}
|
||||
for _, m := range regexp.MustCompile(`(--[a-z-]+):\s*(#[0-9a-fA-F]{6});`).FindAllStringSubmatch(string(css), -1) {
|
||||
tokens[m[1]] = m[2]
|
||||
}
|
||||
rule := regexp.MustCompile(`(?s)\.head \.cell \{(.*?)\}`).FindStringSubmatch(string(css))
|
||||
if rule == nil {
|
||||
t.Fatal("no .head .cell rule")
|
||||
}
|
||||
prop := func(name string) string {
|
||||
m := regexp.MustCompile(`(?m)^\s*` + name + `:\s*var\((--[a-z-]+)\);`).FindStringSubmatch(rule[1])
|
||||
if m == nil || tokens[m[1]] == "" {
|
||||
t.Fatalf(".head .cell %s is not a colour token", name)
|
||||
}
|
||||
return tokens[m[1]]
|
||||
}
|
||||
fg, bg := prop("color"), prop("background")
|
||||
if r := contrast(fg, bg); r < 4.5 {
|
||||
t.Errorf("column labels %s on %s = %.2f:1, want at least 4.5:1", fg, bg, r)
|
||||
}
|
||||
}
|
||||
|
||||
// contrast is the WCAG 2 contrast ratio of two #rrggbb colours.
|
||||
func contrast(a, b string) float64 {
|
||||
lum := func(hex string) float64 {
|
||||
var ch [3]float64
|
||||
for i := range ch {
|
||||
var v int
|
||||
_, _ = fmt.Sscanf(hex[1+2*i:3+2*i], "%02x", &v)
|
||||
c := float64(v) / 255
|
||||
if c <= 0.03928 {
|
||||
ch[i] = c / 12.92
|
||||
} else {
|
||||
ch[i] = math.Pow((c+0.055)/1.055, 2.4)
|
||||
}
|
||||
}
|
||||
return 0.2126*ch[0] + 0.7152*ch[1] + 0.0722*ch[2]
|
||||
}
|
||||
la, lb := lum(a), lum(b)
|
||||
return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
|
||||
}
|
||||
|
||||
func TestAPI_UnlimitedRoundNeverLeaksTheAnswer(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok := h.token(1, groupChat, 5, "Alice", true)
|
||||
r := h.state(tok)
|
||||
if r.code != http.StatusOK || r.v.Mode != "unlimited" || r.v.Seq != 1 || r.v.Max != 8 || len(r.v.Columns) != 7 || r.v.Answer != nil || r.v.Stats != nil {
|
||||
t.Fatalf("fresh = %s", r.body)
|
||||
}
|
||||
for _, name := range []string{"Akali", "Wukong", "Kai'Sa"} {
|
||||
r = h.roundGuess(tok, 1, name)
|
||||
if r.code != http.StatusOK || strings.Contains(r.body, "Ahri") || strings.Contains(r.body, `"answer"`) {
|
||||
t.Fatalf("guess %s leaked or failed: %d %s", name, r.code, r.body)
|
||||
}
|
||||
}
|
||||
row := r.v.Guesses[2]
|
||||
if row.Name != "Kai'Sa" || row.ID != "Kaisa" || row.Marks != "cwccwwd" || len(row.Cells) != 7 {
|
||||
t.Fatalf("row = %+v", row)
|
||||
}
|
||||
// Cells show the guess's own values only.
|
||||
if c := row.Cells[4]; c.Key != "regions" || c.Value != "Shurima, Void" || c.Result != "wrong" {
|
||||
t.Fatalf("regions cell = %+v", c)
|
||||
}
|
||||
if c := row.Cells[6]; c.Value != "2018" || c.Dir != "down" || c.Result != "wrong" {
|
||||
t.Fatalf("year cell = %+v", c)
|
||||
}
|
||||
for name, code := range map[string]string{"zzz": "unknown", "ka": "ambiguous", "akali": "duplicate"} {
|
||||
if r := h.roundGuess(tok, 1, name); r.code != http.StatusUnprocessableEntity || r.err != code {
|
||||
t.Errorf("%s = %d %s", name, r.code, r.body)
|
||||
}
|
||||
}
|
||||
r = h.roundGuess(tok, 1, "ahri")
|
||||
if r.v.Status != "won" || r.v.Answer == nil || r.v.Answer.Name != "Ahri" || r.v.Answer.ID != "Ahri" || r.v.Answer.Title == "" ||
|
||||
r.v.Stats == nil || r.v.Stats.Played != 1 || len(r.v.Stats.Dist) != MaxGuessesCap || r.v.Stats.Dist[3] != 1 || r.v.Share != "" {
|
||||
t.Fatalf("win = %s", r.body)
|
||||
}
|
||||
if r = h.post("new", map[string]any{"token": tok, "seq": 1}); r.v.Seq != 2 || len(r.v.Guesses) != 0 || r.v.Abandoned != nil {
|
||||
t.Fatalf("new = %s", r.body)
|
||||
}
|
||||
// Giving up a round with guesses shows its champion before the next one.
|
||||
h.roundGuess(tok, 2, "Akali") // round 2 is Jinx
|
||||
if r = h.post("new", map[string]any{"token": tok, "seq": 2}); r.v.Seq != 3 || r.v.Answer != nil ||
|
||||
r.v.Abandoned == nil || r.v.Abandoned.Name != "Jinx" || r.v.Abandoned.ID != "Jinx" || r.v.Abandoned.Title == "" {
|
||||
t.Fatalf("abandon = %s", r.body)
|
||||
}
|
||||
if r = h.roundGuess(tok, 1, "akali"); r.code != http.StatusConflict || r.err != "new_round" {
|
||||
t.Fatalf("stale = %d %s", r.code, r.body)
|
||||
}
|
||||
if n := len(h.rep.snapshot()); n != 0 {
|
||||
t.Fatalf("unlimited win reported %d times", n)
|
||||
}
|
||||
if r = h.post("new", map[string]any{"token": h.token(1, groupChat, 5, "Alice", false), "seq": 1}); r.code != http.StatusForbidden || r.err != "bad_mode" {
|
||||
t.Fatalf("new on a daily card = %d %s", r.code, r.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaily_SameChampionForEveryoneAndHiddenUntilFinished(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.pinDaily(1, "Ahri")
|
||||
alice := h.token(1, groupChat, 7, "Alice", false)
|
||||
r := h.state(alice)
|
||||
if r.v.Mode != "daily" || r.v.Num != 1 || r.v.Date != "2026-10-09" || r.v.Max != 8 || r.v.NextAt != guessgame.Epoch.Add(24*time.Hour).Unix() {
|
||||
t.Fatalf("daily = %s", r.body)
|
||||
}
|
||||
r = h.dailyGuess(alice, 1, "Akali")
|
||||
if strings.Contains(r.body, "Ahri") || r.v.Guesses[0].Marks != "cwwwccu" {
|
||||
t.Fatalf("guess leaked or wrong: %s", r.body)
|
||||
}
|
||||
r = h.dailyGuess(alice, 1, "Ahri")
|
||||
if r.v.Status != "won" || r.v.Answer.Name != "Ahri" || r.v.Stats.Played != 1 || len(r.v.Stats.Dist) != 8 || r.v.Stats.Dist[1] != 1 ||
|
||||
r.v.Share != "LoLdle Daily #1 2/8\n\n🟩🟥🟥🟥🟩🟩🟥\n🟩🟩🟩🟩🟩🟩🟩" {
|
||||
t.Fatalf("win = %s", r.body)
|
||||
}
|
||||
if calls := h.rep.snapshot(); len(calls) != 1 || calls[0].score != 7 || calls[0].addr.ChatID != groupChat {
|
||||
t.Fatalf("score = %+v, want 9-2", calls)
|
||||
}
|
||||
if r := h.dailyGuess(alice, 1, "Jinx"); r.code != http.StatusUnprocessableEntity || r.err != "finished" {
|
||||
t.Fatalf("after the win = %d %s", r.code, r.body)
|
||||
}
|
||||
// Bob gets the same champion.
|
||||
bob := h.token(2, 2, 3, "Bob", false)
|
||||
if r := h.dailyGuess(bob, 1, "Ahri"); r.v.Status != "won" {
|
||||
t.Fatalf("bob = %s", r.body)
|
||||
}
|
||||
// At 07:00 ICT the next champion starts; a stale board is refused.
|
||||
h.clock.advance(12 * time.Hour)
|
||||
bob = h.token(2, 2, 3, "Bob", false)
|
||||
if r := h.dailyGuess(bob, 1, "Ahri"); r.code != http.StatusConflict || r.err != "new_puzzle" {
|
||||
t.Fatalf("stale = %d %s", r.code, r.body)
|
||||
}
|
||||
if r := h.state(bob); r.v.Num != 2 || len(r.v.Guesses) != 0 || r.v.Date != "2026-10-10" {
|
||||
t.Fatalf("day 2 = %s", r.body)
|
||||
}
|
||||
// Daily stats live under their own keys, apart from the legacy stats.
|
||||
if keys, _ := h.svc.Stats.List(context.Background(), "dstats:"); len(keys) != 2 {
|
||||
t.Fatalf("daily stats keys = %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
// A champion pinned as a day's answer and later removed from the data keeps
|
||||
// the day playable: guesses score wrong in every column, naming the answer
|
||||
// wins, and the board finishes instead of failing every guess.
|
||||
func TestDaily_PinnedAnswerMissingFromDataStaysPlayable(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.pinDaily(1, "Retired Champ")
|
||||
alice := h.token(1, groupChat, 7, "Alice", false)
|
||||
r := h.dailyGuess(alice, 1, "Akali")
|
||||
if r.code != http.StatusOK || len(r.v.Guesses) != 1 || r.v.Guesses[0].Marks != "wwwwwww" || r.v.Answer != nil {
|
||||
t.Fatalf("guess = %d %s", r.code, r.body)
|
||||
}
|
||||
for name, code := range map[string]string{"zzz": "unknown", "ka": "ambiguous", "akali": "duplicate"} {
|
||||
if r := h.dailyGuess(alice, 1, name); r.code != http.StatusUnprocessableEntity || r.err != code {
|
||||
t.Errorf("%s = %d %s", name, r.code, r.body)
|
||||
}
|
||||
}
|
||||
r = h.dailyGuess(alice, 1, "retired champ")
|
||||
if r.code != http.StatusOK || r.v.Status != "won" || r.v.Answer == nil || r.v.Answer.Name != "Retired Champ" {
|
||||
t.Fatalf("win = %d %s", r.code, r.body)
|
||||
}
|
||||
bob := h.token(2, 2, 3, "Bob", false)
|
||||
for i, name := range []string{"Ahri", "Akali", "Jinx", "Wukong", "Kai'Sa", "Lux", "Garen", "Teemo"} {
|
||||
r = h.dailyGuess(bob, 1, name)
|
||||
if r.code != http.StatusOK {
|
||||
t.Fatalf("bob guess %d = %d %s", i, r.code, r.body)
|
||||
}
|
||||
}
|
||||
if r.v.Status != "lost" || r.v.Answer == nil || r.v.Answer.Name != "Retired Champ" {
|
||||
t.Fatalf("loss = %s", r.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaily_AnswersAreKeyedChampionsWithoutRepeats(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
n := len(loadChampions())
|
||||
seen := map[string]bool{}
|
||||
for num := 1; num <= n; num++ {
|
||||
a := h.svc.AnswerFor(num)
|
||||
if seen[a] || findChampionByExactName(h.svc.cfg.champions, a) == nil {
|
||||
t.Fatalf("puzzle %d answer %q repeats or is unknown", num, a)
|
||||
}
|
||||
seen[a] = true
|
||||
}
|
||||
other := testConfig(h.clock, h.coll, h.rep, nil, h.sched)
|
||||
other.rootKey = []byte("another-root-key-another-root-key")
|
||||
same := 0
|
||||
for num := 1; num <= 10; num++ {
|
||||
if newService(other).AnswerFor(num) == h.svc.AnswerFor(num) {
|
||||
same++
|
||||
}
|
||||
}
|
||||
if same == 10 {
|
||||
t.Fatal("the answer order ignores the key")
|
||||
}
|
||||
// The first resolution pins the day: a later key or data change keeps it.
|
||||
a, err := h.svc.ResolvePuzzle(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := newService(other).ResolvePuzzle(context.Background(), 1); got != a {
|
||||
t.Fatalf("pinned %q, then %q", a, got)
|
||||
}
|
||||
}
|
||||
|
||||
// tokenFor-style helper with a thread; daily tests need topics.
|
||||
func (h *harness) tokenTopic(user, chat int64, msg, thread int, name string) string {
|
||||
h.t.Helper()
|
||||
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)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
func TestDaily_LiveResultsAreSpoilerFree(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.pinDaily(1, "Ahri")
|
||||
alice := h.tokenTopic(1, groupChat, 7, 0, "Alice")
|
||||
h.dailyGuess(alice, 1, "Akali")
|
||||
h.dailyGuess(h.tokenTopic(2, groupChat, 7, 0, "Bob"), 1, "Kai'Sa")
|
||||
h.sched.run()
|
||||
sends := sentMethod(h.rb, "sendMessage")
|
||||
if len(sends) != 1 {
|
||||
t.Fatalf("sends = %+v", h.rb.Sent())
|
||||
}
|
||||
text := sends[0].Text()
|
||||
if text != "LoLdle Daily #1 · live results\n\nAlice playing 1/8\n🟩🟥🟥🟥🟩🟩🟥\nBob playing 1/8\n🟩🟥🟩🟩🟥🟥🟥" {
|
||||
t.Fatalf("live = %q", text)
|
||||
}
|
||||
for _, c := range loadChampions() {
|
||||
if strings.Contains(text, c.ChampionName) {
|
||||
t.Fatalf("live results name %s", c.ChampionName)
|
||||
}
|
||||
}
|
||||
h.dailyGuess(alice, 1, "Ahri")
|
||||
h.sched.run()
|
||||
edits := sentMethod(h.rb, "editMessageText")
|
||||
if len(edits) == 0 || !strings.Contains(edits[len(edits)-1].Form["text"], "Alice 2/8\n🟩🟥🟥🟥🟩🟩🟥\n🟩🟩🟩🟩🟩🟩🟩") {
|
||||
t.Fatalf("edits = %+v", edits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaily_RecapAnswerFirstStreakCountsWinsOnly(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
rb := install(t, h.svc.module(), 0)
|
||||
if got := send(t, rb, groupChat, 1, "/loldledaily_subscribe"); !strings.HasPrefix(got, "✅ Subscribed this chat to LoLdle Daily") {
|
||||
t.Fatalf("subscribe = %q", got)
|
||||
}
|
||||
// Day 1: Alice wins in 2, Dan in 2, Bob loses; day 2: Alice wins.
|
||||
h.pinDaily(1, "Ahri")
|
||||
h.pinDaily(2, "Jinx")
|
||||
h.pinDaily(3, "Wukong")
|
||||
h.dailyGuess(h.tokenTopic(1, groupChat, 7, 0, "Alice"), 1, "Akali")
|
||||
h.dailyGuess(h.tokenTopic(1, groupChat, 7, 0, "Alice"), 1, "Ahri")
|
||||
h.dailyGuess(h.tokenTopic(3, groupChat, 7, 0, "Dan"), 1, "Jinx")
|
||||
h.dailyGuess(h.tokenTopic(3, groupChat, 7, 0, "Dan"), 1, "Ahri")
|
||||
bob := h.tokenTopic(2, groupChat, 7, 0, "Bob")
|
||||
for _, c := range []string{"Akali", "Wukong", "Jinx", "Kai'Sa", "Aatrox", "Annie", "Ashe", "Braum"} {
|
||||
h.dailyGuess(bob, 1, c)
|
||||
}
|
||||
// Day 1's recap groups the finishers by guesses, the best crowned.
|
||||
got, err := h.svc.RenderRecap(ctx, 1, groupChat, 0)
|
||||
if err != nil || got != "LoLdle Daily #1 — yesterday's results\nChampion: Ahri\n👑 2/8: Alice, Dan\nX/8: Bob" {
|
||||
t.Fatalf("day 1 recap = %q, %v", got, err)
|
||||
}
|
||||
h.clock.advance(24 * time.Hour)
|
||||
h.dailyGuess(h.tokenTopic(1, groupChat, 8, 0, "Alice"), 2, "Jinx")
|
||||
h.clock.advance(24 * time.Hour)
|
||||
h.sched.run()
|
||||
h.rb.Reset()
|
||||
if err := h.svc.RunDailyPush(ctx, h.rb.Bot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := h.rb.Sent()
|
||||
if len(sent) != 2 || sent[1].Method != "sendGame" || sent[1].Form["game_short_name"] != "loldle" {
|
||||
t.Fatalf("push = %+v", sent)
|
||||
}
|
||||
if got := sent[0].Text(); got != "LoLdle Daily #2 — yesterday's results\nChampion: Jinx\n🔥 Your group is on a 2 day streak!\n👑 1/8: Alice" {
|
||||
t.Fatalf("recap = %q", got)
|
||||
}
|
||||
// Day 3 only a loss: the streak resets.
|
||||
h.dailyGuess(h.tokenTopic(1, groupChat, 9, 0, "Alice"), 3, "Akali")
|
||||
for _, c := range []string{"Ahri", "Jinx", "Kai'Sa", "Aatrox", "Annie", "Ashe", "Braum"} {
|
||||
h.dailyGuess(h.tokenTopic(1, groupChat, 9, 0, "Alice"), 3, c)
|
||||
}
|
||||
h.clock.advance(24 * time.Hour)
|
||||
h.rb.Reset()
|
||||
if err := h.svc.RunDailyPush(ctx, h.rb.Bot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := h.rb.Sent()[0].Text(); got != "LoLdle Daily #3 — yesterday's results\nChampion: Wukong\nNobody solved it. Group streak reset.\nX/8: Alice" {
|
||||
t.Fatalf("loss recap = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package loldle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame"
|
||||
)
|
||||
|
||||
const (
|
||||
msgDisabled = "LoLdle isn't configured on this server."
|
||||
msgNoGameTarget = "Couldn't find the game message. Send /loldle or /loldledaily 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.
|
||||
// A /loldle card opens the player's unlimited round; every other card,
|
||||
// /loldledaily, the 07:00 push, an inline share or an expired record, opens
|
||||
// today's daily champion.
|
||||
func (s *service) handlePlay(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) signToken(c guessgame.Claims) (string, error) {
|
||||
return guessgame.SignToken(s.tokenKey, c)
|
||||
}
|
||||
|
||||
func (s *service) verifyToken(token string, now time.Time) (guessgame.Claims, error) {
|
||||
return guessgame.VerifyToken(s.tokenKey, token, now)
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Package loldle implements the loldle classic mode — guess the League
|
||||
// champion from attribute hints (gender, species, regions, etc.).
|
||||
package loldle
|
||||
|
||||
import (
|
||||
@@ -8,11 +6,15 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Champion is one row of champions.json. Field tags match loldle.net's
|
||||
// scraped schema verbatim so the embedded JSON file can be regenerated from
|
||||
// the upstream scrape without a transform step.
|
||||
// Champion is one row of champions.json. The attribute tags match
|
||||
// loldle.net's scraped schema verbatim so the embedded JSON file can be
|
||||
// regenerated from the upstream scrape without a transform step. ID (the
|
||||
// Data Dragon key, which names the champion's icon) and Title come from
|
||||
// Riot's Data Dragon by name; see docs/loldle.md.
|
||||
type Champion struct {
|
||||
ChampionName string `json:"championName"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Gender string `json:"gender"`
|
||||
Positions []string `json:"positions"`
|
||||
Species []string `json:"species"`
|
||||
@@ -28,7 +30,13 @@ type Champion struct {
|
||||
//go:embed data/champions.json
|
||||
var rawChampions []byte
|
||||
|
||||
// loadChampions parses the embedded JSON. Panics on malformed data —
|
||||
// tileIDs overrides the icon id of champions whose Data Dragon tile file is
|
||||
// spelled differently from their key. The CDN is case-sensitive: the tile is
|
||||
// FiddleSticks_0.jpg, while tiles/Fiddlesticks_0.jpg answers 403.
|
||||
var tileIDs = map[string]string{"Fiddlesticks": "FiddleSticks"}
|
||||
|
||||
// loadChampions parses the embedded JSON and applies tileIDs, so every ID
|
||||
// names a tile that loads. Panics on malformed data —
|
||||
// a corrupt regen of champions.json is a build-time bug, not a runtime
|
||||
// concern worth recovering from.
|
||||
func loadChampions() []Champion {
|
||||
@@ -39,5 +47,10 @@ func loadChampions() []Champion {
|
||||
if len(cs) == 0 {
|
||||
panic("loldle: champions.json contained no records")
|
||||
}
|
||||
for i := range cs {
|
||||
if id, ok := tileIDs[cs[i].ID]; ok {
|
||||
cs[i].ID = id
|
||||
}
|
||||
}
|
||||
return cs
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package loldle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"strconv"
|
||||
|
||||
"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/guessgame"
|
||||
)
|
||||
|
||||
const (
|
||||
newRoundHint = "🆕 Send <code>/loldle</code> or <code>/loldle <champion></code> to start a new round."
|
||||
|
||||
msgChannel = "LoLdle can't be played in channels."
|
||||
msgSendFail = "Couldn't send LoLdle. Try again later."
|
||||
msgDailyChannel = "LoLdle Daily can't be played in channels."
|
||||
msgDailySendFail = "Couldn't send LoLdle Daily. Try again later."
|
||||
msgNoStore = "LoLdle 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."
|
||||
)
|
||||
|
||||
// The /loldle commands play the caller's unlimited round, the one the
|
||||
// /loldle card opens: each player has their own, in groups too. A chat's
|
||||
// /loldle_setmax sets the length of the rounds started there.
|
||||
|
||||
// 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, msgChannel)
|
||||
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
|
||||
}
|
||||
|
||||
// maxFor is the length of an unlimited round started in subject's chat.
|
||||
func (s *service) maxFor(ctx context.Context, subject string) (int, error) {
|
||||
if subject == "" {
|
||||
return MaxGuesses, nil
|
||||
}
|
||||
return getMaxGuesses(ctx, s.settings, subject)
|
||||
}
|
||||
|
||||
// current returns the caller's round to play on: their round being played,
|
||||
// or a new one when the last is over, since a finished round only waits to
|
||||
// be looked at.
|
||||
func (s *service) current(ctx context.Context, uid int64, maxGuesses int) (guessgame.RoundState, error) {
|
||||
st, err := s.rounds.Load(ctx, uid, maxGuesses)
|
||||
if err != nil || !st.Round.Finished() {
|
||||
return st, err
|
||||
}
|
||||
return s.rounds.New(ctx, uid, st.Round.Seq, maxGuesses)
|
||||
}
|
||||
|
||||
// handleLoldle is /loldle [champion]. Without a champion 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) handleLoldle(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
|
||||
}
|
||||
maxGuesses, err := s.maxFor(ctx, chathelper.SubjectFor(msg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
if arg == "" {
|
||||
st, err := s.current(ctx, uid, maxGuesses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.enabled() {
|
||||
return s.sendCard(ctx, b, msg)
|
||||
}
|
||||
header := fmt.Sprintf("Guess %d/%d. Use <code>/loldle <champion></code>.", 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(
|
||||
"🔁 <b>%s</b> 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 <n> — 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},
|
||||
})
|
||||
}
|
||||
@@ -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, "<b>Akali</b> 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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 <code>/loldle</code> or <code>/loldle <champion></code> 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 <code>/loldle <champion></code>.",
|
||||
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(
|
||||
"🔁 <b>%s</b> 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 <n> — 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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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 <champion>, /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:<subject>" 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
|
||||
}
|
||||
@@ -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:<subject>" and "stats:<subject>" 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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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, ""
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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<View>}
|
||||
*/
|
||||
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<Champ[]>} 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();
|
||||
}
|
||||
}());
|
||||
@@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>LoLdle</title>
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app">
|
||||
<header class="top">
|
||||
<h1 class="logo">Lo<span class="accent">L</span>dle</h1>
|
||||
<p id="mode" class="mode"></p>
|
||||
<p id="player" class="muted"></p>
|
||||
</header>
|
||||
|
||||
<section id="screen-missing" class="card" hidden>
|
||||
<p class="lead">Open the game from Telegram.</p>
|
||||
<p class="muted">Send <code>/loldle</code> or <code>/loldledaily</code> in a chat with the bot, then press Play.</p>
|
||||
</section>
|
||||
|
||||
<section id="screen-game" hidden>
|
||||
<div class="bar">
|
||||
<p id="counter" class="counter"></p>
|
||||
<button type="button" id="new-top" class="ghost" hidden>New game</button>
|
||||
</div>
|
||||
|
||||
<div class="search">
|
||||
<label for="search-input" class="sr-only">Champion</label>
|
||||
<input id="search-input" type="text" role="combobox" aria-expanded="false" aria-controls="search-list"
|
||||
aria-autocomplete="list" autocomplete="off" autocapitalize="off" spellcheck="false"
|
||||
placeholder="Type a champion name...">
|
||||
<ul id="search-list" class="options" role="listbox" aria-label="Champions" hidden></ul>
|
||||
</div>
|
||||
|
||||
<ul class="legend" aria-label="Colour key">
|
||||
<li><span class="swatch correct" aria-hidden="true"></span>Correct</li>
|
||||
<li><span class="swatch partial" aria-hidden="true"></span>Partial</li>
|
||||
<li><span class="swatch wrong" aria-hidden="true"></span>Wrong</li>
|
||||
<li><span class="arrows" aria-hidden="true">↑↓</span>Released later / earlier</li>
|
||||
</ul>
|
||||
|
||||
<section id="end" class="end card" hidden>
|
||||
<h2 id="end-title"></h2>
|
||||
<div class="answer">
|
||||
<span id="end-icon" class="answer-icon"></span>
|
||||
<p id="end-text"></p>
|
||||
</div>
|
||||
<h3>Statistics</h3>
|
||||
<div class="stats">
|
||||
<div><strong id="st-played">0</strong><span>Played</span></div>
|
||||
<div><strong id="st-pct">0</strong><span>Win %</span></div>
|
||||
<div><strong id="st-cur">0</strong><span>Current Streak</span></div>
|
||||
<div><strong id="st-max">0</strong><span>Max Streak</span></div>
|
||||
</div>
|
||||
<h3>Guess Distribution</h3>
|
||||
<ol id="dist" class="dist"></ol>
|
||||
<div id="end-daily" hidden>
|
||||
<p class="next">Next champion in <strong id="countdown">--:--:--</strong></p>
|
||||
<button type="button" id="next-btn" class="primary" hidden>Play the new champion</button>
|
||||
<div class="actions">
|
||||
<button type="button" id="share-btn" class="primary" hidden>Share</button>
|
||||
<button type="button" id="copy-btn">Copy result</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="end-unlimited" class="actions" hidden>
|
||||
<button type="button" id="new-end" class="primary">New game</button>
|
||||
</div>
|
||||
<p id="daily-hint" class="muted hint" hidden>Want the same champion as everyone today? Send <code>/loldledaily</code>.</p>
|
||||
</section>
|
||||
|
||||
<div class="board-wrap">
|
||||
<div id="board" class="board" role="table" aria-label="Guesses"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p id="toast" class="toast" role="status" aria-live="polite" hidden></p>
|
||||
<p id="announce" class="sr-only" aria-live="polite"></p>
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
<script src="https://telegram.org/js/games.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+33
-32
@@ -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) {
|
||||
@@ -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()
|
||||
}
|
||||
+61
-63
@@ -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 "<chat>:<thread>:"; 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)
|
||||
}
|
||||
}
|
||||
|
||||
+67
-73
@@ -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<<st.retries)
|
||||
delay := max(after, SummaryInterval<<st.retries)
|
||||
st.retries++
|
||||
st.scheduled = true
|
||||
f.mu.Unlock()
|
||||
s.cfg.schedule(delay, func() { s.flushSummary(num, chatID, threadID) })
|
||||
d.cfg.Schedule(delay, func() { d.flushSummary(num, chatID, threadID) })
|
||||
}
|
||||
|
||||
// syncSummary sends the chat day's results message the first time and
|
||||
// edits it afterwards. A deleted message is sent again, at most once per
|
||||
// resendInterval. It reports whether the update must be retried, and the
|
||||
// ResendInterval. It reports whether the update must be retried, and the
|
||||
// shortest wait before that.
|
||||
//
|
||||
// The lock is per chat topic, not per day: only the current day's message
|
||||
// is updated, and keylock never frees a key, so a daily key would grow the
|
||||
// lock map forever.
|
||||
func (s *service) syncSummary(key string, chatID int64, threadID int) (bool, time.Duration) {
|
||||
func (d *Daily) syncSummary(key string, chatID int64, threadID int) (bool, time.Duration) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
|
||||
defer cancel()
|
||||
defer s.locks.Acquire("summary:" + chatTopic(chatID, threadID))()
|
||||
cd, _, err := s.chatDays.Get(ctx, key)
|
||||
defer d.Locks.Acquire("summary:" + chatTopic(chatID, threadID))()
|
||||
cd, _, err := d.ChatDays.Get(ctx, key)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
return false, 0
|
||||
}
|
||||
log.Warn("wordledaily summary read failed", "err", err)
|
||||
log.Warn(d.cfg.LogName+" summary read failed", "err", err)
|
||||
return true, 0
|
||||
}
|
||||
text, err := s.renderLive(ctx, cd)
|
||||
text, err := d.RenderLive(ctx, cd)
|
||||
if err != nil {
|
||||
log.Warn("wordledaily summary render failed", "err", err)
|
||||
log.Warn(d.cfg.LogName+" summary render failed", "err", err)
|
||||
return true, 0
|
||||
}
|
||||
if text == "" {
|
||||
return false, 0
|
||||
}
|
||||
now := s.cfg.now()
|
||||
now := d.cfg.Now()
|
||||
if cd.MsgID != 0 {
|
||||
_, err := s.cfg.api.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: cd.ChatID, MessageID: cd.MsgID, Text: text})
|
||||
_, err := d.cfg.API.EditMessageText(ctx, &bot.EditMessageTextParams{ChatID: cd.ChatID, MessageID: cd.MsgID, Text: text})
|
||||
switch {
|
||||
case err == nil || messageNotModified(err):
|
||||
return false, 0
|
||||
case !messageGone(err):
|
||||
log.Warn("wordledaily summary edit failed", "err", err)
|
||||
log.Warn(d.cfg.LogName+" summary edit failed", "err", err)
|
||||
return retryAfter(err)
|
||||
}
|
||||
if wait := resendInterval - now.Sub(time.UnixMilli(cd.MsgSentAt)); wait > 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))) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// "<prefix><subject>". 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
|
||||
}
|
||||
@@ -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:<uid>", and their own stats under
|
||||
// "ustats:<uid>". 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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <word>`.", 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 <word>` 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,
|
||||
))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 <word>`."
|
||||
}
|
||||
rows := make([]string, len(guesses))
|
||||
for i, g := range guesses {
|
||||
rows[i] = renderGuess(g.Word, g.Results)
|
||||
}
|
||||
return strings.Join(rows, "\n\n")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 <word>` 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 <word>`.", 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 <word>`."
|
||||
}
|
||||
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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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:<subject>" and
|
||||
// "stats:<subject>". 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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <word>`.\n\nNo guesses yet. Reply with `/wordle <word>`." {
|
||||
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 <word>`.\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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Wordle Daily</title>
|
||||
<title>Wordle</title>
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app">
|
||||
<header class="top">
|
||||
<div class="title">
|
||||
<h1>Wordle Daily <span id="num"></span></h1>
|
||||
<h1><span id="title">Wordle</span> <span id="num"></span></h1>
|
||||
<p id="subtitle" class="muted"></p>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button type="button" id="new-top" class="new-top" hidden>New game</button>
|
||||
<button type="button" id="contrast-btn" class="icon" aria-pressed="false" aria-label="High contrast colours" title="High contrast colours"><span aria-hidden="true">◐</span></button>
|
||||
<button type="button" id="stats-btn" class="icon" aria-label="Results" title="Results" hidden><span aria-hidden="true">▤</span></button>
|
||||
</div>
|
||||
@@ -23,7 +24,7 @@
|
||||
|
||||
<section id="screen-missing" class="card" hidden>
|
||||
<p class="lead">Open the game from Telegram.</p>
|
||||
<p class="muted">Send <code>/wordledaily</code> in a chat with the bot, then press Play.</p>
|
||||
<p class="muted">Send <code>/wordle</code> or <code>/wordledaily</code> in a chat with the bot, then press Play.</p>
|
||||
</section>
|
||||
|
||||
<section id="screen-game" hidden>
|
||||
@@ -50,11 +51,19 @@
|
||||
</div>
|
||||
<h3>Guess Distribution</h3>
|
||||
<ol id="dist" class="dist"></ol>
|
||||
<p class="next">Next puzzle in <strong id="countdown">--:--:--</strong></p>
|
||||
<button type="button" id="next-btn" class="primary" hidden>Play the new puzzle</button>
|
||||
<div class="actions">
|
||||
<button type="button" id="share-btn" class="primary" hidden>Share</button>
|
||||
<button type="button" id="copy-btn">Copy result</button>
|
||||
<div id="end-daily">
|
||||
<p class="next">Next puzzle in <strong id="countdown">--:--:--</strong></p>
|
||||
<button type="button" id="next-btn" class="primary" hidden>Play the new puzzle</button>
|
||||
<div class="actions">
|
||||
<button type="button" id="share-btn" class="primary" hidden>Share</button>
|
||||
<button type="button" id="copy-btn">Copy result</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="end-unlimited" hidden>
|
||||
<div class="actions">
|
||||
<button type="button" id="new-end" class="primary">New game</button>
|
||||
</div>
|
||||
<p class="hint muted">Want the same word as everyone today? Send <code>/wordledaily</code>.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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 <word>, /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
|
||||
}
|
||||
Reference in new issue
Block a user