mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-15 22:15:19 +00:00
Compare commits
12
Commits
512b9fc995
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80e85012c7 | ||
|
|
629b513c91 | ||
|
|
bc8a7d8869 | ||
|
|
c0231ceadf | ||
|
|
871b4a1042 | ||
|
|
f60b661444 | ||
|
|
d84bc3d1fb | ||
|
|
b8565164b3 | ||
|
|
231fc96b5f | ||
|
|
6f30d13b2d | ||
|
|
fe552f43e4 | ||
|
|
9801a64e18 |
No files matched your search
+32
-3
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
package loldle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/loldle/web"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame"
|
||||
)
|
||||
|
||||
func urlUnescape(t *testing.T, s string) string {
|
||||
t.Helper()
|
||||
u, err := url.QueryUnescape(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestRules_MarksArrowsAndRefusals(t *testing.T) {
|
||||
r := rules{champions: loadChampions()}
|
||||
for guess, want := range map[string]string{"Akali": "cwwwccu", "wukong": "wcwccwc", "kaisa": "cwccwwd", "Ahri": "ccccccc"} {
|
||||
g, err := r.Judge(guess, "Ahri", nil)
|
||||
if err != nil || g.Marks != want {
|
||||
t.Errorf("%s vs Ahri = %+v, %v; want %s", guess, g, err, want)
|
||||
}
|
||||
}
|
||||
if g, _ := r.Judge("jinx", "Kai'Sa", nil); g.Word != "Jinx" || g.Marks != "cpccwcu" {
|
||||
t.Errorf("Jinx vs Kai'Sa = %+v", g)
|
||||
}
|
||||
for input, want := range map[string]error{"zzz": errUnknownChampion, "ka": errAmbiguous, "": errUnknownChampion} {
|
||||
if _, err := r.Judge(input, "Ahri", nil); err != want {
|
||||
t.Errorf("Judge(%q) = %v, want %v", input, err, want)
|
||||
}
|
||||
}
|
||||
if _, err := r.Judge("akali", "Ahri", []guessgame.Guess{{Word: "Akali"}}); err != errDuplicate {
|
||||
t.Errorf("duplicate = %v", err)
|
||||
}
|
||||
if _, err := r.Judge("akali", "Gone", nil); err != errTargetGone {
|
||||
t.Errorf("gone = %v", err)
|
||||
}
|
||||
if got := guessgame.EmojiRow(r, "cpwudcw"); got != "🟩🟨🟥🟥🟥🟩🟥" {
|
||||
t.Errorf("emoji = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChampions_EveryRowHasAnIconID(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
idRe := regexp.MustCompile(`^[A-Za-z]+$`)
|
||||
for _, c := range loadChampions() {
|
||||
if !idRe.MatchString(c.ID) || seen[c.ID] || c.Title == "" {
|
||||
t.Errorf("%s: id %q title %q", c.ChampionName, c.ID, c.Title)
|
||||
}
|
||||
seen[c.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Data Dragon tile file names are case-sensitive and do not always match the
|
||||
// champion key: Fiddlesticks' tile is FiddleSticks_0.jpg.
|
||||
func TestChampions_IconIDsNameTheTileFile(t *testing.T) {
|
||||
ids := map[string]string{}
|
||||
for _, c := range loadChampions() {
|
||||
ids[c.ChampionName] = c.ID
|
||||
}
|
||||
for name, want := range map[string]string{"Fiddlesticks": "FiddleSticks", "Wukong": "MonkeyKing", "Ahri": "Ahri"} {
|
||||
if ids[name] != want {
|
||||
t.Errorf("%s icon id = %q, want %q", name, ids[name], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_ChampionListCarriesNamesAndIDsOnly(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
rec := httptest.NewRecorder()
|
||||
h.handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, routePrefix+"champions.json", nil))
|
||||
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Type"), "application/json") {
|
||||
t.Fatalf("champions.json: %d %v", rec.Code, rec.Header())
|
||||
}
|
||||
var list []map[string]string
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil || len(list) != len(loadChampions()) {
|
||||
t.Fatalf("list = %d, %v", len(list), err)
|
||||
}
|
||||
for _, c := range list {
|
||||
if len(c) != 2 || c["name"] == "" || c["id"] == "" {
|
||||
t.Fatalf("entry %v", c)
|
||||
}
|
||||
}
|
||||
if list[0]["name"] != "Aatrox" || !strings.Contains(rec.Body.String(), `{"name":"Wukong","id":"MonkeyKing"}`) {
|
||||
t.Fatalf("list starts %v", list[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPI_HeadersAllowDataDragonImagesOnly(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
for path, ctype := range map[string]string{"": "text/html", "app.js": "text/javascript", "app.css": "text/css"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, routePrefix+path, nil))
|
||||
csp := rec.Header().Get("Content-Security-Policy")
|
||||
if rec.Code != http.StatusOK || !strings.HasPrefix(rec.Header().Get("Content-Type"), ctype) ||
|
||||
!strings.Contains(csp, "img-src 'self' data: https://ddragon.leagueoflegends.com;") || !strings.Contains(csp, "connect-src 'self'") {
|
||||
t.Errorf("GET %q: %d %v", path, rec.Code, rec.Header())
|
||||
}
|
||||
}
|
||||
if r := h.state("garbage"); r.code != http.StatusUnauthorized || r.err != "bad_token" {
|
||||
t.Fatalf("bad token = %d %s", r.code, r.body)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
inlineScriptRe = regexp.MustCompile(`(?is)<script\b([^>]*)>(.*?)</script>`)
|
||||
urlRe = regexp.MustCompile(`https?://[^'"\s)]+`)
|
||||
inlineHandlerRe = regexp.MustCompile(`\son[a-z]+=`)
|
||||
)
|
||||
|
||||
// The page runs under the strict CSP: no inline script, style or handler,
|
||||
// and it reaches no host but its own and Data Dragon's icons.
|
||||
func TestWebAssets_FitTheCSP(t *testing.T) {
|
||||
index, _ := web.FS.ReadFile("index.html")
|
||||
for _, m := range inlineScriptRe.FindAllStringSubmatch(string(index), -1) {
|
||||
if strings.TrimSpace(m[2]) != "" || !strings.Contains(m[1], "src=") {
|
||||
t.Errorf("inline script breaks the CSP: %q", m[0])
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(index), " style=") || strings.Contains(string(index), "<style") || inlineHandlerRe.Match(index) {
|
||||
t.Error("inline style or handler breaks the CSP")
|
||||
}
|
||||
js, _ := web.FS.ReadFile("app.js")
|
||||
for _, u := range urlRe.FindAllString(string(js), -1) {
|
||||
if !strings.HasPrefix(u, ddragonOrigin+"/") {
|
||||
t.Errorf("app.js reaches %s", u)
|
||||
}
|
||||
}
|
||||
// No champion attributes ship to the page.
|
||||
for _, leak := range []string{"Vastayan", "Manaless", "Shurima", "release_date"} {
|
||||
if strings.Contains(string(js), leak) {
|
||||
t.Errorf("app.js carries %q", leak)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The board's column labels are small text, so they need WCAG AA's 4.5:1
|
||||
// against the header background.
|
||||
func TestWebAssets_ColumnLabelsMeetAAContrast(t *testing.T) {
|
||||
css, err := web.FS.ReadFile("app.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tokens := map[string]string{}
|
||||
for _, m := range regexp.MustCompile(`(--[a-z-]+):\s*(#[0-9a-fA-F]{6});`).FindAllStringSubmatch(string(css), -1) {
|
||||
tokens[m[1]] = m[2]
|
||||
}
|
||||
rule := regexp.MustCompile(`(?s)\.head \.cell \{(.*?)\}`).FindStringSubmatch(string(css))
|
||||
if rule == nil {
|
||||
t.Fatal("no .head .cell rule")
|
||||
}
|
||||
prop := func(name string) string {
|
||||
m := regexp.MustCompile(`(?m)^\s*` + name + `:\s*var\((--[a-z-]+)\);`).FindStringSubmatch(rule[1])
|
||||
if m == nil || tokens[m[1]] == "" {
|
||||
t.Fatalf(".head .cell %s is not a colour token", name)
|
||||
}
|
||||
return tokens[m[1]]
|
||||
}
|
||||
fg, bg := prop("color"), prop("background")
|
||||
if r := contrast(fg, bg); r < 4.5 {
|
||||
t.Errorf("column labels %s on %s = %.2f:1, want at least 4.5:1", fg, bg, r)
|
||||
}
|
||||
}
|
||||
|
||||
// contrast is the WCAG 2 contrast ratio of two #rrggbb colours.
|
||||
func contrast(a, b string) float64 {
|
||||
lum := func(hex string) float64 {
|
||||
var ch [3]float64
|
||||
for i := range ch {
|
||||
var v int
|
||||
_, _ = fmt.Sscanf(hex[1+2*i:3+2*i], "%02x", &v)
|
||||
c := float64(v) / 255
|
||||
if c <= 0.03928 {
|
||||
ch[i] = c / 12.92
|
||||
} else {
|
||||
ch[i] = math.Pow((c+0.055)/1.055, 2.4)
|
||||
}
|
||||
}
|
||||
return 0.2126*ch[0] + 0.7152*ch[1] + 0.0722*ch[2]
|
||||
}
|
||||
la, lb := lum(a), lum(b)
|
||||
return (max(la, lb) + 0.05) / (min(la, lb) + 0.05)
|
||||
}
|
||||
|
||||
func TestAPI_UnlimitedRoundNeverLeaksTheAnswer(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok := h.token(1, groupChat, 5, "Alice", true)
|
||||
r := h.state(tok)
|
||||
if r.code != http.StatusOK || r.v.Mode != "unlimited" || r.v.Seq != 1 || r.v.Max != 8 || len(r.v.Columns) != 7 || r.v.Answer != nil || r.v.Stats != nil {
|
||||
t.Fatalf("fresh = %s", r.body)
|
||||
}
|
||||
for _, name := range []string{"Akali", "Wukong", "Kai'Sa"} {
|
||||
r = h.roundGuess(tok, 1, name)
|
||||
if r.code != http.StatusOK || strings.Contains(r.body, "Ahri") || strings.Contains(r.body, `"answer"`) {
|
||||
t.Fatalf("guess %s leaked or failed: %d %s", name, r.code, r.body)
|
||||
}
|
||||
}
|
||||
row := r.v.Guesses[2]
|
||||
if row.Name != "Kai'Sa" || row.ID != "Kaisa" || row.Marks != "cwccwwd" || len(row.Cells) != 7 {
|
||||
t.Fatalf("row = %+v", row)
|
||||
}
|
||||
// Cells show the guess's own values only.
|
||||
if c := row.Cells[4]; c.Key != "regions" || c.Value != "Shurima, Void" || c.Result != "wrong" {
|
||||
t.Fatalf("regions cell = %+v", c)
|
||||
}
|
||||
if c := row.Cells[6]; c.Value != "2018" || c.Dir != "down" || c.Result != "wrong" {
|
||||
t.Fatalf("year cell = %+v", c)
|
||||
}
|
||||
for name, code := range map[string]string{"zzz": "unknown", "ka": "ambiguous", "akali": "duplicate"} {
|
||||
if r := h.roundGuess(tok, 1, name); r.code != http.StatusUnprocessableEntity || r.err != code {
|
||||
t.Errorf("%s = %d %s", name, r.code, r.body)
|
||||
}
|
||||
}
|
||||
r = h.roundGuess(tok, 1, "ahri")
|
||||
if r.v.Status != "won" || r.v.Answer == nil || r.v.Answer.Name != "Ahri" || r.v.Answer.ID != "Ahri" || r.v.Answer.Title == "" ||
|
||||
r.v.Stats == nil || r.v.Stats.Played != 1 || len(r.v.Stats.Dist) != MaxGuessesCap || r.v.Stats.Dist[3] != 1 || r.v.Share != "" {
|
||||
t.Fatalf("win = %s", r.body)
|
||||
}
|
||||
if r = h.post("new", map[string]any{"token": tok, "seq": 1}); r.v.Seq != 2 || len(r.v.Guesses) != 0 || r.v.Abandoned != nil {
|
||||
t.Fatalf("new = %s", r.body)
|
||||
}
|
||||
// Giving up a round with guesses shows its champion before the next one.
|
||||
h.roundGuess(tok, 2, "Akali") // round 2 is Jinx
|
||||
if r = h.post("new", map[string]any{"token": tok, "seq": 2}); r.v.Seq != 3 || r.v.Answer != nil ||
|
||||
r.v.Abandoned == nil || r.v.Abandoned.Name != "Jinx" || r.v.Abandoned.ID != "Jinx" || r.v.Abandoned.Title == "" {
|
||||
t.Fatalf("abandon = %s", r.body)
|
||||
}
|
||||
if r = h.roundGuess(tok, 1, "akali"); r.code != http.StatusConflict || r.err != "new_round" {
|
||||
t.Fatalf("stale = %d %s", r.code, r.body)
|
||||
}
|
||||
if n := len(h.rep.snapshot()); n != 0 {
|
||||
t.Fatalf("unlimited win reported %d times", n)
|
||||
}
|
||||
if r = h.post("new", map[string]any{"token": h.token(1, groupChat, 5, "Alice", false), "seq": 1}); r.code != http.StatusForbidden || r.err != "bad_mode" {
|
||||
t.Fatalf("new on a daily card = %d %s", r.code, r.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaily_SameChampionForEveryoneAndHiddenUntilFinished(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.pinDaily(1, "Ahri")
|
||||
alice := h.token(1, groupChat, 7, "Alice", false)
|
||||
r := h.state(alice)
|
||||
if r.v.Mode != "daily" || r.v.Num != 1 || r.v.Date != "2026-10-09" || r.v.Max != 8 || r.v.NextAt != guessgame.Epoch.Add(24*time.Hour).Unix() {
|
||||
t.Fatalf("daily = %s", r.body)
|
||||
}
|
||||
r = h.dailyGuess(alice, 1, "Akali")
|
||||
if strings.Contains(r.body, "Ahri") || r.v.Guesses[0].Marks != "cwwwccu" {
|
||||
t.Fatalf("guess leaked or wrong: %s", r.body)
|
||||
}
|
||||
r = h.dailyGuess(alice, 1, "Ahri")
|
||||
if r.v.Status != "won" || r.v.Answer.Name != "Ahri" || r.v.Stats.Played != 1 || len(r.v.Stats.Dist) != 8 || r.v.Stats.Dist[1] != 1 ||
|
||||
r.v.Share != "LoLdle Daily #1 2/8\n\n🟩🟥🟥🟥🟩🟩🟥\n🟩🟩🟩🟩🟩🟩🟩" {
|
||||
t.Fatalf("win = %s", r.body)
|
||||
}
|
||||
if calls := h.rep.snapshot(); len(calls) != 1 || calls[0].score != 7 || calls[0].addr.ChatID != groupChat {
|
||||
t.Fatalf("score = %+v, want 9-2", calls)
|
||||
}
|
||||
if r := h.dailyGuess(alice, 1, "Jinx"); r.code != http.StatusUnprocessableEntity || r.err != "finished" {
|
||||
t.Fatalf("after the win = %d %s", r.code, r.body)
|
||||
}
|
||||
// Bob gets the same champion.
|
||||
bob := h.token(2, 2, 3, "Bob", false)
|
||||
if r := h.dailyGuess(bob, 1, "Ahri"); r.v.Status != "won" {
|
||||
t.Fatalf("bob = %s", r.body)
|
||||
}
|
||||
// At 07:00 ICT the next champion starts; a stale board is refused.
|
||||
h.clock.advance(12 * time.Hour)
|
||||
bob = h.token(2, 2, 3, "Bob", false)
|
||||
if r := h.dailyGuess(bob, 1, "Ahri"); r.code != http.StatusConflict || r.err != "new_puzzle" {
|
||||
t.Fatalf("stale = %d %s", r.code, r.body)
|
||||
}
|
||||
if r := h.state(bob); r.v.Num != 2 || len(r.v.Guesses) != 0 || r.v.Date != "2026-10-10" {
|
||||
t.Fatalf("day 2 = %s", r.body)
|
||||
}
|
||||
// Daily stats live under their own keys, apart from the legacy stats.
|
||||
if keys, _ := h.svc.Stats.List(context.Background(), "dstats:"); len(keys) != 2 {
|
||||
t.Fatalf("daily stats keys = %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
// A champion pinned as a day's answer and later removed from the data keeps
|
||||
// the day playable: guesses score wrong in every column, naming the answer
|
||||
// wins, and the board finishes instead of failing every guess.
|
||||
func TestDaily_PinnedAnswerMissingFromDataStaysPlayable(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.pinDaily(1, "Retired Champ")
|
||||
alice := h.token(1, groupChat, 7, "Alice", false)
|
||||
r := h.dailyGuess(alice, 1, "Akali")
|
||||
if r.code != http.StatusOK || len(r.v.Guesses) != 1 || r.v.Guesses[0].Marks != "wwwwwww" || r.v.Answer != nil {
|
||||
t.Fatalf("guess = %d %s", r.code, r.body)
|
||||
}
|
||||
for name, code := range map[string]string{"zzz": "unknown", "ka": "ambiguous", "akali": "duplicate"} {
|
||||
if r := h.dailyGuess(alice, 1, name); r.code != http.StatusUnprocessableEntity || r.err != code {
|
||||
t.Errorf("%s = %d %s", name, r.code, r.body)
|
||||
}
|
||||
}
|
||||
r = h.dailyGuess(alice, 1, "retired champ")
|
||||
if r.code != http.StatusOK || r.v.Status != "won" || r.v.Answer == nil || r.v.Answer.Name != "Retired Champ" {
|
||||
t.Fatalf("win = %d %s", r.code, r.body)
|
||||
}
|
||||
bob := h.token(2, 2, 3, "Bob", false)
|
||||
for i, name := range []string{"Ahri", "Akali", "Jinx", "Wukong", "Kai'Sa", "Lux", "Garen", "Teemo"} {
|
||||
r = h.dailyGuess(bob, 1, name)
|
||||
if r.code != http.StatusOK {
|
||||
t.Fatalf("bob guess %d = %d %s", i, r.code, r.body)
|
||||
}
|
||||
}
|
||||
if r.v.Status != "lost" || r.v.Answer == nil || r.v.Answer.Name != "Retired Champ" {
|
||||
t.Fatalf("loss = %s", r.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaily_AnswersAreKeyedChampionsWithoutRepeats(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
n := len(loadChampions())
|
||||
seen := map[string]bool{}
|
||||
for num := 1; num <= n; num++ {
|
||||
a := h.svc.AnswerFor(num)
|
||||
if seen[a] || findChampionByExactName(h.svc.cfg.champions, a) == nil {
|
||||
t.Fatalf("puzzle %d answer %q repeats or is unknown", num, a)
|
||||
}
|
||||
seen[a] = true
|
||||
}
|
||||
other := testConfig(h.clock, h.coll, h.rep, nil, h.sched)
|
||||
other.rootKey = []byte("another-root-key-another-root-key")
|
||||
same := 0
|
||||
for num := 1; num <= 10; num++ {
|
||||
if newService(other).AnswerFor(num) == h.svc.AnswerFor(num) {
|
||||
same++
|
||||
}
|
||||
}
|
||||
if same == 10 {
|
||||
t.Fatal("the answer order ignores the key")
|
||||
}
|
||||
// The first resolution pins the day: a later key or data change keeps it.
|
||||
a, err := h.svc.ResolvePuzzle(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := newService(other).ResolvePuzzle(context.Background(), 1); got != a {
|
||||
t.Fatalf("pinned %q, then %q", a, got)
|
||||
}
|
||||
}
|
||||
|
||||
// tokenFor-style helper with a thread; daily tests need topics.
|
||||
func (h *harness) tokenTopic(user, chat int64, msg, thread int, name string) string {
|
||||
h.t.Helper()
|
||||
tok, err := h.svc.signToken(guessgame.Claims{UserID: user, Name: name, ChatID: chat, MessageID: msg, ThreadID: thread,
|
||||
Expiry: h.clock.now().Add(guessgame.TokenTTL).Unix()})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
func TestDaily_LiveResultsAreSpoilerFree(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.pinDaily(1, "Ahri")
|
||||
alice := h.tokenTopic(1, groupChat, 7, 0, "Alice")
|
||||
h.dailyGuess(alice, 1, "Akali")
|
||||
h.dailyGuess(h.tokenTopic(2, groupChat, 7, 0, "Bob"), 1, "Kai'Sa")
|
||||
h.sched.run()
|
||||
sends := sentMethod(h.rb, "sendMessage")
|
||||
if len(sends) != 1 {
|
||||
t.Fatalf("sends = %+v", h.rb.Sent())
|
||||
}
|
||||
text := sends[0].Text()
|
||||
if text != "LoLdle Daily #1 · live results\n\nAlice playing 1/8\n🟩🟥🟥🟥🟩🟩🟥\nBob playing 1/8\n🟩🟥🟩🟩🟥🟥🟥" {
|
||||
t.Fatalf("live = %q", text)
|
||||
}
|
||||
for _, c := range loadChampions() {
|
||||
if strings.Contains(text, c.ChampionName) {
|
||||
t.Fatalf("live results name %s", c.ChampionName)
|
||||
}
|
||||
}
|
||||
h.dailyGuess(alice, 1, "Ahri")
|
||||
h.sched.run()
|
||||
edits := sentMethod(h.rb, "editMessageText")
|
||||
if len(edits) == 0 || !strings.Contains(edits[len(edits)-1].Form["text"], "Alice 2/8\n🟩🟥🟥🟥🟩🟩🟥\n🟩🟩🟩🟩🟩🟩🟩") {
|
||||
t.Fatalf("edits = %+v", edits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaily_RecapAnswerFirstStreakCountsWinsOnly(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
rb := install(t, h.svc.module(), 0)
|
||||
if got := send(t, rb, groupChat, 1, "/loldledaily_subscribe"); !strings.HasPrefix(got, "✅ Subscribed this chat to LoLdle Daily") {
|
||||
t.Fatalf("subscribe = %q", got)
|
||||
}
|
||||
// Day 1: Alice wins in 2, Dan in 2, Bob loses; day 2: Alice wins.
|
||||
h.pinDaily(1, "Ahri")
|
||||
h.pinDaily(2, "Jinx")
|
||||
h.pinDaily(3, "Wukong")
|
||||
h.dailyGuess(h.tokenTopic(1, groupChat, 7, 0, "Alice"), 1, "Akali")
|
||||
h.dailyGuess(h.tokenTopic(1, groupChat, 7, 0, "Alice"), 1, "Ahri")
|
||||
h.dailyGuess(h.tokenTopic(3, groupChat, 7, 0, "Dan"), 1, "Jinx")
|
||||
h.dailyGuess(h.tokenTopic(3, groupChat, 7, 0, "Dan"), 1, "Ahri")
|
||||
bob := h.tokenTopic(2, groupChat, 7, 0, "Bob")
|
||||
for _, c := range []string{"Akali", "Wukong", "Jinx", "Kai'Sa", "Aatrox", "Annie", "Ashe", "Braum"} {
|
||||
h.dailyGuess(bob, 1, c)
|
||||
}
|
||||
// Day 1's recap groups the finishers by guesses, the best crowned.
|
||||
got, err := h.svc.RenderRecap(ctx, 1, groupChat, 0)
|
||||
if err != nil || got != "LoLdle Daily #1 — yesterday's results\nChampion: Ahri\n👑 2/8: Alice, Dan\nX/8: Bob" {
|
||||
t.Fatalf("day 1 recap = %q, %v", got, err)
|
||||
}
|
||||
h.clock.advance(24 * time.Hour)
|
||||
h.dailyGuess(h.tokenTopic(1, groupChat, 8, 0, "Alice"), 2, "Jinx")
|
||||
h.clock.advance(24 * time.Hour)
|
||||
h.sched.run()
|
||||
h.rb.Reset()
|
||||
if err := h.svc.RunDailyPush(ctx, h.rb.Bot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := h.rb.Sent()
|
||||
if len(sent) != 2 || sent[1].Method != "sendGame" || sent[1].Form["game_short_name"] != "loldle" {
|
||||
t.Fatalf("push = %+v", sent)
|
||||
}
|
||||
if got := sent[0].Text(); got != "LoLdle Daily #2 — yesterday's results\nChampion: Jinx\n🔥 Your group is on a 2 day streak!\n👑 1/8: Alice" {
|
||||
t.Fatalf("recap = %q", got)
|
||||
}
|
||||
// Day 3 only a loss: the streak resets.
|
||||
h.dailyGuess(h.tokenTopic(1, groupChat, 9, 0, "Alice"), 3, "Akali")
|
||||
for _, c := range []string{"Ahri", "Jinx", "Kai'Sa", "Aatrox", "Annie", "Ashe", "Braum"} {
|
||||
h.dailyGuess(h.tokenTopic(1, groupChat, 9, 0, "Alice"), 3, c)
|
||||
}
|
||||
h.clock.advance(24 * time.Hour)
|
||||
h.rb.Reset()
|
||||
if err := h.svc.RunDailyPush(ctx, h.rb.Bot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := h.rb.Sent()[0].Text(); got != "LoLdle Daily #3 — yesterday's results\nChampion: Wukong\nNobody solved it. Group streak reset.\nX/8: Alice" {
|
||||
t.Fatalf("loss recap = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package loldle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame"
|
||||
)
|
||||
|
||||
const (
|
||||
msgDisabled = "LoLdle isn't configured on this server."
|
||||
msgNoGameTarget = "Couldn't find the game message. Send /loldle or /loldledaily to play."
|
||||
msgCardLookup = "Couldn't open the game right now. Try again later."
|
||||
)
|
||||
|
||||
// handlePlay answers a Play press with the game URL carrying a signed token.
|
||||
// A /loldle card opens the player's unlimited round; every other card,
|
||||
// /loldledaily, the 07:00 push, an inline share or an expired record, opens
|
||||
// today's daily champion.
|
||||
func (s *service) handlePlay(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
return guessgame.HandlePlay(ctx, b, update.CallbackQuery, guessgame.PlayParams{
|
||||
Enabled: s.enabled(),
|
||||
Cards: s.cards,
|
||||
Now: s.cfg.now(),
|
||||
TokenKey: s.tokenKey,
|
||||
PageURL: s.cfg.baseURL + routePrefix,
|
||||
MsgDisabled: msgDisabled,
|
||||
MsgNoTarget: msgNoGameTarget,
|
||||
MsgCardLookup: msgCardLookup,
|
||||
LogName: ShortName,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *service) signToken(c guessgame.Claims) (string, error) {
|
||||
return guessgame.SignToken(s.tokenKey, c)
|
||||
}
|
||||
|
||||
func (s *service) verifyToken(token string, now time.Time) (guessgame.Claims, error) {
|
||||
return guessgame.VerifyToken(s.tokenKey, token, now)
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
// Package loldle implements the loldle classic mode — guess the League
|
||||
// champion from attribute hints (gender, species, regions, etc.).
|
||||
package loldle
|
||||
|
||||
import (
|
||||
@@ -8,11 +6,15 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Champion is one row of champions.json. Field tags match loldle.net's
|
||||
// scraped schema verbatim so the embedded JSON file can be regenerated from
|
||||
// the upstream scrape without a transform step.
|
||||
// Champion is one row of champions.json. The attribute tags match
|
||||
// loldle.net's scraped schema verbatim so the embedded JSON file can be
|
||||
// regenerated from the upstream scrape without a transform step. ID (the
|
||||
// Data Dragon key, which names the champion's icon) and Title come from
|
||||
// Riot's Data Dragon by name; see docs/loldle.md.
|
||||
type Champion struct {
|
||||
ChampionName string `json:"championName"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Gender string `json:"gender"`
|
||||
Positions []string `json:"positions"`
|
||||
Species []string `json:"species"`
|
||||
@@ -28,7 +30,13 @@ type Champion struct {
|
||||
//go:embed data/champions.json
|
||||
var rawChampions []byte
|
||||
|
||||
// loadChampions parses the embedded JSON. Panics on malformed data —
|
||||
// tileIDs overrides the icon id of champions whose Data Dragon tile file is
|
||||
// spelled differently from their key. The CDN is case-sensitive: the tile is
|
||||
// FiddleSticks_0.jpg, while tiles/Fiddlesticks_0.jpg answers 403.
|
||||
var tileIDs = map[string]string{"Fiddlesticks": "FiddleSticks"}
|
||||
|
||||
// loadChampions parses the embedded JSON and applies tileIDs, so every ID
|
||||
// names a tile that loads. Panics on malformed data —
|
||||
// a corrupt regen of champions.json is a build-time bug, not a runtime
|
||||
// concern worth recovering from.
|
||||
func loadChampions() []Champion {
|
||||
@@ -39,5 +47,10 @@ func loadChampions() []Champion {
|
||||
if len(cs) == 0 {
|
||||
panic("loldle: champions.json contained no records")
|
||||
}
|
||||
for i := range cs {
|
||||
if id, ok := tileIDs[cs[i].ID]; ok {
|
||||
cs[i].ID = id
|
||||
}
|
||||
}
|
||||
return cs
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package loldle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame"
|
||||
)
|
||||
|
||||
const (
|
||||
newRoundHint = "🆕 Send <code>/loldle</code> or <code>/loldle <champion></code> to start a new round."
|
||||
|
||||
msgChannel = "LoLdle can't be played in channels."
|
||||
msgSendFail = "Couldn't send LoLdle. Try again later."
|
||||
msgDailyChannel = "LoLdle Daily can't be played in channels."
|
||||
msgDailySendFail = "Couldn't send LoLdle Daily. Try again later."
|
||||
msgNoStore = "LoLdle isn't available on this server."
|
||||
msgNoPlayer = "Cannot identify the player."
|
||||
msgNotYourself = "Post as yourself to play: anonymous admins and posts on behalf of a chat have no round of their own."
|
||||
)
|
||||
|
||||
// The /loldle commands play the caller's unlimited round, the one the
|
||||
// /loldle card opens: each player has their own, in groups too. A chat's
|
||||
// /loldle_setmax sets the length of the rounds started there.
|
||||
|
||||
// player returns the caller, replying itself when the command cannot be
|
||||
// played here.
|
||||
func (s *service) player(ctx context.Context, b *bot.Bot, msg *models.Message) (int64, bool) {
|
||||
switch {
|
||||
case msg.Chat.Type == models.ChatTypeChannel:
|
||||
_ = chathelper.Reply(ctx, b, msg, msgChannel)
|
||||
case s.rounds == nil:
|
||||
_ = chathelper.Reply(ctx, b, msg, msgNoStore)
|
||||
case msg.From == nil || msg.From.ID <= 0:
|
||||
_ = chathelper.Reply(ctx, b, msg, msgNoPlayer)
|
||||
case msg.From.IsBot || msg.SenderChat != nil:
|
||||
// Telegram's stand-in senders (GroupAnonymousBot, Channel_Bot) share
|
||||
// one id across every chat, so they would share one round.
|
||||
_ = chathelper.Reply(ctx, b, msg, msgNotYourself)
|
||||
default:
|
||||
return msg.From.ID, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// maxFor is the length of an unlimited round started in subject's chat.
|
||||
func (s *service) maxFor(ctx context.Context, subject string) (int, error) {
|
||||
if subject == "" {
|
||||
return MaxGuesses, nil
|
||||
}
|
||||
return getMaxGuesses(ctx, s.settings, subject)
|
||||
}
|
||||
|
||||
// current returns the caller's round to play on: their round being played,
|
||||
// or a new one when the last is over, since a finished round only waits to
|
||||
// be looked at.
|
||||
func (s *service) current(ctx context.Context, uid int64, maxGuesses int) (guessgame.RoundState, error) {
|
||||
st, err := s.rounds.Load(ctx, uid, maxGuesses)
|
||||
if err != nil || !st.Round.Finished() {
|
||||
return st, err
|
||||
}
|
||||
return s.rounds.New(ctx, uid, st.Round.Seq, maxGuesses)
|
||||
}
|
||||
|
||||
// handleLoldle is /loldle [champion]. Without a champion it sends the
|
||||
// unlimited card, or shows the round's board when the web game is not
|
||||
// configured; with one it plays that guess on the caller's round.
|
||||
func (s *service) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
uid, ok := s.player(ctx, b, msg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
maxGuesses, err := s.maxFor(ctx, chathelper.SubjectFor(msg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
if arg == "" {
|
||||
st, err := s.current(ctx, uid, maxGuesses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.enabled() {
|
||||
return s.sendCard(ctx, b, msg)
|
||||
}
|
||||
header := fmt.Sprintf("Guess %d/%d. Use <code>/loldle <champion></code>.", len(st.Round.Guesses), st.Round.MaxGuesses)
|
||||
return chathelper.ReplyHTML(ctx, b, msg, header+"\n\n"+renderBoard(s.boardOf(st.Round)))
|
||||
}
|
||||
|
||||
st, err := s.rounds.Guess(ctx, uid, 0, arg, maxGuesses)
|
||||
if errors.Is(err, guessgame.ErrFinished) {
|
||||
// The last round is over: this guess opens the next one.
|
||||
if _, err = s.rounds.New(ctx, uid, st.Round.Seq, maxGuesses); err == nil {
|
||||
st, err = s.rounds.Guess(ctx, uid, 0, arg, maxGuesses)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, errAmbiguous):
|
||||
return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Ambiguous champion %q. Type the full champion name.", arg))
|
||||
case errors.Is(err, errUnknownChampion):
|
||||
return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Champion not found: %q.", arg))
|
||||
case errors.Is(err, errDuplicate):
|
||||
name := arg
|
||||
if c, _ := findChampionMatch(s.cfg.champions, arg); c != nil {
|
||||
name = c.ChampionName
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf(
|
||||
"🔁 <b>%s</b> was already guessed this round — try another champion.", html.EscapeString(name)))
|
||||
case errors.Is(err, errTargetGone):
|
||||
// champions.json was refreshed and the answer is gone: drop the
|
||||
// round without counting it.
|
||||
if _, err := s.rounds.Replace(ctx, uid, maxGuesses); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg, "Champion data was updated since this round started. "+newRoundHint)
|
||||
case err != nil:
|
||||
return err
|
||||
}
|
||||
|
||||
rd := st.Round
|
||||
last := rd.Guesses[len(rd.Guesses)-1]
|
||||
rendered := renderGuess(last.Word, s.rowsOf(last))
|
||||
champ := html.EscapeString(rd.Target)
|
||||
switch rd.Status {
|
||||
case guessgame.StatusWon:
|
||||
trySendSticker(ctx, b, msg, winStickers)
|
||||
return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf(
|
||||
"%s\n\n🎉 %s %s\n⏱ %s · 🔥 Streak: %d (%d/%d)\n%s",
|
||||
rendered, attemptFlavor(len(rd.Guesses), rd.MaxGuesses), champ, formatDuration(rd.FinishedAt-rd.StartedAt),
|
||||
st.Stats.CurStreak, len(rd.Guesses), rd.MaxGuesses, newRoundHint))
|
||||
case guessgame.StatusLost:
|
||||
trySendSticker(ctx, b, msg, loseStickers)
|
||||
return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf(
|
||||
"%s\n\n❌ Out of guesses. Answer was %s.\n%s", rendered, champ, newRoundHint))
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf("%s\n\nGuess %d/%d.", rendered, len(rd.Guesses), rd.MaxGuesses))
|
||||
}
|
||||
|
||||
// handleGiveup is /loldle_giveup: it ends the caller's round as a loss and
|
||||
// reveals the answer.
|
||||
func (s *service) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
uid, ok := s.player(ctx, b, msg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
st, err := s.rounds.GiveUp(ctx, uid)
|
||||
switch {
|
||||
case errors.Is(err, guessgame.ErrNoRound):
|
||||
return chathelper.ReplyHTML(ctx, b, msg, "No active round. "+newRoundHint)
|
||||
case err != nil:
|
||||
return err
|
||||
case !st.Ended:
|
||||
return chathelper.ReplyHTML(ctx, b, msg, "No active round. "+newRoundHint)
|
||||
}
|
||||
trySendSticker(ctx, b, msg, giveupStickers)
|
||||
return chathelper.ReplyHTML(ctx, b, msg,
|
||||
fmt.Sprintf("🏳️ Answer was %s.\n%s", html.EscapeString(st.Round.Target), newRoundHint))
|
||||
}
|
||||
|
||||
// handleStats is /loldle_stats: the caller's unlimited stats.
|
||||
func (s *service) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
uid, ok := s.player(ctx, b, msg)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
st, err := s.rounds.LoadStats(ctx, uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg, fmt.Sprintf(
|
||||
"📊 LoLdle stats for %s\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d",
|
||||
guessgame.DisplayName(msg.From.FirstName, msg.From.LastName),
|
||||
st.Played, st.Wins, chathelper.WinRate(st.Wins, st.Played), st.CurStreak, st.MaxStreak))
|
||||
}
|
||||
|
||||
// handleSetMax is /loldle_setmax <n> — owner-only (VisibilityPrivate). It
|
||||
// sets the length (1..MaxGuessesCap) of the unlimited rounds started in this
|
||||
// chat from now on, by command or from a card sent here; a round keeps the
|
||||
// length it started with, and daily puzzles always allow MaxGuesses.
|
||||
func (s *service) handleSetMax(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
|
||||
}
|
||||
if s.settings == nil {
|
||||
return chathelper.Reply(ctx, b, msg, msgNoStore)
|
||||
}
|
||||
n, err := strconv.Atoi(chathelper.ArgAfterCommand(msg.Text))
|
||||
if err != nil || !validMaxGuesses(n) {
|
||||
return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Usage: /loldle_setmax <1-%d>", MaxGuessesCap))
|
||||
}
|
||||
if err := setMaxGuesses(ctx, s.settings, subject, n); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg, fmt.Sprintf("✅ Loldle max guesses set to %d (applies to the next round).", n))
|
||||
}
|
||||
|
||||
// sendCard sends the recorded unlimited card into the command's chat.
|
||||
func (s *service) sendCard(ctx context.Context, b *bot.Bot, msg *models.Message) error {
|
||||
if err := s.cards.SendCard(ctx, b, msg, GameShortName); err != nil {
|
||||
_ = chathelper.Reply(ctx, b, msg, msgSendFail)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rowsOf rebuilds a stored guess's comparison rows for the chat board from
|
||||
// its marks and the guessed champion's own values. A champion removed from
|
||||
// the data since keeps its marks with blank values.
|
||||
func (s *service) rowsOf(g guessgame.Guess) []AttributeRow {
|
||||
champ := findChampionByExactName(s.cfg.champions, g.Word)
|
||||
rows := make([]AttributeRow, len(classicAttributes))
|
||||
for i, attr := range classicAttributes {
|
||||
row := attr
|
||||
row.GuessValue = attrDisplay(champ, attr)
|
||||
if i < len(g.Marks) {
|
||||
row.Result, row.Direction = markResult(g.Marks[i])
|
||||
} else {
|
||||
row.Result = ResultWrong
|
||||
}
|
||||
rows[i] = row
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// boardOf is a round's guesses as chat board entries.
|
||||
func (s *service) boardOf(rd guessgame.Round) []boardEntry {
|
||||
out := make([]boardEntry, len(rd.Guesses))
|
||||
for i, g := range rd.Guesses {
|
||||
out[i] = boardEntry{Champion: g.Word, Results: s.rowsOf(g)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// attrDisplay is a champion's own value of one attribute, as the boards
|
||||
// show it; "—" for an unknown champion.
|
||||
func attrDisplay(c *Champion, attr AttributeRow) string {
|
||||
if c == nil {
|
||||
return "—"
|
||||
}
|
||||
v := attrValue(c, attr.Key)
|
||||
if attr.Type == attrYear {
|
||||
return yearOrPlaceholder(parseYear(asString(v)))
|
||||
}
|
||||
return formatValue(v)
|
||||
}
|
||||
|
||||
// trySendSticker sends a sticker, swallowing errors. A bad/expired file_id
|
||||
// must never block the text reply that carries the round outcome.
|
||||
//
|
||||
// Threads MessageThreadID so stickers post in the same forum-supergroup topic
|
||||
// as the inbound command (omission posts to General — same bug class as the
|
||||
// Reply helper).
|
||||
func trySendSticker(ctx context.Context, b *bot.Bot, msg *models.Message, pool []string) {
|
||||
id := pickSticker(pool)
|
||||
if id == "" || msg == nil {
|
||||
return
|
||||
}
|
||||
_, _ = b.SendSticker(ctx, &bot.SendStickerParams{
|
||||
ChatID: msg.Chat.ID,
|
||||
MessageThreadID: msg.MessageThreadID,
|
||||
Sticker: &models.InputFileString{Data: id},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package loldle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame"
|
||||
"github.com/tiennm99/tiennm99bot/internal/storage"
|
||||
"github.com/tiennm99/tiennm99bot/internal/testutil"
|
||||
)
|
||||
|
||||
// chatOnly is the module without the web game: in-chat play only.
|
||||
func chatOnly(h *harness) *service {
|
||||
return newService(config{store: h.coll, now: h.clock.now, champions: loadChampions(), pick: testPick})
|
||||
}
|
||||
|
||||
func TestModule_Registration(t *testing.T) {
|
||||
mod := newService(config{champions: loadChampions()}).module()
|
||||
var names []string
|
||||
for _, c := range mod.Commands {
|
||||
names = append(names, c.Name)
|
||||
want := modules.VisibilityPublic
|
||||
if c.Name == "loldle_setmax" {
|
||||
want = modules.VisibilityPrivate
|
||||
}
|
||||
if c.Visibility != want || c.Description == "" {
|
||||
t.Errorf("command %+v", c)
|
||||
}
|
||||
}
|
||||
if strings.Join(names, " ") != "loldle loldle_giveup loldle_stats loldle_setmax loldledaily loldledaily_subscribe loldledaily_unsubscribe" {
|
||||
t.Fatalf("commands = %v", names)
|
||||
}
|
||||
if mod.Commands[0].Parameters != "[champion]" {
|
||||
t.Fatalf("parameters = %q", mod.Commands[0].Parameters)
|
||||
}
|
||||
if len(mod.Games) != 1 || mod.Games[0].ShortName != "loldle" || len(mod.HTTP) != 0 || len(mod.Crons) != 0 {
|
||||
t.Fatalf("disabled module: %+v", mod)
|
||||
}
|
||||
h := newHarness(t)
|
||||
mod = h.svc.module()
|
||||
if mod.HTTP[0].Pattern != "/games/loldle/" || mod.Crons[0].Name != "loldledaily_daily_push" || mod.Crons[0].Schedule != "0 0 * * *" ||
|
||||
mod.Crons[1].Name != "loldle_unlimited_cards" || mod.Crons[1].Schedule != "40 20 * * *" {
|
||||
t.Fatalf("enabled module: %+v %+v", mod.HTTP, mod.Crons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_FromEnv(t *testing.T) {
|
||||
t.Setenv("GAME_BASE_URL", "https://game.example/")
|
||||
t.Setenv(secretEnv, "")
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
if mod := New(modules.Deps{Bot: rb.Bot, Store: storage.NewMemoryProvider().Collection(ShortName)}); len(mod.HTTP) != 1 {
|
||||
t.Fatalf("routes = %+v", mod.HTTP)
|
||||
}
|
||||
if mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection(ShortName)}); len(mod.HTTP) != 0 {
|
||||
t.Fatal("enabled without a bot or secret")
|
||||
}
|
||||
t.Setenv(secretEnv, "short")
|
||||
if mod := New(modules.Deps{Bot: rb.Bot, Store: storage.NewMemoryProvider().Collection(ShortName)}); len(mod.HTTP) != 0 {
|
||||
t.Fatal("enabled with a short secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoldle_NoArgSendsARecordedCard(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
rb := install(t, h.svc.module(), 0)
|
||||
send(t, rb, groupChat, 1, "/loldle")
|
||||
games := sentMethod(rb, "sendGame")
|
||||
if len(games) != 1 || games[0].Form["game_short_name"] != GameShortName || games[0].ChatID() != "-100" {
|
||||
t.Fatalf("sent %+v", rb.Sent())
|
||||
}
|
||||
docs, _ := storage.Typed[guessgame.CardRecord](h.coll).Scan(context.Background(), "ucard:")
|
||||
if len(docs) != 1 || docs[0].Val.ChatID != groupChat {
|
||||
t.Fatalf("cards = %+v", docs)
|
||||
}
|
||||
// Play on it opens unlimited mode; any other card plays daily.
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGameCallback(1, groupChat, docs[0].Val.MessageID, GameShortName))
|
||||
answers := sentMethod(rb, "answerCallbackQuery")
|
||||
if len(answers) != 1 || !strings.HasPrefix(answers[0].Form["url"], testBase+routePrefix+"?t=") {
|
||||
t.Fatalf("answers = %+v", answers)
|
||||
}
|
||||
tok := strings.TrimPrefix(answers[0].Form["url"], testBase+routePrefix+"?t=")
|
||||
if c, err := h.svc.verifyToken(urlUnescape(t, tok), h.clock.now()); err != nil || !c.Unlimited() {
|
||||
t.Fatalf("claims = %+v, %v", c, err)
|
||||
}
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGameCallback(1, groupChat, 999, GameShortName))
|
||||
tok = strings.TrimPrefix(sentMethod(rb, "answerCallbackQuery")[0].Form["url"], testBase+routePrefix+"?t=")
|
||||
if c, _ := h.svc.verifyToken(urlUnescape(t, tok), h.clock.now()); c.Unlimited() {
|
||||
t.Fatalf("unrecorded card opened unlimited: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoldle_NoArgWithoutTheWebGameShowsTheBoard(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
rb := install(t, chatOnly(h).module(), 0)
|
||||
if got := send(t, rb, 1, 1, "/loldle"); !strings.Contains(got, "Guess 0/8") || !strings.Contains(got, "No guesses yet") {
|
||||
t.Fatalf("board = %q", got)
|
||||
}
|
||||
send(t, rb, 1, 1, "/loldle akali")
|
||||
if got := send(t, rb, 1, 1, "/loldle"); !strings.Contains(got, "Guess 1/8") || !strings.Contains(got, "AKALI") || !strings.Contains(got, "2010 ⬆️") {
|
||||
t.Fatalf("board = %q", got)
|
||||
}
|
||||
if len(sentMethod(rb, "sendGame")) != 0 {
|
||||
t.Fatal("disabled game sent a card")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoldle_GuessesPlayTheCallersRound(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
rb := install(t, h.svc.module(), 0)
|
||||
if got := send(t, rb, groupChat, 1, "/loldle akali"); !strings.Contains(got, "AKALI") || !strings.HasSuffix(got, "Guess 1/8.") {
|
||||
t.Fatalf("guess = %q", got)
|
||||
}
|
||||
if got := send(t, rb, groupChat, 1, "/loldle zzzz"); got != `Champion not found: "zzzz".` {
|
||||
t.Fatalf("unknown = %q", got)
|
||||
}
|
||||
if got := send(t, rb, groupChat, 1, "/loldle ka"); got != `Ambiguous champion "ka". Type the full champion name.` {
|
||||
t.Fatalf("ambiguous = %q", got)
|
||||
}
|
||||
if got := send(t, rb, groupChat, 1, "/loldle AKALI"); !strings.Contains(got, "<b>Akali</b> was already guessed") {
|
||||
t.Fatalf("duplicate = %q", got)
|
||||
}
|
||||
// Bob in the same group has his own round.
|
||||
got := send(t, rb, groupChat, 2, "/loldle ahri")
|
||||
if !strings.Contains(got, "🎉 First try! Ahri") || !strings.Contains(got, "🔥 Streak: 1 (1/8)") || len(sentMethod(rb, "sendSticker")) != 1 {
|
||||
t.Fatalf("bob = %q", got)
|
||||
}
|
||||
// The page and the chat share Alice's round.
|
||||
h.roundGuess(h.token(1, groupChat, 5, "Alice", true), 1, "Wukong")
|
||||
got = send(t, rb, groupChat, 1, "/loldle ahri")
|
||||
if !strings.Contains(got, "🎉") || !strings.Contains(got, "(3/8)") {
|
||||
t.Fatalf("alice = %q", got)
|
||||
}
|
||||
// After a finish the next guess opens the next round (Jinx).
|
||||
if got := send(t, rb, groupChat, 1, "/loldle ahri"); !strings.HasSuffix(got, "Guess 1/8.") {
|
||||
t.Fatalf("next round = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoldle_LossAndGiveup(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
rb := install(t, h.svc.module(), 7)
|
||||
send(t, rb, 7, 7, "/loldle_setmax 2")
|
||||
if got := send(t, rb, 7, 7, "/loldle_giveup"); !strings.Contains(got, "No active round") {
|
||||
t.Fatalf("no round = %q", got)
|
||||
}
|
||||
send(t, rb, 7, 7, "/loldle akali")
|
||||
got := send(t, rb, 7, 7, "/loldle wukong")
|
||||
if !strings.Contains(got, "❌ Out of guesses. Answer was Ahri.") || len(sentMethod(rb, "sendSticker")) != 1 {
|
||||
t.Fatalf("loss = %q", got)
|
||||
}
|
||||
if got := send(t, rb, 7, 7, "/loldle_giveup"); !strings.Contains(got, "No active round") {
|
||||
t.Fatalf("giveup after a loss = %q", got)
|
||||
}
|
||||
send(t, rb, 7, 7, "/loldle akali") // round 2: Jinx
|
||||
if got := send(t, rb, 7, 7, "/loldle_giveup"); !strings.HasPrefix(got, "🏳️ Answer was Jinx.") || len(sentMethod(rb, "sendSticker")) != 1 {
|
||||
t.Fatalf("giveup = %q", got)
|
||||
}
|
||||
if got := send(t, rb, 7, 7, "/loldle_stats"); got != "📊 LoLdle stats for Test\nPlayed: 2\nWins: 0 (0%)\nCurrent streak: 0\nBest streak: 0" {
|
||||
t.Fatalf("stats = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoldleSetMax_AppliesToRoundsStartedInThatChatOnly(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
rb := install(t, h.svc.module(), 7)
|
||||
send(t, rb, groupChat, 1, "/loldle akali") // starts with the default 8
|
||||
if got := send(t, rb, groupChat, 7, "/loldle_setmax 3"); got != "✅ Loldle max guesses set to 3 (applies to the next round)." {
|
||||
t.Fatalf("setmax = %q", got)
|
||||
}
|
||||
if got := send(t, rb, groupChat, 1, "/loldle wukong"); !strings.HasSuffix(got, "Guess 2/8.") {
|
||||
t.Fatalf("running round = %q", got)
|
||||
}
|
||||
send(t, rb, groupChat, 1, "/loldle_giveup")
|
||||
if got := send(t, rb, groupChat, 1, "/loldle akali"); !strings.HasSuffix(got, "Guess 1/3.") {
|
||||
t.Fatalf("next round = %q", got)
|
||||
}
|
||||
// A card from the group starts rounds of 3 too; a private card keeps 8.
|
||||
if r := h.post("new", map[string]any{"token": h.token(1, groupChat, 5, "A", true), "seq": 2}); r.v.Max != 3 {
|
||||
t.Fatalf("group card round = %s", r.body)
|
||||
}
|
||||
if r := h.state(h.token(2, 2, 5, "B", true)); r.v.Max != 8 {
|
||||
t.Fatalf("private card round = %s", r.body)
|
||||
}
|
||||
for _, arg := range []string{"0", "11", "x"} {
|
||||
if got := send(t, rb, groupChat, 7, "/loldle_setmax "+arg); got != "Usage: /loldle_setmax <1-10>" {
|
||||
t.Errorf("setmax %s = %q", arg, got)
|
||||
}
|
||||
}
|
||||
// Only the owner may run it.
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(ctx, testutil.NewGroupMessage(groupChat, 1, "/loldle_setmax 5"))
|
||||
if n, _ := getMaxGuesses(ctx, h.svc.settings, "-100"); n != 3 {
|
||||
t.Fatalf("non-owner changed setmax to %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoldle_ChannelRefused(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
rb := install(t, h.svc.module(), 0)
|
||||
for _, cmd := range []string{"/loldle", "/loldle_giveup", "/loldle_stats"} {
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewChannelMessage(-300, cmd))
|
||||
if got := rb.LastSent().Text(); got != msgChannel {
|
||||
t.Errorf("%s = %q", cmd, got)
|
||||
}
|
||||
}
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewChannelMessage(-300, "/loldledaily"))
|
||||
if got := rb.LastSent().Text(); got != msgDailyChannel {
|
||||
t.Errorf("/loldledaily = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous admins and posts on behalf of a chat arrive from one shared
|
||||
// stand-in bot id, so they are refused instead of sharing one round.
|
||||
func TestLoldle_RefusesStandInSenders(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
rb := install(t, h.svc.module(), 0)
|
||||
for _, cmd := range []string{"/loldle Ahri", "/loldle_giveup", "/loldle_stats"} {
|
||||
rb.Reset()
|
||||
u := testutil.NewGroupMessage(-100, 136817688, cmd)
|
||||
u.Message.From.IsBot = true
|
||||
u.Message.SenderChat = &models.Chat{ID: -1001, Type: models.ChatTypeChannel}
|
||||
rb.Bot.ProcessUpdate(context.Background(), u)
|
||||
if got := rb.LastSent().Text(); got != msgNotYourself {
|
||||
t.Errorf("%s on behalf of a channel = %q", cmd, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A round whose answer left the data is replaced without counting.
|
||||
func TestLoldle_TargetGoneStartsANewRound(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
ctx := context.Background()
|
||||
rounds := storage.Typed[guessgame.Round](h.coll)
|
||||
if err := rounds.Put(ctx, guessgame.RoundKey(1), guessgame.Round{Seq: 4, Target: "Nobody", Guesses: []guessgame.Guess{}, Status: guessgame.StatusPlaying, MaxGuesses: 8}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rb := install(t, h.svc.module(), 0)
|
||||
if got := send(t, rb, 1, 1, "/loldle ahri"); !strings.HasPrefix(got, "Champion data was updated since this round started.") {
|
||||
t.Fatalf("reply = %q", got)
|
||||
}
|
||||
rd, _, _ := rounds.Get(ctx, guessgame.RoundKey(1))
|
||||
st, _ := h.svc.rounds.LoadStats(ctx, 1)
|
||||
if rd.Seq != 5 || rd.Target != "Ahri" || st.Played != 0 {
|
||||
t.Fatalf("round %+v stats %+v", rd, st)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,298 +0,0 @@
|
||||
package loldle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/keylock"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/chathelper"
|
||||
)
|
||||
|
||||
const newRoundHint = "🆕 Send <code>/loldle</code> or <code>/loldle <champion></code> to start a new round."
|
||||
|
||||
// state captures everything a loldle handler needs at runtime. Built once
|
||||
// per Factory call and shared across the four command closures.
|
||||
type state struct {
|
||||
games GameStore
|
||||
stats StatsStore
|
||||
cfg ConfigStore
|
||||
champions []Champion
|
||||
locks keylock.Map // serialises Get→mutate→Put per subject
|
||||
}
|
||||
|
||||
// pickRandomChampion uses math/rand's mutex-protected globals so concurrent
|
||||
// /loldle handlers don't race on a shared *rand.Rand.
|
||||
func (s *state) pickRandomChampion() *Champion {
|
||||
return &s.champions[rand.Intn(len(s.champions))]
|
||||
}
|
||||
|
||||
func (s *state) findByName(name string) *Champion {
|
||||
return findChampionByExactName(s.champions, name)
|
||||
}
|
||||
|
||||
// rehydrateGuesses recomputes board rows from the stored championNames.
|
||||
// Guesses naming champions removed from champions.json since the round
|
||||
// started are skipped, so a data refresh never breaks an active round. A
|
||||
// missing target yields an empty board; handleLoldle clears that round on
|
||||
// the next guess.
|
||||
func (s *state) rehydrateGuesses(g *gameState) []boardEntry {
|
||||
target := s.findByName(g.Target)
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]boardEntry, 0, len(g.Guesses))
|
||||
for _, name := range g.Guesses {
|
||||
guess := s.findByName(name)
|
||||
if guess == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, boardEntry{Champion: name, Results: CompareChampions(guess, target)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// startFreshGame writes a new round with no startedAt clock yet.
|
||||
func (s *state) startFreshGame(ctx context.Context, subject string, maxGuesses int) (*gameState, error) {
|
||||
target := s.pickRandomChampion()
|
||||
g := &gameState{
|
||||
Target: target.ChampionName,
|
||||
Guesses: []string{},
|
||||
StartedAt: nil,
|
||||
MaxGuesses: normalizeMaxGuesses(maxGuesses),
|
||||
}
|
||||
if err := saveGame(ctx, s.games, subject, g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// getOrInitGame loads the active round; if absent (or the saved round has
|
||||
// already exhausted its budget — defensive), starts a fresh one.
|
||||
func (s *state) getOrInitGame(ctx context.Context, subject string, maxGuesses int) (*gameState, error) {
|
||||
existing, err := loadGame(ctx, s.games, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
roundMax := existing.roundMaxGuesses()
|
||||
if len(existing.Guesses) < roundMax {
|
||||
if existing.MaxGuesses != roundMax {
|
||||
existing.MaxGuesses = roundMax
|
||||
if err := saveGame(ctx, s.games, subject, existing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
return s.startFreshGame(ctx, subject, maxGuesses)
|
||||
}
|
||||
|
||||
// trySendSticker sends a sticker, swallowing errors. A bad/expired file_id
|
||||
// must never block the text reply that carries the round outcome.
|
||||
//
|
||||
// Threads MessageThreadID so stickers post in the same forum-supergroup topic
|
||||
// as the inbound command (omission posts to General — same bug class as the
|
||||
// Reply helper).
|
||||
func trySendSticker(ctx context.Context, b *bot.Bot, msg *models.Message, pool []string) {
|
||||
id := pickSticker(pool)
|
||||
if id == "" || msg == nil {
|
||||
return
|
||||
}
|
||||
_, _ = b.SendSticker(ctx, &bot.SendStickerParams{
|
||||
ChatID: msg.Chat.ID,
|
||||
MessageThreadID: msg.MessageThreadID,
|
||||
Sticker: &models.InputFileString{Data: id},
|
||||
})
|
||||
}
|
||||
|
||||
// handleLoldle is /loldle [champion] — show board if no arg, else submit guess.
|
||||
func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
|
||||
maxGuesses, err := getMaxGuesses(ctx, s.cfg, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
game, err := s.getOrInitGame(ctx, subject, maxGuesses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
roundMax := game.roundMaxGuesses()
|
||||
|
||||
if arg == "" {
|
||||
header := fmt.Sprintf("Guess %d/%d. Use <code>/loldle <champion></code>.",
|
||||
len(game.Guesses), roundMax)
|
||||
board := renderBoard(s.rehydrateGuesses(game))
|
||||
return chathelper.ReplyHTML(ctx, b, msg, header+"\n\n"+board)
|
||||
}
|
||||
|
||||
guess, ambiguous := findChampionMatch(s.champions, arg)
|
||||
if ambiguous {
|
||||
return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Ambiguous champion %q. Type the full champion name.", arg))
|
||||
}
|
||||
if guess == nil {
|
||||
return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Champion not found: %q.", arg))
|
||||
}
|
||||
|
||||
for _, prior := range game.Guesses {
|
||||
if prior == guess.ChampionName {
|
||||
return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf(
|
||||
"🔁 <b>%s</b> was already guessed this round — try another champion.",
|
||||
html.EscapeString(guess.ChampionName)))
|
||||
}
|
||||
}
|
||||
|
||||
target := s.findByName(game.Target)
|
||||
if target == nil {
|
||||
// champions.json was refreshed and the target is gone. Drop the
|
||||
// round so the next /loldle starts cleanly.
|
||||
if err := clearGame(ctx, s.games, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg,
|
||||
"Champion data was updated since this round started. "+newRoundHint)
|
||||
}
|
||||
|
||||
results := CompareChampions(guess, target)
|
||||
if game.StartedAt == nil {
|
||||
now := chathelper.NowMillis()
|
||||
game.StartedAt = &now
|
||||
}
|
||||
game.Guesses = append(game.Guesses, guess.ChampionName)
|
||||
won := guess.ChampionName == target.ChampionName
|
||||
|
||||
rendered := renderGuess(guess.ChampionName, results)
|
||||
champ := html.EscapeString(target.ChampionName)
|
||||
elapsed := formatDuration(chathelper.NowMillis() - *game.StartedAt)
|
||||
|
||||
switch {
|
||||
case won:
|
||||
st, err := recordResult(ctx, s.stats, subject, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.games, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
trySendSticker(ctx, b, msg, winStickers)
|
||||
flavor := attemptFlavor(len(game.Guesses), roundMax)
|
||||
return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf(
|
||||
"%s\n\n🎉 %s %s\n⏱ %s · 🔥 Streak: %d (%d/%d)\n%s",
|
||||
rendered, flavor, champ, elapsed, st.Streak, len(game.Guesses), roundMax, newRoundHint))
|
||||
|
||||
case len(game.Guesses) >= roundMax:
|
||||
if _, err := recordResult(ctx, s.stats, subject, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.games, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
trySendSticker(ctx, b, msg, loseStickers)
|
||||
return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf(
|
||||
"%s\n\n❌ Out of guesses. Answer was %s.\n%s", rendered, champ, newRoundHint))
|
||||
|
||||
default:
|
||||
if err := saveGame(ctx, s.games, subject, game); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf(
|
||||
"%s\n\nGuess %d/%d.", rendered, len(game.Guesses), roundMax))
|
||||
}
|
||||
}
|
||||
|
||||
// handleGiveup is /loldle_giveup — reveal answer + clear round.
|
||||
func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
|
||||
existing, err := loadGame(ctx, s.games, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
return chathelper.ReplyHTML(ctx, b, msg, "No active round. "+newRoundHint)
|
||||
}
|
||||
if _, err := recordResult(ctx, s.stats, subject, false); err != nil {
|
||||
return err
|
||||
}
|
||||
target := s.findByName(existing.Target)
|
||||
if err := clearGame(ctx, s.games, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
trySendSticker(ctx, b, msg, giveupStickers)
|
||||
answer := existing.Target
|
||||
if target != nil {
|
||||
answer = target.ChampionName
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg,
|
||||
fmt.Sprintf("🏳️ Answer was %s.\n%s", html.EscapeString(answer), newRoundHint))
|
||||
}
|
||||
|
||||
// handleStats is /loldle_stats — lifetime score.
|
||||
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
|
||||
}
|
||||
st, err := loadStats(ctx, s.stats, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scope := "group"
|
||||
if msg.Chat.Type == models.ChatTypePrivate {
|
||||
scope = "your"
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg, fmt.Sprintf(
|
||||
"📊 Loldle %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d",
|
||||
scope, st.Played, st.Wins, chathelper.WinRate(st.Wins, st.Played), st.Streak, st.BestStreak))
|
||||
}
|
||||
|
||||
// handleSetMax is /loldle_setmax <n> — owner-only (VisibilityPrivate), sets
|
||||
// the per-subject MaxGuesses override (1..MaxGuessesCap). Takes effect on the
|
||||
// next round because an active round keeps its frozen MaxGuesses.
|
||||
func (s *state) handleSetMax(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg, "Cannot identify chat.")
|
||||
}
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
n, err := strconv.Atoi(arg)
|
||||
if err != nil || n < 1 || n > MaxGuessesCap {
|
||||
return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Usage: /loldle_setmax <1-%d>", MaxGuessesCap))
|
||||
}
|
||||
if err := setMaxGuesses(ctx, s.cfg, subject, n); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg, fmt.Sprintf("✅ Loldle max guesses set to %d (applies to the next round).", n))
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
package loldle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules"
|
||||
"github.com/tiennm99/tiennm99bot/internal/storage"
|
||||
"github.com/tiennm99/tiennm99bot/internal/testutil"
|
||||
)
|
||||
|
||||
// installLoldle wires the loldle module + auth (owner gates /loldle_setmax,
|
||||
// which is private). seedTarget pre-seeds a game so guess outcomes are
|
||||
// deterministic without hooking math/rand.
|
||||
func installLoldle(t *testing.T, ownerID int64, seedSubject, seedTarget string) (*testutil.RecordingBot, GameStore, ConfigStore) {
|
||||
t.Helper()
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
col := storage.NewMemoryProvider().Collection("loldle")
|
||||
games := storage.Typed[gameState](col)
|
||||
cfg := storage.Typed[roundConfig](col)
|
||||
mod := New(modules.Deps{Store: col})
|
||||
reg := &modules.Registry{
|
||||
Modules: []modules.Module{{Name: "loldle", Commands: mod.Commands}},
|
||||
AllCommands: map[string]modules.Command{},
|
||||
}
|
||||
for _, c := range mod.Commands {
|
||||
reg.AllCommands[c.Name] = c
|
||||
}
|
||||
modules.Install(rb.Bot, reg, modules.Auth{BotOwnerID: ownerID})
|
||||
|
||||
if seedTarget != "" {
|
||||
g := &gameState{Target: seedTarget, Guesses: []string{}}
|
||||
if err := saveGame(context.Background(), games, seedSubject, g); err != nil {
|
||||
t.Fatalf("seed game: %v", err)
|
||||
}
|
||||
}
|
||||
return rb, games, cfg
|
||||
}
|
||||
|
||||
func TestLoldle_NoArgShowsBoard(t *testing.T) {
|
||||
rb, _, _ := installLoldle(t, 0, "1", "Aatrox")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Guess 0/") {
|
||||
t.Errorf("/loldle no-arg reply missing 'Guess 0/...': %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoldle_Win(t *testing.T) {
|
||||
rb, _, _ := installLoldle(t, 0, "1", "Aatrox")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle aatrox"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(strings.ToLower(got), "aatrox") {
|
||||
t.Errorf("win reply missing 'Aatrox': %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "🎉") {
|
||||
t.Errorf("win reply missing celebration emoji: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoldle_UnknownChampion(t *testing.T) {
|
||||
rb, _, _ := installLoldle(t, 0, "1", "Aatrox")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle ZilbeanZ"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Champion not found") {
|
||||
t.Errorf("unknown champion reject: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoldle_AmbiguousChampionPrefix(t *testing.T) {
|
||||
rb, _, _ := installLoldle(t, 0, "1", "Aatrox")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle A"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Ambiguous champion") || !strings.Contains(got, "full champion name") {
|
||||
t.Errorf("ambiguous champion reject: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoldle_DuplicateGuessRejected(t *testing.T) {
|
||||
rb, _, _ := installLoldle(t, 0, "1", "Aatrox")
|
||||
// First guess: Ahri (not the target — round continues).
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle ahri"))
|
||||
rb.Reset()
|
||||
// Second guess: Ahri again — duplicate path.
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle ahri"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "already guessed") {
|
||||
t.Errorf("duplicate-guess reply: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoldleGiveup_RevealsAnswer(t *testing.T) {
|
||||
rb, _, _ := installLoldle(t, 0, "1", "Aatrox")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_giveup"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Aatrox") {
|
||||
t.Errorf("/loldle_giveup should reveal Aatrox: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoldleGiveup_NoActiveRound(t *testing.T) {
|
||||
rb, _, _ := installLoldle(t, 0, "", "")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_giveup"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "No active round") {
|
||||
t.Errorf("/loldle_giveup with no round: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoldleStats_Empty(t *testing.T) {
|
||||
rb, _, _ := installLoldle(t, 0, "", "")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_stats"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
for _, want := range []string{"Played: 0", "Wins: 0 (0%)", "Best streak: 0"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("/loldle_stats empty missing %q; got %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoldleSetMax_OwnerSucceeds(t *testing.T) {
|
||||
rb, _, cfg := installLoldle(t, 999, "", "")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_setmax 5"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "max guesses set to 5") {
|
||||
t.Errorf("/loldle_setmax reply: %q", got)
|
||||
}
|
||||
c, _, err := cfg.Get(context.Background(), configKey("999"))
|
||||
if err != nil {
|
||||
t.Fatalf("expected config persisted: %v", err)
|
||||
}
|
||||
if c.MaxGuesses != 5 {
|
||||
t.Errorf("MaxGuesses persisted = %d, want 5", c.MaxGuesses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoldleSetMax_AppliesNextRoundOnly(t *testing.T) {
|
||||
rb, _, _ := installLoldle(t, 999, "999", "Aatrox")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_setmax 1"))
|
||||
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle"))
|
||||
if got := rb.LastSent().Text(); !strings.Contains(got, "Guess 0/8") {
|
||||
t.Fatalf("active round should keep default max after setmax: %q", got)
|
||||
}
|
||||
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_giveup"))
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle"))
|
||||
if got := rb.LastSent().Text(); !strings.Contains(got, "Guess 0/1") {
|
||||
t.Fatalf("new round should use changed max: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoldleSetMax_DeniedToNonOwner(t *testing.T) {
|
||||
rb, _, _ := installLoldle(t, 999, "", "")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/loldle_setmax 5"))
|
||||
|
||||
if calls := rb.Sent(); len(calls) != 0 {
|
||||
t.Errorf("non-owner /loldle_setmax replied: %+v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoldleSetMax_RejectsOutOfRange(t *testing.T) {
|
||||
rb, _, _ := installLoldle(t, 999, "", "")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_setmax 99"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Usage:") {
|
||||
t.Errorf("out-of-range setmax should show usage; got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package loldle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
|
||||
"github.com/tiennm99/tiennm99bot/internal/storage"
|
||||
"github.com/tiennm99/tiennm99bot/internal/testutil"
|
||||
)
|
||||
|
||||
var (
|
||||
testRoot = []byte("0123456789abcdef0123456789abcdef")
|
||||
testStart = guessgame.Epoch.Add(12 * time.Hour) // 19:00 ICT on puzzle #1's day
|
||||
testBase = "https://game.example"
|
||||
editStub = `{"message_id":1,"date":0,"chat":{"id":-100,"type":"supergroup"}}`
|
||||
groupChat int64 = -100
|
||||
)
|
||||
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *fakeClock) now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
}
|
||||
|
||||
func (c *fakeClock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
c.t = c.t.Add(d)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
type reportCall struct {
|
||||
addr htmlgame.Address
|
||||
user int64
|
||||
score int
|
||||
}
|
||||
|
||||
type fakeReporter struct {
|
||||
mu sync.Mutex
|
||||
calls []reportCall
|
||||
}
|
||||
|
||||
func (r *fakeReporter) Report(_ context.Context, a htmlgame.Address, userID int64, score int) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls = append(r.calls, reportCall{a, userID, score})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeReporter) snapshot() []reportCall {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]reportCall(nil), r.calls...)
|
||||
}
|
||||
|
||||
// fakeScheduler holds summary flushes until the test runs them.
|
||||
type fakeScheduler struct {
|
||||
mu sync.Mutex
|
||||
fns []func()
|
||||
}
|
||||
|
||||
func (f *fakeScheduler) schedule(_ time.Duration, fn func()) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.fns = append(f.fns, fn)
|
||||
}
|
||||
|
||||
func (f *fakeScheduler) run() {
|
||||
for {
|
||||
f.mu.Lock()
|
||||
if len(f.fns) == 0 {
|
||||
f.mu.Unlock()
|
||||
return
|
||||
}
|
||||
fn := f.fns[0]
|
||||
f.fns = f.fns[1:]
|
||||
f.mu.Unlock()
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
// testPick makes unlimited rounds deterministic: Ahri, then Jinx, then Ahri
|
||||
// again.
|
||||
func testPick(prev string) string {
|
||||
if prev == "Ahri" {
|
||||
return "Jinx"
|
||||
}
|
||||
return "Ahri"
|
||||
}
|
||||
|
||||
type harness struct {
|
||||
t *testing.T
|
||||
svc *service
|
||||
clock *fakeClock
|
||||
rep *fakeReporter
|
||||
rb *testutil.RecordingBot
|
||||
sched *fakeScheduler
|
||||
coll storage.Collection
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
func testConfig(clock *fakeClock, coll storage.Collection, rep guessgame.ScoreReporter, api guessgame.TelegramAPI, sched *fakeScheduler) config {
|
||||
return config{
|
||||
baseURL: testBase,
|
||||
rootKey: testRoot,
|
||||
now: clock.now,
|
||||
store: coll,
|
||||
reporter: rep,
|
||||
api: api,
|
||||
schedule: sched.schedule,
|
||||
async: func(f func()) { f() },
|
||||
champions: loadChampions(),
|
||||
pick: testPick,
|
||||
}
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
h := &harness{
|
||||
t: t,
|
||||
clock: &fakeClock{t: testStart},
|
||||
rep: &fakeReporter{},
|
||||
rb: testutil.NewRecordingBot(t),
|
||||
sched: &fakeScheduler{},
|
||||
coll: storage.NewMemoryProvider().Collection(ShortName),
|
||||
}
|
||||
h.rb.StubMethod("editMessageText", editStub)
|
||||
h.svc = newService(testConfig(h.clock, h.coll, h.rep, h.rb.Bot, h.sched))
|
||||
mod := h.svc.module()
|
||||
if len(mod.HTTP) != 1 || len(mod.Crons) != 2 {
|
||||
t.Fatalf("enabled module: routes %d crons %d", len(mod.HTTP), len(mod.Crons))
|
||||
}
|
||||
h.handler = mod.HTTP[0].Handler
|
||||
return h
|
||||
}
|
||||
|
||||
// pinDaily makes champion the answer of daily puzzle num.
|
||||
func (h *harness) pinDaily(num int, champion string) {
|
||||
h.t.Helper()
|
||||
if err := h.svc.Puzzles.Put(context.Background(), guessgame.PuzzleKey(num), guessgame.PuzzleDoc{Num: num, Answer: champion}); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// token signs a token for user on card msg in chat; unlimited marks a
|
||||
// recorded /loldle card.
|
||||
func (h *harness) token(user, chat int64, msg int, name string, unlimited bool) string {
|
||||
h.t.Helper()
|
||||
c := guessgame.Claims{UserID: user, Name: name, ChatID: chat, MessageID: msg, Expiry: h.clock.now().Add(guessgame.TokenTTL).Unix()}
|
||||
if unlimited {
|
||||
c.Mode = guessgame.ModeUnlimited
|
||||
}
|
||||
tok, err := h.svc.signToken(c)
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
type result struct {
|
||||
code int
|
||||
body string
|
||||
v view
|
||||
err string
|
||||
}
|
||||
|
||||
func (h *harness) post(path string, body any) result {
|
||||
h.t.Helper()
|
||||
raw, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPost, routePrefix+"api/"+path, bytes.NewReader(raw))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
h.handler.ServeHTTP(rec, req)
|
||||
out, _ := io.ReadAll(rec.Result().Body)
|
||||
r := result{code: rec.Code, body: string(out)}
|
||||
if r.code == http.StatusOK {
|
||||
if err := json.Unmarshal(out, &r.v); err != nil {
|
||||
h.t.Fatalf("decode %q: %v", out, err)
|
||||
}
|
||||
} else {
|
||||
var e struct {
|
||||
Code string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(out, &e)
|
||||
r.err = e.Code
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (h *harness) state(tok string) result { return h.post("state", map[string]any{"token": tok}) }
|
||||
|
||||
func (h *harness) dailyGuess(tok string, num int, name string) result {
|
||||
return h.post("guess", map[string]any{"token": tok, "num": num, "name": name})
|
||||
}
|
||||
|
||||
func (h *harness) roundGuess(tok string, seq int, name string) result {
|
||||
return h.post("guess", map[string]any{"token": tok, "seq": seq, "name": name})
|
||||
}
|
||||
|
||||
// install registers mod through the real registry and dispatcher, with
|
||||
// owner as the bot owner.
|
||||
func install(t *testing.T, mod modules.Module, owner int64) *testutil.RecordingBot {
|
||||
t.Helper()
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
reg, err := modules.Build([]string{ShortName}, map[string]modules.Factory{
|
||||
ShortName: func(modules.Deps) modules.Module { return mod },
|
||||
}, storage.NewMemoryProvider(), modules.BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
modules.Install(rb.Bot, reg, modules.Auth{BotOwnerID: owner})
|
||||
return rb
|
||||
}
|
||||
|
||||
// send runs one command from user in chat (a group when chat < 0) and
|
||||
// returns the bot's last text reply.
|
||||
func send(t *testing.T, rb *testutil.RecordingBot, chat, user int64, text string) string {
|
||||
t.Helper()
|
||||
rb.Reset()
|
||||
var u *models.Update
|
||||
if chat < 0 {
|
||||
u = testutil.NewGroupMessage(chat, user, text)
|
||||
} else {
|
||||
u = testutil.NewPrivateMessage(user, text)
|
||||
}
|
||||
rb.Bot.ProcessUpdate(context.Background(), u)
|
||||
sent := rb.Sent()
|
||||
for i := len(sent) - 1; i >= 0; i-- {
|
||||
if sent[i].Method == "sendMessage" {
|
||||
return sent[i].Text()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sentMethod(rb *testutil.RecordingBot, method string) []testutil.SentCall {
|
||||
var out []testutil.SentCall
|
||||
for _, c := range rb.Sent() {
|
||||
if c.Method == method {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package loldle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/log"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/loldle/web"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
|
||||
)
|
||||
|
||||
// requestsPerMinute caps one player's API calls, every endpoint together.
|
||||
const requestsPerMinute = 60
|
||||
|
||||
// maxNameBytes caps a guessed champion name in a request.
|
||||
const maxNameBytes = 64
|
||||
|
||||
// apiError is the JSON error body every API failure returns.
|
||||
type apiError struct {
|
||||
status int
|
||||
Code string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
var (
|
||||
errBadRequestAPI = apiError{http.StatusBadRequest, "bad_request", "Bad request."}
|
||||
errBadTokenAPI = apiError{http.StatusUnauthorized, "bad_token", "This game link is not valid. Open the game again from the chat."}
|
||||
errExpiredAPI = apiError{http.StatusUnauthorized, "expired", "This game link has expired. Open the game again from the chat."}
|
||||
errBadModeAPI = apiError{http.StatusForbidden, "bad_mode", "This card plays the daily champion. Send /loldle for unlimited rounds."}
|
||||
errNewPuzzleAPI = apiError{http.StatusConflict, "new_puzzle", "A new daily champion is up."}
|
||||
errNewRoundAPI = apiError{http.StatusConflict, "new_round", "A new round has started."}
|
||||
errDataChangedAPI = apiError{http.StatusConflict, "new_round", "Champion data changed, so a new round has started."}
|
||||
errUnknownAPI = apiError{http.StatusUnprocessableEntity, "unknown", "Champion not found."}
|
||||
errAmbiguousAPI = apiError{http.StatusUnprocessableEntity, "ambiguous", "Several champions match. Pick one from the list."}
|
||||
errDuplicateAPI = apiError{http.StatusUnprocessableEntity, "duplicate", "You already guessed that champion."}
|
||||
errFinishedAPI = apiError{http.StatusUnprocessableEntity, "finished", "You have already finished today's champion."}
|
||||
errRoundFinishedAPI = apiError{http.StatusUnprocessableEntity, "finished", "This round is over. Start a new game."}
|
||||
errTooFastAPI = apiError{http.StatusTooManyRequests, "rate_limited", "Too many requests. Wait a moment and try again."}
|
||||
errInternalAPI = apiError{http.StatusInternalServerError, "internal", "Something went wrong. Try again later."}
|
||||
)
|
||||
|
||||
// Page modes, as the view reports them.
|
||||
const (
|
||||
modeDaily = "daily"
|
||||
modeUnlimited = "unlimited"
|
||||
)
|
||||
|
||||
// cellView is one attribute of a guess: the guessed champion's own value
|
||||
// and how it compares with the answer. It never carries the answer's value.
|
||||
type cellView struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Result string `json:"result"` // correct | partial | wrong
|
||||
Dir string `json:"dir,omitempty"` // up | down, on a wrong release year
|
||||
}
|
||||
|
||||
// rowView is one guess on the board.
|
||||
type rowView struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
Marks string `json:"marks"`
|
||||
Cells []cellView `json:"cells"`
|
||||
}
|
||||
|
||||
// champView is the answer, shown once the game is over.
|
||||
type champView struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// columnView names one attribute column.
|
||||
type columnView struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// statsView is the player's record in the card's mode, shown once the game
|
||||
// is over.
|
||||
type statsView struct {
|
||||
Played int `json:"played"`
|
||||
WinPct int `json:"win_pct"`
|
||||
Cur int `json:"cur"`
|
||||
Max int `json:"max"`
|
||||
Dist []int `json:"dist"`
|
||||
}
|
||||
|
||||
// view is the state the page renders. The answer, the stats and the share
|
||||
// text are present only once the game is over. A daily view carries the
|
||||
// puzzle's number, date and the next puzzle's start; an unlimited one the
|
||||
// round's number.
|
||||
type view struct {
|
||||
Mode string `json:"mode"`
|
||||
Num int `json:"num,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Seq int `json:"seq,omitempty"`
|
||||
Player string `json:"player"`
|
||||
Max int `json:"max"`
|
||||
Columns []columnView `json:"columns"`
|
||||
Guesses []rowView `json:"guesses"`
|
||||
Status string `json:"status"`
|
||||
GaveUp bool `json:"gave_up,omitempty"`
|
||||
Answer *champView `json:"answer,omitempty"`
|
||||
Stats *statsView `json:"stats,omitempty"`
|
||||
NextAt int64 `json:"next_at,omitempty"`
|
||||
Share string `json:"share,omitempty"`
|
||||
// Abandoned is the champion of the unlimited round a New game gave up
|
||||
// as a loss, so the page can show it before the next round.
|
||||
Abandoned *champView `json:"abandoned,omitempty"`
|
||||
}
|
||||
|
||||
type stateRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// guessRequest is a guess on the board the page shows: Num names a daily
|
||||
// puzzle, Seq an unlimited round.
|
||||
type guessRequest struct {
|
||||
Token string `json:"token"`
|
||||
Num int `json:"num"`
|
||||
Seq int `json:"seq"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// newRequest starts the next unlimited round after round Seq.
|
||||
type newRequest struct {
|
||||
Token string `json:"token"`
|
||||
Seq int `json:"seq"`
|
||||
}
|
||||
|
||||
// handler serves the page, its assets, the champion list and the JSON API
|
||||
// under routePrefix. Images may come from Data Dragon, which serves the
|
||||
// champion icons, and from nowhere else.
|
||||
func (s *service) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET "+routePrefix+"{$}", htmlgame.ServeAsset(web.FS, "index.html"))
|
||||
mux.HandleFunc("GET "+routePrefix+"app.js", htmlgame.ServeAsset(web.FS, "app.js"))
|
||||
mux.HandleFunc("GET "+routePrefix+"app.css", htmlgame.ServeAsset(web.FS, "app.css"))
|
||||
mux.HandleFunc("GET "+routePrefix+"champions.json", s.serveChampions)
|
||||
mux.HandleFunc("POST "+routePrefix+"api/state", s.apiState)
|
||||
mux.HandleFunc("POST "+routePrefix+"api/guess", s.apiGuess)
|
||||
mux.HandleFunc("POST "+routePrefix+"api/new", s.apiNew)
|
||||
return htmlgame.SecurityHeaders(routePrefix, mux, ddragonOrigin)
|
||||
}
|
||||
|
||||
// serveChampions is the search list: every champion's name and icon id,
|
||||
// nothing that hints at an answer.
|
||||
func (s *service) serveChampions(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
_, _ = w.Write(s.championList)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, e apiError) { htmlgame.WriteJSON(w, e.status, e) }
|
||||
|
||||
// authorize verifies the token and the player's request budget, writing
|
||||
// the error itself when either fails.
|
||||
func (s *service) authorize(w http.ResponseWriter, token string, now time.Time) (guessgame.Claims, bool) {
|
||||
c, err := s.verifyToken(token, now)
|
||||
switch {
|
||||
case errors.Is(err, htmlgame.ErrTokenExpired):
|
||||
writeError(w, errExpiredAPI)
|
||||
return guessgame.Claims{}, false
|
||||
case err != nil:
|
||||
writeError(w, errBadTokenAPI)
|
||||
return guessgame.Claims{}, false
|
||||
}
|
||||
if !s.limiter.Allow(c.UserID, now) {
|
||||
writeError(w, errTooFastAPI)
|
||||
return guessgame.Claims{}, false
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
// cardMax is the length of an unlimited round started from the token's
|
||||
// card: its chat's /loldle_setmax, or the default for an inline card.
|
||||
func (s *service) cardMax(ctx context.Context, c guessgame.Claims) (int, error) {
|
||||
if c.InlineID != "" {
|
||||
return MaxGuesses, nil
|
||||
}
|
||||
return s.maxFor(ctx, strconv.FormatInt(c.ChatID, 10))
|
||||
}
|
||||
|
||||
func (s *service) apiState(w http.ResponseWriter, r *http.Request) {
|
||||
var req stateRequest
|
||||
if htmlgame.DecodeJSON(w, r, &req) != nil {
|
||||
writeError(w, errBadRequestAPI)
|
||||
return
|
||||
}
|
||||
c, ok := s.authorize(w, req.Token, s.cfg.now())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if c.Unlimited() {
|
||||
maxGuesses, err := s.cardMax(ctx, c)
|
||||
var st guessgame.RoundState
|
||||
if err == nil {
|
||||
st, err = s.rounds.Load(ctx, c.UserID, maxGuesses)
|
||||
}
|
||||
s.writeRound(w, c, st, err)
|
||||
return
|
||||
}
|
||||
o, err := s.LoadState(ctx, c)
|
||||
if err != nil {
|
||||
log.Error("loldle state failed", "err", err)
|
||||
writeError(w, errInternalAPI)
|
||||
return
|
||||
}
|
||||
htmlgame.WriteJSON(w, http.StatusOK, s.view(c, o))
|
||||
}
|
||||
|
||||
func (s *service) apiGuess(w http.ResponseWriter, r *http.Request) {
|
||||
var req guessRequest
|
||||
if htmlgame.DecodeJSON(w, r, &req) != nil || len(req.Name) > maxNameBytes {
|
||||
writeError(w, errBadRequestAPI)
|
||||
return
|
||||
}
|
||||
c, ok := s.authorize(w, req.Token, s.cfg.now())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if c.Unlimited() {
|
||||
maxGuesses, err := s.cardMax(ctx, c)
|
||||
var st guessgame.RoundState
|
||||
if err == nil {
|
||||
st, err = s.rounds.Guess(ctx, c.UserID, req.Seq, req.Name, maxGuesses)
|
||||
}
|
||||
if errors.Is(err, errTargetGone) {
|
||||
// The round's answer left the data: replace it, uncounted.
|
||||
if _, err = s.rounds.Replace(ctx, c.UserID, maxGuesses); err == nil {
|
||||
writeError(w, errDataChangedAPI)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.writeRound(w, c, st, err)
|
||||
return
|
||||
}
|
||||
o, err := s.SubmitGuess(ctx, c, req.Num, req.Name)
|
||||
switch {
|
||||
case errors.Is(err, guessgame.ErrNewPuzzle):
|
||||
writeError(w, errNewPuzzleAPI)
|
||||
case errors.Is(err, guessgame.ErrFinished):
|
||||
writeError(w, errFinishedAPI)
|
||||
case guessError(w, err):
|
||||
case err != nil:
|
||||
log.Error("loldle guess failed", "err", err)
|
||||
writeError(w, errInternalAPI)
|
||||
default:
|
||||
htmlgame.WriteJSON(w, http.StatusOK, s.view(c, o))
|
||||
}
|
||||
}
|
||||
|
||||
// apiNew starts the player's next unlimited round, giving up the current
|
||||
// one. A stale seq answers with the current round, so a double click
|
||||
// starts one round.
|
||||
func (s *service) apiNew(w http.ResponseWriter, r *http.Request) {
|
||||
var req newRequest
|
||||
if htmlgame.DecodeJSON(w, r, &req) != nil || req.Seq < 1 {
|
||||
writeError(w, errBadRequestAPI)
|
||||
return
|
||||
}
|
||||
c, ok := s.authorize(w, req.Token, s.cfg.now())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !c.Unlimited() {
|
||||
writeError(w, errBadModeAPI)
|
||||
return
|
||||
}
|
||||
maxGuesses, err := s.cardMax(r.Context(), c)
|
||||
var st guessgame.RoundState
|
||||
if err == nil {
|
||||
st, err = s.rounds.New(r.Context(), c.UserID, req.Seq, maxGuesses)
|
||||
}
|
||||
s.writeRound(w, c, st, err)
|
||||
}
|
||||
|
||||
// guessError writes the API error of a refused guess and reports whether
|
||||
// err was one.
|
||||
func guessError(w http.ResponseWriter, err error) bool {
|
||||
switch {
|
||||
case errors.Is(err, errUnknownChampion):
|
||||
writeError(w, errUnknownAPI)
|
||||
case errors.Is(err, errAmbiguous):
|
||||
writeError(w, errAmbiguousAPI)
|
||||
case errors.Is(err, errDuplicate):
|
||||
writeError(w, errDuplicateAPI)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// writeRound answers an unlimited request.
|
||||
func (s *service) writeRound(w http.ResponseWriter, c guessgame.Claims, st guessgame.RoundState, err error) {
|
||||
switch {
|
||||
case errors.Is(err, guessgame.ErrNewRound):
|
||||
writeError(w, errNewRoundAPI)
|
||||
case errors.Is(err, guessgame.ErrFinished):
|
||||
writeError(w, errRoundFinishedAPI)
|
||||
case guessError(w, err):
|
||||
case err != nil:
|
||||
log.Error("loldle unlimited request failed", "err", err)
|
||||
writeError(w, errInternalAPI)
|
||||
default:
|
||||
htmlgame.WriteJSON(w, http.StatusOK, s.roundView(c, st))
|
||||
}
|
||||
}
|
||||
|
||||
// view renders a daily game for the page. The answer leaves the server
|
||||
// only once the game is over.
|
||||
func (s *service) view(c guessgame.Claims, o guessgame.Outcome) view {
|
||||
v := view{
|
||||
Mode: modeDaily,
|
||||
Num: o.Num,
|
||||
Date: s.PuzzleDate(o.Num),
|
||||
Player: c.Name,
|
||||
Max: MaxGuesses,
|
||||
Columns: columns(),
|
||||
Guesses: s.rowViews(o.P.Guesses),
|
||||
Status: o.P.Status,
|
||||
NextAt: s.PuzzleStart(o.Num + 1).Unix(),
|
||||
}
|
||||
if v.Status == "" {
|
||||
v.Status = guessgame.StatusPlaying
|
||||
}
|
||||
if !o.P.Finished() {
|
||||
return v
|
||||
}
|
||||
v.Answer = s.champView(o.Answer)
|
||||
st := o.Stats
|
||||
v.Stats = statsOf(st.Played, st.Wins, guessgame.DisplayStreak(st, o.Num), st.MaxStreak, st.Dist)
|
||||
v.Share = s.ShareText(o.P)
|
||||
return v
|
||||
}
|
||||
|
||||
// roundView renders an unlimited round for the page. The answer leaves the
|
||||
// server only once the round is over.
|
||||
func (s *service) roundView(c guessgame.Claims, st guessgame.RoundState) view {
|
||||
rd := st.Round
|
||||
v := view{
|
||||
Mode: modeUnlimited,
|
||||
Seq: rd.Seq,
|
||||
Player: c.Name,
|
||||
Max: rd.MaxGuesses,
|
||||
Columns: columns(),
|
||||
Guesses: s.rowViews(rd.Guesses),
|
||||
Status: rd.Status,
|
||||
GaveUp: rd.GaveUp,
|
||||
}
|
||||
if st.Abandoned != nil {
|
||||
v.Abandoned = s.champView(st.Abandoned.Target)
|
||||
}
|
||||
if !rd.Finished() {
|
||||
return v
|
||||
}
|
||||
v.Answer = s.champView(rd.Target)
|
||||
v.Stats = statsOf(st.Stats.Played, st.Stats.Wins, st.Stats.CurStreak, st.Stats.MaxStreak, st.Stats.Dist)
|
||||
return v
|
||||
}
|
||||
|
||||
func columns() []columnView {
|
||||
out := make([]columnView, len(classicAttributes))
|
||||
for i, a := range classicAttributes {
|
||||
out[i] = columnView{Key: a.Key, Label: a.Label}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rowViews renders the guesses newest last, as stored; the page orders
|
||||
// them.
|
||||
func (s *service) rowViews(gs []guessgame.Guess) []rowView {
|
||||
out := make([]rowView, len(gs))
|
||||
for i, g := range gs {
|
||||
row := rowView{Name: g.Word, Marks: g.Marks, Cells: make([]cellView, 0, len(classicAttributes))}
|
||||
if c := findChampionByExactName(s.cfg.champions, g.Word); c != nil {
|
||||
row.ID = c.ID
|
||||
}
|
||||
for _, r := range s.rowsOf(g) {
|
||||
row.Cells = append(row.Cells, cellView{Key: r.Key, Value: r.GuessValue, Result: r.Result, Dir: r.Direction})
|
||||
}
|
||||
out[i] = row
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *service) champView(name string) *champView {
|
||||
v := &champView{Name: name}
|
||||
if c := findChampionByExactName(s.cfg.champions, name); c != nil {
|
||||
v.ID, v.Title = c.ID, c.Title
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func statsOf(played, wins, cur, maxStreak int, dist []int) *statsView {
|
||||
pct := 0
|
||||
if played > 0 {
|
||||
pct = (wins*100 + played/2) / played
|
||||
}
|
||||
return &statsView{Played: played, WinPct: pct, Cur: cur, Max: maxStreak, Dist: dist}
|
||||
}
|
||||
@@ -1,46 +1,297 @@
|
||||
// Package loldle is LoLdle, the League of Legends champion guessing game, in
|
||||
// two modes behind the one BotFather game "loldle":
|
||||
//
|
||||
// - unlimited: /loldle sends a card that opens the player's own round
|
||||
// with a random champion; the page's New game button starts another.
|
||||
// /loldle <champion>, /loldle_giveup and /loldle_stats play and read
|
||||
// the same round from the chat, with stickers and flavour lines, and
|
||||
// /loldle_setmax sets a chat's round length.
|
||||
// - LoLdle Daily: one shared champion a day from 07:00 ICT, like Wordle
|
||||
// Daily: /loldledaily, the live spoiler-free group results, the 07:00
|
||||
// recap and push, and setGameScore on a win.
|
||||
//
|
||||
// Each guess is scored on seven attributes (gender, species, range type,
|
||||
// resource, regions, positions, release year). Play picks the mode from the
|
||||
// card: /loldle cards are recorded, and any other card plays daily. The
|
||||
// server owns the game; the answer reaches the page only once that game is
|
||||
// over. See docs/loldle.md.
|
||||
package loldle
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/log"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
|
||||
"github.com/tiennm99/tiennm99bot/internal/storage"
|
||||
)
|
||||
|
||||
// New is the loldle module Factory. Loads champions.json once at construction
|
||||
// and shares the parsed slice + per-subject lock map across all handlers.
|
||||
const (
|
||||
// ShortName is the module's catalog key and the unlimited command name,
|
||||
// so its routes live under /games/loldle/.
|
||||
ShortName = "loldle"
|
||||
// GameShortName is the BotFather game the cards send and Play answers.
|
||||
GameShortName = "loldle"
|
||||
|
||||
dailyCommand = "loldledaily"
|
||||
subscribeCommand = dailyCommand + "_subscribe"
|
||||
unsubscribeCommand = dailyCommand + "_unsubscribe"
|
||||
|
||||
secretEnv = "LOLDLE_GAME_SECRET" //nolint:gosec // G101: an env var name, not a credential
|
||||
|
||||
// minSecretBytes is the shortest LOLDLE_GAME_SECRET accepted.
|
||||
minSecretBytes = 32
|
||||
// tokenKeyLabel and answerKeyLabel derive the two keys from the root
|
||||
// secret, apart from every other game's.
|
||||
tokenKeyLabel = "tiennm99bot/loldle/token/v1" //nolint:gosec // G101: a public derivation label
|
||||
answerKeyLabel = "tiennm99bot/loldle/answer/v1" //nolint:gosec // G101: a public derivation label
|
||||
|
||||
routePrefix = "/games/" + ShortName + "/"
|
||||
|
||||
// dailyStatsPrefix keeps daily stats apart from the in-chat game's
|
||||
// legacy "stats:<subject>" documents in the same collection.
|
||||
dailyStatsPrefix = "dstats:"
|
||||
|
||||
// dailyPushCronName and cardsCronName must be unique across all
|
||||
// modules' crons.
|
||||
dailyPushCronName = "loldledaily_daily_push"
|
||||
cardsCronName = "loldle_unlimited_cards"
|
||||
// cardsSchedule runs the unlimited card cleanup at 03:40 ICT.
|
||||
cardsSchedule = "40 20 * * *"
|
||||
|
||||
// ddragonOrigin serves the champion icons the page shows.
|
||||
ddragonOrigin = "https://ddragon.leagueoflegends.com"
|
||||
)
|
||||
|
||||
// config is everything New reads from the environment, injectable in tests.
|
||||
type config struct {
|
||||
baseURL string // public https base without trailing slash; "" disables
|
||||
rootKey []byte // derives the token and answer keys; empty disables
|
||||
now func() time.Time
|
||||
store storage.Collection // nil disables
|
||||
reporter guessgame.ScoreReporter // nil disables
|
||||
api guessgame.TelegramAPI // nil skips the live group summary
|
||||
schedule func(d time.Duration, f func()) // runs the summary flush; time.AfterFunc by default
|
||||
async func(f func()) // runs a score report; a goroutine by default
|
||||
champions []Champion
|
||||
epoch time.Time // daily puzzle #1 starts here; zero means the shared epoch
|
||||
pick func(prev string) string // an unlimited round's champion; random by default
|
||||
}
|
||||
|
||||
// New builds the module from the environment. The web game is enabled only
|
||||
// when GAME_BASE_URL is a valid https URL, a key is available and storage
|
||||
// exists; the in-chat /loldle commands need storage only.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
s := &state{
|
||||
games: storage.Typed[gameState](deps.Store),
|
||||
stats: storage.Typed[stats](deps.Store),
|
||||
cfg: storage.Typed[roundConfig](deps.Store),
|
||||
cfg := config{
|
||||
baseURL: htmlgame.ParseBaseURL(os.Getenv(htmlgame.BaseURLEnv), ShortName),
|
||||
rootKey: rootKey(os.Getenv(secretEnv), deps),
|
||||
now: time.Now,
|
||||
store: deps.Store,
|
||||
schedule: func(d time.Duration, f func()) { time.AfterFunc(d, f) },
|
||||
champions: loadChampions(),
|
||||
}
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "loldle",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Classic loldle — guess the current champion",
|
||||
Parameters: "[champion]",
|
||||
Handler: s.handleLoldle,
|
||||
},
|
||||
{
|
||||
Name: "loldle_giveup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Reveal the current loldle answer",
|
||||
Handler: s.handleGiveup,
|
||||
},
|
||||
{
|
||||
Name: "loldle_stats",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show your loldle stats (wins, streak)",
|
||||
Handler: s.handleStats,
|
||||
},
|
||||
{
|
||||
Name: "loldle_setmax",
|
||||
Visibility: modules.VisibilityPrivate,
|
||||
Description: "Override max guesses per round (1-10) for this chat",
|
||||
Handler: s.handleSetMax,
|
||||
},
|
||||
},
|
||||
if deps.Bot != nil {
|
||||
cfg.reporter = guessgame.BotReporter{B: deps.Bot}
|
||||
cfg.api = deps.Bot
|
||||
}
|
||||
return newService(cfg).module()
|
||||
}
|
||||
|
||||
// rootKey returns LOLDLE_GAME_SECRET when set, otherwise the bot token. A
|
||||
// secret that is set but too short disables the game rather than silently
|
||||
// falling back.
|
||||
func rootKey(secret string, deps modules.Deps) []byte {
|
||||
if secret != "" {
|
||||
if len(secret) < minSecretBytes {
|
||||
log.Warn("LOLDLE_GAME_SECRET is shorter than 32 bytes; loldle game disabled")
|
||||
return nil
|
||||
}
|
||||
return []byte(secret)
|
||||
}
|
||||
if deps.Bot == nil || deps.Bot.Token() == "" {
|
||||
return nil
|
||||
}
|
||||
return []byte(deps.Bot.Token())
|
||||
}
|
||||
|
||||
// service is the module's runtime state. The daily mode is embedded, so
|
||||
// its stores and methods read as the service's own.
|
||||
type service struct {
|
||||
*guessgame.Daily
|
||||
cfg config
|
||||
tokenKey []byte
|
||||
answerKey []byte
|
||||
rules rules
|
||||
rounds *guessgame.Rounds // nil without storage
|
||||
cards *guessgame.Cards // nil without storage
|
||||
settings ConfigStore // nil without storage
|
||||
limiter *htmlgame.Limiter
|
||||
// championList is the public champion list the page searches: names
|
||||
// and icon ids only, never an attribute.
|
||||
championList []byte
|
||||
}
|
||||
|
||||
func newService(cfg config) *service {
|
||||
if cfg.now == nil {
|
||||
cfg.now = time.Now
|
||||
}
|
||||
names := make([]string, len(cfg.champions))
|
||||
for i, c := range cfg.champions {
|
||||
names[i] = c.ChampionName
|
||||
}
|
||||
slices.Sort(names)
|
||||
if cfg.pick == nil {
|
||||
cfg.pick = randomPick(names)
|
||||
}
|
||||
svc := &service{
|
||||
cfg: cfg,
|
||||
tokenKey: htmlgame.DeriveKey(cfg.rootKey, tokenKeyLabel),
|
||||
answerKey: htmlgame.DeriveKey(cfg.rootKey, answerKeyLabel),
|
||||
rules: rules{champions: cfg.champions},
|
||||
limiter: &htmlgame.Limiter{Per: requestsPerMinute, Window: time.Minute},
|
||||
championList: championList(cfg.champions),
|
||||
}
|
||||
// The daily answers are the champions sorted by name, so the order
|
||||
// depends on the key alone; adding a champion changes only days that
|
||||
// have not been pinned yet.
|
||||
svc.Daily = guessgame.NewDaily(guessgame.DailyConfig{
|
||||
Rules: rules{champions: cfg.champions, keepGone: true},
|
||||
Answers: names,
|
||||
AnswerKey: svc.answerKey,
|
||||
Store: cfg.store,
|
||||
StatsPrefix: dailyStatsPrefix,
|
||||
Epoch: cfg.epoch,
|
||||
Now: cfg.now,
|
||||
Schedule: cfg.schedule,
|
||||
Async: cfg.async,
|
||||
Reporter: cfg.reporter,
|
||||
API: cfg.api,
|
||||
GameShortName: GameShortName,
|
||||
LogName: ShortName,
|
||||
AnswerPrefix: "Champion: ",
|
||||
})
|
||||
if len(names) > 0 {
|
||||
svc.rounds = guessgame.NewRounds(guessgame.RoundsConfig{
|
||||
Store: cfg.store,
|
||||
Rules: svc.rules,
|
||||
Pick: cfg.pick,
|
||||
DistLen: MaxGuessesCap,
|
||||
Now: cfg.now,
|
||||
})
|
||||
}
|
||||
svc.cards = guessgame.NewCards(cfg.store, cfg.now, ShortName)
|
||||
if cfg.store != nil {
|
||||
svc.settings = storage.Typed[roundConfig](cfg.store)
|
||||
}
|
||||
if svc.enabled() {
|
||||
log.Info("loldle game enabled", "champions", len(names))
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// randomPick draws an unlimited round's champion, never the champion of the
|
||||
// round it replaces.
|
||||
func randomPick(names []string) func(prev string) string {
|
||||
return func(prev string) string {
|
||||
for {
|
||||
n := names[rand.IntN(len(names))]
|
||||
if n != prev || len(names) < 2 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// championEntry is one champion in the page's search list.
|
||||
type championEntry struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func championList(champions []Champion) []byte {
|
||||
list := make([]championEntry, len(champions))
|
||||
for i, c := range champions {
|
||||
list[i] = championEntry{Name: c.ChampionName, ID: c.ID}
|
||||
}
|
||||
slices.SortFunc(list, func(a, b championEntry) int { return cmp.Compare(a.Name, b.Name) })
|
||||
raw, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
panic("loldle: encode champion list: " + err.Error())
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// enabled reports whether Play can open the game.
|
||||
func (s *service) enabled() bool {
|
||||
return s.cfg.baseURL != "" && len(s.tokenKey) > 0 && len(s.answerKey) > 0 &&
|
||||
s.cfg.store != nil && s.cfg.reporter != nil && len(s.cfg.champions) > 0
|
||||
}
|
||||
|
||||
// module describes the service to the registry. Routes and crons exist only
|
||||
// when the game is enabled, so a disabled game exposes nothing over HTTP and
|
||||
// pushes nothing.
|
||||
func (s *service) module() modules.Module {
|
||||
daily := guessgame.DailyCommands{
|
||||
Daily: s.Daily,
|
||||
Enabled: s.enabled,
|
||||
Texts: guessgame.DailyTexts{Channel: msgDailyChannel, Disabled: msgDisabled, SendFail: msgDailySendFail},
|
||||
}
|
||||
mod := modules.Module{
|
||||
Commands: []modules.Command{{
|
||||
Name: ShortName,
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Play unlimited LoLdle; with a champion, guess in your round",
|
||||
Parameters: "[champion]",
|
||||
Handler: s.handleLoldle,
|
||||
}, {
|
||||
Name: "loldle_giveup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Reveal the answer of your unlimited LoLdle round",
|
||||
Handler: s.handleGiveup,
|
||||
}, {
|
||||
Name: "loldle_stats",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show your unlimited LoLdle stats",
|
||||
Handler: s.handleStats,
|
||||
}, {
|
||||
Name: "loldle_setmax",
|
||||
Visibility: modules.VisibilityPrivate,
|
||||
Description: "Set max guesses (1-10) for unlimited rounds started in this chat",
|
||||
Handler: s.handleSetMax,
|
||||
}, {
|
||||
Name: dailyCommand,
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Play today's LoLdle Daily champion",
|
||||
Handler: daily.HandleCard,
|
||||
}, {
|
||||
Name: subscribeCommand,
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Get the LoLdle Daily puzzle and group results at 07:00 ICT",
|
||||
Handler: daily.HandleSubscribe,
|
||||
}, {
|
||||
Name: unsubscribeCommand,
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Stop the daily LoLdle Daily push",
|
||||
Handler: daily.HandleUnsubscribe,
|
||||
}},
|
||||
Games: []modules.Game{{
|
||||
ShortName: GameShortName,
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Handler: s.handlePlay,
|
||||
}},
|
||||
}
|
||||
if s.enabled() {
|
||||
mod.HTTP = []modules.Route{{Pattern: routePrefix, Handler: s.handler()}}
|
||||
mod.Crons = []modules.Cron{
|
||||
{Name: dailyPushCronName, Schedule: guessgame.DailyPushSchedule, Handler: s.PushHandler},
|
||||
{Name: cardsCronName, Schedule: cardsSchedule, Handler: s.cards.Cleanup},
|
||||
}
|
||||
}
|
||||
return mod
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package loldle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/log"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame"
|
||||
"github.com/tiennm99/tiennm99bot/internal/storage"
|
||||
)
|
||||
|
||||
// legacyMigrationKey marks, in the system collection, that the in-chat
|
||||
// game's players were carried into unlimited mode.
|
||||
const legacyMigrationKey = "loldle:legacy-unlimited"
|
||||
|
||||
// legacyGame and legacyStats are the documents the in-chat game kept before
|
||||
// unlimited mode, under "game:<subject>" and "stats:<subject>" in this
|
||||
// module's collection. A subject is a user id in a private chat and a
|
||||
// group's chat id (negative) in a group, where the whole group shared one
|
||||
// round. A game was deleted when it ended, so every stored one is active.
|
||||
type legacyGame struct {
|
||||
Target string `json:"target" bson:"target"`
|
||||
Guesses []string `json:"guesses" bson:"guesses"`
|
||||
StartedAt *int64 `json:"startedAt" bson:"startedAt"`
|
||||
MaxGuesses int `json:"maxGuesses,omitempty" bson:"maxGuesses,omitempty"`
|
||||
}
|
||||
|
||||
type legacyStats struct {
|
||||
Played int `json:"played" bson:"played"`
|
||||
Wins int `json:"wins" bson:"wins"`
|
||||
Streak int `json:"streak" bson:"streak"`
|
||||
BestStreak int `json:"bestStreak" bson:"bestStreak"`
|
||||
}
|
||||
|
||||
// InitStore carries the in-chat game's players into unlimited mode, once:
|
||||
// each player's stats become their unlimited stats and their round in play
|
||||
// becomes their current round, with its marks rebuilt from today's data.
|
||||
// Group rounds and group stats belong to no single player and are left
|
||||
// alone. Legacy documents are never changed or deleted and a target that
|
||||
// already exists is never overwritten, so a rerun after a failed boot
|
||||
// changes nothing that was carried already; a completed marker in the
|
||||
// system collection makes later boots a no-op.
|
||||
func InitStore(ctx context.Context, coll, systemColl storage.Collection) error {
|
||||
champions := loadChampions()
|
||||
return guessgame.RunOnce(ctx, systemColl, legacyMigrationKey, func() (int, error) {
|
||||
stats, err := migrateLegacyStats(ctx, coll)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rounds, err := migrateLegacyRounds(ctx, coll, champions)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
log.Info("loldle migrated legacy players", "stats", stats, "rounds", rounds)
|
||||
return stats + rounds, nil
|
||||
})
|
||||
}
|
||||
|
||||
func migrateLegacyStats(ctx context.Context, coll storage.Collection) (int, error) {
|
||||
from := storage.Typed[legacyStats](coll)
|
||||
to := storage.Typed[guessgame.RoundStats](coll)
|
||||
keys, err := from.List(ctx, "stats:")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("loldle migration list stats: %w", err)
|
||||
}
|
||||
n := 0
|
||||
for _, key := range keys {
|
||||
uid, ok := guessgame.LegacyUserID(key, "stats:")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
old, _, err := from.Get(ctx, key)
|
||||
if err != nil {
|
||||
return n, fmt.Errorf("loldle migration get %s: %w", key, err)
|
||||
}
|
||||
st := guessgame.RoundStats{Played: old.Played, Wins: old.Wins, CurStreak: old.Streak, MaxStreak: old.BestStreak,
|
||||
Dist: make([]int, MaxGuessesCap)}
|
||||
added, err := guessgame.PutIfMissing(ctx, to, guessgame.RoundStatsKey(uid), st)
|
||||
if err != nil {
|
||||
return n, fmt.Errorf("loldle migration put stats %d: %w", uid, err)
|
||||
}
|
||||
if added {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func migrateLegacyRounds(ctx context.Context, coll storage.Collection, champions []Champion) (int, error) {
|
||||
from := storage.Typed[legacyGame](coll)
|
||||
to := storage.Typed[guessgame.Round](coll)
|
||||
keys, err := from.List(ctx, "game:")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("loldle migration list games: %w", err)
|
||||
}
|
||||
n := 0
|
||||
for _, key := range keys {
|
||||
uid, ok := guessgame.LegacyUserID(key, "game:")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
old, _, err := from.Get(ctx, key)
|
||||
if err != nil {
|
||||
return n, fmt.Errorf("loldle migration get %s: %w", key, err)
|
||||
}
|
||||
target := findChampionByExactName(champions, old.Target)
|
||||
maxGuesses := normalizeMaxGuesses(old.MaxGuesses)
|
||||
// A round whose answer left the data cannot be scored, and an
|
||||
// exhausted one is over; the in-chat game started afresh on both.
|
||||
if target == nil || len(old.Guesses) >= maxGuesses {
|
||||
continue
|
||||
}
|
||||
rd := guessgame.Round{Seq: 1, Target: target.ChampionName, Guesses: []guessgame.Guess{}, Status: guessgame.StatusPlaying,
|
||||
MaxGuesses: maxGuesses}
|
||||
for _, name := range old.Guesses {
|
||||
// A guess naming a champion since removed is skipped, as the
|
||||
// in-chat board did.
|
||||
if g := findChampionByExactName(champions, name); g != nil {
|
||||
rd.Guesses = append(rd.Guesses, guessgame.Guess{Word: g.ChampionName, Marks: marksOf(CompareChampions(g, target))})
|
||||
}
|
||||
}
|
||||
if old.StartedAt != nil && len(rd.Guesses) > 0 {
|
||||
rd.StartedAt = *old.StartedAt
|
||||
}
|
||||
added, err := guessgame.PutIfMissing(ctx, to, guessgame.RoundKey(uid), rd)
|
||||
if err != nil {
|
||||
return n, fmt.Errorf("loldle migration put round %d: %w", uid, err)
|
||||
}
|
||||
if added {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package loldle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame"
|
||||
"github.com/tiennm99/tiennm99bot/internal/storage"
|
||||
"github.com/tiennm99/tiennm99bot/internal/testutil/mongotest"
|
||||
)
|
||||
|
||||
var mongoTests mongotest.Manager
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(mongoTests.Run(m))
|
||||
}
|
||||
|
||||
// legacyLoldle seeds the in-chat game's documents.
|
||||
func legacyLoldle(t *testing.T, coll storage.Collection) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
stats := storage.Typed[legacyStats](coll)
|
||||
for key, st := range map[string]legacyStats{
|
||||
"stats:1": {Played: 9, Wins: 6, Streak: 2, BestStreak: 4},
|
||||
"stats:2": {Played: 3},
|
||||
"stats:-100": {Played: 30, Wins: 20},
|
||||
} {
|
||||
if err := stats.Put(ctx, key, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
at := int64(1_700_000_000_000)
|
||||
games := storage.Typed[legacyGame](coll)
|
||||
for key, g := range map[string]legacyGame{
|
||||
"game:1": {Target: "Ahri", Guesses: []string{"Akali", "Removed", "Kai'Sa"}, StartedAt: &at, MaxGuesses: 5},
|
||||
"game:3": {Target: "Gone", Guesses: []string{"Akali"}},
|
||||
"game:4": {Target: "Ahri", Guesses: []string{}},
|
||||
"game:5": {Target: "Ahri", Guesses: []string{"Akali", "Jinx"}, MaxGuesses: 2},
|
||||
"game:-100": {Target: "Ahri", Guesses: []string{"Akali"}},
|
||||
} {
|
||||
if err := games.Put(ctx, key, g); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := storage.Typed[roundConfig](coll).Put(ctx, configKey("-100"), roundConfig{MaxGuesses: 4}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// assertLegacyMigration runs the migration twice over provider and checks
|
||||
// what it carried over.
|
||||
func assertLegacyMigration(t *testing.T, provider storage.Provider) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
coll, system := provider.Collection(ShortName), provider.Collection("system")
|
||||
legacyLoldle(t, coll)
|
||||
ustats := storage.Typed[guessgame.RoundStats](coll)
|
||||
if err := ustats.Put(ctx, guessgame.RoundStatsKey(2), guessgame.RoundStats{Played: 1, Wins: 1, LastSeq: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 2 {
|
||||
if err := InitStore(ctx, coll, system); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
st, _, err := ustats.Get(ctx, guessgame.RoundStatsKey(1))
|
||||
if err != nil || st.Played != 9 || st.Wins != 6 || st.CurStreak != 2 || st.MaxStreak != 4 || len(st.Dist) != MaxGuessesCap {
|
||||
t.Fatalf("player 1 stats = %+v, %v", st, err)
|
||||
}
|
||||
if st, _, _ := ustats.Get(ctx, guessgame.RoundStatsKey(2)); st.Played != 1 {
|
||||
t.Fatalf("existing stats overwritten: %+v", st)
|
||||
}
|
||||
rounds := storage.Typed[guessgame.Round](coll)
|
||||
keys, _ := rounds.List(ctx, "u")
|
||||
if got := strings.Join(keys, " "); got != "uround:1 uround:4 ustats:1 ustats:2" {
|
||||
t.Fatalf("migrated keys = %s", got)
|
||||
}
|
||||
rd, _, err := rounds.Get(ctx, guessgame.RoundKey(1))
|
||||
if err != nil || rd.Seq != 1 || rd.Target != "Ahri" || rd.MaxGuesses != 5 || rd.StartedAt != 1_700_000_000_000 || rd.Status != guessgame.StatusPlaying ||
|
||||
len(rd.Guesses) != 2 || rd.Guesses[0] != (guessgame.Guess{Word: "Akali", Marks: "cwwwccu"}) || rd.Guesses[1].Marks != "cwccwwd" {
|
||||
t.Fatalf("player 1 round = %+v, %v", rd, err)
|
||||
}
|
||||
if rd, _, _ := rounds.Get(ctx, guessgame.RoundKey(4)); rd.MaxGuesses != MaxGuesses || rd.StartedAt != 0 {
|
||||
t.Fatalf("legacy default round = %+v", rd)
|
||||
}
|
||||
if rec, _, err := storage.Typed[struct {
|
||||
Status string `json:"status" bson:"status"`
|
||||
Count int64 `json:"count" bson:"count"`
|
||||
}](system).Get(ctx, legacyMigrationKey); err != nil || rec.Status != "complete" || rec.Count != 3 {
|
||||
t.Fatalf("marker = %+v, %v", rec, err)
|
||||
}
|
||||
// Legacy documents and the setmax config are kept as they were.
|
||||
if keys, _ := storage.Typed[legacyStats](coll).List(ctx, "stats:"); len(keys) != 3 {
|
||||
t.Fatalf("legacy stats = %v", keys)
|
||||
}
|
||||
if keys, _ := storage.Typed[legacyGame](coll).List(ctx, "game:"); len(keys) != 5 {
|
||||
t.Fatalf("legacy games = %v", keys)
|
||||
}
|
||||
if n, _ := getMaxGuesses(ctx, storage.Typed[roundConfig](coll), "-100"); n != 4 {
|
||||
t.Fatalf("setmax = %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitStore_CarriesLegacyPlayersOnce(t *testing.T) {
|
||||
assertLegacyMigration(t, storage.NewMemoryProvider())
|
||||
}
|
||||
|
||||
// The migrated round and stats carry on through the page.
|
||||
func TestInitStore_MigratedRoundPlaysOn(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
legacyLoldle(t, h.coll)
|
||||
if err := InitStore(context.Background(), h.coll, storage.NewMemoryProvider().Collection("system")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tok := h.token(1, 1, 5, "A", true)
|
||||
if r := h.state(tok); len(r.v.Guesses) != 2 || r.v.Max != 5 {
|
||||
t.Fatalf("migrated round = %s", r.body)
|
||||
}
|
||||
r := h.roundGuess(tok, 1, "Ahri")
|
||||
if s := r.v.Stats; s == nil || s.Played != 10 || s.Cur != 3 || s.Max != 4 || s.Dist[2] != 1 {
|
||||
t.Fatalf("after the migrated round = %s", r.body)
|
||||
}
|
||||
// Daily stats never read the legacy documents.
|
||||
if st, err := h.svc.LoadStats(context.Background(), 1); err != nil || st.Played != 0 {
|
||||
t.Fatalf("daily stats = %+v, %v", st, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The migration runs against MongoDB in production, where documents go
|
||||
// through BSON rather than JSON.
|
||||
func TestInitStore_MongoCarriesLegacyPlayersOnce(t *testing.T) {
|
||||
uri := mongoTests.URI(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
client, err := storage.NewMongoClient(ctx, uri)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMongoClient: %v", err)
|
||||
}
|
||||
db := client.Database(fmt.Sprintf("tiennm99bot_loldle_test_%d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = db.Drop(ctx)
|
||||
_ = client.Disconnect(ctx)
|
||||
})
|
||||
assertLegacyMigration(t, storage.NewMongoProvider(db))
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package loldle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame"
|
||||
)
|
||||
|
||||
// Rejections of a guess, mapped to API errors in http_api.go and to chat
|
||||
// replies in command.go.
|
||||
var (
|
||||
errUnknownChampion = errors.New("loldle: champion not found")
|
||||
errAmbiguous = errors.New("loldle: ambiguous champion")
|
||||
errDuplicate = errors.New("loldle: champion already guessed")
|
||||
// errTargetGone is a round whose answer left champions.json after a
|
||||
// data refresh; it can no longer be scored.
|
||||
errTargetGone = errors.New("loldle: answer no longer in the data")
|
||||
)
|
||||
|
||||
// Marks are one byte per classic attribute, in classicAttributes order:
|
||||
// c (correct), p (partial), w (wrong), and for a wrong release year u (the
|
||||
// answer is later) or d (earlier). A year that cannot be compared is w.
|
||||
const (
|
||||
markCorrect = 'c'
|
||||
markPartial = 'p'
|
||||
markWrong = 'w'
|
||||
markUp = 'u'
|
||||
markDown = 'd'
|
||||
)
|
||||
|
||||
// rules is LoLdle for the guessgame engine. A guess's Word is the
|
||||
// champion's canonical name.
|
||||
type rules struct {
|
||||
champions []Champion
|
||||
// keepGone scores a guess against a pinned answer that left
|
||||
// champions.json instead of refusing it with errTargetGone. The daily
|
||||
// mode sets it: a pinned day cannot be re-picked, so its board must stay
|
||||
// playable. Unlimited rounds leave it unset and replace the round.
|
||||
keepGone bool
|
||||
}
|
||||
|
||||
func (rules) Label() string { return "LoLdle Daily" }
|
||||
func (rules) MaxGuesses() int { return MaxGuesses }
|
||||
|
||||
// Judge resolves input to one champion, refuses a repeat and scores it
|
||||
// against answer.
|
||||
func (r rules) Judge(input, answer string, prior []guessgame.Guess) (guessgame.Guess, error) {
|
||||
target := findChampionByExactName(r.champions, answer)
|
||||
if target == nil && r.keepGone {
|
||||
return r.judgeGone(input, answer, prior)
|
||||
}
|
||||
guess, ambiguous := findChampionMatch(r.champions, input)
|
||||
switch {
|
||||
case ambiguous:
|
||||
return guessgame.Guess{}, errAmbiguous
|
||||
case guess == nil:
|
||||
return guessgame.Guess{}, errUnknownChampion
|
||||
}
|
||||
for _, p := range prior {
|
||||
if p.Word == guess.ChampionName {
|
||||
return guessgame.Guess{}, errDuplicate
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return guessgame.Guess{}, errTargetGone
|
||||
}
|
||||
return guessgame.Guess{Word: guess.ChampionName, Marks: marksOf(CompareChampions(guess, target))}, nil
|
||||
}
|
||||
|
||||
// judgeGone scores a guess against an answer that is no longer in the data.
|
||||
// Its attributes are unknown, so every other champion scores wrong in every
|
||||
// column; naming the answer itself still wins.
|
||||
func (r rules) judgeGone(input, answer string, prior []guessgame.Guess) (guessgame.Guess, error) {
|
||||
word := answer
|
||||
if normalizeName(input) != normalizeName(answer) {
|
||||
guess, ambiguous := findChampionMatch(r.champions, input)
|
||||
switch {
|
||||
case ambiguous:
|
||||
return guessgame.Guess{}, errAmbiguous
|
||||
case guess == nil:
|
||||
return guessgame.Guess{}, errUnknownChampion
|
||||
}
|
||||
word = guess.ChampionName
|
||||
}
|
||||
for _, p := range prior {
|
||||
if p.Word == word {
|
||||
return guessgame.Guess{}, errDuplicate
|
||||
}
|
||||
}
|
||||
if word == answer {
|
||||
return guessgame.Guess{Word: word, Marks: strings.Repeat(string(rune(markCorrect)), len(classicAttributes))}, nil
|
||||
}
|
||||
return guessgame.Guess{Word: word, Marks: strings.Repeat(string(rune(markWrong)), len(classicAttributes))}, nil
|
||||
}
|
||||
|
||||
func (rules) Marker(mark byte) string {
|
||||
switch mark {
|
||||
case markCorrect:
|
||||
return "🟩"
|
||||
case markPartial:
|
||||
return "🟨"
|
||||
}
|
||||
return "🟥"
|
||||
}
|
||||
|
||||
func (rules) AnswerText(answer string) string { return answer }
|
||||
|
||||
// marksOf encodes a comparison as marks.
|
||||
func marksOf(rows []AttributeRow) string {
|
||||
var b strings.Builder
|
||||
for _, r := range rows {
|
||||
switch {
|
||||
case r.Result == ResultCorrect:
|
||||
b.WriteByte(markCorrect)
|
||||
case r.Result == ResultPartial:
|
||||
b.WriteByte(markPartial)
|
||||
case r.Direction == "up":
|
||||
b.WriteByte(markUp)
|
||||
case r.Direction == "down":
|
||||
b.WriteByte(markDown)
|
||||
default:
|
||||
b.WriteByte(markWrong)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// markResult splits one mark into the comparison result and year arrow.
|
||||
func markResult(mark byte) (result, direction string) {
|
||||
switch mark {
|
||||
case markCorrect:
|
||||
return ResultCorrect, ""
|
||||
case markPartial:
|
||||
return ResultPartial, ""
|
||||
case markUp:
|
||||
return ResultWrong, "up"
|
||||
case markDown:
|
||||
return ResultWrong, "down"
|
||||
}
|
||||
return ResultWrong, ""
|
||||
}
|
||||
@@ -8,56 +8,25 @@ import (
|
||||
"github.com/tiennm99/tiennm99bot/internal/storage"
|
||||
)
|
||||
|
||||
// MaxGuesses is the default round length. Per-subject overrides land via
|
||||
// /loldle_setmax (capped by MaxGuessesCap).
|
||||
// MaxGuesses is the default unlimited round length and the fixed daily one.
|
||||
// /loldle_setmax overrides the unlimited length per chat, up to
|
||||
// MaxGuessesCap.
|
||||
const (
|
||||
MaxGuesses = 8
|
||||
MaxGuessesCap = 10
|
||||
)
|
||||
|
||||
// gameState is the per-subject record.
|
||||
//
|
||||
// StartedAt is *int64 because the timer doesn't start until the first guess;
|
||||
// using time.Time would marshal as "0001-01-01T00:00:00Z" instead of null
|
||||
// and lose that distinction.
|
||||
//
|
||||
// Guesses is just championNames; comparison rows are recomputed at render
|
||||
// time against current champions.json so a data refresh updates
|
||||
// historical board displays without migrating saved rounds.
|
||||
type gameState struct {
|
||||
Target string `json:"target" bson:"target"`
|
||||
Guesses []string `json:"guesses" bson:"guesses"`
|
||||
StartedAt *int64 `json:"startedAt" bson:"startedAt"` // ms-since-epoch | null
|
||||
MaxGuesses int `json:"maxGuesses,omitempty" bson:"maxGuesses,omitempty"` // frozen round budget
|
||||
}
|
||||
|
||||
// stats lifetime score. No LastResultAt field by design (differs from
|
||||
// wordle's Stats — loldle only ever needed running streaks, not "last
|
||||
// played at").
|
||||
type stats struct {
|
||||
Played int `json:"played" bson:"played"`
|
||||
Wins int `json:"wins" bson:"wins"`
|
||||
Streak int `json:"streak" bson:"streak"`
|
||||
BestStreak int `json:"bestStreak" bson:"bestStreak"`
|
||||
}
|
||||
|
||||
// roundConfig stores the per-subject MaxGuesses override. Stored only when
|
||||
// /loldle_setmax has been run; the absence of this record means "use default".
|
||||
// roundConfig stores a chat's unlimited round length. Stored only when
|
||||
// /loldle_setmax has been run; the absence of this record means "use
|
||||
// default". The subject is the chat id in a group and the user id in a
|
||||
// private chat.
|
||||
type roundConfig struct {
|
||||
MaxGuesses int `json:"maxGuesses" bson:"maxGuesses"`
|
||||
}
|
||||
|
||||
// GameStore is the loldle module's typed game store.
|
||||
type GameStore = storage.DocStore[gameState]
|
||||
|
||||
// StatsStore is the loldle module's typed stats store.
|
||||
type StatsStore = storage.DocStore[stats]
|
||||
|
||||
// ConfigStore is the loldle module's typed config store.
|
||||
type ConfigStore = storage.DocStore[roundConfig]
|
||||
|
||||
func gameKey(subject string) string { return "game:" + subject }
|
||||
func statsKey(subject string) string { return "stats:" + subject }
|
||||
func configKey(subject string) string { return "config:" + subject }
|
||||
|
||||
func validMaxGuesses(n int) bool {
|
||||
@@ -71,78 +40,6 @@ func normalizeMaxGuesses(n int) int {
|
||||
return MaxGuesses
|
||||
}
|
||||
|
||||
func (g *gameState) roundMaxGuesses() int {
|
||||
if g == nil {
|
||||
return MaxGuesses
|
||||
}
|
||||
return normalizeMaxGuesses(g.MaxGuesses)
|
||||
}
|
||||
|
||||
// loadGame returns the active round, or (nil, nil) if none exists.
|
||||
func loadGame(ctx context.Context, games GameStore, subject string) (*gameState, error) {
|
||||
g, _, err := games.Get(ctx, gameKey(subject))
|
||||
switch {
|
||||
case err == nil:
|
||||
return &g, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("loldle loadGame: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func saveGame(ctx context.Context, games GameStore, subject string, g *gameState) error {
|
||||
if err := games.Put(ctx, gameKey(subject), *g); err != nil {
|
||||
return fmt.Errorf("loldle saveGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearGame removes the round so the next /loldle starts fresh. Used after
|
||||
// win / loss / giveup; the new round's timer should start on the player's
|
||||
// next interaction, not at the moment the previous round ended.
|
||||
func clearGame(ctx context.Context, games GameStore, subject string) error {
|
||||
if err := games.Delete(ctx, gameKey(subject)); err != nil {
|
||||
return fmt.Errorf("loldle clearGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadStats returns lifetime score; missing → fresh-zero record so callers
|
||||
// never need a nil check.
|
||||
func loadStats(ctx context.Context, st StatsStore, subject string) (*stats, error) {
|
||||
s, _, err := st.Get(ctx, statsKey(subject))
|
||||
switch {
|
||||
case err == nil:
|
||||
return &s, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return &stats{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("loldle loadStats: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func recordResult(ctx context.Context, st StatsStore, subject string, won bool) (*stats, error) {
|
||||
s, err := loadStats(ctx, st, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Played++
|
||||
if won {
|
||||
s.Wins++
|
||||
s.Streak++
|
||||
if s.Streak > s.BestStreak {
|
||||
s.BestStreak = s.Streak
|
||||
}
|
||||
} else {
|
||||
s.Streak = 0
|
||||
}
|
||||
if err := st.Put(ctx, statsKey(subject), *s); err != nil {
|
||||
return nil, fmt.Errorf("loldle recordResult: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// getMaxGuesses returns the effective round length: the per-subject override
|
||||
// if set and in range, otherwise MaxGuesses. Out-of-range values are
|
||||
// silently ignored — better to serve the default than 500 the user.
|
||||
@@ -154,15 +51,12 @@ func getMaxGuesses(ctx context.Context, cfg ConfigStore, subject string) (int, e
|
||||
}
|
||||
return 0, fmt.Errorf("loldle getMaxGuesses: %w", err)
|
||||
}
|
||||
if c.MaxGuesses < 1 || c.MaxGuesses > MaxGuessesCap {
|
||||
return MaxGuesses, nil
|
||||
}
|
||||
return c.MaxGuesses, nil
|
||||
return normalizeMaxGuesses(c.MaxGuesses), nil
|
||||
}
|
||||
|
||||
// setMaxGuesses validates and persists the per-subject override.
|
||||
func setMaxGuesses(ctx context.Context, cfg ConfigStore, subject string, n int) error {
|
||||
if n < 1 || n > MaxGuessesCap {
|
||||
if !validMaxGuesses(n) {
|
||||
return fmt.Errorf("loldle: maxGuesses must be in [1, %d], got %d", MaxGuessesCap, n)
|
||||
}
|
||||
if err := cfg.Put(ctx, configKey(subject), roundConfig{MaxGuesses: n}); err != nil {
|
||||
|
||||
@@ -2,92 +2,15 @@ package loldle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/storage"
|
||||
)
|
||||
|
||||
func newLoldleGames() GameStore {
|
||||
return storage.Typed[gameState](storage.NewMemoryProvider().Collection("loldle"))
|
||||
}
|
||||
|
||||
func newLoldleStats() StatsStore {
|
||||
return storage.Typed[stats](storage.NewMemoryProvider().Collection("loldle"))
|
||||
}
|
||||
|
||||
func newLoldleConfig() ConfigStore {
|
||||
return storage.Typed[roundConfig](storage.NewMemoryProvider().Collection("loldle"))
|
||||
}
|
||||
|
||||
func TestGameState_StartedAtNullByDefault(t *testing.T) {
|
||||
g := gameState{Target: "Aatrox", Guesses: []string{}}
|
||||
b, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"target":"Aatrox","guesses":[],"startedAt":null}`
|
||||
if string(b) != want {
|
||||
t.Errorf("marshal:\ngot %s\nwant %s", b, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameState_StartedAtAsNumber(t *testing.T) {
|
||||
at := int64(1700000000000)
|
||||
g := gameState{Target: "Aatrox", Guesses: []string{"Ahri"}, StartedAt: &at}
|
||||
b, _ := json.Marshal(g)
|
||||
want := `{"target":"Aatrox","guesses":["Ahri"],"startedAt":1700000000000}`
|
||||
if string(b) != want {
|
||||
t.Errorf("marshal:\ngot %s\nwant %s", b, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameState_MaxGuessesOmittedWhenUnset(t *testing.T) {
|
||||
g := gameState{Target: "Aatrox", Guesses: []string{}, MaxGuesses: 0}
|
||||
b, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != `{"target":"Aatrox","guesses":[],"startedAt":null}` {
|
||||
t.Errorf("marshal:\ngot %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStats_NoLastResultAtField(t *testing.T) {
|
||||
// loldle's stats schema deliberately differs from wordle's — no
|
||||
// lastResultAt field. Lock that, since adding the field would silently
|
||||
// change the on-disk shape for every existing player.
|
||||
b, _ := json.Marshal(stats{})
|
||||
want := `{"played":0,"wins":0,"streak":0,"bestStreak":0}`
|
||||
if string(b) != want {
|
||||
t.Errorf("marshal:\ngot %s\nwant %s", b, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordResult_WinAndLossSequence(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newLoldleStats()
|
||||
|
||||
s, err := recordResult(ctx, st, "u1", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Played != 1 || s.Wins != 1 || s.Streak != 1 || s.BestStreak != 1 {
|
||||
t.Errorf("first win: %+v", s)
|
||||
}
|
||||
s, _ = recordResult(ctx, st, "u1", true)
|
||||
if s.Streak != 2 || s.BestStreak != 2 {
|
||||
t.Errorf("two wins: %+v", s)
|
||||
}
|
||||
s, _ = recordResult(ctx, st, "u1", false)
|
||||
if s.Streak != 0 {
|
||||
t.Errorf("loss should reset streak, got %d", s.Streak)
|
||||
}
|
||||
if s.BestStreak != 2 {
|
||||
t.Errorf("best streak should persist, got %d", s.BestStreak)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMaxGuesses_DefaultsAndOverrides(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cfg := newLoldleConfig()
|
||||
@@ -133,86 +56,3 @@ func TestGetMaxGuesses_OutOfRangeIgnored(t *testing.T) {
|
||||
t.Errorf("out-of-range config → %d, want %d (default)", n, MaxGuesses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrInitGame_ExistingRoundKeepsStoredMaxGuesses(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
games := newLoldleGames()
|
||||
s := &state{games: games}
|
||||
if err := saveGame(ctx, games, "u1", &gameState{
|
||||
Target: "Aatrox",
|
||||
Guesses: []string{"Ahri"},
|
||||
MaxGuesses: 8,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := s.getOrInitGame(ctx, "u1", 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.MaxGuesses != 8 || len(got.Guesses) != 1 {
|
||||
t.Fatalf("existing round changed by new config: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrInitGame_LegacyRoundLocksDefaultMaxGuesses(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
games := newLoldleGames()
|
||||
s := &state{games: games}
|
||||
if err := saveGame(ctx, games, "u1", &gameState{
|
||||
Target: "Aatrox",
|
||||
Guesses: []string{"Ahri"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := s.getOrInitGame(ctx, "u1", 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.MaxGuesses != MaxGuesses {
|
||||
t.Fatalf("legacy round MaxGuesses = %d, want default %d", got.MaxGuesses, MaxGuesses)
|
||||
}
|
||||
loaded, err := loadGame(ctx, games, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded == nil || loaded.MaxGuesses != MaxGuesses {
|
||||
t.Fatalf("legacy round was not persisted with default max: %+v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadGame_MissingReturnsNil(t *testing.T) {
|
||||
g, err := loadGame(context.Background(), newLoldleGames(), "nobody")
|
||||
if err != nil {
|
||||
t.Errorf("missing should not error: %v", err)
|
||||
}
|
||||
if g != nil {
|
||||
t.Errorf("expected nil, got %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLoadClearGame_RoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
games := newLoldleGames()
|
||||
at := int64(42)
|
||||
want := &gameState{Target: "Aatrox", Guesses: []string{"Ahri"}, StartedAt: &at}
|
||||
if err := saveGame(ctx, games, "u1", want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := loadGame(ctx, games, "u1")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("loadGame: %v, %v", got, err)
|
||||
}
|
||||
if got.Target != "Aatrox" || len(got.Guesses) != 1 || got.StartedAt == nil || *got.StartedAt != 42 {
|
||||
t.Errorf("round-trip mismatch: %+v", got)
|
||||
}
|
||||
|
||||
if err := clearGame(ctx, games, "u1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = loadGame(ctx, games, "u1")
|
||||
if got != nil {
|
||||
t.Errorf("after clearGame, expected nil, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
:root {
|
||||
--bg: #1a1a2e;
|
||||
--surface: #16213e;
|
||||
--surface-hover: #1f2d50;
|
||||
--header: #2a2a4a;
|
||||
--text: #e0e0e0;
|
||||
--muted: #8a8a9a;
|
||||
/* Column labels are small text on --header: #a8a8b8 keeps 5.9:1 (AA). */
|
||||
--head-text: #a8a8b8;
|
||||
--correct: #4ecb71;
|
||||
--partial: #f4b35e;
|
||||
--wrong: #d85b5b;
|
||||
--on-mark: #1a1a2e;
|
||||
--input-bg: #0f3460;
|
||||
--input-border: #533483;
|
||||
--accent: #533483;
|
||||
--accent-text: #c9b8ff;
|
||||
--radius: 10px;
|
||||
--name-col: 130px;
|
||||
--cell: 90px;
|
||||
--icon: 40px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
html, body { margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 16px/1.4 "Segoe UI", system-ui, -apple-system, Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 12px calc(16px + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.top { text-align: center; }
|
||||
.logo { margin: 0; font-size: 2rem; font-weight: 800; letter-spacing: .02em; }
|
||||
.logo .accent { color: var(--accent-text); }
|
||||
.mode { margin: 2px 0 0; font-weight: 600; }
|
||||
.muted { color: var(--muted); }
|
||||
.top .muted { margin: 0; font-size: .85rem; }
|
||||
.lead { margin-top: 0; font-size: 1.05rem; }
|
||||
code { font-size: .9em; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--header);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--input-border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: 10px 16px;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled { opacity: .6; cursor: default; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); font-weight: 600; }
|
||||
button.ghost { min-height: 36px; padding: 6px 12px; font-size: .9rem; }
|
||||
button.ghost.confirm { border-color: var(--wrong); color: var(--wrong); }
|
||||
|
||||
#screen-game { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.bar { display: flex; align-items: center; justify-content: center; gap: 12px; min-height: 36px; }
|
||||
.counter { margin: 0; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Search: the combobox and its listbox. */
|
||||
.search { position: relative; width: 100%; max-width: 420px; margin: 0 auto; }
|
||||
.search input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--input-border);
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
.search input:focus { border-color: var(--accent-text); }
|
||||
.search input:disabled { opacity: .5; }
|
||||
.search input::placeholder { color: var(--muted); }
|
||||
.options {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--input-border);
|
||||
border-top: 0;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
.options li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
min-height: 48px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.options li[aria-selected="true"], .options li:hover { background: var(--surface-hover); }
|
||||
|
||||
/* A champion icon, or its initial when the image cannot load. */
|
||||
.icon {
|
||||
flex: none;
|
||||
width: var(--icon);
|
||||
height: var(--icon);
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
background: var(--header);
|
||||
}
|
||||
.icon.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.options .icon { --icon: 36px; }
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 6px 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: .8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.legend li { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.swatch { width: 14px; height: 14px; border-radius: 3px; display: inline-block; }
|
||||
.swatch.correct { background: var(--correct); }
|
||||
.swatch.partial { background: var(--partial); }
|
||||
.swatch.wrong { background: var(--wrong); }
|
||||
.arrows { font-weight: 700; color: var(--text); }
|
||||
|
||||
/* Board: a header row, then guesses newest first. It scrolls sideways on
|
||||
a narrow screen with the champion column pinned. */
|
||||
.board-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; padding-bottom: 4px; }
|
||||
.board { display: flex; flex-direction: column; gap: 4px; min-width: fit-content; margin: 0 auto; }
|
||||
.grow {
|
||||
display: grid;
|
||||
grid-template-columns: var(--name-col) repeat(7, var(--cell));
|
||||
gap: 4px;
|
||||
}
|
||||
.cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
min-height: 56px;
|
||||
padding: 6px 4px;
|
||||
border-radius: 4px;
|
||||
font-size: .8rem;
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.head .cell {
|
||||
min-height: 0;
|
||||
background: var(--header);
|
||||
color: var(--head-text);
|
||||
font-size: .7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.cell.name {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding-left: 6px;
|
||||
background: var(--surface);
|
||||
text-align: left;
|
||||
box-shadow: 4px 0 0 var(--bg);
|
||||
}
|
||||
.head .cell.name { background: var(--header); }
|
||||
.cell.correct { background: var(--correct); color: var(--on-mark); }
|
||||
.cell.partial { background: var(--partial); color: var(--on-mark); }
|
||||
.cell.wrong { background: var(--wrong); color: #fff; }
|
||||
.cell .dir { margin-left: 4px; font-size: 1.1em; font-weight: 700; }
|
||||
.grow.fresh .cell { opacity: 0; animation: cell-reveal .3s ease forwards; }
|
||||
|
||||
.end { text-align: center; }
|
||||
.end h2 { margin: 0 0 8px; font-size: 1.5rem; }
|
||||
.end h2.won { color: var(--correct); }
|
||||
.end h2.lost { color: var(--wrong); }
|
||||
.end h3 { font-size: .85rem; margin: 16px 0 8px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
|
||||
.answer { display: flex; align-items: center; justify-content: center; gap: 12px; }
|
||||
.answer p { margin: 0; color: var(--muted); }
|
||||
.answer strong { color: var(--text); }
|
||||
.answer .icon { --icon: 64px; border-radius: 10px; }
|
||||
.answer .title { display: block; font-size: .85rem; }
|
||||
|
||||
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; }
|
||||
.stats strong { display: block; font-size: 1.6rem; font-weight: 600; }
|
||||
.stats span { font-size: .72rem; color: var(--muted); }
|
||||
|
||||
.dist { list-style: none; margin: 0 auto; padding: 0; max-width: 360px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.dist li { display: flex; align-items: center; gap: 6px; font-size: .85rem; }
|
||||
.dist .n { width: 1.4em; text-align: right; }
|
||||
.dist .bar { min-width: 8%; padding: 2px 6px; background: var(--header); text-align: right; font-weight: 700; }
|
||||
.dist li.today .bar { background: var(--correct); color: var(--on-mark); }
|
||||
|
||||
.next { margin: 16px 0 8px; }
|
||||
.next strong { font-variant-numeric: tabular-nums; }
|
||||
#next-btn { display: block; margin: 0 auto 8px; }
|
||||
.actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 12px; }
|
||||
.hint { margin: 12px 0 0; font-size: .85rem; }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 16px;
|
||||
transform: translateX(-50%);
|
||||
max-width: calc(100% - 32px);
|
||||
margin: 0;
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
z-index: 40;
|
||||
}
|
||||
.offscreen { position: fixed; top: -1000px; left: 0; opacity: 0; }
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:root { --name-col: 100px; --cell: 72px; --icon: 32px; }
|
||||
.cell { font-size: .7rem; min-height: 48px; padding: 4px 2px; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
:root { --name-col: 84px; --cell: 60px; --icon: 28px; }
|
||||
.app { padding-left: 8px; padding-right: 8px; }
|
||||
.logo { font-size: 1.6rem; }
|
||||
.cell.name { gap: 4px; padding-left: 4px; font-size: .65rem; }
|
||||
}
|
||||
|
||||
@keyframes cell-reveal {
|
||||
from { opacity: 0; transform: scale(.8); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.grow.fresh .cell { opacity: 1; animation: none; }
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
// LoLdle page. Plain JavaScript, no build step. The server owns the game:
|
||||
// this page only sends guesses and renders the rows it gets back. It never
|
||||
// knows the answer, or any champion's attributes, until the game is over.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* @typedef {{name: string, id: string}} Champ
|
||||
* @typedef {{key: string, value: string, result: 'correct'|'partial'|'wrong', dir?: 'up'|'down'}} Cell
|
||||
* @typedef {{name: string, id: string, marks: string, cells: Cell[]}} Row
|
||||
* @typedef {{key: string, label: string}} Column
|
||||
* @typedef {{played: number, win_pct: number, cur: number, max: number, dist: number[]}} StatsView
|
||||
* @typedef {{name: string, id: string, title?: string}} Answer
|
||||
* @typedef {{
|
||||
* mode: 'daily'|'unlimited', num?: number, date?: string, seq?: number,
|
||||
* player: string, max: number, columns: Column[], guesses: Row[],
|
||||
* status: 'playing'|'won'|'lost', gave_up?: boolean, answer?: Answer,
|
||||
* stats?: StatsView, next_at?: number, share?: string, abandoned?: Answer
|
||||
* }} View
|
||||
* @typedef {{code: string, message: string}} ApiError
|
||||
*/
|
||||
|
||||
var TOKEN_KEY = 'loldle-token';
|
||||
var ICON_BASE = 'https://ddragon.leagueoflegends.com/cdn/img/champion/tiles/';
|
||||
var MAX_OPTIONS = 8;
|
||||
var STAGGER_MS = 80;
|
||||
var CONFIRM_MS = 4000;
|
||||
var CHAMPIONS_RETRY_MS = 1000;
|
||||
var CHAMPIONS_RETRY_MAX_MS = 30000;
|
||||
|
||||
/** @param {string} id @returns {HTMLElement} */
|
||||
function $(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) throw new Error('missing #' + id);
|
||||
return el;
|
||||
}
|
||||
|
||||
var input = /** @type {HTMLInputElement} */ ($('search-input'));
|
||||
var list = $('search-list');
|
||||
var board = $('board');
|
||||
var toast = $('toast');
|
||||
var endBox = $('end');
|
||||
var newTop = /** @type {HTMLButtonElement} */ ($('new-top'));
|
||||
|
||||
/** @type {View|null} */
|
||||
var view = null;
|
||||
/** @type {Champ[]} */
|
||||
var champions = [];
|
||||
var championsLoading = false;
|
||||
var championsTries = 0;
|
||||
/** @type {number|undefined} */
|
||||
var championsTimer;
|
||||
/** @type {Champ[]} */
|
||||
var matches = [];
|
||||
var active = -1;
|
||||
var busy = false;
|
||||
var toastTimer = 0;
|
||||
var countdownTimer = 0;
|
||||
var confirmTimer = 0;
|
||||
/** Guesses already shown, so only a new row animates. */
|
||||
var shownGuesses = -1;
|
||||
var shownKey = '';
|
||||
|
||||
var token = readToken();
|
||||
|
||||
/**
|
||||
* Takes the signed token from the URL, then strips it from the address bar
|
||||
* so copying or sharing the page does not leak it. sessionStorage keeps it
|
||||
* for a reload; the page still works when storage is blocked.
|
||||
* @returns {string}
|
||||
*/
|
||||
function readToken() {
|
||||
var fromURL = new URLSearchParams(window.location.search).get('t') || '';
|
||||
if (fromURL) {
|
||||
try { window.sessionStorage.setItem(TOKEN_KEY, fromURL); } catch (e) { /* storage blocked */ }
|
||||
try {
|
||||
window.history.replaceState(null, '', window.location.pathname + window.location.hash);
|
||||
} catch (e) { /* history API unavailable */ }
|
||||
return fromURL;
|
||||
}
|
||||
try { return window.sessionStorage.getItem(TOKEN_KEY) || ''; } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} path
|
||||
* @param {Object} body
|
||||
* @returns {Promise<View>}
|
||||
*/
|
||||
function api(path, body) {
|
||||
return fetch('api/' + path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
credentials: 'omit',
|
||||
cache: 'no-store'
|
||||
}).then(function (res) {
|
||||
return res.json().catch(function () { return {}; }).then(function (data) {
|
||||
if (!res.ok) {
|
||||
throw { code: data.error || 'http_' + res.status, message: data.message || 'Something went wrong. Try again later.' };
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}, function () {
|
||||
throw { code: 'network', message: 'No connection to the server. Check your network and try again.' };
|
||||
});
|
||||
}
|
||||
|
||||
/** @returns {Promise<Champ[]>} rejects when the list cannot be loaded */
|
||||
function fetchChampions() {
|
||||
return fetch('champions.json', { credentials: 'omit' }).then(function (res) {
|
||||
if (!res.ok) throw new Error('champions.json: ' + res.status);
|
||||
return res.json();
|
||||
}).then(function (list) {
|
||||
if (!Array.isArray(list) || !list.length) throw new Error('champions.json: empty');
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the champion list the search needs, retrying with backoff on a
|
||||
* flaky network. A query typed meanwhile is searched again once it lands.
|
||||
*/
|
||||
function loadChampions() {
|
||||
if (championsLoading || champions.length) return;
|
||||
championsLoading = true;
|
||||
clearTimeout(championsTimer);
|
||||
fetchChampions().then(function (list) {
|
||||
championsLoading = false;
|
||||
championsTries = 0;
|
||||
champions = list;
|
||||
if (document.activeElement === input && input.value.trim()) {
|
||||
matches = search(input.value);
|
||||
active = -1;
|
||||
renderList();
|
||||
}
|
||||
}, function () {
|
||||
championsLoading = false;
|
||||
championsTries++;
|
||||
championsTimer = window.setTimeout(loadChampions, Math.min(CHAMPIONS_RETRY_MS * Math.pow(2, championsTries - 1), CHAMPIONS_RETRY_MAX_MS));
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {string} text @param {number} [ms] 0 keeps it shown */
|
||||
function say(text, ms) {
|
||||
toast.textContent = text;
|
||||
toast.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
if (ms !== 0) {
|
||||
toastTimer = window.setTimeout(function () { toast.hidden = true; }, ms || 2200);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {string} s @returns {string} lowercase letters and digits only */
|
||||
function norm(s) {
|
||||
return s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* A champion's icon from Data Dragon, replaced by its initial when the
|
||||
* image cannot load. The handler is attached here: the CSP forbids inline
|
||||
* event attributes.
|
||||
* @param {string} id @param {string} name @returns {HTMLElement}
|
||||
*/
|
||||
function icon(id, name) {
|
||||
var badge = function () {
|
||||
var b = document.createElement('span');
|
||||
b.className = 'icon badge';
|
||||
b.setAttribute('aria-hidden', 'true');
|
||||
b.textContent = name.charAt(0).toUpperCase();
|
||||
return b;
|
||||
};
|
||||
if (!id) return badge();
|
||||
var img = document.createElement('img');
|
||||
img.className = 'icon';
|
||||
img.alt = '';
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.addEventListener('error', function () {
|
||||
if (img.parentNode) img.parentNode.replaceChild(badge(), img);
|
||||
});
|
||||
img.src = ICON_BASE + encodeURIComponent(id) + '_0.jpg';
|
||||
return img;
|
||||
}
|
||||
|
||||
// ---- Search ----
|
||||
|
||||
/** @param {string} query @returns {Champ[]} */
|
||||
function search(query) {
|
||||
var q = norm(query);
|
||||
if (!q || !view) return [];
|
||||
var guessed = {};
|
||||
view.guesses.forEach(function (g) { guessed[g.name] = true; });
|
||||
var prefix = [];
|
||||
var inside = [];
|
||||
champions.forEach(function (c) {
|
||||
if (guessed[c.name]) return;
|
||||
var n = norm(c.name);
|
||||
var at = n.indexOf(q);
|
||||
if (at === 0) prefix.push(c);
|
||||
else if (at > 0) inside.push(c);
|
||||
});
|
||||
return prefix.concat(inside).slice(0, MAX_OPTIONS);
|
||||
}
|
||||
|
||||
function closeList() {
|
||||
list.hidden = true;
|
||||
list.textContent = '';
|
||||
input.setAttribute('aria-expanded', 'false');
|
||||
input.removeAttribute('aria-activedescendant');
|
||||
matches = [];
|
||||
active = -1;
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
list.textContent = '';
|
||||
if (!matches.length) {
|
||||
closeList();
|
||||
return;
|
||||
}
|
||||
matches.forEach(function (c, i) {
|
||||
var li = document.createElement('li');
|
||||
li.id = 'champion-option-' + i;
|
||||
li.setAttribute('role', 'option');
|
||||
li.setAttribute('aria-selected', i === active ? 'true' : 'false');
|
||||
li.appendChild(icon(c.id, c.name));
|
||||
var label = document.createElement('span');
|
||||
label.textContent = c.name;
|
||||
li.appendChild(label);
|
||||
// mousedown keeps the input focused, so a tap picks the option.
|
||||
li.addEventListener('mousedown', function (ev) { ev.preventDefault(); });
|
||||
li.addEventListener('click', function () { pick(c); });
|
||||
list.appendChild(li);
|
||||
});
|
||||
list.hidden = false;
|
||||
input.setAttribute('aria-expanded', 'true');
|
||||
if (active >= 0) {
|
||||
input.setAttribute('aria-activedescendant', 'champion-option-' + active);
|
||||
var el = list.children[active];
|
||||
if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
|
||||
} else {
|
||||
input.removeAttribute('aria-activedescendant');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {Champ} c */
|
||||
function pick(c) {
|
||||
input.value = '';
|
||||
closeList();
|
||||
submit(c.name);
|
||||
}
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
if (!champions.length) {
|
||||
// Typing retries a failed load at once instead of waiting out the backoff.
|
||||
clearTimeout(championsTimer);
|
||||
loadChampions();
|
||||
}
|
||||
matches = search(input.value);
|
||||
active = -1;
|
||||
renderList();
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'ArrowDown' && matches.length) {
|
||||
ev.preventDefault();
|
||||
active = Math.min(active + 1, matches.length - 1);
|
||||
renderList();
|
||||
} else if (ev.key === 'ArrowUp' && matches.length) {
|
||||
ev.preventDefault();
|
||||
active = Math.max(active - 1, 0);
|
||||
renderList();
|
||||
} else if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
if (active >= 0 && matches[active]) {
|
||||
pick(matches[active]);
|
||||
} else if (matches.length === 1 || (matches.length && norm(matches[0].name) === norm(input.value))) {
|
||||
pick(matches[0]);
|
||||
} else if (input.value.trim() && !champions.length) {
|
||||
say(championsTries ? "Couldn't load the champion list. Retrying…" : 'Loading champions…');
|
||||
} else if (input.value.trim()) {
|
||||
say(matches.length ? 'Pick a champion from the list.' : 'No champion matches.');
|
||||
}
|
||||
} else if (ev.key === 'Escape') {
|
||||
closeList();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mousedown', function (ev) {
|
||||
var t = /** @type {Node} */ (ev.target);
|
||||
if (!list.hidden && t !== input && !list.contains(t)) closeList();
|
||||
});
|
||||
|
||||
// ---- Board ----
|
||||
|
||||
/** @param {string} cls @param {string} text @returns {HTMLElement} */
|
||||
function cell(cls, text) {
|
||||
var d = document.createElement('div');
|
||||
d.className = 'cell' + (cls ? ' ' + cls : '');
|
||||
d.setAttribute('role', 'cell');
|
||||
if (text) d.textContent = text;
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cell as words for screen readers: its column, the guessed value, the
|
||||
* result and, for the year, which way the answer lies.
|
||||
* @param {Cell} c @param {Column[]} columns @returns {string}
|
||||
*/
|
||||
function cellWords(c, columns) {
|
||||
var col = columns.filter(function (x) { return x.key === c.key; })[0];
|
||||
var words = (col ? col.label + ': ' : '') + c.value + ', ' + c.result;
|
||||
if (c.dir) words += c.dir === 'up' ? ', the answer is newer' : ', the answer is older';
|
||||
return words;
|
||||
}
|
||||
|
||||
/** @param {Row} g @param {boolean} fresh @returns {HTMLElement} */
|
||||
function rowEl(g, fresh) {
|
||||
var r = document.createElement('div');
|
||||
r.className = 'grow' + (fresh ? ' fresh' : '');
|
||||
r.setAttribute('role', 'row');
|
||||
var name = cell('name', '');
|
||||
name.setAttribute('role', 'rowheader');
|
||||
name.appendChild(icon(g.id, g.name));
|
||||
var label = document.createElement('span');
|
||||
label.textContent = g.name;
|
||||
name.appendChild(label);
|
||||
var cells = [name];
|
||||
g.cells.forEach(function (c) {
|
||||
var el = cell(c.result, c.value);
|
||||
if (c.dir) {
|
||||
var arrow = document.createElement('span');
|
||||
arrow.className = 'dir';
|
||||
arrow.setAttribute('aria-hidden', 'true');
|
||||
arrow.textContent = c.dir === 'up' ? '↑' : '↓';
|
||||
el.appendChild(arrow);
|
||||
}
|
||||
el.setAttribute('aria-label', cellWords(c, view ? view.columns : []));
|
||||
cells.push(el);
|
||||
});
|
||||
cells.forEach(function (el, i) {
|
||||
if (fresh) el.style.animationDelay = (i * STAGGER_MS) + 'ms';
|
||||
r.appendChild(el);
|
||||
});
|
||||
return r;
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
if (!view) return;
|
||||
var key = view.mode + ':' + (view.num || view.seq);
|
||||
var fresh = key === shownKey && view.guesses.length === shownGuesses + 1;
|
||||
board.textContent = '';
|
||||
if (view.guesses.length) {
|
||||
var head = document.createElement('div');
|
||||
head.className = 'grow head';
|
||||
head.setAttribute('role', 'row');
|
||||
var first = cell('name', 'Champion');
|
||||
first.setAttribute('role', 'columnheader');
|
||||
head.appendChild(first);
|
||||
view.columns.forEach(function (c) {
|
||||
var h = cell('', c.label);
|
||||
h.setAttribute('role', 'columnheader');
|
||||
head.appendChild(h);
|
||||
});
|
||||
board.appendChild(head);
|
||||
}
|
||||
for (var i = view.guesses.length - 1; i >= 0; i--) {
|
||||
board.appendChild(rowEl(view.guesses[i], fresh && i === view.guesses.length - 1));
|
||||
}
|
||||
shownKey = key;
|
||||
shownGuesses = view.guesses.length;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!view) return;
|
||||
var daily = view.mode === 'daily';
|
||||
$('mode').textContent = daily ? 'Daily #' + view.num + ' · ' + view.date : 'Unlimited · Round ' + view.seq;
|
||||
$('player').textContent = view.player || '';
|
||||
$('counter').textContent = view.guesses.length + ' / ' + view.max + ' guesses';
|
||||
var playing = view.status === 'playing';
|
||||
input.disabled = !playing || busy;
|
||||
input.placeholder = playing ? 'Type a champion name...' : 'Game over';
|
||||
if (!playing) closeList();
|
||||
newTop.hidden = daily;
|
||||
resetConfirm();
|
||||
renderBoard();
|
||||
renderEnd();
|
||||
}
|
||||
|
||||
// ---- End of game ----
|
||||
|
||||
function renderEnd() {
|
||||
if (!view || view.status === 'playing' || !view.answer || !view.stats) {
|
||||
endBox.hidden = true;
|
||||
clearInterval(countdownTimer);
|
||||
return;
|
||||
}
|
||||
var won = view.status === 'won';
|
||||
var title = $('end-title');
|
||||
title.textContent = won ? 'You got it!' : 'Game Over';
|
||||
title.className = won ? 'won' : 'lost';
|
||||
var ans = view.answer;
|
||||
var iconBox = $('end-icon');
|
||||
iconBox.textContent = '';
|
||||
iconBox.appendChild(icon(ans.id, ans.name));
|
||||
var text = $('end-text');
|
||||
text.textContent = '';
|
||||
var strong = document.createElement('strong');
|
||||
strong.textContent = ans.name;
|
||||
if (won) {
|
||||
var n = view.guesses.length;
|
||||
text.appendChild(document.createTextNode('You found '));
|
||||
text.appendChild(strong);
|
||||
text.appendChild(document.createTextNode(' in ' + n + ' guess' + (n > 1 ? 'es' : '') + '!'));
|
||||
} else {
|
||||
text.appendChild(document.createTextNode(view.gave_up ? 'You gave up. The champion was ' : 'The champion was '));
|
||||
text.appendChild(strong);
|
||||
}
|
||||
if (ans.title) {
|
||||
var sub = document.createElement('span');
|
||||
sub.className = 'title';
|
||||
sub.textContent = ans.title;
|
||||
text.appendChild(sub);
|
||||
}
|
||||
var st = view.stats;
|
||||
$('st-played').textContent = String(st.played);
|
||||
$('st-pct').textContent = String(st.win_pct);
|
||||
$('st-cur').textContent = String(st.cur);
|
||||
$('st-max').textContent = String(st.max);
|
||||
renderDist(st, won);
|
||||
var daily = view.mode === 'daily';
|
||||
$('end-daily').hidden = !daily;
|
||||
$('end-unlimited').hidden = daily;
|
||||
$('daily-hint').hidden = daily;
|
||||
if (daily) {
|
||||
updateShare();
|
||||
tick();
|
||||
clearInterval(countdownTimer);
|
||||
countdownTimer = window.setInterval(tick, 1000);
|
||||
} else {
|
||||
clearInterval(countdownTimer);
|
||||
}
|
||||
endBox.hidden = false;
|
||||
}
|
||||
|
||||
/** @param {StatsView} st @param {boolean} won */
|
||||
function renderDist(st, won) {
|
||||
var dist = $('dist');
|
||||
dist.textContent = '';
|
||||
// Rows up to the round's budget, or further when an older, longer round
|
||||
// won there.
|
||||
var rows = view ? view.max : st.dist.length;
|
||||
st.dist.forEach(function (n, i) { if (n > 0) rows = Math.max(rows, i + 1); });
|
||||
rows = Math.min(rows, st.dist.length);
|
||||
var top = Math.max.apply(null, st.dist.concat([1]));
|
||||
for (var i = 0; i < rows; i++) {
|
||||
var li = document.createElement('li');
|
||||
if (won && view && i === view.guesses.length - 1) li.className = 'today';
|
||||
var label = document.createElement('span');
|
||||
label.className = 'n';
|
||||
label.textContent = String(i + 1);
|
||||
var bar = document.createElement('span');
|
||||
bar.className = 'bar';
|
||||
bar.textContent = String(st.dist[i]);
|
||||
bar.style.width = Math.max(8, Math.round(st.dist[i] / top * 100)) + '%';
|
||||
li.appendChild(label);
|
||||
li.appendChild(bar);
|
||||
dist.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows Share when Telegram's games.js is loaded. It loads async, so this
|
||||
* runs again on window load in case the results showed first.
|
||||
*/
|
||||
function updateShare() {
|
||||
$('share-btn').hidden = !(window.TelegramGameProxy && typeof window.TelegramGameProxy.shareScore === 'function');
|
||||
}
|
||||
|
||||
/** Counts down to the next daily champion; at zero it offers to load it. */
|
||||
function tick() {
|
||||
if (!view || !view.next_at) return;
|
||||
var left = Math.max(0, Math.floor(view.next_at - Date.now() / 1000));
|
||||
var h = Math.floor(left / 3600);
|
||||
var m = Math.floor(left % 3600 / 60);
|
||||
var s = left % 60;
|
||||
$('countdown').textContent = pad(h) + ':' + pad(m) + ':' + pad(s);
|
||||
$('next-btn').hidden = left > 0;
|
||||
}
|
||||
|
||||
/** @param {number} n */
|
||||
function pad(n) { return (n < 10 ? '0' : '') + n; }
|
||||
|
||||
// ---- Requests ----
|
||||
|
||||
/** @param {View} v */
|
||||
function apply(v) {
|
||||
if (view && v.guesses.length > view.guesses.length && v.mode === view.mode) {
|
||||
var g = v.guesses[v.guesses.length - 1];
|
||||
$('announce').textContent = g.name + ': ' + g.cells.map(function (c) { return cellWords(c, v.columns); }).join('; ');
|
||||
}
|
||||
view = v;
|
||||
render();
|
||||
}
|
||||
|
||||
/** @param {ApiError} err */
|
||||
function fail(err) {
|
||||
switch (err.code) {
|
||||
case 'new_puzzle':
|
||||
case 'new_round':
|
||||
say(err.message, 3000);
|
||||
load();
|
||||
return;
|
||||
case 'finished':
|
||||
load();
|
||||
return;
|
||||
case 'bad_token':
|
||||
case 'expired':
|
||||
say('Open the game again from the chat.', 0);
|
||||
return;
|
||||
}
|
||||
say(err.message, 3000);
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
api('state', { token: token }).then(function (v) {
|
||||
busy = false;
|
||||
$('screen-game').hidden = false;
|
||||
apply(v);
|
||||
}, function (err) {
|
||||
busy = false;
|
||||
$('screen-game').hidden = false;
|
||||
fail(err);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param {string} name */
|
||||
function submit(name) {
|
||||
if (busy || !view || view.status !== 'playing') return;
|
||||
busy = true;
|
||||
input.disabled = true;
|
||||
var body = { token: token, name: name };
|
||||
if (view.mode === 'daily') body.num = view.num; else body.seq = view.seq;
|
||||
api('guess', body).then(function (v) {
|
||||
busy = false;
|
||||
apply(v);
|
||||
if (v.status === 'won') say('You got it!', 2000);
|
||||
if (v.status === 'playing') input.focus();
|
||||
}, function (err) {
|
||||
busy = false;
|
||||
if (view) input.disabled = view.status !== 'playing';
|
||||
fail(err);
|
||||
});
|
||||
}
|
||||
|
||||
function newGame() {
|
||||
if (busy || !view || view.mode !== 'unlimited') return;
|
||||
busy = true;
|
||||
resetConfirm();
|
||||
api('new', { token: token, seq: view.seq }).then(function (v) {
|
||||
busy = false;
|
||||
endBox.hidden = true;
|
||||
apply(v);
|
||||
window.scrollTo(0, 0);
|
||||
if (v.abandoned) say('Round given up. The champion was ' + v.abandoned.name + '.', 4000);
|
||||
}, function (err) {
|
||||
busy = false;
|
||||
fail(err);
|
||||
});
|
||||
}
|
||||
|
||||
/** The header button asks once before giving up a round with guesses. */
|
||||
function resetConfirm() {
|
||||
clearTimeout(confirmTimer);
|
||||
newTop.classList.remove('confirm');
|
||||
newTop.textContent = 'New game';
|
||||
}
|
||||
|
||||
newTop.addEventListener('click', function () {
|
||||
if (!view) return;
|
||||
var risky = view.status === 'playing' && view.guesses.length > 0;
|
||||
if (risky && !newTop.classList.contains('confirm')) {
|
||||
newTop.classList.add('confirm');
|
||||
newTop.textContent = 'Give up this round?';
|
||||
clearTimeout(confirmTimer);
|
||||
confirmTimer = window.setTimeout(resetConfirm, CONFIRM_MS);
|
||||
return;
|
||||
}
|
||||
newGame();
|
||||
});
|
||||
$('new-end').addEventListener('click', newGame);
|
||||
|
||||
/** Copies text, falling back to a hidden textarea where the clipboard API is blocked. */
|
||||
function copy(text) {
|
||||
var done = function () { say('Copied results to clipboard'); };
|
||||
var fallback = function () {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.className = 'offscreen';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
var ok = false;
|
||||
try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
|
||||
document.body.removeChild(ta);
|
||||
if (ok) done(); else say('Copy failed. Select and copy the result by hand.', 3000);
|
||||
};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done, fallback);
|
||||
} else {
|
||||
fallback();
|
||||
}
|
||||
}
|
||||
|
||||
$('share-btn').addEventListener('click', function () {
|
||||
if (window.TelegramGameProxy) window.TelegramGameProxy.shareScore();
|
||||
});
|
||||
$('copy-btn').addEventListener('click', function () {
|
||||
if (view && view.share) copy(view.share);
|
||||
});
|
||||
$('next-btn').addEventListener('click', function () {
|
||||
endBox.hidden = true;
|
||||
load();
|
||||
});
|
||||
window.addEventListener('load', updateShare);
|
||||
|
||||
if (!token) {
|
||||
$('screen-missing').hidden = false;
|
||||
} else {
|
||||
loadChampions();
|
||||
load();
|
||||
}
|
||||
}());
|
||||
@@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>LoLdle</title>
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="app">
|
||||
<header class="top">
|
||||
<h1 class="logo">Lo<span class="accent">L</span>dle</h1>
|
||||
<p id="mode" class="mode"></p>
|
||||
<p id="player" class="muted"></p>
|
||||
</header>
|
||||
|
||||
<section id="screen-missing" class="card" hidden>
|
||||
<p class="lead">Open the game from Telegram.</p>
|
||||
<p class="muted">Send <code>/loldle</code> or <code>/loldledaily</code> in a chat with the bot, then press Play.</p>
|
||||
</section>
|
||||
|
||||
<section id="screen-game" hidden>
|
||||
<div class="bar">
|
||||
<p id="counter" class="counter"></p>
|
||||
<button type="button" id="new-top" class="ghost" hidden>New game</button>
|
||||
</div>
|
||||
|
||||
<div class="search">
|
||||
<label for="search-input" class="sr-only">Champion</label>
|
||||
<input id="search-input" type="text" role="combobox" aria-expanded="false" aria-controls="search-list"
|
||||
aria-autocomplete="list" autocomplete="off" autocapitalize="off" spellcheck="false"
|
||||
placeholder="Type a champion name...">
|
||||
<ul id="search-list" class="options" role="listbox" aria-label="Champions" hidden></ul>
|
||||
</div>
|
||||
|
||||
<ul class="legend" aria-label="Colour key">
|
||||
<li><span class="swatch correct" aria-hidden="true"></span>Correct</li>
|
||||
<li><span class="swatch partial" aria-hidden="true"></span>Partial</li>
|
||||
<li><span class="swatch wrong" aria-hidden="true"></span>Wrong</li>
|
||||
<li><span class="arrows" aria-hidden="true">↑↓</span>Released later / earlier</li>
|
||||
</ul>
|
||||
|
||||
<section id="end" class="end card" hidden>
|
||||
<h2 id="end-title"></h2>
|
||||
<div class="answer">
|
||||
<span id="end-icon" class="answer-icon"></span>
|
||||
<p id="end-text"></p>
|
||||
</div>
|
||||
<h3>Statistics</h3>
|
||||
<div class="stats">
|
||||
<div><strong id="st-played">0</strong><span>Played</span></div>
|
||||
<div><strong id="st-pct">0</strong><span>Win %</span></div>
|
||||
<div><strong id="st-cur">0</strong><span>Current Streak</span></div>
|
||||
<div><strong id="st-max">0</strong><span>Max Streak</span></div>
|
||||
</div>
|
||||
<h3>Guess Distribution</h3>
|
||||
<ol id="dist" class="dist"></ol>
|
||||
<div id="end-daily" hidden>
|
||||
<p class="next">Next champion in <strong id="countdown">--:--:--</strong></p>
|
||||
<button type="button" id="next-btn" class="primary" hidden>Play the new champion</button>
|
||||
<div class="actions">
|
||||
<button type="button" id="share-btn" class="primary" hidden>Share</button>
|
||||
<button type="button" id="copy-btn">Copy result</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="end-unlimited" class="actions" hidden>
|
||||
<button type="button" id="new-end" class="primary">New game</button>
|
||||
</div>
|
||||
<p id="daily-hint" class="muted hint" hidden>Want the same champion as everyone today? Send <code>/loldledaily</code>.</p>
|
||||
</section>
|
||||
|
||||
<div class="board-wrap">
|
||||
<div id="board" class="board" role="table" aria-label="Guesses"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p id="toast" class="toast" role="status" aria-live="polite" hidden></p>
|
||||
<p id="announce" class="sr-only" aria-live="polite"></p>
|
||||
</main>
|
||||
<script src="app.js"></script>
|
||||
<script src="https://telegram.org/js/games.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
// Package web holds the LoLdle page: plain HTML, CSS and JavaScript with no
|
||||
// build step, embedded into the binary.
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
// FS holds index.html, app.css and app.js.
|
||||
//
|
||||
//go:embed index.html app.css app.js
|
||||
var FS embed.FS
|
||||
@@ -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" +
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))]
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, "<b>Tí & Tèo</b>") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:])))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package guessgame
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/log"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules"
|
||||
"github.com/tiennm99/tiennm99bot/internal/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
cardKeyPrefix = "ucard:"
|
||||
// CardTTL is how long a card keeps opening unlimited mode after its
|
||||
// last Play press; after that the cleanup forgets it and it plays daily.
|
||||
CardTTL = 30 * 24 * time.Hour
|
||||
// cardTouchEvery throttles the Play-time refresh of a card's age to
|
||||
// one write a day.
|
||||
cardTouchEvery = 24 * time.Hour
|
||||
)
|
||||
|
||||
// CardRecord is a game message that opens unlimited mode. Every other card
|
||||
// of the game, the daily ones, the 07:00 push, an inline share and a card
|
||||
// whose record expired, plays the daily puzzle.
|
||||
type CardRecord struct {
|
||||
ChatID int64 `json:"chat_id" bson:"chat_id"`
|
||||
MessageID int `json:"message_id" bson:"message_id"`
|
||||
ThreadID int `json:"thread_id,omitempty" bson:"thread_id,omitempty"`
|
||||
TouchedAt int64 `json:"touched_at" bson:"touched_at"` // unix seconds of creation or a recent Play
|
||||
}
|
||||
|
||||
// Cards stores a game's unlimited card records in the game's collection.
|
||||
type Cards struct {
|
||||
store storage.DocStore[CardRecord]
|
||||
now func() time.Time
|
||||
name string
|
||||
}
|
||||
|
||||
// NewCards returns the card records over coll, or nil without storage.
|
||||
// name prefixes log lines.
|
||||
func NewCards(coll storage.Collection, now func() time.Time, name string) *Cards {
|
||||
if coll == nil {
|
||||
return nil
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Cards{store: storage.Typed[CardRecord](coll), now: now, name: name}
|
||||
}
|
||||
|
||||
func cardKey(chatID int64, messageID int) string {
|
||||
return cardKeyPrefix + strconv.FormatInt(chatID, 10) + ":" + strconv.Itoa(messageID)
|
||||
}
|
||||
|
||||
// Record marks the chat message as an unlimited card.
|
||||
func (c *Cards) Record(ctx context.Context, chatID int64, messageID, threadID int) error {
|
||||
if c == nil {
|
||||
return errors.New("guessgame: no storage for cards")
|
||||
}
|
||||
rec := CardRecord{ChatID: chatID, MessageID: messageID, ThreadID: threadID, TouchedAt: c.now().Unix()}
|
||||
return c.store.Put(ctx, cardKey(chatID, messageID), rec)
|
||||
}
|
||||
|
||||
// Lookup reports whether the chat message is an unlimited card, and
|
||||
// refreshes its age at most once a day so a card in use never expires.
|
||||
func (c *Cards) Lookup(ctx context.Context, chatID int64, messageID int) (CardRecord, bool, error) {
|
||||
if c == nil {
|
||||
return CardRecord{}, false, nil
|
||||
}
|
||||
key := cardKey(chatID, messageID)
|
||||
rec, _, err := c.store.Get(ctx, key)
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
return CardRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return CardRecord{}, false, err
|
||||
}
|
||||
now := c.now().Unix()
|
||||
if now-rec.TouchedAt >= int64(cardTouchEvery/time.Second) {
|
||||
rec.TouchedAt = now
|
||||
if err := c.store.Put(ctx, key, rec); err != nil {
|
||||
log.Warn(c.name+" card refresh failed", "err", err)
|
||||
}
|
||||
}
|
||||
return rec, true, nil
|
||||
}
|
||||
|
||||
// Cleanup forgets cards nobody played for CardTTL. It is a cron handler.
|
||||
func (c *Cards) Cleanup(ctx context.Context, _ modules.Deps) error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
docs, err := c.store.Scan(ctx, cardKeyPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cutoff := c.now().Add(-CardTTL).Unix()
|
||||
var errs []error
|
||||
for _, d := range docs {
|
||||
if d.Val.TouchedAt < cutoff {
|
||||
if err := c.store.Delete(ctx, d.ID); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// SendCard sends the BotFather game into the message's chat and topic and
|
||||
// records it as an unlimited card. A card that could not be recorded would
|
||||
// silently play daily, so it is deleted again and the error returned.
|
||||
func (c *Cards) SendCard(ctx context.Context, b *bot.Bot, msg *models.Message, game string) error {
|
||||
thread := TopicOf(msg)
|
||||
sent, err := b.SendGame(ctx, &bot.SendGameParams{
|
||||
ChatID: msg.Chat.ID,
|
||||
MessageThreadID: thread,
|
||||
GameShorName: game, // the library's field name is misspelled
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Record(ctx, msg.Chat.ID, sent.ID, thread); err != nil {
|
||||
if _, derr := b.DeleteMessage(ctx, &bot.DeleteMessageParams{ChatID: msg.Chat.ID, MessageID: sent.ID}); derr != nil {
|
||||
log.Warn(c.nameOr("guessgame")+" delete unrecorded card failed", "err", derr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cards) nameOr(def string) string {
|
||||
if c == nil || c.name == "" {
|
||||
return def
|
||||
}
|
||||
return c.name
|
||||
}
|
||||
|
||||
// PlayParams configures HandlePlay for one game.
|
||||
type PlayParams struct {
|
||||
Enabled bool
|
||||
Cards *Cards
|
||||
Now time.Time
|
||||
TokenKey []byte
|
||||
// PageURL is the page's absolute URL; the token goes in its ?t=.
|
||||
PageURL string
|
||||
MsgDisabled string
|
||||
MsgNoTarget string
|
||||
MsgCardLookup string
|
||||
LogName string
|
||||
}
|
||||
|
||||
// HandlePlay answers a Play press with the game URL carrying a signed token,
|
||||
// or with an alert when the game cannot open. A press on a recorded card
|
||||
// opens unlimited mode; any other card plays daily. Every path answers the
|
||||
// query, so the client never keeps spinning.
|
||||
func HandlePlay(ctx context.Context, b *bot.Bot, q *models.CallbackQuery, p PlayParams) error {
|
||||
if !p.Enabled {
|
||||
return AnswerAlert(ctx, b, q.ID, p.MsgDisabled)
|
||||
}
|
||||
c, form := PlayClaims(q)
|
||||
if form == "" {
|
||||
return AnswerAlert(ctx, b, q.ID, p.MsgNoTarget)
|
||||
}
|
||||
// An inline card has no chat message to record, so it is always daily.
|
||||
if c.InlineID == "" {
|
||||
_, ok, err := p.Cards.Lookup(ctx, c.ChatID, c.MessageID)
|
||||
if err != nil {
|
||||
// Never fall back to daily silently: the player pressed an
|
||||
// unlimited card.
|
||||
_ = AnswerAlert(ctx, b, q.ID, p.MsgCardLookup)
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
c.Mode = ModeUnlimited
|
||||
}
|
||||
}
|
||||
c.Expiry = p.Now.Add(TokenTTL).Unix()
|
||||
token, err := SignToken(p.TokenKey, c)
|
||||
if err != nil {
|
||||
_ = AnswerAlert(ctx, b, q.ID, p.MsgDisabled)
|
||||
return err
|
||||
}
|
||||
log.Debug(p.LogName+" play", "address", form, "mode", c.Mode)
|
||||
_, err = b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{
|
||||
CallbackQueryID: q.ID,
|
||||
URL: p.PageURL + "?t=" + url.QueryEscape(token),
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package guessgame
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
|
||||
)
|
||||
|
||||
// TokenTTL bounds how long a Play link stays usable. The token is not tied
|
||||
// to a puzzle or a round: the page always plays the one current at request
|
||||
// time.
|
||||
const TokenTTL = 6 * time.Hour
|
||||
|
||||
// MaxNameRunes caps the display name carried in a token.
|
||||
const MaxNameRunes = 64
|
||||
|
||||
// Card modes a token carries. The empty mode is daily, so every token
|
||||
// signed before unlimited mode existed still opens the daily puzzle.
|
||||
const (
|
||||
ModeDaily = ""
|
||||
ModeUnlimited = "u"
|
||||
)
|
||||
|
||||
// Claims is what a game token asserts: who pressed Play, on which card, in
|
||||
// which mode, until when. Exactly one address is set: ChatID+MessageID for
|
||||
// a card the bot sent into a chat (ThreadID is its forum topic), or
|
||||
// InlineID for one sent via the bot.
|
||||
type Claims struct {
|
||||
UserID int64 `json:"u"`
|
||||
Name string `json:"n,omitempty"`
|
||||
ChatID int64 `json:"c,omitempty"`
|
||||
MessageID int `json:"m,omitempty"`
|
||||
InlineID string `json:"i,omitempty"`
|
||||
ThreadID int `json:"t,omitempty"`
|
||||
Expiry int64 `json:"e"`
|
||||
Mode string `json:"md,omitempty"`
|
||||
}
|
||||
|
||||
// Valid requires a player, an expiry, a known mode and exactly one card
|
||||
// address.
|
||||
func (c Claims) Valid() bool {
|
||||
if c.UserID <= 0 || c.Expiry <= 0 || (c.Mode != ModeDaily && c.Mode != ModeUnlimited) {
|
||||
return false
|
||||
}
|
||||
chat := c.ChatID != 0 && c.MessageID != 0
|
||||
inline := c.InlineID != ""
|
||||
if c.ThreadID != 0 && !chat {
|
||||
return false
|
||||
}
|
||||
return chat != inline
|
||||
}
|
||||
|
||||
// ExpiresAt is the unix second the token stops working.
|
||||
func (c Claims) ExpiresAt() int64 { return c.Expiry }
|
||||
|
||||
// Unlimited reports whether the token opens the player's unlimited round.
|
||||
func (c Claims) Unlimited() bool { return c.Mode == ModeUnlimited }
|
||||
|
||||
// SignToken signs c with key.
|
||||
func SignToken(key []byte, c Claims) (string, error) { return htmlgame.Sign(key, c) }
|
||||
|
||||
// VerifyToken checks token's signature and expiry under key.
|
||||
func VerifyToken(key []byte, token string, now time.Time) (Claims, error) {
|
||||
var c Claims
|
||||
if err := htmlgame.Verify(key, token, now, &c); err != nil {
|
||||
return Claims{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// DisplayName joins a Telegram first and last name within MaxNameRunes.
|
||||
func DisplayName(first, last string) string {
|
||||
name := strings.TrimSpace(strings.TrimSpace(first) + " " + strings.TrimSpace(last))
|
||||
name = strings.Join(strings.Fields(name), " ")
|
||||
if utf8.RuneCountInString(name) > MaxNameRunes {
|
||||
name = string([]rune(name)[:MaxNameRunes])
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// PlayClaims addresses the card: the message itself when the bot can see
|
||||
// it (with its forum topic), the inaccessible message's chat and id
|
||||
// otherwise, and the inline message id for a card sent via the bot. form is
|
||||
// "" when none is present.
|
||||
func PlayClaims(q *models.CallbackQuery) (Claims, string) {
|
||||
c := Claims{UserID: q.From.ID, Name: DisplayName(q.From.FirstName, q.From.LastName)}
|
||||
switch {
|
||||
case q.Message.Message != nil:
|
||||
m := q.Message.Message
|
||||
c.ChatID, c.MessageID = m.Chat.ID, m.ID
|
||||
if m.IsTopicMessage {
|
||||
c.ThreadID = m.MessageThreadID
|
||||
}
|
||||
return c, "message"
|
||||
case q.Message.InaccessibleMessage != nil:
|
||||
c.ChatID, c.MessageID = q.Message.InaccessibleMessage.Chat.ID, q.Message.InaccessibleMessage.MessageID
|
||||
return c, "inaccessible_message"
|
||||
case q.InlineMessageID != "":
|
||||
c.InlineID = q.InlineMessageID
|
||||
return c, "inline_message"
|
||||
}
|
||||
return c, ""
|
||||
}
|
||||
|
||||
// AnswerAlert answers a callback query with an alert.
|
||||
func AnswerAlert(ctx context.Context, b *bot.Bot, id, text string) error {
|
||||
_, err := b.AnswerCallbackQuery(ctx, &bot.AnswerCallbackQueryParams{CallbackQueryID: id, Text: text, ShowAlert: true})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package guessgame
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/keylock"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/subscription"
|
||||
"github.com/tiennm99/tiennm99bot/internal/storage"
|
||||
)
|
||||
|
||||
// TelegramAPI is the subset of *bot.Bot the summary and the daily push use.
|
||||
type TelegramAPI interface {
|
||||
SendMessage(ctx context.Context, params *bot.SendMessageParams) (*models.Message, error)
|
||||
EditMessageText(ctx context.Context, params *bot.EditMessageTextParams) (*models.Message, error)
|
||||
SendGame(ctx context.Context, params *bot.SendGameParams) (*models.Message, error)
|
||||
}
|
||||
|
||||
// ScoreReporter records a won game's score on the card it was played from.
|
||||
type ScoreReporter interface {
|
||||
Report(ctx context.Context, a htmlgame.Address, userID int64, score int) error
|
||||
}
|
||||
|
||||
// BotReporter reports scores through the bot with setGameScore.
|
||||
type BotReporter struct{ B *bot.Bot }
|
||||
|
||||
// Report implements ScoreReporter.
|
||||
func (r BotReporter) Report(ctx context.Context, a htmlgame.Address, userID int64, score int) error {
|
||||
return htmlgame.ReportScore(ctx, r.B, a, userID, score)
|
||||
}
|
||||
|
||||
// DailyConfig is one game's daily mode.
|
||||
type DailyConfig struct {
|
||||
Rules Rules
|
||||
Answers []string // the answer pool; each cycle walks a keyed permutation of it
|
||||
AnswerKey []byte // keys the answer order
|
||||
Store storage.Collection
|
||||
// StatsPrefix is the key prefix of players' daily stats; "stats:" when
|
||||
// empty. A game whose collection already uses "stats:" sets another.
|
||||
StatsPrefix string
|
||||
Epoch time.Time // puzzle #1 starts here; zero means Epoch
|
||||
Now func() time.Time
|
||||
Schedule func(d time.Duration, f func()) // runs the summary flush; time.AfterFunc by default
|
||||
Async func(f func()) // runs a score report; a goroutine by default
|
||||
Reporter ScoreReporter // nil skips score reports
|
||||
API TelegramAPI // nil skips the live group summary
|
||||
// GameShortName is the BotFather game the 07:00 push sends.
|
||||
GameShortName string
|
||||
// LogName prefixes log lines, e.g. "wordledaily".
|
||||
LogName string
|
||||
// AnswerPrefix starts the recap's answer line; "Answer: " when empty.
|
||||
AnswerPrefix string
|
||||
}
|
||||
|
||||
// Daily is a game's daily mode: everyone plays the same answer, a new one
|
||||
// every day at 07:00 ICT. Its stores are exported for the game's own tests
|
||||
// and migrations.
|
||||
type Daily struct {
|
||||
cfg DailyConfig
|
||||
epoch time.Time
|
||||
|
||||
Puzzles storage.DocStore[PuzzleDoc]
|
||||
Plays storage.DocStore[Progress]
|
||||
Stats storage.DocStore[UserStats]
|
||||
ChatDays storage.DocStore[ChatDay]
|
||||
Streaks storage.DocStore[ChatStreak]
|
||||
Subscribers subscription.Store
|
||||
PushDate subscription.DayStore
|
||||
|
||||
SubscribersMu sync.Mutex
|
||||
// Locks serialises one player's daily requests and one chat topic's
|
||||
// summary updates.
|
||||
Locks keylock.Map
|
||||
|
||||
perms permCache
|
||||
cache puzzleCache
|
||||
flush flusher
|
||||
}
|
||||
|
||||
// NewDaily builds a game's daily mode. Without a store it only answers the
|
||||
// questions that need none; the game reports itself disabled.
|
||||
func NewDaily(cfg DailyConfig) *Daily {
|
||||
if cfg.Now == nil {
|
||||
cfg.Now = time.Now
|
||||
}
|
||||
if cfg.Schedule == nil {
|
||||
cfg.Schedule = func(d time.Duration, f func()) { time.AfterFunc(d, f) }
|
||||
}
|
||||
if cfg.StatsPrefix == "" {
|
||||
cfg.StatsPrefix = "stats:"
|
||||
}
|
||||
if cfg.AnswerPrefix == "" {
|
||||
cfg.AnswerPrefix = "Answer: "
|
||||
}
|
||||
d := &Daily{cfg: cfg, epoch: Epoch}
|
||||
if !cfg.Epoch.IsZero() {
|
||||
d.epoch = cfg.Epoch
|
||||
}
|
||||
if cfg.Store != nil {
|
||||
d.Puzzles = storage.Typed[PuzzleDoc](cfg.Store)
|
||||
d.Plays = storage.Typed[Progress](cfg.Store)
|
||||
d.Stats = storage.Typed[UserStats](cfg.Store)
|
||||
d.ChatDays = storage.Typed[ChatDay](cfg.Store)
|
||||
d.Streaks = storage.Typed[ChatStreak](cfg.Store)
|
||||
d.Subscribers = storage.Typed[subscription.Doc](cfg.Store)
|
||||
d.PushDate = storage.Typed[subscription.DayDoc](cfg.Store)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Rules returns the game's rules.
|
||||
func (d *Daily) Rules() Rules { return d.cfg.Rules }
|
||||
|
||||
// MaxGuesses is the daily guess budget.
|
||||
func (d *Daily) MaxGuesses() int { return d.cfg.Rules.MaxGuesses() }
|
||||
|
||||
// Score is what a daily win reports with setGameScore: fewer guesses rank
|
||||
// higher.
|
||||
func (d *Daily) Score(guesses int) int { return d.MaxGuesses() + 1 - guesses }
|
||||
|
||||
func (d *Daily) async(f func()) {
|
||||
if d.cfg.Async != nil {
|
||||
d.cfg.Async(f)
|
||||
return
|
||||
}
|
||||
go f()
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package guessgame
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/subscription"
|
||||
)
|
||||
|
||||
// DailyTexts are a daily game's command replies.
|
||||
type DailyTexts struct {
|
||||
Channel string // the game cannot be played in a channel
|
||||
Disabled string // the game is not configured
|
||||
SendFail string // the card could not be sent
|
||||
}
|
||||
|
||||
// DailyCommands are the handlers of a daily game's three commands: send
|
||||
// today's card, subscribe to the 07:00 push, unsubscribe. enabled reports
|
||||
// whether Play can open the game.
|
||||
type DailyCommands struct {
|
||||
Daily *Daily
|
||||
Enabled func() bool
|
||||
Texts DailyTexts
|
||||
}
|
||||
|
||||
// HandleCard sends the BotFather game in the same chat and topic. No reply
|
||||
// markup: Telegram then adds the Play button itself, which is the button a
|
||||
// game message requires first. The card is not recorded, so it plays daily.
|
||||
func (dc DailyCommands) HandleCard(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
if msg.Chat.Type == models.ChatTypeChannel {
|
||||
return chathelper.Reply(ctx, b, msg, dc.Texts.Channel)
|
||||
}
|
||||
if !dc.Enabled() {
|
||||
return chathelper.Reply(ctx, b, msg, dc.Texts.Disabled)
|
||||
}
|
||||
if _, err := b.SendGame(ctx, &bot.SendGameParams{
|
||||
ChatID: msg.Chat.ID,
|
||||
MessageThreadID: TopicOf(msg),
|
||||
GameShorName: dc.Daily.cfg.GameShortName, // the library's field name is misspelled
|
||||
}); err != nil {
|
||||
_ = chathelper.Reply(ctx, b, msg, dc.Texts.SendFail)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleSubscribe opts the chat topic into the 07:00 ICT push. Anyone in
|
||||
// the chat may subscribe, like the other daily pushes.
|
||||
func (dc DailyCommands) HandleSubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
if msg.Chat.Type == models.ChatTypeChannel {
|
||||
return chathelper.Reply(ctx, b, msg, dc.Texts.Channel)
|
||||
}
|
||||
// The push cron exists only when the game is enabled, so a
|
||||
// subscription would never be served.
|
||||
if !dc.Enabled() {
|
||||
return chathelper.Reply(ctx, b, msg, dc.Texts.Disabled)
|
||||
}
|
||||
d := dc.Daily
|
||||
thread := TopicOf(msg)
|
||||
d.SubscribersMu.Lock()
|
||||
defer d.SubscribersMu.Unlock()
|
||||
added, err := subscription.Add(ctx, d.Subscribers, msg.Chat.ID, thread)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scope := ScopeOf(thread)
|
||||
if added {
|
||||
return chathelper.Reply(ctx, b, msg,
|
||||
"✅ Subscribed "+scope+" to "+d.cfg.Rules.Label()+": a new puzzle and yesterday's group results every day at 07:00 ICT.\n"+
|
||||
"If you block the bot, you'll be auto-unsubscribed on the next push.")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg, "Already subscribed in "+scope+".")
|
||||
}
|
||||
|
||||
// HandleUnsubscribe opts the chat topic out. It works while the game is
|
||||
// disabled too, so a chat can always leave.
|
||||
func (dc DailyCommands) HandleUnsubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
d := dc.Daily
|
||||
if d.Subscribers == nil {
|
||||
return chathelper.Reply(ctx, b, msg, dc.Texts.Disabled)
|
||||
}
|
||||
thread := TopicOf(msg)
|
||||
d.SubscribersMu.Lock()
|
||||
defer d.SubscribersMu.Unlock()
|
||||
removed, err := subscription.Remove(ctx, d.Subscribers, msg.Chat.ID, thread)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scope := ScopeOf(thread)
|
||||
if removed {
|
||||
return chathelper.Reply(ctx, b, msg, "Unsubscribed "+scope+".")
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg, strings.ToUpper(scope[:1])+scope[1:]+" wasn't subscribed.")
|
||||
}
|
||||
|
||||
// TopicOf is the message's forum topic, or 0 for the whole chat. Telegram
|
||||
// also gives a reply chain in an ordinary supergroup a thread id;
|
||||
// IsTopicMessage marks a real topic. Plays key the group results the same
|
||||
// way (PlayClaims), so a subscription made by replying still gets its
|
||||
// chat's recap and streak.
|
||||
func TopicOf(msg *models.Message) int {
|
||||
if !msg.IsTopicMessage {
|
||||
return 0
|
||||
}
|
||||
return msg.MessageThreadID
|
||||
}
|
||||
|
||||
// ScopeOf names a subscription's scope in replies.
|
||||
func ScopeOf(thread int) string {
|
||||
if thread != 0 {
|
||||
return "this topic"
|
||||
}
|
||||
return "this chat"
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package guessgame
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/tiennm99/tiennm99bot/internal/log"
|
||||
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
|
||||
)
|
||||
|
||||
const reportTimeout = 10 * time.Second
|
||||
|
||||
// Outcome is a player's daily game after a request, with what the request
|
||||
// changed.
|
||||
type Outcome struct {
|
||||
Num int
|
||||
Answer string
|
||||
P Progress
|
||||
Stats UserStats // set once the game is over
|
||||
|
||||
guessed bool // this request added a guess
|
||||
ended bool // this request finished the game
|
||||
joined bool // this request added the card's chat to the player's chats
|
||||
report bool // this request must report the win on the card
|
||||
}
|
||||
|
||||
// cardKeyOf names the card a token was issued for, so a win is reported on
|
||||
// each card once.
|
||||
func cardKeyOf(c Claims) string {
|
||||
if c.InlineID != "" {
|
||||
return "i:" + c.InlineID
|
||||
}
|
||||
return "c:" + strconv.FormatInt(c.ChatID, 10) + ":" + strconv.Itoa(c.MessageID)
|
||||
}
|
||||
|
||||
func userLockKey(userID int64) string { return "u:" + strconv.FormatInt(userID, 10) }
|
||||
|
||||
// SubmitGuess plays input on today's puzzle for the token's player. num is
|
||||
// the puzzle the page shows; a stale one is refused so a board never mixes
|
||||
// two days. Rules.Judge errors pass through unchanged.
|
||||
func (d *Daily) SubmitGuess(ctx context.Context, c Claims, num int, input string) (Outcome, error) {
|
||||
now := d.cfg.Now()
|
||||
today := d.PuzzleNum(now)
|
||||
if num != today {
|
||||
return Outcome{}, ErrNewPuzzle
|
||||
}
|
||||
answer, err := d.ResolvePuzzle(ctx, today)
|
||||
if err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
o, err := d.guessLocked(ctx, c, today, answer, input, now)
|
||||
if err != nil {
|
||||
if o.ended {
|
||||
// The refused guess repaired a finished board's stats; its
|
||||
// group bookkeeping still has to run.
|
||||
d.afterPlay(c, o)
|
||||
}
|
||||
return o, err
|
||||
}
|
||||
d.afterPlay(c, o)
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (d *Daily) guessLocked(ctx context.Context, c Claims, num int, answer, input string, now time.Time) (Outcome, error) {
|
||||
defer d.Locks.Acquire(userLockKey(c.UserID))()
|
||||
p, err := d.LoadProgress(ctx, num, c.UserID)
|
||||
if err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
if p.Finished() {
|
||||
// The board is the record: a finish whose stats write failed is
|
||||
// folded into the stats now, before the guess is refused.
|
||||
o := Outcome{Num: num, Answer: answer, P: p}
|
||||
if o.Stats, o.ended, err = d.finishStats(ctx, &p); err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
return o, ErrFinished
|
||||
}
|
||||
g, err := d.cfg.Rules.Judge(input, answer, p.Guesses)
|
||||
if err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
o := Outcome{Num: num, Answer: answer, guessed: true}
|
||||
p.Name = c.Name
|
||||
p.Guesses = append(p.Guesses, g)
|
||||
o.joined = d.joinLocally(&p, c)
|
||||
if g.Word == answer || len(p.Guesses) >= d.MaxGuesses() {
|
||||
p.Status, p.FinishedAt, o.ended = StatusLost, now.UnixMilli(), true
|
||||
if g.Word == answer {
|
||||
p.Status = StatusWon
|
||||
o.report = needsReport(p, c)
|
||||
}
|
||||
}
|
||||
if err := d.Plays.Put(ctx, PlayKey(num, c.UserID), p); err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
if o.ended {
|
||||
// The board is saved first and is the record. If this stats write
|
||||
// fails, the next state load or guess folds the finished board in;
|
||||
// the LastNum guard keeps it from counting twice.
|
||||
if o.Stats, _, err = d.finishStats(ctx, &p); err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
}
|
||||
o.P = p
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// LoadState returns today's game for the token's player. Opening a card
|
||||
// also puts a player who already guessed into that card's group results,
|
||||
// and reports a win on a card it was not reported on yet.
|
||||
func (d *Daily) LoadState(ctx context.Context, c Claims) (Outcome, error) {
|
||||
today := d.PuzzleNum(d.cfg.Now())
|
||||
answer, err := d.ResolvePuzzle(ctx, today)
|
||||
if err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
o, err := d.stateLocked(ctx, c, today, answer)
|
||||
if err != nil {
|
||||
return o, err
|
||||
}
|
||||
d.afterPlay(c, o)
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func (d *Daily) stateLocked(ctx context.Context, c Claims, num int, answer string) (Outcome, error) {
|
||||
defer d.Locks.Acquire(userLockKey(c.UserID))()
|
||||
p, err := d.LoadProgress(ctx, num, c.UserID)
|
||||
if err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
o := Outcome{Num: num, Answer: answer}
|
||||
if len(p.Guesses) > 0 {
|
||||
o.joined = d.joinLocally(&p, c)
|
||||
}
|
||||
if p.Status == StatusWon {
|
||||
o.report = needsReport(p, c)
|
||||
}
|
||||
if o.joined {
|
||||
if c.Name != "" {
|
||||
p.Name = c.Name
|
||||
}
|
||||
if err := d.Plays.Put(ctx, PlayKey(num, c.UserID), p); err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
}
|
||||
if p.Finished() {
|
||||
// Folds in a finished board whose stats write failed; a no-op
|
||||
// otherwise.
|
||||
if o.Stats, o.ended, err = d.finishStats(ctx, &p); err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
}
|
||||
o.P = p
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// joinLocally adds the card's group topic to the player's chats. Only group
|
||||
// chats (negative IDs) have a results message; a private chat or an inline
|
||||
// card has none.
|
||||
func (d *Daily) joinLocally(p *Progress, c Claims) bool {
|
||||
if c.InlineID != "" || c.ChatID >= 0 || p.joinedChat(c.ChatID, c.ThreadID) || len(p.Chats) >= maxChats {
|
||||
return false
|
||||
}
|
||||
p.Chats = append(p.Chats, ChatRef{ChatID: c.ChatID, ThreadID: c.ThreadID, CardID: c.MessageID})
|
||||
return true
|
||||
}
|
||||
|
||||
// needsReport reports whether the win still has to be reported on the
|
||||
// token's card. A card is recorded in Reported only once Telegram accepted
|
||||
// the score, so a failed report is retried on the next state load.
|
||||
func needsReport(p Progress, c Claims) bool {
|
||||
return !slices.Contains(p.Reported, cardKeyOf(c)) && len(p.Reported) < maxReported
|
||||
}
|
||||
|
||||
// recordReported notes that the win of puzzle num is on the token's card.
|
||||
func (d *Daily) recordReported(ctx context.Context, c Claims, num int) error {
|
||||
defer d.Locks.Acquire(userLockKey(c.UserID))()
|
||||
p, err := d.LoadProgress(ctx, num, c.UserID)
|
||||
if err != nil || p.Status != StatusWon || !needsReport(p, c) {
|
||||
return err
|
||||
}
|
||||
p.Reported = append(p.Reported, cardKeyOf(c))
|
||||
return d.Plays.Put(ctx, PlayKey(num, c.UserID), p)
|
||||
}
|
||||
|
||||
// finishStats folds the finished game into the player's stats, once per
|
||||
// puzzle, and reports whether this call folded it. Caller holds the
|
||||
// player's lock.
|
||||
func (d *Daily) finishStats(ctx context.Context, p *Progress) (UserStats, bool, error) {
|
||||
st, err := d.LoadStats(ctx, p.UserID)
|
||||
if err != nil {
|
||||
return UserStats{}, false, err
|
||||
}
|
||||
if st.LastNum >= p.Num {
|
||||
return st, false, nil
|
||||
}
|
||||
st.Played++
|
||||
if p.Status == StatusWon {
|
||||
st.Wins++
|
||||
st.Dist[len(p.Guesses)-1]++
|
||||
if st.LastWinNum == p.Num-1 {
|
||||
st.CurStreak++
|
||||
} else {
|
||||
st.CurStreak = 1
|
||||
}
|
||||
st.MaxStreak = max(st.MaxStreak, st.CurStreak)
|
||||
st.LastWinNum = p.Num
|
||||
} else {
|
||||
st.CurStreak = 0
|
||||
}
|
||||
st.LastNum = p.Num
|
||||
if err := d.Stats.Put(ctx, d.StatsKey(p.UserID), st); err != nil {
|
||||
return UserStats{}, false, err
|
||||
}
|
||||
return st, true, nil
|
||||
}
|
||||
|
||||
// afterPlay runs a request's side effects once the player's lock is
|
||||
// released: the group results bookkeeping, the live summary and the score
|
||||
// report. Failures are logged; the player's game is already saved.
|
||||
func (d *Daily) afterPlay(c Claims, o Outcome) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
|
||||
defer cancel()
|
||||
if o.joined {
|
||||
ref := o.P.Chats[len(o.P.Chats)-1]
|
||||
if err := d.addChatPlayer(ctx, o.Num, ref, c.UserID, o.P.Name); err != nil {
|
||||
log.Warn(d.cfg.LogName+" chat join failed", "err", err)
|
||||
}
|
||||
if o.P.Status == StatusWon && !o.ended {
|
||||
d.bumpStreak(ctx, o.Num, ref)
|
||||
}
|
||||
}
|
||||
if o.ended && o.P.Status == StatusWon {
|
||||
for _, ref := range o.P.Chats {
|
||||
d.bumpStreak(ctx, o.Num, ref)
|
||||
}
|
||||
}
|
||||
if o.guessed || o.joined || o.ended {
|
||||
for _, ref := range o.P.Chats {
|
||||
d.TouchSummary(o.Num, ref.ChatID, ref.ThreadID)
|
||||
}
|
||||
}
|
||||
if o.report {
|
||||
d.reportWin(c, o.Num, len(o.P.Guesses))
|
||||
}
|
||||
}
|
||||
|
||||
// reportWin sets the score on the card in the background, then records the
|
||||
// card as reported. BOT_SCORE_NOT_MODIFIED (the score does not beat the
|
||||
// player's best there) counts as reported; any other failure leaves the
|
||||
// card unrecorded, so the next state load from it tries again.
|
||||
func (d *Daily) reportWin(c Claims, num, guesses int) {
|
||||
if d.cfg.Reporter == nil {
|
||||
return
|
||||
}
|
||||
addr := htmlgame.Address{ChatID: c.ChatID, MessageID: c.MessageID, InlineID: c.InlineID}
|
||||
d.async(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
|
||||
defer cancel()
|
||||
err := d.cfg.Reporter.Report(ctx, addr, c.UserID, d.Score(guesses))
|
||||
if err != nil && !htmlgame.ScoreNotModified(err) {
|
||||
log.Warn(d.cfg.LogName+" score report failed", "err", err)
|
||||
return
|
||||
}
|
||||
if err := d.recordReported(ctx, c, num); err != nil {
|
||||
log.Warn(d.cfg.LogName+" score report record failed", "err", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// addChatPlayer lists the player in the chat topic's day.
|
||||
func (d *Daily) addChatPlayer(ctx context.Context, num int, ref ChatRef, userID int64, name string) error {
|
||||
return updateVersioned(ctx, d.ChatDays, ChatDayKey(num, ref.ChatID, ref.ThreadID), func(cd *ChatDay, found bool) bool {
|
||||
changed := !found
|
||||
if !found {
|
||||
*cd = ChatDay{Num: num, ChatID: ref.ChatID, ThreadID: ref.ThreadID, Players: []ChatPlayer{}}
|
||||
}
|
||||
if cd.CardID == 0 && ref.CardID != 0 {
|
||||
cd.CardID, changed = ref.CardID, true
|
||||
}
|
||||
if !slices.ContainsFunc(cd.Players, func(p ChatPlayer) bool { return p.UserID == userID }) && len(cd.Players) < maxChatPlayers {
|
||||
cd.Players, changed = append(cd.Players, ChatPlayer{UserID: userID, Name: name}), true
|
||||
}
|
||||
return changed
|
||||
})
|
||||
}
|
||||
|
||||
// bumpStreak counts puzzle num toward the chat topic's streak, once.
|
||||
func (d *Daily) bumpStreak(ctx context.Context, num int, ref ChatRef) {
|
||||
err := updateVersioned(ctx, d.Streaks, ChatStreakKey(ref.ChatID, ref.ThreadID), func(st *ChatStreak, _ bool) bool {
|
||||
switch {
|
||||
case st.LastNum >= num:
|
||||
return false
|
||||
case st.LastNum == num-1:
|
||||
st.Streak++
|
||||
default:
|
||||
st.Streak = 1
|
||||
}
|
||||
st.LastNum = num
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn(d.cfg.LogName+" group streak update failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// DisplayStreak is the current streak as shown on puzzle today: a streak
|
||||
// whose last win is older than yesterday is over.
|
||||
func DisplayStreak(st UserStats, today int) int {
|
||||
if st.LastWinNum < today-1 {
|
||||
return 0
|
||||
}
|
||||
return st.CurStreak
|
||||
}
|
||||
|
||||
// ResultLine is "3/6" for a win, "X/6" for a loss.
|
||||
func (d *Daily) ResultLine(p Progress) string {
|
||||
if p.Status == StatusWon {
|
||||
return strconv.Itoa(len(p.Guesses)) + "/" + strconv.Itoa(d.MaxGuesses())
|
||||
}
|
||||
return "X/" + strconv.Itoa(d.MaxGuesses())
|
||||
}
|
||||
|
||||
// ShareText is the NYT-style spoiler-free result of a finished game.
|
||||
func (d *Daily) ShareText(p Progress) string {
|
||||
text := d.cfg.Rules.Label() + " #" + strconv.Itoa(p.Num) + " " + d.ResultLine(p) + "\n"
|
||||
for _, g := range p.Guesses {
|
||||
text += "\n" + EmojiRow(d.cfg.Rules, g.Marks)
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user