mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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:
1 parent
b8565164b3
commit
d84bc3d1fb
21 files changed
+778
-74
No files matched your search
@@ -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` |
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user