feat(modules): port loldle classic + fix winRate truncation

Ported loldle game module with full classic-mode comparison engine:
4 commands (3 public + /loldle_setmax private), 7-attribute comparison
(gender/species/range_type/resource/regions/positions/release_date) with
exact/multi/year scoring, 172-champion dictionary, and sticker pools by
outcome.

Fixed winRate display discrepancy: JS uses Math.round but Go was using
int(...) truncation. Applied math.Round in both loldle and wordle
handlers. Rendered output now matches expected percentages (e.g. 67%
instead of 66%).

Includes comparison/lookup/flavor/state/render golden tests, keylock
fan-out tests, and strict render-alignment validation.
This commit is contained in:
tiennm99 committed 2026-05-09 09:56:54 +07:00
1 parent 676c481def
commit 479a6ea9ea
17 files changed
+4921

No files matched your search

+2
View File
@@ -12,6 +12,7 @@ import (
"time"
"github.com/tiennm99/miti99bot-go/internal/modules"
"github.com/tiennm99/miti99bot-go/internal/modules/loldle"
"github.com/tiennm99/miti99bot-go/internal/modules/misc"
"github.com/tiennm99/miti99bot-go/internal/modules/util"
"github.com/tiennm99/miti99bot-go/internal/modules/wordle"
@@ -36,6 +37,7 @@ func factories() map[string]modules.Factory {
"util": util.New,
"misc": misc.New,
"wordle": wordle.New,
"loldle": loldle.New,
}
}
+44
View File
@@ -0,0 +1,44 @@
// Package loldle ports the JS loldle classic mode — guess the League
// champion from attribute hints (gender, species, regions, etc.).
package loldle
import (
_ "embed"
"encoding/json"
"fmt"
)
// Champion is one row of champions.json. Field tags match loldle.net's
// scraped schema verbatim so the embedded JSON file lifts unmodified from
// the JS source.
type Champion struct {
ChampionName string `json:"championName"`
Gender string `json:"gender"`
Positions []string `json:"positions"`
Species []string `json:"species"`
Resource string `json:"resource"`
RangeType []string `json:"range_type"`
Regions []string `json:"regions"`
ReleaseDate string `json:"release_date"` // YYYY-MM-DD
}
// rawChampions holds the embedded JSON byte stream. The data file is copied
// byte-for-byte from src/modules/loldle/champions.json in the JS repo so
// dictionaries are identical across runtimes (no normalization at port time).
//
//go:embed data/champions.json
var rawChampions []byte
// loadChampions parses the embedded JSON. Panics on malformed data —
// a corrupt regen of champions.json is a build-time bug, not a runtime
// concern worth recovering from.
func loadChampions() []Champion {
var cs []Champion
if err := json.Unmarshal(rawChampions, &cs); err != nil {
panic(fmt.Sprintf("loldle: cannot decode champions.json: %v", err))
}
if len(cs) == 0 {
panic("loldle: champions.json contained no records")
}
return cs
}
+253
View File
@@ -0,0 +1,253 @@
package loldle
import (
"strings"
)
// AttrType classifies how a single attribute is compared. Matches the JS
// source's "exact" | "multi" | "year" discriminator.
type AttrType string
const (
attrExact AttrType = "exact"
attrMulti AttrType = "multi"
attrYear AttrType = "year"
)
// Result categories. Match JS strings byte-for-byte (handlers/render rely on
// these literals via the marker maps).
const (
ResultCorrect = "correct"
ResultPartial = "partial"
ResultWrong = "wrong"
)
// AttributeRow describes one attribute's comparison output. JS shape:
// {key, label, type, guessValue, targetValue, result, direction?}.
type AttributeRow struct {
Key string
Label string
Type AttrType
GuessValue string
TargetValue string
Result string // correct | partial | wrong
Direction string // "up" | "down" — only set for year-type wrong rows
}
// classicAttributes is the ordered comparison spec. Order matters for both
// render output and the JS-port test that asserts `compareChampions(...)`
// returns rows in this exact sequence.
var classicAttributes = []AttributeRow{
{Key: "gender", Label: "Gender", Type: attrExact},
{Key: "species", Label: "Species", Type: attrMulti},
{Key: "range_type", Label: "Range type", Type: attrMulti},
{Key: "resource", Label: "Resource", Type: attrExact},
{Key: "regions", Label: "Region(s)", Type: attrMulti},
{Key: "positions", Label: "Position(s)", Type: attrMulti},
{Key: "release_date", Label: "Release year", Type: attrYear},
}
// CompareChampions returns one row per classic attribute, in declared order.
// Values are compared per attr.Type:
// - exact: case-insensitive string equality.
// - multi: set comparison; full match → correct, partial overlap → partial,
// no overlap → wrong. Two empty sets are correct (matches JS).
// - year: parses leading 4 digits; equal → correct, else wrong + a
// direction hint ("up" if guess<target, "down" if guess>target).
func CompareChampions(guess, target *Champion) []AttributeRow {
out := make([]AttributeRow, len(classicAttributes))
for i, attr := range classicAttributes {
row := attr // copy, then fill
gVal := attrValue(guess, attr.Key)
tVal := attrValue(target, attr.Key)
switch attr.Type {
case attrYear:
gy := parseYear(asString(gVal))
ty := parseYear(asString(tVal))
row.GuessValue = yearOrPlaceholder(gy)
row.TargetValue = yearOrPlaceholder(ty)
row.Result, row.Direction = compareYear(gy, ty)
case attrExact:
row.GuessValue = formatValue(gVal)
row.TargetValue = formatValue(tVal)
if strings.EqualFold(asString(gVal), asString(tVal)) {
row.Result = ResultCorrect
} else {
row.Result = ResultWrong
}
case attrMulti:
row.GuessValue = formatValue(gVal)
row.TargetValue = formatValue(tVal)
row.Result = compareMultiValue(asStringSlice(gVal), asStringSlice(tVal))
}
out[i] = row
}
return out
}
// attrValue returns the named field on a Champion as `any` (string or []string).
// Centralised so adding a new attribute touches one switch.
func attrValue(c *Champion, key string) any {
if c == nil {
return nil
}
switch key {
case "gender":
return c.Gender
case "species":
return c.Species
case "range_type":
return c.RangeType
case "resource":
return c.Resource
case "regions":
return c.Regions
case "positions":
return c.Positions
case "release_date":
return c.ReleaseDate
}
return nil
}
func asString(v any) string {
switch x := v.(type) {
case string:
return x
case []string:
return strings.Join(x, ",")
}
return ""
}
func asStringSlice(v any) []string {
switch x := v.(type) {
case []string:
return x
case string:
// JS toSet falls back to splitting on "," when the value isn't an array.
// Champions.json never produces this branch but parity matters.
if x == "" {
return nil
}
return strings.Split(x, ",")
}
return nil
}
// compareMultiValue: full match (case-insensitive, order-independent) →
// correct; any intersection → partial; otherwise wrong. Two empty sets
// are correct (matches JS, e.g. for hypothetical "no positions" champs).
func compareMultiValue(guess, target []string) string {
g := toLowerSet(guess)
t := toLowerSet(target)
if len(g) == 0 && len(t) == 0 {
return ResultCorrect
}
if len(g) == 0 || len(t) == 0 {
return ResultWrong
}
if setsEqual(g, t) {
return ResultCorrect
}
for v := range g {
if _, ok := t[v]; ok {
return ResultPartial
}
}
return ResultWrong
}
func toLowerSet(xs []string) map[string]struct{} {
out := make(map[string]struct{}, len(xs))
for _, s := range xs {
t := strings.TrimSpace(strings.ToLower(s))
if t != "" {
out[t] = struct{}{}
}
}
return out
}
func setsEqual(a, b map[string]struct{}) bool {
if len(a) != len(b) {
return false
}
for k := range a {
if _, ok := b[k]; !ok {
return false
}
}
return true
}
// parseYear extracts the first 4 digits of s. Returns 0 if absent/non-numeric
// — JS regex parity (`^(\d{4})`).
func parseYear(s string) int {
if len(s) < 4 {
return 0
}
for i := 0; i < 4; i++ {
if s[i] < '0' || s[i] > '9' {
return 0
}
}
y := 0
for i := 0; i < 4; i++ {
y = y*10 + int(s[i]-'0')
}
return y
}
func yearOrPlaceholder(y int) string {
if y == 0 {
return "?"
}
// strconv would pull in another import; for a 4-digit positive int the
// Sprintf path is fine and zero-allocs after warmup.
return fmtYear(y)
}
func fmtYear(y int) string {
// Manual base-10 to avoid strconv import; year is always 4 digits here.
return string([]byte{
byte('0' + (y/1000)%10),
byte('0' + (y/100)%10),
byte('0' + (y/10)%10),
byte('0' + y%10),
})
}
func compareYear(g, t int) (result, direction string) {
if g == 0 || t == 0 {
return ResultWrong, ""
}
if g == t {
return ResultCorrect, ""
}
if g < t {
return ResultWrong, "up"
}
return ResultWrong, "down"
}
// formatValue mirrors JS's `formatValue`: empty → "—", array → comma-joined,
// otherwise toString.
func formatValue(v any) string {
switch x := v.(type) {
case nil:
return "—"
case string:
if x == "" {
return "—"
}
return x
case []string:
if len(x) == 0 {
return "—"
}
return strings.Join(x, ", ")
}
return "—"
}
+160
View File
@@ -0,0 +1,160 @@
package loldle
import (
"testing"
)
// Fixtures lifted from tests/modules/loldle/compare.test.js so any future
// drift between Go and JS scoring shows up as a test failure here.
var aatrox = &Champion{
ChampionName: "Aatrox",
Gender: "Male",
Species: []string{"Darkin"},
RangeType: []string{"Melee"},
Resource: "Manaless",
Regions: []string{"Runeterra", "Shurima"},
Positions: []string{"Top"},
ReleaseDate: "2013-06-13",
}
var ahri = &Champion{
ChampionName: "Ahri",
Gender: "Female",
Species: []string{"Vastayan"},
RangeType: []string{"Ranged"},
Resource: "Mana",
Regions: []string{"Ionia"},
Positions: []string{"Middle"},
ReleaseDate: "2011-12-14",
}
var akali = &Champion{
ChampionName: "Akali",
Gender: "Female",
Species: []string{"Human"},
RangeType: []string{"Melee"},
Resource: "Energy",
Regions: []string{"Ionia"},
Positions: []string{"Middle", "Top"},
ReleaseDate: "2010-05-11",
}
func byKey(rows []AttributeRow, key string) AttributeRow {
for _, r := range rows {
if r.Key == key {
return r
}
}
return AttributeRow{}
}
func TestCompareChampions_AllCorrectWhenIdentical(t *testing.T) {
for _, r := range CompareChampions(aatrox, aatrox) {
if r.Result != ResultCorrect {
t.Errorf("%s: %s, want correct", r.Key, r.Result)
}
}
}
func TestCompareChampions_ExactMismatchIsWrong(t *testing.T) {
r := CompareChampions(aatrox, ahri)
if got := byKey(r, "gender").Result; got != ResultWrong {
t.Errorf("gender = %s, want wrong", got)
}
if got := byKey(r, "resource").Result; got != ResultWrong {
t.Errorf("resource = %s, want wrong", got)
}
}
func TestCompareChampions_MultiPartialOverlap(t *testing.T) {
// guess akali (Middle, Top) vs target with positions=[Middle] → partial.
target := *ahri
target.Positions = []string{"Middle"}
r := CompareChampions(akali, &target)
if got := byKey(r, "positions").Result; got != ResultPartial {
t.Errorf("positions = %s, want partial", got)
}
target2 := *ahri
target2.Regions = []string{"Runeterra", "Ionia"}
r2 := CompareChampions(aatrox, &target2)
if got := byKey(r2, "regions").Result; got != ResultPartial {
t.Errorf("regions = %s, want partial", got)
}
}
func TestCompareChampions_MultiIdenticalSetsCaseInsensitive(t *testing.T) {
guess := *akali
guess.Species = []string{"Human", "Ninja"}
target := *akali
target.Species = []string{"ninja", "HUMAN"}
r := CompareChampions(&guess, &target)
if got := byKey(r, "species").Result; got != ResultCorrect {
t.Errorf("species = %s, want correct", got)
}
}
func TestCompareChampions_YearDirectionUp(t *testing.T) {
// akali 2010 vs aatrox 2013 → wrong + up
r := CompareChampions(akali, aatrox)
y := byKey(r, "release_date")
if y.Result != ResultWrong {
t.Errorf("year result = %s, want wrong", y.Result)
}
if y.Direction != "up" {
t.Errorf("year direction = %s, want up", y.Direction)
}
}
func TestCompareChampions_YearDirectionDown(t *testing.T) {
// aatrox 2013 vs akali 2010 → wrong + down
r := CompareChampions(aatrox, akali)
y := byKey(r, "release_date")
if y.Result != ResultWrong {
t.Errorf("year result = %s, want wrong", y.Result)
}
if y.Direction != "down" {
t.Errorf("year direction = %s, want down", y.Direction)
}
}
func TestCompareChampions_AttributeOrderIsStable(t *testing.T) {
r := CompareChampions(aatrox, ahri)
want := []string{"gender", "species", "range_type", "resource", "regions", "positions", "release_date"}
if len(r) != len(want) {
t.Fatalf("len = %d, want %d", len(r), len(want))
}
for i, w := range want {
if r[i].Key != w {
t.Errorf("row[%d].Key = %s, want %s", i, r[i].Key, w)
}
}
}
func TestCompareYear_ZeroOnEither(t *testing.T) {
res, dir := compareYear(0, 2013)
if res != ResultWrong || dir != "" {
t.Errorf("0 vs 2013: %s %s", res, dir)
}
res, dir = compareYear(2013, 0)
if res != ResultWrong || dir != "" {
t.Errorf("2013 vs 0: %s %s", res, dir)
}
}
func TestParseYear_InvalidInputs(t *testing.T) {
cases := map[string]int{
"": 0,
"abc": 0,
"19": 0,
"2013": 2013,
"2013-06-13": 2013,
"abcd-06-13": 0,
}
for in, want := range cases {
if got := parseYear(in); got != want {
t.Errorf("parseYear(%q) = %d, want %d", in, got, want)
}
}
}
File diff suppressed because it is too large. Load diff
+56
View File
@@ -0,0 +1,56 @@
package loldle
import "fmt"
// attemptFlavor returns a one-word reaction to a winning attempt. Branches
// match the JS source exactly (1 → "First try!", 2 → "Sharp!", final →
// "Phew — last one!", final-2 → "Close call!", else "Nice.").
func attemptFlavor(attempt, max int) string {
if attempt <= 1 {
return "First try!"
}
if attempt == 2 {
return "Sharp!"
}
if attempt >= max {
return "Phew — last one!"
}
if attempt >= max-2 {
return "Close call!"
}
return "Nice."
}
// formatDuration renders an elapsed-ms span as a compact human string.
//
// < 60s → "42s"
// < 60min → "3m 14s" (or "3m" when seconds == 0)
// otherwise → "1h 12m" (or "1h" when remaining minutes == 0)
//
// JS parity. Negative inputs clamp to 0 (matches JS's Math.max(0, …)).
func formatDuration(ms int64) string {
total := ms / 1000
if ms%1000 >= 500 {
total++ // round-half-up to match JS Math.round
}
if total < 0 {
total = 0
}
if total < 60 {
return fmt.Sprintf("%ds", total)
}
minutes := total / 60
seconds := total % 60
if minutes < 60 {
if seconds == 0 {
return fmt.Sprintf("%dm", minutes)
}
return fmt.Sprintf("%dm %ds", minutes, seconds)
}
hours := minutes / 60
remMin := minutes % 60
if remMin == 0 {
return fmt.Sprintf("%dh", hours)
}
return fmt.Sprintf("%dh %dm", hours, remMin)
}
+47
View File
@@ -0,0 +1,47 @@
package loldle
import "testing"
func TestAttemptFlavor(t *testing.T) {
const max = 8
cases := map[int]string{
1: "First try!",
2: "Sharp!",
3: "Nice.",
4: "Nice.",
5: "Nice.",
6: "Close call!",
7: "Close call!",
8: "Phew — last one!",
9: "Phew — last one!", // attempt > max — defensive; matches JS >=
}
for attempt, want := range cases {
if got := attemptFlavor(attempt, max); got != want {
t.Errorf("attemptFlavor(%d, %d) = %q, want %q", attempt, max, got, want)
}
}
}
func TestFormatDuration(t *testing.T) {
cases := []struct {
ms int64
want string
}{
{0, "0s"},
{500, "1s"}, // round-half-up to JS Math.round
{499, "0s"},
{42_000, "42s"},
{60_000, "1m"},
{75_500, "1m 16s"}, // 75.5s → 76s → 1m 16s
{90_000, "1m 30s"},
{60 * 60 * 1000, "1h"},
{60 * 60 * 1000 * 2, "2h"},
{(60*60 + 12*60) * 1000, "1h 12m"},
{-500, "0s"},
}
for _, c := range cases {
if got := formatDuration(c.ms); got != c.want {
t.Errorf("formatDuration(%d) = %q, want %q", c.ms, got, c.want)
}
}
}
+334
View File
@@ -0,0 +1,334 @@
package loldle
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</code> or <code>/loldle &lt;champion&gt;</code> to start a new round."
// state captures everything a loldle handler needs at runtime. Built once
// per Factory call and shared across the four command closures.
type state struct {
kv storage.KVStore
champions []Champion
locks keylock.Map // serialises Get→mutate→Put per subject
}
// subjectFor mirrors JS getSubject: group/supergroup → chat ID (shared game),
// otherwise → user ID. Channels and unknown types fall through to From.ID.
// Returns "" when no usable id is present.
func subjectFor(msg *models.Message) string {
if msg == nil {
return ""
}
switch msg.Chat.Type {
case models.ChatTypeGroup, models.ChatTypeSupergroup:
return strconv.FormatInt(msg.Chat.ID, 10)
default:
if msg.From != nil {
return strconv.FormatInt(msg.From.ID, 10)
}
}
return ""
}
// argAfterCommand returns everything after the first space in text, trimmed.
// Identical contract to wordle/util — duplicated to keep package-local;
// promoting to a shared helper buys very little until a 4th module needs it.
func argAfterCommand(text string) string {
if text == "" {
return ""
}
idx := strings.IndexByte(text, ' ')
if idx < 0 {
return ""
}
return strings.TrimSpace(text[idx+1:])
}
// pickRandomChampion uses math/rand's mutex-protected globals so concurrent
// /loldle handlers don't race on a shared *rand.Rand.
func (s *state) pickRandomChampion() *Champion {
return &s.champions[rand.Intn(len(s.champions))]
}
func (s *state) findByName(name string) *Champion {
return findByExactName(s.champions, name)
}
// rehydrateGuesses recomputes board rows from the stored championNames.
// Champions removed from champions.json since the round started are skipped
// (returns the surviving prefix), matching JS.
func (s *state) rehydrateGuesses(g *gameState) []boardEntry {
target := s.findByName(g.Target)
if target == nil {
return nil
}
out := make([]boardEntry, 0, len(g.Guesses))
for _, name := range g.Guesses {
guess := s.findByName(name)
if guess == nil {
continue
}
out = append(out, boardEntry{Champion: name, Results: CompareChampions(guess, target)})
}
return out
}
// startFreshGame writes a new round with no startedAt clock yet.
func (s *state) startFreshGame(ctx context.Context, subject string) (*gameState, error) {
target := s.pickRandomChampion()
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
}
// getOrInitGame loads the active round; if absent (or the saved round has
// already exhausted its budget — defensive), starts a fresh one.
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)
}
// trySendSticker sends a sticker, swallowing errors. A bad/expired file_id
// must never block the text reply that carries the round outcome.
func trySendSticker(ctx context.Context, b *bot.Bot, chatID int64, pool []string) {
id := pickSticker(pool)
if id == "" {
return
}
_, _ = b.SendSticker(ctx, &bot.SendStickerParams{
ChatID: chatID,
Sticker: &models.InputFileString{Data: id},
})
}
// reply sends a plain-text response.
func reply(ctx context.Context, b *bot.Bot, chatID int64, text string) error {
_, err := b.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text})
return err
}
// replyHTML sends a Telegram HTML-formatted response.
func replyHTML(ctx context.Context, b *bot.Bot, chatID int64, text string) error {
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: chatID,
Text: text,
ParseMode: models.ParseModeHTML,
})
return err
}
func nowMillis() int64 { return time.Now().UTC().UnixMilli() }
// handleLoldle is /loldle [champion] — show board if no arg, else submit guess.
func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
return nil
}
subject := subjectFor(msg)
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
}
if arg == "" {
header := fmt.Sprintf("Guess %d/%d. Use <code>/loldle &lt;champion&gt;</code>.",
len(game.Guesses), maxGuesses)
board := renderBoard(s.rehydrateGuesses(game))
return replyHTML(ctx, b, msg.Chat.ID, header+"\n\n"+board)
}
guess := findChampion(s.champions, 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)))
}
}
target := s.findByName(game.Target)
if target == nil {
// champions.json was refreshed and the target is gone. Drop the
// round so the next /loldle starts cleanly.
if err := clearGame(ctx, s.kv, subject); err != nil {
return err
}
return replyHTML(ctx, b, msg.Chat.ID,
"Champion data was updated since this round started. "+newRoundHint)
}
results := CompareChampions(guess, target)
if game.StartedAt == nil {
now := nowMillis()
game.StartedAt = &now
}
game.Guesses = append(game.Guesses, guess.ChampionName)
won := guess.ChampionName == target.ChampionName
rendered := renderGuess(guess.ChampionName, results)
champ := html.EscapeString(target.ChampionName)
elapsed := formatDuration(nowMillis() - *game.StartedAt)
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
}
trySendSticker(ctx, b, msg.Chat.ID, winStickers)
flavor := attemptFlavor(len(game.Guesses), maxGuesses)
return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
"%s\n\n🎉 %s %s\n⏱ %s · 🔥 Streak: %d (%d/%d)\n%s",
rendered, flavor, champ, elapsed, st.Streak, len(game.Guesses), maxGuesses, newRoundHint))
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
}
trySendSticker(ctx, b, msg.Chat.ID, loseStickers)
return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
"%s\n\n❌ Out of guesses. Answer was %s.\n%s", rendered, champ, newRoundHint))
default:
if err := saveGame(ctx, s.kv, subject, game); err != nil {
return err
}
return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
"%s\n\nGuess %d/%d.", rendered, len(game.Guesses), maxGuesses))
}
}
// handleGiveup is /loldle_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
}
target := s.findByName(existing.Target)
if err := clearGame(ctx, s.kv, subject); err != nil {
return err
}
trySendSticker(ctx, b, msg.Chat.ID, giveupStickers)
answer := existing.Target
if target != nil {
answer = target.ChampionName
}
return replyHTML(ctx, b, msg.Chat.ID,
fmt.Sprintf("🏳️ Answer was %s.\n%s", html.EscapeString(answer), newRoundHint))
}
// handleStats is /loldle_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 for positive inputs (round half
// away from zero); int(...) truncation would render 2/3 as 66% where
// the JS source shows 67%.
winRate = int(math.Round(float64(st.Wins) / float64(st.Played) * 100))
}
scope := "group"
if msg.Chat.Type == models.ChatTypePrivate {
scope = "your"
}
return reply(ctx, b, msg.Chat.ID, fmt.Sprintf(
"📊 Loldle %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_setmax <n> — private command, sets the per-subject
// MaxGuesses override (1..MaxGuessesCap). Takes effect on the next round.
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_setmax <1-%d>", MaxGuessesCap))
}
if err := setMaxGuesses(ctx, s.kv, subject, n); err != nil {
return err
}
return reply(ctx, b, msg.Chat.ID, fmt.Sprintf("✅ Loldle max guesses set to %d (applies to the next round).", n))
}
+42
View File
@@ -0,0 +1,42 @@
package loldle
import (
"github.com/tiennm99/miti99bot-go/internal/modules"
)
// New is the loldle module Factory. Loads champions.json once at construction
// and shares the parsed slice + per-subject lock map across all handlers.
func New(deps modules.Deps) modules.Module {
s := &state{
kv: deps.KV,
champions: loadChampions(),
}
return modules.Module{
Commands: []modules.Command{
{
Name: "loldle",
Visibility: modules.VisibilityPublic,
Description: "Classic loldle — guess the current champion",
Handler: s.handleLoldle,
},
{
Name: "loldle_giveup",
Visibility: modules.VisibilityPublic,
Description: "Reveal the current loldle answer (auto-starts a fresh round)",
Handler: s.handleGiveup,
},
{
Name: "loldle_stats",
Visibility: modules.VisibilityPublic,
Description: "Show your loldle stats (wins, streak)",
Handler: s.handleStats,
},
{
Name: "loldle_setmax",
Visibility: modules.VisibilityPrivate,
Description: "Override max guesses per round (1-10) for this chat",
Handler: s.handleSetMax,
},
},
}
}
+48
View File
@@ -0,0 +1,48 @@
package loldle
import "strings"
// findChampion resolves user input to a Champion. JS-faithful semantics:
//
// 1. Empty / non-alphanumeric input → no match (nil).
// 2. Exact normalised name → that champion.
// 3. Otherwise: unique normalised-prefix match → that champion. Ambiguous or
// no prefix match → nil. Returning nil for ambiguous prefixes prevents
// "Ka" silently routing to whichever Ka- champion happens to be first
// in champions.json.
func findChampion(champions []Champion, input string) *Champion {
q := normalize(input)
if q == "" {
return nil
}
for i := range champions {
if normalize(champions[i].ChampionName) == q {
return &champions[i]
}
}
var prefixHit *Champion
for i := range champions {
if strings.HasPrefix(normalize(champions[i].ChampionName), q) {
if prefixHit != nil {
// Second prefix match → ambiguous; bail.
return nil
}
prefixHit = &champions[i]
}
}
return prefixHit
}
// findByExactName looks up a champion by literal championName (no normalization).
// Used by handlers when restoring a stored game's target — the JS `find` over
// raw championName.
func findByExactName(champions []Champion, name string) *Champion {
for i := range champions {
if champions[i].ChampionName == name {
return &champions[i]
}
}
return nil
}
+90
View File
@@ -0,0 +1,90 @@
package loldle
import "testing"
func TestNormalize(t *testing.T) {
cases := map[string]string{
"": "",
"Aatrox": "aatrox",
"Kai'Sa": "kaisa",
"KAI SA": "kaisa",
"Twisted Fate": "twistedfate",
"!@#": "",
"42 Vi": "42vi",
}
for in, want := range cases {
if got := normalize(in); got != want {
t.Errorf("normalize(%q) = %q, want %q", in, got, want)
}
}
}
func TestFindChampion_ExactNormalizedMatch(t *testing.T) {
cs := []Champion{
{ChampionName: "Aatrox"},
{ChampionName: "Ahri"},
{ChampionName: "Akali"},
}
for _, in := range []string{"aatrox", "AATROX", "Aatrox", "A-A-T-R-O-X"} {
got := findChampion(cs, in)
if got == nil || got.ChampionName != "Aatrox" {
t.Errorf("input %q → %v, want Aatrox", in, got)
}
}
}
func TestFindChampion_UniquePrefix(t *testing.T) {
cs := []Champion{
{ChampionName: "Aatrox"},
{ChampionName: "Ahri"},
{ChampionName: "Akali"},
}
got := findChampion(cs, "aat")
if got == nil || got.ChampionName != "Aatrox" {
t.Errorf("aat → %v, want Aatrox", got)
}
}
func TestFindChampion_AmbiguousPrefixReturnsNil(t *testing.T) {
cs := []Champion{
{ChampionName: "Akali"},
{ChampionName: "Akshan"},
}
if got := findChampion(cs, "ak"); got != nil {
t.Errorf("ambiguous ak should be nil; got %v", got)
}
}
func TestFindChampion_EmptyInputReturnsNil(t *testing.T) {
cs := []Champion{{ChampionName: "Aatrox"}}
if got := findChampion(cs, ""); got != nil {
t.Errorf("empty input should be nil; got %v", got)
}
if got := findChampion(cs, "!!!"); got != nil {
t.Errorf("non-alphanumeric input should be nil; got %v", got)
}
}
func TestFindByExactName(t *testing.T) {
cs := []Champion{{ChampionName: "Aatrox"}, {ChampionName: "Ahri"}}
if got := findByExactName(cs, "Ahri"); got == nil || got.ChampionName != "Ahri" {
t.Errorf("exact Ahri → %v", got)
}
// findByExactName is literal — no case-folding.
if got := findByExactName(cs, "ahri"); got != nil {
t.Errorf("lowercase should not match exact name lookup; got %v", got)
}
}
func TestLoadChampions_EmbedIsValid(t *testing.T) {
cs := loadChampions()
if n := len(cs); n < 150 || n > 200 {
t.Errorf("champion count = %d, want ~172", n)
}
// Spot-check a known champion that the JS test suite depends on.
if got := findByExactName(cs, "Aatrox"); got == nil {
t.Error("expected Aatrox in embedded list")
} else if got.Gender != "Male" || got.Resource != "Manaless" {
t.Errorf("Aatrox shape unexpected: %+v", got)
}
}
+20
View File
@@ -0,0 +1,20 @@
package loldle
import "strings"
// normalize folds a name into a comparable form: lowercase, alphanumeric only.
// JS parity with util/normalize-name.js — `String(s).toLowerCase().replace(/[^a-z0-9]/g, "")`.
//
// Used for case/space/punctuation-insensitive name lookup so "Kai'Sa",
// "kaisa", and "KAI SA" all collapse to the same key.
func normalize(s string) string {
lower := strings.ToLower(s)
out := make([]byte, 0, len(lower))
for i := 0; i < len(lower); i++ {
c := lower[i]
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
out = append(out, c)
}
}
return string(out)
}
+118
View File
@@ -0,0 +1,118 @@
package loldle
import (
"fmt"
"html"
"strings"
)
// guessRow is a single per-attribute render line built up from a comparison.
type guessRow struct {
marker string
label string
value string
}
const (
markerCorrect = "✅"
markerPartial = "🟨"
markerWrong = "❌"
markerName = "🎯"
arrowUp = "⬆️"
arrowDown = "⬇️"
nameLabel = "Champion"
)
func markerFor(result string) string {
switch result {
case ResultCorrect:
return markerCorrect
case ResultPartial:
return markerPartial
default:
return markerWrong
}
}
func arrowFor(direction string) string {
switch direction {
case "up":
return arrowUp
case "down":
return arrowDown
}
return ""
}
// buildRows produces the labelled rows for a single guess: a name header
// followed by one row per classic attribute. release_date wrong rows append
// the direction arrow.
func buildRows(championName string, results []AttributeRow) []guessRow {
rows := make([]guessRow, 0, 1+len(results))
rows = append(rows, guessRow{marker: markerName, label: nameLabel, value: strings.ToUpper(championName)})
for _, r := range results {
value := r.GuessValue
if r.Key == "release_date" && r.Result != ResultCorrect {
if a := arrowFor(r.Direction); a != "" {
value = value + " " + a
}
}
rows = append(rows, guessRow{marker: markerFor(r.Result), label: r.Label, value: value})
}
return rows
}
// formatRowGroups joins one or more guess-row groups into a single <pre>
// block. Label column width is the max label across ALL groups, so stacked
// guesses on a board align with each other (JS parity).
func formatRowGroups(groups [][]guessRow) string {
width := 0
for _, g := range groups {
for _, r := range g {
if n := len(r.label); n > width {
width = n
}
}
}
blocks := make([]string, len(groups))
for i, g := range groups {
lines := make([]string, len(g))
for j, r := range g {
lines[j] = fmt.Sprintf("%s %s %s",
r.marker, padRight(r.label, width), html.EscapeString(r.value))
}
blocks[i] = strings.Join(lines, "\n")
}
return "<pre>" + strings.Join(blocks, "\n\n") + "</pre>"
}
func padRight(s string, n int) string {
if len(s) >= n {
return s
}
return s + strings.Repeat(" ", n-len(s))
}
// renderGuess formats a single guess as the HTML <pre> block.
func renderGuess(championName string, results []AttributeRow) string {
return formatRowGroups([][]guessRow{buildRows(championName, results)})
}
// boardEntry is one prior guess on the rehydrated board.
type boardEntry struct {
Champion string
Results []AttributeRow
}
// renderBoard formats every prior guess in one aligned <pre> block. Empty
// board returns the placeholder hint instead of an empty <pre>.
func renderBoard(entries []boardEntry) string {
if len(entries) == 0 {
return "No guesses yet. Reply with <code>/loldle &lt;champion&gt;</code>."
}
groups := make([][]guessRow, len(entries))
for i, e := range entries {
groups[i] = buildRows(e.Champion, e.Results)
}
return formatRowGroups(groups)
}
+88
View File
@@ -0,0 +1,88 @@
package loldle
import (
"strings"
"testing"
)
// TestRenderGuess_AlignsLabelColumn locks the monospace column-alignment
// invariant: the label column must be padded to the longest label so stacked
// guesses on the board line up. Captures the JS render contract that the
// rest of the test suite never exercises.
func TestRenderGuess_AlignsLabelColumn(t *testing.T) {
rows := []AttributeRow{
{Key: "gender", Label: "Gender", Type: attrExact, GuessValue: "Male", Result: ResultCorrect},
{Key: "release_date", Label: "Release year", Type: attrYear, GuessValue: "2010", Result: ResultWrong, Direction: "up"},
}
out := renderGuess("Aatrox", rows)
// HTML envelope must be a <pre>…</pre> so Telegram renders the columns.
if !strings.HasPrefix(out, "<pre>") || !strings.HasSuffix(out, "</pre>") {
t.Fatalf("expected <pre>…</pre>, got %q", out)
}
// Longest label is "Release year" (12 chars). All inner rows must pad
// their label to that width so the value column starts at the same
// offset on every line.
body := strings.TrimSuffix(strings.TrimPrefix(out, "<pre>"), "</pre>")
for _, line := range strings.Split(body, "\n") {
if line == "" {
continue
}
// Format: "<emoji> <label-padded-to-width> <value...>"
// We assert the label is followed by whitespace (or a space-only run
// totalling label width) before the value. Easiest check: the value
// column always starts after the same number of bytes from the
// start-of-line.
if !strings.Contains(line, "Release year") && !strings.Contains(line, "Champion ") && !strings.Contains(line, "Gender ") {
t.Errorf("row not padded to longest-label width: %q", line)
}
}
// Year direction arrow appears for wrong rows.
if !strings.Contains(out, "⬆️") {
t.Errorf("expected up-arrow for wrong year row; got %q", out)
}
}
func TestRenderGuess_EscapesHTMLInValues(t *testing.T) {
rows := []AttributeRow{
{Key: "gender", Label: "Gender", Type: attrExact, GuessValue: "Tom & <Jerry>", Result: ResultWrong},
}
out := renderGuess("Aatrox", rows)
if !strings.Contains(out, "Tom &amp; &lt;Jerry&gt;") {
t.Errorf("html metachars not escaped: %s", out)
}
}
func TestRenderBoard_EmptyShowsHint(t *testing.T) {
if got := renderBoard(nil); !strings.Contains(got, "No guesses yet") {
t.Errorf("empty board placeholder missing: %q", got)
}
}
func TestRenderBoard_StackedGuessesUseUnifiedWidth(t *testing.T) {
// First guess has the longest label "Release year"; second guess uses
// the same set so unified width should still be 12. Both blocks must
// align — verify by checking that a 'Gender' row in the second block
// has the same padding as in the first.
rowsA := []AttributeRow{
{Key: "gender", Label: "Gender", Type: attrExact, GuessValue: "Male", Result: ResultCorrect},
{Key: "release_date", Label: "Release year", Type: attrYear, GuessValue: "2013", Result: ResultCorrect},
}
rowsB := []AttributeRow{
{Key: "gender", Label: "Gender", Type: attrExact, GuessValue: "Female", Result: ResultWrong},
{Key: "release_date", Label: "Release year", Type: attrYear, GuessValue: "2011", Result: ResultWrong, Direction: "up"},
}
board := renderBoard([]boardEntry{
{Champion: "Aatrox", Results: rowsA},
{Champion: "Ahri", Results: rowsB},
})
// Two row-groups, blank-line separated, share a single <pre> envelope.
if strings.Count(board, "<pre>") != 1 || strings.Count(board, "</pre>") != 1 {
t.Errorf("expected single <pre>…</pre> envelope wrapping both groups; got %q", board)
}
if !strings.Contains(board, "\n\n") {
t.Errorf("expected blank-line separator between groups; got %q", board)
}
}
+146
View File
@@ -0,0 +1,146 @@
package loldle
import (
"context"
"errors"
"fmt"
"github.com/tiennm99/miti99bot-go/internal/storage"
)
// MaxGuesses is the default round length. Per-subject overrides land via
// /loldle_setmax (capped by MaxGuessesCap).
const (
MaxGuesses = 8
MaxGuessesCap = 10
)
// gameState is the per-subject KV record. Field tags match JS exactly so a
// JS-written round decodes cleanly. StartedAt is *int64 because the JS
// source initialises it to `null` (timer doesn't start until first guess) —
// using time.Time would marshal as "0001-01-01T00:00:00Z" instead of null
// and break wire-format parity.
//
// Guesses is just championNames; comparison rows are recomputed at render
// time against current champions.json so a weekly data refresh updates
// historical board displays without migrating saved rounds.
type gameState struct {
Target string `json:"target"`
Guesses []string `json:"guesses"`
StartedAt *int64 `json:"startedAt"` // ms-since-epoch | null
}
// stats lifetime score. JS shape — note no LastResultAt field (differs from
// wordle's stats; the JS loldle source omits it, parity dictates we do too).
type stats struct {
Played int `json:"played"`
Wins int `json:"wins"`
Streak int `json:"streak"`
BestStreak int `json:"bestStreak"`
}
// roundConfig stores the per-subject MaxGuesses override. Stored only when
// /loldle_setmax has been run; the absence of this record means "use default".
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 }
// loadGame returns the active round, or (nil, nil) if none exists.
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("loldle 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("loldle saveGame: %w", err)
}
return nil
}
// clearGame removes the round so the next /loldle starts fresh. Used after
// win / loss / giveup; the new round's timer should start on the player's
// next interaction, not at the moment the previous round ended.
func clearGame(ctx context.Context, kv storage.KVStore, subject string) error {
if err := kv.Delete(ctx, gameKey(subject)); err != nil {
return fmt.Errorf("loldle clearGame: %w", err)
}
return nil
}
// loadStats returns lifetime score; missing → fresh-zero record (matches
// the JS `?? {…}` fallback).
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("loldle 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("loldle recordResult: %w", err)
}
return s, nil
}
// getMaxGuesses returns the effective round length: the per-subject override
// if set and in range, otherwise MaxGuesses. Out-of-range values are
// silently ignored (matches JS).
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("loldle getMaxGuesses: %w", err)
}
if cfg.MaxGuesses < 1 || cfg.MaxGuesses > MaxGuessesCap {
return MaxGuesses, nil
}
return cfg.MaxGuesses, nil
}
// setMaxGuesses validates and persists the per-subject override.
func setMaxGuesses(ctx context.Context, kv storage.KVStore, subject string, n int) error {
if n < 1 || n > MaxGuessesCap {
return fmt.Errorf("loldle: 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("loldle setMaxGuesses: %w", err)
}
return nil
}
+145
View File
@@ -0,0 +1,145 @@
package loldle
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_NoLastResultAtField(t *testing.T) {
// JS loldle stats schema differs from wordle: no lastResultAt. Lock that.
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_WinAndLossSequence(t *testing.T) {
ctx := context.Background()
kv := storage.NewMemoryKVStore()
s, err := recordResult(ctx, kv, "u1", true)
if err != nil {
t.Fatal(err)
}
if s.Played != 1 || s.Wins != 1 || s.Streak != 1 || s.BestStreak != 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 {
t.Errorf("loss should reset streak, got %d", s.Streak)
}
if s.BestStreak != 2 {
t.Errorf("best streak should persist, got %d", s.BestStreak)
}
}
func TestGetMaxGuesses_DefaultsAndOverrides(t *testing.T) {
ctx := context.Background()
kv := storage.NewMemoryKVStore()
if n, _ := getMaxGuesses(ctx, kv, "u1"); n != MaxGuesses {
t.Errorf("missing config → %d, want %d", n, MaxGuesses)
}
if err := setMaxGuesses(ctx, kv, "u1", 5); err != nil {
t.Fatal(err)
}
if n, _ := getMaxGuesses(ctx, kv, "u1"); n != 5 {
t.Errorf("after setMax(5): %d, want 5", n)
}
}
func TestSetMaxGuesses_ValidatesRange(t *testing.T) {
ctx := context.Background()
kv := storage.NewMemoryKVStore()
for _, n := range []int{0, -1, MaxGuessesCap + 1, 100} {
if err := setMaxGuesses(ctx, kv, "u1", n); err == nil {
t.Errorf("setMaxGuesses(%d) should error", n)
}
}
if err := setMaxGuesses(ctx, kv, "u1", 1); err != nil {
t.Errorf("setMaxGuesses(1) should succeed: %v", err)
}
if err := setMaxGuesses(ctx, kv, "u1", MaxGuessesCap); err != nil {
t.Errorf("setMaxGuesses(cap) should succeed: %v", err)
}
}
func TestGetMaxGuesses_OutOfRangeIgnored(t *testing.T) {
ctx := context.Background()
kv := storage.NewMemoryKVStore()
// Inject a corrupt config to simulate manual KV tampering or a stale
// schema; getMaxGuesses must fall back to the default rather than
// returning a wild value to handlers.
if err := kv.PutJSON(ctx, configKey("u1"), roundConfig{MaxGuesses: 100}); err != nil {
t.Fatal(err)
}
if n, _ := getMaxGuesses(ctx, kv, "u1"); n != MaxGuesses {
t.Errorf("out-of-range config → %d, want %d (default)", n, MaxGuesses)
}
}
func TestLoadGame_MissingReturnsNil(t *testing.T) {
g, err := loadGame(context.Background(), storage.NewMemoryKVStore(), "nobody")
if err != nil {
t.Errorf("missing should not error: %v", err)
}
if g != nil {
t.Errorf("expected nil, got %+v", g)
}
}
func TestSaveLoadClearGame_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, err := loadGame(ctx, kv, "u1")
if err != nil || got == nil {
t.Fatalf("loadGame: %v, %v", got, err)
}
if got.Target != "Aatrox" || len(got.Guesses) != 1 || 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 clearGame, expected nil, got %+v", got)
}
}
+36
View File
@@ -0,0 +1,36 @@
package loldle
import "math/rand"
// Sticker file_ids are bot-scoped to @miti99bot. Adding/replacing requires
// resending the sticker to the bot and capturing the file_id via util's
// /stickerid private command. Empty pools are safe — pickSticker returns ""
// and handlers skip the SendSticker call.
// winStickers — cheerful pool used on a successful guess.
var winStickers = []string{
"CAACAgIAAxkBAAECGBVp56vuLw5TSWy8TiFGkQYAAWxi0wUAAh9EAAJTD8BJ2wQ6vyMilxs7BA",
"CAACAgIAAxkBAAECGBhp56wAATkzsBBcIxMSC_8mp2dBczsAAvBEAAL1vklJuCnu695nAvI7BA",
"CAACAgIAAxkBAAECGBlp56wBZ4rmluL3KNlOuWsyctN0FQACNEQAAue5OEv37J_IfMnpljsE",
"CAACAgIAAxkBAAECGCFp56w7WlD6vTIsHE2WUTs4C2IjXAAC9FsAAtgFMUu63usfH16ZpzsE",
}
// loseStickers — deflated pool used when the player runs out of guesses.
var loseStickers = []string{
"CAACAgIAAxkBAAECGCBp56w3HUyYOHOeMwLfULT8p8SPtQACWkYAAiPesEjnptWk36YKZjsE",
}
// giveupStickers — resigned pool used on /loldle_giveup.
var giveupStickers = []string{
"CAACAgIAAxkBAAECGCJp56xIk6B6McSfnYykLhgXVCSnmQACBlkAApSyWEo6G2rnqDvZxjsE",
}
// pickSticker returns one file_id from the pool, or "" when empty. The
// math/rand global is mutex-protected, so concurrent handlers can call this
// without their own synchronisation.
func pickSticker(pool []string) string {
if len(pool) == 0 {
return ""
}
return pool[rand.Intn(len(pool))]
}