Add market plugin, arena, holidays, UNO stacking fix, and multiple UX improvements

- Market plugin (#49): daily index snapshots (Yahoo Finance + Finnhub fallback),
  Ollama-generated summaries, !howsthemarket, !marketstatus, !marketreport commands,
  exchange hours with DST support, 30-min room cooldown
- Adventure arena: 5-tier combat gauntlet with 20 monsters, risk-reward cashout,
  death lockout changed from 24h to midnight UTC across all code and flavor text
- Adventure holidays: double daily actions on holidays
- Adventure DM fix: 15-minute response window prevents bare numbers from triggering
  adventure during UNO games
- Adventure scheduler: jitter between morning DMs to avoid Matrix rate limits
- Adventure revive: wire up adv_revived achievement on admin revive
- Column migration system for existing databases (holiday_action_taken)
- Fix italic markdown rendering after newlines
- Fix holdem DMs broken by space groups including DM rooms
- UNO No Mercy: remove stacking escalation rule (any draw card stacks on any other)
- UNO multiplayer: add turn announcements after stack absorption and turn passes,
  jitter between rapid-fire messages with safe mutex handling
- Birthday: add €1,000 gift and 10 XP + €100 community celebration bonus
- Market: guard against division by zero, nil pointer, and timezone errors
- README updates: plugin count 48→49, all new commands, feature flags, architecture

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-03-27 21:26:19 -07:00
parent 81e6cecad9
commit 0d3485c7c2
22 changed files with 4327 additions and 116 deletions

View File

@@ -1,6 +1,6 @@
# GogoBee # GogoBee
Matrix community bot with E2EE, 48 plugins, passive tracking, scheduled posts, and optional LLM features. Matrix community bot with E2EE, 49 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.
@@ -29,8 +29,8 @@ Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption a
- **E2EE that actually works** - mautrix-go with goolm (pure Go). Crypto state lives in SQLite so device keys survive restarts. Cross-signing bootstraps on first run — the bot self-verifies its own device. - **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.
- **48 plugins** with dependency injection and ordered registration - **49 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 24 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 - **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 24 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, holiday double actions), Arena (5-tier combat gauntlet with 20 unique monsters, risk-reward cashout system, death lockout, leaderboard), 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
@@ -150,6 +150,8 @@ Everything is configured through environment variables or a `.env` file.
| `FEATURE_ESTEEMED` | Set to anything to enable satirical esteemed member posts | | `FEATURE_ESTEEMED` | Set to anything to enable satirical esteemed member posts |
| `ESTEEMED_ROOM` | Room ID for esteemed member posts (separate from broadcast rooms) | | `ESTEEMED_ROOM` | Room ID for esteemed member posts (separate from broadcast rooms) |
| `FEATURE_MODERATION` | Set to `true` to enable the moderation system (disabled by default) | | `FEATURE_MODERATION` | Set to `true` to enable the moderation system (disabled by default) |
| `FEATURE_MARKET` | Set to `true` to enable the market overview plugin |
| `MARKET_BROADCAST_SUMMARY` | Set to `true` to auto-post daily market summary to broadcast rooms |
### Games & Economy ### Games & Economy
@@ -464,7 +466,7 @@ No economy integration — stats and leaderboard position are the reward.
A daily DM-driven idle RPG where each player takes one action per day — dungeon, mine, forage, visit the shop, or rest. Outcomes resolve with flavor text and loot is credited to your euro balance. An evening summary posts to the games room. TwinBee is a permanent NPC adventurer who distributes rewards to active players. A daily DM-driven idle RPG where each player takes one action per day — dungeon, mine, 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. Characters auto-create on first `!adventure` command. All gameplay happens in DMs — reply to the bot's morning prompt with your choice. DM replies are only interpreted as adventure choices for 15 minutes after a menu is sent, so other DM-based games (UNO, Hold'em) won't conflict.
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
@@ -497,10 +499,34 @@ Three activity types across 5 tiers of locations (15 total). Higher tiers requir
- **Streaks** — consecutive days of activity grant escalating bonuses (XP, loot quality, death chance reduction). Resting or dying resets your streak. - **Streaks** — consecutive days of activity grant escalating bonuses (XP, loot quality, death chance reduction). Resting or dying resets your streak.
- **Grudge** — dying at a location marks it as your grudge. Returning there grants +10% success and +25% XP. Clears on success. - **Grudge** — dying at a location marks it as your grudge. Returning there grants +10% success and +25% XP. Clears on success.
- **Party bonus** — if two players independently visit the same location on the same day, both get +10% loot value. - **Party bonus** — if two players independently visit the same location on the same day, both get +10% loot value.
- **Death** — 24-hour lockout. You're automatically revived when the timer expires. - **Death** — locked out until midnight UTC. You're automatically revived when the next day starts.
- **Holidays** — on recognized holidays (~20/year across religious and cultural calendars), you get a second daily action. Hebrew and Islamic calendar support for floating holidays.
- **TwinBee NPC** — takes a daily action (location tier capped by best player's combined level), distributes loot share to active players scaled quadratically by level, and occasionally gifts random buffs. - **TwinBee NPC** — takes a daily action (location tier capped by best player's combined level), distributes loot share to active players scaled quadratically by level, and occasionally gifts random buffs.
- **Mid-day events** — random events can trigger between actions, delivering bonus loot, buffs, or narrative encounters. - **Mid-day events** — random events can trigger between actions, delivering bonus loot, buffs, or narrative encounters.
#### Arena (`!arena`)
A multi-tier combat gauntlet independent of the daily adventure action. Fight through 5 tiers of 4 rounds each — 20 unique named monsters with escalating lethality. Earnings accumulate across rounds but are forfeited on death. After clearing a tier, choose to descend deeper (keep earnings at risk) or cash out. Death locks you out of both arena and adventure until midnight UTC.
| Command | Description |
|---------|-------------|
| `!arena` | Show the arena tier menu |
| `!arena tier <1-5>` | Select a tier (shows confirmation with R1 opponent) |
| `!arena fight` | Confirm entry / fight current round |
| `!arena cancel` | Back out of tier selection |
| `!arena descend` | Descend to next tier after clearing (earnings carry over, still at risk) |
| `!arena cashout` | Take your earnings and leave |
| `!arena stats` | Your personal arena stats |
| `!arena status` | Current run state |
| `!arena leaderboard` | Top arena players |
| `!arena help` | Arena help text |
**Mechanics:**
- **Death chance** — based on monster lethality, combat level, equipment tier, and battle skill. Clamped between 1% and 98%.
- **Rewards** — tier base payout * round number + battle skill * tier multiplier. Higher tiers and later rounds pay more.
- **Auto-cashout** — 10 minutes to decide after clearing a tier. GogoBee cashes out on your behalf if you're slow.
- **Tier entry confirmation** — `!arena tier N` previews the first opponent; `!arena fight` commits.
### Reminders ### Reminders
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
@@ -548,6 +574,11 @@ Three activity types across 5 tiers of locations (15 total). Higher tiers requir
| `!fx setalert <currency> <rate>` | DM alert when rate hits threshold | | `!fx setalert <currency> <rate>` | DM alert when rate hits threshold |
| `!fx alerts` | List your active alerts | | `!fx alerts` | List your active alerts |
| `!fx delalert <currency> <rate>` | Remove an alert | | `!fx delalert <currency> <rate>` | Remove an alert |
| `!howsthemarket` | Daily market snapshot with Ollama commentary (7 global indices) |
| `!marketstatus` | Exchange open/closed status (NYSE, LSE, Euronext, TSE) |
| `!marketreport week\|month\|year` | Historical trend report with Ollama narrative |
| `!marketreport vix` | VIX fear/greed trajectory |
| `!marketreport compare <index> <days>` | Single index over N days |
### Entertainment ### Entertainment
| Command | Description | | Command | Description |
@@ -694,7 +725,7 @@ All of these run in the background without any commands:
- **Stats** - tracks words, chars, links, images, questions, emojis, and time-of-day patterns - **Stats** - tracks words, chars, links, images, questions, emojis, and time-of-day patterns
- **Streaks** - consecutive days active, first poster of the day - **Streaks** - consecutive days active, first poster of the day
- **Reputation** - detects "thanks", "ty", "thx", etc. with 24h cooldown per pair - **Reputation** - detects "thanks", "ty", "thx", etc. with 24h cooldown per pair
- **Achievements** - 80 of them, checked silently on every message - **Achievements** - 93 of them, checked silently on every message
- **Markov chains** - collects encrypted messages for `!markov` generation (10k cap per user, 90-day TTL) - **Markov chains** - collects encrypted messages for `!markov` generation (10k cap per user, 90-day TTL)
- **Keyword alerts** - DMs you when someone says your watched keywords - **Keyword alerts** - DMs you when someone says your watched keywords
- **Presence** - auto-clears away/afk when you send a message - **Presence** - auto-clears away/afk when you send a message
@@ -715,7 +746,7 @@ Uses [robfig/cron](https://github.com/robfig/cron). All times UTC.
| Time | Job | What it does | | Time | Job | What it does |
|------|-----|--------------| |------|-----|--------------|
| 00:05 | Prefetch | Grabs WOTD data ahead of time | | 00:05 | Prefetch | Grabs WOTD data ahead of time |
| 06:00 | Birthdays | Birthday shoutouts + 100 XP | | 06:00 | Birthdays | Birthday shoutouts + 100 XP + €1,000 |
| 07:00 | Holidays | Multi-calendar holidays (US, Asian, Jewish, Islamic) | | 07:00 | Holidays | Multi-calendar holidays (US, Asian, Jewish, Islamic) |
| 08:00 | WOTD | Posts the Word of the Day | | 08:00 | WOTD | Posts the Word of the Day |
| 09:00 Mon | Releases | Weekly game releases | | 09:00 Mon | Releases | Weekly game releases |
@@ -727,13 +758,14 @@ Uses [robfig/cron](https://github.com/robfig/cron). All times UTC.
| Hourly | Space groups | Refreshes room membership overlap and group mappings | | Hourly | Space groups | Refreshes room membership overlap and group mappings |
| 03:00 | Maintenance | Purges stale caches, old rate limits, expired logs, miniflux seen entries; runs SQLite optimize | | 03:00 | Maintenance | Purges stale caches, old rate limits, expired logs, miniflux seen entries; runs SQLite optimize |
| 03:30 | Markov TTL | Purges markov corpus entries older than 90 days | | 03:30 | Markov TTL | Purges markov corpus entries older than 90 days |
| 23:00 | Market | Daily market index pull (Yahoo Finance + Finnhub fallback, Ollama summary) |
| Every N min | Miniflux | Polls Miniflux for new feed entries (configurable, default 15m) | | Every N min | Miniflux | Polls Miniflux for new feed entries (configurable, default 15m) |
--- ---
## Achievements ## Achievements
80 achievements across 14 categories: 93 achievements across 15 categories:
**Message Milestones** - first_message, 100_messages, 500_messages, 1000_messages, 5000_messages, 10000_messages **Message Milestones** - first_message, 100_messages, 500_messages, 1000_messages, 5000_messages, 10000_messages
@@ -761,6 +793,8 @@ Uses [robfig/cron](https://github.com/robfig/cron). All times UTC.
**Adventure** - adv_first, adv_died, adv_revived, adv_streak_7, adv_streak_30, adv_max_level, adv_treasure_cap, adv_grudge_win, adv_party, adv_twinbee_gift **Adventure** - adv_first, adv_died, adv_revived, adv_streak_7, adv_streak_30, adv_max_level, adv_treasure_cap, adv_grudge_win, adv_party, adv_twinbee_gift
**Arena** - arena_first_blood, arena_tier1, arena_tier2, arena_tier3, arena_tier4, arena_tier5, arena_descend, arena_full_run, arena_death_t5, arena_omega, arena_cashout_big
**Community** - quote_saved, quote_saved_10, missing_poster, tarot_spread **Community** - quote_saved, quote_saved_10, missing_poster, tarot_spread
**WOTD** - wotd_first, wotd_week, wotd_30, logophile **WOTD** - wotd_first, wotd_week, wotd_30, logophile
@@ -818,7 +852,8 @@ All optional. The bot works fine without any of them, you just won't have those
| [HebCal](https://www.hebcal.com) | Yes, no key | Jewish holidays | | [HebCal](https://www.hebcal.com) | Yes, no key | Jewish holidays |
| [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, market index fallback |
| [Yahoo Finance](https://finance.yahoo.com) | Yes, no key | Market index snapshots (unofficial API) |
| [Frankfurter](https://frankfurter.dev) | Yes, no key | Forex rates (ECB-sourced) | | [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 |
@@ -871,7 +906,7 @@ gogobee/
│ │ ├── tools.go # Calculator, QR codes │ │ ├── tools.go # Calculator, QR codes
│ │ ├── user.go # Timezone, quotes, backlog, keyword watches │ │ ├── user.go # Timezone, quotes, backlog, keyword watches
│ │ ├── welcome.go # New user detection, !help │ │ ├── welcome.go # New user detection, !help
│ │ ├── achievements.go # 32 achievements │ │ ├── achievements.go # 93 achievements
│ │ ├── reactions.go # Reaction logging, emojiboard │ │ ├── reactions.go # Reaction logging, emojiboard
│ │ ├── markov.go # Markov chains │ │ ├── markov.go # Markov chains
│ │ ├── urls.go # URL previews │ │ ├── urls.go # URL previews
@@ -884,6 +919,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
│ │ ├── market.go # Market indices (Yahoo Finance + Finnhub fallback)
│ │ ├── forex*.go # Forex rates & alerts (Frankfurter v2) │ │ ├── forex*.go # Forex rates & alerts (Frankfurter v2)
│ │ ├── concerts.go # Concerts │ │ ├── concerts.go # Concerts
│ │ ├── anime.go # Anime │ │ ├── anime.go # Anime
@@ -926,6 +962,10 @@ gogobee/
│ │ ├── adventure_render.go # Character sheet, DMs, daily summary, leaderboard │ │ ├── adventure_render.go # Character sheet, DMs, daily summary, leaderboard
│ │ ├── adventure_events.go # Mid-day random events │ │ ├── adventure_events.go # Mid-day random events
│ │ ├── adventure_scheduler.go # Morning DM, evening summary, midnight reset tickers │ │ ├── adventure_scheduler.go # Morning DM, evening summary, midnight reset tickers
│ │ ├── adventure_holidays.go # Holiday detection (Hebrew/Islamic calendar, fixed dates)
│ │ ├── adventure_arena.go # Arena commands, combat resolution, DB CRUD, auto-cashout
│ │ ├── adventure_arena_monsters.go # 5 tiers, 20 monsters, death messages
│ │ ├── adventure_arena_render.go # Arena UI rendering (menus, stats, leaderboard)
│ │ ├── adventure_flavor_*.go # Flavor text pools (dungeon, mining, treasure, twinbee, events, closing) │ │ ├── adventure_flavor_*.go # Flavor text pools (dungeon, mining, 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)
@@ -952,7 +992,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.
80+ 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, encrypted markov corpus, reaction log, miniflux RSS subscriptions, and various caches. 80+ 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, market snapshots, forex rates/alerts, concerts, anime, movies, countdowns, presence, encrypted markov corpus, reaction log, miniflux RSS subscriptions, and various caches.
### Backup ### Backup

View File

@@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"time" "time"
@@ -56,8 +57,23 @@ func Get() *sql.DB {
} }
func runMigrations(d *sql.DB) error { func runMigrations(d *sql.DB) error {
_, err := d.Exec(schema) if _, err := d.Exec(schema); err != nil {
return err return err
}
// Column migrations — ALTER TABLE ADD COLUMN is a no-op if it already
// exists in SQLite (we just swallow "duplicate column name" errors).
columnMigrations := []string{
`ALTER TABLE adventure_characters ADD COLUMN holiday_action_taken INTEGER NOT NULL DEFAULT 0`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {
// "duplicate column name" means it already exists — safe to ignore.
if !strings.Contains(err.Error(), "duplicate column") {
return fmt.Errorf("migration %q: %w", stmt, err)
}
}
}
return nil
} }
// JobCompleted checks if a scheduled job has already completed for the given date key. // JobCompleted checks if a scheduled job has already completed for the given date key.
@@ -148,6 +164,10 @@ func RunMaintenance() {
// Forex rates older than 2 years (analysis needs 52 weeks, keep buffer) // 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")}}, {"forex_rates", `DELETE FROM forex_rates WHERE date < ?`, []interface{}{now.AddDate(-2, 0, 0).Format("2006-01-02")}},
// Market snapshots older than 1 year
{"market_snapshots", `DELETE FROM market_snapshots WHERE snapshot_date < ?`, []interface{}{now.AddDate(-1, 0, 0).Format("2006-01-02")}},
{"market_daily_summary", `DELETE FROM market_daily_summary WHERE snapshot_date < ?`, []interface{}{now.AddDate(-1, 0, 0).Format("2006-01-02")}},
} }
totalDeleted := int64(0) totalDeleted := int64(0)
@@ -813,6 +833,7 @@ CREATE TABLE IF NOT EXISTS adventure_characters (
alive INTEGER NOT NULL DEFAULT 1, alive INTEGER NOT NULL DEFAULT 1,
dead_until DATETIME, dead_until DATETIME,
action_taken_today INTEGER NOT NULL DEFAULT 0, action_taken_today INTEGER NOT NULL DEFAULT 0,
holiday_action_taken INTEGER NOT NULL DEFAULT 0,
arena_wins INTEGER NOT NULL DEFAULT 0, arena_wins INTEGER NOT NULL DEFAULT 0,
arena_losses INTEGER NOT NULL DEFAULT 0, arena_losses INTEGER NOT NULL DEFAULT 0,
invasion_score INTEGER NOT NULL DEFAULT 0, invasion_score INTEGER NOT NULL DEFAULT 0,
@@ -950,6 +971,44 @@ CREATE TABLE IF NOT EXISTS adventure_events_log (
); );
CREATE INDEX IF NOT EXISTS idx_adv_events_user_outcome ON adventure_events_log(user_id, outcome); CREATE INDEX IF NOT EXISTS idx_adv_events_user_outcome ON adventure_events_log(user_id, outcome);
-- Arena (Phase 2)
CREATE TABLE IF NOT EXISTS arena_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
room_id TEXT NOT NULL,
start_tier INTEGER NOT NULL,
tier INTEGER NOT NULL,
round INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active',
earnings INTEGER NOT NULL DEFAULT 0,
rounds_survived INTEGER NOT NULL DEFAULT 0,
last_monster TEXT NOT NULL DEFAULT '',
started_at INTEGER NOT NULL,
ended_at INTEGER
);
CREATE TABLE IF NOT EXISTS arena_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
start_tier INTEGER NOT NULL,
tier INTEGER NOT NULL,
rounds_survived INTEGER NOT NULL,
earnings INTEGER NOT NULL,
outcome TEXT NOT NULL,
monster_name TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS arena_stats (
user_id TEXT PRIMARY KEY,
total_runs INTEGER NOT NULL DEFAULT 0,
total_earnings INTEGER NOT NULL DEFAULT 0,
total_deaths INTEGER NOT NULL DEFAULT 0,
highest_tier INTEGER NOT NULL DEFAULT 0,
tier5_completions INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
-- Forex -- Forex
CREATE TABLE IF NOT EXISTS forex_rates ( CREATE TABLE IF NOT EXISTS forex_rates (
currency TEXT NOT NULL, currency TEXT NOT NULL,
@@ -983,6 +1042,27 @@ CREATE TABLE IF NOT EXISTS miniflux_seen (
PRIMARY KEY (feed_id, entry_id) PRIMARY KEY (feed_id, entry_id)
); );
-- Market snapshots (daily index data)
CREATE TABLE IF NOT EXISTS market_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_date TEXT NOT NULL,
symbol TEXT NOT NULL,
display_name TEXT NOT NULL,
price REAL,
prev_close REAL,
change_pct REAL,
source TEXT NOT NULL,
pulled_at INTEGER NOT NULL,
UNIQUE(snapshot_date, symbol)
);
CREATE INDEX IF NOT EXISTS idx_market_snap_date ON market_snapshots(snapshot_date, symbol);
CREATE TABLE IF NOT EXISTS market_daily_summary (
snapshot_date TEXT PRIMARY KEY,
summary TEXT,
generated_at INTEGER
);
` `
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist. // SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.

View File

@@ -916,6 +916,105 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
return false return false
}, },
}, },
// ── Arena ──────────────────────────────────────────────────────────
{
ID: "arena_first_blood", Name: "First Blood", Description: "The Arena has noted your existence.",
Emoji: "⚔️",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by arena plugin on first round survived
return false
},
},
{
ID: "arena_tier1", Name: "Scrub Slayer", Description: "Gelatinous Homunculus has been avenged by no one.",
Emoji: "⚔️",
Check: func(d *sql.DB, u id.UserID) bool {
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM arena_history WHERE user_id = ? AND tier = 1 AND outcome IN ('completed','cashed_out')`, string(u)).Scan(&count)
return count > 0
},
},
{
ID: "arena_tier2", Name: "Promoted to Violence", Description: "The Impersonator was not, in fact, a chest.",
Emoji: "⚔️",
Check: func(d *sql.DB, u id.UserID) bool {
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM arena_history WHERE user_id = ? AND tier = 2 AND outcome IN ('completed','cashed_out')`, string(u)).Scan(&count)
return count > 0
},
},
{
ID: "arena_tier3", Name: "Brute Force", Description: "The Inevitable was, on this occasion, avoidable.",
Emoji: "⚔️",
Check: func(d *sql.DB, u id.UserID) bool {
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM arena_history WHERE user_id = ? AND tier = 3 AND outcome IN ('completed','cashed_out')`, string(u)).Scan(&count)
return count > 0
},
},
{
ID: "arena_tier4", Name: "Horror Show", Description: "The Collector of Faces does not have yours. Yet.",
Emoji: "⚔️",
Check: func(d *sql.DB, u id.UserID) bool {
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM arena_history WHERE user_id = ? AND tier = 4 AND outcome IN ('completed','cashed_out')`, string(u)).Scan(&count)
return count > 0
},
},
{
ID: "arena_tier5", Name: "World Eater", Description: "That Which Has Always Been has now lost once.",
Emoji: "⚔️",
Check: func(d *sql.DB, u id.UserID) bool {
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM arena_history WHERE user_id = ? AND tier = 5 AND outcome = 'completed'`, string(u)).Scan(&count)
return count > 0
},
},
{
ID: "arena_descend", Name: "Going Deeper", Description: "Greed is a valid strategy.",
Emoji: "⚔️",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by arena plugin on descend
return false
},
},
{
ID: "arena_full_run", Name: "All the Way Down", Description: "Statistically impossible. Empirically: you.",
Emoji: "🏆",
Check: func(d *sql.DB, u id.UserID) bool {
// A full run = cleared all 5 tiers without cashing out
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM arena_history WHERE user_id = ? AND tier = 5 AND outcome = 'completed' AND rounds_survived = 20`, string(u)).Scan(&count)
return count > 0
},
},
{
ID: "arena_death_t5", Name: "Acceptable Losses", Description: "You made it to Tier 5. That's something.",
Emoji: "💀",
Check: func(d *sql.DB, u id.UserID) bool {
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM arena_history WHERE user_id = ? AND tier = 5 AND outcome = 'dead'`, string(u)).Scan(&count)
return count > 0
},
},
{
ID: "arena_omega", Name: "The Test", Description: "The machine has logged a loss for the first time.",
Emoji: "🤖",
Check: func(d *sql.DB, u id.UserID) bool {
// Granted by arena plugin when defeating Omega Mk. Zero
return false
},
},
{
ID: "arena_cashout_big", Name: "Take the Money", Description: "Discretion. Valor. Etc.",
Emoji: "💰",
Check: func(d *sql.DB, u id.UserID) bool {
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM arena_history WHERE user_id = ? AND outcome = 'cashed_out' AND earnings >= 10000`, string(u)).Scan(&count)
return count > 0
},
},
} }
} }

