diff --git a/README.md b/README.md index e4a2e2d..c7ce66e 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,15 @@ Atlas via long polling and an in-process cron scheduler. | Module | What it does | |---|---| | `util` | `/help`, `/info`, `/stickerid` | -| `misc` | `/ping`, `/mstats`, `/trongtruonghop` disclaimer | +| `misc` | `/ping`, `/ping_stats`, `/the_answer`, `/trongtruonghop` + `/tth` disclaimer | | `wordle` | Daily Wordle game | | `loldle` | League-of-Legends "guess the champion" | | `lol` | Pro-match schedule + daily push | | `wc` | World Cup schedule + silent daily push | | `stock` | VN-stocks paper trading | -| `gold` | Gold paper trading (opt-in; primary VNAppMob SJC buy/sell VND/luong, fallback spot XAU) | +| `gold` | Gold paper trading (opt-in; VNAppMob SJC buy/sell VND/luong) | | `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback) | -| `stats` | `/stats` (top commands), `/stats users`, `/stats user `, `/stats cmd ` | +| `stats` | `/stats` (top commands), `/stats users`, `/stats user `, `/stats cmd ` | Disable modules with the `MODULES` environment variable. diff --git a/cmd/server/main.go b/cmd/server/main.go index 8cce720..05b9b95 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -29,6 +29,7 @@ import ( "github.com/tiennm99/miti99bot/internal/modules/wordle" "github.com/tiennm99/miti99bot/internal/server" "github.com/tiennm99/miti99bot/internal/storage" + "github.com/tiennm99/miti99bot/internal/systemstate" "github.com/tiennm99/miti99bot/internal/telegram" ) @@ -94,7 +95,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) } diff --git a/internal/modules/coin/coin.go b/internal/modules/coin/coin.go index 5d3ffa1..ba4467c 100644 --- a/internal/modules/coin/coin.go +++ b/internal/modules/coin/coin.go @@ -26,13 +26,13 @@ func New(deps modules.Deps) modules.Module { { Name: "coin_buy", Visibility: modules.VisibilityPublic, - Description: "Buy coin with USD amount", + Description: "Spend a USD amount to buy coin", Handler: s.handleBuy, }, { Name: "coin_sell", Visibility: modules.VisibilityPublic, - Description: "Sell coin for a USD amount", + Description: "Sell enough coin to receive a USD amount", Handler: s.handleSell, }, { diff --git a/internal/modules/coin/handlers.go b/internal/modules/coin/handlers.go index 6e531e4..0d19834 100644 --- a/internal/modules/coin/handlers.go +++ b/internal/modules/coin/handlers.go @@ -70,7 +70,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update } args := argsAfterCommand(update.Message.Text) if len(args) != 2 { - return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_buy \nAlternative: /coin_buy \nExample: /coin_buy BTC 10") + return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_buy \nSpend USD to buy coin.\nAlternative: /coin_buy \nExample: /coin_buy BTC 10") } parsed, err := parseCoinValueArgs(args, isSafeUSD, errInvalidUSDAmount) if errors.Is(err, errInvalidUSDAmount) { @@ -113,7 +113,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update } return chathelper.Reply(ctx, b, update.Message, "Bought "+FormatCoinQty(qty)+" "+coin.Symbol+" @ "+FormatUSD(price.USD)+" ("+price.Source+")"+ - "\nCost: "+FormatUSD(amount)+"\nRemaining: "+FormatUSD(p.USD)) + "\nSpent: "+FormatUSD(amount)+"\nRemaining: "+FormatUSD(p.USD)) } func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Update) error { @@ -124,7 +124,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat } args := argsAfterCommand(update.Message.Text) if len(args) != 2 { - return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_sell \nAlternative: /coin_sell \nExample: /coin_sell BTC 10") + return chathelper.Reply(ctx, b, update.Message, "Usage: /coin_sell \nSell enough coin to receive USD.\nAlternative: /coin_sell \nExample: /coin_sell BTC 10") } parsed, err := parseCoinValueArgs(args, isSafeUSD, errInvalidUSDAmount) if errors.Is(err, errInvalidUSDAmount) { @@ -168,7 +168,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat } return chathelper.Reply(ctx, b, update.Message, "Sold "+FormatCoinQty(qty)+" "+coin.Symbol+" @ "+FormatUSD(price.USD)+" ("+price.Source+")"+ - "\nProceeds: "+FormatUSD(amount)+"\nRemaining: "+FormatUSD(p.USD)) + "\nReceived: "+FormatUSD(amount)+"\nRemaining: "+FormatUSD(p.USD)) } func formatInsufficientSellMessage(coin CoinSymbol, requestedUSD, heldQty, priceUSD float64) string { diff --git a/internal/modules/gold/composite_prices_test.go b/internal/modules/gold/composite_prices_test.go index fa6534e..0ad99e0 100644 --- a/internal/modules/gold/composite_prices_test.go +++ b/internal/modules/gold/composite_prices_test.go @@ -80,7 +80,7 @@ func TestGoldPriceLines(t *testing.T) { Source: "vnappmob-sjc", SJC: &SJCPrice{Buy: 90_000_000, Sell: 91_000_000}, }) - want := []string{"Gold Spot Price (SJC)", "Buy:", "Sell:"} + want := []string{"SJC gold price", "SJC buy (you sell):", "SJC sell (you buy):"} if len(lines) != len(want) { t.Fatalf("got %d lines, want %d: %v", len(lines), len(want), lines) } diff --git a/internal/modules/gold/gold.go b/internal/modules/gold/gold.go index fbafa49..4e0d6c8 100644 --- a/internal/modules/gold/gold.go +++ b/internal/modules/gold/gold.go @@ -25,13 +25,13 @@ func New(deps modules.Deps) modules.Module { { Name: "gold_buy", Visibility: modules.VisibilityPublic, - Description: "Buy gold at spot price (luong)", + Description: "Buy gold at SJC sell price (luong)", Handler: s.handleBuy, }, { Name: "gold_sell", Visibility: modules.VisibilityPublic, - Description: "Sell gold back to VND (luong)", + Description: "Sell gold at SJC buy price (luong)", Handler: s.handleSell, }, { diff --git a/internal/modules/gold/handlers.go b/internal/modules/gold/handlers.go index b6b8b48..eb91b02 100644 --- a/internal/modules/gold/handlers.go +++ b/internal/modules/gold/handlers.go @@ -38,9 +38,9 @@ func (s *state) handlePrice(ctx context.Context, b *bot.Bot, update *models.Upda func goldPriceLines(p GoldPrice) []string { return []string{ - "Gold Spot Price (SJC)", - "Buy: " + FormatVND(p.SJC.Buy) + "/luong", - "Sell: " + FormatVND(p.SJC.Sell) + "/luong", + "SJC gold price", + "SJC buy (you sell): " + FormatVND(p.SJC.Buy) + "/luong", + "SJC sell (you buy): " + FormatVND(p.SJC.Sell) + "/luong", } } @@ -116,7 +116,7 @@ func (s *state) handleBuy(ctx context.Context, b *bot.Bot, update *models.Update return chathelper.Reply(ctx, b, update.Message, "Could not save gold portfolio. Try again later.") } return chathelper.Reply(ctx, b, update.Message, - "Bought "+FormatLuong(qty)+" luong gold @ "+FormatVND(sellPrice)+"/luong\nCost: "+FormatVND(cost)+ + "Bought "+FormatLuong(qty)+" luong gold @ "+FormatVND(sellPrice)+"/luong (SJC sell)\nCost: "+FormatVND(cost)+ "\nRemaining: "+FormatVND(p.VND)) } @@ -163,7 +163,7 @@ func (s *state) handleSell(ctx context.Context, b *bot.Bot, update *models.Updat return chathelper.Reply(ctx, b, update.Message, "Could not save gold portfolio. Try again later.") } return chathelper.Reply(ctx, b, update.Message, - "Sold "+FormatLuong(qty)+" luong gold @ "+FormatVND(buyPrice)+"/luong\nRevenue: "+FormatVND(revenue)+ + "Sold "+FormatLuong(qty)+" luong gold @ "+FormatVND(buyPrice)+"/luong (SJC buy)\nRevenue: "+FormatVND(revenue)+ "\nRemaining: "+FormatVND(p.VND)) } diff --git a/internal/modules/gold/handlers_test.go b/internal/modules/gold/handlers_test.go index e7b7753..3609862 100644 --- a/internal/modules/gold/handlers_test.go +++ b/internal/modules/gold/handlers_test.go @@ -211,7 +211,7 @@ func TestHandlePrice(t *testing.T) { t.Fatalf("handlePrice: %v", err) } text := rb.LastSent().Text() - for _, want := range []string{"Gold Spot Price (SJC)", "Buy:", "Sell:", "/luong"} { + for _, want := range []string{"SJC gold price", "SJC buy (you sell):", "SJC sell (you buy):", "/luong"} { if !strings.Contains(text, want) { t.Fatalf("price missing %q in %q", want, text) } diff --git a/internal/modules/lol/format.go b/internal/modules/lol/format.go index 50cf0f3..9a45e4b 100644 --- a/internal/modules/lol/format.go +++ b/internal/modules/lol/format.go @@ -202,7 +202,7 @@ func renderLeagueSection(g leagueGroup) string { func RenderToday(events []ScheduleEvent, day time.Time) string { header := "LoL — " + html.EscapeString(formatIctDayLabel(day)) + " (ICT)" if len(events) == 0 { - return header + "\nNo matches today." + return header + "\nNo major LoL matches today." } groups := groupByLeague(events) sections := make([]string, len(groups)) @@ -220,7 +220,7 @@ func RenderWeek(events []ScheduleEvent, from, to time.Time) string { toLbl := html.EscapeString(formatIctDayLabel(to.Add(-time.Millisecond))) header := "LoL — " + fromLbl + " → " + toLbl + " (ICT)" if len(events) == 0 { - return header + "\nNo matches this week." + return header + "\nNo major LoL matches this week." } leagueBlocks := make([]string, 0, len(events)) diff --git a/internal/modules/lol/format_test.go b/internal/modules/lol/format_test.go index f60cdb5..0ae9c65 100644 --- a/internal/modules/lol/format_test.go +++ b/internal/modules/lol/format_test.go @@ -113,8 +113,8 @@ func TestRenderToday_GroupsByLeagueInOrder(t *testing.T) { func TestRenderToday_EmptyShowsNoMatches(t *testing.T) { day := time.Date(2026, 5, 9, 0, 0, 0, 0, IctLocation) got := RenderToday(nil, day) - if !strings.Contains(got, "No matches today.") { - t.Errorf("empty render missing 'No matches today.': %q", got) + if !strings.Contains(got, "No major LoL matches today.") { + t.Errorf("empty render missing major-match empty text: %q", got) } } diff --git a/internal/modules/lol/handlers.go b/internal/modules/lol/handlers.go index 6705f57..52d134e 100644 --- a/internal/modules/lol/handlers.go +++ b/internal/modules/lol/handlers.go @@ -82,6 +82,20 @@ func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Messa return chathelper.ReplyHTML(ctx, b, msg, text) } +func subscriptionScope(msg *models.Message) string { + if msg != nil && msg.MessageThreadID != 0 { + return "this topic" + } + return "this chat" +} + +func subscriptionScopeSentenceSubject(msg *models.Message) string { + if msg != nil && msg.MessageThreadID != 0 { + return "This topic" + } + return "This chat" +} + // handleSubscribe is /lol_subscribe — opt the chat into the daily // digest delivered by the in-process cron handler. func (s *state) handleSubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error { @@ -95,12 +109,13 @@ func (s *state) handleSubscribe(ctx context.Context, b *bot.Bot, update *models. if err != nil { return err } + scope := subscriptionScope(msg) if added { return chathelper.Reply(ctx, b, msg, - "✅ Subscribed. You'll get today's LoL schedule at 08:00 ICT.\n"+ + "✅ Subscribed "+scope+" to the daily LoL schedule at 08:00 ICT.\n"+ "If you block the bot, you'll be auto-unsubscribed on the next push.") } - return chathelper.Reply(ctx, b, msg, "Already subscribed.") + return chathelper.Reply(ctx, b, msg, "Already subscribed in "+scope+".") } // handleUnsubscribe is /lol_unsubscribe — opt out. @@ -115,8 +130,9 @@ func (s *state) handleUnsubscribe(ctx context.Context, b *bot.Bot, update *model if err != nil { return err } + scope := subscriptionScope(msg) if removed { - return chathelper.Reply(ctx, b, msg, "Unsubscribed.") + return chathelper.Reply(ctx, b, msg, "Unsubscribed "+scope+".") } - return chathelper.Reply(ctx, b, msg, "You weren't subscribed.") + return chathelper.Reply(ctx, b, msg, subscriptionScopeSentenceSubject(msg)+" wasn't subscribed.") } diff --git a/internal/modules/lol/handlers_test.go b/internal/modules/lol/handlers_test.go index a637542..49397b6 100644 --- a/internal/modules/lol/handlers_test.go +++ b/internal/modules/lol/handlers_test.go @@ -122,7 +122,7 @@ func TestHandleWeek_RendersWeek(t *testing.T) { func TestHandleSubscribe_AddsAndIsIdempotent(t *testing.T) { rb, subsStore := installSchedule(t, todayBody, fakeNowMs) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_subscribe")) - if got := rb.LastSent().Text(); !strings.HasPrefix(got, "✅") { + if got := rb.LastSent().Text(); !strings.Contains(got, "Subscribed this chat") || !strings.Contains(got, "daily LoL schedule") { t.Errorf("first subscribe should confirm; got %q", got) } rb.Reset() @@ -146,6 +146,9 @@ func TestHandleSubscribe_ForumTopic_CapturesThreadID(t *testing.T) { upd.Message.MessageThreadID = 42 rb.Bot.ProcessUpdate(context.Background(), upd) + if got := rb.LastSent().Text(); !strings.Contains(got, "Subscribed this topic") { + t.Errorf("topic subscribe reply = %q", got) + } subs, _ := listSubscribers(context.Background(), subsStore) want := Subscriber{ChatID: 555, ThreadID: 42} if len(subs) != 1 || subs[0] != want { @@ -183,12 +186,12 @@ func TestHandleUnsubscribe(t *testing.T) { rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_subscribe")) rb.Reset() rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_unsubscribe")) - if got := rb.LastSent().Text(); got != "Unsubscribed." { - t.Errorf("unsubscribe reply = %q, want 'Unsubscribed.'", got) + if got := rb.LastSent().Text(); got != "Unsubscribed this chat." { + t.Errorf("unsubscribe reply = %q, want 'Unsubscribed this chat.'", got) } rb.Reset() rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_unsubscribe")) - if got := rb.LastSent().Text(); !strings.Contains(got, "weren't subscribed") { + if got := rb.LastSent().Text(); !strings.Contains(got, "This chat wasn't subscribed") { t.Errorf("idempotent unsubscribe reply = %q", got) } } diff --git a/internal/modules/lol/lol.go b/internal/modules/lol/lol.go index bcd28de..72795d4 100644 --- a/internal/modules/lol/lol.go +++ b/internal/modules/lol/lol.go @@ -25,7 +25,7 @@ func New(deps modules.Deps) modules.Module { { Name: "lol", Visibility: modules.VisibilityPublic, - Description: "LoL matches for a date (dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy; default today)", + Description: "LoL matches for a date (dd, dd-mm, dd/mm, ddmm, or full date; default today)", Handler: s.handleSchedule, }, { diff --git a/internal/modules/lol/parse_date.go b/internal/modules/lol/parse_date.go index b1ac0f2..8bc0bf6 100644 --- a/internal/modules/lol/parse_date.go +++ b/internal/modules/lol/parse_date.go @@ -13,7 +13,7 @@ import ( const ictOffset = 7 * time.Hour // formatHint is the user-facing usage line appended to parse errors. -const formatHint = "Use dd-mm-yyyy, dd/mm/yyyy, or ddmmyyyy." +const formatHint = "Use dd, dd-mm, dd/mm, ddmm, dd-mm-yyyy, dd/mm/yyyy, or ddmmyyyy." // IctLocation is the fixed-offset UTC+7 timezone. var IctLocation = time.FixedZone("ICT", int(ictOffset/time.Second)) diff --git a/internal/modules/loldle/handlers.go b/internal/modules/loldle/handlers.go index 3b3a512..d26526d 100644 --- a/internal/modules/loldle/handlers.go +++ b/internal/modules/loldle/handlers.go @@ -57,12 +57,13 @@ func (s *state) rehydrateGuesses(g *gameState) []boardEntry { } // startFreshGame writes a new round with no startedAt clock yet. -func (s *state) startFreshGame(ctx context.Context, subject string) (*gameState, error) { +func (s *state) startFreshGame(ctx context.Context, subject string, maxGuesses int) (*gameState, error) { target := s.pickRandomChampion() g := &gameState{ - Target: target.ChampionName, - Guesses: []string{}, - StartedAt: nil, + Target: target.ChampionName, + Guesses: []string{}, + StartedAt: nil, + MaxGuesses: normalizeMaxGuesses(maxGuesses), } if err := saveGame(ctx, s.games, subject, g); err != nil { return nil, err @@ -77,10 +78,19 @@ func (s *state) getOrInitGame(ctx context.Context, subject string, maxGuesses in if err != nil { return nil, err } - if existing != nil && len(existing.Guesses) < maxGuesses { - return existing, nil + if existing != nil { + roundMax := existing.roundMaxGuesses() + if len(existing.Guesses) < roundMax { + if existing.MaxGuesses != roundMax { + existing.MaxGuesses = roundMax + if err := saveGame(ctx, s.games, subject, existing); err != nil { + return nil, err + } + } + return existing, nil + } } - return s.startFreshGame(ctx, subject) + return s.startFreshGame(ctx, subject, maxGuesses) } // trySendSticker sends a sticker, swallowing errors. A bad/expired file_id @@ -122,15 +132,19 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd if err != nil { return err } + roundMax := game.roundMaxGuesses() if arg == "" { header := fmt.Sprintf("Guess %d/%d. Use /loldle <champion>.", - len(game.Guesses), maxGuesses) + len(game.Guesses), roundMax) board := renderBoard(s.rehydrateGuesses(game)) return chathelper.ReplyHTML(ctx, b, msg, header+"\n\n"+board) } - guess := findChampion(s.champions, arg) + guess, ambiguous := findChampionMatch(s.champions, arg) + if ambiguous { + return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Ambiguous champion %q. Type the full champion name.", arg)) + } if guess == nil { return chathelper.Reply(ctx, b, msg, fmt.Sprintf("Champion not found: %q.", arg)) } @@ -176,12 +190,12 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd return err } trySendSticker(ctx, b, msg, winStickers) - flavor := attemptFlavor(len(game.Guesses), maxGuesses) + flavor := attemptFlavor(len(game.Guesses), roundMax) return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf( "%s\n\n🎉 %s %s\n⏱ %s · 🔥 Streak: %d (%d/%d)\n%s", - rendered, flavor, champ, elapsed, st.Streak, len(game.Guesses), maxGuesses, newRoundHint)) + rendered, flavor, champ, elapsed, st.Streak, len(game.Guesses), roundMax, newRoundHint)) - case len(game.Guesses) >= maxGuesses: + case len(game.Guesses) >= roundMax: if _, err := recordResult(ctx, s.stats, subject, false); err != nil { return err } @@ -197,7 +211,7 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd return err } return chathelper.ReplyHTML(ctx, b, msg, fmt.Sprintf( - "%s\n\nGuess %d/%d.", rendered, len(game.Guesses), maxGuesses)) + "%s\n\nGuess %d/%d.", rendered, len(game.Guesses), roundMax)) } } diff --git a/internal/modules/loldle/handlers_test.go b/internal/modules/loldle/handlers_test.go index b1bfc9a..9ecc23a 100644 --- a/internal/modules/loldle/handlers_test.go +++ b/internal/modules/loldle/handlers_test.go @@ -71,6 +71,16 @@ func TestLoldle_UnknownChampion(t *testing.T) { } } +func TestLoldle_AmbiguousChampionPrefix(t *testing.T) { + rb, _, _ := installLoldle(t, 0, "1", "Aatrox") + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle A")) + + got := rb.LastSent().Text() + if !strings.Contains(got, "Ambiguous champion") || !strings.Contains(got, "full champion name") { + t.Errorf("ambiguous champion reject: %q", got) + } +} + func TestLoldle_DuplicateGuessRejected(t *testing.T) { rb, _, _ := installLoldle(t, 0, "1", "Aatrox") // First guess: Ahri (not the target — round continues). @@ -134,6 +144,25 @@ func TestLoldleSetMax_OwnerSucceeds(t *testing.T) { } } +func TestLoldleSetMax_AppliesNextRoundOnly(t *testing.T) { + rb, _, _ := installLoldle(t, 999, "999", "Aatrox") + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_setmax 1")) + + rb.Reset() + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle")) + if got := rb.LastSent().Text(); !strings.Contains(got, "Guess 0/8") { + t.Fatalf("active round should keep default max after setmax: %q", got) + } + + rb.Reset() + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_giveup")) + rb.Reset() + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle")) + if got := rb.LastSent().Text(); !strings.Contains(got, "Guess 0/1") { + t.Fatalf("new round should use changed max: %q", got) + } +} + func TestLoldleSetMax_DeniedToNonOwner(t *testing.T) { rb, _, _ := installLoldle(t, 999, "", "") rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/loldle_setmax 5")) diff --git a/internal/modules/loldle/loldle.go b/internal/modules/loldle/loldle.go index a8b903e..330b4a0 100644 --- a/internal/modules/loldle/loldle.go +++ b/internal/modules/loldle/loldle.go @@ -25,7 +25,7 @@ func New(deps modules.Deps) modules.Module { { Name: "loldle_giveup", Visibility: modules.VisibilityPublic, - Description: "Reveal the current loldle answer (auto-starts a fresh round)", + Description: "Reveal the current loldle answer", Handler: s.handleGiveup, }, { diff --git a/internal/modules/loldle/lookup.go b/internal/modules/loldle/lookup.go index e55846f..8da8aba 100644 --- a/internal/modules/loldle/lookup.go +++ b/internal/modules/loldle/lookup.go @@ -27,25 +27,30 @@ func normalizeName(s string) string { // prevents "Ka" silently routing to whichever Ka- champion happens to be // first in the data file. func findChampion(pool []Champion, input string) *Champion { + champion, _ := findChampionMatch(pool, input) + return champion +} + +func findChampionMatch(pool []Champion, input string) (*Champion, bool) { q := normalizeName(input) if q == "" { - return nil + return nil, false } for i := range pool { if normalizeName(pool[i].ChampionName) == q { - return &pool[i] + return &pool[i], false } } var hit *Champion for i := range pool { if strings.HasPrefix(normalizeName(pool[i].ChampionName), q) { if hit != nil { - return nil // ambiguous + return nil, true } hit = &pool[i] } } - return hit + return hit, false } // findChampionByExactName looks up a champion by literal display name (no diff --git a/internal/modules/loldle/lookup_test.go b/internal/modules/loldle/lookup_test.go index b8f46f5..f2c78da 100644 --- a/internal/modules/loldle/lookup_test.go +++ b/internal/modules/loldle/lookup_test.go @@ -26,11 +26,11 @@ func TestFindChampion(t *testing.T) { {"aatrox", "Aatrox"}, {"kaisa", "Kai'Sa"}, {"KAI SA", "Kai'Sa"}, - {"Aat", "Aatrox"}, // unique prefix - {"A", ""}, // ambiguous prefix - {"", ""}, // empty - {"!!!", ""}, // no alphanumerics - {"zed", ""}, // no match + {"Aat", "Aatrox"}, // unique prefix + {"A", ""}, // ambiguous prefix + {"", ""}, // empty + {"!!!", ""}, // no alphanumerics + {"zed", ""}, // no match } for _, tc := range cases { got := findChampion(pool, tc.input) @@ -45,3 +45,16 @@ func TestFindChampion(t *testing.T) { } } } + +func TestFindChampionMatchReportsAmbiguousPrefix(t *testing.T) { + pool := []Champion{{ChampionName: "Aatrox"}, {ChampionName: "Ahri"}, {ChampionName: "Kai'Sa"}} + got, ambiguous := findChampionMatch(pool, "A") + if got != nil || !ambiguous { + t.Fatalf("findChampionMatch ambiguous = (%+v, %v), want (nil, true)", got, ambiguous) + } + + got, ambiguous = findChampionMatch(pool, "zed") + if got != nil || ambiguous { + t.Fatalf("findChampionMatch unknown = (%+v, %v), want (nil, false)", got, ambiguous) + } +} diff --git a/internal/modules/loldle/state.go b/internal/modules/loldle/state.go index 31125f3..c9ec87c 100644 --- a/internal/modules/loldle/state.go +++ b/internal/modules/loldle/state.go @@ -25,9 +25,10 @@ const ( // time against current champions.json so a weekly data refresh updates // historical board displays without migrating saved rounds. type gameState struct { - Target string `json:"target" bson:"target"` - Guesses []string `json:"guesses" bson:"guesses"` - StartedAt *int64 `json:"startedAt" bson:"startedAt"` // ms-since-epoch | null + Target string `json:"target" bson:"target"` + Guesses []string `json:"guesses" bson:"guesses"` + StartedAt *int64 `json:"startedAt" bson:"startedAt"` // ms-since-epoch | null + MaxGuesses int `json:"maxGuesses,omitempty" bson:"maxGuesses,omitempty"` // frozen round budget } // stats lifetime score. No LastResultAt field by design (differs from @@ -59,6 +60,24 @@ func gameKey(subject string) string { return "game:" + subject } func statsKey(subject string) string { return "stats:" + subject } func configKey(subject string) string { return "config:" + subject } +func validMaxGuesses(n int) bool { + return n >= 1 && n <= MaxGuessesCap +} + +func normalizeMaxGuesses(n int) int { + if validMaxGuesses(n) { + return n + } + return MaxGuesses +} + +func (g *gameState) roundMaxGuesses() int { + if g == nil { + return MaxGuesses + } + return normalizeMaxGuesses(g.MaxGuesses) +} + // loadGame returns the active round, or (nil, nil) if none exists. func loadGame(ctx context.Context, games GameStore, subject string) (*gameState, error) { g, _, err := games.Get(ctx, gameKey(subject)) diff --git a/internal/modules/loldle/state_test.go b/internal/modules/loldle/state_test.go index 46da2e8..2e1fab2 100644 --- a/internal/modules/loldle/state_test.go +++ b/internal/modules/loldle/state_test.go @@ -42,6 +42,17 @@ func TestGameState_StartedAtAsNumber(t *testing.T) { } } +func TestGameState_MaxGuessesOmittedWhenUnset(t *testing.T) { + g := gameState{Target: "Aatrox", Guesses: []string{}, MaxGuesses: 0} + b, err := json.Marshal(g) + if err != nil { + t.Fatal(err) + } + if string(b) != `{"target":"Aatrox","guesses":[],"startedAt":null}` { + t.Errorf("marshal:\ngot %s", b) + } +} + func TestStats_NoLastResultAtField(t *testing.T) { // loldle's stats schema deliberately differs from wordle's — no // lastResultAt field. Lock that, since adding the field would silently @@ -123,6 +134,54 @@ func TestGetMaxGuesses_OutOfRangeIgnored(t *testing.T) { } } +func TestGetOrInitGame_ExistingRoundKeepsStoredMaxGuesses(t *testing.T) { + ctx := context.Background() + games := newLoldleGames() + s := &state{games: games} + if err := saveGame(ctx, games, "u1", &gameState{ + Target: "Aatrox", + Guesses: []string{"Ahri"}, + MaxGuesses: 8, + }); err != nil { + t.Fatal(err) + } + + got, err := s.getOrInitGame(ctx, "u1", 1) + if err != nil { + t.Fatal(err) + } + if got.MaxGuesses != 8 || len(got.Guesses) != 1 { + t.Fatalf("existing round changed by new config: %+v", got) + } +} + +func TestGetOrInitGame_LegacyRoundLocksDefaultMaxGuesses(t *testing.T) { + ctx := context.Background() + games := newLoldleGames() + s := &state{games: games} + if err := saveGame(ctx, games, "u1", &gameState{ + Target: "Aatrox", + Guesses: []string{"Ahri"}, + }); err != nil { + t.Fatal(err) + } + + got, err := s.getOrInitGame(ctx, "u1", 1) + if err != nil { + t.Fatal(err) + } + if got.MaxGuesses != MaxGuesses { + t.Fatalf("legacy round MaxGuesses = %d, want default %d", got.MaxGuesses, MaxGuesses) + } + loaded, err := loadGame(ctx, games, "u1") + if err != nil { + t.Fatal(err) + } + if loaded == nil || loaded.MaxGuesses != MaxGuesses { + t.Fatalf("legacy round was not persisted with default max: %+v", loaded) + } +} + func TestLoadGame_MissingReturnsNil(t *testing.T) { g, err := loadGame(context.Background(), newLoldleGames(), "nobody") if err != nil { diff --git a/internal/modules/misc/handlers_test.go b/internal/modules/misc/handlers_test.go index 41e9073..bdfbd14 100644 --- a/internal/modules/misc/handlers_test.go +++ b/internal/modules/misc/handlers_test.go @@ -15,7 +15,7 @@ import ( // installMisc wires the misc module to a recording bot with a fresh // in-memory store. Returns the bot and the typed store (so tests can pre-seed -// or read), plus an Auth that permits Owner + Admin so /mstats /fortytwo dispatch. +// or read), plus an Auth that permits Owner + Admin so /ping_stats /the_answer dispatch. func installMisc(t *testing.T, ownerID int64) (*testutil.RecordingBot, storage.DocStore[lastPing]) { t.Helper() rb := testutil.NewRecordingBot(t) @@ -56,36 +56,36 @@ func TestPing_RepliesPongAndWritesStore(t *testing.T) { } } -func TestMstats_NeverWhenStoreEmpty(t *testing.T) { +func TestPingStats_NeverWhenStoreEmpty(t *testing.T) { rb, _ := installMisc(t, 999) - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/mstats")) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/ping_stats")) if got := rb.LastSent().Text(); got != "last ping: never" { - t.Errorf("mstats reply = %q, want 'last ping: never'", got) + t.Errorf("ping_stats reply = %q, want 'last ping: never'", got) } } -func TestMstats_AfterPing(t *testing.T) { +func TestPingStats_AfterPing(t *testing.T) { rb, _ := installMisc(t, 999) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/ping")) rb.Reset() - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/mstats")) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/ping_stats")) got := rb.LastSent().Text() if !strings.HasPrefix(got, "last ping: ") { - t.Errorf("mstats reply = %q, want 'last ping: ...'", got) + t.Errorf("ping_stats reply = %q, want 'last ping: ...'", got) } if strings.Contains(got, "never") { - t.Errorf("mstats still says 'never' after /ping: %q", got) + t.Errorf("ping_stats still says 'never' after /ping: %q", got) } } -func TestMstats_DeniedToNonAdmin(t *testing.T) { +func TestPingStats_DeniedToNonAdmin(t *testing.T) { rb, _ := installMisc(t, 999) // owner = 999 - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/mstats")) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/ping_stats")) if calls := rb.Sent(); len(calls) != 0 { - t.Errorf("non-admin /mstats produced replies: %+v", calls) + t.Errorf("non-admin /ping_stats produced replies: %+v", calls) } } @@ -128,6 +128,20 @@ func TestTrongTruongHop_CustomArg(t *testing.T) { } } +func TestTTHAlias_CustomArg(t *testing.T) { + rb, _ := installMisc(t, 999) + rb.Bot.ProcessUpdate(context.Background(), trongTruongHopUpdate(t, "/tth Acme Corp", + &models.User{ID: 7, Username: "boss", FirstName: "Boss"})) + + got := rb.LastSent().Text() + if !strings.Contains(got, "Acme Corp") { + t.Errorf("reply missing custom arg Acme Corp: %q", got) + } + if n := strings.Count(got, "@boss"); n != 2 { + t.Errorf("reply mentions @boss %d times, want 2: %q", n, got) + } +} + func TestTrongTruongHop_HTMLEscapesArg(t *testing.T) { rb, _ := installMisc(t, 999) rb.Bot.ProcessUpdate(context.Background(), trongTruongHopUpdate(t, "/trongtruonghop