Fix command prefix bug and audit issues across all new plugins

All new plugins used Base{Client: client} instead of NewBase(client),
leaving Prefix empty so no !commands matched. Also fixes hangman UTF-8
safety (use runes not byte indices), euro race conditions, blackjack
deck/timer bugs, and adds difficulty selection to hangman start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-11 19:43:46 -07:00
parent cce5160057
commit 992b62c51f
5 changed files with 173 additions and 82 deletions

View File

@@ -65,6 +65,10 @@ func newDeck() *deck {
} }
func (d *deck) draw() card { func (d *deck) draw() card {
if len(d.cards) == 0 {
// Reshuffle a fresh deck if exhausted (extremely rare)
*d = *newDeck()
}
c := d.cards[0] c := d.cards[0]
d.cards = d.cards[1:] d.cards = d.cards[1:]
return c return c
@@ -172,7 +176,7 @@ type BlackjackPlugin struct {
func NewBlackjackPlugin(client *mautrix.Client, euro *EuroPlugin) *BlackjackPlugin { func NewBlackjackPlugin(client *mautrix.Client, euro *EuroPlugin) *BlackjackPlugin {
return &BlackjackPlugin{ return &BlackjackPlugin{
Base: Base{Client: client}, Base: NewBase(client),
euro: euro, euro: euro,
cfg: loadBJConfig(), cfg: loadBJConfig(),
tables: make(map[id.RoomID]*bjTable), tables: make(map[id.RoomID]*bjTable),
@@ -484,7 +488,8 @@ func (p *BlackjackPlugin) startTurnTimer(roomID id.RoomID, table *bjTable) {
return return
} }
player := table.players[turnIdx] // Use looked-up t, not captured table pointer
player := t.players[turnIdx]
v := player.value() v := player.value()
name := p.bjDisplayName(player.UserID) name := p.bjDisplayName(player.UserID)
@@ -492,24 +497,24 @@ func (p *BlackjackPlugin) startTurnTimer(roomID id.RoomID, table *bjTable) {
_ = p.SendMessage(roomID, _ = p.SendMessage(roomID,
fmt.Sprintf("⏱️ **%s** timed out — auto-playing (stand)", name)) fmt.Sprintf("⏱️ **%s** timed out — auto-playing (stand)", name))
player.Done = true player.Done = true
p.advanceTurn(roomID, table) p.advanceTurn(roomID, t)
} else { } else {
_ = p.SendMessage(roomID, _ = p.SendMessage(roomID,
fmt.Sprintf("⏱️ **%s** timed out — auto-playing (hit)", name)) fmt.Sprintf("⏱️ **%s** timed out — auto-playing (hit)", name))
player.Hand = append(player.Hand, table.deck.draw()) player.Hand = append(player.Hand, t.deck.draw())
v = player.value() v = player.value()
if v > 21 { if v > 21 {
player.Bust = true player.Bust = true
player.Done = true player.Done = true
_ = p.SendMessage(roomID, _ = p.SendMessage(roomID,
fmt.Sprintf("💥 **%s** busts with %s (%d)!", name, handStr(player.Hand), v)) fmt.Sprintf("💥 **%s** busts with %s (%d)!", name, handStr(player.Hand), v))
p.advanceTurn(roomID, table) p.advanceTurn(roomID, t)
} else if v >= p.cfg.AutoplayThreshold { } else if v >= p.cfg.AutoplayThreshold {
player.Done = true player.Done = true
p.advanceTurn(roomID, table) p.advanceTurn(roomID, t)
} else { } else {
// Still below threshold, restart timer // Still below threshold, restart timer
p.startTurnTimer(roomID, table) p.startTurnTimer(roomID, t)
} }
} }
}) })
@@ -613,6 +618,13 @@ func (p *BlackjackPlugin) resolveRound(roomID id.RoomID, table *bjTable) {
sb.WriteString(fmt.Sprintf("\nDealer: %s (%d)\n", handStr(table.dealer), dealerValue)) sb.WriteString(fmt.Sprintf("\nDealer: %s (%d)\n", handStr(table.dealer), dealerValue))
// Stop any pending timers before cleanup
if table.turnTimer != nil {
table.turnTimer.Stop()
}
if table.joinTimer != nil {
table.joinTimer.Stop()
}
delete(p.tables, roomID) delete(p.tables, roomID)
_ = p.SendMessage(roomID, sb.String()) _ = p.SendMessage(roomID, sb.String())
} }

View File