View File

@@ -18,11 +18,15 @@ import (
type AdventurePlugin struct { type AdventurePlugin struct {
Base Base
euro *EuroPlugin euro *EuroPlugin
achievements *AchievementsPlugin
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 dmRemindedDate sync.Map // userID string -> "2006-01-02" date string
dmMenuSentAt sync.Map // userID string -> time.Time (last time actionable menu was DM'd)
arenaDeadlines sync.Map // userID string -> time.Time (auto-cashout deadline)
arenaPending sync.Map // userID string -> int (pending tier number awaiting confirmation)
morningHour int morningHour int
summaryHour int summaryHour int
} }
@@ -33,6 +37,23 @@ func (p *AdventurePlugin) advUserLock(userID id.UserID) *sync.Mutex {
return val.(*sync.Mutex) return val.(*sync.Mutex)
} }
const advDMResponseWindow = 15 * time.Minute
// advMarkMenuSent records that an actionable adventure menu was DM'd to the user.
// Only bare-number DM replies within this window will be treated as adventure choices.
func (p *AdventurePlugin) advMarkMenuSent(userID id.UserID) {
p.dmMenuSentAt.Store(string(userID), time.Now())
}
// advIsInResponseWindow returns true if the user was recently sent an actionable menu.
func (p *AdventurePlugin) advIsInResponseWindow(userID id.UserID) bool {
val, ok := p.dmMenuSentAt.Load(string(userID))
if !ok {
return false
}
return time.Since(val.(time.Time)) < advDMResponseWindow
}
type advPendingInteraction struct { type advPendingInteraction struct {
Type string // "treasure_discard" Type string // "treasure_discard"
Data interface{} Data interface{}
@@ -56,9 +77,15 @@ func NewAdventurePlugin(client *mautrix.Client, euro *EuroPlugin) *AdventurePlug
func (p *AdventurePlugin) Name() string { return "adventure" } func (p *AdventurePlugin) Name() string { return "adventure" }
// SetAchievements wires the achievements plugin after both are initialized.
func (p *AdventurePlugin) SetAchievements(ach *AchievementsPlugin) {
p.achievements = ach
}
func (p *AdventurePlugin) Commands() []CommandDef { func (p *AdventurePlugin) Commands() []CommandDef {
return []CommandDef{ return []CommandDef{
{Name: "adventure", Description: "Daily adventure game — dungeon, mine, forage, or rest", Usage: "!adventure", Category: "Games"}, {Name: "adventure", Description: "Daily adventure game — dungeon, mine, forage, or rest", Usage: "!adventure", Category: "Games"},
{Name: "arena", Description: "Arena combat — fight through 5 tiers of increasingly deadly monsters", Usage: "!arena", Category: "Games"},
} }
} }
@@ -89,6 +116,10 @@ func (p *AdventurePlugin) Init() error {
go p.summaryTicker() go p.summaryTicker()
go p.midnightTicker() go p.midnightTicker()
go p.eventTicker() go p.eventTicker()
go p.arenaAutoCashoutTicker()
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
p.arenaCleanupStaleRuns()
return nil return nil
} }
@@ -117,7 +148,12 @@ func (p *AdventurePlugin) OnReaction(_ ReactionContext) error { return nil }
// ── Message Dispatch ───────────────────────────────────────────────────────── // ── Message Dispatch ─────────────────────────────────────────────────────────
func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
// 1. Check if this is a DM reply from a registered player // 1. Arena commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "arena") {
return p.dispatchArenaCommand(ctx)
}
// 2. Check if this is a DM reply from a registered player
p.mu.Lock() p.mu.Lock()
playerID, isDM := p.dmToPlayer[ctx.RoomID] playerID, isDM := p.dmToPlayer[ctx.RoomID]
p.mu.Unlock() p.mu.Unlock()
@@ -126,7 +162,7 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
return p.handleDMReply(ctx) return p.handleDMReply(ctx)
} }
// 2. Command dispatch // 3. Command dispatch
if !p.IsCommand(ctx.Body, "adventure") { if !p.IsCommand(ctx.Body, "adventure") {
return nil return nil
} }
@@ -179,6 +215,15 @@ const advHelpText = `**Adventure Commands**
` + "`!adventure respond`" + ` — Respond to a mid-day event ` + "`!adventure respond`" + ` — Respond to a mid-day event
` + "`!adventure help`" + ` — This message ` + "`!adventure help`" + ` — This message
**Arena:**
` + "`!arena`" + ` — Show arena tier menu
` + "`!arena tier <1-5>`" + ` — Enter a tier
` + "`!arena fight`" + ` — Fight current round
` + "`!arena descend`" + ` — Descend to next tier (keep earnings at risk)
` + "`!arena cashout`" + ` — Take earnings and leave
` + "`!arena status`" + ` — Current run state
` + "`!arena leaderboard`" + ` — Top arena players
**In DM:** Reply with a number (e.g. ` + "`1`" + `) or location name to take your daily action.` **In DM:** Reply with a number (e.g. ` + "`1`" + `) or location name to take your daily action.`
// ── Command Handlers ───────────────────────────────────────────────────────── // ── Command Handlers ─────────────────────────────────────────────────────────
@@ -195,6 +240,17 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
} }
if char.ActionTakenToday { if char.ActionTakenToday {
// On holidays, allow second action if not yet taken
isHol, _ := isHolidayToday()
if isHol && !char.HolidayActionTaken {
treasures, _ := loadAdvTreasureBonuses(char.UserID)
buffs, _ := loadAdvActiveBuffs(char.UserID)
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
text := renderAdvHolidaySecondPrompt(char, equip, bonuses)
p.advMarkMenuSent(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
now := time.Now().UTC() now := time.Now().UTC()
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC) midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
remaining := midnight.Sub(now) remaining := midnight.Sub(now)
@@ -203,7 +259,8 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
return p.SendDM(ctx.Sender, fmt.Sprintf( return p.SendDM(ctx.Sender, fmt.Sprintf(
"You've already taken your action today. Tomorrow awaits. Try to survive it.\n\n"+ "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"+ "Next action: 00:00 UTC (%dh %dm from now)\n"+
"Morning DM: %02d:00 UTC", "Morning DM: %02d:00 UTC\n\n"+
"The Arena is always open: `!arena`",
hours, minutes, p.morningHour)) hours, minutes, p.morningHour))
} }
@@ -212,7 +269,9 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false) bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
balance := p.euro.GetBalance(char.UserID) balance := p.euro.GetBalance(char.UserID)
text := renderAdvMorningDM(char, equip, balance, bonuses) _, holName := isHolidayToday()
text := renderAdvMorningDM(char, equip, balance, bonuses, holName)
p.advMarkMenuSent(ctx.Sender)
return p.SendDM(ctx.Sender, text) return p.SendDM(ctx.Sender, text)
} }
@@ -331,6 +390,9 @@ func (p *AdventurePlugin) handleAdminRevive(ctx MessageContext, target string) e
} }
p.SendDM(targetID, renderAdvRespawnDM(char)) p.SendDM(targetID, renderAdvRespawnDM(char))
if p.achievements != nil {
p.achievements.GrantAchievement(targetID, "adv_revived")
}
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("✅ %s has been revived.", char.DisplayName)) return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("✅ %s has been revived.", char.DisplayName))
} }
@@ -357,7 +419,7 @@ func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error {
return p.dispatchCommand(ctx) return p.dispatchCommand(ctx)
} }
// Check for pending interaction first // Check for pending interaction first (always honored regardless of window)
if val, ok := p.pending.Load(string(ctx.Sender)); ok { if val, ok := p.pending.Load(string(ctx.Sender)); ok {
interaction := val.(*advPendingInteraction) interaction := val.(*advPendingInteraction)
if time.Now().Before(interaction.ExpiresAt) { if time.Now().Before(interaction.ExpiresAt) {
@@ -367,6 +429,13 @@ func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error {
p.SendDM(ctx.Sender, "Your previous prompt expired. Moving on.") p.SendDM(ctx.Sender, "Your previous prompt expired. Moving on.")
} }
// Only interpret bare messages as adventure choices if the user was recently
// shown an actionable menu. This prevents "1" typed during UNO (or any other
// DM-based game) from triggering adventure responses.
if !p.advIsInResponseWindow(ctx.Sender) {
return nil
}
// Parse as activity choice // Parse as activity choice
return p.parseAndResolveChoice(ctx, body) return p.parseAndResolveChoice(ctx, body)
} }
@@ -427,6 +496,9 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string)
} }
if char.ActionTakenToday { if char.ActionTakenToday {
// On holidays, allow second action if not yet taken
isHol, _ := isHolidayToday()
if !isHol || char.HolidayActionTaken {
// Only send the reminder once per day — subsequent DM messages // Only send the reminder once per day — subsequent DM messages
// are silently ignored so they can be handled by other plugins (e.g. UNO). // are silently ignored so they can be handled by other plugins (e.g. UNO).
today := time.Now().UTC().Format("2006-01-02") today := time.Now().UTC().Format("2006-01-02")
@@ -442,9 +514,12 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string)
minutes := int(remaining.Minutes()) % 60 minutes := int(remaining.Minutes()) % 60
return p.SendDM(ctx.Sender, fmt.Sprintf( return p.SendDM(ctx.Sender, fmt.Sprintf(
"You've already taken your action today. Rest now. Try again tomorrow.\n\n"+ "You've already taken your action today. Rest now. Try again tomorrow.\n\n"+
"Next action: 00:00 UTC (%dh %dm from now)", "Next action: 00:00 UTC (%dh %dm from now)\n\n"+
"The Arena is always open: `!arena`",
hours, minutes)) hours, minutes))
} }
// Fall through for holiday second action
}
lower := strings.ToLower(body) lower := strings.ToLower(body)
@@ -570,7 +645,8 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
// Handle death // Handle death
if result.Outcome == AdvOutcomeDeath { if result.Outcome == AdvOutcomeDeath {
char.Alive = false char.Alive = false
deadUntil := time.Now().UTC().Add(24 * time.Hour) now := time.Now().UTC()
deadUntil := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
char.DeadUntil = &deadUntil char.DeadUntil = &deadUntil
char.GrudgeLocation = loc.Name char.GrudgeLocation = loc.Name
} else if hasGrudge && (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) { } else if hasGrudge && (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) {
@@ -583,9 +659,22 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
_ = addAdvInventoryItem(char.UserID, item) _ = addAdvInventoryItem(char.UserID, item)
} }
// Determine if this is the holiday second action
isAction2 := char.ActionTakenToday // already taken = this is the second
isHol, _ := isHolidayToday()
// Mark action taken and record the date for streak tracking // Mark action taken and record the date for streak tracking
if !isAction2 {
char.ActionTakenToday = true char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02") char.LastActionDate = time.Now().UTC().Format("2006-01-02")
}
// Holiday flags: mark second action done, or mark it done on death during action 1
if isAction2 {
char.HolidayActionTaken = true
} else if isHol && result.Outcome == AdvOutcomeDeath {
char.HolidayActionTaken = true // died on action 1 — no second action
}
// Update streak info // Update streak info
result.StreakBonus = char.CurrentStreak result.StreakBonus = char.CurrentStreak
@@ -637,12 +726,35 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
p.checkTreasureDrop(ctx.Sender, char, loc) p.checkTreasureDrop(ctx.Sender, char, loc)
} }
// TODO: holiday achievement hooks
// Holiday: offer second action if this was action 1 and player survived
if !isAction2 && isHol && result.Outcome != AdvOutcomeDeath {
equip2, _ := loadAdvEquipment(char.UserID)
treasures2, _ := loadAdvTreasureBonuses(char.UserID)
buffs2, _ := loadAdvActiveBuffs(char.UserID)
bonuses2 := computeAdvBonuses(treasures2, buffs2, char.CurrentStreak, false)
prompt := renderAdvHolidaySecondPrompt(char, equip2, bonuses2)
if err := p.SendDM(ctx.Sender, prompt); err != nil {
slog.Error("adventure: failed to send holiday second prompt", "user", ctx.Sender, "err", err)
}
}
return nil return nil
} }
func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharacter) error { func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharacter) error {
isAction2 := char.ActionTakenToday
isHol, _ := isHolidayToday()
if !isAction2 {
char.ActionTakenToday = true char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02") char.LastActionDate = time.Now().UTC().Format("2006-01-02")
}
if isAction2 {
char.HolidayActionTaken = true
}
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.")
} }
@@ -656,15 +768,34 @@ func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharact
hours := int(remaining.Hours()) hours := int(remaining.Hours())
minutes := int(remaining.Minutes()) % 60 minutes := int(remaining.Minutes()) % 60
return p.SendDM(ctx.Sender, fmt.Sprintf( restMsg := 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.\n\n"+ "Tomorrow awaits. It will probably be the same.\n\n"+
"─────────────────────────────\n"+ "─────────────────────────────\n"+
"Next action: 00:00 UTC (%dh %dm from now)\n"+ "Next action: 00:00 UTC (%dh %dm from now)\n"+
"Morning DM: %02d:00 UTC\n"+ "Morning DM: %02d:00 UTC\n"+
"Evening summary: %02d:00 UTC", "Evening summary: %02d:00 UTC\n\n"+
char.DisplayName, hours, minutes, p.morningHour, p.summaryHour)) "The Arena is always open: `!arena`",
char.DisplayName, hours, minutes, p.morningHour, p.summaryHour)
if err := p.SendDM(ctx.Sender, restMsg); err != nil {
slog.Error("adventure: failed to send rest DM", "user", ctx.Sender, "err", err)
}
// Holiday: offer second action if this was action 1
if !isAction2 && isHol {
equip, _ := loadAdvEquipment(char.UserID)
treasures, _ := loadAdvTreasureBonuses(char.UserID)
buffs, _ := loadAdvActiveBuffs(char.UserID)
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
prompt := renderAdvHolidaySecondPrompt(char, equip, bonuses)
if err := p.SendDM(ctx.Sender, prompt); err != nil {
slog.Error("adventure: failed to send holiday second prompt", "user", ctx.Sender, "err", err)
}
}
return nil
} }
// ── Treasure Drop Check ───────────────────────────────────────────────────── // ── Treasure Drop Check ─────────────────────────────────────────────────────

View File

@@ -0,0 +1,838 @@
package plugin
import (
"fmt"
"log/slog"
"math"
"math/rand/v2"
"strconv"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Arena Run State ─────────────────────────────────────────────────────────
type ArenaRun struct {
ID int64
UserID id.UserID
RoomID id.RoomID
StartTier int
Tier int
Round int
Status string // "active", "awaiting", "completed", "dead", "cashed_out"
Earnings int64
RoundsSurvived int
LastMonster string
StartedAt time.Time
EndedAt *time.Time
}
// ── Command Dispatch ────────────────────────────────────────────────────────
func (p *AdventurePlugin) dispatchArenaCommand(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "arena"))
lower := strings.ToLower(args)
switch {
case args == "" || lower == "menu":
return p.handleArenaMenu(ctx)
case strings.HasPrefix(lower, "tier "):
return p.handleArenaTier(ctx, strings.TrimSpace(args[5:]))
case lower == "fight" || lower == "f":
return p.handleArenaFight(ctx)
case lower == "descend":
return p.handleArenaDescend(ctx)
case lower == "cashout":
return p.handleArenaCashout(ctx)
case lower == "cancel":
return p.handleArenaCancel(ctx)
case lower == "status":
return p.handleArenaStatus(ctx)
case lower == "stats":
return p.handleArenaStats(ctx)
case lower == "leaderboard" || lower == "lb":
return p.handleArenaLeaderboard(ctx)
case lower == "help":
return p.SendDM(ctx.Sender, arenaHelpText)
}
return p.SendDM(ctx.Sender, "Unknown arena command. Type `!arena help` for available commands.")
}
const arenaHelpText = `**Arena Commands**
` + "`!arena`" + ` — Show tier menu and eligible tiers
` + "`!arena tier <1-5>`" + ` — Preview a tier (requires confirmation)
` + "`!arena fight`" + ` — Confirm entry or fight current round
` + "`!arena cancel`" + ` — Cancel pending tier entry
` + "`!arena descend`" + ` — Descend to next tier after clearing (earnings stay at risk)
` + "`!arena cashout`" + ` — Take your earnings and leave
` + "`!arena status`" + ` — Current run state
` + "`!arena stats`" + ` — Your personal arena stats
` + "`!arena leaderboard`" + ` — Top arena players
` + "`!arena help`" + ` — This message
The Arena is independent of your daily adventure action. You can run both on the same day. Death in the arena locks you out of both arena and adventure until midnight UTC.`
// ── Handlers ────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleArenaMenu(ctx MessageContext) error {
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one first — the Arena is no place for the unregistered.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
}
// Clear any pending tier selection when viewing menu
p.arenaPending.Delete(string(ctx.Sender))
// Check for active run
run, err := loadActiveArenaRun(ctx.Sender)
if err == nil && run != nil {
return p.SendDM(ctx.Sender, renderArenaAlreadyInRun(run))
}
stats := loadArenaPersonalStats(ctx.Sender)
return p.SendDM(ctx.Sender, renderArenaTierMenu(char, stats))
}
func (p *AdventurePlugin) handleArenaTier(ctx MessageContext, arg string) error {
tierNum, err := strconv.Atoi(arg)
if err != nil || tierNum < 1 || tierNum > 5 {
return p.SendDM(ctx.Sender, "Invalid tier. Use `!arena tier <1-5>`.")
}
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one first.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
}
// Check active run
existing, _ := loadActiveArenaRun(ctx.Sender)
if existing != nil {
return p.SendDM(ctx.Sender, renderArenaAlreadyInRun(existing))
}
tier := arenaGetTier(tierNum)
if tier == nil {
return p.SendDM(ctx.Sender, "Invalid tier.")
}
// Level gate
if char.CombatLevel < tier.MinLevel {
return p.SendDM(ctx.Sender, renderArenaLevelGate(tier, char.CombatLevel))
}
// Store pending tier selection — run is NOT created yet
p.arenaPending.Store(string(ctx.Sender), tierNum)
monster := arenaGetMonster(tierNum, 1)
text := renderArenaTierConfirm(tier, monster)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleArenaFight(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
// Check for pending tier confirmation first
if val, ok := p.arenaPending.LoadAndDelete(string(ctx.Sender)); ok {
tierNum := val.(int)
return p.confirmAndStartArenaRun(ctx, tierNum)
}
run, err := loadActiveArenaRun(ctx.Sender)
if err != nil || run == nil {
return p.SendDM(ctx.Sender, "You don't have an active arena run. Start one with `!arena tier <1-5>`.")
}
if run.Status != "active" {
if run.Status == "awaiting" {
return p.SendDM(ctx.Sender, renderArenaAlreadyInRun(run))
}
return p.SendDM(ctx.Sender, "Your arena run is not in a fightable state.")
}
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load character.")
}
equip, _ := loadAdvEquipment(ctx.Sender)
tier := arenaGetTier(run.Tier)
monster := arenaGetMonster(run.Tier, run.Round)
if tier == nil || monster == nil {
return p.SendDM(ctx.Sender, "Arena data error. This shouldn't happen.")
}
// Resolve combat
deathChance := arenaDeathChance(monster, char, equip)
roll := rand.Float64()
died := roll < deathChance
if died {
return p.resolveArenaDeath(ctx, run, char, tier, monster)
}
return p.resolveArenaSurvival(ctx, run, char, tier, monster)
}
func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext, tierNum int) error {
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load character.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
}
// Re-check active run (could have changed since tier selection)
existing, _ := loadActiveArenaRun(ctx.Sender)
if existing != nil {
return p.SendDM(ctx.Sender, renderArenaAlreadyInRun(existing))
}
tier := arenaGetTier(tierNum)
if tier == nil || char.CombatLevel < tier.MinLevel {
return p.SendDM(ctx.Sender, "You are no longer eligible for this tier.")
}
// NOW create the run
run := &ArenaRun{
UserID: ctx.Sender,
RoomID: ctx.RoomID,
StartTier: tierNum,
Tier: tierNum,
Round: 1,
Status: "active",
Earnings: 0,
StartedAt: time.Now().UTC(),
}
if err := createArenaRun(run); err != nil {
slog.Error("arena: failed to create run", "user", ctx.Sender, "err", err)
return p.SendDM(ctx.Sender, "Failed to start arena run.")
}
// Resolve round 1 immediately
equip, _ := loadAdvEquipment(ctx.Sender)
monster := arenaGetMonster(tierNum, 1)
deathChance := arenaDeathChance(monster, char, equip)
roll := rand.Float64()
died := roll < deathChance
if died {
return p.resolveArenaDeath(ctx, run, char, tier, monster)
}
return p.resolveArenaSurvival(ctx, run, char, tier, monster)
}
func (p *AdventurePlugin) handleArenaCancel(ctx MessageContext) error {
if _, ok := p.arenaPending.LoadAndDelete(string(ctx.Sender)); ok {
return p.SendDM(ctx.Sender, "Tier entry cancelled. The Arena will wait.")
}
return p.SendDM(ctx.Sender, "Nothing to cancel.")
}
func (p *AdventurePlugin) handleArenaDescend(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
run, err := loadActiveArenaRun(ctx.Sender)
if err != nil || run == nil || run.Status != "awaiting" {
return p.SendDM(ctx.Sender, "You don't have a pending tier decision.")
}
if run.Tier >= 5 {
return p.SendDM(ctx.Sender, "There is nothing deeper. You've reached the bottom.")
}
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load character.")
}
nextTier := arenaGetTier(run.Tier + 1)
if nextTier == nil {
return p.SendDM(ctx.Sender, "Invalid next tier.")
}
// Level gate for next tier
if char.CombatLevel < nextTier.MinLevel {
return p.SendDM(ctx.Sender, renderArenaLevelGate(nextTier, char.CombatLevel))
}
// Clear auto-cashout deadline
p.arenaDeadlines.Delete(string(ctx.Sender))
// Update run: advance to next tier
run.Tier = nextTier.Number
run.Round = 1
run.Status = "active"
if err := saveArenaRun(run); err != nil {
return p.SendDM(ctx.Sender, "Failed to update arena run.")
}
// Grant descend achievement
if p.achievements != nil {
p.achievements.GrantAchievement(ctx.Sender, "arena_descend")
}
monster := arenaGetMonster(nextTier.Number, 1)
text := renderArenaRoundStart(nextTier, 1, monster, run)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleArenaCashout(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
run, err := loadActiveArenaRun(ctx.Sender)
if err != nil || run == nil || run.Status != "awaiting" {
return p.SendDM(ctx.Sender, "You don't have a pending tier decision.")
}
return p.arenaCompleteCashout(ctx.Sender, run, false)
}
func (p *AdventurePlugin) handleArenaStatus(ctx MessageContext) error {
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found.")
}
run, err := loadActiveArenaRun(ctx.Sender)
if err != nil || run == nil {
return p.SendDM(ctx.Sender, "No active arena run. Start one with `!arena tier <1-5>`.")
}
return p.SendDM(ctx.Sender, renderArenaStatus(run, char))
}
func (p *AdventurePlugin) handleArenaStats(ctx MessageContext) error {
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one first.")
}
stats := loadArenaPersonalStats(ctx.Sender)
return p.SendDM(ctx.Sender, renderArenaPersonalStats(char, stats))
}
func (p *AdventurePlugin) handleArenaLeaderboard(ctx MessageContext) error {
entries, err := loadArenaLeaderboard()
if err != nil {
slog.Error("arena: failed to load leaderboard", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load arena leaderboard.")
}
return p.SendReply(ctx.RoomID, ctx.EventID, renderArenaLeaderboard(entries))
}
// ── Combat Resolution ───────────────────────────────────────────────────────
func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster) error {
// Calculate reward
reward := arenaRoundReward(tier, run.Round, char.CombatLevel)
run.Earnings += reward
run.RoundsSurvived++
run.LastMonster = monster.Name
// Award battle XP
char.CombatXP += tier.BattleXP
leveled, newLevel := checkAdvLevelUp(char, "combat")
if err := saveAdvCharacter(char); err != nil {
slog.Error("arena: failed to save character after survival", "user", ctx.Sender, "err", err)
}
// Build survival message
text := renderArenaSurvival(tier, run.Round, monster, reward, tier.BattleXP, run.Earnings)
if leveled {
text += fmt.Sprintf("\n🎉 **Combat Level %d!**", newLevel)
}
// Achievement: first blood
if run.RoundsSurvived == 1 && p.achievements != nil {
p.achievements.GrantAchievement(ctx.Sender, "arena_first_blood")
}
// Achievement: Omega Mk. Zero defeated (T5R1 survival)
if run.Tier == 5 && run.Round == 1 && p.achievements != nil {
p.achievements.GrantAchievement(ctx.Sender, "arena_omega")
}
// Check if tier is complete (4 rounds)
if run.Round >= 4 {
// Add completion bonus
run.Earnings += tier.CompletionBonus
run.Round = 4 // keep at 4 for clarity
if run.Tier >= 5 {
// Tier 5 complete — full payout, run ends
return p.arenaCompleteTier5(ctx, run, char, tier, text)
}
// Tier complete, awaiting decision
run.Status = "awaiting"
if err := saveArenaRun(run); err != nil {
slog.Error("arena: failed to save run after tier complete", "user", ctx.Sender, "err", err)
}
// Set auto-cashout deadline
deadline := time.Now().UTC().Add(10 * time.Minute)
p.arenaDeadlines.Store(string(ctx.Sender), deadline)
text += "\n\n" + renderArenaTierComplete(tier, tier.CompletionBonus, run.Earnings)
// Grant tier achievement
p.grantArenaTierAchievement(ctx.Sender, run.Tier)
return p.SendDM(ctx.Sender, text)
}
// Advance to next round
run.Round++
if err := saveArenaRun(run); err != nil {
slog.Error("arena: failed to save run after round", "user", ctx.Sender, "err", err)
}
// Reveal next monster
nextMonster := arenaGetMonster(run.Tier, run.Round)
if nextMonster != nil {
text += fmt.Sprintf("\n\n─────────────────────────────\n\n")
text += fmt.Sprintf("**Round %d/4 — %s**\n", run.Round, nextMonster.Name)
text += fmt.Sprintf("_%s_\n\n", nextMonster.Flavor)
text += "`!arena fight` — Face this opponent"
}
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster) error {
lostEarnings := run.Earnings
run.LastMonster = monster.Name
// Kill the character (locked out until next midnight UTC)
char.Alive = false
now := time.Now().UTC()
deadUntil := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
char.DeadUntil = &deadUntil
char.ArenaLosses++
if err := saveAdvCharacter(char); err != nil {
slog.Error("arena: failed to save character after death", "user", ctx.Sender, "err", err)
}
// End the run
run.Status = "dead"
run.Earnings = 0
run.EndedAt = &now
if err := saveArenaRun(run); err != nil {
slog.Error("arena: failed to end arena run", "user", ctx.Sender, "err", err)
}
// Insert history
insertArenaHistory(run.UserID, run.StartTier, run.Tier, run.RoundsSurvived, 0, "dead", monster.Name)
// Update stats
upsertArenaStats(run.UserID, 0, true, run.Tier)
// Achievement: death in T5
if run.Tier == 5 && p.achievements != nil {
p.achievements.GrantAchievement(ctx.Sender, "arena_death_t5")
}
// Pick death message
deathMsg := arenaPickDeathMessage(monster, run.Tier, run.Round)
text := renderArenaDeath(tier, run.Round, monster, lostEarnings, deathMsg)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) arenaCompleteTier5(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, prefixText string) error {
// Credit earnings
p.euro.Credit(run.UserID, float64(run.Earnings), "arena_tier5_complete")
char.ArenaWins++
if err := saveAdvCharacter(char); err != nil {
slog.Error("arena: failed to save character after T5 complete", "user", ctx.Sender, "err", err)
}
// End run
now := time.Now().UTC()
run.Status = "completed"
run.EndedAt = &now
if err := saveArenaRun(run); err != nil {
slog.Error("arena: failed to end arena run after T5", "user", ctx.Sender, "err", err)
}
// History and stats
insertArenaHistory(run.UserID, run.StartTier, 5, run.RoundsSurvived, run.Earnings, "completed", "That Which Has Always Been")
upsertArenaStats(run.UserID, run.Earnings, false, 5)
// Grant achievements
p.grantArenaTierAchievement(ctx.Sender, 5)
if run.StartTier == 1 && p.achievements != nil {
p.achievements.GrantAchievement(ctx.Sender, "arena_full_run")
}
// Room announcement
gr := gamesRoom()
if gr != "" {
announce := fmt.Sprintf("🏆 **%s has conquered the Arena.** Tier 5 cleared. €%d earned. That Which Has Always Been has fallen.",
char.DisplayName, run.Earnings)
p.SendMessage(id.RoomID(gr), announce)
}
text := prefixText + "\n\n" + renderArenaTier5Complete(run.Earnings, run.StartTier)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) arenaCompleteCashout(userID id.UserID, run *ArenaRun, isAuto bool) error {
// Clear deadline
p.arenaDeadlines.Delete(string(userID))
// Credit earnings
p.euro.Credit(userID, float64(run.Earnings), "arena_cashout")
// Load char to update wins
char, err := loadAdvCharacter(userID)
if err == nil {
char.ArenaWins++
saveAdvCharacter(char)
}
// End run
now := time.Now().UTC()
run.Status = "cashed_out"
run.EndedAt = &now
if err := saveArenaRun(run); err != nil {
slog.Error("arena: failed to end arena run on cashout", "user", userID, "err", err)
}
// History and stats
insertArenaHistory(userID, run.StartTier, run.Tier, run.RoundsSurvived, run.Earnings, "cashed_out", run.LastMonster)
upsertArenaStats(userID, run.Earnings, false, run.Tier)
// Achievement: big cashout
if run.Earnings >= 10000 && p.achievements != nil {
p.achievements.GrantAchievement(userID, "arena_cashout_big")
}
if isAuto {
return p.SendDM(userID, renderArenaAutoCashout(run.Earnings))
}
return p.SendDM(userID, renderArenaCashout(run.Earnings, run.Tier))
}
// ── Combat Math ─────────────────────────────────────────────────────────────
func arenaDeathChance(monster *ArenaMonster, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment) float64 {
baseDeath := monster.BaseLethality
levelMod := float64(monster.ThreatLevel-char.CombatLevel) * 0.015
skillMod := math.Max(0, 0.25-float64(char.CombatLevel)*0.008)
// Average equipment tier → up to 15% reduction at max gear (tier 5 avg = 0.15)
var totalTier float64
count := 0
for _, slot := range allSlots {
if eq, ok := equip[slot]; ok {
totalTier += float64(eq.Tier)
count++
}
}
avgTier := 0.0
if count > 0 {
avgTier = totalTier / float64(count)
}
equipMod := avgTier * 0.03 // 0 at tier 0, 0.15 at tier 5
deathChance := baseDeath + levelMod - equipMod + skillMod
return math.Max(0.01, math.Min(0.98, deathChance))
}
func arenaRoundReward(tier *ArenaTier, round int, battleSkill int) int64 {
base := tier.BasePayout * int64(round)
skillBonus := int64(float64(battleSkill) * tier.SkillMultiplier)
return base + skillBonus
}
// ── Achievement Helpers ─────────────────────────────────────────────────────
func (p *AdventurePlugin) grantArenaTierAchievement(userID id.UserID, tier int) {
if p.achievements == nil {
return
}
ids := map[int]string{
1: "arena_tier1",
2: "arena_tier2",
3: "arena_tier3",
4: "arena_tier4",
5: "arena_tier5",
}
if achID, ok := ids[tier]; ok {
p.achievements.GrantAchievement(userID, achID)
}
}
// ── Death Message Selection ─────────────────────────────────────────────────
func arenaPickDeathMessage(monster *ArenaMonster, tier, round int) string {
// 50% chance of generic, 50% monster-specific
if rand.IntN(2) == 0 {
return arenaDeathMessages[rand.IntN(len(arenaDeathMessages))]
}
template := arenaMonsterDeathMessages[rand.IntN(len(arenaMonsterDeathMessages))]
r := strings.NewReplacer(
"{monster}", monster.Name,
"{tier}", strconv.Itoa(tier),
"{round}", strconv.Itoa(round),
)
return r.Replace(template)
}
// ── Auto-Cashout Ticker ─────────────────────────────────────────────────────
func (p *AdventurePlugin) arenaAutoCashoutTicker() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
now := time.Now().UTC()
p.arenaDeadlines.Range(func(key, value any) bool {
userIDStr := key.(string)
deadline := value.(time.Time)
if now.After(deadline) {
p.arenaDeadlines.Delete(userIDStr)
go p.autoCollectArena(id.UserID(userIDStr))
}
return true
})
}
}
func (p *AdventurePlugin) autoCollectArena(userID id.UserID) {
userMu := p.advUserLock(userID)
userMu.Lock()
defer userMu.Unlock()
run, err := loadActiveArenaRun(userID)
if err != nil || run == nil || run.Status != "awaiting" {
return
}
if err := p.arenaCompleteCashout(userID, run, true); err != nil {
slog.Error("arena: auto-cashout failed", "user", userID, "err", err)
}
}
// arenaCleanupStaleRuns auto-cashes out any runs left in 'awaiting' status
// (e.g. from a bot restart). Called during Init().
func (p *AdventurePlugin) arenaCleanupStaleRuns() {
runs, err := loadAwaitingArenaRuns()
if err != nil {
slog.Error("arena: failed to load stale runs", "err", err)
return
}
for _, run := range runs {
run := run
slog.Info("arena: auto-cashing out stale awaiting run", "user", run.UserID, "earnings", run.Earnings)
if err := p.arenaCompleteCashout(run.UserID, &run, true); err != nil {
slog.Error("arena: stale run cashout failed", "user", run.UserID, "err", err)
}
}
}
// ── DB CRUD ─────────────────────────────────────────────────────────────────
func loadActiveArenaRun(userID id.UserID) (*ArenaRun, error) {
d := db.Get()
run := &ArenaRun{}
var startedAt int64
var endedAt *int64
err := d.QueryRow(`
SELECT id, user_id, room_id, start_tier, tier, round, status, earnings,
rounds_survived, last_monster, started_at, ended_at
FROM arena_runs
WHERE user_id = ? AND status IN ('active', 'awaiting')
ORDER BY id DESC LIMIT 1`, string(userID)).Scan(
&run.ID, &run.UserID, &run.RoomID, &run.StartTier, &run.Tier, &run.Round,
&run.Status, &run.Earnings, &run.RoundsSurvived, &run.LastMonster,
&startedAt, &endedAt,
)
if err != nil {
return nil, err
}
run.StartedAt = time.Unix(startedAt, 0).UTC()
if endedAt != nil {
t := time.Unix(*endedAt, 0).UTC()
run.EndedAt = &t
}
return run, nil
}
func loadAwaitingArenaRuns() ([]ArenaRun, error) {
d := db.Get()
rows, err := d.Query(`
SELECT id, user_id, room_id, start_tier, tier, round, status, earnings,
rounds_survived, last_monster, started_at, ended_at
FROM arena_runs WHERE status = 'awaiting'`)
if err != nil {
return nil, err
}
defer rows.Close()
var runs []ArenaRun
for rows.Next() {
var r ArenaRun
var startedAt int64
var endedAt *int64
if err := rows.Scan(
&r.ID, &r.UserID, &r.RoomID, &r.StartTier, &r.Tier, &r.Round,
&r.Status, &r.Earnings, &r.RoundsSurvived, &r.LastMonster,
&startedAt, &endedAt,
); err != nil {
return nil, err
}
r.StartedAt = time.Unix(startedAt, 0).UTC()
if endedAt != nil {
t := time.Unix(*endedAt, 0).UTC()
r.EndedAt = &t
}
runs = append(runs, r)
}
return runs, rows.Err()
}
func createArenaRun(run *ArenaRun) error {
d := db.Get()
result, err := d.Exec(`
INSERT INTO arena_runs (user_id, room_id, start_tier, tier, round, status, earnings,
rounds_survived, last_monster, started_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
string(run.UserID), string(run.RoomID), run.StartTier, run.Tier, run.Round,
run.Status, run.Earnings, run.RoundsSurvived, run.LastMonster,
run.StartedAt.Unix(),
)
if err != nil {
return err
}
run.ID, _ = result.LastInsertId()
return nil
}
func saveArenaRun(run *ArenaRun) error {
d := db.Get()
var endedAt interface{}
if run.EndedAt != nil {
endedAt = run.EndedAt.Unix()
}
_, err := d.Exec(`
UPDATE arena_runs SET
tier = ?, round = ?, status = ?, earnings = ?,
rounds_survived = ?, last_monster = ?, ended_at = ?
WHERE id = ?`,
run.Tier, run.Round, run.Status, run.Earnings,
run.RoundsSurvived, run.LastMonster, endedAt,
run.ID,
)
return err
}
func insertArenaHistory(userID id.UserID, startTier, tier, roundsSurvived int, earnings int64, outcome, monsterName string) {
db.Exec("arena: insert history",
`INSERT INTO arena_history (user_id, start_tier, tier, rounds_survived, earnings, outcome, monster_name, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
string(userID), startTier, tier, roundsSurvived, earnings, outcome, monsterName, time.Now().Unix(),
)
}
func upsertArenaStats(userID id.UserID, earnings int64, died bool, highestTier int) {
d := db.Get()
now := time.Now().Unix()
deathInc := 0
if died {
deathInc = 1
}
t5Inc := 0
if highestTier == 5 && !died {
t5Inc = 1
}
_, err := d.Exec(`
INSERT INTO arena_stats (user_id, total_runs, total_earnings, total_deaths, highest_tier, tier5_completions, updated_at)
VALUES (?, 1, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
total_runs = total_runs + 1,
total_earnings = total_earnings + ?,
total_deaths = total_deaths + ?,
highest_tier = MAX(highest_tier, ?),
tier5_completions = tier5_completions + ?,
updated_at = ?`,
string(userID), earnings, deathInc, highestTier, t5Inc, now,
earnings, deathInc, highestTier, t5Inc, now,
)
if err != nil {
slog.Error("arena: failed to upsert stats", "user", userID, "err", err)
}
}
func loadArenaLeaderboard() ([]ArenaLeaderboardEntry, error) {
d := db.Get()
rows, err := d.Query(`
SELECT s.user_id, COALESCE(c.display_name, s.user_id),
s.total_earnings, s.highest_tier, s.tier5_completions,
s.total_runs, s.total_deaths
FROM arena_stats s
LEFT JOIN adventure_characters c ON c.user_id = s.user_id
ORDER BY s.total_earnings DESC
LIMIT 10`)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []ArenaLeaderboardEntry
for rows.Next() {
var e ArenaLeaderboardEntry
var uid string
if err := rows.Scan(&uid, &e.DisplayName, &e.TotalEarnings, &e.HighestTier,
&e.Tier5Completions, &e.TotalRuns, &e.TotalDeaths); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, rows.Err()
}
func loadArenaPersonalStats(userID id.UserID) *ArenaPersonalStats {
d := db.Get()
stats := &ArenaPersonalStats{}
err := d.QueryRow(`
SELECT total_runs, total_earnings, total_deaths, highest_tier, tier5_completions
FROM arena_stats WHERE user_id = ?`, string(userID)).Scan(
&stats.TotalRuns, &stats.TotalEarnings, &stats.TotalDeaths,
&stats.HighestTier, &stats.Tier5Completions,
)
if err != nil {
return nil
}
return stats
}

View File

@@ -0,0 +1,201 @@
package plugin
// ── Arena Tier & Monster Definitions ────────────────────────────────────────
//
// Five tiers, four monsters each. Death chance, rewards, and XP scale with tier.
// Monster data from gogobee-arena.md spec.
type ArenaTier struct {
Number int
Name string
MinLevel int
BasePayout int64
SkillMultiplier float64
CompletionBonus int64
BattleXP int
Monsters [4]ArenaMonster
}
type ArenaMonster struct {
Name string
Flavor string
BaseLethality float64
ThreatLevel int
}
var arenaTiers = [5]ArenaTier{
// Tier 1 — Scrubs
{
Number: 1, Name: "Scrubs", MinLevel: 1,
BasePayout: 150, SkillMultiplier: 1.0, CompletionBonus: 2500, BattleXP: 10,
Monsters: [4]ArenaMonster{
{
Name: "Gelatinous Homunculus",
Flavor: "A translucent blob of vague menace. Has been in every dungeon you've ever visited. Doesn't know why it's here.",
BaseLethality: 0.10, ThreatLevel: 2,
},
{
Name: "Ratticus the Persistent",
Flavor: "A rat of unusual confidence. Has survived seventeen previous adventurers through sheer stubbornness.",
BaseLethality: 0.18, ThreatLevel: 5,
},
{
Name: "Roadside Entrepreneur",
Flavor: "A bandit who has been robbing travelers at this exact location since approximately forever. Very committed to the bit.",
BaseLethality: 0.28, ThreatLevel: 8,
},
{
Name: "Leafhopper Exemplar",
Flavor: "An insect of improbable aggression. Scientifically should not be a threat. Is one anyway. Earthbound energy.",
BaseLethality: 0.38, ThreatLevel: 12,
},
},
},
// Tier 2 — Thugs
{
Number: 2, Name: "Thugs", MinLevel: 10,
BasePayout: 500, SkillMultiplier: 2.5, CompletionBonus: 10000, BattleXP: 25,
Monsters: [4]ArenaMonster{
{
Name: "The Maestro of the Underchamber",
Flavor: "An operatic entity of considerable mass and questionable composition who rules his domain with a baritone and an inexhaustible supply of sweet corn. Not technically an RPG villain. Here anyway.",
BaseLethality: 0.35, ThreatLevel: 16,
},
{
Name: "Lycanthropic Freelancer",
Flavor: "A werewolf between contracts. Takes the full moon very seriously. Will eat you on principle.",
BaseLethality: 0.45, ThreatLevel: 20,
},
{
Name: "The Impersonator",
Flavor: "A chest. Definitely a chest. Please open it. (Do not open it.)",
BaseLethality: 0.55, ThreatLevel: 25,
},
{
Name: "Armored Disagreement",
Flavor: "A dark knight who has made peace with violence as a communication strategy. Extensively equipped.",
BaseLethality: 0.65, ThreatLevel: 30,
},
},
},
// Tier 3 — Brutes
{
Number: 3, Name: "Brutes", MinLevel: 25,
BasePayout: 1500, SkillMultiplier: 6.0, CompletionBonus: 30000, BattleXP: 60,
Monsters: [4]ArenaMonster{
{
Name: "Stonefist the Unconvinced",
Flavor: "A golem who has heard every argument for sparing adventurers and found all of them unpersuasive.",
BaseLethality: 0.55, ThreatLevel: 35,
},
{
Name: "Wyrm of Moderate Ambition",
Flavor: "Aspires to be a world-ending dragon. Currently a regional threat at best. Very sensitive about this.",
BaseLethality: 0.65, ThreatLevel: 42,
},
{
Name: "Behemoth Adjacent",
Flavor: "Not quite a Behemoth. Close enough to ruin your day. Appears to have escaped from a larger game.",
BaseLethality: 0.73, ThreatLevel: 48,
},
{
Name: "The Inevitable",
Flavor: "A reaper-class entity. No grievances. No agenda. Simply the direction all things are heading.",
BaseLethality: 0.80, ThreatLevel: 55,
},
},
},
// Tier 4 — Horrors
{
Number: 4, Name: "Horrors", MinLevel: 45,
BasePayout: 5000, SkillMultiplier: 12.0, CompletionBonus: 100000, BattleXP: 120,
Monsters: [4]ArenaMonster{
{
Name: "The Watcher in the Peripheral",
Flavor: "Seventeen eyes. None of them blink at the same time. Has been observing you specifically for longer than you've been alive.",
BaseLethality: 0.72, ThreatLevel: 62,
},
{
Name: "Herald of the Outer Dark",
Flavor: "A daedric-adjacent entity that crossed over from a realm adjacent to yours and immediately became your problem. Very well dressed.",
BaseLethality: 0.80, ThreatLevel: 70,
},
{
Name: "Lich Adjacent",
Flavor: "Not the Lich King. Definitely not. Unrelated individual. Happens to be a skeletal sorcerer of immense power. Coincidence.",
BaseLethality: 0.87, ThreatLevel: 78,
},
{
Name: "The Collector of Faces",
Flavor: "It has yours already. Has had it for some time. The fight is a formality at this point.",
BaseLethality: 0.92, ThreatLevel: 88,
},
},
},
// Tier 5 — World Eaters
{
Number: 5, Name: "World Eaters", MinLevel: 70,
BasePayout: 15000, SkillMultiplier: 25.0, CompletionBonus: 500000, BattleXP: 250,
Monsters: [4]ArenaMonster{
{
Name: "Omega Mk. Zero",
Flavor: "A machine built to be the final test. Has never lost. Is aware of this.",
BaseLethality: 0.85, ThreatLevel: 95,
},
{
Name: "The Calamity That Dreamed It Was Sleeping",
Flavor: "An ancient parasitic entity that fell from the sky an indeterminate number of years ago and has been ending timelines since. Definitely not Lavos.",
BaseLethality: 0.90, ThreatLevel: 105,
},
{
Name: "The Architect of Endings",
Flavor: "A god who decided the world was a failed experiment and appointed itself project manager of its destruction. Silver hair. Long coat. Personal.",
BaseLethality: 0.95, ThreatLevel: 115,
},
{
Name: "That Which Has Always Been",
Flavor: "Pre-dates language. Pre-dates light. The Arena was built around it, not the other way around. Winning this fight is not something the game's designers fully accounted for.",
BaseLethality: 0.98, ThreatLevel: 130,
},
},
},
}
// arenaGetTier returns the tier definition for a 1-indexed tier number.
// Returns nil if the tier number is out of range.
func arenaGetTier(tier int) *ArenaTier {
if tier < 1 || tier > 5 {
return nil
}
return &arenaTiers[tier-1]
}
// arenaGetMonster returns the monster for the given tier/round (both 1-indexed).
func arenaGetMonster(tier, round int) *ArenaMonster {
t := arenaGetTier(tier)
if t == nil || round < 1 || round > 4 {
return nil
}
return &t.Monsters[round-1]
}
// ── Arena Death Flavor Text ─────────────────────────────────────────────────
var arenaDeathMessages = []string{
"The Arena has collected its fee.",
"GogoBee notes your performance for the record. The record is not flattering.",
"Your earnings have been redistributed to the house. The house always wins.",
"The crowd has already forgotten your name.",
"A brief silence. Then the next challenger is called.",
"The Arena floor is cleaned. It takes less time than expected.",
"Your equipment will be returned to your next of kin. They will be disappointed by it.",
"Statistics updated. Morale not applicable.",
}
// arenaMonsterDeathMessages are templates with {monster}, {round}, {tier} placeholders.
var arenaMonsterDeathMessages = []string{
"{monster} has ended your run. Your earnings have been redistributed to the house.",
"You made it to Round {round} of Tier {tier}. {monster} was not impressed.",
"{monster} didn't even use their best material. Round {round}. Tier {tier}. Done.",
"The last thing you see is {monster}. The last thing they see is lunch.",
}

View File

@@ -0,0 +1,280 @@
package plugin
import (
"fmt"
"strings"
)
// ── Arena Tier Menu ─────────────────────────────────────────────────────────
func renderArenaTierMenu(char *AdventureCharacter, stats *ArenaPersonalStats) string {
var b strings.Builder
b.WriteString("⚔️ **THE ARENA**\n\n")
b.WriteString(fmt.Sprintf("Combat Level: %d\n\n", char.CombatLevel))
for i := range arenaTiers {
t := &arenaTiers[i]
eligible := char.CombatLevel >= t.MinLevel
icon := "🔒"
if eligible {
icon = "⬚" // eligible but not cleared
}
if stats != nil && stats.HighestTier >= t.Number {
icon = "✅" // cleared
}
b.WriteString(fmt.Sprintf("%s **Tier %d — %s** (Lv.%d+)\n", icon, t.Number, t.Name, t.MinLevel))
}
if stats != nil && stats.TotalRuns > 0 {
b.WriteString(fmt.Sprintf("\nRuns: %d | Deaths: %d | Earned: €%d\n",
stats.TotalRuns, stats.TotalDeaths, stats.TotalEarnings))
}
b.WriteString("\n`!arena tier <1-5>` — Enter a tier\n")
b.WriteString("`!arena stats` — Your arena stats\n")
b.WriteString("`!arena leaderboard` — Top arena players\n")
return b.String()
}
// ── Round Start (Monster Reveal) ────────────────────────────────────────────
func renderArenaRoundStart(tier *ArenaTier, round int, monster *ArenaMonster, run *ArenaRun) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("⚔️ **Tier %d — %s | Round %d/4**\n\n", tier.Number, tier.Name, round))
b.WriteString(fmt.Sprintf("**%s**\n", monster.Name))
b.WriteString(fmt.Sprintf("_%s_\n\n", monster.Flavor))
if run.Earnings > 0 {
b.WriteString(fmt.Sprintf("Run earnings: €%d (at risk)\n\n", run.Earnings))
}
b.WriteString("`!arena fight` — Face this opponent\n")
return b.String()
}
// ── Survival ────────────────────────────────────────────────────────────────
func renderArenaSurvival(tier *ArenaTier, round int, monster *ArenaMonster, reward int64, xp int, totalEarnings int64) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("✅ **%s defeated.**\n\n", monster.Name))
b.WriteString(fmt.Sprintf("Round reward: €%d\n", reward))
b.WriteString(fmt.Sprintf("Battle XP: +%d\n", xp))
b.WriteString(fmt.Sprintf("Run total: €%d\n", totalEarnings))
return b.String()
}
// ── Tier Complete (Transition Prompt) ───────────────────────────────────────
func renderArenaTierComplete(tier *ArenaTier, completionBonus int64, totalEarnings int64) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("🏆 **Tier %d — %s cleared!**\n\n", tier.Number, tier.Name))
b.WriteString(fmt.Sprintf("Completion bonus: €%d\n", completionBonus))
b.WriteString(fmt.Sprintf("Run total: €%d\n\n", totalEarnings))
if tier.Number < 5 {
nextTier := arenaGetTier(tier.Number + 1)
b.WriteString(fmt.Sprintf("Descend to Tier %d — %s? Your earnings are at risk.\n\n", nextTier.Number, nextTier.Name))
b.WriteString(fmt.Sprintf("`!arena descend` — Enter Tier %d (earnings carry over, still at risk)\n", nextTier.Number))
b.WriteString(fmt.Sprintf("`!arena cashout` — Take your €%d and leave\n\n", totalEarnings))
b.WriteString("_You have 10 minutes to decide. After that, GogoBee collects on your behalf._")
}
return b.String()
}
// ── Tier 5 Complete ─────────────────────────────────────────────────────────
func renderArenaTier5Complete(totalEarnings int64, startTier int) string {
var b strings.Builder
b.WriteString("🏆🏆🏆 **THE ARENA HAS BEEN CONQUERED.**\n\n")
b.WriteString("That Which Has Always Been has fallen. The machine has logged a loss.\n")
b.WriteString("The crowd is silent. Not out of respect — out of disbelief.\n\n")
b.WriteString(fmt.Sprintf("**Total earnings: €%d**\n\n", totalEarnings))
b.WriteString("Your euros have been credited. Your name has been etched. The Arena remembers.")
if startTier == 1 {
b.WriteString("\n\n_All the way down. From Tier 1 to Tier 5 in a single run. Statistically impossible. Empirically: you._")
}
return b.String()
}
// ── Death ────────────────────────────────────────────────────────────────────
func renderArenaDeath(tier *ArenaTier, round int, monster *ArenaMonster, lostEarnings int64, deathMsg string) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("💀 **DEAD** — Tier %d, Round %d\n\n", tier.Number, round))
b.WriteString(fmt.Sprintf("_%s_\n\n", deathMsg))
if lostEarnings > 0 {
b.WriteString(fmt.Sprintf("Forfeited earnings: €%d\n", lostEarnings))
}
b.WriteString("You are dead until midnight UTC. Both arena and daily adventure are blocked until you respawn.")
return b.String()
}
// ── Cashout ─────────────────────────────────────────────────────────────────
func renderArenaCashout(totalEarnings int64, lastTier int) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("💰 **Cashed out: €%d**\n\n", totalEarnings))
b.WriteString(fmt.Sprintf("You cleared through Tier %d and lived to spend it. ", lastTier))
b.WriteString("Wisdom or cowardice — the euros don't care which.")
return b.String()
}
// ── Auto-Cashout ────────────────────────────────────────────────────────────
func renderArenaAutoCashout(totalEarnings int64) string {
return fmt.Sprintf(
"⏰ **Auto-cashout: €%d**\n\n"+
"You took too long to decide. GogoBee collected your winnings on your behalf "+
"and is annoyed about it. The money is in your account. You're welcome.",
totalEarnings)
}
// ── Status ──────────────────────────────────────────────────────────────────
func renderArenaStatus(run *ArenaRun, char *AdventureCharacter) string {
tier := arenaGetTier(run.Tier)
if tier == nil {
return "No active arena run."
}
var b strings.Builder
b.WriteString("⚔️ **Arena Run Status**\n\n")
b.WriteString(fmt.Sprintf("Tier: %d — %s\n", tier.Number, tier.Name))
switch run.Status {
case "active":
monster := arenaGetMonster(run.Tier, run.Round)
b.WriteString(fmt.Sprintf("Round: %d/4\n", run.Round))
if monster != nil {
b.WriteString(fmt.Sprintf("Opponent: %s\n", monster.Name))
}
b.WriteString(fmt.Sprintf("Earnings: €%d (at risk)\n", run.Earnings))
b.WriteString(fmt.Sprintf("Rounds survived: %d\n", run.RoundsSurvived))
b.WriteString("\n`!arena fight` to continue")
case "awaiting":
b.WriteString(fmt.Sprintf("Tier %d cleared — awaiting decision\n", run.Tier))
b.WriteString(fmt.Sprintf("Earnings: €%d (at risk)\n", run.Earnings))
b.WriteString(fmt.Sprintf("Rounds survived: %d\n", run.RoundsSurvived))
b.WriteString("\n`!arena descend` or `!arena cashout`")
}
return b.String()
}
// ── Leaderboard ─────────────────────────────────────────────────────────────
type ArenaLeaderboardEntry struct {
DisplayName string
TotalEarnings int64
HighestTier int
Tier5Completions int
TotalRuns int
TotalDeaths int
}
func renderArenaLeaderboard(entries []ArenaLeaderboardEntry) string {
if len(entries) == 0 {
return "⚔️ **Arena Leaderboard**\n\nNo arena runs recorded yet. Be the first."
}
var b strings.Builder
b.WriteString("⚔️ **Arena Leaderboard**\n\n")
medals := []string{"🥇", "🥈", "🥉"}
for i, e := range entries {
prefix := fmt.Sprintf("%d.", i+1)
if i < 3 {
prefix = medals[i]
}
tierLabel := fmt.Sprintf("T%d", e.HighestTier)
if e.Tier5Completions > 0 {
tierLabel = fmt.Sprintf("T5×%d", e.Tier5Completions)
}
b.WriteString(fmt.Sprintf("%s **%s** — €%d earned | %s | %d runs | %d deaths\n",
prefix, e.DisplayName, e.TotalEarnings, tierLabel, e.TotalRuns, e.TotalDeaths))
}
return b.String()
}
// ── Tier Entry Confirmation ──────────────────────────────────────────────────
func renderArenaTierConfirm(tier *ArenaTier, firstMonster *ArenaMonster) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("⚔️ **Tier %d — %s**\n\n", tier.Number, tier.Name))
b.WriteString(fmt.Sprintf("4 rounds. You cannot leave mid-tier. Death forfeits all earnings.\n\n"))
b.WriteString(fmt.Sprintf("Round 1 opponent: **%s**\n", firstMonster.Name))
b.WriteString(fmt.Sprintf("_%s_\n\n", firstMonster.Flavor))
b.WriteString("`!arena fight` — Enter and fight Round 1\n")
b.WriteString("`!arena cancel` — Back out")
return b.String()
}
// ── Personal Stats ──────────────────────────────────────────────────────────
type ArenaPersonalStats struct {
TotalRuns int
TotalEarnings int64
TotalDeaths int
HighestTier int
Tier5Completions int
}
func renderArenaPersonalStats(char *AdventureCharacter, stats *ArenaPersonalStats) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("⚔️ **%s's Arena Stats**\n\n", char.DisplayName))
if stats == nil || stats.TotalRuns == 0 {
b.WriteString("No arena runs yet. Type `!arena` to begin.")
return b.String()
}
b.WriteString(fmt.Sprintf("Total runs: %d\n", stats.TotalRuns))
b.WriteString(fmt.Sprintf("Total earnings: €%d\n", stats.TotalEarnings))
b.WriteString(fmt.Sprintf("Total deaths: %d\n", stats.TotalDeaths))
b.WriteString(fmt.Sprintf("Highest tier cleared: %d\n", stats.HighestTier))
if stats.TotalRuns > 0 {
survivalRate := float64(stats.TotalRuns-stats.TotalDeaths) / float64(stats.TotalRuns) * 100
b.WriteString(fmt.Sprintf("Survival rate: %.0f%%\n", survivalRate))
}
if stats.Tier5Completions > 0 {
b.WriteString(fmt.Sprintf("Tier 5 completions: %d\n", stats.Tier5Completions))
}
b.WriteString(fmt.Sprintf("\nArena W/L: %d/%d", char.ArenaWins, char.ArenaLosses))
return b.String()
}
// ── Level Gate Message ──────────────────────────────────────────────────────
func renderArenaLevelGate(tier *ArenaTier, playerLevel int) string {
return fmt.Sprintf(
"⚔️ Tier %d — %s requires Combat Level %d. You are Level %d. "+
"The Arena does not negotiate.",
tier.Number, tier.Name, tier.MinLevel, playerLevel)
}
// ── Already In Run Message ──────────────────────────────────────────────────
func renderArenaAlreadyInRun(run *ArenaRun) string {
switch run.Status {
case "awaiting":
return fmt.Sprintf(
"You have a pending arena decision. Tier %d cleared, €%d at risk.\n\n"+
"`!arena descend` or `!arena cashout`",
run.Tier, run.Earnings)
default:
return fmt.Sprintf(
"You're already in an arena run. Tier %d, Round %d.\n\n"+
"`!arena fight` to continue.",
run.Tier, run.Round)
}
}

View File

@@ -0,0 +1,527 @@
package plugin
import (
"math"
"strings"
"testing"
)
// ── Monster Data Tests ──────────────────────────────────────────────────────
func TestArenaTierData(t *testing.T) {
if len(arenaTiers) != 5 {
t.Fatalf("expected 5 arena tiers, got %d", len(arenaTiers))
}
for i, tier := range arenaTiers {
if tier.Number != i+1 {
t.Errorf("tier %d: Number = %d, want %d", i, tier.Number, i+1)
}
if tier.Name == "" {
t.Errorf("tier %d: empty name", tier.Number)
}
if tier.MinLevel <= 0 {
t.Errorf("tier %d: MinLevel %d <= 0", tier.Number, tier.MinLevel)
}
if tier.BasePayout <= 0 {
t.Errorf("tier %d: BasePayout %d <= 0", tier.Number, tier.BasePayout)
}
if tier.SkillMultiplier <= 0 {
t.Errorf("tier %d: SkillMultiplier %f <= 0", tier.Number, tier.SkillMultiplier)
}
if tier.CompletionBonus <= 0 {
t.Errorf("tier %d: CompletionBonus %d <= 0", tier.Number, tier.CompletionBonus)
}
if tier.BattleXP <= 0 {
t.Errorf("tier %d: BattleXP %d <= 0", tier.Number, tier.BattleXP)
}
for j, m := range tier.Monsters {
if m.Name == "" {
t.Errorf("tier %d round %d: empty monster name", tier.Number, j+1)
}
if m.Flavor == "" {
t.Errorf("tier %d round %d: empty monster flavor", tier.Number, j+1)
}
if m.BaseLethality < 0.01 || m.BaseLethality > 0.99 {
t.Errorf("tier %d round %d: BaseLethality %f out of [0.01, 0.99]", tier.Number, j+1, m.BaseLethality)
}
if m.ThreatLevel <= 0 {
t.Errorf("tier %d round %d: ThreatLevel %d <= 0", tier.Number, j+1, m.ThreatLevel)
}
}
}
}
func TestArenaGetTier(t *testing.T) {
for i := 1; i <= 5; i++ {
tier := arenaGetTier(i)
if tier == nil {
t.Errorf("arenaGetTier(%d) returned nil", i)
}
}
if arenaGetTier(0) != nil {
t.Error("arenaGetTier(0) should return nil")
}
if arenaGetTier(6) != nil {
t.Error("arenaGetTier(6) should return nil")
}
}
func TestArenaGetMonster(t *testing.T) {
for tier := 1; tier <= 5; tier++ {
for round := 1; round <= 4; round++ {
m := arenaGetMonster(tier, round)
if m == nil {
t.Errorf("arenaGetMonster(%d, %d) returned nil", tier, round)
}
}
}
if arenaGetMonster(1, 0) != nil {
t.Error("arenaGetMonster(1, 0) should return nil")
}
if arenaGetMonster(1, 5) != nil {
t.Error("arenaGetMonster(1, 5) should return nil")
}
}
func TestArenaMonsterLethality_Increasing(t *testing.T) {
// Within each tier, lethality should increase per round
for i, tier := range arenaTiers {
for j := 1; j < 4; j++ {
if tier.Monsters[j].BaseLethality < tier.Monsters[j-1].BaseLethality {
t.Errorf("tier %d: round %d lethality (%f) < round %d (%f)",
i+1, j+1, tier.Monsters[j].BaseLethality, j, tier.Monsters[j-1].BaseLethality)
}
}
}
}
func TestArenaMinLevel_Increasing(t *testing.T) {
for i := 1; i < 5; i++ {
if arenaTiers[i].MinLevel <= arenaTiers[i-1].MinLevel {
t.Errorf("tier %d MinLevel (%d) <= tier %d MinLevel (%d)",
i+1, arenaTiers[i].MinLevel, i, arenaTiers[i-1].MinLevel)
}
}
}
// ── Death Chance Formula Tests ──────────────────────────────────────────────
func TestArenaDeathChance_Clamped(t *testing.T) {
// Min-level player with tier 0 gear vs easiest monster
char := &AdventureCharacter{CombatLevel: 1}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 0}, SlotArmor: {Tier: 0}, SlotHelmet: {Tier: 0},
SlotBoots: {Tier: 0}, SlotTool: {Tier: 0},
}
monster := arenaGetMonster(1, 1)
dc := arenaDeathChance(monster, char, equip)
if dc < 0.01 || dc > 0.98 {
t.Errorf("death chance %f out of [0.01, 0.98] bounds", dc)
}
}
func TestArenaDeathChance_MaxGearReduces(t *testing.T) {
// Use a mid-tier monster where gear difference is visible above the floor
char := &AdventureCharacter{CombatLevel: 25}
monster := arenaGetMonster(3, 3) // Behemoth Adjacent (0.73 lethality)
noGear := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 0}, SlotArmor: {Tier: 0}, SlotHelmet: {Tier: 0},
SlotBoots: {Tier: 0}, SlotTool: {Tier: 0},
}
maxGear := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 5}, SlotArmor: {Tier: 5}, SlotHelmet: {Tier: 5},
SlotBoots: {Tier: 5}, SlotTool: {Tier: 5},
}
dcNoGear := arenaDeathChance(monster, char, noGear)
dcMaxGear := arenaDeathChance(monster, char, maxGear)
if dcMaxGear >= dcNoGear {
t.Errorf("max gear (%f) should reduce death chance vs no gear (%f)", dcMaxGear, dcNoGear)
}
}
func TestArenaDeathChance_HighLevelReduces(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 3}, SlotArmor: {Tier: 3}, SlotHelmet: {Tier: 3},
SlotBoots: {Tier: 3}, SlotTool: {Tier: 3},
}
monster := arenaGetMonster(3, 4) // The Inevitable
lowLevel := &AdventureCharacter{CombatLevel: 25}
highLevel := &AdventureCharacter{CombatLevel: 50}
dcLow := arenaDeathChance(monster, lowLevel, equip)
dcHigh := arenaDeathChance(monster, highLevel, equip)
if dcHigh >= dcLow {
t.Errorf("high level (%f) should have lower death chance than low level (%f)", dcHigh, dcLow)
}
}
func TestArenaDeathChance_T5R4_AlwaysTerrifying(t *testing.T) {
// Even max-everything player faces high death at T5R4
char := &AdventureCharacter{CombatLevel: 50}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 5}, SlotArmor: {Tier: 5}, SlotHelmet: {Tier: 5},
SlotBoots: {Tier: 5}, SlotTool: {Tier: 5},
}
monster := arenaGetMonster(5, 4) // That Which Has Always Been
dc := arenaDeathChance(monster, char, equip)
if dc < 0.30 {
t.Errorf("T5R4 death chance for max player (%f) should be >= 0.30", dc)
}
// Should hit the 0.98 ceiling
if dc > 0.98 {
t.Errorf("T5R4 death chance (%f) exceeds ceiling 0.98", dc)
}
}
func TestArenaDeathChance_Floor(t *testing.T) {
// Even with absurd stats, floor is 0.01
char := &AdventureCharacter{CombatLevel: 50}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 5}, SlotArmor: {Tier: 5}, SlotHelmet: {Tier: 5},
SlotBoots: {Tier: 5}, SlotTool: {Tier: 5},
}
monster := &ArenaMonster{BaseLethality: 0.01, ThreatLevel: 1}
dc := arenaDeathChance(monster, char, equip)
if dc < 0.01 {
t.Errorf("death chance %f below floor 0.01", dc)
}
}
// ── Reward Formula Tests ────────────────────────────────────────────────────
func TestArenaRoundReward(t *testing.T) {
tier1 := arenaGetTier(1)
// Round 1, no skill bonus
r := arenaRoundReward(tier1, 1, 0)
if r != 150 {
t.Errorf("T1R1 skill=0: got %d, want 150", r)
}
// Round 4, no skill bonus
r = arenaRoundReward(tier1, 4, 0)
if r != 600 {
t.Errorf("T1R4 skill=0: got %d, want 600", r)
}
// Round 1 with skill
r = arenaRoundReward(tier1, 1, 10)
// base=150, skill_bonus=floor(10*1.0)=10
if r != 160 {
t.Errorf("T1R1 skill=10: got %d, want 160", r)
}
// Tier 5 round 4, max skill
tier5 := arenaGetTier(5)
r = arenaRoundReward(tier5, 4, 50)
// base=15000*4=60000, skill_bonus=floor(50*25.0)=1250
if r != 61250 {
t.Errorf("T5R4 skill=50: got %d, want 61250", r)
}
}
func TestArenaRewardScaling(t *testing.T) {
// Rewards should increase with round number
tier := arenaGetTier(3)
prev := int64(0)
for round := 1; round <= 4; round++ {
r := arenaRoundReward(tier, round, 20)
if r <= prev {
t.Errorf("T3R%d reward (%d) should exceed R%d (%d)", round, r, round-1, prev)
}
prev = r
}
}
// ── Death Chance Formula Component Tests ────────────────────────────────────
func TestArenaDeathChance_Components(t *testing.T) {
// Verify formula components individually
// death_chance = base + level_mod - equip_mod + skill_mod
// Level 1, tier 0 gear, T1R1 monster (lethality=0.10, threat=2)
char := &AdventureCharacter{CombatLevel: 1}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 0}, SlotArmor: {Tier: 0}, SlotHelmet: {Tier: 0},
SlotBoots: {Tier: 0}, SlotTool: {Tier: 0},
}
monster := &ArenaMonster{BaseLethality: 0.10, ThreatLevel: 2}
dc := arenaDeathChance(monster, char, equip)
// Manual calculation:
// base = 0.10
// level_mod = (2-1) * 0.015 = 0.015
// skill_mod = max(0, 0.25 - 1*0.008) = 0.242
// equip_mod = 0 * 0.03 = 0 (tier 0 gives no equipment bonus)
// death_chance = 0.10 + 0.015 - 0 + 0.242 = 0.357
expected := 0.10 + 0.015 + 0.242
if math.Abs(dc-expected) > 0.001 {
t.Errorf("T1R1 components: got %f, expected ~%f", dc, expected)
}
}
// ── Render Tests ────────────────────────────────────────────────────────────
func TestRenderArenaTierMenu(t *testing.T) {
char := &AdventureCharacter{CombatLevel: 30}
// nil stats — no tiers cleared
text := renderArenaTierMenu(char, nil)
if !strings.Contains(text, "THE ARENA") {
t.Error("tier menu should contain header")
}
if !strings.Contains(text, "Tier 1") {
t.Error("tier menu should list tier 1")
}
if !strings.Contains(text, "Tier 5") {
t.Error("tier menu should list tier 5")
}
// Level 30 should have tiers 1-3 eligible (⬚) and 4-5 locked (🔒)
if !strings.Contains(text, "⬚") {
t.Error("tier menu should show eligible-but-uncleared tiers")
}
if !strings.Contains(text, "🔒") {
t.Error("tier menu should show locked tiers")
}
// with stats — tier 2 cleared
stats := &ArenaPersonalStats{TotalRuns: 5, TotalDeaths: 1, TotalEarnings: 3000, HighestTier: 2}
text = renderArenaTierMenu(char, stats)
if !strings.Contains(text, "✅") {
t.Error("tier menu should show cleared tiers when stats provided")
}
if !strings.Contains(text, "Runs: 5") {
t.Error("tier menu should show run summary when stats provided")
}
}
func TestRenderArenaRoundStart(t *testing.T) {
tier := arenaGetTier(2)
monster := arenaGetMonster(2, 1)
run := &ArenaRun{Earnings: 500}
text := renderArenaRoundStart(tier, 1, monster, run)
if !strings.Contains(text, "Tier 2") {
t.Error("round start should show tier")
}
if !strings.Contains(text, monster.Name) {
t.Error("round start should show monster name")
}
if !strings.Contains(text, "€500") {
t.Error("round start should show earnings")
}
if !strings.Contains(text, "!arena fight") {
t.Error("round start should show fight command")
}
}
func TestRenderArenaSurvival(t *testing.T) {
tier := arenaGetTier(1)
monster := arenaGetMonster(1, 1)
text := renderArenaSurvival(tier, 1, monster, 150, 10, 150)
if !strings.Contains(text, "defeated") {
t.Error("survival should mention defeat")
}
if !strings.Contains(text, "€150") {
t.Error("survival should show reward")
}
if !strings.Contains(text, "+10") {
t.Error("survival should show XP")
}
}
func TestRenderArenaDeath(t *testing.T) {
tier := arenaGetTier(3)
monster := arenaGetMonster(3, 2)
text := renderArenaDeath(tier, 2, monster, 5000, "The Arena has collected its fee.")
if !strings.Contains(text, "DEAD") {
t.Error("death should say DEAD")
}
if !strings.Contains(text, "€5000") {
t.Error("death should show forfeited earnings")
}
if !strings.Contains(text, "midnight UTC") {
t.Error("death should mention lockout")
}
}
func TestRenderArenaTierComplete(t *testing.T) {
tier := arenaGetTier(2)
text := renderArenaTierComplete(tier, 10000, 15000)
if !strings.Contains(text, "cleared") {
t.Error("tier complete should say cleared")
}
if !strings.Contains(text, "€10000") {
t.Error("tier complete should show completion bonus")
}
if !strings.Contains(text, "descend") {
t.Error("tier complete should offer descend")
}
if !strings.Contains(text, "cashout") {
t.Error("tier complete should offer cashout")
}
if !strings.Contains(text, "10 minutes") {
t.Error("tier complete should mention deadline")
}
}
func TestRenderArenaTier5Complete(t *testing.T) {
// Non-full run
text := renderArenaTier5Complete(650000, 3)
if !strings.Contains(text, "CONQUERED") {
t.Error("T5 complete should say conquered")
}
if !strings.Contains(text, "€650000") {
t.Error("T5 complete should show earnings")
}
if strings.Contains(text, "All the way down") {
t.Error("non-full run should NOT mention all the way down")
}
// Full run (started from tier 1)
textFull := renderArenaTier5Complete(1000000, 1)
if !strings.Contains(textFull, "All the way down") {
t.Error("full run should mention all the way down")
}
}
func TestRenderArenaCashout(t *testing.T) {
text := renderArenaCashout(25000, 3)
if !strings.Contains(text, "€25000") {
t.Error("cashout should show amount")
}
if !strings.Contains(text, "Tier 3") {
t.Error("cashout should show last tier")
}
}
func TestRenderArenaAutoCashout(t *testing.T) {
text := renderArenaAutoCashout(10000)
if !strings.Contains(text, "Auto-cashout") {
t.Error("auto-cashout should identify itself")
}
if !strings.Contains(text, "€10000") {
t.Error("auto-cashout should show amount")
}
if !strings.Contains(text, "annoyed") {
t.Error("auto-cashout should mention GogoBee is annoyed")
}
}
func TestRenderArenaLeaderboard_Empty(t *testing.T) {
text := renderArenaLeaderboard(nil)
if !strings.Contains(text, "No arena runs") {
t.Error("empty leaderboard should say no runs")
}
}
func TestRenderArenaLeaderboard_WithEntries(t *testing.T) {
entries := []ArenaLeaderboardEntry{
{DisplayName: "Alice", TotalEarnings: 100000, HighestTier: 5, Tier5Completions: 1, TotalRuns: 5, TotalDeaths: 2},
{DisplayName: "Bob", TotalEarnings: 50000, HighestTier: 3, TotalRuns: 10, TotalDeaths: 7},
}
text := renderArenaLeaderboard(entries)
if !strings.Contains(text, "Alice") {
t.Error("leaderboard should contain Alice")
}
if !strings.Contains(text, "Bob") {
t.Error("leaderboard should contain Bob")
}
if !strings.Contains(text, "T5×1") {
t.Error("leaderboard should show T5 completions")
}
}
func TestRenderArenaLevelGate(t *testing.T) {
tier := arenaGetTier(3)
text := renderArenaLevelGate(tier, 10)
if !strings.Contains(text, "Level 25") {
t.Error("gate message should show required level")
}
if !strings.Contains(text, "Level 10") {
t.Error("gate message should show player level")
}
}
func TestRenderArenaStatus(t *testing.T) {
run := &ArenaRun{Tier: 3, Round: 2, Status: "active", Earnings: 5000, RoundsSurvived: 5}
char := &AdventureCharacter{CombatLevel: 30}
text := renderArenaStatus(run, char)
if !strings.Contains(text, "Tier: 3") {
t.Error("status should show tier")
}
if !strings.Contains(text, "Round: 2/4") {
t.Error("status should show round")
}
if !strings.Contains(text, "€5000") {
t.Error("status should show earnings")
}
}
// ── Death Message Tests ─────────────────────────────────────────────────────
func TestArenaDeathMessages_NotEmpty(t *testing.T) {
if len(arenaDeathMessages) == 0 {
t.Error("arenaDeathMessages pool is empty")
}
if len(arenaMonsterDeathMessages) == 0 {
t.Error("arenaMonsterDeathMessages pool is empty")
}
}
func TestArenaPickDeathMessage(t *testing.T) {
monster := arenaGetMonster(2, 3) // The Impersonator
for i := 0; i < 20; i++ {
msg := arenaPickDeathMessage(monster, 2, 3)
if msg == "" {
t.Error("death message should not be empty")
}
}
}
// ── Full Run Reward Calculation ─────────────────────────────────────────────
func TestArenaFullRunRewards_Tier1(t *testing.T) {
// Calculate total earnings for a clean Tier 1 run (no skill)
tier := arenaGetTier(1)
var total int64
for round := 1; round <= 4; round++ {
total += arenaRoundReward(tier, round, 0)
}
total += tier.CompletionBonus
// Round rewards: 150 + 300 + 450 + 600 = 1500; + 2500 bonus = 4000
if total != 4000 {
t.Errorf("clean T1 run (no skill): got €%d, want €4000", total)
}
}
func TestArenaFullRunRewards_AllTiers(t *testing.T) {
// A full T1-T5 run with skill=0 should be well over 100k
var grandTotal int64
for tier := 1; tier <= 5; tier++ {
t := arenaGetTier(tier)
for round := 1; round <= 4; round++ {
grandTotal += arenaRoundReward(t, round, 0)
}
grandTotal += t.CompletionBonus
}
if grandTotal < 100000 {
// Sanity check that the numbers aren't accidentally tiny
t.Errorf("full T1-T5 run (no skill) = €%d, expected > €100,000", grandTotal)
}
}

View File

@@ -38,6 +38,7 @@ type AdventureCharacter struct {
Alive bool Alive bool
DeadUntil *time.Time DeadUntil *time.Time
ActionTakenToday bool ActionTakenToday bool
HolidayActionTaken bool
ArenaWins int // v2 ArenaWins int // v2
ArenaLosses int // v2 ArenaLosses int // v2
InvasionScore int // v2 InvasionScore int // v2
@@ -213,14 +214,14 @@ func checkAdvLevelUp(char *AdventureCharacter, skill string) (bool, int) {
func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
d := db.Get() d := db.Get()
c := &AdventureCharacter{} c := &AdventureCharacter{}
var alive, actionTaken int var alive, actionTaken, holidayTaken int
var deadUntil sql.NullTime var deadUntil sql.NullTime
err := d.QueryRow(` err := d.QueryRow(`
SELECT user_id, display_name, SELECT user_id, display_name,
combat_level, mining_skill, foraging_skill, fishing_skill, combat_level, mining_skill, foraging_skill, fishing_skill,
combat_xp, mining_xp, foraging_xp, fishing_xp, combat_xp, mining_xp, foraging_xp, fishing_xp,
alive, dead_until, action_taken_today, alive, dead_until, action_taken_today, holiday_action_taken,
arena_wins, arena_losses, invasion_score, title, arena_wins, arena_losses, invasion_score, title,
current_streak, best_streak, last_action_date, grudge_location, current_streak, best_streak, last_action_date, grudge_location,
created_at, last_active_at created_at, last_active_at
@@ -228,7 +229,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
&c.UserID, &c.DisplayName, &c.UserID, &c.DisplayName,
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill, &c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
&c.CombatXP, &c.MiningXP, &c.ForagingXP, &c.FishingXP, &c.CombatXP, &c.MiningXP, &c.ForagingXP, &c.FishingXP,
&alive, &deadUntil, &actionTaken, &alive, &deadUntil, &actionTaken, &holidayTaken,
&c.ArenaWins, &c.ArenaLosses, &c.InvasionScore, &c.Title, &c.ArenaWins, &c.ArenaLosses, &c.InvasionScore, &c.Title,
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation, &c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
&c.CreatedAt, &c.LastActiveAt, &c.CreatedAt, &c.LastActiveAt,
@@ -238,6 +239,7 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
} }
c.Alive = alive == 1 c.Alive = alive == 1
c.ActionTakenToday = actionTaken == 1 c.ActionTakenToday = actionTaken == 1
c.HolidayActionTaken = holidayTaken == 1
if deadUntil.Valid { if deadUntil.Valid {
c.DeadUntil = &deadUntil.Time c.DeadUntil = &deadUntil.Time
} }
@@ -283,19 +285,23 @@ func saveAdvCharacter(char *AdventureCharacter) error {
if char.ActionTakenToday { if char.ActionTakenToday {
actionTaken = 1 actionTaken = 1
} }
holidayTaken := 0
if char.HolidayActionTaken {
holidayTaken = 1
}
_, err := d.Exec(` _, err := d.Exec(`
UPDATE adventure_characters SET UPDATE adventure_characters SET
display_name = ?, combat_level = ?, mining_skill = ?, foraging_skill = ?, fishing_skill = ?, display_name = ?, combat_level = ?, mining_skill = ?, foraging_skill = ?, fishing_skill = ?,
combat_xp = ?, mining_xp = ?, foraging_xp = ?, fishing_xp = ?, combat_xp = ?, mining_xp = ?, foraging_xp = ?, fishing_xp = ?,
alive = ?, dead_until = ?, action_taken_today = ?, alive = ?, dead_until = ?, action_taken_today = ?, holiday_action_taken = ?,
arena_wins = ?, arena_losses = ?, invasion_score = ?, title = ?, arena_wins = ?, arena_losses = ?, invasion_score = ?, title = ?,
current_streak = ?, best_streak = ?, last_action_date = ?, grudge_location = ?, current_streak = ?, best_streak = ?, last_action_date = ?, grudge_location = ?,
last_active_at = CURRENT_TIMESTAMP last_active_at = CURRENT_TIMESTAMP
WHERE user_id = ?`, WHERE user_id = ?`,
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill, char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP, char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
alive, char.DeadUntil, actionTaken, alive, char.DeadUntil, actionTaken, holidayTaken,
char.ArenaWins, char.ArenaLosses, char.InvasionScore, char.Title, char.ArenaWins, char.ArenaLosses, char.InvasionScore, char.Title,
char.CurrentStreak, char.BestStreak, char.LastActionDate, char.GrudgeLocation, char.CurrentStreak, char.BestStreak, char.LastActionDate, char.GrudgeLocation,
string(char.UserID), string(char.UserID),
@@ -400,7 +406,7 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
SELECT user_id, display_name, SELECT user_id, display_name,
combat_level, mining_skill, foraging_skill, fishing_skill, combat_level, mining_skill, foraging_skill, fishing_skill,
combat_xp, mining_xp, foraging_xp, fishing_xp, combat_xp, mining_xp, foraging_xp, fishing_xp,
alive, dead_until, action_taken_today, alive, dead_until, action_taken_today, holiday_action_taken,
arena_wins, arena_losses, invasion_score, title, arena_wins, arena_losses, invasion_score, title,
current_streak, best_streak, last_action_date, grudge_location, current_streak, best_streak, last_action_date, grudge_location,
created_at, last_active_at created_at, last_active_at
@@ -413,13 +419,13 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
var chars []AdventureCharacter var chars []AdventureCharacter
for rows.Next() { for rows.Next() {
c := AdventureCharacter{} c := AdventureCharacter{}
var alive, actionTaken int var alive, actionTaken, holidayTaken int
var deadUntil sql.NullTime var deadUntil sql.NullTime
if err := rows.Scan( if err := rows.Scan(
&c.UserID, &c.DisplayName, &c.UserID, &c.DisplayName,
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill, &c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
&c.CombatXP, &c.MiningXP, &c.ForagingXP, &c.FishingXP, &c.CombatXP, &c.MiningXP, &c.ForagingXP, &c.FishingXP,
&alive, &deadUntil, &actionTaken, &alive, &deadUntil, &actionTaken, &holidayTaken,
&c.ArenaWins, &c.ArenaLosses, &c.InvasionScore, &c.Title, &c.ArenaWins, &c.ArenaLosses, &c.InvasionScore, &c.Title,
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation, &c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
&c.CreatedAt, &c.LastActiveAt, &c.CreatedAt, &c.LastActiveAt,
@@ -428,6 +434,7 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
} }
c.Alive = alive == 1 c.Alive = alive == 1
c.ActionTakenToday = actionTaken == 1 c.ActionTakenToday = actionTaken == 1
c.HolidayActionTaken = holidayTaken == 1
if deadUntil.Valid { if deadUntil.Valid {
c.DeadUntil = &deadUntil.Time c.DeadUntil = &deadUntil.Time
} }
@@ -441,7 +448,7 @@ func resetAllAdvDailyActions() error {
// Only reset actions taken before today — protects against race if a player // Only reset actions taken before today — protects against race if a player
// resolves their action at exactly midnight. // resolves their action at exactly midnight.
today := time.Now().UTC().Format("2006-01-02") 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) _, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0, holiday_action_taken = 0 WHERE last_action_date < ? OR last_action_date IS NULL`, today)
return err return err
} }

View File

@@ -27,7 +27,7 @@ var DungeonDeath = map[int][]string{
"found themselves in the position regardless. Here we are.", "found themselves in the position regardless. Here we are.",
"GAME OVER. INSERT COIN TO CONTINUE. You do not have a coin. You have " + "GAME OVER. INSERT COIN TO CONTINUE. You do not have a coin. You have " +
"a copay and a 24-hour wait and a healthcare system that is processing " + "a copay and a wait-until-tomorrow and a healthcare system that is processing " +
"your claim and will get back to you. The arcade never prepared you for this. " + "your claim and will get back to you. The arcade never prepared you for this. " +
"Nothing prepared you for this. You went into a cellar.", "Nothing prepared you for this. You went into a cellar.",
@@ -58,7 +58,7 @@ var DungeonDeath = map[int][]string{
"most people get in this situation. You used it to feel regret.", "most people get in this situation. You used it to feel regret.",
"Press F. Just press F. The Soggy Cellar has won this round, which is " + "Press F. Just press F. The Soggy Cellar has won this round, which is " +
"a sentence that should embarrass everyone involved. Respawn in 24 hours. " + "a sentence that should embarrass everyone involved. Respawn tomorrow. " +
"Use the time to consider whether 'adventurer' was the right career path " + "Use the time to consider whether 'adventurer' was the right career path " +
"or whether it is simply the path you are on.", "or whether it is simply the path you are on.",
@@ -95,7 +95,7 @@ var DungeonDeath = map[int][]string{
"You had a plan. The plan was good. The plan did not account for there " + "You had a plan. The plan was good. The plan did not account for there " +
"being two rats instead of one, which in retrospect is the kind of " + "being two rats instead of one, which in retrospect is the kind of " +
"assumption that ends careers. Your career has not ended. It has paused, " + "assumption that ends careers. Your career has not ended. It has paused, " +
"for 24 hours, in a medical facility, reconsidering its assumptions.", "until tomorrow, in a medical facility, reconsidering its assumptions.",
"An Angry Badger and a Wet Slime and a Giant Rat walked into a cellar. " + "An Angry Badger and a Wet Slime and a Giant Rat walked into a cellar. " +
"You were already in the cellar. This is not a joke. There is no punchline. " + "You were already in the cellar. This is not a joke. There is no punchline. " +
@@ -144,7 +144,7 @@ var DungeonDeath = map[int][]string{
"The goblins coordinated. Actually coordinated — flanked you, " + "The goblins coordinated. Actually coordinated — flanked you, " +
"drew your attention left while the attack came right, " + "drew your attention left while the attack came right, " +
"used the terrain. You did not know goblins did that. " + "used the terrain. You did not know goblins did that. " +
"You know now. The knowledge cost 24 hours and some dignity.", "You know now. The knowledge cost a day and some dignity.",
"A Kobold with a crossbow. In the dark. Across a room you couldn't " + "A Kobold with a crossbow. In the dark. Across a room you couldn't " +
"see clearly. This is not a fair fight. Nothing about the Goblin Warrens " + "see clearly. This is not a fair fight. Nothing about the Goblin Warrens " +
@@ -171,7 +171,7 @@ var DungeonDeath = map[int][]string{
"The goblin was smaller than you. The goblin was faster than you. " + "The goblin was smaller than you. The goblin was faster than you. " +
"The goblin had friends and you had a Slightly Less Shit Iron Sword " + "The goblin had friends and you had a Slightly Less Shit Iron Sword " +
"and misplaced confidence. The misplaced confidence did not survive " + "and misplaced confidence. The misplaced confidence did not survive " +
"the encounter. Neither did you, technically, for 24 hours.", "the encounter. Neither did you, technically, until tomorrow.",
"You got lost. In the warrens. Which are, by definition, a warren — " + "You got lost. In the warrens. Which are, by definition, a warren — " +
"a maze of tunnels built by creatures who live there and know " + "a maze of tunnels built by creatures who live there and know " +
@@ -256,7 +256,7 @@ var DungeonDeath = map[int][]string{
"a melee crypt — close quarters, low ceilings, short sight lines. " + "a melee crypt — close quarters, low ceilings, short sight lines. " +
"The skeleton archers had compensated for this " + "The skeleton archers had compensated for this " +
"by being very accurate at short range. Live and learn. " + "by being very accurate at short range. Live and learn. " +
"In 24 hours. When you are alive again.", "Tomorrow. When you are alive again.",
"Something in the Cursed Crypt cursed you specifically, " + "Something in the Cursed Crypt cursed you specifically, " +
"which is both an achievement and a disaster. " + "which is both an achievement and a disaster. " +
@@ -267,7 +267,7 @@ var DungeonDeath = map[int][]string{
"The Draugr spoke. You did not know they spoke. Nobody warned you. " + "The Draugr spoke. You did not know they spoke. Nobody warned you. " +
"You stopped, briefly, because something in you wanted to hear " + "You stopped, briefly, because something in you wanted to hear " +
"what a Draugr had to say. The Draugr used this pause efficiently. " + "what a Draugr had to say. The Draugr used this pause efficiently. " +
"You will not stop next time. There will be a next time in 24 hours.", "You will not stop next time. There will be a next time tomorrow.",
"Dark room. Something in it. You had a torch. The torch went out. " + "Dark room. Something in it. You had a torch. The torch went out. " +
"The something in the room did not require light to function. " + "The something in the room did not require light to function. " +
@@ -368,7 +368,7 @@ var DungeonDeath = map[int][]string{
5: { 5: {
"The demon didn't fight you. It waited until you were tired " + "The demon didn't fight you. It waited until you were tired " +
"and then it stopped waiting. There's a lesson in that. " + "and then it stopped waiting. There's a lesson in that. " +
"The lesson is available in 24 hours.", "The lesson is available tomorrow.",
"An Elder Drake. You know what that means. " + "An Elder Drake. You know what that means. " +
"You knew before you went in. You went in anyway. " + "You knew before you went in. You went in anyway. " +
@@ -395,7 +395,7 @@ var DungeonDeath = map[int][]string{
"YOU DIED in the Abyssal Maw. In large, honest letters. " + "YOU DIED in the Abyssal Maw. In large, honest letters. " +
"The large, honest letters are impressed, which is " + "The large, honest letters are impressed, which is " +
"more than the large honest letters usually feel. " + "more than the large honest letters usually feel. " +
"Recovery in 24 hours. The Maw will be there.", "Recovery tomorrow. The Maw will be there.",
"The floor in the deepest chamber of the Abyssal Maw is not a floor. " + "The floor in the deepest chamber of the Abyssal Maw is not a floor. " +
"You know this now. What it is remains unclear " + "You know this now. What it is remains unclear " +

View File

@@ -0,0 +1,322 @@
package plugin
import (
"strings"
"time"
)
// ── Holiday Definitions ─────────────────────────────────────────────────────
//
// On major holidays, active adventurers get two actions instead of one.
// isHolidayToday() checks both fixed and floating holidays against UTC date.
//
// All floating holidays are computed algorithmically — no hardcoded dates.
// Easter: Meeus-Jones-Butcher computus
// Hebrew calendar: molad + dehiyyot (Rosh Hashanah, Yom Kippur, Passover, Hanukkah)
// Islamic calendar: Tabular Islamic calendar (Eid al-Fitr, Eid al-Adha, ±1 day)
// Nth-Monday: Canadian statutory holidays
//
// Holidays we can't reliably compute (Diwali, Vesak, Lunar New Year) are
// intentionally omitted rather than requiring yearly hardcoded updates.
type adventureHoliday struct {
Month int
Day int
Name string
}
type adventureFloatingHoliday struct {
Date time.Time
Name string
}
// Fixed-date holidays (month/day pairs).
var majorHolidays = []adventureHoliday{
// Global / US
{Month: 1, Day: 1, Name: "New Year's Day"},
{Month: 2, Day: 14, Name: "Valentine's Day"},
{Month: 3, Day: 17, Name: "St. Patrick's Day"},
{Month: 4, Day: 1, Name: "April Fools' Day"},
{Month: 5, Day: 5, Name: "Cinco de Mayo"},
{Month: 7, Day: 4, Name: "Independence Day"},
{Month: 10, Day: 31, Name: "Halloween"},
{Month: 11, Day: 1, Name: "Día de los Muertos / All Saints' Day"},
{Month: 12, Day: 25, Name: "Christmas"},
{Month: 12, Day: 31, Name: "New Year's Eve"},
// Canadian
{Month: 7, Day: 1, Name: "Canada Day"},
{Month: 9, Day: 30, Name: "National Day for Truth and Reconciliation"},
{Month: 11, Day: 11, Name: "Remembrance Day"},
{Month: 12, Day: 26, Name: "Boxing Day"},
// Portuguese
{Month: 4, Day: 25, Name: "Dia da Liberdade"},
{Month: 5, Day: 1, Name: "Dia do Trabalhador"},
{Month: 6, Day: 10, Name: "Dia de Portugal"},
{Month: 8, Day: 15, Name: "Assunção de Nossa Senhora"},
{Month: 10, Day: 5, Name: "Implantação da República"},
{Month: 12, Day: 1, Name: "Restauração da Independência"},
{Month: 12, Day: 8, Name: "Imaculada Conceição"},
}
// floatingHolidays returns all computed floating holidays for the given year.
func floatingHolidays(year int) []adventureFloatingHoliday {
easter := easterDate(year)
holidays := []adventureFloatingHoliday{
// Easter-derived (Gregorian computus)
{Date: easter.AddDate(0, 0, -47), Name: "Mardi Gras / Carnaval"},
{Date: easter.AddDate(0, 0, -2), Name: "Good Friday"},
{Date: easter, Name: "Easter Sunday"},
{Date: easter.AddDate(0, 0, 60), Name: "Corpo de Deus"}, // Corpus Christi PT
// Nowruz — tied to spring equinox, March 20 in most years
{Date: time.Date(year, time.March, 20, 0, 0, 0, 0, time.UTC), Name: "Nowruz"},
// Canadian Nth-Monday holidays
{Date: nthMonday(year, time.February, 3), Name: "Family Day"},
{Date: mondayBeforeMay25(year), Name: "Victoria Day"},
{Date: nthMonday(year, time.September, 1), Name: "Labour Day"},
{Date: nthMonday(year, time.October, 2), Name: "Thanksgiving"},
// Hebrew calendar (molad + dehiyyot)
{Date: hebrewPassover(year), Name: "Passover"},
{Date: hebrewRoshHashanah(year), Name: "Rosh Hashanah"},
{Date: hebrewYomKippur(year), Name: "Yom Kippur"},
{Date: hebrewHanukkah(year), Name: "Hanukkah"},
}
// Tabular Islamic calendar (±1 day from observed)
// These can occur twice in a single Gregorian year since the Islamic
// calendar is ~11 days shorter, so we append all occurrences.
for _, d := range tabularIslamicDates(year, 10, 1) {
holidays = append(holidays, adventureFloatingHoliday{Date: d, Name: "Eid al-Fitr"})
}
for _, d := range tabularIslamicDates(year, 12, 10) {
holidays = append(holidays, adventureFloatingHoliday{Date: d, Name: "Eid al-Adha"})
}
return holidays
}
// isHolidayToday checks both fixed and floating holidays against the current UTC date.
// Returns (true, displayName) if today is a holiday, (false, "") otherwise.
// When multiple holidays fall on the same date, all names are joined with " / ".
func isHolidayToday() (bool, string) {
now := time.Now().UTC()
month := int(now.Month())
day := now.Day()
year := now.Year()
var names []string
// Check fixed holidays
for _, h := range majorHolidays {
if h.Month == month && h.Day == day {
names = append(names, h.Name)
}
}
// Check floating holidays
todayDate := time.Date(year, now.Month(), day, 0, 0, 0, 0, time.UTC)
for _, fh := range floatingHolidays(year) {
if fh.Date.Equal(todayDate) {
names = append(names, fh.Name)
}
}
if len(names) == 0 {
return false, ""
}
return true, strings.Join(names, " / ")
}
// ── Easter Computation (Anonymous Gregorian / Meeus-Jones-Butcher) ──────────
func easterDate(year int) time.Time {
a := year % 19
b := year / 100
c := year % 100
d := b / 4
e := b % 4
f := (b + 8) / 25
g := (b - f + 1) / 3
h := (19*a + b - d - g + 15) % 30
i := c / 4
k := c % 4
l := (32 + 2*e + 2*i - h - k) % 7
m := (a + 11*h + 22*l) / 451
month := (h + l - 7*m + 114) / 31
day := ((h + l - 7*m + 114) % 31) + 1
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
}
// ── Nth Monday / Monday Before ──────────────────────────────────────────────
// nthMonday returns the nth Monday of the given month/year (1-indexed).
func nthMonday(year int, month time.Month, n int) time.Time {
first := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
offset := (int(time.Monday) - int(first.Weekday()) + 7) % 7
return first.AddDate(0, 0, offset+(n-1)*7)
}
// mondayBeforeMay25 returns the last Monday on or before May 24 (Victoria Day).
func mondayBeforeMay25(year int) time.Time {
may24 := time.Date(year, time.May, 24, 0, 0, 0, 0, time.UTC)
offset := (int(may24.Weekday()) - int(time.Monday) + 7) % 7
return may24.AddDate(0, 0, -offset)
}
// ── Hebrew Calendar (Molad + Dehiyyot) ──────────────────────────────────────
//
// Computes Rosh Hashanah from the mean lunation (molad) with the four
// postponement rules (dehiyyot). All other Hebrew holidays derive from it.
//
// Day-of-week convention: 0=Mon, 1=Tue, 2=Wed, 3=Thu, 4=Fri, 5=Sat, 6=Sun.
// Time is measured in halakim (parts): 1 hour = 1080 parts.
//
// Reference: Dershowitz & Reingold, "Calendrical Calculations"
func hebrewLeapYear(hYear int) bool {
return (7*hYear+1)%19 < 7
}
// hebrewNewYearDay returns the day number (from the Hebrew epoch) of 1 Tishrei
// for the given Hebrew year, after applying the four dehiyyot.
func hebrewNewYearDay(hYear int) int {
y := hYear - 1
months := 235*(y/19) + 12*(y%19) + (7*(y%19)+1)/19
// Molad BaHaRaD: day 2, 5 hours, 204 parts (Hebrew time, hours from 6 PM)
// Lunar month: 29 days, 12 hours, 793 parts
p := 204 + 793*(months%1080)
h := 5 + 12*months + 793*(months/1080) + p/1080
parts := p%1080 + 1080*(h%24) // time-of-day in parts (Hebrew hours from 6 PM)
day := 2 + 29*months + h/24 // +2 for BaHaRaD day offset (day 2 = Monday)
// Weekday: 0=Sat, 1=Sun, 2=Mon, 3=Tue, 4=Wed, 5=Thu, 6=Fri
dow := day % 7
// Apply dehiyyot (postponement rules)
alt := parts >= 19440 || // Molad Zaken: molad at or after 18h (= noon civil)
(dow == 3 && parts >= 9924 && !hebrewLeapYear(hYear)) || // GaTaRaD: Tue >= 9h204p
(dow == 2 && parts >= 16789 && hebrewLeapYear(hYear-1)) // BeTuTaKPaT: Mon >= 15h589p
if alt {
day++
dow = day % 7
}
// Lo ADU: Rosh Hashanah cannot fall on Sun(1), Wed(4), or Fri(6)
if dow == 1 || dow == 4 || dow == 6 {
day++
}
return day
}
// hebrewEpochJDN: day 2 in the Hebrew count = JDN 347998 (Monday Oct 7, 3761 BCE Julian),
// so day N maps to JDN = N + 347996.
const hebrewEpochJDN = 347996
// hebrewRoshHashanah returns the Gregorian date of 1 Tishrei for the
// Hebrew year starting in the given Gregorian year's autumn.
func hebrewRoshHashanah(gregYear int) time.Time {
hYear := gregYear + 3761
return gregorianFromJDN(hebrewNewYearDay(hYear) + hebrewEpochJDN)
}
// hebrewYomKippur returns the Gregorian date of 10 Tishrei (Rosh Hashanah + 9 days).
func hebrewYomKippur(gregYear int) time.Time {
return hebrewRoshHashanah(gregYear).AddDate(0, 0, 9)
}
// hebrewPassover returns the Gregorian date of 15 Nisan for the given year.
// 15 Nisan is always exactly 163 days before the next Rosh Hashanah.
func hebrewPassover(gregYear int) time.Time {
hYear := gregYear + 3761 // RH of this Hebrew year falls in gregYear's autumn
nextRHDay := hebrewNewYearDay(hYear)
return gregorianFromJDN(nextRHDay - 163 + hebrewEpochJDN)
}
// hebrewHanukkah returns the Gregorian date of 25 Kislev for the Hebrew year
// starting in the given Gregorian year's autumn.
// Offset from RH depends on whether Cheshvan has 29 or 30 days (year type).
func hebrewHanukkah(gregYear int) time.Time {
hYear := gregYear + 3761
rhDay := hebrewNewYearDay(hYear)
yearLen := hebrewNewYearDay(hYear+1) - rhDay
// Cheshvan has 30 days in "complete" years (355 or 385 days)
cheshvan := 29
if yearLen == 355 || yearLen == 385 {
cheshvan = 30
}
// 25 Kislev = Tishrei(30) + Cheshvan + 24 days into Kislev
offset := 30 + cheshvan + 24
return gregorianFromJDN(rhDay + offset + hebrewEpochJDN)
}
// ── Tabular Islamic Calendar ────────────────────────────────────────────────
//
// Approximates Islamic dates using the standard 30-year intercalation cycle.
// Accuracy: ±1 day from observed dates (which depend on moon sighting).
//
// Reference: Dershowitz & Reingold, "Calendrical Calculations"
// islamicEpochRD is the Rata Die of 1 Muharram 1 AH (July 19, 622 CE Gregorian).
const islamicEpochRD = 227015
// islamicToRD converts an Islamic date to Rata Die (days since Jan 1, 1 CE).
func islamicToRD(year, month, day int) int {
return islamicEpochRD - 1 +
(year-1)*354 +
(3+11*year)/30 +
29*(month-1) +
month/2 +
day
}
// tabularIslamicDates returns all Gregorian dates in the given Gregorian year
// that correspond to the specified Islamic month/day. Usually returns one date,
// but can return two since the Islamic calendar is ~11 days shorter per year.
func tabularIslamicDates(gregYear, iMonth, iDay int) []time.Time {
// Approximate Islamic year from Gregorian year
approxYear := int(float64(gregYear-622) * 33.0 / 32.0)
var dates []time.Time
for y := approxYear - 1; y <= approxYear+1; y++ {
rd := islamicToRD(y, iMonth, iDay)
d := gregorianFromRD(rd)
if d.Year() == gregYear {
dates = append(dates, d)
}
}
return dates
}
// gregorianFromRD converts a Rata Die to a Gregorian date.
// R.D. 1 = January 1, 1 CE = JDN 1721426.
func gregorianFromRD(rd int) time.Time {
return gregorianFromJDN(rd + 1721425)
}
// ── Julian Day Number ↔ Gregorian ──────────────────────────────────────────
//
// Meeus, "Astronomical Algorithms" — standard JDN conversion.
func gregorianFromJDN(jdn int) time.Time {
a := jdn + 32044
b := (4*a + 3) / 146097
c := a - 146097*b/4
d := (4*c + 3) / 1461
e := c - 1461*d/4
m := (5*e + 2) / 153
day := e - (153*m+2)/5 + 1
month := m + 3 - 12*(m/10)
year := 100*b + d - 4800 + m/10
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
}

View File

@@ -0,0 +1,329 @@
package plugin
import (
"strings"
"testing"
"time"
)
func TestEasterDate(t *testing.T) {
tests := []struct {
year int
month time.Month
day int
}{
{2024, time.March, 31},
{2025, time.April, 20},
{2026, time.April, 5},
{2027, time.March, 28},
{2028, time.April, 16},
}
for _, tc := range tests {
got := easterDate(tc.year)
want := time.Date(tc.year, tc.month, tc.day, 0, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Errorf("Easter %d: got %s, want %s", tc.year, got.Format("2006-01-02"), want.Format("2006-01-02"))
}
}
}
func TestNthMonday(t *testing.T) {
tests := []struct {
desc string
year int
month time.Month
n int
want string
}{
{"Family Day 2026", 2026, time.February, 3, "2026-02-16"},
{"Labour Day 2026", 2026, time.September, 1, "2026-09-07"},
{"Thanksgiving CA 2026", 2026, time.October, 2, "2026-10-12"},
{"Family Day 2027", 2027, time.February, 3, "2027-02-15"},
}
for _, tc := range tests {
got := nthMonday(tc.year, tc.month, tc.n)
if got.Format("2006-01-02") != tc.want {
t.Errorf("%s: got %s, want %s", tc.desc, got.Format("2006-01-02"), tc.want)
}
}
}
func TestMondayBeforeMay25(t *testing.T) {
tests := []struct {
year int
want string
}{
{2026, "2026-05-18"},
{2027, "2027-05-24"},
{2028, "2028-05-22"},
}
for _, tc := range tests {
got := mondayBeforeMay25(tc.year)
if got.Format("2006-01-02") != tc.want {
t.Errorf("Victoria Day %d: got %s, want %s", tc.year, got.Format("2006-01-02"), tc.want)
}
}
}
// ── Hebrew Calendar Tests ───────────────────────────────────────────────────
func TestHebrewRoshHashanah(t *testing.T) {
// Verified against hebcal.com
tests := []struct {
year int
want string
}{
{2023, "2023-09-16"}, // 5784
{2024, "2024-10-03"}, // 5785
{2025, "2025-09-23"}, // 5786
{2026, "2026-09-12"}, // 5787
{2027, "2027-10-02"}, // 5788
{2028, "2028-09-21"}, // 5789
{2030, "2030-09-28"}, // 5791
}
for _, tc := range tests {
got := hebrewRoshHashanah(tc.year)
if got.Format("2006-01-02") != tc.want {
t.Errorf("Rosh Hashanah %d: got %s, want %s", tc.year, got.Format("2006-01-02"), tc.want)
}
}
}
func TestHebrewYomKippur(t *testing.T) {
tests := []struct {
year int
want string
}{
{2026, "2026-09-21"},
{2027, "2027-10-11"},
}
for _, tc := range tests {
got := hebrewYomKippur(tc.year)
if got.Format("2006-01-02") != tc.want {
t.Errorf("Yom Kippur %d: got %s, want %s", tc.year, got.Format("2006-01-02"), tc.want)
}
}
}
func TestHebrewPassover(t *testing.T) {
// 15 Nisan (first full day of Pesach)
tests := []struct {
year int
want string
}{
{2024, "2024-04-23"},
{2025, "2025-04-13"},
{2026, "2026-04-02"},
{2027, "2027-04-22"},
}
for _, tc := range tests {
got := hebrewPassover(tc.year)
if got.Format("2006-01-02") != tc.want {
t.Errorf("Passover %d: got %s, want %s", tc.year, got.Format("2006-01-02"), tc.want)
}
}
}
func TestHebrewHanukkah(t *testing.T) {
// 25 Kislev (first full day)
tests := []struct {
year int
want string
}{
{2024, "2024-12-26"},
{2025, "2025-12-15"},
{2026, "2026-12-05"},
{2027, "2027-12-25"},
}
for _, tc := range tests {
got := hebrewHanukkah(tc.year)
if got.Format("2006-01-02") != tc.want {
t.Errorf("Hanukkah %d: got %s, want %s", tc.year, got.Format("2006-01-02"), tc.want)
}
}
}
// ── Tabular Islamic Calendar Tests ──────────────────────────────────────────
func TestIslamicEidAlFitr(t *testing.T) {
// Tabular Islamic calendar — ±1 day from observed
tests := []struct {
year int
want string
}{
{2026, "2026-03-20"},
{2027, "2027-03-10"},
}
for _, tc := range tests {
dates := tabularIslamicDates(tc.year, 10, 1)
if len(dates) == 0 {
t.Errorf("Eid al-Fitr %d: no dates returned", tc.year)
continue
}
got := dates[0].Format("2006-01-02")
if got != tc.want {
t.Errorf("Eid al-Fitr %d: got %s, want %s", tc.year, got, tc.want)
}
}
}
func TestIslamicEidAlAdha(t *testing.T) {
tests := []struct {
year int
want string
}{
{2026, "2026-05-27"},
{2027, "2027-05-17"}, // Tabular Islamic calendar ±1 day from observed
}
for _, tc := range tests {
dates := tabularIslamicDates(tc.year, 12, 10)
if len(dates) == 0 {
t.Errorf("Eid al-Adha %d: no dates returned", tc.year)
continue
}
got := dates[0].Format("2006-01-02")
if got != tc.want {
t.Errorf("Eid al-Adha %d: got %s, want %s", tc.year, got, tc.want)
}
}
}
// ── Integration Tests ───────────────────────────────────────────────────────
func TestFloatingHolidays_AllComputed(t *testing.T) {
for year := 2024; year <= 2035; year++ {
holidays := floatingHolidays(year)
if len(holidays) < 14 { // at least: 4 Easter + Nowruz + 4 Canadian + 4 Hebrew + 2 Islamic
t.Errorf("year %d: only %d floating holidays, expected at least 14", year, len(holidays))
}
// Verify no zero-time dates (would indicate a computation failure)
for _, h := range holidays {
if h.Date.IsZero() {
t.Errorf("year %d: holiday %q has zero date", year, h.Name)
}
if h.Date.Year() != year {
t.Errorf("year %d: holiday %q falls in %d", year, h.Name, h.Date.Year())
}
}
}
}
func TestFloatingHolidays_DateCollisions(t *testing.T) {
holidays := floatingHolidays(2026)
seen := make(map[string][]string)
for _, h := range holidays {
key := h.Date.Format("2006-01-02")
seen[key] = append(seen[key], h.Name)
}
for date, names := range seen {
if len(names) > 1 {
t.Logf("NOTE: %d holidays on %s: %s", len(names), date, strings.Join(names, ", "))
}
}
}
func TestFixedHolidays_Christmas(t *testing.T) {
found := false
for _, h := range majorHolidays {
if h.Month == 12 && h.Day == 25 {
found = true
break
}
}
if !found {
t.Error("Christmas not in majorHolidays")
}
}
func TestFixedHolidays_NoDuplicateMonthDay(t *testing.T) {
seen := make(map[[2]int]string)
for _, h := range majorHolidays {
key := [2]int{h.Month, h.Day}
if prev, ok := seen[key]; ok {
t.Errorf("duplicate fixed holiday on %d/%d: %s and %s", h.Month, h.Day, prev, h.Name)
}
seen[key] = h.Name
}
}
func TestHolidaySecondPromptRender(t *testing.T) {
char := &AdventureCharacter{
DisplayName: "TestPlayer",
CombatLevel: 5,
MiningSkill: 3,
ForagingSkill: 2,
}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 1, Condition: 100, Name: "Iron Sword"},
}
bonuses := &AdvBonusSummary{}
text := renderAdvHolidaySecondPrompt(char, equip, bonuses)
if !strings.Contains(text, "Action 1 complete") {
t.Error("second prompt should mention action 1 complete")
}
if !strings.Contains(text, "second action") {
t.Error("second prompt should mention second action")
}
if !strings.Contains(text, "Dungeon") {
t.Error("second prompt should list Dungeon option")
}
}
func TestDailySummary_HolidayBlock(t *testing.T) {
players := []AdvPlayerDaySummary{
{DisplayName: "Alice", Activity: "dungeon", Location: "Cellar", Outcome: "success", LootValue: 500, HolidayActions: 2},
{DisplayName: "Bob", Activity: "mine", Location: "Quarry", Outcome: "success", LootValue: 300, HolidayActions: 1},
{DisplayName: "Carol", IsDead: true, DeadUntil: "14:00 UTC", Activity: "dungeon", Location: "Cave", Outcome: "death", HolidayActions: 1},
}
text := renderAdvDailySummary("2026-12-25", nil, TwinBeeRewardSummary{}, players, "Christmas")
if !strings.Contains(text, "Christmas") {
t.Error("summary should contain holiday name")
}
if !strings.Contains(text, "two actions today") {
t.Error("summary should mention two actions on holidays")
}
if !strings.Contains(text, "before their second action") {
t.Error("summary should note Carol died before second action")
}
// Without holiday
textNoHol := renderAdvDailySummary("2026-12-26", nil, TwinBeeRewardSummary{}, players, "")
if strings.Contains(textNoHol, "two actions") {
t.Error("non-holiday summary should NOT mention two actions")
}
}
// ── JDN Roundtrip Test ──────────────────────────────────────────────────────
func TestGregorianFromJDN_Roundtrip(t *testing.T) {
// Verify known JDN ↔ Gregorian conversions
tests := []struct {
jdn int
date string
}{
{2451545, "2000-01-01"}, // J2000.0
{2440588, "1970-01-01"}, // Unix epoch
{2299161, "1582-10-15"}, // Gregorian calendar adoption
}
for _, tc := range tests {
got := gregorianFromJDN(tc.jdn)
if got.Format("2006-01-02") != tc.date {
t.Errorf("JDN %d: got %s, want %s", tc.jdn, got.Format("2006-01-02"), tc.date)
}
}
}

View File

@@ -177,9 +177,14 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
// ── Morning DM ─────────────────────────────────────────────────────────────── // ── Morning DM ───────────────────────────────────────────────────────────────
func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, balance float64, bonuses *AdvBonusSummary) string { func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, balance float64, bonuses *AdvBonusSummary, holidayName string) string {
var sb strings.Builder var sb strings.Builder
// Holiday notice (before greeting)
if holidayName != "" {
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, you are able to take **two actions** today.\n\n", holidayName, holidayName))
}
// Pick a morning greeting // Pick a morning greeting
greeting, _ := advPickFlavor(MorningDM, char.UserID, "morning_dm") greeting, _ := advPickFlavor(MorningDM, char.UserID, "morning_dm")
vars := map[string]string{ vars := map[string]string{
@@ -255,6 +260,47 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
return sb.String() return sb.String()
} }
// ── Holiday Second Action Prompt ──────────────────────────────────────────────
func renderAdvHolidaySecondPrompt(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, bonuses *AdvBonusSummary) string {
var sb strings.Builder
sb.WriteString("✅ Action 1 complete.\n\nNow choose your **second action**:\n\n")
sb.WriteString("**1⃣ Dungeon:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityDungeon, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**2⃣ Mine:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityMining, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**3⃣ Forage:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityForaging, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**4⃣ Shop** — buy/sell gear and loot\n")
sb.WriteString("**5⃣ Rest** — skip the second action\n\n")
sb.WriteString("Reply with the number and location, e.g: `1 Soggy Cellar`")
return sb.String()
}
// ── Resolution DM ──────────────────────────────────────────────────────────── // ── Resolution DM ────────────────────────────────────────────────────────────
func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) string { func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) string {
@@ -430,13 +476,18 @@ type AdvPlayerDaySummary struct {
DeadUntil string DeadUntil string
IsResting bool IsResting bool
SummaryLine string SummaryLine string
HolidayActions int // 0 = not holiday or no action; 1 = took one; 2 = took both
} }
func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewardSummary, players []AdvPlayerDaySummary) string { func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewardSummary, players []AdvPlayerDaySummary, holidayName string) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(fmt.Sprintf("📜 **ADVENTURER DAILY REPORT**\n%s\n\n", date)) sb.WriteString(fmt.Sprintf("📜 **ADVENTURER DAILY REPORT**\n%s\n\n", date))
if holidayName != "" {
sb.WriteString(fmt.Sprintf("🎉 In recognition of **%s**, adventurers had two actions today.\n\n", holidayName))
}
// TwinBee section // TwinBee section
if tb != nil { if tb != nil {
sb.WriteString("🐝 **TwinBee's Daily Report**\n") sb.WriteString("🐝 **TwinBee's Daily Report**\n")
@@ -530,6 +581,48 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
sb.WriteString("\n") sb.WriteString("\n")
} }
// Holiday stats
if holidayName != "" {
tookBoth := 0
totalActive := 0
for _, p := range players {
if p.IsDead || p.IsResting {
if p.HolidayActions > 0 {
totalActive++
}
if p.HolidayActions >= 2 {
tookBoth++
}
continue
}
if p.Activity != "" {
totalActive++
}
if p.HolidayActions >= 2 {
tookBoth++
}
}
if totalActive > 0 {
sb.WriteString(fmt.Sprintf("🎉 %s double-action day — %d of %d adventurers took both actions.\n\n", holidayName, tookBoth, totalActive))
}
// Note players who died before their second action
for _, d := range dead {
if d.HolidayActions == 1 {
sb.WriteString(fmt.Sprintf("• %s — died in %s before their second action. Rough holiday.\n", d.DisplayName, d.Location))
}
}
if len(dead) > 0 {
// Check if any had HolidayActions == 1
for _, d := range dead {
if d.HolidayActions == 1 {
sb.WriteString("\n")
break
}
}
}
}
// Standout // Standout
if bestPlayer != nil && bestPlayer.LootValue > 0 { if bestPlayer != nil && bestPlayer.LootValue > 0 {
pool := SummaryStandoutGood pool := SummaryStandoutGood

View File

@@ -3,6 +3,7 @@ package plugin
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"math/rand/v2"
"time" "time"
"gogobee/internal/db" "gogobee/internal/db"
@@ -41,10 +42,24 @@ func (p *AdventurePlugin) sendMorningDMs() {
} }
now := time.Now().UTC() now := time.Now().UTC()
isHol, holName := isHolidayToday()
if isHol {
slog.Info("adventure: holiday detected for morning DMs", "holiday", holName)
}
for _, char := range chars { // Shuffle to avoid always hitting the same users first if early sends
// get rate-limited and later ones don't go out.
rand.Shuffle(len(chars), func(i, j int) { chars[i], chars[j] = chars[j], chars[i] })
for i, char := range chars {
char := char char := char
// Jitter between DMs to avoid Matrix rate limits.
// Skip delay before the first one.
if i > 0 {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
}
// Check if dead and ready to respawn // Check if dead and ready to respawn
if !char.Alive && char.DeadUntil != nil && now.After(*char.DeadUntil) { if !char.Alive && char.DeadUntil != nil && now.After(*char.DeadUntil) {
// Revive // Revive
@@ -88,7 +103,12 @@ func (p *AdventurePlugin) sendMorningDMs() {
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false) bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
balance := p.euro.GetBalance(char.UserID) balance := p.euro.GetBalance(char.UserID)
text := renderAdvMorningDM(&char, equip, balance, bonuses) holidayLabel := ""
if isHol {
holidayLabel = holName
}
text := renderAdvMorningDM(&char, equip, balance, bonuses, holidayLabel)
p.advMarkMenuSent(char.UserID)
if err := p.SendDM(char.UserID, text); err != nil { if err := p.SendDM(char.UserID, text); err != nil {
slog.Error("adventure: failed to send morning DM", "user", char.UserID, "err", err) slog.Error("adventure: failed to send morning DM", "user", char.UserID, "err", err)
continue continue
@@ -141,10 +161,26 @@ func (p *AdventurePlugin) postDailySummary() {
} }
todayLogs, _ := loadAdvTodayLogs() todayLogs, _ := loadAdvTodayLogs()
logMap := make(map[id.UserID]*AdvDayLog) // Group logs per user — holiday days may produce 2 entries per user
logsPerUser := make(map[id.UserID][]*AdvDayLog)
for i := range todayLogs { for i := range todayLogs {
logMap[todayLogs[i].UserID] = &todayLogs[i] uid := todayLogs[i].UserID
logsPerUser[uid] = append(logsPerUser[uid], &todayLogs[i])
} }
// logMap picks the last (most recent) log entry for summary display
// lootSums aggregates loot across all actions (relevant on holiday double-action days)
logMap := make(map[id.UserID]*AdvDayLog)
lootSums := make(map[id.UserID]int64)
for uid, logs := range logsPerUser {
logMap[uid] = logs[len(logs)-1]
var total int64
for _, l := range logs {
total += l.LootValue
}
lootSums[uid] = total
}
isHol, holName := isHolidayToday()
// Build player summaries // Build player summaries
var players []AdvPlayerDaySummary var players []AdvPlayerDaySummary
@@ -156,6 +192,11 @@ func (p *AdventurePlugin) postDailySummary() {
ForagingSkill: c.ForagingSkill, ForagingSkill: c.ForagingSkill,
} }
// Holiday action count from log entries
if isHol {
ps.HolidayActions = len(logsPerUser[c.UserID])
}
if !c.Alive { if !c.Alive {
ps.IsDead = true ps.IsDead = true
if c.DeadUntil != nil { if c.DeadUntil != nil {
@@ -166,8 +207,8 @@ func (p *AdventurePlugin) postDailySummary() {
ps.Activity = log.ActivityType ps.Activity = log.ActivityType
ps.Location = log.Location ps.Location = log.Location
ps.Outcome = log.Outcome ps.Outcome = log.Outcome
ps.LootValue = log.LootValue ps.LootValue = lootSums[c.UserID] // aggregate across all actions
ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), log.LootValue, log.Location) ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), lootSums[c.UserID], log.Location)
} }
players = append(players, ps) players = append(players, ps)
continue continue
@@ -187,8 +228,8 @@ func (p *AdventurePlugin) postDailySummary() {
ps.Activity = log.ActivityType ps.Activity = log.ActivityType
ps.Location = log.Location ps.Location = log.Location
ps.Outcome = log.Outcome ps.Outcome = log.Outcome
ps.LootValue = log.LootValue ps.LootValue = lootSums[c.UserID] // aggregate across all actions
ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), log.LootValue, log.Location) ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), lootSums[c.UserID], log.Location)
} }
players = append(players, ps) players = append(players, ps)
@@ -207,7 +248,11 @@ func (p *AdventurePlugin) postDailySummary() {
} }
date := time.Now().UTC().Format("2006-01-02") date := time.Now().UTC().Format("2006-01-02")
summary := renderAdvDailySummary(date, tbResult, tbRewards, players) summaryHolName := ""
if isHol {
summaryHolName = holName
}
summary := renderAdvDailySummary(date, tbResult, tbRewards, players, summaryHolName)
if err := p.SendMessage(id.RoomID(gr), summary); err != nil { if err := p.SendMessage(id.RoomID(gr), summary); err != nil {
slog.Error("adventure: failed to post daily summary", "err", err) slog.Error("adventure: failed to post daily summary", "err", err)
@@ -250,12 +295,19 @@ func (p *AdventurePlugin) midnightReset() error {
today := time.Now().UTC().Format("2006-01-02") today := time.Now().UTC().Format("2006-01-02")
dmsSent := 0
for _, char := range chars { for _, char := range chars {
if !char.Alive { if !char.Alive {
continue continue
} }
if !char.ActionTakenToday { if !char.ActionTakenToday {
// Jitter between DMs to avoid Matrix rate limits
if dmsSent > 0 {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
}
dmsSent++
// Idle shame DM // Idle shame DM
text := renderAdvIdleShameDM(&char) text := renderAdvIdleShameDM(&char)
if err := p.SendDM(char.UserID, text); err != nil { if err := p.SendDM(char.UserID, text); err != nil {

View File

@@ -18,13 +18,15 @@ import (
type BirthdayPlugin struct { type BirthdayPlugin struct {
Base Base
xp *XPPlugin xp *XPPlugin
euro *EuroPlugin
} }
// NewBirthdayPlugin creates a new BirthdayPlugin. // NewBirthdayPlugin creates a new BirthdayPlugin.
func NewBirthdayPlugin(client *mautrix.Client, xp *XPPlugin) *BirthdayPlugin { func NewBirthdayPlugin(client *mautrix.Client, xp *XPPlugin, euro *EuroPlugin) *BirthdayPlugin {
return &BirthdayPlugin{ return &BirthdayPlugin{
Base: NewBase(client), Base: NewBase(client),
xp: xp, xp: xp,
euro: euro,
} }
} }
@@ -273,7 +275,7 @@ func (p *BirthdayPlugin) CheckAndPost(roomID id.RoomID) error {
ageStr = fmt.Sprintf(" They're turning %d!", age) ageStr = fmt.Sprintf(" They're turning %d!", age)
} }
announcement := fmt.Sprintf("Happy Birthday to %s!%s Have an amazing day!", m.userID, ageStr) announcement := fmt.Sprintf("Happy Birthday to %s!%s Have an amazing day! Everyone gets 10 XP and €100 to celebrate!", m.userID, ageStr)
if err := p.SendMessage(roomID, announcement); err != nil { if err := p.SendMessage(roomID, announcement); err != nil {
slog.Error("birthday: send announcement", "user", m.userID, "err", err) slog.Error("birthday: send announcement", "user", m.userID, "err", err)
continue continue
@@ -290,8 +292,13 @@ func (p *BirthdayPlugin) CheckAndPost(roomID id.RoomID) error {
continue // Already granted XP and sent DM continue // Already granted XP and sent DM
} }
// Grant 1,000 euros
if p.euro != nil {
p.euro.Credit(id.UserID(m.userID), 1000, "birthday gift")
}
// Send DM to the birthday person // Send DM to the birthday person
dmMsg := fmt.Sprintf("Happy Birthday! The community wishes you a wonderful day!%s You've been granted 100 bonus XP as a birthday gift!", ageStr) dmMsg := fmt.Sprintf("Happy Birthday! The community wishes you a wonderful day!%s You've been granted 100 bonus XP and €1,000 as a birthday gift!", ageStr)
if err := p.SendDM(id.UserID(m.userID), dmMsg); err != nil { if err := p.SendDM(id.UserID(m.userID), dmMsg); err != nil {
slog.Error("birthday: send DM", "user", m.userID, "err", err) slog.Error("birthday: send DM", "user", m.userID, "err", err)
} }
@@ -303,6 +310,9 @@ func (p *BirthdayPlugin) CheckAndPost(roomID id.RoomID) error {
p.grantBirthdayXP(id.UserID(m.userID), 100) p.grantBirthdayXP(id.UserID(m.userID), 100)
} }
// Grant 10 XP (10% of birthday award) to every other user as a community celebration
p.grantCommunityBirthdayXP(m.userID)
// Mark as fired (for XP/DM dedup) // Mark as fired (for XP/DM dedup)
db.Exec("birthday: mark fired", db.Exec("birthday: mark fired",
`INSERT INTO birthday_fired (user_id, year) VALUES (?, ?) `INSERT INTO birthday_fired (user_id, year) VALUES (?, ?)
@@ -339,6 +349,45 @@ func (p *BirthdayPlugin) grantBirthdayXP(userID id.UserID, amount int) {
string(userID), amount, "birthday") string(userID), amount, "birthday")
} }
// grantCommunityBirthdayXP awards 10 XP and €100 to every user except the birthday person.
func (p *BirthdayPlugin) grantCommunityBirthdayXP(birthdayUserID string) {
d := db.Get()
now := time.Now().UTC().Unix()
// Bulk-update XP for all users except the birthday person
res, err := d.Exec(
`UPDATE users SET xp = xp + 10, last_xp_at = ? WHERE user_id != ?`,
now, birthdayUserID,
)
if err != nil {
slog.Error("birthday: community xp grant", "err", err)
return
}
n, _ := res.RowsAffected()
slog.Info("birthday: community celebration", "users", n, "xp_each", 10, "euro_each", 100)
// Grant €100 to each user
if p.euro != nil {
rows, err := d.Query(`SELECT user_id FROM users WHERE user_id != ?`, birthdayUserID)
if err != nil {
slog.Error("birthday: community euro query", "err", err)
return
}
var userIDs []string
for rows.Next() {
var uid string
if err := rows.Scan(&uid); err != nil {
continue
}
userIDs = append(userIDs, uid)
}
rows.Close()
for _, uid := range userIDs {
p.euro.Credit(id.UserID(uid), 100, "birthday celebration")
}
}
}
// parseBirthdayDate parses MM-DD or MM-DD-YYYY format. // parseBirthdayDate parses MM-DD or MM-DD-YYYY format.
func parseBirthdayDate(s string) (month, day, year int, err error) { func parseBirthdayDate(s string) (month, day, year int, err error) {
parts := strings.Split(s, "-") parts := strings.Split(s, "-")

1108
internal/plugin/market.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -206,7 +206,7 @@ func (sg *SpaceGroupManager) Refresh() {
return return
} }
// Fetch members for each room // Fetch members for each room, skipping DM rooms (≤2 members)
roomMembers := make(map[id.RoomID]map[id.UserID]bool, len(rooms)) roomMembers := make(map[id.RoomID]map[id.UserID]bool, len(rooms))
for _, roomID := range rooms { for _, roomID := range rooms {
resp, err := sg.client.JoinedMembers(ctx, roomID) resp, err := sg.client.JoinedMembers(ctx, roomID)
@@ -214,6 +214,9 @@ func (sg *SpaceGroupManager) Refresh() {
slog.Warn("space_groups: failed to get members", "room", roomID, "err", err) slog.Warn("space_groups: failed to get members", "room", roomID, "err", err)
continue continue
} }
if len(resp.Joined) <= 2 {
continue // skip DM rooms
}
members := make(map[id.UserID]bool, len(resp.Joined)) members := make(map[id.UserID]bool, len(resp.Joined))
for uid := range resp.Joined { for uid := range resp.Joined {
members[uid] = true members[uid] = true
@@ -472,9 +475,9 @@ func (b *Base) ResolveUser(input string, roomIDs ...id.RoomID) (id.UserID, bool)
// (**bold**, _italic_, `code`, newlines) to Matrix-compatible HTML. // (**bold**, _italic_, `code`, newlines) to Matrix-compatible HTML.
var ( var (
mdBoldRe = regexp.MustCompile(`\*\*(.+?)\*\*`) mdBoldRe = regexp.MustCompile(`\*\*(.+?)\*\*`)
mdItalicRe = regexp.MustCompile(`(?:^|[ (])_([^_]+?)_(?:$|[ ).,!?])`) mdItalicRe = regexp.MustCompile(`(?:^|[\n (])_([^_]+?)_(?:$|[\n ).,!?])`)
mdCodeRe = regexp.MustCompile("`([^`]+)`") mdCodeRe = regexp.MustCompile("`([^`]+)`")
mdHasFmt = regexp.MustCompile(`\*\*|(?:^|[ (])_[^_]+_(?:$|[ ).,!?])|` + "`") mdHasFmt = regexp.MustCompile(`\*\*|(?:^|[\n (])_[^_]+_(?:$|[\n ).,!?])|` + "`")
) )
func simpleMarkdownToHTML(text string) string { func simpleMarkdownToHTML(text string) string {

View File

@@ -744,8 +744,8 @@ func (p *UnoPlugin) handlePlayerPlay(game *unoGame, idx int) error {
if game.noMercy && game.stackMinValue > 0 { if game.noMercy && game.stackMinValue > 0 {
if !card.canPlayOnStacking(game.topColor, game.stackMinValue) { if !card.canPlayOnStacking(game.topColor, game.stackMinValue) {
return p.SendMessage(game.dmRoomID, return p.SendMessage(game.dmRoomID,
fmt.Sprintf("You can't stack %s — need a draw card worth +%d or more. Type **accept** to draw %d.", fmt.Sprintf("You can't stack %s — need a draw card (matching color or wild). Type **accept** to draw %d.",
card.Display(), game.stackMinValue, game.stackTotal)) card.Display(), game.stackTotal))
} }
} else if !card.canPlayOn(game.discardTop, game.topColor) { } else if !card.canPlayOn(game.discardTop, game.topColor) {
return p.SendMessage(game.dmRoomID, fmt.Sprintf("You can't play %s on %s. Choose another card or **draw**.", return p.SendMessage(game.dmRoomID, fmt.Sprintf("You can't play %s on %s. Choose another card or **draw**.",

View File

@@ -643,12 +643,26 @@ func (p *UnoPlugin) initMultiGame(players []playerDMPair, roomID id.RoomID, ante
func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) { func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
var roomBuf strings.Builder var roomBuf strings.Builder
botTurnsInRow := 0 botTurnsInRow := 0
loopMsgsSent := 0 // track messages sent across loop iterations to add jitter
for { for {
if game.done { if game.done {
return return
} }
// Jitter between rapid-fire messages to avoid Matrix rate limits.
// Drop the lock during sleep so DM handlers aren't blocked.
if loopMsgsSent > 0 {
savedTurn := game.turnID
game.mu.Unlock()
time.Sleep(time.Duration(300+rand.IntN(400)) * time.Millisecond)
game.mu.Lock()
// A DM handler may have advanced the game while we slept.
if game.done || game.turnID != savedTurn {
return
}
}
player := game.currentPlayer() player := game.currentPlayer()
// Skip eliminated players (e.g. mercy-killed during their own turn) // Skip eliminated players (e.g. mercy-killed during their own turn)
@@ -665,6 +679,7 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
break break
} }
p.botMultiTurn(game, &roomBuf) p.botMultiTurn(game, &roomBuf)
loopMsgsSent++
if game.done { if game.done {
if roomBuf.Len() > 0 { if roomBuf.Len() > 0 {
p.SendMessage(game.roomID, roomBuf.String()) p.SendMessage(game.roomID, roomBuf.String())
@@ -688,11 +703,13 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
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",
game.stackTotal, pickNoMercyCommentary("stack_absorbed"))) game.stackTotal, pickNoMercyCommentary("stack_absorbed")))
p.SendMessage(game.roomID, fmt.Sprintf("💥 %s absorbs the stack! Draws %d cards. (%d cards now)", absorbMsg := fmt.Sprintf("💥 %s absorbs the stack! Draws %d cards. (%d cards now)",
name, game.stackTotal, len(player.hand))) name, game.stackTotal, len(player.hand))
game.stackTotal = 0 game.stackTotal = 0
game.stackMinValue = 0 game.stackMinValue = 0
if p.checkMultiMercyElimination(game, player) { if p.checkMultiMercyElimination(game, player) {
p.SendMessage(game.roomID, absorbMsg)
loopMsgsSent++
if game.done { if game.done {
return return
} }
@@ -703,6 +720,8 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
// Skip this player's turn // Skip this player's turn
game.currentIdx = game.nextActiveIdx() game.currentIdx = game.nextActiveIdx()
game.turnID++ game.turnID++
p.SendMessage(game.roomID, absorbMsg+"\n"+p.nextTurnLabel(game))
loopMsgsSent++
continue continue
} }
// Player has stackable cards — show stacking prompt // Player has stackable cards — show stacking prompt
@@ -744,10 +763,11 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
} }
name := p.DisplayName(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(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()
game.turnID++ game.turnID++
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s has no playable cards and deck is empty. Turn passes. (%d cards)\n%s", name, len(player.hand), p.nextTurnLabel(game)))
loopMsgsSent += 2
continue continue
} }
if playableCard != nil { if playableCard != nil {
@@ -762,9 +782,10 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
} }
p.SendMessage(player.dmRoomID, p.SendMessage(player.dmRoomID,
fmt.Sprintf("No playable cards — drew %d card(s). None playable. Turn passes.", len(allDrawn))) fmt.Sprintf("No playable cards — drew %d card(s). None playable. Turn passes.", len(allDrawn)))
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s draws %d card(s). Turn passes. (%d cards)", name, len(allDrawn), len(player.hand)))
game.currentIdx = game.nextActiveIdx() game.currentIdx = game.nextActiveIdx()
game.turnID++ game.turnID++
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s draws %d card(s). Turn passes. (%d cards)\n%s", name, len(allDrawn), len(player.hand), p.nextTurnLabel(game)))
loopMsgsSent += 2
continue continue
} }
@@ -772,10 +793,11 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
drawn := game.draw(1) drawn := game.draw(1)
if len(drawn) == 0 { if len(drawn) == 0 {
name := p.DisplayName(player.userID) name := p.DisplayName(player.userID)
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s has no playable cards and deck is empty. Turn passes. (%d cards)", name, len(player.hand)))
p.SendMessage(player.dmRoomID, "No playable cards and deck is empty. Turn passes.") p.SendMessage(player.dmRoomID, "No playable cards and deck is empty. Turn passes.")
game.currentIdx = game.nextActiveIdx() game.currentIdx = game.nextActiveIdx()
game.turnID++ game.turnID++
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s has no playable cards and deck is empty. Turn passes. (%d cards)\n%s", name, len(player.hand), p.nextTurnLabel(game)))
loopMsgsSent += 2
continue continue
} }
@@ -794,9 +816,10 @@ func (p *UnoPlugin) executeMultiTurn(game *unoMultiGame) {
name := p.DisplayName(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)))
game.currentIdx = game.nextActiveIdx() game.currentIdx = game.nextActiveIdx()
game.turnID++ game.turnID++
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s draws a card. Turn passes. (%d cards)\n%s", name, len(player.hand), p.nextTurnLabel(game)))
loopMsgsSent += 2
continue continue
} }
@@ -817,6 +840,16 @@ func (p *UnoPlugin) advanceAndExecute(game *unoMultiGame) {
p.executeMultiTurn(game) p.executeMultiTurn(game)
} }
// nextTurnLabel returns "It's X's turn." for the current player.
func (p *UnoPlugin) nextTurnLabel(game *unoMultiGame) string {
next := game.currentPlayer()
name := p.DisplayName(next.userID)
if next.isBot {
name = unoBotName()
}
return fmt.Sprintf("It's %s's turn.", name)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// DM input handling // DM input handling
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -891,20 +924,26 @@ func (p *UnoPlugin) handleMultiDMInput(ctx MessageContext, game *unoMultiGame) e
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",
game.stackTotal, pickNoMercyCommentary("stack_absorbed"))) game.stackTotal, pickNoMercyCommentary("stack_absorbed")))
p.SendMessage(game.roomID, fmt.Sprintf("💥 %s absorbs the stack! Draws %d cards. (%d cards now)", absorbMsg := fmt.Sprintf("💥 %s absorbs the stack! Draws %d cards. (%d cards now)",
name, game.stackTotal, len(player.hand))) name, game.stackTotal, len(player.hand))
game.stackTotal = 0 game.stackTotal = 0
game.stackMinValue = 0 game.stackMinValue = 0
if p.checkMultiMercyElimination(game, player) { if p.checkMultiMercyElimination(game, player) {
p.SendMessage(game.roomID, absorbMsg)
if game.done { if game.done {
return nil return nil
} }
p.advanceAndExecute(game) p.advanceAndExecute(game)
return nil return nil
} }
p.advanceAndExecute(game) game.currentIdx = game.nextActiveIdx()
game.turnID++
game.turns++
p.SendMessage(game.roomID, absorbMsg+"\n"+p.nextTurnLabel(game))
p.executeMultiTurn(game)
return nil return nil
} }
// "accept" alias handled above; "draw" during stacking = same as accept
if input == "draw" { if input == "draw" {
if game.noMercy && game.stackMinValue > 0 { if game.noMercy && game.stackMinValue > 0 {
p.SendMessage(player.dmRoomID, "You must play a draw card to stack, or type **accept** to draw the stack.") p.SendMessage(player.dmRoomID, "You must play a draw card to stack, or type **accept** to draw the stack.")
@@ -954,8 +993,8 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl
if game.noMercy && game.stackMinValue > 0 { if game.noMercy && game.stackMinValue > 0 {
if !card.canPlayOnStacking(game.topColor, game.stackMinValue) { if !card.canPlayOnStacking(game.topColor, game.stackMinValue) {
p.SendMessage(player.dmRoomID, p.SendMessage(player.dmRoomID,
fmt.Sprintf("You can't stack %s — need a draw card worth +%d or more. Type **accept** to draw %d.", fmt.Sprintf("You can't stack %s — need a draw card (matching color or wild). Type **accept** to draw %d.",
card.Display(), game.stackMinValue, game.stackTotal)) card.Display(), game.stackTotal))
return nil return nil
} }
} else if !card.canPlayOn(game.discardTop, game.topColor) { } else if !card.canPlayOn(game.discardTop, game.topColor) {

View File

@@ -31,11 +31,10 @@ func isDrawCard(v unoValue) bool {
} }
// canPlayOnStacking checks if a card can be played during a stacking phase. // canPlayOnStacking checks if a card can be played during a stacking phase.
// Only draw cards with value >= stackMinValue are allowed. // Any draw card can stack on any other draw card (no escalation requirement).
// Wild draws always match; colored draws must match topColor. // Wild draws always match; colored draws must match topColor.
func (c unoCard) canPlayOnStacking(topColor unoColor, stackMinValue int) bool { func (c unoCard) canPlayOnStacking(topColor unoColor, _ int) bool {
dv := cardDrawValue(c.Value) if cardDrawValue(c.Value) == 0 {
if dv < stackMinValue {
return false return false
} }
if c.isWild() { if c.isWild() {

View File

@@ -216,7 +216,7 @@ func TestCanPlayOnStacking(t *testing.T) {
{"draw two on draw two same color", unoCard{unoRed, unoDrawTwo}, 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}, {"draw two wrong color", unoCard{unoBlue, unoDrawTwo}, unoRed, 2, false},
{"number cant stack", unoCard{unoRed, unoFive}, unoRed, 2, false}, {"number cant stack", unoCard{unoRed, unoFive}, unoRed, 2, false},
{"draw two cant stack on 4", unoCard{unoRed, unoDrawTwo}, unoRed, 4, false}, {"draw two stacks on 4 same color", unoCard{unoRed, unoDrawTwo}, unoRed, 4, true},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {

22
main.go
View File

@@ -117,7 +117,8 @@ func main() {
registry.Register(plugin.NewBlackjackPlugin(client, euroPlugin)) registry.Register(plugin.NewBlackjackPlugin(client, euroPlugin))
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)) adventurePlugin := plugin.NewAdventurePlugin(client, euroPlugin)
registry.Register(adventurePlugin)
wordlePlugin := plugin.NewWordlePlugin(client, euroPlugin) wordlePlugin := plugin.NewWordlePlugin(client, euroPlugin)
registry.Register(wordlePlugin) registry.Register(wordlePlugin)
@@ -127,7 +128,9 @@ func main() {
registry.Register(plugin.NewTarotPlugin(client, ratePlugin)) registry.Register(plugin.NewTarotPlugin(client, ratePlugin))
// Tracking (passive) // Tracking (passive)
registry.Register(plugin.NewAchievementsPlugin(client, registry)) achievementsPlugin := plugin.NewAchievementsPlugin(client, registry)
registry.Register(achievementsPlugin)
adventurePlugin.SetAchievements(achievementsPlugin)
registry.Register(plugin.NewReactionsPlugin(client)) registry.Register(plugin.NewReactionsPlugin(client))
registry.Register(plugin.NewMarkovPlugin(client)) registry.Register(plugin.NewMarkovPlugin(client))
registry.Register(plugin.NewURLsPlugin(client)) registry.Register(plugin.NewURLsPlugin(client))
@@ -147,7 +150,7 @@ func main() {
registry.Register(holidaysPlugin) registry.Register(holidaysPlugin)
gamingPlugin := plugin.NewGamingPlugin(client) gamingPlugin := plugin.NewGamingPlugin(client)
registry.Register(gamingPlugin) registry.Register(gamingPlugin)
birthdayPlugin := plugin.NewBirthdayPlugin(client, xpPlugin) birthdayPlugin := plugin.NewBirthdayPlugin(client, xpPlugin, euroPlugin)
registry.Register(birthdayPlugin) registry.Register(birthdayPlugin)
// Satirical // Satirical
@@ -158,6 +161,10 @@ func main() {
horoscopePlugin := plugin.NewHoroscopePlugin(client) horoscopePlugin := plugin.NewHoroscopePlugin(client)
registry.Register(horoscopePlugin) registry.Register(horoscopePlugin)
// Finance — Market overview
marketPlugin := plugin.NewMarketPlugin(client)
registry.Register(marketPlugin)
// Utility / Meta // Utility / Meta
registry.Register(plugin.NewBotInfoPlugin(client)) registry.Register(plugin.NewBotInfoPlugin(client))
registry.Register(plugin.NewHowAmIPlugin(client)) registry.Register(plugin.NewHowAmIPlugin(client))
@@ -285,7 +292,7 @@ func main() {
// ---- Set up cron scheduler ---- // ---- Set up cron scheduler ----
scheduler := cron.New(cron.WithChain(cron.Recover(cronLogger{}))) scheduler := cron.New(cron.WithChain(cron.Recover(cronLogger{})))
setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin, forexPlugin, minifluxPlugin) setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin, forexPlugin, minifluxPlugin, marketPlugin)
scheduler.Start() scheduler.Start()
// ---- Start syncing ---- // ---- Start syncing ----
@@ -338,6 +345,7 @@ func setupScheduledJobs(
esteemed *plugin.EsteemPlugin, esteemed *plugin.EsteemPlugin,
forex *plugin.ForexPlugin, forex *plugin.ForexPlugin,
miniflux *plugin.MinifluxPlugin, miniflux *plugin.MinifluxPlugin,
market *plugin.MarketPlugin,
) { ) {
rooms := getRooms() rooms := getRooms()
@@ -421,6 +429,12 @@ func setupScheduledJobs(
forex.DailyPoll() forex.DailyPoll()
}) })
// Market data daily pull at 23:00 UTC (after all target markets close)
c.AddFunc("0 23 * * *", func() {
slog.Info("scheduler: market daily pull")
market.DailyPull()
})
// 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")