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{"