mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-20 22:24:10 +00:00
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:
+6
-4
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "🧝 🌿 🔮"
|
||||
}
|
||||
]
|
||||
@@ -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 <champion></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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <champion></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, "<script>") {
|
||||
t.Errorf("html metachars not escaped: %q", out)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user