Add multiplayer UNO, room sentiment tracking, and audit fixes

- Multiplayer UNO: lobby system (2-4 humans + bot), DM-based turns,
  30s auto-play timer, winner-takes-all pot, forfeit handling
- Solo UNO: refactor bot AI to shared standalone functions, support
  starting games from DM, passive UNO call system
- Room sentiment: new room_sentiment_stats table and !roomsentiment
  command showing per-room sentiment breakdown with percentages
- Stop purging llm_classifications (retain indefinitely for analytics)
- Fix multiplayer UNO audit issues: dead code in initMultiGame,
  double game.turns++ on human plays, missing !uno status command
- Update README with UNO commands, config, and architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-13 10:26:50 -07:00
parent 7c9dd28021
commit e0a201ef97
6 changed files with 1891 additions and 63 deletions

View File

@@ -107,6 +107,7 @@ func (p *LLMPassivePlugin) Commands() []CommandDef {
{Name: "insults", Description: "Show insult stats for a user", Usage: "!insults [@user]", Category: "LLM & Sentiment"},
{Name: "insultboard", Description: "Top 10 most insulted users", Usage: "!insultboard", Category: "LLM & Sentiment"},
{Name: "sentiment", Description: "Show sentiment stats for a user", Usage: "!sentiment [@user]", Category: "LLM & Sentiment"},
{Name: "roomsentiment", Description: "Show sentiment breakdown for this room", Usage: "!roomsentiment", Category: "LLM & Sentiment"},
}
}
@@ -137,6 +138,9 @@ func (p *LLMPassivePlugin) OnMessage(ctx MessageContext) error {
if p.IsCommand(ctx.Body, "insultboard") {
return p.handleInsultboard(ctx)
}
if p.IsCommand(ctx.Body, "roomsentiment") {
return p.handleRoomSentiment(ctx)
}
if p.IsCommand(ctx.Body, "sentiment") {
return p.handleSentiment(ctx)
}
@@ -364,6 +368,15 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
string(item.UserID), result.SentimentScore, result.SentimentScore,
)
// Aggregate room sentiment stats
_, _ = d.Exec(
fmt.Sprintf(
`INSERT INTO room_sentiment_stats (room_id, %s, total_score) VALUES (?, 1, ?)
ON CONFLICT(room_id) DO UPDATE SET %s = %s + 1, total_score = total_score + ?`,
sentimentCol, sentimentCol, sentimentCol),
string(item.RoomID), result.SentimentScore, result.SentimentScore,
)
// Track profanity with severity
if result.Profanity {
severity := result.ProfanitySeverity
@@ -781,3 +794,68 @@ func (p *LLMPassivePlugin) handleSentiment(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
func (p *LLMPassivePlugin) handleRoomSentiment(ctx MessageContext) error {
d := db.Get()
var positive, negative, neutral, excited, sarcastic, frustrated, curious, grateful, humorous, supportive int
var totalScore float64
err := d.QueryRow(
`SELECT COALESCE(positive, 0), COALESCE(negative, 0), COALESCE(neutral, 0),
COALESCE(excited, 0), COALESCE(sarcastic, 0), COALESCE(frustrated, 0),
COALESCE(curious, 0), COALESCE(grateful, 0), COALESCE(humorous, 0),
COALESCE(supportive, 0), COALESCE(total_score, 0)
FROM room_sentiment_stats WHERE room_id = ?`,
string(ctx.RoomID),
).Scan(&positive, &negative, &neutral, &excited, &sarcastic, &frustrated,
&curious, &grateful, &humorous, &supportive, &totalScore)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "No sentiment data for this room yet.")
}
total := positive + negative + neutral + excited + sarcastic + frustrated + curious + grateful + humorous + supportive
avgScore := 0.0
if total > 0 {
avgScore = totalScore / float64(total)
}
mood := "neutral"
if avgScore > 0.3 {
mood = "mostly positive"
} else if avgScore > 0.1 {
mood = "leaning positive"
} else if avgScore < -0.3 {
mood = "mostly negative"
} else if avgScore < -0.1 {
mood = "leaning negative"
}
type sentEntry struct {
emoji string
label string
count int
}
entries := []sentEntry{
{"👍", "Positive", positive},
{"🔥", "Excited", excited},
{"🤗", "Supportive", supportive},
{"💜", "Grateful", grateful},
{"😂", "Humorous", humorous},
{"🧐", "Curious", curious},
{"😐", "Neutral", neutral},
{"🤨", "Sarcastic", sarcastic},
{"😮\u200d💨", "Frustrated", frustrated},
{"👎", "Negative", negative},
}
var sb strings.Builder
sb.WriteString("**Room Sentiment:**\n")
for _, e := range entries {
if e.count > 0 {
pct := float64(e.count) / float64(total) * 100
sb.WriteString(fmt.Sprintf(" %s %s: %s (%.0f%%)\n", e.emoji, e.label, formatNumber(e.count), pct))
}
}
sb.WriteString(fmt.Sprintf("\n%s messages classified | Average mood: %.2f (%s)", formatNumber(total), avgScore, mood))
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}

