From 06153880e0d9961263529415fe04e5f3c004feeb Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:51:28 -0700 Subject: [PATCH] Add UNO addbot, fix audit issues, add fishing to superstats, update README UNO addbot: - !uno addbot / !uno removebot for lobby bot management (WinBee, GwinBee) - Bots ante from community pot, increasing the total pot - Single human can start a game if bots fill remaining slots - Per-bot display names throughout (replaces global unoBotName) Audit fixes: - Lobby display now always shows both humans and bots (was omitting bots on join) - Slot check accounts for bots (was only counting humans) - Color Roulette bot victim name fixed (was showing acting bot's name) - Lobby display built under lock to prevent stale reads - Bot name list separated from env var to prevent duplicates - Extracted shared buildLobbyDisplay helper - Inlined unnecessary nameOf closure in sudden death Other: - T5 masterwork drop rate reduced from 1.5% to 0.5% - Added fishing stats to !superstatsexplusalpha - Updated README with all new features Co-Authored-By: Claude Opus 4.6 --- README.md | 32 ++- internal/plugin/adventure_masterwork.go | 8 +- internal/plugin/stats.go | 12 +- internal/plugin/uno.go | 6 +- internal/plugin/uno_multi.go | 362 +++++++++++++----------- internal/plugin/uno_nomercy.go | 7 +- 6 files changed, 232 insertions(+), 195 deletions(-) diff --git a/README.md b/README.md index 52261dd..c09b773 100644 --- a/README.md +++ b/README.md @@ -336,7 +336,7 @@ Rep is earned when someone thanks you. The bot detects this automatically. ### UNO (games channel only) -UNO can be played solo (vs GogoBee) or multiplayer (2-4 players + bot). All gameplay happens in DMs. The games channel is used for lobby management and public announcements. +UNO can be played solo (vs GogoBee) or multiplayer (1-4 players + bots). All gameplay happens in DMs. The games channel is used for lobby management and public announcements. | Command | Description | |---------|-------------| @@ -347,7 +347,9 @@ UNO can be played solo (vs GogoBee) or multiplayer (2-4 players + bot). All game | `!uno start nomercy €amount` | Multiplayer No Mercy lobby | | `!uno start nomercy 7-0 €amount` | Multiplayer No Mercy with 7-0 rule | | `!uno join` | Join an open lobby | -| `!uno go` | Start the game (host only, 2+ players required) | +| `!uno addbot` | Add a bot opponent to the lobby (max 2, funded from community pot) | +| `!uno removebot` | Remove the last added bot from the lobby | +| `!uno go` | Start the game (host only, 2+ participants required) | | `!uno leave` | Leave the lobby (refunds ante) | | `!uno cancel` | Cancel the lobby (host/admin, refunds all) | @@ -385,6 +387,14 @@ Enabled by adding `7-0` to the command. When active: - **Play a 7** — swap hands with another player of your choice (in multiplayer, you pick the target) - **Play a 0** — all players pass their hand to the next player in play direction +#### Sudden Death + +When a 2-player game (including bot matchups) exceeds 80 turns, a 20-turn countdown begins. At turn 100, both hands are scored by UNO card point values — lowest total wins. Tiebreaker: fewer cards, then advantage to the non-active player. + +#### Multiplayer Bots + +The default bot (GogoBee) is always present in multiplayer games. Up to 2 additional bots (WinBee, GwinBee) can be added via `!uno addbot` while the lobby is open. Addbot bots ante from the community pot, increasing the total pot. A single human can start a game if bots fill the remaining slots. + ### Texas Hold'em (games channel only) No-limit Texas Hold'em poker for 2-9 players. Buy-in is debited from your euro balance when you sit down; your remaining stack is cashed out when you leave. Private cards and coaching tips are delivered via DM — the room only sees start/end announcements. @@ -464,7 +474,7 @@ No economy integration — stats and leaderboard position are the reward. ### Adventure (DM-based idle RPG) -A daily DM-driven idle RPG where each player takes one action per day — dungeon, mine, forage, visit the shop, or rest. Outcomes resolve with flavor text and loot is credited to your euro balance. An evening summary posts to the games room. TwinBee is a permanent NPC adventurer who distributes rewards to active players. +A daily DM-driven idle RPG where each player takes one action per day — dungeon, mine, fish, forage, visit the shop, or rest. Outcomes resolve with flavor text and loot is credited to your euro balance. An evening summary posts to the games room. TwinBee is a permanent NPC adventurer who distributes rewards to active players. Characters auto-create on first `!adventure` command. All gameplay happens in DMs — reply to the bot's morning prompt with your choice. DM replies are only interpreted as adventure choices for 15 minutes after a menu is sent, so other DM-based games (UNO, Hold'em) won't conflict. @@ -476,6 +486,7 @@ Characters auto-create on first `!adventure` command. All gameplay happens in DM | `!adventure buy ` | Buy equipment by name or tier shorthand (`3 sword`, `t4 boots`) | | `!adventure sell ` | Sell an inventory item (credits euro balance) | | `!adventure sell all` | Sell entire inventory | +| `!adventure equip` | Equip masterwork gear from inventory | | `!adventure inventory` | List current inventory | | `!adventure leaderboard` | Top adventurers | | `!adventure revive @user` | Revive a dead player (admin) | @@ -486,27 +497,29 @@ Characters auto-create on first `!adventure` command. All gameplay happens in DM #### Activities -Three activity types across 5 tiers of locations (15 total). Higher tiers require higher character level and equipment. +Four activity types across 5 tiers of locations. Higher tiers require higher character level and equipment. - **Dungeon** — combat XP, chance of death, best loot potential - **Mining** — mining XP, cave-in risk, ore and gem drops +- **Fishing** — fishing XP, hazard risk, catch drops - **Foraging** — foraging XP, wildlife hazards, herb and reagent drops #### Mechanics - **Equipment** — 5 slots (weapon, armor, helmet, boots, tool) with tiered upgrades from the shop. Equipment degrades on bad outcomes and breaks at 0 condition. +- **Masterwork gear** — rare skill-specific equipment drops from gathering activities (mining, fishing, foraging). 15 items across 5 tiers with decreasing drop rates (5% T1 down to 0.5% T5). Location-gated — drops only at matching tier. Auto-equips if better than current gear; otherwise goes to inventory for manual equipping via `!adventure equip`. Masterwork items provide a +5% skill bonus when the item's source skill matches the current activity. Character sheet marks masterwork items with a star. - **Treasures** — rare collectibles (up to 3) that provide passive bonuses (XP multipliers, death chance reduction, loot quality). Prompted to discard when at cap. - **Streaks** — consecutive days of activity grant escalating bonuses (XP, loot quality, death chance reduction). Resting or dying resets your streak. - **Grudge** — dying at a location marks it as your grudge. Returning there grants +10% success and +25% XP. Clears on success. - **Party bonus** — if two players independently visit the same location on the same day, both get +10% loot value. -- **Death** — locked out until midnight UTC. You're automatically revived when the next day starts. +- **Death** — locked out until midnight UTC. You're automatically revived when the next day starts. Death's Reprieve (surviving a lethal roll) sets all equipment to 1 condition instead of destroying it. - **Holidays** — on recognized holidays (~20/year across religious and cultural calendars), you get a second daily action. Hebrew and Islamic calendar support for floating holidays. - **TwinBee NPC** — takes a daily action (location tier capped by best player's combined level), distributes loot share to active players scaled quadratically by level, and occasionally gifts random buffs. - **Mid-day events** — random events can trigger between actions, delivering bonus loot, buffs, or narrative encounters. #### Arena (`!arena`) -A multi-tier combat gauntlet independent of the daily adventure action. Fight through 5 tiers of 4 rounds each — 20 unique named monsters with escalating lethality. Earnings accumulate across rounds but are forfeited on death. After clearing a tier, choose to descend deeper (keep earnings at risk) or cash out. Death locks you out of both arena and adventure until midnight UTC. +A multi-tier combat gauntlet independent of the daily adventure action. Fight through 5 tiers of 4 rounds each — 20 unique named monsters with escalating lethality. Earnings accumulate across rounds but are forfeited on death. After clearing a tier, choose to descend deeper (keep earnings at risk) or cash out. Death locks you out of both arena and adventure until midnight UTC. Each fight produces a turn-based combat log (Dragon Quest style) with fabricated HP pools and action narration — the outcome is determined by the roll, the log is assembled backward from the result. Arena losses award +60 participation XP. | Command | Description | |---------|-------------| @@ -938,8 +951,9 @@ gogobee/ │ │ ├── hangman.go # Collaborative Hangman │ │ ├── blackjack.go # Multiplayer Blackjack │ │ ├── uno.go # Solo UNO vs bot (DM-based) -│ │ ├── uno_multi.go # Multiplayer UNO (lobby + DM turns) +│ │ ├── uno_multi.go # Multiplayer UNO (lobby + DM turns + addbot) │ │ ├── uno_nomercy.go # No Mercy mode (deck, stacking, mercy rule, 7-0, bot AI) +│ │ ├── uno_test.go # UNO tests (sudden death, scoring) │ │ ├── holdem.go # Texas Hold'em plugin (commands, game lifecycle, NPC) │ │ ├── holdem_game.go # Game state, player types, deck, position labels │ │ ├── holdem_betting.go # Blinds, action validation, side pots @@ -963,10 +977,12 @@ gogobee/ │ │ ├── adventure_events.go # Mid-day random events │ │ ├── adventure_scheduler.go # Morning DM, evening summary, midnight reset tickers │ │ ├── adventure_holidays.go # Holiday detection (Hebrew/Islamic calendar, fixed dates) +│ │ ├── adventure_masterwork.go # Masterwork gear (15 tiered items, drop logic, equip command) │ │ ├── adventure_arena.go # Arena commands, combat resolution, DB CRUD, auto-cashout +│ │ ├── adventure_arena_combat.go # Arena combat log generator (action pools, HP narration) │ │ ├── adventure_arena_monsters.go # 5 tiers, 20 monsters, death messages │ │ ├── adventure_arena_render.go # Arena UI rendering (menus, stats, leaderboard) -│ │ ├── adventure_flavor_*.go # Flavor text pools (dungeon, mining, treasure, twinbee, events, closing) +│ │ ├── adventure_flavor_*.go # Flavor text pools (dungeon, mining, fishing, treasure, twinbee, events, closing) │ │ ├── esteemed.go # Satirical esteemed member posts │ │ ├── moderation.go # Moderation system (strikes, word list, flood detection) │ │ └── ratelimits.go # Rate limiting diff --git a/internal/plugin/adventure_masterwork.go b/internal/plugin/adventure_masterwork.go index 59f6193..d97bf99 100644 --- a/internal/plugin/adventure_masterwork.go +++ b/internal/plugin/adventure_masterwork.go @@ -19,7 +19,7 @@ type MasterworkDef struct { Tier int // 1-5, matches location tier Name string Description string // character sheet / trade listing - DropRate float64 // per-tier: 0.05, 0.04, 0.03, 0.02, 0.015 + DropRate float64 // per-tier: 0.05, 0.04, 0.03, 0.02, 0.005 } var masterworkDefs = []MasterworkDef{ @@ -46,7 +46,7 @@ var masterworkDefs = []MasterworkDef{ }, { Slot: SlotWeapon, Activity: AdvActivityMining, SkillSource: "mining", Tier: 5, - Name: "The Motherload", DropRate: 0.015, + Name: "The Motherload", DropRate: 0.005, Description: "There is no good explanation for why this was in the rock. The rock isn't talking. You're not asking. You've decided to stop thinking about it and just use it.", }, @@ -73,7 +73,7 @@ var masterworkDefs = []MasterworkDef{ }, { Slot: SlotArmor, Activity: AdvActivityFishing, SkillSource: "fishing", Tier: 5, - Name: "The Deepforged Carapace", DropRate: 0.015, + Name: "The Deepforged Carapace", DropRate: 0.005, Description: "This came up on the line from the Abyssal Trench and the line almost didn't hold. It is not from a fish. It is not from anything you have seen or want to see. It fits perfectly. You find this more unsettling than the alternative.", }, @@ -100,7 +100,7 @@ var masterworkDefs = []MasterworkDef{ }, { Slot: SlotBoots, Activity: AdvActivityForaging, SkillSource: "foraging", Tier: 5, - Name: "The Last Step", DropRate: 0.015, + Name: "The Last Step", DropRate: 0.005, Description: "Found at the edge of the Fungal Dark where the light stops. You almost didn't go that far. Something about wearing them suggests you will always go that far, from now on. This is either good news or the other kind.", }, } diff --git a/internal/plugin/stats.go b/internal/plugin/stats.go index 3993de8..243d7aa 100644 --- a/internal/plugin/stats.go +++ b/internal/plugin/stats.go @@ -399,15 +399,15 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error { sb.WriteString("\n") // ── Adventure ── - var combatLv, miningLv, forageLv, combatXP, miningXP, forageXP int + var combatLv, miningLv, fishingLv, forageLv, combatXP, miningXP, fishingXP, forageXP int var alive bool var streak, bestStreak int err = d.QueryRow( - `SELECT combat_level, mining_skill, foraging_skill, - combat_xp, mining_xp, foraging_xp, + `SELECT combat_level, mining_skill, fishing_skill, foraging_skill, + combat_xp, mining_xp, fishing_xp, foraging_xp, alive, current_streak, best_streak FROM adventure_characters WHERE user_id = ?`, uid, - ).Scan(&combatLv, &miningLv, &forageLv, &combatXP, &miningXP, &forageXP, + ).Scan(&combatLv, &miningLv, &fishingLv, &forageLv, &combatXP, &miningXP, &fishingXP, &forageXP, &alive, &streak, &bestStreak) if err == nil { status := "Alive" @@ -415,8 +415,8 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error { status = "💀 Dead" } sb.WriteString(fmt.Sprintf("⚔️ **Adventure:** %s\n", status)) - sb.WriteString(fmt.Sprintf(" Combat Lv.%d (%d XP) · Mining Lv.%d (%d XP) · Forage Lv.%d (%d XP)\n", - combatLv, combatXP, miningLv, miningXP, forageLv, forageXP)) + sb.WriteString(fmt.Sprintf(" Combat Lv.%d (%d XP) · Mining Lv.%d (%d XP) · Fishing Lv.%d (%d XP) · Forage Lv.%d (%d XP)\n", + combatLv, combatXP, miningLv, miningXP, fishingLv, fishingXP, forageLv, forageXP)) if streak > 0 || bestStreak > 0 { sb.WriteString(fmt.Sprintf(" 🔥 Streak: %d days (best: %d)\n", streak, bestStreak)) } diff --git a/internal/plugin/uno.go b/internal/plugin/uno.go index b3111d2..6bd3053 100644 --- a/internal/plugin/uno.go +++ b/internal/plugin/uno.go @@ -415,7 +415,7 @@ func (p *UnoPlugin) Name() string { return "uno" } func (p *UnoPlugin) Commands() []CommandDef { return []CommandDef{ - {Name: "uno", Description: "Solo or multiplayer Uno", Usage: "!uno [nomercy [7-0]] €amount | !uno start [nomercy [7-0]] €amount | !uno join | !uno go", Category: "Games"}, + {Name: "uno", Description: "Solo or multiplayer Uno", Usage: "!uno [nomercy [7-0]] €amount | !uno start [nomercy [7-0]] €amount | !uno join | !uno addbot | !uno removebot | !uno go | !uno leave | !uno cancel", Category: "Games"}, {Name: "uno_pot", Description: "Show the community pot balance", Usage: "!uno_pot", Category: "Games"}, } } @@ -447,6 +447,10 @@ func (p *UnoPlugin) OnMessage(ctx MessageContext) error { return p.handleMultiLeave(ctx) case lower == "cancel": return p.handleMultiCancel(ctx) + case lower == "addbot": + return p.handleMultiAddBot(ctx) + case lower == "removebot": + return p.handleMultiRemoveBot(ctx) } // Solo challenge: !uno [nomercy [7-0]] €amount diff --git a/internal/plugin/uno_multi.go b/internal/plugin/uno_multi.go index 1e163d1..b4a18df 100644 --- a/internal/plugin/uno_multi.go +++ b/internal/plugin/uno_multi.go @@ -29,6 +29,7 @@ const ( type unoMultiPlayer struct { userID id.UserID + displayName string // set at creation; bots use their assigned name dmRoomID id.RoomID hand []unoCard calledUno bool @@ -67,6 +68,9 @@ type unoMultiGame struct { stackTotal int // cumulative draw penalty during stacking stackMinValue int // minimum draw value to stack (0 = not stacking) + // Antes paid by addbot bots (funded from community pot) + botAntes int + // Sudden death (long-game point scoring) suddenDeath bool suddenDeathTurn int @@ -81,16 +85,66 @@ type unoMultiLobby struct { creator id.UserID ante float64 players []id.UserID + botNames []string // bots added via !uno addbot createdAt time.Time timer *time.Timer noMercy bool sevenZeroRule bool } +// unoExtraBotNames are the names available for !uno addbot (in order). +// The default bot name comes from unoBotName() and is always present. +var unoExtraBotNames = []string{"WinBee", "GwinBee"} + +// lobbySlots returns the number of lobby slots taken (humans + addbot bots). +func (l *unoMultiLobby) lobbySlots() int { + return len(l.players) + len(l.botNames) +} + +// buildLobbyDisplay renders the lobby message. Caller should hold p.mu or +// have a stable snapshot of the lobby fields. +func (p *UnoPlugin) buildLobbyDisplay(lobby *unoMultiLobby) string { + var sb strings.Builder + modeTag := "" + if lobby.noMercy { + modeTag = " 🔥 NO MERCY" + if lobby.sevenZeroRule { + modeTag += " (7-0)" + } + } + slots := lobby.lobbySlots() + sb.WriteString(fmt.Sprintf("🃏 **UNO Lobby**%s — Ante: €%d\nPlayers (%d/4):\n", modeTag, int(lobby.ante), slots)) + idx := 1 + for _, uid := range lobby.players { + name := p.DisplayName(uid) + label := "" + if uid == lobby.creator { + label = " (host)" + } + sb.WriteString(fmt.Sprintf(" %d. %s%s\n", idx, name, label)) + idx++ + } + for _, bn := range lobby.botNames { + sb.WriteString(fmt.Sprintf(" %d. 🤖 %s\n", idx, bn)) + idx++ + } + sb.WriteString("\nType `!uno join` to join, `!uno addbot` for a bot, or `!uno go` to start!") + return sb.String() +} + // --------------------------------------------------------------------------- // Game helpers // --------------------------------------------------------------------------- +// multiName returns the display name for a multiplayer UNO player. +// Bots use their assigned displayName; humans use the Matrix display name. +func (p *UnoPlugin) multiName(pl *unoMultiPlayer) string { + if pl.isBot { + return pl.displayName + } + return p.DisplayName(pl.userID) +} + func (g *unoMultiGame) currentPlayer() *unoMultiPlayer { return g.players[g.currentIdx] } @@ -281,19 +335,10 @@ func (p *UnoPlugin) handleMultiStart(ctx MessageContext, amountStr string, noMer }) p.lobbies[ctx.RoomID] = lobby + msg := p.buildLobbyDisplay(lobby) p.mu.Unlock() - creatorName := p.DisplayName(ctx.Sender) - modeTag := "" - if noMercy { - modeTag = " 🔥 NO MERCY" - if sevenZeroRule { - modeTag += " (7-0)" - } - } - return p.SendMessage(ctx.RoomID, fmt.Sprintf( - "🃏 **UNO Lobby**%s — Ante: €%d\nPlayers (1/4):\n 1. %s (host)\n\nType `!uno join` to join or `!uno go` to start!", - modeTag, int(amount), creatorName)) + return p.SendMessage(ctx.RoomID, msg) } func (p *UnoPlugin) handleMultiJoin(ctx MessageContext) error { @@ -316,9 +361,9 @@ func (p *UnoPlugin) handleMultiJoin(ctx MessageContext) error { } } - if len(lobby.players) >= 4 { + if lobby.lobbySlots() >= 4 { p.mu.Unlock() - return p.SendReply(ctx.RoomID, ctx.EventID, "Lobby is full! (4/4 players)") + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Lobby is full! (%d/4)", lobby.lobbySlots())) } // Check player isn't in another game @@ -344,30 +389,10 @@ func (p *UnoPlugin) handleMultiJoin(ctx MessageContext) error { } lobby.players = append(lobby.players, ctx.Sender) - count := len(lobby.players) + msg := p.buildLobbyDisplay(lobby) p.mu.Unlock() - // Build player list - var sb strings.Builder - lobbyModeTag := "" - if lobby.noMercy { - lobbyModeTag = " 🔥 NO MERCY" - if lobby.sevenZeroRule { - lobbyModeTag += " (7-0)" - } - } - sb.WriteString(fmt.Sprintf("🃏 **UNO Lobby**%s — Ante: €%d\nPlayers (%d/4):\n", lobbyModeTag, int(lobby.ante), count)) - for i, uid := range lobby.players { - name := p.DisplayName(uid) - label := "" - if uid == lobby.creator { - label = " (host)" - } - sb.WriteString(fmt.Sprintf(" %d. %s%s\n", i+1, name, label)) - } - sb.WriteString("\nType `!uno join` to join or `!uno go` to start!") - - return p.SendMessage(ctx.RoomID, sb.String()) + return p.SendMessage(ctx.RoomID, msg) } func (p *UnoPlugin) handleMultiLeave(ctx MessageContext) error { @@ -408,7 +433,7 @@ func (p *UnoPlugin) handleMultiLeave(ctx MessageContext) error { // Refund the leaving player p.euro.Credit(ctx.Sender, lobby.ante, "uno_multi_refund") name := p.DisplayName(ctx.Sender) - return p.SendMessage(ctx.RoomID, fmt.Sprintf("🃏 **%s** left the lobby. (%d/4 players)", name, len(lobby.players))) + return p.SendMessage(ctx.RoomID, fmt.Sprintf("🃏 **%s** left the lobby. (%d/4)", name, lobby.lobbySlots())) } func (p *UnoPlugin) handleMultiCancel(ctx MessageContext) error { @@ -437,6 +462,63 @@ func (p *UnoPlugin) handleMultiCancel(ctx MessageContext) error { return p.SendMessage(ctx.RoomID, "🃏 Lobby cancelled — all antes refunded.") } +func (p *UnoPlugin) handleMultiAddBot(ctx MessageContext) error { + if !isGamesRoom(ctx.RoomID) { + return p.SendReply(ctx.RoomID, ctx.EventID, "Use this command in the games channel!") + } + + p.mu.Lock() + lobby, exists := p.lobbies[ctx.RoomID] + if !exists { + p.mu.Unlock() + return p.SendReply(ctx.RoomID, ctx.EventID, "No lobby open. Start one with `!uno start €amount`.") + } + + if lobby.lobbySlots() >= 4 { + p.mu.Unlock() + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Lobby is full! (%d/4)", lobby.lobbySlots())) + } + + botIdx := len(lobby.botNames) + if botIdx >= len(unoExtraBotNames) { + p.mu.Unlock() + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("Maximum %d extra bots already added.", len(unoExtraBotNames))) + } + + lobby.botNames = append(lobby.botNames, unoExtraBotNames[botIdx]) + msg := p.buildLobbyDisplay(lobby) + p.mu.Unlock() + + return p.SendMessage(ctx.RoomID, msg) +} + +func (p *UnoPlugin) handleMultiRemoveBot(ctx MessageContext) error { + if !isGamesRoom(ctx.RoomID) { + return p.SendReply(ctx.RoomID, ctx.EventID, "Use this command in the games channel!") + } + + p.mu.Lock() + lobby, exists := p.lobbies[ctx.RoomID] + if !exists { + p.mu.Unlock() + return p.SendReply(ctx.RoomID, ctx.EventID, "No lobby open.") + } + + if len(lobby.botNames) == 0 { + p.mu.Unlock() + return p.SendReply(ctx.RoomID, ctx.EventID, "No bots to remove.") + } + + removed := lobby.botNames[len(lobby.botNames)-1] + lobby.botNames = lobby.botNames[:len(lobby.botNames)-1] + msg := p.buildLobbyDisplay(lobby) + p.mu.Unlock() + + p.SendMessage(ctx.RoomID, fmt.Sprintf("🤖 %s removed from the lobby.", removed)) + return p.SendMessage(ctx.RoomID, msg) +} + func (p *UnoPlugin) lobbyExpired(roomID id.RoomID) { p.mu.Lock() lobby, exists := p.lobbies[roomID] @@ -469,13 +551,16 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error { return p.SendReply(ctx.RoomID, ctx.EventID, "Only the host can start the game.") } - if len(lobby.players) < 2 { + // Need at least 2 total participants (humans + lobby bots + default bot) + totalParticipants := len(lobby.players) + len(lobby.botNames) + 1 // +1 for default bot + if totalParticipants < 2 { p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "Need at least 2 players to start.") } lobby.timer.Stop() players := lobby.players + lobbyBots := lobby.botNames ante := lobby.ante roomID := lobby.roomID noMercy := lobby.noMercy @@ -498,8 +583,14 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error { resolved = append(resolved, playerDMPair{uid, dmRoom}) } + // Debit bot antes from community pot + for range lobbyBots { + p.claimFromPot(ante) + } + // Build game - game := p.initMultiGame(resolved, roomID, ante, noMercy, sevenZeroRule) + game := p.initMultiGame(resolved, lobbyBots, roomID, ante, noMercy, sevenZeroRule) + game.botAntes = len(lobbyBots) p.mu.Lock() p.multiGames[game.id] = game @@ -512,7 +603,6 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error { // Announce var sb strings.Builder - bn := unoBotName() modeTag := "" if noMercy { modeTag = " 🔥 NO MERCY" @@ -520,12 +610,10 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error { modeTag += " (7-0)" } } - sb.WriteString(fmt.Sprintf("🃏 **Multiplayer UNO!**%s Pot: €%d\n\nPlayers:\n", modeTag, int(ante)*len(players))) + totalPot := int(ante) * (len(players) + len(lobbyBots)) + sb.WriteString(fmt.Sprintf("🃏 **Multiplayer UNO!**%s Pot: €%d\n\nPlayers:\n", modeTag, totalPot)) for i, pl := range game.players { - name := p.DisplayName(pl.userID) - if pl.isBot { - name = bn - } + name := p.multiName(pl) marker := "" if i == game.currentIdx { marker = " ← first turn" @@ -558,7 +646,7 @@ type playerDMPair struct { dmRoomID id.RoomID } -func (p *UnoPlugin) initMultiGame(players []playerDMPair, roomID id.RoomID, ante float64, noMercy, sevenZeroRule bool) *unoMultiGame { +func (p *UnoPlugin) initMultiGame(players []playerDMPair, lobbyBots []string, roomID id.RoomID, ante float64, noMercy, sevenZeroRule bool) *unoMultiGame { var deck []unoCard if noMercy { deck = newNoMercyDeck() @@ -575,23 +663,33 @@ func (p *UnoPlugin) initMultiGame(players []playerDMPair, roomID id.RoomID, ante copy(hand, deck[cardIdx:cardIdx+cardsPerPlayer]) cardIdx += cardsPerPlayer unshuffled = append(unshuffled, &unoMultiPlayer{ - userID: pd.userID, - dmRoomID: pd.dmRoomID, - hand: hand, - active: true, + userID: pd.userID, + displayName: p.DisplayName(pd.userID), + dmRoomID: pd.dmRoomID, + hand: hand, + active: true, }) } - // Bot - botHand := make([]unoCard, cardsPerPlayer) - copy(botHand, deck[cardIdx:cardIdx+cardsPerPlayer]) - cardIdx += cardsPerPlayer - bot := &unoMultiPlayer{ - userID: id.UserID(unoBotName()), - hand: botHand, - isBot: true, - active: true, + + // Default bot (always present) + addBot := func(name string) { + botHand := make([]unoCard, cardsPerPlayer) + copy(botHand, deck[cardIdx:cardIdx+cardsPerPlayer]) + cardIdx += cardsPerPlayer + unshuffled = append(unshuffled, &unoMultiPlayer{ + userID: id.UserID(name), + displayName: name, + hand: botHand, + isBot: true, + active: true, + }) + } + addBot(unoBotName()) + + // Additional bots from lobby + for _, bn := range lobbyBots { + addBot(bn) } - unshuffled = append(unshuffled, bot) // Shuffle turn order rand.Shuffle(len(unshuffled), func(i, j int) { unshuffled[i], unshuffled[j] = unshuffled[j], unshuffled[i] }) @@ -856,12 +954,7 @@ func (p *UnoPlugin) advanceAndExecute(game *unoMultiGame) { // nextTurnLabel returns "It's X's turn." for the current player. func (p *UnoPlugin) nextTurnLabel(game *unoMultiGame) string { - next := game.currentPlayer() - name := p.DisplayName(next.userID) - if next.isBot { - name = unoBotName() - } - return fmt.Sprintf("It's %s's turn.", name) + return fmt.Sprintf("It's %s's turn.", p.multiName(game.currentPlayer())) } // --------------------------------------------------------------------------- @@ -1087,10 +1180,7 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl if pl == player || !pl.active { continue } - name := p.DisplayName(pl.userID) - if pl.isBot { - name = unoBotName() - } + name := p.multiName(pl) sb.WriteString(fmt.Sprintf("%d. %s (%d cards)\n", i, name, len(pl.hand))) i++ } @@ -1104,10 +1194,7 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl if pl != player && pl.active { swapHandsMulti(player, pl) name := p.DisplayName(player.userID) - otherName := p.DisplayName(pl.userID) - if pl.isBot { - otherName = unoBotName() - } + otherName := p.multiName(pl) p.SendMessage(game.roomID, fmt.Sprintf("🔄 %s swaps hands with %s! %s", name, otherName, pickNoMercyCommentary("hand_swap"))) if !pl.isBot { @@ -1179,10 +1266,7 @@ func (p *UnoPlugin) handleMultiColorChoice(game *unoMultiGame, player *unoMultiP name := p.DisplayName(player.userID) nextIdx := game.nextActiveIdx() target := game.players[nextIdx] - targetName := p.DisplayName(target.userID) - if target.isBot { - targetName = unoBotName() - } + targetName := p.multiName(target) flipped := p.executeColorRouletteMulti(game, target, color) p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s plays: %s (chose %s %s)\n🎰 Color Roulette! %s flips %d cards until finding %s.\n%s", @@ -1377,10 +1461,7 @@ func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffec nextIdx := game.nextActiveIdx() nextPlayer := game.players[nextIdx] - nextName := p.DisplayName(nextPlayer.userID) - if nextPlayer.isBot { - nextName = unoBotName() - } + nextName := p.multiName(nextPlayer) switch card.Value { case unoSkip: @@ -1395,10 +1476,7 @@ func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffec var skippedNames []string for _, pl := range game.players { if pl != game.currentPlayer() && pl.active { - name := p.DisplayName(pl.userID) - if pl.isBot { - name = unoBotName() - } + name := p.multiName(pl) skippedNames = append(skippedNames, name) } } @@ -1473,10 +1551,7 @@ func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffec // After reverse, get next player in new direction nextIdx = game.nextActiveIdx() nextPlayer = game.players[nextIdx] - nextName = p.DisplayName(nextPlayer.userID) - if nextPlayer.isBot { - nextName = unoBotName() - } + nextName = p.multiName(nextPlayer) result.skippedName = nextName result.drawnCount = game.stackTotal game.currentIdx = game.nextActiveIdx() @@ -1566,11 +1641,7 @@ func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer, } // Next player - nextUp := game.currentPlayer() - nextUpName := p.DisplayName(nextUp.userID) - if nextUp.isBot { - nextUpName = unoBotName() - } + nextUpName := p.multiName(game.currentPlayer()) roomMsg.WriteString(fmt.Sprintf("\n It's %s's turn.", nextUpName)) p.SendMessage(game.roomID, roomMsg.String()) @@ -1596,10 +1667,7 @@ func (p *UnoPlugin) startWD4Challenge(game *unoMultiGame, wd4Player *unoMultiPla game.currentIdx = nextIdx game.turnID++ - playerName := p.DisplayName(wd4Player.userID) - if wd4Player.isBot { - playerName = unoBotName() - } + playerName := p.multiName(wd4Player) if victim.isBot { // Bot decides whether to challenge @@ -1617,14 +1685,8 @@ func (p *UnoPlugin) startWD4Challenge(game *unoMultiGame, wd4Player *unoMultiPla func (p *UnoPlugin) resolveWD4Challenge(game *unoMultiGame, challenged bool) { wd4Player := game.wd4Player victim := game.wd4Victim - wd4Name := p.DisplayName(wd4Player.userID) - if wd4Player.isBot { - wd4Name = unoBotName() - } - victimName := p.DisplayName(victim.userID) - if victim.isBot { - victimName = unoBotName() - } + wd4Name := p.multiName(wd4Player) + victimName := p.multiName(victim) // Clear challenge state game.wd4Player = nil @@ -1699,7 +1761,7 @@ func (p *UnoPlugin) botHandleWD4Challenge(game *unoMultiGame) { threshold = 30 } challenged := rand.IntN(100) < threshold - bn := unoBotName() + bn := game.currentPlayer().displayName if challenged { p.SendMessage(game.roomID, fmt.Sprintf("⚡ %s challenges the Wild Draw Four!", bn)) @@ -1716,7 +1778,7 @@ func (p *UnoPlugin) botHandleWD4Challenge(game *unoMultiGame) { func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) { bot := game.currentPlayer() - bn := unoBotName() + bn := bot.displayName // No Mercy stacking: bot must stack or absorb if game.noMercy && game.stackMinValue > 0 { @@ -1931,7 +1993,7 @@ func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) { p.SendMessage(game.roomID, roomBuf.String()) roomBuf.Reset() } - p.multiBotWins(game) + p.multiBotWins(game, bot) return } @@ -1976,10 +2038,7 @@ func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) { // applyCardEffects already advanced currentIdx to the victim if game.noMercy && card.Value == unoWildColorRoulette { target := game.players[game.currentIdx] - targetName := p.DisplayName(target.userID) - if target.isBot { - targetName = bn - } + targetName := p.multiName(target) flipped := p.executeColorRouletteMulti(game, target, game.topColor) roomBuf.WriteString(fmt.Sprintf("\n 🎰 Color Roulette! %s flips %d cards until finding %s %s.", targetName, len(flipped), game.topColor.Emoji(), game.topColor)) @@ -2013,11 +2072,7 @@ func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) { } // Next turn label (applyCardEffects already advanced currentIdx) - nextUp := game.currentPlayer() - nextUpName := p.DisplayName(nextUp.userID) - if nextUp.isBot { - nextUpName = unoBotName() - } + nextUpName := p.multiName(game.currentPlayer()) roomBuf.WriteString(fmt.Sprintf("\n It's %s's turn.", nextUpName)) game.turns++ @@ -2151,10 +2206,7 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer if game.noMercy && pendingCard != nil && pendingCard.Value == unoWildColorRoulette { nextIdx := game.nextActiveIdx() target := game.players[nextIdx] - targetName := p.DisplayName(target.userID) - if target.isBot { - targetName = unoBotName() - } + targetName := p.multiName(target) flipped := p.executeColorRouletteMulti(game, target, color) p.SendMessage(game.roomID, fmt.Sprintf("🎰 Color Roulette! %s flips %d cards until finding %s.\n%s", targetName, len(flipped), color, pickNoMercyCommentary("color_roulette"))) @@ -2234,10 +2286,7 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer target := botChooseSwapTarget(game, player) if target != nil { swapHandsMulti(player, target) - targetName := p.DisplayName(target.userID) - if target.isBot { - targetName = unoBotName() - } + targetName := p.multiName(target) p.SendMessage(player.dmRoomID, fmt.Sprintf("*Auto-played:* Swapped hands with %s.", targetName)) p.SendMessage(game.roomID, fmt.Sprintf("🔄 *%s was auto-played.* Swaps hands with %s!", name, targetName)) } @@ -2466,16 +2515,9 @@ func (p *UnoPlugin) multiSuddenDeathWinner(game *unoMultiGame, active []*unoMult aScore := scoreHand(a.hand) bScore := scoreHand(b.hand) - nameOf := func(pl *unoMultiPlayer) string { - if pl.isBot { - return unoBotName() - } - return p.DisplayName(pl.userID) - } - breakdown := fmt.Sprintf("**Final Scores:**\n%s: %s\n%s: %s", - nameOf(a), formatHandScore(a.hand), - nameOf(b), formatHandScore(b.hand)) + p.multiName(a), formatHandScore(a.hand), + p.multiName(b), formatHandScore(b.hand)) var winner, loser *unoMultiPlayer switch { @@ -2503,7 +2545,7 @@ func (p *UnoPlugin) multiSuddenDeathWinner(game *unoMultiGame, active []*unoMult p.SendMessage(game.roomID, fmt.Sprintf( "⏰ **SUDDEN DEATH!** %s wins on points!\n%s\n\n%s", - nameOf(winner), breakdown, pickCommentary("sudden_death_win"))) + p.multiName(winner), breakdown, pickCommentary("sudden_death_win"))) if winner.isBot { p.multiBotWins2(game) @@ -2521,7 +2563,7 @@ func (p *UnoPlugin) multiPlayerWins2(game *unoMultiGame, winner *unoMultiPlayer) humanCount++ } } - totalPot := game.ante * float64(humanCount) + totalPot := game.ante * float64(humanCount+game.botAntes) p.euro.Credit(winner.userID, totalPot, "uno_multi_win") p.recordMultiGame(game, winner.userID, "sudden_death_win") @@ -2535,7 +2577,7 @@ func (p *UnoPlugin) multiBotWins2(game *unoMultiGame) { humanCount++ } } - totalPot := game.ante * float64(humanCount) + totalPot := game.ante * float64(humanCount+game.botAntes) p.addToPot(totalPot) for _, pl := range game.players { @@ -2571,7 +2613,7 @@ func (p *UnoPlugin) multiPlayerWins(game *unoMultiGame, winner *unoMultiPlayer) humanCount++ } } - totalPot := game.ante * float64(humanCount) + totalPot := game.ante * float64(humanCount+game.botAntes) name := p.DisplayName(winner.userID) p.euro.Credit(winner.userID, totalPot, "uno_multi_win") @@ -2584,7 +2626,7 @@ func (p *UnoPlugin) multiPlayerWins(game *unoMultiGame, winner *unoMultiPlayer) p.cleanupMultiGame(game) } -func (p *UnoPlugin) multiBotWins(game *unoMultiGame) { +func (p *UnoPlugin) multiBotWins(game *unoMultiGame, winner *unoMultiPlayer) { if game.done { return } @@ -2603,12 +2645,12 @@ func (p *UnoPlugin) multiBotWins(game *unoMultiGame) { humanCount++ } } - totalPot := game.ante * float64(humanCount) + totalPot := game.ante * float64(humanCount+game.botAntes) // Pot goes to community p.addToPot(totalPot) - bn := unoBotName() + bn := winner.displayName p.SendMessage(game.roomID, fmt.Sprintf( "💀 **%s wins Multiplayer UNO!**\n€%d goes to the community pot.\n\n%s", bn, int(totalPot), pickCommentary("bot_win"))) @@ -2638,7 +2680,7 @@ func (p *UnoPlugin) multiPlayerForfeit(game *unoMultiGame, player *unoMultiPlaye winner := active[0] if winner.isBot { p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s forfeited. All humans out!", name)) - p.multiBotWins(game) + p.multiBotWins(game, winner) } else { p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s forfeited. Only one player remains!", name)) p.multiPlayerWins(game, winner) @@ -2682,7 +2724,7 @@ func (p *UnoPlugin) recordMultiGame(game *unoMultiGame, winnerID id.UserID, resu } humanCount := len(playerIDs) - totalPot := game.ante * float64(humanCount) + totalPot := game.ante * float64(humanCount+game.botAntes) db.Exec("uno_multi: record game", `INSERT INTO uno_multi_games (room_id, ante, pot_total, winner_id, player_ids, result, turns, started_at, ended_at) @@ -2700,12 +2742,7 @@ func (p *UnoPlugin) recordMultiGame(game *unoMultiGame, winnerID id.UserID, resu func (p *UnoPlugin) sendMultiStatus(game *unoMultiGame, player *unoMultiPlayer) { var sb strings.Builder - bn := unoBotName() - current := game.currentPlayer() - currentName := p.DisplayName(current.userID) - if current.isBot { - currentName = bn - } + currentName := p.multiName(game.currentPlayer()) sb.WriteString(fmt.Sprintf("🃏 **UNO Status**\nDiscard pile: %s\nCurrent turn: %s\n\n", game.discardTop.DisplayWithColor(game.topColor), currentName)) @@ -2725,11 +2762,7 @@ func (p *UnoPlugin) sendMultiStatus(game *unoMultiGame, player *unoMultiPlayer) if pl == player || !pl.active { continue } - name := p.DisplayName(pl.userID) - if pl.isBot { - name = bn - } - counts = append(counts, fmt.Sprintf("%s (%d)", name, len(pl.hand))) + counts = append(counts, fmt.Sprintf("%s (%d)", p.multiName(pl), len(pl.hand))) } sb.WriteString(strings.Join(counts, " | ")) @@ -2752,16 +2785,11 @@ func (p *UnoPlugin) sendMultiHandDisplay(game *unoMultiGame, player *unoMultiPla // Card counts for all opponents sb.WriteString("\nCard counts: ") counts := make([]string, 0) - bn := unoBotName() for _, pl := range game.players { if pl == player || !pl.active { continue } - name := p.DisplayName(pl.userID) - if pl.isBot { - name = bn - } - counts = append(counts, fmt.Sprintf("%s (%d)", name, len(pl.hand))) + counts = append(counts, fmt.Sprintf("%s (%d)", p.multiName(pl), len(pl.hand))) } sb.WriteString(strings.Join(counts, " | ")) @@ -2790,16 +2818,11 @@ func (p *UnoPlugin) sendMultiHandDisplayStacking(game *unoMultiGame, player *uno sb.WriteString("\nCard counts: ") counts := make([]string, 0) - bn := unoBotName() for _, pl := range game.players { if pl == player || !pl.active { continue } - name := p.DisplayName(pl.userID) - if pl.isBot { - name = bn - } - counts = append(counts, fmt.Sprintf("%s (%d)", name, len(pl.hand))) + counts = append(counts, fmt.Sprintf("%s (%d)", p.multiName(pl), len(pl.hand))) } sb.WriteString(strings.Join(counts, " | ")) @@ -2840,10 +2863,7 @@ func (p *UnoPlugin) handleMultiSwapChoice(game *unoMultiGame, player *unoMultiPl game.phase = unoMultiPhasePlay name := p.DisplayName(player.userID) - targetName := p.DisplayName(target.userID) - if target.isBot { - targetName = unoBotName() - } + targetName := p.multiName(target) p.SendMessage(player.dmRoomID, fmt.Sprintf("Swapped hands with %s! You now have %d cards.", targetName, len(player.hand))) if !target.isBot { diff --git a/internal/plugin/uno_nomercy.go b/internal/plugin/uno_nomercy.go index 53ddf95..9e902d6 100644 --- a/internal/plugin/uno_nomercy.go +++ b/internal/plugin/uno_nomercy.go @@ -201,10 +201,7 @@ func (p *UnoPlugin) checkMultiMercyElimination(game *unoMultiGame, player *unoMu } player.active = false - name := p.DisplayName(player.userID) - if player.isBot { - name = unoBotName() - } + name := p.multiName(player) // Shuffle cards back into draw pile game.drawPile = append(game.drawPile, player.hand...) @@ -226,7 +223,7 @@ func (p *UnoPlugin) checkMultiMercyElimination(game *unoMultiGame, player *unoMu if len(active) == 1 { winner := active[0] if winner.isBot { - p.multiBotWins(game) + p.multiBotWins(game, winner) } else { p.multiPlayerWins(game, winner) }