mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Add adventure plugin, holdem CFR fixes, and wordle plugin
Adventure: Complete v1 daily idle RPG with DM-driven gameplay, equipment shop, treasure system, TwinBee NPC, streak/grudge/party mechanics, flavor text, and scheduled morning/evening/midnight tickers. Holdem CFR: Fix three critical training bugs (fold not forfeiting pot, free calls after raises, training/runtime key mismatch). Add performance optimizations (preflop lookup table, zero-alloc equity, integer keys, raise cap, regret pruning). Enrich abstraction with 12 equity buckets, board texture dimension, and 6-char action history. Replace validation with proper multi-street simulation. Also includes wordle plugin, holdem seed tooling, and schema additions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
618
internal/plugin/wordle.go
Normal file
618
internal/plugin/wordle.go
Normal file
@@ -0,0 +1,618 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// WordlePlugin provides a daily cooperative Wordle game.
|
||||
type WordlePlugin struct {
|
||||
Base
|
||||
apiKey string
|
||||
httpClient *http.Client
|
||||
defaultLength int
|
||||
|
||||
mu sync.Mutex
|
||||
puzzles map[id.RoomID]*WordlePuzzle
|
||||
|
||||
// In-memory cache of validated words per puzzle to avoid redundant API calls.
|
||||
validCache map[string]map[string]bool // puzzleID -> word -> valid
|
||||
}
|
||||
|
||||
// NewWordlePlugin creates a new WordlePlugin.
|
||||
func NewWordlePlugin(client *mautrix.Client) *WordlePlugin {
|
||||
length := 5
|
||||
if v := os.Getenv("WORDLE_DEFAULT_LENGTH"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 5 && n <= 7 {
|
||||
length = n
|
||||
}
|
||||
}
|
||||
|
||||
return &WordlePlugin{
|
||||
Base: NewBase(client),
|
||||
apiKey: os.Getenv("WORDNIK_API_KEY"),
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
defaultLength: length,
|
||||
puzzles: make(map[id.RoomID]*WordlePuzzle),
|
||||
validCache: make(map[string]map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) Name() string { return "wordle" }
|
||||
|
||||
func (p *WordlePlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "wordle", Description: "Guess today's Wordle", Usage: "!wordle <word>", Category: "Games"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) Init() error {
|
||||
// Rehydrate today's puzzle from DB if it exists.
|
||||
p.rehydratePuzzles()
|
||||
|
||||
// Start the midnight ticker for auto-posting.
|
||||
go p.midnightTicker()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *WordlePlugin) OnMessage(ctx MessageContext) error {
|
||||
if !p.IsCommand(ctx.Body, "wordle") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isGamesRoom(ctx.RoomID) {
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!")
|
||||
}
|
||||
}
|
||||
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "wordle"))
|
||||
if args == "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!wordle <word>` — guess today's puzzle.\nSee `!wordle help` for all commands.")
|
||||
}
|
||||
|
||||
switch {
|
||||
case args == "help":
|
||||
return p.handleHelp(ctx)
|
||||
case args == "stats":
|
||||
return p.handleStats(ctx)
|
||||
case args == "grid":
|
||||
return p.handleGrid(ctx)
|
||||
case args == "new" || strings.HasPrefix(args, "new "):
|
||||
return p.handleNew(ctx, args)
|
||||
case args == "skip":
|
||||
return p.handleSkip(ctx)
|
||||
default:
|
||||
return p.handleGuess(ctx, args)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) handleHelp(ctx MessageContext) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"🟩 **Wordle Commands**\n\n"+
|
||||
"`!wordle <word>` — Submit a guess\n"+
|
||||
"`!wordle grid` — Re-post current puzzle grid\n"+
|
||||
"`!wordle stats` — All-time leaderboard\n"+
|
||||
"`!wordle new` — Start a new puzzle (admin)\n"+
|
||||
"`!wordle new <5|6|7>` — New puzzle with specific length (admin)\n"+
|
||||
"`!wordle skip` — Reveal answer and end puzzle (admin)")
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
|
||||
guess = strings.ToUpper(strings.TrimSpace(guess))
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
puzzle := p.puzzles[ctx.RoomID]
|
||||
if puzzle == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No active puzzle. An admin can start one with `!wordle new`.")
|
||||
}
|
||||
|
||||
if puzzle.Solved || puzzle.Failed {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Today's puzzle is already over. A new one starts at midnight UTC!")
|
||||
}
|
||||
|
||||
// Check length.
|
||||
if len([]rune(guess)) != puzzle.WordLength {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Guesses must be %d letters.", puzzle.WordLength))
|
||||
}
|
||||
|
||||
// Check for non-alphabetic characters.
|
||||
for _, r := range guess {
|
||||
if r < 'A' || r > 'Z' {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Guesses must contain only letters.")
|
||||
}
|
||||
}
|
||||
|
||||
// Check duplicate guess.
|
||||
for _, g := range puzzle.Guesses {
|
||||
if g.Word == guess {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("**%s** has already been tried.", guess))
|
||||
}
|
||||
}
|
||||
|
||||
// Validate word via Wordnik (with caching).
|
||||
valid, apiErr := p.isValidWord(puzzle.PuzzleID, guess)
|
||||
if apiErr {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"Word validation is temporarily unavailable. Try again in a moment.")
|
||||
}
|
||||
if !valid {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("❌ **%s** is not a valid word.", guess))
|
||||
}
|
||||
|
||||
// Get display name.
|
||||
displayName := p.displayName(ctx.Sender)
|
||||
|
||||
// Score the guess.
|
||||
results := scoreGuess(guess, puzzle.Answer)
|
||||
updateLetterStates(puzzle.LetterStates, guess, results)
|
||||
|
||||
now := time.Now().UTC()
|
||||
g := WordleGuess{
|
||||
Word: guess,
|
||||
PlayerID: ctx.Sender,
|
||||
PlayerName: displayName,
|
||||
Results: results,
|
||||
Timestamp: now,
|
||||
}
|
||||
puzzle.Guesses = append(puzzle.Guesses, g)
|
||||
|
||||
// Check for win.
|
||||
if guess == puzzle.Answer {
|
||||
puzzle.Solved = true
|
||||
puzzle.SolvedAt = &now
|
||||
|
||||
definition := p.fetchDefinition(puzzle.Answer)
|
||||
p.updateStats(puzzle)
|
||||
p.markPuzzleDone(puzzle)
|
||||
|
||||
return p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition))
|
||||
}
|
||||
|
||||
// Check for failure (all guesses used).
|
||||
if len(puzzle.Guesses) >= puzzle.MaxGuesses {
|
||||
puzzle.Failed = true
|
||||
|
||||
definition := p.fetchDefinition(puzzle.Answer)
|
||||
p.updateStats(puzzle)
|
||||
p.markPuzzleDone(puzzle)
|
||||
|
||||
return p.SendMessage(ctx.RoomID, renderFailedAnnouncement(puzzle, definition))
|
||||
}
|
||||
|
||||
// Post updated grid.
|
||||
return p.SendMessage(ctx.RoomID, renderWordleGrid(puzzle))
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) handleGrid(ctx MessageContext) error {
|
||||
p.mu.Lock()
|
||||
puzzle := p.puzzles[ctx.RoomID]
|
||||
p.mu.Unlock()
|
||||
|
||||
if puzzle == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No active puzzle.")
|
||||
}
|
||||
|
||||
if len(puzzle.Guesses) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
renderWordleStartAnnouncement(puzzle.PuzzleNumber, puzzle.WordLength))
|
||||
}
|
||||
|
||||
return p.SendMessage(ctx.RoomID, renderWordleGrid(puzzle))
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) handleNew(ctx MessageContext, args string) error {
|
||||
if !p.IsAdmin(ctx.Sender) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can start a new puzzle.")
|
||||
}
|
||||
|
||||
wordLength := p.defaultLength
|
||||
parts := strings.Fields(args)
|
||||
if len(parts) > 1 {
|
||||
if n, err := strconv.Atoi(parts[1]); err == nil && n >= 5 && n <= 7 {
|
||||
wordLength = n
|
||||
} else {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Word length must be 5, 6, or 7.")
|
||||
}
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
existing := p.puzzles[ctx.RoomID]
|
||||
if existing != nil && !existing.Solved && !existing.Failed {
|
||||
p.mu.Unlock()
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
"There's already an active puzzle. Use `!wordle skip` to end it first.")
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
return p.createAndPostPuzzle(ctx.RoomID, wordLength)
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) handleSkip(ctx MessageContext) error {
|
||||
if !p.IsAdmin(ctx.Sender) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can skip a puzzle.")
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
puzzle := p.puzzles[ctx.RoomID]
|
||||
if puzzle == nil || puzzle.Solved || puzzle.Failed {
|
||||
p.mu.Unlock()
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No active puzzle to skip.")
|
||||
}
|
||||
puzzle.Failed = true
|
||||
p.markPuzzleDone(puzzle)
|
||||
p.mu.Unlock()
|
||||
|
||||
definition := p.fetchDefinition(puzzle.Answer)
|
||||
defLine := ""
|
||||
if definition != "" {
|
||||
defLine = fmt.Sprintf("\n📖 *%s*\n", definition)
|
||||
}
|
||||
|
||||
return p.SendMessage(ctx.RoomID,
|
||||
fmt.Sprintf("⏭️ **Puzzle skipped.**\nThe word was **%s**.%s", puzzle.Answer, defLine))
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) handleStats(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
|
||||
// Fetch top 10.
|
||||
rows, err := d.Query(
|
||||
`SELECT user_id, display_name, total_guesses, puzzles_played, puzzles_solved, winning_guesses
|
||||
FROM wordle_stats ORDER BY puzzles_solved DESC, winning_guesses DESC LIMIT 10`)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load stats.")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stats []WordlePlayerStat
|
||||
for rows.Next() {
|
||||
var s WordlePlayerStat
|
||||
if err := rows.Scan(&s.UserID, &s.DisplayName, &s.TotalGuesses, &s.PuzzlesPlayed, &s.PuzzlesSolved, &s.WinningGuesses); err != nil {
|
||||
continue
|
||||
}
|
||||
stats = append(stats, s)
|
||||
}
|
||||
|
||||
if len(stats) == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No Wordle stats yet. Play some puzzles first!")
|
||||
}
|
||||
|
||||
streak := p.communityStreak()
|
||||
return p.SendMessage(ctx.RoomID, renderWordleLeaderboard(stats, streak))
|
||||
}
|
||||
|
||||
// createAndPostPuzzle creates a new puzzle, persists it, and posts the announcement.
|
||||
func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int) error {
|
||||
if p.apiKey == "" {
|
||||
return p.SendMessage(roomID, "Wordle is unavailable — WORDNIK_API_KEY not configured.")
|
||||
}
|
||||
|
||||
// Try Wordnik first, fall back to local word list.
|
||||
word, err := wordnikFetchRandomWord(p.apiKey, p.httpClient, wordLength)
|
||||
if err != nil {
|
||||
slog.Warn("wordle: Wordnik fetch failed, trying fallback", "err", err)
|
||||
word = pickFallbackWord(wordLength)
|
||||
if word == "" {
|
||||
return p.SendMessage(roomID, "Failed to select a puzzle word. Try again later.")
|
||||
}
|
||||
}
|
||||
|
||||
puzzleNumber := p.nextPuzzleNumber()
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
now := time.Now().UTC()
|
||||
|
||||
puzzle := &WordlePuzzle{
|
||||
PuzzleID: today,
|
||||
PuzzleNumber: puzzleNumber,
|
||||
RoomID: roomID,
|
||||
Answer: word,
|
||||
WordLength: wordLength,
|
||||
MaxGuesses: 6,
|
||||
StartedAt: now,
|
||||
LetterStates: make(map[rune]LetterResult),
|
||||
}
|
||||
|
||||
// Persist to DB.
|
||||
d := db.Get()
|
||||
_, err = d.Exec(
|
||||
`INSERT INTO wordle_puzzles (puzzle_id, room_id, answer, word_length, solved, guess_count, started_at)
|
||||
VALUES (?, ?, ?, ?, 0, 0, ?)
|
||||
ON CONFLICT(puzzle_id, room_id) DO UPDATE SET answer = ?, word_length = ?, solved = 0, guess_count = 0, started_at = ?`,
|
||||
today, string(roomID), word, wordLength, now,
|
||||
word, wordLength, now,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("wordle: persist puzzle", "err", err)
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.puzzles[roomID] = puzzle
|
||||
p.validCache[today] = make(map[string]bool)
|
||||
p.mu.Unlock()
|
||||
|
||||
return p.SendMessage(roomID, renderWordleStartAnnouncement(puzzleNumber, wordLength))
|
||||
}
|
||||
|
||||
// PostDailyPuzzle is called by the scheduler to post today's puzzle.
|
||||
func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
// Check if already posted today.
|
||||
p.mu.Lock()
|
||||
existing := p.puzzles[roomID]
|
||||
if existing != nil && existing.PuzzleID == today {
|
||||
p.mu.Unlock()
|
||||
return nil // already posted
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
return p.createAndPostPuzzle(roomID, p.defaultLength)
|
||||
}
|
||||
|
||||
// isValidWord checks if a word is valid, using the in-memory cache first.
|
||||
// Returns (valid, apiError). apiError is true when the API is unreachable.
|
||||
func (p *WordlePlugin) isValidWord(puzzleID, word string) (bool, bool) {
|
||||
cache := p.validCache[puzzleID]
|
||||
if cache == nil {
|
||||
cache = make(map[string]bool)
|
||||
p.validCache[puzzleID] = cache
|
||||
}
|
||||
|
||||
if valid, ok := cache[word]; ok {
|
||||
return valid, false
|
||||
}
|
||||
|
||||
valid, apiErr := wordnikValidateWord(p.apiKey, p.httpClient, word)
|
||||
if !apiErr {
|
||||
cache[word] = valid // only cache definitive results
|
||||
}
|
||||
return valid, apiErr
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) fetchDefinition(word string) string {
|
||||
return wordnikFetchDefinitionText(p.apiKey, p.httpClient, word)
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) displayName(userID id.UserID) string {
|
||||
resp, err := p.Client.GetDisplayName(context.Background(), userID)
|
||||
if err != nil || resp.DisplayName == "" {
|
||||
return string(userID)
|
||||
}
|
||||
return resp.DisplayName
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) nextPuzzleNumber() int {
|
||||
d := db.Get()
|
||||
var count int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM wordle_puzzles`).Scan(&count)
|
||||
if err != nil {
|
||||
return 1
|
||||
}
|
||||
return count + 1
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) markPuzzleDone(puzzle *WordlePuzzle) {
|
||||
d := db.Get()
|
||||
solved := 0
|
||||
if puzzle.Solved {
|
||||
solved = 1
|
||||
}
|
||||
_, err := d.Exec(
|
||||
`UPDATE wordle_puzzles SET solved = ?, guess_count = ?, solved_at = ? WHERE puzzle_id = ? AND room_id = ?`,
|
||||
solved, len(puzzle.Guesses), puzzle.SolvedAt, puzzle.PuzzleID, string(puzzle.RoomID),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("wordle: mark puzzle done", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) updateStats(puzzle *WordlePuzzle) {
|
||||
d := db.Get()
|
||||
|
||||
// Tally per-player contributions.
|
||||
type contrib struct {
|
||||
name string
|
||||
guesses int
|
||||
solved bool
|
||||
}
|
||||
players := map[id.UserID]*contrib{}
|
||||
for i, g := range puzzle.Guesses {
|
||||
c, ok := players[g.PlayerID]
|
||||
if !ok {
|
||||
c = &contrib{name: g.PlayerName}
|
||||
players[g.PlayerID] = c
|
||||
}
|
||||
c.guesses++
|
||||
if i == len(puzzle.Guesses)-1 && puzzle.Solved {
|
||||
c.solved = true
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
slog.Error("wordle: begin tx", "err", err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for uid, c := range players {
|
||||
puzzlesSolved := 0
|
||||
if puzzle.Solved {
|
||||
puzzlesSolved = 1
|
||||
}
|
||||
winningGuesses := 0
|
||||
if c.solved {
|
||||
winningGuesses = 1
|
||||
}
|
||||
|
||||
_, err := tx.Exec(
|
||||
`INSERT INTO wordle_stats (user_id, display_name, total_guesses, puzzles_played, puzzles_solved, winning_guesses)
|
||||
VALUES (?, ?, ?, 1, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
display_name = ?,
|
||||
total_guesses = total_guesses + ?,
|
||||
puzzles_played = puzzles_played + 1,
|
||||
puzzles_solved = puzzles_solved + ?,
|
||||
winning_guesses = winning_guesses + ?,
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
string(uid), c.name, c.guesses, puzzlesSolved, winningGuesses,
|
||||
c.name, c.guesses, puzzlesSolved, winningGuesses,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("wordle: update stats", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
slog.Error("wordle: commit stats", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) communityStreak() int {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(
|
||||
`SELECT puzzle_id, solved FROM wordle_puzzles ORDER BY puzzle_id DESC LIMIT 100`)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
streak := 0
|
||||
for rows.Next() {
|
||||
var pid string
|
||||
var solved int
|
||||
if err := rows.Scan(&pid, &solved); err != nil {
|
||||
break
|
||||
}
|
||||
if solved == 1 {
|
||||
streak++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return streak
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) rehydratePuzzles() {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
d := db.Get()
|
||||
|
||||
rows, err := d.Query(
|
||||
`SELECT puzzle_id, room_id, answer, word_length, solved, guess_count, started_at
|
||||
FROM wordle_puzzles WHERE puzzle_id = ?`, today)
|
||||
if err != nil {
|
||||
slog.Warn("wordle: rehydrate query failed", "err", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var pid, roomStr, answer string
|
||||
var wordLength, solved, guessCount int
|
||||
var startedAt time.Time
|
||||
if err := rows.Scan(&pid, &roomStr, &answer, &wordLength, &solved, &guessCount, &startedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if solved == 1 || guessCount >= 6 {
|
||||
continue // already done
|
||||
}
|
||||
|
||||
roomID := id.RoomID(roomStr)
|
||||
|
||||
// Get puzzle number.
|
||||
var puzzleNumber int
|
||||
err := d.QueryRow(`SELECT COUNT(*) FROM wordle_puzzles WHERE puzzle_id <= ?`, pid).Scan(&puzzleNumber)
|
||||
if err != nil {
|
||||
puzzleNumber = 1
|
||||
}
|
||||
|
||||
puzzle := &WordlePuzzle{
|
||||
PuzzleID: pid,
|
||||
PuzzleNumber: puzzleNumber,
|
||||
RoomID: roomID,
|
||||
Answer: answer,
|
||||
WordLength: wordLength,
|
||||
MaxGuesses: 6,
|
||||
StartedAt: startedAt,
|
||||
LetterStates: make(map[rune]LetterResult),
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.puzzles[roomID] = puzzle
|
||||
p.validCache[pid] = make(map[string]bool)
|
||||
p.mu.Unlock()
|
||||
|
||||
slog.Info("wordle: rehydrated puzzle", "room", roomID, "answer_len", wordLength)
|
||||
}
|
||||
}
|
||||
|
||||
// midnightTicker checks every minute if it's time to post a new daily puzzle.
|
||||
func (p *WordlePlugin) midnightTicker() {
|
||||
// Check immediately on startup.
|
||||
p.checkAndPostDaily()
|
||||
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
lastDate := time.Now().UTC().Format("2006-01-02")
|
||||
for range ticker.C {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
if today != lastDate {
|
||||
lastDate = today
|
||||
p.checkAndPostDaily()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WordlePlugin) checkAndPostDaily() {
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
d := db.Get()
|
||||
|
||||
// Check if today's puzzle already exists for this room.
|
||||
var exists int
|
||||
err := d.QueryRow(
|
||||
`SELECT 1 FROM wordle_puzzles WHERE puzzle_id = ? AND room_id = ?`,
|
||||
today, string(gr),
|
||||
).Scan(&exists)
|
||||
if err == nil {
|
||||
return // already exists
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
slog.Error("wordle: check daily puzzle", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("wordle: posting daily puzzle", "room", gr)
|
||||
if err := p.createAndPostPuzzle(gr, p.defaultLength); err != nil {
|
||||
slog.Error("wordle: daily puzzle failed", "err", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user