mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-06-08 04:17:16 +00:00
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:
+4
-3
@@ -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
@@ -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
@@ -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 <champion></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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user