mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Add adventure plugin, holdem CFR fixes, and wordle plugin
Adventure: Complete v1 daily idle RPG with DM-driven gameplay, equipment shop, treasure system, TwinBee NPC, streak/grudge/party mechanics, flavor text, and scheduled morning/evening/midnight tickers. Holdem CFR: Fix three critical training bugs (fold not forfeiting pot, free calls after raises, training/runtime key mismatch). Add performance optimizations (preflop lookup table, zero-alloc equity, integer keys, raise cap, regret pruning). Enrich abstraction with 12 equity buckets, board texture dimension, and 6-char action history. Replace validation with proper multi-street simulation. Also includes wordle plugin, holdem seed tooling, and schema additions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
15
.env.example
15
.env.example
@@ -39,7 +39,7 @@ 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)
|
||||||
|
|
||||||
# ---- Games & Economy ----
|
# ---- Games & Economy ----
|
||||||
GAMES_ROOM= # room ID where game commands work (trivia, hangman, blackjack, flip)
|
GAMES_ROOM= # room ID where game commands work (trivia, hangman, blackjack, holdem, wordle, flip)
|
||||||
EURO_COOLDOWN_SECONDS=30 # cooldown between passive euro earning
|
EURO_COOLDOWN_SECONDS=30 # cooldown between passive euro earning
|
||||||
EURO_DEBT_REMINDER=true # weekly DM reminder if in debt
|
EURO_DEBT_REMINDER=true # weekly DM reminder if in debt
|
||||||
EURO_STARTING_CAP=2500 # max starting balance from corpus
|
EURO_STARTING_CAP=2500 # max starting balance from corpus
|
||||||
@@ -59,6 +59,19 @@ UNO_MULTI_LOBBY_TIMEOUT=300 # lobby expiry in seconds (default 300 = 5min)
|
|||||||
UNO_MULTI_TURN_TIMEOUT=30 # auto-play timeout in seconds (default 30)
|
UNO_MULTI_TURN_TIMEOUT=30 # auto-play timeout in seconds (default 30)
|
||||||
UNO_MULTI_MAX_AUTOPLAY=3 # consecutive auto-plays before forfeit
|
UNO_MULTI_MAX_AUTOPLAY=3 # consecutive auto-plays before forfeit
|
||||||
|
|
||||||
|
# Texas Hold'em
|
||||||
|
HOLDEM_SMALL_BLIND=10 # small blind amount
|
||||||
|
HOLDEM_BIG_BLIND=20 # big blind amount
|
||||||
|
HOLDEM_MIN_BUYIN=200 # minimum balance to join
|
||||||
|
HOLDEM_MAX_BUYIN=2000 # maximum stack at buy-in
|
||||||
|
HOLDEM_TIMEOUT_SECONDS=90 # action timeout per turn
|
||||||
|
HOLDEM_NPC_NAME=TwinBee # NPC bot display name
|
||||||
|
HOLDEM_NPC_HOUSE_BALANCE=10000 # NPC starting bankroll
|
||||||
|
HOLDEM_CFR_POLICY=data/policy.gob # path to CFR policy file
|
||||||
|
|
||||||
|
# Wordle
|
||||||
|
WORDLE_DEFAULT_LENGTH=5 # default word length (5, 6, or 7)
|
||||||
|
|
||||||
# ---- Space Groups (automatic room grouping for community-wide leaderboards) ----
|
# ---- Space Groups (automatic room grouping for community-wide leaderboards) ----
|
||||||
SPACE_GROUP_THRESHOLD=50 # % of smaller room's members that must overlap (1-100, default 50)
|
SPACE_GROUP_THRESHOLD=50 # % of smaller room's members that must overlap (1-100, default 50)
|
||||||
|
|
||||||
|
|||||||
105
README.md
105
README.md
@@ -30,7 +30,7 @@ Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption a
|
|||||||
- **E2EE that actually works** - mautrix-go with goolm (pure Go). Crypto state lives in SQLite so device keys survive restarts. Cross-signing bootstraps on first run — the bot self-verifies its own device.
|
- **E2EE that actually works** - mautrix-go with goolm (pure Go). Crypto state lives in SQLite so device keys survive restarts. Cross-signing bootstraps on first run — the bot self-verifies its own device.
|
||||||
- **No CGo, no system deps** - builds to a single static binary. Cross-compile to whatever you want.
|
- **No CGo, no system deps** - builds to a single static binary. Cross-compile to whatever you want.
|
||||||
- **43 plugins** with dependency injection and ordered registration
|
- **43 plugins** with dependency injection and ordered registration
|
||||||
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 2–4 player multiplayer via DMs, with optional No Mercy mode), all with channel restriction
|
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 2–4 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips), Wordle (daily cooperative, Wordnik-powered, 5-7 letter words), 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
|
||||||
@@ -144,7 +144,7 @@ Everything is configured through environment variables or a `.env` file.
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `GAMES_ROOM` | | Room ID where game commands work (trivia, hangman, blackjack, flip) |
|
| `GAMES_ROOM` | | Room ID where game commands work (trivia, hangman, blackjack, holdem, wordle, flip) |
|
||||||
| `EURO_COOLDOWN_SECONDS` | `30` | Cooldown between passive euro earning per user |
|
| `EURO_COOLDOWN_SECONDS` | `30` | Cooldown between passive euro earning per user |
|
||||||
| `EURO_DEBT_REMINDER` | `true` | Weekly DM reminder if player is in debt |
|
| `EURO_DEBT_REMINDER` | `true` | Weekly DM reminder if player is in debt |
|
||||||
| `EURO_STARTING_CAP` | `2500` | Max starting balance seeded from corpus |
|
| `EURO_STARTING_CAP` | `2500` | Max starting balance seeded from corpus |
|
||||||
@@ -163,6 +163,15 @@ Everything is configured through environment variables or a `.env` file.
|
|||||||
| `UNO_MULTI_LOBBY_TIMEOUT` | `300` | Lobby expiry in seconds |
|
| `UNO_MULTI_LOBBY_TIMEOUT` | `300` | Lobby expiry in seconds |
|
||||||
| `UNO_MULTI_TURN_TIMEOUT` | `30` | Auto-play timeout in seconds |
|
| `UNO_MULTI_TURN_TIMEOUT` | `30` | Auto-play timeout in seconds |
|
||||||
| `UNO_MULTI_MAX_AUTOPLAY` | `3` | Consecutive auto-plays before forfeit |
|
| `UNO_MULTI_MAX_AUTOPLAY` | `3` | Consecutive auto-plays before forfeit |
|
||||||
|
| `HOLDEM_SMALL_BLIND` | `10` | Small blind amount |
|
||||||
|
| `HOLDEM_BIG_BLIND` | `20` | Big blind amount |
|
||||||
|
| `HOLDEM_MIN_BUYIN` | `200` | Minimum balance to join |
|
||||||
|
| `HOLDEM_MAX_BUYIN` | `2000` | Maximum stack at buy-in |
|
||||||
|
| `HOLDEM_TIMEOUT_SECONDS` | `90` | Action timeout per turn |
|
||||||
|
| `HOLDEM_NPC_NAME` | `TwinBee` | NPC bot display name |
|
||||||
|
| `HOLDEM_NPC_HOUSE_BALANCE` | `10000` | NPC starting bankroll |
|
||||||
|
| `HOLDEM_CFR_POLICY` | `data/policy.gob` | Path to CFR policy file |
|
||||||
|
| `WORDLE_DEFAULT_LENGTH` | `5` | Default word length (5, 6, or 7) |
|
||||||
|
|
||||||
### Moderation
|
### Moderation
|
||||||
|
|
||||||
@@ -360,6 +369,78 @@ Enabled by adding `7-0` to the command. When active:
|
|||||||
- **Play a 7** — swap hands with another player of your choice (in multiplayer, you pick the target)
|
- **Play a 7** — swap hands with another player of your choice (in multiplayer, you pick the target)
|
||||||
- **Play a 0** — all players pass their hand to the next player in play direction
|
- **Play a 0** — all players pass their hand to the next player in play direction
|
||||||
|
|
||||||
|
### Texas Hold'em (games channel only)
|
||||||
|
|
||||||
|
No-limit Texas Hold'em poker for 2-9 players. Buy-in is debited from your euro balance when you sit down; your remaining stack is cashed out when you leave. Private cards and coaching tips are delivered via DM — the room only sees start/end announcements.
|
||||||
|
|
||||||
|
An optional AI opponent (NPC) uses a CFR-trained poker solver. Add one with `!holdem addbot`.
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `!holdem join` | Sit down at the table |
|
||||||
|
| `!holdem leave` | Leave the table (cashes out stack) |
|
||||||
|
| `!holdem start` | Start dealing (2+ players) |
|
||||||
|
| `!holdem addbot` | Add a CFR-trained AI opponent |
|
||||||
|
| `!holdem fold` | Fold your hand |
|
||||||
|
| `!holdem check` | Check (no bet to call) |
|
||||||
|
| `!holdem call` | Call the current bet |
|
||||||
|
| `!holdem raise <amount>` | Raise to a total of amount |
|
||||||
|
| `!holdem allin` | Go all-in |
|
||||||
|
| `!holdem status` | Current table state (sent via DM) |
|
||||||
|
| `!holdem help` | Show in-game help |
|
||||||
|
|
||||||
|
**DM commands:** `!holdem tips on/off` — toggle coaching tips (equity + pot odds analysis, powered by Ollama with rules-based fallback).
|
||||||
|
|
||||||
|
#### NPC Bot
|
||||||
|
|
||||||
|
The NPC uses Counterfactual Regret Minimization (External Sampling MCCFR), trained via self-play. The policy table ships as `data/policy.gob` and is loaded at startup. The bot plays a mixed strategy — it randomizes actions according to its trained probability distribution, so it won't always make the same play in the same spot.
|
||||||
|
|
||||||
|
#### Training the NPC
|
||||||
|
|
||||||
|
A standalone training CLI is provided under `cmd/holdem-train/`. It requires the `training` build tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the training CLI
|
||||||
|
go build -tags training -o holdem-train ./cmd/holdem-train/
|
||||||
|
|
||||||
|
# Train with 8 workers (defaults to all CPU cores if --workers is omitted)
|
||||||
|
./holdem-train --iterations 5000000 --workers 8 --output data/policy.gob
|
||||||
|
|
||||||
|
# Resume from a checkpoint
|
||||||
|
./holdem-train --iterations 5000000 --workers 8 --resume data/policy.gob.checkpoint --output data/policy.gob
|
||||||
|
|
||||||
|
# Validate the trained policy (10K hands vs random baseline)
|
||||||
|
./holdem-train --validate --output data/policy.gob
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| `--iterations` | `5000000` | Number of training iterations |
|
||||||
|
| `--workers` | `runtime.NumCPU()` | Parallel workers |
|
||||||
|
| `--output` | `data/policy.gob` | Output policy file |
|
||||||
|
| `--resume` | | Resume from checkpoint |
|
||||||
|
| `--validate` | `false` | Run validation instead of training |
|
||||||
|
| `--checkpoint-every` | `500000` | Checkpoint interval |
|
||||||
|
| `--seed` | `42` | Random seed |
|
||||||
|
|
||||||
|
Progress is logged with overall completion percentage and ETA. Checkpoints are saved every 30 seconds during parallel training.
|
||||||
|
|
||||||
|
### Wordle (games channel only)
|
||||||
|
|
||||||
|
Daily cooperative Wordle — one puzzle per day, the community works together with a shared 6-guess limit. Word selection and validation powered by the Wordnik API (`WORDNIK_API_KEY`). A new puzzle auto-posts at midnight UTC. Word length is configurable (5-7 letters). Falls back to a bundled word list if the API is unavailable.
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `!wordle <word>` | Submit a guess for today's puzzle |
|
||||||
|
| `!wordle grid` | Re-post the current puzzle grid |
|
||||||
|
| `!wordle stats` | All-time leaderboard with community streak |
|
||||||
|
| `!wordle new` | Start a new puzzle (admin) |
|
||||||
|
| `!wordle new <5\|6\|7>` | New puzzle with specific word length (admin) |
|
||||||
|
| `!wordle skip` | Reveal answer and end puzzle (admin) |
|
||||||
|
| `!wordle help` | Show commands |
|
||||||
|
|
||||||
|
No economy integration — stats and leaderboard position are the reward.
|
||||||
|
|
||||||
### Reminders
|
### Reminders
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
@@ -634,6 +715,13 @@ All optional. The bot works fine without any of them, you just won't have those
|
|||||||
gogobee/
|
gogobee/
|
||||||
├── main.go # Entry point, plugin registration, cron setup
|
├── main.go # Entry point, plugin registration, cron setup
|
||||||
├── go.mod / go.sum
|
├── go.mod / go.sum
|
||||||
|
├── data/
|
||||||
|
│ └── policy.gob # Pre-trained CFR policy for Hold'em NPC
|
||||||
|
├── cmd/
|
||||||
|
│ ├── holdem-train/
|
||||||
|
│ │ └── main.go # CFR training CLI (build tag: training)
|
||||||
|
│ └── holdem-seed/
|
||||||
|
│ └── main.go # Seed policy generator
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── bot/
|
│ ├── bot/
|
||||||
│ │ ├── client.go # mautrix client + E2EE (cryptohelper + goolm)
|
│ │ ├── client.go # mautrix client + E2EE (cryptohelper + goolm)
|
||||||
@@ -686,6 +774,19 @@ gogobee/
|
|||||||
│ │ ├── uno.go # Solo UNO vs bot (DM-based)
|
│ │ ├── uno.go # Solo UNO vs bot (DM-based)
|
||||||
│ │ ├── uno_multi.go # Multiplayer UNO (lobby + DM turns)
|
│ │ ├── uno_multi.go # Multiplayer UNO (lobby + DM turns)
|
||||||
│ │ ├── uno_nomercy.go # No Mercy mode (deck, stacking, mercy rule, 7-0, bot AI)
|
│ │ ├── uno_nomercy.go # No Mercy mode (deck, stacking, mercy rule, 7-0, bot AI)
|
||||||
|
│ │ ├── holdem.go # Texas Hold'em plugin (commands, game lifecycle, NPC)
|
||||||
|
│ │ ├── holdem_game.go # Game state, player types, deck, position labels
|
||||||
|
│ │ ├── holdem_betting.go # Blinds, action validation, side pots
|
||||||
|
│ │ ├── holdem_eval.go # Hand evaluation, showdown, settlement
|
||||||
|
│ │ ├── holdem_render.go # Card glyphs, table view, announcements
|
||||||
|
│ │ ├── holdem_tips.go # Ollama coaching tips with equity analysis
|
||||||
|
│ │ ├── holdem_equity.go # Monte Carlo equity engine
|
||||||
|
│ │ ├── holdem_cfr.go # CFR policy table, NPC action selection, training
|
||||||
|
│ │ ├── wordle.go # Daily Wordle plugin (commands, lifecycle, scheduler)
|
||||||
|
│ │ ├── wordle_game.go # Puzzle state, scoring algorithm, letter tracking
|
||||||
|
│ │ ├── wordle_render.go # Emoji grid, keyboard, announcements, leaderboard
|
||||||
|
│ │ ├── wordle_wordnik.go # Wordnik API (random word, validation, definitions)
|
||||||
|
│ │ ├── wordle_fallback.go # Emergency word list loader
|
||||||
│ │ ├── esteemed.go # Satirical esteemed member posts
|
│ │ ├── esteemed.go # Satirical esteemed member posts
|
||||||
│ │ ├── moderation.go # Moderation system (strikes, word list, flood detection)
|
│ │ ├── moderation.go # Moderation system (strikes, word list, flood detection)
|
||||||
│ │ └── ratelimits.go # Rate limiting
|
│ │ └── ratelimits.go # Rate limiting
|
||||||
|
|||||||
36
cmd/holdem-seed/main.go
Normal file
36
cmd/holdem-seed/main.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
//go:build training
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gogobee/internal/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Creates a minimal seed policy so the bot can start without a trained policy.
|
||||||
|
// Run: go run -tags training ./cmd/holdem-seed/
|
||||||
|
func main() {
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||||
|
|
||||||
|
data := &plugin.CFRData{
|
||||||
|
Regrets: make(plugin.RegretTable),
|
||||||
|
Strategy: make(plugin.RegretTable),
|
||||||
|
Meta: plugin.CFRTrainingMeta{
|
||||||
|
Iterations: 0,
|
||||||
|
Seed: 42,
|
||||||
|
Date: "seed-policy",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
os.MkdirAll("data", 0o755)
|
||||||
|
if err := plugin.SaveCFRData("data/policy.gob", data); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Seed policy created", "path", "data/policy.gob")
|
||||||
|
slog.Info("Train a real policy with: go run -tags training ./cmd/holdem-train/ --iterations 1000000 --workers 8")
|
||||||
|
}
|
||||||
238
cmd/holdem-train/main.go
Normal file
238
cmd/holdem-train/main.go
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
//go:build training
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
iterations := flag.Int("iterations", 5_000_000, "Number of training iterations")
|
||||||
|
workers := flag.Int("workers", runtime.NumCPU(), "Number of parallel workers")
|
||||||
|
output := flag.String("output", "data/policy.gob", "Output policy file path")
|
||||||
|
resume := flag.String("resume", "", "Resume from checkpoint file")
|
||||||
|
validate := flag.Bool("validate", false, "Run validation instead of training")
|
||||||
|
checkpointEvery := flag.Int("checkpoint-every", 500_000, "Save checkpoint every N iterations")
|
||||||
|
seed := flag.Int64("seed", 42, "Random seed for reproducibility")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||||
|
|
||||||
|
if *validate {
|
||||||
|
runValidation(*output)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runTraining(*iterations, *workers, *output, *resume, *checkpointEvery, *seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTraining(iterations, workers int, output, resumePath string, checkpointEvery int, seed int64) {
|
||||||
|
var data *plugin.CFRData
|
||||||
|
|
||||||
|
if resumePath != "" {
|
||||||
|
slog.Info("Resuming from checkpoint", "path", resumePath)
|
||||||
|
var err error
|
||||||
|
data, err = plugin.LoadCFRData(resumePath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to load checkpoint", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
slog.Info("Checkpoint loaded",
|
||||||
|
"existing_iterations", data.Meta.Iterations,
|
||||||
|
"nodes", len(data.Regrets))
|
||||||
|
} else {
|
||||||
|
data = &plugin.CFRData{
|
||||||
|
Regrets: make(plugin.RegretTable),
|
||||||
|
Strategy: make(plugin.RegretTable),
|
||||||
|
Meta: plugin.CFRTrainingMeta{
|
||||||
|
Seed: seed,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Starting CFR training",
|
||||||
|
"iterations", iterations,
|
||||||
|
"workers", workers,
|
||||||
|
"output", output)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
progress := &plugin.TrainProgress{
|
||||||
|
Total: iterations,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if workers <= 1 {
|
||||||
|
// Single-threaded training.
|
||||||
|
plugin.TrainCFR(data, iterations, 100_000, "", progress)
|
||||||
|
data.Meta.Iterations += iterations
|
||||||
|
} else {
|
||||||
|
// Parallel training with shared regret table.
|
||||||
|
var mu sync.Mutex
|
||||||
|
iterPerWorker := iterations / workers
|
||||||
|
remainder := iterations % workers
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
totalCompleted := 0
|
||||||
|
|
||||||
|
for w := 0; w < workers; w++ {
|
||||||
|
iters := iterPerWorker
|
||||||
|
if w < remainder {
|
||||||
|
iters++
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func(workerID, workerIters int) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
// Each worker trains on its own local data, then merges.
|
||||||
|
localData := &plugin.CFRData{
|
||||||
|
Regrets: make(plugin.RegretTable),
|
||||||
|
Strategy: make(plugin.RegretTable),
|
||||||
|
}
|
||||||
|
|
||||||
|
progressEvery := 100_000 / workers
|
||||||
|
if progressEvery < 10_000 {
|
||||||
|
progressEvery = 10_000
|
||||||
|
}
|
||||||
|
|
||||||
|
label := fmt.Sprintf("worker-%d", workerID)
|
||||||
|
plugin.TrainCFR(localData, workerIters, progressEvery, label, progress)
|
||||||
|
|
||||||
|
// Merge local data into shared data.
|
||||||
|
mu.Lock()
|
||||||
|
for key, regrets := range localData.Regrets {
|
||||||
|
existing := data.Regrets[key]
|
||||||
|
for i := range regrets {
|
||||||
|
existing[i] += regrets[i]
|
||||||
|
}
|
||||||
|
data.Regrets[key] = existing
|
||||||
|
}
|
||||||
|
for key, strat := range localData.Strategy {
|
||||||
|
existing := data.Strategy[key]
|
||||||
|
for i := range strat {
|
||||||
|
existing[i] += strat[i]
|
||||||
|
}
|
||||||
|
data.Strategy[key] = existing
|
||||||
|
}
|
||||||
|
totalCompleted += workerIters
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
slog.Info("Worker completed", "worker", workerID, "iterations", workerIters)
|
||||||
|
}(w, iters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkpoint saver (runs in background).
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(30 * time.Second) // checkpoint every 30s
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
mu.Lock()
|
||||||
|
nodeCount := len(data.Regrets)
|
||||||
|
if nodeCount == 0 {
|
||||||
|
mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Deep copy maps to avoid concurrent read during gob encoding.
|
||||||
|
checkpoint := &plugin.CFRData{
|
||||||
|
Regrets: make(plugin.RegretTable, nodeCount),
|
||||||
|
Strategy: make(plugin.RegretTable, len(data.Strategy)),
|
||||||
|
Meta: data.Meta,
|
||||||
|
}
|
||||||
|
for k, v := range data.Regrets {
|
||||||
|
checkpoint.Regrets[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range data.Strategy {
|
||||||
|
checkpoint.Strategy[k] = v
|
||||||
|
}
|
||||||
|
checkpoint.Meta.Iterations += totalCompleted
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
checkpointPath := output + ".checkpoint"
|
||||||
|
if err := plugin.SaveCFRData(checkpointPath, checkpoint); err != nil {
|
||||||
|
slog.Warn("Checkpoint save failed", "err", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("Checkpoint saved", "path", checkpointPath, "nodes", len(checkpoint.Regrets))
|
||||||
|
}
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(done)
|
||||||
|
data.Meta.Iterations += iterations
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
data.Meta.Date = time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
|
||||||
|
slog.Info("Training complete",
|
||||||
|
"iterations", data.Meta.Iterations,
|
||||||
|
"nodes", len(data.Regrets),
|
||||||
|
"elapsed", elapsed.Round(time.Second))
|
||||||
|
|
||||||
|
// Ensure output directory exists.
|
||||||
|
if dir := outputDir(output); dir != "" {
|
||||||
|
os.MkdirAll(dir, 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := plugin.SaveCFRData(output, data); err != nil {
|
||||||
|
slog.Error("Failed to save policy", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Policy saved", "path", output, "nodes", len(data.Strategy))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runValidation(policyPath string) {
|
||||||
|
slog.Info("Loading policy for validation", "path", policyPath)
|
||||||
|
|
||||||
|
policy, err := plugin.LoadPolicy(policyPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to load policy", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Running validation (50,000 hands)...")
|
||||||
|
winRate, vpip, aggFactor := plugin.ValidatePolicy(policy, 50_000)
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("=== Validation Results ===")
|
||||||
|
fmt.Printf("Win Rate: %.1f%%\n", winRate*100)
|
||||||
|
fmt.Printf("VPIP: %.1f%%\n", vpip*100)
|
||||||
|
fmt.Printf("Agg Factor: %.2f\n", aggFactor)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if winRate > 0.55 {
|
||||||
|
fmt.Println("✅ Policy exceeds 55% win rate threshold.")
|
||||||
|
os.Exit(0)
|
||||||
|
} else if winRate > 0.50 {
|
||||||
|
fmt.Println("⚠️ Policy above 50% but below 55% threshold. Consider more training iterations.")
|
||||||
|
os.Exit(0)
|
||||||
|
} else {
|
||||||
|
fmt.Println("❌ Policy below 50% win rate. More training needed.")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func outputDir(path string) string {
|
||||||
|
for i := len(path) - 1; i >= 0; i-- {
|
||||||
|
if path[i] == '/' {
|
||||||
|
return path[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
1
go.mod
1
go.mod
@@ -18,6 +18,7 @@ require (
|
|||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/AlekSi/pointer v1.0.0 // indirect
|
github.com/AlekSi/pointer v1.0.0 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
|
github.com/chehsunliu/poker v0.1.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/fogleman/gg v1.3.0 // indirect
|
github.com/fogleman/gg v1.3.0 // indirect
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
|
|||||||
9
go.sum
9
go.sum
@@ -8,7 +8,10 @@ github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiU
|
|||||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||||
|
github.com/chehsunliu/poker v0.1.0 h1:OeB4O+QROhA/DiXUhBBlkgbzCx0ZVWMpWgKNu+PX9vI=
|
||||||
|
github.com/chehsunliu/poker v0.1.0/go.mod h1:V6K4yyDbafp0k6lUnYbwoTS/KsHSB1EWiJdEk54uB1w=
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
@@ -27,6 +30,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/loganjspears/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:76SAnflG7ZFhgtnaVCpP6A5Z1S/VMFzRBN7KGm5j4oc=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
@@ -38,6 +42,7 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
|
|||||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/notnil/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:L0Sdr2nYdktjerdXpIn9wOCn+GebPs/nCL2qH6RTGa0=
|
||||||
github.com/olebedev/when v1.1.0 h1:dlpoRa7huImhNtEx4yl0WYfTHVEWmJmIWd7fEkTHayc=
|
github.com/olebedev/when v1.1.0 h1:dlpoRa7huImhNtEx4yl0WYfTHVEWmJmIWd7fEkTHayc=
|
||||||
github.com/olebedev/when v1.1.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
|
github.com/olebedev/when v1.1.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
|
||||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
|
||||||
@@ -55,6 +60,8 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
|||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
@@ -153,6 +160,8 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
|
|||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk=
|
maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk=
|
||||||
|
|||||||
@@ -731,6 +731,50 @@ CREATE TABLE IF NOT EXISTS bot_defeats (
|
|||||||
PRIMARY KEY (user_id, game)
|
PRIMARY KEY (user_id, game)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Texas Hold'em
|
||||||
|
CREATE TABLE IF NOT EXISTS holdem_tips_prefs (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS holdem_scores (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
hands_played INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_won INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_lost INTEGER NOT NULL DEFAULT 0,
|
||||||
|
biggest_pot INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS holdem_npc_balance (
|
||||||
|
npc_name TEXT PRIMARY KEY,
|
||||||
|
balance INTEGER NOT NULL DEFAULT 10000,
|
||||||
|
hands_played INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Wordle
|
||||||
|
CREATE TABLE IF NOT EXISTS wordle_stats (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
total_guesses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
puzzles_played INTEGER NOT NULL DEFAULT 0,
|
||||||
|
puzzles_solved INTEGER NOT NULL DEFAULT 0,
|
||||||
|
winning_guesses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wordle_puzzles (
|
||||||
|
puzzle_id TEXT NOT NULL,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
answer TEXT NOT NULL,
|
||||||
|
word_length INTEGER NOT NULL,
|
||||||
|
solved INTEGER NOT NULL DEFAULT 0,
|
||||||
|
guess_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
started_at DATETIME NOT NULL,
|
||||||
|
solved_at DATETIME,
|
||||||
|
PRIMARY KEY (puzzle_id, room_id)
|
||||||
|
);
|
||||||
|
|
||||||
-- Space groups (rooms with overlapping membership)
|
-- Space groups (rooms with overlapping membership)
|
||||||
CREATE TABLE IF NOT EXISTS space_groups (
|
CREATE TABLE IF NOT EXISTS space_groups (
|
||||||
room_id TEXT PRIMARY KEY,
|
room_id TEXT PRIMARY KEY,
|
||||||
@@ -738,6 +782,146 @@ CREATE TABLE IF NOT EXISTS space_groups (
|
|||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- ── Adventure Plugin ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_characters (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
combat_level INTEGER NOT NULL DEFAULT 1,
|
||||||
|
mining_skill INTEGER NOT NULL DEFAULT 1,
|
||||||
|
foraging_skill INTEGER NOT NULL DEFAULT 1,
|
||||||
|
fishing_skill INTEGER NOT NULL DEFAULT 1,
|
||||||
|
combat_xp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
mining_xp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
foraging_xp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
fishing_xp INTEGER NOT NULL DEFAULT 0,
|
||||||
|
alive INTEGER NOT NULL DEFAULT 1,
|
||||||
|
dead_until DATETIME,
|
||||||
|
action_taken_today INTEGER NOT NULL DEFAULT 0,
|
||||||
|
arena_wins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
arena_losses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
invasion_score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
current_streak INTEGER NOT NULL DEFAULT 0,
|
||||||
|
best_streak INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_action_date TEXT NOT NULL DEFAULT '',
|
||||||
|
grudge_location TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_active_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_equipment (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
slot TEXT NOT NULL,
|
||||||
|
tier INTEGER NOT NULL DEFAULT 0,
|
||||||
|
condition INTEGER NOT NULL DEFAULT 100,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
actions_used INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (user_id, slot)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_inventory (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
item_type TEXT NOT NULL,
|
||||||
|
tier INTEGER NOT NULL,
|
||||||
|
value INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adv_inv_user ON adventure_inventory(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_activity_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
activity_type TEXT NOT NULL,
|
||||||
|
location TEXT,
|
||||||
|
outcome TEXT NOT NULL,
|
||||||
|
loot_value INTEGER NOT NULL DEFAULT 0,
|
||||||
|
xp_gained INTEGER NOT NULL DEFAULT 0,
|
||||||
|
flavor_key TEXT,
|
||||||
|
logged_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adv_log_user ON adventure_activity_log(user_id, logged_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_treasures (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
treasure_key TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
tier INTEGER NOT NULL,
|
||||||
|
bonus_type TEXT NOT NULL,
|
||||||
|
bonus_value REAL NOT NULL,
|
||||||
|
acquired_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, treasure_key, bonus_type)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adv_treasure_user ON adventure_treasures(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_buffs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
buff_type TEXT NOT NULL,
|
||||||
|
buff_name TEXT NOT NULL,
|
||||||
|
modifier REAL NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_adv_buffs_user ON adventure_buffs(user_id, expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_twinbee_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
activity_type TEXT NOT NULL,
|
||||||
|
location TEXT NOT NULL,
|
||||||
|
outcome TEXT NOT NULL,
|
||||||
|
loot_value INTEGER NOT NULL DEFAULT 0,
|
||||||
|
loot_desc TEXT,
|
||||||
|
participant_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
gold_share INTEGER NOT NULL DEFAULT 0,
|
||||||
|
gift_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
logged_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- v2 stubs
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_market_listings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
seller_id TEXT NOT NULL,
|
||||||
|
treasure_id INTEGER NOT NULL,
|
||||||
|
asking_price INTEGER NOT NULL,
|
||||||
|
listed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
sold_at DATETIME,
|
||||||
|
buyer_id TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_invasions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
horde_name TEXT NOT NULL,
|
||||||
|
horde_hp INTEGER NOT NULL,
|
||||||
|
horde_tier INTEGER NOT NULL,
|
||||||
|
outcome TEXT,
|
||||||
|
participant_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ends_at DATETIME NOT NULL,
|
||||||
|
resolved_at DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_invasion_participants (
|
||||||
|
invasion_id INTEGER NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
damage_dealt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
xp_gained INTEGER NOT NULL DEFAULT 0,
|
||||||
|
loot_value INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (invasion_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS adventure_arena_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
challenger_id TEXT NOT NULL,
|
||||||
|
defender_id TEXT NOT NULL,
|
||||||
|
winner_id TEXT NOT NULL,
|
||||||
|
xp_gained INTEGER NOT NULL DEFAULT 0,
|
||||||
|
logged_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
`
|
`
|
||||||
|
|
||||||
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||||
|
|||||||
778
internal/plugin/adventure.go
Normal file
778
internal/plugin/adventure.go
Normal file
@@ -0,0 +1,778 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Plugin ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type AdventurePlugin struct {
|
||||||
|
Base
|
||||||
|
euro *EuroPlugin
|
||||||
|
mu sync.Mutex
|
||||||
|
dmToPlayer map[id.RoomID]id.UserID
|
||||||
|
pending sync.Map // userID string -> *advPendingInteraction
|
||||||
|
userLocks sync.Map // userID string -> *sync.Mutex
|
||||||
|
morningHour int
|
||||||
|
summaryHour int
|
||||||
|
}
|
||||||
|
|
||||||
|
// advUserLock returns a per-user mutex to prevent concurrent action resolution.
|
||||||
|
func (p *AdventurePlugin) advUserLock(userID id.UserID) *sync.Mutex {
|
||||||
|
val, _ := p.userLocks.LoadOrStore(string(userID), &sync.Mutex{})
|
||||||
|
return val.(*sync.Mutex)
|
||||||
|
}
|
||||||
|
|
||||||
|
type advPendingInteraction struct {
|
||||||
|
Type string // "treasure_discard"
|
||||||
|
Data interface{}
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type advPendingTreasureDiscard struct {
|
||||||
|
NewTreasure *AdvTreasureDef
|
||||||
|
Existing []AdvTreasureDef
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdventurePlugin(client *mautrix.Client, euro *EuroPlugin) *AdventurePlugin {
|
||||||
|
return &AdventurePlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
euro: euro,
|
||||||
|
dmToPlayer: make(map[id.RoomID]id.UserID),
|
||||||
|
morningHour: envInt("ADVENTURE_MORNING_HOUR", 8),
|
||||||
|
summaryHour: envInt("ADVENTURE_SUMMARY_HOUR", 20),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) Name() string { return "adventure" }
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "adventure", Description: "Daily adventure game — dungeon, mine, forage, or rest", Usage: "!adventure", Category: "Games"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) Init() error {
|
||||||
|
// Rehydrate DM room mappings for existing characters
|
||||||
|
chars, err := loadAllAdvCharacters()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("adventure: no characters to rehydrate", "err", err)
|
||||||
|
} else {
|
||||||
|
for _, c := range chars {
|
||||||
|
p.registerDMRoom(c.UserID)
|
||||||
|
}
|
||||||
|
slog.Info("adventure: rehydrated DM rooms", "count", len(chars))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start schedulers
|
||||||
|
go p.morningTicker()
|
||||||
|
go p.summaryTicker()
|
||||||
|
go p.midnightTicker()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
// ── Message Dispatch ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
// 1. Check if this is a DM reply from a registered player
|
||||||
|
p.mu.Lock()
|
||||||
|
playerID, isDM := p.dmToPlayer[ctx.RoomID]
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if isDM && playerID == ctx.Sender {
|
||||||
|
return p.handleDMReply(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Command dispatch
|
||||||
|
if !p.IsCommand(ctx.Body, "adventure") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "adventure"))
|
||||||
|
lower := strings.ToLower(args)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case args == "" || lower == "menu":
|
||||||
|
return p.handleMenu(ctx)
|
||||||
|
case lower == "status":
|
||||||
|
return p.handleStatus(ctx)
|
||||||
|
case strings.HasPrefix(lower, "sell "):
|
||||||
|
return p.handleSellCmd(ctx, strings.TrimSpace(args[5:]))
|
||||||
|
case lower == "shop":
|
||||||
|
return p.handleShopCmd(ctx)
|
||||||
|
case strings.HasPrefix(lower, "buy "):
|
||||||
|
return p.handleBuyCmd(ctx, strings.TrimSpace(args[4:]))
|
||||||
|
case lower == "inventory" || lower == "inv":
|
||||||
|
return p.handleInventoryCmd(ctx)
|
||||||
|
case lower == "leaderboard" || lower == "lb":
|
||||||
|
return p.handleLeaderboard(ctx)
|
||||||
|
case strings.HasPrefix(lower, "revive "):
|
||||||
|
return p.handleAdminRevive(ctx, strings.TrimSpace(args[7:]))
|
||||||
|
case lower == "summary":
|
||||||
|
return p.handleAdminSummary(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Command Handlers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
||||||
|
char, equip, err := p.ensureCharacter(ctx.Sender)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !char.Alive {
|
||||||
|
text := renderAdvDeathStatusDM(char)
|
||||||
|
return p.SendDM(ctx.Sender, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if char.ActionTakenToday {
|
||||||
|
return p.SendDM(ctx.Sender, "You've already taken your action today. Tomorrow awaits. Try to survive it.")
|
||||||
|
}
|
||||||
|
|
||||||
|
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||||
|
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||||
|
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||||
|
balance := p.euro.GetBalance(char.UserID)
|
||||||
|
|
||||||
|
text := renderAdvMorningDM(char, equip, balance, bonuses)
|
||||||
|
return p.SendDM(ctx.Sender, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) handleStatus(ctx MessageContext) error {
|
||||||
|
char, err := loadAdvCharacter(ctx.Sender)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No adventurer found. Type `!adventure` to create one.")
|
||||||
|
}
|
||||||
|
|
||||||
|
equip, _ := loadAdvEquipment(ctx.Sender)
|
||||||
|
items, _ := loadAdvInventory(ctx.Sender)
|
||||||
|
treasures, _ := loadAdvTreasureBonuses(ctx.Sender)
|
||||||
|
balance := p.euro.GetBalance(ctx.Sender)
|
||||||
|
|
||||||
|
text := renderAdvCharacterSheet(char, equip, items, treasures, balance)
|
||||||
|
return p.SendDM(ctx.Sender, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) handleShopCmd(ctx MessageContext) error {
|
||||||
|
_, equip, err := p.ensureCharacter(ctx.Sender)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
|
||||||
|
}
|
||||||
|
|
||||||
|
balance := p.euro.GetBalance(ctx.Sender)
|
||||||
|
text := advShopListings(equip, balance)
|
||||||
|
return p.SendDM(ctx.Sender, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) handleBuyCmd(ctx MessageContext, itemName string) error {
|
||||||
|
_, equip, err := p.ensureCharacter(ctx.Sender)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
|
||||||
|
}
|
||||||
|
|
||||||
|
slot, def, found := advFindShopItem(itemName)
|
||||||
|
if !found {
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf("No item matching '%s' found in the shop. Type `!adventure shop` to see what's available.", itemName))
|
||||||
|
}
|
||||||
|
|
||||||
|
result := p.advBuyEquipment(ctx.Sender, slot, def, equip)
|
||||||
|
return p.SendDM(ctx.Sender, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) handleSellCmd(ctx MessageContext, args string) error {
|
||||||
|
p.ensureCharacter(ctx.Sender)
|
||||||
|
|
||||||
|
var result string
|
||||||
|
if strings.ToLower(args) == "all" {
|
||||||
|
result = p.advSellAll(ctx.Sender)
|
||||||
|
} else {
|
||||||
|
result = p.advSellItem(ctx.Sender, args)
|
||||||
|
}
|
||||||
|
return p.SendDM(ctx.Sender, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) handleInventoryCmd(ctx MessageContext) error {
|
||||||
|
p.ensureCharacter(ctx.Sender)
|
||||||
|
text := advInventoryDisplay(ctx.Sender)
|
||||||
|
return p.SendDM(ctx.Sender, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) handleLeaderboard(ctx MessageContext) error {
|
||||||
|
chars, err := loadAllAdvCharacters()
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load leaderboard.")
|
||||||
|
}
|
||||||
|
text := renderAdvLeaderboard(chars)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) handleAdminRevive(ctx MessageContext, target string) error {
|
||||||
|
if !p.IsAdmin(ctx.Sender) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve user
|
||||||
|
targetID, found := p.ResolveUser(target, ctx.RoomID)
|
||||||
|
if !found {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not find that user.")
|
||||||
|
}
|
||||||
|
|
||||||
|
char, err := loadAdvCharacter(targetID)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "That user has no adventurer.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if char.Alive {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s is already alive.", char.DisplayName))
|
||||||
|
}
|
||||||
|
|
||||||
|
char.Alive = true
|
||||||
|
char.DeadUntil = nil
|
||||||
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to revive.")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.SendDM(targetID, renderAdvRespawnDM(char))
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("✅ %s has been revived.", char.DisplayName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) handleAdminSummary(ctx MessageContext) error {
|
||||||
|
if !p.IsAdmin(ctx.Sender) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
go p.postDailySummary()
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Daily summary will be posted shortly.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DM Reply Handling ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error {
|
||||||
|
body := strings.TrimSpace(ctx.Body)
|
||||||
|
|
||||||
|
// Skip if it looks like a command for another plugin
|
||||||
|
if strings.HasPrefix(body, "!") && !strings.HasPrefix(strings.ToLower(body), "!adventure") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip !adventure prefix if present
|
||||||
|
if strings.HasPrefix(strings.ToLower(body), "!adventure") {
|
||||||
|
// Re-dispatch as command
|
||||||
|
return p.OnMessage(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for pending interaction first
|
||||||
|
if val, ok := p.pending.Load(string(ctx.Sender)); ok {
|
||||||
|
interaction := val.(*advPendingInteraction)
|
||||||
|
if time.Now().Before(interaction.ExpiresAt) {
|
||||||
|
return p.resolvePendingInteraction(ctx, interaction)
|
||||||
|
}
|
||||||
|
p.pending.Delete(string(ctx.Sender))
|
||||||
|
p.SendDM(ctx.Sender, "Your previous prompt expired. Moving on.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse as activity choice
|
||||||
|
return p.parseAndResolveChoice(ctx, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||||
|
p.pending.Delete(string(ctx.Sender))
|
||||||
|
|
||||||
|
switch interaction.Type {
|
||||||
|
case "treasure_discard":
|
||||||
|
return p.handleTreasureDiscard(ctx, interaction)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) handleTreasureDiscard(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||||
|
data := interaction.Data.(*advPendingTreasureDiscard)
|
||||||
|
body := strings.TrimSpace(strings.ToLower(ctx.Body))
|
||||||
|
|
||||||
|
if body == "keep" {
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf("You left the %s behind. It will stay where you found it, judging you, forever.", data.NewTreasure.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
choice, err := strconv.Atoi(body)
|
||||||
|
if err != nil || choice < 1 || choice > len(data.Existing) {
|
||||||
|
return p.SendDM(ctx.Sender, "Reply with 1, 2, or 3 to discard, or `keep` to leave the new treasure behind.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard the chosen treasure
|
||||||
|
discarded := data.Existing[choice-1]
|
||||||
|
if err := advDiscardTreasure(ctx.Sender, discarded.Key); err != nil {
|
||||||
|
return p.SendDM(ctx.Sender, "Failed to discard treasure. Try again.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the new treasure
|
||||||
|
if err := advSaveTreasure(ctx.Sender, data.NewTreasure); err != nil {
|
||||||
|
return p.SendDM(ctx.Sender, "Failed to save new treasure. Something went wrong.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf("You discarded **%s** and kept **%s**.\n\n_%s_",
|
||||||
|
discarded.Name, data.NewTreasure.Name, data.NewTreasure.InventoryDesc))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activity Choice Parsing ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string) error {
|
||||||
|
// Acquire per-user lock to prevent double actions from concurrent DM replies
|
||||||
|
userMu := p.advUserLock(ctx.Sender)
|
||||||
|
userMu.Lock()
|
||||||
|
defer userMu.Unlock()
|
||||||
|
|
||||||
|
char, err := loadAdvCharacter(ctx.Sender)
|
||||||
|
if err != nil {
|
||||||
|
return nil // not a registered player
|
||||||
|
}
|
||||||
|
|
||||||
|
if !char.Alive {
|
||||||
|
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
|
||||||
|
}
|
||||||
|
|
||||||
|
if char.ActionTakenToday {
|
||||||
|
return p.SendDM(ctx.Sender, "You've already taken your action today. Rest now. Try again tomorrow.")
|
||||||
|
}
|
||||||
|
|
||||||
|
lower := strings.ToLower(body)
|
||||||
|
|
||||||
|
// Parse "5" or "rest"
|
||||||
|
if lower == "5" || lower == "rest" {
|
||||||
|
return p.resolveRest(ctx, char)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse "4" or "shop"
|
||||||
|
if lower == "4" || lower == "shop" {
|
||||||
|
equip, _ := loadAdvEquipment(ctx.Sender)
|
||||||
|
balance := p.euro.GetBalance(ctx.Sender)
|
||||||
|
return p.SendDM(ctx.Sender, advShopListings(equip, balance))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse activity + location
|
||||||
|
activity, loc := p.parseActivityLocation(lower, char)
|
||||||
|
if loc == nil {
|
||||||
|
return p.SendDM(ctx.Sender, "I didn't understand that. Reply with a number and location, e.g: `1 Soggy Cellar`, or just `1` for the first available.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.resolveActivity(ctx, char, activity, loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) parseActivityLocation(input string, char *AdventureCharacter) (AdvActivityType, *AdvLocation) {
|
||||||
|
parts := strings.SplitN(input, " ", 2)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
first := parts[0]
|
||||||
|
rest := ""
|
||||||
|
if len(parts) > 1 {
|
||||||
|
rest = strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
var activity AdvActivityType
|
||||||
|
|
||||||
|
// Parse activity from number or word
|
||||||
|
switch first {
|
||||||
|
case "1", "dungeon", "d":
|
||||||
|
activity = AdvActivityDungeon
|
||||||
|
case "2", "mine", "m":
|
||||||
|
activity = AdvActivityMining
|
||||||
|
case "3", "forage", "f", "forest":
|
||||||
|
activity = AdvActivityForaging
|
||||||
|
default:
|
||||||
|
// Try matching location name directly
|
||||||
|
for _, act := range []AdvActivityType{AdvActivityDungeon, AdvActivityMining, AdvActivityForaging} {
|
||||||
|
if loc := findAdvLocation(act, input); loc != nil {
|
||||||
|
return act, loc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no location specified, pick first eligible
|
||||||
|
if rest == "" {
|
||||||
|
equip, _ := loadAdvEquipment(char.UserID)
|
||||||
|
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||||
|
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||||
|
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||||
|
eligible := advEligibleLocations(char, equip, activity, bonuses)
|
||||||
|
if len(eligible) == 0 {
|
||||||
|
return activity, nil
|
||||||
|
}
|
||||||
|
return activity, eligible[0].Location
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse tier number
|
||||||
|
if tier, err := strconv.Atoi(rest); err == nil {
|
||||||
|
loc := findAdvLocationByTier(activity, tier)
|
||||||
|
if loc != nil {
|
||||||
|
return activity, loc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuzzy match location name
|
||||||
|
loc := findAdvLocation(activity, rest)
|
||||||
|
return activity, loc
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activity Resolution ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCharacter, activity AdvActivityType, loc *AdvLocation) error {
|
||||||
|
equip, err := loadAdvEquipment(char.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendDM(ctx.Sender, "Failed to load your equipment.")
|
||||||
|
}
|
||||||
|
|
||||||
|
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||||
|
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||||
|
|
||||||
|
// Check grudge
|
||||||
|
hasGrudge := char.GrudgeLocation == loc.Name
|
||||||
|
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
|
||||||
|
|
||||||
|
// Check eligibility
|
||||||
|
eligible, inPenaltyZone := advIsEligible(char, equip, loc, bonuses)
|
||||||
|
if !eligible {
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf("You are not eligible for %s. Your level or equipment tier is too low.", loc.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the action
|
||||||
|
result := resolveAdvAction(char, equip, loc, bonuses, inPenaltyZone)
|
||||||
|
|
||||||
|
// Select flavor text
|
||||||
|
result.FlavorText, result.FlavorKey = p.selectFlavorText(char, result)
|
||||||
|
|
||||||
|
// Apply XP
|
||||||
|
switch result.XPSkill {
|
||||||
|
case "combat":
|
||||||
|
char.CombatXP += result.XPGained
|
||||||
|
case "mining":
|
||||||
|
char.MiningXP += result.XPGained
|
||||||
|
case "foraging":
|
||||||
|
char.ForagingXP += result.XPGained
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check level up
|
||||||
|
result.LeveledUp, result.NewLevel = checkAdvLevelUp(char, result.XPSkill)
|
||||||
|
|
||||||
|
// Handle death
|
||||||
|
if result.Outcome == AdvOutcomeDeath {
|
||||||
|
char.Alive = false
|
||||||
|
deadUntil := time.Now().UTC().Add(24 * time.Hour)
|
||||||
|
char.DeadUntil = &deadUntil
|
||||||
|
char.GrudgeLocation = loc.Name
|
||||||
|
} else if hasGrudge && (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) {
|
||||||
|
// Clear grudge on successful return
|
||||||
|
char.GrudgeLocation = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add loot to inventory
|
||||||
|
for _, item := range result.LootItems {
|
||||||
|
_ = addAdvInventoryItem(char.UserID, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Party bonus: check if someone else visited the same location today
|
||||||
|
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
|
||||||
|
if advCheckPartyBonus(char.UserID, loc.Name) {
|
||||||
|
// Apply party bonus: +10% loot value
|
||||||
|
partyBonus := int64(float64(result.TotalLootValue) * 0.10)
|
||||||
|
if partyBonus > 0 {
|
||||||
|
result.TotalLootValue += partyBonus
|
||||||
|
// Credit the bonus directly
|
||||||
|
p.euro.Credit(char.UserID, float64(partyBonus), "adventure_party_bonus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark action taken
|
||||||
|
char.ActionTakenToday = true
|
||||||
|
|
||||||
|
// Update streak info
|
||||||
|
result.StreakBonus = char.CurrentStreak
|
||||||
|
|
||||||
|
// Save character
|
||||||
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
|
slog.Error("adventure: failed to save character", "user", char.UserID, "err", err)
|
||||||
|
return p.SendDM(ctx.Sender, "Something went wrong saving your progress. Your action was not recorded. Try again.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save equipment changes
|
||||||
|
for _, slot := range allSlots {
|
||||||
|
if eq, ok := equip[slot]; ok {
|
||||||
|
if err := saveAdvEquipment(char.UserID, eq); err != nil {
|
||||||
|
slog.Error("adventure: failed to save equipment", "user", char.UserID, "slot", slot, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
logAdvActivity(char.UserID, string(activity), loc.Name, string(result.Outcome),
|
||||||
|
result.TotalLootValue, result.XPGained, result.FlavorKey)
|
||||||
|
|
||||||
|
// Send resolution DM
|
||||||
|
text := renderAdvResolutionDM(result, char)
|
||||||
|
if err := p.SendDM(ctx.Sender, text); err != nil {
|
||||||
|
slog.Error("adventure: failed to send resolution DM", "user", ctx.Sender, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for treasure drop
|
||||||
|
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
|
||||||
|
p.checkTreasureDrop(ctx.Sender, char, loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharacter) error {
|
||||||
|
char.ActionTakenToday = true
|
||||||
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
|
return p.SendDM(ctx.Sender, "Failed to save. Even resting is broken.")
|
||||||
|
}
|
||||||
|
|
||||||
|
logAdvActivity(char.UserID, string(AdvActivityRest), "", "rest", 0, 0, "")
|
||||||
|
|
||||||
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
|
"%s, you chose rest. No loot. No XP. No death.\n\n"+
|
||||||
|
"You sat in your hovel and stared at the wall and achieved absolutely nothing. "+
|
||||||
|
"Tomorrow awaits. It will probably be the same.",
|
||||||
|
char.DisplayName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Treasure Drop Check ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCharacter, loc *AdvLocation) {
|
||||||
|
drop := rollAdvTreasureDrop(loc.Tier, userID)
|
||||||
|
if drop == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check treasure count
|
||||||
|
count, err := advCountTreasures(userID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if count < advMaxTreasures {
|
||||||
|
// Directly save
|
||||||
|
if err := advSaveTreasure(userID, drop.Def); err != nil {
|
||||||
|
slog.Error("adventure: failed to save treasure", "user", userID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send discovery flavor
|
||||||
|
p.sendTreasureDiscoveryDM(userID, char, drop.Def, loc)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// At cap — prompt for discard
|
||||||
|
existing, err := advUserTreasures(userID)
|
||||||
|
if err != nil || len(existing) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set pending interaction
|
||||||
|
p.pending.Store(string(userID), &advPendingInteraction{
|
||||||
|
Type: "treasure_discard",
|
||||||
|
Data: &advPendingTreasureDiscard{
|
||||||
|
NewTreasure: drop.Def,
|
||||||
|
Existing: existing,
|
||||||
|
},
|
||||||
|
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||||
|
})
|
||||||
|
|
||||||
|
text := renderAdvTreasureDiscardPrompt(drop.Def, existing)
|
||||||
|
p.SendDM(userID, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) sendTreasureDiscoveryDM(userID id.UserID, char *AdventureCharacter, def *AdvTreasureDef, loc *AdvLocation) {
|
||||||
|
// Pick from discovery pool
|
||||||
|
pool, ok := TreasureDiscovery[def.Tier]
|
||||||
|
if !ok || len(pool) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
text := pool[rand.IntN(len(pool))]
|
||||||
|
text = advSubstituteFlavor(text, map[string]string{
|
||||||
|
"{treasure_name}": def.Name,
|
||||||
|
"{bonus_desc}": def.InventoryDesc,
|
||||||
|
"{location}": loc.Name,
|
||||||
|
})
|
||||||
|
|
||||||
|
p.SendDM(userID, text)
|
||||||
|
|
||||||
|
// Room announcement for tier 5 or special items
|
||||||
|
if def.RoomAnnounce != "" {
|
||||||
|
gr := gamesRoom()
|
||||||
|
if gr != "" {
|
||||||
|
announce := advSubstituteFlavor(def.RoomAnnounce, map[string]string{
|
||||||
|
"{name}": char.DisplayName,
|
||||||
|
"{location}": loc.Name,
|
||||||
|
})
|
||||||
|
p.SendMessage(id.RoomID(gr), announce)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flavor Text Selection ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) selectFlavorText(char *AdventureCharacter, result *AdvActionResult) (string, string) {
|
||||||
|
loc := result.Location
|
||||||
|
vars := map[string]string{
|
||||||
|
"{name}": char.DisplayName,
|
||||||
|
"{location}": loc.Name,
|
||||||
|
"{value}": fmt.Sprintf("%d", result.TotalLootValue),
|
||||||
|
"{xp}": fmt.Sprintf("%d", result.XPGained),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add item names
|
||||||
|
if len(result.LootItems) > 0 {
|
||||||
|
names := make([]string, len(result.LootItems))
|
||||||
|
for i, item := range result.LootItems {
|
||||||
|
names[i] = item.Name
|
||||||
|
}
|
||||||
|
vars["{item}"] = joinAdvItems(names)
|
||||||
|
vars["{ore}"] = names[0]
|
||||||
|
vars["{item_2}"] = ""
|
||||||
|
if len(names) > 1 {
|
||||||
|
vars["{item_2}"] = names[1]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vars["{item}"] = ""
|
||||||
|
vars["{ore}"] = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equipment names for flavor
|
||||||
|
equip, _ := loadAdvEquipment(char.UserID)
|
||||||
|
if eq, ok := equip[SlotTool]; ok {
|
||||||
|
vars["{tool}"] = eq.Name
|
||||||
|
}
|
||||||
|
if eq, ok := equip[SlotArmor]; ok {
|
||||||
|
vars["{armor}"] = eq.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
var pool []string
|
||||||
|
category := fmt.Sprintf("%s_%s", loc.Activity, result.Outcome)
|
||||||
|
|
||||||
|
switch loc.Activity {
|
||||||
|
case AdvActivityDungeon:
|
||||||
|
switch result.Outcome {
|
||||||
|
case AdvOutcomeDeath:
|
||||||
|
if tierPool, ok := DungeonDeath[loc.Tier]; ok {
|
||||||
|
pool = tierPool
|
||||||
|
}
|
||||||
|
case AdvOutcomeEmpty:
|
||||||
|
if tierPool, ok := DungeonEmpty[loc.Tier]; ok {
|
||||||
|
pool = tierPool
|
||||||
|
}
|
||||||
|
case AdvOutcomeSuccess:
|
||||||
|
if tierPool, ok := DungeonSuccess[loc.Tier]; ok {
|
||||||
|
pool = tierPool
|
||||||
|
}
|
||||||
|
case AdvOutcomeExceptional:
|
||||||
|
if tierPool, ok := DungeonExceptional[loc.Tier]; ok {
|
||||||
|
pool = tierPool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case AdvActivityMining:
|
||||||
|
switch result.Outcome {
|
||||||
|
case AdvOutcomeDeath:
|
||||||
|
if tierPool, ok := MiningDeath[loc.Tier]; ok {
|
||||||
|
pool = tierPool
|
||||||
|
}
|
||||||
|
case AdvOutcomeCaveIn:
|
||||||
|
pool = MiningCaveIn
|
||||||
|
case AdvOutcomeEmpty:
|
||||||
|
if tierPool, ok := MiningEmpty[loc.Tier]; ok {
|
||||||
|
pool = tierPool
|
||||||
|
}
|
||||||
|
case AdvOutcomeSuccess, AdvOutcomeExceptional:
|
||||||
|
if tierPool, ok := MiningSuccess[loc.Tier]; ok {
|
||||||
|
pool = tierPool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case AdvActivityForaging:
|
||||||
|
switch result.Outcome {
|
||||||
|
case AdvOutcomeDeath:
|
||||||
|
pool = ForagingDeath
|
||||||
|
case AdvOutcomeHornets:
|
||||||
|
pool = ForagingHornets
|
||||||
|
case AdvOutcomeBear:
|
||||||
|
pool = ForagingBear
|
||||||
|
case AdvOutcomeRiver:
|
||||||
|
pool = ForagingRiver
|
||||||
|
case AdvOutcomeSuccess, AdvOutcomeExceptional:
|
||||||
|
if tierPool, ok := ForagingGoodHaul[loc.Tier]; ok {
|
||||||
|
pool = tierPool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pool) == 0 {
|
||||||
|
return fmt.Sprintf("You went to %s. Things happened.", loc.Name), ""
|
||||||
|
}
|
||||||
|
|
||||||
|
text, idx := advPickFlavor(pool, char.UserID, category)
|
||||||
|
key := fmt.Sprintf("%s_%d", category, idx)
|
||||||
|
return advSubstituteFlavor(text, vars), key
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Character Ensurance ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter, map[EquipmentSlot]*AdvEquipment, error) {
|
||||||
|
char, err := loadAdvCharacter(userID)
|
||||||
|
if err != nil {
|
||||||
|
// Auto-create
|
||||||
|
displayName := p.displayName(userID)
|
||||||
|
if err := createAdvCharacter(userID, displayName); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
char, err = loadAdvCharacter(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register DM room
|
||||||
|
p.registerDMRoom(userID)
|
||||||
|
|
||||||
|
// Send onboarding
|
||||||
|
text := renderAdvOnboardingDM(char)
|
||||||
|
p.SendDM(userID, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
equip, err := loadAdvEquipment(userID)
|
||||||
|
if err != nil {
|
||||||
|
return char, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return char, equip, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) displayName(userID id.UserID) string {
|
||||||
|
resp, err := p.Client.GetDisplayName(context.Background(), userID)
|
||||||
|
if err != nil || resp.DisplayName == "" {
|
||||||
|
// Fallback to localpart
|
||||||
|
s := string(userID)
|
||||||
|
if idx := strings.Index(s, ":"); idx > 0 {
|
||||||
|
s = s[1:idx]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return resp.DisplayName
|
||||||
|
}
|
||||||
726
internal/plugin/adventure_activities.go
Normal file
726
internal/plugin/adventure_activities.go
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"math/rand/v2"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Activity & Outcome Types ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type AdvActivityType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AdvActivityDungeon AdvActivityType = "dungeon"
|
||||||
|
AdvActivityMining AdvActivityType = "mining"
|
||||||
|
AdvActivityForaging AdvActivityType = "foraging"
|
||||||
|
AdvActivityRest AdvActivityType = "rest"
|
||||||
|
AdvActivityShop AdvActivityType = "shop"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AdvOutcomeType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AdvOutcomeDeath AdvOutcomeType = "death"
|
||||||
|
AdvOutcomeEmpty AdvOutcomeType = "empty"
|
||||||
|
AdvOutcomeSuccess AdvOutcomeType = "success"
|
||||||
|
AdvOutcomeExceptional AdvOutcomeType = "exceptional"
|
||||||
|
AdvOutcomeCaveIn AdvOutcomeType = "cave_in"
|
||||||
|
AdvOutcomeHornets AdvOutcomeType = "hornets"
|
||||||
|
AdvOutcomeBear AdvOutcomeType = "bear"
|
||||||
|
AdvOutcomeRiver AdvOutcomeType = "river"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Location Definitions ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type AdvLocation struct {
|
||||||
|
Name string
|
||||||
|
Activity AdvActivityType
|
||||||
|
Tier int
|
||||||
|
Denizens string // monsters/resources description
|
||||||
|
BaseDeathPct float64
|
||||||
|
EmptyPct float64
|
||||||
|
MinLevel int
|
||||||
|
MinEquipTier int
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdvLootDef struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
MinValue int64
|
||||||
|
MaxValue int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var advDungeons = []AdvLocation{
|
||||||
|
{"The Soggy Cellar", AdvActivityDungeon, 1, "Giant Rats, Angry Badgers, Wet Slimes", 8, 15, 1, 0},
|
||||||
|
{"Goblin Warrens", AdvActivityDungeon, 2, "Goblins, Kobolds, Trap Spiders", 18, 15, 8, 1},
|
||||||
|
{"The Cursed Crypt", AdvActivityDungeon, 3, "Skeletons, Ghosts, Draugr", 30, 15, 20, 2},
|
||||||
|
{"Troll Bridge Depths", AdvActivityDungeon, 4, "Trolls, Stone Giants, Cursed Knights", 45, 15, 35, 3},
|
||||||
|
{"The Abyssal Maw", AdvActivityDungeon, 5, "Demons, Elder Drakes, The Unnamed", 60, 15, 48, 4},
|
||||||
|
}
|
||||||
|
|
||||||
|
var advMines = []AdvLocation{
|
||||||
|
{"Surface Pits", AdvActivityMining, 1, "Copper, Tin, Coal", 3, 20, 1, 0},
|
||||||
|
{"Iron Ridge", AdvActivityMining, 2, "Iron, Lead, Saltpetre", 8, 20, 8, 1},
|
||||||
|
{"Silver Seam", AdvActivityMining, 3, "Silver, Quartz, Nickel", 15, 20, 18, 2},
|
||||||
|
{"The Deeprock", AdvActivityMining, 4, "Gold, Sapphire, Titanium", 25, 20, 30, 3},
|
||||||
|
{"Mythril Caverns", AdvActivityMining, 5, "Mythril, Dragon Crystal, Voidstone", 35, 20, 44, 4},
|
||||||
|
}
|
||||||
|
|
||||||
|
var advForests = []AdvLocation{
|
||||||
|
{"The Meadow", AdvActivityForaging, 1, "Berries, Twigs, Common Herbs", 1, 10, 1, 0},
|
||||||
|
{"Old Forest", AdvActivityForaging, 2, "Hardwood, Wild Fruit, Mushrooms", 3, 10, 8, 1},
|
||||||
|
{"Ancient Grove", AdvActivityForaging, 3, "Ancient Timber, Rare Herbs, Honey", 7, 10, 16, 2},
|
||||||
|
{"The Deep Jungle", AdvActivityForaging, 4, "Exotic Wood, Tropical Fruits, Spores", 12, 10, 28, 3},
|
||||||
|
{"Primal Wilds", AdvActivityForaging, 5, "Primordial Bark, Spirit Herbs, Starfruit", 20, 10, 40, 4},
|
||||||
|
}
|
||||||
|
|
||||||
|
// allAdvLocations returns all locations for a given activity type.
|
||||||
|
func allAdvLocations(activity AdvActivityType) []AdvLocation {
|
||||||
|
switch activity {
|
||||||
|
case AdvActivityDungeon:
|
||||||
|
return advDungeons
|
||||||
|
case AdvActivityMining:
|
||||||
|
return advMines
|
||||||
|
case AdvActivityForaging:
|
||||||
|
return advForests
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAdvLocation finds a location by name (case-insensitive substring match).
|
||||||
|
func findAdvLocation(activity AdvActivityType, name string) *AdvLocation {
|
||||||
|
locs := allAdvLocations(activity)
|
||||||
|
for i := range locs {
|
||||||
|
if containsFold(locs[i].Name, name) {
|
||||||
|
return &locs[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAdvLocationByTier returns the location for a given activity and tier.
|
||||||
|
func findAdvLocationByTier(activity AdvActivityType, tier int) *AdvLocation {
|
||||||
|
locs := allAdvLocations(activity)
|
||||||
|
for i := range locs {
|
||||||
|
if locs[i].Tier == tier {
|
||||||
|
return &locs[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loot Tables ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var advDungeonLoot = map[int][]AdvLootDef{
|
||||||
|
1: {{"Copper Coins", "treasure", 1, 5}, {"Rat Pelt", "treasure", 3, 8}, {"Mouldy Bread", "treasure", 1, 3}, {"Bent Nail", "treasure", 1, 2}},
|
||||||
|
2: {{"Iron Scraps", "ore", 20, 40}, {"Goblin Trinket", "treasure", 25, 50}, {"Small Gem", "gem", 40, 80}},
|
||||||
|
3: {{"Silver Bar", "ore", 100, 200}, {"Ancient Artifact", "treasure", 150, 300}, {"Quality Gem", "gem", 200, 400}},
|
||||||
|
4: {{"Gold Ingot", "ore", 500, 1000}, {"Enchanted Fragment", "treasure", 800, 1500}, {"Rare Gem", "gem", 1000, 2000}},
|
||||||
|
5: {{"Legendary Fragment", "treasure", 2000, 5000}, {"Dragon Scale", "treasure", 3000, 8000}, {"Mythic Treasure", "treasure", 5000, 15000}},
|
||||||
|
}
|
||||||
|
|
||||||
|
var advMiningLoot = map[int][]AdvLootDef{
|
||||||
|
1: {{"Copper Ore", "ore", 2, 5}, {"Tin Ore", "ore", 3, 6}, {"Coal", "ore", 2, 4}},
|
||||||
|
2: {{"Iron Ore", "ore", 15, 25}, {"Lead Ore", "ore", 18, 30}, {"Saltpetre", "ore", 20, 40}},
|
||||||
|
3: {{"Silver Ore", "ore", 60, 100}, {"Quartz", "ore", 80, 120}, {"Nickel Ore", "ore", 70, 110}},
|
||||||
|
4: {{"Gold Ore", "ore", 200, 400}, {"Sapphire", "gem", 300, 500}, {"Titanium Ore", "ore", 250, 450}},
|
||||||
|
5: {{"Mythril Ore", "ore", 1000, 2500}, {"Dragon Crystal", "gem", 2000, 4000}, {"Voidstone", "ore", 1500, 3500}},
|
||||||
|
}
|
||||||
|
|
||||||
|
var advForagingLoot = map[int][]AdvLootDef{
|
||||||
|
1: {{"Berries", "fruit", 1, 4}, {"Twigs", "wood", 2, 5}, {"Common Herbs", "fruit", 3, 8}},
|
||||||
|
2: {{"Hardwood", "wood", 10, 20}, {"Wild Fruit", "fruit", 12, 22}, {"Mushrooms", "fruit", 15, 30}},
|
||||||
|
3: {{"Ancient Timber", "wood", 40, 80}, {"Rare Herbs", "fruit", 50, 100}, {"Honey", "fruit", 60, 120}},
|
||||||
|
4: {{"Exotic Wood", "wood", 150, 300}, {"Tropical Fruits", "fruit", 180, 400}, {"Spores", "fruit", 200, 500}},
|
||||||
|
5: {{"Primordial Bark", "wood", 600, 1500}, {"Spirit Herbs", "fruit", 800, 2000}, {"Starfruit", "fruit", 1000, 3000}},
|
||||||
|
}
|
||||||
|
|
||||||
|
func advLootTable(activity AdvActivityType) map[int][]AdvLootDef {
|
||||||
|
switch activity {
|
||||||
|
case AdvActivityDungeon:
|
||||||
|
return advDungeonLoot
|
||||||
|
case AdvActivityMining:
|
||||||
|
return advMiningLoot
|
||||||
|
case AdvActivityForaging:
|
||||||
|
return advForagingLoot
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── XP Tables ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type advXPEntry struct {
|
||||||
|
Success int
|
||||||
|
Failure int
|
||||||
|
Death int
|
||||||
|
Exceptional int
|
||||||
|
}
|
||||||
|
|
||||||
|
var advXPTable = map[AdvActivityType]map[int]advXPEntry{
|
||||||
|
AdvActivityDungeon: {
|
||||||
|
1: {60, 20, 10, 90},
|
||||||
|
2: {100, 30, 15, 150},
|
||||||
|
3: {160, 45, 20, 240},
|
||||||
|
4: {230, 60, 25, 345},
|
||||||
|
5: {320, 80, 30, 480},
|
||||||
|
},
|
||||||
|
AdvActivityMining: {
|
||||||
|
1: {50, 18, 10, 75},
|
||||||
|
2: {85, 28, 15, 128},
|
||||||
|
3: {135, 40, 18, 203},
|
||||||
|
4: {200, 55, 22, 300},
|
||||||
|
5: {280, 70, 28, 420},
|
||||||
|
},
|
||||||
|
AdvActivityForaging: {
|
||||||
|
1: {40, 15, 8, 60},
|
||||||
|
2: {70, 22, 12, 105},
|
||||||
|
3: {110, 35, 15, 165},
|
||||||
|
4: {165, 48, 18, 248},
|
||||||
|
5: {230, 62, 22, 345},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func advXPForOutcome(activity AdvActivityType, tier int, outcome AdvOutcomeType) int {
|
||||||
|
table, ok := advXPTable[activity]
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
entry, ok := table[tier]
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
switch outcome {
|
||||||
|
case AdvOutcomeDeath:
|
||||||
|
return entry.Death
|
||||||
|
case AdvOutcomeEmpty, AdvOutcomeHornets:
|
||||||
|
return entry.Failure
|
||||||
|
case AdvOutcomeSuccess, AdvOutcomeCaveIn, AdvOutcomeBear, AdvOutcomeRiver:
|
||||||
|
return entry.Failure // partial successes get failure XP
|
||||||
|
case AdvOutcomeExceptional:
|
||||||
|
return entry.Exceptional
|
||||||
|
}
|
||||||
|
return entry.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
// advXPSkill returns which skill receives XP for an activity.
|
||||||
|
func advXPSkill(activity AdvActivityType) string {
|
||||||
|
switch activity {
|
||||||
|
case AdvActivityDungeon:
|
||||||
|
return "combat"
|
||||||
|
case AdvActivityMining:
|
||||||
|
return "mining"
|
||||||
|
case AdvActivityForaging:
|
||||||
|
return "foraging"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bonus Summary ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type AdvBonusSummary struct {
|
||||||
|
CombatBonus int
|
||||||
|
MiningBonus int
|
||||||
|
ForagingBonus int
|
||||||
|
DeathModifier float64 // negative = less death
|
||||||
|
LootQuality float64 // percentage modifier
|
||||||
|
XPMultiplier float64 // percentage modifier
|
||||||
|
ExceptionalBonus float64 // percentage modifier
|
||||||
|
SuccessBonus float64 // percentage modifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeAdvBonuses(treasures []AdvTreasureBonus, buffs []AdvBuff, streak int, hasGrudge bool) *AdvBonusSummary {
|
||||||
|
b := &AdvBonusSummary{}
|
||||||
|
|
||||||
|
// Treasure bonuses
|
||||||
|
for _, t := range treasures {
|
||||||
|
switch t.BonusType {
|
||||||
|
case "combat_level":
|
||||||
|
b.CombatBonus += int(t.BonusValue)
|
||||||
|
case "mining_skill":
|
||||||
|
b.MiningBonus += int(t.BonusValue)
|
||||||
|
case "foraging_skill":
|
||||||
|
b.ForagingBonus += int(t.BonusValue)
|
||||||
|
case "all_skills":
|
||||||
|
b.CombatBonus += int(t.BonusValue)
|
||||||
|
b.MiningBonus += int(t.BonusValue)
|
||||||
|
b.ForagingBonus += int(t.BonusValue)
|
||||||
|
case "death_chance":
|
||||||
|
b.DeathModifier += t.BonusValue
|
||||||
|
case "loot_quality":
|
||||||
|
b.LootQuality += t.BonusValue
|
||||||
|
case "xp_multiplier":
|
||||||
|
b.XPMultiplier += t.BonusValue
|
||||||
|
case "exceptional_chance":
|
||||||
|
b.ExceptionalBonus += t.BonusValue
|
||||||
|
case "success_chance":
|
||||||
|
b.SuccessBonus += t.BonusValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buff bonuses
|
||||||
|
for _, buf := range buffs {
|
||||||
|
switch buf.BuffType {
|
||||||
|
case "success_chance":
|
||||||
|
b.SuccessBonus += buf.Modifier
|
||||||
|
case "death_chance":
|
||||||
|
b.DeathModifier += buf.Modifier
|
||||||
|
case "loot_quality":
|
||||||
|
b.LootQuality += buf.Modifier
|
||||||
|
case "xp_multiplier":
|
||||||
|
b.XPMultiplier += buf.Modifier
|
||||||
|
case "exceptional_chance":
|
||||||
|
b.ExceptionalBonus += buf.Modifier
|
||||||
|
case "mining_success":
|
||||||
|
b.MiningBonus += int(buf.Modifier)
|
||||||
|
case "foraging_death":
|
||||||
|
b.DeathModifier += buf.Modifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streak bonuses
|
||||||
|
switch {
|
||||||
|
case streak >= 30:
|
||||||
|
b.XPMultiplier += 20
|
||||||
|
b.LootQuality += 15
|
||||||
|
b.DeathModifier -= 5
|
||||||
|
case streak >= 14:
|
||||||
|
b.XPMultiplier += 15
|
||||||
|
b.LootQuality += 10
|
||||||
|
b.DeathModifier -= 3
|
||||||
|
case streak >= 7:
|
||||||
|
b.XPMultiplier += 10
|
||||||
|
b.LootQuality += 5
|
||||||
|
case streak >= 3:
|
||||||
|
b.XPMultiplier += 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grudge bonus
|
||||||
|
if hasGrudge {
|
||||||
|
b.SuccessBonus += 10
|
||||||
|
b.XPMultiplier += 25
|
||||||
|
}
|
||||||
|
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Eligibility ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// advIsEligible checks if a character can enter a location.
|
||||||
|
// Returns (eligible, inPenaltyZone).
|
||||||
|
func advIsEligible(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary) (bool, bool) {
|
||||||
|
// Get effective skill level
|
||||||
|
skillLevel := advEffectiveSkill(char, loc.Activity, bonuses)
|
||||||
|
|
||||||
|
if skillLevel < loc.MinLevel {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check minimum equipment tier
|
||||||
|
minTier := 99
|
||||||
|
for _, eq := range equip {
|
||||||
|
if eq.Tier < minTier {
|
||||||
|
minTier = eq.Tier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if minTier < loc.MinEquipTier {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Penalty zone: within 3 levels of minimum
|
||||||
|
penalty := skillLevel-loc.MinLevel < 3
|
||||||
|
return true, penalty
|
||||||
|
}
|
||||||
|
|
||||||
|
func advEffectiveSkill(char *AdventureCharacter, activity AdvActivityType, bonuses *AdvBonusSummary) int {
|
||||||
|
switch activity {
|
||||||
|
case AdvActivityDungeon:
|
||||||
|
return char.CombatLevel + bonuses.CombatBonus
|
||||||
|
case AdvActivityMining:
|
||||||
|
return char.MiningSkill + bonuses.MiningBonus
|
||||||
|
case AdvActivityForaging:
|
||||||
|
return char.ForagingSkill + bonuses.ForagingBonus
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Probability Calculation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type advProbabilities struct {
|
||||||
|
DeathPct float64
|
||||||
|
EmptyPct float64
|
||||||
|
SuccessPct float64
|
||||||
|
ExceptionalPct float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateAdvProbabilities(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary, inPenaltyZone bool) advProbabilities {
|
||||||
|
eqScore := float64(advEquipmentScore(equip))
|
||||||
|
skillLevel := float64(advEffectiveSkill(char, loc.Activity, bonuses))
|
||||||
|
|
||||||
|
deathPct := loc.BaseDeathPct - (eqScore * 0.8) - (skillLevel * 0.5) + bonuses.DeathModifier
|
||||||
|
if inPenaltyZone {
|
||||||
|
deathPct += 5
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp death
|
||||||
|
deathPct = math.Max(1, math.Min(85, deathPct))
|
||||||
|
|
||||||
|
emptyPct := loc.EmptyPct
|
||||||
|
|
||||||
|
// Success modifiers
|
||||||
|
baseSuccess := 100 - deathPct - emptyPct
|
||||||
|
successMod := (eqScore * 1.2) + (skillLevel * 0.8) + bonuses.SuccessBonus
|
||||||
|
if inPenaltyZone {
|
||||||
|
successMod -= 15
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exceptional is 10% base, modified by bonuses
|
||||||
|
exceptionalPct := 10.0 + bonuses.ExceptionalBonus
|
||||||
|
exceptionalPct = math.Max(2, math.Min(25, exceptionalPct))
|
||||||
|
|
||||||
|
successPct := baseSuccess + successMod - exceptionalPct
|
||||||
|
successPct = math.Max(5, math.Min(90-exceptionalPct, successPct))
|
||||||
|
|
||||||
|
// Normalize if over 100
|
||||||
|
total := deathPct + emptyPct + successPct + exceptionalPct
|
||||||
|
if total > 100 {
|
||||||
|
scale := 100 / total
|
||||||
|
deathPct *= scale
|
||||||
|
emptyPct *= scale
|
||||||
|
successPct *= scale
|
||||||
|
exceptionalPct *= scale
|
||||||
|
} else if total < 100 {
|
||||||
|
// Give remaining to success
|
||||||
|
successPct += 100 - total
|
||||||
|
}
|
||||||
|
|
||||||
|
return advProbabilities{
|
||||||
|
DeathPct: deathPct,
|
||||||
|
EmptyPct: emptyPct,
|
||||||
|
SuccessPct: successPct,
|
||||||
|
ExceptionalPct: exceptionalPct,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loot Generation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func generateAdvLoot(loc *AdvLocation, exceptional bool, lootQualityMod float64) []AdvItem {
|
||||||
|
table := advLootTable(loc.Activity)
|
||||||
|
if table == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defs, ok := table[loc.Tier]
|
||||||
|
if !ok || len(defs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number of items: 1-2 for normal, 2-3 for exceptional
|
||||||
|
count := 1 + rand.IntN(2)
|
||||||
|
if exceptional {
|
||||||
|
count = 2 + rand.IntN(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []AdvItem
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
def := defs[rand.IntN(len(defs))]
|
||||||
|
value := def.MinValue + rand.Int64N(def.MaxValue-def.MinValue+1)
|
||||||
|
|
||||||
|
// Apply loot quality modifier
|
||||||
|
if lootQualityMod != 0 {
|
||||||
|
value = int64(float64(value) * (1 + lootQualityMod/100))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exceptional items are worth more
|
||||||
|
if exceptional {
|
||||||
|
value = int64(float64(value) * 1.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, AdvItem{
|
||||||
|
Name: def.Name,
|
||||||
|
Type: def.Type,
|
||||||
|
Tier: loc.Tier,
|
||||||
|
Value: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Equipment Degradation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func applyAdvEquipDegradation(equip map[EquipmentSlot]*AdvEquipment, outcome AdvOutcomeType) map[EquipmentSlot]int {
|
||||||
|
damage := make(map[EquipmentSlot]int)
|
||||||
|
|
||||||
|
switch outcome {
|
||||||
|
case AdvOutcomeDeath:
|
||||||
|
// All slots -20, weapon and armor -30 (additional)
|
||||||
|
for _, slot := range allSlots {
|
||||||
|
damage[slot] = 20
|
||||||
|
}
|
||||||
|
damage[SlotWeapon] = 30
|
||||||
|
damage[SlotArmor] = 30
|
||||||
|
|
||||||
|
case AdvOutcomeCaveIn:
|
||||||
|
damage[SlotTool] = 25
|
||||||
|
damage[SlotArmor] = 10
|
||||||
|
|
||||||
|
case AdvOutcomeEmpty:
|
||||||
|
// Failed dungeon run
|
||||||
|
damage[SlotWeapon] = 15
|
||||||
|
damage[SlotArmor] = 10
|
||||||
|
|
||||||
|
case AdvOutcomeBear:
|
||||||
|
damage[SlotArmor] = 20
|
||||||
|
damage[SlotBoots] = 15
|
||||||
|
|
||||||
|
case AdvOutcomeRiver:
|
||||||
|
damage[SlotBoots] = 20
|
||||||
|
|
||||||
|
case AdvOutcomeHornets:
|
||||||
|
// No equipment damage — they don't care about your sword
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply damage and check for breaks
|
||||||
|
for slot, dmg := range damage {
|
||||||
|
eq, ok := equip[slot]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Equipment mastery: well-used gear degrades slower
|
||||||
|
if eq.ActionsUsed >= 20 {
|
||||||
|
dmg = int(float64(dmg) * 0.8)
|
||||||
|
}
|
||||||
|
eq.Condition -= dmg
|
||||||
|
if eq.Condition < 0 {
|
||||||
|
eq.Condition = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return damage
|
||||||
|
}
|
||||||
|
|
||||||
|
// advCheckBrokenEquipment checks which slots hit 0 condition and reverts them to tier 0.
|
||||||
|
func advCheckBrokenEquipment(equip map[EquipmentSlot]*AdvEquipment) []EquipmentSlot {
|
||||||
|
var broken []EquipmentSlot
|
||||||
|
for _, slot := range allSlots {
|
||||||
|
eq, ok := equip[slot]
|
||||||
|
if !ok || eq.Condition > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Revert to tier 0
|
||||||
|
def := equipmentTiers[slot][0]
|
||||||
|
eq.Tier = 0
|
||||||
|
eq.Condition = 100
|
||||||
|
eq.Name = def.Name
|
||||||
|
eq.ActionsUsed = 0
|
||||||
|
broken = append(broken, slot)
|
||||||
|
}
|
||||||
|
return broken
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Outcome Resolution ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type AdvActionResult struct {
|
||||||
|
Outcome AdvOutcomeType
|
||||||
|
Location *AdvLocation
|
||||||
|
LootItems []AdvItem
|
||||||
|
TotalLootValue int64
|
||||||
|
XPGained int
|
||||||
|
XPSkill string
|
||||||
|
EquipDamage map[EquipmentSlot]int
|
||||||
|
LeveledUp bool
|
||||||
|
NewLevel int
|
||||||
|
TreasureFound *AdvTreasureDrop
|
||||||
|
FlavorText string
|
||||||
|
FlavorKey string
|
||||||
|
EquipBroken []EquipmentSlot
|
||||||
|
NearDeath bool
|
||||||
|
StreakBonus int
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary, inPenaltyZone bool) *AdvActionResult {
|
||||||
|
result := &AdvActionResult{
|
||||||
|
Location: loc,
|
||||||
|
XPSkill: advXPSkill(loc.Activity),
|
||||||
|
}
|
||||||
|
|
||||||
|
probs := calculateAdvProbabilities(char, equip, loc, bonuses, inPenaltyZone)
|
||||||
|
|
||||||
|
// Roll outcome
|
||||||
|
roll := rand.Float64() * 100
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case roll < probs.DeathPct:
|
||||||
|
result.Outcome = AdvOutcomeDeath
|
||||||
|
case roll < probs.DeathPct+probs.EmptyPct:
|
||||||
|
// Activity-specific empty outcomes
|
||||||
|
result.Outcome = resolveAdvEmptyOutcome(loc, roll)
|
||||||
|
case roll < probs.DeathPct+probs.EmptyPct+probs.SuccessPct:
|
||||||
|
result.Outcome = AdvOutcomeSuccess
|
||||||
|
default:
|
||||||
|
result.Outcome = AdvOutcomeExceptional
|
||||||
|
}
|
||||||
|
|
||||||
|
// Near-death check: survived within 2% of death threshold
|
||||||
|
if result.Outcome != AdvOutcomeDeath && roll < probs.DeathPct+2 && roll >= probs.DeathPct {
|
||||||
|
result.NearDeath = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate loot for success/exceptional
|
||||||
|
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
|
||||||
|
result.LootItems = generateAdvLoot(loc, result.Outcome == AdvOutcomeExceptional, bonuses.LootQuality)
|
||||||
|
for _, item := range result.LootItems {
|
||||||
|
result.TotalLootValue += item.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// XP calculation
|
||||||
|
xp := advXPForOutcome(loc.Activity, loc.Tier, result.Outcome)
|
||||||
|
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
|
||||||
|
xp = advXPTable[loc.Activity][loc.Tier].Success
|
||||||
|
if result.Outcome == AdvOutcomeExceptional {
|
||||||
|
xp = advXPTable[loc.Activity][loc.Tier].Exceptional
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Near-death XP bonus
|
||||||
|
if result.NearDeath {
|
||||||
|
xp = int(float64(xp) * 1.15)
|
||||||
|
}
|
||||||
|
|
||||||
|
// XP multiplier from bonuses
|
||||||
|
if bonuses.XPMultiplier != 0 {
|
||||||
|
xp = int(float64(xp) * (1 + bonuses.XPMultiplier/100))
|
||||||
|
}
|
||||||
|
result.XPGained = xp
|
||||||
|
|
||||||
|
// Equipment degradation on bad outcomes
|
||||||
|
if result.Outcome == AdvOutcomeDeath || result.Outcome == AdvOutcomeEmpty ||
|
||||||
|
result.Outcome == AdvOutcomeCaveIn || result.Outcome == AdvOutcomeBear ||
|
||||||
|
result.Outcome == AdvOutcomeRiver {
|
||||||
|
result.EquipDamage = applyAdvEquipDegradation(equip, result.Outcome)
|
||||||
|
result.EquipBroken = advCheckBrokenEquipment(equip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment actions_used for equipment mastery
|
||||||
|
for _, eq := range equip {
|
||||||
|
eq.ActionsUsed++
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAdvEmptyOutcome returns an activity-specific "empty" outcome.
|
||||||
|
func resolveAdvEmptyOutcome(loc *AdvLocation, _ float64) AdvOutcomeType {
|
||||||
|
switch loc.Activity {
|
||||||
|
case AdvActivityMining:
|
||||||
|
// 40% chance of cave-in on "empty" result
|
||||||
|
if rand.Float64() < 0.4 {
|
||||||
|
return AdvOutcomeCaveIn
|
||||||
|
}
|
||||||
|
return AdvOutcomeEmpty
|
||||||
|
|
||||||
|
case AdvActivityForaging:
|
||||||
|
// Split empty into specific outcomes
|
||||||
|
r := rand.Float64()
|
||||||
|
switch {
|
||||||
|
case r < 0.35:
|
||||||
|
return AdvOutcomeHornets
|
||||||
|
case r < 0.55:
|
||||||
|
return AdvOutcomeBear
|
||||||
|
case r < 0.70:
|
||||||
|
return AdvOutcomeRiver
|
||||||
|
default:
|
||||||
|
return AdvOutcomeEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return AdvOutcomeEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Eligible Locations for DM Menu ───────────────────────────────────────────
|
||||||
|
|
||||||
|
type AdvEligibleLocation struct {
|
||||||
|
Location *AdvLocation
|
||||||
|
InPenaltyZone bool
|
||||||
|
DeathPct float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func advEligibleLocations(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, activity AdvActivityType, bonuses *AdvBonusSummary) []AdvEligibleLocation {
|
||||||
|
var eligible []AdvEligibleLocation
|
||||||
|
for _, loc := range allAdvLocations(activity) {
|
||||||
|
loc := loc
|
||||||
|
ok, penalty := advIsEligible(char, equip, &loc, bonuses)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
probs := calculateAdvProbabilities(char, equip, &loc, bonuses, penalty)
|
||||||
|
eligible = append(eligible, AdvEligibleLocation{
|
||||||
|
Location: &loc,
|
||||||
|
InPenaltyZone: penalty,
|
||||||
|
DeathPct: probs.DeathPct,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return eligible
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Party Bonus Check ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// advCheckPartyBonus checks if other players visited the same location today.
|
||||||
|
func advCheckPartyBonus(userID id.UserID, location string) bool {
|
||||||
|
logs, err := loadAdvTodayLogs()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, l := range logs {
|
||||||
|
if l.UserID != userID && l.Location == location {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func containsFold(s, substr string) bool {
|
||||||
|
if len(substr) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sl := make([]byte, len(s))
|
||||||
|
subl := make([]byte, len(substr))
|
||||||
|
for i := range s {
|
||||||
|
if s[i] >= 'A' && s[i] <= 'Z' {
|
||||||
|
sl[i] = s[i] + 32
|
||||||
|
} else {
|
||||||
|
sl[i] = s[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range substr {
|
||||||
|
if substr[i] >= 'A' && substr[i] <= 'Z' {
|
||||||
|
subl[i] = substr[i] + 32
|
||||||
|
} else {
|
||||||
|
subl[i] = substr[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return containsBytes(sl, subl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsBytes(s, sub []byte) bool {
|
||||||
|
if len(sub) > len(s) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i <= len(s)-len(sub); i++ {
|
||||||
|
match := true
|
||||||
|
for j := range sub {
|
||||||
|
if s[i+j] != sub[j] {
|
||||||
|
match = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
530
internal/plugin/adventure_character.go
Normal file
530
internal/plugin/adventure_character.go
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Equipment Slot Constants ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type EquipmentSlot string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SlotWeapon EquipmentSlot = "weapon"
|
||||||
|
SlotArmor EquipmentSlot = "armor"
|
||||||
|
SlotHelmet EquipmentSlot = "helmet"
|
||||||
|
SlotBoots EquipmentSlot = "boots"
|
||||||
|
SlotTool EquipmentSlot = "tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var allSlots = []EquipmentSlot{SlotWeapon, SlotArmor, SlotHelmet, SlotBoots, SlotTool}
|
||||||
|
|
||||||
|
// ── Core Types ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type AdventureCharacter struct {
|
||||||
|
UserID id.UserID
|
||||||
|
DisplayName string
|
||||||
|
CombatLevel int
|
||||||
|
MiningSkill int
|
||||||
|
ForagingSkill int
|
||||||
|
FishingSkill int // v2
|
||||||
|
CombatXP int
|
||||||
|
MiningXP int
|
||||||
|
ForagingXP int
|
||||||
|
FishingXP int // v2
|
||||||
|
Alive bool
|
||||||
|
DeadUntil *time.Time
|
||||||
|
ActionTakenToday bool
|
||||||
|
ArenaWins int // v2
|
||||||
|
ArenaLosses int // v2
|
||||||
|
InvasionScore int // v2
|
||||||
|
Title string // v2
|
||||||
|
CurrentStreak int
|
||||||
|
BestStreak int
|
||||||
|
LastActionDate string
|
||||||
|
GrudgeLocation string
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastActiveAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdvEquipment struct {
|
||||||
|
Slot EquipmentSlot
|
||||||
|
Tier int
|
||||||
|
Condition int
|
||||||
|
Name string
|
||||||
|
ActionsUsed int
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdvItem struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Type string // ore, wood, fruit, treasure, gem
|
||||||
|
Tier int
|
||||||
|
Value int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdvBuff struct {
|
||||||
|
ID int64
|
||||||
|
UserID id.UserID
|
||||||
|
BuffType string
|
||||||
|
BuffName string
|
||||||
|
Modifier float64
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Equipment Tier Definitions ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
type EquipmentDef struct {
|
||||||
|
Name string
|
||||||
|
Tier int
|
||||||
|
Description string
|
||||||
|
Price float64
|
||||||
|
}
|
||||||
|
|
||||||
|
var equipmentTiers = map[EquipmentSlot][]EquipmentDef{
|
||||||
|
SlotWeapon: {
|
||||||
|
{Name: "Basic Ass Sword", Tier: 0, Description: "It's a sword in the same way a parking ticket is legal documentation. A profound object of shame.", Price: 0},
|
||||||
|
{Name: "Sad Iron Sword", Tier: 1, Description: "It's iron. It holds an edge if you squint. An improvement over the last thing in the same way a bruise improves on a fracture.", Price: 100},
|
||||||
|
{Name: "Dull Steel Sword of Mediocrity", Tier: 2, Description: "Steel, technically. Holds an edge longer than the last one, which isn't saying much. A sword for someone who has given up dreaming but not yet given up entirely.", Price: 450},
|
||||||
|
{Name: "Sword (It's Fine)", Tier: 3, Description: "Fine. It's a fine sword. Not good. Not impressive. Nobody will write songs about it. It will not let you down in a straightforward fight.", Price: 1500},
|
||||||
|
{Name: "Enchanted Blade", Tier: 4, Description: "Enchanted. Glows faintly, hums with something that feels like intent. For the first time in your miserable adventuring career, the weapon is not the problem.", Price: 7500},
|
||||||
|
{Name: "Vorpal Sword", Tier: 5, Description: "Vorpal. You know what it does. The things in the dark know too. They remember the last person who carried this.", Price: 30000},
|
||||||
|
},
|
||||||
|
SlotArmor: {
|
||||||
|
{Name: "Shitty Armor", Tier: 0, Description: "Offers the protection of a strongly-worded letter. Looks worse than it sounds.", Price: 0},
|
||||||
|
{Name: "Leather Scraps (Stitched)", Tier: 1, Description: "Leather. Most of it. Keeps the wind out and occasionally a very polite blade.", Price: 100},
|
||||||
|
{Name: "Embarrassing Chain Mail", Tier: 2, Description: "Chain mail. Heavy, loud, and genuinely better than dying, which is its only selling point and honestly sufficient.", Price: 450},
|
||||||
|
{Name: "Armor (Functional, Ugly)", Tier: 3, Description: "Plate armor. Heavy as bad decisions, protective as a real piece of equipment. Doesn't fit great. Works fine.", Price: 1500},
|
||||||
|
{Name: "Enchanted Plate", Tier: 4, Description: "Enchanted plate. Lighter than it has any right to be, tougher than physics should allow. You feel, for the first time, like someone who is supposed to be doing this.", Price: 7500},
|
||||||
|
{Name: "Dragonscale", Tier: 5, Description: "Dragonscale. An actual dragon died for this. Someone killed it. Maybe you, eventually. For now you wear the proof that such things are possible.", Price: 30000},
|
||||||
|
},
|
||||||
|
SlotHelmet: {
|
||||||
|
{Name: "Goddamn Offensive Helmet", Tier: 0, Description: "Bad for your head. An insult to everyone in the immediate vicinity.", Price: 0},
|
||||||
|
{Name: "Iron Pot with Eyeholes", Tier: 1, Description: "Someone used this as a chamber pot before it was a helmet. There are theories. It has eyeholes now.", Price: 75},
|
||||||
|
{Name: "Helmet of Questionable Provenance", Tier: 2, Description: "The scratches were there when you bought it. The steel is sound. Nobody will compliment this helmet.", Price: 350},
|
||||||
|
{Name: "Helm of Unremarkable Adequacy", Tier: 3, Description: "Reinforced. Fitted, roughly. Doesn't make you look competent but stops the top of your head from becoming someone else's problem.", Price: 1200},
|
||||||
|
{Name: "Guardian's Helm", Tier: 4, Description: "Guardian-grade. This helm has seen real battles, kept real heads intact, and carries itself with the quiet dignity you are only just beginning to deserve.", Price: 6000},
|
||||||
|
{Name: "Crown of the Fallen", Tier: 5, Description: "Crown of the Fallen. Every previous owner died in it. None of them died because of it. It will outlast you too.", Price: 25000},
|
||||||
|
},
|
||||||
|
SlotBoots: {
|
||||||
|
{Name: "Knobby-Ass Boots", Tier: 0, Description: "The knobs are not a feature. Nobody knows what they are. Stop looking at them.", Price: 0},
|
||||||
|
{Name: "Dead Man's Boots", Tier: 1, Description: "Taken off a corpse. The corpse didn't need them. You do. Don't think about it too hard.", Price: 75},
|
||||||
|
{Name: "Boots of Mild Discomfort", Tier: 2, Description: "They've been places. Bad places. Places that did things to the leather you'd rather not examine. They'll hold together. Probably.", Price: 350},
|
||||||
|
{Name: "Boots of Getting There Eventually", Tier: 3, Description: "Light enough. Grip is decent. Built for someone who moves with purpose, which you are in the process of becoming.", Price: 1200},
|
||||||
|
{Name: "Ranger's Boots", Tier: 4, Description: "Ranger's boots. You move quieter. Faster. Longer. The ground cooperates. The forest notices. Something has shifted.", Price: 6000},
|
||||||
|
{Name: "Boots of the Wind", Tier: 5, Description: "The wind doesn't slow you. Terrain offers suggestions you are free to decline. These boots are an affront to the concept of obstacles.", Price: 25000},
|
||||||
|
},
|
||||||
|
SlotTool: {
|
||||||
|
{Name: "Rusted PoS Pickaxe", Tier: 0, Description: "Technically a pickaxe. That is the single nicest thing anyone can say about it.", Price: 0},
|
||||||
|
{Name: "Dull Copper Pickaxe", Tier: 1, Description: "Copper. Soft. Gets the job done if you hit very hard and the ore is feeling cooperative.", Price: 100},
|
||||||
|
{Name: "Chipped Iron Pickaxe", Tier: 2, Description: "Iron. Chipped to hell but bites the rock with something approaching intention. A pickaxe that exists and functions.", Price: 450},
|
||||||
|
{Name: "Serviceable Steel Pickaxe", Tier: 3, Description: "Steel, properly weighted, properly edged. The mountain will acknowledge this pickaxe. Not respect it. Acknowledge it.", Price: 1500},
|
||||||
|
{Name: "Mithril Pickaxe", Tier: 4, Description: "Mithril. Weighs nothing. Hits like consequence. Ores don't resist so much as rearrange themselves out of respect.", Price: 7500},
|
||||||
|
{Name: "Diamond Pickaxe", Tier: 5, Description: "Diamond. Breaks anything short of fate and occasionally that too. The only limits left are your arm, your nerve, and the number of hours in a day.", Price: 30000},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// tier0Equipment returns the name for a given slot at tier 0.
|
||||||
|
func tier0Equipment(slot EquipmentSlot) string {
|
||||||
|
return equipmentTiers[slot][0].Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// equipmentDefByTier returns the definition for a slot at a given tier.
|
||||||
|
func equipmentDefByTier(slot EquipmentSlot, tier int) EquipmentDef {
|
||||||
|
defs := equipmentTiers[slot]
|
||||||
|
if tier < 0 || tier >= len(defs) {
|
||||||
|
return defs[0]
|
||||||
|
}
|
||||||
|
return defs[tier]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Equipment Score ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func advEquipmentScore(equip map[EquipmentSlot]*AdvEquipment) int {
|
||||||
|
score := 0
|
||||||
|
for _, slot := range allSlots {
|
||||||
|
eq, ok := equip[slot]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tierContrib := eq.Tier
|
||||||
|
if slot == SlotWeapon {
|
||||||
|
tierContrib *= 2
|
||||||
|
}
|
||||||
|
// Condition modifier: below 50 halves contribution
|
||||||
|
if eq.Condition < 50 {
|
||||||
|
tierContrib /= 2
|
||||||
|
}
|
||||||
|
score += tierContrib
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── XP & Level-Up ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const maxAdvLevel = 50
|
||||||
|
|
||||||
|
// xpToNextLevel returns XP needed to advance from level to level+1.
|
||||||
|
func xpToNextLevel(level int) int {
|
||||||
|
return 100 + (level * 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAdvLevelUp checks if a character leveled up in the given skill and applies it.
|
||||||
|
// Returns whether a level-up occurred and the new level.
|
||||||
|
func checkAdvLevelUp(char *AdventureCharacter, skill string) (bool, int) {
|
||||||
|
var xp *int
|
||||||
|
var level *int
|
||||||
|
switch skill {
|
||||||
|
case "combat":
|
||||||
|
xp = &char.CombatXP
|
||||||
|
level = &char.CombatLevel
|
||||||
|
case "mining":
|
||||||
|
xp = &char.MiningXP
|
||||||
|
level = &char.MiningSkill
|
||||||
|
case "foraging":
|
||||||
|
xp = &char.ForagingXP
|
||||||
|
level = &char.ForagingSkill
|
||||||
|
default:
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if *level >= maxAdvLevel {
|
||||||
|
return false, *level
|
||||||
|
}
|
||||||
|
|
||||||
|
leveled := false
|
||||||
|
for *level < maxAdvLevel {
|
||||||
|
needed := xpToNextLevel(*level)
|
||||||
|
if *xp < needed {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
*xp -= needed
|
||||||
|
*level++
|
||||||
|
leveled = true
|
||||||
|
}
|
||||||
|
return leveled, *level
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DB CRUD ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
|
||||||
|
d := db.Get()
|
||||||
|
c := &AdventureCharacter{}
|
||||||
|
var alive, actionTaken int
|
||||||
|
var deadUntil sql.NullTime
|
||||||
|
|
||||||
|
err := d.QueryRow(`
|
||||||
|
SELECT user_id, display_name,
|
||||||
|
combat_level, mining_skill, foraging_skill, fishing_skill,
|
||||||
|
combat_xp, mining_xp, foraging_xp, fishing_xp,
|
||||||
|
alive, dead_until, action_taken_today,
|
||||||
|
arena_wins, arena_losses, invasion_score, title,
|
||||||
|
current_streak, best_streak, last_action_date, grudge_location,
|
||||||
|
created_at, last_active_at
|
||||||
|
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
|
||||||
|
&c.UserID, &c.DisplayName,
|
||||||
|
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
||||||
|
&c.CombatXP, &c.MiningXP, &c.ForagingXP, &c.FishingXP,
|
||||||
|
&alive, &deadUntil, &actionTaken,
|
||||||
|
&c.ArenaWins, &c.ArenaLosses, &c.InvasionScore, &c.Title,
|
||||||
|
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
|
||||||
|
&c.CreatedAt, &c.LastActiveAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.Alive = alive == 1
|
||||||
|
c.ActionTakenToday = actionTaken == 1
|
||||||
|
if deadUntil.Valid {
|
||||||
|
c.DeadUntil = &deadUntil.Time
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAdvCharacter(userID id.UserID, displayName string) error {
|
||||||
|
d := db.Get()
|
||||||
|
tx, err := d.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO adventure_characters (user_id, display_name)
|
||||||
|
VALUES (?, ?)`, string(userID), displayName)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tier-0 equipment in all slots
|
||||||
|
for _, slot := range allSlots {
|
||||||
|
def := equipmentTiers[slot][0]
|
||||||
|
_, err = tx.Exec(`
|
||||||
|
INSERT INTO adventure_equipment (user_id, slot, tier, condition, name, actions_used)
|
||||||
|
VALUES (?, ?, 0, 100, ?, 0)`, string(userID), string(slot), def.Name)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveAdvCharacter(char *AdventureCharacter) error {
|
||||||
|
d := db.Get()
|
||||||
|
alive := 0
|
||||||
|
if char.Alive {
|
||||||
|
alive = 1
|
||||||
|
}
|
||||||
|
actionTaken := 0
|
||||||
|
if char.ActionTakenToday {
|
||||||
|
actionTaken = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := d.Exec(`
|
||||||
|
UPDATE adventure_characters SET
|
||||||
|
display_name = ?, combat_level = ?, mining_skill = ?, foraging_skill = ?, fishing_skill = ?,
|
||||||
|
combat_xp = ?, mining_xp = ?, foraging_xp = ?, fishing_xp = ?,
|
||||||
|
alive = ?, dead_until = ?, action_taken_today = ?,
|
||||||
|
arena_wins = ?, arena_losses = ?, invasion_score = ?, title = ?,
|
||||||
|
current_streak = ?, best_streak = ?, last_action_date = ?, grudge_location = ?,
|
||||||
|
last_active_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE user_id = ?`,
|
||||||
|
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
|
||||||
|
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
|
||||||
|
alive, char.DeadUntil, actionTaken,
|
||||||
|
char.ArenaWins, char.ArenaLosses, char.InvasionScore, char.Title,
|
||||||
|
char.CurrentStreak, char.BestStreak, char.LastActionDate, char.GrudgeLocation,
|
||||||
|
string(char.UserID),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAdvEquipment(userID id.UserID) (map[EquipmentSlot]*AdvEquipment, error) {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(`
|
||||||
|
SELECT slot, tier, condition, name, actions_used
|
||||||
|
FROM adventure_equipment WHERE user_id = ?`, string(userID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
equip := make(map[EquipmentSlot]*AdvEquipment)
|
||||||
|
for rows.Next() {
|
||||||
|
e := &AdvEquipment{}
|
||||||
|
var slot string
|
||||||
|
if err := rows.Scan(&slot, &e.Tier, &e.Condition, &e.Name, &e.ActionsUsed); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.Slot = EquipmentSlot(slot)
|
||||||
|
equip[e.Slot] = e
|
||||||
|
}
|
||||||
|
return equip, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveAdvEquipment(userID id.UserID, eq *AdvEquipment) error {
|
||||||
|
d := db.Get()
|
||||||
|
_, err := d.Exec(`
|
||||||
|
UPDATE adventure_equipment
|
||||||
|
SET tier = ?, condition = ?, name = ?, actions_used = ?
|
||||||
|
WHERE user_id = ? AND slot = ?`,
|
||||||
|
eq.Tier, eq.Condition, eq.Name, eq.ActionsUsed,
|
||||||
|
string(userID), string(eq.Slot))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(`
|
||||||
|
SELECT id, name, item_type, tier, value
|
||||||
|
FROM adventure_inventory WHERE user_id = ?
|
||||||
|
ORDER BY tier DESC, value DESC`, string(userID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []AdvItem
|
||||||
|
for rows.Next() {
|
||||||
|
var it AdvItem
|
||||||
|
if err := rows.Scan(&it.ID, &it.Name, &it.Type, &it.Tier, &it.Value); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, it)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func addAdvInventoryItem(userID id.UserID, item AdvItem) error {
|
||||||
|
d := db.Get()
|
||||||
|
_, err := d.Exec(`
|
||||||
|
INSERT INTO adventure_inventory (user_id, name, item_type, tier, value)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
string(userID), item.Name, item.Type, item.Tier, item.Value)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAdvInventoryItem(itemID int64) error {
|
||||||
|
d := db.Get()
|
||||||
|
_, err := d.Exec(`DELETE FROM adventure_inventory WHERE id = ?`, itemID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAdvInventory(userID id.UserID) ([]AdvItem, error) {
|
||||||
|
items, err := loadAdvInventory(userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d := db.Get()
|
||||||
|
_, err = d.Exec(`DELETE FROM adventure_inventory WHERE user_id = ?`, string(userID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func advInventoryCount(userID id.UserID) int {
|
||||||
|
d := db.Get()
|
||||||
|
var count int
|
||||||
|
_ = d.QueryRow(`SELECT COUNT(*) FROM adventure_inventory WHERE user_id = ?`, string(userID)).Scan(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(`
|
||||||
|
SELECT user_id, display_name,
|
||||||
|
combat_level, mining_skill, foraging_skill, fishing_skill,
|
||||||
|
combat_xp, mining_xp, foraging_xp, fishing_xp,
|
||||||
|
alive, dead_until, action_taken_today,
|
||||||
|
arena_wins, arena_losses, invasion_score, title,
|
||||||
|
current_streak, best_streak, last_action_date, grudge_location,
|
||||||
|
created_at, last_active_at
|
||||||
|
FROM adventure_characters`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var chars []AdventureCharacter
|
||||||
|
for rows.Next() {
|
||||||
|
c := AdventureCharacter{}
|
||||||
|
var alive, actionTaken int
|
||||||
|
var deadUntil sql.NullTime
|
||||||
|
if err := rows.Scan(
|
||||||
|
&c.UserID, &c.DisplayName,
|
||||||
|
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
||||||
|
&c.CombatXP, &c.MiningXP, &c.ForagingXP, &c.FishingXP,
|
||||||
|
&alive, &deadUntil, &actionTaken,
|
||||||
|
&c.ArenaWins, &c.ArenaLosses, &c.InvasionScore, &c.Title,
|
||||||
|
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
|
||||||
|
&c.CreatedAt, &c.LastActiveAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.Alive = alive == 1
|
||||||
|
c.ActionTakenToday = actionTaken == 1
|
||||||
|
if deadUntil.Valid {
|
||||||
|
c.DeadUntil = &deadUntil.Time
|
||||||
|
}
|
||||||
|
chars = append(chars, c)
|
||||||
|
}
|
||||||
|
return chars, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetAllAdvDailyActions() error {
|
||||||
|
d := db.Get()
|
||||||
|
_, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func logAdvActivity(userID id.UserID, activityType, location, outcome string, lootValue int64, xpGained int, flavorKey string) {
|
||||||
|
d := db.Get()
|
||||||
|
_, err := d.Exec(`
|
||||||
|
INSERT INTO adventure_activity_log (user_id, activity_type, location, outcome, loot_value, xp_gained, flavor_key)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
string(userID), activityType, location, outcome, lootValue, xpGained, flavorKey)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("adventure: failed to log activity", "user", userID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Buff CRUD ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func loadAdvActiveBuffs(userID id.UserID) ([]AdvBuff, error) {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(`
|
||||||
|
SELECT id, user_id, buff_type, buff_name, modifier, expires_at
|
||||||
|
FROM adventure_buffs
|
||||||
|
WHERE user_id = ? AND expires_at > CURRENT_TIMESTAMP`, string(userID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var buffs []AdvBuff
|
||||||
|
for rows.Next() {
|
||||||
|
var b AdvBuff
|
||||||
|
if err := rows.Scan(&b.ID, &b.UserID, &b.BuffType, &b.BuffName, &b.Modifier, &b.ExpiresAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
buffs = append(buffs, b)
|
||||||
|
}
|
||||||
|
return buffs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func addAdvBuff(userID id.UserID, buffType, buffName string, modifier float64, expiresAt time.Time) error {
|
||||||
|
d := db.Get()
|
||||||
|
_, err := d.Exec(`
|
||||||
|
INSERT INTO adventure_buffs (user_id, buff_type, buff_name, modifier, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
string(userID), buffType, buffName, modifier, expiresAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func pruneAdvExpiredBuffs() error {
|
||||||
|
d := db.Get()
|
||||||
|
_, err := d.Exec(`DELETE FROM adventure_buffs WHERE expires_at < CURRENT_TIMESTAMP`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Today's Activity Log ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type AdvDayLog struct {
|
||||||
|
UserID id.UserID
|
||||||
|
ActivityType string
|
||||||
|
Location string
|
||||||
|
Outcome string
|
||||||
|
LootValue int64
|
||||||
|
XPGained int
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAdvTodayLogs() ([]AdvDayLog, error) {
|
||||||
|
d := db.Get()
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
rows, err := d.Query(`
|
||||||
|
SELECT user_id, activity_type, COALESCE(location,''), outcome, loot_value, xp_gained
|
||||||
|
FROM adventure_activity_log
|
||||||
|
WHERE DATE(logged_at) = ?
|
||||||
|
ORDER BY logged_at`, today)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var logs []AdvDayLog
|
||||||
|
for rows.Next() {
|
||||||
|
var l AdvDayLog
|
||||||
|
if err := rows.Scan(&l.UserID, &l.ActivityType, &l.Location, &l.Outcome, &l.LootValue, &l.XPGained); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
logs = append(logs, l)
|
||||||
|
}
|
||||||
|
return logs, rows.Err()
|
||||||
|
}
|
||||||
927
internal/plugin/adventure_flavor_dungeon.go
Normal file
927
internal/plugin/adventure_flavor_dungeon.go
Normal file
@@ -0,0 +1,927 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
// DungeonFlavor holds all pre-written narrative strings for dungeon outcomes.
|
||||||
|
// Indexed by tier (1–5) and outcome type.
|
||||||
|
// Selection: pick randomly, skip last 5 used per player per category.
|
||||||
|
|
||||||
|
var DungeonDeath = map[int][]string{
|
||||||
|
|
||||||
|
// ── TIER 1: THE SOGGY CELLAR ─────────────────────────────────────────────
|
||||||
|
// Deaths here should be embarrassing above all else. You died to rats.
|
||||||
|
// You died to slimes. The dungeon is four feet underground and damp.
|
||||||
|
// History will not record this kindly.
|
||||||
|
1: {
|
||||||
|
"You entered the Soggy Cellar with a sword and left without consciousness. " +
|
||||||
|
"The rats were not impressed. They had, if you're being honest, a point. " +
|
||||||
|
"You have been retrieved from the floor by a passing merchant who charged you " +
|
||||||
|
"for the inconvenience and did not make eye contact.",
|
||||||
|
|
||||||
|
"A single rat. One rat. You were killed by one rat in a cellar that smells " +
|
||||||
|
"of old vegetables and broken dreams. The rat has returned to its business. " +
|
||||||
|
"You have been taken to the medical facility, which is a cot and a bill " +
|
||||||
|
"and a nurse who has seen this before and has thoughts but keeps them to herself.",
|
||||||
|
|
||||||
|
"YOU DIED. In large, honest letters, if this were a more transparent world. " +
|
||||||
|
"It is not, so instead you simply died, quietly, in a damp place, " +
|
||||||
|
"surrounded by rats who did not particularly want to kill you but " +
|
||||||
|
"found themselves in the position regardless. Here we are.",
|
||||||
|
|
||||||
|
"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 " +
|
||||||
|
"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.",
|
||||||
|
|
||||||
|
"The Wet Slime got you. Not a fire slime. Not a poison slime. A wet slime. " +
|
||||||
|
"An ambient, damp, fundamentally inoffensive slime that nonetheless " +
|
||||||
|
"managed to be fatal to you specifically. The slime has resumed being wet. " +
|
||||||
|
"You have resumed being a problem for the healthcare system.",
|
||||||
|
|
||||||
|
"You slipped. That's it. You slipped on something unidentified on the " +
|
||||||
|
"Soggy Cellar floor — the name should have been a warning, it was a warning, " +
|
||||||
|
"you did not take the warning — hit your head, and that was that. " +
|
||||||
|
"The Angry Badger nearby watched this happen and felt nothing.",
|
||||||
|
|
||||||
|
"The Angry Badger was, it turns out, very angry. You were not prepared " +
|
||||||
|
"for the depth of the anger. You are now in medical care reflecting " +
|
||||||
|
"on the gap between 'Angry Badger' as a concept and 'Angry Badger' " +
|
||||||
|
"as an experience. The gap is significant. The badger is still angry.",
|
||||||
|
|
||||||
|
"You fought bravely. History will record this as 'bravely.' The rats " +
|
||||||
|
"will record it as 'lunch,' which is a matter of perspective, " +
|
||||||
|
"and the rats have the stronger perspective here because they won. " +
|
||||||
|
"You are expected to make a full recovery. Expected is doing a lot of work " +
|
||||||
|
"in that sentence.",
|
||||||
|
|
||||||
|
"Your Basic Ass Sword broke on the first swing. Not bent. Not chipped. " +
|
||||||
|
"Broke. In half. On a rat. You had a moment to process this fully " +
|
||||||
|
"before losing consciousness, which is more processing time than " +
|
||||||
|
"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 " +
|
||||||
|
"a sentence that should embarrass everyone involved. Respawn in 24 hours. " +
|
||||||
|
"Use the time to consider whether 'adventurer' was the right career path " +
|
||||||
|
"or whether it is simply the path you are on.",
|
||||||
|
|
||||||
|
"The ceiling dripped on you. You looked up. The rat hit you while you " +
|
||||||
|
"were looking up. This sequence of events is, in its own way, " +
|
||||||
|
"a complete story about who you are as an adventurer. " +
|
||||||
|
"The healthcare system has received your chart. They are not surprised.",
|
||||||
|
|
||||||
|
"You died to a Wet Slime and a Giant Rat working in what can only be " +
|
||||||
|
"described as accidental coordination. They were not coordinating. " +
|
||||||
|
"They just both happened to be there. This is worse, somehow. " +
|
||||||
|
"A coordinated attack you could respect. This was just Tuesday for them.",
|
||||||
|
|
||||||
|
"The dungeon is four feet underground. It is damp. It contains rats " +
|
||||||
|
"and slimes and one badger with emotional problems. You did not survive it. " +
|
||||||
|
"This information is being processed by multiple parties including " +
|
||||||
|
"yourself, your healthcare provider, and the badger, who feels nothing.",
|
||||||
|
|
||||||
|
"You have died in the Soggy Cellar for what the records indicate is " +
|
||||||
|
"not the first time, which the records find notable. The records " +
|
||||||
|
"have been updated. The update reads: 'again.' The healthcare system " +
|
||||||
|
"is beginning to recognise your face. This is not the achievement it sounds like.",
|
||||||
|
|
||||||
|
"Something bit you. You're not entirely sure what. The darkness was " +
|
||||||
|
"involved, and the dampness, and a sound that in retrospect was " +
|
||||||
|
"a warning and at the time seemed like ambience. You are now ambience " +
|
||||||
|
"for the medical recovery ward. Try to be quieter than the Soggy Cellar.",
|
||||||
|
|
||||||
|
"The Wet Slime engulfed your boots. The Knobby-Ass Boots, to be specific, " +
|
||||||
|
"which offered less traction than anticipated on a wet floor, " +
|
||||||
|
"which is to say none. You went down fast. The slime was apologetic " +
|
||||||
|
"about it in the way that slimes are, which is not at all.",
|
||||||
|
|
||||||
|
"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 " +
|
||||||
|
"assumption that ends careers. Your career has not ended. It has paused, " +
|
||||||
|
"for 24 hours, in a medical facility, reconsidering its assumptions.",
|
||||||
|
|
||||||
|
"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 are the punchline. You are currently in medical care being the punchline.",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── TIER 2: THE GOBLIN WARRENS ───────────────────────────────────────────
|
||||||
|
// Dying here is more understandable but no less embarrassing. Goblins are
|
||||||
|
// real monsters. You still lost to them. They're goblins.
|
||||||
|
2: {
|
||||||
|
"The goblin did not look threatening. This was a trap. The goblin was, " +
|
||||||
|
"in fact, threatening, and had been for some time, and had been waiting " +
|
||||||
|
"for someone to underestimate him specifically. You were that someone. " +
|
||||||
|
"He has returned to his warrens. You have been sent to medical care.",
|
||||||
|
|
||||||
|
"Three goblins. You handled two. The third one was waiting. " +
|
||||||
|
"Goblins, it turns out, have a concept of patience that you " +
|
||||||
|
"failed to account for. The patient goblin is now slightly richer. " +
|
||||||
|
"You are now slightly more medicated. The exchange rate favours the goblin.",
|
||||||
|
|
||||||
|
"The Kobold was not listed as a threat. The Kobold disagreed with this " +
|
||||||
|
"assessment at considerable length and velocity. Your Shitty Armor " +
|
||||||
|
"absorbed the first disagreement and then stopped absorbing. " +
|
||||||
|
"The Kobold made its point. You have conceded the point. Medically.",
|
||||||
|
|
||||||
|
"A Trap Spider. In a warren. Obviously there was a Trap Spider. " +
|
||||||
|
"The name of the dungeon is the Goblin Warrens and goblins are " +
|
||||||
|
"famous for their traps and you walked into a web the size of a doorway " +
|
||||||
|
"because you were looking at the goblins. The spider was not the goblins.",
|
||||||
|
|
||||||
|
"You cleared the first room. You cleared the second room. The third room " +
|
||||||
|
"cleared you, which is a thing that happens and a sentence that " +
|
||||||
|
"will be on your medical chart indefinitely. Room three: dangerous. " +
|
||||||
|
"This information is available to you now, retroactively, useless.",
|
||||||
|
|
||||||
|
"The Goblin Warchief was not supposed to be there. That's the official " +
|
||||||
|
"position. The unofficial position is that the Goblin Warchief " +
|
||||||
|
"is absolutely supposed to be there, has always been there, " +
|
||||||
|
"and you should have checked. You did not check. The Warchief checked for you.",
|
||||||
|
|
||||||
|
"Your sword arm got tired. This is an embarrassing thing to admit " +
|
||||||
|
"but it is the honest account of what happened in the Goblin Warrens " +
|
||||||
|
"between the second and third encounter. Arm tired. Goblin fast. " +
|
||||||
|
"Healthcare called. The sequence is complete.",
|
||||||
|
|
||||||
|
"The goblins coordinated. Actually coordinated — flanked you, " +
|
||||||
|
"drew your attention left while the attack came right, " +
|
||||||
|
"used the terrain. You did not know goblins did that. " +
|
||||||
|
"You know now. The knowledge cost 24 hours and some dignity.",
|
||||||
|
|
||||||
|
"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 " +
|
||||||
|
"is fair. That's the entire deal. You knew the deal. " +
|
||||||
|
"The bolt knew the deal better than you did.",
|
||||||
|
|
||||||
|
"You slipped on something goblin-related. You do not want to know " +
|
||||||
|
"what it was. The floor of the Goblin Warrens is not clean. " +
|
||||||
|
"The fall was not dignified. The subsequent encounter with " +
|
||||||
|
"the subsequent goblin was not survivable. Here we are.",
|
||||||
|
|
||||||
|
"The warrens go deeper than the map suggested. You went deeper than " +
|
||||||
|
"your level suggested. These two facts met in a corridor and " +
|
||||||
|
"produced a medical emergency that the healthcare system " +
|
||||||
|
"is processing with the enthusiasm of an organisation that " +
|
||||||
|
"has processed this before.",
|
||||||
|
|
||||||
|
"Trap. You found the trap. Specifically, you found it with your foot, " +
|
||||||
|
"and then your face, and then your general body in a way that " +
|
||||||
|
"involved the floor. The goblins who set the trap have noted " +
|
||||||
|
"its success in whatever the goblin equivalent of a ledger is. " +
|
||||||
|
"You are a successful data point for their trap program.",
|
||||||
|
|
||||||
|
"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 " +
|
||||||
|
"and misplaced confidence. The misplaced confidence did not survive " +
|
||||||
|
"the encounter. Neither did you, technically, for 24 hours.",
|
||||||
|
|
||||||
|
"You got lost. In the warrens. Which are, by definition, a warren — " +
|
||||||
|
"a maze of tunnels built by creatures who live there and know " +
|
||||||
|
"every turn. You got lost and then you got found, by goblins, " +
|
||||||
|
"which is the worst way to be found in this context.",
|
||||||
|
|
||||||
|
"Three arrows. You saw the first one coming. The second one you heard. " +
|
||||||
|
"The third one you did not experience as a separate event " +
|
||||||
|
"from the second one, which tells you everything about the timing. " +
|
||||||
|
"The kobold archers have been congratulated by their colleagues.",
|
||||||
|
|
||||||
|
"The Trap Spider's web was across a doorway you had already walked " +
|
||||||
|
"through once, going in. It wasn't there going in. The spider " +
|
||||||
|
"built it while you were dealing with the goblins. " +
|
||||||
|
"The spider was waiting. Spiders are good at waiting. " +
|
||||||
|
"You were not good at checking doorways twice.",
|
||||||
|
|
||||||
|
"You ran out of dungeon before you ran out of goblins, " +
|
||||||
|
"which sounds like it should be good news but meant " +
|
||||||
|
"you were cornered in the last room with more goblins " +
|
||||||
|
"than exits. The math did not work in your favour. " +
|
||||||
|
"The math rarely does in the Goblin Warrens.",
|
||||||
|
|
||||||
|
"The Goblin Warchief had a horn. You learned about the horn " +
|
||||||
|
"thirty seconds before you learned about the twelve goblins " +
|
||||||
|
"the horn summons. The horn was loud. The goblins were fast. " +
|
||||||
|
"The medical transport was eventually located. A difficult day.",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── TIER 3: THE CURSED CRYPT ─────────────────────────────────────────────
|
||||||
|
// Dying here is understandable. These are proper monsters. The shame
|
||||||
|
// is less about competence and more about hubris.
|
||||||
|
3: {
|
||||||
|
"The Draugr was asleep when you entered. You were quiet. Not quiet enough. " +
|
||||||
|
"The Draugr's definition of 'quiet' is stricter than yours " +
|
||||||
|
"and its response to noise is well-documented and physical. " +
|
||||||
|
"You are in medical care. The Draugr is asleep again.",
|
||||||
|
|
||||||
|
"A ghost. You cannot hit a ghost. You tried to hit the ghost. " +
|
||||||
|
"The ghost watched you try to hit it with what it correctly " +
|
||||||
|
"identified as a sword, and then it did what ghosts do, " +
|
||||||
|
"which is not care about swords and continue being a ghost.",
|
||||||
|
|
||||||
|
"The Cursed Crypt is cursed. This is in the name. The curse " +
|
||||||
|
"manifested as a compulsion to walk into the room " +
|
||||||
|
"with the most skeletons and swing at the first one. " +
|
||||||
|
"You did this. The curse was efficient. The skeletons were numerous.",
|
||||||
|
|
||||||
|
"Three skeletons. Fine. Five skeletons. Manageable. " +
|
||||||
|
"The eighth skeleton that came through the door you " +
|
||||||
|
"thought was an exit was neither fine nor manageable. " +
|
||||||
|
"The eighth skeleton was the end of the encounter from your perspective.",
|
||||||
|
|
||||||
|
"The Draugr hit you once. Once was sufficient. " +
|
||||||
|
"Your armor held the first blow and then had a brief existential crisis " +
|
||||||
|
"about its structural integrity and resolved that crisis " +
|
||||||
|
"by failing. The Draugr needed only the one.",
|
||||||
|
|
||||||
|
"The ghost phased through your sword, through your armor, " +
|
||||||
|
"through your carefully maintained sense of tactical awareness, " +
|
||||||
|
"and through the part of your nervous system responsible " +
|
||||||
|
"for staying upright. Ghosts do not fight fair. " +
|
||||||
|
"They are not required to. They are ghosts.",
|
||||||
|
|
||||||
|
"You found the crypt boss room. This was not the plan. " +
|
||||||
|
"The plan was to find treasure and leave before finding " +
|
||||||
|
"the crypt boss room. The crypt boss room found you instead, " +
|
||||||
|
"which is a subtle but important distinction " +
|
||||||
|
"that is not available to you right now.",
|
||||||
|
|
||||||
|
"The curse got you on the way out. Not in — you navigated in fine. " +
|
||||||
|
"The curse was waiting on the way out, which is deeply unfair " +
|
||||||
|
"and also completely consistent with being called a curse. " +
|
||||||
|
"You are in medical care. The crypt is still cursed.",
|
||||||
|
|
||||||
|
"A Draugr, two skeletons, and a ghost in a room that was supposed " +
|
||||||
|
"to be empty according to your extremely unreliable " +
|
||||||
|
"pre-run intelligence. The intelligence was wrong. " +
|
||||||
|
"The room was not empty. The distinction matters to your healthcare provider.",
|
||||||
|
|
||||||
|
"The skeleton archers were a surprise. The crypt looked like " +
|
||||||
|
"a melee crypt — close quarters, low ceilings, short sight lines. " +
|
||||||
|
"The skeleton archers had compensated for this " +
|
||||||
|
"by being very accurate at short range. Live and learn. " +
|
||||||
|
"In 24 hours. When you are alive again.",
|
||||||
|
|
||||||
|
"Something in the Cursed Crypt cursed you specifically, " +
|
||||||
|
"which is both an achievement and a disaster. " +
|
||||||
|
"You were cursed enough to be notable. The curse assessed you, " +
|
||||||
|
"selected you, and applied itself with intention. " +
|
||||||
|
"You are special. The specialness requires medical attention.",
|
||||||
|
|
||||||
|
"The Draugr spoke. You did not know they spoke. Nobody warned you. " +
|
||||||
|
"You stopped, briefly, because something in you wanted to hear " +
|
||||||
|
"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.",
|
||||||
|
|
||||||
|
"Dark room. Something in it. You had a torch. The torch went out. " +
|
||||||
|
"The something in the room did not require light to function. " +
|
||||||
|
"You required light to function. The something was aware of this asymmetry " +
|
||||||
|
"and applied it professionally.",
|
||||||
|
|
||||||
|
"You got cocky. There is no other explanation. You were doing well — " +
|
||||||
|
"three rooms cleared, good haul developing, exit in sight — " +
|
||||||
|
"and you got cocky and opened a door that had a very clear " +
|
||||||
|
"'do not open this door' energy and opened it anyway. " +
|
||||||
|
"The door was correct about itself.",
|
||||||
|
|
||||||
|
"The skeleton fell apart when you hit it. You felt good about this. " +
|
||||||
|
"The other eleven skeletons in the room did not fall apart. " +
|
||||||
|
"You had a moment of feeling good followed by a longer moment " +
|
||||||
|
"of not feeling anything, which is the medical situation.",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── TIER 4: TROLL BRIDGE DEPTHS ─────────────────────────────────────────
|
||||||
|
// Dying here is a serious matter. These are high-level monsters.
|
||||||
|
// The tone shifts from embarrassment to something closer to respect
|
||||||
|
// for the thing that killed you.
|
||||||
|
4: {
|
||||||
|
"The Troll did not notice you at first. When it noticed you, " +
|
||||||
|
"you were briefly grateful for the time you'd had when it hadn't. " +
|
||||||
|
"Then it acted on the noticing. You are in medical care. " +
|
||||||
|
"The Troll has returned to its business, which does not involve you.",
|
||||||
|
|
||||||
|
"A Stone Giant. You knew there were Stone Giants in the Troll Bridge Depths. " +
|
||||||
|
"Knowing and experiencing are different scales of the same information " +
|
||||||
|
"and the experiential scale is considerably larger than you anticipated. " +
|
||||||
|
"The Stone Giant was the appropriate size for a Stone Giant. " +
|
||||||
|
"You were not the appropriate size for this encounter.",
|
||||||
|
|
||||||
|
"The Cursed Knight had been down there a long time. " +
|
||||||
|
"You could tell because of the armor — old, wrong era, " +
|
||||||
|
"still functional in ways that defy the passage of centuries. " +
|
||||||
|
"The Knight fought like someone who had been practicing " +
|
||||||
|
"for exactly this encounter, in this corridor, for decades. " +
|
||||||
|
"They had been. You had not.",
|
||||||
|
|
||||||
|
"Two Trolls. You planned for one. The plan was good for one. " +
|
||||||
|
"The second Troll arrived from a tunnel you had assessed " +
|
||||||
|
"as non-threatening, which is the last assessment you made " +
|
||||||
|
"before the assessment became irrelevant.",
|
||||||
|
|
||||||
|
"The Stone Giant threw a rock. From across the chamber. " +
|
||||||
|
"At you specifically, with what appeared to be accurate intent. " +
|
||||||
|
"You did not know Stone Giants had that kind of range or precision. " +
|
||||||
|
"You are in possession of this knowledge now. " +
|
||||||
|
"The knowledge is available to you upon recovery.",
|
||||||
|
|
||||||
|
"The Cursed Knight's sword went through your Enhanced Plate " +
|
||||||
|
"in a way that Enchanted Plate is not supposed to permit. " +
|
||||||
|
"The Knight is old enough to have fought enchanted armor before. " +
|
||||||
|
"The Knight had notes. The Knight applied the notes.",
|
||||||
|
|
||||||
|
"The depths went deeper. You followed them. " +
|
||||||
|
"The things that live in the deepest parts of the Troll Bridge Depths " +
|
||||||
|
"do not come up, generally, because the things above them are also " +
|
||||||
|
"dangerous and the arrangement suits everyone. " +
|
||||||
|
"You disrupted the arrangement. The arrangement corrected itself.",
|
||||||
|
|
||||||
|
"A Troll, a Stone Giant, and a Cursed Knight in the same corridor " +
|
||||||
|
"at the same time is not a coincidence. It is a configuration " +
|
||||||
|
"that the Troll Bridge Depths produces when someone has been " +
|
||||||
|
"pushing their luck across multiple rooms and the dungeon " +
|
||||||
|
"has decided to consolidate the response.",
|
||||||
|
|
||||||
|
"The bridge itself was the trap. You were on the bridge. " +
|
||||||
|
"The Troll was under the bridge, which is where Trolls are, " +
|
||||||
|
"which is information that was available to you before you " +
|
||||||
|
"stepped onto the bridge, and which you had filed " +
|
||||||
|
"under 'probably not relevant right now.'",
|
||||||
|
|
||||||
|
"Your equipment held. Your stamina didn't. " +
|
||||||
|
"The Troll Bridge Depths is a long dungeon and the things inside it " +
|
||||||
|
"are not tiring in the way you are tiring and the gap " +
|
||||||
|
"between your stamina curve and the dungeon's stamina curve " +
|
||||||
|
"met in the fourth room and the dungeon's curve continued upward.",
|
||||||
|
|
||||||
|
"The Cursed Knight spoke your name. Your full name. " +
|
||||||
|
"You have never told anyone in this dungeon your full name. " +
|
||||||
|
"You had a moment to think about this before the encounter concluded. " +
|
||||||
|
"The moment was brief. The thought is available for review upon recovery.",
|
||||||
|
|
||||||
|
"It was the small things. Not the Troll. Not the Stone Giant. " +
|
||||||
|
"The small cursed things that live in the walls and margins " +
|
||||||
|
"of the Troll Bridge Depths, the ones nobody lists in the dungeon " +
|
||||||
|
"briefing because they're individually minor and collectively " +
|
||||||
|
"the reason you are in medical care.",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── TIER 5: THE ABYSSAL MAW ──────────────────────────────────────────────
|
||||||
|
// Dying here is not embarrassing. It is almost an achievement.
|
||||||
|
// The tone is terse, respectful, slightly ominous.
|
||||||
|
// The thing that killed you deserves one sentence.
|
||||||
|
5: {
|
||||||
|
"The demon didn't fight you. It waited until you were tired " +
|
||||||
|
"and then it stopped waiting. There's a lesson in that. " +
|
||||||
|
"The lesson is available in 24 hours.",
|
||||||
|
|
||||||
|
"An Elder Drake. You know what that means. " +
|
||||||
|
"You knew before you went in. You went in anyway. " +
|
||||||
|
"This is either courage or a poor risk assessment " +
|
||||||
|
"and the Abyssal Maw does not distinguish between the two.",
|
||||||
|
|
||||||
|
"The Unnamed was there. You found it or it found you — " +
|
||||||
|
"the distinction collapses at a certain depth. " +
|
||||||
|
"You are alive. That's more than it usually leaves.",
|
||||||
|
|
||||||
|
"Something in the Abyssal Maw does not have a name " +
|
||||||
|
"because the people who named things didn't come back. " +
|
||||||
|
"You came back. Consider this the win it technically is.",
|
||||||
|
|
||||||
|
"The Elder Drake breathed once. Your Dragonscale armor " +
|
||||||
|
"was, in its way, a message to the Drake about its predecessor. " +
|
||||||
|
"The Drake received the message and responded at length.",
|
||||||
|
|
||||||
|
"The demon was old. Older than the dungeon. Older than the concept " +
|
||||||
|
"of dungeons. It has been down there since before people " +
|
||||||
|
"started going into deep places looking for treasure. " +
|
||||||
|
"It is still there. You are here. That's the update.",
|
||||||
|
|
||||||
|
"YOU DIED in the Abyssal Maw. In large, honest letters. " +
|
||||||
|
"The large, honest letters are impressed, which is " +
|
||||||
|
"more than the large honest letters usually feel. " +
|
||||||
|
"Recovery in 24 hours. The Maw will be there.",
|
||||||
|
|
||||||
|
"The floor in the deepest chamber of the Abyssal Maw is not a floor. " +
|
||||||
|
"You know this now. What it is remains unclear " +
|
||||||
|
"because you did not have sufficient time to form a complete theory " +
|
||||||
|
"before the theory became irrelevant.",
|
||||||
|
|
||||||
|
"Three demons, one Elder Drake, and something that doesn't have " +
|
||||||
|
"a category yet. You lasted longer than most. " +
|
||||||
|
"The dungeon log reflects this. The dungeon log is not gentle " +
|
||||||
|
"but it is accurate.",
|
||||||
|
|
||||||
|
"The Abyssal Maw goes deeper than the maps indicate. " +
|
||||||
|
"You found the level below the last mapped level. " +
|
||||||
|
"Nobody has mapped it because nobody came back to do the mapping. " +
|
||||||
|
"You are back. The map remains incomplete. " +
|
||||||
|
"This is a conversation for after recovery.",
|
||||||
|
|
||||||
|
"The Unnamed said something as you lost consciousness. " +
|
||||||
|
"You don't remember what. You remember the shape of it. " +
|
||||||
|
"This is probably fine. Probably.",
|
||||||
|
|
||||||
|
"An Elder Drake, cornered, is different from an Elder Drake with room. " +
|
||||||
|
"You cornered one. This seemed advantageous at the time. " +
|
||||||
|
"The Drake had a different read on the geometry.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var DungeonEmpty = map[int][]string{
|
||||||
|
|
||||||
|
1: {
|
||||||
|
"You explored the entire Soggy Cellar and found: one dead rat (not your kill), " +
|
||||||
|
"a suspicious smell, and the growing suspicion that adventure is complete bullshit. " +
|
||||||
|
"You return home with nothing but the experience. " +
|
||||||
|
"The experience was bad and you would like a refund.",
|
||||||
|
|
||||||
|
"Three hours. Three hours in there. The rats were apparently " +
|
||||||
|
"on some kind of rodent holiday. You found one button behind a loose stone. " +
|
||||||
|
"It was a button, not a coin. An ordinary button. " +
|
||||||
|
"You are home now. The button is in your pocket. You don't know why.",
|
||||||
|
|
||||||
|
"Nothing. The Soggy Cellar had nothing in it today. " +
|
||||||
|
"Not even a pot. There were pots, but smashing them yielded nothing, " +
|
||||||
|
"which has never once been true in any dungeon worth the name. " +
|
||||||
|
"You smashed all the pots anyway. Out of principle. Out of grief.",
|
||||||
|
|
||||||
|
"A chest. There was an actual chest. You opened it. " +
|
||||||
|
"It played a little tune — a proper, sincere, full-length discovery fanfare " +
|
||||||
|
"for a chest that contained absolutely nothing. " +
|
||||||
|
"You stood there and listened to the whole tune. " +
|
||||||
|
"You don't know why. Respect for the process, maybe.",
|
||||||
|
|
||||||
|
"The monsters had already been cleared. By whom is unclear. " +
|
||||||
|
"Someone faster, better equipped, more prepared, and apparently " +
|
||||||
|
"more punctual than you. They left footprints. " +
|
||||||
|
"The footprints were mocking you. You felt it.",
|
||||||
|
|
||||||
|
"The Soggy Cellar was, today, simply a cellar. Soggy, yes. " +
|
||||||
|
"Damp, absolutely. Full of rats and opportunity? No. " +
|
||||||
|
"Just a cellar underground with nothing in it " +
|
||||||
|
"and you in it looking for things that weren't there.",
|
||||||
|
|
||||||
|
"You searched for two hours and found: some damp, a rat hole " +
|
||||||
|
"that went nowhere useful, a piece of wood that might have been " +
|
||||||
|
"a weapon at some point in a different life, and the strong conviction " +
|
||||||
|
"that someone else already had a very good day in here recently.",
|
||||||
|
|
||||||
|
"The rats ran from you today. This is new. You don't know what it means. " +
|
||||||
|
"It means there's nothing left to protect, as it turns out. " +
|
||||||
|
"The rats had already eaten everything interesting. " +
|
||||||
|
"You found the aftermath. The aftermath paid nothing.",
|
||||||
|
|
||||||
|
"Empty. Completely, aggressively, personally empty. " +
|
||||||
|
"The Soggy Cellar had one job today and declined it. " +
|
||||||
|
"You have returned home with XP that barely qualifies as XP " +
|
||||||
|
"and a new, deeper understanding of disappointment as a lifestyle.",
|
||||||
|
|
||||||
|
"You found a locked door. You found a way to open the locked door. " +
|
||||||
|
"Behind the locked door was another locked door. " +
|
||||||
|
"Behind the second locked door was a small room with nothing in it " +
|
||||||
|
"except the specific silence of a room that used to have something in it " +
|
||||||
|
"and doesn't anymore.",
|
||||||
|
|
||||||
|
"The dungeon was empty in the way that says someone was here recently. " +
|
||||||
|
"Fresh tracks. Still-warm torch brackets. One rat looking nervous " +
|
||||||
|
"in a way that suggests it witnessed something. " +
|
||||||
|
"You got there second. Being second in dungeons is being last.",
|
||||||
|
|
||||||
|
"Every door opened. Every chest empty. Every crevice checked. " +
|
||||||
|
"Every pot smashed, every loose stone lifted, every shadow assessed. " +
|
||||||
|
"Nothing. The Soggy Cellar has been professionally cleaned " +
|
||||||
|
"by someone who was not you and did not leave a finder's fee.",
|
||||||
|
},
|
||||||
|
|
||||||
|
2: {
|
||||||
|
"The Goblin Warrens were staffed today but not stocked. " +
|
||||||
|
"Goblins everywhere, treasure nowhere. You fought your way " +
|
||||||
|
"through three rooms of nothing valuable and came home " +
|
||||||
|
"with XP and a strong sense of having been personally targeted " +
|
||||||
|
"by the random number generator.",
|
||||||
|
|
||||||
|
"The goblins had nothing on them. You checked. Thoroughly. " +
|
||||||
|
"Goblins are supposed to hoard things — it's the whole deal with goblins. " +
|
||||||
|
"These goblins had apparently subscribed to a minimalist philosophy " +
|
||||||
|
"and gotten rid of everything. You got rid of the goblins. " +
|
||||||
|
"The net result was nothing.",
|
||||||
|
|
||||||
|
"A chest. A locked chest. A locked chest in a trapped room " +
|
||||||
|
"that took you twenty minutes to navigate safely. " +
|
||||||
|
"The chest contained a goblin IOU for 'eight copper, payable eventually.' " +
|
||||||
|
"The goblin is not available to honour the IOU.",
|
||||||
|
|
||||||
|
"The warren went deeper than the map suggested. " +
|
||||||
|
"You followed it to the bottom, fighting the whole way. " +
|
||||||
|
"The bottom contained a goblin who looked as surprised to see you " +
|
||||||
|
"as you were to find nothing else there. " +
|
||||||
|
"You both agreed, silently, that this wasn't how today was supposed to go.",
|
||||||
|
|
||||||
|
"Room after room after room of goblins and kobolds " +
|
||||||
|
"and trap spiders and none of them carrying anything worth the walk. " +
|
||||||
|
"The Goblin Warrens had a sale recently. You were not informed. " +
|
||||||
|
"Everything has already been liquidated. You are the liquidator of nothing.",
|
||||||
|
|
||||||
|
"You found the goblin treasury. You know this because it said " +
|
||||||
|
"'TREASURY' above the door in goblin script, which you cannot read, " +
|
||||||
|
"but which a helpful kobold graffitied the translation of nearby. " +
|
||||||
|
"The treasury was empty. Someone got here first. " +
|
||||||
|
"The translation is the only treasure you're taking home.",
|
||||||
|
},
|
||||||
|
|
||||||
|
3: {
|
||||||
|
"The Cursed Crypt was cursed today toward emptiness. " +
|
||||||
|
"You fought the undead — all the undead, it felt like, " +
|
||||||
|
"every skeleton in the crypt standing up to discuss your presence — " +
|
||||||
|
"and none of them had anything worth taking. " +
|
||||||
|
"Skeletons, it turns out, travel light.",
|
||||||
|
|
||||||
|
"Three rooms cleared, zero treasure found. The fourth room had a sarcophagus. " +
|
||||||
|
"You opened the sarcophagus. The sarcophagus was empty. " +
|
||||||
|
"In retrospect the Draugr had already left for the day. " +
|
||||||
|
"You opened an empty box dramatically and then went home.",
|
||||||
|
|
||||||
|
"The ghosts had nothing. You cannot take things from ghosts regardless, " +
|
||||||
|
"but usually there are things nearby that they're haunting " +
|
||||||
|
"that you can take. Today the ghosts were haunting a bare room " +
|
||||||
|
"with stone walls and the specific sadness of things that " +
|
||||||
|
"have been haunting nothing for a very long time.",
|
||||||
|
|
||||||
|
"The crypt was picked clean. Ancient clean — not recently cleaned, " +
|
||||||
|
"but cleaned at some point in history by someone thorough " +
|
||||||
|
"who left nothing and sealed it and the Draugr and skeletons " +
|
||||||
|
"and ghosts have been guarding an empty crypt ever since " +
|
||||||
|
"out of what can only be described as institutional inertia.",
|
||||||
|
},
|
||||||
|
|
||||||
|
4: {
|
||||||
|
"The Troll Bridge Depths ran empty today in the way that deep things " +
|
||||||
|
"sometimes do — everything present, nothing accessible. " +
|
||||||
|
"You fought your way through Trolls and Cursed Knights " +
|
||||||
|
"and Stone Giants and came out the other side with bruises " +
|
||||||
|
"and XP and exactly nothing in your pockets.",
|
||||||
|
|
||||||
|
"A treasury room. Well-defended. Three Trolls, two Cursed Knights, " +
|
||||||
|
"a Stone Giant as a bonus. You cleared it. " +
|
||||||
|
"The treasury had been emptied before you arrived. " +
|
||||||
|
"Possibly before any of the monsters arrived. " +
|
||||||
|
"They were guarding an empty room for reasons lost to history.",
|
||||||
|
|
||||||
|
"The dungeon had a good day before you arrived. " +
|
||||||
|
"The dungeon has bad days too, but today was not the dungeon's bad day. " +
|
||||||
|
"Today was your bad day. You were in the dungeon on the dungeon's good day " +
|
||||||
|
"and everything was taken and everything that tried to take you succeeded " +
|
||||||
|
"and you are home now with the participation award, which is XP.",
|
||||||
|
},
|
||||||
|
|
||||||
|
5: {
|
||||||
|
"The Abyssal Maw gave you nothing today except survival, " +
|
||||||
|
"which at this tier is actually something. " +
|
||||||
|
"You went in. You came out. The space between those two facts " +
|
||||||
|
"contains several encounters you will be processing for some time. " +
|
||||||
|
"Nothing of material value. Everything of experiential value. " +
|
||||||
|
"The distinction matters less than it used to.",
|
||||||
|
|
||||||
|
"The demons were between you and everything worth taking. " +
|
||||||
|
"You dealt with the demons. Behind them: more demons. " +
|
||||||
|
"Behind them: the Elder Drake. Behind the Elder Drake: nothing. " +
|
||||||
|
"The Abyssal Maw is well-defended for a room that turns out " +
|
||||||
|
"to have had nothing in it today.",
|
||||||
|
|
||||||
|
"The Unnamed was in the way of the treasure room. " +
|
||||||
|
"You did not attempt to move the Unnamed. " +
|
||||||
|
"This was the correct decision. You went home with XP " +
|
||||||
|
"and the knowledge that some doors are better left as doors.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var DungeonSuccess = map[int][]string{
|
||||||
|
|
||||||
|
1: {
|
||||||
|
"Success! Relative to your recent attempts, which mostly involved " +
|
||||||
|
"screaming and losing consciousness. You found {item} worth €{value}. " +
|
||||||
|
"The rats respected you marginally more on the way out. " +
|
||||||
|
"You chose not to investigate why. Some respects are best left unexplored.",
|
||||||
|
|
||||||
|
"You cleared the Soggy Cellar of one rat and found {item} worth €{value}. " +
|
||||||
|
"The other rats were watching from the shadows and agreed not to intervene, " +
|
||||||
|
"apparently out of pity. Pity! You have been pitied by rats. " +
|
||||||
|
"And yet you have {item}. The rats have a hole. You're ahead.",
|
||||||
|
|
||||||
|
"Not bad. Not good either, let's be honest, but not bad. " +
|
||||||
|
"You found {item} worth €{value}, took a hit that'll bruise nicely by morning, " +
|
||||||
|
"and made it out without dying. By your current standards, " +
|
||||||
|
"this is a triumph. Sad, but measurably true.",
|
||||||
|
|
||||||
|
"You killed a thing. The thing had {item} on it. " +
|
||||||
|
"You have €{value} more than you started with and {xp} XP " +
|
||||||
|
"and a new familiarity with the smell of the Soggy Cellar " +
|
||||||
|
"that no amount of recovery time will fully address. " +
|
||||||
|
"This is the whole job. This is what adventure is.",
|
||||||
|
|
||||||
|
"Level up potential! Well — XP potential. " +
|
||||||
|
"You found {item} worth €{value} and gained {xp} XP " +
|
||||||
|
"and somewhere a small chime played that only you could hear. " +
|
||||||
|
"Progress. Measurable, modest, hard-won progress in a damp cellar.",
|
||||||
|
|
||||||
|
"In and out. {item} worth €{value}. One rat with an attitude problem " +
|
||||||
|
"that has since been resolved. Your Basic Ass Sword is no worse " +
|
||||||
|
"than when you started, which is its version of a good day. " +
|
||||||
|
"Everything survived. You are counting this as a win.",
|
||||||
|
|
||||||
|
"The Angry Badger had {item}. You weren't expecting that. " +
|
||||||
|
"You weren't expecting the badger at all, honestly, " +
|
||||||
|
"but the badger had {item} and now you have {item} " +
|
||||||
|
"and the badger has consequences. €{value}. {xp} XP. " +
|
||||||
|
"An unexpected Tuesday.",
|
||||||
|
|
||||||
|
"Two rats, one Wet Slime, {item} worth €{value}, and you walked out " +
|
||||||
|
"under your own power. The Shitty Armor is slightly worse. " +
|
||||||
|
"Everything else is the same or better. " +
|
||||||
|
"You have chosen to describe this as 'clean execution' " +
|
||||||
|
"and nobody in the Soggy Cellar is alive to contradict you.",
|
||||||
|
|
||||||
|
"A wild {item} appeared. You used ACQUIRE. It was effective. " +
|
||||||
|
"€{value} secured, {xp} XP noted, zero hospitalizations. " +
|
||||||
|
"A complete run. By Soggy Cellar standards, legendary.",
|
||||||
|
|
||||||
|
"You found {item} behind a loose stone that three rats were " +
|
||||||
|
"apparently using as a savings account. The rats objected. " +
|
||||||
|
"Their objections were noted and overruled. €{value}. " +
|
||||||
|
"This is the economy working as intended.",
|
||||||
|
|
||||||
|
"The cellar was manageable today. That's the whole report. " +
|
||||||
|
"Manageable. {item}, €{value}, {xp} XP, no deaths, " +
|
||||||
|
"one close call that was closer than you're admitting " +
|
||||||
|
"but resulted in nothing clinical. Manageable.",
|
||||||
|
|
||||||
|
"You fought your way to the back of the Soggy Cellar " +
|
||||||
|
"and found {item} worth €{value} in a corner that smelled like " +
|
||||||
|
"rats had been storing things there for years. " +
|
||||||
|
"They had. You have the things now. The rats are reconsidering their storage strategy.",
|
||||||
|
|
||||||
|
"Solid. Not exciting, not legendary, just solid. " +
|
||||||
|
"{item} worth €{value}. {xp} XP. All equipment functional. " +
|
||||||
|
"You have returned home upright and slightly less broke than you left. " +
|
||||||
|
"This is the dream. This specifically, unimpressively, is the dream.",
|
||||||
|
},
|
||||||
|
|
||||||
|
2: {
|
||||||
|
"The Goblin Warrens cleared two rooms and {item} worth €{value} " +
|
||||||
|
"and you are not dead, which this dungeon does not give away freely. " +
|
||||||
|
"The goblins are impressed, in the way that goblins are impressed, " +
|
||||||
|
"which is the same way they are everything: angrily.",
|
||||||
|
|
||||||
|
"You took {item} from the goblins. The goblins had {item}. " +
|
||||||
|
"They should not have had it — it's yours now — " +
|
||||||
|
"but the how of them having it is a story " +
|
||||||
|
"that ends with someone else having a worse day than yours. " +
|
||||||
|
"€{value}. You're the winner today.",
|
||||||
|
|
||||||
|
"The Goblin Warchief dropped {item} worth €{value} and you took it " +
|
||||||
|
"before anyone else could form an opinion about that. " +
|
||||||
|
"{xp} XP. The Warchief's ring is still on your thumb. " +
|
||||||
|
"The other goblins are making decisions about you. " +
|
||||||
|
"The decisions are not positive but they are respectful.",
|
||||||
|
|
||||||
|
"Trap avoided. Kobold handled. Two goblins negotiated with " +
|
||||||
|
"(negotiations were physical). {item} acquired, €{value} secured, " +
|
||||||
|
"exit located and used. This is a textbook run " +
|
||||||
|
"by the standards of a textbook nobody has written yet " +
|
||||||
|
"because everyone who tried died in the Goblin Warrens.",
|
||||||
|
|
||||||
|
"Three rooms. {item} in room two. €{value} total. " +
|
||||||
|
"The Trap Spider was in room three and you went to room three " +
|
||||||
|
"anyway because you had already committed emotionally " +
|
||||||
|
"and the spider was manageable. You were right. " +
|
||||||
|
"Being right in the Goblin Warrens is not guaranteed.",
|
||||||
|
|
||||||
|
"You collected {item} worth €{value} from the body of a goblin " +
|
||||||
|
"who was, frankly, carrying more than expected. " +
|
||||||
|
"This is true of most goblins. They are sentimental creatures " +
|
||||||
|
"who attach value to things that aren't valuable " +
|
||||||
|
"and occasionally to things that are. Today: the latter.",
|
||||||
|
|
||||||
|
"The warrens gave ground today. Not easily — {xp} XP worth of not easily — " +
|
||||||
|
"but it gave ground and you took {item} and €{value} " +
|
||||||
|
"and made it out with your equipment in the same condition it started. " +
|
||||||
|
"The goblins are annoyed. The goblins are always annoyed. " +
|
||||||
|
"Today they are additionally annoyed.",
|
||||||
|
},
|
||||||
|
|
||||||
|
3: {
|
||||||
|
"The Cursed Crypt had {item} worth €{value} and you have it now. " +
|
||||||
|
"You fought a Draugr and two skeletons and a ghost that " +
|
||||||
|
"you mostly avoided by walking quickly through rooms " +
|
||||||
|
"and pretending you couldn't hear it. " +
|
||||||
|
"Sound tactical approach. {xp} XP. No deaths.",
|
||||||
|
|
||||||
|
"Three skeletons, one Draugr, {item} worth €{value}. " +
|
||||||
|
"The curse was present but mild today — present in the way " +
|
||||||
|
"that all the torches went out simultaneously and something breathed " +
|
||||||
|
"in your ear when nothing was near you, but not present in the way " +
|
||||||
|
"that kills you. A good day by crypt standards.",
|
||||||
|
|
||||||
|
"The ghost let you pass. You don't know why. " +
|
||||||
|
"Ghosts make decisions on criteria that are not available to the living " +
|
||||||
|
"and today's criteria allowed you through with {item} worth €{value}. " +
|
||||||
|
"You did not ask questions. You took the {item} and left. " +
|
||||||
|
"Correct response.",
|
||||||
|
|
||||||
|
"You found {item} in the sarcophagus. The Draugr who was supposed to be " +
|
||||||
|
"in the sarcophagus was in the next room, which gave you time to " +
|
||||||
|
"take {item} before the situation became complicated. " +
|
||||||
|
"€{value}. {xp} XP. Timing is everything in the Cursed Crypt.",
|
||||||
|
|
||||||
|
"Four rooms. The fourth room had {item} worth €{value} " +
|
||||||
|
"and the kind of ominous atmosphere that suggests something " +
|
||||||
|
"terrible used to happen in it but currently doesn't. " +
|
||||||
|
"Currently was enough. You took the {item} and left " +
|
||||||
|
"before the 'currently' expired.",
|
||||||
|
},
|
||||||
|
|
||||||
|
4: {
|
||||||
|
"The Troll went down. The Stone Giant went down. {item} worth €{value} " +
|
||||||
|
"retrieved from behind them both. {xp} XP. " +
|
||||||
|
"Your equipment is worse than it was — there are marks on it " +
|
||||||
|
"that will require explanation — but you are home and solvent " +
|
||||||
|
"and that is the transaction the Troll Bridge Depths offers.",
|
||||||
|
|
||||||
|
"The Cursed Knight's sword went for your throat. " +
|
||||||
|
"Your Guardian's Helm got in the way. The Knight adjusted. " +
|
||||||
|
"You adjusted faster. {item} worth €{value}. {xp} XP. " +
|
||||||
|
"The Helm has a new dent. You have new money. Fair exchange.",
|
||||||
|
|
||||||
|
"{item} worth €{value} from the fourth level of the Troll Bridge Depths, " +
|
||||||
|
"retrieved past a Troll, two Stone Giants, and the specific kind of " +
|
||||||
|
"architectural malice that suggests the dungeon was designed by someone " +
|
||||||
|
"who didn't want anyone to get to the fourth level. " +
|
||||||
|
"You got to the fourth level. Here is what was there.",
|
||||||
|
|
||||||
|
"You bribed the Troll. Not with money — Trolls don't want money — " +
|
||||||
|
"but with the specific kind of tactical retreat that says " +
|
||||||
|
"'I know where I am and I know what you are and I am choosing " +
|
||||||
|
"this particular angle of withdrawal.' The Troll respected it. " +
|
||||||
|
"{item} was in the room behind the Troll. €{value}. {xp} XP.",
|
||||||
|
},
|
||||||
|
|
||||||
|
5: {
|
||||||
|
"The Abyssal Maw gave up {item} worth €{value} today, " +
|
||||||
|
"which cost more to retrieve than the number suggests " +
|
||||||
|
"and was worth every measure of that cost. " +
|
||||||
|
"{xp} XP. You are home. The Maw is still there. " +
|
||||||
|
"Both of these facts are remarkable.",
|
||||||
|
|
||||||
|
"{item} worth €{value} from the deepest accessible chamber. " +
|
||||||
|
"The Elder Drake was between you and it. " +
|
||||||
|
"The Elder Drake is no longer between anything and anything. " +
|
||||||
|
"You have the {item}. This is what success looks like at Tier 5. " +
|
||||||
|
"Remember it. Not many people get to.",
|
||||||
|
|
||||||
|
"You went into the Abyssal Maw and came back with {item} worth €{value} " +
|
||||||
|
"and {xp} XP and a story that you will tell carefully, " +
|
||||||
|
"to people you trust, in private, " +
|
||||||
|
"because the full version contains several things " +
|
||||||
|
"that are better experienced than described.",
|
||||||
|
|
||||||
|
"The demon had {item}. The demon no longer has {item}. " +
|
||||||
|
"€{value}. {xp} XP. The exchange was not peaceful. " +
|
||||||
|
"The exchange was not brief. The exchange was ultimately yours " +
|
||||||
|
"and that is what the ledger records.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var DungeonExceptional = map[int][]string{
|
||||||
|
|
||||||
|
1: {
|
||||||
|
"STOP EVERYTHING. You found {item} worth €{value} in the SOGGY CELLAR. " +
|
||||||
|
"The SOGGY CELLAR. Where the rats live. Underground. In the damp. " +
|
||||||
|
"You found something worth €{value} in there and gained {xp} XP " +
|
||||||
|
"and came home upright. This does not happen. It has happened. " +
|
||||||
|
"The rats are as surprised as you are.",
|
||||||
|
|
||||||
|
"Against all probability and the general expectations of everyone " +
|
||||||
|
"who has been watching your adventuring career — " +
|
||||||
|
"which has not been impressive — you found {item} worth €{value} " +
|
||||||
|
"in the Soggy Cellar. {xp} XP. The dungeon has filed a formal complaint. " +
|
||||||
|
"The complaint will not be actioned. You have the {item}.",
|
||||||
|
|
||||||
|
"A boss. The Soggy Cellar had a boss today. Not a rat. Not an angry badger. " +
|
||||||
|
"A boss. You fought the boss. You won. You found {item} worth €{value}. " +
|
||||||
|
"{xp} XP. The rats have appointed a new boss. " +
|
||||||
|
"The new boss is watching you leave and reconsidering things.",
|
||||||
|
|
||||||
|
"What the hell. You got a critical hit. In the Soggy Cellar. " +
|
||||||
|
"The numbers came up. The RNG loved you. {item} worth €{value}. " +
|
||||||
|
"{xp} XP. Screenshot this feeling. Frame it. " +
|
||||||
|
"It is unlikely to repeat at this location.",
|
||||||
|
|
||||||
|
"NEW PERSONAL RECORD for the Soggy Cellar. " +
|
||||||
|
"{item} worth €{value}. {xp} XP. The rats acknowledge your authority " +
|
||||||
|
"over this specific damp underground space. " +
|
||||||
|
"This is the most authority you have had over anything. " +
|
||||||
|
"It smells like rats. It is yours.",
|
||||||
|
|
||||||
|
"The Soggy Cellar had a secret room. You found the secret room. " +
|
||||||
|
"The secret room had {item} worth €{value} in it and {xp} bonus XP " +
|
||||||
|
"and the specific satisfaction of a secret found, which is " +
|
||||||
|
"the satisfaction you became an adventurer for. " +
|
||||||
|
"This is what it's supposed to feel like. Remember this.",
|
||||||
|
|
||||||
|
"You found {item} worth €{value} and gained {xp} XP and the Angry Badger " +
|
||||||
|
"that attacked you on the way out was actually carrying " +
|
||||||
|
"a second smaller item that you also took. " +
|
||||||
|
"Two items from one run. The Soggy Cellar is not going to live this down.",
|
||||||
|
},
|
||||||
|
|
||||||
|
2: {
|
||||||
|
"UNPRECEDENTED. The Goblin Warchief was in residence today " +
|
||||||
|
"and you fought the Goblin Warchief and you won. " +
|
||||||
|
"You have {item} worth €{value} and {xp} XP and the signet ring " +
|
||||||
|
"of a Goblin Warchief on your thumb. " +
|
||||||
|
"The goblins are in a governance crisis. This is your fault. Good.",
|
||||||
|
|
||||||
|
"The Goblin Warrens had a treasury room today. " +
|
||||||
|
"You found the treasury room. The treasury room had {item} worth €{value} " +
|
||||||
|
"and several other things of lesser value that sum to a significant " +
|
||||||
|
"improvement in your financial situation. {xp} XP. " +
|
||||||
|
"The goblins are devastated. You are not.",
|
||||||
|
|
||||||
|
"Critical run. Everything went right. Every roll landed. " +
|
||||||
|
"Every trap avoided, every goblin dropped, " +
|
||||||
|
"every room yielded something. {item} worth €{value} total. " +
|
||||||
|
"{xp} XP. The Goblin Warrens is not supposed to work like this. " +
|
||||||
|
"It worked like this today. For you. Today.",
|
||||||
|
|
||||||
|
"The Kobold Cartographer had a map. You have the map now. " +
|
||||||
|
"The map led to {item} worth €{value} in a room that isn't on " +
|
||||||
|
"any other map because the Kobold was the only one who knew about it. " +
|
||||||
|
"The Kobold no longer knows anything. {xp} XP. " +
|
||||||
|
"The map is yours. The room was yours today.",
|
||||||
|
},
|
||||||
|
|
||||||
|
3: {
|
||||||
|
"The Cursed Crypt's inner chamber. Nobody is supposed to reach " +
|
||||||
|
"the inner chamber in one run. You reached the inner chamber. " +
|
||||||
|
"{item} worth €{value}. {xp} XP. The Draugr in the inner chamber " +
|
||||||
|
"had been waiting for centuries for someone to reach it. " +
|
||||||
|
"They did not expect to lose. Neither did you, honestly.",
|
||||||
|
|
||||||
|
"You broke the curse. Not permanently — curses don't break permanently — " +
|
||||||
|
"but for long enough that the inner rooms opened and you took " +
|
||||||
|
"{item} worth €{value} before anything could stop you. " +
|
||||||
|
"{xp} XP. The crypt is cursed again now. You are home and rich. " +
|
||||||
|
"The timing was everything.",
|
||||||
|
|
||||||
|
"A boss encounter in the Cursed Crypt. The boss had been there " +
|
||||||
|
"since before the crypt was a crypt. You fought the boss. " +
|
||||||
|
"You won, which is a sentence the boss did not anticipate " +
|
||||||
|
"being the ending of. {item} worth €{value}. {xp} XP. " +
|
||||||
|
"The crypt is quieter now.",
|
||||||
|
},
|
||||||
|
|
||||||
|
4: {
|
||||||
|
"The deepest chamber of the Troll Bridge Depths yielded {item} worth €{value}. " +
|
||||||
|
"Three Trolls, two Stone Giants, and the Cursed Knight of the Third Bridge " +
|
||||||
|
"disagreed with this outcome. They were overruled. {xp} XP. " +
|
||||||
|
"Your equipment has a story to tell. " +
|
||||||
|
"The story involves those three Trolls and is not suitable for all audiences.",
|
||||||
|
|
||||||
|
"The Stone Giant had {item}. The Stone Giant was the size of a room. " +
|
||||||
|
"The Stone Giant is now the size of a room that is lying down. " +
|
||||||
|
"€{value}. {xp} XP. You found a way. " +
|
||||||
|
"The way was neither clean nor dignified but it was a way " +
|
||||||
|
"and it worked and here you are.",
|
||||||
|
|
||||||
|
"EXCEPTIONAL OUTCOME in the Troll Bridge Depths, which is a sentence " +
|
||||||
|
"that has been said approximately four times in recorded history. " +
|
||||||
|
"You are one of the four times. {item} worth €{value}. " +
|
||||||
|
"{xp} XP. The dungeon will remember this.",
|
||||||
|
},
|
||||||
|
|
||||||
|
5: {
|
||||||
|
"Against all probability, several laws of narrative structure, " +
|
||||||
|
"and the general understanding that the Abyssal Maw does not " +
|
||||||
|
"give things up easily, you have returned with {item} worth €{value} " +
|
||||||
|
"and {xp} XP and a story that begins 'so there were three demons' " +
|
||||||
|
"and gets significantly more complicated from there.",
|
||||||
|
|
||||||
|
"The Elder Drake is dead. You killed an Elder Drake. " +
|
||||||
|
"Its hoard contained {item} worth €{value}. " +
|
||||||
|
"This is now a fact about you that is true. " +
|
||||||
|
"The Abyssal Maw has one fewer Elder Drake. " +
|
||||||
|
"The dungeon log is noting this with what can only be described " +
|
||||||
|
"as reluctant respect.",
|
||||||
|
|
||||||
|
"The Unnamed retreated. You don't know why. " +
|
||||||
|
"The Unnamed does not retreat. It retreated today, from you, " +
|
||||||
|
"and left {item} worth €{value} in the chamber it was occupying. " +
|
||||||
|
"{xp} XP. There will be a next time. " +
|
||||||
|
"You will think about what it means that it retreated " +
|
||||||
|
"and you will not reach a comfortable conclusion.",
|
||||||
|
|
||||||
|
"LEGENDARY RUN. The Abyssal Maw at full depth, full clear, " +
|
||||||
|
"{item} worth €{value}, {xp} XP. " +
|
||||||
|
"Three demons, an Elder Drake, and something that doesn't have a name. " +
|
||||||
|
"You beat all of them. You are home. " +
|
||||||
|
"This is the best day anyone in this community has had " +
|
||||||
|
"in a dungeon. Write it down. " +
|
||||||
|
"The dungeon will tell this story differently.",
|
||||||
|
},
|
||||||
|
}
|
||||||
782
internal/plugin/adventure_flavor_mining.go
Normal file
782
internal/plugin/adventure_flavor_mining.go
Normal file
@@ -0,0 +1,782 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
var MiningDeath = map[int][]string{
|
||||||
|
|
||||||
|
// ── TIER 1: SURFACE PITS ─────────────────────────────────────────────────
|
||||||
|
1: {
|
||||||
|
"The cave-in was technically not your fault. The ceiling disagrees. " +
|
||||||
|
"You have been extracted from the rubble by a miner coming off shift " +
|
||||||
|
"who charged you for the inconvenience and suggested, not unkindly, " +
|
||||||
|
"that you consider a different career. You are considering it. " +
|
||||||
|
"You are considering it from a medical facility.",
|
||||||
|
|
||||||
|
"You hit what you thought was a copper vein. " +
|
||||||
|
"It was a load-bearing wall. " +
|
||||||
|
"The distinction mattered significantly more than anticipated. " +
|
||||||
|
"You are now convalescing, which is a fancy word for lying very still " +
|
||||||
|
"and questioning every decision that led to this exact moment.",
|
||||||
|
|
||||||
|
"The Surface Pits are called Surface Pits because they are near the surface. " +
|
||||||
|
"Near the surface means the ceiling is basically ground. " +
|
||||||
|
"Ground is heavy. You know this now. " +
|
||||||
|
"You knew it before, technically, but now you know it differently. " +
|
||||||
|
"Medically.",
|
||||||
|
|
||||||
|
"A bat. A single bat panicked and flew into your face " +
|
||||||
|
"while you were mid-swing and you hit the wrong thing with the pickaxe " +
|
||||||
|
"and the wrong thing was structural and the structure had opinions. " +
|
||||||
|
"The bat is fine. The structure expressed its opinions. " +
|
||||||
|
"You are in medical care.",
|
||||||
|
|
||||||
|
"The ore was right there. Right there. One more swing and you had it. " +
|
||||||
|
"The one more swing destabilised something adjacent to the ore " +
|
||||||
|
"that was doing more structural work than it looked like it was doing. " +
|
||||||
|
"You did not get the ore. The ore got you. From above. " +
|
||||||
|
"Healthcare is processing this with familiar efficiency.",
|
||||||
|
|
||||||
|
"You were humming while you worked. Miners hum. It's a thing miners do. " +
|
||||||
|
"The vibration of the humming, combined with the vibration of the pickaxe, " +
|
||||||
|
"combined with the fact that the Surface Pits are 'surface' in the sense " +
|
||||||
|
"of 'barely underground at all' produced an outcome that " +
|
||||||
|
"the structural engineer who assessed the pits afterward called " +
|
||||||
|
"'statistically likely.' You were the statistic.",
|
||||||
|
|
||||||
|
"The Rusted PoS Pickaxe broke on impact. The head flew backward. " +
|
||||||
|
"This is not how pickaxes are supposed to work. " +
|
||||||
|
"The resulting sequence of events was neither dignified nor brief " +
|
||||||
|
"and ended with you in a medical facility and the pickaxe head " +
|
||||||
|
"somewhere in the rubble that used to be the mine entrance. " +
|
||||||
|
"Upgrade your tools. Please.",
|
||||||
|
|
||||||
|
"Died in the Surface Pits. The shallowest mines available. " +
|
||||||
|
"The mines where the copper is basically lying on the ground. " +
|
||||||
|
"You died there. The copper is still lying on the ground. " +
|
||||||
|
"You are in a different location. The copper is not.",
|
||||||
|
|
||||||
|
"Something was already in the mine when you got there. " +
|
||||||
|
"You didn't check before going in. " +
|
||||||
|
"Miners check before going in. " +
|
||||||
|
"You are a miner in the same way you are a lot of things: technically and poorly. " +
|
||||||
|
"The something made its presence known. Healthcare has made its bill known.",
|
||||||
|
|
||||||
|
"The water came in faster than the structural integrity left. " +
|
||||||
|
"This is a sentence about priorities and physics and the Surface Pits' " +
|
||||||
|
"proximity to a drainage ditch that you were not informed about. " +
|
||||||
|
"You are now informed about it. From a medical facility.",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── TIER 2: IRON RIDGE ───────────────────────────────────────────────────
|
||||||
|
2: {
|
||||||
|
"A cave troll. In an iron mine. Nobody mentioned the cave troll. " +
|
||||||
|
"The listing for Iron Ridge said: iron, lead, saltpetre. " +
|
||||||
|
"The listing did not say cave troll. The cave troll did not consult the listing. " +
|
||||||
|
"Your Chipped Iron Pickaxe bounced off it. Your armor absorbed one hit. " +
|
||||||
|
"The troll has your hat now. Healthcare has your chart.",
|
||||||
|
|
||||||
|
"The Iron Ridge cave-in took out the main tunnel. " +
|
||||||
|
"Not a small cave-in — a real one, a committed one, " +
|
||||||
|
"a cave-in that had prepared for this moment. " +
|
||||||
|
"You were in the main tunnel. You are in medical care. " +
|
||||||
|
"The relationship between these facts is direct.",
|
||||||
|
|
||||||
|
"You found iron. Significant iron. More iron than your inventory could hold. " +
|
||||||
|
"You were so focused on the iron that you did not notice " +
|
||||||
|
"the floor of the main seam was a different colour than the floor " +
|
||||||
|
"of the entrance tunnel, which is typically how you notice " +
|
||||||
|
"that the floor is a different substance, which in this case was briefly nothing.",
|
||||||
|
|
||||||
|
"Saltpetre is flammable. You know this. Everyone knows this. " +
|
||||||
|
"The question is whether you knew it in the specific moment " +
|
||||||
|
"when your torch got too close to the saltpetre deposit. " +
|
||||||
|
"The answer is: you knew it right after. That's a different kind of knowing. " +
|
||||||
|
"Healthcare is dealing with the result.",
|
||||||
|
|
||||||
|
"The Iron Ridge cave troll had been there since before the mine. " +
|
||||||
|
"The mine was built around it. The miners worked around it. " +
|
||||||
|
"You did not get the briefing about the cave troll " +
|
||||||
|
"because the briefing is for miners and you are not a miner, " +
|
||||||
|
"you are an adventurer with a pickaxe, which is a critical distinction " +
|
||||||
|
"the troll also made.",
|
||||||
|
|
||||||
|
"Lead poisoning is not immediate. You were down there long enough " +
|
||||||
|
"for it to start being immediate. The medical facility has noted this. " +
|
||||||
|
"The medical facility has seen this before. " +
|
||||||
|
"The medical facility has a specific intake form for it. " +
|
||||||
|
"You have filled out the intake form.",
|
||||||
|
},
|
||||||
|
|
||||||
|
3: {
|
||||||
|
"Silver Seam, Tier 3, and you found the silver. You actually found it. " +
|
||||||
|
"And then the thing that was guarding the silver " +
|
||||||
|
"found you, which is how things work at Tier 3, " +
|
||||||
|
"and the thing was not a bat and not a cave troll " +
|
||||||
|
"but something older and more specifically opposed to " +
|
||||||
|
"people taking silver from this particular seam. " +
|
||||||
|
"Healthcare has the details.",
|
||||||
|
|
||||||
|
"The quartz formation was load-bearing in ways that are " +
|
||||||
|
"not immediately obvious from looking at quartz formations. " +
|
||||||
|
"You looked at the quartz formation. You did not correctly " +
|
||||||
|
"identify its structural role. You swung the pickaxe anyway. " +
|
||||||
|
"The quartz formation expressed its structural role clearly.",
|
||||||
|
|
||||||
|
"Three levels down in the Silver Seam and something in the third level " +
|
||||||
|
"decided you'd gone far enough. The decision was made forcefully " +
|
||||||
|
"and immediately and without consultation. You are in medical care. " +
|
||||||
|
"The silver is in the third level. The something is also in the third level. " +
|
||||||
|
"These facts are related.",
|
||||||
|
},
|
||||||
|
|
||||||
|
4: {
|
||||||
|
"The Deeprock is deep. This is noted in the name and in every " +
|
||||||
|
"description of the Deeprock that has ever been written " +
|
||||||
|
"by anyone who came back from it. Deep means pressure. " +
|
||||||
|
"Pressure means the walls behave differently. " +
|
||||||
|
"You found this out in a way that required medical intervention.",
|
||||||
|
|
||||||
|
"Something in the Deeprock does not appear on any mining chart " +
|
||||||
|
"because the people who compile mining charts don't go to the Deeprock " +
|
||||||
|
"specifically because of the something. " +
|
||||||
|
"You went to the Deeprock. You found the something. " +
|
||||||
|
"The something found you first, actually. Healthcare is filing the paperwork.",
|
||||||
|
|
||||||
|
"Titanium ore does not yield easily. You were swinging hard. " +
|
||||||
|
"The hard swing destabilised a gold deposit adjacent to the titanium " +
|
||||||
|
"that was doing significant structural work in a part of the mine " +
|
||||||
|
"that turns out to have significant structural requirements. " +
|
||||||
|
"You did not get the titanium or the gold. You got the ceiling.",
|
||||||
|
},
|
||||||
|
|
||||||
|
5: {
|
||||||
|
"The Mythril Caverns at full depth. The ore was there. " +
|
||||||
|
"The Voidstone was between you and the ore. " +
|
||||||
|
"Voidstone is called Voidstone for a reason. " +
|
||||||
|
"The reason is relevant to your current medical situation.",
|
||||||
|
|
||||||
|
"Mythril does not want to be mined. This is not metaphorical. " +
|
||||||
|
"Mythril ore in the Mythril Caverns has a documented defensive response " +
|
||||||
|
"to mining attempts that involves the surrounding rock and significant velocity. " +
|
||||||
|
"You attempted to mine it. The response was documented again. " +
|
||||||
|
"You are the new documentation.",
|
||||||
|
|
||||||
|
"The Dragon Crystal was there. Your Diamond Pickaxe was there. " +
|
||||||
|
"You were there. The thing that the Dragon Crystal was protecting " +
|
||||||
|
"was also there, which is the fact that changes the outcome. " +
|
||||||
|
"Dragon Crystals don't form around nothing. " +
|
||||||
|
"The nothing turned out to be something. Healthcare is involved.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var MiningCaveIn = []string{
|
||||||
|
"A minor cave-in. Congratulations on the 'minor.' " +
|
||||||
|
"You survived, which the mine did not expect and the mine's structural " +
|
||||||
|
"integrity does not entirely endorse. You emerged with {ore} " +
|
||||||
|
"and the distinct feeling that the mountain is personally annoyed with you. " +
|
||||||
|
"Your {tool} is worse. Your back is worse. The ore is worth €{value}. " +
|
||||||
|
"Two out of three is acceptable.",
|
||||||
|
|
||||||
|
"Rocks fell. Most of them missed, which is more luck than you deserve. " +
|
||||||
|
"Your {tool} took the worst of it and is now in a condition that can " +
|
||||||
|
"generously be described as 'structurally questioning its own existence.' " +
|
||||||
|
"You found {ore} before the ceiling made its feelings known. Worth it. Barely.",
|
||||||
|
|
||||||
|
"The ceiling gave you a warning shot. One rock. Right next to your head. " +
|
||||||
|
"You took the hint and got out with {ore} worth €{value} and a newfound " +
|
||||||
|
"respect for geology that you will maintain for approximately three days " +
|
||||||
|
"before forgetting it again. Your {tool} absorbed some of the exit.",
|
||||||
|
|
||||||
|
"The mine expressed structural concerns via the medium of falling rock. " +
|
||||||
|
"You heard the concerns. You addressed them by leaving, taking {ore} with you. " +
|
||||||
|
"€{value}. Your {tool} has been reviewed by the falling rock and rated poorly. " +
|
||||||
|
"The falling rock's review stands. Consider upgrades.",
|
||||||
|
|
||||||
|
"A section of the ceiling reconsidered its commitment to being a ceiling. " +
|
||||||
|
"This philosophical crisis occurred directly above you, as they do. " +
|
||||||
|
"You got out with {ore} worth €{value}. Your armor is worse. " +
|
||||||
|
"Your {tool} is worse. You are better for having survived it, " +
|
||||||
|
"which is a way of looking at the situation that the mountain does not share.",
|
||||||
|
|
||||||
|
"Something went wrong in a mine. Specifically: the ceiling went wrong, " +
|
||||||
|
"in the direction of your head, at a speed that required running. " +
|
||||||
|
"You ran. You kept {ore}. The ore is worth €{value}. " +
|
||||||
|
"The {tool} was sacrificed to the structural gods and they accepted it. " +
|
||||||
|
"You are home. The mine has made its point.",
|
||||||
|
|
||||||
|
"Cave-in, partial, survived. The official report reads exactly that " +
|
||||||
|
"because the official report is required to be accurate " +
|
||||||
|
"and 'survived a cave-in and came home with {ore} worth €{value} " +
|
||||||
|
"while your {tool} disintegrated and your armor took significant damage' " +
|
||||||
|
"doesn't fit in the field provided. But that's what happened.",
|
||||||
|
|
||||||
|
"The rock didn't fall so much as it slid, which is different in the way " +
|
||||||
|
"that gives you time to move but not enough time to take everything. " +
|
||||||
|
"You took {ore}. You left your dignity. €{value}. The {tool} is on the border " +
|
||||||
|
"of being a different category of object now. The border is 'scrap.'",
|
||||||
|
|
||||||
|
"Tectonic opinion expressed. You received the opinion. " +
|
||||||
|
"You incorporated the opinion into your exit strategy, " +
|
||||||
|
"taking {ore} worth €{value} as you incorporated. " +
|
||||||
|
"Your {tool} participated in absorbing the opinion " +
|
||||||
|
"and is available for review in its current condition, which is poor. " +
|
||||||
|
"But you're here. The ore is here. The ceiling is also here, " +
|
||||||
|
"lower than before.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var MiningEmpty = map[int][]string{
|
||||||
|
|
||||||
|
1: {
|
||||||
|
"You mined for three hours. The mountain was empty. " +
|
||||||
|
"Not metaphorically — there was literally nothing worth a damn in there. " +
|
||||||
|
"You brought home some dust, a bad back, and a grudge against copper specifically.",
|
||||||
|
|
||||||
|
"The promising vein of copper turned out to be copper-coloured rock. " +
|
||||||
|
"All of it. The whole vein. Copper-coloured, worthless, " +
|
||||||
|
"personally offensive rock. You have experienced this before. " +
|
||||||
|
"It has not gotten easier or more interesting.",
|
||||||
|
|
||||||
|
"Nothing. Sweet, simple nothing. You swung your {tool} at that mountain face " +
|
||||||
|
"for the better part of a morning and it gave you absolutely nothing back " +
|
||||||
|
"except sore arms and XP that barely qualifies as XP. " +
|
||||||
|
"The mountain doesn't care about you. That's the lesson. Free of charge.",
|
||||||
|
|
||||||
|
"The vein ran out three swings in. Three swings. You prepared for hours, " +
|
||||||
|
"navigated to the seam, positioned yourself correctly, " +
|
||||||
|
"executed the first three swings, and the vein " +
|
||||||
|
"had the audacity to simply stop existing. The mountain has no shame.",
|
||||||
|
|
||||||
|
"Someone else was here recently. You can tell by the shape of the worked stone — " +
|
||||||
|
"someone competent, with better tools, who took everything and left " +
|
||||||
|
"the bare walls behind as a message. The message is: " +
|
||||||
|
"you came second. In mining, coming second is coming last.",
|
||||||
|
|
||||||
|
"Coal. You found coal. An entire seam of coal " +
|
||||||
|
"in a copper mine, because the universe is specific in its disappointments. " +
|
||||||
|
"The coal is not worth your time. You mined it anyway, briefly, " +
|
||||||
|
"out of frustration, and then stopped and stood there " +
|
||||||
|
"in a coal seam feeling things.",
|
||||||
|
|
||||||
|
"The Surface Pits were empty in the way that suggests they were always empty " +
|
||||||
|
"and the optimism that put 'copper, tin, coal' on the listing " +
|
||||||
|
"was the optimism of someone who hadn't personally checked. " +
|
||||||
|
"You have personally checked. The listing was optimistic.",
|
||||||
|
|
||||||
|
"You found tin. One piece of tin. A single, solitary, " +
|
||||||
|
"inadequate piece of tin ore in three hours of mining. " +
|
||||||
|
"The tin is worth €{value}. €{value} is a number. " +
|
||||||
|
"The number does not justify the morning. Nothing justifies the morning.",
|
||||||
|
|
||||||
|
"The bat colony was in the main seam. The entire main seam. " +
|
||||||
|
"You could not mine around the bats. The bats were the seam, effectively. " +
|
||||||
|
"You came home with nothing and the distinct sense " +
|
||||||
|
"that the bats were watching you leave, satisfied.",
|
||||||
|
|
||||||
|
"An entire morning of swinging at rock and the rock gave back: nothing. " +
|
||||||
|
"Not even a piece of coal as a participation award. " +
|
||||||
|
"The mountain took your time and your arm strength " +
|
||||||
|
"and your {tool}'s remaining condition and gave you experience points. " +
|
||||||
|
"Experience points that barely covered the walk.",
|
||||||
|
},
|
||||||
|
|
||||||
|
2: {
|
||||||
|
"Iron Ridge has iron in it. Usually. Today it had rock. " +
|
||||||
|
"Iron-coloured rock, lead-coloured rock, rock-coloured rock. " +
|
||||||
|
"Rock and more rock and then, at the back of the main seam, " +
|
||||||
|
"rock with an attitude. Nothing worth a euro. " +
|
||||||
|
"You have learned more about rock than you wanted to know.",
|
||||||
|
|
||||||
|
"The cave troll was in the way of the main vein. " +
|
||||||
|
"You went around the cave troll. The around-the-cave-troll route " +
|
||||||
|
"led to a worked-out section. Someone had already mined this section. " +
|
||||||
|
"Someone had also apparently come to an arrangement with the cave troll, " +
|
||||||
|
"which explains the troll's territorial behavior and your empty inventory.",
|
||||||
|
|
||||||
|
"Lead. A full load of lead ore and no way to carry it all. " +
|
||||||
|
"You took what you could. On the way out you dropped it. " +
|
||||||
|
"You went back for it. It was gone. " +
|
||||||
|
"You don't know what happened to the lead. " +
|
||||||
|
"The mine knows. The mine is not telling you.",
|
||||||
|
|
||||||
|
"The iron seam was there. The iron seam was right there. " +
|
||||||
|
"The iron seam was also, it turns out, directly below a water table " +
|
||||||
|
"that expressed itself the moment you broke through. " +
|
||||||
|
"You ran. The water has the iron now. The water doesn't need the iron. " +
|
||||||
|
"You needed the iron. The arrangement is unfair and non-negotiable.",
|
||||||
|
},
|
||||||
|
|
||||||
|
3: {
|
||||||
|
"Silver Seam, third tier, and the silver had run thin. " +
|
||||||
|
"Not empty — you could see the silver in the rock, faint veins " +
|
||||||
|
"of it running through everything — but too embedded to extract " +
|
||||||
|
"with your current tools, which are good tools that are not the right tools. " +
|
||||||
|
"You mined nothing. You saw silver all day. " +
|
||||||
|
"The specific cruelty of this is noted.",
|
||||||
|
|
||||||
|
"Six hours in the Silver Seam. Six hours of promising rock " +
|
||||||
|
"and misleading mineral formations and one moment of genuine excitement " +
|
||||||
|
"that turned out to be quartz, which is beautiful and worthless. " +
|
||||||
|
"You have quartz now. Quartz is pretty. " +
|
||||||
|
"Quartz is worth the walk the same way a nice view is worth the walk: " +
|
||||||
|
"it is, technically, but not when you were hoping for silver.",
|
||||||
|
},
|
||||||
|
|
||||||
|
4: {
|
||||||
|
"The Deeprock does not give up Deeprock gold easily. " +
|
||||||
|
"It did not give it up today at all. " +
|
||||||
|
"You went to the fourth tier of underground, fought your way to the seam, " +
|
||||||
|
"swung at the right rock for two hours, " +
|
||||||
|
"and came home with the XP of having tried and the ore of having not succeeded.",
|
||||||
|
|
||||||
|
"The sapphire deposit was right there and then it wasn't. " +
|
||||||
|
"Not mined out — physically relocated by something that lives in the Deeprock " +
|
||||||
|
"and has opinions about the sapphire that outrank yours. " +
|
||||||
|
"You did not find what relocated it. You found its wake.",
|
||||||
|
},
|
||||||
|
|
||||||
|
5: {
|
||||||
|
"The Mythril Caverns had mythril today. You know this because you could see it. " +
|
||||||
|
"Voidstone adjacent. Dragon Crystal formation blocking the approach. " +
|
||||||
|
"The mythril was there and inaccessible and beautiful " +
|
||||||
|
"and you spent four hours confirming this and came home with nothing. " +
|
||||||
|
"The mythril is still there. It will be there tomorrow. " +
|
||||||
|
"Whether you can access it tomorrow is tomorrow's problem.",
|
||||||
|
|
||||||
|
"The caverns were full today. Full of things that were not for you today. " +
|
||||||
|
"The Voidstone pulsed when you came near the good seams. " +
|
||||||
|
"The Dragon Crystals were warm in ways they shouldn't be. " +
|
||||||
|
"The mythril was there. The space between you and the mythril " +
|
||||||
|
"was too full of intent for you to close. Empty hands. " +
|
||||||
|
"Full understanding of why. That's Tier 5.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var MiningSuccess = map[int][]string{
|
||||||
|
|
||||||
|
1: {
|
||||||
|
"Solid day's work. You extracted {ore} worth €{value} from {location} " +
|
||||||
|
"with minimal personal injury, which around here counts as raging success. " +
|
||||||
|
"Your {tool} held up, which is more than can be said for your back.",
|
||||||
|
|
||||||
|
"The seam held. {ore} in respectable quantity, €{value} worth, " +
|
||||||
|
"and only one near-miss with a very irate bat who had opinions " +
|
||||||
|
"about your presence and then reconsidered. " +
|
||||||
|
"You are calling this a win. You are not wrong.",
|
||||||
|
|
||||||
|
"In and out. {ore}, €{value}, zero cave-ins, one bat that thought about it " +
|
||||||
|
"and decided not to. Your {tool} is slightly worse. Everything else is better. " +
|
||||||
|
"By Surface Pits standards, a professional operation.",
|
||||||
|
|
||||||
|
"The copper vein ran further than the map suggested. Further and richer. " +
|
||||||
|
"You took {ore} worth €{value} and left more than you could carry. " +
|
||||||
|
"This is the correct response to a rich vein. Come back tomorrow. " +
|
||||||
|
"Bring a bigger bag. Bring a better pickaxe.",
|
||||||
|
|
||||||
|
"Clean extraction. {ore} from the main seam, €{value} assessed, " +
|
||||||
|
"no structural events, one moment where the bat looked at you " +
|
||||||
|
"and you looked at the bat and you both made sensible decisions. " +
|
||||||
|
"{xp} XP. The mountain respects you today. Slightly.",
|
||||||
|
|
||||||
|
"Level up potential accumulating. {ore}, €{value}, {xp} XP. " +
|
||||||
|
"You swung the pickaxe correctly the correct number of times " +
|
||||||
|
"and the rock rewarded you for it. This is the entire transaction. " +
|
||||||
|
"It is, when it works, genuinely satisfying.",
|
||||||
|
|
||||||
|
"The tin seam yielded more than expected and the coal beneath it " +
|
||||||
|
"turned out to have a copper pocket behind it " +
|
||||||
|
"that wasn't on any map because nobody had gone that far before. " +
|
||||||
|
"You went that far. {ore} total, €{value}. " +
|
||||||
|
"The {tool} is worse. The discovery is better.",
|
||||||
|
},
|
||||||
|
|
||||||
|
2: {
|
||||||
|
"Iron Ridge delivered today. {ore} extracted, €{value} assessed, " +
|
||||||
|
"cave troll acknowledged and navigated around with something approaching grace. " +
|
||||||
|
"The {tool} performed admirably. Your back has filed a formal complaint. " +
|
||||||
|
"The back's complaint is noted and will not change the outcome.",
|
||||||
|
|
||||||
|
"A good seam. A real seam — proper iron, " +
|
||||||
|
"the kind where the rock splits cleanly and the ore sits in it " +
|
||||||
|
"like it's been waiting. {ore} worth €{value}. {xp} XP. " +
|
||||||
|
"This is what mining is supposed to feel like. " +
|
||||||
|
"Remember it for the days it doesn't.",
|
||||||
|
|
||||||
|
"The lead pocket was deeper than the iron, which meant going deeper " +
|
||||||
|
"than planned, which meant more exposure to everything Iron Ridge contains. " +
|
||||||
|
"You made it. {ore} worth €{value}. The depth was worth it. " +
|
||||||
|
"The {tool} has opinions about the depth. The opinions are structural.",
|
||||||
|
},
|
||||||
|
|
||||||
|
3: {
|
||||||
|
"Silver. Real silver, properly embedded, not quartz, not illusion, " +
|
||||||
|
"not iron-coloured rock lying about its nature. Silver. " +
|
||||||
|
"{ore} worth €{value}. {xp} XP. Your {tool} is a real tool " +
|
||||||
|
"and this was a real seam and you extracted the silver. " +
|
||||||
|
"That's the whole job. Today the whole job got done.",
|
||||||
|
|
||||||
|
"The Silver Seam at depth, past the quartz formations, " +
|
||||||
|
"past the section that looks better than it is, " +
|
||||||
|
"down to where the silver sits in the rock like an argument for effort. " +
|
||||||
|
"{ore}, €{value}, {xp} XP, home by sunset. " +
|
||||||
|
"A good day. In a mine. Those exist.",
|
||||||
|
},
|
||||||
|
|
||||||
|
4: {
|
||||||
|
"Deeprock gold. Actual gold, not pyrite, not gold-coloured copper, " +
|
||||||
|
"not optimism in mineral form. Gold. {ore} worth €{value}. " +
|
||||||
|
"The Deeprock tried to discourage you with atmosphere and pressure. " +
|
||||||
|
"You brought a Mithril Pickaxe and ignored the atmosphere.",
|
||||||
|
|
||||||
|
"You went to the fourth level of the Deeprock and you came back " +
|
||||||
|
"with {ore} worth €{value} and {xp} XP and a story " +
|
||||||
|
"that mostly involves how far down the fourth level is. " +
|
||||||
|
"It is very far down. The ore was worth being very far down. Today.",
|
||||||
|
},
|
||||||
|
|
||||||
|
5: {
|
||||||
|
"Mythril. You found mythril and extracted mythril " +
|
||||||
|
"and brought mythril home and mythril is worth €{value} per unit. " +
|
||||||
|
"The {ore} you're carrying is worth €{value} total. " +
|
||||||
|
"{xp} XP. The Diamond Pickaxe is a Diamond Pickaxe for a reason. " +
|
||||||
|
"Today you proved the reason.",
|
||||||
|
|
||||||
|
"The Voidstone was present but passive today. " +
|
||||||
|
"The Dragon Crystals were warm but not hostile. " +
|
||||||
|
"The mythril seam was open and you took {ore} worth €{value} from it. " +
|
||||||
|
"{xp} XP. The Mythril Caverns had a good day at the same time you did. " +
|
||||||
|
"This does not happen often. Don't examine it. Take the ore home.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FORAGING ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var ForagingDeath = []string{
|
||||||
|
"Look — nobody expects to die foraging. That's the whole thing about foraging. " +
|
||||||
|
"It lowers your guard. You were crossing the river when the current decided " +
|
||||||
|
"your story ended here. It did not end here, but the margin was narrow. " +
|
||||||
|
"The river kept your left boot and is not returning it.",
|
||||||
|
|
||||||
|
"You disturbed what appeared to be an ordinary beehive. " +
|
||||||
|
"It was not ordinary. The bees had a position on disturbance " +
|
||||||
|
"and they held it with every individual sting they possessed. " +
|
||||||
|
"You have been stung approximately four hundred times. " +
|
||||||
|
"The doctor says you will probably be fine. " +
|
||||||
|
"Probably is doing considerable work in that sentence.",
|
||||||
|
|
||||||
|
"You were eaten by a bear. Partially. The bear reconsidered mid-process " +
|
||||||
|
"and left. You are alive in the way that really makes you question " +
|
||||||
|
"what 'alive' means as a category. Healthcare is sorting the paperwork. " +
|
||||||
|
"The bear is eating something else now. You hope.",
|
||||||
|
|
||||||
|
"A wild {item} appeared and you bent to pick it up " +
|
||||||
|
"and what you bent toward was a hornet's nest you couldn't see " +
|
||||||
|
"from standing height and the hornets had been waiting for exactly this. " +
|
||||||
|
"The hornets had been waiting for a very long time. " +
|
||||||
|
"You gave them what they'd been waiting for.",
|
||||||
|
|
||||||
|
"The tree was dead. Dead trees fall. " +
|
||||||
|
"You were at the base of the dead tree when it decided to fall, " +
|
||||||
|
"which the tree did without significant warning, " +
|
||||||
|
"which is the thing about dead trees that makes them different from live ones. " +
|
||||||
|
"Live trees creak. Dead trees have already decided.",
|
||||||
|
|
||||||
|
"The river crossing was knee-deep. The current was described to you as mild. " +
|
||||||
|
"Both of these facts were technically accurate in the dry season, " +
|
||||||
|
"and it is not the dry season, and nobody told you this, " +
|
||||||
|
"and the current had opinions about your presence that it expressed at length.",
|
||||||
|
|
||||||
|
"You found a mushroom. A beautiful mushroom, red-capped, white-spotted, " +
|
||||||
|
"the kind that appears in illustrations and decorative contexts. " +
|
||||||
|
"You ate some of it to check if it was edible. It was not edible. " +
|
||||||
|
"Healthcare has your chart and a different entry form for this specific situation.",
|
||||||
|
|
||||||
|
"Something in {location} that is not on any foraging chart " +
|
||||||
|
"because the people who compile foraging charts do not go to the part of {location} " +
|
||||||
|
"where the something lives. You went there. The something was there. " +
|
||||||
|
"You are in medical care. The something is still there.",
|
||||||
|
|
||||||
|
"You climbed the tree for the fruit. The branch held. " +
|
||||||
|
"The other branch, the one you were holding onto, did not hold. " +
|
||||||
|
"The fall was not long but the landing was conclusive " +
|
||||||
|
"in the way that requires professional medical attention.",
|
||||||
|
|
||||||
|
"The bear was not aggressive. The bear was eating berries. " +
|
||||||
|
"The berries you were there for. The bear's position on sharing berries " +
|
||||||
|
"with a person who walked into their berry clearing was immediate and physical. " +
|
||||||
|
"Your armor is worse. The bear has the berries.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var ForagingHornets = []string{
|
||||||
|
"You found a beautiful old oak. It had hornets. So many goddamn hornets. " +
|
||||||
|
"You ran. The hornets followed. You ran further. The hornets were committed " +
|
||||||
|
"to the bit in a way you were completely unprepared for. " +
|
||||||
|
"You returned home with no loot, significant swelling, and a new personal " +
|
||||||
|
"relationship with the concept of consequences.",
|
||||||
|
|
||||||
|
"The nest looked small. It was not small. It was the visible surface " +
|
||||||
|
"of something vast and very angry. You escaped with your life " +
|
||||||
|
"and approximately twelve percent of your dignity. " +
|
||||||
|
"Your armor has forty-seven new dents. They are all from hornets. " +
|
||||||
|
"Hornets did that. To armor.",
|
||||||
|
|
||||||
|
"Hornets. Again. At some point this stops being bad luck and starts " +
|
||||||
|
"being a you problem. You lost an hour running, gained nothing at all, " +
|
||||||
|
"and look like you lost a fight with a pincushion. Because you did.",
|
||||||
|
|
||||||
|
"You have contracted STATUS EFFECT: STUNG (×52). " +
|
||||||
|
"There is no remedy in your inventory. There is no item for this. " +
|
||||||
|
"You must wait for it to stop and think very carefully " +
|
||||||
|
"about your relationship with trees that have a lot of buzzing near them.",
|
||||||
|
|
||||||
|
"The hornets were not in the nest when you found the nest. " +
|
||||||
|
"This seemed lucky. The hornets were getting back from wherever hornets go " +
|
||||||
|
"and arrived home to find you standing next to their home " +
|
||||||
|
"with {item} in your hand and nowhere to run that you hadn't already calculated. " +
|
||||||
|
"The calculation was wrong.",
|
||||||
|
|
||||||
|
"You heard the buzzing. You made the decision to continue anyway. " +
|
||||||
|
"This is the moment, when you replay it later — and you will replay it — " +
|
||||||
|
"where everything could have been different. You continued anyway. " +
|
||||||
|
"The hornets respected the audacity. The hornets did not let it go unpunished.",
|
||||||
|
|
||||||
|
"A forager's first lesson: if it hums, it has already made a decision about you. " +
|
||||||
|
"You are learning the first lesson. The first lesson has forty-three individual " +
|
||||||
|
"components today and they are all in your neck and arms.",
|
||||||
|
|
||||||
|
"The tree looked ordinary. The tree was ordinary. " +
|
||||||
|
"The hornets inside the tree were not ordinary — they were a specific kind " +
|
||||||
|
"of hornet with a specific kind of patience and a very practiced response " +
|
||||||
|
"to the sound of a woodcutting tool approaching their home. " +
|
||||||
|
"They practiced it on you.",
|
||||||
|
|
||||||
|
"You killed one hornet. You killed it immediately and with precision " +
|
||||||
|
"because you are a practical person and it was there. " +
|
||||||
|
"The hornet's colleagues noted the killing. " +
|
||||||
|
"The hornet's colleagues had a meeting about it. " +
|
||||||
|
"The meeting concluded quickly and unanimously and you were there for the vote.",
|
||||||
|
|
||||||
|
"Pro tip, available retroactively: hornets near the water's edge " +
|
||||||
|
"are more aggressive than woodland hornets because they've already " +
|
||||||
|
"had a bad day from the moisture. You found water's-edge hornets. " +
|
||||||
|
"They had had a bad day. They shared it.",
|
||||||
|
|
||||||
|
"The entire foraging trip went fine until the tree. " +
|
||||||
|
"Everything before the tree was excellent — haul building, " +
|
||||||
|
"weather cooperative, no bears, no river crossings. " +
|
||||||
|
"Then the tree. Then the hornets in the tree. " +
|
||||||
|
"Then nothing. The haul is gone. The morning is gone. " +
|
||||||
|
"The tree is still there. The hornets are very much still there.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var ForagingBear = []string{
|
||||||
|
"A bear. There was a goddamn bear. You and the bear had a brief negotiation " +
|
||||||
|
"whose terms were entirely set by the bear. Your armor absorbed " +
|
||||||
|
"the opening statement and is now in a condition the bear " +
|
||||||
|
"would call 'fair exchange.' You ran. The bear watched you go " +
|
||||||
|
"with what you chose to interpret as respect. It was not respect.",
|
||||||
|
|
||||||
|
"The bear was eating berries. You were also there for berries. " +
|
||||||
|
"The bear felt this was a resource conflict requiring immediate physical resolution. " +
|
||||||
|
"You resolved it by leaving extremely fast. Your {armor} is worse. " +
|
||||||
|
"Your pride is worse. The bear has all the berries.",
|
||||||
|
|
||||||
|
"You saw the bear before it saw you. This did not help. " +
|
||||||
|
"You had a head start. This also did not help. " +
|
||||||
|
"Bears are fast. They are very fast. " +
|
||||||
|
"Your armor took {damage} condition damage and you took the rest personally.",
|
||||||
|
|
||||||
|
"The bear made eye contact. You made eye contact back. " +
|
||||||
|
"In retrospect this was the wrong call — " +
|
||||||
|
"bears interpret sustained eye contact as a challenge, " +
|
||||||
|
"which is information you could have used thirty seconds ago. " +
|
||||||
|
"Your {armor} absorbed the challenge response.",
|
||||||
|
|
||||||
|
"Mother bear. Cubs nearby, which you noticed after the encounter " +
|
||||||
|
"rather than before it, which is the incorrect order in which to notice cubs. " +
|
||||||
|
"Mother bear's response to your presence near the cubs was immediate, " +
|
||||||
|
"comprehensive, and educational. {armor} condition: reduced. " +
|
||||||
|
"Bear education: complete.",
|
||||||
|
|
||||||
|
"You threw {item} at the bear. The item did not deter the bear. " +
|
||||||
|
"The bear was briefly curious about the item and then returned its attention to you. " +
|
||||||
|
"You lost {item} and your armor took damage and you ran home " +
|
||||||
|
"without the item and without anything else you'd collected. " +
|
||||||
|
"The bear has your {item} now. It doesn't know what to do with it.",
|
||||||
|
|
||||||
|
"The bear followed you for twenty minutes. Twenty full minutes. " +
|
||||||
|
"Not charging — just walking, at bear pace, in your direction, " +
|
||||||
|
"through everything you went through, with complete commitment. " +
|
||||||
|
"You dropped your haul to run faster. The bear stopped then. " +
|
||||||
|
"The bear wanted you to drop the haul. This is the worst kind of smart.",
|
||||||
|
|
||||||
|
"The bear sniffed you before it attacked, which is a scientific note " +
|
||||||
|
"that does not help you medically but which you have retained " +
|
||||||
|
"because it seemed important in the moment. " +
|
||||||
|
"The bear then attacked. Your {armor} is worse. You are home.",
|
||||||
|
|
||||||
|
"A bear, unannounced, from the undergrowth. " +
|
||||||
|
"The undergrowth was thick enough that you had no warning, " +
|
||||||
|
"which is relevant information about {location}'s foraging conditions " +
|
||||||
|
"that was not included in the briefing and absolutely should have been.",
|
||||||
|
|
||||||
|
"The bear was smaller than bears are supposed to be. " +
|
||||||
|
"This made you underestimate it. Smaller bears are faster. " +
|
||||||
|
"This is not information that helped you in sequence — " +
|
||||||
|
"it's information that arrived after the sequence concluded, " +
|
||||||
|
"while you were sitting at home with {damage} less armor condition " +
|
||||||
|
"and no loot and a new policy on small bears.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var ForagingRiver = []string{
|
||||||
|
"You needed to cross the river. The river had opinions. Strong ones. " +
|
||||||
|
"You lost {item} to the current, your boots are ruined, " +
|
||||||
|
"and you have gained a new, lasting, personal respect for bridges. " +
|
||||||
|
"Oregon Trail sends its regards. It always does.",
|
||||||
|
|
||||||
|
"A simple ford. Knee-deep at most, you thought. " +
|
||||||
|
"The river was not at most. The river was considerably more than at most " +
|
||||||
|
"and had been since the rains last week which you did not know about " +
|
||||||
|
"because you did not check. You made it across. {item} did not. " +
|
||||||
|
"Your boots are now decorative. Your socks are a closed topic.",
|
||||||
|
|
||||||
|
"The current took {item} right out of your hands. Gone. " +
|
||||||
|
"Just gone. Immediately gone in the way of things taken by fast water, " +
|
||||||
|
"which is completely gone with no warning and no recovery. " +
|
||||||
|
"Then it took your footing. Then your remaining dignity. " +
|
||||||
|
"You sat on the far bank for a while and poured water out of your boots.",
|
||||||
|
|
||||||
|
"You have lost {item} and {item_2} to the river today. " +
|
||||||
|
"The river has gained {item} and {item_2}. " +
|
||||||
|
"The river does not need these things. The river doesn't need anything. " +
|
||||||
|
"The river is a river. You needed those things. " +
|
||||||
|
"The exchange is the worst kind of one-sided.",
|
||||||
|
|
||||||
|
"Stepping stones. You were using the stepping stones. " +
|
||||||
|
"The stepping stones were moss-covered in a way " +
|
||||||
|
"that was visible from where you were standing " +
|
||||||
|
"and that you assessed as 'manageable.' " +
|
||||||
|
"The moss had a different assessment. You met the river. " +
|
||||||
|
"The river kept {item}.",
|
||||||
|
|
||||||
|
"The crossing was going well until step seven. " +
|
||||||
|
"Steps one through six: fine. Step seven: a rock that moved. " +
|
||||||
|
"Not a stepping stone. A rock that happened to be there. " +
|
||||||
|
"It moved. You moved differently. The {item} in your right hand " +
|
||||||
|
"went into the river. The {item} in your left hand stayed. " +
|
||||||
|
"Two items entered the crossing. One item came out.",
|
||||||
|
|
||||||
|
"You crossed the river and then the river crossed you " +
|
||||||
|
"on the way back, heavier with {item}, moving slower, " +
|
||||||
|
"caring more about not dropping things and therefore " +
|
||||||
|
"paying less attention to where your feet were. " +
|
||||||
|
"The feet found out where they were. The river was where they were.",
|
||||||
|
|
||||||
|
"The pack was too heavy for the crossing depth. " +
|
||||||
|
"You knew this at step three. You continued anyway, " +
|
||||||
|
"running the numbers on whether you could make it " +
|
||||||
|
"while simultaneously making it, which split your attention " +
|
||||||
|
"in a way the current noticed and exploited.",
|
||||||
|
|
||||||
|
"You've crossed this river before. The river was lower before. " +
|
||||||
|
"This river and your memory of this river are different rivers " +
|
||||||
|
"at different water levels and your memory does not have {item} " +
|
||||||
|
"at the bottom of it. The real river does now.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var ForagingGoodHaul = map[int][]string{
|
||||||
|
|
||||||
|
1: {
|
||||||
|
"A peaceful morning in {location}. Birds. Dappled light. {item} in quantity. " +
|
||||||
|
"€{value} worth of completely non-threatening nature doing exactly " +
|
||||||
|
"what nature is supposed to do. You are suspicious. " +
|
||||||
|
"Nothing bad happened. Yet. Check tomorrow.",
|
||||||
|
|
||||||
|
"You returned from {location} with {item} worth €{value}, zero injuries, " +
|
||||||
|
"one mildly alarming encounter with a mushroom that turned out to be fine, " +
|
||||||
|
"and the quiet satisfaction of a morning where the worst thing " +
|
||||||
|
"that happened was briefly being in the same meadow as a grumpy deer.",
|
||||||
|
|
||||||
|
"In and out. Clean. Profitable. Nobody died, nothing stung you, " +
|
||||||
|
"no bears had opinions about your presence. " +
|
||||||
|
"{item} worth €{value} and you're home before lunch. " +
|
||||||
|
"This is the life. Savour it. It won't last.",
|
||||||
|
|
||||||
|
"You have collected {item}. Your inventory has increased. " +
|
||||||
|
"There is a fanfare in your heart — brief, sincere, the kind that plays " +
|
||||||
|
"when you pick up something good in a world that is mostly trying to kill you. " +
|
||||||
|
"€{value}. {xp} XP. The fanfare was earned.",
|
||||||
|
|
||||||
|
"A wild {item} appeared. You used FORAGE. It was super effective. " +
|
||||||
|
"€{value} worth of nature obtained without significant trauma. " +
|
||||||
|
"Your Foraging Skill is considering leveling up. It's thinking about it. " +
|
||||||
|
"It's not ready yet but it's thinking.",
|
||||||
|
|
||||||
|
"The meadow was generous today. {item} in the quantity that makes " +
|
||||||
|
"you feel like the meadow wanted you to have it, " +
|
||||||
|
"which it did not, meadows don't have intentions, " +
|
||||||
|
"but the feeling was there and €{value} is €{value}.",
|
||||||
|
|
||||||
|
"Good haul. Better than expected. {item} worth €{value} from {location} " +
|
||||||
|
"with {xp} XP and nothing bad attached to it. " +
|
||||||
|
"Just: went out, found things, came home. " +
|
||||||
|
"Simple. Good. Uncomplicatedly good. Write this date down.",
|
||||||
|
},
|
||||||
|
|
||||||
|
2: {
|
||||||
|
"The Old Forest delivered today. {item} in the kind of quantity " +
|
||||||
|
"that makes you feel briefly competent as a forager, " +
|
||||||
|
"which is a feeling worth pursuing because it doesn't happen constantly. " +
|
||||||
|
"€{value}. {xp} XP. The forest was cooperative. You were prepared. " +
|
||||||
|
"These two things aligned.",
|
||||||
|
|
||||||
|
"Hardwood and wild fruit and one significant mushroom cluster " +
|
||||||
|
"that you correctly identified as edible, which is the forager's " +
|
||||||
|
"primary professional requirement and one you have now met. " +
|
||||||
|
"{item} worth €{value}. No hornets. A successful day in the Old Forest.",
|
||||||
|
|
||||||
|
"The Old Forest is old in ways that mean it has more things in it " +
|
||||||
|
"than newer forests, which is either comforting or alarming depending on " +
|
||||||
|
"what the things are. Today the things were {item} worth €{value}. " +
|
||||||
|
"A good day for the old-things-in-old-forests category.",
|
||||||
|
},
|
||||||
|
|
||||||
|
3: {
|
||||||
|
"Ancient timber. Real ancient timber — the kind where the grain is so tight " +
|
||||||
|
"you can count centuries in it. You found it, took what you could carry, " +
|
||||||
|
"left the rest undisturbed because the Ancient Grove has ways of noticing " +
|
||||||
|
"when you take too much. {item} worth €{value}. {xp} XP. " +
|
||||||
|
"The grove noticed you. The grove allowed it. Don't push it.",
|
||||||
|
|
||||||
|
"The rare herb was where the foraging charts said it would be, " +
|
||||||
|
"which happens less often than you'd hope given that the charts exist. " +
|
||||||
|
"{item} worth €{value}. The Ancient Grove was quiet today — " +
|
||||||
|
"present, watchful in the way old places are watchful, " +
|
||||||
|
"but not hostile. You took the herbs and left quickly anyway. Good instinct.",
|
||||||
|
},
|
||||||
|
|
||||||
|
4: {
|
||||||
|
"The Deep Jungle doesn't give up exotic wood easily. " +
|
||||||
|
"It gave it up today, which means you either got lucky or the jungle " +
|
||||||
|
"made a decision about you. You prefer not to examine which. " +
|
||||||
|
"{item} worth €{value}. {xp} XP. Home before something changed its mind.",
|
||||||
|
|
||||||
|
"Tropical fruits and something that might be spores " +
|
||||||
|
"that you have chosen to identify as 'probably fine' " +
|
||||||
|
"based on the colour and smell and a general optimism about your choices. " +
|
||||||
|
"{item} worth €{value}. The jungle was loud today but not at you. " +
|
||||||
|
"You'll take it.",
|
||||||
|
},
|
||||||
|
|
||||||
|
5: {
|
||||||
|
"The Primal Wilds gave you Starfruit. You know what Starfruit means " +
|
||||||
|
"in the Primal Wilds — it means the place you're standing " +
|
||||||
|
"is one of the few places in the world where Starfruit grows " +
|
||||||
|
"and you were there on a day it was accessible. " +
|
||||||
|
"{item} worth €{value}. {xp} XP. " +
|
||||||
|
"This will not happen on the same terms again.",
|
||||||
|
|
||||||
|
"Spirit Herbs. Primordial Bark. {item} worth €{value} " +
|
||||||
|
"from the furthest reaches of the Primal Wilds on a day " +
|
||||||
|
"when the Wilds were, against all precedent, cooperative. " +
|
||||||
|
"{xp} XP. You are home. The Wilds are still primal. " +
|
||||||
|
"Today they were primal in your favour.",
|
||||||
|
},
|
||||||
|
}
|
||||||
498
internal/plugin/adventure_flavor_treasure.go
Normal file
498
internal/plugin/adventure_flavor_treasure.go
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
// ── TREASURE DISCOVERY ────────────────────────────────────────────────────────
|
||||||
|
// Fired when a rare treasure drops alongside normal loot resolution.
|
||||||
|
// The treasure DM arrives after the normal outcome DM.
|
||||||
|
// Tone scales by tier: Tier 1 is unhinged excitement for garbage.
|
||||||
|
// Tier 5 is one paragraph, terse, slightly wrong-feeling.
|
||||||
|
// {treasure_name}, {bonus_desc}, {location} are substituted at send time.
|
||||||
|
|
||||||
|
var TreasureDiscovery = map[int][]string{
|
||||||
|
|
||||||
|
// ── TIER 1 ───────────────────────────────────────────────────────────────
|
||||||
|
// These are bad items described as world-historical finds.
|
||||||
|
// The bot has fully lost the plot. The item is a button or a bent coin.
|
||||||
|
// The description insists otherwise with complete sincerity.
|
||||||
|
1: {
|
||||||
|
"WAIT.\n\n" +
|
||||||
|
"WAIT. Something else was in there.\n\n" +
|
||||||
|
"Among the rat droppings and damp and general misery of {location}, " +
|
||||||
|
"you have found THE {treasure_name}.\n\n" +
|
||||||
|
"Historians — future historians, the ones who will one day write " +
|
||||||
|
"about THIS SPECIFIC MOMENT in a dungeon that smells like wet rat — " +
|
||||||
|
"will call this a turning point. The turning point has happened. " +
|
||||||
|
"You are the turning point.\n\n" +
|
||||||
|
"BONUS: {bonus_desc}. The universe has acknowledged you. " +
|
||||||
|
"The acknowledgement smells faintly of damp. That's fine. It's still acknowledgement.",
|
||||||
|
|
||||||
|
"ADDITIONAL FINDING.\n\n" +
|
||||||
|
"You almost missed it. You ALMOST MISSED IT. " +
|
||||||
|
"In the corner, behind the loose stone, wrapped in what was once " +
|
||||||
|
"probably cloth and is now mostly an aspiration: the {treasure_name}.\n\n" +
|
||||||
|
"This is real. This is a real thing that is now in your possession. " +
|
||||||
|
"You have {treasure_name} and nobody else does and the rats " +
|
||||||
|
"were RIGHT THERE and didn't even know what they were sitting next to.\n\n" +
|
||||||
|
"BONUS: {bonus_desc}.\n" +
|
||||||
|
"You're welcome for finding it. You found it. It's found. It's yours.",
|
||||||
|
|
||||||
|
"STOP EVERYTHING ELSE.\n\n" +
|
||||||
|
"The loot is fine. The loot can wait. " +
|
||||||
|
"There is something more important than the loot and it is the {treasure_name} " +
|
||||||
|
"which you have just found in {location} and which is NOW YOURS.\n\n" +
|
||||||
|
"Is it large? No. Is it impressive to look at? " +
|
||||||
|
"That depends on what you're looking for, and what you're looking for is HISTORY, " +
|
||||||
|
"and history is right here in your hand and it is the {treasure_name} " +
|
||||||
|
"and it provides {bonus_desc} and that is not nothing.\n\n" +
|
||||||
|
"That is SOMETHING. That is EXACTLY SOMETHING.",
|
||||||
|
|
||||||
|
"A RARE TREASURE HAS BEEN FOUND.\n\n" +
|
||||||
|
"Category: Incredible.\n" +
|
||||||
|
"Location found: {location}, which will now be famous for this.\n" +
|
||||||
|
"Item: {treasure_name}.\n" +
|
||||||
|
"Bonus: {bonus_desc}.\n" +
|
||||||
|
"Rarity: extremely rare, extremely exciting, extremely yours.\n\n" +
|
||||||
|
"The rats didn't find it. Other adventurers didn't find it. " +
|
||||||
|
"You found it. In a damp cellar. Wearing the Goddamn Offensive Helmet, " +
|
||||||
|
"possibly, depending on your gear situation. " +
|
||||||
|
"The helmet doesn't diminish this. Nothing diminishes this.",
|
||||||
|
|
||||||
|
"Oh.\n\nOH.\n\n" +
|
||||||
|
"Under the thing, behind the other thing, in the part of {location} " +
|
||||||
|
"that smelled worst and seemed least likely to contain anything good:\n\n" +
|
||||||
|
"The {treasure_name}.\n\n" +
|
||||||
|
"You've read about items like this. Well — you haven't, specifically, " +
|
||||||
|
"because items like this aren't in books, because people who find them " +
|
||||||
|
"don't write books, they just carry the items and have better outcomes. " +
|
||||||
|
"Now you're one of those people.\n\n" +
|
||||||
|
"BONUS: {bonus_desc}. Go. Go tell someone. " +
|
||||||
|
"Tell everyone. They won't understand. Tell them anyway.",
|
||||||
|
|
||||||
|
"TREASURE ALERT. RARE TREASURE ALERT.\n\n" +
|
||||||
|
"This is not a drill. The {treasure_name} has been found. " +
|
||||||
|
"In {location}. By you. Today.\n\n" +
|
||||||
|
"The {treasure_name} provides {bonus_desc}, which is a passive bonus " +
|
||||||
|
"that will apply every single day that you hold it, " +
|
||||||
|
"which should be every single day going forward, " +
|
||||||
|
"because why would you ever not hold it, " +
|
||||||
|
"because it is THE {treasure_name} and you FOUND IT.\n\n" +
|
||||||
|
"The rats had no idea. They were RIGHT THERE.",
|
||||||
|
|
||||||
|
"Hidden. It was hidden. Not dramatically hidden — " +
|
||||||
|
"not behind a puzzle or a boss or a sealed door — " +
|
||||||
|
"just quietly hidden in a way that means " +
|
||||||
|
"most people walked past it without looking correctly.\n\n" +
|
||||||
|
"You looked correctly. You found the {treasure_name}.\n\n" +
|
||||||
|
"BONUS: {bonus_desc}. " +
|
||||||
|
"The {location} has been keeping this from everyone. " +
|
||||||
|
"It is no longer keeping it from you. " +
|
||||||
|
"You have it. It's in your inventory right now. Go look.",
|
||||||
|
|
||||||
|
"Congratulations are in order and they are being given now.\n\n" +
|
||||||
|
"Congratulations.\n\n" +
|
||||||
|
"You have found the {treasure_name} in {location} " +
|
||||||
|
"and you have done so under conditions that were not ideal " +
|
||||||
|
"(they are never ideal in {location}) " +
|
||||||
|
"and the {treasure_name} provides {bonus_desc} " +
|
||||||
|
"and this is one of the better things that has happened to you.\n\n" +
|
||||||
|
"The bar for 'better things that have happened to you' includes some dark entries. " +
|
||||||
|
"This one is good. Genuinely good. Take the win.",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── TIER 2 ───────────────────────────────────────────────────────────────
|
||||||
|
// Still excited, but dialing back to merely very enthusiastic.
|
||||||
|
// The item is clearly better than a button. The description knows it.
|
||||||
|
2: {
|
||||||
|
"Something else came out of {location} today.\n\n" +
|
||||||
|
"Beyond the normal loot, beyond what was expected: the {treasure_name}.\n\n" +
|
||||||
|
"This is a real find. Not a bent button or an ambiguous artifact — " +
|
||||||
|
"a real, useful, genuinely interesting thing that provides {bonus_desc} " +
|
||||||
|
"as long as you hold it, which you should do indefinitely.\n\n" +
|
||||||
|
"The goblins had no idea what they were sitting on. " +
|
||||||
|
"Classic goblins.",
|
||||||
|
|
||||||
|
"The {treasure_name}. In {location}.\n\n" +
|
||||||
|
"You found it past the goblins, past the traps, past the part of the dungeon " +
|
||||||
|
"that most people turn around in because it gets uncomfortable. " +
|
||||||
|
"You didn't turn around. The {treasure_name} was the reason not to turn around, " +
|
||||||
|
"even though you didn't know that yet.\n\n" +
|
||||||
|
"BONUS: {bonus_desc}. " +
|
||||||
|
"Hold onto it. This is not a thing you found every day.",
|
||||||
|
|
||||||
|
"A rare treasure. Actual rarity — not 'this seems unusual' rarity " +
|
||||||
|
"but statistically unlikely, specifically here, specifically today, specifically you.\n\n" +
|
||||||
|
"The {treasure_name} from {location}.\n" +
|
||||||
|
"Bonus: {bonus_desc}.\n\n" +
|
||||||
|
"The goblins were between you and this. You went through the goblins. " +
|
||||||
|
"In retrospect the goblins were guarding this, badly, " +
|
||||||
|
"without knowing it, which is the best kind of guarding " +
|
||||||
|
"from your perspective.",
|
||||||
|
|
||||||
|
"There it was. Sitting in a room in {location} that didn't announce itself, " +
|
||||||
|
"didn't have dramatic lighting, didn't have a boss standing in front of it. " +
|
||||||
|
"Just sitting there. The {treasure_name}.\n\n" +
|
||||||
|
"You almost didn't check the room. You almost walked past it. " +
|
||||||
|
"You didn't walk past it.\n\n" +
|
||||||
|
"BONUS: {bonus_desc}. " +
|
||||||
|
"The almost doesn't matter. You have it.",
|
||||||
|
|
||||||
|
"Rare find from {location}: the {treasure_name}.\n\n" +
|
||||||
|
"This doesn't happen every run. It doesn't happen most runs. " +
|
||||||
|
"Today it happened, and you were the one it happened to, " +
|
||||||
|
"and {bonus_desc} is now a permanent feature of your operation " +
|
||||||
|
"as long as this stays in your inventory.\n\n" +
|
||||||
|
"Keep it in your inventory.",
|
||||||
|
|
||||||
|
"Beyond the loot, behind what looked like a dead end, " +
|
||||||
|
"in the part of {location} where the map gets vague:\n\n" +
|
||||||
|
"The {treasure_name}.\n\n" +
|
||||||
|
"BONUS: {bonus_desc}.\n\n" +
|
||||||
|
"Rare. Real. Yours. " +
|
||||||
|
"The kobolds who were using that room as a shortcut " +
|
||||||
|
"had been walking past it for years.",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── TIER 3 ───────────────────────────────────────────────────────────────
|
||||||
|
// Matter-of-fact but notable. The item is genuinely interesting.
|
||||||
|
// The description respects it without losing the voice.
|
||||||
|
3: {
|
||||||
|
"The {treasure_name}.\n\n" +
|
||||||
|
"It was in {location}, past everything that tried to stop you getting to it. " +
|
||||||
|
"The things that tried to stop you did not fully stop you. " +
|
||||||
|
"The {treasure_name} was what they were protecting and now it's yours.\n\n" +
|
||||||
|
"BONUS: {bonus_desc}. " +
|
||||||
|
"This is a significant find. The crypt knew what it had.",
|
||||||
|
|
||||||
|
"Past the Draugr, past the skeletons, past the ghost that let you through " +
|
||||||
|
"for reasons it didn't share: the {treasure_name}.\n\n" +
|
||||||
|
"It was waiting. Not dramatically — things don't wait dramatically in crypts, " +
|
||||||
|
"they wait in the dark, quietly, correctly. " +
|
||||||
|
"The {treasure_name} waited correctly and now it's yours.\n\n" +
|
||||||
|
"BONUS: {bonus_desc}. Keep it.",
|
||||||
|
|
||||||
|
"A rare treasure from {location}.\n\n" +
|
||||||
|
"The {treasure_name}. {bonus_desc}.\n\n" +
|
||||||
|
"At this tier, rare means genuinely rare — not 'unusual' rare, " +
|
||||||
|
"not 'interesting' rare, but 'most people who come here don't come back with this' rare. " +
|
||||||
|
"You came back with this. Note the difference.",
|
||||||
|
|
||||||
|
"The {treasure_name} was in {location}.\n" +
|
||||||
|
"You were in {location}.\n" +
|
||||||
|
"These two facts are now connected in a way that benefits you.\n\n" +
|
||||||
|
"BONUS: {bonus_desc}. " +
|
||||||
|
"The benefit is ongoing. The {treasure_name} stays in your inventory " +
|
||||||
|
"and the benefit continues. Don't lose it.",
|
||||||
|
|
||||||
|
"Something was in the inner chamber of {location} " +
|
||||||
|
"that the outer chambers were keeping people away from.\n\n" +
|
||||||
|
"The {treasure_name}.\n\n" +
|
||||||
|
"You got to the inner chamber. You have the thing the outer chambers were hiding. " +
|
||||||
|
"BONUS: {bonus_desc}. " +
|
||||||
|
"The crypt is less well-guarded now than when you arrived.",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── TIER 4 ───────────────────────────────────────────────────────────────
|
||||||
|
// Understated. The item has gravity. The description doesn't oversell it
|
||||||
|
// because it doesn't need to. One or two sentences of context.
|
||||||
|
4: {
|
||||||
|
"The {treasure_name}.\n\n" +
|
||||||
|
"You found it in {location}. It came with you.\n" +
|
||||||
|
"BONUS: {bonus_desc}.\n\n" +
|
||||||
|
"At this tier, 'rare' is a significant understatement. " +
|
||||||
|
"Hold onto it.",
|
||||||
|
|
||||||
|
"Among everything that came out of {location} today, " +
|
||||||
|
"the {treasure_name} is the thing that matters most.\n\n" +
|
||||||
|
"BONUS: {bonus_desc}.\n\n" +
|
||||||
|
"It's been in there for a long time. " +
|
||||||
|
"It came out with you. That means something, probably.",
|
||||||
|
|
||||||
|
"The {treasure_name} was in {location}.\n" +
|
||||||
|
"It is now in your inventory.\n" +
|
||||||
|
"BONUS: {bonus_desc}.\n\n" +
|
||||||
|
"This is not the kind of item that turns up in Tier 1 dungeons. " +
|
||||||
|
"This is not the kind of item that turns up often anywhere. " +
|
||||||
|
"You are at Tier 4, doing Tier 4 things, and this is one of them.",
|
||||||
|
|
||||||
|
"A Tier 4 rare find.\n\n" +
|
||||||
|
"The {treasure_name} from {location}.\n" +
|
||||||
|
"BONUS: {bonus_desc}.\n\n" +
|
||||||
|
"The Troll Bridge Depths held onto this for a long time. " +
|
||||||
|
"You are the reason it stopped holding on.",
|
||||||
|
|
||||||
|
"The {treasure_name}.\n\n" +
|
||||||
|
"It has history. It doesn't need to explain its history to you. " +
|
||||||
|
"The bonus is {bonus_desc} and that's the relevant part right now.\n\n" +
|
||||||
|
"The rest of the history is available to you if you look. " +
|
||||||
|
"Most people who look wish they hadn't. " +
|
||||||
|
"Most people look anyway.",
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── TIER 5 ───────────────────────────────────────────────────────────────
|
||||||
|
// Terse. One paragraph. Slightly wrong-feeling, like the item is aware.
|
||||||
|
// No exclamation marks. No enthusiasm. Just weight.
|
||||||
|
5: {
|
||||||
|
"The {treasure_name}.\n\n" +
|
||||||
|
"You found it in the Abyssal Maw. It found you in the Abyssal Maw. " +
|
||||||
|
"The distinction matters less at depth.\n" +
|
||||||
|
"BONUS: {bonus_desc}.\n\n" +
|
||||||
|
"It came with you. Some things don't come with you. This one did. " +
|
||||||
|
"Think about what that means.",
|
||||||
|
|
||||||
|
"The {treasure_name} is in your inventory.\n\n" +
|
||||||
|
"BONUS: {bonus_desc}.\n\n" +
|
||||||
|
"This is a Tier 5 rare. The Abyssal Maw doesn't give these up. " +
|
||||||
|
"The Abyssal Maw gave this one up. " +
|
||||||
|
"That's a sentence worth sitting with.",
|
||||||
|
|
||||||
|
"You have the {treasure_name}.\n\n" +
|
||||||
|
"BONUS: {bonus_desc}.\n\n" +
|
||||||
|
"It was in the Abyssal Maw. The things that were between you and it " +
|
||||||
|
"are not between anything and anything anymore. " +
|
||||||
|
"The Maw is noting this. So is the item.",
|
||||||
|
|
||||||
|
"The {treasure_name}.\n\n" +
|
||||||
|
"It's warm. It was warm when you found it, which it shouldn't be, " +
|
||||||
|
"which is information you've decided not to investigate right now.\n" +
|
||||||
|
"BONUS: {bonus_desc}.\n\n" +
|
||||||
|
"Right now is for coming home. " +
|
||||||
|
"The investigation can happen later.",
|
||||||
|
|
||||||
|
"Rare. Tier 5 rare, which means: most people who try for this don't get it. " +
|
||||||
|
"Some of them tried for a very long time.\n\n" +
|
||||||
|
"The {treasure_name}. {bonus_desc}.\n\n" +
|
||||||
|
"You have it. " +
|
||||||
|
"Keep it somewhere it won't be lost. " +
|
||||||
|
"The Abyssal Maw does not give second chances.",
|
||||||
|
|
||||||
|
"The {treasure_name} is yours.\n\n" +
|
||||||
|
"BONUS: {bonus_desc}.\n\n" +
|
||||||
|
"The Abyssal Maw had it. You have it now. " +
|
||||||
|
"The Maw is aware of the transfer. " +
|
||||||
|
"The Maw is considering its position. " +
|
||||||
|
"You should not be in the Maw when it finishes considering.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Room announcement for Tier 5 treasure finds only.
|
||||||
|
// Posted to the game room, separate from the player's DM.
|
||||||
|
// Terse. One line. No fanfare. The rarity does the work.
|
||||||
|
var TreasureRoomAnnouncement = []string{
|
||||||
|
"🔴 {name} found the {treasure_name} in {location}. It came with them.",
|
||||||
|
"🔴 {name} recovered the {treasure_name} from {location}. The Maw noticed.",
|
||||||
|
"🔴 {name} has the {treasure_name}. They found it in {location}. It let them take it.",
|
||||||
|
"🔴 The {treasure_name} has left {location}. {name} has it now.",
|
||||||
|
"🔴 {name} came out of {location} with the {treasure_name}. They came out.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inventory cap — player found a 4th treasure and must discard one.
|
||||||
|
var TreasureInventoryCap = []string{
|
||||||
|
"You found the {treasure_name}.\n\n" +
|
||||||
|
"This is good news. The bad news is your pockets.\n\n" +
|
||||||
|
"You are already carrying three treasures and your inventory " +
|
||||||
|
"has filed a formal structural objection:\n\n" +
|
||||||
|
"1. {treasure_1} — {bonus_1}\n" +
|
||||||
|
"2. {treasure_2} — {bonus_2}\n" +
|
||||||
|
"3. {treasure_3} — {bonus_3}\n\n" +
|
||||||
|
"You must discard one to make room. Reply with 1, 2, or 3.\n" +
|
||||||
|
"The discarded item leaves forever and will take it personally.\n\n" +
|
||||||
|
"Or reply 'keep' to leave the {treasure_name} behind.\n" +
|
||||||
|
"It will stay where you found it.\n" +
|
||||||
|
"It will be there when you come back.\n" +
|
||||||
|
"It will remember that you left it.",
|
||||||
|
|
||||||
|
"The {treasure_name}. Here. Now. Yours, conditionally.\n\n" +
|
||||||
|
"The condition is that you're already carrying three treasures " +
|
||||||
|
"and the universe has a firm three-treasure policy " +
|
||||||
|
"that it enforces through inventory mechanics.\n\n" +
|
||||||
|
"Current treasures:\n" +
|
||||||
|
"1. {treasure_1} — {bonus_1}\n" +
|
||||||
|
"2. {treasure_2} — {bonus_2}\n" +
|
||||||
|
"3. {treasure_3} — {bonus_3}\n\n" +
|
||||||
|
"One of these goes. Reply 1, 2, or 3 to discard it.\n" +
|
||||||
|
"Or reply 'keep' to leave the {treasure_name} in {location}.\n" +
|
||||||
|
"The {treasure_name} will wait. It has nowhere else to be.",
|
||||||
|
|
||||||
|
"Good news and a problem, arriving together as they usually do.\n\n" +
|
||||||
|
"Good news: the {treasure_name}.\n" +
|
||||||
|
"Problem: you have three treasures already and can only hold three.\n\n" +
|
||||||
|
"1. {treasure_1} — {bonus_1}\n" +
|
||||||
|
"2. {treasure_2} — {bonus_2}\n" +
|
||||||
|
"3. {treasure_3} — {bonus_3}\n\n" +
|
||||||
|
"Reply 1, 2, or 3 to discard one and take the {treasure_name}.\n" +
|
||||||
|
"Reply 'keep' to leave the {treasure_name} where it is.\n\n" +
|
||||||
|
"Either way: you've found it. " +
|
||||||
|
"The finding counts whether you take it or not.\n" +
|
||||||
|
"The bonus doesn't apply unless you take it.\n" +
|
||||||
|
"Just noting that.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DAILY SUMMARY ONE-LINERS ──────────────────────────────────────────────────
|
||||||
|
// Short outcome summaries for the room-facing daily summary.
|
||||||
|
// These replace the full narrative for the summary view.
|
||||||
|
// Format: one punchy line, past tense, fits in a table row.
|
||||||
|
// Substitutions: {name}, {location}, {item}, {value}, {outcome}
|
||||||
|
|
||||||
|
var SummaryDungeonSuccess = []string{
|
||||||
|
"Found {item} worth €{value} in {location}. Made it out.",
|
||||||
|
"Cleared {location}. Retrieved {item}, €{value}. Home before dark.",
|
||||||
|
"{location}: success. {item} at €{value}. No hospitalizations.",
|
||||||
|
"Went into {location}. Came back with {item}. €{value}. Alive.",
|
||||||
|
"Extracted {item} worth €{value} from {location}. Equipment is worse. Net positive.",
|
||||||
|
"{item} from {location}. €{value}. The dungeon has been informed.",
|
||||||
|
"Profitable run through {location}. {item}, €{value}, {xp} XP.",
|
||||||
|
"{location} cleared, partially. {item} recovered. €{value} assessed.",
|
||||||
|
"Found {item} in {location}. €{value}. Didn't die. A complete run.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var SummaryDungeonExceptional = []string{
|
||||||
|
"EXCEPTIONAL run in {location}. {item} at €{value}. Ask them about it.",
|
||||||
|
"{location} boss dropped {item} worth €{value}. {name} is having a very good day.",
|
||||||
|
"Inner chamber of {location} reached. {item}, €{value}. Historic.",
|
||||||
|
"Critical run. {location}. {item} at €{value}. Screenshot this.",
|
||||||
|
"{item} worth €{value} from the depths of {location}. The dungeon is embarrassed.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var SummaryDungeonDeath = []string{
|
||||||
|
"Died in {location}. Back in {hours}h. The {location} remains standing.",
|
||||||
|
"{location}: death. Equipment damaged. Healthcare involved. {hours}h respawn.",
|
||||||
|
"Did not survive {location}. The {location} did. {hours}h recovery.",
|
||||||
|
"Killed in {location}. American healthcare has the rest. {hours}h.",
|
||||||
|
"{location} wins this round. {name} recovers in {hours}h.",
|
||||||
|
"Dead. {location}. {hours} hours. The equipment is worse.",
|
||||||
|
"{name} lost the argument with {location}. Healthcare is mediating. {hours}h.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var SummaryDungeonEmpty = []string{
|
||||||
|
"Searched {location}. Found nothing. Came home.",
|
||||||
|
"{location}: empty. Goblins/skeletons/trolls present. Treasure absent.",
|
||||||
|
"Nothing in {location} today. The XP was also mostly absent.",
|
||||||
|
"Three hours in {location}. Zero haul. One pot smashed out of principle.",
|
||||||
|
"{location} gave up nothing. {name} gave up on {location} eventually.",
|
||||||
|
"Cleared {location} of everything that could be killed. Nothing else was there.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var SummaryMiningSuccess = []string{
|
||||||
|
"Extracted {ore} worth €{value} from {location}. The mountain cooperated.",
|
||||||
|
"{location}: {ore}, €{value}. One near-miss. Positive outcome.",
|
||||||
|
"Mining run at {location}: {ore} recovered, €{value}, no cave-ins.",
|
||||||
|
"Came back from {location} with {ore} and €{value} and a worse {tool}.",
|
||||||
|
"{ore} from {location}. €{value}. The back is filing a complaint.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var SummaryMiningDeath = []string{
|
||||||
|
"Cave-in. {location}. Healthcare called. {hours}h.",
|
||||||
|
"Died in {location}. Mining death. Rarer than dungeon death. Still death. {hours}h.",
|
||||||
|
"{location} expressed structural concerns physically. {name}: {hours}h recovery.",
|
||||||
|
"The {location} ceiling had a perspective. {name}: medical. {hours}h.",
|
||||||
|
"Dead in {location}. The ore is still in there. {hours}h.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var SummaryMiningEmpty = []string{
|
||||||
|
"Mined {location} for hours. The mountain was empty. The back was not.",
|
||||||
|
"{location}: nothing worth taking. Three hours of nothing worth taking.",
|
||||||
|
"Empty vein in {location}. Copper-coloured rock, wall to wall.",
|
||||||
|
"The {location} had no ore today. Just rock. Very thorough rock.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var SummaryForagingSuccess = []string{
|
||||||
|
"Foraged {item} from {location}. €{value}. No hornets.",
|
||||||
|
"{location}: {item}, €{value}. A peaceful morning. Suspicious in retrospect.",
|
||||||
|
"Returned from {location} with {item} worth €{value} and zero injuries. Good day.",
|
||||||
|
"Clean haul from {location}. {item}, €{value}. The forest cooperated.",
|
||||||
|
"{item} from {location}. €{value}. One bear made eye contact. Moved on.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var SummaryForagingDeath = []string{
|
||||||
|
"Died foraging. In {location}. Yes, foraging. {hours}h.",
|
||||||
|
"{location} produced a fatal outcome via non-combat means. {hours}h.",
|
||||||
|
"Dead in {location}. Bear/hornets/river/tree/mushroom involvement. {hours}h.",
|
||||||
|
"Foraging death. {location}. The forest won. {hours}h.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var SummaryForagingHornets = []string{
|
||||||
|
"Hornets. {location}. No loot. Significant swelling.",
|
||||||
|
"Found hornets in {location}. The hornets found back.",
|
||||||
|
"{location}: hornets. No haul. Home eventually.",
|
||||||
|
"Hornets in {location}. As always. No loot. Many regrets.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var SummaryForagingBear = []string{
|
||||||
|
"Bear encounter in {location}. Armor damaged. Loot lost. Home.",
|
||||||
|
"{location}: bear. Equipment worse. Running occurred. Home.",
|
||||||
|
"The {location} bear had opinions. Armor absorbed the first opinion.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var SummaryForagingRiver = []string{
|
||||||
|
"River crossing in {location}. Lost {item}. Boots ruined. Oregon Trail.",
|
||||||
|
"The river kept {item}. {name} kept everything else, soaking wet.",
|
||||||
|
"{location} river: took {item}, gave back nothing. Boots are planters now.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var SummaryResting = []string{
|
||||||
|
"Did not leave the house. Gained nothing. Avoided death. Low bar.",
|
||||||
|
"Resting today. XP: zero. Loot: zero. Alive: yes.",
|
||||||
|
"No action. Hovel. Wall. Nothing. Home.",
|
||||||
|
"Sat this one out. The dungeons noticed. The dungeons don't care.",
|
||||||
|
"Rest day. TwinBee noticed.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var SummaryDead = []string{
|
||||||
|
"Currently dead. Returns {time} UTC.",
|
||||||
|
"In healthcare. {time} UTC release expected.",
|
||||||
|
"Recovering. The insurance paperwork is ongoing. Back {time} UTC.",
|
||||||
|
"Dead. Healthcare. {time}. The usual.",
|
||||||
|
"Not available. Medical reasons. {time} UTC.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standout lines — bottom of the daily summary, one per day.
|
||||||
|
// Picks the most notable outcome across all adventurers.
|
||||||
|
var SummaryStandoutGood = []string{
|
||||||
|
"🏆 {name} found {item} worth €{value} in {location}. A good day by any measure.",
|
||||||
|
"🏆 {name} had the best haul today: {item}, €{value}, {location}.",
|
||||||
|
"🏆 Today's standout: {name}, {location}, {item} at €{value}. Well done.",
|
||||||
|
"🏆 {name} cleared {location} and came back with €{value}. Leading today.",
|
||||||
|
"🏆 Best outcome: {name}, €{value} from {location}. Not bad at all.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var SummaryStandoutDeath = []string{
|
||||||
|
"💀 {name} died in {location}. The {location} is getting too confident.",
|
||||||
|
"💀 {name} lost to {location}. The rats/goblins/trolls send their regards.",
|
||||||
|
"💀 Notable loss: {name} in {location}. Healthcare is familiar with the file.",
|
||||||
|
"💀 {name} did not survive {location} today. A learning experience. Expensive.",
|
||||||
|
"💀 {location} claimed {name}. {hours}h recovery. The dungeon has been noted.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var SummaryStandoutTreasure = []string{
|
||||||
|
"✨ {name} found the {treasure_name} in {location}. Rare. Real. Theirs.",
|
||||||
|
"✨ Rare find: {name} recovered the {treasure_name} from {location}.",
|
||||||
|
"✨ {name} found a rare treasure today. The {treasure_name}. In {location}.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var SummaryStandoutHornets = []string{
|
||||||
|
"🐝 {name} found hornets in {location}. Again.",
|
||||||
|
"🐝 {name} and the hornets of {location} have met again. The hornets won again.",
|
||||||
|
"🐝 Today's hornet report: {name}, {location}, outcome as expected.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// TwinBee summary one-liners (used in the TwinBee section of daily summary)
|
||||||
|
var TwinBeeSummarySuccess = []string{
|
||||||
|
"Visited {location}. Retrieved {loot} worth €{value}. Returned in excellent condition.",
|
||||||
|
"{location}: success. {loot} recovered at €{value}. Standard operation.",
|
||||||
|
"Cleared {location}. {loot}, €{value}. TwinBee has reported this as typical.",
|
||||||
|
"{loot} worth €{value} from {location}. TwinBee is unsurprised.",
|
||||||
|
"Professional operation in {location}. {loot}, €{value}. No further comment.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var TwinBeeSummaryWithdrawal = []string{
|
||||||
|
"Visited {location}. Tactical withdrawal executed. Haul: none. Assessment: ongoing.",
|
||||||
|
"{location}: early departure. Strategic positioning. Returns tomorrow.",
|
||||||
|
"Reconnaissance of {location} complete. Phase Two: tomorrow.",
|
||||||
|
"Withdrew from {location} on schedule. The schedule has been updated.",
|
||||||
|
"{location} assessed. TwinBee returns tomorrow. The {location} has been warned.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var TwinBeeSummaryEmpty = []string{
|
||||||
|
"Visited {location}. Nothing there today. TwinBee notes this for the record.",
|
||||||
|
"{location}: empty. The dungeon has failed to provide. TwinBee has noted it.",
|
||||||
|
"Nothing found in {location}. TwinBee is filing feedback with the dungeon.",
|
||||||
|
}
|
||||||
627
internal/plugin/adventure_flavor_twinbee.go
Normal file
627
internal/plugin/adventure_flavor_twinbee.go
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
// ── TWINBEE NARRATIVE POOLS ───────────────────────────────────────────────────
|
||||||
|
// All strings are in third person. TwinBee never fails.
|
||||||
|
// TwinBee withdraws, pivots, repositions, reconvenes, and returns.
|
||||||
|
// TwinBee does not lose. TwinBee has setbacks that are tactically indistinct
|
||||||
|
// from victories, looked at the right way, which is TwinBee's way.
|
||||||
|
|
||||||
|
var TwinBeeSuccess = []string{
|
||||||
|
"TwinBee descended into {location} with characteristic enthusiasm " +
|
||||||
|
"and the kind of confidence that makes dungeon monsters briefly " +
|
||||||
|
"reconsider their career choices. Several reconsidered too slowly. " +
|
||||||
|
"TwinBee has returned with {loot} worth €{value} and a new personal philosophy " +
|
||||||
|
"regarding the optimum angle of attack on a Stone Golem. " +
|
||||||
|
"The philosophy works. It has been field-tested. Just now.",
|
||||||
|
|
||||||
|
"Another flawless operation. TwinBee entered {location}, assessed the situation " +
|
||||||
|
"with professional precision, and extracted {loot} worth €{value} " +
|
||||||
|
"with minimal collateral damage to TwinBee specifically. " +
|
||||||
|
"The dungeon has been informed it performed adequately. " +
|
||||||
|
"TwinBee does not give compliments freely. This was a compliment.",
|
||||||
|
|
||||||
|
"TwinBee has returned from {location} victorious, which is to say " +
|
||||||
|
"TwinBee has returned from {location}, because for TwinBee these are the same thing. " +
|
||||||
|
"{loot} worth €{value}. {xp} XP that TwinBee does not need " +
|
||||||
|
"but accepts graciously, as a gift, from the dungeon. " +
|
||||||
|
"The dungeon can try harder next time. It probably won't. They never do.",
|
||||||
|
|
||||||
|
"TwinBee located {loot} worth €{value} in {location} after what TwinBee " +
|
||||||
|
"is describing in the post-mission debrief as 'a series of increasingly correct decisions.' " +
|
||||||
|
"The monsters involved have been described as 'adequate opposition' " +
|
||||||
|
"in the same debrief. TwinBee is generous with these things.",
|
||||||
|
|
||||||
|
"In. Retrieved {loot}. Out. €{value}. {xp} XP. " +
|
||||||
|
"TwinBee has the efficiency of someone who has done this many times " +
|
||||||
|
"and the confidence of someone who has never once considered " +
|
||||||
|
"that it might not go well. These two qualities, combined, produce results. " +
|
||||||
|
"The results are in your share of the haul.",
|
||||||
|
|
||||||
|
"TwinBee reports: {location} cleared, {loot} secured, €{value} assessed, " +
|
||||||
|
"one near-miss that TwinBee is not classifying as a near-miss " +
|
||||||
|
"because TwinBee does not have near-misses, TwinBee has " +
|
||||||
|
"'moments of dynamic tactical adjustment that resolved favorably.' " +
|
||||||
|
"The moment resolved favorably. Obviously.",
|
||||||
|
|
||||||
|
"The {location} boss encountered TwinBee today. " +
|
||||||
|
"This is how TwinBee prefers to frame the encounter — " +
|
||||||
|
"the boss encountered TwinBee, not the other way around — " +
|
||||||
|
"because it accurately reflects the power dynamics. " +
|
||||||
|
"{loot} worth €{value}. The boss has been encountered.",
|
||||||
|
|
||||||
|
"TwinBee went to {location} and came back with {loot} worth €{value}, " +
|
||||||
|
"which is what TwinBee does, and {xp} XP, which TwinBee acknowledges " +
|
||||||
|
"with the quiet grace of someone for whom excellence " +
|
||||||
|
"has long since stopped being a surprise.",
|
||||||
|
|
||||||
|
"Professional assessment of today's {location} operation: successful. " +
|
||||||
|
"Loot: {loot}, €{value}. Opposition: handled. Duration: optimal. " +
|
||||||
|
"TwinBee's equipment: immaculate. TwinBee: as expected.",
|
||||||
|
|
||||||
|
"TwinBee extracted {loot} worth €{value} from {location} " +
|
||||||
|
"with the efficiency of a professional and the cheerfulness of someone " +
|
||||||
|
"who genuinely enjoys doing this, which TwinBee does, " +
|
||||||
|
"which is why TwinBee is better at it than everyone else, " +
|
||||||
|
"which TwinBee is.",
|
||||||
|
|
||||||
|
"The monsters in {location} gave it their best today. " +
|
||||||
|
"TwinBee noticed this and respected it for the duration of the encounter, " +
|
||||||
|
"which was brief. {loot} worth €{value}. {xp} XP. " +
|
||||||
|
"TwinBee has returned in excellent spirits, which is not news, " +
|
||||||
|
"but it is accurate.",
|
||||||
|
|
||||||
|
"TwinBee's route through {location} was, according to TwinBee's own review, " +
|
||||||
|
"'essentially perfect.' No argument has been presented to the contrary. " +
|
||||||
|
"TwinBee returned with {loot} worth €{value} and a complete absence " +
|
||||||
|
"of anything that went wrong, which is TwinBee's preferred kind of absence.",
|
||||||
|
|
||||||
|
"A lesser adventurer would have found {location} challenging today. " +
|
||||||
|
"TwinBee found it brisk. {loot}, €{value}, {xp} XP, " +
|
||||||
|
"and the mild satisfaction of a problem that turned out " +
|
||||||
|
"to be an appropriately-sized problem. TwinBee appreciates appropriate sizing.",
|
||||||
|
|
||||||
|
"TwinBee entered {location} from the north approach, which TwinBee has determined " +
|
||||||
|
"is the correct approach, and exited with {loot} worth €{value}. " +
|
||||||
|
"The southern approach is noted. It is not the correct approach. " +
|
||||||
|
"TwinBee has opinions about approaches and they are correct opinions.",
|
||||||
|
|
||||||
|
"Operational summary: {location} visited, opposition encountered, opposition handled, " +
|
||||||
|
"{loot} extracted at €{value} assessed value, {xp} XP collected, " +
|
||||||
|
"TwinBee returned in the condition TwinBee left in, " +
|
||||||
|
"which is optimal, because TwinBee is always in optimal condition.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var TwinBeeExceptional = []string{
|
||||||
|
"OUTSTANDING PERFORMANCE. TwinBee located and neutralised the dungeon boss " +
|
||||||
|
"— which TwinBee describes as 'a scheduling conflict that has now been resolved' — " +
|
||||||
|
"and recovered {loot} worth €{value}. " +
|
||||||
|
"This is, according to TwinBee, a typical Tuesday. " +
|
||||||
|
"TwinBee has had many exceptional Tuesdays. The dungeons are aware.",
|
||||||
|
|
||||||
|
"TwinBee found {loot} worth €{value} in {location} after what TwinBee " +
|
||||||
|
"is calling 'a brief but educational exchange' with something very large " +
|
||||||
|
"that is no longer a going concern. Bonus XP noted. " +
|
||||||
|
"TwinBee noted it first. TwinBee notes everything first.",
|
||||||
|
|
||||||
|
"The inner chamber. TwinBee reached the inner chamber. " +
|
||||||
|
"TwinBee notes that reaching the inner chamber requires " +
|
||||||
|
"the successful navigation of everything between here and the inner chamber, " +
|
||||||
|
"which TwinBee navigated successfully, and then {loot} worth €{value} " +
|
||||||
|
"was in the inner chamber, and TwinBee took it. " +
|
||||||
|
"The inner chamber is now available to be an outer chamber.",
|
||||||
|
|
||||||
|
"A boss encounter. TwinBee's assessment of the boss: 'interesting.' " +
|
||||||
|
"The boss's assessment of TwinBee: insufficient time to complete. " +
|
||||||
|
"{loot} worth €{value}. TwinBee has forwarded the boss's personnel file " +
|
||||||
|
"to the relevant afterlife authorities with a positive reference.",
|
||||||
|
|
||||||
|
"EXCEPTIONAL. TwinBee's word, used sparingly. " +
|
||||||
|
"Today merits it. {loot} worth €{value} from the deepest chamber " +
|
||||||
|
"of {location}, retrieved past opposition that TwinBee describes " +
|
||||||
|
"as 'the most interesting opposition I've faced this week,' " +
|
||||||
|
"which is high praise from TwinBee and accurately assesses the week so far.",
|
||||||
|
|
||||||
|
"TwinBee found something the dungeon wasn't offering publicly. " +
|
||||||
|
"TwinBee finds things dungeons aren't offering publicly. " +
|
||||||
|
"It's a skill. {loot} worth €{value}. {xp} XP. " +
|
||||||
|
"The dungeon has updated its offering. TwinBee approves of the update " +
|
||||||
|
"from a position of already having taken the thing.",
|
||||||
|
|
||||||
|
"A critical run. Every encounter resolved correctly. Every chamber yielded. " +
|
||||||
|
"Every roll, every decision, every approach: correct. " +
|
||||||
|
"{loot} worth €{value}. {xp} XP. " +
|
||||||
|
"TwinBee submits this run as the reference standard for {location}. " +
|
||||||
|
"Future runs should aspire to this. They will not reach it.",
|
||||||
|
|
||||||
|
"The boss was large. TwinBee is TwinBee. " +
|
||||||
|
"These two facts met in {location} and produced {loot} worth €{value} " +
|
||||||
|
"and a story that TwinBee is telling to everyone and will continue telling " +
|
||||||
|
"until a better story replaces it, which will also be TwinBee's story.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var TwinBeeWithdrawal = []string{
|
||||||
|
"TwinBee has completed today's operation and is taking the remainder " +
|
||||||
|
"of the afternoon for strategic planning. " +
|
||||||
|
"This is unrelated to {location}. {location} is unrelated to everything. " +
|
||||||
|
"TwinBee has already moved on and suggests you do the same.",
|
||||||
|
|
||||||
|
"TwinBee assessed {location} with characteristic thoroughness, " +
|
||||||
|
"identified that today's objectives had been sufficiently advanced " +
|
||||||
|
"by the reconnaissance phase, and withdrew in good order " +
|
||||||
|
"before the situation became interesting in ways TwinBee did not schedule. " +
|
||||||
|
"This is called professionalism. TwinBee has it.",
|
||||||
|
|
||||||
|
"Today's operation at {location} concluded earlier than projected. " +
|
||||||
|
"TwinBee encountered something very large, evaluated the encounter on its merits, " +
|
||||||
|
"and determined that returning tomorrow with more information " +
|
||||||
|
"was the superior strategic outcome. " +
|
||||||
|
"TwinBee was not running. TwinBee does not run. " +
|
||||||
|
"TwinBee was moving with purpose in the opposite direction. These are different.",
|
||||||
|
|
||||||
|
"TwinBee left {location} at a time of TwinBee's choosing, " +
|
||||||
|
"which happened to coincide with the arrival of something " +
|
||||||
|
"that TwinBee is describing in the official report as 'a scheduling conflict.' " +
|
||||||
|
"The conflict has been noted. It will be addressed. Tomorrow. " +
|
||||||
|
"At a time that suits TwinBee.",
|
||||||
|
|
||||||
|
"TACTICAL WITHDRAWAL EXECUTED. {location} has been informed, " +
|
||||||
|
"via the medium of TwinBee's departure, that today was not the day. " +
|
||||||
|
"TwinBee knows which days are the days. Today was for gathering intelligence. " +
|
||||||
|
"The intelligence gathered is: {location} still exists and should watch itself.",
|
||||||
|
|
||||||
|
"TwinBee retreated from {location} in the same way the tide retreats from the shore — " +
|
||||||
|
"deliberately, inevitably, and with the full understanding that it will be back, " +
|
||||||
|
"and the shore knows this, and the shore is thinking about it right now.",
|
||||||
|
|
||||||
|
"Something in {location} today required TwinBee to make a decision. " +
|
||||||
|
"TwinBee made the decision. The decision was: not today. " +
|
||||||
|
"TwinBee's decisions are correct decisions by definition, " +
|
||||||
|
"which means 'not today' was correct, which will be proven correct " +
|
||||||
|
"when TwinBee returns tomorrow and the thing is no longer a factor. " +
|
||||||
|
"These things always work out when TwinBee decides they will.",
|
||||||
|
|
||||||
|
"The mission objectives for today's {location} operation have been " +
|
||||||
|
"reclassified as 'Phase One of a Two-Phase Operation,' " +
|
||||||
|
"where Phase One is reconnaissance and Phase Two is tomorrow. " +
|
||||||
|
"Phase One is complete. Phase One was, in retrospect, always the plan.",
|
||||||
|
|
||||||
|
"TwinBee encountered something in {location} that TwinBee is describing " +
|
||||||
|
"as 'an opportunity to demonstrate strategic patience.' " +
|
||||||
|
"TwinBee demonstrated strategic patience. The patience was demonstrated " +
|
||||||
|
"at significant velocity in the direction of the exit, " +
|
||||||
|
"but the patience was present throughout.",
|
||||||
|
|
||||||
|
"Official statement from TwinBee regarding today's {location} operation: " +
|
||||||
|
"'Everything went according to plan.' " +
|
||||||
|
"The plan has been updated to reflect what happened. " +
|
||||||
|
"The updated plan describes what happened as planned. " +
|
||||||
|
"TwinBee's plans are always accurate.",
|
||||||
|
|
||||||
|
"TwinBee is back. The thing in {location} is also still there. " +
|
||||||
|
"TwinBee is aware of the thing. The thing is aware of TwinBee. " +
|
||||||
|
"This is a stalemate that TwinBee will resolve on TwinBee's timeline, " +
|
||||||
|
"which is different from the thing's timeline, " +
|
||||||
|
"and TwinBee's timeline is the one that matters.",
|
||||||
|
|
||||||
|
"The {location} situation has been described by TwinBee as " +
|
||||||
|
"'temporarily inconclusive,' which is a phrase TwinBee uses " +
|
||||||
|
"when something very large expressed a strong position " +
|
||||||
|
"and TwinBee has chosen to revisit the position on better terms. " +
|
||||||
|
"The terms will be better. TwinBee will see to it.",
|
||||||
|
|
||||||
|
"Today, TwinBee gathered extensive tactical data on {location}. " +
|
||||||
|
"The data indicates that {location} is well-defended. " +
|
||||||
|
"The data was gathered by going in and then coming back out, " +
|
||||||
|
"which is a time-honoured data gathering methodology " +
|
||||||
|
"that TwinBee has employed before and will employ again, ideally less urgently.",
|
||||||
|
|
||||||
|
"TwinBee's return today is best understood as a 'victory lap around the outside,' " +
|
||||||
|
"which is a phrase TwinBee has just coined and will be using going forward. " +
|
||||||
|
"A victory lap around the outside of {location} has been completed. " +
|
||||||
|
"TwinBee is home. The inside of {location} is tomorrow's victory lap.",
|
||||||
|
|
||||||
|
"Something the size of a building expressed interest in TwinBee at depth. " +
|
||||||
|
"TwinBee expressed equal and opposite interest in being elsewhere. " +
|
||||||
|
"Both interests were satisfied. TwinBee is elsewhere. " +
|
||||||
|
"The something is where it was. The relationship is stable.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var TwinBeeEmpty = []string{
|
||||||
|
"TwinBee has returned from {location} and wishes it to be known " +
|
||||||
|
"that the dungeon was empty, which TwinBee attributes entirely to their reputation " +
|
||||||
|
"preceding them. The monsters left. They knew. " +
|
||||||
|
"TwinBee respects this. TwinBee also found nothing. These facts are unrelated.",
|
||||||
|
|
||||||
|
"Intelligence suggested {location} would be productive today. " +
|
||||||
|
"TwinBee's intelligence was incorrect. " +
|
||||||
|
"TwinBee's intelligence is reviewing its methodology. " +
|
||||||
|
"The dungeon contained no loot, no monsters worth discussing, " +
|
||||||
|
"and one confused bat that TwinBee has agreed not to mention publicly. " +
|
||||||
|
"Moving on.",
|
||||||
|
|
||||||
|
"{location} was cleared. By whom is unclear. TwinBee suspects a rival. " +
|
||||||
|
"TwinBee does not have rivals, technically, " +
|
||||||
|
"but is prepared to invent one for the purposes of this grievance. " +
|
||||||
|
"No loot. No comment. The rival has been noted.",
|
||||||
|
|
||||||
|
"The {location} run today yielded nothing of material value, " +
|
||||||
|
"which TwinBee attributes to the universe's temporary misalignment " +
|
||||||
|
"with TwinBee's interests. The universe will correct itself. " +
|
||||||
|
"TwinBee is giving it until tomorrow.",
|
||||||
|
|
||||||
|
"Empty. {location} was empty today, which is {location}'s problem. " +
|
||||||
|
"TwinBee showed up. TwinBee was prepared. TwinBee had the right equipment. " +
|
||||||
|
"The dungeon failed to provide an adequate challenge or adequate reward. " +
|
||||||
|
"This reflects poorly on the dungeon.",
|
||||||
|
|
||||||
|
"TwinBee cleared four rooms of {location} and found nothing worth the trip. " +
|
||||||
|
"TwinBee is not saying the trip was not worth it — TwinBee does not say that — " +
|
||||||
|
"but the material outcome was zero and the XP was minimal " +
|
||||||
|
"and TwinBee would like to register this as feedback for the dungeon.",
|
||||||
|
|
||||||
|
"The treasure was already gone when TwinBee arrived, " +
|
||||||
|
"which means someone got there before TwinBee, " +
|
||||||
|
"which is a sentence that has never previously been true " +
|
||||||
|
"and which TwinBee is treating as a statistical anomaly " +
|
||||||
|
"rather than a pattern that might develop.",
|
||||||
|
|
||||||
|
"No monsters. No treasure. No meaningful opposition. " +
|
||||||
|
"Just TwinBee in {location} swinging at things that weren't there. " +
|
||||||
|
"TwinBee has described this run as 'character-building.' " +
|
||||||
|
"TwinBee's character was already built. " +
|
||||||
|
"This run has contributed nothing to the structure.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EQUIPMENT BREAKING ────────────────────────────────────────────────────────
|
||||||
|
// Indexed by slot name. Select randomly from the pool.
|
||||||
|
// The replacement is always the Tier 0 version of the slot.
|
||||||
|
|
||||||
|
var EquipmentBreaking = map[string][]string{
|
||||||
|
|
||||||
|
"weapon": {
|
||||||
|
"Your {item} has given everything it had, which in retrospect was not much " +
|
||||||
|
"and also embarrassing to admit out loud. It has returned to its natural state: garbage. " +
|
||||||
|
"You are now equipped with the Basic Ass Sword. " +
|
||||||
|
"The cycle of mediocrity turns.",
|
||||||
|
|
||||||
|
"Your {item} is dead. We gather not to mourn it — it was not good — " +
|
||||||
|
"but to acknowledge that it served. Poorly. Consistently, aggressively poorly. " +
|
||||||
|
"The Basic Ass Sword has been reissued. It is equally bad but at least new.",
|
||||||
|
|
||||||
|
"Gone. Your {item} is gone. Condition: zero. Structural integrity: none. " +
|
||||||
|
"Dignity: never present. You are back to the Basic Ass Sword " +
|
||||||
|
"and we are all pretending this is fine. It is not fine. Upgrade. Please.",
|
||||||
|
|
||||||
|
"Your {item} has shattered. The sound it made was brief " +
|
||||||
|
"and embarrassingly small for a weapon failing at a critical moment. " +
|
||||||
|
"The Basic Ass Sword, your original, your constant, your shame, " +
|
||||||
|
"is back in your hand. As if you never left.",
|
||||||
|
|
||||||
|
"The {item} has returned to its components, which are bad components " +
|
||||||
|
"that produced a bad weapon that has now finished being a weapon. " +
|
||||||
|
"The Basic Ass Sword is what you have. " +
|
||||||
|
"The Basic Ass Sword is, tragically, still a sword.",
|
||||||
|
|
||||||
|
"Broken. The {item} is broken, condition zero, " +
|
||||||
|
"broken in a way that happens to weapons that have been asked " +
|
||||||
|
"to do more than their condition could support for longer than their condition recommended. " +
|
||||||
|
"The Basic Ass Sword does not have these concerns because its expectations are already zero.",
|
||||||
|
|
||||||
|
"Your {item} snapped. The upper half is somewhere you cannot retrieve it. " +
|
||||||
|
"The lower half is a handle with opinions. " +
|
||||||
|
"You are holding the lower half. The Basic Ass Sword is the new situation. " +
|
||||||
|
"The new situation is the old situation.",
|
||||||
|
|
||||||
|
"The blade went one way and the handle went another " +
|
||||||
|
"and you went in the direction of 'holding a Basic Ass Sword again' " +
|
||||||
|
"because that is where broken weapons return their owners. " +
|
||||||
|
"The circle is unbroken. The sword is broken. Welcome back.",
|
||||||
|
},
|
||||||
|
|
||||||
|
"armor": {
|
||||||
|
"Your {item} has failed. Not dramatically — just failed, quietly, " +
|
||||||
|
"at the structural level, in a way that became apparent all at once " +
|
||||||
|
"when the last thing hit you. The Shitty Armor is back. " +
|
||||||
|
"The Shitty Armor has always been back, waiting.",
|
||||||
|
|
||||||
|
"Condition zero. Your {item} is now the Shitty Armor. " +
|
||||||
|
"Not figuratively — the item has been replaced by the actual Shitty Armor, " +
|
||||||
|
"the baseline, the floor, the thing below which armor does not go " +
|
||||||
|
"and from which you have apparently returned.",
|
||||||
|
|
||||||
|
"The {item} has been compromised. 'Compromised' is the polite word. " +
|
||||||
|
"The honest words are 'destroyed' and 'sorry' and 'you're wearing Shitty Armor again.' " +
|
||||||
|
"The Shitty Armor offers the protection of a strongly-worded letter. " +
|
||||||
|
"It always has. It still does.",
|
||||||
|
|
||||||
|
"Your {item} bent the wrong way. Armor should not bend. " +
|
||||||
|
"Armor that bends has broken the implicit contract between armor and wearer, " +
|
||||||
|
"which is: you stay rigid, I stay alive. " +
|
||||||
|
"The contract is void. The Shitty Armor is the new contract. " +
|
||||||
|
"It's a worse contract.",
|
||||||
|
|
||||||
|
"The {item} is done. Condition zero, function zero, " +
|
||||||
|
"protective value approximately matching the Shitty Armor that has replaced it, " +
|
||||||
|
"which is a different thing than matching actual armor " +
|
||||||
|
"but is now your actual situation.",
|
||||||
|
|
||||||
|
"Structural failure. Your {item} has experienced structural failure, " +
|
||||||
|
"which is a technical term for 'it broke' that makes you feel " +
|
||||||
|
"like an engineer rather than someone wearing broken armor and the Shitty Armor simultaneously, " +
|
||||||
|
"which you are. The Shitty Armor fits, though. Sadly, it always fits.",
|
||||||
|
},
|
||||||
|
|
||||||
|
"helmet": {
|
||||||
|
"Your {item} is gone. Condition zero. The Goddamn Offensive Helmet " +
|
||||||
|
"is back on your head, where it was always waiting, " +
|
||||||
|
"crouching in the back of your inventory, " +
|
||||||
|
"knowing this day would come. The helmet knew.",
|
||||||
|
|
||||||
|
"The {item} has been retired by force. In its place: the Goddamn Offensive Helmet. " +
|
||||||
|
"It is bad for your head. It is an insult to everyone who sees it. " +
|
||||||
|
"It fits perfectly, which is the most offensive thing about it.",
|
||||||
|
|
||||||
|
"Condition zero means the Goddamn Offensive Helmet. " +
|
||||||
|
"The Goddamn Offensive Helmet means you've come full circle. " +
|
||||||
|
"The circle is not a triumph. The circle is a shape your bad decisions make " +
|
||||||
|
"when you don't repair your gear.",
|
||||||
|
|
||||||
|
"Your {item} cracked in half. The two halves are not useful halves. " +
|
||||||
|
"The useful replacement is the Goddamn Offensive Helmet, " +
|
||||||
|
"which has been retrieved from the bottom of your pack " +
|
||||||
|
"where it was waiting with complete patience " +
|
||||||
|
"because it knew. It always knows.",
|
||||||
|
|
||||||
|
"The {item} has betrayed you in the specific way of head protection: " +
|
||||||
|
"by not being head protection anymore. " +
|
||||||
|
"The Goddamn Offensive Helmet has stepped in to fill the void. " +
|
||||||
|
"The Goddamn Offensive Helmet fills the void in the same way " +
|
||||||
|
"a bad apology fills a silence. Technically. Poorly.",
|
||||||
|
},
|
||||||
|
|
||||||
|
"boots": {
|
||||||
|
"Your {item} are gone. Condition zero. The Knobby-Ass Boots are your boots now. " +
|
||||||
|
"The knobs are not a feature. Nobody has ever established what they are. " +
|
||||||
|
"You will be wearing them until you can afford better.",
|
||||||
|
|
||||||
|
"The {item} have given out. The Knobby-Ass Boots are the replacement. " +
|
||||||
|
"The Knobby-Ass Boots do not explain themselves. " +
|
||||||
|
"They have never explained themselves. They are simply there, knobby, waiting.",
|
||||||
|
|
||||||
|
"Condition zero on the {item}. Condition zero means Knobby-Ass Boots. " +
|
||||||
|
"The Knobby-Ass Boots were in your pack. They were always in your pack. " +
|
||||||
|
"You kept them because getting rid of them felt like tempting fate. " +
|
||||||
|
"Fate has been tempted anyway.",
|
||||||
|
|
||||||
|
"Your {item} fell apart in the field, which is the worst time for boots to fall apart " +
|
||||||
|
"and also, historically, exactly when boots fall apart. " +
|
||||||
|
"The Knobby-Ass Boots are your boots. " +
|
||||||
|
"The knobs are less comfortable than the alternative of no boots. Marginally.",
|
||||||
|
|
||||||
|
"The {item} are done. The Knobby-Ass Boots are the present reality. " +
|
||||||
|
"You will walk in them. They will be uncomfortable. " +
|
||||||
|
"The knobs will be inexplicable. This is what condition zero costs.",
|
||||||
|
},
|
||||||
|
|
||||||
|
"tool": {
|
||||||
|
"Your {item} has broken. The Rusted PoS Pickaxe is your tool now. " +
|
||||||
|
"It is technically a pickaxe. That is the single nicest thing " +
|
||||||
|
"anyone can say about it. It says it with reluctance.",
|
||||||
|
|
||||||
|
"Condition zero on the {item}. The Rusted PoS Pickaxe has returned " +
|
||||||
|
"from wherever tools go when you stop using them, " +
|
||||||
|
"which is apparently the bottom of your pack, " +
|
||||||
|
"rusty and patient and not surprised.",
|
||||||
|
|
||||||
|
"The {item} is gone. The Rusted PoS Pickaxe is here. " +
|
||||||
|
"The Rusted PoS Pickaxe has always been here, spiritually. " +
|
||||||
|
"Now it is here physically. The mountain will not respect it. " +
|
||||||
|
"The mountain never respected it. The mining has become harder.",
|
||||||
|
|
||||||
|
"Your {item} cracked down the haft and the head separated at impact " +
|
||||||
|
"and the impact was on rock that did not move " +
|
||||||
|
"and you have the haft and not the head and " +
|
||||||
|
"the Rusted PoS Pickaxe, which has a head attached, for now. " +
|
||||||
|
"Mine carefully. Everything is worse.",
|
||||||
|
|
||||||
|
"The {item} made a sound that tools should not make " +
|
||||||
|
"and then it was in two pieces and the Rusted PoS Pickaxe " +
|
||||||
|
"was in your hand because that is what happens at condition zero. " +
|
||||||
|
"You are back to the beginning. The beginning bites rock poorly.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SYSTEM MESSAGES ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var DeathDM = []string{
|
||||||
|
"📋 ADVENTURER STATUS: DEAD (RECOVERING)\n\n" +
|
||||||
|
"{name}, you have died. This is, frankly, on you.\n\n" +
|
||||||
|
"You are currently receiving care under the local healthcare system, which\n" +
|
||||||
|
"has reviewed your case, questioned several of your life choices, denied\n" +
|
||||||
|
"the first two claims, pre-authorized one bandage, and will see you in\n" +
|
||||||
|
"approximately 23 hours pending insurance confirmation. The insurance\n" +
|
||||||
|
"confirmation is pending. The insurance company is 'looking into it.'\n\n" +
|
||||||
|
"Expected return: {time} UTC\n" +
|
||||||
|
"Current condition: Alive (technically, and barely)\n" +
|
||||||
|
"Equipment damage: Applied. It's not pretty.\n\n" +
|
||||||
|
"The {location} has been flagged as 'hazardous' in your file,\n" +
|
||||||
|
"which will not change anything about anything.",
|
||||||
|
|
||||||
|
"📋 ADVENTURER STATUS: RECEIVING CARE\n\n" +
|
||||||
|
"You are dead, {name}. Temporarily. Healthcare is involved.\n\n" +
|
||||||
|
"The bill has been pre-estimated. The pre-estimate is not comfortable.\n" +
|
||||||
|
"The final bill will be different from the pre-estimate.\n" +
|
||||||
|
"Neither will be comfortable. This is healthcare.\n\n" +
|
||||||
|
"Your equipment has been assessed. The assessment was not kind.\n" +
|
||||||
|
"The assessment was accurate. Return expected: {time} UTC.\n" +
|
||||||
|
"The {location} has noted your visit in whatever record {location} keeps.\n" +
|
||||||
|
"You are in the record as 'visited once, briefly.'",
|
||||||
|
|
||||||
|
"📋 ADVENTURER STATUS: INDISPOSED\n\n" +
|
||||||
|
"{name}. You have died in {location}.\n\n" +
|
||||||
|
"The relevant parties have been notified. The relevant parties include:\n" +
|
||||||
|
"- Healthcare (notified, processing, unhurried)\n" +
|
||||||
|
"- Your equipment (notified, damaged, also unhurried about this)\n" +
|
||||||
|
"- The {location} (not notified; doesn't care; already moved on)\n\n" +
|
||||||
|
"You have not been notified because you are currently the situation.\n" +
|
||||||
|
"Return: {time} UTC. Rest. The {location} will still be there.",
|
||||||
|
|
||||||
|
"📋 ADVENTURER STATUS: TEMPORARILY NOT ADVENTURING\n\n" +
|
||||||
|
"Dead, {name}. The word is 'dead.'\n\n" +
|
||||||
|
"Healthcare has received your chart. Healthcare has seen this chart before.\n" +
|
||||||
|
"Healthcare has a file. The file is thick.\n" +
|
||||||
|
"Healthcare has not commented on the file's thickness, professionally,\n" +
|
||||||
|
"but the nurses make eye contact with each other when they see your name.\n\n" +
|
||||||
|
"Equipment: damaged in ways that will require attention.\n" +
|
||||||
|
"Return: {time} UTC.\n" +
|
||||||
|
"The {location} has not changed as a result of this incident.",
|
||||||
|
|
||||||
|
"📋 ADVENTURER STATUS: IN THE SYSTEM\n\n" +
|
||||||
|
"You are dead. This is current status, not commentary, {name}.\n\n" +
|
||||||
|
"The healthcare system has processed your intake.\n" +
|
||||||
|
"The healthcare system processed your intake with the efficiency\n" +
|
||||||
|
"of a system that processes many intakes and has a form for yours specifically.\n" +
|
||||||
|
"The form has been filled out. Several boxes checked.\n" +
|
||||||
|
"The box that says 'preventable' has been considered.\n\n" +
|
||||||
|
"Recovery: {time} UTC. Equipment: damaged. {location}: still standing.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var RespawnDM = []string{
|
||||||
|
"📋 ADVENTURER STATUS: DISCHARGED (AGAINST MEDICAL ADVICE, AS USUAL)\n\n" +
|
||||||
|
"{name}, healthcare is done with you. You have been discharged.\n" +
|
||||||
|
"You signed a waiver on the way out. You do not remember signing it.\n" +
|
||||||
|
"It covered a lot of ground. Don't think about it.\n\n" +
|
||||||
|
"You are once again technically alive and available for terrible decisions.\n" +
|
||||||
|
"The hospital has asked that you please not come back,\n" +
|
||||||
|
"which is advice you will almost certainly ignore.\n\n" +
|
||||||
|
"Type !adventure to see today's options.\n" +
|
||||||
|
"Try to make better ones. You won't, but try.",
|
||||||
|
|
||||||
|
"📋 ADVENTURER STATUS: OPERATIONAL (RELUCTANTLY CERTIFIED)\n\n" +
|
||||||
|
"{name}. Healthcare has finished with you.\n\n" +
|
||||||
|
"You have been medically cleared for adventuring,\n" +
|
||||||
|
"which is a certification that healthcare gives\n" +
|
||||||
|
"while clearly feeling that giving it is a mistake.\n" +
|
||||||
|
"The certification has been given. The feeling was noted in your chart.\n\n" +
|
||||||
|
"Your equipment is in the condition it was when you were admitted.\n" +
|
||||||
|
"That condition was not good. It is still not good.\n" +
|
||||||
|
"Type !adventure to continue making it worse.",
|
||||||
|
|
||||||
|
"📋 ADVENTURER STATUS: RETURNED\n\n" +
|
||||||
|
"You're back, {name}.\n\n" +
|
||||||
|
"Healthcare has released you with a list of recommendations\n" +
|
||||||
|
"that you will not be following. They know you won't follow them.\n" +
|
||||||
|
"They give them anyway. It's in the protocol.\n\n" +
|
||||||
|
"The recommendations include: better equipment, lower-tier dungeons,\n" +
|
||||||
|
"a different career. These are good recommendations.\n" +
|
||||||
|
"Type !adventure to not follow them.",
|
||||||
|
|
||||||
|
"📋 ADVENTURER STATUS: DISCHARGED\n\n" +
|
||||||
|
"{name}, you are alive. Healthcare has confirmed this.\n" +
|
||||||
|
"Healthcare's confirmation comes with a bill that has been filed\n" +
|
||||||
|
"and will be filed again and will continue to be filed\n" +
|
||||||
|
"until something happens that resolves the filing.\n\n" +
|
||||||
|
"None of this is your immediate problem.\n" +
|
||||||
|
"Your immediate problem is that your equipment is worse\n" +
|
||||||
|
"and your options are the same and the dungeons are waiting.\n\n" +
|
||||||
|
"Type !adventure. The morning DM arrives at 08:00 UTC.\n" +
|
||||||
|
"Be there. Try to be there tomorrow too. And the day after.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var IdleShameDM = []string{
|
||||||
|
"{name}, you didn't leave the house today.\n\n" +
|
||||||
|
"Your adventurer sat in their hovel, stared at the wall,\n" +
|
||||||
|
"and achieved absolutely nothing. No loot. No XP. No death,\n" +
|
||||||
|
"which is honestly the nicest thing that can be said about today.\n\n" +
|
||||||
|
"Tomorrow's choices arrive at 08:00 UTC.\n" +
|
||||||
|
"Please, for the love of everything, try to be brave.",
|
||||||
|
|
||||||
|
"No action today, {name}.\n\n" +
|
||||||
|
"The morning DM was sent. The morning DM was not answered.\n" +
|
||||||
|
"The dungeon was there. The mine was there. The forest was there.\n" +
|
||||||
|
"You were also there, presumably, somewhere, not responding.\n\n" +
|
||||||
|
"TwinBee noticed. The daily summary has noted your absence.\n" +
|
||||||
|
"Tomorrow: 08:00 UTC. The options will be there. Please be there too.",
|
||||||
|
|
||||||
|
"{name}. You rested today.\n\n" +
|
||||||
|
"'Rested' is the word being used. It is the charitable word.\n" +
|
||||||
|
"The uncharitable word is 'nothing.' You did nothing.\n" +
|
||||||
|
"The dungeons are still there. The mines are still there.\n" +
|
||||||
|
"The foraging areas are still there. Your XP is still where you left it.\n\n" +
|
||||||
|
"08:00 UTC tomorrow. An adventure awaits.\n" +
|
||||||
|
"The adventure has been waiting since you ignored it this morning.",
|
||||||
|
|
||||||
|
"Today passed without you, {name}.\n\n" +
|
||||||
|
"TwinBee went to a dungeon. The dungeon was real.\n" +
|
||||||
|
"Your share of TwinBee's haul was distributed to people who showed up.\n" +
|
||||||
|
"You did not show up. You did not get a share.\n" +
|
||||||
|
"TwinBee noticed. TwinBee says nothing.\n" +
|
||||||
|
"TwinBee's silence is louder than most things.\n\n" +
|
||||||
|
"Tomorrow: 08:00 UTC. Show up.",
|
||||||
|
|
||||||
|
"A day of rest, {name}. Unearned, but rest.\n\n" +
|
||||||
|
"The hovel is comfortable. The wall is familiar.\n" +
|
||||||
|
"Nothing in the hovel tried to kill you, which is the one advantage the hovel has.\n" +
|
||||||
|
"The hovel also paid you nothing and taught you nothing and advanced nothing.\n" +
|
||||||
|
"The hovel is comfortable and useless. So was today.\n\n" +
|
||||||
|
"Tomorrow is 08:00 UTC. The choices will be new ones.\n" +
|
||||||
|
"Make one of them. Any of them. Please.",
|
||||||
|
|
||||||
|
"Where were you today, {name}?\n\n" +
|
||||||
|
"The bot sent the DM. The DM was delivered.\n" +
|
||||||
|
"The DM sat there, unread or unresponded-to, while your adventurer\n" +
|
||||||
|
"sat in the hovel and the day happened without them.\n" +
|
||||||
|
"XP: not gained. Loot: not found. Death: at least avoided.\n\n" +
|
||||||
|
"Low bar. You cleared the low bar by doing nothing.\n" +
|
||||||
|
"Tomorrow: 08:00 UTC. Clear a higher bar.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var OnboardingDM = []string{
|
||||||
|
"Welcome, {name}.\n\n" +
|
||||||
|
"You are an adventurer. A hero. Probably.\n" +
|
||||||
|
"The opening scroll of your life has played and it was unfortunately\n" +
|
||||||
|
"quite short and mentioned nothing about destiny or treasure,\n" +
|
||||||
|
"only that you are broke and smell faintly of the starting village.\n\n" +
|
||||||
|
"You have been issued the standard starter kit:\n" +
|
||||||
|
"⚔️ Basic Ass Sword — it's a sword in the same way a parking ticket is legal documentation\n" +
|
||||||
|
"🛡️ Shitty Armor — offers the protection of a strongly-worded letter\n" +
|
||||||
|
"🪖 Goddamn Offensive Helmet — bad for your head, an insult to witnesses\n" +
|
||||||
|
"👢 Knobby-Ass Boots — the knobs are not a feature\n" +
|
||||||
|
"⛏️ Rusted PoS Pickaxe — technically a pickaxe, the nicest thing anyone can say\n\n" +
|
||||||
|
"Go forth. Try not to die in the Soggy Cellar.\n" +
|
||||||
|
"People have. It was embarrassing and they never fully lived it down.\n\n" +
|
||||||
|
"Your morning DM arrives at 08:00 UTC. Today's choices are ready now.\n" +
|
||||||
|
"Type !adventure to begin.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var MorningDM = []string{
|
||||||
|
"⚔️ Good morning, {name}. Another day, another opportunity for regret.\n\n" +
|
||||||
|
"{character_sheet}\n\n" +
|
||||||
|
"Where are you headed? TwinBee would choose the dungeon.\n" +
|
||||||
|
"TwinBee would also survive it. You are not TwinBee. Choose wisely.",
|
||||||
|
|
||||||
|
"⚔️ {name}. You're alive. The options are:\n\n" +
|
||||||
|
"{character_sheet}\n\n" +
|
||||||
|
"The dungeon is there. The mine is there. The forest is there.\n" +
|
||||||
|
"None of them are getting safer. Reply to choose.",
|
||||||
|
|
||||||
|
"⚔️ Morning, {name}.\n\n" +
|
||||||
|
"{character_sheet}\n\n" +
|
||||||
|
"Today is a day. Days have outcomes. Reply with your choice\n" +
|
||||||
|
"and we will discover what today's outcome is together,\n" +
|
||||||
|
"at different levels of risk depending on what you pick.",
|
||||||
|
|
||||||
|
"⚔️ Rise and choose, {name}.\n\n" +
|
||||||
|
"{character_sheet}\n\n" +
|
||||||
|
"The world is dangerous and full of things worth taking from it.\n" +
|
||||||
|
"Some of those things are in dungeons. Some are in mines.\n" +
|
||||||
|
"Some are in forests, guarded by hornets who feel strongly about them.\n" +
|
||||||
|
"Reply with where you're going.",
|
||||||
|
|
||||||
|
"⚔️ Another morning. Another chance to make something happen, {name}.\n\n" +
|
||||||
|
"{character_sheet}\n\n" +
|
||||||
|
"Or don't. You could rest. You could do nothing.\n" +
|
||||||
|
"The bot will send you a disappointed DM at midnight.\n" +
|
||||||
|
"TwinBee will know. TwinBee always knows.\n" +
|
||||||
|
"Reply to choose.",
|
||||||
|
}
|
||||||
644
internal/plugin/adventure_render.go
Normal file
644
internal/plugin/adventure_render.go
Normal file
@@ -0,0 +1,644 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Flavor Text Selection ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// flavorHistory tracks last N used indices per player per category to avoid repetition.
|
||||||
|
var flavorHistory sync.Map // key: "userID:category" -> []int
|
||||||
|
|
||||||
|
const flavorHistorySize = 5
|
||||||
|
|
||||||
|
func advPickFlavor(pool []string, userID id.UserID, category string) (string, int) {
|
||||||
|
if len(pool) == 0 {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
if len(pool) == 1 {
|
||||||
|
return pool[0], 0
|
||||||
|
}
|
||||||
|
|
||||||
|
key := string(userID) + ":" + category
|
||||||
|
|
||||||
|
// Load history
|
||||||
|
var history []int
|
||||||
|
if val, ok := flavorHistory.Load(key); ok {
|
||||||
|
history = val.([]int)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to pick an index not in recent history
|
||||||
|
var idx int
|
||||||
|
for attempts := 0; attempts < 20; attempts++ {
|
||||||
|
idx = rand.IntN(len(pool))
|
||||||
|
if !intSliceContains(history, idx) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update history
|
||||||
|
history = append(history, idx)
|
||||||
|
if len(history) > flavorHistorySize {
|
||||||
|
history = history[len(history)-flavorHistorySize:]
|
||||||
|
}
|
||||||
|
flavorHistory.Store(key, history)
|
||||||
|
|
||||||
|
return pool[idx], idx
|
||||||
|
}
|
||||||
|
|
||||||
|
func intSliceContains(s []int, v int) bool {
|
||||||
|
for _, x := range s {
|
||||||
|
if x == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// advClearFlavorHistory resets the dedup tracker. Called at midnight reset.
|
||||||
|
func advClearFlavorHistory() {
|
||||||
|
flavorHistory.Range(func(key, _ any) bool {
|
||||||
|
flavorHistory.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// advSubstituteFlavor replaces {var} placeholders in a flavor text string.
|
||||||
|
func advSubstituteFlavor(template string, vars map[string]string) string {
|
||||||
|
pairs := make([]string, 0, len(vars)*2)
|
||||||
|
for k, v := range vars {
|
||||||
|
pairs = append(pairs, k, v)
|
||||||
|
}
|
||||||
|
return strings.NewReplacer(pairs...).Replace(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Character Sheet ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, items []AdvItem, treasures []AdvTreasureBonus, balance float64) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("⚔️ **%s's Adventurer**\n\n", char.DisplayName))
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
sb.WriteString("📊 Stats:\n")
|
||||||
|
sb.WriteString(fmt.Sprintf(" Combat: Lv.%d (%d/%d XP)\n", char.CombatLevel, char.CombatXP, xpToNextLevel(char.CombatLevel)))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Mining: Lv.%d (%d/%d XP)\n", char.MiningSkill, char.MiningXP, xpToNextLevel(char.MiningSkill)))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Forage: Lv.%d (%d/%d XP)\n", char.ForagingSkill, char.ForagingXP, xpToNextLevel(char.ForagingSkill)))
|
||||||
|
|
||||||
|
// Status
|
||||||
|
if char.Alive {
|
||||||
|
sb.WriteString(" Status: Alive\n")
|
||||||
|
} else if char.DeadUntil != nil {
|
||||||
|
sb.WriteString(fmt.Sprintf(" Status: 💀 Dead — revives %s UTC\n", char.DeadUntil.Format("15:04")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streak
|
||||||
|
if char.CurrentStreak > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf(" 🔥 Streak: %d days", char.CurrentStreak))
|
||||||
|
if char.CurrentStreak >= 30 {
|
||||||
|
sb.WriteString(" 🏆")
|
||||||
|
} else if char.CurrentStreak >= 14 {
|
||||||
|
sb.WriteString(" ⭐")
|
||||||
|
} else if char.CurrentStreak >= 7 {
|
||||||
|
sb.WriteString(" 🔥")
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" (best: %d)\n", char.BestStreak))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equipment
|
||||||
|
sb.WriteString("\n🛡️ Equipment:\n")
|
||||||
|
eqScore := advEquipmentScore(equip)
|
||||||
|
slotEmoji := map[EquipmentSlot]string{
|
||||||
|
SlotWeapon: "⚔️", SlotArmor: "🛡️", SlotHelmet: "🪖", SlotBoots: "👢", SlotTool: "⛏️",
|
||||||
|
}
|
||||||
|
for _, slot := range allSlots {
|
||||||
|
eq := equip[slot]
|
||||||
|
mastery := ""
|
||||||
|
if eq != nil && eq.ActionsUsed >= 20 {
|
||||||
|
mastery = " ✦"
|
||||||
|
}
|
||||||
|
if eq != nil {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s %s: %s (Tier %d | %d%% condition%s)\n",
|
||||||
|
slotEmoji[slot], strings.Title(string(slot)), eq.Name, eq.Tier, eq.Condition, mastery))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" Equipment Score: %d\n", eqScore))
|
||||||
|
|
||||||
|
// Treasures
|
||||||
|
if len(treasures) > 0 {
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
sb.WriteString("\n💎 Treasures:\n")
|
||||||
|
for _, t := range treasures {
|
||||||
|
if seen[t.TreasureKey] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[t.TreasureKey] = true
|
||||||
|
// Find def for inventory desc
|
||||||
|
for tier, defs := range advAllTreasures {
|
||||||
|
_ = tier
|
||||||
|
for _, def := range defs {
|
||||||
|
if def.Key == t.TreasureKey {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s\n", def.InventoryDesc))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inventory summary
|
||||||
|
var invValue int64
|
||||||
|
for _, item := range items {
|
||||||
|
invValue += item.Value
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("\n🎒 Inventory: %d items (total value ~€%d)\n", len(items), invValue))
|
||||||
|
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n", balance))
|
||||||
|
|
||||||
|
// Today's action
|
||||||
|
if char.ActionTakenToday {
|
||||||
|
sb.WriteString("\n📅 Today: Action taken")
|
||||||
|
} else {
|
||||||
|
sb.WriteString("\n📅 Today: No action yet — reply to morning DM or type `!adventure`")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Morning DM ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, balance float64, bonuses *AdvBonusSummary) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Pick a morning greeting
|
||||||
|
greeting, _ := advPickFlavor(MorningDM, char.UserID, "morning_dm")
|
||||||
|
vars := map[string]string{
|
||||||
|
"{name}": char.DisplayName,
|
||||||
|
"{character_sheet}": fmt.Sprintf(
|
||||||
|
" ⚔️ Combat Lv.%d ⛏️ Mining Lv.%d 🌿 Foraging Lv.%d\n 💰 €%.0f",
|
||||||
|
char.CombatLevel, char.MiningSkill, char.ForagingSkill, balance),
|
||||||
|
}
|
||||||
|
sb.WriteString(advSubstituteFlavor(greeting, vars))
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Active buffs
|
||||||
|
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||||
|
if len(buffs) > 0 {
|
||||||
|
sb.WriteString("✨ **Active buffs:**\n")
|
||||||
|
for _, b := range buffs {
|
||||||
|
remaining := time.Until(b.ExpiresAt).Truncate(time.Hour)
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s (%.0fh remaining)\n", b.BuffName, remaining.Hours()))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streak info
|
||||||
|
if char.CurrentStreak >= 3 {
|
||||||
|
sb.WriteString(fmt.Sprintf("🔥 **Streak: %d days** — ", char.CurrentStreak))
|
||||||
|
switch {
|
||||||
|
case char.CurrentStreak >= 30:
|
||||||
|
sb.WriteString("+20% XP, +15% loot, -5% death")
|
||||||
|
case char.CurrentStreak >= 14:
|
||||||
|
sb.WriteString("+15% XP, +10% loot, -3% death")
|
||||||
|
case char.CurrentStreak >= 7:
|
||||||
|
sb.WriteString("+10% XP, +5% loot")
|
||||||
|
case char.CurrentStreak >= 3:
|
||||||
|
sb.WriteString("+5% XP")
|
||||||
|
}
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location choices
|
||||||
|
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 today, bank your luck\n\n")
|
||||||
|
|
||||||
|
sb.WriteString("Reply with the number and location, e.g: `1 Soggy Cellar`\n")
|
||||||
|
sb.WriteString("You have until midnight UTC to choose.")
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Resolution DM ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(result.FlavorText)
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Summary line
|
||||||
|
sb.WriteString("───────────────────\n")
|
||||||
|
|
||||||
|
if result.Outcome == AdvOutcomeDeath {
|
||||||
|
sb.WriteString("💀 **You died.**\n")
|
||||||
|
if char.DeadUntil != nil {
|
||||||
|
sb.WriteString(fmt.Sprintf("Expected return: %s UTC\n", char.DeadUntil.Format("2006-01-02 15:04")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.NearDeath && result.Outcome != AdvOutcomeDeath {
|
||||||
|
sb.WriteString("😰 **Near death!** You survived by the thinnest margin. +15% XP bonus.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.LootItems) > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("💰 Loot: €%d total\n", result.TotalLootValue))
|
||||||
|
for _, item := range result.LootItems {
|
||||||
|
sb.WriteString(fmt.Sprintf(" • %s — €%d\n", item.Name, item.Value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("✨ +%d %s XP", result.XPGained, result.XPSkill))
|
||||||
|
if result.LeveledUp {
|
||||||
|
sb.WriteString(fmt.Sprintf(" — **LEVEL UP! %s Lv.%d!** 🎉", strings.Title(result.XPSkill), result.NewLevel))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
// Equipment damage
|
||||||
|
if len(result.EquipDamage) > 0 {
|
||||||
|
sb.WriteString("🔧 Equipment damage:\n")
|
||||||
|
for slot, dmg := range result.EquipDamage {
|
||||||
|
if dmg > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf(" • %s: -%d condition\n", strings.Title(string(slot)), dmg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equipment broken
|
||||||
|
if len(result.EquipBroken) > 0 {
|
||||||
|
for _, slot := range result.EquipBroken {
|
||||||
|
breakPool, ok := EquipmentBreaking[string(slot)]
|
||||||
|
if ok && len(breakPool) > 0 {
|
||||||
|
text := breakPool[rand.IntN(len(breakPool))]
|
||||||
|
replacement := tier0Equipment(slot)
|
||||||
|
text = advSubstituteFlavor(text, map[string]string{
|
||||||
|
"{item}": string(slot),
|
||||||
|
"{replacement}": replacement,
|
||||||
|
})
|
||||||
|
sb.WriteString("\n" + text + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streak
|
||||||
|
if result.StreakBonus > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n🔥 Streak: %d days\n", result.StreakBonus))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Death Status DM ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func renderAdvDeathStatusDM(char *AdventureCharacter) string {
|
||||||
|
text, _ := advPickFlavor(DeathDM, char.UserID, "death_dm")
|
||||||
|
remaining := ""
|
||||||
|
if char.DeadUntil != nil {
|
||||||
|
remaining = char.DeadUntil.Format("15:04")
|
||||||
|
}
|
||||||
|
return advSubstituteFlavor(text, map[string]string{
|
||||||
|
"{name}": char.DisplayName,
|
||||||
|
"{time}": remaining,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Respawn DM ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func renderAdvRespawnDM(char *AdventureCharacter) string {
|
||||||
|
text, _ := advPickFlavor(RespawnDM, char.UserID, "respawn_dm")
|
||||||
|
return advSubstituteFlavor(text, map[string]string{
|
||||||
|
"{name}": char.DisplayName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Idle Shame DM ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func renderAdvIdleShameDM(char *AdventureCharacter) string {
|
||||||
|
text, _ := advPickFlavor(IdleShameDM, char.UserID, "idle_shame")
|
||||||
|
return advSubstituteFlavor(text, map[string]string{
|
||||||
|
"{name}": char.DisplayName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Onboarding DM ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func renderAdvOnboardingDM(char *AdventureCharacter) string {
|
||||||
|
text, _ := advPickFlavor(OnboardingDM, char.UserID, "onboarding")
|
||||||
|
return advSubstituteFlavor(text, map[string]string{
|
||||||
|
"{name}": char.DisplayName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Daily Summary ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type AdvPlayerDaySummary struct {
|
||||||
|
DisplayName string
|
||||||
|
CombatLevel int
|
||||||
|
MiningSkill int
|
||||||
|
ForagingSkill int
|
||||||
|
Activity string
|
||||||
|
Location string
|
||||||
|
Outcome string
|
||||||
|
LootValue int64
|
||||||
|
IsDead bool
|
||||||
|
DeadUntil string
|
||||||
|
IsResting bool
|
||||||
|
SummaryLine string
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewardSummary, players []AdvPlayerDaySummary) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("📜 **ADVENTURER DAILY REPORT**\n%s\n\n", date))
|
||||||
|
|
||||||
|
// TwinBee section
|
||||||
|
if tb != nil {
|
||||||
|
sb.WriteString("🐝 **TwinBee's Daily Report**\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("Went to: %s (Tier %d)\n", tb.Location.Name, tb.Location.Tier))
|
||||||
|
|
||||||
|
// One-liner for TwinBee
|
||||||
|
var tbSummaryPool []string
|
||||||
|
switch tb.Outcome {
|
||||||
|
case AdvOutcomeSuccess, AdvOutcomeExceptional:
|
||||||
|
tbSummaryPool = TwinBeeSummarySuccess
|
||||||
|
case AdvOutcomeEmpty, AdvOutcomeHornets, AdvOutcomeCaveIn, AdvOutcomeBear, AdvOutcomeRiver:
|
||||||
|
tbSummaryPool = TwinBeeSummaryWithdrawal
|
||||||
|
default:
|
||||||
|
tbSummaryPool = TwinBeeSummaryEmpty
|
||||||
|
}
|
||||||
|
if len(tbSummaryPool) > 0 {
|
||||||
|
line := tbSummaryPool[rand.IntN(len(tbSummaryPool))]
|
||||||
|
line = advSubstituteFlavor(line, map[string]string{
|
||||||
|
"{location}": tb.Location.Name,
|
||||||
|
"{loot}": tb.LootDesc,
|
||||||
|
"{value}": fmt.Sprintf("%d", tb.LootValue),
|
||||||
|
})
|
||||||
|
sb.WriteString(fmt.Sprintf("Outcome: %s\n", line))
|
||||||
|
}
|
||||||
|
|
||||||
|
if tb.LootValue > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("Haul: €%d in %s\n", tb.LootValue, tb.LootDesc))
|
||||||
|
} else {
|
||||||
|
sb.WriteString("Haul: Strategic withdrawal. No haul.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
if tbRewards.Eligible > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers:\n", tbRewards.Eligible))
|
||||||
|
if tbRewards.GoldShare > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf(" 💰 €%d each\n", tbRewards.GoldShare))
|
||||||
|
}
|
||||||
|
if tbRewards.GiftCount > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf(" ⭐ %d players received a gift item\n", tbRewards.GiftCount))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString("\n(Players who rested today received nothing. TwinBee noticed.)\n\n")
|
||||||
|
sb.WriteString("───────────────────\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player summaries
|
||||||
|
var dead []AdvPlayerDaySummary
|
||||||
|
var resting []AdvPlayerDaySummary
|
||||||
|
var bestPlayer *AdvPlayerDaySummary
|
||||||
|
var worstPlayer *AdvPlayerDaySummary
|
||||||
|
|
||||||
|
for i := range players {
|
||||||
|
p := &players[i]
|
||||||
|
if p.IsDead {
|
||||||
|
dead = append(dead, *p)
|
||||||
|
if worstPlayer == nil {
|
||||||
|
worstPlayer = p
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.IsResting {
|
||||||
|
resting = append(resting, *p)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("⚔️ **%s** — Combat Lv.%d | Mining Lv.%d | Forage Lv.%d\n",
|
||||||
|
p.DisplayName, p.CombatLevel, p.MiningSkill, p.ForagingSkill))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine))
|
||||||
|
|
||||||
|
if bestPlayer == nil || p.LootValue > bestPlayer.LootValue {
|
||||||
|
bestPlayer = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dead list
|
||||||
|
if len(dead) > 0 {
|
||||||
|
sb.WriteString("💀 **Currently Dead:**\n")
|
||||||
|
for _, d := range dead {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s — returns %s\n", d.DisplayName, d.DeadUntil))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resting list
|
||||||
|
if len(resting) > 0 {
|
||||||
|
sb.WriteString("😴 **Resting/Idle:**\n")
|
||||||
|
for _, r := range resting {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s\n", r.DisplayName))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standout
|
||||||
|
if bestPlayer != nil && bestPlayer.LootValue > 0 {
|
||||||
|
pool := SummaryStandoutGood
|
||||||
|
if len(pool) > 0 {
|
||||||
|
line := pool[rand.IntN(len(pool))]
|
||||||
|
line = advSubstituteFlavor(line, map[string]string{
|
||||||
|
"{name}": bestPlayer.DisplayName,
|
||||||
|
"{item}": "",
|
||||||
|
"{value}": fmt.Sprintf("%d", bestPlayer.LootValue),
|
||||||
|
"{location}": bestPlayer.Location,
|
||||||
|
})
|
||||||
|
sb.WriteString(fmt.Sprintf("🏆 **Today's standout:** %s\n", line))
|
||||||
|
}
|
||||||
|
} else if worstPlayer != nil {
|
||||||
|
pool := SummaryStandoutDeath
|
||||||
|
if len(pool) > 0 {
|
||||||
|
line := pool[rand.IntN(len(pool))]
|
||||||
|
line = advSubstituteFlavor(line, map[string]string{
|
||||||
|
"{name}": worstPlayer.DisplayName,
|
||||||
|
"{location}": worstPlayer.Location,
|
||||||
|
})
|
||||||
|
sb.WriteString(fmt.Sprintf("🏆 **Today's standout:** %s\n", line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Leaderboard ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func renderAdvLeaderboard(chars []AdventureCharacter) string {
|
||||||
|
if len(chars) == 0 {
|
||||||
|
return "No adventurers registered yet. Type `!adventure` to begin."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score
|
||||||
|
type entry struct {
|
||||||
|
Name string
|
||||||
|
Score int
|
||||||
|
Levels string
|
||||||
|
Streak int
|
||||||
|
}
|
||||||
|
var entries []entry
|
||||||
|
for _, c := range chars {
|
||||||
|
score := (c.CombatLevel + c.MiningSkill + c.ForagingSkill) * 10
|
||||||
|
entries = append(entries, entry{
|
||||||
|
Name: c.DisplayName,
|
||||||
|
Score: score,
|
||||||
|
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d", c.CombatLevel, c.MiningSkill, c.ForagingSkill),
|
||||||
|
Streak: c.CurrentStreak,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple sort (small list)
|
||||||
|
for i := range entries {
|
||||||
|
for j := i + 1; j < len(entries); j++ {
|
||||||
|
if entries[j].Score > entries[i].Score {
|
||||||
|
entries[i], entries[j] = entries[j], entries[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("🏆 **Adventure Leaderboard**\n\n")
|
||||||
|
|
||||||
|
limit := 10
|
||||||
|
if len(entries) < limit {
|
||||||
|
limit = len(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
medals := []string{"🥇", "🥈", "🥉"}
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
e := entries[i]
|
||||||
|
prefix := fmt.Sprintf("%2d.", i+1)
|
||||||
|
if i < len(medals) {
|
||||||
|
prefix = medals[i]
|
||||||
|
}
|
||||||
|
streak := ""
|
||||||
|
if e.Streak >= 7 {
|
||||||
|
streak = fmt.Sprintf(" 🔥%d", e.Streak)
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%s **%s** — %s (score: %d%s)\n", prefix, e.Name, e.Levels, e.Score, streak))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Treasure Discard Prompt ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func renderAdvTreasureDiscardPrompt(newTreasure *AdvTreasureDef, existing []AdvTreasureDef) string {
|
||||||
|
// Pick from treasure inventory cap pool
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
if len(TreasureInventoryCap) > 0 {
|
||||||
|
text := TreasureInventoryCap[rand.IntN(len(TreasureInventoryCap))]
|
||||||
|
text = advSubstituteFlavor(text, map[string]string{
|
||||||
|
"{treasure_name}": newTreasure.Name,
|
||||||
|
})
|
||||||
|
sb.WriteString(text)
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("Current treasures:\n")
|
||||||
|
for i, t := range existing {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %d. %s\n", i+1, t.InventoryDesc))
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("\nNew: %s\n", newTreasure.InventoryDesc))
|
||||||
|
sb.WriteString("\nReply with 1, 2, or 3 to discard, or `keep` to leave the new treasure behind.")
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Summary One-Liners ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func advSummaryOneLiner(userID id.UserID, activity AdvActivityType, outcome AdvOutcomeType, lootValue int64, location string) string {
|
||||||
|
var pool []string
|
||||||
|
|
||||||
|
switch activity {
|
||||||
|
case AdvActivityDungeon:
|
||||||
|
switch outcome {
|
||||||
|
case AdvOutcomeDeath:
|
||||||
|
pool = SummaryDungeonDeath
|
||||||
|
case AdvOutcomeEmpty:
|
||||||
|
pool = SummaryDungeonEmpty
|
||||||
|
case AdvOutcomeSuccess:
|
||||||
|
pool = SummaryDungeonSuccess
|
||||||
|
case AdvOutcomeExceptional:
|
||||||
|
pool = SummaryDungeonExceptional
|
||||||
|
}
|
||||||
|
case AdvActivityMining:
|
||||||
|
switch outcome {
|
||||||
|
case AdvOutcomeDeath:
|
||||||
|
pool = SummaryMiningDeath
|
||||||
|
case AdvOutcomeEmpty, AdvOutcomeCaveIn:
|
||||||
|
pool = SummaryMiningEmpty
|
||||||
|
case AdvOutcomeSuccess, AdvOutcomeExceptional:
|
||||||
|
pool = SummaryMiningSuccess
|
||||||
|
}
|
||||||
|
case AdvActivityForaging:
|
||||||
|
switch outcome {
|
||||||
|
case AdvOutcomeDeath:
|
||||||
|
pool = SummaryForagingDeath
|
||||||
|
case AdvOutcomeHornets:
|
||||||
|
pool = SummaryForagingHornets
|
||||||
|
case AdvOutcomeBear:
|
||||||
|
pool = SummaryForagingBear
|
||||||
|
case AdvOutcomeRiver:
|
||||||
|
pool = SummaryForagingRiver
|
||||||
|
case AdvOutcomeEmpty:
|
||||||
|
pool = SummaryForagingSuccess // no specific empty pool for foraging
|
||||||
|
case AdvOutcomeSuccess, AdvOutcomeExceptional:
|
||||||
|
pool = SummaryForagingSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pool) == 0 {
|
||||||
|
return fmt.Sprintf("Went to %s. Things happened.", location)
|
||||||
|
}
|
||||||
|
|
||||||
|
text, _ := advPickFlavor(pool, userID, fmt.Sprintf("summary_%s_%s", activity, outcome))
|
||||||
|
return advSubstituteFlavor(text, map[string]string{
|
||||||
|
"{name}": "",
|
||||||
|
"{item}": "",
|
||||||
|
"{value}": fmt.Sprintf("%d", lootValue),
|
||||||
|
"{location}": location,
|
||||||
|
})
|
||||||
|
}
|
||||||
308
internal/plugin/adventure_scheduler.go
Normal file
308
internal/plugin/adventure_scheduler.go
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Morning DM Ticker ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) morningTicker() {
|
||||||
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if now.Hour() != p.morningHour || now.Minute() != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dateKey := now.Format("2006-01-02")
|
||||||
|
jobName := "adventure_morning"
|
||||||
|
if db.JobCompleted(jobName, dateKey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("adventure: sending morning DMs")
|
||||||
|
p.sendMorningDMs()
|
||||||
|
db.MarkJobCompleted(jobName, dateKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) sendMorningDMs() {
|
||||||
|
chars, err := loadAllAdvCharacters()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("adventure: failed to load characters for morning DMs", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
for _, char := range chars {
|
||||||
|
char := char
|
||||||
|
|
||||||
|
// Check if dead and ready to respawn
|
||||||
|
if !char.Alive && char.DeadUntil != nil && now.After(*char.DeadUntil) {
|
||||||
|
// Revive
|
||||||
|
char.Alive = true
|
||||||
|
char.DeadUntil = nil
|
||||||
|
if err := saveAdvCharacter(&char); err != nil {
|
||||||
|
slog.Error("adventure: failed to revive character", "user", char.UserID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send respawn DM
|
||||||
|
text := renderAdvRespawnDM(&char)
|
||||||
|
if err := p.SendDM(char.UserID, text); err != nil {
|
||||||
|
slog.Error("adventure: failed to send respawn DM", "user", char.UserID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If still dead, send death status
|
||||||
|
if !char.Alive {
|
||||||
|
text := renderAdvDeathStatusDM(&char)
|
||||||
|
if err := p.SendDM(char.UserID, text); err != nil {
|
||||||
|
slog.Error("adventure: failed to send death status DM", "user", char.UserID, "err", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already acted today, skip
|
||||||
|
if char.ActionTakenToday {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send morning DM with choices
|
||||||
|
equip, err := loadAdvEquipment(char.UserID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("adventure: failed to load equipment for morning DM", "user", char.UserID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||||
|
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||||
|
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||||
|
balance := p.euro.GetBalance(char.UserID)
|
||||||
|
|
||||||
|
text := renderAdvMorningDM(&char, equip, balance, bonuses)
|
||||||
|
if err := p.SendDM(char.UserID, text); err != nil {
|
||||||
|
slog.Error("adventure: failed to send morning DM", "user", char.UserID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register DM room for reply routing
|
||||||
|
p.registerDMRoom(char.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Evening Summary Ticker ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) summaryTicker() {
|
||||||
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if now.Hour() != p.summaryHour || now.Minute() != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dateKey := now.Format("2006-01-02")
|
||||||
|
jobName := "adventure_summary"
|
||||||
|
if db.JobCompleted(jobName, dateKey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("adventure: posting daily summary")
|
||||||
|
p.postDailySummary()
|
||||||
|
db.MarkJobCompleted(jobName, dateKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) postDailySummary() {
|
||||||
|
gr := gamesRoom()
|
||||||
|
if gr == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run TwinBee daily action
|
||||||
|
tbResult := p.runTwinBeeDaily()
|
||||||
|
tbRewards := p.distributeTwinBeeRewards(tbResult)
|
||||||
|
|
||||||
|
// Load all characters and today's logs
|
||||||
|
chars, err := loadAllAdvCharacters()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("adventure: failed to load characters for summary", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
todayLogs, _ := loadAdvTodayLogs()
|
||||||
|
logMap := make(map[id.UserID]*AdvDayLog)
|
||||||
|
for i := range todayLogs {
|
||||||
|
logMap[todayLogs[i].UserID] = &todayLogs[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build player summaries
|
||||||
|
var players []AdvPlayerDaySummary
|
||||||
|
for _, c := range chars {
|
||||||
|
ps := AdvPlayerDaySummary{
|
||||||
|
DisplayName: c.DisplayName,
|
||||||
|
CombatLevel: c.CombatLevel,
|
||||||
|
MiningSkill: c.MiningSkill,
|
||||||
|
ForagingSkill: c.ForagingSkill,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.Alive {
|
||||||
|
ps.IsDead = true
|
||||||
|
if c.DeadUntil != nil {
|
||||||
|
ps.DeadUntil = c.DeadUntil.Format("15:04") + " UTC"
|
||||||
|
}
|
||||||
|
// Check if they died today
|
||||||
|
if log, ok := logMap[c.UserID]; ok {
|
||||||
|
ps.Activity = log.ActivityType
|
||||||
|
ps.Location = log.Location
|
||||||
|
ps.Outcome = log.Outcome
|
||||||
|
ps.LootValue = log.LootValue
|
||||||
|
ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), log.LootValue, log.Location)
|
||||||
|
}
|
||||||
|
players = append(players, ps)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.ActionTakenToday {
|
||||||
|
ps.IsResting = true
|
||||||
|
if len(SummaryResting) > 0 {
|
||||||
|
ps.SummaryLine = SummaryResting[time.Now().Nanosecond()%len(SummaryResting)]
|
||||||
|
}
|
||||||
|
players = append(players, ps)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active player with today's log
|
||||||
|
if log, ok := logMap[c.UserID]; ok {
|
||||||
|
ps.Activity = log.ActivityType
|
||||||
|
ps.Location = log.Location
|
||||||
|
ps.Outcome = log.Outcome
|
||||||
|
ps.LootValue = log.LootValue
|
||||||
|
ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), log.LootValue, log.Location)
|
||||||
|
}
|
||||||
|
|
||||||
|
players = append(players, ps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check party bonuses and add to summary
|
||||||
|
for i := range players {
|
||||||
|
if players[i].Location != "" && !players[i].IsDead && !players[i].IsResting {
|
||||||
|
for j := i + 1; j < len(players); j++ {
|
||||||
|
if players[j].Location == players[i].Location && !players[j].IsDead && !players[j].IsResting {
|
||||||
|
players[i].SummaryLine += fmt.Sprintf(" (Party bonus with %s!)", players[j].DisplayName)
|
||||||
|
players[j].SummaryLine += fmt.Sprintf(" (Party bonus with %s!)", players[i].DisplayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
date := time.Now().UTC().Format("2006-01-02")
|
||||||
|
summary := renderAdvDailySummary(date, tbResult, tbRewards, players)
|
||||||
|
|
||||||
|
if err := p.SendMessage(id.RoomID(gr), summary); err != nil {
|
||||||
|
slog.Error("adventure: failed to post daily summary", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Midnight Reset Ticker ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) midnightTicker() {
|
||||||
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if now.Hour() != 0 || now.Minute() != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dateKey := now.Format("2006-01-02")
|
||||||
|
jobName := "adventure_midnight"
|
||||||
|
if db.JobCompleted(jobName, dateKey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("adventure: midnight reset")
|
||||||
|
p.midnightReset()
|
||||||
|
db.MarkJobCompleted(jobName, dateKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) midnightReset() {
|
||||||
|
// Send idle shame DMs to players who didn't act
|
||||||
|
chars, err := loadAllAdvCharacters()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("adventure: midnight reset failed to load chars", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
|
for _, char := range chars {
|
||||||
|
if !char.Alive {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !char.ActionTakenToday {
|
||||||
|
// Idle shame DM
|
||||||
|
text := renderAdvIdleShameDM(&char)
|
||||||
|
if err := p.SendDM(char.UserID, text); err != nil {
|
||||||
|
slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset streak
|
||||||
|
if char.CurrentStreak > 0 {
|
||||||
|
char.CurrentStreak = 0
|
||||||
|
_ = saveAdvCharacter(&char)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update streak
|
||||||
|
if char.LastActionDate == time.Now().UTC().Add(-24*time.Hour).Format("2006-01-02") {
|
||||||
|
char.CurrentStreak++
|
||||||
|
} else if char.LastActionDate != today {
|
||||||
|
char.CurrentStreak = 1
|
||||||
|
}
|
||||||
|
if char.CurrentStreak > char.BestStreak {
|
||||||
|
char.BestStreak = char.CurrentStreak
|
||||||
|
}
|
||||||
|
char.LastActionDate = today
|
||||||
|
_ = saveAdvCharacter(&char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset all daily actions
|
||||||
|
if err := resetAllAdvDailyActions(); err != nil {
|
||||||
|
slog.Error("adventure: failed to reset daily actions", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune expired buffs
|
||||||
|
if err := pruneAdvExpiredBuffs(); err != nil {
|
||||||
|
slog.Error("adventure: failed to prune expired buffs", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear flavor history to prevent unbounded memory growth.
|
||||||
|
// Entries are only used for dedup within a day, so clearing at midnight is fine.
|
||||||
|
advClearFlavorHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) registerDMRoom(userID id.UserID) {
|
||||||
|
room, err := p.GetDMRoom(userID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
p.dmToPlayer[room] = userID
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
187
internal/plugin/adventure_shop.go
Normal file
187
internal/plugin/adventure_shop.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Shop Listings ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func advShopListings(equip map[EquipmentSlot]*AdvEquipment, balance float64) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("🛒 **Equipment Shop**\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("💰 Your balance: €%.0f\n\n", balance))
|
||||||
|
|
||||||
|
for _, slot := range allSlots {
|
||||||
|
current := equip[slot]
|
||||||
|
currentTier := 0
|
||||||
|
if current != nil {
|
||||||
|
currentTier = current.Tier
|
||||||
|
}
|
||||||
|
|
||||||
|
defs := equipmentTiers[slot]
|
||||||
|
currentName := "None"
|
||||||
|
if current != nil {
|
||||||
|
currentName = current.Name
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("**%s** (current: %s, Tier %d)\n", strings.Title(string(slot)), currentName, currentTier))
|
||||||
|
|
||||||
|
hasUpgrades := false
|
||||||
|
for _, def := range defs {
|
||||||
|
if def.Tier <= currentTier || def.Price == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hasUpgrades = true
|
||||||
|
affordable := ""
|
||||||
|
if balance < def.Price {
|
||||||
|
affordable = " ❌"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" Tier %d: %s — €%.0f%s\n", def.Tier, def.Name, def.Price, affordable))
|
||||||
|
sb.WriteString(fmt.Sprintf(" _%s_\n", advTruncate(def.Description, 120)))
|
||||||
|
}
|
||||||
|
if !hasUpgrades {
|
||||||
|
sb.WriteString(" ✨ Max tier reached!\n")
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("To buy: reply with `buy <item name>`\n")
|
||||||
|
sb.WriteString("Example: `buy Sad Iron Sword`")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func advTruncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n-3] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find Shop Item ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func advFindShopItem(name string) (EquipmentSlot, *EquipmentDef, bool) {
|
||||||
|
name = strings.ToLower(strings.TrimSpace(name))
|
||||||
|
for _, slot := range allSlots {
|
||||||
|
for i := range equipmentTiers[slot] {
|
||||||
|
def := &equipmentTiers[slot][i]
|
||||||
|
if def.Price == 0 {
|
||||||
|
continue // can't buy tier 0
|
||||||
|
}
|
||||||
|
if strings.EqualFold(def.Name, name) || containsFold(def.Name, name) {
|
||||||
|
return slot, def, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Buy Equipment ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot, def *EquipmentDef, equip map[EquipmentSlot]*AdvEquipment) string {
|
||||||
|
current := equip[slot]
|
||||||
|
if current != nil && current.Tier >= def.Tier {
|
||||||
|
return fmt.Sprintf("You already have %s (Tier %d). %s is Tier %d. That's not an upgrade, that's a lateral move at best.",
|
||||||
|
current.Name, current.Tier, def.Name, def.Tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
balance := p.euro.GetBalance(userID)
|
||||||
|
if balance < def.Price {
|
||||||
|
return fmt.Sprintf("You cannot afford %s. You have €%.0f. %s costs €%.0f. "+
|
||||||
|
"The gap between these numbers is both mathematical and deeply personal. "+
|
||||||
|
"Go do something about it. Not gambling. You know what I mean.",
|
||||||
|
def.Name, balance, def.Name, def.Price)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.euro.Debit(userID, def.Price, "adventure_shop_"+string(slot)) {
|
||||||
|
return "Transaction failed. The economy is having a moment."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update equipment
|
||||||
|
eq := &AdvEquipment{
|
||||||
|
Slot: slot,
|
||||||
|
Tier: def.Tier,
|
||||||
|
Condition: 100,
|
||||||
|
Name: def.Name,
|
||||||
|
ActionsUsed: 0,
|
||||||
|
}
|
||||||
|
if err := saveAdvEquipment(userID, eq); err != nil {
|
||||||
|
// Refund on DB error
|
||||||
|
p.euro.Credit(userID, def.Price, "adventure_shop_refund")
|
||||||
|
return "Something went wrong saving your equipment. You've been refunded."
|
||||||
|
}
|
||||||
|
equip[slot] = eq
|
||||||
|
|
||||||
|
return fmt.Sprintf("You have purchased **%s** for €%.0f.\n\n_%s_\n\n"+
|
||||||
|
"This is an upgrade over what you had. That bar was underground, but you cleared it. "+
|
||||||
|
"Equip it now before you do something stupid with the money instead.",
|
||||||
|
def.Name, def.Price, def.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sell Items ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
|
||||||
|
items, err := clearAdvInventory(userID)
|
||||||
|
if err != nil {
|
||||||
|
return "Failed to access your inventory. Try again."
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "Your inventory is empty. There is nothing to sell. This is a metaphor for something but now is not the time."
|
||||||
|
}
|
||||||
|
|
||||||
|
var total float64
|
||||||
|
for _, item := range items {
|
||||||
|
total += float64(item.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.euro.Credit(userID, total, "adventure_sell_all")
|
||||||
|
|
||||||
|
return fmt.Sprintf("Sold %d items for **€%.0f**.\n\nThe merchant took everything without comment. "+
|
||||||
|
"This is the most respect anyone has shown your loot collection. Take the money.",
|
||||||
|
len(items), total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) advSellItem(userID id.UserID, itemName string) string {
|
||||||
|
items, err := loadAdvInventory(userID)
|
||||||
|
if err != nil {
|
||||||
|
return "Failed to access your inventory."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fuzzy match
|
||||||
|
for _, item := range items {
|
||||||
|
if containsFold(item.Name, itemName) {
|
||||||
|
if err := removeAdvInventoryItem(item.ID); err != nil {
|
||||||
|
return "Failed to sell that item."
|
||||||
|
}
|
||||||
|
p.euro.Credit(userID, float64(item.Value), "adventure_sell_"+item.Name)
|
||||||
|
return fmt.Sprintf("Sold **%s** for **€%d**. The merchant nodded. That's it. That's the transaction.", item.Name, item.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("No item matching '%s' found in your inventory.", itemName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inventory Display ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func advInventoryDisplay(userID id.UserID) string {
|
||||||
|
items, err := loadAdvInventory(userID)
|
||||||
|
if err != nil {
|
||||||
|
return "Failed to load inventory."
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "🎒 Your inventory is empty. Go adventure."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("🎒 **Inventory**\n\n")
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
for _, item := range items {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s (T%d %s) — €%d\n", item.Name, item.Tier, item.Type, item.Value))
|
||||||
|
total += item.Value
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("\n%d items — total value ~€%d", len(items), total))
|
||||||
|
sb.WriteString("\n\nTo sell: `!adventure sell all` or `!adventure sell <item>`")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
376
internal/plugin/adventure_treasure.go
Normal file
376
internal/plugin/adventure_treasure.go
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Treasure Bonus Type (DB row) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
type AdvTreasureBonus struct {
|
||||||
|
TreasureKey string
|
||||||
|
Name string
|
||||||
|
Tier int
|
||||||
|
BonusType string
|
||||||
|
BonusValue float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Treasure Definition ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type AdvTreasureDef struct {
|
||||||
|
Key string
|
||||||
|
Name string
|
||||||
|
Tier int
|
||||||
|
Bonuses []advTreasureBonusDef
|
||||||
|
InventoryDesc string
|
||||||
|
RoomAnnounce string // non-empty for tier 5 and special items
|
||||||
|
}
|
||||||
|
|
||||||
|
type advTreasureBonusDef struct {
|
||||||
|
Type string
|
||||||
|
Value float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdvTreasureDrop struct {
|
||||||
|
Def *AdvTreasureDef
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drop Rates ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var advTreasureDropRates = map[int]float64{
|
||||||
|
1: 0.015,
|
||||||
|
2: 0.012,
|
||||||
|
3: 0.008,
|
||||||
|
4: 0.004,
|
||||||
|
5: 0.0015,
|
||||||
|
}
|
||||||
|
|
||||||
|
const advMaxTreasures = 3
|
||||||
|
|
||||||
|
// ── Treasure Definitions ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var advAllTreasures = map[int][]AdvTreasureDef{
|
||||||
|
1: {
|
||||||
|
{
|
||||||
|
Key: "soggy_cellar_crown_jewel", Name: "The Soggy Cellar Crown Jewel", Tier: 1,
|
||||||
|
Bonuses: []advTreasureBonusDef{{Type: "foraging_skill", Value: 1}},
|
||||||
|
InventoryDesc: "The Soggy Cellar Crown Jewel. A button. +1 Foraging.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "1up_mushroom_expired", Name: "The 1-Up Mushroom (Expired)", Tier: 1,
|
||||||
|
Bonuses: []advTreasureBonusDef{{Type: "special_respawn_halve", Value: 1}}, // v2: not active in v1
|
||||||
|
InventoryDesc: "The 1-Up Mushroom (Expired). Slightly grey. Once weekly: 12hr respawn.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "rat_king_lucky_foot", Name: "The Rat King's Lucky Foot", Tier: 1,
|
||||||
|
Bonuses: []advTreasureBonusDef{{Type: "death_chance", Value: -2}},
|
||||||
|
InventoryDesc: "The Rat King's Lucky Foot. Slightly damp. -2% death chance.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "ancient_cellar_medallion", Name: "Ancient Cellar Medallion (Probably)", Tier: 1,
|
||||||
|
Bonuses: []advTreasureBonusDef{{Type: "xp_multiplier", Value: 5}},
|
||||||
|
InventoryDesc: "Ancient Cellar Medallion (Probably). Illegible engravings. +5% dungeon XP.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "bent_copper_coin", Name: "The Considerably Bent Copper Coin of Fortune", Tier: 1,
|
||||||
|
Bonuses: []advTreasureBonusDef{{Type: "loot_quality", Value: 2}},
|
||||||
|
InventoryDesc: "The Considerably Bent Copper Coin of Fortune. Very bent. +2% loot quality.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "wooden_sword", Name: "The Wooden Sword (It Was Dangerous to Go Alone)", Tier: 1,
|
||||||
|
Bonuses: []advTreasureBonusDef{{Type: "combat_level", Value: 1}},
|
||||||
|
InventoryDesc: "The Wooden Sword. From an old man. +1 Combat. It was dangerous to go alone.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "suspicious_mushroom", Name: "Suspicious Mushroom (Do Not Ask Where It's Been)", Tier: 1,
|
||||||
|
Bonuses: []advTreasureBonusDef{{Type: "exceptional_chance", Value: 2}},
|
||||||
|
InventoryDesc: "Suspicious Mushroom. Glowing. Humming. Do not ask. +2% exceptional chance.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "power_pellet", Name: "Power Pellet (Slightly Past Its Best)", Tier: 1,
|
||||||
|
Bonuses: []advTreasureBonusDef{{Type: "death_chance", Value: -3}},
|
||||||
|
InventoryDesc: "Power Pellet (Vintage). -3% death chance. The monsters remember.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "konami_scroll", Name: "The Konami Scroll", Tier: 1,
|
||||||
|
Bonuses: []advTreasureBonusDef{
|
||||||
|
{Type: "combat_level", Value: 2},
|
||||||
|
{Type: "mining_skill", Value: 2},
|
||||||
|
{Type: "foraging_skill", Value: 2},
|
||||||
|
},
|
||||||
|
InventoryDesc: "The Konami Scroll. ↑↑↓↓←→←→. +2 all skills. You know what it means.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
{
|
||||||
|
Key: "goblin_warchief_ring", Name: "Goblin Warchief's Signet Ring", Tier: 2,
|
||||||
|
Bonuses: []advTreasureBonusDef{{Type: "combat_level", Value: 2}},
|
||||||
|
InventoryDesc: "Goblin Warchief's Signet Ring. Thumb-sized. +2 Combat.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "kobold_compass", Name: "The Kobold Cartographer's Compass", Tier: 2,
|
||||||
|
Bonuses: []advTreasureBonusDef{{Type: "mining_skill", Value: 3}},
|
||||||
|
InventoryDesc: "Kobold Cartographer's Compass. Fish bone needle. +3 Mining.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "spread_gun", Name: "The Spread Gun", Tier: 2,
|
||||||
|
Bonuses: []advTreasureBonusDef{
|
||||||
|
{Type: "combat_level", Value: 4},
|
||||||
|
{Type: "success_chance", Value: 8},
|
||||||
|
},
|
||||||
|
InventoryDesc: "The Spread Gun. You know. +4 Combat, +8% dungeon success.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "e_tank", Name: "E-Tank (Partially Full, Handle With Care)", Tier: 2,
|
||||||
|
Bonuses: []advTreasureBonusDef{{Type: "special_monthly_respawn", Value: 1}}, // v2: not active in v1
|
||||||
|
InventoryDesc: "E-Tank (Half Full). 12hr respawn on 2nd monthly death. Handle with care.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "stone_of_jordan", Name: "The Stone of Jordan", Tier: 2,
|
||||||
|
Bonuses: []advTreasureBonusDef{
|
||||||
|
{Type: "mining_skill", Value: 3},
|
||||||
|
{Type: "foraging_skill", Value: 3},
|
||||||
|
},
|
||||||
|
InventoryDesc: "The Stone of Jordan. Fits perfectly. Always. +3 Mining/Foraging.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
{
|
||||||
|
Key: "draugr_memory_stone", Name: "The Draugr's Memory Stone", Tier: 3,
|
||||||
|
Bonuses: []advTreasureBonusDef{
|
||||||
|
{Type: "combat_level", Value: 5},
|
||||||
|
{Type: "special_weekly_death_pass", Value: 1}, // v2
|
||||||
|
},
|
||||||
|
InventoryDesc: "The Draugr's Memory Stone. Warm. +5 Combat, weekly death pass.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "ghost_touched_lantern", Name: "Ghost-Touched Lantern", Tier: 3,
|
||||||
|
Bonuses: []advTreasureBonusDef{
|
||||||
|
{Type: "foraging_skill", Value: 4},
|
||||||
|
{Type: "mining_skill", Value: 4},
|
||||||
|
},
|
||||||
|
InventoryDesc: "Ghost-Touched Lantern. Glows without fuel. +4 Foraging, +4 Mining.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "estus_flask", Name: "The Estus Flask (Cracked But Functional)", Tier: 3,
|
||||||
|
Bonuses: []advTreasureBonusDef{{Type: "death_chance", Value: -6}},
|
||||||
|
InventoryDesc: "Estus Flask (Cracked). Warm. Always warm. -6% death, daily death mitigation.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "twinbee_bell", Name: "TwinBee's Bell (Genuine Article)", Tier: 3,
|
||||||
|
Bonuses: []advTreasureBonusDef{
|
||||||
|
{Type: "all_skills", Value: 6},
|
||||||
|
{Type: "xp_multiplier", Value: 5},
|
||||||
|
},
|
||||||
|
InventoryDesc: "TwinBee's Bell. Rings on its own. Approves of you. +6 all skills, +5% XP.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
{
|
||||||
|
Key: "hollow_crown", Name: "The Hollow Crown", Tier: 4,
|
||||||
|
Bonuses: []advTreasureBonusDef{
|
||||||
|
{Type: "combat_level", Value: 10},
|
||||||
|
{Type: "exceptional_chance", Value: 5},
|
||||||
|
},
|
||||||
|
InventoryDesc: "The Hollow Crown. Sits crooked. +10 Combat, +5% exceptional dungeon chance.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "wardens_last_key", Name: "Warden's Last Key", Tier: 4,
|
||||||
|
Bonuses: []advTreasureBonusDef{
|
||||||
|
{Type: "mining_skill", Value: 8},
|
||||||
|
{Type: "combat_level", Value: 8},
|
||||||
|
{Type: "special_sealed_vault", Value: 1}, // v2
|
||||||
|
},
|
||||||
|
InventoryDesc: "Warden's Last Key. Cold to the touch. +8 Mining/Combat. One use.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "thunderfury", Name: "[THUNDERFURY, BLESSED BLADE OF THE WINDSEEKER]", Tier: 4,
|
||||||
|
Bonuses: []advTreasureBonusDef{
|
||||||
|
{Type: "combat_level", Value: 12},
|
||||||
|
{Type: "success_chance", Value: 10},
|
||||||
|
},
|
||||||
|
InventoryDesc: "[THUNDERFURY, BLESSED BLADE OF THE WINDSEEKER]. Yes you got it. +12 Combat.",
|
||||||
|
RoomAnnounce: "⚡ Did {name} get Thunderfury? {name} got Thunderfury. [THUNDERFURY, BLESSED BLADE OF THE WINDSEEKER] has been found in {location}.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "ocarina", Name: "The Ocarina (Cracked, Still Plays)", Tier: 4,
|
||||||
|
Bonuses: []advTreasureBonusDef{{Type: "all_skills", Value: 10}},
|
||||||
|
InventoryDesc: "The Ocarina (Cracked). Three songs. +10 all skills. Do not play the third one.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
{
|
||||||
|
Key: "shard_of_unnamed", Name: "Shard of the Unnamed", Tier: 5,
|
||||||
|
Bonuses: []advTreasureBonusDef{
|
||||||
|
{Type: "combat_level", Value: 15},
|
||||||
|
{Type: "xp_multiplier", Value: 10},
|
||||||
|
{Type: "death_chance", Value: -5},
|
||||||
|
},
|
||||||
|
InventoryDesc: "Shard of the Unnamed. +15 Combat, +10% XP, -5% death.",
|
||||||
|
RoomAnnounce: "🔴 {name} has recovered the Shard of the Unnamed from the Abyssal Maw. The server feels different.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "cartographers_final_map", Name: "The Cartographer's Final Map", Tier: 5,
|
||||||
|
Bonuses: []advTreasureBonusDef{{Type: "all_skills", Value: 12}},
|
||||||
|
InventoryDesc: "The Cartographer's Final Map. Updates on its own. +12 all skills, full map.",
|
||||||
|
RoomAnnounce: "🔴 {name} has found the Cartographer's Final Map in the Abyssal Maw. It has their name on it. It always did.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "triforce_shard", Name: "The Triforce Shard (One Third of Something Larger)", Tier: 5,
|
||||||
|
Bonuses: []advTreasureBonusDef{
|
||||||
|
{Type: "all_skills", Value: 5},
|
||||||
|
{Type: "death_chance", Value: -8},
|
||||||
|
// Note: +15 to chosen skill is v2 interactive
|
||||||
|
},
|
||||||
|
InventoryDesc: "Triforce Shard (×1/3). Warm. Waiting. +5 all skills, -8% death.",
|
||||||
|
RoomAnnounce: "🔺 {name} has recovered a Triforce Shard from the Abyssal Maw. One third of something. The other two thirds are somewhere. Probably.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "the_corridor", Name: "The Corridor (You Know the One)", Tier: 5,
|
||||||
|
Bonuses: []advTreasureBonusDef{
|
||||||
|
{Type: "all_skills", Value: 12},
|
||||||
|
{Type: "special_monthly_death_bypass", Value: 1}, // v2
|
||||||
|
},
|
||||||
|
InventoryDesc: "The Corridor. Folded. Don't look back. +12 all skills, monthly death bypass.",
|
||||||
|
RoomAnnounce: "🔴 {name} found The Corridor in the Abyssal Maw. They know the one. So does it.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Treasure Drop Logic ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func rollAdvTreasureDrop(tier int, userID id.UserID) *AdvTreasureDrop {
|
||||||
|
rate, ok := advTreasureDropRates[tier]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if rand.Float64() >= rate {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, ok := advAllTreasures[tier]
|
||||||
|
if !ok || len(pool) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick random treasure
|
||||||
|
def := &pool[rand.IntN(len(pool))]
|
||||||
|
|
||||||
|
// Duplicate check
|
||||||
|
owns, err := advUserOwnsTreasure(userID, def.Key)
|
||||||
|
if err != nil || owns {
|
||||||
|
// Reroll once
|
||||||
|
def = &pool[rand.IntN(len(pool))]
|
||||||
|
owns, err = advUserOwnsTreasure(userID, def.Key)
|
||||||
|
if err != nil || owns {
|
||||||
|
return nil // both rolls duplicated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AdvTreasureDrop{Def: def}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Treasure DB Operations ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func advSaveTreasure(userID id.UserID, def *AdvTreasureDef) error {
|
||||||
|
d := db.Get()
|
||||||
|
tx, err := d.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bonus := range def.Bonuses {
|
||||||
|
_, err := tx.Exec(`
|
||||||
|
INSERT OR IGNORE INTO adventure_treasures (user_id, treasure_key, name, tier, bonus_type, bonus_value)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
string(userID), def.Key, def.Name, def.Tier, bonus.Type, bonus.Value)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func advDiscardTreasure(userID id.UserID, treasureKey string) error {
|
||||||
|
d := db.Get()
|
||||||
|
_, err := d.Exec(`DELETE FROM adventure_treasures WHERE user_id = ? AND treasure_key = ?`,
|
||||||
|
string(userID), treasureKey)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func advCountTreasures(userID id.UserID) (int, error) {
|
||||||
|
d := db.Get()
|
||||||
|
var count int
|
||||||
|
err := d.QueryRow(`
|
||||||
|
SELECT COUNT(DISTINCT treasure_key) FROM adventure_treasures WHERE user_id = ?`,
|
||||||
|
string(userID)).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func advUserOwnsTreasure(userID id.UserID, treasureKey string) (bool, error) {
|
||||||
|
d := db.Get()
|
||||||
|
var count int
|
||||||
|
err := d.QueryRow(`
|
||||||
|
SELECT COUNT(*) FROM adventure_treasures WHERE user_id = ? AND treasure_key = ?`,
|
||||||
|
string(userID), treasureKey).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAdvTreasureBonuses(userID id.UserID) ([]AdvTreasureBonus, error) {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(`
|
||||||
|
SELECT treasure_key, name, tier, bonus_type, bonus_value
|
||||||
|
FROM adventure_treasures WHERE user_id = ?`, string(userID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var bonuses []AdvTreasureBonus
|
||||||
|
for rows.Next() {
|
||||||
|
var b AdvTreasureBonus
|
||||||
|
if err := rows.Scan(&b.TreasureKey, &b.Name, &b.Tier, &b.BonusType, &b.BonusValue); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bonuses = append(bonuses, b)
|
||||||
|
}
|
||||||
|
return bonuses, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// advUserTreasures returns the distinct treasures a user owns (for display/discard prompts).
|
||||||
|
func advUserTreasures(userID id.UserID) ([]AdvTreasureDef, error) {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(`
|
||||||
|
SELECT DISTINCT treasure_key, name, tier
|
||||||
|
FROM adventure_treasures WHERE user_id = ?
|
||||||
|
ORDER BY tier, treasure_key`, string(userID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var treasures []AdvTreasureDef
|
||||||
|
for rows.Next() {
|
||||||
|
var t AdvTreasureDef
|
||||||
|
if err := rows.Scan(&t.Key, &t.Name, &t.Tier); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Look up full definition
|
||||||
|
for tier, defs := range advAllTreasures {
|
||||||
|
for _, def := range defs {
|
||||||
|
if def.Key == t.Key {
|
||||||
|
t = def
|
||||||
|
t.Tier = tier
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
treasures = append(treasures, t)
|
||||||
|
}
|
||||||
|
return treasures, rows.Err()
|
||||||
|
}
|
||||||
273
internal/plugin/adventure_twinbee.go
Normal file
273
internal/plugin/adventure_twinbee.go
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── TwinBee Character (fixed stats) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
var twinBeeChar = AdventureCharacter{
|
||||||
|
DisplayName: "TwinBee 🐝",
|
||||||
|
CombatLevel: 35,
|
||||||
|
MiningSkill: 28,
|
||||||
|
ForagingSkill: 22,
|
||||||
|
Alive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var twinBeeEquip = map[EquipmentSlot]*AdvEquipment{
|
||||||
|
SlotWeapon: {Slot: SlotWeapon, Tier: 4, Condition: 100, Name: "The Spread Gun"},
|
||||||
|
SlotArmor: {Slot: SlotArmor, Tier: 4, Condition: 100, Name: "Enchanted Plate"},
|
||||||
|
SlotHelmet: {Slot: SlotHelmet, Tier: 4, Condition: 100, Name: "Guardian's Helm"},
|
||||||
|
SlotBoots: {Slot: SlotBoots, Tier: 4, Condition: 100, Name: "Ranger's Boots"},
|
||||||
|
SlotTool: {Slot: SlotTool, Tier: 4, Condition: 100, Name: "Mithril Pickaxe"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TwinBee Action Selection ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type twinBeeActionWeight struct {
|
||||||
|
Activity AdvActivityType
|
||||||
|
Tier int
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
var twinBeeWeights = []twinBeeActionWeight{
|
||||||
|
{AdvActivityDungeon, 3, 0.35},
|
||||||
|
{AdvActivityDungeon, 4, 0.25},
|
||||||
|
{AdvActivityDungeon, 5, 0.10},
|
||||||
|
{AdvActivityMining, 3, 0.15},
|
||||||
|
{AdvActivityForaging, 3, 0.15},
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectTwinBeeAction() (AdvActivityType, *AdvLocation) {
|
||||||
|
roll := rand.Float64()
|
||||||
|
cumulative := 0.0
|
||||||
|
for _, w := range twinBeeWeights {
|
||||||
|
cumulative += w.Weight
|
||||||
|
if roll < cumulative {
|
||||||
|
loc := findAdvLocationByTier(w.Activity, w.Tier)
|
||||||
|
return w.Activity, loc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback
|
||||||
|
loc := findAdvLocationByTier(AdvActivityDungeon, 3)
|
||||||
|
return AdvActivityDungeon, loc
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TwinBee Result ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type TwinBeeResult struct {
|
||||||
|
Activity AdvActivityType
|
||||||
|
Location *AdvLocation
|
||||||
|
Outcome AdvOutcomeType
|
||||||
|
LootValue int64
|
||||||
|
LootDesc string
|
||||||
|
FlavorText string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) runTwinBeeDaily() *TwinBeeResult {
|
||||||
|
activity, loc := selectTwinBeeAction()
|
||||||
|
if loc == nil {
|
||||||
|
slog.Error("adventure: twinbee could not find location")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
bonuses := &AdvBonusSummary{} // TwinBee has no treasures/buffs
|
||||||
|
result := resolveAdvAction(&twinBeeChar, twinBeeEquip, loc, bonuses, false)
|
||||||
|
|
||||||
|
// TwinBee never dies — reroll death to empty
|
||||||
|
if result.Outcome == AdvOutcomeDeath {
|
||||||
|
result.Outcome = AdvOutcomeEmpty
|
||||||
|
result.LootItems = nil
|
||||||
|
result.TotalLootValue = 0
|
||||||
|
result.EquipDamage = nil
|
||||||
|
result.EquipBroken = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// No treasure drops for TwinBee
|
||||||
|
result.TreasureFound = nil
|
||||||
|
|
||||||
|
// Select TwinBee-specific flavor text
|
||||||
|
tbResult := &TwinBeeResult{
|
||||||
|
Activity: activity,
|
||||||
|
Location: loc,
|
||||||
|
Outcome: result.Outcome,
|
||||||
|
LootValue: result.TotalLootValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build loot description
|
||||||
|
if len(result.LootItems) > 0 {
|
||||||
|
names := make([]string, len(result.LootItems))
|
||||||
|
for i, item := range result.LootItems {
|
||||||
|
names[i] = item.Name
|
||||||
|
}
|
||||||
|
tbResult.LootDesc = joinAdvItems(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select flavor
|
||||||
|
tbResult.FlavorText = p.selectTwinBeeFlavor(tbResult)
|
||||||
|
|
||||||
|
return tbResult
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) selectTwinBeeFlavor(result *TwinBeeResult) string {
|
||||||
|
var pool []string
|
||||||
|
switch result.Outcome {
|
||||||
|
case AdvOutcomeExceptional:
|
||||||
|
pool = TwinBeeExceptional
|
||||||
|
case AdvOutcomeSuccess:
|
||||||
|
pool = TwinBeeSuccess
|
||||||
|
case AdvOutcomeEmpty, AdvOutcomeHornets, AdvOutcomeCaveIn, AdvOutcomeBear, AdvOutcomeRiver:
|
||||||
|
// Check if it was a "tactical withdrawal" (would have been death)
|
||||||
|
// Since we rerolled death → empty, we can't distinguish here.
|
||||||
|
// Use the empty pool; the withdrawal pool is for the summary one-liners.
|
||||||
|
pool = TwinBeeEmpty
|
||||||
|
default:
|
||||||
|
pool = TwinBeeEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pool) == 0 {
|
||||||
|
return "TwinBee went to " + result.Location.Name + ". Results pending."
|
||||||
|
}
|
||||||
|
|
||||||
|
text := pool[rand.IntN(len(pool))]
|
||||||
|
vars := map[string]string{
|
||||||
|
"{location}": result.Location.Name,
|
||||||
|
"{loot}": result.LootDesc,
|
||||||
|
"{value}": fmt.Sprintf("%.0f", float64(result.LootValue)),
|
||||||
|
"{xp}": "0",
|
||||||
|
}
|
||||||
|
return advSubstituteFlavor(text, vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinAdvItems(names []string) string {
|
||||||
|
switch len(names) {
|
||||||
|
case 0:
|
||||||
|
return ""
|
||||||
|
case 1:
|
||||||
|
return names[0]
|
||||||
|
case 2:
|
||||||
|
return names[0] + " and " + names[1]
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%s, and %s", joinAdvItems(names[:len(names)-1]), names[len(names)-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TwinBee Reward Distribution ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
type TwinBeeRewardSummary struct {
|
||||||
|
Eligible int
|
||||||
|
GoldShare int64
|
||||||
|
GiftCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBeeRewardSummary {
|
||||||
|
summary := TwinBeeRewardSummary{}
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find eligible players: alive + took action today
|
||||||
|
chars, err := loadAllAdvCharacters()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("adventure: twinbee rewards failed to load chars", "err", err)
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
var eligible []id.UserID
|
||||||
|
for _, c := range chars {
|
||||||
|
if c.ActionTakenToday && c.Alive {
|
||||||
|
eligible = append(eligible, c.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary.Eligible = len(eligible)
|
||||||
|
|
||||||
|
if len(eligible) == 0 || result.LootValue == 0 {
|
||||||
|
// Gift rolls still happen even if no loot
|
||||||
|
if len(eligible) > 0 {
|
||||||
|
for _, uid := range eligible {
|
||||||
|
if rollTwinBeeGift(uid) {
|
||||||
|
summary.GiftCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.logTwinBeeResult(result, summary)
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distribute gold
|
||||||
|
share := result.LootValue / int64(len(eligible))
|
||||||
|
if share < 1 {
|
||||||
|
share = 1
|
||||||
|
}
|
||||||
|
summary.GoldShare = share
|
||||||
|
|
||||||
|
for _, uid := range eligible {
|
||||||
|
p.euro.Credit(uid, float64(share), "twinbee_daily_share")
|
||||||
|
if rollTwinBeeGift(uid) {
|
||||||
|
summary.GiftCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.logTwinBeeResult(result, summary)
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TwinBee Gifts (Temporary Buffs) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
type twinBeeGiftDef struct {
|
||||||
|
BuffType string
|
||||||
|
BuffName string
|
||||||
|
Modifier float64
|
||||||
|
Duration time.Duration
|
||||||
|
Flavor string
|
||||||
|
}
|
||||||
|
|
||||||
|
var twinBeeGifts = []twinBeeGiftDef{
|
||||||
|
{"success_chance", "TwinBee's Lucky Star ⭐", 10, 24 * time.Hour, "TwinBee sends a star. They have many stars. Use it well."},
|
||||||
|
{"death_chance", "Bec's Blessing 🐝", -5, 48 * time.Hour, "Bec has blessed you. Bec does not do this lightly. Don't die."},
|
||||||
|
{"loot_quality", "WinBee's Coin 🪙", 15, 24 * time.Hour, "WinBee flipped this coin and it came up you. Lucky."},
|
||||||
|
{"mining_success", "Goemon's Pipe 🎋", 8, 48 * time.Hour, "Borrowed from Goemon. Return not expected. Results expected."},
|
||||||
|
{"foraging_death", "Pentarou's Feather 🪶", -10, 24 * time.Hour, "Pentarou parted with this reluctantly. They like you enough. Mostly."},
|
||||||
|
{"xp_multiplier", "TwinBee's Bell Fragment 🔔", 5, 48 * time.Hour, "A piece of the Bell. It rings when you're doing well. It will ring."},
|
||||||
|
{"exceptional_chance", "Power Up Pod 🫛", 50, 48 * time.Hour, "TwinBee found extras. This is not a common occurrence. Don't waste it."},
|
||||||
|
}
|
||||||
|
|
||||||
|
const twinBeeGiftChance = 0.15
|
||||||
|
|
||||||
|
func rollTwinBeeGift(userID id.UserID) bool {
|
||||||
|
if rand.Float64() >= twinBeeGiftChance {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
gift := twinBeeGifts[rand.IntN(len(twinBeeGifts))]
|
||||||
|
expiresAt := time.Now().UTC().Add(gift.Duration)
|
||||||
|
|
||||||
|
if err := addAdvBuff(userID, gift.BuffType, gift.BuffName, gift.Modifier, expiresAt); err != nil {
|
||||||
|
slog.Error("adventure: failed to add twinbee gift", "user", userID, "err", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TwinBee Log ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (p *AdventurePlugin) logTwinBeeResult(result *TwinBeeResult, summary TwinBeeRewardSummary) {
|
||||||
|
d := db.Get()
|
||||||
|
_, err := d.Exec(`
|
||||||
|
INSERT INTO adventure_twinbee_log (activity_type, location, outcome, loot_value, loot_desc, participant_count, gold_share, gift_count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
string(result.Activity), result.Location.Name, string(result.Outcome),
|
||||||
|
result.LootValue, result.LootDesc,
|
||||||
|
summary.Eligible, summary.GoldShare, summary.GiftCount)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("adventure: failed to log twinbee result", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,8 @@ func (p *FlipPlugin) handleGames(ctx MessageContext) error {
|
|||||||
"**!hangman start** — Collaborative Hangman\n"+
|
"**!hangman start** — Collaborative Hangman\n"+
|
||||||
"**!blackjack €amount** — Blackjack (1-4 players vs dealer)\n"+
|
"**!blackjack €amount** — Blackjack (1-4 players vs dealer)\n"+
|
||||||
"**!uno €amount** — UNO (solo or multiplayer, classic or No Mercy)\n"+
|
"**!uno €amount** — UNO (solo or multiplayer, classic or No Mercy)\n"+
|
||||||
|
"**!holdem join** — Texas Hold'em poker (2-9 players)\n"+
|
||||||
|
"**!wordle <word>** — Daily cooperative Wordle\n"+
|
||||||
"**!trivia** — Trivia questions\n\n"+
|
"**!trivia** — Trivia questions\n\n"+
|
||||||
"**Economy:**\n"+
|
"**Economy:**\n"+
|
||||||
"**!balance** — Check your euros\n"+
|
"**!balance** — Check your euros\n"+
|
||||||
|
|||||||
1103
internal/plugin/holdem.go
Normal file
1103
internal/plugin/holdem.go
Normal file
File diff suppressed because it is too large
Load Diff
376
internal/plugin/holdem_betting.go
Normal file
376
internal/plugin/holdem_betting.go
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// postBlinds posts SB and BB, handling heads-up edge case.
|
||||||
|
func (g *HoldemGame) postBlinds() (sbIdx, bbIdx int) {
|
||||||
|
inHand := g.inHandPlayers()
|
||||||
|
n := len(inHand)
|
||||||
|
|
||||||
|
if n == 2 {
|
||||||
|
// Heads-up: dealer posts SB, other posts BB.
|
||||||
|
sbIdx = g.DealerIdx
|
||||||
|
bbIdx = g.nextActiveIdx(g.DealerIdx)
|
||||||
|
} else {
|
||||||
|
sbIdx = g.nextActiveIdx(g.DealerIdx)
|
||||||
|
bbIdx = g.nextActiveIdx(sbIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post small blind.
|
||||||
|
sb := g.Players[sbIdx]
|
||||||
|
sbAmount := g.SmallBlind
|
||||||
|
if sbAmount > sb.Stack {
|
||||||
|
sbAmount = sb.Stack
|
||||||
|
}
|
||||||
|
sb.Stack -= sbAmount
|
||||||
|
sb.Bet = sbAmount
|
||||||
|
sb.TotalBet = sbAmount
|
||||||
|
if sb.Stack == 0 {
|
||||||
|
sb.State = PlayerAllIn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post big blind.
|
||||||
|
bb := g.Players[bbIdx]
|
||||||
|
bbAmount := g.BigBlind
|
||||||
|
if bbAmount > bb.Stack {
|
||||||
|
bbAmount = bb.Stack
|
||||||
|
}
|
||||||
|
bb.Stack -= bbAmount
|
||||||
|
bb.Bet = bbAmount
|
||||||
|
bb.TotalBet = bbAmount
|
||||||
|
if bb.Stack == 0 {
|
||||||
|
bb.State = PlayerAllIn
|
||||||
|
}
|
||||||
|
|
||||||
|
g.CurrentBet = g.BigBlind
|
||||||
|
g.MinRaise = g.BigBlind
|
||||||
|
g.LastAggressorIdx = bbIdx // BB has option
|
||||||
|
|
||||||
|
return sbIdx, bbIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstToActPreflop returns the seat index of the first player to act preflop.
|
||||||
|
func (g *HoldemGame) firstToActPreflop(bbIdx int) int {
|
||||||
|
n := len(g.Players)
|
||||||
|
if n == 2 {
|
||||||
|
// Heads-up: dealer/SB acts first preflop.
|
||||||
|
return g.DealerIdx
|
||||||
|
}
|
||||||
|
// UTG = next active after BB.
|
||||||
|
return g.nextCanActIdx(bbIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstToActPostflop returns the first player to act on post-flop streets.
|
||||||
|
func (g *HoldemGame) firstToActPostflop() int {
|
||||||
|
// First active player after dealer.
|
||||||
|
return g.nextCanActIdx(g.DealerIdx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionResult describes what happened after an action.
|
||||||
|
type ActionResult struct {
|
||||||
|
Announcement string
|
||||||
|
HandOver bool // only 1 player remains
|
||||||
|
StreetOver bool // street betting is complete
|
||||||
|
AllAllIn bool // all remaining players are all-in
|
||||||
|
}
|
||||||
|
|
||||||
|
// doFold processes a fold action.
|
||||||
|
func (g *HoldemGame) doFold(seatIdx int) ActionResult {
|
||||||
|
p := g.Players[seatIdx]
|
||||||
|
p.State = PlayerFolded
|
||||||
|
g.StreetHistory += "f"
|
||||||
|
|
||||||
|
ann := renderActionAnnouncement(p.DisplayName, "fold", 0)
|
||||||
|
|
||||||
|
if g.activeCount() == 1 {
|
||||||
|
return ActionResult{Announcement: ann, HandOver: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ActionResult{
|
||||||
|
Announcement: ann,
|
||||||
|
StreetOver: g.isStreetComplete(g.nextCanActIdx(seatIdx)),
|
||||||
|
AllAllIn: g.canActCount() == 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// doCheck processes a check action. Returns error string if invalid.
|
||||||
|
func (g *HoldemGame) doCheck(seatIdx int) (ActionResult, string) {
|
||||||
|
p := g.Players[seatIdx]
|
||||||
|
if p.Bet < g.CurrentBet {
|
||||||
|
return ActionResult{}, "You must call, raise, or fold — there's a bet to you."
|
||||||
|
}
|
||||||
|
|
||||||
|
g.StreetHistory += "c"
|
||||||
|
ann := renderActionAnnouncement(p.DisplayName, "check", 0)
|
||||||
|
return ActionResult{
|
||||||
|
Announcement: ann,
|
||||||
|
StreetOver: g.isStreetComplete(g.nextCanActIdx(seatIdx)),
|
||||||
|
}, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// doCall processes a call action. Returns error string if nothing to call.
|
||||||
|
func (g *HoldemGame) doCall(seatIdx int) (ActionResult, string) {
|
||||||
|
p := g.Players[seatIdx]
|
||||||
|
toCall := g.CurrentBet - p.Bet
|
||||||
|
if toCall <= 0 {
|
||||||
|
return ActionResult{}, "Nothing to call. Use `!holdem check` instead."
|
||||||
|
}
|
||||||
|
if toCall > p.Stack {
|
||||||
|
toCall = p.Stack
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Stack -= toCall
|
||||||
|
p.Bet += toCall
|
||||||
|
p.TotalBet += toCall
|
||||||
|
|
||||||
|
action := "call"
|
||||||
|
if p.Stack == 0 {
|
||||||
|
p.State = PlayerAllIn
|
||||||
|
action = "allin"
|
||||||
|
g.StreetHistory += "a"
|
||||||
|
} else {
|
||||||
|
g.StreetHistory += "c"
|
||||||
|
}
|
||||||
|
|
||||||
|
ann := renderActionAnnouncement(p.DisplayName, action, toCall)
|
||||||
|
|
||||||
|
if g.activeCount() == 1 {
|
||||||
|
return ActionResult{Announcement: ann, HandOver: true}, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return ActionResult{
|
||||||
|
Announcement: ann,
|
||||||
|
StreetOver: g.isStreetComplete(g.nextCanActIdx(seatIdx)),
|
||||||
|
AllAllIn: g.canActCount() == 0,
|
||||||
|
}, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// doRaise processes a raise action. raiseTo is the total bet amount.
|
||||||
|
func (g *HoldemGame) doRaise(seatIdx int, raiseTo int64) (ActionResult, string) {
|
||||||
|
p := g.Players[seatIdx]
|
||||||
|
|
||||||
|
minRaiseTo := g.CurrentBet + g.MinRaise
|
||||||
|
maxRaiseTo := p.Bet + p.Stack
|
||||||
|
|
||||||
|
if raiseTo < minRaiseTo && raiseTo < maxRaiseTo {
|
||||||
|
return ActionResult{}, fmt.Sprintf("Minimum raise is to €%d.", minRaiseTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
if raiseTo > maxRaiseTo {
|
||||||
|
return ActionResult{}, fmt.Sprintf("You can raise to at most €%d (your stack).", maxRaiseTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
raiseAmount := raiseTo - p.Bet
|
||||||
|
actualRaise := raiseTo - g.CurrentBet
|
||||||
|
|
||||||
|
p.Stack -= raiseAmount
|
||||||
|
p.Bet = raiseTo
|
||||||
|
p.TotalBet += raiseAmount
|
||||||
|
|
||||||
|
if actualRaise > 0 {
|
||||||
|
g.MinRaise = actualRaise
|
||||||
|
}
|
||||||
|
g.CurrentBet = raiseTo
|
||||||
|
g.LastAggressorIdx = seatIdx
|
||||||
|
|
||||||
|
action := "raise"
|
||||||
|
if p.Stack == 0 {
|
||||||
|
p.State = PlayerAllIn
|
||||||
|
action = "allin"
|
||||||
|
g.StreetHistory += "a"
|
||||||
|
} else {
|
||||||
|
// Approximate: >=75% of pot is a pot-size raise ('R'), otherwise half-pot ('r').
|
||||||
|
totalPot := g.Pot
|
||||||
|
for _, pp := range g.Players {
|
||||||
|
totalPot += pp.Bet
|
||||||
|
}
|
||||||
|
if totalPot > 0 && float64(actualRaise) >= float64(totalPot)*0.75 {
|
||||||
|
g.StreetHistory += "R"
|
||||||
|
} else {
|
||||||
|
g.StreetHistory += "r"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ann := renderActionAnnouncement(p.DisplayName, action, raiseTo)
|
||||||
|
|
||||||
|
return ActionResult{
|
||||||
|
Announcement: ann,
|
||||||
|
AllAllIn: g.canActCount() == 0,
|
||||||
|
}, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// doAllIn processes an all-in action.
|
||||||
|
func (g *HoldemGame) doAllIn(seatIdx int) ActionResult {
|
||||||
|
p := g.Players[seatIdx]
|
||||||
|
allInAmount := p.Stack
|
||||||
|
totalBet := p.Bet + allInAmount
|
||||||
|
|
||||||
|
p.Stack = 0
|
||||||
|
p.Bet = totalBet
|
||||||
|
p.TotalBet += allInAmount
|
||||||
|
p.State = PlayerAllIn
|
||||||
|
|
||||||
|
if totalBet > g.CurrentBet {
|
||||||
|
actualRaise := totalBet - g.CurrentBet
|
||||||
|
// Only reopen action if the raise meets the minimum.
|
||||||
|
// A short all-in (under-raise) does NOT reopen betting.
|
||||||
|
if actualRaise >= g.MinRaise {
|
||||||
|
g.MinRaise = actualRaise
|
||||||
|
g.LastAggressorIdx = seatIdx
|
||||||
|
}
|
||||||
|
g.CurrentBet = totalBet
|
||||||
|
}
|
||||||
|
|
||||||
|
g.StreetHistory += "a"
|
||||||
|
ann := renderActionAnnouncement(p.DisplayName, "allin", totalBet)
|
||||||
|
|
||||||
|
if g.activeCount() == 1 {
|
||||||
|
return ActionResult{Announcement: ann, HandOver: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ActionResult{
|
||||||
|
Announcement: ann,
|
||||||
|
StreetOver: g.isStreetComplete(g.nextCanActIdx(seatIdx)),
|
||||||
|
AllAllIn: g.canActCount() == 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isStreetComplete checks if the betting round is done.
|
||||||
|
func (g *HoldemGame) isStreetComplete(nextIdx int) bool {
|
||||||
|
// All active players have matched the current bet and action has returned to the last aggressor.
|
||||||
|
if g.canActCount() == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all Active players have matched the bet.
|
||||||
|
for _, p := range g.Players {
|
||||||
|
if p.State == PlayerActive && p.Bet != g.CurrentBet {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the last aggressor is all-in (can't act), the street is done when
|
||||||
|
// all active players have matched the bet (already checked above).
|
||||||
|
if g.Players[g.LastAggressorIdx].State == PlayerAllIn {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action must have gone around to the last aggressor.
|
||||||
|
return nextIdx == g.LastAggressorIdx
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSidePots creates side pots when all-ins are present.
|
||||||
|
func (g *HoldemGame) buildSidePots() {
|
||||||
|
// Collect all bets.
|
||||||
|
g.collectPot()
|
||||||
|
|
||||||
|
type betEntry struct {
|
||||||
|
uid id.UserID
|
||||||
|
bet int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []betEntry
|
||||||
|
for _, p := range g.Players {
|
||||||
|
if p.State != PlayerFolded && p.State != PlayerSatOut {
|
||||||
|
entries = append(entries, betEntry{p.UserID, p.TotalBet})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(entries, func(i, j int) bool {
|
||||||
|
return entries[i].bet < entries[j].bet
|
||||||
|
})
|
||||||
|
|
||||||
|
var sidePots []SidePot
|
||||||
|
prevLevel := int64(0)
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.bet <= prevLevel {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
level := e.bet
|
||||||
|
potSlice := int64(0)
|
||||||
|
|
||||||
|
// All players who bet >= level contribute (level - prevLevel) each.
|
||||||
|
var eligible []id.UserID
|
||||||
|
for _, p := range g.Players {
|
||||||
|
if p.State == PlayerFolded || p.State == PlayerSatOut {
|
||||||
|
// Folded players still contributed up to their TotalBet.
|
||||||
|
contrib := p.TotalBet - prevLevel
|
||||||
|
if contrib > level-prevLevel {
|
||||||
|
contrib = level - prevLevel
|
||||||
|
}
|
||||||
|
if contrib > 0 {
|
||||||
|
potSlice += contrib
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
contrib := p.TotalBet - prevLevel
|
||||||
|
if contrib > level-prevLevel {
|
||||||
|
contrib = level - prevLevel
|
||||||
|
}
|
||||||
|
if contrib > 0 {
|
||||||
|
potSlice += contrib
|
||||||
|
}
|
||||||
|
if p.TotalBet >= level {
|
||||||
|
eligible = append(eligible, p.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if potSlice > 0 {
|
||||||
|
sidePots = append(sidePots, SidePot{Amount: potSlice, Eligible: eligible})
|
||||||
|
}
|
||||||
|
|
||||||
|
prevLevel = level
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sidePots) > 0 {
|
||||||
|
g.SidePots = sidePots
|
||||||
|
g.Pot = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// returnUncalledBet returns any unmatched portion of a bet to the player.
|
||||||
|
func (g *HoldemGame) returnUncalledBet() (name string, amount int64) {
|
||||||
|
// Find the highest and second-highest bets among non-folded players.
|
||||||
|
var highest, secondHighest int64
|
||||||
|
var highestIdx int
|
||||||
|
|
||||||
|
for i, p := range g.Players {
|
||||||
|
if p.State == PlayerFolded || p.State == PlayerSatOut {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.TotalBet > highest {
|
||||||
|
secondHighest = highest
|
||||||
|
highest = p.TotalBet
|
||||||
|
highestIdx = i
|
||||||
|
} else if p.TotalBet > secondHighest {
|
||||||
|
secondHighest = p.TotalBet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
excess := highest - secondHighest
|
||||||
|
if excess > 0 && secondHighest > 0 {
|
||||||
|
p := g.Players[highestIdx]
|
||||||
|
p.Stack += excess
|
||||||
|
p.TotalBet -= excess
|
||||||
|
p.Bet -= excess
|
||||||
|
if p.Bet < 0 {
|
||||||
|
p.Bet = 0
|
||||||
|
}
|
||||||
|
return p.DisplayName, excess
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
1285
internal/plugin/holdem_cfr.go
Normal file
1285
internal/plugin/holdem_cfr.go
Normal file
File diff suppressed because it is too large
Load Diff
114
internal/plugin/holdem_equity.go
Normal file
114
internal/plugin/holdem_equity.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
|
|
||||||
|
"github.com/chehsunliu/poker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EquityResult holds Monte Carlo simulation results.
|
||||||
|
type EquityResult struct {
|
||||||
|
Win float64
|
||||||
|
Tie float64
|
||||||
|
Loss float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// allCards returns a fresh 52-card slice.
|
||||||
|
func allCards() []poker.Card {
|
||||||
|
suits := []string{"s", "h", "d", "c"}
|
||||||
|
ranks := []string{"2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K", "A"}
|
||||||
|
cards := make([]poker.Card, 0, 52)
|
||||||
|
for _, r := range ranks {
|
||||||
|
for _, s := range suits {
|
||||||
|
cards = append(cards, poker.NewCard(r+s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equity computes win/tie/loss fractions via Monte Carlo simulation.
|
||||||
|
func Equity(hole [2]poker.Card, community []poker.Card, numOpponents, iterations int) EquityResult {
|
||||||
|
if numOpponents < 1 {
|
||||||
|
numOpponents = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build set of known cards to exclude.
|
||||||
|
known := make(map[poker.Card]bool, 2+len(community))
|
||||||
|
known[hole[0]] = true
|
||||||
|
known[hole[1]] = true
|
||||||
|
for _, c := range community {
|
||||||
|
known[c] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining deck.
|
||||||
|
remaining := make([]poker.Card, 0, 52-len(known))
|
||||||
|
for _, c := range allCards() {
|
||||||
|
if !known[c] {
|
||||||
|
remaining = append(remaining, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boardNeeded := 5 - len(community)
|
||||||
|
cardsNeeded := numOpponents*2 + boardNeeded
|
||||||
|
|
||||||
|
var wins, ties, losses int
|
||||||
|
|
||||||
|
for i := 0; i < iterations; i++ {
|
||||||
|
// Fisher-Yates shuffle of first cardsNeeded elements.
|
||||||
|
for j := 0; j < cardsNeeded && j < len(remaining); j++ {
|
||||||
|
k := j + rand.IntN(len(remaining)-j)
|
||||||
|
remaining[j], remaining[k] = remaining[k], remaining[j]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deal opponent holes.
|
||||||
|
idx := 0
|
||||||
|
opponentHoles := make([][2]poker.Card, numOpponents)
|
||||||
|
for o := 0; o < numOpponents; o++ {
|
||||||
|
opponentHoles[o] = [2]poker.Card{remaining[idx], remaining[idx+1]}
|
||||||
|
idx += 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete board.
|
||||||
|
fullBoard := make([]poker.Card, 5)
|
||||||
|
copy(fullBoard, community)
|
||||||
|
for b := len(community); b < 5; b++ {
|
||||||
|
fullBoard[b] = remaining[idx]
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate hero.
|
||||||
|
heroCards := make([]poker.Card, 7)
|
||||||
|
heroCards[0] = hole[0]
|
||||||
|
heroCards[1] = hole[1]
|
||||||
|
copy(heroCards[2:], fullBoard)
|
||||||
|
heroRank := poker.Evaluate(heroCards)
|
||||||
|
|
||||||
|
// Evaluate opponents.
|
||||||
|
bestOpp := int32(7463) // worst possible rank
|
||||||
|
for _, oh := range opponentHoles {
|
||||||
|
oppCards := make([]poker.Card, 7)
|
||||||
|
oppCards[0] = oh[0]
|
||||||
|
oppCards[1] = oh[1]
|
||||||
|
copy(oppCards[2:], fullBoard)
|
||||||
|
oppRank := poker.Evaluate(oppCards)
|
||||||
|
if oppRank < bestOpp {
|
||||||
|
bestOpp = oppRank
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if heroRank < bestOpp {
|
||||||
|
wins++
|
||||||
|
} else if heroRank == bestOpp {
|
||||||
|
ties++
|
||||||
|
} else {
|
||||||
|
losses++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total := float64(iterations)
|
||||||
|
return EquityResult{
|
||||||
|
Win: float64(wins) / total,
|
||||||
|
Tie: float64(ties) / total,
|
||||||
|
Loss: float64(losses) / total,
|
||||||
|
}
|
||||||
|
}
|
||||||
156
internal/plugin/holdem_eval.go
Normal file
156
internal/plugin/holdem_eval.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/chehsunliu/poker"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handRank evaluates a player's best 5-card hand from 7 cards.
|
||||||
|
func handRank(hole [2]poker.Card, community []poker.Card) (int32, string) {
|
||||||
|
cards := make([]poker.Card, 0, 7)
|
||||||
|
cards = append(cards, hole[0], hole[1])
|
||||||
|
cards = append(cards, community...)
|
||||||
|
rank := poker.Evaluate(cards)
|
||||||
|
return rank, poker.RankString(rank)
|
||||||
|
}
|
||||||
|
|
||||||
|
type evaluatedPlayer struct {
|
||||||
|
seatIdx int
|
||||||
|
rank int32
|
||||||
|
name string
|
||||||
|
userID id.UserID
|
||||||
|
}
|
||||||
|
|
||||||
|
// runShowdown evaluates all hands and distributes pots. Returns showdown result lines and per-player winnings.
|
||||||
|
func runShowdown(g *HoldemGame) ([]showdownResult, map[id.UserID]int64) {
|
||||||
|
winnings := make(map[id.UserID]int64)
|
||||||
|
var results []showdownResult
|
||||||
|
|
||||||
|
// Evaluate all non-folded players.
|
||||||
|
var evaluated []evaluatedPlayer
|
||||||
|
for i, p := range g.Players {
|
||||||
|
if p.State == PlayerFolded || p.State == PlayerSatOut {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rank, name := handRank(p.Hole, g.Community)
|
||||||
|
evaluated = append(evaluated, evaluatedPlayer{
|
||||||
|
seatIdx: i,
|
||||||
|
rank: rank,
|
||||||
|
name: name,
|
||||||
|
userID: p.UserID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(evaluated, func(i, j int) bool {
|
||||||
|
return evaluated[i].rank < evaluated[j].rank // lower = better
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(g.SidePots) > 0 {
|
||||||
|
// Distribute each side pot.
|
||||||
|
for _, sp := range g.SidePots {
|
||||||
|
distributePot(g, sp.Amount, sp.Eligible, evaluated, winnings, &results)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single pot — collect outstanding bets first.
|
||||||
|
g.collectPot()
|
||||||
|
eligible := make([]id.UserID, 0)
|
||||||
|
for _, e := range evaluated {
|
||||||
|
eligible = append(eligible, e.userID)
|
||||||
|
}
|
||||||
|
distributePot(g, g.Pot, eligible, evaluated, winnings, &results)
|
||||||
|
g.Pot = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add showdown lines for all players.
|
||||||
|
var showdownLines []showdownResult
|
||||||
|
for _, e := range evaluated {
|
||||||
|
p := g.Players[e.seatIdx]
|
||||||
|
won := winnings[p.UserID]
|
||||||
|
line := renderShowdownLine(p.DisplayName, p.Hole, e.name, won)
|
||||||
|
showdownLines = append(showdownLines, showdownResult{line: line})
|
||||||
|
}
|
||||||
|
|
||||||
|
return showdownLines, winnings
|
||||||
|
}
|
||||||
|
|
||||||
|
// distributePot distributes a pot among eligible winners.
|
||||||
|
func distributePot(g *HoldemGame, potAmount int64, eligible []id.UserID, evaluated []evaluatedPlayer, winnings map[id.UserID]int64, results *[]showdownResult) {
|
||||||
|
if potAmount == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
eligibleSet := make(map[id.UserID]bool, len(eligible))
|
||||||
|
for _, uid := range eligible {
|
||||||
|
eligibleSet[uid] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the best rank among eligible players.
|
||||||
|
var winners []evaluatedPlayer
|
||||||
|
bestRank := int32(7463)
|
||||||
|
for _, e := range evaluated {
|
||||||
|
if !eligibleSet[e.userID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.rank < bestRank {
|
||||||
|
bestRank = e.rank
|
||||||
|
winners = []evaluatedPlayer{e}
|
||||||
|
} else if e.rank == bestRank {
|
||||||
|
winners = append(winners, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(winners) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split pot.
|
||||||
|
share := potAmount / int64(len(winners))
|
||||||
|
remainder := potAmount % int64(len(winners))
|
||||||
|
|
||||||
|
for i, w := range winners {
|
||||||
|
won := share
|
||||||
|
if i == 0 {
|
||||||
|
won += remainder // leftmost seat gets the odd chip
|
||||||
|
}
|
||||||
|
g.Players[w.seatIdx].Stack += won
|
||||||
|
winnings[w.userID] += won
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// awardPotToLastPlayer awards the entire pot to the only remaining player (all others folded).
|
||||||
|
func awardPotToLastPlayer(g *HoldemGame) (string, id.UserID) {
|
||||||
|
g.collectPot()
|
||||||
|
|
||||||
|
var winner *HoldemPlayer
|
||||||
|
for _, p := range g.Players {
|
||||||
|
if p.State != PlayerFolded && p.State != PlayerSatOut {
|
||||||
|
winner = p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if winner == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
winner.Stack += g.Pot
|
||||||
|
ann := fmt.Sprintf("🏆 **%s** wins €%d!", winner.DisplayName, g.Pot)
|
||||||
|
g.Pot = 0
|
||||||
|
return ann, winner.UserID
|
||||||
|
}
|
||||||
|
|
||||||
|
// settleNetDeltas credits each player's remaining stack back to their balance.
|
||||||
|
// Buy-in was debited at join time, so we credit the full final stack.
|
||||||
|
func settleNetDeltas(g *HoldemGame, euro *EuroPlugin) {
|
||||||
|
for _, p := range g.Players {
|
||||||
|
if p.IsNPC || p.State == PlayerSatOut {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Stack > 0 {
|
||||||
|
euro.Credit(p.UserID, float64(p.Stack), "holdem_cashout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
277
internal/plugin/holdem_game.go
Normal file
277
internal/plugin/holdem_game.go
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand/v2"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/chehsunliu/poker"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Street represents the current phase of a Hold'em hand.
|
||||||
|
type Street int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StreetPreFlop Street = iota
|
||||||
|
StreetFlop
|
||||||
|
StreetTurn
|
||||||
|
StreetRiver
|
||||||
|
StreetShowdown
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s Street) String() string {
|
||||||
|
switch s {
|
||||||
|
case StreetPreFlop:
|
||||||
|
return "Pre-Flop"
|
||||||
|
case StreetFlop:
|
||||||
|
return "Flop"
|
||||||
|
case StreetTurn:
|
||||||
|
return "Turn"
|
||||||
|
case StreetRiver:
|
||||||
|
return "River"
|
||||||
|
case StreetShowdown:
|
||||||
|
return "Showdown"
|
||||||
|
default:
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerState tracks a player's status within a hand.
|
||||||
|
type PlayerState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
PlayerActive PlayerState = iota
|
||||||
|
PlayerFolded
|
||||||
|
PlayerAllIn
|
||||||
|
PlayerSatOut
|
||||||
|
)
|
||||||
|
|
||||||
|
// SidePot represents a pot with specific eligible players.
|
||||||
|
type SidePot struct {
|
||||||
|
Amount int64
|
||||||
|
Eligible []id.UserID
|
||||||
|
}
|
||||||
|
|
||||||
|
// HoldemPlayer represents a seated player.
|
||||||
|
type HoldemPlayer struct {
|
||||||
|
UserID id.UserID
|
||||||
|
DisplayName string
|
||||||
|
Stack int64
|
||||||
|
OpeningStack int64
|
||||||
|
Hole [2]poker.Card
|
||||||
|
Bet int64 // committed this street
|
||||||
|
TotalBet int64 // committed this hand
|
||||||
|
State PlayerState
|
||||||
|
TipsEnabled bool
|
||||||
|
SittingOut bool
|
||||||
|
WantsLeave bool
|
||||||
|
IsNPC bool
|
||||||
|
DMRoomID id.RoomID
|
||||||
|
}
|
||||||
|
|
||||||
|
// HoldemGame holds all state for one table.
|
||||||
|
type HoldemGame struct {
|
||||||
|
RoomID id.RoomID
|
||||||
|
Players []*HoldemPlayer
|
||||||
|
Community []poker.Card
|
||||||
|
Deck []poker.Card
|
||||||
|
DeckPos int // position in the deck
|
||||||
|
Pot int64
|
||||||
|
SidePots []SidePot
|
||||||
|
Street Street
|
||||||
|
DealerIdx int
|
||||||
|
ActionIdx int
|
||||||
|
CurrentBet int64
|
||||||
|
MinRaise int64
|
||||||
|
SmallBlind int64
|
||||||
|
BigBlind int64
|
||||||
|
LastAggressorIdx int
|
||||||
|
WaitingForPlayers bool
|
||||||
|
HandInProgress bool
|
||||||
|
StreetHistory string // action chars for current street (f/c/r/R/a) for CFR policy lookup
|
||||||
|
|
||||||
|
actionTimer *time.Timer
|
||||||
|
warningTimer *time.Timer
|
||||||
|
}
|
||||||
|
|
||||||
|
// newShuffledDeck creates a shuffled 52-card deck.
|
||||||
|
func newShuffledDeck() []poker.Card {
|
||||||
|
cards := allCards()
|
||||||
|
rand.Shuffle(len(cards), func(i, j int) {
|
||||||
|
cards[i], cards[j] = cards[j], cards[i]
|
||||||
|
})
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawCard draws the next card from the deck.
|
||||||
|
func (g *HoldemGame) drawCard() poker.Card {
|
||||||
|
c := g.Deck[g.DeckPos]
|
||||||
|
g.DeckPos++
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// burnCard discards the top card (standard casino burn).
|
||||||
|
func (g *HoldemGame) burnCard() {
|
||||||
|
g.DeckPos++
|
||||||
|
}
|
||||||
|
|
||||||
|
// activePlayers returns players who are Active or AllIn.
|
||||||
|
func (g *HoldemGame) activePlayers() []*HoldemPlayer {
|
||||||
|
var result []*HoldemPlayer
|
||||||
|
for _, p := range g.Players {
|
||||||
|
if p.State == PlayerActive || p.State == PlayerAllIn {
|
||||||
|
result = append(result, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeCount returns the number of Active or AllIn players.
|
||||||
|
func (g *HoldemGame) activeCount() int {
|
||||||
|
n := 0
|
||||||
|
for _, p := range g.Players {
|
||||||
|
if p.State == PlayerActive || p.State == PlayerAllIn {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// canActCount returns the number of players who can still act (Active with stack > 0).
|
||||||
|
func (g *HoldemGame) canActCount() int {
|
||||||
|
n := 0
|
||||||
|
for _, p := range g.Players {
|
||||||
|
if p.State == PlayerActive {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextActiveIdx returns the next seat index after idx with Active or AllIn state.
|
||||||
|
func (g *HoldemGame) nextActiveIdx(idx int) int {
|
||||||
|
n := len(g.Players)
|
||||||
|
for i := 1; i < n; i++ {
|
||||||
|
next := (idx + i) % n
|
||||||
|
p := g.Players[next]
|
||||||
|
if p.State == PlayerActive || p.State == PlayerAllIn {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextCanActIdx returns the next seat with Active state (can take actions).
|
||||||
|
func (g *HoldemGame) nextCanActIdx(idx int) int {
|
||||||
|
n := len(g.Players)
|
||||||
|
for i := 1; i < n; i++ {
|
||||||
|
next := (idx + i) % n
|
||||||
|
if g.Players[next].State == PlayerActive {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// inHandPlayers returns players participating in the current hand (not SatOut).
|
||||||
|
func (g *HoldemGame) inHandPlayers() []*HoldemPlayer {
|
||||||
|
var result []*HoldemPlayer
|
||||||
|
for _, p := range g.Players {
|
||||||
|
if p.State != PlayerSatOut {
|
||||||
|
result = append(result, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// playerByUserID finds a player by their Matrix user ID.
|
||||||
|
func (g *HoldemGame) playerByUserID(uid id.UserID) *HoldemPlayer {
|
||||||
|
for _, p := range g.Players {
|
||||||
|
if p.UserID == uid {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// playerIdx returns the seat index for a user ID, or -1.
|
||||||
|
func (g *HoldemGame) playerIdx(uid id.UserID) int {
|
||||||
|
for i, p := range g.Players {
|
||||||
|
if p.UserID == uid {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetStreetBets clears per-street bet tracking.
|
||||||
|
func (g *HoldemGame) resetStreetBets() {
|
||||||
|
for _, p := range g.Players {
|
||||||
|
p.Bet = 0
|
||||||
|
}
|
||||||
|
g.CurrentBet = 0
|
||||||
|
g.MinRaise = g.BigBlind
|
||||||
|
g.StreetHistory = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectPot moves all bets into the pot.
|
||||||
|
func (g *HoldemGame) collectPot() {
|
||||||
|
for _, p := range g.Players {
|
||||||
|
g.Pot += p.Bet
|
||||||
|
p.Bet = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// positionLabel returns BTN/SB/BB/UTG/MP/CO for a seat index.
|
||||||
|
func (g *HoldemGame) positionLabel(seatIdx int) string {
|
||||||
|
n := len(g.inHandPlayers())
|
||||||
|
if n <= 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if seatIdx == g.DealerIdx {
|
||||||
|
return "BTN"
|
||||||
|
}
|
||||||
|
|
||||||
|
headsUp := n == 2
|
||||||
|
if headsUp {
|
||||||
|
// In heads-up, dealer is SB; other is BB.
|
||||||
|
return "BB"
|
||||||
|
}
|
||||||
|
|
||||||
|
sbIdx := g.nextActiveIdx(g.DealerIdx)
|
||||||
|
bbIdx := g.nextActiveIdx(sbIdx)
|
||||||
|
|
||||||
|
if seatIdx == sbIdx {
|
||||||
|
return "SB"
|
||||||
|
}
|
||||||
|
if seatIdx == bbIdx {
|
||||||
|
return "BB"
|
||||||
|
}
|
||||||
|
|
||||||
|
utgIdx := g.nextActiveIdx(bbIdx)
|
||||||
|
if seatIdx == utgIdx {
|
||||||
|
return "UTG"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rough labeling for remaining seats.
|
||||||
|
// Count distance from UTG.
|
||||||
|
dist := 0
|
||||||
|
cur := utgIdx
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
cur = g.nextActiveIdx(cur)
|
||||||
|
dist++
|
||||||
|
if cur == seatIdx {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := n - 4 // seats after UTG before BTN
|
||||||
|
if remaining <= 0 {
|
||||||
|
return "MP"
|
||||||
|
}
|
||||||
|
if dist >= remaining {
|
||||||
|
return "CO"
|
||||||
|
}
|
||||||
|
return "MP"
|
||||||
|
}
|
||||||
321
internal/plugin/holdem_render.go
Normal file
321
internal/plugin/holdem_render.go
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/chehsunliu/poker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cardGlyphMap maps poker card strings (e.g. "As") to Unicode glyphs.
|
||||||
|
var cardGlyphMap map[string]string
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
cardGlyphMap = make(map[string]string, 52)
|
||||||
|
|
||||||
|
// Unicode playing card block. Q and K skip a codepoint (Knight sits between J and Q).
|
||||||
|
// Spades: U+1F0A1..
|
||||||
|
// Hearts: U+1F0B1..
|
||||||
|
// Diamonds: U+1F0C1..
|
||||||
|
// Clubs: U+1F0D1..
|
||||||
|
type suitInfo struct {
|
||||||
|
letter string
|
||||||
|
base rune
|
||||||
|
}
|
||||||
|
suits := []suitInfo{
|
||||||
|
{"s", 0x1F0A0}, // Spades
|
||||||
|
{"h", 0x1F0B0}, // Hearts
|
||||||
|
{"d", 0x1F0C0}, // Diamonds
|
||||||
|
{"c", 0x1F0D0}, // Clubs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rank offsets within each suit block.
|
||||||
|
// A=1, 2=2, ..., 10=10, J=11, Q=13(skip Knight at 12), K=14
|
||||||
|
rankOffsets := map[string]int{
|
||||||
|
"A": 1, "2": 2, "3": 3, "4": 4, "5": 5,
|
||||||
|
"6": 6, "7": 7, "8": 8, "9": 9, "T": 10,
|
||||||
|
"J": 11, "Q": 13, "K": 14,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range suits {
|
||||||
|
for rank, offset := range rankOffsets {
|
||||||
|
cardStr := rank + s.letter
|
||||||
|
glyph := string(rune(s.base + rune(offset)))
|
||||||
|
cardGlyphMap[cardStr] = glyph
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdemSuitSymbols maps poker library suit letters to display symbols.
|
||||||
|
var holdemSuitSymbols = map[byte]string{
|
||||||
|
's': "♠", 'h': "♥", 'd': "♦", 'c': "♣",
|
||||||
|
}
|
||||||
|
|
||||||
|
// rankDisplay converts library rank chars to display. "T" -> "10", rest unchanged.
|
||||||
|
var rankDisplay = map[byte]string{
|
||||||
|
'2': "2", '3': "3", '4': "4", '5': "5", '6': "6",
|
||||||
|
'7': "7", '8': "8", '9': "9", 'T': "10",
|
||||||
|
'J': "J", 'Q': "Q", 'K': "K", 'A': "A",
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderCard renders a card as "🂡 (A♠)".
|
||||||
|
func renderCard(c poker.Card) string {
|
||||||
|
s := c.String() // e.g. "As", "Td"
|
||||||
|
glyph := cardGlyphMap[s]
|
||||||
|
if glyph == "" {
|
||||||
|
glyph = "🂠"
|
||||||
|
}
|
||||||
|
rank := rankDisplay[s[0]]
|
||||||
|
suit := holdemSuitSymbols[s[1]]
|
||||||
|
return fmt.Sprintf("%s (%s%s)", glyph, rank, suit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderCards renders multiple cards separated by double space.
|
||||||
|
func renderCards(cards []poker.Card) string {
|
||||||
|
parts := make([]string, len(cards))
|
||||||
|
for i, c := range cards {
|
||||||
|
parts[i] = renderCard(c)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderHoleCards renders a 2-card hole hand.
|
||||||
|
func renderHoleCards(hole [2]poker.Card) string {
|
||||||
|
return renderCard(hole[0]) + " " + renderCard(hole[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTableView builds the DM table view for a specific player.
|
||||||
|
func renderTableView(g *HoldemGame, viewerIdx int) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("🎰 **Texas Hold'em** | %s\n", g.Street.String()))
|
||||||
|
|
||||||
|
// Pot info.
|
||||||
|
if len(g.SidePots) > 0 {
|
||||||
|
total := int64(0)
|
||||||
|
for _, sp := range g.SidePots {
|
||||||
|
total += sp.Amount
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("Pot: €%d", total))
|
||||||
|
for i, sp := range g.SidePots {
|
||||||
|
sb.WriteString(fmt.Sprintf(" | Side %d: €%d", i+1, sp.Amount))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
} else {
|
||||||
|
// Include outstanding bets in displayed pot.
|
||||||
|
totalPot := g.Pot
|
||||||
|
for _, p := range g.Players {
|
||||||
|
totalPot += p.Bet
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("Pot: €%d\n", totalPot))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Board.
|
||||||
|
if len(g.Community) > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("Board: %s\n", renderCards(g.Community)))
|
||||||
|
} else {
|
||||||
|
sb.WriteString("Board: —\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n**Seats:**\n")
|
||||||
|
|
||||||
|
for i, p := range g.Players {
|
||||||
|
if p.State == PlayerSatOut {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s — *(sitting out)*\n", p.DisplayName))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
marker := ""
|
||||||
|
if i == g.DealerIdx {
|
||||||
|
marker = " 🔘"
|
||||||
|
}
|
||||||
|
|
||||||
|
actionMarker := ""
|
||||||
|
if i == g.ActionIdx && g.HandInProgress && g.Street != StreetShowdown {
|
||||||
|
actionMarker = " ← action"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch p.State {
|
||||||
|
case PlayerFolded:
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s%s — €%d [folded]%s\n", p.DisplayName, marker, p.Stack, actionMarker))
|
||||||
|
case PlayerAllIn:
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s%s — €%d [ALL IN]%s\n", p.DisplayName, marker, p.Stack, actionMarker))
|
||||||
|
default:
|
||||||
|
betStr := ""
|
||||||
|
if p.Bet > 0 {
|
||||||
|
betStr = fmt.Sprintf(" | bet: €%d", p.Bet)
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s%s — €%d%s%s\n", p.DisplayName, marker, p.Stack, betStr, actionMarker))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If viewer is the action player, show action prompt.
|
||||||
|
if viewerIdx == g.ActionIdx && g.HandInProgress && g.Street != StreetShowdown {
|
||||||
|
p := g.Players[viewerIdx]
|
||||||
|
if p.State == PlayerActive {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
toCall := g.CurrentBet - p.Bet
|
||||||
|
if toCall > p.Stack {
|
||||||
|
toCall = p.Stack
|
||||||
|
}
|
||||||
|
if toCall > 0 {
|
||||||
|
minRaiseTo := g.CurrentBet + g.MinRaise
|
||||||
|
sb.WriteString(fmt.Sprintf("To call: €%d | Min raise to: €%d\n", toCall, minRaiseTo))
|
||||||
|
sb.WriteString("`!holdem call` `!holdem raise <amount>` `!holdem allin` `!holdem fold`\n")
|
||||||
|
} else {
|
||||||
|
minRaiseTo := g.CurrentBet + g.MinRaise
|
||||||
|
if g.CurrentBet == 0 {
|
||||||
|
minRaiseTo = g.BigBlind
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("Check available | Min bet: €%d\n", minRaiseTo))
|
||||||
|
sb.WriteString("`!holdem check` `!holdem raise <amount>` `!holdem allin` `!holdem fold`\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPrivateHand builds the private hand DM for a player.
|
||||||
|
func renderPrivateHand(g *HoldemGame, playerIdx int) string {
|
||||||
|
p := g.Players[playerIdx]
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("🃏 Your hand: %s\n", renderHoleCards(p.Hole)))
|
||||||
|
|
||||||
|
if len(g.Community) > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("Board: %s\n", renderCards(g.Community)))
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPot := g.Pot
|
||||||
|
for _, pp := range g.Players {
|
||||||
|
totalPot += pp.Bet
|
||||||
|
}
|
||||||
|
|
||||||
|
toCall := g.CurrentBet - p.Bet
|
||||||
|
if toCall < 0 {
|
||||||
|
toCall = 0
|
||||||
|
}
|
||||||
|
if toCall > p.Stack {
|
||||||
|
toCall = p.Stack
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("Stack: €%d | Pot: €%d | To call: €%d\n", p.Stack, totalPot, toCall))
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderActionAnnouncement formats an action for room/DM announcement.
|
||||||
|
func renderActionAnnouncement(name, action string, amount int64) string {
|
||||||
|
switch action {
|
||||||
|
case "fold":
|
||||||
|
return fmt.Sprintf("**%s** folds.", name)
|
||||||
|
case "check":
|
||||||
|
return fmt.Sprintf("**%s** checks.", name)
|
||||||
|
case "call":
|
||||||
|
return fmt.Sprintf("**%s** calls €%d.", name, amount)
|
||||||
|
case "raise":
|
||||||
|
return fmt.Sprintf("**%s** raises to €%d.", name, amount)
|
||||||
|
case "allin":
|
||||||
|
return fmt.Sprintf("**%s** is ALL IN for €%d! 🚨", name, amount)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("**%s** acts.", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderWinnerAnnouncement formats a winner message.
|
||||||
|
func renderWinnerAnnouncement(name string, amount int64, handName string, showCards bool, hole [2]poker.Card) string {
|
||||||
|
if showCards {
|
||||||
|
return fmt.Sprintf("🏆 **%s** wins €%d with %s! %s", name, amount, handName, renderHoleCards(hole))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("🏆 **%s** wins €%d!", name, amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderShowdownLine formats one player's showdown result.
|
||||||
|
func renderShowdownLine(name string, hole [2]poker.Card, handName string, won int64) string {
|
||||||
|
if won > 0 {
|
||||||
|
return fmt.Sprintf(" %s: %s — %s (won €%d)", name, renderHoleCards(hole), handName, won)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(" %s: %s — %s", name, renderHoleCards(hole), handName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderUncalledBetReturn formats the uncalled bet message.
|
||||||
|
func renderUncalledBetReturn(name string, amount int64) string {
|
||||||
|
return fmt.Sprintf("↩️ Uncalled bet of €%d returned to **%s**.", amount, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderStartAnnouncement formats the hand start message for the room.
|
||||||
|
func renderStartAnnouncement(g *HoldemGame) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("🎰 **Texas Hold'em** — Hand starting!\n\n")
|
||||||
|
|
||||||
|
sb.WriteString("**Players:**\n")
|
||||||
|
for i, p := range g.Players {
|
||||||
|
if p.State == PlayerSatOut {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pos := g.positionLabel(i)
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s (%s) — €%d\n", p.DisplayName, pos, p.Stack))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("\nBlinds: €%d / €%d\n", g.SmallBlind, g.BigBlind))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderEndAnnouncement formats the hand end message for the room.
|
||||||
|
func renderEndAnnouncement(results []showdownResult, g *HoldemGame) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("🎰 **Texas Hold'em** — Hand complete!\n\n")
|
||||||
|
|
||||||
|
if len(g.Community) > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("Board: %s\n\n", renderCards(g.Community)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) > 0 {
|
||||||
|
sb.WriteString("**Results:**\n")
|
||||||
|
for _, r := range results {
|
||||||
|
sb.WriteString(r.line + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n**Stacks:**\n")
|
||||||
|
for _, p := range g.Players {
|
||||||
|
if p.State == PlayerSatOut {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delta := p.Stack - p.OpeningStack
|
||||||
|
sign := ""
|
||||||
|
if delta > 0 {
|
||||||
|
sign = "+"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s — €%d (%s%d)\n", p.DisplayName, p.Stack, sign, delta))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderHelpMessage returns the help text for !holdem help.
|
||||||
|
func renderHelpMessage() string {
|
||||||
|
return "🎰 **Texas Hold'em Commands**\n\n" +
|
||||||
|
"**Lobby:**\n" +
|
||||||
|
"`!holdem join` — Sit down at the table\n" +
|
||||||
|
"`!holdem leave` — Leave the table\n" +
|
||||||
|
"`!holdem start` — Start dealing (≥2 players)\n" +
|
||||||
|
"`!holdem addbot` — Add an AI opponent\n\n" +
|
||||||
|
"**In-Game (your turn):**\n" +
|
||||||
|
"`!holdem fold` — Fold your hand\n" +
|
||||||
|
"`!holdem check` — Check (no bet to call)\n" +
|
||||||
|
"`!holdem call` — Call the current bet\n" +
|
||||||
|
"`!holdem raise <amount>` — Raise to a total of €amount\n" +
|
||||||
|
"`!holdem allin` — Go all-in\n\n" +
|
||||||
|
"**Other:**\n" +
|
||||||
|
"`!holdem status` — Get current table state (DM)\n" +
|
||||||
|
"`!holdem help` — Show this message\n\n" +
|
||||||
|
"**DM-Only:**\n" +
|
||||||
|
"`!holdem tips on/off` — Toggle coaching tips\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// showdownResult holds one player's showdown display line.
|
||||||
|
type showdownResult struct {
|
||||||
|
line string
|
||||||
|
}
|
||||||
268
internal/plugin/holdem_tips.go
Normal file
268
internal/plugin/holdem_tips.go
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
var holdemTipsClient = &http.Client{Timeout: 15 * time.Second}
|
||||||
|
|
||||||
|
// loadTipsPref loads a user's tip preference from the database.
|
||||||
|
func loadTipsPref(userID id.UserID) bool {
|
||||||
|
d := db.Get()
|
||||||
|
var enabled int
|
||||||
|
err := d.QueryRow(`SELECT enabled FROM holdem_tips_prefs WHERE user_id = ?`, string(userID)).Scan(&enabled)
|
||||||
|
if err != nil {
|
||||||
|
return true // default: tips on
|
||||||
|
}
|
||||||
|
return enabled == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveTipsPref saves a user's tip preference.
|
||||||
|
func saveTipsPref(userID id.UserID, enabled bool) {
|
||||||
|
d := db.Get()
|
||||||
|
val := 0
|
||||||
|
if enabled {
|
||||||
|
val = 1
|
||||||
|
}
|
||||||
|
_, _ = d.Exec(
|
||||||
|
`INSERT INTO holdem_tips_prefs (user_id, enabled) VALUES (?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET enabled = ?, updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
string(userID), val, val,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdemTipContext holds the data needed to generate a tip.
|
||||||
|
type holdemTipContext struct {
|
||||||
|
PlayerName string
|
||||||
|
Hole [2]string // rendered card strings
|
||||||
|
Community string // rendered board string
|
||||||
|
Equity EquityResult
|
||||||
|
SPR float64
|
||||||
|
PotOddsPct float64
|
||||||
|
ToCall int64
|
||||||
|
Pot int64
|
||||||
|
Stack int64
|
||||||
|
Street Street
|
||||||
|
Position string
|
||||||
|
NumActive int
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTipContext creates a tip context from the current game state.
|
||||||
|
func buildTipContext(g *HoldemGame, playerIdx int) holdemTipContext {
|
||||||
|
p := g.Players[playerIdx]
|
||||||
|
|
||||||
|
totalPot := g.Pot
|
||||||
|
for _, pp := range g.Players {
|
||||||
|
totalPot += pp.Bet
|
||||||
|
}
|
||||||
|
|
||||||
|
toCall := g.CurrentBet - p.Bet
|
||||||
|
if toCall < 0 {
|
||||||
|
toCall = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
numActive := g.activeCount()
|
||||||
|
numOpp := numActive - 1
|
||||||
|
if numOpp < 1 {
|
||||||
|
numOpp = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
iterations := 5000
|
||||||
|
if len(g.Community) > 0 {
|
||||||
|
iterations = 10000
|
||||||
|
}
|
||||||
|
eq := Equity(p.Hole, g.Community, numOpp, iterations)
|
||||||
|
|
||||||
|
spr := 0.0
|
||||||
|
if totalPot > 0 {
|
||||||
|
spr = float64(p.Stack) / float64(totalPot)
|
||||||
|
}
|
||||||
|
|
||||||
|
potOdds := 0.0
|
||||||
|
if toCall > 0 {
|
||||||
|
potOdds = float64(toCall) / float64(totalPot+toCall) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
community := "—"
|
||||||
|
if len(g.Community) > 0 {
|
||||||
|
community = renderCards(g.Community)
|
||||||
|
}
|
||||||
|
|
||||||
|
return holdemTipContext{
|
||||||
|
PlayerName: p.DisplayName,
|
||||||
|
Hole: [2]string{renderCard(p.Hole[0]), renderCard(p.Hole[1])},
|
||||||
|
Community: community,
|
||||||
|
Equity: eq,
|
||||||
|
SPR: spr,
|
||||||
|
PotOddsPct: potOdds,
|
||||||
|
ToCall: toCall,
|
||||||
|
Pot: totalPot,
|
||||||
|
Stack: p.Stack,
|
||||||
|
Street: g.Street,
|
||||||
|
Position: g.positionLabel(playerIdx),
|
||||||
|
NumActive: numActive,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTip generates a coaching tip, trying LLM first then falling back to rules.
|
||||||
|
func generateTip(ctx holdemTipContext) string {
|
||||||
|
host := os.Getenv("OLLAMA_HOST")
|
||||||
|
model := os.Getenv("OLLAMA_MODEL")
|
||||||
|
|
||||||
|
if host != "" && model != "" {
|
||||||
|
tip, err := generateLLMTip(host, model, ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("holdem: LLM tip failed, using fallback", "err", err)
|
||||||
|
} else if tip != "" {
|
||||||
|
return "💡 " + tip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "💡 " + generateRulesTip(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []chatMessage `json:"messages"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatChoice struct {
|
||||||
|
Message chatMessage `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatResponse struct {
|
||||||
|
Choices []chatChoice `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateLLMTip(host, model string, ctx holdemTipContext) (string, error) {
|
||||||
|
systemPrompt := `You are a concise Texas Hold'em coach embedded in a Matrix chat bot.
|
||||||
|
You will be given structured game context including pre-computed equity.
|
||||||
|
Give exactly 2-4 sentences of actionable advice for the player's current decision.
|
||||||
|
Lead with the equity vs pot odds relationship when a bet is to call.
|
||||||
|
Be direct. No preamble. No praise. No "great hand" filler.`
|
||||||
|
|
||||||
|
var userPrompt strings.Builder
|
||||||
|
userPrompt.WriteString(fmt.Sprintf("Street: %s\n", ctx.Street.String()))
|
||||||
|
userPrompt.WriteString(fmt.Sprintf("Your hand: %s %s\n", ctx.Hole[0], ctx.Hole[1]))
|
||||||
|
userPrompt.WriteString(fmt.Sprintf("Board: %s\n", ctx.Community))
|
||||||
|
userPrompt.WriteString(fmt.Sprintf("Equity vs %d opponents: Win %.0f%% | Tie %.0f%% | Loss %.0f%%\n",
|
||||||
|
ctx.NumActive-1, ctx.Equity.Win*100, ctx.Equity.Tie*100, ctx.Equity.Loss*100))
|
||||||
|
|
||||||
|
if ctx.ToCall > 0 {
|
||||||
|
exceeds := "exceeds"
|
||||||
|
if ctx.Equity.Win*100 < ctx.PotOddsPct {
|
||||||
|
exceeds = "falls short of"
|
||||||
|
}
|
||||||
|
userPrompt.WriteString(fmt.Sprintf("Pot odds to call: %.0f%% — equity %s price\n", ctx.PotOddsPct, exceeds))
|
||||||
|
} else {
|
||||||
|
userPrompt.WriteString("Check available — no bet to call\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
userPrompt.WriteString(fmt.Sprintf("SPR: %.1f | Position: %s | Active players: %d\n",
|
||||||
|
ctx.SPR, ctx.Position, ctx.NumActive))
|
||||||
|
userPrompt.WriteString("\nWhat should I consider for my decision?")
|
||||||
|
|
||||||
|
req := chatRequest{
|
||||||
|
Model: model,
|
||||||
|
Messages: []chatMessage{
|
||||||
|
{Role: "system", Content: systemPrompt},
|
||||||
|
{Role: "user", Content: userPrompt.String()},
|
||||||
|
},
|
||||||
|
Stream: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := strings.TrimRight(host, "/") + "/v1/chat/completions"
|
||||||
|
resp, err := holdemTipsClient.Post(url, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var chatResp chatResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
|
||||||
|
return "", fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chatResp.Choices) == 0 || chatResp.Choices[0].Message.Content == "" {
|
||||||
|
return "", fmt.Errorf("empty response")
|
||||||
|
}
|
||||||
|
|
||||||
|
tip := strings.TrimSpace(chatResp.Choices[0].Message.Content)
|
||||||
|
// Strip thinking tags if present.
|
||||||
|
if i := strings.Index(tip, "<think>"); i != -1 {
|
||||||
|
if j := strings.Index(tip, "</think>"); j != -1 {
|
||||||
|
tip = strings.TrimSpace(tip[:i] + tip[j+len("</think>"):])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tip, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRulesTip(ctx holdemTipContext) string {
|
||||||
|
equity := ctx.Equity.Win + ctx.Equity.Tie*0.5
|
||||||
|
|
||||||
|
var tip string
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case equity > 0.80:
|
||||||
|
tip = fmt.Sprintf("Strong hand (%.0f%% equity). Size your bet for value — you want to get paid off.", equity*100)
|
||||||
|
case ctx.ToCall > 0 && equity*100 > ctx.PotOddsPct:
|
||||||
|
tip = fmt.Sprintf("Equity %.0f%% exceeds pot odds %.0f%% — calling is +EV here.", equity*100, ctx.PotOddsPct)
|
||||||
|
case ctx.ToCall > 0 && equity*100 <= ctx.PotOddsPct:
|
||||||
|
tip = fmt.Sprintf("Equity %.0f%% falls short of pot odds %.0f%% — consider folding unless you have a strong draw.", equity*100, ctx.PotOddsPct)
|
||||||
|
case ctx.ToCall == 0 && equity > 0.65:
|
||||||
|
tip = fmt.Sprintf("%.0f%% equity with check available — bet for value and deny free cards to draws.", equity*100)
|
||||||
|
case ctx.ToCall == 0 && equity < 0.40:
|
||||||
|
tip = fmt.Sprintf("%.0f%% equity — check to control pot size. Not enough equity to bet.", equity*100)
|
||||||
|
case ctx.SPR < 1:
|
||||||
|
tip = "Shallow stack (SPR < 1) — commit or fold. No room to maneuver."
|
||||||
|
case ctx.SPR > 10 && ctx.Street == StreetPreFlop:
|
||||||
|
tip = "Deep stacked preflop — implied odds outweigh raw equity. Speculative hands gain value."
|
||||||
|
default:
|
||||||
|
tip = fmt.Sprintf("%.0f%% equity. Evaluate your position and pot odds before acting.", equity*100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position note.
|
||||||
|
switch ctx.Position {
|
||||||
|
case "BTN", "CO":
|
||||||
|
tip += " You have positional advantage — use it."
|
||||||
|
case "SB", "BB":
|
||||||
|
tip += " Out of position — play tighter."
|
||||||
|
case "UTG":
|
||||||
|
tip += " Early position — you need a strong range here."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.NumActive >= 4 {
|
||||||
|
tip += " Multiway pot — hand values shift; drawing hands improve, bluffs lose value."
|
||||||
|
}
|
||||||
|
|
||||||
|
return tip
|
||||||
|
}
|
||||||
618
internal/plugin/wordle.go
Normal file
618
internal/plugin/wordle.go
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WordlePlugin provides a daily cooperative Wordle game.
|
||||||
|
type WordlePlugin struct {
|
||||||
|
Base
|
||||||
|
apiKey string
|
||||||
|
httpClient *http.Client
|
||||||
|
defaultLength int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
puzzles map[id.RoomID]*WordlePuzzle
|
||||||
|
|
||||||
|
// In-memory cache of validated words per puzzle to avoid redundant API calls.
|
||||||
|
validCache map[string]map[string]bool // puzzleID -> word -> valid
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWordlePlugin creates a new WordlePlugin.
|
||||||
|
func NewWordlePlugin(client *mautrix.Client) *WordlePlugin {
|
||||||
|
length := 5
|
||||||
|
if v := os.Getenv("WORDLE_DEFAULT_LENGTH"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n >= 5 && n <= 7 {
|
||||||
|
length = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &WordlePlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
apiKey: os.Getenv("WORDNIK_API_KEY"),
|
||||||
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
defaultLength: length,
|
||||||
|
puzzles: make(map[id.RoomID]*WordlePuzzle),
|
||||||
|
validCache: make(map[string]map[string]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WordlePlugin) Name() string { return "wordle" }
|
||||||
|
|
||||||
|
func (p *WordlePlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "wordle", Description: "Guess today's Wordle", Usage: "!wordle <word>", Category: "Games"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WordlePlugin) Init() error {
|
||||||
|
// Rehydrate today's puzzle from DB if it exists.
|
||||||
|
p.rehydratePuzzles()
|
||||||
|
|
||||||
|
// Start the midnight ticker for auto-posting.
|
||||||
|
go p.midnightTicker()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WordlePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *WordlePlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if !p.IsCommand(ctx.Body, "wordle") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isGamesRoom(ctx.RoomID) {
|
||||||
|
gr := gamesRoom()
|
||||||
|
if gr != "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "wordle"))
|
||||||
|
if args == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!wordle <word>` — guess today's puzzle.\nSee `!wordle help` for all commands.")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case args == "help":
|
||||||
|
return p.handleHelp(ctx)
|
||||||
|
case args == "stats":
|
||||||
|
return p.handleStats(ctx)
|
||||||
|
case args == "grid":
|
||||||
|
return p.handleGrid(ctx)
|
||||||
|
case args == "new" || strings.HasPrefix(args, "new "):
|
||||||
|
return p.handleNew(ctx, args)
|
||||||
|
case args == "skip":
|
||||||
|
return p.handleSkip(ctx)
|
||||||
|
default:
|
||||||
|
return p.handleGuess(ctx, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WordlePlugin) handleHelp(ctx MessageContext) error {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"🟩 **Wordle Commands**\n\n"+
|
||||||
|
"`!wordle <word>` — Submit a guess\n"+
|
||||||
|
"`!wordle grid` — Re-post current puzzle grid\n"+
|
||||||
|
"`!wordle stats` — All-time leaderboard\n"+
|
||||||
|
"`!wordle new` — Start a new puzzle (admin)\n"+
|
||||||
|
"`!wordle new <5|6|7>` — New puzzle with specific length (admin)\n"+
|
||||||
|
"`!wordle skip` — Reveal answer and end puzzle (admin)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
|
||||||
|
guess = strings.ToUpper(strings.TrimSpace(guess))
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
puzzle := p.puzzles[ctx.RoomID]
|
||||||
|
if puzzle == nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No active puzzle. An admin can start one with `!wordle new`.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if puzzle.Solved || puzzle.Failed {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Today's puzzle is already over. A new one starts at midnight UTC!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check length.
|
||||||
|
if len([]rune(guess)) != puzzle.WordLength {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
fmt.Sprintf("Guesses must be %d letters.", puzzle.WordLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for non-alphabetic characters.
|
||||||
|
for _, r := range guess {
|
||||||
|
if r < 'A' || r > 'Z' {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Guesses must contain only letters.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check duplicate guess.
|
||||||
|
for _, g := range puzzle.Guesses {
|
||||||
|
if g.Word == guess {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
fmt.Sprintf("**%s** has already been tried.", guess))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate word via Wordnik (with caching).
|
||||||
|
valid, apiErr := p.isValidWord(puzzle.PuzzleID, guess)
|
||||||
|
if apiErr {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"Word validation is temporarily unavailable. Try again in a moment.")
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
fmt.Sprintf("❌ **%s** is not a valid word.", guess))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get display name.
|
||||||
|
displayName := p.displayName(ctx.Sender)
|
||||||
|
|
||||||
|
// Score the guess.
|
||||||
|
results := scoreGuess(guess, puzzle.Answer)
|
||||||
|
updateLetterStates(puzzle.LetterStates, guess, results)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
g := WordleGuess{
|
||||||
|
Word: guess,
|
||||||
|
PlayerID: ctx.Sender,
|
||||||
|
PlayerName: displayName,
|
||||||
|
Results: results,
|
||||||
|
Timestamp: now,
|
||||||
|
}
|
||||||
|
puzzle.Guesses = append(puzzle.Guesses, g)
|
||||||
|
|
||||||
|
// Check for win.
|
||||||
|
if guess == puzzle.Answer {
|
||||||
|
puzzle.Solved = true
|
||||||
|
puzzle.SolvedAt = &now
|
||||||
|
|
||||||
|
definition := p.fetchDefinition(puzzle.Answer)
|
||||||
|
p.updateStats(puzzle)
|
||||||
|
p.markPuzzleDone(puzzle)
|
||||||
|
|
||||||
|
return p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for failure (all guesses used).
|
||||||
|
if len(puzzle.Guesses) >= puzzle.MaxGuesses {
|
||||||
|
puzzle.Failed = true
|
||||||
|
|
||||||
|
definition := p.fetchDefinition(puzzle.Answer)
|
||||||
|
p.updateStats(puzzle)
|
||||||
|
p.markPuzzleDone(puzzle)
|
||||||
|
|
||||||
|
return p.SendMessage(ctx.RoomID, renderFailedAnnouncement(puzzle, definition))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post updated grid.
|
||||||
|
return p.SendMessage(ctx.RoomID, renderWordleGrid(puzzle))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WordlePlugin) handleGrid(ctx MessageContext) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
puzzle := p.puzzles[ctx.RoomID]
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if puzzle == nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No active puzzle.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(puzzle.Guesses) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
renderWordleStartAnnouncement(puzzle.PuzzleNumber, puzzle.WordLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendMessage(ctx.RoomID, renderWordleGrid(puzzle))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WordlePlugin) handleNew(ctx MessageContext, args string) error {
|
||||||
|
if !p.IsAdmin(ctx.Sender) {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can start a new puzzle.")
|
||||||
|
}
|
||||||
|
|
||||||
|
wordLength := p.defaultLength
|
||||||
|
parts := strings.Fields(args)
|
||||||
|
if len(parts) > 1 {
|
||||||
|
if n, err := strconv.Atoi(parts[1]); err == nil && n >= 5 && n <= 7 {
|
||||||
|
wordLength = n
|
||||||
|
} else {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Word length must be 5, 6, or 7.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
existing := p.puzzles[ctx.RoomID]
|
||||||
|
if existing != nil && !existing.Solved && !existing.Failed {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"There's already an active puzzle. Use `!wordle skip` to end it first.")
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
return p.createAndPostPuzzle(ctx.RoomID, wordLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WordlePlugin) handleSkip(ctx MessageContext) error {
|
||||||
|
if !p.IsAdmin(ctx.Sender) {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Only admins can skip a puzzle.")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
puzzle := p.puzzles[ctx.RoomID]
|
||||||
|
if puzzle == nil || puzzle.Solved || puzzle.Failed {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No active puzzle to skip.")
|
||||||
|
}
|
||||||
|
puzzle.Failed = true
|
||||||
|
p.markPuzzleDone(puzzle)
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
definition := p.fetchDefinition(puzzle.Answer)
|
||||||
|
defLine := ""
|
||||||
|
if definition != "" {
|
||||||
|
defLine = fmt.Sprintf("\n📖 *%s*\n", definition)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendMessage(ctx.RoomID,
|
||||||
|
fmt.Sprintf("⏭️ **Puzzle skipped.**\nThe word was **%s**.%s", puzzle.Answer, defLine))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WordlePlugin) handleStats(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
// Fetch top 10.
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT user_id, display_name, total_guesses, puzzles_played, puzzles_solved, winning_guesses
|
||||||
|
FROM wordle_stats ORDER BY puzzles_solved DESC, winning_guesses DESC LIMIT 10`)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load stats.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var stats []WordlePlayerStat
|
||||||
|
for rows.Next() {
|
||||||
|
var s WordlePlayerStat
|
||||||
|
if err := rows.Scan(&s.UserID, &s.DisplayName, &s.TotalGuesses, &s.PuzzlesPlayed, &s.PuzzlesSolved, &s.WinningGuesses); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats = append(stats, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(stats) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No Wordle stats yet. Play some puzzles first!")
|
||||||
|
}
|
||||||
|
|
||||||
|
streak := p.communityStreak()
|
||||||
|
return p.SendMessage(ctx.RoomID, renderWordleLeaderboard(stats, streak))
|
||||||
|
}
|
||||||
|
|
||||||
|
// createAndPostPuzzle creates a new puzzle, persists it, and posts the announcement.
|
||||||
|
func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int) error {
|
||||||
|
if p.apiKey == "" {
|
||||||
|
return p.SendMessage(roomID, "Wordle is unavailable — WORDNIK_API_KEY not configured.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Wordnik first, fall back to local word list.
|
||||||
|
word, err := wordnikFetchRandomWord(p.apiKey, p.httpClient, wordLength)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("wordle: Wordnik fetch failed, trying fallback", "err", err)
|
||||||
|
word = pickFallbackWord(wordLength)
|
||||||
|
if word == "" {
|
||||||
|
return p.SendMessage(roomID, "Failed to select a puzzle word. Try again later.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
puzzleNumber := p.nextPuzzleNumber()
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
puzzle := &WordlePuzzle{
|
||||||
|
PuzzleID: today,
|
||||||
|
PuzzleNumber: puzzleNumber,
|
||||||
|
RoomID: roomID,
|
||||||
|
Answer: word,
|
||||||
|
WordLength: wordLength,
|
||||||
|
MaxGuesses: 6,
|
||||||
|
StartedAt: now,
|
||||||
|
LetterStates: make(map[rune]LetterResult),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist to DB.
|
||||||
|
d := db.Get()
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO wordle_puzzles (puzzle_id, room_id, answer, word_length, solved, guess_count, started_at)
|
||||||
|
VALUES (?, ?, ?, ?, 0, 0, ?)
|
||||||
|
ON CONFLICT(puzzle_id, room_id) DO UPDATE SET answer = ?, word_length = ?, solved = 0, guess_count = 0, started_at = ?`,
|
||||||
|
today, string(roomID), word, wordLength, now,
|
||||||
|
word, wordLength, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("wordle: persist puzzle", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.puzzles[roomID] = puzzle
|
||||||
|
p.validCache[today] = make(map[string]bool)
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
return p.SendMessage(roomID, renderWordleStartAnnouncement(puzzleNumber, wordLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostDailyPuzzle is called by the scheduler to post today's puzzle.
|
||||||
|
func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error {
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
|
// Check if already posted today.
|
||||||
|
p.mu.Lock()
|
||||||
|
existing := p.puzzles[roomID]
|
||||||
|
if existing != nil && existing.PuzzleID == today {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return nil // already posted
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
return p.createAndPostPuzzle(roomID, p.defaultLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidWord checks if a word is valid, using the in-memory cache first.
|
||||||
|
// Returns (valid, apiError). apiError is true when the API is unreachable.
|
||||||
|
func (p *WordlePlugin) isValidWord(puzzleID, word string) (bool, bool) {
|
||||||
|
cache := p.validCache[puzzleID]
|
||||||
|
if cache == nil {
|
||||||
|
cache = make(map[string]bool)
|
||||||
|
p.validCache[puzzleID] = cache
|
||||||
|
}
|
||||||
|
|
||||||
|
if valid, ok := cache[word]; ok {
|
||||||
|
return valid, false
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, apiErr := wordnikValidateWord(p.apiKey, p.httpClient, word)
|
||||||
|
if !apiErr {
|
||||||
|
cache[word] = valid // only cache definitive results
|
||||||
|
}
|
||||||
|
return valid, apiErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WordlePlugin) fetchDefinition(word string) string {
|
||||||
|
return wordnikFetchDefinitionText(p.apiKey, p.httpClient, word)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WordlePlugin) displayName(userID id.UserID) string {
|
||||||
|
resp, err := p.Client.GetDisplayName(context.Background(), userID)
|
||||||
|
if err != nil || resp.DisplayName == "" {
|
||||||
|
return string(userID)
|
||||||
|
}
|
||||||
|
return resp.DisplayName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WordlePlugin) nextPuzzleNumber() int {
|
||||||
|
d := db.Get()
|
||||||
|
var count int
|
||||||
|
err := d.QueryRow(`SELECT COUNT(*) FROM wordle_puzzles`).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return count + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WordlePlugin) markPuzzleDone(puzzle *WordlePuzzle) {
|
||||||
|
d := db.Get()
|
||||||
|
solved := 0
|
||||||
|
if puzzle.Solved {
|
||||||
|
solved = 1
|
||||||
|
}
|
||||||
|
_, err := d.Exec(
|
||||||
|
`UPDATE wordle_puzzles SET solved = ?, guess_count = ?, solved_at = ? WHERE puzzle_id = ? AND room_id = ?`,
|
||||||
|
solved, len(puzzle.Guesses), puzzle.SolvedAt, puzzle.PuzzleID, string(puzzle.RoomID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("wordle: mark puzzle done", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WordlePlugin) updateStats(puzzle *WordlePuzzle) {
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
// Tally per-player contributions.
|
||||||
|
type contrib struct {
|
||||||
|
name string
|
||||||
|
guesses int
|
||||||
|
solved bool
|
||||||
|
}
|
||||||
|
players := map[id.UserID]*contrib{}
|
||||||
|
for i, g := range puzzle.Guesses {
|
||||||
|
c, ok := players[g.PlayerID]
|
||||||
|
if !ok {
|
||||||
|
c = &contrib{name: g.PlayerName}
|
||||||
|
players[g.PlayerID] = c
|
||||||
|
}
|
||||||
|
c.guesses++
|
||||||
|
if i == len(puzzle.Guesses)-1 && puzzle.Solved {
|
||||||
|
c.solved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := d.Begin()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("wordle: begin tx", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
for uid, c := range players {
|
||||||
|
puzzlesSolved := 0
|
||||||
|
if puzzle.Solved {
|
||||||
|
puzzlesSolved = 1
|
||||||
|
}
|
||||||
|
winningGuesses := 0
|
||||||
|
if c.solved {
|
||||||
|
winningGuesses = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := tx.Exec(
|
||||||
|
`INSERT INTO wordle_stats (user_id, display_name, total_guesses, puzzles_played, puzzles_solved, winning_guesses)
|
||||||
|
VALUES (?, ?, ?, 1, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
display_name = ?,
|
||||||
|
total_guesses = total_guesses + ?,
|
||||||
|
puzzles_played = puzzles_played + 1,
|
||||||
|
puzzles_solved = puzzles_solved + ?,
|
||||||
|
winning_guesses = winning_guesses + ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
string(uid), c.name, c.guesses, puzzlesSolved, winningGuesses,
|
||||||
|
c.name, c.guesses, puzzlesSolved, winningGuesses,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("wordle: update stats", "user", uid, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
slog.Error("wordle: commit stats", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WordlePlugin) communityStreak() int {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT puzzle_id, solved FROM wordle_puzzles ORDER BY puzzle_id DESC LIMIT 100`)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
streak := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var pid string
|
||||||
|
var solved int
|
||||||
|
if err := rows.Scan(&pid, &solved); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if solved == 1 {
|
||||||
|
streak++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return streak
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WordlePlugin) rehydratePuzzles() {
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT puzzle_id, room_id, answer, word_length, solved, guess_count, started_at
|
||||||
|
FROM wordle_puzzles WHERE puzzle_id = ?`, today)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("wordle: rehydrate query failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var pid, roomStr, answer string
|
||||||
|
var wordLength, solved, guessCount int
|
||||||
|
var startedAt time.Time
|
||||||
|
if err := rows.Scan(&pid, &roomStr, &answer, &wordLength, &solved, &guessCount, &startedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if solved == 1 || guessCount >= 6 {
|
||||||
|
continue // already done
|
||||||
|
}
|
||||||
|
|
||||||
|
roomID := id.RoomID(roomStr)
|
||||||
|
|
||||||
|
// Get puzzle number.
|
||||||
|
var puzzleNumber int
|
||||||
|
err := d.QueryRow(`SELECT COUNT(*) FROM wordle_puzzles WHERE puzzle_id <= ?`, pid).Scan(&puzzleNumber)
|
||||||
|
if err != nil {
|
||||||
|
puzzleNumber = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
puzzle := &WordlePuzzle{
|
||||||
|
PuzzleID: pid,
|
||||||
|
PuzzleNumber: puzzleNumber,
|
||||||
|
RoomID: roomID,
|
||||||
|
Answer: answer,
|
||||||
|
WordLength: wordLength,
|
||||||
|
MaxGuesses: 6,
|
||||||
|
StartedAt: startedAt,
|
||||||
|
LetterStates: make(map[rune]LetterResult),
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.puzzles[roomID] = puzzle
|
||||||
|
p.validCache[pid] = make(map[string]bool)
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
slog.Info("wordle: rehydrated puzzle", "room", roomID, "answer_len", wordLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// midnightTicker checks every minute if it's time to post a new daily puzzle.
|
||||||
|
func (p *WordlePlugin) midnightTicker() {
|
||||||
|
// Check immediately on startup.
|
||||||
|
p.checkAndPostDaily()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(1 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
lastDate := time.Now().UTC().Format("2006-01-02")
|
||||||
|
for range ticker.C {
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
if today != lastDate {
|
||||||
|
lastDate = today
|
||||||
|
p.checkAndPostDaily()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WordlePlugin) checkAndPostDaily() {
|
||||||
|
gr := gamesRoom()
|
||||||
|
if gr == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
// Check if today's puzzle already exists for this room.
|
||||||
|
var exists int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT 1 FROM wordle_puzzles WHERE puzzle_id = ? AND room_id = ?`,
|
||||||
|
today, string(gr),
|
||||||
|
).Scan(&exists)
|
||||||
|
if err == nil {
|
||||||
|
return // already exists
|
||||||
|
}
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
slog.Error("wordle: check daily puzzle", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("wordle: posting daily puzzle", "room", gr)
|
||||||
|
if err := p.createAndPostPuzzle(gr, p.defaultLength); err != nil {
|
||||||
|
slog.Error("wordle: daily puzzle failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
57
internal/plugin/wordle_fallback.go
Normal file
57
internal/plugin/wordle_fallback.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
fallbackWords map[int][]string
|
||||||
|
fallbackWordsOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadFallbackWords loads the emergency word list from data/wordle_words.txt.
|
||||||
|
// Each line is one word. Words are grouped by length.
|
||||||
|
func loadFallbackWords() {
|
||||||
|
fallbackWords = make(map[int][]string)
|
||||||
|
|
||||||
|
path := "data/wordle_words.txt"
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("wordle: fallback word list not found", "path", path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
word := strings.TrimSpace(scanner.Text())
|
||||||
|
if word == "" || strings.HasPrefix(word, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
word = strings.ToUpper(word)
|
||||||
|
n := len([]rune(word))
|
||||||
|
if n >= 5 && n <= 7 {
|
||||||
|
fallbackWords[n] = append(fallbackWords[n], word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for n, words := range fallbackWords {
|
||||||
|
slog.Info("wordle: loaded fallback words", "length", n, "count", len(words))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickFallbackWord picks a random word of the given length from the fallback list.
|
||||||
|
func pickFallbackWord(length int) string {
|
||||||
|
fallbackWordsOnce.Do(loadFallbackWords)
|
||||||
|
|
||||||
|
words := fallbackWords[length]
|
||||||
|
if len(words) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return words[rand.IntN(len(words))]
|
||||||
|
}
|
||||||
110
internal/plugin/wordle_game.go
Normal file
110
internal/plugin/wordle_game.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LetterResult represents the result of a single letter in a Wordle guess.
|
||||||
|
type LetterResult int
|
||||||
|
|
||||||
|
const (
|
||||||
|
LetterAbsent LetterResult = iota // ⬛ not in word
|
||||||
|
LetterPresent // 🟨 right letter, wrong position
|
||||||
|
LetterCorrect // 🟩 right letter, right position
|
||||||
|
)
|
||||||
|
|
||||||
|
// WordleGuess stores a single guess and its evaluation.
|
||||||
|
type WordleGuess struct {
|
||||||
|
Word string
|
||||||
|
PlayerID id.UserID
|
||||||
|
PlayerName string
|
||||||
|
Results []LetterResult
|
||||||
|
Timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// WordlePuzzle holds all state for one day's puzzle.
|
||||||
|
type WordlePuzzle struct {
|
||||||
|
PuzzleID string // YYYY-MM-DD
|
||||||
|
PuzzleNumber int
|
||||||
|
RoomID id.RoomID
|
||||||
|
Answer string // uppercased
|
||||||
|
WordLength int
|
||||||
|
MaxGuesses int // always 6
|
||||||
|
Guesses []WordleGuess
|
||||||
|
Solved bool
|
||||||
|
Failed bool
|
||||||
|
StartedAt time.Time
|
||||||
|
SolvedAt *time.Time
|
||||||
|
LetterStates map[rune]LetterResult // best known state per letter
|
||||||
|
}
|
||||||
|
|
||||||
|
// WordlePlayerStat tracks a player's all-time Wordle stats.
|
||||||
|
type WordlePlayerStat struct {
|
||||||
|
UserID id.UserID
|
||||||
|
DisplayName string
|
||||||
|
TotalGuesses int
|
||||||
|
PuzzlesPlayed int
|
||||||
|
PuzzlesSolved int
|
||||||
|
WinningGuesses int
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoreGuess evaluates a guess against the answer using the standard
|
||||||
|
// two-pass Wordle algorithm for correct duplicate-letter handling.
|
||||||
|
func scoreGuess(guess, answer string) []LetterResult {
|
||||||
|
n := len(answer)
|
||||||
|
results := make([]LetterResult, n)
|
||||||
|
pool := make([]rune, 0, n)
|
||||||
|
|
||||||
|
guessRunes := []rune(guess)
|
||||||
|
answerRunes := []rune(answer)
|
||||||
|
|
||||||
|
// First pass: mark exact matches (Correct).
|
||||||
|
used := make([]bool, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if guessRunes[i] == answerRunes[i] {
|
||||||
|
results[i] = LetterCorrect
|
||||||
|
used[i] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build pool of unmatched answer letters.
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if !used[i] {
|
||||||
|
pool = append(pool, answerRunes[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: mark Present or Absent.
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
if results[i] == LetterCorrect {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for j, r := range pool {
|
||||||
|
if guessRunes[i] == r {
|
||||||
|
results[i] = LetterPresent
|
||||||
|
pool = append(pool[:j], pool[j+1:]...)
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
results[i] = LetterAbsent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateLetterStates updates the keyboard state map with results from a guess.
|
||||||
|
// A letter's state only upgrades: Absent → Present → Correct.
|
||||||
|
func updateLetterStates(states map[rune]LetterResult, guess string, results []LetterResult) {
|
||||||
|
for i, r := range []rune(guess) {
|
||||||
|
existing, ok := states[r]
|
||||||
|
if !ok || results[i] > existing {
|
||||||
|
states[r] = results[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
229
internal/plugin/wordle_render.go
Normal file
229
internal/plugin/wordle_render.go
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// letterEmoji returns the colored emoji for a LetterResult.
|
||||||
|
func letterEmoji(r LetterResult) string {
|
||||||
|
switch r {
|
||||||
|
case LetterCorrect:
|
||||||
|
return "🟩"
|
||||||
|
case LetterPresent:
|
||||||
|
return "🟨"
|
||||||
|
default:
|
||||||
|
return "⬛"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderGrid renders the full emoji grid for the current puzzle state.
|
||||||
|
func renderWordleGrid(puzzle *WordlePuzzle) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
status := fmt.Sprintf("%d/6", len(puzzle.Guesses))
|
||||||
|
if puzzle.Solved {
|
||||||
|
status += " 🟩🟩🟩🟩🟩"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("🟩 **Wordle #%d** — %s\n\n", puzzle.PuzzleNumber, status))
|
||||||
|
|
||||||
|
for _, g := range puzzle.Guesses {
|
||||||
|
// Emoji tiles.
|
||||||
|
for _, r := range g.Results {
|
||||||
|
sb.WriteString(letterEmoji(r))
|
||||||
|
}
|
||||||
|
// Word + player name.
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s (%s)", g.Word, g.PlayerName))
|
||||||
|
if g.Word == puzzle.Answer {
|
||||||
|
sb.WriteString(" ✅")
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard view.
|
||||||
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString(renderKeyboard(puzzle.LetterStates))
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderKeyboard renders the QWERTY keyboard with color-coded letter states.
|
||||||
|
func renderKeyboard(states map[rune]LetterResult) string {
|
||||||
|
rows := []string{
|
||||||
|
"QWERTYUIOP",
|
||||||
|
"ASDFGHJKL",
|
||||||
|
"ZXCVBNM",
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, row := range rows {
|
||||||
|
for i, ch := range row {
|
||||||
|
if i > 0 {
|
||||||
|
sb.WriteString(" ")
|
||||||
|
}
|
||||||
|
if state, ok := states[ch]; ok {
|
||||||
|
sb.WriteString(letterEmoji(state))
|
||||||
|
}
|
||||||
|
sb.WriteRune(ch)
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderWordleStartAnnouncement renders the puzzle start message.
|
||||||
|
func renderWordleStartAnnouncement(puzzleNumber, wordLength int) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"🟩 **Daily Wordle #%d**\nA new %d-letter puzzle is ready! Work together — 6 guesses shared.\n\nGuess with: `!wordle <word>`",
|
||||||
|
puzzleNumber, wordLength,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderSolvedAnnouncement renders the solved puzzle message.
|
||||||
|
func renderSolvedAnnouncement(puzzle *WordlePuzzle, definition string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Find the solver.
|
||||||
|
lastGuess := puzzle.Guesses[len(puzzle.Guesses)-1]
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("🎉 **Solved in %d/6!**\n", len(puzzle.Guesses)))
|
||||||
|
sb.WriteString(fmt.Sprintf("The word was **%s** — solved by %s on guess %d.\n",
|
||||||
|
puzzle.Answer, lastGuess.PlayerName, len(puzzle.Guesses)))
|
||||||
|
|
||||||
|
if definition != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n📖 *%s*\n", definition))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full grid.
|
||||||
|
sb.WriteString("\n")
|
||||||
|
for _, g := range puzzle.Guesses {
|
||||||
|
for _, r := range g.Results {
|
||||||
|
sb.WriteString(letterEmoji(r))
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s (%s)", g.Word, g.PlayerName))
|
||||||
|
if g.Word == puzzle.Answer {
|
||||||
|
sb.WriteString(" ✅")
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contributors.
|
||||||
|
sb.WriteString("\n🏆 Today's contributors:\n")
|
||||||
|
contributors := wordleContributors(puzzle)
|
||||||
|
for _, c := range contributors {
|
||||||
|
line := fmt.Sprintf(" %s — %d guess", c.name, c.guesses)
|
||||||
|
if c.guesses != 1 {
|
||||||
|
line += "es"
|
||||||
|
}
|
||||||
|
if c.solved {
|
||||||
|
line += " 🏆"
|
||||||
|
}
|
||||||
|
sb.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderFailedAnnouncement renders the failed puzzle message.
|
||||||
|
func renderFailedAnnouncement(puzzle *WordlePuzzle, definition string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("💀 **Puzzle failed — 6/6**\nThe word was **%s**.\n", puzzle.Answer))
|
||||||
|
|
||||||
|
if definition != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n📖 *%s*\n", definition))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full grid.
|
||||||
|
sb.WriteString("\n")
|
||||||
|
for _, g := range puzzle.Guesses {
|
||||||
|
for _, r := range g.Results {
|
||||||
|
sb.WriteString(letterEmoji(r))
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s (%s)\n", g.Word, g.PlayerName))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\nBetter luck tomorrow. Contributors:\n")
|
||||||
|
contributors := wordleContributors(puzzle)
|
||||||
|
for _, c := range contributors {
|
||||||
|
line := fmt.Sprintf(" %s — %d guess", c.name, c.guesses)
|
||||||
|
if c.guesses != 1 {
|
||||||
|
line += "es"
|
||||||
|
}
|
||||||
|
sb.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
type wordleContributor struct {
|
||||||
|
name string
|
||||||
|
guesses int
|
||||||
|
solved bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordleContributors tallies guess counts per player.
|
||||||
|
func wordleContributors(puzzle *WordlePuzzle) []wordleContributor {
|
||||||
|
type entry struct {
|
||||||
|
name string
|
||||||
|
guesses int
|
||||||
|
solved bool
|
||||||
|
}
|
||||||
|
seen := map[string]*entry{}
|
||||||
|
var order []string
|
||||||
|
|
||||||
|
for i, g := range puzzle.Guesses {
|
||||||
|
key := string(g.PlayerID)
|
||||||
|
if e, ok := seen[key]; ok {
|
||||||
|
e.guesses++
|
||||||
|
if i == len(puzzle.Guesses)-1 && puzzle.Solved {
|
||||||
|
e.solved = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
e := &entry{name: g.PlayerName, guesses: 1}
|
||||||
|
if i == len(puzzle.Guesses)-1 && puzzle.Solved {
|
||||||
|
e.solved = true
|
||||||
|
}
|
||||||
|
seen[key] = e
|
||||||
|
order = append(order, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]wordleContributor, 0, len(order))
|
||||||
|
for _, key := range order {
|
||||||
|
e := seen[key]
|
||||||
|
result = append(result, wordleContributor{name: e.name, guesses: e.guesses, solved: e.solved})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderLeaderboard renders the all-time Wordle leaderboard.
|
||||||
|
func renderWordleLeaderboard(stats []WordlePlayerStat, streak int) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("📊 **Wordle Leaderboard**\n")
|
||||||
|
|
||||||
|
for i, s := range stats {
|
||||||
|
line := fmt.Sprintf(" %d. %s — %d solve", i+1, s.DisplayName, s.PuzzlesSolved)
|
||||||
|
if s.PuzzlesSolved != 1 {
|
||||||
|
line += "s"
|
||||||
|
}
|
||||||
|
if s.WinningGuesses > 0 {
|
||||||
|
line += fmt.Sprintf(" | %d winning guess", s.WinningGuesses)
|
||||||
|
if s.WinningGuesses != 1 {
|
||||||
|
line += "es"
|
||||||
|
}
|
||||||
|
line += " 🏆"
|
||||||
|
}
|
||||||
|
sb.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if streak > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("\nCommunity streak: %d day", streak))
|
||||||
|
if streak != 1 {
|
||||||
|
sb.WriteString("s")
|
||||||
|
}
|
||||||
|
sb.WriteString(" solved in a row 🔥\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
193
internal/plugin/wordle_wordnik.go
Normal file
193
internal/plugin/wordle_wordnik.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wordnikRandomWordResponse is the Wordnik randomWord response.
|
||||||
|
type wordnikRandomWordResponse struct {
|
||||||
|
Word string `json:"word"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordnikDefinitionResponse is one definition entry.
|
||||||
|
type wordnikDefinitionResponse struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
PartOfSpeech string `json:"partOfSpeech"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordnikFetchRandomWord fetches a random word of the given length from Wordnik.
|
||||||
|
// Returns the uppercased word. Retries up to 5 times to avoid bad words.
|
||||||
|
func wordnikFetchRandomWord(apiKey string, client *http.Client, wordLength int) (string, error) {
|
||||||
|
for attempt := 0; attempt < 5; attempt++ {
|
||||||
|
url := fmt.Sprintf(
|
||||||
|
"https://api.wordnik.com/v4/words.json/randomWord?hasDictionaryDef=true&minLength=%d&maxLength=%d&minCorpusCount=5000&minDictionaryCount=3&api_key=%s",
|
||||||
|
wordLength, wordLength, apiKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("wordle: create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("wordle: fetch random word: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("wordle: API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result wordnikRandomWordResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", fmt.Errorf("wordle: decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
word := strings.ToUpper(strings.TrimSpace(result.Word))
|
||||||
|
|
||||||
|
// Reject words with hyphens, spaces, apostrophes.
|
||||||
|
if strings.ContainsAny(word, "-' ") {
|
||||||
|
slog.Debug("wordle: rejecting word with special chars", "word", word, "attempt", attempt+1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject if not all alphabetic.
|
||||||
|
allAlpha := true
|
||||||
|
for _, r := range word {
|
||||||
|
if !unicode.IsLetter(r) {
|
||||||
|
allAlpha = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allAlpha {
|
||||||
|
slog.Debug("wordle: rejecting non-alpha word", "word", word, "attempt", attempt+1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check definition to reject proper nouns.
|
||||||
|
if isProperNoun(apiKey, client, word) {
|
||||||
|
slog.Debug("wordle: rejecting proper noun", "word", word, "attempt", attempt+1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("wordle: selected word", "word", word, "length", wordLength, "attempt", attempt+1)
|
||||||
|
return word, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("wordle: failed to find suitable word after 5 attempts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isProperNoun checks if a word's first definition starts with a capital letter.
|
||||||
|
func isProperNoun(apiKey string, client *http.Client, word string) bool {
|
||||||
|
defs, err := wordnikFetchDefinitions(apiKey, client, strings.ToLower(word), 1)
|
||||||
|
if err != nil || len(defs) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(defs[0].Text)
|
||||||
|
if text == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return unicode.IsUpper([]rune(text)[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordnikValidateWord checks if a word exists by looking up its definitions.
|
||||||
|
// Returns valid bool and an error flag. On API errors, returns false with apiErr=true
|
||||||
|
// so the caller can show a different message than "not a valid word".
|
||||||
|
func wordnikValidateWord(apiKey string, client *http.Client, word string) (valid bool, apiErr bool) {
|
||||||
|
if apiKey == "" {
|
||||||
|
return true, false // no API key = skip validation
|
||||||
|
}
|
||||||
|
defs, err := wordnikFetchDefinitions(apiKey, client, strings.ToLower(word), 1)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("wordle: validation API error", "word", word, "err", err)
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
return len(defs) > 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordnikFetchDefinitionText fetches a clean definition string for display.
|
||||||
|
func wordnikFetchDefinitionText(apiKey string, client *http.Client, word string) string {
|
||||||
|
defs, err := wordnikFetchDefinitions(apiKey, client, strings.ToLower(word), 3)
|
||||||
|
if err != nil || len(defs) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first clean definition.
|
||||||
|
for _, d := range defs {
|
||||||
|
text := strings.TrimSpace(d.Text)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Strip HTML tags if any.
|
||||||
|
text = stripHTMLTags(text)
|
||||||
|
pos := d.PartOfSpeech
|
||||||
|
if pos != "" {
|
||||||
|
return fmt.Sprintf("%s (%s): %s", strings.ToLower(word), pos, text)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: %s", strings.ToLower(word), text)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// wordnikFetchDefinitions fetches definitions from Wordnik.
|
||||||
|
func wordnikFetchDefinitions(apiKey string, client *http.Client, word string, limit int) ([]wordnikDefinitionResponse, error) {
|
||||||
|
url := fmt.Sprintf(
|
||||||
|
"https://api.wordnik.com/v4/word.json/%s/definitions?limit=%d&sourceDictionaries=all&api_key=%s",
|
||||||
|
word, limit, apiKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, nil // word not found
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var defs []wordnikDefinitionResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&defs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return defs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripHTMLTags removes HTML tags from a string.
|
||||||
|
func stripHTMLTags(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
inTag := false
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '<' {
|
||||||
|
inTag = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == '>' {
|
||||||
|
inTag = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !inTag {
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
4
main.go
4
main.go
@@ -113,6 +113,10 @@ func main() {
|
|||||||
registry.Register(plugin.NewHangmanPlugin(client, euroPlugin))
|
registry.Register(plugin.NewHangmanPlugin(client, euroPlugin))
|
||||||
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.NewAdventurePlugin(client, euroPlugin))
|
||||||
|
wordlePlugin := plugin.NewWordlePlugin(client)
|
||||||
|
registry.Register(wordlePlugin)
|
||||||
|
|
||||||
// Community
|
// Community
|
||||||
registry.Register(plugin.NewMilkCartonPlugin(client, ratePlugin))
|
registry.Register(plugin.NewMilkCartonPlugin(client, ratePlugin))
|
||||||
|
|||||||
34
scripts/backfill_room_sentiment.sh
Executable file
34
scripts/backfill_room_sentiment.sh
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Backfill room_sentiment_stats from existing llm_classifications data.
|
||||||
|
# Run once after deploying the room_sentiment_stats schema change.
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/backfill_room_sentiment.sh [path/to/gogobee.db]
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DB="${1:-data/gogobee.db}"
|
||||||
|
|
||||||
|
if [ ! -f "$DB" ]; then
|
||||||
|
echo "Database not found: $DB"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sqlite3 "$DB" <<'SQL'
|
||||||
|
INSERT INTO room_sentiment_stats (room_id, positive, negative, neutral, excited, sarcastic, frustrated, curious, grateful, humorous, supportive, total_score)
|
||||||
|
SELECT room_id,
|
||||||
|
SUM(CASE WHEN sentiment = 'positive' THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN sentiment = 'negative' THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN sentiment = 'neutral' THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN sentiment = 'excited' THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN sentiment = 'sarcastic' THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN sentiment = 'frustrated' THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN sentiment = 'curious' THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN sentiment = 'grateful' THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN sentiment = 'humorous' THEN 1 ELSE 0 END),
|
||||||
|
SUM(CASE WHEN sentiment = 'supportive' THEN 1 ELSE 0 END),
|
||||||
|
SUM(sentiment_score)
|
||||||
|
FROM llm_classifications
|
||||||
|
GROUP BY room_id;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Done. Backfilled room_sentiment_stats from llm_classifications."
|
||||||
Reference in New Issue
Block a user