mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-08 22:20:23 +00:00
feat(modules): port loldle-splash
Phase 6d of the go-port-cloud-run plan. Adds the fourth loldle variant — guess the champion from a splash art (any skin, including non-Default). - internal/modules/loldlesplash: champions.go (embed splashes.json, 10557-line DDragon-sourced pool with SplashChampion + Skin types), state.go (gameState gains a `skinId` field so the same splash shows across guesses; default 4 guesses — splash is harder than ability since non-Default skins are in rotation), handlers.go (sendPhoto path uses the DDragon CDN splash URL via models.InputFileString), loldlesplash.go (Module Factory). - Reuses internal/modules/util/chathelper and internal/champname. - 4 commands wired: loldle_splash (public), loldle_splash_giveup (public), loldle_splash_stats (public), loldle_splash_setmax (private). - 13 tests: lookup (embed shape + DDragon URL prefix + Default skin invariant), state (skinId round-trip + JS-wire-format decode + default 4), handlers (sendPhoto with correct URL, win, unknown, giveup with skin label, stats, setmax owner + non-owner). go test -race -count=1 ./... clean (18 packages); golangci-lint clean.
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
||||
"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/loldlesplash"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/misc"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/wordle"
|
||||
@@ -37,6 +38,7 @@ func factories() map[string]modules.Factory {
|
||||
"loldle-ability": loldleability.New,
|
||||
"loldle-emoji": loldleemoji.New,
|
||||
"loldle-quote": loldlequote.New,
|
||||
"loldle-splash": loldlesplash.New,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Package loldlesplash ports the JS loldle-splash variant — guess the
|
||||
// champion from a randomly-chosen splash art (any skin, including Default).
|
||||
// Pool seeded from Riot Data Dragon. Uses Telegram's sendPhoto with the
|
||||
// DDragon CDN URL directly — no binary embedding.
|
||||
//
|
||||
// Round state persists `{target, skinId, guesses, startedAt}` so the SAME
|
||||
// splash shows across all turns until the round ends.
|
||||
package loldlesplash
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Skin is one champion-skin record: numeric id, display name, splash URL.
|
||||
type Skin struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"` // absolute DDragon CDN URL
|
||||
}
|
||||
|
||||
// SplashChampion is one record of splashes.json — championName + the full
|
||||
// skin list including the Default skin.
|
||||
type SplashChampion struct {
|
||||
ChampionName string `json:"championName"`
|
||||
Skins []Skin `json:"skins"`
|
||||
}
|
||||
|
||||
//go:embed data/splashes.json
|
||||
var rawSplashes []byte
|
||||
|
||||
// loadPool parses splashes.json and drops champions with no skins. Panics
|
||||
// on malformed data — corrupt regen is a build-time bug.
|
||||
func loadPool() []SplashChampion {
|
||||
var all []SplashChampion
|
||||
if err := json.Unmarshal(rawSplashes, &all); err != nil {
|
||||
panic(fmt.Sprintf("loldlesplash: cannot decode splashes.json: %v", err))
|
||||
}
|
||||
out := make([]SplashChampion, 0, len(all))
|
||||
for _, c := range all {
|
||||
if len(c.Skins) > 0 {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
panic("loldlesplash: splashes.json contained no usable records")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// skinByID finds the skin with the given numeric id. Returns nil when id
|
||||
// isn't present — caller treats that as a refresh signal (start over).
|
||||
func skinByID(c *SplashChampion, id int) *Skin {
|
||||
for i := range c.Skins {
|
||||
if c.Skins[i].ID == id {
|
||||
return &c.Skins[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
||||
package loldlesplash
|
||||
|
||||
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_splash</code> or <code>/loldle_splash <champion></code> to start a new round."
|
||||
|
||||
// state captures everything a loldle-splash handler needs at runtime. Built
|
||||
// once per Factory call and shared across the four command closures.
|
||||
type state struct {
|
||||
kv storage.KVStore
|
||||
pool []SplashChampion
|
||||
locks keylock.Map // serialises Get→mutate→Put per subject
|
||||
}
|
||||
|
||||
// championName extracts the comparable name field for champname helpers.
|
||||
func championName(c *SplashChampion) string { return c.ChampionName }
|
||||
|
||||
func (s *state) pickRandomChampion() *SplashChampion {
|
||||
return &s.pool[rand.Intn(len(s.pool))]
|
||||
}
|
||||
|
||||
func pickRandomSkin(c *SplashChampion) *Skin {
|
||||
return &c.Skins[rand.Intn(len(c.Skins))]
|
||||
}
|
||||
|
||||
func (s *state) startFreshGame(ctx context.Context, subject string) (*gameState, error) {
|
||||
target := s.pickRandomChampion()
|
||||
skin := pickRandomSkin(target)
|
||||
g := &gameState{
|
||||
Target: target.ChampionName,
|
||||
SkinID: skin.ID,
|
||||
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 splash.
|
||||
func caption(guesses, maxGuesses int) string {
|
||||
return fmt.Sprintf("🎨 Guess the champion from this splash art. %d/%d guesses so far.", guesses, maxGuesses)
|
||||
}
|
||||
|
||||
// sendSplash dispatches sendPhoto with the splash URL. Returns the bot
|
||||
// library's error verbatim — caller decides whether to log/ignore.
|
||||
func sendSplash(ctx context.Context, b *bot.Bot, chatID int64, skin *Skin, captionText string) error {
|
||||
_, err := b.SendPhoto(ctx, &bot.SendPhotoParams{
|
||||
ChatID: chatID,
|
||||
Photo: &models.InputFileString{Data: skin.URL},
|
||||
Caption: captionText,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// handleSplash is /loldle_splash [champion] — show splash if no arg, else guess.
|
||||
func (s *state) handleSplash(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 skin *Skin
|
||||
if target != nil {
|
||||
skin = skinByID(target, game.SkinID)
|
||||
}
|
||||
if target == nil || skin == nil {
|
||||
// Pool was refreshed mid-round and the skin is gone — drop the round.
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
"Splash data was updated since this round started. "+newRoundHint)
|
||||
}
|
||||
|
||||
if arg == "" {
|
||||
return sendSplash(ctx, b, msg.Chat.ID, skin, 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)
|
||||
skinLabel := fmt.Sprintf("<i>%s</i> skin", html.EscapeString(skin.Name))
|
||||
|
||||
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> in %s. Solved in %d/%d\n🔥 Streak: %d\n%s",
|
||||
answer, skinLabel, 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> in %s.\n%s",
|
||||
answer, skinLabel, 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_splash_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 sk := skinByID(target, existing.SkinID); sk != nil {
|
||||
label = fmt.Sprintf(" in <i>%s</i> skin", html.EscapeString(sk.Name))
|
||||
}
|
||||
}
|
||||
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_splash_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 Splash %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_splash_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_splash_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 splash max guesses set to %d (applies to the next round).", n))
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package loldlesplash
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// installSplash wires the loldle-splash module + auth (owner gates
|
||||
// /loldle_splash_setmax). seedTarget + seedSkinID pre-seed a game so guess
|
||||
// outcomes are deterministic without hooking math/rand.
|
||||
func installSplash(t *testing.T, ownerID int64, seedSubject, seedTarget string, seedSkinID int) (*testutil.RecordingBot, storage.KVStore) {
|
||||
t.Helper()
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
provider := storage.NewMemoryProvider()
|
||||
kv := provider.For("loldle-splash")
|
||||
mod := New(modules.Deps{KV: kv})
|
||||
reg := &modules.Registry{
|
||||
Modules: []modules.Module{{Name: "loldle-splash", 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, SkinID: seedSkinID, Guesses: []string{}}
|
||||
if err := saveGame(context.Background(), kv, seedSubject, g); err != nil {
|
||||
t.Fatalf("seed game: %v", err)
|
||||
}
|
||||
}
|
||||
return rb, kv
|
||||
}
|
||||
|
||||
func TestSplash_NoArgSendsPhoto(t *testing.T) {
|
||||
rb, _ := installSplash(t, 0, "1", "Aatrox", 0)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_splash"))
|
||||
|
||||
calls := rb.Sent()
|
||||
if len(calls) == 0 {
|
||||
t.Fatal("/loldle_splash produced no reply")
|
||||
}
|
||||
last := calls[len(calls)-1]
|
||||
if last.Method != "sendPhoto" {
|
||||
t.Errorf("method = %q, want sendPhoto", last.Method)
|
||||
}
|
||||
photo := last.Form["photo"]
|
||||
if !strings.Contains(photo, "Aatrox_0.jpg") {
|
||||
t.Errorf("photo = %q, want Aatrox_0 splash URL", photo)
|
||||
}
|
||||
caption := last.Form["caption"]
|
||||
if !strings.Contains(caption, "splash art") {
|
||||
t.Errorf("caption missing prompt: %q", caption)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplash_Win(t *testing.T) {
|
||||
rb, _ := installSplash(t, 0, "1", "Aatrox", 0)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_splash 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)
|
||||
}
|
||||
// Skin label format: "in <i>Default</i> skin"
|
||||
if !strings.Contains(got, "Default") {
|
||||
t.Errorf("win reply missing skin name: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplash_UnknownChampion(t *testing.T) {
|
||||
rb, _ := installSplash(t, 0, "1", "Aatrox", 0)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_splash ZilbeanZ"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Champion not found") {
|
||||
t.Errorf("unknown champion reject: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplashGiveup_RevealsAnswerAndSkin(t *testing.T) {
|
||||
rb, _ := installSplash(t, 0, "1", "Aatrox", 0)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_splash_giveup"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Aatrox") {
|
||||
t.Errorf("/loldle_splash_giveup should reveal Aatrox: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Default") {
|
||||
t.Errorf("/loldle_splash_giveup should include skin label: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplashStats_Empty(t *testing.T) {
|
||||
rb, _ := installSplash(t, 0, "", "", 0)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_splash_stats"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
for _, want := range []string{"Played: 0", "Wins: 0 (0%)"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("/loldle_splash_stats empty missing %q; got %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplashSetMax_OwnerSucceeds(t *testing.T) {
|
||||
rb, kv := installSplash(t, 999, "", "", 0)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_splash_setmax 5"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "max guesses set to 5") {
|
||||
t.Errorf("/loldle_splash_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 != 5 {
|
||||
t.Errorf("MaxGuesses persisted = %d, want 5", cfg.MaxGuesses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplashSetMax_DeniedToNonOwner(t *testing.T) {
|
||||
rb, _ := installSplash(t, 999, "", "", 0)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/loldle_splash_setmax 5"))
|
||||
if calls := rb.Sent(); len(calls) != 0 {
|
||||
t.Errorf("non-owner /loldle_splash_setmax replied: %+v", calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package loldlesplash
|
||||
|
||||
import (
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
)
|
||||
|
||||
// New is the loldle-splash 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_splash",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Splash loldle — guess the champion from a splash art",
|
||||
Handler: s.handleSplash,
|
||||
},
|
||||
{
|
||||
Name: "loldle_splash_giveup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Reveal the current splash loldle answer",
|
||||
Handler: s.handleGiveup,
|
||||
},
|
||||
{
|
||||
Name: "loldle_splash_stats",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show your splash loldle stats (wins, streak)",
|
||||
Handler: s.handleStats,
|
||||
},
|
||||
{
|
||||
Name: "loldle_splash_setmax",
|
||||
Visibility: modules.VisibilityPrivate,
|
||||
Description: "Override splash loldle max guesses per round (1-10)",
|
||||
Handler: s.handleSetMax,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package loldlesplash
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/champname"
|
||||
)
|
||||
|
||||
func TestLoadPool_SkinsNonEmpty(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.Skins) == 0 {
|
||||
t.Errorf("empty skins record leaked through filter: %s", c.ChampionName)
|
||||
}
|
||||
}
|
||||
got := champname.FindByExactName(pool, "Aatrox", championName)
|
||||
if got == nil {
|
||||
t.Fatal("expected Aatrox in pool")
|
||||
}
|
||||
if len(got.Skins) < 2 {
|
||||
t.Errorf("Aatrox should have multiple skins, got %d", len(got.Skins))
|
||||
}
|
||||
for _, s := range got.Skins {
|
||||
if !strings.HasPrefix(s.URL, "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/") {
|
||||
t.Errorf("Aatrox skin %q URL is not a DDragon splash URL: %q", s.Name, s.URL)
|
||||
}
|
||||
}
|
||||
// Default skin (id=0) must always be present and named "Default".
|
||||
if got.Skins[0].ID != 0 || got.Skins[0].Name != "Default" {
|
||||
t.Errorf("Aatrox first skin = (%d, %q), want (0, Default)", got.Skins[0].ID, got.Skins[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkinByID(t *testing.T) {
|
||||
c := &SplashChampion{
|
||||
ChampionName: "Test",
|
||||
Skins: []Skin{
|
||||
{ID: 0, Name: "Default"},
|
||||
{ID: 3, Name: "Mecha"},
|
||||
{ID: 5, Name: "Sea Hunter"},
|
||||
},
|
||||
}
|
||||
if got := skinByID(c, 3); got == nil || got.Name != "Mecha" {
|
||||
t.Errorf("skinByID(3) = %v, want Mecha", got)
|
||||
}
|
||||
// Unknown id → nil (caller treats as refresh signal).
|
||||
if got := skinByID(c, 99); got != nil {
|
||||
t.Errorf("skinByID(99) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package loldlesplash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
// Round-length defaults. Mirror JS state.js: 4 default (harder than ability;
|
||||
// any skin can be drawn so non-Default art is in the rotation), capped at
|
||||
// 10 via /loldle_splash_setmax.
|
||||
const (
|
||||
MaxGuesses = 4
|
||||
MaxGuessesCap = 10
|
||||
)
|
||||
|
||||
// gameState locks the chosen skin id at round start so the SAME splash art
|
||||
// shows across every turn until the round ends. Field tags match JS.
|
||||
type gameState struct {
|
||||
Target string `json:"target"`
|
||||
SkinID int `json:"skinId"` // numeric skin id from splashes.json
|
||||
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("loldlesplash 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("loldlesplash 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("loldlesplash 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("loldlesplash 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("loldlesplash 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("loldlesplash 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("loldlesplash: 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("loldlesplash setMaxGuesses: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package loldlesplash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
// gameState gains a `skinId` field — locks the chosen splash at round start.
|
||||
func TestGameState_IncludesSkinIDField(t *testing.T) {
|
||||
g := gameState{Target: "Aatrox", SkinID: 3, Guesses: []string{}}
|
||||
b, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"target":"Aatrox","skinId":3,"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 skinId field name + null-startedAt round-trip.
|
||||
func TestGameState_DecodeFromJSWire(t *testing.T) {
|
||||
var g gameState
|
||||
raw := []byte(`{"target":"Ahri","skinId":7,"guesses":["Akali"],"startedAt":1700000000000}`)
|
||||
if err := json.Unmarshal(raw, &g); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if g.Target != "Ahri" || g.SkinID != 7 || len(g.Guesses) != 1 || g.StartedAt == nil || *g.StartedAt != 1700000000000 {
|
||||
t.Errorf("decoded: %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMaxGuesses_DefaultsToFour(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 != 4 {
|
||||
t.Errorf("MaxGuesses = %d, want 4 (parity with JS — splash is harder than ability)", 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", SkinID: 5, 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.SkinID != 5 {
|
||||
t.Errorf("round-trip lost skinId: %+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)
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,7 @@ This phase ships in five sub-cooks (one per module — each is large enough to r
|
||||
- **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. ✅
|
||||
- **6d:** loldle-splash — DDragon splash URL, sendPhoto reply, gameState locks `skinId` so the same splash shows across guesses. Default 4 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.
|
||||
@@ -78,7 +79,7 @@ This phase ships in five sub-cooks (one per module — each is large enough to r
|
||||
- [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`
|
||||
- [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
|
||||
- [x] loldle-splash responds to `/loldle_splash`, `/loldle_splash_giveup`, `/loldle_splash_stats`, `/loldle_splash_setmax`; sendPhoto path uses the DDragon splash URL directly
|
||||
- [ ] `/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