@@ -58,7 +58,7 @@ type EuroPlugin struct {
func NewEuroPlugin(client *mautrix.Client) *EuroPlugin { func NewEuroPlugin(client *mautrix.Client) *EuroPlugin {
return &EuroPlugin{ return &EuroPlugin{
Base: Base{Client: client}, Base: NewBase(client),
cfg: loadEuroConfig(), cfg: loadEuroConfig(),
cooldowns: make(map[id.UserID]time.Time), cooldowns: make(map[id.UserID]time.Time),
} }
@@ -142,32 +142,34 @@ func (p *EuroPlugin) awardPassiveEuros(ctx MessageContext) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ensureBalance creates a balance row if none exists, seeding from corpus. // ensureBalance creates a balance row if none exists, seeding from corpus.
// Uses INSERT OR IGNORE + RowsAffected to avoid duplicate starting_balance logs.
func (p *EuroPlugin) ensureBalance(userID id.UserID) { func (p *EuroPlugin) ensureBalance(userID id.UserID) {
d := db.Get() d := db.Get()
var exists int
_ = d.QueryRow("SELECT 1 FROM euro_balances WHERE user_id = ?", string(userID)).Scan(&exists)
if exists == 1 {
return
}
// Calculate starting balance from corpus character count // Calculate starting balance from corpus character count
var totalChars float64 var totalChars float64
_ = d.QueryRow("SELECT COALESCE(SUM(total_chars), 0) FROM user_stats WHERE user_id = ?", if err := d.QueryRow("SELECT COALESCE(SUM(total_chars), 0) FROM user_stats WHERE user_id = ?",
string(userID)).Scan(&totalChars) string(userID)).Scan(&totalChars); err != nil {
totalChars = 0
}
starting := totalChars / 1000.0 starting := totalChars / 1000.0
if starting > p.cfg.StartingCap { if starting > p.cfg.StartingCap {
starting = p.cfg.StartingCap starting = p.cfg.StartingCap
} }
_, err := d.Exec( result, err := d.Exec(
"INSERT OR IGNORE INTO euro_balances (user_id, balance) VALUES (?, ?)", "INSERT OR IGNORE INTO euro_balances (user_id, balance) VALUES (?, ?)",
string(userID), starting, string(userID), starting,
) )
if err != nil { if err != nil {
slog.Error("euro: failed to create balance", "user", userID, "err", err) slog.Error("euro: failed to create balance", "user", userID, "err", err)
return
} }
if starting > 0 {
// Only log transaction if a row was actually inserted (not ignored)
affected, _ := result.RowsAffected()
if affected > 0 && starting > 0 {
p.logTransaction(userID, starting, "starting_balance") p.logTransaction(userID, starting, "starting_balance")
} }
} }
@@ -185,27 +187,30 @@ func (p *EuroPlugin) credit(userID id.UserID, amount float64, reason string) {
p.logTransaction(userID, amount, reason) p.logTransaction(userID, amount, reason)
} }
// Debit subtracts euros. Returns false if this would exceed debt limit. // Debit subtracts euros atomically. Returns false if this would exceed debt limit.
// Uses a conditional UPDATE to prevent race conditions (check-and-act in one statement).
func (p *EuroPlugin) Debit(userID id.UserID, amount float64, reason string) bool { func (p *EuroPlugin) Debit(userID id.UserID, amount float64, reason string) bool {
p.ensureBalance(userID) p.ensureBalance(userID)
d := db.Get() d := db.Get()
debtLimit := envFloat("BLACKJACK_DEBT_LIMIT", 1000) debtLimit := envFloat("BLACKJACK_DEBT_LIMIT", 1000)
var balance float64
_ = d.QueryRow("SELECT balance FROM euro_balances WHERE user_id = ?", string(userID)).Scan(&balance)
if balance-amount < -debtLimit { // Atomic: only debit if the result stays within debt limit
return false result, err := d.Exec(
} `UPDATE euro_balances SET balance = balance - ?, updated_at = CURRENT_TIMESTAMP
WHERE user_id = ? AND (balance - ?) >= ?`,
_, err := d.Exec( amount, string(userID), amount, -debtLimit,
"UPDATE euro_balances SET balance = balance - ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?",
amount, string(userID),
) )
if err != nil { if err != nil {
slog.Error("euro: debit failed", "user", userID, "amount", amount, "err", err) slog.Error("euro: debit failed", "user", userID, "amount", amount, "err", err)
return false return false
} }
affected, _ := result.RowsAffected()
if affected == 0 {
return false // balance check failed or user doesn't exist
}
p.logTransaction(userID, -amount, reason) p.logTransaction(userID, -amount, reason)
return true return true
} }
@@ -221,7 +226,10 @@ func (p *EuroPlugin) GetBalance(userID id.UserID) float64 {
p.ensureBalance(userID) p.ensureBalance(userID)
d := db.Get() d := db.Get()
var balance float64 var balance float64
_ = d.QueryRow("SELECT balance FROM euro_balances WHERE user_id = ?", string(userID)).Scan(&balance) if err := d.QueryRow("SELECT balance FROM euro_balances WHERE user_id = ?",
string(userID)).Scan(&balance); err != nil {
slog.Error("euro: failed to get balance", "user", userID, "err", err)
}
return balance return balance
} }
@@ -242,8 +250,10 @@ func (p *EuroPlugin) handleBalance(ctx MessageContext) error {
d := db.Get() d := db.Get()
var balance float64 var balance float64
_ = d.QueryRow("SELECT balance FROM euro_balances WHERE user_id = ?", if err := d.QueryRow("SELECT balance FROM euro_balances WHERE user_id = ?",
string(ctx.Sender)).Scan(&balance) string(ctx.Sender)).Scan(&balance); err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch balance.")
}
debtLimit := envFloat("BLACKJACK_DEBT_LIMIT", 1000) debtLimit := envFloat("BLACKJACK_DEBT_LIMIT", 1000)

View File

@@ -13,7 +13,7 @@ type FlipPlugin struct {
} }
func NewFlipPlugin(client *mautrix.Client) *FlipPlugin { func NewFlipPlugin(client *mautrix.Client) *FlipPlugin {
return &FlipPlugin{Base: Base{Client: client}} return &FlipPlugin{Base: NewBase(client)}
} }
func (p *FlipPlugin) Name() string { return "flip" } func (p *FlipPlugin) Name() string { return "flip" }

