Files
gogobee/internal/plugin/hangman.go
prosolis 05cf6657f5 Add hospital revival, Robbie bandit, MW/shop fixes, float64 scoring
- St. Guildmore's Memorial Hospital: !hospital command with Nurse Joy,
  procedural itemized billing, same-day revival, 6-hour dead timer,
  hospital ad after death, 2-hour nudge follow-up
- Robbie the Friendly Bandit: automated inventory cleaner, random daily
  visits (8-21 UTC, 40% chance), collects sub-tier gear at €50/item,
  donates to community pot, drops Get Out of Medical Debt Free card
- Fix MW auto-equip: T1 MW no longer replaces better equipped gear
- Fix shop MW block: allow purchasing upgrades over lower-tier MW
- Float64 equipment scoring: advEffectiveTier and advEquipmentScore
  return float64, no mid-calculation truncation (MW 1.25x, Arena 1.5x)
- Fix markdown rendering: convert *asterisk* to _underscore_ italics
  across all flavor text files (shop, blacksmith, rival, hospital)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 17:04:03 -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, 0)
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, 0)
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,
)
}