diff --git a/README.md b/README.md index ddd9e6a..5f49315 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Mid-port. Code is on `main`; first AWS deploy still pending the user's manual AW | Track | What | Status | |-------|------|--------| -| Modules | util, misc, wordle, loldle (+ ability/emoji/quote/splash variants), lolschedule, semantle, doantu, twentyq | **done** | +| Modules | util, misc, wordle, loldle, lolschedule, twentyq, trading | **done** | | Storage | KVStore interface; in-memory + Firestore + **DynamoDB** providers | **done** | | AI | Gemini API client (`internal/ai`) | **done** | | AWS IaC | SAM template + Makefile + GH Actions OIDC deploy | **done** | diff --git a/cmd/server/main.go b/cmd/server/main.go index 586b18f..98085f4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -16,15 +16,9 @@ import ( "github.com/tiennm99/miti99bot-go/internal/log" "github.com/tiennm99/miti99bot-go/internal/metrics" "github.com/tiennm99/miti99bot-go/internal/modules" - "github.com/tiennm99/miti99bot-go/internal/modules/doantu" "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/loldlesplash" "github.com/tiennm99/miti99bot-go/internal/modules/lolschedule" "github.com/tiennm99/miti99bot-go/internal/modules/misc" - "github.com/tiennm99/miti99bot-go/internal/modules/semantle" "github.com/tiennm99/miti99bot-go/internal/modules/trading" "github.com/tiennm99/miti99bot-go/internal/modules/twentyq" "github.com/tiennm99/miti99bot-go/internal/modules/util" @@ -39,19 +33,13 @@ import ( // import cycle (modules → util → modules). func factories() map[string]modules.Factory { return map[string]modules.Factory{ - "util": util.New, - "misc": misc.New, - "wordle": wordle.New, - "loldle": loldle.New, - "loldle-ability": loldleability.New, - "loldle-emoji": loldleemoji.New, - "loldle-quote": loldlequote.New, - "loldle-splash": loldlesplash.New, - "lolschedule": lolschedule.New, - "semantle": semantle.New, - "doantu": doantu.New, - "twentyq": twentyq.New, - "trading": trading.New, + "util": util.New, + "misc": misc.New, + "wordle": wordle.New, + "loldle": loldle.New, + "lolschedule": lolschedule.New, + "twentyq": twentyq.New, + "trading": trading.New, } } @@ -92,9 +80,9 @@ func main() { log.Fatal("telegram bot init failed", "err", err) } - // Gemini is optional: modules that need it (semantle/twentyq) check - // for nil and refuse the command at handler time. A blank GEMINI_API_KEY - // is therefore not fatal — the rest of the bot still runs. + // Gemini is optional: twentyq checks for nil and refuses the command at + // handler time. A blank GEMINI_API_KEY is therefore not fatal — the rest + // of the bot still runs. aiClient, err := ai.NewClient(rootCtx, cfg.GeminiAPIKey) if err != nil && !errors.Is(err, ai.ErrNotConfigured) { log.Fatal("gemini init failed", "err", err) @@ -105,10 +93,9 @@ func main() { log.Info("gemini client initialised") } - reg, err := modules.Build(cfg.Modules, factories(), provider, cfg.ModuleEnv, modules.BuildOptions{ - Embedder: aiClient, - Chatter: aiClient, - Bot: b, + reg, err := modules.Build(cfg.Modules, factories(), provider, modules.BuildOptions{ + Chatter: aiClient, + Bot: b, }) if err != nil { log.Fatal("module registry build failed", "err", err) @@ -242,9 +229,8 @@ type config struct { Modules []string BotOwnerID int64 AdminUserIDs map[int64]bool - ModuleEnv map[string]string // per-module allowlist; only declared keys flow through - KVProvider string // empty = auto-detect; or "memory"|"firestore"|"dynamodb" - DynamoDBTable string // required when KVProvider=dynamodb + KVProvider string // empty = auto-detect; or "memory"|"firestore"|"dynamodb" + DynamoDBTable string // required when KVProvider=dynamodb } func loadConfig() config { @@ -264,14 +250,6 @@ func loadConfig() config { if n, err := strconv.Atoi(port); err != nil || n < 0 || n > 65535 { log.Fatal("invalid PORT", "value", port) } - // ModuleEnv is the per-module allowlist. Add a key here when a specific - // module needs it; it never auto-flows from process env. Today only - // PHOW2SIM_API_URL (doantu) is exposed — Gemini is wired through a typed - // dep, not env. - moduleEnv := map[string]string{} - if v := envMap["PHOW2SIM_API_URL"]; v != "" { - moduleEnv["PHOW2SIM_API_URL"] = v - } return config{ Port: port, TelegramBotToken: envMap["TELEGRAM_BOT_TOKEN"], @@ -283,7 +261,6 @@ func loadConfig() config { Modules: splitCSV(envMap["MODULES"]), BotOwnerID: parseInt64(envMap["BOT_OWNER_ID"]), AdminUserIDs: parseInt64Set(envMap["ADMIN_USER_IDS"]), - ModuleEnv: moduleEnv, KVProvider: envMap["KV_PROVIDER"], DynamoDBTable: envMap["DYNAMODB_TABLE"], } diff --git a/internal/ai/client.go b/internal/ai/client.go index 085c1ef..8a21fcb 100644 --- a/internal/ai/client.go +++ b/internal/ai/client.go @@ -9,13 +9,10 @@ import ( "google.golang.org/genai" ) -// Default model identifiers. Pinned strings rather than constants exposed to -// callers — modules should not pick their own model. If we ever need to A/B -// test, a higher-level config wins, not a per-module override. -const ( - embeddingModel = "text-embedding-004" // 768-dim, free tier - chatModel = "gemini-2.5-flash" // newest flash; 15 RPM / 1500 RPD free -) +// chatModel is pinned here rather than exposed to callers — modules should +// not pick their own model. If we ever need to A/B test, a higher-level +// config wins, not a per-module override. +const chatModel = "gemini-2.5-flash" // newest flash; 15 RPM / 1500 RPD free // ErrRateLimited is returned when the upstream rejected with 429 (or our // in-process per-user bucket dropped the call). Modules show a friendly @@ -54,46 +51,6 @@ func NewClient(ctx context.Context, apiKey string) (*Client, error) { return &Client{g: g}, nil } -// Embed batches `texts` into a single EmbedContent call and returns -// dense vectors in the same order. Empty input → (nil, nil). -// -// Errors are wrapped; rate-limit (HTTP 429) is mapped to ErrRateLimited -// so callers can branch on errors.Is. -func (c *Client) Embed(ctx context.Context, texts []string) ([][]float32, error) { - if c == nil || c.g == nil { - return nil, ErrNotConfigured - } - if len(texts) == 0 { - return nil, nil - } - contents := make([]*genai.Content, 0, len(texts)) - for _, t := range texts { - contents = append(contents, genai.NewContentFromText(t, genai.RoleUser)) - } - resp, err := c.g.Models.EmbedContent(ctx, embeddingModel, contents, nil) - if err != nil { - if isRateLimit(err) { - return nil, ErrRateLimited - } - return nil, fmt.Errorf("ai: EmbedContent: %w", err) - } - if resp == nil { - return nil, fmt.Errorf("ai: EmbedContent: nil response") - } - if len(resp.Embeddings) != len(texts) { - return nil, fmt.Errorf("ai: EmbedContent returned %d embeddings, want %d", - len(resp.Embeddings), len(texts)) - } - out := make([][]float32, len(texts)) - for i, e := range resp.Embeddings { - if e == nil || len(e.Values) == 0 { - return nil, fmt.Errorf("ai: EmbedContent: empty embedding at index %d", i) - } - out[i] = e.Values - } - return out, nil -} - // Generate runs a single-turn chat with `system` as the system instruction // and `user` as the user message. Returns the model's text reply. // diff --git a/internal/ai/types.go b/internal/ai/types.go index 8eb8183..ab615ff 100644 --- a/internal/ai/types.go +++ b/internal/ai/types.go @@ -1,29 +1,22 @@ // Package ai wraps Google's genai SDK with a small, mockable surface for the -// semantle/twentyq modules. The package owns: +// twentyq module. The package owns: // -// - Embedder / Chatter interfaces — what modules consume +// - Chatter interface — what modules consume // - Client struct — production implementation backed by genai // - per-user rate limiter — defends the shared 1500-RPD Gemini free tier // -// Modules accept the interfaces (not *Client) so unit tests can pass fakes +// Modules accept the interface (not *Client) so unit tests can pass fakes // without spinning up a real Gemini client. Production wiring in cmd/server -// passes the *Client (which satisfies both interfaces) into Deps. +// passes the *Client (which satisfies the interface) into Deps. package ai import "context" -// Embedder produces dense vectors for text. Used by the semantle module to -// score guess similarity. Implementations must respect ctx cancellation. -// -// On rate-limit (HTTP 429) the implementation should return a sentinel error -// — see ErrRateLimited — so callers can show a user-friendly retry message. -type Embedder interface { - Embed(ctx context.Context, texts []string) ([][]float32, error) -} - // Chatter produces a single text completion from a system + user prompt -// pair. Used by the twentyq module's judge + round-start calls. Same -// rate-limit conventions as Embedder. +// pair. Used by the twentyq module's judge + round-start calls. +// +// On rate-limit (HTTP 429) the implementation should return ErrRateLimited +// so callers can show a user-friendly retry message. type Chatter interface { Generate(ctx context.Context, system, user string) (string, error) } diff --git a/internal/champname/champname.go b/internal/champname/champname.go deleted file mode 100644 index 44df073..0000000 --- a/internal/champname/champname.go +++ /dev/null @@ -1,71 +0,0 @@ -// Package champname holds champion-name primitives shared by loldle and -// loldleemoji (and any future loldle variant). Normalize folds names to a -// comparable form; Find resolves user input to a single champion via exact -// or unique-prefix match. Generic over the champion type via a name-extractor -// closure so each module can keep its own data shape. -package champname - -import "strings" - -// Normalize folds a name to a comparable form: lowercase, alphanumeric only. -// JS-parity with util/normalize-name.js — `String(s).toLowerCase().replace(/[^a-z0-9]/g, "")`. -// -// Used for case/space/punctuation-insensitive lookup so "Kai'Sa", "kaisa", -// and "KAI SA" all collapse to the same key. -func Normalize(s string) string { - lower := strings.ToLower(s) - out := make([]byte, 0, len(lower)) - for i := 0; i < len(lower); i++ { - c := lower[i] - if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { - out = append(out, c) - } - } - return string(out) -} - -// Find resolves user input to a champion via: -// -// 1. Empty / non-alphanumeric input → no match (nil). -// 2. Exact normalised name → that champion. -// 3. Otherwise: unique normalised-prefix match → that champion. -// -// Ambiguous prefix or no hit → nil. Returning nil for ambiguous prefixes -// prevents "Ka" silently routing to whichever Ka- champion happens to be -// first in the data file. -// -// Generic over T so loldle/Champion and loldleemoji/EmojiChampion can both -// use it; nameOf extracts the champion's display name. -func Find[T any](pool []T, input string, nameOf func(*T) string) *T { - q := Normalize(input) - if q == "" { - return nil - } - for i := range pool { - if Normalize(nameOf(&pool[i])) == q { - return &pool[i] - } - } - var hit *T - for i := range pool { - if strings.HasPrefix(Normalize(nameOf(&pool[i])), q) { - if hit != nil { - return nil // ambiguous - } - hit = &pool[i] - } - } - return hit -} - -// FindByExactName looks up a champion by literal display name (no -// normalization). Used to rehydrate a stored game's target. Returns nil if -// the pool was refreshed and the target is no longer present. -func FindByExactName[T any](pool []T, name string, nameOf func(*T) string) *T { - for i := range pool { - if nameOf(&pool[i]) == name { - return &pool[i] - } - } - return nil -} diff --git a/internal/champname/champname_test.go b/internal/champname/champname_test.go deleted file mode 100644 index 7e20c4e..0000000 --- a/internal/champname/champname_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package champname - -import "testing" - -type fakeChamp struct { - Name string -} - -func nameOfFake(c *fakeChamp) string { return c.Name } - -func TestNormalize(t *testing.T) { - tests := []struct { - in, want string - }{ - {"", ""}, - {"Kai'Sa", "kaisa"}, - {"KAI SA", "kaisa"}, - {"kaisa", "kaisa"}, - {"Lee Sin", "leesin"}, - {"Dr. Mundo", "drmundo"}, - {"K9", "k9"}, - {" ", ""}, - {"!!!", ""}, - {"Aurelion Sol", "aurelionsol"}, - {"Cho'Gath", "chogath"}, - } - for _, tt := range tests { - got := Normalize(tt.in) - if got != tt.want { - t.Errorf("Normalize(%q) = %q, want %q", tt.in, got, tt.want) - } - } -} - -func TestFind(t *testing.T) { - pool := []fakeChamp{ - {Name: "Kai'Sa"}, - {Name: "Karma"}, - {Name: "Karthus"}, - {Name: "Lee Sin"}, - {Name: "Lulu"}, - {Name: "Lux"}, - } - tests := []struct { - name, in string - wantName string // "" → nil expected - }{ - {"empty input → nil", "", ""}, - {"non-alpha input → nil", "!!!", ""}, - {"exact match", "kaisa", "Kai'Sa"}, - {"exact match with punctuation", "Kai'Sa", "Kai'Sa"}, - {"exact match with spaces", "Lee Sin", "Lee Sin"}, - {"unique prefix", "kart", "Karthus"}, - {"unique prefix with case", "KART", "Karthus"}, - {"ambiguous prefix → nil", "ka", ""}, - {"ambiguous prefix lu → nil", "lu", ""}, - {"unambiguous prefix lux", "lux", "Lux"}, - {"no match", "zilean", ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := Find(pool, tt.in, nameOfFake) - if tt.wantName == "" { - if got != nil { - t.Errorf("Find(%q): got %v, want nil", tt.in, got) - } - return - } - if got == nil { - t.Fatalf("Find(%q): got nil, want %q", tt.in, tt.wantName) - } - if got.Name != tt.wantName { - t.Errorf("Find(%q): got %q, want %q", tt.in, got.Name, tt.wantName) - } - }) - } -} - -func TestFindByExactName(t *testing.T) { - pool := []fakeChamp{{Name: "Kai'Sa"}, {Name: "Karma"}} - - if got := FindByExactName(pool, "Kai'Sa", nameOfFake); got == nil || got.Name != "Kai'Sa" { - t.Errorf("FindByExactName(Kai'Sa) = %v, want hit", got) - } - // Normalisation must NOT apply — exact match only. - if got := FindByExactName(pool, "kaisa", nameOfFake); got != nil { - t.Errorf("FindByExactName(kaisa) = %v, want nil (case-sensitive)", got) - } - if got := FindByExactName(pool, "Zilean", nameOfFake); got != nil { - t.Errorf("FindByExactName(Zilean) = %v, want nil", got) - } -} diff --git a/internal/modules/doantu/api_client.go b/internal/modules/doantu/api_client.go deleted file mode 100644 index bbd2e0b..0000000 --- a/internal/modules/doantu/api_client.go +++ /dev/null @@ -1,179 +0,0 @@ -// Package doantu ports the JS Vietnamese-semantle module to Go. It uses the -// hosted phow2sim PhoW2V word2vec API (https://phow2sim.sg.miti99.com) for -// target picking + cosine similarity, NOT Gemini embeddings. Rationale: -// -// 1. text-embedding-004 was not trained for Vietnamese semantic relatedness; -// phow2sim is a domain-trained model. -// 2. phow2sim already owns the vocabulary — no need to maintain a Vietnamese -// wordlist alongside it. -// 3. JS parity: the upstream service is the same one the JS bot has been -// using; switching to embeddings would diverge behavior, not preserve it. -// -// Phase 07 plan suggested embedding both modules; this is a documented -// deviation. Update phase-07 plan + plan.md when reviewing. -package doantu - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - "time" -) - -const ( - defaultTimeout = 5 * time.Second - userAgent = "miti99bot-go/doantu" -) - -// UpstreamError is returned for every transport / decode failure. status is -// 0 for non-HTTP failures (timeout, DNS). -type UpstreamError struct { - Status int - Msg string - Body string -} - -func (e *UpstreamError) Error() string { - if e.Status > 0 { - return fmt.Sprintf("phow2sim HTTP %d: %s", e.Status, e.Msg) - } - return "phow2sim: " + e.Msg -} - -// SimResp mirrors the JS api-client similarity() shape. -type SimResp struct { - A string `json:"a"` - B string `json:"b"` - CanonicalA *string `json:"canonical_a"` - CanonicalB *string `json:"canonical_b"` - InVocabA bool `json:"in_vocab_a"` - InVocabB bool `json:"in_vocab_b"` - Similarity *float64 `json:"similarity"` -} - -// RandomResp shape from /random. -type RandomResp struct { - Word string `json:"word"` - Rank *int `json:"rank,omitempty"` -} - -// Neighbor item in /neighbors response. -type Neighbor struct { - Word string `json:"word"` - Similarity float64 `json:"similarity"` -} - -// NeighborsResp from /neighbors. -type NeighborsResp struct { - Word string `json:"word"` - Canonical *string `json:"canonical"` - InVocab bool `json:"in_vocab"` - Neighbors []Neighbor `json:"neighbors"` -} - -// Client is the phow2sim HTTP client. Zero value is unusable — call -// NewClient. Safe for concurrent use; net/http.Client is goroutine-safe. -type Client struct { - base string - hc *http.Client -} - -// NewClient builds a client against the supplied base URL (no trailing -// slash needed; we strip it). timeout=0 → defaultTimeout. -func NewClient(base string, timeout time.Duration) *Client { - if timeout <= 0 { - timeout = defaultTimeout - } - return &Client{ - base: strings.TrimRight(base, "/"), - hc: &http.Client{Timeout: timeout}, - } -} - -func (c *Client) get(ctx context.Context, path string, params url.Values, dst any) error { - full := c.base + path - if len(params) > 0 { - full += "?" + params.Encode() - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil) - if err != nil { - return &UpstreamError{Msg: "build request: " + err.Error()} - } - req.Header.Set("User-Agent", userAgent) - req.Header.Set("Accept", "application/json") - resp, err := c.hc.Do(req) - if err != nil { - return &UpstreamError{Msg: "fetch failed: " + err.Error()} - } - defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB cap - if err != nil { - return &UpstreamError{Status: resp.StatusCode, Msg: "read body: " + err.Error()} - } - if resp.StatusCode != http.StatusOK { - return &UpstreamError{Status: resp.StatusCode, Msg: "non-200", Body: truncate(string(body), 500)} - } - if err := json.Unmarshal(body, dst); err != nil { - return &UpstreamError{Status: resp.StatusCode, Msg: "decode: " + err.Error(), Body: truncate(string(body), 200)} - } - return nil -} - -// RandomWord picks a target word with optional filters (e.g. min_rank/max_rank). -func (c *Client) RandomWord(ctx context.Context, filters map[string]string) (*RandomResp, error) { - q := url.Values{} - for k, v := range filters { - q.Set(k, v) - } - var r RandomResp - if err := c.get(ctx, "/random", q, &r); err != nil { - return nil, err - } - return &r, nil -} - -// Similarity returns target↔guess cosine + canonical forms + vocab flags. -func (c *Client) Similarity(ctx context.Context, a, b string) (*SimResp, error) { - q := url.Values{} - q.Set("a", a) - q.Set("b", b) - var r SimResp - if err := c.get(ctx, "/similarity", q, &r); err != nil { - return nil, err - } - return &r, nil -} - -// Neighbors returns the top-N closest words to `word`. JS-parity default 100. -func (c *Client) Neighbors(ctx context.Context, word string, topn int) (*NeighborsResp, error) { - if topn <= 0 { - topn = 100 - } - q := url.Values{} - q.Set("word", word) - q.Set("topn", strconv.Itoa(topn)) - var r NeighborsResp - if err := c.get(ctx, "/neighbors", q, &r); err != nil { - return nil, err - } - return &r, nil -} - -// SimAPI is the small interface handlers consume — lets tests pass a fake. -type SimAPI interface { - RandomWord(ctx context.Context, filters map[string]string) (*RandomResp, error) - Similarity(ctx context.Context, a, b string) (*SimResp, error) - Neighbors(ctx context.Context, word string, topn int) (*NeighborsResp, error) -} - -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] -} diff --git a/internal/modules/doantu/doantu.go b/internal/modules/doantu/doantu.go deleted file mode 100644 index a46a102..0000000 --- a/internal/modules/doantu/doantu.go +++ /dev/null @@ -1,64 +0,0 @@ -package doantu - -import ( - "crypto/rand" - "encoding/binary" - mrand "math/rand/v2" - - "github.com/tiennm99/miti99bot-go/internal/modules" -) - -// defaultAPI is the production phow2sim instance. Override via env -// PHOW2SIM_API_URL (allowlisted in cmd/server/main.go). -const defaultAPI = "https://phow2sim.sg.miti99.com" - -// New is the doantu module Factory. Reads PHOW2SIM_API_URL from Deps.Env -// (falls back to defaultAPI). The module is always loadable — the upstream -// service handles uptime, not us. -func New(deps modules.Deps) modules.Module { - base := defaultAPI - if v, ok := deps.Env["PHOW2SIM_API_URL"]; ok && v != "" { - base = v - } - s := &state{ - kv: deps.KV, - api: NewClient(base, 0), - rng: newRNG(), - } - return modules.Module{ - Commands: []modules.Command{ - { - Name: "doantu", - Visibility: modules.VisibilityPublic, - Description: "Đoán từ — Vietnamese semantic word guessing (unlimited tries)", - Handler: s.handleDoantu, - }, - { - Name: "doantu_hint", - Visibility: modules.VisibilityPublic, - Description: "Reveal 3 related words (not the answer) to nudge your guessing", - Handler: s.handleHint, - }, - { - Name: "doantu_giveup", - Visibility: modules.VisibilityPublic, - Description: "Reveal the current doantu answer (auto-starts a fresh round)", - Handler: s.handleGiveup, - }, - { - Name: "doantu_stats", - Visibility: modules.VisibilityPublic, - Description: "Show your doantu stats", - Handler: s.handleStats, - }, - }, - } -} - -func newRNG() *mrand.Rand { - var seed [16]byte - _, _ = rand.Read(seed[:]) - s1 := binary.LittleEndian.Uint64(seed[0:8]) - s2 := binary.LittleEndian.Uint64(seed[8:16]) - return mrand.New(mrand.NewPCG(s1, s2)) -} diff --git a/internal/modules/doantu/format.go b/internal/modules/doantu/format.go deleted file mode 100644 index 4754027..0000000 --- a/internal/modules/doantu/format.go +++ /dev/null @@ -1,58 +0,0 @@ -package doantu - -import "math" - -// calibrate: phow2sim cosines already span a wide useful range — JS just -// scales linearly to 0-100 with negative clamp. No sigmoid here. -func calibrate(raw float64) float64 { - v := raw * 100 - switch { - case v < 0: - return 0 - case v > 100: - return 100 - default: - return v - } -} - -func formatWarmth(score float64) string { - pct := int(math.Round(score)) - switch { - case pct >= 100: - return "100" - case pct < 10: - return "0" + itoa(pct) - default: - return itoa(pct) - } -} - -func warmthEmoji(score float64) string { - switch { - case score >= 90: - return "🎯" - case score >= 70: - return "🔥" - case score >= 40: - return "🌡️" - case score >= 15: - return "😐" - default: - return "🥶" - } -} - -func itoa(n int) string { - if n == 0 { - return "0" - } - var buf [4]byte - i := len(buf) - for n > 0 { - i-- - buf[i] = byte('0' + n%10) - n /= 10 - } - return string(buf[i:]) -} diff --git a/internal/modules/doantu/handlers.go b/internal/modules/doantu/handlers.go deleted file mode 100644 index ec0bd53..0000000 --- a/internal/modules/doantu/handlers.go +++ /dev/null @@ -1,328 +0,0 @@ -package doantu - -import ( - "context" - "errors" - "fmt" - "html" - "math/rand/v2" - "regexp" - "strings" - "sync" - - "github.com/go-telegram/bot" - "github.com/go-telegram/bot/models" - - "github.com/tiennm99/miti99bot-go/internal/keylock" - "github.com/tiennm99/miti99bot-go/internal/log" - "github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper" - "github.com/tiennm99/miti99bot-go/internal/storage" -) - -const upstreamFail = "⚠️ Upstream hiccup — try again in a few seconds." - -// JS-parity rank band: keep targets in the top-frequency band so the game -// stays guessable. -var randomFilters = map[string]string{"min_rank": "100", "max_rank": "1000"} - -type state struct { - kv storage.KVStore - api SimAPI - rngMu sync.Mutex - rng *rand.Rand - locks keylock.Map -} - -func (s *state) startFresh(ctx context.Context, subject string) (*GameState, error) { - picked, err := s.api.RandomWord(ctx, randomFilters) - if err != nil { - return nil, err - } - target := strings.ToLower(picked.Word) - if target == "" { - return nil, &UpstreamError{Msg: "empty target from RandomWord"} - } - g := &GameState{Target: target, StartedAt: nil, Solved: false, Guesses: []Guess{}} - if err := saveGame(ctx, s.kv, subject, g); err != nil { - return nil, err - } - return g, nil -} - -func (s *state) getOrInit(ctx context.Context, subject string) (*GameState, error) { - existing, err := loadGame(ctx, s.kv, subject) - if err != nil { - return nil, err - } - if existing != nil && !existing.Solved { - return existing, nil - } - return s.startFresh(ctx, subject) -} - -func (s *state) handleDoantu(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) - game, err := s.getOrInit(ctx, subject) - if err != nil { - log.Warn("doantu random failed", "err", err) - return chathelper.Reply(ctx, b, msg.Chat.ID, upstreamFail) - } - if arg == "" { - return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, renderBoard(game.Guesses, "")) - } - return s.submitGuess(ctx, b, msg, subject, game, arg) -} - -func (s *state) submitGuess(ctx context.Context, b *bot.Bot, msg *models.Message, subject string, game *GameState, arg string) error { - guess := normalize(arg) - if !isValidShape(guess) { - return chathelper.Reply(ctx, b, msg.Chat.ID, "Please provide a Vietnamese word (letters + optional single spaces).") - } - for _, g := range game.Guesses { - if g.Word == guess || g.Canonical == guess { - return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, - fmt.Sprintf("🔁 %s was already guessed this round — try another word.", - html.EscapeString(guess))) - } - } - - res, err := s.api.Similarity(ctx, game.Target, guess) - if err != nil { - log.Warn("doantu similarity failed", "err", err) - return chathelper.Reply(ctx, b, msg.Chat.ID, upstreamFail) - } - // Target OOV — JS-parity reset behaviour. The recorded round was seeded - // pre-vocab-change; let player start fresh instead of fighting a ghost. - if !res.InVocabA { - log.Warn("doantu target OOV", "target", game.Target) - _ = clearGame(ctx, s.kv, subject) - return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, - "⚠️ This round's target is no longer valid (upstream vocabulary changed). "+ - "Send /doantu again to start a fresh round.") - } - if !res.InVocabB || res.Similarity == nil { - return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, - fmt.Sprintf("🤔 %s isn't in the vocabulary.", html.EscapeString(guess))) - } - canonical := guess - if res.CanonicalB != nil && *res.CanonicalB != "" { - canonical = strings.ToLower(*res.CanonicalB) - } - - for _, g := range game.Guesses { - if g.Canonical == canonical { - return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, - fmt.Sprintf("🔁 %s was already guessed this round — try another word.", - html.EscapeString(canonical))) - } - } - - entry := Guess{Word: guess, Canonical: canonical, Similarity: *res.Similarity} - game.Guesses = append(game.Guesses, entry) - if game.StartedAt == nil { - now := chathelper.NowMillis() - game.StartedAt = &now - } - - if entry.Canonical == game.Target { - game.Solved = true - count := len(game.Guesses) - if _, err := recordResult(ctx, s.kv, subject, true, count, chathelper.NowMillis()); err != nil { - return err - } - if err := clearGame(ctx, s.kv, subject); err != nil { - return err - } - board := renderBoard(game.Guesses, entry.Canonical) - return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, - fmt.Sprintf("%s\n✅ Solved in %d guess%s!", board, count, plural(count))) - } - - if err := saveGame(ctx, s.kv, subject, game); err != nil { - return err - } - body := renderGuess(entry) + "\n" + renderBoard(game.Guesses, entry.Canonical) - return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, body) -} - -// playableWord matches lowercase Unicode letter / mark / underscore tokens. -// Used to filter neighbor responses that include foreign place names. -var playableWord = regexp.MustCompile(`^[\p{Ll}\p{M}_]+$`) - -func looksVietnamese(word string) bool { - if strings.Contains(word, "_") { - return true - } - for _, r := range word { - if r > 0x7f { - return true - } - } - return false -} - -func (s *state) pickHintWords(target string, neighbors []Neighbor, alreadyGuessed []string, count int) []Neighbor { - guessedSet := make(map[string]struct{}, len(alreadyGuessed)) - for _, g := range alreadyGuessed { - guessedSet[g] = struct{}{} - } - var playable []Neighbor - for _, n := range neighbors { - if !playableWord.MatchString(n.Word) || !looksVietnamese(n.Word) { - continue - } - if strings.Contains(n.Word, target) || strings.Contains(target, n.Word) { - continue - } - if _, dup := guessedSet[n.Word]; dup { - continue - } - playable = append(playable, n) - } - // Skip top 20% so hints stay "warm but not hot" (JS-parity). - skip := len(playable) / 5 - if skip > 20 { - skip = 20 - } - if skip >= len(playable) { - return nil - } - pool := playable[skip:] - want := count - if want > len(pool) { - want = len(pool) - } - if want <= 0 { - return nil - } - // Reservoir-style sample without replacement. - s.rngMu.Lock() - defer s.rngMu.Unlock() - idx := s.rng.Perm(len(pool))[:want] - out := make([]Neighbor, want) - for i, j := range idx { - out[i] = pool[j] - } - return out -} - -func (s *state) handleHint(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.") - } - game, err := loadGame(ctx, s.kv, subject) - if err != nil { - return err - } - if game == nil || game.Solved { - return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, - "No active round. Send /doantu to start one.") - } - res, err := s.api.Neighbors(ctx, game.Target, 100) - if err != nil { - log.Warn("doantu neighbors failed", "err", err) - return chathelper.Reply(ctx, b, msg.Chat.ID, upstreamFail) - } - already := make([]string, 0, len(game.Guesses)) - for _, g := range game.Guesses { - already = append(already, g.Canonical) - } - picks := s.pickHintWords(game.Target, res.Neighbors, already, 3) - if len(picks) == 0 { - return chathelper.Reply(ctx, b, msg.Chat.ID, "🤷 No usable hints available for this round.") - } - var lines []string - for _, p := range picks { - lines = append(lines, fmt.Sprintf("• %s", html.EscapeString(p.Word))) - } - return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, - "💡 Hints — related words (not the answer):\n"+strings.Join(lines, "\n")) -} - -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)() - game, err := loadGame(ctx, s.kv, subject) - if err != nil { - return err - } - if game == nil { - return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, - "No active round. Send /doantu to start one.") - } - if _, err := recordResult(ctx, s.kv, subject, false, len(game.Guesses), chathelper.NowMillis()); 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("🏳️ The target was %s. Send /doantu for a new round.", - html.EscapeString(game.Target))) -} - -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 - } - if st.Played == 0 { - return chathelper.Reply(ctx, b, msg.Chat.ID, "No doantu games played yet.") - } - solveRate := chathelper.WinRate(st.Solved, st.Played) - avg := "—" - if st.Played > 0 { - avg = fmt.Sprintf("%d", roundDiv(st.TotalGuesses, st.Played)) - } - best := "—" - if st.BestGuessCount != nil { - best = fmt.Sprintf("%d", *st.BestGuessCount) - } - body := fmt.Sprintf( - "🇻🇳 Đoán từ stats\nPlayed: %d\nSolved: %d (%d%%)\nTotal guesses: %d\nFewest to solve: %s\nAvg per round: %s", - st.Played, st.Solved, solveRate, st.TotalGuesses, best, avg, - ) - return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, body) -} - -// roundDiv rounds (a/b) half-away-from-zero (JS Math.round parity for non-neg). -func roundDiv(a, b int) int { - if b <= 0 { - return 0 - } - return (a*2 + b) / (2 * b) -} - -// asUpstream is a helper for tests that want to assert specific error types -// without leaking internals. Currently unused outside the package. -var _ = errors.As diff --git a/internal/modules/doantu/lookup.go b/internal/modules/doantu/lookup.go deleted file mode 100644 index fb25197..0000000 --- a/internal/modules/doantu/lookup.go +++ /dev/null @@ -1,24 +0,0 @@ -package doantu - -import ( - "regexp" - "strings" -) - -// shapeRe: Unicode letters + combining marks, single space between syllables -// for compound words (`con chó`, `máy bay`). Mirrors JS lookup.js. -var shapeRe = regexp.MustCompile(`^[\p{L}\p{M}]+(?: [\p{L}\p{M}]+)*$`) - -func normalize(raw string) string { - if raw == "" { - return "" - } - return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(raw)), " ")) -} - -func isValidShape(word string) bool { - if word == "" || len(word) > 64 { - return false - } - return shapeRe.MatchString(word) -} diff --git a/internal/modules/doantu/lookup_test.go b/internal/modules/doantu/lookup_test.go deleted file mode 100644 index 5b87ecb..0000000 --- a/internal/modules/doantu/lookup_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package doantu - -import "testing" - -func TestNormalize(t *testing.T) { - cases := []struct{ in, want string }{ - {" Con chó ", "con chó"}, - {"Máy Bay", "máy bay"}, - {"", ""}, - {" ", ""}, - } - for _, c := range cases { - if got := normalize(c.in); got != c.want { - t.Errorf("normalize(%q) = %q, want %q", c.in, got, c.want) - } - } -} - -func TestIsValidShape(t *testing.T) { - cases := []struct { - in string - want bool - }{ - {"con chó", true}, - {"máy bay", true}, - {"hello", true}, - {"", false}, - {"abc 123", false}, // digits - {"abc!", false}, // punctuation - {" ", false}, // empty after collapse - } - for _, c := range cases { - if got := isValidShape(c.in); got != c.want { - t.Errorf("isValidShape(%q) = %v, want %v", c.in, got, c.want) - } - } -} - -func TestLooksVietnamese(t *testing.T) { - cases := []struct { - w string - want bool - }{ - {"con", false}, // pure ASCII, no underscore - {"chó", true}, // diacritic - {"thanh_pho", true}, // compound - {"hello", false}, - } - for _, c := range cases { - if got := looksVietnamese(c.w); got != c.want { - t.Errorf("looksVietnamese(%q) = %v, want %v", c.w, got, c.want) - } - } -} diff --git a/internal/modules/doantu/render.go b/internal/modules/doantu/render.go deleted file mode 100644 index b60573f..0000000 --- a/internal/modules/doantu/render.go +++ /dev/null @@ -1,93 +0,0 @@ -package doantu - -import ( - "fmt" - "html" - "sort" - "strings" - "unicode/utf8" -) - -const ( - maxRows = 15 - latestMarker = "➡️" - plainMarker = " " - maxWordWidth = 20 -) - -func renderBoard(guesses []Guess, latestCanonical string) string { - count := len(guesses) - header := fmt.Sprintf("🇻🇳 Đoán từ — %d guess%s", count, plural(count)) - if count == 0 { - return header + "\n🆕 Round ready — reply with /doantu <word>." - } - - sorted := make([]Guess, len(guesses)) - copy(sorted, guesses) - sort.SliceStable(sorted, func(i, j int) bool { - return sorted[i].Similarity > sorted[j].Similarity - }) - if len(sorted) > maxRows { - sorted = sorted[:maxRows] - } - - wordWidth := 0 - for _, g := range sorted { - // Use rune count for visual width — Vietnamese diacritics matter. - if l := utf8.RuneCountInString(g.Canonical); l > wordWidth { - wordWidth = l - } - } - if wordWidth > maxWordWidth { - wordWidth = maxWordWidth - } - - var lines []string - for i, g := range sorted { - score := calibrate(g.Similarity) - marker := plainMarker - if g.Canonical == latestCanonical { - marker = latestMarker - } - rank := padLeft(fmt.Sprintf("%d", i+1), 2) - warmth := padLeft(formatWarmth(score), 3) - word := html.EscapeString(padRunesRight(g.Canonical, wordWidth)) - lines = append(lines, fmt.Sprintf("%s %s %s %s %s", marker, rank, warmth, word, warmthEmoji(score))) - } - - body := "
" + strings.Join(lines, "\n") + "
" - footer := "" - if hidden := count - len(sorted); hidden > 0 { - footer = fmt.Sprintf("\n…%d older guess%s hidden.", hidden, plural(hidden)) - } - return header + "\n" + body + footer -} - -func renderGuess(g Guess) string { - score := calibrate(g.Similarity) - return fmt.Sprintf("%s → %s %s", - html.EscapeString(g.Canonical), formatWarmth(score), warmthEmoji(score)) -} - -func plural(n int) string { - if n == 1 { - return "" - } - return "es" -} - -func padLeft(s string, w int) string { - pad := w - len(s) - if pad <= 0 { - return s - } - return strings.Repeat(" ", pad) + s -} - -func padRunesRight(s string, w int) string { - pad := w - utf8.RuneCountInString(s) - if pad <= 0 { - return s - } - return s + strings.Repeat(" ", pad) -} diff --git a/internal/modules/doantu/state.go b/internal/modules/doantu/state.go deleted file mode 100644 index 8bbcd14..0000000 --- a/internal/modules/doantu/state.go +++ /dev/null @@ -1,95 +0,0 @@ -package doantu - -import ( - "context" - "errors" - "fmt" - - "github.com/tiennm99/miti99bot-go/internal/storage" -) - -type Guess struct { - Word string `json:"word"` - Canonical string `json:"canonical"` - Similarity float64 `json:"similarity"` -} - -type GameState struct { - Target string `json:"target"` - StartedAt *int64 `json:"startedAt"` - Solved bool `json:"solved"` - Guesses []Guess `json:"guesses"` -} - -type Stats struct { - Played int `json:"played"` - Solved int `json:"solved"` - TotalGuesses int `json:"totalGuesses"` - BestGuessCount *int `json:"bestGuessCount"` - LastResultAt *int64 `json:"lastResultAt"` -} - -func gameKey(subject string) string { return "game:" + subject } -func statsKey(subject string) string { return "stats:" + 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("doantu 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("doantu 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 && !errors.Is(err, storage.ErrNotFound) { - return fmt.Errorf("doantu 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("doantu loadStats: %w", err) - } -} - -func recordResult(ctx context.Context, kv storage.KVStore, subject string, solved bool, guessCount int, nowMillis int64) (*Stats, error) { - s, err := loadStats(ctx, kv, subject) - if err != nil { - return nil, err - } - s.Played++ - s.TotalGuesses += guessCount - if solved { - s.Solved++ - if s.BestGuessCount == nil || guessCount < *s.BestGuessCount { - gc := guessCount - s.BestGuessCount = &gc - } - } - now := nowMillis - s.LastResultAt = &now - if err := kv.PutJSON(ctx, statsKey(subject), s); err != nil { - return nil, fmt.Errorf("doantu recordResult: %w", err) - } - return s, nil -} diff --git a/internal/modules/loldle/handlers.go b/internal/modules/loldle/handlers.go index 5d3dc90..5e71152 100644 --- a/internal/modules/loldle/handlers.go +++ b/internal/modules/loldle/handlers.go @@ -10,7 +10,6 @@ import ( "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" @@ -26,9 +25,6 @@ type state struct { locks keylock.Map // serialises Get→mutate→Put per subject } -// championName extracts the comparable name field for champname helpers. -func championName(c *Champion) string { return c.ChampionName } - // pickRandomChampion uses math/rand's mutex-protected globals so concurrent // /loldle handlers don't race on a shared *rand.Rand. func (s *state) pickRandomChampion() *Champion { @@ -36,7 +32,7 @@ func (s *state) pickRandomChampion() *Champion { } func (s *state) findByName(name string) *Champion { - return champname.FindByExactName(s.champions, name, championName) + return findChampionByExactName(s.champions, name) } // rehydrateGuesses recomputes board rows from the stored championNames. @@ -127,7 +123,7 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, header+"\n\n"+board) } - guess := champname.Find(s.champions, arg, championName) + guess := findChampion(s.champions, arg) if guess == nil { return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Champion not found: %q.", arg)) } diff --git a/internal/modules/loldle/lookup.go b/internal/modules/loldle/lookup.go new file mode 100644 index 0000000..dce9298 --- /dev/null +++ b/internal/modules/loldle/lookup.go @@ -0,0 +1,62 @@ +package loldle + +import "strings" + +// normalizeName folds a name to a comparable form: lowercase, alphanumeric +// only. JS-parity with util/normalize-name.js — `String(s).toLowerCase(). +// replace(/[^a-z0-9]/g, "")`. Used for case/space/punctuation-insensitive +// lookup so "Kai'Sa", "kaisa", and "KAI SA" all collapse to the same key. +func normalizeName(s string) string { + lower := strings.ToLower(s) + out := make([]byte, 0, len(lower)) + for i := 0; i < len(lower); i++ { + c := lower[i] + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { + out = append(out, c) + } + } + return string(out) +} + +// findChampion resolves user input to a champion via: +// +// 1. Empty / non-alphanumeric input → no match (nil). +// 2. Exact normalised name → that champion. +// 3. Otherwise: unique normalised-prefix match → that champion. +// +// Ambiguous prefix or no hit → nil. Returning nil for ambiguous prefixes +// prevents "Ka" silently routing to whichever Ka- champion happens to be +// first in the data file. +func findChampion(pool []Champion, input string) *Champion { + q := normalizeName(input) + if q == "" { + return nil + } + for i := range pool { + if normalizeName(pool[i].ChampionName) == q { + return &pool[i] + } + } + var hit *Champion + for i := range pool { + if strings.HasPrefix(normalizeName(pool[i].ChampionName), q) { + if hit != nil { + return nil // ambiguous + } + hit = &pool[i] + } + } + return hit +} + +// findChampionByExactName looks up a champion by literal display name (no +// normalization). Used to rehydrate a stored game's target. Returns nil if +// the pool was refreshed and the target is no longer present. +func findChampionByExactName(pool []Champion, name string) *Champion { + for i := range pool { + if pool[i].ChampionName == name { + return &pool[i] + } + } + return nil +} diff --git a/internal/modules/loldle/lookup_test.go b/internal/modules/loldle/lookup_test.go index 9c5022b..b8f46f5 100644 --- a/internal/modules/loldle/lookup_test.go +++ b/internal/modules/loldle/lookup_test.go @@ -1,20 +1,13 @@ package loldle -import ( - "testing" +import "testing" - "github.com/tiennm99/miti99bot-go/internal/champname" -) - -// Generic name lookup primitives (Normalize, Find, FindByExactName) live in -// internal/champname and are tested there. This file only exercises the -// embedded champions.json — wiring + shape integration test. func TestLoadChampions_EmbedIsValid(t *testing.T) { cs := loadChampions() if n := len(cs); n < 150 || n > 200 { t.Errorf("champion count = %d, want ~172", n) } - got := champname.FindByExactName(cs, "Aatrox", championName) + got := findChampionByExactName(cs, "Aatrox") if got == nil { t.Fatal("expected Aatrox in embedded list") } @@ -22,3 +15,33 @@ func TestLoadChampions_EmbedIsValid(t *testing.T) { t.Errorf("Aatrox shape unexpected: %+v", got) } } + +func TestFindChampion(t *testing.T) { + pool := []Champion{{ChampionName: "Aatrox"}, {ChampionName: "Ahri"}, {ChampionName: "Kai'Sa"}} + cases := []struct { + input string + want string // "" means no match + }{ + {"Aatrox", "Aatrox"}, + {"aatrox", "Aatrox"}, + {"kaisa", "Kai'Sa"}, + {"KAI SA", "Kai'Sa"}, + {"Aat", "Aatrox"}, // unique prefix + {"A", ""}, // ambiguous prefix + {"", ""}, // empty + {"!!!", ""}, // no alphanumerics + {"zed", ""}, // no match + } + for _, tc := range cases { + got := findChampion(pool, tc.input) + if tc.want == "" { + if got != nil { + t.Errorf("findChampion(%q) = %+v, want nil", tc.input, got) + } + continue + } + if got == nil || got.ChampionName != tc.want { + t.Errorf("findChampion(%q) = %+v, want %q", tc.input, got, tc.want) + } + } +} diff --git a/internal/modules/loldleability/champions.go b/internal/modules/loldleability/champions.go deleted file mode 100644 index 727984f..0000000 --- a/internal/modules/loldleability/champions.go +++ /dev/null @@ -1,63 +0,0 @@ -// 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 -} diff --git a/internal/modules/loldleability/data/abilities.json b/internal/modules/loldleability/data/abilities.json deleted file mode 100644 index 44de7f9..0000000 --- a/internal/modules/loldleability/data/abilities.json +++ /dev/null @@ -1,5334 +0,0 @@ -[ - { - "championName": "Aatrox", - "key": "Aatrox", - "abilities": [ - { - "slot": "P", - "name": "Deathbringer Stance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Aatrox_Passive.png" - }, - { - "slot": "Q", - "name": "The Darkin Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AatroxQ.png" - }, - { - "slot": "W", - "name": "Infernal Chains", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AatroxW.png" - }, - { - "slot": "E", - "name": "Umbral Dash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AatroxE.png" - }, - { - "slot": "R", - "name": "World Ender", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AatroxR.png" - } - ] - }, - { - "championName": "Ahri", - "key": "Ahri", - "abilities": [ - { - "slot": "P", - "name": "Essence Theft", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Ahri_SoulEater2.png" - }, - { - "slot": "Q", - "name": "Orb of Deception", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AhriQ.png" - }, - { - "slot": "W", - "name": "Fox-Fire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AhriW.png" - }, - { - "slot": "E", - "name": "Charm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AhriE.png" - }, - { - "slot": "R", - "name": "Spirit Rush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AhriR.png" - } - ] - }, - { - "championName": "Akali", - "key": "Akali", - "abilities": [ - { - "slot": "P", - "name": "Assassin's Mark", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Akali_P.png" - }, - { - "slot": "Q", - "name": "Five Point Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AkaliQ.png" - }, - { - "slot": "W", - "name": "Twilight Shroud", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AkaliW.png" - }, - { - "slot": "E", - "name": "Shuriken Flip", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AkaliE.png" - }, - { - "slot": "R", - "name": "Perfect Execution", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AkaliR.png" - } - ] - }, - { - "championName": "Akshan", - "key": "Akshan", - "abilities": [ - { - "slot": "P", - "name": "Dirty Fighting", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/akshan_p.png" - }, - { - "slot": "Q", - "name": "Avengerang", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AkshanQ.png" - }, - { - "slot": "W", - "name": "Going Rogue", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AkshanW.png" - }, - { - "slot": "E", - "name": "Heroic Swing", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AkshanE.png" - }, - { - "slot": "R", - "name": "Comeuppance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AkshanR.png" - } - ] - }, - { - "championName": "Alistar", - "key": "Alistar", - "abilities": [ - { - "slot": "P", - "name": "Triumphant Roar", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Alistar_E.png" - }, - { - "slot": "Q", - "name": "Pulverize", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Pulverize.png" - }, - { - "slot": "W", - "name": "Headbutt", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Headbutt.png" - }, - { - "slot": "E", - "name": "Trample", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AlistarE.png" - }, - { - "slot": "R", - "name": "Unbreakable Will", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FerociousHowl.png" - } - ] - }, - { - "championName": "Ambessa", - "key": "Ambessa", - "abilities": [ - { - "slot": "P", - "name": "Drakehound's Step", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Icon_Ambessa_Passive.Domina.png" - }, - { - "slot": "Q", - "name": "Cunning Sweep / Sundering Slam", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AmbessaQ.png" - }, - { - "slot": "W", - "name": "Repudiation", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AmbessaW.png" - }, - { - "slot": "E", - "name": "Lacerate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AmbessaE.png" - }, - { - "slot": "R", - "name": "Public Execution", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AmbessaR.png" - } - ] - }, - { - "championName": "Amumu", - "key": "Amumu", - "abilities": [ - { - "slot": "P", - "name": "Cursed Touch", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Amumu_Passive.png" - }, - { - "slot": "Q", - "name": "Bandage Toss", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BandageToss.png" - }, - { - "slot": "W", - "name": "Despair", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AuraofDespair.png" - }, - { - "slot": "E", - "name": "Tantrum", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Tantrum.png" - }, - { - "slot": "R", - "name": "Curse of the Sad Mummy", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CurseoftheSadMummy.png" - } - ] - }, - { - "championName": "Anivia", - "key": "Anivia", - "abilities": [ - { - "slot": "P", - "name": "Rebirth", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Anivia_P.png" - }, - { - "slot": "Q", - "name": "Flash Frost", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FlashFrost.png" - }, - { - "slot": "W", - "name": "Crystallize", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Crystallize.png" - }, - { - "slot": "E", - "name": "Frostbite", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Frostbite.png" - }, - { - "slot": "R", - "name": "Glacial Storm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GlacialStorm.png" - } - ] - }, - { - "championName": "Annie", - "key": "Annie", - "abilities": [ - { - "slot": "P", - "name": "Pyromania", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Annie_Passive.png" - }, - { - "slot": "Q", - "name": "Disintegrate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AnnieQ.png" - }, - { - "slot": "W", - "name": "Incinerate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AnnieW.png" - }, - { - "slot": "E", - "name": "Molten Shield", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AnnieE.png" - }, - { - "slot": "R", - "name": "Summon: Tibbers", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AnnieR.png" - } - ] - }, - { - "championName": "Aphelios", - "key": "Aphelios", - "abilities": [ - { - "slot": "P", - "name": "The Hitman and the Seer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ApheliosP.png" - }, - { - "slot": "Q", - "name": "Weapon Abilites", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ApheliosQ_ClientTooltipWrapper.png" - }, - { - "slot": "W", - "name": "Phase", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ApheliosW.png" - }, - { - "slot": "E", - "name": "Weapon Queue System", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ApheliosE_ClientTooltipWrapper.png" - }, - { - "slot": "R", - "name": "Moonlight Vigil", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ApheliosR.png" - } - ] - }, - { - "championName": "Ashe", - "key": "Ashe", - "abilities": [ - { - "slot": "P", - "name": "Frost Shot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Ashe_P.png" - }, - { - "slot": "Q", - "name": "Ranger's Focus", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AsheQ.png" - }, - { - "slot": "W", - "name": "Volley", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Volley.png" - }, - { - "slot": "E", - "name": "Hawkshot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AsheSpiritOfTheHawk.png" - }, - { - "slot": "R", - "name": "Enchanted Crystal Arrow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EnchantedCrystalArrow.png" - } - ] - }, - { - "championName": "Aurelion Sol", - "key": "AurelionSol", - "abilities": [ - { - "slot": "P", - "name": "Cosmic Creator", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/AurelionSolP.png" - }, - { - "slot": "Q", - "name": "Breath of Light", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AurelionSolQ.png" - }, - { - "slot": "W", - "name": "Astral Flight", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AurelionSolW.png" - }, - { - "slot": "E", - "name": "Singularity", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AurelionSolE.png" - }, - { - "slot": "R", - "name": "Falling Star / The Skies Descend", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AurelionSolR.png" - } - ] - }, - { - "championName": "Aurora", - "key": "Aurora", - "abilities": [ - { - "slot": "P", - "name": "Spirit Abjuration", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/AuroraPassive.Aurora.png" - }, - { - "slot": "Q", - "name": "Twofold Hex", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AuroraQ.png" - }, - { - "slot": "W", - "name": "Across the Veil", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AuroraW.png" - }, - { - "slot": "E", - "name": "The Weirding", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AuroraE.png" - }, - { - "slot": "R", - "name": "Between Worlds", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AuroraR.png" - } - ] - }, - { - "championName": "Azir", - "key": "Azir", - "abilities": [ - { - "slot": "P", - "name": "Shurima's Legacy", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Azir_Passive.png" - }, - { - "slot": "Q", - "name": "Conquering Sands", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AzirQWrapper.png" - }, - { - "slot": "W", - "name": "Arise!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AzirW.png" - }, - { - "slot": "E", - "name": "Shifting Sands", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AzirEWrapper.png" - }, - { - "slot": "R", - "name": "Emperor's Divide", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AzirR.png" - } - ] - }, - { - "championName": "Bard", - "key": "Bard", - "abilities": [ - { - "slot": "P", - "name": "Traveler's Call", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Bard_Passive.png" - }, - { - "slot": "Q", - "name": "Cosmic Binding", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BardQ.png" - }, - { - "slot": "W", - "name": "Caretaker's Shrine", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BardW.png" - }, - { - "slot": "E", - "name": "Magical Journey", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BardE.png" - }, - { - "slot": "R", - "name": "Tempered Fate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BardR.png" - } - ] - }, - { - "championName": "Bel'Veth", - "key": "Belveth", - "abilities": [ - { - "slot": "P", - "name": "Death in Lavender ", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Belveth_Passive.png" - }, - { - "slot": "Q", - "name": "Void Surge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BelvethQ.png" - }, - { - "slot": "W", - "name": "Above and Below", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BelvethW.png" - }, - { - "slot": "E", - "name": "Royal Maelstrom", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BelvethE.png" - }, - { - "slot": "R", - "name": "Endless Banquet", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BelvethR.png" - } - ] - }, - { - "championName": "Blitzcrank", - "key": "Blitzcrank", - "abilities": [ - { - "slot": "P", - "name": "Mana Barrier", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Blitzcrank_ManaBarrier.png" - }, - { - "slot": "Q", - "name": "Rocket Grab", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RocketGrab.png" - }, - { - "slot": "W", - "name": "Overdrive", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Overdrive.png" - }, - { - "slot": "E", - "name": "Power Fist", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PowerFist.png" - }, - { - "slot": "R", - "name": "Static Field", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/StaticField.png" - } - ] - }, - { - "championName": "Brand", - "key": "Brand", - "abilities": [ - { - "slot": "P", - "name": "Blaze", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/BrandP.png" - }, - { - "slot": "Q", - "name": "Sear", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BrandQ.png" - }, - { - "slot": "W", - "name": "Pillar of Flame", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BrandW.png" - }, - { - "slot": "E", - "name": "Conflagration", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BrandE.png" - }, - { - "slot": "R", - "name": "Pyroclasm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BrandR.png" - } - ] - }, - { - "championName": "Braum", - "key": "Braum", - "abilities": [ - { - "slot": "P", - "name": "Concussive Blows", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Braum_Passive.png" - }, - { - "slot": "Q", - "name": "Winter's Bite", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BraumQ.png" - }, - { - "slot": "W", - "name": "Stand Behind Me", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BraumW.png" - }, - { - "slot": "E", - "name": "Unbreakable", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BraumE.png" - }, - { - "slot": "R", - "name": "Glacial Fissure", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BraumRWrapper.png" - } - ] - }, - { - "championName": "Briar", - "key": "Briar", - "abilities": [ - { - "slot": "P", - "name": "Crimson Curse", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/BriarP.png" - }, - { - "slot": "Q", - "name": "Head Rush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BriarQ.png" - }, - { - "slot": "W", - "name": "Blood Frenzy / Snack Attack", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BriarW.png" - }, - { - "slot": "E", - "name": "Chilling Scream", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BriarE.png" - }, - { - "slot": "R", - "name": "Certain Death", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/BriarR.png" - } - ] - }, - { - "championName": "Caitlyn", - "key": "Caitlyn", - "abilities": [ - { - "slot": "P", - "name": "Headshot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Caitlyn_Headshot.png" - }, - { - "slot": "Q", - "name": "Piltover Peacemaker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CaitlynQ.png" - }, - { - "slot": "W", - "name": "Yordle Snap Trap", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CaitlynW.png" - }, - { - "slot": "E", - "name": "90 Caliber Net", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CaitlynE.png" - }, - { - "slot": "R", - "name": "Ace in the Hole", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CaitlynR.png" - } - ] - }, - { - "championName": "Camille", - "key": "Camille", - "abilities": [ - { - "slot": "P", - "name": "Adaptive Defenses", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Camille_Passive.png" - }, - { - "slot": "Q", - "name": "Precision Protocol", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CamilleQ.png" - }, - { - "slot": "W", - "name": "Tactical Sweep", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CamilleW.png" - }, - { - "slot": "E", - "name": "Hookshot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CamilleE.png" - }, - { - "slot": "R", - "name": "The Hextech Ultimatum", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CamilleR.png" - } - ] - }, - { - "championName": "Cassiopeia", - "key": "Cassiopeia", - "abilities": [ - { - "slot": "P", - "name": "Serpentine Grace", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Cassiopeia_Passive.png" - }, - { - "slot": "Q", - "name": "Noxious Blast", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CassiopeiaQ.png" - }, - { - "slot": "W", - "name": "Miasma", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CassiopeiaW.png" - }, - { - "slot": "E", - "name": "Twin Fang", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CassiopeiaE.png" - }, - { - "slot": "R", - "name": "Petrifying Gaze", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CassiopeiaR.png" - } - ] - }, - { - "championName": "Cho'Gath", - "key": "Chogath", - "abilities": [ - { - "slot": "P", - "name": "Carnivore", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/GreenTerror_TailSpike.png" - }, - { - "slot": "Q", - "name": "Rupture", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Rupture.png" - }, - { - "slot": "W", - "name": "Feral Scream", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FeralScream.png" - }, - { - "slot": "E", - "name": "Vorpal Spikes", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VorpalSpikes.png" - }, - { - "slot": "R", - "name": "Feast", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Feast.png" - } - ] - }, - { - "championName": "Corki", - "key": "Corki", - "abilities": [ - { - "slot": "P", - "name": "Hextech Munitions", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Corki_RapidReload.png" - }, - { - "slot": "Q", - "name": "Phosphorus Bomb", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PhosphorusBomb.png" - }, - { - "slot": "W", - "name": "Valkyrie", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CarpetBomb.png" - }, - { - "slot": "E", - "name": "Gatling Gun", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GGun.png" - }, - { - "slot": "R", - "name": "Missile Barrage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MissileBarrage.png" - } - ] - }, - { - "championName": "Darius", - "key": "Darius", - "abilities": [ - { - "slot": "P", - "name": "Hemorrhage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Darius_Icon_Hemorrhage.png" - }, - { - "slot": "Q", - "name": "Decimate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DariusCleave.png" - }, - { - "slot": "W", - "name": "Crippling Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DariusNoxianTacticsONH.png" - }, - { - "slot": "E", - "name": "Apprehend", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DariusAxeGrabCone.png" - }, - { - "slot": "R", - "name": "Noxian Guillotine", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DariusExecute.png" - } - ] - }, - { - "championName": "Diana", - "key": "Diana", - "abilities": [ - { - "slot": "P", - "name": "Moonsilver Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Diana_Passive_LunarBlade.png" - }, - { - "slot": "Q", - "name": "Crescent Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DianaQ.png" - }, - { - "slot": "W", - "name": "Pale Cascade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DianaOrbs.png" - }, - { - "slot": "E", - "name": "Lunar Rush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DianaTeleport.png" - }, - { - "slot": "R", - "name": "Moonfall", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DianaR.png" - } - ] - }, - { - "championName": "Dr. Mundo", - "key": "DrMundo", - "abilities": [ - { - "slot": "P", - "name": "Goes Where He Pleases", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/DrMundo_P.png" - }, - { - "slot": "Q", - "name": "Infected Bonesaw", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DrMundoQ.png" - }, - { - "slot": "W", - "name": "Heart Zapper", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DrMundoW.png" - }, - { - "slot": "E", - "name": "Blunt Force Trauma", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DrMundoE.png" - }, - { - "slot": "R", - "name": "Maximum Dosage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DrMundoR.png" - } - ] - }, - { - "championName": "Draven", - "key": "Draven", - "abilities": [ - { - "slot": "P", - "name": "League of Draven", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Draven_passive.png" - }, - { - "slot": "Q", - "name": "Spinning Axe", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DravenSpinning.png" - }, - { - "slot": "W", - "name": "Blood Rush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DravenFury.png" - }, - { - "slot": "E", - "name": "Stand Aside", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DravenDoubleShot.png" - }, - { - "slot": "R", - "name": "Whirling Death", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DravenRCast.png" - } - ] - }, - { - "championName": "Ekko", - "key": "Ekko", - "abilities": [ - { - "slot": "P", - "name": "Z-Drive Resonance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Ekko_P.png" - }, - { - "slot": "Q", - "name": "Timewinder", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EkkoQ.png" - }, - { - "slot": "W", - "name": "Parallel Convergence", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EkkoW.png" - }, - { - "slot": "E", - "name": "Phase Dive", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EkkoE.png" - }, - { - "slot": "R", - "name": "Chronobreak", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EkkoR.png" - } - ] - }, - { - "championName": "Elise", - "key": "Elise", - "abilities": [ - { - "slot": "P", - "name": "Spider Queen", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ElisePassive.png" - }, - { - "slot": "Q", - "name": "Neurotoxin / Venomous Bite", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EliseHumanQ.png" - }, - { - "slot": "W", - "name": "Volatile Spiderling / Skittering Frenzy", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EliseHumanW.png" - }, - { - "slot": "E", - "name": "Cocoon / Rappel", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EliseHumanE.png" - }, - { - "slot": "R", - "name": "Spider Form", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EliseR.png" - } - ] - }, - { - "championName": "Evelynn", - "key": "Evelynn", - "abilities": [ - { - "slot": "P", - "name": "Demon Shade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Evelynn_Passive.png" - }, - { - "slot": "Q", - "name": "Hate Spike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EvelynnQ.png" - }, - { - "slot": "W", - "name": "Allure", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EvelynnW.png" - }, - { - "slot": "E", - "name": "Whiplash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EvelynnE.png" - }, - { - "slot": "R", - "name": "Last Caress", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EvelynnR.png" - } - ] - }, - { - "championName": "Ezreal", - "key": "Ezreal", - "abilities": [ - { - "slot": "P", - "name": "Rising Spell Force", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Ezreal_RisingSpellForce.png" - }, - { - "slot": "Q", - "name": "Mystic Shot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EzrealQ.png" - }, - { - "slot": "W", - "name": "Essence Flux", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EzrealW.png" - }, - { - "slot": "E", - "name": "Arcane Shift", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EzrealE.png" - }, - { - "slot": "R", - "name": "Trueshot Barrage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EzrealR.png" - } - ] - }, - { - "championName": "Fiddlesticks", - "key": "Fiddlesticks", - "abilities": [ - { - "slot": "P", - "name": "A Harmless Scarecrow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/FiddlesticksP.png" - }, - { - "slot": "Q", - "name": "Terrify", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FiddleSticksQ.png" - }, - { - "slot": "W", - "name": "Bountiful Harvest", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FiddleSticksW.png" - }, - { - "slot": "E", - "name": "Reap", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FiddleSticksE.png" - }, - { - "slot": "R", - "name": "Crowstorm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FiddleSticksR.png" - } - ] - }, - { - "championName": "Fiora", - "key": "Fiora", - "abilities": [ - { - "slot": "P", - "name": "Duelist's Dance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Fiora_P.png" - }, - { - "slot": "Q", - "name": "Lunge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FioraQ.png" - }, - { - "slot": "W", - "name": "Riposte", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FioraW.png" - }, - { - "slot": "E", - "name": "Bladework", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FioraE.png" - }, - { - "slot": "R", - "name": "Grand Challenge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FioraR.png" - } - ] - }, - { - "championName": "Fizz", - "key": "Fizz", - "abilities": [ - { - "slot": "P", - "name": "Nimble Fighter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Fizz_P.png" - }, - { - "slot": "Q", - "name": "Urchin Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FizzQ.png" - }, - { - "slot": "W", - "name": "Seastone Trident", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FizzW.png" - }, - { - "slot": "E", - "name": "Playful / Trickster", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FizzE.png" - }, - { - "slot": "R", - "name": "Chum the Waters", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/FizzR.png" - } - ] - }, - { - "championName": "Galio", - "key": "Galio", - "abilities": [ - { - "slot": "P", - "name": "Colossal Smash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Galio_Passive.png" - }, - { - "slot": "Q", - "name": "Winds of War", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GalioQ.png" - }, - { - "slot": "W", - "name": "Shield of Durand", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GalioW.png" - }, - { - "slot": "E", - "name": "Justice Punch", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GalioE.png" - }, - { - "slot": "R", - "name": "Hero's Entrance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GalioR.png" - } - ] - }, - { - "championName": "Gangplank", - "key": "Gangplank", - "abilities": [ - { - "slot": "P", - "name": "Trial by Fire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Gangplank_Passive.png" - }, - { - "slot": "Q", - "name": "Parrrley", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GangplankQWrapper.png" - }, - { - "slot": "W", - "name": "Remove Scurvy", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GangplankW.png" - }, - { - "slot": "E", - "name": "Powder Keg", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GangplankE.png" - }, - { - "slot": "R", - "name": "Cannon Barrage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GangplankR.png" - } - ] - }, - { - "championName": "Garen", - "key": "Garen", - "abilities": [ - { - "slot": "P", - "name": "Perseverance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Garen_Passive.png" - }, - { - "slot": "Q", - "name": "Decisive Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GarenQ.png" - }, - { - "slot": "W", - "name": "Courage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GarenW.png" - }, - { - "slot": "E", - "name": "Judgment", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GarenE.png" - }, - { - "slot": "R", - "name": "Demacian Justice", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GarenR.png" - } - ] - }, - { - "championName": "Gnar", - "key": "Gnar", - "abilities": [ - { - "slot": "P", - "name": "Rage Gene", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Gnar_Passive.png" - }, - { - "slot": "Q", - "name": "Boomerang Throw / Boulder Toss", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GnarQ.png" - }, - { - "slot": "W", - "name": "Hyper / Wallop", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GnarW.png" - }, - { - "slot": "E", - "name": "Hop / Crunch", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GnarE.png" - }, - { - "slot": "R", - "name": "GNAR!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GnarR.png" - } - ] - }, - { - "championName": "Gragas", - "key": "Gragas", - "abilities": [ - { - "slot": "P", - "name": "Happy Hour", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/GragasPassiveHeal.png" - }, - { - "slot": "Q", - "name": "Barrel Roll", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GragasQ.png" - }, - { - "slot": "W", - "name": "Drunken Rage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GragasW.png" - }, - { - "slot": "E", - "name": "Body Slam", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GragasE.png" - }, - { - "slot": "R", - "name": "Explosive Cask", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GragasR.png" - } - ] - }, - { - "championName": "Graves", - "key": "Graves", - "abilities": [ - { - "slot": "P", - "name": "New Destiny", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/GravesTrueGrit.png" - }, - { - "slot": "Q", - "name": "End of the Line", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GravesQLineSpell.png" - }, - { - "slot": "W", - "name": "Smoke Screen", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GravesSmokeGrenade.png" - }, - { - "slot": "E", - "name": "Quickdraw", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GravesMove.png" - }, - { - "slot": "R", - "name": "Collateral Damage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GravesChargeShot.png" - } - ] - }, - { - "championName": "Gwen", - "key": "Gwen", - "abilities": [ - { - "slot": "P", - "name": "A Thousand Cuts", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Gwen_Passive.png" - }, - { - "slot": "Q", - "name": "Snip Snip!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GwenQ.png" - }, - { - "slot": "W", - "name": "Hallowed Mist", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GwenW.png" - }, - { - "slot": "E", - "name": "Skip 'n Slash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GwenE.png" - }, - { - "slot": "R", - "name": "Needlework", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/GwenR.png" - } - ] - }, - { - "championName": "Hecarim", - "key": "Hecarim", - "abilities": [ - { - "slot": "P", - "name": "Warpath", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Hecarim_Passive.png" - }, - { - "slot": "Q", - "name": "Rampage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HecarimRapidSlash.png" - }, - { - "slot": "W", - "name": "Spirit of Dread", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HecarimW.png" - }, - { - "slot": "E", - "name": "Devastating Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HecarimRamp.png" - }, - { - "slot": "R", - "name": "Onslaught of Shadows", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HecarimUlt.png" - } - ] - }, - { - "championName": "Heimerdinger", - "key": "Heimerdinger", - "abilities": [ - { - "slot": "P", - "name": "Hextech Affinity", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Heimerdinger_Passive.png" - }, - { - "slot": "Q", - "name": "H-28 G Evolution Turret", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HeimerdingerQ.png" - }, - { - "slot": "W", - "name": "Hextech Micro-Rockets", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HeimerdingerW.png" - }, - { - "slot": "E", - "name": "CH-2 Electron Storm Grenade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HeimerdingerE.png" - }, - { - "slot": "R", - "name": "UPGRADE!!!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HeimerdingerR.png" - } - ] - }, - { - "championName": "Hwei", - "key": "Hwei", - "abilities": [ - { - "slot": "P", - "name": "Signature of the Visionary", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/HweiPassive.png" - }, - { - "slot": "Q", - "name": "Subject: Disaster", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HweiQ.png" - }, - { - "slot": "W", - "name": "Subject: Serenity", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HweiW.png" - }, - { - "slot": "E", - "name": "Subject: Torment", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HweiE.png" - }, - { - "slot": "R", - "name": "Spiraling Despair", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HweiR.png" - } - ] - }, - { - "championName": "Illaoi", - "key": "Illaoi", - "abilities": [ - { - "slot": "P", - "name": "Prophet of an Elder God", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Illaoi_P.png" - }, - { - "slot": "Q", - "name": "Tentacle Smash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IllaoiQ.png" - }, - { - "slot": "W", - "name": "Harsh Lesson", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IllaoiW.png" - }, - { - "slot": "E", - "name": "Test of Spirit", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IllaoiE.png" - }, - { - "slot": "R", - "name": "Leap of Faith", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IllaoiR.png" - } - ] - }, - { - "championName": "Irelia", - "key": "Irelia", - "abilities": [ - { - "slot": "P", - "name": "Ionian Fervor", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Irelia_Passive.png" - }, - { - "slot": "Q", - "name": "Bladesurge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IreliaQ.png" - }, - { - "slot": "W", - "name": "Defiant Dance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IreliaW.png" - }, - { - "slot": "E", - "name": "Flawless Duet", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IreliaE.png" - }, - { - "slot": "R", - "name": "Vanguard's Edge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IreliaR.png" - } - ] - }, - { - "championName": "Ivern", - "key": "Ivern", - "abilities": [ - { - "slot": "P", - "name": "Friend of the Forest", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/IvernP.png" - }, - { - "slot": "Q", - "name": "Rootcaller", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IvernQ.png" - }, - { - "slot": "W", - "name": "Brushmaker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IvernW.png" - }, - { - "slot": "E", - "name": "Triggerseed", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IvernE.png" - }, - { - "slot": "R", - "name": "Daisy!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/IvernR.png" - } - ] - }, - { - "championName": "Janna", - "key": "Janna", - "abilities": [ - { - "slot": "P", - "name": "Tailwind", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/JannaP.png" - }, - { - "slot": "Q", - "name": "Howling Gale", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HowlingGale.png" - }, - { - "slot": "W", - "name": "Zephyr", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SowTheWind.png" - }, - { - "slot": "E", - "name": "Eye Of The Storm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/EyeOfTheStorm.png" - }, - { - "slot": "R", - "name": "Monsoon", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ReapTheWhirlwind.png" - } - ] - }, - { - "championName": "Jarvan IV", - "key": "JarvanIV", - "abilities": [ - { - "slot": "P", - "name": "Martial Cadence", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/JarvanIVP.png" - }, - { - "slot": "Q", - "name": "Dragon Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JarvanIVDragonStrike.png" - }, - { - "slot": "W", - "name": "Golden Aegis", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JarvanIVGoldenAegis.png" - }, - { - "slot": "E", - "name": "Demacian Standard", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JarvanIVDemacianStandard.png" - }, - { - "slot": "R", - "name": "Cataclysm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JarvanIVCataclysm.png" - } - ] - }, - { - "championName": "Jax", - "key": "Jax", - "abilities": [ - { - "slot": "P", - "name": "Relentless Assault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Armsmaster_MasterOfArms.png" - }, - { - "slot": "Q", - "name": "Leap Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JaxQ.png" - }, - { - "slot": "W", - "name": "Empower", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JaxW.png" - }, - { - "slot": "E", - "name": "Counter Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JaxE.png" - }, - { - "slot": "R", - "name": "Grandmaster-at-Arms", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JaxR.png" - } - ] - }, - { - "championName": "Jayce", - "key": "Jayce", - "abilities": [ - { - "slot": "P", - "name": "Hextech Capacitor", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Jayce_Passive.png" - }, - { - "slot": "Q", - "name": "To the Skies! / Shock Blast", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JayceToTheSkies.png" - }, - { - "slot": "W", - "name": "Lightning Field / Hyper Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JayceStaticField.png" - }, - { - "slot": "E", - "name": "Thundering Blow / Acceleration Gate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JayceThunderingBlow.png" - }, - { - "slot": "R", - "name": "Mercury Cannon / Mercury Hammer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JayceStanceHtG.png" - } - ] - }, - { - "championName": "Jhin", - "key": "Jhin", - "abilities": [ - { - "slot": "P", - "name": "Whisper", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Jhin_P.png" - }, - { - "slot": "Q", - "name": "Dancing Grenade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JhinQ.png" - }, - { - "slot": "W", - "name": "Deadly Flourish", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JhinW.png" - }, - { - "slot": "E", - "name": "Captive Audience", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JhinE.png" - }, - { - "slot": "R", - "name": "Curtain Call", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JhinR.png" - } - ] - }, - { - "championName": "Jinx", - "key": "Jinx", - "abilities": [ - { - "slot": "P", - "name": "Get Excited!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Jinx_Passive.png" - }, - { - "slot": "Q", - "name": "Switcheroo!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JinxQ.png" - }, - { - "slot": "W", - "name": "Zap!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JinxW.png" - }, - { - "slot": "E", - "name": "Flame Chompers!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JinxE.png" - }, - { - "slot": "R", - "name": "Super Mega Death Rocket!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JinxR.png" - } - ] - }, - { - "championName": "K'Sante", - "key": "KSante", - "abilities": [ - { - "slot": "P", - "name": "Dauntless Instinct", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Icons_KSante_P.png" - }, - { - "slot": "Q", - "name": "Ntofo Strikes", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KSanteQ.png" - }, - { - "slot": "W", - "name": "Path Maker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KSanteW.png" - }, - { - "slot": "E", - "name": "Footwork", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KSanteE.png" - }, - { - "slot": "R", - "name": "All Out", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KSanteR.png" - } - ] - }, - { - "championName": "Kai'Sa", - "key": "Kaisa", - "abilities": [ - { - "slot": "P", - "name": "Second Skin", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Kaisa_Passive.png" - }, - { - "slot": "Q", - "name": "Icathian Rain", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KaisaQ.png" - }, - { - "slot": "W", - "name": "Void Seeker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KaisaW.png" - }, - { - "slot": "E", - "name": "Supercharge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KaisaE.png" - }, - { - "slot": "R", - "name": "Killer Instinct", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KaisaR.png" - } - ] - }, - { - "championName": "Kalista", - "key": "Kalista", - "abilities": [ - { - "slot": "P", - "name": "Martial Poise", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Kalista_Passive.png" - }, - { - "slot": "Q", - "name": "Pierce", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KalistaMysticShot.png" - }, - { - "slot": "W", - "name": "Sentinel", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KalistaW.png" - }, - { - "slot": "E", - "name": "Rend", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KalistaExpungeWrapper.png" - }, - { - "slot": "R", - "name": "Fate's Call", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KalistaRx.png" - } - ] - }, - { - "championName": "Karma", - "key": "Karma", - "abilities": [ - { - "slot": "P", - "name": "Gathering Fire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Karma_Passive.png" - }, - { - "slot": "Q", - "name": "Inner Flame", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KarmaQ.png" - }, - { - "slot": "W", - "name": "Focused Resolve", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KarmaSpiritBind.png" - }, - { - "slot": "E", - "name": "Inspire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KarmaSolKimShield.png" - }, - { - "slot": "R", - "name": "Mantra", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KarmaMantra.png" - } - ] - }, - { - "championName": "Karthus", - "key": "Karthus", - "abilities": [ - { - "slot": "P", - "name": "Death Defied", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Karthus_Passive.png" - }, - { - "slot": "Q", - "name": "Lay Waste", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KarthusLayWasteA1.png" - }, - { - "slot": "W", - "name": "Wall of Pain", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KarthusWallOfPain.png" - }, - { - "slot": "E", - "name": "Defile", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KarthusDefile.png" - }, - { - "slot": "R", - "name": "Requiem", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KarthusFallenOne.png" - } - ] - }, - { - "championName": "Kassadin", - "key": "Kassadin", - "abilities": [ - { - "slot": "P", - "name": "Void Stone", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Kassadin_Passive.png" - }, - { - "slot": "Q", - "name": "Null Sphere", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NullLance.png" - }, - { - "slot": "W", - "name": "Nether Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NetherBlade.png" - }, - { - "slot": "E", - "name": "Force Pulse", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ForcePulse.png" - }, - { - "slot": "R", - "name": "Riftwalk", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RiftWalk.png" - } - ] - }, - { - "championName": "Katarina", - "key": "Katarina", - "abilities": [ - { - "slot": "P", - "name": "Voracity", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Katarina_Passive.png" - }, - { - "slot": "Q", - "name": "Bouncing Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KatarinaQ.png" - }, - { - "slot": "W", - "name": "Preparation", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KatarinaW.png" - }, - { - "slot": "E", - "name": "Shunpo", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KatarinaEWrapper.png" - }, - { - "slot": "R", - "name": "Death Lotus", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KatarinaR.png" - } - ] - }, - { - "championName": "Kayle", - "key": "Kayle", - "abilities": [ - { - "slot": "P", - "name": "Divine Ascent", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Kayle_P.png" - }, - { - "slot": "Q", - "name": "Radiant Blast", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KayleQ.png" - }, - { - "slot": "W", - "name": "Celestial Blessing", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KayleW.png" - }, - { - "slot": "E", - "name": "Starfire Spellblade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KayleE.png" - }, - { - "slot": "R", - "name": "Divine Judgment", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KayleR.png" - } - ] - }, - { - "championName": "Kayn", - "key": "Kayn", - "abilities": [ - { - "slot": "P", - "name": "The Darkin Scythe", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Kayn_Passive_Primary.png" - }, - { - "slot": "Q", - "name": "Reaping Slash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KaynQ.png" - }, - { - "slot": "W", - "name": "Blade's Reach", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KaynW.png" - }, - { - "slot": "E", - "name": "Shadow Step", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KaynE.png" - }, - { - "slot": "R", - "name": "Umbral Trespass", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KaynR.png" - } - ] - }, - { - "championName": "Kennen", - "key": "Kennen", - "abilities": [ - { - "slot": "P", - "name": "Mark of the Storm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Kennen_Passive.png" - }, - { - "slot": "Q", - "name": "Thundering Shuriken", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KennenShurikenHurlMissile1.png" - }, - { - "slot": "W", - "name": "Electrical Surge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KennenBringTheLight.png" - }, - { - "slot": "E", - "name": "Lightning Rush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KennenLightningRush.png" - }, - { - "slot": "R", - "name": "Slicing Maelstrom", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KennenShurikenStorm.png" - } - ] - }, - { - "championName": "Kha'Zix", - "key": "Khazix", - "abilities": [ - { - "slot": "P", - "name": "Unseen Threat", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Khazix_P.png" - }, - { - "slot": "Q", - "name": "Taste Their Fear", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KhazixQ.png" - }, - { - "slot": "W", - "name": "Void Spike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KhazixW.png" - }, - { - "slot": "E", - "name": "Leap", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KhazixE.png" - }, - { - "slot": "R", - "name": "Void Assault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KhazixR.png" - } - ] - }, - { - "championName": "Kindred", - "key": "Kindred", - "abilities": [ - { - "slot": "P", - "name": "Mark of the Kindred", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Kindred_Passive.png" - }, - { - "slot": "Q", - "name": "Dance of Arrows", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KindredQ.png" - }, - { - "slot": "W", - "name": "Wolf's Frenzy", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KindredW.png" - }, - { - "slot": "E", - "name": "Mounting Dread", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KindredEWrapper.png" - }, - { - "slot": "R", - "name": "Lamb's Respite", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KindredR.png" - } - ] - }, - { - "championName": "Kled", - "key": "Kled", - "abilities": [ - { - "slot": "P", - "name": "Skaarl, the Cowardly Lizard", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Kled_P.png" - }, - { - "slot": "Q", - "name": "Bear Trap on a Rope", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KledQ.png" - }, - { - "slot": "W", - "name": "Violent Tendencies", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KledW.png" - }, - { - "slot": "E", - "name": "Jousting", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KledE.png" - }, - { - "slot": "R", - "name": "Chaaaaaaaarge!!!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KledR.png" - } - ] - }, - { - "championName": "Kog'Maw", - "key": "KogMaw", - "abilities": [ - { - "slot": "P", - "name": "Icathian Surprise", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/KogMaw_IcathianSurprise.png" - }, - { - "slot": "Q", - "name": "Caustic Spittle", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KogMawQ.png" - }, - { - "slot": "W", - "name": "Bio-Arcane Barrage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KogMawBioArcaneBarrage.png" - }, - { - "slot": "E", - "name": "Void Ooze", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KogMawVoidOoze.png" - }, - { - "slot": "R", - "name": "Living Artillery", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/KogMawLivingArtillery.png" - } - ] - }, - { - "championName": "LeBlanc", - "key": "Leblanc", - "abilities": [ - { - "slot": "P", - "name": "Mirror Image", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/LeblancP.Leblanc_Rework.png" - }, - { - "slot": "Q", - "name": "Sigil of Malice", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeblancQ.png" - }, - { - "slot": "W", - "name": "Distortion", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeblancW.png" - }, - { - "slot": "E", - "name": "Ethereal Chains", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeblancE.png" - }, - { - "slot": "R", - "name": "Mimic", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeblancR.png" - } - ] - }, - { - "championName": "Lee Sin", - "key": "LeeSin", - "abilities": [ - { - "slot": "P", - "name": "Flurry", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/LeeSinPassive.png" - }, - { - "slot": "Q", - "name": "Sonic Wave / Resonating Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeeSinQOne.png" - }, - { - "slot": "W", - "name": "Safeguard / Iron Will", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeeSinWOne.png" - }, - { - "slot": "E", - "name": "Tempest / Cripple", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeeSinEOne.png" - }, - { - "slot": "R", - "name": "Dragon's Rage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeeSinR.png" - } - ] - }, - { - "championName": "Leona", - "key": "Leona", - "abilities": [ - { - "slot": "P", - "name": "Sunlight", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/LeonaSunlight.png" - }, - { - "slot": "Q", - "name": "Shield of Daybreak", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeonaShieldOfDaybreak.png" - }, - { - "slot": "W", - "name": "Eclipse", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeonaSolarBarrier.png" - }, - { - "slot": "E", - "name": "Zenith Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeonaZenithBlade.png" - }, - { - "slot": "R", - "name": "Solar Flare", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LeonaSolarFlare.png" - } - ] - }, - { - "championName": "Lillia", - "key": "Lillia", - "abilities": [ - { - "slot": "P", - "name": "Dream-Laden Bough", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Lillia_Icon_Passive.png" - }, - { - "slot": "Q", - "name": "Blooming Blows", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LilliaQ.png" - }, - { - "slot": "W", - "name": "Watch Out! Eep!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LilliaW.png" - }, - { - "slot": "E", - "name": "Swirlseed", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LilliaE.png" - }, - { - "slot": "R", - "name": "Lilting Lullaby", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LilliaR.png" - } - ] - }, - { - "championName": "Lissandra", - "key": "Lissandra", - "abilities": [ - { - "slot": "P", - "name": "Iceborn Subjugation", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Lissandra_Passive.png" - }, - { - "slot": "Q", - "name": "Ice Shard", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LissandraQ.png" - }, - { - "slot": "W", - "name": "Ring of Frost", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LissandraW.png" - }, - { - "slot": "E", - "name": "Glacial Path", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LissandraE.png" - }, - { - "slot": "R", - "name": "Frozen Tomb", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LissandraR.png" - } - ] - }, - { - "championName": "Lucian", - "key": "Lucian", - "abilities": [ - { - "slot": "P", - "name": "Lightslinger", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Lucian_Passive.png" - }, - { - "slot": "Q", - "name": "Piercing Light", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LucianQ.png" - }, - { - "slot": "W", - "name": "Ardent Blaze", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LucianW.png" - }, - { - "slot": "E", - "name": "Relentless Pursuit", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LucianE.png" - }, - { - "slot": "R", - "name": "The Culling", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LucianR.png" - } - ] - }, - { - "championName": "Lulu", - "key": "Lulu", - "abilities": [ - { - "slot": "P", - "name": "Pix, Faerie Companion", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Lulu_PixFaerieCompanion.png" - }, - { - "slot": "Q", - "name": "Glitterlance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LuluQ.png" - }, - { - "slot": "W", - "name": "Whimsy", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LuluW.png" - }, - { - "slot": "E", - "name": "Help, Pix!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LuluE.png" - }, - { - "slot": "R", - "name": "Wild Growth", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LuluR.png" - } - ] - }, - { - "championName": "Lux", - "key": "Lux", - "abilities": [ - { - "slot": "P", - "name": "Illumination", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/LuxIlluminatingFraulein.png" - }, - { - "slot": "Q", - "name": "Light Binding", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LuxLightBinding.png" - }, - { - "slot": "W", - "name": "Prismatic Barrier", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LuxPrismaticWave.png" - }, - { - "slot": "E", - "name": "Lucent Singularity", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LuxLightStrikeKugel.png" - }, - { - "slot": "R", - "name": "Final Spark", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/LuxR.png" - } - ] - }, - { - "championName": "Malphite", - "key": "Malphite", - "abilities": [ - { - "slot": "P", - "name": "Granite Shield", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Malphite_GraniteShield.png" - }, - { - "slot": "Q", - "name": "Seismic Shard", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SeismicShard.png" - }, - { - "slot": "W", - "name": "Thunderclap", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Obduracy.png" - }, - { - "slot": "E", - "name": "Ground Slam", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Landslide.png" - }, - { - "slot": "R", - "name": "Unstoppable Force", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UFSlash.png" - } - ] - }, - { - "championName": "Malzahar", - "key": "Malzahar", - "abilities": [ - { - "slot": "P", - "name": "Void Shift", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Malzahar_Passive.png" - }, - { - "slot": "Q", - "name": "Call of the Void", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MalzaharQ.png" - }, - { - "slot": "W", - "name": "Void Swarm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MalzaharW.png" - }, - { - "slot": "E", - "name": "Malefic Visions", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MalzaharE.png" - }, - { - "slot": "R", - "name": "Nether Grasp", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MalzaharR.png" - } - ] - }, - { - "championName": "Maokai", - "key": "Maokai", - "abilities": [ - { - "slot": "P", - "name": "Sap Magic", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Maokai_Passive.png" - }, - { - "slot": "Q", - "name": "Bramble Smash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MaokaiQ.png" - }, - { - "slot": "W", - "name": "Twisted Advance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MaokaiW.png" - }, - { - "slot": "E", - "name": "Sapling Toss", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MaokaiE.png" - }, - { - "slot": "R", - "name": "Nature's Grasp", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MaokaiR.png" - } - ] - }, - { - "championName": "Master Yi", - "key": "MasterYi", - "abilities": [ - { - "slot": "P", - "name": "Double Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/MasterYi_Passive1.png" - }, - { - "slot": "Q", - "name": "Alpha Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AlphaStrike.png" - }, - { - "slot": "W", - "name": "Meditate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Meditate.png" - }, - { - "slot": "E", - "name": "Wuju Style", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/WujuStyle.png" - }, - { - "slot": "R", - "name": "Highlander", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Highlander.png" - } - ] - }, - { - "championName": "Mel", - "key": "Mel", - "abilities": [ - { - "slot": "P", - "name": "Searing Brilliance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Mel_Passive.png" - }, - { - "slot": "Q", - "name": "Radiant Volley", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MelQ.png" - }, - { - "slot": "W", - "name": "Rebuttal", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MelW.png" - }, - { - "slot": "E", - "name": "Solar Snare", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MelE.png" - }, - { - "slot": "R", - "name": "Golden Eclipse", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MelR.png" - } - ] - }, - { - "championName": "Milio", - "key": "Milio", - "abilities": [ - { - "slot": "P", - "name": "Fired Up!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Milio_P.png" - }, - { - "slot": "Q", - "name": "Ultra Mega Fire Kick", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MilioQ.png" - }, - { - "slot": "W", - "name": "Cozy Campfire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MilioW.png" - }, - { - "slot": "E", - "name": "Warm Hugs", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MilioE.png" - }, - { - "slot": "R", - "name": "Breath of Life", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MilioR.png" - } - ] - }, - { - "championName": "Miss Fortune", - "key": "MissFortune", - "abilities": [ - { - "slot": "P", - "name": "Love Tap", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/MissFortune_W.png" - }, - { - "slot": "Q", - "name": "Double Up", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MissFortuneRicochetShot.png" - }, - { - "slot": "W", - "name": "Strut", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MissFortuneViciousStrikes.png" - }, - { - "slot": "E", - "name": "Make It Rain", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MissFortuneScattershot.png" - }, - { - "slot": "R", - "name": "Bullet Time", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MissFortuneBulletTime.png" - } - ] - }, - { - "championName": "Mordekaiser", - "key": "Mordekaiser", - "abilities": [ - { - "slot": "P", - "name": "Darkness Rise", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/MordekaiserPassive.png" - }, - { - "slot": "Q", - "name": "Obliterate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MordekaiserQ.png" - }, - { - "slot": "W", - "name": "Indestructible", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MordekaiserW.png" - }, - { - "slot": "E", - "name": "Death's Grasp", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MordekaiserE.png" - }, - { - "slot": "R", - "name": "Realm of Death", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MordekaiserR.png" - } - ] - }, - { - "championName": "Morgana", - "key": "Morgana", - "abilities": [ - { - "slot": "P", - "name": "Soul Siphon", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/FallenAngel_Empathize.png" - }, - { - "slot": "Q", - "name": "Dark Binding", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MorganaQ.png" - }, - { - "slot": "W", - "name": "Tormented Shadow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MorganaW.png" - }, - { - "slot": "E", - "name": "Black Shield", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MorganaE.png" - }, - { - "slot": "R", - "name": "Soul Shackles", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MorganaR.png" - } - ] - }, - { - "championName": "Naafiri", - "key": "Naafiri", - "abilities": [ - { - "slot": "P", - "name": "We Are More", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Icons_Naafiri_P.png" - }, - { - "slot": "Q", - "name": "Darkin Daggers", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NaafiriQ.png" - }, - { - "slot": "W", - "name": "The Call of the Pack", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NaafiriR.png" - }, - { - "slot": "E", - "name": "Eviscerate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NaafiriE.png" - }, - { - "slot": "R", - "name": "Hounds' Pursuit", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NaafiriW.png" - } - ] - }, - { - "championName": "Nami", - "key": "Nami", - "abilities": [ - { - "slot": "P", - "name": "Surging Tides", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/NamiPassive.png" - }, - { - "slot": "Q", - "name": "Aqua Prison", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NamiQ.png" - }, - { - "slot": "W", - "name": "Ebb and Flow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NamiW.png" - }, - { - "slot": "E", - "name": "Tidecaller's Blessing", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NamiE.png" - }, - { - "slot": "R", - "name": "Tidal Wave", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NamiR.png" - } - ] - }, - { - "championName": "Nasus", - "key": "Nasus", - "abilities": [ - { - "slot": "P", - "name": "Soul Eater", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Nasus_Passive.png" - }, - { - "slot": "Q", - "name": "Siphoning Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NasusQ.png" - }, - { - "slot": "W", - "name": "Wither", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NasusW.png" - }, - { - "slot": "E", - "name": "Spirit Fire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NasusE.png" - }, - { - "slot": "R", - "name": "Fury of the Sands", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NasusR.png" - } - ] - }, - { - "championName": "Nautilus", - "key": "Nautilus", - "abilities": [ - { - "slot": "P", - "name": "Staggering Blow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Nautilus_StaggeringBlow.png" - }, - { - "slot": "Q", - "name": "Dredge Line", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NautilusAnchorDrag.png" - }, - { - "slot": "W", - "name": "Titan's Wrath", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NautilusPiercingGaze.png" - }, - { - "slot": "E", - "name": "Riptide", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NautilusSplashZone.png" - }, - { - "slot": "R", - "name": "Depth Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NautilusGrandLine.png" - } - ] - }, - { - "championName": "Neeko", - "key": "Neeko", - "abilities": [ - { - "slot": "P", - "name": "Inherent Glamour", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Neeko_P.png" - }, - { - "slot": "Q", - "name": "Blooming Burst", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NeekoQ.png" - }, - { - "slot": "W", - "name": "Shapesplitter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NeekoW.png" - }, - { - "slot": "E", - "name": "Tangle-Barbs", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NeekoE.png" - }, - { - "slot": "R", - "name": "Pop Blossom", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NeekoR.png" - } - ] - }, - { - "championName": "Nidalee", - "key": "Nidalee", - "abilities": [ - { - "slot": "P", - "name": "Prowl", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Nidalee_Passive.png" - }, - { - "slot": "Q", - "name": "Javelin Toss / Takedown", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JavelinToss.png" - }, - { - "slot": "W", - "name": "Bushwhack / Pounce", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Bushwhack.png" - }, - { - "slot": "E", - "name": "Primal Surge / Swipe", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PrimalSurge.png" - }, - { - "slot": "R", - "name": "Aspect Of The Cougar", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/AspectOfTheCougar.png" - } - ] - }, - { - "championName": "Nilah", - "key": "Nilah", - "abilities": [ - { - "slot": "P", - "name": "Joy Unending", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/NIlahP.png" - }, - { - "slot": "Q", - "name": "Formless Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NilahQ.png" - }, - { - "slot": "W", - "name": "Jubilant Veil", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NilahW.png" - }, - { - "slot": "E", - "name": "Slipstream", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NilahE.png" - }, - { - "slot": "R", - "name": "Apotheosis", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NilahR.png" - } - ] - }, - { - "championName": "Nocturne", - "key": "Nocturne", - "abilities": [ - { - "slot": "P", - "name": "Umbra Blades", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Nocturne_UmbraBlades.png" - }, - { - "slot": "Q", - "name": "Duskbringer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NocturneDuskbringer.png" - }, - { - "slot": "W", - "name": "Shroud of Darkness", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NocturneShroudofDarkness.png" - }, - { - "slot": "E", - "name": "Unspeakable Horror", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NocturneUnspeakableHorror.png" - }, - { - "slot": "R", - "name": "Paranoia", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NocturneParanoia.png" - } - ] - }, - { - "championName": "Nunu & Willump", - "key": "Nunu", - "abilities": [ - { - "slot": "P", - "name": "Call of the Freljord", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/NunuPassive.png" - }, - { - "slot": "Q", - "name": "Consume", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NunuQ.png" - }, - { - "slot": "W", - "name": "Biggest Snowball Ever!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NunuW.png" - }, - { - "slot": "E", - "name": "Snowball Barrage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NunuE.png" - }, - { - "slot": "R", - "name": "Absolute Zero", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/NunuR.png" - } - ] - }, - { - "championName": "Olaf", - "key": "Olaf", - "abilities": [ - { - "slot": "P", - "name": "Berserker Rage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Olaf_Passive.png" - }, - { - "slot": "Q", - "name": "Undertow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OlafAxeThrowCast.png" - }, - { - "slot": "W", - "name": "Tough It Out", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OlafFrenziedStrikes.png" - }, - { - "slot": "E", - "name": "Reckless Swing", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OlafRecklessStrike.png" - }, - { - "slot": "R", - "name": "Ragnarok", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OlafRagnarok.png" - } - ] - }, - { - "championName": "Orianna", - "key": "Orianna", - "abilities": [ - { - "slot": "P", - "name": "Clockwork Windup", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/OriannaPassive.png" - }, - { - "slot": "Q", - "name": "Command: Attack", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OrianaIzunaCommand.png" - }, - { - "slot": "W", - "name": "Command: Dissonance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OrianaDissonanceCommand.png" - }, - { - "slot": "E", - "name": "Command: Protect", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OrianaRedactCommand.png" - }, - { - "slot": "R", - "name": "Command: Shockwave", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OrianaDetonateCommand.png" - } - ] - }, - { - "championName": "Ornn", - "key": "Ornn", - "abilities": [ - { - "slot": "P", - "name": "Living Forge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/OrnnP.png" - }, - { - "slot": "Q", - "name": "Volcanic Rupture", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OrnnQ.png" - }, - { - "slot": "W", - "name": "Bellows Breath", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OrnnW.png" - }, - { - "slot": "E", - "name": "Searing Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OrnnE.png" - }, - { - "slot": "R", - "name": "Call of the Forge God", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/OrnnR.png" - } - ] - }, - { - "championName": "Pantheon", - "key": "Pantheon", - "abilities": [ - { - "slot": "P", - "name": "Mortal Will", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Pantheon_Passive.png" - }, - { - "slot": "Q", - "name": "Comet Spear", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PantheonQ.png" - }, - { - "slot": "W", - "name": "Shield Vault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PantheonW.png" - }, - { - "slot": "E", - "name": "Aegis Assault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PantheonE.png" - }, - { - "slot": "R", - "name": "Grand Starfall", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PantheonR.png" - } - ] - }, - { - "championName": "Poppy", - "key": "Poppy", - "abilities": [ - { - "slot": "P", - "name": "Iron Ambassador", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Poppy_Passive.png" - }, - { - "slot": "Q", - "name": "Hammer Shock", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PoppyQ.png" - }, - { - "slot": "W", - "name": "Steadfast Presence", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PoppyW.png" - }, - { - "slot": "E", - "name": "Heroic Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PoppyE.png" - }, - { - "slot": "R", - "name": "Keeper's Verdict", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PoppyR.png" - } - ] - }, - { - "championName": "Pyke", - "key": "Pyke", - "abilities": [ - { - "slot": "P", - "name": "Gift of the Drowned Ones", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/PykePassive.png" - }, - { - "slot": "Q", - "name": "Bone Skewer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PykeQ.png" - }, - { - "slot": "W", - "name": "Ghostwater Dive", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PykeW.png" - }, - { - "slot": "E", - "name": "Phantom Undertow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PykeE.png" - }, - { - "slot": "R", - "name": "Death From Below", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PykeR.png" - } - ] - }, - { - "championName": "Qiyana", - "key": "Qiyana", - "abilities": [ - { - "slot": "P", - "name": "Royal Privilege", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Qiyana_Passive.png" - }, - { - "slot": "Q", - "name": "Elemental Wrath / Edge of Ixtal", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/QiyanaQ.png" - }, - { - "slot": "W", - "name": "Terrashape", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/QiyanaW.png" - }, - { - "slot": "E", - "name": "Audacity", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/QiyanaE.png" - }, - { - "slot": "R", - "name": "Supreme Display of Talent", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/QiyanaR.png" - } - ] - }, - { - "championName": "Quinn", - "key": "Quinn", - "abilities": [ - { - "slot": "P", - "name": "Harrier", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Quinn_Passive.png" - }, - { - "slot": "Q", - "name": "Blinding Assault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/QuinnQ.png" - }, - { - "slot": "W", - "name": "Heightened Senses", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/QuinnW.png" - }, - { - "slot": "E", - "name": "Vault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/QuinnE.png" - }, - { - "slot": "R", - "name": "Behind Enemy Lines", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/QuinnR.png" - } - ] - }, - { - "championName": "Rakan", - "key": "Rakan", - "abilities": [ - { - "slot": "P", - "name": "Fey Feathers", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Rakan_P.png" - }, - { - "slot": "Q", - "name": "Gleaming Quill", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RakanQ.png" - }, - { - "slot": "W", - "name": "Grand Entrance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RakanW.png" - }, - { - "slot": "E", - "name": "Battle Dance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RakanE.png" - }, - { - "slot": "R", - "name": "The Quickness", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RakanR.png" - } - ] - }, - { - "championName": "Rammus", - "key": "Rammus", - "abilities": [ - { - "slot": "P", - "name": "Spiked Shell", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Armordillo_ScavengeArmor.png" - }, - { - "slot": "Q", - "name": "Powerball", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PowerBall.png" - }, - { - "slot": "W", - "name": "Defensive Ball Curl", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/DefensiveBallCurl.png" - }, - { - "slot": "E", - "name": "Frenzying Taunt", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PuncturingTaunt.png" - }, - { - "slot": "R", - "name": "Soaring Slam", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Tremors2.png" - } - ] - }, - { - "championName": "Rek'Sai", - "key": "RekSai", - "abilities": [ - { - "slot": "P", - "name": "Fury of the Xer'Sai", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/RekSai_Passive.png" - }, - { - "slot": "Q", - "name": "Queen's Wrath / Prey Seeker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RekSaiQ.png" - }, - { - "slot": "W", - "name": "Burrow / Un-burrow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RekSaiW.png" - }, - { - "slot": "E", - "name": "Furious Bite / Tunnel", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RekSaiE.png" - }, - { - "slot": "R", - "name": "Void Rush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RekSaiR.png" - } - ] - }, - { - "championName": "Rell", - "key": "Rell", - "abilities": [ - { - "slot": "P", - "name": "Break the Mold", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/RellP.png" - }, - { - "slot": "Q", - "name": "Shattering Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RellQ.png" - }, - { - "slot": "W", - "name": "Ferromancy: Crash Down", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RellW_Dismount.png" - }, - { - "slot": "E", - "name": "Full Tilt", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RellE.png" - }, - { - "slot": "R", - "name": "Magnet Storm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RellR.png" - } - ] - }, - { - "championName": "Renata Glasc", - "key": "Renata", - "abilities": [ - { - "slot": "P", - "name": "Leverage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Renata_P.png" - }, - { - "slot": "Q", - "name": "Handshake", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RenataQ.png" - }, - { - "slot": "W", - "name": "Bailout", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RenataW.png" - }, - { - "slot": "E", - "name": "Loyalty Program", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RenataE.png" - }, - { - "slot": "R", - "name": "Hostile Takeover", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RenataR.png" - } - ] - }, - { - "championName": "Renekton", - "key": "Renekton", - "abilities": [ - { - "slot": "P", - "name": "Reign of Anger", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Renekton_Passive.png" - }, - { - "slot": "Q", - "name": "Cull the Meek", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RenektonCleave.png" - }, - { - "slot": "W", - "name": "Ruthless Predator", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RenektonPreExecute.png" - }, - { - "slot": "E", - "name": "Slice and Dice", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RenektonSliceAndDice.png" - }, - { - "slot": "R", - "name": "Dominus", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RenektonReignOfTheTyrant.png" - } - ] - }, - { - "championName": "Rengar", - "key": "Rengar", - "abilities": [ - { - "slot": "P", - "name": "Unseen Predator", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Rengar_P.png" - }, - { - "slot": "Q", - "name": "Savagery", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RengarQ.png" - }, - { - "slot": "W", - "name": "Battle Roar", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RengarW.png" - }, - { - "slot": "E", - "name": "Bola Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RengarE.png" - }, - { - "slot": "R", - "name": "Thrill of the Hunt", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RengarR.png" - } - ] - }, - { - "championName": "Riven", - "key": "Riven", - "abilities": [ - { - "slot": "P", - "name": "Runic Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/RivenRunicBlades.png" - }, - { - "slot": "Q", - "name": "Broken Wings", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RivenTriCleave.png" - }, - { - "slot": "W", - "name": "Ki Burst", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RivenMartyr.png" - }, - { - "slot": "E", - "name": "Valor", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RivenFeint.png" - }, - { - "slot": "R", - "name": "Blade of the Exile", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RivenFengShuiEngine.png" - } - ] - }, - { - "championName": "Rumble", - "key": "Rumble", - "abilities": [ - { - "slot": "P", - "name": "Junkyard Titan", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Rumble_JunkyardTitan1.png" - }, - { - "slot": "Q", - "name": "Flamespitter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RumbleFlameThrower.png" - }, - { - "slot": "W", - "name": "Scrap Shield", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RumbleShield.png" - }, - { - "slot": "E", - "name": "Electro Harpoon", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RumbleGrenade.png" - }, - { - "slot": "R", - "name": "The Equalizer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RumbleCarpetBomb.png" - } - ] - }, - { - "championName": "Ryze", - "key": "Ryze", - "abilities": [ - { - "slot": "P", - "name": "Arcane Mastery", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Ryze_P.png" - }, - { - "slot": "Q", - "name": "Overload", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RyzeQWrapper.png" - }, - { - "slot": "W", - "name": "Rune Prison", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RyzeW.png" - }, - { - "slot": "E", - "name": "Spell Flux", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RyzeE.png" - }, - { - "slot": "R", - "name": "Realm Warp", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/RyzeR.png" - } - ] - }, - { - "championName": "Samira", - "key": "Samira", - "abilities": [ - { - "slot": "P", - "name": "Daredevil Impulse", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/SamiraP.png" - }, - { - "slot": "Q", - "name": "Flair", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SamiraQ.png" - }, - { - "slot": "W", - "name": "Blade Whirl", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SamiraW.png" - }, - { - "slot": "E", - "name": "Wild Rush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SamiraE.png" - }, - { - "slot": "R", - "name": "Inferno Trigger", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SamiraR.png" - } - ] - }, - { - "championName": "Sejuani", - "key": "Sejuani", - "abilities": [ - { - "slot": "P", - "name": "Fury of the North", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Sejuani_passive.png" - }, - { - "slot": "Q", - "name": "Arctic Assault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SejuaniQ.png" - }, - { - "slot": "W", - "name": "Winter's Wrath", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SejuaniW.png" - }, - { - "slot": "E", - "name": "Permafrost", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SejuaniE.png" - }, - { - "slot": "R", - "name": "Glacial Prison", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SejuaniR.png" - } - ] - }, - { - "championName": "Senna", - "key": "Senna", - "abilities": [ - { - "slot": "P", - "name": "Absolution", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Senna_Passive.png" - }, - { - "slot": "Q", - "name": "Piercing Darkness", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SennaQ.png" - }, - { - "slot": "W", - "name": "Last Embrace", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SennaW.png" - }, - { - "slot": "E", - "name": "Curse of the Black Mist", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SennaE.png" - }, - { - "slot": "R", - "name": "Dawning Shadow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SennaR.png" - } - ] - }, - { - "championName": "Seraphine", - "key": "Seraphine", - "abilities": [ - { - "slot": "P", - "name": "Stage Presence", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Seraphine_Passive.png" - }, - { - "slot": "Q", - "name": "High Note", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SeraphineQ.png" - }, - { - "slot": "W", - "name": "Surround Sound", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SeraphineW.png" - }, - { - "slot": "E", - "name": "Beat Drop", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SeraphineE.png" - }, - { - "slot": "R", - "name": "Encore", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SeraphineR.png" - } - ] - }, - { - "championName": "Sett", - "key": "Sett", - "abilities": [ - { - "slot": "P", - "name": "Pit Grit", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Sett_P.png" - }, - { - "slot": "Q", - "name": "Knuckle Down", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SettQ.png" - }, - { - "slot": "W", - "name": "Haymaker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SettW.png" - }, - { - "slot": "E", - "name": "Facebreaker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SettE.png" - }, - { - "slot": "R", - "name": "The Show Stopper", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SettR.png" - } - ] - }, - { - "championName": "Shaco", - "key": "Shaco", - "abilities": [ - { - "slot": "P", - "name": "Backstab", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Jester_CarefulStrikes.png" - }, - { - "slot": "Q", - "name": "Deceive", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Deceive.png" - }, - { - "slot": "W", - "name": "Jack In The Box", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/JackInTheBox.png" - }, - { - "slot": "E", - "name": "Two-Shiv Poison", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TwoShivPoison.png" - }, - { - "slot": "R", - "name": "Hallucinate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/HallucinateFull.png" - } - ] - }, - { - "championName": "Shen", - "key": "Shen", - "abilities": [ - { - "slot": "P", - "name": "Ki Barrier", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Shen_Passive.png" - }, - { - "slot": "Q", - "name": "Twilight Assault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ShenQ.png" - }, - { - "slot": "W", - "name": "Spirit's Refuge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ShenW.png" - }, - { - "slot": "E", - "name": "Shadow Dash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ShenE.png" - }, - { - "slot": "R", - "name": "Stand United", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ShenR.png" - } - ] - }, - { - "championName": "Shyvana", - "key": "Shyvana", - "abilities": [ - { - "slot": "P", - "name": "Scalemail", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Shyvana_Passive.Shyvana_Rework.png" - }, - { - "slot": "Q", - "name": "Emberstrike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ShyvanaQ.png" - }, - { - "slot": "W", - "name": "Inferno Aegis", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ShyvanaW.png" - }, - { - "slot": "E", - "name": "Molten Burst", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ShyvanaE.png" - }, - { - "slot": "R", - "name": "Dragon's Descent", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ShyvanaR.png" - } - ] - }, - { - "championName": "Singed", - "key": "Singed", - "abilities": [ - { - "slot": "P", - "name": "Noxious Slipstream", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Singed_Passive.png" - }, - { - "slot": "Q", - "name": "Poison Trail", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PoisonTrail.png" - }, - { - "slot": "W", - "name": "Mega Adhesive", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MegaAdhesive.png" - }, - { - "slot": "E", - "name": "Fling", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Fling.png" - }, - { - "slot": "R", - "name": "Insanity Potion", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/InsanityPotion.png" - } - ] - }, - { - "championName": "Sion", - "key": "Sion", - "abilities": [ - { - "slot": "P", - "name": "Glory in Death", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Sion_Passive1.png" - }, - { - "slot": "Q", - "name": "Decimating Smash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SionQ.png" - }, - { - "slot": "W", - "name": "Soul Furnace", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SionW.png" - }, - { - "slot": "E", - "name": "Roar of the Slayer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SionE.png" - }, - { - "slot": "R", - "name": "Unstoppable Onslaught", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SionR.png" - } - ] - }, - { - "championName": "Sivir", - "key": "Sivir", - "abilities": [ - { - "slot": "P", - "name": "Fleet of Foot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Sivir_Passive.png" - }, - { - "slot": "Q", - "name": "Boomerang Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SivirQ.png" - }, - { - "slot": "W", - "name": "Ricochet", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SivirW.png" - }, - { - "slot": "E", - "name": "Spell Shield", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SivirE.png" - }, - { - "slot": "R", - "name": "On The Hunt", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SivirR.png" - } - ] - }, - { - "championName": "Skarner", - "key": "Skarner", - "abilities": [ - { - "slot": "P", - "name": "Threads of Vibration", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Skarner_Passive.png" - }, - { - "slot": "Q", - "name": "Shattered Earth / Upheaval", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SkarnerQ.png" - }, - { - "slot": "W", - "name": "Seismic Bastion", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SkarnerW.png" - }, - { - "slot": "E", - "name": "Ixtal's Impact", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SkarnerE.png" - }, - { - "slot": "R", - "name": "Impale", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SkarnerR.png" - } - ] - }, - { - "championName": "Smolder", - "key": "Smolder", - "abilities": [ - { - "slot": "P", - "name": "Dragon Practice", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Icons_Smolder_Passive.png" - }, - { - "slot": "Q", - "name": "Super Scorcher Breath", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SmolderQ.png" - }, - { - "slot": "W", - "name": "Achooo!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SmolderW.png" - }, - { - "slot": "E", - "name": "Flap, Flap, Flap", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SmolderE.png" - }, - { - "slot": "R", - "name": "MMOOOMMMM!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SmolderR.png" - } - ] - }, - { - "championName": "Sona", - "key": "Sona", - "abilities": [ - { - "slot": "P", - "name": "Power Chord", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Sona_Passive_Charged.png" - }, - { - "slot": "Q", - "name": "Hymn of Valor", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SonaQ.png" - }, - { - "slot": "W", - "name": "Aria of Perseverance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SonaW.png" - }, - { - "slot": "E", - "name": "Song of Celerity", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SonaE.png" - }, - { - "slot": "R", - "name": "Crescendo", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SonaR.png" - } - ] - }, - { - "championName": "Soraka", - "key": "Soraka", - "abilities": [ - { - "slot": "P", - "name": "Salvation", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Soraka_Passive.png" - }, - { - "slot": "Q", - "name": "Starcall", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SorakaQ.png" - }, - { - "slot": "W", - "name": "Astral Infusion", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SorakaW.png" - }, - { - "slot": "E", - "name": "Equinox", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SorakaE.png" - }, - { - "slot": "R", - "name": "Wish", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SorakaR.png" - } - ] - }, - { - "championName": "Swain", - "key": "Swain", - "abilities": [ - { - "slot": "P", - "name": "Ravenous Flock", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Swain_P.png" - }, - { - "slot": "Q", - "name": "Death's Hand", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SwainQ.png" - }, - { - "slot": "W", - "name": "Vision of Empire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SwainW.png" - }, - { - "slot": "E", - "name": "Nevermove", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SwainE.png" - }, - { - "slot": "R", - "name": "Demonic Ascension", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SwainR.png" - } - ] - }, - { - "championName": "Sylas", - "key": "Sylas", - "abilities": [ - { - "slot": "P", - "name": "Petricite Burst", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/SylasP.png" - }, - { - "slot": "Q", - "name": "Chain Lash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SylasQ.png" - }, - { - "slot": "W", - "name": "Kingslayer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SylasW.png" - }, - { - "slot": "E", - "name": "Abscond / Abduct", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SylasE.png" - }, - { - "slot": "R", - "name": "Hijack", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SylasR.png" - } - ] - }, - { - "championName": "Syndra", - "key": "Syndra", - "abilities": [ - { - "slot": "P", - "name": "Transcendent", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/SyndraPassive.png" - }, - { - "slot": "Q", - "name": "Dark Sphere", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SyndraQ.png" - }, - { - "slot": "W", - "name": "Force of Will", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SyndraW.png" - }, - { - "slot": "E", - "name": "Scatter the Weak", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SyndraE.png" - }, - { - "slot": "R", - "name": "Unleashed Power", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/SyndraR.png" - } - ] - }, - { - "championName": "Tahm Kench", - "key": "TahmKench", - "abilities": [ - { - "slot": "P", - "name": "An Acquired Taste", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/TahmKenchP.png" - }, - { - "slot": "Q", - "name": "Tongue Lash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TahmKenchQ.png" - }, - { - "slot": "W", - "name": "Abyssal Dive", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TahmKenchW.png" - }, - { - "slot": "E", - "name": "Thick Skin", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TahmKenchE.png" - }, - { - "slot": "R", - "name": "Devour", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TahmKenchRWrapper.png" - } - ] - }, - { - "championName": "Taliyah", - "key": "Taliyah", - "abilities": [ - { - "slot": "P", - "name": "Rock Surfing", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Taliyah_Passive.png" - }, - { - "slot": "Q", - "name": "Threaded Volley", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TaliyahQ.png" - }, - { - "slot": "W", - "name": "Seismic Shove", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TaliyahWVC.png" - }, - { - "slot": "E", - "name": "Unraveled Earth", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TaliyahE.png" - }, - { - "slot": "R", - "name": "Weaver's Wall", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TaliyahR.png" - } - ] - }, - { - "championName": "Talon", - "key": "Talon", - "abilities": [ - { - "slot": "P", - "name": "Blade's End", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/TalonP.png" - }, - { - "slot": "Q", - "name": "Noxian Diplomacy", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TalonQ.png" - }, - { - "slot": "W", - "name": "Rake", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TalonW.png" - }, - { - "slot": "E", - "name": "Assassin's Path", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TalonE.png" - }, - { - "slot": "R", - "name": "Shadow Assault", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TalonR.png" - } - ] - }, - { - "championName": "Taric", - "key": "Taric", - "abilities": [ - { - "slot": "P", - "name": "Bravado", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Taric_Passive.png" - }, - { - "slot": "Q", - "name": "Starlight's Touch", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TaricQ.png" - }, - { - "slot": "W", - "name": "Bastion", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TaricW.png" - }, - { - "slot": "E", - "name": "Dazzle", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TaricE.png" - }, - { - "slot": "R", - "name": "Cosmic Radiance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TaricR.png" - } - ] - }, - { - "championName": "Teemo", - "key": "Teemo", - "abilities": [ - { - "slot": "P", - "name": "Guerrilla Warfare", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/TeemoPassive.ASU_Teemo.png" - }, - { - "slot": "Q", - "name": "Blinding Dart", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TeemoQ.png" - }, - { - "slot": "W", - "name": "Move Quick", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TeemoW.png" - }, - { - "slot": "E", - "name": "Toxic Shot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TeemoE.png" - }, - { - "slot": "R", - "name": "Noxious Trap", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TeemoR.png" - } - ] - }, - { - "championName": "Thresh", - "key": "Thresh", - "abilities": [ - { - "slot": "P", - "name": "Damnation", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Thresh_Passive.png" - }, - { - "slot": "Q", - "name": "Death Sentence", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ThreshQ.png" - }, - { - "slot": "W", - "name": "Dark Passage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ThreshW.png" - }, - { - "slot": "E", - "name": "Flay", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ThreshE.png" - }, - { - "slot": "R", - "name": "The Box", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ThreshRPenta.png" - } - ] - }, - { - "championName": "Tristana", - "key": "Tristana", - "abilities": [ - { - "slot": "P", - "name": "Draw a Bead", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Tristana_Passive.png" - }, - { - "slot": "Q", - "name": "Rapid Fire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TristanaQ.png" - }, - { - "slot": "W", - "name": "Rocket Jump", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TristanaW.png" - }, - { - "slot": "E", - "name": "Explosive Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TristanaE.png" - }, - { - "slot": "R", - "name": "Buster Shot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TristanaR.png" - } - ] - }, - { - "championName": "Trundle", - "key": "Trundle", - "abilities": [ - { - "slot": "P", - "name": "King's Tribute", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Trundle_Passive.png" - }, - { - "slot": "Q", - "name": "Chomp", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TrundleTrollSmash.png" - }, - { - "slot": "W", - "name": "Frozen Domain", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/trundledesecrate.png" - }, - { - "slot": "E", - "name": "Pillar of Ice", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TrundleCircle.png" - }, - { - "slot": "R", - "name": "Subjugate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TrundlePain.png" - } - ] - }, - { - "championName": "Tryndamere", - "key": "Tryndamere", - "abilities": [ - { - "slot": "P", - "name": "Battle Fury", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Tryndamere_Passive.png" - }, - { - "slot": "Q", - "name": "Bloodlust", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TryndamereQ.png" - }, - { - "slot": "W", - "name": "Mocking Shout", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TryndamereW.png" - }, - { - "slot": "E", - "name": "Spinning Slash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TryndamereE.png" - }, - { - "slot": "R", - "name": "Undying Rage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UndyingRage.png" - } - ] - }, - { - "championName": "Twisted Fate", - "key": "TwistedFate", - "abilities": [ - { - "slot": "P", - "name": "Loaded Dice", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Cardmaster_SealFate.png" - }, - { - "slot": "Q", - "name": "Wild Cards", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/WildCards.png" - }, - { - "slot": "W", - "name": "Pick a Card", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/PickACard.png" - }, - { - "slot": "E", - "name": "Stacked Deck", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/CardmasterStack.png" - }, - { - "slot": "R", - "name": "Destiny", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/Destiny.png" - } - ] - }, - { - "championName": "Twitch", - "key": "Twitch", - "abilities": [ - { - "slot": "P", - "name": "Deadly Venom", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Twitch_Passive.png" - }, - { - "slot": "Q", - "name": "Ambush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TwitchHideInShadows.png" - }, - { - "slot": "W", - "name": "Venom Cask", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TwitchVenomCask.png" - }, - { - "slot": "E", - "name": "Contaminate", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TwitchExpunge.png" - }, - { - "slot": "R", - "name": "Spray and Pray", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TwitchFullAutomatic.png" - } - ] - }, - { - "championName": "Udyr", - "key": "Udyr", - "abilities": [ - { - "slot": "P", - "name": "Bridge Between", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Udyr_P.png" - }, - { - "slot": "Q", - "name": "Wilding Claw", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UdyrQ.png" - }, - { - "slot": "W", - "name": "Iron Mantle", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UdyrW.png" - }, - { - "slot": "E", - "name": "Blazing Stampede", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UdyrE.png" - }, - { - "slot": "R", - "name": "Wingborne Storm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UdyrR.png" - } - ] - }, - { - "championName": "Urgot", - "key": "Urgot", - "abilities": [ - { - "slot": "P", - "name": "Echoing Flames", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Urgot_Passive.png" - }, - { - "slot": "Q", - "name": "Corrosive Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UrgotQ.png" - }, - { - "slot": "W", - "name": "Purge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UrgotW.png" - }, - { - "slot": "E", - "name": "Disdain", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UrgotE.png" - }, - { - "slot": "R", - "name": "Fear Beyond Death", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/UrgotR.png" - } - ] - }, - { - "championName": "Varus", - "key": "Varus", - "abilities": [ - { - "slot": "P", - "name": "Living Vengeance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/VarusPassive.png" - }, - { - "slot": "Q", - "name": "Piercing Arrow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VarusQ.png" - }, - { - "slot": "W", - "name": "Blighted Quiver", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VarusW.png" - }, - { - "slot": "E", - "name": "Hail of Arrows", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VarusE.png" - }, - { - "slot": "R", - "name": "Chain of Corruption", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VarusR.png" - } - ] - }, - { - "championName": "Vayne", - "key": "Vayne", - "abilities": [ - { - "slot": "P", - "name": "Night Hunter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Vayne_NightHunter.png" - }, - { - "slot": "Q", - "name": "Tumble", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VayneTumble.png" - }, - { - "slot": "W", - "name": "Silver Bolts", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VayneSilveredBolts.png" - }, - { - "slot": "E", - "name": "Condemn", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VayneCondemn.png" - }, - { - "slot": "R", - "name": "Final Hour", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VayneInquisition.png" - } - ] - }, - { - "championName": "Veigar", - "key": "Veigar", - "abilities": [ - { - "slot": "P", - "name": "Phenomenal Evil Power", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/VeigarEntropy.png" - }, - { - "slot": "Q", - "name": "Baleful Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VeigarBalefulStrike.png" - }, - { - "slot": "W", - "name": "Dark Matter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VeigarDarkMatter.png" - }, - { - "slot": "E", - "name": "Event Horizon", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VeigarEventHorizon.png" - }, - { - "slot": "R", - "name": "Primordial Burst", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VeigarR.png" - } - ] - }, - { - "championName": "Vel'Koz", - "key": "Velkoz", - "abilities": [ - { - "slot": "P", - "name": "Organic Deconstruction", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/VelKoz_Passive.png" - }, - { - "slot": "Q", - "name": "Plasma Fission", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VelkozQ.png" - }, - { - "slot": "W", - "name": "Void Rift", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VelkozW.png" - }, - { - "slot": "E", - "name": "Tectonic Disruption", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VelkozE.png" - }, - { - "slot": "R", - "name": "Life Form Disintegration Ray", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VelkozR.png" - } - ] - }, - { - "championName": "Vex", - "key": "Vex", - "abilities": [ - { - "slot": "P", - "name": "Doom 'n Gloom", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Icons_Vex_Passive.png" - }, - { - "slot": "Q", - "name": "Mistral Bolt", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VexQ.png" - }, - { - "slot": "W", - "name": "Personal Space", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VexW.png" - }, - { - "slot": "E", - "name": "Looming Darkness", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VexE.png" - }, - { - "slot": "R", - "name": "Shadow Surge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VexR.png" - } - ] - }, - { - "championName": "Vi", - "key": "Vi", - "abilities": [ - { - "slot": "P", - "name": "Blast Shield", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ViPassive.png" - }, - { - "slot": "Q", - "name": "Vault Breaker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViQ.png" - }, - { - "slot": "W", - "name": "Denting Blows", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViW.png" - }, - { - "slot": "E", - "name": "Relentless Force", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViE.png" - }, - { - "slot": "R", - "name": "Cease and Desist", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViR.png" - } - ] - }, - { - "championName": "Viego", - "key": "Viego", - "abilities": [ - { - "slot": "P", - "name": "Sovereign's Domination", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Viego_Passive.png" - }, - { - "slot": "Q", - "name": "Blade of the Ruined King", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViegoQ.png" - }, - { - "slot": "W", - "name": "Spectral Maw", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViegoW.png" - }, - { - "slot": "E", - "name": "Harrowed Path", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViegoE.png" - }, - { - "slot": "R", - "name": "Heartbreaker", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViegoR.png" - } - ] - }, - { - "championName": "Viktor", - "key": "Viktor", - "abilities": [ - { - "slot": "P", - "name": "Glorious Evolution", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Viktor_Passive.ViktorVGU.png" - }, - { - "slot": "Q", - "name": "Siphon Power", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViktorQ.png" - }, - { - "slot": "W", - "name": "Gravity Field", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViktorW.png" - }, - { - "slot": "E", - "name": "Hextech Ray", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViktorE.png" - }, - { - "slot": "R", - "name": "Arcane Storm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ViktorR.png" - } - ] - }, - { - "championName": "Vladimir", - "key": "Vladimir", - "abilities": [ - { - "slot": "P", - "name": "Crimson Pact", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/VladimirP.png" - }, - { - "slot": "Q", - "name": "Transfusion", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VladimirQ.png" - }, - { - "slot": "W", - "name": "Sanguine Pool", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VladimirSanguinePool.png" - }, - { - "slot": "E", - "name": "Tides of Blood", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VladimirE.png" - }, - { - "slot": "R", - "name": "Hemoplague", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VladimirHemoplague.png" - } - ] - }, - { - "championName": "Volibear", - "key": "Volibear", - "abilities": [ - { - "slot": "P", - "name": "The Relentless Storm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Volibear_Icon_P.png" - }, - { - "slot": "Q", - "name": "Thundering Smash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VolibearQ.png" - }, - { - "slot": "W", - "name": "Frenzied Maul", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VolibearW.png" - }, - { - "slot": "E", - "name": "Sky Splitter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VolibearE.png" - }, - { - "slot": "R", - "name": "Stormbringer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/VolibearR.png" - } - ] - }, - { - "championName": "Warwick", - "key": "Warwick", - "abilities": [ - { - "slot": "P", - "name": "Eternal Hunger", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/WarwickP.png" - }, - { - "slot": "Q", - "name": "Jaws of the Beast", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/WarwickQ.png" - }, - { - "slot": "W", - "name": "Blood Hunt", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/WarwickW.png" - }, - { - "slot": "E", - "name": "Primal Howl", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/WarwickE.png" - }, - { - "slot": "R", - "name": "Infinite Duress", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/WarwickR.png" - } - ] - }, - { - "championName": "Wukong", - "key": "MonkeyKing", - "abilities": [ - { - "slot": "P", - "name": "Stone Skin", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/MonkeyKingStoneSkin.png" - }, - { - "slot": "Q", - "name": "Crushing Blow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MonkeyKingDoubleAttack.png" - }, - { - "slot": "W", - "name": "Warrior Trickster", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MonkeyKingDecoy.png" - }, - { - "slot": "E", - "name": "Nimbus Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MonkeyKingNimbus.png" - }, - { - "slot": "R", - "name": "Cyclone", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/MonkeyKingSpinToWin.png" - } - ] - }, - { - "championName": "Xayah", - "key": "Xayah", - "abilities": [ - { - "slot": "P", - "name": "Clean Cuts", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/XayahPassive.png" - }, - { - "slot": "Q", - "name": "Double Daggers", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XayahQ.png" - }, - { - "slot": "W", - "name": "Deadly Plumage", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XayahW.png" - }, - { - "slot": "E", - "name": "Bladecaller", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XayahE.png" - }, - { - "slot": "R", - "name": "Featherstorm", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XayahR.png" - } - ] - }, - { - "championName": "Xerath", - "key": "Xerath", - "abilities": [ - { - "slot": "P", - "name": "Mana Surge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Xerath_Passive1.png" - }, - { - "slot": "Q", - "name": "Arcanopulse", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XerathArcanopulseChargeUp.png" - }, - { - "slot": "W", - "name": "Eye of Destruction", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XerathArcaneBarrage2.png" - }, - { - "slot": "E", - "name": "Shocking Orb", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XerathMageSpear.png" - }, - { - "slot": "R", - "name": "Rite of the Arcane", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XerathLocusOfPower2.png" - } - ] - }, - { - "championName": "Xin Zhao", - "key": "XinZhao", - "abilities": [ - { - "slot": "P", - "name": "Determination", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/XinZhaoReworkP.XinZhaoRework.png" - }, - { - "slot": "Q", - "name": "Three Talon Strike", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XinZhaoQ.png" - }, - { - "slot": "W", - "name": "Wind Becomes Lightning", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XinZhaoW.png" - }, - { - "slot": "E", - "name": "Audacious Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XinZhaoE.png" - }, - { - "slot": "R", - "name": "Crescent Guard", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/XinZhaoR.png" - } - ] - }, - { - "championName": "Yasuo", - "key": "Yasuo", - "abilities": [ - { - "slot": "P", - "name": "Way of the Wanderer", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Yasuo_Passive.png" - }, - { - "slot": "Q", - "name": "Steel Tempest", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YasuoQ1Wrapper.png" - }, - { - "slot": "W", - "name": "Wind Wall", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YasuoW.png" - }, - { - "slot": "E", - "name": "Sweeping Blade", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YasuoE.png" - }, - { - "slot": "R", - "name": "Last Breath", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YasuoR.png" - } - ] - }, - { - "championName": "Yone", - "key": "Yone", - "abilities": [ - { - "slot": "P", - "name": "Way of the Hunter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/YonePassive.png" - }, - { - "slot": "Q", - "name": "Mortal Steel", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YoneQ.png" - }, - { - "slot": "W", - "name": "Spirit Cleave", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YoneW.png" - }, - { - "slot": "E", - "name": "Soul Unbound", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YoneE.png" - }, - { - "slot": "R", - "name": "Fate Sealed", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YoneR.png" - } - ] - }, - { - "championName": "Yorick", - "key": "Yorick", - "abilities": [ - { - "slot": "P", - "name": "Shepherd of Souls", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Yorick_P.png" - }, - { - "slot": "Q", - "name": "Last Rites", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YorickQ.png" - }, - { - "slot": "W", - "name": "Dark Procession", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YorickW.png" - }, - { - "slot": "E", - "name": "Mourning Mist", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YorickE.png" - }, - { - "slot": "R", - "name": "Eulogy of the Isles", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YorickR.png" - } - ] - }, - { - "championName": "Yunara", - "key": "Yunara", - "abilities": [ - { - "slot": "P", - "name": "Vow of the First Lands", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Yunara_Passive.Yunara.png" - }, - { - "slot": "Q", - "name": "Cultivation of Spirit", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YunaraQ.png" - }, - { - "slot": "W", - "name": "Arc of Judgment | Arc of Ruin", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YunaraW.png" - }, - { - "slot": "E", - "name": "Kanmei's Steps | Untouchable Shadow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YunaraE.png" - }, - { - "slot": "R", - "name": "Transcend One's Self", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YunaraR.png" - } - ] - }, - { - "championName": "Yuumi", - "key": "Yuumi", - "abilities": [ - { - "slot": "P", - "name": "Feline Friendship", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/YuumiP2.png" - }, - { - "slot": "Q", - "name": "Prowling Projectile", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YuumiQ.png" - }, - { - "slot": "W", - "name": "You and Me!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YuumiW.png" - }, - { - "slot": "E", - "name": "Zoomies", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YuumiE.png" - }, - { - "slot": "R", - "name": "Final Chapter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/YuumiR.png" - } - ] - }, - { - "championName": "Zaahen", - "key": "Zaahen", - "abilities": [ - { - "slot": "P", - "name": "Cultivation of War", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ZaahenP.Zaahen.png" - }, - { - "slot": "Q", - "name": "The Darkin Glaive", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZaahenQ.png" - }, - { - "slot": "W", - "name": "Dreaded Return", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZaahenW.png" - }, - { - "slot": "E", - "name": "Aureate Rush", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZaahenE.png" - }, - { - "slot": "R", - "name": "Grim Deliverance", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZaahenR.png" - } - ] - }, - { - "championName": "Zac", - "key": "Zac", - "abilities": [ - { - "slot": "P", - "name": "Cell Division", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ZacPassive.png" - }, - { - "slot": "Q", - "name": "Stretching Strikes", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZacQ.png" - }, - { - "slot": "W", - "name": "Unstable Matter", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZacW.png" - }, - { - "slot": "E", - "name": "Elastic Slingshot", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZacE.png" - }, - { - "slot": "R", - "name": "Let's Bounce!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZacR.png" - } - ] - }, - { - "championName": "Zed", - "key": "Zed", - "abilities": [ - { - "slot": "P", - "name": "Contempt for the Weak", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ZedP.png" - }, - { - "slot": "Q", - "name": "Razor Shuriken", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZedQ.png" - }, - { - "slot": "W", - "name": "Living Shadow", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZedW.png" - }, - { - "slot": "E", - "name": "Shadow Slash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZedE.png" - }, - { - "slot": "R", - "name": "Death Mark", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZedR.png" - } - ] - }, - { - "championName": "Zeri", - "key": "Zeri", - "abilities": [ - { - "slot": "P", - "name": "Living Battery", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ZeriP.png" - }, - { - "slot": "Q", - "name": "Burst Fire", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZeriQ.png" - }, - { - "slot": "W", - "name": "Ultrashock Laser", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZeriW.png" - }, - { - "slot": "E", - "name": "Spark Surge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZeriE.png" - }, - { - "slot": "R", - "name": "Lightning Crash", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZeriR.png" - } - ] - }, - { - "championName": "Ziggs", - "key": "Ziggs", - "abilities": [ - { - "slot": "P", - "name": "Short Fuse", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ZiggsPassiveReady.png" - }, - { - "slot": "Q", - "name": "Bouncing Bomb", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZiggsQ.png" - }, - { - "slot": "W", - "name": "Satchel Charge", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZiggsW.png" - }, - { - "slot": "E", - "name": "Hexplosive Minefield", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZiggsE.png" - }, - { - "slot": "R", - "name": "Mega Inferno Bomb", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZiggsR.png" - } - ] - }, - { - "championName": "Zilean", - "key": "Zilean", - "abilities": [ - { - "slot": "P", - "name": "Time In A Bottle", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Zilean_Passive.png" - }, - { - "slot": "Q", - "name": "Time Bomb", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZileanQ.png" - }, - { - "slot": "W", - "name": "Rewind", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZileanW.png" - }, - { - "slot": "E", - "name": "Time Warp", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/TimeWarp.png" - }, - { - "slot": "R", - "name": "Chronoshift", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ChronoShift.png" - } - ] - }, - { - "championName": "Zoe", - "key": "Zoe", - "abilities": [ - { - "slot": "P", - "name": "More Sparkles!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/Zoe_P.png" - }, - { - "slot": "Q", - "name": "Paddle Star!", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZoeQ.png" - }, - { - "slot": "W", - "name": "Spell Thief", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZoeW.png" - }, - { - "slot": "E", - "name": "Sleepy Trouble Bubble", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZoeE.png" - }, - { - "slot": "R", - "name": "Portal Jump", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZoeR.png" - } - ] - }, - { - "championName": "Zyra", - "key": "Zyra", - "abilities": [ - { - "slot": "P", - "name": "Garden of Thorns", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/passive/ZyraP.png" - }, - { - "slot": "Q", - "name": "Deadly Spines", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZyraQ.png" - }, - { - "slot": "W", - "name": "Rampant Growth", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZyraW.png" - }, - { - "slot": "E", - "name": "Grasping Roots", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZyraE.png" - }, - { - "slot": "R", - "name": "Stranglethorns", - "icon": "https://ddragon.leagueoflegends.com/cdn/16.8.1/img/spell/ZyraR.png" - } - ] - } -] diff --git a/internal/modules/loldleability/handlers.go b/internal/modules/loldleability/handlers.go deleted file mode 100644 index ac2c5d5..0000000 --- a/internal/modules/loldleability/handlers.go +++ /dev/null @@ -1,256 +0,0 @@ -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 /loldle_ability or /loldle_ability <champion> 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( - "🔁 %s 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("%s (%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 %s — %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 %s — %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 %s. 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(" — %s (%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 %s%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 — 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)) -} diff --git a/internal/modules/loldleability/handlers_test.go b/internal/modules/loldleability/handlers_test.go deleted file mode 100644 index 717931c..0000000 --- a/internal/modules/loldleability/handlers_test.go +++ /dev/null @@ -1,152 +0,0 @@ -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: Name (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) - } -} diff --git a/internal/modules/loldleability/loldleability.go b/internal/modules/loldleability/loldleability.go deleted file mode 100644 index 379fa49..0000000 --- a/internal/modules/loldleability/loldleability.go +++ /dev/null @@ -1,39 +0,0 @@ -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, - }, - }, - } -} diff --git a/internal/modules/loldleability/lookup_test.go b/internal/modules/loldleability/lookup_test.go deleted file mode 100644 index 9cbac2b..0000000 --- a/internal/modules/loldleability/lookup_test.go +++ /dev/null @@ -1,55 +0,0 @@ -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) - } -} diff --git a/internal/modules/loldleability/state.go b/internal/modules/loldleability/state.go deleted file mode 100644 index 15647f5..0000000 --- a/internal/modules/loldleability/state.go +++ /dev/null @@ -1,127 +0,0 @@ -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 -} diff --git a/internal/modules/loldleability/state_test.go b/internal/modules/loldleability/state_test.go deleted file mode 100644 index cb364f3..0000000 --- a/internal/modules/loldleability/state_test.go +++ /dev/null @@ -1,81 +0,0 @@ -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) - } -} diff --git a/internal/modules/loldleemoji/champions.go b/internal/modules/loldleemoji/champions.go deleted file mode 100644 index c7ce76a..0000000 --- a/internal/modules/loldleemoji/champions.go +++ /dev/null @@ -1,44 +0,0 @@ -// Package loldleemoji ports the JS loldle-emoji variant — guess the -// champion from a short emoji clue. Binary right/wrong scoring (no attribute -// comparison); shares the per-subject lifecycle pattern with classic loldle -// but uses its own KV namespace so stats are isolated. -package loldleemoji - -import ( - _ "embed" - "encoding/json" - "fmt" - "strings" -) - -// EmojiChampion is one record of emojis.json. Only championName + emojis -// matter for this variant; the JS source keeps the same shape so the embed -// file lifts unmodified. -type EmojiChampion struct { - ChampionName string `json:"championName"` - Emojis string `json:"emojis"` -} - -//go:embed data/emojis.json -var rawEmojis []byte - -// loadPool parses emojis.json and drops any record with an empty `emojis` -// string (matching the JS `pool.filter(...)` at the top of handlers.js). -// Panics on malformed data — a corrupt regen of the data file is a build- -// time bug, not a runtime concern worth recovering from. -func loadPool() []EmojiChampion { - var all []EmojiChampion - if err := json.Unmarshal(rawEmojis, &all); err != nil { - panic(fmt.Sprintf("loldleemoji: cannot decode emojis.json: %v", err)) - } - out := make([]EmojiChampion, 0, len(all)) - for _, c := range all { - if strings.TrimSpace(c.Emojis) != "" { - out = append(out, c) - } - } - if len(out) == 0 { - panic("loldleemoji: emojis.json contained no usable records") - } - return out -} diff --git a/internal/modules/loldleemoji/data/emojis.json b/internal/modules/loldleemoji/data/emojis.json deleted file mode 100644 index ed7bc05..0000000 --- a/internal/modules/loldleemoji/data/emojis.json +++ /dev/null @@ -1,690 +0,0 @@ -[ - { - "championName": "Aatrox", - "emojis": "⚔️ 🌍 💪" - }, - { - "championName": "Ahri", - "emojis": "🦊 🏯 🔮" - }, - { - "championName": "Akali", - "emojis": "🧝 🏯 ⚡" - }, - { - "championName": "Akshan", - "emojis": "🧝 🏜️ 🔮" - }, - { - "championName": "Alistar", - "emojis": "🐂 🌍 🔮" - }, - { - "championName": "Ambessa", - "emojis": "🧝 ⚙️ ⚡" - }, - { - "championName": "Amumu", - "emojis": "💀 🏜️ 🔮" - }, - { - "championName": "Anivia", - "emojis": "👻 ❄️ 🔮" - }, - { - "championName": "Annie", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Aphelios", - "emojis": "🧝 ⛰️ 🔮" - }, - { - "championName": "Ashe", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Aurelion Sol", - "emojis": "🌟 🌍 🔮" - }, - { - "championName": "Aurora", - "emojis": "🦊 ❄️ 🔮" - }, - { - "championName": "Azir", - "emojis": "⚜️ 🏜️ 🔮" - }, - { - "championName": "Bard", - "emojis": "🌟 🌍 🔮" - }, - { - "championName": "Bel'Veth", - "emojis": "👁️ 👾 💪" - }, - { - "championName": "Blitzcrank", - "emojis": "🗿 🧪 🔮" - }, - { - "championName": "Brand", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Braum", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Briar", - "emojis": "🗿 🗡️ ❤️‍🩹" - }, - { - "championName": "Caitlyn", - "emojis": "🧝 ⚙️ 🔮" - }, - { - "championName": "Camille", - "emojis": "🧝 ⚙️ 🔮" - }, - { - "championName": "Cassiopeia", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Cho'Gath", - "emojis": "👁️ 👾 🔮" - }, - { - "championName": "Corki", - "emojis": "🧚 🏡 🔮" - }, - { - "championName": "Darius", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Diana", - "emojis": "🧝 ⛰️ 🔮" - }, - { - "championName": "Dr. Mundo", - "emojis": "🧝 🧪 ❤️‍🩹" - }, - { - "championName": "Draven", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Ekko", - "emojis": "🧝 🧪 🔮" - }, - { - "championName": "Elise", - "emojis": "🧝 🌫️ 🔮" - }, - { - "championName": "Evelynn", - "emojis": "😈 🌍 🔮" - }, - { - "championName": "Ezreal", - "emojis": "🧝 ⚙️ 🔮" - }, - { - "championName": "Fiddlesticks", - "emojis": "😈 🌍 🔮" - }, - { - "championName": "Fiora", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Fizz", - "emojis": "🧚 ⚓ 🔮" - }, - { - "championName": "Galio", - "emojis": "🗿 🛡️ 🔮" - }, - { - "championName": "Gangplank", - "emojis": "🧝 ⚓ 🔮" - }, - { - "championName": "Garen", - "emojis": "🧝 🛡️ 💪" - }, - { - "championName": "Gnar", - "emojis": "🧚 ❄️ 😡" - }, - { - "championName": "Gragas", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Graves", - "emojis": "🧝 ⚓ 🔮" - }, - { - "championName": "Gwen", - "emojis": "🧝 🌫️ 🔮" - }, - { - "championName": "Hecarim", - "emojis": "💀 🌫️ 🔮" - }, - { - "championName": "Heimerdinger", - "emojis": "🧚 ⚙️ 🔮" - }, - { - "championName": "Hwei", - "emojis": "🧝 🏯 🔮" - }, - { - "championName": "Illaoi", - "emojis": "🧝 ⚓ 🔮" - }, - { - "championName": "Irelia", - "emojis": "🧝 🏯 🔮" - }, - { - "championName": "Ivern", - "emojis": "🧝 🏯 🔮" - }, - { - "championName": "Janna", - "emojis": "👻 🧪 🔮" - }, - { - "championName": "Jarvan IV", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Jax", - "emojis": "❓ 🌍 🔮" - }, - { - "championName": "Jayce", - "emojis": "🧝 ⚙️ 🔮" - }, - { - "championName": "Jhin", - "emojis": "🧝 🏯 🔮" - }, - { - "championName": "Jinx", - "emojis": "🧝 🧪 🔮" - }, - { - "championName": "K'Sante", - "emojis": "🧝 🏜️ 🔮" - }, - { - "championName": "Kai'Sa", - "emojis": "🧝 🏜️ 🔮" - }, - { - "championName": "Kalista", - "emojis": "💀 🌫️ 🔮" - }, - { - "championName": "Karma", - "emojis": "🧝 🏯 🔮" - }, - { - "championName": "Karthus", - "emojis": "💀 🌫️ 🔮" - }, - { - "championName": "Kassadin", - "emojis": "🧝 🏜️ 🔮" - }, - { - "championName": "Katarina", - "emojis": "🧝 🗡️ 💪" - }, - { - "championName": "Kayle", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Kayn", - "emojis": "⚔️ 🏯 🔮" - }, - { - "championName": "Kennen", - "emojis": "🧚 🏯 ⚡" - }, - { - "championName": "Kha'Zix", - "emojis": "👁️ 👾 🔮" - }, - { - "championName": "Kindred", - "emojis": "👻 🌍 🔮" - }, - { - "championName": "Kled", - "emojis": "🧚 🗡️ 🦁" - }, - { - "championName": "Kog'Maw", - "emojis": "👁️ 👾 🔮" - }, - { - "championName": "LeBlanc", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Lee Sin", - "emojis": "🧝 🏯 ⚡" - }, - { - "championName": "Leona", - "emojis": "🧝 ⛰️ 🔮" - }, - { - "championName": "Lillia", - "emojis": "👻 🏯 🔮" - }, - { - "championName": "Lissandra", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Lucian", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Lulu", - "emojis": "🧚 🏡 🔮" - }, - { - "championName": "Lux", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Malphite", - "emojis": "🗿 🌿 🔮" - }, - { - "championName": "Malzahar", - "emojis": "🧝 🏜️ 🔮" - }, - { - "championName": "Maokai", - "emojis": "👻 🌫️ 🔮" - }, - { - "championName": "Master Yi", - "emojis": "🧝 🏯 🔮" - }, - { - "championName": "Mel", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Milio", - "emojis": "🧝 🌿 🔮" - }, - { - "championName": "Miss Fortune", - "emojis": "🧝 ⚓ 🔮" - }, - { - "championName": "Mordekaiser", - "emojis": "👻 🗡️ 🛡️" - }, - { - "championName": "Morgana", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Naafiri", - "emojis": "🐕 🏜️ 🔮" - }, - { - "championName": "Nami", - "emojis": "🦊 🌍 🔮" - }, - { - "championName": "Nasus", - "emojis": "⚜️ 🏜️ 🔮" - }, - { - "championName": "Nautilus", - "emojis": "👻 ⚓ 🔮" - }, - { - "championName": "Neeko", - "emojis": "🦊 🌿 🔮" - }, - { - "championName": "Nidalee", - "emojis": "🧝 🌿 🔮" - }, - { - "championName": "Nilah", - "emojis": "🧝 ⚓ 🔮" - }, - { - "championName": "Nocturne", - "emojis": "😈 🌍 🔮" - }, - { - "championName": "Nunu & Willump", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Olaf", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Orianna", - "emojis": "🗿 ⚙️ 🔮" - }, - { - "championName": "Ornn", - "emojis": "👻 ❄️ 🔮" - }, - { - "championName": "Pantheon", - "emojis": "🧝 ⛰️ 🔮" - }, - { - "championName": "Poppy", - "emojis": "🧚 🛡️ 🔮" - }, - { - "championName": "Pyke", - "emojis": "👻 ⚓ 🔮" - }, - { - "championName": "Qiyana", - "emojis": "🧝 🌿 🔮" - }, - { - "championName": "Quinn", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Rakan", - "emojis": "🦊 🏯 🔮" - }, - { - "championName": "Rammus", - "emojis": "❓ 🏜️ 🔮" - }, - { - "championName": "Rek'Sai", - "emojis": "👁️ 🏜️ 😡" - }, - { - "championName": "Rell", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Renata Glasc", - "emojis": "🧝 🧪 🔮" - }, - { - "championName": "Renekton", - "emojis": "⚜️ 🏜️ 💢" - }, - { - "championName": "Rengar", - "emojis": "🦊 🌿 🔥" - }, - { - "championName": "Riven", - "emojis": "🧝 🏯 💪" - }, - { - "championName": "Rumble", - "emojis": "🧚 🏡 🔥" - }, - { - "championName": "Ryze", - "emojis": "🧝 🌍 🔮" - }, - { - "championName": "Samira", - "emojis": "🧝 🏜️ 🔮" - }, - { - "championName": "Sejuani", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Senna", - "emojis": "🧝 🌫️ 🔮" - }, - { - "championName": "Seraphine", - "emojis": "🧝 ⚙️ 🔮" - }, - { - "championName": "Sett", - "emojis": "🧝 🏯 🪨" - }, - { - "championName": "Shaco", - "emojis": "👻 🌍 🔮" - }, - { - "championName": "Shen", - "emojis": "🧝 🏯 ⚡" - }, - { - "championName": "Shyvana", - "emojis": "🐉 🛡️ 💢" - }, - { - "championName": "Singed", - "emojis": "🧝 🧪 🔮" - }, - { - "championName": "Sion", - "emojis": "👻 🗡️ 🔮" - }, - { - "championName": "Sivir", - "emojis": "🧝 🏜️ 🔮" - }, - { - "championName": "Skarner", - "emojis": "🦂 🌿 🔮" - }, - { - "championName": "Smolder", - "emojis": "🐉 ⚜️ 🔮" - }, - { - "championName": "Sona", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Soraka", - "emojis": "🌟 ⛰️ 🔮" - }, - { - "championName": "Swain", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Sylas", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Syndra", - "emojis": "🧝 🏯 🔮" - }, - { - "championName": "Tahm Kench", - "emojis": "😈 ⚓ 🔮" - }, - { - "championName": "Taliyah", - "emojis": "🧝 🏜️ 🔮" - }, - { - "championName": "Talon", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Taric", - "emojis": "🧝 ⛰️ 🔮" - }, - { - "championName": "Teemo", - "emojis": "🧚 🏡 🔮" - }, - { - "championName": "Thresh", - "emojis": "💀 🌫️ 🔮" - }, - { - "championName": "Tristana", - "emojis": "🧚 🏡 🔮" - }, - { - "championName": "Trundle", - "emojis": "🧌 ❄️ 🔮" - }, - { - "championName": "Tryndamere", - "emojis": "🧝 ❄️ 💢" - }, - { - "championName": "Twisted Fate", - "emojis": "🧝 ⚓ 🔮" - }, - { - "championName": "Twitch", - "emojis": "🐀 🧪 🔮" - }, - { - "championName": "Udyr", - "emojis": "🧝 ❄️ 🔮" - }, - { - "championName": "Urgot", - "emojis": "🧝 🗡️ 🔮" - }, - { - "championName": "Varus", - "emojis": "⚔️ 🏯 🔮" - }, - { - "championName": "Vayne", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Veigar", - "emojis": "🧚 🌍 🔮" - }, - { - "championName": "Vel'Koz", - "emojis": "👁️ 👾 🔮" - }, - { - "championName": "Vex", - "emojis": "🧚 🌫️ 🔮" - }, - { - "championName": "Vi", - "emojis": "🧝 ⚙️ 🔮" - }, - { - "championName": "Viego", - "emojis": "💀 🌫️ 💪" - }, - { - "championName": "Viktor", - "emojis": "🧝 ⚙️ 🔮" - }, - { - "championName": "Vladimir", - "emojis": "🧝 🗡️ 🩸" - }, - { - "championName": "Volibear", - "emojis": "👻 ❄️ 🔮" - }, - { - "championName": "Warwick", - "emojis": "🧝 🧪 🔮" - }, - { - "championName": "Wukong", - "emojis": "🦊 🏯 🔮" - }, - { - "championName": "Xayah", - "emojis": "🦊 🏯 🔮" - }, - { - "championName": "Xerath", - "emojis": "💀 🏜️ 🔮" - }, - { - "championName": "Xin Zhao", - "emojis": "🧝 🛡️ 🔮" - }, - { - "championName": "Yasuo", - "emojis": "🧝 🏯 💧" - }, - { - "championName": "Yone", - "emojis": "🧝 🏯 💪" - }, - { - "championName": "Yorick", - "emojis": "🧝 🌫️ 🔮" - }, - { - "championName": "Yunara", - "emojis": "🧝 🏯 🔮" - }, - { - "championName": "Yuumi", - "emojis": "🐱 🏡 🔮" - }, - { - "championName": "Zaahen", - "emojis": "⚔️ 🏜️ 🔮" - }, - { - "championName": "Zac", - "emojis": "🗿 🧪 ❤️‍🩹" - }, - { - "championName": "Zed", - "emojis": "🧝 🏯 ⚡" - }, - { - "championName": "Zeri", - "emojis": "🧝 🧪 🔮" - }, - { - "championName": "Ziggs", - "emojis": "🧚 🧪 🔮" - }, - { - "championName": "Zilean", - "emojis": "🧝 🌍 🔮" - }, - { - "championName": "Zoe", - "emojis": "🧝 ⛰️ 🔮" - }, - { - "championName": "Zyra", - "emojis": "🧝 🌿 🔮" - } -] diff --git a/internal/modules/loldleemoji/handlers.go b/internal/modules/loldleemoji/handlers.go deleted file mode 100644 index a942544..0000000 --- a/internal/modules/loldleemoji/handlers.go +++ /dev/null @@ -1,218 +0,0 @@ -package loldleemoji - -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 /loldle_emoji or /loldle_emoji <champion> to start a new round." - -// state captures everything a loldle-emoji handler needs at runtime. Built -// once per Factory call and shared across the four command closures. -type state struct { - kv storage.KVStore - pool []EmojiChampion - locks keylock.Map // serialises Get→mutate→Put per subject -} - -// championName extracts the comparable name field for champname helpers. -func championName(c *EmojiChampion) string { return c.ChampionName } - -func (s *state) pickRandom() *EmojiChampion { - return &s.pool[rand.Intn(len(s.pool))] -} - -func (s *state) startFreshGame(ctx context.Context, subject string) (*gameState, error) { - target := s.pickRandom() - g := &gameState{Target: target.ChampionName, Guesses: []string{}, StartedAt: nil} - if err := saveGame(ctx, s.kv, subject, g); err != nil { - return nil, err - } - return g, nil -} - -func (s *state) getOrInitGame(ctx context.Context, subject string, maxGuesses int) (*gameState, error) { - existing, err := loadGame(ctx, s.kv, subject) - if err != nil { - return nil, err - } - if existing != nil && len(existing.Guesses) < maxGuesses { - return existing, nil - } - return s.startFreshGame(ctx, subject) -} - -// handleEmoji is /loldle_emoji [champion] — show clue if no arg, else guess. -func (s *state) handleEmoji(ctx context.Context, b *bot.Bot, update *models.Update) error { - msg := update.Message - if msg == nil { - return nil - } - subject := 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) - if target == nil { - // Pool refreshed mid-round and the target is gone — start over. - if err := clearGame(ctx, s.kv, subject); err != nil { - return err - } - return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, - "Emoji data was updated since this round started. "+newRoundHint) - } - - if arg == "" { - return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, renderBoard(target.Emojis, 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( - "🔁 %s 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) - - 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! %s — solved in %d/%d\n🔥 Streak: %d\n%s", - answer, len(game.Guesses), maxGuesses, st.Streak, newRoundHint)) - - case len(game.Guesses) >= maxGuesses: - if _, err := recordResult(ctx, s.kv, subject, false); err != nil { - return err - } - if err := clearGame(ctx, s.kv, subject); err != nil { - return err - } - return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( - "%s\n\n❌ Out of guesses. Answer was %s.\n%s", - renderBoard(target.Emojis, game.Guesses, maxGuesses), answer, newRoundHint)) - - default: - if err := saveGame(ctx, s.kv, subject, game); err != nil { - return err - } - return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( - "%s\n\n❌ Not %s. Guess %d/%d.", - renderBoard(target.Emojis, game.Guesses, maxGuesses), - html.EscapeString(guess.ChampionName), len(game.Guesses), maxGuesses)) - } -} - -// handleGiveup is /loldle_emoji_giveup — reveal answer + clear round. -func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error { - msg := update.Message - if msg == nil { - return nil - } - subject := 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 - } - if err := clearGame(ctx, s.kv, subject); err != nil { - return err - } - return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( - "🏳️ Answer was %s.\n%s", html.EscapeString(existing.Target), newRoundHint)) -} - -// handleStats is /loldle_emoji_stats — lifetime score. -func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error { - msg := update.Message - if msg == nil { - return nil - } - subject := 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 Emoji %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_emoji_setmax — 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_emoji_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 emoji max guesses set to %d (applies to the next round).", n)) -} diff --git a/internal/modules/loldleemoji/handlers_test.go b/internal/modules/loldleemoji/handlers_test.go deleted file mode 100644 index 5c762ee..0000000 --- a/internal/modules/loldleemoji/handlers_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package loldleemoji - -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" -) - -// installEmoji wires the loldle-emoji module + auth (owner gates -// /loldle_emoji_setmax). seedTarget pre-seeds a game so guess outcomes are -// deterministic without hooking math/rand. -func installEmoji(t *testing.T, ownerID int64, seedSubject, seedTarget string) (*testutil.RecordingBot, storage.KVStore) { - t.Helper() - rb := testutil.NewRecordingBot(t) - provider := storage.NewMemoryProvider() - kv := provider.For("loldle-emoji") - mod := New(modules.Deps{KV: kv}) - reg := &modules.Registry{ - Modules: []modules.Module{{Name: "loldle-emoji", 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, Guesses: []string{}} - if err := saveGame(context.Background(), kv, seedSubject, g); err != nil { - t.Fatalf("seed game: %v", err) - } - } - return rb, kv -} - -func TestEmoji_NoArgShowsClue(t *testing.T) { - rb, _ := installEmoji(t, 0, "1", "Aatrox") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_emoji")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "🎭") { - t.Errorf("emoji clue marker missing: %q", got) - } -} - -func TestEmoji_Win(t *testing.T) { - rb, _ := installEmoji(t, 0, "1", "Aatrox") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_emoji 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) - } -} - -func TestEmoji_UnknownChampion(t *testing.T) { - rb, _ := installEmoji(t, 0, "1", "Aatrox") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_emoji ZilbeanZ")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "Champion not found") { - t.Errorf("unknown champion reject: %q", got) - } -} - -func TestEmoji_DuplicateGuessRejected(t *testing.T) { - rb, _ := installEmoji(t, 0, "1", "Aatrox") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_emoji ahri")) - rb.Reset() - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_emoji ahri")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "already guessed") { - t.Errorf("duplicate-guess reply: %q", got) - } -} - -func TestEmojiGiveup_RevealsAnswer(t *testing.T) { - rb, _ := installEmoji(t, 0, "1", "Aatrox") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_emoji_giveup")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "Aatrox") { - t.Errorf("/loldle_emoji_giveup should reveal Aatrox: %q", got) - } -} - -func TestEmojiStats_Empty(t *testing.T) { - rb, _ := installEmoji(t, 0, "", "") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_emoji_stats")) - - got := rb.LastSent().Text() - for _, want := range []string{"Played: 0", "Wins: 0 (0%)"} { - if !strings.Contains(got, want) { - t.Errorf("/loldle_emoji_stats empty missing %q; got %q", want, got) - } - } -} - -func TestEmojiSetMax_OwnerSucceeds(t *testing.T) { - rb, kv := installEmoji(t, 999, "", "") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_emoji_setmax 7")) - - got := rb.LastSent().Text() - if !strings.Contains(got, "max guesses set to 7") { - t.Errorf("/loldle_emoji_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 != 7 { - t.Errorf("MaxGuesses persisted = %d, want 7", cfg.MaxGuesses) - } -} - -func TestEmojiSetMax_DeniedToNonOwner(t *testing.T) { - rb, _ := installEmoji(t, 999, "", "") - rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/loldle_emoji_setmax 5")) - if calls := rb.Sent(); len(calls) != 0 { - t.Errorf("non-owner /loldle_emoji_setmax replied: %+v", calls) - } -} diff --git a/internal/modules/loldleemoji/loldleemoji.go b/internal/modules/loldleemoji/loldleemoji.go deleted file mode 100644 index 23fd5de..0000000 --- a/internal/modules/loldleemoji/loldleemoji.go +++ /dev/null @@ -1,39 +0,0 @@ -package loldleemoji - -import ( - "github.com/tiennm99/miti99bot-go/internal/modules" -) - -// New is the loldle-emoji module Factory. Loads the embedded pool once and -// shares it (plus the per-subject lock map) across all handlers. -func New(deps modules.Deps) modules.Module { - s := &state{kv: deps.KV, pool: loadPool()} - return modules.Module{ - Commands: []modules.Command{ - { - Name: "loldle_emoji", - Visibility: modules.VisibilityPublic, - Description: "Emoji loldle — guess the champion from emojis", - Handler: s.handleEmoji, - }, - { - Name: "loldle_emoji_giveup", - Visibility: modules.VisibilityPublic, - Description: "Reveal the current emoji loldle answer", - Handler: s.handleGiveup, - }, - { - Name: "loldle_emoji_stats", - Visibility: modules.VisibilityPublic, - Description: "Show your emoji loldle stats (wins, streak)", - Handler: s.handleStats, - }, - { - Name: "loldle_emoji_setmax", - Visibility: modules.VisibilityPrivate, - Description: "Override emoji loldle max guesses per round (1-10)", - Handler: s.handleSetMax, - }, - }, - } -} diff --git a/internal/modules/loldleemoji/lookup_test.go b/internal/modules/loldleemoji/lookup_test.go deleted file mode 100644 index 21bc820..0000000 --- a/internal/modules/loldleemoji/lookup_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package loldleemoji - -import ( - "testing" - - "github.com/tiennm99/miti99bot-go/internal/champname" -) - -// Generic lookup primitives live in internal/champname. This file only -// exercises the embedded pool — wiring + filter integration test. -func TestLoadPool_DropsEmptyEmojiRecords(t *testing.T) { - pool := loadPool() - if n := len(pool); n < 150 || n > 200 { - t.Errorf("pool size = %d, want ~172", n) - } - for _, c := range pool { - if c.Emojis == "" { - t.Errorf("empty-emoji record leaked through filter: %s", c.ChampionName) - } - } - if got := champname.FindByExactName(pool, "Aatrox", championName); got == nil { - t.Error("expected Aatrox in pool") - } -} diff --git a/internal/modules/loldleemoji/render.go b/internal/modules/loldleemoji/render.go deleted file mode 100644 index 19831c3..0000000 --- a/internal/modules/loldleemoji/render.go +++ /dev/null @@ -1,32 +0,0 @@ -package loldleemoji - -import ( - "fmt" - "html" - "strings" -) - -// renderBoard formats the emoji clue + the wrong-guess list. JS-faithful: -// -// 🎭 ⚔️ 🌍 💪 -// -// Guesses (2/5): -// • Aatrox ❌ -// • Ahri ❌ -// -// Empty board returns the placeholder hint. emojis is build-time data (embedded -// JSON) so practically safe, but we escape defensively — Telegram's HTML parse -// mode rejects unknown tags and the page-level invariant is "no unescaped -// user/data input in HTML output". -func renderBoard(emojis string, guesses []string, maxGuesses int) string { - clue := "🎭 " + html.EscapeString(emojis) - if len(guesses) == 0 { - return clue + "\n\nNo guesses yet. Reply with /loldle_emoji <champion>." - } - lines := make([]string, len(guesses)) - for i, name := range guesses { - lines[i] = " • " + html.EscapeString(name) + " ❌" - } - return fmt.Sprintf("%s\n\nGuesses (%d/%d):\n%s", - clue, len(guesses), maxGuesses, strings.Join(lines, "\n")) -} diff --git a/internal/modules/loldleemoji/render_test.go b/internal/modules/loldleemoji/render_test.go deleted file mode 100644 index 032f289..0000000 --- a/internal/modules/loldleemoji/render_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package loldleemoji - -import ( - "strings" - "testing" -) - -func TestRenderBoard_EmptyShowsHint(t *testing.T) { - out := renderBoard("⚔️ 🌍", nil, 5) - if !strings.Contains(out, "🎭 ⚔️ 🌍") { - t.Errorf("emoji clue missing: %q", out) - } - if !strings.Contains(out, "No guesses yet") { - t.Errorf("placeholder missing: %q", out) - } -} - -func TestRenderBoard_ListsGuessesWithCounter(t *testing.T) { - out := renderBoard("⚔️ 🌍", []string{"Aatrox", "Ahri"}, 5) - if !strings.Contains(out, "Guesses (2/5)") { - t.Errorf("counter missing: %q", out) - } - if !strings.Contains(out, " • Aatrox ❌") { - t.Errorf("first guess line missing: %q", out) - } - if !strings.Contains(out, " • Ahri ❌") { - t.Errorf("second guess line missing: %q", out) - } -} - -func TestRenderBoard_EscapesHTMLInGuessNames(t *testing.T) { - // Champion names from the dict are static strings without HTML metachars, - // but render escapes defensively. Prove it. - out := renderBoard("⚔️", []string{"`, nil, 6) - if strings.Contains(got, "