Compare commits

...
12 Commits
Author SHA1 Message Date
tiennm99 80e85012c7 fix(giaxang): label the timestamp as when the prices took effect 2026-10-11 10:55:15 +07:00
tiennm99 629b513c91 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.
2026-10-10 09:29:48 +07:00
tiennm99 bc8a7d8869 feat(wordledaily): send the wordle BotFather game
Cards from /wordledaily and the 07:00 push now use the BotFather game
wordle, and Play answers it. The module, commands and page route keep the
wordledaily name.
2026-10-09 17:23:17 +07:00
tiennm99 c0231ceadf feat(wordledaily): add the daily Wordle HTML5 game
/wordledaily sends a BotFather game card whose page plays one shared word
per day, changing at 07:00 ICT. The server checks guesses, saves each
player's board after every guess and keeps NYT-style stats. A win is
reported with setGameScore, and in a group one live results message shows
everyone's colour grids.

/wordledaily_subscribe and /wordledaily_unsubscribe opt a chat or topic in
to the 07:00 push: yesterday's recap, opening with the answer and the
group's win streak, then today's card.

The HTML5 game plumbing that noitu used is now shared in util/htmlgame, and
the wordle word list and scoring live in wordle/wordlist.
2026-10-09 17:20:02 +07:00
tiennm99 871b4a1042 fix(noitu): drop unrelated htmlgame edits from the noitubot commit
The previous commit picked up an unfinished refactor of noitu.go that imports
internal/modules/util/htmlgame, which is not committed, so main did not build.
Keep only the noitubot game change.
2026-10-09 16:42:59 +07:00
tiennm99 f60b661444 feat(noitu): send the separate noitubot game from /noitubot
/noitubot now sends its own BotFather game, noitubot, so players can tell a
solo game from a /noitu room card. Both games share the Play handler and page.
A noitubot Play press skips the card lookup and always plays solo.
2026-10-09 16:42:31 +07:00
tiennm99 d84bc3d1fb feat(noitu): make /noitu the group game and add /noitutop
/noitu now posts the group play card that /noitupvp used to, and the solo
game against the bot moves to /noitubot. In a private chat /noitu points
the user to a group, or to /noitubot.

/noitutop ranks a group's players across all of its cards by wins, best
game and games played.

A one-time startup migration moves /stats history to the new names in
order: noitu to noitubot first, then noitupvp to noitu.
2026-10-09 16:22:54 +07:00
tiennm99 b8565164b3 feat(noitu): add /noitupvp group play
/noitupvp posts the noitu game card in a group and records it as a PvP
card. Everyone who presses Play on that card joins one room: the first
member hosts and starts, 2 to 8 players take 30-second turns, a player
whose turn runs out or who gives up is eliminated, and the last one
standing wins. Each player's points, plus a winner bonus, go to the
card's high-score table, and the bot replies to the card with the result.

The engine now shares one board between the solo game and the new
multiplayer match, so both use the same word rules and scoring.
2026-10-09 16:07:56 +07:00
tiennm99 231fc96b5f docs(groups): explain reading other bots' messages via admin and bot-to-bot mode 2026-10-09 14:38:17 +07:00
tiennm99 6f30d13b2d feat(noitu): add the noitu HTML5 Telegram game
Serve the BotFather game noitu from the bot's HTTP server under
/games/noitu when GAME_BASE_URL is set. /noitu sends the game, Play
answers with a signed, expiring link, and the server validates every
word against the embedded noitu dictionary (CC BY-SA 4.0), runs the
bot opponent and turn timer, and reports the score with setGameScore.

Modules can now register game-short-name callbacks and HTTP routes.
2026-10-09 14:32:57 +07:00
tiennm99 fe552f43e4 feat(menu): sort Telegram command suggestions by name
The command menu was in module load order, which split related commands across modules. Sorting by name keeps /thoitiet next to /thoitiethomnay. The weather module also lists /thoitiet first so /help matches.
2026-10-08 17:10:42 +07:00
tiennm99 9801a64e18 feat(weather): default to Tân Thuận and accept lat,long coordinates
Forecast commands without a location now use phường Tân Thuận (formerly Quận 7), shared with the flood view, instead of the Ho Chi Minh City centre; city aliases still resolve to the city. A decimal "lat,long" argument skips geocoding and is used directly.
2026-10-08 17:02:05 +07:00
162 changed files with 60917 additions and 2090 deletions

No files matched your search

+32 -3
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,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.
@@ -49,10 +50,38 @@ LOL_PANDASCORE_TOKEN=
# only when running the bot outside compose. Leave blank to fall back to text.
RENDERER_URL=
# Optional public HTTPS base URL for the noitu HTML5 game, e.g.
# https://noitu.example.com. It must route to the bot container's port 8080
# (attach a Coolify domain to the bot service); the game page is served under
# <GAME_BASE_URL>/games/noitu/. Unset or invalid = the game is disabled: /noitu
# still sends the game, Play explains it is not configured, and no /games/
# route exists. See docs/noitu-game.md.
GAME_BASE_URL=
# Optional secret (at least 32 bytes) that signs noitu game links. Unset = a key
# derived from TELEGRAM_BOT_TOKEN, so rotating the bot token invalidates open
# game links. A value shorter than 32 bytes disables the game. Secret.
NOITU_GAME_SECRET=
# Optional secret (at least 32 bytes) for the wordledaily game: it signs game
# links and keys the daily answer order. Unset = derived from
# TELEGRAM_BOT_TOKEN. A value shorter than 32 bytes disables the game. The game
# is also served under <GAME_BASE_URL>/games/wordledaily/. See
# 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
# PORT — defaults to 8080 (internal health server)
# PORT — defaults to 8080 (health server, plus the noitu game
# when GAME_BASE_URL is set)
# TELEGRAM_WEBHOOK_SECRET — long polling has no webhook
# GOLD_VNAPP_API_KEY — gold module auto-fetches + caches the key to Mongo
# Stock/coin/gold URL env overrides are not supported; modules use coded
+17 -8
View File
@@ -11,8 +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` |
| `loldle` | League-of-Legends "guess the champion": `/loldle [champion]`, `/loldle_giveup`, `/loldle_stats`, `/loldle_setmax` (owner) |
| `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" 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` |
@@ -22,7 +23,7 @@ Atlas via long polling and an in-process cron scheduler.
| `alias` | `/alias <name>` save a replied message under a name, then send it back with `/insert <name>`, bare `/<name>`, or inline `@botname <prefix>`; `/aliases` lists, `/unalias` deletes. See [docs/aliases.md](docs/aliases.md) |
| `blacklist` | Per-topic text deny-list with whitelist exceptions: `/blacklist_add`, `/blacklist_del`, `/whitelist_add`, `/whitelist_del`, `/blacklist_rules` lists both, `/blacklist_check` judges a text, `/blacklist` does either, `/whitelist_rnd` picks a random exception. Passive — the bot never scans chat. See [docs/blacklist.md](docs/blacklist.md) |
| `monkeyd` | `/monkeyd_crawl <url> [font_size]` export a monkeydd.com novel as a PDF, `/monkeyd_tags <url>` list its tags as hashtags |
| `weather` | Weather from Open-Meteo: `/thoitiet` for the next 6 hours hour by hour, `/thoitiethomnay` for current conditions and today, `/thoitietngaymai` for tomorrow, `/thoitiettuannay` for the next 7 days. Each takes `[location...]` (Vietnamese with or without diacritics, shorthand like `hcm`/`hn`, or a foreign city); the default is Ho Chi Minh City. Flood alerts for Tân Thuận (Q.7): `/thuyvan` shows the 5-day tide-peak forecast at Phú An and Nhà Bè (Đài KTTV Nam Bộ bulletin), the rain forecast, and live VNDMS river gauges within 30 km; `/thuyvan_subscribe` / `/thuyvan_unsubscribe` toggle a 10:30 ICT push (retried at 12:30 if that run fails) sent only when a forecast peak reaches báo động I (1.40 m) or rain reaches 50 mm |
| `weather` | Weather from Open-Meteo: `/thoitiet` for the next 6 hours hour by hour, `/thoitiethomnay` for current conditions and today, `/thoitietngaymai` for tomorrow, `/thoitiettuannay` for the next 7 days. Each takes `[location... | lat,long]` (Vietnamese with or without diacritics, shorthand like `hcm`/`hn`, a foreign city, or decimal coordinates such as `10.74,106.72`); the default is Tân Thuận (formerly Q.7). Flood alerts for Tân Thuận (Q.7): `/thuyvan` shows the 5-day tide-peak forecast at Phú An and Nhà Bè (Đài KTTV Nam Bộ bulletin), the rain forecast, and live VNDMS river gauges within 30 km; `/thuyvan_subscribe` / `/thuyvan_unsubscribe` toggle a 10:30 ICT push (retried at 12:30 if that run fails) sent only when a forecast peak reaches báo động I (1.40 m) or rain reaches 50 mm |
Commands marked (admin) require a user ID in `ADMIN_IDS` or the owner; commands
marked (owner) require `OWNER_ID`. Both kinds are hidden from `/help` and the
@@ -231,8 +232,8 @@ all.
## Layout
```
cmd/server/ entrypoint (long polling + in-process cron + HTTP health)
internal/server/ HTTP route (/ health only; cron has no HTTP route)
cmd/server/ entrypoint (long polling + in-process cron + HTTP server)
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
@@ -251,6 +252,10 @@ compose.yml Coolify self-host stack (bot + renderer services)
- [Aliases](docs/aliases.md)
- [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)
@@ -317,10 +322,14 @@ Lint settings live in [`.golangci.yml`](.golangci.yml).
## Deploy
[`docs/deploy-coolify-selfhosted.md`](docs/deploy-coolify-selfhosted.md) covers
Coolify + MongoDB Atlas (free M0), long polling (no public ingress), and
in-process cron. Coolify redeploys on every push to `main`. Storage
Coolify + MongoDB Atlas (free M0), long polling (no public ingress unless the
`noitu` game is enabled), and in-process cron. Coolify redeploys on every push to `main`. Storage
auto-selects `mongodb` when `MONGO_URL` is set; the cron scheduler always runs.
## License
[Apache-2.0](LICENSE).
[Apache-2.0](LICENSE). The `noitu` game's dictionary,
[`internal/modules/noitu/dict/data/`](internal/modules/noitu/dict/data/), is
data derived from Wiktionary tiếng Việt and stays under CC BY-SA 4.0; see its
[LICENSE](internal/modules/noitu/dict/data/LICENSE) and
[ATTRIBUTION.md](internal/modules/noitu/dict/data/ATTRIBUTION.md).
+5 -2
View File
@@ -3,6 +3,8 @@ package main
import (
"context"
"errors"
"slices"
"strings"
"time"
"github.com/go-telegram/bot"
@@ -14,8 +16,8 @@ import (
const commandMenuTimeout = 8 * time.Second
// botCommandMenu builds the Telegram command menu from the loaded public
// commands. It intentionally follows registry module order so MODULES controls
// both enabled commands and their menu grouping.
// commands, sorted by name so related commands such as /thoitiet and
// /thoitiethomnay sit together whatever their module order.
func botCommandMenu(reg *modules.Registry) []models.BotCommand {
if reg == nil {
return nil
@@ -32,6 +34,7 @@ func botCommandMenu(reg *modules.Registry) []models.BotCommand {
})
}
}
slices.SortFunc(out, func(a, b models.BotCommand) int { return strings.Compare(a.Command, b.Command) })
return out
}
+22 -6
View File
@@ -16,7 +16,7 @@ import (
"github.com/tiennm99/tiennm99bot/internal/testutil"
)
func TestBotCommandMenu_UsesLoadedPublicCommandsInModuleOrder(t *testing.T) {
func TestBotCommandMenu_ListsLoadedPublicCommandsByName(t *testing.T) {
reg := &modules.Registry{
Modules: []modules.Module{
{
@@ -39,8 +39,8 @@ func TestBotCommandMenu_UsesLoadedPublicCommandsInModuleOrder(t *testing.T) {
got := botCommandMenu(reg)
want := []models.BotCommand{
{Command: "beta_public", Description: "<value>. Beta public."},
{Command: "alpha_public", Description: "Alpha public."},
{Command: "beta_public", Description: "<value>. Beta public."},
}
if len(got) != len(want) {
t.Fatalf("commands = %v, want %v", got, want)
@@ -71,8 +71,13 @@ 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": "",
"noitubot": "",
"noitutop": "",
"random": "<option,...>",
"addsticker": "[emoji...]",
"alias": "<name>",
@@ -96,14 +101,25 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
"stock_sell": "<quantity> <ticker>",
"stock_cash_dividend": "<vnd_per_share> <ticker>",
"stock_share_dividend": "<ratio(owned:new)> <ticker>",
"thoitiet": "[location...]",
"thoitiethomnay": "[location...]",
"thoitietngaymai": "[location...]",
"thoitiettuannay": "[location...]",
"thoitiet": "[location... | lat,long]",
"thoitiethomnay": "[location... | lat,long]",
"thoitietngaymai": "[location... | lat,long]",
"thoitiettuannay": "[location... | lat,long]",
"trongtruonghop": "[target...]",
"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)
+64 -11
View File
@@ -1,7 +1,8 @@
// Command server runs tiennm99bot: it loads configuration from the environment,
// opens the storage backend, builds the module registry, and then serves
// Telegram updates by long polling while an in-process scheduler fires module
// crons. A small HTTP server answers the container health check.
// crons. A small HTTP server answers the container health check and serves
// module routes such as the noitu game page when they are enabled.
package main
import (
@@ -33,15 +34,17 @@ import (
"github.com/tiennm99/tiennm99bot/internal/modules/loldle"
"github.com/tiennm99/tiennm99bot/internal/modules/misc"
"github.com/tiennm99/tiennm99bot/internal/modules/monkeyd"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu"
"github.com/tiennm99/tiennm99bot/internal/modules/random"
"github.com/tiennm99/tiennm99bot/internal/modules/stats"
"github.com/tiennm99/tiennm99bot/internal/modules/sticker"
"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"
"github.com/tiennm99/tiennm99bot/internal/systemstate"
"github.com/tiennm99/tiennm99bot/internal/telegram"
)
@@ -97,8 +100,9 @@ func factories() map[string]modules.Factory {
"random": random.New,
"amlich": amlich.New,
"monkeyd": monkeyd.New,
"wordle": wordle.New,
"loldle": loldle.New,
wordledaily.ShortName: wordledaily.New,
noitu.ShortName: noitu.New,
loldle.ShortName: loldle.New,
lol.CollectionName: lol.New,
coin.CollectionName: coin.New,
"gold": gold.New,
@@ -140,12 +144,21 @@ func main() {
}
defer closeProvider()
if err := stats.InitStore(rootCtx, provider.Collection("stats")); err != nil {
if err := stats.InitStore(rootCtx, provider.Collection("stats"), provider.Collection(systemstate.CollectionName)); err != nil {
log.Fatal("stats storage init failed", "err", err)
}
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 {
@@ -200,15 +213,16 @@ func main() {
GitSHA: resolveCommitSHA(cfg.SourceCommit),
})
handler := server.New()
handler := server.New(serverRoutes(reg)...)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
// The only route is GET / (health). It responds instantly, so this
// write deadline is ample and bounds any slow-loris write.
// Routes are GET / (health) plus module routes such as the noitu game
// page and its small JSON API. All respond quickly, so this write
// deadline is ample and bounds any slow-loris write.
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
@@ -220,8 +234,8 @@ func main() {
}
}()
// Long polling is the sole Telegram transport (no webhook, no public
// ingress). Telegram permits exactly one getUpdates consumer per bot token,
// Long polling is the sole Telegram transport (no webhook; public ingress
// only for module HTTP routes such as the noitu game, when enabled). Telegram permits exactly one getUpdates consumer per bot token,
// so deploy exactly one replica. The webhook was cleared at startup above.
pollingDone := make(chan struct{})
go func() {
@@ -247,6 +261,17 @@ func main() {
metrics.Flush()
}
// serverRoutes converts the registry's module routes for internal/server,
// which stays independent of the modules package.
func serverRoutes(reg *modules.Registry) []server.Route {
var routes []server.Route
for _, r := range reg.HTTPRoutes() {
routes = append(routes, server.Route{Pattern: r.Pattern, Handler: r.Handler})
log.Info("http route", "pattern", r.Pattern)
}
return routes
}
// buildProvider picks the storage backend. Selection order:
// 1. Explicit KV_PROVIDER env (memory|mongodb) wins.
// 2. Auto-detect: MONGO_URL set → mongodb; otherwise memory.
@@ -366,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"],
@@ -390,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 {
+101
View File
@@ -100,6 +100,107 @@ func TestFactoriesRegistersBlacklistCommands(t *testing.T) {
}
}
// noitu is in the catalog and, with GAME_BASE_URL unset, registers its commands
// and game but no public HTTP route.
func TestFactoriesRegistersNoituDisabledByDefault(t *testing.T) {
t.Setenv("GAME_BASE_URL", "")
t.Setenv("NOITU_GAME_SECRET", "")
reg, err := modules.Build([]string{"noitu"}, factories(), storage.NewMemoryProvider(), modules.BuildOptions{})
if err != nil {
t.Fatalf("Build noitu: %v", err)
}
for _, name := range []string{"noitu", "noitubot", "noitutop"} {
if _, ok := reg.AllCommands[name]; !ok {
t.Fatalf("missing command %s", name)
}
}
if _, ok := reg.AllCommands["noitupvp"]; ok {
t.Fatal("renamed command noitupvp is still registered")
}
if routes := serverRoutes(reg); len(routes) != 0 {
t.Fatalf("disabled game exposes routes: %+v", routes)
}
}
// wordledaily is in the catalog and, with GAME_BASE_URL unset, registers its
// commands and game but no public HTTP route and no daily push.
func TestFactoriesRegistersWordleDailyDisabledByDefault(t *testing.T) {
t.Setenv("GAME_BASE_URL", "")
t.Setenv("WORDLEDAILY_GAME_SECRET", "")
reg, err := modules.Build([]string{"wordledaily"}, factories(), storage.NewMemoryProvider(), modules.BuildOptions{})
if err != nil {
t.Fatalf("Build wordledaily: %v", err)
}
for _, name := range []string{"wordledaily", "wordledaily_subscribe", "wordledaily_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)
}
}
}
// 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.
+11 -4
View File
@@ -25,6 +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, wordledaily and loldle web games disabled
# NOITU_GAME_SECRET: ${NOITU_GAME_SECRET:-} # >= 32 bytes, signs game links — SECRET; empty = derived from the bot token
# WORDLEDAILY_GAME_SECRET: ${WORDLEDAILY_GAME_SECRET:-} # >= 32 bytes, signs wordledaily links and keys its answers — SECRET; empty = derived from the bot token
# LOLDLE_GAME_SECRET: ${LOLDLE_GAME_SECRET:-} # >= 32 bytes, signs loldle links and keys its daily champions — SECRET; empty = derived from the bot token
# LOG_LEVEL: ${LOG_LEVEL:-info} # debug|info|warn|error
# --- Fixed by this stack (not Coolify settings) ---
@@ -39,14 +43,17 @@ services:
# declaring it here could override that with an empty value.
# PORT: the health server defaults to 8080, which the healthcheck uses.
# KV_PROVIDER: storage auto-selects mongodb because MONGO_URL is set.
# Long polling = no TELEGRAM_WEBHOOK_SECRET, no /webhook, no public domain.
# Long polling = no TELEGRAM_WEBHOOK_SECRET, no /webhook. A public domain
# is needed only for the noitu game (GAME_BASE_URL).
# Cron is in-process only — no CRON_MODE, no /cron route, no secret.
# No stock/coin/gold URL overrides — modules use their coded default
# providers (stock: SSI/VCI/KBS; coin: Binance->Coinbase->CoinGecko;
# gold: VNAppMob).
# Long polling is outbound-only: nothing inbound to route, so no published
# ports and no public domain. `expose` only documents the health server's
# port; it publishes nothing on the host.
# Long polling is outbound-only, so Telegram itself needs no inbound route
# and no host port is ever published. `expose` documents port 8080 (health,
# plus the noitu game page when GAME_BASE_URL is set). Attach a Coolify
# domain to this service on port 8080 only when GAME_BASE_URL is set; the
# game routes do not exist otherwise.
expose:
- "8080"
# Coolify's UI health check does not apply to Docker Compose apps; it reads
+7 -7
View File
@@ -177,20 +177,20 @@ so they stay valid. They are sent back as entities rather than re-rendered as
markup, which avoids escaping and re-parsing content the user never wrote as
markup.
**Another bot's message cannot be saved.** Telegram's own rule: *"Bots will not
be able to see messages from other bots regardless of mode."* The reply arrives
with its content stripped, so there is nothing to store and no setting that
would change it.
**Another bot's message needs group setup.** By default Telegram strips another
bot's message out of a reply, so there is nothing to store. It arrives intact
once the bot has Bot-to-Bot Communication Mode enabled and is a group admin; see
[Group setup](deploy-coolify-selfhosted.md#4-group-setup).
Every refusal for a message that could not be *read* — as opposed to one whose
kind is unsupported — ends with the same advice, because it is the only thing
that works: **forward it into the chat and reply to your copy.** A forwarded
kind is unsupported — ends with the same advice, because it works without any
setup: **forward it into the chat and reply to your copy.** A forwarded
copy is a new message sent by a user, so it arrives intact. Three shapes reach
that advice, and they are told apart deliberately:
| What arrived | Answer |
| --- | --- |
| Reply from a sender marked as a bot | Telegram does not let bots read other bots' messages |
| Reply from a sender marked as a bot | Telegram hid that bot's message; admin rights plus Bot-to-Bot Communication fix it |
| Reply with a message id but no content field at all | That message reached me with no content |
| No reply attached at all | Reply to the message you want to save — and if you did, Telegram did not pass it along |
+49 -8
View File
@@ -10,17 +10,25 @@ Run `tiennm99bot` as a long-lived container on [Coolify](https://coolify.io) wit
in-process scheduler ───────────────────> module crons
MongoDB Atlas (db / one collection per module + system metadata)
Coolify env vars (plain secrets)
NO public ingress (polling = outbound only; no domain, no /webhook, no TLS in)
no ingress for Telegram (polling = outbound only; no /webhook)
optional HTTPS ingress ──> container :8080 /games/noitu/ (only when GAME_BASE_URL is set)
```
- **Storage** — `mongodb` auto-selected when `MONGO_URL` is set (no `KV_PROVIDER`).
- **Cron** — an in-process scheduler (`internal/cron`) runs unconditionally and
fires each module cron on its `Schedule`, evaluated in UTC. The only cron
today is the `lol` daily digest at `0 1 * * *` (08:00 ICT).
fires each module cron on its `Schedule`, evaluated in UTC. Examples: the
`lol` daily digest at `0 1 * * *` (08:00 ICT), and the `noitu` game's
minutely session sweep when the game is enabled.
- **Transport** — long polling (`b.Start`) is the **only** transport. The bot
opens an outbound connection to Telegram and pulls updates, so there is no
public domain, no `/webhook`, and no webhook secret. The container clears any
leftover webhook on startup (`deleteWebhook`) before polling.
- **Game page** — the `noitu` HTML5 game is the one thing served to the
public. With `GAME_BASE_URL` set, the bot's HTTP server on `:8080` serves the
game page and its JSON API under `/games/noitu/`, and that path must be
reachable over HTTPS from players' phones. With it unset, those routes do not
exist and the bot needs no public ingress at all. See
[the noitu game](noitu-game.md).
## Environment
@@ -31,17 +39,21 @@ 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`, `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 |
| `KV_PROVIDER` | leave unset | `memory` or `mongodb`; unset = `mongodb` when `MONGO_URL` is set, otherwise `memory` |
| `PORT` | leave unset | health server port; default `8080` |
| `PORT` | leave unset | health server (and game page) port; default `8080` |
| `SOURCE_COMMIT` | never set | provided by Coolify at runtime for the deploy DM (see step 5 below) |
`compose.yml` references only the required settings; every optional one is a
@@ -159,9 +171,14 @@ MP4, with the same text fallback.
The committed [`compose.yml`](../compose.yml) defines the `bot` service
and the internal `renderer` service.
2. Set the env vars above in Coolify.
3. **No public domain / port** is needed — polling is outbound-only. Do not
publish a port or attach a domain. `expose: 8080` keeps the health endpoint
reachable only inside Coolify's network.
3. **No public domain / port** is needed for the bot itself — polling is
outbound-only. Never publish a host port. `expose: 8080` keeps the health
endpoint reachable only inside Coolify's network. **Only if the `noitu` game
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/`, `/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
double-fires crons. Prefer **stop-first redeploys** so two containers never
@@ -190,6 +207,30 @@ truth, so no separate command-menu file or manual registration step is
required. See [Command discovery](../README.md#command-discovery) for how the
menu text is built.
## 4. Group setup
By default a bot in a group runs in privacy mode. It receives only commands
addressed to it, replies to its own messages, and service messages, and it
cannot read messages sent by other bots. That breaks two things:
- A bare `/command` typed as a reply to another bot's message is routed to that
other bot. Address this bot explicitly, as `/command@<this bot>`.
- `/addsticker` and `/alias` replying to another bot's message (for example, an
image another bot posted) get the reply with its content stripped.
To make both work in a group:
1. In @BotFather, enable **Bot-to-Bot Communication Mode** for this bot.
2. Promote the bot to **admin** in the group. Admins receive every message, so
no permissions beyond the defaults are needed.
Disabling privacy mode in BotFather (`/setprivacy`) is the alternative to admin
rights, but Telegram applies it only after the bot is removed from the group and
added back. Admin rights take effect immediately. Verified on 2026-10-09:
`/addsticker@<this bot>` replying to another bot's image failed with privacy
mode off and the bot not re-added, and worked once the bot was promoted to admin.
See Telegram's [privacy mode and bot-to-bot rules](https://core.telegram.org/bots/features#privacy-mode).
## Operations
The live deployment is the Coolify container and MongoDB is the sole system of
+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.
+401
View File
@@ -0,0 +1,401 @@
# Nối từ game
`noitu` is a Telegram HTML5 game: Vietnamese word chaining ("nối từ"). It has
three commands:
- `/noitu` sends the game into a group as a room card: the members who press
Play on it play each other. See [Playing together](#playing-together-noitu).
It works only in groups; elsewhere it answers with a hint (see below).
- `/noitubot` sends the separate `noitubot` game for a solo game against the
bot, in a group or a private chat.
- `/noitutop` shows the group's leaderboard across its room games. See
[Leaderboard](#leaderboard-noitutop).
Pressing **Play** opens a page that this bot serves itself. The final score
goes to Telegram's in-chat high-score table.
Until October 2026 the solo game was `/noitu` and the room card was
`/noitupvp`. A one-time startup migration moved their `/stats` history to the
new names: `noitu` to `noitubot` first, then `noitupvp` to `noitu`.
The rules, the dictionary and the bot opponent come from `tiennm99/noitu`. Its
Go code is ported in-tree under `internal/modules/noitu/{dict,engine,opponent}`,
and its dictionary is embedded in the binary.
## Setup
1. **BotFather:** create two games for the bot with `/newgame`, short names
`noitu` (the group card) and `noitubot` (the solo game). Both open the same
page; separate games let players tell a room card from a solo game.
2. **Public URL:** attach an HTTPS domain to the bot container's port 8080. In
Coolify, set the `bot` service's domain to `https://noitu.example.com:8080`.
The proxy terminates TLS and forwards to the container.
3. **Environment:**
- `GAME_BASE_URL`: set it to that domain, with no port and no trailing slash,
for example `https://noitu.example.com`. The page is served at
`<GAME_BASE_URL>/games/noitu/`.
- `NOITU_GAME_SECRET` (optional): at least 32 bytes. It signs the game
links. When it is unset, a key is derived from `TELEGRAM_BOT_TOKEN`, so
rotating the bot token invalidates game links that are already open.
4. Keep `noitu` in `MODULES`, or leave `MODULES` empty.
With `GAME_BASE_URL` unset or invalid, the game is disabled:
- `/noitu` and `/noitubot` still send the game.
- Pressing Play shows the alert "Trò chơi nối từ chưa được cấu hình trên máy
chủ này."
- No `/games/` route exists, so the bot needs no public ingress.
A `GAME_BASE_URL` that is not `https://host[/path]` is logged as a warning and
also disables the game. So is a `NOITU_GAME_SECRET` shorter than 32 bytes.
## How a game flows
1. `/noitubot` calls `sendGame` with no keyboard, so Telegram adds the Play
button itself. In a forum topic the game stays in the topic. In a channel the
bot answers with a short text instead, because Telegram does not allow games
in channels.
2. Play delivers a callback query with `game_short_name=noitubot`. The module
answers it with a URL: `<GAME_BASE_URL>/games/noitu/?t=<token>`. The token
is signed, expires after 6 hours, and names the player and the game message.
The game message is either a chat message (`chat_id` + `message_id`) or, for
a game sent through a `?game=` share, an `inline_message_id`.
3. The page sends the token to `api/start` and gets back a session. From then
on the session ID is what identifies the game.
4. Each word goes to `api/move`. The server validates it, plays the bot's reply
in the same request, and returns the new state.
5. When the game ends, the server reports the score with `setGameScore`
(`force=false`), once, and only if the score is above 0.
The client never sends a score. The server owns the chain, the clock and the
points.
## Rules
- **Opening:** the bot opens with a random word whose last syllable starts at
least 20 words. The player moves first.
- **Valid word:** at least 2 syllables, present in the dictionary, starting with
the previous word's last syllable, and not already played in this game.
- **Order of checks:** syllables, dictionary, link, reuse. A rejected word keeps
the turn and the clock.
- **Accepted spellings:**
- Input is normalized: Unicode NFC, lowercase, and collapsed spaces.
- Alternative tone placement in `oa`/`oe`/`uy` is accepted (`hoà` and `hòa`).
- An `i`/`y` swap after `h k l m t qu` is accepted, as are the `sĩ`/`sỹ` and
`vĩ`/`vỹ` pairs.
- A variant counts only when exactly one dictionary word matches it.
- **Turn timer:**
- Each turn lasts 30 seconds.
- A move that arrives within 2 more seconds still counts, which absorbs
network latency, but it earns no speed points.
- After that the player loses on time.
- A player handed a dead end keeps the turn. When the clock runs out the
game ends as `no_legal_move`, not as a timeout. Pressing "Chịu thua" in a
dead end ends it as `no_legal_move` at once; in a playable position it
ends as `gave_up`.
- **Bot:**
- The player picks the bot's difficulty: easy (a random legal word), medium
(one-move lookahead; the default), or hard (a 4-ply negamax, capped at
20,000 nodes).
- The bot answers instantly, and the page shows a short pause.
- If the bot has no legal word, the player wins.
- **Chain cap:** a chain that reaches 300 words also ends as a player win.
- **After a loss:** the page shows up to 3 words the player could have played.
An empty list means the position was a true dead end.
## Scoring
Each of the player's accepted words scores the sum of five parts, capped at 100
points per word:
| Part | Points |
|---|---|
| Base | 10 |
| Chain | 2 × min(words already played, 15) |
| Length | 5 × (syllables − 2) |
| Speed | 10 × time left ÷ 30 s (measured on the server) |
| Rarity | max(15 − 3 × ⌊log₂(words that start with the answered syllable)⌋, 0) |
The game score is the sum of the player's words. The bot's words are shown but
do not count.
`setGameScore` uses `force=false`, so Telegram keeps each player's best. A
score that is not higher returns `BOT_SCORE_NOT_MODIFIED`, which the page
treats as saved.
## HTTP API
Every route lives under `/games/noitu/`:
- `GET /games/noitu/` serves the page, and `GET /games/noitu/app.js` and
`GET /games/noitu/app.css` serve its assets.
- Every API route is a `POST` with a JSON body.
| Route | Body | Answer |
|---|---|---|
| `api/start` | `{"token","difficulty":"easy\|medium\|hard"}` | session view |
| `api/move` | `{"session","word"}` | `{"result":{accepted,reason,message,player_word,bot_word},"state":view}` |
| `api/state` | `{"session"}` | session view; also settles an expired turn |
| `api/give-up` | `{"session"}` | session view; `no_legal_move` in a dead end, otherwise `gave_up` |
The session view has these fields:
- `session`, `player`, `difficulty`
- `status`: `playing`, `won` or `lost`
- `end_reason`: `timeout`, `no_legal_move`, `gave_up`, `bot_stuck` or
`max_moves`
- `current`: the syllable the next word must start with
- `chain`: a list of `{word, by: bot|player, points, meanings[{pos, gloss}]}`
- `score`
- `turn_limit_ms`, `deadline_ms` and `server_now_ms`: the page counts down
from `deadline_ms − server_now_ms`, so it never depends on the phone's clock
- `suggestions`
- `score_reported`: `pending`, `ok`, `failed` or `skipped`
A move the rules reject answers `accepted:false` with one of these reasons:
`too_few_syllables`, `not_in_dictionary`, `wrong_link`, `already_used` or
`timeout`. Each comes with a Vietnamese message.
Errors are `{"error","message"}`:
| Code | Status |
|---|---|
| `bad_request` | 400 |
| `bad_token` | 401 |
| `token_expired` | 401 |
| `no_session` | 404 |
| `game_over` | 409 |
| `too_fast` | 429 |
| `busy` | 503 |
## Limits and security
- **Token:**
- Format: `base64url(json) "." base64url(HMAC-SHA256)`, checked with
`hmac.Equal`.
- It expires after 6 hours and is checked only at `api/start` and
`api/room/join`.
- It travels in the query string, never in the path. The request log records
only the path. The page removes it from the address bar on load and keeps
it in memory and `sessionStorage`, so copying or sharing the page link
does not leak it.
- Every response sends `Referrer-Policy: no-referrer`, so the token cannot
leak through the referrer.
- **Request limits:**
- Bodies are capped at 4 KiB, and unknown JSON fields are rejected.
- A word is at most 64 characters and 8 syllables.
- At most one move per 300 ms per session.
- At most 10 starts per user per minute.
- **Sessions:**
- At most 2,000 live sessions; beyond that, `api/start` answers `busy`.
- At most 3 live sessions per user. A fourth start ends the user's oldest
session.
- A new start replaces the player's own session on the same game message.
- A session that a start replaces or ends is settled first: an unfinished
game ends as given up (or `no_legal_move` in a dead end), and a positive
score is still reported.
- Sessions live in memory only, so a restart ends live games. The page then
returns to the start screen with "Ván chơi không còn nữa. Hãy bắt đầu ván
mới."
- An idle game is dropped after 15 minutes, and a finished one after 2
minutes.
- A minutely cron settles turns that ran out with the page closed, so those
scores are still reported.
- **Headers:**
- A strict CSP: the page has no inline script or style, and only
`https://telegram.org` is allowed as an external script source.
- `X-Content-Type-Options: nosniff`.
- API responses are `no-store`.
- Framing is not restricted, because Telegram Web shows games in an iframe.
## Playing together (`/noitu`)
`/noitu` works in groups and supergroups only. In a private chat or a channel
it does not send a game; it answers "Gửi /noitu trong nhóm để chơi cùng nhau.
Muốn chơi với bot thì dùng /noitubot nhé." It sends the
BotFather game `noitu`, and records the sent message as a **card** in the
module's storage collection (`noitu`, key `pvp:<chat_id>:<message_id>`, with
the forum topic). If the card cannot be recorded, the bot deletes it again and
answers "Không tạo được phòng nối từ. Thử lại sau nhé."
- Play on a card issues a token marked PvP (`p`), carrying the card's forum
topic (`t`). The page reads that flag and opens the room instead of the
start screen. The server checks the flag again: `api/start` refuses a PvP
token and `api/room/join` refuses any other.
- A forwarded copy of a card is a different message, and a `?game=` share is
an inline message; neither is a card, so both play against the bot. A
`noitubot` game is never looked up as a card and always plays solo, as do
`noitu` games sent before the split.
- A card stays a room for 30 days after its last Play press. Play refreshes
that date at most once a day, and a daily cron (`noitu_pvp_cards`, 03:30
ICT) forgets older cards, which then play solo. The cron runs whenever the
storage is available, even while the game is disabled.
### Rooms
- Everyone who presses Play on the same card joins **one room**. A second
press, or a reload, keeps the member's place.
- The lobby lists the members in join order. The first is the **host** and
starts a game with "Bắt đầu", which needs at least 2 players.
- A game seats the first 8 members, in join order. Anyone else, and anyone
who joins during a game, watches.
- When a game ends, the room returns to the lobby with the result, and the
host can start the next game on the same card.
- Rooms live in memory only, so a restart ends them. A lobby member whose page
stops polling for a minute leaves the room, and the next member becomes
host; a running game never drops anyone. A room without a game is dropped
once nobody is left, so a lobby stays open while any page still polls. A
page whose room is gone joins again with its token, which opens a fresh
room on the same card.
- Limits: at most 500 live rooms, and at most 3 per chat. Joins share the solo
limit of 10 starts per user per minute. The host's "Bắt đầu" counts against
that same limit, and one room starts at most one game every 3 seconds.
### Room rules
The words, the checks, the 30-second turn with its 2-second grace and the
per-word points are the same as against the bot (see [Rules](#rules) and
[Scoring](#scoring)); one engine implements both. What changes:
- Turns rotate in join order, starting with the first seat. The bot only plays
the opening word.
- A player whose turn runs out, or who presses "Chịu thua", is **eliminated**.
The syllable and the used words stay as they were, and the turn passes to
the next player still in.
- "Chịu thua" also works out of turn, to leave a game. In a dead end on the
player's own turn it counts as no legal move.
- A word handed to the next player as a dead end does not end anything: that
player loses it on the clock or with "Chịu thua", and everyone behind them,
who faces the same board, goes out with them. The player who closed the
position wins.
- An expired turn is settled on the next request or by the minutely sweep.
The next turn starts when the expired one ended, grace included, so a late
settlement does not shorten or lengthen anyone's turn.
- The **last player standing** wins. If the chain reaches 300 words, the
player still in with the most points wins.
### Room scoring
- Each player scores the per-word points of their accepted words. The winner
gets 50 more.
- A game in which nobody played a word (everyone left at once) earns nothing:
no bonus, no score report, no announcement and no leaderboard entry.
- When a game ends, the bot posts "Ván nối từ kết thúc: <tên> thắng sau <n>
từ!" as a reply to the card, in its topic. A failure is logged and ignored.
- Then it reports each player whose score is above 0 with `setGameScore`
(`force=false`) on the card, one at a time. Results of one card are
published one game after another, so two games never edit the card at
once; results of different cards publish independently. The card's
high-score table therefore ranks the group, and keeps each member's best.
### Room API
All room routes are `POST` with a JSON body, under `/games/noitu/api/room/`.
The join answers a `member` bearer; every other route takes it.
| Route | Body | Answer |
|---|---|---|
| `join` | `{"token"}` | room view |
| `state` | `{"member","version"}` | room view, or `{"version","unchanged":true,"server_now_ms"}` when `version` is current |
| `start` | `{"member"}` | room view; host only |
| `move` | `{"member","word"}` | `{"result":{accepted,reason,message,player_word},"state":view}` |
| `give-up` | `{"member"}` | room view |
The page polls `state` every second (every 4 seconds while hidden) and sends
the last `version` it saw, so an unchanged room costs one small answer. Every
change to the room, a finished score report included, bumps the version.
The room view has these fields:
- `member`, `version`, `game` (games started in this room)
- `status`: `lobby` or `playing`
- `host`: whether this member hosts the lobby
- `seat`: this member's seat in the running game, `-1` otherwise; `turn`: the
seat to act, `-1` in the lobby
- `players`: `{name, you, host, score, alive, out_reason}`, the seats in a
game or the first 8 members in the lobby; `watchers`: everyone else
- `min_players`, `max_players`
- `current`, `chain` (entries also carry `seat`, `-1` for the opening, and
`name`), `turn_limit_ms`, `deadline_ms`, `server_now_ms`
- `result`: the last finished game until the next starts, `{winner, words,
end_reason, standings[{name, rank, score, bonus, words, out_reason, you}],
score_reported}`
A word sent out of turn, by an eliminated player or by a watcher answers
`accepted:false` with reason `not_your_turn`; a word that arrives after the
grace period answers `timeout`.
| Code | Status |
|---|---|
| `no_room` | 404: unknown member, or the room is gone |
| `not_host` | 403 |
| `room_full` | 409: 32 members |
| `not_enough_players` | 409 |
| `game_running` | 409 |
| `no_game` | 409: move or give-up without a running game |
| `not_in_game` | 409: give-up by a watcher or an eliminated player |
The solo limits apply to rooms too: the 4 KiB body cap and strict JSON, the
word length, one move per 300 ms per member, and the 10 starts per user per
minute, which joins share. At most 500 rooms exist; beyond that a join answers
`busy`. Player names are Telegram first names cut to 64 characters; the page
only ever renders them as text.
## Leaderboard (`/noitutop`)
Every room game in which at least one word was played (the games that are
announced) updates the chat's leaderboard. It is stored in the module's
storage collection (`noitu`), one document per player and chat, keyed
`top:<chat_id>:<user_id>`:
- games played and games won, as a seated player
- the best single-game score and the total of all game scores, winner bonus
included
- the player's latest first name
The update runs in the background publisher after the room is back in the
lobby, before the announcement. A failure is logged and skips only that
player. Two games ending at once for the same player are both kept: each
update is a versioned write that retries on conflict.
`/noitutop` in a group lists the top 10, ranked by wins, then the best game,
then fewer games played (the same record in fewer games ranks higher), then
total points. A caller ranked below 10 also sees their own line under the
table. Names are HTML-escaped. In a private chat or a channel it answers
"Bảng xếp hạng nối từ chỉ có trong nhóm…". Telegram's own high-score table on
each card is separate: it keeps each member's best score on that card only.
## Sharing, and why there is no inline mode
Players share a game in three ways:
- by forwarding the game message;
- with the `t.me/<bot>?game=noitubot` or `?game=noitu` link (both play solo);
- with the share button Telegram shows on the game page.
When `telegram.org/js/games.js` loads, the page also shows "Chia sẻ điểm",
which calls `TelegramGameProxy.shareScore()`. The page works without that
script.
Inline-mode game results (`@bot noitu`) are not offered. Inline queries have a
single owner in the registry, the `alias` module, which answers them with saved
aliases. Mixing game results into that handler would couple two unrelated
modules for a sharing path the three above already cover.
A game sent through a `?game=` link is expected to come back as an inline
message (`inline_message_id`). Its score is reported with a raw Bot API call,
`internal/telegram/game_score.go`, because `go-telegram/bot` v1.20.0 types
`inline_message_id` as an int and cannot decode Telegram's `true` answer.
## Attribution
The dictionary is
[`internal/modules/noitu/dict/data/dictionary.txt`](../internal/modules/noitu/dict/data/dictionary.txt).
It is derived from [Wiktionary tiếng Việt](https://vi.wiktionary.org/) and
licensed under [CC BY-SA 4.0](../internal/modules/noitu/dict/data/LICENSE).
- It is embedded unmodified with `go:embed`.
- Its [ATTRIBUTION.md](../internal/modules/noitu/dict/data/ATTRIBUTION.md)
records the source and the modifications.
- The page footer credits it.
- CC BY-SA applies to the data only. The bot's code stays Apache-2.0.
+4
View File
@@ -113,6 +113,10 @@ exactly 512px with the aspect ratio preserved, uploaded as a PNG, and then
added — attributed to the pack owner, matching the set it is about to join.
Sources above 2 MB, or with either side above 4096px, are rejected.
**Replying to another bot's image** works only after the
[group setup](deploy-coolify-selfhosted.md#4-group-setup); otherwise Telegram
strips the image out of the reply.
## Video and GIF
A replied video, GIF, animation or video note is downloaded and transcoded with
+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).
+343
View File
@@ -0,0 +1,343 @@
# Wordle Daily
`wordledaily` is a Telegram HTML5 game modelled on NYT Wordle and its Discord
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.
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
this bot serves.
- `/wordledaily_subscribe` opts the chat, or the forum topic, into a daily push
at 07:00 ICT.
- `/wordledaily_unsubscribe` opts it out.
A topic means a real forum topic (`is_topic_message`). Telegram also gives a
reply chain in an ordinary supergroup a thread ID; a command sent as such a
reply counts for the whole chat, the same scope its plays and group results
are stored under.
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 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
1. **BotFather:** create a game for the bot with `/newgame` and the short name
`wordle`. The share link is then `t.me/<bot username>?game=wordle`. The
module, its commands and its routes keep the name `wordledaily`; only the
BotFather game is `wordle`.
2. **Public URL:** this uses the same `GAME_BASE_URL` as the
[noitu game](noitu-game.md#setup). The page is served at
`<GAME_BASE_URL>/games/wordledaily/`.
3. **Environment:** `WORDLEDAILY_GAME_SECRET` is optional and must be at least
32 bytes. Two keys are derived from it, each with its own label:
- `tiennm99bot/wordledaily/token/v1` signs the Play links.
- `tiennm99bot/wordledaily/answer/v1` orders the daily answers.
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. A `MODULES`
value that still names the retired `wordle` module loads `wordledaily`.
The game is disabled when any of these holds:
- `GAME_BASE_URL` is unset or invalid.
- The secret is shorter than 32 bytes.
- There is no storage.
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.
## The puzzle
- **Day boundary:** puzzle #1 started at 07:00 ICT on 9 October 2026. Puzzle `n`
runs from `epoch + (n−1) days` until one day later. Because 07:00 ICT is
00:00 UTC, the 00:00 UTC cron always pushes exactly the puzzle a player then
gets.
- **Word lists:**
- `wordlist/data/answers.txt` holds about 2,230 common English words that a
puzzle can be. It was curated for this bot; it is not NYT's list.
- `wordlist/data/words.txt` (14,855 words) is what a guess may be.
- A test checks that the answers are sorted, unique, and all in the
dictionary.
- **Choosing the answer:** the days are split into cycles as long as the answer
list. Each cycle walks one permutation of the list: a Fisher–Yates shuffle
seeded with `HMAC(answer key, "cycle:<n>")` through ChaCha8.
- No answer repeats within a cycle (about six years).
- The order can't be guessed without the key.
- **Pinning:** the first time a puzzle is needed (a request, or the push at
07:00), its answer is stored as `puzzle:<n>`. Every later read uses that
document, so a new secret or an edited answer list never changes a puzzle
that has already started.
- **Rollover mid-game:** a guess names the puzzle number the page shows. A
guess for a puzzle that is no longer current is refused with `new_puzzle`,
and the page reloads the new board.
## Playing
- **Validation:** the server checks every guess and colours it. Two-pass marking
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 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 %,
current and max streak, and the guess distribution, shown on the end screen.
- The current streak counts consecutive puzzle wins.
- A loss or a missed day ends it.
- A streak whose last win is older than yesterday shows as 0.
- **End screen:** it shows the answer, the stats, a countdown to the next
puzzle and two buttons:
- **Share** uses Telegram's game share.
- **Copy result** copies the spoiler-free text, such as
`Wordle Daily #12 3/6` followed by the emoji grid.
- **Page features:** physical and on-screen keyboards, a dark scheme that follows
the system, and a high-contrast (orange/blue) toggle that is remembered in
`localStorage`. The board is sized from the height the header and keyboard
leave, so the page fits a short phone screen without scrolling. Each tile
turns to its colour in turn, and the keyboard colours once the row is
revealed. Tiles and keys carry their result as an accessible label, and
each guess's result is announced to screen readers.
- **Score:** a win reports `7 − guesses` with `setGameScore` (`force=false`). A
1-guess win scores 6 and a 6-guess win scores 1. A loss is not reported.
- The score is set on the card the player played from.
- When they open another card the same day, it is set there too, once per
card.
- Each day's card therefore carries that day's leaderboard.
- A card is recorded as reported only once Telegram accepts the score.
`BOT_SCORE_NOT_MODIFIED` counts as accepted. Any other failure is logged,
and the next time the player opens that card the score is set again.
## Groups: mapping the Discord Activity to Telegram
NYT's Discord Activity works like this:
- Everyone plays the same daily word.
- A live embed in the channel shows each player's progress.
- Results are spoiler-free colour grids.
- A daily message recaps yesterday's scores, crowns the best, and shows the
group streak.
- Reminders of a new puzzle can be muted.
Telegram has different limits:
- A game message has no caption, so any recap has to be a separate text message.
- Bots can edit their own text messages.
- `setGameScore` in a group also posts Telegram's own "X scored N" service
message, which cannot be avoided.
- A message holds at most 4,096 characters, and groups allow about 20 messages
a minute.
The bot maps the Activity onto those limits as follows.
| Discord Activity | Here |
|---|---|
| `/wordle` launches the activity | `/wordledaily` sends the card; Play opens the board |
| Live channel embed of everyone's progress | One **live results** message per group, topic and day (below) |
| `/share` and the Share button | **Share** and **Copy result** on the end screen |
| Daily recap of yesterday, crown, group streak | The 07:00 ICT push to subscribed chats (below) |
| Mute daily reminders | `/wordledaily_unsubscribe` |
| Personal streaks | Per-user stats, the same for every chat |
### Live results message
A live results message exists only in groups and supergroups, never in a
private chat or for an inline card. It works like this:
- **Who appears:** a player is listed in a group topic's day once they have
made a guess from a card in that topic. A player who finished elsewhere and
then opens the group's card is listed at once, with their finished grid.
- **Creating it:** the first time, the bot sends one silent message as a reply
to the card, with `AllowSendingWithoutReply`. After that it only edits that
message.
- **Rate of updates:** edits are coalesced, at most one every 5 seconds per
message, and the text is always rendered from the store.
- **Errors:**
- A "message is not modified" answer is ignored.
- If the message was deleted, a new one is sent at most once every 10
minutes. An update that comes sooner is held until then, so the final
results still arrive.
- A failure that may pass (429, a timeout, a 5xx, a store error) is retried
up to 5 times, waiting Telegram's `retry_after` or 5, 10, 20, 40 and 80
seconds. A refused request (bad request, forbidden) is not retried.
- **Content:** colour grids only, never letters. The rows are ordered like
this:
1. Winners, by fewer guesses and then earlier finish.
2. Losses (`X/6`).
3. Players still playing, with their partial grid, most guesses first.
The message stops before Telegram's limit and ends with `+N more`.
```
Wordle Daily #12 · live results
🔥 Group streak: 5 days
Alice 3/6
⬜🟨⬜⬜⬜
🟨🟩⬜🟩⬜
🟩🟩🟩🟩🟩
Bob X/6
…
Carol playing 2/6
⬜⬜🟨⬜⬜
⬜🟩🟩⬜⬜
```
### The 07:00 ICT push
The cron `wordledaily_daily_push` (`0 0 * * *` UTC) does the following:
1. Pins today's answer.
2. Deletes the `puzzle:`, `play:` and `cday:` documents of puzzles older than
yesterday's (see [Storage](#storage)).
3. Claims the day under `daily_push:last_date`, so a second trigger on the same
day sends nothing.
4. For each subscriber:
1. In a group, it sends yesterday's recap. The recap opens with yesterday's
answer, then the group streak, then the players who finished.
2. It sends today's card in the same topic.
If the recap fails to send, the card is skipped. The shared subscription fan-out
then prunes chats that blocked or removed the bot. Sends are throttled above 30
subscribers.
```
Wordle Daily #11 — yesterday's results
Answer: CRANE
🔥 Your group is on a 5 day streak!
👑 3/6: Alice, Dan
4/6: Eve
X/6: Bob
```
What else the push sends depends on the day before:
- **Group streak:** it counts consecutive puzzles that at least one member of
the group or topic **solved**; a loss does not keep it going. If nobody
solved yesterday's word and a streak of at least 2 days was alive, the
streak line reads `Nobody solved it. Group streak reset.`
- **Nobody played:** the recap is just the title and the answer.
- **Private chats:** a private chat always gets just the card.
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.
## HTTP API
Every route lives under `/games/wordledaily/`:
- `GET /games/wordledaily/` serves the page, and `GET app.js` and `GET app.css`
serve its assets.
- The API routes are `POST` with a JSON body.
Headers, CSP, caching and the 4 KB request limit are the same as noitu's,
through `internal/modules/util/htmlgame`.
| Route | Body | Answer |
|---|---|---|
| `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 token decides the mode. The daily view has these fields:
- `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`
- `next_at`: the unix second the next puzzle starts
- `answer`, `stats` (`played`, `win_pct`, `cur`, `max`, `dist[6]`) and `share`:
only once the game is over
Errors are `{"error","message"}`:
| Status | Codes |
|---|---|
| 400 | `bad_request` |
| 401 | `bad_token`, `expired` |
| 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, every route together |
A Play token expires after 6 hours and is not tied to a day.
## Storage
Everything is in the module's collection, under separate key prefixes.
| Key | Holds |
|---|---|
| `puzzle:<n>` | the pinned answer of puzzle `n` |
| `play:<n>:<user>` | one player's board on puzzle `n`: guesses with marks, status, finish time, cards reported on, group topics joined |
| `stats:<user>` | played, wins, current and max streak, guess distribution, last puzzle finished and won |
| `cday:<n>:<chat>:<thread>:` | the players of a group topic's day, the card the summary replies to, and the summary message ID |
| `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
already counted, and when the stats write after a finished board fails, the
next state load or guess folds that board into the stats. So the stats always
follow the board and never count a game twice. Group documents use versioned
writes with retries.
**Retention:** the 07:00 push deletes `puzzle:<n>`, `play:<n>:*` and
`cday:<n>:*` for every `n` older than yesterday's puzzle; nothing reads them
after the recap. `stats:`, `cstreak:` and the subscription documents are
kept. With the memory store this keeps the process's memory bounded.
## Choices made
These follow NYT's behaviour where it is known. Change them here if wanted:
- The recap shows yesterday's answer first.
- The group streak counts wins only.
- The live results message shows partial grids of players still playing, as
NYT's live embed does. This reveals some colour hints to members who have
not played yet.
- The answer list was curated for this bot and can be replaced. A puzzle that
has already started keeps its stored answer.
+11
View File
@@ -31,3 +31,14 @@ func (m *Map) Acquire(key string) func() {
mu.Lock()
return mu.Unlock
}
// Len counts the keys that have a mutex, which is the map's memory
// footprint: keys are never freed, so callers keep their key set bounded.
func (m *Map) Len() int {
n := 0
m.m.Range(func(_, _ any) bool {
n++
return true
})
return n
}
+13
View File
@@ -71,3 +71,16 @@ func TestMap_SameKeySerialises(t *testing.T) {
t.Errorf("counter = %d, want %d (lost updates → mutex didn't serialise)", got, goroutines*itersEach)
}
}
func TestMap_LenCountsDistinctKeys(t *testing.T) {
var m Map
if m.Len() != 0 {
t.Fatalf("empty Len = %d", m.Len())
}
m.Acquire("a")()
m.Acquire("a")()
m.Acquire("b")()
if m.Len() != 2 {
t.Fatalf("Len = %d, want 2", m.Len())
}
}
+10 -6
View File
@@ -34,13 +34,17 @@ const unsupportedRefusal = "That message cannot be saved. Reply to a sticker, ph
// the answer to all of them — only the reason differs.
const forwardAdvice = "Forward it into this chat, then reply to your copy with /alias <name>."
// otherBotRefusal explains a refusal no change here can lift.
// otherBotRefusal explains why another bot's message arrived empty.
//
// Telegram's own rule: "Bots will not be able to see messages from other bots
// regardless of mode." The reply arrives with its content stripped, so there is
// nothing to save and no setting that would help — worth saying outright rather
// than letting unsupportedRefusal imply the format was wrong.
const otherBotRefusal = "Telegram does not let bots read other bots' messages, so I cannot save that one. " + forwardAdvice
// Telegram strips another bot's message out of a reply unless this bot has
// Bot-to-Bot Communication Mode enabled in BotFather and receives every group
// message — as a group admin, or with privacy mode off. Both are chat and
// BotFather settings, not code, so the refusal names them alongside the
// forward workaround rather than letting unsupportedRefusal imply the format
// was wrong.
const otherBotRefusal = "Telegram hid that bot's message from me, so I cannot save it. " +
"I can read other bots' messages only when I am an admin in this chat and Bot-to-Bot Communication is on for me in @BotFather. " +
forwardAdvice
// strippedReplyRefusal answers a reply Telegram delivered empty: it carried a
// message id but no content field at all.
+1 -1
View File
@@ -448,7 +448,7 @@ func TestAlias_ExplainsAnotherBotsMessage(t *testing.T) {
From: &models.User{ID: 555, IsBot: true, FirstName: "OtherBot"},
}))
rb.AssertSentText(t, "does not let bots read other bots")
rb.AssertSentText(t, "hid that bot's message")
}
// A human's unsupported message still gets the format advice, not the bot one.
+45
View File
@@ -123,6 +123,7 @@ func Install(b *bot.Bot, reg *Registry, auth Auth) {
})
}
installGames(b, reg, auth)
// Registered LAST, and that placement is load-bearing: the bot library
// returns the first handler whose matcher accepts an update, so every
// Command above out-ranks the fallback. A name defined in code therefore
@@ -306,3 +307,47 @@ func matchCommand(name, botUsername string, update *models.Update) bool {
}
return false
}
// installGames registers one handler per BotFather game short name, then a
// catch-all for any other game callback.
//
// A Play press carries game_short_name and no callback data, so the Data-prefix
// handlers never see it. The same panic barrier, auth gate and metrics as the
// Data callbacks apply. The catch-all answers a game that is not loaded (a
// short name created in BotFather whose module is missing from MODULES) with an
// empty answer, so the client stops spinning instead of waiting forever.
func installGames(b *bot.Bot, reg *Registry, auth Auth) {
for short, game := range reg.games {
gameCopy := game
shortCopy := short
b.RegisterHandler(bot.HandlerTypeCallbackQueryGameShortName, shortCopy, bot.MatchTypeExact,
func(ctx context.Context, b *bot.Bot, update *models.Update) {
defer recoverHandler("game", shortCopy, func() { answerEmptyCallback(ctx, b, update) })
if !auth.Permits(gameCopy.Visibility, update) {
answerEmptyCallback(ctx, b, update)
return
}
if err := gameCopy.Handler(ctx, b, update); err != nil {
metrics.IncError("game-handler-error")
log.Error("game", "short_name", shortCopy, "err", err)
}
})
}
b.RegisterHandlerMatchFunc(
func(update *models.Update) bool {
return update != nil && update.CallbackQuery != nil && update.CallbackQuery.GameShortName != ""
},
func(ctx context.Context, b *bot.Bot, update *models.Update) {
defer recoverHandler("game", "unknown", nil)
answerEmptyCallback(ctx, b, update)
},
)
}
// answerEmptyCallback stops the client's spinner without showing anything.
func answerEmptyCallback(ctx context.Context, b *bot.Bot, update *models.Update) {
if update == nil || update.CallbackQuery == nil {
return
}
_, _ = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: update.CallbackQuery.ID})
}
+94
View File
@@ -0,0 +1,94 @@
package modules_test
import (
"context"
"testing"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/tiennm99bot/internal/modules"
"github.com/tiennm99/tiennm99bot/internal/storage"
"github.com/tiennm99/tiennm99bot/internal/testutil"
)
// installGame registers one game module whose Play handler is h.
func installGame(t *testing.T, visibility modules.Visibility, h modules.CallbackHandler) *testutil.RecordingBot {
t.Helper()
rb := testutil.NewRecordingBot(t)
reg, err := modules.Build([]string{"arcade"}, map[string]modules.Factory{
"arcade": func(modules.Deps) modules.Module {
return modules.Module{Games: []modules.Game{{ShortName: "arcade", Visibility: visibility, Handler: h}}}
},
}, storage.NewMemoryProvider(), modules.BuildOptions{})
if err != nil {
t.Fatalf("build registry: %v", err)
}
modules.Install(rb.Bot, reg, modules.Auth{BotOwnerID: 1})
return rb
}
func answeredCallbacks(rb *testutil.RecordingBot) []testutil.SentCall {
var out []testutil.SentCall
for _, c := range rb.Sent() {
if c.Method == "answerCallbackQuery" {
out = append(out, c)
}
}
return out
}
func TestInstall_GameCallbackReachesHandler(t *testing.T) {
var got *models.Update
rb := installGame(t, modules.VisibilityPublic, func(_ context.Context, _ *bot.Bot, u *models.Update) error {
got = u
return nil
})
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGameCallback(42, 42, 7, "arcade"))
if got == nil || got.CallbackQuery.GameShortName != "arcade" {
t.Fatalf("game handler not called; got %+v", got)
}
}
func TestInstall_GameCallbackDeniedAnswersEmpty(t *testing.T) {
called := false
rb := installGame(t, modules.VisibilityPrivate, func(context.Context, *bot.Bot, *models.Update) error {
called = true
return nil
})
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGameCallback(42, 42, 7, "arcade"))
if called {
t.Fatal("denied user reached the game handler")
}
answers := answeredCallbacks(rb)
if len(answers) != 1 || answers[0].Form["url"] != "" || answers[0].Form["text"] != "" {
t.Fatalf("want one empty answer, got %+v", answers)
}
}
func TestInstall_GameCallbackPanicIsAnswered(t *testing.T) {
rb := installGame(t, modules.VisibilityPublic, func(context.Context, *bot.Bot, *models.Update) error {
panic("game exploded")
})
captureLogs(t, func() {
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGameCallback(42, 42, 7, "arcade"))
})
if len(answeredCallbacks(rb)) != 1 {
t.Fatalf("panicking game handler left the query unanswered; sent %+v", rb.Sent())
}
}
func TestInstall_UnknownGameShortNameAnswersEmpty(t *testing.T) {
called := false
rb := installGame(t, modules.VisibilityPublic, func(context.Context, *bot.Bot, *models.Update) error {
called = true
return nil
})
rb.Bot.ProcessUpdate(context.Background(), testutil.NewInlineGameCallback(42, "inline-1", "other"))
if called {
t.Fatal("a different short name reached the arcade handler")
}
if len(answeredCallbacks(rb)) != 1 {
t.Fatalf("unknown game was not answered; sent %+v", rb.Sent())
}
}
+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
+1 -1
View File
@@ -129,7 +129,7 @@ func formatFuelPrices(prices []fuelPrice) string {
var sb strings.Builder
sb.WriteString("⛽ Giá bán lẻ xăng dầu Petrolimex (đ/lít)\n")
if updated, ok := latestUpdate(prices); ok {
sb.WriteString("Cập nhật: " + updated.In(giaxangLocation).Format("15:04 02/01/2006") + "\n")
sb.WriteString("Giá áp dụng từ " + updated.In(giaxangLocation).Format("15:04 02/01/2006") + "\n")
}
sb.WriteString("\n<pre>" + html.EscapeString(strings.TrimRight(table.String(), "\n")) + "</pre>")
return sb.String()
@@ -20,7 +20,7 @@ const petrolimexFixture = `{"Objects":[
]}`
const giaxangFixtureReply = "⛽ Giá bán lẻ xăng dầu Petrolimex (đ/lít)\n" +
"Cập nhật: 14:48 24/09/2026\n" +
"Giá áp dụng từ 14:48 24/09/2026\n" +
"\n<pre>" +
"Mặt hàng Vùng 1 Vùng 2\n" +
"Xăng E10 RON 95-III 27.080 27.620\n" +
+26
View File
@@ -2,6 +2,7 @@ package modules
import (
"context"
"net/http"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
@@ -43,6 +44,29 @@ type Callback struct {
Handler CallbackHandler
}
// Game registers the handler for a BotFather HTML5 game's Play button.
//
// Telegram delivers a Play press as a callback query that carries
// game_short_name instead of callback data, so a Data-prefix Callback can never
// see it. The handler must answer the query — with the game URL, or with an
// alert explaining why the game cannot open — or the client keeps spinning.
type Game struct {
ShortName string // BotFather short name: ^[A-Za-z0-9_]{3,64}$, unique across modules
Visibility Visibility
Handler CallbackHandler
}
// Route is an HTTP handler a module serves on the bot's HTTP server.
//
// Pattern is a net/http ServeMux path pattern without a method or host, and
// must live under /games/<module>/ so a module can never shadow the health
// route or another module's paths. A module that has nothing to serve (for
// example because its public URL is not configured) returns no routes.
type Route struct {
Pattern string
Handler http.Handler
}
// CronHandler runs when a cron fires, driven by the in-process scheduler
// (internal/cron). The handler receives the owning module's Deps — the same
// bundle its Factory got, including its own storage Collection.
@@ -75,6 +99,8 @@ type Module struct {
Commands []Command
Callbacks []Callback
Crons []Cron
Games []Game // optional; BotFather games whose Play button this module answers
HTTP []Route // optional; routes served on the bot's HTTP server
CommandHook func(ctx context.Context, name string, update *models.Update) // optional; called by dispatcher after each authorized command invocation. update carries the originating Telegram update so hooks can attribute usage to a user.
Fallback *CommandFallback // optional; handles a /command no module registered. At most one across all modules.
Inline *InlineQuery // optional; handles inline-mode queries. At most one across all modules.
+80
View File
@@ -0,0 +1,80 @@
package noitu
import (
"context"
"net/url"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/tiennm99bot/internal/log"
)
const (
msgDisabled = "Trò chơi nối từ chưa được cấu hình trên máy chủ này."
msgNoGameTarget = "Không xác định được tin nhắn trò chơi. Hãy gửi /noitubot để chơi với bot, hoặc /noitu trong nhóm để chơi cùng nhau."
)
// 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 /noitu card
// marks the token PvP, so the page joins the card's room. Every path answers
// the query, so the client never keeps spinning.
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)
}
// Cards are noitu games and live only in groups, whose chat IDs are
// negative; a noitubot game or a private chat never needs the lookup, so a
// storage fault cannot block a solo game.
if q.GameShortName == ShortName && c.InlineID == "" && c.ChatID < 0 {
card, ok, err := s.lookupCard(ctx, c.ChatID, c.MessageID)
if err != nil {
_ = answerAlert(ctx, b, q.ID, msgCardLookup)
return err
}
c.PvP, c.ThreadID = ok, card.ThreadID
}
c.Expiry = s.cfg.now().Add(tokenTTL).Unix()
token, err := signToken(s.cfg.key, c)
if err != nil {
_ = answerAlert(ctx, b, q.ID, msgDisabled)
return err
}
// The form tells whether ?game= shares arrive as inline messages; the IDs
// themselves are never logged.
log.Debug("noitu play", "address", form, "pvp", c.PvP)
_, err = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{
CallbackQueryID: q.ID,
URL: s.cfg.baseURL + routePrefix + "?t=" + url.QueryEscape(token),
})
return err
}
// playClaims addresses the game message: the message itself when the bot can
// see it, the inaccessible message's chat and id otherwise, and the inline
// message id for a game sent via the bot. form is "" when none is present.
func playClaims(q *models.CallbackQuery) (claims, string) {
c := claims{UserID: q.From.ID, Name: truncateName(q.From.FirstName)}
switch {
case q.Message.Message != nil:
c.ChatID, c.MessageID = q.Message.Message.Chat.ID, q.Message.Message.ID
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 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
}
+39
View File
@@ -0,0 +1,39 @@
package noitu
import (
"context"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/tiennm99bot/internal/modules/util/chathelper"
)
const (
// soloCommand sends the game played against the bot. It shares its name
// with that BotFather game.
soloCommand = SoloShortName
msgChannel = "Không thể chơi nối từ trong kênh. Hãy dùng /noitubot trong nhóm hoặc chat riêng với bot."
msgSendGameFail = "Không gửi được trò chơi nối từ. Thử lại sau nhé."
)
// handleCommand (/noitubot) sends the noitubot BotFather game for a solo game. No reply markup: Telegram then adds
// the Play button itself, which is the button the game requires first.
// The command works even when the game is disabled; Play explains why.
func (s *service) handleCommand(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg.Chat.Type == models.ChatTypeChannel {
return chathelper.Reply(ctx, b, msg, msgChannel)
}
_, err := b.SendGame(ctx, &bot.SendGameParams{
ChatID: msg.Chat.ID,
MessageThreadID: msg.MessageThreadID,
GameShorName: SoloShortName, // the library's field name is misspelled
})
if err != nil {
_ = chathelper.Reply(ctx, b, msg, msgSendGameFail)
return err
}
return nil
}
+28
View File
@@ -0,0 +1,28 @@
package dict
import (
_ "embed"
"sync"
)
// corpus is tiennm99/noitu's data/dictionary.txt, embedded unmodified. It is
// CC BY-SA 4.0 data; see data/LICENSE and data/ATTRIBUTION.md.
//
//go:embed data/dictionary.txt
var corpus string
var (
defaultOnce sync.Once
defaultStore *Store
errDefault error
)
// Default parses the embedded corpus on first use and returns the shared
// Store. Parsing takes tens of milliseconds and a few MB of heap, so it only
// runs when the game is enabled.
func Default() (*Store, error) {
defaultOnce.Do(func() {
defaultStore, errDefault = Parse(corpus)
})
return defaultStore, errDefault
}
@@ -0,0 +1,104 @@
# Dictionary Data Attribution
> Copied from `tiennm99/noitu` (`data/dictionary.txt` and `data/LICENSE`
> unchanged, this file with one sentence changed: how the corpus ships in the
> bot binary) for the bot's `noitu` game. Paths below such as
> `data/dictionary.txt` and the `make` targets refer to that repository.
The Vietnamese dictionary data used by this game is **not** original work of this project.
It is derived from Wiktionary text licensed under **CC BY-SA 4.0**, and this file records
the attribution and the modifications required by that license.
## Source
| Field | Value |
|---|---|
| Original work | Entries of [Wiktionary tiếng Việt](https://vi.wiktionary.org/), written by its contributors |
| Original license | [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/) (Wiktionary text is dual-licensed CC BY-SA / GFDL) — full text in [`LICENSE`](./LICENSE) |
| Asset | [`viwiktionary-latest-pages-articles.xml.bz2`](https://dumps.wikimedia.org/viwiktionary/latest/viwiktionary-latest-pages-articles.xml.bz2) — the Wikimedia Foundation's dump of every page of the Vietnamese Wiktionary edition with its current wikitext, ~61 MB compressed, ~43,000 pages with a Vietnamese section |
| Refresh | regenerated monthly by Wikimedia; `latest/` is repointed at each new run |
**The asset is not pinned.** Each refresh fetches whatever `latest/` currently points at,
roughly monthly. The exact bytes the committed `data/dictionary.txt` was derived from are
recorded in its `#@` header lines and carried into every `data/noitu.db` built from it, in its
`meta` table:
`source_sha256` (SHA-256 of the file as read), `source_pages` (pages with a Vietnamese
section, redirects excluded) and `source_fetched_at` (the dump's own modification time).
Two refreshes a month apart may differ by a few hundred words; the corpus's git history
shows exactly which words and definitions changed, and the hash says which dump a given
image shipped. Dated dumps under `dumps.wikimedia.org/viwiktionary/`
exist should a build ever need reproducing.
The attribution chain has one link before this project: Wiktionary tiếng Việt's
contributors, who wrote the entries. The dump is their text as the wiki stores it; this
project's builder reads the wikitext itself.
## Modifications made by this project
`server/cmd/build-dictionary` transforms the dump into `data/dictionary.txt`, a sorted text
corpus of the accepted words and their definition excerpts, and builds `data/noitu.db` from
that corpus. Both are a **modified version** of the source data. Changes:
1. **Section selection** — read only the Vietnamese section of each page, in either of the
two markup dialects the wiki currently uses (`{{-vie-}}` or `== {{langname|vi}} ==`).
Pages outside the main namespace, redirects, and pages with no Vietnamese section were
skipped. Other languages' sections on the same page were not read.
2. **Length filter** — kept only words of **2 or more space-separated syllables**, as
required by the nối từ game rules. Single-syllable entries were dropped.
3. **Content filter** — dropped entries containing digits or punctuation, entries using
letters Vietnamese does not have (f, j, w, z), and entries whose syllables do not fit
Vietnamese phonotactics (a closed inventory of onsets, nuclei and codas). Diacritic-free
Vietnamese words ("con cua") are kept.
4. **Normalization** — all words Unicode NFC-normalized, lowercased, and
whitespace-collapsed. Capitalized headwords (`Hà Nội`) become lowercase entries; nothing
is removed on the basis of capitalization or part of speech. Two pages whose titles
normalize to one word are merged into one entry.
5. **Spelling aliases** — added an `aliases` table mapping alternative Vietnamese spellings
to canonical entries. Two kinds: competing tone placement in open oa/oe/uy syllables
(`hoà` → `hòa`, `thuý` → `thúy`), and i/y alternation in Sino-Vietnamese syllables
(`quí` → `quý`, `lí` → `lý`). The majority are the i/y kind. These aliases are generated
by this project and are not present upstream.
6. **Definition text** — each entry carries an **excerpt and modification** of its
definitions, in a `meanings` table: the text of each `#` definition line of the
Vietnamese section, with wiki markup removed (links reduced to their display text,
formatting and references dropped, a few context and link templates unwrapped, every
other template removed whole), cut to at most **five senses of 200 characters** each,
and labelled with the Vietnamese name of the part-of-speech heading it sat under
(`danh từ`, `động từ`, …; empty when the heading was not one the builder knows). This
is not the entry as written: senses past the fifth, text past 200 characters, and
template-only definitions the builder does not understand are gone.
7. **Added columns and tables** — `first` and `last` syllable columns, a `syllables`
count, an index on `first`, a `syllables` out-degree table, the `meanings` table above,
and a `meta` table recording provenance (source URL, SHA-256, page count, fetch time,
licence). All added for game lookups.
8. **Deduplication** — a generated spelling variant that is itself a real word, or that
more than one word would claim, is discarded rather than recorded as an alias.
9. **Dropped fields** — everything else in an entry was discarded: example sentences,
quotations, translations, pronunciations, etymologies, synonyms, derived terms,
categories, images and references. The derived database carries word forms and the
definition excerpts described in item 6, and nothing else of the entry.
## Share-alike obligation
CC BY-SA 4.0 is a **share-alike** license. The derived corpus `data/dictionary.txt`, the
database `data/noitu.db` built from it, and any distribution of either, remain licensed under
**CC BY-SA 4.0** — including this repository, which distributes the corpus, and a container
image or any other packaged build of this project. Because the database
now redistributes edited excerpts of the entries' text and not only their headwords, the
attribution and this record of modifications travel with it wherever it goes.
This obligation applies to the **data only**. The source code of this project is licensed
separately under Apache-2.0 (see the repository root `LICENSE` and `NOTICE`). In tiennm99bot the corpus is embedded in the bot binary with go:embed,
unmodified; CC BY-SA 4.0 applies to this data only, the bot's code remains Apache-2.0.
## How to reproduce the derived data
```sh
make fetch-dict # downloads the current Wiktionary tiếng Việt dump (~61 MB) into data/
make refresh-dict # derives data/dictionary.txt from it, recording the file's SHA-256 in its header
make dict # builds data/noitu.db from data/dictionary.txt
```
The corpus is committed and the dump and database are not. Because `latest/` is repointed
monthly, a refresh in a later month may not be byte-identical to an earlier one; the corpus
as committed is what every build of a given revision uses.
+428
View File
@@ -0,0 +1,428 @@
Attribution-ShareAlike 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution-ShareAlike 4.0 International Public
License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-ShareAlike 4.0 International Public License ("Public
License"). To the extent this Public License may be interpreted as a
contract, You are granted the Licensed Rights in consideration of Your
acceptance of these terms and conditions, and the Licensor grants You
such rights in consideration of benefits the Licensor receives from
making the Licensed Material available under these terms and
conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. BY-SA Compatible License means a license listed at
creativecommons.org/compatiblelicenses, approved by Creative
Commons as essentially the equivalent of this Public License.
d. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
e. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
f. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
g. License Elements means the license attributes listed in the name
of a Creative Commons Public License. The License Elements of this
Public License are Attribution and ShareAlike.
h. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
i. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
j. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
k. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
l. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
m. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part; and
b. produce, reproduce, and Share Adapted Material.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. Additional offer from the Licensor -- Adapted Material.
Every recipient of Adapted Material from You
automatically receives an offer from the Licensor to
exercise the Licensed Rights in the Adapted Material
under the conditions of the Adapter's License You apply.
c. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
b. ShareAlike.
In addition to the conditions in Section 3(a), if You Share
Adapted Material You produce, the following conditions also apply.
1. The Adapter's License You apply must be a Creative Commons
license with the same License Elements, this version or
later, or a BY-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the
Adapter's License You apply. You may satisfy this condition
in any reasonable manner based on the medium, means, and
context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms
or conditions on, or apply any Effective Technological
Measures to, Adapted Material that restrict exercise of the
rights granted under the Adapter's License You apply.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material,
including for purposes of Section 3(b); and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the “Licensor.” The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.
File diff suppressed because it is too large. Load diff
+142
View File
@@ -0,0 +1,142 @@
package dict
import (
"math/rand/v2"
"strings"
"testing"
)
func mustDefault(t *testing.T) *Store {
t.Helper()
s, err := Default()
if err != nil {
t.Fatalf("Default: %v", err)
}
return s
}
func TestNormalize(t *testing.T) {
// "ữ" as u + combining horn + combining tilde, uppercase, odd spacing.
got, syl, err := Normalize(" Nữ   ĐẸP\t")
if err != nil || got != "nữ đẹp" || len(syl) != 2 {
t.Fatalf("Normalize = %q %v %v", got, syl, err)
}
if _, _, err := Normalize(" \t "); err != ErrEmpty {
t.Fatalf("blank input err = %v, want ErrEmpty", err)
}
if HasEnoughSyllables([]string{"một"}) {
t.Fatal("one syllable counted as enough")
}
}
// Parse already rejects a headword that is not normalized or is too short, so
// loading the embedded corpus at all proves every entry is well formed.
func TestDefaultCorpusLoads(t *testing.T) {
s := mustDefault(t)
if s.Len() < 30000 {
t.Fatalf("corpus has %d words, want at least 30000", s.Len())
}
if s.SourceSHA256 == "" {
t.Error("corpus header #@source_sha256 missing")
}
rng := rand.New(rand.NewPCG(1, 2))
opening := s.RandomOpening(rng, 20)
last, _ := s.LastSyllable(opening)
if s.OutDegree(last) < 20 {
t.Fatalf("opening %q ends on %q with out-degree %d, want >= 20", opening, last, s.OutDegree(last))
}
}
func TestResolveSpellingVariants(t *testing.T) {
s := mustDefault(t)
cases := map[string]string{
"hoà giải": "hòa giải", // tone moved to the second vowel
"hòa man": "hoà man", // and back
"lí luận": "lý luận", // i/y swap
"sỹ quan": "sĩ quan", // listed sĩ/sỹ pair
"lí do": "lí do", // both spellings are headwords: exact wins
}
for in, want := range cases {
if got, ok := s.Resolve(in); !ok || got != want {
t.Errorf("Resolve(%q) = %q, %v; want %q", in, got, ok, want)
}
}
if got, ok := s.Resolve("xyz abc"); ok {
t.Errorf("Resolve(non-word) = %q, want miss", got)
}
}
func TestResolveOnSmallCorpus(t *testing.T) {
s, err := Parse("#@source_sha256 abc\n\nsĩ hai\tdanh từ|nghĩa một\nlý do\t|lý lẽ\nlí lẽ\t\nlý lẽ\t\n")
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, ok := s.Resolve("sỹ hai"); !ok || got != "sĩ hai" {
t.Errorf("Resolve(sỹ hai) = %q %v, want sĩ hai", got, ok)
}
if got, ok := s.Resolve("lí do"); !ok || got != "lý do" {
t.Errorf("Resolve(lí do) = %q %v, want lý do", got, ok)
}
// Both "lí lẽ" and "lý lẽ" are headwords: an exact match wins.
if got, _ := s.Resolve("lí lẽ"); got != "lí lẽ" {
t.Errorf("Resolve(lí lẽ) = %q, want exact match", got)
}
if first, _ := s.FirstSyllable("sĩ hai"); first != "sĩ" {
t.Errorf("FirstSyllable = %q", first)
}
if m := s.Meanings("sĩ hai"); len(m) != 1 || m[0].POS != "danh từ" || m[0].Gloss != "nghĩa một" {
t.Errorf("Meanings = %+v", m)
}
if m := s.Meanings("lý do"); len(m) != 1 || m[0].POS != "" {
t.Errorf("Meanings with empty pos = %+v", m)
}
}
func TestResolveRejectsAmbiguousVariant(t *testing.T) {
// "hoá lý" has variants "hóa lý", "hoá lí" and "hóa lí"; with two of them
// real words, the lookup must not guess.
s, err := Parse("hóa lý\t\nhoá lí\t\n")
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got, ok := s.Resolve("hóa lí"); ok {
t.Fatalf("ambiguous Resolve = %q, want miss", got)
}
}
func TestParseRejectsBadHeadwords(t *testing.T) {
for _, bad := range []string{"một\t\n", "Hoa Hồng\t\n", "a b\t\n", "a b\t\na b\t\n", "# only header\n"} {
if _, err := Parse(bad); err == nil {
t.Errorf("Parse(%q) accepted", strings.TrimSpace(bad))
}
}
}
func TestMeaningsCapped(t *testing.T) {
line := "ab cd" + strings.Repeat("\tpos|gloss", 8) + "\n"
s, err := Parse(line)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got := len(s.Meanings("ab cd")); got != maxSenses {
t.Fatalf("Meanings len = %d, want %d", got, maxSenses)
}
}
func TestVariantRules(t *testing.T) {
if v, ok := toneShiftVariant("hòa"); !ok || v != "hoà" {
t.Errorf("toneShiftVariant(hòa) = %q %v", v, ok)
}
if _, ok := toneShiftVariant("hoàn"); ok {
t.Error("closed syllable shifted")
}
if _, ok := toneShiftVariant("quý"); ok {
t.Error("qu onset shifted")
}
if v, ok := iyVariant("lý"); !ok || v != "lí" {
t.Errorf("iyVariant(lý) = %q %v", v, ok)
}
if _, ok := iyVariant("gì"); ok {
t.Error("gi digraph swapped")
}
}
+46
View File
@@ -0,0 +1,46 @@
// Package dict is the nối từ word list: the embedded Wiktionary-derived
// corpus, the normalization player input goes through, the spelling variants
// a lookup accepts, and the word graph the engine and the bot opponent walk.
//
// Ported from tiennm99/noitu: Normalize from server/internal/vietnamese,
// the variant generator from server/cmd/build-dictionary/aliases.go, and the
// store from server/internal/dictionary (minus SQLite — the corpus is parsed
// into maps once, in memory).
package dict
import (
"errors"
"strings"
"golang.org/x/text/unicode/norm"
)
// MinSyllables is the shortest word the game accepts. Nối từ is played with
// compound words; a single syllable is not a valid move.
const MinSyllables = 2
// ErrEmpty is returned when input contains no usable characters.
var ErrEmpty = errors.New("dict: empty input")
// Normalize canonicalizes raw player or corpus input and splits it into
// syllables: NFC composition (so a base letter plus combining marks equals the
// precomposed form), lowercase, and whitespace collapsed to single spaces.
// Tone marks are kept exactly as typed; alternative tone placements are
// handled by Store.Resolve, not here.
func Normalize(raw string) (string, []string, error) {
composed := norm.NFC.String(raw)
// Composed again after lowering: case-folding can enable a composition that
// only exists for the lowercase form, so the lowered string may no longer
// be NFC.
lowered := norm.NFC.String(strings.ToLower(composed))
syllables := strings.Fields(lowered)
if len(syllables) == 0 {
return "", nil, ErrEmpty
}
return strings.Join(syllables, " "), syllables, nil
}
// HasEnoughSyllables reports whether a word is long enough to be a legal move.
func HasEnoughSyllables(syllables []string) bool {
return len(syllables) >= MinSyllables
}
+193
View File
@@ -0,0 +1,193 @@
package dict
import (
"fmt"
"iter"
"math/rand/v2"
"slices"
"sort"
"strings"
)
// maxSenses caps the meanings shown per word. The corpus builder already
// enforces it; repeating the cap here keeps a hand-edited corpus from sending
// an unbounded list to the page.
const maxSenses = 5
// Sense is one meaning of a word: an optional part of speech and a gloss.
// Both are plain text and must be rendered as text, never as markup.
type Sense struct {
POS string `json:"pos"`
Gloss string `json:"gloss"`
}
// entry is one headword. senses holds the raw "pos|gloss" cells, still
// tab-separated, so meanings are only parsed for words a game actually shows.
type entry struct {
first, last string
syllables int
senses string
}
// Store is the read-only word graph. Safe for concurrent use once built.
type Store struct {
words map[string]entry
byFirst map[string][]string // first syllable → words, in corpus (sorted) order
// openers lists every word sorted by how many words start with its last
// syllable, most first, so RandomOpening can cut the list at a threshold.
openers []string
openerOut []int // parallel to openers: that out-degree
// SourceSHA256 is the corpus header's #@source_sha256 value, for logs.
SourceSHA256 string
}
// Parse builds a Store from corpus text: '#' header lines, then one line per
// word, `word<TAB>pos|gloss<TAB>…`. Every headword must already be in
// normalized form with at least MinSyllables syllables; anything else is an
// error, so a bad corpus fails at load rather than as an unplayable word.
func Parse(raw string) (*Store, error) {
s := &Store{words: map[string]entry{}, byFirst: map[string][]string{}}
for line := range strings.Lines(raw) {
line = strings.TrimRight(line, "\r\n")
if line == "" {
continue
}
if strings.HasPrefix(line, "#") {
if v, ok := strings.CutPrefix(line, "#@source_sha256 "); ok {
s.SourceSHA256 = strings.TrimSpace(v)
}
continue
}
word, senses, _ := strings.Cut(line, "\t")
normalized, syllables, err := Normalize(word)
if err != nil || normalized != word || !HasEnoughSyllables(syllables) {
return nil, fmt.Errorf("dict: corpus headword %q is not a normalized word of %d+ syllables", word, MinSyllables)
}
if _, dup := s.words[word]; dup {
return nil, fmt.Errorf("dict: duplicate corpus headword %q", word)
}
e := entry{first: syllables[0], last: syllables[len(syllables)-1], syllables: len(syllables), senses: senses}
s.words[word] = e
s.byFirst[e.first] = append(s.byFirst[e.first], word)
}
if len(s.words) == 0 {
return nil, fmt.Errorf("dict: corpus has no words")
}
s.buildOpeners()
return s, nil
}
func (s *Store) buildOpeners() {
s.openers = make([]string, 0, len(s.words))
for w := range s.words {
s.openers = append(s.openers, w)
}
// Sorting by word first makes the order independent of map iteration, so
// a seeded RandomOpening is reproducible.
slices.Sort(s.openers)
sort.SliceStable(s.openers, func(i, j int) bool {
return s.OutDegree(s.words[s.openers[i]].last) > s.OutDegree(s.words[s.openers[j]].last)
})
s.openerOut = make([]int, len(s.openers))
for i, w := range s.openers {
s.openerOut[i] = s.OutDegree(s.words[w].last)
}
}
// Len reports how many words the store holds.
func (s *Store) Len() int { return len(s.words) }
// Contains reports whether word is a canonical headword.
func (s *Store) Contains(word string) bool {
_, ok := s.words[word]
return ok
}
// Resolve maps a normalized word to its canonical headword. An exact match
// wins; otherwise the word's spelling variants (tone placement in oa/oe/uy,
// i/y after certain onsets) are tried, and the lookup succeeds only when
// exactly one of them is a word, so an ambiguous spelling is never guessed.
func (s *Store) Resolve(word string) (string, bool) {
if _, ok := s.words[word]; ok {
return word, true
}
match := ""
for _, v := range variantsFor(word) {
if _, ok := s.words[v]; !ok {
continue
}
if match != "" {
return "", false
}
match = v
}
return match, match != ""
}
// FirstSyllable reports the first syllable of a canonical word.
func (s *Store) FirstSyllable(word string) (string, bool) {
e, ok := s.words[word]
return e.first, ok
}
// LastSyllable reports the last syllable of a canonical word.
func (s *Store) LastSyllable(word string) (string, bool) {
e, ok := s.words[word]
return e.last, ok
}
// Syllables reports how many syllables a canonical word has.
func (s *Store) Syllables(word string) (int, bool) {
e, ok := s.words[word]
return e.syllables, ok
}
// WordsStartingWith iterates the words whose first syllable is syllable, in
// corpus order.
func (s *Store) WordsStartingWith(syllable string) iter.Seq[string] {
words := s.byFirst[syllable]
return func(yield func(string) bool) {
for _, w := range words {
if !yield(w) {
return
}
}
}
}
// OutDegree reports how many words start with syllable.
func (s *Store) OutDegree(syllable string) int { return len(s.byFirst[syllable]) }
// RandomOpening picks a random word whose last syllable starts at least
// minOut words, so the first reply is never forced. Falls back to the best
// connected word when nothing reaches minOut.
func (s *Store) RandomOpening(rng *rand.Rand, minOut int) string {
// openerOut is sorted descending, so the qualifying words are a prefix.
n := sort.Search(len(s.openerOut), func(i int) bool { return s.openerOut[i] < minOut })
if n == 0 {
return s.openers[0]
}
return s.openers[rng.IntN(n)]
}
// Meanings returns up to maxSenses senses of a canonical word, in corpus
// order. The slice is freshly built, so callers may keep or modify it.
func (s *Store) Meanings(word string) []Sense {
e, ok := s.words[word]
if !ok || e.senses == "" {
return nil
}
var out []Sense
for cell := range strings.SplitSeq(e.senses, "\t") {
pos, gloss, _ := strings.Cut(cell, "|")
gloss = strings.TrimSpace(gloss)
if gloss == "" {
continue
}
out = append(out, Sense{POS: strings.TrimSpace(pos), Gloss: gloss})
if len(out) == maxSenses {
break
}
}
return out
}
+292
View File
@@ -0,0 +1,292 @@
package dict
// Spelling variants, ported from tiennm99/noitu
// server/cmd/build-dictionary/aliases.go. The builder there turns them into an
// alias table; here Store.Resolve generates them at lookup time instead, which
// yields the same mappings because both swaps undo themselves.
import (
"strings"
"golang.org/x/text/unicode/norm"
)
// Vietnamese combining tone marks. These are the five tones; they are distinct
// from the combining marks that form letters (circumflex, breve, horn), which
// belong to their base letter and must never be moved.
var toneMarks = map[rune]bool{
'̀': true, // huyền (grave)
'́': true, // sắc (acute)
'̃': true, // ngã (tilde)
'̉': true, // hỏi (hook above)
'̣': true, // nặng (dot below)
}
// Marks that turn a base letter into a different Vietnamese letter (ă â ê ô ơ ư).
// A vowel carrying one of these is not a plain vowel and is excluded from the
// tone-shifting rule below.
var letterMarks = map[rune]bool{
'̂': true, // circumflex: â ê ô
'̆': true, // breve: ă
'̛': true, // horn: ơ ư
}
// cluster is one base rune plus the combining marks attached to it.
type cluster struct {
base rune
mods []rune
}
func (c cluster) tone() (rune, bool) {
for _, m := range c.mods {
if toneMarks[m] {
return m, true
}
}
return 0, false
}
func (c cluster) hasLetterMark() bool {
for _, m := range c.mods {
if letterMarks[m] {
return true
}
}
return false
}
func (c cluster) without(target rune) cluster {
out := cluster{base: c.base}
for _, m := range c.mods {
if m != target {
out.mods = append(out.mods, m)
}
}
return out
}
func (c cluster) with(extra rune) cluster {
out := cluster{base: c.base, mods: append([]rune{}, c.mods...)}
out.mods = append(out.mods, extra)
return out
}
// decompose splits a syllable into base-plus-marks clusters, working on the NFD
// form so that every diacritic is visible as its own rune.
func decompose(syllable string) []cluster {
var clusters []cluster
for _, r := range norm.NFD.String(syllable) {
if (toneMarks[r] || letterMarks[r]) && len(clusters) > 0 {
last := len(clusters) - 1
clusters[last].mods = append(clusters[last].mods, r)
continue
}
clusters = append(clusters, cluster{base: r})
}
return clusters
}
func recompose(clusters []cluster) string {
var b strings.Builder
for _, c := range clusters {
b.WriteRune(c.base)
for _, m := range c.mods {
b.WriteRune(m)
}
}
return norm.NFC.String(b.String())
}
// shiftablePairs are the vowel clusters where Vietnamese has two competing tone
// placements in real use: the older style marks the first vowel ("hòa", "thúy"),
// the newer style marks the second ("hoà", "thuý"). Both spellings appear in
// dictionaries and both are typed by players, but they are different codepoint
// sequences, so one of them fails a lookup unless we record it as an alias.
var shiftablePairs = [][2]rune{
{'o', 'a'},
{'o', 'e'},
{'u', 'y'},
}
// toneShiftVariant moves the tone mark to the other vowel of an oa/oe/uy cluster.
// Returns the variant and whether one was produced.
func toneShiftVariant(syllable string) (string, bool) {
clusters := decompose(syllable)
for i := 0; i+1 < len(clusters); i++ {
first, second := clusters[i], clusters[i+1]
// Only plain vowels take part; ơ/ư/ô/ê/ă follow different rules.
if first.hasLetterMark() || second.hasLetterMark() {
continue
}
if !isShiftablePair(first.base, second.base) {
continue
}
// The two placements only compete in an OPEN syllable that ends on the
// cluster. Add anything after it -- a final consonant ("hoàn") or a
// third vowel ("hoài") -- and only one spelling is correct. Without this
// check the builder invents words like "hòan" and "hòai".
if i+1 != len(clusters)-1 {
continue
}
// In "qu" the u is part of the consonant onset, not the vowel nucleus,
// so "quý" must not become "qúy".
if first.base == 'u' && i > 0 && clusters[i-1].base == 'q' {
continue
}
if tone, ok := first.tone(); ok {
shifted := append([]cluster{}, clusters...)
shifted[i] = first.without(tone)
shifted[i+1] = second.with(tone)
return recompose(shifted), true
}
if tone, ok := second.tone(); ok {
shifted := append([]cluster{}, clusters...)
shifted[i] = first.with(tone)
shifted[i+1] = second.without(tone)
return recompose(shifted), true
}
}
return "", false
}
func isShiftablePair(a, b rune) bool {
for _, p := range shiftablePairs {
if p[0] == a && p[1] == b {
return true
}
}
return false
}
// iyOnsets are the consonant onsets after which Vietnamese genuinely writes
// both i and y across the tone range: hi/hy, ki/ky, li/ly, mi/my, ti/ty, qui/quy.
//
// This is an allowlist rather than a general rule because most onsets take only
// one spelling. "gi" is a digraph, so "gì" has no "gỳ"; "chì" has no "chỳ".
//
// "v" and "s" were deliberately removed after inspecting real output: they
// produced non-words at scale ("chức vị" -> "chức vỵ", plus "vỳ", "vý", "sỵ",
// "sỳ"). Their genuine alternations are lexical rather than productive, so the
// few real ones are listed in iyPairs instead.
var iyOnsets = map[string]bool{
"h": true, "k": true, "l": true, "m": true, "t": true, "qu": true,
}
// iyPairs are individual syllables that alternate even though their onset does
// not do so productively. Listed explicitly, both directions.
var iyPairs = map[string]string{
"sĩ": "sỹ", "sỹ": "sĩ",
"vĩ": "vỹ", "vỹ": "vĩ",
}
// iyVariant swaps a lone i/y nucleus: quý/quí, lý/lí, mỹ/mĩ.
//
// The nucleus must be the final cluster, so closed syllables are excluded
// ("tính" has no "týnh"), and the onset must be one that takes both spellings.
func iyVariant(syllable string) (string, bool) {
if pair, ok := iyPairs[syllable]; ok {
return pair, true
}
clusters := decompose(syllable)
if len(clusters) < 2 {
return "", false
}
last := len(clusters) - 1
nucleus := clusters[last]
if nucleus.hasLetterMark() {
return "", false
}
var swapped rune
switch nucleus.base {
case 'i':
swapped = 'y'
case 'y':
swapped = 'i'
default:
return "", false
}
var onset strings.Builder
for _, c := range clusters[:last] {
if c.hasLetterMark() {
return "", false
}
onset.WriteRune(c.base)
}
if !iyOnsets[onset.String()] {
return "", false
}
variant := append([]cluster{}, clusters...)
variant[last] = cluster{base: swapped, mods: nucleus.mods}
return recompose(variant), true
}
// maxVariantsPerWord caps the combination count for a word whose syllables are
// all variable. Real words reach at most a handful; the cap only guards against
// a pathological entry blowing up the alias table.
const maxVariantsPerWord = 32
// spellingsOf returns every accepted spelling of a single syllable, the
// original first.
func spellingsOf(syllable string) []string {
out := []string{syllable}
seen := map[string]bool{syllable: true}
for _, gen := range []func(string) (string, bool){toneShiftVariant, iyVariant} {
alt, ok := gen(syllable)
if !ok || seen[alt] {
continue
}
seen[alt] = true
out = append(out, alt)
}
return out
}
// variantsFor returns every accepted alternative spelling of a whole word.
//
// Syllables must be varied together, not one at a time. "hoá lí" has two
// variable syllables, and a player is far more likely to type the fully modern
// "hóa lý" than either half-and-half form. Varying independently produced only
// the halves and rejected the spelling most people actually use.
func variantsFor(word string) []string {
syllables := strings.Split(word, " ")
// Cartesian product over each syllable's accepted spellings.
combos := [][]string{{}}
for _, syl := range syllables {
spellings := spellingsOf(syl)
next := make([][]string, 0, len(combos)*len(spellings))
for _, prefix := range combos {
for _, s := range spellings {
if len(next) >= maxVariantsPerWord {
break
}
next = append(next, append(append([]string{}, prefix...), s))
}
}
combos = next
}
seen := map[string]bool{word: true}
var out []string
for _, combo := range combos {
joined := strings.Join(combo, " ")
if seen[joined] {
continue
}
seen[joined] = true
out = append(out, joined)
}
return out
}
+172
View File
@@ -0,0 +1,172 @@
package engine
import (
"errors"
"fmt"
"iter"
"slices"
"time"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
)
// board is the state and the word rules every game shares, whoever plays it:
// the dictionary, the chain so far, the syllable to answer, the used words
// and the turn clock. Engine (the human against the bot) and Match (people
// against each other) embed it and add only their own turn order.
type board struct {
dict Dictionary
opening string
used map[string]struct{}
current string
turnLimit time.Duration
grace time.Duration
deadline time.Time
history []Move
}
// newBoard opens a chain on opening, with the first turn starting now.
//
// grace is extra time after the deadline during which a submission still
// counts, to absorb network latency. It never earns speed points.
func newBoard(d Dictionary, opening string, turnLimit, grace time.Duration, now time.Time) (board, error) {
if d == nil {
return board{}, errors.New("engine: nil dictionary")
}
if turnLimit <= 0 || grace < 0 {
return board{}, fmt.Errorf("engine: invalid turn limit %v / grace %v", turnLimit, grace)
}
canonical, ok := d.Resolve(opening)
if !ok {
return board{}, fmt.Errorf("engine: opening word %q is not in the dictionary", opening)
}
last, _ := d.LastSyllable(canonical)
b := board{
dict: d,
opening: canonical,
used: map[string]struct{}{canonical: {}},
current: last,
turnLimit: turnLimit,
grace: grace,
deadline: now.Add(turnLimit),
}
if !b.HasLegalMove() {
return board{}, fmt.Errorf("engine: opening word %q ends on %q, which starts no other word", canonical, last)
}
return b, nil
}
// check validates a word against the chain without playing it. On success it
// returns the move (By and Seat left for the caller) and the word's last
// syllable.
//
// Checks run in this order: syllable count, dictionary, link, reuse.
// Resolving before the link check matters: the canonical spelling can change
// the first syllable ("sỹ hai" → "sĩ hai").
func (b *board) check(raw string, now time.Time) (Move, string, Reason) {
normalized, syllables, err := dict.Normalize(raw)
if err != nil || !dict.HasEnoughSyllables(syllables) {
return Move{}, "", ReasonTooFewSyllables
}
canonical, ok := b.dict.Resolve(normalized)
if !ok {
return Move{}, "", ReasonNotInDictionary
}
first, _ := b.dict.FirstSyllable(canonical)
if first != b.current {
return Move{}, "", ReasonWrongLink
}
if _, played := b.used[canonical]; played {
return Move{}, "", ReasonAlreadyUsed
}
last, _ := b.dict.LastSyllable(canonical)
// Count the canonical word's syllables: a variant spelling never changes
// the count, but the canonical form is what was played.
n := len(syllablesOf(canonical))
return Move{Word: canonical, Syllables: n, Points: b.pointsFor(n, first, now), At: now}, last, ReasonNone
}
// play records a move check accepted and hands the next player a full turn.
func (b *board) play(m Move, last string, now time.Time) {
b.used[m.Word] = struct{}{}
b.history = append(b.history, m)
b.current = last
b.deadline = now.Add(b.turnLimit)
}
func syllablesOf(word string) []string {
_, s, _ := dict.Normalize(word)
return s
}
// Opening is the word the chain started from.
func (b *board) Opening() string { return b.opening }
// Current is the syllable the next word must start with.
func (b *board) Current() string { return b.current }
// Deadline is when the current turn ends, before the grace period.
func (b *board) Deadline() time.Time { return b.deadline }
// TurnLimit is the length of one turn.
func (b *board) TurnLimit() time.Duration { return b.turnLimit }
// History returns the played moves after the opening word, oldest first.
func (b *board) History() []Move { return slices.Clone(b.history) }
// ChainLength counts the words played, opening word included.
func (b *board) ChainLength() int { return len(b.history) + 1 }
// Expired reports whether the current turn ran out, grace period included.
func (b *board) Expired(now time.Time) bool {
return now.After(b.deadline.Add(b.grace))
}
// Used reports whether a canonical word has been played.
func (b *board) Used(word string) bool {
_, ok := b.used[word]
return ok
}
// WordsStartingWith iterates the dictionary words starting with syllable,
// played ones included.
func (b *board) WordsStartingWith(syllable string) iter.Seq[string] {
return b.dict.WordsStartingWith(syllable)
}
// LastSyllable reports the last syllable of a canonical word.
func (b *board) LastSyllable(word string) (string, bool) {
return b.dict.LastSyllable(word)
}
// LegalMoves lists every unused word that answers the current syllable.
func (b *board) LegalMoves() []string {
var out []string
for w := range b.dict.WordsStartingWith(b.current) {
if !b.Used(w) {
out = append(out, w)
}
}
return out
}
// HasLegalMove reports whether the player to act has anything to play.
func (b *board) HasLegalMove() bool {
for w := range b.dict.WordsStartingWith(b.current) {
if !b.Used(w) {
return true
}
}
return false
}
// Suggestions lists up to n words the player to act could still play, sorted.
// Shown to a player who lost; empty means the position was a dead end.
func (b *board) Suggestions(n int) []string {
if n <= 0 {
return nil
}
moves := b.LegalMoves()
slices.Sort(moves)
return moves[:min(n, len(moves))]
}
+219
View File
@@ -0,0 +1,219 @@
// Package engine holds the rules of one nối từ game, either a human against
// the bot or people against each other: what counts as a legal move, whose
// turn it is, when the turn expires and how a word is scored.
//
// It is ported from tiennm99/noitu server/internal/game/engine.go: Engine
// is the two-player cut against the bot, Match the game between people.
// The engine never reads the clock: every call that depends on time takes a
// now argument, so the rules are testable without a timer.
package engine
import (
"iter"
"time"
)
// Side is one of the two players.
type Side int
const (
// Human is the Telegram player.
Human Side = iota + 1
// Bot is the computer opponent. It plays the opening word.
Bot
)
func (s Side) other() Side {
if s == Human {
return Bot
}
return Human
}
// Reason says why a submission was rejected, or why it did not count.
type Reason string
// Reject reasons. ReasonNone means the word was accepted.
const (
ReasonNone Reason = ""
ReasonGameOver Reason = "game_over"
ReasonNotYourTurn Reason = "not_your_turn"
ReasonTimeout Reason = "timeout"
ReasonTooFewSyllables Reason = "too_few_syllables"
ReasonNotInDictionary Reason = "not_in_dictionary"
ReasonWrongLink Reason = "wrong_link"
ReasonAlreadyUsed Reason = "already_used"
)
// EndReason says how a finished game ended.
type EndReason string
// End reasons. EndNone means the game is still in play.
const (
EndNone EndReason = ""
EndTimeout EndReason = "timeout" // the player to act ran out of time
EndNoLegalMove EndReason = "no_legal_move" // the player to act was left a dead end
EndGaveUp EndReason = "gave_up" // the player resigned
EndBotStuck EndReason = "bot_stuck" // the bot had no legal move
EndMaxMoves EndReason = "max_moves" // the chain reached the per-game cap
)
// Dictionary is the slice of the word store the engine needs.
type Dictionary interface {
Resolve(word string) (string, bool)
FirstSyllable(word string) (string, bool)
LastSyllable(word string) (string, bool)
WordsStartingWith(syllable string) iter.Seq[string]
OutDegree(syllable string) int
}
// Move is one played word. Word is the canonical spelling, which may differ
// from what the player typed. By is set in an Engine game, Seat in a Match.
type Move struct {
By Side
Seat int
Word string
Syllables int
Points int
At time.Time
}
// Engine is one game of the human against the bot. Not safe for concurrent
// use; the caller serializes.
type Engine struct {
board
turn Side
score int
over bool
winner Side
endReason EndReason
}
// New starts a game from an opening word played by the bot. The human moves
// first, with a full turn from now.
//
// grace is extra time after the deadline during which a submission still
// counts, to absorb network latency. It never earns speed points.
func New(d Dictionary, opening string, turnLimit, grace time.Duration, now time.Time) (*Engine, error) {
b, err := newBoard(d, opening, turnLimit, grace, now)
if err != nil {
return nil, err
}
return &Engine{board: b, turn: Human}, nil
}
// Turn reports whose move it is.
func (e *Engine) Turn() Side { return e.turn }
// Over reports whether the game has finished.
func (e *Engine) Over() bool { return e.over }
// Winner is the winning side once the game is over.
func (e *Engine) Winner() Side { return e.winner }
// EndReason reports how the game ended.
func (e *Engine) EndReason() EndReason { return e.endReason }
// Score is the human's total points.
func (e *Engine) Score() int { return e.score }
// Submit validates a word from side and, when legal, plays it.
//
// Checks run in this order: game over, turn, expiry, then the word rules
// (see board.check). An expired turn ends the game against the human (the
// bot never waits).
func (e *Engine) Submit(side Side, raw string, now time.Time) (Move, Reason) {
if e.over {
return Move{}, ReasonGameOver
}
if side != e.turn {
return Move{}, ReasonNotYourTurn
}
if side == Human && e.Expired(now) {
e.expire()
return Move{}, ReasonTimeout
}
move, last, reason := e.check(raw, now)
if reason != ReasonNone {
return Move{}, reason
}
move.By = side
e.play(move, last, now)
if side == Human {
e.score += move.Points
}
e.turn = side.other()
// A dead end does not end the game here. The human keeps the turn and
// loses it on the clock or claims it with NoMove; the caller settles the
// bot's with BotStuck.
return move, ReasonNone
}
// Timeout ends the game against the human when their turn has expired.
// Reports whether it did.
func (e *Engine) Timeout(now time.Time) bool {
if e.over || e.turn != Human || !e.Expired(now) {
return false
}
e.expire()
return true
}
// expire ends the human's expired turn: as no legal move when the position
// was a dead end, so the result does not blame the clock, otherwise as a
// timeout.
func (e *Engine) expire() {
if e.HasLegalMove() {
e.end(Bot, EndTimeout)
return
}
e.end(Bot, EndNoLegalMove)
}
// NoMove ends the game at once when it is the human's turn and the position
// leaves nothing to play, so a dead end need not wait out the clock. It is
// not a resignation: a playable position is left alone. Reports whether it
// ended the game.
func (e *Engine) NoMove() bool {
if e.over || e.turn != Human || e.HasLegalMove() {
return false
}
e.end(Bot, EndNoLegalMove)
return true
}
// GiveUp ends the game as the human's resignation.
func (e *Engine) GiveUp() bool {
if e.over {
return false
}
e.end(Bot, EndGaveUp)
return true
}
// BotStuck ends the game as a human win when it is the bot's turn and it has
// nothing to play. Reports whether it did.
func (e *Engine) BotStuck() bool {
if e.over || e.turn != Bot || e.HasLegalMove() {
return false
}
e.end(Human, EndBotStuck)
return true
}
// EndMaxMoves ends the game as a human win once the chain reached the cap the
// caller enforces. Surviving that long beats the bot.
func (e *Engine) EndMaxMoves() bool {
if e.over {
return false
}
e.end(Human, EndMaxMoves)
return true
}
func (e *Engine) end(winner Side, reason EndReason) {
e.over = true
e.winner = winner
e.endReason = reason
}
@@ -0,0 +1,228 @@
package engine
import (
"testing"
"time"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
)
const (
limit = 30 * time.Second
grace = 2 * time.Second
)
var t0 = time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
// testDict is a tiny graph:
//
// hoa hồng → hồng hào → hào hoa (back to hoa)
// hồng tâm → tâm sự → sự cố → (cố is a dead end)
// sĩ quan → quan tâm
func testDict(t *testing.T) *dict.Store {
t.Helper()
s, err := dict.Parse("hoa hồng\nhồng hào\nhào hoa\nhồng tâm\ntâm sự\nsự cố\nsĩ quan\nquan tâm\nhào quang lấp lánh\n")
if err != nil {
t.Fatalf("Parse: %v", err)
}
return s
}
func newGame(t *testing.T) *Engine {
t.Helper()
e, err := New(testDict(t), "hoa hồng", limit, grace, t0)
if err != nil {
t.Fatalf("New: %v", err)
}
return e
}
func TestNewRejectsDeadOpening(t *testing.T) {
if _, err := New(testDict(t), "sự cố", limit, grace, t0); err == nil {
t.Fatal("dead-end opening accepted")
}
if _, err := New(testDict(t), "không có", limit, grace, t0); err == nil {
t.Fatal("unknown opening accepted")
}
}
func TestSubmitReasons(t *testing.T) {
e := newGame(t)
if e.Current() != "hồng" || e.Turn() != Human {
t.Fatalf("start: current %q turn %v", e.Current(), e.Turn())
}
now := t0.Add(time.Second)
cases := []struct {
side Side
word string
want Reason
}{
{Bot, "hồng hào", ReasonNotYourTurn},
{Human, "hồng", ReasonTooFewSyllables},
{Human, " ", ReasonTooFewSyllables},
{Human, "hồng xyz", ReasonNotInDictionary},
{Human, "tâm sự", ReasonWrongLink},
}
for _, c := range cases {
if _, got := e.Submit(c.side, c.word, now); got != c.want {
t.Errorf("Submit(%v, %q) = %q, want %q", c.side, c.word, got, c.want)
}
}
if m, r := e.Submit(Human, "Hồng HÀO", now); r != ReasonNone || m.Word != "hồng hào" {
t.Fatalf("valid move = %+v %q", m, r)
}
if _, r := e.Submit(Bot, "hào hoa", now); r != ReasonNone {
t.Fatalf("bot move rejected: %q", r)
}
if _, r := e.Submit(Human, "hoa hồng", now); r != ReasonAlreadyUsed {
t.Fatalf("reused opening = %q, want already_used", r)
}
}
func TestVariantSpellingLinksOnCanonical(t *testing.T) {
e, err := New(testDict(t), "hào hoa", limit, grace, t0) // ends on "hoa"
if err != nil {
t.Fatal(err)
}
e.current = "sĩ" // force a position answered only by "sĩ quan"
if m, r := e.Submit(Human, "sỹ quan", t0); r != ReasonNone || m.Word != "sĩ quan" {
t.Fatalf("variant = %+v %q, want canonical sĩ quan", m, r)
}
}
func TestTimeoutAfterGrace(t *testing.T) {
e := newGame(t)
// Inside the grace window the move still counts, with no speed points.
if m, r := e.Submit(Human, "hồng hào", t0.Add(limit+grace)); r != ReasonNone {
t.Fatalf("move in grace = %q", r)
} else if m.Points != 10+2*1+0+0+rarity(e, "hồng") {
t.Fatalf("points in grace = %d", m.Points)
}
e.Submit(Bot, "hào hoa", t0.Add(limit+grace))
late := t0.Add(limit + grace).Add(limit + grace + time.Millisecond)
if e.Timeout(late.Add(-2 * time.Millisecond)) {
t.Fatal("timeout fired inside the grace window")
}
if _, r := e.Submit(Human, "hoa hồng", late); r != ReasonTimeout {
t.Fatalf("late move = %q, want timeout", r)
}
// "hoa" only starts the used opening, so the expired turn was a dead end.
if !e.Over() || e.Winner() != Bot || e.EndReason() != EndNoLegalMove {
t.Fatalf("after timeout: over %v winner %v reason %q", e.Over(), e.Winner(), e.EndReason())
}
if _, r := e.Submit(Human, "hoa hồng", late); r != ReasonGameOver {
t.Fatalf("move after end = %q", r)
}
}
func rarity(e *Engine, link string) int { return e.rarityPoints(link) }
func TestTimeoutSettlesWithoutMove(t *testing.T) {
e := newGame(t)
if e.Timeout(t0.Add(limit)) {
t.Fatal("timeout at the deadline itself")
}
if !e.Timeout(t0.Add(limit + grace + time.Nanosecond)) {
t.Fatal("timeout not settled after grace")
}
}
func TestBotStuckAndGiveUp(t *testing.T) {
e := newGame(t)
e.Submit(Human, "hồng tâm", t0)
if e.BotStuck() {
t.Fatal("bot stuck while it can answer tâm")
}
e.Submit(Bot, "tâm sự", t0)
e.Submit(Human, "sự cố", t0)
if !e.BotStuck() || e.Winner() != Human || e.EndReason() != EndBotStuck {
t.Fatalf("bot stuck: over %v winner %v reason %q", e.Over(), e.Winner(), e.EndReason())
}
g := newGame(t)
if !g.GiveUp() || g.Winner() != Bot || g.EndReason() != EndGaveUp {
t.Fatal("give up did not end the game for the bot")
}
if g.GiveUp() {
t.Fatal("second give up reported a change")
}
}
func TestScoreSumsHumanPointsOnly(t *testing.T) {
e := newGame(t)
m1, _ := e.Submit(Human, "hồng tâm", t0) // instant: full speed bonus
e.Submit(Bot, "tâm sự", t0.Add(time.Second)) // bot points do not count
m2, _ := e.Submit(Human, "sự cố", t0.Add(16*time.Second)) // half the turn left
if e.Score() != m1.Points+m2.Points {
t.Fatalf("score %d, want %d + %d", e.Score(), m1.Points, m2.Points)
}
// hồng tâm: base 10 + chain 2*1 + syllables 0 + speed 10 + rarity for "hồng" (2 words → 12).
if m1.Points != 10+2+0+10+12 {
t.Fatalf("m1 points = %d", m1.Points)
}
// sự cố: chain 2*3, speed 10*15/30 = 5, rarity for "sự" (1 word → 15).
if m2.Points != 10+6+0+5+15 {
t.Fatalf("m2 points = %d", m2.Points)
}
}
func TestPointsCapped(t *testing.T) {
e := newGame(t)
e.history = make([]Move, 40) // chain bonus at its cap
if got := e.pointsFor(30, "sự", t0); got != MaxPointsPerWord {
t.Fatalf("points = %d, want cap %d", got, MaxPointsPerWord)
}
}
func TestSuggestions(t *testing.T) {
e := newGame(t)
got := e.Suggestions(3)
if len(got) != 2 || got[0] != "hồng hào" || got[1] != "hồng tâm" {
t.Fatalf("Suggestions = %v", got)
}
if e.Suggestions(0) != nil {
t.Fatal("Suggestions(0) not nil")
}
}
// deadEndGame leaves the human to answer "cố", which starts no word.
func deadEndGame(t *testing.T) *Engine {
t.Helper()
e := newGame(t)
e.current = "cố"
return e
}
func TestDeadEndExpiresAsNoLegalMove(t *testing.T) {
late := t0.Add(limit + grace + time.Millisecond)
e := deadEndGame(t)
if !e.Timeout(late) || e.Winner() != Bot || e.EndReason() != EndNoLegalMove {
t.Fatalf("Timeout in a dead end: winner %v reason %q", e.Winner(), e.EndReason())
}
e = deadEndGame(t)
if _, r := e.Submit(Human, "cố gắng", late); r != ReasonTimeout || e.EndReason() != EndNoLegalMove {
t.Fatalf("late Submit in a dead end: %q / %q", r, e.EndReason())
}
// A playable position still ends as an ordinary timeout.
e = newGame(t)
if !e.Timeout(late) || e.EndReason() != EndTimeout {
t.Fatalf("Timeout with moves left: reason %q", e.EndReason())
}
}
func TestNoMoveClaimsOnlyADeadEnd(t *testing.T) {
e := newGame(t)
if e.NoMove() || e.Over() {
t.Fatal("NoMove ended a playable position")
}
e = deadEndGame(t)
if !e.NoMove() || e.Winner() != Bot || e.EndReason() != EndNoLegalMove {
t.Fatalf("NoMove in a dead end: over %v reason %q", e.Over(), e.EndReason())
}
if e.NoMove() || e.GiveUp() {
t.Fatal("a finished game ended a second time")
}
}
+259
View File
@@ -0,0 +1,259 @@
package engine
import (
"fmt"
"slices"
"time"
)
// MinSeats and MaxSeats bound the players in one Match.
const (
MinSeats = 2
MaxSeats = 8
)
// Match is one game between people taking turns in seat order, ported from
// the multiplayer rules of tiennm99/noitu server/internal/game/engine.go.
//
// A player who fails their turn (the clock runs out, or they give up) is
// eliminated, and the rest play on from the same syllable against the same
// used words; the last one standing wins. Words, checks and scoring are the
// board's, the same as in a game against the bot.
//
// Not safe for concurrent use; the caller serializes.
type Match struct {
board
// alive is per seat. An eliminated seat keeps its score, its words and
// its place in the turn order.
alive []bool
aliveN int
turn int
scores []int
outOrder []int
outReason []EndReason
over bool
winner int
endReason EndReason
}
// NewMatch opens a game for seats players on opening. Seat 0 moves first,
// with a full turn from now.
func NewMatch(d Dictionary, seats int, opening string, turnLimit, grace time.Duration, now time.Time) (*Match, error) {
if seats < MinSeats || seats > MaxSeats {
return nil, fmt.Errorf("engine: a match needs %d to %d players, got %d", MinSeats, MaxSeats, seats)
}
b, err := newBoard(d, opening, turnLimit, grace, now)
if err != nil {
return nil, err
}
m := &Match{
board: b,
alive: make([]bool, seats),
aliveN: seats,
scores: make([]int, seats),
outReason: make([]EndReason, seats),
winner: -1,
}
for i := range m.alive {
m.alive[i] = true
}
return m, nil
}
// Seats is the number of players the match started with.
func (m *Match) Seats() int { return len(m.alive) }
// Turn is the seat to act: always a seat still in the game, and the winner
// once the match is over.
func (m *Match) Turn() int { return m.turn }
// Alive reports whether seat is still in the game.
func (m *Match) Alive(seat int) bool { return m.valid(seat) && m.alive[seat] }
// Score is seat's points over the words it played.
func (m *Match) Score(seat int) int {
if !m.valid(seat) {
return 0
}
return m.scores[seat]
}
// OutReason reports why seat was eliminated, EndNone while it is still in.
func (m *Match) OutReason(seat int) EndReason {
if !m.valid(seat) {
return EndNone
}
return m.outReason[seat]
}
// Over reports whether the match has finished.
func (m *Match) Over() bool { return m.over }
// Winner is the winning seat once the match is over, -1 before.
func (m *Match) Winner() int { return m.winner }
// EndReason is the reason of the elimination that ended the match, or
// EndMaxMoves.
func (m *Match) EndReason() EndReason { return m.endReason }
// Standings lists the seats best first once the match is over: the winner,
// then the eliminated seats, last out first. Outlasting a player beats them,
// whatever the scores.
func (m *Match) Standings() []int {
if !m.over {
return nil
}
out := make([]int, 0, len(m.alive))
out = append(out, m.winner)
// Only a max-moves end leaves other seats standing. They survived, so
// they rank ahead of every eliminated seat, by points, the earlier seat
// on a tie, the same order that picked the winner.
var survivors []int
for s, alive := range m.alive {
if alive && s != m.winner {
survivors = append(survivors, s)
}
}
slices.SortStableFunc(survivors, func(a, b int) int { return m.scores[b] - m.scores[a] })
out = append(out, survivors...)
for i := len(m.outOrder) - 1; i >= 0; i-- {
out = append(out, m.outOrder[i])
}
return out
}
// Submit validates a word from seat and, when legal, plays it. Checks run in
// this order: game over, turn, expiry, then the word rules (see board.check).
// An expired turn eliminates the seat and reports ReasonTimeout. A rejected
// word keeps the turn and the clock.
func (m *Match) Submit(seat int, raw string, now time.Time) (Move, Reason) {
if m.over {
return Move{}, ReasonGameOver
}
if seat != m.turn {
return Move{}, ReasonNotYourTurn
}
if m.Expired(now) {
m.expire(m.deadline.Add(m.grace))
return Move{}, ReasonTimeout
}
move, last, reason := m.check(raw, now)
if reason != ReasonNone {
return Move{}, reason
}
move.Seat = seat
m.play(move, last, now)
m.scores[seat] += move.Points
m.advance()
// A dead end does not end anything here: the next player gets the turn
// and loses it on the clock (or by giving up), and settle then takes out
// everyone behind them who faces the same board.
return move, ReasonNone
}
// Timeout eliminates the seat whose turn expired and reports whether it did.
// The next turn starts when the expired one ended, grace included, not at
// now: a caller that settles late (a poll, a sweep) then replays the clock
// as a timer would have, and one call per expired turn catches up.
func (m *Match) Timeout(now time.Time) bool {
if m.over || !m.Expired(now) {
return false
}
m.expire(m.deadline.Add(m.grace))
return true
}
// Resign eliminates seat, which need not be the seat to act: a player may
// leave while somebody else is thinking. On their own turn in a dead end it
// counts as no legal move rather than giving up. The clock restarts only when
// the turn moved on. Reports whether it changed anything.
func (m *Match) Resign(seat int, now time.Time) bool {
if m.over || !m.Alive(seat) {
return false
}
before := m.turn
reason := EndGaveUp
if seat == m.turn && !m.HasLegalMove() {
reason = EndNoLegalMove
}
m.eliminate(seat, reason)
if !m.over && m.turn != before {
m.settle()
if !m.over {
m.deadline = now.Add(m.turnLimit)
}
}
return true
}
// EndMaxMoves ends the match once the chain reached the cap the caller
// enforces. Every seat still in survived; the one with the most points wins,
// the earlier seat on a tie.
func (m *Match) EndMaxMoves() bool {
if m.over {
return false
}
best := -1
for s, alive := range m.alive {
if alive && (best < 0 || m.scores[s] > m.scores[best]) {
best = s
}
}
m.over, m.winner, m.endReason = true, best, EndMaxMoves
m.turn = best
return true
}
// expire eliminates the seat to act, whose turn ended at end: as no legal
// move when the position was a dead end, so the result does not blame the
// clock, otherwise as a timeout.
func (m *Match) expire(end time.Time) {
reason := EndTimeout
if !m.HasLegalMove() {
reason = EndNoLegalMove
}
m.eliminate(m.turn, reason)
m.settle()
if !m.over {
m.deadline = end.Add(m.turnLimit)
}
}
// settle takes out everyone a dead end leaves with nothing. The first player
// to face it lost it on their own turn; everyone behind them has seen that
// board, so making each sit out a turn they cannot use would only stall a
// decided game. The player who closed the position is left standing.
func (m *Match) settle() {
for !m.over && !m.HasLegalMove() {
m.eliminate(m.turn, EndNoLegalMove)
}
}
// eliminate takes seat out and ends the match when one seat is left.
func (m *Match) eliminate(seat int, reason EndReason) {
m.alive[seat] = false
m.aliveN--
m.outOrder = append(m.outOrder, seat)
m.outReason[seat] = reason
m.endReason = reason
if m.turn == seat {
m.advance()
}
if m.aliveN <= 1 {
m.over = true
m.winner = m.turn
}
}
// advance moves the turn to the next seat still in the game.
func (m *Match) advance() {
for range m.alive {
m.turn = (m.turn + 1) % len(m.alive)
if m.alive[m.turn] {
return
}
}
}
func (m *Match) valid(seat int) bool { return seat >= 0 && seat < len(m.alive) }
+230
View File
@@ -0,0 +1,230 @@
package engine
import (
"slices"
"testing"
"time"
)
func newMatch(t *testing.T, seats int) *Match {
t.Helper()
m, err := NewMatch(testDict(t), seats, "hoa hồng", limit, grace, t0)
if err != nil {
t.Fatalf("NewMatch: %v", err)
}
return m
}
// late is just past the first turn's deadline and grace.
var late = t0.Add(limit + grace + time.Nanosecond)
func TestNewMatchSeatBounds(t *testing.T) {
for _, n := range []int{0, 1, MaxSeats + 1} {
if _, err := NewMatch(testDict(t), n, "hoa hồng", limit, grace, t0); err == nil {
t.Errorf("NewMatch with %d seats accepted", n)
}
}
if _, err := NewMatch(testDict(t), 2, "sự cố", limit, grace, t0); err == nil {
t.Error("dead-end opening accepted")
}
m := newMatch(t, MaxSeats)
if m.Seats() != MaxSeats || m.Turn() != 0 || m.Winner() != -1 || m.Over() {
t.Fatalf("fresh match: seats %d turn %d winner %d over %v", m.Seats(), m.Turn(), m.Winner(), m.Over())
}
}
func TestMatchWordRulesAreTheBoards(t *testing.T) {
m := newMatch(t, 2)
cases := []struct {
seat int
word string
want Reason
}{
{1, "hồng hào", ReasonNotYourTurn},
{0, "hồng", ReasonTooFewSyllables},
{0, "hồng xyz", ReasonNotInDictionary},
{0, "tâm sự", ReasonWrongLink},
}
for _, c := range cases {
if _, got := m.Submit(c.seat, c.word, t0); got != c.want {
t.Errorf("Submit(%d, %q) = %q, want %q", c.seat, c.word, got, c.want)
}
}
mv, r := m.Submit(0, "Hồng HÀO", t0)
if r != ReasonNone || mv.Word != "hồng hào" || mv.Seat != 0 || m.Score(0) != mv.Points || m.Turn() != 1 {
t.Fatalf("valid move = %+v %q; score %d turn %d", mv, r, m.Score(0), m.Turn())
}
m.Submit(1, "hào hoa", t0)
if _, r := m.Submit(0, "hoa hồng", t0); r != ReasonAlreadyUsed {
t.Fatalf("reused opening = %q", r)
}
}
func TestMatchTimeoutEliminatesAndPlaysOn(t *testing.T) {
m := newMatch(t, 3)
if m.Timeout(t0.Add(limit + grace)) {
t.Fatal("timeout inside the grace window")
}
if !m.Timeout(late) {
t.Fatal("timeout did not fire")
}
if m.Over() || m.Alive(0) || m.OutReason(0) != EndTimeout || m.Turn() != 1 {
t.Fatalf("after timeout: over %v alive0 %v reason %q turn %d", m.Over(), m.Alive(0), m.OutReason(0), m.Turn())
}
// The position survives the player: the next seat answers the same
// syllable, with a full turn that starts when the expired one ended.
if m.Current() != "hồng" {
t.Errorf("current = %q, want hồng", m.Current())
}
if want := t0.Add(limit + grace + limit); !m.Deadline().Equal(want) {
t.Errorf("deadline = %v, want %v", m.Deadline(), want)
}
}
func TestMatchSyllableAndUsedWordsSurviveAnEliminatedPlayer(t *testing.T) {
m := newMatch(t, 3)
m.Submit(0, "hồng tâm", t0)
at := t0.Add(limit + grace + time.Nanosecond)
if !m.Timeout(at) || m.Alive(1) {
t.Fatal("seat 1 not eliminated")
}
if m.Turn() != 2 || m.Current() != "tâm" || !m.Used("hồng tâm") {
t.Fatalf("turn %d current %q used %v", m.Turn(), m.Current(), m.Used("hồng tâm"))
}
if _, r := m.Submit(2, "tâm sự", at); r != ReasonNone {
t.Fatalf("next seat answering the same syllable = %q", r)
}
if m.Score(0) == 0 {
t.Fatal("seat 0's points were lost")
}
}
func TestMatchTurnSkipsEliminatedSeats(t *testing.T) {
m := newMatch(t, 3)
m.Submit(0, "hồng tâm", t0)
deadline := m.Deadline()
// Seat 2 leaves while seat 1 is thinking: seat 1 keeps its turn and clock.
if !m.Resign(2, t0.Add(time.Second)) || m.OutReason(2) != EndGaveUp {
t.Fatal("out-of-turn resign failed")
}
if m.Turn() != 1 || !m.Deadline().Equal(deadline) {
t.Fatalf("turn %d deadline %v, want 1 / %v", m.Turn(), m.Deadline(), deadline)
}
m.Submit(1, "tâm sự", t0.Add(2*time.Second))
if m.Turn() != 0 {
t.Fatalf("turn = %d, want 0: the eliminated seat 2 is skipped", m.Turn())
}
if _, r := m.Submit(2, "sự cố", t0.Add(3*time.Second)); r != ReasonNotYourTurn {
t.Fatalf("eliminated seat submit = %q", r)
}
if m.Resign(2, t0) {
t.Fatal("an eliminated seat resigned twice")
}
}
func TestMatchLastStandingWins(t *testing.T) {
m := newMatch(t, 3)
m.Submit(0, "hồng hào", t0)
// Nobody else plays. One Timeout per expired turn catches up even when
// the caller settles late.
at := t0.Add(3 * (limit + grace))
for m.Timeout(at) {
}
if !m.Over() || m.Winner() != 0 || m.Alive(1) || m.Alive(2) || m.Turn() != 0 {
t.Fatalf("over %v winner %d", m.Over(), m.Winner())
}
if got := m.Standings(); !slices.Equal(got, []int{0, 2, 1}) {
t.Fatalf("standings = %v, want [0 2 1]", got)
}
if _, r := m.Submit(0, "hào hoa", at); r != ReasonGameOver {
t.Fatalf("move after the end = %q", r)
}
}
func TestMatchDeadEndSettlesEveryoneBehind(t *testing.T) {
m := newMatch(t, 3)
m.Submit(0, "hồng tâm", t0)
m.Submit(1, "tâm sự", t0)
m.Submit(2, "sự cố", t0) // "cố" starts nothing
if m.Over() || m.Turn() != 0 {
t.Fatal("a dead end ended the match before the next seat had its turn")
}
if !m.Timeout(late) {
t.Fatal("timeout did not fire")
}
if !m.Over() || m.Winner() != 2 || m.OutReason(0) != EndNoLegalMove || m.OutReason(1) != EndNoLegalMove {
t.Fatalf("over %v winner %d reasons %q %q", m.Over(), m.Winner(), m.OutReason(0), m.OutReason(1))
}
if got := m.Standings(); !slices.Equal(got, []int{2, 1, 0}) {
t.Fatalf("standings = %v", got)
}
}
func TestMatchResignInADeadEndIsNoLegalMove(t *testing.T) {
m := newMatch(t, 2)
m.Submit(0, "hồng tâm", t0)
m.Submit(1, "tâm sự", t0)
m.Submit(0, "sự cố", t0)
if !m.Resign(1, t0) || m.OutReason(1) != EndNoLegalMove || m.Winner() != 0 || m.EndReason() != EndNoLegalMove {
t.Fatalf("reason %q winner %d", m.OutReason(1), m.Winner())
}
}
func TestMatchEndMaxMovesRanksSurvivorsByPoints(t *testing.T) {
m := newMatch(t, 3)
// Seat 0 answers on the buzzer, seats 1 and 2 at once, so the points run
// against seat order: 2 > 1 > 0.
at := t0.Add(limit - time.Second)
m.Submit(0, "hồng tâm", at)
m.Submit(1, "tâm sự", at)
m.Submit(2, "sự cố", at)
if m.Score(2) <= m.Score(1) || m.Score(1) <= m.Score(0) {
t.Fatalf("scores %d %d %d do not run against seat order", m.Score(0), m.Score(1), m.Score(2))
}
if !m.EndMaxMoves() || m.Winner() != 2 || m.EndReason() != EndMaxMoves || m.Turn() != 2 {
t.Fatalf("winner %d reason %q", m.Winner(), m.EndReason())
}
if got := m.Standings(); !slices.Equal(got, []int{2, 1, 0}) {
t.Fatalf("standings = %v, want [2 1 0] by points", got)
}
if m.EndMaxMoves() {
t.Fatal("ended twice")
}
}
func TestMatchLateSubmitStartsTheNextTurnWhenTheExpiredOneEnded(t *testing.T) {
m := newMatch(t, 3)
at := t0.Add(2 * time.Minute)
if _, r := m.Submit(0, "hồng hào", at); r != ReasonTimeout || m.Alive(0) || m.Turn() != 1 {
t.Fatalf("late submit = %q alive %v turn %d", r, m.Alive(0), m.Turn())
}
if want := t0.Add(limit + grace + limit); !m.Deadline().Equal(want) {
t.Fatalf("deadline = %v, want %v", m.Deadline(), want)
}
}
func TestMatchOnTurnResignRestartsTheClock(t *testing.T) {
m := newMatch(t, 3)
at := t0.Add(10 * time.Second)
if !m.Resign(0, at) || m.Turn() != 1 || m.OutReason(0) != EndGaveUp {
t.Fatalf("turn %d reason %q", m.Turn(), m.OutReason(0))
}
if want := at.Add(limit); !m.Deadline().Equal(want) {
t.Fatalf("deadline = %v, want %v", m.Deadline(), want)
}
}
// Only the seat facing a dead end can claim it; anyone else leaving gives up,
// and the seat to act keeps its own turn.
func TestMatchOutOfTurnResignInADeadEndIsGivingUp(t *testing.T) {
m := newMatch(t, 3)
m.Submit(0, "hồng tâm", t0)
m.Submit(1, "tâm sự", t0)
m.Submit(2, "sự cố", t0) // seat 0 faces "cố"
if !m.Resign(1, t0) || m.OutReason(1) != EndGaveUp {
t.Fatalf("out-of-turn reason = %q", m.OutReason(1))
}
if m.Over() || m.Turn() != 0 {
t.Fatalf("over %v turn %d: the dead end was settled out of turn", m.Over(), m.Turn())
}
}
+64
View File
@@ -0,0 +1,64 @@
package engine
import (
"math/bits"
"time"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
)
// Scoring, ported from tiennm99/noitu server/internal/game/engine.go. A word
// is worth more the longer the chain it extends, the longer the word, the
// faster it was played, and the fewer words the corpus offers for the
// syllable it answered.
const (
basePoints = 10
chainBonus = 2 // per word already played
chainBonusWords = 15 // the chain term stops growing here
syllableBonus = 5 // per syllable beyond the minimum
speedBonus = 10 // full for an instant answer, nothing on the buzzer
rarityBonus = 15 // full for a syllable with a single answer
rarityHalvingPenalty = 3 // lost per doubling of the answers available
// MaxPointsPerWord caps one word's score. Overflow is trimmed from rarity
// first, then speed, then syllables.
MaxPointsPerWord = 100
)
// pointsFor scores a word about to be played: syllables is its length, link
// the syllable it answers, now when it was played.
func (b *board) pointsFor(syllables int, link string, now time.Time) int {
parts := []int{
basePoints,
chainBonus * min(b.ChainLength(), chainBonusWords),
syllableBonus * (syllables - dict.MinSyllables),
b.speedPoints(now),
b.rarityPoints(link),
}
total := 0
for _, p := range parts {
total += p
}
return min(total, MaxPointsPerWord)
}
// speedPoints pays for the share of the turn left on the clock. A move in the
// grace period is past the deadline and earns nothing.
func (b *board) speedPoints(now time.Time) int {
remaining := b.deadline.Sub(now)
if remaining <= 0 {
return 0
}
remaining = min(remaining, b.turnLimit)
return int(int64(speedBonus) * int64(remaining) / int64(b.turnLimit))
}
// rarityPoints pays for how few corpus words answer link, spent ones
// included: the reward is a property of the dictionary, not of this game.
func (b *board) rarityPoints(link string) int {
options := b.dict.OutDegree(link)
if options < 1 {
return 0
}
halvings := bits.Len(uint(options)) - 1
return max(rarityBonus-rarityHalvingPenalty*halvings, 0)
}
+248
View File
@@ -0,0 +1,248 @@
package noitu
import (
"bytes"
"context"
"encoding/json"
"math/rand/v2"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/tiennm99/tiennm99bot/internal/modules"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
var testKey = []byte("0123456789abcdef0123456789abcdef")
const testBase = "https://game.example"
// testCorpus is a tiny word graph. The opening falls back to the best
// connected word, "hoa hồng":
//
// hồng → hồng hào → hào hoa → hoa lá (dead) | hoa hồng (opening)
// hồng → hồng tâm → tâm sự → sự cố (dead)
const testCorpus = "hoa hồng\tdanh từ|loài hoa\nhồng hào\t\nhồng tâm\t\ntâm sự\t\nsự cố\t\nhào hoa\t\nhoa lá\t\n"
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 {
claims claims
score int
}
type fakeReporter struct {
mu sync.Mutex
calls []reportCall
err error
announced []string
cards []claims
// beforeReport, when set before any game ends, runs at the start of
// every Report, outside the mutex, so a test can hold a report.
beforeReport func(claims)
}
func (r *fakeReporter) Announce(_ context.Context, card claims, text string) error {
r.mu.Lock()
defer r.mu.Unlock()
r.announced = append(r.announced, text)
r.cards = append(r.cards, card)
return r.err
}
// announcedOn counts the announcements made on the card with message msg.
func (r *fakeReporter) announcedOn(msg int) int {
r.mu.Lock()
defer r.mu.Unlock()
n := 0
for _, c := range r.cards {
if c.MessageID == msg {
n++
}
}
return n
}
func (r *fakeReporter) announcements() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.announced...)
}
func (r *fakeReporter) Report(_ context.Context, c claims, score int) error {
if r.beforeReport != nil {
r.beforeReport(c)
}
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, reportCall{c, score})
return r.err
}
func (r *fakeReporter) snapshot() []reportCall {
r.mu.Lock()
defer r.mu.Unlock()
return append([]reportCall(nil), r.calls...)
}
type harness struct {
t *testing.T
svc *service
mod modules.Module
clock *fakeClock
reporter *fakeReporter
handler http.Handler
}
func newHarness(t *testing.T, corpus string) *harness {
t.Helper()
store, err := dict.Parse(corpus)
if err != nil {
t.Fatalf("Parse: %v", err)
}
return newHarnessWithStore(t, store)
}
func newHarnessWithStore(t *testing.T, store *dict.Store) *harness {
t.Helper()
h := &harness{t: t, clock: &fakeClock{t: time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)}, reporter: &fakeReporter{}}
seed := uint64(0)
coll := storage.NewMemoryProvider().Collection("noitu")
cfg := config{
baseURL: testBase,
key: testKey,
now: h.clock.now,
newRNG: func() *rand.Rand { seed++; return rand.New(rand.NewPCG(seed, 42)) },
reporter: h.reporter,
announcer: h.reporter,
cards: storage.Typed[pvpCard](coll),
top: storage.Typed[topEntry](coll),
loadDict: func() (*dict.Store, error) { return store, nil },
}
h.svc = newService(cfg)
h.mod = h.svc.module()
if len(h.mod.HTTP) != 1 {
t.Fatalf("enabled module has %d routes, want 1", len(h.mod.HTTP))
}
h.handler = h.mod.HTTP[0].Handler
return h
}
// token signs a chat-message token for user valid from the harness clock.
func (h *harness) token(user int64) string {
h.t.Helper()
return h.tokenFor(user, 5)
}
// tokenFor signs a token for user on game message msg in the user's chat.
func (h *harness) tokenFor(user int64, msg int) string {
h.t.Helper()
tok, err := signToken(testKey, claims{UserID: user, Name: "Tí", ChatID: user, MessageID: msg, Expiry: h.clock.now().Add(tokenTTL).Unix()})
if err != nil {
h.t.Fatal(err)
}
return tok
}
// post sends a raw body and returns the recorder.
func (h *harness) post(path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, routePrefix+"api/"+path, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.handler.ServeHTTP(rec, req)
return rec
}
// postJSON marshals v and posts it.
func (h *harness) postJSON(path string, v any) *httptest.ResponseRecorder {
h.t.Helper()
body, err := json.Marshal(v)
if err != nil {
h.t.Fatal(err)
}
return h.post(path, string(body))
}
// call posts v and decodes the answer into out, failing on an unexpected status.
func (h *harness) call(path string, v any, wantStatus int, out any) {
h.t.Helper()
body, _ := json.Marshal(v)
rec := h.post(path, string(body))
if rec.Code != wantStatus {
h.t.Fatalf("%s: status %d, want %d; body %s", path, rec.Code, wantStatus, rec.Body.String())
}
if out != nil {
if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
h.t.Fatalf("%s: decode %q: %v", path, rec.Body.String(), err)
}
}
}
func (h *harness) start(user int64, difficulty string) sessionView {
h.t.Helper()
var v sessionView
h.call("start", map[string]string{"token": h.token(user), "difficulty": difficulty}, http.StatusOK, &v)
return v
}
// move advances the clock past the per-move rate limit, then plays word.
func (h *harness) move(session, word string) moveResponse {
h.t.Helper()
h.clock.advance(time.Second)
var r moveResponse
h.call("move", map[string]string{"session": session, "word": word}, http.StatusOK, &r)
return r
}
func (h *harness) state(session string) sessionView {
h.t.Helper()
var v sessionView
h.call("state", map[string]string{"session": session}, http.StatusOK, &v)
return v
}
// waitReport polls the state until the asynchronous score report settles.
func (h *harness) waitReport(session string) sessionView {
h.t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
v := h.state(session)
if v.ScoreReported != reportPending || time.Now().After(deadline) {
return v
}
time.Sleep(5 * time.Millisecond)
}
}
func (h *harness) apiError(rec *httptest.ResponseRecorder) string {
h.t.Helper()
var e struct {
Code string `json:"error"`
Message string `json:"message"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
h.t.Fatalf("decode error body %q: %v", rec.Body.String(), err)
}
if e.Message == "" {
h.t.Errorf("error %q has no message", e.Code)
}
return e.Code
}
+403
View File
@@ -0,0 +1,403 @@
package noitu
import (
"errors"
"net/http"
"strings"
"time"
"unicode/utf8"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/engine"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/opponent"
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
)
const (
maxBodyBytes = htmlgame.MaxBodyBytes
maxWordRunes = 64
maxWordTokens = 8
)
// apiError is the JSON error body every API failure returns.
type apiError struct {
status int
Code string `json:"error"`
Message string `json:"message"`
}
func (e apiError) Error() string { return "noitu: " + e.Code }
var (
errBadRequest = apiError{http.StatusBadRequest, "bad_request", "Yêu cầu không hợp lệ."}
errWordTooLong = apiError{http.StatusBadRequest, "bad_request", "Từ quá dài."}
errBadTokenAPI = apiError{http.StatusUnauthorized, "bad_token", "Liên kết trò chơi không hợp lệ. Hãy mở lại trò chơi từ Telegram."}
errTokenExpAPI = apiError{http.StatusUnauthorized, "token_expired", "Liên kết trò chơi đã hết hạn. Hãy bấm Chơi trong Telegram để mở lại."}
errNoSession = apiError{http.StatusNotFound, "no_session", "Ván chơi không còn nữa. Hãy bắt đầu ván mới."}
errGameOver = apiError{http.StatusConflict, "game_over", "Ván chơi đã kết thúc."}
errTooFast = apiError{http.StatusTooManyRequests, "too_fast", "Bạn thao tác nhanh quá, thử lại sau giây lát."}
errBusyAPI = apiError{http.StatusServiceUnavailable, "busy", "Máy chủ đang bận, thử lại sau."}
errInternalAPI = apiError{http.StatusInternalServerError, "internal", "Có lỗi xảy ra, thử lại sau."}
rejectMessages = map[engine.Reason]string{
engine.ReasonTooFewSyllables: "Từ phải có ít nhất 2 tiếng.",
engine.ReasonNotInDictionary: "Từ này không có trong từ điển.",
engine.ReasonWrongLink: "Từ phải bắt đầu bằng tiếng “%s”.",
engine.ReasonAlreadyUsed: "Từ này đã được dùng rồi.",
engine.ReasonTimeout: "Hết giờ!",
}
)
// sessionView is the game state the page renders. See docs/noitu-game.md.
type sessionView struct {
Session string `json:"session"`
Player string `json:"player"`
Difficulty string `json:"difficulty"`
Status string `json:"status"` // playing | won | lost
EndReason string `json:"end_reason"`
Current string `json:"current"`
Chain []chainEntry `json:"chain"`
Score int `json:"score"`
TurnLimitMS int64 `json:"turn_limit_ms"`
DeadlineMS int64 `json:"deadline_ms"`
ServerNowMS int64 `json:"server_now_ms"`
Suggestions []string `json:"suggestions"`
ScoreReported string `json:"score_reported"`
}
type moveResult struct {
Accepted bool `json:"accepted"`
Reason string `json:"reason"`
Message string `json:"message"`
PlayerWord string `json:"player_word"`
BotWord string `json:"bot_word"`
}
type moveResponse struct {
Result moveResult `json:"result"`
State sessionView `json:"state"`
}
// handler serves the page, its assets and the JSON API under routePrefix.
func (s *service) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET "+routePrefix+"{$}", serveAsset("index.html"))
mux.HandleFunc("GET "+routePrefix+"app.js", serveAsset("app.js"))
mux.HandleFunc("GET "+routePrefix+"app.css", serveAsset("app.css"))
mux.HandleFunc("POST "+routePrefix+"api/start", s.apiStart)
mux.HandleFunc("POST "+routePrefix+"api/move", s.apiMove)
mux.HandleFunc("POST "+routePrefix+"api/state", s.apiState)
mux.HandleFunc("POST "+routePrefix+"api/give-up", s.apiGiveUp)
mux.HandleFunc("POST "+routePrefix+"api/room/join", s.apiRoomJoin)
mux.HandleFunc("POST "+routePrefix+"api/room/state", s.apiRoomState)
mux.HandleFunc("POST "+routePrefix+"api/room/start", s.apiRoomStart)
mux.HandleFunc("POST "+routePrefix+"api/room/move", s.apiRoomMove)
mux.HandleFunc("POST "+routePrefix+"api/room/give-up", s.apiRoomGiveUp)
return securityHeaders(mux)
}
// securityHeaders applies to every response under the game 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.
func securityHeaders(next http.Handler) http.Handler {
return htmlgame.SecurityHeaders(routePrefix, next)
}
func writeJSON(w http.ResponseWriter, status int, v any) { htmlgame.WriteJSON(w, status, v) }
func writeError(w http.ResponseWriter, e apiError) { writeJSON(w, e.status, e) }
// decode reads one JSON object of at most maxBodyBytes with no unknown fields
// and nothing after it.
func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
if err := htmlgame.DecodeJSON(w, r, dst); err != nil {
writeError(w, errBadRequest)
return false
}
return true
}
type startRequest struct {
Token string `json:"token"`
Difficulty string `json:"difficulty"`
}
func (s *service) apiStart(w http.ResponseWriter, r *http.Request) {
var req startRequest
if !decode(w, r, &req) {
return
}
now := s.cfg.now()
c, ok := s.verifyRequestToken(w, req.Token, now)
if !ok {
return
}
if c.PvP {
writeError(w, errPvPSoloAPI)
return
}
difficulty, ok := opponent.ParseDifficulty(req.Difficulty)
if !ok {
writeError(w, errBadRequest)
return
}
if !s.sessions.allowStart(c.UserID, now) {
writeError(w, errTooFast)
return
}
s.sweep(now)
sess, err := s.newSession(c, difficulty, now)
if err != nil {
log.Error("noitu start failed", "err", err)
writeError(w, errInternalAPI)
return
}
displaced, err := s.sessions.add(sess)
if err != nil {
writeError(w, errBusyAPI)
return
}
if displaced != nil {
s.settle(displaced, now)
}
sess.mu.Lock()
defer sess.mu.Unlock()
writeJSON(w, http.StatusOK, s.view(sess, now))
}
func (s *service) newSession(c claims, d opponent.Difficulty, now time.Time) (*session, error) {
id, err := newSessionID()
if err != nil {
return nil, err
}
rng := s.cfg.newRNG()
strategy, err := opponent.New(d, rng)
if err != nil {
return nil, err
}
eng, err := engine.New(s.words, s.words.RandomOpening(rng, minOpeningOut), turnLimit, turnGrace, now)
if err != nil {
return nil, err
}
sess := &session{id: id, claims: c, difficulty: d, eng: eng, strategy: strategy, lastSeen: now}
sess.chain = append(sess.chain, s.entry(eng.Opening(), "bot", 0))
return sess, nil
}
func (s *service) entry(word, by string, points int) chainEntry {
m := s.words.Meanings(word)
if m == nil {
m = []dict.Sense{}
}
return chainEntry{Word: word, By: by, Points: points, Meanings: m}
}
type sessionRequest struct {
Session string `json:"session"`
}
type moveRequest struct {
Session string `json:"session"`
Word string `json:"word"`
}
// lockedSession finds and locks the session named in a request, settling an
// expired turn first and answering no_session when the session is gone.
// timedOut reports that this call ended the game on the clock. On ok the
// caller must unlock.
func (s *service) lockedSession(w http.ResponseWriter, id string, now time.Time) (sess *session, timedOut, ok bool) {
sess = s.sessions.get(id)
if sess == nil {
writeError(w, errNoSession)
return nil, false, false
}
sess.mu.Lock()
if sess.eng.Timeout(now) {
s.finish(sess, now)
timedOut = true
}
if sess.expired(now) {
sess.mu.Unlock()
s.sessions.remove(sess)
writeError(w, errNoSession)
return nil, false, false
}
return sess, timedOut, true
}
func (s *service) apiState(w http.ResponseWriter, r *http.Request) {
var req sessionRequest
if !decode(w, r, &req) {
return
}
now := s.cfg.now()
sess, _, ok := s.lockedSession(w, req.Session, now)
if !ok {
return
}
defer sess.mu.Unlock()
if !sess.eng.Over() {
sess.lastSeen = now
}
writeJSON(w, http.StatusOK, s.view(sess, now))
}
func (s *service) apiGiveUp(w http.ResponseWriter, r *http.Request) {
var req sessionRequest
if !decode(w, r, &req) {
return
}
now := s.cfg.now()
sess, _, ok := s.lockedSession(w, req.Session, now)
if !ok {
return
}
defer sess.mu.Unlock()
if s.resign(sess) {
s.finish(sess, now)
}
writeJSON(w, http.StatusOK, s.view(sess, now))
}
func (s *service) apiMove(w http.ResponseWriter, r *http.Request) {
var req moveRequest
if !decode(w, r, &req) {
return
}
now := s.cfg.now()
sess, timedOut, ok := s.lockedSession(w, req.Session, now)
if !ok {
return
}
defer sess.mu.Unlock()
if timedOut {
// The move arrived after the deadline plus grace: it ended the game.
writeJSON(w, http.StatusOK, moveResponse{Result: s.rejected(sess, engine.ReasonTimeout), State: s.view(sess, now)})
return
}
if sess.eng.Over() {
writeError(w, errGameOver)
return
}
if now.Sub(sess.lastMove) < moveInterval {
writeError(w, errTooFast)
return
}
sess.lastMove, sess.lastSeen = now, now
if wordTooLong(req.Word) {
writeError(w, errWordTooLong)
return
}
result, err := s.play(sess, req.Word, now)
if err != nil {
log.Error("noitu bot move failed", "err", err)
writeError(w, errInternalAPI)
return
}
writeJSON(w, http.StatusOK, moveResponse{Result: result, State: s.view(sess, now)})
}
func wordTooLong(word string) bool {
return utf8.RuneCountInString(word) > maxWordRunes || len(strings.Fields(word)) > maxWordTokens
}
// play submits the human's word and, when it is accepted, the bot's reply.
// Caller holds sess.mu.
func (s *service) play(sess *session, word string, now time.Time) (moveResult, error) {
mv, reason := sess.eng.Submit(engine.Human, word, now)
if reason != engine.ReasonNone {
if sess.eng.Over() {
s.finish(sess, now)
}
return s.rejected(sess, reason), nil
}
sess.chain = append(sess.chain, s.entry(mv.Word, "player", mv.Points))
result := moveResult{Accepted: true, PlayerWord: mv.Word}
switch {
case len(sess.chain) >= maxChain:
sess.eng.EndMaxMoves()
case sess.eng.BotStuck():
// The bot has no reply: BotStuck already ended the game as a win.
default:
botWord, err := s.botMove(sess, now)
if err != nil {
return moveResult{}, err
}
result.BotWord = botWord
if len(sess.chain) >= maxChain {
sess.eng.EndMaxMoves()
}
}
if sess.eng.Over() {
s.finish(sess, now)
}
return result, nil
}
// botMove plays the strategy's choice. Every strategy picks from the legal
// moves, so a rejection here is a bug; fall back to the first legal move
// rather than leaving the game stuck on the bot's turn.
func (s *service) botMove(sess *session, now time.Time) (string, error) {
word, err := sess.strategy.Choose(sess.eng)
if err != nil {
word = ""
}
mv, reason := sess.eng.Submit(engine.Bot, word, now)
if reason != engine.ReasonNone {
legal := sess.eng.LegalMoves()
if len(legal) == 0 {
return "", errors.New("noitu: bot has no move but BotStuck did not end the game")
}
if mv, reason = sess.eng.Submit(engine.Bot, legal[0], now); reason != engine.ReasonNone {
return "", errors.New("noitu: engine rejected a legal bot move: " + string(reason))
}
}
sess.chain = append(sess.chain, s.entry(mv.Word, "bot", mv.Points))
return mv.Word, nil
}
func (s *service) rejected(sess *session, reason engine.Reason) moveResult {
return rejectedFor(reason, sess.eng.Current())
}
// rejectedFor explains a rejected word; current is the syllable to answer.
func rejectedFor(reason engine.Reason, current string) moveResult {
msg := rejectMessages[reason]
if reason == engine.ReasonWrongLink {
msg = strings.Replace(msg, "%s", current, 1)
}
return moveResult{Reason: string(reason), Message: msg}
}
// view renders the session for the page. Caller holds sess.mu.
func (s *service) view(sess *session, now time.Time) sessionView {
v := sessionView{
Session: sess.id,
Player: sess.claims.Name,
Difficulty: string(sess.difficulty),
Status: "playing",
EndReason: string(sess.eng.EndReason()),
Current: sess.eng.Current(),
Chain: sess.chain,
Score: sess.eng.Score(),
TurnLimitMS: turnLimit.Milliseconds(),
ServerNowMS: now.UnixMilli(),
Suggestions: []string{},
ScoreReported: sess.report,
}
if sess.eng.Over() {
v.Status = "lost"
if sess.eng.Winner() == engine.Human {
v.Status = "won"
}
if sess.suggestions != nil {
v.Suggestions = sess.suggestions
}
return v
}
v.DeadlineMS = sess.eng.Deadline().UnixMilli()
return v
}
+444
View File
@@ -0,0 +1,444 @@
package noitu
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/tiennm99/tiennm99bot/internal/modules"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
)
func TestAPI_PlayToBotStuckWinsAndReportsOnce(t *testing.T) {
h := newHarness(t, testCorpus)
v := h.start(42, "")
if v.Status != "playing" || v.Difficulty != "medium" || v.Current != "hồng" || v.Player != "Tí" {
t.Fatalf("start view = %+v", v)
}
if len(v.Chain) != 1 || v.Chain[0].Word != "hoa hồng" || v.Chain[0].By != "bot" || len(v.Chain[0].Meanings) != 1 {
t.Fatalf("opening chain = %+v", v.Chain)
}
if v.DeadlineMS-v.ServerNowMS != turnLimit.Milliseconds() || v.TurnLimitMS != 30000 {
t.Fatalf("deadline %d now %d limit %d", v.DeadlineMS, v.ServerNowMS, v.TurnLimitMS)
}
r := h.move(v.Session, "Hồng Tâm")
if !r.Result.Accepted || r.Result.PlayerWord != "hồng tâm" || r.Result.BotWord != "tâm sự" {
t.Fatalf("move 1 = %+v", r.Result)
}
if r.State.Current != "sự" || r.State.Status != "playing" {
t.Fatalf("after move 1: %+v", r.State)
}
r = h.move(v.Session, "sự cố")
if !r.Result.Accepted || r.Result.BotWord != "" || r.State.Status != "won" || r.State.EndReason != "bot_stuck" {
t.Fatalf("move 2 = %+v / %+v", r.Result, r.State)
}
if r.State.DeadlineMS != 0 {
t.Errorf("finished game still has a deadline: %d", r.State.DeadlineMS)
}
sum := 0
for _, e := range r.State.Chain {
if e.By == "player" {
sum += e.Points
}
}
if sum == 0 || r.State.Score != sum {
t.Fatalf("score %d, want the sum of player points %d", r.State.Score, sum)
}
final := h.waitReport(v.Session)
if final.ScoreReported != reportOK {
t.Fatalf("score_reported = %q, want ok", final.ScoreReported)
}
calls := h.reporter.snapshot()
if len(calls) != 1 || calls[0].score != sum || calls[0].claims.ChatID != 42 || calls[0].claims.MessageID != 5 {
t.Fatalf("reporter calls = %+v", calls)
}
// Further calls never report again.
h.call("give-up", map[string]string{"session": v.Session}, http.StatusOK, nil)
h.state(v.Session)
if n := len(h.reporter.snapshot()); n != 1 {
t.Fatalf("reporter called %d times, want once", n)
}
rec := h.post("move", fmt.Sprintf(`{"session":%q,"word":"cố gắng"}`, v.Session))
if rec.Code != http.StatusConflict || h.apiError(rec) != "game_over" {
t.Fatalf("move after end: %d %s", rec.Code, rec.Body.String())
}
}
func TestAPI_RejectReasonsKeepTheTurn(t *testing.T) {
h := newHarness(t, testCorpus)
v := h.start(42, "easy")
cases := []struct{ word, reason string }{
{"hồng", "too_few_syllables"},
{"hồng xyz", "not_in_dictionary"},
{"tâm sự", "wrong_link"},
}
for _, c := range cases {
r := h.move(v.Session, c.word)
if r.Result.Accepted || r.Result.Reason != c.reason || r.Result.Message == "" {
t.Errorf("%q: result %+v, want %s", c.word, r.Result, c.reason)
}
if r.State.DeadlineMS != v.DeadlineMS || r.State.Current != "hồng" || len(r.State.Chain) != 1 {
t.Errorf("%q: a rejected move changed the turn: %+v", c.word, r.State)
}
}
if r := h.move(v.Session, "tâm sự"); !strings.Contains(r.Result.Message, "“hồng”") {
t.Errorf("wrong_link message %q does not name the syllable", r.Result.Message)
}
r := h.move(v.Session, "hồng hào") // bot answers "hào hoa"
if r.Result.BotWord != "hào hoa" {
t.Fatalf("bot reply = %q", r.Result.BotWord)
}
r = h.move(v.Session, "hoa hồng")
if r.Result.Accepted || r.Result.Reason != "already_used" {
t.Fatalf("reused opening: %+v", r.Result)
}
h.clock.advance(time.Second)
rec := h.post("move", fmt.Sprintf(`{"session":%q,"word":%q}`, v.Session, strings.Repeat("a ", 9)))
if rec.Code != http.StatusBadRequest {
t.Fatalf("over-long word: status %d", rec.Code)
}
}
func TestAPI_TimeoutAfterGraceEndsTheGame(t *testing.T) {
h := newHarness(t, testCorpus)
v := h.start(42, "")
// Still inside the grace window: accepted, but without speed points.
h.clock.advance(turnLimit + turnGrace - time.Second)
var r moveResponse
h.call("move", map[string]string{"session": v.Session, "word": "hồng hào"}, http.StatusOK, &r)
if !r.Result.Accepted {
t.Fatalf("move in grace rejected: %+v", r.Result)
}
h.clock.advance(turnLimit + turnGrace + time.Millisecond)
h.call("move", map[string]string{"session": v.Session, "word": "hoa lá"}, http.StatusOK, &r)
if r.Result.Accepted || r.Result.Reason != "timeout" || r.State.Status != "lost" || r.State.EndReason != "timeout" {
t.Fatalf("late move = %+v / %+v", r.Result, r.State)
}
if fmt.Sprint(r.State.Suggestions) != "[hoa lá]" {
t.Fatalf("suggestions = %v", r.State.Suggestions)
}
if got := h.waitReport(v.Session); got.ScoreReported != reportOK {
t.Fatalf("score_reported = %q", got.ScoreReported)
}
}
func TestAPI_StateSettlesTimeoutAndSweepReports(t *testing.T) {
h := newHarness(t, testCorpus)
v := h.start(42, "")
h.clock.advance(turnLimit + turnGrace + time.Second)
got := h.state(v.Session)
if got.Status != "lost" || got.EndReason != "timeout" || got.ScoreReported != reportSkipped {
t.Fatalf("state after deadline = %+v", got)
}
// A player who scored and then closed the page is settled by the sweep.
v2 := h.start(43, "")
h.move(v2.Session, "hồng hào")
h.clock.advance(time.Minute)
if err := h.svc.sweepCron(t.Context(), modules.Deps{}); err != nil {
t.Fatal(err)
}
if h.waitReport(v2.Session).ScoreReported != reportOK {
t.Fatal("sweep did not report the abandoned game")
}
h.clock.advance(finishedTTL + time.Second)
h.svc.sweep(h.clock.now())
rec := h.post("state", fmt.Sprintf(`{"session":%q}`, v2.Session))
if rec.Code != http.StatusNotFound || h.apiError(rec) != "no_session" {
t.Fatalf("expired session: %d %s", rec.Code, rec.Body.String())
}
}
func TestAPI_GiveUpWithoutPointsSkipsReport(t *testing.T) {
h := newHarness(t, testCorpus)
v := h.start(42, "hard")
var got sessionView
h.call("give-up", map[string]string{"session": v.Session}, http.StatusOK, &got)
if got.Status != "lost" || got.EndReason != "gave_up" || got.ScoreReported != reportSkipped {
t.Fatalf("give up = %+v", got)
}
if len(got.Suggestions) != 2 {
t.Fatalf("suggestions = %v", got.Suggestions)
}
if len(h.reporter.snapshot()) != 0 {
t.Fatal("zero score was reported")
}
}
func TestAPI_ReportOutcomes(t *testing.T) {
for _, c := range []struct {
err error
want string
}{
{fmt.Errorf("Bad Request: BOT_SCORE_NOT_MODIFIED"), reportOK},
{fmt.Errorf("Forbidden"), reportFailed},
} {
h := newHarness(t, testCorpus)
h.reporter.err = c.err
v := h.start(42, "")
h.move(v.Session, "hồng hào")
h.call("give-up", map[string]string{"session": v.Session}, http.StatusOK, nil)
if got := h.waitReport(v.Session).ScoreReported; got != c.want {
t.Errorf("reporter err %v: score_reported %q, want %q", c.err, got, c.want)
}
}
}
func TestAPI_InlineTokenReportsInlineAddress(t *testing.T) {
h := newHarness(t, testCorpus)
tok, _ := signToken(testKey, claims{UserID: 9, InlineID: "BAAAAInline", Expiry: h.clock.now().Add(time.Hour).Unix()})
var v sessionView
h.call("start", map[string]string{"token": tok}, http.StatusOK, &v)
h.move(v.Session, "hồng hào")
h.call("give-up", map[string]string{"session": v.Session}, http.StatusOK, nil)
h.waitReport(v.Session)
calls := h.reporter.snapshot()
if len(calls) != 1 || calls[0].claims.InlineID != "BAAAAInline" || calls[0].claims.ChatID != 0 {
t.Fatalf("reporter calls = %+v", calls)
}
}
func TestAPI_TokenErrors(t *testing.T) {
h := newHarness(t, testCorpus)
tok := h.token(42)
rec := h.post("start", fmt.Sprintf(`{"token":%q}`, tok[:len(tok)-2]+"xx"))
if rec.Code != http.StatusUnauthorized || h.apiError(rec) != "bad_token" {
t.Fatalf("tampered token: %d %s", rec.Code, rec.Body.String())
}
h.clock.advance(tokenTTL)
rec = h.post("start", fmt.Sprintf(`{"token":%q}`, tok))
if rec.Code != http.StatusUnauthorized || h.apiError(rec) != "token_expired" {
t.Fatalf("expired token: %d %s", rec.Code, rec.Body.String())
}
}
func TestAPI_RequestLimits(t *testing.T) {
h := newHarness(t, testCorpus)
big := `{"token":"` + strings.Repeat("a", maxBodyBytes) + `"}`
if rec := h.post("start", big); rec.Code != http.StatusBadRequest || h.apiError(rec) != "bad_request" {
t.Fatalf("oversized body: %d", rec.Code)
}
if rec := h.post("start", `{"token":"x","score":1000}`); rec.Code != http.StatusBadRequest {
t.Fatalf("unknown field: %d", rec.Code)
}
if rec := h.post("start", `{"token":"x"}{}`); rec.Code != http.StatusBadRequest {
t.Fatalf("trailing data: %d", rec.Code)
}
tok := h.token(42)
if rec := h.post("start", fmt.Sprintf(`{"token":%q,"difficulty":"insane"}`, tok)); rec.Code != http.StatusBadRequest {
t.Fatalf("unknown difficulty: %d", rec.Code)
}
if rec := h.post("state", `{"session":"nope"}`); rec.Code != http.StatusNotFound || h.apiError(rec) != "no_session" {
t.Fatalf("unknown session: %d", rec.Code)
}
v := h.start(42, "")
h.move(v.Session, "hồng hào")
rec := h.post("move", fmt.Sprintf(`{"session":%q,"word":"hoa lá"}`, v.Session))
if rec.Code != http.StatusTooManyRequests || h.apiError(rec) != "too_fast" {
t.Fatalf("fast move: %d %s", rec.Code, rec.Body.String())
}
// Ten starts a minute per user, counting the one above.
for i := 0; i < startsPerMinute-1; i++ {
h.start(42, "")
}
rec = h.post("start", fmt.Sprintf(`{"token":%q}`, h.token(42)))
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("start flood: %d", rec.Code)
}
h.clock.advance(time.Minute)
h.start(42, "")
}
func TestAPI_SessionCap(t *testing.T) {
h := newHarness(t, testCorpus)
for user := int64(1); user <= maxSessions; user++ {
h.start(user, "easy")
}
rec := h.post("start", fmt.Sprintf(`{"token":%q}`, h.token(maxSessions+1)))
if rec.Code != http.StatusServiceUnavailable || h.apiError(rec) != "busy" {
t.Fatalf("over cap: %d %s", rec.Code, rec.Body.String())
}
// Restarting replaces the player's own session instead of adding one.
h.start(1, "easy")
}
func TestAPI_NewStartReplacesOwnSession(t *testing.T) {
h := newHarness(t, testCorpus)
first := h.start(42, "")
h.start(42, "")
if rec := h.post("state", fmt.Sprintf(`{"session":%q}`, first.Session)); rec.Code != http.StatusNotFound {
t.Fatalf("replaced session still answers: %d", rec.Code)
}
}
// A player who closes the page mid-game and starts again on the same message
// still gets the abandoned game's score reported.
func TestAPI_RestartMidGameReportsTheReplacedScore(t *testing.T) {
h := newHarness(t, testCorpus)
first := h.start(42, "")
r := h.move(first.Session, "hồng hào")
if !r.Result.Accepted || r.State.Score <= 0 {
t.Fatalf("scoring move = %+v / score %d", r.Result, r.State.Score)
}
h.start(42, "")
calls := waitCalls(t, h.reporter, 1)
if len(calls) != 1 || calls[0].score != r.State.Score || calls[0].claims.MessageID != 5 {
t.Fatalf("reporter calls = %+v, want one with score %d", calls, r.State.Score)
}
}
// Each user holds at most maxUserSessions live sessions; a further start on
// another message settles and drops the oldest.
func TestAPI_PerUserSessionCap(t *testing.T) {
h := newHarness(t, testCorpus)
var ids []string
for msg := 1; msg <= maxUserSessions; msg++ {
var v sessionView
h.call("start", map[string]string{"token": h.tokenFor(42, msg)}, http.StatusOK, &v)
ids = append(ids, v.Session)
}
scored := h.move(ids[0], "hồng hào").State.Score
var v sessionView
h.call("start", map[string]string{"token": h.tokenFor(42, maxUserSessions+1)}, http.StatusOK, &v)
if rec := h.post("state", fmt.Sprintf(`{"session":%q}`, ids[0])); rec.Code != http.StatusNotFound {
t.Fatalf("oldest session still answers: %d", rec.Code)
}
for _, id := range append(ids[1:], v.Session) {
h.state(id)
}
calls := waitCalls(t, h.reporter, 1)
if len(calls) != 1 || calls[0].score != scored || calls[0].claims.MessageID != 1 {
t.Fatalf("reporter calls = %+v, want the evicted game's %d", calls, scored)
}
// Another user is unaffected.
h.start(43, "")
}
func waitCalls(t *testing.T, r *fakeReporter, n int) []reportCall {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
calls := r.snapshot()
if len(calls) >= n || time.Now().After(deadline) {
return calls
}
time.Sleep(5 * time.Millisecond)
}
}
// cycleCorpus is a three-word cycle: after one exchange the human must answer
// the opening's first syllable, whose only word is the used opening.
const cycleCorpus = "hoa hồng\t\nhồng hào\t\nhào hoa\t\n"
// deadEnd starts a game for user and plays into the dead end.
func (h *harness) deadEnd(user int64) sessionView {
h.t.Helper()
v := h.start(user, "easy")
sess := h.svc.sessions.get(v.Session)
sess.mu.Lock()
reply := sess.eng.LegalMoves()[0]
sess.mu.Unlock()
r := h.move(v.Session, reply)
if !r.Result.Accepted || r.Result.BotWord == "" || r.State.Status != "playing" {
h.t.Fatalf("exchange = %+v / %+v", r.Result, r.State)
}
return r.State
}
func TestAPI_DeadEndEndsAsNoLegalMove(t *testing.T) {
h := newHarness(t, cycleCorpus)
v := h.deadEnd(42)
var got sessionView
h.call("give-up", map[string]string{"session": v.Session}, http.StatusOK, &got)
if got.Status != "lost" || got.EndReason != "no_legal_move" || len(got.Suggestions) != 0 {
t.Fatalf("give up in a dead end = %+v", got)
}
v = h.deadEnd(43)
h.clock.advance(turnLimit + turnGrace + time.Second)
if got := h.state(v.Session); got.Status != "lost" || got.EndReason != "no_legal_move" {
t.Fatalf("dead end on the clock = %+v", got)
}
}
func TestAPI_HeadersAndRoutes(t *testing.T) {
h := newHarness(t, testCorpus)
rec := h.post("start", `{}`)
hdr := rec.Header()
if !strings.Contains(hdr.Get("Content-Security-Policy"), "default-src 'self'") ||
hdr.Get("Referrer-Policy") != "no-referrer" ||
hdr.Get("Cache-Control") != "no-store" ||
hdr.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("API headers = %v", hdr)
}
if hdr.Get("X-Frame-Options") != "" || strings.Contains(hdr.Get("Content-Security-Policy"), "frame-ancestors") {
t.Fatal("framing restricted; Telegram Web embeds games in an iframe")
}
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+"?t=abc", nil))
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Type"), ctype) || rec.Body.Len() == 0 {
t.Errorf("GET %q: %d %q", path, rec.Code, rec.Header().Get("Content-Type"))
}
if rec.Header().Get("Cache-Control") != "public, max-age=300" || rec.Header().Get("Referrer-Policy") != "no-referrer" {
t.Errorf("GET %q headers = %v", path, rec.Header())
}
}
for _, path := range []string{"web.go", "secret", "api/start"} {
rec := httptest.NewRecorder()
h.handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, routePrefix+path, nil))
if rec.Code == http.StatusOK {
t.Errorf("GET %q served", path)
}
}
}
// One game against the real corpus, playing a legal word each turn, proves
// the embedded dictionary, the engine and the opponent fit together.
func TestAPI_RealCorpusGame(t *testing.T) {
store, err := dict.Default()
if err != nil {
t.Fatal(err)
}
h := newHarnessWithStore(t, store)
v := h.start(42, "hard")
if len(v.Chain) != 1 || v.Current == "" {
t.Fatalf("start view = %+v", v)
}
accepted, replies := 0, 0
for range 5 {
sess := h.svc.sessions.get(v.Session)
sess.mu.Lock()
legal := sess.eng.LegalMoves()
sess.mu.Unlock()
if len(legal) == 0 {
break
}
r := h.move(v.Session, legal[0])
if !r.Result.Accepted {
t.Fatalf("legal word %q rejected: %+v", legal[0], r.Result)
}
accepted++
if r.State.Status != "playing" {
break
}
if r.Result.BotWord == "" || r.State.Current == "" {
t.Fatalf("game still playing without a bot reply: %+v / %+v", r.Result, r.State)
}
replies++
v = r.State
}
if accepted == 0 || replies == 0 {
t.Fatalf("accepted %d words with %d bot replies; the real corpus must sustain a game", accepted, replies)
}
}
+12
View File
@@ -0,0 +1,12 @@
package noitu
import (
"net/http"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/web"
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
)
// serveAsset serves one embedded page file with a short public cache. See
// htmlgame.ServeAsset.
func serveAsset(name string) http.HandlerFunc { return htmlgame.ServeAsset(web.FS, name) }
+168
View File
@@ -0,0 +1,168 @@
package noitu
import (
"cmp"
"context"
"errors"
"fmt"
"slices"
"strconv"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/modules/util/chathelper"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
const (
// topCommand shows a group's leaderboard across its /noitu room games.
topCommand = "noitutop"
topKeyPrefix = "top:"
// topShown is how many players the leaderboard lists; the caller's own
// line is added below when they rank lower.
topShown = 10
// topWriteRetries bounds the versioned read-modify-write of one entry.
// Two cards of one chat can end games with the same player at once.
topWriteRetries = 5
msgTopNeedsGroup = "Bảng xếp hạng nối từ chỉ có trong nhóm. Gửi /noitutop trong nhóm nhé."
msgTopEmpty = "Nhóm này chưa có ván nối từ nào. Gửi /noitu để chơi cùng nhau."
msgTopFail = "Không đọc được bảng xếp hạng nối từ. Thử lại sau nhé."
)
// topEntry is one player's record across every room game of one chat.
type topEntry struct {
ChatID int64 `bson:"chatId"`
UserID int64 `bson:"userId"`
Name string `bson:"name"` // the player's latest first name
Games int64 `bson:"games"` // games played, as a seated player
Wins int64 `bson:"wins"`
Best int64 `bson:"best"` // best single-game score, winner bonus included
Total int64 `bson:"total"` // sum of every game's score
}
// topChatPrefix scopes a scan to one chat. The trailing colon keeps chat -1
// from matching chat -100.
func topChatPrefix(chatID int64) string {
return topKeyPrefix + strconv.FormatInt(chatID, 10) + ":"
}
func topKey(chatID, userID int64) string {
return topChatPrefix(chatID) + strconv.FormatInt(userID, 10)
}
// recordTop adds a finished room game to the chat's leaderboard. It runs in
// the background publisher, never under a room's lock; a failure is logged
// and skips only that player.
func (s *service) recordTop(chatID int64, standings []roomStanding) {
if s.cfg.top == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
defer cancel()
for _, st := range standings {
if err := s.addTopGame(ctx, chatID, st); err != nil {
log.Warn("noitu leaderboard update failed", "err", err)
}
}
}
// addTopGame folds one player's game into their entry with a versioned write,
// retrying when another game updated the entry in between.
func (s *service) addTopGame(ctx context.Context, chatID int64, st roomStanding) error {
key := topKey(chatID, st.userID)
for range topWriteRetries {
entry, version, err := s.cfg.top.Get(ctx, key)
switch {
case errors.Is(err, storage.ErrNotFound):
entry, version = topEntry{ChatID: chatID, UserID: st.userID}, 0
case err != nil:
return err
}
entry.Name = st.Name
entry.Games++
if st.won {
entry.Wins++
}
score := int64(st.Score)
entry.Best = max(entry.Best, score)
entry.Total += score
err = s.cfg.top.PutVersioned(ctx, key, version, entry)
if !errors.Is(err, storage.ErrConflict) {
return err
}
}
return storage.ErrConflict
}
// sortTop orders the leaderboard: more wins first, then the higher best
// game, then fewer games (the same record in fewer games ranks higher), then
// more total points, then the user ID so the order is stable.
func sortTop(entries []topEntry) {
slices.SortFunc(entries, func(a, b topEntry) int {
return cmp.Or(
cmp.Compare(b.Wins, a.Wins),
cmp.Compare(b.Best, a.Best),
cmp.Compare(a.Games, b.Games),
cmp.Compare(b.Total, a.Total),
cmp.Compare(a.UserID, b.UserID),
)
})
}
// handleTopCommand shows the chat's leaderboard. Rooms exist only in groups,
// so other chats are told where it works.
func (s *service) handleTopCommand(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg.Chat.Type != models.ChatTypeGroup && msg.Chat.Type != models.ChatTypeSupergroup {
return chathelper.Reply(ctx, b, msg, msgTopNeedsGroup)
}
if s.cfg.top == nil {
_ = chathelper.Reply(ctx, b, msg, msgTopFail)
return errors.New("noitu: no storage for the leaderboard")
}
docs, err := s.cfg.top.Scan(ctx, topChatPrefix(msg.Chat.ID))
if err != nil {
_ = chathelper.Reply(ctx, b, msg, msgTopFail)
return err
}
if len(docs) == 0 {
return chathelper.Reply(ctx, b, msg, msgTopEmpty)
}
entries := make([]topEntry, len(docs))
for i, d := range docs {
entries[i] = d.Val
}
var caller int64
if msg.From != nil {
caller = msg.From.ID
}
return chathelper.ReplyHTML(ctx, b, msg, renderTop(entries, caller))
}
// renderTop renders the top entries as an HTML table, plus the caller's own
// line when they rank below it. MonospaceTable escapes every cell.
func renderTop(entries []topEntry, caller int64) string {
sortTop(entries)
row := func(rank int, e topEntry) []string {
return []string{
strconv.Itoa(rank), e.Name,
strconv.FormatInt(e.Wins, 10), strconv.FormatInt(e.Games, 10),
strconv.FormatInt(e.Best, 10), strconv.FormatInt(e.Total, 10),
}
}
var rows [][]string
for i, e := range entries {
switch {
case i < topShown:
rows = append(rows, row(i+1, e))
case caller != 0 && e.UserID == caller:
rows = append(rows, []string{"…"}, row(i+1, e))
}
}
table := chathelper.MonospaceTable([]string{"#", "Tên", "Thắng", "Ván", "Cao nhất", "Tổng"}, rows)
return fmt.Sprintf("<b>Bảng xếp hạng nối từ</b> (%d người chơi)\n%s", len(entries), table)
}
+210
View File
@@ -0,0 +1,210 @@
package noitu
import (
"context"
"fmt"
"net/http"
"strings"
"sync"
"testing"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/tiennm99bot/internal/storage"
"github.com/tiennm99/tiennm99bot/internal/testutil"
)
// topOf reads one player's leaderboard entry on the test card's chat.
func (h *harness) topOf(user int64) topEntry {
h.t.Helper()
e, _, err := h.svc.cfg.top.Get(context.Background(), topKey(cardChat, user))
if err != nil {
h.t.Fatalf("leaderboard entry of %d: %v", user, err)
}
return e
}
func TestLeaderboard_RecordsEveryRoomGameThatHadAWord(t *testing.T) {
h := newHarness(t, testCorpus)
a := h.join(1, "An")
b := h.join(2, "Bình")
// Game 1: An leaves Bình a dead end and wins.
h.roomStart(a.Member, http.StatusOK)
h.roomMove(a.Member, "hồng tâm")
h.roomMove(b.Member, "tâm sự")
h.roomMove(a.Member, "sự cố")
h.call("room/give-up", map[string]string{"member": b.Member}, http.StatusOK, nil)
first := h.waitRoomReport(a.Member).Result.Standings
aFirst, bFirst := int64(first[0].Score), int64(first[1].Score)
if got, want := h.topOf(1), (topEntry{ChatID: cardChat, UserID: 1, Name: "An", Games: 1, Wins: 1, Best: aFirst, Total: aFirst}); got != want {
t.Fatalf("An after game 1 = %+v, want %+v", got, want)
}
if got, want := h.topOf(2), (topEntry{ChatID: cardChat, UserID: 2, Name: "Bình", Games: 1, Best: bFirst, Total: bFirst}); got != want {
t.Fatalf("Bình after game 1 = %+v, want %+v", got, want)
}
// Game 2: nobody plays a word, so it does not count.
h.clock.advance(roomStartInterval)
h.roomStart(a.Member, http.StatusOK)
h.call("room/give-up", map[string]string{"member": a.Member}, http.StatusOK, nil)
if res := h.roomState(a.Member).Result; res == nil || res.Words != 0 {
t.Fatalf("game 2 result = %+v", res)
}
// Game 3: An plays a word then leaves; Bình wins, and takes a new name.
b = h.join(2, "Bé Bình")
h.clock.advance(roomStartInterval)
h.roomStart(a.Member, http.StatusOK)
h.roomMove(a.Member, "hồng tâm")
h.call("room/give-up", map[string]string{"member": a.Member}, http.StatusOK, nil)
third := h.waitRoomReport(b.Member).Result.Standings
bThird, aThird := int64(third[0].Score), int64(third[1].Score)
if got, want := h.topOf(1), (topEntry{ChatID: cardChat, UserID: 1, Name: "An", Games: 2, Wins: 1, Best: max(aFirst, aThird), Total: aFirst + aThird}); got != want {
t.Fatalf("An after game 3 = %+v, want %+v", got, want)
}
if got, want := h.topOf(2), (topEntry{ChatID: cardChat, UserID: 2, Name: "Bé Bình", Games: 2, Wins: 1, Best: max(bFirst, bThird), Total: bFirst + bThird}); got != want {
t.Fatalf("Bình after game 3 = %+v, want %+v", got, want)
}
}
func TestLeaderboard_ZeroWordGameRecordsNothing(t *testing.T) {
h := newHarness(t, testCorpus)
a := h.join(1, "An")
h.join(2, "Bình")
h.roomStart(a.Member, http.StatusOK)
h.call("room/give-up", map[string]string{"member": a.Member}, http.StatusOK, nil)
// No publisher runs for such a game, so there is nothing to wait for.
docs, err := h.svc.cfg.top.Scan(context.Background(), topKeyPrefix)
if err != nil || len(docs) != 0 {
t.Fatalf("leaderboard after a zero-word game = %+v, %v", docs, err)
}
}
// Two cards of one chat can finish games with the same player at once; the
// versioned write keeps both.
func TestLeaderboard_ConcurrentGamesKeepEveryUpdate(t *testing.T) {
h := newHarness(t, testCorpus)
var wg sync.WaitGroup
for i := range 2 {
wg.Add(1)
go func() {
defer wg.Done()
h.svc.recordTop(cardChat, []roomStanding{{userID: 1, Name: "An", Score: 10 * (i + 1), won: i == 0}})
}()
}
wg.Wait()
if got := h.topOf(1); got.Games != 2 || got.Wins != 1 || got.Best != 20 || got.Total != 30 {
t.Fatalf("entry = %+v", got)
}
}
func TestLeaderboard_StoreFailureIsNotFatal(t *testing.T) {
h := newHarness(t, testCorpus)
h.svc.cfg.top = storage.Typed[topEntry](storage.NewMemoryProvider().Collection("Not A Valid Name"))
h.svc.recordTop(cardChat, []roomStanding{{userID: 1, Name: "An", Score: 10, won: true}})
rb := installModule(t, h.svc.module())
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGroupMessage(cardChat, 1, "/noitutop"))
if last := rb.LastSent(); last.Method != "sendMessage" || last.Text() != msgTopFail {
t.Fatalf("sent %+v", rb.Sent())
}
}
func TestSortTop_WinsThenBestThenFewerGames(t *testing.T) {
entries := []topEntry{
{UserID: 1, Wins: 1, Best: 90, Games: 3},
{UserID: 2, Wins: 2, Best: 10, Games: 9},
{UserID: 3, Wins: 1, Best: 90, Games: 2},
{UserID: 4, Wins: 1, Best: 95, Games: 9},
{UserID: 5, Wins: 1, Best: 90, Games: 2, Total: 300},
{UserID: 6, Wins: 1, Best: 90, Games: 2},
}
sortTop(entries)
var order []int64
for _, e := range entries {
order = append(order, e.UserID)
}
if fmt.Sprint(order) != "[2 4 5 3 6 1]" {
t.Fatalf("order = %v", order)
}
}
func TestTopCommand_RefusesOutsideGroups(t *testing.T) {
h := newHarness(t, testCorpus)
for name, update := range map[string]*models.Update{
"private": testutil.NewPrivateMessage(42, "/noitutop"),
"channel": testutil.NewChannelMessage(-100888, "/noitutop"),
} {
rb := installModule(t, h.mod)
rb.Bot.ProcessUpdate(context.Background(), update)
if last := rb.LastSent(); last.Method != "sendMessage" || last.Text() != msgTopNeedsGroup || len(rb.Sent()) != 1 {
t.Errorf("%s: sent %+v", name, rb.Sent())
}
}
}
func TestTopCommand_EmptyGroup(t *testing.T) {
h := newHarness(t, testCorpus)
// Another chat's records never show, even one whose ID shares the prefix.
h.svc.recordTop(-1005, []roomStanding{{userID: 1, Name: "An", Score: 10, won: true}})
rb := installModule(t, h.mod)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGroupMessage(-100, 42, "/noitutop"))
if last := rb.LastSent(); last.Method != "sendMessage" || last.Text() != msgTopEmpty {
t.Fatalf("sent %+v", rb.Sent())
}
}
func TestTopCommand_ShowsTopTenPlusTheCallerEscaped(t *testing.T) {
h := newHarness(t, testCorpus)
ctx := context.Background()
// Player i has i wins, so player 12 ranks first and player 1 last.
for i := int64(1); i <= 12; i++ {
name := fmt.Sprintf("P%02d", i)
if i == 12 {
name = "<b>Tí & Tèo</b>"
}
e := topEntry{ChatID: cardChat, UserID: i, Name: name, Games: 20, Wins: i, Best: 100 + i, Total: 1000 + i}
if err := h.svc.cfg.top.Put(ctx, topKey(cardChat, i), e); err != nil {
t.Fatal(err)
}
}
send := func(caller int64) testutil.SentCall {
t.Helper()
rb := installModule(t, h.mod)
rb.Bot.ProcessUpdate(ctx, testutil.NewSupergroupMessage(cardChat, caller, "/noitutop"))
last := rb.LastSent()
if last.Method != "sendMessage" || last.Form["parse_mode"] != "HTML" {
t.Fatalf("sent %+v", rb.Sent())
}
return last
}
text := send(1).Text()
if !strings.HasPrefix(text, "<b>Bảng xếp hạng nối từ</b> (12 người chơi)\n<pre>") {
t.Fatalf("header: %q", text)
}
if strings.Contains(text, "<b>Tí") || !strings.Contains(text, "&lt;b&gt;Tí &amp; Tèo&lt;/b&gt;") {
t.Fatalf("name not escaped: %q", text)
}
lines := strings.Split(strings.TrimSuffix(text, "</pre>"), "\n")
// Header line, table header, separator, 10 rows, the gap, the caller.
if len(lines) != 15 {
t.Fatalf("%d lines: %q", len(lines), text)
}
if !strings.HasPrefix(lines[3], "1 ") || !strings.Contains(lines[3], "Tí") || !strings.HasPrefix(lines[12], "10") ||
!strings.Contains(lines[12], "P03") || strings.TrimSpace(lines[13]) != "…" ||
!strings.HasPrefix(lines[14], "12") || !strings.Contains(lines[14], "P01") {
t.Fatalf("rows: %q", lines)
}
// A caller inside the top ten, or without a record, gets no extra line.
for _, caller := range []int64{5, 99} {
if text := send(caller).Text(); strings.Count(text, "\n") != 12 || strings.Contains(text, "…") {
t.Fatalf("caller %d: %q", caller, text)
}
}
}
+177
View File
@@ -0,0 +1,177 @@
// Package noitu is the "nối từ" Telegram HTML5 game: the /noitubot command
// sends the noitubot BotFather game, the Play button opens a page this bot
// serves, and the player chains Vietnamese words against the bot opponent
// there. /noitu sends the noitu game to a group as a card whose Play button
// opens a room where the chat's members chain words against each other, and
// /noitutop shows the group's leaderboard across those room games.
//
// The server owns the game. The page only sends words; validation, the turn
// timer, the bot's replies and the score all happen here, and the score is
// reported with setGameScore so Telegram's in-chat high-score table works.
// See docs/noitu-game.md.
package noitu
import (
"crypto/rand"
"encoding/binary"
mrand "math/rand/v2"
"os"
"time"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/modules"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
const (
// ShortName is the BotFather short name of the group game /noitu sends.
ShortName = "noitu"
// SoloShortName is the BotFather short name of the game /noitubot sends.
// It always plays against the bot.
SoloShortName = "noitubot"
baseURLEnv = "GAME_BASE_URL"
secretEnv = "NOITU_GAME_SECRET" //nolint:gosec // G101: an env var name, not a credential
// minSecretBytes is the shortest NOITU_GAME_SECRET accepted.
minSecretBytes = 32
// tokenKeyLabel derives the token key from the bot token when no
// dedicated secret is set.
tokenKeyLabel = "tiennm99bot/noitu/token/v1" //nolint:gosec // G101: a public derivation label; the key is the bot token
routePrefix = "/games/" + ShortName + "/"
)
// config is everything New reads from the environment, injectable in tests.
type config struct {
baseURL string // public https base without trailing slash; "" disables
key []byte // token HMAC key; empty disables
now func() time.Time
newRNG func() *mrand.Rand
reporter scoreReporter // nil disables
announcer announcer // nil skips room result messages
cards storage.DocStore[pvpCard]
top storage.DocStore[topEntry] // nil disables the group leaderboard
loadDict func() (*dict.Store, error)
}
// New builds the module from the environment. The game is enabled only when
// GAME_BASE_URL is a valid https URL and a token key is available; otherwise
// /noitu and /noitubot still send the game and Play explains it is not
// configured.
func New(deps modules.Deps) modules.Module {
cfg := config{
baseURL: parseBaseURL(os.Getenv(baseURLEnv)),
key: tokenKey(os.Getenv(secretEnv), deps),
now: time.Now,
newRNG: newSessionRNG,
loadDict: dict.Default,
}
if deps.Bot != nil {
cfg.reporter = botReporter{b: deps.Bot}
cfg.announcer = botReporter{b: deps.Bot}
}
if deps.Store != nil {
cfg.cards = storage.Typed[pvpCard](deps.Store)
cfg.top = storage.Typed[topEntry](deps.Store)
}
return newWithConfig(cfg)
}
// newWithConfig assembles the module from an explicit config.
func newWithConfig(cfg config) modules.Module { return newService(cfg).module() }
// newService loads the dictionary when the game is configured. A load failure
// leaves the game disabled rather than failing startup for every module.
func newService(cfg config) *service {
svc := &service{cfg: cfg, sessions: newSessionStore(), rooms: newRoomStore()}
if cfg.baseURL != "" && len(cfg.key) > 0 && cfg.reporter != nil {
words, err := cfg.loadDict()
if err != nil {
log.Error("noitu dictionary load failed; game disabled", "err", err)
} else {
svc.words = words
log.Info("noitu game enabled", "words", words.Len(), "dictionary_sha256", words.SourceSHA256)
}
}
return svc
}
// module describes the service to the registry. Routes and the sweep cron
// exist only when the game is enabled, so a disabled game exposes nothing
// over HTTP. The card cleanup runs whenever cards can be stored, because
// /noitu registers cards even while the game is disabled.
func (svc *service) module() modules.Module {
mod := modules.Module{
Commands: []modules.Command{{
Name: pvpCommand,
Visibility: modules.VisibilityPublic,
Description: "Chơi nối từ với các thành viên trong nhóm (chỉ trong nhóm)",
Handler: svc.handlePvPCommand,
}, {
Name: soloCommand,
Visibility: modules.VisibilityPublic,
Description: "Chơi nối từ với bot",
Handler: svc.handleCommand,
}, {
Name: topCommand,
Visibility: modules.VisibilityPublic,
Description: "Bảng xếp hạng nối từ của nhóm",
Handler: svc.handleTopCommand,
}},
Games: []modules.Game{{
ShortName: ShortName,
Visibility: modules.VisibilityPublic,
Handler: svc.handlePlay,
}, {
ShortName: SoloShortName,
Visibility: modules.VisibilityPublic,
Handler: svc.handlePlay,
}},
}
if svc.enabled() {
mod.HTTP = []modules.Route{{Pattern: routePrefix, Handler: svc.handler()}}
mod.Crons = []modules.Cron{{Schedule: "* * * * *", Name: "noitu_sweep", Handler: svc.sweepCron}}
}
if svc.cfg.cards != nil {
mod.Crons = append(mod.Crons, modules.Cron{Schedule: cardCleanupSchedule, Name: "noitu_pvp_cards", Handler: svc.cleanupCards})
}
return mod
}
// parseBaseURL accepts https://host[/path] and returns it without a trailing
// slash. Anything else is logged and disables the game.
func parseBaseURL(raw string) string { return htmlgame.ParseBaseURL(raw, ShortName) }
// tokenKey returns NOITU_GAME_SECRET when set, otherwise a key derived from
// the bot token. A secret that is set but too short disables the game rather
// than silently falling back.
func tokenKey(secret string, deps modules.Deps) []byte {
if secret != "" {
if len(secret) < minSecretBytes {
log.Warn("NOITU_GAME_SECRET is shorter than 32 bytes; noitu game disabled")
return nil
}
return []byte(secret)
}
if deps.Bot == nil {
return nil
}
return deriveKey(deps.Bot.Token())
}
// deriveKey is HMAC-SHA256(key=bot token, tokenKeyLabel). Rotating the bot
// token therefore invalidates every open game link.
func deriveKey(botToken string) []byte {
return htmlgame.DeriveKey([]byte(botToken), tokenKeyLabel)
}
// newSessionRNG seeds a per-game generator for the opening word and the bot's
// choices. Gameplay randomness, not security; crypto/rand only seeds it.
func newSessionRNG() *mrand.Rand {
var seed [16]byte
_, _ = rand.Read(seed[:])
return mrand.New(mrand.NewPCG(binary.LittleEndian.Uint64(seed[:8]), binary.LittleEndian.Uint64(seed[8:])))
}
+303
View File
@@ -0,0 +1,303 @@
package noitu
import (
"context"
"errors"
"net/url"
"regexp"
"strings"
"testing"
"time"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/tiennm99bot/internal/modules"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/web"
"github.com/tiennm99/tiennm99bot/internal/storage"
"github.com/tiennm99/tiennm99bot/internal/testutil"
)
func TestToken_RoundTripTamperExpiryAndKey(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
c := claims{UserID: 7, Name: "An", ChatID: -100123, MessageID: 9, Expiry: now.Add(tokenTTL).Unix()}
tok, err := signToken(testKey, c)
if err != nil {
t.Fatal(err)
}
got, err := verifyToken(testKey, tok, now)
if err != nil || got != c {
t.Fatalf("round trip = %+v, %v", got, err)
}
body, sig, _ := strings.Cut(tok, ".")
forged, _ := signToken([]byte("another-key-another-key-another!!"), claims{UserID: 8, ChatID: 1, MessageID: 1, Expiry: c.Expiry})
_, forgedSig, _ := strings.Cut(forged, ".")
for name, bad := range map[string]string{
"tampered body": strings.ToUpper(body[:4]) + body[4:] + "." + sig,
"swapped sig": body + "." + forgedSig,
"no dot": body,
"empty": "",
} {
if _, err := verifyToken(testKey, bad, now); !errors.Is(err, errBadToken) {
t.Errorf("%s: err = %v, want errBadToken", name, err)
}
}
if _, err := verifyToken([]byte("wrong-key-wrong-key-wrong-key-xx"), tok, now); !errors.Is(err, errBadToken) {
t.Errorf("wrong key: err = %v", err)
}
if _, err := verifyToken(testKey, tok, now.Add(tokenTTL)); !errors.Is(err, errTokenExpired) {
t.Errorf("expired: err = %v", err)
}
if _, err := verifyToken(nil, tok, now); !errors.Is(err, errBadToken) {
t.Errorf("no key: err = %v", err)
}
// A signed token must still name exactly one game message.
both, _ := signToken(testKey, claims{UserID: 7, ChatID: 1, MessageID: 1, InlineID: "x", Expiry: c.Expiry})
neither, _ := signToken(testKey, claims{UserID: 7, Expiry: c.Expiry})
for _, bad := range []string{both, neither} {
if _, err := verifyToken(testKey, bad, now); !errors.Is(err, errBadToken) {
t.Errorf("bad address accepted: %v", err)
}
}
}
func TestTokenKey(t *testing.T) {
rb := testutil.NewRecordingBot(t)
derived := tokenKey("", modules.Deps{Bot: rb.Bot})
if len(derived) != 32 || string(derived) == "test-token" {
t.Fatalf("derived key = %x", derived)
}
if string(derived) != string(deriveKey("test-token")) {
t.Fatal("derivation is not deterministic")
}
if tokenKey("", modules.Deps{}) != nil {
t.Fatal("key without a bot or secret")
}
secret := strings.Repeat("s", 32)
if string(tokenKey(secret, modules.Deps{Bot: rb.Bot})) != secret {
t.Fatal("NOITU_GAME_SECRET not preferred")
}
if tokenKey("short", modules.Deps{Bot: rb.Bot}) != nil {
t.Fatal("short secret accepted")
}
}
func TestParseBaseURL(t *testing.T) {
cases := map[string]string{
"": "",
"https://game.example": "https://game.example",
" https://game.example/ ": "https://game.example",
"https://game.example/bot//": "https://game.example/bot",
"http://game.example": "",
"game.example": "",
"https://u:p@game.example": "",
"https://game.example/?x=1": "",
"https://game.example/#frag": "",
"https:///no-host": "",
}
for in, want := range cases {
if got := parseBaseURL(in); got != want {
t.Errorf("parseBaseURL(%q) = %q, want %q", in, got, want)
}
}
}
func TestNew_DisabledWithoutBaseURL(t *testing.T) {
t.Setenv(baseURLEnv, "")
t.Setenv(secretEnv, "")
rb := testutil.NewRecordingBot(t)
mod := New(modules.Deps{Bot: rb.Bot})
if len(mod.HTTP) != 0 || len(mod.Crons) != 0 {
t.Fatalf("disabled game exposes routes %v / crons %v", mod.HTTP, mod.Crons)
}
var names []string
for _, c := range mod.Commands {
names = append(names, c.Name)
}
if strings.Join(names, " ") != "noitu noitubot noitutop" {
t.Fatalf("commands = %v", names)
}
for _, c := range mod.Commands {
if c.Visibility != modules.VisibilityPublic || c.Description == "" || c.Parameters != "" {
t.Fatalf("command %+v is not a described public command", c)
}
}
if len(mod.Games) != 2 || mod.Games[0].ShortName != "noitu" || mod.Games[1].ShortName != "noitubot" {
t.Fatalf("games = %+v", mod.Games)
}
// /noitu registers cards even while the game is disabled, so their
// cleanup still runs; nothing else does.
mod = New(modules.Deps{Bot: rb.Bot, Store: storage.NewMemoryProvider().Collection("noitu")})
if len(mod.HTTP) != 0 || len(mod.Crons) != 1 || mod.Crons[0].Name != "noitu_pvp_cards" {
t.Fatalf("disabled game with a store: routes %v / crons %+v", mod.HTTP, mod.Crons)
}
}
func TestNew_EnabledFromEnv(t *testing.T) {
t.Setenv(baseURLEnv, "https://game.example/")
t.Setenv(secretEnv, "")
rb := testutil.NewRecordingBot(t)
mod := New(modules.Deps{Bot: rb.Bot})
if len(mod.HTTP) != 1 || mod.HTTP[0].Pattern != "/games/noitu/" {
t.Fatalf("routes = %+v", mod.HTTP)
}
// Without a bot there is neither a derived key nor a reporter.
if mod := New(modules.Deps{}); len(mod.HTTP) != 0 {
t.Fatal("enabled without a bot or secret")
}
}
// installModule registers mod through the real registry and dispatcher.
func installModule(t *testing.T, mod modules.Module) *testutil.RecordingBot {
t.Helper()
rb := testutil.NewRecordingBot(t)
reg, err := modules.Build([]string{"noitu"}, map[string]modules.Factory{
"noitu": 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{})
return rb
}
func lastAnswer(t *testing.T, rb *testutil.RecordingBot) map[string]string {
t.Helper()
for _, c := range rb.Sent() {
if c.Method == "answerCallbackQuery" {
return c.Form
}
}
t.Fatalf("no answerCallbackQuery; sent %+v", rb.Sent())
return nil
}
func TestPlay_DisabledAnswersAlert(t *testing.T) {
rb := installModule(t, newWithConfig(config{now: time.Now}))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGameCallback(42, 42, 7, ShortName))
a := lastAnswer(t, rb)
if a["text"] != msgDisabled || a["show_alert"] != "true" || a["url"] != "" {
t.Fatalf("answer = %v", a)
}
}
func TestPlay_EnabledAnswersSignedURL(t *testing.T) {
h := newHarness(t, testCorpus)
for name, update := range map[string]*models.Update{
"chat": testutil.NewGameCallback(42, -100500, 7, ShortName),
"inline": testutil.NewInlineGameCallback(42, "AgAAAInline", ShortName),
} {
t.Run(name, func(t *testing.T) {
rb := installModule(t, h.mod)
rb.Bot.ProcessUpdate(context.Background(), update)
a := lastAnswer(t, rb)
prefix := testBase + "/games/noitu/?t="
if !strings.HasPrefix(a["url"], prefix) {
t.Fatalf("url = %q", a["url"])
}
u, _ := url.Parse(a["url"])
c, err := verifyToken(testKey, u.Query().Get("t"), h.clock.now())
if err != nil {
t.Fatalf("token: %v", err)
}
if c.UserID != 42 || c.Name != "Test" || c.Expiry != h.clock.now().Add(tokenTTL).Unix() {
t.Fatalf("claims = %+v", c)
}
if name == "chat" && (c.ChatID != -100500 || c.MessageID != 7 || c.InlineID != "") {
t.Fatalf("chat claims = %+v", c)
}
if name == "inline" && (c.InlineID != "AgAAAInline" || c.ChatID != 0) {
t.Fatalf("inline claims = %+v", c)
}
})
}
}
func TestPlay_InaccessibleMessageAndNoTarget(t *testing.T) {
h := newHarness(t, testCorpus)
update := testutil.NewGameCallback(42, 0, 0, ShortName)
update.CallbackQuery.Message = models.MaybeInaccessibleMessage{
Type: models.MaybeInaccessibleMessageTypeInaccessibleMessage,
InaccessibleMessage: &models.InaccessibleMessage{Chat: models.Chat{ID: -5}, MessageID: 3},
}
rb := installModule(t, h.mod)
rb.Bot.ProcessUpdate(context.Background(), update)
u, _ := url.Parse(lastAnswer(t, rb)["url"])
if c, err := verifyToken(testKey, u.Query().Get("t"), h.clock.now()); err != nil || c.ChatID != -5 || c.MessageID != 3 {
t.Fatalf("inaccessible claims = %+v, %v", c, err)
}
update.CallbackQuery.Message = models.MaybeInaccessibleMessage{}
rb = installModule(t, h.mod)
rb.Bot.ProcessUpdate(context.Background(), update)
if a := lastAnswer(t, rb); a["text"] != msgNoGameTarget || a["show_alert"] != "true" {
t.Fatalf("no target answer = %v", a)
}
}
func TestCommand_SendsGameKeepingTopic(t *testing.T) {
rb := installModule(t, newWithConfig(config{now: time.Now}))
update := testutil.NewSupergroupMessage(-100777, 42, "/noitubot")
update.Message.MessageThreadID = 12
rb.Bot.ProcessUpdate(context.Background(), update)
last := rb.LastSent()
if last.Method != "sendGame" || last.Form["game_short_name"] != "noitubot" || last.ChatID() != "-100777" || last.Form["message_thread_id"] != "12" {
t.Fatalf("sent %+v", rb.Sent())
}
if last.Form["reply_markup"] != "" {
t.Fatalf("sendGame carries a keyboard: %q", last.Form["reply_markup"])
}
}
func TestCommand_ChannelAndFailure(t *testing.T) {
rb := installModule(t, newWithConfig(config{now: time.Now}))
rb.Bot.ProcessUpdate(context.Background(), testutil.NewChannelMessage(-100888, "/noitubot"))
if last := rb.LastSent(); last.Method != "sendMessage" || last.Text() != msgChannel {
t.Fatalf("channel: sent %+v", rb.Sent())
}
rb = installModule(t, newWithConfig(config{now: time.Now}))
rb.FailMethodCode("sendGame", 400, "Bad Request: GAME_SHORTNAME_INVALID")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/noitubot"))
if last := rb.LastSent(); last.Method != "sendMessage" || last.Text() != msgSendGameFail {
t.Fatalf("failure: sent %+v", rb.Sent())
}
}
func TestBotReporter_ChatMessageAndNotModified(t *testing.T) {
rb := testutil.NewRecordingBot(t)
r := botReporter{b: rb.Bot}
if err := r.Report(context.Background(), claims{UserID: 42, ChatID: -1005, MessageID: 8}, 77); err != nil {
t.Fatalf("Report: %v", err)
}
f := rb.LastSent().Form
if rb.LastSent().Method != "setGameScore" || f["user_id"] != "42" || f["score"] != "77" || f["chat_id"] != "-1005" || f["message_id"] != "8" || f["force"] != "" {
t.Fatalf("setGameScore form = %v", f)
}
rb.FailMethodCode("setGameScore", 400, "Bad Request: BOT_SCORE_NOT_MODIFIED")
err := r.Report(context.Background(), claims{UserID: 42, ChatID: -1005, MessageID: 8}, 1)
if err == nil || !scoreNotModified(err) {
t.Fatalf("not-modified err = %v", err)
}
}
var inlineScriptRe = regexp.MustCompile(`(?is)<script\b([^>]*)>(.*?)</script>`)
func TestWebAssets(t *testing.T) {
for _, name := range []string{"index.html", "app.css", "app.js"} {
if b, err := web.FS.ReadFile(name); err != nil || len(b) == 0 {
t.Fatalf("asset %s: %v", name, err)
}
}
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") {
t.Error("inline style breaks the CSP")
}
if !strings.Contains(string(index), "CC BY-SA 4.0") || !strings.Contains(string(index), "Wiktionary") {
t.Error("dictionary attribution missing from the page footer")
}
}
+17
View File
@@ -0,0 +1,17 @@
package opponent
import "math/rand/v2"
// easy plays a uniformly random legal move, without looking at what it hands
// the opponent.
type easy struct {
rng *rand.Rand
}
func (s *easy) Choose(b Board) (string, error) {
moves := b.LegalMoves()
if len(moves) == 0 {
return "", ErrNoMove
}
return moves[s.rng.IntN(len(moves))], nil
}
+218
View File
@@ -0,0 +1,218 @@
package opponent
import (
"math"
"math/rand/v2"
"slices"
)
// Tuning. These are constants rather than structure precisely so the bot can be
// made easier or harder without touching the search.
const (
// hardKillRate is how often Hard takes an available instant win. A bot that
// always wins is not a game, so it sometimes plays on instead.
hardKillRate = 0.85
// searchDepth is plies of lookahead. Directed Edge Geography is
// PSPACE-complete, so this is a heuristic cutoff, not a solver.
searchDepth = 4
// nodeCap bounds the search on hub syllables with a large branching
// factor, keeping Choose inside its latency budget.
nodeCap = 20000
// branchCap limits how many candidates are explored per node. Moves are
// ordered tightest-first, so the pruned tail is the least interesting.
branchCap = 12
// A win is the negation of a child's loss, so only the losing terminal
// needs a constant. A loss found with more depth still to search is
// scored lower than one found later, so a lost position prefers the
// slower loss and a won one the faster win.
loseScore = -1000.0
)
// hard looks ahead.
//
// First it checks for an immediate kill: a move that leaves the opponent with
// nothing. Failing that it runs a depth-limited negamax over the remaining
// edges, valuing a position by how few replies the opponent has. If the search
// finds nothing useful it falls back to Medium's heuristic.
type hard struct {
rng *rand.Rand
// lastNodes is how many search nodes the latest Choose visited, so a test
// can pin the node cap that bounds a move request's CPU time.
lastNodes int
}
func (s *hard) Choose(b Board) (string, error) {
s.lastNodes = 0
moves := b.LegalMoves()
if len(moves) == 0 {
return "", ErrNoMove
}
scored := scoreMoves(b, moves)
if len(scored) == 0 {
return moves[s.rng.IntN(len(moves))], nil
}
// An instant win: the opponent has no reply at all.
var kills []string
for _, m := range scored {
if m.handsOver == 0 {
kills = append(kills, m.word)
}
}
if len(kills) > 0 {
if s.rng.Float64() < hardKillRate {
return kills[s.rng.IntN(len(kills))], nil
}
// Declined. The killing moves must be taken off the table, not just
// skipped here: the search below would otherwise rediscover the same
// win and play it anyway, making hardKillRate do nothing at all.
var spared []scoredMove
for _, m := range scored {
if m.handsOver > 0 {
spared = append(spared, m)
}
}
if len(spared) > 0 {
scored = spared
}
}
// Order tightest-first so alpha-beta prunes early.
slices.SortStableFunc(scored, func(a, b scoredMove) int { return a.handsOver - b.handsOver })
st := &search{board: b, used: map[string]bool{}, budget: nodeCap}
best := ""
alpha := math.Inf(-1)
for i, m := range scored {
if i >= branchCap || st.budget <= 0 {
// Out of budget: keep the best move found so far rather than
// scoring the rest against an exhausted search.
break
}
last, ok := b.LastSyllable(m.word)
if !ok {
continue
}
st.used[m.word] = true
// Negamax: the opponent's best outcome, negated, is ours. Alpha carries
// across children so later subtrees can be pruned.
score := -st.negamax(last, searchDepth-1, math.Inf(-1), -alpha)
delete(st.used, m.word)
if score > alpha {
alpha, best = score, m.word
}
}
s.lastNodes = nodeCap - st.budget
if best == "" {
// Nothing scored; fall back to the tightest-first heuristic, which is
// Medium's judgement.
return scored[0].word, nil
}
return best, nil
}
// search carries the mutable state of one lookahead. Moves are made and unmade
// on `used` rather than copying the position at every node.
type search struct {
board Board
used map[string]bool
budget int
}
// spent reports whether a word is unavailable: already played in the real game,
// or played earlier in this search line.
func (s *search) spent(word string) bool {
return s.used[word] || s.board.Used(word)
}
// negamax scores the position for the player to move at `current`.
//
// Returns a value from that player's point of view, so the caller negates it.
func (s *search) negamax(current string, depth int, alpha, beta float64) float64 {
if s.budget <= 0 {
// Fail soft: return the bound already established rather than a neutral
// 0, which reads as "this line is fine" and can push the root toward a
// losing move when the budget bites.
return alpha
}
s.budget--
var candidates []string
for word := range s.board.WordsStartingWith(current) {
if !s.spent(word) {
candidates = append(candidates, word)
}
}
// No reply: the player to move has lost. The remaining depth breaks the
// tie between losses, so when every line is lost the search still avoids
// the ones that end at once — a human may not find the slower refutation.
if len(candidates) == 0 {
return loseScore - float64(depth)
}
if depth <= 0 {
// Negamax evaluates from the perspective of the player to move, so
// mobility is a positive: more replies available is better for them.
// The caller negates this, which is what turns it into "leave the
// opponent with as little as possible". Getting this sign backwards
// makes the search prefer positions where it is about to be trapped —
// measured at a 29% win rate against the one-ply bot.
//
// Log rather than the raw count so one hub syllable cannot dominate.
return math.Log(1 + float64(len(candidates)))
}
// Order tightest-first: explore the moves most likely to be strong.
type cand struct {
word string
last string
handsOver int
}
ordered := make([]cand, 0, len(candidates))
for _, w := range candidates {
last, ok := s.board.LastSyllable(w)
if !ok {
continue
}
n := 0
for reply := range s.board.WordsStartingWith(last) {
if reply != w && !s.spent(reply) {
n++
}
}
ordered = append(ordered, cand{word: w, last: last, handsOver: n})
}
slices.SortStableFunc(ordered, func(a, b cand) int { return a.handsOver - b.handsOver })
// Starts at the losing score: if every candidate is pruned or unresolvable,
// this position is no better than lost, which is the honest floor.
best := loseScore
for i, c := range ordered {
if i >= branchCap {
break
}
s.used[c.word] = true
score := -s.negamax(c.last, depth-1, -beta, -alpha)
delete(s.used, c.word)
if score > best {
best = score
}
if best > alpha {
alpha = best
}
if alpha >= beta {
break // the opponent would avoid this line
}
}
return best
}
+71
View File
@@ -0,0 +1,71 @@
package opponent
import (
"math/rand/v2"
"slices"
)
// medium plays greedily one move deep.
//
// It scores each legal move by how many continuations remain from the syllable
// it hands over, takes an immediate win when it sees one, and otherwise picks
// at random from the tightest quartile. Random rather than strictly best, so it
// does not play the same game every time.
//
// What separates it from hard is lookahead, not ruthlessness: medium sees only
// the move in front of it, while hard searches several plies. An earlier
// version withheld the winning move to feel gentler, and simulation showed it
// losing to the random bot 97% of the time — a strategy that declines to win
// loses to a coin flip. Difficulty has to come from how well a bot plays, not
// from it refusing to.
type medium struct {
rng *rand.Rand
}
type scoredMove struct {
word string
handsOver int // continuations left for the opponent
}
func (s *medium) Choose(b Board) (string, error) {
moves := b.LegalMoves()
if len(moves) == 0 {
return "", ErrNoMove
}
scored := scoreMoves(b, moves)
if len(scored) == 0 {
return moves[s.rng.IntN(len(moves))], nil
}
slices.SortStableFunc(scored, func(a, b scoredMove) int {
return a.handsOver - b.handsOver
})
// A move that leaves the opponent nothing wins immediately. Sorting puts
// those first, so this is simply taking the best available move.
if scored[0].handsOver == 0 {
return scored[0].word, nil
}
// Otherwise pick at random from the tightest quartile.
cut := max(len(scored)/4, 1)
return scored[s.rng.IntN(cut)].word, nil
}
// scoreMoves annotates each candidate with how many replies it leaves. The
// move itself is counted as spent, since the opponent cannot replay it.
func scoreMoves(b Board, moves []string) []scoredMove {
scored := make([]scoredMove, 0, len(moves))
for _, word := range moves {
last, ok := b.LastSyllable(word)
if !ok {
continue
}
scored = append(scored, scoredMove{
word: word,
handsOver: remainingOutDegree(b, last, word),
})
}
return scored
}
@@ -0,0 +1,81 @@
// Package opponent chooses the bot's moves.
//
// Ported from tiennm99/noitu server/internal/bot. The strategies only read the
// position through Board; the chosen word still goes through the engine's
// validation like a human's. The source's "thinking delay" is dropped: the
// bot answers inside the move request and the page animates the pause.
package opponent
import (
"errors"
"iter"
"math/rand/v2"
)
// Difficulty selects a strategy.
type Difficulty string
// Difficulties, in the wire form the game page sends.
const (
Easy Difficulty = "easy"
Medium Difficulty = "medium"
Hard Difficulty = "hard"
)
// ParseDifficulty maps the wire value to a Difficulty. Empty means Medium.
func ParseDifficulty(s string) (Difficulty, bool) {
switch Difficulty(s) {
case "", Medium:
return Medium, true
case Easy:
return Easy, true
case Hard:
return Hard, true
}
return "", false
}
// ErrNoMove means the bot has nothing legal to play, so it has lost.
var ErrNoMove = errors.New("opponent: no legal move")
// Board is what a strategy may look at.
type Board interface {
LegalMoves() []string
Used(word string) bool
WordsStartingWith(syllable string) iter.Seq[string]
LastSyllable(word string) (string, bool)
}
// Strategy picks a move for the position.
type Strategy interface {
Choose(b Board) (string, error)
}
// New returns the strategy for a difficulty, drawing randomness from rng.
// The rng is per game, never shared, and not safe for concurrent use.
func New(d Difficulty, rng *rand.Rand) (Strategy, error) {
if rng == nil {
return nil, errors.New("opponent: nil rng")
}
switch d {
case Easy:
return &easy{rng: rng}, nil
case Medium:
return &medium{rng: rng}, nil
case Hard:
return &hard{rng: rng}, nil
}
return nil, errors.New("opponent: unknown difficulty")
}
// remainingOutDegree counts the unplayed continuations from syllable,
// ignoring excluding (the move being considered).
func remainingOutDegree(b Board, syllable, excluding string) int {
n := 0
for word := range b.WordsStartingWith(syllable) {
if word != excluding && !b.Used(word) {
n++
}
}
return n
}
@@ -0,0 +1,131 @@
package opponent
import (
"errors"
"iter"
"math/rand/v2"
"slices"
"testing"
"time"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/engine"
)
var t0 = time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
func realGame(t *testing.T, seed uint64) *engine.Engine {
t.Helper()
store, err := dict.Default()
if err != nil {
t.Fatalf("dict: %v", err)
}
opening := store.RandomOpening(rand.New(rand.NewPCG(seed, seed)), 20)
e, err := engine.New(store, opening, 30*time.Second, 2*time.Second, t0)
if err != nil {
t.Fatalf("engine: %v", err)
}
return e
}
func TestParseDifficulty(t *testing.T) {
for in, want := range map[string]Difficulty{"": Medium, "easy": Easy, "medium": Medium, "hard": Hard} {
if got, ok := ParseDifficulty(in); !ok || got != want {
t.Errorf("ParseDifficulty(%q) = %q %v", in, got, ok)
}
}
if _, ok := ParseDifficulty("insane"); ok {
t.Error("unknown difficulty accepted")
}
if _, err := New(Easy, nil); err == nil {
t.Error("nil rng accepted")
}
}
// Every strategy plays legal moves for both sides until the game ends, and
// each choice passes the engine's own validation.
func TestStrategiesPlayLegalMoves(t *testing.T) {
for _, d := range []Difficulty{Easy, Medium, Hard} {
t.Run(string(d), func(t *testing.T) {
e := realGame(t, 7)
rng := rand.New(rand.NewPCG(1, 2))
strategy, _ := New(d, rng)
side := engine.Human
for range 40 {
word, err := strategy.Choose(e)
if errors.Is(err, ErrNoMove) {
if e.HasLegalMove() {
t.Fatal("ErrNoMove while a legal move exists")
}
return
}
if !slices.Contains(e.LegalMoves(), word) {
t.Fatalf("%q is not a legal move", word)
}
if _, r := e.Submit(side, word, t0); r != engine.ReasonNone {
t.Fatalf("engine rejected %q: %q", word, r)
}
if side == engine.Human {
side = engine.Bot
} else {
side = engine.Human
}
}
})
}
}
// deadBoard has no legal move at all.
type deadBoard struct{}
func (deadBoard) LegalMoves() []string { return nil }
func (deadBoard) Used(string) bool { return false }
func (deadBoard) WordsStartingWith(string) iter.Seq[string] {
return func(func(string) bool) {}
}
func (deadBoard) LastSyllable(string) (string, bool) { return "", false }
func TestStrategiesReportNoMoveOnDeadEnd(t *testing.T) {
for _, d := range []Difficulty{Easy, Medium, Hard} {
strategy, _ := New(d, rand.New(rand.NewPCG(1, 1)))
if _, err := strategy.Choose(deadBoard{}); !errors.Is(err, ErrNoMove) {
t.Errorf("%s: err = %v, want ErrNoMove", d, err)
}
}
}
func TestMediumTakesImmediateWin(t *testing.T) {
store, err := dict.Parse("hoa hồng\nhồng hào\nhồng tâm\ntâm sự\ntâm hồn\nhồn nhiên\nhồn ma\nhào hoa\nhoa lá\n")
if err != nil {
t.Fatal(err)
}
// After "hồng tâm" the bot picks between "tâm hồn" (two replies) and "tâm sự"
// (none on "sự"): the winning move must be taken.
e, err := engine.New(store, "hoa hồng", time.Minute, 0, t0)
if err != nil {
t.Fatal(err)
}
e.Submit(engine.Human, "hồng tâm", t0)
strategy, _ := New(Medium, rand.New(rand.NewPCG(3, 3)))
if w, err := strategy.Choose(e); err != nil || w != "tâm sự" {
t.Fatalf("medium chose %q %v, want the winning tâm sự", w, err)
}
}
func TestHardRespectsNodeCap(t *testing.T) {
maxSeen := 0
for seed := range uint64(12) {
e := realGame(t, seed)
h := &hard{rng: rand.New(rand.NewPCG(seed, 99))}
if _, err := h.Choose(e); err != nil {
t.Fatalf("seed %d: %v", seed, err)
}
if h.lastNodes > nodeCap {
t.Fatalf("seed %d: searched %d nodes, cap %d", seed, h.lastNodes, nodeCap)
}
maxSeen = max(maxSeen, h.lastNodes)
}
if maxSeen == 0 {
t.Fatal("hard never searched; the node counter is not wired")
}
}
+129
View File
@@ -0,0 +1,129 @@
package noitu
import (
"context"
"errors"
"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/modules/util/chathelper"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
const (
// pvpCommand sends a game card that the chat's members play together. It
// shares its name with the BotFather game.
pvpCommand = ShortName
cardKeyPrefix = "pvp:"
// cardTTL is how long a card stays a room after its last Play press;
// after that the daily cleanup forgets it and it plays solo.
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
// cardCleanupSchedule runs at 03:30 ICT.
cardCleanupSchedule = "30 20 * * *"
msgPvPNeedsGroup = "Gửi /noitu trong nhóm để chơi cùng nhau. Muốn chơi với bot thì dùng /noitubot nhé."
msgPvPFail = "Không tạo được phòng nối từ. Thử lại sau nhé."
msgCardLookup = "Không mở được trò chơi lúc này. Thử lại sau nhé."
)
// pvpCard records a game message /noitu sent. A Play press on it opens the
// card's room; any other game message, a forwarded copy of a card included,
// plays against the bot.
type pvpCard struct {
ChatID int64 `bson:"chatId"`
MessageID int `bson:"messageId"`
ThreadID int `bson:"threadId"` // forum topic; 0 outside forums
TouchedAt int64 `bson:"touchedAt"` // unix seconds of creation or a recent Play
}
func cardKey(chatID int64, messageID int) string {
return cardKeyPrefix + strconv.FormatInt(chatID, 10) + ":" + strconv.Itoa(messageID)
}
// handlePvPCommand sends the game and registers it as a room card. Like
// /noitubot it works even when the game is disabled; Play explains why. Rooms
// are for a group's members, so private chats and channels are refused and
// pointed at /noitubot.
func (s *service) handlePvPCommand(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg.Chat.Type != models.ChatTypeGroup && msg.Chat.Type != models.ChatTypeSupergroup {
return chathelper.Reply(ctx, b, msg, msgPvPNeedsGroup)
}
if s.cfg.cards == nil {
_ = chathelper.Reply(ctx, b, msg, msgPvPFail)
return errors.New("noitu: no storage for pvp cards")
}
sent, err := b.SendGame(ctx, &bot.SendGameParams{
ChatID: msg.Chat.ID,
MessageThreadID: msg.MessageThreadID,
GameShorName: ShortName, // the library's field name is misspelled
})
if err != nil {
_ = chathelper.Reply(ctx, b, msg, msgSendGameFail)
return err
}
card := pvpCard{ChatID: msg.Chat.ID, MessageID: sent.ID, ThreadID: msg.MessageThreadID, TouchedAt: s.cfg.now().Unix()}
if err := s.cfg.cards.Put(ctx, cardKey(card.ChatID, card.MessageID), card); err != nil {
// An unregistered card would silently play solo; take it back.
if _, derr := b.DeleteMessage(ctx, &bot.DeleteMessageParams{ChatID: card.ChatID, MessageID: card.MessageID}); derr != nil {
log.Warn("noitu: delete unregistered pvp card failed", "err", derr)
}
_ = chathelper.Reply(ctx, b, msg, msgPvPFail)
return err
}
return nil
}
// lookupCard reports whether the chat message is a registered card, and
// refreshes its age at most once a day so a card in use never expires.
func (s *service) lookupCard(ctx context.Context, chatID int64, messageID int) (pvpCard, bool, error) {
if s.cfg.cards == nil {
return pvpCard{}, false, nil
}
key := cardKey(chatID, messageID)
card, _, err := s.cfg.cards.Get(ctx, key)
if errors.Is(err, storage.ErrNotFound) {
return pvpCard{}, false, nil
}
if err != nil {
return pvpCard{}, false, err
}
now := s.cfg.now().Unix()
if now-card.TouchedAt >= int64(cardTouchEvery/time.Second) {
card.TouchedAt = now
if err := s.cfg.cards.Put(ctx, key, card); err != nil {
log.Warn("noitu: refresh pvp card failed", "err", err)
}
}
return card, true, nil
}
// cleanupCards forgets cards nobody played for cardTTL.
func (s *service) cleanupCards(ctx context.Context, _ modules.Deps) error {
if s.cfg.cards == nil {
return nil
}
docs, err := s.cfg.cards.Scan(ctx, cardKeyPrefix)
if err != nil {
return err
}
cutoff := s.cfg.now().Add(-cardTTL).Unix()
var errs []error
for _, d := range docs {
if d.Val.TouchedAt < cutoff {
if err := s.cfg.cards.Delete(ctx, d.ID); err != nil && !errors.Is(err, storage.ErrNotFound) {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}
+194
View File
@@ -0,0 +1,194 @@
package noitu
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/tiennm99bot/internal/modules"
"github.com/tiennm99/tiennm99bot/internal/storage"
"github.com/tiennm99/tiennm99bot/internal/testutil"
)
func TestPvPCommand_RegistersCardKeepingTopic(t *testing.T) {
h := newHarness(t, testCorpus)
rb := installModule(t, h.mod)
update := testutil.NewSupergroupMessage(-100777, 42, "/noitu")
update.Message.MessageThreadID = 12
rb.Bot.ProcessUpdate(context.Background(), update)
last := rb.LastSent()
if last.Method != "sendGame" || last.Form["game_short_name"] != "noitu" || last.ChatID() != "-100777" || last.Form["message_thread_id"] != "12" {
t.Fatalf("sent %+v", rb.Sent())
}
card, _, err := h.svc.cfg.cards.Get(context.Background(), cardKey(-100777, 1))
if err != nil {
t.Fatalf("card not registered: %v", err)
}
want := pvpCard{ChatID: -100777, MessageID: 1, ThreadID: 12, TouchedAt: h.clock.now().Unix()}
if card != want {
t.Fatalf("card = %+v, want %+v", card, want)
}
}
func TestPvPCommand_RefusesPrivateChatsAndChannels(t *testing.T) {
h := newHarness(t, testCorpus)
for name, update := range map[string]*models.Update{
"private": testutil.NewPrivateMessage(42, "/noitu"),
"channel": testutil.NewChannelMessage(-100888, "/noitu"),
} {
rb := installModule(t, h.mod)
rb.Bot.ProcessUpdate(context.Background(), update)
if last := rb.LastSent(); last.Method != "sendMessage" || last.Text() != msgPvPNeedsGroup || len(rb.Sent()) != 1 {
t.Errorf("%s: sent %+v", name, rb.Sent())
}
}
}
// A card that cannot be recorded would play solo while looking like a room,
// so it is deleted again and the chat is told.
func TestPvPCommand_StoreFailureTakesTheCardBack(t *testing.T) {
h := newHarness(t, testCorpus)
h.svc.cfg.cards = storage.Typed[pvpCard](storage.NewMemoryProvider().Collection("Not A Valid Name"))
rb := installModule(t, h.svc.module())
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGroupMessage(-100777, 42, "/noitu"))
var methods []string
for _, c := range rb.Sent() {
methods = append(methods, c.Method)
}
if fmt.Sprint(methods) != "[sendGame deleteMessage sendMessage]" || rb.LastSent().Text() != msgPvPFail {
t.Fatalf("sent %+v", rb.Sent())
}
rb = installModule(t, h.svc.module())
rb.FailMethodCode("sendGame", 400, "Bad Request: GAME_SHORTNAME_INVALID")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGroupMessage(-100777, 42, "/noitu"))
if last := rb.LastSent(); last.Method != "sendMessage" || last.Text() != msgSendGameFail {
t.Fatalf("sendGame failure: sent %+v", rb.Sent())
}
}
// playToken presses Play on update and returns the claims of the answered URL.
func (h *harness) playToken(update *models.Update) claims {
h.t.Helper()
rb := installModule(h.t, h.mod)
rb.Bot.ProcessUpdate(context.Background(), update)
u, err := url.Parse(lastAnswer(h.t, rb)["url"])
if err != nil {
h.t.Fatal(err)
}
c, err := verifyToken(testKey, u.Query().Get("t"), h.clock.now())
if err != nil {
h.t.Fatalf("token: %v", err)
}
return c
}
func TestPlay_OnACardIssuesAPvPToken(t *testing.T) {
h := newHarness(t, testCorpus)
ctx := context.Background()
if err := h.svc.cfg.cards.Put(ctx, cardKey(-100500, 7), pvpCard{ChatID: -100500, MessageID: 7, ThreadID: 3, TouchedAt: h.clock.now().Unix()}); err != nil {
t.Fatal(err)
}
c := h.playToken(testutil.NewGameCallback(42, -100500, 7, ShortName))
if !c.PvP || c.ThreadID != 3 || c.ChatID != -100500 || c.MessageID != 7 || c.UserID != 42 {
t.Fatalf("card claims = %+v", c)
}
// A forwarded copy is another message, and an inline game is never a
// card: both play solo.
if c := h.playToken(testutil.NewGameCallback(42, -100500, 8, ShortName)); c.PvP {
t.Fatalf("unregistered message gave a pvp token: %+v", c)
}
if c := h.playToken(testutil.NewInlineGameCallback(42, "AgAAAInline", ShortName)); c.PvP {
t.Fatalf("inline message gave a pvp token: %+v", c)
}
// The noitubot game always plays solo, whatever is stored for its message.
if c := h.playToken(testutil.NewGameCallback(42, -100500, 7, SoloShortName)); c.PvP {
t.Fatalf("noitubot game gave a pvp token: %+v", c)
}
}
func TestPlay_CardLookupFailureAnswersAlert(t *testing.T) {
h := newHarness(t, testCorpus)
h.svc.cfg.cards = storage.Typed[pvpCard](storage.NewMemoryProvider().Collection("Not A Valid Name"))
rb := installModule(t, h.svc.module())
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGameCallback(42, -100500, 7, ShortName))
if a := lastAnswer(t, rb); a["text"] != msgCardLookup || a["show_alert"] != "true" || a["url"] != "" {
t.Fatalf("answer = %v", a)
}
}
func TestPvPToken_NeedsAChatMessageAndIsRefusedForSolo(t *testing.T) {
h := newHarness(t, testCorpus)
exp := h.clock.now().Add(time.Hour).Unix()
inline, _ := signToken(testKey, claims{UserID: 1, InlineID: "x", PvP: true, Expiry: exp})
if _, err := verifyToken(testKey, inline, h.clock.now()); err == nil {
t.Fatal("a pvp token on an inline message verified")
}
rec := h.post("start", fmt.Sprintf(`{"token":%q}`, h.pvpToken(42, "An")))
if rec.Code != http.StatusBadRequest || h.apiError(rec) != "bad_request" {
t.Fatalf("solo start with a pvp token: %d %s", rec.Code, rec.Body.String())
}
}
func TestCards_PlayRefreshesAndCleanupForgetsStaleCards(t *testing.T) {
h := newHarness(t, testCorpus)
ctx := context.Background()
cards := h.svc.cfg.cards
now := h.clock.now()
old := now.Add(-cardTTL - time.Hour).Unix()
for _, c := range []pvpCard{
{ChatID: -1, MessageID: 1, TouchedAt: old},
{ChatID: -1, MessageID: 2, TouchedAt: old},
{ChatID: -1, MessageID: 3, TouchedAt: now.Unix()},
} {
if err := cards.Put(ctx, cardKey(c.ChatID, c.MessageID), c); err != nil {
t.Fatal(err)
}
}
// Playing card 2 keeps it alive.
if c := h.playToken(testutil.NewGameCallback(42, -1, 2, ShortName)); !c.PvP {
t.Fatal("stale card no longer a card before cleanup")
}
if err := h.svc.cleanupCards(ctx, modules.Deps{}); err != nil {
t.Fatal(err)
}
keys, err := cards.List(ctx, cardKeyPrefix)
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(keys) != "[pvp:-1:2 pvp:-1:3]" {
t.Fatalf("cards after cleanup = %v", keys)
}
}
func TestBotReporter_AnnounceRepliesToTheCardInItsTopic(t *testing.T) {
rb := testutil.NewRecordingBot(t)
r := botReporter{b: rb.Bot}
if err := r.Announce(context.Background(), claims{ChatID: cardChat, MessageID: cardMsg, ThreadID: cardThread}, "<b>An</b> thắng"); err != nil {
t.Fatalf("Announce: %v", err)
}
last := rb.LastSent()
if last.Method != "sendMessage" || last.ChatID() != "-100500" || last.Form["message_thread_id"] != "3" ||
last.Text() != "<b>An</b> thắng" || last.Form["parse_mode"] != "" ||
!strings.Contains(last.Form["reply_parameters"], `"message_id":7`) {
t.Fatalf("sendMessage form = %v", last.Form)
}
}
// Cards live only in groups, so a private chat never touches the card store
// and a storage fault cannot block its solo game.
func TestPlay_PrivateChatSkipsTheCardLookup(t *testing.T) {
h := newHarness(t, testCorpus)
h.svc.cfg.cards = storage.Typed[pvpCard](storage.NewMemoryProvider().Collection("Not A Valid Name"))
h.mod = h.svc.module()
if c := h.playToken(testutil.NewGameCallback(42, 42, 7, ShortName)); c.PvP || c.ChatID != 42 {
t.Fatalf("private chat claims = %+v", c)
}
}
+367
View File
@@ -0,0 +1,367 @@
package noitu
import (
"errors"
"net/http"
"strings"
"time"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/engine"
)
var (
errNoRoomAPI = apiError{http.StatusNotFound, "no_room", "Phòng chơi không còn nữa. Hãy bấm Chơi trên thẻ trò chơi để vào lại."}
errNotPvPAPI = apiError{http.StatusBadRequest, "bad_request", "Liên kết này không mở phòng chơi nhóm."}
errPvPSoloAPI = apiError{http.StatusBadRequest, "bad_request", "Liên kết này mở phòng chơi nhóm, không dùng để chơi với bot."}
errRoomFullAPI = apiError{http.StatusConflict, "room_full", "Phòng đã đủ người."}
errNotHostAPI = apiError{http.StatusForbidden, "not_host", "Chỉ chủ phòng mới bắt đầu được ván chơi."}
errNotEnoughAPI = apiError{http.StatusConflict, "not_enough_players", "Cần ít nhất 2 người để bắt đầu."}
errGameRunningAPI = apiError{http.StatusConflict, "game_running", "Ván chơi đang diễn ra."}
errNoGameAPI = apiError{http.StatusConflict, "no_game", "Ván chơi chưa bắt đầu hoặc đã kết thúc."}
errNotInGameAPI = apiError{http.StatusConflict, "not_in_game", "Bạn không chơi trong ván này."}
errChatRoomsAPI = apiError{http.StatusConflict, "too_many_rooms", "Nhóm này đang có quá nhiều phòng chơi. Hãy chơi trên một thẻ đang mở."}
msgWatching = "Bạn đang xem ván này."
msgEliminated = "Bạn đã bị loại khỏi ván này."
msgNotYetTurn = "Chưa đến lượt bạn."
)
// roomPlayerView is one player as the page shows them: a lobby member, or a
// seat of the running game.
type roomPlayerView struct {
Name string `json:"name"`
You bool `json:"you"`
Host bool `json:"host"`
Score int `json:"score"`
Alive bool `json:"alive"`
OutReason string `json:"out_reason"`
}
// roomView is the room state the page renders. See docs/noitu-game.md.
type roomView struct {
Member string `json:"member"`
Version int64 `json:"version"`
Status string `json:"status"` // lobby | playing
Game int `json:"game"`
Host bool `json:"host"`
Seat int `json:"seat"` // your seat in the running game, -1 otherwise
Turn int `json:"turn"` // seat to act, -1 in the lobby
Players []roomPlayerView `json:"players"`
Watchers int `json:"watchers"`
MinPlayers int `json:"min_players"`
MaxPlayers int `json:"max_players"`
Current string `json:"current"`
Chain []roomEntry `json:"chain"`
TurnLimitMS int64 `json:"turn_limit_ms"`
DeadlineMS int64 `json:"deadline_ms"`
ServerNowMS int64 `json:"server_now_ms"`
Result *roomResult `json:"result"`
}
// roomPing answers a poll whose version is current.
type roomPing struct {
Version int64 `json:"version"`
Unchanged bool `json:"unchanged"`
ServerNowMS int64 `json:"server_now_ms"`
}
type roomMoveResponse struct {
Result moveResult `json:"result"`
State roomView `json:"state"`
}
type joinRequest struct {
Token string `json:"token"`
}
type memberRequest struct {
Member string `json:"member"`
}
type roomStateRequest struct {
Member string `json:"member"`
Version int64 `json:"version"`
}
type roomMoveRequest struct {
Member string `json:"member"`
Word string `json:"word"`
}
// roomReply is an answer built under the room lock and written after it is
// released, so a slow client never holds up the room.
type roomReply struct {
status int
body any
}
func okReply(v any) roomReply { return roomReply{http.StatusOK, v} }
func errReply(e apiError) roomReply { return roomReply{e.status, e} }
func (r roomReply) write(w http.ResponseWriter) { writeJSON(w, r.status, r.body) }
func (s *service) apiRoomJoin(w http.ResponseWriter, r *http.Request) {
var req joinRequest
if !decode(w, r, &req) {
return
}
now := s.cfg.now()
c, ok := s.verifyRequestToken(w, req.Token, now)
if !ok {
return
}
if !c.PvP {
writeError(w, errNotPvPAPI)
return
}
if !s.sessions.allowStart(c.UserID, now) {
writeError(w, errTooFast)
return
}
s.sweepRooms(now)
// A sweep can close the room between open and lock; open then replaces
// the closed room, so the second try gets a live one.
for range 2 {
rm, err := s.rooms.open(c)
if err != nil {
var apiErr apiError
if errors.As(err, &apiErr) {
writeError(w, apiErr)
return
}
writeError(w, errBusyAPI)
return
}
reply, retry := s.joinRoom(rm, c, now)
if !retry {
reply.write(w)
return
}
}
writeError(w, errBusyAPI)
}
// joinRoom adds c's user to rm under its lock. retry reports that the sweep
// closed rm after open returned it, so the caller must open again.
func (s *service) joinRoom(rm *room, c claims, now time.Time) (reply roomReply, retry bool) {
rm.mu.Lock()
defer rm.mu.Unlock()
if rm.closed.Load() {
return roomReply{}, true
}
s.settleRoom(rm, now)
m, err := rm.join(c, now)
if err != nil {
var apiErr apiError
if errors.As(err, &apiErr) {
return errReply(apiErr), false
}
log.Error("noitu room join failed", "err", err)
return errReply(errInternalAPI), false
}
return okReply(s.roomView(rm, m, now)), false
}
// verifyRequestToken checks a page token, answering the error itself.
func (s *service) verifyRequestToken(w http.ResponseWriter, token string, now time.Time) (claims, bool) {
c, err := verifyToken(s.cfg.key, token, now)
switch {
case errors.Is(err, errTokenExpired):
writeError(w, errTokenExpAPI)
return claims{}, false
case err != nil:
writeError(w, errBadTokenAPI)
return claims{}, false
}
return c, true
}
// withRoom finds and locks the room and member a bearer names, catches the
// room up with the clock, runs fn under the lock and writes its answer after
// unlocking. expired tells fn that the member held a turn that had run out
// before the catch-up, which therefore eliminated them. An unknown member,
// a closed room, or a member the catch-up dropped answers no_room.
func (s *service) withRoom(w http.ResponseWriter, bearer string, now time.Time, fn func(rm *room, m *roomMember, expired bool) roomReply) {
id, secret, found := strings.Cut(bearer, ".")
rm := s.rooms.get(id)
if !found || rm == nil {
writeError(w, errNoRoomAPI)
return
}
func() roomReply {
rm.mu.Lock()
defer rm.mu.Unlock()
m := rm.member(secret)
if m == nil || rm.closed.Load() {
return errReply(errNoRoomAPI)
}
expired := false
if rm.playing() {
seat := rm.seatOf(m)
expired = seat != noSeat && rm.match.Turn() == seat && rm.match.Expired(now)
}
s.settleRoom(rm, now)
// The catch-up drops members who stopped polling; they rejoin.
if rm.member(secret) != m {
return errReply(errNoRoomAPI)
}
m.lastSeen = now
return fn(rm, m, expired)
}().write(w)
}
func (s *service) apiRoomState(w http.ResponseWriter, r *http.Request) {
var req roomStateRequest
if !decode(w, r, &req) {
return
}
now := s.cfg.now()
s.withRoom(w, req.Member, now, func(rm *room, m *roomMember, _ bool) roomReply {
if req.Version == rm.version {
return okReply(roomPing{Version: rm.version, Unchanged: true, ServerNowMS: now.UnixMilli()})
}
return okReply(s.roomView(rm, m, now))
})
}
func (s *service) apiRoomStart(w http.ResponseWriter, r *http.Request) {
var req memberRequest
if !decode(w, r, &req) {
return
}
now := s.cfg.now()
s.withRoom(w, req.Member, now, func(rm *room, m *roomMember, _ bool) roomReply {
switch {
case rm.playing():
return errReply(errGameRunningAPI)
case rm.members[0] != m:
return errReply(errNotHostAPI)
case !rm.lastStart.IsZero() && now.Sub(rm.lastStart) < roomStartInterval:
return errReply(errTooFast)
case !s.sessions.allowStart(m.userID, now):
return errReply(errTooFast)
}
if err := s.startGame(rm, now); err != nil {
var apiErr apiError
if errors.As(err, &apiErr) {
return errReply(apiErr)
}
log.Error("noitu room start failed", "err", err)
return errReply(errInternalAPI)
}
return okReply(s.roomView(rm, m, now))
})
}
func (s *service) apiRoomMove(w http.ResponseWriter, r *http.Request) {
var req roomMoveRequest
if !decode(w, r, &req) {
return
}
now := s.cfg.now()
s.withRoom(w, req.Member, now, func(rm *room, m *roomMember, expired bool) roomReply {
if expired {
// The word came after the deadline plus grace: the clock took
// the player out before it could count.
return okReply(roomMoveResponse{Result: rejectedFor(engine.ReasonTimeout, ""), State: s.roomView(rm, m, now)})
}
if !rm.playing() {
return errReply(errNoGameAPI)
}
if now.Sub(m.lastMove) < moveInterval {
return errReply(errTooFast)
}
m.lastMove = now
if wordTooLong(req.Word) {
return errReply(errWordTooLong)
}
seat := rm.seatOf(m)
var result moveResult
switch {
case seat == noSeat:
result = moveResult{Reason: string(engine.ReasonNotYourTurn), Message: msgWatching}
case !rm.match.Alive(seat):
result = moveResult{Reason: string(engine.ReasonNotYourTurn), Message: msgEliminated}
case rm.match.Turn() != seat:
result = moveResult{Reason: string(engine.ReasonNotYourTurn), Message: msgNotYetTurn}
default:
result = s.playRoomWord(rm, seat, req.Word, now)
}
return okReply(roomMoveResponse{Result: result, State: s.roomView(rm, m, now)})
})
}
func (s *service) apiRoomGiveUp(w http.ResponseWriter, r *http.Request) {
var req memberRequest
if !decode(w, r, &req) {
return
}
now := s.cfg.now()
s.withRoom(w, req.Member, now, func(rm *room, m *roomMember, _ bool) roomReply {
if !rm.playing() {
return errReply(errNoGameAPI)
}
seat := rm.seatOf(m)
if seat == noSeat || !rm.match.Alive(seat) {
return errReply(errNotInGameAPI)
}
s.resignRoom(rm, seat, now)
return okReply(s.roomView(rm, m, now))
})
}
// roomView renders the room for member m. Caller holds rm.mu. The view
// shares no mutable state with the room, so it may be encoded after the
// lock is released: the chain is only ever appended to or replaced.
func (s *service) roomView(rm *room, m *roomMember, now time.Time) roomView {
v := roomView{
Member: rm.id + "." + m.secret,
Version: rm.version,
Status: roomLobby,
Game: rm.game,
Host: len(rm.members) > 0 && rm.members[0] == m,
Seat: noSeat,
Turn: noSeat,
MinPlayers: engine.MinSeats,
MaxPlayers: engine.MaxSeats,
Chain: rm.chain,
TurnLimitMS: turnLimit.Milliseconds(),
ServerNowMS: now.UnixMilli(),
}
if v.Chain == nil {
v.Chain = []roomEntry{}
}
if rm.playing() {
v.Status = roomPlaying
v.Seat = rm.seatOf(m)
v.Turn = rm.match.Turn()
v.Current = rm.match.Current()
v.DeadlineMS = rm.match.Deadline().UnixMilli()
for i, p := range rm.seats {
v.Players = append(v.Players, roomPlayerView{
Name: p.name,
You: p == m,
Score: rm.match.Score(i),
Alive: rm.match.Alive(i),
OutReason: string(rm.match.OutReason(i)),
})
}
// Members are only dropped in the lobby, so every seat is a member.
v.Watchers = len(rm.members) - len(rm.seats)
return v
}
for i, p := range rm.members[:min(len(rm.members), engine.MaxSeats)] {
v.Players = append(v.Players, roomPlayerView{Name: p.name, You: p == m, Host: i == 0, Alive: true})
}
v.Watchers = max(len(rm.members)-engine.MaxSeats, 0)
if rm.result != nil {
res := *rm.result
res.Standings = make([]roomStanding, len(rm.result.Standings))
for i, st := range rm.result.Standings {
st.You = st.userID == m.userID
res.Standings[i] = st
}
v.Result = &res
}
return v
}
+655
View File
@@ -0,0 +1,655 @@
package noitu
import (
"fmt"
"net/http"
"strings"
"sync"
"testing"
"time"
"github.com/tiennm99/tiennm99bot/internal/modules"
)
const (
cardChat = -100500
cardMsg = 7
cardThread = 3
)
// pvpToken signs a Play token for user on the test card.
func (h *harness) pvpToken(user int64, name string) string {
h.t.Helper()
return h.pvpTokenOn(user, name, cardMsg)
}
func (h *harness) pvpTokenOn(user int64, name string, msg int) string {
h.t.Helper()
return h.pvpTokenAt(user, name, cardChat, msg)
}
func (h *harness) pvpTokenAt(user int64, name string, chat int64, msg int) string {
h.t.Helper()
tok, err := signToken(testKey, claims{UserID: user, Name: name, ChatID: chat, MessageID: msg, ThreadID: cardThread, PvP: true, Expiry: h.clock.now().Add(tokenTTL).Unix()})
if err != nil {
h.t.Fatal(err)
}
return tok
}
func (h *harness) join(user int64, name string) roomView {
h.t.Helper()
var v roomView
h.call("room/join", map[string]string{"token": h.pvpToken(user, name)}, http.StatusOK, &v)
return v
}
func (h *harness) roomState(member string) roomView {
h.t.Helper()
var v roomView
h.call("room/state", map[string]any{"member": member}, http.StatusOK, &v)
return v
}
func (h *harness) roomStart(member string, wantStatus int) roomView {
h.t.Helper()
var v roomView
h.call("room/start", map[string]string{"member": member}, wantStatus, &v)
return v
}
// roomMove advances the clock past the per-member move limit, then plays.
func (h *harness) roomMove(member, word string) roomMoveResponse {
h.t.Helper()
h.clock.advance(time.Second)
var r roomMoveResponse
h.call("room/move", map[string]string{"member": member, "word": word}, http.StatusOK, &r)
return r
}
func (h *harness) roomErr(path string, body any) (int, string) {
h.t.Helper()
rec := h.postJSON(path, body)
return rec.Code, h.apiError(rec)
}
// waitRoomReport polls until the finished game's score report settles.
func (h *harness) waitRoomReport(member string) roomView {
h.t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
v := h.roomState(member)
if v.Result == nil {
h.t.Fatalf("no result: %+v", v)
}
if v.Result.ScoreReported != reportPending || time.Now().After(deadline) {
return v
}
time.Sleep(5 * time.Millisecond)
}
}
func TestRoom_JoinStartPlayAndReport(t *testing.T) {
h := newHarness(t, testCorpus)
a := h.join(1, "An")
if a.Status != roomLobby || !a.Host || len(a.Players) != 1 || !a.Players[0].You || a.Seat != noSeat {
t.Fatalf("first join = %+v", a)
}
if code, e := h.roomErr("room/start", map[string]string{"member": a.Member}); code != http.StatusConflict || e != "not_enough_players" {
t.Fatalf("start alone: %d %s", code, e)
}
b := h.join(2, "Bình")
if b.Host || len(b.Players) != 2 || b.Players[0].Name != "An" || !b.Players[0].Host || !b.Players[1].You {
t.Fatalf("second join = %+v", b)
}
if code, e := h.roomErr("room/start", map[string]string{"member": b.Member}); code != http.StatusForbidden || e != "not_host" {
t.Fatalf("start by guest: %d %s", code, e)
}
v := h.roomStart(a.Member, http.StatusOK)
if v.Status != roomPlaying || v.Seat != 0 || v.Turn != 0 || v.Current != "hồng" || v.Game != 1 || len(v.Chain) != 1 || v.Chain[0].Seat != noSeat {
t.Fatalf("started = %+v", v)
}
if v.DeadlineMS-v.ServerNowMS != turnLimit.Milliseconds() {
t.Fatalf("deadline %d now %d", v.DeadlineMS, v.ServerNowMS)
}
if code, e := h.roomErr("room/start", map[string]string{"member": a.Member}); code != http.StatusConflict || e != "game_running" {
t.Fatalf("second start: %d %s", code, e)
}
// Playing out of turn is refused and changes nothing.
r := h.roomMove(b.Member, "hồng tâm")
if r.Result.Accepted || r.Result.Reason != "not_your_turn" || r.Result.Message != msgNotYetTurn || len(r.State.Chain) != 1 {
t.Fatalf("out of turn = %+v", r)
}
r = h.roomMove(a.Member, "hồng tâm")
if !r.Result.Accepted || r.State.Turn != 1 || r.State.Chain[1].Name != "An" || r.State.Chain[1].Seat != 0 {
t.Fatalf("a's move = %+v", r)
}
if r = h.roomMove(b.Member, "hồng hào"); r.Result.Accepted || r.Result.Reason != "wrong_link" || !strings.Contains(r.Result.Message, "“tâm”") {
t.Fatalf("wrong link = %+v", r.Result)
}
h.roomMove(b.Member, "tâm sự")
r = h.roomMove(a.Member, "sự cố") // leaves b a dead end
if r.State.Status != roomPlaying || r.State.Turn != 1 {
t.Fatalf("dead end ended the game early: %+v", r.State)
}
aScore := r.State.Players[0].Score
bScore := r.State.Players[1].Score
var end roomView
h.call("room/give-up", map[string]string{"member": b.Member}, http.StatusOK, &end)
if end.Status != roomLobby || end.Result == nil || end.Result.Winner != "An" || end.Result.Words != 3 || end.Result.EndReason != "no_legal_move" {
t.Fatalf("after give up = %+v / %+v", end, end.Result)
}
st := end.Result.Standings
if len(st) != 2 || st[0].Name != "An" || st[0].Score != aScore+winnerBonus || st[0].Bonus != winnerBonus || st[0].Words != 2 ||
st[1].Name != "Bình" || !st[1].You || st[1].Score != bScore || st[1].OutReason != "no_legal_move" {
t.Fatalf("standings = %+v", st)
}
if got := h.waitRoomReport(a.Member); got.Result.ScoreReported != reportOK || !got.Result.Standings[0].You {
t.Fatalf("report = %+v", got.Result)
}
calls := h.reporter.snapshot()
if len(calls) != 2 || calls[0].score != aScore+winnerBonus || calls[0].claims.UserID != 1 || calls[1].claims.UserID != 2 {
t.Fatalf("reports = %+v", calls)
}
for _, c := range calls {
if c.claims.ChatID != cardChat || c.claims.MessageID != cardMsg || c.claims.PvP {
t.Fatalf("report not on the card: %+v", c.claims)
}
}
if ann := h.reporter.announcements(); len(ann) != 1 || ann[0] != "Ván nối từ kết thúc: An thắng sau 3 từ!" {
t.Fatalf("announcements = %q", ann)
}
if h.reporter.cards[0].ThreadID != cardThread || h.reporter.cards[0].MessageID != cardMsg {
t.Fatalf("announced on %+v", h.reporter.cards[0])
}
// An unchanged poll is cheap; the same card plays again.
final := h.roomState(a.Member)
var ping roomPing
h.call("room/state", map[string]any{"member": a.Member, "version": final.Version}, http.StatusOK, &ping)
if !ping.Unchanged || ping.Version != final.Version {
t.Fatalf("unchanged poll = %+v", ping)
}
again := h.roomStart(a.Member, http.StatusOK)
if again.Status != roomPlaying || again.Game != 2 || len(again.Chain) != 1 || again.Result != nil || again.Version <= final.Version {
t.Fatalf("second game = %+v", again)
}
}
func TestRoom_NonMembersAndWrongModeAreRejected(t *testing.T) {
h := newHarness(t, testCorpus)
a := h.join(1, "An")
id, _, _ := strings.Cut(a.Member, ".")
for _, member := range []string{"", "nope", id, id + ".forged", "x." + strings.Repeat("0", 32)} {
for _, path := range []string{"room/state", "room/start", "room/move", "room/give-up"} {
if code, e := h.roomErr(path, map[string]string{"member": member}); code != http.StatusNotFound || e != "no_room" {
t.Errorf("%s with %q: %d %s", path, member, code, e)
}
}
}
if code, e := h.roomErr("room/join", map[string]string{"token": h.token(1)}); code != http.StatusBadRequest || e != "bad_request" {
t.Fatalf("join with a solo token: %d %s", code, e)
}
tok := h.pvpToken(1, "An")
if code, e := h.roomErr("room/join", map[string]string{"token": tok[:len(tok)-2] + "xx"}); code != http.StatusUnauthorized || e != "bad_token" {
t.Fatalf("tampered token: %d %s", code, e)
}
if rec := h.post("room/join", `{"token":"x","room":"y"}`); rec.Code != http.StatusBadRequest {
t.Fatalf("unknown field: %d", rec.Code)
}
// Moves and give-ups need a running game.
if code, e := h.roomErr("room/give-up", map[string]string{"member": a.Member}); code != http.StatusConflict || e != "no_game" {
t.Fatalf("give up in the lobby: %d %s", code, e)
}
h.clock.advance(time.Second)
if code, e := h.roomErr("room/move", map[string]string{"member": a.Member, "word": "hồng hào"}); code != http.StatusConflict || e != "no_game" {
t.Fatalf("move in the lobby: %d %s", code, e)
}
}
func TestRoom_RejoinKeepsTheMembershipAndLateJoinersWatch(t *testing.T) {
h := newHarness(t, testCorpus)
a := h.join(1, "An")
b := h.join(2, "Bình")
if again := h.join(1, "An"); again.Member != a.Member || !again.Host || len(again.Players) != 2 {
t.Fatalf("rejoin = %+v", again)
}
h.roomStart(a.Member, http.StatusOK)
w := h.join(3, "<b>Chi</b>")
if w.Status != roomPlaying || w.Seat != noSeat || w.Watchers != 1 || len(w.Players) != 2 {
t.Fatalf("late joiner = %+v", w)
}
if r := h.roomMove(w.Member, "hồng hào"); r.Result.Accepted || r.Result.Message != msgWatching {
t.Fatalf("watcher move = %+v", r.Result)
}
if code, e := h.roomErr("room/give-up", map[string]string{"member": w.Member}); code != http.StatusConflict || e != "not_in_game" {
t.Fatalf("watcher give up: %d %s", code, e)
}
// Leaving mid-game out of turn: the turn and its clock stay with a.
var v roomView
h.call("room/give-up", map[string]string{"member": b.Member}, http.StatusOK, &v)
if v.Status != roomLobby || v.Result.Winner != "An" || v.Result.Standings[1].OutReason != "gave_up" {
t.Fatalf("after b leaves = %+v / %+v", v, v.Result)
}
// Back in the lobby, the watcher is a player for the next game, and the
// name comes back exactly as given: the page escapes it.
if v.Watchers != 0 || len(v.Players) != 3 || v.Players[2].Name != "<b>Chi</b>" {
t.Fatalf("lobby after the game = %+v", v.Players)
}
// Nobody played a word: no bonus, no report and no announcement.
if v.Result.Standings[0].Bonus != 0 || v.Result.Standings[0].Score != 0 || v.Result.Words != 0 || v.Result.ScoreReported != reportSkipped {
t.Fatalf("zero-word result = %+v", v.Result)
}
time.Sleep(20 * time.Millisecond) // a wrongly started publisher would have run by now
if calls, ann := h.reporter.snapshot(), h.reporter.announcements(); len(calls) != 0 || len(ann) != 0 {
t.Fatalf("zero-word game reported %+v / announced %q", calls, ann)
}
}
func TestRoom_TimeoutEliminatesAndALateWordCountsAsTimeout(t *testing.T) {
h := newHarness(t, testCorpus)
a := h.join(1, "An")
b := h.join(2, "Bình")
c := h.join(3, "Chi")
h.roomStart(a.Member, http.StatusOK)
h.clock.advance(turnLimit + turnGrace + time.Millisecond)
v := h.roomState(c.Member)
if v.Players[0].Alive || v.Players[0].OutReason != "timeout" || v.Turn != 1 || v.Status != roomPlaying || v.Current != "hồng" {
t.Fatalf("after a's timeout = %+v", v)
}
// b's turn began when a's ended, grace included.
if want := h.clock.now().Add(turnLimit - time.Millisecond); v.DeadlineMS != want.UnixMilli() {
t.Fatalf("b's deadline = %d, want %d", v.DeadlineMS, want.UnixMilli())
}
if r := h.roomMove(a.Member, "hồng hào"); r.Result.Accepted || r.Result.Message != msgEliminated {
t.Fatalf("eliminated move = %+v", r.Result)
}
h.clock.advance(turnLimit + turnGrace)
var r roomMoveResponse
h.call("room/move", map[string]string{"member": b.Member, "word": "hồng hào"}, http.StatusOK, &r)
if r.Result.Accepted || r.Result.Reason != "timeout" || r.State.Status != roomLobby || r.State.Result.Winner != "Chi" {
t.Fatalf("late move = %+v / %+v", r.Result, r.State.Result)
}
// Nobody played a word, so nothing is reported.
if r.State.Result.ScoreReported != reportSkipped || r.State.Result.Standings[0].Bonus != 0 {
t.Fatalf("zero-word result = %+v", r.State.Result)
}
}
// A game whose players all closed the page still ends on the clock through
// the sweep, and is still reported and announced.
func TestRoom_SweepSettlesAnAbandonedGame(t *testing.T) {
h := newHarness(t, testCorpus)
a := h.join(1, "An")
h.join(2, "Bình")
h.roomStart(a.Member, http.StatusOK)
h.roomMove(a.Member, "hồng hào")
h.clock.advance(5 * time.Minute)
if err := h.svc.sweepCron(t.Context(), modules.Deps{}); err != nil {
t.Fatal(err)
}
waitCalls(t, h.reporter, 1)
calls := h.reporter.snapshot()
if len(calls) != 1 || calls[0].claims.UserID != 1 {
t.Fatalf("reports = %+v", calls)
}
if ann := h.reporter.announcements(); len(ann) != 1 || !strings.Contains(ann[0], "An thắng sau 1 từ") {
t.Fatalf("announcements = %q", ann)
}
}
func TestRoom_ExpiryAndStaleMembers(t *testing.T) {
h := newHarness(t, testCorpus)
a := h.join(1, "An")
b := h.join(2, "Bình")
// a keeps polling; b's page closed. b's own late poll is what drops b,
// and it must not be answered as a member.
h.clock.advance(memberStaleTTL / 2)
h.roomState(a.Member)
h.clock.advance(memberStaleTTL/2 + time.Second)
if code, e := h.roomErr("room/state", map[string]string{"member": b.Member}); code != http.StatusNotFound || e != "no_room" {
t.Fatalf("stale member poll: %d %s", code, e)
}
if v := h.roomState(a.Member); len(v.Players) != 1 || !v.Host {
t.Fatalf("lobby after b went stale = %+v", v.Players)
}
// A lobby somebody polls lives on, however long nobody starts a game.
for range 30 {
h.clock.advance(memberStaleTTL - time.Second)
h.roomState(a.Member)
h.svc.sweepRooms(h.clock.now())
}
if n := len(h.svc.rooms.all()); n != 1 {
t.Fatalf("polled lobby dropped: %d rooms", n)
}
// Once nobody polls, the room goes with its last member.
h.clock.advance(memberStaleTTL + time.Second)
h.svc.sweepRooms(h.clock.now())
if n := len(h.svc.rooms.all()); n != 0 {
t.Fatalf("%d rooms left", n)
}
if code, e := h.roomErr("room/state", map[string]string{"member": a.Member}); code != http.StatusNotFound || e != "no_room" {
t.Fatalf("expired room: %d %s", code, e)
}
// Joining again opens a fresh room on the same card.
if v := h.join(1, "An"); v.Member == a.Member || !v.Host {
t.Fatalf("rejoin after expiry = %+v", v)
}
}
// A running game keeps every member, polling or not: only the lobby drops
// the absent.
func TestRoom_RunningGameNeverDropsMembers(t *testing.T) {
h := newHarness(t, testCorpus)
a := h.join(1, "An")
b := h.join(2, "Bình")
h.roomStart(a.Member, http.StatusOK)
w := h.join(3, "Chi") // watches, then stops polling
for _, mv := range []struct{ member, word string }{
{a.Member, "hồng tâm"}, {b.Member, "tâm sự"}, {a.Member, "sự cố"},
} {
h.clock.advance(turnLimit - 5*time.Second)
if r := h.roomMove(mv.member, mv.word); !r.Result.Accepted {
t.Fatalf("%s: %+v", mv.word, r.Result)
}
}
// b now faces a dead end; the game is still running past memberStaleTTL.
v := h.roomState(w.Member)
if v.Status != roomPlaying || v.Watchers != 1 || v.Seat != noSeat {
t.Fatalf("watcher after %v = %+v", 3*(turnLimit-4*time.Second), v)
}
}
// A room the sweep has closed answers no_room even before it leaves the
// store, and a join that races the removal gets a fresh room, not busy.
func TestRoom_ClosedRoomIsReplacedNotJoined(t *testing.T) {
h := newHarness(t, testCorpus)
a := h.join(1, "An")
rooms := h.svc.rooms.all()
if len(rooms) != 1 {
t.Fatalf("%d rooms", len(rooms))
}
rooms[0].closed.Store(true) // the sweep's first step, before remove
if code, e := h.roomErr("room/state", map[string]string{"member": a.Member}); code != http.StatusNotFound || e != "no_room" {
t.Fatalf("closed room poll: %d %s", code, e)
}
v := h.join(1, "An")
if v.Member == a.Member || !v.Host {
t.Fatalf("join on a closed room = %+v", v)
}
if got := h.svc.rooms.all(); len(got) != 1 || got[0] == rooms[0] {
t.Fatal("the closed room was not replaced")
}
}
func TestRoom_GiveUpByAnEliminatedPlayerIsRefused(t *testing.T) {
h := newHarness(t, testCorpus)
a := h.join(1, "An")
h.join(2, "Bình")
h.join(3, "Chi")
h.roomStart(a.Member, http.StatusOK)
h.clock.advance(turnLimit + turnGrace + time.Millisecond)
if code, e := h.roomErr("room/give-up", map[string]string{"member": a.Member}); code != http.StatusConflict || e != "not_in_game" {
t.Fatalf("eliminated give up: %d %s", code, e)
}
if v := h.roomState(a.Member); v.Status != roomPlaying || v.Players[0].OutReason != "timeout" {
t.Fatalf("state = %+v", v)
}
}
func TestRoom_MaxChainEndsTheGame(t *testing.T) {
h := newHarness(t, testCorpus)
a := h.join(1, "An")
h.join(2, "Bình")
h.roomStart(a.Member, http.StatusOK)
rm := h.svc.rooms.all()[0]
rm.mu.Lock()
for len(rm.chain) < maxChain-1 {
rm.chain = append(rm.chain, rm.chain[0])
}
rm.mu.Unlock()
r := h.roomMove(a.Member, "hồng hào")
if !r.Result.Accepted || r.State.Status != roomLobby || r.State.Result == nil || r.State.Result.EndReason != "max_moves" || r.State.Result.Winner != "An" {
t.Fatalf("max chain = %+v / %+v", r.Result, r.State.Result)
}
}
func TestRoom_FailedReportShowsFailed(t *testing.T) {
h := newHarness(t, testCorpus)
h.reporter.err = fmt.Errorf("Forbidden")
a := h.join(1, "An")
b := h.join(2, "Bình")
h.roomStart(a.Member, http.StatusOK)
h.roomMove(a.Member, "hồng hào")
h.call("room/give-up", map[string]string{"member": b.Member}, http.StatusOK, nil)
if got := h.waitRoomReport(a.Member); got.Result.ScoreReported != reportFailed {
t.Fatalf("score_reported = %q", got.Result.ScoreReported)
}
}
// Starts are limited per room and per host, so a start/give-up loop cannot
// flood the chat or the card.
func TestRoom_StartRateLimits(t *testing.T) {
h := newHarness(t, testCorpus)
a := h.join(1, "An")
b := h.join(2, "Bình")
h.roomStart(a.Member, http.StatusOK)
h.call("room/give-up", map[string]string{"member": b.Member}, http.StatusOK, nil)
if code, e := h.roomErr("room/start", map[string]string{"member": a.Member}); code != http.StatusTooManyRequests || e != "too_fast" {
t.Fatalf("immediate restart: %d %s", code, e)
}
starts := 1
for {
h.clock.advance(roomStartInterval)
rec := h.postJSON("room/start", map[string]string{"member": a.Member})
if rec.Code != http.StatusOK {
if rec.Code != http.StatusTooManyRequests || h.apiError(rec) != "too_fast" {
t.Fatalf("start %d: %d %s", starts+1, rec.Code, rec.Body.String())
}
break
}
starts++
h.call("room/give-up", map[string]string{"member": b.Member}, http.StatusOK, nil)
}
// The host's join counted as one of their startsPerMinute.
if starts != startsPerMinute-1 {
t.Fatalf("%d starts in a minute, want %d", starts, startsPerMinute-1)
}
}
func TestRoom_Limits(t *testing.T) {
h := newHarness(t, testCorpus)
for user := int64(1); user <= maxRoomMembers; user++ {
h.join(user, "P")
}
if code, e := h.roomErr("room/join", map[string]string{"token": h.pvpToken(maxRoomMembers+1, "P")}); code != http.StatusConflict || e != "room_full" {
t.Fatalf("full room: %d %s", code, e)
}
// Only the first MaxSeats members play; the rest wait in the lobby.
v := h.join(1, "P")
if len(v.Players) != 8 || v.Watchers != maxRoomMembers-8 {
t.Fatalf("lobby = %d players / %d watchers", len(v.Players), v.Watchers)
}
started := h.roomStart(v.Member, http.StatusOK)
if len(started.Players) != 8 || started.Watchers != maxRoomMembers-8 {
t.Fatalf("game = %d players / %d watchers", len(started.Players), started.Watchers)
}
// The room cap answers busy.
h2 := newHarness(t, testCorpus)
for msg := 1; msg <= maxRooms; msg++ {
h2.call("room/join", map[string]string{"token": h2.pvpTokenAt(int64(msg), "P", -int64(msg), msg)}, http.StatusOK, nil)
}
tok := h2.pvpTokenAt(maxRooms+1, "P", -maxRooms-1, maxRooms+1)
if code, e := h2.roomErr("room/join", map[string]string{"token": tok}); code != http.StatusServiceUnavailable || e != "busy" {
t.Fatalf("room cap: %d %s", code, e)
}
// One chat holds at most maxChatRooms live rooms; other chats are free.
h4 := newHarness(t, testCorpus)
for msg := 1; msg <= maxChatRooms; msg++ {
h4.call("room/join", map[string]string{"token": h4.pvpTokenOn(int64(msg), "P", msg)}, http.StatusOK, nil)
}
if code, e := h4.roomErr("room/join", map[string]string{"token": h4.pvpTokenOn(99, "P", 99)}); code != http.StatusConflict || e != "too_many_rooms" {
t.Fatalf("chat room cap: %d %s", code, e)
}
h4.call("room/join", map[string]string{"token": h4.pvpTokenAt(99, "P", -1, 99)}, http.StatusOK, nil)
// Joins share the per-user start limit.
h3 := newHarness(t, testCorpus)
for range startsPerMinute {
h3.join(1, "P")
}
if code, e := h3.roomErr("room/join", map[string]string{"token": h3.pvpToken(1, "P")}); code != http.StatusTooManyRequests || e != "too_fast" {
t.Fatalf("join flood: %d %s", code, e)
}
}
func TestRoom_MoveRateLimitAndWordLength(t *testing.T) {
h := newHarness(t, testCorpus)
a := h.join(1, "An")
h.join(2, "Bình")
h.roomStart(a.Member, http.StatusOK)
h.roomMove(a.Member, "hồng xyz")
if code, e := h.roomErr("room/move", map[string]string{"member": a.Member, "word": "hồng hào"}); code != http.StatusTooManyRequests || e != "too_fast" {
t.Fatalf("fast move: %d %s", code, e)
}
h.clock.advance(time.Second)
if code, _ := h.roomErr("room/move", map[string]string{"member": a.Member, "word": strings.Repeat("a ", 9)}); code != http.StatusBadRequest {
t.Fatalf("long word: %d", code)
}
}
// The sweep marks a room it drops as closed, so a request already holding
// the room sees it gone.
func TestRoom_SweepClosesTheRoomItDrops(t *testing.T) {
h := newHarness(t, testCorpus)
h.join(1, "An")
held := h.svc.rooms.all()[0]
h.clock.advance(memberStaleTTL + time.Second)
h.svc.sweepRooms(h.clock.now())
if !held.closed.Load() {
t.Fatal("the swept room is not marked closed")
}
if n := len(h.svc.rooms.all()); n != 0 {
t.Fatalf("%d rooms left", n)
}
}
// A join that got a room from open before the sweep closed it adds nobody
// to it and asks for a retry.
func TestRoom_JoinRetriesARoomClosedAfterOpen(t *testing.T) {
h := newHarness(t, testCorpus)
now := h.clock.now()
c := claims{UserID: 1, Name: "An", ChatID: cardChat, MessageID: cardMsg, ThreadID: cardThread, PvP: true}
rm, err := h.svc.rooms.open(c)
if err != nil {
t.Fatal(err)
}
rm.closed.Store(true) // the sweep ran between open and lock
if _, retry := h.svc.joinRoom(rm, c, now); !retry {
t.Fatal("join on a room closed after open did not retry")
}
rm.mu.Lock()
n := len(rm.members)
rm.mu.Unlock()
if n != 0 {
t.Fatalf("closed room gained %d members", n)
}
fresh, err := h.svc.rooms.open(c)
if err != nil || fresh == rm {
t.Fatalf("retry open = %p, %v; want a new room", fresh, err)
}
if reply, retry := h.svc.joinRoom(fresh, c, now); retry || reply.status != http.StatusOK {
t.Fatalf("retry join = %+v, %v", reply, retry)
}
}
// Results on one card publish one game after another: while the first
// game's report is held, a second game on the same card announces nothing,
// but a game on another card publishes freely.
func TestRoom_PublishingIsSerializedPerCard(t *testing.T) {
h := newHarness(t, testCorpus)
const otherMsg = cardMsg + 1
entered := make(chan struct{})
release := make(chan struct{})
var once sync.Once
h.reporter.beforeReport = func(c claims) {
if c.MessageID != cardMsg {
return
}
once.Do(func() {
close(entered)
<-release
})
}
released := false
defer func() {
if !released {
close(release)
}
}()
// playOut runs one game on the card a and b joined: a plays a word, b
// gives up, so a wins with one word.
playOut := func(a, b roomView) {
h.roomStart(a.Member, http.StatusOK)
if r := h.roomMove(a.Member, "hồng hào"); !r.Result.Accepted {
t.Fatalf("move: %+v", r.Result)
}
h.call("room/give-up", map[string]string{"member": b.Member}, http.StatusOK, nil)
}
a := h.join(1, "An")
b := h.join(2, "Bình")
playOut(a, b)
select {
case <-entered:
case <-time.After(2 * time.Second):
t.Fatal("first report never started")
}
// A second game on the same card ends while the first still reports.
h.clock.advance(roomStartInterval)
playOut(a, b)
// A game on another card publishes while the first card is held.
var c, d roomView
h.call("room/join", map[string]string{"token": h.pvpTokenOn(3, "Chi", otherMsg)}, http.StatusOK, &c)
h.call("room/join", map[string]string{"token": h.pvpTokenOn(4, "Dũng", otherMsg)}, http.StatusOK, &d)
playOut(c, d)
deadline := time.Now().Add(2 * time.Second)
for h.reporter.announcedOn(otherMsg) == 0 {
if time.Now().After(deadline) {
t.Fatal("the other card's result was blocked by the first card")
}
time.Sleep(5 * time.Millisecond)
}
if calls := waitCalls(t, h.reporter, 1); len(calls) != 1 || calls[0].claims.MessageID != otherMsg {
t.Fatalf("reports while the first card is held = %+v", calls)
}
if n := h.reporter.announcedOn(cardMsg); n != 1 {
t.Fatalf("%d announcements on the held card, want only the first game's", n)
}
close(release)
released = true
deadline = time.Now().Add(2 * time.Second)
for h.reporter.announcedOn(cardMsg) != 2 {
if time.Now().After(deadline) {
t.Fatal("the second game never published after the first finished")
}
time.Sleep(5 * time.Millisecond)
}
if calls := waitCalls(t, h.reporter, 3); len(calls) != 3 {
t.Fatalf("reports = %+v, want 3", calls)
}
}
+449
View File
@@ -0,0 +1,449 @@
package noitu
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/engine"
)
const (
maxRooms = 500
maxChatRooms = 3 // live rooms per chat, so one group cannot fill maxRooms
maxRoomMembers = 32 // MaxSeats players plus watchers
// memberStaleTTL drops a lobby member whose page stopped polling, so a
// closed page does not hold a seat, and a lobby nobody polls is dropped
// with its last member. A running game never drops anyone: an absent
// player loses their turns on the clock instead.
memberStaleTTL = time.Minute
// roomStartInterval is the shortest gap between two games in one room.
// Starts also count against the host's startsPerMinute.
roomStartInterval = 3 * time.Second
// winnerBonus is added to the winner's points.
winnerBonus = 50
noSeat = -1
)
// Room statuses shown on the page.
const (
roomLobby = "lobby"
roomPlaying = "playing"
)
// roomMember is one person who joined a room through the card's Play button.
// secret is their bearer credential; it is only ever sent to them.
type roomMember struct {
secret string
userID int64
name string
lastSeen time.Time
lastMove time.Time
}
// roomEntry is one word of a room's chain. Seat indexes the game's players;
// the opening word is the bot's, with seat -1.
type roomEntry struct {
chainEntry
Seat int `json:"seat"`
Name string `json:"name,omitempty"`
}
// roomStanding is one player's line in a finished game's table.
type roomStanding struct {
userID int64
Name string `json:"name"`
Rank int `json:"rank"`
Score int `json:"score"` // word points plus the winner bonus
Bonus int `json:"bonus"`
Words int `json:"words"`
OutReason string `json:"out_reason"`
You bool `json:"you"`
won bool
}
// roomReport is one score to report on the card.
type roomReport struct {
claims claims
score int
}
// roomResult is the last finished game, shown in the lobby until the next
// game starts.
type roomResult struct {
Winner string `json:"winner"`
Words int `json:"words"` // words played, opening excluded
EndReason string `json:"end_reason"`
Standings []roomStanding `json:"standings"`
ScoreReported string `json:"score_reported"`
}
// room is the live game of one /noitu card. Its mutex guards every field
// below it; the room store's mutex is never taken while holding it.
type room struct {
id string
card claims // ChatID, MessageID and ThreadID of the card
// closed marks a room the sweep dropped. It is atomic so the store can
// replace a closed room without taking the room's mutex.
closed atomic.Bool
mu sync.Mutex
version int64
members []*roomMember // join order; members[0] hosts the lobby
game int // games started, so the page can tell chains apart
match *engine.Match // nil before the first game; kept after one ends
seats []*roomMember // the match's players, by seat
chain []roomEntry
result *roomResult
lastStart time.Time
}
// playing reports whether a game is in progress. Caller holds r.mu.
func (r *room) playing() bool { return r.match != nil && !r.match.Over() }
func (r *room) changed() { r.version++ }
// member finds the member holding secret. Caller holds r.mu.
func (r *room) member(secret string) *roomMember {
for _, m := range r.members {
if m.secret == secret {
return m
}
}
return nil
}
// seatOf is m's seat in the current or last game, noSeat when m watches.
// Caller holds r.mu.
func (r *room) seatOf(m *roomMember) int {
for i, s := range r.seats {
if s == m {
return i
}
}
return noSeat
}
// expired reports whether the room can be dropped: no game running and
// nobody left, every member having stopped polling. Caller holds r.mu.
func (r *room) expired() bool {
return !r.playing() && len(r.members) == 0
}
// roomStore indexes live rooms by card and by id.
type roomStore struct {
mu sync.Mutex
byCard map[string]*room
byID map[string]*room
}
func newRoomStore() *roomStore {
return &roomStore{byCard: map[string]*room{}, byID: map[string]*room{}}
}
func roomCardKey(c claims) string { return cardKey(c.ChatID, c.MessageID) }
// open returns the card's room, creating it when there is none, or only a
// closed one the sweep has not removed yet, and the room caps allow.
func (st *roomStore) open(card claims) (*room, error) {
st.mu.Lock()
defer st.mu.Unlock()
key := roomCardKey(card)
if r := st.byCard[key]; r != nil {
if !r.closed.Load() {
return r, nil
}
st.removeLocked(r)
}
if len(st.byID) >= maxRooms {
return nil, errBusy
}
inChat := 0
for _, r := range st.byID {
if r.card.ChatID == card.ChatID && !r.closed.Load() {
inChat++
}
}
if inChat >= maxChatRooms {
return nil, errChatRoomsAPI
}
id, err := newSessionID()
if err != nil {
return nil, err
}
r := &room{
id: id,
card: claims{ChatID: card.ChatID, MessageID: card.MessageID, ThreadID: card.ThreadID},
version: 1,
}
st.byCard[key] = r
st.byID[id] = r
return r, nil
}
func (st *roomStore) get(id string) *room {
st.mu.Lock()
defer st.mu.Unlock()
return st.byID[id]
}
func (st *roomStore) remove(r *room) {
st.mu.Lock()
defer st.mu.Unlock()
st.removeLocked(r)
}
func (st *roomStore) removeLocked(r *room) {
if st.byID[r.id] == r {
delete(st.byID, r.id)
}
if key := roomCardKey(r.card); st.byCard[key] == r {
delete(st.byCard, key)
}
}
func (st *roomStore) all() []*room {
st.mu.Lock()
defer st.mu.Unlock()
out := make([]*room, 0, len(st.byID))
for _, r := range st.byID {
out = append(out, r)
}
return out
}
// sweepRooms settles expired turns in rooms nobody is polling, so their
// games still end and report, and drops idle rooms.
func (s *service) sweepRooms(now time.Time) {
for _, r := range s.rooms.all() {
r.mu.Lock()
s.settleRoom(r, now)
drop := r.expired()
if drop {
r.closed.Store(true)
}
r.mu.Unlock()
if drop {
s.rooms.remove(r)
}
}
}
// settleRoom catches the room up with the clock: every turn that ran out
// eliminates its player, and lobby members who stopped polling leave.
// Caller holds r.mu.
func (s *service) settleRoom(r *room, now time.Time) {
if r.playing() {
timedOut := false
for r.match.Timeout(now) {
timedOut = true
}
if timedOut {
r.changed()
if r.match.Over() {
s.endRoomGame(r, now)
}
}
}
if r.playing() {
return
}
kept := r.members[:0]
for _, m := range r.members {
if now.Sub(m.lastSeen) <= memberStaleTTL {
kept = append(kept, m)
}
}
if len(kept) != len(r.members) {
clear(r.members[len(kept):])
r.members = kept
r.changed()
}
}
// join adds the user to the room, or returns their existing membership so a
// reloaded page or a second Play press keeps its place. Caller holds r.mu.
func (r *room) join(c claims, now time.Time) (*roomMember, error) {
name := c.Name
if name == "" {
name = "Người chơi"
}
for _, m := range r.members {
if m.userID == c.UserID {
m.lastSeen = now
if m.name != name {
m.name = name
r.changed()
}
return m, nil
}
}
if len(r.members) >= maxRoomMembers {
return nil, errRoomFullAPI
}
secret, err := newSessionID()
if err != nil {
return nil, err
}
m := &roomMember{secret: secret, userID: c.UserID, name: name, lastSeen: now}
r.members = append(r.members, m)
r.changed()
return m, nil
}
// startGame seats the first MaxSeats members in join order and opens a
// match. Caller holds r.mu and has checked the lobby and the host.
func (s *service) startGame(r *room, now time.Time) error {
seats := r.members[:min(len(r.members), engine.MaxSeats)]
if len(seats) < engine.MinSeats {
return errNotEnoughAPI
}
rng := s.cfg.newRNG()
match, err := engine.NewMatch(s.words, len(seats), s.words.RandomOpening(rng, minOpeningOut), turnLimit, turnGrace, now)
if err != nil {
return err
}
r.match = match
r.seats = append([]*roomMember(nil), seats...)
r.chain = []roomEntry{{chainEntry: s.entry(match.Opening(), "bot", 0), Seat: noSeat}}
r.result = nil
r.game++
r.lastStart = now
for _, m := range r.seats {
m.lastMove = time.Time{}
}
r.changed()
return nil
}
// playRoomWord submits seat's word. Caller holds r.mu and has checked that a
// game runs.
func (s *service) playRoomWord(r *room, seat int, word string, now time.Time) moveResult {
mv, reason := r.match.Submit(seat, word, now)
if reason != engine.ReasonNone {
if r.match.Over() || reason == engine.ReasonTimeout {
r.changed()
}
if r.match.Over() {
s.endRoomGame(r, now)
}
return rejectedFor(reason, r.match.Current())
}
r.chain = append(r.chain, roomEntry{chainEntry: s.entry(mv.Word, "player", mv.Points), Seat: seat, Name: r.seats[seat].name})
if len(r.chain) >= maxChain {
r.match.EndMaxMoves()
}
r.changed()
if r.match.Over() {
s.endRoomGame(r, now)
}
return moveResult{Accepted: true, PlayerWord: mv.Word}
}
// resignRoom takes seat out of the running game. Caller holds r.mu.
func (s *service) resignRoom(r *room, seat int, now time.Time) {
if !r.match.Resign(seat, now) {
return
}
r.changed()
if r.match.Over() {
s.endRoomGame(r, now)
}
}
// endRoomGame records the finished game's table, returns the room to the
// lobby, then posts the result and reports every positive score in the
// background. Caller holds r.mu.
func (s *service) endRoomGame(r *room, now time.Time) {
words := make([]int, len(r.seats))
for _, e := range r.chain {
if e.Seat >= 0 {
words[e.Seat]++
}
}
res := &roomResult{
Winner: r.seats[r.match.Winner()].name,
Words: len(r.chain) - 1,
EndReason: string(r.match.EndReason()),
}
var reports []roomReport
for rank, seat := range r.match.Standings() {
m := r.seats[seat]
st := roomStanding{
userID: m.userID,
Name: m.name,
Rank: rank + 1,
Score: r.match.Score(seat),
Words: words[seat],
OutReason: string(r.match.OutReason(seat)),
}
// A game nobody played a word in (everyone left at once) earns
// nothing: no bonus, no score report and no announcement.
if seat == r.match.Winner() && res.Words > 0 {
st.Bonus = winnerBonus
st.Score += winnerBonus
st.won = true
}
res.Standings = append(res.Standings, st)
if st.Score > 0 {
reports = append(reports, roomReport{claims: claims{UserID: m.userID, ChatID: r.card.ChatID, MessageID: r.card.MessageID}, score: st.Score})
}
}
res.ScoreReported = reportPending
if len(reports) == 0 || s.cfg.reporter == nil {
res.ScoreReported = reportSkipped
}
r.result = res
for _, m := range r.members {
// Back in the lobby, the clock for leaving starts now, not at the
// last poll before a long game.
if m.lastSeen.Before(now) {
m.lastSeen = now
}
}
r.changed()
if res.Words == 0 {
return
}
text := fmt.Sprintf("Ván nối từ kết thúc: %s thắng sau %d từ!", res.Winner, res.Words)
go s.publishRoomResult(r, res, text, reports, res.ScoreReported == reportPending) //nolint:gosec // G118: the report must outlive the request that ended the game
}
// publishRoomResult records the game on the group leaderboard, announces the
// result in the chat and reports the scores one by one. Publishing holds the
// card's lock, so two games on one card never edit its game message at the
// same time. No step is fatal to the room. res.Standings is never modified
// after endRoomGame, so it is read here without the room's lock.
func (s *service) publishRoomResult(r *room, res *roomResult, text string, reports []roomReport, report bool) {
defer s.publishing.Acquire(roomCardKey(r.card))()
s.recordTop(r.card.ChatID, res.Standings)
if s.cfg.announcer != nil {
ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
if err := s.cfg.announcer.Announce(ctx, r.card, text); err != nil {
log.Warn("noitu room result message failed", "err", err)
}
cancel()
}
if !report {
return
}
status := reportOK
for _, rep := range reports {
ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
err := s.cfg.reporter.Report(ctx, rep.claims, rep.score)
cancel()
if err != nil && !scoreNotModified(err) {
status = reportFailed
log.Warn("noitu room setGameScore failed", "err", err)
}
}
r.mu.Lock()
defer r.mu.Unlock()
res.ScoreReported = status
if r.result == res {
r.changed()
}
}
+47
View File
@@ -0,0 +1,47 @@
package noitu
import (
"context"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
)
// scoreReporter records a finished game's score on the game message.
type scoreReporter interface {
Report(ctx context.Context, c claims, score int) error
}
// botReporter reports through the Bot API. A chat message goes through the
// library; an inline message needs the raw call, because the library types
// inline_message_id as an int and cannot decode Telegram's `true` result.
type botReporter struct {
b *bot.Bot
}
func (r botReporter) Report(ctx context.Context, c claims, score int) error {
return htmlgame.ReportScore(ctx, r.b, htmlgame.Address{ChatID: c.ChatID, MessageID: c.MessageID, InlineID: c.InlineID}, c.UserID, score)
}
// announcer posts a finished room game's result into the card's chat.
type announcer interface {
Announce(ctx context.Context, card claims, text string) error
}
// Announce replies to the card in its chat and forum topic, as plain text so
// player names need no escaping.
func (r botReporter) Announce(ctx context.Context, card claims, text string) error {
_, err := r.b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: card.ChatID,
MessageThreadID: card.ThreadID,
Text: text,
ReplyParameters: &models.ReplyParameters{MessageID: card.MessageID, AllowSendingWithoutReply: true},
})
return err
}
// scoreNotModified reports Telegram's answer to a score that does not beat the
// player's best. With force=false that is the expected outcome, not a failure.
func scoreNotModified(err error) bool { return htmlgame.ScoreNotModified(err) }
+278
View File
@@ -0,0 +1,278 @@
package noitu
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"slices"
"sync"
"time"
"github.com/tiennm99/tiennm99bot/internal/keylock"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/modules"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/dict"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/engine"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu/opponent"
)
const (
turnLimit = 30 * time.Second
turnGrace = 2 * time.Second
minOpeningOut = 20
maxSessions = 2000
maxUserSessions = 3
idleTTL = 15 * time.Minute
finishedTTL = 2 * time.Minute
startsPerMinute = 10
moveInterval = 300 * time.Millisecond
maxChain = 300
maxSuggestions = 3
reportTimeout = 10 * time.Second
)
// Score report states shown on the page.
const (
reportNone = ""
reportPending = "pending"
reportOK = "ok"
reportFailed = "failed"
reportSkipped = "skipped"
)
var errBusy = errors.New("noitu: too many sessions")
// chainEntry is one word of the chain as the page shows it.
type chainEntry struct {
Word string `json:"word"`
By string `json:"by"` // "bot" or "player"
Points int `json:"points"`
Meanings []dict.Sense `json:"meanings"`
}
// session is one live game. Its mutex guards every field below it; the
// store's mutex is never taken while holding it.
type session struct {
id string
claims claims
difficulty opponent.Difficulty
mu sync.Mutex
eng *engine.Engine
strategy opponent.Strategy
chain []chainEntry
suggestions []string
lastMove time.Time
lastSeen time.Time
endedAt time.Time
report string
}
// expired reports whether the session can be dropped: idle too long, or
// finished long enough ago that its result screen has been read.
func (s *session) expired(now time.Time) bool {
if s.eng.Over() {
return now.Sub(s.endedAt) > finishedTTL
}
return now.Sub(s.lastSeen) > idleTTL
}
// sessionStore indexes live sessions by id, by owner (player + game
// message) and by user, and counts recent starts per user.
type sessionStore struct {
mu sync.Mutex
byID map[string]*session
byOwner map[string]*session
byUser map[int64][]*session
starts map[int64][]time.Time
}
func newSessionStore() *sessionStore {
return &sessionStore{
byID: map[string]*session{},
byOwner: map[string]*session{},
byUser: map[int64][]*session{},
starts: map[int64][]time.Time{},
}
}
func (st *sessionStore) get(id string) *session {
st.mu.Lock()
defer st.mu.Unlock()
return st.byID[id]
}
// allowStart records a start for user and reports whether it is within
// startsPerMinute.
func (st *sessionStore) allowStart(user int64, now time.Time) bool {
st.mu.Lock()
defer st.mu.Unlock()
recent := st.starts[user][:0]
for _, t := range st.starts[user] {
if now.Sub(t) < time.Minute {
recent = append(recent, t)
}
}
if len(recent) >= startsPerMinute {
st.starts[user] = recent
return false
}
st.starts[user] = append(recent, now)
return true
}
// add registers s and returns the session it displaced, if any: the owner's
// previous session on the same game message, or else the user's oldest
// session once they hold maxUserSessions. The caller must settle the
// displaced session (see service.settle) so a game left mid-play still gets
// its score reported. Nothing changes when add fails.
func (st *sessionStore) add(s *session) (*session, error) {
st.mu.Lock()
defer st.mu.Unlock()
user := s.claims.UserID
displaced := st.byOwner[s.claims.ownerKey()]
if displaced == nil && len(st.byUser[user]) >= maxUserSessions {
displaced = st.byUser[user][0] // kept oldest first
}
if displaced == nil && len(st.byID) >= maxSessions {
return nil, errBusy
}
if displaced != nil {
st.removeLocked(displaced)
}
st.byID[s.id] = s
st.byOwner[s.claims.ownerKey()] = s
st.byUser[user] = append(st.byUser[user], s)
return displaced, nil
}
func (st *sessionStore) remove(s *session) {
st.mu.Lock()
defer st.mu.Unlock()
st.removeLocked(s)
}
func (st *sessionStore) removeLocked(s *session) {
if st.byID[s.id] == s {
delete(st.byID, s.id)
}
if key := s.claims.ownerKey(); st.byOwner[key] == s {
delete(st.byOwner, key)
}
user := s.claims.UserID
if live := slices.DeleteFunc(st.byUser[user], func(o *session) bool { return o == s }); len(live) > 0 {
st.byUser[user] = live
} else {
delete(st.byUser, user)
}
}
// all snapshots the live sessions and drops start records older than a
// minute.
func (st *sessionStore) all(now time.Time) []*session {
st.mu.Lock()
defer st.mu.Unlock()
for user, ts := range st.starts {
if len(ts) == 0 || now.Sub(ts[len(ts)-1]) >= time.Minute {
delete(st.starts, user)
}
}
out := make([]*session, 0, len(st.byID))
for _, s := range st.byID {
out = append(out, s)
}
return out
}
func newSessionID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}
// service is the module's runtime: config, the word store and the sessions.
type service struct {
cfg config
words *dict.Store // nil when the game is disabled
sessions *sessionStore
rooms *roomStore
// publishing serializes each card's room results: its announcements and
// setGameScore edits.
publishing keylock.Map
}
func (s *service) enabled() bool { return s.words != nil }
// sweep settles turns that ran out with nobody calling the API (so their
// scores are still reported) and drops expired sessions.
func (s *service) sweep(now time.Time) {
for _, sess := range s.sessions.all(now) {
sess.mu.Lock()
if sess.eng.Timeout(now) {
s.finish(sess, now)
}
drop := sess.expired(now)
sess.mu.Unlock()
if drop {
s.sessions.remove(sess)
}
}
}
func (s *service) sweepCron(context.Context, modules.Deps) error {
now := s.cfg.now()
s.sweep(now)
s.sweepRooms(now)
return nil
}
// settle ends a game the player walked away from and reports its score, as
// the sweep would on timeout. A dead-end position ends as no legal move
// rather than a resignation. Caller must not hold sess.mu.
func (s *service) settle(sess *session, now time.Time) {
sess.mu.Lock()
defer sess.mu.Unlock()
if s.resign(sess) {
s.finish(sess, now)
}
}
// resign ends the human's game: as no legal move when the position is a dead
// end, otherwise as giving up. Reports whether it ended the game. Caller
// holds sess.mu.
func (s *service) resign(sess *session) bool {
return sess.eng.NoMove() || sess.eng.GiveUp()
}
// finish records the end of a game and reports a positive score once.
// Caller holds sess.mu.
func (s *service) finish(sess *session, now time.Time) {
sess.endedAt = now
if sess.eng.Winner() == engine.Bot {
sess.suggestions = sess.eng.Suggestions(maxSuggestions)
}
score := sess.eng.Score()
if score <= 0 || s.cfg.reporter == nil {
sess.report = reportSkipped
return
}
sess.report = reportPending
go s.report(sess, score) //nolint:gosec // G118: the report must outlive the request that ended the game
}
func (s *service) report(sess *session, score int) {
ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
defer cancel()
err := s.cfg.reporter.Report(ctx, sess.claims, score)
status := reportOK
if err != nil && !scoreNotModified(err) {
status = reportFailed
log.Warn("noitu setGameScore failed", "err", err)
}
sess.mu.Lock()
sess.report = status
sess.mu.Unlock()
}
+85
View File
@@ -0,0 +1,85 @@
package noitu
import (
"strconv"
"time"
"unicode/utf8"
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
)
// tokenTTL bounds how long a Play link stays usable.
const tokenTTL = 6 * time.Hour
// maxNameRunes caps the first name carried in a token.
const maxNameRunes = 64
var (
errBadToken = htmlgame.ErrBadToken
errTokenExpired = htmlgame.ErrTokenExpired
)
// claims is what a game token asserts: who pressed Play, on which game
// message, until when. Exactly one address is set: ChatID+MessageID for a
// message the bot sent, or InlineID for one sent via the bot.
//
// PvP marks a Play press on a card /noitu registered: the page then joins
// the card's room instead of playing the bot. Only a chat message can be a
// card, and ThreadID is its forum topic, where the room posts its results.
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"`
PvP bool `json:"p,omitempty"`
ThreadID int `json:"t,omitempty"`
Expiry int64 `json:"e"`
}
// Valid and ExpiresAt let htmlgame.Verify check a decoded token.
func (c claims) Valid() bool { return c.valid() }
// ExpiresAt is the unix second the token stops working.
func (c claims) ExpiresAt() int64 { return c.Expiry }
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.PvP && !chat {
return false
}
return chat != inline
}
// ownerKey identifies one player on one game message; a new start replaces
// the live session with the same key.
func (c claims) ownerKey() string {
if c.InlineID != "" {
return strconv.FormatInt(c.UserID, 10) + "|i|" + c.InlineID
}
return strconv.FormatInt(c.UserID, 10) + "|c|" + strconv.FormatInt(c.ChatID, 10) + "|" + strconv.Itoa(c.MessageID)
}
// truncateName keeps a first name within maxNameRunes.
func truncateName(name string) string {
if utf8.RuneCountInString(name) <= maxNameRunes {
return name
}
return string([]rune(name)[:maxNameRunes])
}
// signToken encodes claims as base64url(json) "." base64url(HMAC-SHA256).
func signToken(key []byte, c claims) (string, error) { return htmlgame.Sign(key, c) }
// verifyToken checks the signature first, then the claims and the expiry.
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
}
+168
View File
@@ -0,0 +1,168 @@
:root {
--bg: #f6f7f9;
--surface: #ffffff;
--text: #1b1f24;
--muted: #5f6b7a;
--border: #d9dee5;
--accent: #2481cc;
--accent-text: #ffffff;
--bot: #eef1f5;
--player: #e3f0fb;
--other: #fbf1e3;
--error: #c0392b;
--ok: #1e8e3e;
--warn: #d68910;
--radius: 12px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #17212b;
--surface: #1f2b38;
--text: #e9eef3;
--muted: #93a1b0;
--border: #2f3d4c;
--accent: #5aa7e6;
--accent-text: #0e1621;
--bot: #263342;
--player: #1d3b55;
--other: #3d3423;
--error: #ff7b6b;
--ok: #6fcf87;
--warn: #f5b041;
}
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-text-size-adjust: 100%;
}
.app {
max-width: 560px;
margin: 0 auto;
padding: 12px 16px calc(16px + env(safe-area-inset-bottom));
min-height: 100vh;
display: flex;
flex-direction: column;
gap: 12px;
}
.top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
h1 { font-size: 1.5rem; margin: 0; }
h2 { font-size: 1.2rem; margin: 0 0 8px; }
.player-name { margin: 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.lead { margin-top: 0; font-size: 1.05rem; }
.muted { color: var(--muted); }
.rules { padding-left: 20px; color: var(--muted); }
.error { color: var(--error); min-height: 1.4em; margin: 8px 0 0; }
button {
font: inherit;
border-radius: 10px;
border: 1px solid var(--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); color: var(--accent-text); font-weight: 600; }
button.ghost { background: transparent; color: var(--muted); align-self: center; }
.difficulty { border: 1px solid var(--border); border-radius: 10px; margin: 0 0 16px; padding: 8px 12px 12px; }
.difficulty legend { padding: 0 4px; color: var(--muted); }
.difficulty label { display: inline-flex; align-items: center; gap: 6px; margin-right: 16px; min-height: 36px; }
.scoreline { display: flex; justify-content: space-between; align-items: center; font-size: 1.05rem; }
.timer-text { font-variant-numeric: tabular-nums; font-weight: 600; }
.timer-text.low { color: var(--error); }
.timer-track { height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
.timer-bar { height: 100%; width: 100%; background: var(--accent); transform-origin: left center; }
.timer-bar.low { background: var(--error); }
.move-form { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
.move-form label { color: var(--muted); }
.move-form label strong { color: var(--text); font-size: 1.15rem; }
.move-row { display: flex; gap: 8px; }
.move-row input {
flex: 1;
min-width: 0;
font: inherit;
font-size: 1.1rem;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
}
.move-row input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.message { min-height: 1.4em; margin: 0; }
.message.error { color: var(--error); }
.message.thinking { color: var(--muted); }
.message.thinking::after { content: "…"; animation: blink 1s steps(2) infinite; }
@keyframes blink { 50% { opacity: 0; } }
.chain-box { display: flex; flex-direction: column; }
.chain-title { font-size: 1rem; color: var(--muted); font-weight: 600; }
.chain { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column-reverse; gap: 6px; }
.chain > li { border-radius: 10px; padding: 8px 12px; background: var(--bot); }
.chain > li.player { background: var(--player); }
.chain .row { display: flex; align-items: center; gap: 8px; }
.chain .who { font-size: .8rem; color: var(--muted); min-width: 34px; max-width: 35%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chain > li.other { background: var(--other); }
.chain .word { font-weight: 600; flex: 1; overflow-wrap: anywhere; }
.chain .points { font-size: .85rem; color: var(--ok); }
.chain .toggle { padding: 2px 8px; min-height: 30px; font-size: .8rem; }
.chain .no-meaning { font-size: .8rem; color: var(--muted); }
.chain ol.senses { margin: 6px 0 0; padding-left: 22px; font-size: .9rem; color: var(--muted); }
.chain > li.fresh { animation: pop .35s ease-out; }
@keyframes pop { from { transform: scale(.97); opacity: .4; } to { transform: none; opacity: 1; } }
.players { list-style: none; margin: 12px 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.players > li { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 10px; background: var(--bot); }
.players > li.you { background: var(--player); }
.players .name { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.players .tag { font-size: .8rem; color: var(--muted); }
.players .score { font-variant-numeric: tabular-nums; color: var(--ok); }
.players.compact { flex-direction: row; flex-wrap: wrap; margin: 0; }
.players.compact > li { padding: 4px 10px; font-size: .9rem; max-width: 100%; }
.players.compact > li.turn { outline: 2px solid var(--accent); }
.players > li.out { opacity: .55; }
.players > li.out .name { text-decoration: line-through; }
.turn-line { margin: 0; font-weight: 600; }
.turn-line.mine { color: var(--accent); }
.room-result { border-bottom: 1px solid var(--border); margin-bottom: 12px; padding-bottom: 8px; }
.standings { padding-left: 22px; margin: 8px 0; }
.standings li.you { font-weight: 600; }
.final-score { font-size: 1.2rem; }
.suggestions { padding-left: 20px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.report-ok { color: var(--ok); }
.report-failed { color: var(--warn); }
.footer { margin-top: auto; padding-top: 8px; font-size: .8rem; color: var(--muted); text-align: center; }
.footer a { color: inherit; }
@media (prefers-reduced-motion: reduce) {
.chain > li.fresh, .message.thinking::after { animation: none; }
}
+760
View File
@@ -0,0 +1,760 @@
// Nối từ game page. Plain JavaScript, no build step. The server owns the game:
// this page only sends words and renders the state it gets back.
(function () {
'use strict';
/**
* @typedef {{pos: string, gloss: string}} Sense
* @typedef {{word: string, by: 'bot'|'player', points: number, meanings: Sense[]}} ChainEntry
* @typedef {{
* session: string, player: string, difficulty: string,
* status: 'playing'|'won'|'lost', end_reason: string, current: string,
* chain: ChainEntry[], score: number, turn_limit_ms: number,
* deadline_ms: number, server_now_ms: number, suggestions: string[],
* score_reported: string
* }} SessionView
* @typedef {{accepted: boolean, reason: string, message: string, player_word: string, bot_word: string}} MoveResult
* @typedef {{name: string, you: boolean, host: boolean, score: number, alive: boolean, out_reason: string}} RoomPlayer
* @typedef {{name: string, rank: number, score: number, bonus: number, words: number, out_reason: string, you: boolean}} RoomStanding
* @typedef {{winner: string, words: number, end_reason: string, standings: RoomStanding[], score_reported: string}} RoomResult
* @typedef {ChainEntry & {seat: number, name?: string}} RoomEntry
* @typedef {{
* member: string, version: number, status: 'lobby'|'playing', game: number,
* host: boolean, seat: number, turn: number, players: RoomPlayer[],
* watchers: number, min_players: number, max_players: number,
* current: string, chain: RoomEntry[], turn_limit_ms: number,
* deadline_ms: number, server_now_ms: number, result: RoomResult|null
* }} RoomView
*/
/** Grace the server allows after the deadline, plus a little slack. */
var GRACE_MS = 2300;
/** Shortest "bot đang nghĩ" pause, so the reply does not feel instant. */
var THINK_MS = 350;
var REPORT_POLL_MS = 1500;
var REPORT_POLLS = 10;
/** Room state polling while the page is visible, and while it is hidden. */
var POLL_MS = 1000;
var POLL_HIDDEN_MS = 4000;
/** @param {string} id @returns {HTMLElement} */
function $(id) {
var el = document.getElementById(id);
if (!el) throw new Error('missing #' + id);
return el;
}
var screens = {
missing: $('screen-missing'),
start: $('screen-start'),
lobby: $('screen-lobby'),
game: $('screen-game'),
end: $('screen-end')
};
var chainBox = $('chain-box');
var chainList = $('chain');
var wordInput = /** @type {HTMLInputElement} */ ($('word'));
var sendBtn = /** @type {HTMLButtonElement} */ ($('send-btn'));
var startBtn = /** @type {HTMLButtonElement} */ ($('start-btn'));
var giveUpBtn = /** @type {HTMLButtonElement} */ ($('give-up'));
var message = $('message');
var timerBar = $('timer-bar');
var timerText = $('timer-text');
var TOKEN_KEY = 'noitu-token';
var token = readToken();
/** A token from a /noitu card opens the card's room instead of a game vs the bot. */
var roomMode = isRoomToken(token);
/**
* Takes the signed token from the URL, then strips it from the address bar
* so copying, sharing or opening the page elsewhere does not leak it. The
* hash stays: Telegram passes its own parameters there. sessionStorage keeps
* the token 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 ''; }
}
/**
* Reads the PvP flag from the token's payload. The payload is signed, not
* secret; the server checks the flag again, so this only picks the screen.
* @param {string} t @returns {boolean}
*/
function isRoomToken(t) {
try {
var body = t.split('.')[0].replace(/-/g, '+').replace(/_/g, '/');
while (body.length % 4) body += '=';
return JSON.parse(window.atob(body)).p === true;
} catch (e) {
return false;
}
}
/** @type {SessionView|null} */
var view = null;
/** @type {RoomView|null} */
var room = null;
var pollHandle = 0;
var rejoined = false;
/** Game number whose chain is on screen, so a new game clears it. */
var shownGame = -1;
/** Game and deadline of the room turn on screen, so a new turn clears the message. */
var shownTurn = '';
/** Set while a room error shows a lost connection, so the next good poll clears it. */
var offlineShown = false;
/** Turn length of the countdown on screen. */
var turnLimitMs = 30000;
/** Local wall-clock time at which the current turn ends. */
var deadlineLocal = 0;
var timerHandle = 0;
var expiryHandle = 0;
var reportPolls = 0;
var busy = false;
var giveUpArmed = 0;
/** Words whose meanings are expanded, so a re-render keeps them open. */
var expanded = Object.create(null);
/** @param {keyof typeof screens} name */
function show(name) {
Object.keys(screens).forEach(function (key) {
screens[/** @type {keyof typeof screens} */ (key)].hidden = key !== name;
});
chainBox.hidden = !(name === 'game' || name === 'end' || (name === 'lobby' && chainList.children.length > 0));
}
/** @param {number} ms @returns {Promise<void>} */
function delay(ms) {
return new Promise(function (resolve) { setTimeout(resolve, ms); });
}
/**
* POSTs a JSON body to the game API. Rejects with {code, message} on a
* non-2xx answer or a network failure.
* @param {string} path @param {object} body @returns {Promise<any>}
*/
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 || 'Có lỗi xảy ra, thử lại sau.' };
}
return data;
});
}, function () {
throw { code: 'network', message: 'Mất kết nối tới máy chủ. Kiểm tra mạng rồi thử lại.' };
});
}
/** @param {string} text @param {string} [kind] */
function say(text, kind) {
message.textContent = text;
message.className = 'message' + (kind ? ' ' + kind : '');
}
/**
* Who played a chain word: the bot or "Bạn" in a game vs the bot; in a
* room, the opening, "Bạn", or the player's name.
* @param {ChainEntry|RoomEntry} entry @returns {{label: string, cls: string}}
*/
function author(entry) {
if (!roomMode) return entry.by === 'bot' ? { label: 'Bot', cls: 'bot' } : { label: 'Bạn', cls: 'player' };
var e = /** @type {RoomEntry} */ (entry);
if (e.by === 'bot') return { label: 'Mở đầu', cls: 'bot' };
if (room && room.seat >= 0 && e.seat === room.seat) return { label: 'Bạn', cls: 'player' };
return { label: e.name || '', cls: 'other' };
}
/** @param {ChainEntry|RoomEntry} entry @param {boolean} fresh @returns {HTMLLIElement} */
function chainItem(entry, fresh) {
var li = document.createElement('li');
var by = author(entry);
li.className = by.cls + (fresh ? ' fresh' : '');
var row = document.createElement('div');
row.className = 'row';
var who = document.createElement('span');
who.className = 'who';
who.textContent = by.label;
who.title = by.label;
var word = document.createElement('span');
word.className = 'word';
word.textContent = entry.word;
row.appendChild(who);
row.appendChild(word);
if (entry.by === 'player' && entry.points > 0) {
var pts = document.createElement('span');
pts.className = 'points';
pts.textContent = '+' + entry.points;
row.appendChild(pts);
}
li.appendChild(row);
if (!entry.meanings || entry.meanings.length === 0) {
var none = document.createElement('span');
none.className = 'no-meaning';
none.textContent = 'Chưa có nghĩa';
row.appendChild(none);
return li;
}
var senses = document.createElement('ol');
senses.className = 'senses';
entry.meanings.forEach(function (sense) {
var item = document.createElement('li');
item.textContent = (sense.pos ? '(' + sense.pos + ') ' : '') + sense.gloss;
senses.appendChild(item);
});
var toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'toggle';
function sync() {
var open = !!expanded[entry.word];
senses.hidden = !open;
toggle.textContent = open ? 'Ẩn nghĩa' : 'Xem nghĩa';
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
toggle.setAttribute('aria-label', (open ? 'Ẩn nghĩa của ' : 'Xem nghĩa của ') + entry.word);
}
toggle.addEventListener('click', function () {
expanded[entry.word] = !expanded[entry.word];
sync();
});
sync();
row.appendChild(toggle);
li.appendChild(senses);
return li;
}
/** @param {Array<ChainEntry|RoomEntry>} chain */
function renderChain(chain) {
var before = chainList.children.length;
if (chain.length < before) {
chainList.textContent = '';
before = 0;
}
for (var i = before; i < chain.length; i++) {
chainList.appendChild(chainItem(chain[i], before > 0));
}
}
function stopTimers() {
clearInterval(timerHandle);
clearTimeout(expiryHandle);
timerHandle = 0;
expiryHandle = 0;
}
function tick() {
var left = Math.max(0, deadlineLocal - Date.now());
var ratio = turnLimitMs > 0 ? left / turnLimitMs : 0;
timerBar.style.transform = 'scaleX(' + Math.min(1, ratio).toFixed(3) + ')';
var low = left <= 5000;
timerBar.classList.toggle('low', low);
timerText.classList.toggle('low', low);
timerText.textContent = String(Math.ceil(left / 1000));
if (left <= 0 && timerHandle) {
clearInterval(timerHandle);
timerHandle = 0;
say('Hết giờ…', 'error');
}
}
/**
* Starts the countdown from the server's deadline, not the local clock.
* Once the grace period has passed, onExpire asks the server to settle it.
* @param {{deadline_ms: number, server_now_ms: number, turn_limit_ms: number}} v
* @param {() => void} onExpire
*/
function startCountdown(v, onExpire) {
stopTimers();
var left = v.deadline_ms - v.server_now_ms;
turnLimitMs = v.turn_limit_ms;
deadlineLocal = Date.now() + left;
tick();
timerHandle = window.setInterval(tick, 100);
expiryHandle = window.setTimeout(onExpire, Math.max(0, left) + GRACE_MS);
}
/** @param {SessionView} next */
function render(next) {
view = next;
$('player').textContent = next.player || '';
$('score').textContent = String(next.score);
$('current').textContent = next.current;
renderChain(next.chain);
if (next.status === 'playing') {
show('game');
startCountdown(next, refresh);
return;
}
stopTimers();
renderEnd(next);
}
var endText = {
bot_stuck: ['Bạn thắng!', 'Bot hết từ để nối.'],
max_moves: ['Bạn thắng!', 'Chuỗi đã đủ dài, bot chịu thua.'],
timeout: ['Bot thắng', 'Bạn đã hết giờ.'],
no_legal_move: ['Bot thắng', 'Không còn từ nào nối được — đây là ngõ cụt.'],
gave_up: ['Bot thắng', 'Bạn đã chịu thua.']
};
var reportText = {
pending: 'Đang lưu điểm lên bảng xếp hạng Telegram…',
ok: 'Đã lưu điểm vào bảng xếp hạng Telegram.',
failed: 'Không lưu được điểm lên Telegram.',
skipped: 'Không có điểm để lưu.'
};
/** @param {SessionView} v */
function renderEnd(v) {
show('end');
var text = endText[/** @type {keyof typeof endText} */ (v.end_reason)] ||
(v.status === 'won' ? ['Bạn thắng!', ''] : ['Bot thắng', '']);
$('result-title').textContent = text[0];
$('result-detail').textContent = text[1];
$('final-score').textContent = String(v.score);
var report = $('report-status');
report.textContent = reportText[/** @type {keyof typeof reportText} */ (v.score_reported)] || '';
report.className = v.score_reported === 'ok' ? 'report-ok' : v.score_reported === 'failed' ? 'report-failed' : 'muted';
var box = $('suggestions-box');
var list = $('suggestions');
list.textContent = '';
if (v.status === 'lost' && v.suggestions.length > 0) {
$('suggestions-label').textContent = 'Bạn có thể đã nối:';
v.suggestions.forEach(function (w) {
var li = document.createElement('li');
li.textContent = w;
list.appendChild(li);
});
box.hidden = false;
} else {
box.hidden = true;
}
$('share').hidden = !(window.TelegramGameProxy && typeof window.TelegramGameProxy.shareScore === 'function');
if (v.score_reported === 'pending' && reportPolls < REPORT_POLLS) {
reportPolls++;
expiryHandle = window.setTimeout(refresh, REPORT_POLL_MS);
}
}
/** @param {{code: string, message: string}} err */
function handleError(err) {
if (err.code === 'no_session') {
stopTimers();
view = null;
chainList.textContent = '';
expanded = Object.create(null);
show('start');
$('start-error').textContent = err.message;
return;
}
if (err.code === 'game_over') {
refresh();
return;
}
say(err.message, 'error');
}
function refresh() {
if (!view) return;
api('state', { session: view.session }).then(render, handleError);
}
function startGame() {
if (busy) return;
busy = true;
startBtn.disabled = true;
$('start-error').textContent = '';
var picked = /** @type {HTMLInputElement|null} */ (document.querySelector('input[name="difficulty"]:checked'));
api('start', { token: token, difficulty: picked ? picked.value : 'medium' }).then(function (v) {
chainList.textContent = '';
expanded = Object.create(null);
reportPolls = 0;
say('');
wordInput.value = '';
render(v);
wordInput.focus();
}, function (err) {
$('start-error').textContent = err.message;
}).then(function () {
busy = false;
startBtn.disabled = false;
});
}
/** @param {Event} event */
function submitWord(event) {
event.preventDefault();
var word = wordInput.value.trim();
if (roomMode) {
submitRoomWord(word);
return;
}
if (!view || busy || word === '') return;
busy = true;
sendBtn.disabled = true;
say('Bot đang nghĩ', 'thinking');
Promise.all([api('move', { session: view.session, word: word }), delay(THINK_MS)]).then(function (out) {
/** @type {{result: MoveResult, state: SessionView}} */
var res = out[0];
if (res.result.accepted) {
wordInput.value = '';
say(res.result.bot_word ? 'Bot nối: ' + res.result.bot_word : '');
} else {
say(res.result.message || 'Từ không hợp lệ.', 'error');
wordInput.select();
}
render(res.state);
}, handleError).then(function () {
busy = false;
sendBtn.disabled = false;
if (view && view.status === 'playing') wordInput.focus();
});
}
function giveUp() {
if ((roomMode ? !room : !view) || busy) return;
if (!giveUpArmed) {
giveUpBtn.textContent = 'Bấm lần nữa để chịu thua';
giveUpArmed = window.setTimeout(function () {
giveUpArmed = 0;
giveUpBtn.textContent = 'Chịu thua';
}, 3000);
return;
}
clearTimeout(giveUpArmed);
giveUpArmed = 0;
giveUpBtn.textContent = 'Chịu thua';
if (roomMode) {
if (room) api('room/give-up', { member: room.member }).then(renderRoom, roomError);
return;
}
if (view) api('give-up', { session: view.session }).then(render, handleError);
}
// Room mode: the members of a chat play each other on one /noitu card.
// The page polls the room's state; the server owns the turns and the clock.
var roomStartBtn = /** @type {HTMLButtonElement} */ ($('room-start'));
var turnLine = $('turn-line');
var outText = {
timeout: 'hết giờ',
no_legal_move: 'hết từ để nối',
gave_up: 'chịu thua'
};
/** @param {number} [ms] */
function schedulePoll(ms) {
clearTimeout(pollHandle);
var wait = ms === undefined ? (document.hidden ? POLL_HIDDEN_MS : POLL_MS) : ms;
pollHandle = window.setTimeout(poll, wait);
}
function poll() {
if (!room) return;
api('room/state', { member: room.member, version: room.version }).then(function (res) {
if (offlineShown) {
offlineShown = false;
$('lobby-error').textContent = '';
say('');
}
if (!res.unchanged) renderRoom(res);
}, roomError).then(function () {
if (room) schedulePoll();
});
}
/** Joins the card's room with the page token; a reload rejoins the same seat. */
function joinRoom() {
stopTimers();
clearTimeout(pollHandle);
show('lobby');
roomStartBtn.hidden = true;
$('lobby-error').textContent = '';
$('lobby-wait').textContent = 'Đang vào phòng…';
return api('room/join', { token: token }).then(function (v) {
rejoined = false;
renderRoom(v);
schedulePoll();
}, function (err) {
room = null;
$('lobby-wait').textContent = '';
$('lobby-error').textContent = err.message;
roomStartBtn.textContent = 'Vào lại phòng';
roomStartBtn.disabled = false;
roomStartBtn.hidden = err.code === 'bad_token' || err.code === 'token_expired';
});
}
/** @param {{code: string, message: string}} err */
function roomError(err) {
if (err.code === 'no_room' && !rejoined) {
// The room expired or dropped this member; the token still names the card.
rejoined = true;
room = null;
return joinRoom();
}
if (err.code === 'no_room') {
room = null;
stopTimers();
show('lobby');
$('lobby-wait').textContent = '';
$('lobby-error').textContent = err.message;
return;
}
// A rate-limited move retries on the next tap; a rate-limited start says so.
if (err.code === 'too_fast' && room && room.status === 'playing') return;
offlineShown = err.code === 'network';
if (err.code === 'no_game' || err.code === 'game_running' || err.code === 'not_in_game') {
schedulePoll(0);
}
if (room && room.status === 'playing') {
say(err.message, 'error');
} else {
$('lobby-error').textContent = err.message;
}
}
/**
* @param {RoomPlayer} p @param {string} tag
* @returns {HTMLLIElement}
*/
function playerItem(p, tag) {
var li = document.createElement('li');
if (p.you) li.className = 'you';
var name = document.createElement('span');
name.className = 'name';
name.textContent = p.name + (p.you ? ' (bạn)' : '');
li.appendChild(name);
if (tag) {
var t = document.createElement('span');
t.className = 'tag';
t.textContent = tag;
li.appendChild(t);
}
return li;
}
/** @param {RoomView} v */
function renderRoom(v) {
room = v;
if (v.game !== shownGame) {
chainList.textContent = '';
expanded = Object.create(null);
$('lobby-error').textContent = '';
shownGame = v.game;
}
renderChain(v.chain);
/** @type {RoomPlayer|null} */
var me = null;
v.players.forEach(function (p) { if (p.you) me = p; });
$('player').textContent = me ? me.name : '';
if (v.status === 'playing') {
renderRoomGame(v, me);
} else {
renderLobby(v, me);
}
}
/** @param {RoomView} v @param {RoomPlayer|null} me */
function renderLobby(v, me) {
stopTimers();
show('lobby');
var list = $('lobby-players');
list.textContent = '';
v.players.forEach(function (p) {
list.appendChild(playerItem(p, p.host ? 'chủ phòng' : ''));
});
var watchers = $('lobby-watchers');
watchers.hidden = v.watchers === 0;
watchers.textContent = 'Thêm ' + v.watchers + ' người đang chờ: mỗi ván có tối đa ' + v.max_players + ' người chơi.';
var enough = v.players.length >= v.min_players;
roomStartBtn.textContent = 'Bắt đầu';
roomStartBtn.hidden = !v.host;
roomStartBtn.disabled = busy || !enough;
var wait = '';
if (v.host && !enough) {
wait = 'Cần ít nhất ' + v.min_players + ' người. Mời mọi người bấm Chơi trên thẻ trò chơi.';
} else if (!v.host) {
wait = me ? 'Chờ chủ phòng bắt đầu…' : 'Phòng đã đủ người chơi; bạn sẽ được xem ván tiếp theo.';
}
$('lobby-wait').textContent = wait;
renderResult(v.result);
}
/** @param {RoomResult|null} res */
function renderResult(res) {
var box = $('room-result');
if (!res) {
box.hidden = true;
return;
}
box.hidden = false;
$('room-result-title').textContent = res.winner + ' thắng!';
$('room-result-detail').textContent = 'Chuỗi có ' + res.words + ' từ.' +
(res.end_reason === 'max_moves' ? ' Chuỗi đã đủ dài, người nhiều điểm nhất thắng.' : '');
var list = $('room-standings');
list.textContent = '';
res.standings.forEach(function (st) {
var li = document.createElement('li');
if (st.you) li.className = 'you';
var why = outText[/** @type {keyof typeof outText} */ (st.out_reason)];
li.textContent = st.name + (st.you ? ' (bạn)' : '') + ': ' + st.score + ' điểm, ' + st.words + ' từ' +
(st.bonus > 0 ? ' (thưởng ' + st.bonus + ')' : '') + (why ? ' — ' + why : '');
list.appendChild(li);
});
var report = $('room-report');
report.textContent = reportText[/** @type {keyof typeof reportText} */ (res.score_reported)] || '';
report.className = res.score_reported === 'ok' ? 'report-ok' : res.score_reported === 'failed' ? 'report-failed' : 'muted';
}
/** @param {RoomView} v @param {RoomPlayer|null} me */
function renderRoomGame(v, me) {
var wasMine = !wordInput.disabled && !screens.game.hidden;
show('game');
var strip = $('room-players');
strip.hidden = false;
strip.textContent = '';
v.players.forEach(function (p, i) {
var li = playerItem(p, p.alive ? String(p.score) : (outText[/** @type {keyof typeof outText} */ (p.out_reason)] || 'bị loại'));
if (i === v.turn) li.classList.add('turn');
if (!p.alive) li.classList.add('out');
strip.appendChild(li);
});
var mine = v.seat >= 0 && v.seat === v.turn;
// A new turn clears the last one's message, such as "Hết giờ…"; a
// re-render within the turn keeps it, such as a rejected word's reason.
var turnKey = v.game + ':' + v.deadline_ms;
if (turnKey !== shownTurn) {
shownTurn = turnKey;
say('');
}
var turnName = v.players[v.turn] ? v.players[v.turn].name : '';
turnLine.hidden = false;
turnLine.className = 'turn-line' + (mine ? ' mine' : '');
if (mine) {
turnLine.textContent = 'Đến lượt bạn!';
} else if (!me) {
turnLine.textContent = 'Bạn đang xem. Lượt của ' + turnName + '.';
} else if (!me.alive) {
turnLine.textContent = 'Bạn đã bị loại. Lượt của ' + turnName + '.';
} else {
turnLine.textContent = 'Lượt của ' + turnName + '.';
}
$('score').textContent = String(me ? me.score : 0);
$('current').textContent = v.current;
wordInput.disabled = !mine;
sendBtn.disabled = busy || !mine;
giveUpBtn.hidden = !(me && me.alive);
if (mine && !wasMine) wordInput.focus();
startCountdown(v, function () { schedulePoll(0); });
}
/**
* Ends a room request: clears busy and re-enables the control the room now
* needs, which a render during the request left disabled.
*/
function finishRoomAction() {
busy = false;
if (!room) return;
if (room.status === 'playing') {
if (room.seat >= 0 && room.seat === room.turn) {
sendBtn.disabled = false;
wordInput.focus();
}
return;
}
roomStartBtn.disabled = room.players.length < room.min_players;
}
/** @param {string} word */
function submitRoomWord(word) {
if (!room || busy || word === '') return;
busy = true;
sendBtn.disabled = true;
say('');
api('room/move', { member: room.member, word: word }).then(function (res) {
// Render first: a late word moves the turn on, and the new turn
// clears the message.
renderRoom(res.state);
if (res.result.accepted) {
wordInput.value = '';
} else {
say(res.result.message || 'Từ không hợp lệ.', 'error');
wordInput.select();
}
}, roomError).then(finishRoomAction);
}
function startRoomGame() {
if (busy) return;
if (!room) {
joinRoom();
return;
}
busy = true;
roomStartBtn.disabled = true;
$('lobby-error').textContent = '';
api('room/start', { member: room.member }).then(function (v) {
say('');
wordInput.value = '';
renderRoom(v);
}, roomError).then(finishRoomAction);
}
function share() {
try {
if (window.TelegramGameProxy) window.TelegramGameProxy.shareScore();
} catch (e) {
$('share').hidden = true;
}
}
startBtn.addEventListener('click', startGame);
$('move-form').addEventListener('submit', submitWord);
giveUpBtn.addEventListener('click', giveUp);
$('replay').addEventListener('click', function () {
stopTimers();
show('start');
});
$('share').addEventListener('click', share);
roomStartBtn.addEventListener('click', startRoomGame);
// A tab that slept (phone locked) has a stale countdown; resync on return.
document.addEventListener('visibilitychange', function () {
if (document.hidden) return;
if (roomMode && room) {
schedulePoll(0);
} else if (view && view.status === 'playing') {
refresh();
}
});
if (!token) {
show('missing');
} else if (roomMode) {
joinRoom();
} else {
show('start');
}
})();
+108
View File
@@ -0,0 +1,108 @@
<!doctype html>
<html lang="vi">
<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="light dark">
<title>Nối từ</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
<main class="app">
<header class="top">
<h1>Nối từ</h1>
<p class="player-name" id="player"></p>
</header>
<section id="screen-missing" class="card" hidden>
<p class="lead">Hãy mở trò chơi từ Telegram.</p>
<p class="muted">Gửi <code>/noitubot</code> cho bot rồi bấm nút Chơi. Để chơi cùng bạn bè, gửi <code>/noitu</code> trong nhóm.</p>
</section>
<section id="screen-start" class="card" hidden>
<p class="lead">Nối tiếp chuỗi từ với bot: từ của bạn phải bắt đầu bằng tiếng cuối của từ trước.</p>
<ul class="rules">
<li>Mỗi từ có ít nhất 2 tiếng và phải có trong từ điển.</li>
<li>Không dùng lại từ đã chơi.</li>
<li>Mỗi lượt có 30 giây. Trả lời nhanh, dùng từ dài hoặc từ hiếm để được nhiều điểm.</li>
</ul>
<fieldset class="difficulty">
<legend>Độ khó</legend>
<label><input type="radio" name="difficulty" value="easy"> Dễ</label>
<label><input type="radio" name="difficulty" value="medium" checked> Vừa</label>
<label><input type="radio" name="difficulty" value="hard"> Khó</label>
</fieldset>
<button type="button" id="start-btn" class="primary">Bắt đầu</button>
<p id="start-error" class="error" role="alert"></p>
</section>
<section id="screen-lobby" class="card" hidden>
<div id="room-result" class="room-result" hidden>
<h2 id="room-result-title"></h2>
<p id="room-result-detail" class="muted"></p>
<ol id="room-standings" class="standings"></ol>
<p id="room-report" class="muted"></p>
</div>
<h2>Phòng chơi nhóm</h2>
<p class="lead">Mọi người bấm Chơi trên cùng thẻ trò chơi để vào phòng, rồi lần lượt nối từ theo thứ tự vào phòng.</p>
<ul class="rules">
<li>Mỗi lượt có 30 giây. Hết giờ hoặc chịu thua là bị loại.</li>
<li>Người còn lại cuối cùng thắng và được thêm 50 điểm.</li>
<li>Từ 2 đến 8 người chơi; người vào sau xem ván đang diễn ra.</li>
</ul>
<ul id="lobby-players" class="players"></ul>
<p id="lobby-watchers" class="muted" hidden></p>
<button type="button" id="room-start" class="primary" hidden>Bắt đầu</button>
<p id="lobby-wait" class="muted"></p>
<p id="lobby-error" class="error" role="alert"></p>
</section>
<section id="screen-game" hidden>
<ul id="room-players" class="players compact" hidden></ul>
<p id="turn-line" class="turn-line" hidden></p>
<div class="scoreline">
<span>Điểm: <strong id="score">0</strong></span>
<span id="timer-text" class="timer-text">30</span>
</div>
<div class="timer-track" aria-hidden="true"><div id="timer-bar" class="timer-bar"></div></div>
<form id="move-form" class="move-form" autocomplete="off">
<label for="word">Từ bắt đầu bằng “<strong id="current"></strong>”</label>
<div class="move-row">
<input id="word" name="word" type="text" maxlength="64" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="send" required>
<button type="submit" id="send-btn" class="primary">Gửi</button>
</div>
</form>
<p id="message" class="message" role="status" aria-live="polite"></p>
<button type="button" id="give-up" class="ghost">Chịu thua</button>
</section>
<section id="screen-end" class="card" hidden>
<h2 id="result-title"></h2>
<p id="result-detail"></p>
<p class="final-score">Điểm: <strong id="final-score">0</strong></p>
<p id="report-status" class="muted"></p>
<div id="suggestions-box" hidden>
<p id="suggestions-label">Bạn có thể đã nối:</p>
<ul id="suggestions" class="suggestions"></ul>
</div>
<div class="actions">
<button type="button" id="replay" class="primary">Chơi lại</button>
<button type="button" id="share" hidden>Chia sẻ điểm</button>
</div>
</section>
<section id="chain-box" class="chain-box" hidden>
<h2 class="chain-title">Chuỗi từ</h2>
<ol id="chain" class="chain"></ol>
</section>
<footer class="footer">
Từ điển: <a href="https://vi.wiktionary.org/" target="_blank" rel="noopener noreferrer">Wiktionary tiếng Việt</a>
(<a href="https://creativecommons.org/licenses/by-sa/4.0/deed.vi" target="_blank" rel="noopener noreferrer">CC BY-SA 4.0</a>)
</footer>
</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 noitu game 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
+55
View File
@@ -38,6 +38,8 @@ type Registry struct {
crons map[string]Cron // name → Cron, unique across modules
cronDeps map[string]Deps // cron name → owning module's Deps
callbacks map[string]Callback
games map[string]Game // short name → Game, unique across modules
routes map[string]Route // pattern → Route, unique across modules
commandHooks []func(ctx context.Context, name string, update *models.Update)
fallback *CommandFallback // at most one; owner tracked in Build
inline *InlineQuery // at most one; owner tracked in Build
@@ -197,12 +199,15 @@ func Build(enabled []string, factories map[string]Factory, provider storage.Prov
crons: map[string]Cron{},
cronDeps: map[string]Deps{},
callbacks: map[string]Callback{},
games: map[string]Game{},
routes: map[string]Route{},
botUsername: opts.BotUsername,
}
owners := map[string]string{} // command name → module that registered it
cronOwners := map[string]string{}
callbackOwners := map[string]string{}
gameOwners := map[string]string{}
var fallbackOwner, inlineOwner string
seenModule := map[string]bool{}
var unknown []string
@@ -261,6 +266,14 @@ func Build(enabled []string, factories map[string]Factory, provider storage.Prov
return nil, err
}
if err := reg.addGames(name, mod.Games, gameOwners); err != nil {
return nil, err
}
if err := reg.addRoutes(name, mod.HTTP); err != nil {
return nil, err
}
if err := reg.addSingletons(name, mod, &fallbackOwner, &inlineOwner); err != nil {
return nil, err
}
@@ -291,6 +304,48 @@ func (r *Registry) addCallbacks(module string, callbacks []Callback, owners map[
return nil
}
// addGames validates and indexes one module's BotFather games. A short name
// is matched exactly, so two modules claiming the same one is a conflict.
func (r *Registry) addGames(module string, games []Game, owners map[string]string) error {
for _, g := range games {
if err := validateGame(g); err != nil {
return fmt.Errorf("module %q: %w", module, err)
}
if prev, dup := owners[g.ShortName]; dup {
return fmt.Errorf("game conflict: %q defined in %q and %q", g.ShortName, prev, module)
}
owners[g.ShortName] = module
r.games[g.ShortName] = g
}
return nil
}
// addRoutes validates and indexes one module's HTTP routes. validateRoute
// confines each module to its own /games/<module>/ subtree, so a duplicate can
// only come from the same module declaring a pattern twice.
func (r *Registry) addRoutes(module string, routes []Route) error {
for _, rt := range routes {
if err := validateRoute(module, rt); err != nil {
return fmt.Errorf("module %q: %w", module, err)
}
if _, dup := r.routes[rt.Pattern]; dup {
return fmt.Errorf("route conflict: %q declared twice in %q", rt.Pattern, module)
}
r.routes[rt.Pattern] = rt
}
return nil
}
// HTTPRoutes returns every module-contributed HTTP route, sorted by pattern.
func (r *Registry) HTTPRoutes() []Route {
out := make([]Route, 0, len(r.routes))
for _, rt := range r.routes {
out = append(out, rt)
}
sort.Slice(out, func(i, j int) bool { return out[i].Pattern < out[j].Pattern })
return out
}
func sortedCommands(m map[string]Command) []Command {
out := make([]Command, 0, len(m))
for _, c := range m {
+66
View File
@@ -3,6 +3,7 @@ package modules
import (
"context"
"errors"
"net/http"
"strings"
"testing"
@@ -422,3 +423,68 @@ func TestBuild_RejectsHandlerlessFallbackAndInline(t *testing.T) {
})
}
}
func TestBuild_DetectsGameConflict(t *testing.T) {
game := Game{ShortName: "noitu", Visibility: VisibilityPublic, Handler: okHandler}
factories := map[string]Factory{
"alpha": func(_ Deps) Module { return Module{Games: []Game{game}} },
"beta": func(_ Deps) Module { return Module{Games: []Game{game}} },
}
_, err := Build([]string{"alpha", "beta"}, factories, newProvider(), BuildOptions{})
if err == nil || !strings.Contains(err.Error(), "game conflict") {
t.Fatalf("expected game conflict, got %v", err)
}
}
func TestBuild_RejectsInvalidGame(t *testing.T) {
factories := map[string]Factory{
"alpha": func(_ Deps) Module {
return Module{Games: []Game{{ShortName: "no-itu", Visibility: VisibilityPublic, Handler: okHandler}}}
},
}
_, err := Build([]string{"alpha"}, factories, newProvider(), BuildOptions{})
if err == nil || !strings.Contains(err.Error(), `module "alpha"`) {
t.Fatalf("expected game validation error naming the module, got %v", err)
}
}
func TestBuild_IndexesHTTPRoutesSorted(t *testing.T) {
h := http.NotFoundHandler()
factories := map[string]Factory{
"zeta": func(_ Deps) Module { return Module{HTTP: []Route{{Pattern: "/games/zeta/", Handler: h}}} },
"alpha": func(_ Deps) Module { return Module{HTTP: []Route{{Pattern: "/games/alpha/", Handler: h}}} },
}
reg, err := Build([]string{"zeta", "alpha"}, factories, newProvider(), BuildOptions{})
if err != nil {
t.Fatalf("Build: %v", err)
}
routes := reg.HTTPRoutes()
if len(routes) != 2 || routes[0].Pattern != "/games/alpha/" || routes[1].Pattern != "/games/zeta/" {
t.Fatalf("HTTPRoutes = %+v, want alpha then zeta", routes)
}
}
func TestBuild_RejectsRouteOutsideModuleSubtree(t *testing.T) {
h := http.NotFoundHandler()
for _, pattern := range []string{"/", "/games/other/", "/games/alpha", "GET /games/alpha/", "example.com/games/alpha/", ""} {
factories := map[string]Factory{
"alpha": func(_ Deps) Module { return Module{HTTP: []Route{{Pattern: pattern, Handler: h}}} },
}
if _, err := Build([]string{"alpha"}, factories, newProvider(), BuildOptions{}); err == nil {
t.Errorf("pattern %q: expected validation error", pattern)
}
}
}
func TestBuild_RejectsDuplicateRoute(t *testing.T) {
h := http.NotFoundHandler()
factories := map[string]Factory{
"alpha": func(_ Deps) Module {
return Module{HTTP: []Route{{Pattern: "/games/alpha/", Handler: h}, {Pattern: "/games/alpha/", Handler: h}}}
},
}
_, err := Build([]string{"alpha"}, factories, newProvider(), BuildOptions{})
if err == nil || !strings.Contains(err.Error(), "route conflict") {
t.Fatalf("expected route conflict, got %v", err)
}
}
+107 -6
View File
@@ -2,13 +2,17 @@ package stats
import (
"context"
"errors"
"fmt"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"github.com/tiennm99/tiennm99bot/internal/storage"
"github.com/tiennm99/tiennm99bot/internal/systemstate"
)
const (
@@ -17,14 +21,111 @@ const (
statsUsernameLookupIndexName = "stats_user_uid"
)
// InitStore creates the stats collection's MongoDB indexes. Index creation is
// idempotent, so it runs on every boot; the memory backend needs none.
func InitStore(ctx context.Context, statsColl storage.Collection) error {
mongoColl, ok := storage.MongoCollection(statsColl)
if !ok {
// commandRename moves one command's usage history to its new name. Each
// rename has its own completion marker in the system collection.
type commandRename struct {
oldCmd, newCmd, markerKey string
}
// commandRenames run in order, each at most once. The noitu renames swap two
// names, so /noitu (the solo game, now /noitubot) must move out before
// /noitupvp (the group game, now /noitu) moves in; the reverse order would
// merge both histories into noitubot.
var commandRenames = []commandRename{
{oldCmd: "noitu", newCmd: "noitubot", markerKey: "stats:command-rename:noitu-to-noitubot"},
{oldCmd: "noitupvp", newCmd: "noitu", markerKey: "stats:command-rename:noitupvp-to-noitu"},
}
// InitStore performs stats collection startup maintenance. It is safe to call
// every boot: MongoDB indexes are created idempotently, and one-time command
// rename migrations are guarded by markers in the shared system collection.
func InitStore(ctx context.Context, statsColl, systemColl storage.Collection) error {
if mongoColl, ok := storage.MongoCollection(statsColl); ok {
if err := ensureUsageIndexes(ctx, mongoColl); err != nil {
return err
}
}
for _, r := range commandRenames {
if err := migrateCommandRename(ctx, statsColl, systemColl, r); err != nil {
return err
}
}
return nil
}
// migrateCommandRename merges every usage row of r.oldCmd, the anonymous
// total and the per-user rows, into the matching r.newCmd row, deletes the
// old row, and records completion. A completed marker makes it a no-op, so a
// later rename that reuses r.oldCmd as its new name is never moved again.
func migrateCommandRename(ctx context.Context, statsColl, systemColl storage.Collection, r commandRename) error {
state := systemstate.New(systemColl)
if rec, ok, err := state.Get(ctx, r.markerKey); err != nil {
return fmt.Errorf("stats command rename marker %s: %w", r.markerKey, err)
} else if ok && rec.Status == "complete" {
return nil
}
return ensureUsageIndexes(ctx, mongoColl)
docs := storage.Typed[usageEntry](statsColl)
keys, err := docs.List(ctx, r.oldCmd)
if err != nil {
return fmt.Errorf("stats command rename list %s: %w", r.oldCmd, err)
}
var moved int64
for _, key := range keys {
// The key prefix also matches longer command names.
if key != r.oldCmd && !strings.HasPrefix(key, r.oldCmd+":") {
continue
}
entry, _, err := docs.Get(ctx, key)
if err != nil {
return fmt.Errorf("stats command rename get %s: %w", key, err)
}
if entry.Cmd != r.oldCmd {
continue
}
targetKey := usageKey(r.newCmd, entry.UserID)
target, _, err := docs.Get(ctx, targetKey)
missingTarget := errors.Is(err, storage.ErrNotFound)
if err != nil && !missingTarget {
return fmt.Errorf("stats command rename get target %s: %w", targetKey, err)
}
if missingTarget {
target = usageEntry{Deleted: entry.Deleted}
}
target.Cmd = r.newCmd
target.UserID = entry.UserID
if entry.UserID == 0 {
target.Username = ""
} else if entry.Username != "" {
target.Username = entry.Username
}
target.N += entry.N
if !entry.Deleted {
target.Deleted = false
}
if err := docs.Put(ctx, targetKey, target); err != nil {
return fmt.Errorf("stats command rename put target %s: %w", targetKey, err)
}
if err := docs.Delete(ctx, key); err != nil {
return fmt.Errorf("stats command rename delete old %s: %w", key, err)
}
moved += entry.N
}
now := time.Now().UTC().UnixMilli()
if err := state.Put(ctx, r.markerKey, systemstate.Record{
Kind: "migration",
Name: r.markerKey,
Status: "complete",
Count: moved,
CompletedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("stats command rename marker put %s: %w", r.markerKey, err)
}
return nil
}
func ensureUsageIndexes(ctx context.Context, coll *mongo.Collection) error {
+18 -5
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/tiennm99/tiennm99bot/internal/storage"
"github.com/tiennm99/tiennm99bot/internal/systemstate"
"github.com/tiennm99/tiennm99bot/internal/testutil/mongotest"
)
@@ -18,7 +19,9 @@ func TestMain(m *testing.M) {
}
func TestInitStore_MongoCreatesIndexes(t *testing.T) {
ctx, statsColl := setupMongoStatsTest(t)
ctx, provider := setupMongoStatsProvider(t)
statsColl := provider.Collection("stats")
systemColl := provider.Collection(systemstate.CollectionName)
rawStatsColl, ok := storage.MongoCollection(statsColl)
if !ok {
@@ -36,10 +39,10 @@ func TestInitStore_MongoCreatesIndexes(t *testing.T) {
t.Fatalf("seed prefix stats: %v", err)
}
if err := InitStore(ctx, statsColl); err != nil {
if err := InitStore(ctx, statsColl, systemColl); err != nil {
t.Fatalf("InitStore: %v", err)
}
if err := InitStore(ctx, statsColl); err != nil {
if err := InitStore(ctx, statsColl, systemColl); err != nil {
t.Fatalf("InitStore second run: %v", err)
}
@@ -82,6 +85,12 @@ func TestInitStore_MongoCreatesIndexes(t *testing.T) {
func setupMongoStatsTest(t *testing.T) (context.Context, storage.Collection) {
t.Helper()
ctx, provider := setupMongoStatsProvider(t)
return ctx, provider.Collection("stats")
}
func setupMongoStatsProvider(t *testing.T) (context.Context, storage.Provider) {
t.Helper()
uri := mongoTests.URI(t)
@@ -101,11 +110,15 @@ func setupMongoStatsTest(t *testing.T) (context.Context, storage.Collection) {
_ = client.Disconnect(cleanupCtx)
})
provider := storage.NewMongoProvider(db)
return ctx, provider.Collection("stats")
return ctx, storage.NewMongoProvider(db)
}
func TestInc_MongoUsernameMoveClearsOldHolder(t *testing.T) {
_, statsColl := setupMongoStatsTest(t)
assertUsernameMoveClearsOldHolder(t, statsColl)
}
func TestInitStore_MongoSwapsNoituCommandStats(t *testing.T) {
_, provider := setupMongoStatsProvider(t)
assertNoituCommandSwap(t, provider)
}
+148
View File
@@ -0,0 +1,148 @@
package stats
import (
"context"
"errors"
"testing"
"github.com/tiennm99/tiennm99bot/internal/storage"
"github.com/tiennm99/tiennm99bot/internal/systemstate"
)
func TestInitStore_SwapsNoituCommandStats(t *testing.T) {
assertNoituCommandSwap(t, storage.NewMemoryProvider())
}
// assertNoituCommandSwap seeds the solo game's history under "noitu" and the
// group game's under "noitupvp", runs the startup migration twice, and checks
// that the solo history landed on "noitubot" and the group history on
// "noitu" without the two mixing.
func assertNoituCommandSwap(t *testing.T, provider storage.Provider) {
t.Helper()
ctx := context.Background()
statsColl := provider.Collection("stats")
systemColl := provider.Collection(systemstate.CollectionName)
docs := storage.Typed[usageEntry](statsColl)
seeds := map[string]usageEntry{
usageKey("noitu", 0): {Cmd: "noitu", N: 5},
usageKey("noitu", 7): {Cmd: "noitu", UserID: 7, Username: "alice", N: 2},
usageKey("noitupvp", 0): {Cmd: "noitupvp", N: 3},
usageKey("noitupvp", 7): {Cmd: "noitupvp", UserID: 7, Username: "alice", N: 4},
usageKey("noitupvp", 8): {Cmd: "noitupvp", UserID: 8, Username: "bob", N: 6},
// A command that only shares the prefix stays where it is.
usageKey("noitutop", 7): {Cmd: "noitutop", UserID: 7, Username: "alice", N: 1},
}
for key, entry := range seeds {
if err := docs.Put(ctx, key, entry); err != nil {
t.Fatalf("seed %s: %v", key, err)
}
}
for run := range 2 {
if err := InitStore(ctx, statsColl, systemColl); err != nil {
t.Fatalf("InitStore run %d: %v", run+1, err)
}
}
want := map[string]usageEntry{
usageKey("noitubot", 0): {Cmd: "noitubot", N: 5},
usageKey("noitubot", 7): {Cmd: "noitubot", UserID: 7, Username: "alice", N: 2},
usageKey("noitu", 0): {Cmd: "noitu", N: 3},
usageKey("noitu", 7): {Cmd: "noitu", UserID: 7, Username: "alice", N: 4},
usageKey("noitu", 8): {Cmd: "noitu", UserID: 8, Username: "bob", N: 6},
usageKey("noitutop", 7): {Cmd: "noitutop", UserID: 7, Username: "alice", N: 1},
}
for key, w := range want {
got, _, err := docs.Get(ctx, key)
if err != nil {
t.Fatalf("get %s: %v", key, err)
}
if got != w {
t.Errorf("%s = %+v, want %+v", key, got, w)
}
}
for _, key := range []string{usageKey("noitupvp", 0), usageKey("noitupvp", 7), usageKey("noitupvp", 8)} {
if _, _, err := docs.Get(ctx, key); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("old key %s err = %v, want ErrNotFound", key, err)
}
}
state := systemstate.New(systemColl)
for key, count := range map[string]int64{
"stats:command-rename:noitu-to-noitubot": 7,
"stats:command-rename:noitupvp-to-noitu": 13,
} {
rec, ok, err := state.Get(ctx, key)
if err != nil || !ok || rec.Status != "complete" || rec.Count != count {
t.Errorf("marker %s = %+v ok=%v err=%v, want complete count %d", key, rec, ok, err, count)
}
}
}
// A boot that stopped after the first rename resumes with the second only:
// the rows already under the new "noitu" name are group history and must not
// move to noitubot again.
func TestInitStore_NoituSwapResumesAfterFirstStep(t *testing.T) {
ctx := context.Background()
provider := storage.NewMemoryProvider()
statsColl := provider.Collection("stats")
systemColl := provider.Collection(systemstate.CollectionName)
docs := storage.Typed[usageEntry](statsColl)
if err := migrateCommandRename(ctx, statsColl, systemColl, commandRenames[0]); err != nil {
t.Fatalf("first step: %v", err)
}
for key, entry := range map[string]usageEntry{
usageKey("noitubot", 7): {Cmd: "noitubot", UserID: 7, Username: "alice", N: 2},
// Moved by an interrupted second step before the restart.
usageKey("noitu", 7): {Cmd: "noitu", UserID: 7, Username: "alice", N: 4},
usageKey("noitupvp", 8): {Cmd: "noitupvp", UserID: 8, Username: "bob", N: 6},
} {
if err := docs.Put(ctx, key, entry); err != nil {
t.Fatalf("seed %s: %v", key, err)
}
}
if err := InitStore(ctx, statsColl, systemColl); err != nil {
t.Fatalf("InitStore: %v", err)
}
for key, n := range map[string]int64{
usageKey("noitubot", 7): 2,
usageKey("noitu", 7): 4,
usageKey("noitu", 8): 6,
} {
got, _, err := docs.Get(ctx, key)
if err != nil || got.N != n {
t.Errorf("%s = %+v err=%v, want n %d", key, got, err, n)
}
}
}
// A rename merges into rows already under the new name and keeps a row that
// was only ever deleted history marked deleted.
func TestMigrateCommandRename_MergesAndKeepsDeletedFlag(t *testing.T) {
ctx := context.Background()
provider := storage.NewMemoryProvider()
statsColl := provider.Collection("stats")
systemColl := provider.Collection(systemstate.CollectionName)
docs := storage.Typed[usageEntry](statsColl)
for key, entry := range map[string]usageEntry{
usageKey("old", 0): {Cmd: "old", N: 2, Deleted: true},
usageKey("old", 7): {Cmd: "old", UserID: 7, Username: "alice", N: 3},
usageKey("new", 7): {Cmd: "new", UserID: 7, Username: "al", N: 5},
} {
if err := docs.Put(ctx, key, entry); err != nil {
t.Fatalf("seed %s: %v", key, err)
}
}
if err := migrateCommandRename(ctx, statsColl, systemColl, commandRename{oldCmd: "old", newCmd: "new", markerKey: "test-rename"}); err != nil {
t.Fatalf("migrate: %v", err)
}
if got, _, _ := docs.Get(ctx, usageKey("new", 0)); got != (usageEntry{Cmd: "new", N: 2, Deleted: true}) {
t.Errorf("anonymous = %+v", got)
}
if got, _, _ := docs.Get(ctx, usageKey("new", 7)); got != (usageEntry{Cmd: "new", UserID: 7, Username: "alice", N: 8}) {
t.Errorf("user = %+v", got)
}
}
+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
}
@@ -0,0 +1,148 @@
package guessgame
import (
"context"
"crypto/hmac"
"crypto/sha256"
"errors"
"math/rand/v2"
"strconv"
"sync"
"time"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
const (
dayLength = 24 * time.Hour
// maxCachedPuzzles bounds the in-memory answer cache: today and
// yesterday are all a request or the push ever needs.
maxCachedPuzzles = 4
)
// 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. 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)
// 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 (d *Daily) PuzzleNum(now time.Time) int {
since := now.Sub(d.epoch)
if since < 0 {
return 1
}
return int(since/dayLength) + 1
}
// 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 (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
// len(answers) days walks one keyed permutation of the list, so no answer
// 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 d.cfg.Answers[d.perms.get(d.cfg.AnswerKey, cycle, n)[pos]]
}
// permCache keeps the permutation of the current cycle.
type permCache struct {
mu sync.Mutex
cycle int
perm []int
}
func (c *permCache) get(key []byte, cycle, n int) []int {
c.mu.Lock()
defer c.mu.Unlock()
if c.perm != nil && c.cycle == cycle && len(c.perm) == n {
return c.perm
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte("cycle:" + strconv.Itoa(cycle)))
var seed [32]byte
copy(seed[:], mac.Sum(nil))
rng := rand.New(rand.NewChaCha8(seed))
perm := make([]int, n)
for i := range perm {
perm[i] = i
}
rng.Shuffle(n, func(i, j int) { perm[i], perm[j] = perm[j], perm[i] })
c.cycle, c.perm = cycle, perm
return perm
}
// puzzleCache remembers resolved answers so a guess does not read the store
// for the puzzle every time.
type puzzleCache struct {
mu sync.Mutex
answers map[int]string
}
func (c *puzzleCache) get(num int) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
a, ok := c.answers[num]
return a, ok
}
func (c *puzzleCache) put(num int, answer string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.answers == nil {
c.answers = map[int]string{}
}
c.answers[num] = answer
for len(c.answers) > maxCachedPuzzles {
oldest := num
for k := range c.answers {
oldest = min(oldest, k)
}
delete(c.answers, oldest)
}
}
// 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 (d *Daily) ResolvePuzzle(ctx context.Context, num int) (string, error) {
if a, ok := d.cache.get(num); ok {
return a, nil
}
key := PuzzleKey(num)
for range writeRetries {
doc, _, err := d.Puzzles.Get(ctx, key)
if err == nil {
d.cache.put(num, doc.Answer)
return doc.Answer, nil
}
if !errors.Is(err, storage.ErrNotFound) {
return "", err
}
doc = PuzzleDoc{Num: num, Answer: d.AnswerFor(num), CreatedAt: d.cfg.Now().UnixMilli()}
err = d.Puzzles.PutVersioned(ctx, key, 0, doc)
if err == nil {
d.cache.put(num, doc.Answer)
return doc.Answer, nil
}
if !errors.Is(err, storage.ErrConflict) {
return "", err
}
}
return "", 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()
}
@@ -0,0 +1,239 @@
package guessgame
import (
"context"
"errors"
"slices"
"strconv"
"strings"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
// 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 (
// 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.
maxChats = 20
// maxChatPlayers bounds one chat's daily player list.
maxChatPlayers = 200
// writeRetries bounds a versioned read-modify-write.
writeRetries = 5
)
// 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"`
}
// 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 {
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 {
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"`
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"`
}
// 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 })
}
// 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"`
MaxStreak int `json:"max_streak" bson:"max_streak"`
Dist []int `json:"dist" bson:"dist"` // wins by guess count, index 0 = 1 guess
LastNum int `json:"last_num" bson:"last_num"`
LastWinNum int `json:"last_win_num" bson:"last_win_num"`
}
// 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
// summary message the bot keeps there.
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"`
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 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 {
if c.LastNum >= num-1 {
return c.Streak
}
return 0
}
// PuzzleKey is the key of puzzle num's pinned answer.
func PuzzleKey(num int) string { return "puzzle:" + strconv.Itoa(num) }
// 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)
}
// 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.
func chatTopic(chatID int64, threadID int) string {
return strconv.FormatInt(chatID, 10) + ":" + strconv.Itoa(threadID) + ":"
}
// 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)
}
// 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 (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
default:
return Progress{}, err
}
}
// 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, n)}, nil
}
if len(st.Dist) != n {
st.Dist = append(st.Dist, make([]int, n)...)[:n]
}
return st, err
}
// updateVersioned applies mutate to the document at key with a versioned
// write, retrying when another writer got in first. mutate gets the current
// value (zero and found=false when absent) and reports whether to write.
func updateVersioned[T any](ctx context.Context, store storage.DocStore[T], key string, mutate func(v *T, found bool) bool) error {
for range writeRetries {
v, version, err := store.Get(ctx, key)
found := err == nil
switch {
case errors.Is(err, storage.ErrNotFound):
version = 0
case err != nil:
return err
}
if !mutate(&v, found) {
return nil
}
err = store.PutVersioned(ctx, key, version, v)
if !errors.Is(err, storage.ErrConflict) {
return err
}
}
return storage.ErrConflict
}
// 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
// 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 (d *Daily) PruneDays(ctx context.Context, today int) {
stores := []struct {
prefix string
store dayStore
}{{"puzzle:", d.Puzzles}, {"play:", d.Plays}, {"cday:", d.ChatDays}}
deleted := 0
for _, ds := range stores {
keys, err := ds.store.List(ctx, ds.prefix)
if err != nil {
log.Warn(d.cfg.LogName+" prune list failed", "prefix", ds.prefix, "err", err)
continue
}
for _, key := range keys {
num, ok := keyNum(key, ds.prefix)
if !ok || num >= today-1 {
continue
}
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(d.cfg.LogName+" pruned old puzzle days", "deleted", deleted, "today", today)
}
}
// keyNum reads the puzzle number that follows prefix in a per-puzzle key.
func keyNum(key, prefix string) (int, bool) {
rest := strings.TrimPrefix(key, prefix)
if i := strings.IndexByte(rest, ':'); i >= 0 {
rest = rest[:i]
}
n, err := strconv.Atoi(rest)
return n, err == nil
}
@@ -0,0 +1,351 @@
package guessgame
import (
"cmp"
"context"
"errors"
"slices"
"strconv"
"strings"
"sync"
"time"
"unicode/utf16"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
const (
// 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
// for one chat's day, when the old one was deleted.
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
)
// 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
// restart only delays the next update, because the message is always
// rendered from the store.
type flusher struct {
mu sync.Mutex
state map[string]*flushState
}
type flushState struct {
num int
scheduled bool
last time.Time
retries int // failed updates in a row
}
// TouchSummary schedules an update of the live results message of the chat
// topic's day. Only group chats (negative IDs) get one.
func (d *Daily) TouchSummary(num int, chatID int64, threadID int) {
if d.cfg.API == nil || chatID >= 0 {
return
}
key := ChatDayKey(num, chatID, threadID)
now := d.cfg.Now()
f := &d.flush
f.mu.Lock()
if f.state == nil {
f.state = map[string]*flushState{}
}
st := f.state[key]
if st == nil {
for k, old := range f.state {
if old.num < num-1 && !old.scheduled {
delete(f.state, k)
}
}
st = &flushState{num: num}
f.state[key] = st
}
if st.scheduled {
f.mu.Unlock()
return
}
st.scheduled = true
delay := max(SummaryInterval-now.Sub(st.last), 0)
if st.last.IsZero() {
delay = 0
}
f.mu.Unlock()
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 (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, d.cfg.Now()
}
f.mu.Unlock()
retry, after := d.syncSummary(key, chatID, threadID)
f.mu.Lock()
if f.state == nil {
f.state = map[string]*flushState{}
}
st := f.state[key]
if st == nil {
st = &flushState{num: num, last: d.cfg.Now()}
f.state[key] = st
}
if !retry {
st.retries = 0
f.mu.Unlock()
return
}
if st.scheduled {
// A newer update is already queued and renders the same state.
f.mu.Unlock()
return
}
if st.retries >= MaxSummaryRetries {
st.retries = 0
f.mu.Unlock()
log.Warn(d.cfg.LogName+" summary update abandoned", "puzzle", num, "chat", chatID)
return
}
delay := max(after, SummaryInterval<<st.retries)
st.retries++
st.scheduled = true
f.mu.Unlock()
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
// 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 (d *Daily) syncSummary(key string, chatID int64, threadID int) (bool, time.Duration) {
ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
defer cancel()
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(d.cfg.LogName+" summary read failed", "err", err)
return true, 0
}
text, err := d.RenderLive(ctx, cd)
if err != nil {
log.Warn(d.cfg.LogName+" summary render failed", "err", err)
return true, 0
}
if text == "" {
return false, 0
}
now := d.cfg.Now()
if cd.MsgID != 0 {
_, 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(d.cfg.LogName+" summary edit failed", "err", err)
return retryAfter(err)
}
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
}
}
params := &bot.SendMessageParams{
ChatID: cd.ChatID,
MessageThreadID: cd.ThreadID,
Text: text,
DisableNotification: true,
}
if cd.CardID != 0 {
params.ReplyParameters = &models.ReplyParameters{MessageID: cd.CardID, AllowSendingWithoutReply: true}
}
msg, err := d.cfg.API.SendMessage(ctx, params)
if err != nil {
log.Warn(d.cfg.LogName+" summary send failed", "err", err)
return retryAfter(err)
}
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(d.cfg.LogName+" summary id save failed", "err", err)
}
return false, 0
}
// retryAfter classifies a failed Telegram call: a request Telegram refused
// (bad request, forbidden, not found, unauthorized) fails the same way
// again, while a 429 says how long to wait and a timeout or 5xx may pass.
func retryAfter(err error) (bool, time.Duration) {
var tooMany *bot.TooManyRequestsError
switch {
case errors.As(err, &tooMany):
return true, time.Duration(tooMany.RetryAfter) * time.Second
case errors.Is(err, bot.ErrorBadRequest), errors.Is(err, bot.ErrorForbidden),
errors.Is(err, bot.ErrorNotFound), errors.Is(err, bot.ErrorUnauthorized):
return false, 0
}
return true, 0
}
func messageNotModified(err error) bool {
return strings.Contains(err.Error(), "message is not modified")
}
// messageGone reports an edit that can never succeed, so the summary has to
// be sent again.
func messageGone(err error) bool {
msg := err.Error()
return strings.Contains(msg, "message to edit not found") ||
strings.Contains(msg, "message can't be edited") ||
strings.Contains(msg, "MESSAGE_ID_INVALID")
}
// chatResult is one player's game as a chat's results show it.
type chatResult struct {
name string
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 (d *Daily) chatResults(ctx context.Context, cd ChatDay) ([]chatResult, error) {
out := make([]chatResult, 0, len(cd.Players))
for _, pl := range cd.Players {
p, _, err := d.Plays.Get(ctx, PlayKey(cd.Num, pl.UserID))
if errors.Is(err, storage.ErrNotFound) {
continue
}
if err != nil {
return nil, err
}
if len(p.Guesses) == 0 {
continue
}
name := p.Name
if name == "" {
name = pl.Name
}
out = append(out, chatResult{name: name, p: p})
}
return out, nil
}
// sortResults orders winners by fewer guesses then earlier finish, then
// losses by finish, then players still playing with the most guesses first.
func sortResults(rs []chatResult) {
rank := func(r chatResult) int {
switch r.p.Status {
case StatusWon:
return 0
case StatusLost:
return 1
}
return 2
}
slices.SortStableFunc(rs, func(a, b chatResult) int {
if c := cmp.Compare(rank(a), rank(b)); c != 0 {
return c
}
switch a.p.Status {
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:
return cmp.Compare(a.p.FinishedAt, b.p.FinishedAt)
}
return cmp.Compare(len(b.p.Guesses), len(a.p.Guesses))
})
}
// 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 st, err
}
// 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 (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 := d.ChatStreakAt(ctx, cd.ChatID, cd.ThreadID)
if err != nil {
return "", err
}
var head strings.Builder
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")
}
blocks := make([]string, len(results))
for i, r := range results {
var b strings.Builder
b.WriteString(r.name + " ")
if r.p.Finished() {
b.WriteString(d.ResultLine(r.p))
} else {
b.WriteString("playing " + strconv.Itoa(len(r.p.Guesses)) + "/" + strconv.Itoa(d.MaxGuesses()))
}
for _, g := range r.p.Guesses {
b.WriteString("\n" + EmojiRow(d.cfg.Rules, g.Marks))
}
blocks[i] = b.String()
}
return JoinLimited(head.String()+"\n", blocks, "\n"), nil
}
// 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 {
text := head
for i, p := range parts {
next := text
if i > 0 {
next += sep
}
next += p
if UTF16Len(next) > maxTextUnits {
return text + sep + "+" + strconv.Itoa(len(parts)-i) + " more"
}
text = next
}
return text
}
// 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
}
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more