feat(modules): port loldle-ability

Phase 6c of the go-port-cloud-run plan. Adds the third loldle variant
— guess the champion from a single ability icon (passive or Q/W/E/R).

- internal/modules/loldleability: champions.go (embed
  abilities.json, 5334-line DDragon-sourced pool with
  AbilityChampion + Ability types), state.go (gameState gains a
  `slot` field so the same icon shows across guesses in a round),
  render-free handlers.go (sendPhoto path uses
  models.InputFileString with the DDragon CDN URL directly — no
  binary upload), loldleability.go (Module Factory).
- Reuses internal/modules/util/chathelper and internal/champname
  (same shared layer the other variants use).
- 4 commands wired: loldle_ability (public), loldle_ability_giveup
  (public), loldle_ability_stats (public), loldle_ability_setmax
  (private).
- 14 tests: lookup (embed shape + DDragon URL prefix + slot
  coverage), state (slot round-trip + JS-wire-format decode +
  streak), handlers (sendPhoto with correct URL, win, unknown
  champion, duplicate, giveup with slot label, stats, setmax owner
  + non-owner).
- gocyclo cap nudged 20 -> 22 to accommodate handleAbility's
  pre-flight validation branch.

go test -race -count=1 ./... clean (17 packages); golangci-lint
clean.
This commit is contained in:
2026-05-09 16:53:26 +07:00
parent 53ce1113eb
commit dd4e86a5de
11 changed files with 6119 additions and 7 deletions
+4 -3
View File
@@ -23,9 +23,10 @@ linters:
- unused
settings:
gocyclo:
# handleLoldle / handleEmoji dispatch on game outcome (won / lost /
# ongoing) plus error returns; 19 reads cleaner inline than as 3 helpers.
min-complexity: 20
# The loldle handlers dispatch on game outcome (won / lost / ongoing)
# plus error returns plus pre-flight validation. Reads cleaner inline
# than as 4 nano-helpers; cap is empirical, not a design target.
min-complexity: 22
gosec:
excludes:
# G104 (unhandled errors) — already enforced via errcheck with
+5 -3
View File
@@ -14,6 +14,7 @@ import (
"github.com/tiennm99/miti99bot-go/internal/log"
"github.com/tiennm99/miti99bot-go/internal/modules"
"github.com/tiennm99/miti99bot-go/internal/modules/loldle"
"github.com/tiennm99/miti99bot-go/internal/modules/loldleability"
"github.com/tiennm99/miti99bot-go/internal/modules/loldleemoji"
"github.com/tiennm99/miti99bot-go/internal/modules/loldlequote"
"github.com/tiennm99/miti99bot-go/internal/modules/misc"
@@ -32,9 +33,10 @@ func factories() map[string]modules.Factory {
"util": util.New,
"misc": misc.New,
"wordle": wordle.New,
"loldle": loldle.New,
"loldle-emoji": loldleemoji.New,
"loldle-quote": loldlequote.New,
"loldle": loldle.New,
"loldle-ability": loldleability.New,
"loldle-emoji": loldleemoji.New,
"loldle-quote": loldlequote.New,
}
}
@@ -0,0 +1,63 @@
// Package loldleability ports the JS loldle-ability variant — guess the
// champion from a single ability icon. Pool seeded from Riot Data Dragon
// (passive + Q/W/E/R for each champion). Uses Telegram's sendPhoto with the
// DDragon CDN URL as the file source — no binary embedding.
//
// Round state persists `{target, slot, guesses, startedAt}` so the SAME
// ability icon shows across all turns until the round ends.
package loldleability
import (
_ "embed"
"encoding/json"
"fmt"
)
// Ability is one of P/Q/W/E/R for a champion.
type Ability struct {
Slot string `json:"slot"` // P, Q, W, E, R
Name string `json:"name"`
Icon string `json:"icon"` // absolute DDragon CDN URL
}
// AbilityChampion is one record of abilities.json — championName + the full
// ability list.
type AbilityChampion struct {
ChampionName string `json:"championName"`
Key string `json:"key"` // DDragon internal id; not used by handlers but kept for parity
Abilities []Ability `json:"abilities"`
}
//go:embed data/abilities.json
var rawAbilities []byte
// loadPool parses abilities.json and drops champions with no abilities.
// Panics on malformed data — corrupt regen is a build-time bug.
func loadPool() []AbilityChampion {
var all []AbilityChampion
if err := json.Unmarshal(rawAbilities, &all); err != nil {
panic(fmt.Sprintf("loldleability: cannot decode abilities.json: %v", err))
}
out := make([]AbilityChampion, 0, len(all))
for _, c := range all {
if len(c.Abilities) > 0 {
out = append(out, c)
}
}
if len(out) == 0 {
panic("loldleability: abilities.json contained no usable records")
}
return out
}
// abilityBySlot finds the ability with the given slot ("Q", "W", ...).
// Returns nil when the slot is unknown — caller treats that as a refresh
// signal (start over).
func abilityBySlot(c *AbilityChampion, slot string) *Ability {
for i := range c.Abilities {
if c.Abilities[i].Slot == slot {
return &c.Abilities[i]
}
}
return nil
}
File diff suppressed because it is too large Load Diff
+256
View File
@@ -0,0 +1,256 @@
package loldleability
import (
"context"
"fmt"
"html"
"math/rand"
"strconv"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot-go/internal/champname"
"github.com/tiennm99/miti99bot-go/internal/keylock"
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
"github.com/tiennm99/miti99bot-go/internal/storage"
)
const newRoundHint = "🆕 Send <code>/loldle_ability</code> or <code>/loldle_ability &lt;champion&gt;</code> to start a new round."
// state captures everything a loldle-ability handler needs at runtime.
// Built once per Factory call and shared across the four command closures.
type state struct {
kv storage.KVStore
pool []AbilityChampion
locks keylock.Map // serialises Get→mutate→Put per subject
}
// championName extracts the comparable name field for champname helpers.
func championName(c *AbilityChampion) string { return c.ChampionName }
func (s *state) pickRandomChampion() *AbilityChampion {
return &s.pool[rand.Intn(len(s.pool))]
}
func pickRandomAbility(c *AbilityChampion) *Ability {
return &c.Abilities[rand.Intn(len(c.Abilities))]
}
func (s *state) startFreshGame(ctx context.Context, subject string) (*gameState, error) {
target := s.pickRandomChampion()
ability := pickRandomAbility(target)
g := &gameState{
Target: target.ChampionName,
Slot: ability.Slot,
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)
}
// caption is the photo caption shown above each round-in-progress icon.
func caption(guesses, maxGuesses int) string {
return fmt.Sprintf("🔮 Guess the champion from this ability. %d/%d guesses so far.", guesses, maxGuesses)
}
// sendAbilityIcon dispatches a sendPhoto with the ability icon URL. Returns
// the bot library's error verbatim — caller decides whether to log/ignore.
func sendAbilityIcon(ctx context.Context, b *bot.Bot, chatID int64, ability *Ability, captionText string) error {
_, err := b.SendPhoto(ctx, &bot.SendPhotoParams{
ChatID: chatID,
Photo: &models.InputFileString{Data: ability.Icon},
Caption: captionText,
})
return err
}
// handleAbility is /loldle_ability [champion] — show icon if no arg, else guess.
func (s *state) handleAbility(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
return nil
}
subject := chathelper.SubjectFor(msg)
if subject == "" {
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
}
defer s.locks.Acquire(subject)()
arg := chathelper.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 := champname.FindByExactName(s.pool, game.Target, championName)
var ability *Ability
if target != nil {
ability = abilityBySlot(target, game.Slot)
}
if target == nil || ability == nil {
// Pool was refreshed mid-round and the slot is gone — drop the round.
if err := clearGame(ctx, s.kv, subject); err != nil {
return err
}
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
"Ability data was updated since this round started. "+newRoundHint)
}
if arg == "" {
return sendAbilityIcon(ctx, b, msg.Chat.ID, ability, caption(len(game.Guesses), maxGuesses))
}
guess := champname.Find(s.pool, arg, championName)
if guess == nil {
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Champion not found: %q.", arg))
}
for _, prior := range game.Guesses {
if prior == guess.ChampionName {
return chathelper.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 := chathelper.NowMillis()
game.StartedAt = &now
}
game.Guesses = append(game.Guesses, guess.ChampionName)
won := guess.ChampionName == target.ChampionName
answer := html.EscapeString(target.ChampionName)
abilityLabel := fmt.Sprintf("<i>%s</i> (%s)", html.EscapeString(ability.Name), ability.Slot)
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 chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
"🎉 Got it! That was <b>%s</b> — %s. Solved in %d/%d\n🔥 Streak: %d\n%s",
answer, abilityLabel, 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 chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
"❌ Out of guesses. Answer was <b>%s</b> — %s.\n%s",
answer, abilityLabel, newRoundHint))
default:
if err := saveGame(ctx, s.kv, subject, game); err != nil {
return err
}
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
"❌ Not <b>%s</b>. Guess %d/%d.",
html.EscapeString(guess.ChampionName), len(game.Guesses), maxGuesses))
}
}
// handleGiveup is /loldle_ability_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 := chathelper.SubjectFor(msg)
if subject == "" {
return chathelper.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 chathelper.ReplyHTML(ctx, b, msg.Chat.ID, "No active round. "+newRoundHint)
}
if _, err := recordResult(ctx, s.kv, subject, false); err != nil {
return err
}
target := champname.FindByExactName(s.pool, existing.Target, championName)
var label string
if target != nil {
if a := abilityBySlot(target, existing.Slot); a != nil {
label = fmt.Sprintf(" — <i>%s</i> (%s)", html.EscapeString(a.Name), a.Slot)
}
}
if err := clearGame(ctx, s.kv, subject); err != nil {
return err
}
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
"🏳️ Answer was <b>%s</b>%s.\n%s",
html.EscapeString(existing.Target), label, newRoundHint))
}
// handleStats is /loldle_ability_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 := chathelper.SubjectFor(msg)
if subject == "" {
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
}
st, err := loadStats(ctx, s.kv, subject)
if err != nil {
return err
}
scope := "group"
if msg.Chat.Type == models.ChatTypePrivate {
scope = "your"
}
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf(
"📊 Loldle Ability %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d",
scope, st.Played, st.Wins, chathelper.WinRate(st.Wins, st.Played), st.Streak, st.BestStreak))
}
// handleSetMax is /loldle_ability_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 := chathelper.SubjectFor(msg)
if subject == "" {
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
}
arg := chathelper.ArgAfterCommand(msg.Text)
n, err := strconv.Atoi(arg)
if err != nil || n < 1 || n > MaxGuessesCap {
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Usage: /loldle_ability_setmax <1-%d>", MaxGuessesCap))
}
if err := setMaxGuesses(ctx, s.kv, subject, n); err != nil {
return err
}
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("✅ Loldle ability max guesses set to %d (applies to the next round).", n))
}
@@ -0,0 +1,152 @@
package loldleability
import (
"context"
"strings"
"testing"
"github.com/tiennm99/miti99bot-go/internal/modules"
"github.com/tiennm99/miti99bot-go/internal/storage"
"github.com/tiennm99/miti99bot-go/internal/testutil"
)
// installAbility wires the loldle-ability module + auth (owner gates
// /loldle_ability_setmax). seedTarget + seedSlot pre-seed a game so guess
// outcomes are deterministic without hooking math/rand.
func installAbility(t *testing.T, ownerID int64, seedSubject, seedTarget, seedSlot string) (*testutil.RecordingBot, storage.KVStore) {
t.Helper()
rb := testutil.NewRecordingBot(t)
provider := storage.NewMemoryProvider()
kv := provider.For("loldle-ability")
mod := New(modules.Deps{KV: kv})
reg := &modules.Registry{
Modules: []modules.Module{{Name: "loldle-ability", Commands: mod.Commands}},
AllCommands: map[string]modules.Command{},
}
for _, c := range mod.Commands {
reg.AllCommands[c.Name] = c
}
modules.Install(rb.Bot, reg, modules.Auth{BotOwnerID: ownerID})
if seedTarget != "" {
g := &gameState{Target: seedTarget, Slot: seedSlot, Guesses: []string{}}
if err := saveGame(context.Background(), kv, seedSubject, g); err != nil {
t.Fatalf("seed game: %v", err)
}
}
return rb, kv
}
// /loldle_ability with no arg sends a photo, not a text message.
func TestAbility_NoArgSendsPhoto(t *testing.T) {
rb, _ := installAbility(t, 0, "1", "Aatrox", "Q")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_ability"))
calls := rb.Sent()
if len(calls) == 0 {
t.Fatal("/loldle_ability produced no reply")
}
last := calls[len(calls)-1]
if last.Method != "sendPhoto" {
t.Errorf("method = %q, want sendPhoto", last.Method)
}
// Aatrox Q icon — DDragon URL pattern.
photo := last.Form["photo"]
if !strings.Contains(photo, "AatroxQ") || !strings.Contains(photo, "ddragon.leagueoflegends.com") {
t.Errorf("photo = %q, want Aatrox Q DDragon URL", photo)
}
caption := last.Form["caption"]
if !strings.Contains(caption, "Guess the champion") {
t.Errorf("caption missing prompt: %q", caption)
}
}
func TestAbility_Win(t *testing.T) {
rb, _ := installAbility(t, 0, "1", "Aatrox", "Q")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_ability aatrox"))
got := rb.LastSent().Text()
if !strings.Contains(got, "Got it") {
t.Errorf("win reply missing 'Got it': %q", got)
}
if !strings.Contains(got, "Aatrox") {
t.Errorf("win reply missing 'Aatrox': %q", got)
}
// Ability label format: <i>Name</i> (Slot) — the slot must surface so the
// player sees which ability the bot was thinking of.
if !strings.Contains(got, "(Q)") {
t.Errorf("win reply missing slot tag (Q): %q", got)
}
}
func TestAbility_UnknownChampion(t *testing.T) {
rb, _ := installAbility(t, 0, "1", "Aatrox", "Q")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_ability ZilbeanZ"))
got := rb.LastSent().Text()
if !strings.Contains(got, "Champion not found") {
t.Errorf("unknown champion reject: %q", got)
}
}
func TestAbility_DuplicateGuessRejected(t *testing.T) {
rb, _ := installAbility(t, 0, "1", "Aatrox", "Q")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_ability ahri"))
rb.Reset()
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_ability ahri"))
got := rb.LastSent().Text()
if !strings.Contains(got, "already guessed") {
t.Errorf("duplicate-guess reply: %q", got)
}
}
func TestAbilityGiveup_RevealsAnswerAndAbility(t *testing.T) {
rb, _ := installAbility(t, 0, "1", "Aatrox", "Q")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_ability_giveup"))
got := rb.LastSent().Text()
if !strings.Contains(got, "Aatrox") {
t.Errorf("/loldle_ability_giveup should reveal Aatrox: %q", got)
}
if !strings.Contains(got, "(Q)") {
t.Errorf("/loldle_ability_giveup should include slot label: %q", got)
}
}
func TestAbilityStats_Empty(t *testing.T) {
rb, _ := installAbility(t, 0, "", "", "")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_ability_stats"))
got := rb.LastSent().Text()
for _, want := range []string{"Played: 0", "Wins: 0 (0%)"} {
if !strings.Contains(got, want) {
t.Errorf("/loldle_ability_stats empty missing %q; got %q", want, got)
}
}
}
func TestAbilitySetMax_OwnerSucceeds(t *testing.T) {
rb, kv := installAbility(t, 999, "", "", "")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_ability_setmax 3"))
got := rb.LastSent().Text()
if !strings.Contains(got, "max guesses set to 3") {
t.Errorf("/loldle_ability_setmax reply: %q", got)
}
var cfg roundConfig
if err := kv.GetJSON(context.Background(), configKey("999"), &cfg); err != nil {
t.Fatalf("expected config persisted: %v", err)
}
if cfg.MaxGuesses != 3 {
t.Errorf("MaxGuesses persisted = %d, want 3", cfg.MaxGuesses)
}
}
func TestAbilitySetMax_DeniedToNonOwner(t *testing.T) {
rb, _ := installAbility(t, 999, "", "", "")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/loldle_ability_setmax 5"))
if calls := rb.Sent(); len(calls) != 0 {
t.Errorf("non-owner /loldle_ability_setmax replied: %+v", calls)
}
}
@@ -0,0 +1,39 @@
package loldleability
import (
"github.com/tiennm99/miti99bot-go/internal/modules"
)
// New is the loldle-ability 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_ability",
Visibility: modules.VisibilityPublic,
Description: "Ability loldle — guess the champion from an ability icon",
Handler: s.handleAbility,
},
{
Name: "loldle_ability_giveup",
Visibility: modules.VisibilityPublic,
Description: "Reveal the current ability loldle answer",
Handler: s.handleGiveup,
},
{
Name: "loldle_ability_stats",
Visibility: modules.VisibilityPublic,
Description: "Show your ability loldle stats (wins, streak)",
Handler: s.handleStats,
},
{
Name: "loldle_ability_setmax",
Visibility: modules.VisibilityPrivate,
Description: "Override ability loldle max guesses per round (1-10)",
Handler: s.handleSetMax,
},
},
}
}
@@ -0,0 +1,55 @@
package loldleability
import (
"strings"
"testing"
"github.com/tiennm99/miti99bot-go/internal/champname"
)
func TestLoadPool_AbilitiesNonEmpty(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 len(c.Abilities) == 0 {
t.Errorf("empty abilities record leaked through filter: %s", c.ChampionName)
}
}
got := champname.FindByExactName(pool, "Aatrox", championName)
if got == nil {
t.Fatal("expected Aatrox in pool")
}
// Aatrox should have all 5 standard ability slots present.
slots := map[string]bool{}
for _, a := range got.Abilities {
slots[a.Slot] = true
if !strings.HasPrefix(a.Icon, "https://ddragon.leagueoflegends.com/cdn/") {
t.Errorf("Aatrox ability %s icon is not a DDragon URL: %q", a.Slot, a.Icon)
}
}
for _, want := range []string{"P", "Q", "W", "E", "R"} {
if !slots[want] {
t.Errorf("Aatrox missing slot %q", want)
}
}
}
func TestAbilityBySlot(t *testing.T) {
c := &AbilityChampion{
ChampionName: "Test",
Abilities: []Ability{
{Slot: "P", Name: "Passive"},
{Slot: "Q", Name: "Q ability"},
{Slot: "R", Name: "R ability"},
},
}
if got := abilityBySlot(c, "Q"); got == nil || got.Name != "Q ability" {
t.Errorf("abilityBySlot(Q) = %v, want 'Q ability'", got)
}
// Unknown slot → nil (caller treats as refresh signal).
if got := abilityBySlot(c, "W"); got != nil {
t.Errorf("abilityBySlot(W) = %v, want nil (slot not present)", got)
}
}
+127
View File
@@ -0,0 +1,127 @@
package loldleability
import (
"context"
"errors"
"fmt"
"github.com/tiennm99/miti99bot-go/internal/storage"
)
// Round-length defaults. Mirror JS: 5 default, capped at 10 via
// /loldle_ability_setmax.
const (
MaxGuesses = 5
MaxGuessesCap = 10
)
// gameState differs from emoji/quote: it locks the chosen ability slot at
// round start so the SAME icon shows across every turn until the round ends.
// Field tags match JS.
type gameState struct {
Target string `json:"target"`
Slot string `json:"slot"` // ability slot — P, Q, W, E, R
Guesses []string `json:"guesses"`
StartedAt *int64 `json:"startedAt"`
}
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("loldleability 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("loldleability 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("loldleability 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("loldleability 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("loldleability 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("loldleability 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("loldleability: 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("loldleability setMaxGuesses: %w", err)
}
return nil
}
@@ -0,0 +1,81 @@
package loldleability
import (
"context"
"encoding/json"
"testing"
"github.com/tiennm99/miti99bot-go/internal/storage"
)
// gameState gains a `slot` field vs emoji/quote — locks the chosen ability
// at round start. JSON wire format must include `slot`.
func TestGameState_IncludesSlotField(t *testing.T) {
g := gameState{Target: "Aatrox", Slot: "Q", Guesses: []string{}}
b, err := json.Marshal(g)
if err != nil {
t.Fatal(err)
}
want := `{"target":"Aatrox","slot":"Q","guesses":[],"startedAt":null}`
if string(b) != want {
t.Errorf("marshal:\ngot %s\nwant %s", b, want)
}
}
// JS-wire-format decode parity: a record written by the JS bot must decode
// directly. Locks the slot field name + null-startedAt round-trip.
func TestGameState_DecodeFromJSWire(t *testing.T) {
var g gameState
raw := []byte(`{"target":"Ahri","slot":"E","guesses":["Akali"],"startedAt":1700000000000}`)
if err := json.Unmarshal(raw, &g); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if g.Target != "Ahri" || g.Slot != "E" || len(g.Guesses) != 1 || g.StartedAt == nil || *g.StartedAt != 1700000000000 {
t.Errorf("decoded: %+v", g)
}
}
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 TestRecordResult_StreakSequence(t *testing.T) {
ctx := context.Background()
kv := storage.NewMemoryKVStore()
s, _ := recordResult(ctx, kv, "u1", true)
if s.Streak != 1 || s.Wins != 1 {
t.Errorf("first win: %+v", s)
}
s, _ = recordResult(ctx, kv, "u1", false)
if s.Streak != 0 || s.BestStreak != 1 {
t.Errorf("loss after streak=1: %+v", s)
}
}
func TestSaveLoadClear_RoundTrip(t *testing.T) {
ctx := context.Background()
kv := storage.NewMemoryKVStore()
at := int64(42)
want := &gameState{Target: "Aatrox", Slot: "R", 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.Slot != "R" {
t.Errorf("round-trip lost slot: %+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)
}
}
@@ -69,6 +69,7 @@ A small shared package would help, but keep modules independent (KISS) until dup
This phase ships in five sub-cooks (one per module — each is large enough to risk context exhaustion):
- **6a:** loldle-emoji — 172-record emoji clue dict, binary scoring, simplest variant. ✅
- **6b:** loldle-quote — quote-pool variant, default 6 guesses. ✅ (consumes the shared `chathelper` + `champname` packages extracted in fix-all-review-findings Phase 03)
- **6c:** loldle-ability — DDragon ability-icon URL builder, sendPhoto reply, gameState gains a `slot` field so the same icon shows across guesses. ✅
- **6c (next):** loldle-ability — DDragon ability-icon URL builder, sendPhoto.
- **6d (next):** loldle-splash — DDragon splash URL builder, sendPhoto.
- **6e (next):** lolschedule — HTTP client to lolesports/leaguepedia API; no game state, different shape entirely.
@@ -76,7 +77,8 @@ This phase ships in five sub-cooks (one per module — each is large enough to r
## Success Criteria
- [x] loldle-emoji responds to `/loldle_emoji`, `/loldle_emoji_giveup`, `/loldle_emoji_stats`, `/loldle_emoji_setmax`
- [x] loldle-quote responds to `/loldle_quote`, `/loldle_quote_giveup`, `/loldle_quote_stats`, `/loldle_quote_setmax`
- [ ] Ability + splash images render in Telegram (no broken-image markers) — deferred to 6c/6d
- [x] loldle-ability responds to `/loldle_ability`, `/loldle_ability_giveup`, `/loldle_ability_stats`, `/loldle_ability_setmax`; sendPhoto path uses the DDragon icon URL directly
- [ ] Splash images render in Telegram (no broken-image markers) — deferred to 6d
- [ ] `/lolschedule today` matches JS behavior — deferred to 6e
- [x] All variants share consistent guess-count limits matching JS (emoji 5, quote 6 — JS parity)
- [x] Ported tests pass for loldle-emoji + loldle-quote (lookup, state, render, JS-wire-format decode, handler integration)