View File

@@ -334,10 +334,15 @@ type UnoPlugin struct {
euro *EuroPlugin
mu sync.Mutex
games map[id.UserID]*unoGame // one game per player
games map[id.UserID]*unoGame // solo: one game per player
// reverse lookup: DM room -> player
// reverse lookup: DM room -> player (solo)
dmToPlayer map[id.RoomID]id.UserID
// Multiplayer
lobbies map[id.RoomID]*unoMultiLobby // one lobby per room
multiGames map[string]*unoMultiGame // game ID -> active game
dmToMulti map[id.RoomID]string // DM room -> game ID
}
func NewUnoPlugin(client *mautrix.Client, euro *EuroPlugin) *UnoPlugin {
@@ -346,6 +351,9 @@ func NewUnoPlugin(client *mautrix.Client, euro *EuroPlugin) *UnoPlugin {
euro: euro,
games: make(map[id.UserID]*unoGame),
dmToPlayer: make(map[id.RoomID]id.UserID),
lobbies: make(map[id.RoomID]*unoMultiLobby),
multiGames: make(map[string]*unoMultiGame),
dmToMulti: make(map[id.RoomID]string),
}
}
@@ -353,7 +361,7 @@ func (p *UnoPlugin) Name() string { return "uno" }
func (p *UnoPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "uno", Description: "Challenge the bot to Uno", Usage: "!uno €amount", Category: "Games"},
{Name: "uno", Description: "Solo or multiplayer Uno", Usage: "!uno €amount | !uno start €amount | !uno join | !uno go", Category: "Games"},
{Name: "uno_pot", Description: "Show the community pot balance", Usage: "!uno_pot", Category: "Games"},
}
}
@@ -368,30 +376,57 @@ func (p *UnoPlugin) OnMessage(ctx MessageContext) error {
return p.handlePotCheck(ctx)
}
if p.IsCommand(ctx.Body, "uno") {
if !isGamesRoom(ctx.RoomID) {
gr := gamesRoom()
if gr != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Uno is only available in the games channel!")
}
args := strings.TrimSpace(p.GetArgs(ctx.Body, "uno"))
lower := strings.ToLower(args)
// Multiplayer subcommands
switch {
case strings.HasPrefix(lower, "start "):
return p.handleMultiStart(ctx, strings.TrimSpace(args[6:]))
case lower == "join":
return p.handleMultiJoin(ctx)
case lower == "go":
return p.handleMultiGo(ctx)
case lower == "leave":
return p.handleMultiLeave(ctx)
case lower == "cancel":
return p.handleMultiCancel(ctx)
}
return p.handleChallenge(ctx)
// Solo challenge: !uno €amount
if isGamesRoom(ctx.RoomID) {
return p.handleChallenge(ctx, ctx.RoomID)
}
// Allow starting from DM — announce to games room
gr := gamesRoom()
if gr == "" {
return nil
}
return p.handleChallenge(ctx, id.RoomID(gr))
}
// DM gameplay — check if this room is a known DM game room
// DM gameplay — check solo games first, then multiplayer
p.mu.Lock()
playerID, isDM := p.dmToPlayer[ctx.RoomID]
if !isDM || playerID != ctx.Sender {
p.mu.Unlock()
return nil
playerID, isSoloDM := p.dmToPlayer[ctx.RoomID]
if isSoloDM && playerID == ctx.Sender {
game := p.games[playerID]
if game != nil && !game.done {
p.mu.Unlock()
return p.handleDMInput(ctx, game)
}
}
game := p.games[playerID]
if game == nil || game.done {
p.mu.Unlock()
return nil
gameID, isMultiDM := p.dmToMulti[ctx.RoomID]
if isMultiDM {
mg := p.multiGames[gameID]
if mg != nil && !mg.done {
p.mu.Unlock()
return p.handleMultiDMInput(ctx, mg)
}
}
p.mu.Unlock()
return p.handleDMInput(ctx, game)
return nil
}
// ---------------------------------------------------------------------------
@@ -452,7 +487,11 @@ func (p *UnoPlugin) handlePotCheck(ctx MessageContext) error {
// Challenge
// ---------------------------------------------------------------------------
func (p *UnoPlugin) handleChallenge(ctx MessageContext) error {
func (p *UnoPlugin) handleChallenge(ctx MessageContext, announceRoom id.RoomID) error {
reply := func(text string) error {
return p.SendReply(ctx.RoomID, ctx.EventID, text)
}
args := p.GetArgs(ctx.Body, "uno")
amountStr := strings.TrimPrefix(strings.TrimSpace(args), "€")
var amount float64
@@ -460,15 +499,14 @@ func (p *UnoPlugin) handleChallenge(ctx MessageContext) error {
minBet := envFloat("UNO_MIN_BET", 10)
if amount < minBet {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Minimum wager is €%d. Usage: `!uno €amount`", int(minBet)))
return reply(fmt.Sprintf("Minimum wager is €%d. Usage: `!uno €amount`", int(minBet)))
}
// Hold lock for check-and-reserve to prevent TOCTOU double-challenge
p.mu.Lock()
if _, active := p.games[ctx.Sender]; active {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "You already have an Uno game in progress!")
return reply("You already have an Uno game in progress!")
}
// Reserve the slot with a placeholder so concurrent challenges are blocked
p.games[ctx.Sender] = &unoGame{done: true} // placeholder
@@ -479,7 +517,7 @@ func (p *UnoPlugin) handleChallenge(ctx MessageContext) error {
p.mu.Lock()
delete(p.games, ctx.Sender)
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "Insufficient balance for that wager.")
return reply("Insufficient balance for that wager.")
}
// Get DM room
@@ -489,11 +527,11 @@ func (p *UnoPlugin) handleChallenge(ctx MessageContext) error {
p.mu.Lock()
delete(p.games, ctx.Sender)
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't open a DM with you. Make sure you accept DMs from the bot.")
return reply("Couldn't open a DM with you. Make sure you accept DMs from the bot.")
}
// Initialize game
game := p.initGame(ctx.Sender, ctx.RoomID, dmRoom, amount)
game := p.initGame(ctx.Sender, announceRoom, dmRoom, amount)
p.mu.Lock()
p.games[ctx.Sender] = game
@@ -503,7 +541,7 @@ func (p *UnoPlugin) handleChallenge(ctx MessageContext) error {
// Room announcement
playerName := p.unoDisplayName(ctx.Sender)
botName := unoBotName()
p.SendMessage(ctx.RoomID, fmt.Sprintf(
p.SendMessage(announceRoom, fmt.Sprintf(
"🃏 **%s** has challenged %s to Uno! Stakes: €%d\n\n%s\n\n[Check your DMs to play.]",
playerName, botName, int(amount), pickCommentary("start"),
))
@@ -954,13 +992,15 @@ func (p *UnoPlugin) botPlaysCard(game *unoGame, card unoCard) error {
}
// ---------------------------------------------------------------------------
// Bot AI
// Bot AI (shared between solo and multiplayer)
// ---------------------------------------------------------------------------
func (p *UnoPlugin) botChooseCard(game *unoGame) (unoCard, int) {
// botPickCard selects the best card to play from the given hand.
// opponentMinCards is the smallest hand size among opponents.
func botPickCard(hand []unoCard, discardTop unoCard, topColor unoColor, bookDown bool, opponentMinCards int) (unoCard, int) {
var playable []int
for i, c := range game.botHand {
if c.canPlayOn(game.discardTop, game.topColor) {
for i, c := range hand {
if c.canPlayOn(discardTop, topColor) {
playable = append(playable, i)
}
}
@@ -969,17 +1009,17 @@ func (p *UnoPlugin) botChooseCard(game *unoGame) (unoCard, int) {
return unoCard{}, -1
}
if game.bookDown {
return p.botChooseAggressive(game, playable)
if bookDown {
return botPickAggressive(hand, playable)
}
return p.botChooseNormal(game, playable)
return botPickNormal(hand, topColor, playable, opponentMinCards)
}
func (p *UnoPlugin) botChooseNormal(game *unoGame, playable []int) (unoCard, int) {
func botPickNormal(hand []unoCard, topColor unoColor, playable []int, opponentMinCards int) (unoCard, int) {
var actions, numbers, wd4s []int
for _, i := range playable {
c := game.botHand[i]
c := hand[i]
switch {
case c.Value == unoWildDrawFour:
wd4s = append(wd4s, i)
@@ -990,47 +1030,46 @@ func (p *UnoPlugin) botChooseNormal(game *unoGame, playable []int) (unoCard, int
}
}
// Save WD4 unless player has 2-3 cards
if len(game.playerHand) > 3 {
// Save WD4 unless opponent is close to winning
if opponentMinCards > 3 {
if len(actions) > 0 {
for _, i := range actions {
if game.botHand[i].Color == game.topColor {
return game.botHand[i], i
if hand[i].Color == topColor {
return hand[i], i
}
}
idx := actions[0]
return game.botHand[idx], idx
return hand[idx], idx
}
if len(numbers) > 0 {
for _, i := range numbers {
if game.botHand[i].Color == game.topColor {
return game.botHand[i], i
if hand[i].Color == topColor {
return hand[i], i
}
}
idx := numbers[0]
return game.botHand[idx], idx
return hand[idx], idx
}
}
// Player close to winning or no other choice
// Opponent close to winning or no other choice
if len(wd4s) > 0 {
idx := wd4s[0]
return game.botHand[idx], idx
return hand[idx], idx
}
if len(actions) > 0 {
idx := actions[0]
return game.botHand[idx], idx
return hand[idx], idx
}
// Must have numbers (playable is non-empty and every card goes into exactly one bucket)
idx := numbers[0]
return game.botHand[idx], idx
return hand[idx], idx
}
func (p *UnoPlugin) botChooseAggressive(game *unoGame, playable []int) (unoCard, int) {
func botPickAggressive(hand []unoCard, playable []int) (unoCard, int) {
var wd4s, actions, numbers []int
for _, i := range playable {
c := game.botHand[i]
c := hand[i]
switch {
case c.Value == unoWildDrawFour:
wd4s = append(wd4s, i)
@@ -1043,19 +1082,19 @@ func (p *UnoPlugin) botChooseAggressive(game *unoGame, playable []int) (unoCard,
if len(wd4s) > 0 {
idx := wd4s[0]
return game.botHand[idx], idx
return hand[idx], idx
}
if len(actions) > 0 {
idx := actions[0]
return game.botHand[idx], idx
return hand[idx], idx
}
idx := numbers[0]
return game.botHand[idx], idx
return hand[idx], idx
}
func (p *UnoPlugin) botChooseColor(game *unoGame) unoColor {
func botPickColor(hand []unoCard) unoColor {
counts := map[unoColor]int{}
for _, c := range game.botHand {
for _, c := range hand {
if c.Color != unoWild {
counts[c.Color]++
}
@@ -1072,6 +1111,15 @@ func (p *UnoPlugin) botChooseColor(game *unoGame) unoColor {
return best
}
// Solo wrappers for backward compatibility
func (p *UnoPlugin) botChooseCard(game *unoGame) (unoCard, int) {
return botPickCard(game.botHand, game.discardTop, game.topColor, game.bookDown, len(game.playerHand))
}
func (p *UnoPlugin) botChooseColor(game *unoGame) unoColor {
return botPickColor(game.botHand)
}
// afterBotTurn is called when the bot's turn was skipped (player played skip/reverse/draw two).
// Shows the hand display for the player's next turn.
func (p *UnoPlugin) afterBotTurn(game *unoGame) error {

1656
internal/plugin/uno_multi.go Normal file

File diff suppressed because it is too large Load Diff