feat(noitu): make /noitu the group game and add /noitutop

/noitu now posts the group play card that /noitupvp used to, and the solo
game against the bot moves to /noitubot. In a private chat /noitu points
the user to a group, or to /noitubot.

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

A one-time startup migration moves /stats history to the new names in
order: noitu to noitubot first, then noitupvp to noitu.
This commit is contained in:
tiennm99 committed 2026-10-09 16:22:54 +07:00
1 parent b8565164b3
commit d84bc3d1fb
21 files changed
+778 -74

No files matched your search

+1 -1
View File
@@ -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` |
+2 -1
View File
@@ -74,7 +74,8 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
"monkeyd_crawl": "<url> [font_size]",
"monkeyd_tags": "<url>",
"noitu": "",
"noitupvp": "",
"noitubot": "",
"noitutop": "",
"random": "<option,...>",
"addsticker": "[emoji...]",
"alias": "<name>",
+2 -1
View File
@@ -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 {
+8 -3
View File
@@ -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)
+47 -13
View File
@@ -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:<chat_id>:<message_id>`, 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: <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`
@@ -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:<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:
+2 -2
View File
@@ -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 {
+5 -2
View File
@@ -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 {
+3 -1
View File
@@ -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)
+168
View File
@@ -0,0 +1,168 @@
package noitu
import (
"cmp"
"context"
"errors"
"fmt"
"slices"
"strconv"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/tiennm99bot/internal/log"
"github.com/tiennm99/tiennm99bot/internal/modules/util/chathelper"
"github.com/tiennm99/tiennm99bot/internal/storage"
)
const (
// topCommand shows a group's leaderboard across its /noitu room games.
topCommand = "noitutop"
topKeyPrefix = "top:"
// topShown is how many players the leaderboard lists; the caller's own
// line is added below when they rank lower.
topShown = 10
// topWriteRetries bounds the versioned read-modify-write of one entry.
// Two cards of one chat can end games with the same player at once.
topWriteRetries = 5
msgTopNeedsGroup = "Bảng xếp hạng nối từ chỉ có trong nhóm. Gửi /noitutop trong nhóm nhé."
msgTopEmpty = "Nhóm này chưa có ván nối từ nào. Gửi /noitu để chơi cùng nhau."
msgTopFail = "Không đọc được bảng xếp hạng nối từ. Thử lại sau nhé."
)
// topEntry is one player's record across every room game of one chat.
type topEntry struct {
ChatID int64 `bson:"chatId"`
UserID int64 `bson:"userId"`
Name string `bson:"name"` // the player's latest first name
Games int64 `bson:"games"` // games played, as a seated player
Wins int64 `bson:"wins"`
Best int64 `bson:"best"` // best single-game score, winner bonus included
Total int64 `bson:"total"` // sum of every game's score
}
// topChatPrefix scopes a scan to one chat. The trailing colon keeps chat -1
// from matching chat -100.
func topChatPrefix(chatID int64) string {
return topKeyPrefix + strconv.FormatInt(chatID, 10) + ":"
}
func topKey(chatID, userID int64) string {
return topChatPrefix(chatID) + strconv.FormatInt(userID, 10)
}
// recordTop adds a finished room game to the chat's leaderboard. It runs in
// the background publisher, never under a room's lock; a failure is logged
// and skips only that player.
func (s *service) recordTop(chatID int64, standings []roomStanding) {
if s.cfg.top == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), reportTimeout)
defer cancel()
for _, st := range standings {
if err := s.addTopGame(ctx, chatID, st); err != nil {
log.Warn("noitu leaderboard update failed", "err", err)
}
}
}
// addTopGame folds one player's game into their entry with a versioned write,
// retrying when another game updated the entry in between.
func (s *service) addTopGame(ctx context.Context, chatID int64, st roomStanding) error {
key := topKey(chatID, st.userID)
for range topWriteRetries {
entry, version, err := s.cfg.top.Get(ctx, key)
switch {
case errors.Is(err, storage.ErrNotFound):
entry, version = topEntry{ChatID: chatID, UserID: st.userID}, 0
case err != nil:
return err
}
entry.Name = st.Name
entry.Games++
if st.won {
entry.Wins++
}
score := int64(st.Score)
entry.Best = max(entry.Best, score)
entry.Total += score
err = s.cfg.top.PutVersioned(ctx, key, version, entry)
if !errors.Is(err, storage.ErrConflict) {
return err
}
}
return storage.ErrConflict
}
// sortTop orders the leaderboard: more wins first, then the higher best
// game, then fewer games (the same record in fewer games ranks higher), then
// more total points, then the user ID so the order is stable.
func sortTop(entries []topEntry) {
slices.SortFunc(entries, func(a, b topEntry) int {
return cmp.Or(
cmp.Compare(b.Wins, a.Wins),
cmp.Compare(b.Best, a.Best),
cmp.Compare(a.Games, b.Games),
cmp.Compare(b.Total, a.Total),
cmp.Compare(a.UserID, b.UserID),
)
})
}
// handleTopCommand shows the chat's leaderboard. Rooms exist only in groups,
// so other chats are told where it works.
func (s *service) handleTopCommand(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg.Chat.Type != models.ChatTypeGroup && msg.Chat.Type != models.ChatTypeSupergroup {
return chathelper.Reply(ctx, b, msg, msgTopNeedsGroup)
}
if s.cfg.top == nil {
_ = chathelper.Reply(ctx, b, msg, msgTopFail)
return errors.New("noitu: no storage for the leaderboard")
}
docs, err := s.cfg.top.Scan(ctx, topChatPrefix(msg.Chat.ID))
if err != nil {
_ = chathelper.Reply(ctx, b, msg, msgTopFail)
return err
}
if len(docs) == 0 {
return chathelper.Reply(ctx, b, msg, msgTopEmpty)
}
entries := make([]topEntry, len(docs))
for i, d := range docs {
entries[i] = d.Val
}
var caller int64
if msg.From != nil {
caller = msg.From.ID
}
return chathelper.ReplyHTML(ctx, b, msg, renderTop(entries, caller))
}
// renderTop renders the top entries as an HTML table, plus the caller's own
// line when they rank below it. MonospaceTable escapes every cell.
func renderTop(entries []topEntry, caller int64) string {
sortTop(entries)
row := func(rank int, e topEntry) []string {
return []string{
strconv.Itoa(rank), e.Name,
strconv.FormatInt(e.Wins, 10), strconv.FormatInt(e.Games, 10),
strconv.FormatInt(e.Best, 10), strconv.FormatInt(e.Total, 10),
}
}
var rows [][]string
for i, e := range entries {
switch {
case i < topShown:
rows = append(rows, row(i+1, e))
case caller != 0 && e.UserID == caller:
rows = append(rows, []string{"…"}, row(i+1, e))
}
}
table := chathelper.MonospaceTable([]string{"#", "Tên", "Thắng", "Ván", "Cao nhất", "Tổng"}, rows)
return fmt.Sprintf("<b>Bảng xếp hạng nối từ</b> (%d người chơi)\n%s", len(entries), table)
}
+210
View File
@@ -0,0 +1,210 @@
package noitu
import (
"context"
"fmt"
"net/http"
"strings"
"sync"
"testing"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/tiennm99bot/internal/storage"
"github.com/tiennm99/tiennm99bot/internal/testutil"
)
// topOf reads one player's leaderboard entry on the test card's chat.
func (h *harness) topOf(user int64) topEntry {
h.t.Helper()
e, _, err := h.svc.cfg.top.Get(context.Background(), topKey(cardChat, user))
if err != nil {
h.t.Fatalf("leaderboard entry of %d: %v", user, err)
}
return e
}
func TestLeaderboard_RecordsEveryRoomGameThatHadAWord(t *testing.T) {
h := newHarness(t, testCorpus)
a := h.join(1, "An")
b := h.join(2, "Bình")
// Game 1: An leaves Bình a dead end and wins.
h.roomStart(a.Member, http.StatusOK)
h.roomMove(a.Member, "hồng tâm")
h.roomMove(b.Member, "tâm sự")
h.roomMove(a.Member, "sự cố")
h.call("room/give-up", map[string]string{"member": b.Member}, http.StatusOK, nil)
first := h.waitRoomReport(a.Member).Result.Standings
aFirst, bFirst := int64(first[0].Score), int64(first[1].Score)
if got, want := h.topOf(1), (topEntry{ChatID: cardChat, UserID: 1, Name: "An", Games: 1, Wins: 1, Best: aFirst, Total: aFirst}); got != want {
t.Fatalf("An after game 1 = %+v, want %+v", got, want)
}
if got, want := h.topOf(2), (topEntry{ChatID: cardChat, UserID: 2, Name: "Bình", Games: 1, Best: bFirst, Total: bFirst}); got != want {
t.Fatalf("Bình after game 1 = %+v, want %+v", got, want)
}
// Game 2: nobody plays a word, so it does not count.
h.clock.advance(roomStartInterval)
h.roomStart(a.Member, http.StatusOK)
h.call("room/give-up", map[string]string{"member": a.Member}, http.StatusOK, nil)
if res := h.roomState(a.Member).Result; res == nil || res.Words != 0 {
t.Fatalf("game 2 result = %+v", res)
}
// Game 3: An plays a word then leaves; Bình wins, and takes a new name.
b = h.join(2, "Bé Bình")
h.clock.advance(roomStartInterval)
h.roomStart(a.Member, http.StatusOK)
h.roomMove(a.Member, "hồng tâm")
h.call("room/give-up", map[string]string{"member": a.Member}, http.StatusOK, nil)
third := h.waitRoomReport(b.Member).Result.Standings
bThird, aThird := int64(third[0].Score), int64(third[1].Score)
if got, want := h.topOf(1), (topEntry{ChatID: cardChat, UserID: 1, Name: "An", Games: 2, Wins: 1, Best: max(aFirst, aThird), Total: aFirst + aThird}); got != want {
t.Fatalf("An after game 3 = %+v, want %+v", got, want)
}
if got, want := h.topOf(2), (topEntry{ChatID: cardChat, UserID: 2, Name: "Bé Bình", Games: 2, Wins: 1, Best: max(bFirst, bThird), Total: bFirst + bThird}); got != want {
t.Fatalf("Bình after game 3 = %+v, want %+v", got, want)
}
}
func TestLeaderboard_ZeroWordGameRecordsNothing(t *testing.T) {
h := newHarness(t, testCorpus)
a := h.join(1, "An")
h.join(2, "Bình")
h.roomStart(a.Member, http.StatusOK)
h.call("room/give-up", map[string]string{"member": a.Member}, http.StatusOK, nil)
// No publisher runs for such a game, so there is nothing to wait for.
docs, err := h.svc.cfg.top.Scan(context.Background(), topKeyPrefix)
if err != nil || len(docs) != 0 {
t.Fatalf("leaderboard after a zero-word game = %+v, %v", docs, err)
}
}
// Two cards of one chat can finish games with the same player at once; the
// versioned write keeps both.
func TestLeaderboard_ConcurrentGamesKeepEveryUpdate(t *testing.T) {
h := newHarness(t, testCorpus)
var wg sync.WaitGroup
for i := range 2 {
wg.Add(1)
go func() {
defer wg.Done()
h.svc.recordTop(cardChat, []roomStanding{{userID: 1, Name: "An", Score: 10 * (i + 1), won: i == 0}})
}()
}
wg.Wait()
if got := h.topOf(1); got.Games != 2 || got.Wins != 1 || got.Best != 20 || got.Total != 30 {
t.Fatalf("entry = %+v", got)
}
}
func TestLeaderboard_StoreFailureIsNotFatal(t *testing.T) {
h := newHarness(t, testCorpus)
h.svc.cfg.top = storage.Typed[topEntry](storage.NewMemoryProvider().Collection("Not A Valid Name"))
h.svc.recordTop(cardChat, []roomStanding{{userID: 1, Name: "An", Score: 10, won: true}})
rb := installModule(t, h.svc.module())
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGroupMessage(cardChat, 1, "/noitutop"))
if last := rb.LastSent(); last.Method != "sendMessage" || last.Text() != msgTopFail {
t.Fatalf("sent %+v", rb.Sent())
}
}
func TestSortTop_WinsThenBestThenFewerGames(t *testing.T) {
entries := []topEntry{
{UserID: 1, Wins: 1, Best: 90, Games: 3},
{UserID: 2, Wins: 2, Best: 10, Games: 9},
{UserID: 3, Wins: 1, Best: 90, Games: 2},
{UserID: 4, Wins: 1, Best: 95, Games: 9},
{UserID: 5, Wins: 1, Best: 90, Games: 2, Total: 300},
{UserID: 6, Wins: 1, Best: 90, Games: 2},
}
sortTop(entries)
var order []int64
for _, e := range entries {
order = append(order, e.UserID)
}
if fmt.Sprint(order) != "[2 4 5 3 6 1]" {
t.Fatalf("order = %v", order)
}
}
func TestTopCommand_RefusesOutsideGroups(t *testing.T) {
h := newHarness(t, testCorpus)
for name, update := range map[string]*models.Update{
"private": testutil.NewPrivateMessage(42, "/noitutop"),
"channel": testutil.NewChannelMessage(-100888, "/noitutop"),
} {
rb := installModule(t, h.mod)
rb.Bot.ProcessUpdate(context.Background(), update)
if last := rb.LastSent(); last.Method != "sendMessage" || last.Text() != msgTopNeedsGroup || len(rb.Sent()) != 1 {
t.Errorf("%s: sent %+v", name, rb.Sent())
}
}
}
func TestTopCommand_EmptyGroup(t *testing.T) {
h := newHarness(t, testCorpus)
// Another chat's records never show, even one whose ID shares the prefix.
h.svc.recordTop(-1005, []roomStanding{{userID: 1, Name: "An", Score: 10, won: true}})
rb := installModule(t, h.mod)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewGroupMessage(-100, 42, "/noitutop"))
if last := rb.LastSent(); last.Method != "sendMessage" || last.Text() != msgTopEmpty {
t.Fatalf("sent %+v", rb.Sent())
}
}
func TestTopCommand_ShowsTopTenPlusTheCallerEscaped(t *testing.T) {
h := newHarness(t, testCorpus)
ctx := context.Background()
// Player i has i wins, so player 12 ranks first and player 1 last.
for i := int64(1); i <= 12; i++ {
name := fmt.Sprintf("P%02d", i)
if i == 12 {
name = "<b>Tí & Tèo</b>"
}
e := topEntry{ChatID: cardChat, UserID: i, Name: name, Games: 20, Wins: i, Best: 100 + i, Total: 1000 + i}
if err := h.svc.cfg.top.Put(ctx, topKey(cardChat, i), e); err != nil {
t.Fatal(err)
}
}
send := func(caller int64) testutil.SentCall {
t.Helper()
rb := installModule(t, h.mod)
rb.Bot.ProcessUpdate(ctx, testutil.NewSupergroupMessage(cardChat, caller, "/noitutop"))
last := rb.LastSent()
if last.Method != "sendMessage" || last.Form["parse_mode"] != "HTML" {
t.Fatalf("sent %+v", rb.Sent())
}
return last
}
text := send(1).Text()
if !strings.HasPrefix(text, "<b>Bảng xếp hạng nối từ</b> (12 người chơi)\n<pre>") {
t.Fatalf("header: %q", text)
}
if strings.Contains(text, "<b>Tí") || !strings.Contains(text, "&lt;b&gt;Tí &amp; Tèo&lt;/b&gt;") {
t.Fatalf("name not escaped: %q", text)
}
lines := strings.Split(strings.TrimSuffix(text, "</pre>"), "\n")
// Header line, table header, separator, 10 rows, the gap, the caller.
if len(lines) != 15 {
t.Fatalf("%d lines: %q", len(lines), text)
}
if !strings.HasPrefix(lines[3], "1 ") || !strings.Contains(lines[3], "Tí") || !strings.HasPrefix(lines[12], "10") ||
!strings.Contains(lines[12], "P03") || strings.TrimSpace(lines[13]) != "…" ||
!strings.HasPrefix(lines[14], "12") || !strings.Contains(lines[14], "P01") {
t.Fatalf("rows: %q", lines)
}
// A caller inside the top ten, or without a record, gets no extra line.
for _, caller := range []int64{5, 99} {
if text := send(caller).Text(); strings.Count(text, "\n") != 12 || strings.Contains(text, "…") {
t.Fatalf("caller %d: %q", caller, text)
}
}
}
+20 -11
View File
@@ -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,
+11 -8
View File
@@ -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())
}
+8 -6
View File
@@ -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 {
+5 -5
View File
@@ -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())
}
+9 -5
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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'));
+1 -1
View File
@@ -17,7 +17,7 @@
<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>/noitu</code> cho bot rồi bấm nút Chơi. Để chơi cùng bạn bè, gửi <code>/noitupvp</code> trong nhóm.</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>
+107 -6
View File
@@ -2,13 +2,17 @@ package stats
import (
"context"
"errors"
"fmt"
"strings"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"github.com/tiennm99/tiennm99bot/internal/storage"
"github.com/tiennm99/tiennm99bot/internal/systemstate"
)
const (
@@ -17,14 +21,111 @@ const (
statsUsernameLookupIndexName = "stats_user_uid"
)
// InitStore creates the stats collection's MongoDB indexes. Index creation is
// idempotent, so it runs on every boot; the memory backend needs none.
func InitStore(ctx context.Context, statsColl storage.Collection) error {
mongoColl, ok := storage.MongoCollection(statsColl)
if !ok {
// commandRename moves one command's usage history to its new name. Each
// rename has its own completion marker in the system collection.
type commandRename struct {
oldCmd, newCmd, markerKey string
}
// commandRenames run in order, each at most once. The noitu renames swap two
// names, so /noitu (the solo game, now /noitubot) must move out before
// /noitupvp (the group game, now /noitu) moves in; the reverse order would
// merge both histories into noitubot.
var commandRenames = []commandRename{
{oldCmd: "noitu", newCmd: "noitubot", markerKey: "stats:command-rename:noitu-to-noitubot"},
{oldCmd: "noitupvp", newCmd: "noitu", markerKey: "stats:command-rename:noitupvp-to-noitu"},
}
// InitStore performs stats collection startup maintenance. It is safe to call
// every boot: MongoDB indexes are created idempotently, and one-time command
// rename migrations are guarded by markers in the shared system collection.
func InitStore(ctx context.Context, statsColl, systemColl storage.Collection) error {
if mongoColl, ok := storage.MongoCollection(statsColl); ok {
if err := ensureUsageIndexes(ctx, mongoColl); err != nil {
return err
}
}
for _, r := range commandRenames {
if err := migrateCommandRename(ctx, statsColl, systemColl, r); err != nil {
return err
}
}
return nil
}
// migrateCommandRename merges every usage row of r.oldCmd, the anonymous
// total and the per-user rows, into the matching r.newCmd row, deletes the
// old row, and records completion. A completed marker makes it a no-op, so a
// later rename that reuses r.oldCmd as its new name is never moved again.
func migrateCommandRename(ctx context.Context, statsColl, systemColl storage.Collection, r commandRename) error {
state := systemstate.New(systemColl)
if rec, ok, err := state.Get(ctx, r.markerKey); err != nil {
return fmt.Errorf("stats command rename marker %s: %w", r.markerKey, err)
} else if ok && rec.Status == "complete" {
return nil
}
return ensureUsageIndexes(ctx, mongoColl)
docs := storage.Typed[usageEntry](statsColl)
keys, err := docs.List(ctx, r.oldCmd)
if err != nil {
return fmt.Errorf("stats command rename list %s: %w", r.oldCmd, err)
}
var moved int64
for _, key := range keys {
// The key prefix also matches longer command names.
if key != r.oldCmd && !strings.HasPrefix(key, r.oldCmd+":") {
continue
}
entry, _, err := docs.Get(ctx, key)
if err != nil {
return fmt.Errorf("stats command rename get %s: %w", key, err)
}
if entry.Cmd != r.oldCmd {
continue
}
targetKey := usageKey(r.newCmd, entry.UserID)
target, _, err := docs.Get(ctx, targetKey)
missingTarget := errors.Is(err, storage.ErrNotFound)
if err != nil && !missingTarget {
return fmt.Errorf("stats command rename get target %s: %w", targetKey, err)
}
if missingTarget {
target = usageEntry{Deleted: entry.Deleted}
}
target.Cmd = r.newCmd
target.UserID = entry.UserID
if entry.UserID == 0 {
target.Username = ""
} else if entry.Username != "" {
target.Username = entry.Username
}
target.N += entry.N
if !entry.Deleted {
target.Deleted = false
}
if err := docs.Put(ctx, targetKey, target); err != nil {
return fmt.Errorf("stats command rename put target %s: %w", targetKey, err)
}
if err := docs.Delete(ctx, key); err != nil {
return fmt.Errorf("stats command rename delete old %s: %w", key, err)
}
moved += entry.N
}
now := time.Now().UTC().UnixMilli()
if err := state.Put(ctx, r.markerKey, systemstate.Record{
Kind: "migration",
Name: r.markerKey,
Status: "complete",
Count: moved,
CompletedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("stats command rename marker put %s: %w", r.markerKey, err)
}
return nil
}
func ensureUsageIndexes(ctx context.Context, coll *mongo.Collection) error {
+18 -5
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/tiennm99/tiennm99bot/internal/storage"
"github.com/tiennm99/tiennm99bot/internal/systemstate"
"github.com/tiennm99/tiennm99bot/internal/testutil/mongotest"
)
@@ -18,7 +19,9 @@ func TestMain(m *testing.M) {
}
func TestInitStore_MongoCreatesIndexes(t *testing.T) {
ctx, statsColl := setupMongoStatsTest(t)
ctx, provider := setupMongoStatsProvider(t)
statsColl := provider.Collection("stats")
systemColl := provider.Collection(systemstate.CollectionName)
rawStatsColl, ok := storage.MongoCollection(statsColl)
if !ok {
@@ -36,10 +39,10 @@ func TestInitStore_MongoCreatesIndexes(t *testing.T) {
t.Fatalf("seed prefix stats: %v", err)
}
if err := InitStore(ctx, statsColl); err != nil {
if err := InitStore(ctx, statsColl, systemColl); err != nil {
t.Fatalf("InitStore: %v", err)
}
if err := InitStore(ctx, statsColl); err != nil {
if err := InitStore(ctx, statsColl, systemColl); err != nil {
t.Fatalf("InitStore second run: %v", err)
}
@@ -82,6 +85,12 @@ func TestInitStore_MongoCreatesIndexes(t *testing.T) {
func setupMongoStatsTest(t *testing.T) (context.Context, storage.Collection) {
t.Helper()
ctx, provider := setupMongoStatsProvider(t)
return ctx, provider.Collection("stats")
}
func setupMongoStatsProvider(t *testing.T) (context.Context, storage.Provider) {
t.Helper()
uri := mongoTests.URI(t)
@@ -101,11 +110,15 @@ func setupMongoStatsTest(t *testing.T) (context.Context, storage.Collection) {
_ = client.Disconnect(cleanupCtx)
})
provider := storage.NewMongoProvider(db)
return ctx, provider.Collection("stats")
return ctx, storage.NewMongoProvider(db)
}
func TestInc_MongoUsernameMoveClearsOldHolder(t *testing.T) {
_, statsColl := setupMongoStatsTest(t)
assertUsernameMoveClearsOldHolder(t, statsColl)
}
func TestInitStore_MongoSwapsNoituCommandStats(t *testing.T) {
_, provider := setupMongoStatsProvider(t)
assertNoituCommandSwap(t, provider)
}
+148
View File
@@ -0,0 +1,148 @@
package stats
import (
"context"
"errors"
"testing"
"github.com/tiennm99/tiennm99bot/internal/storage"
"github.com/tiennm99/tiennm99bot/internal/systemstate"
)
func TestInitStore_SwapsNoituCommandStats(t *testing.T) {
assertNoituCommandSwap(t, storage.NewMemoryProvider())
}
// assertNoituCommandSwap seeds the solo game's history under "noitu" and the
// group game's under "noitupvp", runs the startup migration twice, and checks
// that the solo history landed on "noitubot" and the group history on
// "noitu" without the two mixing.
func assertNoituCommandSwap(t *testing.T, provider storage.Provider) {
t.Helper()
ctx := context.Background()
statsColl := provider.Collection("stats")
systemColl := provider.Collection(systemstate.CollectionName)
docs := storage.Typed[usageEntry](statsColl)
seeds := map[string]usageEntry{
usageKey("noitu", 0): {Cmd: "noitu", N: 5},
usageKey("noitu", 7): {Cmd: "noitu", UserID: 7, Username: "alice", N: 2},
usageKey("noitupvp", 0): {Cmd: "noitupvp", N: 3},
usageKey("noitupvp", 7): {Cmd: "noitupvp", UserID: 7, Username: "alice", N: 4},
usageKey("noitupvp", 8): {Cmd: "noitupvp", UserID: 8, Username: "bob", N: 6},
// A command that only shares the prefix stays where it is.
usageKey("noitutop", 7): {Cmd: "noitutop", UserID: 7, Username: "alice", N: 1},
}
for key, entry := range seeds {
if err := docs.Put(ctx, key, entry); err != nil {
t.Fatalf("seed %s: %v", key, err)
}
}
for run := range 2 {
if err := InitStore(ctx, statsColl, systemColl); err != nil {
t.Fatalf("InitStore run %d: %v", run+1, err)
}
}
want := map[string]usageEntry{
usageKey("noitubot", 0): {Cmd: "noitubot", N: 5},
usageKey("noitubot", 7): {Cmd: "noitubot", UserID: 7, Username: "alice", N: 2},
usageKey("noitu", 0): {Cmd: "noitu", N: 3},
usageKey("noitu", 7): {Cmd: "noitu", UserID: 7, Username: "alice", N: 4},
usageKey("noitu", 8): {Cmd: "noitu", UserID: 8, Username: "bob", N: 6},
usageKey("noitutop", 7): {Cmd: "noitutop", UserID: 7, Username: "alice", N: 1},
}
for key, w := range want {
got, _, err := docs.Get(ctx, key)
if err != nil {
t.Fatalf("get %s: %v", key, err)
}
if got != w {
t.Errorf("%s = %+v, want %+v", key, got, w)
}
}
for _, key := range []string{usageKey("noitupvp", 0), usageKey("noitupvp", 7), usageKey("noitupvp", 8)} {
if _, _, err := docs.Get(ctx, key); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("old key %s err = %v, want ErrNotFound", key, err)
}
}
state := systemstate.New(systemColl)
for key, count := range map[string]int64{
"stats:command-rename:noitu-to-noitubot": 7,
"stats:command-rename:noitupvp-to-noitu": 13,
} {
rec, ok, err := state.Get(ctx, key)
if err != nil || !ok || rec.Status != "complete" || rec.Count != count {
t.Errorf("marker %s = %+v ok=%v err=%v, want complete count %d", key, rec, ok, err, count)
}
}
}
// A boot that stopped after the first rename resumes with the second only:
// the rows already under the new "noitu" name are group history and must not
// move to noitubot again.
func TestInitStore_NoituSwapResumesAfterFirstStep(t *testing.T) {
ctx := context.Background()
provider := storage.NewMemoryProvider()
statsColl := provider.Collection("stats")
systemColl := provider.Collection(systemstate.CollectionName)
docs := storage.Typed[usageEntry](statsColl)
if err := migrateCommandRename(ctx, statsColl, systemColl, commandRenames[0]); err != nil {
t.Fatalf("first step: %v", err)
}
for key, entry := range map[string]usageEntry{
usageKey("noitubot", 7): {Cmd: "noitubot", UserID: 7, Username: "alice", N: 2},
// Moved by an interrupted second step before the restart.
usageKey("noitu", 7): {Cmd: "noitu", UserID: 7, Username: "alice", N: 4},
usageKey("noitupvp", 8): {Cmd: "noitupvp", UserID: 8, Username: "bob", N: 6},
} {
if err := docs.Put(ctx, key, entry); err != nil {
t.Fatalf("seed %s: %v", key, err)
}
}
if err := InitStore(ctx, statsColl, systemColl); err != nil {
t.Fatalf("InitStore: %v", err)
}
for key, n := range map[string]int64{
usageKey("noitubot", 7): 2,
usageKey("noitu", 7): 4,
usageKey("noitu", 8): 6,
} {
got, _, err := docs.Get(ctx, key)
if err != nil || got.N != n {
t.Errorf("%s = %+v err=%v, want n %d", key, got, err, n)
}
}
}
// A rename merges into rows already under the new name and keeps a row that
// was only ever deleted history marked deleted.
func TestMigrateCommandRename_MergesAndKeepsDeletedFlag(t *testing.T) {
ctx := context.Background()
provider := storage.NewMemoryProvider()
statsColl := provider.Collection("stats")
systemColl := provider.Collection(systemstate.CollectionName)
docs := storage.Typed[usageEntry](statsColl)
for key, entry := range map[string]usageEntry{
usageKey("old", 0): {Cmd: "old", N: 2, Deleted: true},
usageKey("old", 7): {Cmd: "old", UserID: 7, Username: "alice", N: 3},
usageKey("new", 7): {Cmd: "new", UserID: 7, Username: "al", N: 5},
} {
if err := docs.Put(ctx, key, entry); err != nil {
t.Fatalf("seed %s: %v", key, err)
}
}
if err := migrateCommandRename(ctx, statsColl, systemColl, commandRename{oldCmd: "old", newCmd: "new", markerKey: "test-rename"}); err != nil {
t.Fatalf("migrate: %v", err)
}
if got, _, _ := docs.Get(ctx, usageKey("new", 0)); got != (usageEntry{Cmd: "new", N: 2, Deleted: true}) {
t.Errorf("anonymous = %+v", got)
}
if got, _, _ := docs.Get(ctx, usageKey("new", 7)); got != (usageEntry{Cmd: "new", UserID: 7, Username: "alice", N: 8}) {
t.Errorf("user = %+v", got)
}
}