mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
94
README.md
94
README.md
@@ -1,6 +1,6 @@
|
|||||||
# GogoBee
|
# 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.
|
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.
|
- **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.
|
- **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
|
- **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.
|
- **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
|
- **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
|
- **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
|
- **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
|
- **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.
|
- **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
|
- **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 |
|
| Variable | Service | Used By |
|
||||||
|----------|---------|---------|
|
|----------|---------|---------|
|
||||||
| `RAWG_API_KEY` | [RAWG](https://rawg.io/apidocs) | `!game`, `!retro`, `!releases` |
|
| `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 |
|
| `CALENDARIFIC_API_KEY` | [Calendarific](https://calendarific.com) | Holiday posts |
|
||||||
| `OPENWEATHER_API_KEY` | [OpenWeather](https://openweathermap.org/api) | `!weather` |
|
| `OPENWEATHER_API_KEY` | [OpenWeather](https://openweathermap.org/api) | `!weather` |
|
||||||
| `FINNHUB_API_KEY` | [Finnhub](https://finnhub.io) | `!stock` |
|
| `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_NPC_HOUSE_BALANCE` | `10000` | NPC starting bankroll |
|
||||||
| `HOLDEM_CFR_POLICY` | `data/policy.gob` | Path to CFR policy file |
|
| `HOLDEM_CFR_POLICY` | `data/policy.gob` | Path to CFR policy file |
|
||||||
| `WORDLE_DEFAULT_LENGTH` | `5` | Default word length (5, 6, or 7) |
|
| `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
|
### Moderation
|
||||||
|
|
||||||
@@ -271,6 +274,7 @@ Rep is earned when someone thanks you. The bot detects this automatically.
|
|||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `!stats [@user]` | Message statistics |
|
| `!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 |
|
| `!rankings [category]` | Rankings by words, links, questions, or emojis |
|
||||||
| `!personality` | Your community archetype |
|
| `!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 |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `!holdem join` | Sit down at the table |
|
| `!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 start` | Start dealing (2+ players) |
|
||||||
|
| `!holdem play` | Shortcut: join + start in one command |
|
||||||
| `!holdem addbot` | Add a CFR-trained AI opponent |
|
| `!holdem addbot` | Add a CFR-trained AI opponent |
|
||||||
| `!holdem fold` | Fold your hand |
|
| `!holdem fold` | Fold your hand |
|
||||||
| `!holdem check` | Check (no bet to call) |
|
| `!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).
|
**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
|
#### 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
|
#### Training the NPC
|
||||||
|
|
||||||
@@ -427,7 +436,7 @@ Progress is logged with overall completion percentage and ETA. Checkpoints are s
|
|||||||
|
|
||||||
### Wordle (games channel only)
|
### 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 |
|
| 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.
|
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 <item>` | Buy equipment by name or tier shorthand (`3 sword`, `t4 boots`) |
|
||||||
|
| `!adventure sell <item>` | 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 <choice>` | 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
|
### Reminders
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
@@ -476,14 +526,23 @@ No economy integration — stats and leaderboard position are the reward.
|
|||||||
| `!calc <expression>` | Calculator, understands "5 plus 3" |
|
| `!calc <expression>` | Calculator, understands "5 plus 3" |
|
||||||
| `!qr <text>` | Generate QR code |
|
| `!qr <text>` | Generate QR code |
|
||||||
|
|
||||||
### Games & Entertainment
|
### Finance
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `!stock <ticker>` | 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 <currency> <rate>` | DM alert when rate hits threshold |
|
||||||
|
| `!fx alerts` | List your active alerts |
|
||||||
|
| `!fx delalert <currency> <rate>` | Remove an alert |
|
||||||
|
|
||||||
|
### Entertainment
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `!game <query>` / `!retro <query>` | Game lookup (RAWG) |
|
| `!game <query>` / `!retro <query>` | Game lookup (RAWG) |
|
||||||
| `!releases [month\|search <q>]` | Game releases |
|
| `!releases [month\|search <q>]` | Game releases |
|
||||||
| `!releasewatch add\|list\|remove` | Release watchlist |
|
| `!releasewatch add\|list\|remove` | Release watchlist |
|
||||||
| `!stock <ticker>` | Stock quote (Finnhub) |
|
|
||||||
| `!stockwatch add\|list\|remove` | Stock watchlist |
|
|
||||||
| `!concerts <artist>` | Concert search |
|
| `!concerts <artist>` | Concert search |
|
||||||
| `!concerts watch\|watching\|unwatch` | Concert watchlist |
|
| `!concerts watch\|watching\|unwatch` | Concert watchlist |
|
||||||
| `!anime <title>` | Anime search (MAL) |
|
| `!anime <title>` | 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 |
|
| [Aladhan](https://aladhan.com/prayer-times-api) | Yes, no key | Islamic dates |
|
||||||
| [OpenWeather](https://openweathermap.org/api) | Yes (1k/day) | Weather |
|
| [OpenWeather](https://openweathermap.org/api) | Yes (1k/day) | Weather |
|
||||||
| [Finnhub](https://finnhub.io) | Yes | Stock quotes |
|
| [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 |
|
| [Bandsintown](https://artists.bandsintown.com) | Yes | Concert data |
|
||||||
| [Jikan/MAL](https://jikan.moe) | Yes, no key | Anime data |
|
| [Jikan/MAL](https://jikan.moe) | Yes, no key | Anime data |
|
||||||
| [TMDB](https://www.themoviedb.org) | Yes | Movie/TV data |
|
| [TMDB](https://www.themoviedb.org) | Yes | Movie/TV data |
|
||||||
@@ -716,7 +776,8 @@ gogobee/
|
|||||||
├── main.go # Entry point, plugin registration, cron setup
|
├── main.go # Entry point, plugin registration, cron setup
|
||||||
├── go.mod / go.sum
|
├── go.mod / go.sum
|
||||||
├── data/
|
├── 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/
|
├── cmd/
|
||||||
│ ├── holdem-train/
|
│ ├── holdem-train/
|
||||||
│ │ └── main.go # CFR training CLI (build tag: training)
|
│ │ └── main.go # CFR training CLI (build tag: training)
|
||||||
@@ -756,6 +817,7 @@ gogobee/
|
|||||||
│ │ ├── lookup.go # Wiki, dictionary, urban, translate
|
│ │ ├── lookup.go # Wiki, dictionary, urban, translate
|
||||||
│ │ ├── countdown.go # Countdowns
|
│ │ ├── countdown.go # Countdowns
|
||||||
│ │ ├── stocks.go # Stocks
|
│ │ ├── stocks.go # Stocks
|
||||||
|
│ │ ├── forex*.go # Forex rates & alerts (Frankfurter v2)
|
||||||
│ │ ├── concerts.go # Concerts
|
│ │ ├── concerts.go # Concerts
|
||||||
│ │ ├── anime.go # Anime
|
│ │ ├── anime.go # Anime
|
||||||
│ │ ├── movies.go # Movies/TV
|
│ │ ├── movies.go # Movies/TV
|
||||||
@@ -787,6 +849,16 @@ gogobee/
|
|||||||
│ │ ├── wordle_render.go # Emoji grid, keyboard, announcements, leaderboard
|
│ │ ├── wordle_render.go # Emoji grid, keyboard, announcements, leaderboard
|
||||||
│ │ ├── wordle_wordnik.go # Wordnik API (random word, validation, definitions)
|
│ │ ├── wordle_wordnik.go # Wordnik API (random word, validation, definitions)
|
||||||
│ │ ├── wordle_fallback.go # Emergency word list loader
|
│ │ ├── 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
|
│ │ ├── esteemed.go # Satirical esteemed member posts
|
||||||
│ │ ├── moderation.go # Moderation system (strikes, word list, flood detection)
|
│ │ ├── moderation.go # Moderation system (strikes, word list, flood detection)
|
||||||
│ │ └── ratelimits.go # Rate limiting
|
│ │ └── ratelimits.go # Rate limiting
|
||||||
@@ -812,7 +884,7 @@ gogobee/
|
|||||||
|
|
||||||
Single SQLite file at `$DATA_DIR/gogobee.db`. Schema auto-creates on first run. WAL mode enabled.
|
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
|
### Backup
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ func (r *Registry) Init() error {
|
|||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
for _, p := range r.plugins {
|
for _, p := range r.plugins {
|
||||||
|
slog.Info("initializing plugin", "name", p.Name())
|
||||||
if err := p.Init(); err != nil {
|
if err := p.Init(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
slog.Info("initialized plugin", "name", p.Name())
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -43,12 +45,27 @@ func (r *Registry) DispatchMessage(ctx plugin.MessageContext) {
|
|||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
for _, p := range r.plugins {
|
for _, p := range r.plugins {
|
||||||
if err := p.OnMessage(ctx); err != nil {
|
r.safeOnMessage(p, ctx)
|
||||||
slog.Error("plugin message handler error",
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
"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()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
for _, p := range r.plugins {
|
for _, p := range r.plugins {
|
||||||
if err := p.OnReaction(ctx); err != nil {
|
r.safeOnReaction(p, ctx)
|
||||||
slog.Error("plugin reaction handler error",
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
"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
|
return cmds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "…"
|
||||||
|
}
|
||||||
|
|
||||||
// GetPlugin returns a plugin by name.
|
// GetPlugin returns a plugin by name.
|
||||||
func (r *Registry) GetPlugin(name string) plugin.Plugin {
|
func (r *Registry) GetPlugin(name string) plugin.Plugin {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
|
|||||||
@@ -73,14 +73,11 @@ func JobCompleted(jobName, dateKey string) bool {
|
|||||||
|
|
||||||
// MarkJobCompleted marks a scheduled job as completed for the given date key.
|
// MarkJobCompleted marks a scheduled job as completed for the given date key.
|
||||||
func MarkJobCompleted(jobName, dateKey string) {
|
func MarkJobCompleted(jobName, dateKey string) {
|
||||||
_, err := Get().Exec(
|
Exec("mark job completed",
|
||||||
`INSERT INTO daily_prefetch (job_name, date, completed) VALUES (?, ?, 1)
|
`INSERT INTO daily_prefetch (job_name, date, completed) VALUES (?, ?, 1)
|
||||||
ON CONFLICT(job_name, date) DO UPDATE SET completed = 1`,
|
ON CONFLICT(job_name, date) DO UPDATE SET completed = 1`,
|
||||||
jobName, dateKey,
|
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.
|
// 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.
|
// CacheSet stores data in the generic API cache.
|
||||||
func CacheSet(key, data string) {
|
func CacheSet(key, data string) {
|
||||||
d := Get()
|
Exec("cache set",
|
||||||
_, err := d.Exec(
|
|
||||||
`INSERT INTO api_cache (cache_key, data, cached_at) VALUES (?, ?, unixepoch())
|
`INSERT INTO api_cache (cache_key, data, cached_at) VALUES (?, ?, unixepoch())
|
||||||
ON CONFLICT(cache_key) DO UPDATE SET data = ?, cached_at = unixepoch()`,
|
ON CONFLICT(cache_key) DO UPDATE SET data = ?, cached_at = unixepoch()`,
|
||||||
key, data, data,
|
key, data, data,
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
slog.Error("cache set", "key", key, "err", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunMaintenance purges stale data from cache tables, old rate limits,
|
// RunMaintenance purges stale data from cache tables, old rate limits,
|
||||||
@@ -152,6 +145,9 @@ func RunMaintenance() {
|
|||||||
|
|
||||||
// Daily activity older than 1 year
|
// Daily activity older than 1 year
|
||||||
{"daily_activity", `DELETE FROM daily_activity WHERE date < ?`, []interface{}{now.AddDate(-1, 0, 0).Format("2006-01-02")}},
|
{"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)
|
totalDeleted := int64(0)
|
||||||
@@ -176,6 +172,25 @@ func RunMaintenance() {
|
|||||||
slog.Info("maintenance: complete", "total_purged", totalDeleted)
|
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 = `
|
const schema = `
|
||||||
-- Users & XP
|
-- Users & XP
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
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
|
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.
|
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||||
|
|||||||
@@ -453,7 +453,22 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// statGTE checks if a user_stats column is >= threshold.
|
// 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 {
|
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
|
var val int
|
||||||
query := fmt.Sprintf(`SELECT COALESCE(%s, 0) FROM user_stats WHERE user_id = ?`, column)
|
query := fmt.Sprintf(`SELECT COALESCE(%s, 0) FROM user_stats WHERE user_id = ?`, column)
|
||||||
err := d.QueryRow(query, string(userID)).Scan(&val)
|
err := d.QueryRow(query, string(userID)).Scan(&val)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
@@ -20,9 +19,10 @@ type AdventurePlugin struct {
|
|||||||
Base
|
Base
|
||||||
euro *EuroPlugin
|
euro *EuroPlugin
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
dmToPlayer map[id.RoomID]id.UserID
|
dmToPlayer map[id.RoomID]id.UserID
|
||||||
pending sync.Map // userID string -> *advPendingInteraction
|
pending sync.Map // userID string -> *advPendingInteraction
|
||||||
userLocks sync.Map // userID string -> *sync.Mutex
|
userLocks sync.Map // userID string -> *sync.Mutex
|
||||||
|
dmRemindedDate sync.Map // userID string -> "2006-01-02" date string
|
||||||
morningHour int
|
morningHour int
|
||||||
summaryHour int
|
summaryHour int
|
||||||
}
|
}
|
||||||
@@ -78,6 +78,7 @@ func (p *AdventurePlugin) Init() error {
|
|||||||
go p.morningTicker()
|
go p.morningTicker()
|
||||||
go p.summaryTicker()
|
go p.summaryTicker()
|
||||||
go p.midnightTicker()
|
go p.midnightTicker()
|
||||||
|
go p.eventTicker()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -101,6 +102,10 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return p.dispatchCommand(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
||||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "adventure"))
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "adventure"))
|
||||||
lower := strings.ToLower(args)
|
lower := strings.ToLower(args)
|
||||||
|
|
||||||
@@ -111,8 +116,8 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
|||||||
return p.handleStatus(ctx)
|
return p.handleStatus(ctx)
|
||||||
case strings.HasPrefix(lower, "sell "):
|
case strings.HasPrefix(lower, "sell "):
|
||||||
return p.handleSellCmd(ctx, strings.TrimSpace(args[5:]))
|
return p.handleSellCmd(ctx, strings.TrimSpace(args[5:]))
|
||||||
case lower == "shop":
|
case lower == "shop" || strings.HasPrefix(lower, "shop "):
|
||||||
return p.handleShopCmd(ctx)
|
return p.handleShopCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "shop")))
|
||||||
case strings.HasPrefix(lower, "buy "):
|
case strings.HasPrefix(lower, "buy "):
|
||||||
return p.handleBuyCmd(ctx, strings.TrimSpace(args[4:]))
|
return p.handleBuyCmd(ctx, strings.TrimSpace(args[4:]))
|
||||||
case lower == "inventory" || lower == "inv":
|
case lower == "inventory" || lower == "inv":
|
||||||
@@ -123,11 +128,30 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
|||||||
return p.handleAdminRevive(ctx, strings.TrimSpace(args[7:]))
|
return p.handleAdminRevive(ctx, strings.TrimSpace(args[7:]))
|
||||||
case lower == "summary":
|
case lower == "summary":
|
||||||
return p.handleAdminSummary(ctx)
|
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 ─────────────────────────────────────────────────────────
|
// ── Command Handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
||||||
@@ -142,7 +166,16 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if char.ActionTakenToday {
|
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)
|
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||||
@@ -169,22 +202,34 @@ func (p *AdventurePlugin) handleStatus(ctx MessageContext) error {
|
|||||||
return p.SendDM(ctx.Sender, text)
|
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)
|
_, equip, err := p.ensureCharacter(ctx.Sender)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
|
||||||
}
|
}
|
||||||
|
|
||||||
balance := p.euro.GetBalance(ctx.Sender)
|
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 {
|
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 {
|
if err != nil {
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
|
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)
|
slot, def, found := advFindShopItem(itemName)
|
||||||
if !found {
|
if !found {
|
||||||
@@ -196,7 +241,13 @@ func (p *AdventurePlugin) handleBuyCmd(ctx MessageContext, itemName string) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *AdventurePlugin) handleSellCmd(ctx MessageContext, args string) error {
|
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
|
var result string
|
||||||
if strings.ToLower(args) == "all" {
|
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 {
|
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)
|
text := advInventoryDisplay(ctx.Sender)
|
||||||
return p.SendDM(ctx.Sender, text)
|
return p.SendDM(ctx.Sender, text)
|
||||||
}
|
}
|
||||||
@@ -270,10 +323,9 @@ func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip !adventure prefix if present
|
// Strip !adventure prefix if present — dispatch directly to avoid recursion
|
||||||
if strings.HasPrefix(strings.ToLower(body), "!adventure") {
|
if strings.HasPrefix(strings.ToLower(body), "!adventure") {
|
||||||
// Re-dispatch as command
|
return p.dispatchCommand(ctx)
|
||||||
return p.OnMessage(ctx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for pending interaction first
|
// Check for pending interaction first
|
||||||
@@ -346,7 +398,23 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if char.ActionTakenToday {
|
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)
|
lower := strings.ToLower(body)
|
||||||
@@ -360,7 +428,7 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string)
|
|||||||
if lower == "4" || lower == "shop" {
|
if lower == "4" || lower == "shop" {
|
||||||
equip, _ := loadAdvEquipment(ctx.Sender)
|
equip, _ := loadAdvEquipment(ctx.Sender)
|
||||||
balance := p.euro.GetBalance(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
|
// Parse activity + location
|
||||||
@@ -486,21 +554,9 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
|||||||
_ = addAdvInventoryItem(char.UserID, item)
|
_ = addAdvInventoryItem(char.UserID, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Party bonus: check if someone else visited the same location today
|
// Mark action taken and record the date for streak tracking
|
||||||
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
|
|
||||||
char.ActionTakenToday = true
|
char.ActionTakenToday = true
|
||||||
|
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
// Update streak info
|
// Update streak info
|
||||||
result.StreakBonus = char.CurrentStreak
|
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),
|
logAdvActivity(char.UserID, string(activity), loc.Name, string(result.Outcome),
|
||||||
result.TotalLootValue, result.XPGained, result.FlavorKey)
|
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)
|
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 {
|
if err := p.SendDM(ctx.Sender, text); err != nil {
|
||||||
slog.Error("adventure: failed to send resolution DM", "user", ctx.Sender, "err", err)
|
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 {
|
func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharacter) error {
|
||||||
char.ActionTakenToday = true
|
char.ActionTakenToday = true
|
||||||
|
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
|
||||||
if err := saveAdvCharacter(char); err != nil {
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Failed to save. Even resting is broken.")
|
return p.SendDM(ctx.Sender, "Failed to save. Even resting is broken.")
|
||||||
}
|
}
|
||||||
|
|
||||||
logAdvActivity(char.UserID, string(AdvActivityRest), "", "rest", 0, 0, "")
|
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(
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
"%s, you chose rest. No loot. No XP. No death.\n\n"+
|
"%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. "+
|
"You sat in your hovel and stared at the wall and achieved absolutely nothing. "+
|
||||||
"Tomorrow awaits. It will probably be the same.",
|
"Tomorrow awaits. It will probably be the same.\n\n"+
|
||||||
char.DisplayName))
|
"─────────────────────────────\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 ─────────────────────────────────────────────────────
|
// ── Treasure Drop Check ─────────────────────────────────────────────────────
|
||||||
@@ -739,7 +824,7 @@ func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter
|
|||||||
char, err := loadAdvCharacter(userID)
|
char, err := loadAdvCharacter(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Auto-create
|
// Auto-create
|
||||||
displayName := p.displayName(userID)
|
displayName := p.DisplayName(userID)
|
||||||
if err := createAdvCharacter(userID, displayName); err != nil {
|
if err := createAdvCharacter(userID, displayName); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -764,15 +849,3 @@ func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter
|
|||||||
return char, equip, nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -317,11 +317,14 @@ func advIsEligible(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipme
|
|||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check minimum equipment tier
|
// Check minimum equipment tier — no equipment means tier 0
|
||||||
minTier := 99
|
minTier := 0
|
||||||
for _, eq := range equip {
|
if len(equip) > 0 {
|
||||||
if eq.Tier < minTier {
|
minTier = 99
|
||||||
minTier = eq.Tier
|
for _, eq := range equip {
|
||||||
|
if eq.Tier < minTier {
|
||||||
|
minTier = eq.Tier
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if minTier < loc.MinEquipTier {
|
if minTier < loc.MinEquipTier {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package plugin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"log/slog"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
@@ -251,12 +250,12 @@ func createAdvCharacter(userID id.UserID, displayName string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
_, err = tx.Exec(`
|
_, err = tx.Exec(`
|
||||||
INSERT INTO adventure_characters (user_id, display_name)
|
INSERT INTO adventure_characters (user_id, display_name)
|
||||||
VALUES (?, ?)`, string(userID), displayName)
|
VALUES (?, ?)`, string(userID), displayName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return err
|
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)
|
INSERT INTO adventure_equipment (user_id, slot, tier, condition, name, actions_used)
|
||||||
VALUES (?, ?, 0, 100, ?, 0)`, string(userID), string(slot), def.Name)
|
VALUES (?, ?, 0, 100, ?, 0)`, string(userID), string(slot), def.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -440,19 +438,18 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
|||||||
|
|
||||||
func resetAllAdvDailyActions() error {
|
func resetAllAdvDailyActions() error {
|
||||||
d := db.Get()
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func logAdvActivity(userID id.UserID, activityType, location, outcome string, lootValue int64, xpGained int, flavorKey string) {
|
func logAdvActivity(userID id.UserID, activityType, location, outcome string, lootValue int64, xpGained int, flavorKey string) {
|
||||||
d := db.Get()
|
db.Exec("adventure: log activity",
|
||||||
_, err := d.Exec(`
|
`INSERT INTO adventure_activity_log (user_id, activity_type, location, outcome, loot_value, xp_gained, flavor_key)
|
||||||
INSERT INTO adventure_activity_log (user_id, activity_type, location, outcome, loot_value, xp_gained, flavor_key)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
string(userID), activityType, location, outcome, lootValue, xpGained, flavorKey)
|
string(userID), activityType, location, outcome, lootValue, xpGained, flavorKey)
|
||||||
if err != nil {
|
|
||||||
slog.Error("adventure: failed to log activity", "user", userID, "err", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Buff CRUD ────────────────────────────────────────────────────────────────
|
// ── Buff CRUD ────────────────────────────────────────────────────────────────
|
||||||
@@ -511,8 +508,8 @@ func loadAdvTodayLogs() ([]AdvDayLog, error) {
|
|||||||
rows, err := d.Query(`
|
rows, err := d.Query(`
|
||||||
SELECT user_id, activity_type, COALESCE(location,''), outcome, loot_value, xp_gained
|
SELECT user_id, activity_type, COALESCE(location,''), outcome, loot_value, xp_gained
|
||||||
FROM adventure_activity_log
|
FROM adventure_activity_log
|
||||||
WHERE DATE(logged_at) = ?
|
WHERE logged_at >= ? AND logged_at < DATE(?, '+1 day')
|
||||||
ORDER BY logged_at`, today)
|
ORDER BY logged_at`, today, today)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
355
internal/plugin/adventure_events.go
Normal file
355
internal/plugin/adventure_events.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
204
internal/plugin/adventure_flavor_closing.go
Normal file
204
internal/plugin/adventure_flavor_closing.go
Normal file
@@ -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.",
|
||||||
|
}
|
||||||
854
internal/plugin/adventure_flavor_events.go
Normal file
854
internal/plugin/adventure_flavor_events.go
Normal file
@@ -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})"
|
||||||
@@ -377,6 +377,7 @@ var SummaryMiningSuccess = []string{
|
|||||||
"Mining run at {location}: {ore} recovered, €{value}, no cave-ins.",
|
"Mining run at {location}: {ore} recovered, €{value}, no cave-ins.",
|
||||||
"Came back from {location} with {ore} and €{value} and a worse {tool}.",
|
"Came back from {location} with {ore} and €{value} and a worse {tool}.",
|
||||||
"{ore} from {location}. €{value}. The back is filing a complaint.",
|
"{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{
|
var SummaryMiningDeath = []string{
|
||||||
@@ -400,6 +401,14 @@ var SummaryForagingSuccess = []string{
|
|||||||
"Returned from {location} with {item} worth €{value} and zero injuries. Good day.",
|
"Returned from {location} with {item} worth €{value} and zero injuries. Good day.",
|
||||||
"Clean haul from {location}. {item}, €{value}. The forest cooperated.",
|
"Clean haul from {location}. {item}, €{value}. The forest cooperated.",
|
||||||
"{item} from {location}. €{value}. One bear made eye contact. Moved on.",
|
"{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{
|
var SummaryForagingDeath = []string{
|
||||||
|
|||||||
@@ -454,7 +454,7 @@ var DeathDM = []string{
|
|||||||
"Neither will be comfortable. This is healthcare.\n\n" +
|
"Neither will be comfortable. This is healthcare.\n\n" +
|
||||||
"Your equipment has been assessed. The assessment was not kind.\n" +
|
"Your equipment has been assessed. The assessment was not kind.\n" +
|
||||||
"The assessment was accurate. Return expected: {time} UTC.\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.'",
|
"You are in the record as 'visited once, briefly.'",
|
||||||
|
|
||||||
"📋 ADVENTURER STATUS: INDISPOSED\n\n" +
|
"📋 ADVENTURER STATUS: INDISPOSED\n\n" +
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ func advClearFlavorHistory() {
|
|||||||
func advSubstituteFlavor(template string, vars map[string]string) string {
|
func advSubstituteFlavor(template string, vars map[string]string) string {
|
||||||
pairs := make([]string, 0, len(vars)*2)
|
pairs := make([]string, 0, len(vars)*2)
|
||||||
for k, v := range vars {
|
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)
|
pairs = append(pairs, k, v)
|
||||||
}
|
}
|
||||||
return strings.NewReplacer(pairs...).Replace(template)
|
return strings.NewReplacer(pairs...).Replace(template)
|
||||||
@@ -114,9 +122,6 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
|
|||||||
// Equipment
|
// Equipment
|
||||||
sb.WriteString("\n🛡️ Equipment:\n")
|
sb.WriteString("\n🛡️ Equipment:\n")
|
||||||
eqScore := advEquipmentScore(equip)
|
eqScore := advEquipmentScore(equip)
|
||||||
slotEmoji := map[EquipmentSlot]string{
|
|
||||||
SlotWeapon: "⚔️", SlotArmor: "🛡️", SlotHelmet: "🪖", SlotBoots: "👢", SlotTool: "⛏️",
|
|
||||||
}
|
|
||||||
for _, slot := range allSlots {
|
for _, slot := range allSlots {
|
||||||
eq := equip[slot]
|
eq := equip[slot]
|
||||||
mastery := ""
|
mastery := ""
|
||||||
@@ -125,7 +130,7 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
|
|||||||
}
|
}
|
||||||
if eq != nil {
|
if eq != nil {
|
||||||
sb.WriteString(fmt.Sprintf(" %s %s: %s (Tier %d | %d%% condition%s)\n",
|
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))
|
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))
|
sb.WriteString(fmt.Sprintf("✨ +%d %s XP", result.XPGained, result.XPSkill))
|
||||||
if result.LeveledUp {
|
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")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
@@ -290,7 +295,7 @@ func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) st
|
|||||||
sb.WriteString("🔧 Equipment damage:\n")
|
sb.WriteString("🔧 Equipment damage:\n")
|
||||||
for slot, dmg := range result.EquipDamage {
|
for slot, dmg := range result.EquipDamage {
|
||||||
if dmg > 0 {
|
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()
|
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 ──────────────────────────────────────────────────────────
|
// ── Death Status DM ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func renderAdvDeathStatusDM(char *AdventureCharacter) string {
|
func renderAdvDeathStatusDM(char *AdventureCharacter) string {
|
||||||
@@ -327,9 +377,14 @@ func renderAdvDeathStatusDM(char *AdventureCharacter) string {
|
|||||||
if char.DeadUntil != nil {
|
if char.DeadUntil != nil {
|
||||||
remaining = char.DeadUntil.Format("15:04")
|
remaining = char.DeadUntil.Format("15:04")
|
||||||
}
|
}
|
||||||
|
location := char.GrudgeLocation
|
||||||
|
if location == "" {
|
||||||
|
location = "an unknown location"
|
||||||
|
}
|
||||||
return advSubstituteFlavor(text, map[string]string{
|
return advSubstituteFlavor(text, map[string]string{
|
||||||
"{name}": char.DisplayName,
|
"{name}": char.DisplayName,
|
||||||
"{time}": remaining,
|
"{time}": remaining,
|
||||||
|
"{location}": location,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,9 +470,9 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
|||||||
|
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
if tbRewards.Eligible > 0 {
|
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 {
|
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 {
|
if tbRewards.GiftCount > 0 {
|
||||||
sb.WriteString(fmt.Sprintf(" ⭐ %d players received a gift item\n", tbRewards.GiftCount))
|
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 ──────────────────────────────────────────────────
|
// ── Treasure Discard Prompt ──────────────────────────────────────────────────
|
||||||
|
|
||||||
func renderAdvTreasureDiscardPrompt(newTreasure *AdvTreasureDef, existing []AdvTreasureDef) string {
|
func renderAdvTreasureDiscardPrompt(newTreasure *AdvTreasureDef, existing []AdvTreasureDef) string {
|
||||||
// Pick from treasure inventory cap pool
|
if len(TreasureInventoryCap) == 0 {
|
||||||
var sb strings.Builder
|
return "You found a treasure but your inventory is full. Reply 1, 2, or 3 to discard, or `keep`."
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteString("Current treasures:\n")
|
// Build substitution map with existing treasure info
|
||||||
for i, t := range existing {
|
subs := map[string]string{
|
||||||
sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, t.InventoryDesc))
|
"{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 ───────────────────────────────────────────────────────
|
// ── Summary One-Liners ───────────────────────────────────────────────────────
|
||||||
@@ -624,7 +681,7 @@ func advSummaryOneLiner(userID id.UserID, activity AdvActivityType, outcome AdvO
|
|||||||
case AdvOutcomeRiver:
|
case AdvOutcomeRiver:
|
||||||
pool = SummaryForagingRiver
|
pool = SummaryForagingRiver
|
||||||
case AdvOutcomeEmpty:
|
case AdvOutcomeEmpty:
|
||||||
pool = SummaryForagingSuccess // no specific empty pool for foraging
|
pool = SummaryForagingEmpty
|
||||||
case AdvOutcomeSuccess, AdvOutcomeExceptional:
|
case AdvOutcomeSuccess, AdvOutcomeExceptional:
|
||||||
pool = SummaryForagingSuccess
|
pool = SummaryForagingSuccess
|
||||||
}
|
}
|
||||||
@@ -640,5 +697,9 @@ func advSummaryOneLiner(userID id.UserID, activity AdvActivityType, outcome AdvO
|
|||||||
"{item}": "",
|
"{item}": "",
|
||||||
"{value}": fmt.Sprintf("%d", lootValue),
|
"{value}": fmt.Sprintf("%d", lootValue),
|
||||||
"{location}": location,
|
"{location}": location,
|
||||||
|
"{hours}": "24",
|
||||||
|
"{ore}": "",
|
||||||
|
"{tool}": "",
|
||||||
|
"{xp}": "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,16 +266,17 @@ func (p *AdventurePlugin) midnightReset() {
|
|||||||
_ = saveAdvCharacter(&char)
|
_ = saveAdvCharacter(&char)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Update streak
|
// Update streak — LastActionDate was set at action time
|
||||||
if char.LastActionDate == time.Now().UTC().Add(-24*time.Hour).Format("2006-01-02") {
|
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||||
|
if char.LastActionDate == yesterday || char.LastActionDate == today {
|
||||||
char.CurrentStreak++
|
char.CurrentStreak++
|
||||||
} else if char.LastActionDate != today {
|
} else {
|
||||||
|
// Gap in activity — start fresh
|
||||||
char.CurrentStreak = 1
|
char.CurrentStreak = 1
|
||||||
}
|
}
|
||||||
if char.CurrentStreak > char.BestStreak {
|
if char.CurrentStreak > char.BestStreak {
|
||||||
char.BestStreak = char.CurrentStreak
|
char.BestStreak = char.CurrentStreak
|
||||||
}
|
}
|
||||||
char.LastActionDate = today
|
|
||||||
_ = saveAdvCharacter(&char)
|
_ = saveAdvCharacter(&char)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,6 +294,12 @@ func (p *AdventurePlugin) midnightReset() {
|
|||||||
// Clear flavor history to prevent unbounded memory growth.
|
// Clear flavor history to prevent unbounded memory growth.
|
||||||
// Entries are only used for dedup within a day, so clearing at midnight is fine.
|
// Entries are only used for dedup within a day, so clearing at midnight is fine.
|
||||||
advClearFlavorHistory()
|
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 ───────────────────────────────────────────────────────────────────
|
// ── Helper ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -9,67 +9,164 @@ import (
|
|||||||
|
|
||||||
// ── Shop Listings ────────────────────────────────────────────────────────────
|
// ── 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
|
var sb strings.Builder
|
||||||
sb.WriteString("🛒 **Equipment Shop**\n")
|
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 {
|
for _, slot := range allSlots {
|
||||||
current := equip[slot]
|
current := equip[slot]
|
||||||
currentTier := 0
|
currentTier := 0
|
||||||
if current != nil {
|
|
||||||
currentTier = current.Tier
|
|
||||||
}
|
|
||||||
|
|
||||||
defs := equipmentTiers[slot]
|
|
||||||
currentName := "None"
|
currentName := "None"
|
||||||
if current != nil {
|
if current != nil {
|
||||||
|
currentTier = current.Tier
|
||||||
currentName = current.Name
|
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 {
|
for _, def := range defs {
|
||||||
if def.Tier <= currentTier || def.Price == 0 {
|
if def.Tier > currentTier && def.Price > 0 {
|
||||||
continue
|
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("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteString("To buy: reply with `buy <item name>`\n")
|
sb.WriteString("Browse a category: `!adventure shop <category>`\n")
|
||||||
sb.WriteString("Example: `buy Sad Iron Sword`")
|
sb.WriteString("Categories: `weapon` · `armor` · `helmet` · `boots` · `tool`")
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func advTruncate(s string, n int) string {
|
var shopCategoryAliases = map[string]EquipmentSlot{
|
||||||
if len(s) <= n {
|
"weapon": SlotWeapon, "weapons": SlotWeapon, "sword": SlotWeapon, "swords": SlotWeapon,
|
||||||
return s
|
"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 ───────────────────────────────────────────────────────────
|
// ── 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) {
|
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 _, slot := range allSlots {
|
||||||
for i := range equipmentTiers[slot] {
|
for i := range equipmentTiers[slot] {
|
||||||
def := &equipmentTiers[slot][i]
|
def := &equipmentTiers[slot][i]
|
||||||
if def.Price == 0 {
|
if def.Price == 0 {
|
||||||
continue // can't buy tier 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
|
return slot, def, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,6 +174,45 @@ func advFindShopItem(name string) (EquipmentSlot, *EquipmentDef, bool) {
|
|||||||
return "", nil, false
|
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 ────────────────────────────────────────────────────────────
|
// ── Buy Equipment ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot, def *EquipmentDef, equip map[EquipmentSlot]*AdvEquipment) string {
|
func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot, def *EquipmentDef, equip map[EquipmentSlot]*AdvEquipment) string {
|
||||||
|
|||||||
302
internal/plugin/adventure_test.go
Normal file
302
internal/plugin/adventure_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -281,6 +281,7 @@ func advSaveTreasure(userID id.UserID, def *AdvTreasureDef) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
for _, bonus := range def.Bonuses {
|
for _, bonus := range def.Bonuses {
|
||||||
_, err := tx.Exec(`
|
_, err := tx.Exec(`
|
||||||
@@ -288,7 +289,6 @@ func advSaveTreasure(userID id.UserID, def *AdvTreasureDef) error {
|
|||||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
string(userID), def.Key, def.Name, def.Tier, bonus.Type, bonus.Value)
|
string(userID), def.Key, def.Name, def.Tier, bonus.Type, bonus.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,10 +44,57 @@ var twinBeeWeights = []twinBeeActionWeight{
|
|||||||
{AdvActivityForaging, 3, 0.15},
|
{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) {
|
func selectTwinBeeAction() (AdvActivityType, *AdvLocation) {
|
||||||
roll := rand.Float64()
|
maxTier := twinBeeMaxTier()
|
||||||
cumulative := 0.0
|
|
||||||
|
// Filter weights to only include tiers within range.
|
||||||
|
var filtered []twinBeeActionWeight
|
||||||
|
var totalWeight float64
|
||||||
for _, w := range twinBeeWeights {
|
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
|
cumulative += w.Weight
|
||||||
if roll < cumulative {
|
if roll < cumulative {
|
||||||
loc := findAdvLocationByTier(w.Activity, w.Tier)
|
loc := findAdvLocationByTier(w.Activity, w.Tier)
|
||||||
@@ -55,8 +102,8 @@ func selectTwinBeeAction() (AdvActivityType, *AdvLocation) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback
|
// Fallback
|
||||||
loc := findAdvLocationByTier(AdvActivityDungeon, 3)
|
loc := findAdvLocationByTier(filtered[0].Activity, filtered[0].Tier)
|
||||||
return AdvActivityDungeon, loc
|
return filtered[0].Activity, loc
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── TwinBee Result ───────────────────────────────────────────────────────────
|
// ── TwinBee Result ───────────────────────────────────────────────────────────
|
||||||
@@ -77,8 +124,15 @@ func (p *AdventurePlugin) runTwinBeeDaily() *TwinBeeResult {
|
|||||||
return nil
|
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
|
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
|
// TwinBee never dies — reroll death to empty
|
||||||
if result.Outcome == AdvOutcomeDeath {
|
if result.Outcome == AdvOutcomeDeath {
|
||||||
@@ -201,19 +255,48 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
|
|||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
// Distribute gold
|
// Distribute gold weighted by combined adventure level.
|
||||||
share := result.LootValue / int64(len(eligible))
|
// Higher-level players get a proportionally larger share.
|
||||||
if share < 1 {
|
type eligiblePlayer struct {
|
||||||
share = 1
|
uid id.UserID
|
||||||
|
weight int
|
||||||
}
|
}
|
||||||
summary.GoldShare = share
|
var players []eligiblePlayer
|
||||||
|
totalWeight := 0
|
||||||
for _, uid := range eligible {
|
for _, uid := range eligible {
|
||||||
p.euro.Credit(uid, float64(share), "twinbee_daily_share")
|
weight := 3 // minimum (level 1 in all 3 skills)
|
||||||
if rollTwinBeeGift(uid) {
|
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++
|
summary.GiftCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Report average share for the summary display.
|
||||||
|
summary.GoldShare = totalDistributed / int64(len(eligible))
|
||||||
|
|
||||||
p.logTwinBeeResult(result, summary)
|
p.logTwinBeeResult(result, summary)
|
||||||
return summary
|
return summary
|
||||||
@@ -260,14 +343,10 @@ func rollTwinBeeGift(userID id.UserID) bool {
|
|||||||
// ── TwinBee Log ──────────────────────────────────────────────────────────────
|
// ── TwinBee Log ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (p *AdventurePlugin) logTwinBeeResult(result *TwinBeeResult, summary TwinBeeRewardSummary) {
|
func (p *AdventurePlugin) logTwinBeeResult(result *TwinBeeResult, summary TwinBeeRewardSummary) {
|
||||||
d := db.Get()
|
db.Exec("adventure: log twinbee result",
|
||||||
_, err := d.Exec(`
|
`INSERT INTO adventure_twinbee_log (activity_type, location, outcome, loot_value, loot_desc, participant_count, gold_share, gift_count)
|
||||||
INSERT INTO adventure_twinbee_log (activity_type, location, outcome, loot_value, loot_desc, participant_count, gold_share, gift_count)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
string(result.Activity), result.Location.Name, string(result.Outcome),
|
string(result.Activity), result.Location.Name, string(result.Outcome),
|
||||||
result.LootValue, result.LootDesc,
|
result.LootValue, result.LootDesc,
|
||||||
summary.Eligible, summary.GoldShare, summary.GiftCount)
|
summary.Eligible, summary.GoldShare, summary.GiftCount)
|
||||||
if err != nil {
|
|
||||||
slog.Error("adventure: failed to log twinbee result", "err", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,12 +111,8 @@ func (p *AnimePlugin) OnMessage(ctx MessageContext) error {
|
|||||||
parts := strings.SplitN(args, " ", 2)
|
parts := strings.SplitN(args, " ", 2)
|
||||||
sub := strings.ToLower(parts[0])
|
sub := strings.ToLower(parts[0])
|
||||||
|
|
||||||
|
// Subcommands that only touch the DB don't need async
|
||||||
switch sub {
|
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":
|
case "watching":
|
||||||
return p.handleWatching(ctx)
|
return p.handleWatching(ctx)
|
||||||
case "unwatch":
|
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.SendReply(ctx.RoomID, ctx.EventID, "Usage: !anime unwatch <id>")
|
||||||
}
|
}
|
||||||
return p.handleUnwatch(ctx, strings.TrimSpace(parts[1]))
|
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.
|
// 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 {
|
func (p *AnimePlugin) handleSearch(ctx MessageContext, query string) error {
|
||||||
d := db.Get()
|
|
||||||
|
|
||||||
// Search via API
|
// Search via API
|
||||||
encoded := url.QueryEscape(query)
|
encoded := url.QueryEscape(query)
|
||||||
apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime?q=%s&limit=3&sfw=true", encoded)
|
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
|
// Cache the result
|
||||||
data, _ := json.Marshal(anime)
|
data, _ := json.Marshal(anime)
|
||||||
if _, err := d.Exec(
|
db.Exec("anime: cache write",
|
||||||
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
|
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
|
||||||
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
|
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
anime.MalID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
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))
|
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
|
// Also cache the anime data
|
||||||
data, _ := json.Marshal(anime)
|
data, _ := json.Marshal(anime)
|
||||||
_, _ = d.Exec(
|
db.Exec("anime: cache watchlist entry",
|
||||||
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
|
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
|
||||||
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
|
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
anime.MalID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
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
|
// Update cache
|
||||||
data, _ := json.Marshal(animeData)
|
data, _ := json.Marshal(animeData)
|
||||||
_, _ = d.Exec(
|
db.Exec("anime: update daily cache",
|
||||||
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
|
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
|
||||||
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
|
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
entry.malID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
entry.malID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||||
|
|||||||
@@ -304,14 +304,11 @@ func (p *BirthdayPlugin) CheckAndPost(roomID id.RoomID) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mark as fired (for XP/DM dedup)
|
// Mark as fired (for XP/DM dedup)
|
||||||
_, err = d.Exec(
|
db.Exec("birthday: mark fired",
|
||||||
`INSERT INTO birthday_fired (user_id, year) VALUES (?, ?)
|
`INSERT INTO birthday_fired (user_id, year) VALUES (?, ?)
|
||||||
ON CONFLICT(user_id, year) DO NOTHING`,
|
ON CONFLICT(user_id, year) DO NOTHING`,
|
||||||
m.userID, year,
|
m.userID, year,
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
slog.Error("birthday: mark fired", "user", m.userID, "err", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -337,12 +334,9 @@ func (p *BirthdayPlugin) grantBirthdayXP(userID id.UserID, amount int) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = d.Exec(
|
db.Exec("birthday: log xp",
|
||||||
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
|
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
|
||||||
string(userID), amount, "birthday")
|
string(userID), amount, "birthday")
|
||||||
if err != nil {
|
|
||||||
slog.Error("birthday: log xp", "err", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseBirthdayDate parses MM-DD or MM-DD-YYYY format.
|
// parseBirthdayDate parses MM-DD or MM-DD-YYYY format.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"math/rand/v2"
|
"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})
|
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 {
|
if len(table.players) >= 4 {
|
||||||
_ = p.SendMessage(ctx.RoomID,
|
_ = p.SendMessage(ctx.RoomID,
|
||||||
@@ -323,7 +322,7 @@ func (p *BlackjackPlugin) handleBlackjack(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
p.tables[ctx.RoomID] = table
|
p.tables[ctx.RoomID] = table
|
||||||
|
|
||||||
name := p.bjDisplayName(ctx.Sender)
|
name := p.DisplayName(ctx.Sender)
|
||||||
_ = p.SendMessage(ctx.RoomID,
|
_ = 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)",
|
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)))
|
name, int(bet)))
|
||||||
@@ -357,7 +356,7 @@ func (p *BlackjackPlugin) handleLeave(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
player.Bust = true
|
player.Bust = true
|
||||||
player.Done = true
|
player.Done = true
|
||||||
name := p.bjDisplayName(ctx.Sender)
|
name := p.DisplayName(ctx.Sender)
|
||||||
_ = p.SendMessage(ctx.RoomID,
|
_ = p.SendMessage(ctx.RoomID,
|
||||||
fmt.Sprintf("🏳️ **%s** forfeits! Bet lost.", name))
|
fmt.Sprintf("🏳️ **%s** forfeits! Bet lost.", name))
|
||||||
p.checkAllDone(ctx.RoomID, table)
|
p.checkAllDone(ctx.RoomID, table)
|
||||||
@@ -381,7 +380,7 @@ func (p *BlackjackPlugin) handleLeave(ctx MessageContext) error {
|
|||||||
delete(p.tables, ctx.RoomID)
|
delete(p.tables, ctx.RoomID)
|
||||||
return p.SendMessage(ctx.RoomID, "🃏 Table closed — all players left.")
|
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,
|
return p.SendMessage(ctx.RoomID,
|
||||||
fmt.Sprintf("🃏 **%s** left the table. Bet refunded.", name))
|
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 {
|
for _, pl := range table.players {
|
||||||
if !pl.Done {
|
if !pl.Done {
|
||||||
allDone = false
|
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 {
|
if v > 21 {
|
||||||
player.Bust = true
|
player.Bust = true
|
||||||
player.Done = 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)}
|
msgs := []string{fmt.Sprintf("💥 **%s** busts with %s (%d)!", name, handStr(player.Hand), v)}
|
||||||
allDoneMsgs := p.collectAllDone(ctx.RoomID, table)
|
allDoneMsgs := p.collectAllDone(ctx.RoomID, table)
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
@@ -496,7 +495,7 @@ func (p *BlackjackPlugin) handleHit(ctx MessageContext) error {
|
|||||||
|
|
||||||
if v == 21 {
|
if v == 21 {
|
||||||
player.Done = true
|
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))}
|
msgs := []string{fmt.Sprintf("**%s** has 21! %s", name, handStr(player.Hand))}
|
||||||
allDoneMsgs := p.collectAllDone(ctx.RoomID, table)
|
allDoneMsgs := p.collectAllDone(ctx.RoomID, table)
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
@@ -528,7 +527,7 @@ func (p *BlackjackPlugin) handleStand(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
player.Done = true
|
player.Done = true
|
||||||
name := p.bjDisplayName(player.UserID)
|
name := p.DisplayName(player.UserID)
|
||||||
msgs := []string{fmt.Sprintf("**%s** stands at %d.", name, player.value())}
|
msgs := []string{fmt.Sprintf("**%s** stands at %d.", name, player.value())}
|
||||||
allDoneMsgs := p.collectAllDone(ctx.RoomID, table)
|
allDoneMsgs := p.collectAllDone(ctx.RoomID, table)
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
@@ -552,7 +551,7 @@ func (p *BlackjackPlugin) collectAllDone(roomID id.RoomID, table *bjTable) []str
|
|||||||
var waiting []string
|
var waiting []string
|
||||||
for _, pl := range table.players {
|
for _, pl := range table.players {
|
||||||
if !pl.Done {
|
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
|
var waiting []string
|
||||||
for _, pl := range tbl.players {
|
for _, pl := range tbl.players {
|
||||||
if !pl.Done {
|
if !pl.Done {
|
||||||
waiting = append(waiting, p.bjDisplayName(pl.UserID))
|
waiting = append(waiting, p.DisplayName(pl.UserID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(waiting) > 0 {
|
if len(waiting) > 0 {
|
||||||
@@ -613,7 +612,7 @@ func (p *BlackjackPlugin) startRoundTimer(roomID id.RoomID, table *bjTable) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
v := pl.value()
|
v := pl.value()
|
||||||
name := p.bjDisplayName(pl.UserID)
|
name := p.DisplayName(pl.UserID)
|
||||||
|
|
||||||
if v >= p.cfg.AutoplayThreshold {
|
if v >= p.cfg.AutoplayThreshold {
|
||||||
_ = p.SendMessage(roomID,
|
_ = p.SendMessage(roomID,
|
||||||
@@ -709,7 +708,7 @@ func (p *BlackjackPlugin) collectResolveRound(roomID id.RoomID, table *bjTable)
|
|||||||
sb.WriteString("🃏 **Round over!**\n\n")
|
sb.WriteString("🃏 **Round over!**\n\n")
|
||||||
|
|
||||||
for _, pl := range table.players {
|
for _, pl := range table.players {
|
||||||
name := p.bjDisplayName(pl.UserID)
|
name := p.DisplayName(pl.UserID)
|
||||||
playerValue := pl.value()
|
playerValue := pl.value()
|
||||||
playerBJ := isBlackjack(pl.Hand)
|
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")
|
sb.WriteString("🃏 **Blackjack** — Round in progress\n\n")
|
||||||
|
|
||||||
for _, pl := range table.players {
|
for _, pl := range table.players {
|
||||||
name := p.bjDisplayName(pl.UserID)
|
name := p.DisplayName(pl.UserID)
|
||||||
v := pl.value()
|
v := pl.value()
|
||||||
extra := ""
|
extra := ""
|
||||||
if isBlackjack(pl.Hand) {
|
if isBlackjack(pl.Hand) {
|
||||||
@@ -842,7 +841,7 @@ func (p *BlackjackPlugin) handleBoard(ctx MessageContext) error {
|
|||||||
var played, won int
|
var played, won int
|
||||||
rows.Scan(&userID, &earned, &played, &won)
|
rows.Scan(&userID, &earned, &played, &won)
|
||||||
rank++
|
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))
|
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) {
|
func (p *BlackjackPlugin) recordBJScore(userID id.UserID, net float64) {
|
||||||
d := db.Get()
|
|
||||||
won := 0
|
won := 0
|
||||||
if net > 0 {
|
if net > 0 {
|
||||||
won = 1
|
won = 1
|
||||||
}
|
}
|
||||||
_, _ = d.Exec(
|
db.Exec("blackjack: record score",
|
||||||
`INSERT INTO blackjack_scores (user_id, total_earned, games_played, games_won)
|
`INSERT INTO blackjack_scores (user_id, total_earned, games_played, games_won)
|
||||||
VALUES (?, ?, 1, ?)
|
VALUES (?, ?, 1, ?)
|
||||||
ON CONFLICT(user_id) DO UPDATE SET
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
88
internal/plugin/blackjack_test.go
Normal file
88
internal/plugin/blackjack_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,12 +92,8 @@ func (p *ConcertsPlugin) OnMessage(ctx MessageContext) error {
|
|||||||
parts := strings.SplitN(args, " ", 2)
|
parts := strings.SplitN(args, " ", 2)
|
||||||
sub := strings.ToLower(parts[0])
|
sub := strings.ToLower(parts[0])
|
||||||
|
|
||||||
|
// DB-only subcommands
|
||||||
switch sub {
|
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":
|
case "watching":
|
||||||
return p.handleWatching(ctx)
|
return p.handleWatching(ctx)
|
||||||
case "unwatch":
|
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.SendReply(ctx.RoomID, ctx.EventID, "Usage: !concerts unwatch <artist>")
|
||||||
}
|
}
|
||||||
return p.handleUnwatch(ctx, strings.TrimSpace(parts[1]))
|
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 {
|
func (p *ConcertsPlugin) handleSearch(ctx MessageContext, artist string) error {
|
||||||
@@ -222,14 +235,11 @@ func (p *ConcertsPlugin) fetchEvents(artist string) ([]bandsintownEvent, error)
|
|||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
data, _ := json.Marshal(events)
|
data, _ := json.Marshal(events)
|
||||||
_, err = d.Exec(
|
db.Exec("concerts: cache write",
|
||||||
`INSERT INTO concerts_cache (artist, data, cached_at) VALUES (?, ?, ?)
|
`INSERT INTO concerts_cache (artist, data, cached_at) VALUES (?, ?, ?)
|
||||||
ON CONFLICT(artist) DO UPDATE SET data = ?, cached_at = ?`,
|
ON CONFLICT(artist) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
artistKey, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
artistKey, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
slog.Error("concerts: cache write", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return events, nil
|
return events, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -260,13 +260,9 @@ func (p *CountdownPlugin) handleShow(ctx MessageContext, idStr string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *CountdownPlugin) completeOldCountdowns() {
|
func (p *CountdownPlugin) completeOldCountdowns() {
|
||||||
d := db.Get()
|
|
||||||
cutoff := time.Now().UTC().AddDate(0, 0, -7).Format("2006-01-02")
|
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`,
|
`UPDATE countdowns SET completed = 1 WHERE target_date < ? AND completed = 0`,
|
||||||
cutoff,
|
cutoff,
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
slog.Error("countdown: auto-complete", "err", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -234,8 +233,7 @@ func (p *EuroPlugin) GetBalance(userID id.UserID) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *EuroPlugin) logTransaction(userID id.UserID, amount float64, reason string) {
|
func (p *EuroPlugin) logTransaction(userID id.UserID, amount float64, reason string) {
|
||||||
d := db.Get()
|
db.Exec("euro: log transaction",
|
||||||
_, _ = d.Exec(
|
|
||||||
"INSERT INTO euro_transactions (user_id, amount, reason) VALUES (?, ?, ?)",
|
"INSERT INTO euro_transactions (user_id, amount, reason) VALUES (?, ?, ?)",
|
||||||
string(userID), amount, reason,
|
string(userID), amount, reason,
|
||||||
)
|
)
|
||||||
@@ -309,7 +307,7 @@ func (p *EuroPlugin) handleBaltop(ctx MessageContext) error {
|
|||||||
var balance float64
|
var balance float64
|
||||||
rows.Scan(&userID, &balance)
|
rows.Scan(&userID, &balance)
|
||||||
rank++
|
rank++
|
||||||
name := p.displayName(id.UserID(userID))
|
name := p.DisplayName(id.UserID(userID))
|
||||||
medal := ""
|
medal := ""
|
||||||
switch rank {
|
switch rank {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -362,16 +360,9 @@ func (p *EuroPlugin) handleTransfer(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
p.Credit(targetID, amount, fmt.Sprintf("transfer from %s", ctx.Sender))
|
p.Credit(targetID, amount, fmt.Sprintf("transfer from %s", ctx.Sender))
|
||||||
|
|
||||||
senderName := p.displayName(ctx.Sender)
|
senderName := p.DisplayName(ctx.Sender)
|
||||||
targetName := p.displayName(targetID)
|
targetName := p.DisplayName(targetID)
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
fmt.Sprintf("💸 **%s** sent €%d to **%s**.", senderName, int(amount), targetName))
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -143,7 +142,7 @@ func (p *FlipPlugin) handleLoseboard(ctx MessageContext) error {
|
|||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("🐝 **GogoBee's Trophy Wall**\n\n")
|
sb.WriteString("🐝 **GogoBee's Trophy Wall**\n\n")
|
||||||
for i, uid := range order {
|
for i, uid := range order {
|
||||||
name := p.flipDisplayName(id.UserID(uid))
|
name := p.DisplayName(id.UserID(uid))
|
||||||
stats := users[uid]
|
stats := users[uid]
|
||||||
var parts []string
|
var parts []string
|
||||||
for game, count := range stats.breakdown {
|
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.
|
// 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.
|
// This is a package-level function so all game plugins can call it.
|
||||||
func recordBotDefeat(userID id.UserID, game string) {
|
func recordBotDefeat(userID id.UserID, game string) {
|
||||||
d := db.Get()
|
db.Exec("flip: record bot defeat",
|
||||||
_, _ = d.Exec(
|
|
||||||
`INSERT INTO bot_defeats (user_id, game, losses)
|
`INSERT INTO bot_defeats (user_id, game, losses)
|
||||||
VALUES (?, ?, 1)
|
VALUES (?, ?, 1)
|
||||||
ON CONFLICT(user_id, game) DO UPDATE SET
|
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.
|
// redirectToGamesRoom returns the room ID for games-restricted redirect.
|
||||||
func redirectToGamesRoom(sender id.UserID) string {
|
func redirectToGamesRoom(sender id.UserID) string {
|
||||||
|
|||||||
262
internal/plugin/forex.go
Normal file
262
internal/plugin/forex.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
206
internal/plugin/forex_analysis.go
Normal file
206
internal/plugin/forex_analysis.go
Normal file
@@ -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) + "]"
|
||||||
|
}
|
||||||
159
internal/plugin/forex_api.go
Normal file
159
internal/plugin/forex_api.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
140
internal/plugin/forex_store.go
Normal file
140
internal/plugin/forex_store.go
Normal file
@@ -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`)
|
||||||
|
}
|
||||||
@@ -321,7 +321,7 @@ func (p *FunPlugin) handleHLTB(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache result
|
// Cache result
|
||||||
_, _ = db.Get().Exec(
|
db.Exec("hltb: cache result",
|
||||||
`INSERT INTO hltb_cache (game_name, data, cached_at) VALUES (?, ?, ?)
|
`INSERT INTO hltb_cache (game_name, data, cached_at) VALUES (?, ?, ?)
|
||||||
ON CONFLICT(game_name) DO UPDATE SET data = ?, cached_at = ?`,
|
ON CONFLICT(game_name) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
strings.ToLower(gameName), result, time.Now().Unix(), result, time.Now().Unix(),
|
strings.ToLower(gameName), result, time.Now().Unix(), result, time.Now().Unix(),
|
||||||
|
|||||||
@@ -73,7 +73,12 @@ func (p *GamingPlugin) OnReaction(_ ReactionContext) error { return nil }
|
|||||||
|
|
||||||
func (p *GamingPlugin) OnMessage(ctx MessageContext) error {
|
func (p *GamingPlugin) OnMessage(ctx MessageContext) error {
|
||||||
if p.IsCommand(ctx.Body, "releases") {
|
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") {
|
if p.IsCommand(ctx.Body, "releasewatch") {
|
||||||
return p.handleReleaseWatch(ctx)
|
return p.handleReleaseWatch(ctx)
|
||||||
@@ -244,14 +249,11 @@ func (p *GamingPlugin) fetchReleases(startDate, endDate, cacheKey string) ([]rel
|
|||||||
|
|
||||||
// Cache results
|
// Cache results
|
||||||
data, _ := json.Marshal(games)
|
data, _ := json.Marshal(games)
|
||||||
_, err = d.Exec(
|
db.Exec("gaming: cache write",
|
||||||
`INSERT INTO releases_cache (cache_key, data, cached_at) VALUES (?, ?, ?)
|
`INSERT INTO releases_cache (cache_key, data, cached_at) VALUES (?, ?, ?)
|
||||||
ON CONFLICT(cache_key) DO UPDATE SET data = ?, cached_at = ?`,
|
ON CONFLICT(cache_key) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
fullKey, string(data), time.Now().UTC().Unix(), string(data), time.Now().UTC().Unix(),
|
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
|
return games, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package plugin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -630,7 +629,7 @@ func (p *HangmanPlugin) endGame(roomID id.RoomID, game *hangmanGame) error {
|
|||||||
eligibleParticipants = append(eligibleParticipants, uid)
|
eligibleParticipants = append(eligibleParticipants, uid)
|
||||||
}
|
}
|
||||||
|
|
||||||
solverName := p.displayName(game.solvedBy)
|
solverName := p.DisplayName(game.solvedBy)
|
||||||
participantCount := len(eligibleParticipants)
|
participantCount := len(eligibleParticipants)
|
||||||
|
|
||||||
var sb strings.Builder
|
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.euro.Credit(uid, payout, "hangman_win")
|
||||||
p.recordHangmanScore(uid, payout)
|
p.recordHangmanScore(uid, payout)
|
||||||
name := p.displayName(uid)
|
name := p.DisplayName(uid)
|
||||||
sb.WriteString(fmt.Sprintf(" **%s**: +€%d%s\n", name, int(payout), label))
|
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
|
var won int
|
||||||
rows.Scan(&userID, &earned, &won)
|
rows.Scan(&userID, &earned, &won)
|
||||||
rank++
|
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))
|
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) {
|
func (p *HangmanPlugin) recordHangmanScore(userID id.UserID, earned float64) {
|
||||||
d := db.Get()
|
db.Exec("hangman: record score",
|
||||||
_, _ = d.Exec(
|
|
||||||
`INSERT INTO hangman_scores (user_id, total_earned, games_played, games_won)
|
`INSERT INTO hangman_scores (user_id, total_earned, games_played, games_won)
|
||||||
VALUES (?, ?, 1, 1)
|
VALUES (?, ?, 1, 1)
|
||||||
ON CONFLICT(user_id) DO UPDATE SET
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -97,6 +96,16 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
|
|||||||
return p.handleTipsToggle(ctx, args, isDM)
|
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.
|
// Room-only commands need games room check.
|
||||||
if !isDM && !isGamesRoom(ctx.RoomID) {
|
if !isDM && !isGamesRoom(ctx.RoomID) {
|
||||||
gr := gamesRoom()
|
gr := gamesRoom()
|
||||||
@@ -106,8 +115,10 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case args == "join":
|
case args == "play" || strings.HasPrefix(args, "play "):
|
||||||
return p.handleJoin(ctx)
|
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":
|
case args == "leave":
|
||||||
return p.handleLeave(ctx)
|
return p.handleLeave(ctx)
|
||||||
case args == "start":
|
case args == "start":
|
||||||
@@ -139,6 +150,17 @@ func (p *HoldemPlugin) isDMRoom(roomID id.RoomID) bool {
|
|||||||
return len(members) <= 2
|
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 {
|
func (p *HoldemPlugin) handleTipsToggle(ctx MessageContext, args string, isDM bool) error {
|
||||||
if !isDM {
|
if !isDM {
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips can only be toggled in DM. Send `!holdem tips on/off` directly to me.")
|
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()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
@@ -186,6 +241,7 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext) error {
|
|||||||
WaitingForPlayers: true,
|
WaitingForPlayers: true,
|
||||||
}
|
}
|
||||||
p.games[ctx.RoomID] = game
|
p.games[ctx.RoomID] = game
|
||||||
|
p.startIdleTimer(game)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already seated.
|
// Check if already seated.
|
||||||
@@ -212,12 +268,36 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get display name.
|
// Get display name.
|
||||||
name := p.holdemDisplayName(ctx.Sender)
|
name := p.DisplayName(ctx.Sender)
|
||||||
|
|
||||||
// Determine buy-in (capped at MaxBuyin).
|
// Determine buy-in: player-specified, or max buy-in, capped by balance.
|
||||||
buyin := int64(balance)
|
var buyin int64
|
||||||
if buyin > p.cfg.MaxBuyin {
|
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
|
buyin = p.cfg.MaxBuyin
|
||||||
|
if int64(balance) < buyin {
|
||||||
|
buyin = int64(balance)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debit buy-in immediately to prevent double-spending across tables.
|
// 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 {
|
if !game.HandInProgress || player.SittingOut {
|
||||||
// Credit remaining stack back (buy-in was debited at join).
|
// Credit remaining stack back (buy-in was debited at join).
|
||||||
if !player.IsNPC && player.Stack > 0 {
|
cashout := player.Stack
|
||||||
p.euro.Credit(player.UserID, float64(player.Stack), "holdem_cashout")
|
if !player.IsNPC && cashout > 0 {
|
||||||
|
p.euro.Credit(player.UserID, float64(cashout), "holdem_cashout")
|
||||||
}
|
}
|
||||||
// Remove immediately.
|
// Remove immediately.
|
||||||
p.removePlayer(game, ctx.Sender)
|
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 {
|
if len(game.Players) == 0 {
|
||||||
delete(p.games, ctx.RoomID)
|
delete(p.games, ctx.RoomID)
|
||||||
@@ -286,6 +367,7 @@ func (p *HoldemPlugin) handleLeave(ctx MessageContext) error {
|
|||||||
idx := game.playerIdx(ctx.Sender)
|
idx := game.playerIdx(ctx.Sender)
|
||||||
if idx == game.ActionIdx {
|
if idx == game.ActionIdx {
|
||||||
// It's their turn — fold and remove.
|
// It's their turn — fold and remove.
|
||||||
|
p.stopActionTimer(game)
|
||||||
game.doFold(idx)
|
game.doFold(idx)
|
||||||
p.SendMessage(ctx.RoomID, fmt.Sprintf("**%s** folds and leaves the table.", player.DisplayName))
|
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 ---
|
// --- Game lifecycle ---
|
||||||
|
|
||||||
func (p *HoldemPlugin) startHand(game *HoldemGame) {
|
func (p *HoldemPlugin) startHand(game *HoldemGame) {
|
||||||
|
p.stopIdleTimer(game)
|
||||||
|
|
||||||
// Unsit players who were waiting.
|
// Unsit players who were waiting.
|
||||||
for _, pl := range game.Players {
|
for _, pl := range game.Players {
|
||||||
if pl.SittingOut {
|
if pl.SittingOut {
|
||||||
@@ -546,6 +630,7 @@ func (p *HoldemPlugin) startHand(game *HoldemGame) {
|
|||||||
pl.State = PlayerActive
|
pl.State = PlayerActive
|
||||||
pl.Bet = 0
|
pl.Bet = 0
|
||||||
pl.TotalBet = 0
|
pl.TotalBet = 0
|
||||||
|
pl.HasActed = false
|
||||||
pl.Hole = [2]poker.Card{}
|
pl.Hole = [2]poker.Card{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,8 +655,9 @@ func (p *HoldemPlugin) startHand(game *HoldemGame) {
|
|||||||
p.SendMessage(game.RoomID, renderStartAnnouncement(game))
|
p.SendMessage(game.RoomID, renderStartAnnouncement(game))
|
||||||
|
|
||||||
// Send hole cards to all players via DM.
|
// 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 {
|
for i, pl := range game.Players {
|
||||||
if pl.IsNPC || pl.State == PlayerSatOut {
|
if pl.IsNPC || pl.State == PlayerSatOut || i == game.ActionIdx {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
view := renderPrivateHand(game, i)
|
view := renderPrivateHand(game, i)
|
||||||
@@ -807,11 +893,15 @@ func (p *HoldemPlugin) endHand(game *HoldemGame) {
|
|||||||
game.HandInProgress = false
|
game.HandInProgress = false
|
||||||
game.Street = StreetPreFlop
|
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-- {
|
for i := len(game.Players) - 1; i >= 0; i-- {
|
||||||
pl := game.Players[i]
|
pl := game.Players[i]
|
||||||
if pl.WantsLeave || pl.Stack <= 0 {
|
if pl.WantsLeave || pl.Stack <= 0 {
|
||||||
if !pl.IsNPC {
|
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))
|
p.SendMessage(game.RoomID, fmt.Sprintf("**%s** has left the table.", pl.DisplayName))
|
||||||
}
|
}
|
||||||
game.Players = append(game.Players[:i], game.Players[i+1:]...)
|
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 {
|
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.")
|
p.SendMessage(game.RoomID, "Not enough players for another hand. Game over.")
|
||||||
delete(p.games, game.RoomID)
|
delete(p.games, game.RoomID)
|
||||||
return
|
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.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 ---
|
// --- Notifications ---
|
||||||
@@ -848,11 +945,12 @@ func (p *HoldemPlugin) sendTurnNotifications(game *HoldemGame) {
|
|||||||
hand := renderPrivateHand(game, game.ActionIdx)
|
hand := renderPrivateHand(game, game.ActionIdx)
|
||||||
p.SendMessage(actionPlayer.DMRoomID, hand+"\n"+view)
|
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 {
|
if actionPlayer.TipsEnabled {
|
||||||
tipCtx := buildTipContext(game, game.ActionIdx)
|
snap := snapshotForTip(game, game.ActionIdx)
|
||||||
dmRoom := actionPlayer.DMRoomID
|
dmRoom := actionPlayer.DMRoomID
|
||||||
go func() {
|
go func() {
|
||||||
|
tipCtx := buildTipContext(snap)
|
||||||
tip := generateTip(tipCtx)
|
tip := generateTip(tipCtx)
|
||||||
p.SendMessage(dmRoom, tip)
|
p.SendMessage(dmRoom, tip)
|
||||||
}()
|
}()
|
||||||
@@ -892,6 +990,9 @@ func (p *HoldemPlugin) npcAct(game *HoldemGame) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop the action timer so it can't fire while we process.
|
||||||
|
p.stopActionTimer(game)
|
||||||
|
|
||||||
actionStr, amount := cfrActionToGameAction(action, game, npcIdx)
|
actionStr, amount := cfrActionToGameAction(action, game, npcIdx)
|
||||||
|
|
||||||
var result ActionResult
|
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 ---
|
// --- Helpers ---
|
||||||
|
|
||||||
func (p *HoldemPlugin) removePlayer(game *HoldemGame, uid id.UserID) {
|
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 {
|
if pl.UserID == uid {
|
||||||
game.Players = append(game.Players[:i], game.Players[i+1:]...)
|
game.Players = append(game.Players[:i], game.Players[i+1:]...)
|
||||||
// Adjust seat indices that reference positions after the removed player.
|
// 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--
|
game.DealerIdx--
|
||||||
}
|
}
|
||||||
if game.ActionIdx >= i && game.ActionIdx > 0 {
|
if game.ActionIdx > i {
|
||||||
game.ActionIdx--
|
game.ActionIdx--
|
||||||
}
|
}
|
||||||
if game.LastAggressorIdx >= i && game.LastAggressorIdx > 0 {
|
if game.LastAggressorIdx > i {
|
||||||
game.LastAggressorIdx--
|
game.LastAggressorIdx--
|
||||||
}
|
}
|
||||||
// Wrap indices if they exceed the new length.
|
// 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 {
|
func (p *HoldemPlugin) getNPCBalance() int64 {
|
||||||
d := db.Get()
|
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)
|
err := d.QueryRow(`SELECT balance FROM holdem_npc_balance WHERE npc_name = ?`, p.cfg.NPCName).Scan(&balance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Initialize.
|
// 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)
|
p.cfg.NPCName, p.cfg.NPCHouseBalance)
|
||||||
return p.cfg.NPCHouseBalance
|
return p.cfg.NPCHouseBalance
|
||||||
}
|
}
|
||||||
@@ -1065,13 +1211,12 @@ func (p *HoldemPlugin) getNPCBalance() int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *HoldemPlugin) updateNPCBalance(delta int64) {
|
func (p *HoldemPlugin) updateNPCBalance(delta int64) {
|
||||||
d := db.Get()
|
db.Exec("holdem: update npc balance",
|
||||||
_, _ = d.Exec(`UPDATE holdem_npc_balance SET balance = balance + ?, hands_played = hands_played + 1 WHERE npc_name = ?`,
|
`UPDATE holdem_npc_balance SET balance = balance + ?, hands_played = hands_played + 1 WHERE npc_name = ?`,
|
||||||
delta, p.cfg.NPCName)
|
delta, p.cfg.NPCName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *HoldemPlugin) recordScores(game *HoldemGame, winnings map[id.UserID]int64) {
|
func (p *HoldemPlugin) recordScores(game *HoldemGame, winnings map[id.UserID]int64) {
|
||||||
d := db.Get()
|
|
||||||
for _, pl := range game.Players {
|
for _, pl := range game.Players {
|
||||||
if pl.IsNPC {
|
if pl.IsNPC {
|
||||||
continue
|
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)
|
`INSERT INTO holdem_scores (user_id, hands_played, total_won, total_lost, biggest_pot)
|
||||||
VALUES (?, 1, ?, ?, ?)
|
VALUES (?, 1, ?, ?, ?)
|
||||||
ON CONFLICT(user_id) DO UPDATE SET
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ func (g *HoldemGame) postBlinds() (sbIdx, bbIdx int) {
|
|||||||
|
|
||||||
// firstToActPreflop returns the seat index of the first player to act preflop.
|
// firstToActPreflop returns the seat index of the first player to act preflop.
|
||||||
func (g *HoldemGame) firstToActPreflop(bbIdx int) int {
|
func (g *HoldemGame) firstToActPreflop(bbIdx int) int {
|
||||||
n := len(g.Players)
|
inHand := len(g.inHandPlayers())
|
||||||
if n == 2 {
|
if inHand == 2 {
|
||||||
// Heads-up: dealer/SB acts first preflop.
|
// Heads-up: dealer/SB acts first preflop.
|
||||||
return g.DealerIdx
|
return g.DealerIdx
|
||||||
}
|
}
|
||||||
@@ -83,6 +83,7 @@ type ActionResult struct {
|
|||||||
func (g *HoldemGame) doFold(seatIdx int) ActionResult {
|
func (g *HoldemGame) doFold(seatIdx int) ActionResult {
|
||||||
p := g.Players[seatIdx]
|
p := g.Players[seatIdx]
|
||||||
p.State = PlayerFolded
|
p.State = PlayerFolded
|
||||||
|
p.HasActed = true
|
||||||
g.StreetHistory += "f"
|
g.StreetHistory += "f"
|
||||||
|
|
||||||
ann := renderActionAnnouncement(p.DisplayName, "fold", 0)
|
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."
|
return ActionResult{}, "You must call, raise, or fold — there's a bet to you."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.HasActed = true
|
||||||
g.StreetHistory += "c"
|
g.StreetHistory += "c"
|
||||||
ann := renderActionAnnouncement(p.DisplayName, "check", 0)
|
ann := renderActionAnnouncement(p.DisplayName, "check", 0)
|
||||||
return ActionResult{
|
return ActionResult{
|
||||||
@@ -127,6 +129,7 @@ func (g *HoldemGame) doCall(seatIdx int) (ActionResult, string) {
|
|||||||
p.Stack -= toCall
|
p.Stack -= toCall
|
||||||
p.Bet += toCall
|
p.Bet += toCall
|
||||||
p.TotalBet += toCall
|
p.TotalBet += toCall
|
||||||
|
p.HasActed = true
|
||||||
|
|
||||||
action := "call"
|
action := "call"
|
||||||
if p.Stack == 0 {
|
if p.Stack == 0 {
|
||||||
@@ -171,6 +174,7 @@ func (g *HoldemGame) doRaise(seatIdx int, raiseTo int64) (ActionResult, string)
|
|||||||
p.Stack -= raiseAmount
|
p.Stack -= raiseAmount
|
||||||
p.Bet = raiseTo
|
p.Bet = raiseTo
|
||||||
p.TotalBet += raiseAmount
|
p.TotalBet += raiseAmount
|
||||||
|
p.HasActed = true
|
||||||
|
|
||||||
if actualRaise > 0 {
|
if actualRaise > 0 {
|
||||||
g.MinRaise = actualRaise
|
g.MinRaise = actualRaise
|
||||||
@@ -214,6 +218,7 @@ func (g *HoldemGame) doAllIn(seatIdx int) ActionResult {
|
|||||||
p.Bet = totalBet
|
p.Bet = totalBet
|
||||||
p.TotalBet += allInAmount
|
p.TotalBet += allInAmount
|
||||||
p.State = PlayerAllIn
|
p.State = PlayerAllIn
|
||||||
|
p.HasActed = true
|
||||||
|
|
||||||
if totalBet > g.CurrentBet {
|
if totalBet > g.CurrentBet {
|
||||||
actualRaise := totalBet - g.CurrentBet
|
actualRaise := totalBet - g.CurrentBet
|
||||||
@@ -247,11 +252,16 @@ func (g *HoldemGame) isStreetComplete(nextIdx int) bool {
|
|||||||
return true
|
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 {
|
for _, p := range g.Players {
|
||||||
if p.State == PlayerActive && p.Bet != g.CurrentBet {
|
if p.State == PlayerActive && p.Bet != g.CurrentBet {
|
||||||
return false
|
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
|
// 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 := g.Players[highestIdx]
|
||||||
p.Stack += excess
|
p.Stack += excess
|
||||||
p.TotalBet -= excess
|
p.TotalBet -= excess
|
||||||
p.Bet -= excess
|
// Don't adjust per-street Bet — it may be smaller than the hand-total
|
||||||
if p.Bet < 0 {
|
// excess, and it's irrelevant since the hand is ending.
|
||||||
p.Bet = 0
|
|
||||||
}
|
|
||||||
return p.DisplayName, excess
|
return p.DisplayName, excess
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/chehsunliu/poker"
|
"github.com/chehsunliu/poker"
|
||||||
)
|
)
|
||||||
@@ -112,3 +114,224 @@ func Equity(hole [2]poker.Card, community []poker.Card, numOpponents, iterations
|
|||||||
Loss: float64(losses) / total,
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -142,15 +142,8 @@ func awardPotToLastPlayer(g *HoldemGame) (string, id.UserID) {
|
|||||||
return ann, winner.UserID
|
return ann, winner.UserID
|
||||||
}
|
}
|
||||||
|
|
||||||
// settleNetDeltas credits each player's remaining stack back to their balance.
|
// settleNetDeltas is intentionally a no-op.
|
||||||
// Buy-in was debited at join time, so we credit the full final stack.
|
// Economy settlement happens at leave time: buy-in is debited at join,
|
||||||
func settleNetDeltas(g *HoldemGame, euro *EuroPlugin) {
|
// full remaining stack is credited at leave/cashout. Per-hand settlement
|
||||||
for _, p := range g.Players {
|
// would double-count because the stack already reflects cumulative results.
|
||||||
if p.IsNPC || p.State == PlayerSatOut {
|
func settleNetDeltas(_ *HoldemGame, _ *EuroPlugin) {}
|
||||||
continue
|
|
||||||
}
|
|
||||||
if p.Stack > 0 {
|
|
||||||
euro.Credit(p.UserID, float64(p.Stack), "holdem_cashout")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
183
internal/plugin/holdem_eval_test.go
Normal file
183
internal/plugin/holdem_eval_test.go
Normal file
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ type HoldemPlayer struct {
|
|||||||
Bet int64 // committed this street
|
Bet int64 // committed this street
|
||||||
TotalBet int64 // committed this hand
|
TotalBet int64 // committed this hand
|
||||||
State PlayerState
|
State PlayerState
|
||||||
|
HasActed bool // whether the player has voluntarily acted this street
|
||||||
TipsEnabled bool
|
TipsEnabled bool
|
||||||
SittingOut bool
|
SittingOut bool
|
||||||
WantsLeave bool
|
WantsLeave bool
|
||||||
@@ -90,8 +91,10 @@ type HoldemGame struct {
|
|||||||
HandInProgress bool
|
HandInProgress bool
|
||||||
StreetHistory string // action chars for current street (f/c/r/R/a) for CFR policy lookup
|
StreetHistory string // action chars for current street (f/c/r/R/a) for CFR policy lookup
|
||||||
|
|
||||||
actionTimer *time.Timer
|
actionTimer *time.Timer
|
||||||
warningTimer *time.Timer
|
warningTimer *time.Timer
|
||||||
|
idleTimer *time.Timer
|
||||||
|
idleWarningTimer *time.Timer
|
||||||
}
|
}
|
||||||
|
|
||||||
// newShuffledDeck creates a shuffled 52-card deck.
|
// newShuffledDeck creates a shuffled 52-card deck.
|
||||||
@@ -208,6 +211,7 @@ func (g *HoldemGame) playerIdx(uid id.UserID) int {
|
|||||||
func (g *HoldemGame) resetStreetBets() {
|
func (g *HoldemGame) resetStreetBets() {
|
||||||
for _, p := range g.Players {
|
for _, p := range g.Players {
|
||||||
p.Bet = 0
|
p.Bet = 0
|
||||||
|
p.HasActed = false
|
||||||
}
|
}
|
||||||
g.CurrentBet = 0
|
g.CurrentBet = 0
|
||||||
g.MinRaise = g.BigBlind
|
g.MinRaise = g.BigBlind
|
||||||
|
|||||||
@@ -297,8 +297,10 @@ func renderEndAnnouncement(results []showdownResult, g *HoldemGame) string {
|
|||||||
// renderHelpMessage returns the help text for !holdem help.
|
// renderHelpMessage returns the help text for !holdem help.
|
||||||
func renderHelpMessage() string {
|
func renderHelpMessage() string {
|
||||||
return "🎰 **Texas Hold'em Commands**\n\n" +
|
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" +
|
"**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 leave` — Leave the table\n" +
|
||||||
"`!holdem start` — Start dealing (≥2 players)\n" +
|
"`!holdem start` — Start dealing (≥2 players)\n" +
|
||||||
"`!holdem addbot` — Add an AI opponent\n\n" +
|
"`!holdem addbot` — Add an AI opponent\n\n" +
|
||||||
@@ -306,7 +308,7 @@ func renderHelpMessage() string {
|
|||||||
"`!holdem fold` — Fold your hand\n" +
|
"`!holdem fold` — Fold your hand\n" +
|
||||||
"`!holdem check` — Check (no bet to call)\n" +
|
"`!holdem check` — Check (no bet to call)\n" +
|
||||||
"`!holdem call` — Call the current bet\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" +
|
"`!holdem allin` — Go all-in\n\n" +
|
||||||
"**Other:**\n" +
|
"**Other:**\n" +
|
||||||
"`!holdem status` — Get current table state (DM)\n" +
|
"`!holdem status` — Get current table state (DM)\n" +
|
||||||
|
|||||||
@@ -8,15 +8,17 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"github.com/chehsunliu/poker"
|
||||||
"maunium.net/go/mautrix/id"
|
"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.
|
// loadTipsPref loads a user's tip preference from the database.
|
||||||
func loadTipsPref(userID id.UserID) bool {
|
func loadTipsPref(userID id.UserID) bool {
|
||||||
@@ -31,12 +33,11 @@ func loadTipsPref(userID id.UserID) bool {
|
|||||||
|
|
||||||
// saveTipsPref saves a user's tip preference.
|
// saveTipsPref saves a user's tip preference.
|
||||||
func saveTipsPref(userID id.UserID, enabled bool) {
|
func saveTipsPref(userID id.UserID, enabled bool) {
|
||||||
d := db.Get()
|
|
||||||
val := 0
|
val := 0
|
||||||
if enabled {
|
if enabled {
|
||||||
val = 1
|
val = 1
|
||||||
}
|
}
|
||||||
_, _ = d.Exec(
|
db.Exec("holdem: save tips preference",
|
||||||
`INSERT INTO holdem_tips_prefs (user_id, enabled) VALUES (?, ?)
|
`INSERT INTO holdem_tips_prefs (user_id, enabled) VALUES (?, ?)
|
||||||
ON CONFLICT(user_id) DO UPDATE SET enabled = ?, updated_at = CURRENT_TIMESTAMP`,
|
ON CONFLICT(user_id) DO UPDATE SET enabled = ?, updated_at = CURRENT_TIMESTAMP`,
|
||||||
string(userID), val, val,
|
string(userID), val, val,
|
||||||
@@ -45,22 +46,43 @@ func saveTipsPref(userID id.UserID, enabled bool) {
|
|||||||
|
|
||||||
// holdemTipContext holds the data needed to generate a tip.
|
// holdemTipContext holds the data needed to generate a tip.
|
||||||
type holdemTipContext struct {
|
type holdemTipContext struct {
|
||||||
PlayerName string
|
PlayerName string
|
||||||
Hole [2]string // rendered card strings
|
Hole [2]string // rendered card strings
|
||||||
Community string // rendered board string
|
Community string // rendered board string
|
||||||
Equity EquityResult
|
Equity EquityResult
|
||||||
SPR float64
|
SPR float64
|
||||||
PotOddsPct float64
|
PotOddsPct float64
|
||||||
ToCall int64
|
ToCall int64
|
||||||
Pot int64
|
Pot int64
|
||||||
Stack int64
|
Stack int64
|
||||||
Street Street
|
Street Street
|
||||||
Position string
|
Position string
|
||||||
NumActive int
|
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.
|
// tipSnapshot holds game data captured under lock for async tip generation.
|
||||||
func buildTipContext(g *HoldemGame, playerIdx int) holdemTipContext {
|
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]
|
p := g.Players[playerIdx]
|
||||||
|
|
||||||
totalPot := g.Pot
|
totalPot := g.Pot
|
||||||
@@ -79,40 +101,104 @@ func buildTipContext(g *HoldemGame, playerIdx int) holdemTipContext {
|
|||||||
numOpp = 1
|
numOpp = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
iterations := 5000
|
communityS := "—"
|
||||||
if len(g.Community) > 0 {
|
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
|
iterations = 10000
|
||||||
}
|
}
|
||||||
eq := Equity(p.Hole, g.Community, numOpp, iterations)
|
eq := Equity(snap.hole, snap.community, snap.numOpp, iterations)
|
||||||
|
|
||||||
spr := 0.0
|
spr := 0.0
|
||||||
if totalPot > 0 {
|
if snap.totalPot > 0 {
|
||||||
spr = float64(p.Stack) / float64(totalPot)
|
spr = float64(snap.stack) / float64(snap.totalPot)
|
||||||
}
|
}
|
||||||
|
|
||||||
potOdds := 0.0
|
potOdds := 0.0
|
||||||
if toCall > 0 {
|
if snap.toCall > 0 {
|
||||||
potOdds = float64(toCall) / float64(totalPot+toCall) * 100
|
potOdds = float64(snap.toCall) / float64(snap.totalPot+snap.toCall) * 100
|
||||||
}
|
}
|
||||||
|
|
||||||
community := "—"
|
// Compute hand category from current best 5-card hand.
|
||||||
if len(g.Community) > 0 {
|
handCategory := ""
|
||||||
community = renderCards(g.Community)
|
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{
|
return holdemTipContext{
|
||||||
PlayerName: p.DisplayName,
|
PlayerName: snap.playerName,
|
||||||
Hole: [2]string{renderCard(p.Hole[0]), renderCard(p.Hole[1])},
|
Hole: snap.holeStr,
|
||||||
Community: community,
|
Community: snap.communityS,
|
||||||
Equity: eq,
|
Equity: eq,
|
||||||
SPR: spr,
|
SPR: spr,
|
||||||
PotOddsPct: potOdds,
|
PotOddsPct: potOdds,
|
||||||
ToCall: toCall,
|
ToCall: snap.toCall,
|
||||||
Pot: totalPot,
|
Pot: snap.totalPot,
|
||||||
Stack: p.Stack,
|
Stack: snap.stack,
|
||||||
Street: g.Street,
|
Street: snap.street,
|
||||||
Position: g.positionLabel(playerIdx),
|
Position: snap.position,
|
||||||
NumActive: numActive,
|
NumActive: snap.numActive,
|
||||||
|
HandCategory: handCategory,
|
||||||
|
Draw: draw,
|
||||||
|
HeadsUp: snap.headsUp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +210,7 @@ func generateTip(ctx holdemTipContext) string {
|
|||||||
if host != "" && model != "" {
|
if host != "" && model != "" {
|
||||||
tip, err := generateLLMTip(host, model, ctx)
|
tip, err := generateLLMTip(host, model, ctx)
|
||||||
if err != nil {
|
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 != "" {
|
} else if tip != "" {
|
||||||
return "💡 " + tip
|
return "💡 " + tip
|
||||||
}
|
}
|
||||||
@@ -138,32 +224,62 @@ type chatMessage struct {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type chatRequest struct {
|
type ollamaChatRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Messages []chatMessage `json:"messages"`
|
Messages []chatMessage `json:"messages"`
|
||||||
Stream bool `json:"stream"`
|
Stream bool `json:"stream"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type chatChoice struct {
|
type ollamaChatResponse struct {
|
||||||
Message chatMessage `json:"message"`
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type chatResponse struct {
|
var thinkTagRe = regexp.MustCompile(`(?s)<think>.*?</think>`)
|
||||||
Choices []chatChoice `json:"choices"`
|
|
||||||
|
// 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) {
|
// buildTipSystemPrompt returns the system prompt for poker tip generation.
|
||||||
systemPrompt := `You are a concise Texas Hold'em coach embedded in a Matrix chat bot.
|
func buildTipSystemPrompt() string {
|
||||||
You will be given structured game context including pre-computed equity.
|
return `You are a Texas Hold'em coach giving advice to a single player via private message.
|
||||||
Give exactly 2-4 sentences of actionable advice for the player's current decision.
|
You will receive structured game context. Reason through it in this order:
|
||||||
Lead with the equity vs pot odds relationship when a bet is to call.
|
|
||||||
Be direct. No preamble. No praise. No "great hand" filler.`
|
|
||||||
|
|
||||||
var userPrompt strings.Builder
|
1. What type of hand do I have — made hand, drawing hand, or air?
|
||||||
userPrompt.WriteString(fmt.Sprintf("Street: %s\n", ctx.Street.String()))
|
2. If drawing: how many outs, and do pot odds justify continuing?
|
||||||
userPrompt.WriteString(fmt.Sprintf("Your hand: %s %s\n", ctx.Hole[0], ctx.Hole[1]))
|
3. If made hand: is it strong enough to bet for value, or weak enough to just pot control?
|
||||||
userPrompt.WriteString(fmt.Sprintf("Board: %s\n", ctx.Community))
|
4. Does position affect what I should do here?
|
||||||
userPrompt.WriteString(fmt.Sprintf("Equity vs %d opponents: Win %.0f%% | Tie %.0f%% | Loss %.0f%%\n",
|
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))
|
ctx.NumActive-1, ctx.Equity.Win*100, ctx.Equity.Tie*100, ctx.Equity.Loss*100))
|
||||||
|
|
||||||
if ctx.ToCall > 0 {
|
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 {
|
if ctx.Equity.Win*100 < ctx.PotOddsPct {
|
||||||
exceeds = "falls short of"
|
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 {
|
} 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",
|
headsUp := "no"
|
||||||
ctx.SPR, ctx.Position, ctx.NumActive))
|
if ctx.HeadsUp {
|
||||||
userPrompt.WriteString("\nWhat should I consider for my decision?")
|
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,
|
Model: model,
|
||||||
Messages: []chatMessage{
|
Messages: []chatMessage{
|
||||||
{Role: "system", Content: systemPrompt},
|
{Role: "system", Content: buildTipSystemPrompt()},
|
||||||
{Role: "user", Content: userPrompt.String()},
|
{Role: "user", Content: buildTipUserPrompt(ctx)},
|
||||||
},
|
},
|
||||||
Stream: false,
|
Stream: false,
|
||||||
}
|
}
|
||||||
@@ -194,7 +317,7 @@ Be direct. No preamble. No praise. No "great hand" filler.`
|
|||||||
return "", fmt.Errorf("marshal: %w", err)
|
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))
|
resp, err := holdemTipsClient.Post(url, "application/json", bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("request: %w", err)
|
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))
|
return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
|
||||||
}
|
}
|
||||||
|
|
||||||
var chatResp chatResponse
|
var ollamaResp ollamaChatResponse
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
|
||||||
return "", fmt.Errorf("decode: %w", err)
|
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")
|
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
|
return tip, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,18 +350,34 @@ func generateRulesTip(ctx holdemTipContext) string {
|
|||||||
switch {
|
switch {
|
||||||
case equity > 0.80:
|
case equity > 0.80:
|
||||||
tip = fmt.Sprintf("Strong hand (%.0f%% equity). Size your bet for value — you want to get paid off.", equity*100)
|
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:
|
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)
|
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:
|
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:
|
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)
|
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:
|
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:
|
case ctx.SPR < 1:
|
||||||
tip = "Shallow stack (SPR < 1) — commit or fold. No room to maneuver."
|
tip = "Shallow stack (SPR < 1) — commit or fold. No room to maneuver."
|
||||||
|
|
||||||
case ctx.SPR > 10 && ctx.Street == StreetPreFlop:
|
case ctx.SPR > 10 && ctx.Street == StreetPreFlop:
|
||||||
tip = "Deep stacked preflop — implied odds outweigh raw equity. Speculative hands gain value."
|
tip = "Deep stacked preflop — implied odds outweigh raw equity. Speculative hands gain value."
|
||||||
|
|
||||||
default:
|
default:
|
||||||
tip = fmt.Sprintf("%.0f%% equity. Evaluate your position and pot odds before acting.", equity*100)
|
tip = fmt.Sprintf("%.0f%% equity. Evaluate your position and pot odds before acting.", equity*100)
|
||||||
}
|
}
|
||||||
|
|||||||
324
internal/plugin/holdem_tips_test.go
Normal file
324
internal/plugin/holdem_tips_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -65,14 +65,15 @@ func (p *HowAmIPlugin) OnMessage(ctx MessageContext) error {
|
|||||||
slog.Error("howami: send thinking", "err", err)
|
slog.Error("howami: send thinking", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
profile := p.gatherProfile(target)
|
go func() {
|
||||||
|
profile := p.gatherProfile(target)
|
||||||
|
|
||||||
botName := os.Getenv("BOT_DISPLAY_NAME")
|
botName := os.Getenv("BOT_DISPLAY_NAME")
|
||||||
if botName == "" {
|
if botName == "" {
|
||||||
botName = "GogoBee"
|
botName = "GogoBee"
|
||||||
}
|
}
|
||||||
prompt := fmt.Sprintf(
|
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.
|
`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
|
User: %s
|
||||||
|
|
||||||
@@ -80,16 +81,20 @@ Profile data:
|
|||||||
%s
|
%s
|
||||||
|
|
||||||
Write the roast now. Do not include any preamble or explanation, just the roast text.`,
|
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)
|
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("howami: ollama call", "err", err)
|
slog.Error("howami: ollama call", "err", err)
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate profile. LLM might be offline.")
|
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 {
|
func (p *HowAmIPlugin) gatherProfile(userID id.UserID) string {
|
||||||
|
|||||||
@@ -326,8 +326,6 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
d := db.Get()
|
|
||||||
|
|
||||||
// Store classification
|
// Store classification
|
||||||
topicsJSON, _ := json.Marshal(result.Topics)
|
topicsJSON, _ := json.Marshal(result.Topics)
|
||||||
profanityInt := 0
|
profanityInt := 0
|
||||||
@@ -339,16 +337,13 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
|||||||
wotdInt = 1
|
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)
|
`INSERT INTO llm_classifications (user_id, room_id, message_text, sentiment, sentiment_score, topics, profanity, profanity_severity, insult_target, wotd_used, gratitude_target)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
string(item.UserID), string(item.RoomID), item.Body,
|
string(item.UserID), string(item.RoomID), item.Body,
|
||||||
result.Sentiment, result.SentimentScore, string(topicsJSON),
|
result.Sentiment, result.SentimentScore, string(topicsJSON),
|
||||||
profanityInt, result.ProfanitySeverity, result.InsultTarget, wotdInt, result.GratitudeTarget,
|
profanityInt, result.ProfanitySeverity, result.InsultTarget, wotdInt, result.GratitudeTarget,
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
slog.Error("llm: store classification", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aggregate sentiment stats
|
// Aggregate sentiment stats
|
||||||
sentimentCol := "neutral"
|
sentimentCol := "neutral"
|
||||||
@@ -360,7 +355,7 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
|||||||
if validSentiments[result.Sentiment] {
|
if validSentiments[result.Sentiment] {
|
||||||
sentimentCol = result.Sentiment
|
sentimentCol = result.Sentiment
|
||||||
}
|
}
|
||||||
_, _ = d.Exec(
|
db.Exec("llm: update user sentiment stats",
|
||||||
fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
`INSERT INTO sentiment_stats (user_id, %s, total_score) VALUES (?, 1, ?)
|
`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 + ?`,
|
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
|
// Aggregate room sentiment stats
|
||||||
_, _ = d.Exec(
|
db.Exec("llm: update room sentiment stats",
|
||||||
fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
`INSERT INTO room_sentiment_stats (room_id, %s, total_score) VALUES (?, 1, ?)
|
`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 + ?`,
|
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:
|
case 3:
|
||||||
scorchInc = 1
|
scorchInc = 1
|
||||||
}
|
}
|
||||||
_, _ = d.Exec(
|
db.Exec("llm: track profanity",
|
||||||
`INSERT INTO potty_mouth (user_id, count, mild, moderate, scorching) VALUES (?, 1, ?, ?, ?)
|
`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 + ?`,
|
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,
|
string(item.UserID), mildInc, modInc, scorchInc, mildInc, modInc, scorchInc,
|
||||||
@@ -417,12 +412,12 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
|||||||
|
|
||||||
// Track insults
|
// Track insults
|
||||||
if result.InsultTarget != "" {
|
if result.InsultTarget != "" {
|
||||||
_, _ = d.Exec(
|
db.Exec("llm: track insult sender",
|
||||||
`INSERT INTO insult_log (user_id, times_insulting) VALUES (?, 1)
|
`INSERT INTO insult_log (user_id, times_insulting) VALUES (?, 1)
|
||||||
ON CONFLICT(user_id) DO UPDATE SET times_insulting = times_insulting + 1`,
|
ON CONFLICT(user_id) DO UPDATE SET times_insulting = times_insulting + 1`,
|
||||||
string(item.UserID),
|
string(item.UserID),
|
||||||
)
|
)
|
||||||
_, _ = d.Exec(
|
db.Exec("llm: track insult target",
|
||||||
`INSERT INTO insult_log (user_id, times_insulted) VALUES (?, 1)
|
`INSERT INTO insult_log (user_id, times_insulted) VALUES (?, 1)
|
||||||
ON CONFLICT(user_id) DO UPDATE SET times_insulted = times_insulted + 1`,
|
ON CONFLICT(user_id) DO UPDATE SET times_insulted = times_insulted + 1`,
|
||||||
result.InsultTarget,
|
result.InsultTarget,
|
||||||
|
|||||||
@@ -48,16 +48,24 @@ func (p *LookupPlugin) Init() error { return nil }
|
|||||||
func (p *LookupPlugin) OnReaction(_ ReactionContext) error { return nil }
|
func (p *LookupPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
func (p *LookupPlugin) OnMessage(ctx MessageContext) error {
|
func (p *LookupPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
var handler func(MessageContext) error
|
||||||
switch {
|
switch {
|
||||||
case p.IsCommand(ctx.Body, "wiki"):
|
case p.IsCommand(ctx.Body, "wiki"):
|
||||||
return p.handleWiki(ctx)
|
handler = p.handleWiki
|
||||||
case p.IsCommand(ctx.Body, "define"):
|
case p.IsCommand(ctx.Body, "define"):
|
||||||
return p.handleDefine(ctx)
|
handler = p.handleDefine
|
||||||
case p.IsCommand(ctx.Body, "urban"):
|
case p.IsCommand(ctx.Body, "urban"):
|
||||||
return p.handleUrban(ctx)
|
handler = p.handleUrban
|
||||||
case p.IsCommand(ctx.Body, "translate"):
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,14 +274,11 @@ func (p *LookupPlugin) handleUrban(ctx MessageContext) error {
|
|||||||
msg := sb.String()
|
msg := sb.String()
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
_, err = d.Exec(
|
db.Exec("lookup: urban cache",
|
||||||
`INSERT INTO urban_cache (term, data, cached_at) VALUES (?, ?, ?)
|
`INSERT INTO urban_cache (term, data, cached_at) VALUES (?, ?, ?)
|
||||||
ON CONFLICT(term) DO UPDATE SET data = ?, cached_at = ?`,
|
ON CONFLICT(term) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
termLower, msg, now, msg, now,
|
termLower, msg, now, msg, now,
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
slog.Error("lookup: urban cache", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,15 +68,12 @@ func (p *MarkovPlugin) collectMessage(userID id.UserID, text string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cap at 10,000 messages per user — delete oldest excess
|
// 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 (
|
`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
|
SELECT id FROM markov_corpus WHERE user_id = ? ORDER BY id DESC LIMIT 10000
|
||||||
)`,
|
)`,
|
||||||
string(userID), string(userID),
|
string(userID), string(userID),
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
slog.Error("markov: prune corpus", "err", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *MarkovPlugin) handleMarkov(ctx MessageContext) error {
|
func (p *MarkovPlugin) handleMarkov(ctx MessageContext) error {
|
||||||
|
|||||||
@@ -240,36 +240,44 @@ func (p *MilkCartonPlugin) getMissingMembers() []missingMember {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var result []missingMember
|
type candidate struct {
|
||||||
|
userID, lastDate string
|
||||||
|
}
|
||||||
|
var candidates []candidate
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var userID, lastDate string
|
var c candidate
|
||||||
if err := rows.Scan(&userID, &lastDate); err != nil {
|
if err := rows.Scan(&c.userID, &c.lastDate); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
candidates = append(candidates, c)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
var result []missingMember
|
||||||
|
for _, c := range candidates {
|
||||||
// Skip excluded users
|
// Skip excluded users
|
||||||
if p.excludeUsers[userID] {
|
if p.excludeUsers[c.userID] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip bots (our own user ID)
|
// Skip bots (our own user ID)
|
||||||
if id.UserID(userID) == p.Client.UserID {
|
if id.UserID(c.userID) == p.Client.UserID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip users with active away/afk status
|
// Skip users with active away/afk status
|
||||||
var awayStatus int
|
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 {
|
if awayStatus == 1 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
lastTime, err := time.Parse("2006-01-02", lastDate)
|
lastTime, err := time.Parse("2006-01-02", c.lastDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
days := int(now.Sub(lastTime).Hours() / 24)
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -860,15 +860,11 @@ func (p *ModerationPlugin) notifyAdmin(text string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *ModerationPlugin) logAction(userID id.UserID, roomID id.RoomID, action, reason, takenBy string) {
|
func (p *ModerationPlugin) logAction(userID id.UserID, roomID id.RoomID, action, reason, takenBy string) {
|
||||||
d := db.Get()
|
db.Exec("moderation: log action",
|
||||||
_, err := d.Exec(
|
|
||||||
`INSERT INTO mod_actions (user_id, room_id, action, reason, taken_at, taken_by)
|
`INSERT INTO mod_actions (user_id, room_id, action, reason, taken_at, taken_by)
|
||||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?)`,
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?)`,
|
||||||
string(userID), string(roomID), action, reason, takenBy,
|
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 {
|
func modTruncate(s string, max int) string {
|
||||||
|
|||||||
@@ -119,18 +119,26 @@ func (p *MoviesPlugin) Init() error { return nil }
|
|||||||
func (p *MoviesPlugin) OnReaction(_ ReactionContext) error { return nil }
|
func (p *MoviesPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
func (p *MoviesPlugin) OnMessage(ctx MessageContext) error {
|
func (p *MoviesPlugin) OnMessage(ctx MessageContext) error {
|
||||||
if p.IsCommand(ctx.Body, "movie") {
|
var handler func(MessageContext) error
|
||||||
return p.handleMovie(ctx)
|
switch {
|
||||||
}
|
case p.IsCommand(ctx.Body, "movie"):
|
||||||
if p.IsCommand(ctx.Body, "tv") {
|
handler = p.handleMovie
|
||||||
return p.handleTV(ctx)
|
case p.IsCommand(ctx.Body, "tv"):
|
||||||
}
|
handler = p.handleTV
|
||||||
if p.IsCommand(ctx.Body, "upcoming") {
|
case p.IsCommand(ctx.Body, "upcoming"):
|
||||||
args := p.GetArgs(ctx.Body, "upcoming")
|
args := p.GetArgs(ctx.Body, "upcoming")
|
||||||
if strings.ToLower(strings.TrimSpace(args)) == "movies" {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,14 +229,11 @@ func (p *MoviesPlugin) fetchMovieDetail(movieID int) (*tmdbMovieDetail, error) {
|
|||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
data, _ := json.Marshal(detail)
|
data, _ := json.Marshal(detail)
|
||||||
_, err = d.Exec(
|
db.Exec("movies: cache write",
|
||||||
`INSERT INTO movie_cache (tmdb_id, data, cached_at) VALUES (?, ?, ?)
|
`INSERT INTO movie_cache (tmdb_id, data, cached_at) VALUES (?, ?, ?)
|
||||||
ON CONFLICT(tmdb_id) DO UPDATE SET data = ?, cached_at = ?`,
|
ON CONFLICT(tmdb_id) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
movieID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
movieID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
slog.Error("movies: cache write", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &detail, nil
|
return &detail, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package plugin
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -96,6 +98,20 @@ func (b *Base) IsAdmin(userID id.UserID) bool {
|
|||||||
return false
|
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
|
// 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
|
// 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.
|
// 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)
|
slog.Error("space_groups: begin tx", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
// Delete only rooms we have fresh data for (preserve entries for rooms that failed to fetch)
|
// Delete only rooms we have fresh data for (preserve entries for rooms that failed to fetch)
|
||||||
for r := range newRoomToGroup {
|
for r := range newRoomToGroup {
|
||||||
if _, err := tx.Exec(`DELETE FROM space_groups WHERE room_id = ?`, string(r)); err != nil {
|
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)
|
slog.Error("space_groups: delete row", "room", r, "err", err)
|
||||||
tx.Rollback()
|
|
||||||
return
|
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)`,
|
if _, err := tx.Exec(`INSERT INTO space_groups (room_id, group_id, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)`,
|
||||||
string(r), gid); err != nil {
|
string(r), gid); err != nil {
|
||||||
slog.Error("space_groups: insert row", "room", r, "err", err)
|
slog.Error("space_groups: insert row", "room", r, "err", err)
|
||||||
tx.Rollback()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -453,12 +468,64 @@ func (b *Base) ResolveUser(input string, roomIDs ...id.RoomID) (id.UserID, bool)
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendMessage sends a plain text message to a room.
|
// simpleMarkdownToHTML converts the limited Markdown subset used in bot messages
|
||||||
func (b *Base) SendMessage(roomID id.RoomID, text string) error {
|
// (**bold**, _italic_, `code`, newlines) to Matrix-compatible HTML.
|
||||||
content := &event.MessageEventContent{
|
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,
|
MsgType: event.MsgText,
|
||||||
Body: text,
|
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)
|
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to send message", "room", roomID, "err", err)
|
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
|
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) {
|
func (b *Base) SendMessageID(roomID id.RoomID, text string) (id.EventID, error) {
|
||||||
content := &event.MessageEventContent{
|
content := textContent(text)
|
||||||
MsgType: event.MsgText,
|
|
||||||
Body: text,
|
|
||||||
}
|
|
||||||
resp, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
resp, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to send message", "room", roomID, "err", err)
|
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.
|
// SendThread sends a message in a thread rooted at threadID.
|
||||||
func (b *Base) SendThread(roomID id.RoomID, threadID id.EventID, text string) error {
|
func (b *Base) SendThread(roomID id.RoomID, threadID id.EventID, text string) error {
|
||||||
content := &event.MessageEventContent{
|
content := textContent(text)
|
||||||
MsgType: event.MsgText,
|
content.RelatesTo = &event.RelatesTo{
|
||||||
Body: text,
|
Type: event.RelThread,
|
||||||
RelatesTo: &event.RelatesTo{
|
EventID: threadID,
|
||||||
Type: event.RelThread,
|
InReplyTo: &event.InReplyTo{
|
||||||
EventID: threadID,
|
EventID: threadID,
|
||||||
InReplyTo: &event.InReplyTo{
|
|
||||||
EventID: threadID,
|
|
||||||
},
|
|
||||||
IsFallingBack: true,
|
|
||||||
},
|
},
|
||||||
|
IsFallingBack: true,
|
||||||
}
|
}
|
||||||
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||||
if err != nil {
|
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.
|
// SendNotice sends an m.notice message to a room.
|
||||||
func (b *Base) SendNotice(roomID id.RoomID, text string) error {
|
func (b *Base) SendNotice(roomID id.RoomID, text string) error {
|
||||||
content := &event.MessageEventContent{
|
content := textContent(text)
|
||||||
MsgType: event.MsgNotice,
|
content.MsgType = event.MsgNotice
|
||||||
Body: text,
|
|
||||||
}
|
|
||||||
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendReply sends a reply to a specific event.
|
// SendReply sends a reply to a specific event.
|
||||||
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
|
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
|
||||||
content := &event.MessageEventContent{
|
content := textContent(text)
|
||||||
MsgType: event.MsgText,
|
content.RelatesTo = &event.RelatesTo{
|
||||||
Body: text,
|
InReplyTo: &event.InReplyTo{
|
||||||
RelatesTo: &event.RelatesTo{
|
EventID: eventID,
|
||||||
InReplyTo: &event.InReplyTo{
|
|
||||||
EventID: eventID,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
_, 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.
|
// 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{
|
content := &event.MessageEventContent{
|
||||||
MsgType: event.MsgText,
|
MsgType: event.MsgText,
|
||||||
Body: plain,
|
Body: plain,
|
||||||
Format: event.FormatHTML,
|
Format: event.FormatHTML,
|
||||||
FormattedBody: html,
|
FormattedBody: htmlBody,
|
||||||
}
|
}
|
||||||
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -55,14 +55,10 @@ func (p *ReactionsPlugin) OnReaction(ctx ReactionContext) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
d := db.Get()
|
db.Exec("reactions: log reaction",
|
||||||
_, err = d.Exec(
|
|
||||||
`INSERT INTO reaction_log (room_id, event_id, sender, target_user, emoji) VALUES (?, ?, ?, ?, ?)`,
|
`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,
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,22 +174,33 @@ func FirePendingReminders(client *mautrix.Client) {
|
|||||||
|
|
||||||
base := NewBase(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() {
|
for rows.Next() {
|
||||||
var reminderID, userID, roomID, message string
|
var r pendingReminder
|
||||||
if err := rows.Scan(&reminderID, &userID, &roomID, &message); err != nil {
|
if err := rows.Scan(&r.ID, &r.UserID, &r.RoomID, &r.Message); err != nil {
|
||||||
slog.Error("reminders: scan row", "err", err)
|
slog.Error("reminders: scan row", "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
pending = append(pending, r)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
msg := fmt.Sprintf("⏰ Reminder for %s: %s", userID, message)
|
for _, r := range pending {
|
||||||
if err := base.SendMessage(id.RoomID(roomID), msg); err != nil {
|
// Mark fired BEFORE sending so a crash doesn't re-fire on restart.
|
||||||
slog.Error("reminders: send reminder", "err", err, "id", reminderID)
|
_, 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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := db.Get().Exec(`UPDATE reminders SET fired = 1 WHERE id = ?`, reminderID)
|
msg := fmt.Sprintf("⏰ Reminder for %s: %s", r.UserID, r.Message)
|
||||||
if err != nil {
|
if err := base.SendMessage(id.RoomID(r.RoomID), msg); err != nil {
|
||||||
slog.Error("reminders: mark fired", "err", err, "id", reminderID)
|
slog.Error("reminders: send reminder", "err", err, "id", r.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,12 +87,20 @@ func (p *RetroPlugin) Init() error { return nil }
|
|||||||
func (p *RetroPlugin) OnReaction(_ ReactionContext) error { return nil }
|
func (p *RetroPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
func (p *RetroPlugin) OnMessage(ctx MessageContext) error {
|
func (p *RetroPlugin) OnMessage(ctx MessageContext) error {
|
||||||
if p.IsCommand(ctx.Body, "game") {
|
var query string
|
||||||
return p.handleSearch(ctx, p.GetArgs(ctx.Body, "game"))
|
switch {
|
||||||
}
|
case p.IsCommand(ctx.Body, "game"):
|
||||||
if p.IsCommand(ctx.Body, "retro") {
|
query = p.GetArgs(ctx.Body, "game")
|
||||||
return p.handleSearch(ctx, p.GetArgs(ctx.Body, "retro"))
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,14 +195,11 @@ func (p *RetroPlugin) fetchGames(query string) (*retroCacheEntry, error) {
|
|||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
data, _ := json.Marshal(entry)
|
data, _ := json.Marshal(entry)
|
||||||
_, err = d.Exec(
|
db.Exec("retro: cache write",
|
||||||
`INSERT INTO retro_cache (search_term, data, cached_at) VALUES (?, ?, ?)
|
`INSERT INTO retro_cache (search_term, data, cached_at) VALUES (?, ?, ?)
|
||||||
ON CONFLICT(search_term) DO UPDATE SET data = ?, cached_at = ?`,
|
ON CONFLICT(search_term) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
cacheKey, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
cacheKey, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
slog.Error("retro: cache write", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry, nil
|
return entry, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: "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: "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: "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") {
|
if p.IsCommand(ctx.Body, "personality") {
|
||||||
return p.handlePersonality(ctx)
|
return p.handlePersonality(ctx)
|
||||||
}
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "superstatsexplusalpha") {
|
||||||
|
return p.handleSuperStats(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
// Skip tracking for bot commands
|
// Skip tracking for bot commands
|
||||||
if ctx.IsCommand {
|
if ctx.IsCommand {
|
||||||
@@ -316,3 +320,238 @@ func (p *StatsPlugin) handlePersonality(ctx MessageContext) error {
|
|||||||
)
|
)
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,7 +76,12 @@ func (p *StocksPlugin) OnReaction(_ ReactionContext) error { return nil }
|
|||||||
|
|
||||||
func (p *StocksPlugin) OnMessage(ctx MessageContext) error {
|
func (p *StocksPlugin) OnMessage(ctx MessageContext) error {
|
||||||
if p.IsCommand(ctx.Body, "stock") {
|
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") {
|
if p.IsCommand(ctx.Body, "stockwatch") {
|
||||||
return p.handleStockwatch(ctx)
|
return p.handleStockwatch(ctx)
|
||||||
@@ -152,14 +157,11 @@ func (p *StocksPlugin) fetchStock(ticker string) (*stockCacheEntry, error) {
|
|||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
data, _ := json.Marshal(entry)
|
data, _ := json.Marshal(entry)
|
||||||
_, err = d.Exec(
|
db.Exec("stocks: cache write",
|
||||||
`INSERT INTO stocks_cache (ticker, data, cached_at) VALUES (?, ?, ?)
|
`INSERT INTO stocks_cache (ticker, data, cached_at) VALUES (?, ?, ?)
|
||||||
ON CONFLICT(ticker) DO UPDATE SET data = ?, cached_at = ?`,
|
ON CONFLICT(ticker) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
ticker, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
ticker, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
slog.Error("stocks: cache write", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry, nil
|
return entry, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,32 +59,24 @@ func (p *StreaksPlugin) OnMessage(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *StreaksPlugin) trackDailyActivity(ctx MessageContext) {
|
func (p *StreaksPlugin) trackDailyActivity(ctx MessageContext) {
|
||||||
d := db.Get()
|
|
||||||
today := time.Now().UTC().Format("2006-01-02")
|
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)
|
`INSERT INTO daily_activity (user_id, date, message_count) VALUES (?, ?, 1)
|
||||||
ON CONFLICT(user_id, date) DO UPDATE SET message_count = message_count + 1`,
|
ON CONFLICT(user_id, date) DO UPDATE SET message_count = message_count + 1`,
|
||||||
string(ctx.Sender), today,
|
string(ctx.Sender), today,
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
slog.Error("streaks: track daily activity", "err", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *StreaksPlugin) trackFirstPoster(ctx MessageContext) {
|
func (p *StreaksPlugin) trackFirstPoster(ctx MessageContext) {
|
||||||
d := db.Get()
|
|
||||||
today := time.Now().UTC().Format("2006-01-02")
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
now := time.Now().UTC().Unix()
|
now := time.Now().UTC().Unix()
|
||||||
|
|
||||||
// INSERT OR IGNORE — only the first poster for this room+date wins
|
// 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 (?, ?, ?, ?)`,
|
`INSERT OR IGNORE INTO daily_first (room_id, date, user_id, timestamp) VALUES (?, ?, ?, ?)`,
|
||||||
string(ctx.RoomID), today, string(ctx.Sender), now,
|
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 {
|
func (p *StreaksPlugin) handleStreak(ctx MessageContext) error {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -583,7 +581,7 @@ func (p *UnoPlugin) handleChallenge(ctx MessageContext, announceRoom id.RoomID,
|
|||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
// Room announcement
|
// Room announcement
|
||||||
playerName := p.unoDisplayName(ctx.Sender)
|
playerName := p.DisplayName(ctx.Sender)
|
||||||
botName := unoBotName()
|
botName := unoBotName()
|
||||||
modeTag := ""
|
modeTag := ""
|
||||||
startComment := pickCommentary("start")
|
startComment := pickCommentary("start")
|
||||||
@@ -1741,7 +1739,7 @@ func (p *UnoPlugin) playerWins(game *unoGame) error {
|
|||||||
p.euro.Credit(game.playerID, totalPayout, "uno_win")
|
p.euro.Credit(game.playerID, totalPayout, "uno_win")
|
||||||
|
|
||||||
newPot := p.getPot()
|
newPot := p.getPot()
|
||||||
playerName := p.unoDisplayName(game.playerID)
|
playerName := p.DisplayName(game.playerID)
|
||||||
|
|
||||||
p.SendMessage(game.dmRoomID, "🎉 **Uno out! You win!**")
|
p.SendMessage(game.dmRoomID, "🎉 **Uno out! You win!**")
|
||||||
|
|
||||||
@@ -1772,7 +1770,7 @@ func (p *UnoPlugin) botWins(game *unoGame) error {
|
|||||||
potBefore := p.getPot()
|
potBefore := p.getPot()
|
||||||
p.addToPot(game.wager)
|
p.addToPot(game.wager)
|
||||||
newPot := p.getPot()
|
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.dmRoomID, "💀 **"+unoBotName()+" wins.** Better luck next time.")
|
||||||
p.SendMessage(game.roomID, fmt.Sprintf(
|
p.SendMessage(game.roomID, fmt.Sprintf(
|
||||||
@@ -1796,7 +1794,7 @@ func (p *UnoPlugin) forfeitGame(game *unoGame, timeout bool) error {
|
|||||||
|
|
||||||
potBefore := p.getPot()
|
potBefore := p.getPot()
|
||||||
p.addToPot(game.wager)
|
p.addToPot(game.wager)
|
||||||
playerName := p.unoDisplayName(game.playerID)
|
playerName := p.DisplayName(game.playerID)
|
||||||
|
|
||||||
if timeout {
|
if timeout {
|
||||||
p.SendMessage(game.dmRoomID,
|
p.SendMessage(game.dmRoomID,
|
||||||
@@ -1881,23 +1879,13 @@ func (p *UnoPlugin) cleanupGame(game *unoGame) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *UnoPlugin) recordGame(game *unoGame, result string, potBefore float64) {
|
func (p *UnoPlugin) recordGame(game *unoGame, result string, potBefore float64) {
|
||||||
d := db.Get()
|
db.Exec("uno: record game",
|
||||||
_, err := d.Exec(
|
|
||||||
`INSERT INTO uno_games (player_id, wager, result, pot_before, pot_after, turns, started_at, ended_at)
|
`INSERT INTO uno_games (player_id, wager, result, pot_before, pot_after, turns, started_at, ended_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
string(game.playerID), game.wager, result, potBefore, p.getPot(),
|
string(game.playerID), game.wager, result, potBefore, p.getPot(),
|
||||||
game.turns, game.startedAt.UTC().Format("2006-01-02 15:04:05"),
|
game.turns, game.startedAt.UTC().Format("2006-01-02 15:04:05"),
|
||||||
time.Now().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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ func (p *UnoPlugin) handleMultiStart(ctx MessageContext, amountStr string, noMer
|
|||||||
p.lobbies[ctx.RoomID] = lobby
|
p.lobbies[ctx.RoomID] = lobby
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
creatorName := p.unoDisplayName(ctx.Sender)
|
creatorName := p.DisplayName(ctx.Sender)
|
||||||
modeTag := ""
|
modeTag := ""
|
||||||
if noMercy {
|
if noMercy {
|
||||||
modeTag = " 🔥 NO MERCY"
|
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))
|
sb.WriteString(fmt.Sprintf("🃏 **UNO Lobby**%s — Ante: €%d\nPlayers (%d/4):\n", lobbyModeTag, int(lobby.ante), count))
|
||||||
for i, uid := range lobby.players {
|
for i, uid := range lobby.players {
|
||||||
name := p.unoDisplayName(uid)
|
name := p.DisplayName(uid)
|
||||||
label := ""
|
label := ""
|
||||||
if uid == lobby.creator {
|
if uid == lobby.creator {
|
||||||
label = " (host)"
|
label = " (host)"
|
||||||
@@ -403,7 +403,7 @@ func (p *UnoPlugin) handleMultiLeave(ctx MessageContext) error {
|
|||||||
|
|
||||||
// Refund the leaving player
|
// Refund the leaving player
|
||||||
p.euro.Credit(ctx.Sender, lobby.ante, "uno_multi_refund")
|
p.euro.Credit(ctx.Sender, lobby.ante, "uno_multi_refund")
|
||||||
name := p.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)))
|
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 {
|
for _, u := range players {
|
||||||
p.euro.Credit(u, ante, "uno_multi_refund")
|
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})
|
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)))
|
sb.WriteString(fmt.Sprintf("🃏 **Multiplayer UNO!**%s Pot: €%d\n\nPlayers:\n", modeTag, int(ante)*len(players)))
|
||||||
for i, pl := range game.players {
|
for i, pl := range game.players {
|
||||||
name := p.unoDisplayName(pl.userID)
|
name := p.DisplayName(pl.userID)
|
||||||
if pl.isBot {
|
if pl.isBot {
|
||||||
name = bn
|
name = bn
|
||||||
}
|
}
|
||||||
@@ -651,6 +651,13 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
|
|||||||
|
|
||||||
player := game.currentPlayer()
|
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 {
|
if player.isBot {
|
||||||
botTurnsInRow++
|
botTurnsInRow++
|
||||||
if botTurnsInRow > 10 {
|
if botTurnsInRow > 10 {
|
||||||
@@ -676,7 +683,7 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
|
|||||||
// No Mercy stacking: check if player must absorb
|
// No Mercy stacking: check if player must absorb
|
||||||
if game.noMercy && game.stackMinValue > 0 {
|
if game.noMercy && game.stackMinValue > 0 {
|
||||||
if !hasStackableCard(player.hand, game.topColor, game.stackMinValue) {
|
if !hasStackableCard(player.hand, game.topColor, game.stackMinValue) {
|
||||||
name := p.unoDisplayName(player.userID)
|
name := p.DisplayName(player.userID)
|
||||||
drawn := game.draw(game.stackTotal)
|
drawn := game.draw(game.stackTotal)
|
||||||
player.hand = append(player.hand, drawn...)
|
player.hand = append(player.hand, drawn...)
|
||||||
p.SendMessage(player.dmRoomID, fmt.Sprintf("💥 No stackable card! You draw %d cards.\n%s",
|
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 {
|
if !player.active {
|
||||||
continue // mercy-killed
|
continue // mercy-killed
|
||||||
}
|
}
|
||||||
name := p.unoDisplayName(player.userID)
|
name := p.DisplayName(player.userID)
|
||||||
if len(allDrawn) == 0 {
|
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(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.")
|
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
|
// Classic: draw 1
|
||||||
drawn := game.draw(1)
|
drawn := game.draw(1)
|
||||||
if len(drawn) == 0 {
|
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(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.")
|
p.SendMessage(player.dmRoomID, "No playable cards and deck is empty. Turn passes.")
|
||||||
game.currentIdx = game.nextActiveIdx()
|
game.currentIdx = game.nextActiveIdx()
|
||||||
@@ -784,7 +791,7 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
name := p.unoDisplayName(player.userID)
|
name := p.DisplayName(player.userID)
|
||||||
p.SendMessage(player.dmRoomID,
|
p.SendMessage(player.dmRoomID,
|
||||||
fmt.Sprintf("No playable cards — drew automatically: %s\nNot playable. Turn passes.", card.Display()))
|
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)))
|
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
|
// No Mercy stacking: accept
|
||||||
if game.noMercy && game.stackMinValue > 0 && (input == "accept" || input == "a") {
|
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)
|
drawn := game.draw(game.stackTotal)
|
||||||
player.hand = append(player.hand, drawn...)
|
player.hand = append(player.hand, drawn...)
|
||||||
p.SendMessage(player.dmRoomID, fmt.Sprintf("💥 You accept the stack and draw %d cards.\n%s",
|
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 {
|
func (p *UnoPlugin) handleMultiChallengeInput(game *unoMultiGame, player *unoMultiPlayer, input string) error {
|
||||||
switch input {
|
switch input {
|
||||||
case "challenge", "c":
|
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.SendMessage(game.roomID, fmt.Sprintf("⚡ %s challenges the Wild Draw Four!", name))
|
||||||
p.resolveWD4Challenge(game, true)
|
p.resolveWD4Challenge(game, true)
|
||||||
case "accept", "a":
|
case "accept", "a":
|
||||||
@@ -963,7 +970,7 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl
|
|||||||
player.hand = append(player.hand, drawn...)
|
player.hand = append(player.hand, drawn...)
|
||||||
player.calledUno = false
|
player.calledUno = false
|
||||||
p.SendMessage(player.dmRoomID, "⚠️ You forgot to call UNO! Draw 2 as penalty.")
|
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))
|
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s forgot to call UNO! 2 card penalty.", name))
|
||||||
if game.noMercy {
|
if game.noMercy {
|
||||||
if p.checkMultiMercyElimination(game, player) {
|
if p.checkMultiMercyElimination(game, player) {
|
||||||
@@ -1024,7 +1031,7 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl
|
|||||||
if pl == player || !pl.active {
|
if pl == player || !pl.active {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := p.unoDisplayName(pl.userID)
|
name := p.DisplayName(pl.userID)
|
||||||
if pl.isBot {
|
if pl.isBot {
|
||||||
name = unoBotName()
|
name = unoBotName()
|
||||||
}
|
}
|
||||||
@@ -1040,8 +1047,8 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl
|
|||||||
for _, pl := range game.players {
|
for _, pl := range game.players {
|
||||||
if pl != player && pl.active {
|
if pl != player && pl.active {
|
||||||
swapHandsMulti(player, pl)
|
swapHandsMulti(player, pl)
|
||||||
name := p.unoDisplayName(player.userID)
|
name := p.DisplayName(player.userID)
|
||||||
otherName := p.unoDisplayName(pl.userID)
|
otherName := p.DisplayName(pl.userID)
|
||||||
if pl.isBot {
|
if pl.isBot {
|
||||||
otherName = unoBotName()
|
otherName = unoBotName()
|
||||||
}
|
}
|
||||||
@@ -1068,7 +1075,7 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl
|
|||||||
|
|
||||||
// Check win (after discard all, after swap)
|
// Check win (after discard all, after swap)
|
||||||
if len(player.hand) == 0 {
|
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.SendMessage(game.roomID, fmt.Sprintf("🃏 %s plays: %s", name, card.Display()))
|
||||||
p.multiPlayerWins(game, player)
|
p.multiPlayerWins(game, player)
|
||||||
return nil
|
return nil
|
||||||
@@ -1104,7 +1111,7 @@ func (p *UnoPlugin) handleMultiColorChoice(game *unoMultiGame, player *unoMultiP
|
|||||||
|
|
||||||
// Check win
|
// Check win
|
||||||
if len(player.hand) == 0 {
|
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)",
|
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s plays: %s (chose %s %s)",
|
||||||
name, pendingCard.Display(), color.Emoji(), color))
|
name, pendingCard.Display(), color.Emoji(), color))
|
||||||
p.multiPlayerWins(game, player)
|
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
|
// No Mercy: Color Roulette — next player flips until chosen color
|
||||||
if game.noMercy && pendingCard != nil && pendingCard.Value == unoWildColorRoulette {
|
if game.noMercy && pendingCard != nil && pendingCard.Value == unoWildColorRoulette {
|
||||||
name := p.unoDisplayName(player.userID)
|
name := p.DisplayName(player.userID)
|
||||||
nextIdx := game.nextActiveIdx()
|
nextIdx := game.nextActiveIdx()
|
||||||
target := game.players[nextIdx]
|
target := game.players[nextIdx]
|
||||||
targetName := p.unoDisplayName(target.userID)
|
targetName := p.DisplayName(target.userID)
|
||||||
if target.isBot {
|
if target.isBot {
|
||||||
targetName = unoBotName()
|
targetName = unoBotName()
|
||||||
}
|
}
|
||||||
@@ -1157,7 +1164,7 @@ func (p *UnoPlugin) handleMultiPlayerDraw(game *unoMultiGame, player *unoMultiPl
|
|||||||
drawn := game.draw(1)
|
drawn := game.draw(1)
|
||||||
if len(drawn) == 0 {
|
if len(drawn) == 0 {
|
||||||
p.SendMessage(player.dmRoomID, "No cards left to draw! Turn passes.")
|
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.SendMessage(game.roomID, fmt.Sprintf("🃏 %s can't draw — deck is empty. Turn passes.", name))
|
||||||
p.advanceAndExecute(game)
|
p.advanceAndExecute(game)
|
||||||
return nil
|
return nil
|
||||||
@@ -1175,7 +1182,7 @@ func (p *UnoPlugin) handleMultiPlayerDraw(game *unoMultiGame, player *unoMultiPl
|
|||||||
return nil
|
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(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.SendMessage(game.roomID, fmt.Sprintf("🃏 %s draws a card. Turn passes. (%d cards)", name, len(player.hand)))
|
||||||
p.advanceAndExecute(game)
|
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 {
|
if len(allDrawn) == 0 {
|
||||||
p.SendMessage(player.dmRoomID, "No cards left to draw! Turn passes.")
|
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
|
game.phase = unoMultiPhasePlay
|
||||||
|
|
||||||
if input == "no" || input == "n" {
|
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(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.SendMessage(game.roomID, fmt.Sprintf("🃏 %s draws a card. Turn passes. (%d cards)", name, len(player.hand)))
|
||||||
p.advanceAndExecute(game)
|
p.advanceAndExecute(game)
|
||||||
@@ -1281,7 +1288,7 @@ func (p *UnoPlugin) handleMultiDrawnPlayable(game *unoMultiGame, player *unoMult
|
|||||||
game.topColor = drawnCard.Color
|
game.topColor = drawnCard.Color
|
||||||
|
|
||||||
if len(player.hand) == 0 {
|
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.SendMessage(game.roomID, fmt.Sprintf("🃏 %s plays: %s", name, drawnCard.Display()))
|
||||||
p.multiPlayerWins(game, player)
|
p.multiPlayerWins(game, player)
|
||||||
return nil
|
return nil
|
||||||
@@ -1311,7 +1318,7 @@ func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffec
|
|||||||
|
|
||||||
nextIdx := game.nextActiveIdx()
|
nextIdx := game.nextActiveIdx()
|
||||||
nextPlayer := game.players[nextIdx]
|
nextPlayer := game.players[nextIdx]
|
||||||
nextName := p.unoDisplayName(nextPlayer.userID)
|
nextName := p.DisplayName(nextPlayer.userID)
|
||||||
if nextPlayer.isBot {
|
if nextPlayer.isBot {
|
||||||
nextName = unoBotName()
|
nextName = unoBotName()
|
||||||
}
|
}
|
||||||
@@ -1329,7 +1336,7 @@ func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffec
|
|||||||
var skippedNames []string
|
var skippedNames []string
|
||||||
for _, pl := range game.players {
|
for _, pl := range game.players {
|
||||||
if pl != game.currentPlayer() && pl.active {
|
if pl != game.currentPlayer() && pl.active {
|
||||||
name := p.unoDisplayName(pl.userID)
|
name := p.DisplayName(pl.userID)
|
||||||
if pl.isBot {
|
if pl.isBot {
|
||||||
name = unoBotName()
|
name = unoBotName()
|
||||||
}
|
}
|
||||||
@@ -1407,7 +1414,7 @@ func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffec
|
|||||||
// After reverse, get next player in new direction
|
// After reverse, get next player in new direction
|
||||||
nextIdx = game.nextActiveIdx()
|
nextIdx = game.nextActiveIdx()
|
||||||
nextPlayer = game.players[nextIdx]
|
nextPlayer = game.players[nextIdx]
|
||||||
nextName = p.unoDisplayName(nextPlayer.userID)
|
nextName = p.DisplayName(nextPlayer.userID)
|
||||||
if nextPlayer.isBot {
|
if nextPlayer.isBot {
|
||||||
nextName = unoBotName()
|
nextName = unoBotName()
|
||||||
}
|
}
|
||||||
@@ -1468,7 +1475,7 @@ func writeEffectLines(sb *strings.Builder, eff cardEffectResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer, card unoCard) {
|
func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer, card unoCard) {
|
||||||
name := p.unoDisplayName(player.userID)
|
name := p.DisplayName(player.userID)
|
||||||
var roomMsg strings.Builder
|
var roomMsg strings.Builder
|
||||||
|
|
||||||
roomMsg.WriteString(fmt.Sprintf("🃏 %s plays: %s", name, card.DisplayWithColor(game.topColor)))
|
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
|
// Next player
|
||||||
nextUp := game.currentPlayer()
|
nextUp := game.currentPlayer()
|
||||||
nextUpName := p.unoDisplayName(nextUp.userID)
|
nextUpName := p.DisplayName(nextUp.userID)
|
||||||
if nextUp.isBot {
|
if nextUp.isBot {
|
||||||
nextUpName = unoBotName()
|
nextUpName = unoBotName()
|
||||||
}
|
}
|
||||||
@@ -1527,7 +1534,7 @@ func (p *UnoPlugin) startWD4Challenge(game *unoMultiGame, wd4Player *unoMultiPla
|
|||||||
game.currentIdx = nextIdx
|
game.currentIdx = nextIdx
|
||||||
game.turnID++
|
game.turnID++
|
||||||
|
|
||||||
playerName := p.unoDisplayName(wd4Player.userID)
|
playerName := p.DisplayName(wd4Player.userID)
|
||||||
if wd4Player.isBot {
|
if wd4Player.isBot {
|
||||||
playerName = unoBotName()
|
playerName = unoBotName()
|
||||||
}
|
}
|
||||||
@@ -1548,11 +1555,11 @@ func (p *UnoPlugin) startWD4Challenge(game *unoMultiGame, wd4Player *unoMultiPla
|
|||||||
func (p *UnoPlugin) resolveWD4Challenge(game *unoMultiGame, challenged bool) {
|
func (p *UnoPlugin) resolveWD4Challenge(game *unoMultiGame, challenged bool) {
|
||||||
wd4Player := game.wd4Player
|
wd4Player := game.wd4Player
|
||||||
victim := game.wd4Victim
|
victim := game.wd4Victim
|
||||||
wd4Name := p.unoDisplayName(wd4Player.userID)
|
wd4Name := p.DisplayName(wd4Player.userID)
|
||||||
if wd4Player.isBot {
|
if wd4Player.isBot {
|
||||||
wd4Name = unoBotName()
|
wd4Name = unoBotName()
|
||||||
}
|
}
|
||||||
victimName := p.unoDisplayName(victim.userID)
|
victimName := p.DisplayName(victim.userID)
|
||||||
if victim.isBot {
|
if victim.isBot {
|
||||||
victimName = unoBotName()
|
victimName = unoBotName()
|
||||||
}
|
}
|
||||||
@@ -1898,7 +1905,7 @@ func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) {
|
|||||||
// applyCardEffects already advanced currentIdx to the victim
|
// applyCardEffects already advanced currentIdx to the victim
|
||||||
if game.noMercy && card.Value == unoWildColorRoulette {
|
if game.noMercy && card.Value == unoWildColorRoulette {
|
||||||
target := game.players[game.currentIdx]
|
target := game.players[game.currentIdx]
|
||||||
targetName := p.unoDisplayName(target.userID)
|
targetName := p.DisplayName(target.userID)
|
||||||
if target.isBot {
|
if target.isBot {
|
||||||
targetName = bn
|
targetName = bn
|
||||||
}
|
}
|
||||||
@@ -1973,7 +1980,7 @@ func (p *UnoPlugin) startMultiAutoPlayTimer(game *unoMultiGame) {
|
|||||||
player.autoPlays++
|
player.autoPlays++
|
||||||
maxAutoPlays := envInt("UNO_MULTI_MAX_AUTOPLAY", 3)
|
maxAutoPlays := envInt("UNO_MULTI_MAX_AUTOPLAY", 3)
|
||||||
|
|
||||||
name := p.unoDisplayName(player.userID)
|
name := p.DisplayName(player.userID)
|
||||||
|
|
||||||
if player.autoPlays >= maxAutoPlays {
|
if player.autoPlays >= maxAutoPlays {
|
||||||
p.SendMessage(mg.roomID, fmt.Sprintf("🃏 %s was auto-played %d times in a row — forfeited!", name, 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) {
|
func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer) {
|
||||||
name := p.unoDisplayName(player.userID)
|
name := p.DisplayName(player.userID)
|
||||||
|
|
||||||
switch game.phase {
|
switch game.phase {
|
||||||
case unoMultiPhaseChallenge:
|
case unoMultiPhaseChallenge:
|
||||||
@@ -2065,7 +2072,7 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer
|
|||||||
if game.noMercy && pendingCard != nil && pendingCard.Value == unoWildColorRoulette {
|
if game.noMercy && pendingCard != nil && pendingCard.Value == unoWildColorRoulette {
|
||||||
nextIdx := game.nextActiveIdx()
|
nextIdx := game.nextActiveIdx()
|
||||||
target := game.players[nextIdx]
|
target := game.players[nextIdx]
|
||||||
targetName := p.unoDisplayName(target.userID)
|
targetName := p.DisplayName(target.userID)
|
||||||
if target.isBot {
|
if target.isBot {
|
||||||
targetName = unoBotName()
|
targetName = unoBotName()
|
||||||
}
|
}
|
||||||
@@ -2145,7 +2152,7 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer
|
|||||||
target := botChooseSwapTarget(game, player)
|
target := botChooseSwapTarget(game, player)
|
||||||
if target != nil {
|
if target != nil {
|
||||||
swapHandsMulti(player, target)
|
swapHandsMulti(player, target)
|
||||||
targetName := p.unoDisplayName(target.userID)
|
targetName := p.DisplayName(target.userID)
|
||||||
if target.isBot {
|
if target.isBot {
|
||||||
targetName = unoBotName()
|
targetName = unoBotName()
|
||||||
}
|
}
|
||||||
@@ -2327,7 +2334,7 @@ func (p *UnoPlugin) multiPlayerWins(game *unoMultiGame, winner *unoMultiPlayer)
|
|||||||
}
|
}
|
||||||
totalPot := game.ante * float64(humanCount)
|
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.euro.Credit(winner.userID, totalPot, "uno_multi_win")
|
||||||
|
|
||||||
p.SendMessage(game.roomID, fmt.Sprintf(
|
p.SendMessage(game.roomID, fmt.Sprintf(
|
||||||
@@ -2378,7 +2385,7 @@ func (p *UnoPlugin) multiBotWins(game *unoMultiGame) {
|
|||||||
|
|
||||||
func (p *UnoPlugin) multiPlayerForfeit(game *unoMultiGame, player *unoMultiPlayer) {
|
func (p *UnoPlugin) multiPlayerForfeit(game *unoMultiGame, player *unoMultiPlayer) {
|
||||||
player.active = false
|
player.active = false
|
||||||
name := p.unoDisplayName(player.userID)
|
name := p.DisplayName(player.userID)
|
||||||
|
|
||||||
// Shuffle cards back into draw pile
|
// Shuffle cards back into draw pile
|
||||||
game.drawPile = append(game.drawPile, player.hand...)
|
game.drawPile = append(game.drawPile, player.hand...)
|
||||||
@@ -2428,8 +2435,6 @@ func (p *UnoPlugin) cleanupMultiGame(game *unoMultiGame) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *UnoPlugin) recordMultiGame(game *unoMultiGame, winnerID id.UserID, result string) {
|
func (p *UnoPlugin) recordMultiGame(game *unoMultiGame, winnerID id.UserID, result string) {
|
||||||
d := db.Get()
|
|
||||||
|
|
||||||
playerIDs := make([]string, 0)
|
playerIDs := make([]string, 0)
|
||||||
for _, pl := range game.players {
|
for _, pl := range game.players {
|
||||||
if !pl.isBot {
|
if !pl.isBot {
|
||||||
@@ -2440,7 +2445,7 @@ func (p *UnoPlugin) recordMultiGame(game *unoMultiGame, winnerID id.UserID, resu
|
|||||||
humanCount := len(playerIDs)
|
humanCount := len(playerIDs)
|
||||||
totalPot := game.ante * float64(humanCount)
|
totalPot := game.ante * float64(humanCount)
|
||||||
|
|
||||||
_, 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)
|
`INSERT INTO uno_multi_games (room_id, ante, pot_total, winner_id, player_ids, result, turns, started_at, ended_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
string(game.roomID), game.ante, totalPot, string(winnerID),
|
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"),
|
game.startedAt.UTC().Format("2006-01-02 15:04:05"),
|
||||||
time.Now().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
|
var sb strings.Builder
|
||||||
bn := unoBotName()
|
bn := unoBotName()
|
||||||
current := game.currentPlayer()
|
current := game.currentPlayer()
|
||||||
currentName := p.unoDisplayName(current.userID)
|
currentName := p.DisplayName(current.userID)
|
||||||
if current.isBot {
|
if current.isBot {
|
||||||
currentName = bn
|
currentName = bn
|
||||||
}
|
}
|
||||||
@@ -2484,7 +2486,7 @@ func (p *UnoPlugin) sendMultiStatus(game *unoMultiGame, player *unoMultiPlayer)
|
|||||||
if pl == player || !pl.active {
|
if pl == player || !pl.active {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := p.unoDisplayName(pl.userID)
|
name := p.DisplayName(pl.userID)
|
||||||
if pl.isBot {
|
if pl.isBot {
|
||||||
name = bn
|
name = bn
|
||||||
}
|
}
|
||||||
@@ -2516,7 +2518,7 @@ func (p *UnoPlugin) sendMultiHandDisplay(game *unoMultiGame, player *unoMultiPla
|
|||||||
if pl == player || !pl.active {
|
if pl == player || !pl.active {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := p.unoDisplayName(pl.userID)
|
name := p.DisplayName(pl.userID)
|
||||||
if pl.isBot {
|
if pl.isBot {
|
||||||
name = bn
|
name = bn
|
||||||
}
|
}
|
||||||
@@ -2554,7 +2556,7 @@ func (p *UnoPlugin) sendMultiHandDisplayStacking(game *unoMultiGame, player *uno
|
|||||||
if pl == player || !pl.active {
|
if pl == player || !pl.active {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := p.unoDisplayName(pl.userID)
|
name := p.DisplayName(pl.userID)
|
||||||
if pl.isBot {
|
if pl.isBot {
|
||||||
name = bn
|
name = bn
|
||||||
}
|
}
|
||||||
@@ -2598,8 +2600,8 @@ func (p *UnoPlugin) handleMultiSwapChoice(game *unoMultiGame, player *unoMultiPl
|
|||||||
swapHandsMulti(player, target)
|
swapHandsMulti(player, target)
|
||||||
game.phase = unoMultiPhasePlay
|
game.phase = unoMultiPhasePlay
|
||||||
|
|
||||||
name := p.unoDisplayName(player.userID)
|
name := p.DisplayName(player.userID)
|
||||||
targetName := p.unoDisplayName(target.userID)
|
targetName := p.DisplayName(target.userID)
|
||||||
if target.isBot {
|
if target.isBot {
|
||||||
targetName = unoBotName()
|
targetName = unoBotName()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ func (p *UnoPlugin) checkMultiMercyElimination(game *unoMultiGame, player *unoMu
|
|||||||
}
|
}
|
||||||
|
|
||||||
player.active = false
|
player.active = false
|
||||||
name := p.unoDisplayName(player.userID)
|
name := p.DisplayName(player.userID)
|
||||||
if player.isBot {
|
if player.isBot {
|
||||||
name = unoBotName()
|
name = unoBotName()
|
||||||
}
|
}
|
||||||
|
|||||||
290
internal/plugin/uno_test.go
Normal file
290
internal/plugin/uno_test.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,38 +70,39 @@ func (p *URLsPlugin) OnMessage(ctx MessageContext) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, u := range urls {
|
go p.previewURL(ctx, urls[0])
|
||||||
title, desc, err := p.fetchPreview(u)
|
return nil
|
||||||
if err != nil {
|
}
|
||||||
slog.Debug("urls: fetch preview failed", "url", u, "err", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if title == "" && desc == "" {
|
func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) {
|
||||||
continue
|
title, desc, err := p.fetchPreview(targetURL)
|
||||||
}
|
if err != nil {
|
||||||
|
slog.Debug("urls: fetch preview failed", "url", targetURL, "err", err)
|
||||||
var preview strings.Builder
|
return
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
// 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
|
// Cache the result
|
||||||
_, cacheErr := d.Exec(
|
db.Exec("urls: cache write",
|
||||||
`INSERT INTO url_cache (url, title, description, cached_at) VALUES (?, ?, ?, ?)
|
`INSERT INTO url_cache (url, title, description, cached_at) VALUES (?, ?, ?, ?)
|
||||||
ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, cached_at = ?`,
|
ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, cached_at = ?`,
|
||||||
rawURL, title, desc, now, title, desc, now,
|
rawURL, title, desc, now, title, desc, now,
|
||||||
)
|
)
|
||||||
if cacheErr != nil {
|
|
||||||
slog.Error("urls: cache write", "err", cacheErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return title, desc, nil
|
return title, desc, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package plugin
|
package plugin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -21,6 +20,7 @@ import (
|
|||||||
// WordlePlugin provides a daily cooperative Wordle game.
|
// WordlePlugin provides a daily cooperative Wordle game.
|
||||||
type WordlePlugin struct {
|
type WordlePlugin struct {
|
||||||
Base
|
Base
|
||||||
|
euro *EuroPlugin
|
||||||
apiKey string
|
apiKey string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
defaultLength int
|
defaultLength int
|
||||||
@@ -33,7 +33,7 @@ type WordlePlugin struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewWordlePlugin creates a new WordlePlugin.
|
// NewWordlePlugin creates a new WordlePlugin.
|
||||||
func NewWordlePlugin(client *mautrix.Client) *WordlePlugin {
|
func NewWordlePlugin(client *mautrix.Client, euro *EuroPlugin) *WordlePlugin {
|
||||||
length := 5
|
length := 5
|
||||||
if v := os.Getenv("WORDLE_DEFAULT_LENGTH"); v != "" {
|
if v := os.Getenv("WORDLE_DEFAULT_LENGTH"); v != "" {
|
||||||
if n, err := strconv.Atoi(v); err == nil && n >= 5 && n <= 7 {
|
if n, err := strconv.Atoi(v); err == nil && n >= 5 && n <= 7 {
|
||||||
@@ -43,6 +43,7 @@ func NewWordlePlugin(client *mautrix.Client) *WordlePlugin {
|
|||||||
|
|
||||||
return &WordlePlugin{
|
return &WordlePlugin{
|
||||||
Base: NewBase(client),
|
Base: NewBase(client),
|
||||||
|
euro: euro,
|
||||||
apiKey: os.Getenv("WORDNIK_API_KEY"),
|
apiKey: os.Getenv("WORDNIK_API_KEY"),
|
||||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
defaultLength: length,
|
defaultLength: length,
|
||||||
@@ -163,7 +164,7 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get display name.
|
// Get display name.
|
||||||
displayName := p.displayName(ctx.Sender)
|
displayName := p.DisplayName(ctx.Sender)
|
||||||
|
|
||||||
// Score the guess.
|
// Score the guess.
|
||||||
results := scoreGuess(guess, puzzle.Answer)
|
results := scoreGuess(guess, puzzle.Answer)
|
||||||
@@ -187,8 +188,9 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
|
|||||||
definition := p.fetchDefinition(puzzle.Answer)
|
definition := p.fetchDefinition(puzzle.Answer)
|
||||||
p.updateStats(puzzle)
|
p.updateStats(puzzle)
|
||||||
p.markPuzzleDone(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).
|
// Check for failure (all guesses used).
|
||||||
@@ -216,8 +218,12 @@ func (p *WordlePlugin) handleGrid(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(puzzle.Guesses) == 0 {
|
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,
|
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))
|
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.
|
// createAndPostPuzzle creates a new puzzle, persists it, and posts the announcement.
|
||||||
func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int) error {
|
func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int) error {
|
||||||
if p.apiKey == "" {
|
// Use local word list for puzzle selection. Wordnik's randomWord endpoint
|
||||||
return p.SendMessage(roomID, "Wordle is unavailable — WORDNIK_API_KEY not configured.")
|
// requires a paid API tier; we keep Wordnik for guess validation and definitions only.
|
||||||
}
|
word := pickFallbackWord(wordLength)
|
||||||
|
if word == "" {
|
||||||
// Try Wordnik first, fall back to local word list.
|
return p.SendMessage(roomID, "Failed to select a puzzle word — no words available for that length.")
|
||||||
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.")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
puzzleNumber := p.nextPuzzleNumber()
|
puzzleNumber := p.nextPuzzleNumber()
|
||||||
@@ -336,24 +335,30 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist to DB.
|
// Persist to DB.
|
||||||
d := db.Get()
|
db.Exec("wordle: persist puzzle",
|
||||||
_, err = d.Exec(
|
|
||||||
`INSERT INTO wordle_puzzles (puzzle_id, room_id, answer, word_length, solved, guess_count, started_at)
|
`INSERT INTO wordle_puzzles (puzzle_id, room_id, answer, word_length, solved, guess_count, started_at)
|
||||||
VALUES (?, ?, ?, ?, 0, 0, ?)
|
VALUES (?, ?, ?, ?, 0, 0, ?)
|
||||||
ON CONFLICT(puzzle_id, room_id) DO UPDATE SET answer = ?, word_length = ?, solved = 0, guess_count = 0, started_at = ?`,
|
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,
|
today, string(roomID), word, wordLength, now,
|
||||||
word, wordLength, now,
|
word, wordLength, now,
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
slog.Error("wordle: persist puzzle", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
p.puzzles[roomID] = puzzle
|
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.validCache[today] = make(map[string]bool)
|
||||||
p.mu.Unlock()
|
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.
|
// 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)
|
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.
|
// Returns (valid, apiError). apiError is true when the API is unreachable.
|
||||||
func (p *WordlePlugin) isValidWord(puzzleID, word string) (bool, bool) {
|
func (p *WordlePlugin) isValidWord(puzzleID, word string) (bool, bool) {
|
||||||
cache := p.validCache[puzzleID]
|
cache := p.validCache[puzzleID]
|
||||||
@@ -385,6 +391,12 @@ func (p *WordlePlugin) isValidWord(puzzleID, word string) (bool, bool) {
|
|||||||
return valid, false
|
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)
|
valid, apiErr := wordnikValidateWord(p.apiKey, p.httpClient, word)
|
||||||
if !apiErr {
|
if !apiErr {
|
||||||
cache[word] = valid // only cache definitive results
|
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)
|
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 {
|
func (p *WordlePlugin) nextPuzzleNumber() int {
|
||||||
d := db.Get()
|
d := db.Get()
|
||||||
@@ -415,18 +420,14 @@ func (p *WordlePlugin) nextPuzzleNumber() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *WordlePlugin) markPuzzleDone(puzzle *WordlePuzzle) {
|
func (p *WordlePlugin) markPuzzleDone(puzzle *WordlePuzzle) {
|
||||||
d := db.Get()
|
|
||||||
solved := 0
|
solved := 0
|
||||||
if puzzle.Solved {
|
if puzzle.Solved {
|
||||||
solved = 1
|
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 = ?`,
|
`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),
|
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) {
|
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 {
|
func (p *WordlePlugin) communityStreak() int {
|
||||||
d := db.Get()
|
d := db.Get()
|
||||||
rows, err := d.Query(
|
rows, err := d.Query(
|
||||||
@@ -520,6 +581,17 @@ func (p *WordlePlugin) rehydratePuzzles() {
|
|||||||
today := time.Now().UTC().Format("2006-01-02")
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
d := db.Get()
|
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(
|
rows, err := d.Query(
|
||||||
`SELECT puzzle_id, room_id, answer, word_length, solved, guess_count, started_at
|
`SELECT puzzle_id, room_id, answer, word_length, solved, guess_count, started_at
|
||||||
FROM wordle_puzzles WHERE puzzle_id = ?`, today)
|
FROM wordle_puzzles WHERE puzzle_id = ?`, today)
|
||||||
@@ -527,8 +599,8 @@ func (p *WordlePlugin) rehydratePuzzles() {
|
|||||||
slog.Warn("wordle: rehydrate query failed", "err", err)
|
slog.Warn("wordle: rehydrate query failed", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
|
var pending []puzzleRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var pid, roomStr, answer string
|
var pid, roomStr, answer string
|
||||||
var wordLength, solved, guessCount int
|
var wordLength, solved, guessCount int
|
||||||
@@ -541,32 +613,37 @@ func (p *WordlePlugin) rehydratePuzzles() {
|
|||||||
continue // already done
|
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
|
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 {
|
if err != nil {
|
||||||
puzzleNumber = 1
|
puzzleNumber = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
puzzle := &WordlePuzzle{
|
puzzle := &WordlePuzzle{
|
||||||
PuzzleID: pid,
|
PuzzleID: pr.pid,
|
||||||
PuzzleNumber: puzzleNumber,
|
PuzzleNumber: puzzleNumber,
|
||||||
RoomID: roomID,
|
RoomID: roomID,
|
||||||
Answer: answer,
|
Answer: pr.answer,
|
||||||
WordLength: wordLength,
|
WordLength: pr.wordLength,
|
||||||
MaxGuesses: 6,
|
MaxGuesses: 6,
|
||||||
StartedAt: startedAt,
|
StartedAt: pr.startedAt,
|
||||||
LetterStates: make(map[rune]LetterResult),
|
LetterStates: make(map[rune]LetterResult),
|
||||||
}
|
}
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
p.puzzles[roomID] = puzzle
|
p.puzzles[roomID] = puzzle
|
||||||
p.validCache[pid] = make(map[string]bool)
|
p.validCache[pr.pid] = make(map[string]bool)
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
slog.Info("wordle: rehydrated puzzle", "room", roomID, "answer_len", wordLength)
|
slog.Info("wordle: rehydrated puzzle", "room", roomID, "answer_len", pr.wordLength)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
fallbackWords map[int][]string
|
fallbackWords map[int][]string
|
||||||
|
customAllowSet map[string]bool // words valid as guesses but not in dictionary
|
||||||
fallbackWordsOnce sync.Once
|
fallbackWordsOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,9 +46,41 @@ func loadFallbackWords() {
|
|||||||
for n, words := range fallbackWords {
|
for n, words := range fallbackWords {
|
||||||
slog.Info("wordle: loaded fallback words", "length", n, "count", len(words))
|
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 {
|
func pickFallbackWord(length int) string {
|
||||||
fallbackWordsOnce.Do(loadFallbackWords)
|
fallbackWordsOnce.Do(loadFallbackWords)
|
||||||
|
|
||||||
@@ -53,5 +88,47 @@ func pickFallbackWord(length int) string {
|
|||||||
if len(words) == 0 {
|
if len(words) == 0 {
|
||||||
return ""
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
148
internal/plugin/wordle_game_test.go
Normal file
148
internal/plugin/wordle_game_test.go
Normal file
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,15 +72,20 @@ func renderKeyboard(states map[rune]LetterResult) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// renderWordleStartAnnouncement renders the puzzle start message.
|
// renderWordleStartAnnouncement renders the puzzle start message.
|
||||||
func renderWordleStartAnnouncement(puzzleNumber, wordLength int) string {
|
func renderWordleStartAnnouncement(puzzleNumber, wordLength int, hint string) string {
|
||||||
return fmt.Sprintf(
|
base := fmt.Sprintf(
|
||||||
"🟩 **Daily Wordle #%d**\nA new %d-letter puzzle is ready! Work together — 6 guesses shared.\n\nGuess with: `!wordle <word>`",
|
"🟩 **Daily Wordle #%d**\nA new %d-letter puzzle is ready! Work together — 6 guesses shared.",
|
||||||
puzzleNumber, wordLength,
|
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.
|
// 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
|
var sb strings.Builder
|
||||||
|
|
||||||
// Find the solver.
|
// Find the solver.
|
||||||
@@ -107,18 +112,29 @@ func renderSolvedAnnouncement(puzzle *WordlePuzzle, definition string) string {
|
|||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contributors.
|
// Contributors with payouts.
|
||||||
sb.WriteString("\n🏆 Today's contributors:\n")
|
if len(payouts) > 0 {
|
||||||
contributors := wordleContributors(puzzle)
|
sb.WriteString("\n💰 **Payouts:**\n")
|
||||||
for _, c := range contributors {
|
for _, pay := range payouts {
|
||||||
line := fmt.Sprintf(" %s — %d guess", c.name, c.guesses)
|
bonus := ""
|
||||||
if c.guesses != 1 {
|
if pay.Solver {
|
||||||
line += "es"
|
bonus = " 🏆 (solver bonus!)"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" **%s**: +€%d%s\n", pay.Name, pay.Amount, bonus))
|
||||||
}
|
}
|
||||||
if c.solved {
|
} else {
|
||||||
line += " 🏆"
|
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()
|
return sb.String()
|
||||||
|
|||||||
@@ -67,7 +67,12 @@ func (p *WOTDPlugin) OnReaction(_ ReactionContext) error { return nil }
|
|||||||
|
|
||||||
func (p *WOTDPlugin) OnMessage(ctx MessageContext) error {
|
func (p *WOTDPlugin) OnMessage(ctx MessageContext) error {
|
||||||
if p.IsCommand(ctx.Body, "wotd") {
|
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
|
// Passive: track WOTD usage in messages
|
||||||
@@ -383,10 +388,7 @@ func (p *WOTDPlugin) grantWOTDXP(userID id.UserID, amount int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log XP grant
|
// Log XP grant
|
||||||
_, err = d.Exec(
|
db.Exec("wotd: log xp",
|
||||||
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
|
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
|
||||||
string(userID), amount, "wotd_usage")
|
string(userID), amount, "wotd_usage")
|
||||||
if err != nil {
|
|
||||||
slog.Error("wotd: log xp", "err", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,12 +144,9 @@ func (p *XPPlugin) grantXPInternal(userID id.UserID, amount int, reason string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log the XP grant
|
// Log the XP grant
|
||||||
_, err = d.Exec(
|
db.Exec("xp: log grant",
|
||||||
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
|
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
|
||||||
string(userID), amount, reason)
|
string(userID), amount, reason)
|
||||||
if err != nil {
|
|
||||||
slog.Error("xp: log grant", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newXP, leveledUp, newLevel
|
return newXP, leveledUp, newLevel
|
||||||
}
|
}
|
||||||
|
|||||||
67
main.go
67
main.go
@@ -8,6 +8,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gogobee/internal/bot"
|
"gogobee/internal/bot"
|
||||||
"gogobee/internal/db"
|
"gogobee/internal/db"
|
||||||
@@ -99,6 +100,8 @@ func main() {
|
|||||||
registry.Register(plugin.NewLookupPlugin(client, ratePlugin))
|
registry.Register(plugin.NewLookupPlugin(client, ratePlugin))
|
||||||
registry.Register(plugin.NewCountdownPlugin(client))
|
registry.Register(plugin.NewCountdownPlugin(client))
|
||||||
registry.Register(plugin.NewStocksPlugin(client))
|
registry.Register(plugin.NewStocksPlugin(client))
|
||||||
|
forexPlugin := plugin.NewForexPlugin(client)
|
||||||
|
registry.Register(forexPlugin)
|
||||||
concertsPlugin := plugin.NewConcertsPlugin(client, ratePlugin)
|
concertsPlugin := plugin.NewConcertsPlugin(client, ratePlugin)
|
||||||
registry.Register(concertsPlugin)
|
registry.Register(concertsPlugin)
|
||||||
animePlugin := plugin.NewAnimePlugin(client)
|
animePlugin := plugin.NewAnimePlugin(client)
|
||||||
@@ -115,7 +118,7 @@ func main() {
|
|||||||
registry.Register(plugin.NewUnoPlugin(client, euroPlugin))
|
registry.Register(plugin.NewUnoPlugin(client, euroPlugin))
|
||||||
registry.Register(plugin.NewHoldemPlugin(client, euroPlugin))
|
registry.Register(plugin.NewHoldemPlugin(client, euroPlugin))
|
||||||
registry.Register(plugin.NewAdventurePlugin(client, euroPlugin))
|
registry.Register(plugin.NewAdventurePlugin(client, euroPlugin))
|
||||||
wordlePlugin := plugin.NewWordlePlugin(client)
|
wordlePlugin := plugin.NewWordlePlugin(client, euroPlugin)
|
||||||
registry.Register(wordlePlugin)
|
registry.Register(wordlePlugin)
|
||||||
|
|
||||||
// Community
|
// Community
|
||||||
@@ -172,6 +175,12 @@ func main() {
|
|||||||
|
|
||||||
// Auto-join on invite + moderation member tracking
|
// Auto-join on invite + moderation member tracking
|
||||||
syncer.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) {
|
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()
|
mem := evt.Content.AsMember()
|
||||||
if mem == nil {
|
if mem == nil {
|
||||||
return
|
return
|
||||||
@@ -197,6 +206,13 @@ func main() {
|
|||||||
|
|
||||||
// Message handler
|
// Message handler
|
||||||
syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) {
|
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
|
// Skip own messages
|
||||||
if evt.Sender == client.UserID {
|
if evt.Sender == client.UserID {
|
||||||
return
|
return
|
||||||
@@ -234,6 +250,13 @@ func main() {
|
|||||||
|
|
||||||
// Reaction handler
|
// Reaction handler
|
||||||
syncer.OnEventType(event.EventReaction, func(ctx context.Context, evt *event.Event) {
|
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 {
|
if evt.Sender == client.UserID {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -256,8 +279,8 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ---- Set up cron scheduler ----
|
// ---- Set up cron scheduler ----
|
||||||
scheduler := cron.New()
|
scheduler := cron.New(cron.WithChain(cron.Recover(cronLogger{})))
|
||||||
setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin)
|
setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin, forexPlugin)
|
||||||
scheduler.Start()
|
scheduler.Start()
|
||||||
|
|
||||||
// ---- Start syncing ----
|
// ---- Start syncing ----
|
||||||
@@ -272,11 +295,26 @@ func main() {
|
|||||||
sig := <-sigCh
|
sig := <-sigCh
|
||||||
slog.Info("shutting down", "signal", sig)
|
slog.Info("shutting down", "signal", sig)
|
||||||
scheduler.Stop()
|
scheduler.Stop()
|
||||||
|
client.StopSync()
|
||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err := client.SyncWithContext(ctx); err != nil {
|
syncLoop:
|
||||||
slog.Error("sync stopped", "err", err)
|
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")
|
slog.Info("GogoBee stopped")
|
||||||
@@ -293,6 +331,7 @@ func setupScheduledJobs(
|
|||||||
movies *plugin.MoviesPlugin,
|
movies *plugin.MoviesPlugin,
|
||||||
concerts *plugin.ConcertsPlugin,
|
concerts *plugin.ConcertsPlugin,
|
||||||
esteemed *plugin.EsteemPlugin,
|
esteemed *plugin.EsteemPlugin,
|
||||||
|
forex *plugin.ForexPlugin,
|
||||||
) {
|
) {
|
||||||
rooms := getRooms()
|
rooms := getRooms()
|
||||||
|
|
||||||
@@ -370,6 +409,12 @@ func setupScheduledJobs(
|
|||||||
esteemed.PostWeekly()
|
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
|
// Space groups refresh every hour
|
||||||
c.AddFunc("0 * * * *", func() {
|
c.AddFunc("0 * * * *", func() {
|
||||||
slog.Info("scheduler: refreshing space groups")
|
slog.Info("scheduler: refreshing space groups")
|
||||||
@@ -461,3 +506,15 @@ func envOr(key, fallback string) string {
|
|||||||
}
|
}
|
||||||
return v
|
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...)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user