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:
2026-05-09 09:35:23 +07:00
parent 3736e80d11
commit 6ee8118ad9
16 changed files with 16015 additions and 2 deletions
+4 -2
View File
@@ -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,
}
}
+76
View File
@@ -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
}
+90
View File
@@ -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])
}
}
}
+59
View File
@@ -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
}
+94
View File
@@ -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
+279
View File
@@ -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,
))
}
+31
View File
@@ -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
}
+54
View File
@@ -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}
}
+49
View File
@@ -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)
}
}
}
+43
View File
@@ -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")
}
+123
View File
@@ -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
}
+142
View File
@@ -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")
}
}
+41
View File
@@ -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,
},
},
}
}
+51
View File
@@ -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
}
+24
View File
@@ -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)
}
}
}