mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-21 06:26:52 +00:00
refactor(modules): extract shared chathelper + champname packages
Phase 3 of the 2026-05-09 review remediation plan. Eliminates the helper drift (subjectFor / argAfterCommand / nowMillis / reply / replyHTML / winRate) that previously lived in 3-4 modules, plus the loldle-specific normalize / findChampion / findByExactName. - internal/modules/util/chathelper: SubjectFor, ArgAfterCommand, NowMillis, Reply, ReplyHTML, WinRate. Single canonical SubjectFor shape (group/supergroup -> chat ID, else user ID); WinRate uses math.Round to match JS Math.round (the truncation drift caught in Phase 5b/5c). - internal/champname: Normalize + generic Find[T] / FindByExactName[T] with name-extractor closure. Loldle and loldle-emoji both consume via Champion / EmojiChampion. Migrations: wordle, loldle, loldle-emoji, misc, util/info, util/stickerid. Module-local lookup.go + normalize.go in loldle and loldle-emoji deleted. go test -race -count=1 ./... clean across all 12 packages. Net ~290 lines removed across handler files.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
// Package champname holds champion-name primitives shared by loldle and
|
||||
// loldleemoji (and any future loldle variant). Normalize folds names to a
|
||||
// comparable form; Find resolves user input to a single champion via exact
|
||||
// or unique-prefix match. Generic over the champion type via a name-extractor
|
||||
// closure so each module can keep its own data shape.
|
||||
package champname
|
||||
|
||||
import "strings"
|
||||
|
||||
// Normalize folds a name to a comparable form: lowercase, alphanumeric only.
|
||||
// JS-parity with util/normalize-name.js — `String(s).toLowerCase().replace(/[^a-z0-9]/g, "")`.
|
||||
//
|
||||
// Used for case/space/punctuation-insensitive lookup so "Kai'Sa", "kaisa",
|
||||
// and "KAI SA" all collapse to the same key.
|
||||
func Normalize(s string) string {
|
||||
lower := strings.ToLower(s)
|
||||
out := make([]byte, 0, len(lower))
|
||||
for i := 0; i < len(lower); i++ {
|
||||
c := lower[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// Find resolves user input to a champion via:
|
||||
//
|
||||
// 1. Empty / non-alphanumeric input → no match (nil).
|
||||
// 2. Exact normalised name → that champion.
|
||||
// 3. Otherwise: unique normalised-prefix match → that champion.
|
||||
//
|
||||
// Ambiguous prefix or no hit → nil. Returning nil for ambiguous prefixes
|
||||
// prevents "Ka" silently routing to whichever Ka- champion happens to be
|
||||
// first in the data file.
|
||||
//
|
||||
// Generic over T so loldle/Champion and loldleemoji/EmojiChampion can both
|
||||
// use it; nameOf extracts the champion's display name.
|
||||
func Find[T any](pool []T, input string, nameOf func(*T) string) *T {
|
||||
q := Normalize(input)
|
||||
if q == "" {
|
||||
return nil
|
||||
}
|
||||
for i := range pool {
|
||||
if Normalize(nameOf(&pool[i])) == q {
|
||||
return &pool[i]
|
||||
}
|
||||
}
|
||||
var hit *T
|
||||
for i := range pool {
|
||||
if strings.HasPrefix(Normalize(nameOf(&pool[i])), q) {
|
||||
if hit != nil {
|
||||
return nil // ambiguous
|
||||
}
|
||||
hit = &pool[i]
|
||||
}
|
||||
}
|
||||
return hit
|
||||
}
|
||||
|
||||
// FindByExactName looks up a champion by literal display name (no
|
||||
// normalization). Used to rehydrate a stored game's target. Returns nil if
|
||||
// the pool was refreshed and the target is no longer present.
|
||||
func FindByExactName[T any](pool []T, name string, nameOf func(*T) string) *T {
|
||||
for i := range pool {
|
||||
if nameOf(&pool[i]) == name {
|
||||
return &pool[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package champname
|
||||
|
||||
import "testing"
|
||||
|
||||
type fakeChamp struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func nameOfFake(c *fakeChamp) string { return c.Name }
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"Kai'Sa", "kaisa"},
|
||||
{"KAI SA", "kaisa"},
|
||||
{"kaisa", "kaisa"},
|
||||
{"Lee Sin", "leesin"},
|
||||
{"Dr. Mundo", "drmundo"},
|
||||
{"K9", "k9"},
|
||||
{" ", ""},
|
||||
{"!!!", ""},
|
||||
{"Aurelion Sol", "aurelionsol"},
|
||||
{"Cho'Gath", "chogath"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := Normalize(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("Normalize(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFind(t *testing.T) {
|
||||
pool := []fakeChamp{
|
||||
{Name: "Kai'Sa"},
|
||||
{Name: "Karma"},
|
||||
{Name: "Karthus"},
|
||||
{Name: "Lee Sin"},
|
||||
{Name: "Lulu"},
|
||||
{Name: "Lux"},
|
||||
}
|
||||
tests := []struct {
|
||||
name, in string
|
||||
wantName string // "" → nil expected
|
||||
}{
|
||||
{"empty input → nil", "", ""},
|
||||
{"non-alpha input → nil", "!!!", ""},
|
||||
{"exact match", "kaisa", "Kai'Sa"},
|
||||
{"exact match with punctuation", "Kai'Sa", "Kai'Sa"},
|
||||
{"exact match with spaces", "Lee Sin", "Lee Sin"},
|
||||
{"unique prefix", "kart", "Karthus"},
|
||||
{"unique prefix with case", "KART", "Karthus"},
|
||||
{"ambiguous prefix → nil", "ka", ""},
|
||||
{"ambiguous prefix lu → nil", "lu", ""},
|
||||
{"unambiguous prefix lux", "lux", "Lux"},
|
||||
{"no match", "zilean", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Find(pool, tt.in, nameOfFake)
|
||||
if tt.wantName == "" {
|
||||
if got != nil {
|
||||
t.Errorf("Find(%q): got %v, want nil", tt.in, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("Find(%q): got nil, want %q", tt.in, tt.wantName)
|
||||
}
|
||||
if got.Name != tt.wantName {
|
||||
t.Errorf("Find(%q): got %q, want %q", tt.in, got.Name, tt.wantName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindByExactName(t *testing.T) {
|
||||
pool := []fakeChamp{{Name: "Kai'Sa"}, {Name: "Karma"}}
|
||||
|
||||
if got := FindByExactName(pool, "Kai'Sa", nameOfFake); got == nil || got.Name != "Kai'Sa" {
|
||||
t.Errorf("FindByExactName(Kai'Sa) = %v, want hit", got)
|
||||
}
|
||||
// Normalisation must NOT apply — exact match only.
|
||||
if got := FindByExactName(pool, "kaisa", nameOfFake); got != nil {
|
||||
t.Errorf("FindByExactName(kaisa) = %v, want nil (case-sensitive)", got)
|
||||
}
|
||||
if got := FindByExactName(pool, "Zilean", nameOfFake); got != nil {
|
||||
t.Errorf("FindByExactName(Zilean) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,15 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"math"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/champname"
|
||||
"github.com/tiennm99/miti99bot-go/internal/keylock"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
@@ -27,37 +26,8 @@ type state struct {
|
||||
locks keylock.Map // serialises Get→mutate→Put per subject
|
||||
}
|
||||
|
||||
// subjectFor mirrors JS getSubject: group/supergroup → chat ID (shared game),
|
||||
// otherwise → user ID. Channels and unknown types fall through to From.ID.
|
||||
// Returns "" when no usable id is present.
|
||||
func subjectFor(msg *models.Message) string {
|
||||
if msg == nil {
|
||||
return ""
|
||||
}
|
||||
switch msg.Chat.Type {
|
||||
case models.ChatTypeGroup, models.ChatTypeSupergroup:
|
||||
return strconv.FormatInt(msg.Chat.ID, 10)
|
||||
default:
|
||||
if msg.From != nil {
|
||||
return strconv.FormatInt(msg.From.ID, 10)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// argAfterCommand returns everything after the first space in text, trimmed.
|
||||
// Identical contract to wordle/util — duplicated to keep package-local;
|
||||
// promoting to a shared helper buys very little until a 4th module needs it.
|
||||
func argAfterCommand(text string) string {
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
idx := strings.IndexByte(text, ' ')
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(text[idx+1:])
|
||||
}
|
||||
// championName extracts the comparable name field for champname helpers.
|
||||
func championName(c *Champion) string { return c.ChampionName }
|
||||
|
||||
// pickRandomChampion uses math/rand's mutex-protected globals so concurrent
|
||||
// /loldle handlers don't race on a shared *rand.Rand.
|
||||
@@ -66,7 +36,7 @@ func (s *state) pickRandomChampion() *Champion {
|
||||
}
|
||||
|
||||
func (s *state) findByName(name string) *Champion {
|
||||
return findByExactName(s.champions, name)
|
||||
return champname.FindByExactName(s.champions, name, championName)
|
||||
}
|
||||
|
||||
// rehydrateGuesses recomputes board rows from the stored championNames.
|
||||
@@ -128,36 +98,18 @@ func trySendSticker(ctx context.Context, b *bot.Bot, chatID int64, pool []string
|
||||
})
|
||||
}
|
||||
|
||||
// reply sends a plain-text response.
|
||||
func reply(ctx context.Context, b *bot.Bot, chatID int64, text string) error {
|
||||
_, err := b.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text})
|
||||
return err
|
||||
}
|
||||
|
||||
// replyHTML sends a Telegram HTML-formatted response.
|
||||
func replyHTML(ctx context.Context, b *bot.Bot, chatID int64, text string) error {
|
||||
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: chatID,
|
||||
Text: text,
|
||||
ParseMode: models.ParseModeHTML,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func nowMillis() int64 { return time.Now().UTC().UnixMilli() }
|
||||
|
||||
// handleLoldle is /loldle [champion] — show board if no arg, else submit guess.
|
||||
func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := subjectFor(msg)
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
arg := argAfterCommand(msg.Text)
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
|
||||
maxGuesses, err := getMaxGuesses(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
@@ -172,17 +124,17 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
header := fmt.Sprintf("Guess %d/%d. Use <code>/loldle <champion></code>.",
|
||||
len(game.Guesses), maxGuesses)
|
||||
board := renderBoard(s.rehydrateGuesses(game))
|
||||
return replyHTML(ctx, b, msg.Chat.ID, header+"\n\n"+board)
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, header+"\n\n"+board)
|
||||
}
|
||||
|
||||
guess := findChampion(s.champions, arg)
|
||||
guess := champname.Find(s.champions, arg, championName)
|
||||
if guess == nil {
|
||||
return reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Champion not found: %q.", arg))
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Champion not found: %q.", arg))
|
||||
}
|
||||
|
||||
for _, prior := range game.Guesses {
|
||||
if prior == guess.ChampionName {
|
||||
return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"🔁 <b>%s</b> was already guessed this round — try another champion.",
|
||||
html.EscapeString(guess.ChampionName)))
|
||||
}
|
||||
@@ -195,13 +147,13 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return replyHTML(ctx, b, msg.Chat.ID,
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
"Champion data was updated since this round started. "+newRoundHint)
|
||||
}
|
||||
|
||||
results := CompareChampions(guess, target)
|
||||
if game.StartedAt == nil {
|
||||
now := nowMillis()
|
||||
now := chathelper.NowMillis()
|
||||
game.StartedAt = &now
|
||||
}
|
||||
game.Guesses = append(game.Guesses, guess.ChampionName)
|
||||
@@ -209,7 +161,7 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
|
||||
rendered := renderGuess(guess.ChampionName, results)
|
||||
champ := html.EscapeString(target.ChampionName)
|
||||
elapsed := formatDuration(nowMillis() - *game.StartedAt)
|
||||
elapsed := formatDuration(chathelper.NowMillis() - *game.StartedAt)
|
||||
|
||||
switch {
|
||||
case won:
|
||||
@@ -222,7 +174,7 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
}
|
||||
trySendSticker(ctx, b, msg.Chat.ID, winStickers)
|
||||
flavor := attemptFlavor(len(game.Guesses), maxGuesses)
|
||||
return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, 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))
|
||||
|
||||
@@ -234,14 +186,14 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
return err
|
||||
}
|
||||
trySendSticker(ctx, b, msg.Chat.ID, loseStickers)
|
||||
return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"%s\n\n❌ Out of guesses. Answer was %s.\n%s", rendered, champ, newRoundHint))
|
||||
|
||||
default:
|
||||
if err := saveGame(ctx, s.kv, subject, game); err != nil {
|
||||
return err
|
||||
}
|
||||
return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"%s\n\nGuess %d/%d.", rendered, len(game.Guesses), maxGuesses))
|
||||
}
|
||||
}
|
||||
@@ -252,9 +204,9 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := subjectFor(msg)
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
|
||||
@@ -263,7 +215,7 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
return replyHTML(ctx, b, msg.Chat.ID, "No active round. "+newRoundHint)
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, "No active round. "+newRoundHint)
|
||||
}
|
||||
if _, err := recordResult(ctx, s.kv, subject, false); err != nil {
|
||||
return err
|
||||
@@ -277,7 +229,7 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
if target != nil {
|
||||
answer = target.ChampionName
|
||||
}
|
||||
return replyHTML(ctx, b, msg.Chat.ID,
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("🏳️ Answer was %s.\n%s", html.EscapeString(answer), newRoundHint))
|
||||
}
|
||||
|
||||
@@ -287,28 +239,21 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := subjectFor(msg)
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
st, err := loadStats(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
winRate := 0
|
||||
if st.Played > 0 {
|
||||
// math.Round matches JS Math.round for positive inputs (round half
|
||||
// away from zero); int(...) truncation would render 2/3 as 66% where
|
||||
// the JS source shows 67%.
|
||||
winRate = int(math.Round(float64(st.Wins) / float64(st.Played) * 100))
|
||||
}
|
||||
scope := "group"
|
||||
if msg.Chat.Type == models.ChatTypePrivate {
|
||||
scope = "your"
|
||||
}
|
||||
return reply(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"📊 Loldle %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d",
|
||||
scope, st.Played, st.Wins, winRate, st.Streak, st.BestStreak))
|
||||
scope, st.Played, st.Wins, chathelper.WinRate(st.Wins, st.Played), st.Streak, st.BestStreak))
|
||||
}
|
||||
|
||||
// handleSetMax is /loldle_setmax <n> — private command, sets the per-subject
|
||||
@@ -318,17 +263,17 @@ func (s *state) handleSetMax(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := subjectFor(msg)
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
arg := argAfterCommand(msg.Text)
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
n, err := strconv.Atoi(arg)
|
||||
if err != nil || n < 1 || n > MaxGuessesCap {
|
||||
return reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Usage: /loldle_setmax <1-%d>", MaxGuessesCap))
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Usage: /loldle_setmax <1-%d>", MaxGuessesCap))
|
||||
}
|
||||
if err := setMaxGuesses(ctx, s.kv, subject, n); err != nil {
|
||||
return err
|
||||
}
|
||||
return reply(ctx, b, msg.Chat.ID, fmt.Sprintf("✅ Loldle max guesses set to %d (applies to the next round).", n))
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("✅ Loldle max guesses set to %d (applies to the next round).", n))
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package loldle
|
||||
|
||||
import "strings"
|
||||
|
||||
// findChampion resolves user input to a Champion. JS-faithful semantics:
|
||||
//
|
||||
// 1. Empty / non-alphanumeric input → no match (nil).
|
||||
// 2. Exact normalised name → that champion.
|
||||
// 3. Otherwise: unique normalised-prefix match → that champion. Ambiguous or
|
||||
// no prefix match → nil. Returning nil for ambiguous prefixes prevents
|
||||
// "Ka" silently routing to whichever Ka- champion happens to be first
|
||||
// in champions.json.
|
||||
func findChampion(champions []Champion, input string) *Champion {
|
||||
q := normalize(input)
|
||||
if q == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range champions {
|
||||
if normalize(champions[i].ChampionName) == q {
|
||||
return &champions[i]
|
||||
}
|
||||
}
|
||||
|
||||
var prefixHit *Champion
|
||||
for i := range champions {
|
||||
if strings.HasPrefix(normalize(champions[i].ChampionName), q) {
|
||||
if prefixHit != nil {
|
||||
// Second prefix match → ambiguous; bail.
|
||||
return nil
|
||||
}
|
||||
prefixHit = &champions[i]
|
||||
}
|
||||
}
|
||||
return prefixHit
|
||||
}
|
||||
|
||||
// findByExactName looks up a champion by literal championName (no normalization).
|
||||
// Used by handlers when restoring a stored game's target — the JS `find` over
|
||||
// raw championName.
|
||||
func findByExactName(champions []Champion, name string) *Champion {
|
||||
for i := range champions {
|
||||
if champions[i].ChampionName == name {
|
||||
return &champions[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,90 +1,24 @@
|
||||
package loldle
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"Aatrox": "aatrox",
|
||||
"Kai'Sa": "kaisa",
|
||||
"KAI SA": "kaisa",
|
||||
"Twisted Fate": "twistedfate",
|
||||
"!@#": "",
|
||||
"42 Vi": "42vi",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalize(in); got != want {
|
||||
t.Errorf("normalize(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindChampion_ExactNormalizedMatch(t *testing.T) {
|
||||
cs := []Champion{
|
||||
{ChampionName: "Aatrox"},
|
||||
{ChampionName: "Ahri"},
|
||||
{ChampionName: "Akali"},
|
||||
}
|
||||
for _, in := range []string{"aatrox", "AATROX", "Aatrox", "A-A-T-R-O-X"} {
|
||||
got := findChampion(cs, in)
|
||||
if got == nil || got.ChampionName != "Aatrox" {
|
||||
t.Errorf("input %q → %v, want Aatrox", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindChampion_UniquePrefix(t *testing.T) {
|
||||
cs := []Champion{
|
||||
{ChampionName: "Aatrox"},
|
||||
{ChampionName: "Ahri"},
|
||||
{ChampionName: "Akali"},
|
||||
}
|
||||
got := findChampion(cs, "aat")
|
||||
if got == nil || got.ChampionName != "Aatrox" {
|
||||
t.Errorf("aat → %v, want Aatrox", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindChampion_AmbiguousPrefixReturnsNil(t *testing.T) {
|
||||
cs := []Champion{
|
||||
{ChampionName: "Akali"},
|
||||
{ChampionName: "Akshan"},
|
||||
}
|
||||
if got := findChampion(cs, "ak"); got != nil {
|
||||
t.Errorf("ambiguous ak should be nil; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindChampion_EmptyInputReturnsNil(t *testing.T) {
|
||||
cs := []Champion{{ChampionName: "Aatrox"}}
|
||||
if got := findChampion(cs, ""); got != nil {
|
||||
t.Errorf("empty input should be nil; got %v", got)
|
||||
}
|
||||
if got := findChampion(cs, "!!!"); got != nil {
|
||||
t.Errorf("non-alphanumeric input should be nil; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindByExactName(t *testing.T) {
|
||||
cs := []Champion{{ChampionName: "Aatrox"}, {ChampionName: "Ahri"}}
|
||||
if got := findByExactName(cs, "Ahri"); got == nil || got.ChampionName != "Ahri" {
|
||||
t.Errorf("exact Ahri → %v", got)
|
||||
}
|
||||
// findByExactName is literal — no case-folding.
|
||||
if got := findByExactName(cs, "ahri"); got != nil {
|
||||
t.Errorf("lowercase should not match exact name lookup; got %v", got)
|
||||
}
|
||||
}
|
||||
"github.com/tiennm99/miti99bot-go/internal/champname"
|
||||
)
|
||||
|
||||
// Generic name lookup primitives (Normalize, Find, FindByExactName) live in
|
||||
// internal/champname and are tested there. This file only exercises the
|
||||
// embedded champions.json — wiring + shape integration test.
|
||||
func TestLoadChampions_EmbedIsValid(t *testing.T) {
|
||||
cs := loadChampions()
|
||||
if n := len(cs); n < 150 || n > 200 {
|
||||
t.Errorf("champion count = %d, want ~172", n)
|
||||
}
|
||||
// Spot-check a known champion that the JS test suite depends on.
|
||||
if got := findByExactName(cs, "Aatrox"); got == nil {
|
||||
t.Error("expected Aatrox in embedded list")
|
||||
} else if got.Gender != "Male" || got.Resource != "Manaless" {
|
||||
got := champname.FindByExactName(cs, "Aatrox", championName)
|
||||
if got == nil {
|
||||
t.Fatal("expected Aatrox in embedded list")
|
||||
}
|
||||
if got.Gender != "Male" || got.Resource != "Manaless" {
|
||||
t.Errorf("Aatrox shape unexpected: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package loldle
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalize folds a name into a comparable form: lowercase, alphanumeric only.
|
||||
// JS parity with util/normalize-name.js — `String(s).toLowerCase().replace(/[^a-z0-9]/g, "")`.
|
||||
//
|
||||
// Used for case/space/punctuation-insensitive name lookup so "Kai'Sa",
|
||||
// "kaisa", and "KAI SA" all collapse to the same key.
|
||||
func normalize(s string) string {
|
||||
lower := strings.ToLower(s)
|
||||
out := make([]byte, 0, len(lower))
|
||||
for i := 0; i < len(lower); i++ {
|
||||
c := lower[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -4,16 +4,15 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"math"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/champname"
|
||||
"github.com/tiennm99/miti99bot-go/internal/keylock"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
@@ -27,43 +26,13 @@ type state struct {
|
||||
locks keylock.Map // serialises Get→mutate→Put per subject
|
||||
}
|
||||
|
||||
// subjectFor: group/supergroup → chat ID, otherwise → user ID. Matches the
|
||||
// JS source (loldle-emoji/handlers.js getSubject) and classic loldle. Same
|
||||
// helper exists in classic loldle's handlers.go — duplication accepted at
|
||||
// 2 callers; will extract once a third loldle variant lands (5 total are
|
||||
// expected per Phase 6).
|
||||
func subjectFor(msg *models.Message) string {
|
||||
if msg == nil {
|
||||
return ""
|
||||
}
|
||||
switch msg.Chat.Type {
|
||||
case models.ChatTypeGroup, models.ChatTypeSupergroup:
|
||||
return strconv.FormatInt(msg.Chat.ID, 10)
|
||||
default:
|
||||
if msg.From != nil {
|
||||
return strconv.FormatInt(msg.From.ID, 10)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func argAfterCommand(text string) string {
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
idx := strings.IndexByte(text, ' ')
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(text[idx+1:])
|
||||
}
|
||||
// championName extracts the comparable name field for champname helpers.
|
||||
func championName(c *EmojiChampion) string { return c.ChampionName }
|
||||
|
||||
func (s *state) pickRandom() *EmojiChampion {
|
||||
return &s.pool[rand.Intn(len(s.pool))]
|
||||
}
|
||||
|
||||
func nowMillis() int64 { return time.Now().UTC().UnixMilli() }
|
||||
|
||||
func (s *state) startFreshGame(ctx context.Context, subject string) (*gameState, error) {
|
||||
target := s.pickRandom()
|
||||
g := &gameState{Target: target.ChampionName, Guesses: []string{}, StartedAt: nil}
|
||||
@@ -84,32 +53,18 @@ func (s *state) getOrInitGame(ctx context.Context, subject string, maxGuesses in
|
||||
return s.startFreshGame(ctx, subject)
|
||||
}
|
||||
|
||||
func reply(ctx context.Context, b *bot.Bot, chatID int64, text string) error {
|
||||
_, err := b.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text})
|
||||
return err
|
||||
}
|
||||
|
||||
func replyHTML(ctx context.Context, b *bot.Bot, chatID int64, text string) error {
|
||||
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: chatID,
|
||||
Text: text,
|
||||
ParseMode: models.ParseModeHTML,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// handleEmoji is /loldle_emoji [champion] — show clue if no arg, else guess.
|
||||
func (s *state) handleEmoji(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := subjectFor(msg)
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
arg := argAfterCommand(msg.Text)
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
|
||||
maxGuesses, err := getMaxGuesses(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
@@ -119,35 +74,35 @@ func (s *state) handleEmoji(ctx context.Context, b *bot.Bot, update *models.Upda
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := findByExactName(s.pool, game.Target)
|
||||
target := champname.FindByExactName(s.pool, game.Target, championName)
|
||||
if target == nil {
|
||||
// Pool refreshed mid-round and the target is gone — start over.
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return replyHTML(ctx, b, msg.Chat.ID,
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
"Emoji data was updated since this round started. "+newRoundHint)
|
||||
}
|
||||
|
||||
if arg == "" {
|
||||
return replyHTML(ctx, b, msg.Chat.ID, renderBoard(target.Emojis, game.Guesses, maxGuesses))
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, renderBoard(target.Emojis, game.Guesses, maxGuesses))
|
||||
}
|
||||
|
||||
guess := findChampion(s.pool, arg)
|
||||
guess := champname.Find(s.pool, arg, championName)
|
||||
if guess == nil {
|
||||
return reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Champion not found: %q.", arg))
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Champion not found: %q.", arg))
|
||||
}
|
||||
|
||||
for _, prior := range game.Guesses {
|
||||
if prior == guess.ChampionName {
|
||||
return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"🔁 <b>%s</b> was already guessed this round — try another champion.",
|
||||
html.EscapeString(guess.ChampionName)))
|
||||
}
|
||||
}
|
||||
|
||||
if game.StartedAt == nil {
|
||||
now := nowMillis()
|
||||
now := chathelper.NowMillis()
|
||||
game.StartedAt = &now
|
||||
}
|
||||
game.Guesses = append(game.Guesses, guess.ChampionName)
|
||||
@@ -163,7 +118,7 @@ func (s *state) handleEmoji(ctx context.Context, b *bot.Bot, update *models.Upda
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"🎉 Got it! <b>%s</b> — solved in %d/%d\n🔥 Streak: %d\n%s",
|
||||
answer, len(game.Guesses), maxGuesses, st.Streak, newRoundHint))
|
||||
|
||||
@@ -174,7 +129,7 @@ func (s *state) handleEmoji(ctx context.Context, b *bot.Bot, update *models.Upda
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"%s\n\n❌ Out of guesses. Answer was <b>%s</b>.\n%s",
|
||||
renderBoard(target.Emojis, game.Guesses, maxGuesses), answer, newRoundHint))
|
||||
|
||||
@@ -182,7 +137,7 @@ func (s *state) handleEmoji(ctx context.Context, b *bot.Bot, update *models.Upda
|
||||
if err := saveGame(ctx, s.kv, subject, game); err != nil {
|
||||
return err
|
||||
}
|
||||
return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"%s\n\n❌ Not <b>%s</b>. Guess %d/%d.",
|
||||
renderBoard(target.Emojis, game.Guesses, maxGuesses),
|
||||
html.EscapeString(guess.ChampionName), len(game.Guesses), maxGuesses))
|
||||
@@ -195,9 +150,9 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := subjectFor(msg)
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
|
||||
@@ -206,7 +161,7 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
return replyHTML(ctx, b, msg.Chat.ID, "No active round. "+newRoundHint)
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, "No active round. "+newRoundHint)
|
||||
}
|
||||
if _, err := recordResult(ctx, s.kv, subject, false); err != nil {
|
||||
return err
|
||||
@@ -214,7 +169,7 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"🏳️ Answer was <b>%s</b>.\n%s", html.EscapeString(existing.Target), newRoundHint))
|
||||
}
|
||||
|
||||
@@ -224,27 +179,21 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := subjectFor(msg)
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
st, err := loadStats(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
winRate := 0
|
||||
if st.Played > 0 {
|
||||
// math.Round matches JS Math.round; int(...) truncation would render
|
||||
// 2/3 as 66% where the JS source shows 67%.
|
||||
winRate = int(math.Round(float64(st.Wins) / float64(st.Played) * 100))
|
||||
}
|
||||
scope := "group"
|
||||
if msg.Chat.Type == models.ChatTypePrivate {
|
||||
scope = "your"
|
||||
}
|
||||
return reply(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"📊 Loldle Emoji %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d",
|
||||
scope, st.Played, st.Wins, winRate, st.Streak, st.BestStreak))
|
||||
scope, st.Played, st.Wins, chathelper.WinRate(st.Wins, st.Played), st.Streak, st.BestStreak))
|
||||
}
|
||||
|
||||
// handleSetMax is /loldle_emoji_setmax <n> — private; per-subject override.
|
||||
@@ -253,17 +202,17 @@ func (s *state) handleSetMax(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := subjectFor(msg)
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
arg := argAfterCommand(msg.Text)
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
n, err := strconv.Atoi(arg)
|
||||
if err != nil || n < 1 || n > MaxGuessesCap {
|
||||
return reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Usage: /loldle_emoji_setmax <1-%d>", MaxGuessesCap))
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Usage: /loldle_emoji_setmax <1-%d>", MaxGuessesCap))
|
||||
}
|
||||
if err := setMaxGuesses(ctx, s.kv, subject, n); err != nil {
|
||||
return err
|
||||
}
|
||||
return reply(ctx, b, msg.Chat.ID, fmt.Sprintf("✅ Loldle emoji max guesses set to %d (applies to the next round).", n))
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("✅ Loldle emoji max guesses set to %d (applies to the next round).", n))
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package loldleemoji
|
||||
|
||||
import "strings"
|
||||
|
||||
// findChampion: exact normalised match first, then unique-prefix fallback.
|
||||
// Ambiguous prefix or no hit → nil. JS-faithful (lookup.js).
|
||||
func findChampion(pool []EmojiChampion, input string) *EmojiChampion {
|
||||
q := normalize(input)
|
||||
if q == "" {
|
||||
return nil
|
||||
}
|
||||
for i := range pool {
|
||||
if normalize(pool[i].ChampionName) == q {
|
||||
return &pool[i]
|
||||
}
|
||||
}
|
||||
var hit *EmojiChampion
|
||||
for i := range pool {
|
||||
if strings.HasPrefix(normalize(pool[i].ChampionName), q) {
|
||||
if hit != nil {
|
||||
return nil // ambiguous
|
||||
}
|
||||
hit = &pool[i]
|
||||
}
|
||||
}
|
||||
return hit
|
||||
}
|
||||
|
||||
// findByExactName looks up a champion by literal championName. Used to
|
||||
// rehydrate the target from a stored game. Returns nil if the pool was
|
||||
// refreshed and the target is no longer present.
|
||||
func findByExactName(pool []EmojiChampion, name string) *EmojiChampion {
|
||||
for i := range pool {
|
||||
if pool[i].ChampionName == name {
|
||||
return &pool[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,62 +1,13 @@
|
||||
package loldleemoji
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"Aatrox": "aatrox",
|
||||
"Kai'Sa": "kaisa",
|
||||
"KAI SA": "kaisa",
|
||||
"Twisted Fate": "twistedfate",
|
||||
"!@#": "",
|
||||
"42 Vi": "42vi",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalize(in); got != want {
|
||||
t.Errorf("normalize(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindChampion_ExactAndPrefixAndAmbiguous(t *testing.T) {
|
||||
pool := []EmojiChampion{
|
||||
{ChampionName: "Aatrox", Emojis: "⚔️"},
|
||||
{ChampionName: "Akali", Emojis: "🥷"},
|
||||
{ChampionName: "Akshan", Emojis: "🪝"},
|
||||
}
|
||||
|
||||
// Exact normalised match.
|
||||
if got := findChampion(pool, "AATROX"); got == nil || got.ChampionName != "Aatrox" {
|
||||
t.Errorf("AATROX → %v, want Aatrox", got)
|
||||
}
|
||||
// Unique prefix.
|
||||
if got := findChampion(pool, "aat"); got == nil || got.ChampionName != "Aatrox" {
|
||||
t.Errorf("aat → %v, want Aatrox", got)
|
||||
}
|
||||
// Ambiguous prefix → nil.
|
||||
if got := findChampion(pool, "ak"); got != nil {
|
||||
t.Errorf("ambiguous ak → %v, want nil", got)
|
||||
}
|
||||
// Empty / non-alphanumeric → nil.
|
||||
if got := findChampion(pool, ""); got != nil {
|
||||
t.Errorf("empty input → %v, want nil", got)
|
||||
}
|
||||
if got := findChampion(pool, "!!!"); got != nil {
|
||||
t.Errorf("!!! → %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindByExactName(t *testing.T) {
|
||||
pool := []EmojiChampion{{ChampionName: "Aatrox"}, {ChampionName: "Ahri"}}
|
||||
if got := findByExactName(pool, "Ahri"); got == nil || got.ChampionName != "Ahri" {
|
||||
t.Errorf("exact Ahri → %v", got)
|
||||
}
|
||||
if got := findByExactName(pool, "ahri"); got != nil {
|
||||
t.Errorf("lowercase should not match exact lookup, got %v", got)
|
||||
}
|
||||
}
|
||||
"github.com/tiennm99/miti99bot-go/internal/champname"
|
||||
)
|
||||
|
||||
// Generic lookup primitives live in internal/champname. This file only
|
||||
// exercises the embedded pool — wiring + filter integration test.
|
||||
func TestLoadPool_DropsEmptyEmojiRecords(t *testing.T) {
|
||||
pool := loadPool()
|
||||
if n := len(pool); n < 150 || n > 200 {
|
||||
@@ -67,8 +18,7 @@ func TestLoadPool_DropsEmptyEmojiRecords(t *testing.T) {
|
||||
t.Errorf("empty-emoji record leaked through filter: %s", c.ChampionName)
|
||||
}
|
||||
}
|
||||
// Spot-check a known champion.
|
||||
if got := findByExactName(pool, "Aatrox"); got == nil {
|
||||
if got := champname.FindByExactName(pool, "Aatrox", championName); got == nil {
|
||||
t.Error("expected Aatrox in pool")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package loldleemoji
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalize folds names to a comparable form: lowercase, alphanumeric only.
|
||||
// JS-parity with util/normalize-name.js. Same logic also lives in the
|
||||
// classic loldle package — duplication accepted for now (two callers); a
|
||||
// shared `internal/champname` helper makes sense once the next loldle
|
||||
// variant lands.
|
||||
func normalize(s string) string {
|
||||
lower := strings.ToLower(s)
|
||||
out := make([]byte, 0, len(lower))
|
||||
for i := 0; i < len(lower); i++ {
|
||||
c := lower[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
@@ -45,16 +46,15 @@ func pingCommand(deps modules.Deps) modules.Command {
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Health check — replies pong and records last ping",
|
||||
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
if update.Message == nil {
|
||||
return nil
|
||||
}
|
||||
// Best-effort write — if KV is unavailable, still reply.
|
||||
payload := lastPing{At: time.Now().UTC().UnixMilli()}
|
||||
payload := lastPing{At: chathelper.NowMillis()}
|
||||
if err := deps.KV.PutJSON(ctx, lastPingKey, payload); err != nil {
|
||||
log.Printf("misc /ping: putJSON failed: %v", err)
|
||||
}
|
||||
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: update.Message.Chat.ID,
|
||||
Text: "pong",
|
||||
})
|
||||
return err
|
||||
return chathelper.Reply(ctx, b, update.Message.Chat.ID, "pong")
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,9 @@ func mstatsCommand(deps modules.Deps) modules.Command {
|
||||
Visibility: modules.VisibilityProtected,
|
||||
Description: "Show the timestamp of the last /ping",
|
||||
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
if update.Message == nil {
|
||||
return nil
|
||||
}
|
||||
var last lastPing
|
||||
text := "last ping: never"
|
||||
err := deps.KV.GetJSON(ctx, lastPingKey, &last)
|
||||
@@ -75,11 +78,7 @@ func mstatsCommand(deps modules.Deps) modules.Command {
|
||||
case err != nil && !errors.Is(err, storage.ErrNotFound):
|
||||
return fmt.Errorf("misc /mstats: %w", err)
|
||||
}
|
||||
_, err = b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: update.Message.Chat.ID,
|
||||
Text: text,
|
||||
})
|
||||
return err
|
||||
return chathelper.Reply(ctx, b, update.Message.Chat.ID, text)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -90,11 +89,10 @@ func fortytwoCommand() modules.Command {
|
||||
Visibility: modules.VisibilityPrivate,
|
||||
Description: "Easter egg — the answer",
|
||||
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: update.Message.Chat.ID,
|
||||
Text: "The answer.",
|
||||
})
|
||||
return err
|
||||
if update.Message == nil {
|
||||
return nil
|
||||
}
|
||||
return chathelper.Reply(ctx, b, update.Message.Chat.ID, "The answer.")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package chathelper consolidates per-module Telegram helpers that diverged
|
||||
// across wordle/loldle/loldleemoji/misc. SubjectFor, ArgAfterCommand,
|
||||
// NowMillis, Reply, ReplyHTML, WinRate previously had near-identical copies
|
||||
// in each module — one drift incident (winRate truncation) already bit
|
||||
// Phase 5b/5c. Single source here; modules import.
|
||||
package chathelper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
)
|
||||
|
||||
// SubjectFor mirrors JS getSubject: group/supergroup → chat ID (shared game
|
||||
// state), otherwise → user ID. Returns "" when no usable id is present
|
||||
// (caller should reply with a "cannot identify chat" error). Channels and
|
||||
// unknown chat types fall through to From.ID.
|
||||
//
|
||||
// Canonical shape: the wordle module previously had an explicit
|
||||
// ChatTypePrivate branch returning From.ID, which is identical to the
|
||||
// default branch — folded together here.
|
||||
func SubjectFor(msg *models.Message) string {
|
||||
if msg == nil {
|
||||
return ""
|
||||
}
|
||||
switch msg.Chat.Type {
|
||||
case models.ChatTypeGroup, models.ChatTypeSupergroup:
|
||||
return strconv.FormatInt(msg.Chat.ID, 10)
|
||||
default:
|
||||
if msg.From != nil {
|
||||
return strconv.FormatInt(msg.From.ID, 10)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ArgAfterCommand returns everything after the first space in text, trimmed.
|
||||
// Works for `/cmd arg`, `/cmd@bot arg`, etc. JS-parity.
|
||||
func ArgAfterCommand(text string) string {
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
idx := strings.IndexByte(text, ' ')
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(text[idx+1:])
|
||||
}
|
||||
|
||||
// NowMillis returns current UTC ms-since-epoch.
|
||||
func NowMillis() int64 { return time.Now().UTC().UnixMilli() }
|
||||
|
||||
// Reply sends a plain-text response to the given chat.
|
||||
func Reply(ctx context.Context, b *bot.Bot, chatID int64, text string) error {
|
||||
_, err := b.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text})
|
||||
return err
|
||||
}
|
||||
|
||||
// ReplyHTML sends a Telegram HTML-formatted response to the given chat.
|
||||
func ReplyHTML(ctx context.Context, b *bot.Bot, chatID int64, text string) error {
|
||||
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: chatID,
|
||||
Text: text,
|
||||
ParseMode: models.ParseModeHTML,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// WinRate computes wins/played as a percentage rounded to nearest int.
|
||||
// math.Round matches JS Math.round (round half away from zero for positive
|
||||
// inputs); plain int(...) truncation would render 2/3 as 66% where JS shows
|
||||
// 67%. Returns 0 when played == 0 (avoids NaN).
|
||||
func WinRate(wins, played int) int {
|
||||
if played <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(math.Round(float64(wins) / float64(played) * 100))
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package chathelper
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
)
|
||||
|
||||
func TestSubjectFor(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg *models.Message
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil message",
|
||||
msg: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "private chat with From",
|
||||
msg: &models.Message{
|
||||
Chat: models.Chat{ID: 999, Type: models.ChatTypePrivate},
|
||||
From: &models.User{ID: 42},
|
||||
},
|
||||
want: "42",
|
||||
},
|
||||
{
|
||||
name: "private chat without From",
|
||||
msg: &models.Message{
|
||||
Chat: models.Chat{ID: 999, Type: models.ChatTypePrivate},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "group chat → chat id (ignores From)",
|
||||
msg: &models.Message{
|
||||
Chat: models.Chat{ID: -100, Type: models.ChatTypeGroup},
|
||||
From: &models.User{ID: 42},
|
||||
},
|
||||
want: "-100",
|
||||
},
|
||||
{
|
||||
name: "supergroup → chat id",
|
||||
msg: &models.Message{
|
||||
Chat: models.Chat{ID: -1001, Type: models.ChatTypeSupergroup},
|
||||
From: &models.User{ID: 42},
|
||||
},
|
||||
want: "-1001",
|
||||
},
|
||||
{
|
||||
name: "channel falls through to From.ID",
|
||||
msg: &models.Message{
|
||||
Chat: models.Chat{ID: -200, Type: models.ChatTypeChannel},
|
||||
From: &models.User{ID: 7},
|
||||
},
|
||||
want: "7",
|
||||
},
|
||||
{
|
||||
name: "channel without From",
|
||||
msg: &models.Message{
|
||||
Chat: models.Chat{ID: -200, Type: models.ChatTypeChannel},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := SubjectFor(tt.msg); got != tt.want {
|
||||
t.Errorf("SubjectFor: got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgAfterCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"/cmd", ""},
|
||||
{"/cmd ", ""},
|
||||
{"/cmd ", ""},
|
||||
{"/cmd word", "word"},
|
||||
{"/cmd word ", "word"},
|
||||
{"/cmd@bot word", "word"},
|
||||
{"/cmd two words", "two words"},
|
||||
{"/cmd two words ", "two words"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.in, func(t *testing.T) {
|
||||
if got := ArgAfterCommand(tt.in); got != tt.want {
|
||||
t.Errorf("ArgAfterCommand(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNowMillis(t *testing.T) {
|
||||
a := NowMillis()
|
||||
b := NowMillis()
|
||||
if b < a {
|
||||
t.Errorf("NowMillis went backwards: %d → %d", a, b)
|
||||
}
|
||||
if a < 1700000000000 {
|
||||
t.Errorf("NowMillis too small (not ms-epoch?): %d", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWinRate(t *testing.T) {
|
||||
tests := []struct {
|
||||
wins, played, want int
|
||||
}{
|
||||
{0, 0, 0}, // no games
|
||||
{0, 5, 0}, // 0%
|
||||
{5, 5, 100}, // 100%
|
||||
{2, 3, 67}, // round-half-up: 66.67% → 67% (NOT 66%)
|
||||
{1, 3, 33}, // 33.33% → 33%
|
||||
{1, 6, 17}, // 16.67% → 17%
|
||||
{1, 2, 50}, // exact 50%
|
||||
{3, 4, 75}, // exact 75%
|
||||
// negative played guards against caller bugs.
|
||||
{1, -1, 0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := WinRate(tt.wins, tt.played)
|
||||
if got != tt.want {
|
||||
t.Errorf("WinRate(%d,%d) = %d, want %d", tt.wins, tt.played, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
|
||||
)
|
||||
|
||||
// infoCommand returns /info — replies plain text with chat / thread / sender
|
||||
@@ -36,11 +37,7 @@ func infoCommand() modules.Command {
|
||||
senderID = fmt.Sprintf("%d", msg.From.ID)
|
||||
}
|
||||
text := fmt.Sprintf("chat id: %s\nthread id: %s\nsender id: %s", chatID, threadID, senderID)
|
||||
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: msg.Chat.ID,
|
||||
Text: text,
|
||||
})
|
||||
return err
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, text)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
|
||||
)
|
||||
|
||||
const stickerIDUsage = "Reply to a sticker message with /stickerid to get its file_id.\n" +
|
||||
@@ -31,11 +32,7 @@ func stickerIDCommand() modules.Command {
|
||||
|
||||
sticker := stickerFrom(msg)
|
||||
if sticker == nil {
|
||||
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: msg.Chat.ID,
|
||||
Text: stickerIDUsage,
|
||||
})
|
||||
return err
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, stickerIDUsage)
|
||||
}
|
||||
|
||||
setName := sticker.SetName
|
||||
@@ -55,12 +52,7 @@ func stickerIDCommand() modules.Command {
|
||||
fmt.Fprintf(&sb, "set: %s · emoji: %s",
|
||||
html.EscapeString(setName), html.EscapeString(emoji))
|
||||
|
||||
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: msg.Chat.ID,
|
||||
Text: sb.String(),
|
||||
ParseMode: models.ParseModeHTML,
|
||||
})
|
||||
return err
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, sb.String())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,13 @@ package wordle
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/keylock"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
@@ -24,41 +22,6 @@ type state struct {
|
||||
locks keylock.Map // per-subject mutex; serialises Get→mutate→Put
|
||||
}
|
||||
|
||||
// subjectFor mirrors JS getSubject: per-user in DMs, per-chat in groups,
|
||||
// per-user fallback for channels and unknown types. Returns an empty string
|
||||
// when no usable subject id is present (caller replies with an error).
|
||||
func subjectFor(msg *models.Message) string {
|
||||
if msg == nil {
|
||||
return ""
|
||||
}
|
||||
switch msg.Chat.Type {
|
||||
case models.ChatTypePrivate:
|
||||
if msg.From != nil {
|
||||
return strconv.FormatInt(msg.From.ID, 10)
|
||||
}
|
||||
case models.ChatTypeGroup, models.ChatTypeSupergroup:
|
||||
return strconv.FormatInt(msg.Chat.ID, 10)
|
||||
default:
|
||||
if msg.From != nil {
|
||||
return strconv.FormatInt(msg.From.ID, 10)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// argAfterCommand returns everything after the first space in text, trimmed.
|
||||
// JS-parity. Works for `/wordle apple`, `/wordle@bot apple`, etc.
|
||||
func argAfterCommand(text string) string {
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
idx := strings.IndexByte(text, ' ')
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(text[idx+1:])
|
||||
}
|
||||
|
||||
// rejectMessage maps a validation failure into the user-facing reply. JS
|
||||
// parity word-for-word.
|
||||
func rejectMessage(reason rejectReason) string {
|
||||
@@ -72,20 +35,6 @@ func rejectMessage(reason rejectReason) string {
|
||||
}
|
||||
}
|
||||
|
||||
// reply is a tiny helper so the four handlers don't all repeat the same
|
||||
// SendMessage incantation. Returns the SendMessage error to the dispatcher.
|
||||
func reply(ctx context.Context, b *bot.Bot, msg *models.Message, text string) error {
|
||||
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: msg.Chat.ID,
|
||||
Text: text,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// nowMillis returns current UTC ms-since-epoch — single source of "now" so
|
||||
// tests can substitute via the kv-state path if needed.
|
||||
func nowMillis() int64 { return time.Now().UTC().UnixMilli() }
|
||||
|
||||
// startFresh writes a brand-new round and returns it. Errors propagate to
|
||||
// the caller (not swallowed — a KV failure means subsequent ops will lie).
|
||||
func (s *state) startFresh(ctx context.Context, subject string) (*GameState, error) {
|
||||
@@ -98,7 +47,7 @@ func (s *state) startFresh(ctx context.Context, subject string) (*GameState, err
|
||||
Guesses: []GuessRecord{},
|
||||
Solved: false,
|
||||
Giveup: false,
|
||||
StartedAt: nowMillis(),
|
||||
StartedAt: chathelper.NowMillis(),
|
||||
}
|
||||
if err := saveGame(ctx, s.kv, subject, g); err != nil {
|
||||
return nil, err
|
||||
@@ -123,12 +72,12 @@ func (s *state) handleWordle(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := subjectFor(msg)
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return reply(ctx, b, msg, "Cannot identify chat.")
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
arg := argAfterCommand(msg.Text)
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
|
||||
g, err := s.getOrInit(ctx, subject)
|
||||
if err != nil {
|
||||
@@ -145,17 +94,17 @@ func (s *state) handleWordle(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
default:
|
||||
header = fmt.Sprintf("Guess %d/%d. Use `/wordle <word>`.", len(g.Guesses), MaxGuesses)
|
||||
}
|
||||
return reply(ctx, b, msg, header+"\n\n"+renderBoard(g.Guesses))
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, header+"\n\n"+renderBoard(g.Guesses))
|
||||
}
|
||||
|
||||
if isFinished(g) {
|
||||
return reply(ctx, b, msg,
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("Current round is over. Use /wordle_new to start another. Answer was %s.", strings.ToUpper(g.Target)))
|
||||
}
|
||||
|
||||
v := validateGuess(s.set, arg)
|
||||
if !v.OK {
|
||||
return reply(ctx, b, msg, rejectMessage(v.Reason))
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, rejectMessage(v.Reason))
|
||||
}
|
||||
|
||||
results := CompareWords(v.Word, g.Target)
|
||||
@@ -171,20 +120,20 @@ func (s *state) handleWordle(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
rendered := renderGuess(v.Word, results)
|
||||
switch {
|
||||
case won:
|
||||
stats, err := recordResult(ctx, s.kv, subject, true, nowMillis())
|
||||
stats, err := recordResult(ctx, s.kv, subject, true, chathelper.NowMillis())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return reply(ctx, b, msg, fmt.Sprintf("%s\n\n🎉 Solved in %d/%d! Streak: %d. /wordle_new for another.",
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("%s\n\n🎉 Solved in %d/%d! Streak: %d. /wordle_new for another.",
|
||||
rendered, len(g.Guesses), MaxGuesses, stats.Streak))
|
||||
case len(g.Guesses) >= MaxGuesses:
|
||||
if _, err := recordResult(ctx, s.kv, subject, false, nowMillis()); err != nil {
|
||||
if _, err := recordResult(ctx, s.kv, subject, false, chathelper.NowMillis()); err != nil {
|
||||
return err
|
||||
}
|
||||
return reply(ctx, b, msg, fmt.Sprintf("%s\n\n❌ Out of guesses. Answer was %s. /wordle_new to retry.",
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("%s\n\n❌ Out of guesses. Answer was %s. /wordle_new to retry.",
|
||||
rendered, strings.ToUpper(g.Target)))
|
||||
default:
|
||||
return reply(ctx, b, msg, fmt.Sprintf("%s\n\nGuess %d/%d.", rendered, len(g.Guesses), MaxGuesses))
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("%s\n\nGuess %d/%d.", rendered, len(g.Guesses), MaxGuesses))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,9 +144,9 @@ func (s *state) handleNew(ctx context.Context, b *bot.Bot, update *models.Update
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := subjectFor(msg)
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return reply(ctx, b, msg, "Cannot identify chat.")
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
|
||||
@@ -207,7 +156,7 @@ func (s *state) handleNew(ctx context.Context, b *bot.Bot, update *models.Update
|
||||
return err
|
||||
}
|
||||
if prior != nil && !isFinished(prior) {
|
||||
if _, err := recordResult(ctx, s.kv, subject, false, nowMillis()); err != nil {
|
||||
if _, err := recordResult(ctx, s.kv, subject, false, chathelper.NowMillis()); err != nil {
|
||||
return err
|
||||
}
|
||||
prelude = fmt.Sprintf("🏳️ Previous round abandoned (auto-giveup). Answer was %s.\n\n",
|
||||
@@ -217,7 +166,7 @@ func (s *state) handleNew(ctx context.Context, b *bot.Bot, update *models.Update
|
||||
if _, err := s.startFresh(ctx, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return reply(ctx, b, msg, prelude+"🆕 New round started. Use `/wordle <word>` to guess.")
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, prelude+"🆕 New round started. Use `/wordle <word>` to guess.")
|
||||
}
|
||||
|
||||
// handleGiveup is /wordle_giveup — reveals answer for the current round.
|
||||
@@ -227,9 +176,9 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := subjectFor(msg)
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return reply(ctx, b, msg, "Cannot identify chat.")
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
g, err := s.getOrInit(ctx, subject)
|
||||
@@ -237,19 +186,19 @@ func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
return err
|
||||
}
|
||||
if g.Solved {
|
||||
return reply(ctx, b, msg, fmt.Sprintf("Already solved — %s.", strings.ToUpper(g.Target)))
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Already solved — %s.", strings.ToUpper(g.Target)))
|
||||
}
|
||||
if g.Giveup {
|
||||
return reply(ctx, b, msg, fmt.Sprintf("Already gave up — %s.", strings.ToUpper(g.Target)))
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Already gave up — %s.", strings.ToUpper(g.Target)))
|
||||
}
|
||||
g.Giveup = true
|
||||
if err := saveGame(ctx, s.kv, subject, g); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := recordResult(ctx, s.kv, subject, false, nowMillis()); err != nil {
|
||||
if _, err := recordResult(ctx, s.kv, subject, false, chathelper.NowMillis()); err != nil {
|
||||
return err
|
||||
}
|
||||
return reply(ctx, b, msg, fmt.Sprintf("🏳️ Answer was %s. /wordle_new for another.", strings.ToUpper(g.Target)))
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("🏳️ Answer was %s. /wordle_new for another.", strings.ToUpper(g.Target)))
|
||||
}
|
||||
|
||||
// handleStats is /wordle_stats — shows lifetime score for the subject.
|
||||
@@ -258,27 +207,20 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := subjectFor(msg)
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return reply(ctx, b, msg, "Cannot identify chat.")
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
stats, err := loadStats(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
winRate := 0
|
||||
if stats.Played > 0 {
|
||||
// math.Round matches JS Math.round (round half away from zero for
|
||||
// positive inputs); int(...) would truncate 66.66 to 66 where JS
|
||||
// shows 67.
|
||||
winRate = int(math.Round(float64(stats.Wins) / float64(stats.Played) * 100))
|
||||
}
|
||||
scope := "group"
|
||||
if msg.Chat.Type == models.ChatTypePrivate {
|
||||
scope = "your"
|
||||
}
|
||||
return reply(ctx, b, msg, fmt.Sprintf(
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"📊 Wordle %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d",
|
||||
scope, stats.Played, stats.Wins, winRate, stats.Streak, stats.BestStreak,
|
||||
scope, stats.Played, stats.Wins, chathelper.WinRate(stats.Wins, stats.Played), stats.Streak, stats.BestStreak,
|
||||
))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user