From 479a6ea9ea183dc78d86114f27a51098cae276f9 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 9 May 2026 09:56:54 +0700 Subject: [PATCH] feat(modules): port loldle classic + fix winRate truncation Ported loldle game module with full classic-mode comparison engine: 4 commands (3 public + /loldle_setmax private), 7-attribute comparison (gender/species/range_type/resource/regions/positions/release_date) with exact/multi/year scoring, 172-champion dictionary, and sticker pools by outcome. Fixed winRate display discrepancy: JS uses Math.round but Go was using int(...) truncation. Applied math.Round in both loldle and wordle handlers. Rendered output now matches expected percentages (e.g. 67% instead of 66%). Includes comparison/lookup/flavor/state/render golden tests, keylock fan-out tests, and strict render-alignment validation. --- cmd/server/main.go | 2 + internal/modules/loldle/champions.go | 44 + internal/modules/loldle/compare.go | 253 ++ internal/modules/loldle/compare_test.go | 160 + internal/modules/loldle/data/champions.json | 3292 +++++++++++++++++++ internal/modules/loldle/flavor.go | 56 + internal/modules/loldle/flavor_test.go | 47 + internal/modules/loldle/handlers.go | 334 ++ internal/modules/loldle/loldle.go | 42 + internal/modules/loldle/lookup.go | 48 + internal/modules/loldle/lookup_test.go | 90 + internal/modules/loldle/normalize.go | 20 + internal/modules/loldle/render.go | 118 + internal/modules/loldle/render_test.go | 88 + internal/modules/loldle/state.go | 146 + internal/modules/loldle/state_test.go | 145 + internal/modules/loldle/stickers.go | 36 + 17 files changed, 4921 insertions(+) create mode 100644 internal/modules/loldle/champions.go create mode 100644 internal/modules/loldle/compare.go create mode 100644 internal/modules/loldle/compare_test.go create mode 100644 internal/modules/loldle/data/champions.json create mode 100644 internal/modules/loldle/flavor.go create mode 100644 internal/modules/loldle/flavor_test.go create mode 100644 internal/modules/loldle/handlers.go create mode 100644 internal/modules/loldle/loldle.go create mode 100644 internal/modules/loldle/lookup.go create mode 100644 internal/modules/loldle/lookup_test.go create mode 100644 internal/modules/loldle/normalize.go create mode 100644 internal/modules/loldle/render.go create mode 100644 internal/modules/loldle/render_test.go create mode 100644 internal/modules/loldle/state.go create mode 100644 internal/modules/loldle/state_test.go create mode 100644 internal/modules/loldle/stickers.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 63fe8e3..2297614 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -12,6 +12,7 @@ import ( "time" "github.com/tiennm99/miti99bot-go/internal/modules" + "github.com/tiennm99/miti99bot-go/internal/modules/loldle" "github.com/tiennm99/miti99bot-go/internal/modules/misc" "github.com/tiennm99/miti99bot-go/internal/modules/util" "github.com/tiennm99/miti99bot-go/internal/modules/wordle" @@ -36,6 +37,7 @@ func factories() map[string]modules.Factory { "util": util.New, "misc": misc.New, "wordle": wordle.New, + "loldle": loldle.New, } } diff --git a/internal/modules/loldle/champions.go b/internal/modules/loldle/champions.go new file mode 100644 index 0000000..59cfc91 --- /dev/null +++ b/internal/modules/loldle/champions.go @@ -0,0 +1,44 @@ +// Package loldle ports the JS loldle classic mode — guess the League +// champion from attribute hints (gender, species, regions, etc.). +package loldle + +import ( + _ "embed" + "encoding/json" + "fmt" +) + +// Champion is one row of champions.json. Field tags match loldle.net's +// scraped schema verbatim so the embedded JSON file lifts unmodified from +// the JS source. +type Champion struct { + ChampionName string `json:"championName"` + Gender string `json:"gender"` + Positions []string `json:"positions"` + Species []string `json:"species"` + Resource string `json:"resource"` + RangeType []string `json:"range_type"` + Regions []string `json:"regions"` + ReleaseDate string `json:"release_date"` // YYYY-MM-DD +} + +// rawChampions holds the embedded JSON byte stream. The data file is copied +// byte-for-byte from src/modules/loldle/champions.json in the JS repo so +// dictionaries are identical across runtimes (no normalization at port time). +// +//go:embed data/champions.json +var rawChampions []byte + +// loadChampions parses the embedded JSON. Panics on malformed data — +// a corrupt regen of champions.json is a build-time bug, not a runtime +// concern worth recovering from. +func loadChampions() []Champion { + var cs []Champion + if err := json.Unmarshal(rawChampions, &cs); err != nil { + panic(fmt.Sprintf("loldle: cannot decode champions.json: %v", err)) + } + if len(cs) == 0 { + panic("loldle: champions.json contained no records") + } + return cs +} diff --git a/internal/modules/loldle/compare.go b/internal/modules/loldle/compare.go new file mode 100644 index 0000000..9b08fed --- /dev/null +++ b/internal/modules/loldle/compare.go @@ -0,0 +1,253 @@ +package loldle + +import ( + "strings" +) + +// AttrType classifies how a single attribute is compared. Matches the JS +// source's "exact" | "multi" | "year" discriminator. +type AttrType string + +const ( + attrExact AttrType = "exact" + attrMulti AttrType = "multi" + attrYear AttrType = "year" +) + +// Result categories. Match JS strings byte-for-byte (handlers/render rely on +// these literals via the marker maps). +const ( + ResultCorrect = "correct" + ResultPartial = "partial" + ResultWrong = "wrong" +) + +// AttributeRow describes one attribute's comparison output. JS shape: +// {key, label, type, guessValue, targetValue, result, direction?}. +type AttributeRow struct { + Key string + Label string + Type AttrType + GuessValue string + TargetValue string + Result string // correct | partial | wrong + Direction string // "up" | "down" — only set for year-type wrong rows +} + +// classicAttributes is the ordered comparison spec. Order matters for both +// render output and the JS-port test that asserts `compareChampions(...)` +// returns rows in this exact sequence. +var classicAttributes = []AttributeRow{ + {Key: "gender", Label: "Gender", Type: attrExact}, + {Key: "species", Label: "Species", Type: attrMulti}, + {Key: "range_type", Label: "Range type", Type: attrMulti}, + {Key: "resource", Label: "Resource", Type: attrExact}, + {Key: "regions", Label: "Region(s)", Type: attrMulti}, + {Key: "positions", Label: "Position(s)", Type: attrMulti}, + {Key: "release_date", Label: "Release year", Type: attrYear}, +} + +// CompareChampions returns one row per classic attribute, in declared order. +// Values are compared per attr.Type: +// - exact: case-insensitive string equality. +// - multi: set comparison; full match → correct, partial overlap → partial, +// no overlap → wrong. Two empty sets are correct (matches JS). +// - year: parses leading 4 digits; equal → correct, else wrong + a +// direction hint ("up" if guesstarget). +func CompareChampions(guess, target *Champion) []AttributeRow { + out := make([]AttributeRow, len(classicAttributes)) + for i, attr := range classicAttributes { + row := attr // copy, then fill + gVal := attrValue(guess, attr.Key) + tVal := attrValue(target, attr.Key) + + switch attr.Type { + case attrYear: + gy := parseYear(asString(gVal)) + ty := parseYear(asString(tVal)) + row.GuessValue = yearOrPlaceholder(gy) + row.TargetValue = yearOrPlaceholder(ty) + row.Result, row.Direction = compareYear(gy, ty) + case attrExact: + row.GuessValue = formatValue(gVal) + row.TargetValue = formatValue(tVal) + if strings.EqualFold(asString(gVal), asString(tVal)) { + row.Result = ResultCorrect + } else { + row.Result = ResultWrong + } + case attrMulti: + row.GuessValue = formatValue(gVal) + row.TargetValue = formatValue(tVal) + row.Result = compareMultiValue(asStringSlice(gVal), asStringSlice(tVal)) + } + out[i] = row + } + return out +} + +// attrValue returns the named field on a Champion as `any` (string or []string). +// Centralised so adding a new attribute touches one switch. +func attrValue(c *Champion, key string) any { + if c == nil { + return nil + } + switch key { + case "gender": + return c.Gender + case "species": + return c.Species + case "range_type": + return c.RangeType + case "resource": + return c.Resource + case "regions": + return c.Regions + case "positions": + return c.Positions + case "release_date": + return c.ReleaseDate + } + return nil +} + +func asString(v any) string { + switch x := v.(type) { + case string: + return x + case []string: + return strings.Join(x, ",") + } + return "" +} + +func asStringSlice(v any) []string { + switch x := v.(type) { + case []string: + return x + case string: + // JS toSet falls back to splitting on "," when the value isn't an array. + // Champions.json never produces this branch but parity matters. + if x == "" { + return nil + } + return strings.Split(x, ",") + } + return nil +} + +// compareMultiValue: full match (case-insensitive, order-independent) → +// correct; any intersection → partial; otherwise wrong. Two empty sets +// are correct (matches JS, e.g. for hypothetical "no positions" champs). +func compareMultiValue(guess, target []string) string { + g := toLowerSet(guess) + t := toLowerSet(target) + if len(g) == 0 && len(t) == 0 { + return ResultCorrect + } + if len(g) == 0 || len(t) == 0 { + return ResultWrong + } + if setsEqual(g, t) { + return ResultCorrect + } + for v := range g { + if _, ok := t[v]; ok { + return ResultPartial + } + } + return ResultWrong +} + +func toLowerSet(xs []string) map[string]struct{} { + out := make(map[string]struct{}, len(xs)) + for _, s := range xs { + t := strings.TrimSpace(strings.ToLower(s)) + if t != "" { + out[t] = struct{}{} + } + } + return out +} + +func setsEqual(a, b map[string]struct{}) bool { + if len(a) != len(b) { + return false + } + for k := range a { + if _, ok := b[k]; !ok { + return false + } + } + return true +} + +// parseYear extracts the first 4 digits of s. Returns 0 if absent/non-numeric +// — JS regex parity (`^(\d{4})`). +func parseYear(s string) int { + if len(s) < 4 { + return 0 + } + for i := 0; i < 4; i++ { + if s[i] < '0' || s[i] > '9' { + return 0 + } + } + y := 0 + for i := 0; i < 4; i++ { + y = y*10 + int(s[i]-'0') + } + return y +} + +func yearOrPlaceholder(y int) string { + if y == 0 { + return "?" + } + // strconv would pull in another import; for a 4-digit positive int the + // Sprintf path is fine and zero-allocs after warmup. + return fmtYear(y) +} + +func fmtYear(y int) string { + // Manual base-10 to avoid strconv import; year is always 4 digits here. + return string([]byte{ + byte('0' + (y/1000)%10), + byte('0' + (y/100)%10), + byte('0' + (y/10)%10), + byte('0' + y%10), + }) +} + +func compareYear(g, t int) (result, direction string) { + if g == 0 || t == 0 { + return ResultWrong, "" + } + if g == t { + return ResultCorrect, "" + } + if g < t { + return ResultWrong, "up" + } + return ResultWrong, "down" +} + +// formatValue mirrors JS's `formatValue`: empty → "—", array → comma-joined, +// otherwise toString. +func formatValue(v any) string { + switch x := v.(type) { + case nil: + return "—" + case string: + if x == "" { + return "—" + } + return x + case []string: + if len(x) == 0 { + return "—" + } + return strings.Join(x, ", ") + } + return "—" +} diff --git a/internal/modules/loldle/compare_test.go b/internal/modules/loldle/compare_test.go new file mode 100644 index 0000000..6f6eaf6 --- /dev/null +++ b/internal/modules/loldle/compare_test.go @@ -0,0 +1,160 @@ +package loldle + +import ( + "testing" +) + +// Fixtures lifted from tests/modules/loldle/compare.test.js so any future +// drift between Go and JS scoring shows up as a test failure here. + +var aatrox = &Champion{ + ChampionName: "Aatrox", + Gender: "Male", + Species: []string{"Darkin"}, + RangeType: []string{"Melee"}, + Resource: "Manaless", + Regions: []string{"Runeterra", "Shurima"}, + Positions: []string{"Top"}, + ReleaseDate: "2013-06-13", +} + +var ahri = &Champion{ + ChampionName: "Ahri", + Gender: "Female", + Species: []string{"Vastayan"}, + RangeType: []string{"Ranged"}, + Resource: "Mana", + Regions: []string{"Ionia"}, + Positions: []string{"Middle"}, + ReleaseDate: "2011-12-14", +} + +var akali = &Champion{ + ChampionName: "Akali", + Gender: "Female", + Species: []string{"Human"}, + RangeType: []string{"Melee"}, + Resource: "Energy", + Regions: []string{"Ionia"}, + Positions: []string{"Middle", "Top"}, + ReleaseDate: "2010-05-11", +} + +func byKey(rows []AttributeRow, key string) AttributeRow { + for _, r := range rows { + if r.Key == key { + return r + } + } + return AttributeRow{} +} + +func TestCompareChampions_AllCorrectWhenIdentical(t *testing.T) { + for _, r := range CompareChampions(aatrox, aatrox) { + if r.Result != ResultCorrect { + t.Errorf("%s: %s, want correct", r.Key, r.Result) + } + } +} + +func TestCompareChampions_ExactMismatchIsWrong(t *testing.T) { + r := CompareChampions(aatrox, ahri) + if got := byKey(r, "gender").Result; got != ResultWrong { + t.Errorf("gender = %s, want wrong", got) + } + if got := byKey(r, "resource").Result; got != ResultWrong { + t.Errorf("resource = %s, want wrong", got) + } +} + +func TestCompareChampions_MultiPartialOverlap(t *testing.T) { + // guess akali (Middle, Top) vs target with positions=[Middle] → partial. + target := *ahri + target.Positions = []string{"Middle"} + r := CompareChampions(akali, &target) + if got := byKey(r, "positions").Result; got != ResultPartial { + t.Errorf("positions = %s, want partial", got) + } + + target2 := *ahri + target2.Regions = []string{"Runeterra", "Ionia"} + r2 := CompareChampions(aatrox, &target2) + if got := byKey(r2, "regions").Result; got != ResultPartial { + t.Errorf("regions = %s, want partial", got) + } +} + +func TestCompareChampions_MultiIdenticalSetsCaseInsensitive(t *testing.T) { + guess := *akali + guess.Species = []string{"Human", "Ninja"} + target := *akali + target.Species = []string{"ninja", "HUMAN"} + r := CompareChampions(&guess, &target) + if got := byKey(r, "species").Result; got != ResultCorrect { + t.Errorf("species = %s, want correct", got) + } +} + +func TestCompareChampions_YearDirectionUp(t *testing.T) { + // akali 2010 vs aatrox 2013 → wrong + up + r := CompareChampions(akali, aatrox) + y := byKey(r, "release_date") + if y.Result != ResultWrong { + t.Errorf("year result = %s, want wrong", y.Result) + } + if y.Direction != "up" { + t.Errorf("year direction = %s, want up", y.Direction) + } +} + +func TestCompareChampions_YearDirectionDown(t *testing.T) { + // aatrox 2013 vs akali 2010 → wrong + down + r := CompareChampions(aatrox, akali) + y := byKey(r, "release_date") + if y.Result != ResultWrong { + t.Errorf("year result = %s, want wrong", y.Result) + } + if y.Direction != "down" { + t.Errorf("year direction = %s, want down", y.Direction) + } +} + +func TestCompareChampions_AttributeOrderIsStable(t *testing.T) { + r := CompareChampions(aatrox, ahri) + want := []string{"gender", "species", "range_type", "resource", "regions", "positions", "release_date"} + if len(r) != len(want) { + t.Fatalf("len = %d, want %d", len(r), len(want)) + } + for i, w := range want { + if r[i].Key != w { + t.Errorf("row[%d].Key = %s, want %s", i, r[i].Key, w) + } + } +} + +func TestCompareYear_ZeroOnEither(t *testing.T) { + res, dir := compareYear(0, 2013) + if res != ResultWrong || dir != "" { + t.Errorf("0 vs 2013: %s %s", res, dir) + } + res, dir = compareYear(2013, 0) + if res != ResultWrong || dir != "" { + t.Errorf("2013 vs 0: %s %s", res, dir) + } +} + +func TestParseYear_InvalidInputs(t *testing.T) { + cases := map[string]int{ + "": 0, + "abc": 0, + "19": 0, + "2013": 2013, + "2013-06-13": 2013, + "abcd-06-13": 0, + } + for in, want := range cases { + if got := parseYear(in); got != want { + t.Errorf("parseYear(%q) = %d, want %d", in, got, want) + } + } +} diff --git a/internal/modules/loldle/data/champions.json b/internal/modules/loldle/data/champions.json new file mode 100644 index 0000000..61a8b33 --- /dev/null +++ b/internal/modules/loldle/data/champions.json @@ -0,0 +1,3292 @@ +[ + { + "championName": "Aatrox", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Darkin" + ], + "resource": "Manaless", + "range_type": [ + "Melee" + ], + "regions": [ + "Runeterra", + "Shurima" + ], + "release_date": "2013-06-13" + }, + { + "championName": "Ahri", + "gender": "Female", + "positions": [ + "Middle" + ], + "species": [ + "Vastayan" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Ionia" + ], + "release_date": "2011-12-14" + }, + { + "championName": "Akali", + "gender": "Female", + "positions": [ + "Middle" + ], + "species": [ + "Human" + ], + "resource": "Energy", + "range_type": [ + "Melee" + ], + "regions": [ + "Ionia" + ], + "release_date": "2010-05-11" + }, + { + "championName": "Akshan", + "gender": "Male", + "positions": [ + "Middle" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Shurima" + ], + "release_date": "2021-07-22" + }, + { + "championName": "Alistar", + "gender": "Male", + "positions": [ + "Support" + ], + "species": [ + "Minotaur" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Runeterra", + "Noxus" + ], + "release_date": "2009-02-21" + }, + { + "championName": "Ambessa", + "gender": "Female", + "positions": [ + "Top" + ], + "species": [ + "Human" + ], + "resource": "Energy", + "range_type": [ + "Melee" + ], + "regions": [ + "Piltover", + "Noxus" + ], + "release_date": "2024-11-06" + }, + { + "championName": "Amumu", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Undead", + "Yordle" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Shurima" + ], + "release_date": "2009-06-26" + }, + { + "championName": "Anivia", + "gender": "Female", + "positions": [ + "Middle" + ], + "species": [ + "Spirit", + "God" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Freljord" + ], + "release_date": "2009-07-10" + }, + { + "championName": "Annie", + "gender": "Female", + "positions": [ + "Middle" + ], + "species": [ + "Human", + "Magicborn" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Noxus", + "Runeterra" + ], + "release_date": "2009-02-21" + }, + { + "championName": "Aphelios", + "gender": "Male", + "positions": [ + "Bottom" + ], + "species": [ + "Human", + "Spiritualist" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Targon" + ], + "release_date": "2019-12-11" + }, + { + "championName": "Ashe", + "gender": "Female", + "positions": [ + "Bottom" + ], + "species": [ + "Human", + "Iceborn" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Freljord" + ], + "release_date": "2009-02-21" + }, + { + "championName": "Aurelion Sol", + "gender": "Male", + "positions": [ + "Middle" + ], + "species": [ + "Celestial", + "Dragon" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Runeterra", + "Targon" + ], + "release_date": "2016-03-24" + }, + { + "championName": "Aurora", + "gender": "Female", + "positions": [ + "Middle" + ], + "species": [ + "Vastayan" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Freljord" + ], + "release_date": "2024-07-17" + }, + { + "championName": "Azir", + "gender": "Male", + "positions": [ + "Middle" + ], + "species": [ + "God-Warrior" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Shurima" + ], + "release_date": "2014-09-16" + }, + { + "championName": "Bard", + "gender": "Male", + "positions": [ + "Support" + ], + "species": [ + "Celestial" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Runeterra" + ], + "release_date": "2015-03-12" + }, + { + "championName": "Bel'Veth", + "gender": "Female", + "positions": [ + "Jungle" + ], + "species": [ + "Void-Being" + ], + "resource": "Manaless", + "range_type": [ + "Melee" + ], + "regions": [ + "Void" + ], + "release_date": "2022-06-09" + }, + { + "championName": "Blitzcrank", + "gender": "Other", + "positions": [ + "Support" + ], + "species": [ + "Golem" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Zaun" + ], + "release_date": "2009-09-02" + }, + { + "championName": "Brand", + "gender": "Male", + "positions": [ + "Support" + ], + "species": [ + "Human", + "Magically Altered" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Freljord", + "Runeterra" + ], + "release_date": "2011-04-12" + }, + { + "championName": "Braum", + "gender": "Male", + "positions": [ + "Support" + ], + "species": [ + "Human", + "Iceborn" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Freljord" + ], + "release_date": "2014-05-12" + }, + { + "championName": "Briar", + "gender": "Female", + "positions": [ + "Jungle" + ], + "species": [ + "Golem" + ], + "resource": "Health costs", + "range_type": [ + "Melee" + ], + "regions": [ + "Noxus" + ], + "release_date": "2023-09-14" + }, + { + "championName": "Caitlyn", + "gender": "Female", + "positions": [ + "Bottom" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Piltover" + ], + "release_date": "2011-01-04" + }, + { + "championName": "Camille", + "gender": "Female", + "positions": [ + "Top" + ], + "species": [ + "Human", + "Cyborg" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Piltover" + ], + "release_date": "2016-12-07" + }, + { + "championName": "Cassiopeia", + "gender": "Female", + "positions": [ + "Top", + "Middle" + ], + "species": [ + "Human", + "Magically Altered" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Noxus", + "Shurima" + ], + "release_date": "2010-12-14" + }, + { + "championName": "Cho'Gath", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Void-Being" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Void" + ], + "release_date": "2009-06-26" + }, + { + "championName": "Corki", + "gender": "Male", + "positions": [ + "Bottom" + ], + "species": [ + "Yordle" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Bandle City", + "Piltover" + ], + "release_date": "2009-09-19" + }, + { + "championName": "Darius", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Noxus" + ], + "release_date": "2012-05-23" + }, + { + "championName": "Diana", + "gender": "Female", + "positions": [ + "Jungle", + "Middle" + ], + "species": [ + "Human", + "Aspect" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Targon" + ], + "release_date": "2012-08-07" + }, + { + "championName": "Dr. Mundo", + "gender": "Male", + "positions": [ + "Top", + "Jungle" + ], + "species": [ + "Human", + "Chemically Altered" + ], + "resource": "Health costs", + "range_type": [ + "Melee" + ], + "regions": [ + "Zaun" + ], + "release_date": "2009-09-02" + }, + { + "championName": "Draven", + "gender": "Male", + "positions": [ + "Bottom" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Noxus" + ], + "release_date": "2012-06-06" + }, + { + "championName": "Ekko", + "gender": "Male", + "positions": [ + "Jungle", + "Middle" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Zaun" + ], + "release_date": "2015-05-29" + }, + { + "championName": "Elise", + "gender": "Female", + "positions": [ + "Jungle", + "Support" + ], + "species": [ + "Human", + "Magically Altered" + ], + "resource": "Mana", + "range_type": [ + "Melee", + "Ranged" + ], + "regions": [ + "Shadow Isles", + "Noxus" + ], + "release_date": "2012-10-26" + }, + { + "championName": "Evelynn", + "gender": "Female", + "positions": [ + "Jungle" + ], + "species": [ + "Demon", + "Spirit" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Runeterra" + ], + "release_date": "2009-05-01" + }, + { + "championName": "Ezreal", + "gender": "Male", + "positions": [ + "Bottom" + ], + "species": [ + "Human", + "Magicborn" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Piltover" + ], + "release_date": "2010-03-16" + }, + { + "championName": "Fiddlesticks", + "gender": "Other", + "positions": [ + "Jungle" + ], + "species": [ + "Demon", + "Spirit" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Runeterra" + ], + "release_date": "2009-02-21" + }, + { + "championName": "Fiora", + "gender": "Female", + "positions": [ + "Top" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Demacia" + ], + "release_date": "2012-02-29" + }, + { + "championName": "Fizz", + "gender": "Male", + "positions": [ + "Middle" + ], + "species": [ + "Yordle" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Bilgewater" + ], + "release_date": "2011-11-15" + }, + { + "championName": "Galio", + "gender": "Male", + "positions": [ + "Middle" + ], + "species": [ + "Golem" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Demacia" + ], + "release_date": "2010-08-10" + }, + { + "championName": "Gangplank", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Bilgewater" + ], + "release_date": "2009-08-19" + }, + { + "championName": "Garen", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Human" + ], + "resource": "Manaless", + "range_type": [ + "Melee" + ], + "regions": [ + "Demacia" + ], + "release_date": "2010-04-27" + }, + { + "championName": "Gnar", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Yordle" + ], + "resource": "Rage", + "range_type": [ + "Melee", + "Ranged" + ], + "regions": [ + "Freljord" + ], + "release_date": "2014-08-14" + }, + { + "championName": "Gragas", + "gender": "Male", + "positions": [ + "Top", + "Jungle" + ], + "species": [ + "Human", + "Iceborn" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Freljord" + ], + "release_date": "2010-02-02" + }, + { + "championName": "Graves", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Bilgewater" + ], + "release_date": "2011-10-19" + }, + { + "championName": "Gwen", + "gender": "Female", + "positions": [ + "Top", + "Jungle" + ], + "species": [ + "Human", + "Magically Altered" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Shadow Isles", + "Camavor" + ], + "release_date": "2021-04-15" + }, + { + "championName": "Hecarim", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Undead" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Shadow Isles", + "Camavor" + ], + "release_date": "2012-04-18" + }, + { + "championName": "Heimerdinger", + "gender": "Male", + "positions": [ + "Top", + "Middle" + ], + "species": [ + "Yordle" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Piltover" + ], + "release_date": "2009-10-10" + }, + { + "championName": "Hwei", + "gender": "Male", + "positions": [ + "Middle" + ], + "species": [ + "Human", + "Magicborn" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Ionia" + ], + "release_date": "2023-12-05" + }, + { + "championName": "Illaoi", + "gender": "Female", + "positions": [ + "Top" + ], + "species": [ + "Human", + "Spiritualist" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Bilgewater" + ], + "release_date": "2015-11-24" + }, + { + "championName": "Irelia", + "gender": "Female", + "positions": [ + "Top", + "Middle" + ], + "species": [ + "Human", + "Spiritualist" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Ionia" + ], + "release_date": "2010-11-16" + }, + { + "championName": "Ivern", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Human", + "Magically Altered" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Ionia", + "Freljord" + ], + "release_date": "2016-10-05" + }, + { + "championName": "Janna", + "gender": "Female", + "positions": [ + "Support" + ], + "species": [ + "Spirit", + "God" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Zaun", + "Shurima" + ], + "release_date": "2009-09-02" + }, + { + "championName": "Jarvan IV", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Demacia" + ], + "release_date": "2011-03-01" + }, + { + "championName": "Jax", + "gender": "Male", + "positions": [ + "Top", + "Jungle" + ], + "species": [ + "Unknown" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Runeterra", + "Icathia", + "Shurima" + ], + "release_date": "2009-02-21" + }, + { + "championName": "Jayce", + "gender": "Male", + "positions": [ + "Top", + "Jungle" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Melee", + "Ranged" + ], + "regions": [ + "Piltover" + ], + "release_date": "2012-07-07" + }, + { + "championName": "Jhin", + "gender": "Male", + "positions": [ + "Bottom" + ], + "species": [ + "Human", + "Spiritualist" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Ionia" + ], + "release_date": "2016-02-01" + }, + { + "championName": "Jinx", + "gender": "Female", + "positions": [ + "Bottom" + ], + "species": [ + "Human", + "Chemically Altered" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Zaun" + ], + "release_date": "2013-10-10" + }, + { + "championName": "K'Sante", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Shurima" + ], + "release_date": "2022-11-02" + }, + { + "championName": "Kai'Sa", + "gender": "Female", + "positions": [ + "Bottom" + ], + "species": [ + "Human", + "Void-Being" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Shurima", + "Void" + ], + "release_date": "2018-03-07" + }, + { + "championName": "Kalista", + "gender": "Female", + "positions": [ + "Bottom" + ], + "species": [ + "Undead" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Shadow Isles", + "Camavor" + ], + "release_date": "2014-11-20" + }, + { + "championName": "Karma", + "gender": "Female", + "positions": [ + "Support" + ], + "species": [ + "Human", + "Spiritualist" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Ionia" + ], + "release_date": "2011-02-01" + }, + { + "championName": "Karthus", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Undead" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Shadow Isles", + "Noxus" + ], + "release_date": "2009-06-12" + }, + { + "championName": "Kassadin", + "gender": "Male", + "positions": [ + "Middle" + ], + "species": [ + "Human", + "Void-Being" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Shurima", + "Void" + ], + "release_date": "2009-08-07" + }, + { + "championName": "Katarina", + "gender": "Female", + "positions": [ + "Middle" + ], + "species": [ + "Human" + ], + "resource": "Manaless", + "range_type": [ + "Melee" + ], + "regions": [ + "Noxus" + ], + "release_date": "2009-09-19" + }, + { + "championName": "Kayle", + "gender": "Female", + "positions": [ + "Top" + ], + "species": [ + "Human", + "Magically Altered", + "Aspect" + ], + "resource": "Mana", + "range_type": [ + "Melee", + "Ranged" + ], + "regions": [ + "Demacia", + "Targon" + ], + "release_date": "2009-02-21" + }, + { + "championName": "Kayn", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Darkin", + "Human", + "Magically Altered" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Ionia", + "Runeterra", + "Noxus", + "Shurima" + ], + "release_date": "2017-07-12" + }, + { + "championName": "Kennen", + "gender": "Male", + "positions": [ + "Top", + "Middle" + ], + "species": [ + "Yordle" + ], + "resource": "Energy", + "range_type": [ + "Ranged" + ], + "regions": [ + "Ionia" + ], + "release_date": "2010-04-08" + }, + { + "championName": "Kha'Zix", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Void-Being" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Void" + ], + "release_date": "2012-09-27" + }, + { + "championName": "Kindred", + "gender": "Other", + "positions": [ + "Jungle" + ], + "species": [ + "Spirit", + "God" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Runeterra" + ], + "release_date": "2015-10-14" + }, + { + "championName": "Kled", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Yordle" + ], + "resource": "Courage", + "range_type": [ + "Melee" + ], + "regions": [ + "Noxus" + ], + "release_date": "2016-08-10" + }, + { + "championName": "Kog'Maw", + "gender": "Male", + "positions": [ + "Bottom" + ], + "species": [ + "Void-Being" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Void" + ], + "release_date": "2010-06-24" + }, + { + "championName": "LeBlanc", + "gender": "Female", + "positions": [ + "Middle" + ], + "species": [ + "Human", + "Magically Altered" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Noxus" + ], + "release_date": "2010-11-02" + }, + { + "championName": "Lee Sin", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Human", + "Spiritualist" + ], + "resource": "Energy", + "range_type": [ + "Melee" + ], + "regions": [ + "Ionia" + ], + "release_date": "2011-04-01" + }, + { + "championName": "Leona", + "gender": "Female", + "positions": [ + "Support" + ], + "species": [ + "Human", + "Aspect" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Targon" + ], + "release_date": "2011-07-13" + }, + { + "championName": "Lillia", + "gender": "Female", + "positions": [ + "Jungle" + ], + "species": [ + "Spirit" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Ionia" + ], + "release_date": "2020-07-22" + }, + { + "championName": "Lissandra", + "gender": "Female", + "positions": [ + "Middle" + ], + "species": [ + "Human", + "Iceborn" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Freljord" + ], + "release_date": "2013-04-30" + }, + { + "championName": "Lucian", + "gender": "Male", + "positions": [ + "Bottom" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Demacia", + "Shadow Isles" + ], + "release_date": "2013-08-22" + }, + { + "championName": "Lulu", + "gender": "Female", + "positions": [ + "Support" + ], + "species": [ + "Yordle" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Bandle City" + ], + "release_date": "2012-03-20" + }, + { + "championName": "Lux", + "gender": "Female", + "positions": [ + "Middle", + "Support" + ], + "species": [ + "Human", + "Magicborn" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Demacia" + ], + "release_date": "2010-10-19" + }, + { + "championName": "Malphite", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Golem" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Ixtal", + "Shurima" + ], + "release_date": "2009-09-02" + }, + { + "championName": "Malzahar", + "gender": "Male", + "positions": [ + "Middle" + ], + "species": [ + "Human", + "Void-Being" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Shurima", + "Void" + ], + "release_date": "2010-06-01" + }, + { + "championName": "Maokai", + "gender": "Male", + "positions": [ + "Support" + ], + "species": [ + "Spirit" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Shadow Isles" + ], + "release_date": "2011-02-16" + }, + { + "championName": "Master Yi", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Human", + "Spiritualist" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Ionia" + ], + "release_date": "2009-02-21" + }, + { + "championName": "Mel", + "gender": "Female", + "positions": [ + "Middle", + "Support" + ], + "species": [ + "Human", + "Magically Altered" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Noxus", + "Piltover" + ], + "release_date": "2025-01-23" + }, + { + "championName": "Milio", + "gender": "Male", + "positions": [ + "Support" + ], + "species": [ + "Human", + "Magicborn" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Ixtal" + ], + "release_date": "2023-03-23" + }, + { + "championName": "Miss Fortune", + "gender": "Female", + "positions": [ + "Bottom" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Bilgewater" + ], + "release_date": "2010-09-08" + }, + { + "championName": "Mordekaiser", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Revenant" + ], + "resource": "Shield", + "range_type": [ + "Melee" + ], + "regions": [ + "Noxus", + "Shadow Isles" + ], + "release_date": "2010-02-24" + }, + { + "championName": "Morgana", + "gender": "Female", + "positions": [ + "Middle", + "Support" + ], + "species": [ + "Human", + "Magically Altered", + "Aspect" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Demacia", + "Targon" + ], + "release_date": "2009-02-21" + }, + { + "championName": "Naafiri", + "gender": "Female", + "positions": [ + "Jungle", + "Middle" + ], + "species": [ + "Dog", + "Darkin" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Shurima" + ], + "release_date": "2023-07-19" + }, + { + "championName": "Nami", + "gender": "Female", + "positions": [ + "Support" + ], + "species": [ + "Vastayan" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Runeterra", + "Bilgewater" + ], + "release_date": "2012-12-07" + }, + { + "championName": "Nasus", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "God-Warrior" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Shurima" + ], + "release_date": "2009-10-01" + }, + { + "championName": "Nautilus", + "gender": "Male", + "positions": [ + "Support" + ], + "species": [ + "Revenant" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Bilgewater" + ], + "release_date": "2012-02-14" + }, + { + "championName": "Neeko", + "gender": "Female", + "positions": [ + "Support" + ], + "species": [ + "Vastayan" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Ixtal" + ], + "release_date": "2018-12-05" + }, + { + "championName": "Nidalee", + "gender": "Female", + "positions": [ + "Jungle" + ], + "species": [ + "Human", + "Spiritualist" + ], + "resource": "Mana", + "range_type": [ + "Melee", + "Ranged" + ], + "regions": [ + "Ixtal" + ], + "release_date": "2009-12-17" + }, + { + "championName": "Nilah", + "gender": "Female", + "positions": [ + "Bottom" + ], + "species": [ + "Human", + "Magically Altered" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Bilgewater" + ], + "release_date": "2022-07-13" + }, + { + "championName": "Nocturne", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Demon", + "Spirit" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Runeterra" + ], + "release_date": "2011-03-15" + }, + { + "championName": "Nunu & Willump", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Human", + "Yeti" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Freljord" + ], + "release_date": "2009-02-21" + }, + { + "championName": "Olaf", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Human", + "Iceborn" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Freljord" + ], + "release_date": "2010-06-09" + }, + { + "championName": "Orianna", + "gender": "Female", + "positions": [ + "Middle" + ], + "species": [ + "Golem" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Piltover" + ], + "release_date": "2011-06-01" + }, + { + "championName": "Ornn", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Spirit", + "God" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Freljord" + ], + "release_date": "2017-08-23" + }, + { + "championName": "Pantheon", + "gender": "Male", + "positions": [ + "Top", + "Jungle", + "Support" + ], + "species": [ + "Human", + "Aspect" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Targon" + ], + "release_date": "2010-02-02" + }, + { + "championName": "Poppy", + "gender": "Female", + "positions": [ + "Top", + "Support" + ], + "species": [ + "Yordle" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Demacia" + ], + "release_date": "2010-01-13" + }, + { + "championName": "Pyke", + "gender": "Male", + "positions": [ + "Support" + ], + "species": [ + "Revenant" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Bilgewater" + ], + "release_date": "2018-05-31" + }, + { + "championName": "Qiyana", + "gender": "Female", + "positions": [ + "Jungle", + "Middle" + ], + "species": [ + "Human", + "Magicborn" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Ixtal" + ], + "release_date": "2019-06-28" + }, + { + "championName": "Quinn", + "gender": "Female", + "positions": [ + "Top" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Demacia" + ], + "release_date": "2013-03-01" + }, + { + "championName": "Rakan", + "gender": "Male", + "positions": [ + "Support" + ], + "species": [ + "Vastayan" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Ionia" + ], + "release_date": "2017-04-19" + }, + { + "championName": "Rammus", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Unknown" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Shurima" + ], + "release_date": "2009-07-10" + }, + { + "championName": "Rek'Sai", + "gender": "Female", + "positions": [ + "Jungle" + ], + "species": [ + "Void-Being" + ], + "resource": "Rage", + "range_type": [ + "Melee" + ], + "regions": [ + "Shurima", + "Void" + ], + "release_date": "2014-12-11" + }, + { + "championName": "Rell", + "gender": "Female", + "positions": [ + "Support" + ], + "species": [ + "Human", + "Magicborn", + "Magically Altered" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Noxus" + ], + "release_date": "2020-12-10" + }, + { + "championName": "Renata Glasc", + "gender": "Female", + "positions": [ + "Support" + ], + "species": [ + "Human", + "Chemically Altered" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Zaun" + ], + "release_date": "2022-02-17" + }, + { + "championName": "Renekton", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "God-Warrior" + ], + "resource": "Fury", + "range_type": [ + "Melee" + ], + "regions": [ + "Shurima" + ], + "release_date": "2011-01-18" + }, + { + "championName": "Rengar", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Vastayan" + ], + "resource": "Ferocity", + "range_type": [ + "Melee" + ], + "regions": [ + "Ixtal", + "Shurima" + ], + "release_date": "2012-08-21" + }, + { + "championName": "Riven", + "gender": "Female", + "positions": [ + "Top" + ], + "species": [ + "Human" + ], + "resource": "Manaless", + "range_type": [ + "Melee" + ], + "regions": [ + "Ionia", + "Noxus" + ], + "release_date": "2011-09-14" + }, + { + "championName": "Rumble", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Yordle" + ], + "resource": "Heat", + "range_type": [ + "Melee" + ], + "regions": [ + "Bandle City" + ], + "release_date": "2011-04-26" + }, + { + "championName": "Ryze", + "gender": "Male", + "positions": [ + "Middle" + ], + "species": [ + "Human", + "Magically Altered" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Runeterra" + ], + "release_date": "2009-02-21" + }, + { + "championName": "Samira", + "gender": "Female", + "positions": [ + "Bottom" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Shurima", + "Noxus" + ], + "release_date": "2020-09-21" + }, + { + "championName": "Sejuani", + "gender": "Female", + "positions": [ + "Jungle" + ], + "species": [ + "Human", + "Iceborn" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Freljord" + ], + "release_date": "2012-01-17" + }, + { + "championName": "Senna", + "gender": "Female", + "positions": [ + "Support" + ], + "species": [ + "Human", + "Undead" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Shadow Isles" + ], + "release_date": "2019-11-10" + }, + { + "championName": "Seraphine", + "gender": "Female", + "positions": [ + "Support" + ], + "species": [ + "Human", + "Magicborn" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Piltover", + "Zaun" + ], + "release_date": "2020-10-29" + }, + { + "championName": "Sett", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Human", + "Vastayan" + ], + "resource": "Grit", + "range_type": [ + "Melee" + ], + "regions": [ + "Ionia" + ], + "release_date": "2020-01-14" + }, + { + "championName": "Shaco", + "gender": "Male", + "positions": [ + "Jungle", + "Support" + ], + "species": [ + "Spirit" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Runeterra" + ], + "release_date": "2009-10-10" + }, + { + "championName": "Shen", + "gender": "Male", + "positions": [ + "Top", + "Support" + ], + "species": [ + "Human", + "Spiritualist" + ], + "resource": "Energy", + "range_type": [ + "Melee" + ], + "regions": [ + "Ionia" + ], + "release_date": "2010-03-24" + }, + { + "championName": "Shyvana", + "gender": "Female", + "positions": [ + "Jungle" + ], + "species": [ + "Dragon", + "Magically Altered" + ], + "resource": "Fury", + "range_type": [ + "Melee" + ], + "regions": [ + "Demacia" + ], + "release_date": "2011-11-01" + }, + { + "championName": "Singed", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Human", + "Chemically Altered" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Zaun", + "Piltover" + ], + "release_date": "2009-04-18" + }, + { + "championName": "Sion", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Revenant" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Noxus" + ], + "release_date": "2009-02-21" + }, + { + "championName": "Sivir", + "gender": "Female", + "positions": [ + "Bottom" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Shurima" + ], + "release_date": "2009-02-21" + }, + { + "championName": "Skarner", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Brackern" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Ixtal" + ], + "release_date": "2011-08-09" + }, + { + "championName": "Smolder", + "gender": "Male", + "positions": [ + "Bottom" + ], + "species": [ + "Dragon" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Camavor", + "Noxus" + ], + "release_date": "2024-01-31" + }, + { + "championName": "Sona", + "gender": "Female", + "positions": [ + "Support" + ], + "species": [ + "Human", + "Magicborn" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Demacia", + "Ionia" + ], + "release_date": "2010-09-21" + }, + { + "championName": "Soraka", + "gender": "Female", + "positions": [ + "Support" + ], + "species": [ + "Celestial" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Targon", + "Ionia" + ], + "release_date": "2009-02-21" + }, + { + "championName": "Swain", + "gender": "Male", + "positions": [ + "Support" + ], + "species": [ + "Human", + "Magically Altered" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Noxus" + ], + "release_date": "2010-10-05" + }, + { + "championName": "Sylas", + "gender": "Male", + "positions": [ + "Jungle", + "Middle" + ], + "species": [ + "Human", + "Magicborn" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Demacia", + "Freljord" + ], + "release_date": "2019-01-25" + }, + { + "championName": "Syndra", + "gender": "Female", + "positions": [ + "Middle" + ], + "species": [ + "Human", + "Magicborn" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Ionia" + ], + "release_date": "2012-09-13" + }, + { + "championName": "Tahm Kench", + "gender": "Male", + "positions": [ + "Top", + "Support" + ], + "species": [ + "Demon", + "Spirit" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Bilgewater", + "Runeterra" + ], + "release_date": "2015-07-09" + }, + { + "championName": "Taliyah", + "gender": "Female", + "positions": [ + "Middle" + ], + "species": [ + "Human", + "Magicborn" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Shurima" + ], + "release_date": "2016-05-18" + }, + { + "championName": "Talon", + "gender": "Male", + "positions": [ + "Jungle", + "Middle" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Noxus" + ], + "release_date": "2011-08-24" + }, + { + "championName": "Taric", + "gender": "Male", + "positions": [ + "Support" + ], + "species": [ + "Human", + "Aspect" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Targon", + "Demacia" + ], + "release_date": "2009-08-19" + }, + { + "championName": "Teemo", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Yordle" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Bandle City" + ], + "release_date": "2009-02-21" + }, + { + "championName": "Thresh", + "gender": "Male", + "positions": [ + "Support" + ], + "species": [ + "Undead" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Shadow Isles" + ], + "release_date": "2013-01-23" + }, + { + "championName": "Tristana", + "gender": "Female", + "positions": [ + "Bottom" + ], + "species": [ + "Yordle" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Bandle City" + ], + "release_date": "2009-02-21" + }, + { + "championName": "Trundle", + "gender": "Male", + "positions": [ + "Top", + "Jungle" + ], + "species": [ + "Troll", + "Iceborn" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Freljord" + ], + "release_date": "2010-12-01" + }, + { + "championName": "Tryndamere", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Human", + "Magically Altered" + ], + "resource": "Fury", + "range_type": [ + "Melee" + ], + "regions": [ + "Freljord" + ], + "release_date": "2009-05-01" + }, + { + "championName": "Twisted Fate", + "gender": "Male", + "positions": [ + "Middle" + ], + "species": [ + "Human", + "Magicborn" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Bilgewater" + ], + "release_date": "2009-02-21" + }, + { + "championName": "Twitch", + "gender": "Male", + "positions": [ + "Bottom" + ], + "species": [ + "Rat", + "Chemically Altered" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Zaun" + ], + "release_date": "2009-05-01" + }, + { + "championName": "Udyr", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Human", + "Spiritualist" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Freljord", + "Ionia" + ], + "release_date": "2009-12-02" + }, + { + "championName": "Urgot", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Human", + "Chemically Altered", + "Cyborg" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Noxus", + "Zaun" + ], + "release_date": "2010-08-24" + }, + { + "championName": "Varus", + "gender": "Male", + "positions": [ + "Bottom" + ], + "species": [ + "Darkin", + "Human" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Ionia", + "Runeterra", + "Shurima" + ], + "release_date": "2012-05-08" + }, + { + "championName": "Vayne", + "gender": "Female", + "positions": [ + "Top", + "Bottom" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Demacia" + ], + "release_date": "2011-05-10" + }, + { + "championName": "Veigar", + "gender": "Male", + "positions": [ + "Middle" + ], + "species": [ + "Yordle" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Runeterra", + "Bandle City", + "Shadow Isles" + ], + "release_date": "2009-07-24" + }, + { + "championName": "Vel'Koz", + "gender": "Male", + "positions": [ + "Middle", + "Support" + ], + "species": [ + "Void-Being" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Void" + ], + "release_date": "2014-02-27" + }, + { + "championName": "Vex", + "gender": "Female", + "positions": [ + "Middle" + ], + "species": [ + "Yordle" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Shadow Isles", + "Bandle City" + ], + "release_date": "2021-09-23" + }, + { + "championName": "Vi", + "gender": "Female", + "positions": [ + "Jungle" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Piltover", + "Zaun" + ], + "release_date": "2012-12-19" + }, + { + "championName": "Viego", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Undead" + ], + "resource": "Manaless", + "range_type": [ + "Melee" + ], + "regions": [ + "Shadow Isles", + "Camavor" + ], + "release_date": "2021-01-21" + }, + { + "championName": "Viktor", + "gender": "Male", + "positions": [ + "Middle" + ], + "species": [ + "Human", + "Cyborg" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Piltover", + "Zaun" + ], + "release_date": "2011-12-29" + }, + { + "championName": "Vladimir", + "gender": "Male", + "positions": [ + "Top", + "Middle" + ], + "species": [ + "Human", + "Magically Altered" + ], + "resource": "Bloodthirst", + "range_type": [ + "Ranged" + ], + "regions": [ + "Noxus", + "Shadow Isles", + "Camavor" + ], + "release_date": "2010-07-27" + }, + { + "championName": "Volibear", + "gender": "Male", + "positions": [ + "Top", + "Jungle" + ], + "species": [ + "Spirit", + "God" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Freljord" + ], + "release_date": "2011-11-29" + }, + { + "championName": "Warwick", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Human", + "Chemically Altered", + "Cyborg" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Zaun" + ], + "release_date": "2009-02-21" + }, + { + "championName": "Wukong", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Vastayan" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Ionia" + ], + "release_date": "2011-07-26" + }, + { + "championName": "Xayah", + "gender": "Female", + "positions": [ + "Bottom" + ], + "species": [ + "Vastayan" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Ionia" + ], + "release_date": "2017-04-19" + }, + { + "championName": "Xerath", + "gender": "Male", + "positions": [ + "Middle", + "Support" + ], + "species": [ + "Baccai" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Shurima" + ], + "release_date": "2011-10-05" + }, + { + "championName": "Xin Zhao", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Human" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Demacia", + "Ionia", + "Noxus" + ], + "release_date": "2010-07-13" + }, + { + "championName": "Yasuo", + "gender": "Male", + "positions": [ + "Top", + "Middle" + ], + "species": [ + "Human", + "Magicborn" + ], + "resource": "Flow", + "range_type": [ + "Melee" + ], + "regions": [ + "Ionia" + ], + "release_date": "2013-12-13" + }, + { + "championName": "Yone", + "gender": "Male", + "positions": [ + "Top", + "Middle" + ], + "species": [ + "Human", + "Magically Altered" + ], + "resource": "Manaless", + "range_type": [ + "Melee" + ], + "regions": [ + "Ionia" + ], + "release_date": "2020-08-06" + }, + { + "championName": "Yorick", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Human", + "Magically Altered" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Shadow Isles" + ], + "release_date": "2011-06-22" + }, + { + "championName": "Yunara", + "gender": "Female", + "positions": [ + "Bottom" + ], + "species": [ + "Human", + "Spiritualist" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Ionia" + ], + "release_date": "2025-07-16" + }, + { + "championName": "Yuumi", + "gender": "Female", + "positions": [ + "Support" + ], + "species": [ + "Cat", + "Magically Altered" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Bandle City" + ], + "release_date": "2019-05-14" + }, + { + "championName": "Zaahen", + "gender": "Male", + "positions": [ + "Top" + ], + "species": [ + "Darkin", + "Human" + ], + "resource": "Mana", + "range_type": [ + "Melee" + ], + "regions": [ + "Shurima", + "Ionia" + ], + "release_date": "2025-11-19" + }, + { + "championName": "Zac", + "gender": "Male", + "positions": [ + "Jungle" + ], + "species": [ + "Golem" + ], + "resource": "Health costs", + "range_type": [ + "Melee" + ], + "regions": [ + "Zaun" + ], + "release_date": "2013-03-29" + }, + { + "championName": "Zed", + "gender": "Male", + "positions": [ + "Jungle", + "Middle" + ], + "species": [ + "Human", + "Magically Altered" + ], + "resource": "Energy", + "range_type": [ + "Melee" + ], + "regions": [ + "Ionia" + ], + "release_date": "2012-11-13" + }, + { + "championName": "Zeri", + "gender": "Female", + "positions": [ + "Bottom" + ], + "species": [ + "Human", + "Magicborn" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Zaun" + ], + "release_date": "2022-01-20" + }, + { + "championName": "Ziggs", + "gender": "Male", + "positions": [ + "Middle", + "Bottom" + ], + "species": [ + "Yordle" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Zaun" + ], + "release_date": "2012-02-01" + }, + { + "championName": "Zilean", + "gender": "Male", + "positions": [ + "Support" + ], + "species": [ + "Human", + "Magicborn" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Runeterra", + "Icathia", + "Shurima" + ], + "release_date": "2009-04-18" + }, + { + "championName": "Zoe", + "gender": "Female", + "positions": [ + "Middle" + ], + "species": [ + "Human", + "Aspect" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Targon" + ], + "release_date": "2017-11-21" + }, + { + "championName": "Zyra", + "gender": "Female", + "positions": [ + "Support" + ], + "species": [ + "Human", + "Plant" + ], + "resource": "Mana", + "range_type": [ + "Ranged" + ], + "regions": [ + "Ixtal" + ], + "release_date": "2012-07-24" + } +] diff --git a/internal/modules/loldle/flavor.go b/internal/modules/loldle/flavor.go new file mode 100644 index 0000000..f3d9609 --- /dev/null +++ b/internal/modules/loldle/flavor.go @@ -0,0 +1,56 @@ +package loldle + +import "fmt" + +// attemptFlavor returns a one-word reaction to a winning attempt. Branches +// match the JS source exactly (1 → "First try!", 2 → "Sharp!", final → +// "Phew — last one!", final-2 → "Close call!", else "Nice."). +func attemptFlavor(attempt, max int) string { + if attempt <= 1 { + return "First try!" + } + if attempt == 2 { + return "Sharp!" + } + if attempt >= max { + return "Phew — last one!" + } + if attempt >= max-2 { + return "Close call!" + } + return "Nice." +} + +// formatDuration renders an elapsed-ms span as a compact human string. +// +// < 60s → "42s" +// < 60min → "3m 14s" (or "3m" when seconds == 0) +// otherwise → "1h 12m" (or "1h" when remaining minutes == 0) +// +// JS parity. Negative inputs clamp to 0 (matches JS's Math.max(0, …)). +func formatDuration(ms int64) string { + total := ms / 1000 + if ms%1000 >= 500 { + total++ // round-half-up to match JS Math.round + } + if total < 0 { + total = 0 + } + if total < 60 { + return fmt.Sprintf("%ds", total) + } + minutes := total / 60 + seconds := total % 60 + if minutes < 60 { + if seconds == 0 { + return fmt.Sprintf("%dm", minutes) + } + return fmt.Sprintf("%dm %ds", minutes, seconds) + } + hours := minutes / 60 + remMin := minutes % 60 + if remMin == 0 { + return fmt.Sprintf("%dh", hours) + } + return fmt.Sprintf("%dh %dm", hours, remMin) +} diff --git a/internal/modules/loldle/flavor_test.go b/internal/modules/loldle/flavor_test.go new file mode 100644 index 0000000..f38e59f --- /dev/null +++ b/internal/modules/loldle/flavor_test.go @@ -0,0 +1,47 @@ +package loldle + +import "testing" + +func TestAttemptFlavor(t *testing.T) { + const max = 8 + cases := map[int]string{ + 1: "First try!", + 2: "Sharp!", + 3: "Nice.", + 4: "Nice.", + 5: "Nice.", + 6: "Close call!", + 7: "Close call!", + 8: "Phew — last one!", + 9: "Phew — last one!", // attempt > max — defensive; matches JS >= + } + for attempt, want := range cases { + if got := attemptFlavor(attempt, max); got != want { + t.Errorf("attemptFlavor(%d, %d) = %q, want %q", attempt, max, got, want) + } + } +} + +func TestFormatDuration(t *testing.T) { + cases := []struct { + ms int64 + want string + }{ + {0, "0s"}, + {500, "1s"}, // round-half-up to JS Math.round + {499, "0s"}, + {42_000, "42s"}, + {60_000, "1m"}, + {75_500, "1m 16s"}, // 75.5s → 76s → 1m 16s + {90_000, "1m 30s"}, + {60 * 60 * 1000, "1h"}, + {60 * 60 * 1000 * 2, "2h"}, + {(60*60 + 12*60) * 1000, "1h 12m"}, + {-500, "0s"}, + } + for _, c := range cases { + if got := formatDuration(c.ms); got != c.want { + t.Errorf("formatDuration(%d) = %q, want %q", c.ms, got, c.want) + } + } +} diff --git a/internal/modules/loldle/handlers.go b/internal/modules/loldle/handlers.go new file mode 100644 index 0000000..478a56b --- /dev/null +++ b/internal/modules/loldle/handlers.go @@ -0,0 +1,334 @@ +package loldle + +import ( + "context" + "fmt" + "html" + "math" + "math/rand" + "strconv" + "strings" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot-go/internal/keylock" + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +const newRoundHint = "🆕 Send /loldle or /loldle <champion> to start a new round." + +// state captures everything a loldle handler needs at runtime. Built once +// per Factory call and shared across the four command closures. +type state struct { + kv storage.KVStore + champions []Champion + locks keylock.Map // serialises Get→mutate→Put per subject +} + +// subjectFor mirrors JS getSubject: group/supergroup → chat ID (shared game), +// otherwise → user ID. Channels and unknown types fall through to From.ID. +// Returns "" when no usable id is present. +func subjectFor(msg *models.Message) string { + if msg == nil { + return "" + } + switch msg.Chat.Type { + case models.ChatTypeGroup, models.ChatTypeSupergroup: + return strconv.FormatInt(msg.Chat.ID, 10) + default: + if msg.From != nil { + return strconv.FormatInt(msg.From.ID, 10) + } + } + return "" +} + +// argAfterCommand returns everything after the first space in text, trimmed. +// Identical contract to wordle/util — duplicated to keep package-local; +// promoting to a shared helper buys very little until a 4th module needs it. +func argAfterCommand(text string) string { + if text == "" { + return "" + } + idx := strings.IndexByte(text, ' ') + if idx < 0 { + return "" + } + return strings.TrimSpace(text[idx+1:]) +} + +// pickRandomChampion uses math/rand's mutex-protected globals so concurrent +// /loldle handlers don't race on a shared *rand.Rand. +func (s *state) pickRandomChampion() *Champion { + return &s.champions[rand.Intn(len(s.champions))] +} + +func (s *state) findByName(name string) *Champion { + return findByExactName(s.champions, name) +} + +// rehydrateGuesses recomputes board rows from the stored championNames. +// Champions removed from champions.json since the round started are skipped +// (returns the surviving prefix), matching JS. +func (s *state) rehydrateGuesses(g *gameState) []boardEntry { + target := s.findByName(g.Target) + if target == nil { + return nil + } + out := make([]boardEntry, 0, len(g.Guesses)) + for _, name := range g.Guesses { + guess := s.findByName(name) + if guess == nil { + continue + } + out = append(out, boardEntry{Champion: name, Results: CompareChampions(guess, target)}) + } + return out +} + +// startFreshGame writes a new round with no startedAt clock yet. +func (s *state) startFreshGame(ctx context.Context, subject string) (*gameState, error) { + target := s.pickRandomChampion() + g := &gameState{ + Target: target.ChampionName, + Guesses: []string{}, + StartedAt: nil, + } + if err := saveGame(ctx, s.kv, subject, g); err != nil { + return nil, err + } + return g, nil +} + +// getOrInitGame loads the active round; if absent (or the saved round has +// already exhausted its budget — defensive), starts a fresh one. +func (s *state) getOrInitGame(ctx context.Context, subject string, maxGuesses int) (*gameState, error) { + existing, err := loadGame(ctx, s.kv, subject) + if err != nil { + return nil, err + } + if existing != nil && len(existing.Guesses) < maxGuesses { + return existing, nil + } + return s.startFreshGame(ctx, subject) +} + +// trySendSticker sends a sticker, swallowing errors. A bad/expired file_id +// must never block the text reply that carries the round outcome. +func trySendSticker(ctx context.Context, b *bot.Bot, chatID int64, pool []string) { + id := pickSticker(pool) + if id == "" { + return + } + _, _ = b.SendSticker(ctx, &bot.SendStickerParams{ + ChatID: chatID, + Sticker: &models.InputFileString{Data: id}, + }) +} + +// reply sends a plain-text response. +func reply(ctx context.Context, b *bot.Bot, chatID int64, text string) error { + _, err := b.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text}) + return err +} + +// replyHTML sends a Telegram HTML-formatted response. +func replyHTML(ctx context.Context, b *bot.Bot, chatID int64, text string) error { + _, err := b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: chatID, + Text: text, + ParseMode: models.ParseModeHTML, + }) + return err +} + +func nowMillis() int64 { return time.Now().UTC().UnixMilli() } + +// handleLoldle is /loldle [champion] — show board if no arg, else submit guess. +func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + subject := subjectFor(msg) + if subject == "" { + return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") + } + defer s.locks.Acquire(subject)() + arg := argAfterCommand(msg.Text) + + maxGuesses, err := getMaxGuesses(ctx, s.kv, subject) + if err != nil { + return err + } + game, err := s.getOrInitGame(ctx, subject, maxGuesses) + if err != nil { + return err + } + + if arg == "" { + header := fmt.Sprintf("Guess %d/%d. Use /loldle <champion>.", + len(game.Guesses), maxGuesses) + board := renderBoard(s.rehydrateGuesses(game)) + return replyHTML(ctx, b, msg.Chat.ID, header+"\n\n"+board) + } + + guess := findChampion(s.champions, arg) + if guess == nil { + return reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Champion not found: %q.", arg)) + } + + for _, prior := range game.Guesses { + if prior == guess.ChampionName { + return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( + "🔁 %s was already guessed this round — try another champion.", + html.EscapeString(guess.ChampionName))) + } + } + + target := s.findByName(game.Target) + if target == nil { + // champions.json was refreshed and the target is gone. Drop the + // round so the next /loldle starts cleanly. + if err := clearGame(ctx, s.kv, subject); err != nil { + return err + } + return replyHTML(ctx, b, msg.Chat.ID, + "Champion data was updated since this round started. "+newRoundHint) + } + + results := CompareChampions(guess, target) + if game.StartedAt == nil { + now := nowMillis() + game.StartedAt = &now + } + game.Guesses = append(game.Guesses, guess.ChampionName) + won := guess.ChampionName == target.ChampionName + + rendered := renderGuess(guess.ChampionName, results) + champ := html.EscapeString(target.ChampionName) + elapsed := formatDuration(nowMillis() - *game.StartedAt) + + switch { + case won: + st, err := recordResult(ctx, s.kv, subject, true) + if err != nil { + return err + } + if err := clearGame(ctx, s.kv, subject); err != nil { + return err + } + trySendSticker(ctx, b, msg.Chat.ID, winStickers) + flavor := attemptFlavor(len(game.Guesses), maxGuesses) + return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( + "%s\n\n🎉 %s %s\n⏱ %s · 🔥 Streak: %d (%d/%d)\n%s", + rendered, flavor, champ, elapsed, st.Streak, len(game.Guesses), maxGuesses, newRoundHint)) + + case len(game.Guesses) >= maxGuesses: + if _, err := recordResult(ctx, s.kv, subject, false); err != nil { + return err + } + if err := clearGame(ctx, s.kv, subject); err != nil { + return err + } + trySendSticker(ctx, b, msg.Chat.ID, loseStickers) + return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( + "%s\n\n❌ Out of guesses. Answer was %s.\n%s", rendered, champ, newRoundHint)) + + default: + if err := saveGame(ctx, s.kv, subject, game); err != nil { + return err + } + return replyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf( + "%s\n\nGuess %d/%d.", rendered, len(game.Guesses), maxGuesses)) + } +} + +// handleGiveup is /loldle_giveup — reveal answer + clear round. +func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + subject := subjectFor(msg) + if subject == "" { + return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") + } + defer s.locks.Acquire(subject)() + + existing, err := loadGame(ctx, s.kv, subject) + if err != nil { + return err + } + if existing == nil { + return replyHTML(ctx, b, msg.Chat.ID, "No active round. "+newRoundHint) + } + if _, err := recordResult(ctx, s.kv, subject, false); err != nil { + return err + } + target := s.findByName(existing.Target) + if err := clearGame(ctx, s.kv, subject); err != nil { + return err + } + trySendSticker(ctx, b, msg.Chat.ID, giveupStickers) + answer := existing.Target + if target != nil { + answer = target.ChampionName + } + return replyHTML(ctx, b, msg.Chat.ID, + fmt.Sprintf("🏳️ Answer was %s.\n%s", html.EscapeString(answer), newRoundHint)) +} + +// handleStats is /loldle_stats — lifetime score. +func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + subject := subjectFor(msg) + if subject == "" { + return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") + } + st, err := loadStats(ctx, s.kv, subject) + if err != nil { + return err + } + winRate := 0 + if st.Played > 0 { + // math.Round matches JS Math.round for positive inputs (round half + // away from zero); int(...) truncation would render 2/3 as 66% where + // the JS source shows 67%. + winRate = int(math.Round(float64(st.Wins) / float64(st.Played) * 100)) + } + scope := "group" + if msg.Chat.Type == models.ChatTypePrivate { + scope = "your" + } + return reply(ctx, b, msg.Chat.ID, fmt.Sprintf( + "📊 Loldle %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d", + scope, st.Played, st.Wins, winRate, st.Streak, st.BestStreak)) +} + +// handleSetMax is /loldle_setmax — private command, sets the per-subject +// MaxGuesses override (1..MaxGuessesCap). Takes effect on the next round. +func (s *state) handleSetMax(ctx context.Context, b *bot.Bot, update *models.Update) error { + msg := update.Message + if msg == nil { + return nil + } + subject := subjectFor(msg) + if subject == "" { + return reply(ctx, b, msg.Chat.ID, "Cannot identify chat.") + } + arg := argAfterCommand(msg.Text) + n, err := strconv.Atoi(arg) + if err != nil || n < 1 || n > MaxGuessesCap { + return reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Usage: /loldle_setmax <1-%d>", MaxGuessesCap)) + } + if err := setMaxGuesses(ctx, s.kv, subject, n); err != nil { + return err + } + return reply(ctx, b, msg.Chat.ID, fmt.Sprintf("✅ Loldle max guesses set to %d (applies to the next round).", n)) +} diff --git a/internal/modules/loldle/loldle.go b/internal/modules/loldle/loldle.go new file mode 100644 index 0000000..ca05cac --- /dev/null +++ b/internal/modules/loldle/loldle.go @@ -0,0 +1,42 @@ +package loldle + +import ( + "github.com/tiennm99/miti99bot-go/internal/modules" +) + +// New is the loldle module Factory. Loads champions.json once at construction +// and shares the parsed slice + per-subject lock map across all handlers. +func New(deps modules.Deps) modules.Module { + s := &state{ + kv: deps.KV, + champions: loadChampions(), + } + return modules.Module{ + Commands: []modules.Command{ + { + Name: "loldle", + Visibility: modules.VisibilityPublic, + Description: "Classic loldle — guess the current champion", + Handler: s.handleLoldle, + }, + { + Name: "loldle_giveup", + Visibility: modules.VisibilityPublic, + Description: "Reveal the current loldle answer (auto-starts a fresh round)", + Handler: s.handleGiveup, + }, + { + Name: "loldle_stats", + Visibility: modules.VisibilityPublic, + Description: "Show your loldle stats (wins, streak)", + Handler: s.handleStats, + }, + { + Name: "loldle_setmax", + Visibility: modules.VisibilityPrivate, + Description: "Override max guesses per round (1-10) for this chat", + Handler: s.handleSetMax, + }, + }, + } +} diff --git a/internal/modules/loldle/lookup.go b/internal/modules/loldle/lookup.go new file mode 100644 index 0000000..488adb9 --- /dev/null +++ b/internal/modules/loldle/lookup.go @@ -0,0 +1,48 @@ +package loldle + +import "strings" + +// findChampion resolves user input to a Champion. JS-faithful semantics: +// +// 1. Empty / non-alphanumeric input → no match (nil). +// 2. Exact normalised name → that champion. +// 3. Otherwise: unique normalised-prefix match → that champion. Ambiguous or +// no prefix match → nil. Returning nil for ambiguous prefixes prevents +// "Ka" silently routing to whichever Ka- champion happens to be first +// in champions.json. +func findChampion(champions []Champion, input string) *Champion { + q := normalize(input) + if q == "" { + return nil + } + + for i := range champions { + if normalize(champions[i].ChampionName) == q { + return &champions[i] + } + } + + var prefixHit *Champion + for i := range champions { + if strings.HasPrefix(normalize(champions[i].ChampionName), q) { + if prefixHit != nil { + // Second prefix match → ambiguous; bail. + return nil + } + prefixHit = &champions[i] + } + } + return prefixHit +} + +// findByExactName looks up a champion by literal championName (no normalization). +// Used by handlers when restoring a stored game's target — the JS `find` over +// raw championName. +func findByExactName(champions []Champion, name string) *Champion { + for i := range champions { + if champions[i].ChampionName == name { + return &champions[i] + } + } + return nil +} diff --git a/internal/modules/loldle/lookup_test.go b/internal/modules/loldle/lookup_test.go new file mode 100644 index 0000000..9b84add --- /dev/null +++ b/internal/modules/loldle/lookup_test.go @@ -0,0 +1,90 @@ +package loldle + +import "testing" + +func TestNormalize(t *testing.T) { + cases := map[string]string{ + "": "", + "Aatrox": "aatrox", + "Kai'Sa": "kaisa", + "KAI SA": "kaisa", + "Twisted Fate": "twistedfate", + "!@#": "", + "42 Vi": "42vi", + } + for in, want := range cases { + if got := normalize(in); got != want { + t.Errorf("normalize(%q) = %q, want %q", in, got, want) + } + } +} + +func TestFindChampion_ExactNormalizedMatch(t *testing.T) { + cs := []Champion{ + {ChampionName: "Aatrox"}, + {ChampionName: "Ahri"}, + {ChampionName: "Akali"}, + } + for _, in := range []string{"aatrox", "AATROX", "Aatrox", "A-A-T-R-O-X"} { + got := findChampion(cs, in) + if got == nil || got.ChampionName != "Aatrox" { + t.Errorf("input %q → %v, want Aatrox", in, got) + } + } +} + +func TestFindChampion_UniquePrefix(t *testing.T) { + cs := []Champion{ + {ChampionName: "Aatrox"}, + {ChampionName: "Ahri"}, + {ChampionName: "Akali"}, + } + got := findChampion(cs, "aat") + if got == nil || got.ChampionName != "Aatrox" { + t.Errorf("aat → %v, want Aatrox", got) + } +} + +func TestFindChampion_AmbiguousPrefixReturnsNil(t *testing.T) { + cs := []Champion{ + {ChampionName: "Akali"}, + {ChampionName: "Akshan"}, + } + if got := findChampion(cs, "ak"); got != nil { + t.Errorf("ambiguous ak should be nil; got %v", got) + } +} + +func TestFindChampion_EmptyInputReturnsNil(t *testing.T) { + cs := []Champion{{ChampionName: "Aatrox"}} + if got := findChampion(cs, ""); got != nil { + t.Errorf("empty input should be nil; got %v", got) + } + if got := findChampion(cs, "!!!"); got != nil { + t.Errorf("non-alphanumeric input should be nil; got %v", got) + } +} + +func TestFindByExactName(t *testing.T) { + cs := []Champion{{ChampionName: "Aatrox"}, {ChampionName: "Ahri"}} + if got := findByExactName(cs, "Ahri"); got == nil || got.ChampionName != "Ahri" { + t.Errorf("exact Ahri → %v", got) + } + // findByExactName is literal — no case-folding. + if got := findByExactName(cs, "ahri"); got != nil { + t.Errorf("lowercase should not match exact name lookup; got %v", got) + } +} + +func TestLoadChampions_EmbedIsValid(t *testing.T) { + cs := loadChampions() + if n := len(cs); n < 150 || n > 200 { + t.Errorf("champion count = %d, want ~172", n) + } + // Spot-check a known champion that the JS test suite depends on. + if got := findByExactName(cs, "Aatrox"); got == nil { + t.Error("expected Aatrox in embedded list") + } else if got.Gender != "Male" || got.Resource != "Manaless" { + t.Errorf("Aatrox shape unexpected: %+v", got) + } +} diff --git a/internal/modules/loldle/normalize.go b/internal/modules/loldle/normalize.go new file mode 100644 index 0000000..d647d09 --- /dev/null +++ b/internal/modules/loldle/normalize.go @@ -0,0 +1,20 @@ +package loldle + +import "strings" + +// normalize folds a name into a comparable form: lowercase, alphanumeric only. +// JS parity with util/normalize-name.js — `String(s).toLowerCase().replace(/[^a-z0-9]/g, "")`. +// +// Used for case/space/punctuation-insensitive name lookup so "Kai'Sa", +// "kaisa", and "KAI SA" all collapse to the same key. +func normalize(s string) string { + lower := strings.ToLower(s) + out := make([]byte, 0, len(lower)) + for i := 0; i < len(lower); i++ { + c := lower[i] + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { + out = append(out, c) + } + } + return string(out) +} diff --git a/internal/modules/loldle/render.go b/internal/modules/loldle/render.go new file mode 100644 index 0000000..d65c8be --- /dev/null +++ b/internal/modules/loldle/render.go @@ -0,0 +1,118 @@ +package loldle + +import ( + "fmt" + "html" + "strings" +) + +// guessRow is a single per-attribute render line built up from a comparison. +type guessRow struct { + marker string + label string + value string +} + +const ( + markerCorrect = "✅" + markerPartial = "🟨" + markerWrong = "❌" + markerName = "🎯" + arrowUp = "⬆️" + arrowDown = "⬇️" + nameLabel = "Champion" +) + +func markerFor(result string) string { + switch result { + case ResultCorrect: + return markerCorrect + case ResultPartial: + return markerPartial + default: + return markerWrong + } +} + +func arrowFor(direction string) string { + switch direction { + case "up": + return arrowUp + case "down": + return arrowDown + } + return "" +} + +// buildRows produces the labelled rows for a single guess: a name header +// followed by one row per classic attribute. release_date wrong rows append +// the direction arrow. +func buildRows(championName string, results []AttributeRow) []guessRow { + rows := make([]guessRow, 0, 1+len(results)) + rows = append(rows, guessRow{marker: markerName, label: nameLabel, value: strings.ToUpper(championName)}) + for _, r := range results { + value := r.GuessValue + if r.Key == "release_date" && r.Result != ResultCorrect { + if a := arrowFor(r.Direction); a != "" { + value = value + " " + a + } + } + rows = append(rows, guessRow{marker: markerFor(r.Result), label: r.Label, value: value}) + } + return rows +} + +// formatRowGroups joins one or more guess-row groups into a single
+// block. Label column width is the max label across ALL groups, so stacked
+// guesses on a board align with each other (JS parity).
+func formatRowGroups(groups [][]guessRow) string {
+	width := 0
+	for _, g := range groups {
+		for _, r := range g {
+			if n := len(r.label); n > width {
+				width = n
+			}
+		}
+	}
+	blocks := make([]string, len(groups))
+	for i, g := range groups {
+		lines := make([]string, len(g))
+		for j, r := range g {
+			lines[j] = fmt.Sprintf("%s %s %s",
+				r.marker, padRight(r.label, width), html.EscapeString(r.value))
+		}
+		blocks[i] = strings.Join(lines, "\n")
+	}
+	return "
" + strings.Join(blocks, "\n\n") + "
" +} + +func padRight(s string, n int) string { + if len(s) >= n { + return s + } + return s + strings.Repeat(" ", n-len(s)) +} + +// renderGuess formats a single guess as the HTML
 block.
+func renderGuess(championName string, results []AttributeRow) string {
+	return formatRowGroups([][]guessRow{buildRows(championName, results)})
+}
+
+// boardEntry is one prior guess on the rehydrated board.
+type boardEntry struct {
+	Champion string
+	Results  []AttributeRow
+}
+
+// renderBoard formats every prior guess in one aligned 
 block. Empty
+// board returns the placeholder hint instead of an empty 
.
+func renderBoard(entries []boardEntry) string {
+	if len(entries) == 0 {
+		return "No guesses yet. Reply with /loldle <champion>."
+	}
+	groups := make([][]guessRow, len(entries))
+	for i, e := range entries {
+		groups[i] = buildRows(e.Champion, e.Results)
+	}
+	return formatRowGroups(groups)
+}
diff --git a/internal/modules/loldle/render_test.go b/internal/modules/loldle/render_test.go
new file mode 100644
index 0000000..541c6c2
--- /dev/null
+++ b/internal/modules/loldle/render_test.go
@@ -0,0 +1,88 @@
+package loldle
+
+import (
+	"strings"
+	"testing"
+)
+
+// TestRenderGuess_AlignsLabelColumn locks the monospace column-alignment
+// invariant: the label column must be padded to the longest label so stacked
+// guesses on the board line up. Captures the JS render contract that the
+// rest of the test suite never exercises.
+func TestRenderGuess_AlignsLabelColumn(t *testing.T) {
+	rows := []AttributeRow{
+		{Key: "gender", Label: "Gender", Type: attrExact, GuessValue: "Male", Result: ResultCorrect},
+		{Key: "release_date", Label: "Release year", Type: attrYear, GuessValue: "2010", Result: ResultWrong, Direction: "up"},
+	}
+	out := renderGuess("Aatrox", rows)
+
+	// HTML envelope must be a 
…
so Telegram renders the columns. + if !strings.HasPrefix(out, "
") || !strings.HasSuffix(out, "
") { + t.Fatalf("expected
…
, got %q", out) + } + + // Longest label is "Release year" (12 chars). All inner rows must pad + // their label to that width so the value column starts at the same + // offset on every line. + body := strings.TrimSuffix(strings.TrimPrefix(out, "
"), "
") + for _, line := range strings.Split(body, "\n") { + if line == "" { + continue + } + // Format: " " + // We assert the label is followed by whitespace (or a space-only run + // totalling label width) before the value. Easiest check: the value + // column always starts after the same number of bytes from the + // start-of-line. + if !strings.Contains(line, "Release year") && !strings.Contains(line, "Champion ") && !strings.Contains(line, "Gender ") { + t.Errorf("row not padded to longest-label width: %q", line) + } + } + + // Year direction arrow appears for wrong rows. + if !strings.Contains(out, "⬆️") { + t.Errorf("expected up-arrow for wrong year row; got %q", out) + } +} + +func TestRenderGuess_EscapesHTMLInValues(t *testing.T) { + rows := []AttributeRow{ + {Key: "gender", Label: "Gender", Type: attrExact, GuessValue: "Tom & ", Result: ResultWrong}, + } + out := renderGuess("Aatrox", rows) + if !strings.Contains(out, "Tom & <Jerry>") { + t.Errorf("html metachars not escaped: %s", out) + } +} + +func TestRenderBoard_EmptyShowsHint(t *testing.T) { + if got := renderBoard(nil); !strings.Contains(got, "No guesses yet") { + t.Errorf("empty board placeholder missing: %q", got) + } +} + +func TestRenderBoard_StackedGuessesUseUnifiedWidth(t *testing.T) { + // First guess has the longest label "Release year"; second guess uses + // the same set so unified width should still be 12. Both blocks must + // align — verify by checking that a 'Gender' row in the second block + // has the same padding as in the first. + rowsA := []AttributeRow{ + {Key: "gender", Label: "Gender", Type: attrExact, GuessValue: "Male", Result: ResultCorrect}, + {Key: "release_date", Label: "Release year", Type: attrYear, GuessValue: "2013", Result: ResultCorrect}, + } + rowsB := []AttributeRow{ + {Key: "gender", Label: "Gender", Type: attrExact, GuessValue: "Female", Result: ResultWrong}, + {Key: "release_date", Label: "Release year", Type: attrYear, GuessValue: "2011", Result: ResultWrong, Direction: "up"}, + } + board := renderBoard([]boardEntry{ + {Champion: "Aatrox", Results: rowsA}, + {Champion: "Ahri", Results: rowsB}, + }) + // Two row-groups, blank-line separated, share a single
 envelope.
+	if strings.Count(board, "
") != 1 || strings.Count(board, "
") != 1 { + t.Errorf("expected single
…
envelope wrapping both groups; got %q", board) + } + if !strings.Contains(board, "\n\n") { + t.Errorf("expected blank-line separator between groups; got %q", board) + } +} diff --git a/internal/modules/loldle/state.go b/internal/modules/loldle/state.go new file mode 100644 index 0000000..07c9007 --- /dev/null +++ b/internal/modules/loldle/state.go @@ -0,0 +1,146 @@ +package loldle + +import ( + "context" + "errors" + "fmt" + + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +// MaxGuesses is the default round length. Per-subject overrides land via +// /loldle_setmax (capped by MaxGuessesCap). +const ( + MaxGuesses = 8 + MaxGuessesCap = 10 +) + +// gameState is the per-subject KV record. Field tags match JS exactly so a +// JS-written round decodes cleanly. StartedAt is *int64 because the JS +// source initialises it to `null` (timer doesn't start until first guess) — +// using time.Time would marshal as "0001-01-01T00:00:00Z" instead of null +// and break wire-format parity. +// +// Guesses is just championNames; comparison rows are recomputed at render +// time against current champions.json so a weekly data refresh updates +// historical board displays without migrating saved rounds. +type gameState struct { + Target string `json:"target"` + Guesses []string `json:"guesses"` + StartedAt *int64 `json:"startedAt"` // ms-since-epoch | null +} + +// stats lifetime score. JS shape — note no LastResultAt field (differs from +// wordle's stats; the JS loldle source omits it, parity dictates we do too). +type stats struct { + Played int `json:"played"` + Wins int `json:"wins"` + Streak int `json:"streak"` + BestStreak int `json:"bestStreak"` +} + +// roundConfig stores the per-subject MaxGuesses override. Stored only when +// /loldle_setmax has been run; the absence of this record means "use default". +type roundConfig struct { + MaxGuesses int `json:"maxGuesses"` +} + +func gameKey(subject string) string { return "game:" + subject } +func statsKey(subject string) string { return "stats:" + subject } +func configKey(subject string) string { return "config:" + subject } + +// loadGame returns the active round, or (nil, nil) if none exists. +func loadGame(ctx context.Context, kv storage.KVStore, subject string) (*gameState, error) { + var g gameState + err := kv.GetJSON(ctx, gameKey(subject), &g) + switch { + case err == nil: + return &g, nil + case errors.Is(err, storage.ErrNotFound): + return nil, nil + default: + return nil, fmt.Errorf("loldle loadGame: %w", err) + } +} + +func saveGame(ctx context.Context, kv storage.KVStore, subject string, g *gameState) error { + if err := kv.PutJSON(ctx, gameKey(subject), g); err != nil { + return fmt.Errorf("loldle saveGame: %w", err) + } + return nil +} + +// clearGame removes the round so the next /loldle starts fresh. Used after +// win / loss / giveup; the new round's timer should start on the player's +// next interaction, not at the moment the previous round ended. +func clearGame(ctx context.Context, kv storage.KVStore, subject string) error { + if err := kv.Delete(ctx, gameKey(subject)); err != nil { + return fmt.Errorf("loldle clearGame: %w", err) + } + return nil +} + +// loadStats returns lifetime score; missing → fresh-zero record (matches +// the JS `?? {…}` fallback). +func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*stats, error) { + var s stats + err := kv.GetJSON(ctx, statsKey(subject), &s) + switch { + case err == nil: + return &s, nil + case errors.Is(err, storage.ErrNotFound): + return &stats{}, nil + default: + return nil, fmt.Errorf("loldle loadStats: %w", err) + } +} + +func recordResult(ctx context.Context, kv storage.KVStore, subject string, won bool) (*stats, error) { + s, err := loadStats(ctx, kv, subject) + if err != nil { + return nil, err + } + s.Played++ + if won { + s.Wins++ + s.Streak++ + if s.Streak > s.BestStreak { + s.BestStreak = s.Streak + } + } else { + s.Streak = 0 + } + if err := kv.PutJSON(ctx, statsKey(subject), s); err != nil { + return nil, fmt.Errorf("loldle recordResult: %w", err) + } + return s, nil +} + +// getMaxGuesses returns the effective round length: the per-subject override +// if set and in range, otherwise MaxGuesses. Out-of-range values are +// silently ignored (matches JS). +func getMaxGuesses(ctx context.Context, kv storage.KVStore, subject string) (int, error) { + var cfg roundConfig + err := kv.GetJSON(ctx, configKey(subject), &cfg) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return MaxGuesses, nil + } + return 0, fmt.Errorf("loldle getMaxGuesses: %w", err) + } + if cfg.MaxGuesses < 1 || cfg.MaxGuesses > MaxGuessesCap { + return MaxGuesses, nil + } + return cfg.MaxGuesses, nil +} + +// setMaxGuesses validates and persists the per-subject override. +func setMaxGuesses(ctx context.Context, kv storage.KVStore, subject string, n int) error { + if n < 1 || n > MaxGuessesCap { + return fmt.Errorf("loldle: maxGuesses must be in [1, %d], got %d", MaxGuessesCap, n) + } + if err := kv.PutJSON(ctx, configKey(subject), roundConfig{MaxGuesses: n}); err != nil { + return fmt.Errorf("loldle setMaxGuesses: %w", err) + } + return nil +} diff --git a/internal/modules/loldle/state_test.go b/internal/modules/loldle/state_test.go new file mode 100644 index 0000000..2cd0c0c --- /dev/null +++ b/internal/modules/loldle/state_test.go @@ -0,0 +1,145 @@ +package loldle + +import ( + "context" + "encoding/json" + "testing" + + "github.com/tiennm99/miti99bot-go/internal/storage" +) + +func TestGameState_StartedAtNullByDefault(t *testing.T) { + g := gameState{Target: "Aatrox", Guesses: []string{}} + b, err := json.Marshal(g) + if err != nil { + t.Fatal(err) + } + want := `{"target":"Aatrox","guesses":[],"startedAt":null}` + if string(b) != want { + t.Errorf("marshal:\ngot %s\nwant %s", b, want) + } +} + +func TestGameState_StartedAtAsNumber(t *testing.T) { + at := int64(1700000000000) + g := gameState{Target: "Aatrox", Guesses: []string{"Ahri"}, StartedAt: &at} + b, _ := json.Marshal(g) + want := `{"target":"Aatrox","guesses":["Ahri"],"startedAt":1700000000000}` + if string(b) != want { + t.Errorf("marshal:\ngot %s\nwant %s", b, want) + } +} + +func TestStats_NoLastResultAtField(t *testing.T) { + // JS loldle stats schema differs from wordle: no lastResultAt. Lock that. + b, _ := json.Marshal(stats{}) + want := `{"played":0,"wins":0,"streak":0,"bestStreak":0}` + if string(b) != want { + t.Errorf("marshal:\ngot %s\nwant %s", b, want) + } +} + +func TestRecordResult_WinAndLossSequence(t *testing.T) { + ctx := context.Background() + kv := storage.NewMemoryKVStore() + + s, err := recordResult(ctx, kv, "u1", true) + if err != nil { + t.Fatal(err) + } + if s.Played != 1 || s.Wins != 1 || s.Streak != 1 || s.BestStreak != 1 { + t.Errorf("first win: %+v", s) + } + s, _ = recordResult(ctx, kv, "u1", true) + if s.Streak != 2 || s.BestStreak != 2 { + t.Errorf("two wins: %+v", s) + } + s, _ = recordResult(ctx, kv, "u1", false) + if s.Streak != 0 { + t.Errorf("loss should reset streak, got %d", s.Streak) + } + if s.BestStreak != 2 { + t.Errorf("best streak should persist, got %d", s.BestStreak) + } +} + +func TestGetMaxGuesses_DefaultsAndOverrides(t *testing.T) { + ctx := context.Background() + kv := storage.NewMemoryKVStore() + + if n, _ := getMaxGuesses(ctx, kv, "u1"); n != MaxGuesses { + t.Errorf("missing config → %d, want %d", n, MaxGuesses) + } + + if err := setMaxGuesses(ctx, kv, "u1", 5); err != nil { + t.Fatal(err) + } + if n, _ := getMaxGuesses(ctx, kv, "u1"); n != 5 { + t.Errorf("after setMax(5): %d, want 5", n) + } +} + +func TestSetMaxGuesses_ValidatesRange(t *testing.T) { + ctx := context.Background() + kv := storage.NewMemoryKVStore() + for _, n := range []int{0, -1, MaxGuessesCap + 1, 100} { + if err := setMaxGuesses(ctx, kv, "u1", n); err == nil { + t.Errorf("setMaxGuesses(%d) should error", n) + } + } + if err := setMaxGuesses(ctx, kv, "u1", 1); err != nil { + t.Errorf("setMaxGuesses(1) should succeed: %v", err) + } + if err := setMaxGuesses(ctx, kv, "u1", MaxGuessesCap); err != nil { + t.Errorf("setMaxGuesses(cap) should succeed: %v", err) + } +} + +func TestGetMaxGuesses_OutOfRangeIgnored(t *testing.T) { + ctx := context.Background() + kv := storage.NewMemoryKVStore() + // Inject a corrupt config to simulate manual KV tampering or a stale + // schema; getMaxGuesses must fall back to the default rather than + // returning a wild value to handlers. + if err := kv.PutJSON(ctx, configKey("u1"), roundConfig{MaxGuesses: 100}); err != nil { + t.Fatal(err) + } + if n, _ := getMaxGuesses(ctx, kv, "u1"); n != MaxGuesses { + t.Errorf("out-of-range config → %d, want %d (default)", n, MaxGuesses) + } +} + +func TestLoadGame_MissingReturnsNil(t *testing.T) { + g, err := loadGame(context.Background(), storage.NewMemoryKVStore(), "nobody") + if err != nil { + t.Errorf("missing should not error: %v", err) + } + if g != nil { + t.Errorf("expected nil, got %+v", g) + } +} + +func TestSaveLoadClearGame_RoundTrip(t *testing.T) { + ctx := context.Background() + kv := storage.NewMemoryKVStore() + at := int64(42) + want := &gameState{Target: "Aatrox", Guesses: []string{"Ahri"}, StartedAt: &at} + if err := saveGame(ctx, kv, "u1", want); err != nil { + t.Fatal(err) + } + got, err := loadGame(ctx, kv, "u1") + if err != nil || got == nil { + t.Fatalf("loadGame: %v, %v", got, err) + } + if got.Target != "Aatrox" || len(got.Guesses) != 1 || got.StartedAt == nil || *got.StartedAt != 42 { + t.Errorf("round-trip mismatch: %+v", got) + } + + if err := clearGame(ctx, kv, "u1"); err != nil { + t.Fatal(err) + } + got, _ = loadGame(ctx, kv, "u1") + if got != nil { + t.Errorf("after clearGame, expected nil, got %+v", got) + } +} diff --git a/internal/modules/loldle/stickers.go b/internal/modules/loldle/stickers.go new file mode 100644 index 0000000..5a5455e --- /dev/null +++ b/internal/modules/loldle/stickers.go @@ -0,0 +1,36 @@ +package loldle + +import "math/rand" + +// Sticker file_ids are bot-scoped to @miti99bot. Adding/replacing requires +// resending the sticker to the bot and capturing the file_id via util's +// /stickerid private command. Empty pools are safe — pickSticker returns "" +// and handlers skip the SendSticker call. + +// winStickers — cheerful pool used on a successful guess. +var winStickers = []string{ + "CAACAgIAAxkBAAECGBVp56vuLw5TSWy8TiFGkQYAAWxi0wUAAh9EAAJTD8BJ2wQ6vyMilxs7BA", + "CAACAgIAAxkBAAECGBhp56wAATkzsBBcIxMSC_8mp2dBczsAAvBEAAL1vklJuCnu695nAvI7BA", + "CAACAgIAAxkBAAECGBlp56wBZ4rmluL3KNlOuWsyctN0FQACNEQAAue5OEv37J_IfMnpljsE", + "CAACAgIAAxkBAAECGCFp56w7WlD6vTIsHE2WUTs4C2IjXAAC9FsAAtgFMUu63usfH16ZpzsE", +} + +// loseStickers — deflated pool used when the player runs out of guesses. +var loseStickers = []string{ + "CAACAgIAAxkBAAECGCBp56w3HUyYOHOeMwLfULT8p8SPtQACWkYAAiPesEjnptWk36YKZjsE", +} + +// giveupStickers — resigned pool used on /loldle_giveup. +var giveupStickers = []string{ + "CAACAgIAAxkBAAECGCJp56xIk6B6McSfnYykLhgXVCSnmQACBlkAApSyWEo6G2rnqDvZxjsE", +} + +// pickSticker returns one file_id from the pool, or "" when empty. The +// math/rand global is mutex-protected, so concurrent handlers can call this +// without their own synchronisation. +func pickSticker(pool []string) string { + if len(pool) == 0 { + return "" + } + return pool[rand.Intn(len(pool))] +}