diff --git a/cmd/server/main.go b/cmd/server/main.go index 2297614..3d1ad10 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,6 +13,7 @@ import ( "github.com/tiennm99/miti99bot-go/internal/modules" "github.com/tiennm99/miti99bot-go/internal/modules/loldle" + "github.com/tiennm99/miti99bot-go/internal/modules/loldleemoji" "github.com/tiennm99/miti99bot-go/internal/modules/misc" "github.com/tiennm99/miti99bot-go/internal/modules/util" "github.com/tiennm99/miti99bot-go/internal/modules/wordle" @@ -34,10 +35,11 @@ var secretEnvKeys = []string{ // import cycle (modules → util → modules). func factories() map[string]modules.Factory { return map[string]modules.Factory{ - "util": util.New, - "misc": misc.New, - "wordle": wordle.New, - "loldle": loldle.New, + "util": util.New, + "misc": misc.New, + "wordle": wordle.New, + "loldle": loldle.New, + "loldle-emoji": loldleemoji.New, } } diff --git a/internal/modules/loldleemoji/champions.go b/internal/modules/loldleemoji/champions.go new file mode 100644 index 0000000..c7ce76a --- /dev/null +++ b/internal/modules/loldleemoji/champions.go @@ -0,0 +1,44 @@ +// Package loldleemoji ports the JS loldle-emoji variant — guess the +// champion from a short emoji clue. Binary right/wrong scoring (no attribute +// comparison); shares the per-subject lifecycle pattern with classic loldle +// but uses its own KV namespace so stats are isolated. +package loldleemoji + +import ( + _ "embed" + "encoding/json" + "fmt" + "strings" +) + +// EmojiChampion is one record of emojis.json. Only championName + emojis +// matter for this variant; the JS source keeps the same shape so the embed +// file lifts unmodified. +type EmojiChampion struct { + ChampionName string `json:"championName"` + Emojis string `json:"emojis"` +} + +//go:embed data/emojis.json +var rawEmojis []byte + +// loadPool parses emojis.json and drops any record with an empty `emojis` +// string (matching the JS `pool.filter(...)` at the top of handlers.js). +// Panics on malformed data — a corrupt regen of the data file is a build- +// time bug, not a runtime concern worth recovering from. +func loadPool() []EmojiChampion { + var all []EmojiChampion + if err := json.Unmarshal(rawEmojis, &all); err != nil { + panic(fmt.Sprintf("loldleemoji: cannot decode emojis.json: %v", err)) + } + out := make([]EmojiChampion, 0, len(all)) + for _, c := range all { + if strings.TrimSpace(c.Emojis) != "" { + out = append(out, c) + } + } + if len(out) == 0 { + panic("loldleemoji: emojis.json contained no usable records") + } + return out +} diff --git a/internal/modules/loldleemoji/data/emojis.json b/internal/modules/loldleemoji/data/emojis.json new file mode 100644 index 0000000..ed7bc05 --- /dev/null +++ b/internal/modules/loldleemoji/data/emojis.json @@ -0,0 +1,690 @@ +[ + { + "championName": "Aatrox", + "emojis": "⚔️ 🌍 💪" + }, + { + "championName": "Ahri", + "emojis": "🦊 🏯 🔮" + }, + { + "championName": "Akali", + "emojis": "🧝 🏯 ⚡" + }, + { + "championName": "Akshan", + "emojis": "🧝 🏜️ 🔮" + }, + { + "championName": "Alistar", + "emojis": "🐂 🌍 🔮" + }, + { + "championName": "Ambessa", + "emojis": "🧝 ⚙️ ⚡" + }, + { + "championName": "Amumu", + "emojis": "💀 🏜️ 🔮" + }, + { + "championName": "Anivia", + "emojis": "👻 ❄️ 🔮" + }, + { + "championName": "Annie", + "emojis": "🧝 🗡️ 🔮" + }, + { + "championName": "Aphelios", + "emojis": "🧝 ⛰️ 🔮" + }, + { + "championName": "Ashe", + "emojis": "🧝 ❄️ 🔮" + }, + { + "championName": "Aurelion Sol", + "emojis": "🌟 🌍 🔮" + }, + { + "championName": "Aurora", + "emojis": "🦊 ❄️ 🔮" + }, + { + "championName": "Azir", + "emojis": "⚜️ 🏜️ 🔮" + }, + { + "championName": "Bard", + "emojis": "🌟 🌍 🔮" + }, + { + "championName": "Bel'Veth", + "emojis": "👁️ 👾 💪" + }, + { + "championName": "Blitzcrank", + "emojis": "🗿 🧪 🔮" + }, + { + "championName": "Brand", + "emojis": "🧝 ❄️ 🔮" + }, + { + "championName": "Braum", + "emojis": "🧝 ❄️ 🔮" + }, + { + "championName": "Briar", + "emojis": "🗿 🗡️ ❤️‍🩹" + }, + { + "championName": "Caitlyn", + "emojis": "🧝 ⚙️ 🔮" + }, + { + "championName": "Camille", + "emojis": "🧝 ⚙️ 🔮" + }, + { + "championName": "Cassiopeia", + "emojis": "🧝 🗡️ 🔮" + }, + { + "championName": "Cho'Gath", + "emojis": "👁️ 👾 🔮" + }, + { + "championName": "Corki", + "emojis": "🧚 🏡 🔮" + }, + { + "championName": "Darius", + "emojis": "🧝 🗡️ 🔮" + }, + { + "championName": "Diana", + "emojis": "🧝 ⛰️ 🔮" + }, + { + "championName": "Dr. Mundo", + "emojis": "🧝 🧪 ❤️‍🩹" + }, + { + "championName": "Draven", + "emojis": "🧝 🗡️ 🔮" + }, + { + "championName": "Ekko", + "emojis": "🧝 🧪 🔮" + }, + { + "championName": "Elise", + "emojis": "🧝 🌫️ 🔮" + }, + { + "championName": "Evelynn", + "emojis": "😈 🌍 🔮" + }, + { + "championName": "Ezreal", + "emojis": "🧝 ⚙️ 🔮" + }, + { + "championName": "Fiddlesticks", + "emojis": "😈 🌍 🔮" + }, + { + "championName": "Fiora", + "emojis": "🧝 🛡️ 🔮" + }, + { + "championName": "Fizz", + "emojis": "🧚 ⚓ 🔮" + }, + { + "championName": "Galio", + "emojis": "🗿 🛡️ 🔮" + }, + { + "championName": "Gangplank", + "emojis": "🧝 ⚓ 🔮" + }, + { + "championName": "Garen", + "emojis": "🧝 🛡️ 💪" + }, + { + "championName": "Gnar", + "emojis": "🧚 ❄️ 😡" + }, + { + "championName": "Gragas", + "emojis": "🧝 ❄️ 🔮" + }, + { + "championName": "Graves", + "emojis": "🧝 ⚓ 🔮" + }, + { + "championName": "Gwen", + "emojis": "🧝 🌫️ 🔮" + }, + { + "championName": "Hecarim", + "emojis": "💀 🌫️ 🔮" + }, + { + "championName": "Heimerdinger", + "emojis": "🧚 ⚙️ 🔮" + }, + { + "championName": "Hwei", + "emojis": "🧝 🏯 🔮" + }, + { + "championName": "Illaoi", + "emojis": "🧝 ⚓ 🔮" + }, + { + "championName": "Irelia", + "emojis": "🧝 🏯 🔮" + }, + { + "championName": "Ivern", + "emojis": "🧝 🏯 🔮" + }, + { + "championName": "Janna", + "emojis": "👻 🧪 🔮" + }, + { + "championName": "Jarvan IV", + "emojis": "🧝 🛡️ 🔮" + }, + { + "championName": "Jax", + "emojis": "❓ 🌍 🔮" + }, + { + "championName": "Jayce", + "emojis": "🧝 ⚙️ 🔮" + }, + { + "championName": "Jhin", + "emojis": "🧝 🏯 🔮" + }, + { + "championName": "Jinx", + "emojis": "🧝 🧪 🔮" + }, + { + "championName": "K'Sante", + "emojis": "🧝 🏜️ 🔮" + }, + { + "championName": "Kai'Sa", + "emojis": "🧝 🏜️ 🔮" + }, + { + "championName": "Kalista", + "emojis": "💀 🌫️ 🔮" + }, + { + "championName": "Karma", + "emojis": "🧝 🏯 🔮" + }, + { + "championName": "Karthus", + "emojis": "💀 🌫️ 🔮" + }, + { + "championName": "Kassadin", + "emojis": "🧝 🏜️ 🔮" + }, + { + "championName": "Katarina", + "emojis": "🧝 🗡️ 💪" + }, + { + "championName": "Kayle", + "emojis": "🧝 🛡️ 🔮" + }, + { + "championName": "Kayn", + "emojis": "⚔️ 🏯 🔮" + }, + { + "championName": "Kennen", + "emojis": "🧚 🏯 ⚡" + }, + { + "championName": "Kha'Zix", + "emojis": "👁️ 👾 🔮" + }, + { + "championName": "Kindred", + "emojis": "👻 🌍 🔮" + }, + { + "championName": "Kled", + "emojis": "🧚 🗡️ 🦁" + }, + { + "championName": "Kog'Maw", + "emojis": "👁️ 👾 🔮" + }, + { + "championName": "LeBlanc", + "emojis": "🧝 🗡️ 🔮" + }, + { + "championName": "Lee Sin", + "emojis": "🧝 🏯 ⚡" + }, + { + "championName": "Leona", + "emojis": "🧝 ⛰️ 🔮" + }, + { + "championName": "Lillia", + "emojis": "👻 🏯 🔮" + }, + { + "championName": "Lissandra", + "emojis": "🧝 ❄️ 🔮" + }, + { + "championName": "Lucian", + "emojis": "🧝 🛡️ 🔮" + }, + { + "championName": "Lulu", + "emojis": "🧚 🏡 🔮" + }, + { + "championName": "Lux", + "emojis": "🧝 🛡️ 🔮" + }, + { + "championName": "Malphite", + "emojis": "🗿 🌿 🔮" + }, + { + "championName": "Malzahar", + "emojis": "🧝 🏜️ 🔮" + }, + { + "championName": "Maokai", + "emojis": "👻 🌫️ 🔮" + }, + { + "championName": "Master Yi", + "emojis": "🧝 🏯 🔮" + }, + { + "championName": "Mel", + "emojis": "🧝 🗡️ 🔮" + }, + { + "championName": "Milio", + "emojis": "🧝 🌿 🔮" + }, + { + "championName": "Miss Fortune", + "emojis": "🧝 ⚓ 🔮" + }, + { + "championName": "Mordekaiser", + "emojis": "👻 🗡️ 🛡️" + }, + { + "championName": "Morgana", + "emojis": "🧝 🛡️ 🔮" + }, + { + "championName": "Naafiri", + "emojis": "🐕 🏜️ 🔮" + }, + { + "championName": "Nami", + "emojis": "🦊 🌍 🔮" + }, + { + "championName": "Nasus", + "emojis": "⚜️ 🏜️ 🔮" + }, + { + "championName": "Nautilus", + "emojis": "👻 ⚓ 🔮" + }, + { + "championName": "Neeko", + "emojis": "🦊 🌿 🔮" + }, + { + "championName": "Nidalee", + "emojis": "🧝 🌿 🔮" + }, + { + "championName": "Nilah", + "emojis": "🧝 ⚓ 🔮" + }, + { + "championName": "Nocturne", + "emojis": "😈 🌍 🔮" + }, + { + "championName": "Nunu & Willump", + "emojis": "🧝 ❄️ 🔮" + }, + { + "championName": "Olaf", + "emojis": "🧝 ❄️ 🔮" + }, + { + "championName": "Orianna", + "emojis": "🗿 ⚙️ 🔮" + }, + { + "championName": "Ornn", + "emojis": "👻 ❄️ 🔮" + }, + { + "championName": "Pantheon", + "emojis": "🧝 ⛰️ 🔮" + }, + { + "championName": "Poppy", + "emojis": "🧚 🛡️ 🔮" + }, + { + "championName": "Pyke", + "emojis": "👻 ⚓ 🔮" + }, + { + "championName": "Qiyana", + "emojis": "🧝 🌿 🔮" + }, + { + "championName": "Quinn", + "emojis": "🧝 🛡️ 🔮" + }, + { + "championName": "Rakan", + "emojis": "🦊 🏯 🔮" + }, + { + "championName": "Rammus", + "emojis": "❓ 🏜️ 🔮" + }, + { + "championName": "Rek'Sai", + "emojis": "👁️ 🏜️ 😡" + }, + { + "championName": "Rell", + "emojis": "🧝 🗡️ 🔮" + }, + { + "championName": "Renata Glasc", + "emojis": "🧝 🧪 🔮" + }, + { + "championName": "Renekton", + "emojis": "⚜️ 🏜️ 💢" + }, + { + "championName": "Rengar", + "emojis": "🦊 🌿 🔥" + }, + { + "championName": "Riven", + "emojis": "🧝 🏯 💪" + }, + { + "championName": "Rumble", + "emojis": "🧚 🏡 🔥" + }, + { + "championName": "Ryze", + "emojis": "🧝 🌍 🔮" + }, + { + "championName": "Samira", + "emojis": "🧝 🏜️ 🔮" + }, + { + "championName": "Sejuani", + "emojis": "🧝 ❄️ 🔮" + }, + { + "championName": "Senna", + "emojis": "🧝 🌫️ 🔮" + }, + { + "championName": "Seraphine", + "emojis": "🧝 ⚙️ 🔮" + }, + { + "championName": "Sett", + "emojis": "🧝 🏯 🪨" + }, + { + "championName": "Shaco", + "emojis": "👻 🌍 🔮" + }, + { + "championName": "Shen", + "emojis": "🧝 🏯 ⚡" + }, + { + "championName": "Shyvana", + "emojis": "🐉 🛡️ 💢" + }, + { + "championName": "Singed", + "emojis": "🧝 🧪 🔮" + }, + { + "championName": "Sion", + "emojis": "👻 🗡️ 🔮" + }, + { + "championName": "Sivir", + "emojis": "🧝 🏜️ 🔮" + }, + { + "championName": "Skarner", + "emojis": "🦂 🌿 🔮" + }, + { + "championName": "Smolder", + "emojis": "🐉 ⚜️ 🔮" + }, + { + "championName": "Sona", + "emojis": "🧝 🛡️ 🔮" + }, + { + "championName": "Soraka", + "emojis": "🌟 ⛰️ 🔮" + }, + { + "championName": "Swain", + "emojis": "🧝 🗡️ 🔮" + }, + { + "championName": "Sylas", + "emojis": "🧝 🛡️ 🔮" + }, + { + "championName": "Syndra", + "emojis": "🧝 🏯 🔮" + }, + { + "championName": "Tahm Kench", + "emojis": "😈 ⚓ 🔮" + }, + { + "championName": "Taliyah", + "emojis": "🧝 🏜️ 🔮" + }, + { + "championName": "Talon", + "emojis": "🧝 🗡️ 🔮" + }, + { + "championName": "Taric", + "emojis": "🧝 ⛰️ 🔮" + }, + { + "championName": "Teemo", + "emojis": "🧚 🏡 🔮" + }, + { + "championName": "Thresh", + "emojis": "💀 🌫️ 🔮" + }, + { + "championName": "Tristana", + "emojis": "🧚 🏡 🔮" + }, + { + "championName": "Trundle", + "emojis": "🧌 ❄️ 🔮" + }, + { + "championName": "Tryndamere", + "emojis": "🧝 ❄️ 💢" + }, + { + "championName": "Twisted Fate", + "emojis": "🧝 ⚓ 🔮" + }, + { + "championName": "Twitch", + "emojis": "🐀 🧪 🔮" + }, + { + "championName": "Udyr", + "emojis": "🧝 ❄️ 🔮" + }, + { + "championName": "Urgot", + "emojis": "🧝 🗡️ 🔮" + }, + { + "championName": "Varus", + "emojis": "⚔️ 🏯 🔮" + }, + { + "championName": "Vayne", + "emojis": "🧝 🛡️ 🔮" + }, + { + "championName": "Veigar", + "emojis": "🧚 🌍 🔮" + }, + { + "championName": "Vel'Koz", + "emojis": "👁️ 👾 🔮" + }, + { + "championName": "Vex", + "emojis": "🧚 🌫️ 🔮" + }, + { + "championName": "Vi", + "emojis": "🧝 ⚙️ 🔮" + }, + { + "championName": "Viego", + "emojis": "💀 🌫️ 💪" + }, + { + "championName": "Viktor", + "emojis": "🧝 ⚙️ 🔮" + }, + { + "championName": "Vladimir", + "emojis": "🧝 🗡️ 🩸" + }, + { + "championName": "Volibear", + "emojis": "👻 ❄️ 🔮" + }, + { + "championName": "Warwick", + "emojis": "🧝 🧪 🔮" + }, + { + "championName": "Wukong", + "emojis": "🦊 🏯 🔮" + }, + { + "championName": "Xayah", + "emojis": "🦊 🏯 🔮" + }, + { + "championName": "Xerath", + "emojis": "💀 🏜️ 🔮" + }, + { + "championName": "Xin Zhao", + "emojis": "🧝 🛡️ 🔮" + }, + { + "championName": "Yasuo", + "emojis": "🧝 🏯 💧" + }, + { + "championName": "Yone", + "emojis": "🧝 🏯 💪" + }, + { + "championName": "Yorick", + "emojis": "🧝 🌫️ 🔮" + }, + { + "championName": "Yunara", + "emojis": "🧝 🏯 🔮" + }, + { + "championName": "Yuumi", + "emojis": "🐱 🏡 🔮" + }, + { + "championName": "Zaahen", + "emojis": "⚔️ 🏜️ 🔮" + }, + { + "championName": "Zac", + "emojis": "🗿 🧪 ❤️‍🩹" + }, + { + "championName": "Zed", + "emojis": "🧝 🏯 ⚡" + }, + { + "championName": "Zeri", + "emojis": "🧝 🧪 🔮" + }, + { + "championName": "Ziggs", + "emojis": "🧚 🧪 🔮" + }, + { + "championName": "Zilean", + "emojis": "🧝 🌍 🔮" + }, + { + "championName": "Zoe", + "emojis": "🧝 ⛰️ 🔮" + }, + { + "championName": "Zyra", + "emojis": "🧝 🌿 🔮" + } +] diff --git a/internal/modules/loldleemoji/handlers.go b/internal/modules/loldleemoji/handlers.go new file mode 100644 index 0000000..cad874f --- /dev/null +++ b/internal/modules/loldleemoji/handlers.go @@ -0,0 +1,269 @@ +package loldleemoji + +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/keylock" + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +const newRoundHint = "🆕 Send /loldle_emoji or /loldle_emoji <champion> to start a new round." + +// state captures everything a loldle-emoji handler needs at runtime. Built +// once per Factory call and shared across the four command closures. +type state struct { + kv storage.KVStore + pool []EmojiChampion + 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:]) +} + +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} + if err := saveGame(ctx, s.kv, subject, g); err != nil { + return nil, err + } + return g, nil +} + +func (s *state) getOrInitGame(ctx context.Context, subject string, maxGuesses int) (*gameState, error) { + existing, err := loadGame(ctx, s.kv, subject) + if err != nil { + return nil, err + } + if existing != nil && len(existing.Guesses) < maxGuesses { + return existing, nil + } + 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) + if subject == "" { + return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") + } + defer s.locks.Acquire(subject)() + arg := argAfterCommand(msg.Text) + + maxGuesses, err := getMaxGuesses(ctx, s.kv, subject) + if err != nil { + return err + } + game, err := s.getOrInitGame(ctx, subject, maxGuesses) + if err != nil { + return err + } + target := findByExactName(s.pool, game.Target) + 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, + "Emoji data was updated since this round started. "+newRoundHint) + } + + if arg == "" { + return replyHTML(ctx, b, msg.Chat.ID, renderBoard(target.Emojis, game.Guesses, maxGuesses)) + } + + guess := findChampion(s.pool, arg) + if guess == nil { + return 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( + "🔁 %s was already guessed this round — try another champion.", + html.EscapeString(guess.ChampionName))) + } + } + + if game.StartedAt == nil { + now := nowMillis() + game.StartedAt = &now + } + game.Guesses = append(game.Guesses, guess.ChampionName) + won := guess.ChampionName == target.ChampionName + answer := html.EscapeString(target.ChampionName) + + switch { + case won: + st, err := recordResult(ctx, s.kv, subject, true) + if err != nil { + return err + } + if err := clearGame(ctx, s.kv, subject); err != nil { + return err + } + return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( + "🎉 Got it! %s — solved in %d/%d\n🔥 Streak: %d\n%s", + answer, len(game.Guesses), maxGuesses, st.Streak, newRoundHint)) + + case len(game.Guesses) >= maxGuesses: + if _, err := recordResult(ctx, s.kv, subject, false); err != nil { + return err + } + if err := clearGame(ctx, s.kv, subject); err != nil { + return err + } + return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( + "%s\n\n❌ Out of guesses. Answer was %s.\n%s", + renderBoard(target.Emojis, game.Guesses, maxGuesses), answer, newRoundHint)) + + default: + if err := saveGame(ctx, s.kv, subject, game); err != nil { + return err + } + return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( + "%s\n\n❌ Not %s. Guess %d/%d.", + renderBoard(target.Emojis, game.Guesses, maxGuesses), + html.EscapeString(guess.ChampionName), len(game.Guesses), maxGuesses)) + } +} + +// handleGiveup is /loldle_emoji_giveup — reveal answer + clear round. +func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + subject := subjectFor(msg) + if subject == "" { + return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") + } + defer s.locks.Acquire(subject)() + + existing, err := loadGame(ctx, s.kv, subject) + if err != nil { + return err + } + if existing == nil { + return replyHTML(ctx, b, msg.Chat.ID, "No active round. "+newRoundHint) + } + if _, err := recordResult(ctx, s.kv, subject, false); err != nil { + return err + } + if err := clearGame(ctx, s.kv, subject); err != nil { + return err + } + return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( + "🏳️ Answer was %s.\n%s", html.EscapeString(existing.Target), newRoundHint)) +} + +// handleStats is /loldle_emoji_stats — lifetime score. +func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + subject := subjectFor(msg) + if subject == "" { + return 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( + "📊 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)) +} + +// handleSetMax is /loldle_emoji_setmax — private; per-subject override. +func (s *state) handleSetMax(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + subject := subjectFor(msg) + if subject == "" { + return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") + } + arg := 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)) + } + 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)) +} diff --git a/internal/modules/loldleemoji/loldleemoji.go b/internal/modules/loldleemoji/loldleemoji.go new file mode 100644 index 0000000..23fd5de --- /dev/null +++ b/internal/modules/loldleemoji/loldleemoji.go @@ -0,0 +1,39 @@ +package loldleemoji + +import ( + "github.com/tiennm99/miti99bot-go/internal/modules" +) + +// New is the loldle-emoji module Factory. Loads the embedded pool once and +// shares it (plus the per-subject lock map) across all handlers. +func New(deps modules.Deps) modules.Module { + s := &state{kv: deps.KV, pool: loadPool()} + return modules.Module{ + Commands: []modules.Command{ + { + Name: "loldle_emoji", + Visibility: modules.VisibilityPublic, + Description: "Emoji loldle — guess the champion from emojis", + Handler: s.handleEmoji, + }, + { + Name: "loldle_emoji_giveup", + Visibility: modules.VisibilityPublic, + Description: "Reveal the current emoji loldle answer", + Handler: s.handleGiveup, + }, + { + Name: "loldle_emoji_stats", + Visibility: modules.VisibilityPublic, + Description: "Show your emoji loldle stats (wins, streak)", + Handler: s.handleStats, + }, + { + Name: "loldle_emoji_setmax", + Visibility: modules.VisibilityPrivate, + Description: "Override emoji loldle max guesses per round (1-10)", + Handler: s.handleSetMax, + }, + }, + } +} diff --git a/internal/modules/loldleemoji/lookup.go b/internal/modules/loldleemoji/lookup.go new file mode 100644 index 0000000..4a9e922 --- /dev/null +++ b/internal/modules/loldleemoji/lookup.go @@ -0,0 +1,39 @@ +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 +} diff --git a/internal/modules/loldleemoji/lookup_test.go b/internal/modules/loldleemoji/lookup_test.go new file mode 100644 index 0000000..4e77e29 --- /dev/null +++ b/internal/modules/loldleemoji/lookup_test.go @@ -0,0 +1,74 @@ +package loldleemoji + +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) + } +} + +func TestLoadPool_DropsEmptyEmojiRecords(t *testing.T) { + pool := loadPool() + if n := len(pool); n < 150 || n > 200 { + t.Errorf("pool size = %d, want ~172", n) + } + for _, c := range pool { + if c.Emojis == "" { + t.Errorf("empty-emoji record leaked through filter: %s", c.ChampionName) + } + } + // Spot-check a known champion. + if got := findByExactName(pool, "Aatrox"); got == nil { + t.Error("expected Aatrox in pool") + } +} diff --git a/internal/modules/loldleemoji/normalize.go b/internal/modules/loldleemoji/normalize.go new file mode 100644 index 0000000..efed520 --- /dev/null +++ b/internal/modules/loldleemoji/normalize.go @@ -0,0 +1,20 @@ +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) +} diff --git a/internal/modules/loldleemoji/render.go b/internal/modules/loldleemoji/render.go new file mode 100644 index 0000000..2321735 --- /dev/null +++ b/internal/modules/loldleemoji/render.go @@ -0,0 +1,30 @@ +package loldleemoji + +import ( + "fmt" + "html" + "strings" +) + +// renderBoard formats the emoji clue + the wrong-guess list. JS-faithful: +// +// 🎭 ⚔️ 🌍 💪 +// +// Guesses (2/5): +// • Aatrox ❌ +// • Ahri ❌ +// +// Empty board returns the placeholder hint. emojis is the target's emoji +// string (already safe — emojis aren't HTML-escaped in the JS source either). +func renderBoard(emojis string, guesses []string, maxGuesses int) string { + clue := "🎭 " + emojis + if len(guesses) == 0 { + return clue + "\n\nNo guesses yet. Reply with /loldle_emoji <champion>." + } + lines := make([]string, len(guesses)) + for i, name := range guesses { + lines[i] = " • " + html.EscapeString(name) + " ❌" + } + return fmt.Sprintf("%s\n\nGuesses (%d/%d):\n%s", + clue, len(guesses), maxGuesses, strings.Join(lines, "\n")) +} diff --git a/internal/modules/loldleemoji/render_test.go b/internal/modules/loldleemoji/render_test.go new file mode 100644 index 0000000..032f289 --- /dev/null +++ b/internal/modules/loldleemoji/render_test.go @@ -0,0 +1,38 @@ +package loldleemoji + +import ( + "strings" + "testing" +) + +func TestRenderBoard_EmptyShowsHint(t *testing.T) { + out := renderBoard("⚔️ 🌍", nil, 5) + if !strings.Contains(out, "🎭 ⚔️ 🌍") { + t.Errorf("emoji clue missing: %q", out) + } + if !strings.Contains(out, "No guesses yet") { + t.Errorf("placeholder missing: %q", out) + } +} + +func TestRenderBoard_ListsGuessesWithCounter(t *testing.T) { + out := renderBoard("⚔️ 🌍", []string{"Aatrox", "Ahri"}, 5) + if !strings.Contains(out, "Guesses (2/5)") { + t.Errorf("counter missing: %q", out) + } + if !strings.Contains(out, " • Aatrox ❌") { + t.Errorf("first guess line missing: %q", out) + } + if !strings.Contains(out, " • Ahri ❌") { + t.Errorf("second guess line missing: %q", out) + } +} + +func TestRenderBoard_EscapesHTMLInGuessNames(t *testing.T) { + // Champion names from the dict are static strings without HTML metachars, + // but render escapes defensively. Prove it. + out := renderBoard("⚔️", []string{"