mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-03 00:18:09 +00:00
feat(modules): port wordle module + per-subject locking
Phase 5b of go-port-cloud-run plan. Port 14855-word dictionary (89 KB, byte-identical to JS source) and four wordle commands (/wordle, /wordle_new, /wordle_giveup, /wordle_stats). KV wire-format parity: GameState/Stats JSON match JS shape; *int64 LastResultAt for null-value compatibility. Two real bugs caught and fixed: (1) defaultRNG data race in handlers — switched to math/rand.Intn (mutex-protected package-level); (2) Get→mutate→Put logical race in groups — added per-subject sync.Mutex map to serialize access. TTL deferred (Firestore has no expirationTtl equiv — Phase 11 GC).
This commit is contained in:
+4
-2
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/misc"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/util"
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules/wordle"
|
||||
"github.com/tiennm99/miti99bot-go/internal/server"
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
"github.com/tiennm99/miti99bot-go/internal/telegram"
|
||||
@@ -32,8 +33,9 @@ var secretEnvKeys = []string{
|
||||
// import cycle (modules → util → modules).
|
||||
func factories() map[string]modules.Factory {
|
||||
return map[string]modules.Factory{
|
||||
"util": util.New,
|
||||
"misc": misc.New,
|
||||
"util": util.New,
|
||||
"misc": misc.New,
|
||||
"wordle": wordle.New,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Package wordle ports the JS wordle module — classic 5-letter word-guess
|
||||
// game, scored letter-by-letter green/yellow/grey.
|
||||
package wordle
|
||||
|
||||
// WordLength is wordle's fixed 5. Exposed so render.go and tests can reuse it
|
||||
// without magic numbers.
|
||||
const WordLength = 5
|
||||
|
||||
// LetterResult labels a single guessed letter's state. Values match the JS
|
||||
// wire format byte-for-byte: "correct" | "partial" | "wrong".
|
||||
const (
|
||||
ResultCorrect = "correct"
|
||||
ResultPartial = "partial"
|
||||
ResultWrong = "wrong"
|
||||
)
|
||||
|
||||
// LetterScore is the JSON shape stored in KV per guess. Field tags match JS
|
||||
// exactly so a saved JS game round-trips through Go without a custom decoder.
|
||||
type LetterScore struct {
|
||||
Letter string `json:"letter"`
|
||||
Result string `json:"result"`
|
||||
}
|
||||
|
||||
// CompareWords scores guess against target letter-by-letter. Both are assumed
|
||||
// lowercase a-z and exactly WordLength long; callers validate via
|
||||
// validateGuess before reaching here.
|
||||
//
|
||||
// Two-pass marking is required to handle duplicate letters correctly:
|
||||
// - pass 1: positional matches → "correct"; consume those slots from the
|
||||
// target's available pool.
|
||||
// - pass 2: remaining guess letters → "partial" if still in the pool (and
|
||||
// consume), else "wrong".
|
||||
//
|
||||
// Example: target "abbey", guess "babes" →
|
||||
//
|
||||
// b@0 partial, a@1 partial, b@2 correct, e@3 correct, s@4 wrong.
|
||||
func CompareWords(guess, target string) []LetterScore {
|
||||
out := make([]LetterScore, WordLength)
|
||||
pool := make([]byte, 0, WordLength)
|
||||
|
||||
// Pass 1 — positional matches.
|
||||
for i := 0; i < WordLength; i++ {
|
||||
if guess[i] == target[i] {
|
||||
out[i] = LetterScore{Letter: string(guess[i]), Result: ResultCorrect}
|
||||
} else {
|
||||
pool = append(pool, target[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2 — partial matches against the remaining-pool, with consumption.
|
||||
for i := 0; i < WordLength; i++ {
|
||||
if out[i].Result == ResultCorrect {
|
||||
continue
|
||||
}
|
||||
idx := indexOfByte(pool, guess[i])
|
||||
if idx >= 0 {
|
||||
pool = append(pool[:idx], pool[idx+1:]...)
|
||||
out[i] = LetterScore{Letter: string(guess[i]), Result: ResultPartial}
|
||||
} else {
|
||||
out[i] = LetterScore{Letter: string(guess[i]), Result: ResultWrong}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// indexOfByte returns the first index of c in s, or -1.
|
||||
// (bytes.IndexByte gives the same answer; inlined to keep this file
|
||||
// dependency-free and emphasize the JS-parity origin.)
|
||||
func indexOfByte(s []byte, c byte) int {
|
||||
for i, b := range s {
|
||||
if b == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package wordle
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// resultsLetters joins the .Result fields so test expectations stay readable.
|
||||
func resultsLetters(rs []LetterScore) string {
|
||||
out := make([]string, len(rs))
|
||||
for i, r := range rs {
|
||||
out[i] = r.Result
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
func TestCompareWords_AllCorrect(t *testing.T) {
|
||||
r := CompareWords("crane", "crane")
|
||||
if got, want := resultsLetters(r), "correct,correct,correct,correct,correct"; got != want {
|
||||
t.Errorf("got %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareWords_AllWrong(t *testing.T) {
|
||||
r := CompareWords("abcde", "fghij")
|
||||
if got, want := resultsLetters(r), "wrong,wrong,wrong,wrong,wrong"; got != want {
|
||||
t.Errorf("got %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareWords_CorrectOverPartial(t *testing.T) {
|
||||
// guess "slate" vs target "shale" → s correct, l partial, a correct,
|
||||
// t wrong, e correct
|
||||
r := CompareWords("slate", "shale")
|
||||
if got, want := resultsLetters(r), "correct,partial,correct,wrong,correct"; got != want {
|
||||
t.Errorf("got %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareWords_DuplicateExcessLettersWrong(t *testing.T) {
|
||||
// target "abbey", guess "babes": b@0 partial, a@1 partial, b@2 correct,
|
||||
// e@3 correct, s@4 wrong
|
||||
r := CompareWords("babes", "abbey")
|
||||
if got, want := resultsLetters(r), "partial,partial,correct,correct,wrong"; got != want {
|
||||
t.Errorf("got %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareWords_DuplicateGuessSingleTarget(t *testing.T) {
|
||||
// target "abide", guess "aahed": a@0 correct, a@1 wrong (pool exhausted),
|
||||
// h wrong, e@3 partial, d@4 partial
|
||||
r := CompareWords("aahed", "abide")
|
||||
if got, want := resultsLetters(r), "correct,wrong,wrong,partial,partial"; got != want {
|
||||
t.Errorf("got %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareWords_DuplicatesBothSides(t *testing.T) {
|
||||
// target "lever", guess "ebbed":
|
||||
// pass1: e@3=e correct (consume e). pool=[l,e,v,r]
|
||||
// pass2: e@0 partial (consume remaining e); b@1 wrong; b@2 wrong; d@4 wrong
|
||||
r := CompareWords("ebbed", "lever")
|
||||
if got, want := resultsLetters(r), "partial,wrong,wrong,correct,wrong"; got != want {
|
||||
t.Errorf("got %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareWords_AllSameTargetExhaustsPool(t *testing.T) {
|
||||
// target "aaaaa", guess "aabbb" → both 'a's are positional matches; the
|
||||
// remaining b's find nothing in the pool (already empty), so they're wrong.
|
||||
// Locks the "pool exhausted before pass 2" branch.
|
||||
r := CompareWords("aabbb", "aaaaa")
|
||||
if got, want := resultsLetters(r), "correct,correct,wrong,wrong,wrong"; got != want {
|
||||
t.Errorf("got %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareWords_PreservesGuessLetters(t *testing.T) {
|
||||
r := CompareWords("crane", "cloud")
|
||||
letters := make([]string, len(r))
|
||||
for i, x := range r {
|
||||
letters[i] = x.Letter
|
||||
}
|
||||
want := []string{"c", "r", "a", "n", "e"}
|
||||
for i := range want {
|
||||
if letters[i] != want[i] {
|
||||
t.Errorf("letter[%d] = %s, want %s", i, letters[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package wordle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
// errEmptyWordList is returned by pickers when the dictionary is empty —
|
||||
// callers (Init) should fail fast rather than spin a game with no answers.
|
||||
var errEmptyWordList = errors.New("wordle: word list is empty")
|
||||
|
||||
// todayUTC returns the current date as YYYY-MM-DD in UTC. Mirrors JS
|
||||
// `new Date().toISOString().slice(0, 10)`.
|
||||
func todayUTC(now time.Time) string {
|
||||
return now.UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// hashDJB2 is the same djb2 variant the JS source uses, with an explicit
|
||||
// 32-bit mask at the end.
|
||||
func hashDJB2(s string) uint32 {
|
||||
h := uint32(5381)
|
||||
for i := 0; i < len(s); i++ {
|
||||
h = (h * 33) ^ uint32(s[i])
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// pickDaily returns a deterministic pick keyed by a string seed (defaulting
|
||||
// to today's UTC date). Same word for everyone on the same UTC day.
|
||||
//
|
||||
// Currently unused by handlers (which use pickRandom for variety per round)
|
||||
// but kept for parity with the JS source — Phase 5c may switch to a daily.
|
||||
func pickDaily(words []string, seed string) (string, error) {
|
||||
if len(words) == 0 {
|
||||
return "", errEmptyWordList
|
||||
}
|
||||
if seed == "" {
|
||||
seed = todayUTC(time.Now())
|
||||
}
|
||||
idx := int(hashDJB2(seed) % uint32(len(words)))
|
||||
return words[idx], nil
|
||||
}
|
||||
|
||||
// pickRandom is the picker handlers actually use today. Uniform random pick.
|
||||
// rng allows tests to inject a deterministic source. When rng is nil we fall
|
||||
// through to math/rand's package-level Intn, which IS goroutine-safe via an
|
||||
// internal mutex on the global Source — important because the bot dispatcher
|
||||
// runs each Telegram update in its own goroutine and concurrent /wordle_new
|
||||
// calls would otherwise race on a shared *rand.Rand.
|
||||
func pickRandom(words []string, rng *rand.Rand) (string, error) {
|
||||
if len(words) == 0 {
|
||||
return "", errEmptyWordList
|
||||
}
|
||||
if rng != nil {
|
||||
return words[rng.Intn(len(words))], nil
|
||||
}
|
||||
return words[rand.Intn(len(words))], nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package wordle
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTodayUTC_FormatStable(t *testing.T) {
|
||||
now := time.Date(2026, 5, 9, 9, 30, 0, 0, time.UTC)
|
||||
if got := todayUTC(now); got != "2026-05-09" {
|
||||
t.Errorf("todayUTC = %s, want 2026-05-09", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickDaily_DeterministicForSameSeed(t *testing.T) {
|
||||
words := []string{"alpha", "bravo", "delta", "gamma"}
|
||||
a, err := pickDaily(words, "2026-05-09")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := pickDaily(words, "2026-05-09")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a != b {
|
||||
t.Errorf("daily picks differ: %s vs %s", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickDaily_DifferentSeedsDiffer(t *testing.T) {
|
||||
// Not strictly required by spec — but a useful smoke that the hash isn't
|
||||
// degenerate. Use a long word list so collisions are unlikely.
|
||||
words := []string{
|
||||
"alpha", "bravo", "delta", "gamma", "echo", "fox",
|
||||
"hotel", "india", "juliet", "kilo", "lima", "mike",
|
||||
}
|
||||
a, _ := pickDaily(words, "2026-05-09")
|
||||
b, _ := pickDaily(words, "2026-05-10")
|
||||
if a == b {
|
||||
t.Logf("warn: same daily for different seeds (acceptable but rare): %s", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickDaily_EmptyErrors(t *testing.T) {
|
||||
if _, err := pickDaily(nil, "x"); err == nil {
|
||||
t.Error("expected error for empty list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickRandom_UsesInjectedRNG(t *testing.T) {
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
words := []string{"a", "b", "c", "d", "e"}
|
||||
first, err := pickRandom(words, rng)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Re-seed with same source → same sequence; lock determinism.
|
||||
rng = rand.New(rand.NewSource(1))
|
||||
again, _ := pickRandom(words, rng)
|
||||
if first != again {
|
||||
t.Errorf("seeded RNG should be deterministic: %s vs %s", first, again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickRandom_EmptyErrors(t *testing.T) {
|
||||
if _, err := pickRandom(nil, nil); err == nil {
|
||||
t.Error("expected error for empty list")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickRandom_NilRNGIsRaceFree exercises the production path (rng==nil)
|
||||
// from many goroutines under -race. A regression to a non-thread-safe RNG
|
||||
// would flag here. Cheap insurance for the hot handler path.
|
||||
func TestPickRandom_NilRNGIsRaceFree(t *testing.T) {
|
||||
words := []string{"a", "b", "c", "d", "e"}
|
||||
const goroutines = 64
|
||||
const itersEach = 50
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < itersEach; j++ {
|
||||
if _, err := pickRandom(words, nil); err != nil {
|
||||
t.Errorf("pickRandom: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
package wordle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
// state captures everything a wordle command needs at handler-time. Built
|
||||
// once in New and shared across all four handlers via closure.
|
||||
type state struct {
|
||||
kv storage.KVStore
|
||||
words []string
|
||||
set map[string]struct{}
|
||||
locks subjectLocks // per-subject mutex; serialises Get→mutate→Put
|
||||
}
|
||||
|
||||
// subjectFor mirrors JS getSubject: per-user in DMs, per-chat in groups,
|
||||
// per-user fallback for channels and unknown types. Returns an empty string
|
||||
// when no usable subject id is present (caller replies with an error).
|
||||
func subjectFor(msg *models.Message) string {
|
||||
if msg == nil {
|
||||
return ""
|
||||
}
|
||||
switch msg.Chat.Type {
|
||||
case models.ChatTypePrivate:
|
||||
if msg.From != nil {
|
||||
return strconv.FormatInt(msg.From.ID, 10)
|
||||
}
|
||||
case models.ChatTypeGroup, models.ChatTypeSupergroup:
|
||||
return strconv.FormatInt(msg.Chat.ID, 10)
|
||||
default:
|
||||
if msg.From != nil {
|
||||
return strconv.FormatInt(msg.From.ID, 10)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// argAfterCommand returns everything after the first space in text, trimmed.
|
||||
// JS-parity. Works for `/wordle apple`, `/wordle@bot apple`, etc.
|
||||
func argAfterCommand(text string) string {
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
idx := strings.IndexByte(text, ' ')
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(text[idx+1:])
|
||||
}
|
||||
|
||||
// rejectMessage maps a validation failure into the user-facing reply. JS
|
||||
// parity word-for-word.
|
||||
func rejectMessage(reason rejectReason) string {
|
||||
switch reason {
|
||||
case reasonEmpty:
|
||||
return fmt.Sprintf("Please provide a %d-letter word.", WordLength)
|
||||
case reasonLength:
|
||||
return fmt.Sprintf("Word must be exactly %d letters.", WordLength)
|
||||
default:
|
||||
return "Not in the word list."
|
||||
}
|
||||
}
|
||||
|
||||
// reply is a tiny helper so the four handlers don't all repeat the same
|
||||
// SendMessage incantation. Returns the SendMessage error to the dispatcher.
|
||||
func reply(ctx context.Context, b *bot.Bot, msg *models.Message, text string) error {
|
||||
_, err := b.SendMessage(ctx, &bot.SendMessageParams{
|
||||
ChatID: msg.Chat.ID,
|
||||
Text: text,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// nowMillis returns current UTC ms-since-epoch — single source of "now" so
|
||||
// tests can substitute via the kv-state path if needed.
|
||||
func nowMillis() int64 { return time.Now().UTC().UnixMilli() }
|
||||
|
||||
// startFresh writes a brand-new round and returns it. Errors propagate to
|
||||
// the caller (not swallowed — a KV failure means subsequent ops will lie).
|
||||
func (s *state) startFresh(ctx context.Context, subject string) (*GameState, error) {
|
||||
target, err := pickRandom(s.words, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wordle startFresh: %w", err)
|
||||
}
|
||||
g := &GameState{
|
||||
Target: target,
|
||||
Guesses: []GuessRecord{},
|
||||
Solved: false,
|
||||
Giveup: false,
|
||||
StartedAt: nowMillis(),
|
||||
}
|
||||
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) {
|
||||
g, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if g != nil {
|
||||
return g, nil
|
||||
}
|
||||
return s.startFresh(ctx, subject)
|
||||
}
|
||||
|
||||
// handleWordle is /wordle [word] — show board if no arg, else submit a guess.
|
||||
func (s *state) handleWordle(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := subjectFor(msg)
|
||||
if subject == "" {
|
||||
return reply(ctx, b, msg, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.acquire(subject)()
|
||||
arg := argAfterCommand(msg.Text)
|
||||
|
||||
g, err := s.getOrInit(ctx, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if arg == "" {
|
||||
var header string
|
||||
switch {
|
||||
case g.Solved:
|
||||
header = fmt.Sprintf("🎉 Solved in %d/%d. /wordle_new for another.", len(g.Guesses), MaxGuesses)
|
||||
case g.Giveup:
|
||||
header = fmt.Sprintf("🏳️ Gave up. Answer was %s. /wordle_new for another.", strings.ToUpper(g.Target))
|
||||
default:
|
||||
header = fmt.Sprintf("Guess %d/%d. Use `/wordle <word>`.", len(g.Guesses), MaxGuesses)
|
||||
}
|
||||
return reply(ctx, b, msg, header+"\n\n"+renderBoard(g.Guesses))
|
||||
}
|
||||
|
||||
if isFinished(g) {
|
||||
return reply(ctx, b, msg,
|
||||
fmt.Sprintf("Current round is over. Use /wordle_new to start another. Answer was %s.", strings.ToUpper(g.Target)))
|
||||
}
|
||||
|
||||
v := validateGuess(s.set, arg)
|
||||
if !v.OK {
|
||||
return reply(ctx, b, msg, rejectMessage(v.Reason))
|
||||
}
|
||||
|
||||
results := CompareWords(v.Word, g.Target)
|
||||
g.Guesses = append(g.Guesses, GuessRecord{Word: v.Word, Results: results})
|
||||
won := v.Word == g.Target
|
||||
if won {
|
||||
g.Solved = true
|
||||
}
|
||||
if err := saveGame(ctx, s.kv, subject, g); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rendered := renderGuess(v.Word, results)
|
||||
switch {
|
||||
case won:
|
||||
stats, err := recordResult(ctx, s.kv, subject, true, nowMillis())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return reply(ctx, b, msg, fmt.Sprintf("%s\n\n🎉 Solved in %d/%d! Streak: %d. /wordle_new for another.",
|
||||
rendered, len(g.Guesses), MaxGuesses, stats.Streak))
|
||||
case len(g.Guesses) >= MaxGuesses:
|
||||
if _, err := recordResult(ctx, s.kv, subject, false, nowMillis()); err != nil {
|
||||
return err
|
||||
}
|
||||
return reply(ctx, b, msg, fmt.Sprintf("%s\n\n❌ Out of guesses. Answer was %s. /wordle_new to retry.",
|
||||
rendered, strings.ToUpper(g.Target)))
|
||||
default:
|
||||
return reply(ctx, b, msg, fmt.Sprintf("%s\n\nGuess %d/%d.", rendered, len(g.Guesses), MaxGuesses))
|
||||
}
|
||||
}
|
||||
|
||||
// handleNew is /wordle_new — abandons any in-progress round (counts as
|
||||
// giveup → stats hit) and starts fresh.
|
||||
func (s *state) handleNew(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := subjectFor(msg)
|
||||
if subject == "" {
|
||||
return reply(ctx, b, msg, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.acquire(subject)()
|
||||
|
||||
prelude := ""
|
||||
prior, err := loadGame(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if prior != nil && !isFinished(prior) {
|
||||
if _, err := recordResult(ctx, s.kv, subject, false, nowMillis()); err != nil {
|
||||
return err
|
||||
}
|
||||
prelude = fmt.Sprintf("🏳️ Previous round abandoned (auto-giveup). Answer was %s.\n\n",
|
||||
strings.ToUpper(prior.Target))
|
||||
}
|
||||
|
||||
if _, err := s.startFresh(ctx, subject); err != nil {
|
||||
return err
|
||||
}
|
||||
return reply(ctx, b, msg, prelude+"🆕 New round started. Use `/wordle <word>` to guess.")
|
||||
}
|
||||
|
||||
// handleGiveup is /wordle_giveup — reveals answer for the current round.
|
||||
// Idempotent on already-finished rounds (parrots the same answer back).
|
||||
func (s *state) handleGiveup(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := subjectFor(msg)
|
||||
if subject == "" {
|
||||
return reply(ctx, b, msg, "Cannot identify chat.")
|
||||
}
|
||||
defer s.locks.acquire(subject)()
|
||||
g, err := s.getOrInit(ctx, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if g.Solved {
|
||||
return reply(ctx, b, msg, fmt.Sprintf("Already solved — %s.", strings.ToUpper(g.Target)))
|
||||
}
|
||||
if g.Giveup {
|
||||
return reply(ctx, b, msg, fmt.Sprintf("Already gave up — %s.", strings.ToUpper(g.Target)))
|
||||
}
|
||||
g.Giveup = true
|
||||
if err := saveGame(ctx, s.kv, subject, g); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := recordResult(ctx, s.kv, subject, false, nowMillis()); err != nil {
|
||||
return err
|
||||
}
|
||||
return reply(ctx, b, msg, fmt.Sprintf("🏳️ Answer was %s. /wordle_new for another.", strings.ToUpper(g.Target)))
|
||||
}
|
||||
|
||||
// handleStats is /wordle_stats — shows lifetime score for the subject.
|
||||
func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Update) error {
|
||||
msg := update.Message
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
subject := subjectFor(msg)
|
||||
if subject == "" {
|
||||
return reply(ctx, b, msg, "Cannot identify chat.")
|
||||
}
|
||||
stats, err := loadStats(ctx, s.kv, subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
winRate := 0
|
||||
if stats.Played > 0 {
|
||||
winRate = int(float64(stats.Wins) / float64(stats.Played) * 100)
|
||||
}
|
||||
scope := "group"
|
||||
if msg.Chat.Type == models.ChatTypePrivate {
|
||||
scope = "your"
|
||||
}
|
||||
return reply(ctx, b, msg, fmt.Sprintf(
|
||||
"📊 Wordle %s stats\nPlayed: %d\nWins: %d (%d%%)\nCurrent streak: %d\nBest streak: %d",
|
||||
scope, stats.Played, stats.Wins, winRate, stats.Streak, stats.BestStreak,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package wordle
|
||||
|
||||
import "sync"
|
||||
|
||||
// subjectLocks serialises Get → mutate → Put compound operations on a
|
||||
// GameState by per-subject mutex.
|
||||
//
|
||||
// Why: the KVStore guarantees atomicity on a single op (Get, Put, Delete) but
|
||||
// not on a load-then-save sequence. With the bot dispatcher running each
|
||||
// Telegram update in its own goroutine, two concurrent /wordle calls for the
|
||||
// same chat (groups can have many active users) can interleave and silently
|
||||
// drop one player's guess. The Cloudflare Workers source the JS bot ran in
|
||||
// happens to serialise this for free; Go + Firestore does not.
|
||||
//
|
||||
// Implementation: one *sync.Mutex per subject, lazily created via sync.Map.
|
||||
// The map grows unboundedly with distinct subjects but each entry is ~32 B,
|
||||
// so 1M chats costs ~32 MB — acceptable for v1. Sharded eviction is a Phase
|
||||
// 11 concern.
|
||||
type subjectLocks struct {
|
||||
m sync.Map // key: subject string → val: *sync.Mutex
|
||||
}
|
||||
|
||||
// acquire locks the per-subject mutex and returns the unlock func. Callers
|
||||
// must `defer s.acquire(subject)()` at the top of any handler that reads,
|
||||
// mutates, then writes the same KV record.
|
||||
func (s *subjectLocks) acquire(subject string) func() {
|
||||
v, _ := s.m.LoadOrStore(subject, &sync.Mutex{})
|
||||
mu := v.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package wordle
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizeWord lowercases input and strips anything outside a-z. JS parity:
|
||||
// `String(input).toLowerCase().replace(/[^a-z]/g, "")`.
|
||||
func normalizeWord(input string) string {
|
||||
lower := strings.ToLower(input)
|
||||
out := make([]byte, 0, len(lower))
|
||||
for i := 0; i < len(lower); i++ {
|
||||
c := lower[i]
|
||||
if c >= 'a' && c <= 'z' {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// rejectReason classifies why validateGuess returned not-ok. Values match the
|
||||
// JS source's discriminated-union strings so the user-facing reply mapping
|
||||
// (handlers.rejectReason) stays parallel.
|
||||
type rejectReason string
|
||||
|
||||
const (
|
||||
reasonEmpty rejectReason = "empty"
|
||||
reasonLength rejectReason = "length"
|
||||
reasonUnknown rejectReason = "unknown"
|
||||
)
|
||||
|
||||
// guessResult mirrors JS's `{ok: true, word} | {ok: false, reason, word}`.
|
||||
// Word is always populated (the normalized form), even on failure, so callers
|
||||
// can include it in error messages if desired.
|
||||
type guessResult struct {
|
||||
OK bool
|
||||
Word string
|
||||
Reason rejectReason
|
||||
}
|
||||
|
||||
// validateGuess normalizes input then checks length + dictionary membership.
|
||||
//
|
||||
// Reasons in priority order: empty (post-normalize blank) > length > unknown.
|
||||
func validateGuess(dict map[string]struct{}, input string) guessResult {
|
||||
w := normalizeWord(input)
|
||||
if w == "" {
|
||||
return guessResult{OK: false, Word: w, Reason: reasonEmpty}
|
||||
}
|
||||
if len(w) != WordLength {
|
||||
return guessResult{OK: false, Word: w, Reason: reasonLength}
|
||||
}
|
||||
if _, ok := dict[w]; !ok {
|
||||
return guessResult{OK: false, Word: w, Reason: reasonUnknown}
|
||||
}
|
||||
return guessResult{OK: true, Word: w}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package wordle
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeWord(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"crane": "crane",
|
||||
"CRANE": "crane",
|
||||
" crane ": "crane",
|
||||
"c-r-a-n-e": "crane",
|
||||
"héllo": "hllo", // strips non a-z (including the é and accented o-equivalent)
|
||||
"!@#$%": "",
|
||||
"42 crane": "crane",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeWord(in); got != want {
|
||||
t.Errorf("normalizeWord(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGuess(t *testing.T) {
|
||||
dict := map[string]struct{}{"crane": {}, "shale": {}}
|
||||
|
||||
type want struct {
|
||||
ok bool
|
||||
reason rejectReason
|
||||
word string
|
||||
}
|
||||
cases := []struct {
|
||||
input string
|
||||
want want
|
||||
}{
|
||||
{"", want{ok: false, reason: reasonEmpty, word: ""}},
|
||||
{"!!!", want{ok: false, reason: reasonEmpty, word: ""}},
|
||||
{"cat", want{ok: false, reason: reasonLength, word: "cat"}},
|
||||
{"craning", want{ok: false, reason: reasonLength, word: "craning"}},
|
||||
{"there", want{ok: false, reason: reasonUnknown, word: "there"}},
|
||||
{"crane", want{ok: true, word: "crane"}},
|
||||
{" CRANE!", want{ok: true, word: "crane"}}, // normalization survives
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := validateGuess(dict, c.input)
|
||||
if got.OK != c.want.ok || got.Reason != c.want.reason || got.Word != c.want.word {
|
||||
t.Errorf("validateGuess(%q) = %+v, want %+v", c.input, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package wordle
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// markerFor maps a LetterScore.Result to the NYT-Wordle share emoji.
|
||||
func markerFor(result string) string {
|
||||
switch result {
|
||||
case ResultCorrect:
|
||||
return "🟩"
|
||||
case ResultPartial:
|
||||
return "🟨"
|
||||
default:
|
||||
return "⬜"
|
||||
}
|
||||
}
|
||||
|
||||
// renderGuess formats one guess as the NYT share-pattern: word on one line,
|
||||
// emoji marker row below.
|
||||
//
|
||||
// CRANE
|
||||
// 🟩🟨⬜🟩🟩
|
||||
func renderGuess(word string, results []LetterScore) string {
|
||||
var markers strings.Builder
|
||||
for _, r := range results {
|
||||
markers.WriteString(markerFor(r.Result))
|
||||
}
|
||||
return strings.ToUpper(word) + "\n" + markers.String()
|
||||
}
|
||||
|
||||
// renderBoard joins all prior guesses, blank-line separated. Used when a
|
||||
// player asks for `/wordle` mid-round.
|
||||
func renderBoard(guesses []GuessRecord) string {
|
||||
if len(guesses) == 0 {
|
||||
return "No guesses yet. Reply with `/wordle <word>`."
|
||||
}
|
||||
rows := make([]string, len(guesses))
|
||||
for i, g := range guesses {
|
||||
rows[i] = renderGuess(g.Word, g.Results)
|
||||
}
|
||||
return strings.Join(rows, "\n\n")
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package wordle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
// MaxGuesses is the standard wordle round length.
|
||||
const MaxGuesses = 6
|
||||
|
||||
// gameTTLSeconds matches the JS source's KV TTL — informational only. Cloud
|
||||
// Firestore has no native per-document TTL equivalent to Cloudflare KV; old
|
||||
// games linger until manually cleaned. Document the deviation rather than
|
||||
// scaffold a TTL cron we don't need today.
|
||||
const gameTTLSeconds = 60 * 60 * 24 * 7
|
||||
|
||||
// GuessRecord is one entry in a game's history. JSON shape locks JS parity:
|
||||
//
|
||||
// { "word": "crane", "results": [{"letter":"c","result":"correct"}, ...] }
|
||||
type GuessRecord struct {
|
||||
Word string `json:"word"`
|
||||
Results []LetterScore `json:"results"`
|
||||
}
|
||||
|
||||
// GameState is the per-subject KV record for an in-progress (or finished)
|
||||
// round. Field tags match JS exactly so a JS-written round decodes cleanly.
|
||||
//
|
||||
// `giveup` is always emitted (initialized to false on /wordle_new). Do NOT
|
||||
// add omitempty — the JS source serializes the field unconditionally and
|
||||
// cross-runtime migration depends on shape parity.
|
||||
type GameState struct {
|
||||
Target string `json:"target"`
|
||||
Guesses []GuessRecord `json:"guesses"`
|
||||
Solved bool `json:"solved"`
|
||||
Giveup bool `json:"giveup"`
|
||||
StartedAt int64 `json:"startedAt"` // ms-since-epoch (Date.now())
|
||||
}
|
||||
|
||||
// Stats is the lifetime score record. lastResultAt is *int64 so an unplayed
|
||||
// account marshals as `"lastResultAt": null` matching JS's initial shape.
|
||||
type Stats struct {
|
||||
Played int `json:"played"`
|
||||
Wins int `json:"wins"`
|
||||
Streak int `json:"streak"`
|
||||
BestStreak int `json:"bestStreak"`
|
||||
LastResultAt *int64 `json:"lastResultAt"` // ms-since-epoch | null
|
||||
}
|
||||
|
||||
func gameKey(subject string) string { return "game:" + subject }
|
||||
func statsKey(subject string) string { return "stats:" + subject }
|
||||
|
||||
// loadGame returns the active round, or (nil, nil) if none exists.
|
||||
func loadGame(ctx context.Context, kv storage.KVStore, subject string) (*GameState, error) {
|
||||
var g GameState
|
||||
err := kv.GetJSON(ctx, gameKey(subject), &g)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &g, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("wordle loadGame: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// saveGame writes the round. TTL is not honored on Firestore (see comment on
|
||||
// gameTTLSeconds) — kept here as documentation of intent.
|
||||
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("wordle saveGame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadStats returns lifetime stats; missing → fresh-zero record (with
|
||||
// LastResultAt=nil), matching the JS `?? {…}` fallback.
|
||||
func loadStats(ctx context.Context, kv storage.KVStore, subject string) (*Stats, error) {
|
||||
var s Stats
|
||||
err := kv.GetJSON(ctx, statsKey(subject), &s)
|
||||
switch {
|
||||
case err == nil:
|
||||
return &s, nil
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
return &Stats{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("wordle loadStats: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// recordResult bumps stats with the round outcome (won true → win + streak,
|
||||
// false → reset streak). Returns the updated stats so callers can show the
|
||||
// new streak in the win message.
|
||||
func recordResult(ctx context.Context, kv storage.KVStore, subject string, won bool, nowMillis int64) (*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
|
||||
}
|
||||
now := nowMillis
|
||||
s.LastResultAt = &now
|
||||
if err := kv.PutJSON(ctx, statsKey(subject), s); err != nil {
|
||||
return nil, fmt.Errorf("wordle recordResult: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// isFinished is true when the round can no longer accept guesses: solved,
|
||||
// gave up, or out of guesses.
|
||||
func isFinished(g *GameState) bool {
|
||||
return g.Solved || g.Giveup || len(g.Guesses) >= MaxGuesses
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package wordle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/tiennm99/miti99bot-go/internal/storage"
|
||||
)
|
||||
|
||||
func TestStats_DefaultLastResultAtIsNull(t *testing.T) {
|
||||
// JS shape: `{ ..., lastResultAt: null }` — Go's *int64 must marshal
|
||||
// as null when nil to keep cross-runtime KV documents compatible.
|
||||
s := Stats{}
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(b)
|
||||
want := `{"played":0,"wins":0,"streak":0,"bestStreak":0,"lastResultAt":null}`
|
||||
if got != want {
|
||||
t.Errorf("Stats marshal:\ngot %s\nwant %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStats_WithResultMarshalsAsNumber(t *testing.T) {
|
||||
at := int64(1700000000000)
|
||||
s := Stats{Played: 1, Wins: 1, Streak: 1, BestStreak: 1, LastResultAt: &at}
|
||||
b, _ := json.Marshal(s)
|
||||
want := `{"played":1,"wins":1,"streak":1,"bestStreak":1,"lastResultAt":1700000000000}`
|
||||
if string(b) != want {
|
||||
t.Errorf("Stats marshal:\ngot %s\nwant %s", b, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameState_JSONShapeMatchesJS(t *testing.T) {
|
||||
g := GameState{
|
||||
Target: "crane",
|
||||
Guesses: []GuessRecord{
|
||||
{Word: "slate", Results: []LetterScore{
|
||||
{Letter: "s", Result: ResultCorrect},
|
||||
{Letter: "l", Result: ResultPartial},
|
||||
{Letter: "a", Result: ResultCorrect},
|
||||
{Letter: "t", Result: ResultWrong},
|
||||
{Letter: "e", Result: ResultCorrect},
|
||||
}},
|
||||
},
|
||||
Solved: false,
|
||||
Giveup: false,
|
||||
StartedAt: 1700000000000,
|
||||
}
|
||||
b, err := json.Marshal(g)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"target":"crane","guesses":[{"word":"slate","results":[{"letter":"s","result":"correct"},{"letter":"l","result":"partial"},{"letter":"a","result":"correct"},{"letter":"t","result":"wrong"},{"letter":"e","result":"correct"}]}],"solved":false,"giveup":false,"startedAt":1700000000000}`
|
||||
if string(b) != want {
|
||||
t.Errorf("GameState marshal:\ngot %s\nwant %s", b, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordResult_WinIncrementsStreak(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := storage.NewMemoryKVStore()
|
||||
|
||||
s, err := recordResult(ctx, kv, "u1", true, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("recordResult: %v", err)
|
||||
}
|
||||
if s.Played != 1 || s.Wins != 1 || s.Streak != 1 || s.BestStreak != 1 {
|
||||
t.Errorf("first win: %+v", s)
|
||||
}
|
||||
if s.LastResultAt == nil || *s.LastResultAt != 100 {
|
||||
t.Errorf("LastResultAt = %v, want *=100", s.LastResultAt)
|
||||
}
|
||||
|
||||
// Second win bumps streak; bestStreak follows.
|
||||
s, _ = recordResult(ctx, kv, "u1", true, 200)
|
||||
if s.Streak != 2 || s.BestStreak != 2 {
|
||||
t.Errorf("two wins: streak=%d best=%d", s.Streak, s.BestStreak)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordResult_LossResetsStreakKeepsBest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := storage.NewMemoryKVStore()
|
||||
|
||||
_, _ = recordResult(ctx, kv, "u1", true, 100)
|
||||
_, _ = recordResult(ctx, kv, "u1", true, 200)
|
||||
s, _ := recordResult(ctx, kv, "u1", false, 300)
|
||||
if s.Streak != 0 {
|
||||
t.Errorf("loss should reset streak, got %d", s.Streak)
|
||||
}
|
||||
if s.BestStreak != 2 {
|
||||
t.Errorf("best streak should persist, got %d", s.BestStreak)
|
||||
}
|
||||
if s.Played != 3 || s.Wins != 2 {
|
||||
t.Errorf("counters: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadGame_MissingReturnsNil(t *testing.T) {
|
||||
g, err := loadGame(context.Background(), storage.NewMemoryKVStore(), "nobody")
|
||||
if err != nil {
|
||||
t.Errorf("missing game should not error: %v", err)
|
||||
}
|
||||
if g != nil {
|
||||
t.Errorf("expected nil game, got %+v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveGame_RoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := storage.NewMemoryKVStore()
|
||||
want := &GameState{Target: "crane", Guesses: []GuessRecord{}, StartedAt: 42}
|
||||
if err := saveGame(ctx, kv, "u1", want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := loadGame(ctx, kv, "u1")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("loadGame: got=%v err=%v", got, err)
|
||||
}
|
||||
if got.Target != "crane" || got.StartedAt != 42 {
|
||||
t.Errorf("round-trip mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsFinished(t *testing.T) {
|
||||
if !isFinished(&GameState{Solved: true}) {
|
||||
t.Error("solved should be finished")
|
||||
}
|
||||
if !isFinished(&GameState{Giveup: true}) {
|
||||
t.Error("giveup should be finished")
|
||||
}
|
||||
full := &GameState{Guesses: make([]GuessRecord, MaxGuesses)}
|
||||
if !isFinished(full) {
|
||||
t.Error("max-guesses should be finished")
|
||||
}
|
||||
if isFinished(&GameState{Guesses: make([]GuessRecord, MaxGuesses-1)}) {
|
||||
t.Error("under-max not finished")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package wordle
|
||||
|
||||
import (
|
||||
"github.com/tiennm99/miti99bot-go/internal/modules"
|
||||
)
|
||||
|
||||
// New is the wordle module Factory. Loads the embedded dictionary once,
|
||||
// captures the per-module KV via closure, and registers all four commands.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
words, set := loadWords()
|
||||
s := &state{kv: deps.KV, words: words, set: set}
|
||||
|
||||
return modules.Module{
|
||||
Commands: []modules.Command{
|
||||
{
|
||||
Name: "wordle",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Classic wordle — guess the 5-letter word",
|
||||
Handler: s.handleWordle,
|
||||
},
|
||||
{
|
||||
Name: "wordle_new",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Start a new round (auto-gives-up any in-progress one)",
|
||||
Handler: s.handleNew,
|
||||
},
|
||||
{
|
||||
Name: "wordle_giveup",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Reveal the current wordle answer",
|
||||
Handler: s.handleGiveup,
|
||||
},
|
||||
{
|
||||
Name: "wordle_stats",
|
||||
Visibility: modules.VisibilityPublic,
|
||||
Description: "Show your wordle stats (wins, streak)",
|
||||
Handler: s.handleStats,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package wordle
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// rawWords holds the raw words.txt bytes embedded at compile time. The file
|
||||
// was extracted byte-for-byte from the JS source's words-data.js so the Go
|
||||
// and JS bots have identical dictionaries.
|
||||
//
|
||||
//go:embed data/words.txt
|
||||
var rawWords string
|
||||
|
||||
// loadWords parses the embedded list into a slice plus a membership set. Both
|
||||
// outputs share the same backing strings, so memory is roughly the dict size
|
||||
// (≈90 KiB) — well under the binary-size budget.
|
||||
//
|
||||
// Words are validated to be exactly WordLength a-z; any malformed line panics
|
||||
// at startup so a bad regen of the data file is caught immediately, not on
|
||||
// the first /wordle.
|
||||
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
|
||||
}
|
||||
if !validWord(w) {
|
||||
panic("wordle: invalid word in embedded list: " + w)
|
||||
}
|
||||
words = append(words, w)
|
||||
set[w] = struct{}{}
|
||||
}
|
||||
return words, set
|
||||
}
|
||||
|
||||
func validWord(w string) bool {
|
||||
if len(w) != WordLength {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(w); i++ {
|
||||
c := w[i]
|
||||
if c < 'a' || c > 'z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package wordle
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestLoadWords_EmbeddedDictIsValid asserts the embedded data file: every
|
||||
// entry is exactly 5 lowercase a-z, count is plausible, and a known word
|
||||
// is present. Cheap insurance against a bad regen of words.txt.
|
||||
func TestLoadWords_EmbeddedDictIsValid(t *testing.T) {
|
||||
words, set := loadWords()
|
||||
|
||||
if len(words) < 14000 || len(words) > 15000 {
|
||||
t.Errorf("word count = %d, want ~14855", len(words))
|
||||
}
|
||||
if len(words) != len(set) {
|
||||
t.Errorf("words/set length mismatch: %d vs %d (duplicates?)", len(words), len(set))
|
||||
}
|
||||
|
||||
// Spot-check known words from the dracos list.
|
||||
for _, sample := range []string{"crane", "abase", "zymic"} {
|
||||
if _, ok := set[sample]; !ok {
|
||||
t.Errorf("expected %q in dict", sample)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user