View File

@@ -111,8 +111,9 @@ func getTier(phrase string) hangmanTier {
type hangmanGame struct { type hangmanGame struct {
phrase string phrase string
runes []rune // phrase as rune slice for safe indexing
tier hangmanTier tier hangmanTier
revealed []bool // true for each char that is revealed revealed []bool // true for each rune that is revealed
wrongGuesses []rune wrongGuesses []rune
maxWrong int maxWrong int
participants map[id.UserID]bool participants map[id.UserID]bool
@@ -123,15 +124,17 @@ type hangmanGame struct {
} }
func newHangmanGame(phrase string, maxWrong int) *hangmanGame { func newHangmanGame(phrase string, maxWrong int) *hangmanGame {
revealed := make([]bool, len(phrase)) runes := []rune(phrase)
revealed := make([]bool, len(runes))
// Auto-reveal spaces and punctuation // Auto-reveal spaces and punctuation
for i, ch := range phrase { for i, ch := range runes {
if !unicode.IsLetter(ch) && !unicode.IsDigit(ch) { if !unicode.IsLetter(ch) && !unicode.IsDigit(ch) {
revealed[i] = true revealed[i] = true
} }
} }
return &hangmanGame{ return &hangmanGame{
phrase: phrase, phrase: phrase,
runes: runes,
tier: getTier(phrase), tier: getTier(phrase),
revealed: revealed, revealed: revealed,
maxWrong: maxWrong, maxWrong: maxWrong,
@@ -162,25 +165,18 @@ func (g *hangmanGame) isFullyRevealed() bool {
} }
func (g *hangmanGame) revealedCount() int { func (g *hangmanGame) revealedCount() int {
letterTotal := 0 count := 0
letterRevealed := 0 for i, ch := range g.runes {
for i, ch := range g.phrase { if (unicode.IsLetter(ch) || unicode.IsDigit(ch)) && g.revealed[i] {
if unicode.IsLetter(ch) || unicode.IsDigit(ch) { count++
letterTotal++
if g.revealed[i] {
letterRevealed++
} }
} }
} return count
if letterTotal == 0 {
return 0
}
return letterRevealed
} }
func (g *hangmanGame) letterCount() int { func (g *hangmanGame) letterCount() int {
count := 0 count := 0
for _, ch := range g.phrase { for _, ch := range g.runes {
if unicode.IsLetter(ch) || unicode.IsDigit(ch) { if unicode.IsLetter(ch) || unicode.IsDigit(ch) {
count++ count++
} }
@@ -190,15 +186,15 @@ func (g *hangmanGame) letterCount() int {
func (g *hangmanGame) displayPhrase() string { func (g *hangmanGame) displayPhrase() string {
var sb strings.Builder var sb strings.Builder
for i, ch := range g.phrase { for i, ch := range g.runes {
if g.revealed[i] { if g.revealed[i] {
sb.WriteRune(ch) sb.WriteRune(ch)
} else { } else {
sb.WriteRune('_') sb.WriteRune('_')
} }
// Add space between characters for readability // Add space between characters for readability
if i < len(g.phrase)-1 { if i < len(g.runes)-1 {
next := rune(g.phrase[i+1]) next := g.runes[i+1]
if !g.revealed[i] || !g.revealed[i+1] || unicode.IsLetter(ch) || unicode.IsLetter(next) { if !g.revealed[i] || !g.revealed[i+1] || unicode.IsLetter(ch) || unicode.IsLetter(next) {
if ch != ' ' && next != ' ' { if ch != ' ' && next != ' ' {
sb.WriteRune(' ') sb.WriteRune(' ')
@@ -221,12 +217,12 @@ func (g *hangmanGame) guessLetter(ch rune) (hit bool, alreadyGuessed bool) {
} }
hit = false hit = false
for i, c := range g.phrase { for i, c := range g.runes {
if unicode.ToUpper(c) == ch || unicode.ToLower(c) == lower { if unicode.ToUpper(c) == ch || unicode.ToLower(c) == lower {
if g.revealed[i] { if g.revealed[i] {
// Already revealed — check if ALL instances are revealed // Already revealed — check if ALL instances are revealed
allRevealed := true allRevealed := true
for j, cc := range g.phrase { for j, cc := range g.runes {
if (unicode.ToUpper(cc) == ch || unicode.ToLower(cc) == lower) && !g.revealed[j] { if (unicode.ToUpper(cc) == ch || unicode.ToLower(cc) == lower) && !g.revealed[j] {
allRevealed = false allRevealed = false
} }
@@ -242,16 +238,15 @@ func (g *hangmanGame) guessLetter(ch rune) (hit bool, alreadyGuessed bool) {
// Reveal adjacent punctuation when a letter is revealed // Reveal adjacent punctuation when a letter is revealed
if hit { if hit {
for i, ch := range g.phrase { for i := range g.runes {
if g.revealed[i] { if g.revealed[i] {
// Reveal adjacent non-letter chars // Reveal adjacent non-letter chars
if i > 0 && !unicode.IsLetter(rune(g.phrase[i-1])) && !unicode.IsDigit(rune(g.phrase[i-1])) { if i > 0 && !unicode.IsLetter(g.runes[i-1]) && !unicode.IsDigit(g.runes[i-1]) {
g.revealed[i-1] = true g.revealed[i-1] = true
} }
if i < len(g.phrase)-1 && !unicode.IsLetter(rune(g.phrase[i+1])) && !unicode.IsDigit(rune(g.phrase[i+1])) { if i < len(g.runes)-1 && !unicode.IsLetter(g.runes[i+1]) && !unicode.IsDigit(g.runes[i+1]) {
g.revealed[i+1] = true g.revealed[i+1] = true
} }
_ = ch
} }
} }
} }
@@ -294,7 +289,7 @@ type HangmanPlugin struct {
func NewHangmanPlugin(client *mautrix.Client, euro *EuroPlugin) *HangmanPlugin { func NewHangmanPlugin(client *mautrix.Client, euro *EuroPlugin) *HangmanPlugin {
return &HangmanPlugin{ return &HangmanPlugin{
Base: Base{Client: client}, Base: NewBase(client),
euro: euro, euro: euro,
maxWrong: envInt("HANGMAN_MAX_WRONG_GUESSES", 6), maxWrong: envInt("HANGMAN_MAX_WRONG_GUESSES", 6),
bonusMul: envFloat("HANGMAN_SOLUTION_BONUS_MULTIPLIER", 2), bonusMul: envFloat("HANGMAN_SOLUTION_BONUS_MULTIPLIER", 2),
@@ -306,7 +301,7 @@ func (p *HangmanPlugin) Name() string { return "hangman" }
func (p *HangmanPlugin) Commands() []CommandDef { func (p *HangmanPlugin) Commands() []CommandDef {
return []CommandDef{ return []CommandDef{
{Name: "hangman", Description: "Collaborative Hangman game", Usage: "!hangman start | !hangman [letter/phrase] | !hangman submit [phrase]", Category: "Games"}, {Name: "hangman", Description: "Collaborative Hangman game", Usage: "!hangman start [easy|medium|hard|extreme] | !hangman [letter/phrase] | !hangman submit [phrase]", Category: "Games"},
{Name: "hangboard", Description: "Hangman leaderboard", Usage: "!hangboard", Category: "Games"}, {Name: "hangboard", Description: "Hangman leaderboard", Usage: "!hangboard", Category: "Games"},
} }
} }
@@ -336,12 +331,15 @@ func (p *HangmanPlugin) OnMessage(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "hangman")) args := strings.TrimSpace(p.GetArgs(ctx.Body, "hangman"))
lower := strings.ToLower(args)
switch { switch {
case args == "" || strings.EqualFold(args, "start"): case args == "" || lower == "start":
return p.handleStart(ctx) return p.handleStart(ctx, "")
case strings.HasPrefix(strings.ToLower(args), "submit "): 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:])) return p.handleSubmit(ctx, strings.TrimSpace(args[7:]))
case strings.EqualFold(args, "skip"): case lower == "skip":
return p.handleSkip(ctx) return p.handleSkip(ctx)
default: default:
return p.handleGuess(ctx, args) return p.handleGuess(ctx, args)
@@ -404,7 +402,7 @@ func (p *HangmanPlugin) addPhrase(phrase string) error {
// Game commands // Game commands
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func (p *HangmanPlugin) handleStart(ctx MessageContext) error { func (p *HangmanPlugin) handleStart(ctx MessageContext, difficulty string) error {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
@@ -416,7 +414,38 @@ func (p *HangmanPlugin) handleStart(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, "No phrases loaded. Ask an admin to set up HANGMAN_PHRASE_FILE.") return p.SendReply(ctx.RoomID, ctx.EventID, "No phrases loaded. Ask an admin to set up HANGMAN_PHRASE_FILE.")
} }
phrase := p.phrases[rand.IntN(len(p.phrases))] // 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 {
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 {
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) game := newHangmanGame(phrase, p.maxWrong)
p.games[ctx.RoomID] = game p.games[ctx.RoomID] = game
@@ -433,15 +462,26 @@ func (p *HangmanPlugin) handleGuess(ctx MessageContext, guess string) error {
p.mu.Unlock() p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "No Hangman game in progress. Start one with `!hangman start`") return p.SendReply(ctx.RoomID, ctx.EventID, "No Hangman game in progress. Start one with `!hangman start`")
} }
p.mu.Unlock()
// Register participant // Register participant (under lock)
game.participants[ctx.Sender] = true game.participants[ctx.Sender] = true
// DM verification on first guess // DM verification on first guess — need to unlock for network call
needDM := false
if _, checked := game.dmVerified[ctx.Sender]; !checked { if _, checked := game.dmVerified[ctx.Sender]; !checked {
err := p.SendDM(ctx.Sender, fmt.Sprintf("🎮 You've joined the Hangman game!\nPhrase: %s", game.displayPhrase())) needDM = true
}
displayForDM := ""
if needDM {
displayForDM = game.displayPhrase()
}
p.mu.Unlock()
if needDM {
err := p.SendDM(ctx.Sender, fmt.Sprintf("🎮 You've joined the Hangman game!\nPhrase: %s", displayForDM))
p.mu.Lock()
game.dmVerified[ctx.Sender] = err == nil game.dmVerified[ctx.Sender] = err == nil
p.mu.Unlock()
} }
guess = strings.TrimSpace(guess) guess = strings.TrimSpace(guess)
@@ -460,69 +500,98 @@ func (p *HangmanPlugin) handleGuess(ctx MessageContext, guess string) error {
} }
func (p *HangmanPlugin) processLetterGuess(ctx MessageContext, game *hangmanGame, ch rune) error { 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) hit, already := game.guessLetter(ch)
if already { if already {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("'%c' was already guessed.", unicode.ToUpper(ch))) fmt.Sprintf("'%c' was already guessed.", unicode.ToUpper(ch)))
} }
// DM the guesser // Snapshot state for messages before unlocking
if game.dmVerified[ctx.Sender] { shouldDM := game.dmVerified[ctx.Sender]
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()
// DM the guesser (outside lock)
if shouldDM {
result := "❌" result := "❌"
if hit { if hit {
result = "✅" result = "✅"
} }
_ = p.SendDM(ctx.Sender, fmt.Sprintf( _ = p.SendDM(ctx.Sender, fmt.Sprintf(
"🎮 Hangman update\nPhrase: %s\nYour guess: %c %s\nWrong guesses so far: %s\nGuesses remaining: %d", "🎮 Hangman update\nPhrase: %s\nYour guess: %c %s\nWrong guesses so far: %s\nGuesses remaining: %d",
game.displayPhrase(), unicode.ToUpper(ch), result, game.wrongGuessStr(), game.remaining(), display, unicode.ToUpper(ch), result, wrongStr, remaining,
)) ))
} }
if hit { if hit {
if game.isFullyRevealed() { if fullyRevealed {
game.solved = true
game.solvedBy = ctx.Sender
return p.endGame(ctx.RoomID, game) return p.endGame(ctx.RoomID, game)
} }
return p.SendMessage(ctx.RoomID, fmt.Sprintf( return p.SendMessage(ctx.RoomID, fmt.Sprintf(
"✅ '%c' is in the phrase! (%d guesses remaining)\n%s\n\nWrong guesses: %s", "✅ '%c' is in the phrase! (%d guesses remaining)\n%s\n\nWrong guesses: %s",
unicode.ToUpper(ch), game.remaining(), game.displayPhrase(), game.wrongGuessStr(), unicode.ToUpper(ch), remaining, display, wrongStr,
)) ))
} }
// Wrong guess // Wrong guess
if game.isDead() { if dead {
return p.endGame(ctx.RoomID, game) return p.endGame(ctx.RoomID, game)
} }
return p.SendMessage(ctx.RoomID, fmt.Sprintf( return p.SendMessage(ctx.RoomID, fmt.Sprintf(
"❌ Wrong! (%d guesses remaining)\n```\n%s\n```\n%s\n\nWrong guesses: %s", "❌ Wrong! (%d guesses remaining)\n```\n%s\n```\n%s\n\nWrong guesses: %s",
game.remaining(), gallows[game.wrongCount()], game.displayPhrase(), game.wrongGuessStr(), remaining, gallows[wrongCount], display, wrongStr,
)) ))
} }
func (p *HangmanPlugin) processSolutionGuess(ctx MessageContext, game *hangmanGame, guess string) error { func (p *HangmanPlugin) processSolutionGuess(ctx MessageContext, game *hangmanGame, guess string) error {
p.mu.Lock()
if game.guessSolution(guess) { if game.guessSolution(guess) {
game.solved = true game.solved = true
game.solvedBy = ctx.Sender game.solvedBy = ctx.Sender
// Check for early solve (less than half letters revealed)
if game.revealedCount() < game.letterCount()/2 { if game.revealedCount() < game.letterCount()/2 {
game.earlySolve = true game.earlySolve = true
} }
p.mu.Unlock()
return p.endGame(ctx.RoomID, game) return p.endGame(ctx.RoomID, game)
} }
// Wrong solution — costs a life // Wrong solution — costs a life
game.wrongGuesses = append(game.wrongGuesses, '?') // placeholder for wrong solution game.wrongGuesses = append(game.wrongGuesses, '?')
dead := game.isDead()
remaining := game.remaining()
wrongCount := game.wrongCount()
display := game.displayPhrase()
wrongStr := game.wrongGuessStr()
p.mu.Unlock()
if game.isDead() { if dead {
return p.endGame(ctx.RoomID, game) return p.endGame(ctx.RoomID, game)
} }
return p.SendMessage(ctx.RoomID, fmt.Sprintf( return p.SendMessage(ctx.RoomID, fmt.Sprintf(
"❌ Wrong solution! (%d guesses remaining)\n```\n%s\n```\n%s\n\nWrong guesses: %s", "❌ Wrong solution! (%d guesses remaining)\n```\n%s\n```\n%s\n\nWrong guesses: %s",
game.remaining(), gallows[game.wrongCount()], game.displayPhrase(), game.wrongGuessStr(), remaining, gallows[wrongCount], display, wrongStr,
)) ))
} }

View File

@@ -459,7 +459,7 @@ func NewModerationPlugin(client *mautrix.Client) *ModerationPlugin {
} }
return &ModerationPlugin{ return &ModerationPlugin{
Base: Base{Client: client}, Base: NewBase(client),
cfg: cfg, cfg: cfg,
enabled: enabled, enabled: enabled,
wl: newWordList(cfg.WordListPath, cfg.WordListVariations), wl: newWordList(cfg.WordListPath, cfg.WordListVariations),