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 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-29 20:51:28 -07:00
parent 0a1359de46
commit 06153880e0
6 changed files with 232 additions and 195 deletions

View File

@@ -336,7 +336,7 @@ Rep is earned when someone thanks you. The bot detects this automatically.
### UNO (games channel only) ### 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 | | 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 €amount` | Multiplayer No Mercy lobby |
| `!uno start nomercy 7-0 €amount` | Multiplayer No Mercy with 7-0 rule | | `!uno start nomercy 7-0 €amount` | Multiplayer No Mercy with 7-0 rule |
| `!uno join` | Join an open lobby | | `!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 leave` | Leave the lobby (refunds ante) |
| `!uno cancel` | Cancel the lobby (host/admin, refunds all) | | `!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 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 - **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) ### 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. 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) ### 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. 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 <item>` | Buy equipment by name or tier shorthand (`3 sword`, `t4 boots`) | | `!adventure buy <item>` | Buy equipment by name or tier shorthand (`3 sword`, `t4 boots`) |
| `!adventure sell <item>` | Sell an inventory item (credits euro balance) | | `!adventure sell <item>` | Sell an inventory item (credits euro balance) |
| `!adventure sell all` | Sell entire inventory | | `!adventure sell all` | Sell entire inventory |
| `!adventure equip` | Equip masterwork gear from inventory |
| `!adventure inventory` | List current inventory | | `!adventure inventory` | List current inventory |
| `!adventure leaderboard` | Top adventurers | | `!adventure leaderboard` | Top adventurers |
| `!adventure revive @user` | Revive a dead player (admin) | | `!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 #### 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 - **Dungeon** — combat XP, chance of death, best loot potential
- **Mining** — mining XP, cave-in risk, ore and gem drops - **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 - **Foraging** — foraging XP, wildlife hazards, herb and reagent drops
#### Mechanics #### 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. - **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. - **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. - **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. - **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. - **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. - **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. - **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. - **Mid-day events** — random events can trigger between actions, delivering bonus loot, buffs, or narrative encounters.
#### Arena (`!arena`) #### 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 | | Command | Description |
|---------|-------------| |---------|-------------|
@@ -938,8 +951,9 @@ gogobee/
│ │ ├── hangman.go # Collaborative Hangman │ │ ├── hangman.go # Collaborative Hangman
│ │ ├── blackjack.go # Multiplayer Blackjack │ │ ├── blackjack.go # Multiplayer Blackjack
│ │ ├── uno.go # Solo UNO vs bot (DM-based) │ │ ├── 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_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.go # Texas Hold'em plugin (commands, game lifecycle, NPC)
│ │ ├── holdem_game.go # Game state, player types, deck, position labels │ │ ├── holdem_game.go # Game state, player types, deck, position labels
│ │ ├── holdem_betting.go # Blinds, action validation, side pots │ │ ├── holdem_betting.go # Blinds, action validation, side pots
@@ -963,10 +977,12 @@ gogobee/
│ │ ├── adventure_events.go # Mid-day random events │ │ ├── adventure_events.go # Mid-day random events
│ │ ├── adventure_scheduler.go # Morning DM, evening summary, midnight reset tickers │ │ ├── adventure_scheduler.go # Morning DM, evening summary, midnight reset tickers
│ │ ├── adventure_holidays.go # Holiday detection (Hebrew/Islamic calendar, fixed dates) │ │ ├── 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.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_monsters.go # 5 tiers, 20 monsters, death messages
│ │ ├── adventure_arena_render.go # Arena UI rendering (menus, stats, leaderboard) │ │ ├── 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 │ │ ├── esteemed.go # Satirical esteemed member posts
│ │ ├── moderation.go # Moderation system (strikes, word list, flood detection) │ │ ├── moderation.go # Moderation system (strikes, word list, flood detection)
│ │ └── ratelimits.go # Rate limiting │ │ └── ratelimits.go # Rate limiting

View File

@@ -19,7 +19,7 @@ type MasterworkDef struct {
Tier int // 1-5, matches location tier Tier int // 1-5, matches location tier
Name string Name string
Description string // character sheet / trade listing 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{ var masterworkDefs = []MasterworkDef{
@@ -46,7 +46,7 @@ var masterworkDefs = []MasterworkDef{
}, },
{ {
Slot: SlotWeapon, Activity: AdvActivityMining, SkillSource: "mining", Tier: 5, 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.", 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, 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.", 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, 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.", 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.",
}, },
} }

View File

@@ -399,15 +399,15 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error {
sb.WriteString("\n") sb.WriteString("\n")
// ── Adventure ── // ── Adventure ──
var combatLv, miningLv, forageLv, combatXP, miningXP, forageXP int var combatLv, miningLv, fishingLv, forageLv, combatXP, miningXP, fishingXP, forageXP int
var alive bool var alive bool
var streak, bestStreak int var streak, bestStreak int
err = d.QueryRow( err = d.QueryRow(
`SELECT combat_level, mining_skill, foraging_skill, `SELECT combat_level, mining_skill, fishing_skill, foraging_skill,
combat_xp, mining_xp, foraging_xp, combat_xp, mining_xp, fishing_xp, foraging_xp,
alive, current_streak, best_streak alive, current_streak, best_streak
FROM adventure_characters WHERE user_id = ?`, uid, 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) &alive, &streak, &bestStreak)
if err == nil { if err == nil {
status := "Alive" status := "Alive"
@@ -415,8 +415,8 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error {
status = "💀 Dead" status = "💀 Dead"
} }
sb.WriteString(fmt.Sprintf("⚔️ **Adventure:** %s\n", status)) 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", 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, forageLv, forageXP)) combatLv, combatXP, miningLv, miningXP, fishingLv, fishingXP, forageLv, forageXP))
if streak > 0 || bestStreak > 0 { if streak > 0 || bestStreak > 0 {
sb.WriteString(fmt.Sprintf(" 🔥 Streak: %d days (best: %d)\n", streak, bestStreak)) sb.WriteString(fmt.Sprintf(" 🔥 Streak: %d days (best: %d)\n", streak, bestStreak))
} }

View File

@@ -415,7 +415,7 @@ func (p *UnoPlugin) Name() string { return "uno" }
func (p *UnoPlugin) Commands() []CommandDef { func (p *UnoPlugin) Commands() []CommandDef {
return []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"}, {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) return p.handleMultiLeave(ctx)
case lower == "cancel": case lower == "cancel":
return p.handleMultiCancel(ctx) 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 // Solo challenge: !uno [nomercy [7-0]] €amount

View File

@@ -29,6 +29,7 @@ const (
type unoMultiPlayer struct { type unoMultiPlayer struct {
userID id.UserID userID id.UserID
displayName string // set at creation; bots use their assigned name
dmRoomID id.RoomID dmRoomID id.RoomID
hand []unoCard hand []unoCard
calledUno bool calledUno bool
@@ -67,6 +68,9 @@ type unoMultiGame struct {
stackTotal int // cumulative draw penalty during stacking stackTotal int // cumulative draw penalty during stacking
stackMinValue int // minimum draw value to stack (0 = not 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) // Sudden death (long-game point scoring)
suddenDeath bool suddenDeath bool
suddenDeathTurn int suddenDeathTurn int
@@ -81,16 +85,66 @@ type unoMultiLobby struct {
creator id.UserID creator id.UserID
ante float64 ante float64
players []id.UserID players []id.UserID
botNames []string // bots added via !uno addbot
createdAt time.Time createdAt time.Time
timer *time.Timer timer *time.Timer
noMercy bool noMercy bool
sevenZeroRule 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 // 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 { func (g *unoMultiGame) currentPlayer() *unoMultiPlayer {
return g.players[g.currentIdx] return g.players[g.currentIdx]
} }
@@ -281,19 +335,10 @@ func (p *UnoPlugin) handleMultiStart(ctx MessageContext, amountStr string, noMer
}) })
p.lobbies[ctx.RoomID] = lobby p.lobbies[ctx.RoomID] = lobby
msg := p.buildLobbyDisplay(lobby)
p.mu.Unlock() p.mu.Unlock()
creatorName := p.DisplayName(ctx.Sender) return p.SendMessage(ctx.RoomID, msg)
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))
} }
func (p *UnoPlugin) handleMultiJoin(ctx MessageContext) error { 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() 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 // 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) lobby.players = append(lobby.players, ctx.Sender)
count := len(lobby.players) msg := p.buildLobbyDisplay(lobby)
p.mu.Unlock() p.mu.Unlock()
// Build player list return p.SendMessage(ctx.RoomID, msg)
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())
} }
func (p *UnoPlugin) handleMultiLeave(ctx MessageContext) error { func (p *UnoPlugin) handleMultiLeave(ctx MessageContext) error {
@@ -408,7 +433,7 @@ func (p *UnoPlugin) handleMultiLeave(ctx MessageContext) error {
// Refund the leaving player // Refund the leaving player
p.euro.Credit(ctx.Sender, lobby.ante, "uno_multi_refund") p.euro.Credit(ctx.Sender, lobby.ante, "uno_multi_refund")
name := p.DisplayName(ctx.Sender) 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 { 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.") 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) { func (p *UnoPlugin) lobbyExpired(roomID id.RoomID) {
p.mu.Lock() p.mu.Lock()
lobby, exists := p.lobbies[roomID] 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.") 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() p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "Need at least 2 players to start.") return p.SendReply(ctx.RoomID, ctx.EventID, "Need at least 2 players to start.")
} }
lobby.timer.Stop() lobby.timer.Stop()
players := lobby.players players := lobby.players
lobbyBots := lobby.botNames
ante := lobby.ante ante := lobby.ante
roomID := lobby.roomID roomID := lobby.roomID
noMercy := lobby.noMercy noMercy := lobby.noMercy
@@ -498,8 +583,14 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error {
resolved = append(resolved, playerDMPair{uid, dmRoom}) resolved = append(resolved, playerDMPair{uid, dmRoom})
} }
// Debit bot antes from community pot
for range lobbyBots {
p.claimFromPot(ante)
}
// Build game // 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.mu.Lock()
p.multiGames[game.id] = game p.multiGames[game.id] = game
@@ -512,7 +603,6 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error {
// Announce // Announce
var sb strings.Builder var sb strings.Builder
bn := unoBotName()
modeTag := "" modeTag := ""
if noMercy { if noMercy {
modeTag = " 🔥 NO MERCY" modeTag = " 🔥 NO MERCY"
@@ -520,12 +610,10 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error {
modeTag += " (7-0)" 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 { for i, pl := range game.players {
name := p.DisplayName(pl.userID) name := p.multiName(pl)
if pl.isBot {
name = bn
}
marker := "" marker := ""
if i == game.currentIdx { if i == game.currentIdx {
marker = " ← first turn" marker = " ← first turn"
@@ -558,7 +646,7 @@ type playerDMPair struct {
dmRoomID id.RoomID 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 var deck []unoCard
if noMercy { if noMercy {
deck = newNoMercyDeck() deck = newNoMercyDeck()
@@ -575,23 +663,33 @@ func (p *UnoPlugin) initMultiGame(players []playerDMPair, roomID id.RoomID, ante
copy(hand, deck[cardIdx:cardIdx+cardsPerPlayer]) copy(hand, deck[cardIdx:cardIdx+cardsPerPlayer])
cardIdx += cardsPerPlayer cardIdx += cardsPerPlayer
unshuffled = append(unshuffled, &unoMultiPlayer{ unshuffled = append(unshuffled, &unoMultiPlayer{
userID: pd.userID, userID: pd.userID,
dmRoomID: pd.dmRoomID, displayName: p.DisplayName(pd.userID),
hand: hand, dmRoomID: pd.dmRoomID,
active: true, hand: hand,
active: true,
}) })
} }
// Bot
botHand := make([]unoCard, cardsPerPlayer) // Default bot (always present)
copy(botHand, deck[cardIdx:cardIdx+cardsPerPlayer]) addBot := func(name string) {
cardIdx += cardsPerPlayer botHand := make([]unoCard, cardsPerPlayer)
bot := &unoMultiPlayer{ copy(botHand, deck[cardIdx:cardIdx+cardsPerPlayer])
userID: id.UserID(unoBotName()), cardIdx += cardsPerPlayer
hand: botHand, unshuffled = append(unshuffled, &unoMultiPlayer{
isBot: true, userID: id.UserID(name),
active: true, 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 // Shuffle turn order
rand.Shuffle(len(unshuffled), func(i, j int) { unshuffled[i], unshuffled[j] = unshuffled[j], unshuffled[i] }) 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. // nextTurnLabel returns "It's X's turn." for the current player.
func (p *UnoPlugin) nextTurnLabel(game *unoMultiGame) string { func (p *UnoPlugin) nextTurnLabel(game *unoMultiGame) string {
next := game.currentPlayer() return fmt.Sprintf("It's %s's turn.", p.multiName(game.currentPlayer()))
name := p.DisplayName(next.userID)
if next.isBot {
name = unoBotName()
}
return fmt.Sprintf("It's %s's turn.", name)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1087,10 +1180,7 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl
if pl == player || !pl.active { if pl == player || !pl.active {
continue continue
} }
name := p.DisplayName(pl.userID) name := p.multiName(pl)
if pl.isBot {
name = unoBotName()
}
sb.WriteString(fmt.Sprintf("%d. %s (%d cards)\n", i, name, len(pl.hand))) sb.WriteString(fmt.Sprintf("%d. %s (%d cards)\n", i, name, len(pl.hand)))
i++ i++
} }
@@ -1104,10 +1194,7 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl
if pl != player && pl.active { if pl != player && pl.active {
swapHandsMulti(player, pl) swapHandsMulti(player, pl)
name := p.DisplayName(player.userID) name := p.DisplayName(player.userID)
otherName := p.DisplayName(pl.userID) otherName := p.multiName(pl)
if pl.isBot {
otherName = unoBotName()
}
p.SendMessage(game.roomID, fmt.Sprintf("🔄 %s swaps hands with %s! %s", p.SendMessage(game.roomID, fmt.Sprintf("🔄 %s swaps hands with %s! %s",
name, otherName, pickNoMercyCommentary("hand_swap"))) name, otherName, pickNoMercyCommentary("hand_swap")))
if !pl.isBot { if !pl.isBot {
@@ -1179,10 +1266,7 @@ func (p *UnoPlugin) handleMultiColorChoice(game *unoMultiGame, player *unoMultiP
name := p.DisplayName(player.userID) name := p.DisplayName(player.userID)
nextIdx := game.nextActiveIdx() nextIdx := game.nextActiveIdx()
target := game.players[nextIdx] target := game.players[nextIdx]
targetName := p.DisplayName(target.userID) targetName := p.multiName(target)
if target.isBot {
targetName = unoBotName()
}
flipped := p.executeColorRouletteMulti(game, target, color) 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", 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() nextIdx := game.nextActiveIdx()
nextPlayer := game.players[nextIdx] nextPlayer := game.players[nextIdx]
nextName := p.DisplayName(nextPlayer.userID) nextName := p.multiName(nextPlayer)
if nextPlayer.isBot {
nextName = unoBotName()
}
switch card.Value { switch card.Value {
case unoSkip: case unoSkip:
@@ -1395,10 +1476,7 @@ func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffec
var skippedNames []string var skippedNames []string
for _, pl := range game.players { for _, pl := range game.players {
if pl != game.currentPlayer() && pl.active { if pl != game.currentPlayer() && pl.active {
name := p.DisplayName(pl.userID) name := p.multiName(pl)
if pl.isBot {
name = unoBotName()
}
skippedNames = append(skippedNames, name) 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 // After reverse, get next player in new direction
nextIdx = game.nextActiveIdx() nextIdx = game.nextActiveIdx()
nextPlayer = game.players[nextIdx] nextPlayer = game.players[nextIdx]
nextName = p.DisplayName(nextPlayer.userID) nextName = p.multiName(nextPlayer)
if nextPlayer.isBot {
nextName = unoBotName()
}
result.skippedName = nextName result.skippedName = nextName
result.drawnCount = game.stackTotal result.drawnCount = game.stackTotal
game.currentIdx = game.nextActiveIdx() game.currentIdx = game.nextActiveIdx()
@@ -1566,11 +1641,7 @@ func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer,
} }
// Next player // Next player
nextUp := game.currentPlayer() nextUpName := p.multiName(game.currentPlayer())
nextUpName := p.DisplayName(nextUp.userID)
if nextUp.isBot {
nextUpName = unoBotName()
}
roomMsg.WriteString(fmt.Sprintf("\n It's %s's turn.", nextUpName)) roomMsg.WriteString(fmt.Sprintf("\n It's %s's turn.", nextUpName))
p.SendMessage(game.roomID, roomMsg.String()) p.SendMessage(game.roomID, roomMsg.String())
@@ -1596,10 +1667,7 @@ func (p *UnoPlugin) startWD4Challenge(game *unoMultiGame, wd4Player *unoMultiPla
game.currentIdx = nextIdx game.currentIdx = nextIdx
game.turnID++ game.turnID++
playerName := p.DisplayName(wd4Player.userID) playerName := p.multiName(wd4Player)
if wd4Player.isBot {
playerName = unoBotName()
}
if victim.isBot { if victim.isBot {
// Bot decides whether to challenge // 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) { func (p *UnoPlugin) resolveWD4Challenge(game *unoMultiGame, challenged bool) {
wd4Player := game.wd4Player wd4Player := game.wd4Player
victim := game.wd4Victim victim := game.wd4Victim
wd4Name := p.DisplayName(wd4Player.userID) wd4Name := p.multiName(wd4Player)
if wd4Player.isBot { victimName := p.multiName(victim)
wd4Name = unoBotName()
}
victimName := p.DisplayName(victim.userID)
if victim.isBot {
victimName = unoBotName()
}
// Clear challenge state // Clear challenge state
game.wd4Player = nil game.wd4Player = nil
@@ -1699,7 +1761,7 @@ func (p *UnoPlugin) botHandleWD4Challenge(game *unoMultiGame) {
threshold = 30 threshold = 30
} }
challenged := rand.IntN(100) < threshold challenged := rand.IntN(100) < threshold
bn := unoBotName() bn := game.currentPlayer().displayName
if challenged { if challenged {
p.SendMessage(game.roomID, fmt.Sprintf("⚡ %s challenges the Wild Draw Four!", bn)) 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) { func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) {
bot := game.currentPlayer() bot := game.currentPlayer()
bn := unoBotName() bn := bot.displayName
// No Mercy stacking: bot must stack or absorb // No Mercy stacking: bot must stack or absorb
if game.noMercy && game.stackMinValue > 0 { 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()) p.SendMessage(game.roomID, roomBuf.String())
roomBuf.Reset() roomBuf.Reset()
} }
p.multiBotWins(game) p.multiBotWins(game, bot)
return return
} }
@@ -1976,10 +2038,7 @@ func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) {
// applyCardEffects already advanced currentIdx to the victim // applyCardEffects already advanced currentIdx to the victim
if game.noMercy && card.Value == unoWildColorRoulette { if game.noMercy && card.Value == unoWildColorRoulette {
target := game.players[game.currentIdx] target := game.players[game.currentIdx]
targetName := p.DisplayName(target.userID) targetName := p.multiName(target)
if target.isBot {
targetName = bn
}
flipped := p.executeColorRouletteMulti(game, target, game.topColor) flipped := p.executeColorRouletteMulti(game, target, game.topColor)
roomBuf.WriteString(fmt.Sprintf("\n 🎰 Color Roulette! %s flips %d cards until finding %s %s.", roomBuf.WriteString(fmt.Sprintf("\n 🎰 Color Roulette! %s flips %d cards until finding %s %s.",
targetName, len(flipped), game.topColor.Emoji(), game.topColor)) 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) // Next turn label (applyCardEffects already advanced currentIdx)
nextUp := game.currentPlayer() nextUpName := p.multiName(game.currentPlayer())
nextUpName := p.DisplayName(nextUp.userID)
if nextUp.isBot {
nextUpName = unoBotName()
}
roomBuf.WriteString(fmt.Sprintf("\n It's %s's turn.", nextUpName)) roomBuf.WriteString(fmt.Sprintf("\n It's %s's turn.", nextUpName))
game.turns++ game.turns++
@@ -2151,10 +2206,7 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer
if game.noMercy && pendingCard != nil && pendingCard.Value == unoWildColorRoulette { if game.noMercy && pendingCard != nil && pendingCard.Value == unoWildColorRoulette {
nextIdx := game.nextActiveIdx() nextIdx := game.nextActiveIdx()
target := game.players[nextIdx] target := game.players[nextIdx]
targetName := p.DisplayName(target.userID) targetName := p.multiName(target)
if target.isBot {
targetName = unoBotName()
}
flipped := p.executeColorRouletteMulti(game, target, color) flipped := p.executeColorRouletteMulti(game, target, color)
p.SendMessage(game.roomID, fmt.Sprintf("🎰 Color Roulette! %s flips %d cards until finding %s.\n%s", p.SendMessage(game.roomID, fmt.Sprintf("🎰 Color Roulette! %s flips %d cards until finding %s.\n%s",
targetName, len(flipped), color, pickNoMercyCommentary("color_roulette"))) targetName, len(flipped), color, pickNoMercyCommentary("color_roulette")))
@@ -2234,10 +2286,7 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer
target := botChooseSwapTarget(game, player) target := botChooseSwapTarget(game, player)
if target != nil { if target != nil {
swapHandsMulti(player, target) swapHandsMulti(player, target)
targetName := p.DisplayName(target.userID) targetName := p.multiName(target)
if target.isBot {
targetName = unoBotName()
}
p.SendMessage(player.dmRoomID, fmt.Sprintf("*Auto-played:* Swapped hands with %s.", targetName)) 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)) 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) aScore := scoreHand(a.hand)
bScore := scoreHand(b.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", breakdown := fmt.Sprintf("**Final Scores:**\n%s: %s\n%s: %s",
nameOf(a), formatHandScore(a.hand), p.multiName(a), formatHandScore(a.hand),
nameOf(b), formatHandScore(b.hand)) p.multiName(b), formatHandScore(b.hand))
var winner, loser *unoMultiPlayer var winner, loser *unoMultiPlayer
switch { switch {
@@ -2503,7 +2545,7 @@ func (p *UnoPlugin) multiSuddenDeathWinner(game *unoMultiGame, active []*unoMult
p.SendMessage(game.roomID, fmt.Sprintf( p.SendMessage(game.roomID, fmt.Sprintf(
"⏰ **SUDDEN DEATH!** %s wins on points!\n%s\n\n%s", "⏰ **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 { if winner.isBot {
p.multiBotWins2(game) p.multiBotWins2(game)
@@ -2521,7 +2563,7 @@ func (p *UnoPlugin) multiPlayerWins2(game *unoMultiGame, winner *unoMultiPlayer)
humanCount++ humanCount++
} }
} }
totalPot := game.ante * float64(humanCount) totalPot := game.ante * float64(humanCount+game.botAntes)
p.euro.Credit(winner.userID, totalPot, "uno_multi_win") p.euro.Credit(winner.userID, totalPot, "uno_multi_win")
p.recordMultiGame(game, winner.userID, "sudden_death_win") p.recordMultiGame(game, winner.userID, "sudden_death_win")
@@ -2535,7 +2577,7 @@ func (p *UnoPlugin) multiBotWins2(game *unoMultiGame) {
humanCount++ humanCount++
} }
} }
totalPot := game.ante * float64(humanCount) totalPot := game.ante * float64(humanCount+game.botAntes)
p.addToPot(totalPot) p.addToPot(totalPot)
for _, pl := range game.players { for _, pl := range game.players {
@@ -2571,7 +2613,7 @@ func (p *UnoPlugin) multiPlayerWins(game *unoMultiGame, winner *unoMultiPlayer)
humanCount++ humanCount++
} }
} }
totalPot := game.ante * float64(humanCount) totalPot := game.ante * float64(humanCount+game.botAntes)
name := p.DisplayName(winner.userID) name := p.DisplayName(winner.userID)
p.euro.Credit(winner.userID, totalPot, "uno_multi_win") p.euro.Credit(winner.userID, totalPot, "uno_multi_win")
@@ -2584,7 +2626,7 @@ func (p *UnoPlugin) multiPlayerWins(game *unoMultiGame, winner *unoMultiPlayer)
p.cleanupMultiGame(game) p.cleanupMultiGame(game)
} }
func (p *UnoPlugin) multiBotWins(game *unoMultiGame) { func (p *UnoPlugin) multiBotWins(game *unoMultiGame, winner *unoMultiPlayer) {
if game.done { if game.done {
return return
} }
@@ -2603,12 +2645,12 @@ func (p *UnoPlugin) multiBotWins(game *unoMultiGame) {
humanCount++ humanCount++
} }
} }
totalPot := game.ante * float64(humanCount) totalPot := game.ante * float64(humanCount+game.botAntes)
// Pot goes to community // Pot goes to community
p.addToPot(totalPot) p.addToPot(totalPot)
bn := unoBotName() bn := winner.displayName
p.SendMessage(game.roomID, fmt.Sprintf( p.SendMessage(game.roomID, fmt.Sprintf(
"💀 **%s wins Multiplayer UNO!**\n€%d goes to the community pot.\n\n%s", "💀 **%s wins Multiplayer UNO!**\n€%d goes to the community pot.\n\n%s",
bn, int(totalPot), pickCommentary("bot_win"))) bn, int(totalPot), pickCommentary("bot_win")))
@@ -2638,7 +2680,7 @@ func (p *UnoPlugin) multiPlayerForfeit(game *unoMultiGame, player *unoMultiPlaye
winner := active[0] winner := active[0]
if winner.isBot { if winner.isBot {
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s forfeited. All humans out!", name)) p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s forfeited. All humans out!", name))
p.multiBotWins(game) p.multiBotWins(game, winner)
} else { } else {
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s forfeited. Only one player remains!", name)) p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s forfeited. Only one player remains!", name))
p.multiPlayerWins(game, winner) p.multiPlayerWins(game, winner)
@@ -2682,7 +2724,7 @@ func (p *UnoPlugin) recordMultiGame(game *unoMultiGame, winnerID id.UserID, resu
} }
humanCount := len(playerIDs) humanCount := len(playerIDs)
totalPot := game.ante * float64(humanCount) totalPot := game.ante * float64(humanCount+game.botAntes)
db.Exec("uno_multi: record game", 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) `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) { func (p *UnoPlugin) sendMultiStatus(game *unoMultiGame, player *unoMultiPlayer) {
var sb strings.Builder var sb strings.Builder
bn := unoBotName() currentName := p.multiName(game.currentPlayer())
current := game.currentPlayer()
currentName := p.DisplayName(current.userID)
if current.isBot {
currentName = bn
}
sb.WriteString(fmt.Sprintf("🃏 **UNO Status**\nDiscard pile: %s\nCurrent turn: %s\n\n", sb.WriteString(fmt.Sprintf("🃏 **UNO Status**\nDiscard pile: %s\nCurrent turn: %s\n\n",
game.discardTop.DisplayWithColor(game.topColor), currentName)) game.discardTop.DisplayWithColor(game.topColor), currentName))
@@ -2725,11 +2762,7 @@ func (p *UnoPlugin) sendMultiStatus(game *unoMultiGame, player *unoMultiPlayer)
if pl == player || !pl.active { if pl == player || !pl.active {
continue continue
} }
name := p.DisplayName(pl.userID) counts = append(counts, fmt.Sprintf("%s (%d)", p.multiName(pl), len(pl.hand)))
if pl.isBot {
name = bn
}
counts = append(counts, fmt.Sprintf("%s (%d)", name, len(pl.hand)))
} }
sb.WriteString(strings.Join(counts, " | ")) sb.WriteString(strings.Join(counts, " | "))
@@ -2752,16 +2785,11 @@ func (p *UnoPlugin) sendMultiHandDisplay(game *unoMultiGame, player *unoMultiPla
// Card counts for all opponents // Card counts for all opponents
sb.WriteString("\nCard counts: ") sb.WriteString("\nCard counts: ")
counts := make([]string, 0) counts := make([]string, 0)
bn := unoBotName()
for _, pl := range game.players { for _, pl := range game.players {
if pl == player || !pl.active { if pl == player || !pl.active {
continue continue
} }
name := p.DisplayName(pl.userID) counts = append(counts, fmt.Sprintf("%s (%d)", p.multiName(pl), len(pl.hand)))
if pl.isBot {
name = bn
}
counts = append(counts, fmt.Sprintf("%s (%d)", name, len(pl.hand)))
} }
sb.WriteString(strings.Join(counts, " | ")) sb.WriteString(strings.Join(counts, " | "))
@@ -2790,16 +2818,11 @@ func (p *UnoPlugin) sendMultiHandDisplayStacking(game *unoMultiGame, player *uno
sb.WriteString("\nCard counts: ") sb.WriteString("\nCard counts: ")
counts := make([]string, 0) counts := make([]string, 0)
bn := unoBotName()
for _, pl := range game.players { for _, pl := range game.players {
if pl == player || !pl.active { if pl == player || !pl.active {
continue continue
} }
name := p.DisplayName(pl.userID) counts = append(counts, fmt.Sprintf("%s (%d)", p.multiName(pl), len(pl.hand)))
if pl.isBot {
name = bn
}
counts = append(counts, fmt.Sprintf("%s (%d)", name, len(pl.hand)))
} }
sb.WriteString(strings.Join(counts, " | ")) sb.WriteString(strings.Join(counts, " | "))
@@ -2840,10 +2863,7 @@ func (p *UnoPlugin) handleMultiSwapChoice(game *unoMultiGame, player *unoMultiPl
game.phase = unoMultiPhasePlay game.phase = unoMultiPhasePlay
name := p.DisplayName(player.userID) name := p.DisplayName(player.userID)
targetName := p.DisplayName(target.userID) targetName := p.multiName(target)
if target.isBot {
targetName = unoBotName()
}
p.SendMessage(player.dmRoomID, fmt.Sprintf("Swapped hands with %s! You now have %d cards.", targetName, len(player.hand))) p.SendMessage(player.dmRoomID, fmt.Sprintf("Swapped hands with %s! You now have %d cards.", targetName, len(player.hand)))
if !target.isBot { if !target.isBot {

View File

@@ -201,10 +201,7 @@ func (p *UnoPlugin) checkMultiMercyElimination(game *unoMultiGame, player *unoMu
} }
player.active = false player.active = false
name := p.DisplayName(player.userID) name := p.multiName(player)
if player.isBot {
name = unoBotName()
}
// Shuffle cards back into draw pile // Shuffle cards back into draw pile
game.drawPile = append(game.drawPile, player.hand...) game.drawPile = append(game.drawPile, player.hand...)
@@ -226,7 +223,7 @@ func (p *UnoPlugin) checkMultiMercyElimination(game *unoMultiGame, player *unoMu
if len(active) == 1 { if len(active) == 1 {
winner := active[0] winner := active[0]
if winner.isBot { if winner.isBot {
p.multiBotWins(game) p.multiBotWins(game, winner)
} else { } else {
p.multiPlayerWins(game, winner) p.multiPlayerWins(game, winner)
} }