Files
tiennm99 629b513c91 feat(games): move /wordle and /loldle to the web games and add /loldledaily
/wordle and /loldle now send the wordle and loldle BotFather games in an
unlimited mode: each player gets their own word or champion, saved after
every guess, with a New game button. Cards the bot did not record as
unlimited, including the daily cards, play the daily puzzle. The helper
commands keep working on the player's round.

/loldledaily mirrors /wordledaily: one champion per day at 07:00 ICT,
subscribe and unsubscribe, the morning recap and the live group results.

The daily and unlimited machinery is shared in util/guessgame, the old
in-chat wordle module is removed, and a one-time startup migration carries
each player's stats and unfinished round over without changing old data.
2026-10-10 09:29:48 +07:00

261 lines
6.4 KiB
Go

package loldle
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/tiennm99bot/internal/modules"
"github.com/tiennm99/tiennm99bot/internal/modules/util/guessgame"
"github.com/tiennm99/tiennm99bot/internal/modules/util/htmlgame"
"github.com/tiennm99/tiennm99bot/internal/storage"
"github.com/tiennm99/tiennm99bot/internal/testutil"
)
var (
testRoot = []byte("0123456789abcdef0123456789abcdef")
testStart = guessgame.Epoch.Add(12 * time.Hour) // 19:00 ICT on puzzle #1's day
testBase = "https://game.example"
editStub = `{"message_id":1,"date":0,"chat":{"id":-100,"type":"supergroup"}}`
groupChat int64 = -100
)
type fakeClock struct {
mu sync.Mutex
t time.Time
}
func (c *fakeClock) now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.t
}
func (c *fakeClock) advance(d time.Duration) {
c.mu.Lock()
c.t = c.t.Add(d)
c.mu.Unlock()
}
type reportCall struct {
addr htmlgame.Address
user int64
score int
}
type fakeReporter struct {
mu sync.Mutex
calls []reportCall
}
func (r *fakeReporter) Report(_ context.Context, a htmlgame.Address, userID int64, score int) error {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, reportCall{a, userID, score})
return nil
}
func (r *fakeReporter) snapshot() []reportCall {
r.mu.Lock()
defer r.mu.Unlock()
return append([]reportCall(nil), r.calls...)
}
// fakeScheduler holds summary flushes until the test runs them.
type fakeScheduler struct {
mu sync.Mutex
fns []func()
}
func (f *fakeScheduler) schedule(_ time.Duration, fn func()) {
f.mu.Lock()
defer f.mu.Unlock()
f.fns = append(f.fns, fn)
}
func (f *fakeScheduler) run() {
for {
f.mu.Lock()
if len(f.fns) == 0 {
f.mu.Unlock()
return
}
fn := f.fns[0]
f.fns = f.fns[1:]
f.mu.Unlock()
fn()
}
}
// testPick makes unlimited rounds deterministic: Ahri, then Jinx, then Ahri
// again.
func testPick(prev string) string {
if prev == "Ahri" {
return "Jinx"
}
return "Ahri"
}
type harness struct {
t *testing.T
svc *service
clock *fakeClock
rep *fakeReporter
rb *testutil.RecordingBot
sched *fakeScheduler
coll storage.Collection
handler http.Handler
}
func testConfig(clock *fakeClock, coll storage.Collection, rep guessgame.ScoreReporter, api guessgame.TelegramAPI, sched *fakeScheduler) config {
return config{
baseURL: testBase,
rootKey: testRoot,
now: clock.now,
store: coll,
reporter: rep,
api: api,
schedule: sched.schedule,
async: func(f func()) { f() },
champions: loadChampions(),
pick: testPick,
}
}
func newHarness(t *testing.T) *harness {
t.Helper()
h := &harness{
t: t,
clock: &fakeClock{t: testStart},
rep: &fakeReporter{},
rb: testutil.NewRecordingBot(t),
sched: &fakeScheduler{},
coll: storage.NewMemoryProvider().Collection(ShortName),
}
h.rb.StubMethod("editMessageText", editStub)
h.svc = newService(testConfig(h.clock, h.coll, h.rep, h.rb.Bot, h.sched))
mod := h.svc.module()
if len(mod.HTTP) != 1 || len(mod.Crons) != 2 {
t.Fatalf("enabled module: routes %d crons %d", len(mod.HTTP), len(mod.Crons))
}
h.handler = mod.HTTP[0].Handler
return h
}
// pinDaily makes champion the answer of daily puzzle num.
func (h *harness) pinDaily(num int, champion string) {
h.t.Helper()
if err := h.svc.Puzzles.Put(context.Background(), guessgame.PuzzleKey(num), guessgame.PuzzleDoc{Num: num, Answer: champion}); err != nil {
h.t.Fatal(err)
}
}
// token signs a token for user on card msg in chat; unlimited marks a
// recorded /loldle card.
func (h *harness) token(user, chat int64, msg int, name string, unlimited bool) string {
h.t.Helper()
c := guessgame.Claims{UserID: user, Name: name, ChatID: chat, MessageID: msg, Expiry: h.clock.now().Add(guessgame.TokenTTL).Unix()}
if unlimited {
c.Mode = guessgame.ModeUnlimited
}
tok, err := h.svc.signToken(c)
if err != nil {
h.t.Fatal(err)
}
return tok
}
type result struct {
code int
body string
v view
err string
}
func (h *harness) post(path string, body any) result {
h.t.Helper()
raw, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, routePrefix+"api/"+path, bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.handler.ServeHTTP(rec, req)
out, _ := io.ReadAll(rec.Result().Body)
r := result{code: rec.Code, body: string(out)}
if r.code == http.StatusOK {
if err := json.Unmarshal(out, &r.v); err != nil {
h.t.Fatalf("decode %q: %v", out, err)
}
} else {
var e struct {
Code string `json:"error"`
}
_ = json.Unmarshal(out, &e)
r.err = e.Code
}
return r
}
func (h *harness) state(tok string) result { return h.post("state", map[string]any{"token": tok}) }
func (h *harness) dailyGuess(tok string, num int, name string) result {
return h.post("guess", map[string]any{"token": tok, "num": num, "name": name})
}
func (h *harness) roundGuess(tok string, seq int, name string) result {
return h.post("guess", map[string]any{"token": tok, "seq": seq, "name": name})
}
// install registers mod through the real registry and dispatcher, with
// owner as the bot owner.
func install(t *testing.T, mod modules.Module, owner int64) *testutil.RecordingBot {
t.Helper()
rb := testutil.NewRecordingBot(t)
reg, err := modules.Build([]string{ShortName}, map[string]modules.Factory{
ShortName: func(modules.Deps) modules.Module { return mod },
}, storage.NewMemoryProvider(), modules.BuildOptions{})
if err != nil {
t.Fatalf("Build: %v", err)
}
modules.Install(rb.Bot, reg, modules.Auth{BotOwnerID: owner})
return rb
}
// send runs one command from user in chat (a group when chat < 0) and
// returns the bot's last text reply.
func send(t *testing.T, rb *testutil.RecordingBot, chat, user int64, text string) string {
t.Helper()
rb.Reset()
var u *models.Update
if chat < 0 {
u = testutil.NewGroupMessage(chat, user, text)
} else {
u = testutil.NewPrivateMessage(user, text)
}
rb.Bot.ProcessUpdate(context.Background(), u)
sent := rb.Sent()
for i := len(sent) - 1; i >= 0; i-- {
if sent[i].Method == "sendMessage" {
return sent[i].Text()
}
}
return ""
}
func sentMethod(rb *testutil.RecordingBot, method string) []testutil.SentCall {
var out []testutil.SentCall
for _, c := range rb.Sent() {
if c.Method == method {
out = append(out, c)
}
}
return out
}