feat(modules): port loldle-emoji

Phase 6a of go-port-cloud-run; first of 5 sub-cooks for Phase 6 loldle
variants. Implements binary right/wrong scoring (no attribute compare).
Per-subject keylock and math/rand.Intn applied from the start, lessons
from prior phase reviews. JS-wire-format decode test added per
code-review concern F#1, locking the migration contract. Helpers
(normalize/subjectFor/argAfterCommand) duplicated from classic loldle;
extraction earmarked for 6b prep.
This commit is contained in:
2026-05-09 12:19:22 +07:00
parent 9e95db2851
commit d9cafdcb30
12 changed files with 1536 additions and 4 deletions
+6 -4
View File
@@ -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,
}
}
+44
View File
@@ -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
}
@@ -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": "🧝 🌿 🔮"
}
]
+269
View File
@@ -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 <code>/loldle_emoji</code> or <code>/loldle_emoji &lt;champion&gt;</code> 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(
"🔁 <b>%s</b> 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! <b>%s</b> — 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 <b>%s</b>.\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 <b>%s</b>. 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 <b>%s</b>.\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 <n> — 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))
}
@@ -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,
},
},
}
}
+39
View File
@@ -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
}
@@ -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")
}
}
+20
View File
@@ -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)
}
+30
View File
@@ -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 <code>/loldle_emoji &lt;champion&gt;</code>."
}
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"))
}
@@ -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{"<script>"}, 5)
if !strings.Contains(out, "&lt;script&gt;") {
t.Errorf("html metachars not escaped: %q", out)
}
}
+129
View File
@@ -0,0 +1,129 @@
package loldleemoji
import (
"context"
"errors"
"fmt"
"github.com/tiennm99/miti99bot-go/internal/storage"
)
// Round-length defaults. Mirror JS: 5 default, capped at 10 via
// /loldle_emoji_setmax. (Classic loldle uses 8 by default — different game,
// different round length is intentional.)
const (
MaxGuesses = 5
MaxGuessesCap = 10
)
// gameState is the per-subject KV record. Field tags match JS exactly;
// StartedAt is *int64 for `null | number` parity (timer doesn't tick until
// the player submits their first actual guess).
type gameState struct {
Target string `json:"target"`
Guesses []string `json:"guesses"`
StartedAt *int64 `json:"startedAt"`
}
// stats lifetime score. Matches JS shape — no LastResultAt (parity with
// classic loldle stats, which also omits it).
type stats struct {
Played int `json:"played"`
Wins int `json:"wins"`
Streak int `json:"streak"`
BestStreak int `json:"bestStreak"`
}
type roundConfig struct {
MaxGuesses int `json:"maxGuesses"`
}
func gameKey(subject string) string { return "game:" + subject }
func statsKey(subject string) string { return "stats:" + subject }
func configKey(subject string) string { return "config:" + subject }
func loadGame(ctx context.Context, kv storage.KVStore, subject string) (*gameState, error) {
var g gameState
err := kv.GetJSON(ctx, gameKey(subject), &g)
switch {
case err == nil:
return &g, nil
case errors.Is(err, storage.ErrNotFound):
return nil, nil
default:
return nil, fmt.Errorf("loldleemoji loadGame: %w", err)
}
}
func saveGame(ctx context.Context, kv storage.KVStore, subject string, g *gameState) error {
if err := kv.PutJSON(ctx, gameKey(subject), g); err != nil {
return fmt.Errorf("loldleemoji saveGame: %w", err)
}
return nil
}
func clearGame(ctx context.Context, kv storage.KVStore, subject string) error {
if err := kv.Delete(ctx, gameKey(subject)); err != nil {
return fmt.Errorf("loldleemoji clearGame: %w", err)
}
return nil
}
func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*stats, error) {
var s stats
err := kv.GetJSON(ctx, statsKey(subject), &s)
switch {
case err == nil:
return &s, nil
case errors.Is(err, storage.ErrNotFound):
return &stats{}, nil
default:
return nil, fmt.Errorf("loldleemoji loadStats: %w", err)
}
}
func recordResult(ctx context.Context, kv storage.KVStore, subject string, won bool) (*stats, error) {
s, err := loadStats(ctx, kv, subject)
if err != nil {
return nil, err
}
s.Played++
if won {
s.Wins++
s.Streak++
if s.Streak > s.BestStreak {
s.BestStreak = s.Streak
}
} else {
s.Streak = 0
}
if err := kv.PutJSON(ctx, statsKey(subject), s); err != nil {
return nil, fmt.Errorf("loldleemoji recordResult: %w", err)
}
return s, nil
}
func getMaxGuesses(ctx context.Context, kv storage.KVStore, subject string) (int, error) {
var cfg roundConfig
err := kv.GetJSON(ctx, configKey(subject), &cfg)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
return MaxGuesses, nil
}
return 0, fmt.Errorf("loldleemoji getMaxGuesses: %w", err)
}
if cfg.MaxGuesses < 1 || cfg.MaxGuesses > MaxGuessesCap {
return MaxGuesses, nil
}
return cfg.MaxGuesses, nil
}
func setMaxGuesses(ctx context.Context, kv storage.KVStore, subject string, n int) error {
if n < 1 || n > MaxGuessesCap {
return fmt.Errorf("loldleemoji: maxGuesses must be in [1, %d], got %d", MaxGuessesCap, n)
}
if err := kv.PutJSON(ctx, configKey(subject), roundConfig{MaxGuesses: n}); err != nil {
return fmt.Errorf("loldleemoji setMaxGuesses: %w", err)
}
return nil
}
+158
View File
@@ -0,0 +1,158 @@
package loldleemoji
import (
"context"
"encoding/json"
"testing"
"github.com/tiennm99/miti99bot-go/internal/storage"
)
func TestGameState_StartedAtNullByDefault(t *testing.T) {
g := gameState{Target: "Aatrox", Guesses: []string{}}
b, err := json.Marshal(g)
if err != nil {
t.Fatal(err)
}
want := `{"target":"Aatrox","guesses":[],"startedAt":null}`
if string(b) != want {
t.Errorf("marshal:\ngot %s\nwant %s", b, want)
}
}
func TestGameState_StartedAtAsNumber(t *testing.T) {
at := int64(1700000000000)
g := gameState{Target: "Aatrox", Guesses: []string{"Ahri"}, StartedAt: &at}
b, _ := json.Marshal(g)
want := `{"target":"Aatrox","guesses":["Ahri"],"startedAt":1700000000000}`
if string(b) != want {
t.Errorf("marshal:\ngot %s\nwant %s", b, want)
}
}
func TestStats_NoLastResultAt(t *testing.T) {
// Parity with classic loldle: emoji stats also omit lastResultAt.
b, _ := json.Marshal(stats{})
want := `{"played":0,"wins":0,"streak":0,"bestStreak":0}`
if string(b) != want {
t.Errorf("marshal:\ngot %s\nwant %s", b, want)
}
}
func TestRecordResult_StreakSequence(t *testing.T) {
ctx := context.Background()
kv := storage.NewMemoryKVStore()
s, err := recordResult(ctx, kv, "u1", true)
if err != nil {
t.Fatal(err)
}
if s.Streak != 1 || s.BestStreak != 1 || s.Wins != 1 {
t.Errorf("first win: %+v", s)
}
s, _ = recordResult(ctx, kv, "u1", true)
if s.Streak != 2 || s.BestStreak != 2 {
t.Errorf("two wins: %+v", s)
}
s, _ = recordResult(ctx, kv, "u1", false)
if s.Streak != 0 || s.BestStreak != 2 {
t.Errorf("loss: %+v", s)
}
}
func TestGetMaxGuesses_DefaultsToFive(t *testing.T) {
ctx := context.Background()
kv := storage.NewMemoryKVStore()
if n, _ := getMaxGuesses(ctx, kv, "u1"); n != MaxGuesses {
t.Errorf("default = %d, want %d", n, MaxGuesses)
}
if MaxGuesses != 5 {
t.Errorf("MaxGuesses = %d, want 5 (parity with JS)", MaxGuesses)
}
}
func TestSetGetMaxGuesses_RoundTripAndValidation(t *testing.T) {
ctx := context.Background()
kv := storage.NewMemoryKVStore()
if err := setMaxGuesses(ctx, kv, "u1", 3); err != nil {
t.Fatal(err)
}
if n, _ := getMaxGuesses(ctx, kv, "u1"); n != 3 {
t.Errorf("after set(3): %d", n)
}
for _, n := range []int{0, -1, MaxGuessesCap + 1} {
if err := setMaxGuesses(ctx, kv, "u1", n); err == nil {
t.Errorf("setMaxGuesses(%d) should error", n)
}
}
}
// TestStateShapes_DecodeFromJSWire locks the migration contract: a raw
// record written by the JS bot must decode into the Go structs without a
// custom decoder. Captures byte-shape (not just our own round-trip).
func TestStateShapes_DecodeFromJSWire(t *testing.T) {
t.Run("game with null startedAt", func(t *testing.T) {
var g gameState
raw := []byte(`{"target":"Aatrox","guesses":[],"startedAt":null}`)
if err := json.Unmarshal(raw, &g); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if g.Target != "Aatrox" || len(g.Guesses) != 0 || g.StartedAt != nil {
t.Errorf("decoded: %+v", g)
}
})
t.Run("game with numeric startedAt and guesses", func(t *testing.T) {
var g gameState
raw := []byte(`{"target":"Ahri","guesses":["Aatrox","Akali"],"startedAt":1700000000000}`)
if err := json.Unmarshal(raw, &g); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if g.Target != "Ahri" || len(g.Guesses) != 2 || g.StartedAt == nil || *g.StartedAt != 1700000000000 {
t.Errorf("decoded: %+v (StartedAt=%v)", g, g.StartedAt)
}
})
t.Run("stats", func(t *testing.T) {
var s stats
raw := []byte(`{"played":7,"wins":4,"streak":2,"bestStreak":3}`)
if err := json.Unmarshal(raw, &s); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if s.Played != 7 || s.Wins != 4 || s.Streak != 2 || s.BestStreak != 3 {
t.Errorf("decoded: %+v", s)
}
})
t.Run("config", func(t *testing.T) {
var c roundConfig
raw := []byte(`{"maxGuesses":7}`)
if err := json.Unmarshal(raw, &c); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if c.MaxGuesses != 7 {
t.Errorf("decoded: %+v", c)
}
})
}
func TestSaveLoadClear_RoundTrip(t *testing.T) {
ctx := context.Background()
kv := storage.NewMemoryKVStore()
at := int64(42)
want := &gameState{Target: "Aatrox", Guesses: []string{"Ahri"}, StartedAt: &at}
if err := saveGame(ctx, kv, "u1", want); err != nil {
t.Fatal(err)
}
got, _ := loadGame(ctx, kv, "u1")
if got == nil || got.Target != "Aatrox" || got.StartedAt == nil || *got.StartedAt != 42 {
t.Errorf("round-trip mismatch: %+v", got)
}
if err := clearGame(ctx, kv, "u1"); err != nil {
t.Fatal(err)
}
got, _ = loadGame(ctx, kv, "u1")
if got != nil {
t.Errorf("after clear, got %+v, want nil", got)
}
}