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:
tiennm99 committed 2026-10-10 09:29:48 +07:00
1 parent bc8a7d8869
commit 629b513c91
80 files changed
+7965 -3099

No files matched your search

+10 -2
View File
@@ -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
+5 -4
View File
@@ -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)
+9
View File
@@ -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
View File
@@ -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 {
+55
View File
@@ -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
View File
@@ -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) ---
+4 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+450
View File
@@ -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)
}
}
+43
View File
@@ -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)
}
+19 -6
View File
@@ -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
}
+287
View File
@@ -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 &lt;champion&gt;</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 &lt;champion&gt;</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},
})
}
+254
View File
@@ -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
-298
View File
@@ -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 &lt;champion&gt;</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 &lt;champion&gt;</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))
}
-183
View File
@@ -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)
}
}
+260
View File
@@ -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
}
+407
View File
@@ -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}
}
+285 -34
View File
@@ -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
}
+134
View File
@@ -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
}
+151
View File
@@ -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))
}
+142
View File
@@ -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, ""
}
+9 -115
View File
@@ -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 {
-160
View File
@@ -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)
}
}
+282
View File
@@ -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; }
}
+635
View File
@@ -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();
}
}());
+85
View File
@@ -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>
+10
View File
@@ -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
+195
View File
@@ -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
}
+115
View File
@@ -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
}
+132
View File
@@ -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
}
@@ -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()
}
@@ -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)
}
}
@@ -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
}
+317
View File
@@ -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
}
+67
View File
@@ -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"`
+8 -3
View File
@@ -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")
}
-24
View File
@@ -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) }
-90
View File
@@ -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])
}
}
}
-235
View File
@@ -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,
))
}
-216
View File
@@ -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)
}
}
-26
View File
@@ -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)
}
-49
View File
@@ -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)
}
}
}
-26
View File
@@ -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()
}
-29
View File
@@ -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")
}
-125
View File
@@ -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
}
-151
View File
@@ -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")
}
}
-48
View File
@@ -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,
},
},
}
}
-7
View File
@@ -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() }
-24
View File
@@ -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)
}
}
}
+21 -49
View File
@@ -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)
}
+17 -11
View File
@@ -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"))
+191 -79
View File
@@ -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")
}
+16 -5
View File
@@ -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)
}
+167 -56
View File
@@ -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}
}
+142
View File
@@ -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))
}
-369
View File
@@ -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
}
+26 -25
View File
@@ -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())
+18 -15
View File
@@ -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)
}
-169
View File
@@ -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()
}
+21 -20
View File
@@ -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)
}
+71
View File
@@ -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) }
+2 -1
View File
@@ -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 ||
+37 -35
View File
@@ -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)
}
}
-66
View File
@@ -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)
}
}
+3
View File
@@ -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;
+79 -16
View File
@@ -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();
+17 -8
View File
@@ -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>
+125 -81
View File
@@ -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
}