From 9c6ded13fafcae48b59a59f44625845f1b17f861 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:22:02 -0700 Subject: [PATCH] Add forex plugin, stability fixes, and async HTTP dispatch - Add forex plugin (Frankfurter v2 API) with rate lookups, analysis, DM-based alerts, and daily cron poll. Backfills 1 year of history on startup for moving averages and buy signal scoring. - Fix bot hang caused by SQLite lock contention in reminder polling: rows cursor was held open while writing to the same DB. Collect results first, close cursor, then process. Same fix in milkcarton. - Add sync retry loop so the bot reconnects after network drops instead of silently exiting. StopSync() for clean Ctrl+C shutdown. - Add panic recovery to all dispatch, syncer, and cron paths. - Make all HTTP-calling plugin commands async (goroutines) so a slow or dead external API cannot block the message dispatch pipeline. Affects: lookup, stocks, forex, anime, movies, concerts, gaming, retro, wotd, urls, howami. - Extract DisplayName to Base, add db.Exec helper, convert silent error discards across the codebase. - Fix UNO mercy-kill bug (eliminated bot continues playing), adventure DM nag spam, stats column mismatch, per-call regex/replacer allocs. - Update README: forex commands, Finance section, 47 plugins. Co-Authored-By: Claude Opus 4.6 --- README.md | 94 +- internal/bot/dispatch.go | 51 +- internal/db/db.go | 62 +- internal/plugin/achievements.go | 15 + internal/plugin/adventure.go | 173 ++-- internal/plugin/adventure_activities.go | 13 +- internal/plugin/adventure_character.go | 21 +- internal/plugin/adventure_events.go | 355 ++++++++ internal/plugin/adventure_flavor_closing.go | 204 +++++ internal/plugin/adventure_flavor_events.go | 854 +++++++++++++++++++ internal/plugin/adventure_flavor_treasure.go | 9 + internal/plugin/adventure_flavor_twinbee.go | 2 +- internal/plugin/adventure_render.go | 115 ++- internal/plugin/adventure_scheduler.go | 15 +- internal/plugin/adventure_shop.go | 192 ++++- internal/plugin/adventure_test.go | 302 +++++++ internal/plugin/adventure_treasure.go | 2 +- internal/plugin/adventure_twinbee.go | 117 ++- internal/plugin/anime.go | 47 +- internal/plugin/birthday.go | 10 +- internal/plugin/blackjack.go | 40 +- internal/plugin/blackjack_test.go | 88 ++ internal/plugin/concerts.go | 32 +- internal/plugin/countdown.go | 6 +- internal/plugin/euro.go | 17 +- internal/plugin/flip.go | 14 +- internal/plugin/forex.go | 262 ++++++ internal/plugin/forex_analysis.go | 206 +++++ internal/plugin/forex_api.go | 159 ++++ internal/plugin/forex_store.go | 140 +++ internal/plugin/fun.go | 2 +- internal/plugin/gaming.go | 12 +- internal/plugin/hangman.go | 17 +- internal/plugin/holdem.go | 205 ++++- internal/plugin/holdem_betting.go | 22 +- internal/plugin/holdem_equity.go | 223 +++++ internal/plugin/holdem_eval.go | 17 +- internal/plugin/holdem_eval_test.go | 183 ++++ internal/plugin/holdem_game.go | 8 +- internal/plugin/holdem_render.go | 6 +- internal/plugin/holdem_tips.go | 286 +++++-- internal/plugin/holdem_tips_test.go | 324 +++++++ internal/plugin/howami.go | 35 +- internal/plugin/llm_passive.go | 17 +- internal/plugin/lookup.go | 21 +- internal/plugin/markov.go | 5 +- internal/plugin/milkcarton.go | 24 +- internal/plugin/moderation.go | 6 +- internal/plugin/movies.go | 29 +- internal/plugin/plugin.go | 120 ++- internal/plugin/reactions.go | 6 +- internal/plugin/reminders.go | 27 +- internal/plugin/retro.go | 23 +- internal/plugin/stats.go | 239 ++++++ internal/plugin/stocks.go | 12 +- internal/plugin/streaks.go | 12 +- internal/plugin/uno.go | 24 +- internal/plugin/uno_multi.go | 104 +-- internal/plugin/uno_nomercy.go | 2 +- internal/plugin/uno_test.go | 290 +++++++ internal/plugin/urls.go | 64 +- internal/plugin/wordle.go | 169 +++- internal/plugin/wordle_fallback.go | 81 +- internal/plugin/wordle_game_test.go | 148 ++++ internal/plugin/wordle_render.go | 44 +- internal/plugin/wotd.go | 12 +- internal/plugin/xp.go | 5 +- main.go | 67 +- 68 files changed, 5784 insertions(+), 724 deletions(-) create mode 100644 internal/plugin/adventure_events.go create mode 100644 internal/plugin/adventure_flavor_closing.go create mode 100644 internal/plugin/adventure_flavor_events.go create mode 100644 internal/plugin/adventure_test.go create mode 100644 internal/plugin/blackjack_test.go create mode 100644 internal/plugin/forex.go create mode 100644 internal/plugin/forex_analysis.go create mode 100644 internal/plugin/forex_api.go create mode 100644 internal/plugin/forex_store.go create mode 100644 internal/plugin/holdem_eval_test.go create mode 100644 internal/plugin/holdem_tips_test.go create mode 100644 internal/plugin/uno_test.go create mode 100644 internal/plugin/wordle_game_test.go diff --git a/README.md b/README.md index 4c0deef..202f8c0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GogoBee -Matrix community bot with E2EE, 43 plugins, passive tracking, scheduled posts, and optional LLM features. +Matrix community bot with E2EE, 47 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. @@ -30,11 +30,12 @@ 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. - **43 plugins** with dependency injection and ordered registration -- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 2–4 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips), Wordle (daily cooperative, Wordnik-powered, 5-7 letter words), all with channel restriction +- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 2–4 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips, 1-hour idle auto-close with 45-min warning), Wordle (daily cooperative, Wordnik-powered, 5-7 letter words, video game themed bonus words with category hints, dupe prevention across last 500 puzzles), Adventure (daily idle RPG via DMs — dungeon, mine, forage, shop, or rest with TwinBee NPC distributing level-scaled rewards, mid-day random events, tier shorthand buying), 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 - **LLM integration** (optional) - Ollama-powered sentiment analysis, roast profiles, room vibes, tarot readings, conversation summaries +- **Markdown rendering** - auto-detects `**bold**`, `_italic_`, and `` `code` `` in messages and sends proper HTML to Matrix clients - **Encrypted quote wall** - AES-256-GCM encrypted quotes at rest, reply-to-save, search, leaderboard - **Space groups** - automatic room grouping via membership overlap. Leaderboards, stats, and trivia scores span all rooms in a group. No Matrix Spaces API needed — the bot infers community boundaries from shared members. - **SQLite everything** - one file, no external database needed @@ -106,7 +107,7 @@ Everything is configured through environment variables or a `.env` file. | Variable | Service | Used By | |----------|---------|---------| | `RAWG_API_KEY` | [RAWG](https://rawg.io/apidocs) | `!game`, `!retro`, `!releases` | -| `WORDNIK_API_KEY` | [Wordnik](https://developer.wordnik.com) | Word of the Day | +| `WORDNIK_API_KEY` | [Wordnik](https://developer.wordnik.com) | Word of the Day, Wordle (guess validation + definitions) | | `CALENDARIFIC_API_KEY` | [Calendarific](https://calendarific.com) | Holiday posts | | `OPENWEATHER_API_KEY` | [OpenWeather](https://openweathermap.org/api) | `!weather` | | `FINNHUB_API_KEY` | [Finnhub](https://finnhub.io) | `!stock` | @@ -172,6 +173,8 @@ Everything is configured through environment variables or a `.env` file. | `HOLDEM_NPC_HOUSE_BALANCE` | `10000` | NPC starting bankroll | | `HOLDEM_CFR_POLICY` | `data/policy.gob` | Path to CFR policy file | | `WORDLE_DEFAULT_LENGTH` | `5` | Default word length (5, 6, or 7) | +| `ADVENTURE_MORNING_HOUR` | `8` | Hour (UTC) to send morning DMs with daily choices | +| `ADVENTURE_SUMMARY_HOUR` | `20` | Hour (UTC) to post daily summary to games room | ### Moderation @@ -271,6 +274,7 @@ Rep is earned when someone thanks you. The bot detects this automatically. | Command | Description | |---------|-------------| | `!stats [@user]` | Message statistics | +| `!superstatsexplusalpha [@user]` | Comprehensive profile: economy, games W/L, adventure levels, achievements, and more | | `!rankings [category]` | Rankings by words, links, questions, or emojis | | `!personality` | Your community archetype | @@ -378,8 +382,9 @@ An optional AI opponent (NPC) uses a CFR-trained poker solver. Add one with `!ho | Command | Description | |---------|-------------| | `!holdem join` | Sit down at the table | -| `!holdem leave` | Leave the table (cashes out stack) | +| `!holdem leave` | Leave the table (cashes out stack, shows refund) | | `!holdem start` | Start dealing (2+ players) | +| `!holdem play` | Shortcut: join + start in one command | | `!holdem addbot` | Add a CFR-trained AI opponent | | `!holdem fold` | Fold your hand | | `!holdem check` | Check (no bet to call) | @@ -391,9 +396,13 @@ An optional AI opponent (NPC) uses a CFR-trained poker solver. Add one with `!ho **DM commands:** `!holdem tips on/off` — toggle coaching tips (equity + pot odds analysis, powered by Ollama with rules-based fallback). +**Idle timeout:** Tables with no hands dealt close after 1 hour. A warning is posted at 45 minutes. + #### NPC Bot -The NPC uses Counterfactual Regret Minimization (External Sampling MCCFR), trained via self-play. The policy table ships as `data/policy.gob` and is loaded at startup. The bot plays a mixed strategy — it randomizes actions according to its trained probability distribution, so it won't always make the same play in the same spot. +The NPC opponent uses Counterfactual Regret Minimization (CFR), specifically the External Sampling variant of Monte Carlo CFR. Instead of hardcoding poker strategy, the bot learns one through millions of rounds of self-play. On each iteration, it plays both seats of a heads-up hand, tracks how much it "regrets" not having taken each alternative action, and gradually shifts its strategy toward the actions it regrets not taking. Over time, the strategy converges toward a Nash equilibrium — a point where no single change in play can improve its expected winnings against a perfect opponent. The trained policy ships as a single `.gob` file loaded at startup, and the bot plays a mixed strategy: it doesn't always make the same play in the same spot, but randomizes according to its trained probability distribution, which makes it harder to exploit. + +The interesting engineering is in the abstraction layer. Real poker has roughly 10^17 possible game states — far too many for a tabular approach. We collapse this space down to around 23,000 information sets by bucketing hand strength into 12 equity tiers (computed via Monte Carlo rollout against random opponent hands), classifying boards as dry, wet, or paired based on flush/straight draw density and board pairing, tracking stack-to-pot ratio across 5 buckets, and encoding the last 6 actions as a compressed history string. All of this gets packed into a single `uint64` key — no string formatting, no allocations on the hot path. Preflop hands use a lookup table of the 169 strategically distinct hold'em starting hands, which eliminates Monte Carlo during the most frequently visited part of the game tree. Training runs at about 5 million iterations across all CPU cores with shared regret tables, periodic checkpointing, and regret pruning after a 500K warmup to skip deeply negative branches. The result is a bot that plays a solid, unpredictable game — not world-class, but strong enough to make the table interesting. #### Training the NPC @@ -427,7 +436,7 @@ Progress is logged with overall completion percentage and ETA. Checkpoints are s ### Wordle (games channel only) -Daily cooperative Wordle — one puzzle per day, the community works together with a shared 6-guess limit. Word selection and validation powered by the Wordnik API (`WORDNIK_API_KEY`). A new puzzle auto-posts at midnight UTC. Word length is configurable (5-7 letters). Falls back to a bundled word list if the API is unavailable. +Daily cooperative Wordle — one puzzle per day, the community works together with a shared 6-guess limit. Word selection and validation powered by the Wordnik API (`WORDNIK_API_KEY`). A new puzzle auto-posts at midnight UTC. Word length is configurable (5-7 letters). Falls back to a bundled word list if the API is unavailable. The puzzle pool includes video game themed words (loaded from `data/wordle_games.txt`) — when one is selected, a category hint is shown. Duplicate prevention ensures the same word won't appear within the last 500 puzzles. | Command | Description | |---------|-------------| @@ -441,6 +450,47 @@ Daily cooperative Wordle — one puzzle per day, the community works together wi 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. + +Characters auto-create on first `!adventure` command. All gameplay happens in DMs — reply to the bot's morning prompt with your choice. + +| Command | Description | +|---------|-------------| +| `!adventure` | Open today's action menu (sent via DM) | +| `!adventure status` | Character sheet (sent via DM) | +| `!adventure shop` | Browse equipment for sale (sent via 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 inventory` | List current inventory | +| `!adventure leaderboard` | Top adventurers | +| `!adventure revive @user` | Revive a dead player (admin) | +| `!adventure respond ` | Reply to today's action prompt (alternative to DM reply) | +| `!adventure summary` | Force daily summary post (admin) | + +**DM replies:** Reply to the morning prompt with a number (`1`–`5`) or activity name (`dungeon`, `mine`, `forage`, `shop`, `rest`). You can specify a location: `1 Soggy Cellar`, `mine 3`, etc. + +#### Activities + +Three activity types across 5 tiers of locations (15 total). 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 +- **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. +- **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** — 24-hour lockout. You're automatically revived when the timer expires. +- **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. + ### Reminders | Command | Description | |---------|-------------| @@ -476,14 +526,23 @@ No economy integration — stats and leaderboard position are the reward. | `!calc ` | Calculator, understands "5 plus 3" | | `!qr ` | Generate QR code | -### Games & Entertainment +### Finance +| Command | Description | +|---------|-------------| +| `!stock ` | Stock quote (Finnhub) | +| `!stockwatch add\|list\|remove` | Stock watchlist | +| `!fx rate [EUR\|JPY]` | Live USD exchange rate + quick signal | +| `!fx report [EUR\|JPY]` | Full analysis (averages, 52w range, buy score) | +| `!fx setalert ` | DM alert when rate hits threshold | +| `!fx alerts` | List your active alerts | +| `!fx delalert ` | Remove an alert | + +### Entertainment | Command | Description | |---------|-------------| | `!game ` / `!retro ` | Game lookup (RAWG) | | `!releases [month\|search ]` | Game releases | | `!releasewatch add\|list\|remove` | Release watchlist | -| `!stock ` | Stock quote (Finnhub) | -| `!stockwatch add\|list\|remove` | Stock watchlist | | `!concerts ` | Concert search | | `!concerts watch\|watching\|unwatch` | Concert watchlist | | `!anime ` | Anime search (MAL) | @@ -694,6 +753,7 @@ All optional. The bot works fine without any of them, you just won't have those | [Aladhan](https://aladhan.com/prayer-times-api) | Yes, no key | Islamic dates | | [OpenWeather](https://openweathermap.org/api) | Yes (1k/day) | Weather | | [Finnhub](https://finnhub.io) | Yes | Stock quotes | +| [Frankfurter](https://frankfurter.dev) | Yes, no key | Forex rates (ECB-sourced) | | [Bandsintown](https://artists.bandsintown.com) | Yes | Concert data | | [Jikan/MAL](https://jikan.moe) | Yes, no key | Anime data | | [TMDB](https://www.themoviedb.org) | Yes | Movie/TV data | @@ -716,7 +776,8 @@ gogobee/ ├── main.go # Entry point, plugin registration, cron setup ├── go.mod / go.sum ├── data/ -│ └── policy.gob # Pre-trained CFR policy for Hold'em NPC +│ ├── policy.gob # Pre-trained CFR policy for Hold'em NPC +│ └── wordle_games.txt # Video game themed words for Wordle bonus puzzles ├── cmd/ │ ├── holdem-train/ │ │ └── main.go # CFR training CLI (build tag: training) @@ -756,6 +817,7 @@ gogobee/ │ │ ├── lookup.go # Wiki, dictionary, urban, translate │ │ ├── countdown.go # Countdowns │ │ ├── stocks.go # Stocks +│ │ ├── forex*.go # Forex rates & alerts (Frankfurter v2) │ │ ├── concerts.go # Concerts │ │ ├── anime.go # Anime │ │ ├── movies.go # Movies/TV @@ -787,6 +849,16 @@ gogobee/ │ │ ├── wordle_render.go # Emoji grid, keyboard, announcements, leaderboard │ │ ├── wordle_wordnik.go # Wordnik API (random word, validation, definitions) │ │ ├── wordle_fallback.go # Emergency word list loader +│ │ ├── adventure.go # Adventure plugin (commands, DM routing, activity resolution) +│ │ ├── adventure_character.go # Character types, DB CRUD, level-up, equipment +│ │ ├── adventure_activities.go # Locations, probability engine, loot, XP tables +│ │ ├── adventure_shop.go # Shop listings, buy/sell, inventory +│ │ ├── adventure_treasure.go # Treasure definitions, drop logic, bonuses +│ │ ├── adventure_twinbee.go # TwinBee NPC, reward distribution, buff gifts +│ │ ├── adventure_render.go # Character sheet, DMs, daily summary, leaderboard +│ │ ├── adventure_events.go # Mid-day random events +│ │ ├── adventure_scheduler.go # Morning DM, evening summary, midnight reset tickers +│ │ ├── adventure_flavor_*.go # Flavor text pools (dungeon, mining, treasure, twinbee, events, closing) │ │ ├── esteemed.go # Satirical esteemed member posts │ │ ├── moderation.go # Moderation system (strikes, word list, flood detection) │ │ └── ratelimits.go # Rate limiting @@ -812,7 +884,7 @@ gogobee/ Single SQLite file at `$DATA_DIR/gogobee.db`. Schema auto-creates on first run. WAL mode enabled. -40+ tables covering users, XP, stats, streaks, reputation, reminders, trivia, achievements, encrypted quotes (AES-256-GCM), backlog, keyword watches, scheduler config, birthdays, horoscopes, LLM classifications, stocks, concerts, anime, movies, countdowns, presence, markov corpus, reaction log, and various caches. +40+ tables covering users, XP, stats, streaks, reputation, reminders, trivia, achievements, encrypted quotes (AES-256-GCM), backlog, keyword watches, scheduler config, birthdays, horoscopes, LLM classifications, stocks, forex rates/alerts, concerts, anime, movies, countdowns, presence, markov corpus, reaction log, and various caches. ### Backup diff --git a/internal/bot/dispatch.go b/internal/bot/dispatch.go index ff22e62..e1e4925 100644 --- a/internal/bot/dispatch.go +++ b/internal/bot/dispatch.go @@ -31,9 +31,11 @@ func (r *Registry) Init() error { r.mu.RLock() defer r.mu.RUnlock() for _, p := range r.plugins { + slog.Info("initializing plugin", "name", p.Name()) if err := p.Init(); err != nil { return err } + slog.Info("initialized plugin", "name", p.Name()) } return nil } @@ -43,12 +45,27 @@ func (r *Registry) DispatchMessage(ctx plugin.MessageContext) { r.mu.RLock() defer r.mu.RUnlock() for _, p := range r.plugins { - if err := p.OnMessage(ctx); err != nil { - slog.Error("plugin message handler error", + r.safeOnMessage(p, ctx) + } +} + +func (r *Registry) safeOnMessage(p plugin.Plugin, ctx plugin.MessageContext) { + defer func() { + if rec := recover(); rec != nil { + slog.Error("plugin message handler panic", "plugin", p.Name(), - "err", err, + "panic", rec, + "sender", ctx.Sender, + "room", ctx.RoomID, + "body", truncate(ctx.Body, 100), ) } + }() + if err := p.OnMessage(ctx); err != nil { + slog.Error("plugin message handler error", + "plugin", p.Name(), + "err", err, + ) } } @@ -57,12 +74,27 @@ func (r *Registry) DispatchReaction(ctx plugin.ReactionContext) { r.mu.RLock() defer r.mu.RUnlock() for _, p := range r.plugins { - if err := p.OnReaction(ctx); err != nil { - slog.Error("plugin reaction handler error", + r.safeOnReaction(p, ctx) + } +} + +func (r *Registry) safeOnReaction(p plugin.Plugin, ctx plugin.ReactionContext) { + defer func() { + if rec := recover(); rec != nil { + slog.Error("plugin reaction handler panic", "plugin", p.Name(), - "err", err, + "panic", rec, + "sender", ctx.Sender, + "room", ctx.RoomID, + "emoji", ctx.Emoji, ) } + }() + if err := p.OnReaction(ctx); err != nil { + slog.Error("plugin reaction handler error", + "plugin", p.Name(), + "err", err, + ) } } @@ -77,6 +109,13 @@ func (r *Registry) GetCommands() []plugin.CommandDef { return cmds } +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + // GetPlugin returns a plugin by name. func (r *Registry) GetPlugin(name string) plugin.Plugin { r.mu.RLock() diff --git a/internal/db/db.go b/internal/db/db.go index 0395ff2..3a26449 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -73,14 +73,11 @@ func JobCompleted(jobName, dateKey string) bool { // MarkJobCompleted marks a scheduled job as completed for the given date key. func MarkJobCompleted(jobName, dateKey string) { - _, err := Get().Exec( + Exec("mark job completed", `INSERT INTO daily_prefetch (job_name, date, completed) VALUES (?, ?, 1) ON CONFLICT(job_name, date) DO UPDATE SET completed = 1`, jobName, dateKey, ) - if err != nil { - slog.Error("mark job completed", "job", jobName, "date", dateKey, "err", err) - } } // CacheGet returns cached data for the given key if it exists and is within ttlSeconds. @@ -100,15 +97,11 @@ func CacheGet(key string, ttlSeconds int) string { // CacheSet stores data in the generic API cache. func CacheSet(key, data string) { - d := Get() - _, err := d.Exec( + Exec("cache set", `INSERT INTO api_cache (cache_key, data, cached_at) VALUES (?, ?, unixepoch()) ON CONFLICT(cache_key) DO UPDATE SET data = ?, cached_at = unixepoch()`, key, data, data, ) - if err != nil { - slog.Error("cache set", "key", key, "err", err) - } } // RunMaintenance purges stale data from cache tables, old rate limits, @@ -152,6 +145,9 @@ func RunMaintenance() { // Daily activity older than 1 year {"daily_activity", `DELETE FROM daily_activity WHERE date < ?`, []interface{}{now.AddDate(-1, 0, 0).Format("2006-01-02")}}, + + // Forex rates older than 2 years (analysis needs 52 weeks, keep buffer) + {"forex_rates", `DELETE FROM forex_rates WHERE date < ?`, []interface{}{now.AddDate(-2, 0, 0).Format("2006-01-02")}}, } totalDeleted := int64(0) @@ -176,6 +172,25 @@ func RunMaintenance() { slog.Info("maintenance: complete", "total_purged", totalDeleted) } +// Exec runs a write query, logging any error with the given label. +// Use for fire-and-forget writes where the error doesn't affect control flow. +func Exec(label, query string, args ...interface{}) { + _, err := Get().Exec(query, args...) + if err != nil { + slog.Error("db: "+label, "err", err) + } +} + +// ExecResult runs a write query and returns the result, logging any error. +// Use when you need RowsAffected() or LastInsertId() but still want auto-logging. +func ExecResult(label, query string, args ...interface{}) sql.Result { + res, err := Get().Exec(query, args...) + if err != nil { + slog.Error("db: "+label, "err", err) + } + return res +} + const schema = ` -- Users & XP CREATE TABLE IF NOT EXISTS users ( @@ -922,6 +937,35 @@ CREATE TABLE IF NOT EXISTS adventure_arena_log ( logged_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS adventure_events_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + event_key TEXT NOT NULL, + triggered_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + responded_at DATETIME, + gold_awarded INTEGER NOT NULL DEFAULT 0, + xp_awarded INTEGER NOT NULL DEFAULT 0, + outcome TEXT NOT NULL DEFAULT 'pending' +); +CREATE INDEX IF NOT EXISTS idx_adv_events_user_outcome ON adventure_events_log(user_id, outcome); + +-- Forex +CREATE TABLE IF NOT EXISTS forex_rates ( + currency TEXT NOT NULL, + date TEXT NOT NULL, + rate REAL NOT NULL, + PRIMARY KEY (currency, date) +); + +CREATE TABLE IF NOT EXISTS forex_alerts ( + user_id TEXT NOT NULL, + currency TEXT NOT NULL, + threshold REAL NOT NULL, + fired_at INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, currency, threshold) +); + ` // SeedSchedulerDefaults inserts default scheduler jobs if they don't exist. diff --git a/internal/plugin/achievements.go b/internal/plugin/achievements.go index 42aefe5..e5a7026 100644 --- a/internal/plugin/achievements.go +++ b/internal/plugin/achievements.go @@ -453,7 +453,22 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef { } // statGTE checks if a user_stats column is >= threshold. +var allowedStatColumns = map[string]bool{ + "total_messages": true, + "night_messages": true, + "morning_messages": true, + "total_links": true, + "total_images": true, + "total_questions": true, + "total_emojis": true, + "total_exclamations": true, +} + func statGTE(d *sql.DB, userID id.UserID, column string, threshold int) bool { + if !allowedStatColumns[column] { + slog.Error("statGTE: unknown column", "column", column) + return false + } var val int query := fmt.Sprintf(`SELECT COALESCE(%s, 0) FROM user_stats WHERE user_id = ?`, column) err := d.QueryRow(query, string(userID)).Scan(&val) diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 5d733c5..11dfbde 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -1,7 +1,6 @@ package plugin import ( - "context" "fmt" "log/slog" "math/rand/v2" @@ -20,9 +19,10 @@ type AdventurePlugin struct { Base euro *EuroPlugin mu sync.Mutex - dmToPlayer map[id.RoomID]id.UserID - pending sync.Map // userID string -> *advPendingInteraction - userLocks sync.Map // userID string -> *sync.Mutex + dmToPlayer map[id.RoomID]id.UserID + pending sync.Map // userID string -> *advPendingInteraction + userLocks sync.Map // userID string -> *sync.Mutex + dmRemindedDate sync.Map // userID string -> "2006-01-02" date string morningHour int summaryHour int } @@ -78,6 +78,7 @@ func (p *AdventurePlugin) Init() error { go p.morningTicker() go p.summaryTicker() go p.midnightTicker() + go p.eventTicker() return nil } @@ -101,6 +102,10 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { return nil } + return p.dispatchCommand(ctx) +} + +func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error { args := strings.TrimSpace(p.GetArgs(ctx.Body, "adventure")) lower := strings.ToLower(args) @@ -111,8 +116,8 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { return p.handleStatus(ctx) case strings.HasPrefix(lower, "sell "): return p.handleSellCmd(ctx, strings.TrimSpace(args[5:])) - case lower == "shop": - return p.handleShopCmd(ctx) + case lower == "shop" || strings.HasPrefix(lower, "shop "): + return p.handleShopCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "shop"))) case strings.HasPrefix(lower, "buy "): return p.handleBuyCmd(ctx, strings.TrimSpace(args[4:])) case lower == "inventory" || lower == "inv": @@ -123,11 +128,30 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { return p.handleAdminRevive(ctx, strings.TrimSpace(args[7:])) case lower == "summary": return p.handleAdminSummary(ctx) + case lower == "respond": + return p.handleEventRespond(ctx) + case lower == "help": + return p.SendDM(ctx.Sender, advHelpText) } - return nil + return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.") } +const advHelpText = `**Adventure Commands** + +` + "`!adventure`" + ` — Show today's activity menu +` + "`!adventure status`" + ` — View your character sheet +` + "`!adventure shop`" + ` — Browse equipment categories +` + "`!adventure shop <category>`" + ` — View a category (weapon, armor, helmet, boots, tool) +` + "`!adventure buy <item>`" + ` — Buy equipment (e.g. ` + "`buy Enchanted Blade`" + ` or ` + "`buy 4 sword`" + `) +` + "`!adventure sell <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `) +` + "`!adventure inventory`" + ` — View your inventory +` + "`!adventure leaderboard`" + ` — View the leaderboard +` + "`!adventure respond`" + ` — Respond to a mid-day event +` + "`!adventure help`" + ` — This message + +**In DM:** Reply with a number (e.g. ` + "`1`" + `) or location name to take your daily action.` + // ── Command Handlers ───────────────────────────────────────────────────────── func (p *AdventurePlugin) handleMenu(ctx MessageContext) error { @@ -142,7 +166,16 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error { } if char.ActionTakenToday { - return p.SendDM(ctx.Sender, "You've already taken your action today. Tomorrow awaits. Try to survive it.") + now := time.Now().UTC() + midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC) + remaining := midnight.Sub(now) + hours := int(remaining.Hours()) + minutes := int(remaining.Minutes()) % 60 + return p.SendDM(ctx.Sender, fmt.Sprintf( + "You've already taken your action today. Tomorrow awaits. Try to survive it.\n\n"+ + "Next action: 00:00 UTC (%dh %dm from now)\n"+ + "Morning DM: %02d:00 UTC", + hours, minutes, p.morningHour)) } treasures, _ := loadAdvTreasureBonuses(char.UserID) @@ -169,22 +202,34 @@ func (p *AdventurePlugin) handleStatus(ctx MessageContext) error { return p.SendDM(ctx.Sender, text) } -func (p *AdventurePlugin) handleShopCmd(ctx MessageContext) error { +func (p *AdventurePlugin) handleShopCmd(ctx MessageContext, category string) error { _, equip, err := p.ensureCharacter(ctx.Sender) if err != nil { return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.") } balance := p.euro.GetBalance(ctx.Sender) - text := advShopListings(equip, balance) - return p.SendDM(ctx.Sender, text) + + if category == "" { + return p.SendDM(ctx.Sender, advShopOverview(equip, balance)) + } + + slot := advParseShopCategory(category) + if slot == "" { + return p.SendDM(ctx.Sender, fmt.Sprintf("Unknown category '%s'. Try: weapon, armor, helmet, boots, or tool.", category)) + } + + return p.SendDM(ctx.Sender, advShopCategory(slot, equip, balance)) } func (p *AdventurePlugin) handleBuyCmd(ctx MessageContext, itemName string) error { - _, equip, err := p.ensureCharacter(ctx.Sender) + char, equip, err := p.ensureCharacter(ctx.Sender) if err != nil { return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.") } + if !char.Alive { + return p.SendDM(ctx.Sender, "You're dead. Shopping can wait until you've respawned.") + } slot, def, found := advFindShopItem(itemName) if !found { @@ -196,7 +241,13 @@ func (p *AdventurePlugin) handleBuyCmd(ctx MessageContext, itemName string) erro } func (p *AdventurePlugin) handleSellCmd(ctx MessageContext, args string) error { - p.ensureCharacter(ctx.Sender) + char, _, err := p.ensureCharacter(ctx.Sender) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.") + } + if !char.Alive { + return p.SendDM(ctx.Sender, "You're dead. No haggling from beyond the grave.") + } var result string if strings.ToLower(args) == "all" { @@ -208,7 +259,9 @@ func (p *AdventurePlugin) handleSellCmd(ctx MessageContext, args string) error { } func (p *AdventurePlugin) handleInventoryCmd(ctx MessageContext) error { - p.ensureCharacter(ctx.Sender) + if _, _, err := p.ensureCharacter(ctx.Sender); err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.") + } text := advInventoryDisplay(ctx.Sender) return p.SendDM(ctx.Sender, text) } @@ -270,10 +323,9 @@ func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error { return nil } - // Strip !adventure prefix if present + // Strip !adventure prefix if present — dispatch directly to avoid recursion if strings.HasPrefix(strings.ToLower(body), "!adventure") { - // Re-dispatch as command - return p.OnMessage(ctx) + return p.dispatchCommand(ctx) } // Check for pending interaction first @@ -346,7 +398,23 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string) } if char.ActionTakenToday { - return p.SendDM(ctx.Sender, "You've already taken your action today. Rest now. Try again tomorrow.") + // Only send the reminder once per day — subsequent DM messages + // are silently ignored so they can be handled by other plugins (e.g. UNO). + today := time.Now().UTC().Format("2006-01-02") + if prev, ok := p.dmRemindedDate.Load(string(ctx.Sender)); ok && prev.(string) == today { + return nil + } + p.dmRemindedDate.Store(string(ctx.Sender), today) + + now := time.Now().UTC() + midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC) + remaining := midnight.Sub(now) + hours := int(remaining.Hours()) + minutes := int(remaining.Minutes()) % 60 + return p.SendDM(ctx.Sender, fmt.Sprintf( + "You've already taken your action today. Rest now. Try again tomorrow.\n\n"+ + "Next action: 00:00 UTC (%dh %dm from now)", + hours, minutes)) } lower := strings.ToLower(body) @@ -360,7 +428,7 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string) if lower == "4" || lower == "shop" { equip, _ := loadAdvEquipment(ctx.Sender) balance := p.euro.GetBalance(ctx.Sender) - return p.SendDM(ctx.Sender, advShopListings(equip, balance)) + return p.SendDM(ctx.Sender, advShopOverview(equip, balance)) } // Parse activity + location @@ -486,21 +554,9 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha _ = addAdvInventoryItem(char.UserID, item) } - // Party bonus: check if someone else visited the same location today - if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional { - if advCheckPartyBonus(char.UserID, loc.Name) { - // Apply party bonus: +10% loot value - partyBonus := int64(float64(result.TotalLootValue) * 0.10) - if partyBonus > 0 { - result.TotalLootValue += partyBonus - // Credit the bonus directly - p.euro.Credit(char.UserID, float64(partyBonus), "adventure_party_bonus") - } - } - } - - // Mark action taken + // Mark action taken and record the date for streak tracking char.ActionTakenToday = true + char.LastActionDate = time.Now().UTC().Format("2006-01-02") // Update streak info result.StreakBonus = char.CurrentStreak @@ -520,12 +576,29 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha } } - // Log activity + // Log activity BEFORE party bonus check so both visitors can see each other logAdvActivity(char.UserID, string(activity), loc.Name, string(result.Outcome), result.TotalLootValue, result.XPGained, result.FlavorKey) - // Send resolution DM + // Party bonus: check if someone else visited the same location today + if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional { + if advCheckPartyBonus(char.UserID, loc.Name) { + // Apply party bonus: +10% loot value + partyBonus := int64(float64(result.TotalLootValue) * 0.10) + if partyBonus > 0 { + result.TotalLootValue += partyBonus + // Credit the bonus directly + p.euro.Credit(char.UserID, float64(partyBonus), "adventure_party_bonus") + } + } + } + + // Send resolution DM with closing block text := renderAdvResolutionDM(result, char) + closing := advClosingBlock(result.Outcome, char.UserID, loc.Name, p.morningHour, p.summaryHour) + if closing != "" { + text += "\n" + closing + } if err := p.SendDM(ctx.Sender, text); err != nil { slog.Error("adventure: failed to send resolution DM", "user", ctx.Sender, "err", err) } @@ -540,17 +613,29 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharacter) error { char.ActionTakenToday = true + char.LastActionDate = time.Now().UTC().Format("2006-01-02") if err := saveAdvCharacter(char); err != nil { return p.SendDM(ctx.Sender, "Failed to save. Even resting is broken.") } logAdvActivity(char.UserID, string(AdvActivityRest), "", "rest", 0, 0, "") + // Compute reset countdown + now := time.Now().UTC() + midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC) + remaining := midnight.Sub(now) + hours := int(remaining.Hours()) + minutes := int(remaining.Minutes()) % 60 + return p.SendDM(ctx.Sender, fmt.Sprintf( "%s, you chose rest. No loot. No XP. No death.\n\n"+ "You sat in your hovel and stared at the wall and achieved absolutely nothing. "+ - "Tomorrow awaits. It will probably be the same.", - char.DisplayName)) + "Tomorrow awaits. It will probably be the same.\n\n"+ + "─────────────────────────────\n"+ + "Next action: 00:00 UTC (%dh %dm from now)\n"+ + "Morning DM: %02d:00 UTC\n"+ + "Evening summary: %02d:00 UTC", + char.DisplayName, hours, minutes, p.morningHour, p.summaryHour)) } // ── Treasure Drop Check ───────────────────────────────────────────────────── @@ -739,7 +824,7 @@ func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter char, err := loadAdvCharacter(userID) if err != nil { // Auto-create - displayName := p.displayName(userID) + displayName := p.DisplayName(userID) if err := createAdvCharacter(userID, displayName); err != nil { return nil, nil, err } @@ -764,15 +849,3 @@ func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter return char, equip, nil } -func (p *AdventurePlugin) displayName(userID id.UserID) string { - resp, err := p.Client.GetDisplayName(context.Background(), userID) - if err != nil || resp.DisplayName == "" { - // Fallback to localpart - s := string(userID) - if idx := strings.Index(s, ":"); idx > 0 { - s = s[1:idx] - } - return s - } - return resp.DisplayName -} diff --git a/internal/plugin/adventure_activities.go b/internal/plugin/adventure_activities.go index fc9844a..627cf21 100644 --- a/internal/plugin/adventure_activities.go +++ b/internal/plugin/adventure_activities.go @@ -317,11 +317,14 @@ func advIsEligible(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipme return false, false } - // Check minimum equipment tier - minTier := 99 - for _, eq := range equip { - if eq.Tier < minTier { - minTier = eq.Tier + // Check minimum equipment tier — no equipment means tier 0 + minTier := 0 + if len(equip) > 0 { + minTier = 99 + for _, eq := range equip { + if eq.Tier < minTier { + minTier = eq.Tier + } } } if minTier < loc.MinEquipTier { diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index c5dae8e..7b6f582 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -2,7 +2,6 @@ package plugin import ( "database/sql" - "log/slog" "time" "gogobee/internal/db" @@ -251,12 +250,12 @@ func createAdvCharacter(userID id.UserID, displayName string) error { if err != nil { return err } + defer tx.Rollback() _, err = tx.Exec(` INSERT INTO adventure_characters (user_id, display_name) VALUES (?, ?)`, string(userID), displayName) if err != nil { - tx.Rollback() return err } @@ -267,7 +266,6 @@ func createAdvCharacter(userID id.UserID, displayName string) error { INSERT INTO adventure_equipment (user_id, slot, tier, condition, name, actions_used) VALUES (?, ?, 0, 100, ?, 0)`, string(userID), string(slot), def.Name) if err != nil { - tx.Rollback() return err } } @@ -440,19 +438,18 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) { func resetAllAdvDailyActions() error { d := db.Get() - _, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0`) + // Only reset actions taken before today — protects against race if a player + // resolves their action at exactly midnight. + today := time.Now().UTC().Format("2006-01-02") + _, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0 WHERE last_action_date < ? OR last_action_date IS NULL`, today) return err } func logAdvActivity(userID id.UserID, activityType, location, outcome string, lootValue int64, xpGained int, flavorKey string) { - d := db.Get() - _, err := d.Exec(` - INSERT INTO adventure_activity_log (user_id, activity_type, location, outcome, loot_value, xp_gained, flavor_key) + db.Exec("adventure: log activity", + `INSERT INTO adventure_activity_log (user_id, activity_type, location, outcome, loot_value, xp_gained, flavor_key) VALUES (?, ?, ?, ?, ?, ?, ?)`, string(userID), activityType, location, outcome, lootValue, xpGained, flavorKey) - if err != nil { - slog.Error("adventure: failed to log activity", "user", userID, "err", err) - } } // ── Buff CRUD ──────────────────────────────────────────────────────────────── @@ -511,8 +508,8 @@ func loadAdvTodayLogs() ([]AdvDayLog, error) { rows, err := d.Query(` SELECT user_id, activity_type, COALESCE(location,''), outcome, loot_value, xp_gained FROM adventure_activity_log - WHERE DATE(logged_at) = ? - ORDER BY logged_at`, today) + WHERE logged_at >= ? AND logged_at < DATE(?, '+1 day') + ORDER BY logged_at`, today, today) if err != nil { return nil, err } diff --git a/internal/plugin/adventure_events.go b/internal/plugin/adventure_events.go new file mode 100644 index 0000000..c7a9e9b --- /dev/null +++ b/internal/plugin/adventure_events.go @@ -0,0 +1,355 @@ +package plugin + +import ( + "database/sql" + "fmt" + "log/slog" + "math/rand/v2" + "sync" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// ── Types ──────────────────────────────────────────────────────────────────── + +type advActiveEvent struct { + ID int64 + UserID id.UserID + EventKey string + TriggeredAt time.Time + ExpiresAt time.Time +} + +// ── In-memory schedule ─────────────────────────────────────────────────────── +// Each day, every eligible player is assigned a random minute between 10:00 +// and 16:00 UTC at which the 0.5% trigger roll happens. + +var ( + advEventScheduleMu sync.Mutex + advEventSchedule map[string]int // userID string -> minute-of-day (600..959) + advEventScheduleDay string // "2006-01-02" the schedule was built for +) + +func advBuildEventSchedule(chars []AdventureCharacter) { + advEventSchedule = make(map[string]int, len(chars)) + for _, c := range chars { + if !c.Alive { + continue + } + // Random minute between 600 (10:00) and 959 (15:59) + advEventSchedule[string(c.UserID)] = 600 + rand.IntN(360) + } + advEventScheduleDay = time.Now().UTC().Format("2006-01-02") +} + +// ── Event Ticker ───────────────────────────────────────────────────────────── + +func (p *AdventurePlugin) eventTicker() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + now := time.Now().UTC() + dateKey := now.Format("2006-01-02") + + // Expire stale pending events every tick + expireAdvPendingEvents() + + // Outside the trigger window — nothing to do + if now.Hour() < 10 || now.Hour() >= 16 { + continue + } + + // Rebuild schedule if it's a new day or uninitialised + advEventScheduleMu.Lock() + if advEventScheduleDay != dateKey { + chars, err := loadAllAdvCharacters() + if err != nil { + slog.Error("adventure: events: failed to load chars for schedule", "err", err) + advEventScheduleMu.Unlock() + continue + } + advBuildEventSchedule(chars) + slog.Info("adventure: event schedule built", "players", len(advEventSchedule)) + } + + // Find players whose roll minute is now + currentMinute := now.Hour()*60 + now.Minute() + var toRoll []id.UserID + for uid, minute := range advEventSchedule { + if minute == currentMinute { + toRoll = append(toRoll, id.UserID(uid)) + } + } + advEventScheduleMu.Unlock() + + for _, uid := range toRoll { + p.tryTriggerEvent(uid) + } + } +} + +func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) { + // Load character — must be alive and have acted today + char, err := loadAdvCharacter(userID) + if err != nil || char == nil || !char.Alive || !char.ActionTakenToday { + return + } + + // Already has an active event? + active, _ := loadAdvActiveEvent(userID) + if active != nil { + return + } + + // 0.5% chance + if rand.Float64() >= 0.005 { + return + } + + // Determine today's activity for filtering + activityType := advPlayerTodayActivity(userID) + + // Pick an event + event := advPickRandomEvent(userID, activityType) + if event == nil { + return + } + + // Insert into DB + now := time.Now().UTC() + expiresAt := now.Add(2 * time.Hour) + eventID, err := insertAdvEvent(userID, event.Key, expiresAt) + if err != nil { + slog.Error("adventure: events: failed to insert event", "user", userID, "err", err) + return + } + + slog.Info("adventure: mid-day event triggered", "user", userID, "event", event.Key, "id", eventID) + + // DM the player + triggerDM := advSubstituteFlavor(event.TriggerDM, map[string]string{ + "{name}": char.DisplayName, + }) + if err := p.SendDM(userID, triggerDM); err != nil { + slog.Error("adventure: events: failed to send trigger DM", "user", userID, "err", err) + } + + // Post to game room + gr := gamesRoom() + if gr != "" { + roomLine := advSubstituteFlavor(advEventRoomTriggerWrapper, map[string]string{ + "{trigger_room_line}": advSubstituteFlavor(event.TriggerRoomLine, map[string]string{"{name}": char.DisplayName}), + }) + _ = p.SendMessage(id.RoomID(gr), roomLine) + } +} + +// handleEventRespond processes `!adventure respond`. +func (p *AdventurePlugin) handleEventRespond(ctx MessageContext) error { + mu := p.advUserLock(ctx.Sender) + mu.Lock() + defer mu.Unlock() + + active, err := loadAdvActiveEvent(ctx.Sender) + if err != nil { + slog.Error("adventure: events: failed to load active event", "user", ctx.Sender, "err", err) + return p.SendDM(ctx.Sender, "Something went wrong checking for events. Try again in a moment.") + } + if active == nil { + return p.SendDM(ctx.Sender, "You don't have an active event to respond to.") + } + + // Look up event definition + event := advFindEventByKey(active.EventKey) + if event == nil { + slog.Error("adventure: events: unknown event key in DB", "key", active.EventKey) + return p.SendDM(ctx.Sender, "Something went wrong — the event couldn't be found. This shouldn't happen.") + } + + // Roll gold reward + gold := event.GoldMin + if event.GoldMax > event.GoldMin { + gold += rand.Int64N(event.GoldMax - event.GoldMin + 1) + } + + // Credit gold + p.euro.Credit(ctx.Sender, float64(gold), "adventure_event_"+event.Key) + + // Apply XP if applicable + xpSkill := event.XPSkill + if xpSkill == "" && event.XP > 0 { + // For "any" events, apply XP to whatever they did today + activityType := advPlayerTodayActivity(ctx.Sender) + xpSkill = advXPSkill(AdvActivityType(activityType)) + } + + if event.XP > 0 && xpSkill != "" { + char, err := loadAdvCharacter(ctx.Sender) + if err == nil && char != nil { + switch xpSkill { + case "combat": + char.CombatXP += event.XP + case "mining": + char.MiningXP += event.XP + case "foraging": + char.ForagingXP += event.XP + } + checkAdvLevelUp(char, xpSkill) + _ = saveAdvCharacter(char) + } + } + + // Mark responded in DB + markAdvEventResponded(active.ID, gold, event.XP) + + // Load display name for substitutions + displayName := p.DisplayName(ctx.Sender) + + // Send outcome DM + goldStr := fmt.Sprintf("%d", gold) + xpStr := fmt.Sprintf("%d", event.XP) + outcomeDM := advSubstituteFlavor(event.OutcomeDM, map[string]string{ + "{gold}": goldStr, + "{xp}": xpStr, + "{name}": displayName, + }) + _ = p.SendDM(ctx.Sender, outcomeDM) + + // Post outcome to game room + gr := gamesRoom() + if gr != "" { + xpSuffix := "" + if event.XP > 0 { + xpSuffix = fmt.Sprintf(" · +%d XP", event.XP) + } + roomLine := advSubstituteFlavor(advEventRoomOutcomeWrapper, map[string]string{ + "{outcome_room_line}": advSubstituteFlavor(event.OutcomeRoomLine, map[string]string{ + "{name}": displayName, + "{gold}": goldStr, + }), + "{gold}": goldStr, + "{xp_suffix}": xpSuffix, + }) + _ = p.SendMessage(id.RoomID(gr), roomLine) + } + + return nil +} + +// ── Event Selection ────────────────────────────────────────────────────────── + +func advPickRandomEvent(userID id.UserID, activityType string) *AdvRandomEvent { + // Load recent event keys for dedup + recent, _ := loadAdvRecentEventKeys(userID, 10) + recentSet := make(map[string]bool, len(recent)) + for _, k := range recent { + recentSet[k] = true + } + + // Filter eligible events + var candidates []int + for i, e := range advRandomEvents { + if e.Activity != "any" && e.Activity != activityType { + continue + } + if recentSet[e.Key] { + continue + } + candidates = append(candidates, i) + } + + if len(candidates) == 0 { + return nil + } + + return &advRandomEvents[candidates[rand.IntN(len(candidates))]] +} + +func advFindEventByKey(key string) *AdvRandomEvent { + for i := range advRandomEvents { + if advRandomEvents[i].Key == key { + return &advRandomEvents[i] + } + } + return nil +} + +// advPlayerTodayActivity returns the activity type string for what the player did today. +func advPlayerTodayActivity(userID id.UserID) string { + d := db.Get() + today := time.Now().UTC().Format("2006-01-02") + var actType string + err := d.QueryRow(`SELECT activity_type FROM adventure_activity_log + WHERE user_id = ? AND logged_at >= ? AND logged_at < DATE(?, '+1 day') LIMIT 1`, string(userID), today, today).Scan(&actType) + if err != nil { + return "" + } + return actType +} + +// ── DB Operations ──────────────────────────────────────────────────────────── + +func insertAdvEvent(userID id.UserID, eventKey string, expiresAt time.Time) (int64, error) { + d := db.Get() + res, err := d.Exec(`INSERT INTO adventure_events_log (user_id, event_key, expires_at) + VALUES (?, ?, ?)`, string(userID), eventKey, expiresAt) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func loadAdvActiveEvent(userID id.UserID) (*advActiveEvent, error) { + d := db.Get() + var e advActiveEvent + err := d.QueryRow(`SELECT id, user_id, event_key, triggered_at, expires_at + FROM adventure_events_log + WHERE user_id = ? AND outcome = 'pending' AND expires_at > CURRENT_TIMESTAMP + ORDER BY triggered_at DESC LIMIT 1`, string(userID)).Scan( + &e.ID, &e.UserID, &e.EventKey, &e.TriggeredAt, &e.ExpiresAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &e, nil +} + +func markAdvEventResponded(eventID int64, goldAwarded int64, xpAwarded int) { + db.Exec("adventure: mark event responded", + `UPDATE adventure_events_log + SET outcome = 'responded', responded_at = CURRENT_TIMESTAMP, + gold_awarded = ?, xp_awarded = ? + WHERE id = ?`, goldAwarded, xpAwarded, eventID) +} + +func expireAdvPendingEvents() { + db.Exec("adventure: expire pending events", + `UPDATE adventure_events_log SET outcome = 'expired' + WHERE outcome = 'pending' AND expires_at < CURRENT_TIMESTAMP`) +} + +func loadAdvRecentEventKeys(userID id.UserID, limit int) ([]string, error) { + d := db.Get() + rows, err := d.Query(`SELECT event_key FROM adventure_events_log + WHERE user_id = ? ORDER BY triggered_at DESC LIMIT ?`, string(userID), limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var keys []string + for rows.Next() { + var k string + if err := rows.Scan(&k); err != nil { + return nil, err + } + keys = append(keys, k) + } + return keys, rows.Err() +} diff --git a/internal/plugin/adventure_flavor_closing.go b/internal/plugin/adventure_flavor_closing.go new file mode 100644 index 0000000..834a49d --- /dev/null +++ b/internal/plugin/adventure_flavor_closing.go @@ -0,0 +1,204 @@ +package plugin + +// ── OUTCOME DM CLOSING BLOCKS ───────────────────────────────────────────────── +// +// These are appended after the stats block in every resolution DM. +// They confirm the day is spent, show time to reset, and close +// the interaction in the game's voice. +// +// Substitutions: +// {location} — where the player went +// {reset_time} — absolute UTC reset time e.g. "00:00 UTC" +// {time_until} — relative countdown e.g. "9h 23m" +// {morning_time} — morning DM time e.g. "08:00 UTC" +// {summary_time} — evening summary time e.g. "20:00 UTC" +// +// Selection: pick randomly per outcome category. +// Skip last 3 used per player to avoid immediate repetition. +// Death closings are NOT used when death DM is sent — that DM +// has its own closure. These are for survive/succeed/fail outcomes. + +// ── SUCCESS ─────────────────────────────────────────────────────────────────── +// Tone: dry acknowledgement. A good day happened. Don't oversell it. +// The player earned something. The game acknowledges this without enthusiasm. + +var ClosingSuccess = []string{ + "─────────────────────────────\n" + + "That's your day. {location} has been dealt with.\n\n" + + "Next action available: {reset_time} ({time_until} from now)\n" + + "Evening summary: {summary_time} UTC — see what everyone else managed. Or didn't.\n" + + "Tomorrow's choices: {morning_time} UTC\n\n" + + "Rest. You've earned it. Questionably, but earned.", + + "─────────────────────────────\n" + + "Done. That's the day accounted for.\n\n" + + "The {location} is behind you. The loot is in your inventory.\n" + + "The rest of today is yours to do nothing useful with.\n\n" + + "Resets: {reset_time} ({time_until})\n" + + "Morning DM: {morning_time} UTC\n" + + "Evening summary: {summary_time} UTC\n\n" + + "Go rest. You've been through a dungeon.", + + "─────────────────────────────\n" + + "You're done. The {location} has been dealt with,\n" + + "or dealt with you, depending on how you're counting.\n\n" + + "Tomorrow arrives at {morning_time} UTC ({time_until} from now).\n" + + "The evening summary posts at {summary_time} UTC if you want\n" + + "to see what everyone else managed. Or didn't.\n\n" + + "Rest. You've earned it. Questionably.", + + "─────────────────────────────\n" + + "A day, completed. The {location} has had its say\n" + + "and you've had yours and the ledger is updated.\n\n" + + "Nothing more to do until {reset_time} ({time_until}).\n" + + "Morning DM at {morning_time} UTC with tomorrow's options.\n" + + "TwinBee's results post at {summary_time} UTC.\n\n" + + "You did alright today. Don't let it go to your head.", + + "─────────────────────────────\n" + + "That's it. Day spent. Action taken. Outcome recorded.\n\n" + + "The {location} will still be there tomorrow, worse for your visit.\n" + + "Your choices will also be there, at {morning_time} UTC,\n" + + "refreshed and waiting and not judging yesterday.\n\n" + + "Reset: {reset_time} · {time_until} remaining\n" + + "Evening summary: {summary_time} UTC\n\n" + + "Go do something that isn't this. You've done this for today.", + + "─────────────────────────────\n" + + "Done for today. The rest of the day is just\n" + + "you and your choices and none of those choices involve this.\n\n" + + "Resets: {reset_time} UTC · {time_until} from now\n" + + "Tomorrow: {morning_time} UTC\n" + + "Summary tonight: {summary_time} UTC\n\n" + + "Rest. The {location} will be there when you get back.\n" + + "The {location} is always there when you get back.", + + "─────────────────────────────\n" + + "You're done. Good run. Not exceptional, not terrible —\n" + + "a run. The kind of run that keeps an adventurer\n" + + "in business and out of the deeper healthcare plans.\n\n" + + "Next action: {reset_time} ({time_until})\n" + + "Morning DM: {morning_time} UTC\n" + + "Evening summary: {summary_time} UTC — TwinBee will be there.\n\n" + + "So will everyone else who showed up today.", +} + +// ── EXCEPTIONAL ─────────────────────────────────────────────────────────────── +// Tone: lets the result breathe. Doesn't undercut the achievement. +// The closing is shorter here — the exceptional outcome DM already did +// the heavy lifting. The closing just marks the day as done. + +var ClosingExceptional = []string{ + "─────────────────────────────\n" + + "That's your day. Write it down.\n\n" + + "Resets: {reset_time} · {time_until}\n" + + "Morning DM: {morning_time} UTC\n" + + "Evening summary: {summary_time} UTC — this one's getting mentioned.", + + "─────────────────────────────\n" + + "Done. The {location} will not forget this.\n" + + "Neither should you.\n\n" + + "Next action: {reset_time} ({time_until})\n" + + "Summary tonight at {summary_time} UTC.\n" + + "Tomorrow: {morning_time} UTC.", + + "─────────────────────────────\n" + + "That's today. Keep the receipt.\n\n" + + "Resets: {reset_time} · {time_until} remaining\n" + + "Evening summary: {summary_time} UTC\n" + + "Tomorrow's choices: {morning_time} UTC\n\n" + + "Rest. You've more than earned it.", + + "─────────────────────────────\n" + + "Day spent. Exceptionally.\n\n" + + "Next action: {reset_time} ({time_until})\n" + + "The {summary_time} UTC summary is going to be good tonight.\n" + + "Morning DM at {morning_time} UTC with whatever comes next.\n\n" + + "It won't be this. But it'll be something.", + + "─────────────────────────────\n" + + "Done. That's a day worth having had.\n\n" + + "Resets: {reset_time} · {time_until}\n" + + "Summary: {summary_time} UTC\n" + + "Tomorrow: {morning_time} UTC\n\n" + + "Go rest. Exceptional days are still days and days end.", +} + +// ── FAILURE / EMPTY ─────────────────────────────────────────────────────────── +// Tone: the game knows it went badly. It's not cruel about it. +// Resigned. Accurate. Leaves a door open for tomorrow. + +var ClosingFailure = []string{ + "─────────────────────────────\n" + + "That's your day. Not the day you wanted, but the day you got.\n\n" + + "Next action: {reset_time} · {time_until} from now\n" + + "Tomorrow's choices: {morning_time} UTC\n" + + "Evening summary: {summary_time} UTC — see how everyone else did.\n\n" + + "Some days the dungeon wins. Today was that day.\n" + + "Tomorrow is a different proposition.", + + "─────────────────────────────\n" + + "Done. The {location} was uncooperative today.\n" + + "This is noted. This will not be the last time it's noted.\n\n" + + "Resets: {reset_time} ({time_until})\n" + + "Morning DM: {morning_time} UTC — new choices, clean slate.\n" + + "Summary: {summary_time} UTC\n\n" + + "Rest. Tomorrow the {location} gets another chance to disappoint you.\n" + + "You also get another chance. That's how this works.", + + "─────────────────────────────\n" + + "You're done. The {location} gave nothing today.\n" + + "The {location} does not apologise for this.\n" + + "The {location} does not apologise for anything.\n\n" + + "Next action: {reset_time} · {time_until}\n" + + "Tomorrow: {morning_time} UTC\n" + + "Summary tonight: {summary_time} UTC\n\n" + + "Come back tomorrow. Bring the same bad equipment.\n" + + "Maybe the mountain will be in a better mood.", + + "─────────────────────────────\n" + + "That's today. Nothing found. XP gained, technically.\n" + + "The experience of finding nothing is still experience.\n" + + "The game counts it. The game is generous with definitions.\n\n" + + "Resets: {reset_time} · {time_until} remaining\n" + + "Morning DM: {morning_time} UTC\n" + + "Evening summary: {summary_time} UTC\n\n" + + "Tomorrow is not today. That's the best thing that can be said about it.", + + "─────────────────────────────\n" + + "Done. Bad day. It happens.\n\n" + + "The {location} is still there. It will still be there tomorrow.\n" + + "So will you, which is the important part.\n\n" + + "Next action: {reset_time} ({time_until})\n" + + "Tomorrow's choices at {morning_time} UTC.\n" + + "TwinBee had a better day. The summary at {summary_time} UTC will confirm this.\n\n" + + "Rest. You've earned the rest, at least.", + + "─────────────────────────────\n" + + "That's your day. The {location} was not impressed with you today.\n" + + "The feeling, presumably, is mutual.\n\n" + + "Resets: {reset_time} · {time_until}\n" + + "Morning DM: {morning_time} UTC — fresh options.\n" + + "Evening summary: {summary_time} UTC\n\n" + + "Go rest. The {location} will be here when you get back.\n" + + "The {location} is always here. That's the problem with it.", +} + +// ── DEATH ───────────────────────────────────────────────────────────────────── +// Terse — the death resolution DM already handles the emotional weight. +// This closing is a brief bridge to the healthcare lockout. + +var ClosingDeath = []string{ + "─────────────────────────────\n" + + "That's your day. Healthcare has the rest.\n\n" + + "A separate DM is inbound with the details.\n" + + "The details are not good. You already know the details.\n\n" + + "Next action: after recovery\n" + + "Morning DM: {morning_time} UTC — subject to medical clearance.", + + "─────────────────────────────\n" + + "Day over. Healthcare is involved.\n" + + "Expect a DM shortly with the full situation.\n\n" + + "The full situation: {location} won today.\n" + + "Tomorrow is pending insurance confirmation.", +} diff --git a/internal/plugin/adventure_flavor_events.go b/internal/plugin/adventure_flavor_events.go new file mode 100644 index 0000000..29946b1 --- /dev/null +++ b/internal/plugin/adventure_flavor_events.go @@ -0,0 +1,854 @@ +package plugin + +// ── RANDOM MID-DAY EVENTS ───────────────────────────────────────────────────── +// +// Trigger chance: 0.5% per player per day, checked at a random time +// between 10:00 and 16:00 UTC. +// Player has 2 hours to reply !adventure respond. +// No response: event expires silently, no penalty, no reward. +// Response: OutcomeDM sent, reward applied, one-liner posted to room. + +// AdvRandomEvent defines a single mid-day event with trigger/outcome text and rewards. +type AdvRandomEvent struct { + Key string + TriggerDM string + TriggerRoomLine string + OutcomeDM string + OutcomeRoomLine string + GoldMin int64 + GoldMax int64 + XP int + XPSkill string // "combat", "mining", "foraging", or "" for activity-based + Activity string // "any" | "dungeon" | "mining" | "foraging" +} + +var advRandomEvents = []AdvRandomEvent{ + + // ── FOOD & BODILY CONSEQUENCES ──────────────────────────────────────────── + + { + Key: "roast_chicken_wall", + TriggerDM: "While picking at a crumbling wall for no reason you can adequately explain, " + + "you find a roast chicken.\n\n" + + "It has been in there a while. How long is unclear. " + + "The colour is wrong. The smell is wrong. " + + "A part of your brain is screaming at you and another part is already eating it.\n\n" + + "You eat it. All of it. You lick your fingers.\n\n" + + "Twenty minutes later your stomach has filed a formal complaint " + + "that is escalating rapidly through your entire body.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ {name} found something in a wall and ate it. This is developing.", + OutcomeDM: "Instead of holding it like an idiot — which is exactly what you would have done — " + + "you drop trow and handle your situation in the nearest available ditch.\n\n" + + "There is a person in the ditch.\n\n" + + "The person immediately stands, climbing out of the ditch toward you, " + + "covered in the consequences of your recent dietary decision. " + + "You notice the name on their uniform: S. Kelly.\n\n" + + "S. Kelly does not confront you. S. Kelly makes a sound. " + + "An excited sound. Reaches into a pocket and throws a damp wad of bills at your feet.\n\n" + + "You pick them up. You make eye contact with S. Kelly one final time. " + + "You leave at pace.\n\n" + + "Some questions are better left unasked. You have €{gold}.\n\n" + + "S. Kelly watches you go.", + OutcomeRoomLine: "✅ {name} handled the situation. S. Kelly was involved. Nobody is elaborating.", + GoldMin: 35, + GoldMax: 80, + XP: 0, + Activity: "any", + }, + + { + Key: "burning_house", + TriggerDM: "There is a house on fire.\n\n" + + "You run in.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ {name} has run into a burning building.", + OutcomeDM: "There is nobody inside.\n\n" + + "There was never anyone inside. You are an idiot who ran into a burning building " + + "for no reason that holds up to scrutiny.\n\n" + + "What IS inside is a truly remarkable amount of unsecured valuables " + + "belonging to someone who left in a hurry. " + + "You fill your pockets. A beam falls nearby. " + + "You fill your pockets faster. Another beam falls. " + + "You make a mental note about the beams and immediately forget it " + + "because your pockets are full.\n\n" + + "You emerge from the building on fire, briefly, " + + "which a neighbour extinguishes with a bucket they were apparently already holding.\n\n" + + "They applaud. They assume you saved someone. " + + "You accept this.\n\n" + + "€{gold}. You are on fire slightly less than before.", + OutcomeRoomLine: "✅ {name} ran into a burning building. Nobody was inside. {name} was briefly on fire.", + GoldMin: 80, + GoldMax: 200, + XP: 10, + Activity: "any", + }, + + { + Key: "bandit_carriage", + TriggerDM: "You come across a band of bandits robbing a carriage.\n\n" + + "There are four of them. They are large. " + + "The carriage owner is inside with the curtains drawn.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ {name} has spotted a bandit ambush and is deciding what kind of person they are.", + OutcomeDM: "You dash over and immediately confront the bandits. " + + "You point at them and proclaim that if they resist, " + + "you will be forced to inflict great pain upon them.\n\n" + + "They stomp the ever-loving shit out of you.\n\n" + + "All four. In sequence and then simultaneously. " + + "Your armor absorbs the first two hits and then stops absorbing. " + + "You are making sounds you have not made since childhood.\n\n" + + "The carriage door opens. The owner steps out with an auto-crossbow " + + "and dispatches all four in approximately eight seconds.\n\n" + + "The owner walks over to you on the ground and says, calmly, " + + "that they could have stepped out considerably earlier. " + + "They say the sight of you getting the stuffing knocked out of you " + + "was honestly the most entertainment they'd had in weeks. " + + "They press €{gold} into your hand and return to the carriage, " + + "already mimicking the high-pitched yelps you made as each blow landed.\n\n" + + "You walk away. The owner is still laughing. " + + "You can hear it for quite some time.", + OutcomeRoomLine: "✅ {name} confronted four bandits. The carriage owner let it play out for a while first.", + GoldMin: 120, + GoldMax: 250, + XP: 25, + Activity: "any", + }, + + { + Key: "mystery_stew", + TriggerDM: "A stranger shoves a bowl of stew into your hands and walks away.\n\n" + + "The stew is hot. The stranger is gone. " + + "You are standing in the street holding someone else's dinner.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ A stranger gave {name} a bowl of stew and immediately left.", + OutcomeDM: "You eat the stew.\n\n" + + "It is the best thing you have ever eaten. Not 'best thing today.' Best thing. Ever. " + + "You stand holding the empty bowl for a full minute just thinking about it.\n\n" + + "Then you feel incredible. Not good. Incredible. " + + "Like someone has gone into your settings and fixed something you didn't know was broken.\n\n" + + "You turn the bowl over. €{gold} taped to the bottom.\n\n" + + "You will think about this stew for the rest of your life. " + + "You will never find the stranger. " + + "This is fine. Some things are better as open wounds.", + OutcomeRoomLine: "✅ {name} ate the stew. {name} will not be discussing the stew.", + GoldMin: 20, + GoldMax: 50, + XP: 15, + Activity: "any", + }, + + // ── CRIME & MORAL FLEXIBILITY ───────────────────────────────────────────── + + { + Key: "drunk_merchant", + TriggerDM: "A merchant is asleep at their stall. Aggressively asleep. " + + "The kind that follows a serious lunch.\n\n" + + "Their coin purse is on the counter. Three feet away. Unattended.\n\n" + + "The merchant snores.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ {name} has found an unattended coin purse. A moral crossroads.", + OutcomeDM: "You take the coin purse.\n\n" + + "You were never not going to take the coin purse.\n\n" + + "Inside: €{gold}, a very small portrait of someone's dog, " + + "and a note that says 'DO NOT LOSE THIS.' " + + "You pocket the money. You leave the note on the counter as a courtesy. " + + "You leave the portrait because you are not a monster.\n\n" + + "The merchant snores.\n\n" + + "You walk away. The merchant wakes up three hours later " + + "with a note that says DO NOT LOSE THIS " + + "and no memory of what it referred to.\n\n" + + "This haunts them. Good.", + OutcomeRoomLine: "✅ {name} made a financial decision at the market. The merchant will be confused later.", + GoldMin: 40, + GoldMax: 120, + XP: 0, + Activity: "any", + }, + + { + Key: "wrongful_arrest", + TriggerDM: "A guard grabs you by the collar. " + + "You match the description of someone who robbed a bakery this morning.\n\n" + + "You did not rob a bakery this morning. " + + "You were somewhere significantly worse, which is a different problem.\n\n" + + "The guard is waiting.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ A guard has {name} by the collar. Bakery robbery. {name} has an alibi of sorts.", + OutcomeDM: "You explain where you actually were this morning in considerable detail.\n\n" + + "The guard listens with the expression of someone who cannot determine " + + "whether a dungeon is a better alibi than a bakery robbery. " + + "They're still deciding when a runner arrives: actual robber caught three streets over.\n\n" + + "The guard releases you. You ask about compensation.\n\n" + + "A long pause. The guard produces a voucher for a free loaf " + + "from the robbed bakery, which they apparently issue to witnesses as goodwill. " + + "You go to the bakery. You sell the loaf you bought before knowing about the voucher for €{gold}.\n\n" + + "The justice system has, in its way, provided.", + OutcomeRoomLine: "✅ {name} was detained and compensated with bread. The system works, loosely.", + GoldMin: 25, + GoldMax: 60, + XP: 5, + Activity: "any", + }, + + { + Key: "tax_refund", + TriggerDM: "A tax collector approaches you with a ledger and an apologetic expression.\n\n" + + "The city has been over-collecting from you for several years. " + + "There is a refund.\n\n" + + "You have never paid city taxes. Not once. Not ever.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ A tax collector has found {name} in a ledger. A refund is being discussed.", + OutcomeDM: "You accept the refund.\n\n" + + "You nod seriously while the amount is calculated. " + + "You say 'that sounds about right' in the tone of someone " + + "who has been thinking about this number for years. " + + "You sign the form. The collector apologises on behalf of the city.\n\n" + + "€{gold} is counted into your hand.\n\n" + + "Somewhere in the city, someone who actually paid those taxes " + + "is still waiting on their refund.\n\n" + + "Not your problem.", + OutcomeRoomLine: "✅ {name} accepted a refund for taxes they never paid. The city apologised.", + GoldMin: 100, + GoldMax: 250, + XP: 0, + Activity: "any", + }, + + { + Key: "pickpocket_reversed", + TriggerDM: "Someone has just tried to pick your pocket.\n\n" + + "You felt it. They're still standing right next to you, " + + "hand inside your coat, making eye contact and smiling " + + "with the frozen confidence of someone whose plan has not fully resolved.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ Someone is in {name}'s pocket. Both parties are aware of this.", + OutcomeDM: "You look down at the hand. You look up at the person. " + + "The person looks at you. A long moment.\n\n" + + "You pick their pocket.\n\n" + + "While they are processing what just happened, " + + "while the hand is still technically inside your coat, " + + "you reach into their jacket and remove their wallet " + + "with the smooth efficiency of someone who has spent considerable time in dungeons " + + "and has a working relationship with fast decisions.\n\n" + + "The pickpocket looks at their own empty pocket. Looks at you. " + + "Has the expression of someone revising a career.\n\n" + + "You hand them back their own wallet, minus €{gold} for the inconvenience, " + + "and walk away while they work through the sequence of events.\n\n" + + "They do not follow you. They sit down on the nearest step " + + "and think about their choices. Good.", + OutcomeRoomLine: "✅ {name} was pickpocketed and immediately pickpocketed back. Net outcome: positive.", + GoldMin: 30, + GoldMax: 85, + XP: 10, + Activity: "any", + }, + + // ── WILDLIFE ───────────────────────────────────────────────────────────── + + { + Key: "runaway_pig", + TriggerDM: "A pig is running directly at you at a speed pigs should not be capable of.\n\n" + + "It is large. It has committed to its direction. You are in its direction.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ {name} is in the path of a large fast pig.", + OutcomeDM: "You sidestep the pig at the last possible moment.\n\n" + + "The pig continues. You hear it round a corner. " + + "Then shouting. Then nothing. You choose not to investigate.\n\n" + + "A farmer arrives thirty seconds later, completely out of breath, " + + "and asks which way. You point. The farmer presses coins into your hand " + + "without breaking stride.\n\n" + + "You stood in a road and moved slightly to the left. €{gold}.\n\n" + + "The shouting was bad. " + + "You do not ask what happened to the pig.", + OutcomeRoomLine: "✅ {name} avoided the pig and was paid for it. The pig's status is unknown.", + GoldMin: 15, + GoldMax: 40, + XP: 0, + Activity: "any", + }, + + { + Key: "suspicious_crow", + TriggerDM: "A crow has been following you for twenty minutes.\n\n" + + "Not flying past. Following. Fence to fence, tree to tree, " + + "making sustained eye contact every time you check.\n\n" + + "You have checked six times.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ A crow is following {name}. Sustained eye contact has been established.", + OutcomeDM: "You stop and face the crow.\n\n" + + "It lands in front of you and drops a ring from its beak. " + + "Silver. Engraved with initials you don't recognise. " + + "Dropped with the deliberate energy of something being returned.\n\n" + + "The crow watches you pick it up. Then it leaves. " + + "Just flies away. Done. No further communication.\n\n" + + "You stand in the road holding a ring a crow gave you.\n\n" + + "A jeweller gives you €{gold} and asks no questions.\n\n" + + "You do not ask questions either.\n\n" + + "On the way home you see the crow drop a ring at someone else's feet.\n\n" + + "The crow looks up at you. Brief eye contact.\n\n" + + "The crow looks away. Business as usual.", + OutcomeRoomLine: "✅ The crow gave {name} a ring and left. It gives everyone rings. Questions: none.", + GoldMin: 50, + GoldMax: 110, + XP: 0, + Activity: "any", + }, + + { + Key: "horse_standoff", + TriggerDM: "A horse is standing in the middle of the road and will not move.\n\n" + + "Not startled. Not lost. Just standing there with the settled energy " + + "of something that has made a decision and is at peace with it. " + + "A queue of people on both sides. Nobody doing anything.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ A horse is blocking the road. {name} is the nearest person with any credentials.", + OutcomeDM: "You approach the horse.\n\n" + + "The horse looks at you. You say something — more of a tone than words. " + + "The horse considers this and then steps aside.\n\n" + + "The crowd applauds. A merchant whose cargo has been stuck forty minutes " + + "presses €{gold} into your hand.\n\n" + + "You do not know what you said to the horse. " + + "You walk away before anyone asks you to try.\n\n" + + "Behind you, someone else approaches the horse with great confidence.\n\n" + + "The horse bites them immediately.\n\n" + + "You do not look back.", + OutcomeRoomLine: "✅ {name} said something to a horse and it worked. The next person was bitten immediately. {name} cannot explain this.", + GoldMin: 20, + GoldMax: 55, + XP: 0, + Activity: "any", + }, + + { + Key: "cat_with_something", + TriggerDM: "A cat has dropped something at your feet and is sitting back " + + "looking at you with the expectant energy of a cat that has done its part " + + "and is waiting for you to do yours.\n\n" + + "The something is small and wrapped in cloth " + + "and the cat is not going to explain itself.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ A cat has left something at {name}'s feet and is waiting.", + OutcomeDM: "You pick up the cloth and unwrap it.\n\n" + + "A key. Old. Specific. The kind that opens one thing " + + "and that one thing is somewhere.\n\n" + + "You spend an hour finding what the key opens. " + + "The cat follows you the entire time, maintaining three feet of distance, " + + "watching with total composure.\n\n" + + "The key opens a small lockbox behind a loose brick " + + "in a wall the cat led you to by sitting in front of it and waiting.\n\n" + + "Inside: €{gold} and a note that says 'good cat.'\n\n" + + "The cat has already left when you look up. " + + "The note was for the cat. " + + "The money was apparently for you. " + + "You do not understand any part of this and you never will.", + OutcomeRoomLine: "✅ A cat led {name} to a lockbox. The note inside was for the cat. The money was for {name}.", + GoldMin: 45, + GoldMax: 100, + XP: 0, + Activity: "any", + }, + + // ── PEOPLE & SOCIAL DISASTERS ───────────────────────────────────────────── + + { + Key: "funeral_wrong_procession", + TriggerDM: "You have accidentally joined a funeral procession.\n\n" + + "You don't know how. One moment you were walking, " + + "the next you were part of a slow column of mourners " + + "and everyone assumed you belonged " + + "and you didn't correct anyone " + + "and now the church is very close.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ {name} has accidentally joined a funeral. The church is very close.", + OutcomeDM: "You attend the entire funeral.\n\n" + + "An hour and forty minutes. Third pew. You accept a memorial card " + + "for one Roderick Hannaway, beloved, " + + "and fold it carefully into a pocket.\n\n" + + "At the reception a woman assumes you were a business colleague " + + "and spends twenty minutes telling you what a genuinely difficult man Roderick was. " + + "She cries twice. You hand her a napkin both times " + + "because the napkins are right there and it would be weird not to.\n\n" + + "She presses €{gold} into your hand and says Roderick would have wanted you to have it.\n\n" + + "Based on everything you've heard today, " + + "Roderick would have wanted no such thing. " + + "Roderick sounds like he was awful. " + + "You have his money now. " + + "Karma is imprecise but directionally sound.", + OutcomeRoomLine: "✅ {name} attended Roderick Hannaway's funeral uninvited. Roderick was difficult. {name} has the money.", + GoldMin: 60, + GoldMax: 130, + XP: 10, + Activity: "any", + }, + + { + Key: "bar_fight_aftermath", + TriggerDM: "The bar fight is over.\n\n" + + "You weren't in it. You were at a corner table minding your business " + + "when it erupted and now it's done and there are people on the floor " + + "in various states of consciousness and the barkeep is looking at everyone " + + "like they're all someone else's problem.\n\n" + + "You look at the people on the floor.\n\n" + + "You look at your medical kit.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ A bar fight has concluded near {name}. {name} has a medical kit and is looking at it.", + OutcomeDM: "You open the medical kit and get to work.\n\n" + + "You also get out your pricing chart.\n\n" + + "The first one is a large man with a broken nose. " + + "Standard rate. You patch him up, he pays, he leaves without making eye contact.\n\n" + + "The second is a woman who is, frankly, very attractive, " + + "which means she has had it easy her entire life " + + "and can afford to have it slightly less easy right now. " + + "You charge her triple. She pays it. You feel nothing about this.\n\n" + + "The third is a child.\n\n" + + "A sweet child. A small child with enormous eyes " + + "who had absolutely no business being in a bar during a bar fight " + + "and has a gash on their forehead that needs cleaning.\n\n" + + "You look at the child with sympathetic eyes.\n\n" + + "You charge the child the most of all.\n\n" + + "'This,' you say, cleaning the wound with practiced efficiency, " + + "'is what happens when you meddle in grown folks' business.'\n\n" + + "The parent shows up and pays you while glaring at you the entire time. " + + "In fact the whole room is staring daggers at you " + + "but you don't mind at all.\n\n" + + "You smile and wave back at them as you count your money " + + "while continuing on your way.\n\n" + + "Friends made today: -20.\n" + + "Money made today: €{gold}. A whole lot.", + OutcomeRoomLine: "✅ {name} treated the bar fight wounded on a sliding scale. The child paid the most. Friends made: -20.", + GoldMin: 85, + GoldMax: 190, + XP: 10, + Activity: "any", + }, + + { + Key: "duel_wrong_person", + TriggerDM: "Someone has challenged you to a duel.\n\n" + + "Wrong person. Wrong name. Wrong grievance. " + + "The passion with which they're delivering the challenge " + + "suggests this has been building for a long time " + + "in someone else's direction.\n\n" + + "There is a crowd. They are waiting for your response.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ {name} has been challenged to a duel. Wrong person. {name} hasn't clarified this.", + OutcomeDM: "You accept.\n\n" + + "Correcting the situation now feels impossible. " + + "There is a crowd. A duelling ground. A ritual. " + + "You go through all of it.\n\n" + + "Your opponent is very good. Specifically trained, " + + "for years probably, for a fight against a person who is not you.\n\n" + + "Halfway through the duel they stop, squint at you, " + + "and say slowly that you are not who they thought.\n\n" + + "A long silence.\n\n" + + "They sheathe their weapon. Press €{gold} into your hand " + + "as 'compensation for the inconvenience' and leave at speed.\n\n" + + "The crowd disperses. You stand on the duelling ground alone " + + "wondering who they were actually looking for " + + "and whether that person knows what's coming.\n\n" + + "They don't know. They will find out. " + + "That is not your problem.", + OutcomeRoomLine: "✅ {name} accepted a duel meant for someone else. Error discovered mid-duel. Compensation paid.", + GoldMin: 80, + GoldMax: 170, + XP: 30, + Activity: "any", + }, + + { + Key: "wrong_house_dinner", + TriggerDM: "You have walked into the wrong house.\n\n" + + "The door was open. You thought it was somewhere else. " + + "It is very much not somewhere else.\n\n" + + "There is a family eating dinner. " + + "They are looking at you. " + + "You are looking at them.\n\n" + + "Nobody has said anything yet.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ {name} is standing in a stranger's home. Dinner is in progress. Nobody has spoken.", + OutcomeDM: "You sit down.\n\n" + + "There is an empty chair and your body uses it " + + "before your brain can propose an alternative.\n\n" + + "The family, after a moment, continues dinner.\n\n" + + "They serve you a plate. The food is excellent. " + + "Nobody explains the empty chair. You don't ask. " + + "Conversation happens around you the way weather happens — " + + "you're present without being the cause of it.\n\n" + + "The eldest person at the table gives you €{gold} at the end " + + "and says 'for the road' and shows you out.\n\n" + + "On the way out you notice a painting on the wall.\n\n" + + "It is a painting of you.\n\n" + + "Not someone who looks like you. You.\n\n" + + "The door closes before you can say anything.\n\n" + + "You stand on the step for a moment.\n\n" + + "You walk home.", + OutcomeRoomLine: "✅ {name} sat at a stranger's dinner table. Was served. Was paid. The chair was waiting.", + GoldMin: 30, + GoldMax: 80, + XP: 10, + Activity: "any", + }, + + { + Key: "street_prophet", + TriggerDM: "A street prophet has stopped their sermon to point at you.\n\n" + + "The crowd is looking at you. The prophet is describing a prophecy " + + "that is clearly about you — your description, your general situation, " + + "several accurate details that are uncomfortably specific.\n\n" + + "The prophet is waiting.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ A street prophet has identified {name} in a prophecy. The crowd is watching.", + OutcomeDM: "You step forward.\n\n" + + "The prophet delivers the rest of it directly to you, " + + "in detail, for eleven minutes. A great task. A difficult road. " + + "A door that 'will know you when you find it.'\n\n" + + "You have no idea what door. You go through a lot of doors. " + + "One of them is apparently significant " + + "and you will not recognise the moment when it happens.\n\n" + + "The crowd donates €{gold} to you afterward, " + + "unprompted, as if this is simply what you do when someone is in a prophecy.\n\n" + + "You walk away with money and a prophecy you don't understand " + + "and the distinct feeling that something is now your problem " + + "that wasn't your problem this morning.\n\n" + + "Behind you the prophet points at someone else in the crowd " + + "and begins describing a new prophecy with the same specific details. " + + "Your description. Your equipment. Your general situation.\n\n" + + "The prophet makes eye contact with you briefly.\n\n" + + "The prophet looks away.", + OutcomeRoomLine: "✅ {name} is in a prophecy. A door will know them. Nobody can say which door.", + GoldMin: 50, + GoldMax: 110, + XP: 20, + Activity: "any", + }, + + // ── FOUND OBJECTS ───────────────────────────────────────────────────────── + + { + Key: "mysterious_clock", + TriggerDM: "There is a package on your doorstep.\n\n" + + "Your name on it, spelled correctly. " + + "No return address. " + + "A small but definite amount of ticking.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ {name} has received an anonymous package. It is ticking.", + OutcomeDM: "You open it.\n\n" + + "A clock. Small, ornate, running accurately. " + + "A note: 'Sorry about the clock. Keep it. — T'\n\n" + + "You don't know anyone whose name starts with T.\n\n" + + "You sell it to a jeweller immediately " + + "because a clock that arrives with a preemptive apology " + + "is not a clock you want in your home.\n\n" + + "€{gold}. The jeweller asks no questions.\n\n" + + "You are half a street away when the explosion happens.\n\n" + + "You turn around.\n\n" + + "The jeweller is standing in the smoking doorway, " + + "entirely unharmed, holding something aloft, " + + "screaming at the top of their lungs.\n\n" + + "'I'm rich! I'm rich! " + + "Fuck all of you dirty bitches because I'm stinking wealthy!'\n\n" + + "Congratulations. " + + "That explosion would have probably had your ears ringing for a bit. " + + "Whew.", + OutcomeRoomLine: "✅ {name} received a clock and an apology from T. Both have been processed.", + GoldMin: 55, + GoldMax: 120, + XP: 0, + Activity: "any", + }, + + { + Key: "well_box", + TriggerDM: "There is something shining at the bottom of a well.\n\n" + + "The bucket is right there.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ {name} is looking at something shining at the bottom of a well.", + OutcomeDM: "You go in.\n\n" + + "Not in the bucket — the bucket wouldn't hold you — " + + "but using the rope and the wall and a technique you invent on the way down " + + "that works better than it has any right to.\n\n" + + "At the bottom: an ornate box. Waterlogged. Locked. Heavy.\n\n" + + "You get it in the bucket and climb back out, " + + "which is harder than going down in the specific way " + + "that makes you hate yourself for not planning this.\n\n" + + "The box contains €{gold} in old currency and a letter.\n\n" + + "You read the letter.\n\n" + + "You read it again.\n\n" + + "You drop it into a different well.\n\n" + + "The money is yours. " + + "The letter is the other well's problem. " + + "You are not thinking about the letter.", + OutcomeRoomLine: "✅ {name} went into a well. Came out. The letter has been re-disposed of in a different well.", + GoldMin: 90, + GoldMax: 210, + XP: 10, + Activity: "any", + }, + + { + Key: "treasure_map", + TriggerDM: "Someone has pressed a piece of paper into your hand and walked away very fast.\n\n" + + "It is old. There are markings. There is an X.\n\n" + + "There is always an X.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ {name} has been given a piece of paper by someone who left immediately.", + OutcomeDM: "You follow the map.\n\n" + + "It leads, through three wrong turns and one moment of genuine doubt " + + "about your entire direction in life, " + + "to a loose flagstone in an unremarkable courtyard.\n\n" + + "Under the flagstone: a tin. Inside the tin: €{gold} " + + "and a note that says TELL NO ONE.\n\n" + + "You replace the flagstone. You pocket the money.\n\n" + + "You are telling everyone.", + OutcomeRoomLine: "✅ {name} followed the X. It worked. {name} has been asked to tell no one.", + GoldMin: 70, + GoldMax: 160, + XP: 5, + Activity: "any", + }, + + // ── ACTIVITY-SPECIFIC ───────────────────────────────────────────────────── + + { + Key: "dungeon_staff_entrance", + TriggerDM: "You've found a back entrance to a dungeon.\n\n" + + "A side door. Half-hidden. " + + "The kind that exists because someone who worked here " + + "wanted a way in that the front-door things didn't know about.\n\n" + + "It's open.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ {name} has found an unmarked open door in a dungeon.", + OutcomeDM: "You go in.\n\n" + + "Staff area. A schedule in a language you don't read. " + + "A considerable amount of food the monsters keep back here. " + + "Three goblins on break who look up at you.\n\n" + + "Under the table: a strongbox the goblins were clearly not supposed to be near.\n\n" + + "You take the strongbox. You take some of the food. " + + "The goblins watch and say nothing, " + + "possibly because you've caught them somewhere they're not supposed to be " + + "and the situation is mutually awkward for everyone.\n\n" + + "You make eye contact with each goblin in turn. " + + "A tacit agreement is reached. " + + "You leave. The goblins file a complaint with management.\n\n" + + "Management investigates and finds nothing useful.\n\n" + + "Management is one of the goblins.\n\n" + + "The goblins know exactly what happened.\n\n" + + "The lock has already been changed.\n\n" + + "€{gold}. The food was decent.", + OutcomeRoomLine: "✅ {name} found the dungeon staff area. The goblins were on break. An agreement was reached.", + GoldMin: 80, + GoldMax: 180, + XP: 20, + XPSkill: "combat", + Activity: "dungeon", + }, + + { + Key: "surface_ore", + TriggerDM: "There is ore sitting on the ground.\n\n" + + "Not in a mine. Not behind anything. " + + "Just on the ground, in the open. " + + "Several people have walked past it.\n\n" + + "You don't know why nobody has taken it.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ {name} has found ore on the ground. Nobody has taken it. {name} is suspicious.", + OutcomeDM: "You pick it up.\n\n" + + "Nothing happens.\n\n" + + "No one objects. No one appears. " + + "The ground doesn't care. " + + "The ore is just ore that was on the ground " + + "and is now in your pocket.\n\n" + + "You sell it for €{gold}.\n\n" + + "The people who walked past it will think about this for the rest of the day. " + + "Some of them will still be thinking about it next week.\n\n" + + "You will not be thinking about it. " + + "€{gold} has a clarifying effect on the mind.", + OutcomeRoomLine: "✅ {name} picked up free ore off the ground. Nothing happened. €{gold}.", + GoldMin: 20, + GoldMax: 65, + XP: 5, + XPSkill: "mining", + Activity: "mining", + }, + + { + Key: "unusual_plant", + TriggerDM: "You've spotted a plant you've never seen before.\n\n" + + "It's doing something. Not moving — present in a way regular plants aren't. " + + "It's not on any foraging chart you've seen.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ {name} has found an unidentified plant that is present in an unusual way.", + OutcomeDM: "You take a cutting.\n\n" + + "A small piece. Just enough. " + + "The plant doesn't react. " + + "You're not certain what a reaction would look like " + + "and you don't push your luck.\n\n" + + "A herbalist in town goes very still when you show it to them. " + + "The kind of still that means they know exactly what it is " + + "and are working very hard not to show you that they know.\n\n" + + "They buy it for €{gold} with the focused calm " + + "of someone containing a significant emotion.\n\n" + + "You don't ask what it is. " + + "Whatever you just sold, the foraging skill has decided it counts.", + OutcomeRoomLine: "✅ {name} sold an unidentified plant to a herbalist who was very controlled about wanting it.", + GoldMin: 60, + GoldMax: 140, + XP: 25, + XPSkill: "foraging", + Activity: "foraging", + }, + + { + Key: "army_enlistment", + TriggerDM: "A company is giving away free clothing.\n\n" + + "You can see the table from here. Nice stuff too — " + + "proper fabric, clean lines, nothing corroded or held together by optimism. " + + "An actual upgrade over what you're currently wearing, " + + "which the game has already described at length and does not need to describe again.\n\n" + + "You get in line.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ {name} has spotted a free clothing giveaway and is getting in line.", + OutcomeDM: "You wait your turn. You get to the front. " + + "They hand you a very nice set of clothing that costs you absolutely nothing.\n\n" + + "You are shuffled into a changing room.\n\n" + + "You emerge from the changing room onto a battlefield.\n\n" + + "This was not a clothing giveaway. " + + "This was an army enlistment event. " + + "Your reading ability, never your strongest attribute, " + + "has failed you at a meaningful moment.\n\n" + + "You are assigned to the Commanding General's meat shield group. " + + "The arrangement is explained to you clearly: " + + "stand in front of the General with your shield raised, " + + "keep the General alive, receive 20,000 gold per day. " + + "This is, mathematically, an excellent rate of pay.\n\n" + + "You stand tall on the battlefield. Shield raised. General behind you. " + + "The others in your group doing the same. " + + "You are, for the first time in your life, part of something larger than yourself.\n\n" + + "You notice a shiny button on the ground.\n\n" + + "You bend over to pick it up.\n\n" + + "The arrow that would have been stopped by your shield " + + "strikes the Commanding General in the head, killing him instantly.\n\n" + + "You are discharged from the army on the spot. " + + "No gold. No commendation. No acknowledgement that you were " + + "technically doing your job correctly right up until the button.\n\n" + + "You do have a shiny button and €{gold} in back-pay for the twelve minutes you were technically enlisted.\n\n" + + "Huzzah.", + OutcomeRoomLine: "✅ {name} accidentally enlisted in an army and immediately got the General killed. Has a button and €{gold}.", + GoldMin: 8, + GoldMax: 22, + XP: 5, + Activity: "any", + }, + + { + Key: "tower_bird", + TriggerDM: "There is a tall abandoned lookout tower nearby.\n\n" + + "If you climbed it, you could survey the surrounding area. " + + "Tactically useful. Good information. A sound decision.\n\n" + + "It's time to do this like a professional.\n\n" + + "Let's climb this ridiculously tall and abandoned lookout tower that is in extreme stages of disrepair. " + + "The view from up there could be very enlightening!\n\n" + + "The tower sways slightly in the wind from down here.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ {name} has decided to climb an abandoned tower that is visibly swaying.", + OutcomeDM: "The tower sways violently back and forth as you climb. " + + "Each gust is a conversation between the wind and the structural integrity " + + "of something that has been abandoned for good reason.\n\n" + + "After what seemed like ages — but thanks to the miracles of narrative " + + "storytelling was just the next sentence for us — " + + "you reach the top.\n\n" + + "Before you can survey anything, something catches your eye. " + + "Something sparkly. Inside a bird's nest.\n\n" + + "A treasure, perhaps.\n\n" + + "From the size of the nest, even a half-wit could tell " + + "this is no bird to be trifled with. " + + "Thankfully, you are not the average half-wit, " + + "and you proceed to dig through the nest.\n\n" + + "You find what you're seeking because it cuts you. " + + "A worthless piece of broken glass. " + + "You yank your hand back in pain, knocking something out of the nest " + + "that makes a sound like an egg shattering.\n\n" + + "It was an egg.\n\n" + + "As luck would have it, mum is right about to arrive home.\n\n" + + "A miracle occurs: you finally understand the situation you are in " + + "and make haste down the ladder. " + + "The bird lands on top of the tower. " + + "For one brief moment you breathe a sigh of relief.\n\n" + + "The bird looks down.\n\n" + + "The bird begins descending.\n\n" + + "You put up what few would consider an effective counterattack. " + + "You are knocked off the ladder.\n\n" + + "Down and down and down.\n\n" + + "Splat.\n\n" + + "You are not dead.\n\n" + + "As luck would have it, you have landed on an enormous and moist pile of manure. " + + "The bits on the very top are still warm. " + + "You turn to see a farmer walking away with a shovel. " + + "Must be fresh.\n\n" + + "As you dig yourself out you notice something stuck in your hand. " + + "A claw. A beautiful one, from that very large and very angry bird. " + + "This ought to sell for a decent amount.\n\n" + + "Mission accomplished.", + OutcomeRoomLine: "✅ {name} climbed a tower, broke an egg, got knocked off the ladder, and landed in manure. Has a claw.", + GoldMin: 45, + GoldMax: 95, + XP: 10, + Activity: "any", + }, + + { + Key: "monkey_coin", + TriggerDM: "You're walking home from the market.\n\n" + + "You were so unsuccessful at haggling that you ended up " + + "paying more than list price for your items, " + + "which is an achievement in the wrong direction.\n\n" + + "As you make your way home, you spot a monkey.\n\n" + + "Ordinarily, most sensible folks would question what a monkey " + + "is doing out here, whether it escaped from somewhere, " + + "what the implications are.\n\n" + + "You are fixated on what's in its hand.\n\n" + + "A shiny gold coin.\n\n" + + "Reply `!adventure respond` within 2 hours.", + TriggerRoomLine: "⚡ {name} has spotted a monkey with a gold coin and is devising a plan.", + OutcomeDM: "What's a monkey doing with a gold coin? " + + "It doesn't need it. You'll use your superior wits to outsmart it " + + "and claim that coin for yourself.\n\n" + + "You devise an ingenious plan.\n\n" + + "You'll toss an interesting-looking rock at it and it'll grab the rock instead, " + + "leaving the gold coin behind. And why not? " + + "This stupid monkey will only care that the rock is way bigger than the coin.\n\n" + + "After a few moments you finally stop congratulating yourself " + + "long enough to put the plan into action.\n\n" + + "You gently toss the rock towards the monkey.\n\n" + + "Eureka.\n\n" + + "The monkey drops the coin exactly as planned and runs for the rock. " + + "You hurry over and snatch up the coin. " + + "You are about to perform your god awful victory dance " + + "when something clocks you in the back of the head " + + "causing you to drop your groceries.\n\n" + + "Instead of turning around to confront your assailant " + + "you focus on picking up the groceries, " + + "as getting smacked in the head is a common occurrence for you " + + "and the groceries are not.\n\n" + + "You finally get yourself together and look up.\n\n" + + "The monkey is holding the gold coin and your bananas.\n\n" + + "It laughs.\n\n" + + "It tosses you the coin out of pity and scurries away.\n\n" + + "You did it.\n\n" + + "Congratulations.", + OutcomeRoomLine: "✅ {name} outsmarted a monkey. The monkey took the bananas and gave back the coin out of pity.", + GoldMin: 1, + GoldMax: 1, + XP: 40, + Activity: "any", + }, +} + +// ── FORMAT CONSTANTS ────────────────────────────────────────────────────────── + +const advEventRoomTriggerWrapper = "{trigger_room_line}\n" + + "They have 2 hours to respond with `!adventure respond`." + +const advEventRoomOutcomeWrapper = "{outcome_room_line}\n" + + "(+€{gold}{xp_suffix})" diff --git a/internal/plugin/adventure_flavor_treasure.go b/internal/plugin/adventure_flavor_treasure.go index 7d66c61..bbb4731 100644 --- a/internal/plugin/adventure_flavor_treasure.go +++ b/internal/plugin/adventure_flavor_treasure.go @@ -377,6 +377,7 @@ var SummaryMiningSuccess = []string{ "Mining run at {location}: {ore} recovered, €{value}, no cave-ins.", "Came back from {location} with {ore} and €{value} and a worse {tool}.", "{ore} from {location}. €{value}. The back is filing a complaint.", + "You went mining. You tripped. You fell into the wall. You're out cold. But a rock broke free. You may have lost 8 hours. But hell, you gained {ore}. €{value}.", } var SummaryMiningDeath = []string{ @@ -400,6 +401,14 @@ var SummaryForagingSuccess = []string{ "Returned from {location} with {item} worth €{value} and zero injuries. Good day.", "Clean haul from {location}. {item}, €{value}. The forest cooperated.", "{item} from {location}. €{value}. One bear made eye contact. Moved on.", + "You went foraging. You foraged. Yep. Stuff found. Good job. What was it? Who cares? It's foraging. Does it really matter? Okay. Fine. It was {item}. €{value}.", +} + +var SummaryForagingEmpty = []string{ + "Wandered through {location}. Found nothing. Not even hornets.", + "{location}: searched every bush. Came home empty-handed.", + "Nothing from {location}. The forest was uncooperative today.", + "Went foraging in {location}. The forest kept its things.", } var SummaryForagingDeath = []string{ diff --git a/internal/plugin/adventure_flavor_twinbee.go b/internal/plugin/adventure_flavor_twinbee.go index ab10a0e..e248b7a 100644 --- a/internal/plugin/adventure_flavor_twinbee.go +++ b/internal/plugin/adventure_flavor_twinbee.go @@ -454,7 +454,7 @@ var DeathDM = []string{ "Neither will be comfortable. This is healthcare.\n\n" + "Your equipment has been assessed. The assessment was not kind.\n" + "The assessment was accurate. Return expected: {time} UTC.\n" + - "The {location} has noted your visit in whatever record {location} keeps.\n" + + "The {location} has noted your visit in whatever records it keeps.\n" + "You are in the record as 'visited once, briefly.'", "📋 ADVENTURER STATUS: INDISPOSED\n\n" + diff --git a/internal/plugin/adventure_render.go b/internal/plugin/adventure_render.go index adf3189..8aafdc7 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -73,6 +73,14 @@ func advClearFlavorHistory() { func advSubstituteFlavor(template string, vars map[string]string) string { pairs := make([]string, 0, len(vars)*2) for k, v := range vars { + // Prevent "The The Soggy Cellar" — if the value starts with "The " + // and the template says "The {location}", replace that as a unit first. + if strings.HasPrefix(v, "The ") { + theKey := "The " + k + if strings.Contains(template, theKey) { + pairs = append(pairs, theKey, v) + } + } pairs = append(pairs, k, v) } return strings.NewReplacer(pairs...).Replace(template) @@ -114,9 +122,6 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]* // Equipment sb.WriteString("\n🛡️ Equipment:\n") eqScore := advEquipmentScore(equip) - slotEmoji := map[EquipmentSlot]string{ - SlotWeapon: "⚔️", SlotArmor: "🛡️", SlotHelmet: "🪖", SlotBoots: "👢", SlotTool: "⛏️", - } for _, slot := range allSlots { eq := equip[slot] mastery := "" @@ -125,7 +130,7 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]* } if eq != nil { sb.WriteString(fmt.Sprintf(" %s %s: %s (Tier %d | %d%% condition%s)\n", - slotEmoji[slot], strings.Title(string(slot)), eq.Name, eq.Tier, eq.Condition, mastery)) + slotEmoji(slot), slotTitle(slot), eq.Name, eq.Tier, eq.Condition, mastery)) } } sb.WriteString(fmt.Sprintf(" Equipment Score: %d\n", eqScore)) @@ -281,7 +286,7 @@ func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) st sb.WriteString(fmt.Sprintf("✨ +%d %s XP", result.XPGained, result.XPSkill)) if result.LeveledUp { - sb.WriteString(fmt.Sprintf(" — **LEVEL UP! %s Lv.%d!** 🎉", strings.Title(result.XPSkill), result.NewLevel)) + sb.WriteString(fmt.Sprintf(" — **LEVEL UP! %s Lv.%d!** 🎉", titleCase(result.XPSkill), result.NewLevel)) } sb.WriteString("\n") @@ -290,7 +295,7 @@ func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) st sb.WriteString("🔧 Equipment damage:\n") for slot, dmg := range result.EquipDamage { if dmg > 0 { - sb.WriteString(fmt.Sprintf(" • %s: -%d condition\n", strings.Title(string(slot)), dmg)) + sb.WriteString(fmt.Sprintf(" • %s: -%d condition\n", slotTitle(slot), dmg)) } } } @@ -319,6 +324,51 @@ func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) st return sb.String() } +// advClosingBlock selects and formats a closing block based on outcome. +func advClosingBlock(outcome AdvOutcomeType, userID id.UserID, location string, morningHour, summaryHour int) string { + var pool []string + var category string + + switch outcome { + case AdvOutcomeExceptional: + pool = ClosingExceptional + category = "closing_exceptional" + case AdvOutcomeSuccess: + pool = ClosingSuccess + category = "closing_success" + case AdvOutcomeDeath: + pool = ClosingDeath + category = "closing_death" + default: + // Empty, cave-in, hornets, bear, river — all failure closings + pool = ClosingFailure + category = "closing_failure" + } + + if len(pool) == 0 { + return "" + } + + text, _ := advPickFlavor(pool, userID, category) + + // Compute reset time (next midnight UTC) + now := time.Now().UTC() + midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC) + remaining := midnight.Sub(now) + hours := int(remaining.Hours()) + minutes := int(remaining.Minutes()) % 60 + + timeUntil := fmt.Sprintf("%dh %dm", hours, minutes) + + return advSubstituteFlavor(text, map[string]string{ + "{location}": location, + "{reset_time}": "00:00 UTC", + "{time_until}": timeUntil, + "{morning_time}": fmt.Sprintf("%02d:00", morningHour), + "{summary_time}": fmt.Sprintf("%02d:00", summaryHour), + }) +} + // ── Death Status DM ────────────────────────────────────────────────────────── func renderAdvDeathStatusDM(char *AdventureCharacter) string { @@ -327,9 +377,14 @@ func renderAdvDeathStatusDM(char *AdventureCharacter) string { if char.DeadUntil != nil { remaining = char.DeadUntil.Format("15:04") } + location := char.GrudgeLocation + if location == "" { + location = "an unknown location" + } return advSubstituteFlavor(text, map[string]string{ - "{name}": char.DisplayName, - "{time}": remaining, + "{name}": char.DisplayName, + "{time}": remaining, + "{location}": location, }) } @@ -415,9 +470,9 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa sb.WriteString("\n") if tbRewards.Eligible > 0 { - sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers:\n", tbRewards.Eligible)) + sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers (scaled by level):\n", tbRewards.Eligible)) if tbRewards.GoldShare > 0 { - sb.WriteString(fmt.Sprintf(" 💰 €%d each\n", tbRewards.GoldShare)) + sb.WriteString(fmt.Sprintf(" 💰 ~€%d avg\n", tbRewards.GoldShare)) } if tbRewards.GiftCount > 0 { sb.WriteString(fmt.Sprintf(" ⭐ %d players received a gift item\n", tbRewards.GiftCount)) @@ -565,26 +620,28 @@ func renderAdvLeaderboard(chars []AdventureCharacter) string { // ── Treasure Discard Prompt ────────────────────────────────────────────────── func renderAdvTreasureDiscardPrompt(newTreasure *AdvTreasureDef, existing []AdvTreasureDef) string { - // Pick from treasure inventory cap pool - var sb strings.Builder - - if len(TreasureInventoryCap) > 0 { - text := TreasureInventoryCap[rand.IntN(len(TreasureInventoryCap))] - text = advSubstituteFlavor(text, map[string]string{ - "{treasure_name}": newTreasure.Name, - }) - sb.WriteString(text) - sb.WriteString("\n\n") + if len(TreasureInventoryCap) == 0 { + return "You found a treasure but your inventory is full. Reply 1, 2, or 3 to discard, or `keep`." } - sb.WriteString("Current treasures:\n") - for i, t := range existing { - sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, t.InventoryDesc)) + // Build substitution map with existing treasure info + subs := map[string]string{ + "{treasure_name}": newTreasure.Name, + "{location}": "", + } + for i := 0; i < 3; i++ { + key := fmt.Sprintf("%d", i+1) + if i < len(existing) { + subs["{treasure_"+key+"}"] = existing[i].Name + subs["{bonus_"+key+"}"] = existing[i].InventoryDesc + } else { + subs["{treasure_"+key+"}"] = "(empty)" + subs["{bonus_"+key+"}"] = "" + } } - sb.WriteString(fmt.Sprintf("\nNew: %s\n", newTreasure.InventoryDesc)) - sb.WriteString("\nReply with 1, 2, or 3 to discard, or `keep` to leave the new treasure behind.") - return sb.String() + text := TreasureInventoryCap[rand.IntN(len(TreasureInventoryCap))] + return advSubstituteFlavor(text, subs) } // ── Summary One-Liners ─────────────────────────────────────────────────────── @@ -624,7 +681,7 @@ func advSummaryOneLiner(userID id.UserID, activity AdvActivityType, outcome AdvO case AdvOutcomeRiver: pool = SummaryForagingRiver case AdvOutcomeEmpty: - pool = SummaryForagingSuccess // no specific empty pool for foraging + pool = SummaryForagingEmpty case AdvOutcomeSuccess, AdvOutcomeExceptional: pool = SummaryForagingSuccess } @@ -640,5 +697,9 @@ func advSummaryOneLiner(userID id.UserID, activity AdvActivityType, outcome AdvO "{item}": "", "{value}": fmt.Sprintf("%d", lootValue), "{location}": location, + "{hours}": "24", + "{ore}": "", + "{tool}": "", + "{xp}": "", }) } diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index aa0a419..b664e78 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -266,16 +266,17 @@ func (p *AdventurePlugin) midnightReset() { _ = saveAdvCharacter(&char) } } else { - // Update streak - if char.LastActionDate == time.Now().UTC().Add(-24*time.Hour).Format("2006-01-02") { + // Update streak — LastActionDate was set at action time + yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02") + if char.LastActionDate == yesterday || char.LastActionDate == today { char.CurrentStreak++ - } else if char.LastActionDate != today { + } else { + // Gap in activity — start fresh char.CurrentStreak = 1 } if char.CurrentStreak > char.BestStreak { char.BestStreak = char.CurrentStreak } - char.LastActionDate = today _ = saveAdvCharacter(&char) } } @@ -293,6 +294,12 @@ func (p *AdventurePlugin) midnightReset() { // Clear flavor history to prevent unbounded memory growth. // Entries are only used for dedup within a day, so clearing at midnight is fine. advClearFlavorHistory() + + // Clear DM reminder dedup — entries are date-keyed so stale after midnight. + p.dmRemindedDate.Range(func(key, _ any) bool { + p.dmRemindedDate.Delete(key) + return true + }) } // ── Helper ─────────────────────────────────────────────────────────────────── diff --git a/internal/plugin/adventure_shop.go b/internal/plugin/adventure_shop.go index 04b9311..2e9f80f 100644 --- a/internal/plugin/adventure_shop.go +++ b/internal/plugin/adventure_shop.go @@ -9,67 +9,164 @@ import ( // ── Shop Listings ──────────────────────────────────────────────────────────── -func advShopListings(equip map[EquipmentSlot]*AdvEquipment, balance float64) string { +// slotEmoji returns a display emoji for a slot category. +func slotEmoji(slot EquipmentSlot) string { + switch slot { + case SlotWeapon: + return "⚔️" + case SlotArmor: + return "🛡️" + case SlotHelmet: + return "🪖" + case SlotBoots: + return "👢" + case SlotTool: + return "⛏️" + default: + return "📦" + } +} + +// slotTitle returns a capitalized display name for a slot. +func slotTitle(slot EquipmentSlot) string { + s := string(slot) + if len(s) == 0 { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// advShopOverview shows a compact category menu with current equipment and next upgrade. +func advShopOverview(equip map[EquipmentSlot]*AdvEquipment, balance float64) string { var sb strings.Builder sb.WriteString("🛒 **Equipment Shop**\n") - sb.WriteString(fmt.Sprintf("💰 Your balance: €%.0f\n\n", balance)) + sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance)) for _, slot := range allSlots { current := equip[slot] currentTier := 0 - if current != nil { - currentTier = current.Tier - } - - defs := equipmentTiers[slot] currentName := "None" if current != nil { + currentTier = current.Tier currentName = current.Name } - sb.WriteString(fmt.Sprintf("**%s** (current: %s, Tier %d)\n", strings.Title(string(slot)), currentName, currentTier)) - hasUpgrades := false + emoji := slotEmoji(slot) + title := slotTitle(slot) + + // Find next upgrade. + defs := equipmentTiers[slot] + nextUpgrade := "" for _, def := range defs { - if def.Tier <= currentTier || def.Price == 0 { - continue + if def.Tier > currentTier && def.Price > 0 { + nextUpgrade = fmt.Sprintf("Next: %s — €%.0f", def.Name, def.Price) + break } - hasUpgrades = true - affordable := "" - if balance < def.Price { - affordable = " ❌" - } - sb.WriteString(fmt.Sprintf(" Tier %d: %s — €%.0f%s\n", def.Tier, def.Name, def.Price, affordable)) - sb.WriteString(fmt.Sprintf(" _%s_\n", advTruncate(def.Description, 120))) } - if !hasUpgrades { - sb.WriteString(" ✨ Max tier reached!\n") + + sb.WriteString(fmt.Sprintf("%s **%s** — %s (T%d)\n", emoji, title, currentName, currentTier)) + if nextUpgrade != "" { + sb.WriteString(fmt.Sprintf(" %s\n", nextUpgrade)) + } else { + sb.WriteString(" ✨ Maxed out!\n") } sb.WriteString("\n") } - sb.WriteString("To buy: reply with `buy <item name>`\n") - sb.WriteString("Example: `buy Sad Iron Sword`") + sb.WriteString("Browse a category: `!adventure shop <category>`\n") + sb.WriteString("Categories: `weapon` · `armor` · `helmet` · `boots` · `tool`") return sb.String() } -func advTruncate(s string, n int) string { - if len(s) <= n { - return s +var shopCategoryAliases = map[string]EquipmentSlot{ + "weapon": SlotWeapon, "weapons": SlotWeapon, "sword": SlotWeapon, "swords": SlotWeapon, + "armor": SlotArmor, "armour": SlotArmor, + "helmet": SlotHelmet, "helm": SlotHelmet, "helmets": SlotHelmet, + "boots": SlotBoots, "boot": SlotBoots, + "tool": SlotTool, "tools": SlotTool, "pickaxe": SlotTool, +} + +// advParseShopCategory maps user input to an EquipmentSlot. +func advParseShopCategory(input string) EquipmentSlot { + return shopCategoryAliases[strings.ToLower(strings.TrimSpace(input))] +} + +// advShopCategory shows detailed listings for a single equipment slot. +func advShopCategory(slot EquipmentSlot, equip map[EquipmentSlot]*AdvEquipment, balance float64) string { + var sb strings.Builder + + current := equip[slot] + currentTier := 0 + currentName := "None" + if current != nil { + currentTier = current.Tier + currentName = current.Name } - return s[:n-3] + "..." + + emoji := slotEmoji(slot) + title := slotTitle(slot) + + sb.WriteString(fmt.Sprintf("%s **%s Shop**\n", emoji, title)) + sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n", balance)) + sb.WriteString(fmt.Sprintf("Equipped: **%s** (Tier %d)\n\n", currentName, currentTier)) + + defs := equipmentTiers[slot] + hasUpgrades := false + for _, def := range defs { + if def.Tier <= currentTier || def.Price == 0 { + continue + } + hasUpgrades = true + + priceTag := fmt.Sprintf("€%.0f", def.Price) + if balance < def.Price { + priceTag += " 💸 can't afford" + } else { + priceTag += " ✅ affordable" + } + + sb.WriteString(fmt.Sprintf("**Tier %d: %s** — %s\n", def.Tier, def.Name, priceTag)) + sb.WriteString(fmt.Sprintf("%s\n\n", def.Description)) + } + + if !hasUpgrades { + sb.WriteString("✨ You've reached max tier! Nothing left to buy here.\n\n") + } + + sb.WriteString("To buy: `!adventure buy <item name>` or `!adventure buy <tier> <category>`\n") + sb.WriteString("Example: `!adventure buy Enchanted Blade` or `!adventure buy 4 sword`\n") + sb.WriteString("Back to overview: `!adventure shop`") + return sb.String() } // ── Find Shop Item ─────────────────────────────────────────────────────────── +// normalizeQuotes replaces common Unicode quotes/apostrophes with ASCII equivalents. +var quoteReplacer = strings.NewReplacer( + "\u2018", "'", "\u2019", "'", // left/right single curly quotes + "\u201C", "\"", "\u201D", "\"", // left/right double curly quotes + "\u2032", "'", // prime +) + +func normalizeQuotes(s string) string { + return quoteReplacer.Replace(s) +} + func advFindShopItem(name string) (EquipmentSlot, *EquipmentDef, bool) { - name = strings.ToLower(strings.TrimSpace(name)) + name = normalizeQuotes(strings.TrimSpace(name)) + + // Support tier+category shorthand: "3 sword", "tier 3 weapon", "t3 boots", etc. + if slot, def, ok := advFindByTierShorthand(name); ok { + return slot, def, true + } + for _, slot := range allSlots { for i := range equipmentTiers[slot] { def := &equipmentTiers[slot][i] if def.Price == 0 { continue // can't buy tier 0 } - if strings.EqualFold(def.Name, name) || containsFold(def.Name, name) { + if strings.EqualFold(def.Name, name) || containsFold(normalizeQuotes(def.Name), name) { return slot, def, true } } @@ -77,6 +174,45 @@ func advFindShopItem(name string) (EquipmentSlot, *EquipmentDef, bool) { return "", nil, false } +// advFindByTierShorthand matches patterns like "3 sword", "tier 3 weapon", "t3 boots". +func advFindByTierShorthand(input string) (EquipmentSlot, *EquipmentDef, bool) { + lower := strings.ToLower(input) + + // Strip optional "tier " or "t" prefix. + lower = strings.TrimPrefix(lower, "tier ") + lower = strings.TrimPrefix(lower, "t") + + // Expect "<number> <category>" or "<number><category>". + parts := strings.SplitN(strings.TrimSpace(lower), " ", 2) + if len(parts) < 2 { + return "", nil, false + } + + tierStr := strings.TrimSpace(parts[0]) + category := strings.TrimSpace(parts[1]) + + tier := 0 + for _, c := range tierStr { + if c < '0' || c > '9' { + return "", nil, false + } + tier = tier*10 + int(c-'0') + } + + slot := advParseShopCategory(category) + if slot == "" { + return "", nil, false + } + + defs := equipmentTiers[slot] + for i := range defs { + if defs[i].Tier == tier && defs[i].Price > 0 { + return slot, &defs[i], true + } + } + return "", nil, false +} + // ── Buy Equipment ──────────────────────────────────────────────────────────── func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot, def *EquipmentDef, equip map[EquipmentSlot]*AdvEquipment) string { diff --git a/internal/plugin/adventure_test.go b/internal/plugin/adventure_test.go new file mode 100644 index 0000000..a5f7ea7 --- /dev/null +++ b/internal/plugin/adventure_test.go @@ -0,0 +1,302 @@ +package plugin + +import "testing" + +func TestAdvEquipmentScore_Empty(t *testing.T) { + score := advEquipmentScore(map[EquipmentSlot]*AdvEquipment{}) + if score != 0 { + t.Errorf("empty equipment should score 0, got %d", score) + } +} + +func TestAdvEquipmentScore_WeaponDoubled(t *testing.T) { + equip := map[EquipmentSlot]*AdvEquipment{ + SlotWeapon: {Tier: 3, Condition: 100}, + SlotArmor: {Tier: 3, Condition: 100}, + } + score := advEquipmentScore(equip) + // Weapon: 3*2=6, Armor: 3 + if score != 9 { + t.Errorf("got %d, want 9 (weapon 6 + armor 3)", score) + } +} + +func TestAdvEquipmentScore_LowConditionHalved(t *testing.T) { + equip := map[EquipmentSlot]*AdvEquipment{ + SlotArmor: {Tier: 4, Condition: 49}, // below 50% + } + score := advEquipmentScore(equip) + // Tier 4, halved = 2 + if score != 2 { + t.Errorf("got %d, want 2 (tier 4 halved for low condition)", score) + } +} + +func TestAdvEquipmentScore_FullLoadout(t *testing.T) { + equip := map[EquipmentSlot]*AdvEquipment{ + SlotWeapon: {Tier: 5, Condition: 100}, + SlotArmor: {Tier: 5, Condition: 100}, + SlotHelmet: {Tier: 5, Condition: 100}, + SlotBoots: {Tier: 5, Condition: 100}, + SlotTool: {Tier: 5, Condition: 100}, + } + score := advEquipmentScore(equip) + // Weapon: 5*2=10, others: 5*4=20, total=30 + if score != 30 { + t.Errorf("got %d, want 30", score) + } +} + +func TestXpToNextLevel(t *testing.T) { + // Level 1: 100 + 3 = 103 + if xp := xpToNextLevel(1); xp != 103 { + t.Errorf("level 1: got %d, want 103", xp) + } + // Level 50: 100 + 150 = 250 + if xp := xpToNextLevel(50); xp != 250 { + t.Errorf("level 50: got %d, want 250", xp) + } + // Higher levels should require more XP + for i := 1; i < 50; i++ { + if xpToNextLevel(i) >= xpToNextLevel(i+1) { + t.Errorf("xp for level %d should be less than level %d", i, i+1) + } + } +} + +func TestAdvIsEligible_TooLowLevel(t *testing.T) { + char := &AdventureCharacter{CombatLevel: 5} + equip := map[EquipmentSlot]*AdvEquipment{ + SlotWeapon: {Tier: 3, Condition: 100}, + } + loc := &AdvLocation{Activity: AdvActivityDungeon, MinLevel: 20, MinEquipTier: 0} + bonuses := &AdvBonusSummary{} + + eligible, _ := advIsEligible(char, equip, loc, bonuses) + if eligible { + t.Error("should not be eligible with combat level 5 for min level 20") + } +} + +func TestAdvIsEligible_TooLowEquipTier(t *testing.T) { + char := &AdventureCharacter{CombatLevel: 30} + equip := map[EquipmentSlot]*AdvEquipment{ + SlotWeapon: {Tier: 1, Condition: 100}, + SlotArmor: {Tier: 0, Condition: 100}, // this is the bottleneck + } + loc := &AdvLocation{Activity: AdvActivityDungeon, MinLevel: 1, MinEquipTier: 1} + bonuses := &AdvBonusSummary{} + + eligible, _ := advIsEligible(char, equip, loc, bonuses) + if eligible { + t.Error("should not be eligible with min equip tier 0 for requirement 1") + } +} + +func TestAdvIsEligible_PenaltyZone(t *testing.T) { + char := &AdventureCharacter{CombatLevel: 21} // within 3 of min 20 + equip := map[EquipmentSlot]*AdvEquipment{ + SlotWeapon: {Tier: 2, Condition: 100}, + } + loc := &AdvLocation{Activity: AdvActivityDungeon, MinLevel: 20, MinEquipTier: 0} + bonuses := &AdvBonusSummary{} + + eligible, penalty := advIsEligible(char, equip, loc, bonuses) + if !eligible { + t.Error("should be eligible at combat level 21 for min 20") + } + if !penalty { + t.Error("should be in penalty zone (21-20=1, less than 3)") + } +} + +func TestAdvIsEligible_NoPenalty(t *testing.T) { + char := &AdventureCharacter{CombatLevel: 25} + equip := map[EquipmentSlot]*AdvEquipment{ + SlotWeapon: {Tier: 2, Condition: 100}, + } + loc := &AdvLocation{Activity: AdvActivityDungeon, MinLevel: 20, MinEquipTier: 0} + bonuses := &AdvBonusSummary{} + + eligible, penalty := advIsEligible(char, equip, loc, bonuses) + if !eligible { + t.Error("should be eligible") + } + if penalty { + t.Error("should NOT be in penalty zone (25-20=5, >= 3)") + } +} + +func TestCalculateAdvProbabilities_SumsTo100(t *testing.T) { + char := &AdventureCharacter{CombatLevel: 10} + equip := map[EquipmentSlot]*AdvEquipment{ + SlotWeapon: {Tier: 2, Condition: 100}, + SlotArmor: {Tier: 2, Condition: 100}, + } + loc := &AdvLocation{ + Activity: AdvActivityDungeon, + BaseDeathPct: 18, + EmptyPct: 15, + MinLevel: 8, + } + bonuses := &AdvBonusSummary{} + + prob := calculateAdvProbabilities(char, equip, loc, bonuses, false) + total := prob.DeathPct + prob.EmptyPct + prob.SuccessPct + prob.ExceptionalPct + + if total < 99.9 || total > 100.1 { + t.Errorf("probabilities should sum to 100, got %.2f (death=%.1f empty=%.1f success=%.1f exceptional=%.1f)", + total, prob.DeathPct, prob.EmptyPct, prob.SuccessPct, prob.ExceptionalPct) + } +} + +func TestCalculateAdvProbabilities_PenaltyIncreasesRisk(t *testing.T) { + char := &AdventureCharacter{CombatLevel: 10} + equip := map[EquipmentSlot]*AdvEquipment{ + SlotWeapon: {Tier: 2, Condition: 100}, + } + loc := &AdvLocation{ + Activity: AdvActivityDungeon, + BaseDeathPct: 18, + EmptyPct: 15, + MinLevel: 8, + } + bonuses := &AdvBonusSummary{} + + noPenalty := calculateAdvProbabilities(char, equip, loc, bonuses, false) + withPenalty := calculateAdvProbabilities(char, equip, loc, bonuses, true) + + if withPenalty.DeathPct <= noPenalty.DeathPct { + t.Errorf("penalty zone should increase death %% (%.1f vs %.1f)", withPenalty.DeathPct, noPenalty.DeathPct) + } +} + +func TestCalculateAdvProbabilities_BetterGearReducesDeath(t *testing.T) { + char := &AdventureCharacter{CombatLevel: 10} + loc := &AdvLocation{ + Activity: AdvActivityDungeon, + BaseDeathPct: 30, + EmptyPct: 15, + MinLevel: 8, + } + bonuses := &AdvBonusSummary{} + + weakGear := map[EquipmentSlot]*AdvEquipment{ + SlotWeapon: {Tier: 1, Condition: 100}, + } + strongGear := map[EquipmentSlot]*AdvEquipment{ + SlotWeapon: {Tier: 5, Condition: 100}, + SlotArmor: {Tier: 5, Condition: 100}, + SlotHelmet: {Tier: 5, Condition: 100}, + SlotBoots: {Tier: 5, Condition: 100}, + SlotTool: {Tier: 5, Condition: 100}, + } + + weakProb := calculateAdvProbabilities(char, weakGear, loc, bonuses, false) + strongProb := calculateAdvProbabilities(char, strongGear, loc, bonuses, false) + + if strongProb.DeathPct >= weakProb.DeathPct { + t.Errorf("better gear should reduce death %% (strong=%.1f vs weak=%.1f)", strongProb.DeathPct, weakProb.DeathPct) + } +} + +func TestAdvEffectiveSkill(t *testing.T) { + char := &AdventureCharacter{ + CombatLevel: 10, + MiningSkill: 15, + ForagingSkill: 20, + } + bonuses := &AdvBonusSummary{ + CombatBonus: 5, + MiningBonus: 3, + ForagingBonus: 0, + } + + if s := advEffectiveSkill(char, AdvActivityDungeon, bonuses); s != 15 { + t.Errorf("dungeon: got %d, want 15 (10+5)", s) + } + if s := advEffectiveSkill(char, AdvActivityMining, bonuses); s != 18 { + t.Errorf("mining: got %d, want 18 (15+3)", s) + } + if s := advEffectiveSkill(char, AdvActivityForaging, bonuses); s != 20 { + t.Errorf("foraging: got %d, want 20 (20+0)", s) + } +} + +func TestAdvParseShopCategory(t *testing.T) { + tests := []struct { + input string + want EquipmentSlot + }{ + {"weapon", SlotWeapon}, + {"sword", SlotWeapon}, + {"swords", SlotWeapon}, + {"armor", SlotArmor}, + {"armour", SlotArmor}, + {"helmet", SlotHelmet}, + {"helm", SlotHelmet}, + {"boots", SlotBoots}, + {"boot", SlotBoots}, + {"tool", SlotTool}, + {"pickaxe", SlotTool}, + {" WEAPON ", SlotWeapon}, // trimmed + lowered + {"nonsense", ""}, + {"", ""}, + } + + for _, tt := range tests { + got := advParseShopCategory(tt.input) + if got != tt.want { + t.Errorf("advParseShopCategory(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestSlotEmoji(t *testing.T) { + if e := slotEmoji(SlotWeapon); e != "⚔️" { + t.Errorf("weapon emoji: got %q", e) + } + if e := slotEmoji(SlotArmor); e != "🛡️" { + t.Errorf("armor emoji: got %q", e) + } + // Default case + if e := slotEmoji("unknown"); e != "📦" { + t.Errorf("unknown slot emoji: got %q, want 📦", e) + } +} + +func TestSlotTitle(t *testing.T) { + if s := slotTitle(SlotWeapon); s != "Weapon" { + t.Errorf("got %q, want Weapon", s) + } + if s := slotTitle(SlotBoots); s != "Boots" { + t.Errorf("got %q, want Boots", s) + } + if s := slotTitle(""); s != "" { + t.Errorf("empty slot should return empty, got %q", s) + } +} + +func TestComputeAdvBonuses(t *testing.T) { + treasures := []AdvTreasureBonus{ + {BonusType: "combat_level", BonusValue: 3}, + {BonusType: "mining_skill", BonusValue: 2}, + {BonusType: "all_skills", BonusValue: 1}, + {BonusType: "death_chance", BonusValue: -2.5}, + } + + bonuses := computeAdvBonuses(treasures, nil, 0, false) + + if bonuses.CombatBonus != 4 { // 3 + 1 from all_skills + t.Errorf("CombatBonus: got %d, want 4", bonuses.CombatBonus) + } + if bonuses.MiningBonus != 3 { // 2 + 1 from all_skills + t.Errorf("MiningBonus: got %d, want 3", bonuses.MiningBonus) + } + if bonuses.ForagingBonus != 1 { // 1 from all_skills + t.Errorf("ForagingBonus: got %d, want 1", bonuses.ForagingBonus) + } + if bonuses.DeathModifier != -2.5 { + t.Errorf("DeathModifier: got %f, want -2.5", bonuses.DeathModifier) + } +} diff --git a/internal/plugin/adventure_treasure.go b/internal/plugin/adventure_treasure.go index 661cc8e..18506ef 100644 --- a/internal/plugin/adventure_treasure.go +++ b/internal/plugin/adventure_treasure.go @@ -281,6 +281,7 @@ func advSaveTreasure(userID id.UserID, def *AdvTreasureDef) error { if err != nil { return err } + defer tx.Rollback() for _, bonus := range def.Bonuses { _, err := tx.Exec(` @@ -288,7 +289,6 @@ func advSaveTreasure(userID id.UserID, def *AdvTreasureDef) error { VALUES (?, ?, ?, ?, ?, ?)`, string(userID), def.Key, def.Name, def.Tier, bonus.Type, bonus.Value) if err != nil { - tx.Rollback() return err } } diff --git a/internal/plugin/adventure_twinbee.go b/internal/plugin/adventure_twinbee.go index e3cb87e..50e6e18 100644 --- a/internal/plugin/adventure_twinbee.go +++ b/internal/plugin/adventure_twinbee.go @@ -44,10 +44,57 @@ var twinBeeWeights = []twinBeeActionWeight{ {AdvActivityForaging, 3, 0.15}, } +// twinBeeMaxTier returns the highest tier TwinBee should visit, +// based on the best player's combined adventure level. +func twinBeeMaxTier() int { + chars, err := loadAllAdvCharacters() + if err != nil || len(chars) == 0 { + return 3 + } + bestLevel := 0 + for _, c := range chars { + if !c.Alive { + continue + } + combined := c.CombatLevel + c.MiningSkill + c.ForagingSkill + if combined > bestLevel { + bestLevel = combined + } + } + // Tier 3: anyone (combined 3+) + // Tier 4: best player has combined 12+ (avg level 4 per skill) + // Tier 5: best player has combined 21+ (avg level 7 per skill) + switch { + case bestLevel >= 21: + return 5 + case bestLevel >= 12: + return 4 + default: + return 3 + } +} + func selectTwinBeeAction() (AdvActivityType, *AdvLocation) { - roll := rand.Float64() - cumulative := 0.0 + maxTier := twinBeeMaxTier() + + // Filter weights to only include tiers within range. + var filtered []twinBeeActionWeight + var totalWeight float64 for _, w := range twinBeeWeights { + if w.Tier <= maxTier { + filtered = append(filtered, w) + totalWeight += w.Weight + } + } + + if len(filtered) == 0 { + loc := findAdvLocationByTier(AdvActivityDungeon, 3) + return AdvActivityDungeon, loc + } + + roll := rand.Float64() * totalWeight + cumulative := 0.0 + for _, w := range filtered { cumulative += w.Weight if roll < cumulative { loc := findAdvLocationByTier(w.Activity, w.Tier) @@ -55,8 +102,8 @@ func selectTwinBeeAction() (AdvActivityType, *AdvLocation) { } } // Fallback - loc := findAdvLocationByTier(AdvActivityDungeon, 3) - return AdvActivityDungeon, loc + loc := findAdvLocationByTier(filtered[0].Activity, filtered[0].Tier) + return filtered[0].Activity, loc } // ── TwinBee Result ─────────────────────────────────────────────────────────── @@ -77,8 +124,15 @@ func (p *AdventurePlugin) runTwinBeeDaily() *TwinBeeResult { return nil } + // Copy equipment so mutations don't accumulate on the global template. + equip := make(map[EquipmentSlot]*AdvEquipment, len(twinBeeEquip)) + for k, v := range twinBeeEquip { + copy := *v + equip[k] = © + } + bonuses := &AdvBonusSummary{} // TwinBee has no treasures/buffs - result := resolveAdvAction(&twinBeeChar, twinBeeEquip, loc, bonuses, false) + result := resolveAdvAction(&twinBeeChar, equip, loc, bonuses, false) // TwinBee never dies — reroll death to empty if result.Outcome == AdvOutcomeDeath { @@ -201,19 +255,48 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe return summary } - // Distribute gold - share := result.LootValue / int64(len(eligible)) - if share < 1 { - share = 1 + // Distribute gold weighted by combined adventure level. + // Higher-level players get a proportionally larger share. + type eligiblePlayer struct { + uid id.UserID + weight int } - summary.GoldShare = share - + var players []eligiblePlayer + totalWeight := 0 for _, uid := range eligible { - p.euro.Credit(uid, float64(share), "twinbee_daily_share") - if rollTwinBeeGift(uid) { + weight := 3 // minimum (level 1 in all 3 skills) + for _, c := range chars { + if c.UserID == uid { + weight = c.CombatLevel + c.MiningSkill + c.ForagingSkill + if weight < 3 { + weight = 3 + } + break + } + } + weight = weight * weight // quadratic scaling — veterans get much more + players = append(players, eligiblePlayer{uid: uid, weight: weight}) + totalWeight += weight + } + + if totalWeight == 0 { + totalWeight = 1 + } + + var totalDistributed int64 + for _, ep := range players { + share := result.LootValue * int64(ep.weight) / int64(totalWeight) + if share < 1 { + share = 1 + } + p.euro.Credit(ep.uid, float64(share), "twinbee_daily_share") + totalDistributed += share + if rollTwinBeeGift(ep.uid) { summary.GiftCount++ } } + // Report average share for the summary display. + summary.GoldShare = totalDistributed / int64(len(eligible)) p.logTwinBeeResult(result, summary) return summary @@ -260,14 +343,10 @@ func rollTwinBeeGift(userID id.UserID) bool { // ── TwinBee Log ────────────────────────────────────────────────────────────── func (p *AdventurePlugin) logTwinBeeResult(result *TwinBeeResult, summary TwinBeeRewardSummary) { - d := db.Get() - _, err := d.Exec(` - INSERT INTO adventure_twinbee_log (activity_type, location, outcome, loot_value, loot_desc, participant_count, gold_share, gift_count) + db.Exec("adventure: log twinbee result", + `INSERT INTO adventure_twinbee_log (activity_type, location, outcome, loot_value, loot_desc, participant_count, gold_share, gift_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, string(result.Activity), result.Location.Name, string(result.Outcome), result.LootValue, result.LootDesc, summary.Eligible, summary.GoldShare, summary.GiftCount) - if err != nil { - slog.Error("adventure: failed to log twinbee result", "err", err) - } } diff --git a/internal/plugin/anime.go b/internal/plugin/anime.go index 43074a6..d7cfb60 100644 --- a/internal/plugin/anime.go +++ b/internal/plugin/anime.go @@ -111,12 +111,8 @@ func (p *AnimePlugin) OnMessage(ctx MessageContext) error { parts := strings.SplitN(args, " ", 2) sub := strings.ToLower(parts[0]) + // Subcommands that only touch the DB don't need async switch sub { - case "watch": - if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" { - return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !anime watch <title>") - } - return p.handleWatch(ctx, strings.TrimSpace(parts[1])) case "watching": return p.handleWatching(ctx) case "unwatch": @@ -124,13 +120,30 @@ func (p *AnimePlugin) OnMessage(ctx MessageContext) error { return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !anime unwatch <id>") } return p.handleUnwatch(ctx, strings.TrimSpace(parts[1])) - case "season": - return p.handleSeason(ctx) - case "upcoming": - return p.handleUpcoming(ctx) - default: - return p.handleSearch(ctx, args) } + + // Subcommands that hit the Jikan API run async to avoid blocking dispatch + go func() { + var err error + switch sub { + case "watch": + if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" { + err = p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !anime watch <title>") + } else { + err = p.handleWatch(ctx, strings.TrimSpace(parts[1])) + } + case "season": + err = p.handleSeason(ctx) + case "upcoming": + err = p.handleUpcoming(ctx) + default: + err = p.handleSearch(ctx, args) + } + if err != nil { + slog.Error("anime: handler error", "err", err) + } + }() + return nil } // rateLimit enforces the 400ms delay between Jikan API calls. @@ -166,8 +179,6 @@ func (p *AnimePlugin) jikanGet(apiURL string, target interface{}) error { } func (p *AnimePlugin) handleSearch(ctx MessageContext, query string) error { - d := db.Get() - // Search via API encoded := url.QueryEscape(query) apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime?q=%s&limit=3&sfw=true", encoded) @@ -186,13 +197,11 @@ func (p *AnimePlugin) handleSearch(ctx MessageContext, query string) error { // Cache the result data, _ := json.Marshal(anime) - if _, err := d.Exec( + db.Exec("anime: cache write", `INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?) ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`, anime.MalID, string(data), time.Now().Unix(), string(data), time.Now().Unix(), - ); err != nil { - slog.Error("anime: cache write", "err", err) - } + ) return p.SendReply(ctx.RoomID, ctx.EventID, p.formatAnime(anime)) } @@ -293,7 +302,7 @@ func (p *AnimePlugin) handleWatch(ctx MessageContext, title string) error { // Also cache the anime data data, _ := json.Marshal(anime) - _, _ = d.Exec( + db.Exec("anime: cache watchlist entry", `INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?) ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`, anime.MalID, string(data), time.Now().Unix(), string(data), time.Now().Unix(), @@ -514,7 +523,7 @@ func (p *AnimePlugin) PostDailyReleases(roomID id.RoomID) { // Update cache data, _ := json.Marshal(animeData) - _, _ = d.Exec( + db.Exec("anime: update daily cache", `INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?) ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`, entry.malID, string(data), time.Now().Unix(), string(data), time.Now().Unix(), diff --git a/internal/plugin/birthday.go b/internal/plugin/birthday.go index 3f5c290..fa64a01 100644 --- a/internal/plugin/birthday.go +++ b/internal/plugin/birthday.go @@ -304,14 +304,11 @@ func (p *BirthdayPlugin) CheckAndPost(roomID id.RoomID) error { } // Mark as fired (for XP/DM dedup) - _, err = d.Exec( + db.Exec("birthday: mark fired", `INSERT INTO birthday_fired (user_id, year) VALUES (?, ?) ON CONFLICT(user_id, year) DO NOTHING`, m.userID, year, ) - if err != nil { - slog.Error("birthday: mark fired", "user", m.userID, "err", err) - } } return nil @@ -337,12 +334,9 @@ func (p *BirthdayPlugin) grantBirthdayXP(userID id.UserID, amount int) { return } - _, err = d.Exec( + db.Exec("birthday: log xp", `INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`, string(userID), amount, "birthday") - if err != nil { - slog.Error("birthday: log xp", "err", err) - } } // parseBirthdayDate parses MM-DD or MM-DD-YYYY format. diff --git a/internal/plugin/blackjack.go b/internal/plugin/blackjack.go index 2843660..04ed291 100644 --- a/internal/plugin/blackjack.go +++ b/internal/plugin/blackjack.go @@ -1,7 +1,6 @@ package plugin import ( - "context" "fmt" "math" "math/rand/v2" @@ -288,7 +287,7 @@ func (p *BlackjackPlugin) handleBlackjack(ctx MessageContext) error { } table.players = append(table.players, &bjPlayer{UserID: ctx.Sender, Bet: bet}) - name := p.bjDisplayName(ctx.Sender) + name := p.DisplayName(ctx.Sender) if len(table.players) >= 4 { _ = p.SendMessage(ctx.RoomID, @@ -323,7 +322,7 @@ func (p *BlackjackPlugin) handleBlackjack(ctx MessageContext) error { } p.tables[ctx.RoomID] = table - name := p.bjDisplayName(ctx.Sender) + name := p.DisplayName(ctx.Sender) _ = p.SendMessage(ctx.RoomID, fmt.Sprintf("🃏 **%s** opens a Blackjack table! Bet: €%d\nJoin with `!blackjack €amount` or `!blackjack deal` to start now (60s to join)", name, int(bet))) @@ -357,7 +356,7 @@ func (p *BlackjackPlugin) handleLeave(ctx MessageContext) error { } player.Bust = true player.Done = true - name := p.bjDisplayName(ctx.Sender) + name := p.DisplayName(ctx.Sender) _ = p.SendMessage(ctx.RoomID, fmt.Sprintf("🏳️ **%s** forfeits! Bet lost.", name)) p.checkAllDone(ctx.RoomID, table) @@ -381,7 +380,7 @@ func (p *BlackjackPlugin) handleLeave(ctx MessageContext) error { delete(p.tables, ctx.RoomID) return p.SendMessage(ctx.RoomID, "🃏 Table closed — all players left.") } - name := p.bjDisplayName(ctx.Sender) + name := p.DisplayName(ctx.Sender) return p.SendMessage(ctx.RoomID, fmt.Sprintf("🃏 **%s** left the table. Bet refunded.", name)) } @@ -440,7 +439,7 @@ func (p *BlackjackPlugin) startRound(roomID id.RoomID, table *bjTable) { for _, pl := range table.players { if !pl.Done { allDone = false - activeNames = append(activeNames, p.bjDisplayName(pl.UserID)) + activeNames = append(activeNames, p.DisplayName(pl.UserID)) } } @@ -484,7 +483,7 @@ func (p *BlackjackPlugin) handleHit(ctx MessageContext) error { if v > 21 { player.Bust = true player.Done = true - name := p.bjDisplayName(player.UserID) + name := p.DisplayName(player.UserID) msgs := []string{fmt.Sprintf("💥 **%s** busts with %s (%d)!", name, handStr(player.Hand), v)} allDoneMsgs := p.collectAllDone(ctx.RoomID, table) p.mu.Unlock() @@ -496,7 +495,7 @@ func (p *BlackjackPlugin) handleHit(ctx MessageContext) error { if v == 21 { player.Done = true - name := p.bjDisplayName(player.UserID) + name := p.DisplayName(player.UserID) msgs := []string{fmt.Sprintf("**%s** has 21! %s", name, handStr(player.Hand))} allDoneMsgs := p.collectAllDone(ctx.RoomID, table) p.mu.Unlock() @@ -528,7 +527,7 @@ func (p *BlackjackPlugin) handleStand(ctx MessageContext) error { } player.Done = true - name := p.bjDisplayName(player.UserID) + name := p.DisplayName(player.UserID) msgs := []string{fmt.Sprintf("**%s** stands at %d.", name, player.value())} allDoneMsgs := p.collectAllDone(ctx.RoomID, table) p.mu.Unlock() @@ -552,7 +551,7 @@ func (p *BlackjackPlugin) collectAllDone(roomID id.RoomID, table *bjTable) []str var waiting []string for _, pl := range table.players { if !pl.Done { - waiting = append(waiting, p.bjDisplayName(pl.UserID)) + waiting = append(waiting, p.DisplayName(pl.UserID)) } } @@ -587,7 +586,7 @@ func (p *BlackjackPlugin) startRoundTimer(roomID id.RoomID, table *bjTable) { var waiting []string for _, pl := range tbl.players { if !pl.Done { - waiting = append(waiting, p.bjDisplayName(pl.UserID)) + waiting = append(waiting, p.DisplayName(pl.UserID)) } } if len(waiting) > 0 { @@ -613,7 +612,7 @@ func (p *BlackjackPlugin) startRoundTimer(roomID id.RoomID, table *bjTable) { continue } v := pl.value() - name := p.bjDisplayName(pl.UserID) + name := p.DisplayName(pl.UserID) if v >= p.cfg.AutoplayThreshold { _ = p.SendMessage(roomID, @@ -709,7 +708,7 @@ func (p *BlackjackPlugin) collectResolveRound(roomID id.RoomID, table *bjTable) sb.WriteString("🃏 **Round over!**\n\n") for _, pl := range table.players { - name := p.bjDisplayName(pl.UserID) + name := p.DisplayName(pl.UserID) playerValue := pl.value() playerBJ := isBlackjack(pl.Hand) @@ -794,7 +793,7 @@ func (p *BlackjackPlugin) renderTable(table *bjTable, showDealer bool) string { sb.WriteString("🃏 **Blackjack** — Round in progress\n\n") for _, pl := range table.players { - name := p.bjDisplayName(pl.UserID) + name := p.DisplayName(pl.UserID) v := pl.value() extra := "" if isBlackjack(pl.Hand) { @@ -842,7 +841,7 @@ func (p *BlackjackPlugin) handleBoard(ctx MessageContext) error { var played, won int rows.Scan(&userID, &earned, &played, &won) rank++ - name := p.bjDisplayName(id.UserID(userID)) + name := p.DisplayName(id.UserID(userID)) sb.WriteString(fmt.Sprintf("%d. **%s** — €%d (%d W in %d games)\n", rank, name, int(earned), won, played)) } @@ -854,12 +853,11 @@ func (p *BlackjackPlugin) handleBoard(ctx MessageContext) error { } func (p *BlackjackPlugin) recordBJScore(userID id.UserID, net float64) { - d := db.Get() won := 0 if net > 0 { won = 1 } - _, _ = d.Exec( + db.Exec("blackjack: record score", `INSERT INTO blackjack_scores (user_id, total_earned, games_played, games_won) VALUES (?, ?, 1, ?) ON CONFLICT(user_id) DO UPDATE SET @@ -873,10 +871,4 @@ func (p *BlackjackPlugin) recordBJScore(userID id.UserID, net float64) { } } -func (p *BlackjackPlugin) bjDisplayName(userID id.UserID) string { - resp, err := p.Client.GetDisplayName(context.Background(), userID) - if err != nil || resp.DisplayName == "" { - return string(userID) - } - return resp.DisplayName -} + diff --git a/internal/plugin/blackjack_test.go b/internal/plugin/blackjack_test.go new file mode 100644 index 0000000..c3cbe77 --- /dev/null +++ b/internal/plugin/blackjack_test.go @@ -0,0 +1,88 @@ +package plugin + +import "testing" + +func TestHandValue_SimpleHand(t *testing.T) { + cards := []card{{Rank: 10, Suit: spades}, {Rank: 7, Suit: hearts}} + val, soft := handValue(cards) + if val != 17 || soft { + t.Errorf("got %d soft=%v, want 17 soft=false", val, soft) + } +} + +func TestHandValue_AceAsSoft(t *testing.T) { + cards := []card{{Rank: 1, Suit: spades}, {Rank: 6, Suit: hearts}} + val, soft := handValue(cards) + if val != 17 || !soft { + t.Errorf("got %d soft=%v, want 17 soft=true", val, soft) + } +} + +func TestHandValue_AceDowngradesToOne(t *testing.T) { + cards := []card{ + {Rank: 1, Suit: spades}, + {Rank: 9, Suit: hearts}, + {Rank: 5, Suit: diamonds}, + } + val, soft := handValue(cards) + // A(11)+9+5 = 25, bust, so A=1: 1+9+5 = 15 + if val != 15 || soft { + t.Errorf("got %d soft=%v, want 15 soft=false", val, soft) + } +} + +func TestHandValue_TwoAces(t *testing.T) { + cards := []card{ + {Rank: 1, Suit: spades}, + {Rank: 1, Suit: hearts}, + } + val, soft := handValue(cards) + // A(11)+A(11) = 22, one A->1: 12, still soft + if val != 12 || !soft { + t.Errorf("got %d soft=%v, want 12 soft=true", val, soft) + } +} + +func TestHandValue_FaceCards(t *testing.T) { + cards := []card{{Rank: 11, Suit: spades}, {Rank: 12, Suit: hearts}} // J + Q + val, soft := handValue(cards) + if val != 20 || soft { + t.Errorf("got %d soft=%v, want 20 soft=false", val, soft) + } +} + +func TestHandValue_Bust(t *testing.T) { + cards := []card{ + {Rank: 10, Suit: spades}, + {Rank: 8, Suit: hearts}, + {Rank: 5, Suit: diamonds}, + } + val, _ := handValue(cards) + if val != 23 { + t.Errorf("got %d, want 23 (bust)", val) + } +} + +func TestIsBlackjack(t *testing.T) { + tests := []struct { + name string + cards []card + want bool + }{ + {"ace+king", []card{{1, spades}, {13, hearts}}, true}, + {"ace+ten", []card{{1, spades}, {10, hearts}}, true}, + {"ace+queen", []card{{1, spades}, {12, hearts}}, true}, + {"ten+jack", []card{{10, spades}, {11, hearts}}, false}, // 20, not 21 + {"three cards summing 21", []card{{7, spades}, {7, hearts}, {7, diamonds}}, false}, + {"ace+five", []card{{1, spades}, {5, hearts}}, false}, // 16 + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isBlackjack(tt.cards) + if got != tt.want { + t.Errorf("isBlackjack(%v) = %v, want %v", tt.cards, got, tt.want) + } + }) + } +} diff --git a/internal/plugin/concerts.go b/internal/plugin/concerts.go index 328467f..66f3e5a 100644 --- a/internal/plugin/concerts.go +++ b/internal/plugin/concerts.go @@ -92,12 +92,8 @@ func (p *ConcertsPlugin) OnMessage(ctx MessageContext) error { parts := strings.SplitN(args, " ", 2) sub := strings.ToLower(parts[0]) + // DB-only subcommands switch sub { - case "watch": - if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" { - return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !concerts watch <artist>") - } - return p.handleWatch(ctx, strings.TrimSpace(parts[1])) case "watching": return p.handleWatching(ctx) case "unwatch": @@ -105,9 +101,26 @@ func (p *ConcertsPlugin) OnMessage(ctx MessageContext) error { return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !concerts unwatch <artist>") } return p.handleUnwatch(ctx, strings.TrimSpace(parts[1])) - default: - return p.handleSearch(ctx, args) } + + // API-calling subcommands run async + go func() { + var err error + switch sub { + case "watch": + if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" { + err = p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !concerts watch <artist>") + } else { + err = p.handleWatch(ctx, strings.TrimSpace(parts[1])) + } + default: + err = p.handleSearch(ctx, args) + } + if err != nil { + slog.Error("concerts: handler error", "err", err) + } + }() + return nil } func (p *ConcertsPlugin) handleSearch(ctx MessageContext, artist string) error { @@ -222,14 +235,11 @@ func (p *ConcertsPlugin) fetchEvents(artist string) ([]bandsintownEvent, error) // Update cache data, _ := json.Marshal(events) - _, err = d.Exec( + db.Exec("concerts: cache write", `INSERT INTO concerts_cache (artist, data, cached_at) VALUES (?, ?, ?) ON CONFLICT(artist) DO UPDATE SET data = ?, cached_at = ?`, artistKey, string(data), time.Now().Unix(), string(data), time.Now().Unix(), ) - if err != nil { - slog.Error("concerts: cache write", "err", err) - } return events, nil } diff --git a/internal/plugin/countdown.go b/internal/plugin/countdown.go index 7583a22..b3d40cf 100644 --- a/internal/plugin/countdown.go +++ b/internal/plugin/countdown.go @@ -260,13 +260,9 @@ func (p *CountdownPlugin) handleShow(ctx MessageContext, idStr string) error { } func (p *CountdownPlugin) completeOldCountdowns() { - d := db.Get() cutoff := time.Now().UTC().AddDate(0, 0, -7).Format("2006-01-02") - _, err := d.Exec( + db.Exec("countdown: auto-complete", `UPDATE countdowns SET completed = 1 WHERE target_date < ? AND completed = 0`, cutoff, ) - if err != nil { - slog.Error("countdown: auto-complete", "err", err) - } } diff --git a/internal/plugin/euro.go b/internal/plugin/euro.go index 7f8ff36..29f0284 100644 --- a/internal/plugin/euro.go +++ b/internal/plugin/euro.go @@ -1,7 +1,6 @@ package plugin import ( - "context" "fmt" "log/slog" "os" @@ -234,8 +233,7 @@ func (p *EuroPlugin) GetBalance(userID id.UserID) float64 { } func (p *EuroPlugin) logTransaction(userID id.UserID, amount float64, reason string) { - d := db.Get() - _, _ = d.Exec( + db.Exec("euro: log transaction", "INSERT INTO euro_transactions (user_id, amount, reason) VALUES (?, ?, ?)", string(userID), amount, reason, ) @@ -309,7 +307,7 @@ func (p *EuroPlugin) handleBaltop(ctx MessageContext) error { var balance float64 rows.Scan(&userID, &balance) rank++ - name := p.displayName(id.UserID(userID)) + name := p.DisplayName(id.UserID(userID)) medal := "" switch rank { case 1: @@ -362,16 +360,9 @@ func (p *EuroPlugin) handleTransfer(ctx MessageContext) error { } p.Credit(targetID, amount, fmt.Sprintf("transfer from %s", ctx.Sender)) - senderName := p.displayName(ctx.Sender) - targetName := p.displayName(targetID) + senderName := p.DisplayName(ctx.Sender) + targetName := p.DisplayName(targetID) return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("💸 **%s** sent €%d to **%s**.", senderName, int(amount), targetName)) } -func (p *EuroPlugin) displayName(userID id.UserID) string { - resp, err := p.Client.GetDisplayName(context.Background(), userID) - if err != nil || resp.DisplayName == "" { - return string(userID) - } - return resp.DisplayName -} diff --git a/internal/plugin/flip.go b/internal/plugin/flip.go index 136e360..4372956 100644 --- a/internal/plugin/flip.go +++ b/internal/plugin/flip.go @@ -1,7 +1,6 @@ package plugin import ( - "context" "fmt" "math/rand/v2" "strings" @@ -143,7 +142,7 @@ func (p *FlipPlugin) handleLoseboard(ctx MessageContext) error { var sb strings.Builder sb.WriteString("🐝 **GogoBee's Trophy Wall**\n\n") for i, uid := range order { - name := p.flipDisplayName(id.UserID(uid)) + name := p.DisplayName(id.UserID(uid)) stats := users[uid] var parts []string for game, count := range stats.breakdown { @@ -163,8 +162,7 @@ func (p *FlipPlugin) handleLoseboard(ctx MessageContext) error { // recordBotDefeat increments the bot defeat counter for a user in a specific game. // This is a package-level function so all game plugins can call it. func recordBotDefeat(userID id.UserID, game string) { - d := db.Get() - _, _ = d.Exec( + db.Exec("flip: record bot defeat", `INSERT INTO bot_defeats (user_id, game, losses) VALUES (?, ?, 1) ON CONFLICT(user_id, game) DO UPDATE SET @@ -173,13 +171,7 @@ func recordBotDefeat(userID id.UserID, game string) { ) } -func (p *FlipPlugin) flipDisplayName(userID id.UserID) string { - resp, err := p.Client.GetDisplayName(context.Background(), userID) - if err != nil || resp.DisplayName == "" { - return string(userID) - } - return resp.DisplayName -} + // redirectToGamesRoom returns the room ID for games-restricted redirect. func redirectToGamesRoom(sender id.UserID) string { diff --git a/internal/plugin/forex.go b/internal/plugin/forex.go new file mode 100644 index 0000000..4dd85f6 --- /dev/null +++ b/internal/plugin/forex.go @@ -0,0 +1,262 @@ +package plugin + +import ( + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/id" +) + +// ForexPlugin tracks USD exchange rates against EUR and JPY using the +// Frankfurter API (ECB-sourced, no API key required). Stores daily history, +// computes buy signals, and provides rate alerts. +type ForexPlugin struct { + Base + httpClient *http.Client +} + +func NewForexPlugin(client *mautrix.Client) *ForexPlugin { + return &ForexPlugin{ + Base: NewBase(client), + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (p *ForexPlugin) Name() string { return "forex" } + +func (p *ForexPlugin) Commands() []CommandDef { + return []CommandDef{ + {Name: "fx", Description: "Forex rates and analysis", Usage: "!fx rate [EUR|JPY] · !fx report [EUR|JPY] · !fx setalert <cur> <rate> · !fx alerts · !fx delalert <cur> <rate>", Category: "Entertainment"}, + } +} + +func (p *ForexPlugin) Init() error { + go p.backfill() + return nil +} + +func (p *ForexPlugin) OnReaction(_ ReactionContext) error { return nil } + +func (p *ForexPlugin) OnMessage(ctx MessageContext) error { + if !p.IsCommand(ctx.Body, "fx") { + return nil + } + + args := strings.TrimSpace(p.GetArgs(ctx.Body, "fx")) + if args == "" { + return p.SendReply(ctx.RoomID, ctx.EventID, + "Usage: `!fx rate [EUR|JPY]` · `!fx report [EUR|JPY]` · `!fx setalert <currency> <rate>` · `!fx alerts` · `!fx delalert <currency> <rate>`") + } + + parts := strings.Fields(args) + sub := strings.ToLower(parts[0]) + + // DB-only and instant subcommands + switch sub { + case "setalert": + return p.cmdSetAlert(ctx, parts[1:]) + case "alerts": + return p.cmdListAlerts(ctx) + case "delalert": + return p.cmdDelAlert(ctx, parts[1:]) + case "help": + return p.SendReply(ctx.RoomID, ctx.EventID, fxHelpText) + } + + // API-calling subcommands run async + switch sub { + case "rate", "report": + go func() { + var err error + if sub == "rate" { + err = p.cmdRate(ctx, parts[1:]) + } else { + err = p.cmdReport(ctx, parts[1:]) + } + if err != nil { + slog.Error("forex: handler error", "err", err) + } + }() + return nil + default: + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Unknown subcommand `%s`. Try `!fx help`.", parts[0])) + } +} + +const fxHelpText = "**Forex Commands**\n\n" + + "`!fx rate [EUR|JPY]` — current rate + quick signal\n" + + "`!fx report [EUR|JPY]` — full analysis (averages, 52w range, buy score)\n" + + "`!fx setalert <currency> <rate>` — alert when USD/currency reaches threshold\n" + + "`!fx alerts` — list active alerts in this room\n" + + "`!fx delalert <currency> <rate>` — remove an alert\n" + + "`!fx help` — this message" + +// ── Command Handlers ──────────────────────────────────────────────────────── + +func (p *ForexPlugin) cmdRate(ctx MessageContext, args []string) error { + currencies := fxParseCurrencies(args) + rates, err := p.fxLiveRates(currencies) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not fetch rates: %v", err)) + } + + var lines []string + for _, cur := range currencies { + rate, ok := rates[cur] + if !ok { + continue + } + sig, err := p.fxComputeSignal(cur, rate) + if err != nil { + lines = append(lines, fmt.Sprintf("%s %s: %s (insufficient data for analysis)", fxMeta[cur].Emoji, cur, fxFormatRate(cur, rate))) + continue + } + lines = append(lines, sig.FormatQuick()) + } + return p.SendReply(ctx.RoomID, ctx.EventID, strings.Join(lines, "\n")) +} + +func (p *ForexPlugin) cmdReport(ctx MessageContext, args []string) error { + currencies := fxParseCurrencies(args) + rates, err := p.fxLiveRates(currencies) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not fetch rates: %v", err)) + } + + var sections []string + for _, cur := range currencies { + rate, ok := rates[cur] + if !ok { + continue + } + sig, err := p.fxComputeSignal(cur, rate) + if err != nil { + sections = append(sections, fmt.Sprintf("%s **%s** — %s\n_Insufficient history for full analysis._", fxMeta[cur].Emoji, cur, fxFormatRate(cur, rate))) + continue + } + sections = append(sections, sig.FormatReport()) + } + return p.SendReply(ctx.RoomID, ctx.EventID, strings.Join(sections, "\n\n---\n\n")) +} + +func (p *ForexPlugin) cmdSetAlert(ctx MessageContext, args []string) error { + if len(args) < 2 { + return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!fx setalert <currency> <rate>` — e.g. `!fx setalert JPY 155`") + } + cur := strings.ToUpper(args[0]) + if !fxIsTracked(cur) { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Unknown currency `%s`. Tracked: %s", cur, strings.Join(fxTrackedCurrencies, ", "))) + } + threshold, err := strconv.ParseFloat(args[1], 64) + if err != nil || threshold <= 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid rate `%s`.", args[1])) + } + if err := fxSaveAlert(string(ctx.Sender), cur, threshold); err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not save alert: %v", err)) + } + meta := fxMeta[cur] + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("Alert set: I'll notify when 1 USD >= **%s %s** %s", fxFormatRate(cur, threshold), cur, meta.Emoji)) +} + +func (p *ForexPlugin) cmdListAlerts(ctx MessageContext) error { + alerts, err := fxAlertsForUser(string(ctx.Sender)) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Error: %v", err)) + } + if len(alerts) == 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, "No active alerts. Set one with `!fx setalert <currency> <rate>`.") + } + var sb strings.Builder + sb.WriteString("**Your FX alerts:**\n") + for _, a := range alerts { + meta := fxMeta[a.Currency] + status := "" + if a.FiredAt != 0 { + status = fmt.Sprintf(" _(fired %s)_", time.Unix(a.FiredAt, 0).UTC().Format("Jan 2 15:04 UTC")) + } + sb.WriteString(fmt.Sprintf(" %s `%s %s`%s\n", + meta.Emoji, fxFormatRate(a.Currency, a.Threshold), a.Currency, status)) + } + sb.WriteString("\nAlerts are delivered via DM.") + return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) +} + +func (p *ForexPlugin) cmdDelAlert(ctx MessageContext, args []string) error { + if len(args) < 2 { + return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!fx delalert <currency> <rate>` — e.g. `!fx delalert JPY 155`") + } + cur := strings.ToUpper(args[0]) + threshold, err := strconv.ParseFloat(args[1], 64) + if err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid rate `%s`.", args[1])) + } + if err := fxDeleteAlert(string(ctx.Sender), cur, threshold); err != nil { + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Error: %v", err)) + } + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Alert for `%s %s` removed.", fxFormatRate(cur, threshold), cur)) +} + +// ── Daily Poll (called by cron) ───────────────────────────────────────────── + +func (p *ForexPlugin) DailyPoll() { + rates, err := p.fxFetchCurrent(fxTrackedCurrencies) + if err != nil { + slog.Error("forex: daily poll fetch failed", "err", err) + return + } + + today := time.Now().UTC().Format("2006-01-02") + for cur, rate := range rates { + fxSaveRate(cur, today, rate) + } + + fxResetExpiredAlerts() + p.checkAlerts(rates) +} + +func (p *ForexPlugin) checkAlerts(rates map[string]float64) { + alerts, err := fxAllAlerts() + if err != nil { + slog.Error("forex: alert check error", "err", err) + return + } + for _, a := range alerts { + if a.FiredAt != 0 { + continue + } + rate, ok := rates[a.Currency] + if !ok || rate < a.Threshold { + continue + } + meta := fxMeta[a.Currency] + msg := fmt.Sprintf("**FX Alert** %s — 1 USD = **%s %s** has reached your threshold of **%s**.", + meta.Emoji, fxFormatRate(a.Currency, rate), a.Currency, + fxFormatRate(a.Currency, a.Threshold)) + p.SendDM(id.UserID(a.UserID), msg) + fxMarkAlertFired(a) + } +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +func fxParseCurrencies(args []string) []string { + var out []string + for _, a := range args { + c := strings.ToUpper(a) + if fxIsTracked(c) { + out = append(out, c) + } + } + if len(out) == 0 { + return fxTrackedCurrencies + } + return out +} diff --git a/internal/plugin/forex_analysis.go b/internal/plugin/forex_analysis.go new file mode 100644 index 0000000..cdfd349 --- /dev/null +++ b/internal/plugin/forex_analysis.go @@ -0,0 +1,206 @@ +package plugin + +import ( + "fmt" + "math" + "strings" +) + +// ForexSignal holds the computed analysis for a currency pair. +type ForexSignal struct { + Currency string + Rate float64 + Avg30 float64 + Avg90 float64 + High52w float64 + Low52w float64 + Percentile float64 // 0-100: where current rate sits in 52w range + Score int // 1-10 + Label string // "Excellent", "Good", "Fair", "Poor" + Emoji string // color indicator +} + +// fxComputeSignal computes the full signal for a currency at the given rate. +func (p *ForexPlugin) fxComputeSignal(currency string, currentRate float64) (*ForexSignal, error) { + // Get last 260 trading days (~52 weeks) of data + records, err := fxGetRatesByLimit(currency, 260) + if err != nil || len(records) < 10 { + return nil, fmt.Errorf("insufficient data (%d records)", len(records)) + } + + // Append today's live rate for analysis + rates := make([]float64, len(records)+1) + for i, r := range records { + rates[i] = r.Rate + } + rates[len(records)] = currentRate + + n := len(rates) + + // 30-day and 90-day moving averages (trading days, not calendar) + avg30 := fxAvg(rates, 30) + avg90 := fxAvg(rates, 90) + + // 52-week high/low + high52w, low52w := rates[0], rates[0] + for _, r := range rates { + if r > high52w { + high52w = r + } + if r < low52w { + low52w = r + } + } + + // Percentile in 52w range + var percentile float64 + if high52w != low52w { + percentile = (currentRate - low52w) / (high52w - low52w) * 100 + } else { + percentile = 50 + } + + // Score: weighted combination of percentile position and deviation from averages + // Higher = USD is stronger = better time to convert + devScore := fxDeviationScore(currentRate, avg30, avg90) + rawScore := percentile/100*10*0.6 + devScore*0.4 + score := int(math.Round(rawScore)) + if score < 1 { + score = 1 + } + if score > 10 { + score = 10 + } + + label, emoji := fxScoreLabel(score) + + _ = n // rates slice used above + + return &ForexSignal{ + Currency: currency, + Rate: currentRate, + Avg30: avg30, + Avg90: avg90, + High52w: high52w, + Low52w: low52w, + Percentile: percentile, + Score: score, + Label: label, + Emoji: emoji, + }, nil +} + +// fxAvg computes the average of the last n values in a slice. +func fxAvg(rates []float64, n int) float64 { + if len(rates) < n { + n = len(rates) + } + if n == 0 { + return 0 + } + sum := 0.0 + start := len(rates) - n + for i := start; i < len(rates); i++ { + sum += rates[i] + } + return sum / float64(n) +} + +// fxDeviationScore computes how far above/below the moving averages the current rate is. +// +5% above average = 10, -5% below = 0, linear interpolation between. +func fxDeviationScore(current, avg30, avg90 float64) float64 { + avgAvg := (avg30 + avg90) / 2 + if avgAvg == 0 { + return 5 + } + dev := (current - avgAvg) / avgAvg * 100 // percent deviation + // Map [-5%, +5%] to [0, 10] + score := (dev + 5) / 10 * 10 + if score < 0 { + score = 0 + } + if score > 10 { + score = 10 + } + return score +} + +func fxScoreLabel(score int) (string, string) { + switch { + case score >= 8: + return "Excellent", "🟢" + case score >= 6: + return "Good", "🟡" + case score >= 4: + return "Fair", "🟠" + default: + return "Poor", "🔴" + } +} + +// ── Formatting ────────────────────────────────────────────────────────────── + +// FormatQuick returns a one-line summary. +func (s *ForexSignal) FormatQuick() string { + meta := fxMeta[s.Currency] + return fmt.Sprintf("%s **%s** 1 USD = **%s** %s · 30d avg: %s · USD strength: %d/10 %s %s", + meta.Emoji, s.Currency, fxFormatRate(s.Currency, s.Rate), s.Currency, + fxFormatRate(s.Currency, s.Avg30), + s.Score, s.Emoji, s.Label) +} + +// FormatReport returns a detailed analysis block. +func (s *ForexSignal) FormatReport() string { + meta := fxMeta[s.Currency] + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("%s **%s — %s**\n", meta.Emoji, s.Currency, meta.Label)) + sb.WriteString(fmt.Sprintf("**Current:** 1 USD = **%s %s**\n\n", fxFormatRate(s.Currency, s.Rate), s.Currency)) + + // Moving averages + sb.WriteString(fmt.Sprintf(" 30-day avg: %s", fxFormatRate(s.Currency, s.Avg30))) + if s.Rate > s.Avg30 { + sb.WriteString(fmt.Sprintf(" _(+%.1f%% above)_", (s.Rate-s.Avg30)/s.Avg30*100)) + } else if s.Rate < s.Avg30 { + sb.WriteString(fmt.Sprintf(" _(%.1f%% below)_", (s.Rate-s.Avg30)/s.Avg30*100)) + } + sb.WriteString("\n") + sb.WriteString(fmt.Sprintf(" 90-day avg: %s", fxFormatRate(s.Currency, s.Avg90))) + if s.Rate > s.Avg90 { + sb.WriteString(fmt.Sprintf(" _(+%.1f%% above)_", (s.Rate-s.Avg90)/s.Avg90*100)) + } else if s.Rate < s.Avg90 { + sb.WriteString(fmt.Sprintf(" _(%.1f%% below)_", (s.Rate-s.Avg90)/s.Avg90*100)) + } + sb.WriteString("\n\n") + + // 52-week range bar + sb.WriteString(fmt.Sprintf(" 52-week range: %s — %s\n", + fxFormatRate(s.Currency, s.Low52w), fxFormatRate(s.Currency, s.High52w))) + sb.WriteString(fmt.Sprintf(" %s\n", fxRangeBar(s.Percentile))) + sb.WriteString(fmt.Sprintf(" Position: %.0f%%\n\n", s.Percentile)) + + // Score + sb.WriteString(fmt.Sprintf(" **USD Strength: %d/10** %s %s\n", s.Score, s.Emoji, s.Label)) + sb.WriteString(" _Higher = USD buys more — good time to convert USD._") + + return sb.String() +} + +// fxRangeBar renders a text-based position indicator. +func fxRangeBar(percentile float64) string { + width := 20 + pos := int(percentile / 100 * float64(width)) + if pos < 0 { + pos = 0 + } + if pos >= width { + pos = width - 1 + } + + bar := make([]byte, width) + for i := range bar { + bar[i] = '-' + } + bar[pos] = '|' + return "[" + string(bar) + "]" +} diff --git a/internal/plugin/forex_api.go b/internal/plugin/forex_api.go new file mode 100644 index 0000000..cf2cc4d --- /dev/null +++ b/internal/plugin/forex_api.go @@ -0,0 +1,159 @@ +package plugin + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + "time" +) + +// ── Currency Configuration ────────────────────────────────────────────────── + +var fxTrackedCurrencies = []string{"EUR", "JPY"} + +type fxCurrencyMeta struct { + Emoji string + Label string +} + +var fxMeta = map[string]fxCurrencyMeta{ + "EUR": {Emoji: "🇪🇺", Label: "Euro"}, + "JPY": {Emoji: "🇯🇵", Label: "Japanese Yen"}, +} + +func fxIsTracked(cur string) bool { + for _, c := range fxTrackedCurrencies { + if c == cur { + return true + } + } + return false +} + +// fxFormatRate formats a rate: JPY uses 2 decimal places, others use 4. +func fxFormatRate(currency string, rate float64) string { + if currency == "JPY" { + return fmt.Sprintf("%.2f", rate) + } + return fmt.Sprintf("%.4f", rate) +} + +// ── Frankfurter v2 API ────────────────────────────────────────────────────── +// +// Frankfurter v2 (api.frankfurter.dev) tracks rates from 20 central banks, +// covering 150 currencies. No API key required, no usage limits. + +const frankfurterBaseURL = "https://api.frankfurter.dev/v2" + +// frankfurterV2Rate is one entry in the v2 response array. +type frankfurterV2Rate struct { + Date string `json:"date"` + Base string `json:"base"` + Quote string `json:"quote"` + Rate float64 `json:"rate"` +} + +// fxFetchCurrent fetches live rates from Frankfurter v2 with a context timeout +// so user commands don't block the dispatch pipeline if the API is slow. +func (p *ForexPlugin) fxFetchCurrent(currencies []string) (map[string]float64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + + url := fmt.Sprintf("%s/rates?base=USD"es=%s", frankfurterBaseURL, strings.Join(currencies, ",")) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("frankfurter API error: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("frankfurter API returned %d", resp.StatusCode) + } + + var data []frankfurterV2Rate + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, fmt.Errorf("frankfurter decode error: %w", err) + } + + rates := make(map[string]float64, len(data)) + for _, r := range data { + rates[r.Quote] = r.Rate + } + return rates, nil +} + +// fxFetchRange fetches historical rates for a date range from Frankfurter v2. +func (p *ForexPlugin) fxFetchRange(from, to time.Time, currencies []string) ([]frankfurterV2Rate, error) { + url := fmt.Sprintf("%s/rates?base=USD"es=%s&from=%s&to=%s", + frankfurterBaseURL, strings.Join(currencies, ","), + from.Format("2006-01-02"), to.Format("2006-01-02")) + + req, err := http.NewRequestWithContext(context.Background(), "GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("frankfurter API error: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("frankfurter API returned %d", resp.StatusCode) + } + + var data []frankfurterV2Rate + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, fmt.Errorf("frankfurter decode error: %w", err) + } + + return data, nil +} + +// fxLiveRates fetches current rates, falling back to the most recent stored rate. +func (p *ForexPlugin) fxLiveRates(currencies []string) (map[string]float64, error) { + rates, err := p.fxFetchCurrent(currencies) + if err != nil { + slog.Warn("forex: live fetch failed, using stored fallback", "err", err) + rates = make(map[string]float64) + for _, cur := range currencies { + if r, ok := fxLatestRate(cur); ok { + rates[cur] = r + } + } + if len(rates) == 0 { + return nil, fmt.Errorf("could not fetch rates and no stored data available") + } + } + return rates, nil +} + +// backfill fetches the trailing year of rates and saves them. +// Safe to call repeatedly — uses INSERT OR IGNORE. +func (p *ForexPlugin) backfill() { + end := time.Now().UTC() + start := end.AddDate(-1, 0, 0) + + slog.Info("forex: starting backfill", "from", start.Format("2006-01-02"), "to", end.Format("2006-01-02")) + + data, err := p.fxFetchRange(start, end, fxTrackedCurrencies) + if err != nil { + slog.Error("forex: backfill failed", "err", err) + return + } + + for _, r := range data { + fxSaveRate(r.Quote, r.Date, r.Rate) + } + + slog.Info("forex: backfill complete", "records", len(data)) +} diff --git a/internal/plugin/forex_store.go b/internal/plugin/forex_store.go new file mode 100644 index 0000000..ab1d20b --- /dev/null +++ b/internal/plugin/forex_store.go @@ -0,0 +1,140 @@ +package plugin + +import ( + "gogobee/internal/db" +) + +// ── Rate Storage ──────────────────────────────────────────────────────────── + +func fxSaveRate(currency, date string, rate float64) { + db.Exec("forex: save rate", + `INSERT OR IGNORE INTO forex_rates (currency, date, rate) VALUES (?, ?, ?)`, + currency, date, rate) +} + +type fxRateRecord struct { + Date string + Rate float64 +} + +// fxGetRates returns rates for a currency in the given date range, ordered by date. +// Uses ORDER BY date DESC LIMIT n when limit > 0 (for "last N trading days"). +func fxGetRatesByLimit(currency string, limit int) ([]fxRateRecord, error) { + d := db.Get() + rows, err := d.Query( + `SELECT date, rate FROM forex_rates WHERE currency = ? ORDER BY date DESC LIMIT ?`, + currency, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var records []fxRateRecord + for rows.Next() { + var r fxRateRecord + if err := rows.Scan(&r.Date, &r.Rate); err != nil { + continue + } + records = append(records, r) + } + + // Reverse to chronological order (oldest first) + for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 { + records[i], records[j] = records[j], records[i] + } + return records, nil +} + +// fxLatestRate returns the most recent stored rate for a currency. +func fxLatestRate(currency string) (float64, bool) { + d := db.Get() + var rate float64 + err := d.QueryRow( + `SELECT rate FROM forex_rates WHERE currency = ? ORDER BY date DESC LIMIT 1`, + currency).Scan(&rate) + if err != nil { + return 0, false + } + return rate, true +} + +// ── Alert Storage ─────────────────────────────────────────────────────────── + +type fxAlertRecord struct { + UserID string + Currency string + Threshold float64 + FiredAt int64 +} + +func fxSaveAlert(userID, currency string, threshold float64) error { + d := db.Get() + _, err := d.Exec( + `INSERT OR REPLACE INTO forex_alerts (user_id, currency, threshold, fired_at) + VALUES (?, ?, ?, 0)`, + userID, currency, threshold) + return err +} + +func fxAlertsForUser(userID string) ([]fxAlertRecord, error) { + d := db.Get() + rows, err := d.Query( + `SELECT user_id, currency, threshold, fired_at + FROM forex_alerts WHERE user_id = ? ORDER BY currency, threshold`, + userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var alerts []fxAlertRecord + for rows.Next() { + var a fxAlertRecord + if err := rows.Scan(&a.UserID, &a.Currency, &a.Threshold, &a.FiredAt); err != nil { + continue + } + alerts = append(alerts, a) + } + return alerts, nil +} + +func fxAllAlerts() ([]fxAlertRecord, error) { + d := db.Get() + rows, err := d.Query( + `SELECT user_id, currency, threshold, fired_at FROM forex_alerts`) + if err != nil { + return nil, err + } + defer rows.Close() + + var alerts []fxAlertRecord + for rows.Next() { + var a fxAlertRecord + if err := rows.Scan(&a.UserID, &a.Currency, &a.Threshold, &a.FiredAt); err != nil { + continue + } + alerts = append(alerts, a) + } + return alerts, nil +} + +func fxDeleteAlert(userID, currency string, threshold float64) error { + d := db.Get() + _, err := d.Exec( + `DELETE FROM forex_alerts WHERE user_id = ? AND currency = ? AND threshold = ?`, + userID, currency, threshold) + return err +} + +func fxMarkAlertFired(a fxAlertRecord) { + db.Exec("forex: mark alert fired", + `UPDATE forex_alerts SET fired_at = unixepoch() WHERE user_id = ? AND currency = ? AND threshold = ?`, + a.UserID, a.Currency, a.Threshold) +} + +// fxResetExpiredAlerts clears fired_at for alerts that fired more than 24 hours ago, +// allowing them to re-fire if the threshold is still met. +func fxResetExpiredAlerts() { + db.Exec("forex: reset expired alerts", + `UPDATE forex_alerts SET fired_at = 0 WHERE fired_at > 0 AND fired_at < unixepoch() - 86400`) +} diff --git a/internal/plugin/fun.go b/internal/plugin/fun.go index 20fe65b..43393f8 100644 --- a/internal/plugin/fun.go +++ b/internal/plugin/fun.go @@ -321,7 +321,7 @@ func (p *FunPlugin) handleHLTB(ctx MessageContext) error { } // Cache result - _, _ = db.Get().Exec( + db.Exec("hltb: cache result", `INSERT INTO hltb_cache (game_name, data, cached_at) VALUES (?, ?, ?) ON CONFLICT(game_name) DO UPDATE SET data = ?, cached_at = ?`, strings.ToLower(gameName), result, time.Now().Unix(), result, time.Now().Unix(), diff --git a/internal/plugin/gaming.go b/internal/plugin/gaming.go index 6a1c5d1..f8cd9ba 100644 --- a/internal/plugin/gaming.go +++ b/internal/plugin/gaming.go @@ -73,7 +73,12 @@ func (p *GamingPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *GamingPlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "releases") { - return p.handleReleases(ctx) + go func() { + if err := p.handleReleases(ctx); err != nil { + slog.Error("gaming: handler error", "err", err) + } + }() + return nil } if p.IsCommand(ctx.Body, "releasewatch") { return p.handleReleaseWatch(ctx) @@ -244,14 +249,11 @@ func (p *GamingPlugin) fetchReleases(startDate, endDate, cacheKey string) ([]rel // Cache results data, _ := json.Marshal(games) - _, err = d.Exec( + db.Exec("gaming: cache write", `INSERT INTO releases_cache (cache_key, data, cached_at) VALUES (?, ?, ?) ON CONFLICT(cache_key) DO UPDATE SET data = ?, cached_at = ?`, fullKey, string(data), time.Now().UTC().Unix(), string(data), time.Now().UTC().Unix(), ) - if err != nil { - slog.Error("gaming: cache write", "err", err) - } return games, nil } diff --git a/internal/plugin/hangman.go b/internal/plugin/hangman.go index bf21e2b..939bd5c 100644 --- a/internal/plugin/hangman.go +++ b/internal/plugin/hangman.go @@ -2,7 +2,6 @@ package plugin import ( "bufio" - "context" "encoding/json" "fmt" "log/slog" @@ -630,7 +629,7 @@ func (p *HangmanPlugin) endGame(roomID id.RoomID, game *hangmanGame) error { eligibleParticipants = append(eligibleParticipants, uid) } - solverName := p.displayName(game.solvedBy) + solverName := p.DisplayName(game.solvedBy) participantCount := len(eligibleParticipants) var sb strings.Builder @@ -652,7 +651,7 @@ func (p *HangmanPlugin) endGame(roomID id.RoomID, game *hangmanGame) error { } p.euro.Credit(uid, payout, "hangman_win") p.recordHangmanScore(uid, payout) - name := p.displayName(uid) + name := p.DisplayName(uid) sb.WriteString(fmt.Sprintf(" **%s**: +€%d%s\n", name, int(payout), label)) } } @@ -779,7 +778,7 @@ func (p *HangmanPlugin) handleBoard(ctx MessageContext) error { var won int rows.Scan(&userID, &earned, &won) rank++ - name := p.displayName(id.UserID(userID)) + name := p.DisplayName(id.UserID(userID)) sb.WriteString(fmt.Sprintf("%d. **%s** — €%d earned (%d wins)\n", rank, name, int(earned), won)) } @@ -791,8 +790,7 @@ func (p *HangmanPlugin) handleBoard(ctx MessageContext) error { } func (p *HangmanPlugin) recordHangmanScore(userID id.UserID, earned float64) { - d := db.Get() - _, _ = d.Exec( + db.Exec("hangman: record score", `INSERT INTO hangman_scores (user_id, total_earned, games_played, games_won) VALUES (?, ?, 1, 1) ON CONFLICT(user_id) DO UPDATE SET @@ -803,10 +801,3 @@ func (p *HangmanPlugin) recordHangmanScore(userID id.UserID, earned float64) { ) } -func (p *HangmanPlugin) displayName(userID id.UserID) string { - resp, err := p.Client.GetDisplayName(context.Background(), userID) - if err != nil || resp.DisplayName == "" { - return string(userID) - } - return resp.DisplayName -} diff --git a/internal/plugin/holdem.go b/internal/plugin/holdem.go index 3f59ab6..1ee238d 100644 --- a/internal/plugin/holdem.go +++ b/internal/plugin/holdem.go @@ -1,7 +1,6 @@ package plugin import ( - "context" "fmt" "log/slog" "strconv" @@ -97,6 +96,16 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error { return p.handleTipsToggle(ctx, args, isDM) } + // If this is a DM, resolve the player's game room so actions route correctly. + if isDM { + p.mu.Lock() + gameRoom := p.findGameRoom(ctx.Sender) + p.mu.Unlock() + if gameRoom != "" { + ctx.RoomID = gameRoom + } + } + // Room-only commands need games room check. if !isDM && !isGamesRoom(ctx.RoomID) { gr := gamesRoom() @@ -106,8 +115,10 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error { } switch { - case args == "join": - return p.handleJoin(ctx) + case args == "play" || strings.HasPrefix(args, "play "): + return p.handlePlay(ctx, strings.TrimSpace(strings.TrimPrefix(args, "play"))) + case args == "join" || strings.HasPrefix(args, "join "): + return p.handleJoin(ctx, strings.TrimSpace(strings.TrimPrefix(args, "join"))) case args == "leave": return p.handleLeave(ctx) case args == "start": @@ -139,6 +150,17 @@ func (p *HoldemPlugin) isDMRoom(roomID id.RoomID) bool { return len(members) <= 2 } +// findGameRoom returns the game room ID for a player, or empty if not found. +// Must be called with p.mu held. +func (p *HoldemPlugin) findGameRoom(userID id.UserID) id.RoomID { + for _, game := range p.games { + if game.playerByUserID(userID) != nil { + return game.RoomID + } + } + return "" +} + func (p *HoldemPlugin) handleTipsToggle(ctx MessageContext, args string, isDM bool) error { if !isDM { return p.SendReply(ctx.RoomID, ctx.EventID, "Tips can only be toggled in DM. Send `!holdem tips on/off` directly to me.") @@ -170,7 +192,40 @@ func (p *HoldemPlugin) handleTipsToggle(ctx MessageContext, args string, isDM bo } } -func (p *HoldemPlugin) handleJoin(ctx MessageContext) error { +// handlePlay is a shortcut that joins the player, adds the bot, and starts dealing. +func (p *HoldemPlugin) handlePlay(ctx MessageContext, amountStr string) error { + p.mu.Lock() + + // If a game already exists with a hand in progress, treat as a regular join. + if game := p.games[ctx.RoomID]; game != nil && game.HandInProgress { + p.mu.Unlock() + return p.handleJoin(ctx, amountStr) + } + + // If player is already seated, just try to add bot + start. + if game := p.games[ctx.RoomID]; game != nil && game.playerByUserID(ctx.Sender) != nil { + p.mu.Unlock() + if err := p.handleAddBot(ctx); err != nil { + slog.Warn("holdem: addbot failed", "room", ctx.RoomID, "err", err) + } + return p.handleStart(ctx) + } + p.mu.Unlock() + + // Join the table. + if err := p.handleJoin(ctx, amountStr); err != nil { + return err + } + + if err := p.handleAddBot(ctx); err != nil { + slog.Warn("holdem: addbot failed", "room", ctx.RoomID, "err", err) + } + + // Start dealing. + return p.handleStart(ctx) +} + +func (p *HoldemPlugin) handleJoin(ctx MessageContext, amountStr string) error { p.mu.Lock() defer p.mu.Unlock() @@ -186,6 +241,7 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext) error { WaitingForPlayers: true, } p.games[ctx.RoomID] = game + p.startIdleTimer(game) } // Check if already seated. @@ -212,12 +268,36 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext) error { } // Get display name. - name := p.holdemDisplayName(ctx.Sender) + name := p.DisplayName(ctx.Sender) - // Determine buy-in (capped at MaxBuyin). - buyin := int64(balance) - if buyin > p.cfg.MaxBuyin { + // Determine buy-in: player-specified, or max buy-in, capped by balance. + var buyin int64 + if amountStr != "" { + // Strip € prefix if present. + amountStr = strings.TrimPrefix(amountStr, "€") + requested, err := strconv.ParseInt(amountStr, 10, 64) + if err != nil || requested <= 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("Usage: `!holdem join [amount]` — amount between €%d and €%d.", p.cfg.MinBuyin, p.cfg.MaxBuyin)) + } + if requested < p.cfg.MinBuyin { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("Minimum buy-in is €%d.", p.cfg.MinBuyin)) + } + if requested > p.cfg.MaxBuyin { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("Maximum buy-in is €%d.", p.cfg.MaxBuyin)) + } + if requested > int64(balance) { + return p.SendReply(ctx.RoomID, ctx.EventID, + fmt.Sprintf("You only have €%.0f.", balance)) + } + buyin = requested + } else { buyin = p.cfg.MaxBuyin + if int64(balance) < buyin { + buyin = int64(balance) + } } // Debit buy-in immediately to prevent double-spending across tables. @@ -270,12 +350,13 @@ func (p *HoldemPlugin) handleLeave(ctx MessageContext) error { if !game.HandInProgress || player.SittingOut { // Credit remaining stack back (buy-in was debited at join). - if !player.IsNPC && player.Stack > 0 { - p.euro.Credit(player.UserID, float64(player.Stack), "holdem_cashout") + cashout := player.Stack + if !player.IsNPC && cashout > 0 { + p.euro.Credit(player.UserID, float64(cashout), "holdem_cashout") } // Remove immediately. p.removePlayer(game, ctx.Sender) - p.SendMessage(ctx.RoomID, fmt.Sprintf("**%s** has left the table.", player.DisplayName)) + p.SendMessage(ctx.RoomID, fmt.Sprintf("**%s** has left the table. (€%d returned)", player.DisplayName, cashout)) if len(game.Players) == 0 { delete(p.games, ctx.RoomID) @@ -286,6 +367,7 @@ func (p *HoldemPlugin) handleLeave(ctx MessageContext) error { idx := game.playerIdx(ctx.Sender) if idx == game.ActionIdx { // It's their turn — fold and remove. + p.stopActionTimer(game) game.doFold(idx) p.SendMessage(ctx.RoomID, fmt.Sprintf("**%s** folds and leaves the table.", player.DisplayName)) @@ -510,6 +592,8 @@ func (p *HoldemPlugin) handleAddBot(ctx MessageContext) error { // --- Game lifecycle --- func (p *HoldemPlugin) startHand(game *HoldemGame) { + p.stopIdleTimer(game) + // Unsit players who were waiting. for _, pl := range game.Players { if pl.SittingOut { @@ -546,6 +630,7 @@ func (p *HoldemPlugin) startHand(game *HoldemGame) { pl.State = PlayerActive pl.Bet = 0 pl.TotalBet = 0 + pl.HasActed = false pl.Hole = [2]poker.Card{} } @@ -570,8 +655,9 @@ func (p *HoldemPlugin) startHand(game *HoldemGame) { p.SendMessage(game.RoomID, renderStartAnnouncement(game)) // Send hole cards to all players via DM. + // Skip the first-to-act player — they'll get hand + table from sendTurnNotifications. for i, pl := range game.Players { - if pl.IsNPC || pl.State == PlayerSatOut { + if pl.IsNPC || pl.State == PlayerSatOut || i == game.ActionIdx { continue } view := renderPrivateHand(game, i) @@ -807,11 +893,15 @@ func (p *HoldemPlugin) endHand(game *HoldemGame) { game.HandInProgress = false game.Street = StreetPreFlop - // Remove players who want to leave. + // Remove players who want to leave or busted out. + // Credit their remaining stack (buy-in was debited at join). for i := len(game.Players) - 1; i >= 0; i-- { pl := game.Players[i] if pl.WantsLeave || pl.Stack <= 0 { if !pl.IsNPC { + if pl.Stack > 0 { + p.euro.Credit(pl.UserID, float64(pl.Stack), "holdem_cashout") + } p.SendMessage(game.RoomID, fmt.Sprintf("**%s** has left the table.", pl.DisplayName)) } game.Players = append(game.Players[:i], game.Players[i+1:]...) @@ -824,12 +914,19 @@ func (p *HoldemPlugin) endHand(game *HoldemGame) { } if len(game.Players) < 2 { + // Cash out remaining players. + for _, pl := range game.Players { + if !pl.IsNPC && pl.Stack > 0 { + p.euro.Credit(pl.UserID, float64(pl.Stack), "holdem_cashout") + } + } p.SendMessage(game.RoomID, "Not enough players for another hand. Game over.") delete(p.games, game.RoomID) return } p.SendMessage(game.RoomID, fmt.Sprintf("Hand complete. %d players at the table. Type `!holdem start` for the next hand.", len(game.Players))) + p.startIdleTimer(game) } // --- Notifications --- @@ -848,11 +945,12 @@ func (p *HoldemPlugin) sendTurnNotifications(game *HoldemGame) { hand := renderPrivateHand(game, game.ActionIdx) p.SendMessage(actionPlayer.DMRoomID, hand+"\n"+view) - // Generate tip asynchronously — build context under lock, generate outside. + // Generate tip asynchronously — snapshot under lock, compute equity + generate outside. if actionPlayer.TipsEnabled { - tipCtx := buildTipContext(game, game.ActionIdx) + snap := snapshotForTip(game, game.ActionIdx) dmRoom := actionPlayer.DMRoomID go func() { + tipCtx := buildTipContext(snap) tip := generateTip(tipCtx) p.SendMessage(dmRoom, tip) }() @@ -892,6 +990,9 @@ func (p *HoldemPlugin) npcAct(game *HoldemGame) { return } + // Stop the action timer so it can't fire while we process. + p.stopActionTimer(game) + actionStr, amount := cfrActionToGameAction(action, game, npcIdx) var result ActionResult @@ -1016,6 +1117,55 @@ func (p *HoldemPlugin) stopActionTimer(game *HoldemGame) { } } +// startIdleTimer starts a 1-hour timer that closes the table if no hand is started. +// A warning is sent at the 45-minute mark. +// Call when a game lobby is created or when a hand ends. +func (p *HoldemPlugin) startIdleTimer(game *HoldemGame) { + p.stopIdleTimer(game) + roomID := game.RoomID + + // Warning at 45 minutes. + game.idleWarningTimer = time.AfterFunc(45*time.Minute, func() { + p.mu.Lock() + defer p.mu.Unlock() + g := p.games[roomID] + if g == nil || g.HandInProgress { + return + } + p.SendMessage(roomID, "⏰ Table will close in **15 minutes** due to inactivity. Type `!holdem start` to play or `!holdem leave` to cash out.") + }) + + game.idleTimer = time.AfterFunc(1*time.Hour, func() { + p.mu.Lock() + defer p.mu.Unlock() + + g := p.games[roomID] + if g == nil || g.HandInProgress { + return + } + + // Refund all players and close the table. + for _, pl := range g.Players { + if !pl.IsNPC && pl.Stack > 0 { + p.euro.Credit(pl.UserID, float64(pl.Stack), "holdem_idle_refund") + } + } + p.SendMessage(roomID, "Table closed — 1 hour of inactivity. All buy-ins have been refunded.") + delete(p.games, roomID) + }) +} + +func (p *HoldemPlugin) stopIdleTimer(game *HoldemGame) { + if game.idleWarningTimer != nil { + game.idleWarningTimer.Stop() + game.idleWarningTimer = nil + } + if game.idleTimer != nil { + game.idleTimer.Stop() + game.idleTimer = nil + } +} + // --- Helpers --- func (p *HoldemPlugin) removePlayer(game *HoldemGame, uid id.UserID) { @@ -1023,13 +1173,15 @@ func (p *HoldemPlugin) removePlayer(game *HoldemGame, uid id.UserID) { if pl.UserID == uid { game.Players = append(game.Players[:i], game.Players[i+1:]...) // Adjust seat indices that reference positions after the removed player. - if game.DealerIdx >= i && game.DealerIdx > 0 { + // Only decrement when strictly greater — if equal, the next player + // slides into the same index so no adjustment is needed. + if game.DealerIdx > i { game.DealerIdx-- } - if game.ActionIdx >= i && game.ActionIdx > 0 { + if game.ActionIdx > i { game.ActionIdx-- } - if game.LastAggressorIdx >= i && game.LastAggressorIdx > 0 { + if game.LastAggressorIdx > i { game.LastAggressorIdx-- } // Wrap indices if they exceed the new length. @@ -1043,13 +1195,6 @@ func (p *HoldemPlugin) removePlayer(game *HoldemGame, uid id.UserID) { } } -func (p *HoldemPlugin) holdemDisplayName(userID id.UserID) string { - resp, err := p.Client.GetDisplayName(context.Background(), userID) - if err != nil || resp.DisplayName == "" { - return string(userID) - } - return resp.DisplayName -} func (p *HoldemPlugin) getNPCBalance() int64 { d := db.Get() @@ -1057,7 +1202,8 @@ func (p *HoldemPlugin) getNPCBalance() int64 { err := d.QueryRow(`SELECT balance FROM holdem_npc_balance WHERE npc_name = ?`, p.cfg.NPCName).Scan(&balance) if err != nil { // Initialize. - _, _ = d.Exec(`INSERT OR IGNORE INTO holdem_npc_balance (npc_name, balance) VALUES (?, ?)`, + db.Exec("holdem: init npc balance", + `INSERT OR IGNORE INTO holdem_npc_balance (npc_name, balance) VALUES (?, ?)`, p.cfg.NPCName, p.cfg.NPCHouseBalance) return p.cfg.NPCHouseBalance } @@ -1065,13 +1211,12 @@ func (p *HoldemPlugin) getNPCBalance() int64 { } func (p *HoldemPlugin) updateNPCBalance(delta int64) { - d := db.Get() - _, _ = d.Exec(`UPDATE holdem_npc_balance SET balance = balance + ?, hands_played = hands_played + 1 WHERE npc_name = ?`, + db.Exec("holdem: update npc balance", + `UPDATE holdem_npc_balance SET balance = balance + ?, hands_played = hands_played + 1 WHERE npc_name = ?`, delta, p.cfg.NPCName) } func (p *HoldemPlugin) recordScores(game *HoldemGame, winnings map[id.UserID]int64) { - d := db.Get() for _, pl := range game.Players { if pl.IsNPC { continue @@ -1089,7 +1234,7 @@ func (p *HoldemPlugin) recordScores(game *HoldemGame, winnings map[id.UserID]int } } - _, _ = d.Exec( + db.Exec("holdem: record player score", `INSERT INTO holdem_scores (user_id, hands_played, total_won, total_lost, biggest_pot) VALUES (?, 1, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET diff --git a/internal/plugin/holdem_betting.go b/internal/plugin/holdem_betting.go index 6364c42..268021d 100644 --- a/internal/plugin/holdem_betting.go +++ b/internal/plugin/holdem_betting.go @@ -56,8 +56,8 @@ func (g *HoldemGame) postBlinds() (sbIdx, bbIdx int) { // firstToActPreflop returns the seat index of the first player to act preflop. func (g *HoldemGame) firstToActPreflop(bbIdx int) int { - n := len(g.Players) - if n == 2 { + inHand := len(g.inHandPlayers()) + if inHand == 2 { // Heads-up: dealer/SB acts first preflop. return g.DealerIdx } @@ -83,6 +83,7 @@ type ActionResult struct { func (g *HoldemGame) doFold(seatIdx int) ActionResult { p := g.Players[seatIdx] p.State = PlayerFolded + p.HasActed = true g.StreetHistory += "f" ann := renderActionAnnouncement(p.DisplayName, "fold", 0) @@ -105,6 +106,7 @@ func (g *HoldemGame) doCheck(seatIdx int) (ActionResult, string) { return ActionResult{}, "You must call, raise, or fold — there's a bet to you." } + p.HasActed = true g.StreetHistory += "c" ann := renderActionAnnouncement(p.DisplayName, "check", 0) return ActionResult{ @@ -127,6 +129,7 @@ func (g *HoldemGame) doCall(seatIdx int) (ActionResult, string) { p.Stack -= toCall p.Bet += toCall p.TotalBet += toCall + p.HasActed = true action := "call" if p.Stack == 0 { @@ -171,6 +174,7 @@ func (g *HoldemGame) doRaise(seatIdx int, raiseTo int64) (ActionResult, string) p.Stack -= raiseAmount p.Bet = raiseTo p.TotalBet += raiseAmount + p.HasActed = true if actualRaise > 0 { g.MinRaise = actualRaise @@ -214,6 +218,7 @@ func (g *HoldemGame) doAllIn(seatIdx int) ActionResult { p.Bet = totalBet p.TotalBet += allInAmount p.State = PlayerAllIn + p.HasActed = true if totalBet > g.CurrentBet { actualRaise := totalBet - g.CurrentBet @@ -247,11 +252,16 @@ func (g *HoldemGame) isStreetComplete(nextIdx int) bool { return true } - // Check if all Active players have matched the bet. + // Check if all Active players have matched the bet AND have acted at least once. + // The acted check is critical for preflop: BB posts a blind but hasn't voluntarily + // acted yet, so the street can't end before BB gets their option. for _, p := range g.Players { if p.State == PlayerActive && p.Bet != g.CurrentBet { return false } + if p.State == PlayerActive && !p.HasActed { + return false + } } // If the last aggressor is all-in (can't act), the street is done when @@ -364,10 +374,8 @@ func (g *HoldemGame) returnUncalledBet() (name string, amount int64) { p := g.Players[highestIdx] p.Stack += excess p.TotalBet -= excess - p.Bet -= excess - if p.Bet < 0 { - p.Bet = 0 - } + // Don't adjust per-street Bet — it may be smaller than the hand-total + // excess, and it's irrelevant since the hand is ending. return p.DisplayName, excess } diff --git a/internal/plugin/holdem_equity.go b/internal/plugin/holdem_equity.go index bf27961..65ad940 100644 --- a/internal/plugin/holdem_equity.go +++ b/internal/plugin/holdem_equity.go @@ -1,7 +1,9 @@ package plugin import ( + "fmt" "math/rand/v2" + "strings" "github.com/chehsunliu/poker" ) @@ -112,3 +114,224 @@ func Equity(hole [2]poker.Card, community []poker.Card, numOpponents, iterations Loss: float64(losses) / total, } } + +// DrawInfo holds computed draw information for tip generation. +type DrawInfo struct { + IsDraw bool + FlushDrawOuts int + StraightDrawOuts int + TotalOuts int + Description string // e.g. "flush draw + gutshot (13 outs)" +} + +// computeDraws analyzes hole cards and community for flush and straight draws. +// Only meaningful on flop and turn (not preflop, not river). +func computeDraws(hole [2]poker.Card, community []poker.Card) DrawInfo { + if len(community) < 3 || len(community) > 4 { + return DrawInfo{} + } + + all := make([]poker.Card, 0, 7) + all = append(all, hole[0], hole[1]) + all = append(all, community...) + + flushOuts := countFlushOuts(hole, community) + straightOuts := countStraightOuts(all, hole, community) + + total := flushOuts + straightOuts + if total > 15 { + total = 15 // cap to avoid double-counting + } + + if total == 0 { + // Check backdoor draws (only on flop) + if len(community) == 3 { + return computeBackdoorDraws(hole, community) + } + return DrawInfo{} + } + + var parts []string + if flushOuts >= 8 { + parts = append(parts, "flush draw") + } + if straightOuts == 8 { + parts = append(parts, "open-ended straight draw") + } else if straightOuts == 4 { + parts = append(parts, "gutshot straight draw") + } + + desc := fmt.Sprintf("%s (%d outs)", strings.Join(parts, " + "), total) + + return DrawInfo{ + IsDraw: true, + FlushDrawOuts: flushOuts, + StraightDrawOuts: straightOuts, + TotalOuts: total, + Description: desc, + } +} + +// countFlushOuts returns 9 if we have a flush draw (4 to a flush), 0 otherwise. +func countFlushOuts(hole [2]poker.Card, community []poker.Card) int { + suitCounts := map[int32]int{} + holeSuits := map[int32]bool{} + + for _, c := range []poker.Card{hole[0], hole[1]} { + s := c.Suit() + suitCounts[s]++ + holeSuits[s] = true + } + for _, c := range community { + suitCounts[c.Suit()]++ + } + + for s, count := range suitCounts { + if count == 4 && holeSuits[s] { + return 9 // 13 cards of suit minus 4 seen = 9 outs + } + } + return 0 +} + +// countStraightOuts returns the number of straight outs (8 for OESD, 4 for gutshot). +func countStraightOuts(allCards []poker.Card, hole [2]poker.Card, community []poker.Card) int { + // Get unique ranks present (0-12 where 0=2, 12=A) + rankSet := uint16(0) + for _, c := range allCards { + rankSet |= 1 << uint(c.Rank()) + } + + // Already have a straight? (5+ consecutive bits) + if hasStraight(rankSet) { + return 0 + } + + // Try adding each rank not already present; if it completes a straight, it's an out. + // But only count if at least one hole card is part of the straight. + outs := 0 + for r := int32(0); r < 13; r++ { + if rankSet&(1<<uint(r)) != 0 { + continue // already have this rank + } + test := rankSet | (1 << uint(r)) + if hasStraight(test) { + // Verify at least one hole card participates in the completed straight. + if holeParticipatesInStraight(test, hole) { + // Count available cards of this rank (4 minus those on board) + available := 4 + for _, c := range community { + if c.Rank() == r { + available-- + } + } + outs += available + } + } + } + + // Normalize: OESD = 8, gutshot = 4, double gutshot = 8 + if outs > 8 { + outs = 8 + } + return outs +} + +// hasStraight checks if a rank bitset contains 5+ consecutive ranks. +// Handles A-low straight (A-2-3-4-5) by duplicating ace as rank -1. +func hasStraight(ranks uint16) bool { + // Check A-low straight: A(12), 2(0), 3(1), 4(2), 5(3) + if ranks&0x100F == 0x100F { // bits 0,1,2,3,12 + return true + } + + consecutive := 0 + for i := uint(0); i < 13; i++ { + if ranks&(1<<i) != 0 { + consecutive++ + if consecutive >= 5 { + return true + } + } else { + consecutive = 0 + } + } + return false +} + +// holeParticipatesInStraight checks if at least one hole card rank is part of +// any 5-consecutive-rank window in the given rank set. +func holeParticipatesInStraight(ranks uint16, hole [2]poker.Card) bool { + hr0 := uint(hole[0].Rank()) + hr1 := uint(hole[1].Rank()) + + // Check each possible 5-card window + for start := uint(0); start <= 8; start++ { + window := uint16(0x1F) << start // 5 consecutive bits + if ranks&window == window { + if hr0 >= start && hr0 < start+5 { + return true + } + if hr1 >= start && hr1 < start+5 { + return true + } + } + } + + // Check A-low straight (A=12, 2=0, 3=1, 4=2, 5=3) + if ranks&0x100F == 0x100F && ranks&0x6 == 0x6 { // A,2,3,4,5 + if hr0 == 12 || hr0 <= 3 || hr1 == 12 || hr1 <= 3 { + return true + } + } + + return false +} + +// computeBackdoorDraws detects backdoor flush/straight draws (flop only). +func computeBackdoorDraws(hole [2]poker.Card, community []poker.Card) DrawInfo { + var parts []string + totalOuts := 0 + + // Backdoor flush: 3 to a flush with at least one hole card + suitCounts := map[int32]int{} + holeSuits := map[int32]bool{} + for _, c := range []poker.Card{hole[0], hole[1]} { + s := c.Suit() + suitCounts[s]++ + holeSuits[s] = true + } + for _, c := range community { + suitCounts[c.Suit()]++ + } + for s, count := range suitCounts { + if count == 3 && holeSuits[s] { + parts = append(parts, "backdoor flush") + totalOuts += 1 + break + } + } + + // Backdoor straight: 3 to a straight with connected hole cards + // Simplified: if hole cards are within 4 ranks of each other, count it + r0 := hole[0].Rank() + r1 := hole[1].Rank() + gap := r0 - r1 + if gap < 0 { + gap = -gap + } + if gap >= 1 && gap <= 4 { + parts = append(parts, "backdoor straight") + totalOuts += 1 + } + + if len(parts) == 0 { + return DrawInfo{} + } + + return DrawInfo{ + IsDraw: true, + TotalOuts: totalOuts, + Description: fmt.Sprintf("%s (%d outs)", strings.Join(parts, " + "), totalOuts), + } +} diff --git a/internal/plugin/holdem_eval.go b/internal/plugin/holdem_eval.go index b991b81..90b61bb 100644 --- a/internal/plugin/holdem_eval.go +++ b/internal/plugin/holdem_eval.go @@ -142,15 +142,8 @@ func awardPotToLastPlayer(g *HoldemGame) (string, id.UserID) { return ann, winner.UserID } -// settleNetDeltas credits each player's remaining stack back to their balance. -// Buy-in was debited at join time, so we credit the full final stack. -func settleNetDeltas(g *HoldemGame, euro *EuroPlugin) { - for _, p := range g.Players { - if p.IsNPC || p.State == PlayerSatOut { - continue - } - if p.Stack > 0 { - euro.Credit(p.UserID, float64(p.Stack), "holdem_cashout") - } - } -} +// settleNetDeltas is intentionally a no-op. +// Economy settlement happens at leave time: buy-in is debited at join, +// full remaining stack is credited at leave/cashout. Per-hand settlement +// would double-count because the stack already reflects cumulative results. +func settleNetDeltas(_ *HoldemGame, _ *EuroPlugin) {} diff --git a/internal/plugin/holdem_eval_test.go b/internal/plugin/holdem_eval_test.go new file mode 100644 index 0000000..22c923e --- /dev/null +++ b/internal/plugin/holdem_eval_test.go @@ -0,0 +1,183 @@ +package plugin + +import ( + "testing" + + "github.com/chehsunliu/poker" + "maunium.net/go/mautrix/id" +) + +func TestHandRank_HighCard(t *testing.T) { + hole := [2]poker.Card{ + poker.NewCard("2s"), + poker.NewCard("7h"), + } + community := []poker.Card{ + poker.NewCard("9d"), + poker.NewCard("Jc"), + poker.NewCard("Ks"), + poker.NewCard("4h"), + poker.NewCard("3d"), + } + rank, _ := handRank(hole, community) + if rank <= 0 { + t.Errorf("expected positive rank, got %d", rank) + } +} + +func TestHandRank_Pair(t *testing.T) { + hole := [2]poker.Card{ + poker.NewCard("As"), + poker.NewCard("Ah"), + } + community := []poker.Card{ + poker.NewCard("2d"), + poker.NewCard("5c"), + poker.NewCard("9s"), + poker.NewCard("Jh"), + poker.NewCard("Kd"), + } + _, name := handRank(hole, community) + if name != "Pair" { + t.Errorf("expected Pair, got %q", name) + } +} + +func TestHandRank_Flush(t *testing.T) { + hole := [2]poker.Card{ + poker.NewCard("2s"), + poker.NewCard("5s"), + } + community := []poker.Card{ + poker.NewCard("8s"), + poker.NewCard("Js"), + poker.NewCard("Ks"), + poker.NewCard("3h"), + poker.NewCard("7d"), + } + _, name := handRank(hole, community) + if name != "Flush" { + t.Errorf("expected Flush, got %q", name) + } +} + +func TestHandRank_FullHouse(t *testing.T) { + hole := [2]poker.Card{ + poker.NewCard("As"), + poker.NewCard("Ah"), + } + community := []poker.Card{ + poker.NewCard("Ad"), + poker.NewCard("Kc"), + poker.NewCard("Ks"), + poker.NewCard("2h"), + poker.NewCard("3d"), + } + _, name := handRank(hole, community) + if name != "Full House" { + t.Errorf("expected Full House, got %q", name) + } +} + +func TestHandRank_BetterHandHasLowerRank(t *testing.T) { + community := []poker.Card{ + poker.NewCard("2d"), + poker.NewCard("5c"), + poker.NewCard("9s"), + poker.NewCard("Jh"), + poker.NewCard("Kd"), + } + // Player 1: pair of aces + pairHole := [2]poker.Card{poker.NewCard("As"), poker.NewCard("Ah")} + pairRank, _ := handRank(pairHole, community) + + // Player 2: high card 7 + highHole := [2]poker.Card{poker.NewCard("3s"), poker.NewCard("7h")} + highRank, _ := handRank(highHole, community) + + // Lower rank = better hand in poker lib + if pairRank >= highRank { + t.Errorf("pair (rank %d) should beat high card (rank %d) — lower is better", pairRank, highRank) + } +} + +func TestDistributePot_SingleWinner(t *testing.T) { + g := &HoldemGame{ + Players: make([]*HoldemPlayer, 2), + } + g.Players[0] = &HoldemPlayer{UserID: "@alice:test", Stack: 0} + g.Players[1] = &HoldemPlayer{UserID: "@bob:test", Stack: 0} + + eligible := []id.UserID{"@alice:test", "@bob:test"} + evaluated := []evaluatedPlayer{ + {seatIdx: 0, rank: 100, name: "Flush", userID: "@alice:test"}, + {seatIdx: 1, rank: 500, name: "Pair", userID: "@bob:test"}, + } + winnings := make(map[id.UserID]int64) + var results []showdownResult + + distributePot(g, 1000, eligible, evaluated, winnings, &results) + + if winnings["@alice:test"] != 1000 { + t.Errorf("alice should win 1000, got %d", winnings["@alice:test"]) + } + if winnings["@bob:test"] != 0 { + t.Errorf("bob should win 0, got %d", winnings["@bob:test"]) + } +} + +func TestDistributePot_SplitPot(t *testing.T) { + g := &HoldemGame{ + Players: make([]*HoldemPlayer, 2), + } + g.Players[0] = &HoldemPlayer{UserID: "@alice:test", Stack: 0} + g.Players[1] = &HoldemPlayer{UserID: "@bob:test", Stack: 0} + + eligible := []id.UserID{"@alice:test", "@bob:test"} + evaluated := []evaluatedPlayer{ + {seatIdx: 0, rank: 100, name: "Flush", userID: "@alice:test"}, + {seatIdx: 1, rank: 100, name: "Flush", userID: "@bob:test"}, // tie + } + winnings := make(map[id.UserID]int64) + var results []showdownResult + + distributePot(g, 1000, eligible, evaluated, winnings, &results) + + // Should split evenly; odd chip goes to leftmost seat + if winnings["@alice:test"] != 500 { + t.Errorf("alice should win 500, got %d", winnings["@alice:test"]) + } + if winnings["@bob:test"] != 500 { + t.Errorf("bob should win 500, got %d", winnings["@bob:test"]) + } +} + +func TestDistributePot_OddChip(t *testing.T) { + g := &HoldemGame{ + Players: make([]*HoldemPlayer, 3), + } + g.Players[0] = &HoldemPlayer{UserID: "@alice:test", Stack: 0} + g.Players[1] = &HoldemPlayer{UserID: "@bob:test", Stack: 0} + g.Players[2] = &HoldemPlayer{UserID: "@carol:test", Stack: 0} + + eligible := []id.UserID{"@alice:test", "@bob:test", "@carol:test"} + evaluated := []evaluatedPlayer{ + {seatIdx: 0, rank: 100, name: "Flush", userID: "@alice:test"}, + {seatIdx: 1, rank: 100, name: "Flush", userID: "@bob:test"}, + {seatIdx: 2, rank: 100, name: "Flush", userID: "@carol:test"}, + } + winnings := make(map[id.UserID]int64) + var results []showdownResult + + distributePot(g, 100, eligible, evaluated, winnings, &results) + + total := winnings["@alice:test"] + winnings["@bob:test"] + winnings["@carol:test"] + if total != 100 { + t.Errorf("total winnings should be 100, got %d", total) + } + // Leftmost seat gets the odd chip + if winnings["@alice:test"] < winnings["@bob:test"] || winnings["@alice:test"] < winnings["@carol:test"] { + t.Errorf("leftmost seat should get odd chip: alice=%d bob=%d carol=%d", + winnings["@alice:test"], winnings["@bob:test"], winnings["@carol:test"]) + } +} diff --git a/internal/plugin/holdem_game.go b/internal/plugin/holdem_game.go index 56b80fd..93c1496 100644 --- a/internal/plugin/holdem_game.go +++ b/internal/plugin/holdem_game.go @@ -62,6 +62,7 @@ type HoldemPlayer struct { Bet int64 // committed this street TotalBet int64 // committed this hand State PlayerState + HasActed bool // whether the player has voluntarily acted this street TipsEnabled bool SittingOut bool WantsLeave bool @@ -90,8 +91,10 @@ type HoldemGame struct { HandInProgress bool StreetHistory string // action chars for current street (f/c/r/R/a) for CFR policy lookup - actionTimer *time.Timer - warningTimer *time.Timer + actionTimer *time.Timer + warningTimer *time.Timer + idleTimer *time.Timer + idleWarningTimer *time.Timer } // newShuffledDeck creates a shuffled 52-card deck. @@ -208,6 +211,7 @@ func (g *HoldemGame) playerIdx(uid id.UserID) int { func (g *HoldemGame) resetStreetBets() { for _, p := range g.Players { p.Bet = 0 + p.HasActed = false } g.CurrentBet = 0 g.MinRaise = g.BigBlind diff --git a/internal/plugin/holdem_render.go b/internal/plugin/holdem_render.go index ba71ab3..ffff760 100644 --- a/internal/plugin/holdem_render.go +++ b/internal/plugin/holdem_render.go @@ -297,8 +297,10 @@ func renderEndAnnouncement(results []showdownResult, g *HoldemGame) string { // renderHelpMessage returns the help text for !holdem help. func renderHelpMessage() string { return "🎰 **Texas Hold'em Commands**\n\n" + + "**Quick Start:**\n" + + "`!holdem play [amount]` — Join, add bot, and deal in one command\n\n" + "**Lobby:**\n" + - "`!holdem join` — Sit down at the table\n" + + "`!holdem join [amount]` — Sit down (optional buy-in amount)\n" + "`!holdem leave` — Leave the table\n" + "`!holdem start` — Start dealing (≥2 players)\n" + "`!holdem addbot` — Add an AI opponent\n\n" + @@ -306,7 +308,7 @@ func renderHelpMessage() string { "`!holdem fold` — Fold your hand\n" + "`!holdem check` — Check (no bet to call)\n" + "`!holdem call` — Call the current bet\n" + - "`!holdem raise <amount>` — Raise to a total of €amount\n" + + "`!holdem raise <amount>` — Raise *to* €amount total (not raise *by*)\n" + "`!holdem allin` — Go all-in\n\n" + "**Other:**\n" + "`!holdem status` — Get current table state (DM)\n" + diff --git a/internal/plugin/holdem_tips.go b/internal/plugin/holdem_tips.go index 59b94c5..479aee9 100644 --- a/internal/plugin/holdem_tips.go +++ b/internal/plugin/holdem_tips.go @@ -8,15 +8,17 @@ import ( "log/slog" "net/http" "os" + "regexp" "strings" "time" "gogobee/internal/db" + "github.com/chehsunliu/poker" "maunium.net/go/mautrix/id" ) -var holdemTipsClient = &http.Client{Timeout: 15 * time.Second} +var holdemTipsClient = &http.Client{Timeout: 60 * time.Second} // loadTipsPref loads a user's tip preference from the database. func loadTipsPref(userID id.UserID) bool { @@ -31,12 +33,11 @@ func loadTipsPref(userID id.UserID) bool { // saveTipsPref saves a user's tip preference. func saveTipsPref(userID id.UserID, enabled bool) { - d := db.Get() val := 0 if enabled { val = 1 } - _, _ = d.Exec( + db.Exec("holdem: save tips preference", `INSERT INTO holdem_tips_prefs (user_id, enabled) VALUES (?, ?) ON CONFLICT(user_id) DO UPDATE SET enabled = ?, updated_at = CURRENT_TIMESTAMP`, string(userID), val, val, @@ -45,22 +46,43 @@ func saveTipsPref(userID id.UserID, enabled bool) { // holdemTipContext holds the data needed to generate a tip. type holdemTipContext struct { - PlayerName string - Hole [2]string // rendered card strings - Community string // rendered board string - Equity EquityResult - SPR float64 - PotOddsPct float64 - ToCall int64 - Pot int64 - Stack int64 - Street Street - Position string - NumActive int + PlayerName string + Hole [2]string // rendered card strings + Community string // rendered board string + Equity EquityResult + SPR float64 + PotOddsPct float64 + ToCall int64 + Pot int64 + Stack int64 + Street Street + Position string + NumActive int + HandCategory string // from poker.RankString(), e.g. "Pair", "Flush" + Draw DrawInfo // flush/straight draw analysis + HeadsUp bool } -// buildTipContext creates a tip context from the current game state. -func buildTipContext(g *HoldemGame, playerIdx int) holdemTipContext { +// tipSnapshot holds game data captured under lock for async tip generation. +type tipSnapshot struct { + playerName string + hole [2]poker.Card + holeStr [2]string + community []poker.Card + communityS string + numActive int + numOpp int + toCall int64 + totalPot int64 + stack int64 + street Street + position string + headsUp bool + isDealer bool +} + +// snapshotForTip captures game state under lock. Cheap — no MC here. +func snapshotForTip(g *HoldemGame, playerIdx int) tipSnapshot { p := g.Players[playerIdx] totalPot := g.Pot @@ -79,40 +101,104 @@ func buildTipContext(g *HoldemGame, playerIdx int) holdemTipContext { numOpp = 1 } - iterations := 5000 + communityS := "—" if len(g.Community) > 0 { + communityS = renderCards(g.Community) + } + + // Copy community cards so the slice is safe outside the lock. + comm := make([]poker.Card, len(g.Community)) + copy(comm, g.Community) + + headsUp := numActive == 2 + isDealer := playerIdx == g.DealerIdx + + // Compute position with heads-up street awareness. + position := g.positionLabel(playerIdx) + if headsUp { + position = tipPositionLabel(isDealer, g.Street) + } + + return tipSnapshot{ + playerName: p.DisplayName, + hole: p.Hole, + holeStr: [2]string{renderCard(p.Hole[0]), renderCard(p.Hole[1])}, + community: comm, + communityS: communityS, + numActive: numActive, + numOpp: numOpp, + toCall: toCall, + totalPot: totalPot, + stack: p.Stack, + street: g.Street, + position: position, + headsUp: headsUp, + isDealer: isDealer, + } +} + +// tipPositionLabel returns the correct position label for heads-up play, +// accounting for the fact that position semantics change between preflop and postflop. +// Pre-flop: dealer/SB acts first (out of position for tips), BB acts last. +// Post-flop: dealer/BTN acts last (positional advantage), BB acts first (out of position). +func tipPositionLabel(isDealer bool, street Street) string { + if street == StreetPreFlop { + if isDealer { + return "SB" // dealer is SB in heads-up, acts first preflop + } + return "BB" + } + // Post-flop: dealer has position + if isDealer { + return "BTN" // acts last = positional advantage + } + return "BB" // acts first = out of position +} + +// buildTipContext computes the full tip context including equity MC. +// Call this OUTSIDE the lock — the expensive equity computation runs here. +func buildTipContext(snap tipSnapshot) holdemTipContext { + iterations := 5000 + if len(snap.community) > 0 { iterations = 10000 } - eq := Equity(p.Hole, g.Community, numOpp, iterations) + eq := Equity(snap.hole, snap.community, snap.numOpp, iterations) spr := 0.0 - if totalPot > 0 { - spr = float64(p.Stack) / float64(totalPot) + if snap.totalPot > 0 { + spr = float64(snap.stack) / float64(snap.totalPot) } potOdds := 0.0 - if toCall > 0 { - potOdds = float64(toCall) / float64(totalPot+toCall) * 100 + if snap.toCall > 0 { + potOdds = float64(snap.toCall) / float64(snap.totalPot+snap.toCall) * 100 } - community := "—" - if len(g.Community) > 0 { - community = renderCards(g.Community) + // Compute hand category from current best 5-card hand. + handCategory := "" + if len(snap.community) >= 3 { + _, handCategory = handRank(snap.hole, snap.community) } + // Compute draw info (meaningful on flop/turn only). + draw := computeDraws(snap.hole, snap.community) + return holdemTipContext{ - PlayerName: p.DisplayName, - Hole: [2]string{renderCard(p.Hole[0]), renderCard(p.Hole[1])}, - Community: community, - Equity: eq, - SPR: spr, - PotOddsPct: potOdds, - ToCall: toCall, - Pot: totalPot, - Stack: p.Stack, - Street: g.Street, - Position: g.positionLabel(playerIdx), - NumActive: numActive, + PlayerName: snap.playerName, + Hole: snap.holeStr, + Community: snap.communityS, + Equity: eq, + SPR: spr, + PotOddsPct: potOdds, + ToCall: snap.toCall, + Pot: snap.totalPot, + Stack: snap.stack, + Street: snap.street, + Position: snap.position, + NumActive: snap.numActive, + HandCategory: handCategory, + Draw: draw, + HeadsUp: snap.headsUp, } } @@ -124,7 +210,7 @@ func generateTip(ctx holdemTipContext) string { if host != "" && model != "" { tip, err := generateLLMTip(host, model, ctx) if err != nil { - slog.Debug("holdem: LLM tip failed, using fallback", "err", err) + slog.Warn("holdem: LLM tip failed, using fallback", "err", err) } else if tip != "" { return "💡 " + tip } @@ -138,32 +224,62 @@ type chatMessage struct { Content string `json:"content"` } -type chatRequest struct { +type ollamaChatRequest struct { Model string `json:"model"` Messages []chatMessage `json:"messages"` Stream bool `json:"stream"` } -type chatChoice struct { - Message chatMessage `json:"message"` +type ollamaChatResponse struct { + Message struct { + Content string `json:"content"` + } `json:"message"` } -type chatResponse struct { - Choices []chatChoice `json:"choices"` +var thinkTagRe = regexp.MustCompile(`(?s)<think>.*?</think>`) + +// extractTipFromResponse strips thinking tags from LLM output. +func extractTipFromResponse(raw string) string { + cleaned := thinkTagRe.ReplaceAllString(raw, "") + return strings.TrimSpace(cleaned) } -func generateLLMTip(host, model string, ctx holdemTipContext) (string, error) { - systemPrompt := `You are a concise Texas Hold'em coach embedded in a Matrix chat bot. -You will be given structured game context including pre-computed equity. -Give exactly 2-4 sentences of actionable advice for the player's current decision. -Lead with the equity vs pot odds relationship when a bet is to call. -Be direct. No preamble. No praise. No "great hand" filler.` +// buildTipSystemPrompt returns the system prompt for poker tip generation. +func buildTipSystemPrompt() string { + return `You are a Texas Hold'em coach giving advice to a single player via private message. +You will receive structured game context. Reason through it in this order: - var userPrompt strings.Builder - userPrompt.WriteString(fmt.Sprintf("Street: %s\n", ctx.Street.String())) - userPrompt.WriteString(fmt.Sprintf("Your hand: %s %s\n", ctx.Hole[0], ctx.Hole[1])) - userPrompt.WriteString(fmt.Sprintf("Board: %s\n", ctx.Community)) - userPrompt.WriteString(fmt.Sprintf("Equity vs %d opponents: Win %.0f%% | Tie %.0f%% | Loss %.0f%%\n", +1. What type of hand do I have — made hand, drawing hand, or air? +2. If drawing: how many outs, and do pot odds justify continuing? +3. If made hand: is it strong enough to bet for value, or weak enough to just pot control? +4. Does position affect what I should do here? +5. Is a free card available, and if so, is taking it correct? + +Then write ONE piece of advice — 2 to 3 sentences maximum — that tells the player +what to do and why, using the specific cards and numbers provided. +Do not list concepts. Do not use generic poker vocabulary without connecting it to +this specific hand. If the correct play is obvious (e.g. free card with a draw), +say so plainly and briefly.` +} + +// buildTipUserPrompt builds the structured user prompt with hand context. +func buildTipUserPrompt(ctx holdemTipContext) string { + var b strings.Builder + b.WriteString(fmt.Sprintf("Street: %s\n", ctx.Street.String())) + + if ctx.HandCategory != "" { + b.WriteString(fmt.Sprintf("Your hand: %s %s [%s]\n", ctx.Hole[0], ctx.Hole[1], ctx.HandCategory)) + } else { + b.WriteString(fmt.Sprintf("Your hand: %s %s\n", ctx.Hole[0], ctx.Hole[1])) + } + + b.WriteString(fmt.Sprintf("Board: %s\n", ctx.Community)) + + if ctx.Draw.IsDraw { + b.WriteString(fmt.Sprintf("Draw outs: %d (%s)\n", ctx.Draw.TotalOuts, ctx.Draw.Description)) + } + + b.WriteString(fmt.Sprintf("Equity vs %d opponent(s): Win %.0f%% | Tie %.0f%% | Loss %.0f%%\n", ctx.NumActive-1, ctx.Equity.Win*100, ctx.Equity.Tie*100, ctx.Equity.Loss*100)) if ctx.ToCall > 0 { @@ -171,20 +287,27 @@ Be direct. No preamble. No praise. No "great hand" filler.` if ctx.Equity.Win*100 < ctx.PotOddsPct { exceeds = "falls short of" } - userPrompt.WriteString(fmt.Sprintf("Pot odds to call: %.0f%% — equity %s price\n", ctx.PotOddsPct, exceeds)) + b.WriteString(fmt.Sprintf("Pot odds to call: %.0f%% — equity %s price\n", ctx.PotOddsPct, exceeds)) } else { - userPrompt.WriteString("Check available — no bet to call\n") + b.WriteString("Free card available — no bet to call\n") } - userPrompt.WriteString(fmt.Sprintf("SPR: %.1f | Position: %s | Active players: %d\n", - ctx.SPR, ctx.Position, ctx.NumActive)) - userPrompt.WriteString("\nWhat should I consider for my decision?") + headsUp := "no" + if ctx.HeadsUp { + headsUp = "yes" + } + b.WriteString(fmt.Sprintf("SPR: %.1f | Position: %s | Heads-up: %s | Active players: %d\n", + ctx.SPR, ctx.Position, headsUp, ctx.NumActive)) - req := chatRequest{ + return b.String() +} + +func generateLLMTip(host, model string, ctx holdemTipContext) (string, error) { + req := ollamaChatRequest{ Model: model, Messages: []chatMessage{ - {Role: "system", Content: systemPrompt}, - {Role: "user", Content: userPrompt.String()}, + {Role: "system", Content: buildTipSystemPrompt()}, + {Role: "user", Content: buildTipUserPrompt(ctx)}, }, Stream: false, } @@ -194,7 +317,7 @@ Be direct. No preamble. No praise. No "great hand" filler.` return "", fmt.Errorf("marshal: %w", err) } - url := strings.TrimRight(host, "/") + "/v1/chat/completions" + url := strings.TrimRight(host, "/") + "/api/chat" resp, err := holdemTipsClient.Post(url, "application/json", bytes.NewReader(body)) if err != nil { return "", fmt.Errorf("request: %w", err) @@ -206,23 +329,16 @@ Be direct. No preamble. No praise. No "great hand" filler.` return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody)) } - var chatResp chatResponse - if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil { + var ollamaResp ollamaChatResponse + if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil { return "", fmt.Errorf("decode: %w", err) } - if len(chatResp.Choices) == 0 || chatResp.Choices[0].Message.Content == "" { + tip := extractTipFromResponse(ollamaResp.Message.Content) + if tip == "" { return "", fmt.Errorf("empty response") } - tip := strings.TrimSpace(chatResp.Choices[0].Message.Content) - // Strip thinking tags if present. - if i := strings.Index(tip, "<think>"); i != -1 { - if j := strings.Index(tip, "</think>"); j != -1 { - tip = strings.TrimSpace(tip[:i] + tip[j+len("</think>"):]) - } - } - return tip, nil } @@ -234,18 +350,34 @@ func generateRulesTip(ctx holdemTipContext) string { switch { case equity > 0.80: tip = fmt.Sprintf("Strong hand (%.0f%% equity). Size your bet for value — you want to get paid off.", equity*100) + + case ctx.Draw.IsDraw && ctx.ToCall == 0: + tip = fmt.Sprintf("You have a %s. With a free card available, check and see the next card without risk.", ctx.Draw.Description) + + case ctx.Draw.IsDraw && ctx.ToCall > 0 && equity*100 > ctx.PotOddsPct: + tip = fmt.Sprintf("Drawing hand (%s) with %.0f%% equity vs %.0f%% pot odds — the price is right to call.", ctx.Draw.Description, equity*100, ctx.PotOddsPct) + + case ctx.Draw.IsDraw && ctx.ToCall > 0 && equity*100 <= ctx.PotOddsPct: + tip = fmt.Sprintf("Drawing hand (%s) but equity %.0f%% falls short of pot odds %.0f%% — consider folding unless implied odds justify calling.", ctx.Draw.Description, equity*100, ctx.PotOddsPct) + case ctx.ToCall > 0 && equity*100 > ctx.PotOddsPct: tip = fmt.Sprintf("Equity %.0f%% exceeds pot odds %.0f%% — calling is +EV here.", equity*100, ctx.PotOddsPct) + case ctx.ToCall > 0 && equity*100 <= ctx.PotOddsPct: - tip = fmt.Sprintf("Equity %.0f%% falls short of pot odds %.0f%% — consider folding unless you have a strong draw.", equity*100, ctx.PotOddsPct) + tip = fmt.Sprintf("Equity %.0f%% falls short of pot odds %.0f%% — consider folding.", equity*100, ctx.PotOddsPct) + case ctx.ToCall == 0 && equity > 0.65: tip = fmt.Sprintf("%.0f%% equity with check available — bet for value and deny free cards to draws.", equity*100) + case ctx.ToCall == 0 && equity < 0.40: - tip = fmt.Sprintf("%.0f%% equity — check to control pot size. Not enough equity to bet.", equity*100) + tip = fmt.Sprintf("%.0f%% equity — check to control pot size.", equity*100) + case ctx.SPR < 1: tip = "Shallow stack (SPR < 1) — commit or fold. No room to maneuver." + case ctx.SPR > 10 && ctx.Street == StreetPreFlop: tip = "Deep stacked preflop — implied odds outweigh raw equity. Speculative hands gain value." + default: tip = fmt.Sprintf("%.0f%% equity. Evaluate your position and pot odds before acting.", equity*100) } diff --git a/internal/plugin/holdem_tips_test.go b/internal/plugin/holdem_tips_test.go new file mode 100644 index 0000000..e10a34d --- /dev/null +++ b/internal/plugin/holdem_tips_test.go @@ -0,0 +1,324 @@ +package plugin + +import ( + "testing" + + "github.com/chehsunliu/poker" +) + +// --------------------------------------------------------------------------- +// Position label — heads-up street awareness +// --------------------------------------------------------------------------- + +func TestTipPositionLabel_HeadsUp_PreFlop(t *testing.T) { + // Pre-flop: dealer is SB (acts first), other is BB + if pos := tipPositionLabel(true, StreetPreFlop); pos != "SB" { + t.Errorf("dealer preflop HU: got %q, want SB", pos) + } + if pos := tipPositionLabel(false, StreetPreFlop); pos != "BB" { + t.Errorf("non-dealer preflop HU: got %q, want BB", pos) + } +} + +func TestTipPositionLabel_HeadsUp_PostFlop(t *testing.T) { + // Post-flop: dealer is BTN (acts last = positional advantage) + for _, street := range []Street{StreetFlop, StreetTurn, StreetRiver} { + if pos := tipPositionLabel(true, street); pos != "BTN" { + t.Errorf("dealer %s HU: got %q, want BTN", street, pos) + } + if pos := tipPositionLabel(false, street); pos != "BB" { + t.Errorf("non-dealer %s HU: got %q, want BB", street, pos) + } + } +} + +// --------------------------------------------------------------------------- +// Draw detection +// --------------------------------------------------------------------------- + +func TestComputeDraws_GutShotStraight(t *testing.T) { + // 9♠ 8♠ on 7♣ 5♦ 2♥ — gutshot (need 6 for 5-6-7-8-9) + hole := [2]poker.Card{poker.NewCard("9s"), poker.NewCard("8s")} + community := []poker.Card{poker.NewCard("7c"), poker.NewCard("5d"), poker.NewCard("2h")} + + draw := computeDraws(hole, community) + if !draw.IsDraw { + t.Fatal("should detect a draw") + } + if draw.StraightDrawOuts != 4 { + t.Errorf("straight outs: got %d, want 4 (gutshot)", draw.StraightDrawOuts) + } +} + +func TestComputeDraws_SpecScenario(t *testing.T) { + // 8♥ 7♥ on Q♥ K♠ 10♦ — the spec scenario + // This is a backdoor flush (3 hearts) + backdoor straight, NOT a standard gutshot. + // A straight here needs 9+J (two cards), so it's runner-runner. + hole := [2]poker.Card{poker.NewCard("8h"), poker.NewCard("7h")} + community := []poker.Card{poker.NewCard("Qh"), poker.NewCard("Ks"), poker.NewCard("Td")} + + draw := computeDraws(hole, community) + if !draw.IsDraw { + t.Fatal("should detect backdoor draws") + } + if draw.TotalOuts == 0 { + t.Error("should have some backdoor outs") + } +} + +func TestComputeDraws_FlushDraw(t *testing.T) { + // 5♥ 6♥ on 7♥ 8♣ 2♥ — flush draw (4 hearts, need 1 more) + hole := [2]poker.Card{poker.NewCard("5h"), poker.NewCard("6h")} + community := []poker.Card{poker.NewCard("7h"), poker.NewCard("8c"), poker.NewCard("2h")} + + draw := computeDraws(hole, community) + if !draw.IsDraw { + t.Fatal("should detect a draw") + } + if draw.FlushDrawOuts != 9 { + t.Errorf("flush outs: got %d, want 9", draw.FlushDrawOuts) + } +} + +func TestComputeDraws_OESDPlusFlush(t *testing.T) { + // 5♥ 6♥ on 7♥ 8♣ 2♥ — OESD (4-5-6-7-8) + flush draw + hole := [2]poker.Card{poker.NewCard("5h"), poker.NewCard("6h")} + community := []poker.Card{poker.NewCard("7h"), poker.NewCard("8c"), poker.NewCard("2h")} + + draw := computeDraws(hole, community) + if draw.TotalOuts > 15 { + t.Errorf("total outs should be capped at 15, got %d", draw.TotalOuts) + } + if draw.FlushDrawOuts == 0 && draw.StraightDrawOuts == 0 { + t.Error("should detect flush and/or straight draw") + } +} + +func TestComputeDraws_MadeHand_NoDraw(t *testing.T) { + // Q♣ Q♦ on Q♥ 2♠ 7♣ — trips, no draw + hole := [2]poker.Card{poker.NewCard("Qc"), poker.NewCard("Qd")} + community := []poker.Card{poker.NewCard("Qh"), poker.NewCard("2s"), poker.NewCard("7c")} + + draw := computeDraws(hole, community) + if draw.IsDraw && draw.TotalOuts > 2 { + t.Errorf("set of queens should not report significant draw, got %d outs", draw.TotalOuts) + } +} + +func TestComputeDraws_PreFlop_NoDraw(t *testing.T) { + // No community cards — draws not applicable + hole := [2]poker.Card{poker.NewCard("As"), poker.NewCard("Ks")} + draw := computeDraws(hole, nil) + if draw.IsDraw { + t.Error("preflop should not report draws") + } +} + +func TestComputeDraws_River_NoDraw(t *testing.T) { + // 5 community cards — draws not applicable (no more cards to come) + hole := [2]poker.Card{poker.NewCard("5h"), poker.NewCard("6h")} + community := []poker.Card{ + poker.NewCard("7h"), poker.NewCard("8c"), poker.NewCard("2h"), + poker.NewCard("Ks"), poker.NewCard("3d"), + } + draw := computeDraws(hole, community) + if draw.IsDraw { + t.Error("river should not report draws") + } +} + +// --------------------------------------------------------------------------- +// Straight detection helpers +// --------------------------------------------------------------------------- + +func TestHasStraight(t *testing.T) { + tests := []struct { + name string + ranks uint16 + want bool + }{ + {"A-high straight (T-J-Q-K-A)", 0x1F00, true}, // bits 8-12 + {"low straight (A-2-3-4-5)", 0x100F, true}, // bits 0,1,2,3,12 + {"5-6-7-8-9", 0x00F8, true}, // bits 3-7 + {"four consecutive", 0x000F, false}, // bits 0-3 + {"scattered", 0x1111, false}, // bits 0,4,8,12 + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hasStraight(tt.ranks); got != tt.want { + t.Errorf("hasStraight(0x%04X) = %v, want %v", tt.ranks, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Think tag stripping +// --------------------------------------------------------------------------- + +func TestExtractTipFromResponse(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + "no think tags", + "Check here and take the free card.", + "Check here and take the free card.", + }, + { + "with think tags", + "<think>Let me analyze this hand...</think>Check here and take the free card.", + "Check here and take the free card.", + }, + { + "multiline think block", + "<think>\nStep 1: assess hand type\nStep 2: evaluate outs\n</think>\n\nYou have a gutshot. Take the free card.", + "You have a gutshot. Take the free card.", + }, + { + "only think block", + "<think>reasoning only</think>", + "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractTipFromResponse(tt.input) + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// User prompt structure +// --------------------------------------------------------------------------- + +func TestBuildTipUserPrompt_IncludesDrawInfo(t *testing.T) { + ctx := holdemTipContext{ + Hole: [2]string{"8♥", "7♥"}, + Community: "Q♥ K♠ 10♦", + Street: StreetFlop, + Position: "BB", + NumActive: 2, + HeadsUp: true, + Equity: EquityResult{Win: 0.29, Tie: 0.01, Loss: 0.70}, + Draw: DrawInfo{ + IsDraw: true, + StraightDrawOuts: 4, + TotalOuts: 4, + Description: "gutshot straight draw (4 outs)", + }, + HandCategory: "High Card", + } + + prompt := buildTipUserPrompt(ctx) + + if !contains(prompt, "gutshot straight draw") { + t.Error("prompt should include draw description") + } + if !contains(prompt, "[High Card]") { + t.Error("prompt should include hand category") + } + if !contains(prompt, "Heads-up: yes") { + t.Error("prompt should indicate heads-up") + } + if !contains(prompt, "Free card available") { + t.Error("prompt should indicate free card when ToCall=0") + } +} + +func TestBuildTipUserPrompt_NoDrawWhenMadeHand(t *testing.T) { + ctx := holdemTipContext{ + Hole: [2]string{"Q♣", "Q♦"}, + Community: "Q♥ 2♠ 7♣", + Street: StreetFlop, + Position: "BTN", + NumActive: 3, + Equity: EquityResult{Win: 0.90, Tie: 0.02, Loss: 0.08}, + HandCategory: "Three of a Kind", + } + + prompt := buildTipUserPrompt(ctx) + + if contains(prompt, "Draw outs:") { + t.Error("prompt should not include draw line for made hand without draws") + } + if !contains(prompt, "[Three of a Kind]") { + t.Error("prompt should include hand category") + } +} + +func TestBuildTipUserPrompt_PotOddsWhenFacingBet(t *testing.T) { + ctx := holdemTipContext{ + Hole: [2]string{"A♠", "K♠"}, + Community: "2♣ 7♦ J♠", + Street: StreetFlop, + Position: "CO", + NumActive: 4, + Equity: EquityResult{Win: 0.45, Tie: 0.02, Loss: 0.53}, + ToCall: 50, + PotOddsPct: 25.0, + HandCategory: "High Card", + } + + prompt := buildTipUserPrompt(ctx) + + if !contains(prompt, "Pot odds to call:") { + t.Error("prompt should include pot odds when facing a bet") + } + if contains(prompt, "Free card available") { + t.Error("prompt should not say free card when facing a bet") + } +} + +// --------------------------------------------------------------------------- +// Rules-based fallback — draw awareness +// --------------------------------------------------------------------------- + +func TestRulesTip_DrawWithFreeCard(t *testing.T) { + ctx := holdemTipContext{ + Equity: EquityResult{Win: 0.29, Tie: 0.01, Loss: 0.70}, + ToCall: 0, + Draw: DrawInfo{IsDraw: true, TotalOuts: 4, Description: "gutshot straight draw (4 outs)"}, + Position: "BB", + Street: StreetFlop, + } + + tip := generateRulesTip(ctx) + if !contains(tip, "free card") { + t.Errorf("draw + free card tip should mention free card, got: %s", tip) + } +} + +func TestRulesTip_DrawFacingBet_GoodOdds(t *testing.T) { + ctx := holdemTipContext{ + Equity: EquityResult{Win: 0.35, Tie: 0.01, Loss: 0.64}, + ToCall: 20, + PotOddsPct: 20.0, + Draw: DrawInfo{IsDraw: true, TotalOuts: 9, Description: "flush draw (9 outs)"}, + Position: "BB", + Street: StreetFlop, + } + + tip := generateRulesTip(ctx) + if !contains(tip, "price is right") { + t.Errorf("draw with good odds should mention price, got: %s", tip) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchSubstring(s, substr) +} + +func searchSubstring(s, sub string) bool { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/plugin/howami.go b/internal/plugin/howami.go index c67ea63..a015d31 100644 --- a/internal/plugin/howami.go +++ b/internal/plugin/howami.go @@ -65,14 +65,15 @@ func (p *HowAmIPlugin) OnMessage(ctx MessageContext) error { slog.Error("howami: send thinking", "err", err) } - profile := p.gatherProfile(target) + go func() { + profile := p.gatherProfile(target) - botName := os.Getenv("BOT_DISPLAY_NAME") - if botName == "" { - botName = "GogoBee" - } - prompt := fmt.Sprintf( - `You are a witty, playful community bot called %s. Based on the following user profile data, write a fun, lighthearted "roast" of this user in 3-5 sentences. Be creative, funny, and reference specific stats. Keep it friendly — no truly mean insults. + botName := os.Getenv("BOT_DISPLAY_NAME") + if botName == "" { + botName = "GogoBee" + } + prompt := fmt.Sprintf( + `You are a witty, playful community bot called %s. Based on the following user profile data, write a fun, lighthearted "roast" of this user in 3-5 sentences. Be creative, funny, and reference specific stats. Keep it friendly — no truly mean insults. User: %s @@ -80,16 +81,20 @@ Profile data: %s Write the roast now. Do not include any preamble or explanation, just the roast text.`, - botName, string(target), profile, - ) + botName, string(target), profile, + ) - response, err := callOllama(ollamaHost, ollamaModel, prompt) - if err != nil { - slog.Error("howami: ollama call", "err", err) - return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate profile. LLM might be offline.") - } + response, err := callOllama(ollamaHost, ollamaModel, prompt) + if err != nil { + slog.Error("howami: ollama call", "err", err) + p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate profile. LLM might be offline.") + return + } - return p.SendReply(ctx.RoomID, ctx.EventID, response) + p.SendReply(ctx.RoomID, ctx.EventID, response) + }() + + return nil } func (p *HowAmIPlugin) gatherProfile(userID id.UserID) string { diff --git a/internal/plugin/llm_passive.go b/internal/plugin/llm_passive.go index 5a9fc7e..33125dd 100644 --- a/internal/plugin/llm_passive.go +++ b/internal/plugin/llm_passive.go @@ -326,8 +326,6 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error { } } - d := db.Get() - // Store classification topicsJSON, _ := json.Marshal(result.Topics) profanityInt := 0 @@ -339,16 +337,13 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error { wotdInt = 1 } - _, err = d.Exec( + db.Exec("llm: store classification", `INSERT INTO llm_classifications (user_id, room_id, message_text, sentiment, sentiment_score, topics, profanity, profanity_severity, insult_target, wotd_used, gratitude_target) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, string(item.UserID), string(item.RoomID), item.Body, result.Sentiment, result.SentimentScore, string(topicsJSON), profanityInt, result.ProfanitySeverity, result.InsultTarget, wotdInt, result.GratitudeTarget, ) - if err != nil { - slog.Error("llm: store classification", "err", err) - } // Aggregate sentiment stats sentimentCol := "neutral" @@ -360,7 +355,7 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error { if validSentiments[result.Sentiment] { sentimentCol = result.Sentiment } - _, _ = d.Exec( + db.Exec("llm: update user sentiment stats", fmt.Sprintf( `INSERT INTO sentiment_stats (user_id, %s, total_score) VALUES (?, 1, ?) ON CONFLICT(user_id) DO UPDATE SET %s = %s + 1, total_score = total_score + ?`, @@ -369,7 +364,7 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error { ) // Aggregate room sentiment stats - _, _ = d.Exec( + db.Exec("llm: update room sentiment stats", 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 + ?`, @@ -395,7 +390,7 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error { case 3: scorchInc = 1 } - _, _ = d.Exec( + db.Exec("llm: track profanity", `INSERT INTO potty_mouth (user_id, count, mild, moderate, scorching) VALUES (?, 1, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET count = count + 1, mild = mild + ?, moderate = moderate + ?, scorching = scorching + ?`, string(item.UserID), mildInc, modInc, scorchInc, mildInc, modInc, scorchInc, @@ -417,12 +412,12 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error { // Track insults if result.InsultTarget != "" { - _, _ = d.Exec( + db.Exec("llm: track insult sender", `INSERT INTO insult_log (user_id, times_insulting) VALUES (?, 1) ON CONFLICT(user_id) DO UPDATE SET times_insulting = times_insulting + 1`, string(item.UserID), ) - _, _ = d.Exec( + db.Exec("llm: track insult target", `INSERT INTO insult_log (user_id, times_insulted) VALUES (?, 1) ON CONFLICT(user_id) DO UPDATE SET times_insulted = times_insulted + 1`, result.InsultTarget, diff --git a/internal/plugin/lookup.go b/internal/plugin/lookup.go index 920f736..ff722ef 100644 --- a/internal/plugin/lookup.go +++ b/internal/plugin/lookup.go @@ -48,16 +48,24 @@ func (p *LookupPlugin) Init() error { return nil } func (p *LookupPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *LookupPlugin) OnMessage(ctx MessageContext) error { + var handler func(MessageContext) error switch { case p.IsCommand(ctx.Body, "wiki"): - return p.handleWiki(ctx) + handler = p.handleWiki case p.IsCommand(ctx.Body, "define"): - return p.handleDefine(ctx) + handler = p.handleDefine case p.IsCommand(ctx.Body, "urban"): - return p.handleUrban(ctx) + handler = p.handleUrban case p.IsCommand(ctx.Body, "translate"): - return p.handleTranslate(ctx) + handler = p.handleTranslate + default: + return nil } + go func() { + if err := handler(ctx); err != nil { + slog.Error("lookup: handler error", "err", err) + } + }() return nil } @@ -266,14 +274,11 @@ func (p *LookupPlugin) handleUrban(ctx MessageContext) error { msg := sb.String() // Cache the result - _, err = d.Exec( + db.Exec("lookup: urban cache", `INSERT INTO urban_cache (term, data, cached_at) VALUES (?, ?, ?) ON CONFLICT(term) DO UPDATE SET data = ?, cached_at = ?`, termLower, msg, now, msg, now, ) - if err != nil { - slog.Error("lookup: urban cache", "err", err) - } return p.SendReply(ctx.RoomID, ctx.EventID, msg) } diff --git a/internal/plugin/markov.go b/internal/plugin/markov.go index a0410c2..c8b3e2b 100644 --- a/internal/plugin/markov.go +++ b/internal/plugin/markov.go @@ -68,15 +68,12 @@ func (p *MarkovPlugin) collectMessage(userID id.UserID, text string) { } // Cap at 10,000 messages per user — delete oldest excess - _, err = d.Exec( + db.Exec("markov: prune corpus", `DELETE FROM markov_corpus WHERE user_id = ? AND id NOT IN ( SELECT id FROM markov_corpus WHERE user_id = ? ORDER BY id DESC LIMIT 10000 )`, string(userID), string(userID), ) - if err != nil { - slog.Error("markov: prune corpus", "err", err) - } } func (p *MarkovPlugin) handleMarkov(ctx MessageContext) error { diff --git a/internal/plugin/milkcarton.go b/internal/plugin/milkcarton.go index 450fa46..3782a83 100644 --- a/internal/plugin/milkcarton.go +++ b/internal/plugin/milkcarton.go @@ -240,36 +240,44 @@ func (p *MilkCartonPlugin) getMissingMembers() []missingMember { } defer rows.Close() - var result []missingMember + type candidate struct { + userID, lastDate string + } + var candidates []candidate for rows.Next() { - var userID, lastDate string - if err := rows.Scan(&userID, &lastDate); err != nil { + var c candidate + if err := rows.Scan(&c.userID, &c.lastDate); err != nil { continue } + candidates = append(candidates, c) + } + rows.Close() + var result []missingMember + for _, c := range candidates { // Skip excluded users - if p.excludeUsers[userID] { + if p.excludeUsers[c.userID] { continue } // Skip bots (our own user ID) - if id.UserID(userID) == p.Client.UserID { + if id.UserID(c.userID) == p.Client.UserID { continue } // Skip users with active away/afk status var awayStatus int - _ = d.QueryRow(`SELECT 1 FROM presence WHERE user_id = ? AND status IN ('away', 'afk')`, userID).Scan(&awayStatus) + _ = d.QueryRow(`SELECT 1 FROM presence WHERE user_id = ? AND status IN ('away', 'afk')`, c.userID).Scan(&awayStatus) if awayStatus == 1 { continue } - lastTime, err := time.Parse("2006-01-02", lastDate) + lastTime, err := time.Parse("2006-01-02", c.lastDate) if err != nil { continue } days := int(now.Sub(lastTime).Hours() / 24) - result = append(result, missingMember{userID: userID, daysSince: days}) + result = append(result, missingMember{userID: c.userID, daysSince: days}) } return result } diff --git a/internal/plugin/moderation.go b/internal/plugin/moderation.go index ad9ab81..22adf90 100644 --- a/internal/plugin/moderation.go +++ b/internal/plugin/moderation.go @@ -860,15 +860,11 @@ func (p *ModerationPlugin) notifyAdmin(text string) { } func (p *ModerationPlugin) logAction(userID id.UserID, roomID id.RoomID, action, reason, takenBy string) { - d := db.Get() - _, err := d.Exec( + db.Exec("moderation: log action", `INSERT INTO mod_actions (user_id, room_id, action, reason, taken_at, taken_by) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?)`, string(userID), string(roomID), action, reason, takenBy, ) - if err != nil { - slog.Error("moderation: failed to log action", "err", err) - } } func modTruncate(s string, max int) string { diff --git a/internal/plugin/movies.go b/internal/plugin/movies.go index 892fc60..065b78d 100644 --- a/internal/plugin/movies.go +++ b/internal/plugin/movies.go @@ -119,18 +119,26 @@ func (p *MoviesPlugin) Init() error { return nil } func (p *MoviesPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *MoviesPlugin) OnMessage(ctx MessageContext) error { - if p.IsCommand(ctx.Body, "movie") { - return p.handleMovie(ctx) - } - if p.IsCommand(ctx.Body, "tv") { - return p.handleTV(ctx) - } - if p.IsCommand(ctx.Body, "upcoming") { + var handler func(MessageContext) error + switch { + case p.IsCommand(ctx.Body, "movie"): + handler = p.handleMovie + case p.IsCommand(ctx.Body, "tv"): + handler = p.handleTV + case p.IsCommand(ctx.Body, "upcoming"): args := p.GetArgs(ctx.Body, "upcoming") if strings.ToLower(strings.TrimSpace(args)) == "movies" { - return p.handleUpcoming(ctx) + handler = p.handleUpcoming } } + if handler == nil { + return nil + } + go func() { + if err := handler(ctx); err != nil { + slog.Error("movies: handler error", "err", err) + } + }() return nil } @@ -221,14 +229,11 @@ func (p *MoviesPlugin) fetchMovieDetail(movieID int) (*tmdbMovieDetail, error) { // Update cache data, _ := json.Marshal(detail) - _, err = d.Exec( + db.Exec("movies: cache write", `INSERT INTO movie_cache (tmdb_id, data, cached_at) VALUES (?, ?, ?) ON CONFLICT(tmdb_id) DO UPDATE SET data = ?, cached_at = ?`, movieID, string(data), time.Now().Unix(), string(data), time.Now().Unix(), ) - if err != nil { - slog.Error("movies: cache write", "err", err) - } return &detail, nil } diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index a2275b9..a3f911f 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -3,8 +3,10 @@ package plugin import ( "context" "fmt" + "html" "log/slog" "os" + "regexp" "strconv" "strings" "sync" @@ -96,6 +98,20 @@ func (b *Base) IsAdmin(userID id.UserID) bool { return false } +// DisplayName returns the Matrix display name for a user, falling back +// to the localpart extracted from the user ID (e.g., "@alice:server" -> "alice"). +func (b *Base) DisplayName(userID id.UserID) string { + resp, err := b.Client.GetDisplayName(context.Background(), userID) + if err != nil || resp.DisplayName == "" { + s := string(userID) + if idx := strings.Index(s, ":"); idx > 0 { + s = s[1:idx] + } + return s + } + return resp.DisplayName +} + // RoomMembers returns the set of user IDs visible from a room. If space groups // are enabled, this returns the union of all members across rooms in the same // space group. Otherwise falls back to the single room's membership. @@ -289,11 +305,11 @@ func (sg *SpaceGroupManager) Refresh() { slog.Error("space_groups: begin tx", "err", err) return } + defer tx.Rollback() // Delete only rooms we have fresh data for (preserve entries for rooms that failed to fetch) for r := range newRoomToGroup { if _, err := tx.Exec(`DELETE FROM space_groups WHERE room_id = ?`, string(r)); err != nil { slog.Error("space_groups: delete row", "room", r, "err", err) - tx.Rollback() return } } @@ -301,7 +317,6 @@ func (sg *SpaceGroupManager) Refresh() { if _, err := tx.Exec(`INSERT INTO space_groups (room_id, group_id, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)`, string(r), gid); err != nil { slog.Error("space_groups: insert row", "room", r, "err", err) - tx.Rollback() return } } @@ -453,12 +468,64 @@ func (b *Base) ResolveUser(input string, roomIDs ...id.RoomID) (id.UserID, bool) return "", false } -// SendMessage sends a plain text message to a room. -func (b *Base) SendMessage(roomID id.RoomID, text string) error { - content := &event.MessageEventContent{ +// simpleMarkdownToHTML converts the limited Markdown subset used in bot messages +// (**bold**, _italic_, `code`, newlines) to Matrix-compatible HTML. +var ( + mdBoldRe = regexp.MustCompile(`\*\*(.+?)\*\*`) + mdItalicRe = regexp.MustCompile(`(?:^|[ (])_([^_]+?)_(?:$|[ ).,!?])`) + mdCodeRe = regexp.MustCompile("`([^`]+)`") + mdHasFmt = regexp.MustCompile(`\*\*|(?:^|[ (])_[^_]+_(?:$|[ ).,!?])|` + "`") +) + +func simpleMarkdownToHTML(text string) string { + h := html.EscapeString(text) + h = mdBoldRe.ReplaceAllString(h, "<strong>$1</strong>") + // Italic needs careful handling to not match snake_case + h = mdItalicRe.ReplaceAllStringFunc(h, func(m string) string { + // Preserve leading/trailing non-underscore chars + start := 0 + for start < len(m) && m[start] != '_' { + start++ + } + end := len(m) - 1 + for end > start && m[end] != '_' { + end-- + } + return m[:start] + "<em>" + m[start+1:end] + "</em>" + m[end+1:] + }) + h = mdCodeRe.ReplaceAllString(h, "<code>$1</code>") + h = strings.ReplaceAll(h, "\n", "<br>\n") + return h +} + +var mdStripItalicRe = regexp.MustCompile(`_([^_]+?)_`) + +func stripMarkdown(text string) string { + s := mdBoldRe.ReplaceAllString(text, "$1") + s = mdStripItalicRe.ReplaceAllString(s, "$1") + s = mdCodeRe.ReplaceAllString(s, "$1") + return s +} + +// textContent builds a MessageEventContent, adding HTML formatting when Markdown is detected. +func textContent(text string) *event.MessageEventContent { + if mdHasFmt.MatchString(text) { + return &event.MessageEventContent{ + MsgType: event.MsgText, + Body: stripMarkdown(text), + Format: event.FormatHTML, + FormattedBody: simpleMarkdownToHTML(text), + } + } + return &event.MessageEventContent{ MsgType: event.MsgText, Body: text, } +} + +// SendMessage sends a message to a room, auto-formatting Markdown as HTML. +func (b *Base) SendMessage(roomID id.RoomID, text string) error { + content := textContent(text) _, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content) if err != nil { slog.Error("failed to send message", "room", roomID, "err", err) @@ -466,12 +533,9 @@ func (b *Base) SendMessage(roomID id.RoomID, text string) error { return err } -// SendMessageID sends a plain text message and returns the event ID. +// SendMessageID sends a message and returns the event ID. func (b *Base) SendMessageID(roomID id.RoomID, text string) (id.EventID, error) { - content := &event.MessageEventContent{ - MsgType: event.MsgText, - Body: text, - } + content := textContent(text) resp, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content) if err != nil { slog.Error("failed to send message", "room", roomID, "err", err) @@ -482,17 +546,14 @@ func (b *Base) SendMessageID(roomID id.RoomID, text string) (id.EventID, error) // SendThread sends a message in a thread rooted at threadID. func (b *Base) SendThread(roomID id.RoomID, threadID id.EventID, text string) error { - content := &event.MessageEventContent{ - MsgType: event.MsgText, - Body: text, - RelatesTo: &event.RelatesTo{ - Type: event.RelThread, + content := textContent(text) + content.RelatesTo = &event.RelatesTo{ + Type: event.RelThread, + EventID: threadID, + InReplyTo: &event.InReplyTo{ EventID: threadID, - InReplyTo: &event.InReplyTo{ - EventID: threadID, - }, - IsFallingBack: true, }, + IsFallingBack: true, } _, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content) if err != nil { @@ -503,23 +564,18 @@ func (b *Base) SendThread(roomID id.RoomID, threadID id.EventID, text string) er // SendNotice sends an m.notice message to a room. func (b *Base) SendNotice(roomID id.RoomID, text string) error { - content := &event.MessageEventContent{ - MsgType: event.MsgNotice, - Body: text, - } + content := textContent(text) + content.MsgType = event.MsgNotice _, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content) return err } // SendReply sends a reply to a specific event. func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error { - content := &event.MessageEventContent{ - MsgType: event.MsgText, - Body: text, - RelatesTo: &event.RelatesTo{ - InReplyTo: &event.InReplyTo{ - EventID: eventID, - }, + content := textContent(text) + content.RelatesTo = &event.RelatesTo{ + InReplyTo: &event.InReplyTo{ + EventID: eventID, }, } _, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content) @@ -530,12 +586,12 @@ func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) erro } // SendHTML sends an HTML-formatted message. -func (b *Base) SendHTML(roomID id.RoomID, plain, html string) error { +func (b *Base) SendHTML(roomID id.RoomID, plain, htmlBody string) error { content := &event.MessageEventContent{ MsgType: event.MsgText, Body: plain, Format: event.FormatHTML, - FormattedBody: html, + FormattedBody: htmlBody, } _, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content) return err diff --git a/internal/plugin/reactions.go b/internal/plugin/reactions.go index fc327ef..9209b0b 100644 --- a/internal/plugin/reactions.go +++ b/internal/plugin/reactions.go @@ -55,14 +55,10 @@ func (p *ReactionsPlugin) OnReaction(ctx ReactionContext) error { return nil } - d := db.Get() - _, err = d.Exec( + db.Exec("reactions: log reaction", `INSERT INTO reaction_log (room_id, event_id, sender, target_user, emoji) VALUES (?, ?, ?, ?, ?)`, string(ctx.RoomID), string(ctx.TargetEvent), string(ctx.Sender), string(targetUser), ctx.Emoji, ) - if err != nil { - slog.Error("reactions: log reaction", "err", err) - } return nil } diff --git a/internal/plugin/reminders.go b/internal/plugin/reminders.go index e636ecc..73fea2b 100644 --- a/internal/plugin/reminders.go +++ b/internal/plugin/reminders.go @@ -174,22 +174,33 @@ func FirePendingReminders(client *mautrix.Client) { base := NewBase(client) + // Collect all pending reminders first, then close the rows. + // Iterating rows while also writing to the DB can cause SQLite lock issues. + type pendingReminder struct { + ID, UserID, RoomID, Message string + } + var pending []pendingReminder for rows.Next() { - var reminderID, userID, roomID, message string - if err := rows.Scan(&reminderID, &userID, &roomID, &message); err != nil { + var r pendingReminder + if err := rows.Scan(&r.ID, &r.UserID, &r.RoomID, &r.Message); err != nil { slog.Error("reminders: scan row", "err", err) continue } + pending = append(pending, r) + } + rows.Close() - msg := fmt.Sprintf("⏰ Reminder for %s: %s", userID, message) - if err := base.SendMessage(id.RoomID(roomID), msg); err != nil { - slog.Error("reminders: send reminder", "err", err, "id", reminderID) + for _, r := range pending { + // Mark fired BEFORE sending so a crash doesn't re-fire on restart. + _, err := db.Get().Exec(`UPDATE reminders SET fired = 1 WHERE id = ?`, r.ID) + if err != nil { + slog.Error("reminders: mark fired", "err", err, "id", r.ID) continue } - _, err := db.Get().Exec(`UPDATE reminders SET fired = 1 WHERE id = ?`, reminderID) - if err != nil { - slog.Error("reminders: mark fired", "err", err, "id", reminderID) + msg := fmt.Sprintf("⏰ Reminder for %s: %s", r.UserID, r.Message) + if err := base.SendMessage(id.RoomID(r.RoomID), msg); err != nil { + slog.Error("reminders: send reminder", "err", err, "id", r.ID) } } } diff --git a/internal/plugin/retro.go b/internal/plugin/retro.go index 304e92a..0656593 100644 --- a/internal/plugin/retro.go +++ b/internal/plugin/retro.go @@ -87,12 +87,20 @@ func (p *RetroPlugin) Init() error { return nil } func (p *RetroPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *RetroPlugin) OnMessage(ctx MessageContext) error { - if p.IsCommand(ctx.Body, "game") { - return p.handleSearch(ctx, p.GetArgs(ctx.Body, "game")) - } - if p.IsCommand(ctx.Body, "retro") { - return p.handleSearch(ctx, p.GetArgs(ctx.Body, "retro")) + var query string + switch { + case p.IsCommand(ctx.Body, "game"): + query = p.GetArgs(ctx.Body, "game") + case p.IsCommand(ctx.Body, "retro"): + query = p.GetArgs(ctx.Body, "retro") + default: + return nil } + go func() { + if err := p.handleSearch(ctx, query); err != nil { + slog.Error("retro: handler error", "err", err) + } + }() return nil } @@ -187,14 +195,11 @@ func (p *RetroPlugin) fetchGames(query string) (*retroCacheEntry, error) { // Update cache data, _ := json.Marshal(entry) - _, err = d.Exec( + db.Exec("retro: cache write", `INSERT INTO retro_cache (search_term, data, cached_at) VALUES (?, ?, ?) ON CONFLICT(search_term) DO UPDATE SET data = ?, cached_at = ?`, cacheKey, string(data), time.Now().Unix(), string(data), time.Now().Unix(), ) - if err != nil { - slog.Error("retro: cache write", "err", err) - } return entry, nil } diff --git a/internal/plugin/stats.go b/internal/plugin/stats.go index d7703f5..3993de8 100644 --- a/internal/plugin/stats.go +++ b/internal/plugin/stats.go @@ -37,6 +37,7 @@ func (p *StatsPlugin) Commands() []CommandDef { {Name: "stats", Description: "Show message statistics for a user", Usage: "!stats [@user]", Category: "Leveling & Stats"}, {Name: "rankings", Description: "Show rankings for a stat category", Usage: "!rankings [words|links|questions|emojis]", Category: "Leveling & Stats"}, {Name: "personality", Description: "Show your chat personality archetype", Usage: "!personality", Category: "Leveling & Stats"}, + {Name: "superstatsexplusalpha", Description: "Everything we know about you", Usage: "!superstatsexplusalpha [@user]", Category: "Leveling & Stats"}, } } @@ -55,6 +56,9 @@ func (p *StatsPlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "personality") { return p.handlePersonality(ctx) } + if p.IsCommand(ctx.Body, "superstatsexplusalpha") { + return p.handleSuperStats(ctx) + } // Skip tracking for bot commands if ctx.IsCommand { @@ -316,3 +320,238 @@ func (p *StatsPlugin) handlePersonality(ctx MessageContext) error { ) return p.SendReply(ctx.RoomID, ctx.EventID, msg) } + +func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error { + target := ctx.Sender + args := p.GetArgs(ctx.Body, "superstatsexplusalpha") + if args != "" { + if resolved, ok := p.ResolveUser(args, ctx.RoomID); ok { + target = resolved + } + } + + d := db.Get() + uid := string(target) + displayName := p.DisplayName(target) + var sb strings.Builder + + sb.WriteString(fmt.Sprintf("📋 **SUPER STATS EX+α — %s**\n", displayName)) + sb.WriteString("═══════════════════════════════\n\n") + + // ── Level & Economy ── + var xp, level int + var balance float64 + _ = d.QueryRow(`SELECT xp, level FROM users WHERE user_id = ?`, uid).Scan(&xp, &level) + _ = d.QueryRow(`SELECT balance FROM euro_balances WHERE user_id = ?`, uid).Scan(&balance) + + var achievementCount int + _ = d.QueryRow(`SELECT COUNT(*) FROM achievements WHERE user_id = ?`, uid).Scan(&achievementCount) + + sb.WriteString(fmt.Sprintf("⭐ **Level %d** · %s XP · 💰 €%.0f · 🏅 %d achievements\n\n", + level, formatNumber(xp), balance, achievementCount)) + + // ── Chat Stats ── + var totalMsg, totalWords, totalLinks, totalEmojis, totalQuestions int + err := d.QueryRow( + `SELECT total_messages, total_words, total_links, total_emojis, total_questions + FROM user_stats WHERE user_id = ?`, uid, + ).Scan(&totalMsg, &totalWords, &totalLinks, &totalEmojis, &totalQuestions) + if err == nil && totalMsg > 0 { + sb.WriteString(fmt.Sprintf("💬 **Chat:** %s msgs · %s words · %s links · %s emoji · %s questions\n", + formatNumber(totalMsg), formatNumber(totalWords), formatNumber(totalLinks), + formatNumber(totalEmojis), formatNumber(totalQuestions))) + + // Personality + msgStats := util.MessageStats{Words: totalWords, Links: totalLinks, Emojis: totalEmojis, Questions: totalQuestions} + archetype := util.DeriveArchetype(msgStats, totalMsg) + sb.WriteString(fmt.Sprintf(" Personality: **%s**\n", archetype.Name)) + } + + // ── Sentiment ── + var positive, negative, neutral int + err = d.QueryRow( + `SELECT COALESCE(positive,0), COALESCE(negative,0), COALESCE(neutral,0) + FROM sentiment_stats WHERE user_id = ?`, uid, + ).Scan(&positive, &negative, &neutral) + if err == nil { + total := positive + negative + neutral + if total > 0 { + sb.WriteString(fmt.Sprintf(" Sentiment: +%d / -%d / ~%d", positive, negative, neutral)) + pct := float64(positive) / float64(total) * 100 + if pct >= 60 { + sb.WriteString(" 😊") + } else if float64(negative)/float64(total)*100 >= 40 { + sb.WriteString(" 😤") + } + sb.WriteString("\n") + } + } + + // ── Potty Mouth ── + var mild, moderate, scorching int + err = d.QueryRow( + `SELECT COALESCE(mild,0), COALESCE(moderate,0), COALESCE(scorching,0) + FROM potty_mouth WHERE user_id = ?`, uid, + ).Scan(&mild, &moderate, &scorching) + if err == nil && (mild+moderate+scorching) > 0 { + sb.WriteString(fmt.Sprintf(" Potty mouth: 🤬 %d mild · %d moderate · %d scorching\n", mild, moderate, scorching)) + } + sb.WriteString("\n") + + // ── Adventure ── + var combatLv, miningLv, forageLv, combatXP, miningXP, 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, + alive, current_streak, best_streak + FROM adventure_characters WHERE user_id = ?`, uid, + ).Scan(&combatLv, &miningLv, &forageLv, &combatXP, &miningXP, &forageXP, + &alive, &streak, &bestStreak) + if err == nil { + status := "Alive" + if !alive { + 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)) + if streak > 0 || bestStreak > 0 { + sb.WriteString(fmt.Sprintf(" 🔥 Streak: %d days (best: %d)\n", streak, bestStreak)) + } + + // Equipment score (use canonical scoring function) + equip, eqErr := loadAdvEquipment(id.UserID(uid)) + if eqErr == nil && len(equip) > 0 { + sb.WriteString(fmt.Sprintf(" Equipment score: %d\n", advEquipmentScore(equip))) + } + sb.WriteString("\n") + } + + // ── Game Records ── + sb.WriteString("🎮 **Games:**\n") + hasGames := false + + // Holdem + var hPlayed int + var hWon, hLost, hBiggest int64 + err = d.QueryRow( + `SELECT hands_played, total_won, total_lost, biggest_pot + FROM holdem_scores WHERE user_id = ?`, uid, + ).Scan(&hPlayed, &hWon, &hLost, &hBiggest) + if err == nil && hPlayed > 0 { + net := hWon - hLost + sign := "+" + if net < 0 { + sign = "" + } + sb.WriteString(fmt.Sprintf(" 🃏 Holdem: %d hands · %s€%d net · biggest pot €%d\n", + hPlayed, sign, net, hBiggest)) + hasGames = true + } + + // Blackjack + var bjPlayed, bjWon int + var bjEarned float64 + err = d.QueryRow( + `SELECT games_played, games_won, total_earned + FROM blackjack_scores WHERE user_id = ?`, uid, + ).Scan(&bjPlayed, &bjWon, &bjEarned) + if err == nil && bjPlayed > 0 { + sb.WriteString(fmt.Sprintf(" 🂡 Blackjack: %d/%d W/L · €%.0f earned\n", + bjWon, bjPlayed-bjWon, bjEarned)) + hasGames = true + } + + // Hangman + var hmPlayed, hmWon int + var hmEarned float64 + err = d.QueryRow( + `SELECT games_played, games_won, total_earned + FROM hangman_scores WHERE user_id = ?`, uid, + ).Scan(&hmPlayed, &hmWon, &hmEarned) + if err == nil && hmPlayed > 0 { + sb.WriteString(fmt.Sprintf(" 📝 Hangman: %d/%d W/L · €%.0f earned\n", + hmWon, hmPlayed-hmWon, hmEarned)) + hasGames = true + } + + // Wordle + var wPlayed, wSolved, wGuesses int + err = d.QueryRow( + `SELECT puzzles_played, puzzles_solved, total_guesses + FROM wordle_stats WHERE user_id = ?`, uid, + ).Scan(&wPlayed, &wSolved, &wGuesses) + if err == nil && wPlayed > 0 { + avg := float64(wGuesses) / float64(wPlayed) + sb.WriteString(fmt.Sprintf(" 🟩 Wordle: %d/%d solved · %.1f avg guesses\n", + wSolved, wPlayed, avg)) + hasGames = true + } + + // Trivia + var tCorrect, tWrong int + var tFastest int64 + err = d.QueryRow( + `SELECT COALESCE(SUM(correct),0), COALESCE(SUM(wrong),0), COALESCE(MIN(fastest_ms),0) + FROM trivia_scores WHERE user_id = ?`, uid, + ).Scan(&tCorrect, &tWrong, &tFastest) + if (tCorrect + tWrong) > 0 { + fastStr := "" + if tFastest > 0 { + fastStr = fmt.Sprintf(" · fastest: %.1fs", float64(tFastest)/1000) + } + sb.WriteString(fmt.Sprintf(" 🧠 Trivia: %d/%d correct%s\n", + tCorrect, tCorrect+tWrong, fastStr)) + hasGames = true + } + + // UNO (single + multi combined) + var unoSingleWins, unoSingleTotal int + _ = d.QueryRow( + `SELECT COUNT(*), COALESCE(SUM(CASE WHEN result = 'win' THEN 1 ELSE 0 END), 0) + FROM uno_games WHERE player_id = ?`, uid, + ).Scan(&unoSingleTotal, &unoSingleWins) + var unoMultiWins, unoMultiTotal int + _ = d.QueryRow( + `SELECT COUNT(*), COALESCE(SUM(CASE WHEN winner_id = ? THEN 1 ELSE 0 END), 0) + FROM uno_multi_games WHERE player_ids LIKE ?`, uid, "%"+uid+"%", + ).Scan(&unoMultiTotal, &unoMultiWins) + unoTotal := unoSingleTotal + unoMultiTotal + unoWins := unoSingleWins + unoMultiWins + if unoTotal > 0 { + sb.WriteString(fmt.Sprintf(" 🎴 UNO: %d/%d W/L\n", + unoWins, unoTotal-unoWins)) + hasGames = true + } + + // Bot defeats + var botDefeats int + _ = d.QueryRow(`SELECT COALESCE(SUM(losses), 0) FROM bot_defeats WHERE user_id = ?`, uid).Scan(&botDefeats) + if botDefeats > 0 { + sb.WriteString(fmt.Sprintf(" 🤖 Lost to TwinBee: %d times\n", botDefeats)) + hasGames = true + } + + if !hasGames { + sb.WriteString(" No game records yet.\n") + } + + // ── Commands ── + var cmdCount int + _ = d.QueryRow(`SELECT COALESCE(SUM(count), 0) FROM command_usage WHERE user_id = ?`, uid).Scan(&cmdCount) + if cmdCount > 0 { + sb.WriteString(fmt.Sprintf("\n⌨️ %s commands used\n", formatNumber(cmdCount))) + } + + // ── Account Age ── + var createdAt time.Time + err = d.QueryRow(`SELECT created_at FROM users WHERE user_id = ?`, uid).Scan(&createdAt) + if err == nil { + days := int(time.Since(createdAt).Hours() / 24) + sb.WriteString(fmt.Sprintf("📅 Member for %d days\n", days)) + } + + return p.SendMessage(ctx.RoomID, sb.String()) +} diff --git a/internal/plugin/stocks.go b/internal/plugin/stocks.go index 168676f..b51d9b0 100644 --- a/internal/plugin/stocks.go +++ b/internal/plugin/stocks.go @@ -76,7 +76,12 @@ func (p *StocksPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *StocksPlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "stock") { - return p.handleStock(ctx) + go func() { + if err := p.handleStock(ctx); err != nil { + slog.Error("stocks: handler error", "err", err) + } + }() + return nil } if p.IsCommand(ctx.Body, "stockwatch") { return p.handleStockwatch(ctx) @@ -152,14 +157,11 @@ func (p *StocksPlugin) fetchStock(ticker string) (*stockCacheEntry, error) { // Update cache data, _ := json.Marshal(entry) - _, err = d.Exec( + db.Exec("stocks: cache write", `INSERT INTO stocks_cache (ticker, data, cached_at) VALUES (?, ?, ?) ON CONFLICT(ticker) DO UPDATE SET data = ?, cached_at = ?`, ticker, string(data), time.Now().Unix(), string(data), time.Now().Unix(), ) - if err != nil { - slog.Error("stocks: cache write", "err", err) - } return entry, nil } diff --git a/internal/plugin/streaks.go b/internal/plugin/streaks.go index 03f0d89..2b13648 100644 --- a/internal/plugin/streaks.go +++ b/internal/plugin/streaks.go @@ -59,32 +59,24 @@ func (p *StreaksPlugin) OnMessage(ctx MessageContext) error { } func (p *StreaksPlugin) trackDailyActivity(ctx MessageContext) { - d := db.Get() today := time.Now().UTC().Format("2006-01-02") - _, err := d.Exec( + db.Exec("streaks: track daily activity", `INSERT INTO daily_activity (user_id, date, message_count) VALUES (?, ?, 1) ON CONFLICT(user_id, date) DO UPDATE SET message_count = message_count + 1`, string(ctx.Sender), today, ) - if err != nil { - slog.Error("streaks: track daily activity", "err", err) - } } func (p *StreaksPlugin) trackFirstPoster(ctx MessageContext) { - d := db.Get() today := time.Now().UTC().Format("2006-01-02") now := time.Now().UTC().Unix() // INSERT OR IGNORE — only the first poster for this room+date wins - _, err := d.Exec( + db.Exec("streaks: track first poster", `INSERT OR IGNORE INTO daily_first (room_id, date, user_id, timestamp) VALUES (?, ?, ?, ?)`, string(ctx.RoomID), today, string(ctx.Sender), now, ) - if err != nil { - slog.Error("streaks: track first poster", "err", err) - } } func (p *StreaksPlugin) handleStreak(ctx MessageContext) error { diff --git a/internal/plugin/uno.go b/internal/plugin/uno.go index 5501dd4..6e46c27 100644 --- a/internal/plugin/uno.go +++ b/internal/plugin/uno.go @@ -1,9 +1,7 @@ package plugin import ( - "context" "fmt" - "log/slog" "math/rand/v2" "os" "strings" @@ -583,7 +581,7 @@ func (p *UnoPlugin) handleChallenge(ctx MessageContext, announceRoom id.RoomID, p.mu.Unlock() // Room announcement - playerName := p.unoDisplayName(ctx.Sender) + playerName := p.DisplayName(ctx.Sender) botName := unoBotName() modeTag := "" startComment := pickCommentary("start") @@ -1741,7 +1739,7 @@ func (p *UnoPlugin) playerWins(game *unoGame) error { p.euro.Credit(game.playerID, totalPayout, "uno_win") newPot := p.getPot() - playerName := p.unoDisplayName(game.playerID) + playerName := p.DisplayName(game.playerID) p.SendMessage(game.dmRoomID, "🎉 **Uno out! You win!**") @@ -1772,7 +1770,7 @@ func (p *UnoPlugin) botWins(game *unoGame) error { potBefore := p.getPot() p.addToPot(game.wager) newPot := p.getPot() - playerName := p.unoDisplayName(game.playerID) + playerName := p.DisplayName(game.playerID) p.SendMessage(game.dmRoomID, "💀 **"+unoBotName()+" wins.** Better luck next time.") p.SendMessage(game.roomID, fmt.Sprintf( @@ -1796,7 +1794,7 @@ func (p *UnoPlugin) forfeitGame(game *unoGame, timeout bool) error { potBefore := p.getPot() p.addToPot(game.wager) - playerName := p.unoDisplayName(game.playerID) + playerName := p.DisplayName(game.playerID) if timeout { p.SendMessage(game.dmRoomID, @@ -1881,23 +1879,13 @@ func (p *UnoPlugin) cleanupGame(game *unoGame) { } func (p *UnoPlugin) recordGame(game *unoGame, result string, potBefore float64) { - d := db.Get() - _, err := d.Exec( + db.Exec("uno: record game", `INSERT INTO uno_games (player_id, wager, result, pot_before, pot_after, turns, started_at, ended_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, string(game.playerID), game.wager, result, potBefore, p.getPot(), game.turns, game.startedAt.UTC().Format("2006-01-02 15:04:05"), time.Now().UTC().Format("2006-01-02 15:04:05"), ) - if err != nil { - slog.Error("uno: failed to record game", "err", err) - } } -func (p *UnoPlugin) unoDisplayName(userID id.UserID) string { - resp, err := p.Client.GetDisplayName(context.Background(), userID) - if err != nil || resp.DisplayName == "" { - return string(userID) - } - return resp.DisplayName -} + diff --git a/internal/plugin/uno_multi.go b/internal/plugin/uno_multi.go index 447956e..e838b1f 100644 --- a/internal/plugin/uno_multi.go +++ b/internal/plugin/uno_multi.go @@ -279,7 +279,7 @@ func (p *UnoPlugin) handleMultiStart(ctx MessageContext, amountStr string, noMer p.lobbies[ctx.RoomID] = lobby p.mu.Unlock() - creatorName := p.unoDisplayName(ctx.Sender) + creatorName := p.DisplayName(ctx.Sender) modeTag := "" if noMercy { modeTag = " 🔥 NO MERCY" @@ -354,7 +354,7 @@ func (p *UnoPlugin) handleMultiJoin(ctx MessageContext) error { } 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.unoDisplayName(uid) + name := p.DisplayName(uid) label := "" if uid == lobby.creator { label = " (host)" @@ -403,7 +403,7 @@ func (p *UnoPlugin) handleMultiLeave(ctx MessageContext) error { // Refund the leaving player p.euro.Credit(ctx.Sender, lobby.ante, "uno_multi_refund") - name := p.unoDisplayName(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))) } @@ -489,7 +489,7 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error { for _, u := range players { p.euro.Credit(u, ante, "uno_multi_refund") } - return p.SendMessage(roomID, fmt.Sprintf("🃏 Game cancelled — couldn't open DMs with %s.", p.unoDisplayName(uid))) + return p.SendMessage(roomID, fmt.Sprintf("🃏 Game cancelled — couldn't open DMs with %s.", p.DisplayName(uid))) } resolved = append(resolved, playerDMPair{uid, dmRoom}) } @@ -518,7 +518,7 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error { } sb.WriteString(fmt.Sprintf("🃏 **Multiplayer UNO!**%s Pot: €%d\n\nPlayers:\n", modeTag, int(ante)*len(players))) for i, pl := range game.players { - name := p.unoDisplayName(pl.userID) + name := p.DisplayName(pl.userID) if pl.isBot { name = bn } @@ -651,6 +651,13 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) { player := game.currentPlayer() + // Skip eliminated players (e.g. mercy-killed during their own turn) + if !player.active { + game.currentIdx = game.nextActiveIdx() + game.turnID++ + continue + } + if player.isBot { botTurnsInRow++ if botTurnsInRow > 10 { @@ -676,7 +683,7 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) { // No Mercy stacking: check if player must absorb if game.noMercy && game.stackMinValue > 0 { if !hasStackableCard(player.hand, game.topColor, game.stackMinValue) { - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) drawn := game.draw(game.stackTotal) player.hand = append(player.hand, drawn...) p.SendMessage(player.dmRoomID, fmt.Sprintf("💥 No stackable card! You draw %d cards.\n%s", @@ -735,7 +742,7 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) { if !player.active { continue // mercy-killed } - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) if len(allDrawn) == 0 { p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s has no playable cards and deck is empty. Turn passes. (%d cards)", name, len(player.hand))) p.SendMessage(player.dmRoomID, "No playable cards and deck is empty. Turn passes.") @@ -764,7 +771,7 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) { // Classic: draw 1 drawn := game.draw(1) if len(drawn) == 0 { - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s has no playable cards and deck is empty. Turn passes. (%d cards)", name, len(player.hand))) p.SendMessage(player.dmRoomID, "No playable cards and deck is empty. Turn passes.") game.currentIdx = game.nextActiveIdx() @@ -784,7 +791,7 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) { return } - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) p.SendMessage(player.dmRoomID, fmt.Sprintf("No playable cards — drew automatically: %s\nNot playable. Turn passes.", card.Display())) p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s draws a card. Turn passes. (%d cards)", name, len(player.hand))) @@ -879,7 +886,7 @@ func (p *UnoPlugin) handleMultiDMInput(ctx MessageContext, game *unoMultiGame) e } // No Mercy stacking: accept if game.noMercy && game.stackMinValue > 0 && (input == "accept" || input == "a") { - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) drawn := game.draw(game.stackTotal) player.hand = append(player.hand, drawn...) p.SendMessage(player.dmRoomID, fmt.Sprintf("💥 You accept the stack and draw %d cards.\n%s", @@ -929,7 +936,7 @@ func (p *UnoPlugin) handleMultiDMInput(ctx MessageContext, game *unoMultiGame) e func (p *UnoPlugin) handleMultiChallengeInput(game *unoMultiGame, player *unoMultiPlayer, input string) error { switch input { case "challenge", "c": - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) p.SendMessage(game.roomID, fmt.Sprintf("⚡ %s challenges the Wild Draw Four!", name)) p.resolveWD4Challenge(game, true) case "accept", "a": @@ -963,7 +970,7 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl player.hand = append(player.hand, drawn...) player.calledUno = false p.SendMessage(player.dmRoomID, "⚠️ You forgot to call UNO! Draw 2 as penalty.") - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s forgot to call UNO! 2 card penalty.", name)) if game.noMercy { if p.checkMultiMercyElimination(game, player) { @@ -1024,7 +1031,7 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl if pl == player || !pl.active { continue } - name := p.unoDisplayName(pl.userID) + name := p.DisplayName(pl.userID) if pl.isBot { name = unoBotName() } @@ -1040,8 +1047,8 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl for _, pl := range game.players { if pl != player && pl.active { swapHandsMulti(player, pl) - name := p.unoDisplayName(player.userID) - otherName := p.unoDisplayName(pl.userID) + name := p.DisplayName(player.userID) + otherName := p.DisplayName(pl.userID) if pl.isBot { otherName = unoBotName() } @@ -1068,7 +1075,7 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl // Check win (after discard all, after swap) if len(player.hand) == 0 { - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s plays: %s", name, card.Display())) p.multiPlayerWins(game, player) return nil @@ -1104,7 +1111,7 @@ func (p *UnoPlugin) handleMultiColorChoice(game *unoMultiGame, player *unoMultiP // Check win if len(player.hand) == 0 { - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s plays: %s (chose %s %s)", name, pendingCard.Display(), color.Emoji(), color)) p.multiPlayerWins(game, player) @@ -1113,10 +1120,10 @@ func (p *UnoPlugin) handleMultiColorChoice(game *unoMultiGame, player *unoMultiP // No Mercy: Color Roulette — next player flips until chosen color if game.noMercy && pendingCard != nil && pendingCard.Value == unoWildColorRoulette { - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) nextIdx := game.nextActiveIdx() target := game.players[nextIdx] - targetName := p.unoDisplayName(target.userID) + targetName := p.DisplayName(target.userID) if target.isBot { targetName = unoBotName() } @@ -1157,7 +1164,7 @@ func (p *UnoPlugin) handleMultiPlayerDraw(game *unoMultiGame, player *unoMultiPl drawn := game.draw(1) if len(drawn) == 0 { p.SendMessage(player.dmRoomID, "No cards left to draw! Turn passes.") - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s can't draw — deck is empty. Turn passes.", name)) p.advanceAndExecute(game) return nil @@ -1175,7 +1182,7 @@ func (p *UnoPlugin) handleMultiPlayerDraw(game *unoMultiGame, player *unoMultiPl return nil } - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) p.SendMessage(player.dmRoomID, fmt.Sprintf("You drew: %s\nNot playable. Turn passes.", card.Display())) p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s draws a card. Turn passes. (%d cards)", name, len(player.hand))) p.advanceAndExecute(game) @@ -1207,7 +1214,7 @@ func (p *UnoPlugin) handleMultiPlayerDrawNoMercy(game *unoMultiGame, player *uno } } - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) if len(allDrawn) == 0 { p.SendMessage(player.dmRoomID, "No cards left to draw! Turn passes.") @@ -1245,7 +1252,7 @@ func (p *UnoPlugin) handleMultiDrawnPlayable(game *unoMultiGame, player *unoMult game.phase = unoMultiPhasePlay if input == "no" || input == "n" { - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) p.SendMessage(player.dmRoomID, "Card kept. Turn passes.") p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s draws a card. Turn passes. (%d cards)", name, len(player.hand))) p.advanceAndExecute(game) @@ -1281,7 +1288,7 @@ func (p *UnoPlugin) handleMultiDrawnPlayable(game *unoMultiGame, player *unoMult game.topColor = drawnCard.Color if len(player.hand) == 0 { - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s plays: %s", name, drawnCard.Display())) p.multiPlayerWins(game, player) return nil @@ -1311,7 +1318,7 @@ func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffec nextIdx := game.nextActiveIdx() nextPlayer := game.players[nextIdx] - nextName := p.unoDisplayName(nextPlayer.userID) + nextName := p.DisplayName(nextPlayer.userID) if nextPlayer.isBot { nextName = unoBotName() } @@ -1329,7 +1336,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.unoDisplayName(pl.userID) + name := p.DisplayName(pl.userID) if pl.isBot { name = unoBotName() } @@ -1407,7 +1414,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.unoDisplayName(nextPlayer.userID) + nextName = p.DisplayName(nextPlayer.userID) if nextPlayer.isBot { nextName = unoBotName() } @@ -1468,7 +1475,7 @@ func writeEffectLines(sb *strings.Builder, eff cardEffectResult) { } func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer, card unoCard) { - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) var roomMsg strings.Builder roomMsg.WriteString(fmt.Sprintf("🃏 %s plays: %s", name, card.DisplayWithColor(game.topColor))) @@ -1501,7 +1508,7 @@ func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer, // Next player nextUp := game.currentPlayer() - nextUpName := p.unoDisplayName(nextUp.userID) + nextUpName := p.DisplayName(nextUp.userID) if nextUp.isBot { nextUpName = unoBotName() } @@ -1527,7 +1534,7 @@ func (p *UnoPlugin) startWD4Challenge(game *unoMultiGame, wd4Player *unoMultiPla game.currentIdx = nextIdx game.turnID++ - playerName := p.unoDisplayName(wd4Player.userID) + playerName := p.DisplayName(wd4Player.userID) if wd4Player.isBot { playerName = unoBotName() } @@ -1548,11 +1555,11 @@ func (p *UnoPlugin) startWD4Challenge(game *unoMultiGame, wd4Player *unoMultiPla func (p *UnoPlugin) resolveWD4Challenge(game *unoMultiGame, challenged bool) { wd4Player := game.wd4Player victim := game.wd4Victim - wd4Name := p.unoDisplayName(wd4Player.userID) + wd4Name := p.DisplayName(wd4Player.userID) if wd4Player.isBot { wd4Name = unoBotName() } - victimName := p.unoDisplayName(victim.userID) + victimName := p.DisplayName(victim.userID) if victim.isBot { victimName = unoBotName() } @@ -1898,7 +1905,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.unoDisplayName(target.userID) + targetName := p.DisplayName(target.userID) if target.isBot { targetName = bn } @@ -1973,7 +1980,7 @@ func (p *UnoPlugin) startMultiAutoPlayTimer(game *unoMultiGame) { player.autoPlays++ maxAutoPlays := envInt("UNO_MULTI_MAX_AUTOPLAY", 3) - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) if player.autoPlays >= maxAutoPlays { p.SendMessage(mg.roomID, fmt.Sprintf("🃏 %s was auto-played %d times in a row — forfeited!", name, maxAutoPlays)) @@ -2031,7 +2038,7 @@ func (p *UnoPlugin) startInactivityTimer(game *unoMultiGame) { } func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer) { - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) switch game.phase { case unoMultiPhaseChallenge: @@ -2065,7 +2072,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.unoDisplayName(target.userID) + targetName := p.DisplayName(target.userID) if target.isBot { targetName = unoBotName() } @@ -2145,7 +2152,7 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer target := botChooseSwapTarget(game, player) if target != nil { swapHandsMulti(player, target) - targetName := p.unoDisplayName(target.userID) + targetName := p.DisplayName(target.userID) if target.isBot { targetName = unoBotName() } @@ -2327,7 +2334,7 @@ func (p *UnoPlugin) multiPlayerWins(game *unoMultiGame, winner *unoMultiPlayer) } totalPot := game.ante * float64(humanCount) - name := p.unoDisplayName(winner.userID) + name := p.DisplayName(winner.userID) p.euro.Credit(winner.userID, totalPot, "uno_multi_win") p.SendMessage(game.roomID, fmt.Sprintf( @@ -2378,7 +2385,7 @@ func (p *UnoPlugin) multiBotWins(game *unoMultiGame) { func (p *UnoPlugin) multiPlayerForfeit(game *unoMultiGame, player *unoMultiPlayer) { player.active = false - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) // Shuffle cards back into draw pile game.drawPile = append(game.drawPile, player.hand...) @@ -2428,8 +2435,6 @@ func (p *UnoPlugin) cleanupMultiGame(game *unoMultiGame) { } func (p *UnoPlugin) recordMultiGame(game *unoMultiGame, winnerID id.UserID, result string) { - d := db.Get() - playerIDs := make([]string, 0) for _, pl := range game.players { if !pl.isBot { @@ -2440,7 +2445,7 @@ func (p *UnoPlugin) recordMultiGame(game *unoMultiGame, winnerID id.UserID, resu humanCount := len(playerIDs) totalPot := game.ante * float64(humanCount) - _, err := d.Exec( + 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, string(game.roomID), game.ante, totalPot, string(winnerID), @@ -2448,9 +2453,6 @@ func (p *UnoPlugin) recordMultiGame(game *unoMultiGame, winnerID id.UserID, resu game.startedAt.UTC().Format("2006-01-02 15:04:05"), time.Now().UTC().Format("2006-01-02 15:04:05"), ) - if err != nil { - slog.Error("uno_multi: failed to record game", "err", err) - } } // --------------------------------------------------------------------------- @@ -2461,7 +2463,7 @@ func (p *UnoPlugin) sendMultiStatus(game *unoMultiGame, player *unoMultiPlayer) var sb strings.Builder bn := unoBotName() current := game.currentPlayer() - currentName := p.unoDisplayName(current.userID) + currentName := p.DisplayName(current.userID) if current.isBot { currentName = bn } @@ -2484,7 +2486,7 @@ func (p *UnoPlugin) sendMultiStatus(game *unoMultiGame, player *unoMultiPlayer) if pl == player || !pl.active { continue } - name := p.unoDisplayName(pl.userID) + name := p.DisplayName(pl.userID) if pl.isBot { name = bn } @@ -2516,7 +2518,7 @@ func (p *UnoPlugin) sendMultiHandDisplay(game *unoMultiGame, player *unoMultiPla if pl == player || !pl.active { continue } - name := p.unoDisplayName(pl.userID) + name := p.DisplayName(pl.userID) if pl.isBot { name = bn } @@ -2554,7 +2556,7 @@ func (p *UnoPlugin) sendMultiHandDisplayStacking(game *unoMultiGame, player *uno if pl == player || !pl.active { continue } - name := p.unoDisplayName(pl.userID) + name := p.DisplayName(pl.userID) if pl.isBot { name = bn } @@ -2598,8 +2600,8 @@ func (p *UnoPlugin) handleMultiSwapChoice(game *unoMultiGame, player *unoMultiPl swapHandsMulti(player, target) game.phase = unoMultiPhasePlay - name := p.unoDisplayName(player.userID) - targetName := p.unoDisplayName(target.userID) + name := p.DisplayName(player.userID) + targetName := p.DisplayName(target.userID) if target.isBot { targetName = unoBotName() } diff --git a/internal/plugin/uno_nomercy.go b/internal/plugin/uno_nomercy.go index 7c58f59..8305eda 100644 --- a/internal/plugin/uno_nomercy.go +++ b/internal/plugin/uno_nomercy.go @@ -151,7 +151,7 @@ func (p *UnoPlugin) checkMultiMercyElimination(game *unoMultiGame, player *unoMu } player.active = false - name := p.unoDisplayName(player.userID) + name := p.DisplayName(player.userID) if player.isBot { name = unoBotName() } diff --git a/internal/plugin/uno_test.go b/internal/plugin/uno_test.go new file mode 100644 index 0000000..d06f449 --- /dev/null +++ b/internal/plugin/uno_test.go @@ -0,0 +1,290 @@ +package plugin + +import "testing" + +// --------------------------------------------------------------------------- +// Card properties +// --------------------------------------------------------------------------- + +func TestUnoCard_IsWild(t *testing.T) { + tests := []struct { + name string + card unoCard + want bool + }{ + {"wild", unoCard{unoWild, unoWildCard}, true}, + {"wild draw four", unoCard{unoWild, unoWildDrawFour}, true}, + {"wild draw six", unoCard{unoWild, unoWildDrawSix}, true}, + {"wild draw ten", unoCard{unoWild, unoWildDrawTen}, true}, + {"wild reverse draw 4", unoCard{unoWild, unoWildReverseDraw4}, true}, + {"wild color roulette", unoCard{unoWild, unoWildColorRoulette}, true}, + {"red skip", unoCard{unoRed, unoSkip}, false}, + {"blue 5", unoCard{unoBlue, unoFive}, false}, + {"green draw two", unoCard{unoGreen, unoDrawTwo}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.card.isWild(); got != tt.want { + t.Errorf("isWild() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestUnoValue_IsAction(t *testing.T) { + actions := []unoValue{unoSkip, unoReverse, unoDrawTwo, unoWildCard, unoWildDrawFour, + unoSkipEveryone, unoDrawFour, unoDiscardAll, unoWildReverseDraw4, + unoWildDrawSix, unoWildDrawTen, unoWildColorRoulette} + for _, v := range actions { + if !v.isAction() { + t.Errorf("%s should be an action card", v) + } + } + + numbers := []unoValue{unoZero, unoOne, unoTwo, unoThree, unoFour, unoFive, unoSix, unoSeven, unoEight, unoNine} + for _, v := range numbers { + if v.isAction() { + t.Errorf("%s should NOT be an action card", v) + } + } +} + +// --------------------------------------------------------------------------- +// canPlayOn +// --------------------------------------------------------------------------- + +func TestCanPlayOn(t *testing.T) { + tests := []struct { + name string + card unoCard + top unoCard + topColor unoColor + want bool + }{ + {"wild always playable", unoCard{unoWild, unoWildCard}, unoCard{unoRed, unoFive}, unoRed, true}, + {"wd4 always playable", unoCard{unoWild, unoWildDrawFour}, unoCard{unoBlue, unoThree}, unoBlue, true}, + {"same color", unoCard{unoRed, unoThree}, unoCard{unoRed, unoSeven}, unoRed, true}, + {"same value diff color", unoCard{unoBlue, unoFive}, unoCard{unoRed, unoFive}, unoRed, true}, + {"diff color diff value", unoCard{unoBlue, unoThree}, unoCard{unoRed, unoSeven}, unoRed, false}, + {"matches chosen color not card color", unoCard{unoGreen, unoTwo}, unoCard{unoWild, unoWildCard}, unoGreen, true}, + {"doesnt match chosen color", unoCard{unoRed, unoTwo}, unoCard{unoWild, unoWildCard}, unoGreen, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.card.canPlayOn(tt.top, tt.topColor); got != tt.want { + t.Errorf("canPlayOn() = %v, want %v", got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Deck sizes +// --------------------------------------------------------------------------- + +func TestNewUnoDeck_Size(t *testing.T) { + deck := newUnoDeck() + // Standard UNO: 4 colors × (1 zero + 2×12 other) + 8 wilds = 4×25 + 8 = 108 + if len(deck) != 108 { + t.Errorf("standard deck size = %d, want 108", len(deck)) + } +} + +func TestNewNoMercyDeck_Size(t *testing.T) { + deck := newNoMercyDeck() + if len(deck) < 108 { + t.Errorf("no mercy deck should be larger than standard, got %d", len(deck)) + } +} + +// --------------------------------------------------------------------------- +// Bot AI +// --------------------------------------------------------------------------- + +func TestBotPickCard_NoPlayable(t *testing.T) { + hand := []unoCard{ + {unoRed, unoOne}, + {unoRed, unoTwo}, + } + top := unoCard{unoBlue, unoSeven} + _, idx := botPickCard(hand, top, unoBlue, false, 5) + if idx != -1 { + t.Errorf("should return -1 when nothing playable, got %d", idx) + } +} + +func TestBotPickCard_PlaysPlayable(t *testing.T) { + hand := []unoCard{ + {unoRed, unoOne}, + {unoBlue, unoSeven}, // matches top + } + top := unoCard{unoBlue, unoFive} + card, idx := botPickCard(hand, top, unoBlue, false, 5) + if idx == -1 { + t.Fatal("should find a playable card") + } + if !card.canPlayOn(top, unoBlue) { + t.Error("picked card should be playable on top") + } +} + +func TestBotPickCard_AggressiveUsesWD4(t *testing.T) { + hand := []unoCard{ + {unoRed, unoOne}, + {unoWild, unoWildDrawFour}, + {unoBlue, unoSeven}, + } + top := unoCard{unoBlue, unoFive} + // bookDown=true triggers aggressive mode + card, idx := botPickCard(hand, top, unoBlue, true, 5) + if idx == -1 { + t.Fatal("should find a playable card") + } + if card.Value != unoWildDrawFour { + t.Errorf("aggressive mode should prefer WD4, got %s", card.Value) + } +} + +func TestBotPickColor_MostCommon(t *testing.T) { + hand := []unoCard{ + {unoRed, unoOne}, + {unoRed, unoTwo}, + {unoRed, unoThree}, + {unoBlue, unoFive}, + {unoWild, unoWildCard}, // wilds don't count + } + color := botPickColor(hand) + if color != unoRed { + t.Errorf("should pick red (3 cards), got %s", color) + } +} + +func TestBotPickColor_EmptyHand(t *testing.T) { + // Should default to red with no cards + color := botPickColor([]unoCard{}) + if color != unoRed { + t.Errorf("empty hand should default to red, got %s", color) + } +} + +// --------------------------------------------------------------------------- +// No Mercy helpers +// --------------------------------------------------------------------------- + +func TestCardDrawValue(t *testing.T) { + tests := []struct { + value unoValue + want int + }{ + {unoDrawTwo, 2}, + {unoDrawFour, 4}, + {unoWildReverseDraw4, 4}, + {unoWildDrawSix, 6}, + {unoWildDrawTen, 10}, + {unoOne, 0}, + {unoSkip, 0}, + {unoWildCard, 0}, + } + for _, tt := range tests { + if got := cardDrawValue(tt.value); got != tt.want { + t.Errorf("cardDrawValue(%s) = %d, want %d", tt.value, got, tt.want) + } + } +} + +func TestIsDrawCard(t *testing.T) { + if !isDrawCard(unoDrawTwo) { + t.Error("Draw Two should be a draw card") + } + if !isDrawCard(unoWildDrawTen) { + t.Error("Wild Draw Ten should be a draw card") + } + if isDrawCard(unoSkip) { + t.Error("Skip should not be a draw card") + } +} + +func TestCanPlayOnStacking(t *testing.T) { + tests := []struct { + name string + card unoCard + topColor unoColor + stackMinValue int + want bool + }{ + {"wild draw six stacks on 2", unoCard{unoWild, unoWildDrawSix}, unoRed, 2, true}, + {"draw two on draw two same color", unoCard{unoRed, unoDrawTwo}, unoRed, 2, true}, + {"draw two wrong color", unoCard{unoBlue, unoDrawTwo}, unoRed, 2, false}, + {"number cant stack", unoCard{unoRed, unoFive}, unoRed, 2, false}, + {"draw two cant stack on 4", unoCard{unoRed, unoDrawTwo}, unoRed, 4, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.card.canPlayOnStacking(tt.topColor, tt.stackMinValue); got != tt.want { + t.Errorf("canPlayOnStacking() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHasStackableCard(t *testing.T) { + hand := []unoCard{ + {unoRed, unoFive}, + {unoBlue, unoDrawTwo}, + {unoGreen, unoSkip}, + } + if hasStackableCard(hand, unoBlue, 2) != true { + t.Error("should find blue draw two as stackable") + } + if hasStackableCard(hand, unoRed, 2) != false { + t.Error("no red draw cards in hand") + } +} + +func TestParseNoMercyFlags(t *testing.T) { + tests := []struct { + input string + noMercy bool + sevenZero bool + amount string + }{ + {"nomercy", true, false, ""}, + {"nomercy 50", true, false, "50"}, + {"nomercy7-0", true, true, ""}, + {"nomercy7-0 100", true, true, "100"}, + {"50", false, false, "50"}, + {"", false, false, ""}, + {"NOMERCY", true, false, ""}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + nm, sz, amt := parseNoMercyFlags(tt.input) + if nm != tt.noMercy || sz != tt.sevenZero || amt != tt.amount { + t.Errorf("parseNoMercyFlags(%q) = (%v, %v, %q), want (%v, %v, %q)", + tt.input, nm, sz, amt, tt.noMercy, tt.sevenZero, tt.amount) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Color/Value string representations +// --------------------------------------------------------------------------- + +func TestUnoColor_String(t *testing.T) { + if unoRed.String() != "Red" { + t.Errorf("red: got %q", unoRed.String()) + } + if unoWild.String() != "Wild" { + t.Errorf("wild: got %q", unoWild.String()) + } +} + +func TestUnoColor_Emoji(t *testing.T) { + if unoRed.Emoji() != "🟥" { + t.Errorf("red emoji: got %q", unoRed.Emoji()) + } + if unoBlue.Emoji() != "🟦" { + t.Errorf("blue emoji: got %q", unoBlue.Emoji()) + } +} diff --git a/internal/plugin/urls.go b/internal/plugin/urls.go index 92c3423..588087c 100644 --- a/internal/plugin/urls.go +++ b/internal/plugin/urls.go @@ -70,38 +70,39 @@ func (p *URLsPlugin) OnMessage(ctx MessageContext) error { return nil } - for _, u := range urls { - title, desc, err := p.fetchPreview(u) - if err != nil { - slog.Debug("urls: fetch preview failed", "url", u, "err", err) - continue - } + go p.previewURL(ctx, urls[0]) + return nil +} - if title == "" && desc == "" { - continue - } - - var preview strings.Builder - if title != "" { - preview.WriteString(fmt.Sprintf("Title: %s", title)) - } - if desc != "" { - if preview.Len() > 0 { - preview.WriteString("\n") - } - // Truncate long descriptions - if len(desc) > 200 { - desc = desc[:200] + "..." - } - preview.WriteString(fmt.Sprintf("Description: %s", desc)) - } - - if err := p.SendReply(ctx.RoomID, ctx.EventID, preview.String()); err != nil { - slog.Error("urls: send preview", "err", err) - } +func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) { + title, desc, err := p.fetchPreview(targetURL) + if err != nil { + slog.Debug("urls: fetch preview failed", "url", targetURL, "err", err) + return } - return nil + if title == "" && desc == "" { + return + } + + var preview strings.Builder + if title != "" { + preview.WriteString(fmt.Sprintf("Title: %s", title)) + } + if desc != "" { + if preview.Len() > 0 { + preview.WriteString("\n") + } + // Truncate long descriptions + if len(desc) > 200 { + desc = desc[:200] + "..." + } + preview.WriteString(fmt.Sprintf("Description: %s", desc)) + } + + if err := p.SendReply(ctx.RoomID, ctx.EventID, preview.String()); err != nil { + slog.Error("urls: send preview", "err", err) + } } // fetchPreview retrieves og:title and og:description, checking cache first. @@ -127,14 +128,11 @@ func (p *URLsPlugin) fetchPreview(rawURL string) (string, string, error) { } // Cache the result - _, cacheErr := d.Exec( + db.Exec("urls: cache write", `INSERT INTO url_cache (url, title, description, cached_at) VALUES (?, ?, ?, ?) ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, cached_at = ?`, rawURL, title, desc, now, title, desc, now, ) - if cacheErr != nil { - slog.Error("urls: cache write", "err", cacheErr) - } return title, desc, nil } diff --git a/internal/plugin/wordle.go b/internal/plugin/wordle.go index 6742036..c6697e4 100644 --- a/internal/plugin/wordle.go +++ b/internal/plugin/wordle.go @@ -1,7 +1,6 @@ package plugin import ( - "context" "database/sql" "fmt" "log/slog" @@ -21,6 +20,7 @@ import ( // WordlePlugin provides a daily cooperative Wordle game. type WordlePlugin struct { Base + euro *EuroPlugin apiKey string httpClient *http.Client defaultLength int @@ -33,7 +33,7 @@ type WordlePlugin struct { } // NewWordlePlugin creates a new WordlePlugin. -func NewWordlePlugin(client *mautrix.Client) *WordlePlugin { +func NewWordlePlugin(client *mautrix.Client, euro *EuroPlugin) *WordlePlugin { length := 5 if v := os.Getenv("WORDLE_DEFAULT_LENGTH"); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 5 && n <= 7 { @@ -43,6 +43,7 @@ func NewWordlePlugin(client *mautrix.Client) *WordlePlugin { return &WordlePlugin{ Base: NewBase(client), + euro: euro, apiKey: os.Getenv("WORDNIK_API_KEY"), httpClient: &http.Client{Timeout: 10 * time.Second}, defaultLength: length, @@ -163,7 +164,7 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error { } // Get display name. - displayName := p.displayName(ctx.Sender) + displayName := p.DisplayName(ctx.Sender) // Score the guess. results := scoreGuess(guess, puzzle.Answer) @@ -187,8 +188,9 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error { definition := p.fetchDefinition(puzzle.Answer) p.updateStats(puzzle) p.markPuzzleDone(puzzle) + payouts := p.awardPrize(puzzle) - return p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition)) + return p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition, payouts)) } // Check for failure (all guesses used). @@ -216,8 +218,12 @@ func (p *WordlePlugin) handleGrid(ctx MessageContext) error { } if len(puzzle.Guesses) == 0 { + hint := "" + if isCustomAllowedWord(puzzle.Answer) { + hint = "Today's word is video game related" + } return p.SendReply(ctx.RoomID, ctx.EventID, - renderWordleStartAnnouncement(puzzle.PuzzleNumber, puzzle.WordLength)) + renderWordleStartAnnouncement(puzzle.PuzzleNumber, puzzle.WordLength, hint)) } return p.SendMessage(ctx.RoomID, renderWordleGrid(puzzle)) @@ -306,18 +312,11 @@ func (p *WordlePlugin) handleStats(ctx MessageContext) error { // createAndPostPuzzle creates a new puzzle, persists it, and posts the announcement. func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int) error { - if p.apiKey == "" { - return p.SendMessage(roomID, "Wordle is unavailable — WORDNIK_API_KEY not configured.") - } - - // Try Wordnik first, fall back to local word list. - word, err := wordnikFetchRandomWord(p.apiKey, p.httpClient, wordLength) - if err != nil { - slog.Warn("wordle: Wordnik fetch failed, trying fallback", "err", err) - word = pickFallbackWord(wordLength) - if word == "" { - return p.SendMessage(roomID, "Failed to select a puzzle word. Try again later.") - } + // Use local word list for puzzle selection. Wordnik's randomWord endpoint + // requires a paid API tier; we keep Wordnik for guess validation and definitions only. + word := pickFallbackWord(wordLength) + if word == "" { + return p.SendMessage(roomID, "Failed to select a puzzle word — no words available for that length.") } puzzleNumber := p.nextPuzzleNumber() @@ -336,24 +335,30 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int) err } // Persist to DB. - d := db.Get() - _, err = d.Exec( + db.Exec("wordle: persist puzzle", `INSERT INTO wordle_puzzles (puzzle_id, room_id, answer, word_length, solved, guess_count, started_at) VALUES (?, ?, ?, ?, 0, 0, ?) ON CONFLICT(puzzle_id, room_id) DO UPDATE SET answer = ?, word_length = ?, solved = 0, guess_count = 0, started_at = ?`, today, string(roomID), word, wordLength, now, word, wordLength, now, ) - if err != nil { - slog.Error("wordle: persist puzzle", "err", err) - } p.mu.Lock() p.puzzles[roomID] = puzzle + // Evict stale cache entries from previous days. + for key := range p.validCache { + if key != today { + delete(p.validCache, key) + } + } p.validCache[today] = make(map[string]bool) p.mu.Unlock() - return p.SendMessage(roomID, renderWordleStartAnnouncement(puzzleNumber, wordLength)) + hint := "" + if isCustomAllowedWord(word) { + hint = "Today's word is video game related" + } + return p.SendMessage(roomID, renderWordleStartAnnouncement(puzzleNumber, wordLength, hint)) } // PostDailyPuzzle is called by the scheduler to post today's puzzle. @@ -372,7 +377,8 @@ func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error { return p.createAndPostPuzzle(roomID, p.defaultLength) } -// isValidWord checks if a word is valid, using the in-memory cache first. +// isValidWord checks if a word is valid, using the in-memory cache first, +// then the custom allow-list, then Wordnik. // Returns (valid, apiError). apiError is true when the API is unreachable. func (p *WordlePlugin) isValidWord(puzzleID, word string) (bool, bool) { cache := p.validCache[puzzleID] @@ -385,6 +391,12 @@ func (p *WordlePlugin) isValidWord(puzzleID, word string) (bool, bool) { return valid, false } + // Check custom allow-list (game titles, etc.) before hitting the API. + if isCustomAllowedWord(word) { + cache[word] = true + return true, false + } + valid, apiErr := wordnikValidateWord(p.apiKey, p.httpClient, word) if !apiErr { cache[word] = valid // only cache definitive results @@ -396,13 +408,6 @@ func (p *WordlePlugin) fetchDefinition(word string) string { return wordnikFetchDefinitionText(p.apiKey, p.httpClient, word) } -func (p *WordlePlugin) displayName(userID id.UserID) string { - resp, err := p.Client.GetDisplayName(context.Background(), userID) - if err != nil || resp.DisplayName == "" { - return string(userID) - } - return resp.DisplayName -} func (p *WordlePlugin) nextPuzzleNumber() int { d := db.Get() @@ -415,18 +420,14 @@ func (p *WordlePlugin) nextPuzzleNumber() int { } func (p *WordlePlugin) markPuzzleDone(puzzle *WordlePuzzle) { - d := db.Get() solved := 0 if puzzle.Solved { solved = 1 } - _, err := d.Exec( + db.Exec("wordle: mark puzzle done", `UPDATE wordle_puzzles SET solved = ?, guess_count = ?, solved_at = ? WHERE puzzle_id = ? AND room_id = ?`, solved, len(puzzle.Guesses), puzzle.SolvedAt, puzzle.PuzzleID, string(puzzle.RoomID), ) - if err != nil { - slog.Error("wordle: mark puzzle done", "err", err) - } } func (p *WordlePlugin) updateStats(puzzle *WordlePuzzle) { @@ -491,6 +492,66 @@ func (p *WordlePlugin) updateStats(puzzle *WordlePuzzle) { } } +// WordlePayout tracks a payout for rendering. +type WordlePayout struct { + Name string + Amount int + Solver bool +} + +// wordleBasePots maps guess count to euro prize pot. Fewer guesses = bigger reward. +var wordleBasePots = [7]int{0, 100, 80, 60, 45, 35, 25} + +// awardPrize credits euros to all contributors when a puzzle is solved. +// The solver who got the final guess gets a 50% bonus on their share. +func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout { + if !puzzle.Solved || p.euro == nil { + return nil + } + + guessesUsed := len(puzzle.Guesses) + pot := 25 + if guessesUsed >= 1 && guessesUsed < len(wordleBasePots) { + pot = wordleBasePots[guessesUsed] + } + + // Tally contributors. + type info struct { + name string + solver bool + } + contributors := map[id.UserID]*info{} + var order []id.UserID + for i, g := range puzzle.Guesses { + if _, ok := contributors[g.PlayerID]; !ok { + contributors[g.PlayerID] = &info{name: g.PlayerName} + order = append(order, g.PlayerID) + } + if i == len(puzzle.Guesses)-1 { + contributors[g.PlayerID].solver = true + } + } + + numPlayers := len(contributors) + share := pot / numPlayers + if share < 5 { + share = 5 + } + solverBonus := share / 2 + + var payouts []WordlePayout + for _, uid := range order { + c := contributors[uid] + amount := share + if c.solver { + amount += solverBonus + } + p.euro.Credit(uid, float64(amount), "wordle_win") + payouts = append(payouts, WordlePayout{Name: c.name, Amount: amount, Solver: c.solver}) + } + return payouts +} + func (p *WordlePlugin) communityStreak() int { d := db.Get() rows, err := d.Query( @@ -520,6 +581,17 @@ func (p *WordlePlugin) rehydratePuzzles() { today := time.Now().UTC().Format("2006-01-02") d := db.Get() + // Collect rows first, then close the cursor before doing any nested queries. + // SQLite deadlocks if a nested query runs while rows are still open on a + // single-connection pool. + type puzzleRow struct { + pid string + roomStr string + answer string + wordLength int + startedAt time.Time + } + rows, err := d.Query( `SELECT puzzle_id, room_id, answer, word_length, solved, guess_count, started_at FROM wordle_puzzles WHERE puzzle_id = ?`, today) @@ -527,8 +599,8 @@ func (p *WordlePlugin) rehydratePuzzles() { slog.Warn("wordle: rehydrate query failed", "err", err) return } - defer rows.Close() + var pending []puzzleRow for rows.Next() { var pid, roomStr, answer string var wordLength, solved, guessCount int @@ -541,32 +613,37 @@ func (p *WordlePlugin) rehydratePuzzles() { continue // already done } - roomID := id.RoomID(roomStr) + pending = append(pending, puzzleRow{pid, roomStr, answer, wordLength, startedAt}) + } + rows.Close() - // Get puzzle number. + for _, pr := range pending { + roomID := id.RoomID(pr.roomStr) + + // Get puzzle number (safe now — no open cursor). var puzzleNumber int - err := d.QueryRow(`SELECT COUNT(*) FROM wordle_puzzles WHERE puzzle_id <= ?`, pid).Scan(&puzzleNumber) + err := d.QueryRow(`SELECT COUNT(*) FROM wordle_puzzles WHERE puzzle_id <= ?`, pr.pid).Scan(&puzzleNumber) if err != nil { puzzleNumber = 1 } puzzle := &WordlePuzzle{ - PuzzleID: pid, + PuzzleID: pr.pid, PuzzleNumber: puzzleNumber, RoomID: roomID, - Answer: answer, - WordLength: wordLength, + Answer: pr.answer, + WordLength: pr.wordLength, MaxGuesses: 6, - StartedAt: startedAt, + StartedAt: pr.startedAt, LetterStates: make(map[rune]LetterResult), } p.mu.Lock() p.puzzles[roomID] = puzzle - p.validCache[pid] = make(map[string]bool) + p.validCache[pr.pid] = make(map[string]bool) p.mu.Unlock() - slog.Info("wordle: rehydrated puzzle", "room", roomID, "answer_len", wordLength) + slog.Info("wordle: rehydrated puzzle", "room", roomID, "answer_len", pr.wordLength) } } diff --git a/internal/plugin/wordle_fallback.go b/internal/plugin/wordle_fallback.go index 13a7787..eb0149b 100644 --- a/internal/plugin/wordle_fallback.go +++ b/internal/plugin/wordle_fallback.go @@ -7,10 +7,13 @@ import ( "os" "strings" "sync" + + "gogobee/internal/db" ) var ( fallbackWords map[int][]string + customAllowSet map[string]bool // words valid as guesses but not in dictionary fallbackWordsOnce sync.Once ) @@ -43,9 +46,41 @@ func loadFallbackWords() { for n, words := range fallbackWords { slog.Info("wordle: loaded fallback words", "length", n, "count", len(words)) } + + // Load custom word lists (game titles, etc.) — these are added to the + // puzzle pool AND accepted as valid guesses even if not in the dictionary. + customAllowSet = make(map[string]bool) + loadCustomWordFile("data/wordle_games.txt") } -// pickFallbackWord picks a random word of the given length from the fallback list. +func loadCustomWordFile(path string) { + f, err := os.Open(path) + if err != nil { + slog.Warn("wordle: custom word list not found", "path", path) + return + } + defer f.Close() + + count := 0 + scanner := bufio.NewScanner(f) + for scanner.Scan() { + word := strings.TrimSpace(scanner.Text()) + if word == "" || strings.HasPrefix(word, "#") { + continue + } + word = strings.ToUpper(word) + n := len([]rune(word)) + if n >= 5 && n <= 7 { + fallbackWords[n] = append(fallbackWords[n], word) + customAllowSet[word] = true + count++ + } + } + slog.Info("wordle: loaded custom words", "path", path, "count", count) +} + +// pickFallbackWord picks a random word of the given length from the fallback list, +// excluding words used in the last 500 puzzles. func pickFallbackWord(length int) string { fallbackWordsOnce.Do(loadFallbackWords) @@ -53,5 +88,47 @@ func pickFallbackWord(length int) string { if len(words) == 0 { return "" } - return words[rand.IntN(len(words))] + + recent := loadRecentWordleAnswers(500) + + var candidates []string + for _, w := range words { + if !recent[w] { + candidates = append(candidates, w) + } + } + + // Fall back to full list if all words have been used recently. + if len(candidates) == 0 { + candidates = words + } + return candidates[rand.IntN(len(candidates))] +} + +// isCustomAllowedWord checks if a word is in the custom allow-list (game titles, etc.). +func isCustomAllowedWord(word string) bool { + fallbackWordsOnce.Do(loadFallbackWords) + return customAllowSet[strings.ToUpper(word)] +} + +// loadRecentWordleAnswers returns a set of answers from the most recent N puzzles. +func loadRecentWordleAnswers(limit int) map[string]bool { + d := db.Get() + rows, err := d.Query(`SELECT DISTINCT answer FROM wordle_puzzles + ORDER BY started_at DESC LIMIT ?`, limit) + if err != nil { + slog.Error("wordle: failed to load recent answers", "err", err) + return nil + } + defer rows.Close() + + recent := make(map[string]bool) + for rows.Next() { + var word string + if err := rows.Scan(&word); err != nil { + continue + } + recent[word] = true + } + return recent } diff --git a/internal/plugin/wordle_game_test.go b/internal/plugin/wordle_game_test.go new file mode 100644 index 0000000..59af8a5 --- /dev/null +++ b/internal/plugin/wordle_game_test.go @@ -0,0 +1,148 @@ +package plugin + +import ( + "testing" +) + +func TestScoreGuess_ExactMatch(t *testing.T) { + results := scoreGuess("HELLO", "HELLO") + for i, r := range results { + if r != LetterCorrect { + t.Errorf("position %d: got %d, want LetterCorrect", i, r) + } + } +} + +func TestScoreGuess_AllAbsent(t *testing.T) { + results := scoreGuess("XXXYZ", "HELLO") + for i, r := range results { + if r != LetterAbsent { + t.Errorf("position %d: got %d, want LetterAbsent", i, r) + } + } +} + +func TestScoreGuess_MixedResults(t *testing.T) { + // WORLD vs HELLO: + // W=absent, O=present, R=absent, L=correct(pos3 matches), D=absent + results := scoreGuess("WORLD", "HELLO") + expected := []LetterResult{LetterAbsent, LetterPresent, LetterAbsent, LetterCorrect, LetterAbsent} + for i, r := range results { + if r != expected[i] { + t.Errorf("position %d: got %d, want %d", i, r, expected[i]) + } + } +} + +func TestScoreGuess_DuplicateLetters(t *testing.T) { + // SPEED vs ABIDE: + // S=absent, P=absent, E=present(E is in ABIDE pos 4), E=present? no, only one E in answer + // Actually: ABIDE has E at position 4 (0-indexed: A=0,B=1,I=2,D=3,E=4) + // SPEED: S=absent, P=absent, E=absent(no more E after first match), E=present, D=present + results := scoreGuess("SPEED", "ABIDE") + // S not in ABIDE -> absent + // P not in ABIDE -> absent + // E in ABIDE pos4 -> present (consumes the E) + // E again but no more E in pool -> absent + // D in ABIDE pos3 -> present + expected := []LetterResult{LetterAbsent, LetterAbsent, LetterPresent, LetterAbsent, LetterPresent} + for i, r := range results { + if r != expected[i] { + t.Errorf("position %d: got %d, want %d", i, r, expected[i]) + } + } +} + +func TestScoreGuess_DuplicateWithCorrect(t *testing.T) { + // LLAMA vs HELLO: + // L at 0: not correct (H), L is in HELLO -> present (consumes one L) + // L at 1: not correct (E), L is in HELLO -> present (consumes second L) + // A at 2: absent + // M at 3: absent + // A at 4: absent + // Wait, HELLO has L at positions 2,3. So pool after first pass has H,E,L,L,O + // Actually no first pass: no exact matches for LLAMA vs HELLO + // Pool = H,E,L,L,O + // L at 0: found L in pool, present (pool = H,E,L,O) + // L at 1: found L in pool, present (pool = H,E,O) + // A at 2: not found, absent + // M at 3: not found, absent + // A at 4: not found, absent + results := scoreGuess("LLAMA", "HELLO") + expected := []LetterResult{LetterPresent, LetterPresent, LetterAbsent, LetterAbsent, LetterAbsent} + for i, r := range results { + if r != expected[i] { + t.Errorf("position %d: got %d, want %d", i, r, expected[i]) + } + } +} + +func TestScoreGuess_CorrectTakesPriority(t *testing.T) { + // HELLO vs LLAMA: H=absent, E=absent, L=correct(pos2? no) + // HELLO vs LEMON: H=absent, E=present, L=present, L=absent, O=present + // Let's use a clearer case: + // ALLAY vs LLAMA: + // A at 0: not L -> but A is in LLAMA. First pass: check exact matches + // pos0: A vs L -> no + // pos1: L vs L -> CORRECT + // pos2: L vs A -> no + // pos3: A vs M -> no + // pos4: Y vs A -> no + // Pool (unmatched answer letters): L(0), A(2), M(3), A(4) + // Second pass: + // pos0: A -> found A in pool, present (pool: L, M, A) + // pos2: L -> found L in pool, present (pool: M, A) + // pos3: A -> found A in pool, present (pool: M) + // pos4: Y -> not found, absent + results := scoreGuess("ALLAY", "LLAMA") + expected := []LetterResult{LetterPresent, LetterCorrect, LetterPresent, LetterPresent, LetterAbsent} + for i, r := range results { + if r != expected[i] { + t.Errorf("position %d: got %d, want %d", i, r, expected[i]) + } + } +} + +func TestUpdateLetterStates_Upgrades(t *testing.T) { + states := map[rune]LetterResult{} + + // First guess: A is absent + updateLetterStates(states, "A", []LetterResult{LetterAbsent}) + if states['A'] != LetterAbsent { + t.Errorf("A should be Absent, got %d", states['A']) + } + + // Second guess: A is present — should upgrade + updateLetterStates(states, "A", []LetterResult{LetterPresent}) + if states['A'] != LetterPresent { + t.Errorf("A should be Present after upgrade, got %d", states['A']) + } + + // Third guess: A is correct — should upgrade again + updateLetterStates(states, "A", []LetterResult{LetterCorrect}) + if states['A'] != LetterCorrect { + t.Errorf("A should be Correct after upgrade, got %d", states['A']) + } +} + +func TestUpdateLetterStates_NoDowngrade(t *testing.T) { + states := map[rune]LetterResult{ + 'A': LetterCorrect, + } + + // A was correct, now appears absent in a different position — should NOT downgrade + updateLetterStates(states, "A", []LetterResult{LetterAbsent}) + if states['A'] != LetterCorrect { + t.Errorf("A should remain Correct, got %d", states['A']) + } +} + +func TestWordleBasePots(t *testing.T) { + // Verify reward structure: fewer guesses = more money + for i := 1; i < len(wordleBasePots)-1; i++ { + if wordleBasePots[i] <= wordleBasePots[i+1] { + t.Errorf("pot for %d guesses (%d) should be > pot for %d guesses (%d)", + i, wordleBasePots[i], i+1, wordleBasePots[i+1]) + } + } +} diff --git a/internal/plugin/wordle_render.go b/internal/plugin/wordle_render.go index cc1d234..e03fec5 100644 --- a/internal/plugin/wordle_render.go +++ b/internal/plugin/wordle_render.go @@ -72,15 +72,20 @@ func renderKeyboard(states map[rune]LetterResult) string { } // renderWordleStartAnnouncement renders the puzzle start message. -func renderWordleStartAnnouncement(puzzleNumber, wordLength int) string { - return fmt.Sprintf( - "🟩 **Daily Wordle #%d**\nA new %d-letter puzzle is ready! Work together — 6 guesses shared.\n\nGuess with: `!wordle <word>`", +func renderWordleStartAnnouncement(puzzleNumber, wordLength int, hint string) string { + base := fmt.Sprintf( + "🟩 **Daily Wordle #%d**\nA new %d-letter puzzle is ready! Work together — 6 guesses shared.", puzzleNumber, wordLength, ) + if hint != "" { + base += fmt.Sprintf("\n🎮 **Hint:** %s", hint) + } + base += "\n\nGuess with: `!wordle <word>`" + return base } // renderSolvedAnnouncement renders the solved puzzle message. -func renderSolvedAnnouncement(puzzle *WordlePuzzle, definition string) string { +func renderSolvedAnnouncement(puzzle *WordlePuzzle, definition string, payouts []WordlePayout) string { var sb strings.Builder // Find the solver. @@ -107,18 +112,29 @@ func renderSolvedAnnouncement(puzzle *WordlePuzzle, definition string) string { sb.WriteString("\n") } - // Contributors. - sb.WriteString("\n🏆 Today's contributors:\n") - contributors := wordleContributors(puzzle) - for _, c := range contributors { - line := fmt.Sprintf(" %s — %d guess", c.name, c.guesses) - if c.guesses != 1 { - line += "es" + // Contributors with payouts. + if len(payouts) > 0 { + sb.WriteString("\n💰 **Payouts:**\n") + for _, pay := range payouts { + bonus := "" + if pay.Solver { + bonus = " 🏆 (solver bonus!)" + } + sb.WriteString(fmt.Sprintf(" **%s**: +€%d%s\n", pay.Name, pay.Amount, bonus)) } - if c.solved { - line += " 🏆" + } else { + sb.WriteString("\n🏆 Today's contributors:\n") + contributors := wordleContributors(puzzle) + for _, c := range contributors { + line := fmt.Sprintf(" %s — %d guess", c.name, c.guesses) + if c.guesses != 1 { + line += "es" + } + if c.solved { + line += " 🏆" + } + sb.WriteString(line + "\n") } - sb.WriteString(line + "\n") } return sb.String() diff --git a/internal/plugin/wotd.go b/internal/plugin/wotd.go index f57f40b..1cb2a15 100644 --- a/internal/plugin/wotd.go +++ b/internal/plugin/wotd.go @@ -67,7 +67,12 @@ func (p *WOTDPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *WOTDPlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "wotd") { - return p.handleWOTD(ctx) + go func() { + if err := p.handleWOTD(ctx); err != nil { + slog.Error("wotd: handler error", "err", err) + } + }() + return nil } // Passive: track WOTD usage in messages @@ -383,10 +388,7 @@ func (p *WOTDPlugin) grantWOTDXP(userID id.UserID, amount int) { } // Log XP grant - _, err = d.Exec( + db.Exec("wotd: log xp", `INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`, string(userID), amount, "wotd_usage") - if err != nil { - slog.Error("wotd: log xp", "err", err) - } } diff --git a/internal/plugin/xp.go b/internal/plugin/xp.go index 42e45cb..d42f966 100644 --- a/internal/plugin/xp.go +++ b/internal/plugin/xp.go @@ -144,12 +144,9 @@ func (p *XPPlugin) grantXPInternal(userID id.UserID, amount int, reason string) } // Log the XP grant - _, err = d.Exec( + db.Exec("xp: log grant", `INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`, string(userID), amount, reason) - if err != nil { - slog.Error("xp: log grant", "err", err) - } return newXP, leveledUp, newLevel } diff --git a/main.go b/main.go index c2f45d7..2ee2510 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "os/signal" "strings" "syscall" + "time" "gogobee/internal/bot" "gogobee/internal/db" @@ -99,6 +100,8 @@ func main() { registry.Register(plugin.NewLookupPlugin(client, ratePlugin)) registry.Register(plugin.NewCountdownPlugin(client)) registry.Register(plugin.NewStocksPlugin(client)) + forexPlugin := plugin.NewForexPlugin(client) + registry.Register(forexPlugin) concertsPlugin := plugin.NewConcertsPlugin(client, ratePlugin) registry.Register(concertsPlugin) animePlugin := plugin.NewAnimePlugin(client) @@ -115,7 +118,7 @@ func main() { registry.Register(plugin.NewUnoPlugin(client, euroPlugin)) registry.Register(plugin.NewHoldemPlugin(client, euroPlugin)) registry.Register(plugin.NewAdventurePlugin(client, euroPlugin)) - wordlePlugin := plugin.NewWordlePlugin(client) + wordlePlugin := plugin.NewWordlePlugin(client, euroPlugin) registry.Register(wordlePlugin) // Community @@ -172,6 +175,12 @@ func main() { // Auto-join on invite + moderation member tracking syncer.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) { + defer func() { + if rec := recover(); rec != nil { + slog.Error("member event handler panic", "panic", rec, "room", evt.RoomID) + } + }() + mem := evt.Content.AsMember() if mem == nil { return @@ -197,6 +206,13 @@ func main() { // Message handler syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) { + defer func() { + if rec := recover(); rec != nil { + slog.Error("message event handler panic", "panic", rec, + "sender", evt.Sender, "room", evt.RoomID) + } + }() + // Skip own messages if evt.Sender == client.UserID { return @@ -234,6 +250,13 @@ func main() { // Reaction handler syncer.OnEventType(event.EventReaction, func(ctx context.Context, evt *event.Event) { + defer func() { + if rec := recover(); rec != nil { + slog.Error("reaction event handler panic", "panic", rec, + "sender", evt.Sender, "room", evt.RoomID) + } + }() + if evt.Sender == client.UserID { return } @@ -256,8 +279,8 @@ func main() { }) // ---- Set up cron scheduler ---- - scheduler := cron.New() - setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin) + scheduler := cron.New(cron.WithChain(cron.Recover(cronLogger{}))) + setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin, forexPlugin) scheduler.Start() // ---- Start syncing ---- @@ -272,11 +295,26 @@ func main() { sig := <-sigCh slog.Info("shutting down", "signal", sig) scheduler.Stop() + client.StopSync() cancel() }() - if err := client.SyncWithContext(ctx); err != nil { - slog.Error("sync stopped", "err", err) +syncLoop: + for { + err := client.SyncWithContext(ctx) + if ctx.Err() != nil { + break // shutdown requested + } + if err != nil { + slog.Error("sync stopped, restarting in 5s", "err", err) + } else { + slog.Warn("sync returned without error, restarting in 5s") + } + select { + case <-time.After(5 * time.Second): + case <-ctx.Done(): + break syncLoop + } } slog.Info("GogoBee stopped") @@ -293,6 +331,7 @@ func setupScheduledJobs( movies *plugin.MoviesPlugin, concerts *plugin.ConcertsPlugin, esteemed *plugin.EsteemPlugin, + forex *plugin.ForexPlugin, ) { rooms := getRooms() @@ -370,6 +409,12 @@ func setupScheduledJobs( esteemed.PostWeekly() }) + // Forex daily poll at 17:01 UTC (ECB publishes ~16:00 CET) + c.AddFunc("1 17 * * *", func() { + slog.Info("scheduler: forex daily poll") + forex.DailyPoll() + }) + // Space groups refresh every hour c.AddFunc("0 * * * *", func() { slog.Info("scheduler: refreshing space groups") @@ -461,3 +506,15 @@ func envOr(key, fallback string) string { } return v } + +// cronLogger adapts slog to the cron.Logger interface for panic recovery logging. +type cronLogger struct{} + +func (cronLogger) Info(msg string, keysAndValues ...interface{}) { + slog.Info(msg, keysAndValues...) +} + +func (cronLogger) Error(err error, msg string, keysAndValues ...interface{}) { + args := append([]interface{}{"err", err}, keysAndValues...) + slog.Error(msg, args...) +}