diff --git a/README.md b/README.md index 1228e54..54abfcf 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Atlas via long polling and an in-process cron scheduler. | `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` | -| `noitu` | Nối từ HTML5 game vs the bot (`/noitu`), or against the other members of a group (`/noitupvp`: everyone who presses Play on that card joins one room, last player standing wins); 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) | +| `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`); `/noitutop` shows the group's leaderboard of room games (wins, best game, games played). Needs `GAME_BASE_URL`. Play opens a page this bot serves; words are checked against a Vietnamese dictionary derived from [Wiktionary tiếng Việt](https://vi.wiktionary.org/) (CC BY-SA 4.0, see [its attribution](internal/modules/noitu/dict/data/ATTRIBUTION.md)), and scores go to Telegram's high-score table. See [docs/noitu-game.md](docs/noitu-game.md) | | `loldle` | League-of-Legends "guess the champion": `/loldle [champion]`, `/loldle_giveup`, `/loldle_stats`, `/loldle_setmax` (owner) | | `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` | diff --git a/cmd/server/command_menu_test.go b/cmd/server/command_menu_test.go index 185714f..9270173 100644 --- a/cmd/server/command_menu_test.go +++ b/cmd/server/command_menu_test.go @@ -74,7 +74,8 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) { "monkeyd_crawl": " [font_size]", "monkeyd_tags": "", "noitu": "", - "noitupvp": "", + "noitubot": "", + "noitutop": "", "random": "", "addsticker": "[emoji...]", "alias": "", diff --git a/cmd/server/main.go b/cmd/server/main.go index 50a0dc2..cc86172 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -44,6 +44,7 @@ import ( "github.com/tiennm99/tiennm99bot/internal/modules/wordle" "github.com/tiennm99/tiennm99bot/internal/server" "github.com/tiennm99/tiennm99bot/internal/storage" + "github.com/tiennm99/tiennm99bot/internal/systemstate" "github.com/tiennm99/tiennm99bot/internal/telegram" ) @@ -143,7 +144,7 @@ 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 { diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index b1f09dd..8560ef2 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -100,7 +100,7 @@ func TestFactoriesRegistersBlacklistCommands(t *testing.T) { } } -// noitu is in the catalog and, with GAME_BASE_URL unset, registers its command +// 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", "") @@ -109,8 +109,13 @@ func TestFactoriesRegistersNoituDisabledByDefault(t *testing.T) { if err != nil { t.Fatalf("Build noitu: %v", err) } - if _, ok := reg.AllCommands["noitu"]; !ok { - t.Fatal("missing command noitu") + 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) diff --git a/docs/noitu-game.md b/docs/noitu-game.md index c0f9acf..25d44ef 100644 --- a/docs/noitu-game.md +++ b/docs/noitu-game.md @@ -1,13 +1,22 @@ # Nối từ game -`noitu` is a Telegram HTML5 game: Vietnamese word chaining ("nối từ") against -the bot. `/noitu` sends the game into the chat. Pressing **Play** opens a page -that this bot serves itself, where the player chains words against the bot. -The final score goes to Telegram's in-chat high-score table. +`noitu` is a Telegram HTML5 game: Vietnamese word chaining ("nối từ"). It has +three commands: -`/noitupvp` sends the same game as a room card: the members of a group who -press Play on it play each other instead of the bot. See -[Playing together](#playing-together-noitupvp). +- `/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 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}`, @@ -31,7 +40,7 @@ and its dictionary is embedded in the binary. With `GAME_BASE_URL` unset or invalid, the game is disabled: -- `/noitu` still sends the game. +- `/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. @@ -41,7 +50,7 @@ also disables the game. So is a `NOITU_GAME_SECRET` shorter than 32 bytes. ## How a game flows -1. `/noitu` calls `sendGame` with no keyboard, so Telegram adds the Play +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. @@ -198,10 +207,11 @@ Errors are `{"error","message"}`: - API responses are `no-store`. - Framing is not restricted, because Telegram Web shows games in an iframe. -## Playing together (`/noitupvp`) +## Playing together (`/noitu`) -`/noitupvp` works in groups and supergroups only; in a private chat or a -channel it answers "Chơi nối từ cùng nhau cần một nhóm…". It sends the same +`/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 same BotFather game, `noitu`, and records the sent message as a **card** in the module's storage collection (`noitu`, key `pvp::`, with the forum topic). If the card cannot be recorded, the bot deletes it again and @@ -266,7 +276,7 @@ per-word points are the same as against the bot (see [Rules](#rules) and - 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 and no announcement. + 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: thắng sau 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` @@ -328,6 +338,30 @@ 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::`: + +- 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: diff --git a/internal/modules/noitu/callback.go b/internal/modules/noitu/callback.go index 4293014..087d97b 100644 --- a/internal/modules/noitu/callback.go +++ b/internal/modules/noitu/callback.go @@ -12,11 +12,11 @@ import ( 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 /noitu để chơi." + 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 /noitupvp card +// 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 { diff --git a/internal/modules/noitu/command.go b/internal/modules/noitu/command.go index fd818b8..9f77b5f 100644 --- a/internal/modules/noitu/command.go +++ b/internal/modules/noitu/command.go @@ -10,11 +10,14 @@ import ( ) const ( - msgChannel = "Không thể chơi nối từ trong kênh. Hãy dùng /noitu trong nhóm hoặc chat riêng với bot." + // soloCommand sends the game played against the bot. + soloCommand = "noitubot" + + 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 sends the BotFather game. No reply markup: Telegram then adds +// handleCommand (/noitubot) sends the 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 { diff --git a/internal/modules/noitu/helpers_test.go b/internal/modules/noitu/helpers_test.go index 2d6e1ff..ba8924e 100644 --- a/internal/modules/noitu/helpers_test.go +++ b/internal/modules/noitu/helpers_test.go @@ -125,6 +125,7 @@ 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, @@ -132,7 +133,8 @@ func newHarnessWithStore(t *testing.T, store *dict.Store) *harness { newRNG: func() *rand.Rand { seed++; return rand.New(rand.NewPCG(seed, 42)) }, reporter: h.reporter, announcer: h.reporter, - cards: storage.Typed[pvpCard](storage.NewMemoryProvider().Collection("noitu")), + cards: storage.Typed[pvpCard](coll), + top: storage.Typed[topEntry](coll), loadDict: func() (*dict.Store, error) { return store, nil }, } h.svc = newService(cfg) diff --git a/internal/modules/noitu/leaderboard.go b/internal/modules/noitu/leaderboard.go new file mode 100644 index 0000000..0b24014 --- /dev/null +++ b/internal/modules/noitu/leaderboard.go @@ -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ảng xếp hạng nối từ (%d người chơi)\n%s", len(entries), table) +} diff --git a/internal/modules/noitu/leaderboard_test.go b/internal/modules/noitu/leaderboard_test.go new file mode 100644 index 0000000..917eb9e --- /dev/null +++ b/internal/modules/noitu/leaderboard_test.go @@ -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 = "Tí & Tèo" + } + 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ảng xếp hạng nối từ (12 người chơi)\n
") {
+		t.Fatalf("header: %q", text)
+	}
+	if strings.Contains(text, "Tí") || !strings.Contains(text, "<b>Tí & Tèo</b>") {
+		t.Fatalf("name not escaped: %q", text)
+	}
+	lines := strings.Split(strings.TrimSuffix(text, "
"), "\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) + } + } +} diff --git a/internal/modules/noitu/noitu.go b/internal/modules/noitu/noitu.go index ad359e7..5f9c55b 100644 --- a/internal/modules/noitu/noitu.go +++ b/internal/modules/noitu/noitu.go @@ -1,8 +1,9 @@ -// Package noitu is the "nối từ" Telegram HTML5 game: the /noitu command sends -// the BotFather game, the Play button opens a page this bot serves, and the -// player chains Vietnamese words against the bot opponent there. /noitupvp -// sends a card whose Play button opens a room where the chat's members chain -// words against each other. +// Package noitu is the "nối từ" Telegram HTML5 game: the /noitubot command +// sends the BotFather game, the Play button opens a page this bot serves, and +// the player chains Vietnamese words against the bot opponent there. /noitu +// sends a card to a group 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 @@ -52,12 +53,14 @@ type config struct { 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 still sends the game and Play explains it is not configured. +// /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)), @@ -72,6 +75,7 @@ func New(deps modules.Deps) modules.Module { } if deps.Store != nil { cfg.cards = storage.Typed[pvpCard](deps.Store) + cfg.top = storage.Typed[topEntry](deps.Store) } return newWithConfig(cfg) } @@ -98,19 +102,24 @@ func newService(cfg config) *service { // 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 -// /noitupvp registers cards even while the game is disabled. +// /noitu registers cards even while the game is disabled. func (svc *service) module() modules.Module { mod := modules.Module{ Commands: []modules.Command{{ - Name: ShortName, + 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: pvpCommand, + Name: topCommand, Visibility: modules.VisibilityPublic, - Description: "Chơi nối từ với các thành viên trong nhóm", - Handler: svc.handlePvPCommand, + Description: "Bảng xếp hạng nối từ của nhóm", + Handler: svc.handleTopCommand, }}, Games: []modules.Game{{ ShortName: ShortName, diff --git a/internal/modules/noitu/noitu_test.go b/internal/modules/noitu/noitu_test.go index d747e14..d584ba6 100644 --- a/internal/modules/noitu/noitu_test.go +++ b/internal/modules/noitu/noitu_test.go @@ -109,19 +109,22 @@ func TestNew_DisabledWithoutBaseURL(t *testing.T) { if len(mod.HTTP) != 0 || len(mod.Crons) != 0 { t.Fatalf("disabled game exposes routes %v / crons %v", mod.HTTP, mod.Crons) } - if len(mod.Commands) != 2 || mod.Commands[0].Name != "noitu" || mod.Commands[1].Name != "noitupvp" || - mod.Commands[0].Parameters != "" || mod.Commands[1].Parameters != "" { - t.Fatalf("commands = %+v", mod.Commands) + 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 == "" { + if c.Visibility != modules.VisibilityPublic || c.Description == "" || c.Parameters != "" { t.Fatalf("command %+v is not a described public command", c) } } if len(mod.Games) != 1 || mod.Games[0].ShortName != "noitu" { t.Fatalf("games = %+v", mod.Games) } - // /noitupvp registers cards even while the game is disabled, so their + // /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" { @@ -233,7 +236,7 @@ func TestPlay_InaccessibleMessageAndNoTarget(t *testing.T) { func TestCommand_SendsGameKeepingTopic(t *testing.T) { rb := installModule(t, newWithConfig(config{now: time.Now})) - update := testutil.NewSupergroupMessage(-100777, 42, "/noitu") + update := testutil.NewSupergroupMessage(-100777, 42, "/noitubot") update.Message.MessageThreadID = 12 rb.Bot.ProcessUpdate(context.Background(), update) last := rb.LastSent() @@ -247,14 +250,14 @@ func TestCommand_SendsGameKeepingTopic(t *testing.T) { func TestCommand_ChannelAndFailure(t *testing.T) { rb := installModule(t, newWithConfig(config{now: time.Now})) - rb.Bot.ProcessUpdate(context.Background(), testutil.NewChannelMessage(-100888, "/noitu")) + 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, "/noitu")) + 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()) } diff --git a/internal/modules/noitu/pvp_card.go b/internal/modules/noitu/pvp_card.go index 865e227..89c04f4 100644 --- a/internal/modules/noitu/pvp_card.go +++ b/internal/modules/noitu/pvp_card.go @@ -16,8 +16,9 @@ import ( ) const ( - // pvpCommand sends a game card that the chat's members play together. - pvpCommand = "noitupvp" + // 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; @@ -29,12 +30,12 @@ const ( // cardCleanupSchedule runs at 03:30 ICT. cardCleanupSchedule = "30 20 * * *" - msgPvPNeedsGroup = "Chơi nối từ cùng nhau cần một nhóm. Hãy dùng /noitupvp trong nhóm, hoặc /noitu để chơi với bot." + 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 /noitupvp sent. A Play press on it opens the +// 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 { @@ -49,8 +50,9 @@ func cardKey(chatID int64, messageID int) string { } // handlePvPCommand sends the game and registers it as a room card. Like -// /noitu 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. +// /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 { diff --git a/internal/modules/noitu/pvp_card_test.go b/internal/modules/noitu/pvp_card_test.go index b62e860..43e845a 100644 --- a/internal/modules/noitu/pvp_card_test.go +++ b/internal/modules/noitu/pvp_card_test.go @@ -19,7 +19,7 @@ import ( func TestPvPCommand_RegistersCardKeepingTopic(t *testing.T) { h := newHarness(t, testCorpus) rb := installModule(t, h.mod) - update := testutil.NewSupergroupMessage(-100777, 42, "/noitupvp") + update := testutil.NewSupergroupMessage(-100777, 42, "/noitu") update.Message.MessageThreadID = 12 rb.Bot.ProcessUpdate(context.Background(), update) @@ -40,8 +40,8 @@ func TestPvPCommand_RegistersCardKeepingTopic(t *testing.T) { func TestPvPCommand_RefusesPrivateChatsAndChannels(t *testing.T) { h := newHarness(t, testCorpus) for name, update := range map[string]*models.Update{ - "private": testutil.NewPrivateMessage(42, "/noitupvp"), - "channel": testutil.NewChannelMessage(-100888, "/noitupvp"), + "private": testutil.NewPrivateMessage(42, "/noitu"), + "channel": testutil.NewChannelMessage(-100888, "/noitu"), } { rb := installModule(t, h.mod) rb.Bot.ProcessUpdate(context.Background(), update) @@ -57,7 +57,7 @@ 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, "/noitupvp")) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewGroupMessage(-100777, 42, "/noitu")) var methods []string for _, c := range rb.Sent() { methods = append(methods, c.Method) @@ -68,7 +68,7 @@ func TestPvPCommand_StoreFailureTakesTheCardBack(t *testing.T) { rb = installModule(t, h.svc.module()) rb.FailMethodCode("sendGame", 400, "Bad Request: GAME_SHORTNAME_INVALID") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewGroupMessage(-100777, 42, "/noitupvp")) + 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()) } diff --git a/internal/modules/noitu/rooms.go b/internal/modules/noitu/rooms.go index bb23639..99a3966 100644 --- a/internal/modules/noitu/rooms.go +++ b/internal/modules/noitu/rooms.go @@ -62,6 +62,7 @@ type roomStanding struct { Words int `json:"words"` OutReason string `json:"out_reason"` You bool `json:"you"` + won bool } // roomReport is one score to report on the card. @@ -80,7 +81,7 @@ type roomResult struct { ScoreReported string `json:"score_reported"` } -// room is the live game of one /noitupvp card. Its mutex guards every field +// 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 @@ -384,6 +385,7 @@ func (s *service) endRoomGame(r *room, now time.Time) { 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 { @@ -410,12 +412,14 @@ func (s *service) endRoomGame(r *room, now time.Time) { go s.publishRoomResult(r, res, text, reports, res.ScoreReported == reportPending) //nolint:gosec // G118: the report must outlive the request that ended the game } -// publishRoomResult 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. Neither step is fatal to -// the room. +// 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 { diff --git a/internal/modules/noitu/token.go b/internal/modules/noitu/token.go index a7926eb..d8738e6 100644 --- a/internal/modules/noitu/token.go +++ b/internal/modules/noitu/token.go @@ -27,7 +27,7 @@ var ( // 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 /noitupvp registered: the page then joins +// 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 { diff --git a/internal/modules/noitu/web/app.js b/internal/modules/noitu/web/app.js index 608bd5a..046c3ee 100644 --- a/internal/modules/noitu/web/app.js +++ b/internal/modules/noitu/web/app.js @@ -63,7 +63,7 @@ var TOKEN_KEY = 'noitu-token'; var token = readToken(); - /** A token from a /noitupvp card opens the card's room instead of a game vs the bot. */ + /** A token from a /noitu card opens the card's room instead of a game vs the bot. */ var roomMode = isRoomToken(token); /** @@ -452,7 +452,7 @@ if (view) api('give-up', { session: view.session }).then(render, handleError); } - // Room mode: the members of a chat play each other on one /noitupvp card. + // 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')); diff --git a/internal/modules/noitu/web/index.html b/internal/modules/noitu/web/index.html index 298711d..ff28c28 100644 --- a/internal/modules/noitu/web/index.html +++ b/internal/modules/noitu/web/index.html @@ -17,7 +17,7 @@