mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 18:19:22 +00:00
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.
This commit is contained in:
1 parent
676c481def
commit
479a6ea9ea
17 files changed
+4921
No files matched your search
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 guess<target, "down" if guess>target).
|
||||
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 "—"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <code>/loldle</code> or <code>/loldle <champion></code> 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 <code>/loldle <champion></code>.",
|
||||
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(
|
||||
"🔁 <b>%s</b> 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 <n> — 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))
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <pre>
|
||||
// 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 "<pre>" + strings.Join(blocks, "\n\n") + "</pre>"
|
||||
}
|
||||
|
||||
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 <pre> 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 <pre> block. Empty
|
||||
// board returns the placeholder hint instead of an empty <pre>.
|
||||
func renderBoard(entries []boardEntry) string {
|
||||
if len(entries) == 0 {
|
||||
return "No guesses yet. Reply with <code>/loldle <champion></code>."
|
||||
}
|
||||
groups := make([][]guessRow, len(entries))
|
||||
for i, e := range entries {
|
||||
groups[i] = buildRows(e.Champion, e.Results)
|
||||
}
|
||||
return formatRowGroups(groups)
|
||||
}
|
||||
@@ -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 <pre>…</pre> so Telegram renders the columns.
|
||||
if !strings.HasPrefix(out, "<pre>") || !strings.HasSuffix(out, "</pre>") {
|
||||
t.Fatalf("expected <pre>…</pre>, 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, "<pre>"), "</pre>")
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Format: "<emoji> <label-padded-to-width> <value...>"
|
||||
// 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 & <Jerry>", 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 <pre> envelope.
|
||||
if strings.Count(board, "<pre>") != 1 || strings.Count(board, "</pre>") != 1 {
|
||||
t.Errorf("expected single <pre>…</pre> envelope wrapping both groups; got %q", board)
|
||||
}
|
||||
if !strings.Contains(board, "\n\n") {
|
||||
t.Errorf("expected blank-line separator between groups; got %q", board)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))]
|
||||
}
|
||||
Reference in new issue
Block a user