mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user