mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Add multiplayer UNO, room sentiment tracking, and audit fixes
- Multiplayer UNO: lobby system (2-4 humans + bot), DM-based turns, 30s auto-play timer, winner-takes-all pot, forfeit handling - Solo UNO: refactor bot AI to shared standalone functions, support starting games from DM, passive UNO call system - Room sentiment: new room_sentiment_stats table and !roomsentiment command showing per-room sentiment breakdown with percentages - Stop purging llm_classifications (retain indefinitely for analytics) - Fix multiplayer UNO audit issues: dead code in initMultiGame, double game.turns++ on human plays, missing !uno status command - Update README with UNO commands, config, and architecture Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -51,8 +51,13 @@ BLACKJACK_AUTOPLAY_THRESHOLD=15
|
||||
BLACKJACK_MIN_BET=1
|
||||
BLACKJACK_MAX_BET=500
|
||||
BLACKJACK_DEBT_LIMIT=1000 # max debt before betting disabled
|
||||
UNO_MIN_BET=10 # minimum wager in euros
|
||||
UNO_MIN_BET=10 # minimum wager in euros (solo)
|
||||
UNO_POT_TAUNT_THRESHOLD=500 # pot size at which GogoBee starts taunting
|
||||
UNO_MULTI_MIN_BET=25 # minimum ante for multiplayer UNO
|
||||
UNO_MULTI_MAX_BET=500 # maximum ante for multiplayer UNO
|
||||
UNO_MULTI_LOBBY_TIMEOUT=300 # lobby expiry in seconds (default 300 = 5min)
|
||||
UNO_MULTI_TURN_TIMEOUT=30 # auto-play timeout in seconds (default 30)
|
||||
UNO_MULTI_MAX_AUTOPLAY=3 # consecutive auto-plays before forfeit
|
||||
|
||||
# ---- Space Groups (automatic room grouping for community-wide leaderboards) ----
|
||||
SPACE_GROUP_THRESHOLD=50 # % of smaller room's members that must overlap (1-100, default 50)
|
||||
|
||||
21
README.md
21
README.md
@@ -1,6 +1,6 @@
|
||||
# GogoBee
|
||||
|
||||
Matrix community bot with E2EE, 37 plugins, passive tracking, scheduled posts, and optional LLM features.
|
||||
Matrix community bot with E2EE, 43 plugins, passive tracking, scheduled posts, and optional LLM features.
|
||||
|
||||
Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption and [modernc.org/sqlite](https://modernc.org/sqlite) for storage.
|
||||
|
||||
@@ -29,8 +29,8 @@ Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption a
|
||||
|
||||
- **E2EE that actually works** - mautrix-go with goolm (pure Go). Crypto state lives in SQLite so device keys survive restarts. Cross-signing bootstraps on first run — the bot self-verifies its own device.
|
||||
- **No CGo, no system deps** - builds to a single static binary. Cross-compile to whatever you want.
|
||||
- **42 plugins** with dependency injection and ordered registration
|
||||
- **Games & economy** - Euro virtual currency, Hangman (collaborative, tiered scoring), Blackjack (1-2 players, auto-play timeout), all with channel restriction
|
||||
- **43 plugins** with dependency injection and ordered registration
|
||||
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring), Blackjack (1-2 players, auto-play timeout), UNO (solo vs bot or 2–4 player multiplayer via DMs), all with channel restriction
|
||||
- **Moderation system** (optional) - deterministic detection only, no LLM. Word list with leetspeak variation matching, text/image flood, repeated messages, mention flooding, link rate limiting, invite flooding, join/leave cycling. Three-strike ladder (warn → mute → ban). Admin room notifications, DMs over public callouts.
|
||||
- **Passive tracking** - XP, stats, streaks, achievements, markov corpus, keyword alerts, all running silently
|
||||
- **Scheduled posts** via [robfig/cron](https://github.com/robfig/cron) - WOTD, holidays, game releases, birthdays, anime/movie releases, concert digests, esteemed members
|
||||
@@ -156,6 +156,13 @@ Everything is configured through environment variables or a `.env` file.
|
||||
| `BLACKJACK_MIN_BET` | `1` | Minimum bet in euros |
|
||||
| `BLACKJACK_MAX_BET` | `500` | Maximum bet per hand |
|
||||
| `BLACKJACK_DEBT_LIMIT` | `1000` | Maximum debt before betting disabled |
|
||||
| `UNO_MIN_BET` | `10` | Minimum wager in euros (solo) |
|
||||
| `UNO_POT_TAUNT_THRESHOLD` | `500` | Pot size at which GogoBee starts taunting |
|
||||
| `UNO_MULTI_MIN_BET` | `25` | Minimum ante for multiplayer UNO |
|
||||
| `UNO_MULTI_MAX_BET` | `500` | Maximum ante for multiplayer UNO |
|
||||
| `UNO_MULTI_LOBBY_TIMEOUT` | `300` | Lobby expiry in seconds |
|
||||
| `UNO_MULTI_TURN_TIMEOUT` | `30` | Auto-play timeout in seconds |
|
||||
| `UNO_MULTI_MAX_AUTOPLAY` | `3` | Consecutive auto-plays before forfeit |
|
||||
|
||||
### Moderation
|
||||
|
||||
@@ -296,6 +303,12 @@ Rep is earned when someone thanks you. The bot detects this automatically.
|
||||
| `!stand` | End your turn |
|
||||
| `!blackjack leave` | Leave before game starts |
|
||||
| `!bjboard` | Blackjack leaderboard |
|
||||
| `!uno €amount` | Start a solo UNO game vs GogoBee (played in DMs) |
|
||||
| `!uno start €amount` | Create a multiplayer UNO lobby |
|
||||
| `!uno join` | Join an open UNO lobby |
|
||||
| `!uno go` | Start the game (host only, 2+ players required) |
|
||||
| `!uno leave` | Leave the lobby (refunds ante) |
|
||||
| `!uno cancel` | Cancel the lobby (host/admin, refunds all) |
|
||||
|
||||
### Reminders
|
||||
| Command | Description |
|
||||
@@ -620,6 +633,8 @@ gogobee/
|
||||
│ │ ├── flip.go # Coin flip, !games
|
||||
│ │ ├── hangman.go # Collaborative Hangman
|
||||
│ │ ├── blackjack.go # Multiplayer Blackjack
|
||||
│ │ ├── uno.go # Solo UNO vs bot (DM-based)
|
||||
│ │ ├── uno_multi.go # Multiplayer UNO (lobby + DM turns)
|
||||
│ │ ├── esteemed.go # Satirical esteemed member posts
|
||||
│ │ ├── moderation.go # Moderation system (strikes, word list, flood detection)
|
||||
│ │ └── ratelimits.go # Rate limiting
|
||||
|
||||
@@ -150,9 +150,6 @@ func RunMaintenance() {
|
||||
{"wotd_log", `DELETE FROM wotd_log WHERE date < ?`, []interface{}{date90d}},
|
||||
{"wotd_usage", `DELETE FROM wotd_usage WHERE date < ?`, []interface{}{date90d}},
|
||||
|
||||
// LLM classifications — keep 30 days
|
||||
{"llm_classifications", `DELETE FROM llm_classifications WHERE timestamp < ?`, []interface{}{cutoff30d}},
|
||||
|
||||
// Daily activity older than 1 year
|
||||
{"daily_activity", `DELETE FROM daily_activity WHERE date < ?`, []interface{}{now.AddDate(-1, 0, 0).Format("2006-01-02")}},
|
||||
}
|
||||
@@ -589,6 +586,21 @@ CREATE TABLE IF NOT EXISTS sentiment_stats (
|
||||
total_score REAL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_sentiment_stats (
|
||||
room_id TEXT PRIMARY KEY,
|
||||
positive INTEGER DEFAULT 0,
|
||||
negative INTEGER DEFAULT 0,
|
||||
neutral INTEGER DEFAULT 0,
|
||||
excited INTEGER DEFAULT 0,
|
||||
sarcastic INTEGER DEFAULT 0,
|
||||
frustrated INTEGER DEFAULT 0,
|
||||
curious INTEGER DEFAULT 0,
|
||||
grateful INTEGER DEFAULT 0,
|
||||
humorous INTEGER DEFAULT 0,
|
||||
supportive INTEGER DEFAULT 0,
|
||||
total_score REAL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Daily prefetch tracking
|
||||
CREATE TABLE IF NOT EXISTS daily_prefetch (
|
||||
job_name TEXT NOT NULL,
|
||||
@@ -697,6 +709,20 @@ CREATE TABLE IF NOT EXISTS uno_games (
|
||||
ended_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- Uno multiplayer
|
||||
CREATE TABLE IF NOT EXISTS uno_multi_games (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id TEXT NOT NULL,
|
||||
ante REAL NOT NULL,
|
||||
pot_total REAL NOT NULL,
|
||||
winner_id TEXT NOT NULL,
|
||||
player_ids TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
turns INTEGER NOT NULL,
|
||||
started_at DATETIME NOT NULL,
|
||||
ended_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- Space groups (rooms with overlapping membership)
|
||||
CREATE TABLE IF NOT EXISTS space_groups (
|
||||
room_id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -107,6 +107,7 @@ func (p *LLMPassivePlugin) Commands() []CommandDef {
|
||||
{Name: "insults", Description: "Show insult stats for a user", Usage: "!insults [@user]", Category: "LLM & Sentiment"},
|
||||
{Name: "insultboard", Description: "Top 10 most insulted users", Usage: "!insultboard", Category: "LLM & Sentiment"},
|
||||
{Name: "sentiment", Description: "Show sentiment stats for a user", Usage: "!sentiment [@user]", Category: "LLM & Sentiment"},
|
||||
{Name: "roomsentiment", Description: "Show sentiment breakdown for this room", Usage: "!roomsentiment", Category: "LLM & Sentiment"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +138,9 @@ func (p *LLMPassivePlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "insultboard") {
|
||||
return p.handleInsultboard(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "roomsentiment") {
|
||||
return p.handleRoomSentiment(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "sentiment") {
|
||||
return p.handleSentiment(ctx)
|
||||
}
|
||||
@@ -364,6 +368,15 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
||||
string(item.UserID), result.SentimentScore, result.SentimentScore,
|
||||
)
|
||||
|
||||
// Aggregate room sentiment stats
|
||||
_, _ = d.Exec(
|
||||
fmt.Sprintf(
|
||||
`INSERT INTO room_sentiment_stats (room_id, %s, total_score) VALUES (?, 1, ?)
|
||||
ON CONFLICT(room_id) DO UPDATE SET %s = %s + 1, total_score = total_score + ?`,
|
||||
sentimentCol, sentimentCol, sentimentCol),
|
||||
string(item.RoomID), result.SentimentScore, result.SentimentScore,
|
||||
)
|
||||
|
||||
// Track profanity with severity
|
||||
if result.Profanity {
|
||||
severity := result.ProfanitySeverity
|
||||
@@ -781,3 +794,68 @@ func (p *LLMPassivePlugin) handleSentiment(ctx MessageContext) error {
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *LLMPassivePlugin) handleRoomSentiment(ctx MessageContext) error {
|
||||
d := db.Get()
|
||||
var positive, negative, neutral, excited, sarcastic, frustrated, curious, grateful, humorous, supportive int
|
||||
var totalScore float64
|
||||
err := d.QueryRow(
|
||||
`SELECT COALESCE(positive, 0), COALESCE(negative, 0), COALESCE(neutral, 0),
|
||||
COALESCE(excited, 0), COALESCE(sarcastic, 0), COALESCE(frustrated, 0),
|
||||
COALESCE(curious, 0), COALESCE(grateful, 0), COALESCE(humorous, 0),
|
||||
COALESCE(supportive, 0), COALESCE(total_score, 0)
|
||||
FROM room_sentiment_stats WHERE room_id = ?`,
|
||||
string(ctx.RoomID),
|
||||
).Scan(&positive, &negative, &neutral, &excited, &sarcastic, &frustrated,
|
||||
&curious, &grateful, &humorous, &supportive, &totalScore)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No sentiment data for this room yet.")
|
||||
}
|
||||
|
||||
total := positive + negative + neutral + excited + sarcastic + frustrated + curious + grateful + humorous + supportive
|
||||
avgScore := 0.0
|
||||
if total > 0 {
|
||||
avgScore = totalScore / float64(total)
|
||||
}
|
||||
|
||||
mood := "neutral"
|
||||
if avgScore > 0.3 {
|
||||
mood = "mostly positive"
|
||||
} else if avgScore > 0.1 {
|
||||
mood = "leaning positive"
|
||||
} else if avgScore < -0.3 {
|
||||
mood = "mostly negative"
|
||||
} else if avgScore < -0.1 {
|
||||
mood = "leaning negative"
|
||||
}
|
||||
|
||||
type sentEntry struct {
|
||||
emoji string
|
||||
label string
|
||||
count int
|
||||
}
|
||||
entries := []sentEntry{
|
||||
{"👍", "Positive", positive},
|
||||
{"🔥", "Excited", excited},
|
||||
{"🤗", "Supportive", supportive},
|
||||
{"💜", "Grateful", grateful},
|
||||
{"😂", "Humorous", humorous},
|
||||
{"🧐", "Curious", curious},
|
||||
{"😐", "Neutral", neutral},
|
||||
{"🤨", "Sarcastic", sarcastic},
|
||||
{"😮\u200d💨", "Frustrated", frustrated},
|
||||
{"👎", "Negative", negative},
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("**Room Sentiment:**\n")
|
||||
for _, e := range entries {
|
||||
if e.count > 0 {
|
||||
pct := float64(e.count) / float64(total) * 100
|
||||
sb.WriteString(fmt.Sprintf(" %s %s: %s (%.0f%%)\n", e.emoji, e.label, formatNumber(e.count), pct))
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\n%s messages classified | Average mood: %.2f (%s)", formatNumber(total), avgScore, mood))
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
@@ -334,10 +334,15 @@ type UnoPlugin struct {
|
||||
euro *EuroPlugin
|
||||
|
||||
mu sync.Mutex
|
||||
games map[id.UserID]*unoGame // one game per player
|
||||
games map[id.UserID]*unoGame // solo: one game per player
|
||||
|
||||
// reverse lookup: DM room -> player
|
||||
// reverse lookup: DM room -> player (solo)
|
||||
dmToPlayer map[id.RoomID]id.UserID
|
||||
|
||||
// Multiplayer
|
||||
lobbies map[id.RoomID]*unoMultiLobby // one lobby per room
|
||||
multiGames map[string]*unoMultiGame // game ID -> active game
|
||||
dmToMulti map[id.RoomID]string // DM room -> game ID
|
||||
}
|
||||
|
||||
func NewUnoPlugin(client *mautrix.Client, euro *EuroPlugin) *UnoPlugin {
|
||||
@@ -346,6 +351,9 @@ func NewUnoPlugin(client *mautrix.Client, euro *EuroPlugin) *UnoPlugin {
|
||||
euro: euro,
|
||||
games: make(map[id.UserID]*unoGame),
|
||||
dmToPlayer: make(map[id.RoomID]id.UserID),
|
||||
lobbies: make(map[id.RoomID]*unoMultiLobby),
|
||||
multiGames: make(map[string]*unoMultiGame),
|
||||
dmToMulti: make(map[id.RoomID]string),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +361,7 @@ func (p *UnoPlugin) Name() string { return "uno" }
|
||||
|
||||
func (p *UnoPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "uno", Description: "Challenge the bot to Uno", Usage: "!uno €amount", Category: "Games"},
|
||||
{Name: "uno", Description: "Solo or multiplayer Uno", Usage: "!uno €amount | !uno start €amount | !uno join | !uno go", Category: "Games"},
|
||||
{Name: "uno_pot", Description: "Show the community pot balance", Usage: "!uno_pot", Category: "Games"},
|
||||
}
|
||||
}
|
||||
@@ -368,30 +376,57 @@ func (p *UnoPlugin) OnMessage(ctx MessageContext) error {
|
||||
return p.handlePotCheck(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "uno") {
|
||||
if !isGamesRoom(ctx.RoomID) {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "uno"))
|
||||
lower := strings.ToLower(args)
|
||||
|
||||
// Multiplayer subcommands
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "start "):
|
||||
return p.handleMultiStart(ctx, strings.TrimSpace(args[6:]))
|
||||
case lower == "join":
|
||||
return p.handleMultiJoin(ctx)
|
||||
case lower == "go":
|
||||
return p.handleMultiGo(ctx)
|
||||
case lower == "leave":
|
||||
return p.handleMultiLeave(ctx)
|
||||
case lower == "cancel":
|
||||
return p.handleMultiCancel(ctx)
|
||||
}
|
||||
|
||||
// Solo challenge: !uno €amount
|
||||
if isGamesRoom(ctx.RoomID) {
|
||||
return p.handleChallenge(ctx, ctx.RoomID)
|
||||
}
|
||||
// Allow starting from DM — announce to games room
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Uno is only available in the games channel!")
|
||||
if gr == "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return p.handleChallenge(ctx)
|
||||
return p.handleChallenge(ctx, id.RoomID(gr))
|
||||
}
|
||||
|
||||
// DM gameplay — check if this room is a known DM game room
|
||||
// DM gameplay — check solo games first, then multiplayer
|
||||
p.mu.Lock()
|
||||
playerID, isDM := p.dmToPlayer[ctx.RoomID]
|
||||
if !isDM || playerID != ctx.Sender {
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
playerID, isSoloDM := p.dmToPlayer[ctx.RoomID]
|
||||
if isSoloDM && playerID == ctx.Sender {
|
||||
game := p.games[playerID]
|
||||
if game == nil || game.done {
|
||||
if game != nil && !game.done {
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
return p.handleDMInput(ctx, game)
|
||||
}
|
||||
}
|
||||
|
||||
gameID, isMultiDM := p.dmToMulti[ctx.RoomID]
|
||||
if isMultiDM {
|
||||
mg := p.multiGames[gameID]
|
||||
if mg != nil && !mg.done {
|
||||
p.mu.Unlock()
|
||||
return p.handleMultiDMInput(ctx, mg)
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
return p.handleDMInput(ctx, game)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -452,7 +487,11 @@ func (p *UnoPlugin) handlePotCheck(ctx MessageContext) error {
|
||||
// Challenge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (p *UnoPlugin) handleChallenge(ctx MessageContext) error {
|
||||
func (p *UnoPlugin) handleChallenge(ctx MessageContext, announceRoom id.RoomID) error {
|
||||
reply := func(text string) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, text)
|
||||
}
|
||||
|
||||
args := p.GetArgs(ctx.Body, "uno")
|
||||
amountStr := strings.TrimPrefix(strings.TrimSpace(args), "€")
|
||||
var amount float64
|
||||
@@ -460,15 +499,14 @@ func (p *UnoPlugin) handleChallenge(ctx MessageContext) error {
|
||||
|
||||
minBet := envFloat("UNO_MIN_BET", 10)
|
||||
if amount < minBet {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Minimum wager is €%d. Usage: `!uno €amount`", int(minBet)))
|
||||
return reply(fmt.Sprintf("Minimum wager is €%d. Usage: `!uno €amount`", int(minBet)))
|
||||
}
|
||||
|
||||
// Hold lock for check-and-reserve to prevent TOCTOU double-challenge
|
||||
p.mu.Lock()
|
||||
if _, active := p.games[ctx.Sender]; active {
|
||||
p.mu.Unlock()
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You already have an Uno game in progress!")
|
||||
return reply("You already have an Uno game in progress!")
|
||||
}
|
||||
// Reserve the slot with a placeholder so concurrent challenges are blocked
|
||||
p.games[ctx.Sender] = &unoGame{done: true} // placeholder
|
||||
@@ -479,7 +517,7 @@ func (p *UnoPlugin) handleChallenge(ctx MessageContext) error {
|
||||
p.mu.Lock()
|
||||
delete(p.games, ctx.Sender)
|
||||
p.mu.Unlock()
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Insufficient balance for that wager.")
|
||||
return reply("Insufficient balance for that wager.")
|
||||
}
|
||||
|
||||
// Get DM room
|
||||
@@ -489,11 +527,11 @@ func (p *UnoPlugin) handleChallenge(ctx MessageContext) error {
|
||||
p.mu.Lock()
|
||||
delete(p.games, ctx.Sender)
|
||||
p.mu.Unlock()
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't open a DM with you. Make sure you accept DMs from the bot.")
|
||||
return reply("Couldn't open a DM with you. Make sure you accept DMs from the bot.")
|
||||
}
|
||||
|
||||
// Initialize game
|
||||
game := p.initGame(ctx.Sender, ctx.RoomID, dmRoom, amount)
|
||||
game := p.initGame(ctx.Sender, announceRoom, dmRoom, amount)
|
||||
|
||||
p.mu.Lock()
|
||||
p.games[ctx.Sender] = game
|
||||
@@ -503,7 +541,7 @@ func (p *UnoPlugin) handleChallenge(ctx MessageContext) error {
|
||||
// Room announcement
|
||||
playerName := p.unoDisplayName(ctx.Sender)
|
||||
botName := unoBotName()
|
||||
p.SendMessage(ctx.RoomID, fmt.Sprintf(
|
||||
p.SendMessage(announceRoom, fmt.Sprintf(
|
||||
"🃏 **%s** has challenged %s to Uno! Stakes: €%d\n\n%s\n\n[Check your DMs to play.]",
|
||||
playerName, botName, int(amount), pickCommentary("start"),
|
||||
))
|
||||
@@ -954,13 +992,15 @@ func (p *UnoPlugin) botPlaysCard(game *unoGame, card unoCard) error {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bot AI
|
||||
// Bot AI (shared between solo and multiplayer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (p *UnoPlugin) botChooseCard(game *unoGame) (unoCard, int) {
|
||||
// botPickCard selects the best card to play from the given hand.
|
||||
// opponentMinCards is the smallest hand size among opponents.
|
||||
func botPickCard(hand []unoCard, discardTop unoCard, topColor unoColor, bookDown bool, opponentMinCards int) (unoCard, int) {
|
||||
var playable []int
|
||||
for i, c := range game.botHand {
|
||||
if c.canPlayOn(game.discardTop, game.topColor) {
|
||||
for i, c := range hand {
|
||||
if c.canPlayOn(discardTop, topColor) {
|
||||
playable = append(playable, i)
|
||||
}
|
||||
}
|
||||
@@ -969,17 +1009,17 @@ func (p *UnoPlugin) botChooseCard(game *unoGame) (unoCard, int) {
|
||||
return unoCard{}, -1
|
||||
}
|
||||
|
||||
if game.bookDown {
|
||||
return p.botChooseAggressive(game, playable)
|
||||
if bookDown {
|
||||
return botPickAggressive(hand, playable)
|
||||
}
|
||||
return p.botChooseNormal(game, playable)
|
||||
return botPickNormal(hand, topColor, playable, opponentMinCards)
|
||||
}
|
||||
|
||||
func (p *UnoPlugin) botChooseNormal(game *unoGame, playable []int) (unoCard, int) {
|
||||
func botPickNormal(hand []unoCard, topColor unoColor, playable []int, opponentMinCards int) (unoCard, int) {
|
||||
var actions, numbers, wd4s []int
|
||||
|
||||
for _, i := range playable {
|
||||
c := game.botHand[i]
|
||||
c := hand[i]
|
||||
switch {
|
||||
case c.Value == unoWildDrawFour:
|
||||
wd4s = append(wd4s, i)
|
||||
@@ -990,47 +1030,46 @@ func (p *UnoPlugin) botChooseNormal(game *unoGame, playable []int) (unoCard, int
|
||||
}
|
||||
}
|
||||
|
||||
// Save WD4 unless player has 2-3 cards
|
||||
if len(game.playerHand) > 3 {
|
||||
// Save WD4 unless opponent is close to winning
|
||||
if opponentMinCards > 3 {
|
||||
if len(actions) > 0 {
|
||||
for _, i := range actions {
|
||||
if game.botHand[i].Color == game.topColor {
|
||||
return game.botHand[i], i
|
||||
if hand[i].Color == topColor {
|
||||
return hand[i], i
|
||||
}
|
||||
}
|
||||
idx := actions[0]
|
||||
return game.botHand[idx], idx
|
||||
return hand[idx], idx
|
||||
}
|
||||
if len(numbers) > 0 {
|
||||
for _, i := range numbers {
|
||||
if game.botHand[i].Color == game.topColor {
|
||||
return game.botHand[i], i
|
||||
if hand[i].Color == topColor {
|
||||
return hand[i], i
|
||||
}
|
||||
}
|
||||
idx := numbers[0]
|
||||
return game.botHand[idx], idx
|
||||
return hand[idx], idx
|
||||
}
|
||||
}
|
||||
|
||||
// Player close to winning or no other choice
|
||||
// Opponent close to winning or no other choice
|
||||
if len(wd4s) > 0 {
|
||||
idx := wd4s[0]
|
||||
return game.botHand[idx], idx
|
||||
return hand[idx], idx
|
||||
}
|
||||
if len(actions) > 0 {
|
||||
idx := actions[0]
|
||||
return game.botHand[idx], idx
|
||||
return hand[idx], idx
|
||||
}
|
||||
// Must have numbers (playable is non-empty and every card goes into exactly one bucket)
|
||||
idx := numbers[0]
|
||||
return game.botHand[idx], idx
|
||||
return hand[idx], idx
|
||||
}
|
||||
|
||||
func (p *UnoPlugin) botChooseAggressive(game *unoGame, playable []int) (unoCard, int) {
|
||||
func botPickAggressive(hand []unoCard, playable []int) (unoCard, int) {
|
||||
var wd4s, actions, numbers []int
|
||||
|
||||
for _, i := range playable {
|
||||
c := game.botHand[i]
|
||||
c := hand[i]
|
||||
switch {
|
||||
case c.Value == unoWildDrawFour:
|
||||
wd4s = append(wd4s, i)
|
||||
@@ -1043,19 +1082,19 @@ func (p *UnoPlugin) botChooseAggressive(game *unoGame, playable []int) (unoCard,
|
||||
|
||||
if len(wd4s) > 0 {
|
||||
idx := wd4s[0]
|
||||
return game.botHand[idx], idx
|
||||
return hand[idx], idx
|
||||
}
|
||||
if len(actions) > 0 {
|
||||
idx := actions[0]
|
||||
return game.botHand[idx], idx
|
||||
return hand[idx], idx
|
||||
}
|
||||
idx := numbers[0]
|
||||
return game.botHand[idx], idx
|
||||
return hand[idx], idx
|
||||
}
|
||||
|
||||
func (p *UnoPlugin) botChooseColor(game *unoGame) unoColor {
|
||||
func botPickColor(hand []unoCard) unoColor {
|
||||
counts := map[unoColor]int{}
|
||||
for _, c := range game.botHand {
|
||||
for _, c := range hand {
|
||||
if c.Color != unoWild {
|
||||
counts[c.Color]++
|
||||
}
|
||||
@@ -1072,6 +1111,15 @@ func (p *UnoPlugin) botChooseColor(game *unoGame) unoColor {
|
||||
return best
|
||||
}
|
||||
|
||||
// Solo wrappers for backward compatibility
|
||||
func (p *UnoPlugin) botChooseCard(game *unoGame) (unoCard, int) {
|
||||
return botPickCard(game.botHand, game.discardTop, game.topColor, game.bookDown, len(game.playerHand))
|
||||
}
|
||||
|
||||
func (p *UnoPlugin) botChooseColor(game *unoGame) unoColor {
|
||||
return botPickColor(game.botHand)
|
||||
}
|
||||
|
||||
// afterBotTurn is called when the bot's turn was skipped (player played skip/reverse/draw two).
|
||||
// Shows the hand display for the player's next turn.
|
||||
func (p *UnoPlugin) afterBotTurn(game *unoGame) error {
|
||||
|
||||
1656
internal/plugin/uno_multi.go
Normal file
1656
internal/plugin/uno_multi.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user