Files
gogobee/internal/plugin/hangman.go
prosolis 78cbd895f4 Add dictionary commands, antonym/etymology integrations, and audit fixes
New DictionaryPlugin with !antonym, !pronounce, !etymology, !difficulty,
and !rhyme commands. Integrate antonyms into !define (concurrent 500ms
fetch), difficulty tier into Hangman headers, and etymology into WOTD.
Fix rhyme limit bug (request 50, display 7), variable shadowing, and
add spec-required emoji prefixes to all dictionary command outputs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 06:45:25 -07:00

975 lines
25 KiB
Go

package plugin
import (
"bufio"
"encoding/json"
"fmt"
"log/slog"
"math/rand/v2"
"os"
"strings"
"sync"
"unicode"
"gogobee/internal/db"
"gogobee/internal/dreamclient"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// ---------------------------------------------------------------------------
// ASCII Gallows
// ---------------------------------------------------------------------------
var gallows = [7]string{
// Stage 0
`
+---+
|
|
|
|
===`,
// Stage 1
`
+---+
| |
|
|
|
===`,
// Stage 2
`
+---+
| |
| O
|
|
===`,
// Stage 3
`
+---+
| |
| O
| |
|
===`,
// Stage 4
`
+---+
| |
| O
| /|
|
===`,
// Stage 5
`
+---+
| |
| O
| /|\
|
===`,
// Stage 6 (dead)
`
+---+
| |
| O
| /|\
| / \
===`,
}
// ---------------------------------------------------------------------------
// Hangman config and types
// ---------------------------------------------------------------------------
type hangmanTier struct {
Name string
Min int
Max int
Bonus float64
}
var hangmanTiers = []hangmanTier{
{"Easy", 8, 20, 25},
{"Medium", 21, 40, 75},
{"Hard", 41, 80, 200},
{"Extreme", 81, 9999, 500},
}
func getTier(phrase string) hangmanTier {
n := len(phrase)
for _, t := range hangmanTiers {
if n >= t.Min && n <= t.Max {
return t
}
}
return hangmanTiers[0]
}
type hangmanGame struct {
phrase string
runes []rune // phrase as rune slice for safe indexing
tier hangmanTier
revealed []bool // true for each rune that is revealed
wrongGuesses []rune
maxWrong int
participants map[id.UserID]bool
solved bool
solvedBy id.UserID
earlySolve bool
threadID id.EventID // thread root event for this game
lang string // puzzle word language (e.g. "pt-PT")
clueLang string // clue language (e.g. "en")
clueWord string // translated clue word
}
func newHangmanGame(phrase string, maxWrong int) *hangmanGame {
runes := []rune(phrase)
revealed := make([]bool, len(runes))
// Auto-reveal spaces and punctuation
for i, ch := range runes {
if !unicode.IsLetter(ch) && !unicode.IsDigit(ch) {
revealed[i] = true
}
}
return &hangmanGame{
phrase: phrase,
runes: runes,
tier: getTier(phrase),
revealed: revealed,
maxWrong: maxWrong,
participants: make(map[id.UserID]bool),
}
}
func (g *hangmanGame) wrongCount() int {
return len(g.wrongGuesses)
}
func (g *hangmanGame) remaining() int {
return g.maxWrong - g.wrongCount()
}
func (g *hangmanGame) isDead() bool {
return g.wrongCount() >= g.maxWrong
}
func (g *hangmanGame) isFullyRevealed() bool {
for _, r := range g.revealed {
if !r {
return false
}
}
return true
}
func (g *hangmanGame) revealedCount() int {
count := 0
for i, ch := range g.runes {
if (unicode.IsLetter(ch) || unicode.IsDigit(ch)) && g.revealed[i] {
count++
}
}
return count
}
func (g *hangmanGame) letterCount() int {
count := 0
for _, ch := range g.runes {
if unicode.IsLetter(ch) || unicode.IsDigit(ch) {
count++
}
}
return count
}
func (g *hangmanGame) displayPhrase() string {
var sb strings.Builder
for i, ch := range g.runes {
if ch == ' ' {
sb.WriteString(" ") // wide gap for word boundaries
} else if g.revealed[i] {
sb.WriteRune(ch)
sb.WriteRune(' ')
} else {
sb.WriteString("_ ")
}
}
return strings.TrimRight(sb.String(), " ")
}
func (g *hangmanGame) guessLetter(ch rune) (hit bool, alreadyGuessed bool) {
ch = unicode.ToUpper(ch)
lower := unicode.ToLower(ch)
// Check if already guessed (wrong list or already revealed)
for _, w := range g.wrongGuesses {
if unicode.ToUpper(w) == ch {
return false, true
}
}
hit = false
for i, c := range g.runes {
if unicode.ToUpper(c) == ch || unicode.ToLower(c) == lower {
if g.revealed[i] {
// Already revealed — check if ALL instances are revealed
allRevealed := true
for j, cc := range g.runes {
if (unicode.ToUpper(cc) == ch || unicode.ToLower(cc) == lower) && !g.revealed[j] {
allRevealed = false
}
}
if allRevealed {
return false, true
}
}
g.revealed[i] = true
hit = true
}
}
// Reveal adjacent punctuation when a letter is revealed
if hit {
for i := range g.runes {
if g.revealed[i] {
// Reveal adjacent non-letter chars
if i > 0 && !unicode.IsLetter(g.runes[i-1]) && !unicode.IsDigit(g.runes[i-1]) {
g.revealed[i-1] = true
}
if i < len(g.runes)-1 && !unicode.IsLetter(g.runes[i+1]) && !unicode.IsDigit(g.runes[i+1]) {
g.revealed[i+1] = true
}
}
}
}
if !hit {
g.wrongGuesses = append(g.wrongGuesses, ch)
}
return hit, false
}
func (g *hangmanGame) guessSolution(attempt string) bool {
return strings.EqualFold(strings.TrimSpace(attempt), g.phrase)
}
func (g *hangmanGame) wrongGuessStr() string {
if len(g.wrongGuesses) == 0 {
return "none"
}
parts := make([]string, len(g.wrongGuesses))
for i, r := range g.wrongGuesses {
parts[i] = string(unicode.ToUpper(r))
}
return strings.Join(parts, ", ")
}
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
type HangmanPlugin struct {
Base
euro *EuroPlugin
dict *dreamclient.Client
phrases []string
maxWrong int
bonusMul float64
mu sync.Mutex
games map[id.RoomID]*hangmanGame
}
func NewHangmanPlugin(client *mautrix.Client, euro *EuroPlugin, dict *dreamclient.Client) *HangmanPlugin {
return &HangmanPlugin{
Base: NewBase(client),
euro: euro,
dict: dict,
maxWrong: envInt("HANGMAN_MAX_WRONG_GUESSES", 6),
bonusMul: envFloat("HANGMAN_SOLUTION_BONUS_MULTIPLIER", 2),
games: make(map[id.RoomID]*hangmanGame),
}
}
func (p *HangmanPlugin) Name() string { return "hangman" }
func (p *HangmanPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "hangman", Description: "Collaborative Hangman game", Usage: "!hangman start [easy|medium|hard|extreme] | !hangman [lang] [--clue <lang>] | !hangman submit [phrase]", Category: "Games"},
{Name: "hangboard", Description: "Hangman leaderboard", Usage: "!hangboard", Category: "Games"},
}
}
func (p *HangmanPlugin) Init() error {
path := os.Getenv("HANGMAN_PHRASE_FILE")
if path != "" {
p.loadPhrases(path)
}
return nil
}
func (p *HangmanPlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *HangmanPlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "hangboard") {
return p.handleBoard(ctx)
}
if !p.IsCommand(ctx.Body, "hangman") {
// Check if this is a thread reply to an active hangman game
if !isGamesRoom(ctx.RoomID) {
return nil
}
p.mu.Lock()
game, active := p.games[ctx.RoomID]
p.mu.Unlock()
if active && game != nil {
content := ctx.Event.Content.AsMessage()
if content != nil && content.RelatesTo != nil &&
content.RelatesTo.Type == event.RelThread &&
content.RelatesTo.EventID == game.threadID {
guess := strings.TrimSpace(ctx.Body)
if guess != "" {
return p.handleGuess(ctx, guess)
}
}
}
return nil
}
if !isGamesRoom(ctx.RoomID) {
return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!")
}
args := strings.TrimSpace(p.GetArgs(ctx.Body, "hangman"))
lower := strings.ToLower(args)
switch {
case args == "" || lower == "start":
return p.handleStart(ctx, "", "", "")
case strings.HasPrefix(lower, "start "):
return p.handleStart(ctx, strings.TrimSpace(args[6:]), "", "")
case strings.HasPrefix(lower, "submit "):
return p.handleSubmit(ctx, strings.TrimSpace(args[7:]))
case lower == "skip":
return p.handleSkip(ctx)
default:
// Check if this is a multilingual start: !hangman <lang> [--clue <lang>]
if lang, clueLang, ok := p.parseMultilingualArgs(args); ok {
return p.handleStart(ctx, "", lang, clueLang)
}
return p.handleGuess(ctx, args)
}
}
// parseMultilingualArgs parses "pt-PT --clue en" or "fr --clue en" style args.
// Returns (lang, clueLang, true) if the first token is a valid language tag.
func (p *HangmanPlugin) parseMultilingualArgs(args string) (string, string, bool) {
parts := strings.Fields(args)
if len(parts) == 0 {
return "", "", false
}
lang := normaliseLang(parts[0])
if lang == "" {
return "", "", false
}
clueLang := ""
for i := 1; i < len(parts); i++ {
if strings.ToLower(parts[i]) == "--clue" && i+1 < len(parts) {
clueLang = normaliseLang(parts[i+1])
if clueLang == "" {
return lang, "", false // invalid clue lang, will be handled in handleStart
}
break
}
}
return lang, clueLang, true
}
// ---------------------------------------------------------------------------
// Phrase management
// ---------------------------------------------------------------------------
func (p *HangmanPlugin) loadPhrases(path string) {
f, err := os.Open(path)
if err != nil {
slog.Warn("hangman: failed to load phrases", "path", path, "err", err)
return
}
defer f.Close()
var phrases []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" && !strings.HasPrefix(line, "#") {
phrases = append(phrases, line)
}
}
p.phrases = phrases
slog.Info("hangman: phrases loaded", "count", len(phrases))
}
func (p *HangmanPlugin) addPhrase(phrase string) error {
path := os.Getenv("HANGMAN_PHRASE_FILE")
if path == "" {
return fmt.Errorf("no phrase file configured")
}
// Duplicate check
for _, existing := range p.phrases {
if strings.EqualFold(existing, phrase) {
return fmt.Errorf("duplicate phrase")
}
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = fmt.Fprintln(f, phrase)
if err != nil {
return err
}
p.phrases = append(p.phrases, phrase)
return nil
}
// ---------------------------------------------------------------------------
// Game commands
// ---------------------------------------------------------------------------
func (p *HangmanPlugin) handleStart(ctx MessageContext, difficulty, lang, clueLang string) error {
p.mu.Lock()
if _, active := p.games[ctx.RoomID]; active {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "A Hangman game is already in progress!")
}
// Multilingual mode: use DreamDict for word selection
if lang != "" {
// Validate before reserving the slot
if p.dict == nil {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "❌ Dictionary service unavailable. Try again shortly.")
}
if clueLang != "" && clueLang == lang {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "❌ Clue language must differ from the puzzle language.")
}
if clueLang != "" {
if normaliseLang(clueLang) == "" {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("❌ Unknown clue language \"%s\". Supported: en, fr, pt-PT", clueLang))
}
}
// Reserve the slot so no concurrent start can race us during network I/O
p.games[ctx.RoomID] = &hangmanGame{} // placeholder
p.mu.Unlock()
return p.startMultilingualGame(ctx, lang, clueLang)
}
if len(p.phrases) == 0 {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "No phrases loaded. Ask an admin to set up HANGMAN_PHRASE_FILE.")
}
// Filter by difficulty if specified
pool := p.phrases
if difficulty != "" {
var tier *hangmanTier
for i := range hangmanTiers {
if strings.EqualFold(hangmanTiers[i].Name, difficulty) {
tier = &hangmanTiers[i]
break
}
}
if tier == nil {
p.mu.Unlock()
names := make([]string, len(hangmanTiers))
for i, t := range hangmanTiers {
names[i] = t.Name
}
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Unknown difficulty. Options: %s", strings.Join(names, ", ")))
}
var filtered []string
for _, ph := range p.phrases {
if n := len(ph); n >= tier.Min && n <= tier.Max {
filtered = append(filtered, ph)
}
}
if len(filtered) == 0 {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("No phrases available for **%s** difficulty.", tier.Name))
}
pool = filtered
}
phrase := pool[rand.IntN(len(pool))]
game := newHangmanGame(phrase, p.maxWrong)
// Reserve the slot so no concurrent start can race us
p.games[ctx.RoomID] = game
p.mu.Unlock()
// Create thread root message (network I/O outside mutex)
threadRoot := fmt.Sprintf(
"🎮 **Hangman!** Tier: **%s** | %d guesses allowed\n\n```\n%s\n```\n%s\n\nReply in this thread with a letter or the full phrase to guess!",
game.tier.Name, game.maxWrong, gallows[0], game.displayPhrase(),
)
eventID, err := p.SendMessageID(ctx.RoomID, threadRoot)
if err != nil {
// Roll back reservation
p.mu.Lock()
delete(p.games, ctx.RoomID)
p.mu.Unlock()
return err
}
p.mu.Lock()
game.threadID = eventID
p.mu.Unlock()
// Send an initial thread reply to materialize the thread in clients
return p.SendThread(ctx.RoomID, eventID, fmt.Sprintf(
"```\n%s\n```\n%s\n\nWrong guesses: none",
gallows[0], game.displayPhrase(),
))
}
// startMultilingualGame picks a DreamDict word and starts a multilingual hangman game.
// Caller must have already reserved p.games[ctx.RoomID] with a placeholder.
func (p *HangmanPlugin) startMultilingualGame(ctx MessageContext, lang, clueLang string) error {
rollback := func() {
p.mu.Lock()
if g := p.games[ctx.RoomID]; g != nil && g.phrase == "" {
delete(p.games, ctx.RoomID)
}
p.mu.Unlock()
}
langName := dreamdictLangNames[lang]
if langName == "" {
langName = lang
}
// Pick a word, optionally with a translatable clue
var word, clueWord string
for attempt := 0; attempt < 3; attempt++ {
candidate, err := p.dict.RandomWord(lang, "", 4, 12)
if err != nil {
slog.Error("hangman: random word failed", "lang", lang, "err", err)
rollback()
return p.SendReply(ctx.RoomID, ctx.EventID, "❌ Dictionary service unavailable. Try again shortly.")
}
if clueLang == "" {
word = candidate
break
}
translations, err := p.dict.Translate(candidate, lang, clueLang)
if err != nil || len(translations) == 0 {
continue // retry with a different word
}
word = candidate
clueWord = translations[0]
break
}
if word == "" {
// Fallback: start without a clue
candidate, err := p.dict.RandomWord(lang, "", 4, 12)
if err != nil {
rollback()
return p.SendReply(ctx.RoomID, ctx.EventID, "❌ Dictionary service unavailable. Try again shortly.")
}
word = candidate
}
game := newHangmanGame(word, p.maxWrong)
game.lang = lang
game.clueLang = clueLang
game.clueWord = clueWord
// Replace the placeholder reserved by handleStart
p.mu.Lock()
p.games[ctx.RoomID] = game
p.mu.Unlock()
// Fetch difficulty tier from DreamDict.
diffLabel := ""
if p.dict != nil {
if diff, err := p.dict.Difficulty(strings.ToLower(word), lang); err == nil && diff >= 0 {
diffLabel = " (" + difficultyTier(diff) + ")"
}
}
// Build thread root
var sb strings.Builder
if clueLang != "" {
clueLangName := dreamdictLangNames[clueLang]
if clueLangName == "" {
clueLangName = clueLang
}
sb.WriteString(fmt.Sprintf("🐝 **Hangman — %s%s** (clue: %s)\n", langName, diffLabel, clueLangName))
} else {
sb.WriteString(fmt.Sprintf("🐝 **Hangman — %s%s**\n", langName, diffLabel))
}
sb.WriteString(fmt.Sprintf("Word: %s (%d letters)\n", game.displayPhrase(), len([]rune(word))))
if clueWord != "" {
sb.WriteString(fmt.Sprintf("💡 Clue: %s\n", clueWord))
} else if clueLang != "" {
sb.WriteString("💡 No clue available for this word.\n")
}
sb.WriteString(fmt.Sprintf("\nWrong guesses: 0/%d\n", game.maxWrong))
sb.WriteString("Guess a letter: `!hangman <letter>`")
eventID, err := p.SendMessageID(ctx.RoomID, sb.String())
if err != nil {
p.mu.Lock()
delete(p.games, ctx.RoomID)
p.mu.Unlock()
return err
}
p.mu.Lock()
game.threadID = eventID
p.mu.Unlock()
// Materialize thread
return p.SendThread(ctx.RoomID, eventID, fmt.Sprintf(
"```\n%s\n```\n%s\n\nWrong guesses: none",
gallows[0], game.displayPhrase(),
))
}
func (p *HangmanPlugin) handleGuess(ctx MessageContext, guess string) error {
p.mu.Lock()
game, active := p.games[ctx.RoomID]
if !active {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "No Hangman game in progress. Start one with `!hangman start`")
}
// Game exists but thread not yet created (start in progress)
if game.threadID == "" {
p.mu.Unlock()
return nil
}
// Register participant (under lock)
game.participants[ctx.Sender] = true
p.mu.Unlock()
guess = strings.TrimSpace(guess)
// Single letter guess
if len([]rune(guess)) == 1 {
ch := []rune(guess)[0]
if !unicode.IsLetter(ch) {
return p.SendThread(ctx.RoomID, game.threadID, "Please guess a letter.")
}
return p.processLetterGuess(ctx, game, ch)
}
// Full solution attempt
return p.processSolutionGuess(ctx, game, guess)
}
func (p *HangmanPlugin) processLetterGuess(ctx MessageContext, game *hangmanGame, ch rune) error {
// All game state mutations under lock
p.mu.Lock()
hit, already := game.guessLetter(ch)
if already {
threadID := game.threadID
p.mu.Unlock()
return p.SendThread(ctx.RoomID, threadID,
fmt.Sprintf("'%c' was already guessed.", unicode.ToUpper(ch)))
}
// Snapshot state for messages before unlocking
display := game.displayPhrase()
wrongStr := game.wrongGuessStr()
remaining := game.remaining()
fullyRevealed := false
dead := false
if hit {
fullyRevealed = game.isFullyRevealed()
if fullyRevealed {
game.solved = true
game.solvedBy = ctx.Sender
}
} else {
dead = game.isDead()
}
wrongCount := game.wrongCount()
p.mu.Unlock()
if hit {
if fullyRevealed {
return p.endGame(ctx.RoomID, game)
}
return p.SendThread(ctx.RoomID, game.threadID, fmt.Sprintf(
"✅ '%c' is in the phrase! (%d guesses remaining)\n%s\n\nWrong guesses: %s",
unicode.ToUpper(ch), remaining, display, wrongStr,
))
}
// Wrong guess
if dead {
return p.endGame(ctx.RoomID, game)
}
return p.SendThread(ctx.RoomID, game.threadID, fmt.Sprintf(
"❌ Wrong! (%d guesses remaining)\n```\n%s\n```\n%s\n\nWrong guesses: %s",
remaining, gallows[wrongCount], display, wrongStr,
))
}
func (p *HangmanPlugin) processSolutionGuess(ctx MessageContext, game *hangmanGame, guess string) error {
p.mu.Lock()
if game.guessSolution(guess) {
game.solved = true
game.solvedBy = ctx.Sender
if game.revealedCount() < game.letterCount()/2 {
game.earlySolve = true
}
p.mu.Unlock()
return p.endGame(ctx.RoomID, game)
}
// Wrong solution — costs a life
game.wrongGuesses = append(game.wrongGuesses, '?')
dead := game.isDead()
remaining := game.remaining()
wrongCount := game.wrongCount()
display := game.displayPhrase()
wrongStr := game.wrongGuessStr()
p.mu.Unlock()
if dead {
return p.endGame(ctx.RoomID, game)
}
return p.SendThread(ctx.RoomID, game.threadID, fmt.Sprintf(
"❌ Wrong solution! (%d guesses remaining)\n```\n%s\n```\n%s\n\nWrong guesses: %s",
remaining, gallows[wrongCount], display, wrongStr,
))
}
func (p *HangmanPlugin) endGame(roomID id.RoomID, game *hangmanGame) error {
p.mu.Lock()
delete(p.games, roomID)
p.mu.Unlock()
if !game.solved {
// Loss — broadcast to room
return p.SendMessage(roomID, fmt.Sprintf(
"💀 Game over! The phrase was:\n\"%s\"\n```\n%s\n```\nNobody solved it this time.",
game.phrase, gallows[6],
))
}
// Calculate payouts
eligibleParticipants := make([]id.UserID, 0, len(game.participants))
for uid := range game.participants {
eligibleParticipants = append(eligibleParticipants, uid)
}
solverName := p.DisplayName(game.solvedBy)
participantCount := len(eligibleParticipants)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🎉 Solved by **%s**!\n\"%s\"\nTier: %s | Participants: %d\n\n",
solverName, game.phrase, game.tier.Name, participantCount,
))
if participantCount > 0 {
basePot := game.tier.Bonus
share := basePot / float64(participantCount)
sb.WriteString("**Payouts:**\n")
for _, uid := range eligibleParticipants {
payout := share
label := ""
if uid == game.solvedBy && game.earlySolve {
payout = share * p.bonusMul
label = " (early solve bonus!)"
}
p.euro.Credit(uid, payout, "hangman_win")
p.recordHangmanScore(uid, payout)
name := p.DisplayName(uid)
sb.WriteString(fmt.Sprintf(" **%s**: +€%d%s\n", name, int(payout), label))
}
}
return p.SendMessage(roomID, sb.String())
}
func (p *HangmanPlugin) handleSkip(ctx MessageContext) error {
if !p.IsAdmin(ctx.Sender) {
return nil
}
p.mu.Lock()
game, active := p.games[ctx.RoomID]
if !active {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "No game in progress.")
}
delete(p.games, ctx.RoomID)
p.mu.Unlock()
return p.SendMessage(ctx.RoomID, fmt.Sprintf(
"⏭️ Game skipped! The phrase was:\n\"%s\"", game.phrase))
}
func (p *HangmanPlugin) handleSubmit(ctx MessageContext, phrase string) error {
if len(phrase) < 8 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Phrase must be at least 8 characters.")
}
// LLM screening
ollamaHost := os.Getenv("OLLAMA_HOST")
ollamaModel := os.Getenv("OLLAMA_MODEL")
if ollamaHost == "" || ollamaModel == "" {
// No LLM available — add directly
if err := p.addPhrase(phrase); err != nil {
if err.Error() == "duplicate phrase" {
return p.SendReply(ctx.RoomID, ctx.EventID, "That phrase already exists.")
}
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save phrase.")
}
_ = p.SendDM(ctx.Sender, "Your phrase has been added to the Hangman pool. Thanks!")
return p.SendReply(ctx.RoomID, ctx.EventID, "Phrase submitted and added!")
}
prompt := fmt.Sprintf(`You are screening community submissions for a Hangman game. Evaluate only whether the phrase is offensive, hateful, sexually explicit, or otherwise inappropriate for a general adult audience.
Respond only in JSON:
{ "approved": true }
or
{ "approved": false, "reason": "one sentence explanation" }
Phrase: %s`, phrase)
result, err := callOllama(ollamaHost, ollamaModel, prompt)
if err != nil {
slog.Error("hangman: LLM screening failed", "err", err)
// Fail open — add it
if addErr := p.addPhrase(phrase); addErr != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save phrase.")
}
_ = p.SendDM(ctx.Sender, "Your phrase has been added to the Hangman pool. Thanks!")
return p.SendReply(ctx.RoomID, ctx.EventID, "Phrase submitted and added!")
}
var screening struct {
Approved bool `json:"approved"`
Reason string `json:"reason"`
}
// Extract JSON from response
jsonStr := result
if idx := strings.Index(result, "{"); idx >= 0 {
if end := strings.LastIndex(result, "}"); end >= idx {
jsonStr = result[idx : end+1]
}
}
if err := json.Unmarshal([]byte(jsonStr), &screening); err != nil {
// Parse failed — add it
if addErr := p.addPhrase(phrase); addErr != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save phrase.")
}
_ = p.SendDM(ctx.Sender, "Your phrase has been added to the Hangman pool. Thanks!")
return p.SendReply(ctx.RoomID, ctx.EventID, "Phrase submitted and added!")
}
if !screening.Approved {
_ = p.SendDM(ctx.Sender, fmt.Sprintf("Your Hangman phrase was not approved: %s", screening.Reason))
return p.SendReply(ctx.RoomID, ctx.EventID, "Phrase reviewed — check your DMs for details.")
}
if err := p.addPhrase(phrase); err != nil {
if err.Error() == "duplicate phrase" {
return p.SendReply(ctx.RoomID, ctx.EventID, "That phrase already exists.")
}
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save phrase.")
}
_ = p.SendDM(ctx.Sender, "Your phrase has been added to the Hangman pool. Thanks!")
return p.SendReply(ctx.RoomID, ctx.EventID, "Phrase submitted and added!")
}
// ---------------------------------------------------------------------------
// Leaderboard
// ---------------------------------------------------------------------------
func (p *HangmanPlugin) handleBoard(ctx MessageContext) error {
d := db.Get()
rows, err := d.Query(
`SELECT user_id, total_earned, games_won FROM hangman_scores ORDER BY total_earned DESC LIMIT 10`,
)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch leaderboard.")
}
defer rows.Close()
var sb strings.Builder
sb.WriteString("🎮 **Hangman Leaderboard**\n\n")
rank := 0
for rows.Next() {
var userID string
var earned float64
var won int
rows.Scan(&userID, &earned, &won)
rank++
name := p.DisplayName(id.UserID(userID))
sb.WriteString(fmt.Sprintf("%d. **%s** — €%d earned (%d wins)\n", rank, name, int(earned), won))
}
if rank == 0 {
sb.WriteString("No games played yet.")
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
func (p *HangmanPlugin) recordHangmanScore(userID id.UserID, earned float64) {
db.Exec("hangman: record score",
`INSERT INTO hangman_scores (user_id, total_earned, games_played, games_won)
VALUES (?, ?, 1, 1)
ON CONFLICT(user_id) DO UPDATE SET
total_earned = total_earned + ?,
games_played = games_played + 1,
games_won = games_won + 1`,
string(userID), earned, earned,
)
}