mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Persist Wordle guesses, fix hardcoded rendering, announce expired puzzles
- Add wordle_guesses table to persist individual guesses to DB - Reload guesses during rehydration so they survive bot restarts - Clear stale guesses when puzzle is replaced (skip+new) - Fix all hardcoded /6 and 🟩🟩🟩🟩🟩 in rendering to use puzzle.MaxGuesses and puzzle.WordLength - Announce unsolved puzzles at midnight rollover instead of silently replacing - Mark expired puzzles as failed and update stats on rollover Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -822,6 +822,17 @@ CREATE TABLE IF NOT EXISTS wordle_puzzles (
|
|||||||
PRIMARY KEY (puzzle_id, room_id)
|
PRIMARY KEY (puzzle_id, room_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wordle_guesses (
|
||||||
|
puzzle_id TEXT NOT NULL,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
guess_num INTEGER NOT NULL,
|
||||||
|
word TEXT NOT NULL,
|
||||||
|
player_id TEXT NOT NULL,
|
||||||
|
player_name TEXT NOT NULL,
|
||||||
|
guessed_at DATETIME NOT NULL,
|
||||||
|
PRIMARY KEY (puzzle_id, room_id, guess_num)
|
||||||
|
);
|
||||||
|
|
||||||
-- Space groups (rooms with overlapping membership)
|
-- Space groups (rooms with overlapping membership)
|
||||||
CREATE TABLE IF NOT EXISTS space_groups (
|
CREATE TABLE IF NOT EXISTS space_groups (
|
||||||
room_id TEXT PRIMARY KEY,
|
room_id TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -180,6 +180,13 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
|
|||||||
}
|
}
|
||||||
puzzle.Guesses = append(puzzle.Guesses, g)
|
puzzle.Guesses = append(puzzle.Guesses, g)
|
||||||
|
|
||||||
|
// Persist guess to DB so it survives restarts.
|
||||||
|
db.Exec("wordle: persist guess",
|
||||||
|
`INSERT INTO wordle_guesses (puzzle_id, room_id, guess_num, word, player_id, player_name, guessed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
puzzle.PuzzleID, string(puzzle.RoomID), len(puzzle.Guesses), guess, string(ctx.Sender), displayName, now,
|
||||||
|
)
|
||||||
|
|
||||||
// Check for win.
|
// Check for win.
|
||||||
if guess == puzzle.Answer {
|
if guess == puzzle.Answer {
|
||||||
puzzle.Solved = true
|
puzzle.Solved = true
|
||||||
@@ -235,7 +242,7 @@ func (p *WordlePlugin) handleNew(ctx MessageContext, args string) error {
|
|||||||
category := WordleCategoryEN
|
category := WordleCategoryEN
|
||||||
parts := strings.Fields(args)
|
parts := strings.Fields(args)
|
||||||
|
|
||||||
// Parse optional arguments: language (pt/fr) and/or length (5/6/7)
|
// Parse optional arguments: language (pt/fr) and/or length (5-20)
|
||||||
for _, part := range parts[1:] {
|
for _, part := range parts[1:] {
|
||||||
lower := strings.ToLower(part)
|
lower := strings.ToLower(part)
|
||||||
switch lower {
|
switch lower {
|
||||||
@@ -353,6 +360,11 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, cat
|
|||||||
today, string(roomID), word, wordLength, string(category), now,
|
today, string(roomID), word, wordLength, string(category), now,
|
||||||
word, wordLength, string(category), now,
|
word, wordLength, string(category), now,
|
||||||
)
|
)
|
||||||
|
// Clear any stale guesses from a previous puzzle on the same day (e.g. after skip+new).
|
||||||
|
db.Exec("wordle: clear old guesses",
|
||||||
|
`DELETE FROM wordle_guesses WHERE puzzle_id = ? AND room_id = ?`,
|
||||||
|
today, string(roomID),
|
||||||
|
)
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
p.puzzles[roomID] = puzzle
|
p.puzzles[roomID] = puzzle
|
||||||
@@ -432,6 +444,29 @@ func wordleCategoryHint(category WordleCategory) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// expireUnsolved marks an active puzzle as failed and posts an announcement.
|
||||||
|
// Must be called without holding p.mu.
|
||||||
|
func (p *WordlePlugin) expireUnsolved(roomID id.RoomID) {
|
||||||
|
p.mu.Lock()
|
||||||
|
existing := p.puzzles[roomID]
|
||||||
|
if existing == nil || existing.Solved || existing.Failed {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing.Failed = true
|
||||||
|
p.markPuzzleDone(existing)
|
||||||
|
p.updateStats(existing)
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
definition := p.fetchDefinition(existing.Answer)
|
||||||
|
defLine := ""
|
||||||
|
if definition != "" {
|
||||||
|
defLine = fmt.Sprintf("\n📖 *%s*\n", definition)
|
||||||
|
}
|
||||||
|
p.SendMessage(roomID,
|
||||||
|
fmt.Sprintf("⏰ **Time's up!** Yesterday's puzzle expired.\nThe word was **%s**.%s", existing.Answer, defLine))
|
||||||
|
}
|
||||||
|
|
||||||
// PostDailyPuzzle is called by the scheduler to post today's puzzle.
|
// PostDailyPuzzle is called by the scheduler to post today's puzzle.
|
||||||
func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error {
|
func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error {
|
||||||
today := time.Now().UTC().Format("2006-01-02")
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
@@ -445,6 +480,9 @@ func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error {
|
|||||||
}
|
}
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// Announce expiry of yesterday's unsolved puzzle before creating the new one.
|
||||||
|
p.expireUnsolved(roomID)
|
||||||
|
|
||||||
return p.createAndPostPuzzle(roomID, p.defaultLength, WordleCategoryEN)
|
return p.createAndPostPuzzle(roomID, p.defaultLength, WordleCategoryEN)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,12 +777,48 @@ func (p *WordlePlugin) rehydratePuzzles() {
|
|||||||
LetterStates: make(map[rune]LetterResult),
|
LetterStates: make(map[rune]LetterResult),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reload persisted guesses.
|
||||||
|
guessRows, err := d.Query(
|
||||||
|
`SELECT word, player_id, player_name, guessed_at FROM wordle_guesses
|
||||||
|
WHERE puzzle_id = ? AND room_id = ? ORDER BY guess_num ASC`,
|
||||||
|
pr.pid, pr.roomStr,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
for guessRows.Next() {
|
||||||
|
var word, playerID, playerName string
|
||||||
|
var guessedAt time.Time
|
||||||
|
if err := guessRows.Scan(&word, &playerID, &playerName, &guessedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results := scoreGuess(word, puzzle.Answer)
|
||||||
|
updateLetterStates(puzzle.LetterStates, word, results)
|
||||||
|
puzzle.Guesses = append(puzzle.Guesses, WordleGuess{
|
||||||
|
Word: word,
|
||||||
|
PlayerID: id.UserID(playerID),
|
||||||
|
PlayerName: playerName,
|
||||||
|
Results: results,
|
||||||
|
Timestamp: guessedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
guessRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if puzzle is actually done (guesses may have filled up).
|
||||||
|
if len(puzzle.Guesses) >= puzzle.MaxGuesses {
|
||||||
|
puzzle.Failed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(puzzle.Guesses) > 0 && puzzle.Guesses[len(puzzle.Guesses)-1].Word == puzzle.Answer {
|
||||||
|
puzzle.Solved = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
p.puzzles[roomID] = puzzle
|
p.puzzles[roomID] = puzzle
|
||||||
p.validCache[pr.pid] = make(map[string]bool)
|
p.validCache[pr.pid] = make(map[string]bool)
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
slog.Info("wordle: rehydrated puzzle", "room", roomID, "answer_len", pr.wordLength)
|
slog.Info("wordle: rehydrated puzzle", "room", roomID, "answer_len", pr.wordLength, "guesses", len(puzzle.Guesses))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,6 +863,9 @@ func (p *WordlePlugin) checkAndPostDaily() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Announce expiry of yesterday's unsolved puzzle before creating the new one.
|
||||||
|
p.expireUnsolved(gr)
|
||||||
|
|
||||||
slog.Info("wordle: posting daily puzzle", "room", gr)
|
slog.Info("wordle: posting daily puzzle", "room", gr)
|
||||||
if err := p.createAndPostPuzzle(gr, p.defaultLength, WordleCategoryEN); err != nil {
|
if err := p.createAndPostPuzzle(gr, p.defaultLength, WordleCategoryEN); err != nil {
|
||||||
slog.Error("wordle: daily puzzle failed", "err", err)
|
slog.Error("wordle: daily puzzle failed", "err", err)
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ func letterEmoji(r LetterResult) string {
|
|||||||
func renderWordleGrid(puzzle *WordlePuzzle) string {
|
func renderWordleGrid(puzzle *WordlePuzzle) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
status := fmt.Sprintf("%d/6", len(puzzle.Guesses))
|
status := fmt.Sprintf("%d/%d", len(puzzle.Guesses), puzzle.MaxGuesses)
|
||||||
if puzzle.Solved {
|
if puzzle.Solved {
|
||||||
status += " 🟩🟩🟩🟩🟩"
|
status += " " + strings.Repeat("🟩", puzzle.WordLength)
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("🟩 **Wordle #%d** — %s\n\n", puzzle.PuzzleNumber, status))
|
sb.WriteString(fmt.Sprintf("🟩 **Wordle #%d** — %s\n\n", puzzle.PuzzleNumber, status))
|
||||||
|
|
||||||
@@ -74,8 +74,8 @@ func renderKeyboard(states map[rune]LetterResult) string {
|
|||||||
// renderWordleStartAnnouncement renders the puzzle start message.
|
// renderWordleStartAnnouncement renders the puzzle start message.
|
||||||
func renderWordleStartAnnouncement(puzzleNumber, wordLength int, hint string) string {
|
func renderWordleStartAnnouncement(puzzleNumber, wordLength int, hint string) string {
|
||||||
base := fmt.Sprintf(
|
base := fmt.Sprintf(
|
||||||
"🟩 **Daily Wordle #%d**\nA new %d-letter puzzle is ready! Work together — 6 guesses shared.",
|
"🟩 **Daily Wordle #%d**\nA new %d-letter puzzle is ready! Work together — %d guesses shared.",
|
||||||
puzzleNumber, wordLength,
|
puzzleNumber, wordLength, wordleMaxGuesses(wordLength),
|
||||||
)
|
)
|
||||||
if hint != "" {
|
if hint != "" {
|
||||||
base += fmt.Sprintf("\n💡 **Hint:** %s", hint)
|
base += fmt.Sprintf("\n💡 **Hint:** %s", hint)
|
||||||
@@ -91,7 +91,7 @@ func renderSolvedAnnouncement(puzzle *WordlePuzzle, definition string, payouts [
|
|||||||
// Find the solver.
|
// Find the solver.
|
||||||
lastGuess := puzzle.Guesses[len(puzzle.Guesses)-1]
|
lastGuess := puzzle.Guesses[len(puzzle.Guesses)-1]
|
||||||
|
|
||||||
sb.WriteString(fmt.Sprintf("🎉 **Solved in %d/6!**\n", len(puzzle.Guesses)))
|
sb.WriteString(fmt.Sprintf("🎉 **Solved in %d/%d!**\n", len(puzzle.Guesses), puzzle.MaxGuesses))
|
||||||
sb.WriteString(fmt.Sprintf("The word was **%s** — solved by %s on guess %d.\n",
|
sb.WriteString(fmt.Sprintf("The word was **%s** — solved by %s on guess %d.\n",
|
||||||
puzzle.Answer, lastGuess.PlayerName, len(puzzle.Guesses)))
|
puzzle.Answer, lastGuess.PlayerName, len(puzzle.Guesses)))
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ func renderSolvedAnnouncement(puzzle *WordlePuzzle, definition string, payouts [
|
|||||||
func renderFailedAnnouncement(puzzle *WordlePuzzle, definition string) string {
|
func renderFailedAnnouncement(puzzle *WordlePuzzle, definition string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
sb.WriteString(fmt.Sprintf("💀 **Puzzle failed — 6/6**\nThe word was **%s**.\n", puzzle.Answer))
|
sb.WriteString(fmt.Sprintf("💀 **Puzzle failed — %d/%d**\nThe word was **%s**.\n", puzzle.MaxGuesses, puzzle.MaxGuesses, puzzle.Answer))
|
||||||
|
|
||||||
if definition != "" {
|
if definition != "" {
|
||||||
sb.WriteString(fmt.Sprintf("\n📖 *%s*\n", definition))
|
sb.WriteString(fmt.Sprintf("\n📖 *%s*\n", definition))
|
||||||
|
|||||||
Reference in New Issue
Block a user