mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
- Version system: per-plugin versions via Versioned interface, crash_log and version_history DB tables, !version command, crash stats in !botinfo - Tax tracking: tax_ledger table, trackTaxPaid across all communityTax and communityPotAdd sites, tax paid display in !stats and !superstatsexplusalpha - Lottery: DM winners with winning ticket details and matched numbers bolded - fmtEuro: generic currency formatter with thousand separators across all economy display paths - Audit fixes: streak_decayed column for Streak Survivor achievement, combat_narrative empty-phase guard, crafting success rate integer division, RecordCrash nil-DB guard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
977 lines
25 KiB
Go
977 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) Version() string { return "1.1.0" }
|
|
|
|
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!)"
|
|
}
|
|
net, _ := communityTax(uid, payout, 0.05)
|
|
p.euro.Credit(uid, net, "hangman_win")
|
|
p.recordHangmanScore(uid, net)
|
|
name := p.DisplayName(uid)
|
|
sb.WriteString(fmt.Sprintf(" **%s**: +€%d%s\n", name, int(net), 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,
|
|
)
|
|
}
|
|
|