mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-08 18:17:54 +00:00
refactor(modules): drop loldle variants, semantle, doantu and dead framework surface
Removes six modules (loldle-ability/emoji/quote/splash, semantle, doantu) and prunes the framework deps that were only there to serve them: - ai.Embedder + Client.Embed + embeddingModel const (semantle only) - Deps.Embedder + BuildOptions.Embedder - Deps.Env + Build(env) param + ModuleEnv config field + PHOW2SIM allowlist (doantu only) - internal/champname package (loldle now owns its lookup helpers directly) - template.yaml: Phow2simAPIURL parameter + PHOW2SIM_API_URL Lambda env Active catalog: util, misc, wordle, loldle, lolschedule, twentyq, trading. go build / vet / test all pass.
This commit is contained in:
@@ -8,7 +8,7 @@ Mid-port. Code is on `main`; first AWS deploy still pending the user's manual AW
|
||||
|
||||
| Track | What | Status |
|
||||
|-------|------|--------|
|
||||
| Modules | util, misc, wordle, loldle (+ ability/emoji/quote/splash variants), lolschedule, semantle, doantu, twentyq | **done** |
|
||||
| Modules | util, misc, wordle, loldle, lolschedule, twentyq, trading | **done** |
|
||||
| Storage | KVStore interface; in-memory + Firestore + **DynamoDB** providers | **done** |
|
||||
| AI | Gemini API client (`internal/ai`) | **done** |
|
||||
| AWS IaC | SAM template + Makefile + GH Actions OIDC deploy | **done** |
|
||||
|
||||
+15
-38
@@ -16,15 +16,9 @@ import (
|
||||
"github.com/tiennm99/miti99bot-go/internal/log"
|
||||
"github.com/tiennm99/miti99bot-go/internal/metrics"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/doantu"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/loldle"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/loldleability"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/loldleemoji"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/loldlequote"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/loldlesplash"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/lolschedule"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/misc"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/semantle"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/trading"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/twentyq"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util"
|
||||
@@ -39,19 +33,13 @@ import (
|
||||
// import cycle (modules → util → modules).
|
||||
func factories() map[string]modules.Factory {
|
||||
return map[string]modules.Factory{
|
||||
"util": util.New,
|
||||
"misc": misc.New,
|
||||
"wordle": wordle.New,
|
||||
"loldle": loldle.New,
|
||||
"loldle-ability": loldleability.New,
|
||||
"loldle-emoji": loldleemoji.New,
|
||||
"loldle-quote": loldlequote.New,
|
||||
"loldle-splash": loldlesplash.New,
|
||||
"lolschedule": lolschedule.New,
|
||||
"semantle": semantle.New,
|
||||
"doantu": doantu.New,
|
||||
"twentyq": twentyq.New,
|
||||
"trading": trading.New,
|
||||
"util": util.New,
|
||||
"misc": misc.New,
|
||||
"wordle": wordle.New,
|
||||
"loldle": loldle.New,
|
||||
"lolschedule": lolschedule.New,
|
||||
"twentyq": twentyq.New,
|
||||
"trading": trading.New,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +80,9 @@ func main() {
|
||||
log.Fatal("telegram bot init failed", "err", err)
|
||||
}
|
||||
|
||||
// Gemini is optional: modules that need it (semantle/twentyq) check
|
||||
// for nil and refuse the command at handler time. A blank GEMINI_API_KEY
|
||||
// is therefore not fatal — the rest of the bot still runs.
|
||||
// Gemini is optional: twentyq checks for nil and refuses the command at
|
||||
// handler time. A blank GEMINI_API_KEY is therefore not fatal — the rest
|
||||
// of the bot still runs.
|
||||
aiClient, err := ai.NewClient(rootCtx, cfg.GeminiAPIKey)
|
||||
if err != nil && !errors.Is(err, ai.ErrNotConfigured) {
|
||||
log.Fatal("gemini init failed", "err", err)
|
||||
@@ -105,10 +93,9 @@ func main() {
|
||||
log.Info("gemini client initialised")
|
||||
}
|
||||
|
||||
reg, err := modules.Build(cfg.Modules, factories(), provider, cfg.ModuleEnv, modules.BuildOptions{
|
||||
Embedder: aiClient,
|
||||
Chatter: aiClient,
|
||||
Bot: b,
|
||||
reg, err := modules.Build(cfg.Modules, factories(), provider, modules.BuildOptions{
|
||||
Chatter: aiClient,
|
||||
Bot: b,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal("module registry build failed", "err", err)
|
||||
@@ -242,9 +229,8 @@ type config struct {
|
||||
Modules []string
|
||||
BotOwnerID int64
|
||||
AdminUserIDs map[int64]bool
|
||||
ModuleEnv map[string]string // per-module allowlist; only declared keys flow through
|
||||
KVProvider string // empty = auto-detect; or "memory"|"firestore"|"dynamodb"
|
||||
DynamoDBTable string // required when KVProvider=dynamodb
|
||||
KVProvider string // empty = auto-detect; or "memory"|"firestore"|"dynamodb"
|
||||
DynamoDBTable string // required when KVProvider=dynamodb
|
||||
}
|
||||
|
||||
func loadConfig() config {
|
||||
@@ -264,14 +250,6 @@ func loadConfig() config {
|
||||
if n, err := strconv.Atoi(port); err != nil || n < 0 || n > 65535 {
|
||||
log.Fatal("invalid PORT", "value", port)
|
||||
}
|
||||
// ModuleEnv is the per-module allowlist. Add a key here when a specific
|
||||
// module needs it; it never auto-flows from process env. Today only
|
||||
// PHOW2SIM_API_URL (doantu) is exposed — Gemini is wired through a typed
|
||||
// dep, not env.
|
||||
moduleEnv := map[string]string{}
|
||||
if v := envMap["PHOW2SIM_API_URL"]; v != "" {
|
||||
moduleEnv["PHOW2SIM_API_URL"] = v
|
||||
}
|
||||
return config{
|
||||
Port: port,
|
||||
TelegramBotToken: envMap["TELEGRAM_BOT_TOKEN"],
|
||||
@@ -283,7 +261,6 @@ func loadConfig() config {
|
||||
Modules: splitCSV(envMap["MODULES"]),
|
||||
BotOwnerID: parseInt64(envMap["BOT_OWNER_ID"]),
|
||||
AdminUserIDs: parseInt64Set(envMap["ADMIN_USER_IDS"]),
|
||||
ModuleEnv: moduleEnv,
|
||||
KVProvider: envMap["KV_PROVIDER"],
|
||||
DynamoDBTable: envMap["DYNAMODB_TABLE"],
|
||||
}
|
||||
|
||||
+4
-47
@@ -9,13 +9,10 @@ import (
|
||||
"google.golang.org/genai"
|
||||
)
|
||||
|
||||
// Default model identifiers. Pinned strings rather than constants exposed to
|
||||
// callers — modules should not pick their own model. If we ever need to A/B
|
||||
// test, a higher-level config wins, not a per-module override.
|
||||
const (
|
||||
embeddingModel = "text-embedding-004" // 768-dim, free tier
|
||||
chatModel = "gemini-2.5-flash" // newest flash; 15 RPM / 1500 RPD free
|
||||
)
|
||||
// chatModel is pinned here rather than exposed to callers — modules should
|
||||
// not pick their own model. If we ever need to A/B test, a higher-level
|
||||
// config wins, not a per-module override.
|
||||
const chatModel = "gemini-2.5-flash" // newest flash; 15 RPM / 1500 RPD free
|
||||
|
||||
// ErrRateLimited is returned when the upstream rejected with 429 (or our
|
||||
// in-process per-user bucket dropped the call). Modules show a friendly
|
||||
@@ -54,46 +51,6 @@ func NewClient(ctx context.Context, apiKey string) (*Client, error) {
|
||||
return &Client{g: g}, nil
|
||||
}
|
||||
|
||||
// Embed batches `texts` into a single EmbedContent call and returns
|
||||
// dense vectors in the same order. Empty input → (nil, nil).
|
||||
//
|
||||
// Errors are wrapped; rate-limit (HTTP 429) is mapped to ErrRateLimited
|
||||
// so callers can branch on errors.Is.
|
||||
func (c *Client) Embed(ctx context.Context, texts []string) ([][]float32, error) {
|
||||
if c == nil || c.g == nil {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
if len(texts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
contents := make([]*genai.Content, 0, len(texts))
|
||||
for _, t := range texts {
|
||||
contents = append(contents, genai.NewContentFromText(t, genai.RoleUser))
|
||||
}
|
||||
resp, err := c.g.Models.EmbedContent(ctx, embeddingModel, contents, nil)
|
||||
if err != nil {
|
||||
if isRateLimit(err) {
|
||||
return nil, ErrRateLimited
|
||||
}
|
||||
return nil, fmt.Errorf("ai: EmbedContent: %w", err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, fmt.Errorf("ai: EmbedContent: nil response")
|
||||
}
|
||||
if len(resp.Embeddings) != len(texts) {
|
||||
return nil, fmt.Errorf("ai: EmbedContent returned %d embeddings, want %d",
|
||||
len(resp.Embeddings), len(texts))
|
||||
}
|
||||
out := make([][]float32, len(texts))
|
||||
for i, e := range resp.Embeddings {
|
||||
if e == nil || len(e.Values) == 0 {
|
||||
return nil, fmt.Errorf("ai: EmbedContent: empty embedding at index %d", i)
|
||||
}
|
||||
out[i] = e.Values
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Generate runs a single-turn chat with `system` as the system instruction
|
||||
// and `user` as the user message. Returns the model's text reply.
|
||||
//
|
||||
|
||||
+8
-15
@@ -1,29 +1,22 @@
|
||||
// Package ai wraps Google's genai SDK with a small, mockable surface for the
|
||||
// semantle/twentyq modules. The package owns:
|
||||
// twentyq module. The package owns:
|
||||
//
|
||||
// - Embedder / Chatter interfaces — what modules consume
|
||||
// - Chatter interface — what modules consume
|
||||
// - Client struct — production implementation backed by genai
|
||||
// - per-user rate limiter — defends the shared 1500-RPD Gemini free tier
|
||||
//
|
||||
// Modules accept the interfaces (not *Client) so unit tests can pass fakes
|
||||
// Modules accept the interface (not *Client) so unit tests can pass fakes
|
||||
// without spinning up a real Gemini client. Production wiring in cmd/server
|
||||
// passes the *Client (which satisfies both interfaces) into Deps.
|
||||
// passes the *Client (which satisfies the interface) into Deps.
|
||||
package ai
|
||||
|
||||
import "context"
|
||||
|
||||
// Embedder produces dense vectors for text. Used by the semantle module to
|
||||
// score guess similarity. Implementations must respect ctx cancellation.
|
||||
//
|
||||
// On rate-limit (HTTP 429) the implementation should return a sentinel error
|
||||
// — see ErrRateLimited — so callers can show a user-friendly retry message.
|
||||
type Embedder interface {
|
||||
Embed(ctx context.Context, texts []string) ([][]float32, error)
|
||||
}
|
||||
|
||||
// Chatter produces a single text completion from a system + user prompt
|
||||
// pair. Used by the twentyq module's judge + round-start calls. Same
|
||||
// rate-limit conventions as Embedder.
|
||||
// pair. Used by the twentyq module's judge + round-start calls.
|
||||
//
|
||||
// On rate-limit (HTTP 429) the implementation should return ErrRateLimited
|
||||
// so callers can show a user-friendly retry message.
|
||||
type Chatter interface {
|
||||
Generate(ctx context.Context, system, user string) (string, error)
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
// Package champname holds champion-name primitives shared by loldle and
|
||||
// loldleemoji (and any future loldle variant). Normalize folds names to a
|
||||
// comparable form; Find resolves user input to a single champion via exact
|
||||
// or unique-prefix match. Generic over the champion type via a name-extractor
|
||||
// closure so each module can keep its own data shape.
|
||||
package champname
|
||||
|
||||
import "strings"
|
||||
|
||||
// Normalize folds a name to a comparable form: lowercase, alphanumeric only.
|
||||
// JS-parity with util/normalize-name.js — `String(s).toLowerCase().replace(/[^a-z0-9]/g, "")`.
|
||||
//
|
||||
// Used for case/space/punctuation-insensitive lookup so "Kai'Sa", "kaisa",
|
||||
// and "KAI SA" all collapse to the same key.
|
||||
func Normalize(s string) string {
|
||||
lower := strings.ToLower(s)
|
||||
out := make([]byte, 0, len(lower))
|
||||
for i := 0; i < len(lower); i++ {
|
||||
c := lower[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// Find resolves user input to a champion via:
|
||||
//
|
||||
// 1. Empty / non-alphanumeric input → no match (nil).
|
||||
// 2. Exact normalised name → that champion.
|
||||
// 3. Otherwise: unique normalised-prefix match → that champion.
|
||||
//
|
||||
// Ambiguous prefix or no hit → nil. Returning nil for ambiguous prefixes
|
||||
// prevents "Ka" silently routing to whichever Ka- champion happens to be
|
||||
// first in the data file.
|
||||
//
|
||||
// Generic over T so loldle/Champion and loldleemoji/EmojiChampion can both
|
||||
// use it; nameOf extracts the champion's display name.
|
||||
func Find[T any](pool []T, input string, nameOf func(*T) string) *T {
|
||||
q := Normalize(input)
|
||||
if q == "" {
|
||||
return nil
|
||||
}
|
||||
for i := range pool {
|
||||
if Normalize(nameOf(&pool[i])) == q {
|
||||
return &pool[i]
|
||||
}
|
||||
}
|
||||
var hit *T
|
||||
for i := range pool {
|
||||
if strings.HasPrefix(Normalize(nameOf(&pool[i])), q) {
|
||||
if hit != nil {
|
||||
return nil // ambiguous
|
||||
}
|
||||
hit = &pool[i]
|
||||
}
|
||||
}
|
||||
return hit
|
||||
}
|
||||
|
||||
// FindByExactName looks up a champion by literal display name (no
|
||||
// normalization). Used to rehydrate a stored game's target. Returns nil if
|
||||
// the pool was refreshed and the target is no longer present.
|
||||
func FindByExactName[T any](pool []T, name string, nameOf func(*T) string) *T {
|
||||
for i := range pool {
|
||||
if nameOf(&pool[i]) == name {
|
||||
return &pool[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package champname
|
||||
|
||||
import "testing"
|
||||
|
||||
type fakeChamp struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func nameOfFake(c *fakeChamp) string { return c.Name }
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"Kai'Sa", "kaisa"},
|
||||
{"KAI SA", "kaisa"},
|
||||
{"kaisa", "kaisa"},
|
||||
{"Lee Sin", "leesin"},
|
||||
{"Dr. Mundo", "drmundo"},
|
||||
{"K9", "k9"},
|
||||
{" ", ""},
|
||||
{"!!!", ""},
|
||||
{"Aurelion Sol", "aurelionsol"},
|
||||
{"Cho'Gath", "chogath"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := Normalize(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("Normalize(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFind(t *testing.T) {
|
||||
pool := []fakeChamp{
|
||||
{Name: "Kai'Sa"},
|
||||
{Name: "Karma"},
|
||||
{Name: "Karthus"},
|
||||
{Name: "Lee Sin"},
|
||||
{Name: "Lulu"},
|
||||
{Name: "Lux"},
|
||||
}
|
||||
tests := []struct {
|
||||
name, in string
|
||||
wantName string // "" → nil expected
|
||||
}{
|
||||
{"empty input → nil", "", ""},
|
||||
{"non-alpha input → nil", "!!!", ""},
|
||||
{"exact match", "kaisa", "Kai'Sa"},
|
||||
{"exact match with punctuation", "Kai'Sa", "Kai'Sa"},
|
||||
{"exact match with spaces", "Lee Sin", "Lee Sin"},
|
||||
{"unique prefix", "kart", "Karthus"},
|
||||
{"unique prefix with case", "KART", "Karthus"},
|
||||
{"ambiguous prefix → nil", "ka", ""},
|
||||
{"ambiguous prefix lu → nil", "lu", ""},
|
||||
{"unambiguous prefix lux", "lux", "Lux"},
|
||||
{"no match", "zilean", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := Find(pool, tt.in, nameOfFake)
|
||||
if tt.wantName == "" {
|
||||
if got != nil {
|
||||
t.Errorf("Find(%q): got %v, want nil", tt.in, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("Find(%q): got nil, want %q", tt.in, tt.wantName)
|
||||
}
|
||||
if got.Name != tt.wantName {
|
||||
t.Errorf("Find(%q): got %q, want %q", tt.in, got.Name, tt.wantName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindByExactName(t *testing.T) {
|
||||
pool := []fakeChamp{{Name: "Kai'Sa"}, {Name: "Karma"}}
|
||||
|
||||
if got := FindByExactName(pool, "Kai'Sa", nameOfFake); got == nil || got.Name != "Kai'Sa" {
|
||||
t.Errorf("FindByExactName(Kai'Sa) = %v, want hit", got)
|
||||
}
|
||||
// Normalisation must NOT apply — exact match only.
|
||||
if got := FindByExactName(pool, "kaisa", nameOfFake); got != nil {
|
||||
t.Errorf("FindByExactName(kaisa) = %v, want nil (case-sensitive)", got)
|
||||
}
|
||||
if got := FindByExactName(pool, "Zilean", nameOfFake); got != nil {
|
||||
t.Errorf("FindByExactName(Zilean) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
// Package doantu ports the JS Vietnamese-semantle module to Go. It uses the
|
||||
// hosted phow2sim PhoW2V word2vec API (https://phow2sim.sg.miti99.com) for
|
||||
// target picking + cosine similarity, NOT Gemini embeddings. Rationale:
|
||||
//
|
||||
// 1. text-embedding-004 was not trained for Vietnamese semantic relatedness;
|
||||
// phow2sim is a domain-trained model.
|
||||
// 2. phow2sim already owns the vocabulary — no need to maintain a Vietnamese
|
||||
// wordlist alongside it.
|
||||
// 3. JS parity: the upstream service is the same one the JS bot has been
|
||||
// using; switching to embeddings would diverge behavior, not preserve it.
|
||||
//
|
||||
// Phase 07 plan suggested embedding both modules; this is a documented
|
||||
// deviation. Update phase-07 plan + plan.md when reviewing.
|
||||
package doantu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimeout = 5 * time.Second
|
||||
userAgent = "miti99bot-go/doantu"
|
||||
)
|
||||
|
||||
// UpstreamError is returned for every transport / decode failure. status is
|
||||
// 0 for non-HTTP failures (timeout, DNS).
|
||||
type UpstreamError struct {
|
||||
Status int
|
||||
Msg string
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *UpstreamError) Error() string {
|
||||
if e.Status > 0 {
|
||||
return fmt.Sprintf("phow2sim HTTP %d: %s", e.Status, e.Msg)
|
||||
}
|
||||
return "phow2sim: " + e.Msg
|
||||
}
|
||||
|
||||
// SimResp mirrors the JS api-client similarity() shape.
|
||||
type SimResp struct {
|
||||
A string `json:"a"`
|
||||
B string `json:"b"`
|
||||
CanonicalA *string `json:"canonical_a"`
|
||||
CanonicalB *string `json:"canonical_b"`
|
||||
InVocabA bool `json:"in_vocab_a"`
|
||||
InVocabB bool `json:"in_vocab_b"`
|
||||
Similarity *float64 `json:"similarity"`
|
||||
}
|
||||
|
||||
// RandomResp shape from /random.
|
||||
type RandomResp struct {
|
||||
Word string `json:"word"`
|
||||
Rank *int `json:"rank,omitempty"`
|
||||
}
|
||||
|
||||
// Neighbor item in /neighbors response.
|
||||
type Neighbor struct {
|
||||
Word string `json:"word"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
}
|
||||
|
||||
// NeighborsResp from /neighbors.
|
||||
type NeighborsResp struct {
|
||||
Word string `json:"word"`
|
||||
Canonical *string `json:"canonical"`
|
||||
InVocab bool `json:"in_vocab"`
|
||||
Neighbors []Neighbor `json:"neighbors"`
|
||||
}
|
||||
|
||||
// Client is the phow2sim HTTP client. Zero value is unusable — call
|
||||
// NewClient. Safe for concurrent use; net/http.Client is goroutine-safe.
|
||||
type Client struct {
|
||||
base string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
// NewClient builds a client against the supplied base URL (no trailing
|
||||
// slash needed; we strip it). timeout=0 → defaultTimeout.
|
||||
func NewClient(base string, timeout time.Duration) *Client {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
return &Client{
|
||||
base: strings.TrimRight(base, "/"),
|
||||
hc: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, params url.Values, dst any) error {
|
||||
full := c.base + path
|
||||
if len(params) > 0 {
|
||||
full += "?" + params.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, full, nil)
|
||||
if err != nil {
|
||||
return &UpstreamError{Msg: "build request: " + err.Error()}
|
||||
}
|
||||
req.Header.Set("User-Agent", userAgent)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return &UpstreamError{Msg: "fetch failed: " + err.Error()}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB cap
|
||||
if err != nil {
|
||||
return &UpstreamError{Status: resp.StatusCode, Msg: "read body: " + err.Error()}
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &UpstreamError{Status: resp.StatusCode, Msg: "non-200", Body: truncate(string(body), 500)}
|
||||
}
|
||||
if err := json.Unmarshal(body, dst); err != nil {
|
||||
return &UpstreamError{Status: resp.StatusCode, Msg: "decode: " + err.Error(), Body: truncate(string(body), 200)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RandomWord picks a target word with optional filters (e.g. min_rank/max_rank).
|
||||
func (c *Client) RandomWord(ctx context.Context, filters map[string]string) (*RandomResp, error) {
|
||||
q := url.Values{}
|
||||
for k, v := range filters {
|
||||
q.Set(k, v)
|
||||
}
|
||||
var r RandomResp
|
||||
if err := c.get(ctx, "/random", q, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// Similarity returns target↔guess cosine + canonical forms + vocab flags.
|
||||
func (c *Client) Similarity(ctx context.Context, a, b string) (*SimResp, error) {
|
||||
q := url.Values{}
|
||||
q.Set("a", a)
|
||||
q.Set("b", b)
|
||||
var r SimResp
|
||||
if err := c.get(ctx, "/similarity", q, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// Neighbors returns the top-N closest words to `word`. JS-parity default 100.
|
||||
func (c *Client) Neighbors(ctx context.Context, word string, topn int) (*NeighborsResp, error) {
|
||||
if topn <= 0 {
|
||||
topn = 100
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("word", word)
|
||||
q.Set("topn", strconv.Itoa(topn))
|
||||
var r NeighborsResp
|
||||
if err := c.get(ctx, "/neighbors", q, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// SimAPI is the small interface handlers consume — lets tests pass a fake.
|
||||
type SimAPI interface {
|
||||
RandomWord(ctx context.Context, filters map[string]string) (*RandomResp, error)
|
||||
Similarity(ctx context.Context, a, b string) (*SimResp, error)
|
||||
Neighbors(ctx context.Context, word string, topn int) (*NeighborsResp, error)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package doantu
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
mrand "math/rand/v2"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
)
|
||||
|
||||
// defaultAPI is the production phow2sim instance. Override via env
|
||||
// PHOW2SIM_API_URL (allowlisted in cmd/server/main.go).
|
||||
const defaultAPI = "https://phow2sim.sg.miti99.com"
|
||||
|
||||
// New is the doantu module Factory. Reads PHOW2SIM_API_URL from Deps.Env
|
||||
// (falls back to defaultAPI). The module is always loadable — the upstream
|
||||
// service handles uptime, not us.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
base := defaultAPI
|
||||
if v, ok := deps.Env["PHOW2SIM_API_URL"]; ok && v != "" {
|
||||
base = v
|
||||
}
|
||||
s := &state{
|
||||
kv: deps.KV,
|
||||
api: NewClient(base, 0),
|
||||
rng: newRNG(),
|
||||
}
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "doantu",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Đoán từ — Vietnamese semantic word guessing (unlimited tries)",
|
||||
Handler: s.handleDoantu,
|
||||
},
|
||||
{
|
||||
Name: "doantu_hint",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Reveal 3 related words (not the answer) to nudge your guessing",
|
||||
Handler: s.handleHint,
|
||||
},
|
||||
{
|
||||
Name: "doantu_giveup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Reveal the current doantu answer (auto-starts a fresh round)",
|
||||
Handler: s.handleGiveup,
|
||||
},
|
||||
{
|
||||
Name: "doantu_stats",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show your doantu stats",
|
||||
Handler: s.handleStats,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newRNG() *mrand.Rand {
|
||||
var seed [16]byte
|
||||
_, _ = rand.Read(seed[:])
|
||||
s1 := binary.LittleEndian.Uint64(seed[0:8])
|
||||
s2 := binary.LittleEndian.Uint64(seed[8:16])
|
||||
return mrand.New(mrand.NewPCG(s1, s2))
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package doantu
|
||||
|
||||
import "math"
|
||||
|
||||
// calibrate: phow2sim cosines already span a wide useful range — JS just
|
||||
// scales linearly to 0-100 with negative clamp. No sigmoid here.
|
||||
func calibrate(raw float64) float64 {
|
||||
v := raw * 100
|
||||
switch {
|
||||
case v < 0:
|
||||
return 0
|
||||
case v > 100:
|
||||
return 100
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func formatWarmth(score float64) string {
|
||||
pct := int(math.Round(score))
|
||||
switch {
|
||||
case pct >= 100:
|
||||
return "100"
|
||||
case pct < 10:
|
||||
return "0" + itoa(pct)
|
||||
default:
|
||||
return itoa(pct)
|
||||
}
|
||||
}
|
||||
|
||||
func warmthEmoji(score float64) string {
|
||||
switch {
|
||||
case score >= 90:
|
||||
return "🎯"
|
||||
case score >= 70:
|
||||
return "🔥"
|
||||
case score >= 40:
|
||||
return "🌡️"
|
||||
case score >= 15:
|
||||
return "😐"
|
||||
default:
|
||||
return "🥶"
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [4]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
package doantu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"math/rand/v2"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/keylock"
|
||||
"github.com/tiennm99/miti99bot-go/internal/log"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
const upstreamFail = "⚠️ Upstream hiccup — try again in a few seconds."
|
||||
|
||||
// JS-parity rank band: keep targets in the top-frequency band so the game
|
||||
// stays guessable.
|
||||
var randomFilters = map[string]string{"min_rank": "100", "max_rank": "1000"}
|
||||
|
||||
type state struct {
|
||||
kv storage.KVStore
|
||||
api SimAPI
|
||||
rngMu sync.Mutex
|
||||
rng *rand.Rand
|
||||
locks keylock.Map
|
||||
}
|
||||
|
||||
func (s *state) startFresh(ctx context.Context, subject string) (*GameState, error) {
|
||||
picked, err := s.api.RandomWord(ctx, randomFilters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
target := strings.ToLower(picked.Word)
|
||||
if target == "" {
|
||||
return nil, &UpstreamError{Msg: "empty target from RandomWord"}
|
||||
}
|
||||
g := &GameState{Target: target, StartedAt: nil, Solved: false, Guesses: []Guess{}}
|
||||
if err := saveGame(ctx, s.kv, subject, g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (s *state) getOrInit(ctx context.Context, subject string) (*GameState, error) {
|
||||
existing, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil && !existing.Solved {
|
||||
return existing, nil
|
||||
}
|
||||
return s.startFresh(ctx, subject)
|
||||
}
|
||||
|
||||
func (s *state) handleDoantu(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
game, err := s.getOrInit(ctx, subject)
|
||||
if err != nil {
|
||||
log.Warn("doantu random failed", "err", err)
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, upstreamFail)
|
||||
}
|
||||
if arg == "" {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, renderBoard(game.Guesses, ""))
|
||||
}
|
||||
return s.submitGuess(ctx, b, msg, subject, game, arg)
|
||||
}
|
||||
|
||||
func (s *state) submitGuess(ctx context.Context, b *bot.Bot, msg *models.Message, subject string, game *GameState, arg string) error {
|
||||
guess := normalize(arg)
|
||||
if !isValidShape(guess) {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Please provide a Vietnamese word (letters + optional single spaces).")
|
||||
}
|
||||
for _, g := range game.Guesses {
|
||||
if g.Word == guess || g.Canonical == guess {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("🔁 <b>%s</b> was already guessed this round — try another word.",
|
||||
html.EscapeString(guess)))
|
||||
}
|
||||
}
|
||||
|
||||
res, err := s.api.Similarity(ctx, game.Target, guess)
|
||||
if err != nil {
|
||||
log.Warn("doantu similarity failed", "err", err)
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, upstreamFail)
|
||||
}
|
||||
// Target OOV — JS-parity reset behaviour. The recorded round was seeded
|
||||
// pre-vocab-change; let player start fresh instead of fighting a ghost.
|
||||
if !res.InVocabA {
|
||||
log.Warn("doantu target OOV", "target", game.Target)
|
||||
_ = clearGame(ctx, s.kv, subject)
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
"⚠️ This round's target is no longer valid (upstream vocabulary changed). "+
|
||||
"Send <code>/doantu</code> again to start a fresh round.")
|
||||
}
|
||||
if !res.InVocabB || res.Similarity == nil {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("🤔 <code>%s</code> isn't in the vocabulary.", html.EscapeString(guess)))
|
||||
}
|
||||
canonical := guess
|
||||
if res.CanonicalB != nil && *res.CanonicalB != "" {
|
||||
canonical = strings.ToLower(*res.CanonicalB)
|
||||
}
|
||||
|
||||
for _, g := range game.Guesses {
|
||||
if g.Canonical == canonical {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("🔁 <b>%s</b> was already guessed this round — try another word.",
|
||||
html.EscapeString(canonical)))
|
||||
}
|
||||
}
|
||||
|
||||
entry := Guess{Word: guess, Canonical: canonical, Similarity: *res.Similarity}
|
||||
game.Guesses = append(game.Guesses, entry)
|
||||
if game.StartedAt == nil {
|
||||
now := chathelper.NowMillis()
|
||||
game.StartedAt = &now
|
||||
}
|
||||
|
||||
if entry.Canonical == game.Target {
|
||||
game.Solved = true
|
||||
count := len(game.Guesses)
|
||||
if _, err := recordResult(ctx, s.kv, subject, true, count, chathelper.NowMillis()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
board := renderBoard(game.Guesses, entry.Canonical)
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("%s\n✅ Solved in %d guess%s!", board, count, plural(count)))
|
||||
}
|
||||
|
||||
if err := saveGame(ctx, s.kv, subject, game); err != nil {
|
||||
return err
|
||||
}
|
||||
body := renderGuess(entry) + "\n" + renderBoard(game.Guesses, entry.Canonical)
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, body)
|
||||
}
|
||||
|
||||
// playableWord matches lowercase Unicode letter / mark / underscore tokens.
|
||||
// Used to filter neighbor responses that include foreign place names.
|
||||
var playableWord = regexp.MustCompile(`^[\p{Ll}\p{M}_]+$`)
|
||||
|
||||
func looksVietnamese(word string) bool {
|
||||
if strings.Contains(word, "_") {
|
||||
return true
|
||||
}
|
||||
for _, r := range word {
|
||||
if r > 0x7f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *state) pickHintWords(target string, neighbors []Neighbor, alreadyGuessed []string, count int) []Neighbor {
|
||||
guessedSet := make(map[string]struct{}, len(alreadyGuessed))
|
||||
for _, g := range alreadyGuessed {
|
||||
guessedSet[g] = struct{}{}
|
||||
}
|
||||
var playable []Neighbor
|
||||
for _, n := range neighbors {
|
||||
if !playableWord.MatchString(n.Word) || !looksVietnamese(n.Word) {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(n.Word, target) || strings.Contains(target, n.Word) {
|
||||
continue
|
||||
}
|
||||
if _, dup := guessedSet[n.Word]; dup {
|
||||
continue
|
||||
}
|
||||
playable = append(playable, n)
|
||||
}
|
||||
// Skip top 20% so hints stay "warm but not hot" (JS-parity).
|
||||
skip := len(playable) / 5
|
||||
if skip > 20 {
|
||||
skip = 20
|
||||
}
|
||||
if skip >= len(playable) {
|
||||
return nil
|
||||
}
|
||||
pool := playable[skip:]
|
||||
want := count
|
||||
if want > len(pool) {
|
||||
want = len(pool)
|
||||
}
|
||||
if want <= 0 {
|
||||
return nil
|
||||
}
|
||||
// Reservoir-style sample without replacement.
|
||||
s.rngMu.Lock()
|
||||
defer s.rngMu.Unlock()
|
||||
idx := s.rng.Perm(len(pool))[:want]
|
||||
out := make([]Neighbor, want)
|
||||
for i, j := range idx {
|
||||
out[i] = pool[j]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *state) handleHint(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
game, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if game == nil || game.Solved {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
"No active round. Send <code>/doantu</code> to start one.")
|
||||
}
|
||||
res, err := s.api.Neighbors(ctx, game.Target, 100)
|
||||
if err != nil {
|
||||
log.Warn("doantu neighbors failed", "err", err)
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, upstreamFail)
|
||||
}
|
||||
already := make([]string, 0, len(game.Guesses))
|
||||
for _, g := range game.Guesses {
|
||||
already = append(already, g.Canonical)
|
||||
}
|
||||
picks := s.pickHintWords(game.Target, res.Neighbors, already, 3)
|
||||
if len(picks) == 0 {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "🤷 No usable hints available for this round.")
|
||||
}
|
||||
var lines []string
|
||||
for _, p := range picks {
|
||||
lines = append(lines, fmt.Sprintf("• <code>%s</code>", html.EscapeString(p.Word)))
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
"💡 <b>Hints</b> — related words (not the answer):\n"+strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
game, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if game == nil {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
"No active round. Send <code>/doantu</code> to start one.")
|
||||
}
|
||||
if _, err := recordResult(ctx, s.kv, subject, false, len(game.Guesses), chathelper.NowMillis()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("🏳️ The target was <b>%s</b>. Send <code>/doantu</code> for a new round.",
|
||||
html.EscapeString(game.Target)))
|
||||
}
|
||||
|
||||
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
st, err := loadStats(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if st.Played == 0 {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "No doantu games played yet.")
|
||||
}
|
||||
solveRate := chathelper.WinRate(st.Solved, st.Played)
|
||||
avg := "—"
|
||||
if st.Played > 0 {
|
||||
avg = fmt.Sprintf("%d", roundDiv(st.TotalGuesses, st.Played))
|
||||
}
|
||||
best := "—"
|
||||
if st.BestGuessCount != nil {
|
||||
best = fmt.Sprintf("%d", *st.BestGuessCount)
|
||||
}
|
||||
body := fmt.Sprintf(
|
||||
"🇻🇳 <b>Đoán từ stats</b>\nPlayed: %d\nSolved: %d (%d%%)\nTotal guesses: %d\nFewest to solve: %s\nAvg per round: %s",
|
||||
st.Played, st.Solved, solveRate, st.TotalGuesses, best, avg,
|
||||
)
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, body)
|
||||
}
|
||||
|
||||
// roundDiv rounds (a/b) half-away-from-zero (JS Math.round parity for non-neg).
|
||||
func roundDiv(a, b int) int {
|
||||
if b <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (a*2 + b) / (2 * b)
|
||||
}
|
||||
|
||||
// asUpstream is a helper for tests that want to assert specific error types
|
||||
// without leaking internals. Currently unused outside the package.
|
||||
var _ = errors.As
|
||||
@@ -1,24 +0,0 @@
|
||||
package doantu
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// shapeRe: Unicode letters + combining marks, single space between syllables
|
||||
// for compound words (`con chó`, `máy bay`). Mirrors JS lookup.js.
|
||||
var shapeRe = regexp.MustCompile(`^[\p{L}\p{M}]+(?: [\p{L}\p{M}]+)*$`)
|
||||
|
||||
func normalize(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(raw)), " "))
|
||||
}
|
||||
|
||||
func isValidShape(word string) bool {
|
||||
if word == "" || len(word) > 64 {
|
||||
return false
|
||||
}
|
||||
return shapeRe.MatchString(word)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package doantu
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{" Con chó ", "con chó"},
|
||||
{"Máy Bay", "máy bay"},
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := normalize(c.in); got != c.want {
|
||||
t.Errorf("normalize(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidShape(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"con chó", true},
|
||||
{"máy bay", true},
|
||||
{"hello", true},
|
||||
{"", false},
|
||||
{"abc 123", false}, // digits
|
||||
{"abc!", false}, // punctuation
|
||||
{" ", false}, // empty after collapse
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isValidShape(c.in); got != c.want {
|
||||
t.Errorf("isValidShape(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLooksVietnamese(t *testing.T) {
|
||||
cases := []struct {
|
||||
w string
|
||||
want bool
|
||||
}{
|
||||
{"con", false}, // pure ASCII, no underscore
|
||||
{"chó", true}, // diacritic
|
||||
{"thanh_pho", true}, // compound
|
||||
{"hello", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := looksVietnamese(c.w); got != c.want {
|
||||
t.Errorf("looksVietnamese(%q) = %v, want %v", c.w, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package doantu
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
maxRows = 15
|
||||
latestMarker = "➡️"
|
||||
plainMarker = " "
|
||||
maxWordWidth = 20
|
||||
)
|
||||
|
||||
func renderBoard(guesses []Guess, latestCanonical string) string {
|
||||
count := len(guesses)
|
||||
header := fmt.Sprintf("🇻🇳 Đoán từ — %d guess%s", count, plural(count))
|
||||
if count == 0 {
|
||||
return header + "\n🆕 Round ready — reply with <code>/doantu <word></code>."
|
||||
}
|
||||
|
||||
sorted := make([]Guess, len(guesses))
|
||||
copy(sorted, guesses)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
return sorted[i].Similarity > sorted[j].Similarity
|
||||
})
|
||||
if len(sorted) > maxRows {
|
||||
sorted = sorted[:maxRows]
|
||||
}
|
||||
|
||||
wordWidth := 0
|
||||
for _, g := range sorted {
|
||||
// Use rune count for visual width — Vietnamese diacritics matter.
|
||||
if l := utf8.RuneCountInString(g.Canonical); l > wordWidth {
|
||||
wordWidth = l
|
||||
}
|
||||
}
|
||||
if wordWidth > maxWordWidth {
|
||||
wordWidth = maxWordWidth
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for i, g := range sorted {
|
||||
score := calibrate(g.Similarity)
|
||||
marker := plainMarker
|
||||
if g.Canonical == latestCanonical {
|
||||
marker = latestMarker
|
||||
}
|
||||
rank := padLeft(fmt.Sprintf("%d", i+1), 2)
|
||||
warmth := padLeft(formatWarmth(score), 3)
|
||||
word := html.EscapeString(padRunesRight(g.Canonical, wordWidth))
|
||||
lines = append(lines, fmt.Sprintf("%s %s %s %s %s", marker, rank, warmth, word, warmthEmoji(score)))
|
||||
}
|
||||
|
||||
body := "<pre>" + strings.Join(lines, "\n") + "</pre>"
|
||||
footer := ""
|
||||
if hidden := count - len(sorted); hidden > 0 {
|
||||
footer = fmt.Sprintf("\n…%d older guess%s hidden.", hidden, plural(hidden))
|
||||
}
|
||||
return header + "\n" + body + footer
|
||||
}
|
||||
|
||||
func renderGuess(g Guess) string {
|
||||
score := calibrate(g.Similarity)
|
||||
return fmt.Sprintf("<code>%s</code> → %s %s",
|
||||
html.EscapeString(g.Canonical), formatWarmth(score), warmthEmoji(score))
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "es"
|
||||
}
|
||||
|
||||
func padLeft(s string, w int) string {
|
||||
pad := w - len(s)
|
||||
if pad <= 0 {
|
||||
return s
|
||||
}
|
||||
return strings.Repeat(" ", pad) + s
|
||||
}
|
||||
|
||||
func padRunesRight(s string, w int) string {
|
||||
pad := w - utf8.RuneCountInString(s)
|
||||
if pad <= 0 {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", pad)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package doantu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
type Guess struct {
|
||||
Word string `json:"word"`
|
||||
Canonical string `json:"canonical"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
}
|
||||
|
||||
type GameState struct {
|
||||
Target string `json:"target"`
|
||||
StartedAt *int64 `json:"startedAt"`
|
||||
Solved bool `json:"solved"`
|
||||
Guesses []Guess `json:"guesses"`
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
Played int `json:"played"`
|
||||
Solved int `json:"solved"`
|
||||
TotalGuesses int `json:"totalGuesses"`
|
||||
BestGuessCount *int `json:"bestGuessCount"`
|
||||
LastResultAt *int64 `json:"lastResultAt"`
|
||||
}
|
||||
|
||||
func gameKey(subject string) string { return "game:" + subject }
|
||||
func statsKey(subject string) string { return "stats:" + subject }
|
||||
|
||||
func loadGame(ctx context.Context, kv storage.KVStore, subject string) (*GameState, error) {
|
||||
var g GameState
|
||||
err := kv.GetJSON(ctx, gameKey(subject), &g)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &g, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("doantu loadGame: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func saveGame(ctx context.Context, kv storage.KVStore, subject string, g *GameState) error {
|
||||
if err := kv.PutJSON(ctx, gameKey(subject), g); err != nil {
|
||||
return fmt.Errorf("doantu saveGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearGame(ctx context.Context, kv storage.KVStore, subject string) error {
|
||||
if err := kv.Delete(ctx, gameKey(subject)); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
return fmt.Errorf("doantu clearGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*Stats, error) {
|
||||
var s Stats
|
||||
err := kv.GetJSON(ctx, statsKey(subject), &s)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &s, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return &Stats{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("doantu loadStats: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func recordResult(ctx context.Context, kv storage.KVStore, subject string, solved bool, guessCount int, nowMillis int64) (*Stats, error) {
|
||||
s, err := loadStats(ctx, kv, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Played++
|
||||
s.TotalGuesses += guessCount
|
||||
if solved {
|
||||
s.Solved++
|
||||
if s.BestGuessCount == nil || guessCount < *s.BestGuessCount {
|
||||
gc := guessCount
|
||||
s.BestGuessCount = &gc
|
||||
}
|
||||
}
|
||||
now := nowMillis
|
||||
s.LastResultAt = &now
|
||||
if err := kv.PutJSON(ctx, statsKey(subject), s); err != nil {
|
||||
return nil, fmt.Errorf("doantu recordResult: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/champname"
|
||||
"github.com/tiennm99/miti99bot-go/internal/keylock"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
@@ -26,9 +25,6 @@ type state struct {
|
||||
locks keylock.Map // serialises Get→mutate→Put per subject
|
||||
}
|
||||
|
||||
// championName extracts the comparable name field for champname helpers.
|
||||
func championName(c *Champion) string { return c.ChampionName }
|
||||
|
||||
// pickRandomChampion uses math/rand's mutex-protected globals so concurrent
|
||||
// /loldle handlers don't race on a shared *rand.Rand.
|
||||
func (s *state) pickRandomChampion() *Champion {
|
||||
@@ -36,7 +32,7 @@ func (s *state) pickRandomChampion() *Champion {
|
||||
}
|
||||
|
||||
func (s *state) findByName(name string) *Champion {
|
||||
return champname.FindByExactName(s.champions, name, championName)
|
||||
return findChampionByExactName(s.champions, name)
|
||||
}
|
||||
|
||||
// rehydrateGuesses recomputes board rows from the stored championNames.
|
||||
@@ -127,7 +123,7 @@ func (s *state) handleLoldle(ctx context.Context, b *bot.Bot, update *models.Upd
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, header+"\n\n"+board)
|
||||
}
|
||||
|
||||
guess := champname.Find(s.champions, arg, championName)
|
||||
guess := findChampion(s.champions, arg)
|
||||
if guess == nil {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Champion not found: %q.", arg))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package loldle
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizeName folds a name to a comparable form: lowercase, alphanumeric
|
||||
// only. JS-parity with util/normalize-name.js — `String(s).toLowerCase().
|
||||
// replace(/[^a-z0-9]/g, "")`. Used for case/space/punctuation-insensitive
|
||||
// lookup so "Kai'Sa", "kaisa", and "KAI SA" all collapse to the same key.
|
||||
func normalizeName(s string) string {
|
||||
lower := strings.ToLower(s)
|
||||
out := make([]byte, 0, len(lower))
|
||||
for i := 0; i < len(lower); i++ {
|
||||
c := lower[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// findChampion resolves user input to a champion via:
|
||||
//
|
||||
// 1. Empty / non-alphanumeric input → no match (nil).
|
||||
// 2. Exact normalised name → that champion.
|
||||
// 3. Otherwise: unique normalised-prefix match → that champion.
|
||||
//
|
||||
// Ambiguous prefix or no hit → nil. Returning nil for ambiguous prefixes
|
||||
// prevents "Ka" silently routing to whichever Ka- champion happens to be
|
||||
// first in the data file.
|
||||
func findChampion(pool []Champion, input string) *Champion {
|
||||
q := normalizeName(input)
|
||||
if q == "" {
|
||||
return nil
|
||||
}
|
||||
for i := range pool {
|
||||
if normalizeName(pool[i].ChampionName) == q {
|
||||
return &pool[i]
|
||||
}
|
||||
}
|
||||
var hit *Champion
|
||||
for i := range pool {
|
||||
if strings.HasPrefix(normalizeName(pool[i].ChampionName), q) {
|
||||
if hit != nil {
|
||||
return nil // ambiguous
|
||||
}
|
||||
hit = &pool[i]
|
||||
}
|
||||
}
|
||||
return hit
|
||||
}
|
||||
|
||||
// findChampionByExactName looks up a champion by literal display name (no
|
||||
// normalization). Used to rehydrate a stored game's target. Returns nil if
|
||||
// the pool was refreshed and the target is no longer present.
|
||||
func findChampionByExactName(pool []Champion, name string) *Champion {
|
||||
for i := range pool {
|
||||
if pool[i].ChampionName == name {
|
||||
return &pool[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,20 +1,13 @@
|
||||
package loldle
|
||||
|
||||
import (
|
||||
"testing"
|
||||
import "testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/champname"
|
||||
)
|
||||
|
||||
// Generic name lookup primitives (Normalize, Find, FindByExactName) live in
|
||||
// internal/champname and are tested there. This file only exercises the
|
||||
// embedded champions.json — wiring + shape integration test.
|
||||
func TestLoadChampions_EmbedIsValid(t *testing.T) {
|
||||
cs := loadChampions()
|
||||
if n := len(cs); n < 150 || n > 200 {
|
||||
t.Errorf("champion count = %d, want ~172", n)
|
||||
}
|
||||
got := champname.FindByExactName(cs, "Aatrox", championName)
|
||||
got := findChampionByExactName(cs, "Aatrox")
|
||||
if got == nil {
|
||||
t.Fatal("expected Aatrox in embedded list")
|
||||
}
|
||||
@@ -22,3 +15,33 @@ func TestLoadChampions_EmbedIsValid(t *testing.T) {
|
||||
t.Errorf("Aatrox shape unexpected: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindChampion(t *testing.T) {
|
||||
pool := []Champion{{ChampionName: "Aatrox"}, {ChampionName: "Ahri"}, {ChampionName: "Kai'Sa"}}
|
||||
cases := []struct {
|
||||
input string
|
||||
want string // "" means no match
|
||||
}{
|
||||
{"Aatrox", "Aatrox"},
|
||||
{"aatrox", "Aatrox"},
|
||||
{"kaisa", "Kai'Sa"},
|
||||
{"KAI SA", "Kai'Sa"},
|
||||
{"Aat", "Aatrox"}, // unique prefix
|
||||
{"A", ""}, // ambiguous prefix
|
||||
{"", ""}, // empty
|
||||
{"!!!", ""}, // no alphanumerics
|
||||
{"zed", ""}, // no match
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := findChampion(pool, tc.input)
|
||||
if tc.want == "" {
|
||||
if got != nil {
|
||||
t.Errorf("findChampion(%q) = %+v, want nil", tc.input, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got == nil || got.ChampionName != tc.want {
|
||||
t.Errorf("findChampion(%q) = %+v, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
// Package loldleability ports the JS loldle-ability variant — guess the
|
||||
// champion from a single ability icon. Pool seeded from Riot Data Dragon
|
||||
// (passive + Q/W/E/R for each champion). Uses Telegram's sendPhoto with the
|
||||
// DDragon CDN URL as the file source — no binary embedding.
|
||||
//
|
||||
// Round state persists `{target, slot, guesses, startedAt}` so the SAME
|
||||
// ability icon shows across all turns until the round ends.
|
||||
package loldleability
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Ability is one of P/Q/W/E/R for a champion.
|
||||
type Ability struct {
|
||||
Slot string `json:"slot"` // P, Q, W, E, R
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"` // absolute DDragon CDN URL
|
||||
}
|
||||
|
||||
// AbilityChampion is one record of abilities.json — championName + the full
|
||||
// ability list.
|
||||
type AbilityChampion struct {
|
||||
ChampionName string `json:"championName"`
|
||||
Key string `json:"key"` // DDragon internal id; not used by handlers but kept for parity
|
||||
Abilities []Ability `json:"abilities"`
|
||||
}
|
||||
|
||||
//go:embed data/abilities.json
|
||||
var rawAbilities []byte
|
||||
|
||||
// loadPool parses abilities.json and drops champions with no abilities.
|
||||
// Panics on malformed data — corrupt regen is a build-time bug.
|
||||
func loadPool() []AbilityChampion {
|
||||
var all []AbilityChampion
|
||||
if err := json.Unmarshal(rawAbilities, &all); err != nil {
|
||||
panic(fmt.Sprintf("loldleability: cannot decode abilities.json: %v", err))
|
||||
}
|
||||
out := make([]AbilityChampion, 0, len(all))
|
||||
for _, c := range all {
|
||||
if len(c.Abilities) > 0 {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
panic("loldleability: abilities.json contained no usable records")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// abilityBySlot finds the ability with the given slot ("Q", "W", ...).
|
||||
// Returns nil when the slot is unknown — caller treats that as a refresh
|
||||
// signal (start over).
|
||||
func abilityBySlot(c *AbilityChampion, slot string) *Ability {
|
||||
for i := range c.Abilities {
|
||||
if c.Abilities[i].Slot == slot {
|
||||
return &c.Abilities[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,256 +0,0 @@
|
||||
package loldleability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/champname"
|
||||
"github.com/tiennm99/miti99bot-go/internal/keylock"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
const newRoundHint = "🆕 Send <code>/loldle_ability</code> or <code>/loldle_ability <champion></code> to start a new round."
|
||||
|
||||
// state captures everything a loldle-ability handler needs at runtime.
|
||||
// Built once per Factory call and shared across the four command closures.
|
||||
type state struct {
|
||||
kv storage.KVStore
|
||||
pool []AbilityChampion
|
||||
locks keylock.Map // serialises Get→mutate→Put per subject
|
||||
}
|
||||
|
||||
// championName extracts the comparable name field for champname helpers.
|
||||
func championName(c *AbilityChampion) string { return c.ChampionName }
|
||||
|
||||
func (s *state) pickRandomChampion() *AbilityChampion {
|
||||
return &s.pool[rand.Intn(len(s.pool))]
|
||||
}
|
||||
|
||||
func pickRandomAbility(c *AbilityChampion) *Ability {
|
||||
return &c.Abilities[rand.Intn(len(c.Abilities))]
|
||||
}
|
||||
|
||||
func (s *state) startFreshGame(ctx context.Context, subject string) (*gameState, error) {
|
||||
target := s.pickRandomChampion()
|
||||
ability := pickRandomAbility(target)
|
||||
g := &gameState{
|
||||
Target: target.ChampionName,
|
||||
Slot: ability.Slot,
|
||||
Guesses: []string{},
|
||||
StartedAt: nil,
|
||||
}
|
||||
if err := saveGame(ctx, s.kv, subject, g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (s *state) getOrInitGame(ctx context.Context, subject string, maxGuesses int) (*gameState, error) {
|
||||
existing, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil && len(existing.Guesses) < maxGuesses {
|
||||
return existing, nil
|
||||
}
|
||||
return s.startFreshGame(ctx, subject)
|
||||
}
|
||||
|
||||
// caption is the photo caption shown above each round-in-progress icon.
|
||||
func caption(guesses, maxGuesses int) string {
|
||||
return fmt.Sprintf("🔮 Guess the champion from this ability. %d/%d guesses so far.", guesses, maxGuesses)
|
||||
}
|
||||
|
||||
// sendAbilityIcon dispatches a sendPhoto with the ability icon URL. Returns
|
||||
// the bot library's error verbatim — caller decides whether to log/ignore.
|
||||
func sendAbilityIcon(ctx context.Context, b *bot.Bot, chatID int64, ability *Ability, captionText string) error {
|
||||
_, err := b.SendPhoto(ctx, &bot.SendPhotoParams{
|
||||
ChatID: chatID,
|
||||
Photo: &models.InputFileString{Data: ability.Icon},
|
||||
Caption: captionText,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// handleAbility is /loldle_ability [champion] — show icon if no arg, else guess.
|
||||
func (s *state) handleAbility(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
|
||||
maxGuesses, err := getMaxGuesses(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
game, err := s.getOrInitGame(ctx, subject, maxGuesses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := champname.FindByExactName(s.pool, game.Target, championName)
|
||||
var ability *Ability
|
||||
if target != nil {
|
||||
ability = abilityBySlot(target, game.Slot)
|
||||
}
|
||||
if target == nil || ability == nil {
|
||||
// Pool was refreshed mid-round and the slot is gone — drop the round.
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
"Ability data was updated since this round started. "+newRoundHint)
|
||||
}
|
||||
|
||||
if arg == "" {
|
||||
return sendAbilityIcon(ctx, b, msg.Chat.ID, ability, caption(len(game.Guesses), maxGuesses))
|
||||
}
|
||||
|
||||
guess := champname.Find(s.pool, arg, championName)
|
||||
if guess == nil {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Champion not found: %q.", arg))
|
||||
}
|
||||
|
||||
for _, prior := range game.Guesses {
|
||||
if prior == guess.ChampionName {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"🔁 <b>%s</b> was already guessed this round — try another champion.",
|
||||
html.EscapeString(guess.ChampionName)))
|
||||
}
|
||||
}
|
||||
|
||||
if game.StartedAt == nil {
|
||||
now := chathelper.NowMillis()
|
||||
game.StartedAt = &now
|
||||
}
|
||||
game.Guesses = append(game.Guesses, guess.ChampionName)
|
||||
won := guess.ChampionName == target.ChampionName
|
||||
answer := html.EscapeString(target.ChampionName)
|
||||
abilityLabel := fmt.Sprintf("<i>%s</i> (%s)", html.EscapeString(ability.Name), ability.Slot)
|
||||
|
||||
switch {
|
||||
case won:
|
||||
st, err := recordResult(ctx, s.kv, subject, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"🎉 Got it! That was <b>%s</b> — %s. Solved in %d/%d\n🔥 Streak: %d\n%s",
|
||||
answer, abilityLabel, len(game.Guesses), maxGuesses, st.Streak, newRoundHint))
|
||||
|
||||
case len(game.Guesses) >= maxGuesses:
|
||||
if _, err := recordResult(ctx, s.kv, subject, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"❌ Out of guesses. Answer was <b>%s</b> — %s.\n%s",
|
||||
answer, abilityLabel, newRoundHint))
|
||||
|
||||
default:
|
||||
if err := saveGame(ctx, s.kv, subject, game); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"❌ Not <b>%s</b>. Guess %d/%d.",
|
||||
html.EscapeString(guess.ChampionName), len(game.Guesses), maxGuesses))
|
||||
}
|
||||
}
|
||||
|
||||
// handleGiveup is /loldle_ability_giveup — reveal answer + clear round.
|
||||
func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
|
||||
existing, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, "No active round. "+newRoundHint)
|
||||
}
|
||||
if _, err := recordResult(ctx, s.kv, subject, false); err != nil {
|
||||
return err
|
||||
}
|
||||
target := champname.FindByExactName(s.pool, existing.Target, championName)
|
||||
var label string
|
||||
if target != nil {
|
||||
if a := abilityBySlot(target, existing.Slot); a != nil {
|
||||
label = fmt.Sprintf(" — <i>%s</i> (%s)", html.EscapeString(a.Name), a.Slot)
|
||||
}
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"🏳️ Answer was <b>%s</b>%s.\n%s",
|
||||
html.EscapeString(existing.Target), label, newRoundHint))
|
||||
}
|
||||
|
||||
// handleStats is /loldle_ability_stats — lifetime score.
|
||||
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
st, err := loadStats(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scope := "group"
|
||||
if msg.Chat.Type == models.ChatTypePrivate {
|
||||
scope = "your"
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"📊 Loldle Ability %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d",
|
||||
scope, st.Played, st.Wins, chathelper.WinRate(st.Wins, st.Played), st.Streak, st.BestStreak))
|
||||
}
|
||||
|
||||
// handleSetMax is /loldle_ability_setmax <n> — private; per-subject override.
|
||||
func (s *state) handleSetMax(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
n, err := strconv.Atoi(arg)
|
||||
if err != nil || n < 1 || n > MaxGuessesCap {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Usage: /loldle_ability_setmax <1-%d>", MaxGuessesCap))
|
||||
}
|
||||
if err := setMaxGuesses(ctx, s.kv, subject, n); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("✅ Loldle ability max guesses set to %d (applies to the next round).", n))
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package loldleability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
"github.com/tiennm99/miti99bot-go/internal/testutil"
|
||||
)
|
||||
|
||||
// installAbility wires the loldle-ability module + auth (owner gates
|
||||
// /loldle_ability_setmax). seedTarget + seedSlot pre-seed a game so guess
|
||||
// outcomes are deterministic without hooking math/rand.
|
||||
func installAbility(t *testing.T, ownerID int64, seedSubject, seedTarget, seedSlot string) (*testutil.RecordingBot, storage.KVStore) {
|
||||
t.Helper()
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
provider := storage.NewMemoryProvider()
|
||||
kv := provider.For("loldle-ability")
|
||||
mod := New(modules.Deps{KV: kv})
|
||||
reg := &modules.Registry{
|
||||
Modules: []modules.Module{{Name: "loldle-ability", Commands: mod.Commands}},
|
||||
AllCommands: map[string]modules.Command{},
|
||||
}
|
||||
for _, c := range mod.Commands {
|
||||
reg.AllCommands[c.Name] = c
|
||||
}
|
||||
modules.Install(rb.Bot, reg, modules.Auth{BotOwnerID: ownerID})
|
||||
|
||||
if seedTarget != "" {
|
||||
g := &gameState{Target: seedTarget, Slot: seedSlot, Guesses: []string{}}
|
||||
if err := saveGame(context.Background(), kv, seedSubject, g); err != nil {
|
||||
t.Fatalf("seed game: %v", err)
|
||||
}
|
||||
}
|
||||
return rb, kv
|
||||
}
|
||||
|
||||
// /loldle_ability with no arg sends a photo, not a text message.
|
||||
func TestAbility_NoArgSendsPhoto(t *testing.T) {
|
||||
rb, _ := installAbility(t, 0, "1", "Aatrox", "Q")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_ability"))
|
||||
|
||||
calls := rb.Sent()
|
||||
if len(calls) == 0 {
|
||||
t.Fatal("/loldle_ability produced no reply")
|
||||
}
|
||||
last := calls[len(calls)-1]
|
||||
if last.Method != "sendPhoto" {
|
||||
t.Errorf("method = %q, want sendPhoto", last.Method)
|
||||
}
|
||||
// Aatrox Q icon — DDragon URL pattern.
|
||||
photo := last.Form["photo"]
|
||||
if !strings.Contains(photo, "AatroxQ") || !strings.Contains(photo, "ddragon.leagueoflegends.com") {
|
||||
t.Errorf("photo = %q, want Aatrox Q DDragon URL", photo)
|
||||
}
|
||||
caption := last.Form["caption"]
|
||||
if !strings.Contains(caption, "Guess the champion") {
|
||||
t.Errorf("caption missing prompt: %q", caption)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbility_Win(t *testing.T) {
|
||||
rb, _ := installAbility(t, 0, "1", "Aatrox", "Q")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_ability aatrox"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Got it") {
|
||||
t.Errorf("win reply missing 'Got it': %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Aatrox") {
|
||||
t.Errorf("win reply missing 'Aatrox': %q", got)
|
||||
}
|
||||
// Ability label format: <i>Name</i> (Slot) — the slot must surface so the
|
||||
// player sees which ability the bot was thinking of.
|
||||
if !strings.Contains(got, "(Q)") {
|
||||
t.Errorf("win reply missing slot tag (Q): %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbility_UnknownChampion(t *testing.T) {
|
||||
rb, _ := installAbility(t, 0, "1", "Aatrox", "Q")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_ability ZilbeanZ"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Champion not found") {
|
||||
t.Errorf("unknown champion reject: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbility_DuplicateGuessRejected(t *testing.T) {
|
||||
rb, _ := installAbility(t, 0, "1", "Aatrox", "Q")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_ability ahri"))
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_ability ahri"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "already guessed") {
|
||||
t.Errorf("duplicate-guess reply: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbilityGiveup_RevealsAnswerAndAbility(t *testing.T) {
|
||||
rb, _ := installAbility(t, 0, "1", "Aatrox", "Q")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_ability_giveup"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Aatrox") {
|
||||
t.Errorf("/loldle_ability_giveup should reveal Aatrox: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "(Q)") {
|
||||
t.Errorf("/loldle_ability_giveup should include slot label: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbilityStats_Empty(t *testing.T) {
|
||||
rb, _ := installAbility(t, 0, "", "", "")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_ability_stats"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
for _, want := range []string{"Played: 0", "Wins: 0 (0%)"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("/loldle_ability_stats empty missing %q; got %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbilitySetMax_OwnerSucceeds(t *testing.T) {
|
||||
rb, kv := installAbility(t, 999, "", "", "")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_ability_setmax 3"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "max guesses set to 3") {
|
||||
t.Errorf("/loldle_ability_setmax reply: %q", got)
|
||||
}
|
||||
var cfg roundConfig
|
||||
if err := kv.GetJSON(context.Background(), configKey("999"), &cfg); err != nil {
|
||||
t.Fatalf("expected config persisted: %v", err)
|
||||
}
|
||||
if cfg.MaxGuesses != 3 {
|
||||
t.Errorf("MaxGuesses persisted = %d, want 3", cfg.MaxGuesses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbilitySetMax_DeniedToNonOwner(t *testing.T) {
|
||||
rb, _ := installAbility(t, 999, "", "", "")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/loldle_ability_setmax 5"))
|
||||
if calls := rb.Sent(); len(calls) != 0 {
|
||||
t.Errorf("non-owner /loldle_ability_setmax replied: %+v", calls)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package loldleability
|
||||
|
||||
import (
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
)
|
||||
|
||||
// New is the loldle-ability module Factory. Loads the embedded pool once
|
||||
// and shares it (plus the per-subject lock map) across all handlers.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
s := &state{kv: deps.KV, pool: loadPool()}
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "loldle_ability",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Ability loldle — guess the champion from an ability icon",
|
||||
Handler: s.handleAbility,
|
||||
},
|
||||
{
|
||||
Name: "loldle_ability_giveup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Reveal the current ability loldle answer",
|
||||
Handler: s.handleGiveup,
|
||||
},
|
||||
{
|
||||
Name: "loldle_ability_stats",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show your ability loldle stats (wins, streak)",
|
||||
Handler: s.handleStats,
|
||||
},
|
||||
{
|
||||
Name: "loldle_ability_setmax",
|
||||
Visibility: modules.VisibilityPrivate,
|
||||
Description: "Override ability loldle max guesses per round (1-10)",
|
||||
Handler: s.handleSetMax,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package loldleability
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/champname"
|
||||
)
|
||||
|
||||
func TestLoadPool_AbilitiesNonEmpty(t *testing.T) {
|
||||
pool := loadPool()
|
||||
if n := len(pool); n < 150 || n > 200 {
|
||||
t.Errorf("pool size = %d, want ~172", n)
|
||||
}
|
||||
for _, c := range pool {
|
||||
if len(c.Abilities) == 0 {
|
||||
t.Errorf("empty abilities record leaked through filter: %s", c.ChampionName)
|
||||
}
|
||||
}
|
||||
got := champname.FindByExactName(pool, "Aatrox", championName)
|
||||
if got == nil {
|
||||
t.Fatal("expected Aatrox in pool")
|
||||
}
|
||||
// Aatrox should have all 5 standard ability slots present.
|
||||
slots := map[string]bool{}
|
||||
for _, a := range got.Abilities {
|
||||
slots[a.Slot] = true
|
||||
if !strings.HasPrefix(a.Icon, "https://ddragon.leagueoflegends.com/cdn/") {
|
||||
t.Errorf("Aatrox ability %s icon is not a DDragon URL: %q", a.Slot, a.Icon)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"P", "Q", "W", "E", "R"} {
|
||||
if !slots[want] {
|
||||
t.Errorf("Aatrox missing slot %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbilityBySlot(t *testing.T) {
|
||||
c := &AbilityChampion{
|
||||
ChampionName: "Test",
|
||||
Abilities: []Ability{
|
||||
{Slot: "P", Name: "Passive"},
|
||||
{Slot: "Q", Name: "Q ability"},
|
||||
{Slot: "R", Name: "R ability"},
|
||||
},
|
||||
}
|
||||
if got := abilityBySlot(c, "Q"); got == nil || got.Name != "Q ability" {
|
||||
t.Errorf("abilityBySlot(Q) = %v, want 'Q ability'", got)
|
||||
}
|
||||
// Unknown slot → nil (caller treats as refresh signal).
|
||||
if got := abilityBySlot(c, "W"); got != nil {
|
||||
t.Errorf("abilityBySlot(W) = %v, want nil (slot not present)", got)
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package loldleability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
// Round-length defaults. Mirror JS: 5 default, capped at 10 via
|
||||
// /loldle_ability_setmax.
|
||||
const (
|
||||
MaxGuesses = 5
|
||||
MaxGuessesCap = 10
|
||||
)
|
||||
|
||||
// gameState differs from emoji/quote: it locks the chosen ability slot at
|
||||
// round start so the SAME icon shows across every turn until the round ends.
|
||||
// Field tags match JS.
|
||||
type gameState struct {
|
||||
Target string `json:"target"`
|
||||
Slot string `json:"slot"` // ability slot — P, Q, W, E, R
|
||||
Guesses []string `json:"guesses"`
|
||||
StartedAt *int64 `json:"startedAt"`
|
||||
}
|
||||
|
||||
type stats struct {
|
||||
Played int `json:"played"`
|
||||
Wins int `json:"wins"`
|
||||
Streak int `json:"streak"`
|
||||
BestStreak int `json:"bestStreak"`
|
||||
}
|
||||
|
||||
type roundConfig struct {
|
||||
MaxGuesses int `json:"maxGuesses"`
|
||||
}
|
||||
|
||||
func gameKey(subject string) string { return "game:" + subject }
|
||||
func statsKey(subject string) string { return "stats:" + subject }
|
||||
func configKey(subject string) string { return "config:" + subject }
|
||||
|
||||
func loadGame(ctx context.Context, kv storage.KVStore, subject string) (*gameState, error) {
|
||||
var g gameState
|
||||
err := kv.GetJSON(ctx, gameKey(subject), &g)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &g, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("loldleability loadGame: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func saveGame(ctx context.Context, kv storage.KVStore, subject string, g *gameState) error {
|
||||
if err := kv.PutJSON(ctx, gameKey(subject), g); err != nil {
|
||||
return fmt.Errorf("loldleability saveGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearGame(ctx context.Context, kv storage.KVStore, subject string) error {
|
||||
if err := kv.Delete(ctx, gameKey(subject)); err != nil {
|
||||
return fmt.Errorf("loldleability clearGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*stats, error) {
|
||||
var s stats
|
||||
err := kv.GetJSON(ctx, statsKey(subject), &s)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &s, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return &stats{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("loldleability loadStats: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func recordResult(ctx context.Context, kv storage.KVStore, subject string, won bool) (*stats, error) {
|
||||
s, err := loadStats(ctx, kv, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Played++
|
||||
if won {
|
||||
s.Wins++
|
||||
s.Streak++
|
||||
if s.Streak > s.BestStreak {
|
||||
s.BestStreak = s.Streak
|
||||
}
|
||||
} else {
|
||||
s.Streak = 0
|
||||
}
|
||||
if err := kv.PutJSON(ctx, statsKey(subject), s); err != nil {
|
||||
return nil, fmt.Errorf("loldleability recordResult: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func getMaxGuesses(ctx context.Context, kv storage.KVStore, subject string) (int, error) {
|
||||
var cfg roundConfig
|
||||
err := kv.GetJSON(ctx, configKey(subject), &cfg)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
return MaxGuesses, nil
|
||||
}
|
||||
return 0, fmt.Errorf("loldleability getMaxGuesses: %w", err)
|
||||
}
|
||||
if cfg.MaxGuesses < 1 || cfg.MaxGuesses > MaxGuessesCap {
|
||||
return MaxGuesses, nil
|
||||
}
|
||||
return cfg.MaxGuesses, nil
|
||||
}
|
||||
|
||||
func setMaxGuesses(ctx context.Context, kv storage.KVStore, subject string, n int) error {
|
||||
if n < 1 || n > MaxGuessesCap {
|
||||
return fmt.Errorf("loldleability: maxGuesses must be in [1, %d], got %d", MaxGuessesCap, n)
|
||||
}
|
||||
if err := kv.PutJSON(ctx, configKey(subject), roundConfig{MaxGuesses: n}); err != nil {
|
||||
return fmt.Errorf("loldleability setMaxGuesses: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package loldleability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
// gameState gains a `slot` field vs emoji/quote — locks the chosen ability
|
||||
// at round start. JSON wire format must include `slot`.
|
||||
func TestGameState_IncludesSlotField(t *testing.T) {
|
||||
g := gameState{Target: "Aatrox", Slot: "Q", Guesses: []string{}}
|
||||
b, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"target":"Aatrox","slot":"Q","guesses":[],"startedAt":null}`
|
||||
if string(b) != want {
|
||||
t.Errorf("marshal:\ngot %s\nwant %s", b, want)
|
||||
}
|
||||
}
|
||||
|
||||
// JS-wire-format decode parity: a record written by the JS bot must decode
|
||||
// directly. Locks the slot field name + null-startedAt round-trip.
|
||||
func TestGameState_DecodeFromJSWire(t *testing.T) {
|
||||
var g gameState
|
||||
raw := []byte(`{"target":"Ahri","slot":"E","guesses":["Akali"],"startedAt":1700000000000}`)
|
||||
if err := json.Unmarshal(raw, &g); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if g.Target != "Ahri" || g.Slot != "E" || len(g.Guesses) != 1 || g.StartedAt == nil || *g.StartedAt != 1700000000000 {
|
||||
t.Errorf("decoded: %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMaxGuesses_DefaultsToFive(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := storage.NewMemoryKVStore()
|
||||
if n, _ := getMaxGuesses(ctx, kv, "u1"); n != MaxGuesses {
|
||||
t.Errorf("default = %d, want %d", n, MaxGuesses)
|
||||
}
|
||||
if MaxGuesses != 5 {
|
||||
t.Errorf("MaxGuesses = %d, want 5 (parity with JS)", MaxGuesses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordResult_StreakSequence(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := storage.NewMemoryKVStore()
|
||||
s, _ := recordResult(ctx, kv, "u1", true)
|
||||
if s.Streak != 1 || s.Wins != 1 {
|
||||
t.Errorf("first win: %+v", s)
|
||||
}
|
||||
s, _ = recordResult(ctx, kv, "u1", false)
|
||||
if s.Streak != 0 || s.BestStreak != 1 {
|
||||
t.Errorf("loss after streak=1: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLoadClear_RoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := storage.NewMemoryKVStore()
|
||||
at := int64(42)
|
||||
want := &gameState{Target: "Aatrox", Slot: "R", Guesses: []string{"Ahri"}, StartedAt: &at}
|
||||
if err := saveGame(ctx, kv, "u1", want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := loadGame(ctx, kv, "u1")
|
||||
if got == nil || got.Slot != "R" {
|
||||
t.Errorf("round-trip lost slot: %+v", got)
|
||||
}
|
||||
if err := clearGame(ctx, kv, "u1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = loadGame(ctx, kv, "u1")
|
||||
if got != nil {
|
||||
t.Errorf("after clear, got %+v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Package loldleemoji ports the JS loldle-emoji variant — guess the
|
||||
// champion from a short emoji clue. Binary right/wrong scoring (no attribute
|
||||
// comparison); shares the per-subject lifecycle pattern with classic loldle
|
||||
// but uses its own KV namespace so stats are isolated.
|
||||
package loldleemoji
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EmojiChampion is one record of emojis.json. Only championName + emojis
|
||||
// matter for this variant; the JS source keeps the same shape so the embed
|
||||
// file lifts unmodified.
|
||||
type EmojiChampion struct {
|
||||
ChampionName string `json:"championName"`
|
||||
Emojis string `json:"emojis"`
|
||||
}
|
||||
|
||||
//go:embed data/emojis.json
|
||||
var rawEmojis []byte
|
||||
|
||||
// loadPool parses emojis.json and drops any record with an empty `emojis`
|
||||
// string (matching the JS `pool.filter(...)` at the top of handlers.js).
|
||||
// Panics on malformed data — a corrupt regen of the data file is a build-
|
||||
// time bug, not a runtime concern worth recovering from.
|
||||
func loadPool() []EmojiChampion {
|
||||
var all []EmojiChampion
|
||||
if err := json.Unmarshal(rawEmojis, &all); err != nil {
|
||||
panic(fmt.Sprintf("loldleemoji: cannot decode emojis.json: %v", err))
|
||||
}
|
||||
out := make([]EmojiChampion, 0, len(all))
|
||||
for _, c := range all {
|
||||
if strings.TrimSpace(c.Emojis) != "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
panic("loldleemoji: emojis.json contained no usable records")
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,690 +0,0 @@
|
||||
[
|
||||
{
|
||||
"championName": "Aatrox",
|
||||
"emojis": "⚔️ 🌍 💪"
|
||||
},
|
||||
{
|
||||
"championName": "Ahri",
|
||||
"emojis": "🦊 🏯 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Akali",
|
||||
"emojis": "🧝 🏯 ⚡"
|
||||
},
|
||||
{
|
||||
"championName": "Akshan",
|
||||
"emojis": "🧝 🏜️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Alistar",
|
||||
"emojis": "🐂 🌍 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Ambessa",
|
||||
"emojis": "🧝 ⚙️ ⚡"
|
||||
},
|
||||
{
|
||||
"championName": "Amumu",
|
||||
"emojis": "💀 🏜️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Anivia",
|
||||
"emojis": "👻 ❄️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Annie",
|
||||
"emojis": "🧝 🗡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Aphelios",
|
||||
"emojis": "🧝 ⛰️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Ashe",
|
||||
"emojis": "🧝 ❄️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Aurelion Sol",
|
||||
"emojis": "🌟 🌍 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Aurora",
|
||||
"emojis": "🦊 ❄️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Azir",
|
||||
"emojis": "⚜️ 🏜️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Bard",
|
||||
"emojis": "🌟 🌍 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Bel'Veth",
|
||||
"emojis": "👁️ 👾 💪"
|
||||
},
|
||||
{
|
||||
"championName": "Blitzcrank",
|
||||
"emojis": "🗿 🧪 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Brand",
|
||||
"emojis": "🧝 ❄️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Braum",
|
||||
"emojis": "🧝 ❄️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Briar",
|
||||
"emojis": "🗿 🗡️ ❤️🩹"
|
||||
},
|
||||
{
|
||||
"championName": "Caitlyn",
|
||||
"emojis": "🧝 ⚙️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Camille",
|
||||
"emojis": "🧝 ⚙️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Cassiopeia",
|
||||
"emojis": "🧝 🗡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Cho'Gath",
|
||||
"emojis": "👁️ 👾 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Corki",
|
||||
"emojis": "🧚 🏡 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Darius",
|
||||
"emojis": "🧝 🗡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Diana",
|
||||
"emojis": "🧝 ⛰️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Dr. Mundo",
|
||||
"emojis": "🧝 🧪 ❤️🩹"
|
||||
},
|
||||
{
|
||||
"championName": "Draven",
|
||||
"emojis": "🧝 🗡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Ekko",
|
||||
"emojis": "🧝 🧪 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Elise",
|
||||
"emojis": "🧝 🌫️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Evelynn",
|
||||
"emojis": "😈 🌍 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Ezreal",
|
||||
"emojis": "🧝 ⚙️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Fiddlesticks",
|
||||
"emojis": "😈 🌍 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Fiora",
|
||||
"emojis": "🧝 🛡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Fizz",
|
||||
"emojis": "🧚 ⚓ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Galio",
|
||||
"emojis": "🗿 🛡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Gangplank",
|
||||
"emojis": "🧝 ⚓ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Garen",
|
||||
"emojis": "🧝 🛡️ 💪"
|
||||
},
|
||||
{
|
||||
"championName": "Gnar",
|
||||
"emojis": "🧚 ❄️ 😡"
|
||||
},
|
||||
{
|
||||
"championName": "Gragas",
|
||||
"emojis": "🧝 ❄️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Graves",
|
||||
"emojis": "🧝 ⚓ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Gwen",
|
||||
"emojis": "🧝 🌫️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Hecarim",
|
||||
"emojis": "💀 🌫️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Heimerdinger",
|
||||
"emojis": "🧚 ⚙️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Hwei",
|
||||
"emojis": "🧝 🏯 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Illaoi",
|
||||
"emojis": "🧝 ⚓ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Irelia",
|
||||
"emojis": "🧝 🏯 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Ivern",
|
||||
"emojis": "🧝 🏯 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Janna",
|
||||
"emojis": "👻 🧪 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Jarvan IV",
|
||||
"emojis": "🧝 🛡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Jax",
|
||||
"emojis": "❓ 🌍 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Jayce",
|
||||
"emojis": "🧝 ⚙️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Jhin",
|
||||
"emojis": "🧝 🏯 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Jinx",
|
||||
"emojis": "🧝 🧪 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "K'Sante",
|
||||
"emojis": "🧝 🏜️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Kai'Sa",
|
||||
"emojis": "🧝 🏜️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Kalista",
|
||||
"emojis": "💀 🌫️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Karma",
|
||||
"emojis": "🧝 🏯 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Karthus",
|
||||
"emojis": "💀 🌫️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Kassadin",
|
||||
"emojis": "🧝 🏜️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Katarina",
|
||||
"emojis": "🧝 🗡️ 💪"
|
||||
},
|
||||
{
|
||||
"championName": "Kayle",
|
||||
"emojis": "🧝 🛡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Kayn",
|
||||
"emojis": "⚔️ 🏯 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Kennen",
|
||||
"emojis": "🧚 🏯 ⚡"
|
||||
},
|
||||
{
|
||||
"championName": "Kha'Zix",
|
||||
"emojis": "👁️ 👾 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Kindred",
|
||||
"emojis": "👻 🌍 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Kled",
|
||||
"emojis": "🧚 🗡️ 🦁"
|
||||
},
|
||||
{
|
||||
"championName": "Kog'Maw",
|
||||
"emojis": "👁️ 👾 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "LeBlanc",
|
||||
"emojis": "🧝 🗡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Lee Sin",
|
||||
"emojis": "🧝 🏯 ⚡"
|
||||
},
|
||||
{
|
||||
"championName": "Leona",
|
||||
"emojis": "🧝 ⛰️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Lillia",
|
||||
"emojis": "👻 🏯 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Lissandra",
|
||||
"emojis": "🧝 ❄️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Lucian",
|
||||
"emojis": "🧝 🛡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Lulu",
|
||||
"emojis": "🧚 🏡 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Lux",
|
||||
"emojis": "🧝 🛡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Malphite",
|
||||
"emojis": "🗿 🌿 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Malzahar",
|
||||
"emojis": "🧝 🏜️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Maokai",
|
||||
"emojis": "👻 🌫️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Master Yi",
|
||||
"emojis": "🧝 🏯 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Mel",
|
||||
"emojis": "🧝 🗡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Milio",
|
||||
"emojis": "🧝 🌿 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Miss Fortune",
|
||||
"emojis": "🧝 ⚓ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Mordekaiser",
|
||||
"emojis": "👻 🗡️ 🛡️"
|
||||
},
|
||||
{
|
||||
"championName": "Morgana",
|
||||
"emojis": "🧝 🛡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Naafiri",
|
||||
"emojis": "🐕 🏜️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Nami",
|
||||
"emojis": "🦊 🌍 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Nasus",
|
||||
"emojis": "⚜️ 🏜️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Nautilus",
|
||||
"emojis": "👻 ⚓ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Neeko",
|
||||
"emojis": "🦊 🌿 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Nidalee",
|
||||
"emojis": "🧝 🌿 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Nilah",
|
||||
"emojis": "🧝 ⚓ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Nocturne",
|
||||
"emojis": "😈 🌍 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Nunu & Willump",
|
||||
"emojis": "🧝 ❄️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Olaf",
|
||||
"emojis": "🧝 ❄️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Orianna",
|
||||
"emojis": "🗿 ⚙️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Ornn",
|
||||
"emojis": "👻 ❄️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Pantheon",
|
||||
"emojis": "🧝 ⛰️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Poppy",
|
||||
"emojis": "🧚 🛡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Pyke",
|
||||
"emojis": "👻 ⚓ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Qiyana",
|
||||
"emojis": "🧝 🌿 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Quinn",
|
||||
"emojis": "🧝 🛡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Rakan",
|
||||
"emojis": "🦊 🏯 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Rammus",
|
||||
"emojis": "❓ 🏜️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Rek'Sai",
|
||||
"emojis": "👁️ 🏜️ 😡"
|
||||
},
|
||||
{
|
||||
"championName": "Rell",
|
||||
"emojis": "🧝 🗡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Renata Glasc",
|
||||
"emojis": "🧝 🧪 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Renekton",
|
||||
"emojis": "⚜️ 🏜️ 💢"
|
||||
},
|
||||
{
|
||||
"championName": "Rengar",
|
||||
"emojis": "🦊 🌿 🔥"
|
||||
},
|
||||
{
|
||||
"championName": "Riven",
|
||||
"emojis": "🧝 🏯 💪"
|
||||
},
|
||||
{
|
||||
"championName": "Rumble",
|
||||
"emojis": "🧚 🏡 🔥"
|
||||
},
|
||||
{
|
||||
"championName": "Ryze",
|
||||
"emojis": "🧝 🌍 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Samira",
|
||||
"emojis": "🧝 🏜️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Sejuani",
|
||||
"emojis": "🧝 ❄️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Senna",
|
||||
"emojis": "🧝 🌫️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Seraphine",
|
||||
"emojis": "🧝 ⚙️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Sett",
|
||||
"emojis": "🧝 🏯 🪨"
|
||||
},
|
||||
{
|
||||
"championName": "Shaco",
|
||||
"emojis": "👻 🌍 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Shen",
|
||||
"emojis": "🧝 🏯 ⚡"
|
||||
},
|
||||
{
|
||||
"championName": "Shyvana",
|
||||
"emojis": "🐉 🛡️ 💢"
|
||||
},
|
||||
{
|
||||
"championName": "Singed",
|
||||
"emojis": "🧝 🧪 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Sion",
|
||||
"emojis": "👻 🗡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Sivir",
|
||||
"emojis": "🧝 🏜️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Skarner",
|
||||
"emojis": "🦂 🌿 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Smolder",
|
||||
"emojis": "🐉 ⚜️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Sona",
|
||||
"emojis": "🧝 🛡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Soraka",
|
||||
"emojis": "🌟 ⛰️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Swain",
|
||||
"emojis": "🧝 🗡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Sylas",
|
||||
"emojis": "🧝 🛡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Syndra",
|
||||
"emojis": "🧝 🏯 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Tahm Kench",
|
||||
"emojis": "😈 ⚓ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Taliyah",
|
||||
"emojis": "🧝 🏜️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Talon",
|
||||
"emojis": "🧝 🗡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Taric",
|
||||
"emojis": "🧝 ⛰️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Teemo",
|
||||
"emojis": "🧚 🏡 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Thresh",
|
||||
"emojis": "💀 🌫️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Tristana",
|
||||
"emojis": "🧚 🏡 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Trundle",
|
||||
"emojis": "🧌 ❄️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Tryndamere",
|
||||
"emojis": "🧝 ❄️ 💢"
|
||||
},
|
||||
{
|
||||
"championName": "Twisted Fate",
|
||||
"emojis": "🧝 ⚓ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Twitch",
|
||||
"emojis": "🐀 🧪 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Udyr",
|
||||
"emojis": "🧝 ❄️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Urgot",
|
||||
"emojis": "🧝 🗡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Varus",
|
||||
"emojis": "⚔️ 🏯 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Vayne",
|
||||
"emojis": "🧝 🛡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Veigar",
|
||||
"emojis": "🧚 🌍 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Vel'Koz",
|
||||
"emojis": "👁️ 👾 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Vex",
|
||||
"emojis": "🧚 🌫️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Vi",
|
||||
"emojis": "🧝 ⚙️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Viego",
|
||||
"emojis": "💀 🌫️ 💪"
|
||||
},
|
||||
{
|
||||
"championName": "Viktor",
|
||||
"emojis": "🧝 ⚙️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Vladimir",
|
||||
"emojis": "🧝 🗡️ 🩸"
|
||||
},
|
||||
{
|
||||
"championName": "Volibear",
|
||||
"emojis": "👻 ❄️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Warwick",
|
||||
"emojis": "🧝 🧪 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Wukong",
|
||||
"emojis": "🦊 🏯 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Xayah",
|
||||
"emojis": "🦊 🏯 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Xerath",
|
||||
"emojis": "💀 🏜️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Xin Zhao",
|
||||
"emojis": "🧝 🛡️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Yasuo",
|
||||
"emojis": "🧝 🏯 💧"
|
||||
},
|
||||
{
|
||||
"championName": "Yone",
|
||||
"emojis": "🧝 🏯 💪"
|
||||
},
|
||||
{
|
||||
"championName": "Yorick",
|
||||
"emojis": "🧝 🌫️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Yunara",
|
||||
"emojis": "🧝 🏯 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Yuumi",
|
||||
"emojis": "🐱 🏡 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Zaahen",
|
||||
"emojis": "⚔️ 🏜️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Zac",
|
||||
"emojis": "🗿 🧪 ❤️🩹"
|
||||
},
|
||||
{
|
||||
"championName": "Zed",
|
||||
"emojis": "🧝 🏯 ⚡"
|
||||
},
|
||||
{
|
||||
"championName": "Zeri",
|
||||
"emojis": "🧝 🧪 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Ziggs",
|
||||
"emojis": "🧚 🧪 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Zilean",
|
||||
"emojis": "🧝 🌍 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Zoe",
|
||||
"emojis": "🧝 ⛰️ 🔮"
|
||||
},
|
||||
{
|
||||
"championName": "Zyra",
|
||||
"emojis": "🧝 🌿 🔮"
|
||||
}
|
||||
]
|
||||
@@ -1,218 +0,0 @@
|
||||
package loldleemoji
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/champname"
|
||||
"github.com/tiennm99/miti99bot-go/internal/keylock"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
const newRoundHint = "🆕 Send <code>/loldle_emoji</code> or <code>/loldle_emoji <champion></code> to start a new round."
|
||||
|
||||
// state captures everything a loldle-emoji handler needs at runtime. Built
|
||||
// once per Factory call and shared across the four command closures.
|
||||
type state struct {
|
||||
kv storage.KVStore
|
||||
pool []EmojiChampion
|
||||
locks keylock.Map // serialises Get→mutate→Put per subject
|
||||
}
|
||||
|
||||
// championName extracts the comparable name field for champname helpers.
|
||||
func championName(c *EmojiChampion) string { return c.ChampionName }
|
||||
|
||||
func (s *state) pickRandom() *EmojiChampion {
|
||||
return &s.pool[rand.Intn(len(s.pool))]
|
||||
}
|
||||
|
||||
func (s *state) startFreshGame(ctx context.Context, subject string) (*gameState, error) {
|
||||
target := s.pickRandom()
|
||||
g := &gameState{Target: target.ChampionName, Guesses: []string{}, StartedAt: nil}
|
||||
if err := saveGame(ctx, s.kv, subject, g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (s *state) getOrInitGame(ctx context.Context, subject string, maxGuesses int) (*gameState, error) {
|
||||
existing, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil && len(existing.Guesses) < maxGuesses {
|
||||
return existing, nil
|
||||
}
|
||||
return s.startFreshGame(ctx, subject)
|
||||
}
|
||||
|
||||
// handleEmoji is /loldle_emoji [champion] — show clue if no arg, else guess.
|
||||
func (s *state) handleEmoji(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
|
||||
maxGuesses, err := getMaxGuesses(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
game, err := s.getOrInitGame(ctx, subject, maxGuesses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := champname.FindByExactName(s.pool, game.Target, championName)
|
||||
if target == nil {
|
||||
// Pool refreshed mid-round and the target is gone — start over.
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
"Emoji data was updated since this round started. "+newRoundHint)
|
||||
}
|
||||
|
||||
if arg == "" {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, renderBoard(target.Emojis, game.Guesses, maxGuesses))
|
||||
}
|
||||
|
||||
guess := champname.Find(s.pool, arg, championName)
|
||||
if guess == nil {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Champion not found: %q.", arg))
|
||||
}
|
||||
|
||||
for _, prior := range game.Guesses {
|
||||
if prior == guess.ChampionName {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"🔁 <b>%s</b> was already guessed this round — try another champion.",
|
||||
html.EscapeString(guess.ChampionName)))
|
||||
}
|
||||
}
|
||||
|
||||
if game.StartedAt == nil {
|
||||
now := chathelper.NowMillis()
|
||||
game.StartedAt = &now
|
||||
}
|
||||
game.Guesses = append(game.Guesses, guess.ChampionName)
|
||||
won := guess.ChampionName == target.ChampionName
|
||||
answer := html.EscapeString(target.ChampionName)
|
||||
|
||||
switch {
|
||||
case won:
|
||||
st, err := recordResult(ctx, s.kv, subject, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"🎉 Got it! <b>%s</b> — solved in %d/%d\n🔥 Streak: %d\n%s",
|
||||
answer, len(game.Guesses), maxGuesses, st.Streak, newRoundHint))
|
||||
|
||||
case len(game.Guesses) >= maxGuesses:
|
||||
if _, err := recordResult(ctx, s.kv, subject, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"%s\n\n❌ Out of guesses. Answer was <b>%s</b>.\n%s",
|
||||
renderBoard(target.Emojis, game.Guesses, maxGuesses), answer, newRoundHint))
|
||||
|
||||
default:
|
||||
if err := saveGame(ctx, s.kv, subject, game); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"%s\n\n❌ Not <b>%s</b>. Guess %d/%d.",
|
||||
renderBoard(target.Emojis, game.Guesses, maxGuesses),
|
||||
html.EscapeString(guess.ChampionName), len(game.Guesses), maxGuesses))
|
||||
}
|
||||
}
|
||||
|
||||
// handleGiveup is /loldle_emoji_giveup — reveal answer + clear round.
|
||||
func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
|
||||
existing, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, "No active round. "+newRoundHint)
|
||||
}
|
||||
if _, err := recordResult(ctx, s.kv, subject, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"🏳️ Answer was <b>%s</b>.\n%s", html.EscapeString(existing.Target), newRoundHint))
|
||||
}
|
||||
|
||||
// handleStats is /loldle_emoji_stats — lifetime score.
|
||||
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
st, err := loadStats(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scope := "group"
|
||||
if msg.Chat.Type == models.ChatTypePrivate {
|
||||
scope = "your"
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"📊 Loldle Emoji %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d",
|
||||
scope, st.Played, st.Wins, chathelper.WinRate(st.Wins, st.Played), st.Streak, st.BestStreak))
|
||||
}
|
||||
|
||||
// handleSetMax is /loldle_emoji_setmax <n> — private; per-subject override.
|
||||
func (s *state) handleSetMax(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
n, err := strconv.Atoi(arg)
|
||||
if err != nil || n < 1 || n > MaxGuessesCap {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Usage: /loldle_emoji_setmax <1-%d>", MaxGuessesCap))
|
||||
}
|
||||
if err := setMaxGuesses(ctx, s.kv, subject, n); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("✅ Loldle emoji max guesses set to %d (applies to the next round).", n))
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package loldleemoji
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
"github.com/tiennm99/miti99bot-go/internal/testutil"
|
||||
)
|
||||
|
||||
// installEmoji wires the loldle-emoji module + auth (owner gates
|
||||
// /loldle_emoji_setmax). seedTarget pre-seeds a game so guess outcomes are
|
||||
// deterministic without hooking math/rand.
|
||||
func installEmoji(t *testing.T, ownerID int64, seedSubject, seedTarget string) (*testutil.RecordingBot, storage.KVStore) {
|
||||
t.Helper()
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
provider := storage.NewMemoryProvider()
|
||||
kv := provider.For("loldle-emoji")
|
||||
mod := New(modules.Deps{KV: kv})
|
||||
reg := &modules.Registry{
|
||||
Modules: []modules.Module{{Name: "loldle-emoji", Commands: mod.Commands}},
|
||||
AllCommands: map[string]modules.Command{},
|
||||
}
|
||||
for _, c := range mod.Commands {
|
||||
reg.AllCommands[c.Name] = c
|
||||
}
|
||||
modules.Install(rb.Bot, reg, modules.Auth{BotOwnerID: ownerID})
|
||||
|
||||
if seedTarget != "" {
|
||||
g := &gameState{Target: seedTarget, Guesses: []string{}}
|
||||
if err := saveGame(context.Background(), kv, seedSubject, g); err != nil {
|
||||
t.Fatalf("seed game: %v", err)
|
||||
}
|
||||
}
|
||||
return rb, kv
|
||||
}
|
||||
|
||||
func TestEmoji_NoArgShowsClue(t *testing.T) {
|
||||
rb, _ := installEmoji(t, 0, "1", "Aatrox")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_emoji"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "🎭") {
|
||||
t.Errorf("emoji clue marker missing: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmoji_Win(t *testing.T) {
|
||||
rb, _ := installEmoji(t, 0, "1", "Aatrox")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_emoji aatrox"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Got it") {
|
||||
t.Errorf("win reply missing 'Got it': %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Aatrox") {
|
||||
t.Errorf("win reply missing 'Aatrox': %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmoji_UnknownChampion(t *testing.T) {
|
||||
rb, _ := installEmoji(t, 0, "1", "Aatrox")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_emoji ZilbeanZ"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Champion not found") {
|
||||
t.Errorf("unknown champion reject: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmoji_DuplicateGuessRejected(t *testing.T) {
|
||||
rb, _ := installEmoji(t, 0, "1", "Aatrox")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_emoji ahri"))
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_emoji ahri"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "already guessed") {
|
||||
t.Errorf("duplicate-guess reply: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmojiGiveup_RevealsAnswer(t *testing.T) {
|
||||
rb, _ := installEmoji(t, 0, "1", "Aatrox")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_emoji_giveup"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Aatrox") {
|
||||
t.Errorf("/loldle_emoji_giveup should reveal Aatrox: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmojiStats_Empty(t *testing.T) {
|
||||
rb, _ := installEmoji(t, 0, "", "")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_emoji_stats"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
for _, want := range []string{"Played: 0", "Wins: 0 (0%)"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("/loldle_emoji_stats empty missing %q; got %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmojiSetMax_OwnerSucceeds(t *testing.T) {
|
||||
rb, kv := installEmoji(t, 999, "", "")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_emoji_setmax 7"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "max guesses set to 7") {
|
||||
t.Errorf("/loldle_emoji_setmax reply: %q", got)
|
||||
}
|
||||
var cfg roundConfig
|
||||
if err := kv.GetJSON(context.Background(), configKey("999"), &cfg); err != nil {
|
||||
t.Fatalf("expected config persisted: %v", err)
|
||||
}
|
||||
if cfg.MaxGuesses != 7 {
|
||||
t.Errorf("MaxGuesses persisted = %d, want 7", cfg.MaxGuesses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmojiSetMax_DeniedToNonOwner(t *testing.T) {
|
||||
rb, _ := installEmoji(t, 999, "", "")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/loldle_emoji_setmax 5"))
|
||||
if calls := rb.Sent(); len(calls) != 0 {
|
||||
t.Errorf("non-owner /loldle_emoji_setmax replied: %+v", calls)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package loldleemoji
|
||||
|
||||
import (
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
)
|
||||
|
||||
// New is the loldle-emoji module Factory. Loads the embedded pool once and
|
||||
// shares it (plus the per-subject lock map) across all handlers.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
s := &state{kv: deps.KV, pool: loadPool()}
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "loldle_emoji",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Emoji loldle — guess the champion from emojis",
|
||||
Handler: s.handleEmoji,
|
||||
},
|
||||
{
|
||||
Name: "loldle_emoji_giveup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Reveal the current emoji loldle answer",
|
||||
Handler: s.handleGiveup,
|
||||
},
|
||||
{
|
||||
Name: "loldle_emoji_stats",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show your emoji loldle stats (wins, streak)",
|
||||
Handler: s.handleStats,
|
||||
},
|
||||
{
|
||||
Name: "loldle_emoji_setmax",
|
||||
Visibility: modules.VisibilityPrivate,
|
||||
Description: "Override emoji loldle max guesses per round (1-10)",
|
||||
Handler: s.handleSetMax,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package loldleemoji
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/champname"
|
||||
)
|
||||
|
||||
// Generic lookup primitives live in internal/champname. This file only
|
||||
// exercises the embedded pool — wiring + filter integration test.
|
||||
func TestLoadPool_DropsEmptyEmojiRecords(t *testing.T) {
|
||||
pool := loadPool()
|
||||
if n := len(pool); n < 150 || n > 200 {
|
||||
t.Errorf("pool size = %d, want ~172", n)
|
||||
}
|
||||
for _, c := range pool {
|
||||
if c.Emojis == "" {
|
||||
t.Errorf("empty-emoji record leaked through filter: %s", c.ChampionName)
|
||||
}
|
||||
}
|
||||
if got := champname.FindByExactName(pool, "Aatrox", championName); got == nil {
|
||||
t.Error("expected Aatrox in pool")
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package loldleemoji
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// renderBoard formats the emoji clue + the wrong-guess list. JS-faithful:
|
||||
//
|
||||
// 🎭 ⚔️ 🌍 💪
|
||||
//
|
||||
// Guesses (2/5):
|
||||
// • Aatrox ❌
|
||||
// • Ahri ❌
|
||||
//
|
||||
// Empty board returns the placeholder hint. emojis is build-time data (embedded
|
||||
// JSON) so practically safe, but we escape defensively — Telegram's HTML parse
|
||||
// mode rejects unknown tags and the page-level invariant is "no unescaped
|
||||
// user/data input in HTML output".
|
||||
func renderBoard(emojis string, guesses []string, maxGuesses int) string {
|
||||
clue := "🎭 " + html.EscapeString(emojis)
|
||||
if len(guesses) == 0 {
|
||||
return clue + "\n\nNo guesses yet. Reply with <code>/loldle_emoji <champion></code>."
|
||||
}
|
||||
lines := make([]string, len(guesses))
|
||||
for i, name := range guesses {
|
||||
lines[i] = " • " + html.EscapeString(name) + " ❌"
|
||||
}
|
||||
return fmt.Sprintf("%s\n\nGuesses (%d/%d):\n%s",
|
||||
clue, len(guesses), maxGuesses, strings.Join(lines, "\n"))
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package loldleemoji
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderBoard_EmptyShowsHint(t *testing.T) {
|
||||
out := renderBoard("⚔️ 🌍", nil, 5)
|
||||
if !strings.Contains(out, "🎭 ⚔️ 🌍") {
|
||||
t.Errorf("emoji clue missing: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "No guesses yet") {
|
||||
t.Errorf("placeholder missing: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderBoard_ListsGuessesWithCounter(t *testing.T) {
|
||||
out := renderBoard("⚔️ 🌍", []string{"Aatrox", "Ahri"}, 5)
|
||||
if !strings.Contains(out, "Guesses (2/5)") {
|
||||
t.Errorf("counter missing: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, " • Aatrox ❌") {
|
||||
t.Errorf("first guess line missing: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, " • Ahri ❌") {
|
||||
t.Errorf("second guess line missing: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderBoard_EscapesHTMLInGuessNames(t *testing.T) {
|
||||
// Champion names from the dict are static strings without HTML metachars,
|
||||
// but render escapes defensively. Prove it.
|
||||
out := renderBoard("⚔️", []string{"<script>"}, 5)
|
||||
if !strings.Contains(out, "<script>") {
|
||||
t.Errorf("html metachars not escaped: %q", out)
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package loldleemoji
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
// Round-length defaults. Mirror JS: 5 default, capped at 10 via
|
||||
// /loldle_emoji_setmax. (Classic loldle uses 8 by default — different game,
|
||||
// different round length is intentional.)
|
||||
const (
|
||||
MaxGuesses = 5
|
||||
MaxGuessesCap = 10
|
||||
)
|
||||
|
||||
// gameState is the per-subject KV record. Field tags match JS exactly;
|
||||
// StartedAt is *int64 for `null | number` parity (timer doesn't tick until
|
||||
// the player submits their first actual guess).
|
||||
type gameState struct {
|
||||
Target string `json:"target"`
|
||||
Guesses []string `json:"guesses"`
|
||||
StartedAt *int64 `json:"startedAt"`
|
||||
}
|
||||
|
||||
// stats lifetime score. Matches JS shape — no LastResultAt (parity with
|
||||
// classic loldle stats, which also omits it).
|
||||
type stats struct {
|
||||
Played int `json:"played"`
|
||||
Wins int `json:"wins"`
|
||||
Streak int `json:"streak"`
|
||||
BestStreak int `json:"bestStreak"`
|
||||
}
|
||||
|
||||
type roundConfig struct {
|
||||
MaxGuesses int `json:"maxGuesses"`
|
||||
}
|
||||
|
||||
func gameKey(subject string) string { return "game:" + subject }
|
||||
func statsKey(subject string) string { return "stats:" + subject }
|
||||
func configKey(subject string) string { return "config:" + subject }
|
||||
|
||||
func loadGame(ctx context.Context, kv storage.KVStore, subject string) (*gameState, error) {
|
||||
var g gameState
|
||||
err := kv.GetJSON(ctx, gameKey(subject), &g)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &g, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("loldleemoji 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("loldleemoji saveGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearGame(ctx context.Context, kv storage.KVStore, subject string) error {
|
||||
if err := kv.Delete(ctx, gameKey(subject)); err != nil {
|
||||
return fmt.Errorf("loldleemoji clearGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*stats, error) {
|
||||
var s stats
|
||||
err := kv.GetJSON(ctx, statsKey(subject), &s)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &s, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return &stats{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("loldleemoji 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("loldleemoji recordResult: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func getMaxGuesses(ctx context.Context, kv storage.KVStore, subject string) (int, error) {
|
||||
var cfg roundConfig
|
||||
err := kv.GetJSON(ctx, configKey(subject), &cfg)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
return MaxGuesses, nil
|
||||
}
|
||||
return 0, fmt.Errorf("loldleemoji getMaxGuesses: %w", err)
|
||||
}
|
||||
if cfg.MaxGuesses < 1 || cfg.MaxGuesses > MaxGuessesCap {
|
||||
return MaxGuesses, nil
|
||||
}
|
||||
return cfg.MaxGuesses, nil
|
||||
}
|
||||
|
||||
func setMaxGuesses(ctx context.Context, kv storage.KVStore, subject string, n int) error {
|
||||
if n < 1 || n > MaxGuessesCap {
|
||||
return fmt.Errorf("loldleemoji: 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("loldleemoji setMaxGuesses: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package loldleemoji
|
||||
|
||||
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_NoLastResultAt(t *testing.T) {
|
||||
// Parity with classic loldle: emoji stats also omit lastResultAt.
|
||||
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_StreakSequence(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := storage.NewMemoryKVStore()
|
||||
|
||||
s, err := recordResult(ctx, kv, "u1", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Streak != 1 || s.BestStreak != 1 || s.Wins != 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 || s.BestStreak != 2 {
|
||||
t.Errorf("loss: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMaxGuesses_DefaultsToFive(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := storage.NewMemoryKVStore()
|
||||
if n, _ := getMaxGuesses(ctx, kv, "u1"); n != MaxGuesses {
|
||||
t.Errorf("default = %d, want %d", n, MaxGuesses)
|
||||
}
|
||||
if MaxGuesses != 5 {
|
||||
t.Errorf("MaxGuesses = %d, want 5 (parity with JS)", MaxGuesses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetMaxGuesses_RoundTripAndValidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := storage.NewMemoryKVStore()
|
||||
if err := setMaxGuesses(ctx, kv, "u1", 3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := getMaxGuesses(ctx, kv, "u1"); n != 3 {
|
||||
t.Errorf("after set(3): %d", n)
|
||||
}
|
||||
for _, n := range []int{0, -1, MaxGuessesCap + 1} {
|
||||
if err := setMaxGuesses(ctx, kv, "u1", n); err == nil {
|
||||
t.Errorf("setMaxGuesses(%d) should error", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStateShapes_DecodeFromJSWire locks the migration contract: a raw
|
||||
// record written by the JS bot must decode into the Go structs without a
|
||||
// custom decoder. Captures byte-shape (not just our own round-trip).
|
||||
func TestStateShapes_DecodeFromJSWire(t *testing.T) {
|
||||
t.Run("game with null startedAt", func(t *testing.T) {
|
||||
var g gameState
|
||||
raw := []byte(`{"target":"Aatrox","guesses":[],"startedAt":null}`)
|
||||
if err := json.Unmarshal(raw, &g); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if g.Target != "Aatrox" || len(g.Guesses) != 0 || g.StartedAt != nil {
|
||||
t.Errorf("decoded: %+v", g)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("game with numeric startedAt and guesses", func(t *testing.T) {
|
||||
var g gameState
|
||||
raw := []byte(`{"target":"Ahri","guesses":["Aatrox","Akali"],"startedAt":1700000000000}`)
|
||||
if err := json.Unmarshal(raw, &g); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if g.Target != "Ahri" || len(g.Guesses) != 2 || g.StartedAt == nil || *g.StartedAt != 1700000000000 {
|
||||
t.Errorf("decoded: %+v (StartedAt=%v)", g, g.StartedAt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stats", func(t *testing.T) {
|
||||
var s stats
|
||||
raw := []byte(`{"played":7,"wins":4,"streak":2,"bestStreak":3}`)
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if s.Played != 7 || s.Wins != 4 || s.Streak != 2 || s.BestStreak != 3 {
|
||||
t.Errorf("decoded: %+v", s)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("config", func(t *testing.T) {
|
||||
var c roundConfig
|
||||
raw := []byte(`{"maxGuesses":7}`)
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if c.MaxGuesses != 7 {
|
||||
t.Errorf("decoded: %+v", c)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSaveLoadClear_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, _ := loadGame(ctx, kv, "u1")
|
||||
if got == nil || got.Target != "Aatrox" || 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 clear, got %+v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
// Package loldlequote ports the JS loldle-quote variant — guess the champion
|
||||
// from a one-sentence lore blurb. Binary right/wrong scoring (no attribute
|
||||
// comparison); shares the per-subject lifecycle pattern with classic loldle
|
||||
// + loldle-emoji but uses its own KV namespace so stats are isolated.
|
||||
package loldlequote
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// QuoteChampion is one record of quotes.json. The JS source keeps the same
|
||||
// shape so the embed file lifts unmodified — championName + a one-sentence
|
||||
// lore blurb where the champion's name is replaced with `___`.
|
||||
type QuoteChampion struct {
|
||||
ChampionName string `json:"championName"`
|
||||
Quote string `json:"quote"`
|
||||
}
|
||||
|
||||
//go:embed data/quotes.json
|
||||
var rawQuotes []byte
|
||||
|
||||
// loadPool parses quotes.json and drops any record with an empty/whitespace
|
||||
// `quote` field (matching the JS `pool.filter(...)` at the top of
|
||||
// handlers.js). Panics on malformed data — a corrupt regen of the data file
|
||||
// is a build-time bug, not a runtime concern.
|
||||
func loadPool() []QuoteChampion {
|
||||
var all []QuoteChampion
|
||||
if err := json.Unmarshal(rawQuotes, &all); err != nil {
|
||||
panic(fmt.Sprintf("loldlequote: cannot decode quotes.json: %v", err))
|
||||
}
|
||||
out := make([]QuoteChampion, 0, len(all))
|
||||
for _, c := range all {
|
||||
if strings.TrimSpace(c.Quote) != "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
panic("loldlequote: quotes.json contained no usable records")
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,690 +0,0 @@
|
||||
[
|
||||
{
|
||||
"championName": "Aatrox",
|
||||
"quote": "the Darkin Blade — Once honored defenders of Shurima against the Void, ___ and his brethren would eventually become an even greater threat to Runeterra, and were defeated only by cunning mortal sorcery."
|
||||
},
|
||||
{
|
||||
"championName": "Ahri",
|
||||
"quote": "the Nine-Tailed Fox — Innately connected to the magic of the spirit realm, ___ is a fox-like vastaya who can manipulate her prey's emotions and consume their essence—receiving flashes of their memory and insight from each soul she consumes."
|
||||
},
|
||||
{
|
||||
"championName": "Akali",
|
||||
"quote": "the Rogue Assassin — Abandoning the Kinkou Order and her title of the Fist of Shadow, ___ now strikes alone, ready to be the deadly weapon her people need."
|
||||
},
|
||||
{
|
||||
"championName": "Akshan",
|
||||
"quote": "the Rogue Sentinel — Raising an eyebrow in the face of danger, ___ fights evil with dashing charisma, righteous vengeance, and a conspicuous lack of shirts."
|
||||
},
|
||||
{
|
||||
"championName": "Alistar",
|
||||
"quote": "the Minotaur — Always a mighty warrior with a fearsome reputation, ___ seeks revenge for the death of his clan at the hands of the Noxian empire."
|
||||
},
|
||||
{
|
||||
"championName": "Ambessa",
|
||||
"quote": "Matriarch of War — All who know the name Medarda respect and fear the family's leader, ___."
|
||||
},
|
||||
{
|
||||
"championName": "Amumu",
|
||||
"quote": "the Sad Mummy — Legend claims that ___ is a lonely and melancholy soul from ancient Shurima, roaming the world in search of a friend."
|
||||
},
|
||||
{
|
||||
"championName": "Anivia",
|
||||
"quote": "the Cryophoenix — ___ is a benevolent winged spirit who endures endless cycles of life, death, and rebirth to protect the Freljord."
|
||||
},
|
||||
{
|
||||
"championName": "Annie",
|
||||
"quote": "the Dark Child — Dangerous, yet disarmingly precocious, ___ is a child mage with immense pyromantic power."
|
||||
},
|
||||
{
|
||||
"championName": "Aphelios",
|
||||
"quote": "the Weapon of the Faithful — Emerging from moonlight's shadow with weapons drawn, ___ kills the enemies of his faith in brooding silence—speaking only through the certainty of his aim, and the firing of each gun."
|
||||
},
|
||||
{
|
||||
"championName": "Ashe",
|
||||
"quote": "the Frost Archer — Iceborn warmother of the Avarosan tribe, ___ commands the most populous horde in the north."
|
||||
},
|
||||
{
|
||||
"championName": "Aurelion Sol",
|
||||
"quote": "The Star Forger — ___ once graced the vast emptiness of the cosmos with celestial wonders of his own devising."
|
||||
},
|
||||
{
|
||||
"championName": "Aurora",
|
||||
"quote": "the Witch Between Worlds — From the moment she was born, ___ navigated life with a unique ability to move between the spirit and material realms."
|
||||
},
|
||||
{
|
||||
"championName": "Azir",
|
||||
"quote": "the Emperor of the Sands — ___ was a mortal emperor of Shurima in a far distant age, a proud man who stood at the cusp of immortality."
|
||||
},
|
||||
{
|
||||
"championName": "Bard",
|
||||
"quote": "the Wandering Caretaker — A traveler from beyond the stars, ___ is an agent of serendipity who fights to maintain a balance where life can endure the indifference of chaos."
|
||||
},
|
||||
{
|
||||
"championName": "Bel'Veth",
|
||||
"quote": "the Empress of the Void — A nightmarish empress created from the raw material of an entire devoured city, ___ is the end of Runeterra itself."
|
||||
},
|
||||
{
|
||||
"championName": "Blitzcrank",
|
||||
"quote": "the Great Steam Golem — ___ is an enormous, near-indestructible automaton from Zaun, originally built to dispose of hazardous waste."
|
||||
},
|
||||
{
|
||||
"championName": "Brand",
|
||||
"quote": "the Burning Vengeance — Once a tribesman of the icy Freljord named Kegan Rodhe, the creature known as ___ is a lesson in the temptation of greater power."
|
||||
},
|
||||
{
|
||||
"championName": "Braum",
|
||||
"quote": "the Heart of the Freljord — Blessed with massive biceps and an even bigger heart, ___ is a beloved hero of the Freljord."
|
||||
},
|
||||
{
|
||||
"championName": "Briar",
|
||||
"quote": "the Restrained Hunger — A failed experiment by the Black Rose, ___'s uncontrollable bloodlust required a special pillory to focus her frenzied mind."
|
||||
},
|
||||
{
|
||||
"championName": "Caitlyn",
|
||||
"quote": "the Sheriff of Piltover — Renowned as its finest peacekeeper, ___ Kiramman is also Piltover's best shot at ridding the city of its elusive criminal elements."
|
||||
},
|
||||
{
|
||||
"championName": "Camille",
|
||||
"quote": "the Steel Shadow — Weaponized to operate outside the boundaries of the law, ___ is the Principal Intelligencer of Clan Ferros—an elegant and elite agent who ensures the Piltover machine and its Zaunite underbelly runs smoothly."
|
||||
},
|
||||
{
|
||||
"championName": "Cassiopeia",
|
||||
"quote": "the Serpent's Embrace — ___ is a deadly creature bent on manipulating others to her sinister will."
|
||||
},
|
||||
{
|
||||
"championName": "Cho'Gath",
|
||||
"quote": "the Terror of the Void — From the moment ___ first emerged into the harsh light of Runeterra's sun, the beast was driven by the most pure and insatiable hunger."
|
||||
},
|
||||
{
|
||||
"championName": "Corki",
|
||||
"quote": "the Daring Bombardier — The yordle pilot ___ loves two things above all others: flying, and his glamorous mustache."
|
||||
},
|
||||
{
|
||||
"championName": "Darius",
|
||||
"quote": "the Hand of Noxus — There is no greater symbol of Noxian might than ___, the nation's most feared and battle-hardened commander."
|
||||
},
|
||||
{
|
||||
"championName": "Diana",
|
||||
"quote": "Scorn of the Moon — Bearing her crescent moonblade, ___ fights as a warrior of the Lunari—a faith all but quashed in the lands around Mount Targon."
|
||||
},
|
||||
{
|
||||
"championName": "Dr. Mundo",
|
||||
"quote": "the Madman of Zaun — Utterly mad, tragically homicidal, and horrifyingly purple, Dr."
|
||||
},
|
||||
{
|
||||
"championName": "Draven",
|
||||
"quote": "the Glorious Executioner — In Noxus, warriors known as Reckoners face one another in arenas where blood is spilled and strength tested—but none has ever been as celebrated as ___."
|
||||
},
|
||||
{
|
||||
"championName": "Ekko",
|
||||
"quote": "the Boy Who Shattered Time — A prodigy from the rough streets of Zaun, ___ is able to manipulate time to twist any situation to his advantage."
|
||||
},
|
||||
{
|
||||
"championName": "Elise",
|
||||
"quote": "the Spider Queen — ___ is a deadly predator who dwells in a shuttered, lightless palace, deep within the oldest city of Noxus."
|
||||
},
|
||||
{
|
||||
"championName": "Evelynn",
|
||||
"quote": "Agony's Embrace — Within the dark seams of Runeterra, the demon ___ searches for her next victim."
|
||||
},
|
||||
{
|
||||
"championName": "Ezreal",
|
||||
"quote": "the Prodigal Explorer — A dashing adventurer, unknowingly gifted in the magical arts, ___ raids long-lost catacombs, tangles with ancient curses, and overcomes seemingly impossible odds with ease."
|
||||
},
|
||||
{
|
||||
"championName": "Fiddlesticks",
|
||||
"quote": "the Ancient Fear — Something has awoken in Runeterra."
|
||||
},
|
||||
{
|
||||
"championName": "Fiora",
|
||||
"quote": "the Grand Duelist — The most feared duelist in all Valoran, ___ is as renowned for her brusque manner and cunning mind as she is for the speed of her bluesteel rapier."
|
||||
},
|
||||
{
|
||||
"championName": "Fizz",
|
||||
"quote": "the Tidal Trickster — ___ is an amphibious yordle, who dwells among the reefs surrounding Bilgewater."
|
||||
},
|
||||
{
|
||||
"championName": "Galio",
|
||||
"quote": "the Colossus — Outside the gleaming city of Demacia, the stone colossus ___ keeps vigilant watch."
|
||||
},
|
||||
{
|
||||
"championName": "Gangplank",
|
||||
"quote": "the Saltwater Scourge — As unpredictable as he is brutal, the dethroned reaver king ___ is feared far and wide."
|
||||
},
|
||||
{
|
||||
"championName": "Garen",
|
||||
"quote": "The Might of Demacia — A proud and noble warrior, ___ fights as one of the Dauntless Vanguard."
|
||||
},
|
||||
{
|
||||
"championName": "Gnar",
|
||||
"quote": "the Missing Link — ___ is a primeval yordle whose playful antics can erupt into a toddler's outrage in an instant, transforming him into a massive beast bent on destruction."
|
||||
},
|
||||
{
|
||||
"championName": "Gragas",
|
||||
"quote": "the Rabble Rouser — Equal parts jolly and imposing, ___ is a massive, rowdy brewmaster who's always on the lookout for new ways to raise everyone's spirits."
|
||||
},
|
||||
{
|
||||
"championName": "Graves",
|
||||
"quote": "the Outlaw — Malcolm ___ is a renowned mercenary, gambler, and thief—a wanted man in every city and empire he has visited."
|
||||
},
|
||||
{
|
||||
"championName": "Gwen",
|
||||
"quote": "The Hallowed Seamstress — A former doll transformed and brought to life by magic, ___ wields the very tools that once created her."
|
||||
},
|
||||
{
|
||||
"championName": "Hecarim",
|
||||
"quote": "the Shadow of War — ___ is a spectral fusion of man and beast, cursed to ride down the souls of the living for all eternity."
|
||||
},
|
||||
{
|
||||
"championName": "Heimerdinger",
|
||||
"quote": "the Revered Inventor — The eccentric Professor Cecil B."
|
||||
},
|
||||
{
|
||||
"championName": "Hwei",
|
||||
"quote": "the Visionary — ___ is a brooding painter who creates brilliant art in order to confront Ionia's criminals and comfort their victims."
|
||||
},
|
||||
{
|
||||
"championName": "Illaoi",
|
||||
"quote": "the Kraken Priestess — ___'s powerful physique is dwarfed only by her indomitable faith."
|
||||
},
|
||||
{
|
||||
"championName": "Irelia",
|
||||
"quote": "the Blade Dancer — The Noxian occupation of Ionia produced many heroes, none more unlikely than young ___ of Navori."
|
||||
},
|
||||
{
|
||||
"championName": "Ivern",
|
||||
"quote": "the Green Father — ___ Bramblefoot, known to many as the Green Father, is a peculiar half man, half tree who roams Runeterra's forests, cultivating life everywhere he goes."
|
||||
},
|
||||
{
|
||||
"championName": "Janna",
|
||||
"quote": "the Storm's Fury — Armed with the power of Runeterra's gales, ___ is a mysterious, elemental wind spirit who protects the dispossessed of Zaun."
|
||||
},
|
||||
{
|
||||
"championName": "Jarvan IV",
|
||||
"quote": "the Exemplar of Demacia — Prince ___, scion of the Lightshield dynasty, is heir apparent to the throne of Demacia."
|
||||
},
|
||||
{
|
||||
"championName": "Jax",
|
||||
"quote": "Grandmaster at Arms — Unmatched in both his skill with unique armaments and his biting sarcasm, ___ is the last known weapons master of Icathia."
|
||||
},
|
||||
{
|
||||
"championName": "Jayce",
|
||||
"quote": "the Defender of Tomorrow — ___ Talis is a brilliant inventor who, along with his friend Viktor, made the first great discoveries in the field of hextech."
|
||||
},
|
||||
{
|
||||
"championName": "Jhin",
|
||||
"quote": "the Virtuoso — ___ is a meticulous criminal psychopath who believes murder is art."
|
||||
},
|
||||
{
|
||||
"championName": "Jinx",
|
||||
"quote": "the Loose Cannon — An unhinged and impulsive criminal from the undercity, ___ is haunted by the consequences of her past—but that doesn't stop her from bringing her own chaotic brand of pandemonium to Piltover and Zaun."
|
||||
},
|
||||
{
|
||||
"championName": "K'Sante",
|
||||
"quote": "the Pride of Nazumah — Defiant and courageous, ___ battles colossal beasts and ruthless Ascended to protect his home of Nazumah, a coveted oasis amid the sands of Shurima."
|
||||
},
|
||||
{
|
||||
"championName": "Kai'Sa",
|
||||
"quote": "Daughter of the Void — Claimed by the Void when she was only a child, ___ managed to survive through sheer tenacity and strength of will."
|
||||
},
|
||||
{
|
||||
"championName": "Kalista",
|
||||
"quote": "the Spear of Vengeance — A specter of wrath and retribution, ___ is the undying spirit of vengeance, an armored nightmare summoned from the Shadow Isles to hunt deceivers and traitors."
|
||||
},
|
||||
{
|
||||
"championName": "Karma",
|
||||
"quote": "the Enlightened One — No mortal exemplifies the spiritual traditions of Ionia more than ___."
|
||||
},
|
||||
{
|
||||
"championName": "Karthus",
|
||||
"quote": "the Deathsinger — The harbinger of oblivion, ___ is an undying spirit whose haunting songs are a prelude to the horror of his nightmarish appearance."
|
||||
},
|
||||
{
|
||||
"championName": "Kassadin",
|
||||
"quote": "the Void Walker — Cutting a burning swath through the darkest places of the world, ___ knows his days are numbered."
|
||||
},
|
||||
{
|
||||
"championName": "Katarina",
|
||||
"quote": "the Sinister Blade — Decisive in judgment and lethal in combat, ___ is a Noxian assassin of the highest caliber."
|
||||
},
|
||||
{
|
||||
"championName": "Kayle",
|
||||
"quote": "the Righteous — Born to a Targonian Aspect at the height of the Rune Wars, ___ honored her mother's legacy by fighting for justice on wings of divine flame."
|
||||
},
|
||||
{
|
||||
"championName": "Kayn",
|
||||
"quote": "the Shadow Reaper — A peerless practitioner of lethal shadow magic, Shieda ___ battles to achieve his true destiny—to one day lead the Order of Shadow into a new era of Ionian supremacy."
|
||||
},
|
||||
{
|
||||
"championName": "Kennen",
|
||||
"quote": "the Heart of the Tempest — More than just the lightning-quick enforcer of Ionian balance, ___ is the only yordle member of the Kinkou."
|
||||
},
|
||||
{
|
||||
"championName": "Kha'Zix",
|
||||
"quote": "the Voidreaver — The Void grows, and the Void adapts—in none of its myriad spawn are these truths more apparent than ___."
|
||||
},
|
||||
{
|
||||
"championName": "Kindred",
|
||||
"quote": "The Eternal Hunters — Separate, but never parted, ___ represents the twin essences of death."
|
||||
},
|
||||
{
|
||||
"championName": "Kled",
|
||||
"quote": "the Cantankerous Cavalier — A warrior as fearless as he is ornery, the yordle ___ embodies the furious bravado of Noxus."
|
||||
},
|
||||
{
|
||||
"championName": "Kog'Maw",
|
||||
"quote": "the Mouth of the Abyss — Belched forth from a rotting Void incursion deep in the wastelands of Icathia, ___ is an inquisitive yet putrid creature with a caustic, gaping mouth."
|
||||
},
|
||||
{
|
||||
"championName": "LeBlanc",
|
||||
"quote": "the Deceiver — Mysterious even to other members of the Black Rose cabal, ___ is but one of many names for a pale woman who has manipulated people and events since the earliest days of Noxus."
|
||||
},
|
||||
{
|
||||
"championName": "Lee Sin",
|
||||
"quote": "the Blind Monk — A master of Ionia's ancient martial arts, ___ is a principled fighter who channels the essence of the dragon spirit to face any challenge."
|
||||
},
|
||||
{
|
||||
"championName": "Leona",
|
||||
"quote": "the Radiant Dawn — Imbued with the fire of the sun, ___ is a holy warrior of the Solari who defends Mount Targon with her Zenith Blade and the Shield of Daybreak."
|
||||
},
|
||||
{
|
||||
"championName": "Lillia",
|
||||
"quote": "the Bashful Bloom — Intensely shy, the fae fawn ___ skittishly wanders Ionia's forests."
|
||||
},
|
||||
{
|
||||
"championName": "Lissandra",
|
||||
"quote": "the Ice Witch — ___'s magic twists the pure power of ice into something dark and terrible."
|
||||
},
|
||||
{
|
||||
"championName": "Lucian",
|
||||
"quote": "the Purifier — ___, a Sentinel of Light, is a grim hunter of wraiths and specters, pursuing them relentlessly and annihilating them with his twin relic pistols."
|
||||
},
|
||||
{
|
||||
"championName": "Lulu",
|
||||
"quote": "the Fae Sorceress — The yordle mage ___ is known for conjuring dreamlike illusions and fanciful creatures as she roams Runeterra with her fairy companion Pix."
|
||||
},
|
||||
{
|
||||
"championName": "Lux",
|
||||
"quote": "the Lady of Luminosity — Luxanna Crownguard hails from Demacia, an insular realm where magical abilities are viewed with fear and suspicion."
|
||||
},
|
||||
{
|
||||
"championName": "Malphite",
|
||||
"quote": "Shard of the Monolith — A massive creature of living stone, ___ struggles to impose blessed order on a chaotic world."
|
||||
},
|
||||
{
|
||||
"championName": "Malzahar",
|
||||
"quote": "the Prophet of the Void — A zealous seer dedicated to the unification of all life, ___ truly believes the newly emergent Void to be the path to Runeterra's salvation."
|
||||
},
|
||||
{
|
||||
"championName": "Maokai",
|
||||
"quote": "the Twisted Treant — ___ is a rageful, towering treant who fights the unnatural horrors of the Shadow Isles."
|
||||
},
|
||||
{
|
||||
"championName": "Master Yi",
|
||||
"quote": "the Wuju Bladesman — ___ has tempered his body and sharpened his mind, so that thought and action have become almost as one."
|
||||
},
|
||||
{
|
||||
"championName": "Mel",
|
||||
"quote": "the Soul's Reflection — ___ Medarda is the presumed heir of the Medarda family, once one of the most powerful in Noxus."
|
||||
},
|
||||
{
|
||||
"championName": "Milio",
|
||||
"quote": "The Gentle Flame — ___ is a warmhearted boy from Ixtal who has, despite his young age, mastered the fire axiom and discovered something new: soothing fire."
|
||||
},
|
||||
{
|
||||
"championName": "Miss Fortune",
|
||||
"quote": "the Bounty Hunter — A Bilgewater captain famed for her looks but feared for her ruthlessness, Sarah ___ paints a stark figure among the hardened criminals of the port city."
|
||||
},
|
||||
{
|
||||
"championName": "Mordekaiser",
|
||||
"quote": "the Iron Revenant — Twice slain and thrice born, ___ is a brutal warlord from a foregone epoch who uses his necromantic sorcery to bind souls into an eternity of servitude."
|
||||
},
|
||||
{
|
||||
"championName": "Morgana",
|
||||
"quote": "the Fallen — Conflicted between her celestial and mortal natures, ___ bound her wings to embrace humanity, and inflicts her pain and bitterness upon the dishonest and the corrupt."
|
||||
},
|
||||
{
|
||||
"championName": "Naafiri",
|
||||
"quote": "the Hound of a Hundred Bites — Across the sands of Shurima, a chorus of howls rings out."
|
||||
},
|
||||
{
|
||||
"championName": "Nami",
|
||||
"quote": "the Tidecaller — A headstrong young vastaya of the seas, ___ was the first of the Marai tribe to leave the waves and venture onto dry land, when their ancient accord with the Targonians was broken."
|
||||
},
|
||||
{
|
||||
"championName": "Nasus",
|
||||
"quote": "the Curator of the Sands — ___ is an imposing, jackal-headed Ascended being from ancient Shurima, a heroic figure regarded as a demigod by the people of the desert."
|
||||
},
|
||||
{
|
||||
"championName": "Nautilus",
|
||||
"quote": "the Titan of the Depths — A lonely legend as old as the first piers sunk in Bilgewater, the armored goliath known as ___ roams the dark waters off the coast of the Blue Flame Isles."
|
||||
},
|
||||
{
|
||||
"championName": "Neeko",
|
||||
"quote": "the Curious Chameleon — Hailing from a long lost tribe of vastaya, ___ can blend into any crowd by borrowing the appearances of others, even absorbing something of their emotional state to tell friend from foe in an instant."
|
||||
},
|
||||
{
|
||||
"championName": "Nidalee",
|
||||
"quote": "the Bestial Huntress — Raised in the deepest jungle, ___ is a master tracker who can shapeshift into a ferocious cougar at will."
|
||||
},
|
||||
{
|
||||
"championName": "Nilah",
|
||||
"quote": "the Joy Unbound — ___ is an ascetic warrior from a distant land, seeking the world's deadliest, most titanic opponents so that she might challenge and destroy them."
|
||||
},
|
||||
{
|
||||
"championName": "Nocturne",
|
||||
"quote": "the Eternal Nightmare — A demonic amalgamation drawn from the nightmares that haunt every sentient mind, the thing known as ___ has become a primordial force of pure evil."
|
||||
},
|
||||
{
|
||||
"championName": "Nunu & Willump",
|
||||
"quote": "the Boy and His Yeti — Once upon a time, there was a boy who wanted to prove he was a hero by slaying a fearsome monster—only to discover that the beast, a lonely and magical yeti, merely needed a friend."
|
||||
},
|
||||
{
|
||||
"championName": "Olaf",
|
||||
"quote": "the Berserker — An unstoppable force of destruction, the axe-wielding ___ wants nothing but to die in glorious combat."
|
||||
},
|
||||
{
|
||||
"championName": "Orianna",
|
||||
"quote": "the Lady of Clockwork — Once a curious girl of flesh and blood, ___ is now a technological marvel comprised entirely of clockwork."
|
||||
},
|
||||
{
|
||||
"championName": "Ornn",
|
||||
"quote": "The Fire below the Mountain — ___ is the Freljordian spirit of forging and craftsmanship."
|
||||
},
|
||||
{
|
||||
"championName": "Pantheon",
|
||||
"quote": "the Unbreakable Spear — Once an unwilling host to the Aspect of War, Atreus survived when the celestial power within him was slain, refusing to succumb to a blow that tore stars from the heavens."
|
||||
},
|
||||
{
|
||||
"championName": "Poppy",
|
||||
"quote": "Keeper of the Hammer — Runeterra has no shortage of valiant champions, but few are as tenacious as ___."
|
||||
},
|
||||
{
|
||||
"championName": "Pyke",
|
||||
"quote": "the Bloodharbor Ripper — A renowned harpooner from the slaughter docks of Bilgewater, ___ should have met his death in the belly of a gigantic jaull-fish… and yet, he returned."
|
||||
},
|
||||
{
|
||||
"championName": "Qiyana",
|
||||
"quote": "Empress of the Elements — In the jungle city of Ixaocan, ___ plots her own ruthless path to the high seat of the Yun Tal."
|
||||
},
|
||||
{
|
||||
"championName": "Quinn",
|
||||
"quote": "Demacia's Wings — ___ is an elite ranger-knight of Demacia, who undertakes dangerous missions deep in enemy territory."
|
||||
},
|
||||
{
|
||||
"championName": "Rakan",
|
||||
"quote": "The Charmer — As mercurial as he is charming, ___ is an infamous vastayan troublemaker and the greatest battle-dancer in Lhotlan tribal history."
|
||||
},
|
||||
{
|
||||
"championName": "Rammus",
|
||||
"quote": "the Armordillo — Idolized by many, dismissed by some, mystifying to all, the curious being ___ is an enigma."
|
||||
},
|
||||
{
|
||||
"championName": "Rek'Sai",
|
||||
"quote": "the Void Burrower — An apex predator, ___ is a merciless Void-spawn that tunnels beneath the ground to ambush and devour unsuspecting prey."
|
||||
},
|
||||
{
|
||||
"championName": "Rell",
|
||||
"quote": "the Iron Maiden — The product of brutal experimentation at the hands of the Black Rose, ___ is a defiant, living weapon determined to topple Noxus."
|
||||
},
|
||||
{
|
||||
"championName": "Renata Glasc",
|
||||
"quote": "the Chem-Baroness — ___ rose from the ashes of her childhood home with nothing but her name and her parents' alchemical research."
|
||||
},
|
||||
{
|
||||
"championName": "Renekton",
|
||||
"quote": "the Butcher of the Sands — ___ is a terrifying, rage-fueled Ascended being from the scorched deserts of Shurima."
|
||||
},
|
||||
{
|
||||
"championName": "Rengar",
|
||||
"quote": "the Pridestalker — ___ is a ferocious vastayan trophy hunter who lives for the thrill of tracking down and killing dangerous creatures."
|
||||
},
|
||||
{
|
||||
"championName": "Riven",
|
||||
"quote": "the Exile — Once a swordmaster in the warhosts of Noxus, ___ is an expatriate in a land she previously tried to conquer."
|
||||
},
|
||||
{
|
||||
"championName": "Rumble",
|
||||
"quote": "the Mechanized Menace — ___ is a young inventor with a temper."
|
||||
},
|
||||
{
|
||||
"championName": "Ryze",
|
||||
"quote": "the Rune Mage — Widely considered one of the most adept sorcerers on Runeterra, ___ is an ancient, hard-bitten archmage with an impossibly heavy burden to bear."
|
||||
},
|
||||
{
|
||||
"championName": "Samira",
|
||||
"quote": "the Desert Rose — ___ stares death in the eye with unyielding confidence, seeking thrill wherever she goes."
|
||||
},
|
||||
{
|
||||
"championName": "Sejuani",
|
||||
"quote": "Fury of the North — ___ is the brutal, unforgiving Iceborn warmother of the Winter's Claw, one of the most feared tribes of the Freljord."
|
||||
},
|
||||
{
|
||||
"championName": "Senna",
|
||||
"quote": "the Redeemer — Cursed from childhood to be haunted by the supernatural Black Mist, ___ joined a sacred order known as the Sentinels of Light, and fiercely fought back—only to be killed, her soul imprisoned in a lantern by the cruel specter Thresh."
|
||||
},
|
||||
{
|
||||
"championName": "Seraphine",
|
||||
"quote": "the Starry-Eyed Songstress — Born in Piltover to Zaunite parents, ___ can hear the souls of others—the world sings to her, and she sings back."
|
||||
},
|
||||
{
|
||||
"championName": "Sett",
|
||||
"quote": "the Boss — A leader of Ionia's growing criminal underworld, ___ rose to prominence in the wake of the war with Noxus."
|
||||
},
|
||||
{
|
||||
"championName": "Shaco",
|
||||
"quote": "the Demon Jester — Crafted long ago as a plaything for a lonely prince, the enchanted marionette ___ now delights in murder and mayhem."
|
||||
},
|
||||
{
|
||||
"championName": "Shen",
|
||||
"quote": "the Eye of Twilight — Among the secretive, Ionian warriors known as the Kinkou, ___ serves as their leader, the Eye of Twilight."
|
||||
},
|
||||
{
|
||||
"championName": "Shyvana",
|
||||
"quote": "the Half-Dragon — ___ is a fearsome half-dragon warrior."
|
||||
},
|
||||
{
|
||||
"championName": "Singed",
|
||||
"quote": "the Mad Chemist — ___ is a brilliant alchemist of dubious morality, whose experiments would turn the stomach of even the most cutthroat criminal."
|
||||
},
|
||||
{
|
||||
"championName": "Sion",
|
||||
"quote": "The Undead Juggernaut — A war hero from a bygone era, ___ was revered in Noxus for choking the life out of a Demacian king with his bare hands—but, denied oblivion, he was resurrected to serve his empire even in death."
|
||||
},
|
||||
{
|
||||
"championName": "Sivir",
|
||||
"quote": "the Battle Mistress — ___ is a renowned fortune hunter and mercenary captain who plies her trade in the deserts of Shurima."
|
||||
},
|
||||
{
|
||||
"championName": "Skarner",
|
||||
"quote": "the Primordial Sovereign — The ancient, colossal brackern ___ is revered in Ixtal as one of the founding members of its ruling caste, the Yun Tal."
|
||||
},
|
||||
{
|
||||
"championName": "Smolder",
|
||||
"quote": "the Fiery Fledgling — Hidden amongst the craggy cliffs of the Noxian frontier, under the watchful eyes of his mother, a young dragon is learning what it means to be heir to the Camavoran imperial dragon lineage."
|
||||
},
|
||||
{
|
||||
"championName": "Sona",
|
||||
"quote": "Maven of the Strings — ___ is Demacia's foremost virtuoso of the stringed etwahl, speaking only through her graceful chords and vibrant arias."
|
||||
},
|
||||
{
|
||||
"championName": "Soraka",
|
||||
"quote": "the Starchild — A wanderer from the celestial dimensions beyond Mount Targon, ___ gave up her immortality to protect the mortal races from their own more violent instincts."
|
||||
},
|
||||
{
|
||||
"championName": "Swain",
|
||||
"quote": "the Noxian Grand General — Jericho ___ is the visionary ruler of Noxus, an expansionist nation that reveres only strength."
|
||||
},
|
||||
{
|
||||
"championName": "Sylas",
|
||||
"quote": "the Unshackled — Raised in one of Demacia's lesser quarters, ___ of Dregbourne has come to symbolize the darker side of the Great City."
|
||||
},
|
||||
{
|
||||
"championName": "Syndra",
|
||||
"quote": "the Dark Sovereign — ___ is a fearsome Ionian mage with incredible power at her command."
|
||||
},
|
||||
{
|
||||
"championName": "Tahm Kench",
|
||||
"quote": "The River King — Known by many names throughout history, the demon ___ travels the waterways of Runeterra, feeding his insatiable appetite with the misery of others."
|
||||
},
|
||||
{
|
||||
"championName": "Taliyah",
|
||||
"quote": "the Stoneweaver — ___ is a nomadic mage from Shurima, torn between teenage wonder and adult responsibility."
|
||||
},
|
||||
{
|
||||
"championName": "Talon",
|
||||
"quote": "the Blade's Shadow — ___ is the knife in the darkness, a merciless killer able to strike without warning and escape before any alarm is raised."
|
||||
},
|
||||
{
|
||||
"championName": "Taric",
|
||||
"quote": "the Shield of Valoran — ___ is the Aspect of the Protector, wielding incredible power as Runeterra's guardian of life, love, and beauty."
|
||||
},
|
||||
{
|
||||
"championName": "Teemo",
|
||||
"quote": "the Swift Scout — Undeterred by even the most dangerous and threatening of obstacles, ___ scouts the world with boundless enthusiasm and a cheerful spirit."
|
||||
},
|
||||
{
|
||||
"championName": "Thresh",
|
||||
"quote": "the Chain Warden — Sadistic and cunning, ___ is an ambitious and restless specter of the Shadow Isles."
|
||||
},
|
||||
{
|
||||
"championName": "Tristana",
|
||||
"quote": "the Yordle Gunner — While many other yordles channel their energy into discovery, invention, or just plain mischief-making, ___ was always inspired by the adventures of great warriors."
|
||||
},
|
||||
{
|
||||
"championName": "Trundle",
|
||||
"quote": "the Troll King — ___ is a hulking and devious troll with a particularly vicious streak, and there is nothing he cannot bludgeon into submission—not even the Freljord itself."
|
||||
},
|
||||
{
|
||||
"championName": "Tryndamere",
|
||||
"quote": "the Barbarian King — Fueled by unbridled fury and rage, ___ once carved his way through the Freljord, openly challenging the greatest warriors of the north to prepare himself for even darker days ahead."
|
||||
},
|
||||
{
|
||||
"championName": "Twisted Fate",
|
||||
"quote": "the Card Master — ___ is an infamous cardsharp and swindler who has gambled and charmed his way across much of the known world, earning the enmity and admiration of the rich and foolish alike."
|
||||
},
|
||||
{
|
||||
"championName": "Twitch",
|
||||
"quote": "the Plague Rat — A Zaunite plague rat by birth, but a connoisseur of filth by passion, ___ is not afraid to get his paws dirty."
|
||||
},
|
||||
{
|
||||
"championName": "Udyr",
|
||||
"quote": "the Spirit Walker — The most powerful spirit walker alive, ___ communes with all the spirits of the Freljord, whether by empathically understanding their needs, or by channeling and transforming their ethereal energy into his own primal fighting style."
|
||||
},
|
||||
{
|
||||
"championName": "Urgot",
|
||||
"quote": "the Dreadnought — Once a powerful Noxian headsman, ___ was betrayed by the empire for which he had killed so many."
|
||||
},
|
||||
{
|
||||
"championName": "Varus",
|
||||
"quote": "the Arrow of Retribution — One of the ancient darkin, ___ was a deadly killer who loved to torment his foes, driving them almost to insanity before delivering the killing arrow."
|
||||
},
|
||||
{
|
||||
"championName": "Vayne",
|
||||
"quote": "the Night Hunter — Shauna ___ is a deadly, remorseless Demacian monster hunter, who has dedicated her life to finding and destroying the demon that murdered her family."
|
||||
},
|
||||
{
|
||||
"championName": "Veigar",
|
||||
"quote": "the Tiny Master of Evil — An enthusiastic master of dark sorcery, ___ has embraced powers that few mortals dare approach."
|
||||
},
|
||||
{
|
||||
"championName": "Vel'Koz",
|
||||
"quote": "the Eye of the Void — It is unclear if ___ was the first Void-spawn to emerge on Runeterra, but there has certainly never been another to match his level of cruel, calculating sentience."
|
||||
},
|
||||
{
|
||||
"championName": "Vex",
|
||||
"quote": "the Gloomist — In the black heart of the Shadow Isles, a lone yordle trudges through the spectral fog, content in its murky misery."
|
||||
},
|
||||
{
|
||||
"championName": "Vi",
|
||||
"quote": "the Piltover Enforcer — ___Raised___ ___on___ ___the___ ___mean___ ___streets___ ___of___ ___Zaun___, ___Vi___ ___is___ ___a___ ___hotheaded___, ___impulsive___, ___and___ ___fearsome___ ___woman___ ___with___ ___very___ ___little___ ___respect___ ___for___ ___authority___."
|
||||
},
|
||||
{
|
||||
"championName": "Viego",
|
||||
"quote": "The Ruined King — Once ruler of a long-lost kingdom, ___ perished over a thousand years ago when his attempt to bring his wife back from the dead triggered the magical catastrophe known as the Ruination."
|
||||
},
|
||||
{
|
||||
"championName": "Viktor",
|
||||
"quote": "the Herald of the Arcane — The fully biomechanical evolution of his former self, ___ has embraced his Glorious Evolution and become something of a messiah to his followers."
|
||||
},
|
||||
{
|
||||
"championName": "Vladimir",
|
||||
"quote": "the Crimson Reaper — A fiend with a thirst for mortal blood, ___ has influenced the affairs of Noxus since the empire's earliest days."
|
||||
},
|
||||
{
|
||||
"championName": "Volibear",
|
||||
"quote": "the Relentless Storm — To those who still revere him, the ___ is the storm made manifest."
|
||||
},
|
||||
{
|
||||
"championName": "Warwick",
|
||||
"quote": "the Uncaged Wrath of Zaun — ___ is a monster who hunts the gray alleys of Zaun."
|
||||
},
|
||||
{
|
||||
"championName": "Wukong",
|
||||
"quote": "the Monkey King — ___ is a vastayan trickster who uses his strength, agility, and intelligence to confuse his opponents and gain the upper hand."
|
||||
},
|
||||
{
|
||||
"championName": "Xayah",
|
||||
"quote": "the Rebel — Deadly and precise, ___ is a vastayan revolutionary waging a personal war to save her people."
|
||||
},
|
||||
{
|
||||
"championName": "Xerath",
|
||||
"quote": "the Magus Ascendant — ___ is an Ascended Magus of ancient Shurima, a being of arcane energy writhing in the broken shards of a magical sarcophagus."
|
||||
},
|
||||
{
|
||||
"championName": "Xin Zhao",
|
||||
"quote": "the Seneschal of Demacia — ___ is a resolute warrior loyal to the ruling Lightshield dynasty."
|
||||
},
|
||||
{
|
||||
"championName": "Yasuo",
|
||||
"quote": "the Unforgiven — An Ionian of deep resolve, ___ is an agile swordsman who wields the air itself against his enemies."
|
||||
},
|
||||
{
|
||||
"championName": "Yone",
|
||||
"quote": "the Unforgotten — In life, he was ___—half-brother of Yasuo, and renowned student of his village's sword school."
|
||||
},
|
||||
{
|
||||
"championName": "Yorick",
|
||||
"quote": "Shepherd of Souls — The last survivor of a long-forgotten religious order, ___ is both blessed and cursed with power over the dead."
|
||||
},
|
||||
{
|
||||
"championName": "Yunara",
|
||||
"quote": "the Unbroken Faith — Unwavering in her devotion to Ionia, ___ has spent centuries cloistered away in the spirit realm honing her skills with the Aion Er'na, a legendary Kinkou relic."
|
||||
},
|
||||
{
|
||||
"championName": "Yuumi",
|
||||
"quote": "the Magical Cat — A magical cat from Bandle City, ___ was once the familiar of a yordle enchantress, Norra."
|
||||
},
|
||||
{
|
||||
"championName": "Zaahen",
|
||||
"quote": "The Unsundered — A fallen god wielding both divine and profane power, ___ hunts his fellow Darkin while defying the corruption that threatens to consume him."
|
||||
},
|
||||
{
|
||||
"championName": "Zac",
|
||||
"quote": "the Secret Weapon — ___ is the product of a toxic spill that ran through a chemtech seam and pooled in an isolated cavern deep in Zaun's Sump."
|
||||
},
|
||||
{
|
||||
"championName": "Zed",
|
||||
"quote": "the Master of Shadows — Utterly ruthless and without mercy, ___ is the leader of the Order of Shadow, an organization he created with the intent of militarizing Ionia's magical and martial traditions to drive out Noxian invaders."
|
||||
},
|
||||
{
|
||||
"championName": "Zeri",
|
||||
"quote": "The Spark of Zaun — A headstrong, spirited young woman from Zaun's working-class, ___ channels her electric magic to charge herself and her custom-crafted gun."
|
||||
},
|
||||
{
|
||||
"championName": "Ziggs",
|
||||
"quote": "the Hexplosives Expert — With a love of big bombs and short fuses, the yordle ___ is an explosive force of nature."
|
||||
},
|
||||
{
|
||||
"championName": "Zilean",
|
||||
"quote": "the Chronokeeper — Once a powerful Icathian mage, ___ became obsessed with the passage of time after witnessing his homeland's destruction by the Void."
|
||||
},
|
||||
{
|
||||
"championName": "Zoe",
|
||||
"quote": "the Aspect of Twilight — As the embodiment of mischief, imagination, and change, ___ acts as the cosmic messenger of Targon, heralding major events that reshape worlds."
|
||||
},
|
||||
{
|
||||
"championName": "Zyra",
|
||||
"quote": "Rise of the Thorns — Born in an ancient, sorcerous catastrophe, ___ is the wrath of nature given form—an alluring hybrid of plant and human, kindling new life with every step."
|
||||
}
|
||||
]
|
||||
@@ -1,218 +0,0 @@
|
||||
package loldlequote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/champname"
|
||||
"github.com/tiennm99/miti99bot-go/internal/keylock"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
const newRoundHint = "🆕 Send <code>/loldle_quote</code> or <code>/loldle_quote <champion></code> to start a new round."
|
||||
|
||||
// state captures everything a loldle-quote handler needs at runtime. Built
|
||||
// once per Factory call and shared across the four command closures.
|
||||
type state struct {
|
||||
kv storage.KVStore
|
||||
pool []QuoteChampion
|
||||
locks keylock.Map // serialises Get→mutate→Put per subject
|
||||
}
|
||||
|
||||
// championName extracts the comparable name field for champname helpers.
|
||||
func championName(c *QuoteChampion) string { return c.ChampionName }
|
||||
|
||||
func (s *state) pickRandom() *QuoteChampion {
|
||||
return &s.pool[rand.Intn(len(s.pool))]
|
||||
}
|
||||
|
||||
func (s *state) startFreshGame(ctx context.Context, subject string) (*gameState, error) {
|
||||
target := s.pickRandom()
|
||||
g := &gameState{Target: target.ChampionName, Guesses: []string{}, StartedAt: nil}
|
||||
if err := saveGame(ctx, s.kv, subject, g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (s *state) getOrInitGame(ctx context.Context, subject string, maxGuesses int) (*gameState, error) {
|
||||
existing, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil && len(existing.Guesses) < maxGuesses {
|
||||
return existing, nil
|
||||
}
|
||||
return s.startFreshGame(ctx, subject)
|
||||
}
|
||||
|
||||
// handleQuote is /loldle_quote [champion] — show clue if no arg, else guess.
|
||||
func (s *state) handleQuote(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
|
||||
maxGuesses, err := getMaxGuesses(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
game, err := s.getOrInitGame(ctx, subject, maxGuesses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := champname.FindByExactName(s.pool, game.Target, championName)
|
||||
if target == nil {
|
||||
// Pool refreshed mid-round and the target is gone — start over.
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
"Quote data was updated since this round started. "+newRoundHint)
|
||||
}
|
||||
|
||||
if arg == "" {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, renderBoard(target.Quote, game.Guesses, maxGuesses))
|
||||
}
|
||||
|
||||
guess := champname.Find(s.pool, arg, championName)
|
||||
if guess == nil {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Champion not found: %q.", arg))
|
||||
}
|
||||
|
||||
for _, prior := range game.Guesses {
|
||||
if prior == guess.ChampionName {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"🔁 <b>%s</b> was already guessed this round — try another champion.",
|
||||
html.EscapeString(guess.ChampionName)))
|
||||
}
|
||||
}
|
||||
|
||||
if game.StartedAt == nil {
|
||||
now := chathelper.NowMillis()
|
||||
game.StartedAt = &now
|
||||
}
|
||||
game.Guesses = append(game.Guesses, guess.ChampionName)
|
||||
won := guess.ChampionName == target.ChampionName
|
||||
answer := html.EscapeString(target.ChampionName)
|
||||
|
||||
switch {
|
||||
case won:
|
||||
st, err := recordResult(ctx, s.kv, subject, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"🎉 Nailed it! <b>%s</b> — solved in %d/%d\n🔥 Streak: %d\n%s",
|
||||
answer, len(game.Guesses), maxGuesses, st.Streak, newRoundHint))
|
||||
|
||||
case len(game.Guesses) >= maxGuesses:
|
||||
if _, err := recordResult(ctx, s.kv, subject, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"%s\n\n❌ Out of guesses. Answer: <b>%s</b>.\n%s",
|
||||
renderBoard(target.Quote, game.Guesses, maxGuesses), answer, newRoundHint))
|
||||
|
||||
default:
|
||||
if err := saveGame(ctx, s.kv, subject, game); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"%s\n\n❌ Not <b>%s</b>. Guess %d/%d.",
|
||||
renderBoard(target.Quote, game.Guesses, maxGuesses),
|
||||
html.EscapeString(guess.ChampionName), len(game.Guesses), maxGuesses))
|
||||
}
|
||||
}
|
||||
|
||||
// handleGiveup is /loldle_quote_giveup — reveal answer + clear round.
|
||||
func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
|
||||
existing, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, "No active round. "+newRoundHint)
|
||||
}
|
||||
if _, err := recordResult(ctx, s.kv, subject, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"🏳️ Answer: <b>%s</b>.\n%s", html.EscapeString(existing.Target), newRoundHint))
|
||||
}
|
||||
|
||||
// handleStats is /loldle_quote_stats — lifetime score.
|
||||
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
st, err := loadStats(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scope := "group"
|
||||
if msg.Chat.Type == models.ChatTypePrivate {
|
||||
scope = "your"
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"📊 Loldle Quote %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d",
|
||||
scope, st.Played, st.Wins, chathelper.WinRate(st.Wins, st.Played), st.Streak, st.BestStreak))
|
||||
}
|
||||
|
||||
// handleSetMax is /loldle_quote_setmax <n> — private; per-subject override.
|
||||
func (s *state) handleSetMax(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
n, err := strconv.Atoi(arg)
|
||||
if err != nil || n < 1 || n > MaxGuessesCap {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Usage: /loldle_quote_setmax <1-%d>", MaxGuessesCap))
|
||||
}
|
||||
if err := setMaxGuesses(ctx, s.kv, subject, n); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("✅ Loldle quote max guesses set to %d (applies to the next round).", n))
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package loldlequote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
"github.com/tiennm99/miti99bot-go/internal/testutil"
|
||||
)
|
||||
|
||||
// installQuote wires the loldle-quote module + auth (owner gates
|
||||
// /loldle_quote_setmax). seedTarget pre-seeds a game so guess outcomes are
|
||||
// deterministic without hooking math/rand.
|
||||
func installQuote(t *testing.T, ownerID int64, seedSubject, seedTarget string) (*testutil.RecordingBot, storage.KVStore) {
|
||||
t.Helper()
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
provider := storage.NewMemoryProvider()
|
||||
kv := provider.For("loldle-quote")
|
||||
mod := New(modules.Deps{KV: kv})
|
||||
reg := &modules.Registry{
|
||||
Modules: []modules.Module{{Name: "loldle-quote", Commands: mod.Commands}},
|
||||
AllCommands: map[string]modules.Command{},
|
||||
}
|
||||
for _, c := range mod.Commands {
|
||||
reg.AllCommands[c.Name] = c
|
||||
}
|
||||
modules.Install(rb.Bot, reg, modules.Auth{BotOwnerID: ownerID})
|
||||
|
||||
if seedTarget != "" {
|
||||
g := &gameState{Target: seedTarget, Guesses: []string{}}
|
||||
if err := saveGame(context.Background(), kv, seedSubject, g); err != nil {
|
||||
t.Fatalf("seed game: %v", err)
|
||||
}
|
||||
}
|
||||
return rb, kv
|
||||
}
|
||||
|
||||
func TestQuote_NoArgShowsClue(t *testing.T) {
|
||||
rb, _ := installQuote(t, 0, "1", "Aatrox")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_quote"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "🎭 <i>") {
|
||||
t.Errorf("quote clue marker missing: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "</i>") {
|
||||
t.Errorf("quote italic close tag missing: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuote_Win(t *testing.T) {
|
||||
rb, _ := installQuote(t, 0, "1", "Aatrox")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_quote aatrox"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Nailed it") {
|
||||
t.Errorf("win reply missing 'Nailed it': %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Aatrox") {
|
||||
t.Errorf("win reply missing 'Aatrox': %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuote_UnknownChampion(t *testing.T) {
|
||||
rb, _ := installQuote(t, 0, "1", "Aatrox")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_quote ZilbeanZ"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Champion not found") {
|
||||
t.Errorf("unknown champion reject: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuote_DuplicateGuessRejected(t *testing.T) {
|
||||
rb, _ := installQuote(t, 0, "1", "Aatrox")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_quote ahri"))
|
||||
rb.Reset()
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_quote ahri"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "already guessed") {
|
||||
t.Errorf("duplicate-guess reply: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteGiveup_RevealsAnswer(t *testing.T) {
|
||||
rb, _ := installQuote(t, 0, "1", "Aatrox")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_quote_giveup"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Aatrox") {
|
||||
t.Errorf("/loldle_quote_giveup should reveal Aatrox: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteStats_Empty(t *testing.T) {
|
||||
rb, _ := installQuote(t, 0, "", "")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_quote_stats"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
for _, want := range []string{"Played: 0", "Wins: 0 (0%)"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("/loldle_quote_stats empty missing %q; got %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteSetMax_OwnerSucceeds(t *testing.T) {
|
||||
rb, kv := installQuote(t, 999, "", "")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_quote_setmax 4"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "max guesses set to 4") {
|
||||
t.Errorf("/loldle_quote_setmax reply: %q", got)
|
||||
}
|
||||
var cfg roundConfig
|
||||
if err := kv.GetJSON(context.Background(), configKey("999"), &cfg); err != nil {
|
||||
t.Fatalf("expected config persisted: %v", err)
|
||||
}
|
||||
if cfg.MaxGuesses != 4 {
|
||||
t.Errorf("MaxGuesses persisted = %d, want 4", cfg.MaxGuesses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteSetMax_DeniedToNonOwner(t *testing.T) {
|
||||
rb, _ := installQuote(t, 999, "", "")
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/loldle_quote_setmax 5"))
|
||||
if calls := rb.Sent(); len(calls) != 0 {
|
||||
t.Errorf("non-owner /loldle_quote_setmax replied: %+v", calls)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package loldlequote
|
||||
|
||||
import (
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
)
|
||||
|
||||
// New is the loldle-quote module Factory. Loads the embedded pool once and
|
||||
// shares it (plus the per-subject lock map) across all handlers.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
s := &state{kv: deps.KV, pool: loadPool()}
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "loldle_quote",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Quote loldle — guess the champion from a lore blurb",
|
||||
Handler: s.handleQuote,
|
||||
},
|
||||
{
|
||||
Name: "loldle_quote_giveup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Reveal the current quote loldle answer",
|
||||
Handler: s.handleGiveup,
|
||||
},
|
||||
{
|
||||
Name: "loldle_quote_stats",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show your quote loldle stats (wins, streak)",
|
||||
Handler: s.handleStats,
|
||||
},
|
||||
{
|
||||
Name: "loldle_quote_setmax",
|
||||
Visibility: modules.VisibilityPrivate,
|
||||
Description: "Override quote loldle max guesses per round (1-10)",
|
||||
Handler: s.handleSetMax,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package loldlequote
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/champname"
|
||||
)
|
||||
|
||||
// Generic lookup primitives live in internal/champname. This file only
|
||||
// exercises the embedded pool — wiring + filter integration test.
|
||||
func TestLoadPool_DropsEmptyQuoteRecords(t *testing.T) {
|
||||
pool := loadPool()
|
||||
if n := len(pool); n < 150 || n > 200 {
|
||||
t.Errorf("pool size = %d, want ~172", n)
|
||||
}
|
||||
for _, c := range pool {
|
||||
if strings.TrimSpace(c.Quote) == "" {
|
||||
t.Errorf("empty-quote record leaked through filter: %s", c.ChampionName)
|
||||
}
|
||||
}
|
||||
if got := champname.FindByExactName(pool, "Aatrox", championName); got == nil {
|
||||
t.Error("expected Aatrox in pool")
|
||||
} else if !strings.Contains(got.Quote, "___") {
|
||||
// JS source replaces the champion name with `___` as the redaction
|
||||
// marker. Lock the contract here so a future regen that forgets to
|
||||
// redact gets caught.
|
||||
t.Errorf("Aatrox quote missing `___` redaction marker: %q", got.Quote)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package loldlequote
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// renderBoard formats the quote clue + the wrong-guess list. JS-faithful:
|
||||
//
|
||||
// 🎭 <i>the Darkin Blade — Once honored defenders of Shurima against the Void, ___ ...</i>
|
||||
//
|
||||
// Guesses (2/6):
|
||||
// • Aatrox ❌
|
||||
// • Ahri ❌
|
||||
//
|
||||
// Empty board returns the placeholder hint. The quote is HTML-escaped before
|
||||
// wrapping in <i> so apostrophes / ampersands / stray angle brackets in the
|
||||
// data source can't break Telegram's HTML parse mode.
|
||||
func renderBoard(quote string, guesses []string, maxGuesses int) string {
|
||||
clue := "🎭 <i>" + html.EscapeString(quote) + "</i>"
|
||||
if len(guesses) == 0 {
|
||||
return clue + "\n\nNo guesses yet. Reply with <code>/loldle_quote <champion></code>."
|
||||
}
|
||||
lines := make([]string, len(guesses))
|
||||
for i, name := range guesses {
|
||||
lines[i] = " • " + html.EscapeString(name) + " ❌"
|
||||
}
|
||||
return fmt.Sprintf("%s\n\nGuesses (%d/%d):\n%s",
|
||||
clue, len(guesses), maxGuesses, strings.Join(lines, "\n"))
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package loldlequote
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderBoard_EmptyShowsHint(t *testing.T) {
|
||||
got := renderBoard("the test quote", nil, 6)
|
||||
if !strings.Contains(got, "🎭 <i>the test quote</i>") {
|
||||
t.Errorf("missing italic clue line: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "/loldle_quote <champion>") {
|
||||
t.Errorf("missing usage hint: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderBoard_GuessLines(t *testing.T) {
|
||||
got := renderBoard("clue", []string{"Aatrox", "Ahri"}, 6)
|
||||
for _, want := range []string{"Guesses (2/6):", "• Aatrox ❌", "• Ahri ❌"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HTML in either the quote text or a guess name (unlikely but defensive)
|
||||
// must be escaped before emission.
|
||||
func TestRenderBoard_EscapesHTMLInQuote(t *testing.T) {
|
||||
got := renderBoard(`<script>alert("x")</script>`, nil, 6)
|
||||
if strings.Contains(got, "<script>") {
|
||||
t.Errorf("raw <script> leaked: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "<script>") {
|
||||
t.Errorf("expected escaped <script>; got: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package loldlequote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
// Round-length defaults. Mirror JS state.js: 6 default, capped at 10 via
|
||||
// /loldle_quote_setmax. (Different from emoji's 5 — quote is harder.)
|
||||
const (
|
||||
MaxGuesses = 6
|
||||
MaxGuessesCap = 10
|
||||
)
|
||||
|
||||
// gameState is the per-subject KV record. Field tags match JS exactly;
|
||||
// StartedAt is *int64 for `null | number` parity (timer doesn't tick until
|
||||
// the player submits their first guess).
|
||||
type gameState struct {
|
||||
Target string `json:"target"`
|
||||
Guesses []string `json:"guesses"`
|
||||
StartedAt *int64 `json:"startedAt"`
|
||||
}
|
||||
|
||||
// stats lifetime score. Matches JS shape — no LastResultAt (parity with the
|
||||
// other loldle variants which also omit it).
|
||||
type stats struct {
|
||||
Played int `json:"played"`
|
||||
Wins int `json:"wins"`
|
||||
Streak int `json:"streak"`
|
||||
BestStreak int `json:"bestStreak"`
|
||||
}
|
||||
|
||||
type roundConfig struct {
|
||||
MaxGuesses int `json:"maxGuesses"`
|
||||
}
|
||||
|
||||
func gameKey(subject string) string { return "game:" + subject }
|
||||
func statsKey(subject string) string { return "stats:" + subject }
|
||||
func configKey(subject string) string { return "config:" + subject }
|
||||
|
||||
func loadGame(ctx context.Context, kv storage.KVStore, subject string) (*gameState, error) {
|
||||
var g gameState
|
||||
err := kv.GetJSON(ctx, gameKey(subject), &g)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &g, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("loldlequote 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("loldlequote saveGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearGame(ctx context.Context, kv storage.KVStore, subject string) error {
|
||||
if err := kv.Delete(ctx, gameKey(subject)); err != nil {
|
||||
return fmt.Errorf("loldlequote clearGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*stats, error) {
|
||||
var s stats
|
||||
err := kv.GetJSON(ctx, statsKey(subject), &s)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &s, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return &stats{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("loldlequote 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("loldlequote recordResult: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func getMaxGuesses(ctx context.Context, kv storage.KVStore, subject string) (int, error) {
|
||||
var cfg roundConfig
|
||||
err := kv.GetJSON(ctx, configKey(subject), &cfg)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
return MaxGuesses, nil
|
||||
}
|
||||
return 0, fmt.Errorf("loldlequote getMaxGuesses: %w", err)
|
||||
}
|
||||
if cfg.MaxGuesses < 1 || cfg.MaxGuesses > MaxGuessesCap {
|
||||
return MaxGuesses, nil
|
||||
}
|
||||
return cfg.MaxGuesses, nil
|
||||
}
|
||||
|
||||
func setMaxGuesses(ctx context.Context, kv storage.KVStore, subject string, n int) error {
|
||||
if n < 1 || n > MaxGuessesCap {
|
||||
return fmt.Errorf("loldlequote: 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("loldlequote setMaxGuesses: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package loldlequote
|
||||
|
||||
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 TestStats_NoLastResultAt(t *testing.T) {
|
||||
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_StreakSequence(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := storage.NewMemoryKVStore()
|
||||
|
||||
s, err := recordResult(ctx, kv, "u1", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Streak != 1 || s.BestStreak != 1 || s.Wins != 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 || s.BestStreak != 2 {
|
||||
t.Errorf("loss: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Locks the variant-specific default (6 — different from emoji's 5).
|
||||
func TestGetMaxGuesses_DefaultsToSix(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := storage.NewMemoryKVStore()
|
||||
if n, _ := getMaxGuesses(ctx, kv, "u1"); n != MaxGuesses {
|
||||
t.Errorf("default = %d, want %d", n, MaxGuesses)
|
||||
}
|
||||
if MaxGuesses != 6 {
|
||||
t.Errorf("MaxGuesses = %d, want 6 (parity with JS)", MaxGuesses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetGetMaxGuesses_RoundTripAndValidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := storage.NewMemoryKVStore()
|
||||
if err := setMaxGuesses(ctx, kv, "u1", 4); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := getMaxGuesses(ctx, kv, "u1"); n != 4 {
|
||||
t.Errorf("after set(4): %d", n)
|
||||
}
|
||||
for _, n := range []int{0, -1, MaxGuessesCap + 1} {
|
||||
if err := setMaxGuesses(ctx, kv, "u1", n); err == nil {
|
||||
t.Errorf("setMaxGuesses(%d) should error", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JS-wire-format decode: a record written by the JS bot must decode without
|
||||
// a custom decoder. Locks migration parity.
|
||||
func TestStateShapes_DecodeFromJSWire(t *testing.T) {
|
||||
t.Run("game with null startedAt", func(t *testing.T) {
|
||||
var g gameState
|
||||
raw := []byte(`{"target":"Aatrox","guesses":[],"startedAt":null}`)
|
||||
if err := json.Unmarshal(raw, &g); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if g.Target != "Aatrox" || len(g.Guesses) != 0 || g.StartedAt != nil {
|
||||
t.Errorf("decoded: %+v", g)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stats", func(t *testing.T) {
|
||||
var s stats
|
||||
raw := []byte(`{"played":7,"wins":4,"streak":2,"bestStreak":3}`)
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if s.Played != 7 || s.Wins != 4 || s.Streak != 2 || s.BestStreak != 3 {
|
||||
t.Errorf("decoded: %+v", s)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("config", func(t *testing.T) {
|
||||
var c roundConfig
|
||||
raw := []byte(`{"maxGuesses":7}`)
|
||||
if err := json.Unmarshal(raw, &c); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if c.MaxGuesses != 7 {
|
||||
t.Errorf("decoded: %+v", c)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSaveLoadClear_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, _ := loadGame(ctx, kv, "u1")
|
||||
if got == nil || got.Target != "Aatrox" || 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 clear, got %+v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// Package loldlesplash ports the JS loldle-splash variant — guess the
|
||||
// champion from a randomly-chosen splash art (any skin, including Default).
|
||||
// Pool seeded from Riot Data Dragon. Uses Telegram's sendPhoto with the
|
||||
// DDragon CDN URL directly — no binary embedding.
|
||||
//
|
||||
// Round state persists `{target, skinId, guesses, startedAt}` so the SAME
|
||||
// splash shows across all turns until the round ends.
|
||||
package loldlesplash
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Skin is one champion-skin record: numeric id, display name, splash URL.
|
||||
type Skin struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"` // absolute DDragon CDN URL
|
||||
}
|
||||
|
||||
// SplashChampion is one record of splashes.json — championName + the full
|
||||
// skin list including the Default skin.
|
||||
type SplashChampion struct {
|
||||
ChampionName string `json:"championName"`
|
||||
Skins []Skin `json:"skins"`
|
||||
}
|
||||
|
||||
//go:embed data/splashes.json
|
||||
var rawSplashes []byte
|
||||
|
||||
// loadPool parses splashes.json and drops champions with no skins. Panics
|
||||
// on malformed data — corrupt regen is a build-time bug.
|
||||
func loadPool() []SplashChampion {
|
||||
var all []SplashChampion
|
||||
if err := json.Unmarshal(rawSplashes, &all); err != nil {
|
||||
panic(fmt.Sprintf("loldlesplash: cannot decode splashes.json: %v", err))
|
||||
}
|
||||
out := make([]SplashChampion, 0, len(all))
|
||||
for _, c := range all {
|
||||
if len(c.Skins) > 0 {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
panic("loldlesplash: splashes.json contained no usable records")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// skinByID finds the skin with the given numeric id. Returns nil when id
|
||||
// isn't present — caller treats that as a refresh signal (start over).
|
||||
func skinByID(c *SplashChampion, id int) *Skin {
|
||||
for i := range c.Skins {
|
||||
if c.Skins[i].ID == id {
|
||||
return &c.Skins[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,256 +0,0 @@
|
||||
package loldlesplash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/champname"
|
||||
"github.com/tiennm99/miti99bot-go/internal/keylock"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
const newRoundHint = "🆕 Send <code>/loldle_splash</code> or <code>/loldle_splash <champion></code> to start a new round."
|
||||
|
||||
// state captures everything a loldle-splash handler needs at runtime. Built
|
||||
// once per Factory call and shared across the four command closures.
|
||||
type state struct {
|
||||
kv storage.KVStore
|
||||
pool []SplashChampion
|
||||
locks keylock.Map // serialises Get→mutate→Put per subject
|
||||
}
|
||||
|
||||
// championName extracts the comparable name field for champname helpers.
|
||||
func championName(c *SplashChampion) string { return c.ChampionName }
|
||||
|
||||
func (s *state) pickRandomChampion() *SplashChampion {
|
||||
return &s.pool[rand.Intn(len(s.pool))]
|
||||
}
|
||||
|
||||
func pickRandomSkin(c *SplashChampion) *Skin {
|
||||
return &c.Skins[rand.Intn(len(c.Skins))]
|
||||
}
|
||||
|
||||
func (s *state) startFreshGame(ctx context.Context, subject string) (*gameState, error) {
|
||||
target := s.pickRandomChampion()
|
||||
skin := pickRandomSkin(target)
|
||||
g := &gameState{
|
||||
Target: target.ChampionName,
|
||||
SkinID: skin.ID,
|
||||
Guesses: []string{},
|
||||
StartedAt: nil,
|
||||
}
|
||||
if err := saveGame(ctx, s.kv, subject, g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (s *state) getOrInitGame(ctx context.Context, subject string, maxGuesses int) (*gameState, error) {
|
||||
existing, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil && len(existing.Guesses) < maxGuesses {
|
||||
return existing, nil
|
||||
}
|
||||
return s.startFreshGame(ctx, subject)
|
||||
}
|
||||
|
||||
// caption is the photo caption shown above each round-in-progress splash.
|
||||
func caption(guesses, maxGuesses int) string {
|
||||
return fmt.Sprintf("🎨 Guess the champion from this splash art. %d/%d guesses so far.", guesses, maxGuesses)
|
||||
}
|
||||
|
||||
// sendSplash dispatches sendPhoto with the splash URL. Returns the bot
|
||||
// library's error verbatim — caller decides whether to log/ignore.
|
||||
func sendSplash(ctx context.Context, b *bot.Bot, chatID int64, skin *Skin, captionText string) error {
|
||||
_, err := b.SendPhoto(ctx, &bot.SendPhotoParams{
|
||||
ChatID: chatID,
|
||||
Photo: &models.InputFileString{Data: skin.URL},
|
||||
Caption: captionText,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// handleSplash is /loldle_splash [champion] — show splash if no arg, else guess.
|
||||
func (s *state) handleSplash(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
|
||||
maxGuesses, err := getMaxGuesses(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
game, err := s.getOrInitGame(ctx, subject, maxGuesses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := champname.FindByExactName(s.pool, game.Target, championName)
|
||||
var skin *Skin
|
||||
if target != nil {
|
||||
skin = skinByID(target, game.SkinID)
|
||||
}
|
||||
if target == nil || skin == nil {
|
||||
// Pool was refreshed mid-round and the skin is gone — drop the round.
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
"Splash data was updated since this round started. "+newRoundHint)
|
||||
}
|
||||
|
||||
if arg == "" {
|
||||
return sendSplash(ctx, b, msg.Chat.ID, skin, caption(len(game.Guesses), maxGuesses))
|
||||
}
|
||||
|
||||
guess := champname.Find(s.pool, arg, championName)
|
||||
if guess == nil {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Champion not found: %q.", arg))
|
||||
}
|
||||
|
||||
for _, prior := range game.Guesses {
|
||||
if prior == guess.ChampionName {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"🔁 <b>%s</b> was already guessed this round — try another champion.",
|
||||
html.EscapeString(guess.ChampionName)))
|
||||
}
|
||||
}
|
||||
|
||||
if game.StartedAt == nil {
|
||||
now := chathelper.NowMillis()
|
||||
game.StartedAt = &now
|
||||
}
|
||||
game.Guesses = append(game.Guesses, guess.ChampionName)
|
||||
won := guess.ChampionName == target.ChampionName
|
||||
answer := html.EscapeString(target.ChampionName)
|
||||
skinLabel := fmt.Sprintf("<i>%s</i> skin", html.EscapeString(skin.Name))
|
||||
|
||||
switch {
|
||||
case won:
|
||||
st, err := recordResult(ctx, s.kv, subject, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"🎉 Got it! That was <b>%s</b> in %s. Solved in %d/%d\n🔥 Streak: %d\n%s",
|
||||
answer, skinLabel, len(game.Guesses), maxGuesses, st.Streak, newRoundHint))
|
||||
|
||||
case len(game.Guesses) >= maxGuesses:
|
||||
if _, err := recordResult(ctx, s.kv, subject, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"❌ Out of guesses. Answer was <b>%s</b> in %s.\n%s",
|
||||
answer, skinLabel, newRoundHint))
|
||||
|
||||
default:
|
||||
if err := saveGame(ctx, s.kv, subject, game); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"❌ Not <b>%s</b>. Guess %d/%d.",
|
||||
html.EscapeString(guess.ChampionName), len(game.Guesses), maxGuesses))
|
||||
}
|
||||
}
|
||||
|
||||
// handleGiveup is /loldle_splash_giveup — reveal answer + clear round.
|
||||
func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
|
||||
existing, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, "No active round. "+newRoundHint)
|
||||
}
|
||||
if _, err := recordResult(ctx, s.kv, subject, false); err != nil {
|
||||
return err
|
||||
}
|
||||
target := champname.FindByExactName(s.pool, existing.Target, championName)
|
||||
var label string
|
||||
if target != nil {
|
||||
if sk := skinByID(target, existing.SkinID); sk != nil {
|
||||
label = fmt.Sprintf(" in <i>%s</i> skin", html.EscapeString(sk.Name))
|
||||
}
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"🏳️ Answer was <b>%s</b>%s.\n%s",
|
||||
html.EscapeString(existing.Target), label, newRoundHint))
|
||||
}
|
||||
|
||||
// handleStats is /loldle_splash_stats — lifetime score.
|
||||
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
st, err := loadStats(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scope := "group"
|
||||
if msg.Chat.Type == models.ChatTypePrivate {
|
||||
scope = "your"
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf(
|
||||
"📊 Loldle Splash %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d",
|
||||
scope, st.Played, st.Wins, chathelper.WinRate(st.Wins, st.Played), st.Streak, st.BestStreak))
|
||||
}
|
||||
|
||||
// handleSetMax is /loldle_splash_setmax <n> — private; per-subject override.
|
||||
func (s *state) handleSetMax(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
n, err := strconv.Atoi(arg)
|
||||
if err != nil || n < 1 || n > MaxGuessesCap {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("Usage: /loldle_splash_setmax <1-%d>", MaxGuessesCap))
|
||||
}
|
||||
if err := setMaxGuesses(ctx, s.kv, subject, n); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, fmt.Sprintf("✅ Loldle splash max guesses set to %d (applies to the next round).", n))
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package loldlesplash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
"github.com/tiennm99/miti99bot-go/internal/testutil"
|
||||
)
|
||||
|
||||
// installSplash wires the loldle-splash module + auth (owner gates
|
||||
// /loldle_splash_setmax). seedTarget + seedSkinID pre-seed a game so guess
|
||||
// outcomes are deterministic without hooking math/rand.
|
||||
func installSplash(t *testing.T, ownerID int64, seedSubject, seedTarget string, seedSkinID int) (*testutil.RecordingBot, storage.KVStore) {
|
||||
t.Helper()
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
provider := storage.NewMemoryProvider()
|
||||
kv := provider.For("loldle-splash")
|
||||
mod := New(modules.Deps{KV: kv})
|
||||
reg := &modules.Registry{
|
||||
Modules: []modules.Module{{Name: "loldle-splash", Commands: mod.Commands}},
|
||||
AllCommands: map[string]modules.Command{},
|
||||
}
|
||||
for _, c := range mod.Commands {
|
||||
reg.AllCommands[c.Name] = c
|
||||
}
|
||||
modules.Install(rb.Bot, reg, modules.Auth{BotOwnerID: ownerID})
|
||||
|
||||
if seedTarget != "" {
|
||||
g := &gameState{Target: seedTarget, SkinID: seedSkinID, Guesses: []string{}}
|
||||
if err := saveGame(context.Background(), kv, seedSubject, g); err != nil {
|
||||
t.Fatalf("seed game: %v", err)
|
||||
}
|
||||
}
|
||||
return rb, kv
|
||||
}
|
||||
|
||||
func TestSplash_NoArgSendsPhoto(t *testing.T) {
|
||||
rb, _ := installSplash(t, 0, "1", "Aatrox", 0)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_splash"))
|
||||
|
||||
calls := rb.Sent()
|
||||
if len(calls) == 0 {
|
||||
t.Fatal("/loldle_splash produced no reply")
|
||||
}
|
||||
last := calls[len(calls)-1]
|
||||
if last.Method != "sendPhoto" {
|
||||
t.Errorf("method = %q, want sendPhoto", last.Method)
|
||||
}
|
||||
photo := last.Form["photo"]
|
||||
if !strings.Contains(photo, "Aatrox_0.jpg") {
|
||||
t.Errorf("photo = %q, want Aatrox_0 splash URL", photo)
|
||||
}
|
||||
caption := last.Form["caption"]
|
||||
if !strings.Contains(caption, "splash art") {
|
||||
t.Errorf("caption missing prompt: %q", caption)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplash_Win(t *testing.T) {
|
||||
rb, _ := installSplash(t, 0, "1", "Aatrox", 0)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_splash aatrox"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Got it") {
|
||||
t.Errorf("win reply missing 'Got it': %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Aatrox") {
|
||||
t.Errorf("win reply missing 'Aatrox': %q", got)
|
||||
}
|
||||
// Skin label format: "in <i>Default</i> skin"
|
||||
if !strings.Contains(got, "Default") {
|
||||
t.Errorf("win reply missing skin name: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplash_UnknownChampion(t *testing.T) {
|
||||
rb, _ := installSplash(t, 0, "1", "Aatrox", 0)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_splash ZilbeanZ"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Champion not found") {
|
||||
t.Errorf("unknown champion reject: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplashGiveup_RevealsAnswerAndSkin(t *testing.T) {
|
||||
rb, _ := installSplash(t, 0, "1", "Aatrox", 0)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_splash_giveup"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "Aatrox") {
|
||||
t.Errorf("/loldle_splash_giveup should reveal Aatrox: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Default") {
|
||||
t.Errorf("/loldle_splash_giveup should include skin label: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplashStats_Empty(t *testing.T) {
|
||||
rb, _ := installSplash(t, 0, "", "", 0)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/loldle_splash_stats"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
for _, want := range []string{"Played: 0", "Wins: 0 (0%)"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("/loldle_splash_stats empty missing %q; got %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplashSetMax_OwnerSucceeds(t *testing.T) {
|
||||
rb, kv := installSplash(t, 999, "", "", 0)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/loldle_splash_setmax 5"))
|
||||
|
||||
got := rb.LastSent().Text()
|
||||
if !strings.Contains(got, "max guesses set to 5") {
|
||||
t.Errorf("/loldle_splash_setmax reply: %q", got)
|
||||
}
|
||||
var cfg roundConfig
|
||||
if err := kv.GetJSON(context.Background(), configKey("999"), &cfg); err != nil {
|
||||
t.Fatalf("expected config persisted: %v", err)
|
||||
}
|
||||
if cfg.MaxGuesses != 5 {
|
||||
t.Errorf("MaxGuesses persisted = %d, want 5", cfg.MaxGuesses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplashSetMax_DeniedToNonOwner(t *testing.T) {
|
||||
rb, _ := installSplash(t, 999, "", "", 0)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/loldle_splash_setmax 5"))
|
||||
if calls := rb.Sent(); len(calls) != 0 {
|
||||
t.Errorf("non-owner /loldle_splash_setmax replied: %+v", calls)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package loldlesplash
|
||||
|
||||
import (
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
)
|
||||
|
||||
// New is the loldle-splash module Factory. Loads the embedded pool once
|
||||
// and shares it (plus the per-subject lock map) across all handlers.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
s := &state{kv: deps.KV, pool: loadPool()}
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "loldle_splash",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Splash loldle — guess the champion from a splash art",
|
||||
Handler: s.handleSplash,
|
||||
},
|
||||
{
|
||||
Name: "loldle_splash_giveup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Reveal the current splash loldle answer",
|
||||
Handler: s.handleGiveup,
|
||||
},
|
||||
{
|
||||
Name: "loldle_splash_stats",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show your splash loldle stats (wins, streak)",
|
||||
Handler: s.handleStats,
|
||||
},
|
||||
{
|
||||
Name: "loldle_splash_setmax",
|
||||
Visibility: modules.VisibilityPrivate,
|
||||
Description: "Override splash loldle max guesses per round (1-10)",
|
||||
Handler: s.handleSetMax,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package loldlesplash
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/champname"
|
||||
)
|
||||
|
||||
func TestLoadPool_SkinsNonEmpty(t *testing.T) {
|
||||
pool := loadPool()
|
||||
if n := len(pool); n < 150 || n > 200 {
|
||||
t.Errorf("pool size = %d, want ~172", n)
|
||||
}
|
||||
for _, c := range pool {
|
||||
if len(c.Skins) == 0 {
|
||||
t.Errorf("empty skins record leaked through filter: %s", c.ChampionName)
|
||||
}
|
||||
}
|
||||
got := champname.FindByExactName(pool, "Aatrox", championName)
|
||||
if got == nil {
|
||||
t.Fatal("expected Aatrox in pool")
|
||||
}
|
||||
if len(got.Skins) < 2 {
|
||||
t.Errorf("Aatrox should have multiple skins, got %d", len(got.Skins))
|
||||
}
|
||||
for _, s := range got.Skins {
|
||||
if !strings.HasPrefix(s.URL, "https://ddragon.leagueoflegends.com/cdn/img/champion/splash/") {
|
||||
t.Errorf("Aatrox skin %q URL is not a DDragon splash URL: %q", s.Name, s.URL)
|
||||
}
|
||||
}
|
||||
// Default skin (id=0) must always be present and named "Default".
|
||||
if got.Skins[0].ID != 0 || got.Skins[0].Name != "Default" {
|
||||
t.Errorf("Aatrox first skin = (%d, %q), want (0, Default)", got.Skins[0].ID, got.Skins[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkinByID(t *testing.T) {
|
||||
c := &SplashChampion{
|
||||
ChampionName: "Test",
|
||||
Skins: []Skin{
|
||||
{ID: 0, Name: "Default"},
|
||||
{ID: 3, Name: "Mecha"},
|
||||
{ID: 5, Name: "Sea Hunter"},
|
||||
},
|
||||
}
|
||||
if got := skinByID(c, 3); got == nil || got.Name != "Mecha" {
|
||||
t.Errorf("skinByID(3) = %v, want Mecha", got)
|
||||
}
|
||||
// Unknown id → nil (caller treats as refresh signal).
|
||||
if got := skinByID(c, 99); got != nil {
|
||||
t.Errorf("skinByID(99) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package loldlesplash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
// Round-length defaults. Mirror JS state.js: 4 default (harder than ability;
|
||||
// any skin can be drawn so non-Default art is in the rotation), capped at
|
||||
// 10 via /loldle_splash_setmax.
|
||||
const (
|
||||
MaxGuesses = 4
|
||||
MaxGuessesCap = 10
|
||||
)
|
||||
|
||||
// gameState locks the chosen skin id at round start so the SAME splash art
|
||||
// shows across every turn until the round ends. Field tags match JS.
|
||||
type gameState struct {
|
||||
Target string `json:"target"`
|
||||
SkinID int `json:"skinId"` // numeric skin id from splashes.json
|
||||
Guesses []string `json:"guesses"`
|
||||
StartedAt *int64 `json:"startedAt"`
|
||||
}
|
||||
|
||||
type stats struct {
|
||||
Played int `json:"played"`
|
||||
Wins int `json:"wins"`
|
||||
Streak int `json:"streak"`
|
||||
BestStreak int `json:"bestStreak"`
|
||||
}
|
||||
|
||||
type roundConfig struct {
|
||||
MaxGuesses int `json:"maxGuesses"`
|
||||
}
|
||||
|
||||
func gameKey(subject string) string { return "game:" + subject }
|
||||
func statsKey(subject string) string { return "stats:" + subject }
|
||||
func configKey(subject string) string { return "config:" + subject }
|
||||
|
||||
func loadGame(ctx context.Context, kv storage.KVStore, subject string) (*gameState, error) {
|
||||
var g gameState
|
||||
err := kv.GetJSON(ctx, gameKey(subject), &g)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &g, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("loldlesplash loadGame: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func saveGame(ctx context.Context, kv storage.KVStore, subject string, g *gameState) error {
|
||||
if err := kv.PutJSON(ctx, gameKey(subject), g); err != nil {
|
||||
return fmt.Errorf("loldlesplash saveGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearGame(ctx context.Context, kv storage.KVStore, subject string) error {
|
||||
if err := kv.Delete(ctx, gameKey(subject)); err != nil {
|
||||
return fmt.Errorf("loldlesplash clearGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*stats, error) {
|
||||
var s stats
|
||||
err := kv.GetJSON(ctx, statsKey(subject), &s)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &s, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return &stats{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("loldlesplash loadStats: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func recordResult(ctx context.Context, kv storage.KVStore, subject string, won bool) (*stats, error) {
|
||||
s, err := loadStats(ctx, kv, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Played++
|
||||
if won {
|
||||
s.Wins++
|
||||
s.Streak++
|
||||
if s.Streak > s.BestStreak {
|
||||
s.BestStreak = s.Streak
|
||||
}
|
||||
} else {
|
||||
s.Streak = 0
|
||||
}
|
||||
if err := kv.PutJSON(ctx, statsKey(subject), s); err != nil {
|
||||
return nil, fmt.Errorf("loldlesplash recordResult: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func getMaxGuesses(ctx context.Context, kv storage.KVStore, subject string) (int, error) {
|
||||
var cfg roundConfig
|
||||
err := kv.GetJSON(ctx, configKey(subject), &cfg)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
return MaxGuesses, nil
|
||||
}
|
||||
return 0, fmt.Errorf("loldlesplash getMaxGuesses: %w", err)
|
||||
}
|
||||
if cfg.MaxGuesses < 1 || cfg.MaxGuesses > MaxGuessesCap {
|
||||
return MaxGuesses, nil
|
||||
}
|
||||
return cfg.MaxGuesses, nil
|
||||
}
|
||||
|
||||
func setMaxGuesses(ctx context.Context, kv storage.KVStore, subject string, n int) error {
|
||||
if n < 1 || n > MaxGuessesCap {
|
||||
return fmt.Errorf("loldlesplash: maxGuesses must be in [1, %d], got %d", MaxGuessesCap, n)
|
||||
}
|
||||
if err := kv.PutJSON(ctx, configKey(subject), roundConfig{MaxGuesses: n}); err != nil {
|
||||
return fmt.Errorf("loldlesplash setMaxGuesses: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package loldlesplash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
// gameState gains a `skinId` field — locks the chosen splash at round start.
|
||||
func TestGameState_IncludesSkinIDField(t *testing.T) {
|
||||
g := gameState{Target: "Aatrox", SkinID: 3, Guesses: []string{}}
|
||||
b, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"target":"Aatrox","skinId":3,"guesses":[],"startedAt":null}`
|
||||
if string(b) != want {
|
||||
t.Errorf("marshal:\ngot %s\nwant %s", b, want)
|
||||
}
|
||||
}
|
||||
|
||||
// JS-wire-format decode parity: a record written by the JS bot must decode
|
||||
// directly. Locks the skinId field name + null-startedAt round-trip.
|
||||
func TestGameState_DecodeFromJSWire(t *testing.T) {
|
||||
var g gameState
|
||||
raw := []byte(`{"target":"Ahri","skinId":7,"guesses":["Akali"],"startedAt":1700000000000}`)
|
||||
if err := json.Unmarshal(raw, &g); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if g.Target != "Ahri" || g.SkinID != 7 || len(g.Guesses) != 1 || g.StartedAt == nil || *g.StartedAt != 1700000000000 {
|
||||
t.Errorf("decoded: %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMaxGuesses_DefaultsToFour(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := storage.NewMemoryKVStore()
|
||||
if n, _ := getMaxGuesses(ctx, kv, "u1"); n != MaxGuesses {
|
||||
t.Errorf("default = %d, want %d", n, MaxGuesses)
|
||||
}
|
||||
if MaxGuesses != 4 {
|
||||
t.Errorf("MaxGuesses = %d, want 4 (parity with JS — splash is harder than ability)", MaxGuesses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordResult_StreakSequence(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := storage.NewMemoryKVStore()
|
||||
s, _ := recordResult(ctx, kv, "u1", true)
|
||||
if s.Streak != 1 || s.Wins != 1 {
|
||||
t.Errorf("first win: %+v", s)
|
||||
}
|
||||
s, _ = recordResult(ctx, kv, "u1", false)
|
||||
if s.Streak != 0 || s.BestStreak != 1 {
|
||||
t.Errorf("loss after streak=1: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLoadClear_RoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := storage.NewMemoryKVStore()
|
||||
at := int64(42)
|
||||
want := &gameState{Target: "Aatrox", SkinID: 5, Guesses: []string{"Ahri"}, StartedAt: &at}
|
||||
if err := saveGame(ctx, kv, "u1", want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := loadGame(ctx, kv, "u1")
|
||||
if got == nil || got.SkinID != 5 {
|
||||
t.Errorf("round-trip lost skinId: %+v", got)
|
||||
}
|
||||
if err := clearGame(ctx, kv, "u1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = loadGame(ctx, kv, "u1")
|
||||
if got != nil {
|
||||
t.Errorf("after clear, got %+v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -60,13 +60,7 @@ type Module struct {
|
||||
Crons []Cron
|
||||
}
|
||||
|
||||
// Deps is the dependency bundle a Factory receives. Each field is added in the
|
||||
// phase that introduces it; today KV, Env, and Registry exist (Gemini: Phase 07).
|
||||
//
|
||||
// Deps.Env is empty by default — process env does NOT auto-flow to modules
|
||||
// (allowlist semantics). Phase 07+ introduces a per-module env declaration so
|
||||
// keys flow only to declared consumers; this prevents a future API key from
|
||||
// silently reaching every module.
|
||||
// Deps is the dependency bundle a Factory receives.
|
||||
//
|
||||
// Deps.Registry is a pointer to the Registry being built. At factory call
|
||||
// time the Registry is partially populated (only modules earlier in the
|
||||
@@ -74,12 +68,10 @@ type Module struct {
|
||||
// Modules that need to introspect commands (e.g. /help) capture this pointer
|
||||
// in their handler closures.
|
||||
type Deps struct {
|
||||
KV storage.KVStore // already prefixed with the module name when passed to a Factory
|
||||
Env map[string]string // empty by default; per-module allowlist (Phase 07+)
|
||||
Registry *Registry // populated by Build; safe to capture but read-only at module use
|
||||
Embedder ai.Embedder // nil if GEMINI_API_KEY unset; semantle/doantu must check
|
||||
Chatter ai.Chatter // nil if GEMINI_API_KEY unset; twentyq must check
|
||||
Bot *bot.Bot // nil-safe: only crons that fan-out (lolschedule daily push) need it
|
||||
KV storage.KVStore // already prefixed with the module name when passed to a Factory
|
||||
Registry *Registry // populated by Build; safe to capture but read-only at module use
|
||||
Chatter ai.Chatter // nil if GEMINI_API_KEY unset; twentyq must check
|
||||
Bot *bot.Bot // nil-safe: only crons that fan-out (lolschedule daily push) need it
|
||||
}
|
||||
|
||||
// Factory constructs a Module from its Deps. Spec deviation: Phase 03 plan
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
)
|
||||
|
||||
// moduleNameRe is intentionally looser than commandNameRe — it allows hyphen
|
||||
// so modules can keep their JS-source names verbatim (e.g. "loldle-emoji").
|
||||
// The crucial constraint is "no `:`" so the storage Prefixed wrapper's `:`
|
||||
// so module names can carry hyphenated suffixes (e.g. "loldle-classic"). The
|
||||
// crucial constraint is "no `:`" so the storage Prefixed wrapper's `:`
|
||||
// delimiter cannot be subverted; everything else is style.
|
||||
//
|
||||
// Telegram command names still need the stricter [a-z0-9_]{1,32} alphabet
|
||||
@@ -83,12 +83,11 @@ func (r *Registry) Crons() []Cron {
|
||||
// Factory's Deps. Adding new optional deps here keeps Build's signature
|
||||
// stable as the dep list grows.
|
||||
type BuildOptions struct {
|
||||
Embedder ai.Embedder
|
||||
Chatter ai.Chatter
|
||||
Bot *bot.Bot
|
||||
Chatter ai.Chatter
|
||||
Bot *bot.Bot
|
||||
}
|
||||
|
||||
func Build(enabled []string, factories map[string]Factory, kv storage.KVProvider, env map[string]string, opts BuildOptions) (*Registry, error) {
|
||||
func Build(enabled []string, factories map[string]Factory, kv storage.KVProvider, opts BuildOptions) (*Registry, error) {
|
||||
if kv == nil {
|
||||
return nil, fmt.Errorf("modules: KVProvider is required")
|
||||
}
|
||||
@@ -124,9 +123,7 @@ func Build(enabled []string, factories map[string]Factory, kv storage.KVProvider
|
||||
|
||||
moduleDeps := Deps{
|
||||
KV: kv.For(name),
|
||||
Env: env,
|
||||
Registry: reg,
|
||||
Embedder: opts.Embedder,
|
||||
Chatter: opts.Chatter,
|
||||
Bot: opts.Bot,
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func factory(name string, cmds []Command, crons []Cron) Factory {
|
||||
func newProvider() storage.KVProvider { return storage.NewMemoryProvider() }
|
||||
|
||||
func TestBuild_EmptyModulesBootsCleanly(t *testing.T) {
|
||||
reg, err := Build(nil, map[string]Factory{}, newProvider(), nil, BuildOptions{})
|
||||
reg, err := Build(nil, map[string]Factory{}, newProvider(), BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build empty: %v", err)
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func TestBuild_LoadsRequestedModules(t *testing.T) {
|
||||
"alpha": factory("alpha", []Command{noopCmd("a1")}, nil),
|
||||
"beta": factory("beta", []Command{noopCmd("b1")}, []Cron{noopCron("daily")}),
|
||||
}
|
||||
reg, err := Build([]string{"alpha", "beta"}, factories, newProvider(), nil, BuildOptions{})
|
||||
reg, err := Build([]string{"alpha", "beta"}, factories, newProvider(), BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func TestBuild_SkipsModulesNotInEnv(t *testing.T) {
|
||||
"alpha": factory("alpha", []Command{noopCmd("a1")}, nil),
|
||||
"beta": factory("beta", []Command{noopCmd("b1")}, nil),
|
||||
}
|
||||
reg, err := Build([]string{"alpha"}, factories, newProvider(), nil, BuildOptions{})
|
||||
reg, err := Build([]string{"alpha"}, factories, newProvider(), BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func TestBuild_SkipsModulesNotInEnv(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuild_RejectsUnknownModule(t *testing.T) {
|
||||
_, err := Build([]string{"ghost"}, map[string]Factory{}, newProvider(), nil, BuildOptions{})
|
||||
_, err := Build([]string{"ghost"}, map[string]Factory{}, newProvider(), BuildOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "ghost") {
|
||||
t.Errorf("expected error mentioning ghost, got %v", err)
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func TestBuild_DetectsCommandConflict(t *testing.T) {
|
||||
"alpha": factory("alpha", []Command{noopCmd("ping")}, nil),
|
||||
"beta": factory("beta", []Command{noopCmd("ping")}, nil),
|
||||
}
|
||||
_, err := Build([]string{"alpha", "beta"}, factories, newProvider(), nil, BuildOptions{})
|
||||
_, err := Build([]string{"alpha", "beta"}, factories, newProvider(), BuildOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected conflict error")
|
||||
}
|
||||
@@ -107,14 +107,14 @@ func TestBuild_DetectsCronConflict(t *testing.T) {
|
||||
"alpha": factory("alpha", nil, []Cron{noopCron("daily")}),
|
||||
"beta": factory("beta", nil, []Cron{noopCron("daily")}),
|
||||
}
|
||||
_, err := Build([]string{"alpha", "beta"}, factories, newProvider(), nil, BuildOptions{})
|
||||
_, err := Build([]string{"alpha", "beta"}, factories, newProvider(), BuildOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "cron conflict") {
|
||||
t.Errorf("expected cron conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_RequiresProvider(t *testing.T) {
|
||||
_, err := Build(nil, map[string]Factory{}, nil, nil, BuildOptions{})
|
||||
_, err := Build(nil, map[string]Factory{}, nil, BuildOptions{})
|
||||
if err == nil {
|
||||
t.Error("expected error when KVProvider is nil")
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func TestBuild_ValidationErrorsMentionModule(t *testing.T) {
|
||||
factories := map[string]Factory{
|
||||
"alpha": factory("alpha", []Command{bad}, nil),
|
||||
}
|
||||
_, err := Build([]string{"alpha"}, factories, newProvider(), nil, BuildOptions{})
|
||||
_, err := Build([]string{"alpha"}, factories, newProvider(), BuildOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "alpha") {
|
||||
t.Errorf("expected error mentioning module 'alpha', got %v", err)
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func TestDispatchScheduled_RunsHandler(t *testing.T) {
|
||||
},
|
||||
}}),
|
||||
}
|
||||
reg, err := Build([]string{"alpha"}, factories, newProvider(), nil, BuildOptions{})
|
||||
reg, err := Build([]string{"alpha"}, factories, newProvider(), BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -155,7 +155,7 @@ func TestDispatchScheduled_RunsHandler(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDispatchScheduled_UnknownReturnsErrCronNotFound(t *testing.T) {
|
||||
reg, err := Build(nil, map[string]Factory{}, newProvider(), nil, BuildOptions{})
|
||||
reg, err := Build(nil, map[string]Factory{}, newProvider(), BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -187,7 +187,7 @@ func TestDispatchScheduled_PassesPrefixedDeps(t *testing.T) {
|
||||
}}}
|
||||
},
|
||||
}
|
||||
reg, err := Build([]string{"alpha", "beta"}, factories, provider, nil, BuildOptions{})
|
||||
reg, err := Build([]string{"alpha", "beta"}, factories, provider, BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -210,12 +210,12 @@ func TestDispatchScheduled_PassesPrefixedDeps(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuild_RejectsInvalidModuleName(t *testing.T) {
|
||||
// `-` is intentionally allowed (loldle-emoji and friends carry hyphenated
|
||||
// names from the JS source). `:` must stay rejected — it's the storage
|
||||
// prefix delimiter and a hyphen-allowing regex must not let it through.
|
||||
// `-` is intentionally allowed so modules can carry hyphenated names. `:`
|
||||
// must stay rejected — it's the storage prefix delimiter and a
|
||||
// hyphen-allowing regex must not let it through.
|
||||
for _, name := range []string{"BadName", "a:b", "", "with space", "with.dot", "with/slash"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := Build([]string{name}, map[string]Factory{}, newProvider(), nil, BuildOptions{})
|
||||
_, err := Build([]string{name}, map[string]Factory{}, newProvider(), BuildOptions{})
|
||||
if err == nil {
|
||||
t.Errorf("name %q: expected error", name)
|
||||
}
|
||||
@@ -231,7 +231,7 @@ func TestBuild_RejectsFactoryNameMismatch(t *testing.T) {
|
||||
return Module{Name: "imposter", Commands: []Command{noopCmd("a1")}}
|
||||
},
|
||||
}
|
||||
_, err := Build([]string{"alpha"}, factories, newProvider(), nil, BuildOptions{})
|
||||
_, err := Build([]string{"alpha"}, factories, newProvider(), BuildOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for factory Name mismatch")
|
||||
}
|
||||
@@ -248,7 +248,7 @@ func TestBuild_AllowsFactoryWithBlankName(t *testing.T) {
|
||||
return Module{Commands: []Command{noopCmd("a1")}}
|
||||
},
|
||||
}
|
||||
reg, err := Build([]string{"alpha"}, factories, newProvider(), nil, BuildOptions{})
|
||||
reg, err := Build([]string{"alpha"}, factories, newProvider(), BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -259,13 +259,13 @@ func TestBuild_AllowsFactoryWithBlankName(t *testing.T) {
|
||||
|
||||
func TestBuild_AcceptsHyphenatedModuleName(t *testing.T) {
|
||||
factories := map[string]Factory{
|
||||
"loldle-emoji": factory("loldle-emoji", []Command{noopCmd("emoji_cmd")}, nil),
|
||||
"demo-mod": factory("demo-mod", []Command{noopCmd("demo_cmd")}, nil),
|
||||
}
|
||||
reg, err := Build([]string{"loldle-emoji"}, factories, newProvider(), nil, BuildOptions{})
|
||||
reg, err := Build([]string{"demo-mod"}, factories, newProvider(), BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("hyphenated name should be allowed: %v", err)
|
||||
}
|
||||
if len(reg.Modules) != 1 || reg.Modules[0].Name != "loldle-emoji" {
|
||||
if len(reg.Modules) != 1 || reg.Modules[0].Name != "demo-mod" {
|
||||
t.Errorf("module not registered correctly: %+v", reg.Modules)
|
||||
}
|
||||
}
|
||||
@@ -274,7 +274,7 @@ func TestBuild_RejectsDuplicateModuleInEnv(t *testing.T) {
|
||||
factories := map[string]Factory{
|
||||
"alpha": factory("alpha", []Command{noopCmd("a1")}, nil),
|
||||
}
|
||||
_, err := Build([]string{"alpha", "alpha"}, factories, newProvider(), nil, BuildOptions{})
|
||||
_, err := Build([]string{"alpha", "alpha"}, factories, newProvider(), BuildOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Errorf("expected duplicate-module error, got %v", err)
|
||||
}
|
||||
@@ -297,7 +297,7 @@ func TestBuild_PerModulePrefixedKV(t *testing.T) {
|
||||
return Module{Commands: []Command{noopCmd("b")}}
|
||||
},
|
||||
}
|
||||
if _, err := Build([]string{"alpha", "beta"}, factories, provider, nil, BuildOptions{}); err != nil {
|
||||
if _, err := Build([]string{"alpha", "beta"}, factories, provider, BuildOptions{}); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,86 +0,0 @@
|
||||
package semantle
|
||||
|
||||
import "math"
|
||||
|
||||
// Calibration constants — tuned empirically for bge-m3 on the JS side.
|
||||
// text-embedding-004 (768d, this port) lives in a similar narrow cone, so
|
||||
// the same sigmoid mapping holds well enough that retuning is not blocking
|
||||
// for v1. Phase 11 soak data may justify a re-fit; until then, JS parity.
|
||||
const (
|
||||
floor = 0.4
|
||||
center = 0.6
|
||||
scale = 8.0
|
||||
)
|
||||
|
||||
var (
|
||||
floorSig = sigmoid(scale * (floor - center))
|
||||
oneSig = sigmoid(scale * (1 - center))
|
||||
sigRange = oneSig - floorSig
|
||||
)
|
||||
|
||||
func sigmoid(x float64) float64 { return 1.0 / (1.0 + math.Exp(-x)) }
|
||||
|
||||
// calibrate maps raw cosine ∈ [-1, 1] → display score ∈ [0, 100]. Mirrors
|
||||
// JS format.js calibrate(). Returns 0 below floor, 100 at exact match.
|
||||
func calibrate(raw float64) float64 {
|
||||
if raw >= 1 {
|
||||
return 100
|
||||
}
|
||||
if raw <= floor {
|
||||
return 0
|
||||
}
|
||||
s := sigmoid(scale * (raw - center))
|
||||
v := ((s - floorSig) / sigRange) * 100
|
||||
switch {
|
||||
case v < 0:
|
||||
return 0
|
||||
case v > 100:
|
||||
return 100
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// formatWarmth: zero-padded percent, width 2 ("07", "54", "100").
|
||||
func formatWarmth(score float64) string {
|
||||
pct := int(math.Round(score))
|
||||
if pct >= 100 {
|
||||
return "100"
|
||||
}
|
||||
if pct < 10 {
|
||||
return "0" + itoa(pct)
|
||||
}
|
||||
return itoa(pct)
|
||||
}
|
||||
|
||||
// warmthEmoji: bucket emoji by calibrated score, JS-parity thresholds.
|
||||
func warmthEmoji(score float64) string {
|
||||
switch {
|
||||
case score >= 90:
|
||||
return "🎯"
|
||||
case score >= 70:
|
||||
return "🔥"
|
||||
case score >= 40:
|
||||
return "🌡️"
|
||||
case score >= 15:
|
||||
return "😐"
|
||||
default:
|
||||
return "🥶"
|
||||
}
|
||||
}
|
||||
|
||||
// itoa is a tiny stdlib-free int→string for the 0-99 range above. strconv
|
||||
// works too; this is a perf nit borrowed from wordle/render. Either is fine.
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [4]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
package semantle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/ai"
|
||||
"github.com/tiennm99/miti99bot-go/internal/keylock"
|
||||
"github.com/tiennm99/miti99bot-go/internal/log"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util/chathelper"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
upstreamFail = "⚠️ Upstream hiccup — try again in a few seconds."
|
||||
notConfig = "⚠️ Semantle is not configured (missing GEMINI_API_KEY)."
|
||||
rateLimited = "⚠️ AI is rate-limited. Try again in a minute."
|
||||
)
|
||||
|
||||
// state is what every handler captures. Loaded once in New.
|
||||
type state struct {
|
||||
kv storage.KVStore
|
||||
embedder ai.Embedder
|
||||
limiter *ai.PerUserLimiter
|
||||
words []string
|
||||
vocab map[string]struct{}
|
||||
|
||||
rngMu sync.Mutex
|
||||
rng *rand.Rand // overridable by tests via newWithRNG; defaults to crypto-seeded
|
||||
|
||||
locks keylock.Map
|
||||
}
|
||||
|
||||
// pickTarget returns a random word from the pool. Lock-protected so
|
||||
// concurrent handlers see deterministic behavior under a fixed-seed test.
|
||||
func (s *state) pickTarget() string {
|
||||
s.rngMu.Lock()
|
||||
defer s.rngMu.Unlock()
|
||||
return s.words[s.rng.IntN(len(s.words))]
|
||||
}
|
||||
|
||||
func (s *state) startFresh(ctx context.Context, subject string) (*GameState, error) {
|
||||
target := s.pickTarget()
|
||||
g := &GameState{Target: target, StartedAt: nil, Solved: false, Guesses: []Guess{}}
|
||||
if err := saveGame(ctx, s.kv, subject, g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (s *state) getOrInit(ctx context.Context, subject string) (*GameState, error) {
|
||||
existing, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil && !existing.Solved {
|
||||
return existing, nil
|
||||
}
|
||||
return s.startFresh(ctx, subject)
|
||||
}
|
||||
|
||||
// handleSemantle: /semantle [word] — show board if no arg, else submit guess.
|
||||
func (s *state) handleSemantle(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
if s.embedder == nil {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, notConfig)
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
|
||||
arg := chathelper.ArgAfterCommand(msg.Text)
|
||||
game, err := s.getOrInit(ctx, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if arg == "" {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, renderBoard(game.Guesses, ""))
|
||||
}
|
||||
return s.submitGuess(ctx, b, msg, subject, game, arg)
|
||||
}
|
||||
|
||||
func (s *state) submitGuess(ctx context.Context, b *bot.Bot, msg *models.Message, subject string, game *GameState, arg string) error {
|
||||
guess := normalize(arg)
|
||||
if !isValidShape(guess) {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Please provide a single letter-only word.")
|
||||
}
|
||||
// Fast-path dedup: same raw or same canonical → no upstream call.
|
||||
for _, g := range game.Guesses {
|
||||
if g.Word == guess || g.Canonical == guess {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("🔁 <b>%s</b> was already guessed this round — try another word.",
|
||||
html.EscapeString(guess)))
|
||||
}
|
||||
}
|
||||
// OOV cheap-check before spending a Gemini call.
|
||||
if _, ok := s.vocab[guess]; !ok {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("🤔 <code>%s</code> isn't in the vocabulary.", html.EscapeString(guess)))
|
||||
}
|
||||
// Per-user rate limit. Bucket key = subject so DM and group are scoped.
|
||||
if s.limiter != nil && !s.limiter.Allow(subject) {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "⏳ Slow down — too many guesses in a short window.")
|
||||
}
|
||||
|
||||
vecs, err := s.embedder.Embed(ctx, []string{game.Target, guess})
|
||||
if err != nil {
|
||||
if errors.Is(err, ai.ErrRateLimited) {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, rateLimited)
|
||||
}
|
||||
log.Warn("semantle embed failed", "err", err)
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, upstreamFail)
|
||||
}
|
||||
if len(vecs) != 2 {
|
||||
log.Warn("semantle embed: bad vec count", "got", len(vecs))
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, upstreamFail)
|
||||
}
|
||||
sim, ok := cosine(vecs[0], vecs[1])
|
||||
if !ok {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("🤔 <code>%s</code> isn't in the vocabulary.", html.EscapeString(guess)))
|
||||
}
|
||||
|
||||
entry := Guess{Word: guess, Canonical: guess, Similarity: sim}
|
||||
game.Guesses = append(game.Guesses, entry)
|
||||
if game.StartedAt == nil {
|
||||
now := chathelper.NowMillis()
|
||||
game.StartedAt = &now
|
||||
}
|
||||
|
||||
if entry.Canonical == game.Target {
|
||||
game.Solved = true
|
||||
count := len(game.Guesses)
|
||||
if _, err := recordResult(ctx, s.kv, subject, true, count, chathelper.NowMillis()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
board := renderBoard(game.Guesses, entry.Canonical)
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("%s\n✅ Solved in %d guess%s!", board, count, plural(count)))
|
||||
}
|
||||
|
||||
if err := saveGame(ctx, s.kv, subject, game); err != nil {
|
||||
return err
|
||||
}
|
||||
body := renderGuess(entry) + "\n" + renderBoard(game.Guesses, entry.Canonical)
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, body)
|
||||
}
|
||||
|
||||
// handleGiveup: /semantle_giveup — reveal target + end round + record loss.
|
||||
func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.Acquire(subject)()
|
||||
game, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if game == nil {
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
"No active round. Send <code>/semantle</code> to start one.")
|
||||
}
|
||||
if _, err := recordResult(ctx, s.kv, subject, false, len(game.Guesses), chathelper.NowMillis()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGame(ctx, s.kv, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID,
|
||||
fmt.Sprintf("🏳️ The target was <b>%s</b>. Send <code>/semantle</code> for a new round.",
|
||||
html.EscapeString(game.Target)))
|
||||
}
|
||||
|
||||
// handleStats: /semantle_stats — lifetime score.
|
||||
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := chathelper.SubjectFor(msg)
|
||||
if subject == "" {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "Cannot identify chat.")
|
||||
}
|
||||
st, err := loadStats(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if st.Played == 0 {
|
||||
return chathelper.Reply(ctx, b, msg.Chat.ID, "No semantle games played yet.")
|
||||
}
|
||||
solveRate := chathelper.WinRate(st.Solved, st.Played)
|
||||
avg := "—"
|
||||
if st.Played > 0 {
|
||||
avg = fmt.Sprintf("%d", roundDiv(st.TotalGuesses, st.Played))
|
||||
}
|
||||
best := "—"
|
||||
if st.BestGuessCount != nil {
|
||||
best = fmt.Sprintf("%d", *st.BestGuessCount)
|
||||
}
|
||||
body := fmt.Sprintf(
|
||||
"🎯 <b>Semantle stats</b>\nPlayed: %d\nSolved: %d (%d%%)\nTotal guesses: %d\nFewest to solve: %s\nAvg per round: %s",
|
||||
st.Played, st.Solved, solveRate, st.TotalGuesses, best, avg,
|
||||
)
|
||||
return chathelper.ReplyHTML(ctx, b, msg.Chat.ID, body)
|
||||
}
|
||||
|
||||
// roundDiv rounds (a/b) half-away-from-zero, JS Math.round parity for non-negative inputs.
|
||||
func roundDiv(a, b int) int {
|
||||
if b <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (a*2 + b) / (2 * b)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package semantle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/ai"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
"github.com/tiennm99/miti99bot-go/internal/testutil"
|
||||
)
|
||||
|
||||
// fakeEmbedder always returns deterministic vectors so cosine math is testable.
|
||||
type fakeEmbedder struct {
|
||||
vecs map[string][]float32
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeEmbedder) Embed(_ context.Context, texts []string) ([][]float32, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
out := make([][]float32, len(texts))
|
||||
for i, t := range texts {
|
||||
if v, ok := f.vecs[t]; ok {
|
||||
out[i] = v
|
||||
continue
|
||||
}
|
||||
// Default: distinct unit vector per text — orthogonal pairs score 0.
|
||||
v := make([]float32, 8)
|
||||
v[len(t)%len(v)] = 1
|
||||
out[i] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func install(t *testing.T, embedder ai.Embedder) (*testutil.RecordingBot, *modules.Registry) {
|
||||
t.Helper()
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
reg, err := modules.Build([]string{"semantle"},
|
||||
map[string]modules.Factory{"semantle": New},
|
||||
storage.NewMemoryProvider(), nil,
|
||||
modules.BuildOptions{Embedder: embedder})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
modules.Install(rb.Bot, reg, modules.Auth{})
|
||||
return rb, reg
|
||||
}
|
||||
|
||||
func TestSemantle_NoEmbedderRefuses(t *testing.T) {
|
||||
rb, _ := install(t, nil)
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/semantle"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "GEMINI_API_KEY") {
|
||||
t.Errorf("missing-key warning: got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemantle_BoardOnEmptyArg(t *testing.T) {
|
||||
rb, _ := install(t, &fakeEmbedder{})
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/semantle"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "Semantle") {
|
||||
t.Errorf("board-render: got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemantle_OOVRejected(t *testing.T) {
|
||||
rb, _ := install(t, &fakeEmbedder{})
|
||||
// "qzwxyz" is not in the embedded wordlist → OOV reply, no upstream call.
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/semantle qzwxyz"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "vocabulary") {
|
||||
t.Errorf("OOV reply: got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemantle_RateLimitedReply(t *testing.T) {
|
||||
rb, _ := install(t, &fakeEmbedder{err: ai.ErrRateLimited})
|
||||
// Use a real vocab word so we get past the OOV gate.
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/semantle the"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "rate-limited") {
|
||||
t.Errorf("rate-limit reply: got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemantle_UpstreamFail(t *testing.T) {
|
||||
rb, _ := install(t, &fakeEmbedder{err: errors.New("boom")})
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/semantle the"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "Upstream hiccup") {
|
||||
t.Errorf("upstream reply: got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemantle_GiveupNoActiveRound(t *testing.T) {
|
||||
rb, _ := install(t, &fakeEmbedder{})
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/semantle_giveup"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "No active round") {
|
||||
t.Errorf("giveup-no-round: got %q", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemantle_StatsEmpty(t *testing.T) {
|
||||
rb, _ := install(t, &fakeEmbedder{})
|
||||
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(42, "/semantle_stats"))
|
||||
last := rb.LastSent().Text()
|
||||
if !strings.Contains(last, "No semantle games") {
|
||||
t.Errorf("empty-stats: got %q", last)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package semantle
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// shapeRe enforces the JS lookup.js policy: ASCII letters only, no spaces,
|
||||
// max 64 chars. Wordlist is ASCII at build time so anything else is OOV.
|
||||
var shapeRe = regexp.MustCompile(`^[a-z]+$`)
|
||||
|
||||
// normalize collapses whitespace + lowercases. JS-parity.
|
||||
func normalize(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(raw)), " "))
|
||||
}
|
||||
|
||||
// isValidShape mirrors JS isValidShape: non-empty, ≤64 chars, ASCII letters.
|
||||
func isValidShape(word string) bool {
|
||||
if word == "" || len(word) > 64 {
|
||||
return false
|
||||
}
|
||||
return shapeRe.MatchString(word)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package semantle
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{" Hello ", "hello"},
|
||||
{"FooBar", "foobar"},
|
||||
{" word ", "word"},
|
||||
{"", ""},
|
||||
{"two words", "two words"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := normalize(c.in); got != c.want {
|
||||
t.Errorf("normalize(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidShape(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"hello", true},
|
||||
{"a", true},
|
||||
{"", false},
|
||||
{"two words", false}, // spaces not allowed in semantle
|
||||
{"hello1", false}, // digits not allowed
|
||||
{"hello!", false}, // punctuation not allowed
|
||||
{string(make([]byte, 65)), false}, // > 64 chars
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isValidShape(c.in); got != c.want {
|
||||
t.Errorf("isValidShape(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWords_HasContent(t *testing.T) {
|
||||
words, set := loadWords()
|
||||
if len(words) < 1000 {
|
||||
t.Errorf("loadWords: expected >1000 words, got %d", len(words))
|
||||
}
|
||||
if len(set) != len(words) {
|
||||
t.Errorf("loadWords: slice/set size mismatch: %d vs %d", len(words), len(set))
|
||||
}
|
||||
// "the" is the most common English word — sanity check.
|
||||
if _, ok := set["the"]; !ok {
|
||||
t.Errorf("loadWords: 'the' missing from vocab — list looks malformed")
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package semantle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Telegram message limit is 4096 chars; cap the visible row count so a
|
||||
// 200-guess game still fits. JS-parity constants.
|
||||
const (
|
||||
maxRows = 15
|
||||
latestMarker = "➡️"
|
||||
plainMarker = " "
|
||||
maxWordWidth = 20
|
||||
)
|
||||
|
||||
// renderBoard returns the HTML-formatted board. Latest canonical (if any)
|
||||
// gets the arrow marker even when sort order shuffles it down.
|
||||
func renderBoard(guesses []Guess, latestCanonical string) string {
|
||||
count := len(guesses)
|
||||
header := fmt.Sprintf("🎯 Semantle — %d guess%s", count, plural(count))
|
||||
if count == 0 {
|
||||
return header + "\n🆕 Round ready — reply with <code>/semantle <word></code>."
|
||||
}
|
||||
|
||||
sorted := make([]Guess, len(guesses))
|
||||
copy(sorted, guesses)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
return sorted[i].Similarity > sorted[j].Similarity
|
||||
})
|
||||
if len(sorted) > maxRows {
|
||||
sorted = sorted[:maxRows]
|
||||
}
|
||||
|
||||
wordWidth := 0
|
||||
for _, g := range sorted {
|
||||
if l := len(g.Canonical); l > wordWidth {
|
||||
wordWidth = l
|
||||
}
|
||||
}
|
||||
if wordWidth > maxWordWidth {
|
||||
wordWidth = maxWordWidth
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for i, g := range sorted {
|
||||
score := calibrate(g.Similarity)
|
||||
marker := plainMarker
|
||||
if g.Canonical == latestCanonical {
|
||||
marker = latestMarker
|
||||
}
|
||||
rank := padLeft(fmt.Sprintf("%d", i+1), 2)
|
||||
warmth := padLeft(formatWarmth(score), 3)
|
||||
word := html.EscapeString(padRight(g.Canonical, wordWidth))
|
||||
lines = append(lines, fmt.Sprintf("%s %s %s %s %s", marker, rank, warmth, word, warmthEmoji(score)))
|
||||
}
|
||||
|
||||
body := "<pre>" + strings.Join(lines, "\n") + "</pre>"
|
||||
footer := ""
|
||||
if hidden := count - len(sorted); hidden > 0 {
|
||||
footer = fmt.Sprintf("\n…%d older guess%s hidden.", hidden, plural(hidden))
|
||||
}
|
||||
return header + "\n" + body + footer
|
||||
}
|
||||
|
||||
// renderGuess: single-line summary used after a scored guess (above the board).
|
||||
func renderGuess(g Guess) string {
|
||||
score := calibrate(g.Similarity)
|
||||
return fmt.Sprintf("<code>%s</code> → %s %s",
|
||||
html.EscapeString(g.Canonical), formatWarmth(score), warmthEmoji(score))
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "es"
|
||||
}
|
||||
|
||||
func padLeft(s string, w int) string {
|
||||
if len(s) >= w {
|
||||
return s
|
||||
}
|
||||
return strings.Repeat(" ", w-len(s)) + s
|
||||
}
|
||||
|
||||
func padRight(s string, w int) string {
|
||||
if len(s) >= w {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", w-len(s))
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package semantle
|
||||
|
||||
import "math"
|
||||
|
||||
// cosine returns the cosine similarity of two float32 vectors. nil/empty
|
||||
// inputs and length mismatch return (0, false) — caller should treat as OOV.
|
||||
//
|
||||
// math.Sqrt is float64 internally; cast at the boundary, accumulate in
|
||||
// float64 to avoid 32-bit precision loss on long vectors (text-embedding-004
|
||||
// is 768-dim, so the dot product easily exceeds 2^24 mantissa precision).
|
||||
func cosine(a, b []float32) (float64, bool) {
|
||||
if len(a) == 0 || len(b) == 0 || len(a) != len(b) {
|
||||
return 0, false
|
||||
}
|
||||
var dot, nA, nB float64
|
||||
for i := range a {
|
||||
da := float64(a[i])
|
||||
db := float64(b[i])
|
||||
dot += da * db
|
||||
nA += da * da
|
||||
nB += db * db
|
||||
}
|
||||
denom := math.Sqrt(nA) * math.Sqrt(nB)
|
||||
if denom == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return dot / denom, true
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package semantle
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCosine_IdenticalVectors(t *testing.T) {
|
||||
a := []float32{1, 0, 0}
|
||||
got, ok := cosine(a, a)
|
||||
if !ok {
|
||||
t.Fatal("ok=false for identical vectors")
|
||||
}
|
||||
if math.Abs(got-1.0) > 1e-6 {
|
||||
t.Errorf("identical: got %v, want 1.0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCosine_Orthogonal(t *testing.T) {
|
||||
got, ok := cosine([]float32{1, 0}, []float32{0, 1})
|
||||
if !ok {
|
||||
t.Fatal("ok=false")
|
||||
}
|
||||
if math.Abs(got) > 1e-6 {
|
||||
t.Errorf("orthogonal: got %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCosine_Opposite(t *testing.T) {
|
||||
got, ok := cosine([]float32{1, 0}, []float32{-1, 0})
|
||||
if !ok {
|
||||
t.Fatal("ok=false")
|
||||
}
|
||||
if math.Abs(got+1) > 1e-6 {
|
||||
t.Errorf("opposite: got %v, want -1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCosine_LengthMismatch(t *testing.T) {
|
||||
if _, ok := cosine([]float32{1}, []float32{1, 0}); ok {
|
||||
t.Errorf("length-mismatch: want ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCosine_Empty(t *testing.T) {
|
||||
if _, ok := cosine(nil, nil); ok {
|
||||
t.Errorf("nil: want ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalibrate_FloorAndCeiling(t *testing.T) {
|
||||
if v := calibrate(-0.5); v != 0 {
|
||||
t.Errorf("below floor: got %v, want 0", v)
|
||||
}
|
||||
if v := calibrate(1.0); v != 100 {
|
||||
t.Errorf("at ceiling: got %v, want 100", v)
|
||||
}
|
||||
// Mid-range stays in bounds.
|
||||
for _, raw := range []float64{0.5, 0.7, 0.9} {
|
||||
v := calibrate(raw)
|
||||
if v < 0 || v > 100 {
|
||||
t.Errorf("calibrate(%v) = %v out of [0,100]", raw, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalibrate_Monotonic(t *testing.T) {
|
||||
prev := -1.0
|
||||
for _, raw := range []float64{0.45, 0.55, 0.65, 0.75, 0.85, 0.95} {
|
||||
v := calibrate(raw)
|
||||
if v < prev {
|
||||
t.Errorf("calibrate non-monotonic at raw=%v: %v < %v", raw, v, prev)
|
||||
}
|
||||
prev = v
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package semantle
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
mrand "math/rand/v2"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/ai"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
)
|
||||
|
||||
// New is the semantle module Factory. Loads the embedded wordlist once,
|
||||
// captures Embedder via Deps, and registers /semantle, /semantle_giveup,
|
||||
// /semantle_stats. If Deps.Embedder is nil (GEMINI_API_KEY unset) the module
|
||||
// still loads and the handlers reply with a config-error message — keeping
|
||||
// the rest of the bot functional.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
words, set := loadWords()
|
||||
s := &state{
|
||||
kv: deps.KV,
|
||||
embedder: deps.Embedder,
|
||||
limiter: ai.NewPerUserLimiter(5.0/60.0, 5), // 5 guesses per 60s burst
|
||||
words: words,
|
||||
vocab: set,
|
||||
rng: newRNG(),
|
||||
}
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "semantle",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Semantle — guess the hidden word (unlimited tries)",
|
||||
Handler: s.handleSemantle,
|
||||
},
|
||||
{
|
||||
Name: "semantle_giveup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Reveal the current semantle answer (auto-starts a fresh round)",
|
||||
Handler: s.handleGiveup,
|
||||
},
|
||||
{
|
||||
Name: "semantle_stats",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show your semantle stats",
|
||||
Handler: s.handleStats,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// newRNG returns a crypto-seeded math/rand v2 PCG. We use math/rand for the
|
||||
// hot path (target pick) because crypto/rand on every call is wasteful, but
|
||||
// seeding from crypto/rand prevents the deterministic-seed footgun that bit
|
||||
// wordle/loldle in earlier reviews.
|
||||
func newRNG() *mrand.Rand {
|
||||
var seed [32]byte
|
||||
_, _ = rand.Read(seed[:])
|
||||
var s1, s2 uint64
|
||||
s1 = binary.LittleEndian.Uint64(seed[0:8])
|
||||
s2 = binary.LittleEndian.Uint64(seed[8:16])
|
||||
return mrand.New(mrand.NewPCG(s1, s2))
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package semantle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
// Guess is one entry in a round's history. JSON shape locks JS parity:
|
||||
// `{ "word": "raw", "canonical": "raw", "similarity": 0.42 }`.
|
||||
type Guess struct {
|
||||
Word string `json:"word"`
|
||||
Canonical string `json:"canonical"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
}
|
||||
|
||||
// GameState is the per-subject KV record. *int64 startedAt mirrors the JS
|
||||
// `null` initial value before the first scored guess.
|
||||
type GameState struct {
|
||||
Target string `json:"target"`
|
||||
StartedAt *int64 `json:"startedAt"`
|
||||
Solved bool `json:"solved"`
|
||||
Guesses []Guess `json:"guesses"`
|
||||
}
|
||||
|
||||
// Stats: lifetime counters per subject. *int64/null parity with JS.
|
||||
type Stats struct {
|
||||
Played int `json:"played"`
|
||||
Solved int `json:"solved"`
|
||||
TotalGuesses int `json:"totalGuesses"`
|
||||
BestGuessCount *int `json:"bestGuessCount"`
|
||||
LastResultAt *int64 `json:"lastResultAt"`
|
||||
}
|
||||
|
||||
func gameKey(subject string) string { return "game:" + subject }
|
||||
func statsKey(subject string) string { return "stats:" + subject }
|
||||
|
||||
func loadGame(ctx context.Context, kv storage.KVStore, subject string) (*GameState, error) {
|
||||
var g GameState
|
||||
err := kv.GetJSON(ctx, gameKey(subject), &g)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &g, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("semantle 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("semantle saveGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearGame(ctx context.Context, kv storage.KVStore, subject string) error {
|
||||
if err := kv.Delete(ctx, gameKey(subject)); err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
return fmt.Errorf("semantle clearGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*Stats, error) {
|
||||
var s Stats
|
||||
err := kv.GetJSON(ctx, statsKey(subject), &s)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &s, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return &Stats{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("semantle loadStats: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// recordResult bumps stats with the round outcome. JS-parity: solved counts
|
||||
// total + bestGuessCount; non-solved (giveup) counts only total + guesses.
|
||||
func recordResult(ctx context.Context, kv storage.KVStore, subject string, solved bool, guessCount int, nowMillis int64) (*Stats, error) {
|
||||
s, err := loadStats(ctx, kv, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.Played++
|
||||
s.TotalGuesses += guessCount
|
||||
if solved {
|
||||
s.Solved++
|
||||
if s.BestGuessCount == nil || guessCount < *s.BestGuessCount {
|
||||
gc := guessCount
|
||||
s.BestGuessCount = &gc
|
||||
}
|
||||
}
|
||||
now := nowMillis
|
||||
s.LastResultAt = &now
|
||||
if err := kv.PutJSON(ctx, statsKey(subject), s); err != nil {
|
||||
return nil, fmt.Errorf("semantle recordResult: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package semantle
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// rawWords is the embedded google-10000-english list, byte-for-byte from the
|
||||
// JS source's words-data.js (extracted with scripts/build-semantle-words.js).
|
||||
// The pool doubles as the OOV vocabulary — anything not in here gets the
|
||||
// "not in vocabulary" reply rather than a noisy embedding score.
|
||||
//
|
||||
//go:embed data/words.txt
|
||||
var rawWords string
|
||||
|
||||
// loadWords parses the embedded list into (slice, set). The slice preserves
|
||||
// JS pick order (target = LINES[Math.floor(Math.random()*LINES.length)]) and
|
||||
// the set is for O(1) membership checks.
|
||||
func loadWords() ([]string, map[string]struct{}) {
|
||||
lines := strings.Split(strings.TrimSpace(rawWords), "\n")
|
||||
words := make([]string, 0, len(lines))
|
||||
set := make(map[string]struct{}, len(lines))
|
||||
for _, w := range lines {
|
||||
w = strings.TrimSpace(w)
|
||||
if w == "" {
|
||||
continue
|
||||
}
|
||||
words = append(words, w)
|
||||
set[w] = struct{}{}
|
||||
}
|
||||
return words, set
|
||||
}
|
||||
@@ -36,7 +36,7 @@ func install(t *testing.T, c ai.Chatter) *testutil.RecordingBot {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
reg, err := modules.Build([]string{"twentyq"},
|
||||
map[string]modules.Factory{"twentyq": New},
|
||||
storage.NewMemoryProvider(), nil,
|
||||
storage.NewMemoryProvider(),
|
||||
modules.BuildOptions{Chatter: c})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// Package chathelper consolidates per-module Telegram helpers that diverged
|
||||
// across wordle/loldle/loldleemoji/misc. SubjectFor, ArgAfterCommand,
|
||||
// NowMillis, Reply, ReplyHTML, WinRate previously had near-identical copies
|
||||
// in each module — one drift incident (winRate truncation) already bit
|
||||
// Phase 5b/5c. Single source here; modules import.
|
||||
// Package chathelper consolidates per-module Telegram helpers (SubjectFor,
|
||||
// ArgAfterCommand, NowMillis, Reply, ReplyHTML, WinRate) that would
|
||||
// otherwise be duplicated across every module. Single source here; modules
|
||||
// import.
|
||||
package chathelper
|
||||
|
||||
import (
|
||||
|
||||
@@ -20,7 +20,7 @@ func installUtil(t *testing.T, ownerID int64) *testutil.RecordingBot {
|
||||
rb := testutil.NewRecordingBot(t)
|
||||
reg, err := modules.Build([]string{"util"},
|
||||
map[string]modules.Factory{"util": util.New},
|
||||
storage.NewMemoryProvider(), nil, modules.BuildOptions{})
|
||||
storage.NewMemoryProvider(), modules.BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestRenderHelp_GroupsByModuleAndSkipsPrivate(t *testing.T) {
|
||||
cmd("b_amp", modules.VisibilityPublic, `Tom & "Jerry"`),
|
||||
}),
|
||||
}
|
||||
reg, err := modules.Build([]string{"alpha", "beta"}, factories, storage.NewMemoryProvider(), nil, modules.BuildOptions{})
|
||||
reg, err := modules.Build([]string{"alpha", "beta"}, factories, storage.NewMemoryProvider(), modules.BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func TestRenderHelp_ModuleOrderMatchesEnvOrder(t *testing.T) {
|
||||
}
|
||||
|
||||
// MODULES order: second,first → expect "second" section before "first".
|
||||
reg, err := modules.Build([]string{"second", "first"}, factories, storage.NewMemoryProvider(), nil, modules.BuildOptions{})
|
||||
reg, err := modules.Build([]string{"second", "first"}, factories, storage.NewMemoryProvider(), modules.BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func TestRenderHelp_OmitsModulesWithNoVisibleCommands(t *testing.T) {
|
||||
cmd("seen", modules.VisibilityPublic),
|
||||
}),
|
||||
}
|
||||
reg, err := modules.Build([]string{"shadow", "visible"}, factories, storage.NewMemoryProvider(), nil, modules.BuildOptions{})
|
||||
reg, err := modules.Build([]string{"shadow", "visible"}, factories, storage.NewMemoryProvider(), modules.BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const testCronSecret = "shared-cron-secret"
|
||||
|
||||
func buildRegistry(t *testing.T, factories map[string]modules.Factory, names ...string) *modules.Registry {
|
||||
t.Helper()
|
||||
reg, err := modules.Build(names, factories, storage.NewMemoryProvider(), nil, modules.BuildOptions{})
|
||||
reg, err := modules.Build(names, factories, storage.NewMemoryProvider(), modules.BuildOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("modules.Build: %v", err)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestFirestoreProvider_For_AcceptsCanonicalNames(t *testing.T) {
|
||||
// 1..32 chars. We can't dereference the returned FirestoreKVStore (nil
|
||||
// client), but we can assert it's NOT an invalidStore — validation passed.
|
||||
p := &FirestoreProvider{client: nil}
|
||||
for _, name := range []string{"misc", "loldle-emoji", "wordle", "x", "a1_b-2"} {
|
||||
for _, name := range []string{"misc", "demo-mod", "wordle", "x", "a1_b-2"} {
|
||||
store := p.For(name)
|
||||
if _, ok := store.(invalidStore); ok {
|
||||
t.Errorf("For(%q) returned invalidStore; expected validation to pass", name)
|
||||
|
||||
+1
-7
@@ -14,7 +14,7 @@ Parameters:
|
||||
|
||||
ModulesCSV:
|
||||
Type: String
|
||||
Default: util,misc,wordle,loldle,loldle-ability,loldle-emoji,loldle-quote,loldle-splash,lolschedule,semantle,doantu,twentyq,trading
|
||||
Default: util,misc,wordle,loldle,lolschedule,twentyq,trading
|
||||
Description: Comma-separated module names enabled at runtime (matches MODULES env).
|
||||
|
||||
BotOwnerID:
|
||||
@@ -27,11 +27,6 @@ Parameters:
|
||||
Default: ""
|
||||
Description: Comma-separated Telegram user IDs allowed to use admin commands.
|
||||
|
||||
Phow2simAPIURL:
|
||||
Type: String
|
||||
Default: ""
|
||||
Description: Optional override for the doantu module's PHOW2SIM endpoint.
|
||||
|
||||
# AWS Lambda Web Adapter ARM64 layer ARN. Pin a specific version so deploys
|
||||
# are reproducible. Bump by checking the latest at:
|
||||
# https://github.com/awslabs/aws-lambda-web-adapter/releases
|
||||
@@ -126,7 +121,6 @@ Resources:
|
||||
MODULES: !Ref ModulesCSV
|
||||
BOT_OWNER_ID: !Ref BotOwnerID
|
||||
ADMIN_USER_IDS: !Ref AdminUserIDs
|
||||
PHOW2SIM_API_URL: !Ref Phow2simAPIURL
|
||||
# ---- Secrets (resolved from Parameter Store at deploy time) ----
|
||||
# Token rotation = update parameter, redeploy stack. For zero-redeploy
|
||||
# rotation, switch to runtime fetch in main.go.
|
||||
|
||||
Reference in New Issue
Block a user