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:
prosolis
2026-03-22 10:09:57 -07:00
parent e9890fd880
commit c7c1b76589
35 changed files with 12388 additions and 3 deletions

618
internal/plugin/wordle.go Normal file
View 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)
}
}