Hold'em: route sender-private replies to DM origin + jitter broadcasts

Two changes that were running in prod but hadn't been committed:

1. Sender-private DM routing. When a player invokes a holdem command from
   their DM, the dispatcher rewrites ctx.RoomID to the game room so
   actions affect the right table. But sender-private replies (errors,
   help, status, "unknown command") would then post to the public games
   room instead of back to the DM that asked. Now: the dispatcher saves
   ctx.OriginRoomID = the DM, and a new reply() helper routes sender-
   private messages to OriginRoomID when set, falling back to in-thread
   reply otherwise. (OriginRoomID was added to MessageContext earlier.)

2. broadcastDM jitter. 150-400ms sleep between sends to avoid Matrix
   rate-limit bursts when a game has 4+ players each in their own DM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-28 22:37:19 -07:00
parent 4826b5e466
commit e22d4f8362

View File

@@ -3,6 +3,7 @@ package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strconv"
"strings"
"sync"
@@ -98,12 +99,14 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
}
// If this is a DM, resolve the player's game room so actions route correctly.
// Clear EventID so replies go as plain messages — the room can't resolve DM events.
// Save the original DM room into OriginRoomID so sender-private replies (errors,
// help, status) route back via DM rather than broadcasting to the games room.
if isDM {
p.mu.Lock()
gameRoom := p.findGameRoom(ctx.Sender)
p.mu.Unlock()
if gameRoom != "" {
ctx.OriginRoomID = ctx.RoomID
ctx.RoomID = gameRoom
ctx.EventID = ""
}
@@ -113,7 +116,7 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
if !isDM && !isGamesRoom(ctx.RoomID) {
gr := gamesRoom()
if gr != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!")
return p.reply(ctx, "Games are only available in the games channel!")
}
}
@@ -139,14 +142,23 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
case args == "status":
return p.handleStatus(ctx)
case args == "help":
return p.SendReply(ctx.RoomID, ctx.EventID, renderHelpMessage())
return p.reply(ctx, renderHelpMessage())
case args == "addbot":
return p.handleAddBot(ctx)
default:
return p.SendReply(ctx.RoomID, ctx.EventID, "Unknown command. Try `!holdem help`.")
return p.reply(ctx, "Unknown command. Try `!holdem help`.")
}
}
// reply routes sender-private responses to the originating DM when the command
// came from a DM (ctx.OriginRoomID set), otherwise replies in-thread.
func (p *HoldemPlugin) reply(ctx MessageContext, msg string) error {
if ctx.OriginRoomID != "" {
return p.SendMessage(ctx.OriginRoomID, msg)
}
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
func (p *HoldemPlugin) isDMRoom(roomID id.RoomID) bool {
// DM rooms typically have exactly 2 members.
members := p.RoomMembers(roomID)
@@ -166,11 +178,11 @@ func (p *HoldemPlugin) findGameRoom(userID id.UserID) id.RoomID {
func (p *HoldemPlugin) handleTipsToggle(ctx MessageContext, args string, isDM bool) error {
if !isDM {
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips can only be toggled in DM. Send `!holdem tips on/off` directly to me.")
return p.reply(ctx, "Tips can only be toggled in DM. Send `!holdem tips on/off` directly to me.")
}
if p.IsAdmin(ctx.Sender) {
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips are always on for admins. 🐝")
return p.reply(ctx, "Tips are always on for admins. 🐝")
}
parts := strings.Fields(args)
@@ -180,18 +192,18 @@ func (p *HoldemPlugin) handleTipsToggle(ctx MessageContext, args string, isDM bo
if !pref {
status = "off"
}
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Tips are currently **%s**. Use `!holdem tips on/off` to change.", status))
return p.reply(ctx, fmt.Sprintf("Tips are currently **%s**. Use `!holdem tips on/off` to change.", status))
}
switch parts[1] {
case "on":
saveTipsPref(ctx.Sender, true)
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips enabled. You'll receive coaching on your turn.")
return p.reply(ctx, "Tips enabled. You'll receive coaching on your turn.")
case "off":
saveTipsPref(ctx.Sender, false)
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips disabled.")
return p.reply(ctx, "Tips disabled.")
default:
return p.SendReply(ctx.RoomID, ctx.EventID, "Use `!holdem tips on` or `!holdem tips off`.")
return p.reply(ctx, "Use `!holdem tips on` or `!holdem tips off`.")
}
}
@@ -249,25 +261,25 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext, amountStr string) error {
// Check if already seated.
if game.playerByUserID(ctx.Sender) != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "You're already at the table.")
return p.reply(ctx, "You're already at the table.")
}
// Max 9 players.
if len(game.Players) >= 9 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Table is full (9 players max).")
return p.reply(ctx, "Table is full (9 players max).")
}
// Check balance.
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(p.cfg.MinBuyin) {
return p.SendReply(ctx.RoomID, ctx.EventID,
return p.reply(ctx,
fmt.Sprintf("Minimum buy-in is €%d. Your balance: €%.0f.", p.cfg.MinBuyin, balance))
}
// Resolve DM room.
dmRoom, err := p.GetDMRoom(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't open a DM with you. Check your privacy settings.")
return p.reply(ctx, "Couldn't open a DM with you. Check your privacy settings.")
}
// Get display name.
@@ -280,19 +292,19 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext, amountStr string) error {
amountStr = strings.TrimPrefix(amountStr, "€")
requested, err := strconv.ParseInt(amountStr, 10, 64)
if err != nil || requested <= 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
return p.reply(ctx,
fmt.Sprintf("Usage: `!holdem join [amount]` — amount between €%d and €%d.", p.cfg.MinBuyin, p.cfg.MaxBuyin))
}
if requested < p.cfg.MinBuyin {
return p.SendReply(ctx.RoomID, ctx.EventID,
return p.reply(ctx,
fmt.Sprintf("Minimum buy-in is €%d.", p.cfg.MinBuyin))
}
if requested > p.cfg.MaxBuyin {
return p.SendReply(ctx.RoomID, ctx.EventID,
return p.reply(ctx,
fmt.Sprintf("Maximum buy-in is €%d.", p.cfg.MaxBuyin))
}
if requested > int64(balance) {
return p.SendReply(ctx.RoomID, ctx.EventID,
return p.reply(ctx,
fmt.Sprintf("You only have €%.0f.", balance))
}
buyin = requested
@@ -305,7 +317,7 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext, amountStr string) error {
// Debit buy-in immediately to prevent double-spending across tables.
if !p.euro.Debit(ctx.Sender, float64(buyin), "holdem_buyin") {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reserve buy-in.")
return p.reply(ctx, "Failed to reserve buy-in.")
}
state := PlayerActive
@@ -343,12 +355,12 @@ func (p *HoldemPlugin) handleLeave(ctx MessageContext) error {
game := p.games[ctx.RoomID]
if game == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "No active game here.")
return p.reply(ctx, "No active game here.")
}
player := game.playerByUserID(ctx.Sender)
if player == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at the table.")
return p.reply(ctx, "You're not at the table.")
}
if !game.HandInProgress || player.SittingOut {
@@ -401,11 +413,11 @@ func (p *HoldemPlugin) handleStart(ctx MessageContext) error {
game := p.games[ctx.RoomID]
if game == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "No players seated. Use `!holdem join` first.")
return p.reply(ctx, "No players seated. Use `!holdem join` first.")
}
if game.HandInProgress {
return p.SendReply(ctx.RoomID, ctx.EventID, "A hand is already in progress.")
return p.reply(ctx, "A hand is already in progress.")
}
// Count non-sitting-out players.
@@ -421,7 +433,7 @@ func (p *HoldemPlugin) handleStart(ctx MessageContext) error {
if ready == 1 {
hint = " Use `!holdem addbot` to add an AI opponent."
}
return p.SendReply(ctx.RoomID, ctx.EventID, "Need at least 2 players to start."+hint)
return p.reply(ctx, "Need at least 2 players to start."+hint)
}
p.startHand(game)
@@ -431,13 +443,13 @@ func (p *HoldemPlugin) handleStart(ctx MessageContext) error {
func (p *HoldemPlugin) handleRaise(ctx MessageContext, args string) error {
parts := strings.Fields(args)
if len(parts) < 2 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!holdem raise <amount>`")
return p.reply(ctx, "Usage: `!holdem raise <amount>`")
}
amtStr := strings.TrimPrefix(parts[1], "€")
amount, err := strconv.ParseInt(amtStr, 10, 64)
if err != nil || amount <= 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Invalid amount. Usage: `!holdem raise <amount>`")
return p.reply(ctx, "Invalid amount. Usage: `!holdem raise <amount>`")
}
return p.handleAction(ctx, "raise", amount)
@@ -449,21 +461,21 @@ func (p *HoldemPlugin) handleAction(ctx MessageContext, action string, amount in
game := p.games[ctx.RoomID]
if game == nil || !game.HandInProgress {
return p.SendReply(ctx.RoomID, ctx.EventID, "No hand in progress.")
return p.reply(ctx, "No hand in progress.")
}
seatIdx := game.playerIdx(ctx.Sender)
if seatIdx < 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at this table.")
return p.reply(ctx, "You're not at this table.")
}
if seatIdx != game.ActionIdx {
return p.SendReply(ctx.RoomID, ctx.EventID, "It's not your turn.")
return p.reply(ctx, "It's not your turn.")
}
player := game.Players[seatIdx]
if player.State != PlayerActive {
return p.SendReply(ctx.RoomID, ctx.EventID, "You can't act right now.")
return p.reply(ctx, "You can't act right now.")
}
var result ActionResult
@@ -483,7 +495,7 @@ func (p *HoldemPlugin) handleAction(ctx MessageContext, action string, amount in
}
if errMsg != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, errMsg)
return p.reply(ctx, errMsg)
}
// Reset action timer.
@@ -520,12 +532,12 @@ func (p *HoldemPlugin) handleStatus(ctx MessageContext) error {
game := p.games[ctx.RoomID]
if game == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "No active game here.")
return p.reply(ctx, "No active game here.")
}
seatIdx := game.playerIdx(ctx.Sender)
if seatIdx < 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at this table.")
return p.reply(ctx, "You're not at this table.")
}
player := game.Players[seatIdx]
@@ -556,12 +568,12 @@ func (p *HoldemPlugin) handleAddBot(ctx MessageContext) error {
// Check if NPC already exists.
for _, pl := range game.Players {
if pl.IsNPC {
return p.SendReply(ctx.RoomID, ctx.EventID, "An AI opponent is already at the table.")
return p.reply(ctx, "An AI opponent is already at the table.")
}
}
if len(game.Players) >= 9 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Table is full.")
return p.reply(ctx, "Table is full.")
}
// Get NPC house balance.
@@ -827,9 +839,10 @@ func (p *HoldemPlugin) doShowdown(game *HoldemGame) {
}
}
// Post end announcement to room.
// Post end announcement to room and DM each player.
endAnn := renderEndAnnouncement(results, game)
p.SendMessage(game.RoomID, endAnn)
p.broadcastDM(game, endAnn)
// Settle balances.
settleNetDeltas(game, p.euro)
@@ -862,6 +875,7 @@ func (p *HoldemPlugin) finishHand(game *HoldemGame) {
ann, winnerID := awardPotToLastPlayer(game)
if ann != "" {
p.SendMessage(game.RoomID, ann)
p.broadcastDM(game, ann)
}
// Track bot defeats (if NPC won by everyone folding).
@@ -967,6 +981,7 @@ func (p *HoldemPlugin) sendTurnNotifications(game *HoldemGame) {
}
func (p *HoldemPlugin) broadcastDM(game *HoldemGame, msg string) {
first := true
for _, pl := range game.Players {
if pl.IsNPC || pl.State == PlayerSatOut {
continue
@@ -977,6 +992,11 @@ func (p *HoldemPlugin) broadcastDM(game *HoldemGame, msg string) {
if pl.DMRoomID == game.RoomID {
continue
}
if !first {
// Jitter 150400ms between sends to avoid bursts on the Matrix server.
time.Sleep(150*time.Millisecond + time.Duration(rand.IntN(250))*time.Millisecond)
}
first = false
p.SendMessage(pl.DMRoomID, msg)
}
}