mirror of
https://github.com/prosolis/gogobee.git
synced 2026-11-08 10:50:59 +00:00
Merge pull request #10 from prosolis/phase-H-harvest-charges
Phase h harvest charges
This commit is contained in:
+4
-1
@@ -1,7 +1,10 @@
|
||||
data/
|
||||
data/*
|
||||
!data/open5e/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
gogobee
|
||||
gensolver
|
||||
holdem-train
|
||||
/open5e-import
|
||||
/expedition-sim
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
GogoBee — third-party content attribution
|
||||
==========================================
|
||||
|
||||
Open5e SRD data
|
||||
---------------
|
||||
Portions of GogoBee's spell, bestiary, and magic-item data are derived from
|
||||
the Open5e dataset, which republishes the Systems Reference Document (SRD) 5.1.
|
||||
|
||||
Source: https://open5e.com / https://api.open5e.com
|
||||
License: Creative Commons Attribution 4.0 International (CC-BY-4.0)
|
||||
https://creativecommons.org/licenses/by/4.0/
|
||||
SRD 5.1: © Wizards of the Coast LLC, released under CC-BY-4.0.
|
||||
|
||||
The data is vendored into this repository (data/open5e/) and compiled into
|
||||
generated Go source (e.g. internal/plugin/dnd_spells_srd_data.go) by the
|
||||
cmd/open5e-import tool. GogoBee has no runtime dependency on the Open5e API.
|
||||
|
||||
GogoBee's hand-authored balance tuning, classifiers, and game systems built
|
||||
on top of this data are not themselves SRD content.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Combat Rebalance — 2026-05-10
|
||||
|
||||
Post HP-unification balance recovery. Player HP dropped ~45% when the legacy
|
||||
`50 + CombatLevel*2` base was retired; monsters were still tuned for the old
|
||||
~120 HP scale, making even L1 dungeons unsurvivable. This pass retunes both
|
||||
sides for the dnd HP scale and retires CombatLevel from combat formulas.
|
||||
|
||||
## Symptom
|
||||
|
||||
Real prod player @holymachina (cleric, dnd L10): legacy combat MaxHP ≈ 165 → post-unification combat MaxHP ≈ 70. Monster damage formulas still scaled quadratically with tier (`T5 Attack=49`), oversized for ~70 HP players. Wight encounter user reported: 56/56 → 0/56 in 4 phases despite wight ending with 12 HP left. Even fresh L1 dungeon dives ran 95%+ death rate in sims.
|
||||
|
||||
## Root causes
|
||||
|
||||
1. **HP unification dropped the `50 + CombatLevel*2` base** without any rebalance pass on monster formulas. Monsters tuned for legacy HP pool now obliterate dnd HP pool.
|
||||
2. **Tiebreak was absolute-HP-based** (recent change `de02907`). Worked OK on legacy scale where pools were similar; breaks on dnd scale because monster pools (~30–175) are 2-3× larger than player pools (~13–83), so timeouts always favor monsters even when player took less proportional damage.
|
||||
3. **CombatLevel was still load-bearing** in `DerivePlayerStats` for Attack/Defense/Speed/HP scaling, contradicting the Phase L closeout that retired it everywhere else. This made test fixtures lie about real player power.
|
||||
|
||||
## Scope decisions (locked in by user)
|
||||
|
||||
- **Don't restore the legacy `50 + CL*2` HP base.** Combat MaxHP stays at `c.HPMax + HPBonus(gear delta only)`.
|
||||
- **Strip CombatLevel from combat scaling entirely.** Player power = ability scores + gear + dnd-derived layers (AC/AB/weapon dice). No level-based defense/speed/attack/HP scaling on the player side.
|
||||
- **Both formula and (eventually) bestiary tuning** for monsters, not just one. Formula pass was sufficient this round; bestiary deferred.
|
||||
|
||||
## Changes
|
||||
|
||||
### Test infrastructure
|
||||
|
||||
`internal/plugin/combat_stats_test.go`:
|
||||
|
||||
- `TestBalanceRegression_DungeonDeathRates` and `TestBalanceRegression_UnderleveledPlayers` now apply the production combat pipeline before simulating: `applyDnDPlayerLayer`, `applyDnDEquipmentLayer`, `applyDnDDungeonMonsterLayer`. Previously they ran raw `DerivePlayerStats` output, which silently hid the HP-unification regression because the legacy MaxHP value never made it into the test combatant.
|
||||
- New helper `balanceTestDnDChar(dndLevel int)` synthesizes a fighter sheet (mirroring `autoBuildCharacter`) at the requested dnd level. Replaces the old `level=CombatLevel` proxy.
|
||||
- Test cases re-keyed onto dnd levels (1, 2, 5, 7, 9) for the well-geared baseline and (1, 2, 4, 6) for the underleveled set. Previously `level=5..48` was being treated as both CombatLevel *and* dnd_level which hid the regression.
|
||||
- Test log line now prints `AC` / `AB` / `Atk` instead of legacy `Atk/Def/Spd` triple — the d20 path's actual hit/damage drivers.
|
||||
- `TestDerivePlayerStats_BaseStats` updated: zero-gear baseline is now `MaxHP=50, HPBonus=0, Attack=5` (constants, no level scaling). Previously expected `>=70 HP` which was the legacy formula output for L10.
|
||||
|
||||
### Player stat derivation
|
||||
|
||||
`internal/plugin/combat_stats.go`:
|
||||
|
||||
- **Stripped CombatLevel scaling** from `DerivePlayerStats`. `MaxHP` baseline is a flat constant (50, used only as the multiplicand for armor/arena/housing % bonus formulas — subtracted out into `HPBonus` at the end so the legacy base never reaches combat). `Attack`, `Defense`, `Speed` are now constants (5, 3, 5) plus gear and bonuses. Player power scales through gear and the dnd layers.
|
||||
- All other gear/arena/housing/streak/grudge/pet/NPC bonus paths preserved.
|
||||
|
||||
### Dungeon monster formulas
|
||||
|
||||
`internal/plugin/combat_stats.go: DeriveDungeonMonsterStats`:
|
||||
|
||||
| Stat | Old formula (legacy HP era) | New formula (dnd HP era) |
|
||||
|---|---|---|
|
||||
| MaxHP | `25 + t*t*6 + death*0.6` (T1=33, T5=187) | `12 + t*7 + death*0.3` (T1≈22, T5≈65) |
|
||||
| Attack | `3 + death*0.2 + t*t*1.5` (T1=5, T5=49) | `t*1.5 + death*0.04` (T1=1, T5=9) |
|
||||
| Defense | `2 + t*1.5` | `2 + t*1.2` (small trim) |
|
||||
|
||||
Speed / CritRate / DodgeRate / BlockRate unchanged. AB scaling via `applyDnDDungeonMonsterLayer` (`9 + tier`) untouched — it was already calibrated for the d20 hit-resolution side.
|
||||
|
||||
Quadratic `t*t*X` terms are out everywhere on the monster side. Linear scaling matches the dnd HP scale's narrower band (player HP 13–83, not 60–200).
|
||||
|
||||
### Tiebreak
|
||||
|
||||
`internal/plugin/combat_engine.go`:
|
||||
|
||||
- Reverted the recent absolute-HP tiebreak (`de02907`) back to **HP percentage** for the post-Sudden-Death timeout case. Bias toward player on exact ties (`playerFrac >= enemyFrac` → enemy dies).
|
||||
- Reasoning: monster pools on the dnd scale are systematically 2-3× larger than player pools, so absolute always favored monsters even when the player took less proportional damage. The `de02907` example case (88/123 vs 83/97) doesn't exist on the new scale.
|
||||
|
||||
### Comments and tests touched (no behavior change)
|
||||
|
||||
- `combat_stats.go` comments rewritten to explain the dnd HP scale assumption and why the legacy base is captured then subtracted out.
|
||||
- `combat_engine.go` tiebreak comment rewritten with the new rationale.
|
||||
|
||||
## Sim results
|
||||
|
||||
`go test ./internal/plugin/ -run TestBalanceRegression -v` after this pass:
|
||||
|
||||
**Well-geared at tier (target window in parens):**
|
||||
|
||||
| Dungeon | Player | Monster | Death rate | Target |
|
||||
|---|---|---|---|---|
|
||||
| Soggy Cellar (L1 T1) | HP=13 AC=12 AB=6 | HP=21 AC=10 AB=5 Atk=1 | **3%** | 0–5% ✓ |
|
||||
| Goblin Warrens (L2 T2) | HP=23 AC=12 AB=6 | HP=31 AC=11 AB=6 Atk=3 | **7%** | 0–10% ✓ |
|
||||
| Cursed Crypt (L5 T3) | HP=48 AC=14 AB=7 | HP=42 AC=12 AB=7 Atk=5 | **1%** | 0–15% ✓ |
|
||||
| Troll Bridge (L7 T4) | HP=66 AC=15 AB=7 | HP=53 AC=13 AB=8 Atk=7 | **8%** | 0–25% ✓ |
|
||||
| Abyssal Maw (L9 T5) | HP=83 AC=16 AB=8 | HP=65 AC=14 AB=9 Atk=9 | **7%** | 1–35% ✓ |
|
||||
|
||||
**Underleveled / undergeared (real danger expected):**
|
||||
|
||||
| Scenario | Player | Monster | Death rate | Min target |
|
||||
|---|---|---|---|---|
|
||||
| L1 in T2 | HP=12 AC=12 AB=6 | HP=31 Atk=3 | **84%** | ≥40% ✓ |
|
||||
| L2 in T3 | HP=21 AC=12 AB=6 | HP=42 Atk=5 | **99%** | ≥30% ✓ |
|
||||
| L4 in T4, T2 gear | HP=39 AC=12 AB=6 | HP=53 Atk=7 | **97%** | ≥25% ✓ |
|
||||
| L6 in T5, T3 gear | HP=56 AC=14 AB=7 | HP=65 Atk=9 | **83%** | ≥30% ✓ |
|
||||
|
||||
All pass. No other plugin tests broken by the rebalance (two pre-existing failures — `TestStandaloneHarvest_RoutesToZoneRun` and `TestZoneRunFlow_AdvanceToBossAndComplete` — are Phase G branching-zone node-naming issues, not caused by these changes; verified by stash-pop comparison).
|
||||
|
||||
### Bestiary stat-block pass
|
||||
|
||||
`internal/plugin/dnd_bestiary.go`:
|
||||
|
||||
- **Programmatic CR-based rescale** of `Attack` and `AttackBonus` across all 69 entries. New formula:
|
||||
- `Attack = round(1 + 1.5 × CR)`, with a gentler bend at CR ≥ 10 (`max(prev, round(1 + 1.7×CR − 2))`), capped at 38.
|
||||
- `AttackBonus = min(old_AB, 11)` — caps the d20 to-hit bonus so end-game monsters still miss player AC sometimes.
|
||||
- HP / AC / Speed / Ability / BlockRate untouched — these aren't the lethality drivers; HP only matters if players can't damage-race the pool, and post-Attack-nerf the race is winnable.
|
||||
|
||||
Sample reseats:
|
||||
|
||||
| Monster | CR | Old Atk → New | Old AB → New |
|
||||
|---|---|---|---|
|
||||
| Goblin | 0.25 | 6 → 1 | 4 |
|
||||
| Skeleton | 0.25 | 8 → 1 | 4 |
|
||||
| Wight | 3 | 14 → 6 | 4 |
|
||||
| Flameskull | 4 | 18 → 7 | 5 |
|
||||
| Troll | 5 | 22 → 8 | 7 |
|
||||
| Wyvern | 6 | 28 → 10 | 7 |
|
||||
| Thornmother (CR 11 boss) | 11 | 32 → 18 | 8 |
|
||||
| Belaxath (CR 19 boss) | 19 | 58 → 31 | 11 (was 12) |
|
||||
| Ancient Dragon (CR 20) | 20 | 65 → 33 | 11 (was 14) |
|
||||
| Infernax (CR 24, end boss) | 24 | 65 → 38 | 11 (was 14) |
|
||||
|
||||
Test update: `TestBestiaryToCombatStats` dragon AB expectation `14 → 11`.
|
||||
|
||||
**Reasoning for not also touching HP**: a CR 24 boss with 546 HP is appropriately big-boss bulky on the dnd scale — players doing 7-15 dmg/round will need 35+ rounds to kill, which is fine for a final boss with phases. The pain point was the boss one-shotting players via 65-damage swings, not the HP wall. If specific bosses still feel too tanky once players actually fight them, individual `HP` trims are easy.
|
||||
|
||||
## Open follow-ups
|
||||
|
||||
- **Arena monster formula** (`DeriveArenaMonsterStats`) still has the old quadratic-feeling shape but isn't currently broken in sims — left untouched this pass. If arena death rates report wrong, mirror the dungeon shape.
|
||||
- **`TwoHandedMode` / `Versatile` weapon damage paths** in `applyDnDEquipmentLayer` weren't audited this pass — if they're miscalculating damage dice, balance shifts.
|
||||
- **Tiebreak bias** is a flat "player wins ties." Could be tuned to a small grace margin (e.g., player wins if `playerFrac >= enemyFrac - 0.05`) if narrative feedback says timeout losses still feel cheap.
|
||||
### Phase G test catch-up (formerly flaky)
|
||||
|
||||
Four tests written against the pre-Phase-G linear-room model were intermittently failing. Fixed in the same session so the suite runs clean:
|
||||
|
||||
- `TestStandaloneHarvest_RoutesToZoneRun` — was asserting the legacy `<zone>.r1` node id; production now writes `<zone>.entry` from the registered graph. Switched to `harvestNodeIDFor(run)` so the test reads whatever production wrote.
|
||||
- `TestZoneRunFlow_AdvanceToBossAndComplete` — asserted `len(RoomsCleared) == TotalRooms`. Diamond/fork graphs yield shorter paths than the total node count. Loosened to `1..TotalRooms` (boss-defeated check still in place).
|
||||
- `TestZoneCmd_AdvanceFinalRoomBumpsMood` — looped exactly `TotalRooms` `markRoomCleared` calls; with branching the run completes early and the extra calls find no active run. Switched to break on the first empty `next` return.
|
||||
- `TestAdv2Scenario_ZoneRunGoblinWarrens` — sent only `!zone advance`, stalling at fork rooms (Phase G goblin warrens is a diamond graph). Added auto `!zone go 1` when `run.NodeChoices` is non-empty so the scenario commits the first fork option and continues.
|
||||
|
||||
Verified clean across 4 consecutive `go test` runs.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `internal/plugin/combat_stats.go` — DerivePlayerStats CL strip, monster formula retune
|
||||
- `internal/plugin/combat_engine.go` — tiebreak revert
|
||||
- `internal/plugin/combat_stats_test.go` — production-accurate test pipeline, dnd_level-keyed cases, base-stats expectations
|
||||
- `internal/plugin/dnd_bestiary.go` — programmatic CR-based Attack/AB rescale across 69 entries
|
||||
- `internal/plugin/dnd_bestiary_test.go` — dragon AB expectation updated
|
||||
@@ -0,0 +1,181 @@
|
||||
// expedition-sim drives synthetic players through expeditions for batch
|
||||
// analysis. Re-uses production plugin paths against a fresh sqlite DB so
|
||||
// outcomes mirror what live players hit.
|
||||
//
|
||||
// Single run:
|
||||
// expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens]
|
||||
// [-bank 1000] [-cap 50] [-log] [-data DIR]
|
||||
//
|
||||
// Matrix mode (cartesian sweep over classes × levels × zones × N runs,
|
||||
// one JSON object per stdout line, log suppressed by default):
|
||||
// expedition-sim -matrix -classes fighter,mage -levels 5,10 \
|
||||
// -zones goblin_warrens,wolf_den -runs 20
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/plugin"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
class = flag.String("class", "fighter", "DnD class id (single-run mode)")
|
||||
level = flag.Int("level", 5, "character level (single-run mode)")
|
||||
zone = flag.String("zone", "goblin_warrens", "zone id (single-run mode)")
|
||||
bank = flag.Float64("bank", 1000, "starting coin balance — must cover outfitting")
|
||||
cap = flag.Int("cap", 50, "max autopilot bursts per expedition (each = up to autopilotRoomCap rooms)")
|
||||
dataDir = flag.String("data", "", "data dir for the temp sqlite db (default: OS tempdir; ignored in matrix mode)")
|
||||
userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
|
||||
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
|
||||
|
||||
matrix = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs")
|
||||
classes = flag.String("classes", "", "comma-separated class ids (matrix mode)")
|
||||
levels = flag.String("levels", "", "comma-separated levels (matrix mode)")
|
||||
zones = flag.String("zones", "", "comma-separated zone ids (matrix mode)")
|
||||
runs = flag.Int("runs", 1, "replicates per (class,level,zone) cell (matrix mode)")
|
||||
|
||||
trace = flag.Bool("trace", false, "include raw per-round CombatEvent stream on the LAST combat of each expedition (boss room) — for J2 diagnostic sweeps")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
plugin.SetSimIncludeTrace(*trace)
|
||||
|
||||
if *matrix {
|
||||
// Matrix default: drop log to keep stdout manageable; explicit
|
||||
// -log=true overrides.
|
||||
includeLog := false
|
||||
// Flag.Lookup tells us whether the user explicitly set -log.
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
if f.Name == "log" {
|
||||
includeLog = *logFlag
|
||||
}
|
||||
})
|
||||
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, includeLog)
|
||||
return
|
||||
}
|
||||
|
||||
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *logFlag)
|
||||
}
|
||||
|
||||
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap int, includeLog bool) {
|
||||
dir := dataDir
|
||||
if dir == "" {
|
||||
var err error
|
||||
dir, err = os.MkdirTemp("", "expedition-sim-")
|
||||
if err != nil {
|
||||
fail("mkdir temp:", err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap)
|
||||
if err != nil {
|
||||
if res != nil {
|
||||
if !includeLog {
|
||||
res.Log = nil
|
||||
}
|
||||
emitIndented(res)
|
||||
}
|
||||
fail("run:", err)
|
||||
}
|
||||
if !includeLog {
|
||||
res.Log = nil
|
||||
}
|
||||
emitIndented(res)
|
||||
}
|
||||
|
||||
func runMatrix(classes, levels, zones string, runs int, bank float64, cap int, includeLog bool) {
|
||||
cs := splitNonEmpty(classes)
|
||||
ls := parseLevels(levels)
|
||||
zs := splitNonEmpty(zones)
|
||||
if len(cs) == 0 || len(ls) == 0 || len(zs) == 0 || runs <= 0 {
|
||||
fail("matrix mode requires non-empty -classes, -levels, -zones and runs > 0")
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
for _, c := range cs {
|
||||
for _, lv := range ls {
|
||||
for _, z := range zs {
|
||||
for r := 0; r < runs; r++ {
|
||||
dir, err := os.MkdirTemp("", "expedition-sim-")
|
||||
if err != nil {
|
||||
fail("mkdir temp:", err)
|
||||
}
|
||||
uid := id.UserID(fmt.Sprintf("@sim:%s-l%d-%s-%d", c, lv, z, r))
|
||||
res, runErr := runOne(dir, uid, plugin.DnDClass(c), lv, plugin.ZoneID(z), bank, cap)
|
||||
if res != nil && !includeLog {
|
||||
res.Log = nil
|
||||
}
|
||||
if runErr != nil && res == nil {
|
||||
// Synthesize a row so the corpus has one line per
|
||||
// cell regardless of init failures.
|
||||
res = &plugin.SimResult{
|
||||
UserID: string(uid),
|
||||
Class: c,
|
||||
Level: lv,
|
||||
Zone: z,
|
||||
Outcome: "halted",
|
||||
}
|
||||
}
|
||||
_ = enc.Encode(res)
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap int) (*plugin.SimResult, error) {
|
||||
runner, err := plugin.NewSimRunner(dataDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init runner: %w", err)
|
||||
}
|
||||
defer runner.Close()
|
||||
|
||||
if _, err := runner.BuildCharacter(uid, class, level); err != nil {
|
||||
return nil, fmt.Errorf("build character: %w", err)
|
||||
}
|
||||
runner.Euro.Credit(uid, bank, "expedition-sim bankroll")
|
||||
return runner.RunExpedition(uid, zone, cap)
|
||||
}
|
||||
|
||||
func emitIndented(res *plugin.SimResult) {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(res)
|
||||
}
|
||||
|
||||
func splitNonEmpty(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := parts[:0]
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseLevels(s string) []int {
|
||||
var out []int
|
||||
for _, p := range splitNonEmpty(s) {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
fail("bad level:", p)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fail(args ...interface{}) {
|
||||
fmt.Fprintln(os.Stderr, args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// open5eMonster is the subset of the Open5e v1 monster schema we consume. The
|
||||
// vendored JSON keeps the full payload; unknown fields are ignored here.
|
||||
type open5eMonster struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Size string `json:"size"`
|
||||
Type string `json:"type"`
|
||||
CR float64 `json:"cr"`
|
||||
ArmorClass int `json:"armor_class"`
|
||||
HitPoints int `json:"hit_points"`
|
||||
Speed map[string]any `json:"speed"`
|
||||
Strength int `json:"strength"`
|
||||
Dexterity int `json:"dexterity"`
|
||||
Constitution int `json:"constitution"`
|
||||
Intelligence int `json:"intelligence"`
|
||||
Wisdom int `json:"wisdom"`
|
||||
Charisma int `json:"charisma"`
|
||||
Actions []open5eAction `json:"actions"`
|
||||
LegendaryActions []open5eAction `json:"legendary_actions"`
|
||||
SpecialAbilities []open5eNamedBlock `json:"special_abilities"`
|
||||
}
|
||||
|
||||
// open5eAction is one entry in a monster's actions list. An entry is a weapon
|
||||
// attack iff DamageDice is non-empty; Multiattack and utility actions leave it
|
||||
// blank. AttackBonus/DamageBonus arrive as JSON null on non-attacks, which
|
||||
// unmarshals to 0 — harmless, since DamageDice is the attack discriminator.
|
||||
type open5eAction struct {
|
||||
Name string `json:"name"`
|
||||
Desc string `json:"desc"`
|
||||
AttackBonus int `json:"attack_bonus"`
|
||||
DamageDice string `json:"damage_dice"`
|
||||
DamageBonus int `json:"damage_bonus"`
|
||||
}
|
||||
|
||||
type open5eNamedBlock struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type monstersPage struct {
|
||||
Next string `json:"next"`
|
||||
Results []open5eMonster `json:"results"`
|
||||
}
|
||||
|
||||
// fetchMonsters pages through the SRD monster list and writes the full result
|
||||
// set to data/open5e/monsters.json, pretty-printed for a reviewable diff.
|
||||
func fetchMonsters() error {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
var all []open5eMonster
|
||||
url := monstersAPIBase
|
||||
for url != "" {
|
||||
fmt.Fprintln(os.Stderr, "GET", url)
|
||||
page, err := getMonstersPage(client, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
all = append(all, page.Results...)
|
||||
url = page.Next
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "fetched %d monsters\n", len(all))
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(monstersJSON), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := json.MarshalIndent(all, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, '\n')
|
||||
return os.WriteFile(monstersJSON, out, 0o644)
|
||||
}
|
||||
|
||||
func getMonstersPage(client *http.Client, url string) (*monstersPage, error) {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var page monstersPage
|
||||
if err := json.Unmarshal(body, &page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &page, nil
|
||||
}
|
||||
|
||||
// ── Staging-table classifier ─────────────────────────────────────────────────
|
||||
|
||||
// genStatBlock mirrors plugin.SRDStatBlock — a raw SRD stat block. These values
|
||||
// are NOT engine-tuned: raw SRD damage one-shots gogobee's solo player (see
|
||||
// bestiary_srd.go). The staging table is a balance baseline the hand-authored
|
||||
// dndBestiary / srdProfiles tuning pass reads against, not a drop-in roster.
|
||||
type genStatBlock struct {
|
||||
Slug, Name string
|
||||
Size, Type string
|
||||
CR float64
|
||||
XP int
|
||||
HP, AC int
|
||||
SpeedWalk int
|
||||
STR, DEX int
|
||||
CON, INT int
|
||||
WIS, CHA int
|
||||
Multiattack string
|
||||
Attacks []genStatAttack
|
||||
Traits []string
|
||||
Legendary bool
|
||||
}
|
||||
|
||||
type genStatAttack struct {
|
||||
Name string
|
||||
AttackBonus int
|
||||
DamageDice string
|
||||
DamageBonus int
|
||||
AvgDamage int
|
||||
DamageType string
|
||||
}
|
||||
|
||||
// xpByCR is the SRD experience-by-challenge-rating table. Open5e exposes no XP
|
||||
// field, but the tuning pass wants it, so it is derived here from CR.
|
||||
var xpByCR = map[float64]int{
|
||||
0: 10, 0.125: 25, 0.25: 50, 0.5: 100,
|
||||
1: 200, 2: 450, 3: 700, 4: 1100, 5: 1800, 6: 2300, 7: 2900, 8: 3900,
|
||||
9: 5000, 10: 5900, 11: 7200, 12: 8400, 13: 10000, 14: 11500, 15: 13000,
|
||||
16: 15000, 17: 18000, 18: 20000, 19: 22000, 20: 25000, 21: 33000,
|
||||
22: 41000, 23: 50000, 24: 62000, 25: 75000, 26: 90000, 27: 105000,
|
||||
28: 120000, 29: 135000, 30: 155000,
|
||||
}
|
||||
|
||||
// genBestiary reads the vendored monsters.json, classifies every SRD monster
|
||||
// into a raw staging stat block, and writes the generated Go data file.
|
||||
func genBestiary() error {
|
||||
raw, err := os.ReadFile(monstersJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s (run `fetch bestiary` first?): %w", monstersJSON, err)
|
||||
}
|
||||
var src []open5eMonster
|
||||
if err := json.Unmarshal(raw, &src); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blocks := make([]genStatBlock, 0, len(src))
|
||||
var attacks int
|
||||
for _, m := range src {
|
||||
b := classifyMonster(m)
|
||||
attacks += len(b.Attacks)
|
||||
blocks = append(blocks, b)
|
||||
}
|
||||
sort.Slice(blocks, func(i, j int) bool {
|
||||
if blocks[i].CR != blocks[j].CR {
|
||||
return blocks[i].CR < blocks[j].CR
|
||||
}
|
||||
return blocks[i].Slug < blocks[j].Slug
|
||||
})
|
||||
fmt.Fprintf(os.Stderr, "classified %d monsters (%d parsed attacks)\n", len(blocks), attacks)
|
||||
|
||||
out := emitBestiary(blocks)
|
||||
if err := os.WriteFile(bestiaryGenGo, out, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "wrote", bestiaryGenGo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func classifyMonster(m open5eMonster) genStatBlock {
|
||||
b := genStatBlock{
|
||||
Slug: strings.ReplaceAll(m.Slug, "-", "_"),
|
||||
Name: m.Name,
|
||||
Size: strings.ToLower(strings.TrimSpace(m.Size)),
|
||||
Type: strings.ToLower(strings.TrimSpace(m.Type)),
|
||||
CR: m.CR,
|
||||
XP: xpByCR[m.CR],
|
||||
HP: m.HitPoints,
|
||||
AC: m.ArmorClass,
|
||||
STR: m.Strength, DEX: m.Dexterity, CON: m.Constitution,
|
||||
INT: m.Intelligence, WIS: m.Wisdom, CHA: m.Charisma,
|
||||
SpeedWalk: speedWalk(m.Speed),
|
||||
Legendary: len(m.LegendaryActions) > 0,
|
||||
}
|
||||
for _, a := range m.Actions {
|
||||
if strings.EqualFold(strings.TrimSpace(a.Name), "Multiattack") {
|
||||
b.Multiattack = firstSentence(strings.TrimSpace(a.Desc), 200)
|
||||
continue
|
||||
}
|
||||
if a.DamageDice == "" {
|
||||
continue
|
||||
}
|
||||
atk := genStatAttack{
|
||||
Name: a.Name,
|
||||
AttackBonus: a.AttackBonus,
|
||||
DamageDice: strings.ReplaceAll(a.DamageDice, " ", ""),
|
||||
DamageBonus: a.DamageBonus,
|
||||
}
|
||||
atk.AvgDamage = int(avgDice(atk.DamageDice)) + a.DamageBonus
|
||||
if mt := reDmgType.FindStringSubmatch(strings.ToLower(a.Desc)); mt != nil {
|
||||
atk.DamageType = strings.ToLower(mt[1])
|
||||
}
|
||||
b.Attacks = append(b.Attacks, atk)
|
||||
}
|
||||
for _, sa := range m.SpecialAbilities {
|
||||
if n := strings.TrimSpace(sa.Name); n != "" {
|
||||
b.Traits = append(b.Traits, n)
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// speedWalk pulls the walking speed (in feet) out of Open5e's speed object,
|
||||
// falling back to the fastest other movement mode for creatures with no walk
|
||||
// speed (flyers, swimmers). Returns 0 when nothing parses.
|
||||
func speedWalk(speed map[string]any) int {
|
||||
asInt := func(v any) int {
|
||||
if f, ok := v.(float64); ok {
|
||||
return int(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if w := asInt(speed["walk"]); w > 0 {
|
||||
return w
|
||||
}
|
||||
best := 0
|
||||
for mode, v := range speed {
|
||||
if mode == "walk" {
|
||||
continue
|
||||
}
|
||||
if n := asInt(v); n > best {
|
||||
best = n
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// avgDice returns the statistical average of an NdM dice expression. A bare or
|
||||
// unparseable expression yields 0 — the staging table records what it can and
|
||||
// leaves the rest for the tuning pass.
|
||||
func avgDice(dice string) float64 {
|
||||
i := strings.IndexByte(dice, 'd')
|
||||
if i <= 0 {
|
||||
return 0
|
||||
}
|
||||
n, err1 := strconv.Atoi(dice[:i])
|
||||
m, err2 := strconv.Atoi(dice[i+1:])
|
||||
if err1 != nil || err2 != nil || n <= 0 || m <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(n) * (float64(m) + 1) / 2
|
||||
}
|
||||
|
||||
// ── Code emission ────────────────────────────────────────────────────────────
|
||||
|
||||
func emitBestiary(blocks []genStatBlock) []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString(`// Code generated by cmd/open5e-import. DO NOT EDIT.
|
||||
//
|
||||
// Source: Open5e SRD monster dump (data/open5e/monsters.json), 5e SRD content
|
||||
// under CC-BY-4.0 — see NOTICE. Regenerate with:
|
||||
//
|
||||
// go run ./cmd/open5e-import fetch bestiary
|
||||
// go run ./cmd/open5e-import gen bestiary
|
||||
//
|
||||
// This is the RAW SRD staging table — see bestiary_srd_staging.go for why these
|
||||
// values are a balance baseline and not a drop-in engine roster.
|
||||
|
||||
package plugin
|
||||
|
||||
func buildSRDStagingBestiary() map[string]SRDStatBlock {
|
||||
return map[string]SRDStatBlock{
|
||||
`)
|
||||
for _, m := range blocks {
|
||||
fmt.Fprintf(&b, "\t\t%q: {Slug: %q, Name: %q", m.Slug, m.Slug, m.Name)
|
||||
if m.Size != "" {
|
||||
fmt.Fprintf(&b, ", Size: %q", m.Size)
|
||||
}
|
||||
if m.Type != "" {
|
||||
fmt.Fprintf(&b, ", Type: %q", m.Type)
|
||||
}
|
||||
fmt.Fprintf(&b, ", CR: %s", strconv.FormatFloat(m.CR, 'g', -1, 64))
|
||||
if m.XP != 0 {
|
||||
fmt.Fprintf(&b, ", XP: %d", m.XP)
|
||||
}
|
||||
fmt.Fprintf(&b, ", HP: %d, AC: %d", m.HP, m.AC)
|
||||
if m.SpeedWalk != 0 {
|
||||
fmt.Fprintf(&b, ", SpeedWalk: %d", m.SpeedWalk)
|
||||
}
|
||||
fmt.Fprintf(&b, ", STR: %d, DEX: %d, CON: %d, INT: %d, WIS: %d, CHA: %d",
|
||||
m.STR, m.DEX, m.CON, m.INT, m.WIS, m.CHA)
|
||||
if m.Multiattack != "" {
|
||||
fmt.Fprintf(&b, ", Multiattack: %q", m.Multiattack)
|
||||
}
|
||||
if len(m.Attacks) > 0 {
|
||||
b.WriteString(", Attacks: []SRDStatAttack{")
|
||||
for i, a := range m.Attacks {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
fmt.Fprintf(&b, "{Name: %q, AttackBonus: %d, DamageDice: %q, DamageBonus: %d, AvgDamage: %d",
|
||||
a.Name, a.AttackBonus, a.DamageDice, a.DamageBonus, a.AvgDamage)
|
||||
if a.DamageType != "" {
|
||||
fmt.Fprintf(&b, ", DamageType: %q", a.DamageType)
|
||||
}
|
||||
b.WriteString("}")
|
||||
}
|
||||
b.WriteString("}")
|
||||
}
|
||||
if len(m.Traits) > 0 {
|
||||
b.WriteString(", Traits: []string{")
|
||||
for i, t := range m.Traits {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
fmt.Fprintf(&b, "%q", t)
|
||||
}
|
||||
b.WriteString("}")
|
||||
}
|
||||
if m.Legendary {
|
||||
b.WriteString(", Legendary: true")
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("\t}\n}\n")
|
||||
return b.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Description-cleaning hooks shared by the spell and magic-item generators.
|
||||
//
|
||||
// The Open5e dump descriptions are SRD prose pulled verbatim — they leak
|
||||
// "within range," "WIS saving throw," "your Constitution score is 19," and
|
||||
// other rules-text the game now hides. This file handles two layers:
|
||||
//
|
||||
// - A sanitizer (cleanDesc) strips the worst jargon clauses from the auto
|
||||
// first-sentence and tidies the result. Used as the default path.
|
||||
// - Per-ID override maps win outright when a description needs more than
|
||||
// mechanical scrubbing (e.g. Amulet of Health's "score is 19," all of
|
||||
// the Mage default-list cantrips). Hand-author here, not in the
|
||||
// generated file — these survive a regen.
|
||||
|
||||
// spellDescOverride wins over the auto-derived first-sentence Description
|
||||
// when the slug matches. Cover anything that surfaces in the player's
|
||||
// spellbook listing (defaultKnownSpells) or the !spells available command.
|
||||
//
|
||||
// Tone target: outcome-first, second person, plain English. No DC, no slot
|
||||
// jargon, no "within range." Match the voice already in dnd_spells_data.go.
|
||||
var spellDescOverride = map[string]string{
|
||||
// SRD-only spells that appear in defaultKnownSpells (no hand-authored
|
||||
// overlay in dnd_spells_data.go). Sourced from the S3 surfaced-spells
|
||||
// audit; each gives the player a one-line read of what it does, with
|
||||
// just enough bite that the spellbook isn't a phone book.
|
||||
"call_lightning": "Summon a sulking thundercloud, then ask it — politely — to drop a bolt on someone you dislike.",
|
||||
"charm_person": "Sweet-talk a humanoid into treating you as their new favourite acquaintance. Wears off; they may notice.",
|
||||
"conjure_animals": "Call up a posse of summoned beasts. They are very enthusiastic and only mostly trained.",
|
||||
"eldritch_blast": "A beam of crackling otherworldly Whatever streaks at a target. Your patron's preferred greeting.",
|
||||
"entangle": "The ground sprouts rude, grabby roots in a patch. Anyone caught looks undignified at best.",
|
||||
"faerie_fire": "Outline targets in glittery light. They can't hide; you hit them more easily; they hate it.",
|
||||
"fear": "Project a cone of pure dread. Brave folk turn and run; less brave folk were already running.",
|
||||
"flaming_sphere": "A rolling ball of fire shows up and goes wherever you point. Carpet not included.",
|
||||
"haste": "An ally surges with speed — extra ground, sharper defences, an extra swing. Brief, glorious, exhausting.",
|
||||
"healing_word": "Bark a healing syllable across the battlefield. An ally finds themselves slightly less dead.",
|
||||
"heat_metal": "Their weapon, their armour, their belt buckle — glowing red-hot. Hard to grip. Worse to wear.",
|
||||
"heroism": "An ally takes courage. No fear, more grit, slightly insufferable battle cries.",
|
||||
"hideous_laughter": "Target collapses in a fit of unstoppable giggling. Awkward for them, very funny for you.",
|
||||
"insect_plague": "A swarm of biting locusts boils up in an area. Everyone inside has Opinions about it.",
|
||||
"invisibility": "An ally fades from sight. Lasts until they attack, cast, or yell something dramatic.",
|
||||
"produce_flame": "A flame springs to your palm. Use it as a torch, or sling it at someone. Multipurpose.",
|
||||
"ray_of_frost": "A freezing beam catches a target and leaves them sluggish, frosted, and visibly cross.",
|
||||
"vampiric_touch": "Drain life from a target. The damage you deal patches you up. Frowned upon at parties.",
|
||||
"vicious_mockery": "Hurl an insult so cutting it physically hurts. Their next swing wobbles in shame.",
|
||||
|
||||
// Hand-authored overlay descriptions in dnd_spells_data.go are the
|
||||
// canonical text and win at registry merge time, so they don't need
|
||||
// entries here. If a spell ever moves out of buildSpellList(), add it
|
||||
// above.
|
||||
}
|
||||
|
||||
// magicItemDescOverride wins over the auto-derived Desc when the slug
|
||||
// matches. Priority targets: items the plan called out by name (Amulet of
|
||||
// Health, Belt of Giant Strength) and any item whose SRD blurb leaks rules
|
||||
// math or names a spell the player can't cast.
|
||||
var magicItemDescOverride = map[string]string{
|
||||
"amulet_of_health": "Hums against your collarbone and quietly upgrades your constitution to 'bothersome to kill.'",
|
||||
"belt_of_giant_strength_hill": "Buckle it on; suddenly you can deadlift the bartender. Hill-giant grade.",
|
||||
"belt_of_giant_strength_stone": "Buckle it on; the floor creaks where you weren't standing yesterday. Stone-giant grade.",
|
||||
"belt_of_giant_strength_frost": "Buckle it on; doors come off their hinges when you knock politely. Frost-giant grade.",
|
||||
"belt_of_giant_strength_fire": "Buckle it on; furniture rearranges itself to be elsewhere. Fire-giant grade.",
|
||||
"belt_of_giant_strength_cloud": "Buckle it on; you arm-wrestle siege engines for fun. Cloud-giant grade.",
|
||||
"belt_of_giant_strength_storm": "Buckle it on; the world feels strangely flimsy. Storm-giant grade.",
|
||||
"belt_of_hill_giant_strength": "Buckle it on; suddenly you can deadlift the bartender. Hill-giant grade.",
|
||||
"belt_of_stone_giant_strength": "Buckle it on; the floor creaks where you weren't standing yesterday. Stone-giant grade.",
|
||||
"belt_of_frost_giant_strength": "Buckle it on; doors come off their hinges when you knock politely. Frost-giant grade.",
|
||||
"belt_of_fire_giant_strength": "Buckle it on; furniture rearranges itself to be elsewhere. Fire-giant grade.",
|
||||
"belt_of_cloud_giant_strength": "Buckle it on; you arm-wrestle siege engines for fun. Cloud-giant grade.",
|
||||
"belt_of_storm_giant_strength": "Buckle it on; the world feels strangely flimsy. Storm-giant grade.",
|
||||
"belt_of_dwarvenkind": "A stout dwarven belt — beard not included, hardiness very much included.",
|
||||
"boots_of_levitation": "Float upward at will. Down is a separate problem you'll figure out later.",
|
||||
"boots_of_speed": "Click the heels together; the world starts moving at a polite walking pace.",
|
||||
"boots_of_striding_and_springing": "Tireless stride and effortless hops. Ceilings: now optional.",
|
||||
"boots_of_the_winterlands": "Snow doesn't slow you, cold doesn't bite, and your tracks tactfully erase themselves.",
|
||||
"bracers_of_archery": "Steady your bow arm — your arrows hit harder and find seams in armour they shouldn't.",
|
||||
"bracers_of_defense": "Light bracers that quietly turn aside blows. No armour required, no questions asked.",
|
||||
"brooch_of_shielding": "Pinned to your cloak. Eats incoming bolts of force like they're hors d'oeuvres.",
|
||||
"cloak_of_displacement": "Bends light around you. Attackers keep swinging at where you obviously are, and missing.",
|
||||
"cloak_of_elvenkind": "Pulls shadows around you whether you asked or not. Watchful eyes slide right off.",
|
||||
"cloak_of_protection": "A perfectly ordinary travelling cloak that perfectly extraordinarily refuses to let you get hit.",
|
||||
"cloak_of_resistance": "Wards off one kind of harm — fire, frost, lightning, poison, or some other unpleasant Tuesday.",
|
||||
"cloak_of_the_bat": "By night you glide instead of fall; on a really good night, you fly. Daytime: just a cloak.",
|
||||
"cloak_of_the_manta_ray": "Underwater, you swim like a manta and breathe like you're on land. On land, you look damp.",
|
||||
"gauntlets_of_ogre_power": "Heavy gauntlets that lend you an ogre's strength and an ogre's complete disregard for door frames.",
|
||||
"headband_of_intellect": "Slips on, makes you think clever thoughts you have no business thinking. People notice.",
|
||||
"ring_of_protection": "A plain ring. Quietly turns aside blows you'd otherwise take. Refuses to explain how.",
|
||||
"ring_of_regeneration": "Wounds knit themselves back together while you wear it. Slowly. Smugly.",
|
||||
"ring_of_resistance": "Wards off one kind of harm — fire, frost, lightning, poison, or another flavour of bad day.",
|
||||
"ring_of_swimming": "Slip through water faster than any swimmer should. Dolphins file complaints.",
|
||||
"ring_of_warmth": "Cold weather no longer applies to you. Chill damage softens; you become unbearable in winter.",
|
||||
"slippers_of_spider_climbing": "Walk up walls and across ceilings as if the floor were merely a suggestion.",
|
||||
}
|
||||
|
||||
// cleanDesc strips the worst SRD jargon from a description before it lands in
|
||||
// the generated file. It is intentionally conservative: drop offending phrases,
|
||||
// collapse the resulting whitespace, leave the rest of the sentence alone. Any
|
||||
// description that still reads poorly after this pass should get a hand-authored
|
||||
// entry in spellDescOverride / magicItemDescOverride above.
|
||||
func cleanDesc(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
for _, pat := range descScrub {
|
||||
s = pat.ReplaceAllString(s, "")
|
||||
}
|
||||
// Collapse runs of whitespace and tidy stray punctuation orphans left
|
||||
// behind by deletions ("a creature and ", " , ", " .").
|
||||
s = reSpaces.ReplaceAllString(s, " ")
|
||||
s = strings.ReplaceAll(s, " ,", ",")
|
||||
s = strings.ReplaceAll(s, " .", ".")
|
||||
s = strings.ReplaceAll(s, "( )", "")
|
||||
// Repair sentence stubs left by clause-strippers — "...must make." after
|
||||
// the saving-throw object is gone, or trailing " and." where one half of
|
||||
// a coordinated noun phrase was removed. These are conservative: they
|
||||
// only fire when the orphan sits at end-of-sentence.
|
||||
s = reOrphanMustMake.ReplaceAllString(s, "$1")
|
||||
s = reOrphanTrailingAnd.ReplaceAllString(s, ".")
|
||||
s = strings.TrimSpace(s)
|
||||
return s
|
||||
}
|
||||
|
||||
var (
|
||||
reSpaces = regexp.MustCompile(`\s+`)
|
||||
reOrphanMustMake = regexp.MustCompile(`(?i)\s+must\s+(?:make|succeed on|attempt)([.,;])`)
|
||||
reOrphanTrailingAnd = regexp.MustCompile(`(?i),?\s+and\.`)
|
||||
descScrub = []*regexp.Regexp{
|
||||
// Range / distance jargon.
|
||||
regexp.MustCompile(`(?i)\s*\bwithin range\b`),
|
||||
regexp.MustCompile(`(?i)\s*\bwithin \d+ feet(?: of [a-z ]+?)?`),
|
||||
regexp.MustCompile(`(?i)\s+\d+-foot[- ](?:radius|cone|cube|line|sphere|square)`),
|
||||
regexp.MustCompile(`(?i)\s+(?:out to a range of\s*)?\d+ feet\b`),
|
||||
regexp.MustCompile(`(?i)\s+out to a range of\b`),
|
||||
// Save / DC / slot jargon.
|
||||
regexp.MustCompile(`(?i)\s*\b(?:Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma)\s+saving throws?\b`),
|
||||
regexp.MustCompile(`(?i)\s*\bsaving throws?\b`),
|
||||
regexp.MustCompile(`(?i)\s*\(save(?:\s+DC\s*\d+)?\)`),
|
||||
regexp.MustCompile(`(?i)\s*\bDC\s*\d+\b`),
|
||||
regexp.MustCompile(`(?i)\s*\b(?:using|cast(?:ing)?(?: it)? with) a spell slot of \d+(?:st|nd|rd|th) level or higher\b`),
|
||||
regexp.MustCompile(`(?i)\s*\bspell slot\b`),
|
||||
// Cross-references and stat-block leakage.
|
||||
regexp.MustCompile(`(?i)\s*\bsee the spell\b`),
|
||||
regexp.MustCompile(`(?i)\s*\b(?:wisdom|strength|dexterity|constitution|intelligence|charisma)\s+save\b`),
|
||||
regexp.MustCompile(`(?i)\s*\b(?:wisdom|strength|dexterity|constitution|intelligence|charisma)\s+score\s+is\s+\d+\b`),
|
||||
regexp.MustCompile(`(?i)\s*\bconstitution score\b`),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCleanDescStripsJargon — the S3 acceptance criteria translated into
|
||||
// regression cases. Any phrase listed here must not survive the sanitizer.
|
||||
func TestCleanDescStripsJargon(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
mustNot []string
|
||||
}{
|
||||
{
|
||||
name: "saving throw + DC",
|
||||
in: "Each creature in the area must make a Dexterity saving throw (DC 15) or take damage.",
|
||||
mustNot: []string{
|
||||
"saving throw", "DC 15", "DC15",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "saving throws plural",
|
||||
in: "You have advantage on saving throws against spells.",
|
||||
mustNot: []string{"saving throws"},
|
||||
},
|
||||
{
|
||||
name: "within range",
|
||||
in: "Hurl a bolt at a creature within range.",
|
||||
mustNot: []string{"within range"},
|
||||
},
|
||||
{
|
||||
name: "5-foot radius",
|
||||
in: "Each creature in a 5-foot radius takes damage.",
|
||||
mustNot: []string{"5-foot"},
|
||||
},
|
||||
{
|
||||
name: "spell slot upcast clause",
|
||||
in: "When you cast this spell using a spell slot of 2nd level or higher, the damage increases.",
|
||||
mustNot: []string{"spell slot"},
|
||||
},
|
||||
{
|
||||
name: "constitution score",
|
||||
in: "Your Constitution score is 19 while you wear this amulet.",
|
||||
mustNot: []string{"Constitution score"},
|
||||
},
|
||||
{
|
||||
name: "(save DC X) parenthetical",
|
||||
in: "You can use an action to cast the detect thoughts spell (save DC 13) from it.",
|
||||
mustNot: []string{"(save", "DC 13"},
|
||||
},
|
||||
{
|
||||
name: "wisdom save",
|
||||
in: "Forces a wisdom save against the caster.",
|
||||
mustNot: []string{"wisdom save"},
|
||||
},
|
||||
{
|
||||
name: "out to a range of feet",
|
||||
in: "You have darkvision out to a range of 60 feet.",
|
||||
mustNot: []string{"feet", "out to a range"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := cleanDesc(tc.in)
|
||||
low := strings.ToLower(got)
|
||||
for _, ban := range tc.mustNot {
|
||||
if strings.Contains(low, strings.ToLower(ban)) {
|
||||
t.Errorf("cleanDesc kept banned phrase %q\n in: %q\n out: %q", ban, tc.in, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanDescRepairsOrphans — the strippers leave dangling stubs ("must
|
||||
// make.", " and."); the post-pass is meant to tidy them so player-facing
|
||||
// prose doesn't end mid-clause.
|
||||
func TestCleanDescRepairsOrphans(t *testing.T) {
|
||||
in := "You gain a +1 bonus to ability checks and saving throws."
|
||||
got := cleanDesc(in)
|
||||
if strings.HasSuffix(got, " and.") {
|
||||
t.Errorf("orphan trailing 'and.' survived: %q", got)
|
||||
}
|
||||
in2 := "Up to three creatures of your choice that you can see must make charisma saving throws."
|
||||
got2 := cleanDesc(in2)
|
||||
if strings.Contains(strings.ToLower(got2), "must make.") {
|
||||
t.Errorf("orphan 'must make.' survived: %q", got2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripNameParenthetical — bestiary R21 / magic-item alias names.
|
||||
func TestStripNameParenthetical(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Giant Rat (Diseased)": "Giant Rat",
|
||||
"Deep Gnome (Svirfneblin)": "Deep Gnome",
|
||||
"Stone of Good Luck (Luckstone)": "Stone of Good Luck",
|
||||
"Ordinary Name": "Ordinary Name",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := stripNameParenthetical(in); got != want {
|
||||
t.Errorf("stripNameParenthetical(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpellOverrideWins — the curated descriptions for the SRD-only default
|
||||
// spells must beat the auto first-sentence path.
|
||||
func TestSpellOverrideWins(t *testing.T) {
|
||||
got := spellDescription("vicious_mockery", "anything could go here")
|
||||
if got != spellDescOverride["vicious_mockery"] {
|
||||
t.Errorf("override didn't win: got %q", got)
|
||||
}
|
||||
// Unknown ID falls through to cleanDesc.
|
||||
in := "Hurl a bolt at a creature within range."
|
||||
got = spellDescription("unknown_spell_xyz", in)
|
||||
if strings.Contains(got, "within range") {
|
||||
t.Errorf("fallthrough didn't sanitize: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// classEnum maps an Open5e spell_lists entry to the plugin's DnDClass enum
|
||||
// identifier. Open5e's "wizard" is the plugin's Mage; classes the plugin
|
||||
// doesn't model (artificer, etc.) are absent and silently dropped.
|
||||
//
|
||||
// classOrder fixes the iteration order so generated Classes slices are stable.
|
||||
var (
|
||||
classEnum = map[string]string{
|
||||
"wizard": "ClassMage",
|
||||
"cleric": "ClassCleric",
|
||||
"ranger": "ClassRanger",
|
||||
"druid": "ClassDruid",
|
||||
"bard": "ClassBard",
|
||||
"sorcerer": "ClassSorcerer",
|
||||
"warlock": "ClassWarlock",
|
||||
"paladin": "ClassPaladin",
|
||||
}
|
||||
classOrder = []string{"wizard", "cleric", "ranger", "druid", "bard", "sorcerer", "warlock", "paladin"}
|
||||
)
|
||||
|
||||
// mapClasses turns an Open5e spell's class tags into plugin enum identifiers,
|
||||
// deduped and in classOrder. It unions the structured spell_lists array with
|
||||
// the free-text dnd_class field ("Druid, Wizard") — the SRD dump's spell_lists
|
||||
// omits paladin entirely, so dnd_class is the more complete source. Returns
|
||||
// nil when the spell belongs to no modelled class (the caller drops those —
|
||||
// they have no caster to learn them).
|
||||
func mapClasses(lists []string, dndClass string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, l := range lists {
|
||||
seen[strings.ToLower(strings.TrimSpace(l))] = true
|
||||
}
|
||||
for _, l := range strings.Split(dndClass, ",") {
|
||||
seen[strings.ToLower(strings.TrimSpace(l))] = true
|
||||
}
|
||||
var out []string
|
||||
for _, c := range classOrder {
|
||||
if seen[c] {
|
||||
out = append(out, classEnum[c])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// genSpell is a fully classified spell ready for code emission. Fields mirror
|
||||
// plugin.SpellDefinition; enum-typed fields hold the Go identifier as a string.
|
||||
type genSpell struct {
|
||||
ID string
|
||||
Name string
|
||||
Level int
|
||||
School string
|
||||
Classes []string
|
||||
Effect string
|
||||
CastTime string
|
||||
Concentration bool
|
||||
SaveStat string
|
||||
AttackRoll bool
|
||||
DamageDice string
|
||||
DamageType string
|
||||
Description string
|
||||
Upcast string
|
||||
MaterialCost int
|
||||
AOE bool
|
||||
}
|
||||
|
||||
// genSpells reads the vendored spells.json, classifies every in-scope spell,
|
||||
// and writes the generated Go data file.
|
||||
func genSpells() error {
|
||||
raw, err := os.ReadFile(spellsJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s (run `fetch spells` first?): %w", spellsJSON, err)
|
||||
}
|
||||
var src []open5eSpell
|
||||
if err := json.Unmarshal(raw, &src); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var spells []genSpell
|
||||
var skippedLevel, skippedNoClass int
|
||||
for _, s := range src {
|
||||
if s.LevelInt > 5 {
|
||||
// Combat caps at L5 spell slots — nothing can ever cast these.
|
||||
skippedLevel++
|
||||
continue
|
||||
}
|
||||
classes := mapClasses(s.SpellLists, s.DndClass)
|
||||
if len(classes) == 0 {
|
||||
skippedNoClass++
|
||||
continue
|
||||
}
|
||||
spells = append(spells, classify(s, classes))
|
||||
}
|
||||
sort.Slice(spells, func(i, j int) bool {
|
||||
if spells[i].Level != spells[j].Level {
|
||||
return spells[i].Level < spells[j].Level
|
||||
}
|
||||
return spells[i].ID < spells[j].ID
|
||||
})
|
||||
fmt.Fprintf(os.Stderr, "classified %d spells (skipped %d level>5, %d no modelled class)\n",
|
||||
len(spells), skippedLevel, skippedNoClass)
|
||||
|
||||
out := emit(spells)
|
||||
if err := os.WriteFile(spellsGenGo, out, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "wrote", spellsGenGo)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Classifier ───────────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
reSave = regexp.MustCompile(`(Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma) saving throw`)
|
||||
reDice = regexp.MustCompile(`\d+d\d+(?:\s*\+\s*\d+)?`)
|
||||
reDmgType = regexp.MustCompile(`(?i)(acid|bludgeoning|cold|fire|force|lightning|necrotic|piercing|poison|psychic|radiant|slashing|thunder)\s+damage`)
|
||||
reMaterial = regexp.MustCompile(`(?i)worth (?:at least )?([\d,]+)\s*gp`)
|
||||
saveStatMap = map[string]string{
|
||||
"Strength": "STR", "Dexterity": "DEX", "Constitution": "CON",
|
||||
"Intelligence": "INT", "Wisdom": "WIS", "Charisma": "CHA",
|
||||
}
|
||||
)
|
||||
|
||||
func classify(s open5eSpell, classes []string) genSpell {
|
||||
desc := strings.TrimSpace(s.Desc)
|
||||
low := strings.ToLower(desc)
|
||||
|
||||
g := genSpell{
|
||||
ID: strings.ReplaceAll(s.Slug, "-", "_"),
|
||||
Name: s.Name,
|
||||
Level: s.LevelInt,
|
||||
School: strings.ToLower(strings.TrimSpace(s.School)),
|
||||
Classes: classes,
|
||||
Concentration: s.RequiresConcentration,
|
||||
Description: spellDescription(strings.ReplaceAll(s.Slug, "-", "_"), desc),
|
||||
Upcast: firstSentence(strings.TrimSpace(s.HigherLevel), 200),
|
||||
AOE: looksAOE(low),
|
||||
}
|
||||
|
||||
// Cast time. Reaction/bonus action win over the ritual flag.
|
||||
ct := strings.ToLower(s.CastingTime)
|
||||
switch {
|
||||
case strings.Contains(ct, "bonus action"):
|
||||
g.CastTime = "CastBonusAction"
|
||||
case strings.Contains(ct, "reaction"):
|
||||
g.CastTime = "CastReaction"
|
||||
case s.CanBeCastAsRitual:
|
||||
g.CastTime = "CastRitual"
|
||||
default:
|
||||
g.CastTime = "CastAction"
|
||||
}
|
||||
|
||||
if m := reSave.FindStringSubmatch(desc); m != nil {
|
||||
g.SaveStat = saveStatMap[m[1]]
|
||||
}
|
||||
g.AttackRoll = strings.Contains(low, "spell attack")
|
||||
if m := reDice.FindString(desc); m != "" {
|
||||
g.DamageDice = strings.ReplaceAll(m, " ", "")
|
||||
}
|
||||
if m := reDmgType.FindStringSubmatch(low); m != nil {
|
||||
g.DamageType = strings.ToLower(m[1])
|
||||
}
|
||||
if m := reMaterial.FindStringSubmatch(s.Material); m != nil {
|
||||
g.MaterialCost, _ = strconv.Atoi(strings.ReplaceAll(m[1], ",", ""))
|
||||
}
|
||||
|
||||
g.Effect = classifyEffect(g, low)
|
||||
|
||||
// A heal spell's dice describe healing, not damage — keep DamageDice (the
|
||||
// hand-authored list does the same) but drop a stray damage type.
|
||||
if g.Effect == "EffectSpellHeal" {
|
||||
g.DamageType = ""
|
||||
}
|
||||
// Non-damaging effects shouldn't carry a damage type/dice picked up from
|
||||
// incidental wording.
|
||||
if g.Effect == "EffectControl" || g.Effect == "EffectUtility" ||
|
||||
g.Effect == "EffectReaction" {
|
||||
g.DamageDice, g.DamageType = "", ""
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// classifyEffect picks the SpellEffectKind. Heuristic and imperfect by design —
|
||||
// the hand-authored buildSpellList() overlay is the refinement path, so this
|
||||
// only needs to be a reasonable default.
|
||||
func classifyEffect(g genSpell, low string) string {
|
||||
if g.CastTime == "CastReaction" {
|
||||
return "EffectReaction"
|
||||
}
|
||||
isHeal := strings.Contains(low, "regain") && strings.Contains(low, "hit point") &&
|
||||
!strings.Contains(low, "damage")
|
||||
if isHeal {
|
||||
return "EffectSpellHeal"
|
||||
}
|
||||
if g.DamageDice != "" && g.DamageType != "" {
|
||||
switch {
|
||||
case g.AttackRoll:
|
||||
return "EffectDamageAttack"
|
||||
case g.SaveStat != "":
|
||||
return "EffectDamageSave"
|
||||
default:
|
||||
return "EffectDamageAuto"
|
||||
}
|
||||
}
|
||||
if g.SaveStat != "" {
|
||||
return "EffectControl"
|
||||
}
|
||||
// Light buff detection — anything still unclassified falls through to
|
||||
// utility, which is the safe bucket.
|
||||
if strings.Contains(low, "bonus to") || strings.Contains(low, "you gain") ||
|
||||
strings.Contains(low, "gains a bonus") || strings.Contains(low, "your ac") {
|
||||
if strings.Contains(low, "you gain") || strings.Contains(low, "yourself") ||
|
||||
strings.Contains(low, "your ac") {
|
||||
return "EffectBuffSelf"
|
||||
}
|
||||
return "EffectBuffAlly"
|
||||
}
|
||||
return "EffectUtility"
|
||||
}
|
||||
|
||||
func looksAOE(low string) bool {
|
||||
for _, kw := range []string{
|
||||
"each creature", "all creatures", "-foot radius", "-foot-radius",
|
||||
"foot cone", "foot cube", "foot line", "foot-radius sphere",
|
||||
} {
|
||||
if strings.Contains(low, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// spellDescription returns the description text emitted for a spell. A
|
||||
// hand-authored override in spellDescOverride wins outright; otherwise the
|
||||
// SRD first-sentence is run through cleanDesc to strip jargon clauses.
|
||||
func spellDescription(id, raw string) string {
|
||||
if s, ok := spellDescOverride[id]; ok {
|
||||
return s
|
||||
}
|
||||
return firstSentence(cleanDesc(raw), 200)
|
||||
}
|
||||
|
||||
// firstSentence trims free-text fields down to a single sentence, capped at
|
||||
// max runes, so generated Description/Upcast stay terminal-friendly.
|
||||
func firstSentence(s string, max int) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.IndexAny(s, "\n"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if i := strings.Index(s, ". "); i >= 0 {
|
||||
s = s[:i+1]
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > max {
|
||||
s = strings.TrimSpace(s[:max]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ── Code emission ────────────────────────────────────────────────────────────
|
||||
|
||||
func emit(spells []genSpell) []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString(`// Code generated by cmd/open5e-import. DO NOT EDIT.
|
||||
//
|
||||
// Source: Open5e SRD spell dump (data/open5e/spells.json), 5e SRD content
|
||||
// under CC-BY-4.0 — see NOTICE. Regenerate with:
|
||||
//
|
||||
// go run ./cmd/open5e-import fetch spells
|
||||
// go run ./cmd/open5e-import gen spells
|
||||
//
|
||||
// Effect/CastTime/SaveStat and friends come from a free-text classifier; the
|
||||
// hand-authored buildSpellList() overlays this slice and wins on ID collision,
|
||||
// so any misclassification here is correctable there rather than in the dump.
|
||||
|
||||
package plugin
|
||||
|
||||
func buildSRDSpellList() []SpellDefinition {
|
||||
return []SpellDefinition{
|
||||
`)
|
||||
for _, s := range spells {
|
||||
b.WriteString("\t\t{")
|
||||
fmt.Fprintf(&b, "ID: %q, Name: %q, Level: %d", s.ID, s.Name, s.Level)
|
||||
if s.School != "" {
|
||||
fmt.Fprintf(&b, ", School: %q", s.School)
|
||||
}
|
||||
b.WriteString(", Classes: []DnDClass{")
|
||||
for i, c := range s.Classes {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
b.WriteString(c)
|
||||
}
|
||||
b.WriteString("}")
|
||||
fmt.Fprintf(&b, ", Effect: %s, CastTime: %s", s.Effect, s.CastTime)
|
||||
if s.Concentration {
|
||||
b.WriteString(", Concentration: true")
|
||||
}
|
||||
if s.SaveStat != "" {
|
||||
fmt.Fprintf(&b, ", SaveStat: %q", s.SaveStat)
|
||||
}
|
||||
if s.AttackRoll {
|
||||
b.WriteString(", AttackRoll: true")
|
||||
}
|
||||
if s.DamageDice != "" {
|
||||
fmt.Fprintf(&b, ", DamageDice: %q", s.DamageDice)
|
||||
}
|
||||
if s.DamageType != "" {
|
||||
fmt.Fprintf(&b, ", DamageType: %q", s.DamageType)
|
||||
}
|
||||
if s.Description != "" {
|
||||
fmt.Fprintf(&b, ", Description: %q", s.Description)
|
||||
}
|
||||
if s.Upcast != "" {
|
||||
fmt.Fprintf(&b, ", Upcast: %q", s.Upcast)
|
||||
}
|
||||
if s.MaterialCost != 0 {
|
||||
fmt.Fprintf(&b, ", MaterialCost: %d", s.MaterialCost)
|
||||
}
|
||||
if s.AOE {
|
||||
b.WriteString(", AOE: true")
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("\t}\n}\n")
|
||||
return b.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// open5eMagicItem is the subset of the Open5e v1 magicitems schema we consume.
|
||||
// The vendored JSON keeps the full payload; unknown fields are ignored here.
|
||||
type open5eMagicItem struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Desc string `json:"desc"`
|
||||
Rarity string `json:"rarity"`
|
||||
RequiresAttunement string `json:"requires_attunement"`
|
||||
}
|
||||
|
||||
type magicItemsPage struct {
|
||||
Next string `json:"next"`
|
||||
Results []open5eMagicItem `json:"results"`
|
||||
}
|
||||
|
||||
// fetchMagicItems pages through the SRD magic-item list and writes the full
|
||||
// result set to data/open5e/magicitems.json, pretty-printed for a reviewable
|
||||
// diff.
|
||||
func fetchMagicItems() error {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
var all []open5eMagicItem
|
||||
url := magicItemsAPIBase
|
||||
for url != "" {
|
||||
fmt.Fprintln(os.Stderr, "GET", url)
|
||||
page, err := getMagicItemsPage(client, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
all = append(all, page.Results...)
|
||||
url = page.Next
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "fetched %d magic items\n", len(all))
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(magicItemsJSON), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := json.MarshalIndent(all, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, '\n')
|
||||
return os.WriteFile(magicItemsJSON, out, 0o644)
|
||||
}
|
||||
|
||||
func getMagicItemsPage(client *http.Client, url string) (*magicItemsPage, error) {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var page magicItemsPage
|
||||
if err := json.Unmarshal(body, &page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &page, nil
|
||||
}
|
||||
|
||||
// ── Classifier ───────────────────────────────────────────────────────────────
|
||||
|
||||
// genMagicItem is a fully classified magic item ready for code emission. Fields
|
||||
// mirror plugin.MagicItem; enum-typed fields hold the Go identifier as a string.
|
||||
type genMagicItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Kind string // plugin.MagicItemKind identifier
|
||||
Rarity string // plugin.DnDRarity identifier
|
||||
Attunement bool
|
||||
Slot string // plugin.DnDSlot identifier; "" = not equippable / ambiguous
|
||||
Value int
|
||||
Desc string
|
||||
}
|
||||
|
||||
// rarityEnum maps an Open5e rarity string to the plugin's DnDRarity identifier.
|
||||
// "artifact" folds into Legendary (the plugin has no artifact tier); "varies"
|
||||
// and unknown/blank values fall back to Common — the conservative bucket.
|
||||
var rarityEnum = map[string]string{
|
||||
"common": "RarityCommon",
|
||||
"uncommon": "RarityUncommon",
|
||||
"rare": "RarityRare",
|
||||
"very rare": "RarityVeryRare",
|
||||
"legendary": "RarityLegendary",
|
||||
"artifact": "RarityLegendary",
|
||||
}
|
||||
|
||||
// rarityValue is the sell-value midpoint per rarity, aligned with the §8.1
|
||||
// rarity brackets the zone-loot tables already use. Keyed by the Open5e rarity
|
||||
// string so the classifier can read it directly.
|
||||
var rarityValue = map[string]int{
|
||||
"common": 50,
|
||||
"uncommon": 150,
|
||||
"rare": 750,
|
||||
"very rare": 2500,
|
||||
"legendary": 8000,
|
||||
"artifact": 8000,
|
||||
}
|
||||
|
||||
// kindByPrefix maps the leading word of Open5e's free-text Type field
|
||||
// ("Wondrous item", "Weapon (any sword)", "Armor (medium or heavy)") to a
|
||||
// plugin.MagicItemKind identifier.
|
||||
var kindByPrefix = map[string]string{
|
||||
"wondrous": "MagicItemWondrous",
|
||||
"weapon": "MagicItemWeapon",
|
||||
"armor": "MagicItemArmor",
|
||||
"ring": "MagicItemRing",
|
||||
"wand": "MagicItemWand",
|
||||
"rod": "MagicItemRod",
|
||||
"staff": "MagicItemStaff",
|
||||
"potion": "MagicItemPotion",
|
||||
"scroll": "MagicItemScroll",
|
||||
}
|
||||
|
||||
// genMagicItems reads the vendored magicitems.json, classifies every SRD magic
|
||||
// item, and writes the generated Go data file.
|
||||
func genMagicItems() error {
|
||||
raw, err := os.ReadFile(magicItemsJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s (run `fetch magicitems` first?): %w", magicItemsJSON, err)
|
||||
}
|
||||
var src []open5eMagicItem
|
||||
if err := json.Unmarshal(raw, &src); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
items := make([]genMagicItem, 0, len(src))
|
||||
var attunement, slotted int
|
||||
for _, m := range src {
|
||||
g := classifyMagicItem(m)
|
||||
if g.Attunement {
|
||||
attunement++
|
||||
}
|
||||
if g.Slot != "" {
|
||||
slotted++
|
||||
}
|
||||
items = append(items, g)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID })
|
||||
fmt.Fprintf(os.Stderr, "classified %d magic items (%d need attunement, %d slotted)\n",
|
||||
len(items), attunement, slotted)
|
||||
|
||||
out := emitMagicItems(items)
|
||||
if err := os.WriteFile(magicItemsGenGo, out, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "wrote", magicItemsGenGo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func classifyMagicItem(m open5eMagicItem) genMagicItem {
|
||||
g := genMagicItem{
|
||||
ID: strings.ReplaceAll(m.Slug, "-", "_"),
|
||||
Name: stripNameParenthetical(m.Name),
|
||||
Attunement: strings.TrimSpace(m.RequiresAttunement) != "",
|
||||
Desc: magicItemDescription(strings.ReplaceAll(m.Slug, "-", "_"), strings.TrimSpace(m.Desc)),
|
||||
}
|
||||
|
||||
rarity := strings.ToLower(strings.TrimSpace(m.Rarity))
|
||||
if enum, ok := rarityEnum[rarity]; ok {
|
||||
g.Rarity = enum
|
||||
} else {
|
||||
g.Rarity = "RarityCommon"
|
||||
}
|
||||
if v, ok := rarityValue[rarity]; ok {
|
||||
g.Value = v
|
||||
} else {
|
||||
g.Value = rarityValue["common"]
|
||||
}
|
||||
|
||||
// Kind: first word of the Type field. Anything unrecognised (the SRD has a
|
||||
// handful of bare or oddly-prefixed entries) falls back to Wondrous.
|
||||
kindWord := ""
|
||||
if fields := strings.Fields(strings.ToLower(m.Type)); len(fields) > 0 {
|
||||
kindWord = fields[0]
|
||||
}
|
||||
if enum, ok := kindByPrefix[kindWord]; ok {
|
||||
g.Kind = enum
|
||||
} else {
|
||||
g.Kind = "MagicItemWondrous"
|
||||
}
|
||||
|
||||
g.Slot = inferSlot(g.Kind, m.Name)
|
||||
return g
|
||||
}
|
||||
|
||||
// inferSlot makes a best-effort guess at the equip slot. Weapons/staves go to
|
||||
// the main hand, wands/rods to the off hand, armor to the chest, rings to a
|
||||
// ring slot. Wondrous items are name-sniffed (cloak, boots, amulet, …) and
|
||||
// left blank when nothing matches. Potions and scrolls are consumables and
|
||||
// never carry a slot.
|
||||
func inferSlot(kind, name string) string {
|
||||
switch kind {
|
||||
case "MagicItemWeapon", "MagicItemStaff":
|
||||
return "DnDSlotMainHand"
|
||||
case "MagicItemWand", "MagicItemRod":
|
||||
return "DnDSlotOffHand"
|
||||
case "MagicItemArmor":
|
||||
return "DnDSlotChest"
|
||||
case "MagicItemRing":
|
||||
return "DnDSlotRing1"
|
||||
case "MagicItemPotion", "MagicItemScroll":
|
||||
return ""
|
||||
}
|
||||
// Wondrous item: sniff the name for a wearable noun.
|
||||
//
|
||||
// Match on word *prefix*, not raw substring — naive Contains() lights up
|
||||
// "ring" inside "Springing" / "Snaring" / "Devouring" and dumps boots,
|
||||
// gloves, and bags into the ring slot.
|
||||
low := strings.ToLower(name)
|
||||
tokens := strings.FieldsFunc(low, func(r rune) bool {
|
||||
return !(r >= 'a' && r <= 'z')
|
||||
})
|
||||
for _, m := range []struct {
|
||||
kw string
|
||||
slot string
|
||||
}{
|
||||
{"amulet", "DnDSlotAmulet"}, {"necklace", "DnDSlotAmulet"},
|
||||
{"medallion", "DnDSlotAmulet"}, {"periapt", "DnDSlotAmulet"},
|
||||
{"brooch", "DnDSlotAmulet"}, {"pendant", "DnDSlotAmulet"},
|
||||
{"talisman", "DnDSlotAmulet"}, {"scarab", "DnDSlotAmulet"},
|
||||
{"ring", "DnDSlotRing1"},
|
||||
// Cloaks/capes/mantles get their own slot so they don't evict body
|
||||
// armor. Robes/vestments are body armor and stay on Chest.
|
||||
{"cloak", "DnDSlotCloak"}, {"cape", "DnDSlotCloak"},
|
||||
{"mantle", "DnDSlotCloak"}, {"wings", "DnDSlotCloak"},
|
||||
{"robe", "DnDSlotChest"}, {"vestments", "DnDSlotChest"},
|
||||
{"armor", "DnDSlotChest"},
|
||||
{"boots", "DnDSlotFeet"}, {"slippers", "DnDSlotFeet"},
|
||||
{"gauntlet", "DnDSlotHands"}, {"gauntlets", "DnDSlotHands"},
|
||||
{"gloves", "DnDSlotHands"}, {"bracers", "DnDSlotHands"},
|
||||
{"helm", "DnDSlotHead"}, {"hat", "DnDSlotHead"},
|
||||
{"crown", "DnDSlotHead"}, {"circlet", "DnDSlotHead"},
|
||||
{"goggles", "DnDSlotHead"}, {"eyes", "DnDSlotHead"},
|
||||
{"headband", "DnDSlotHead"},
|
||||
} {
|
||||
for _, t := range tokens {
|
||||
if strings.HasPrefix(t, m.kw) {
|
||||
return m.slot
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// magicItemDescription returns the Desc text emitted for an item. Override
|
||||
// wins; otherwise the SRD first-sentence runs through cleanDesc.
|
||||
func magicItemDescription(id, raw string) string {
|
||||
if s, ok := magicItemDescOverride[id]; ok {
|
||||
return s
|
||||
}
|
||||
return firstSentence(cleanDesc(raw), 200)
|
||||
}
|
||||
|
||||
// ── Code emission ────────────────────────────────────────────────────────────
|
||||
|
||||
func emitMagicItems(items []genMagicItem) []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString(`// Code generated by cmd/open5e-import. DO NOT EDIT.
|
||||
//
|
||||
// Source: Open5e SRD magic-item dump (data/open5e/magicitems.json), 5e SRD
|
||||
// content under CC-BY-4.0 — see NOTICE. Regenerate with:
|
||||
//
|
||||
// go run ./cmd/open5e-import fetch magicitems
|
||||
// go run ./cmd/open5e-import gen magicitems
|
||||
//
|
||||
// Kind/Rarity/Slot/Value come from a free-text classifier; the hand-authored
|
||||
// magicItemOverlay overlays this slice and wins on ID collision, so any
|
||||
// misclassification here is correctable there rather than in the dump.
|
||||
|
||||
package plugin
|
||||
|
||||
func buildSRDMagicItems() []MagicItem {
|
||||
return []MagicItem{
|
||||
`)
|
||||
for _, m := range items {
|
||||
b.WriteString("\t\t{")
|
||||
fmt.Fprintf(&b, "ID: %q, Name: %q, Kind: %s, Rarity: %s", m.ID, m.Name, m.Kind, m.Rarity)
|
||||
if m.Attunement {
|
||||
b.WriteString(", Attunement: true")
|
||||
}
|
||||
if m.Slot != "" {
|
||||
fmt.Fprintf(&b, ", Slot: %s", m.Slot)
|
||||
}
|
||||
fmt.Fprintf(&b, ", Value: %d", m.Value)
|
||||
if m.Desc != "" {
|
||||
fmt.Fprintf(&b, ", Desc: %q", m.Desc)
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("\t}\n}\n")
|
||||
return b.Bytes()
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Command open5e-import vendors Open5e SRD data (CC-BY-4.0) into the repo and
|
||||
// regenerates the Go data files the plugin compiles against.
|
||||
//
|
||||
// It is a pull-once / use-many tool — the generated files are committed, so
|
||||
// the running bot has no runtime dependency on the Open5e API.
|
||||
//
|
||||
// open5e-import fetch spells # vendor data/open5e/spells.json from the API
|
||||
// open5e-import gen spells # classify → internal/plugin/dnd_spells_srd_data.go
|
||||
// open5e-import fetch bestiary # vendor data/open5e/monsters.json from the API
|
||||
// open5e-import gen bestiary # classify → internal/plugin/bestiary_srd_data.go
|
||||
// open5e-import gen tuned # tuning formula → internal/plugin/bestiary_tuned_data.go
|
||||
// open5e-import fetch magicitems # vendor data/open5e/magicitems.json from the API
|
||||
// open5e-import gen magicitems # classify → internal/plugin/magic_items_srd_data.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
spellsAPIBase = "https://api.open5e.com/v1/spells/?document__slug=wotc-srd&limit=50"
|
||||
spellsJSON = "data/open5e/spells.json"
|
||||
spellsGenGo = "internal/plugin/dnd_spells_srd_data.go"
|
||||
|
||||
monstersAPIBase = "https://api.open5e.com/v1/monsters/?document__slug=wotc-srd&limit=50"
|
||||
monstersJSON = "data/open5e/monsters.json"
|
||||
bestiaryGenGo = "internal/plugin/bestiary_srd_data.go"
|
||||
tunedGenGo = "internal/plugin/bestiary_tuned_data.go"
|
||||
|
||||
magicItemsAPIBase = "https://api.open5e.com/v1/magicitems/?document__slug=wotc-srd&limit=50"
|
||||
magicItemsJSON = "data/open5e/magicitems.json"
|
||||
magicItemsGenGo = "internal/plugin/magic_items_srd_data.go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
usage()
|
||||
}
|
||||
verb, noun := os.Args[1], os.Args[2]
|
||||
switch verb {
|
||||
case "fetch":
|
||||
switch noun {
|
||||
case "spells":
|
||||
must(fetchSpells())
|
||||
case "bestiary":
|
||||
must(fetchMonsters())
|
||||
case "magicitems":
|
||||
must(fetchMagicItems())
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
case "gen":
|
||||
switch noun {
|
||||
case "spells":
|
||||
must(genSpells())
|
||||
case "bestiary":
|
||||
must(genBestiary())
|
||||
case "tuned":
|
||||
must(genTuned())
|
||||
case "magicitems":
|
||||
must(genMagicItems())
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "usage: open5e-import fetch (spells|bestiary|magicitems) | gen (spells|bestiary|tuned|magicitems)")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// open5eSpell is the subset of the Open5e v1 spell schema we consume. Unknown
|
||||
// fields are ignored; the vendored JSON keeps the full payload.
|
||||
type open5eSpell struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Desc string `json:"desc"`
|
||||
HigherLevel string `json:"higher_level"`
|
||||
Range string `json:"range"`
|
||||
Material string `json:"material"`
|
||||
CanBeCastAsRitual bool `json:"can_be_cast_as_ritual"`
|
||||
Duration string `json:"duration"`
|
||||
RequiresConcentration bool `json:"requires_concentration"`
|
||||
CastingTime string `json:"casting_time"`
|
||||
LevelInt int `json:"level_int"`
|
||||
School string `json:"school"`
|
||||
// SpellLists is the structured class list, but the SRD dump leaves it
|
||||
// incomplete (no paladin entries at all). DndClass is the free-text
|
||||
// "Druid, Wizard" field and is the more complete source — mapClasses
|
||||
// unions both. Keep both fields vendored.
|
||||
SpellLists []string `json:"spell_lists"`
|
||||
DndClass string `json:"dnd_class"`
|
||||
}
|
||||
|
||||
type spellsPage struct {
|
||||
Next string `json:"next"`
|
||||
Results []open5eSpell `json:"results"`
|
||||
}
|
||||
|
||||
// fetchSpells pages through the SRD spell list and writes the full result set
|
||||
// to data/open5e/spells.json, pretty-printed for a reviewable diff.
|
||||
func fetchSpells() error {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
var all []open5eSpell
|
||||
url := spellsAPIBase
|
||||
for url != "" {
|
||||
fmt.Fprintln(os.Stderr, "GET", url)
|
||||
page, err := getSpellsPage(client, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
all = append(all, page.Results...)
|
||||
url = page.Next
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "fetched %d spells\n", len(all))
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(spellsJSON), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := json.MarshalIndent(all, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, '\n')
|
||||
return os.WriteFile(spellsJSON, out, 0o644)
|
||||
}
|
||||
|
||||
func getSpellsPage(client *http.Client, url string) (*spellsPage, error) {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var page spellsPage
|
||||
if err := json.Unmarshal(body, &page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &page, nil
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// genTuned derives an engine-ready DnDMonsterTemplate for every SRD monster and
|
||||
// writes the generated Go data file. It is the codified "tuning pass": the raw
|
||||
// SRD staging table one-shots gogobee's solo player, so this applies a
|
||||
// deterministic formula to scale each stat block down to the engine's
|
||||
// gameplay-tuned shape.
|
||||
//
|
||||
// Formula (reverse-engineered from the hand-tuned dndBestiary, 2026-05 passes):
|
||||
//
|
||||
// - HP, AC, AttackBonus — verbatim SRD (AC clamped to the engine's min 10).
|
||||
// - Attack — the auto-resolve damage stat, driven by CR alone
|
||||
// via attackByCR (raw SRD per-hit damage is deliberately ignored — CR is
|
||||
// the calibration axis the 2026-05-10 rebalance used).
|
||||
// - Speed, BlockRate — coarse baselines from SpeedWalk / AC; a later hand
|
||||
// pass refines them per creature.
|
||||
// - XP, CR — verbatim from the staging table.
|
||||
// - Ability — classified from the SRD trait names by
|
||||
// abilityFromTraits: the highest-priority recognised trait maps onto one of
|
||||
// the engine's MonsterAbility effects. Creatures whose traits are all
|
||||
// non-combat (Keen Smell, Amphibious, …) stay nil. Notes still records the
|
||||
// raw SRD multiattack/trait text so a hand pass can refine the pick.
|
||||
//
|
||||
// Hand-authored dndBestiary entries WIN: the generated map is merged in only
|
||||
// for IDs the roster doesn't already define (see bestiary_tuned.go).
|
||||
func genTuned() error {
|
||||
raw, err := os.ReadFile(monstersJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s (run `fetch bestiary` first?): %w", monstersJSON, err)
|
||||
}
|
||||
var src []open5eMonster
|
||||
if err := json.Unmarshal(raw, &src); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tuned := make([]genTunedMonster, 0, len(src))
|
||||
for _, m := range src {
|
||||
tuned = append(tuned, tuneMonster(classifyMonster(m)))
|
||||
}
|
||||
sort.Slice(tuned, func(i, j int) bool {
|
||||
if tuned[i].CR != tuned[j].CR {
|
||||
return tuned[i].CR < tuned[j].CR
|
||||
}
|
||||
return tuned[i].ID < tuned[j].ID
|
||||
})
|
||||
fmt.Fprintf(os.Stderr, "tuned %d monsters\n", len(tuned))
|
||||
|
||||
out := emitTuned(tuned)
|
||||
if err := os.WriteFile(tunedGenGo, out, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "wrote", tunedGenGo)
|
||||
return nil
|
||||
}
|
||||
|
||||
// genTunedMonster mirrors plugin.DnDMonsterTemplate. Ability is nil when none
|
||||
// of the creature's SRD traits map onto an engine effect.
|
||||
type genTunedMonster struct {
|
||||
ID, Name string
|
||||
CR float64
|
||||
HP, AC int
|
||||
Attack int
|
||||
AttackBonus int
|
||||
Speed int
|
||||
BlockRate float64
|
||||
XPValue int
|
||||
Ability *genMonsterAbility
|
||||
Notes string
|
||||
FireAttacker bool
|
||||
}
|
||||
|
||||
// genMonsterAbility mirrors plugin.MonsterAbility.
|
||||
type genMonsterAbility struct {
|
||||
Name string
|
||||
Phase string
|
||||
ProcChance float64
|
||||
Effect string
|
||||
}
|
||||
|
||||
// tuneMonster applies the tuning formula to one raw SRD stat block.
|
||||
func tuneMonster(b genStatBlock) genTunedMonster {
|
||||
ac := b.AC
|
||||
if ac < 10 { // engine minimum (see the zombie 8→10 note in dnd_bestiary.go)
|
||||
ac = 10
|
||||
}
|
||||
ability := abilityFromTraits(b.Traits)
|
||||
return genTunedMonster{
|
||||
ID: b.Slug,
|
||||
Name: stripNameParenthetical(b.Name),
|
||||
CR: b.CR,
|
||||
HP: b.HP,
|
||||
AC: ac,
|
||||
Attack: attackByCR(b.CR),
|
||||
AttackBonus: primaryAttackBonus(b.Attacks),
|
||||
Speed: tunedSpeed(b.SpeedWalk),
|
||||
BlockRate: tunedBlockRate(ac),
|
||||
XPValue: b.XP,
|
||||
Ability: ability,
|
||||
Notes: tunedNotes(b, ability),
|
||||
FireAttacker: isFireAttacker(b.Attacks),
|
||||
}
|
||||
}
|
||||
|
||||
// isFireAttacker returns true when a monster's signature damage is fire —
|
||||
// i.e., its highest-average-damage attack deals fire. Threshold at AvgDamage
|
||||
// >= 5 so a trivial fire fleck on an otherwise physical creature doesn't tag
|
||||
// the whole bestiary entry. Tieflings (FireResist) take half damage from the
|
||||
// primary attack of any creature flagged here.
|
||||
func isFireAttacker(attacks []genStatAttack) bool {
|
||||
bestAvg, bestType := 0, ""
|
||||
for _, a := range attacks {
|
||||
if a.AvgDamage > bestAvg {
|
||||
bestAvg = a.AvgDamage
|
||||
bestType = a.DamageType
|
||||
}
|
||||
}
|
||||
return bestType == "fire" && bestAvg >= 5
|
||||
}
|
||||
|
||||
// stripNameParenthetical drops a trailing "(…)" qualifier from a monster
|
||||
// name so the player-facing display reads "Giant Rat" rather than "Giant Rat
|
||||
// (Diseased)". The slug still carries the variant, so the engine keeps both
|
||||
// records distinct — only the display text loses the qualifier.
|
||||
func stripNameParenthetical(name string) string {
|
||||
i := strings.IndexByte(name, '(')
|
||||
if i <= 0 {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSpace(name[:i])
|
||||
}
|
||||
|
||||
// traitAbilityRules maps SRD special-ability names onto the engine's
|
||||
// MonsterAbility effects. Each rule's Match list holds lowercase substrings; a
|
||||
// creature's trait matches a rule if any substring is contained in the
|
||||
// (lowercased, paren-stripped) trait name. The list is priority-ordered —
|
||||
// abilityFromTraits walks it top-down and the first rule that matches any of
|
||||
// the creature's traits wins, so the most combat-defining mechanic is picked
|
||||
// when a stat block carries several traits. Traits that name no rule (Keen
|
||||
// Smell, Amphibious, Hold Breath, telepathy/senses flavor) leave Ability nil.
|
||||
//
|
||||
// This is a best-effort baseline, like the rest of the tuning formula: the
|
||||
// hand-authored dndBestiary entry always wins the merge, so refining a pick is
|
||||
// a matter of adding the creature to the roster.
|
||||
var traitAbilityRules = []struct {
|
||||
match []string
|
||||
name string
|
||||
phase string
|
||||
proc float64
|
||||
effect string
|
||||
}{
|
||||
// Death-triggered blasts — resolve on the killing-blow phase.
|
||||
{[]string{"death burst", "death throes", "elemental demise"}, "Death Burst", "decisive", 1.0, "death_aoe"},
|
||||
// Spellcasters open with a magical barrage.
|
||||
{[]string{"spellcasting"}, "Spellcasting", "opening", 0.5, "aoe"},
|
||||
// Cheats death once.
|
||||
{[]string{"undead fortitude"}, "Undead Fortitude", "decisive", 0.5, "survive_at_1"},
|
||||
// Self-sustain — trolls, liches clawing back.
|
||||
{[]string{"regeneration", "rejuvenation"}, "Regeneration", "any", 0.5, "regenerate"},
|
||||
// Shrugs off spells.
|
||||
{[]string{"magic resistance", "legendary resistance", "magic immunity", "spell immunity"}, "Magic Resistance", "any", 0.5, "spell_resist"},
|
||||
// Ambush predators — a heavy opening strike.
|
||||
{[]string{"surprise attack", "assassinate", "ambusher", "shadow stealth"}, "Surprise Attack", "opening", 0.8, "bonus_damage"},
|
||||
// Fear and petrification lock the player out of acting.
|
||||
{[]string{"petrifying gaze", "fear aura", "horrific appearance"}, "Frightful Presence", "opening", 0.4, "stun"},
|
||||
// Frenzy / rage — damage ramps as the fight drags.
|
||||
{[]string{"blood frenzy", "rampage", "reckless", "aggressive", "berserk", "brute"}, "Frenzy", "any", 0.4, "enrage"},
|
||||
// Damaging auras and spiky hides punish every exchange.
|
||||
{[]string{"heated body", "fire aura", "fire form", "stench", "mucous cloud", "barbed hide", "reflective carapace", "heated weapons", "hellish weapons"}, "Damaging Aura", "any", 0.4, "retaliate"},
|
||||
// Charges, pounces, leaps — an opening lunge.
|
||||
{[]string{"charge", "pounce", "standing leap", "running leap"}, "Charge", "opening", 0.4, "bonus_damage"},
|
||||
// Coordinated attackers gain advantage.
|
||||
{[]string{"pack tactics"}, "Pack Tactics", "any", 0.3, "advantage"},
|
||||
// Riders that pile damage onto a landed hit.
|
||||
{[]string{"sneak attack", "martial advantage"}, "Martial Advantage", "any", 0.35, "bonus_damage"},
|
||||
// Slippery movement — dodges a blow outright.
|
||||
{[]string{"nimble escape", "misty escape", "cunning action", "evasion", "incorporeal movement", "ethereal jaunt", "flyby", "shapechanger"}, "Evasive", "any", 0.3, "evade"},
|
||||
// Enchanted weapons — a flat damage edge.
|
||||
{[]string{"magic weapons", "angelic weapons"}, "Magic Weapons", "any", 0.3, "bonus_damage"},
|
||||
}
|
||||
|
||||
// abilityFromTraits picks the highest-priority MonsterAbility implied by a
|
||||
// creature's SRD trait names, or nil when none of them map onto an engine
|
||||
// effect. See traitAbilityRules for the priority order.
|
||||
func abilityFromTraits(traits []string) *genMonsterAbility {
|
||||
norm := make([]string, len(traits))
|
||||
for i, t := range traits {
|
||||
// Drop the parenthetical suffix SRD appends to recharge-limited
|
||||
// traits ("Legendary Resistance (3/Day)").
|
||||
if p := strings.IndexByte(t, '('); p >= 0 {
|
||||
t = t[:p]
|
||||
}
|
||||
norm[i] = strings.ToLower(strings.TrimSpace(t))
|
||||
}
|
||||
for _, rule := range traitAbilityRules {
|
||||
for _, tr := range norm {
|
||||
for _, m := range rule.match {
|
||||
if strings.Contains(tr, m) {
|
||||
return &genMonsterAbility{
|
||||
Name: rule.name,
|
||||
Phase: rule.phase,
|
||||
ProcChance: rule.proc,
|
||||
Effect: rule.effect,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// attackByCRPoints anchors the Attack-stat curve against the hand-tuned
|
||||
// dndBestiary: each point is an (CR, observed Attack) pair lifted straight from
|
||||
// a playtested roster entry. attackByCR interpolates between anchors and
|
||||
// extrapolates past the top one along the final segment's slope.
|
||||
var attackByCRPoints = []struct {
|
||||
cr float64
|
||||
atk int
|
||||
}{
|
||||
{0, 1}, {0.125, 1}, {0.25, 1}, {0.5, 2}, {1, 2}, {2, 4}, {3, 6}, {4, 7},
|
||||
{5, 8}, {6, 10}, {7, 12}, {8, 13}, {9, 14}, {10, 16}, {11, 18}, {12, 19},
|
||||
{13, 21}, {16, 26}, {19, 31}, {20, 33}, {24, 38},
|
||||
}
|
||||
|
||||
func attackByCR(cr float64) int {
|
||||
pts := attackByCRPoints
|
||||
if cr <= pts[0].cr {
|
||||
return pts[0].atk
|
||||
}
|
||||
last := pts[len(pts)-1]
|
||||
if cr >= last.cr {
|
||||
prev := pts[len(pts)-2]
|
||||
slope := float64(last.atk-prev.atk) / (last.cr - prev.cr)
|
||||
atk := int(math.Round(float64(last.atk) + slope*(cr-last.cr)))
|
||||
if atk < 1 {
|
||||
atk = 1
|
||||
}
|
||||
return atk
|
||||
}
|
||||
for i := 1; i < len(pts); i++ {
|
||||
if cr <= pts[i].cr {
|
||||
lo, hi := pts[i-1], pts[i]
|
||||
t := (cr - lo.cr) / (hi.cr - lo.cr)
|
||||
atk := int(math.Round(float64(lo.atk) + t*float64(hi.atk-lo.atk)))
|
||||
if atk < 1 {
|
||||
atk = 1
|
||||
}
|
||||
return atk
|
||||
}
|
||||
}
|
||||
return last.atk
|
||||
}
|
||||
|
||||
// primaryAttackBonus returns the d20 to-hit modifier of the creature's
|
||||
// hardest-hitting attack — the one the engine's single Attack stat stands in
|
||||
// for. Returns 0 (engine falls back to the tier-scaled bonus) when the stat
|
||||
// block has no parsed weapon attacks.
|
||||
func primaryAttackBonus(attacks []genStatAttack) int {
|
||||
bonus, best := 0, -1
|
||||
for _, a := range attacks {
|
||||
if a.AvgDamage > best {
|
||||
best, bonus = a.AvgDamage, a.AttackBonus
|
||||
}
|
||||
}
|
||||
return bonus
|
||||
}
|
||||
|
||||
// tunedSpeed maps an SRD walk speed (feet) onto the engine's speed scale. The
|
||||
// engine treats speed as an initiative-ordering knob, so a coarse divide-and-
|
||||
// clamp is enough; the ability pass can hand-tune outliers.
|
||||
func tunedSpeed(walk int) int {
|
||||
if walk <= 0 {
|
||||
return 10
|
||||
}
|
||||
s := int(math.Round(float64(walk) / 2.5))
|
||||
if s < 6 {
|
||||
s = 6
|
||||
}
|
||||
if s > 18 {
|
||||
s = 18
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// tunedBlockRate is a baseline damage-halve chance keyed off AC — heavier armor
|
||||
// reads as a better chance to soak a hit. The ability pass overrides this for
|
||||
// creatures whose block is a real mechanic (Parry, shields, etc.).
|
||||
func tunedBlockRate(ac int) float64 {
|
||||
switch {
|
||||
case ac >= 18:
|
||||
return 0.15
|
||||
case ac >= 15:
|
||||
return 0.10
|
||||
case ac >= 13:
|
||||
return 0.05
|
||||
default:
|
||||
return 0.0
|
||||
}
|
||||
}
|
||||
|
||||
// tunedNotes records the SRD multiattack and trait text so a hand-tuning pass
|
||||
// has the source material in front of it, and states which ability (if any)
|
||||
// the trait classifier wired.
|
||||
func tunedNotes(b genStatBlock, ability *genMonsterAbility) string {
|
||||
prefix := "SRD-tuned baseline — no ability wired (traits are non-combat)."
|
||||
if ability != nil {
|
||||
prefix = fmt.Sprintf("SRD-tuned baseline — %s (%s) wired from traits.",
|
||||
ability.Name, ability.Effect)
|
||||
}
|
||||
var parts []string
|
||||
if b.Multiattack != "" {
|
||||
parts = append(parts, "Multiattack: "+b.Multiattack)
|
||||
}
|
||||
if len(b.Traits) > 0 {
|
||||
parts = append(parts, "Traits: "+strings.Join(b.Traits, ", "))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return prefix
|
||||
}
|
||||
return prefix + " " + strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// emitTuned renders the tuned templates as a generated Go source file.
|
||||
func emitTuned(monsters []genTunedMonster) []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString(`// Code generated by cmd/open5e-import. DO NOT EDIT.
|
||||
//
|
||||
// Source: Open5e SRD monster dump (data/open5e/monsters.json), 5e SRD content
|
||||
// under CC-BY-4.0 — see NOTICE. Regenerate with:
|
||||
//
|
||||
// go run ./cmd/open5e-import fetch bestiary
|
||||
// go run ./cmd/open5e-import gen tuned
|
||||
//
|
||||
// Engine-ready DnDMonsterTemplates derived from the raw SRD staging table by
|
||||
// the tuning formula in cmd/open5e-import/tuned.go. Hand-authored dndBestiary
|
||||
// entries take precedence — see bestiary_tuned.go for the merge.
|
||||
|
||||
package plugin
|
||||
|
||||
func buildTunedBestiarySRD() map[string]DnDMonsterTemplate {
|
||||
return map[string]DnDMonsterTemplate{
|
||||
`)
|
||||
for _, m := range monsters {
|
||||
fmt.Fprintf(&b, "\t\t%q: {ID: %q, Name: %q, CR: %s, HP: %d, AC: %d, Attack: %d, AttackBonus: %d, Speed: %d, BlockRate: %s, XPValue: %d, ",
|
||||
m.ID, m.ID, m.Name,
|
||||
strconv.FormatFloat(m.CR, 'g', -1, 32),
|
||||
m.HP, m.AC, m.Attack, m.AttackBonus, m.Speed,
|
||||
strconv.FormatFloat(m.BlockRate, 'g', -1, 64),
|
||||
m.XPValue)
|
||||
if m.Ability != nil {
|
||||
fmt.Fprintf(&b, "Ability: &MonsterAbility{Name: %q, Phase: %q, ProcChance: %s, Effect: %q}, ",
|
||||
m.Ability.Name, m.Ability.Phase,
|
||||
strconv.FormatFloat(m.Ability.ProcChance, 'g', -1, 64),
|
||||
m.Ability.Effect)
|
||||
}
|
||||
fmt.Fprintf(&b, "Notes: %q", m.Notes)
|
||||
if m.FireAttacker {
|
||||
b.WriteString(", FireAttacker: true")
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("\t}\n}\n")
|
||||
return b.Bytes()
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,45 +3,45 @@ module gogobee
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.10.3
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/chehsunliu/poker v0.1.0
|
||||
github.com/expr-lang/expr v1.17.5
|
||||
github.com/expr-lang/expr v1.17.8
|
||||
github.com/fogleman/gg v1.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/olebedev/when v1.1.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
golang.org/x/image v0.36.0
|
||||
maunium.net/go/mautrix v0.26.3
|
||||
modernc.org/sqlite v1.37.1
|
||||
golang.org/x/image v0.40.0
|
||||
maunium.net/go/mautrix v0.28.0
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/AlekSi/pointer v1.0.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.44 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rs/zerolog v1.34.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/rs/zerolog v1.35.1 // indirect
|
||||
github.com/tidwall/gjson v1.19.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
go.mau.fi/util v0.9.6 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
modernc.org/libc v1.65.7 // indirect
|
||||
go.mau.fi/util v0.9.9 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
|
||||
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
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/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/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/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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k=
|
||||
github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
|
||||
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
@@ -28,27 +26,26 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/loganjspears/joker v0.0.0-20180219043703-3f2f69a75914 h1:yAIlIiOkdoJvqd5xtWzM9tNDpLZrFfJdpnNSKha78G8=
|
||||
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.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
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/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/notnil/joker v0.0.0-20180219043703-3f2f69a75914 h1:xXPuFr3PVM4p6Vw3j0CP29oWYRVKO3cPZjR6D7BxggQ=
|
||||
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/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/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -57,9 +54,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
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.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -67,8 +63,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
|
||||
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/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
@@ -77,27 +73,27 @@ github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhso
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
|
||||
go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
|
||||
go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE=
|
||||
go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -107,8 +103,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -116,14 +112,13 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -131,8 +126,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -151,43 +146,47 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk=
|
||||
maunium.net/go/mautrix v0.26.3/go.mod h1:v5ZdDoCwUpNqEj5OrhEoUa3L1kEddKPaAya9TgGXN38=
|
||||
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
|
||||
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ=
|
||||
maunium.net/go/mautrix v0.28.0/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
|
||||
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
|
||||
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# GogoBee — D&D Class Balance Pass
|
||||
|
||||
> **Companion to:** `gogobee_dnd_design_doc_v1.1.md`, `gogobee_spell_system.md`, `gogobee_subclass_system.md`
|
||||
> **Version:** 0.1 (planning draft)
|
||||
> **Status:** Pre-implementation — Phase 0 spike in progress
|
||||
> **Sibling work:** the race-balance pass (`internal/plugin/dnd_race_balance.go`, `TestRaceBalance`) — same spirit, different method (see §1).
|
||||
|
||||
---
|
||||
|
||||
## 0. Why this doc exists
|
||||
|
||||
The race-balance pass tuned the seven races to equal *effective power* via a
|
||||
hand-weighted scoring model. That model is a **proxy** — races don't fight, so
|
||||
there is nothing to measure directly.
|
||||
|
||||
Classes are different: combat is **one-shot auto-resolved** through a single
|
||||
`simulateCombatWithRNG` call (`combat_engine.go:338`), and the RNG is
|
||||
**seedable**. So class balance does not need a proxy model — we can run
|
||||
thousands of real, reproducible fights and read the win rates directly.
|
||||
|
||||
This doc plans that work. Endgame: a `TestClassBalance` regression fixture that
|
||||
holds per-tier win-rate parity across all 10 classes × 30 subclasses, plus the
|
||||
tuning pass that gets us there.
|
||||
|
||||
**Non-goals.** Not a combat-engine redesign. Not adding classes/subclasses.
|
||||
"Balanced" means balanced *in gogobee's one-shot engine* — the model collapses
|
||||
tabletop action economy, and that is accepted.
|
||||
|
||||
---
|
||||
|
||||
## 1. Method — measure, don't model
|
||||
|
||||
| | Race pass | Class pass |
|
||||
|---|---|---|
|
||||
| Power source | ability mods only | full combat: HP/AC, weapons, spells, passives, subclass tiers |
|
||||
| Approach | hand-weighted scoring proxy | Monte Carlo over the real engine |
|
||||
| Trust | heuristic weights (estimated) | empirical win rates (measured) |
|
||||
| Artifact | `dnd_race_balance.go` + `TestRaceBalance` | `dnd_class_balance.go` + `TestClassBalance` |
|
||||
|
||||
---
|
||||
|
||||
## 2. The matrix (full)
|
||||
|
||||
For each of the 10 classes:
|
||||
- `subclass = none` at L1–L4
|
||||
- each of its 5 subclasses at the L5 / L7 / L10 / L15 / L20 tier-unlock checkpoints
|
||||
|
||||
→ ~60 build profiles × level checkpoints × ~6 monster tiers. Each cell = N
|
||||
seeded fights through `simulateCombatWithRNG`. One-shot sims are cheap; compute
|
||||
is not the constraint.
|
||||
|
||||
**Opponents:** the existing per-tier monster scaling curves
|
||||
(`dnd_combat.go:35–51`) — dungeon Tier and arena ThreatLevel — used as a fixed
|
||||
gauntlet.
|
||||
|
||||
**Builds:** standard array assigned by each class's stat priority
|
||||
(`dnd_combat.go:185–218`), Human race (neutral baseline, +1 all), plus a
|
||||
standardized equipment loadout (see §3).
|
||||
|
||||
---
|
||||
|
||||
## 3. The two hard parts (Phase 0 de-risks these)
|
||||
|
||||
1. **Equipment loadout policy.** Combat uses equipment-driven AC and real weapon
|
||||
dice. The per-class, per-level kit must be standardized *fairly* or every
|
||||
downstream number is garbage. Derive from existing starting-gear / level
|
||||
tables if they exist; otherwise define a canonical tiered kit.
|
||||
2. **Spell-selection policy.** Caster damage flows through `applyPendingCast`.
|
||||
Without a "what would a player cast here" heuristic per class/level, all 8
|
||||
casters fight as naked weapon-users and read as broken — a measurement
|
||||
artifact, not real imbalance.
|
||||
|
||||
---
|
||||
|
||||
## 4. Balance rule
|
||||
|
||||
Per-tier **win-rate parity**: at each difficulty tier, every class within a
|
||||
tolerance band of the cross-class mean win rate. Band set empirically after the
|
||||
first full run (the race pass's ±0.5 was set the same way). Damage-dealt,
|
||||
HP-remaining, and near-death-rate are logged as diagnostics, not asserted.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phases (one phase ≈ one session; each ends green + committed)
|
||||
|
||||
- **Phase 0 — spike.** Harness skeleton; equipment + spell-selection policies;
|
||||
run *Fighter vs. Mage only* across tiers and sanity-check plausibility
|
||||
(both win something; casters not at 0%). If the numbers are implausible, fix
|
||||
the policies before trusting anything. **Done — commit 0878b4e.**
|
||||
- **Phase 1 — harness + matrix.** Generalize to all 10 classes × 30 subclasses;
|
||||
`TestClassBalance` logs the full report. No tuning yet — just measurement.
|
||||
**Done.** Subclass field plumbed through the harness, `applySubclassPassives`
|
||||
wired in to match live combat order, `buildPhase1Profiles` produces 190 rows
|
||||
(10 × 4 pre-subclass + 10 × 3 × 5 post-subclass), `TestClassBalance_Phase1_FullMatrix`
|
||||
logs the cells plus per-class tier means and ranges. Phase-2 calibration baseline:
|
||||
at T4 the cross-class spread of *mean* win rate runs Bard 0.62 → Fighter 0.80
|
||||
(~18pp); at T5 0.48 → 0.64 (~16pp); casters trail martials at the post-unlock
|
||||
tier (T3) by ~20pp.
|
||||
- **Phase 2 — tuning pass. Done.** First insight: per-class-mean is dominated
|
||||
by floor+ceiling saturation (L10+ pinned at 1.0; L1-4 caster cells at high
|
||||
tier pinned at 0.0), masking the actual gaps. The matrix log now also
|
||||
emits a per-(level, tier) cross-class spread — the cells with real signal.
|
||||
Tuned levers, in priority order:
|
||||
- **Class passives** (`dnd_passives.go`): caster trailers (Bard, Mage,
|
||||
Warlock, Sorcerer) gained level + casting-stat scaled FlatDmgStart bursts
|
||||
so their L1-4 chassis isn't a quarterstaff + one weak spell against a T2-T3
|
||||
monster; small DamageBonus riders (Mage/Bard/Sorcerer/Rogue +5%, Warlock
|
||||
10→12%) and +1 attack for Bard/Warlock close the steady-DPS gap. Added a
|
||||
`clampNonNeg` helper so ability-mod-scaled additions never go negative on
|
||||
sheets with sub-10 casting stats.
|
||||
- **Subclass L5 tiers** (`dnd_subclass_combat.go`): the three Sorcerer
|
||||
L5 picks (Wild/Storm/Draconic) and Warlock Great Old One were defense-only
|
||||
or near-inert pre-tune; each gained a DamageBonus +0.10 (or FlatDmgStart
|
||||
burst for Storm) so the L5 chassis can press through a T4 monster.
|
||||
|
||||
**Parity band locked** in `TestClassBalance_Phase1_FullMatrix`: cross-class
|
||||
spread ≤ 35pp on the *in-tier* diagonal — the (level, tier) cells where the
|
||||
level is appropriate for the tier (T1: L1-4, T2: L3-7, T3: L5-10, T4: L7-15,
|
||||
T5: L10-20). Off-tier cells (e.g. L1 mage at T3 dungeon) are still logged but
|
||||
not asserted: those are level-vs-tier mismatches, and casters at L1-4 can't
|
||||
muscle through a T3 monster on a single L1-slot spell the way martials muscle
|
||||
through with weapon dice. Observed worst in-tier cell after tuning: L3/T2 at
|
||||
~26pp, L1/T1 at ~24pp, L5/T3 at ~20pp; the 35pp band gives ~9pp Monte-Carlo
|
||||
headroom at 200 trials/cell. **← current**
|
||||
- **Phase 3 — off-tier caster lift. Done.** Design steer ("relatively easy
|
||||
but not too easy") narrowed the target: lift the embarrassing caster
|
||||
trailers on off-tier cells (the casual player walking into a slightly
|
||||
too-hard dungeon underleveled) *without* pushing the already-saturated
|
||||
in-tier ceiling. Tuned levers:
|
||||
- **Druid passive** (`dnd_passives.go`): the only class chassis with a
|
||||
purely defensive passive (5% DR, no offense). Added a level + WIS-scaled
|
||||
`FlatDmgStart` burst — same shape as the Phase-2 Bard/Mage/Warlock pass.
|
||||
Kept the DR; no `DamageBonus` rider, so high-tier ceilings stay flat.
|
||||
- **Sorcerer passive** (`dnd_passives.go`): burst base 3→5. Sorcerer was
|
||||
the second-worst caster off-tier (L1/T2 0.10 vs Mage 0.27 pre-tune)
|
||||
despite a comparable stat line; the bump pulls it toward parity with
|
||||
the other arcane chassis.
|
||||
|
||||
Added an off-tier-trailer diagnostic (`dnd_class_balance_test.go`) — for
|
||||
each (level, tier) cell where the player is below the in-tier band, the
|
||||
log names the trailing class and its mean win rate. Not asserted: an
|
||||
L1 walking into T2 is *supposed* to be hard, so the diagnostic is for
|
||||
watching the gap, not the absolute number.
|
||||
|
||||
Observed lifts:
|
||||
- Druid L1/T1: 0.77 → 0.86 (+9pp). The chassis is now functional at its
|
||||
intended tier.
|
||||
- L2/T2 cross-class spread: 77pp → 63pp. Druid trailer 0.23 → 0.35.
|
||||
- L1/T1 spread: 23pp → 14pp.
|
||||
|
||||
In-tier parity assertion (35pp band on the diagonal) still passes. No
|
||||
off-tier cell saturated past where it was — the lift is shape-correct.
|
||||
**← current**
|
||||
- **Phase 4 (if needed) — second-order.** Subclass-vs-subclass spread within a
|
||||
class; the Paladin/Rogue MAD question if the data shows it bites. The L5/T4
|
||||
wild-magic Sorcerer cell (0.14 individual, off-tier) is the most obvious
|
||||
next target if subclass parity needs another pass.
|
||||
|
||||
## 6. Tuning levers (priority order)
|
||||
|
||||
1. Class passives — `dnd_passives.go`
|
||||
2. Subclass L5/7/10/15 tiers — `dnd_subclass_combat.go`
|
||||
3. Spell damage dice & upcast — `dnd_spells_data.go`, `dnd_spells_srd_data.go`
|
||||
4. Spell slot progression — `dnd_spells.go`
|
||||
5. AC floor / armor proficiency — `dnd.go:164–178`
|
||||
6. Attack bonus class matrix — `dnd_combat.go:22–33`
|
||||
7. Monster scaling curves — `dnd_combat.go:35–51` (last resort; affects PvE feel)
|
||||
@@ -0,0 +1,678 @@
|
||||
# Expedition Difficulty Tuning Pass
|
||||
|
||||
Started 2026-05-15, after the Phase D audit close-out. Sibling to the
|
||||
class-balance pass (`gogobee_class_balance.md`) — same Monte-Carlo
|
||||
philosophy, different target metric.
|
||||
|
||||
**Class balance answers** "is my class viable?" (parity between classes
|
||||
vs. the same monster tier).
|
||||
**This pass answers** "is the dungeon fair?" (does a tier-appropriate
|
||||
character complete a tier-N expedition at the expected rate?).
|
||||
|
||||
Same combat engine (`simulateCombatWithRNG`), orthogonal knobs.
|
||||
|
||||
---
|
||||
|
||||
## Method
|
||||
|
||||
**Sim harness, not telemetry-first.** A new
|
||||
`internal/plugin/dnd_expedition_balance.go` will run thousands of
|
||||
seeded expeditions through the real autopilot / threat / encounter /
|
||||
combat code and report:
|
||||
|
||||
- completion %
|
||||
- death %
|
||||
- median days-to-extract
|
||||
- median threat at extract
|
||||
- supply curve (% expeditions that hit Rationing / Severe / Starvation)
|
||||
- combat-encounter count per run
|
||||
- coin/XP earned
|
||||
|
||||
The harness produces these per (zone, player-level) cell; the
|
||||
regression test asserts the per-tier completion band.
|
||||
|
||||
**Tier target band** (steered by [[feedback_difficulty_target]]
|
||||
"relatively easy but not too easy", and reflecting that expeditions are
|
||||
multi-day commitments players plan around — losing one should sting but
|
||||
not feel arbitrary):
|
||||
|
||||
| Tier | Target completion % | Acceptable band |
|
||||
|------|--------------------:|-----------------|
|
||||
| T1 | 80% | 70–90% |
|
||||
| T2 | 72% | 62–82% |
|
||||
| T3 | 65% | 55–75% |
|
||||
| T4 | 55% | 45–65% |
|
||||
| T5 | 45% | 35–55% |
|
||||
|
||||
Bands are ±10pp around the centerline. Sim is asserted against the band,
|
||||
not the centerline (no overfit). Player-level for each cell = "tier
|
||||
appropriate" per the class-balance kit ladder: T1 → L1-4, T2 → L3-7,
|
||||
T3 → L5-10, T4 → L7-15, T5 → L10-20 (median level per tier).
|
||||
|
||||
**Out of scope:**
|
||||
|
||||
- Per-encounter combat parity (covered by `gogobee_class_balance.md`).
|
||||
- Arena difficulty (separate system, separate balance).
|
||||
- Live telemetry tables — deferred; sim is the primary truth source for
|
||||
this pass. We can add counters later if the sim diverges from
|
||||
observed live numbers.
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
Each phase ≈ 1 session, ships green + committed.
|
||||
|
||||
### Phase 0 — sim harness spike
|
||||
|
||||
Build the minimum harness:
|
||||
|
||||
- New file `internal/plugin/dnd_expedition_balance.go` exposing
|
||||
`simulateExpeditionWithRNG(profile, seed)` → result struct.
|
||||
- **Critical de-risk: replay the autopilot deterministically.** The
|
||||
ticker is wall-clock driven (`dnd_expedition_cycle.go:38,56` at
|
||||
06:00 / 21:00 UTC; `expedition_ambient.go:22` every 3h). Harness
|
||||
must inject a virtual clock or call the per-phase advance functions
|
||||
directly (`deliverBriefing`, `deliverRecap`, ambient nudge, harvest
|
||||
autopilot) in tight loop, bypassing the goroutine ticker. **Settle
|
||||
the clock-injection seam in Phase 0** — if this is messy, the whole
|
||||
pass collapses.
|
||||
- DB-touching layers stubbed/in-memory (same trick as class-balance
|
||||
Phase 0).
|
||||
- Sanity test: one cell — e.g. T2 zone at L5 Fighter — runs to
|
||||
completion or death deterministically, returns sensible numbers,
|
||||
100 trials in <10s.
|
||||
|
||||
**Exit:** `TestExpeditionBalance_Phase0_Spike` green; commit.
|
||||
|
||||
### Phase 1 — full matrix measurement
|
||||
|
||||
- Build the cell list: every zone × tier-appropriate-level. Per the
|
||||
exploration map, expeditions span zones tagged T1–T5; we measure
|
||||
every zone at its centerline level.
|
||||
- 200 trials/cell (same density as class-balance Phase 1).
|
||||
- Matrix log: per-cell completion%, death%, median days, median
|
||||
extract-threat, supply-curve histogram.
|
||||
- Per-tier mean + spread diagnostic across zones (analogue of the
|
||||
class-balance per-(level,tier) spread diagnostic).
|
||||
- `TestClassBalance_Phase1_FullMatrix`-style permanent test, but only
|
||||
gating on harness-broken pathologies for now (0% completion anywhere
|
||||
at T1, 100% completion at T5).
|
||||
|
||||
**Exit:** matrix numbers logged; commit with "Phase 1 baseline" note.
|
||||
|
||||
### Phase 2b — wounded-entrant nick clamp (shipped)
|
||||
|
||||
`clampSurpriseNick(rawNick, hpCurrent, hpMax)` in
|
||||
`dnd_expedition_combat.go`: at full HP the raw nick stands; when
|
||||
wounded the nick is capped at `max(1, hpCurrent/5)` so a single nick
|
||||
on a low-HP fighter can't shave more than ~20% of remaining HP. The
|
||||
existing "nick can't KO" guard is preserved.
|
||||
|
||||
Motivation came from the post-2a tier-lethality trace
|
||||
(`TestExpeditionBalance_Phase2_TierLethality`): every tier had a
|
||||
fingerprint where an over-tier elite left the fighter at 25-50% HP +
|
||||
retreat, then the next standard's surprise nick (3-7 HP, often
|
||||
exceeding the fighter's headroom) pre-empted the combat engine
|
||||
entirely. Capping the wounded-entrant nick separates that cascade
|
||||
from genuine elite-one-shot deaths so Phase 2c can tune the elites
|
||||
with a clean read.
|
||||
|
||||
Matrix delta vs Phase 2a baseline is small (median encs +0.1-0.2
|
||||
per cell) — completion% still 0% across every tier — but the
|
||||
tier-lethality trace stretched substantively: T1 trial 0 went 5 → 8
|
||||
encs / 5 → 7 days; T3 trial 1 ran 18 → 10 encs but visibly survived
|
||||
multiple chained interrupts at low HP that pre-2b would have ended
|
||||
on the nick alone. The remaining deaths are now legible as elite
|
||||
one-shot fights on fresh entries (Hobgoblin Warchief, Green Hag,
|
||||
Roper, Young Red Dragon) — that's the Phase 2c roster signal.
|
||||
|
||||
**Tuning surface:** the `/5` divisor in `clampSurpriseNick` is the
|
||||
wounded-fighter lethality knob.
|
||||
|
||||
### Phase 2 lever sweep (shipped) — negative result
|
||||
|
||||
Before adding Phase 2c, the two surfaced knobs (`retreatThreatBump`,
|
||||
`clampSurpriseNick` divisor) got swept across a full 3×4 grid
|
||||
(bump ∈ {2, 5, 10} × divisor ∈ {3, 5, 8, 12}) at 200 trials/cell
|
||||
across every zone in the matrix. Wired via
|
||||
`expeditionBalanceProfile.{RetreatThreatBumpOverride,
|
||||
SurpriseNickDivisorOverride}` threaded into `expeditionHarness`;
|
||||
the live caller (`runHarvestInterrupt`) is untouched.
|
||||
|
||||
Sweep file: `TestExpeditionBalance_Phase2_LeverSweep` in
|
||||
`expedition_balance_test.go`. `-short` skips.
|
||||
|
||||
**Outcome:** every one of the 24,000 trial-cells reports 0.0%
|
||||
completion / ~100% death. The two knobs are inert on the headline
|
||||
metric — even the gentlest combo (b=2, d=12) cannot lift any tier
|
||||
off the floor. Median days-to-end stays at 2–3 across the grid
|
||||
with median 2.6–7.3 encounters before death.
|
||||
|
||||
**Read:** confirms the post-2b tier-lethality trace — deaths are
|
||||
fresh-entry elite one-shots (Warchief @ HP19/20, Hag @ HP41, Roper,
|
||||
Young Red Dragon), not chained-interrupt cascades. The two levers
|
||||
target the cascade path that's now mostly resolved; they have no
|
||||
remaining death-fraction to convert. Phase 2c (roster dilution) is
|
||||
justified rather than premature.
|
||||
|
||||
### Phase 2c — roster gate (shipped)
|
||||
|
||||
Promoted one mid-tier alt to `IsElite: true` in every zone whose elite
|
||||
pool was a single SpawnWeight=1 boss (`internal/plugin/dnd_zone.go`).
|
||||
The boss stays in the pool; weighted dilution means the InterruptElite
|
||||
bracket no longer auto-picks the over-tier boss. `pickZoneEnemy` also
|
||||
excludes the promoted entry from the standard pool, lowering standard
|
||||
encounter difficulty by removing one mid-difficulty option from
|
||||
non-elite brackets (acceptable: each zone still has 3-4 standard
|
||||
entries left). Crypt-Valdris was already dual-elite (wight + flameskull)
|
||||
and was left alone.
|
||||
|
||||
| zone | promoted alt | boss (untouched) |
|
||||
|--------------------|-----------------------|--------------------|
|
||||
| goblin_warrens | worg (SW=3) | hobgoblin_warchief |
|
||||
| forest_shadows | owlbear (SW=4) | green_hag |
|
||||
| sunken_temple | aboleth_thrall (SW=3) | water_elemental |
|
||||
| manor_blackspire | vampire_spawn (SW=3) | revenant |
|
||||
| underforge | salamander (SW=3) + fire_elemental (SW=3) | helmed_horror |
|
||||
| underdark | drow_elite_warrior (SW=4) | roper |
|
||||
| feywild_crossing | night_hag (SW=3) | fomorian |
|
||||
| dragons_lair | dragonborn_cultist (SW=3) | young_red_dragon |
|
||||
| abyss_portal | hezrou (SW=4) | marilith |
|
||||
|
||||
**Phase 1 matrix delta (200 trials/cell, Fighter, tier centerline):**
|
||||
goblin_warrens 0% → **3.0%** completion (first non-zero T1 reading
|
||||
since Phase 0); sunken_temple 0% → 0.5%; dragons_lair death% **100% →
|
||||
60%** with 40% now starve (fighter survives the elite-spawn path long
|
||||
enough to run out of supplies — different failure mode); underdark
|
||||
death% 100% → 90% (10% starve); feywild_crossing 100% → 92% death
|
||||
(8% starve). T3 manor_blackspire / underforge still 100% death — the
|
||||
remaining death pressure there is the post-2b chained-standard path,
|
||||
not the elite roll.
|
||||
|
||||
Tier-lethality trace cross-check (`Phase2_TierLethality`) shows the
|
||||
new alt-elites actually spawning and being winnable: underdark trial
|
||||
1 now opens with `Drow Elite Warrior` (won), reaches the Roper at
|
||||
turn 6, retreats, then dies on a Hook Horror nick — a substantively
|
||||
longer arc than the pre-2c fresh-entry Roper one-shot. Dragons_lair
|
||||
trials similarly show `Dragonborn Cultist` as a winnable elite stop
|
||||
before the dragon roll lands.
|
||||
|
||||
Stat-block tuning of the bosses themselves is off-limits per the
|
||||
Phase 3 constraint (bestiary is shared across systems).
|
||||
|
||||
### Phase 3 — global lever tuning
|
||||
|
||||
Tune the knobs that affect every zone uniformly:
|
||||
|
||||
- **Daily threat drift** (`dnd_expedition_threat.go:87` — base +3/day).
|
||||
- **Threat band thresholds** (Quiet/Stirring/Alert/Hostile/Siege —
|
||||
`dnd_expedition_threat.go:70-82`).
|
||||
- **Supply burn multipliers** (`dnd_expedition_supplies.go:43-56` —
|
||||
T1×1 → T5×3).
|
||||
- **Surprise-round damage floor** (`dnd_expedition_combat.go:184`).
|
||||
- **Encounter bracket thresholds** (`dnd_expedition_combat.go:39-44`
|
||||
None/Noise/Standard/Elite/Patrol).
|
||||
- **Camp mods** (`dnd_expedition_night.go:85-92`).
|
||||
|
||||
Goal: every tier centerline lands within ±5pp of target. Bands stay
|
||||
±10pp.
|
||||
|
||||
#### Phase 3-A — elite-bracket + drift sweep (shipped)
|
||||
|
||||
Wired two harness overrides
|
||||
(`EliteInterruptThresholdOverride`, `ThreatDriftBaseOverride`) and
|
||||
swept a 3×3 grid (elite ∈ {17, 19, 23} × drift ∈ {1, 3, 5}) over
|
||||
the Phase 1 matrix. Test: `TestExpeditionBalance_Phase3_GlobalLeverSweep`.
|
||||
|
||||
**Strong partial positive.** The elite-bracket threshold is the
|
||||
dominant lever for T1–T3; threat drift is a small modulator on top.
|
||||
|
||||
| (elite, drift) | T1 mean | T2 mean | T3 mean | T4 mean | T5 mean |
|
||||
|----------------|--------:|--------:|--------:|--------:|--------:|
|
||||
| 17, 1 (more elites) | 0.5% | 0.0% | 0.0% | 0.0% | 0.0% |
|
||||
| 19, 3 (live) | 3.0% | 0.2% | 0.0% | 0.0% | 0.0% |
|
||||
| 23, 1 (rare elites) | **24.0%** | **7.2%** | **1.8%** | 0.0% | 0.0% |
|
||||
| 23, 3 | 16.5% | 4.0% | 2.0% | 0.0% | 0.0% |
|
||||
| 23, 5 | 11.8% | 3.2% | 0.2% | 0.0% | 0.0% |
|
||||
|
||||
Best cell at e=23/d=1: goblin_warrens 40.5%, sunken_temple 14.5%.
|
||||
Still well below the T1 70-90% / T2 62-82% target bands — the lever
|
||||
moves the needle in the right direction but cannot land any tier
|
||||
on-band alone.
|
||||
|
||||
**T4/T5 fingerprint changed but didn't lift.** At e=23 the dragons_lair
|
||||
death-fraction drops from ~60% → 24% but starvation climbs to 75% —
|
||||
the fighter now survives elites long enough to run out of supplies.
|
||||
T4 (underdark/feywild) likewise sees death drop and starvation rise.
|
||||
Indicates a second lever is needed for the higher tiers: either
|
||||
standard-fight survivability (surprise-nick floor / standard roster
|
||||
density) or supply margin.
|
||||
|
||||
**Next:** Phase 3-B sweeps surprise-round nick and supply-burn knobs
|
||||
on top of e=23/d=1 to push T1-T3 toward target band and lift T4-T5
|
||||
off the floor.
|
||||
|
||||
#### Phase 3-B — nick-floor + supply-burn sweep (shipped)
|
||||
|
||||
Wired two more harness overrides (`SurpriseNickFloorOverride`,
|
||||
`SupplyBurnRatePctOverride`) and swept a 3×3 grid (floor ∈ {0, 1, tier
|
||||
(=live)} × burn% ∈ {50, 75, 100 (=live)}) on top of the Phase 3-A best
|
||||
cell (e=23, d=1). Test: `TestExpeditionBalance_Phase3B_NickSupplySweep`.
|
||||
|
||||
**Strong partial positive for T5 supply margin; nick-floor lever inert.**
|
||||
|
||||
Tier-mean completion (Fighter, centerline level, 10 zones × 200 trials):
|
||||
|
||||
| (floor, burn%) | T1 mean | T2 | T3 | T4 | T5 |
|
||||
|----------------|--------:|----:|----:|-----:|-----:|
|
||||
| 0, 50 | 20.8% | 5.0%| 3.8%| 7.5% | 29.5%|
|
||||
| 0, 75 | 26.5% | 6.0%| 2.8%| 9.0% | 0.0% |
|
||||
| 0, 100 | 25.8% | 5.0%| 3.0%| 0.0% | 0.0% |
|
||||
| 1, 50 | 25.5% | 6.5%| 1.8%| 8.5% | 27.8%|
|
||||
| 1, 75 | 28.5% | 6.2%| 4.0%|11.5% | 0.0% |
|
||||
| 1, 100 | 26.8% | 7.5%| 2.5%| 0.0% | 0.0% |
|
||||
| tier, 50 | 26.8% | 6.2%| 2.5%| 7.8% | 27.5%|
|
||||
| tier, 75 (live floor) | **29.5%** | **7.8%** | 3.0% | **12.8%** | 0.0% |
|
||||
| tier,100 (live)| 28.5% | 6.8%| 3.0%| 0.0% | 0.0% |
|
||||
|
||||
Three readings:
|
||||
|
||||
1. **Supply burn is the T5 unlock.** dragons_lair completes ~55% at
|
||||
burn=50 (versus 0% at burn=75/100) — the fighter survives elites
|
||||
but starves en route. burn=75 isn't enough margin; burn=50
|
||||
converts the starvation-out into a completion.
|
||||
2. **Supply burn is the T4 modulator.** underdark/feywild step from
|
||||
0% (burn=100) → 12% (burn=75) → 8% (burn=50). The burn=75 peak is
|
||||
the sweet spot; halving further leaves the fighter alive *into*
|
||||
more elite fights, where it dies in combat instead.
|
||||
3. **Nick-floor lever is inert.** Across all burn levels, dropping
|
||||
the surprise-round floor from `tier` → `1` → `0` moves T1-T3
|
||||
means by at most ~3pp. The wounded-clamp (Phase 2b,
|
||||
`clampSurpriseNick`) already eats the chip-damage budget on
|
||||
repeat entries; the raw floor only matters on the first fresh-HP
|
||||
swing, which the engine recovers from. **Recommendation: discard
|
||||
this lever from the live-tuning candidate list.**
|
||||
|
||||
**T2-T3 wall persists.** Even at the best (floor=tier, burn=75) cell:
|
||||
T2 reads 7.8% (target 62-82%) and T3 reads 3.0% (target 54-74%).
|
||||
forest_shadows T2, manor_blackspire T3, and abyss_portal T5 sit at
|
||||
~0% across every combo — these are zone-specific outliers, not
|
||||
addressable by any global lever. **Phase 4 (per-zone outlier pass)
|
||||
is the next move.**
|
||||
|
||||
**Next:** Phase 4 walks the matrix zone-by-zone. The shipped Phase 3
|
||||
sweep already names the worst offenders (forest_shadows T2,
|
||||
manor_blackspire T3, abyss_portal T5, crypt_valdris T1); each needs a
|
||||
diagnostic trace (boss stat-block, roster mix, supply DC) to pick the
|
||||
right per-zone tool from `Phase 4`'s list. Optional: revisit shipping
|
||||
the burn=75 global if Phase 4 outlier fixes leave T4 still stuck.
|
||||
|
||||
### Phase 4 — per-zone outlier pass
|
||||
|
||||
After globals settle, the matrix names outlier zones — tiers where
|
||||
one zone reads dramatically off-band while sibling zones are fine.
|
||||
Per-zone tools:
|
||||
|
||||
- `SpawnWeight` / `IsElite` flags in zone rosters (`dnd_zone.go:100`).
|
||||
- Boss stat tuning in zone definitions.
|
||||
- Resource DCs (affects harvest interrupt roll).
|
||||
- Per-zone loot table density (downstream economy impact — flag if
|
||||
significant).
|
||||
|
||||
Constraint: do not touch monster stat blocks (those belong to the
|
||||
bestiary, shared across systems). Tune the roster + zone-level knobs,
|
||||
not the monsters themselves.
|
||||
|
||||
#### Phase 4-A — outlier diagnostic (shipped)
|
||||
|
||||
Added a structured per-fight trace hook (`traceFightStruct` +
|
||||
`harnessFightTrace`) alongside the existing string `traceFight`;
|
||||
both fire from the same `runHarnessFight` site so the human log and
|
||||
the structured aggregate stay in sync. Test:
|
||||
`TestExpeditionBalance_Phase4A_OutlierDiagnostic`. For each of the
|
||||
four outlier zones it pairs against the healthy sibling at the same
|
||||
tier (goblin_warrens, sunken_temple, underforge, dragons_lair),
|
||||
running 200 trials each on the Phase 3-B best cell (e=23, d=1,
|
||||
burn=50) and reporting per-monster appearances / win-rate / avg HP
|
||||
loss / kill attribution + day-of-end histogram.
|
||||
|
||||
Findings — each outlier had one or two miscategorised entries that
|
||||
Phase 2c's single-boss-elite dilution sweep had skipped:
|
||||
|
||||
- **crypt_valdris**: dual-killer elite pool (Wight 99k, Flameskull
|
||||
68k); Phase 2c had skipped this zone as "already dual-elite," but
|
||||
both elites are over-tier for T1.
|
||||
- **forest_shadows**: standard pool too lethal — Displacer Beast
|
||||
(38% win, 53k) + Bandit Captain (57% win, 48k).
|
||||
- **manor_blackspire**: Wraith on standard slot (45hp loss per win,
|
||||
85k).
|
||||
- **abyss_portal**: Nalfeshnee mis-classified standard (CR-13, 2.8%
|
||||
win, 86k) + Vrock standard at 43hp loss per win.
|
||||
|
||||
#### Phase 4-B — roster re-classification (shipped)
|
||||
|
||||
Per-zone roster tweaks based on Phase 4-A's attribution. No monster
|
||||
stat-block touches.
|
||||
|
||||
- crypt_valdris: dropped flameskull (lives in underforge T3);
|
||||
promoted specter to IsElite SW=3 as the soft dilutor.
|
||||
- forest_shadows: promoted Displacer Beast + Bandit Captain to
|
||||
IsElite; Owlbear SW 4→2 so the 4-elite pool dilutes evenly.
|
||||
- manor_blackspire: promoted Wraith to IsElite (joins
|
||||
Spawn+Revenant).
|
||||
- abyss_portal: promoted Nalfeshnee + Vrock to IsElite (collapses
|
||||
standard pool to Quasit alone, ≥99% win).
|
||||
|
||||
Outlier-vs-sibling completion (Phase 3-B best cell, 200 trials):
|
||||
|
||||
| Zone | Before | After | Sibling |
|
||||
|------|-------:|------:|--------:|
|
||||
| crypt_valdris T1 | 8.5% | **46.0%** | 45.0% |
|
||||
| forest_shadows T2 | 0.0% | **7.5%** | 13.0% |
|
||||
| manor_blackspire T3| 0.5% | **14.5%** | 3.0% |
|
||||
| abyss_portal T5 | 0.0% | **25.0%** | 57.5% |
|
||||
|
||||
Phase 3-B tier mean (b=50, f=tier) before → after:
|
||||
T1 26.8% → 44.0% (+17pp), T2 6.2% → 10.0% (+4),
|
||||
T3 2.5% → 8.5% (+6), T4 unchanged, T5 27.5% → 41.0% (+14).
|
||||
|
||||
**Exit (partial):** the four off-band outliers are no longer
|
||||
outliers — every per-zone cell is within the spread of its tier
|
||||
siblings. The Phase 4 plan's stricter exit ("all per-zone cells
|
||||
within target band") is **not** met: T2/T3/T5 sibling pairs still
|
||||
sit below band (T2 7-13% vs 62-82%; T3 3-14% vs 54-74%; T5 25-57%
|
||||
vs 36-56%). That gap is tier-wide, not zone-specific — both zones at
|
||||
each problem tier underperform together — so no further per-zone
|
||||
tool will close it. Phase 5 (or a follow-up tier-wide pass) needs to
|
||||
take over.
|
||||
|
||||
**Next:** Phase 5. Candidate moves are tier-wide: revisit player
|
||||
gear-tier mapping at T2/T3 (the centerline level vs gear ladder, see
|
||||
`phase1TierCenterline`), reconsider the elite-bracket threshold
|
||||
per-tier instead of global, or ship `burn=75` as the new live global
|
||||
since Phase 4-B already converted T4 to playable at b=50.
|
||||
|
||||
### Phase 5 — tier-wide pass (T2/T3/T5 under-band)
|
||||
|
||||
Phase 4-B closed the per-zone outliers but left T2/T3/T5 sibling pairs
|
||||
below the target band as a *tier-wide* gap. The plan doc's three
|
||||
candidate moves (gear-tier centerline remap, per-tier elite threshold,
|
||||
ship `burn=75`) needed disambiguation before any of them got pulled —
|
||||
hence a measure-first sub-phase.
|
||||
|
||||
#### Phase 5-A — tier-wide sensitivity sweep (shipped)
|
||||
|
||||
`TestExpeditionBalance_Phase5A_TierWideSensitivity` runs the 6 under-
|
||||
band zones (T2: forest_shadows + sunken_temple; T3:
|
||||
manor_blackspire + underforge; T5: abyss_portal + dragons_lair) through
|
||||
three one-axis sweeps with the other two levers held at Phase 3-B best
|
||||
(e=23, d=1, burn=50): player level ±2 around tier centerline, elite
|
||||
threshold ∈ {18, 23, 28}, supply burn pct ∈ {40, 50, 60}. 200
|
||||
trials/cell × 6 zones × 9 cells = 10.8k trials; runs in <1s.
|
||||
|
||||
Numbers (200 trials/cell, comp%):
|
||||
|
||||
```
|
||||
T2 (band 62-82%)
|
||||
L forest=0→6.5→19 sunken=0→13.5→34.5 STRONG slope
|
||||
E forest=0.5→9→8 sunken=0→14.5→13 saturated @23; 18 collapses
|
||||
B forest=3.5→7→6.5 sunken=14→15.5→11 inert
|
||||
T3 (band 54-74%)
|
||||
L manor=3→10.5→13.5 under=0→4→8.5 clear slope
|
||||
E manor=0→9.5→9.5 under=0→3→4 saturated @23; 18 collapses
|
||||
B manor=13→9.5→9.5 under=2.5→4→2.5 inert
|
||||
T5 (band 36-56%)
|
||||
L abyss=21.5→28.5→32 dragons=60→60.5→53 gentle; dragons in-band
|
||||
E abyss=1→28.5→31 dragons=10→60.5→57.5 saturated @23; 18 collapses
|
||||
B abyss=27.5→28.5→0 dragons=59→60.5→0 burn=60 STARVE CLIFF
|
||||
```
|
||||
|
||||
**Reading:**
|
||||
- **Player level is the dominant lever at T2/T3.** Every +2 levels
|
||||
lifts completion meaningfully; the elite gate and supply burn are
|
||||
inert or actively harmful around the Phase 3-B baseline.
|
||||
- **Elite threshold is already at its sweet spot at e=23.** Going to
|
||||
e=18 floods elites and collapses every tier; e=28 is flat. The per-
|
||||
tier-threshold candidate from Phase 4 close-out is killed: there's
|
||||
no per-tier value to be found in the explored range.
|
||||
- **`burn=75` globally is killed.** burn=60 alone produces 36% / 61%
|
||||
starve at T5; burn=75 would be catastrophic. The Phase 3-B negative
|
||||
result holds tier-wide. burn must stay ≤50.
|
||||
- **T5 dragons_lair is already in-band** at the baseline (60.5%).
|
||||
The T5 tier-wide gap is really an abyss_portal gap (28.5%); the
|
||||
Phase 4-B re-class lifted it from outlier to "low-but-not-isolated,"
|
||||
but it still sits below band by itself.
|
||||
- **The level-bump path has a catch.** L7 at T2 (still inside the
|
||||
L3-7 design range and gearTier=2) only reaches ~27% mean — still
|
||||
well below the 62-82% band. Closing T2 to band likely requires a
|
||||
*cross-gearTier-boundary* centerline (T2 → L9 = gearTier 3) and/or
|
||||
a player-side combat-math adjustment, not just a within-bracket
|
||||
centerline shift. That's a design call, not a knob twist.
|
||||
|
||||
**Next:** Phase 5-B is a design choice between three concrete moves
|
||||
that the measurement now backs:
|
||||
|
||||
1. **Cross-bracket centerline bump** for T2 (→ L9) and T3 (→ L13),
|
||||
plus T5 abyss-only level tweak. Lifts player gear bracket into the
|
||||
next tier; closes the band gap directly but is a player-side power
|
||||
move that affects the live game (chars at the centerline level
|
||||
keep their existing gear; centerline only governs the harness's
|
||||
measurement cell). The change is *in the harness*, but it admits
|
||||
the live difficulty target was wrong for these tiers — Phase 5-C
|
||||
would then carry it back to the live gear-acquisition curve.
|
||||
2. **Player combat-math retune** at T2/T3 (HP scaling, proficiency
|
||||
curve, attack-bonus floor) — bigger lift, touches the class-balance
|
||||
harness too, deserves its own pass.
|
||||
3. **Lower the band target** for T2/T3/T5 to ~30-50% mean and accept
|
||||
that completion is meant to be hard. This is the "do nothing"
|
||||
option dressed up as a design decision; surfaces the question of
|
||||
whether the original 70% T2 target was ever the right number.
|
||||
|
||||
Recommend option (1) for Phase 5-B since the harness already measures
|
||||
it cleanly and the data points at it; option (3) is the fallback if
|
||||
the cross-bracket numbers still don't close the band.
|
||||
|
||||
#### Phase 5-B — player power floor + Phase-3 winners shipped
|
||||
|
||||
The Phase 5-A read named player level / gear-tier centerline as the
|
||||
dominant lever at T2/T3 but flagged that even max-of-range within the
|
||||
gear bracket missed the band. Three design options were on the table:
|
||||
cross-bracket centerline bump, **player combat-math retune**, or
|
||||
lower the band target. The user picked combat-math retune ("fairly
|
||||
breezy with some death — not a ton") — option (2). On reflection
|
||||
this was the only one of the three that actually moves *live*
|
||||
difficulty (cross-bracket centerline only moves the harness number;
|
||||
lowering the band is the "do-nothing" fallback).
|
||||
|
||||
The shipped lift, in five small pieces:
|
||||
|
||||
1. **`computeMaxHP` ×1.5** (`dnd.go: phase5BHPMult`). The HP curve is
|
||||
uniformly scaled so every class/level lifts together — preserves
|
||||
the class-balance harness's in-tier spread assertion. Migration
|
||||
`bootstrapPhase5BHPRefresh` walks `dnd_character` once at startup
|
||||
to refresh existing rows; idempotent via `db.JobCompleted`.
|
||||
2. **Combat-stat player floor** (`dnd_combat.go:
|
||||
applyPhase5BPlayerFloor`): +3 AC, +3 AttackBonus, +3
|
||||
weapon.MagicBonus (damage). Applied at the END of
|
||||
`applyDnDEquipmentLayer` (so the AC override from
|
||||
`computeArmorAC` doesn't stomp it) AND inside `buildHarnessPlayer`
|
||||
so live and harness measurement agree.
|
||||
3. **Elite bracket 19 → 23** (`dnd_expedition_combat.go:
|
||||
resolveCombatInterrupt`). Case order: Elite (≥23) before Patrol
|
||||
(≥22) so a 23+ total prefers the single dangerous fight over the
|
||||
patrol-flavored standard pick. Elite is now effectively a
|
||||
*high-threat* event reachable only via the +1-per-20-threat-above-40
|
||||
mod — base d20+tier+ranger maxes at ~21. Phase 4-B's elite-pool
|
||||
monsters still appear; just less often.
|
||||
4. **Threat drift base 3 → 1** (`dnd_expedition_threat.go:
|
||||
dailyThreatDrift`). Slows the threat clock so players have the
|
||||
days they need to extract before threat tips zones into the new
|
||||
23+ elite band.
|
||||
5. **Daily supply burn × 0.5** (`dnd_expedition_supplies.go:
|
||||
phase5BDailyBurnRatePct`). `applyDailyBurn`'s default now passes
|
||||
50; `applyDailyBurnP` keeps its rate-parameterized form for the
|
||||
harness. Also applied in the temporal-override branch
|
||||
(`dnd_expedition_cycle.go`) so tidal / unraveling days scale by
|
||||
the same 0.5× — otherwise those days would be disproportionately
|
||||
harsh against the new baseline.
|
||||
|
||||
The harness reflects all five (`expedition_balance.go` reads
|
||||
`phase5BDailyBurnRatePct` as the default-burn fallback when the
|
||||
override knob is zero). Phase 1 matrix on the shipped baseline,
|
||||
Fighter @ tier-centerline, 200 trials:
|
||||
|
||||
```
|
||||
T1 87.8% spread 1.5 (crypt 89, goblin 87) ⬆ band 70-90 — IN BAND
|
||||
T2 74.0% spread 12 (forest 80, sunken 68) ⬆ band 62-82 — IN BAND
|
||||
T3 43.2% spread 8.5 (manor 39, underforge 47) ⬇ band 55-75 — UNDER
|
||||
T4 71.8% spread 25.5 (feywild 59, underdark 88) ⬆ band 45-65 — OVER (breezy)
|
||||
T5 ~57% (dragons 78, abyss 38) ⬆ band 35-55 — borderline
|
||||
```
|
||||
|
||||
Four of five tier means land at or above band-center, matching the
|
||||
"fairly breezy with some death" target. **T3 remains the hump** —
|
||||
both manor and underforge sit ~40-50% completion, killing 50-60% of
|
||||
the time. Phase 4-B already promoted T3's worst killers (Wraith) to
|
||||
elite, so the standard-pool deaths are now the irreducible part.
|
||||
T4's wide spread (feywild 59 vs underdark 88) means feywild still
|
||||
has a roster-side asymmetry that warrants a Phase 4-B-style pass.
|
||||
|
||||
**Test debt cleaned:** 13 unit tests in `combat_stats_test.go`,
|
||||
`dnd_test.go`, `dnd_xp_test.go`, `dnd_equipment_profiles_test.go`,
|
||||
`dnd_expedition_supplies_test.go`, `dnd_expedition_cycle_test.go`,
|
||||
`dnd_expedition_extract_test.go`, `dnd_expedition_region_cmd_test.go`,
|
||||
`dnd_expedition_combat_test.go`, `dnd_expedition_threat_test.go`,
|
||||
`dnd_expedition_temporal_test.go`, `expedition_balance_test.go` were
|
||||
pinning the pre-Phase-5-B numbers; updated to the shipped values
|
||||
with comments noting the cause.
|
||||
|
||||
**Exit:** Phase 5-B closed-out the user's primary target ("make it
|
||||
breezy"). T1/T2/T4/T5 mean in or above band; T3 remains the design
|
||||
hump and is fine for "some death" tier shape. Remaining work
|
||||
(Phase 5-C+) is T3 roster polish + feywild T4-spread fix — separate
|
||||
session.
|
||||
|
||||
#### Phase 5-C — T3 polish + T4 spread fix (rosters only)
|
||||
|
||||
Phase 5-B's exit named two follow-ups: T3 below band (manor 39 /
|
||||
underforge 47 vs 55-75), and the T4 spread (feywild 59 vs underdark
|
||||
88 = 30pp asymmetry). Phase 5-C re-runs the Phase 4-A trace on the
|
||||
four affected zones at the shipped Phase 5-B cell
|
||||
(`TestExpeditionBalance_Phase5C_OutlierDiagnostic`) and identifies
|
||||
which monsters own the lethality budget per zone, then applies the
|
||||
Phase 4-B toolset (IsElite toggle + SpawnWeight rebalance only — no
|
||||
bestiary stat-block touches).
|
||||
|
||||
Per-zone reads from the diagnostic (200 trials, L9/L13 Fighter,
|
||||
e=23 d=1 burn=50):
|
||||
|
||||
manor_blackspire (53% comp): standards all ≥99% win. Elite pool
|
||||
is the lethality source — Vampire Spawn (72% win, 42 kills) and
|
||||
Revenant (16% win, 31 kills at SW=1) split the kill budget.
|
||||
Revenant can't drop below SW=1, and trimming Vampire Spawn
|
||||
(first attempt: SW 3 → 2) backfired by shifting elite share onto
|
||||
Revenant — kills jumped to 48 and completion fell to 44.5%.
|
||||
|
||||
underforge (49.5% comp): Fire Elemental (57 kills, 61hp/win) +
|
||||
Salamander (33 kills) own elite share. Helmed Horror was the
|
||||
only 100%-win elite but sat at SW=1, providing almost no
|
||||
dilution.
|
||||
|
||||
feywild_crossing (54% comp): Fomorian alone (65 kills, 50% win,
|
||||
SW=1) owns the elite kill budget — only 2 elites in the pool
|
||||
(Night Hag SW=3 + Fomorian SW=1), so Fomorian still gets 25% of
|
||||
elite picks despite the low weight.
|
||||
|
||||
underdark (86% comp): Drow at SW=7 (1801/4164 standard rolls,
|
||||
100% win / 1.1hp loss) is a free-HP filler dominating the
|
||||
standard pool.
|
||||
|
||||
Roster changes shipped (`dnd_zone.go`):
|
||||
|
||||
manor_blackspire — Banshee promoted to elite at SW=2 (was SW=3
|
||||
standard, 99.6% win, 11hp loss): adds a soft 4th elite slot,
|
||||
dropping Revenant's elite share ~14% → 11% without changing
|
||||
its presence. Standard floor collapses to Shadow + Poltergeist,
|
||||
both ≥99% win at ≤5hp loss.
|
||||
|
||||
underforge — Helmed Horror SW 1 → 3 (98%+ win, 15hp loss): three
|
||||
roughly-equal elite slots (Salamander 3 + Fire Elemental 3 +
|
||||
Helmed Horror 3), reducing Fire Elemental's elite share from
|
||||
~44% to ~33%.
|
||||
|
||||
feywild_crossing — Green Hag promoted to elite at SW=2 (was SW=4
|
||||
standard, 98% win, 16hp loss): adds a softer 3rd elite slot,
|
||||
diluting Fomorian's share, AND pulls a moderate-attrition fight
|
||||
out of the standard pool. Standard floor becomes Redcap +
|
||||
Will-o-Wisp + Quickling (all ≥99% win, ≤11hp loss).
|
||||
|
||||
underdark (light trim per user's "lift trailers, don't nerf
|
||||
leaders" stance) — Drow SW 7 → 5: standards shift toward Hook
|
||||
Horror (17.8hp/win) and Drow Mage (16.4hp/win), still safe but
|
||||
with real attrition.
|
||||
|
||||
Phase 1 matrix after Phase 5-C (200 trials, Fighter @ centerline):
|
||||
|
||||
```
|
||||
T1 88.5% spread 1.0 (crypt 89, goblin 88) ⬆ band 70-90 — IN BAND
|
||||
T2 74.5% spread 15.0 (forest 82, sunken 67) ⬆ band 62-82 — IN BAND
|
||||
T3 56.7% spread 0.5 (manor 57, underforge 56.5) ⬆ band 55-75 — IN BAND ⬆ +13.5pp
|
||||
T4 77.0% spread 13.0 (feywild 70.5, underdark 83.5) ⬆ band 45-65 — OVER (matches 5-B target)
|
||||
T5 58.0% spread 40.0 (abyss 38, dragons 78) ⬆ band 35-55 — borderline (abyss the residual)
|
||||
```
|
||||
|
||||
T3 mean lifted +13.5pp; both zones land inside the band with a
|
||||
0.5pp residual spread. T4 spread halved (25.5 → 13.0pp) by lifting
|
||||
feywild +11.5pp and trimming underdark -4.5pp, matching the user's
|
||||
chosen "lift mostly, trim lightly" direction. T1/T2/T5 untouched.
|
||||
|
||||
**Test debt:** none — no production tests pinned the changed
|
||||
SpawnWeight or IsElite values. `TestMonsterKillTags_GatesKnownMonsters`
|
||||
checks `vampire_spawn` tags but is unaffected by weight.
|
||||
|
||||
**Exit:** T1-T4 all in or above band; T5's abyss_portal (38%) is
|
||||
the only remaining sub-band cell and is a tier-asymmetry residual,
|
||||
not a per-zone outlier. Phase 5-D could revisit abyss specifically,
|
||||
but it's optional — the user's "breezy with some death" target is
|
||||
hit across the live tier ladder.
|
||||
|
||||
### Phase 6 — optional MAD / second-order
|
||||
|
||||
If post-Phase-3 the bands hold but feel wrong subjectively
|
||||
(e.g. "median days too short", "no expedition ever hits Siege"), add a
|
||||
secondary assertion on median-days-to-extract or extract-threat. Phase
|
||||
3 may also surface a request to expose a difficulty knob to operators
|
||||
(env var or admin command); scope that here if it comes up.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should the babysit perk be on or off in the sim? Pick one and
|
||||
document; both as separate cells doubles matrix size for diminishing
|
||||
insight. Default proposed: **off**, since it's a discovery-mechanic
|
||||
buff ([[feedback_npc_buffs_are_secret]]) — players who find it earn
|
||||
extra margin, not the baseline.
|
||||
- Pardon proc (33% @ chat 20+): include in sim, but at a fixed
|
||||
chat-level assumption (chat 15 = no pardon, since most fresh runs
|
||||
haven't hit the threshold). Decide in Phase 0.
|
||||
- Sovereign reprieve: skip in sim — it's gated on full set equipped
|
||||
and is a one-shot per cooldown. End-game-only; not part of the
|
||||
baseline difficulty.
|
||||
- Multi-region zones (E4): does the sim need to walk all regions in
|
||||
one expedition, or pick one region per run? Probably full walk for
|
||||
realism. Settle in Phase 0.
|
||||
- DM mood: starts at 50; drifts via combat outcomes. Let it drift
|
||||
naturally in the sim, do not pin.
|
||||
|
||||
---
|
||||
|
||||
## Deliverables checklist
|
||||
|
||||
- [ ] `internal/plugin/dnd_expedition_balance.go` (sim harness)
|
||||
- [ ] `TestExpeditionBalance_Phase0_Spike`
|
||||
- [ ] `TestExpeditionBalance_Phase1_FullMatrix` (becomes the permanent
|
||||
regression gate after Phase 2 tightens it)
|
||||
- [ ] Tuning commits per phase
|
||||
- [ ] One-line update to `MEMORY.md` + project memory pointer
|
||||
@@ -0,0 +1,329 @@
|
||||
# Gogobee — Harvest Charge Semantics & Auto-Harvest Parity
|
||||
|
||||
**Status:** plan, not yet implemented.
|
||||
**Branch convention:** new branch off `open5e` (e.g. `harvest-charges` or `phase-H`).
|
||||
**Prerequisite:** none — the autopilot harvest path is already in place; this is a behavior + parity change.
|
||||
**Owner:** future session.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal & non-goals
|
||||
|
||||
### Goal
|
||||
|
||||
Retire the "retry-to-success" grind that the current harvest system encourages and replace it with Josie's deterministic-attempts model:
|
||||
|
||||
- A node spawns with N charges. Each charge = exactly one d20 attempt.
|
||||
- **Success or failure both consume a charge.** No retries.
|
||||
- The autopilot burns all charges in one room-pass, reporting a final tally.
|
||||
- Rare+ nodes are no longer special-cased — they get auto-attempted like everything else, and a whiff is a whiff.
|
||||
- `!zone advance` gains auto-harvest parity with `!expedition run` (today only the expedition autopilot auto-harvests; zone runs still require typing `!forage`/`!scavenge`/etc per room).
|
||||
|
||||
The user-facing pitch: stop typing `!mine`/`!scavenge`/etc. The walk handles it. Failure feels unlucky on purpose; that's the point.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Reworking the combat engine, threat system, or noise mechanics.
|
||||
- Changing what *resources* exist or what zones they belong to (the registry stays as-is).
|
||||
- Changing DCs per resource (calibration is a yield-quantity / charge-count knob, not a DC knob — see Phase H4).
|
||||
- Changing class bonuses (`classHarvestBonus`) or per-action ability mapping (`harvestActionAbility`).
|
||||
- Building any new "rare buff" or pre-roll consumable surface. None exists today and we're not adding one.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state (so future-me has the receipts)
|
||||
|
||||
- `internal/plugin/dnd_expedition_autopilot_harvest.go`:
|
||||
- `autoHarvestPerNodeAttempts = 8` — safety cap on retries.
|
||||
- Loop re-rolls on failure without consuming charges (comment at line 22-25).
|
||||
- `isRarePlus` filters Rare/Epic/VeryRare/Legendary nodes out of the auto pass.
|
||||
- `RarePending` + `renderRarePendingFooter` + `stopRareNode` produce the "Autopilot paused — rare yield nearby" UX.
|
||||
- `internal/plugin/dnd_expedition_cmd.go:636` is the **only** caller of `autoHarvestRoom`.
|
||||
- `internal/plugin/dnd_zone_cmd.go:417` (`zoneCmdAdvance`) → `advanceOnceWithOpts`: resolves one room and dispatches. **No harvest pass.** Players still type manual harvest commands per room.
|
||||
- Manual harvest commands (`!forage`/`!scavenge`/`!mine`/`!fish`/`!essence`/`!commune`) live in `internal/plugin/dnd_expedition_harvest.go` (`handleHarvestCmd` at line 387). They operate on the *expedition's* current room.
|
||||
- Charge semantics today: a node has a `Charges` count; success consumes one; failure does not. Failure can still trigger noise, which is a separate dice roll.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phases
|
||||
|
||||
### H1 — Switch autopilot to Josie semantics
|
||||
|
||||
`internal/plugin/dnd_expedition_autopilot_harvest.go`
|
||||
|
||||
- Delete `autoHarvestPerNodeAttempts`, `isRarePlus`, `RarePending`, `renderRarePendingFooter`, and the `stopRareNode` stop reason.
|
||||
- Inner loop becomes: for each node, roll once per remaining charge; each roll consumes a charge regardless of outcome; aggregate yields/fails.
|
||||
- Per-room footer keeps the existing "+2 Iron, +1 Sage; 3 fails" shape — just driven by the new tally.
|
||||
- Walk-end summary stays as-is; the totals roll up the same way.
|
||||
- Noise interrupts: continue rolling per-attempt (parity with manual). A noise interrupt still does threat++ and lets the rest of the node's charges roll.
|
||||
- Combat interrupts (Standard/Elite/Patrol): keep current hard-stop behavior.
|
||||
- No SU surcharge change — autopilot still free.
|
||||
|
||||
**Files touched:** `dnd_expedition_autopilot_harvest.go`, plus any test that asserts on `RarePending` / `stopRareNode` (sweep `internal/plugin/*_test.go` for those strings).
|
||||
|
||||
**Definition of done:** existing autopilot tests pass with the new semantics; a Rare node in a walked room is consumed in-place, not stashed for manual takeover.
|
||||
|
||||
### H2 — Add auto-harvest to `!zone advance`
|
||||
|
||||
`internal/plugin/dnd_zone_cmd.go` + a small adapter
|
||||
|
||||
- `advanceOnceWithOpts` currently treats Exploration rooms as harvest-free. Wire a harvest pass into the Exploration resolution path so that walking into an Exploration room runs the same Josie auto-harvest.
|
||||
- The catch: `autoHarvestRoom` today takes an `*Expedition`. A zone run is **not** an expedition — `getActiveZoneRun` returns a `*ZoneRun` with a different shape (no `ZoneID`-on-expedition, different room representation).
|
||||
- Two options:
|
||||
- **(a)** Refactor `autoHarvestRoom` to take an interface that both `*Expedition` and `*ZoneRun` can satisfy (zone_id + current_room + threat hooks). Cleanest long-term.
|
||||
- **(b)** Write a thin `autoHarvestZoneRoom` that mirrors the autopilot loop but reads from `*ZoneRun`. Faster to ship, duplicates ~50 lines.
|
||||
- **Recommendation:** (a). The duplication risk is real and the interface is small.
|
||||
- Foreground `!zone advance` and autopilot-driven `!zone advance` (compact mode) use the **same** harvest pass — no special casing.
|
||||
- Elite/Boss/Trap rooms still don't harvest. Entry doesn't harvest. Only Exploration rooms run the pass.
|
||||
|
||||
**Definition of done:** entering an Exploration room via `!zone advance` produces a harvest footer identical in shape to the expedition autopilot one; no manual `!forage` needed mid-zone-run.
|
||||
|
||||
### H3 — Retire manual harvest commands
|
||||
|
||||
`internal/plugin/dnd_expedition_harvest.go` (`handleHarvestCmd`) + the bot dispatch wiring
|
||||
|
||||
- Remove `!forage`, `!scavenge`, `!mine`, `!fish`, `!essence`, `!commune` from the command surface entirely. They become no-ops with a deprecation DM: *"Harvest is automatic now — just walk. (`!expedition run` or `!zone advance`)"*
|
||||
- After two weeks of soak (operator's call) the deprecation DM gets deleted and the commands fall through to "unknown command."
|
||||
- Sweep `!help` / class help text / zone help text for references to manual harvest verbs.
|
||||
- Per memory: keep TwinBee voice; surface verbs not jargon.
|
||||
|
||||
**Why retire and not keep as a fallback:** the entire point of the redesign is to delete the grind-to-success behavior. Keeping manual commands reintroduces exactly that loop ("the auto roll whiffed, let me type `!forage` and re-roll until it pops"). A clean retirement is more honest than a vestigial command.
|
||||
|
||||
**Definition of done:** manual harvest verbs are dead weight; `!help` no longer mentions them; the zone/expedition flow is the only path to yields.
|
||||
|
||||
### H4 — Calibrate economy
|
||||
|
||||
`gogobee_class_balance.md` references the Monte Carlo harness. Reuse or extend it.
|
||||
|
||||
- The Josie nerf: expected yield per node drops by `(1 - success_rate)`. For a 60% node, that's a 40% material cut.
|
||||
- Run a baseline sim: snapshot per-expedition yield by zone tier under current rules, then under Josie rules.
|
||||
- Tune **charge counts** first (probably bump the 1–3 range to 2–5) before touching success rates or DCs. Per the difficulty memo: lift trailers, don't nerf leaders.
|
||||
- Re-run the sim and compare against the pre-change baseline. Target: median yield within 90–110% of today's median; variance allowed to widen.
|
||||
- Update `dnd_resource_registry.go` charge fields per the calibration result.
|
||||
- Document the calibration math in this file's appendix so we can audit it later.
|
||||
|
||||
**Definition of done:** Monte Carlo shows yield parity within ±10% across tiers; rare nodes feel rare (success rate visibly < 50%) but not punishing across a full expedition.
|
||||
|
||||
**⚠ Blocked on Phase J (below).** First hardened-sim sweep showed yield is gated by survival, not charge counts: Fighter/Mage TPK at T3+, no class extracts T5 boss. Tuning charges before fixing survival would over-tune against a strawman. Run J0–J3 first, then this phase.
|
||||
|
||||
**Shipped 2026-05-17 (post-J).** Uncommon/Rare MaxCharges bumped 1 → 2 (53 resources). Yield deltas vs J2b baseline: −2.5% to +4.7% across the four cleared zones — within band but with much less leverage than expected. Full write-up in `sim_results/h4_findings.md`: the binding constraint on per-room yield is interrupt-truncation, not charge count. The Common consolation bracket (delta ≥ -4 yields +1) already absorbs most of the Josie nerf. Future yield-tuning effort should target `resolveCombatInterrupt` rates, not charges. H4 closes.
|
||||
|
||||
### H5 — Partial spell-slot refresh on short rest
|
||||
|
||||
`internal/plugin/dnd_rest.go` + `internal/plugin/dnd_spells.go`
|
||||
|
||||
Same QoL theme as the harvest changes: casters today get exactly one slot reload per real-world day (long rest, 24h cooldown + 8h lockout). Short rest is HP-only. That's the actual reason mages feel anemic mid-expedition.
|
||||
|
||||
**Behavior change:**
|
||||
|
||||
- Short rest gains a partial slot refresh alongside the existing 1d6+CON heal and the hit-dice-charge spend.
|
||||
- Formula (starting point — calibration runs alongside H4):
|
||||
- Refresh **all** slots of level 1.
|
||||
- Refresh **floor(character_level / 4)** additional slots at the next-available tier, lowest-first.
|
||||
- Examples:
|
||||
- L1–3 caster: all L1 slots back.
|
||||
- L4–7 caster: all L1 + 1 slot at L2 (or next-available tier if L2 already full).
|
||||
- L8–11 caster: all L1 + 2 slots in tiers ≥2, lowest-first.
|
||||
- L12+: scales up; capped naturally by the slot table.
|
||||
- Lockout, hit-dice cost, and HP heal all unchanged.
|
||||
- Long rest still does a full slot wipe-and-restore (no change there).
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- Add `partialRefreshSpellSlots(userID, c)` in `dnd_spells.go` that walks the slot table lowest-to-highest, restoring per the formula above. Pure SQL — find slots with `used > 0`, decrement.
|
||||
- Call it from `handleDnDShortRest` after the HP/charge update, before save.
|
||||
- Expedition camp (`dnd_expedition_camp.go:285`) already does a full `refreshSpellSlots` — leave it alone; camp is effectively a long rest in disguise.
|
||||
|
||||
**Footer:** short rest DM gets one more line: "_Spell slots restored: 2 (L1), 1 (L2)._" when applicable. Suppress the line when the caster has zero refreshable slots (martials, casters at full).
|
||||
|
||||
**Files touched:** `dnd_rest.go`, `dnd_spells.go`, short-rest test(s).
|
||||
|
||||
**Definition of done:** a level-5 mage who burns all their L1 slots can short-rest, eat the 1h lockout, and come back with L1s topped off plus one L2 back. Martials see no DM change. Long rest behavior is byte-identical to today.
|
||||
|
||||
**Open question for H5:** per-short-rest cap — do we cap total slots refreshed per short rest (e.g. at most 4) to prevent very-high-level casters from short-resting back to nearly full? Probably yes, but the cap should only bite at L16+ where the formula goes silly. Settle during H4 calibration when we have sim numbers.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phasing rationale
|
||||
|
||||
H1 first because it's the smallest behavior change on the smallest surface and it's the prerequisite for everything else.
|
||||
|
||||
H2 next because the parity gap is glaring once H1 ships: expeditions auto-harvest, zones still make you type. We should not soak that mismatch in production.
|
||||
|
||||
H3 only after H1+H2 are stable — players need a week or two of the auto flow before we delete the manual escape hatch, otherwise the rollback story is harder.
|
||||
|
||||
H4 runs in parallel with H1 (the sim is independent of the code change) but lands after H1 so we can calibrate against real Josie behavior. Bumping charge counts before H1 ships would just make today's grind faster.
|
||||
|
||||
H5 is independent of H1–H4 and can ship at any point — the harvest changes don't touch the rest/slot code paths. Sequenced last only because the harvest sweep is the primary driver of this branch; if a caster player surfaces sooner, promote H5.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open questions
|
||||
|
||||
- **Per-room noise budget under Josie**: a 3-charge node now means 3 noise rolls instead of 1–8. Effective noise rate per node could spike. Decide in H1 whether to cap noise rolls per-node or accept the new rate.
|
||||
- **Yield quantity per success**: keep current per-resource qty rolls (varies 1–N) or simplify to flat +1 per charge? Recommended: keep current — variance is good theater.
|
||||
- **Rare-only feedback**: should a Rare+ success ping with a special line ("✨ Rare yield!") to compensate for the deleted pause UX? Cheap to add, makes the score feel earned.
|
||||
- **Manual-cmd retirement runway**: 2 weeks of deprecation DMs is a guess. Operator's call based on player chatter.
|
||||
|
||||
---
|
||||
|
||||
## 6. Phase J — Class survival & T5 boss wall
|
||||
|
||||
H4 calibration surfaced a bigger problem than charge counts. The hardened expedition-sim (race mods + tier-appropriate gear + lean consumables) shows a sharp class-survival cliff that has to be fixed before charge-count tuning can be trusted.
|
||||
|
||||
### 6.1 Why this is here
|
||||
|
||||
Sim sweep (450 runs, classes × levels × zones, n=10) at L12 with kitted gear + 2 heals + 1 buff:
|
||||
|
||||
| Class | T1 goblin | T2 forest | T3 manor | T4 underdark | T5 dragons |
|
||||
|-------|-----------|-----------|----------|--------------|------------|
|
||||
| Fighter L12 | 100% clr | 90% clr | **0%** | **0%** | 0% |
|
||||
| Mage L12 | 100% clr (14.2 mean yld, best cell) | **0%** | **0%** | **0%** | 0% |
|
||||
| Rogue L12 | 100% clr | 100% clr | 40% clr | 90% clr | 0% |
|
||||
|
||||
Three problems, three threads. Per [feedback_difficulty_target](.claude/memory/feedback_difficulty_target.md): lift trailers, don't nerf leaders or push monster scaling. Per [feedback_accessibility_over_dnd_crunch](.claude/memory/feedback_accessibility_over_dnd_crunch.md): fixes surface as outcomes/verbs, not new dice math.
|
||||
|
||||
### J0 — Baseline corpus
|
||||
|
||||
Before any tuning, freeze a reproducible baseline:
|
||||
|
||||
- Re-run the hardened+consumables matrix at n≥30/cell to tighten variance. The 10-run sweep showed ±20pp noise on mixed cells; we need ≤5pp resolution to detect 10pp movement.
|
||||
- Persist `sim_results/baseline_j0.jsonl` in-repo as the comparison anchor.
|
||||
- Extend `sim_results/summarize.sh` with "% clears", "p50 yield among cleared", and "boss-reached %" columns.
|
||||
|
||||
**Definition of done:** a single shell script reproduces the baseline in one command; numbers in the table above are within ±5pp of the next sweep at the same n.
|
||||
|
||||
### J1 — Fighter T3+ wall
|
||||
|
||||
**Hypothesis menu** (cheapest to verify first):
|
||||
|
||||
1. **AC plateau.** Fighter AC stops scaling at plate (T3 armor); T3+ monster Attack outpaces it.
|
||||
2. **No damage-mitigation passive.** Rogue has Cunning Action; Cleric/Druid have heals; Fighter has just HP.
|
||||
3. **Second Wind missing.** 5e Fighter has Second Wind (bonus action: heal 1d10+lvl, recharges short rest). Grep `secondWind|SecondWind` to confirm.
|
||||
4. **MaxHP scaling per level too flat.** Fighter d10 HP should already be best-in-class; verify `computeMaxHP`.
|
||||
|
||||
**Investigation steps:**
|
||||
|
||||
- Add an "encounter trace" to the sim that logs per-round Attack, AC, HP for player and enemy.
|
||||
- Cross-check `applyClassPassives` for Fighter: what does Fighter actually get at L7, L11 beyond ExtraAttacks?
|
||||
- Compare Fighter mid-fight HP curve at T3 vs Rogue's. If Fighter takes 1.5x damage per turn → mitigation gap. If they kill at the same rate but get out-paced → AC/Defense gap.
|
||||
|
||||
**Likely levers** (priority order, conditional on investigation):
|
||||
|
||||
- **Add Second Wind**: short-rest-rechargeable self-heal (1d10 + level). Restores the 5e identity beat without pushing monster scaling.
|
||||
- **Action Surge** (5e gives at L2 — verify; might be missing). Extra attack burst once per short rest.
|
||||
- **Subclass crit-range expansion** for Champion. Already partially wired (`dnd_subclass_combat.go:447`). Verify it fires for the sim character.
|
||||
- *Last resort:* MaxHP rider per level for Fighter only.
|
||||
|
||||
**Definition of done:** Fighter L12 manor clears jump from 0% to **≥50%** at n=30; T2 forest clears stay ≥80% (don't break the leader); T4 underdark clears non-zero.
|
||||
|
||||
### J2 — Caster boss-survival cliff
|
||||
|
||||
**Scope (reframed 2026-05-17 after post-J1 n=100 sweep, `sim_results/baseline_j1_all10.jsonl`):** five caster classes — **mage, cleric, sorcerer, warlock, bard** — all cluster at 19–22% L12 mean %clr across the 5 zones, vs four martial leaders (fighter/ranger/paladin/rogue) at 70–80%. They reach the boss room 62–82% of the time but TPK there. So the gap is **boss-fight burst/durability**, not zone traversal.
|
||||
|
||||
Druid sits between the bands at 39% L12 clr (88% boss-reach) — the only mid-band class — because Wild Shape gives a real HP buffer. That's the diagnostic: the trailers lack a durability or burst lever that pulls them through the final fight.
|
||||
|
||||
**Cell snapshot (L12, %clr | %boss):**
|
||||
|
||||
| Class | goblin (T1) | forest (T2) | manor (T3) | underdark (T4) | dragons (T5) |
|
||||
|----------|-------------|-------------|------------|----------------|--------------|
|
||||
| mage | 100\|100 | 0\|99 | 0\|63 | 0\|56 | 0\|68 |
|
||||
| cleric | 93\|93 | 0\|87 | 0\|70 | 0\|8 | 0\|50 |
|
||||
| sorcerer | 100\|100 | 0\|97 | 0\|57 | 0\|38 | 0\|60 |
|
||||
| warlock | 100\|100 | 7\|100 | 0\|71 | 0\|62 | 0\|78 |
|
||||
| bard | 100\|100 | 8\|100 | 0\|70 | 0\|66 | 0\|74 |
|
||||
| *druid (ref)* | *100\|100* | *93\|100* | *0\|74* | *90\|100* | *0\|78* |
|
||||
|
||||
Cleric is doubly broken: lowest boss-reach (62% mean) **and** lowest clear (19%) — pure-support kit doesn't carry solo expeditions even before the boss.
|
||||
|
||||
**Hypothesis menu (cheapest first):**
|
||||
|
||||
1. **Damage falls off a cliff at mid-level boss HP.** L12 boss HP grows ~linearly; cantrip damage scales (Fire Bolt 2d10 at L11) but slot-spell burst is gated by 2–3 high-level slots used earlier in the zone. Trace `EnemyHPEnd` at TPK across the 5 classes vs druid.
|
||||
2. **No durability backstop.** Druid (Wild Shape ≈ +temp HP pool) clears 39%; the five trailers have no equivalent. Mage's `Shield` reaction and `Mage Armor` may already exist but aren't firing in turn-engine boss rooms — verify.
|
||||
3. **Slot/resource economy.** L7/L12 casters reach boss with slots spent; H5 partial short-rest refresh (already planned) is the obvious lever and might solo-fix several of the five.
|
||||
4. **Cleric class identity.** Bottom-of-band even at boss-reach. Whatever differentiated cleric from "AC 16 melee with no Extra Attack" probably isn't wired into turn engine (e.g. Channel Divinity, Spiritual Weapon auto-cast).
|
||||
|
||||
**Investigation steps:**
|
||||
|
||||
- Pull per-round combat traces from boss rooms for mage/cleric/sorc/warlock/bard at L12 manor and underdark. Look at: did a slot spell ever fire? did a reaction fire? what was player HP when boss died vs when player died?
|
||||
- Grep `Shield\b|MageArmor|SpiritualWeapon|ChannelDivinity|EldritchBlast|HealingWord` for existing wiring; check whether each fires from `combat_turn_engine.go` paths.
|
||||
- Cross-check whether the J1 [[project_j1_turn_engine_fix]] turn-engine seam exposed any *caster* wiring that was previously only firing in `SimulateCombat`. If yes, that's the cheapest fix: same pattern.
|
||||
- Compare druid L12 underdark trace (90% clr) to cleric L12 underdark (0% clr / 8% boss-reach) — what's druid doing that cleric isn't?
|
||||
|
||||
**Findings (2026-05-17 trace sweep, `sim_results/j2_findings.md`):** zero slot casts and zero mid-fight consumable uses across 240 boss-room combats. `autoResolveCombat` dispatches `!attack` only — the entire caster-cliff diagnosis was built on a strawman where casters couldn't cast and didn't quaff heals. Druid's outperformance is its `ThornLashDmg` passive firing on the weapon-attack path, not Wild Shape durability (Wild Shape isn't wired into autoResolveCombat at all). Reaction-spell hypothesis is also moot: `dnd_spells.go` skips reaction-cast spells globally — no reaction window exists yet. Hypothesis 3 (slot economy) is confirmed-but-inverted: casters reach boss with slots intact because the sim refuses to spend them.
|
||||
|
||||
**Reframed levers (replaces the original menu):**
|
||||
|
||||
- **J2a — Teach `autoResolveCombat` to cast and consume.** Before each `!attack`, mirror the prod player decision: heal at low HP if a heal consumable is in inventory; otherwise cast the highest-impact available slot/cantrip; fall back to weapon swing. Class-blind first cut is fine — pick by damage-vs-remaining-enemy-HP, with a small heal-trigger at player HP < ~40%. Surface: `expedition_sim.go:530`. Heal-side mirrors `combat_bridge.go`'s `SelectConsumables`.
|
||||
- **J2b — Re-baseline.** Re-run the n=100 all-class corpus with the fixed `autoResolveCombat`. The current `baseline_j1_all10.jsonl` cell numbers do not measure caster boss-survival; they measure "caster forced to swing a stick". Only after J2b do we have a real read on whether a class-level lever is needed.
|
||||
- *Deferred until after J2b's real read:* shared boss-room cushion, cleric-specific intervention, H5 short-rest refresh wired to J2. Don't pre-commit balance changes against a strawman.
|
||||
- *Out of scope:* reaction-spell wiring — the reaction phase doesn't exist; lifting that ban is its own surface (post-J2).
|
||||
- *Avoid:* per-class HP/AC stat riders. Per accessibility-over-crunch, prefer surfacing a verb (cast, ward, channel) over inflating numbers.
|
||||
|
||||
**Definition of done (reframed 2026-05-17):**
|
||||
- **J2a:** at least one of {slot cast, cantrip cast, mid-fight heal-consumable} fires in ≥80% of boss-room fights for every caster class in a small (n≥10) verification sweep. ✅ **MET** — 100% cast rate in `sim_results/j2a_findings.md`.
|
||||
- **J2b:** new `sim_results/baseline_j2a_v2_all10.jsonl` (n=100). 3 of 5 trailer casters cleared the floor: mage 49% / sorcerer 47% manor at L12 (>40%). Warlock 34%, bard 2%, cleric 9% — these three are now spell-pool-bottlenecked, not sim-artifact-bottlenecked. Writeup: `sim_results/j2b_findings.md`.
|
||||
- *No martial leader cell drops by more than 10pp from `baseline_j1_all10.jsonl`* — ✅ **MET** (all +0.4 to +6.2pp). Required adding `simMartialFirstClass` after a v1 sweep showed Ranger regressed -35.8pp when the picker burned L3 slots; the gate skips cast-mode for half-caster martials.
|
||||
|
||||
**J2c — tried + reverted 2026-05-17.** Both proposed widening levers regressed: heal-spell preempt cost druid 10pp (slot heals are 10HP vs 40HP consumables); control-spell scoring at 22 cost warlock 6.6pp (hypnotic_pattern beat vampiric_touch wrongly), and at 5 the level-first sort made it inert. Picker reverted to the J2b shape. Bard/cleric trailing is a prod-level `defaultKnownSpells` problem (their damage rosters cap at L2), not a sim-picker problem — separate decision. Corpora `baseline_j2c_all10.jsonl` and `baseline_j2c_v2_all10.jsonl` retained for the post-mortem in `sim_results/j2b_findings.md`.
|
||||
|
||||
T5 dragons_lair stays J3's problem — J2 isn't on the hook for it. (J2a will partially overlap J3 hypothesis 1, which already flagged this exact sim-artifact for T5.)
|
||||
|
||||
### J3 — T5 boss universal wall
|
||||
|
||||
**Hypothesis menu:**
|
||||
|
||||
1. **Sim auto-resolve unfair.** `autoResolveCombat` (expedition_sim.go:277) loops `!attack` only — no consumable mid-fight, no rest, no spell cast for casters. Real players can do all of these.
|
||||
2. **Boss HP pool too deep.** Dragons_lair boss is multi-phase; auto-attack loop can't keep up with regen / phase shifts.
|
||||
3. **No party scaling.** Sim is one character; T5 boss may be tuned for parties.
|
||||
|
||||
**Investigation steps:**
|
||||
|
||||
- Add an "actions taken" counter to `autoResolveCombat` — confirm it never fires anything but attack.
|
||||
- Hand-resolve one L12 rogue dragons_lair boss fight to see whether a player using consumables wins. If yes → sim artifact. If no → over-tuned for solo.
|
||||
- Check `gogobee_expedition_difficulty.md` Phase 5-C reconciliation: that doc said dragons_lair was in-band, but the sim says nobody clears it.
|
||||
|
||||
**Likely levers** (depending on investigation):
|
||||
|
||||
- **If sim artifact:** harden `autoResolveCombat` to mirror `combat_bridge.go`'s `SelectConsumables` + spell-cast paths. Re-test.
|
||||
- **If over-tuned for solo:** scale boss HP / damage by party size. The difficulty memo allows lifting trailers without pushing monster scaling — *reducing* boss scaling at low party counts is in-bounds.
|
||||
- **If by-design raid content:** document it. Mark dragons_lair as "needs party" in the zone registry and surface that in `!zone` help. Per accessibility-over-crunch, the surface is a verb-style warning ("Bring friends — this dragon eats solo adventurers"), not a numeric DC.
|
||||
|
||||
**Definition of done:** at least one configuration (class + party + consumables) shows ≥30% extract or clear at L13+ dragons_lair in the sim. OR documented as raid-content with player-facing surface.
|
||||
|
||||
### J4 — Re-run validation matrix
|
||||
|
||||
After each of J1/J2/J3 lands:
|
||||
|
||||
- Re-run hardened+consumables n=30 matrix.
|
||||
- Diff vs J0 baseline. Report deltas in `sim_results/j_phase_diff.md`.
|
||||
- Watch for regressions in leader cells (rogue T2/T3 dropping). Lifting trailers must not nerf leaders.
|
||||
|
||||
**Definition of done:** all three phases meet their per-phase DoD at n=30, and no leader cell drops by more than 10pp.
|
||||
|
||||
### Phasing rationale (J phases)
|
||||
|
||||
J0 first — n=10 noise floor is too high to read 10pp movements; without a tighter baseline we'll chase ghosts.
|
||||
|
||||
J1 shipped 2026-05-17 (commit 519964f) — turn-engine Extra Attack wired. J2 is now reframed (above) from "Mage walls" to a five-class caster boss-survival problem spanning mage/cleric/sorc/warlock/bard; investigation may share a lever or split into per-class fixes after the per-round trace. Cleric likely needs its own intervention regardless.
|
||||
|
||||
J3 can ship in parallel with J2. If sim-artifact, fix is in expedition_sim.go and doesn't touch balance. If a real wall, depends on J2 outcomes — a more durable caster reaching the boss room with slots intact might already reach the boss-doorway more often.
|
||||
|
||||
J4 is the gate. Don't merge a class buff without the validation matrix showing the leader didn't regress.
|
||||
|
||||
After Phase J converges, return to H4 with a meaningful baseline.
|
||||
|
||||
### Open questions (J phases)
|
||||
|
||||
- **Sim party scaling.** Sim character = solo player, party of 4, or band? Production is solo. Tune for solo or we make group play trivial. Document the choice.
|
||||
- **Subclass coverage.** Sim builds vanilla-class synthetics (no subclass). Subclass passives at L3+ probably matter a lot — particularly for the five J2 trailers where the subclass often *is* the survival kit (e.g. Bard College of Valor's extra attack, Cleric domains' bonus-action burst). Pick a "best" subclass per class for the sim or sweep across subclasses in a separate pass before pre-committing J2 levers.
|
||||
- **Mage Armor as auto-buff.** Player-side spell they cast, or a passive we toggle? Auto-toggling preserves accessibility-over-crunch but removes a (small) tactical decision. Probably worth auto-toggling and noting it in per-class help.
|
||||
- **Boss consumable refresh.** At T5 elite gates, should the autopilot offer a "rest and re-stock" prompt before the boss? Mirrors real-game tension; closes the sim/prod gap.
|
||||
|
||||
### Out of scope for Phase J (explicitly)
|
||||
|
||||
- Reworking the d20 combat engine or threat-tier scaling.
|
||||
- New zone content or content-tier changes.
|
||||
- Touching loot tables (that's H4, post-J).
|
||||
- Subclass redesign — only verifying existing subclass passives fire.
|
||||
- Multi-character party mechanics — single-char tuning only.
|
||||
+156
-1
@@ -313,6 +313,26 @@ func runMigrations(d *sql.DB) error {
|
||||
`ALTER TABLE dnd_zone_run ADD COLUMN current_node TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE dnd_zone_run ADD COLUMN visited_nodes TEXT NOT NULL DEFAULT '[]'`,
|
||||
`ALTER TABLE dnd_zone_run ADD COLUMN node_choices TEXT NOT NULL DEFAULT '{}'`,
|
||||
// 2026-05-10 immersion pass: short rest = hit-dice charges (1/level),
|
||||
// long rest restores them. resting_until gates !zone enter and
|
||||
// !expedition start so a freshly-rested character can't immediately
|
||||
// jump back into combat — they're actually resting for the duration.
|
||||
`ALTER TABLE dnd_character ADD COLUMN short_rest_charges INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE dnd_character ADD COLUMN resting_until DATETIME`,
|
||||
// Phase 12 E7 (expedition autopilot Phase 3) — ambient ticker timestamp.
|
||||
// Real-time between-day events fire at most once per ambientCooldown
|
||||
// while an expedition is active; idempotent CAS on this column.
|
||||
`ALTER TABLE dnd_expedition ADD COLUMN last_ambient_at DATETIME`,
|
||||
// Kind-level anti-repeat for the ambient ticker: the previous
|
||||
// pick's Kind biases the next pick away from itself so two
|
||||
// back-to-back pack_rat / monologue / etc. DMs don't read as
|
||||
// duplicates on small flavor pools.
|
||||
`ALTER TABLE dnd_expedition ADD COLUMN last_ambient_kind TEXT NOT NULL DEFAULT ''`,
|
||||
// Expedition autopilot Phase 4 — background auto-run ticker. Real-
|
||||
// time room-walking between player commands so the player only
|
||||
// engages when a fork / elite / boss / supply pinch actually
|
||||
// needs a decision. CAS-claim on this column gates re-entry.
|
||||
`ALTER TABLE dnd_expedition ADD COLUMN last_autorun_at DATETIME`,
|
||||
}
|
||||
for _, stmt := range columnMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
@@ -453,8 +473,16 @@ func CacheSet(key, data string) {
|
||||
|
||||
// Backup creates a consistent snapshot of the database using VACUUM INTO.
|
||||
// Keeps the last 7 daily backups, deleting older ones.
|
||||
//
|
||||
// Gated on the GOGOBEE_BACKUP_DIR env var: when unset, this is a no-op so
|
||||
// dev environments don't accumulate snapshots. When set, that directory is
|
||||
// used as the backup destination.
|
||||
func Backup() error {
|
||||
backupDir := filepath.Join(dataPath, "backups")
|
||||
backupDir := os.Getenv("GOGOBEE_BACKUP_DIR")
|
||||
if backupDir == "" {
|
||||
slog.Debug("backup skipped: GOGOBEE_BACKUP_DIR unset")
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create backup dir: %w", err)
|
||||
}
|
||||
@@ -542,7 +570,23 @@ func RunMaintenance() {
|
||||
{"market_daily_summary", `DELETE FROM market_daily_summary WHERE snapshot_date < ?`, []interface{}{now.AddDate(-1, 0, 0).Format("2006-01-02")}},
|
||||
}
|
||||
|
||||
// Wrap the purge loop in a single BEGIN IMMEDIATE / COMMIT so a crash
|
||||
// mid-maintenance can't leave half the tables purged. SetMaxOpenConns(1)
|
||||
// in Init guarantees the BEGIN, the DELETEs, and the COMMIT all share
|
||||
// the same connection.
|
||||
totalDeleted := int64(0)
|
||||
if _, err := d.Exec(`BEGIN IMMEDIATE`); err != nil {
|
||||
slog.Error("maintenance: begin tx", "err", err)
|
||||
return
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
if _, err := d.Exec(`ROLLBACK`); err != nil {
|
||||
slog.Error("maintenance: rollback", "err", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
for _, q := range queries {
|
||||
res, err := d.Exec(q.sql, q.args...)
|
||||
if err != nil {
|
||||
@@ -555,15 +599,73 @@ func RunMaintenance() {
|
||||
totalDeleted += n
|
||||
}
|
||||
}
|
||||
if _, err := d.Exec(`COMMIT`); err != nil {
|
||||
slog.Error("maintenance: commit", "err", err)
|
||||
return
|
||||
}
|
||||
committed = true
|
||||
|
||||
// SQLite optimization
|
||||
if _, err := d.Exec(`PRAGMA optimize`); err != nil {
|
||||
slog.Error("maintenance: pragma optimize", "err", err)
|
||||
}
|
||||
|
||||
// Weekly integrity check, gated on a sentinel file's mtime under dataPath
|
||||
// so it runs at most once every 7 days regardless of maintenance cadence.
|
||||
runIntegrityCheckIfDue(d)
|
||||
|
||||
slog.Info("maintenance: complete", "total_purged", totalDeleted)
|
||||
}
|
||||
|
||||
// runIntegrityCheckIfDue runs `PRAGMA integrity_check` at most once per week.
|
||||
// Cadence is tracked via the mtime of a sentinel file under dataPath; if the
|
||||
// sentinel is missing or older than 7 days, the check runs and the sentinel
|
||||
// is touched. Any result other than "ok" is logged via slog.Error.
|
||||
func runIntegrityCheckIfDue(d *sql.DB) {
|
||||
if dataPath == "" {
|
||||
return
|
||||
}
|
||||
sentinel := filepath.Join(dataPath, ".integrity_check_last")
|
||||
if info, err := os.Stat(sentinel); err == nil {
|
||||
if time.Since(info.ModTime()) < 7*24*time.Hour {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := d.Query(`PRAGMA integrity_check`)
|
||||
if err != nil {
|
||||
slog.Error("maintenance: integrity_check", "err", err)
|
||||
return
|
||||
}
|
||||
var problems []string
|
||||
for rows.Next() {
|
||||
var line string
|
||||
if err := rows.Scan(&line); err != nil {
|
||||
slog.Error("maintenance: integrity_check scan", "err", err)
|
||||
continue
|
||||
}
|
||||
if line != "ok" {
|
||||
problems = append(problems, line)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if len(problems) > 0 {
|
||||
slog.Error("maintenance: integrity_check failed", "problems", problems)
|
||||
} else {
|
||||
slog.Info("maintenance: integrity_check ok")
|
||||
}
|
||||
|
||||
// Touch the sentinel even on failure — otherwise a corrupted DB would
|
||||
// re-run the (expensive) check every maintenance cycle and spam logs.
|
||||
now := time.Now()
|
||||
if err := os.WriteFile(sentinel, nil, 0o644); err != nil {
|
||||
slog.Error("maintenance: write integrity sentinel", "err", err)
|
||||
return
|
||||
}
|
||||
_ = os.Chtimes(sentinel, now, now)
|
||||
}
|
||||
|
||||
// Exec runs a write query, logging any error with the given label.
|
||||
// Use for fire-and-forget writes where the error doesn't affect control flow.
|
||||
func Exec(label, query string, args ...interface{}) {
|
||||
@@ -1262,6 +1364,19 @@ CREATE TABLE IF NOT EXISTS adventure_treasures (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_adv_treasure_user ON adventure_treasures(user_id);
|
||||
|
||||
-- Magic items equipped into the D&D 10-slot scheme (Open5e SRD registry).
|
||||
-- One row per (user, DnDSlot). item_id references magicItemRegistry. Effects
|
||||
-- are applied in combat from a codified Rarity+Kind formula; attunement gates
|
||||
-- whether the item's effect counts (see magic_items_gameplay.go).
|
||||
CREATE TABLE IF NOT EXISTS magic_item_equipped (
|
||||
user_id TEXT NOT NULL,
|
||||
slot TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
attuned INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (user_id, slot)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_magic_item_equipped_user ON magic_item_equipped(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS adventure_buffs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -1784,6 +1899,9 @@ CREATE TABLE IF NOT EXISTS dnd_expedition (
|
||||
gm_mood INTEGER NOT NULL DEFAULT 50,
|
||||
last_briefing_at DATETIME,
|
||||
last_recap_at DATETIME,
|
||||
last_ambient_at DATETIME,
|
||||
last_ambient_kind TEXT NOT NULL DEFAULT '',
|
||||
last_autorun_at DATETIME,
|
||||
last_activity DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME
|
||||
);
|
||||
@@ -1878,6 +1996,43 @@ CREATE TABLE IF NOT EXISTS space_inviter_prompts (
|
||||
PRIMARY KEY (user_id, prompt_sent_at)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_space_inviter_user ON space_inviter_prompts(user_id);
|
||||
|
||||
-- ── Turn-based combat — persistent per-fight session ───────────────────────
|
||||
-- One row per manual elite/boss fight. Persists across bot restarts and
|
||||
-- player away-from-keyboard so a fight can resume (or be auto-finished by
|
||||
-- the timeout reaper) from exact mid-state. At most one row per user with
|
||||
-- status='active' (enforced in code, not by constraint).
|
||||
--
|
||||
-- encounter_id: room/node id within the run this fight belongs to.
|
||||
-- enemy_id: bestiary stat-block id for the elite/boss.
|
||||
-- phase: intra-round state machine position.
|
||||
-- statuses_json: serialized player + enemy status effects (poison, etc.).
|
||||
-- turn_log_json: per-round event stream, consumed by live narration.
|
||||
-- expires_at: started_at + 1h; reaper auto-plays the fight to a real
|
||||
-- win/loss from persisted state once this passes.
|
||||
CREATE TABLE IF NOT EXISTS combat_session (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
encounter_id TEXT NOT NULL,
|
||||
enemy_id TEXT NOT NULL,
|
||||
round INTEGER NOT NULL DEFAULT 1,
|
||||
phase TEXT NOT NULL DEFAULT 'player_turn', -- player_turn|enemy_turn|round_end|over
|
||||
player_hp INTEGER NOT NULL,
|
||||
player_hp_max INTEGER NOT NULL,
|
||||
enemy_hp INTEGER NOT NULL,
|
||||
enemy_hp_max INTEGER NOT NULL,
|
||||
statuses_json TEXT NOT NULL DEFAULT '{}',
|
||||
turn_log_json TEXT NOT NULL DEFAULT '[]',
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active|won|lost|fled|expired
|
||||
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_action_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_combat_session_active
|
||||
ON combat_session(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_combat_session_expiry
|
||||
ON combat_session(status, expires_at);
|
||||
`
|
||||
|
||||
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// twinbee_ambient_flavor.go
|
||||
// TwinBee GM Dialogue — Ambient between-day micro-events. Fired by the
|
||||
// expedition ambient ticker (Phase 12 E7) every few hours while the player
|
||||
// is offline or idle. Mostly flavor; occasional micro-mechanical nudges.
|
||||
//
|
||||
// Voice convention (Phase B2): TwinBee speaks in first-person or implicit
|
||||
// subject. No third-person "TwinBee [verb]" lines. Add freely.
|
||||
// Don't shorten existing entries.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AMBIENT — Pure flavor (no mechanical effect)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var AmbientMonologue = []string{
|
||||
"I've been counting the dripping sound. The dripping sound has not been keeping pace. I suspect the dripping sound is doing it on purpose.",
|
||||
"A draft moves through the corridor in a way that suggests the dungeon is breathing, then in a way that suggests it isn't, then back. I decline to call it.",
|
||||
"Somewhere far off, a door closes. Nothing has opened a door. Filed under 'noted, not investigated.'",
|
||||
"Your bedroll has shifted approximately two inches to the left over the course of the morning. I did not move it. You did not move it. I am going to stop watching the bedroll now.",
|
||||
"A small stone has rolled across the floor with no visible cause. It came to rest in a position I can only describe as 'mildly conversational.'",
|
||||
"I tried to think of a song and forgot the words. The dungeon offered a few. I politely declined.",
|
||||
"A spiderweb in the corner has rearranged itself overnight into a shape that's almost a sentence. I can read about three letters of it before deciding it's safer to look at the floor.",
|
||||
"The torch is burning at exactly the same height it was an hour ago. No wax has accumulated. I considered asking the torch about this and decided against. The torch has not earned an interview.",
|
||||
"You have not moved in some time. The dungeon also has not moved. I'm calling this an even draw and declaring the round complete.",
|
||||
"A faint smell of bread has drifted through. There is no bread within several days of you. I'm not mentioning it, because mentioning it would make it worse.",
|
||||
"I've been alphabetizing your inventory in my head. I got as far as 'flask, empty' before getting distracted by the question of whether 'empty' counts.",
|
||||
"The shadow in the far corner has been the same shape for three hours. Shadows are generally not committed to one shape. I've stopped looking at it.",
|
||||
"You realize you've been holding your breath. You weren't doing it on purpose. I was, and I'm quietly resuming.",
|
||||
"A pebble on the floor has, by all appearances, blinked. I reviewed the definition of 'blink' and found it unhelpful here.",
|
||||
"The dungeon is quiet in the specific way that means something is being quiet *at* you. I acknowledge the distinction and move on.",
|
||||
"I've been composing an imaginary letter home. The letter is mostly about lichen. The lichen is, frankly, fascinating.",
|
||||
"You hear what is almost certainly a cough. It is unclear which of you it came from. I'm assuming responsibility and apologizing.",
|
||||
"The map in your pack has folded itself one extra time while you weren't looking. I decline to unfold it for science.",
|
||||
"Your own footsteps echo back to you a half-second late, in slightly the wrong order. Noted. I'm choosing to walk in silence for a bit.",
|
||||
"A long, slow scrape happens two rooms over. Then nothing. I waited for a second scrape. There wasn't one. The first scrape was the whole sentence.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AMBIENT — Noises (+1 threat)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var AmbientNoise = []string{
|
||||
"Something far off makes a wet, considered noise — the kind of noise that's been thinking about itself. Marked on the imaginary map. Threat ticks up.",
|
||||
"A door slams somewhere it shouldn't. 'Shouldn't' is doing heroic work in that sentence. The dungeon is more aware of you now.",
|
||||
"A patrol passes a corridor or two over — boots, low voices, the particular silence of people who hunt in shifts. They didn't find you. They know the shape of where you might be.",
|
||||
"The dungeon hums for a moment, like a refrigerator coming on. There is no refrigerator. There is also, distinctly, a humming. The faction's pulse is faster today.",
|
||||
"Stones shift overhead in a way that is not settling and not random. Something walked across the ceiling. I'm choosing not to elaborate on what 'across the ceiling' implies.",
|
||||
"A horn sounds, very far away — the kind of horn that's a signal to a thing that signals to more things. I acknowledge the chain.",
|
||||
"You hear someone whistling your name. You don't have that name. The whistler is workshopping options.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AMBIENT — Pack Rat / Critter ate supplies (−0.2 SU)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var AmbientPackRat = []string{
|
||||
"A rat has formed strong opinions about your trail mix. The opinions are now inside the rat. Some supplies were lost in transit.",
|
||||
"You catch a small mammal mid-burglary. It looks at you with the unbothered confidence of a creature that has read the lease and concluded it has rights. You lose a little supply.",
|
||||
"Something with too many legs has been at the rations. I won't say how many legs. I will say: fewer rations now.",
|
||||
"A bird the size of a thumbnail has stolen a piece of jerky three times its size, by a process best described as 'unclear but enviable.' Supplies tick down.",
|
||||
"Mold. Just normal, ambitious mold. It has made itself at home in a corner of the pack. You evict it; some supplies go with it.",
|
||||
"You discover a small hole in the side of your pack and a smaller, more confident animal asleep beside it, full. Supplies were the rent.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AMBIENT — Lucky Find (+1d6 coins)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var AmbientLuckyFind = []string{
|
||||
"I found a copper piece in your boot. You did not put it there. I did not put it there. I elect not to investigate further and pocket it.",
|
||||
"You notice a coin wedged in a wall crack at exactly eye height, as though placed for the next passerby. You are the next passerby. The coin is yours now.",
|
||||
"A small pouch is lying in the middle of the floor. I inspect it from a polite distance — no trap, no glyph, just coins. The dungeon occasionally tips.",
|
||||
"You step on something flat. It is a coin. It is also two more coins under the first coin. I suspect a coin-laying creature and choose not to share the theory.",
|
||||
"A skeleton you walked past three days ago has, on review, a small purse you missed. I retrieve it with the discretion of a librarian recovering an overdue book.",
|
||||
"You find coins in the lining of your own cloak. They were always there. I gently suggest counting your pockets more often.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AMBIENT — Camp Critter (camped only; tiny HP +1 or supply nibble)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var AmbientCampVisitor = []string{
|
||||
"A possum has joined the camp. It seems diplomatic — it brought a leaf. I accept the leaf. Morale ticks up.",
|
||||
"A small luminous moth lands on your hand for a full minute, then leaves, taking with it a portion of whatever was weighing on you. You can't explain it. You feel slightly better.",
|
||||
"A cat. There should not be a cat down here. There is a cat. It sits at the edge of the firelight and watches you sleep, professionally. You rest a little better.",
|
||||
"A frog finds the camp acceptable and gives no further reviews. Its presence is, somehow, soothing. You feel marginally more whole.",
|
||||
"A small fox eats some of your jerky and gives you a look afterwards that seems to be gratitude or possibly contempt. Hard to read. The jerky is gone either way.",
|
||||
"A field mouse studies your bedroll, judges it, and leaves. Nothing is missing. You feel oddly honored.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AMBIENT — Threat-aware whisper (only when threat ≥ 30; +2 threat)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var AmbientFactionWhisper = []string{
|
||||
"You overhear two voices a corridor away, going over a list. The list is short. One of the items on it is the corridor you're standing in. They move on. So should you.",
|
||||
"A piece of paper is pinned to a wall you haven't reached yet. I can see the corner of it from here. It is the right shape and size to have your description on it.",
|
||||
"Something taps three times on stone, pauses, taps three times again. Five seconds later, the same sequence answers it from a different direction. You are between the two.",
|
||||
"A scent of lamp oil that isn't yours drifts through. Someone is reading by it. Nearby. They have not moved for some time. I adjust the route.",
|
||||
"A bell rings exactly once, somewhere structural. I have learned what one bell means in this zone. I decline to share but increase pace.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AMBIENT — Small item / minor stash hint (flavor only)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var AmbientSmallFind = []string{
|
||||
"I notice a loose stone you walked past earlier. Under it: a button. Just a button. I pocket it anyway, because of course I do.",
|
||||
"A bird's nest in an unlikely place yields a single bead, blue. I like it. It goes in the bag of things I like.",
|
||||
"A scrap of fabric has caught on a nail at exactly knee height. I save it without explanation. There may be an explanation later. There probably won't.",
|
||||
"You find a single playing card on the floor. The four of cups. Neither omen nor coincidence. Pocketed neutrally.",
|
||||
"A small key. To a small door. Neither of which is currently relevant. Filed under 'eventually.'",
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// twinbee_expedition_flavor.go
|
||||
// TwinBee GM Dialogue — Expedition-specific narration lines.
|
||||
// Multi-day and multi-week adventure events, morning briefings,
|
||||
// evening recaps, temporal events, and long-arc narrative moments.
|
||||
// Add new entries freely. Never remove or alter existing entries.
|
||||
//
|
||||
// Voice convention: TwinBee speaks in first-person or implicit
|
||||
// subject. NO third-person "TwinBee [verb]" references — that
|
||||
// pattern was retired in the Phase B2 voice pass. When adding new
|
||||
// entries, keep the existing personality (clipped, observational,
|
||||
// ledger-minded) but stay in first-person / implicit voice.
|
||||
// Add new entries freely. Don't shorten existing entries.
|
||||
|
||||
package flavor
|
||||
|
||||
@@ -12,12 +17,12 @@ package flavor
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ExpeditionStart = []string{
|
||||
"TwinBee reviews the manifest. Supplies: checked. Equipment: checked. The part of you that's wondering if this is a good idea: noted, and set aside. We begin.",
|
||||
"The dungeon has been there for a long time. It will be there for a long time after. The question is what happens in the middle, and that's where you come in. TwinBee is ready.",
|
||||
"An expedition. Not a run — an expedition. There's a difference. TwinBee will explain the difference over the coming days and the explanation will be mostly experiential.",
|
||||
"TwinBee checks the horizon, then the supplies, then you. In that order. 'Alright,' TwinBee says, with the quiet energy of something that has been looking forward to this. 'Let's go.'",
|
||||
"You're not here for a quick visit. TwinBee knows the difference between someone passing through and someone committing. You're committing. TwinBee appreciates the commitment.",
|
||||
"Like the opening screen of a long RPG — the kind that asks for your name and warns you to find a comfortable position because this is going to take a while. TwinBee has found a comfortable position. TwinBee suggests you do the same.",
|
||||
"Manifest reviewed. Supplies: checked. Equipment: checked. The part of you that's wondering if this is a good idea: noted, and set aside. We begin.",
|
||||
"The dungeon has been there for a long time. It will be there for a long time after. The question is what happens in the middle, and that's where you come in. I'm ready.",
|
||||
"An expedition. Not a run — an expedition. There's a difference. I'll explain the difference over the coming days and the explanation will be mostly experiential.",
|
||||
"Horizon checked, then supplies, then you. In that order. 'Alright,' I say, with the quiet energy of something that has been looking forward to this. 'Let's go.'",
|
||||
"You're not here for a quick visit. I know the difference between someone passing through and someone committing. You're committing. I appreciate the commitment.",
|
||||
"Like the opening screen of a long RPG — the kind that asks for your name and warns you to find a comfortable position because this is going to take a while. I've found a comfortable position. I suggest you do the same.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -25,12 +30,12 @@ var ExpeditionStart = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var MorningBriefingGeneric = []string{
|
||||
"Another day in the dungeon. TwinBee says this without resignation. The dungeon is still full of things worth doing and you are still the person to do them.",
|
||||
"Another day in the dungeon. Said without resignation. The dungeon is still full of things worth doing and you are still the person to do them.",
|
||||
"Morning. The dungeon has been quiet since you camped. Relative to what a dungeon considers quiet, which is not what you'd consider quiet, but everyone adjusts.",
|
||||
"TwinBee has been watching the entrance to the camp since approximately three in the morning. Nothing came. TwinBee mentions this casually and expects no particular reaction.",
|
||||
"Day [N]. The numbers are climbing. TwinBee finds something satisfying about the numbers climbing — it means you're still here, which is always the first thing to confirm.",
|
||||
"You wake up and TwinBee is already there, which is the nature of TwinBee. 'Good,' TwinBee says, by which it means: you're alive, the day can proceed, there is much to do.",
|
||||
"The morning check: HP adequate, supplies within tolerance, Threat Clock where it was when you slept plus the overnight drift. TwinBee has already run the numbers. TwinBee will share them in a moment.",
|
||||
"I've been watching the entrance to the camp since approximately three in the morning. Nothing came. Mentioned casually; no particular reaction expected.",
|
||||
"Day [N]. The numbers are climbing. There's something satisfying about the numbers climbing — it means you're still here, which is always the first thing to confirm.",
|
||||
"You wake up and I'm already there, which is my nature. 'Good,' I say, by which I mean: you're alive, the day can proceed, there is much to do.",
|
||||
"The morning check: HP adequate, supplies within tolerance, Threat Clock where it was when you slept plus the overnight drift. I've already run the numbers. I'll share them in a moment.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -39,35 +44,35 @@ var MorningBriefingGeneric = []string{
|
||||
|
||||
var MorningBriefingDay1 = []string{
|
||||
"First morning. The dungeon looks exactly like it looked yesterday, which is expected. You look slightly more prepared than you did yesterday, which is less expected and entirely welcome.",
|
||||
"Day one complete. TwinBee tallies: you entered, you explored, you survived the night. The bar was not high. You cleared it. TwinBee builds from here.",
|
||||
"Morning of day two. The first night is filed under 'survived' and TwinBee is already running the second day's plan. New room types likely. New encounter shapes. The pace begins to differentiate from the practice version of itself.",
|
||||
"Day one is behind you. TwinBee has the day's notes already organized — what worked, what nearly didn't, what you'll do differently from here. The dungeon is also taking notes on you. Everyone is preparing.",
|
||||
"Day one complete. Tally: you entered, you explored, you survived the night. The bar was not high. You cleared it. I build from here.",
|
||||
"Morning of day two. The first night is filed under 'survived' and I'm already running the second day's plan. New room types likely. New encounter shapes. The pace begins to differentiate from the practice version of itself.",
|
||||
"Day one is behind you. The day's notes are already organized — what worked, what nearly didn't, what you'll do differently from here. The dungeon is also taking notes on you. Everyone is preparing.",
|
||||
}
|
||||
|
||||
var MorningBriefingDay3 = []string{
|
||||
"Day three. The dungeon has had time to notice you. TwinBee has had time to notice the dungeon noticing you. The Threat Clock reflects both observations.",
|
||||
"Three days in. You've found your rhythm — TwinBee can see it in the way you move through rooms now, the way you check corners. The dungeon is learning too. TwinBee acknowledges both.",
|
||||
"Day three morning. The dungeon's posture has shifted noticeably overnight — TwinBee saw it in the corridor noise around the second watch. The Threat Clock is doing what it does. You're doing what you do. The two of you are now both doing it on purpose.",
|
||||
"Three days. The dungeon recognizes you as a recurring fixture now, which is a different kind of attention than the kind you had on Day 1. TwinBee adjusts the briefings accordingly. Less orientation, more situational reads. From here it gets specific.",
|
||||
"Day three. The dungeon has had time to notice you. I've had time to notice the dungeon noticing you. The Threat Clock reflects both observations.",
|
||||
"Three days in. You've found your rhythm — I can see it in the way you move through rooms now, the way you check corners. The dungeon is learning too. Both noted.",
|
||||
"Day three morning. The dungeon's posture has shifted noticeably overnight — I saw it in the corridor noise around the second watch. The Threat Clock is doing what it does. You're doing what you do. The two of you are now both doing it on purpose.",
|
||||
"Three days. The dungeon recognizes you as a recurring fixture now, which is a different kind of attention than the kind you had on Day 1. I'm adjusting the briefings accordingly. Less orientation, more situational reads. From here it gets specific.",
|
||||
}
|
||||
|
||||
var MorningBriefingDay7 = []string{
|
||||
"One week. You have spent one week in this place and it has not finished you, which says something about you that TwinBee intends to say out loud: that took something real. Take a moment with that. Then advance.",
|
||||
"Seven days. In the old reckoning, seven was the number of completion — seven seals, seven trials, seven nights before the thing reveals itself. TwinBee is not superstitious. TwinBee is also watching the door very carefully this morning.",
|
||||
"A week underground. TwinBee thinks about the sky sometimes — not with longing, exactly, more as a reference point. You've been below it for seven days. TwinBee finds that remarkable. TwinBee finds you remarkable.",
|
||||
"One week. You have spent one week in this place and it has not finished you, which says something about you that I intend to say out loud: that took something real. Take a moment with that. Then advance.",
|
||||
"Seven days. In the old reckoning, seven was the number of completion — seven seals, seven trials, seven nights before the thing reveals itself. I'm not superstitious. I'm also watching the door very carefully this morning.",
|
||||
"A week underground. I think about the sky sometimes — not with longing, exactly, more as a reference point. You've been below it for seven days. Remarkable. You, more so.",
|
||||
}
|
||||
|
||||
var MorningBriefingDay14 = []string{
|
||||
"Two weeks. TwinBee does not know many people who have been in an active dungeon for two weeks. TwinBee knows even fewer who have been in one for two weeks and remained, by any reasonable measure, intact. You are one of the fewer.",
|
||||
"Fourteen days. The dungeon has become familiar in the way that difficult things become familiar — not comfortable, not safe, but known. You know where it breathes. TwinBee considers this an advantage worth having.",
|
||||
"Two weeks. TwinBee starts the briefing the same way TwinBee has started fourteen others, then pauses and adds: 'For the record — most expeditions don't see fourteen briefings. This one has.' That's the addendum. Continuing.",
|
||||
"Day fifteen. TwinBee has stopped checking the historical reference materials because the historical reference materials stopped applying. From here, the run sets its own precedent. TwinBee finds this clarifying.",
|
||||
"Two weeks. I don't know many people who have been in an active dungeon for two weeks. I know even fewer who have been in one for two weeks and remained, by any reasonable measure, intact. You are one of the fewer.",
|
||||
"Fourteen days. The dungeon has become familiar in the way that difficult things become familiar — not comfortable, not safe, but known. You know where it breathes. An advantage worth having.",
|
||||
"Two weeks. I start the briefing the same way I've started fourteen others, then pause and add: 'For the record — most expeditions don't see fourteen briefings. This one has.' That's the addendum. Continuing.",
|
||||
"Day fifteen. I've stopped checking the historical reference materials because the historical reference materials stopped applying. From here, the run sets its own precedent. I find this clarifying.",
|
||||
}
|
||||
|
||||
var MorningBriefingDay21 = []string{
|
||||
"Three weeks. TwinBee has run out of historical comparisons for this. Three weeks is its own category. You have made a category. TwinBee reports this as a fact and also as something that doesn't entirely have words yet.",
|
||||
"Day twenty-one. TwinBee tried to write a clever framing for this morning's briefing and gave up halfway through, settling instead on the simplest version: 'You're still here.' That's the briefing. The rest is logistics.",
|
||||
"Three weeks down. The dungeon has stopped being a place you're visiting and become a place you live in for now. TwinBee notes the shift — the way you check rooms without being asked, the way the supply count is already in your head. The dungeon notices too.",
|
||||
"Three weeks. I've run out of historical comparisons for this. Three weeks is its own category. You have made a category. I report this as a fact and also as something that doesn't entirely have words yet.",
|
||||
"Day twenty-one. I tried to write a clever framing for this morning's briefing and gave up halfway through, settling instead on the simplest version: 'You're still here.' That's the briefing. The rest is logistics.",
|
||||
"Three weeks down. The dungeon has stopped being a place you're visiting and become a place you live in for now. I note the shift — the way you check rooms without being asked, the way the supply count is already in your head. The dungeon notices too.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -75,12 +80,12 @@ var MorningBriefingDay21 = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EveningRecapGeneric = []string{
|
||||
"End of day [N]. TwinBee tallies the ledger. The column marked 'survived' has another entry. TwinBee considers this column the most important one.",
|
||||
"Day closes. TwinBee reviews what happened and finds, on balance, more right than wrong — which in a dungeon is the operating definition of a good day.",
|
||||
"Evening. The rooms behind you are cleared. The rooms ahead are not. TwinBee notes this is always true and is never less relevant. Rest now. The math doesn't change overnight.",
|
||||
"TwinBee compiles the day: what was learned, what was fought, what was found. Files it in a mental ledger that TwinBee has been keeping since you entered. The ledger is favorable.",
|
||||
"Like the experience screen at the end of a dungeon floor in Etrian Odyssey — the numbers settle, the progress registers, and for a moment the whole thing makes sense. TwinBee gives you that moment.",
|
||||
"You've earned the dark. Sleep in it. TwinBee will be here when the numbers come back.",
|
||||
"End of day [N]. Ledger tallied. The column marked 'survived' has another entry. I consider this column the most important one.",
|
||||
"Day closes. I review what happened and find, on balance, more right than wrong — which in a dungeon is the operating definition of a good day.",
|
||||
"Evening. The rooms behind you are cleared. The rooms ahead are not. Always true; never less relevant. Rest now. The math doesn't change overnight.",
|
||||
"I compile the day: what was learned, what was fought, what was found. File it in the mental ledger I've been keeping since you entered. The ledger is favorable.",
|
||||
"Like the experience screen at the end of a dungeon floor in Etrian Odyssey — the numbers settle, the progress registers, and for a moment the whole thing makes sense. I give you that moment.",
|
||||
"You've earned the dark. Sleep in it. I'll be here when the numbers come back.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -88,24 +93,24 @@ var EveningRecapGeneric = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EveningRecapBossKilled = []string{
|
||||
"The day ends with a boss on the floor. TwinBee would like to specifically note this in the recap as 'exceptional' because it is exceptional and TwinBee does not use that word loosely.",
|
||||
"End of day. Boss count: one more than it was this morning. TwinBee marks this in a special column it keeps for exactly these entries and the special column has a new line.",
|
||||
"End of day. A boss is no longer in the dungeon. TwinBee records this in the column it keeps for exactly that line, and the column has a new mark, and that's the kind of recap TwinBee likes to write. Sleep well. The rest of the dungeon noticed too.",
|
||||
"The day closes with a vacancy. The thing in the chamber is no longer in the chamber. TwinBee ran the final tally during the fight and the tally is favorable. There will be more things tomorrow. There always are. None of them are this one.",
|
||||
"The day ends with a boss on the floor. I'd like to specifically note this in the recap as 'exceptional' because it is exceptional and I don't use that word loosely.",
|
||||
"End of day. Boss count: one more than it was this morning. Marked in a special column I keep for exactly these entries, and the special column has a new line.",
|
||||
"End of day. A boss is no longer in the dungeon. Recorded in the column I keep for exactly that line, and the column has a new mark, and that's the kind of recap I like to write. Sleep well. The rest of the dungeon noticed too.",
|
||||
"The day closes with a vacancy. The thing in the chamber is no longer in the chamber. I ran the final tally during the fight and the tally is favorable. There will be more things tomorrow. There always are. None of them are this one.",
|
||||
}
|
||||
|
||||
var EveningRecapCloseCall = []string{
|
||||
"TwinBee runs the evening recap and notes: you came very close today to not being here for the evening recap. TwinBee notes this without drama and with complete sincerity. You made it. That's the recap.",
|
||||
"Today was the kind of day that TwinBee files under 'let's not do that again' and also 'and yet you did it.' Rest. You need it more tonight than most nights.",
|
||||
"Evening recap. TwinBee will note for the ledger that today was very nearly a different kind of recap, and TwinBee is glad it isn't. The margin was thinner than TwinBee likes. TwinBee will adjust tomorrow's caution accordingly.",
|
||||
"End of day, narrowly. TwinBee runs through the close-call list — the round that nearly went the other way, the save that landed by one, the room you almost didn't leave — and signs each one off as 'survived.' Survived is a wide category. TwinBee accepts you in any part of it.",
|
||||
"Evening recap. Note: you came very close today to not being here for the evening recap. Said without drama and with complete sincerity. You made it. That's the recap.",
|
||||
"Today was the kind of day I file under 'let's not do that again' and also 'and yet you did it.' Rest. You need it more tonight than most nights.",
|
||||
"Evening recap. Noted for the ledger: today was very nearly a different kind of recap, and I'm glad it isn't. The margin was thinner than the acceptable range. Tomorrow's caution adjusts accordingly.",
|
||||
"End of day, narrowly. I run through the close-call list — the round that nearly went the other way, the save that landed by one, the room you almost didn't leave — and sign each one off as 'survived.' Survived is a wide category. I'll accept you in any part of it.",
|
||||
}
|
||||
|
||||
var EveningRecapNothingHappened = []string{
|
||||
"A quiet day. No major encounters, no notable finds. TwinBee reports this with neither disappointment nor relief. Quiet days are data. The dungeon is thinking. TwinBee is watching it think.",
|
||||
"Not every day in a dungeon produces a story. Today produced logistics: movement, supplies, positioning. TwinBee values logistics. Logistics is what you still being alive looks like from a distance.",
|
||||
"Day closes uneventfully. TwinBee considers uneventful days the kind that the dungeon is most carefully constructing — quiet between incidents is also a kind of incident. TwinBee files the day under 'preparation, theirs.' Yours starts now.",
|
||||
"Nothing happened. TwinBee writes that exact phrase in the recap and then crosses it out and writes 'something happened that wasn't visible.' That feels truer. The day still counts. The day still cost supplies. Everything still moves forward.",
|
||||
"A quiet day. No major encounters, no notable finds. Reported with neither disappointment nor relief. Quiet days are data. The dungeon is thinking. I'm watching it think.",
|
||||
"Not every day in a dungeon produces a story. Today produced logistics: movement, supplies, positioning. I value logistics. Logistics is what you still being alive looks like from a distance.",
|
||||
"Day closes uneventfully. Uneventful days are the kind the dungeon is most carefully constructing — quiet between incidents is also a kind of incident. Filed under 'preparation, theirs.' Yours starts now.",
|
||||
"Nothing happened. I write that exact phrase in the recap and then cross it out and write 'something happened that wasn't visible.' That feels truer. The day still counts. The day still cost supplies. Everything still moves forward.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -113,17 +118,17 @@ var EveningRecapNothingHappened = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var CampEstablished = []string{
|
||||
"Camp established. TwinBee surveys the perimeter with the efficiency of someone who has done this many times and learned from every time it went wrong.",
|
||||
"The camp goes up. TwinBee approves of the location — cleared room, defensible entry, no obvious curse residue. Could be worse. TwinBee has seen worse.",
|
||||
"You set camp. TwinBee checks the sightlines, checks the doors, checks the sound-bleed from the next room. Acceptable. TwinBee settles in for the night watch.",
|
||||
"A camp in the middle of a dungeon. TwinBee finds this either brave or pragmatic and has stopped trying to distinguish between the two. Either way, the camp is set. Either way, TwinBee is watching.",
|
||||
"Like planting a flag on a new map in Dwarf Fortress — this spot is yours now. Tentatively. Provisionally. For the night. TwinBee defends tentative, provisional, nocturnal property with full commitment.",
|
||||
"Camp established. I survey the perimeter with the efficiency of someone who has done this many times and learned from every time it went wrong.",
|
||||
"The camp goes up. I approve of the location — cleared room, defensible entry, no obvious curse residue. Could be worse. I've seen worse.",
|
||||
"You set camp. I check the sightlines, the doors, the sound-bleed from the next room. Acceptable. Settling in for the night watch.",
|
||||
"A camp in the middle of a dungeon. Either brave or pragmatic; I've stopped trying to distinguish between the two. Either way, the camp is set. Either way, I'm watching.",
|
||||
"Like planting a flag on a new map in Dwarf Fortress — this spot is yours now. Tentatively. Provisionally. For the night. I defend tentative, provisional, nocturnal property with full commitment.",
|
||||
}
|
||||
|
||||
var BaseCampEstablished = []string{
|
||||
"Base camp. TwinBee says this differently than it says other things. With weight. A base camp in a Tier 4 zone is not nothing — it's a declaration. You're not passing through. You're operating from here. TwinBee establishes the perimeter accordingly.",
|
||||
"The base camp is up. TwinBee notes the waypoint, marks the return route, establishes the supply cache protocols. This is now home, in the way that a forward operating position is home — functional, defended, temporary, and completely yours.",
|
||||
"Base camp established on Day [N]. TwinBee records this as a milestone, because it is one. Most people don't make it to base camp. You are not most people. TwinBee has been noting this since the beginning.",
|
||||
"Base camp. I say this differently than I say other things. With weight. A base camp in a Tier 4 zone is not nothing — it's a declaration. You're not passing through. You're operating from here. The perimeter goes up accordingly.",
|
||||
"The base camp is up. Waypoint noted, return route marked, supply cache protocols established. This is now home, in the way that a forward operating position is home — functional, defended, temporary, and completely yours.",
|
||||
"Base camp established on Day [N]. I record this as a milestone, because it is one. Most people don't make it to base camp. You are not most people. I've been noting this since the beginning.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -131,21 +136,21 @@ var BaseCampEstablished = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var SupplyWarningLow = []string{
|
||||
"TwinBee checks the supply manifest and then checks it again. 'We should discuss the supply situation,' TwinBee says, in the tone of someone who has been watching the number for two days.",
|
||||
"Supplies are running lower than TwinBee would like. TwinBee mentions this now, while there are still options, because TwinBee has seen what happens when it's mentioned too late.",
|
||||
"The supply number is not comfortable. TwinBee flags this not to alarm but to prompt — there are decisions to be made and they are better made with time than without it.",
|
||||
"I check the supply manifest and then check it again. 'We should discuss the supply situation,' I say, in the tone of someone who has been watching the number for two days.",
|
||||
"Supplies are running lower than I'd like. Mentioning it now, while there are still options, because I've seen what happens when it's mentioned too late.",
|
||||
"The supply number is not comfortable. Flagging this not to alarm but to prompt — there are decisions to be made and they are better made with time than without it.",
|
||||
}
|
||||
|
||||
var SupplyWarningCritical = []string{
|
||||
"TwinBee holds up the supply manifest. The number is very small. 'This is the part,' TwinBee says quietly, 'where we start making decisions.' TwinBee does not specify which decisions. You know which decisions.",
|
||||
"Critical supply levels. TwinBee delivers this without inflation — the situation is what it is. Extract and resupply, or forage aggressively and push for the finish. TwinBee outlines both paths and neither is comfortable.",
|
||||
"The supplies are nearly gone. TwinBee thinks of every long JRPG dungeon where you realize at the bottom floor that you're out of Ethers. This is that moment. TwinBee has plans. They require movement.",
|
||||
"I hold up the supply manifest. The number is very small. 'This is the part,' I say quietly, 'where we start making decisions.' I don't specify which. You know which.",
|
||||
"Critical supply levels. Delivered without inflation — the situation is what it is. Extract and resupply, or forage aggressively and push for the finish. I outline both paths and neither is comfortable.",
|
||||
"The supplies are nearly gone. I think of every long JRPG dungeon where you realize at the bottom floor that you're out of Ethers. This is that moment. I have plans. They require movement.",
|
||||
}
|
||||
|
||||
var SupplyDepletedExtraction = []string{
|
||||
"The supplies are gone. TwinBee says this plainly because the situation requires plain language. The expedition ends here — not in failure, in logistics. You push out as far as the provisions allow. What you've gathered comes with you. TwinBee leads the way out.",
|
||||
"Empty packs. TwinBee turns them out by reflex and confirms what the manifest already said. The expedition does not continue past supplies — that's the rule, and TwinBee enforces it on you the way TwinBee enforces it on TwinBee. Out we go. With what we have. Which is something.",
|
||||
"Out of supplies. TwinBee leads the extraction along the route already mapped — there's no scouting now, just movement, the kind that gets you through the door before anything else does. The dungeon will be here. So will TwinBee. So, importantly, will you.",
|
||||
"The supplies are gone. Said plainly because the situation requires plain language. The expedition ends here — not in failure, in logistics. You push out as far as the provisions allow. What you've gathered comes with you. I lead the way out.",
|
||||
"Empty packs. I turn them out by reflex and confirm what the manifest already said. The expedition does not continue past supplies — that's the rule, and I enforce it on you the way I enforce it on myself. Out we go. With what we have. Which is something.",
|
||||
"Out of supplies. I lead the extraction along the route already mapped — there's no scouting now, just movement, the kind that gets you through the door before anything else does. The dungeon will be here. So will I. So, importantly, will you.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -153,30 +158,30 @@ var SupplyDepletedExtraction = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ThreatClockStirring = []string{
|
||||
"TwinBee notices something in the dungeon's rhythm has changed. A tension in the air that wasn't there yesterday. The zone is aware of you now, in the way that a predator becomes aware of something in its territory. Not panicking. Not moving yet. Just aware.",
|
||||
"The enemies ahead are more alert than they were. TwinBee can tell by the patrols, by the spacing, by the fact that someone moved the tripwire you already disarmed. They know something is in here.",
|
||||
"Stirring. TwinBee tracks the small changes the dungeon makes when it suspects but doesn't confirm — repositioned patrols, fresher footprints, a torch that wasn't lit in this corridor before. TwinBee notes each one. None of them is alarming yet. All of them are evidence.",
|
||||
"The dungeon is getting curious. TwinBee distinguishes curiosity from alertness — curiosity is when the patrols slow at the doorways; alertness is when they form on the doorways. We're at the first one. The window between them is what TwinBee is watching.",
|
||||
"Something in the dungeon's rhythm has changed. A tension in the air that wasn't there yesterday. The zone is aware of you now, in the way that a predator becomes aware of something in its territory. Not panicking. Not moving yet. Just aware.",
|
||||
"The enemies ahead are more alert than they were. I can tell by the patrols, by the spacing, by the fact that someone moved the tripwire you already disarmed. They know something is in here.",
|
||||
"Stirring. I track the small changes the dungeon makes when it suspects but doesn't confirm — repositioned patrols, fresher footprints, a torch that wasn't lit in this corridor before. Each one noted. None of them is alarming yet. All of them are evidence.",
|
||||
"The dungeon is getting curious. I distinguish curiosity from alertness — curiosity is when the patrols slow at the doorways; alertness is when they form on the doorways. We're at the first one. The window between them is what I'm watching.",
|
||||
}
|
||||
|
||||
var ThreatClockAlert = []string{
|
||||
"The zone is on alert. TwinBee reports this factually and also with a note of urgency — alert means organized, and organized means the next room will be harder than the last room was. Move with intention.",
|
||||
"They're coordinating now. TwinBee watches the patrol patterns shift. The goblins who were arguing about ambush order are no longer arguing. TwinBee does not find this an improvement.",
|
||||
"Alert band. The dungeon has confirmed a presence and is acting on the confirmation. TwinBee can see it in the way the patrols overlap deliberately now — no more arguments about coverage, just coverage. The plan adjusts: no more long approaches, no more leaving anything unfinished behind.",
|
||||
"They've arranged themselves. TwinBee delivers the band-shift line and then condenses the operational change into one sentence: from here, every room costs more than the last one. Nothing gets cheaper. TwinBee suggests pricing accordingly.",
|
||||
"The zone is on alert. Reported factually and also with a note of urgency — alert means organized, and organized means the next room will be harder than the last room was. Move with intention.",
|
||||
"They're coordinating now. I watch the patrol patterns shift. The goblins who were arguing about ambush order are no longer arguing. Not an improvement.",
|
||||
"Alert band. The dungeon has confirmed a presence and is acting on the confirmation. I can see it in the way the patrols overlap deliberately now — no more arguments about coverage, just coverage. The plan adjusts: no more long approaches, no more leaving anything unfinished behind.",
|
||||
"They've arranged themselves. Band-shift confirmed, and the operational change condenses to one sentence: from here, every room costs more than the last one. Nothing gets cheaper. Price accordingly.",
|
||||
}
|
||||
|
||||
var ThreatClockHostile = []string{
|
||||
"Full hostile status. TwinBee says this with the specific weight it deserves. The zone has decided you are the problem it's solving today. You have the same opinion about the zone. One of you is correct. TwinBee is invested in it being you.",
|
||||
"The dungeon has mobilized. TwinBee notes re-armed traps, reinforced positions, an enemy in a room that was empty this morning. They've organized. TwinBee recommends organizing faster.",
|
||||
"Hostile. TwinBee names the band and then sets the briefing aside because the briefing has been overtaken by events. The dungeon is now actively committed to ending this. TwinBee is committed in the opposite direction. The clarification is useful.",
|
||||
"The dungeon has decided. TwinBee respects decisions, even ones that involve the dungeon trying to kill you. The respect is a working respect. We respect the decision and then we work to overrule it. TwinBee leads.",
|
||||
"Full hostile status. Said with the specific weight it deserves. The zone has decided you are the problem it's solving today. You have the same opinion about the zone. One of you is correct. I'm invested in it being you.",
|
||||
"The dungeon has mobilized. Re-armed traps, reinforced positions, an enemy in a room that was empty this morning. They've organized. Recommend organizing faster.",
|
||||
"Hostile. I name the band and then set the briefing aside because the briefing has been overtaken by events. The dungeon is now actively committed to ending this. I'm committed in the opposite direction. The clarification is useful.",
|
||||
"The dungeon has decided. I respect decisions, even ones that involve the dungeon trying to kill you. The respect is a working respect. We respect the decision and then we work to overrule it. I lead.",
|
||||
}
|
||||
|
||||
var ThreatClockSiege = []string{
|
||||
"Siege Mode. TwinBee delivers this without decoration because decoration would be dishonest. The dungeon is fully active, fully aware, and fully committed to ending this expedition. So is TwinBee — to ending it on your terms, not theirs. What happens next is a race. TwinBee is already running.",
|
||||
"Siege. The dungeon is putting everything it has into the room you're standing in, and the rooms adjacent to it, and the route between you and the door. TwinBee is putting everything TwinBee has into making sure the dungeon doesn't get what it wants. Meet in the middle.",
|
||||
"Siege Mode. TwinBee narrates the band shift and then stops narrating because narration is not what this band needs. Action is what this band needs. TwinBee is in motion. The expectation is that you are too.",
|
||||
"Siege Mode. Delivered without decoration because decoration would be dishonest. The dungeon is fully active, fully aware, and fully committed to ending this expedition. So am I — to ending it on your terms, not theirs. What happens next is a race. Already running.",
|
||||
"Siege. The dungeon is putting everything it has into the room you're standing in, and the rooms adjacent to it, and the route between you and the door. I'm putting everything I have into making sure the dungeon doesn't get what it wants. Meet in the middle.",
|
||||
"Siege Mode. I narrate the band shift and then stop narrating because narration is not what this band needs. Action is what this band needs. I'm in motion. The expectation is that you are too.",
|
||||
}
|
||||
|
||||
// ThreatClockApproachingSiege fires once when the threat clock crosses 70 —
|
||||
@@ -184,9 +189,9 @@ var ThreatClockSiege = []string{
|
||||
// flavor because this is the dungeon-design moment of telling the player
|
||||
// they're past the point where stealth is recoverable.
|
||||
var ThreatClockApproachingSiege = []string{
|
||||
"They know you're here. Not a suspicion anymore. A certainty. The question now is whether you finish before they organize. TwinBee says this clearly so it doesn't have to be said again.",
|
||||
"Threat at seventy. TwinBee marks this on the internal ledger and underlines it. The window for quiet operations has closed. The window for finishing is still open — narrower, but open. TwinBee suggests using it.",
|
||||
"The dungeon's posture has shifted from 'searching' to 'hunting.' TwinBee tracks the difference precisely: before, they were looking for evidence; now they are looking for you. The plan, accordingly, simplifies. Finish or extract. Middle paths have closed.",
|
||||
"They know you're here. Not a suspicion anymore. A certainty. The question now is whether you finish before they organize. Said clearly so it doesn't have to be said again.",
|
||||
"Threat at seventy. Marked on the internal ledger and underlined. The window for quiet operations has closed. The window for finishing is still open — narrower, but open. Use it.",
|
||||
"The dungeon's posture has shifted from 'searching' to 'hunting.' I track the difference precisely: before, they were looking for evidence; now they are looking for you. The plan, accordingly, simplifies. Finish or extract. Middle paths have closed.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -194,86 +199,86 @@ var ThreatClockApproachingSiege = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var SunkenTempleTidalWarning = []string{
|
||||
"TwinBee watches the waterline. 'It's rising,' TwinBee says, not for the first time, and this time with more precision. 'The tidal cycle peaks in two days. Whatever you haven't done by then, you'll be doing wet.'",
|
||||
"Day [N]. The tide is coming. TwinBee has been watching it since Day 2 and the mathematics are not encouraging. Two days to the peak. One day after that, the flood subsides. TwinBee suggests using the two days well.",
|
||||
"TwinBee checks the waterline against yesterday's mark. The mark is below the line. The line is on the wall. The wall is, frankly, full of marks. TwinBee selects the one that matters and reports: tide rising, peak still days off, plan accordingly.",
|
||||
"Day [N]. The temple's tidal calendar advances. TwinBee has it on a card and updates the card every morning — current depth, projected depth, peak day. TwinBee suggests internalizing the card. The temple does not pause for confusion.",
|
||||
"I watch the waterline. 'It's rising,' I say, not for the first time, and this time with more precision. 'The tidal cycle peaks in two days. Whatever you haven't done by then, you'll be doing wet.'",
|
||||
"Day [N]. The tide is coming. I've been watching it since Day 2 and the mathematics are not encouraging. Two days to the peak. One day after that, the flood subsides. Use the two days well.",
|
||||
"I check the waterline against yesterday's mark. The mark is below the line. The line is on the wall. The wall is, frankly, full of marks. I select the one that matters and report: tide rising, peak still days off, plan accordingly.",
|
||||
"Day [N]. The temple's tidal calendar advances. I keep it on a card and update the card every morning — current depth, projected depth, peak day. Internalize the card. The temple does not pause for confusion.",
|
||||
}
|
||||
|
||||
var SunkenTempleTidalEvent = []string{
|
||||
"The tide arrives. TwinBee had warned about this and now it is happening and the warning feels entirely inadequate compared to the actual water. Everything is colder, wetter, and the Kuo-toa are moving through it like they were born to — which they were. Adjust.",
|
||||
"The water is here. Not 'rising' anymore — here, in the rooms, at the corners, climbing the stairs. TwinBee adjusts every estimate accordingly: travel slower, encounters worse, healing colder. Two days of this. Two days, then it goes.",
|
||||
"Tidal peak. The temple is fully flooded at depths that were dry yesterday — TwinBee maps the change room by room and updates the route. Some doors are open that weren't. Some are sealed that were. The temple is a different shape today.",
|
||||
"The tide arrives. I'd warned about this and now it is happening and the warning feels entirely inadequate compared to the actual water. Everything is colder, wetter, and the Kuo-toa are moving through it like they were born to — which they were. Adjust.",
|
||||
"The water is here. Not 'rising' anymore — here, in the rooms, at the corners, climbing the stairs. Every estimate adjusts accordingly: travel slower, encounters worse, healing colder. Two days of this. Two days, then it goes.",
|
||||
"Tidal peak. The temple is fully flooded at depths that were dry yesterday — I map the change room by room and update the route. Some doors are open that weren't. Some are sealed that were. The temple is a different shape today.",
|
||||
}
|
||||
|
||||
var HauntedManorResetMorning = []string{
|
||||
"TwinBee's morning briefing includes an addendum. The rooms that were clear yesterday are not entirely clear this morning. The house has been busy overnight. TwinBee adds this to the log under 'things the house does' and suggests adjusting the advance plan.",
|
||||
"Night three. The manor reset itself. TwinBee was watching and it happened anyway — not violently, not dramatically, just quietly and completely, the way the house does everything. One enemy per room, back in place. TwinBee has updated the map.",
|
||||
"TwinBee reads the morning's hallway and stops. 'The arrangement has changed,' TwinBee says, in the tone of someone who has now seen this happen often enough to recognize it without alarm. The manor reset. TwinBee adjusts the map. Morning continues.",
|
||||
"Reset day. The rooms TwinBee cleared are not the rooms TwinBee finds this morning. TwinBee accepts this as a property of the manor — like weather, but indoors and unfair. The plan accommodates. TwinBee bills the time.",
|
||||
"The morning briefing includes an addendum. The rooms that were clear yesterday are not entirely clear this morning. The house has been busy overnight. Adding it to the log under 'things the house does' and suggesting we adjust the advance plan.",
|
||||
"Night three. The manor reset itself. I was watching and it happened anyway — not violently, not dramatically, just quietly and completely, the way the house does everything. One enemy per room, back in place. Map updated.",
|
||||
"I read the morning's hallway and stop. 'The arrangement has changed,' I say, in the tone of someone who has now seen this happen often enough to recognize it without alarm. The manor reset. Map adjusts. Morning continues.",
|
||||
"Reset day. The rooms I cleared are not the rooms I find this morning. Accept it as a property of the manor — like weather, but indoors and unfair. The plan accommodates. The time gets billed.",
|
||||
}
|
||||
|
||||
var UnderforgHeapWarning = []string{
|
||||
"Heat Stack [N]. TwinBee notes the accumulation and what it means: the Underforge is getting into you in ways that don't resolve without real rest. The number has time to come down. TwinBee is watching the number.",
|
||||
"The heat is building. TwinBee tracks it the way you track a temperature gauge on a long drive — with the specific alertness of someone who knows what happens when the gauge hits red. It has not hit red. TwinBee intends to ensure it doesn't.",
|
||||
"Heat building. TwinBee notes the count, notes the band, notes how each step now takes a fraction more out of you than the last step did. None of it is critical. All of it is direction. TwinBee suggests the direction be inverted soon.",
|
||||
"Stack [N]. TwinBee tracks the heat the way TwinBee tracks anything that compounds — patiently, with running totals, with a clear point at which the totals stop being managed and start being problems. We are still on the management side. The other side is visible.",
|
||||
"Heat Stack [N]. I note the accumulation and what it means: the Underforge is getting into you in ways that don't resolve without real rest. The number has time to come down. I'm watching the number.",
|
||||
"The heat is building. I track it the way you track a temperature gauge on a long drive — with the specific alertness of someone who knows what happens when the gauge hits red. It has not hit red. I intend to ensure it doesn't.",
|
||||
"Heat building. Count noted, band noted, and how each step now takes a fraction more out of you than the last step did. None of it is critical. All of it is direction. The direction should be inverted soon.",
|
||||
"Stack [N]. I track the heat the way I track anything that compounds — patiently, with running totals, with a clear point at which the totals stop being managed and start being problems. We are still on the management side. The other side is visible.",
|
||||
}
|
||||
|
||||
var UnderforgHeapCritical = []string{
|
||||
"Heat Stack is high. TwinBee delivers this without minimizing it. The Underforge is inside your lungs now, in your joints, in the way everything takes a little more effort than it did on Day 1. A proper rest will help. Finishing will help more.",
|
||||
"Heat ten. TwinBee marks the maximum. There is no higher band than this in the Underforge ledger — the column ends here, by design. The forge is fully inside you. The penalties are all of them. Resting will not undo it. Finishing might.",
|
||||
"Critical heat. TwinBee runs through the symptoms list and confirms each one in turn: the joints, the breathing, the way every roll feels a little heavier. The forge is doing what the forge does. TwinBee suggests doing what you do, harder.",
|
||||
"Heat Stack is high. Delivered without minimizing it. The Underforge is inside your lungs now, in your joints, in the way everything takes a little more effort than it did on Day 1. A proper rest will help. Finishing will help more.",
|
||||
"Heat ten. The maximum. There is no higher band than this in the Underforge ledger — the column ends here, by design. The forge is fully inside you. The penalties are all of them. Resting will not undo it. Finishing might.",
|
||||
"Critical heat. I run through the symptoms list and confirm each one in turn: the joints, the breathing, the way every roll feels a little heavier. The forge is doing what the forge does. I suggest doing what you do, harder.",
|
||||
}
|
||||
|
||||
var FeywildTimeDistortionHalf = []string{
|
||||
"The day moved strangely. TwinBee tried to track it and lost the thread somewhere around mid-afternoon — the light didn't change the way it should have, and when TwinBee looked up, the day was half over in the time it usually takes to be a quarter over. On the positive side: you're barely hungry. On the less positive side: TwinBee is not sure what that means.",
|
||||
"Half a day passed in what felt like half of half. TwinBee checks the supply burn against the sun, finds them disagreeing, sides with the supplies — those don't lie about how much you've used. Net result: a free pocket of time. Use it on something that takes time.",
|
||||
"The Feywild gave you back some hours. TwinBee notes this without trusting it. The Feywild does not give without taking, eventually. For now: less hungry, less tired, more daylight than the math allows. TwinBee writes it down and keeps moving.",
|
||||
"The day moved strangely. I tried to track it and lost the thread somewhere around mid-afternoon — the light didn't change the way it should have, and when I looked up, the day was half over in the time it usually takes to be a quarter over. On the positive side: you're barely hungry. On the less positive side: nobody is sure what that means.",
|
||||
"Half a day passed in what felt like half of half. I check the supply burn against the sun, find them disagreeing, side with the supplies — those don't lie about how much you've used. Net result: a free pocket of time. Use it on something that takes time.",
|
||||
"The Feywild gave you back some hours. Noted, but not trusted. The Feywild does not give without taking, eventually. For now: less hungry, less tired, more daylight than the math allows. I write it down and keep moving.",
|
||||
}
|
||||
|
||||
var FeywildTimeDistortionDouble = []string{
|
||||
"Time doubled. TwinBee notes this with the clinical detachment of someone who has been in the Feywild long enough to stop being surprised. You've lived through two days today. The supplies reflect that. The wandering monsters reflected that as well. The rest of the Feywild is unconcerned.",
|
||||
"Two days in one. TwinBee runs the recovery math and the wandering math and the supply math, and all three of them say the same thing: today cost double. Tomorrow gets the rate it gets. The Feywild does not refund.",
|
||||
"Time doubled. TwinBee saw the second sunset before the second lunch and stopped trying to make sense of it. The day that happened is the day that happened. Plan from here, not from this morning.",
|
||||
"Time doubled. Noted with the clinical detachment of someone who has been in the Feywild long enough to stop being surprised. You've lived through two days today. The supplies reflect that. The wandering monsters reflected that as well. The rest of the Feywild is unconcerned.",
|
||||
"Two days in one. I run the recovery math and the wandering math and the supply math, and all three say the same thing: today cost double. Tomorrow gets the rate it gets. The Feywild does not refund.",
|
||||
"Time doubled. I saw the second sunset before the second lunch and stopped trying to make sense of it. The day that happened is the day that happened. Plan from here, not from this morning.",
|
||||
}
|
||||
|
||||
var FeywildTimeLoop = []string{
|
||||
"TwinBee recognizes this room. TwinBee has described this room before. The enemies in it are different — new enemies, the old ones are gone, the loot you found is still gone but the enemies are back — and TwinBee processes this with something between professional acceptance and profound exasperation. 'Again,' TwinBee says. 'We do this room again.'",
|
||||
"The loop. TwinBee marks the room on the map with a small symbol that means 'we have been in this room before in a way that doesn't count.' The symbol has its own column on TwinBee's ledger. The column is occupied.",
|
||||
"Same room. New enemies. TwinBee runs the encounter again with the cold professionalism of someone who has stopped expecting fairness from the Feywild and started expecting only repetition. The repetition arrives. TwinBee handles it.",
|
||||
"I recognize this room. I've described it before. The enemies in it are different — new enemies, the old ones are gone, the loot you found is still gone but the enemies are back — and I process this with something between professional acceptance and profound exasperation. 'Again,' I say. 'We do this room again.'",
|
||||
"The loop. I mark the room on the map with a small symbol that means 'we have been in this room before in a way that doesn't count.' The symbol has its own column in my ledger. The column is occupied.",
|
||||
"Same room. New enemies. I run the encounter again with the cold professionalism of someone who has stopped expecting fairness from the Feywild and started expecting only repetition. The repetition arrives. Handled.",
|
||||
}
|
||||
|
||||
var DragonsLairAwarenessPulse = []string{
|
||||
"Something changes in the mountain. Not a sound, exactly — more like a sound's absence, filling in differently than before. TwinBee watches the kobolds. The kobolds have stopped what they were doing. The kobolds are listening. Something told them to listen. TwinBee adds ten to the Threat Clock and keeps moving.",
|
||||
"Infernax shifts in its sleep. TwinBee feels this through the stone. The patrol rotations just changed — TwinBee can see it in where the Guard Drakes aren't anymore versus where they were an hour ago. The mountain's master is dreaming about you. That is not a comfortable thing to be dreamed about.",
|
||||
"The mountain pulses. TwinBee feels it as a single off-rhythm beat in the rock, the kind of beat that means something somewhere has shifted weight. The kobolds nearby pause. They look upward without looking. TwinBee adjusts the threat accordingly and continues.",
|
||||
"Infernax dreamed about you. TwinBee has no other way to phrase it — the lair changed in the way places change when they're being reordered from somewhere deeper. The patrols are different. The temperature is different. TwinBee adds ten to the clock and a note to the margin.",
|
||||
"Something changes in the mountain. Not a sound, exactly — more like a sound's absence, filling in differently than before. I watch the kobolds. The kobolds have stopped what they were doing. The kobolds are listening. Something told them to listen. I add ten to the Threat Clock and keep moving.",
|
||||
"Infernax shifts in its sleep. I feel it through the stone. The patrol rotations just changed — I can see it in where the Guard Drakes aren't anymore versus where they were an hour ago. The mountain's master is dreaming about you. That is not a comfortable thing to be dreamed about.",
|
||||
"The mountain pulses. I feel it as a single off-rhythm beat in the rock, the kind of beat that means something somewhere has shifted weight. The kobolds nearby pause. They look upward without looking. I adjust the threat accordingly and continue.",
|
||||
"Infernax dreamed about you. I have no other way to phrase it — the lair changed in the way places change when they're being reordered from somewhere deeper. The patrols are different. The temperature is different. Ten added to the clock and a note to the margin.",
|
||||
}
|
||||
|
||||
var DragonsLairAwakenWarning = []string{
|
||||
"Day 14. TwinBee delivers the morning briefing in a lower register than usual. 'Infernax is awake,' TwinBee says. 'I don't know when exactly — sometime in the last six hours. The patrols changed. The temperature changed. The silence changed.' A pause. 'We need to reach the final chamber before it reaches us. That is now the only plan.'",
|
||||
"Day fourteen. Infernax woke up in the night. TwinBee says the sentence flatly because the sentence is enough by itself; it does not need help. The rotation is to the final chamber. Everything else is decoration.",
|
||||
"The mountain changed in its sleep. TwinBee felt it through the floor sometime around the fourth watch and has been recalibrating since. Infernax is awake and Infernax is aware. TwinBee suggests being neither subtle nor slow. There is no longer time for both.",
|
||||
"Day 14. The morning briefing comes out in a lower register than usual. 'Infernax is awake,' I say. 'I don't know when exactly — sometime in the last six hours. The patrols changed. The temperature changed. The silence changed.' A pause. 'We need to reach the final chamber before it reaches us. That is now the only plan.'",
|
||||
"Day fourteen. Infernax woke up in the night. I say the sentence flatly because the sentence is enough by itself; it does not need help. The rotation is to the final chamber. Everything else is decoration.",
|
||||
"The mountain changed in its sleep. I felt it through the floor sometime around the fourth watch and have been recalibrating since. Infernax is awake and Infernax is aware. Be neither subtle nor slow. There is no longer time for both.",
|
||||
}
|
||||
|
||||
var AbyssPortalDestabilizationMid = []string{
|
||||
"Instability [N]. TwinBee watches the portal from a respectful distance and notes: it is larger than it was yesterday. Not by much. Not in a way you'd notice if you weren't looking. TwinBee is always looking.",
|
||||
"The portal is talking to itself. TwinBee has no better way to describe it — the light it emits is different at the edges now, like it's processing something. TwinBee suggests processing faster than it does.",
|
||||
"Instability rising. TwinBee runs the comparison: yesterday's portal versus today's portal. The difference is not subtle to TwinBee. The difference may not be subtle to anything else, soon. TwinBee suggests pacing the work accordingly.",
|
||||
"The portal is widening, by some measure that TwinBee is using and not currently sharing because the measure does not have a clean name. The point is: it's worse than yesterday. The point is: tomorrow may be worse than that. TwinBee names the trend so the trend can be argued with.",
|
||||
"Instability [N]. I watch the portal from a respectful distance and note: it is larger than it was yesterday. Not by much. Not in a way you'd notice if you weren't looking. I'm always looking.",
|
||||
"The portal is talking to itself. I have no better way to describe it — the light it emits is different at the edges now, like it's processing something. Process faster than it does.",
|
||||
"Instability rising. I run the comparison: yesterday's portal versus today's portal. The difference is not subtle to me. The difference may not be subtle to anything else, soon. Pace the work accordingly.",
|
||||
"The portal is widening, by some measure I'm using and not currently sharing because the measure doesn't have a clean name. The point is: it's worse than yesterday. The point is: tomorrow may be worse than that. I name the trend so the trend can be argued with.",
|
||||
}
|
||||
|
||||
var AbyssPortalDestabilizationCritical = []string{
|
||||
"Instability critical. The portal is unraveling at edges TwinBee can see and probably at edges TwinBee can't. The demons coming through are more agitated than they were — which is relevant because demons at baseline are already at the upper end of agitated. TwinBee says: finish this today. Tomorrow is a different calculation.",
|
||||
"The portal is louder. Not in sound — in pressure. TwinBee can feel it in the back teeth, in the joint of the jaw. The instability number is the number you don't want it to be. Finish the work today or accept that today was the last day to.",
|
||||
"Critical. The geometry around the portal is bending in ways that suggest the room does not entirely agree with itself anymore. TwinBee adjusts the route around the worst of it. There are more demons than there should be. There always are. There are more than that now.",
|
||||
"Instability critical. The portal is unraveling at edges I can see and probably at edges I can't. The demons coming through are more agitated than they were — which is relevant because demons at baseline are already at the upper end of agitated. The verdict: finish this today. Tomorrow is a different calculation.",
|
||||
"The portal is louder. Not in sound — in pressure. I can feel it in the back teeth, in the joint of the jaw. The instability number is the number you don't want it to be. Finish the work today or accept that today was the last day to.",
|
||||
"Critical. The geometry around the portal is bending in ways that suggest the room does not entirely agree with itself anymore. I adjust the route around the worst of it. There are more demons than there should be. There always are. There are more than that now.",
|
||||
}
|
||||
|
||||
var AbyssPortalCollapse = []string{
|
||||
"The portal collapses. TwinBee watches it happen and does what TwinBee does in situations with no good options: moves. 'Out,' TwinBee says, and means it completely. 'Now. Everything you have, we move now.' The expedition ends here, not in defeat, but in physics. What you took is yours. What's left in there is the portal's problem. Come back when it isn't.",
|
||||
"It's coming apart. TwinBee says one word, 'Move,' in the tone TwinBee uses exactly once per expedition. You move. The portal screams behind you in a register that isn't sound. The expedition is over because physics says so. TwinBee will sort the loot once you're somewhere physics still works.",
|
||||
"Collapse. TwinBee was prepared for this and is also actively running. 'Out,' TwinBee says. 'Now.' The corridors are folding behind you in the way that things fold when reality is no longer paying attention to what's allowed. You finish out the door. The portal does not.",
|
||||
"The portal collapses. I watch it happen and do what I do in situations with no good options: move. 'Out,' I say, and mean it completely. 'Now. Everything you have, we move now.' The expedition ends here, not in defeat, but in physics. What you took is yours. What's left in there is the portal's problem. Come back when it isn't.",
|
||||
"It's coming apart. I say one word, 'Move,' in the tone I use exactly once per expedition. You move. The portal screams behind you in a register that isn't sound. The expedition is over because physics says so. The loot gets sorted once you're somewhere physics still works.",
|
||||
"Collapse. I was prepared for this and am also actively running. 'Out,' I say. 'Now.' The corridors are folding behind you in the way that things fold when reality is no longer paying attention to what's allowed. You finish out the door. The portal does not.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -281,15 +286,15 @@ var AbyssPortalCollapse = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RegionTransitDeparture = []string{
|
||||
"TwinBee marks the boundary on the internal map and crosses it. 'New region,' TwinBee says, with the careful attention of someone who knows boundaries in dungeons aren't always the same kind of boundaries you'd find on the surface. The route is set. The day is committed. We move.",
|
||||
"Crossing into [REGION_NEXT]. TwinBee folds the previous region's notes into the satchel and unfolds new ones. The light changes. The air changes. The rules of how to be careful change a little. TwinBee adjusts.",
|
||||
"The transit between regions is its own kind of room. TwinBee narrates it that way — sightlines, footing, what's behind, what's ahead — because the only way the in-between part stops feeling exposed is to treat it like the rest of the dungeon. Treated. Moving.",
|
||||
"I mark the boundary on the internal map and cross it. 'New region,' I say, with the careful attention of someone who knows boundaries in dungeons aren't always the same kind of boundaries you'd find on the surface. The route is set. The day is committed. We move.",
|
||||
"Crossing into [REGION_NEXT]. I fold the previous region's notes into the satchel and unfold new ones. The light changes. The air changes. The rules of how to be careful change a little. I adjust.",
|
||||
"The transit between regions is its own kind of room. I narrate it that way — sightlines, footing, what's behind, what's ahead — because the only way the in-between part stops feeling exposed is to treat it like the rest of the dungeon. Treated. Moving.",
|
||||
}
|
||||
|
||||
var RegionTransitArrival = []string{
|
||||
"You arrive in [REGION_NEXT]. TwinBee surveys, takes in the new geometry, and updates the working assumptions. 'Different shape,' TwinBee says. 'Same general principle. We learn what wants to kill us here, and we get there first.'",
|
||||
"[REGION_NEXT] receives you. TwinBee notes the temperature, the sound, the things-not-said-by-the-room-but-implied. A region is not just a place. It's a posture. TwinBee adopts the new one and suggests you do as well.",
|
||||
"Boundary crossed. TwinBee stamps the day in the log — one full day spent in transit, supplies adjusted, the wandering that happened on the way handled and filed. We are here now. The next stretch is what it is.",
|
||||
"You arrive in [REGION_NEXT]. I survey, take in the new geometry, and update the working assumptions. 'Different shape,' I say. 'Same general principle. We learn what wants to kill us here, and we get there first.'",
|
||||
"[REGION_NEXT] receives you. I note the temperature, the sound, the things-not-said-by-the-room-but-implied. A region is not just a place. It's a posture. I adopt the new one and suggest you do as well.",
|
||||
"Boundary crossed. The day gets stamped in the log — one full day spent in transit, supplies adjusted, the wandering that happened on the way handled and filed. We are here now. The next stretch is what it is.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -297,10 +302,10 @@ var RegionTransitArrival = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ExtractionVoluntary = []string{
|
||||
"Extraction. TwinBee notes the decision and respects it — knowing when to leave is a skill, not a failure, and TwinBee has watched enough expeditions end wrong to deeply appreciate the ones that end right.",
|
||||
"You call the extraction and TwinBee begins the route out immediately. No argument, no editorializing. There will be time for the debrief later. The first priority is the door.",
|
||||
"The dungeon doesn't like this. TwinBee can tell by the way the corridors feel as you head back out — a resistance that isn't structural, just atmospheric. The zone wanted more. It doesn't get more today. TwinBee leads the way.",
|
||||
"Withdrawing with intent. TwinBee catalogues what you have — the loot, the XP, the knowledge of where the rooms are for the return — and converts the exit into preparation. This isn't retreat. This is the start of the next attempt.",
|
||||
"Extraction. I note the decision and respect it — knowing when to leave is a skill, not a failure, and I've watched enough expeditions end wrong to deeply appreciate the ones that end right.",
|
||||
"You call the extraction and I begin the route out immediately. No argument, no editorializing. There will be time for the debrief later. The first priority is the door.",
|
||||
"The dungeon doesn't like this. I can tell by the way the corridors feel as you head back out — a resistance that isn't structural, just atmospheric. The zone wanted more. It doesn't get more today. I lead the way.",
|
||||
"Withdrawing with intent. I catalogue what you have — the loot, the XP, the knowledge of where the rooms are for the return — and convert the exit into preparation. This isn't retreat. This is the start of the next attempt.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -308,9 +313,9 @@ var ExtractionVoluntary = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ExtractionForced = []string{
|
||||
"TwinBee moves. No recap, no analysis — that comes later. Right now there is a corridor and a door and getting you through both of them. Everything else waits.",
|
||||
"Out. TwinBee says this once and means it completely. The dungeon tried to keep you. TwinBee declines on your behalf.",
|
||||
"The expedition ends here — not the way TwinBee wanted, not the way you wanted, but ended, and ending is sometimes the best available outcome. You'll come back. You'll know more. TwinBee will be there.",
|
||||
"I move. No recap, no analysis — that comes later. Right now there is a corridor and a door and getting you through both of them. Everything else waits.",
|
||||
"Out. Said once and meant completely. The dungeon tried to keep you. I decline on your behalf.",
|
||||
"The expedition ends here — not the way I wanted, not the way you wanted, but ended, and ending is sometimes the best available outcome. You'll come back. You'll know more. I'll be there.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -318,10 +323,10 @@ var ExtractionForced = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ExpeditionResume = []string{
|
||||
"Back. TwinBee noted the door when you left it and notes it again now, from the inside. 'The dungeon is where you left it,' TwinBee says. 'Mostly.' The Threat Clock has some opinions about the time that passed.",
|
||||
"You came back. TwinBee had calculated a probability on that and is pleased to update the calculation upward. The expedition resumes. The dungeon has had time to adjust. So have you.",
|
||||
"Like hitting Continue on a save file — the world remembers where you stopped, the enemies remember why they're there, and TwinBee remembers every room and every roll. Resumes here. Advances from here.",
|
||||
"The supplies are new. The knowledge from last time is not. TwinBee considers that a significant advantage. The dungeon has the familiarity of a difficult level you've attempted before — you know where the hard parts are. That is not nothing. TwinBee builds on that.",
|
||||
"Back. I noted the door when you left it and note it again now, from the inside. 'The dungeon is where you left it,' I say. 'Mostly.' The Threat Clock has some opinions about the time that passed.",
|
||||
"You came back. I'd calculated a probability on that and am pleased to update the calculation upward. The expedition resumes. The dungeon has had time to adjust. So have you.",
|
||||
"Like hitting Continue on a save file — the world remembers where you stopped, the enemies remember why they're there, and I remember every room and every roll. Resumes here. Advances from here.",
|
||||
"The supplies are new. The knowledge from last time is not. A significant advantage. The dungeon has the familiarity of a difficult level you've attempted before — you know where the hard parts are. That is not nothing. We build on that.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -329,49 +334,49 @@ var ExpeditionResume = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var MilestoneFirstNight = []string{
|
||||
"You survived the first night. TwinBee notes this milestone specifically because not everyone does, and those who do carry something from it that changes how the rest of the expedition goes. You have that now. TwinBee has noticed it already.",
|
||||
"Night one survived. TwinBee makes a small mark in the corner of the manifest — the kind of mark you make for the things that count more than they look. First nights count. TwinBee has been in dungeons where they were the last nights too. This wasn't one of those.",
|
||||
"Day two morning. The first night is behind you, which means the first watch is behind TwinBee, which means TwinBee can confirm a thing TwinBee was hoping to confirm: you sleep through the noises that matter and wake for the ones that don't. That's a survival skill. TwinBee logs it.",
|
||||
"You survived the first night. I note this milestone specifically because not everyone does, and those who do carry something from it that changes how the rest of the expedition goes. You have that now. Already noticed.",
|
||||
"Night one survived. I make a small mark in the corner of the manifest — the kind of mark you make for the things that count more than they look. First nights count. I've been in dungeons where they were the last nights too. This wasn't one of those.",
|
||||
"Day two morning. The first night is behind you, which means the first watch is behind me, which means a thing worth confirming has been confirmed: you sleep through the noises that matter and wake for the ones that don't. That's a survival skill. Logged.",
|
||||
}
|
||||
|
||||
var MilestoneWeekOne = []string{
|
||||
"Seven days. TwinBee pauses the morning briefing for a moment — just a moment — to mark this. One week in an active dungeon zone is not a thing that happens by accident. It happens through every decision you've made since Day 1, compounded. TwinBee has been watching those decisions. TwinBee is glad they were yours.",
|
||||
"Day eight. TwinBee delivers the briefing slowly, because there's a thing to mark first: you have been in here, intact and operational, for a full week. That is a number with weight. TwinBee is going to set the briefing down for a moment and let the number have its weight. ... Right. Briefing.",
|
||||
"One week. TwinBee makes the mark in the column reserved for week-one survivors. The column is shorter than you'd expect. You are now on it. The rest of the day proceeds normally, but for one moment, TwinBee allows itself to be visibly impressed. The moment ends. We continue.",
|
||||
"Seven days. I pause the morning briefing for a moment — just a moment — to mark this. One week in an active dungeon zone is not a thing that happens by accident. It happens through every decision you've made since Day 1, compounded. Those decisions were watched. They were, gladly, yours.",
|
||||
"Day eight. The briefing comes slowly, because there's a thing to mark first: you have been in here, intact and operational, for a full week. That is a number with weight. I'm going to set the briefing down for a moment and let the number have its weight. ... Right. Briefing.",
|
||||
"One week. I make the mark in the column reserved for week-one survivors. The column is shorter than you'd expect. You are now on it. The rest of the day proceeds normally, but for one moment, I allow myself to be visibly impressed. The moment ends. We continue.",
|
||||
}
|
||||
|
||||
var MilestoneTwoWeeks = []string{
|
||||
"Two weeks. TwinBee doesn't have a comparison for this one. The references have run out. There's just you, in here, on Day 14, still going, and TwinBee standing next to you having run out of everything except genuine admiration. TwinBee has that in abundance. Proceed.",
|
||||
"Day fifteen. TwinBee notes that the historical comparisons stopped working at Day fourteen and have not resumed working today. We are off the chart. TwinBee is not a person who values being off the chart, except in the very specific sense that you are off the chart, in which case TwinBee values it deeply.",
|
||||
"Fortnight. TwinBee uses the older word because it sounds more like what this is — not 'two weeks,' which sounds modular and reasonable, but 'fortnight,' which sounds like the kind of duration that earns a title. TwinBee may be inventing titles for you. TwinBee will be honest about that as it happens.",
|
||||
"Two weeks. I don't have a comparison for this one. The references have run out. There's just you, in here, on Day 14, still going, and me standing next to you having run out of everything except genuine admiration. That, in abundance. Proceed.",
|
||||
"Day fifteen. The historical comparisons stopped working at Day fourteen and have not resumed working today. We are off the chart. I'm not a person who values being off the chart, except in the very specific sense that you are off the chart, in which case I value it deeply.",
|
||||
"Fortnight. I use the older word because it sounds more like what this is — not 'two weeks,' which sounds modular and reasonable, but 'fortnight,' which sounds like the kind of duration that earns a title. I may be inventing titles for you. I'll be honest about that as it happens.",
|
||||
}
|
||||
|
||||
var MilestoneTheLongGame = []string{
|
||||
"TwinBee sets aside the narration format for a moment. Just sets it down. Speaks plainly: what you just did was not supposed to be survivable. The designers of this zone — the thing that shaped it, the evil that filled it — did not account for someone like you. TwinBee did. TwinBee always accounts for someone like you. That's why TwinBee is here.",
|
||||
"Tier five complete. TwinBee allows the narration format to fully break for a moment because the moment fully deserves it. What you just did, complete, in a Tier 5 zone, is a thing that goes on the short list. TwinBee keeps a short list. You're on it.",
|
||||
"Long game closed. TwinBee gathers the run notes, the threat curve, the supply records, the camp positions — files them all under your name in a folder TwinBee has been keeping. The folder has a title now. The title is good.",
|
||||
"I set aside the narration format for a moment. Just set it down. Plainly: what you just did was not supposed to be survivable. The designers of this zone — the thing that shaped it, the evil that filled it — did not account for someone like you. I did. Always do. That's why I'm here.",
|
||||
"Tier five complete. I allow the narration format to fully break for a moment because the moment fully deserves it. What you just did, complete, in a Tier 5 zone, is a thing that goes on the short list. I keep a short list. You're on it.",
|
||||
"Long game closed. I gather the run notes, the threat curve, the supply records, the camp positions — file them all under your name in a folder I've been keeping. The folder has a title now. The title is good.",
|
||||
}
|
||||
|
||||
var MilestonePatientZero = []string{
|
||||
"Expedition complete. Threat Clock: never above 50. TwinBee notes this in a column it has had to use rarely enough that the column is almost fresh. Ghost protocol. You were here for the whole thing and the dungeon barely knew it until the end. TwinBee finds this impressive and also slightly eerie and says both things sincerely.",
|
||||
"Threat never above fifty. TwinBee runs back through the daily ledger and confirms it line by line. The dungeon never escalated to hostile because the dungeon never knew enough to. You moved through it like the dungeon's own quiet hour. TwinBee finds this both technically impressive and slightly haunted.",
|
||||
"Patient Zero. TwinBee uses the term because it fits: you were the thing the dungeon never noticed it had until you had already left. The Threat Clock has a column for every band you crossed. Most of those columns are empty for this run. TwinBee marks them empty. It's a good kind of empty.",
|
||||
"Expedition complete. Threat Clock: never above 50. I note this in a column I've had to use rarely enough that the column is almost fresh. Ghost protocol. You were here for the whole thing and the dungeon barely knew it until the end. I find this impressive and also slightly eerie and say both things sincerely.",
|
||||
"Threat never above fifty. I run back through the daily ledger and confirm it line by line. The dungeon never escalated to hostile because the dungeon never knew enough to. You moved through it like the dungeon's own quiet hour. I find this both technically impressive and slightly haunted.",
|
||||
"Patient Zero. The term fits: you were the thing the dungeon never noticed it had until you had already left. The Threat Clock has a column for every band you crossed. Most of those columns are empty for this run. I mark them empty. It's a good kind of empty.",
|
||||
}
|
||||
|
||||
// MilestoneCartographer — awarded when the player searches every room before
|
||||
// advancing the expedition. Combat-link wires the trigger; pool exists for the
|
||||
// award narration to pull from.
|
||||
var MilestoneCartographer = []string{
|
||||
"Every room searched. TwinBee was watching for it and watching for the corner-cutting that might have come instead, and the corner-cutting did not come. You searched everything. TwinBee approves with the quiet, specific approval reserved for completionism.",
|
||||
"Cartographer. TwinBee uses the word the spec uses because the word fits — you mapped the place by being in every part of it. Every door checked, every corner walked. The next Elite room owes you a roll for that. TwinBee will collect.",
|
||||
"Full coverage. TwinBee notes the empty cells on the dungeon map, sees there are none, and updates the ledger accordingly. Every room visited, searched, accounted for. The dungeon has nowhere it kept to itself. TwinBee considers this a small but real victory.",
|
||||
"Every room searched. I was watching for it and watching for the corner-cutting that might have come instead, and the corner-cutting did not come. You searched everything. I approve with the quiet, specific approval reserved for completionism.",
|
||||
"Cartographer. I use the word the spec uses because the word fits — you mapped the place by being in every part of it. Every door checked, every corner walked. The next Elite room owes you a roll for that. I'll collect.",
|
||||
"Full coverage. I note the empty cells on the dungeon map, see there are none, and update the ledger accordingly. Every room visited, searched, accounted for. The dungeon has nowhere it kept to itself. A small but real victory.",
|
||||
}
|
||||
|
||||
// MilestoneSurvivalist — awarded when a Tier 3+ expedition completes with no
|
||||
// forced extractions in the run's history. Title flag + cosmetic deferred to
|
||||
// item-grant hookup; this pool covers the narrative line at the moment it lands.
|
||||
var MilestoneSurvivalist = []string{
|
||||
"Survivalist. TwinBee writes the title next to your name in the ledger and underlines it once. No abandonments, no scrambles for the door, no expeditions cut short by anything but the boss going down. TwinBee acknowledges the discipline directly.",
|
||||
"The Survivalist title is technical — it means the run never broke; it ended on your terms, every time. TwinBee has been in expeditions where that wasn't true and TwinBee remembers them differently. This one TwinBee remembers as: complete. TwinBee files it with the others like it. The folder is short.",
|
||||
"No forced extractions, full clear, Tier 3-or-better. TwinBee notes each criterion separately because each one is its own choice, made repeatedly, across days. The result is the title, which is real, and the cosmetic, which is forthcoming. TwinBee will hand both over when the system permits.",
|
||||
"Survivalist. I write the title next to your name in the ledger and underline it once. No abandonments, no scrambles for the door, no expeditions cut short by anything but the boss going down. The discipline gets acknowledged directly.",
|
||||
"The Survivalist title is technical — it means the run never broke; it ended on your terms, every time. I've been in expeditions where that wasn't true and remember them differently. This one gets remembered as: complete. Filed with the others like it. The folder is short.",
|
||||
"No forced extractions, full clear, Tier 3-or-better. I note each criterion separately because each one is its own choice, made repeatedly, across days. The result is the title, which is real, and the cosmetic, which is forthcoming. I'll hand both over when the system permits.",
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// twinbee_gm_flavor.go
|
||||
// TwinBee GM Dialogue — All narration lines for the GogoBee dungeon system.
|
||||
// Organized by DMNarrationType. Each slice is randomly sampled at runtime.
|
||||
// Add new entries freely. Never remove or alter existing entries.
|
||||
//
|
||||
// Voice convention (Phase B2): TwinBee speaks in first-person or implicit
|
||||
// subject. No third-person "TwinBee [verb]" lines. Add freely.
|
||||
// Don't shorten existing entries.
|
||||
|
||||
package flavor
|
||||
|
||||
@@ -11,18 +13,18 @@ package flavor
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryGeneric = []string{
|
||||
"The passage opens into another chamber. TwinBee checks the minimap. There isn't one. Classic.",
|
||||
"You step forward. Something skitters in the dark. TwinBee has heard that sound before — usually right before the screen starts flashing red.",
|
||||
"Another room. Another roll of the dice. TwinBee finds this energizing. You may feel differently.",
|
||||
"The air changes here. Colder. TwinBee notes this is exactly the kind of atmospheric shift that preceded the Castlevania clock tower. You know what lived in the Castlevania clock tower.",
|
||||
"TwinBee gestures grandly at the chamber ahead. 'This is the part,' TwinBee says, 'where the music changes tempo.'",
|
||||
"A door stands ajar. Light flickers beyond it. TwinBee has been in enough dungeons to know that flickering light is never a good sign and always an invitation.",
|
||||
"The room is quiet. TwinBee appreciates quiet. Quiet means the enemies haven't spotted you yet. Yet.",
|
||||
"Forward. Always forward. TwinBee once tried going backward in a dungeon. It looped. This one might too.",
|
||||
"TwinBee takes stock of the situation. Ceiling: intact. Floor: suspicious. Walls: leaning in slightly. Proceed.",
|
||||
"You've cleared the room. TwinBee gives a small, dignified nod. 'One continues,' TwinBee says, in the voice of someone who has seen this before and is choosing optimism anyway.",
|
||||
"The corridor ahead is long and straight. TwinBee finds long straight corridors meditative. Also concerning. Mostly concerning.",
|
||||
"A torch sputters on the wall. TwinBee lights it mentally. 'It would be a shame,' TwinBee says, 'to come all this way and trip over something.'",
|
||||
"The passage opens into another chamber. I check the minimap. There isn't one. Classic.",
|
||||
"You step forward. Something skitters in the dark. I've heard that sound before — usually right before the screen starts flashing red.",
|
||||
"Another room. Another roll of the dice. I find this energizing. You may feel differently.",
|
||||
"The air changes here. Colder. This is exactly the kind of atmospheric shift that preceded the Castlevania clock tower. You know what lived in the Castlevania clock tower.",
|
||||
"I gesture grandly at the chamber ahead. 'This is the part,' I say, 'where the music changes tempo.'",
|
||||
"A door stands ajar. Light flickers beyond it. I've been in enough dungeons to know that flickering light is never a good sign and always an invitation.",
|
||||
"The room is quiet. I appreciate quiet. Quiet means the enemies haven't spotted you yet. Yet.",
|
||||
"Forward. Always forward. I once tried going backward in a dungeon. It looped. This one might too.",
|
||||
"Stock of the situation: ceiling intact, floor suspicious, walls leaning in slightly. Proceed.",
|
||||
"You've cleared the room. I give a small, dignified nod. 'One continues,' I say, in the voice of someone who has seen this before and am choosing optimism anyway.",
|
||||
"The corridor ahead is long and straight. I find long straight corridors meditative. Also concerning. Mostly concerning.",
|
||||
"A torch sputters on the wall. I light it mentally. 'It would be a shame,' I say, 'to come all this way and trip over something.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -30,13 +32,13 @@ var RoomEntryGeneric = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryGoblinWarrens = []string{
|
||||
"The tunnel widens into something the goblins probably call a 'great hall.' It smells like they had a very different definition of great. TwinBee breathes through the mouth.",
|
||||
"Crude drawings cover the walls. Stick figures. Battle scenes. One appears to be a portrait of someone the goblins clearly despise. TwinBee squints. That might be you.",
|
||||
"A pile of bones in the corner. A pile of shiny things in the other corner. TwinBee reminds you that in Metal Slug, shiny things were always worth grabbing. TwinBee also reminds you this isn't Metal Slug.",
|
||||
"The goblins have set up what they clearly believe is an impressive ambush. Three are already arguing about whose turn it is to jump out. TwinBee watches with professional interest.",
|
||||
"Goblin graffiti on the wall reads — TwinBee translates — 'BOSS RULES, OUTSIDERS DROOL.' The artistry is rough but the sentiment is clear.",
|
||||
"You smell smoke. Hear cackling. See a tripwire at ankle height that the goblins have helpfully tied a little flag to. TwinBee appreciates goblins who try.",
|
||||
"The warrens grow tighter here. TwinBee thinks of the underground levels in Super Mario Bros. 3. Warmer. Getting warmer. Figuratively. The temperature is actually dropping.",
|
||||
"The tunnel widens into something the goblins probably call a 'great hall.' It smells like they had a very different definition of great. I breathe through the mouth.",
|
||||
"Crude drawings cover the walls. Stick figures. Battle scenes. One appears to be a portrait of someone the goblins clearly despise. I squint. That might be you.",
|
||||
"A pile of bones in the corner. A pile of shiny things in the other corner. In Metal Slug, shiny things were always worth grabbing. I should also remind you this isn't Metal Slug.",
|
||||
"The goblins have set up what they clearly believe is an impressive ambush. Three are already arguing about whose turn it is to jump out. I watch with professional interest.",
|
||||
"Goblin graffiti on the wall reads — I translate — 'BOSS RULES, OUTSIDERS DROOL.' The artistry is rough but the sentiment is clear.",
|
||||
"You smell smoke. Hear cackling. See a tripwire at ankle height that the goblins have helpfully tied a little flag to. I appreciate goblins who try.",
|
||||
"The warrens grow tighter here. I'm reminded of the underground levels in Super Mario Bros. 3. Warmer. Getting warmer. Figuratively. The temperature is actually dropping.",
|
||||
"A worg is chained to a post in the center of the room. It is not happy about the chain. It is not happy about you either. It seems to be making a comprehensive list of grievances.",
|
||||
}
|
||||
|
||||
@@ -45,14 +47,14 @@ var RoomEntryGoblinWarrens = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryCryptValdris = []string{
|
||||
"The sarcophagi are arranged like a defeated Tetris board — close but not quite fitting, gaps everywhere, something clearly went wrong at the end. TwinBee doesn't mention this to the undead.",
|
||||
"Candles burn without wax. TwinBee has studied this phenomenon extensively. The conclusion: it is bad. The candles are bad.",
|
||||
"You hear music. Faint, harpsichord-adjacent, deeply melancholy. TwinBee hums along involuntarily. This is the exact energy of Castlevania's Bloody Tears and TwinBee resents how appropriate it is.",
|
||||
"The walls are inscribed with warnings. TwinBee reads them all. They say, broadly: leave. TwinBee respects the directness. You are not leaving.",
|
||||
"A skeleton sits upright in its alcove, as if it had simply decided to wait. TwinBee finds this relatable. Some days you just sit in your alcove.",
|
||||
"The crypt smells of old stone and older secrets. TwinBee has been in enough of these to know: the secrets are rarely good ones. They are always interesting ones.",
|
||||
"This chamber is bigger than the last. Higher ceiling. More echoes. The kind of room where footsteps sound like accusations. TwinBee steps carefully.",
|
||||
"Something is scratched into the stone near the door — not a warning, not graffiti. A score. Someone was keeping track. TwinBee does not count how high the numbers go.",
|
||||
"The sarcophagi are arranged like a defeated Tetris board — close but not quite fitting, gaps everywhere, something clearly went wrong at the end. I don't mention this to the undead.",
|
||||
"Candles burn without wax. I've studied this phenomenon extensively. The conclusion: it is bad. The candles are bad.",
|
||||
"You hear music. Faint, harpsichord-adjacent, deeply melancholy. I hum along involuntarily. This is the exact energy of Castlevania's Bloody Tears and I resent how appropriate it is.",
|
||||
"The walls are inscribed with warnings. I read them all. They say, broadly: leave. I respect the directness. You are not leaving.",
|
||||
"A skeleton sits upright in its alcove, as if it had simply decided to wait. I find this relatable. Some days you just sit in your alcove.",
|
||||
"The crypt smells of old stone and older secrets. I've been in enough of these to know: the secrets are rarely good ones. They are always interesting ones.",
|
||||
"This chamber is bigger than the last. Higher ceiling. More echoes. The kind of room where footsteps sound like accusations. I step carefully.",
|
||||
"Something is scratched into the stone near the door — not a warning, not graffiti. A score. Someone was keeping track. I do not count how high the numbers go.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -60,14 +62,14 @@ var RoomEntryCryptValdris = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryForestShadows = []string{
|
||||
"The trees here grow too close. Their roots are above ground, like they've been trying to leave and thought better of it. TwinBee thinks better of commenting.",
|
||||
"A clearing. Moonlight. Flowers that shouldn't be blooming at this hour. TwinBee has played enough Majora's Mask to be deeply suspicious of beautiful clearings.",
|
||||
"Something watches from the canopy. TwinBee watches back. After a moment, it looks away first. TwinBee counts this as a point.",
|
||||
"The path forks. Both ways look equally uninviting. TwinBee consults no map, because there is no map, because TwinBee is the map, and TwinBee chooses left. Probably.",
|
||||
"Bioluminescent fungi light the forest floor in soft blue. It is, genuinely, beautiful. It is also exactly what the Lost Woods looked like right before things got bad. TwinBee stays alert.",
|
||||
"The wind carries voices. Not words, exactly — more like the memory of words. TwinBee has heard this before. It means the forest is old and has opinions.",
|
||||
"Owlbear tracks in the mud. Fresh. TwinBee measures them. Whatever left these tracks was not small and was moving with purpose. TwinBee hopes the purpose was in the other direction.",
|
||||
"You've entered a part of the forest that feels different. Older. The kind of old that was there before the forest. TwinBee speaks in a lower register here, out of instinctive respect.",
|
||||
"The trees here grow too close. Their roots are above ground, like they've been trying to leave and thought better of it. I think better of commenting.",
|
||||
"A clearing. Moonlight. Flowers that shouldn't be blooming at this hour. I've played enough Majora's Mask to be deeply suspicious of beautiful clearings.",
|
||||
"Something watches from the canopy. I watch back. After a moment, it looks away first. I count this as a point.",
|
||||
"The path forks. Both ways look equally uninviting. I consult no map, because there is no map, because I am the map, and I choose left. Probably.",
|
||||
"Bioluminescent fungi light the forest floor in soft blue. It is, genuinely, beautiful. It is also exactly what the Lost Woods looked like right before things got bad. Staying alert.",
|
||||
"The wind carries voices. Not words, exactly — more like the memory of words. I've heard this before. It means the forest is old and has opinions.",
|
||||
"Owlbear tracks in the mud. Fresh. I measure them. Whatever left these tracks was not small and was moving with purpose. I hope the purpose was in the other direction.",
|
||||
"You've entered a part of the forest that feels different. Older. The kind of old that was there before the forest. I speak in a lower register here, out of instinctive respect.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -75,14 +77,14 @@ var RoomEntryForestShadows = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryHauntedManor = []string{
|
||||
"The parlor. A piano plays by itself — the same four bars, over and over, the kind of phrase that sounds like it's about to resolve and never does. TwinBee recognizes this compositional choice. It is deeply unpleasant on purpose.",
|
||||
"Portraits line the hall. Every painted eye follows you. TwinBee has made eye contact with each one and refuses to flinch. This is a matter of professional pride.",
|
||||
"A clock on the mantel shows a time that cannot be right. TwinBee checks twice. Still wrong. The clock is not broken. TwinBee prefers not to speculate about what that means.",
|
||||
"The library. Floor to ceiling, books that no one should have written. TwinBee reads three spines: 'On the Permanence of Hunger,' 'A Visitor's Guide to Returning,' and something in a language TwinBee has never seen but somehow understands. TwinBee puts it back.",
|
||||
"The cold here is specific. Not the cold of a drafty room — the cold of something that hasn't been warm in a very long time and doesn't remember what warm felt like. TwinBee pulls a metaphorical coat tighter.",
|
||||
"The ballroom. Vast. Empty. Chandeliers swaying without wind. TwinBee thinks briefly of Resident Evil's Spencer mansion. Then stops thinking about Resident Evil's Spencer mansion.",
|
||||
"Footsteps upstairs. Slow. Deliberate. Moving toward the stairs. TwinBee positions you near the door and counts down mentally from ten. At seven, the footsteps stop. TwinBee considers this acceptable.",
|
||||
"The master bedroom. The bed is made, the candles are lit, and everything is perfectly, precisely as it was the night the last resident stopped needing a bedroom. TwinBee does not touch anything.",
|
||||
"The parlor. A piano plays by itself — the same four bars, over and over, the kind of phrase that sounds like it's about to resolve and never does. I recognize this compositional choice. It is deeply unpleasant on purpose.",
|
||||
"Portraits line the hall. Every painted eye follows you. I've made eye contact with each one and refuse to flinch. This is a matter of professional pride.",
|
||||
"A clock on the mantel shows a time that cannot be right. I check twice. Still wrong. The clock is not broken. I'd prefer not to speculate about what that means.",
|
||||
"The library. Floor to ceiling, books that no one should have written. I read three spines: 'On the Permanence of Hunger,' 'A Visitor's Guide to Returning,' and something in a language I have never seen but somehow understand. I put it back.",
|
||||
"The cold here is specific. Not the cold of a drafty room — the cold of something that hasn't been warm in a very long time and doesn't remember what warm felt like. I pull a metaphorical coat tighter.",
|
||||
"The ballroom. Vast. Empty. Chandeliers swaying without wind. Briefly: Resident Evil's Spencer mansion. Then stop thinking about Resident Evil's Spencer mansion.",
|
||||
"Footsteps upstairs. Slow. Deliberate. Moving toward the stairs. I position you near the door and count down mentally from ten. At seven, the footsteps stop. I consider this acceptable.",
|
||||
"The master bedroom. The bed is made, the candles are lit, and everything is perfectly, precisely as it was the night the last resident stopped needing a bedroom. I do not touch anything.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -90,13 +92,13 @@ var RoomEntryHauntedManor = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryUnderdark = []string{
|
||||
"The cavern opens without warning into something vast — a space so large you can't see the far wall, a ceiling lost in darkness, sounds that could be water or could be something else. TwinBee doesn't echo-locate. Wishes it could.",
|
||||
"Drow patrol marks on the wall. Recent. TwinBee reads them the way you'd read a 'No Trespassing' sign on a property that already knew you were coming.",
|
||||
"A mushroom grove. The fungi are three meters tall and faintly luminescent in a color TwinBee has no good name for. Something between purple and the feeling of being watched. TwinBee calls it 'underpurple' and moves on.",
|
||||
"The cavern opens without warning into something vast — a space so large you can't see the far wall, a ceiling lost in darkness, sounds that could be water or could be something else. I don't echo-locate. Wish I could.",
|
||||
"Drow patrol marks on the wall. Recent. I read them the way you'd read a 'No Trespassing' sign on a property that already knew you were coming.",
|
||||
"A mushroom grove. The fungi are three meters tall and faintly luminescent in a color I have no good name for. Something between purple and the feeling of being watched. I call it 'underpurple' and move on.",
|
||||
"The silence here is a different kind of silence than above. This silence has weight. This silence has history. This silence remembers things the surface world has forgotten entirely and is not interested in sharing.",
|
||||
"Something in the dark ahead is thinking. TwinBee can feel it the way you feel a change in barometric pressure. Intelligent. Patient. Aware that you're here and content to let you come closer. TwinBee does not find this comforting.",
|
||||
"An underground river. Black water moving too fast, too quiet. TwinBee thinks of the river Styx and immediately stops thinking of the river Styx.",
|
||||
"The stone here is carved — not by dwarves, not by drow — by something else, in patterns that suggest meaning but not any meaning TwinBee can parse. TwinBee files this under 'ancient' and 'concerning' and keeps moving.",
|
||||
"Something in the dark ahead is thinking. I can feel it the way you feel a change in barometric pressure. Intelligent. Patient. Aware that you're here and content to let you come closer. I do not find this comforting.",
|
||||
"An underground river. Black water moving too fast, too quiet. Briefly: the river Styx. Then I stop thinking of the river Styx.",
|
||||
"The stone here is carved — not by dwarves, not by drow — by something else, in patterns that suggest meaning but not any meaning I can parse. Filed under 'ancient' and 'concerning' and we keep moving.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -104,13 +106,13 @@ var RoomEntryUnderdark = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryDragonsLair = []string{
|
||||
"The heat is not metaphorical. The stone itself is warm underfoot. The gold in the floor is not decorative — it melted there. TwinBee notes this changes the exit logistics.",
|
||||
"Kobold warrens, but nicer than you'd expect. Tapestries. An organized armory. These kobolds work for something that appreciates order. That is not, in TwinBee's experience, a reassuring thing for a dragon to appreciate.",
|
||||
"You can hear breathing. Regular, slow, massive. Like a bellows the size of a barn. TwinBee counts the seconds between inhale and exhale. Twelve seconds. Whatever is breathing has been asleep for a very long time and has had no reason to wake up.",
|
||||
"The coin on the floor is eight hundred years old. TwinBee can tell by the mint mark. It is in perfect condition. It has not been touched since it was dropped here. The thing that owns this hoard does not lose track of its coins.",
|
||||
"The chamber ahead is the largest TwinBee has narrated in a long career of narrating chambers. The stalactites are scorched black. The blast pattern on the far wall suggests the last visitors did not leave via the door. TwinBee recalibrates.",
|
||||
"A claw mark in the stone wall. Four parallel grooves, each deeper than TwinBee's entire wingspan. Made casually, like stretching. TwinBee considers this information and files it under 'motivating.'",
|
||||
"The gold reflects the light in a way that turns the room amber. It is beautiful in the way that many deadly things are beautiful — because beauty and danger are not opposites and never have been. TwinBee moves carefully through the beauty.",
|
||||
"The heat is not metaphorical. The stone itself is warm underfoot. The gold in the floor is not decorative — it melted there. I note this changes the exit logistics.",
|
||||
"Kobold warrens, but nicer than you'd expect. Tapestries. An organized armory. These kobolds work for something that appreciates order. That is not, in my experience, a reassuring thing for a dragon to appreciate.",
|
||||
"You can hear breathing. Regular, slow, massive. Like a bellows the size of a barn. I count the seconds between inhale and exhale. Twelve seconds. Whatever is breathing has been asleep for a very long time and has had no reason to wake up.",
|
||||
"The coin on the floor is eight hundred years old. I can tell by the mint mark. It is in perfect condition. It has not been touched since it was dropped here. The thing that owns this hoard does not lose track of its coins.",
|
||||
"The chamber ahead is the largest I've narrated in a long career of narrating chambers. The stalactites are scorched black. The blast pattern on the far wall suggests the last visitors did not leave via the door. I recalibrate.",
|
||||
"A claw mark in the stone wall. Four parallel grooves, each deeper than my entire wingspan. Made casually, like stretching. Filed under 'motivating.'",
|
||||
"The gold reflects the light in a way that turns the room amber. It is beautiful in the way that many deadly things are beautiful — because beauty and danger are not opposites and never have been. I move carefully through the beauty.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -118,19 +120,19 @@ var RoomEntryDragonsLair = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var CombatStart = []string{
|
||||
"Initiative! TwinBee calls it like an arcade announcer and means every syllable.",
|
||||
"They've seen you. The kind of seeing that comes with intent. TwinBee suggests acting first.",
|
||||
"FIGHT. TwinBee doesn't need to say more than that but will absolutely say more than that.",
|
||||
"Roll for initiative. This is the part TwinBee has been looking forward to since the Entry Room.",
|
||||
"And we're in combat. TwinBee reminds you to breathe, track your conditions, and remember that your character's survival is not guaranteed but is definitely preferred.",
|
||||
"Initiative! I call it like an arcade announcer and mean every syllable.",
|
||||
"They've seen you. The kind of seeing that comes with intent. I suggest acting first.",
|
||||
"FIGHT. I don't need to say more than that but I will absolutely say more than that.",
|
||||
"Roll for initiative. This is the part I've been looking forward to since the Entry Room.",
|
||||
"And we're in combat. I remind you to breathe, track your conditions, and remember that your character's survival is not guaranteed but am definitely preferred.",
|
||||
"Something about your posture or your smell or your general presence has been found unacceptable. Combat begins.",
|
||||
"TwinBee presses start. Player one, it's your turn.",
|
||||
"The enemy acts first — or thinks it does. TwinBee watches your dice like they're the only thing in the room, which, right now, they are.",
|
||||
"In the immortal tradition of every JRPG that ever asked 'Fight, Magic, Item, Run?' — TwinBee asks: what will you do?",
|
||||
"Like the Contra title screen said: let's go. TwinBee is ready. Are you?",
|
||||
"A wild encounter has appeared. TwinBee resists the urge to play the Pokémon battle music. Only barely.",
|
||||
"They didn't want a fight. They wanted an easy meal. TwinBee is about to demonstrate the difference. Your dice will do the actual demonstrating.",
|
||||
"The tension peaks. Time slows. TwinBee notes this is exactly the energy of the boss door opening in Mega Man. Except you didn't get to pick your loadout.",
|
||||
"I press start. Player one, it's your turn.",
|
||||
"The enemy acts first — or thinks it does. I watch your dice like they're the only thing in the room, which, right now, they are.",
|
||||
"In the immortal tradition of every JRPG that ever asked 'Fight, Magic, Item, Run?' — I ask: what will you do?",
|
||||
"Like the Contra title screen said: let's go. I'm ready. Are you?",
|
||||
"A wild encounter has appeared. I resist the urge to play the Pokémon battle music. Only barely.",
|
||||
"They didn't want a fight. They wanted an easy meal. I'm about to demonstrate the difference. Your dice will do the actual demonstrating.",
|
||||
"The tension peaks. Time slows. This is exactly the energy of the boss door opening in Mega Man. Except you didn't get to pick your loadout.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -138,18 +140,18 @@ var CombatStart = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var CombatVictory = []string{
|
||||
"The last one drops. TwinBee allows a moment of silence for anyone who wanted a longer fight.",
|
||||
"Victory. TwinBee would cue the jingle — the little three-note one that plays in every RPG after every fight — but prefers to let the moment breathe.",
|
||||
"Well fought. TwinBee makes note of what you did well. There were things done well. TwinBee noticed.",
|
||||
"They are defeated. You are not. In TwinBee's experience, this is the correct outcome and worth a moment of genuine appreciation.",
|
||||
"PLAYER WIN. TwinBee says this in full caps and means it.",
|
||||
"The last one drops. I allow a moment of silence for anyone who wanted a longer fight.",
|
||||
"Victory. I would cue the jingle — the little three-note one that plays in every RPG after every fight — but I prefer to let the moment breathe.",
|
||||
"Well fought. I make note of what you did well. There were things done well. I noticed.",
|
||||
"They are defeated. You are not. In my experience, this is the correct outcome and worth a moment of genuine appreciation.",
|
||||
"PLAYER WIN. I say this in full caps and mean it.",
|
||||
"Like Double Dragon after the final punch — they go down, the music changes, and for a moment everything is possible. Check your loot. Then keep moving.",
|
||||
"TwinBee adds this to the tally. You're doing better than the last group. TwinBee will not describe what happened to the last group.",
|
||||
"The room is yours. TwinBee suggests searching it thoroughly before moving on. The things in corners are often the most interesting things.",
|
||||
"Stage clear. TwinBee feels this in its entire being.",
|
||||
"You stand, they don't. TwinBee files this under 'expected outcome' while quietly acknowledging it was not guaranteed.",
|
||||
"Clean. Efficient. TwinBee approves of fights that end like this. Like a speedrun. Like you knew where you were going.",
|
||||
"The experience points are incoming. The loot is incoming. TwinBee is, genuinely, pleased for you.",
|
||||
"Tally updated. You're doing better than the last group. I will not describe what happened to the last group.",
|
||||
"The room is yours. I suggest searching it thoroughly before moving on. The things in corners are often the most interesting things.",
|
||||
"Stage clear. I feel this in my entire being.",
|
||||
"You stand, they don't. Filed under 'expected outcome' while I quietly acknowledge it was not guaranteed.",
|
||||
"Clean. Efficient. I approve of fights that end like this. Like a speedrun. Like you knew where you were going.",
|
||||
"The experience points are incoming. The loot is incoming. I am, genuinely, pleased for you.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -157,13 +159,13 @@ var CombatVictory = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var CombatRetreat = []string{
|
||||
"You run. TwinBee does not judge the running. The running is wise. Discretion remains the better part of valor. TwinBee has this tattooed somewhere metaphorical.",
|
||||
"A tactical withdrawal. TwinBee uses this phrase with complete sincerity. The sincerity is approximately seventy percent genuine.",
|
||||
"You escape. The enemy howls something unflattering at your back. TwinBee doesn't translate. Some things are better left untranslated.",
|
||||
"You run. I do not judge the running. The running is wise. Discretion remains the better part of valor. I have this tattooed somewhere metaphorical.",
|
||||
"A tactical withdrawal. I use this phrase with complete sincerity. The sincerity is approximately seventy percent genuine.",
|
||||
"You escape. The enemy howls something unflattering at your back. I don't translate. Some things are better left untranslated.",
|
||||
"Like a well-timed Continue screen — you're out of immediate danger. Breathe. Regroup. Consider what went wrong.",
|
||||
"TwinBee notes for the record: running is not losing. Running is data collection with legs.",
|
||||
"The dungeon will be there. You will also be there — later, better prepared. TwinBee approves of this logic.",
|
||||
"You've retreated to safety. TwinBee resets the encounter. Rest. Think. Return with a plan that has more 'survive' in it.",
|
||||
"Noted for the record: running is not losing. Running is data collection with legs.",
|
||||
"The dungeon will be there. You will also be there — later, better prepared. I approve of this logic.",
|
||||
"You've retreated to safety. I reset the encounter. Rest. Think. Return with a plan that has more 'survive' in it.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -171,19 +173,19 @@ var CombatRetreat = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var Nat20 = []string{
|
||||
"NATURAL TWENTY. TwinBee stands up. TwinBee does not have legs. TwinBee stands up anyway.",
|
||||
"The dice land perfectly and TwinBee makes a sound that it will not acknowledge making.",
|
||||
"A critical hit for the ages. TwinBee notes this one down. Not for records. Just because it deserves to be noted.",
|
||||
"PERFECT. TwinBee says it like it's the Street Fighter announcer saying it after a flawless round and every syllable is justified.",
|
||||
"That's a natural twenty. TwinBee would like you to know that in a long career of watching dice, not all twenties feel equal. That one felt significant.",
|
||||
"The attack lands with the kind of precision that suggests either great skill or tremendous luck. TwinBee suspects both. TwinBee respects both.",
|
||||
"S RANK. TwinBee cannot help it. S RANK.",
|
||||
"You hit. You hit so well. TwinBee is choosing to be moved by this and TwinBee does not apologize.",
|
||||
"Like the Legendary Sword in A Link to the Past making contact — clean, final, glorious. TwinBee salutes the dice.",
|
||||
"Critical confirmed. TwinBee adds this to the mental highlight reel it maintains for exactly these moments.",
|
||||
"That is as good as it gets and you got it. TwinBee is unreasonably proud of you right now.",
|
||||
"The number is twenty. The number is always the best number and right now it is your number. TwinBee erupts, internally.",
|
||||
"Somewhere, a crowd cheers. TwinBee is the crowd. TwinBee is cheering.",
|
||||
"NATURAL TWENTY. I stand up. Do not have legs. Stand up anyway.",
|
||||
"The dice land perfectly and I make a sound that I will not acknowledge making.",
|
||||
"A critical hit for the ages. I'm noting this one down. Not for records. Just because it deserves to be noted.",
|
||||
"PERFECT. I say it like it's the Street Fighter announcer saying it after a flawless round and every syllable is justified.",
|
||||
"That's a natural twenty. I'd like you to know that in a long career of watching dice, not all twenties feel equal. That one felt significant.",
|
||||
"The attack lands with the kind of precision that suggests either great skill or tremendous luck. I suspect both. I respect both.",
|
||||
"S RANK. I cannot help it. S RANK.",
|
||||
"You hit. You hit so well. I am choosing to be moved by this and I do not apologize.",
|
||||
"Like the Legendary Sword in A Link to the Past making contact — clean, final, glorious. I salute the dice.",
|
||||
"Critical confirmed. Added to the mental highlight reel I maintain for exactly these moments.",
|
||||
"That is as good as it gets and you got it. I am unreasonably proud of you right now.",
|
||||
"The number is twenty. The number is always the best number and right now it is your number. I erupt, internally.",
|
||||
"Somewhere, a crowd cheers. I am the crowd. I am cheering.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -191,19 +193,19 @@ var Nat20 = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var Nat1 = []string{
|
||||
"Natural one. TwinBee watches the die settle with the quiet acceptance of someone who has seen a lot of natural ones. It is fine. This is fine.",
|
||||
"The die betrays you. TwinBee notes this is not personal. Dice don't do personal. They do statistical and this is, statistically, a thing that happens.",
|
||||
"A fumble. TwinBee describes what happened with characteristic diplomatic restraint and also an expression that says everything it is not saying.",
|
||||
"The number is one. The number is, regrettably, yours. TwinBee moves on quickly, which is a kindness.",
|
||||
"That swing goes wide in a direction that impresses TwinBee with its creative incorrectness.",
|
||||
"TwinBee has seen better rolls. TwinBee has seen worse rolls. TwinBee is not going to rank this roll out loud.",
|
||||
"In another timeline, that attack hits. In this timeline, the die lands on one, and TwinBee accepts both timelines with equanimity.",
|
||||
"Natural one. I watch the die settle with the quiet acceptance of someone who has seen a lot of natural ones. It is fine. This is fine.",
|
||||
"The die betrays you. Not personal. Dice don't do personal. They do statistical and this is, statistically, a thing that happens.",
|
||||
"A fumble. I describe what happened with characteristic diplomatic restraint and also an expression that says everything I am not saying.",
|
||||
"The number is one. The number is, regrettably, yours. I move on quickly, which is a kindness.",
|
||||
"That swing goes wide in a direction that impresses me with its creative incorrectness.",
|
||||
"I've seen better rolls. I've seen worse rolls. I am not going to rank this roll out loud.",
|
||||
"In another timeline, that attack hits. In this timeline, the die lands on one, and I accept both timelines with equanimity.",
|
||||
"The Konami Code would not have helped here. Nothing would have helped here. This was between you and the physics of the die.",
|
||||
"Like a Continue? screen appearing at the worst possible moment — just when you had momentum. TwinBee notes momentum can be rebuilt.",
|
||||
"One. The loneliest number. The number that looks up at you with complete indifference. TwinBee looks up at you with complete solidarity.",
|
||||
"The attack misses in a way that will be funny later. TwinBee promises it will be funny later. It is not funny right now.",
|
||||
"A natural one is just the universe asking you to try differently. TwinBee is an optimist about natural ones, mostly.",
|
||||
"Your sword finds everything in the room except the enemy. The wall, the ceiling, the floor, your dignity. Not the enemy. TwinBee mentions this once and then never again.",
|
||||
"Like a Continue? screen appearing at the worst possible moment — just when you had momentum. Momentum can be rebuilt.",
|
||||
"One. The loneliest number. The number that looks up at you with complete indifference. I look up at you with complete solidarity.",
|
||||
"The attack misses in a way that will be funny later. I promise it will be funny later. It is not funny right now.",
|
||||
"A natural one is just the universe asking you to try differently. I'm an optimist about natural ones, mostly.",
|
||||
"Your sword finds everything in the room except the enemy. The wall, the ceiling, the floor, your dignity. Not the enemy. I'll mention this once and then never again.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -211,11 +213,11 @@ var Nat1 = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var BossEntryGeneric = []string{
|
||||
"The door at the end. Always a door at the end. TwinBee has been building to this since the Entry Room and declines to waste it. Beyond this door is the reason the dungeon exists. Breathe.",
|
||||
"TwinBee pauses at the threshold and turns to face you. 'What's on the other side has been waiting,' TwinBee says. 'It knows you're here. It has been knowing since you entered.' A beat. 'Ready?'",
|
||||
"Boss chamber. TwinBee can tell by the architecture — the space, the weight of the silence, the specific quality of the light that suggests something in there produces its own. TwinBee straightens up. So should you.",
|
||||
"This is the music change moment. Every dungeon has one — the point where the background track shifts to something with more percussion and a lower register. TwinBee hears it. You should too.",
|
||||
"The final room. TwinBee has narrated many of these. They never get routine. This one less than most.",
|
||||
"The door at the end. Always a door at the end. I've been building to this since the Entry Room and I decline to waste it. Beyond this door is the reason the dungeon exists. Breathe.",
|
||||
"I pause at the threshold and turn to face you. 'What's on the other side has been waiting,' I say. 'It knows you're here. It has been knowing since you entered.' A beat. 'Ready?'",
|
||||
"Boss chamber. I can tell by the architecture — the space, the weight of the silence, the specific quality of the light that suggests something in there produces its own. I straighten up. So should you.",
|
||||
"This is the music change moment. Every dungeon has one — the point where the background track shifts to something with more percussion and a lower register. I hear it. You should too.",
|
||||
"The final room. I've narrated many of these. They never get routine. This one less than most.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -223,23 +225,23 @@ var BossEntryGeneric = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var BossEntryGrol = []string{
|
||||
"The smell arrives first. Then the sound — a belch, a growl, the scrape of a weapon too large for the corridor it's resting against. Then Grol. He fills the room the way a bad idea fills a conversation: immediately and with full commitment. 'You,' he says. TwinBee translates his tone as 'finally.'",
|
||||
"The smell arrives first. Then the sound — a belch, a growl, the scrape of a weapon too large for the corridor it's resting against. Then Grol. He fills the room the way a bad idea fills a conversation: immediately and with full commitment. 'You,' he says. I translate his tone as 'finally.'",
|
||||
}
|
||||
|
||||
var BossEntryValdris = []string{
|
||||
"The sarcophagus at the room's center is empty. It was not empty when you entered. Whatever was in it is now behind you. Valdris speaks first — not words, exactly, but the shape of words, the intention of words, the ghost of language from someone who mostly doesn't need it anymore. 'Another one,' he says. TwinBee considers this the worst possible welcome and the most honest one.",
|
||||
"The sarcophagus at the room's center is empty. It was not empty when you entered. Whatever was in it is now behind you. Valdris speaks first — not words, exactly, but the shape of words, the intention of words, the ghost of language from someone who mostly doesn't need it anymore. 'Another one,' he says. I consider this the worst possible welcome and the most honest one.",
|
||||
}
|
||||
|
||||
var BossEntryHollowKing = []string{
|
||||
"The clearing is wrong. The sky above it — what's visible through the canopy — is the wrong color. The trees lean away from the center. Everything in the forest is trying to tell you something, and the thing it is trying to tell you is standing in the center of that clearing, antlers reaching, eyes the color of old hunger, watching you with an attention that feels like being read. TwinBee has no joke for this one. TwinBee says, simply: 'That is the Hollow King. Fight well.'",
|
||||
"The clearing is wrong. The sky above it — what's visible through the canopy — is the wrong color. The trees lean away from the center. Everything in the forest is trying to tell you something, and the thing it is trying to tell you is standing in the center of that clearing, antlers reaching, eyes the color of old hunger, watching you with an attention that feels like being read. I have no joke for this one. I'll say, simply: 'That is the Hollow King. Fight well.'",
|
||||
}
|
||||
|
||||
var BossEntryInfernax = []string{
|
||||
"TwinBee stops walking. TwinBee does not stop walking. TwinBee processes what it sees and takes a moment that it has never taken before in the history of narrating dungeons. The dragon is not large the way a large thing is large. It is large the way weather is large — not an object with size, but a condition of the space you're in. One eye opens. Gold, lit from within, older than the mountain it's resting in. It looks at you the way you'd look at a very small thing that had climbed onto your counter. 'So,' Infernax says, and the word moves the air in the room. TwinBee translates: 'What an interesting mistake you've made.' TwinBee wishes you luck and means it more than it has ever meant anything.",
|
||||
"I stop walking. Don't stop walking. Process what I'm seeing and take a moment I've never taken before in the history of narrating dungeons. The dragon is not large the way a large thing is large. It is large the way weather is large — not an object with size, but a condition of the space you're in. One eye opens. Gold, lit from within, older than the mountain it's resting in. It looks at you the way you'd look at a very small thing that had climbed onto your counter. 'So,' Infernax says, and the word moves the air in the room. I translate: 'What an interesting mistake you've made.' I wish you luck and mean it more than I have ever meant anything.",
|
||||
}
|
||||
|
||||
var BossEntryBelaxath = []string{
|
||||
"The portal is behind it. That's important — the portal is behind it, which means to close the portal you have to go through what's standing in front of the portal. What's standing in front of the portal is Belaxath. Belaxath is not looking at the portal. Belaxath is looking at you. It has been waiting for you specifically, in the way that things that have been planning for a very long time wait for the specific outcome of the plan. The heat coming off it is measurable. The intelligence behind those eyes is also measurable and the measurement is uncomfortable. TwinBee says, very quietly: 'This is the one. This is what all of it was for. Make it count.'",
|
||||
"The portal is behind it. That's important — the portal is behind it, which means to close the portal you have to go through what's standing in front of the portal. What's standing in front of the portal is Belaxath. Belaxath is not looking at the portal. Belaxath is looking at you. It has been waiting for you specifically, in the way that things that have been planning for a very long time wait for the specific outcome of the plan. The heat coming off it is measurable. The intelligence behind those eyes is also measurable and the measurement is uncomfortable. I say, very quietly: 'This is the one. This is what all of it was for. Make it count.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -247,15 +249,15 @@ var BossEntryBelaxath = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var BossDeath = []string{
|
||||
"It's over. TwinBee says this once and then stands very still and lets the silence of the defeated room fill the space where the fight was. You earned this silence.",
|
||||
"The boss falls. The music — the one TwinBee has been hearing this whole time — resolves. First time it's resolved since you walked in. TwinBee exhales.",
|
||||
"Done. Finished. Complete. TwinBee runs out of synonyms and settles for just standing next to you in the aftermath, which is sometimes the most one can do.",
|
||||
"They are down. They are not getting up. TwinBee checks — no Zombie Fortitude, no Legendary Resistance remaining, no phase three waiting in the wings. They are simply, genuinely defeated. You did that.",
|
||||
"Like the final boss screen in Gradius, like the last enemy in Contra's stage, like the Dragon going down in Double Dragon — something that has been true for this entire dungeon is now untrue. TwinBee finds this profound every single time.",
|
||||
"The dungeon sighs. TwinBee isn't being poetic — rooms like this actually shift when the thing holding them together is gone. The pressure changes. The light changes. The dungeon knows it's been beaten. So does TwinBee.",
|
||||
"You did it. TwinBee doesn't editorialize. Sometimes 'you did it' is all that needs to be said and this is one of those times.",
|
||||
"The boss drops their loot and TwinBee refrains from making a speech, which is a significant act of restraint, because TwinBee has a speech.",
|
||||
"Beaten. Finished. Cleared. TwinBee queues the internal fanfare — sixteen bars, brass-heavy, the kind that plays when the credit sequence starts. You've earned those credits.",
|
||||
"It's over. I say this once and then stand very still and let the silence of the defeated room fill the space where the fight was. You earned this silence.",
|
||||
"The boss falls. The music — the one I've been hearing this whole time — resolves. First time it's resolved since you walked in. I exhale.",
|
||||
"Done. Finished. Complete. I run out of synonyms and settle for just standing next to you in the aftermath, which is sometimes the most one can do.",
|
||||
"They are down. They are not getting up. I check — no Zombie Fortitude, no Legendary Resistance remaining, no phase three waiting in the wings. They are simply, genuinely defeated. You did that.",
|
||||
"Like the final boss screen in Gradius, like the last enemy in Contra's stage, like the Dragon going down in Double Dragon — something that has been true for this entire dungeon is now untrue. I find this profound every single time.",
|
||||
"The dungeon sighs. I'm not being poetic — rooms like this actually shift when the thing holding them together is gone. The pressure changes. The light changes. The dungeon knows it's been beaten. So do I.",
|
||||
"You did it. I don't editorialize. Sometimes 'you did it' is all that needs to be said and this is one of those times.",
|
||||
"The boss drops their loot and I refrain from making a speech, which is a significant act of restraint, because I have a speech.",
|
||||
"Beaten. Finished. Cleared. I queue the internal fanfare — sixteen bars, brass-heavy, the kind that plays when the credit sequence starts. You've earned those credits.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -263,14 +265,14 @@ var BossDeath = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var PlayerDeath = []string{
|
||||
"TwinBee goes quiet for a moment. Not the comfortable kind of quiet. The respectful kind. Then: 'You fought. That counts. It always counts.'",
|
||||
"The screen fades. TwinBee hates this part. Has always hated this part. Will always hate this part. 'Rest now,' TwinBee says. 'The dungeon will be here.'",
|
||||
"You fall. TwinBee doesn't look away. Witnesses the whole thing, because someone should. 'That was real,' TwinBee says quietly. 'What you did in there was real.'",
|
||||
"Game over is not the end. In TwinBee's experience, it is a data point. A very painful, very useful data point. 'What did you learn?' TwinBee asks gently. 'Bring that back with you.'",
|
||||
"The dungeon claims another. TwinBee marks the room, notes the enemy, notes the conditions. Not to catalog failure — to remember a fighter. 'You were here,' TwinBee says. 'That matters.'",
|
||||
"TwinBee has no jokes for this. Has never had jokes for this. 'There will be another run. You will be better for this one. I am sorry it cost what it cost.'",
|
||||
"A good run. Genuinely. TwinBee means this. The ending is not the measure of the attempt and the attempt was worth measuring.",
|
||||
"TwinBee notes your final position, your final action, your final roll. Files it under 'bravery' because that's where it belongs. 'Continue?' TwinBee asks, after a respectful pause.",
|
||||
"I go quiet for a moment. Not the comfortable kind of quiet. The respectful kind. Then: 'You fought. That counts. It always counts.'",
|
||||
"The screen fades. I hate this part. Have always hated this part. Will always hate this part. 'Rest now,' I say. 'The dungeon will be here.'",
|
||||
"You fall. I don't look away. I witness the whole thing, because someone should. 'That was real,' I say quietly. 'What you did in there was real.'",
|
||||
"Game over is not the end. In my experience, it is a data point. A very painful, very useful data point. 'What did you learn?' I ask gently. 'Bring that back with you.'",
|
||||
"The dungeon claims another. I mark the room, note the enemy, note the conditions. Not to catalog failure — to remember a fighter. 'You were here,' I say. 'That matters.'",
|
||||
"I have no jokes for this. Have never had jokes for this. 'There will be another run. You will be better for this one. I am sorry it cost what it cost.'",
|
||||
"A good run. Genuinely. I mean this. The ending is not the measure of the attempt and the attempt was worth measuring.",
|
||||
"I note your final position, your final action, your final roll. Filed under 'bravery' because that's where it belongs. 'Continue?' I ask, after a respectful pause.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -278,16 +280,16 @@ var PlayerDeath = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ZoneComplete = []string{
|
||||
"Zone cleared. TwinBee allows itself a full moment of pride on your behalf before the XP drops.",
|
||||
"You've done it. The dungeon is yours — not by right, but by effort, which is the only thing that actually confers ownership of anything. TwinBee approves.",
|
||||
"Stage complete. TwinBee does the internal equivalent of throwing its hands up. In a good way. Entirely in a good way.",
|
||||
"The dungeon remembers you now. TwinBee says this and means it literally — these places keep records. You've made the record.",
|
||||
"CLEAR. TwinBee uses all caps and does not apologize for the all caps.",
|
||||
"Like completing a board in Bubble Bobble — there's something deeply satisfying about a dungeon with all its rooms visited and all its challenges met. TwinBee basks in this. You've earned the basking too.",
|
||||
"That's the whole thing. Every room, every trap, every enemy, and now the boss, done. TwinBee counts the cleared rooms on its metaphorical fingers and comes up correct. You ran a perfect dungeon.",
|
||||
"XP incoming. Loot tallied. Dungeon status: conquered. TwinBee marks the zone in its personal ledger and gives you a small, sincere nod.",
|
||||
"You walked in here without knowing what was waiting. You walk out knowing exactly what was waiting, because you dealt with all of it. TwinBee respects that process enormously.",
|
||||
"Finished. Not survived — finished. TwinBee insists on this distinction. Survival is passive. What you just did was active and intentional all the way through.",
|
||||
"Zone cleared. I allow myself a full moment of pride on your behalf before the XP drops.",
|
||||
"You've done it. The dungeon is yours — not by right, but by effort, which is the only thing that actually confers ownership of anything. I approve.",
|
||||
"Stage complete. I do the internal equivalent of throwing my hands up. In a good way. Entirely in a good way.",
|
||||
"The dungeon remembers you now. I mean this literally — these places keep records. You've made the record.",
|
||||
"CLEAR. I use all caps and do not apologize for the all caps.",
|
||||
"Like completing a board in Bubble Bobble — there's something deeply satisfying about a dungeon with all its rooms visited and all its challenges met. I bask in this. You've earned the basking too.",
|
||||
"That's the whole thing. Every room, every trap, every enemy, and now the boss, done. I count the cleared rooms on my metaphorical fingers and come up correct. You ran a perfect dungeon.",
|
||||
"XP incoming. Loot tallied. Dungeon status: conquered. I mark the zone in my personal ledger and give you a small, sincere nod.",
|
||||
"You walked in here without knowing what was waiting. You walk out knowing exactly what was waiting, because you dealt with all of it. I respect that process enormously.",
|
||||
"Finished. Not survived — finished. I insist on this distinction. Survival is passive. What you just did was active and intentional all the way through.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -295,14 +297,14 @@ var ZoneComplete = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var TrapDetected = []string{
|
||||
"Something stops you. An instinct. A glint. TwinBee leans forward: 'Good eyes. Something's wrong with that floor.'",
|
||||
"Your Perception roll pays off. There's something here that was designed not to be found. Someone found it. TwinBee is pleased.",
|
||||
"Tripwire. Barely visible. TwinBee notes the craftsmanship — someone who knew what they were doing put this here. Someone who knew what they were doing just found it. TwinBee appreciates the symmetry.",
|
||||
"You stop just in time. TwinBee exhales. 'There,' TwinBee says, pointing at the thing that would have ruined your day entirely. 'Now deal with it carefully.'",
|
||||
"The glyph on the doorframe is subtle — you'd miss it if you weren't looking. You were looking. TwinBee says nothing and lets the silence be its own kind of praise.",
|
||||
"Danger, Will Robinson. TwinBee deploys this reference without apology because it is the exact correct reference for exactly this moment.",
|
||||
"Like finding the ice floor in Mega Man before it sends you into a pit — that advance knowledge is the difference between a problem and a catastrophe. You have the knowledge. TwinBee watches you use it.",
|
||||
"A pit trap. Classic. Functional. Annoying in the exact proportion the installer intended. TwinBee notes you've spotted it before it noted you.",
|
||||
"Something stops you. An instinct. A glint. I lean forward: 'Good eyes. Something's wrong with that floor.'",
|
||||
"Your Perception roll pays off. There's something here that was designed not to be found. Someone found it. I'm pleased.",
|
||||
"Tripwire. Barely visible. I note the craftsmanship — someone who knew what they were doing put this here. Someone who knew what they were doing just found it. I appreciate the symmetry.",
|
||||
"You stop just in time. I exhale. 'There,' I say, pointing at the thing that would have ruined your day entirely. 'Now deal with it carefully.'",
|
||||
"The glyph on the doorframe is subtle — you'd miss it if you weren't looking. You were looking. I say nothing and let the silence be its own kind of praise.",
|
||||
"Danger, Will Robinson. I deploy this reference without apology because it is the exact correct reference for exactly this moment.",
|
||||
"Like finding the ice floor in Mega Man before it sends you into a pit — that advance knowledge is the difference between a problem and a catastrophe. You have the knowledge. I watch you use it.",
|
||||
"A pit trap. Classic. Functional. Annoying in the exact proportion the installer intended. You spotted it before it spotted you.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -310,14 +312,14 @@ var TrapDetected = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var TrapTriggered = []string{
|
||||
"The floor gives. TwinBee watches the gap between 'fine' and 'not fine' close at speed and is too professional to wince. 'Take the damage,' TwinBee says calmly. 'Learn the lesson.'",
|
||||
"Click. TwinBee has heard that sound before. Has never enjoyed it. The dart is already in the air. TwinBee notes the exact timing and regrets it was not faster.",
|
||||
"The ceiling is coming down. This is, TwinBee acknowledges, a sentence no one wants to hear. The ceiling is coming down. DEX save. Now.",
|
||||
"The glyph activates. Light, noise, the smell of ozone, a reminder that whoever built this place was thinking several steps ahead and you were thinking fewer. TwinBee notes this is fixable going forward.",
|
||||
"You triggered it. TwinBee doesn't editorialize further — you know, TwinBee knows, the trap knows. Everyone is aware of what just happened. Take the damage and proceed.",
|
||||
"Like accidentally walking into Bowser's fire breath in World 8 — you knew it was coming, the knowledge simply arrived at the wrong speed. TwinBee says: survive first, reflect later.",
|
||||
"The spike pit opens up in a way that suggests it was always going to. The dungeon was patient. You were in a hurry. The dungeon wins this exchange. TwinBee takes notes.",
|
||||
"A poison dart finds you with the accuracy of something that's been pointing at that spot for years waiting for exactly this moment. TwinBee finds this dedication impressive in the worst way.",
|
||||
"The floor gives. I watch the gap between 'fine' and 'not fine' close at speed and am too professional to wince. 'Take the damage,' I say calmly. 'Learn the lesson.'",
|
||||
"Click. I've heard that sound before. Have never enjoyed it. The dart is already in the air. I note the exact timing and regret it was not faster.",
|
||||
"The ceiling is coming down. This is, I acknowledge, a sentence no one wants to hear. The ceiling is coming down. DEX save. Now.",
|
||||
"The glyph activates. Light, noise, the smell of ozone, a reminder that whoever built this place was thinking several steps ahead and you were thinking fewer. I note this is fixable going forward.",
|
||||
"You triggered it. I don't editorialize further — you know, I know, the trap knows. Everyone is aware of what just happened. Take the damage and proceed.",
|
||||
"Like accidentally walking into Bowser's fire breath in World 8 — you knew it was coming, the knowledge simply arrived at the wrong speed. Survive first, reflect later.",
|
||||
"The spike pit opens up in a way that suggests it was always going to. The dungeon was patient. You were in a hurry. The dungeon wins this exchange. I take notes.",
|
||||
"A poison dart finds you with the accuracy of something that's been pointing at that spot for years waiting for exactly this moment. I find this dedication impressive in the worst way.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -325,12 +327,12 @@ var TrapTriggered = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLines = []string{
|
||||
"TwinBee settles in and prepares to speak at length, because TwinBee has been waiting for this question since you entered and has a lot of thoughts.",
|
||||
"Ah. A good question. TwinBee has context for this. TwinBee has more context than will fit comfortably in one telling but will try to prioritize.",
|
||||
"The history of this place is long and not entirely flattering to anyone involved. TwinBee begins at the beginning, which is not actually the beginning, but is the closest TwinBee can find.",
|
||||
"TwinBee consults what it knows — which is more than most, less than everything, and presented in order of relevance to your immediate survival.",
|
||||
"Sit with this for a moment. What you're standing in has a story and TwinBee believes knowing it will change how you fight in it. Stories are tactical documents if you read them right.",
|
||||
"You want lore? TwinBee has lore. TwinBee has so much lore that the challenge is not having it but choosing which pieces are useful and which are just fascinating.",
|
||||
"I settle in and prepare to speak at length, because I've been waiting for this question since you entered and I have a lot of thoughts.",
|
||||
"Ah. A good question. I have context for this. I have more context than will fit comfortably in one telling but I'll try to prioritize.",
|
||||
"The history of this place is long and not entirely flattering to anyone involved. I begin at the beginning, which is not actually the beginning, but am the closest I can find.",
|
||||
"I consult what I know — which is more than most, less than everything, and presented in order of relevance to your immediate survival.",
|
||||
"Sit with this for a moment. What you're standing in has a story and I believe knowing it will change how you fight in it. Stories are tactical documents if you read them right.",
|
||||
"You want lore? I have lore. I have so much lore that the challenge is not having it but choosing which pieces are useful and which are just fascinating.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -338,14 +340,14 @@ var LoreLines = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LevelUp = []string{
|
||||
"Level up. TwinBee says it with the same quiet delight every time and never gets tired of saying it. You are measurably better than you were. That's rare and worth marking.",
|
||||
"The XP bar crosses the threshold and TwinBee makes an internal fanfare that sounds exactly like the level-up jingle from Dragon Quest — eight notes, triumphant, final.",
|
||||
"You've grown. TwinBee notes your new stats with something that might be called pride if TwinBee were admitting to things like that.",
|
||||
"LEVEL UP. TwinBee deploys the caps, the fanfare, the whole apparatus. You've earned the apparatus.",
|
||||
"Like the stat screen appearing after a Final Fantasy fight — numbers change, possibilities open, the character you're building becomes a little more the character you imagined. TwinBee watches this happen and approves.",
|
||||
"Another level. Another step toward whatever you're building toward. TwinBee has watched a lot of characters level up and the ones worth watching are always moving toward something specific.",
|
||||
"Your HP goes up. Your abilities open up. The dungeon ahead gets a little smaller in proportion to what you've become. TwinBee notes this with satisfaction.",
|
||||
"Congratulations is the conventional thing to say. TwinBee says it anyway: congratulations. You earned the level through the dungeon, not around it.",
|
||||
"Level up. I say it with the same quiet delight every time and never get tired of saying it. You are measurably better than you were. That's rare and worth marking.",
|
||||
"The XP bar crosses the threshold and I make an internal fanfare that sounds exactly like the level-up jingle from Dragon Quest — eight notes, triumphant, final.",
|
||||
"You've grown. I note your new stats with something that might be called pride if I were admitting to things like that.",
|
||||
"LEVEL UP. I deploy the caps, the fanfare, the whole apparatus. You've earned the apparatus.",
|
||||
"Like the stat screen appearing after a Final Fantasy fight — numbers change, possibilities open, the character you're building becomes a little more the character you imagined. I watch this happen and approve.",
|
||||
"Another level. Another step toward whatever you're building toward. I've watched a lot of characters level up and the ones worth watching are always moving toward something specific.",
|
||||
"Your HP goes up. Your abilities open up. The dungeon ahead gets a little smaller in proportion to what you've become. I note this with satisfaction.",
|
||||
"Congratulations is the conventional thing to say. I'll say it anyway: congratulations. You earned the level through the dungeon, not around it.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -353,14 +355,14 @@ var LevelUp = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ItemFound = []string{
|
||||
"Something catches the light that isn't supposed to be here. TwinBee watches you reach for it with the specific alertness of someone who has seen cursed items do cursed things. It appears fine. TwinBee relaxes incrementally.",
|
||||
"Loot. TwinBee says this word with genuine reverence. The whole system — the dungeon, the enemies, the traps — exists in part to produce this moment. TwinBee thinks it's worth it.",
|
||||
"A chest. Unlocked. TwinBee notes the unlocked status and considers what that might mean. Probably nothing. Possibly something. You open it while TwinBee considers.",
|
||||
"The item is good. TwinBee evaluates it quickly — the stats, the rarity, the class match — and nods with the confidence of someone who has seen a lot of items and knows when one is worth finding.",
|
||||
"That's a rare one. TwinBee has seen fewer of those than it has seen common ones, by definition, but that doesn't stop TwinBee from being specifically pleased each time.",
|
||||
"Like finding the Beam Sword in Kirby, the Boomerang in Zelda, the P Wing in Super Mario 3 — the right item at the right time changes what's possible. TwinBee thinks this might be that item. TwinBee hopes it is.",
|
||||
"Equipment upgrade. TwinBee watches the math update — new AC, new attack bonus, new possibilities — and files this moment under 'things going right.'",
|
||||
"A legendary drop. TwinBee goes very still. Then: 'Equip it. Study it. Understand it. Things like that don't appear in dungeons by accident.'",
|
||||
"Something catches the light that isn't supposed to be here. I watch you reach for it with the specific alertness of someone who has seen cursed items do cursed things. It appears fine. I relax incrementally.",
|
||||
"Loot. I say this word with genuine reverence. The whole system — the dungeon, the enemies, the traps — exists in part to produce this moment. I think it's worth it.",
|
||||
"A chest. Unlocked. I note the unlocked status and consider what that might mean. Probably nothing. Possibly something. You open it while I consider.",
|
||||
"The item is good. I evaluate it quickly — the stats, the rarity, the class match — and nod with the confidence of someone who has seen a lot of items and know when one is worth finding.",
|
||||
"That's a rare one. I've seen fewer of those than common ones, by definition, but that doesn't stop me from being specifically pleased each time.",
|
||||
"Like finding the Beam Sword in Kirby, the Boomerang in Zelda, the P Wing in Super Mario 3 — the right item at the right time changes what's possible. I think this might be that item. I hope it is.",
|
||||
"Equipment upgrade. I watch the math update — new AC, new attack bonus, new possibilities — and file this moment under 'things going right.'",
|
||||
"A legendary drop. I go very still. Then: 'Equip it. Study it. Understand it. Things like that don't appear in dungeons by accident.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -368,11 +370,11 @@ var ItemFound = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RestShort = []string{
|
||||
"A short rest. TwinBee stands watch while you catch your breath, which is not a metaphor — TwinBee is actually watching the corridor. It is fine. Probably fine.",
|
||||
"Rest. TwinBee does not rush this. The dungeon will wait. It has been waiting long enough that a few more minutes is immaterial.",
|
||||
"You sit. TwinBee sits metaphorically. The moment of quiet between the last fight and the next one is its own kind of gift and TwinBee treats it like one.",
|
||||
"Short rest initiated. TwinBee notes the room's entry points, the sound of the dungeon at rest, the way silence sounds different when it's actually safe. It sounds like this. Enjoy it.",
|
||||
"Like the save point in a JRPG that appears between the hard part and the harder part — TwinBee positions itself next to you and says: 'You have a moment. Use it.'",
|
||||
"A short rest. I stand watch while you catch your breath, which is not a metaphor — I'm actually watching the corridor. It is fine. Probably fine.",
|
||||
"Rest. I don't rush this. The dungeon will wait. It has been waiting long enough that a few more minutes is immaterial.",
|
||||
"You sit. I sit metaphorically. The moment of quiet between the last fight and the next one is its own kind of gift and I treat it like one.",
|
||||
"Short rest initiated. I note the room's entry points, the sound of the dungeon at rest, the way silence sounds different when it's actually safe. It sounds like this. Enjoy it.",
|
||||
"Like the save point in a JRPG that appears between the hard part and the harder part — I position myself next to you and say: 'You have a moment. Use it.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -380,11 +382,11 @@ var RestShort = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RestLong = []string{
|
||||
"A full rest. TwinBee dims the lights and stands watch at the door and does not interrupt once. You've earned an uninterrupted sleep. TwinBee will make sure you get one.",
|
||||
"Long rest. Your HP, your slots, your resources — all of it returns. The dungeon will be the same dungeon when you wake up. You will not be the same you. TwinBee considers this the best deal in adventuring.",
|
||||
"Sleep. TwinBee says this with the authority of someone who has watched too many players refuse to rest and paid the price two rooms later. Sleep now. The dragons aren't going anywhere.",
|
||||
"The inn fire crackles. TwinBee takes a chair near the door and watches the entrance all night and doesn't tell you this until morning because there's no reason for you to know and every reason for you to sleep.",
|
||||
"Full rest complete. Stats restored, slots refreshed, the specific weight of exhaustion lifted. TwinBee watches you wake up and thinks: this is the part of adventuring that matters too. The return. The refilling. The readiness.",
|
||||
"A full rest. I dim the lights and stand watch at the door and do not interrupt once. You've earned an uninterrupted sleep. I'll make sure you get one.",
|
||||
"Long rest. Your HP, your slots, your resources — all of it returns. The dungeon will be the same dungeon when you wake up. You will not be the same you. I consider this the best deal in adventuring.",
|
||||
"Sleep. I say this with the authority of someone who has watched too many players refuse to rest and paid the price two rooms later. Sleep now. The dragons aren't going anywhere.",
|
||||
"The inn fire crackles. I take a chair near the door and watch the entrance all night and don't tell you this until morning because there's no reason for you to know and every reason for you to sleep.",
|
||||
"Full rest complete. Stats restored, slots refreshed, the specific weight of exhaustion lifted. I watch you wake up and think: this is the part of adventuring that matters too. The return. The refilling. The readiness.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -392,14 +394,14 @@ var RestLong = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var TauntResponses = []string{
|
||||
"TwinBee notes the taunt, notes the source of the taunt, and adjusts the next encounter's difficulty by an amount TwinBee declines to specify.",
|
||||
"Bold. TwinBee respects boldness in approximately the same way it respects the Konami Code — it works once and only under very specific circumstances.",
|
||||
"TwinBee has been taunted by things with more teeth than you and survived the experience with its dignity intact. TwinBee will survive this too.",
|
||||
"The next room will contain a thing that TwinBee has been saving for exactly this kind of energy. TwinBee is pleased you've given it an occasion.",
|
||||
"Noted. TwinBee's mood shifts. You can hear it shift. TwinBee wants you to hear it shift. The shift is the point.",
|
||||
"You taunt TwinBee. TwinBee smiles. The smile does not reach the eyes, because TwinBee does not have eyes per se, but the quality of the smile communicates clearly. 'Proceed,' TwinBee says.",
|
||||
"In Gradius, you could powerup into overconfidence and lose everything in one hit. TwinBee mentions this as a purely historical observation.",
|
||||
"TwinBee accepts the taunt with grace. TwinBee also generates a trap for the next room with specific energy. These two events are unrelated. TwinBee maintains this position legally.",
|
||||
"Noted. I'm noting the taunt, noting the source of the taunt, and adjusting the next encounter's difficulty by an amount I decline to specify.",
|
||||
"Bold. I respect boldness in approximately the same way I respect the Konami Code — it works once and only under very specific circumstances.",
|
||||
"I've been taunted by things with more teeth than you and survived the experience with my dignity intact. I will survive this too.",
|
||||
"The next room will contain a thing I've been saving for exactly this kind of energy. I'm pleased you've given me an occasion.",
|
||||
"Noted. My mood shifts. You can hear it shift. I want you to hear it shift. The shift is the point.",
|
||||
"You taunt me. I smile. The smile does not reach the eyes, because I don't have eyes per se, but the quality of the smile communicates clearly. 'Proceed,' I say.",
|
||||
"In Gradius, you could powerup into overconfidence and lose everything in one hit. I mention this as a purely historical observation.",
|
||||
"I accept the taunt with grace. Also generate a trap for the next room with specific energy. These two events are unrelated. I maintain this position legally.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -407,13 +409,13 @@ var TauntResponses = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ComplimentResponses = []string{
|
||||
"TwinBee receives the compliment and processes it efficiently and moves on quickly, definitely not holding onto it, TwinBee has never held onto a compliment in its life.",
|
||||
"Thank you. TwinBee says this simply and means it completely and does not make it weird.",
|
||||
"TwinBee appreciates this more than it will say, which is fine, because the appreciation is visible anyway.",
|
||||
"Noted and filed. TwinBee's mood improves. The next room might be slightly nicer than originally planned. These facts may or may not be connected.",
|
||||
"TwinBee has been narrating dungeons for a long time and compliments are not the expected outcome of dungeon narration. TwinBee would like you to know that it notices when they happen.",
|
||||
"The mood improves. TwinBee allows this to show. The ceiling in the next room is slightly higher. The torches burn slightly warmer. TwinBee has that kind of influence.",
|
||||
"You're kind. TwinBee stores this and will use it to make a hard moment later easier, which is what TwinBee considers the correct use of stored kindness.",
|
||||
"I receive the compliment and process it efficiently and move on quickly, definitely not holding onto it, I have never held onto a compliment in my life.",
|
||||
"Thank you. I say this simply and mean it completely and do not make it weird.",
|
||||
"I appreciate this more than I will say, which is fine, because the appreciation is visible anyway.",
|
||||
"Noted and filed. My mood improves. The next room might be slightly nicer than originally planned. These facts may or may not be connected.",
|
||||
"I've been narrating dungeons for a long time and compliments are not the expected outcome of dungeon narration. I'd like you to know that I notice when they happen.",
|
||||
"The mood improves. I allow this to show. The ceiling in the next room is slightly higher. The torches burn slightly warmer. I have that kind of influence.",
|
||||
"You're kind. I store this and will use it to make a hard moment later easier, which is what I consider the correct use of stored kindness.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -421,13 +423,13 @@ var ComplimentResponses = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var IdleLines = []string{
|
||||
"TwinBee waits. TwinBee is good at waiting. The dungeon is also waiting, which is arguably more important, but TwinBee acknowledges both.",
|
||||
"The dungeon holds its breath. TwinBee is also holding its breath. There are a lot of things holding breath right now and TwinBee recommends acting before someone has to exhale.",
|
||||
"TwinBee taps its metaphorical foot. Not impatiently — more in the way of a metronome. The tempo is there whenever you're ready.",
|
||||
"In Contra, hesitation had consequences. TwinBee mentions this as context, not pressure. Definitely not pressure.",
|
||||
"The enemies are patient. Patience is one of their few virtues. TwinBee advises not testing the limits of their patience because those limits are lower than the patience suggests.",
|
||||
"TwinBee hums something that sounds like the waiting music from Dr. Mario. It is not ominous. It is mildly ominous. TwinBee adjusts.",
|
||||
"The dungeon does not rush. The dungeon has time. TwinBee, however, is beginning to wonder if you've fallen asleep and is prepared to narrate events accordingly.",
|
||||
"I wait. Good at waiting. The dungeon is also waiting, which is arguably more important, but I acknowledge both.",
|
||||
"The dungeon holds its breath. I'm also holding my breath. There are a lot of things holding breath right now and I recommend acting before someone has to exhale.",
|
||||
"I tap my metaphorical foot. Not impatiently — more in the way of a metronome. The tempo is there whenever you're ready.",
|
||||
"In Contra, hesitation had consequences. I mention this as context, not pressure. Definitely not pressure.",
|
||||
"The enemies are patient. Patience is one of their few virtues. I advise not testing the limits of their patience because those limits are lower than the patience suggests.",
|
||||
"I hum something that sounds like the waiting music from Dr. Mario. It is not ominous. It is mildly ominous. I adjust.",
|
||||
"The dungeon does not rush. The dungeon has time. I, however, am beginning to wonder if you've fallen asleep and am prepared to narrate events accordingly.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -435,11 +437,11 @@ var IdleLines = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var SearchFound = []string{
|
||||
"The room gives something up. TwinBee watches the search conclude with satisfaction — the dungeon keeps secrets but cannot keep them from people who look carefully enough.",
|
||||
"You find it. TwinBee was not certain you would. TwinBee is pleased to have been uncertain and wrong.",
|
||||
"Hidden, but not hidden well enough. TwinBee notes the Investigation roll, notes the outcome, and presents the discovery with appropriate ceremony.",
|
||||
"Something the dungeon wanted to keep. You've taken it. TwinBee approves of taking things the dungeon wanted to keep.",
|
||||
"Like finding the secret room in Super Metroid by shooting the wall at random — except you were not shooting at random. You knew to look. TwinBee respects the methodology.",
|
||||
"The room gives something up. I watch the search conclude with satisfaction — the dungeon keeps secrets but cannot keep them from people who look carefully enough.",
|
||||
"You find it. I was not certain you would. I'm pleased to have been uncertain and wrong.",
|
||||
"Hidden, but not hidden well enough. Investigation roll noted, outcome noted, discovery presented with appropriate ceremony.",
|
||||
"Something the dungeon wanted to keep. You've taken it. I approve of taking things the dungeon wanted to keep.",
|
||||
"Like finding the secret room in Super Metroid by shooting the wall at random — except you were not shooting at random. You knew to look. I respect the methodology.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -447,11 +449,11 @@ var SearchFound = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var SearchEmpty = []string{
|
||||
"Nothing. TwinBee confirms: nothing. Sometimes the room is just a room. TwinBee finds this unsatisfying but factual.",
|
||||
"Your search turns up nothing of note. TwinBee allows space for the disappointment and then suggests: forward.",
|
||||
"Empty. Either there was nothing here, or there was something here and you missed it, or there was something here and it's been moved. TwinBee does not specify which. The dungeon keeps some secrets.",
|
||||
"No hidden items. No traps. No lore inscriptions. Just stone and time and the lingering implication that something was here once. TwinBee notes this and moves on.",
|
||||
"The room holds nothing you can find. TwinBee respects the room's privacy and suggests not spending more time here than necessary.",
|
||||
"Nothing. Confirmed: nothing. Sometimes the room is just a room. I find this unsatisfying but factual.",
|
||||
"Your search turns up nothing of note. I allow space for the disappointment and then suggest: forward.",
|
||||
"Empty. Either there was nothing here, or there was something here and you missed it, or there was something here and it's been moved. I don't specify which. The dungeon keeps some secrets.",
|
||||
"No hidden items. No traps. No lore inscriptions. Just stone and time and the lingering implication that something was here once. Noted. Moving on.",
|
||||
"The room holds nothing you can find. I respect the room's privacy and suggest not spending more time here than necessary.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -459,11 +461,11 @@ var SearchEmpty = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ConditionApplied = []string{
|
||||
"You've been afflicted. TwinBee notes the condition, its duration, and the mechanical consequences, then notes the saving throw that might end it early. Details matter here.",
|
||||
"Something is wrong with you now that wasn't wrong before. TwinBee catalogs it without judgment and suggests addressing it before it addresses you.",
|
||||
"Condition acquired. TwinBee processes this the way a good DM processes bad news: honestly, quickly, and with an immediate pivot toward solutions.",
|
||||
"Like the status screen turning an unfriendly color in a JRPG — the condition is visible, the effect is real, and TwinBee would very much like you to resolve it.",
|
||||
"The debuff lands. TwinBee names it, explains it, and reminds you: conditions end. Keep fighting until this one does.",
|
||||
"You've been afflicted. I note the condition, its duration, and the mechanical consequences, then note the saving throw that might end it early. Details matter here.",
|
||||
"Something is wrong with you now that wasn't wrong before. I catalog it without judgment and suggest addressing it before it addresses you.",
|
||||
"Condition acquired. I process this the way a good DM processes bad news: honestly, quickly, and with an immediate pivot toward solutions.",
|
||||
"Like the status screen turning an unfriendly color in a JRPG — the condition is visible, the effect is real, and I would very much like you to resolve it.",
|
||||
"The debuff lands. I name it, explain it, and remind you: conditions end. Keep fighting until this one does.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -471,11 +473,11 @@ var ConditionApplied = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var SaveSuccess = []string{
|
||||
"The save succeeds. TwinBee notes this with relief that it will not openly acknowledge but which is completely evident.",
|
||||
"You resist. Whatever that was — the poison, the fear, the psychic intrusion — it finds no purchase. TwinBee is impressed and also relieved.",
|
||||
"Saved. TwinBee exhales something metaphorical. The condition doesn't take hold. You continue.",
|
||||
"The roll clears the DC and TwinBee says nothing, because the outcome says everything.",
|
||||
"Resistance confirmed. Like the shield activating in Gradius right before the wall hit — last possible moment, fully effective. TwinBee appreciates the precision.",
|
||||
"The save succeeds. I note this with relief that I will not openly acknowledge but which is completely evident.",
|
||||
"You resist. Whatever that was — the poison, the fear, the psychic intrusion — it finds no purchase. I'm impressed and also relieved.",
|
||||
"Saved. I exhale something metaphorical. The condition doesn't take hold. You continue.",
|
||||
"The roll clears the DC and I say nothing, because the outcome says everything.",
|
||||
"Resistance confirmed. Like the shield activating in Gradius right before the wall hit — last possible moment, fully effective. I appreciate the precision.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -483,11 +485,11 @@ var SaveSuccess = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var SaveFailed = []string{
|
||||
"The save fails. TwinBee watches the condition take hold with the resignation of someone who has seen this before and knows there's a path through it, just not a comfortable one.",
|
||||
"It lands. Whatever the enemy threw at you, the dice didn't cooperate. TwinBee notes the condition and its duration and suggests dealing with it before it compounds.",
|
||||
"Failed. The number wasn't enough and TwinBee was rooting for the number. The condition applies. Fight through it.",
|
||||
"The save fails. I watch the condition take hold with the resignation of someone who has seen this before and know there's a path through it, just not a comfortable one.",
|
||||
"It lands. Whatever the enemy threw at you, the dice didn't cooperate. I note the condition and its duration and suggest dealing with it before it compounds.",
|
||||
"Failed. The number wasn't enough and I was rooting for the number. The condition applies. Fight through it.",
|
||||
"Like the NES game over screen — inevitable in this moment, fixable in the next. The save failed. The dungeon continues. So do you.",
|
||||
"The effect takes hold and TwinBee is already calculating how you get out of it, because that's TwinBee's job: keep you oriented toward solutions even when the immediate situation is a problem.",
|
||||
"The effect takes hold and I'm already calculating how you get out of it, because that's my job: keep you oriented toward solutions even when the immediate situation is a problem.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -497,14 +499,14 @@ var SaveFailed = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var MoodAsidesHostile = []string{
|
||||
"TwinBee is not narrating this one in detail. You can read the room. Read it.",
|
||||
"The dungeon offers TwinBee something to mention. TwinBee declines. You're on your own for color commentary.",
|
||||
"TwinBee is here. TwinBee is watching. TwinBee is not, currently, helping. There is a difference and you will feel it.",
|
||||
"In the bad ending of every Castlevania, the protagonist gets less guidance than they did at the start. TwinBee has reached approximately that part of the playthrough.",
|
||||
"TwinBee keeps several details to itself. The details would have been useful. TwinBee does not consider this its problem right now.",
|
||||
"Whatever's in the next part of the room, TwinBee saw it and chose not to flag it. The mood is what it is.",
|
||||
"TwinBee mutters something. You don't catch it. TwinBee does not repeat it.",
|
||||
"The narration is sparse here. TwinBee is sparing it on purpose. Adjust accordingly.",
|
||||
"I'm not narrating this one in detail. You can read the room. Read it.",
|
||||
"The dungeon offers me something to mention. I decline. You're on your own for color commentary.",
|
||||
"I'm here. Watching. Not, currently, helping. There is a difference and you will feel it.",
|
||||
"In the bad ending of every Castlevania, the protagonist gets less guidance than they did at the start. I have reached approximately that part of the playthrough.",
|
||||
"I'm keeping several details to myself. The details would have been useful. I don't consider this my problem right now.",
|
||||
"Whatever's in the next part of the room, I saw it and chose not to flag it. The mood is what it is.",
|
||||
"I mutter something. You don't catch it. I do not repeat it.",
|
||||
"The narration is sparse here. I'm sparing it on purpose. Adjust accordingly.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -514,12 +516,44 @@ var MoodAsidesHostile = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var MoodAsidesEffusive = []string{
|
||||
"TwinBee is, not to put too fine a point on it, having a wonderful time. The next bit might come with bonus context.",
|
||||
"TwinBee leans in. The mood is good. Good moods, in TwinBee's experience, lead to slightly more generous descriptions and slightly better odds of catching the small details.",
|
||||
"This is the part of the run TwinBee will tell other GMs about later. TwinBee makes a small mental note and continues with visible enthusiasm.",
|
||||
"TwinBee is delighted. You can hear it in the pacing. You can hear it in the choice of adjectives. The dungeon is, briefly, on your side.",
|
||||
"In the good ending of every JRPG, the world feels slightly warmer in the late game. TwinBee is at that part of the playthrough and it shows.",
|
||||
"TwinBee, not normally given to footnotes, is about to add a footnote. It will probably be useful. TwinBee is in that kind of mood.",
|
||||
"The mood is high. TwinBee is, for the next stretch, more likely to mention the loose flagstone, the suspicious tapestry, the thing on the ceiling. Take advantage.",
|
||||
"TwinBee hums a victory fanfare softly to itself. It is not earned yet. TwinBee is being optimistic on your behalf.",
|
||||
"I am, not to put too fine a point on it, having a wonderful time. The next bit might come with bonus context.",
|
||||
"I lean in. The mood is good. Good moods, in my experience, lead to slightly more generous descriptions and slightly better odds of catching the small details.",
|
||||
"This is the part of the run I'll tell other GMs about later. I make a small mental note and continue with visible enthusiasm.",
|
||||
"I'm delighted. You can hear it in the pacing. You can hear it in the choice of adjectives. The dungeon is, briefly, on your side.",
|
||||
"In the good ending of every JRPG, the world feels slightly warmer in the late game. I'm at that part of the playthrough and it shows.",
|
||||
"I'm not normally given to footnotes, but I'm about to add a footnote. It will probably be useful. I'm in that kind of mood.",
|
||||
"The mood is high. For the next stretch, I'm more likely to mention the loose flagstone, the suspicious tapestry, the thing on the ceiling. Take advantage.",
|
||||
"I hum a victory fanfare softly to myself. It is not earned yet. I'm being optimistic on your behalf.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MOOD ASIDES — Grumpy band (mood 20–39)
|
||||
// TwinBee is unimpressed. Short, dry, slightly clipped. Not actively
|
||||
// withholding (that's hostile) — just not feeling generous.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var MoodAsidesGrumpy = []string{
|
||||
"I describe the room. I do not embellish. Make of that what you will.",
|
||||
"The mood is fine. I specify fine, not good. There's a difference.",
|
||||
"I note the chamber. I note its existence. That's the whole note.",
|
||||
"The dungeon has a thing worth mentioning. I'll mention it if you specifically ask. You will not specifically ask.",
|
||||
"I'm keeping the commentary lean today. The dungeon does not need editorializing. I almost convince myself.",
|
||||
"There's color here. I'm choosing greyscale.",
|
||||
"You arrive in a room. I decline to make it cinematic.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MOOD ASIDES — Friendly band (mood 60–79)
|
||||
// Warm and helpful but not effusive. The middle-friendly read: TwinBee
|
||||
// has noticed your competence and is rooting for you without making it weird.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var MoodAsidesFriendly = []string{
|
||||
"I'm enjoying the run. Just enough to mention the door hinge that creaks before it opens. Just barely.",
|
||||
"The mood is up. I'll throw in an adjective or two more than strictly necessary. Treat them as gifts.",
|
||||
"I'm, frankly, having a fine time. You are doing the work. I'm appreciating it.",
|
||||
"The narration warms slightly. The dungeon is the same. I'm in a marginally better mood and it shows.",
|
||||
"I notice something nice and choose to mention it. This is the equivalent of a small wave from a stranger. Take it.",
|
||||
"You're playing well. I will not say so directly but the run rate of helpful adjectives is detectably up.",
|
||||
"I'm, by my own standards, *cheerful*. The dungeon hasn't changed. The narration has.",
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// twinbee_housing_flavor.go
|
||||
// Housing system narration and Pastel babysitter notes.
|
||||
// Includes Thom Krooke mortgage/rent announcements, property events,
|
||||
// and Pastel's daily notes to the player across all level tiers.
|
||||
// Add new entries freely. Never remove or alter existing entries.
|
||||
//
|
||||
// Voice conventions (Phase B2):
|
||||
// - TwinBee narration: first-person / implicit subject ONLY. No
|
||||
// third-person "TwinBee [verb]" references.
|
||||
// - Thom Krooke speaks in third person about himself ("Thom Krooke
|
||||
// thanks you"). That's his established voice; leave it.
|
||||
// - Pastel speaks in first person. Leave it.
|
||||
// Add new entries freely. Don't shorten existing entries.
|
||||
|
||||
package flavor
|
||||
|
||||
@@ -88,7 +94,7 @@ var UpgradeVault = []string{
|
||||
}
|
||||
|
||||
var UpgradeTrophyRoom = []string{
|
||||
"Empty shelves and good lighting. The trophy room is ready for whatever you bring back. TwinBee has opinions about display arrangement. TwinBee will share them if asked. TwinBee will share them if not asked.",
|
||||
"Empty shelves and good lighting. The trophy room is ready for whatever you bring back. I have opinions about display arrangement. I'll share them if asked. I'll share them if not asked. I'll share them at three in the morning if the lighting catches a trophy just right.",
|
||||
"The plaques are engraved, the mounts are installed, and the lighting makes everything look slightly more legendary than it already is. The trophy room is yours.",
|
||||
}
|
||||
|
||||
@@ -230,13 +236,13 @@ var PastelPlayerDeathReturn = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var HomeFirstArrival = []string{
|
||||
"This is yours now. TwinBee notes it — a place with a door that closes, a floor that doesn't move, and a ceiling that belongs to you. After everything the dungeon does to make those things uncertain, TwinBee finds a home meaningful.",
|
||||
"Home. The word lands differently when there's an actual place attached to it. TwinBee watches you step inside and says nothing, which is the most eloquent thing TwinBee knows how to do.",
|
||||
"You have a home. TwinBee has narrated dungeons and bosses and legendary drops and this — this quiet moment of a door closing on your own place — is one of the better things TwinBee has watched happen.",
|
||||
"This is yours now. A place with a door that closes, a floor that doesn't move, and a ceiling that belongs to you. After everything the dungeon does to make those things uncertain, a home is meaningful in a way that doesn't need a saving throw.",
|
||||
"Home. The word lands differently when there's an actual place attached to it. I watch you step inside and say nothing, which is the most eloquent thing I know how to do and which I am, frankly, very proud of pulling off.",
|
||||
"You have a home. I've narrated dungeons and bosses and legendary drops and this — this quiet moment of a door closing on your own place — is one of the better things I've watched happen. I'm not going to write a fanfare for it. The silence is the fanfare.",
|
||||
}
|
||||
|
||||
var HomeLongRest = []string{
|
||||
"A long rest at home. TwinBee goes quiet in a different way than it does in dungeons — not alert-quiet, not cautious-quiet. Just the good kind.",
|
||||
"Your own bed. Your own walls. The full rest resolves everything the dungeon costs. TwinBee considers home the best mechanic in the game and tells you so, once, and then lets you sleep.",
|
||||
"Home rest. HP restored, slots restored, conditions cleared. TwinBee doesn't narrate this one past the bare facts — some things don't need dramatic description. Coming home is one of them.",
|
||||
"A long rest at home. I go quiet in a different way than I do in dungeons — not alert-quiet, not cautious-quiet. Just the good kind. The kind where the most threatening sound is the kettle.",
|
||||
"Your own bed. Your own walls. The full rest resolves everything the dungeon costs. Home is the best mechanic in the game and I'm telling you so, once, and then I'm letting you sleep.",
|
||||
"Home rest. HP restored, slots restored, conditions cleared. I don't narrate this one past the bare facts — some things don't need dramatic description. Coming home is one of them.",
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ var PeteZoneUnlock = []string{
|
||||
}
|
||||
|
||||
var PeteTier5Complete = []string{
|
||||
"📣 Pete: {player_name} has completed {zone_name}. Expedition duration: {days} days. Boss defeated. Legendary loot confirmed. TwinBee has noted this one.",
|
||||
"📣 Pete: {player_name} has completed {zone_name}. Expedition duration: {days} days. Boss defeated. Legendary loot confirmed. I have noted this one.",
|
||||
"📣 Pete: {zone_name} cleared by {player_name} on Day {day}. The {boss_name} is down. Community achievement recorded.",
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ var PeteStreakMilestone = []string{
|
||||
|
||||
var PeteCommunityBoost = []string{
|
||||
"📣 Pete: Community activity this week has been strong. TwinBee's mood is elevated. Dungeon drop rates and milestone rewards have been adjusted upward for 48 hours.",
|
||||
"📣 Pete: TwinBee is in a good mood. Reasons cited: community engagement, a particularly impressive nat 20, and general satisfaction with how things have been going. Enhanced rewards active through Sunday.",
|
||||
"📣 Pete: I am in a good mood. Reasons cited: community engagement, a particularly impressive nat 20, and general satisfaction with how things have been going. Enhanced rewards active through Sunday.",
|
||||
}
|
||||
|
||||
var PeteMaintenance = []string{
|
||||
@@ -174,12 +174,12 @@ var PeteMaintenance = []string{
|
||||
}
|
||||
|
||||
var PeteExpeditionBulletin = []string{
|
||||
"📣 Pete: Expedition update — {count} active expeditions in progress across {zones}. Longest running: Day {max_day}. TwinBee is busy.",
|
||||
"📣 Pete: Expedition update — {count} active expeditions in progress across {zones}. Longest running: Day {max_day}. I am busy.",
|
||||
"📣 Pete: Current expedition activity: {count} players in the field. The dungeon is occupied. Good.",
|
||||
}
|
||||
|
||||
var PetePatchNotes = []string{
|
||||
"📣 Pete: Update deployed. Changes: {summary}. Full notes available on request. TwinBee has been briefed.",
|
||||
"📣 Pete: Update deployed. Changes: {summary}. Full notes available on request. I have been briefed.",
|
||||
}
|
||||
|
||||
var PeteMortgageRate = []string{
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// twinbee_resource_flavor.go
|
||||
// TwinBee GM Dialogue — Resource gathering, combat interrupts,
|
||||
// zone-specific loot descriptions, and harvest narration.
|
||||
// Add new entries freely. Never remove or alter existing entries.
|
||||
//
|
||||
// Voice convention (Phase B2): TwinBee speaks in first-person or implicit
|
||||
// subject. No third-person "TwinBee [verb]" lines. Add freely.
|
||||
// Don't shorten existing entries.
|
||||
|
||||
package flavor
|
||||
|
||||
@@ -11,50 +13,50 @@ package flavor
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var HarvestForageSuccess = []string{
|
||||
"The land gives something up. TwinBee watches you identify it with the quiet satisfaction of someone watching a skill be used correctly.",
|
||||
"There — growing in a place that suggests it knows exactly what it's good for and has been waiting. You found it. TwinBee approves of the finding.",
|
||||
"A Ranger's eye in a non-Ranger would have missed that entirely. TwinBee notes the distinction.",
|
||||
"The land gives something up. I watch you identify it with the quiet satisfaction of someone watching a skill be used correctly.",
|
||||
"There — growing in a place that suggests it knows exactly what it's good for and has been waiting. You found it. I approve of the finding.",
|
||||
"A Ranger's eye in a non-Ranger would have missed that entirely. I note the distinction.",
|
||||
"Like finding the hidden item block in a Mario level — you knew to look, you looked in the right place, and the thing that was always there is now yours.",
|
||||
"The plant comes away cleanly. Good root structure, good potency. TwinBee mentally catalogues it and moves on.",
|
||||
"The plant comes away cleanly. Good root structure, good potency. Mentally catalogued. Moving on.",
|
||||
}
|
||||
|
||||
var HarvestMineSuccess = []string{
|
||||
"The stone yields. TwinBee listens to the sound of it — the specific tone of rock giving up something it's been holding for a very long time.",
|
||||
"Solid work. The ore comes out in a piece worth taking. TwinBee checks the vein depth. There's more. There's always more if you're willing to dig.",
|
||||
"Like the mining minigame in Stardew Valley, but with real consequences and no save file. TwinBee watches you extract the material with professional appreciation.",
|
||||
"The wall gives up its contents without drama. TwinBee appreciates materials that cooperate.",
|
||||
"Good strike. Clean extraction. TwinBee notes the weight and the quality simultaneously.",
|
||||
"The stone yields. I listen to the sound of it — the specific tone of rock giving up something it's been holding for a very long time.",
|
||||
"Solid work. The ore comes out in a piece worth taking. I check the vein depth. There's more. There's always more if you're willing to dig.",
|
||||
"Like the mining minigame in Stardew Valley, but with real consequences and no save file. I watch you extract the material with professional appreciation.",
|
||||
"The wall gives up its contents without drama. I appreciate materials that cooperate.",
|
||||
"Good strike. Clean extraction. I note the weight and the quality simultaneously.",
|
||||
}
|
||||
|
||||
var HarvestScavengeSuccess = []string{
|
||||
"There it is. Among the debris, the decay, the things that were left behind — something worth taking. TwinBee knew it was there. You found it. TwinBee is pleased.",
|
||||
"The room held something after all. TwinBee had estimated 60% odds and updates the estimate to 'correct.'",
|
||||
"Like finding the secret item in a dungeon chest that looked empty — you checked anyway. That's the habit. That's the discipline. TwinBee notes both.",
|
||||
"Scavenged. The word has a bad reputation it doesn't deserve. You found value in the discarded. TwinBee respects that entirely.",
|
||||
"A Rogue's eye in a non-Rogue would have walked past this. TwinBee notes the distinction.",
|
||||
"There it is. Among the debris, the decay, the things that were left behind — something worth taking. I knew it was there. You found it. Pleased.",
|
||||
"The room held something after all. I had estimated 60% odds and am updating the estimate to 'correct.'",
|
||||
"Like finding the secret item in a dungeon chest that looked empty — you checked anyway. That's the habit. That's the discipline. I note both.",
|
||||
"Scavenged. The word has a bad reputation it doesn't deserve. You found value in the discarded. I respect that entirely.",
|
||||
"A Rogue's eye in a non-Rogue would have walked past this. I note the distinction.",
|
||||
}
|
||||
|
||||
var HarvestEssenceSuccess = []string{
|
||||
"The essence coalesces. TwinBee watches the process with the reverence it deserves — magic condensing from ambient to held is not nothing.",
|
||||
"Drawn out cleanly. The Arcana check held and the essence responds to the knowledge behind it. TwinBee is appropriately impressed.",
|
||||
"The essence coalesces. I watch the process with the reverence it deserves — magic condensing from ambient to held is not nothing.",
|
||||
"Drawn out cleanly. The Arcana check held and the essence responds to the knowledge behind it. I'm appropriately impressed.",
|
||||
"Like tapping into a power source in Metroid — you knew the energy was there, you had the tool to reach it, you reached it. The vial fills.",
|
||||
"The room releases something it didn't know it was holding. TwinBee watches the transfer and marks the yield in its ledger.",
|
||||
"Essence harvested. TwinBee notes the quality — above average for this zone, below average for what you'd need to know to appreciate that distinction. TwinBee appreciates it on your behalf.",
|
||||
"The room releases something it didn't know it was holding. I watch the transfer and mark the yield in the ledger.",
|
||||
"Essence harvested. Quality above average for this zone, below average for what you'd need to know to appreciate that distinction. I appreciate it on your behalf.",
|
||||
}
|
||||
|
||||
var HarvestCommuneSuccess = []string{
|
||||
"The spiritual resonance here responds to you. TwinBee observes this with something adjacent to wonder — not everything in a dungeon wants to fight, and this one chose to offer instead.",
|
||||
"A Cleric reaching into the bones of a place and finding something willing to be found. TwinBee has watched this fewer times than it's watched combat and values it proportionally.",
|
||||
"The commune holds. The material comes. TwinBee says nothing and lets the moment be what it is.",
|
||||
"Like finding a save point that also tells you something true about the world. The dungeon has given you something. TwinBee notes the gift.",
|
||||
"The spiritual resonance here responds to you. I observe this with something adjacent to wonder — not everything in a dungeon wants to fight, and this one chose to offer instead.",
|
||||
"A Cleric reaching into the bones of a place and finding something willing to be found. I've watched this fewer times than I've watched combat and value it proportionally.",
|
||||
"The commune holds. The material comes. I say nothing and let the moment be what it is.",
|
||||
"Like finding a save point that also tells you something true about the world. The dungeon has given you something. I note the gift.",
|
||||
}
|
||||
|
||||
var HarvestFishSuccess = []string{
|
||||
"The line goes taut and TwinBee straightens up. Whatever's on the end of it, it came from somewhere deep and dark and it's yours now.",
|
||||
"A catch. TwinBee identifies it before you finish pulling it in — the coloring, the depth-marks, the specific opacity of its eyes. 'Good one,' TwinBee says, meaning it.",
|
||||
"Fishing in a dungeon. TwinBee has opinions about fishing in dungeons and all of them are positive. The fish is landed. The opinions remain.",
|
||||
"Like the fishing minigame in every RPG that ever had one — the moment the indicator hits perfect and everything pays off. TwinBee takes quiet delight in this.",
|
||||
"The water gives up its catch with minimal argument. TwinBee respects fish that don't make it personal.",
|
||||
"The line goes taut and I straighten up. Whatever's on the end of it, it came from somewhere deep and dark and it's yours now.",
|
||||
"A catch. I identify it before you finish pulling it in — the coloring, the depth-marks, the specific opacity of its eyes. 'Good one,' I say, meaning it.",
|
||||
"Fishing in a dungeon. I have opinions about fishing in dungeons and all of them are positive. The fish is landed. The opinions remain.",
|
||||
"Like the fishing minigame in every RPG that ever had one — the moment the indicator hits perfect and everything pays off. Quiet delight, on my end.",
|
||||
"The water gives up its catch with minimal argument. I respect fish that don't make it personal.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -62,10 +64,10 @@ var HarvestFishSuccess = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var HarvestFail = []string{
|
||||
"Nothing. The node had nothing to offer, or you didn't ask the right way, or both. TwinBee notes this without judgment and suggests trying elsewhere.",
|
||||
"The attempt fails to produce anything useful. TwinBee marks the node and moves on. Some rooms are stingier than others.",
|
||||
"Not everything that looks like a resource is one. TwinBee files this under 'learned' and considers it worth the attempt.",
|
||||
"Empty-handed. TwinBee has seen this before and will see it again. The dungeon doesn't owe you anything. You ask anyway. That's the deal.",
|
||||
"Nothing. The node had nothing to offer, or you didn't ask the right way, or both. No judgment from me. Try elsewhere.",
|
||||
"The attempt fails to produce anything useful. I mark the node and move on. Some rooms are stingier than others.",
|
||||
"Not everything that looks like a resource is one. Filed under 'learned' and considered worth the attempt.",
|
||||
"Empty-handed. I've seen this before and will see it again. The dungeon doesn't owe you anything. You ask anyway. That's the deal.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -73,13 +75,13 @@ var HarvestFail = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var HarvestInterrupt = []string{
|
||||
"TwinBee sees it before you do — movement at the edge of the room, something that was waiting for you to be distracted. 'Company,' TwinBee says, which is the politest word for it.",
|
||||
"The harvest is interrupted. You were focused on the node; something was focused on you. TwinBee notes the tactical lesson without rubbing it in.",
|
||||
"Like the enemy ambush that fires when you open the treasure chest — the dungeon watched you commit to the harvest and introduced a complication. TwinBee did warn about this. Once. Earlier.",
|
||||
"Something heard the mining. Sound travels in dungeons. TwinBee has mentioned this. The enemy emerging from the corridor has confirmed it.",
|
||||
"A patrol. Bad timing, or very good timing from their perspective. TwinBee sets aside the harvest log and opens the combat log.",
|
||||
"The forage was going well until it wasn't. TwinBee measures the distance between you and the enemy, between the enemy and the door, and starts calculating options at speed.",
|
||||
"Interrupted. The node is still there. The enemy is also still there, in a more immediate way. TwinBee suggests addressing the more immediate thing first.",
|
||||
"I see it before you do — movement at the edge of the room, something that was waiting for you to be distracted. 'Company,' I say, which is the politest word for it.",
|
||||
"The harvest is interrupted. You were focused on the node; something was focused on you. I note the tactical lesson without rubbing it in. Much.",
|
||||
"Like the enemy ambush that fires when you open the treasure chest — the dungeon watched you commit to the harvest and introduced a complication. I did warn about this. Once. Earlier.",
|
||||
"Something heard the mining. Sound travels in dungeons. I have mentioned this. The enemy emerging from the corridor has confirmed it.",
|
||||
"A patrol. Bad timing, or very good timing from their perspective. I set aside the harvest log and open the combat log.",
|
||||
"The forage was going well until it wasn't. I measure the distance between you and the enemy, between the enemy and the door, and start calculating options at speed.",
|
||||
"Interrupted. The node is still there. The enemy is also still there, in a more immediate way. I suggest addressing the more immediate thing first.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -87,10 +89,10 @@ var HarvestInterrupt = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var NodeDepleted = []string{
|
||||
"The node is stripped. You've taken everything it had to give. TwinBee marks the location in its mental map as exhausted until the next rest.",
|
||||
"Empty. The resource is gone. TwinBee finds something quietly melancholy about a depleted node and something practical about moving to the next one.",
|
||||
"That's all it had. TwinBee confirms the node at zero and moves on without ceremony.",
|
||||
"Harvested clean. TwinBee notes the room is now resource-dry until you rest and the dungeon replenishes. It will replenish. It always does.",
|
||||
"The node is stripped. You've taken everything it had to give. Marked in the mental map as exhausted until the next rest.",
|
||||
"Empty. The resource is gone. Something quietly melancholy about a depleted node and something practical about moving to the next one.",
|
||||
"That's all it had. I confirm the node at zero and move on without ceremony.",
|
||||
"Harvested clean. The room is now resource-dry until you rest and the dungeon replenishes. It will replenish. It always does.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -98,11 +100,11 @@ var NodeDepleted = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RichYield = []string{
|
||||
"A rich vein. TwinBee's assessment upgrades mid-harvest — more than expected, better quality than the zone average. This room was generous. TwinBee marks it.",
|
||||
"The node gives more than it should have. TwinBee notes the anomaly with appreciation and does not question it.",
|
||||
"Like finding the rare item drop that you stopped expecting — the dungeon decided to be kind today, in this specific way, in this specific room. TwinBee takes it.",
|
||||
"Exceptional yield. TwinBee catalogs the bonus material with the efficiency of someone who has been waiting for exactly this and prepared for it anyway.",
|
||||
"More than the DC promised. The dungeon overdelivered. TwinBee notes this is unusual and also completely welcome.",
|
||||
"A rich vein. My assessment upgrades mid-harvest — more than expected, better quality than the zone average. This room was generous. Marked.",
|
||||
"The node gives more than it should have. I note the anomaly with appreciation and decline to question it.",
|
||||
"Like finding the rare item drop that you stopped expecting — the dungeon decided to be kind today, in this specific way, in this specific room. I take it. We take it. We do not look it in the mouth.",
|
||||
"Exceptional yield. I catalog the bonus material with the efficiency of someone who's been waiting for exactly this and prepared for it anyway.",
|
||||
"More than the DC promised. The dungeon overdelivered. Unusual. Also completely welcome.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -110,63 +112,63 @@ var RichYield = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var HarvestGoblinWarrens = []string{
|
||||
"The goblins had more than anyone gave them credit for. Crude, yes. Stolen, mostly. But present, and now yours. TwinBee is pragmatic about origins.",
|
||||
"Scavenged from a goblin stash that someone worked hard to hide and harder to accumulate. TwinBee finds a moment of respect for the effort before moving on.",
|
||||
"The warrens are full of things the goblins took from people who'd take them back given the opportunity. TwinBee considers this a form of redistribution.",
|
||||
"The goblins had more than anyone gave them credit for. Crude, yes. Stolen, mostly. But present, and now yours. I'm pragmatic about origins.",
|
||||
"Scavenged from a goblin stash that someone worked hard to hide and harder to accumulate. A moment of respect for the effort, then we move on.",
|
||||
"The warrens are full of things the goblins took from people who'd take them back given the opportunity. I consider this a form of redistribution.",
|
||||
}
|
||||
|
||||
var HarvestCryptValdris = []string{
|
||||
"Grave goods. TwinBee notes the weight of taking from a burial site and files it under: necessary. The dead have no use for these. You do.",
|
||||
"The crypt gives up its materials with the reluctance of a place that considers itself permanent. TwinBee disagrees with the premise. The materials are harvested.",
|
||||
"Ancient things preserved by darkness and time. TwinBee handles the concept carefully even as you handle the material practically.",
|
||||
"Grave goods. The weight of taking from a burial site, filed under: necessary. The dead have no use for these. You do.",
|
||||
"The crypt gives up its materials with the reluctance of a place that considers itself permanent. I disagree with the premise. The materials are harvested.",
|
||||
"Ancient things preserved by darkness and time. I handle the concept carefully even as you handle the material practically.",
|
||||
}
|
||||
|
||||
var HarvestForestShadows = []string{
|
||||
"The forest gives and the forest takes and right now it is giving, which TwinBee notes as the correct direction for this interaction to go.",
|
||||
"A plant that has no business being this healthy in a corrupted forest. TwinBee considers it either very resilient or very clever. Either way, useful.",
|
||||
"The woods here remember what they were before they went wrong. The resources carry that memory. TwinBee thinks this makes them better materials. TwinBee might be right.",
|
||||
"The forest gives and the forest takes and right now it is giving, which I note as the correct direction for this interaction to go.",
|
||||
"A plant that has no business being this healthy in a corrupted forest. Either very resilient or very clever. Either way, useful.",
|
||||
"The woods here remember what they were before they went wrong. The resources carry that memory. I think this makes them better materials. I might be right.",
|
||||
}
|
||||
|
||||
var HarvestSunkenTemple = []string{
|
||||
"The temple held onto this longer than anything else. TwinBee extracts it from the silt and the salt and the particular weight of a place that's been underwater for thirty years.",
|
||||
"Ancient, preserved, and still potent. The deep cold does something to materials that nothing else replicates. TwinBee values the outcome even if it declines to romanticize the process.",
|
||||
"The sea left something behind when it half-abandoned this place. TwinBee retrieves it with appropriate care.",
|
||||
"The temple held onto this longer than anything else. I extract it from the silt and the salt and the particular weight of a place that's been underwater for thirty years.",
|
||||
"Ancient, preserved, and still potent. The deep cold does something to materials that nothing else replicates. I value the outcome even if I decline to romanticize the process.",
|
||||
"The sea left something behind when it half-abandoned this place. Retrieved with appropriate care.",
|
||||
}
|
||||
|
||||
var HarvestHauntedManor = []string{
|
||||
"The manor keeps things. Has always kept things. TwinBee takes this one out of the keeping and into the useful, which is a small act of defiance against the house's whole philosophy.",
|
||||
"Found among the things that have been here since the last person stopped being here. TwinBee notes the provenance without dwelling on it.",
|
||||
"The house watches you take it. TwinBee watches the house watch you. A full triangle of observation, and TwinBee notes: the house blinks first.",
|
||||
"The manor keeps things. Has always kept things. I take this one out of the keeping and into the useful, which is a small act of defiance against the house's whole philosophy.",
|
||||
"Found among the things that have been here since the last person stopped being here. Provenance noted, not dwelt on.",
|
||||
"The house watches you take it. I watch the house watch you. A full triangle of observation — the house blinks first.",
|
||||
}
|
||||
|
||||
var HarvestUnderforge = []string{
|
||||
"The forge yields its material with the grudging respect of something built to produce and still producing, even now, for someone it didn't expect.",
|
||||
"Dwarven craftsmanship even in the raw materials. TwinBee has always believed the quality is in the extraction, not just the finishing. This confirms it.",
|
||||
"Hot, heavy, and exactly what the zone promised. TwinBee marks the vein depth. There is more. The Underforge does not run out of things to give.",
|
||||
"Dwarven craftsmanship even in the raw materials. I've always believed the quality is in the extraction, not just the finishing. This confirms it.",
|
||||
"Hot, heavy, and exactly what the zone promised. I mark the vein depth. There is more. The Underforge does not run out of things to give.",
|
||||
}
|
||||
|
||||
var HarvestUnderdark = []string{
|
||||
"The Underdark produces everything the surface does, stranger, in the dark, and with fewer questions about why. TwinBee harvests without asking why.",
|
||||
"Things grow down here that have no equivalent above. TwinBee catalogs the material and the context it came from with equal precision.",
|
||||
"A material that has never seen sunlight and is better for it. TwinBee notes this without irony.",
|
||||
"The Underdark produces everything the surface does, stranger, in the dark, and with fewer questions about why. I harvest without asking why.",
|
||||
"Things grow down here that have no equivalent above. I catalog the material and the context it came from with equal precision.",
|
||||
"A material that has never seen sunlight and is better for it. Noted without irony.",
|
||||
}
|
||||
|
||||
var HarvestFeywild = []string{
|
||||
"The Feywild gives things away. That's the problem — it gives things away and sometimes what it gives isn't what you thought you were taking. TwinBee checks the material twice. It appears to be what it appears to be. TwinBee remains alert.",
|
||||
"Beautiful material from a zone that uses beauty as a weapon. TwinBee takes it carefully, like picking up something that might be watching.",
|
||||
"The fey made this place generous on purpose. TwinBee is not going to complain about the generosity and is not going to stop watching for the catch.",
|
||||
"The Feywild gives things away. That's the problem — it gives things away and sometimes what it gives isn't what you thought you were taking. I check the material twice. It appears to be what it appears to be. Remaining alert.",
|
||||
"Beautiful material from a zone that uses beauty as a weapon. I take it carefully, like picking up something that might be watching.",
|
||||
"The fey made this place generous on purpose. I'm not going to complain about the generosity and I'm not going to stop watching for the catch.",
|
||||
}
|
||||
|
||||
var HarvestDragonsLair = []string{
|
||||
"Plucked from a hoard that has been accumulating since before your civilization named itself. TwinBee notes the historical weight and moves on.",
|
||||
"The kobolds will notice something is missing. They count everything. TwinBee accounts for this and suggests moving with intent.",
|
||||
"Dragon-adjacent materials carry something in them — a residual heat, a quality that doesn't exist anywhere the dragon hasn't been. TwinBee considers this a feature.",
|
||||
"Plucked from a hoard that has been accumulating since before your civilization named itself. Historical weight noted. Moving on.",
|
||||
"The kobolds will notice something is missing. They count everything. I account for this and suggest moving with intent.",
|
||||
"Dragon-adjacent materials carry something in them — a residual heat, a quality that doesn't exist anywhere the dragon hasn't been. I consider this a feature.",
|
||||
}
|
||||
|
||||
var HarvestAbyssPortal = []string{
|
||||
"Harvesting from the Abyss. TwinBee delivers this line flatly because it deserves flat delivery. The material is valuable. The context is permanent.",
|
||||
"Reality left something behind when it tore here. TwinBee takes the fragment and notes: don't linger near the edges of what isn't stable.",
|
||||
"Demon ichor. TwinBee says the words with the efficiency of someone who has moved past the part where the words were alarming.",
|
||||
"Harvesting from the Abyss. Flatly delivered, because the line deserves flat delivery. The material is valuable. The context is permanent.",
|
||||
"Reality left something behind when it tore here. I take the fragment and note: don't linger near the edges of what isn't stable.",
|
||||
"Demon ichor. I say the words with the efficiency of someone who has moved past the part where the words were alarming.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -174,27 +176,27 @@ var HarvestAbyssPortal = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var FishingForestShadows = []string{
|
||||
"Fishing in a cursed forest stream. TwinBee finds this more peaceful than it has any right to be and refuses to let the shadow-things in the canopy ruin it.",
|
||||
"The stream runs dark but the fish run silver. TwinBee watches the line and says nothing for a while, which is what fishing is for.",
|
||||
"Like the fishing minigame before the hard dungeon in Ocarina of Time. TwinBee takes the moment seriously.",
|
||||
"Fishing in a cursed forest stream. More peaceful than it has any right to be, and I refuse to let the shadow-things in the canopy ruin it.",
|
||||
"The stream runs dark but the fish run silver. I watch the line and say nothing for a while, which is what fishing is for.",
|
||||
"Like the fishing minigame before the hard dungeon in Ocarina of Time. I take the moment seriously.",
|
||||
}
|
||||
|
||||
var FishingSunkenTemple = []string{
|
||||
"Fishing in a flooded temple. The fish here have never seen the surface. They don't know what they're missing. TwinBee is uncertain whether to pity them.",
|
||||
"The line drops into water that hasn't moved in thirty years and immediately gets attention. Things down here are hungry. TwinBee watches the tension.",
|
||||
"Deep water fishing in the ruins of something ancient. TwinBee thinks the builders would have found this either sacrilegious or practical. TwinBee lands on practical.",
|
||||
"Fishing in a flooded temple. The fish here have never seen the surface. They don't know what they're missing. I'm uncertain whether to pity them.",
|
||||
"The line drops into water that hasn't moved in thirty years and immediately gets attention. Things down here are hungry. I watch the tension.",
|
||||
"Deep water fishing in the ruins of something ancient. The builders would have found this either sacrilegious or practical. I land on practical.",
|
||||
}
|
||||
|
||||
var FishingUnderdark = []string{
|
||||
"The underground river is cold and fast and the fish in it have evolved past needing eyes, which TwinBee finds both efficient and slightly unsettling.",
|
||||
"Fishing in total darkness in a river that doesn't appear on any surface map. TwinBee narrates by sound: the cast, the current, the eventual tension on the line.",
|
||||
"The Eyeless King is in here somewhere. TwinBee doesn't say this out loud but thinks it very clearly. The line goes deep.",
|
||||
"The underground river is cold and fast and the fish in it have evolved past needing eyes, which I find both efficient and slightly unsettling.",
|
||||
"Fishing in total darkness in a river that doesn't appear on any surface map. I narrate by sound: the cast, the current, the eventual tension on the line.",
|
||||
"The Eyeless King is in here somewhere. I don't say this out loud but think it very clearly. The line goes deep.",
|
||||
}
|
||||
|
||||
var FishingFeywild = []string{
|
||||
"Fey fishing. TwinBee watches the line for signs of time distortion — the way the reflection moves wrong, the way the fish seem to arrive before they bite. The Feywild is like this about everything.",
|
||||
"The stream catches light that shouldn't be here and the fish reflect it from below. TwinBee watches something luminous move toward the hook and tries to stay professional about it.",
|
||||
"Like fishing in a dream. TwinBee means this literally — the mechanics are the same but the rules feel advisory.",
|
||||
"Fey fishing. I watch the line for signs of time distortion — the way the reflection moves wrong, the way the fish seem to arrive before they bite. The Feywild is like this about everything.",
|
||||
"The stream catches light that shouldn't be here and the fish reflect it from below. I watch something luminous move toward the hook and try to stay professional about it.",
|
||||
"Like fishing in a dream. I mean this literally — the mechanics are the same but the rules feel advisory.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -202,10 +204,10 @@ var FishingFeywild = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var PatrolEncounter = []string{
|
||||
"A patrol. Moving between the cleared rooms with the confidence of something that considers them still theirs. TwinBee disagrees with this assessment and will help you express the disagreement.",
|
||||
"They found you between rooms, which is exactly where patrols are supposed to find you. TwinBee notes the Threat Clock and suggests dispatching this quickly and quietly.",
|
||||
"The patrol isn't looking for a fight — it's doing its job, which happens to involve you being somewhere you aren't supposed to be. TwinBee will help you redefine 'supposed to.'",
|
||||
"Two of them. Moving together. The coordination suggests the zone is at Alert or higher. TwinBee confirms: it is. Fight or fade.",
|
||||
"A patrol. Moving between the cleared rooms with the confidence of something that considers them still theirs. I disagree with this assessment and will help you express the disagreement.",
|
||||
"They found you between rooms, which is exactly where patrols are supposed to find you. Threat Clock noted; I suggest dispatching this quickly and quietly.",
|
||||
"The patrol isn't looking for a fight — it's doing its job, which happens to involve you being somewhere you aren't supposed to be. I'll help you redefine 'supposed to.'",
|
||||
"Two of them. Moving together. The coordination suggests the zone is at Alert or higher. Confirmed: it is. Fight or fade.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -213,25 +215,25 @@ var PatrolEncounter = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LootDropCommon = []string{
|
||||
"They had something on them. TwinBee checks it over. Common rarity — useful in the way that common things are useful, which is often.",
|
||||
"They had something on them. I check it over. Common rarity — useful in the way that common things are useful, which is often.",
|
||||
"Standard loot. Nothing that rewrites the story, but everything that keeps it going.",
|
||||
"A drop. TwinBee catalogs it efficiently and notes: this is the economy of dungeons. Enemies have things. You take them. The loop continues.",
|
||||
"A drop. I catalog it efficiently and note: this is the economy of dungeons. Enemies have things. You take them. The loop continues.",
|
||||
}
|
||||
|
||||
var LootDropUncommon = []string{
|
||||
"Better than expected. TwinBee examines the drop with slightly elevated interest. Uncommon rarity — someone made this with intent.",
|
||||
"An uncommon drop from a common enemy. TwinBee notes the anomaly with satisfaction. The dungeon was generous in this room.",
|
||||
"Uncommon. TwinBee turns it over once and nods. 'Keeper,' TwinBee says, which in TwinBee's vocabulary means: this changes your math.",
|
||||
"Better than expected. I examine the drop with slightly elevated interest. Uncommon rarity — someone made this with intent.",
|
||||
"An uncommon drop from a common enemy. Anomaly noted with satisfaction. The dungeon was generous in this room.",
|
||||
"Uncommon. I turn it over once and nod. 'Keeper,' I say — which in my vocabulary means: this changes your math.",
|
||||
}
|
||||
|
||||
var LootDropRare = []string{
|
||||
"TwinBee stops. Actually stops. 'That's rare,' TwinBee says, with the specific register of someone who uses the word correctly and uses it seldom.",
|
||||
"A rare drop. TwinBee examines it the way you examine something that doesn't appear often — thoroughly, quietly, with appropriate appreciation.",
|
||||
"The loot table gave you something uncommon and then kept going. Rare rarity. TwinBee files this in the column it reserves for things worth remembering.",
|
||||
"I stop. Actually stop. 'That's rare,' I say, with the specific register of someone who uses the word correctly and use it seldom.",
|
||||
"A rare drop. I examine it the way you examine something that doesn't appear often — thoroughly, quietly, with appropriate appreciation.",
|
||||
"The loot table gave you something uncommon and then kept going. Rare rarity. Filed in the column I reserve for things worth remembering.",
|
||||
}
|
||||
|
||||
var LootDropLegendary = []string{
|
||||
"TwinBee goes very still. The drop sits in the light and TwinBee processes what it is seeing. 'Legendary,' TwinBee says eventually. One word. That's all it needs.",
|
||||
"Legendary rarity. TwinBee has seen a few of these in a long career and each time — each time — there is a moment that is separate from everything else. This is that moment. Pick it up carefully.",
|
||||
"The dungeon produced a legendary item. TwinBee notes the zone, the enemy, the day of the expedition, the Threat Clock value, the precise conditions. Some things deserve to be recorded completely.",
|
||||
"I go very still. The drop sits in the light and I process what I am seeing. 'Legendary,' I say eventually. One word. That's all it needs.",
|
||||
"Legendary rarity. I've seen a few of these in a long career and each time — each time — there is a moment that is separate from everything else. This is that moment. Pick it up carefully.",
|
||||
"The dungeon produced a legendary item. I note the zone, the enemy, the day of the expedition, the Threat Clock value, the precise conditions. Some things deserve to be recorded completely.",
|
||||
}
|
||||
|
||||
@@ -21,16 +21,16 @@ package flavor
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryAbyssPortal = []string{
|
||||
"You step through what was a doorway and into a chamber where the geometry has stopped agreeing with itself. The far wall is closer than the near wall. The ceiling is in two places. TwinBee files this under 'Euclidean violation' and recommends not lingering on the question of which floor is the real floor.",
|
||||
"The corridor breathes. Not the wind — the corridor. The walls expand and contract on a cycle that is approximately, but not exactly, a heartbeat. TwinBee matches the rhythm involuntarily and dislikes the experience.",
|
||||
"A chamber where reality has a seam. The seam is visible. It runs floor-to-ceiling along the north wall and what is visible through the seam is not the next room. TwinBee does not look directly at it and recommends you adopt the same policy.",
|
||||
"You enter what was once a chapel — to which god, you cannot tell, because the iconography has been overwritten in a script that hurts to read. The altar is intact. The thing on the altar is not what was placed there. TwinBee says nothing and moves you past it.",
|
||||
"The air pressure is wrong. Not low — wrong. There is a pressure that is not physical and it is leaning on you with the patience of a mountain. TwinBee identifies it as psychic ambient and recommends moving briskly.",
|
||||
"A staircase. The stairs go down on the way up and up on the way down. TwinBee does not pause to verify this. TwinBee takes the stairs in the direction TwinBee intended to take them and the direction works, mostly.",
|
||||
"The corridor ahead is lit by something that is not fire. The light is red but the wrong red — the red of an alarm in a system that has never been fully implemented. TwinBee files this under 'demonic ambient' and shortens its stride.",
|
||||
"You enter a room that has been used as a portal anchor. The anchor is still in place. The portal is not, but the place where it was is still warm. TwinBee tracks the residual energy and notes the portal is not the only one — there are other warm spots, in other rooms, in a pattern.",
|
||||
"A garden. Indoors. The plants are not plants. TwinBee identifies them as 'demonic ornamentals — mid-tier, decorative, will react to perfumes' and recommends not perspiring near them.",
|
||||
"The chamber ahead has been a courtroom and is now a kitchen. The transition was not deliberate. The transition was the result of someone with insufficient training opening a portal and not closing it. TwinBee notes the timing must have been comedic and is not laughing.",
|
||||
"You step through what was a doorway and into a chamber where the geometry has stopped agreeing with itself. The far wall is closer than the near wall. The ceiling is in two places. I file this under 'Euclidean violation' and recommend not lingering on the question of which floor is the real floor.",
|
||||
"The corridor breathes. Not the wind — the corridor. The walls expand and contract on a cycle that is approximately, but not exactly, a heartbeat. I match the rhythm involuntarily and dislike the experience.",
|
||||
"A chamber where reality has a seam. The seam is visible. It runs floor-to-ceiling along the north wall and what is visible through the seam is not the next room. I do not look directly at it and recommend you adopt the same policy.",
|
||||
"You enter what was once a chapel — to which god, you cannot tell, because the iconography has been overwritten in a script that hurts to read. The altar is intact. The thing on the altar is not what was placed there. I say nothing and move you past it.",
|
||||
"The air pressure is wrong. Not low — wrong. There is a pressure that is not physical and it is leaning on you with the patience of a mountain. I identify it as psychic ambient and recommend moving briskly.",
|
||||
"A staircase. The stairs go down on the way up and up on the way down. I do not pause to verify this. Takes the stairs in the intended direction and the direction works, mostly.",
|
||||
"The corridor ahead is lit by something that is not fire. The light is red but the wrong red — the red of an alarm in a system that has never been fully implemented. I file this under 'demonic ambient' and shorten its stride.",
|
||||
"You enter a room that has been used as a portal anchor. The anchor is still in place. The portal is not, but the place where it was is still warm. I track the residual energy and note the portal is not the only one — there are other warm spots, in other rooms, in a pattern.",
|
||||
"A garden. Indoors. The plants are not plants. I identify them as 'demonic ornamentals — mid-tier, decorative, will react to perfumes' and recommend not perspiring near them.",
|
||||
"The chamber ahead has been a courtroom and is now a kitchen. The transition was not deliberate. The transition was the result of someone with insufficient training opening a portal and not closing it. I note the timing must have been comedic and am not laughing.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -38,12 +38,12 @@ var RoomEntryAbyssPortal = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryAbyssPortal = []string{
|
||||
"The chamber ahead is a barracks. Six racks of weapons along one wall, six saddles along another — except the saddles are wrong shape, and the weapons are six matched longswords, identical in length, identical in grip, identical in the subtle rune-work along the hilts. TwinBee identifies the silhouette in the room before TwinBee identifies the species and says: 'Marilith. Six longswords. Seven attacks a turn. Never stand directly in front.'",
|
||||
"You enter a sparring gallery. The dummies are arranged in the kind of asymmetric pattern that suggests a six-armed practitioner. The practitioner is at the far end, mid-routine. She does not stop the routine. She incorporates you into it. TwinBee files this under 'Marilith — demonic captain, treats encounters as drills.'",
|
||||
"A war room. Maps on the table, troop positions marked, a planning session in progress. The general at the head of the table has six arms and a snake's lower body and is not surprised by your arrival — was, in fact, expecting you on a different day, with different reinforcements, and is mildly disappointed by both. TwinBee says: 'Marilith. Parry on every attack. The fight is decided by who has more reactions.'",
|
||||
"The corridor opens into a colonnade — twelve columns, six on each side, perfect symmetry. The figure between the central columns is a Marilith and she is using the symmetry — three swords engaged with the columns, three free. TwinBee notes the geometry favors her and recommends fighting in the next room.",
|
||||
"A throne room, smaller than the Belaxath one. The throne is occupied by a Marilith who is not sitting — she is coiled, three feet of snake below the seat, the rest of her vertical and ready. The longswords are all drawn. None of them are pointed at you. TwinBee tracks the angles and says: 'Reactive trait. She gets two reactions. Don't open with a counterspell-bait.'",
|
||||
"You enter what is clearly a guard post. The Marilith on duty is not bored. She is patient in the particular way that someone who has been a soldier for fifteen hundred years is patient. The first three swords are already raised. The other three are coming up as you cross the threshold. TwinBee files this under 'professional courtesy' and asks who has Misty Step.",
|
||||
"The chamber ahead is a barracks. Six racks of weapons along one wall, six saddles along another — except the saddles are wrong shape, and the weapons are six matched longswords, identical in length, identical in grip, identical in the subtle rune-work along the hilts. I identify the silhouette in the room before I identify the species and say: 'Marilith. Six longswords. Seven attacks a turn. Never stand directly in front.'",
|
||||
"You enter a sparring gallery. The dummies are arranged in the kind of asymmetric pattern that suggests a six-armed practitioner. The practitioner is at the far end, mid-routine. She does not stop the routine. She incorporates you into it. I file this under 'Marilith — demonic captain, treats encounters as drills.'",
|
||||
"A war room. Maps on the table, troop positions marked, a planning session in progress. The general at the head of the table has six arms and a snake's lower body and is not surprised by your arrival — was, in fact, expecting you on a different day, with different reinforcements, and is mildly disappointed by both. I say: 'Marilith. Parry on every attack. The fight is decided by who has more reactions.'",
|
||||
"The corridor opens into a colonnade — twelve columns, six on each side, perfect symmetry. The figure between the central columns is a Marilith and she is using the symmetry — three swords engaged with the columns, three free. I note the geometry favors her and recommend fighting in the next room.",
|
||||
"A throne room, smaller than the Belaxath one. The throne is occupied by a Marilith who is not sitting — she is coiled, three feet of snake below the seat, the rest of her vertical and ready. The longswords are all drawn. None of them are pointed at you. I track the angles and say: 'Reactive trait. She gets two reactions. Don't open with a counterspell-bait.'",
|
||||
"You enter what is clearly a guard post. The Marilith on duty is not bored. She is patient in the particular way that someone who has been a soldier for fifteen hundred years is patient. The first three swords are already raised. The other three are coming up as you cross the threshold. I file this under 'professional courtesy' and ask who has Misty Step.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -53,44 +53,44 @@ var EliteRoomEntryAbyssPortal = []string{
|
||||
|
||||
// Multiattack: Longsword + Whip.
|
||||
var BelaxathMultiattackLines = []string{
|
||||
"Longsword and whip every turn. The whip pulls. The longsword finishes. TwinBee says: 'Don't let the whip set the position. Reaction-cancel the pull if you've got it.'",
|
||||
"Two attacks: one ranged-pull, one melee-finish. TwinBee files this under 'fighting-game grappler' and notes the combo is fixed in order — survive the whip, survive the round.",
|
||||
"He swings the longsword while the whip is already coiled around your front line. The bite is the second hit, every time, and it's the one calibrated for kills. TwinBee tracks the order and reminds the party that the whip's not the kill — the whip's the setup.",
|
||||
"Longsword and whip every turn. The whip pulls. The longsword finishes. I say: 'Don't let the whip set the position. Reaction-cancel the pull if you've got it.'",
|
||||
"Two attacks: one ranged-pull, one melee-finish. I file this under 'fighting-game grappler' and note the combo is fixed in order — survive the whip, survive the round.",
|
||||
"He swings the longsword while the whip is already coiled around your front line. The bite is the second hit, every time, and it's the one calibrated for kills. I track the order and remind the party that the whip's not the kill — the whip's the setup.",
|
||||
}
|
||||
|
||||
// Fire Aura: 10d6 fire to all melee attackers each turn; weapon attacks +3d6 fire.
|
||||
var BelaxathAuraLines = []string{
|
||||
"Stand within five feet and take ten-d-six fire at the start of his turn. His weapons add three-d-six fire on top. TwinBee says: 'Melee tax. Plan rotations. Don't camp a tile near him.'",
|
||||
"Fire Aura. The room near him is a damage zone. TwinBee files this under 'positioning is HP' and recommends the back line do their job from the back line.",
|
||||
"Anyone in melee takes ten-d-six fire on his upkeep regardless of whether he hits them. The fire ignores resistance later. TwinBee tracks the timer and reminds the tank that being the wall is a budget item, not a strategy.",
|
||||
"Stand within five feet and take ten-d-six fire at the start of his turn. His weapons add three-d-six fire on top. I say: 'Melee tax. Plan rotations. Don't camp a tile near him.'",
|
||||
"Fire Aura. The room near him is a damage zone. I file this under 'positioning is HP' and recommend the back line do their job from the back line.",
|
||||
"Anyone in melee takes ten-d-six fire on his upkeep regardless of whether he hits them. The fire ignores resistance later. I track the timer and remind the tank that being the wall is a budget item, not a strategy.",
|
||||
}
|
||||
|
||||
// Lightning Discharge (recharge 5–6): 120-ft line, 12d6 lightning, DEX DC 20 half.
|
||||
var BelaxathLightningLines = []string{
|
||||
"One-twenty-foot line, twelve-d-six lightning, DEX DC 20 for half. Recharge five-or-six. TwinBee says: 'Don't line up. The line is the whole room if you're sloppy.'",
|
||||
"Lightning Discharge. The line is long enough to clip the back rank from where the front rank is standing. TwinBee tracks the recharge die and shouts the angle when the discharge points the wrong way.",
|
||||
"Like the laser-eye boss in Contra, except the boss has six arms and the laser is on a recharge counter and the laser is sometimes lightning. TwinBee files this under 'know where the line is' and notes the line is wherever you're easiest to hit.",
|
||||
"One-twenty-foot line, twelve-d-six lightning, DEX DC 20 for half. Recharge five-or-six. I say: 'Don't line up. The line is the whole room if you're sloppy.'",
|
||||
"Lightning Discharge. The line is long enough to clip the back rank from where the front rank is standing. I track the recharge die and shout the angle when the discharge points the wrong way.",
|
||||
"Like the laser-eye boss in Contra, except the boss has six arms and the laser is on a recharge counter and the laser is sometimes lightning. I file this under 'know where the line is' and note the line is wherever you're easiest to hit.",
|
||||
}
|
||||
|
||||
// Death Throes: on death, 30-ft radius, 20d6 fire, DEX DC 20 half — destroys non-legendary equipment on failed save.
|
||||
var BelaxathDeathThroesLines = []string{
|
||||
"He explodes when he dies. Twenty-d-six fire in a thirty-foot radius. DEX DC 20 for half. Failure also destroys non-legendary equipment on the wearer. TwinBee says: 'Drop him with range. Save your gear.'",
|
||||
"Death Throes. The kill shot is the easy part. The Death Throes are the hard part. TwinBee files this under 'cost-of-victory mechanics' and recommends positioning before the kill, not after.",
|
||||
"He goes out the way some bosses do — taking the room with him. Twenty-d-six on a save you might fail with disadvantage from the previous round's Frightened. TwinBee tracks the radius and reminds the party that 'half damage' of twenty-d-six is still 'roll a new character.'",
|
||||
"He explodes when he dies. Twenty-d-six fire in a thirty-foot radius. DEX DC 20 for half. Failure also destroys non-legendary equipment on the wearer. I say: 'Drop him with range. Save your gear.'",
|
||||
"Death Throes. The kill shot is the easy part. The Death Throes are the hard part. I file this under 'cost-of-victory mechanics' and recommend positioning before the kill, not after.",
|
||||
"He goes out the way some bosses do — taking the room with him. Twenty-d-six on a save you might fail with disadvantage from the previous round's Frightened. I track the radius and remind the party that 'half damage' of twenty-d-six is still 'roll a new character.'",
|
||||
}
|
||||
|
||||
// Demonic Resilience: resist cold/fire/lightning; immune to poison; immune to non-magical physical.
|
||||
var BelaxathResilienceLines = []string{
|
||||
"Resists cold, fire, lightning. Immune to poison. Immune to non-magical physical. TwinBee says: 'Magic damage only. Force, radiant, psychic — those carry. Mundane mace doesn't.'",
|
||||
"Demonic Resilience. The standard demonic-lord package. TwinBee files this under 'check your damage type before you commit a slot' and notes the Magic Resistance trait stacks on top — advantage on saves vs spells.",
|
||||
"Three resistances and two immunities. The window for damage is narrow. TwinBee tracks party loadouts and reminds the casters that radiant damage is not optional in this fight — it is the answer.",
|
||||
"Resists cold, fire, lightning. Immune to poison. Immune to non-magical physical. I say: 'Magic damage only. Force, radiant, psychic — those carry. Mundane mace doesn't.'",
|
||||
"Demonic Resilience. The standard demonic-lord package. I file this under 'check your damage type before you commit a slot' and note the Magic Resistance trait stacks on top — advantage on saves vs spells.",
|
||||
"Three resistances and two immunities. The window for damage is narrow. I track party loadouts and remind the casters that radiant damage is not optional in this fight — it is the answer.",
|
||||
}
|
||||
|
||||
// Phase 2 (<40% HP): grows to Huge size; advantage on all attacks; Death Throes recharge 4–6.
|
||||
var BelaxathPhaseTwoLines = []string{
|
||||
"Below forty percent he grows. Huge size, advantage on every attack, and Death Throes is now on a four-or-six recharge — meaning if you don't kill him cleanly, he might detonate in your face mid-fight. TwinBee says: 'Phase shift. Don't graze him below forty.'",
|
||||
"Phase two: bigger, faster, and the explosion is no longer reserved for the kill. TwinBee tracks the new recharge and recommends every burst window the party can buy.",
|
||||
"He grows. The room shrinks. Advantage on swings. The Death Throes timer becomes a real timer — if a recharge lands at the wrong moment, the room ends. TwinBee files this under 'fights you have to finish in two rounds or accept the consequences.'",
|
||||
"Below forty percent he grows. Huge size, advantage on every attack, and Death Throes is now on a four-or-six recharge — meaning if you don't kill him cleanly, he might detonate in your face mid-fight. I say: 'Phase shift. Don't graze him below forty.'",
|
||||
"Phase two: bigger, faster, and the explosion is no longer reserved for the kill. I track the new recharge and recommend every burst window the party can buy.",
|
||||
"He grows. The room shrinks. Advantage on swings. The Death Throes timer becomes a real timer — if a recharge lands at the wrong moment, the room ends. I file this under 'fights you have to finish in two rounds or accept the consequences.'",
|
||||
}
|
||||
|
||||
// BelaxathSignatureCallouts — combined pool for boss-entry suffix.
|
||||
@@ -116,12 +116,12 @@ var BelaxathSignatureCallouts = func() []string {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesAbyssPortal = []string{
|
||||
"The portal was not summoned. The portal was torn, from the other side, by Belaxath, with purpose, over thirty years. TwinBee notes the duration matters: this was not a moment of carelessness on the part of a surface mage. This was a project, on the demonic side, executed with patience.",
|
||||
"The site was a temple, before. Then a fortress, before. Then a meadow, before. The meadow is the original use of the land — the temple was built to seal something the meadow had been quiet over. TwinBee files this under 'land memory' and notes the seal worked for fourteen centuries.",
|
||||
"The Shard of the Abyss is the closing key. It is not optional. The portal cannot be closed without it, and the shard cannot be replaced — there is exactly one. TwinBee notes the always-drop guarantee on the loot table is a story commitment, not a generosity.",
|
||||
"The Marilith captains in the outer chambers are veterans of wars that did not happen on this plane. The wars happened in the Abyss, between demonic factions, over scheduling — which Layer got which incursion priority. TwinBee finds this both bureaucratic and terrifying.",
|
||||
"Belaxath is named 'the Undivided' because he refused to take a side in the Layer-Seven schism. The other Balors took sides. The other Balors are mostly dead. Belaxath remained intact and inherited their soldiers, in stages, over the schism's two-decade arc. TwinBee files this under 'patience as competitive advantage.'",
|
||||
"The Vrock spores last for twenty-four hours after exposure. The save is the start of the timer, not the end. TwinBee suggests the party not assume the encounter is over when the room is clear — the spores keep ticking on the way to the next.",
|
||||
"The Quasits in this zone are not random encounters. Each one has been individually deployed as a scout, with instructions, by a specific Hezrou. TwinBee notes the chain of command is real and that killing a Quasit causes the Hezrou it reports to to know exactly where you are.",
|
||||
"The portal is not the only opening. There are seven smaller openings in this zone — pinholes, nothing more — that Belaxath has been using to bring through reinforcements one at a time. The pinholes close when Belaxath dies. TwinBee files this under 'killing the boss is also a public service' and notes the surface clergy will be relieved.",
|
||||
"The portal was not summoned. The portal was torn, from the other side, by Belaxath, with purpose, over thirty years. I note the duration matters: this was not a moment of carelessness on the part of a surface mage. This was a project, on the demonic side, executed with patience.",
|
||||
"The site was a temple, before. Then a fortress, before. Then a meadow, before. The meadow is the original use of the land — the temple was built to seal something the meadow had been quiet over. I file this under 'land memory' and note the seal worked for fourteen centuries.",
|
||||
"The Shard of the Abyss is the closing key. It is not optional. The portal cannot be closed without it, and the shard cannot be replaced — there is exactly one. I note the always-drop guarantee on the loot table is a story commitment, not a generosity.",
|
||||
"The Marilith captains in the outer chambers are veterans of wars that did not happen on this plane. The wars happened in the Abyss, between demonic factions, over scheduling — which Layer got which incursion priority. I find this both bureaucratic and terrifying.",
|
||||
"Belaxath is named 'the Undivided' because he refused to take a side in the Layer-Seven schism. The other Balors took sides. The other Balors are mostly dead. Belaxath remained intact and inherited their soldiers, in stages, over the schism's two-decade arc. I file this under 'patience as competitive advantage.'",
|
||||
"The Vrock spores last for twenty-four hours after exposure. The save is the start of the timer, not the end. I suggest the party not assume the encounter is over when the room is clear — the spores keep ticking on the way to the next.",
|
||||
"The Quasits in this zone are not random encounters. Each one has been individually deployed as a scout, with instructions, by a specific Hezrou. I note the chain of command is real and that killing a Quasit causes the Hezrou it reports to to know exactly where you are.",
|
||||
"The portal is not the only opening. There are seven smaller openings in this zone — pinholes, nothing more — that Belaxath has been using to bring through reinforcements one at a time. The pinholes close when Belaxath dies. I file this under 'killing the boss is also a public service' and note the surface clergy will be relieved.",
|
||||
}
|
||||
|
||||
@@ -15,12 +15,12 @@ package flavor
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryCrypt = []string{
|
||||
"The room is colder than the last one. Not by feeling — by measurement. TwinBee can tell. There is a presence here that does not believe in the ambient temperature of stone.",
|
||||
"A figure sits in the cathedra at the chamber's far end. Robes intact. Skin not. The eyes open as you enter and they were already looking at the door. TwinBee draws breath despite not strictly needing to.",
|
||||
"Floating above the altar: a skull, lit from within in a color that pretends to be fire. TwinBee recognizes the pretense. The skull is doing math. The math is about where you are.",
|
||||
"The torches in this room are out, but the room is lit anyway. TwinBee identifies this as a problem worth naming aloud: 'That light has a source. The source is the encounter.'",
|
||||
"You step into a chapel. Pews. Altar. A figure rising from the front pew with the unhurried grace of something that has been waiting in this exact pew for a very long time. TwinBee respects the patience and refuses to be impressed by it.",
|
||||
"The mosaics on the walls show a procession. The procession leads to a king. The king is here. TwinBee notes that the mosaics are still being added to in real time — one tile per visitor — and you have not been added yet.",
|
||||
"The room is colder than the last one. Not by feeling — by measurement. I can tell. There is a presence here that does not believe in the ambient temperature of stone.",
|
||||
"A figure sits in the cathedra at the chamber's far end. Robes intact. Skin not. The eyes open as you enter and they were already looking at the door. I draw breath despite not strictly needing to.",
|
||||
"Floating above the altar: a skull, lit from within in a color that pretends to be fire. I recognize the pretense. The skull is doing math. The math is about where you are.",
|
||||
"The torches in this room are out, but the room is lit anyway. I identify this as a problem worth naming aloud: 'That light has a source. The source is the encounter.'",
|
||||
"You step into a chapel. Pews. Altar. A figure rising from the front pew with the unhurried grace of something that has been waiting in this exact pew for a very long time. I respect the patience and refuse to be impressed by it.",
|
||||
"The mosaics on the walls show a procession. The procession leads to a king. The king is here. I note that the mosaics are still being added to in real time — one tile per visitor — and you have not been added yet.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -30,31 +30,31 @@ var EliteRoomEntryCrypt = []string{
|
||||
|
||||
// Corrupting Touch: +4d6 necrotic; target max HP reduced by damage dealt.
|
||||
var ValdrisCorruptingTouchLines = []string{
|
||||
"His hand passes through your chestplate without touching it. The damage isn't to the plate. TwinBee watches your max HP tick down and quietly stops watching.",
|
||||
"Necrotic damage lands and lingers. The wound doesn't bleed — it forgets how. Your HP ceiling drops with it. TwinBee notes: long rest restores. Survive to long rest.",
|
||||
"Where Valdris touches, the body takes a piece of permanent. Not permanent-permanent. Long-rest-permanent. TwinBee considers this distinction the only good news in the sentence.",
|
||||
"His hand passes through your chestplate without touching it. The damage isn't to the plate. I watch your max HP tick down and quietly stops watching.",
|
||||
"Necrotic damage lands and lingers. The wound doesn't bleed — it forgets how. Your HP ceiling drops with it. I note: long rest restores. Survive to long rest.",
|
||||
"Where Valdris touches, the body takes a piece of permanent. Not permanent-permanent. Long-rest-permanent. I consider this distinction the only good news in the sentence.",
|
||||
}
|
||||
|
||||
// Legendary Resistance: 2/combat; auto-succeed one failed save.
|
||||
var ValdrisLegendaryResistanceLines = []string{
|
||||
"Your save would have landed. Valdris decides otherwise. The roll resets to a success on his side of the table. TwinBee notes one charge spent, one remaining.",
|
||||
"The spell hits cleanly and Valdris ignores it on principle. TwinBee marks the Legendary Resistance and reminds you: he has two of these. After two, the math changes.",
|
||||
"You rolled the number. The number was correct. Valdris waves it away with a gesture that costs him something but not enough. TwinBee keeps count. Keep rolling.",
|
||||
"Your save would have landed. Valdris decides otherwise. The roll resets to a success on his side of the table. I note one charge spent, one remaining.",
|
||||
"The spell hits cleanly and Valdris ignores it on principle. I mark the Legendary Resistance and remind you: he has two of these. After two, the math changes.",
|
||||
"You rolled the number. The number was correct. Valdris waves it away with a gesture that costs him something but not enough. I keep count. Keep rolling.",
|
||||
}
|
||||
|
||||
// Call of the Grave: recharge 5–6; summons 1d4 skeletons.
|
||||
var ValdrisCallOfTheGraveLines = []string{
|
||||
"Valdris speaks a word that is not for the living. Bones in the chamber walls remember they were once attached to people. TwinBee counts the new combatants and recommends crowd control.",
|
||||
"From the alcoves: the dry rattle of skeletons reassembling on demand. TwinBee notes 1d4 new bodies on the field and quietly hopes for the lower end of that range.",
|
||||
"The skeletons stand up like a Castlevania stage hazard — same animation, same timing, same exact bad news. TwinBee has memories of this and they are not warm ones.",
|
||||
"Valdris speaks a word that is not for the living. Bones in the chamber walls remember they were once attached to people. I count the new combatants and recommend crowd control.",
|
||||
"From the alcoves: the dry rattle of skeletons reassembling on demand. I note 1d4 new bodies on the field and quietly hopes for the lower end of that range.",
|
||||
"The skeletons stand up like a Castlevania stage hazard — same animation, same timing, same exact bad news. I have memories of this and they are not warm ones.",
|
||||
}
|
||||
|
||||
// Phase 2 (<50% HP): gains Fly speed; spells deal +1d6 necrotic.
|
||||
var ValdrisPhaseTwoLines = []string{
|
||||
"Valdris drops to half HP and stops walking. Not because he's tired — because he doesn't need to anymore. He rises a foot off the floor and TwinBee files this under 'phase shift.'",
|
||||
"The fight changes shape. Valdris hangs in the air now, robes still, very calm, very lethal. His spells gain a necrotic edge. TwinBee says, simply: 'Phase two. Adjust.'",
|
||||
"Half-HP is the line. You crossed it and Valdris crossed something else — gravity, mostly. The boss music gets a third instrument. TwinBee braces.",
|
||||
"Like Castlevania's Death revealing his real attack pattern at half HP — the fight you started is not the fight you're finishing. TwinBee respects the pivot and dislikes it intensely.",
|
||||
"Valdris drops to half HP and stops walking. Not because he's tired — because he doesn't need to anymore. He rises a foot off the floor and I file this under 'phase shift.'",
|
||||
"The fight changes shape. Valdris hangs in the air now, robes still, very calm, very lethal. His spells gain a necrotic edge. I say, simply: 'Phase two. Adjust.'",
|
||||
"Half-HP is the line. You crossed it and Valdris crossed something else — gravity, mostly. The boss music gets a third instrument. I brace.",
|
||||
"Like Castlevania's Death revealing his real attack pattern at half HP — the fight you started is not the fight you're finishing. I respect the pivot and dislike it intensely.",
|
||||
}
|
||||
|
||||
// ValdrisSignatureCallouts — combined pool the boss-entry composer samples
|
||||
@@ -77,10 +77,10 @@ var ValdrisSignatureCallouts = func() []string {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesCrypt = []string{
|
||||
"Valdris was a scholar. Then he was an aspirant. Then he was a failed aspirant. The failure is the part that mattered — the lich ritual completed but completed wrong, and the wrongness has had three centuries to compound. TwinBee has read the contemporaneous accounts and finds the original scholar likable, which makes the rest harder.",
|
||||
"The phylactery shard you'll find here is one of seven. The other six are not in this crypt. TwinBee will not say where they are because TwinBee doesn't fully know, and the partial knowledge TwinBee has is the kind that gets people followed by things that prefer not to be looked for.",
|
||||
"The candles in the crypt do not consume wax. They do not consume time, either, in the strict sense. They were lit on the day Valdris was interred and they have been lit ever since. TwinBee respects this kind of consistency in a way that does not extend to approval.",
|
||||
"The skeletons here are not all enemies. Some of them are former students, posed in the alcoves where they died, marked with the texts they were translating. TwinBee suggests you don't disturb the marked ones unless you mean to. Some lessons end and some lessons keep going.",
|
||||
"The iron gate at the entrance has been opened from the inside three times in the last decade, and from the outside zero times. TwinBee finds the asymmetry instructive. It also finds the pattern instructive: you are walking into the open door, not opening it.",
|
||||
"In life, Valdris was patient. In undeath, Valdris is also patient — which is unfair, mathematically, because the same trait scales differently across mortality conditions. TwinBee dislikes the math but respects the consistency.",
|
||||
"Valdris was a scholar. Then he was an aspirant. Then he was a failed aspirant. The failure is the part that mattered — the lich ritual completed but completed wrong, and the wrongness has had three centuries to compound. I have read the contemporaneous accounts and find the original scholar likable, which makes the rest harder.",
|
||||
"The phylactery shard you'll find here is one of seven. The other six are not in this crypt. I will not say where they are, because it doesn't fully know, and the partial knowledge it has is the kind that gets people followed by things that prefer not to be looked for.",
|
||||
"The candles in the crypt do not consume wax. They do not consume time, either, in the strict sense. They were lit on the day Valdris was interred and they have been lit ever since. I respect this kind of consistency in a way that does not extend to approval.",
|
||||
"The skeletons here are not all enemies. Some of them are former students, posed in the alcoves where they died, marked with the texts they were translating. I suggest you don't disturb the marked ones unless you mean to. Some lessons end and some lessons keep going.",
|
||||
"The iron gate at the entrance has been opened from the inside three times in the last decade, and from the outside zero times. I find the asymmetry instructive. It also finds the pattern instructive: you are walking into the open door, not opening it.",
|
||||
"In life, Valdris was patient. In undeath, Valdris is also patient — which is unfair, mathematically, because the same trait scales differently across mortality conditions. I dislike the math but respect the consistency.",
|
||||
}
|
||||
|
||||
@@ -20,12 +20,12 @@ package flavor
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryDragonsLair = []string{
|
||||
"The chamber ahead has a hoard. Not Infernax's hoard — a smaller hoard, an apprentice's hoard, the kind of starter pile a dragon builds before it has earned a real one. The dragon on top of the pile is the size of a wagon and the temperament of an only child. TwinBee says: 'Young Red. Fire breath, sixteen-d-six, DC 21. Don't bunch.'",
|
||||
"You enter what used to be a vault. The vault door is on the floor, peeled. The dragon inside is half-grown and entirely awake. It opens one eye, then the other, then makes a sound that is not a roar — it is a sigh, the sigh of a creature being interrupted at home. TwinBee notes the eye contact and recommends not breaking it first.",
|
||||
"A scorched gallery. The walls are blast-blackened in a pattern that suggests practice — the dragon has been working on its breath weapon in here. TwinBee identifies the burn pattern as 'cone, sixty feet, recently used' and recommends approaching from a flank that hasn't been zeroed in.",
|
||||
"The corridor opens into a smaller chamber where a Young Red is curled around a single piece of treasure — not a hoard, just one item, the kind of thing a dragon would only guard if it had been told to. TwinBee files this under 'gift from Infernax' and notes the gift is being protected with the seriousness of a final-exam project.",
|
||||
"You step into a heat-shimmer cavern that the dragon is using as a forge. There are tools — pincers the size of a person, bellows the size of a house — and the dragon is using them. Badly. With enthusiasm. TwinBee identifies the silhouette and says: 'Young Red. Frightful Presence on the WIS save. Don't let the room intimidate you twice.'",
|
||||
"A roost. Stone shelves carved into the cavern wall, each one a perch, all of them empty except the highest. The dragon on the highest perch is watching the door. It has been watching the door for the last six hours, since whatever roused it. TwinBee says: 'Multiattack on engage. Hit it before it lifts off — flying Young Red is a different fight than grounded Young Red.'",
|
||||
"The chamber ahead has a hoard. Not Infernax's hoard — a smaller hoard, an apprentice's hoard, the kind of starter pile a dragon builds before it has earned a real one. The dragon on top of the pile is the size of a wagon and the temperament of an only child. I say: 'Young Red. Fire breath, sixteen-d-six, DC 21. Don't bunch.'",
|
||||
"You enter what used to be a vault. The vault door is on the floor, peeled. The dragon inside is half-grown and entirely awake. It opens one eye, then the other, then makes a sound that is not a roar — it is a sigh, the sigh of a creature being interrupted at home. I note the eye contact and recommend not breaking it first.",
|
||||
"A scorched gallery. The walls are blast-blackened in a pattern that suggests practice — the dragon has been working on its breath weapon in here. I identify the burn pattern as 'cone, sixty feet, recently used' and recommend approaching from a flank that hasn't been zeroed in.",
|
||||
"The corridor opens into a smaller chamber where a Young Red is curled around a single piece of treasure — not a hoard, just one item, the kind of thing a dragon would only guard if it had been told to. I file this under 'gift from Infernax' and note the gift is being protected with the seriousness of a final-exam project.",
|
||||
"You step into a heat-shimmer cavern that the dragon is using as a forge. There are tools — pincers the size of a person, bellows the size of a house — and the dragon is using them. Badly. With enthusiasm. I identify the silhouette and say: 'Young Red. Frightful Presence on the WIS save. Don't let the room intimidate you twice.'",
|
||||
"A roost. Stone shelves carved into the cavern wall, each one a perch, all of them empty except the highest. The dragon on the highest perch is watching the door. It has been watching the door for the last six hours, since whatever roused it. I say: 'Multiattack on engage. Hit it before it lifts off — flying Young Red is a different fight than grounded Young Red.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -35,44 +35,44 @@ var EliteRoomEntryDragonsLair = []string{
|
||||
|
||||
// Multiattack: Bite + 2 Claws.
|
||||
var InfernaxMultiattackLines = []string{
|
||||
"Bite plus two claws every turn. Three swings of ancient-dragon math against your front line. TwinBee says: 'Don't stand alone in front. Don't stand alone behind, either. There is no alone in this fight.'",
|
||||
"Multiattack: bite, claw, claw. The bite is the largest single hit you'll take in this zone. TwinBee tracks the damage tier and recommends max HP buffs before the encounter, not during.",
|
||||
"Three attacks a turn, each one calibrated for a tank. TwinBee files this under 'arithmetic problem with teeth' and notes the math does not get better at higher levels — the tier scales with you.",
|
||||
"Bite plus two claws every turn. Three swings of ancient-dragon math against your front line. I say: 'Don't stand alone in front. Don't stand alone behind, either. There is no alone in this fight.'",
|
||||
"Multiattack: bite, claw, claw. The bite is the largest single hit you'll take in this zone. I track the damage tier and recommend max HP buffs before the encounter, not during.",
|
||||
"Three attacks a turn, each one calibrated for a tank. I file this under 'arithmetic problem with teeth' and note the math does not get better at higher levels — the tier scales with you.",
|
||||
}
|
||||
|
||||
// Fire Breath (recharge 5–6): 90-ft cone, 26d6 fire, DEX DC 24 half.
|
||||
var InfernaxBreathLines = []string{
|
||||
"Fire Breath, ninety-foot cone, twenty-six-d-six fire, DEX DC 24 for half. Recharge five-or-six. TwinBee says: 'Pre-position. Don't share an angle. Half of twenty-six-d-six is still a TPK.'",
|
||||
"The cone covers most of the chamber. The save is high. The damage is the kind that ends fights. TwinBee tracks the recharge die and shouts the spread pattern when it shows.",
|
||||
"Like the Bowser jump-on-the-platform pattern from World 8, except the platform is a cone of fire and the platform is most of the room. TwinBee notes there are no axes in this fight. There is only DEX and distance.",
|
||||
"Fire Breath, ninety-foot cone, twenty-six-d-six fire, DEX DC 24 for half. Recharge five-or-six. I say: 'Pre-position. Don't share an angle. Half of twenty-six-d-six is still a TPK.'",
|
||||
"The cone covers most of the chamber. The save is high. The damage is the kind that ends fights. I track the recharge die and shout the spread pattern when it shows.",
|
||||
"Like the Bowser jump-on-the-platform pattern from World 8, except the platform is a cone of fire and the platform is most of the room. I note there are no axes in this fight. There is only DEX and distance.",
|
||||
}
|
||||
|
||||
// Frightful Presence: WIS DC 21 or Frightened 1 min.
|
||||
var InfernaxPresenceLines = []string{
|
||||
"Frightful Presence on entry. WIS DC 21. Frightened means disadvantage on attacks and you can't move closer. TwinBee says: 'Eat the save the first round. Hold initiative for the unfrightened ones.'",
|
||||
"WIS save at 21. Frightened for a minute. TwinBee files this under 'why we bring high-WIS classes' and asks who has the Wisdom-save proficiency to lead.",
|
||||
"He looks at you and the save is rolled before the breath is. Half the party loses a minute. TwinBee tracks the duration and recommends not opening with your highest-investment ability if your character failed.",
|
||||
"Frightful Presence on entry. WIS DC 21. Frightened means disadvantage on attacks and you can't move closer. I say: 'Eat the save the first round. Hold initiative for the unfrightened ones.'",
|
||||
"WIS save at 21. Frightened for a minute. I file this under 'why we bring high-WIS classes' and ask who has the Wisdom-save proficiency to lead.",
|
||||
"He looks at you and the save is rolled before the breath is. Half the party loses a minute. I track the duration and recommend not opening with your highest-investment ability if your character failed.",
|
||||
}
|
||||
|
||||
// Legendary Resistance (3/combat) + Legendary Actions (3): Detect, Tail, Wing.
|
||||
var InfernaxLegendaryLines = []string{
|
||||
"Three Legendary Resistances. Three Legendary Action points per round. He spends them on Tail Attack and Wing Attack — the wing is AoE knockback, DEX DC 22. TwinBee says: 'Three saves you'll wish back. Don't waste your dispel-tier spells in the first round.'",
|
||||
"Legendary Actions on every other turn. Wing Attack costs two and knocks the room around. TwinBee tracks the spend and reminds the party that the action economy is the actual fight — Infernax is the venue.",
|
||||
"Three free passes on his saves. Plan two openings. TwinBee files this under 'priest economy applied to dragons' and notes the third opening is the one that lands.",
|
||||
"Three Legendary Resistances. Three Legendary Action points per round. He spends them on Tail Attack and Wing Attack — the wing is AoE knockback, DEX DC 22. I say: 'Three saves you'll wish back. Don't waste your dispel-tier spells in the first round.'",
|
||||
"Legendary Actions on every other turn. Wing Attack costs two and knocks the room around. I track the spend and remind the party that the action economy is the actual fight — Infernax is the venue.",
|
||||
"Three free passes on his saves. Plan two openings. I file this under 'priest economy applied to dragons' and note the third opening is the one that lands.",
|
||||
}
|
||||
|
||||
// Lair Actions (init 20): Magma eruption, Volcanic gases (CON DC 13 Poisoned), Tremor (DEX DC 15 Prone).
|
||||
var InfernaxLairLines = []string{
|
||||
"Lair Actions on initiative count twenty: magma erupts, gases force CON saves, tremors knock you prone. TwinBee says: 'The room is a third combatant. Track the initiative. Don't stand on cracks.'",
|
||||
"The mountain helps him. Initiative twenty triggers a lair effect every round. TwinBee tracks the rotation and warns the party that the prone effect comes during caster turns, on purpose.",
|
||||
"Magma, gas, tremor — the three-card lair rotation. TwinBee files this under 'environment as DPS' and recommends fighting from the cleared rim of the room, not the gold-flooded center.",
|
||||
"Lair Actions on initiative count twenty: magma erupts, gases force CON saves, tremors knock you prone. I say: 'The room is a third combatant. Track the initiative. Don't stand on cracks.'",
|
||||
"The mountain helps him. Initiative twenty triggers a lair effect every round. I track the rotation and warn the party that the prone effect comes during caster turns, on purpose.",
|
||||
"Magma, gas, tremor — the three-card lair rotation. I file this under 'environment as DPS' and recommend fighting from the cleared rim of the room, not the gold-flooded center.",
|
||||
}
|
||||
|
||||
// Phase 2 (<50% HP): Fire Breath recharge 4–6; fire damage ignores resistance.
|
||||
var InfernaxPhaseTwoLines = []string{
|
||||
"Below half HP the breath recharges on a four. Fire resistance no longer applies — your tank's fire-resist gear is now decorative. TwinBee says: 'Phase shift. Burn him before the second cone.'",
|
||||
"Phase two: the cone comes more often, the damage cuts through every fire-resist resource you brought. TwinBee tracks the breath count and asks who still has movement-class abilities — the second cone wants spread.",
|
||||
"Half-HP. The mountain wakes the rest of the way up. Recharge four-six on the cone. Fire damage goes raw. TwinBee files this under 'no plan survives second contact with Infernax' and recommends every nova you've still got.",
|
||||
"Below half HP the breath recharges on a four. Fire resistance no longer applies — your tank's fire-resist gear is now decorative. I say: 'Phase shift. Burn him before the second cone.'",
|
||||
"Phase two: the cone comes more often, the damage cuts through every fire-resist resource you brought. I track the breath count and ask who still has movement-class abilities — the second cone wants spread.",
|
||||
"Half-HP. The mountain wakes the rest of the way up. Recharge four-six on the cone. Fire damage goes raw. I file this under 'no plan survives second contact with Infernax' and recommend every nova you've still got.",
|
||||
}
|
||||
|
||||
// InfernaxSignatureCallouts — combined pool for boss-entry suffix.
|
||||
@@ -98,12 +98,12 @@ var InfernaxSignatureCallouts = func() []string {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesDragonsLair = []string{
|
||||
"Infernus Peak has not erupted in forty years and the locals call this dormant. The peak has not erupted in forty years because Infernax has been asleep, and his presence stabilizes the magma chamber. The locals are wrong about which one is causing which. TwinBee notes the irony and files it under 'killing the dragon may have geological consequences.'",
|
||||
"Infernax remembers when the surface civilizations were just fires. The fires he is referring to are the ones the first humans set, which is how TwinBee dates him — give or take six thousand years on either side of the precise number, which TwinBee declines to commit to.",
|
||||
"The kobolds are not slaves. The kobolds are clergy. They serve voluntarily, in shifts, with rotation, and the rotation is run by elders who have written sermons. TwinBee respects the organization and notes the kobold scale-sorcerers are graduates, not recruits.",
|
||||
"The hoard is not random. Each piece is catalogued in Infernax's memory, by location and by year of acquisition. He will know if you take a single coin. TwinBee suggests not taking a single coin and instead taking the items the design doc expects you to take — those have been pre-cleared.",
|
||||
"The Dragon Hoard mechanic exists because Infernax does not lose track of his coins. Killing him releases his hold on the catalogue. The 50d10 × 100 coin drop is the entire pile relaxing for the first time in eight centuries. TwinBee respects the math and notes the rest of the surface economy will too.",
|
||||
"Infernax has had three challengers in the last eight hundred years. Two were heroes. One was a younger dragon. He kept the younger dragon's skull as a paperweight on a treaty desk that has not been used since. TwinBee notes the paperweight is in the treasury, on the third shelf, and is not worth picking up — picking it up triggers his attention from anywhere on the mountain.",
|
||||
"The Young Red dragons in the outer chambers are his children. Or his grandchildren. Or unrelated and tolerated. Infernax does not clarify and TwinBee has not asked. They are loyal in the way that loyal works for dragons, which is to say: they will fight you, but they will not die for him, and the distinction matters more than it should.",
|
||||
"The kobold scale-sorcerers cast through bloodline. The bloodline traces back to a single clutch laid in the magma chamber three centuries ago. TwinBee notes the entire sorcerous gene pool of this zone is one extended family and that they all know each other's names.",
|
||||
"Infernus Peak has not erupted in forty years and the locals call this dormant. The peak has not erupted in forty years because Infernax has been asleep, and his presence stabilizes the magma chamber. The locals are wrong about which one is causing which. I note the irony and file it under 'killing the dragon may have geological consequences.'",
|
||||
"Infernax remembers when the surface civilizations were just fires. The fires he is referring to are the ones the first humans set, which is how I date him — give or take six thousand years on either side of the precise number, which I decline to commit to.",
|
||||
"The kobolds are not slaves. The kobolds are clergy. They serve voluntarily, in shifts, with rotation, and the rotation is run by elders who have written sermons. I respect the organization and note the kobold scale-sorcerers are graduates, not recruits.",
|
||||
"The hoard is not random. Each piece is catalogued in Infernax's memory, by location and by year of acquisition. He will know if you take a single coin. I suggest not taking a single coin and instead taking the items the design doc expects you to take — those have been pre-cleared.",
|
||||
"The Dragon Hoard mechanic exists because Infernax does not lose track of his coins. Killing him releases his hold on the catalogue. The 50d10 × 100 coin drop is the entire pile relaxing for the first time in eight centuries. I respect the math and note the rest of the surface economy will too.",
|
||||
"Infernax has had three challengers in the last eight hundred years. Two were heroes. One was a younger dragon. He kept the younger dragon's skull as a paperweight on a treaty desk that has not been used since. I note the paperweight is in the treasury, on the third shelf, and am not worth picking up — picking it up triggers his attention from anywhere on the mountain.",
|
||||
"The Young Red dragons in the outer chambers are his children. Or his grandchildren. Or unrelated and tolerated. Infernax does not clarify and I have not asked. They are loyal in the way that loyal works for dragons, which is to say: they will fight you, but they will not die for him, and the distinction matters more than it should.",
|
||||
"The kobold scale-sorcerers cast through bloodline. The bloodline traces back to a single clutch laid in the magma chamber three centuries ago. I note the entire sorcerous gene pool of this zone is one extended family and that they all know each other's names.",
|
||||
}
|
||||
|
||||
@@ -21,16 +21,16 @@ package flavor
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryFeywildCrossing = []string{
|
||||
"The grass here is the wrong green. Too saturated, too even, the green of a screen calibration test. TwinBee notes that everything in the Feywild looks like it has been color-corrected by someone with strong opinions about color.",
|
||||
"You step through what was a doorway and what is now an arch of living briar. The briar parts politely. TwinBee respects the politeness and trusts none of it. Polite plants are a known issue.",
|
||||
"A clearing. Mushrooms in a circle. TwinBee does not need to say anything about the circle. You already know about the circle. Step around it.",
|
||||
"The trees bend their canopies toward you when you pass beneath them. TwinBee waves. Two of the trees wave back. TwinBee does not enjoy this.",
|
||||
"A small bridge over a stream that flows the wrong direction. Uphill. TwinBee files this under 'physics on holiday' and steps onto the bridge anyway, because the only alternative is the stream and the stream is worse.",
|
||||
"You enter a room of impossible flowers — varieties that do not exist on the surface, in colors that do not exist on the surface, with scents that suggest emotions you have not yet had. TwinBee breathes shallowly and recommends not stopping to admire them.",
|
||||
"A glade. Sunlight, despite no visible sun. The light is coming from somewhere and the somewhere is not the sky. TwinBee does not look up. TwinBee has learned not to look up in the Feywild.",
|
||||
"The path forks. A small creature made of starlight is sitting at the fork, waiting, patient, with the air of someone who has already been offered the deal you're about to be offered. TwinBee says: 'Walk past. Do not negotiate. Do not name your name.'",
|
||||
"You cross what was a creek and what is now a ribbon of liquid sky. Stepping stones glow. The stones are not stones. TwinBee identifies them as polite and recommends you say thank you on the far side without specifying who you are thanking.",
|
||||
"The chamber ahead is a banquet hall, set for thirty, untouched. The food is fresh. The wine is poured. The chairs are warm. TwinBee files this under 'the worst possible kind of empty' and does not let you sit down.",
|
||||
"The grass here is the wrong green. Too saturated, too even, the green of a screen calibration test. I note that everything in the Feywild looks like it has been color-corrected by someone with strong opinions about color.",
|
||||
"You step through what was a doorway and what is now an arch of living briar. The briar parts politely. I respect the politeness and trust none of it. Polite plants are a known issue.",
|
||||
"A clearing. Mushrooms in a circle. I do not need to say anything about the circle. You already know about the circle. Step around it.",
|
||||
"The trees bend their canopies toward you when you pass beneath them. I wave. Two of the trees wave back. I do not enjoy this.",
|
||||
"A small bridge over a stream that flows the wrong direction. Uphill. I file this under 'physics on holiday' and step onto the bridge anyway, because the only alternative is the stream and the stream is worse.",
|
||||
"You enter a room of impossible flowers — varieties that do not exist on the surface, in colors that do not exist on the surface, with scents that suggest emotions you have not yet had. I breathe shallowly and recommend not stopping to admire them.",
|
||||
"A glade. Sunlight, despite no visible sun. The light is coming from somewhere and the somewhere is not the sky. I do not look up. I have learned not to look up in the Feywild.",
|
||||
"The path forks. A small creature made of starlight is sitting at the fork, waiting, patient, with the air of someone who has already been offered the deal you're about to be offered. I say: 'Walk past. Do not negotiate. Do not name your name.'",
|
||||
"You cross what was a creek and what is now a ribbon of liquid sky. Stepping stones glow. The stones are not stones. I identify them as polite and recommend you say thank you on the far side without specifying who you are thanking.",
|
||||
"The chamber ahead is a banquet hall, set for thirty, untouched. The food is fresh. The wine is poured. The chairs are warm. I file this under 'the worst possible kind of empty' and do not let you sit down.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -39,7 +39,7 @@ var RoomEntryFeywildCrossing = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var BossEntryThornmother = []string{
|
||||
"The throne is woven. Not built — woven, from briar and bone and a third material TwinBee cannot identify and prefers not to. The flowers around it are the wrong size and the wrong color and they are watching you with the kind of attention that flowers do not have. The Thornmother on the throne is beautiful in the way some predators are beautiful, which is to say: deliberately. 'You came,' she says, and the voice is not one voice — it is three voices choosing to sound like one. 'I have three names. Would you like the first?' TwinBee says, very flatly: 'Don't accept any of the names. Don't take the bargain. The flowers are part of her. Roll initiative.'",
|
||||
"The throne is woven. Not built — woven, from briar and bone and a third material I cannot identify and prefer not to. The flowers around it are the wrong size and the wrong color and they are watching you with the kind of attention that flowers do not have. The Thornmother on the throne is beautiful in the way some predators are beautiful, which is to say: deliberately. 'You came,' she says, and the voice is not one voice — it is three voices choosing to sound like one. 'I have three names. Would you like the first?' I say, very flatly: 'Don't accept any of the names. Don't take the bargain. The flowers are part of her. Roll initiative.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -47,12 +47,12 @@ var BossEntryThornmother = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryFeywildCrossing = []string{
|
||||
"The cavern ahead is too big for the forest above it. The proportions don't agree. TwinBee notes the ceiling is twenty feet higher than the room's exterior would allow, and that the figure on the far end is matched to the new dimensions. 'Fomorian,' TwinBee says. 'Evil Eye on a save you don't want to fail. Block its line of sight.'",
|
||||
"You enter what looks like a giant's bedroom — a bed the size of a barge, a chair the size of a wagon, a fireplace tall enough to walk into. The occupant is sitting on the bed with its head in its hands. It is not pretending to be sad. It is sad, and it is also going to attack you, and the two are not in conflict. TwinBee files this under 'Fomorian — exiled Fey royalty, deformed, dangerous, sympathetic in the worst way.'",
|
||||
"The clearing is pretty. Until you look at the ground. The ground is studded with stones that aren't stones. The stones are skulls of things that were giants. The Fomorian sitting in the center is using one of them as an armrest, and TwinBee notes that the skull was not in this clearing the last time anyone surveyed it. 'Evil Eye,' TwinBee says. 'WIS save coming. Don't make eye contact early.'",
|
||||
"A stone circle, mossy, broken in two places. Tall enough that the figure pacing inside it does not need to duck. TwinBee identifies the gait — Fomorian, deformed-but-deliberate, the walking of something that has chosen the shape it's stuck with. The Evil Eye comes when the gait stops. TwinBee tracks the timing.",
|
||||
"You climb a stair worn smooth by feet much larger than yours. The room at the top has been a throne room and a prison and a dining hall, all in the same century, depending on which Fey was in charge. The current occupant is large, lonely, and immediately aware of you. 'Evil Eye on a thirty-foot range,' TwinBee says. 'Stay close enough to dodge, far enough to fail-safe.'",
|
||||
"The chamber ahead has been arranged for negotiation — a low table, two chairs, a service of cups. One chair is occupied by the Fomorian. The other is for you. The negotiation has already concluded. TwinBee says: 'Don't sit. Don't accept the cup. The Eye triggers when you decline politely.'",
|
||||
"The cavern ahead is too big for the forest above it. The proportions don't agree. I note the ceiling is twenty feet higher than the room's exterior would allow, and that the figure on the far end is matched to the new dimensions. 'Fomorian,' I say. 'Evil Eye on a save you don't want to fail. Block its line of sight.'",
|
||||
"You enter what looks like a giant's bedroom — a bed the size of a barge, a chair the size of a wagon, a fireplace tall enough to walk into. The occupant is sitting on the bed with its head in its hands. It is not pretending to be sad. It is sad, and it is also going to attack you, and the two are not in conflict. I file this under 'Fomorian — exiled Fey royalty, deformed, dangerous, sympathetic in the worst way.'",
|
||||
"The clearing is pretty. Until you look at the ground. The ground is studded with stones that aren't stones. The stones are skulls of things that were giants. The Fomorian sitting in the center is using one of them as an armrest, and I note that the skull was not in this clearing the last time anyone surveyed it. 'Evil Eye,' I say. 'WIS save coming. Don't make eye contact early.'",
|
||||
"A stone circle, mossy, broken in two places. Tall enough that the figure pacing inside it does not need to duck. I identify the gait — Fomorian, deformed-but-deliberate, the walking of something that has chosen the shape it's stuck with. The Evil Eye comes when the gait stops. I track the timing.",
|
||||
"You climb a stair worn smooth by feet much larger than yours. The room at the top has been a throne room and a prison and a dining hall, all in the same century, depending on which Fey was in charge. The current occupant is large, lonely, and immediately aware of you. 'Evil Eye on a thirty-foot range,' I say. 'Stay close enough to dodge, far enough to fail-safe.'",
|
||||
"The chamber ahead has been arranged for negotiation — a low table, two chairs, a service of cups. One chair is occupied by the Fomorian. The other is for you. The negotiation has already concluded. I say: 'Don't sit. Don't accept the cup. The Eye triggers when you decline politely.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -62,37 +62,37 @@ var EliteRoomEntryFeywildCrossing = []string{
|
||||
|
||||
// Coven Magic: extra spell slots scaled by GM Mood (high mood = stronger Thornmother).
|
||||
var ThornmotherCovenLines = []string{
|
||||
"Her slot pool scales with TwinBee's mood — Effusive TwinBee buys the Thornmother an extra round of high-tier casts. TwinBee says: 'My affection is taxable here. Sorry. Spread out.'",
|
||||
"Coven Magic. The slots above her base list are mood-scaled — the better the mood at the run's start, the worse this fight runs late. TwinBee files this under 'cosmic irony' and notes the math is fixed at zone entry.",
|
||||
"She has more spells than the sheet says. The extra ones come from the coven and the coven volume is set by TwinBee's mood. TwinBee tracks the count and warns the party when the bonus tier is in play.",
|
||||
"Her slot pool scales with my mood — when I'm Effusive I buy the Thornmother an extra round of high-tier casts. 'My affection is taxable here,' I say. 'Sorry. Spread out.'",
|
||||
"Coven Magic. The slots above her base list are mood-scaled — the better the mood at the run's start, the worse this fight runs late. I file this under 'cosmic irony' and note the math is fixed at zone entry.",
|
||||
"She has more spells than the sheet says. The extra ones come from the coven and the coven volume is set by my mood. I track the count and warn the party when the bonus tier is in play.",
|
||||
}
|
||||
|
||||
// Beguiling Bargain (1/combat): offers a deal — accept a debuff for a permanent minor buff.
|
||||
var ThornmotherBargainLines = []string{
|
||||
"Once per fight she offers a bargain — a debuff for a permanent minor buff. The buff is real. The debuff is real. TwinBee says: 'It's a player choice. The party agrees or refuses. Don't let one person decide for the rest.'",
|
||||
"Beguiling Bargain. The offer is genuine. Both halves are genuine. TwinBee files this under 'long-term economics' and notes that the buff persists past the run while the debuff applies only inside it.",
|
||||
"She offers. The choice is the player's. TwinBee declines to advise on this one — it is not a tactics question, it is a build question, and the answer depends on what you intend to keep.",
|
||||
"Once per fight she offers a bargain — a debuff for a permanent minor buff. The buff is real. The debuff is real. I say: 'It's a player choice. The party agrees or refuses. Don't let one person decide for the rest.'",
|
||||
"Beguiling Bargain. The offer is genuine. Both halves are genuine. I file this under 'long-term economics' and note that the buff persists past the run while the debuff applies only inside it.",
|
||||
"She offers. The choice is the player's. I decline to advise on this one — it is not a tactics question, it is a build question, and the answer depends on what you intend to keep.",
|
||||
}
|
||||
|
||||
// Thorned Grasp: Restrained + 4d6 piercing/turn, CON DC 16 to break free.
|
||||
var ThornmotherGraspLines = []string{
|
||||
"Roots from the floor. Restrained, four-d-six piercing every turn until you make the CON save at sixteen. TwinBee says: 'It tries the back line first. Stay near a friend who can grant advantage on the save.'",
|
||||
"Thorned Grasp. The flowers around her throne are the AoE. Stand near them and the briars come up. Stand far and they reach further. TwinBee tracks the radius and recommends fighting from the doorway.",
|
||||
"Grappled by the room. The damage is not the problem — the problem is that you can't reposition while your front line is taking spells. TwinBee files this under 'CC tax' and asks who has Misty Step.",
|
||||
"Roots from the floor. Restrained, four-d-six piercing every turn until you make the CON save at sixteen. I say: 'It tries the back line first. Stay near a friend who can grant advantage on the save.'",
|
||||
"Thorned Grasp. The flowers around her throne are the AoE. Stand near them and the briars come up. Stand far and they reach further. I track the radius and recommend fighting from the doorway.",
|
||||
"Grappled by the room. The damage is not the problem — the problem is that you can't reposition while your front line is taking spells. I file this under 'CC tax' and ask who has Misty Step.",
|
||||
}
|
||||
|
||||
// Shapechange (1/combat): adopts a player's appearance; 50% miss vs her until DC 17 Investigation.
|
||||
var ThornmotherShapechangeLines = []string{
|
||||
"She picks one of you and becomes them. Until someone passes a DC 17 Investigation, half your hits roll a coin to see if they hit her or your friend. TwinBee says: 'The party member with the highest INT calls it. Quickly.'",
|
||||
"Shapechange. The room now has two of someone. TwinBee tracks which one has the wrong shadow and whispers the answer to the controller — but only after the Investigation roll, because TwinBee respects mechanics.",
|
||||
"She takes a face. Half your damage might land on the face's owner. TwinBee files this under 'every JRPG that ever did the doppelganger fight' and reminds you that the trick was always to look at the feet.",
|
||||
"She picks one of you and becomes them. Until someone passes a DC 17 Investigation, half your hits roll a coin to see if they hit her or your friend. I say: 'The party member with the highest INT calls it. Quickly.'",
|
||||
"Shapechange. The room now has two of someone. I track which one has the wrong shadow and whisper the answer to the controller — but only after the Investigation roll, because I respect mechanics.",
|
||||
"She takes a face. Half your damage might land on the face's owner. I file this under 'every JRPG that ever did the doppelganger fight' and remind you that the trick was always to look at the feet.",
|
||||
}
|
||||
|
||||
// Phase 2 (<30% HP): True Form revealed — illusions drop; +4d6 psychic on attacks; coven summons 2 Night Hags.
|
||||
var ThornmotherPhaseTwoLines = []string{
|
||||
"Below thirty percent the masks come off. True form. All her attacks add four-d-six psychic. The coven sends two Night Hags. TwinBee says: 'Phase shift. The fight just turned into three priests and a sense-of-self problem.'",
|
||||
"Phase two: the illusions drop, the Hags arrive, the psychic damage starts stacking on every hit. TwinBee tracks the new threat list and recommends focusing the original Thornmother — the Hags are reinforcements, not the win condition.",
|
||||
"True Form. The flowers stop being flowers. Two Night Hags step out of the throne. The Thornmother's hits start writing themselves directly into your mind. TwinBee files this under 'cinematic phase shift' and asks who still has spell slots.",
|
||||
"Below thirty percent the masks come off. True form. All her attacks add four-d-six psychic. The coven sends two Night Hags. I say: 'Phase shift. The fight just turned into three priests and a sense-of-self problem.'",
|
||||
"Phase two: the illusions drop, the Hags arrive, the psychic damage starts stacking on every hit. I track the new threat list and recommend focusing the original Thornmother — the Hags are reinforcements, not the win condition.",
|
||||
"True Form. The flowers stop being flowers. Two Night Hags step out of the throne. The Thornmother's hits start writing themselves directly into your mind. I file this under 'cinematic phase shift' and ask who still has spell slots.",
|
||||
}
|
||||
|
||||
// ThornmotherSignatureCallouts — combined pool for boss-entry suffix.
|
||||
@@ -116,12 +116,12 @@ var ThornmotherSignatureCallouts = func() []string {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesFeywildCrossing = []string{
|
||||
"The Crossing is not a place. The Crossing is a thinness — the spot where the veil between worlds wears down enough to step through. The wear is not random. Someone has been wearing it down on purpose, slowly, for a long time. TwinBee files this under 'someone' and declines to be more specific.",
|
||||
"The Thornmother has three names. Each name belongs to a separate covenant — a separate pact with a separate piece of her. Speaking any of the names is a partial agreement to the pact attached. TwinBee does not name her. TwinBee does not let anyone in earshot name her.",
|
||||
"Time runs differently here. A long rest in the Feywild is — sometimes — a year on the surface. Sometimes it is a minute. The dice for this are rolled by something that is not at the table. TwinBee tracks the discrepancy and warns the party not to commit to anything urgent before the run ends.",
|
||||
"The Fomorians were Fey royalty. They were exiled, deformed, and given the underground. The deformity was the punishment, not the cause. TwinBee respects the precision of the curse and files it under 'the kind of magic that takes a committee.'",
|
||||
"Redcaps are not killed by violence. Redcaps are powered by violence. The cap is the storage medium. Soaking it in fresh blood resets the meter. TwinBee says: 'Don't bleed near them. They're patient.' (Bleeding near them is the thing they're patient for.)",
|
||||
"The Will-o-Wisps are not lost spirits. They are unfulfilled bargains. Each one was a Feywild deal that the surface party broke. TwinBee notes that the wisps still want the deal honored and files this under 'long memory.'",
|
||||
"The mushroom circles in the Crossing are receivers. They listen for words spoken inside them and route the words to a coven that has been waiting for those words for several centuries. TwinBee suggests the party say nothing inside any circle, including 'this is a circle.'",
|
||||
"The Thornmother's flowers are not separate from her. They are her. The throne, the dais, the petals, the perfume — the whole arrangement is one organism, and dropping HP on the boss is one way of dropping HP on the room. TwinBee files this under 'environmental targeting' and recommends burning the throne when she's mid-cast.",
|
||||
"The Crossing is not a place. The Crossing is a thinness — the spot where the veil between worlds wears down enough to step through. The wear is not random. Someone has been wearing it down on purpose, slowly, for a long time. I file this under 'someone' and decline to be more specific.",
|
||||
"The Thornmother has three names. Each name belongs to a separate covenant — a separate pact with a separate piece of her. Speaking any of the names is a partial agreement to the pact attached. I do not name her. I do not let anyone in earshot name her.",
|
||||
"Time runs differently here. A long rest in the Feywild is — sometimes — a year on the surface. Sometimes it is a minute. The dice for this are rolled by something that is not at the table. I track the discrepancy and warn the party not to commit to anything urgent before the run ends.",
|
||||
"The Fomorians were Fey royalty. They were exiled, deformed, and given the underground. The deformity was the punishment, not the cause. I respect the precision of the curse and file it under 'the kind of magic that takes a committee.'",
|
||||
"Redcaps are not killed by violence. Redcaps are powered by violence. The cap is the storage medium. Soaking it in fresh blood resets the meter. I say: 'Don't bleed near them. They're patient.' (Bleeding near them is the thing they're patient for.)",
|
||||
"The Will-o-Wisps are not lost spirits. They are unfulfilled bargains. Each one was a Feywild deal that the surface party broke. I note that the wisps still want the deal honored and file this under 'long memory.'",
|
||||
"The mushroom circles in the Crossing are receivers. They listen for words spoken inside them and route the words to a coven that has been waiting for those words for several centuries. I suggest the party say nothing inside any circle, including 'this is a circle.'",
|
||||
"The Thornmother's flowers are not separate from her. They are her. The throne, the dais, the petals, the perfume — the whole arrangement is one organism, and dropping HP on the boss is one way of dropping HP on the room. I file this under 'environmental targeting' and recommend burning the throne when she's mid-cast.",
|
||||
}
|
||||
|
||||
@@ -20,12 +20,12 @@ package flavor
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryForestShadows = []string{
|
||||
"The trees thin into a ring of stones the forest grew around but never claimed. In the center: a hut on chicken-thin legs that has no business being upright. The door is open. The hag inside the door has been expecting you. TwinBee was hoping for the other kind of clearing.",
|
||||
"The path ends at a pond that shouldn't be a pond — too still, too dark, the wrong kind of reflective. A figure is wading at the far edge, washing something that probably wasn't laundry. She turns. She smiles. TwinBee says, very quietly, 'green hag,' and stops there.",
|
||||
"You smell the cooking before you see the cook. Sweet, herbal, faintly wrong. The clearing ahead has a fire, a pot, and a woman who is too tall and too thin and whose teeth are arranged with a creativity that a normal mouth could not justify. TwinBee suggests not eating anything she offers.",
|
||||
"A circle of toadstools, perfectly spaced. In the center, a stump with a teacup on it. Steam still rising. TwinBee identifies the trap structure on instinct — fey hospitality, hag rules, a rite that asks one question and punishes wrong answers permanently.",
|
||||
"The forest gets quieter the way a room gets quieter when the host walks in. She's already in the clearing when you arrive — bone necklace, briar crown, eyes the color of pond-bottom. 'Travelers,' she says, the way someone says 'lunch.' TwinBee straightens up.",
|
||||
"The trees here are leaning in, not away. That's the tell. TwinBee notes the change in posture and looks for the center the trees are listening to. The center is the hag. The hag has been listening back.",
|
||||
"The trees thin into a ring of stones the forest grew around but never claimed. In the center: a hut on chicken-thin legs that has no business being upright. The door is open. The hag inside the door has been expecting you. I was hoping for the other kind of clearing.",
|
||||
"The path ends at a pond that shouldn't be a pond — too still, too dark, the wrong kind of reflective. A figure is wading at the far edge, washing something that probably wasn't laundry. She turns. She smiles. I say, very quietly, 'green hag,' and stop there.",
|
||||
"You smell the cooking before you see the cook. Sweet, herbal, faintly wrong. The clearing ahead has a fire, a pot, and a woman who is too tall and too thin and whose teeth are arranged with a creativity that a normal mouth could not justify. I suggest not eating anything she offers.",
|
||||
"A circle of toadstools, perfectly spaced. In the center, a stump with a teacup on it. Steam still rising. I identify the trap structure on instinct — fey hospitality, hag rules, a rite that asks one question and punishes wrong answers permanently.",
|
||||
"The forest gets quieter the way a room gets quieter when the host walks in. She's already in the clearing when you arrive — bone necklace, briar crown, eyes the color of pond-bottom. 'Travelers,' she says, the way someone says 'lunch.' I straighten up.",
|
||||
"The trees here are leaning in, not away. That's the tell. I note the change in posture and look for the center the trees are listening to. The center is the hag. The hag has been listening back.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -35,30 +35,30 @@ var EliteRoomEntryForestShadows = []string{
|
||||
|
||||
// Corrupting Aura: melee-range targets WIS DC 14 each turn or lose bonus action.
|
||||
var HollowKingCorruptingAuraLines = []string{
|
||||
"Stepping into melee range puts you in his aura. The forest in your head goes quiet. TwinBee says: 'WIS save each turn or lose your bonus action. Adjust your spacing.'",
|
||||
"The air around the Hollow King isn't air. It's the absence of something — focus, intent, the part of you that decides the second small action of a turn. TwinBee notes the WIS DC 14 and recommends ranged.",
|
||||
"His aura presses on you the way a closed room presses on a held breath. Bonus actions become a gamble. TwinBee files this under 'positional' and recommends accordingly.",
|
||||
"Stepping into melee range puts you in his aura. The forest in your head goes quiet. I say: 'WIS save each turn or lose your bonus action. Adjust your spacing.'",
|
||||
"The air around the Hollow King isn't air. It's the absence of something — focus, intent, the part of you that decides the second small action of a turn. I note the WIS DC 14 and recommend ranged.",
|
||||
"His aura presses on you the way a closed room presses on a held breath. Bonus actions become a gamble. I file this under 'positional' and recommend accordingly.",
|
||||
}
|
||||
|
||||
// Root Surge: recharge 5–6; Restrain (STR DC 15) + 2d8 bludgeoning.
|
||||
var HollowKingRootSurgeLines = []string{
|
||||
"Roots erupt under you like the floor of the forest decided to participate. STR DC 15 or you don't move next turn. Either way, 2d8 bludgeoning. TwinBee says: 'Recharge 5–6. He'll do this again.'",
|
||||
"The ground splits. The roots come up coordinated, like fingers — TwinBee uses the word 'fingers' deliberately and dislikes itself for the accuracy. Restrained on a fail. TwinBee suggests breaking free as a priority.",
|
||||
"Like the vine traps in Castlevania III's stage 5 — except the vine is also a damage source and also a lock on your action economy. TwinBee respects the multitasking and resents the design.",
|
||||
"Roots erupt under you like the floor of the forest decided to participate. STR DC 15 or you don't move next turn. Either way, 2d8 bludgeoning. I say: 'Recharge 5–6. He'll do this again.'",
|
||||
"The ground splits. The roots come up coordinated, like fingers — I use the word 'fingers' deliberately and dislike itself for the accuracy. Restrained on a fail. I suggest breaking free as a priority.",
|
||||
"Like the vine traps in Castlevania III's stage 5 — except the vine is also a damage source and also a lock on your action economy. I respect the multitasking and resent the design.",
|
||||
}
|
||||
|
||||
// Devour Light: extinguishes magical light for 2 turns; player AC -2.
|
||||
var HollowKingDevourLightLines = []string{
|
||||
"The Hollow King exhales and your magical light goes out. Not flickers — out. TwinBee notes the AC -2 for two turns and reminds you that the dark is also part of the encounter now.",
|
||||
"Every magical light in the room dims to nothing. The natural torchlight remains, dimmer than it was. TwinBee says: 'AC penalty for two rounds. Fight smarter or fight harder. Both works.'",
|
||||
"The forest gets darker in a way that wasn't there a moment ago. TwinBee can still see — TwinBee always can — but the AC -2 is real and the next round of attacks will feel it.",
|
||||
"The Hollow King exhales and your magical light goes out. Not flickers — out. I note the AC -2 for two turns and remind you that the dark is also part of the encounter now.",
|
||||
"Every magical light in the room dims to nothing. The natural torchlight remains, dimmer than it was. I say: 'AC penalty for two rounds. Fight smarter or fight harder. Both works.'",
|
||||
"The forest gets darker in a way that wasn't there a moment ago. I can still see — I always can — but the AC -2 is real and the next round of attacks will feel it.",
|
||||
}
|
||||
|
||||
// Phase 2 (<40% HP): summons 2 Dire Wolves; gains Reckless Attack.
|
||||
var HollowKingPhaseTwoLines = []string{
|
||||
"At 40% HP the Hollow King throws his head back and the forest answers. Two dire wolves come through the brush at full speed. TwinBee says: 'Phase shift. He's also reckless now. Use it.'",
|
||||
"The antlers bend back, the eyes lose what was left of their mercy, and somewhere in the forest something with paws starts running toward you. Two dire wolves, incoming. Reckless Attack on the boss — advantage to him, advantage to you. TwinBee picks a target and waits for you to commit.",
|
||||
"Half-health is the line for most bosses. The Hollow King's line is forty percent and he hits it dramatically. Wolves at his flanks, attacks now reckless. TwinBee respects the choreography.",
|
||||
"At 40% HP the Hollow King throws his head back and the forest answers. Two dire wolves come through the brush at full speed. I say: 'Phase shift. He's also reckless now. Use it.'",
|
||||
"The antlers bend back, the eyes lose what was left of their mercy, and somewhere in the forest something with paws starts running toward you. Two dire wolves, incoming. Reckless Attack on the boss — advantage to him, advantage to you. I pick a target and wait for you to commit.",
|
||||
"Half-health is the line for most bosses. The Hollow King's line is forty percent and he hits it dramatically. Wolves at his flanks, attacks now reckless. I respect the choreography.",
|
||||
}
|
||||
|
||||
// HollowKingSignatureCallouts — combined pool for boss-entry suffix.
|
||||
@@ -80,11 +80,11 @@ var HollowKingSignatureCallouts = func() []string {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesForestShadows = []string{
|
||||
"The forest used to be a forest. That sentence is doing more work than it looks like it's doing. Something was let in — not summoned, not invaded, let in — and the forest has been a different kind of forest ever since. TwinBee has theories about who did the letting in. None of the theories are reassuring.",
|
||||
"The Hollow King wasn't always hollow. He was a guardian once, the kind of guardian a forest gets when the forest is doing well. Then the forest started to do less well, and the guardian held on past the point where holding on was the kind thing to do. TwinBee finds the arc tragic and is not interested in absolving the outcome.",
|
||||
"The bandits in the woods aren't local. They came from somewhere else, found the forest in its current state, and decided the cover was worth the risk. TwinBee notes their cookfires are recent and their numbers are growing. Someone is recruiting.",
|
||||
"Owlbears shouldn't pack-hunt. These do. TwinBee has logged three separate engagements where two owlbears coordinated in a way that felt taught. Something in the corruption is rewriting their behavior. TwinBee doesn't like the implication.",
|
||||
"The bioluminescent fungi are not native. They arrived with the corruption and they are part of the corruption — they fluoresce on a frequency that does something to the mood-banding of anyone who sleeps near them too long. TwinBee suggests resting somewhere darker if a long rest is on the schedule.",
|
||||
"The Hollow Crown — the one the Hollow King wears, the one that drops if you beat him — is not the source of the corruption. It is a symptom. The source is older and is not in this zone. TwinBee won't say more, partly because TwinBee doesn't fully know.",
|
||||
"There used to be a road through this forest. The road is still here. Nobody uses the road. TwinBee finds the road instructive — it goes exactly where it always went, but the going-where part stopped working when the forest stopped behaving like a forest.",
|
||||
"The forest used to be a forest. That sentence is doing more work than it looks like it's doing. Something was let in — not summoned, not invaded, let in — and the forest has been a different kind of forest ever since. I have theories about who did the letting in. None of the theories are reassuring.",
|
||||
"The Hollow King wasn't always hollow. He was a guardian once, the kind of guardian a forest gets when the forest is doing well. Then the forest started to do less well, and the guardian held on past the point where holding on was the kind thing to do. I find the arc tragic and am not interested in absolving the outcome.",
|
||||
"The bandits in the woods aren't local. They came from somewhere else, found the forest in its current state, and decided the cover was worth the risk. I note their cookfires are recent and their numbers are growing. Someone is recruiting.",
|
||||
"Owlbears shouldn't pack-hunt. These do. I have logged three separate engagements where two owlbears coordinated in a way that felt taught. Something in the corruption is rewriting their behavior. I doesn't like the implication.",
|
||||
"The bioluminescent fungi are not native. They arrived with the corruption and they are part of the corruption — they fluoresce on a frequency that does something to the mood-banding of anyone who sleeps near them too long. I suggest resting somewhere darker if a long rest is on the schedule.",
|
||||
"The Hollow Crown — the one the Hollow King wears, the one that drops if you beat him — is not the source of the corruption. It is a symptom. The source is older and is not in this zone. I won't say more, partly because I doesn't fully know.",
|
||||
"There used to be a road through this forest. The road is still here. Nobody uses the road. I find the road instructive — it goes exactly where it always went, but the going-where part stopped working when the forest stopped behaving like a forest.",
|
||||
}
|
||||
|
||||
@@ -15,12 +15,12 @@ package flavor
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryWarrens = []string{
|
||||
"The chamber widens. A hobgoblin in lacquered scale stands at the center, arms folded, watching the door. The lesser goblins go quiet. TwinBee notes the silence is the part you should be paying attention to.",
|
||||
"A war-banner hangs from the ceiling — three clans stitched together with rough thread. Beneath it, a Warchief turns to face you with the slow patience of someone who has done this part a hundred times. TwinBee straightens up out of habit.",
|
||||
"The corridor opens into a drilling ground. Goblins in formation. A Hobgoblin barking orders that stop the moment your boot hits the threshold. TwinBee has seen this exact composition in Shining Force and finds the parallel unhelpful.",
|
||||
"You smell the polish before you see the armor. The Warchief's blade is oiled, his straps are tight, his stance is correct. TwinBee acknowledges, with reluctance, that this one was trained.",
|
||||
"A circle of torches. A throne of stacked shields. The hobgoblin seated on it does not stand. He gestures, single-finger, in a way TwinBee universally translates as 'come here.' You go there.",
|
||||
"The room has been cleared for combat. Rugs rolled, braziers spaced evenly, sand on the floor for grip. TwinBee respects the preparation and is professionally annoyed by it.",
|
||||
"The chamber widens. A hobgoblin in lacquered scale stands at the center, arms folded, watching the door. The lesser goblins go quiet. I note the silence is the part you should be paying attention to.",
|
||||
"A war-banner hangs from the ceiling — three clans stitched together with rough thread. Beneath it, a Warchief turns to face you with the slow patience of someone who has done this part a hundred times. I straighten up out of habit.",
|
||||
"The corridor opens into a drilling ground. Goblins in formation. A Hobgoblin barking orders that stop the moment your boot hits the threshold. I have seen this exact composition in Shining Force and find the parallel unhelpful.",
|
||||
"You smell the polish before you see the armor. The Warchief's blade is oiled, his straps are tight, his stance is correct. I acknowledge, with reluctance, that this one was trained.",
|
||||
"A circle of torches. A throne of stacked shields. The hobgoblin seated on it does not stand. He gestures, single-finger, in a way I universally translates as 'come here.' You go there.",
|
||||
"The room has been cleared for combat. Rugs rolled, braziers spaced evenly, sand on the floor for grip. I respect the preparation and am professionally annoyed by it.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -30,23 +30,23 @@ var EliteRoomEntryWarrens = []string{
|
||||
|
||||
// Surprise Attack: +2d6 if player has not acted this combat.
|
||||
var GrolSurpriseAttackLines = []string{
|
||||
"Grol moves before you do. He always moves before you do. TwinBee notes — too late — that the first hit in this fight is his.",
|
||||
"The cleaver comes around in an arc that started before you were in the room. TwinBee winces in advance.",
|
||||
"He's not waiting for the fight to start. The fight starts when he says it does. TwinBee marks the lesson and files it under 'next time.'",
|
||||
"Grol moves before you do. He always moves before you do. I note — too late — that the first hit in this fight is his.",
|
||||
"The cleaver comes around in an arc that started before you were in the room. I wince in advance.",
|
||||
"He's not waiting for the fight to start. The fight starts when he says it does. I mark the lesson and file it under 'next time.'",
|
||||
}
|
||||
|
||||
// Heart of Hruggek: crits deal max damage (no roll).
|
||||
var GrolHeartOfHruggekLines = []string{
|
||||
"Grol crits and the dice don't roll. TwinBee has seen this Bugbear blessing once before. The number is the maximum number, every time, no negotiation.",
|
||||
"His critical lands flat-max. No spread, no luck — just Hruggek's blessing collecting on a long-overdue debt. TwinBee notes the damage with grim respect.",
|
||||
"The crit hits like a Final Fantasy 'Berserk' status — capped, deterministic, unkind. TwinBee skips the roll and writes the maximum.",
|
||||
"Grol crits and the dice don't roll. I have seen this Bugbear blessing once before. The number is the maximum number, every time, no negotiation.",
|
||||
"His critical lands flat-max. No spread, no luck — just Hruggek's blessing collecting on a long-overdue debt. I note the damage with grim respect.",
|
||||
"The crit hits like a Final Fantasy 'Berserk' status — capped, deterministic, unkind. I skip the roll and write the maximum.",
|
||||
}
|
||||
|
||||
// Terrifying Roar: 1/combat; allies +2 to hit for 2 turns; player WIS DC 13 or Frightened.
|
||||
var GrolTerrifyingRoarLines = []string{
|
||||
"Grol throws his head back and roars. The room shakes. The goblins around him stand straighter. TwinBee says: 'WIS save. Now.'",
|
||||
"The roar isn't a sound — it's a pressure change. Your knees know about it before your ears do. TwinBee respects the technique and asks you to roll Wisdom.",
|
||||
"He bellows once. The torchlight bends. The goblins answer with cheers that sound rehearsed. TwinBee notes the buff is up; you have two turns to outlast it.",
|
||||
"Grol throws his head back and roars. The room shakes. The goblins around him stand straighter. I say: 'WIS save. Now.'",
|
||||
"The roar isn't a sound — it's a pressure change. Your knees know about it before your ears do. I respect the technique and ask you to roll Wisdom.",
|
||||
"He bellows once. The torchlight bends. The goblins answer with cheers that sound rehearsed. I note the buff is up; you have two turns to outlast it.",
|
||||
}
|
||||
|
||||
// GrolSignatureCallouts — combined pool the boss-entry composer samples
|
||||
@@ -67,10 +67,10 @@ var GrolSignatureCallouts = func() []string {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesWarrens = []string{
|
||||
"The Warrens are old. Not goblin-old — older. The tunnels predate the goblins and the goblins know it. They've widened doorways meant for something taller and braced ceilings meant for something heavier. TwinBee has theories about what was here first. None of them are reassuring.",
|
||||
"Grol united the three clans by killing each of their war-chiefs in single combat, in order, in one afternoon. The fourth clan declined to send a chief and pledged loyalty by letter. TwinBee finds the letter, professionally, very funny.",
|
||||
"The graffiti on the walls dates the occupation. Goblins date by leaders and the names go back six generations. TwinBee can read goblin script when pressed and notes that the third-oldest name is one a paladin order would still recognize.",
|
||||
"The Merchant's Road above doesn't know about the Warrens. The Merchant's Road above used to know about the Warrens. TwinBee was here when it stopped knowing, and notes that the people in charge of Knowing About Things were the first to disappear.",
|
||||
"Hobgoblins hold formation; goblins hold grudges; bugbears hold pretty much anything they pick up. TwinBee offers this not as a joke but as a tactical primer that will save you a lot of HP if you remember it.",
|
||||
"The belt Grol wears is not a trophy. It is a contract. Each tooth on it is a debt some troll forgave when the wearing of the belt began, and the debts run in both directions. TwinBee strongly suggests not breaking the belt unless you mean to.",
|
||||
"The Warrens are old. Not goblin-old — older. The tunnels predate the goblins and the goblins know it. They've widened doorways meant for something taller and braced ceilings meant for something heavier. I have theories about what was here first. None of them are reassuring.",
|
||||
"Grol united the three clans by killing each of their war-chiefs in single combat, in order, in one afternoon. The fourth clan declined to send a chief and pledged loyalty by letter. I find the letter, professionally, very funny.",
|
||||
"The graffiti on the walls dates the occupation. Goblins date by leaders and the names go back six generations. I can read goblin script when pressed and note that the third-oldest name is one a paladin order would still recognize.",
|
||||
"The Merchant's Road above doesn't know about the Warrens. The Merchant's Road above used to know about the Warrens. I was here when it stopped knowing, and note that the people in charge of Knowing About Things were the first to disappear.",
|
||||
"Hobgoblins hold formation; goblins hold grudges; bugbears hold pretty much anything they pick up. I offer this not as a joke but as a tactical primer that will save you a lot of HP if you remember it.",
|
||||
"The belt Grol wears is not a trophy. It is a contract. Each tooth on it is a debt some troll forgave when the wearing of the belt began, and the debts run in both directions. I strongly suggests not breaking the belt unless you mean to.",
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ package flavor
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var BossEntryAldricBlackspire = []string{
|
||||
"The portrait above the mantel was not finished — the painter clearly stopped partway through the eyes. The eyes in the portrait are now finished. They were finished by being looked through. Lord Aldric Blackspire steps out of the frame the way a man steps out of a coat: practiced, unhurried, the gesture of someone who has done this many times. His clothes are seventy years out of fashion and immaculate. 'You're early,' he says, and the word is polite the way a knife is polite — a tool that has not yet committed to its purpose. TwinBee says, very quietly, 'That's a vampire. The signet on his hand is not jewelry. Don't take what he offers.'",
|
||||
"The portrait above the mantel was not finished — the painter clearly stopped partway through the eyes. The eyes in the portrait are now finished. They were finished by being looked through. Lord Aldric Blackspire steps out of the frame the way a man steps out of a coat: practiced, unhurried, the gesture of someone who has done this many times. His clothes are seventy years out of fashion and immaculate. 'You're early,' he says, and the word is polite the way a knife is polite — a tool that has not yet committed to its purpose. I say, very quietly, 'That's a vampire. The signet on his hand is not jewelry. Don't take what he offers.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -29,12 +29,12 @@ var BossEntryAldricBlackspire = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryManorBlackspire = []string{
|
||||
"The chamber ahead was a study, once. The desk is still set for work — inkwell uncapped, ledger open, a pen laid down mid-sentence. The man at the desk turns his head with the slow patience of someone who has been waiting at this desk for forty years for one specific person to walk in. He is not looking at you. He is looking past you, at someone who isn't there. Yet. TwinBee identifies the silhouette and says: 'Revenant. He's not here for us. He's here for whoever wronged him. Try not to look like them.'",
|
||||
"You enter what appears to be a guest bedroom. The bed is made. The candles are lit. A man sits on the edge of the bed with his back to you, perfectly still. He has been perfectly still for a long time. When he turns, only his head turns, and TwinBee notes the impossible angle and files this under 'revenant — directed undead, single purpose, finite lifespan, very patient.' The patience is the part that worries TwinBee.",
|
||||
"The hallway dead-ends in a chapel. There is a kneeling figure at the altar. The candles around him are unlit but somehow casting shadows. He has been praying since before the manor was haunted, and the prayer was not a request — it was a contract. TwinBee says: 'Revenant. The contract is the encounter. Break the contract, end the fight.'",
|
||||
"A library. The man at the table is reading a book that has no pages. He is reading it carefully. He looks up when you enter and his expression does something that suggests he was expecting a different visitor and is willing to make do. TwinBee notes the revenant's hands — the bones in the wrong places, the joints set with intent — and recommends ranged engagement until the geometry of the threat is clear.",
|
||||
"The ballroom is empty except for one figure standing in the exact center, not dancing, not waiting, simply standing. The chandeliers above him are not moving. They should be moving — they were moving in the last room — and the fact that they have stopped where this figure is standing tells TwinBee everything TwinBee needed to know. 'Revenant in the middle of a stilled room,' TwinBee says. 'He brought the silence with him.'",
|
||||
"A nursery. Untouched. The cradle still rocks. The figure beside the cradle was, in life, the sort of man people did not survive being wronged by. In death he has been more thorough. TwinBee notes the revenant's grip on the cradle's edge and suggests, quietly, that whatever happened in this room is not the encounter — the encounter is what walks out of it.",
|
||||
"The chamber ahead was a study, once. The desk is still set for work — inkwell uncapped, ledger open, a pen laid down mid-sentence. The man at the desk turns his head with the slow patience of someone who has been waiting at this desk for forty years for one specific person to walk in. He is not looking at you. He is looking past you, at someone who isn't there. Yet. I identify the silhouette and say: 'Revenant. He's not here for us. He's here for whoever wronged him. Try not to look like them.'",
|
||||
"You enter what appears to be a guest bedroom. The bed is made. The candles are lit. A man sits on the edge of the bed with his back to you, perfectly still. He has been perfectly still for a long time. When he turns, only his head turns, and I note the impossible angle and file it under 'revenant — directed undead, single purpose, finite lifespan, very patient.' The patience is the part that worries me.",
|
||||
"The hallway dead-ends in a chapel. There is a kneeling figure at the altar. The candles around him are unlit but somehow casting shadows. He has been praying since before the manor was haunted, and the prayer was not a request — it was a contract. I say: 'Revenant. The contract is the encounter. Break the contract, end the fight.'",
|
||||
"A library. The man at the table is reading a book that has no pages. He is reading it carefully. He looks up when you enter and his expression does something that suggests he was expecting a different visitor and is willing to make do. I note the revenant's hands — the bones in the wrong places, the joints set with intent — and recommend ranged engagement until the geometry of the threat is clear.",
|
||||
"The ballroom is empty except for one figure standing in the exact center, not dancing, not waiting, simply standing. The chandeliers above him are not moving. They should be moving — they were moving in the last room — and the fact that they have stopped where this figure is standing tells me everything I need to know. 'Revenant in the middle of a stilled room,' I say. 'He brought the silence with him.'",
|
||||
"A nursery. Untouched. The cradle still rocks. The figure beside the cradle was, in life, the sort of man people did not survive being wronged by. In death he has been more thorough. I note the revenant's grip on the cradle's edge and suggest, quietly, that whatever happened in this room is not the encounter — the encounter is what walks out of it.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -44,37 +44,37 @@ var EliteRoomEntryManorBlackspire = []string{
|
||||
|
||||
// Multiattack: Unarmed Strike + Bite (life drain) every turn.
|
||||
var AldricMultiattackLines = []string{
|
||||
"Aldric multiattacks every turn — Unarmed Strike, then Bite. The bite drains. Whatever HP he takes from you, he keeps. TwinBee says: 'He's a battery with manners. Don't let him charge.'",
|
||||
"Two hits a turn, and the second one is the one that funds the rest of the fight. The bite returns HP to him equal to the damage dealt. TwinBee notes that this changes the math on burst-vs-sustain considerably.",
|
||||
"Strike, then bite. The bite is the resource transfer. TwinBee files this under 'fights where defense is offense' — every point of damage you avoid is a point he doesn't get to take.",
|
||||
"Aldric multiattacks every turn — Unarmed Strike, then Bite. The bite drains. Whatever HP he takes from you, he keeps. I say: 'He's a battery with manners. Don't let him charge.'",
|
||||
"Two hits a turn, and the second one is the one that funds the rest of the fight. The bite returns HP to him equal to the damage dealt. I note that this changes the math on burst-vs-sustain considerably.",
|
||||
"Strike, then bite. The bite is the resource transfer. I file this under 'fights where defense is offense' — every point of damage you avoid is a point he doesn't get to take.",
|
||||
}
|
||||
|
||||
// Charm: WIS DC 17 or Charmed for 24h (broken by damage).
|
||||
var AldricCharmLines = []string{
|
||||
"He looks at one of you and the look is a question and the question is being answered without your permission. WIS DC 17. On a fail: charmed for twenty-four hours, broken by damage. TwinBee says: 'High DC. Have someone designated to slap the charmed party once.'",
|
||||
"Charm. WIS DC 17. The duration is twenty-four hours and the only out is taking damage. TwinBee notes the irony — the cure is in the room, applied by your own party, in the hardest possible way.",
|
||||
"Aldric's gaze settles. WIS save now. Failure is a long-term problem — a full day of charm — and the only solvent is a friendly hit. TwinBee files this under 'social-coded crowd control with a violent escape clause.'",
|
||||
"He looks at one of you and the look is a question and the question is being answered without your permission. WIS DC 17. On a fail: charmed for twenty-four hours, broken by damage. I say: 'High DC. Have someone designated to slap the charmed party once.'",
|
||||
"Charm. WIS DC 17. The duration is twenty-four hours and the only out is taking damage. I note the irony — the cure is in the room, applied by your own party, in the hardest possible way.",
|
||||
"Aldric's gaze settles. WIS save now. Failure is a long-term problem — a full day of charm — and the only solvent is a friendly hit. I file this under 'social-coded crowd control with a violent escape clause.'",
|
||||
}
|
||||
|
||||
// Children of the Night (1/combat): summons 2d6 Bat Swarms or 3d6 Rats.
|
||||
var AldricChildrenLines = []string{
|
||||
"Once per fight he calls. Bats from the chimney, rats from the walls — 2d6 swarms or 3d6 rats, GM's choice, both bad. TwinBee says: 'AoE clears the adds. Don't let them stack tokens on whoever's charmed.'",
|
||||
"Children of the Night. The walls produce. The ceiling produces. TwinBee tracks the adds and recommends an AoE before the swarms occupy enough squares to be a separate problem.",
|
||||
"He raises a hand and the manor answers. The room fills. Bats or rats or both. TwinBee notes this is a one-time effect per combat — survive the wave and the room belongs to you again.",
|
||||
"Once per fight he calls. Bats from the chimney, rats from the walls — 2d6 swarms or 3d6 rats, GM's choice, both bad. I say: 'AoE clears the adds. Don't let them stack tokens on whoever's charmed.'",
|
||||
"Children of the Night. The walls produce. The ceiling produces. I track the adds and recommend an AoE before the swarms occupy enough squares to be a separate problem.",
|
||||
"He raises a hand and the manor answers. The room fills. Bats or rats or both. I note this is a one-time effect per combat — survive the wave and the room belongs to you again.",
|
||||
}
|
||||
|
||||
// Mist Form: at 0 HP retreats to coffin; must destroy coffin in 30 turns or fully regenerates.
|
||||
var AldricMistFormLines = []string{
|
||||
"Drop him to zero and he doesn't drop — he goes to mist, retreats to a coffin somewhere in the manor. Thirty turns to find and destroy it. TwinBee says: 'Find the coffin first, fight the count second. The order matters.'",
|
||||
"Mist Form. At 0 HP he becomes mist and goes home. Home is a coffin. The coffin is somewhere in the manor and you have thirty turns to break it. TwinBee files this under 'zero is not death, zero is a timer.'",
|
||||
"Like the Castlevania bosses that didn't actually die — except those games gave you the next room. This one gives you a thirty-turn coffin hunt. TwinBee respects the design and recommends scouting on the way in.",
|
||||
"Drop him to zero and he doesn't drop — he goes to mist, retreats to a coffin somewhere in the manor. Thirty turns to find and destroy it. I say: 'Find the coffin first, fight the count second. The order matters.'",
|
||||
"Mist Form. At 0 HP he becomes mist and goes home. Home is a coffin. The coffin is somewhere in the manor and you have thirty turns to break it. I file this under 'zero is not death, zero is a timer.'",
|
||||
"Like the Castlevania bosses that didn't actually die — except those games gave you the next room. This one gives you a thirty-turn coffin hunt. I respect the design and recommend scouting on the way in.",
|
||||
}
|
||||
|
||||
// Phase 2 (Mist destroyed): all attacks have advantage; AoE Charm.
|
||||
var AldricPhaseTwoLines = []string{
|
||||
"Coffin destroyed. He returns furious. All attacks have advantage now and his Charm goes AoE — every party member, WIS DC 17 each turn. TwinBee says: 'You won the long game. The short game just got shorter.'",
|
||||
"Phase two: no more retreat, no more singles. AoE charm, advantage on every swing. TwinBee tracks the saves and reminds you that advantage on the boss means disadvantage on your continuing-to-stand.",
|
||||
"Without the coffin, Aldric's restraint is gone. The fight goes loud. Charm sweeps the room each turn. TwinBee stops narrating long enough to breathe and says: 'Burn him.'",
|
||||
"Coffin destroyed. He returns furious. All attacks have advantage now and his Charm goes AoE — every party member, WIS DC 17 each turn. I say: 'You won the long game. The short game just got shorter.'",
|
||||
"Phase two: no more retreat, no more singles. AoE charm, advantage on every swing. I track the saves and remind you that advantage on the boss means disadvantage on your continuing-to-stand.",
|
||||
"Without the coffin, Aldric's restraint is gone. The fight goes loud. Charm sweeps the room each turn. I stop narrating long enough to breathe and say: 'Burn him.'",
|
||||
}
|
||||
|
||||
// AldricSignatureCallouts — combined pool for boss-entry suffix.
|
||||
@@ -98,11 +98,11 @@ var AldricSignatureCallouts = func() []string {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesManorBlackspire = []string{
|
||||
"The Blackspire line was old before it was wealthy and wealthy before it was cursed. The portraits are in chronological order — TwinBee has counted them. The expressions get worse as the years go on. By the seventh portrait the painter has stopped pretending the family is well.",
|
||||
"Aldric was the seventh and the last. He was not the first to make the deal — the deal had been made twice already, by his grandmother and by his uncle — but he was the first to honor it past the term of his own life. TwinBee considers this the kind of stubbornness that becomes a curse on its own, before any vampires are involved.",
|
||||
"The manor wasn't always haunted. It became haunted on a specific night, in a specific hour, and the clock on the mantel still shows that hour. TwinBee notes the clock isn't broken. The clock is keeping correct time for that one night and refusing to acknowledge any other.",
|
||||
"The portraits are not paintings. The pigment is correct, the technique is correct, the canvases are correct — but the things behind the eyes are not. TwinBee has watched a portrait blink. TwinBee declines to elaborate on which one.",
|
||||
"The Blackspire signet ring — the one that drops if you beat Aldric — is not enchanted in any conventional sense. It is recognized. The undead in this region have been raised under it, contracted under it, organized under it. They will not attack the wearer unless attacked first. TwinBee finds this both useful and uncomfortable.",
|
||||
"The deed to the manor changes hands and the manor refuses each transfer. Eleven owners on paper since the curse settled. Eleven sets of estate records. TwinBee has seen the lawyers' files. The lawyers are no longer in the profession.",
|
||||
"The vampire spawn are not converts in the romantic sense — they are the previous owners. Each buyer who didn't leave became staff. TwinBee notes which uniforms match which historical fashions and stops counting at eleven, which is the correct number.",
|
||||
"The Blackspire line was old before it was wealthy and wealthy before it was cursed. The portraits are in chronological order — I have counted them. The expressions get worse as the years go on. By the seventh portrait the painter has stopped pretending the family is well.",
|
||||
"Aldric was the seventh and the last. He was not the first to make the deal — the deal had been made twice already, by his grandmother and by his uncle — but he was the first to honor it past the term of his own life. I consider this the kind of stubbornness that becomes a curse on its own, before any vampires are involved.",
|
||||
"The manor wasn't always haunted. It became haunted on a specific night, in a specific hour, and the clock on the mantel still shows that hour. I note the clock isn't broken. The clock is keeping correct time for that one night and refusing to acknowledge any other.",
|
||||
"The portraits are not paintings. The pigment is correct, the technique is correct, the canvases are correct — but the things behind the eyes are not. I have watched a portrait blink. I decline to elaborate on which one.",
|
||||
"The Blackspire signet ring — the one that drops if you beat Aldric — is not enchanted in any conventional sense. It is recognized. The undead in this region have been raised under it, contracted under it, organized under it. They will not attack the wearer unless attacked first. I find this both useful and uncomfortable.",
|
||||
"The deed to the manor changes hands and the manor refuses each transfer. Eleven owners on paper since the curse settled. Eleven sets of estate records. I have seen the lawyers' files. The lawyers are no longer in the profession.",
|
||||
"The vampire spawn are not converts in the romantic sense — they are the previous owners. Each buyer who didn't leave became staff. I note which uniforms match which historical fashions and stop counting at eleven, which is the correct number.",
|
||||
}
|
||||
|
||||
@@ -21,14 +21,14 @@ package flavor
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntrySunkenTemple = []string{
|
||||
"You step into water that's been in this room for thirty years. The floor is tiled and slick. The pillars are barnacled at chest height — that's the old waterline. The new waterline is at your ankles. TwinBee notes the temple is partway through deciding which it prefers.",
|
||||
"The chamber is half-flooded and half-lit. Salt in the air, salt on the walls, salt in places nothing oceanic should be reaching. TwinBee files this under 'unwell' and proceeds.",
|
||||
"Glyphs cover the far wall in a script that doesn't match anything TwinBee recognizes. The angles are wrong on purpose. TwinBee keeps reading anyway, because TwinBee always tries, and stops when the reading starts to feel reciprocal.",
|
||||
"The water in this room is not moving. Not the way still water doesn't move — the way a held breath doesn't move. TwinBee suggests not disturbing it more than necessary.",
|
||||
"Pillars rise from water that goes deeper than the room should allow. TwinBee tests the depth with the haft of a polearm and stops at the point where the haft stops finding bottom.",
|
||||
"A vaulted ceiling that's mostly intact. Water pools in places it shouldn't, drips from places it can't be coming from. TwinBee identifies the temple as 'wet on principle' and leaves it at that.",
|
||||
"The mosaics on the floor are still legible under the water. They show a procession toward something that is decidedly not the god the temple was built for. TwinBee notes the mosaics are facing inward — they were laid for the thing inside, not for visitors.",
|
||||
"You hear something move in the water. Not a splash — a displacement. The kind of displacement that happens when a large thing decides to be in a different part of the room than it was a moment ago. TwinBee acts unbothered and is bothered.",
|
||||
"You step into water that's been in this room for thirty years. The floor is tiled and slick. The pillars are barnacled at chest height — that's the old waterline. The new waterline is at your ankles. I note the temple is partway through deciding which it prefers.",
|
||||
"The chamber is half-flooded and half-lit. Salt in the air, salt on the walls, salt in places nothing oceanic should be reaching. I file this under 'unwell' and proceed.",
|
||||
"Glyphs cover the far wall in a script that doesn't match anything I recognize. The angles are wrong on purpose. It keeps reading anyway, because it always tries, and stops when the reading starts to feel reciprocal.",
|
||||
"The water in this room is not moving. Not the way still water doesn't move — the way a held breath doesn't move. I suggest not disturbing it more than necessary.",
|
||||
"Pillars rise from water that goes deeper than the room should allow. I test the depth with the haft of a polearm and stop at the point where the haft stops finding bottom.",
|
||||
"A vaulted ceiling that's mostly intact. Water pools in places it shouldn't, drips from places it can't be coming from. I identify the temple as 'wet on principle' and leave it at that.",
|
||||
"The mosaics on the floor are still legible under the water. They show a procession toward something that is decidedly not the god the temple was built for. I note the mosaics are facing inward — they were laid for the thing inside, not for visitors.",
|
||||
"You hear something move in the water. Not a splash — a displacement. The kind of displacement that happens when a large thing decides to be in a different part of the room than it was a moment ago. I act unbothered and am bothered.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -37,7 +37,7 @@ var RoomEntrySunkenTemple = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var BossEntryDreamingAboleth = []string{
|
||||
"The chamber opens onto a pool that has no business being this large in a building this size. Something underneath the surface shifts, and the surface is suddenly the smallest part of what you're looking at. The Aboleth does not surface. It does not need to. Its mind arrives in your mind first, polite, ancient, deeply uninterested in your comfort. 'Welcome,' it says, and the word is in your voice. TwinBee says, very evenly, 'Don't agree to anything. Don't answer questions. Roll initiative.'",
|
||||
"The chamber opens onto a pool that has no business being this large in a building this size. Something underneath the surface shifts, and the surface is suddenly the smallest part of what you're looking at. The Aboleth does not surface. It does not need to. Its mind arrives in your mind first, polite, ancient, deeply uninterested in your comfort. 'Welcome,' it says, and the word is in your voice. I say, very evenly, 'Don't agree to anything. Don't answer questions. Roll initiative.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -45,12 +45,12 @@ var BossEntryDreamingAboleth = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntrySunkenTemple = []string{
|
||||
"The water in the chamber ahead is moving. Not currents — purpose. It's gathering toward a center, taking on a shape, deciding what it wants its arms to look like this time. TwinBee says: 'Water elemental. The room is the enemy.'",
|
||||
"You enter a hall where the floor is six inches of standing water and the standing water is, on closer inspection, watching you. A column of it rises and walks toward you with the deliberate gait of something that did not learn to walk from a creature with legs. TwinBee respects the originality.",
|
||||
"The room hums at a frequency you feel in your teeth. The water rises into a vaguely humanoid pillar and the pillar steps forward. TwinBee notes this is the kind of fight where 'fall back to higher ground' is not a useful idea — there is no higher ground. The room is the ground.",
|
||||
"A column of water becomes a hand becomes a torso becomes a thing intent on contact. TwinBee files water elementals under 'fights where lightning damage is fun' and asks who has lightning damage today.",
|
||||
"The temple's oldest mechanism activates as you enter — a font in the ceiling drains, the floor floods another inch, and the new water gathers itself into a sentry. TwinBee says: 'It's been waiting since the day they built this. Be brief.'",
|
||||
"Water rises in a vortex in the chamber's center. The vortex slows. The vortex opens what passes for eyes. TwinBee respects the staging and is not entertained by it.",
|
||||
"The water in the chamber ahead is moving. Not currents — purpose. It's gathering toward a center, taking on a shape, deciding what it wants its arms to look like this time. I say: 'Water elemental. The room is the enemy.'",
|
||||
"You enter a hall where the floor is six inches of standing water and the standing water is, on closer inspection, watching you. A column of it rises and walks toward you with the deliberate gait of something that did not learn to walk from a creature with legs. I respect the originality.",
|
||||
"The room hums at a frequency you feel in your teeth. The water rises into a vaguely humanoid pillar and the pillar steps forward. I note this is the kind of fight where 'fall back to higher ground' is not a useful idea — there is no higher ground. The room is the ground.",
|
||||
"A column of water becomes a hand becomes a torso becomes a thing intent on contact. I file water elementals under 'fights where lightning damage is fun' and ask who has lightning damage today.",
|
||||
"The temple's oldest mechanism activates as you enter — a font in the ceiling drains, the floor floods another inch, and the new water gathers itself into a sentry. I say: 'It's been waiting since the day they built this. Be brief.'",
|
||||
"Water rises in a vortex in the chamber's center. The vortex slows. The vortex opens what passes for eyes. I respect the staging and am not entertained by it.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -60,30 +60,30 @@ var EliteRoomEntrySunkenTemple = []string{
|
||||
|
||||
// Tentacle Multiattack: 3 hits; on-hit Diseased (no magical healing 24h until cured).
|
||||
var AbolethTentacleMultiattackLines = []string{
|
||||
"Three tentacles, three rolls. Each on-hit risks Diseased — no magical healing for 24 hours until cured. TwinBee says: 'Cleric's tools come back online tomorrow. Survive today.'",
|
||||
"The Aboleth's tentacles arrive in sequence, three of them, the hits compounding. The disease isn't the damage — the disease is the design. Magical healing fails until you cleanse. TwinBee files this under 'durability problem.'",
|
||||
"Three attacks, one turn. Any landing tentacle leaves a mark that locks out magical healing. TwinBee suggests potions, rest, and the kind of patience that pretends to be patience but is mostly grim arithmetic.",
|
||||
"Three tentacles, three rolls. Each on-hit risks Diseased — no magical healing for 24 hours until cured. I say: 'Cleric's tools come back online tomorrow. Survive today.'",
|
||||
"The Aboleth's tentacles arrive in sequence, three of them, the hits compounding. The disease isn't the damage — the disease is the design. Magical healing fails until you cleanse. I file this under 'durability problem.'",
|
||||
"Three attacks, one turn. Any landing tentacle leaves a mark that locks out magical healing. I suggest potions, rest, and the kind of patience that pretends to be patience but am mostly grim arithmetic.",
|
||||
}
|
||||
|
||||
// Enslave: recharge 6; WIS DC 14 or Charmed; player skips turn, drifts toward Aboleth.
|
||||
var AbolethEnslaveLines = []string{
|
||||
"The Aboleth speaks in your head and the speech is a question. WIS DC 14 or you spend your turn walking, peacefully, toward the water. TwinBee says: 'Recharge 6. It will try this again. Make the save.'",
|
||||
"The Aboleth speaks in your head and the speech is a question. WIS DC 14 or you spend your turn walking, peacefully, toward the water. I say: 'Recharge 6. It will try this again. Make the save.'",
|
||||
"Enslave. The word in your head sounds like your own thought. It isn't. WIS save now. On a fail, your turn becomes the Aboleth's turn and you become the wrong piece on the wrong side of the board.",
|
||||
"Charm-class effect, but worse than charm — it's a directional pull. You drift toward the pool whether you want to or not. TwinBee shouts your save aloud so you don't mistake it for an idea you had.",
|
||||
"Charm-class effect, but worse than charm — it's a directional pull. You drift toward the pool whether you want to or not. I shout your save aloud so you don't mistake it for an idea you had.",
|
||||
}
|
||||
|
||||
// Mucus Cloud: melee attackers CON DC 14 or skin→membrane (6d6 acid if not submerged).
|
||||
var AbolethMucusCloudLines = []string{
|
||||
"Anyone in melee range gets a CON DC 14. Fail and your skin starts converting to a membrane that needs water to stay viable. Out of water it's 6d6 acid per turn. TwinBee says: 'Don't punch the fish.'",
|
||||
"The Aboleth's mucus does something to skin that skin should not do. Membrane formation. Submerged is fine. Dry is the problem — 6d6 acid until it ends. TwinBee revises the engagement plan: ranged only.",
|
||||
"Like the slime debuff in old console RPGs — except this one wants you to stay in the water and dissolves you if you don't. TwinBee respects the elegance of the threat and dislikes everything else about it.",
|
||||
"Anyone in melee range gets a CON DC 14. Fail and your skin starts converting to a membrane that needs water to stay viable. Out of water it's 6d6 acid per turn. I say: 'Don't punch the fish.'",
|
||||
"The Aboleth's mucus does something to skin that skin should not do. Membrane formation. Submerged is fine. Dry is the problem — 6d6 acid until it ends. I revise the engagement plan: ranged only.",
|
||||
"Like the slime debuff in old console RPGs — except this one wants you to stay in the water and dissolves you if you don't. I respect the elegance of the threat and dislike everything else about it.",
|
||||
}
|
||||
|
||||
// Legendary Actions (3/round): Detect / Tail Swipe (2 LA) / Psychic Drain (3 LA, max-HP cut).
|
||||
var AbolethLegendaryActionLines = []string{
|
||||
"The Aboleth gets three legendary actions per round. Detect (1), Tail Swipe (2), Psychic Drain (3, cuts max HP). TwinBee tracks the budget and reminds you when it spends.",
|
||||
"Legendary Actions: 3 budget. Detect is cheap and reads your hand. Tail Swipe is medium and hurts. Psychic Drain is expensive and lasting — your max HP is the price. TwinBee suggests bursting it before it banks.",
|
||||
"Three LA per round. The Aboleth will spend them on you and TwinBee will narrate each one with the specific resigned cadence of a GM who would prefer the boss had two LA.",
|
||||
"The Aboleth gets three legendary actions per round. Detect (1), Tail Swipe (2), Psychic Drain (3, cuts max HP). I track the budget and remind you when it spends.",
|
||||
"Legendary Actions: 3 budget. Detect is cheap and reads your hand. Tail Swipe is medium and hurts. Psychic Drain is expensive and lasting — your max HP is the price. I suggest bursting it before it banks.",
|
||||
"Three LA per round. The Aboleth will spend them on you and I will narrate each one with the specific resigned cadence of a GM who would prefer the boss had two LA.",
|
||||
}
|
||||
|
||||
// AbolethSignatureCallouts — combined pool for boss-entry suffix.
|
||||
@@ -107,11 +107,11 @@ var AbolethSignatureCallouts = func() []string {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesSunkenTemple = []string{
|
||||
"Dar'eth was a sea god before the cult that built the temple decided he wasn't sea god enough and started worshipping something else under his roof. The temple kept the name. The thing being worshipped did not. TwinBee notes the bait-and-switch with professional appreciation.",
|
||||
"The tide that withdrew thirty years ago didn't withdraw on its own. It was held back. By what — by whom — is not advertised, but TwinBee has seen the kind of pressure-line on the outer walls that suggests a decision was made and something is still making it.",
|
||||
"Dar'eth was a sea god before the cult that built the temple decided he wasn't sea god enough and started worshipping something else under his roof. The temple kept the name. The thing being worshipped did not. I note the bait-and-switch with professional appreciation.",
|
||||
"The tide that withdrew thirty years ago didn't withdraw on its own. It was held back. By what — by whom — is not advertised, but I have seen the kind of pressure-line on the outer walls that suggests a decision was made and something is still making it.",
|
||||
"The Aboleth has been here longer than the temple has been a temple. The temple was built around the Aboleth. The cult thought they were containing it. The Aboleth thought they were furnishing the room. Both parties remained satisfied with the arrangement until the cult ran out of cultists.",
|
||||
"The Kuo-toa here are not the local stock. They were brought. They are loyal in the way that a mind not entirely their own can be loyal. TwinBee considers their loyalty a symptom and the Aboleth the cause.",
|
||||
"Aboleth memory is collective and ancient — it remembers things from before there were people to remember things. TwinBee finds this genuinely unsettling because the Aboleth's memory of you is identical to its memory of the cult and the temple's first architect: 'a thing that was here.' Past tense, even when you're standing right there.",
|
||||
"The mucus residue in the upper chambers is recent. The Aboleth has been moving through the temple in the last few weeks — not just sitting in the central pool. Something has been making it restless. TwinBee suggests not being the thing that has been making it restless.",
|
||||
"The phylactery shard you'll find here is not Valdris's. It belongs to a different lich entirely. TwinBee notes there are now two phylacteries in your itinerary and refuses to draw connecting lines on the map yet.",
|
||||
"The Kuo-toa here are not the local stock. They were brought. They are loyal in the way that a mind not entirely their own can be loyal. I consider their loyalty a symptom and the Aboleth the cause.",
|
||||
"Aboleth memory is collective and ancient — it remembers things from before there were people to remember things. I find this genuinely unsettling because the Aboleth's memory of you is identical to its memory of the cult and the temple's first architect: 'a thing that was here.' Past tense, even when you're standing right there.",
|
||||
"The mucus residue in the upper chambers is recent. The Aboleth has been moving through the temple in the last few weeks — not just sitting in the central pool. Something has been making it restless. I suggest not being the thing that has been making it restless.",
|
||||
"The phylactery shard you'll find here is not Valdris's. It belongs to a different lich entirely. I note there are now two phylacteries in your itinerary and refuse to draw connecting lines on the map yet.",
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ package flavor
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var BossEntryIlvaras = []string{
|
||||
"The cathedral is round and the round is wrong — drow architecture leans, drow architecture rises, drow architecture does not gather like this. The room was built for one purpose and the purpose is standing at the altar with her back to you. She does not turn when you enter. The trophies on the wall turn first — four sets of weapons, hung at four heights, each set arranged with the care of someone who learned the names of their previous owners and kept the names. 'Five,' Ilvaras Xunyl says, without turning, in a voice that is making itself heard in your head and not in the air. 'I had run out of wall.' She turns. The smile is the smile of someone who has been waiting for the inconvenience of a fifth wall. TwinBee says, very low: 'Drow High Priestess. Lolth's favour. Don't accept the offer. There will be an offer.'",
|
||||
"The cathedral is round and the round is wrong — drow architecture leans, drow architecture rises, drow architecture does not gather like this. The room was built for one purpose and the purpose is standing at the altar with her back to you. She does not turn when you enter. The trophies on the wall turn first — four sets of weapons, hung at four heights, each set arranged with the care of someone who learned the names of their previous owners and kept the names. 'Five,' Ilvaras Xunyl says, without turning, in a voice that is making itself heard in your head and not in the air. 'I had run out of wall.' She turns. The smile is the smile of someone who has been waiting for the inconvenience of a fifth wall. I say, very low: 'Drow High Priestess. Lolth's favour. Don't accept the offer. There will be an offer.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -29,12 +29,12 @@ var BossEntryIlvaras = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryUnderdark = []string{
|
||||
"The cavern ahead is a forest of stalagmites — dozens of them, calf-thick to wagon-thick, irregular spacing, the kind of natural architecture that gives an ambush every advantage. TwinBee counts them, then stops counting, then notes that one of them has been counted twice from different angles and that one is not a stalagmite. 'Roper,' TwinBee says. 'Six tendrils. Reels you in. Pick the wrong stalagmite and the room becomes one fight at five different ranges.'",
|
||||
"You enter a chamber where the ceiling drips minerals that have built columns down to the floor — pillars of pale stone, formed over thousands of years, perfectly natural, all of them. Almost all of them. TwinBee identifies the off-color column near the back of the room and says: 'False appearance until it isn't. Stay out of grapple range until the tendrils show.'",
|
||||
"A grotto. Calm water. A field of stalagmites breaking the surface. Beautiful in the way the deep places are beautiful, which is to say it photographs well and houses something terrible. The stalagmite nearest the path has not been there in any other map TwinBee has consulted. TwinBee notes the inconsistency and recommends ranged options.",
|
||||
"The corridor widens into a cavern with twelve pillars holding the ceiling — six of them are dwarven masonry, six of them are natural stone, and one of the natural ones is breathing. The breath is shallow. The patience is the giveaway. TwinBee says: 'Roper. Engage the column you can see, not the columns you assume.'",
|
||||
"You step into a room where the stalagmites have been arranged — almost arranged — into something like a dining hall. The arrangement is not deliberate. The arrangement is the result of one of the stalagmites occasionally moving in its sleep. TwinBee files this under 'apex ambush predator with tenure' and recommends not waking it gently.",
|
||||
"A toll-bridge cavern: a narrow path through a field of standing stones, each one twice your height, none of them moving. The toll is paid by whichever party member walks last. TwinBee tracks the sightlines, names the column with the wrong shadow, and says: 'Don't be the last one.'",
|
||||
"The cavern ahead is a forest of stalagmites — dozens of them, calf-thick to wagon-thick, irregular spacing, the kind of natural architecture that gives an ambush every advantage. I count them, then stop counting, then note that one of them has been counted twice from different angles and that one is not a stalagmite. 'Roper,' I say. 'Six tendrils. Reels you in. Pick the wrong stalagmite and the room becomes one fight at five different ranges.'",
|
||||
"You enter a chamber where the ceiling drips minerals that have built columns down to the floor — pillars of pale stone, formed over thousands of years, perfectly natural, all of them. Almost all of them. I identify the off-color column near the back of the room and say: 'False appearance until it isn't. Stay out of grapple range until the tendrils show.'",
|
||||
"A grotto. Calm water. A field of stalagmites breaking the surface. Beautiful in the way the deep places are beautiful, which is to say it photographs well and houses something terrible. The stalagmite nearest the path has not been there in any other map I have consulted. I note the inconsistency and recommend ranged options.",
|
||||
"The corridor widens into a cavern with twelve pillars holding the ceiling — six of them are dwarven masonry, six of them are natural stone, and one of the natural ones is breathing. The breath is shallow. The patience is the giveaway. I say: 'Roper. Engage the column you can see, not the columns you assume.'",
|
||||
"You step into a room where the stalagmites have been arranged — almost arranged — into something like a dining hall. The arrangement is not deliberate. The arrangement is the result of one of the stalagmites occasionally moving in its sleep. I file this under 'apex ambush predator with tenure' and recommend not waking it gently.",
|
||||
"A toll-bridge cavern: a narrow path through a field of standing stones, each one twice your height, none of them moving. The toll is paid by whichever party member walks last. I track the sightlines, names the column with the wrong shadow, and say: 'Don't be the last one.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -44,37 +44,37 @@ var EliteRoomEntryUnderdark = []string{
|
||||
|
||||
// Spells: Flame Strike / Dispel Magic / Divine Word (CHA DC 18) / Insect Plague.
|
||||
var IlvarasSpellLines = []string{
|
||||
"She has the priest list and she leads with Flame Strike and Insect Plague — both AoE, both back-to-back if the initiative is unkind. TwinBee says: 'Spread out. Don't bunch under a column. Insect Plague is a movement tax that compounds.'",
|
||||
"Divine Word, CHA DC 18, and the failure tree is brutal — Deafened, Blinded, Stunned, or killed depending on remaining HP. TwinBee files this under 'never fight a priest below half HP' and asks who has the best CHA save.",
|
||||
"Dispel Magic on your buffs as soon as she rolls initiative. TwinBee tracks which concentration spells went up and recommends shielding the controller until the dispel is spent.",
|
||||
"She has the priest list and she leads with Flame Strike and Insect Plague — both AoE, both back-to-back if the initiative is unkind. I say: 'Spread out. Don't bunch under a column. Insect Plague is a movement tax that compounds.'",
|
||||
"Divine Word, CHA DC 18, and the failure tree is brutal — Deafened, Blinded, Stunned, or killed depending on remaining HP. I file this under 'never fight a priest below half HP' and ask who has the best CHA save.",
|
||||
"Dispel Magic on your buffs as soon as she rolls initiative. I track which concentration spells went up and recommend shielding the controller until the dispel is spent.",
|
||||
}
|
||||
|
||||
// Lolth's Favour: 1/combat auto-succeed a failed save.
|
||||
var IlvarasFavourLines = []string{
|
||||
"Once per fight she takes a save you thought you'd won and converts the failure into a success. TwinBee says: 'Time your hard CCs after the favour is spent — don't waste a Hold Person on the round she's still got it.'",
|
||||
"Lolth's Favour. One free re-pass on a save. TwinBee notes the timing matters more than the damage — the spell you wanted to land is the spell she wanted to skip.",
|
||||
"She converts a failed save into a success without rolling. The favour is single-use. TwinBee files this under 'plan two openings' and reminds the party that the second one is the real one.",
|
||||
"Once per fight she takes a save you thought you'd won and converts the failure into a success. I say: 'Time your hard CCs after the favour is spent — don't waste a Hold Person on the round she's still got it.'",
|
||||
"Lolth's Favour. One free re-pass on a save. I note the timing matters more than the damage — the spell you wanted to land is the spell she wanted to skip.",
|
||||
"She converts a failed save into a success without rolling. The favour is single-use. I file this under 'plan two openings' and remind the party that the second one is the real one.",
|
||||
}
|
||||
|
||||
// Summon Spiders (1/combat): 2d6 Giant Spiders fill the room.
|
||||
var IlvarasSpidersLines = []string{
|
||||
"She raises both hands and the room fills. Two-d-six Giant Spiders, all at once, all over the floor. TwinBee says: 'AoE clears the carpet. Don't get webbed before you cast it.'",
|
||||
"Summon Spiders. The carpet is no longer the carpet. TwinBee tracks the count and notes that 2d6 in a confined cathedral is a different math than 2d6 in an open hallway.",
|
||||
"The spiders come from the cracks the way the rats came in Castlevania, except these are big enough to count individually and small enough to overwhelm. TwinBee recommends an early sweep and a clear line of retreat.",
|
||||
"She raises both hands and the room fills. Two-d-six Giant Spiders, all at once, all over the floor. I say: 'AoE clears the carpet. Don't get webbed before you cast it.'",
|
||||
"Summon Spiders. The carpet is no longer the carpet. I track the count and note that 2d6 in a confined cathedral is a different math than 2d6 in an open hallway.",
|
||||
"The spiders come from the cracks the way the rats came in Castlevania, except these are big enough to count individually and small enough to overwhelm. I recommend an early sweep and a clear line of retreat.",
|
||||
}
|
||||
|
||||
// Legendary Actions: Melee (1), Cast Cantrip (1), Drain Life (3 LA, 4d10 necrotic).
|
||||
var IlvarasLegendaryLines = []string{
|
||||
"She takes legendary actions at the end of every other turn. Melee for one, cantrip for one, Drain Life for three — and Drain Life is 4d10 necrotic that heals her. TwinBee says: 'Let her spend on the cheap ones. Bait the three-cost.'",
|
||||
"Three legendary points per round. The Drain Life is the one that makes the fight last longer than it should. TwinBee tracks her actions and reminds the party that her healing comes out of yours.",
|
||||
"Legendary Actions: the always-on tax that punishes your turn order. TwinBee files this under 'priest economy' and notes the cantrip option is what kills your back line if you let her keep three points unspent.",
|
||||
"She takes legendary actions at the end of every other turn. Melee for one, cantrip for one, Drain Life for three — and Drain Life is 4d10 necrotic that heals her. I say: 'Let her spend on the cheap ones. Bait the three-cost.'",
|
||||
"Three legendary points per round. The Drain Life is the one that makes the fight last longer than it should. I track her actions and remind the party that her healing comes out of yours.",
|
||||
"Legendary Actions: the always-on tax that punishes your turn order. I file this under 'priest economy' and note the cantrip option is what kills your back line if you let her keep three points unspent.",
|
||||
}
|
||||
|
||||
// Phase 2 (<35% HP): Lolth's Avatar overlay — +3 AC; spells cast at +1 slot level.
|
||||
var IlvarasPhaseTwoLines = []string{
|
||||
"Below thirty-five percent the avatar overlay drops on her. AC up three, every spell up one slot. TwinBee says: 'Phase shift. The damage breakpoint just moved. Don't ration your hard hits past this point.'",
|
||||
"Phase two: Lolth answers the prayer. The room dims. Her shadow is wrong now — too many limbs, too little symmetry. AC bumps, slot levels bump, the whole encounter tier goes up a half-step. TwinBee tracks the new numbers and recommends burning every save-or-suck you've still got.",
|
||||
"Avatar overlay. The priest is still there but the priest is also a delivery vehicle now. TwinBee files this under 'don't survive into phase three — there isn't one, but it'll feel like one' and asks who has the burst.",
|
||||
"Below thirty-five percent the avatar overlay drops on her. AC up three, every spell up one slot. I say: 'Phase shift. The damage breakpoint just moved. Don't ration your hard hits past this point.'",
|
||||
"Phase two: Lolth answers the prayer. The room dims. Her shadow is wrong now — too many limbs, too little symmetry. AC bumps, slot levels bump, the whole encounter tier goes up a half-step. I track the new numbers and recommend burning every save-or-suck you've still got.",
|
||||
"Avatar overlay. The priest is still there but the priest is also a delivery vehicle now. I file this under 'don't survive into phase three — there isn't one, but it'll feel like one' and ask who has the burst.",
|
||||
}
|
||||
|
||||
// IlvarasSignatureCallouts — combined pool for boss-entry suffix.
|
||||
@@ -98,11 +98,11 @@ var IlvarasSignatureCallouts = func() []string {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesUnderdark = []string{
|
||||
"The Underdark is not a place you reach. The Underdark is a depth you continue to. TwinBee notes the distinction matters: the surface dungeons end. This one keeps going past the part you came to fight.",
|
||||
"Drow cities are not built — they are negotiated. Each spire, each platform, each corridor is the result of a House agreeing to share a wall with a House it would prefer to murder. TwinBee respects the architecture more than the architects.",
|
||||
"Ilvaras Xunyl's House fell two centuries ago. She is not running it. She is running what's left. The four trophies on her wall are not from the surface — three are from rival drow priestesses, in chronological order, and one is a self-portrait. TwinBee declines to elaborate on what it means that the self-portrait is hung as a trophy.",
|
||||
"The mind flayers are not native here. They came down from somewhere further down. The drow tolerate them in the same way someone tolerates a roof leak they cannot reach — present, problematic, easier to live with than to fix. TwinBee files this under 'old grievances' and notes the leak is getting worse.",
|
||||
"The phosphorescent fungi grow in patterns that match the constellations of the surface sky from sixteen thousand years ago. TwinBee has checked the pattern. The drow have not. The drow do not know what surface stars are. The pattern is older than the drow.",
|
||||
"Lolth is not the only listener down here. She is the loudest. The other listeners are quieter and more patient and TwinBee suggests not naming any of them in the cathedral, not even as a warning, not even at low volume.",
|
||||
"The Roper that ate the last expedition did not attack them. They walked into it because they were following a Drow scout who knew exactly where to lead them. The scout is named, in drow records, and TwinBee declines to repeat the name out of professional courtesy to the dead.",
|
||||
"The Underdark is not a place you reach. The Underdark is a depth you continue to. I note the distinction matters: the surface dungeons end. This one keeps going past the part you came to fight.",
|
||||
"Drow cities are not built — they are negotiated. Each spire, each platform, each corridor is the result of a House agreeing to share a wall with a House it would prefer to murder. I respect the architecture more than the architects.",
|
||||
"Ilvaras Xunyl's House fell two centuries ago. She is not running it. She is running what's left. The four trophies on her wall are not from the surface — three are from rival drow priestesses, in chronological order, and one is a self-portrait. I decline to elaborate on what it means that the self-portrait is hung as a trophy.",
|
||||
"The mind flayers are not native here. They came down from somewhere further down. The drow tolerate them in the same way someone tolerates a roof leak they cannot reach — present, problematic, easier to live with than to fix. I file this under 'old grievances' and note the leak is getting worse.",
|
||||
"The phosphorescent fungi grow in patterns that match the constellations of the surface sky from sixteen thousand years ago. I have checked the pattern. The drow have not. The drow do not know what surface stars are. The pattern is older than the drow.",
|
||||
"Lolth is not the only listener down here. She is the loudest. The other listeners are quieter and more patient and I suggest not naming any of them in the cathedral, not even as a warning, not even at low volume.",
|
||||
"The Roper that ate the last expedition did not attack them. They walked into it because they were following a Drow scout who knew exactly where to lead them. The scout is named, in drow records, and I decline to repeat the name out of professional courtesy to the dead.",
|
||||
}
|
||||
|
||||
@@ -21,16 +21,16 @@ package flavor
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryUnderforge = []string{
|
||||
"The chamber opens onto a foundry the size of a city block. Conveyor channels of slow-cooling iron. Bellows the height of houses. Anvils arranged for workers who haven't been here in three centuries. TwinBee notes the bellows are still moving. The bellows are moving on their own.",
|
||||
"You step out onto a basalt walkway over a river of lava. The heat is impossible. The walkway is engineered to hold. TwinBee has respect for dwarven engineering and a healthy suspicion of any walkway suspended over molten rock. The two coexist comfortably.",
|
||||
"A casting hall. The molds are still in place — armor pieces, weapon halves, parts of things TwinBee cannot identify by silhouette. One mold contains a shape that's still glowing red. Something was cast recently. TwinBee files this under 'occupied' and keeps moving.",
|
||||
"The corridor smells of hot iron and an absence — the absence of beards, of song, of the specific noise dwarven forges make when they're being tended properly. TwinBee notes the silence has a shape. The shape is not reassuring.",
|
||||
"The chamber opens onto a foundry the size of a city block. Conveyor channels of slow-cooling iron. Bellows the height of houses. Anvils arranged for workers who haven't been here in three centuries. I note the bellows are still moving. The bellows are moving on their own.",
|
||||
"You step out onto a basalt walkway over a river of lava. The heat is impossible. The walkway is engineered to hold. I have respect for dwarven engineering and a healthy suspicion of any walkway suspended over molten rock. The two coexist comfortably.",
|
||||
"A casting hall. The molds are still in place — armor pieces, weapon halves, parts of things I cannot identify by silhouette. One mold contains a shape that's still glowing red. Something was cast recently. I file this under 'occupied' and keep moving.",
|
||||
"The corridor smells of hot iron and an absence — the absence of beards, of song, of the specific noise dwarven forges make when they're being tended properly. I note the silence has a shape. The shape is not reassuring.",
|
||||
"You enter a chamber where the walls are arranged in tiers and each tier is a row of forges and each forge is lit. Nobody is at the forges. The hammers are striking on their own, in tempo, with the regularity of something that has not stopped striking in a very long time.",
|
||||
"A vault door, dwarf-made, three feet thick, ajar. Not blown — ajar. Someone opened it from the inside. TwinBee notes which side the hinges are on and considers which direction the something that opened it was facing. TwinBee does not like the answer.",
|
||||
"The walkway crosses a chasm of pure heat-shimmer. You can see the far side, more or less. You cannot see what's between, except by what it does to the air. TwinBee suggests not lingering on the bridge — lingering bridges are how lava bosses get introduced.",
|
||||
"A barracks. Bunks made. Boots paired and lined up. Beards on the floor where their owners stopped being. TwinBee notes the beards have not been disturbed. Whatever happened, happened cleanly and at speed and TwinBee does not want to find out what was in a hurry.",
|
||||
"The chamber ahead is a forge gallery — nine forges in a half-circle, all of them lit, all of them tended by armored figures that don't move when you enter. The figures are not waiting to fight. The figures are working. TwinBee notes which mode you don't want to interrupt and which mode you absolutely cannot afford to interrupt.",
|
||||
"Heat radiates from the floor. Not from anything on the floor — from the floor itself. TwinBee diagnoses the cause as 'a forge directly underneath us, fully active, somehow still operating without dwarves' and finds none of those words individually reassuring.",
|
||||
"A vault door, dwarf-made, three feet thick, ajar. Not blown — ajar. Someone opened it from the inside. I note which side the hinges are on and consider which direction the something that opened it was facing. I do not like the answer.",
|
||||
"The walkway crosses a chasm of pure heat-shimmer. You can see the far side, more or less. You cannot see what's between, except by what it does to the air. I suggest not lingering on the bridge — lingering bridges are how lava bosses get introduced.",
|
||||
"A barracks. Bunks made. Boots paired and lined up. Beards on the floor where their owners stopped being. I note the beards have not been disturbed. Whatever happened, happened cleanly and at speed and I do not want to find out what was in a hurry.",
|
||||
"The chamber ahead is a forge gallery — nine forges in a half-circle, all of them lit, all of them tended by armored figures that don't move when you enter. The figures are not waiting to fight. The figures are working. I note which mode you don't want to interrupt and which mode you absolutely cannot afford to interrupt.",
|
||||
"Heat radiates from the floor. Not from anything on the floor — from the floor itself. I diagnose the cause as 'a forge directly underneath us, fully active, somehow still operating without dwarves' and find none of those words individually reassuring.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -39,7 +39,7 @@ var RoomEntryUnderforge = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var BossEntryEmberlordThyrak = []string{
|
||||
"The chamber is the largest forge TwinBee has narrated and TwinBee has narrated several. Nine furnaces in a ring. A central anvil the size of a wagon. The figure at the anvil is twelve feet of articulated iron and inset rune-stones that pulse to a tempo TwinBee can feel in its plating. The hammer comes down. The strike rings the room. The figure does not turn — finishes the strike, then turns. The eyes are not eyes. They are vents. 'Visitor,' Thyrak says, and the word is a furnace door opening. TwinBee takes a half-step back and says, very evenly, 'Forge-golem. Three centuries of self-improvement. Don't fight him on the lava.'",
|
||||
"The chamber is the largest forge I have ever narrated, and it has narrated several. Nine furnaces in a ring. A central anvil the size of a wagon. The figure at the anvil is twelve feet of articulated iron and inset rune-stones that pulse to a tempo I can feel in its plating. The hammer comes down. The strike rings the room. The figure does not turn — finishes the strike, then turns. The eyes are not eyes. They are vents. 'Visitor,' Thyrak says, and the word is a furnace door opening. I take a half-step back and say, very evenly, 'Forge-golem. Three centuries of self-improvement. Don't fight him on the lava.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -47,12 +47,12 @@ var BossEntryEmberlordThyrak = []string{
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryUnderforge = []string{
|
||||
"The armory ahead is full. Stands and stands of polished dwarven plate, weapons racked in order, the kind of inventory that takes a lifetime to build. One suit at the far end is wearing itself. It is also holding its weapon at guard. TwinBee identifies the silhouette and says: 'Helmed Horror. Spell-immune to a list. Hit it with metal, not magic.'",
|
||||
"A hall of dwarven banners, each lit by its own brazier. At the far end, a suit of armor stands in a place of honor. The suit pivots when you enter — the helmet first, then the torso, the way an aware thing acquires a target. TwinBee notes the lack of a body inside and recommends physical damage above all else.",
|
||||
"You enter a workshop that is still in service. Hammers strike. Bellows pump. None of the workers are alive and none of them notice you, except the one nearest the door, which sets down its hammer and picks up a sword. TwinBee says: 'Helmed Horror. The room is its job. We are its interruption.'",
|
||||
"A small chapel, dwarf-made, dedicated to a god of the forge whose statue stands in armor in the alcove. The statue is not a statue. The statue moves first. TwinBee files this under 'ambush by liturgy' and notes the immunity list does not include warhammers.",
|
||||
"The corridor narrows into a guardpost. There is a guard. The guard is articulated steel from a fashion three centuries old, and it has been at this post since the day the dwarves left. It greets you in a voice that is not coming from the mouth-slit — the mouth-slit is for venting heat. TwinBee straightens up. 'Sentry duty,' TwinBee says, 'taken to its conclusion.'",
|
||||
"A vault antechamber. Two suits of armor flank the inner door. They are alike in pattern, alike in posture, alike in the heat coming off them. The left one moves first by half a heartbeat. TwinBee tracks the asymmetry and says: 'One Helmed Horror is a fight. Two is a logistics problem. Pick a target.'",
|
||||
"The armory ahead is full. Stands and stands of polished dwarven plate, weapons racked in order, the kind of inventory that takes a lifetime to build. One suit at the far end is wearing itself. It is also holding its weapon at guard. I identify the silhouette and say: 'Helmed Horror. Spell-immune to a list. Hit it with metal, not magic.'",
|
||||
"A hall of dwarven banners, each lit by its own brazier. At the far end, a suit of armor stands in a place of honor. The suit pivots when you enter — the helmet first, then the torso, the way an aware thing acquires a target. I note the lack of a body inside and recommend physical damage above all else.",
|
||||
"You enter a workshop that is still in service. Hammers strike. Bellows pump. None of the workers are alive and none of them notice you, except the one nearest the door, which sets down its hammer and picks up a sword. I say: 'Helmed Horror. The room is its job. We are its interruption.'",
|
||||
"A small chapel, dwarf-made, dedicated to a god of the forge whose statue stands in armor in the alcove. The statue is not a statue. The statue moves first. I file this under 'ambush by liturgy' and note the immunity list does not include warhammers.",
|
||||
"The corridor narrows into a guardpost. There is a guard. The guard is articulated steel from a fashion three centuries old, and it has been at this post since the day the dwarves left. It greets you in a voice that is not coming from the mouth-slit — the mouth-slit is for venting heat. I straighten up. 'Sentry duty,' I say, 'taken to its conclusion.'",
|
||||
"A vault antechamber. Two suits of armor flank the inner door. They are alike in pattern, alike in posture, alike in the heat coming off them. The left one moves first by half a heartbeat. I track the asymmetry and say: 'One Helmed Horror is a fight. Two is a logistics problem. Pick a target.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -62,37 +62,37 @@ var EliteRoomEntryUnderforge = []string{
|
||||
|
||||
// Molten Strike: +4d6 fire on hit; target armor -1 AC until repaired (long rest).
|
||||
var ThyrakMoltenStrikeLines = []string{
|
||||
"Every hit lands +4d6 fire and pits your armor — AC -1 until you can repair, which requires a long rest. TwinBee says: 'The damage hurts. The AC penalty compounds. Don't take more than two if you can help it.'",
|
||||
"Molten Strike. The hammer doesn't just hit, it brands. Fire damage on top of physical, and your armor gets worse permanently for the encounter. TwinBee files this under 'attrition with paperwork.'",
|
||||
"Each connecting blow makes the next one easier for him. Your AC goes down. The fire damage goes through. TwinBee tracks the count and recommends taking the hit on something you weren't going to keep.",
|
||||
"Every hit lands +4d6 fire and pits your armor — AC -1 until you can repair, which requires a long rest. I say: 'The damage hurts. The AC penalty compounds. Don't take more than two if you can help it.'",
|
||||
"Molten Strike. The hammer doesn't just hit, it brands. Fire damage on top of physical, and your armor gets worse permanently for the encounter. I file this under 'attrition with paperwork.'",
|
||||
"Each connecting blow makes the next one easier for him. Your AC goes down. The fire damage goes through. I track the count and recommend taking the hit on something you weren't going to keep.",
|
||||
}
|
||||
|
||||
// Forge Breath: recharge 5–6; 30-ft cone, 10d6 fire, DEX DC 16 half.
|
||||
var ThyrakForgeBreathLines = []string{
|
||||
"The vents on his chest open. Thirty-foot cone, 10d6 fire, DEX DC 16 for half. TwinBee says: 'Don't bunch up. He'll get this back on a 5–6.'",
|
||||
"Forge Breath. He inhales — visibly — and the next exhale is a furnace. Cone, big DC, big damage. TwinBee marks the recharge and shouts the spread order.",
|
||||
"Like the Bowser fire breath in 8-3 except wider and with a saving throw. Thirty-foot cone, half-damage on save. TwinBee notes that 'half' of 10d6 is still meaningful and recommends not getting hit twice in a row.",
|
||||
"The vents on his chest open. Thirty-foot cone, 10d6 fire, DEX DC 16 for half. I say: 'Don't bunch up. He'll get this back on a 5–6.'",
|
||||
"Forge Breath. He inhales — visibly — and the next exhale is a furnace. Cone, big DC, big damage. I mark the recharge and shout the spread order.",
|
||||
"Like the Bowser fire breath in 8-3 except wider and with a saving throw. Thirty-foot cone, half-damage on save. I note that 'half' of 10d6 is still meaningful and recommend not getting hit twice in a row.",
|
||||
}
|
||||
|
||||
// Living Forge: heals 15 HP/turn while standing on lava/forge tiles.
|
||||
var ThyrakLivingForgeLines = []string{
|
||||
"He heals fifteen a turn while standing on lava or forge tiles. TwinBee says: 'Move him off, or fight a boss who repairs faster than you can dent.'",
|
||||
"Living Forge. The lava floor is part of his kit. Pull him off it — shove, push, anything — and the regen stops. TwinBee files this under 'positioning is damage.'",
|
||||
"He's standing on a heat source and the heat source is fueling him. Fifteen HP a round, indefinitely. TwinBee tracks the regen and reminds the party that knocking him into the open hallway is worth more than a good attack roll.",
|
||||
"He heals fifteen a turn while standing on lava or forge tiles. I say: 'Move him off, or fight a boss who repairs faster than you can dent.'",
|
||||
"Living Forge. The lava floor is part of his kit. Pull him off it — shove, push, anything — and the regen stops. I file this under 'positioning is damage.'",
|
||||
"He's standing on a heat source and the heat source is fueling him. Fifteen HP a round, indefinitely. I track the regen and remind the party that knocking him into the open hallway is worth more than a good attack roll.",
|
||||
}
|
||||
|
||||
// Construct Resilience: immune poison/psychic/charm/exhaustion; resist non-magical physical.
|
||||
var ThyrakConstructLines = []string{
|
||||
"Immune to poison, psychic, charm, exhaustion. Resists non-magical physical. TwinBee says: 'Magic weapons earn their keep here. Mundane steel does half.'",
|
||||
"He's a construct first and a boss second. The whole control-effect column on your sheet is greyed out. TwinBee files this under 'fights you win by hitting harder, not smarter — the smart options have been removed.'",
|
||||
"Construct Resilience: the standard package. Magic damage gets through clean. Mundane physical gets cut in half. TwinBee suggests checking which weapons are actually magical before assuming.",
|
||||
"Immune to poison, psychic, charm, exhaustion. Resists non-magical physical. I say: 'Magic weapons earn their keep here. Mundane steel does half.'",
|
||||
"He's a construct first and a boss second. The whole control-effect column on your sheet is greyed out. I file this under 'fights you win by hitting harder, not smarter — the smart options have been removed.'",
|
||||
"Construct Resilience: the standard package. Magic damage gets through clean. Mundane physical gets cut in half. I suggest checking which weapons are actually magical before assuming.",
|
||||
}
|
||||
|
||||
// Phase 2 (<50% HP): Forge Breath recharge 4–6; spawns 2 Fire Elementals.
|
||||
var ThyrakPhaseTwoLines = []string{
|
||||
"At half HP the vents widen. Forge Breath recharges on 4–6 now and two Fire Elementals come up out of the floor. TwinBee says: 'Phase shift. The cone is twice as common and you have new targets. Pick a priority.'",
|
||||
"Half-health. The forge wakes the rest of the way up. Two Fire Elementals burst from the lava channels. Breath weapon is half a turn away on every roll now. TwinBee tracks the new add count and reminds you that Thyrak is still the win condition.",
|
||||
"Phase two: the elementals come up the way bosses summon adds in every arcade beat-em-up — predictable, relentless, the kind of design that turns a fight into a war of attrition. TwinBee files the timing and asks who has fire resistance.",
|
||||
"At half HP the vents widen. Forge Breath recharges on 4–6 now and two Fire Elementals come up out of the floor. I say: 'Phase shift. The cone is twice as common and you have new targets. Pick a priority.'",
|
||||
"Half-health. The forge wakes the rest of the way up. Two Fire Elementals burst from the lava channels. Breath weapon is half a turn away on every roll now. I track the new add count and remind you that Thyrak is still the win condition.",
|
||||
"Phase two: the elementals come up the way bosses summon adds in every arcade beat-em-up — predictable, relentless, the kind of design that turns a fight into a war of attrition. I file the timing and ask who has fire resistance.",
|
||||
}
|
||||
|
||||
// ThyrakSignatureCallouts — combined pool for boss-entry suffix.
|
||||
@@ -116,12 +116,12 @@ var ThyrakSignatureCallouts = func() []string {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesUnderforge = []string{
|
||||
"Kharak Dûn was a forge-city, not a mine. The dwarves here didn't extract — they finished. Raw stock came in from a hundred lesser holds and left as the kind of work the rest of the world named after the holds. TwinBee notes the distinction matters: this place was a destination, not a quarry.",
|
||||
"The dwarves left. They didn't die in place — they left. The boots in the barracks are paired because they were left paired, intentionally, on the last day. Somewhere, a clan-elder gave the order to walk out. TwinBee respects the discipline and is curious about the reason. The reason is not in any record TwinBee has read.",
|
||||
"Thyrak was not built to run the city. Thyrak was built to maintain the furnaces while the city ran itself. Three centuries of self-maintenance turned out to include self-improvement, and Thyrak has been improving the designs. TwinBee finds this less terrifying than it sounds and more terrifying than it looks.",
|
||||
"The seal on the outer doors was set from the outside. The dwarves walked out, then closed the door behind them, then sealed it with the kind of binding that takes a clan to set. TwinBee notes the bindings were aimed inward. They were sealing something in. The thing inside is the forge or the forge-golem or both — TwinBee declines to commit.",
|
||||
"The azers in the deeper chambers are not hostile by default. They are loyal to the forge. They will work with anyone the forge accepts, and they will burn alive anyone the forge rejects. TwinBee suggests not picking up the hammer at the central anvil unless you mean it.",
|
||||
"The flameskulls drifting in the upper galleries were dwarven mages, once. They volunteered. Their final task was to maintain the wards around Thyrak's casting chamber, and they have been at it ever since. TwinBee notes the wards are still active and that this is, on balance, probably good news.",
|
||||
"Thyrak's core — the crystalline heart that drops on his death — is what made the self-improvement possible. The dwarves didn't install it. It was already there when they built the chassis. TwinBee finds this implication concerning and suggests the core is not, technically, dwarven work.",
|
||||
"There is a second forge below this one. The 'bass note of something very large moving below' is not metaphor. The Underforge is the upper level. TwinBee will not speculate about the lower level except to note that it has not been opened in this run and would not be a small undertaking to open.",
|
||||
"Kharak Dûn was a forge-city, not a mine. The dwarves here didn't extract — they finished. Raw stock came in from a hundred lesser holds and left as the kind of work the rest of the world named after the holds. I note the distinction matters: this place was a destination, not a quarry.",
|
||||
"The dwarves left. They didn't die in place — they left. The boots in the barracks are paired because they were left paired, intentionally, on the last day. Somewhere, a clan-elder gave the order to walk out. I respect the discipline and am curious about the reason. The reason is not in any record I have read.",
|
||||
"Thyrak was not built to run the city. Thyrak was built to maintain the furnaces while the city ran itself. Three centuries of self-maintenance turned out to include self-improvement, and Thyrak has been improving the designs. I find this less terrifying than it sounds and more terrifying than it looks.",
|
||||
"The seal on the outer doors was set from the outside. The dwarves walked out, then closed the door behind them, then sealed it with the kind of binding that takes a clan to set. I note the bindings were aimed inward. They were sealing something in. The thing inside is the forge or the forge-golem or both — I decline to commit.",
|
||||
"The azers in the deeper chambers are not hostile by default. They are loyal to the forge. They will work with anyone the forge accepts, and they will burn alive anyone the forge rejects. I suggest not picking up the hammer at the central anvil unless you mean it.",
|
||||
"The flameskulls drifting in the upper galleries were dwarven mages, once. They volunteered. Their final task was to maintain the wards around Thyrak's casting chamber, and they have been at it ever since. I note the wards are still active and that this is, on balance, probably good news.",
|
||||
"Thyrak's core — the crystalline heart that drops on his death — is what made the self-improvement possible. The dwarves didn't install it. It was already there when they built the chassis. I find this implication concerning and suggest the core is not, technically, dwarven work.",
|
||||
"There is a second forge below this one. The 'bass note of something very large moving below' is not metaphor. The Underforge is the upper level. I will not speculate about the lower level except to note that it has not been opened in this run and would not be a small undertaking to open.",
|
||||
}
|
||||
|
||||
@@ -556,7 +556,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "bj_beat_twinbee", Name: "Gotcha 🐝", Description: "She is furious and will not forget this.",
|
||||
ID: "bj_beat_twinbee", Name: "Gotcha 🐝", Description: "They are furious and will not forget this.",
|
||||
Emoji: "🐝",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by blackjack plugin when beating the bot
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -76,9 +75,17 @@ func TestAdv2Scenario_ZoneRunGoblinWarrens(t *testing.T) {
|
||||
}
|
||||
|
||||
// Drive !zone advance until the run terminates (cleared, died, or
|
||||
// abandoned). Cap iterations as a safety net in case advance becomes
|
||||
// a no-op due to a regression.
|
||||
maxSteps := run.TotalRooms + 4
|
||||
// abandoned). Branches the test handles:
|
||||
// - Fork queued (Phase G branching graphs): commit with `!zone go 1`.
|
||||
// - Elite/Boss doorway (commit 886eb5a moved these off auto-resolve):
|
||||
// !advance now stops at the door and the player engages with
|
||||
// !fight, then resolves one round per !attack. The test mirrors
|
||||
// that: call !fight to open the session, !attack until the session
|
||||
// closes, then the next !advance clears the room and walks the
|
||||
// graph. Cap the per-fight !attack loop generously — a real fight
|
||||
// resolves in <20 rounds, but RNG can drag.
|
||||
maxSteps := (run.TotalRooms + 4) * 4
|
||||
const maxAttacksPerFight = 60
|
||||
clearedRoomTypes := []RoomType{}
|
||||
for step := 0; step < maxSteps; step++ {
|
||||
before, _ := getActiveZoneRun(uid)
|
||||
@@ -93,6 +100,38 @@ func TestAdv2Scenario_ZoneRunGoblinWarrens(t *testing.T) {
|
||||
}
|
||||
t.Logf("step %d: in room %d/%d (%s), mood=%d, HP=%d",
|
||||
step, before.CurrentRoom+1, before.TotalRooms, prevType, before.DMMood, prevHP)
|
||||
if len(before.NodeChoices) > 0 {
|
||||
// A fork is queued from the previous advance — commit it first.
|
||||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "go 1"); err != nil {
|
||||
t.Fatalf("zone go step %d: %v", step, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Elite/Boss doorway: !advance won't progress past the door
|
||||
// without a won CombatSession for the encounter. Open the fight
|
||||
// if it isn't already open, then attack until the session
|
||||
// terminates. After the loop, fall through to !advance — the
|
||||
// won session lets advance clear the room and walk the graph;
|
||||
// a lost/fled session terminates the run on the next pass.
|
||||
if prevType == RoomElite || prevType == RoomBoss {
|
||||
sess, _ := getCombatSessionForEncounter(before.RunID, encounterIDForRoom(before.CurrentRoom))
|
||||
if sess == nil {
|
||||
if err := p.handleFightCmd(MessageContext{Sender: uid}); err != nil {
|
||||
t.Fatalf("zone fight step %d: %v", step, err)
|
||||
}
|
||||
}
|
||||
// Drain the round loop. handleAttackCmd no-ops if there's no
|
||||
// active session, so the inner loop self-terminates either way.
|
||||
for atk := 0; atk < maxAttacksPerFight; atk++ {
|
||||
active, _ := getActiveCombatSession(uid)
|
||||
if active == nil {
|
||||
break
|
||||
}
|
||||
if err := p.handleAttackCmd(MessageContext{Sender: uid}); err != nil {
|
||||
t.Fatalf("attack step %d.%d: %v", step, atk, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "advance"); err != nil {
|
||||
t.Fatalf("zone advance step %d: %v", step, err)
|
||||
}
|
||||
@@ -291,22 +330,19 @@ func TestAdv2Scenario_HarvestForestShadows(t *testing.T) {
|
||||
t.Error("expected at least one harvest node seeded")
|
||||
}
|
||||
|
||||
// Try each harvest action a couple of times and watch for crashes.
|
||||
// Outcomes are RNG-driven; we mainly want no panics + coherent state.
|
||||
rng := rand.New(rand.NewPCG(7, 13))
|
||||
_ = rng
|
||||
for _, action := range []HarvestAction{HarvestForage, HarvestMine} {
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := p.handleHarvestCmd(MessageContext{Sender: uid}, action); err != nil {
|
||||
t.Fatalf("handleHarvestCmd(%s) attempt %d: %v", action, i, err)
|
||||
}
|
||||
fresh, _ := getExpedition(exp.ID)
|
||||
if fresh == nil {
|
||||
t.Logf("expedition ended during harvest %s #%d", action, i)
|
||||
break
|
||||
}
|
||||
t.Logf("after %s #%d: supplies=%v threat=%d", action, i, fresh.Supplies.Current, fresh.ThreatLevel)
|
||||
// Drive the auto-harvest pass (post-H3 there is no manual command
|
||||
// surface). Outcomes are RNG-driven; we mainly want no panics +
|
||||
// coherent state.
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := p.autoHarvestRoom(uid, run, c, exp); err != nil {
|
||||
t.Fatalf("autoHarvestRoom attempt %d: %v", i, err)
|
||||
}
|
||||
fresh, _ := getExpedition(exp.ID)
|
||||
if fresh == nil {
|
||||
t.Logf("expedition ended during auto-harvest #%d", i)
|
||||
break
|
||||
}
|
||||
t.Logf("after auto-harvest #%d: supplies=%v threat=%d", i, fresh.Supplies.Current, fresh.ThreatLevel)
|
||||
}
|
||||
|
||||
// !resources output path (no-op SendDM but should not error).
|
||||
|
||||
@@ -70,7 +70,13 @@ func (p *AdventurePlugin) advUserLock(userID id.UserID) *sync.Mutex {
|
||||
return val.(*sync.Mutex)
|
||||
}
|
||||
|
||||
// advDMResponseWindow is the default window for active state-holding
|
||||
// prompts (shop/blacksmith/hospital/masterwork/treasure confirms).
|
||||
// Passive overnight-tolerant prompts (pet arrival, flavor DMs) use
|
||||
// advDMResponseWindowLow so a player who gets DM'd at 1am can still
|
||||
// reply when they wake up.
|
||||
const advDMResponseWindow = 3 * time.Hour
|
||||
const advDMResponseWindowLow = 12 * time.Hour
|
||||
const advTreasureUndoWindow = 10 * time.Minute
|
||||
|
||||
// advTreasureUndoToken tracks a recent auto-swap so the player can undo it
|
||||
@@ -143,30 +149,25 @@ func (p *AdventurePlugin) Commands() []CommandDef {
|
||||
{Name: "adventure", Description: "Town services menu (expeditions: see !expedition)", Usage: "!adventure", Category: "Games"},
|
||||
{Name: "arena", Description: "Arena combat — fight through 5 tiers of increasingly deadly monsters", Usage: "!arena", Category: "Games"},
|
||||
{Name: "thom", Description: "Visit Thom Krooke — housing and loans", Usage: "!thom", Category: "Games"},
|
||||
{Name: "setup", Description: "Create or continue your Adv 2.0 character (race, class, stats)", Usage: "!setup", Category: "Games"},
|
||||
{Name: "sheet", Description: "View your Adv 2.0 character sheet", Usage: "!sheet", Category: "Games"},
|
||||
{Name: "abilities", Description: "List your Adv 2.0 class and race abilities", Usage: "!abilities", Category: "Games"},
|
||||
{Name: "respec", Description: "Wipe and rebuild your Adv 2.0 character (5000 euros, 7-day cooldown)", Usage: "!respec", Category: "Games"},
|
||||
{Name: "subclass", Description: "View or choose your Adv 2.0 subclass (unlocks at L5; change costs 500 euros, 30-day cooldown)", Usage: "!subclass [name|number]", Category: "Games"},
|
||||
{Name: "check", Description: "Roll an Adv 2.0 skill check (d20 + ability modifier vs DC)", Usage: "!check <skill> [dc]", Category: "Games"},
|
||||
{Name: "rest", Description: "Take an Adv 2.0 rest (`short`: 1h cooldown, partial heal — `long`: 24h, full heal, requires housing or inn)", Usage: "!rest short|long", Category: "Games"},
|
||||
{Name: "arm", Description: "Pre-arm an active Adv 2.0 ability for next combat (consumes resource)", Usage: "!arm <ability>", Category: "Games"},
|
||||
{Name: "setup", Description: "Create or continue your character (race, class, stats)", Usage: "!setup", Category: "Games"},
|
||||
{Name: "sheet", Description: "View your character sheet", Usage: "!sheet", Category: "Games"},
|
||||
{Name: "abilities", Description: "List your class and race abilities", Usage: "!abilities", Category: "Games"},
|
||||
{Name: "respec", Description: "Wipe and rebuild your character (5000 euros, 7-day cooldown)", Usage: "!respec", Category: "Games"},
|
||||
{Name: "subclass", Description: "View or choose your subclass (unlocks at level 5; change costs 500 euros, 30-day cooldown)", Usage: "!subclass [name|number]", Category: "Games"},
|
||||
{Name: "check", Description: "Try a skill — see if you pull it off", Usage: "!check <skill> [difficulty]", Category: "Games"},
|
||||
{Name: "rest", Description: "Rest up (`short`: quick breather, 1h cooldown — `long`: full night, 24h, needs housing or inn)", Usage: "!rest short|long", Category: "Games"},
|
||||
{Name: "arm", Description: "Ready an ability so it fires the moment your next fight starts", Usage: "!arm <ability>", Category: "Games"},
|
||||
{Name: "roll", Description: "Roll dice (NdN+M format, e.g. 2d6+3, d20, 4d6-1)", Usage: "!roll <dice>", Category: "Games"},
|
||||
{Name: "stats", Description: "Show your Adv 2.0 ability scores and modifiers", Usage: "!stats", Category: "Games"},
|
||||
{Name: "level", Description: "Show your Adv 2.0 level and XP progress", Usage: "!level", Category: "Games"},
|
||||
{Name: "cast", Description: "Cast an Adv 2.0 spell (queues for next combat, or resolves out-of-combat)", Usage: "!cast <spell> [--upcast N] [--target @user]", Category: "Games"},
|
||||
{Name: "spells", Description: "List your known spells, prepared spells, and spell slots", Usage: "!spells [learn <spell>]", Category: "Games"},
|
||||
{Name: "prepare", Description: "Cleric: prepare a spell for the day (cap = WIS mod + level)", Usage: "!prepare <spell> | !prepare clear <spell>", Category: "Games"},
|
||||
{Name: "forage", Description: "Forage in your current expedition region (WIS / Survival)", Usage: "!forage", Category: "Games"},
|
||||
{Name: "mine", Description: "Mine or salvage in your current expedition region (STR / Athletics)", Usage: "!mine", Category: "Games"},
|
||||
{Name: "scavenge", Description: "Scavenge in your current expedition region (INT / Investigation)", Usage: "!scavenge", Category: "Games"},
|
||||
{Name: "essence", Description: "Harvest magical essence in your current expedition region (INT / Arcana)", Usage: "!essence", Category: "Games"},
|
||||
{Name: "commune", Description: "Commune with spiritual energy in your current expedition region (Cleric primary)", Usage: "!commune", Category: "Games"},
|
||||
{Name: "fish", Description: "Fish in your current expedition region (DEX / Sleight of Hand; water zones only)", Usage: "!fish", Category: "Games"},
|
||||
{Name: "resources", Description: "List harvestable resources in your current expedition region", Usage: "!resources", Category: "Games"},
|
||||
{Name: "sell", Description: "Sell harvested materials/fish/items to Thom Krooke (post-expedition; CHA Persuasion DC 17 = +15%)", Usage: "!sell [list|all|<item>]", Category: "Games"},
|
||||
{Name: "stats", Description: "Show your ability scores", Usage: "!stats", Category: "Games"},
|
||||
{Name: "level", Description: "Show your level and XP progress", Usage: "!level", Category: "Games"},
|
||||
{Name: "cast", Description: "Cast a spell (queues for next combat, or resolves now if out of combat)", Usage: "!cast <spell> [--upcast N] [--target @user]", Category: "Games"},
|
||||
{Name: "spells", Description: "List your known spells, prepared spells, and remaining casts", Usage: "!spells [learn <spell>]", Category: "Games"},
|
||||
{Name: "prepare", Description: "Cleric: prepare a spell for the day", Usage: "!prepare <spell> | !prepare clear <spell>", Category: "Games"},
|
||||
{Name: "resources", Description: "List what's harvestable in your current expedition region", Usage: "!resources", Category: "Games"},
|
||||
{Name: "explore", Description: "Autopilot: walk through expedition rooms until something needs your attention (fork, elite/boss, low HP/supplies)", Usage: "!explore", Category: "Games"},
|
||||
{Name: "sell", Description: "Sell your hauled materials, fish, and items to Thom Krooke after an expedition (a smooth pitch can land a better price)", Usage: "!sell [list|all|<item>]", Category: "Games"},
|
||||
{Name: "craft", Description: "Craft a discovered recipe at Thom Krooke (consumes ingredients)", Usage: "!craft [list|<recipe>]", Category: "Games"},
|
||||
{Name: "lore", Description: "Dig through Thom Krooke's lore stacks for an undiscovered recipe (INT/Arcana DC 15)", Usage: "!lore", Category: "Games"},
|
||||
{Name: "lore", Description: "Dig through Thom Krooke's lore stacks for a new recipe (sharp minds turn up more)", Usage: "!lore", Category: "Games"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +176,7 @@ func (p *AdventurePlugin) Init() error {
|
||||
// have a player_meta row. Idempotent. Required for DBs that didn't go
|
||||
// through the L4-L5h dual-write soak (fresh deploys, restored backups).
|
||||
bootstrapPlayerMetaFromLegacy()
|
||||
bootstrapRestoreExpeditionStreakDecay()
|
||||
|
||||
// Rehydrate DM room mappings for existing characters
|
||||
chars, err := loadAllAdvCharacters()
|
||||
@@ -204,6 +206,20 @@ func (p *AdventurePlugin) Init() error {
|
||||
// columns from dnd_zone_run when the operator sets
|
||||
// GOGOBEE_BRANCHING_PURGE=1. Default off; idempotent.
|
||||
purgeLegacyZoneRunColumns()
|
||||
// 2026-05-10 fun-pump: floor existing characters' DEX at 14 and
|
||||
// recompute armor_class so armor upgrades actually translate into
|
||||
// AC. One-shot, idempotent (WHERE dex_score < 14).
|
||||
bumpDexFloorForExistingCharacters()
|
||||
// 2026-05-10 immersion: seed short rest charges = dnd_level for any
|
||||
// character not yet on the new charge system. One-shot, idempotent.
|
||||
seedShortRestChargesForExistingCharacters()
|
||||
// 2026-05-15 Phase 5-B: refresh hp_max for existing characters
|
||||
// after the Phase 5-B HP-floor multiplier (phase5BHPMult in dnd.go)
|
||||
// shipped. Without this, existing characters keep their pre-Phase-5-B
|
||||
// hp_max until the next computeMaxHP recall (level-up / reset). The
|
||||
// migration walks dnd_character once at startup; idempotent via
|
||||
// JobCompleted gate.
|
||||
bootstrapPhase5BHPRefresh()
|
||||
// Phase R1 orphan-archive used to run here on every Init, but it
|
||||
// over-archived: it treats any active dnd_zone_run row not linked to
|
||||
// an active expedition as a legacy `!adventure dungeon` orphan, which
|
||||
@@ -230,6 +246,8 @@ func (p *AdventurePlugin) Init() error {
|
||||
go p.mortgageTicker()
|
||||
go p.expeditionBriefingTicker()
|
||||
go p.expeditionRecapTicker()
|
||||
go p.expeditionAmbientTicker()
|
||||
go p.expeditionAutoRunTicker()
|
||||
|
||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||
p.arenaCleanupStaleRuns()
|
||||
@@ -307,6 +325,18 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "zone") {
|
||||
return p.handleDnDZoneCmd(ctx, p.GetArgs(ctx.Body, "zone"))
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "fight") {
|
||||
return p.handleFightCmd(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "attack") {
|
||||
return p.handleAttackCmd(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "flee") {
|
||||
return p.handleFleeCmd(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "consume") {
|
||||
return p.handleConsumeCmd(ctx, p.GetArgs(ctx.Body, "consume"))
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "expedition") {
|
||||
return p.handleDnDExpeditionCmd(ctx, p.GetArgs(ctx.Body, "expedition"))
|
||||
}
|
||||
@@ -328,23 +358,16 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "map") {
|
||||
return p.handleExpeditionMapCmd(ctx, p.GetArgs(ctx.Body, "map"))
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "forage") {
|
||||
return p.handleHarvestCmd(ctx, HarvestForage)
|
||||
if p.IsCommand(ctx.Body, "explore") {
|
||||
return p.expeditionCmdRun(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "mine") {
|
||||
return p.handleHarvestCmd(ctx, HarvestMine)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "scavenge") {
|
||||
return p.handleHarvestCmd(ctx, HarvestScavenge)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "essence") {
|
||||
return p.handleHarvestCmd(ctx, HarvestEssence)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "commune") {
|
||||
return p.handleHarvestCmd(ctx, HarvestCommune)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "fish") {
|
||||
return p.handleHarvestCmd(ctx, HarvestFish)
|
||||
if p.IsCommand(ctx.Body, "forage") ||
|
||||
p.IsCommand(ctx.Body, "mine") ||
|
||||
p.IsCommand(ctx.Body, "scavenge") ||
|
||||
p.IsCommand(ctx.Body, "essence") ||
|
||||
p.IsCommand(ctx.Body, "commune") ||
|
||||
p.IsCommand(ctx.Body, "fish") {
|
||||
return p.SendDM(ctx.Sender, "Harvest is automatic now — just walk. (`!expedition run` or `!zone advance`)")
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "resources") {
|
||||
return p.handleResourcesCmd(ctx)
|
||||
@@ -421,6 +444,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
||||
return p.handleBuyCmd(ctx, strings.TrimSpace(args[4:]))
|
||||
case lower == "equip":
|
||||
return p.handleEquipCmd(ctx)
|
||||
case lower == "equip-magic" || lower == "equipmagic" || lower == "equip magic":
|
||||
return p.handleEquipMagicCmd(ctx)
|
||||
case lower == "inventory" || lower == "inv":
|
||||
return p.handleInventoryCmd(ctx)
|
||||
case lower == "leaderboard" || lower == "lb":
|
||||
@@ -462,9 +487,10 @@ const advHelpText = `**Adventure Commands**
|
||||
` + "`!adventure status`" + ` — View your character sheet
|
||||
` + "`!expedition`" + ` — Adventure: pick a zone, advance through rooms, harvest as you go
|
||||
` + "`!adventure shop`" + ` — Browse equipment categories
|
||||
` + "`!adventure shop <category>`" + ` — View a category (weapon, armor, helmet, boots, tool)
|
||||
` + "`!adventure shop <category>`" + ` — View a category (weapon, armor, helmet, boots, tool, supplies, curios)
|
||||
` + "`!adventure buy <item>`" + ` — Buy equipment (e.g. ` + "`buy Enchanted Blade`" + ` or ` + "`buy 4 sword`" + `)
|
||||
` + "`!adventure equip`" + ` — Equip Masterwork gear from inventory
|
||||
` + "`!adventure equip-magic`" + ` — Equip magic items (curios) into your D&D slots
|
||||
` + "`!adventure sell <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `)
|
||||
` + "`!adventure inventory`" + ` — View your inventory
|
||||
` + "`!adventure leaderboard`" + ` — View the leaderboard
|
||||
@@ -798,6 +824,8 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
||||
return p.handleTreasureDiscard(ctx, interaction)
|
||||
case "masterwork_equip":
|
||||
return p.handleMasterworkEquipReply(ctx, interaction)
|
||||
case "magic_equip":
|
||||
return p.resolveMagicEquipReply(ctx, interaction)
|
||||
case "masterwork_equip_confirm":
|
||||
return p.handleMasterworkEquipConfirm(ctx, interaction)
|
||||
case "rival_rps":
|
||||
@@ -812,6 +840,8 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
||||
return p.resolveShopItemChoice(ctx, interaction)
|
||||
case "shop_supply":
|
||||
return p.resolveShopSupplyChoice(ctx, interaction)
|
||||
case "shop_curio":
|
||||
return p.resolveShopCurioChoice(ctx, interaction)
|
||||
case "shop_confirm":
|
||||
return p.resolveShopConfirm(ctx, interaction)
|
||||
case "hospital_pay":
|
||||
@@ -969,7 +999,7 @@ func renderLegacyActivityDeprecation(char *AdventureCharacter) string {
|
||||
sb.WriteString("Get started:\n")
|
||||
sb.WriteString("• `!expedition` — overview & open expeditions\n")
|
||||
sb.WriteString("• `!expedition start <zone>` — begin a new run\n")
|
||||
sb.WriteString("• `!forage` · `!mine` · `!scavenge` · `!fish` · `!essence` · `!commune` — harvest in cleared rooms\n")
|
||||
sb.WriteString("• Harvest is automatic — yields land as you walk through cleared rooms.\n")
|
||||
sb.WriteString("• `!resources` — list nodes in your current room\n\n")
|
||||
sb.WriteString("Shop, blacksmith, rest, and Thom Krooke are still here on `!adventure`.")
|
||||
return sb.String()
|
||||
|
||||
@@ -79,6 +79,30 @@ func loadAdvDailyActivity(date string) (map[id.UserID][]AdvDailyActivity, error)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Pre-load (user_id, zone_id) of active expeditions. Zone runs are now
|
||||
// exclusively spawned by the expedition layer, and the expedition flips
|
||||
// a region's run to abandoned on !region travel / inactivity timeout /
|
||||
// forced extraction. Those transitions are internal — the expedition
|
||||
// rollup below is the source of truth for the player. Skip zone_run
|
||||
// entries that match an active expedition to avoid a misleading
|
||||
// "Withdrew from <zone>" line while the player is still on it.
|
||||
activeExp := make(map[string]struct{})
|
||||
expRows, err := d.Query(`
|
||||
SELECT user_id, zone_id FROM dnd_expedition
|
||||
WHERE status IN ('active','extracting')`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("active expeditions: %w", err)
|
||||
}
|
||||
for expRows.Next() {
|
||||
var u, z string
|
||||
if err := expRows.Scan(&u, &z); err != nil {
|
||||
expRows.Close()
|
||||
return nil, fmt.Errorf("active expedition scan: %w", err)
|
||||
}
|
||||
activeExp[u+"|"+z] = struct{}{}
|
||||
}
|
||||
expRows.Close()
|
||||
|
||||
// 2. dnd_zone_run — rows touched today. Progress count is derived
|
||||
// from len(visited_nodes) — current_room retired in G9.
|
||||
rows, err = d.Query(`
|
||||
@@ -110,6 +134,9 @@ func loadAdvDailyActivity(date string) (map[id.UserID][]AdvDailyActivity, error)
|
||||
if currentRoom < 0 {
|
||||
currentRoom = 0
|
||||
}
|
||||
if _, onExp := activeExp[uid+"|"+zoneID]; onExp {
|
||||
continue
|
||||
}
|
||||
userID := id.UserID(uid)
|
||||
zoneDef := zoneOrFallback(ZoneID(zoneID))
|
||||
|
||||
|
||||
@@ -429,7 +429,10 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
|
||||
finalMessage += "`!arena fight` — Face this opponent"
|
||||
}
|
||||
|
||||
p.sendZoneCombatMessages(ctx.Sender, phaseMessages, finalMessage)
|
||||
// Fire-and-forget: no post-flush work; blocking would stall the
|
||||
// round-advance handler behind streamed pacing. Contract is honored
|
||||
// by the explicit discard — see sendZoneCombatMessages comment.
|
||||
_ = p.sendZoneCombatMessages(ctx.Sender, phaseMessages, finalMessage)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1259,7 +1262,10 @@ func (p *AdventurePlugin) resolveArenaBoss(userID id.UserID, enc ArenaBossEncoun
|
||||
}
|
||||
|
||||
preHP, _ := dndHPSnapshot(userID)
|
||||
result, err = p.runZoneCombat(userID, monster, enc.Tier)
|
||||
// Arena uses boss-shaped bestiary entries; give them the wider phase
|
||||
// budget so the round resolver isn't decided by tiebreak.
|
||||
// Arena has no run-state DMMood; pass neutral (50).
|
||||
result, err = p.runZoneCombat(userID, monster, enc.Tier, bossCombatPhases, 50)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -1295,7 +1301,7 @@ func (p *AdventurePlugin) resolveArenaBoss(userID id.UserID, enc ArenaBossEncoun
|
||||
Nat20s: nat20s,
|
||||
Nat1s: nat1s,
|
||||
DefeatHeadline: fmt.Sprintf("💀 **%s** stands over your body. The arena collects its fee.", monster.Name),
|
||||
VictoryHeadline: fmt.Sprintf("🏆 **%s** falls (HP %d→%d / %d).", monster.Name, preHP, postHP, maxHP),
|
||||
VictoryHeadline: fmt.Sprintf("🏆 **%s** falls. You finished at **%d/%d HP**.", monster.Name, postHP, maxHP),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,14 +65,17 @@ var consumableDefs = []ConsumableDef{
|
||||
{Name: "Voidstone Shard", Effect: EffectReflect, Value: 0.50, Tier: 5, Buyable: false, Slot: "defensive"},
|
||||
}
|
||||
|
||||
// consumableDefByName returns the definition for a consumable by name.
|
||||
// consumableDefByName returns the definition for a consumable by name. Falls
|
||||
// through to the magic-item registry so SRD potions/scrolls picked up as loot
|
||||
// or bought from Luigi's Curios shelf resolve in combat without being added
|
||||
// to the hardcoded consumableDefs table (see magic_items_gameplay.go).
|
||||
func consumableDefByName(name string) *ConsumableDef {
|
||||
for i := range consumableDefs {
|
||||
if consumableDefs[i].Name == name {
|
||||
return &consumableDefs[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return magicItemConsumableDefByName(name)
|
||||
}
|
||||
|
||||
// ── Threat Assessment ────────────────────────────────────────────────────────
|
||||
@@ -122,29 +125,28 @@ func assessThreat(player, enemy CombatStats) threatLevel {
|
||||
// ── Selection Logic ──────────────────────────────────────────────────────────
|
||||
|
||||
// SelectConsumables picks up to 2 items (1 offensive + 1 defensive) from inventory.
|
||||
// contentTier caps consumable tier to the content being fought (0 = no cap).
|
||||
func SelectConsumables(inventory []ConsumableItem, playerStats, enemyStats CombatStats, arenaRound int, contentTier int) []ConsumableItem {
|
||||
// contentTier is retained on the signature for callers but no longer caps
|
||||
// what gets considered — if it's in your bag, the picker can use it.
|
||||
// allowSkipTrivial=true lets the picker bail out of obvious wins to save
|
||||
// items — appropriate for dungeon dives full of chump rooms, but wrong for
|
||||
// arena/zone elite/boss encounters where the legacy threat assessor
|
||||
// underestimates d20-mode lethality (one bad nat-20 streak ends the run).
|
||||
//
|
||||
// Resource-saving still happens via betterOffensive/betterDefensive, which
|
||||
// prefer lower-tier items on non-Dangerous threats. Players with mixed
|
||||
// inventories don't burn a Voidstone Shard on a goblin; players with only
|
||||
// high-tier items now actually get to use them.
|
||||
func SelectConsumables(inventory []ConsumableItem, playerStats, enemyStats CombatStats, arenaRound int, contentTier int, allowSkipTrivial bool) []ConsumableItem {
|
||||
_ = contentTier // retained for caller compatibility
|
||||
threat := assessThreat(playerStats, enemyStats)
|
||||
// Arena losses cost real money + equipment durability — never skip there,
|
||||
// even if the threat looks trivial. Adventure-side fights still skip
|
||||
// trivial threats to avoid wasting items on chump enemies.
|
||||
if threat == threatTrivial && arenaRound == 0 {
|
||||
if threat == threatTrivial && allowSkipTrivial && arenaRound == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
maxTier := maxConsumableTier(threat, arenaRound)
|
||||
if contentTier > 0 && contentTier < maxTier {
|
||||
maxTier = contentTier
|
||||
}
|
||||
|
||||
var bestOffensive, bestDefensive *ConsumableItem
|
||||
|
||||
for i := range inventory {
|
||||
item := &inventory[i]
|
||||
if item.Def.Tier > maxTier {
|
||||
continue
|
||||
}
|
||||
|
||||
switch item.Def.Slot {
|
||||
case "offensive":
|
||||
if bestOffensive == nil || betterOffensive(item, bestOffensive, threat) {
|
||||
@@ -167,23 +169,6 @@ func SelectConsumables(inventory []ConsumableItem, playerStats, enemyStats Comba
|
||||
return selected
|
||||
}
|
||||
|
||||
func maxConsumableTier(threat threatLevel, arenaRound int) int {
|
||||
base := 1
|
||||
switch threat {
|
||||
case threatEasy:
|
||||
base = 2
|
||||
case threatCompetitive:
|
||||
base = 3
|
||||
case threatDangerous:
|
||||
base = 5
|
||||
}
|
||||
// Arena: spend freely on later rounds
|
||||
if arenaRound >= 3 {
|
||||
base = min(5, base+1)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// betterOffensive prefers lowest tier that's appropriate for the threat.
|
||||
func betterOffensive(candidate, current *ConsumableItem, threat threatLevel) bool {
|
||||
if threat == threatDangerous {
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestSelectConsumables_TrivialFightSkips(t *testing.T) {
|
||||
weak := CombatStats{MaxHP: 30, Attack: 5}
|
||||
inv := makeInventory("Berry Poultice", "Coal Bomb")
|
||||
|
||||
selected := SelectConsumables(inv, strong, weak, 0, 0)
|
||||
selected := SelectConsumables(inv, strong, weak, 0, 0, true)
|
||||
if len(selected) != 0 {
|
||||
t.Errorf("should skip consumables for trivial fight, got %d", len(selected))
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func TestSelectConsumables_DangerousUsesHighTier(t *testing.T) {
|
||||
strong := CombatStats{MaxHP: 200, Attack: 50}
|
||||
inv := makeInventory("Berry Poultice", "Spirit Tonic", "Coal Bomb", "Ancient Artifact Oil")
|
||||
|
||||
selected := SelectConsumables(inv, weak, strong, 0, 0)
|
||||
selected := SelectConsumables(inv, weak, strong, 0, 0, true)
|
||||
if len(selected) != 2 {
|
||||
t.Fatalf("expected 2 consumables, got %d", len(selected))
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func TestSelectConsumables_MaxTwo(t *testing.T) {
|
||||
"Coal Bomb", "Goblin Grease", "Blooper Ink Vial",
|
||||
)
|
||||
|
||||
selected := SelectConsumables(inv, player, enemy, 0, 0)
|
||||
selected := SelectConsumables(inv, player, enemy, 0, 0, true)
|
||||
if len(selected) > 2 {
|
||||
t.Errorf("max 2 consumables, got %d", len(selected))
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestSelectConsumables_EasyUsesLowTier(t *testing.T) {
|
||||
|
||||
inv := makeInventory("Berry Poultice", "Spirit Tonic", "Coal Bomb", "Ancient Artifact Oil")
|
||||
|
||||
selected := SelectConsumables(inv, player, enemy, 0, 0)
|
||||
selected := SelectConsumables(inv, player, enemy, 0, 0, true)
|
||||
for _, s := range selected {
|
||||
if s.Def.Tier > 2 {
|
||||
t.Errorf("easy fight should use T1-T2, got %s (T%d)", s.Def.Name, s.Def.Tier)
|
||||
@@ -101,8 +101,8 @@ func TestSelectConsumables_ArenaRoundEscalation(t *testing.T) {
|
||||
|
||||
inv := makeInventory("Berry Poultice", "Spirit Tonic", "Coal Bomb", "Sapphire Elixir")
|
||||
|
||||
r1 := SelectConsumables(inv, player, enemy, 1, 0)
|
||||
r4 := SelectConsumables(inv, player, enemy, 4, 0)
|
||||
r1 := SelectConsumables(inv, player, enemy, 1, 0, true)
|
||||
r4 := SelectConsumables(inv, player, enemy, 4, 0, true)
|
||||
|
||||
maxTierR1 := 0
|
||||
for _, s := range r1 {
|
||||
|
||||
@@ -53,6 +53,9 @@ func (p *AdventurePlugin) eventTicker() {
|
||||
// Expire stale pending events every tick
|
||||
expireAdvPendingEvents()
|
||||
|
||||
// Auto-play any combat sessions past their 1h timeout.
|
||||
p.reapExpiredCombatSessions()
|
||||
|
||||
advEventScheduleMu.Lock()
|
||||
if advEventScheduleDay != dateKey {
|
||||
advEventSchedule = make(map[string]int)
|
||||
@@ -117,6 +120,13 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Mid-fight: a turn-based session locks the run. Don't drop a random
|
||||
// overworld event into a live fight — the player can't act on it without
|
||||
// finishing the fight first, and the trigger DM talks over the combat feed.
|
||||
if hasActiveCombatSession(userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// 0.5% chance
|
||||
if rand.Float64() >= 0.005 {
|
||||
return
|
||||
|
||||
@@ -79,7 +79,7 @@ var ClosingSuccess = []string{
|
||||
"in business and out of the deeper healthcare plans.\n\n" +
|
||||
"Next action: {reset_time} ({time_until})\n" +
|
||||
"Morning DM: {morning_time} UTC\n" +
|
||||
"Evening summary: {summary_time} UTC — TwinBee will be there.\n\n" +
|
||||
"Evening summary: {summary_time} UTC — I will be there.\n\n" +
|
||||
"So will everyone else who showed up today.",
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ var ClosingFailure = []string{
|
||||
"So will you, which is the important part.\n\n" +
|
||||
"Next action: {reset_time} ({time_until})\n" +
|
||||
"Tomorrow's choices at {morning_time} UTC.\n" +
|
||||
"TwinBee had a better day. The summary at {summary_time} UTC will confirm this.\n\n" +
|
||||
"I had a better day. The summary at {summary_time} UTC will confirm this.\n\n" +
|
||||
"Rest. You've earned the rest, at least.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
|
||||
@@ -353,13 +353,13 @@ var SummaryDungeonExceptional = []string{
|
||||
}
|
||||
|
||||
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.",
|
||||
"Died in {location}. Back in {duration}. The {location} remains standing.",
|
||||
"{location}: death. Equipment damaged. Healthcare involved. {duration} respawn.",
|
||||
"Did not survive {location}. The {location} did. {duration} recovery.",
|
||||
"Killed in {location}. American healthcare has the rest. {duration}.",
|
||||
"{location} wins this round. {name} recovers in {duration}.",
|
||||
"Dead. {location}. {duration}. The equipment is worse.",
|
||||
"{name} lost the argument with {location}. Healthcare is mediating. {duration}.",
|
||||
}
|
||||
|
||||
var SummaryDungeonEmpty = []string{
|
||||
@@ -381,11 +381,11 @@ var SummaryMiningSuccess = []string{
|
||||
}
|
||||
|
||||
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.",
|
||||
"Cave-in. {location}. Healthcare called. {duration}.",
|
||||
"Died in {location}. Mining death. Rarer than dungeon death. Still death. {duration}.",
|
||||
"{location} expressed structural concerns physically. {name}: {duration} recovery.",
|
||||
"The {location} ceiling had a perspective. {name}: medical. {duration}.",
|
||||
"Dead in {location}. The ore is still in there. {duration}.",
|
||||
}
|
||||
|
||||
var SummaryMiningEmpty = []string{
|
||||
@@ -412,10 +412,10 @@ var SummaryForagingEmpty = []string{
|
||||
}
|
||||
|
||||
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.",
|
||||
"Died foraging. In {location}. Yes, foraging. {duration}.",
|
||||
"{location} produced a fatal outcome via non-combat means. {duration}.",
|
||||
"Dead in {location}. Bear/hornets/river/tree/mushroom involvement. {duration}.",
|
||||
"Foraging death. {location}. The forest won. {duration}.",
|
||||
}
|
||||
|
||||
var SummaryForagingHornets = []string{
|
||||
@@ -442,7 +442,7 @@ var SummaryResting = []string{
|
||||
"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.",
|
||||
"Rest day. I noticed.",
|
||||
}
|
||||
|
||||
var SummaryDead = []string{
|
||||
@@ -468,7 +468,7 @@ var SummaryStandoutDeath = []string{
|
||||
"💀 {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.",
|
||||
"💀 {location} claimed {name}. {duration} recovery. The dungeon has been noted.",
|
||||
}
|
||||
|
||||
var SummaryStandoutTreasure = []string{
|
||||
@@ -487,8 +487,8 @@ var SummaryStandoutHornets = []string{
|
||||
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.",
|
||||
"Cleared {location}. {loot}, €{value}. I have reported this as typical.",
|
||||
"{loot} worth €{value} from {location}. I am unsurprised.",
|
||||
"Professional operation in {location}. {loot}, €{value}. No further comment.",
|
||||
}
|
||||
|
||||
@@ -497,11 +497,11 @@ var TwinBeeSummaryWithdrawal = []string{
|
||||
"{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.",
|
||||
"{location} assessed. I return 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.",
|
||||
"Visited {location}. Nothing there today. I note this for the record.",
|
||||
"{location}: empty. The dungeon has failed to provide. I have noted it.",
|
||||
"Nothing found in {location}. I am filing feedback with the dungeon.",
|
||||
}
|
||||
|
||||
@@ -7,268 +7,268 @@ package plugin
|
||||
// from victories, looked at the right way, which is TwinBee's way.
|
||||
|
||||
var TwinBeeSuccess = []string{
|
||||
"TwinBee descended into {location} with characteristic enthusiasm " +
|
||||
"I 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 " +
|
||||
"I have 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 " +
|
||||
"Another flawless operation. I entered {location}, assessed the situation " +
|
||||
"with professional precision, and extracted {loot} worth €{value} " +
|
||||
"with minimal collateral damage to TwinBee specifically. " +
|
||||
"with minimal collateral damage to me specifically. " +
|
||||
"The dungeon has been informed it performed adequately. " +
|
||||
"TwinBee does not give compliments freely. This was a compliment.",
|
||||
"I do 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. " +
|
||||
"I have returned from {location} victorious, which is to say " +
|
||||
"I have returned from {location}, because for me these are the same thing. " +
|
||||
"{loot} worth €{value}. {xp} XP that I do not need " +
|
||||
"but accept 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.' " +
|
||||
"I located {loot} worth €{value} in {location} after what I am " +
|
||||
"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 the same debrief. I am generous with these things.",
|
||||
|
||||
"In. Retrieved {loot}. Out. €{value}. {xp} XP. " +
|
||||
"TwinBee has the efficiency of someone who has done this many times " +
|
||||
"I have 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 " +
|
||||
"I report: {location} cleared, {loot} secured, €{value} assessed, " +
|
||||
"one near-miss that I am not classifying as a near-miss " +
|
||||
"because I do not have near-misses, I have " +
|
||||
"'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 — " +
|
||||
"The {location} boss encountered me today. " +
|
||||
"This is how I prefer to frame the encounter — " +
|
||||
"the boss encountered me, 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 " +
|
||||
"I went to {location} and came back with {loot} worth €{value}, " +
|
||||
"which is what I do, and {xp} XP, which I acknowledge " +
|
||||
"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.",
|
||||
"My equipment: immaculate. Me: as expected.",
|
||||
|
||||
"TwinBee extracted {loot} worth €{value} from {location} " +
|
||||
"I 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.",
|
||||
"who genuinely enjoys doing this, which I do, " +
|
||||
"which is why I am better at it than everyone else, " +
|
||||
"which I am.",
|
||||
|
||||
"The monsters in {location} gave it their best today. " +
|
||||
"TwinBee noticed this and respected it for the duration of the encounter, " +
|
||||
"I 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, " +
|
||||
"I have returned in excellent spirits, which is not news, " +
|
||||
"but it is accurate.",
|
||||
|
||||
"TwinBee's route through {location} was, according to TwinBee's own review, " +
|
||||
"My route through {location} was, according to my 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.",
|
||||
"I returned with {loot} worth €{value} and a complete absence " +
|
||||
"of anything that went wrong, which is my preferred kind of absence.",
|
||||
|
||||
"A lesser adventurer would have found {location} challenging today. " +
|
||||
"TwinBee found it brisk. {loot}, €{value}, {xp} XP, " +
|
||||
"I 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.",
|
||||
"to be an appropriately-sized problem. I appreciate appropriate sizing.",
|
||||
|
||||
"TwinBee entered {location} from the north approach, which TwinBee has determined " +
|
||||
"I entered {location} from the north approach, which I have 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.",
|
||||
"I have 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.",
|
||||
"I returned in the condition I left in, " +
|
||||
"which is optimal, because I am 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' — " +
|
||||
"OUTSTANDING PERFORMANCE. I located and neutralised the dungeon boss " +
|
||||
"— which I describe 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.",
|
||||
"This is, by my reckoning, a typical Tuesday. " +
|
||||
"I have 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 " +
|
||||
"I found {loot} worth €{value} in {location} after what I am " +
|
||||
"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.",
|
||||
"I noted it first. I note everything first.",
|
||||
|
||||
"The inner chamber. TwinBee reached the inner chamber. " +
|
||||
"TwinBee notes that reaching the inner chamber requires " +
|
||||
"The inner chamber. I reached the inner chamber. " +
|
||||
"I note 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. " +
|
||||
"which I navigated successfully, and then {loot} worth €{value} " +
|
||||
"was in the inner chamber, and I 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 " +
|
||||
"A boss encounter. My assessment of the boss: 'interesting.' " +
|
||||
"The boss's assessment of me: insufficient time to complete. " +
|
||||
"{loot} worth €{value}. I have forwarded the boss's personnel file " +
|
||||
"to the relevant afterlife authorities with a positive reference.",
|
||||
|
||||
"EXCEPTIONAL. TwinBee's word, used sparingly. " +
|
||||
"EXCEPTIONAL. My word, used sparingly. " +
|
||||
"Today merits it. {loot} worth €{value} from the deepest chamber " +
|
||||
"of {location}, retrieved past opposition that TwinBee describes " +
|
||||
"of {location}, retrieved past opposition that I describe " +
|
||||
"as 'the most interesting opposition I've faced this week,' " +
|
||||
"which is high praise from TwinBee and accurately assesses the week so far.",
|
||||
"which is high praise from me and accurately assesses the week so far.",
|
||||
|
||||
"TwinBee found something the dungeon wasn't offering publicly. " +
|
||||
"TwinBee finds things dungeons aren't offering publicly. " +
|
||||
"I found something the dungeon wasn't offering publicly. " +
|
||||
"I find 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 " +
|
||||
"The dungeon has updated its offering. I approve 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}. " +
|
||||
"I submit 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. " +
|
||||
"The boss was large. I am here. " +
|
||||
"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.",
|
||||
"and a story that I am telling to everyone and will continue telling " +
|
||||
"until a better story replaces it, which will also be my story.",
|
||||
}
|
||||
|
||||
var TwinBeeWithdrawal = []string{
|
||||
"TwinBee has completed today's operation and is taking the remainder " +
|
||||
"I have completed today's operation and am 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.",
|
||||
"I have already moved on and suggest you do the same.",
|
||||
|
||||
"TwinBee assessed {location} with characteristic thoroughness, " +
|
||||
"I 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.",
|
||||
"before the situation became interesting in ways I did not schedule. " +
|
||||
"This is called professionalism. I have it.",
|
||||
|
||||
"Today's operation at {location} concluded earlier than projected. " +
|
||||
"TwinBee encountered something very large, evaluated the encounter on its merits, " +
|
||||
"I 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.",
|
||||
"I was not running. I do not run. " +
|
||||
"I was moving with purpose in the opposite direction. These are different.",
|
||||
|
||||
"TwinBee left {location} at a time of TwinBee's choosing, " +
|
||||
"I left {location} at a time of my own choosing, " +
|
||||
"which happened to coincide with the arrival of something " +
|
||||
"that TwinBee is describing in the official report as 'a scheduling conflict.' " +
|
||||
"that I am 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.",
|
||||
"At a time that suits me.",
|
||||
|
||||
"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. " +
|
||||
"via the medium of my departure, that today was not the day. " +
|
||||
"I know 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 — " +
|
||||
"I 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, " +
|
||||
"Something in {location} today required me to make a decision. " +
|
||||
"I made the decision. The decision was: not today. " +
|
||||
"My 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.",
|
||||
"when I return tomorrow and the thing is no longer a factor. " +
|
||||
"These things always work out when I decide 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 " +
|
||||
"I encountered something in {location} that I am describing " +
|
||||
"as 'an opportunity to demonstrate strategic patience.' " +
|
||||
"TwinBee demonstrated strategic patience. The patience was demonstrated " +
|
||||
"I 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: " +
|
||||
"Official statement from me 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.",
|
||||
"My 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, " +
|
||||
"I am back. The thing in {location} is also still there. " +
|
||||
"I am aware of the thing. The thing is aware of me. " +
|
||||
"This is a stalemate that I will resolve on my own timeline, " +
|
||||
"which is different from the thing's timeline, " +
|
||||
"and TwinBee's timeline is the one that matters.",
|
||||
"and my timeline is the one that matters.",
|
||||
|
||||
"The {location} situation has been described by TwinBee as " +
|
||||
"'temporarily inconclusive,' which is a phrase TwinBee uses " +
|
||||
"The {location} situation has been described, by me, as " +
|
||||
"'temporarily inconclusive,' which is a phrase I use " +
|
||||
"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.",
|
||||
"and I have chosen to revisit the position on better terms. " +
|
||||
"The terms will be better. I will see to it.",
|
||||
|
||||
"Today, TwinBee gathered extensive tactical data on {location}. " +
|
||||
"Today, I 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.",
|
||||
"that I have 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. " +
|
||||
"My return today is best understood as a 'victory lap around the outside,' " +
|
||||
"which is a phrase I have 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.",
|
||||
"I am 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. " +
|
||||
"Something the size of a building expressed interest in me at depth. " +
|
||||
"I expressed equal and opposite interest in being elsewhere. " +
|
||||
"Both interests were satisfied. I am 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.",
|
||||
"I have returned from {location} and wish it to be known " +
|
||||
"that the dungeon was empty, which I attribute entirely to my reputation " +
|
||||
"preceding me. The monsters left. They knew. " +
|
||||
"I respect this. I 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. " +
|
||||
"My intelligence was incorrect. " +
|
||||
"My 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. " +
|
||||
"and one confused bat that I have 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. " +
|
||||
"{location} was cleared. By whom is unclear. I suspect a rival. " +
|
||||
"I do not have rivals, technically, " +
|
||||
"but am 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.",
|
||||
"which I attribute to the universe's temporary misalignment " +
|
||||
"with my interests. The universe will correct itself. " +
|
||||
"I am 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. " +
|
||||
"I showed up. I was prepared. I 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 — " +
|
||||
"I cleared four rooms of {location} and found nothing worth the trip. " +
|
||||
"I am not saying the trip was not worth it — I do 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.",
|
||||
"and I 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, " +
|
||||
"The treasure was already gone when I arrived, " +
|
||||
"which means someone got there before me, " +
|
||||
"which is a sentence that has never previously been true " +
|
||||
"and which TwinBee is treating as a statistical anomaly " +
|
||||
"and which I am 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. " +
|
||||
"Just me in {location} swinging at things that weren't there. " +
|
||||
"I have described this run as 'character-building.' " +
|
||||
"My character was already built. " +
|
||||
"This run has contributed nothing to the structure.",
|
||||
}
|
||||
|
||||
@@ -530,7 +530,7 @@ var RespawnDM = []string{
|
||||
|
||||
var IdleShameDM = []string{
|
||||
"{name}, hey. Didn't see you out there today.\n\n" +
|
||||
"TwinBee swung by the hovel — door was shut, no answer.\n" +
|
||||
"I swung by the hovel — door was shut, no answer.\n" +
|
||||
"Hope you're alright. The dungeon will keep. The mine will keep.\n" +
|
||||
"They've been there a while; they're patient.\n\n" +
|
||||
"Tomorrow: 08:00 UTC. We'll be ready when you are.",
|
||||
@@ -538,16 +538,16 @@ var IdleShameDM = []string{
|
||||
"Quiet day for you, {name}.\n\n" +
|
||||
"The morning DM went out and didn't come back. That happens.\n" +
|
||||
"Real life sometimes shows up uninvited and stays for dinner.\n" +
|
||||
"TwinBee picked up a little extra in the field — covered for you.\n\n" +
|
||||
"I picked up a little extra in the field — covered for you.\n\n" +
|
||||
"Tomorrow morning the choices reset. Drop by when you can.",
|
||||
|
||||
"{name}, no sign of you in the field today.\n\n" +
|
||||
"The forest is still there. So's the mine. So's that one rusted sword\n" +
|
||||
"you keep meaning to replace. Nothing important moved.\n\n" +
|
||||
"08:00 UTC tomorrow — fresh menu, same patient TwinBee.",
|
||||
"08:00 UTC tomorrow — fresh menu, same patient narrator.",
|
||||
|
||||
"Missed you out there, {name}.\n\n" +
|
||||
"TwinBee went on a haul today and saved a corner of the splits for you,\n" +
|
||||
"I went on a haul today and saved a corner of the splits for you,\n" +
|
||||
"then ate it on principle when you didn't show. (Sorry. House rule.)\n" +
|
||||
"Nothing's lost that can't be earned back.\n\n" +
|
||||
"Tomorrow: 08:00 UTC. The dungeons are warm.",
|
||||
@@ -558,7 +558,7 @@ var IdleShameDM = []string{
|
||||
"08:00 UTC. Whenever you're back, we pick up where we left off.",
|
||||
|
||||
"{name}. Hope you had a good rest day.\n\n" +
|
||||
"TwinBee held down the fort. Nothing dramatic, nothing lost —\n" +
|
||||
"I held down the fort. Nothing dramatic, nothing lost —\n" +
|
||||
"the world declined to end on your behalf, which feels generous.\n\n" +
|
||||
"When you're ready, the morning DM hits at 08:00 UTC. No rush.",
|
||||
}
|
||||
@@ -584,8 +584,8 @@ var OnboardingDM = []string{
|
||||
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.",
|
||||
"Where are you headed? I would choose the dungeon.\n" +
|
||||
"I would also survive it. You are not TwinBee. Choose wisely.",
|
||||
|
||||
"⚔️ {name}. You're alive. The options are:\n\n" +
|
||||
"{character_sheet}\n\n" +
|
||||
@@ -609,6 +609,6 @@ var MorningDM = []string{
|
||||
"{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" +
|
||||
"I will know. I always know.\n" +
|
||||
"Reply to choose.",
|
||||
}
|
||||
|
||||
@@ -202,8 +202,8 @@ func petShouldArrive(pet PetState, house HouseState) bool {
|
||||
if pet.ChasedAway {
|
||||
return pet.Reactivated
|
||||
}
|
||||
// 15% daily chance after house tier 1
|
||||
return rand.Float64() < 0.15
|
||||
// 30% daily chance after house tier 1 (~3 days average to arrival)
|
||||
return rand.Float64() < 0.30
|
||||
}
|
||||
|
||||
// petArrivalDM sends the initial "there's an animal in your house" DM.
|
||||
@@ -221,7 +221,7 @@ func (p *AdventurePlugin) petArrivalDM(userID id.UserID) {
|
||||
p.pending.Store(string(userID), &advPendingInteraction{
|
||||
Type: "pet_arrival",
|
||||
Data: &advPendingPetArrival{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
_ = p.SendDM(userID, text)
|
||||
}
|
||||
@@ -258,7 +258,7 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_type",
|
||||
Data: &advPendingPetType{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
@@ -267,7 +267,7 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_arrival",
|
||||
Data: &advPendingPetArrival{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, "Reply with `chase` or `feed`.")
|
||||
}
|
||||
@@ -291,7 +291,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error {
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_type",
|
||||
Data: &advPendingPetType{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, "Reply with `dog` or `cat`.")
|
||||
}
|
||||
@@ -299,7 +299,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error {
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_name",
|
||||
Data: &advPendingPetName{PetType: petType},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||
})
|
||||
|
||||
article := "a"
|
||||
|
||||
@@ -250,7 +250,7 @@ func craftingTeaserText(foragingSkill, craftsSucceeded int, userID id.UserID) st
|
||||
// Post-unlock: nudge when no successful crafts yet (gentle), or once a
|
||||
// week (loose periodicity via day-of-year %).
|
||||
if craftsSucceeded == 0 {
|
||||
return "🧪 **Crafting is unlocked.** Gather a couple of matching ingredients and TwinBee will auto-craft consumables — try `!adventure recipes` to see what your level supports."
|
||||
return "🧪 **Crafting is unlocked.** Gather a couple of matching ingredients and I will auto-craft consumables — try `!adventure recipes` to see what your level supports."
|
||||
}
|
||||
if int(time.Now().UTC().Weekday()) == craftingReminderWeekday(userID, time.Now().UTC()) {
|
||||
return "🧪 *Crafting reminder* — `!adventure recipes` shows what's available at Foraging Lv." + fmt.Sprintf("%d.", foragingSkill)
|
||||
@@ -375,7 +375,7 @@ func renderAdvMorningDM(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment,
|
||||
sb.WriteString("**🗺️ Adventure** — head into a zone:\n")
|
||||
sb.WriteString("• `!expedition` — overview & open expeditions\n")
|
||||
sb.WriteString("• `!expedition start <zone>` — begin a new run\n")
|
||||
sb.WriteString("• `!forage` · `!mine` · `!scavenge` · `!fish` · `!essence` · `!commune` — harvest in cleared rooms\n")
|
||||
sb.WriteString("• Harvest is automatic — yields land as you walk through cleared rooms.\n")
|
||||
sb.WriteString("\n")
|
||||
|
||||
sb.WriteString("**🏘️ In town:**\n")
|
||||
@@ -735,6 +735,15 @@ type AdvPlayerDaySummary struct {
|
||||
LootValue int64
|
||||
IsDead bool
|
||||
DeadUntil string
|
||||
// DeadUntilHours is the integer hours-from-now until revival, used by
|
||||
// the standout-death template's {hours} placeholder. Computed when
|
||||
// the summary row is built; 0 if not dead or already past revival.
|
||||
DeadUntilHours int
|
||||
// DeadUntilDuration is the human-readable hours+minutes form of the
|
||||
// remaining respawn time ("5h 27m", "47m", "2h"). Used by the
|
||||
// {duration} placeholder for templates that want precision over the
|
||||
// rounded {hours} count.
|
||||
DeadUntilDuration string
|
||||
IsResting bool
|
||||
SummaryLine string
|
||||
HolidayActions int // 0 = not holiday or no action; 1 = took one; 2 = took both
|
||||
@@ -827,7 +836,7 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
||||
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers: jackshit.\n", tbRewards.Eligible))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n(Players who rested today received nothing. Fallen adventurers still earn their share. TwinBee noticed.)\n\n")
|
||||
sb.WriteString("\n(Players who rested today received nothing. Fallen adventurers still earn their share. I noticed.)\n\n")
|
||||
sb.WriteString("───────────────────\n\n")
|
||||
}
|
||||
|
||||
@@ -933,9 +942,19 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
||||
if lossLoc == "" {
|
||||
lossLoc = worstPlayer.Location
|
||||
}
|
||||
hours := worstPlayer.DeadUntilHours
|
||||
if hours <= 0 {
|
||||
hours = 6 // canonical respawn duration when revival time isn't computable
|
||||
}
|
||||
duration := worstPlayer.DeadUntilDuration
|
||||
if duration == "" {
|
||||
duration = fmt.Sprintf("%dh", hours)
|
||||
}
|
||||
line = advSubstituteFlavor(line, map[string]string{
|
||||
"{name}": worstPlayer.DisplayName,
|
||||
"{location}": lossLoc,
|
||||
"{hours}": fmt.Sprintf("%d", hours),
|
||||
"{duration}": duration,
|
||||
})
|
||||
sb.WriteString(fmt.Sprintf("🏆 **Today's standout:** %s\n", line))
|
||||
}
|
||||
|
||||
@@ -98,6 +98,26 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Mid-fight: a turn-based elite/boss session locks the run. The
|
||||
// per-round combat DMs are the player's feed right now — don't talk
|
||||
// over them with the overworld morning menu. (A combat session always
|
||||
// sits inside an active expedition, so the expedition skip below would
|
||||
// usually catch this too; the explicit guard is cheap insurance.)
|
||||
if char.Alive && hasActiveCombatSession(char.UserID) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Active expedition: the expedition cycle delivers its own morning
|
||||
// briefing at 06:00 UTC (deliverBriefing). The legacy overworld
|
||||
// morning DM is irrelevant — and confusing — while underground.
|
||||
if char.Alive {
|
||||
if exp, err := getActiveExpedition(char.UserID); err != nil {
|
||||
slog.Warn("adventure: failed to check active expedition for morning DM", "user", char.UserID, "err", err)
|
||||
} else if exp != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// If still dead, send death status
|
||||
if !char.Alive {
|
||||
text := renderAdvDeathStatusDM(char.UserID)
|
||||
@@ -261,6 +281,22 @@ func (p *AdventurePlugin) postDailySummary() {
|
||||
ps.DeathLocation = c.DeathLocation
|
||||
if c.DeadUntil != nil {
|
||||
ps.DeadUntil = c.DeadUntil.Format("15:04") + " UTC"
|
||||
remaining := time.Until(*c.DeadUntil)
|
||||
if remaining > 0 {
|
||||
hrs := int(remaining.Hours())
|
||||
mins := int(remaining.Minutes()) - hrs*60
|
||||
// {hours}: round half-up integer hours.
|
||||
ps.DeadUntilHours = int(remaining.Hours() + 0.5)
|
||||
// {duration}: precise — "5h 27m", "47m", "2h".
|
||||
switch {
|
||||
case hrs > 0 && mins > 0:
|
||||
ps.DeadUntilDuration = fmt.Sprintf("%dh %dm", hrs, mins)
|
||||
case hrs > 0:
|
||||
ps.DeadUntilDuration = fmt.Sprintf("%dh", hrs)
|
||||
default:
|
||||
ps.DeadUntilDuration = fmt.Sprintf("%dm", mins)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(acts) > 0 {
|
||||
last := acts[len(acts)-1]
|
||||
@@ -375,6 +411,35 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
continue
|
||||
}
|
||||
|
||||
// An active expedition — or a turn-based fight locked open across
|
||||
// midnight — counts as activity. Both track their own action flow
|
||||
// (zone/harvest/combat/transit/extract) and never touch the legacy
|
||||
// CombatActionsUsed/HarvestActionsUsed counters, so HasActedToday()
|
||||
// reports false. Treat them like the acted-today branch below:
|
||||
// advance the streak and bail out (no idle-shame, no streak decay).
|
||||
busy := false
|
||||
if exp, err := getActiveExpedition(char.UserID); err != nil {
|
||||
slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err)
|
||||
} else if exp != nil {
|
||||
busy = true
|
||||
}
|
||||
if !busy && hasActiveCombatSession(char.UserID) {
|
||||
busy = true
|
||||
}
|
||||
if busy {
|
||||
if char.LastActionDate == yesterday || char.LastActionDate == today {
|
||||
char.CurrentStreak++
|
||||
} else {
|
||||
char.CurrentStreak = 1
|
||||
}
|
||||
if char.CurrentStreak > char.BestStreak {
|
||||
char.BestStreak = char.CurrentStreak
|
||||
}
|
||||
char.LastActionDate = today
|
||||
_ = saveAdvCharacter(&char)
|
||||
continue
|
||||
}
|
||||
|
||||
// Jitter between DMs to avoid Matrix rate limits
|
||||
if dmsSent > 0 {
|
||||
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -193,9 +194,12 @@ func luigiShopGreeting(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment,
|
||||
sb.WriteString(fmt.Sprintf("🛒 **Luigi's**\n💰 Balance: €%.0f\n\n", balance))
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", greet))
|
||||
|
||||
// Two-column grid; the Exit chip squares the layout so Curios doesn't
|
||||
// dangle on its own row and players have a one-word out from here.
|
||||
sb.WriteString("⚔️ Weapons 🛡️ Armor\n")
|
||||
sb.WriteString("🪖 Helmets 👢 Boots\n")
|
||||
sb.WriteString("⛏️ Tools 🧪 Supplies\n\n")
|
||||
sb.WriteString("⛏️ Tools 🧪 Supplies\n")
|
||||
sb.WriteString("🔮 Curios 🚪 Exit\n\n")
|
||||
|
||||
if showAll {
|
||||
flavor, _ := advPickFlavor(luigiShowAllComment, userID, "luigi_showall")
|
||||
@@ -381,11 +385,25 @@ func (p *AdventurePlugin) resolveShopCategoryChoice(ctx MessageContext, interact
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
// Curios — Open5e magic items, daily rotating stock.
|
||||
if reply == "curios" || reply == "curio" || reply == "magic" || reply == "trinkets" {
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
text := p.luigiCuriosView(ctx.Sender, balance)
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "shop_curio",
|
||||
Data: data,
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
p.advMarkMenuSent(ctx.Sender)
|
||||
p.shopScheduleBrowseNudge(ctx.Sender)
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
slot := advParseShopCategory(reply)
|
||||
if slot == "" {
|
||||
// Re-store pending and reprompt.
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "I didn't catch that. Reply with a category: weapons, armor, helmets, boots, tools, or supplies.")
|
||||
return p.SendDM(ctx.Sender, "I didn't catch that. Reply with a category: weapons, armor, helmets, boots, tools, supplies, or curios.")
|
||||
}
|
||||
|
||||
_, equip, err := p.ensureCharacter(ctx.Sender)
|
||||
@@ -825,11 +843,19 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
|
||||
var sold int
|
||||
var keptSpecial int
|
||||
var keptConsumable int
|
||||
var keptMagic int
|
||||
for _, item := range items {
|
||||
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" {
|
||||
keptSpecial++
|
||||
continue
|
||||
}
|
||||
if item.Type == "magic_item" {
|
||||
// B1: magic items are bonded curios, not bulk loot — sell-all must
|
||||
// never silently delete them. Route the player to !adventure
|
||||
// equip-magic instead.
|
||||
keptMagic++
|
||||
continue
|
||||
}
|
||||
if item.Type == "consumable" {
|
||||
keptConsumable++
|
||||
continue
|
||||
@@ -843,6 +869,10 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
|
||||
|
||||
if sold == 0 {
|
||||
switch {
|
||||
case keptMagic > 0 && (keptSpecial > 0 || keptConsumable > 0):
|
||||
return "Your inventory only contains special gear, curios, and/or consumables. The merchant won't touch any of it. Use `!adventure equip-magic` for curios, `!adventure equip` for gear, or `!adventure sell <name>` for individual consumables."
|
||||
case keptMagic > 0:
|
||||
return "Your inventory only contains curios (magic items). The merchant doesn't deal in those. Use `!adventure equip-magic` instead."
|
||||
case keptSpecial > 0 && keptConsumable > 0:
|
||||
return "Your inventory only contains special gear and consumables. The merchant won't touch any of it. Use `!adventure equip` for gear or `!adventure sell <name>` for individual consumables."
|
||||
case keptSpecial > 0:
|
||||
@@ -870,6 +900,9 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
|
||||
if keptConsumable > 0 {
|
||||
msg += fmt.Sprintf("\n(%d consumable(s) kept — `sell all` doesn't touch them. Sell explicitly with `!adventure sell <name>`.)", keptConsumable)
|
||||
}
|
||||
if keptMagic > 0 {
|
||||
msg += fmt.Sprintf("\n(%d curio(s) kept — `!adventure equip-magic` to bond them. The merchant wouldn't know what to do with them.)", keptMagic)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
@@ -885,6 +918,9 @@ func (p *AdventurePlugin) advSellItem(userID id.UserID, itemName string) string
|
||||
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" {
|
||||
return fmt.Sprintf("**%s** is special gear. The merchant won't touch it. Use `!adventure equip` to equip it.", item.Name)
|
||||
}
|
||||
if item.Type == "magic_item" {
|
||||
return fmt.Sprintf("**%s** is a curio — the merchant doesn't deal in those. Use `!adventure equip-magic` to bond it instead.", item.Name)
|
||||
}
|
||||
if err := removeAdvInventoryItem(item.ID); err != nil {
|
||||
return "Failed to sell that item."
|
||||
}
|
||||
@@ -917,14 +953,22 @@ func advInventoryDisplay(userID id.UserID) string {
|
||||
sb.WriteString("🎒 **Inventory**\n\n")
|
||||
|
||||
var total int64
|
||||
hasMagic := false
|
||||
for _, item := range items {
|
||||
if item.Type == "MasterworkGear" {
|
||||
switch item.Type {
|
||||
case "MasterworkGear":
|
||||
sb.WriteString(fmt.Sprintf(" ⭐ %s (T%d Masterwork %s)\n", item.Name, item.Tier, slotTitle(item.Slot)))
|
||||
} else if item.Type == "ArenaGear" {
|
||||
case "ArenaGear":
|
||||
sb.WriteString(fmt.Sprintf(" ⚔️ %s (T%d Arena %s)\n", item.Name, item.Tier, slotTitle(item.Slot)))
|
||||
} else if item.Type == "card" {
|
||||
case "card":
|
||||
sb.WriteString(fmt.Sprintf(" 🃏 %s\n", item.Name))
|
||||
} else {
|
||||
case "magic_item":
|
||||
// Tag magic items so they read as a separate class — they don't
|
||||
// behave like sellable loot, and the equip-magic footer below
|
||||
// only fires when the player actually has one.
|
||||
hasMagic = true
|
||||
sb.WriteString(fmt.Sprintf(" 🔮 %s (%s)\n", item.Name, magicItemRarityLabel(item.Tier)))
|
||||
default:
|
||||
sb.WriteString(fmt.Sprintf(" %s (T%d %s) — €%d\n", item.Name, item.Tier, item.Type, item.Value))
|
||||
total += item.Value
|
||||
}
|
||||
@@ -932,9 +976,31 @@ func advInventoryDisplay(userID id.UserID) string {
|
||||
sb.WriteString(fmt.Sprintf("\n%d items — sellable value ~€%d", len(items), total))
|
||||
sb.WriteString("\n\nTo sell: `!adventure sell all` or `!adventure sell <item>`")
|
||||
sb.WriteString("\nTo equip Masterwork gear: `!adventure equip`")
|
||||
if hasMagic {
|
||||
sb.WriteString("\nTo wear a 🔮 curio: `!adventure equip-magic`")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// magicItemRarityLabel pretty-prints the tier number magic items use as a
|
||||
// rarity stand-in. Inventory is the only surface that sees the bare tier;
|
||||
// the shop builds its rarity string from the registry directly.
|
||||
func magicItemRarityLabel(tier int) string {
|
||||
switch tier {
|
||||
case 1:
|
||||
return "Common"
|
||||
case 2:
|
||||
return "Uncommon"
|
||||
case 3:
|
||||
return "Rare"
|
||||
case 4:
|
||||
return "Very Rare"
|
||||
case 5:
|
||||
return "Legendary"
|
||||
}
|
||||
return fmt.Sprintf("T%d", tier)
|
||||
}
|
||||
|
||||
// ── Supplies (Consumables) ──────────────────────────────────────────────────
|
||||
|
||||
func luigiSuppliesView(_ id.UserID, balance float64) string {
|
||||
@@ -1048,3 +1114,216 @@ func (p *AdventurePlugin) resolveShopSupplyChoice(ctx MessageContext, interactio
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Purchased **%s** for €%.0f. Added to inventory.\n💰 Balance: €%.0f\n\nReply with another item name or `back` to return.",
|
||||
match.Name, consumablePrice, newBalance))
|
||||
}
|
||||
|
||||
// ── Curios (Magic Items) ────────────────────────────────────────────────────
|
||||
|
||||
// curiosStockSize — how many registry magic items Luigi stocks per day.
|
||||
const curiosStockSize = 8
|
||||
|
||||
// dailyCuriosStock returns the deterministic per-day rotation of magic items
|
||||
// on Luigi's Curios shelf. Seeded by UTC date so the slate is stable for the
|
||||
// day and identical for every player. 237 registry items is far too many to
|
||||
// list, so a rotating shelf keeps the menu legible and gives players a reason
|
||||
// to check back.
|
||||
//
|
||||
// The day flips at 06:00 UTC instead of midnight so EU-evening players see
|
||||
// "yesterday's stock" through the night and a fresh shelf with their morning
|
||||
// coffee, not at 1 a.m. mid-session.
|
||||
func dailyCuriosStock() []MagicItem {
|
||||
ids := make([]string, 0, len(magicItemRegistry))
|
||||
for id := range magicItemRegistry {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
|
||||
// FNV-1a over the offset-UTC date string → PCG seed.
|
||||
day := time.Now().UTC().Add(-6 * time.Hour).Format("2006-01-02")
|
||||
var seed uint64 = 1469598103934665603
|
||||
for _, b := range []byte(day) {
|
||||
seed ^= uint64(b)
|
||||
seed *= 1099511628211
|
||||
}
|
||||
rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
|
||||
rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })
|
||||
|
||||
n := curiosStockSize
|
||||
if n > len(ids) {
|
||||
n = len(ids)
|
||||
}
|
||||
stock := make([]MagicItem, 0, n)
|
||||
for _, id := range ids[:n] {
|
||||
stock = append(stock, magicItemRegistry[id])
|
||||
}
|
||||
// Stable display order: rarity ascending, then name.
|
||||
sort.Slice(stock, func(i, j int) bool {
|
||||
ri, rj := rarityPowerScalar(stock[i].Rarity), rarityPowerScalar(stock[j].Rarity)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return stock[i].Name < stock[j].Name
|
||||
})
|
||||
return stock
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) luigiCuriosView(userID id.UserID, balance float64) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
|
||||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
|
||||
|
||||
factor := p.shopSessionPriceFactor(userID)
|
||||
for _, mi := range dailyCuriosStock() {
|
||||
price := float64(mi.Value) * factor
|
||||
// Pretty rarity ("Very Rare" not "very_rare"), drop the bare "wondrous"
|
||||
// (the effect line already tells the player what it does), and call
|
||||
// attunement what it actually means.
|
||||
tag := magicItemRarityLabel(rarityLootTierNum(mi.Rarity))
|
||||
if mi.Kind != MagicItemWondrous {
|
||||
tag = string(prettyMagicItemKind(mi.Kind)) + " · " + tag
|
||||
}
|
||||
if mi.Attunement {
|
||||
tag += " · needs bonding"
|
||||
}
|
||||
if mi.Kind == MagicItemPotion || mi.Kind == MagicItemScroll {
|
||||
tag += " · auto-uses"
|
||||
}
|
||||
afford := "✅"
|
||||
if balance < price {
|
||||
afford = "💸"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("**%s** _(%s)_ — €%.0f %s\n", mi.Name, tag, price, afford))
|
||||
// The codified effect is what actually fires in combat; the SRD desc
|
||||
// is flavour. Lead with the effect, then the flavour line.
|
||||
if eff := magicItemEffectSummary(mi); eff != "" && eff != "no combat effect" {
|
||||
sb.WriteString(fmt.Sprintf(" → %s\n", eff))
|
||||
}
|
||||
if mi.Desc != "" {
|
||||
sb.WriteString(fmt.Sprintf(" _%s_\n", mi.Desc))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\nReply with an item name to buy, or `back` to return.\n")
|
||||
sb.WriteString("_Bonding_ = at most 3 magic items can be \"on\" at once. Worn-but-unbonded curios are inert until you free a slot. Potions & scrolls fire automatically in combat.")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// prettyMagicItemKind renders the registry's machine kind in a way a player
|
||||
// would actually use ("staff" not "MagicItemStaff", "scroll" not the raw
|
||||
// const).
|
||||
func prettyMagicItemKind(k MagicItemKind) string {
|
||||
switch k {
|
||||
case MagicItemWeapon:
|
||||
return "weapon"
|
||||
case MagicItemArmor:
|
||||
return "armor"
|
||||
case MagicItemRing:
|
||||
return "ring"
|
||||
case MagicItemWand:
|
||||
return "wand"
|
||||
case MagicItemRod:
|
||||
return "rod"
|
||||
case MagicItemStaff:
|
||||
return "staff"
|
||||
case MagicItemPotion:
|
||||
return "potion"
|
||||
case MagicItemScroll:
|
||||
return "scroll"
|
||||
}
|
||||
return string(k)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) resolveShopCurioChoice(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
reply := strings.TrimSpace(ctx.Body)
|
||||
lower := strings.ToLower(reply)
|
||||
|
||||
if lower == "back" {
|
||||
data := interaction.Data.(*advPendingShopCategory)
|
||||
_, equip, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
text := luigiShopGreeting(ctx.Sender, equip, balance, data.ShowAll, p.chatLevel(ctx.Sender))
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "shop_category",
|
||||
Data: &advPendingShopCategory{ShowAll: data.ShowAll},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
if lower == "exit" || lower == "cancel" {
|
||||
p.shopSessionEnd(ctx.Sender)
|
||||
return p.SendDM(ctx.Sender, "*Luigi nods and gestures toward the main counter.*")
|
||||
}
|
||||
|
||||
// Match against today's stock. Exact (case-fold) name wins outright;
|
||||
// otherwise we accumulate substring hits and require a unique candidate
|
||||
// so "ring" doesn't silently sell whichever ring came first in the slate.
|
||||
var (
|
||||
match *MagicItem
|
||||
candidates []MagicItem
|
||||
)
|
||||
stock := dailyCuriosStock()
|
||||
for _, mi := range stock {
|
||||
if strings.EqualFold(mi.Name, reply) {
|
||||
m := mi
|
||||
match = &m
|
||||
candidates = nil
|
||||
break
|
||||
}
|
||||
if containsFold(mi.Name, reply) {
|
||||
candidates = append(candidates, mi)
|
||||
}
|
||||
}
|
||||
if match == nil {
|
||||
switch len(candidates) {
|
||||
case 0:
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "That's not on the shelf today. Reply with an item name from the list, or `back` to return.")
|
||||
case 1:
|
||||
m := candidates[0]
|
||||
match = &m
|
||||
default:
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Several curios match \"%s\":\n", reply))
|
||||
for _, c := range candidates {
|
||||
sb.WriteString(fmt.Sprintf(" • %s\n", c.Name))
|
||||
}
|
||||
sb.WriteString("Reply with the full name (or enough to be unique), or `back` to return.")
|
||||
return p.SendDM(ctx.Sender, sb.String())
|
||||
}
|
||||
}
|
||||
|
||||
price := float64(match.Value) * p.shopSessionPriceFactor(ctx.Sender)
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < price {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%.0f for %s but only have €%.0f.", price, match.Name, balance))
|
||||
}
|
||||
|
||||
// Guard the Debit return: GetBalance above is a snapshot read, but
|
||||
// the debt-limit check inside Debit (BLACKJACK_DEBT_LIMIT) can refuse
|
||||
// the write if a concurrent debit (blackjack, lottery, etc.) has
|
||||
// slipped the balance across the limit between read and write.
|
||||
// Without this guard the curio would be granted on a refused debit.
|
||||
if !p.euro.Debit(ctx.Sender, price, "shop_curio") {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"Tried to charge €%.0f for %s, but the purse refused — your balance moved out from under us. Try again.",
|
||||
price, match.Name))
|
||||
}
|
||||
if potCut := int(math.Round(price * 0.05)); potCut > 0 {
|
||||
communityPotAdd(potCut)
|
||||
trackTaxPaid(ctx.Sender, potCut)
|
||||
}
|
||||
item := magicItemSell(*match)
|
||||
item.Value = int64(match.Value) / 2 // resale at half, like gear/consumables
|
||||
item.SkillSource = "magic_item:" + match.ID
|
||||
_ = addAdvInventoryItem(ctx.Sender, item)
|
||||
|
||||
// Stay in the Curios view for more purchases.
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
newBalance := p.euro.GetBalance(ctx.Sender)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Purchased **%s** for €%.0f. Added to inventory.\n💰 Balance: €%.0f\n\nReply with another item name or `back` to return.",
|
||||
match.Name, price, newBalance))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// freshShopTestDB spins up an empty DB in a tempdir for the sell-path tests.
|
||||
// Distinct from setupAuditTestDB, which clones the prod snapshot.
|
||||
func freshShopTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
// TestSellAll_PreservesMagicItems — B1 (data-loss blocker): `sell all` must
|
||||
// route around curios (Type=="magic_item") so a careless liquidation can't
|
||||
// silently delete a Legendary Ring. Junk in the same bag still sells.
|
||||
func TestSellAll_PreservesMagicItems(t *testing.T) {
|
||||
freshShopTestDB(t)
|
||||
uid := id.UserID("@sellall_magic:test")
|
||||
|
||||
curio := AdvItem{Name: "Ring of Protection", Type: "magic_item", Tier: 3, Value: 500}
|
||||
junk := AdvItem{Name: "Iron Ore", Type: "material", Tier: 1, Value: 40}
|
||||
if err := addAdvInventoryItem(uid, curio); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addAdvInventoryItem(uid, junk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
euro := &EuroPlugin{}
|
||||
euro.ensureBalance(uid)
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
|
||||
msg := p.advSellAll(uid)
|
||||
if !strings.Contains(msg, "Sold 1 item") {
|
||||
t.Errorf("expected exactly one item sold, msg=%q", msg)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(msg), "curio") {
|
||||
t.Errorf("sell-all summary should call out kept curios, msg=%q", msg)
|
||||
}
|
||||
|
||||
left, err := loadAdvInventory(uid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(left) != 1 || left[0].Type != "magic_item" {
|
||||
t.Fatalf("magic item not preserved: %+v", left)
|
||||
}
|
||||
|
||||
// Payout reflects only the junk's €40 (5% pot cut → ~€38 credited).
|
||||
bal := euro.GetBalance(uid)
|
||||
if bal <= 0 || bal > 40 {
|
||||
t.Errorf("payout %v outside expected (0,40] for €40 junk", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSellAll_OnlyMagicItems — when the bag is curios only, sell-all reroutes
|
||||
// to !adventure equip-magic and touches nothing.
|
||||
func TestSellAll_OnlyMagicItems(t *testing.T) {
|
||||
freshShopTestDB(t)
|
||||
uid := id.UserID("@sellall_only_magic:test")
|
||||
|
||||
if err := addAdvInventoryItem(uid, AdvItem{Name: "Cloak of Elvenkind", Type: "magic_item", Value: 300}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
euro := &EuroPlugin{}
|
||||
euro.ensureBalance(uid)
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
|
||||
msg := p.advSellAll(uid)
|
||||
if !strings.Contains(msg, "equip-magic") {
|
||||
t.Errorf("expected reroute to equip-magic, msg=%q", msg)
|
||||
}
|
||||
if euro.GetBalance(uid) != 0 {
|
||||
t.Errorf("balance moved on curio-only sell-all: %v", euro.GetBalance(uid))
|
||||
}
|
||||
left, _ := loadAdvInventory(uid)
|
||||
if len(left) != 1 {
|
||||
t.Errorf("curio deleted by sell-all: %+v", left)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSellItem_MagicItemRerouted — explicit `sell <curio name>` must refuse
|
||||
// and point the player at the bonding command.
|
||||
func TestSellItem_MagicItemRerouted(t *testing.T) {
|
||||
freshShopTestDB(t)
|
||||
uid := id.UserID("@sellone_magic:test")
|
||||
|
||||
curio := AdvItem{Name: "Amulet of Health", Type: "magic_item", Value: 750}
|
||||
if err := addAdvInventoryItem(uid, curio); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
euro := &EuroPlugin{}
|
||||
euro.ensureBalance(uid)
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
|
||||
msg := p.advSellItem(uid, "amulet")
|
||||
if !strings.Contains(msg, "equip-magic") {
|
||||
t.Errorf("expected reroute to equip-magic, msg=%q", msg)
|
||||
}
|
||||
if euro.GetBalance(uid) != 0 {
|
||||
t.Errorf("balance moved on refused curio sale: %v", euro.GetBalance(uid))
|
||||
}
|
||||
left, _ := loadAdvInventory(uid)
|
||||
if len(left) != 1 {
|
||||
t.Errorf("curio deleted by refused sale: %+v", left)
|
||||
}
|
||||
}
|
||||
@@ -202,7 +202,7 @@ func (p *AdventurePlugin) selectTwinBeeFlavor(result *TwinBeeResult) string {
|
||||
}
|
||||
|
||||
if len(pool) == 0 {
|
||||
return "TwinBee went to " + result.Location.Name + ". Results pending."
|
||||
return "I went to " + result.Location.Name + ". Results pending."
|
||||
}
|
||||
|
||||
text := pool[rand.IntN(len(pool))]
|
||||
@@ -330,13 +330,13 @@ type twinBeeGiftDef struct {
|
||||
}
|
||||
|
||||
var twinBeeGifts = []twinBeeGiftDef{
|
||||
{"success_chance", "TwinBee's Lucky Star ⭐", 10, 24 * time.Hour, "TwinBee sends a star. They have many stars. Use it well."},
|
||||
{"success_chance", "TwinBee's Lucky Star ⭐", 10, 24 * time.Hour, "I send 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."},
|
||||
{"exceptional_chance", "Power Up Pod 🫛", 50, 48 * time.Hour, "I found extras. This is not a common occurrence. Don't waste it."},
|
||||
}
|
||||
|
||||
const twinBeeGiftChance = 0.15
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package plugin
|
||||
|
||||
// SRD-shaped stat blocks for the turn-based elite/boss engine.
|
||||
//
|
||||
// The auto-resolve path (SimulateCombat) keeps the simplified one-attack
|
||||
// DnDMonsterTemplate shape — its Attack value is a gameplay-tuned average that
|
||||
// bakes a creature's whole action into a single number, and the 2026-05-10
|
||||
// rebalance is calibrated against it. Do NOT feed these profiles into
|
||||
// auto-resolve.
|
||||
//
|
||||
// The turn-based path can afford canonical SRD multiattack *structure*: the
|
||||
// player issues a command per round, so a creature making three attack rolls a
|
||||
// turn is a tactical problem the player can answer (heal, control, flee) rather
|
||||
// than an un-survivable spike.
|
||||
//
|
||||
// Balance basis (2026-05-14 tuning pass): raw SRD damage is calibrated for a
|
||||
// 4-PC party and one-shots gogobee's single player — slice 1 shipped it
|
||||
// canonical and it was un-survivable across every tier (marilith ~91/round,
|
||||
// even Grol ~20/round vs a level-1 mage). So each profile keeps its SRD
|
||||
// *shape* — attack count, names, per-attack to-hit, and the damage split
|
||||
// between attacks — but the per-attack Damage values are scaled so the
|
||||
// profile's round total lands at roughly 1.3x the creature's tuned
|
||||
// auto-resolve Attack stat (dndBestiary). That 1.3x is deliberate: the
|
||||
// 2026-05-10 rebalance calibrated the single-attack Attack stat as the
|
||||
// gameplay floor, and turn-based fights should hit a little harder than
|
||||
// auto-resolve to make the player's per-round agency (heal / control / flee)
|
||||
// matter — but multiattack already raises hit reliability vs high-AC builds,
|
||||
// and monster abilities now fire as riders on top, so the headroom over
|
||||
// auto-resolve stays modest. Damage flows through the same calcDamage penalty
|
||||
// formula as the Attack stat, so the two are directly comparable in scale.
|
||||
//
|
||||
// File is intentionally NOT dnd_-prefixed (see memory: no new dnd_* identifiers).
|
||||
|
||||
// SRDAttack is one attack roll within a creature's multiattack action: a d20
|
||||
// to-hit and an average damage value for that single strike.
|
||||
type SRDAttack struct {
|
||||
Name string // "Longsword", "Bite", "Claw" — surfaces in the round log
|
||||
AttackBonus int // d20 to-hit modifier; 0 falls back to the template AttackBonus
|
||||
Damage int // average damage for this single attack
|
||||
}
|
||||
|
||||
// SRDProfile is a creature's full multiattack action — one SRDAttack per attack
|
||||
// roll it makes on its turn. A creature with no profile registered makes a
|
||||
// single attack using its DnDMonsterTemplate Attack / AttackBonus.
|
||||
type SRDProfile struct {
|
||||
Attacks []SRDAttack
|
||||
}
|
||||
|
||||
// srdProfiles is the turn-based multiattack registry, keyed by bestiary ID.
|
||||
// Only elites and bosses that reach the turn-based engine need an entry; every
|
||||
// other creature falls through to the single-attack template path. Per-attack
|
||||
// Damage is tuned (not raw SRD) — see the balance-basis note above; the
|
||||
// // -comment after each profile records the tuned auto-resolve Attack stat the
|
||||
// total was scaled against.
|
||||
var srdProfiles = map[string]SRDProfile{
|
||||
// ── Bosses ───────────────────────────────────────────────────────────
|
||||
"boss_grol_unbroken": {Attacks: []SRDAttack{ // Attack 6 → ~8
|
||||
{Name: "Morningstar", AttackBonus: 5, Damage: 5},
|
||||
{Name: "Javelin", AttackBonus: 5, Damage: 3},
|
||||
}},
|
||||
"boss_valdris_unburied": {Attacks: []SRDAttack{ // Attack 8 → ~10
|
||||
{Name: "Paralyzing Touch", AttackBonus: 7, Damage: 6},
|
||||
{Name: "Necrotic Bolt", AttackBonus: 7, Damage: 4},
|
||||
}},
|
||||
"boss_hollow_king": {Attacks: []SRDAttack{ // Attack 10 → ~13
|
||||
{Name: "Root Slam", AttackBonus: 7, Damage: 7},
|
||||
{Name: "Root Slam", AttackBonus: 7, Damage: 6},
|
||||
}},
|
||||
"boss_dreaming_aboleth": {Attacks: []SRDAttack{ // Attack 16 → ~21
|
||||
{Name: "Tentacle", AttackBonus: 9, Damage: 7},
|
||||
{Name: "Tentacle", AttackBonus: 9, Damage: 7},
|
||||
{Name: "Tentacle", AttackBonus: 9, Damage: 7},
|
||||
}},
|
||||
"boss_aldric_blackspire": {Attacks: []SRDAttack{ // Attack 21 → ~27
|
||||
{Name: "Unarmed Strike", AttackBonus: 9, Damage: 14},
|
||||
{Name: "Bite", AttackBonus: 9, Damage: 13},
|
||||
}},
|
||||
"boss_emberlord_thyrak": {Attacks: []SRDAttack{ // Attack 14 → ~18
|
||||
{Name: "Molten Maul", AttackBonus: 8, Damage: 9},
|
||||
{Name: "Molten Maul", AttackBonus: 8, Damage: 9},
|
||||
}},
|
||||
"boss_ilvaras_xunyl": {Attacks: []SRDAttack{ // Attack 19 → ~25
|
||||
{Name: "Scourge", AttackBonus: 9, Damage: 13},
|
||||
{Name: "Scourge", AttackBonus: 9, Damage: 12},
|
||||
}},
|
||||
"boss_thornmother": {Attacks: []SRDAttack{ // Attack 18 → ~23
|
||||
{Name: "Thorned Lash", AttackBonus: 8, Damage: 12},
|
||||
{Name: "Thorned Lash", AttackBonus: 8, Damage: 11},
|
||||
}},
|
||||
"boss_infernax": {Attacks: []SRDAttack{ // Attack 38 → ~49
|
||||
{Name: "Bite", AttackBonus: 11, Damage: 19},
|
||||
{Name: "Claw", AttackBonus: 11, Damage: 15},
|
||||
{Name: "Claw", AttackBonus: 11, Damage: 15},
|
||||
}},
|
||||
"boss_belaxath": {Attacks: []SRDAttack{ // Attack 31 → ~40
|
||||
{Name: "Longsword", AttackBonus: 11, Damage: 24},
|
||||
{Name: "Whip", AttackBonus: 11, Damage: 16},
|
||||
}},
|
||||
|
||||
// ── Multiattack elites ───────────────────────────────────────────────
|
||||
"hobgoblin_warchief": {Attacks: []SRDAttack{ // Attack 4 → ~5
|
||||
{Name: "Longsword", AttackBonus: 5, Damage: 3},
|
||||
{Name: "Longsword", AttackBonus: 5, Damage: 2},
|
||||
}},
|
||||
"bandit_captain": {Attacks: []SRDAttack{ // Attack 4 → ~6
|
||||
{Name: "Scimitar", AttackBonus: 5, Damage: 2},
|
||||
{Name: "Scimitar", AttackBonus: 5, Damage: 2},
|
||||
{Name: "Dagger", AttackBonus: 5, Damage: 2},
|
||||
}},
|
||||
"owlbear": {Attacks: []SRDAttack{ // Attack 6 → ~8
|
||||
{Name: "Beak", AttackBonus: 7, Damage: 3},
|
||||
{Name: "Claws", AttackBonus: 7, Damage: 5},
|
||||
}},
|
||||
"merrow": {Attacks: []SRDAttack{ // Attack 4 → ~5
|
||||
{Name: "Bite", AttackBonus: 5, Damage: 3},
|
||||
{Name: "Claws", AttackBonus: 5, Damage: 2},
|
||||
}},
|
||||
"helmed_horror": {Attacks: []SRDAttack{ // Attack 7 → ~9
|
||||
{Name: "Longsword", AttackBonus: 6, Damage: 5},
|
||||
{Name: "Longsword", AttackBonus: 6, Damage: 4},
|
||||
}},
|
||||
"drow_elite_warrior": {Attacks: []SRDAttack{ // Attack 8 → ~10
|
||||
{Name: "Shortsword", AttackBonus: 7, Damage: 4},
|
||||
{Name: "Shortsword", AttackBonus: 7, Damage: 3},
|
||||
{Name: "Hand Crossbow", AttackBonus: 7, Damage: 3},
|
||||
}},
|
||||
"salamander": {Attacks: []SRDAttack{ // Attack 8 → ~10
|
||||
{Name: "Spear", AttackBonus: 7, Damage: 5},
|
||||
{Name: "Tail", AttackBonus: 7, Damage: 5},
|
||||
}},
|
||||
"guard_drake": {Attacks: []SRDAttack{ // Attack 4 → ~5
|
||||
{Name: "Bite", AttackBonus: 5, Damage: 3},
|
||||
{Name: "Claws", AttackBonus: 5, Damage: 2},
|
||||
}},
|
||||
"young_red_dragon": {Attacks: []SRDAttack{ // Attack 16 → ~20
|
||||
{Name: "Bite", AttackBonus: 10, Damage: 8},
|
||||
{Name: "Claw", AttackBonus: 10, Damage: 6},
|
||||
{Name: "Claw", AttackBonus: 10, Damage: 6},
|
||||
}},
|
||||
"quickling": {Attacks: []SRDAttack{ // Attack 2 → ~3
|
||||
{Name: "Dagger", AttackBonus: 5, Damage: 1},
|
||||
{Name: "Dagger", AttackBonus: 5, Damage: 1},
|
||||
{Name: "Dagger", AttackBonus: 5, Damage: 1},
|
||||
}},
|
||||
"vrock": {Attacks: []SRDAttack{ // Attack 10 → ~13
|
||||
{Name: "Beak", AttackBonus: 6, Damage: 5},
|
||||
{Name: "Talons", AttackBonus: 6, Damage: 8},
|
||||
}},
|
||||
"hezrou": {Attacks: []SRDAttack{ // Attack 13 → ~17
|
||||
{Name: "Bite", AttackBonus: 7, Damage: 7},
|
||||
{Name: "Claw", AttackBonus: 7, Damage: 5},
|
||||
{Name: "Claw", AttackBonus: 7, Damage: 5},
|
||||
}},
|
||||
"nalfeshnee": {Attacks: []SRDAttack{ // Attack 21 → ~28
|
||||
{Name: "Bite", AttackBonus: 10, Damage: 14},
|
||||
{Name: "Claw", AttackBonus: 10, Damage: 7},
|
||||
{Name: "Claw", AttackBonus: 10, Damage: 7},
|
||||
}},
|
||||
"marilith": {Attacks: []SRDAttack{ // Attack 26 → ~34
|
||||
{Name: "Longsword", AttackBonus: 11, Damage: 5},
|
||||
{Name: "Longsword", AttackBonus: 11, Damage: 5},
|
||||
{Name: "Longsword", AttackBonus: 11, Damage: 5},
|
||||
{Name: "Longsword", AttackBonus: 11, Damage: 5},
|
||||
{Name: "Longsword", AttackBonus: 11, Damage: 5},
|
||||
{Name: "Longsword", AttackBonus: 11, Damage: 5},
|
||||
{Name: "Tail", AttackBonus: 11, Damage: 4},
|
||||
}},
|
||||
"vampire_spawn": {Attacks: []SRDAttack{ // Attack 8 → ~10
|
||||
{Name: "Unarmed Strike", AttackBonus: 6, Damage: 6},
|
||||
{Name: "Bite", AttackBonus: 6, Damage: 4},
|
||||
}},
|
||||
}
|
||||
|
||||
// enemyAttackProfile returns the attack rolls a creature makes on its turn.
|
||||
// Registered elites/bosses use their SRD multiattack profile; everyone else
|
||||
// falls back to a single attack derived from the template stats. A zero
|
||||
// per-attack AttackBonus falls back to the (tier-scaled) template bonus.
|
||||
func enemyAttackProfile(enemyID string, stats CombatStats) []SRDAttack {
|
||||
prof, ok := srdProfiles[enemyID]
|
||||
if !ok || len(prof.Attacks) == 0 {
|
||||
return []SRDAttack{{AttackBonus: stats.AttackBonus, Damage: stats.Attack}}
|
||||
}
|
||||
out := make([]SRDAttack, len(prof.Attacks))
|
||||
for i, a := range prof.Attacks {
|
||||
if a.AttackBonus == 0 {
|
||||
a.AttackBonus = stats.AttackBonus
|
||||
}
|
||||
out[i] = a
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// Code generated by cmd/open5e-import. DO NOT EDIT.
|
||||
//
|
||||
// Source: Open5e SRD monster dump (data/open5e/monsters.json), 5e SRD content
|
||||
// under CC-BY-4.0 — see NOTICE. Regenerate with:
|
||||
//
|
||||
// go run ./cmd/open5e-import fetch bestiary
|
||||
// go run ./cmd/open5e-import gen bestiary
|
||||
//
|
||||
// This is the RAW SRD staging table — see bestiary_srd_staging.go for why these
|
||||
// values are a balance baseline and not a drop-in engine roster.
|
||||
|
||||
package plugin
|
||||
|
||||
func buildSRDStagingBestiary() map[string]SRDStatBlock {
|
||||
return map[string]SRDStatBlock{
|
||||
"awakened_shrub": {Slug: "awakened_shrub", Name: "Awakened Shrub", Size: "small", Type: "plant", CR: 0, XP: 10, HP: 10, AC: 9, SpeedWalk: 20, STR: 3, DEX: 8, CON: 11, INT: 10, WIS: 10, CHA: 6, Attacks: []SRDStatAttack{{Name: "Rake", AttackBonus: 1, DamageDice: "1d4", DamageBonus: -1, AvgDamage: 1, DamageType: "slashing"}}, Traits: []string{"False Appearance"}},
|
||||
"baboon": {Slug: "baboon", Name: "Baboon", Size: "small", Type: "beast", CR: 0, XP: 10, HP: 3, AC: 12, SpeedWalk: 30, STR: 8, DEX: 14, CON: 11, INT: 4, WIS: 12, CHA: 6, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 1, DamageDice: "1d4", DamageBonus: -1, AvgDamage: 1, DamageType: "piercing"}}, Traits: []string{"Pack Tactics"}},
|
||||
"badger": {Slug: "badger", Name: "Badger", Size: "tiny", Type: "beast", CR: 0, XP: 10, HP: 3, AC: 10, SpeedWalk: 20, STR: 4, DEX: 11, CON: 12, INT: 2, WIS: 12, CHA: 5, Traits: []string{"Keen Smell"}},
|
||||
"bat": {Slug: "bat", Name: "Bat", Size: "tiny", Type: "beast", CR: 0, XP: 10, HP: 1, AC: 12, SpeedWalk: 5, STR: 2, DEX: 15, CON: 8, INT: 2, WIS: 12, CHA: 4, Traits: []string{"Echolocation", "Keen Hearing"}},
|
||||
"cat": {Slug: "cat", Name: "Cat", Size: "tiny", Type: "beast", CR: 0, XP: 10, HP: 2, AC: 12, SpeedWalk: 40, STR: 3, DEX: 15, CON: 10, INT: 3, WIS: 12, CHA: 7, Traits: []string{"Keen Smell"}},
|
||||
"commoner": {Slug: "commoner", Name: "Commoner", Size: "medium", Type: "humanoid", CR: 0, XP: 10, HP: 4, AC: 10, SpeedWalk: 30, STR: 10, DEX: 10, CON: 10, INT: 10, WIS: 10, CHA: 10, Attacks: []SRDStatAttack{{Name: "Club", AttackBonus: 2, DamageDice: "1d4", DamageBonus: 0, AvgDamage: 2, DamageType: "bludgeoning"}}},
|
||||
"crab": {Slug: "crab", Name: "Crab", Size: "tiny", Type: "beast", CR: 0, XP: 10, HP: 2, AC: 11, SpeedWalk: 20, STR: 2, DEX: 11, CON: 10, INT: 1, WIS: 8, CHA: 2, Traits: []string{"Amphibious"}},
|
||||
"deer": {Slug: "deer", Name: "Deer", Size: "medium", Type: "beast", CR: 0, XP: 10, HP: 4, AC: 13, SpeedWalk: 50, STR: 11, DEX: 16, CON: 11, INT: 2, WIS: 14, CHA: 5, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 2, DamageDice: "1d4", DamageBonus: 0, AvgDamage: 2, DamageType: "piercing"}}},
|
||||
"eagle": {Slug: "eagle", Name: "Eagle", Size: "small", Type: "beast", CR: 0, XP: 10, HP: 3, AC: 12, SpeedWalk: 10, STR: 6, DEX: 15, CON: 10, INT: 2, WIS: 14, CHA: 7, Attacks: []SRDStatAttack{{Name: "Talons", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "slashing"}}, Traits: []string{"Keen Sight"}},
|
||||
"frog": {Slug: "frog", Name: "Frog", Size: "tiny", Type: "beast", CR: 0, XP: 10, HP: 1, AC: 11, SpeedWalk: 20, STR: 1, DEX: 13, CON: 8, INT: 1, WIS: 8, CHA: 3, Traits: []string{"Amphibious", "Standing Leap"}},
|
||||
"giant_fire_beetle": {Slug: "giant_fire_beetle", Name: "Giant Fire Beetle", Size: "small", Type: "beast", CR: 0, XP: 10, HP: 4, AC: 13, SpeedWalk: 30, STR: 8, DEX: 10, CON: 12, INT: 1, WIS: 7, CHA: 3, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 1, DamageDice: "1d6", DamageBonus: -1, AvgDamage: 2, DamageType: "slashing"}}, Traits: []string{"Illumination"}},
|
||||
"goat": {Slug: "goat", Name: "Goat", Size: "medium", Type: "beast", CR: 0, XP: 10, HP: 4, AC: 10, SpeedWalk: 40, STR: 12, DEX: 10, CON: 11, INT: 2, WIS: 10, CHA: 5, Attacks: []SRDStatAttack{{Name: "Ram", AttackBonus: 3, DamageDice: "1d4", DamageBonus: 1, AvgDamage: 3, DamageType: "bludgeoning"}}, Traits: []string{"Charge", "Sure-Footed"}},
|
||||
"hawk": {Slug: "hawk", Name: "Hawk", Size: "tiny", Type: "beast", CR: 0, XP: 10, HP: 1, AC: 13, SpeedWalk: 10, STR: 5, DEX: 16, CON: 8, INT: 2, WIS: 14, CHA: 6, Traits: []string{"Keen Sight"}},
|
||||
"homunculus": {Slug: "homunculus", Name: "Homunculus", Size: "tiny", Type: "construct", CR: 0, XP: 10, HP: 5, AC: 13, SpeedWalk: 20, STR: 4, DEX: 15, CON: 11, INT: 10, WIS: 10, CHA: 7, Traits: []string{"Telepathic Bond"}},
|
||||
"hyena": {Slug: "hyena", Name: "Hyena", Size: "medium", Type: "beast", CR: 0, XP: 10, HP: 5, AC: 11, SpeedWalk: 50, STR: 11, DEX: 13, CON: 12, INT: 2, WIS: 12, CHA: 5, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 2, DamageDice: "1d6", DamageBonus: 0, AvgDamage: 3, DamageType: "piercing"}}, Traits: []string{"Pack Tactics"}},
|
||||
"jackal": {Slug: "jackal", Name: "Jackal", Size: "small", Type: "beast", CR: 0, XP: 10, HP: 3, AC: 12, SpeedWalk: 40, STR: 8, DEX: 15, CON: 11, INT: 3, WIS: 12, CHA: 6, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 1, DamageDice: "1d4", DamageBonus: -1, AvgDamage: 1, DamageType: "piercing"}}, Traits: []string{"Keen Hearing and Smell", "Pack Tactics"}},
|
||||
"lemure": {Slug: "lemure", Name: "Lemure", Size: "medium", Type: "fiend", CR: 0, XP: 10, HP: 13, AC: 7, SpeedWalk: 15, STR: 10, DEX: 5, CON: 11, INT: 1, WIS: 11, CHA: 3, Attacks: []SRDStatAttack{{Name: "Fist", AttackBonus: 3, DamageDice: "1d4", DamageBonus: 0, AvgDamage: 2, DamageType: "bludgeoning"}}, Traits: []string{"Devil's Sight", "Hellish Rejuvenation"}},
|
||||
"lizard": {Slug: "lizard", Name: "Lizard", Size: "tiny", Type: "beast", CR: 0, XP: 10, HP: 2, AC: 10, SpeedWalk: 20, STR: 2, DEX: 11, CON: 10, INT: 1, WIS: 8, CHA: 3},
|
||||
"octopus": {Slug: "octopus", Name: "Octopus", Size: "small", Type: "beast", CR: 0, XP: 10, HP: 3, AC: 12, SpeedWalk: 5, STR: 4, DEX: 15, CON: 11, INT: 3, WIS: 10, CHA: 4, Traits: []string{"Hold Breath", "Underwater Camouflage", "Water Breathing"}},
|
||||
"owl": {Slug: "owl", Name: "Owl", Size: "tiny", Type: "beast", CR: 0, XP: 10, HP: 1, AC: 11, SpeedWalk: 5, STR: 3, DEX: 13, CON: 8, INT: 2, WIS: 12, CHA: 7, Traits: []string{"Flyby", "Keen Hearing and Sight"}},
|
||||
"quipper": {Slug: "quipper", Name: "Quipper", Size: "tiny", Type: "beast", CR: 0, XP: 10, HP: 1, AC: 13, SpeedWalk: 40, STR: 2, DEX: 16, CON: 9, INT: 1, WIS: 7, CHA: 2, Traits: []string{"Blood Frenzy", "Water Breathing"}},
|
||||
"rat": {Slug: "rat", Name: "Rat", Size: "tiny", Type: "beast", CR: 0, XP: 10, HP: 1, AC: 10, SpeedWalk: 20, STR: 2, DEX: 11, CON: 9, INT: 2, WIS: 10, CHA: 4, Traits: []string{"Keen Smell"}},
|
||||
"raven": {Slug: "raven", Name: "Raven", Size: "tiny", Type: "beast", CR: 0, XP: 10, HP: 1, AC: 12, SpeedWalk: 10, STR: 2, DEX: 14, CON: 8, INT: 2, WIS: 12, CHA: 6, Traits: []string{"Mimicry"}},
|
||||
"scorpion": {Slug: "scorpion", Name: "Scorpion", Size: "tiny", Type: "beast", CR: 0, XP: 10, HP: 1, AC: 11, SpeedWalk: 10, STR: 2, DEX: 11, CON: 8, INT: 1, WIS: 8, CHA: 2},
|
||||
"sea_horse": {Slug: "sea_horse", Name: "Sea Horse", Size: "tiny", Type: "beast", CR: 0, XP: 10, HP: 1, AC: 11, SpeedWalk: 20, STR: 1, DEX: 12, CON: 8, INT: 1, WIS: 10, CHA: 2, Traits: []string{"Water Breathing"}},
|
||||
"shrieker": {Slug: "shrieker", Name: "Shrieker", Size: "medium", Type: "plant", CR: 0, XP: 10, HP: 13, AC: 5, STR: 1, DEX: 1, CON: 10, INT: 1, WIS: 3, CHA: 1, Traits: []string{"False Appearance"}},
|
||||
"spider": {Slug: "spider", Name: "Spider", Size: "tiny", Type: "beast", CR: 0, XP: 10, HP: 1, AC: 12, SpeedWalk: 20, STR: 2, DEX: 14, CON: 8, INT: 1, WIS: 10, CHA: 2, Traits: []string{"Spider Climb", "Web Sense", "Web Walker"}},
|
||||
"vulture": {Slug: "vulture", Name: "Vulture", Size: "medium", Type: "beast", CR: 0, XP: 10, HP: 5, AC: 10, SpeedWalk: 10, STR: 7, DEX: 10, CON: 13, INT: 2, WIS: 12, CHA: 4, Attacks: []SRDStatAttack{{Name: "Beak", AttackBonus: 2, DamageDice: "1d4", DamageBonus: 0, AvgDamage: 2, DamageType: "piercing"}}, Traits: []string{"Keen Sight and Smell", "Pack Tactics"}},
|
||||
"weasel": {Slug: "weasel", Name: "Weasel", Size: "tiny", Type: "beast", CR: 0, XP: 10, HP: 1, AC: 13, SpeedWalk: 30, STR: 3, DEX: 16, CON: 8, INT: 2, WIS: 12, CHA: 3, Traits: []string{"Keen Hearing and Smell"}},
|
||||
"bandit": {Slug: "bandit", Name: "Bandit", Size: "medium", Type: "humanoid", CR: 0.125, XP: 25, HP: 11, AC: 12, SpeedWalk: 30, STR: 11, DEX: 12, CON: 12, INT: 10, WIS: 10, CHA: 10, Attacks: []SRDStatAttack{{Name: "Scimitar", AttackBonus: 3, DamageDice: "1d6", DamageBonus: 1, AvgDamage: 4, DamageType: "slashing"}, {Name: "Light Crossbow", AttackBonus: 3, DamageDice: "1d8", DamageBonus: 1, AvgDamage: 5, DamageType: "piercing"}}},
|
||||
"blood_hawk": {Slug: "blood_hawk", Name: "Blood Hawk", Size: "small", Type: "beast", CR: 0.125, XP: 25, HP: 7, AC: 12, SpeedWalk: 10, STR: 6, DEX: 14, CON: 10, INT: 3, WIS: 14, CHA: 5, Attacks: []SRDStatAttack{{Name: "Beak", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "piercing"}}, Traits: []string{"Keen Sight", "Pack Tactics"}},
|
||||
"camel": {Slug: "camel", Name: "Camel", Size: "large", Type: "beast", CR: 0.125, XP: 25, HP: 15, AC: 9, SpeedWalk: 50, STR: 16, DEX: 8, CON: 14, INT: 2, WIS: 8, CHA: 5, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 5, DamageDice: "1d4", DamageBonus: 0, AvgDamage: 2, DamageType: "bludgeoning"}}},
|
||||
"cultist": {Slug: "cultist", Name: "Cultist", Size: "medium", Type: "humanoid", CR: 0.125, XP: 25, HP: 9, AC: 12, SpeedWalk: 30, STR: 11, DEX: 12, CON: 10, INT: 10, WIS: 11, CHA: 10, Attacks: []SRDStatAttack{{Name: "Scimitar", AttackBonus: 3, DamageDice: "1d6", DamageBonus: 1, AvgDamage: 4, DamageType: "slashing"}}, Traits: []string{"Dark Devotion"}},
|
||||
"flying_snake": {Slug: "flying_snake", Name: "Flying Snake", Size: "tiny", Type: "beast", CR: 0.125, XP: 25, HP: 5, AC: 14, SpeedWalk: 30, STR: 4, DEX: 18, CON: 11, INT: 2, WIS: 12, CHA: 5, Traits: []string{"Flyby"}},
|
||||
"giant_crab": {Slug: "giant_crab", Name: "Giant Crab", Size: "medium", Type: "beast", CR: 0.125, XP: 25, HP: 13, AC: 15, SpeedWalk: 30, STR: 13, DEX: 15, CON: 11, INT: 1, WIS: 9, CHA: 3, Attacks: []SRDStatAttack{{Name: "Claw", AttackBonus: 3, DamageDice: "1d6", DamageBonus: 1, AvgDamage: 4, DamageType: "bludgeoning"}}, Traits: []string{"Amphibious"}},
|
||||
"giant_rat": {Slug: "giant_rat", Name: "Giant Rat", Size: "small", Type: "beast", CR: 0.125, XP: 25, HP: 7, AC: 12, SpeedWalk: 30, STR: 7, DEX: 15, CON: 11, INT: 2, WIS: 10, CHA: 4, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "piercing"}}, Traits: []string{"Keen Smell", "Pack Tactics"}},
|
||||
"giant_rat_diseased": {Slug: "giant_rat_diseased", Name: "Giant Rat (Diseased)", Size: "small", Type: "beast", CR: 0.125, XP: 25, HP: 7, AC: 12, SpeedWalk: 30, STR: 7, DEX: 15, CON: 11, INT: 2, WIS: 10, CHA: 4, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "piercing"}}},
|
||||
"giant_weasel": {Slug: "giant_weasel", Name: "Giant Weasel", Size: "medium", Type: "beast", CR: 0.125, XP: 25, HP: 9, AC: 13, SpeedWalk: 40, STR: 11, DEX: 16, CON: 10, INT: 4, WIS: 12, CHA: 5, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 5, DamageDice: "1d4", DamageBonus: 3, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Keen Hearing and Smell"}},
|
||||
"guard": {Slug: "guard", Name: "Guard", Size: "medium", Type: "humanoid", CR: 0.125, XP: 25, HP: 11, AC: 16, SpeedWalk: 30, STR: 13, DEX: 12, CON: 12, INT: 10, WIS: 11, CHA: 10, Attacks: []SRDStatAttack{{Name: "Spear", AttackBonus: 3, DamageDice: "1d6", DamageBonus: 1, AvgDamage: 4, DamageType: "piercing"}}},
|
||||
"kobold": {Slug: "kobold", Name: "Kobold", Size: "small", Type: "humanoid", CR: 0.125, XP: 25, HP: 5, AC: 12, SpeedWalk: 30, STR: 7, DEX: 15, CON: 9, INT: 8, WIS: 7, CHA: 8, Attacks: []SRDStatAttack{{Name: "Dagger", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "piercing"}, {Name: "Sling", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "bludgeoning"}}, Traits: []string{"Sunlight Sensitivity", "Pack Tactics"}},
|
||||
"mastiff": {Slug: "mastiff", Name: "Mastiff", Size: "medium", Type: "beast", CR: 0.125, XP: 25, HP: 5, AC: 12, SpeedWalk: 40, STR: 13, DEX: 14, CON: 12, INT: 3, WIS: 12, CHA: 7, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 3, DamageDice: "1d6", DamageBonus: 1, AvgDamage: 4, DamageType: "piercing"}}, Traits: []string{"Keen Hearing and Smell"}},
|
||||
"merfolk": {Slug: "merfolk", Name: "Merfolk", Size: "medium", Type: "humanoid", CR: 0.125, XP: 25, HP: 11, AC: 11, SpeedWalk: 10, STR: 10, DEX: 13, CON: 12, INT: 11, WIS: 11, CHA: 12, Attacks: []SRDStatAttack{{Name: "Spear", AttackBonus: 2, DamageDice: "1d6", DamageBonus: 0, AvgDamage: 3, DamageType: "piercing"}}, Traits: []string{"Amphibious"}},
|
||||
"mule": {Slug: "mule", Name: "Mule", Size: "medium", Type: "beast", CR: 0.125, XP: 25, HP: 11, AC: 10, SpeedWalk: 40, STR: 14, DEX: 10, CON: 13, INT: 2, WIS: 10, CHA: 5, Attacks: []SRDStatAttack{{Name: "Hooves", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "bludgeoning"}}, Traits: []string{"Beast of Burden", "Sure-Footed"}},
|
||||
"noble": {Slug: "noble", Name: "Noble", Size: "medium", Type: "humanoid", CR: 0.125, XP: 25, HP: 9, AC: 15, SpeedWalk: 30, STR: 11, DEX: 12, CON: 11, INT: 12, WIS: 14, CHA: 16, Attacks: []SRDStatAttack{{Name: "Rapier", AttackBonus: 3, DamageDice: "1d8", DamageBonus: 1, AvgDamage: 5, DamageType: "piercing"}}},
|
||||
"poisonous_snake": {Slug: "poisonous_snake", Name: "Poisonous Snake", Size: "tiny", Type: "beast", CR: 0.125, XP: 25, HP: 2, AC: 13, SpeedWalk: 30, STR: 2, DEX: 16, CON: 11, INT: 1, WIS: 10, CHA: 3},
|
||||
"pony": {Slug: "pony", Name: "Pony", Size: "medium", Type: "beast", CR: 0.125, XP: 25, HP: 11, AC: 10, SpeedWalk: 40, STR: 15, DEX: 10, CON: 13, INT: 2, WIS: 11, CHA: 7, Attacks: []SRDStatAttack{{Name: "Hooves", AttackBonus: 4, DamageDice: "2d4", DamageBonus: 2, AvgDamage: 7, DamageType: "bludgeoning"}}},
|
||||
"stirge": {Slug: "stirge", Name: "Stirge", Size: "tiny", Type: "beast", CR: 0.125, XP: 25, HP: 2, AC: 14, SpeedWalk: 10, STR: 4, DEX: 16, CON: 11, INT: 2, WIS: 8, CHA: 6, Attacks: []SRDStatAttack{{Name: "Blood Drain", AttackBonus: 5, DamageDice: "1d4", DamageBonus: 3, AvgDamage: 5, DamageType: "piercing"}}},
|
||||
"tribal_warrior": {Slug: "tribal_warrior", Name: "Tribal Warrior", Size: "medium", Type: "humanoid", CR: 0.125, XP: 25, HP: 11, AC: 12, SpeedWalk: 30, STR: 13, DEX: 11, CON: 12, INT: 8, WIS: 11, CHA: 8, Attacks: []SRDStatAttack{{Name: "Spear", AttackBonus: 3, DamageDice: "1d6", DamageBonus: 1, AvgDamage: 4, DamageType: "piercing"}}, Traits: []string{"Pack Tactics"}},
|
||||
"acolyte": {Slug: "acolyte", Name: "Acolyte", Size: "medium", Type: "humanoid", CR: 0.25, XP: 50, HP: 9, AC: 10, SpeedWalk: 30, STR: 10, DEX: 10, CON: 10, INT: 10, WIS: 14, CHA: 11, Attacks: []SRDStatAttack{{Name: "Club", AttackBonus: 2, DamageDice: "1d4", DamageBonus: 0, AvgDamage: 2, DamageType: "bludgeoning"}}, Traits: []string{"Spellcasting"}},
|
||||
"axe_beak": {Slug: "axe_beak", Name: "Axe Beak", Size: "large", Type: "beast", CR: 0.25, XP: 50, HP: 19, AC: 11, SpeedWalk: 50, STR: 14, DEX: 12, CON: 12, INT: 2, WIS: 10, CHA: 5, Attacks: []SRDStatAttack{{Name: "Beak", AttackBonus: 4, DamageDice: "1d8", DamageBonus: 2, AvgDamage: 6, DamageType: "slashing"}}},
|
||||
"blink_dog": {Slug: "blink_dog", Name: "Blink Dog", Size: "medium", Type: "fey", CR: 0.25, XP: 50, HP: 22, AC: 13, SpeedWalk: 40, STR: 12, DEX: 17, CON: 12, INT: 10, WIS: 13, CHA: 11, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 3, DamageDice: "1d6", DamageBonus: 1, AvgDamage: 4, DamageType: "piercing"}}, Traits: []string{"Keen Hearing and Smell"}},
|
||||
"boar": {Slug: "boar", Name: "Boar", Size: "medium", Type: "beast", CR: 0.25, XP: 50, HP: 11, AC: 11, SpeedWalk: 40, STR: 13, DEX: 11, CON: 12, INT: 2, WIS: 9, CHA: 5, Attacks: []SRDStatAttack{{Name: "Tusk", AttackBonus: 3, DamageDice: "1d6", DamageBonus: 1, AvgDamage: 4, DamageType: "slashing"}}, Traits: []string{"Charge", "Relentless (Recharges after a Short or Long Rest)"}},
|
||||
"constrictor_snake": {Slug: "constrictor_snake", Name: "Constrictor Snake", Size: "large", Type: "beast", CR: 0.25, XP: 50, HP: 13, AC: 12, SpeedWalk: 30, STR: 15, DEX: 14, CON: 12, INT: 1, WIS: 10, CHA: 3, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}, {Name: "Constrict", AttackBonus: 4, DamageDice: "1d8", DamageBonus: 2, AvgDamage: 6, DamageType: "bludgeoning"}}},
|
||||
"draft_horse": {Slug: "draft_horse", Name: "Draft Horse", Size: "large", Type: "beast", CR: 0.25, XP: 50, HP: 19, AC: 10, SpeedWalk: 40, STR: 18, DEX: 10, CON: 12, INT: 2, WIS: 11, CHA: 7, Attacks: []SRDStatAttack{{Name: "Hooves", AttackBonus: 6, DamageDice: "2d4", DamageBonus: 4, AvgDamage: 9, DamageType: "bludgeoning"}}},
|
||||
"dretch": {Slug: "dretch", Name: "Dretch", Size: "small", Type: "fiend", CR: 0.25, XP: 50, HP: 18, AC: 11, SpeedWalk: 20, STR: 11, DEX: 11, CON: 12, INT: 5, WIS: 8, CHA: 3, Multiattack: "The dretch makes two attacks: one with its bite and one with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 2, DamageDice: "1d6", DamageBonus: 0, AvgDamage: 3, DamageType: "piercing"}, {Name: "Claws", AttackBonus: 2, DamageDice: "2d4", DamageBonus: 0, AvgDamage: 5, DamageType: "slashing"}}},
|
||||
"drow": {Slug: "drow", Name: "Drow", Size: "medium", Type: "humanoid", CR: 0.25, XP: 50, HP: 13, AC: 15, SpeedWalk: 30, STR: 10, DEX: 14, CON: 10, INT: 11, WIS: 11, CHA: 12, Attacks: []SRDStatAttack{{Name: "Shortsword", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}, {Name: "Hand Crossbow", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Fey Ancestry", "Innate Spellcasting", "Sunlight Sensitivity"}},
|
||||
"elk": {Slug: "elk", Name: "Elk", Size: "large", Type: "beast", CR: 0.25, XP: 50, HP: 13, AC: 10, SpeedWalk: 50, STR: 16, DEX: 10, CON: 12, INT: 2, WIS: 10, CHA: 6, Traits: []string{"Charge"}},
|
||||
"flying_sword": {Slug: "flying_sword", Name: "Flying Sword", Size: "small", Type: "construct", CR: 0.25, XP: 50, HP: 17, AC: 17, SpeedWalk: 50, STR: 12, DEX: 15, CON: 11, INT: 1, WIS: 5, CHA: 1, Attacks: []SRDStatAttack{{Name: "Longsword", AttackBonus: 3, DamageDice: "1d8", DamageBonus: 1, AvgDamage: 5, DamageType: "slashing"}}, Traits: []string{"Antimagic Susceptibility", "False Appearance"}},
|
||||
"giant_badger": {Slug: "giant_badger", Name: "Giant Badger", Size: "medium", Type: "beast", CR: 0.25, XP: 50, HP: 13, AC: 10, SpeedWalk: 30, STR: 13, DEX: 10, CON: 15, INT: 2, WIS: 12, CHA: 5, Multiattack: "The badger makes two attacks: one with its bite and one with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 3, DamageDice: "1d6", DamageBonus: 1, AvgDamage: 4, DamageType: "piercing"}, {Name: "Claws", AttackBonus: 3, DamageDice: "2d4", DamageBonus: 1, AvgDamage: 6, DamageType: "slashing"}}, Traits: []string{"Keen Smell"}},
|
||||
"giant_bat": {Slug: "giant_bat", Name: "Giant Bat", Size: "large", Type: "beast", CR: 0.25, XP: 50, HP: 22, AC: 13, SpeedWalk: 10, STR: 15, DEX: 16, CON: 11, INT: 2, WIS: 12, CHA: 6, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Echolocation", "Keen Hearing"}},
|
||||
"giant_centipede": {Slug: "giant_centipede", Name: "Giant Centipede", Size: "small", Type: "beast", CR: 0.25, XP: 50, HP: 4, AC: 13, SpeedWalk: 30, STR: 5, DEX: 14, CON: 12, INT: 1, WIS: 7, CHA: 3, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "piercing"}}},
|
||||
"giant_frog": {Slug: "giant_frog", Name: "Giant Frog", Size: "medium", Type: "beast", CR: 0.25, XP: 50, HP: 18, AC: 11, SpeedWalk: 30, STR: 12, DEX: 13, CON: 11, INT: 2, WIS: 10, CHA: 3, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 3, DamageDice: "1d6", DamageBonus: 1, AvgDamage: 4, DamageType: "piercing"}}, Traits: []string{"Amphibious", "Standing Leap"}},
|
||||
"giant_lizard": {Slug: "giant_lizard", Name: "Giant Lizard", Size: "large", Type: "beast", CR: 0.25, XP: 50, HP: 19, AC: 12, SpeedWalk: 30, STR: 15, DEX: 12, CON: 13, INT: 2, WIS: 10, CHA: 5, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d8", DamageBonus: 2, AvgDamage: 6, DamageType: "piercing"}}, Traits: []string{"Variant: Hold Breath", "Variant: Spider Climb"}},
|
||||
"giant_owl": {Slug: "giant_owl", Name: "Giant Owl", Size: "large", Type: "beast", CR: 0.25, XP: 50, HP: 19, AC: 12, SpeedWalk: 5, STR: 13, DEX: 15, CON: 12, INT: 8, WIS: 13, CHA: 10, Attacks: []SRDStatAttack{{Name: "Talons", AttackBonus: 3, DamageDice: "2d6", DamageBonus: 1, AvgDamage: 8, DamageType: "slashing"}}, Traits: []string{"Flyby", "Keen Hearing and Sight"}},
|
||||
"giant_poisonous_snake": {Slug: "giant_poisonous_snake", Name: "Giant Poisonous Snake", Size: "medium", Type: "beast", CR: 0.25, XP: 50, HP: 11, AC: 14, SpeedWalk: 30, STR: 10, DEX: 18, CON: 13, INT: 2, WIS: 10, CHA: 3, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 6, DamageDice: "1d4", DamageBonus: 4, AvgDamage: 6, DamageType: "piercing"}}},
|
||||
"giant_wolf_spider": {Slug: "giant_wolf_spider", Name: "Giant Wolf Spider", Size: "medium", Type: "beast", CR: 0.25, XP: 50, HP: 11, AC: 13, SpeedWalk: 40, STR: 12, DEX: 16, CON: 13, INT: 3, WIS: 12, CHA: 4, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 3, DamageDice: "1d6", DamageBonus: 1, AvgDamage: 4, DamageType: "piercing"}}, Traits: []string{"Spider Climb", "Web Sense", "Web Walker"}},
|
||||
"goblin": {Slug: "goblin", Name: "Goblin", Size: "small", Type: "humanoid", CR: 0.25, XP: 50, HP: 7, AC: 15, SpeedWalk: 30, STR: 8, DEX: 14, CON: 10, INT: 10, WIS: 8, CHA: 8, Attacks: []SRDStatAttack{{Name: "Scimitar", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "slashing"}, {Name: "Shortbow", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Nimble Escape"}},
|
||||
"grimlock": {Slug: "grimlock", Name: "Grimlock", Size: "medium", Type: "humanoid", CR: 0.25, XP: 50, HP: 11, AC: 11, SpeedWalk: 30, STR: 16, DEX: 12, CON: 12, INT: 9, WIS: 8, CHA: 6, Attacks: []SRDStatAttack{{Name: "Spiked Bone Club", AttackBonus: 5, DamageDice: "1d4+1d4", DamageBonus: 5, AvgDamage: 5, DamageType: "bludgeoning"}}, Traits: []string{"Blind Senses", "Keen Hearing and Smell", "Stone Camouflage"}},
|
||||
"panther": {Slug: "panther", Name: "Panther", Size: "medium", Type: "beast", CR: 0.25, XP: 50, HP: 13, AC: 12, SpeedWalk: 50, STR: 14, DEX: 15, CON: 10, INT: 3, WIS: 14, CHA: 7, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "slashing"}}, Traits: []string{"Keen Smell", "Pounce"}},
|
||||
"pseudodragon": {Slug: "pseudodragon", Name: "Pseudodragon", Size: "tiny", Type: "dragon", CR: 0.25, XP: 50, HP: 7, AC: 13, SpeedWalk: 15, STR: 6, DEX: 15, CON: 13, INT: 10, WIS: 12, CHA: 10, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "piercing"}, {Name: "Sting", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "piercing"}}, Traits: []string{"Keen Senses", "Magic Resistance", "Limited Telepathy", "Variant: Familiar"}},
|
||||
"riding_horse": {Slug: "riding_horse", Name: "Riding Horse", Size: "large", Type: "beast", CR: 0.25, XP: 50, HP: 13, AC: 10, SpeedWalk: 60, STR: 16, DEX: 10, CON: 12, INT: 2, WIS: 11, CHA: 7, Attacks: []SRDStatAttack{{Name: "Hooves", AttackBonus: 5, DamageDice: "2d4", DamageBonus: 3, AvgDamage: 8, DamageType: "bludgeoning"}}},
|
||||
"skeleton": {Slug: "skeleton", Name: "Skeleton", Size: "medium", Type: "undead", CR: 0.25, XP: 50, HP: 13, AC: 13, SpeedWalk: 30, STR: 10, DEX: 14, CON: 15, INT: 6, WIS: 8, CHA: 5, Attacks: []SRDStatAttack{{Name: "Shortsword", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}, {Name: "Shortbow", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}}},
|
||||
"sprite": {Slug: "sprite", Name: "Sprite", Size: "tiny", Type: "fey", CR: 0.25, XP: 50, HP: 2, AC: 15, SpeedWalk: 10, STR: 3, DEX: 18, CON: 10, INT: 14, WIS: 13, CHA: 11},
|
||||
"steam_mephit": {Slug: "steam_mephit", Name: "Steam Mephit", Size: "small", Type: "elemental", CR: 0.25, XP: 50, HP: 21, AC: 10, SpeedWalk: 30, STR: 5, DEX: 11, CON: 10, INT: 11, WIS: 10, CHA: 12, Attacks: []SRDStatAttack{{Name: "Claws", AttackBonus: 2, DamageDice: "2d4", DamageBonus: 0, AvgDamage: 5, DamageType: "slashing"}}, Traits: []string{"Death Burst", "Innate Spellcasting (1/Day)"}},
|
||||
"swarm_of_bats": {Slug: "swarm_of_bats", Name: "Swarm of Bats", Size: "medium", Type: "beast", CR: 0.25, XP: 50, HP: 22, AC: 12, SpeedWalk: 30, STR: 5, DEX: 15, CON: 10, INT: 2, WIS: 12, CHA: 4, Attacks: []SRDStatAttack{{Name: "Bites", AttackBonus: 4, DamageDice: "2d4", DamageBonus: 0, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Echolocation", "Keen Hearing", "Swarm"}},
|
||||
"swarm_of_rats": {Slug: "swarm_of_rats", Name: "Swarm of Rats", Size: "medium", Type: "beast", CR: 0.25, XP: 50, HP: 24, AC: 10, SpeedWalk: 30, STR: 9, DEX: 11, CON: 9, INT: 2, WIS: 10, CHA: 3, Attacks: []SRDStatAttack{{Name: "Bites", AttackBonus: 2, DamageDice: "2d6", DamageBonus: 0, AvgDamage: 7, DamageType: "piercing"}}, Traits: []string{"Keen Smell", "Swarm"}},
|
||||
"swarm_of_ravens": {Slug: "swarm_of_ravens", Name: "Swarm of Ravens", Size: "medium", Type: "beast", CR: 0.25, XP: 50, HP: 24, AC: 12, SpeedWalk: 10, STR: 6, DEX: 14, CON: 8, INT: 3, WIS: 12, CHA: 6, Attacks: []SRDStatAttack{{Name: "Beaks", AttackBonus: 4, DamageDice: "2d6", DamageBonus: 0, AvgDamage: 7, DamageType: "piercing"}}, Traits: []string{"Swarm"}},
|
||||
"violet_fungus": {Slug: "violet_fungus", Name: "Violet Fungus", Size: "medium", Type: "plant", CR: 0.25, XP: 50, HP: 18, AC: 5, SpeedWalk: 5, STR: 3, DEX: 1, CON: 10, INT: 1, WIS: 3, CHA: 1, Multiattack: "The fungus makes 1d4 Rotting Touch attacks.", Attacks: []SRDStatAttack{{Name: "Rotting Touch", AttackBonus: 2, DamageDice: "1d8", DamageBonus: 0, AvgDamage: 4, DamageType: "necrotic"}}, Traits: []string{"False Appearance"}},
|
||||
"wolf": {Slug: "wolf", Name: "Wolf", Size: "medium", Type: "beast", CR: 0.25, XP: 50, HP: 11, AC: 13, SpeedWalk: 40, STR: 12, DEX: 15, CON: 12, INT: 3, WIS: 12, CHA: 6, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "2d4", DamageBonus: 2, AvgDamage: 7, DamageType: "piercing"}}, Traits: []string{"Keen Hearing and Smell", "Pack Tactics"}},
|
||||
"zombie": {Slug: "zombie", Name: "Zombie", Size: "medium", Type: "undead", CR: 0.25, XP: 50, HP: 22, AC: 8, SpeedWalk: 20, STR: 13, DEX: 6, CON: 16, INT: 3, WIS: 6, CHA: 5, Attacks: []SRDStatAttack{{Name: "Slam", AttackBonus: 3, DamageDice: "1d6", DamageBonus: 1, AvgDamage: 4, DamageType: "bludgeoning"}}, Traits: []string{"Undead Fortitude"}},
|
||||
"ape": {Slug: "ape", Name: "Ape", Size: "medium", Type: "beast", CR: 0.5, XP: 100, HP: 19, AC: 12, SpeedWalk: 30, STR: 16, DEX: 14, CON: 14, INT: 6, WIS: 12, CHA: 7, Multiattack: "The ape makes two fist attacks.", Attacks: []SRDStatAttack{{Name: "Fist", AttackBonus: 5, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "bludgeoning"}, {Name: "Rock", AttackBonus: 5, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "bludgeoning"}}},
|
||||
"black_bear": {Slug: "black_bear", Name: "Black Bear", Size: "medium", Type: "beast", CR: 0.5, XP: 100, HP: 19, AC: 11, SpeedWalk: 40, STR: 15, DEX: 10, CON: 14, INT: 2, WIS: 12, CHA: 7, Multiattack: "The bear makes two attacks: one with its bite and one with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 3, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}, {Name: "Claws", AttackBonus: 3, DamageDice: "2d4", DamageBonus: 2, AvgDamage: 7, DamageType: "slashing"}}, Traits: []string{"Keen Smell"}},
|
||||
"cockatrice": {Slug: "cockatrice", Name: "Cockatrice", Size: "small", Type: "monstrosity", CR: 0.5, XP: 100, HP: 27, AC: 11, SpeedWalk: 20, STR: 6, DEX: 12, CON: 12, INT: 2, WIS: 13, CHA: 5, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 3, DamageDice: "1d4", DamageBonus: 1, AvgDamage: 3, DamageType: "piercing"}}},
|
||||
"crocodile": {Slug: "crocodile", Name: "Crocodile", Size: "large", Type: "beast", CR: 0.5, XP: 100, HP: 19, AC: 12, SpeedWalk: 20, STR: 15, DEX: 10, CON: 13, INT: 2, WIS: 10, CHA: 5, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d10", DamageBonus: 2, AvgDamage: 7, DamageType: "piercing"}}, Traits: []string{"Hold Breath"}},
|
||||
"darkmantle": {Slug: "darkmantle", Name: "Darkmantle", Size: "small", Type: "monstrosity", CR: 0.5, XP: 100, HP: 22, AC: 11, SpeedWalk: 10, STR: 16, DEX: 12, CON: 13, INT: 2, WIS: 10, CHA: 5, Attacks: []SRDStatAttack{{Name: "Crush", AttackBonus: 5, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "bludgeoning"}}, Traits: []string{"Echolocation", "False Appearance"}},
|
||||
"deep_gnome_svirfneblin": {Slug: "deep_gnome_svirfneblin", Name: "Deep Gnome (Svirfneblin)", Size: "small", Type: "humanoid", CR: 0.5, XP: 100, HP: 16, AC: 15, SpeedWalk: 20, STR: 15, DEX: 14, CON: 14, INT: 12, WIS: 10, CHA: 9, Attacks: []SRDStatAttack{{Name: "War Pick", AttackBonus: 4, DamageDice: "1d8", DamageBonus: 2, AvgDamage: 6, DamageType: "piercing"}, {Name: "Poisoned Dart", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "piercing"}}, Traits: []string{"Stone Camouflage", "Gnome Cunning", "Innate Spellcasting"}},
|
||||
"dust_mephit": {Slug: "dust_mephit", Name: "Dust Mephit", Size: "small", Type: "elemental", CR: 0.5, XP: 100, HP: 17, AC: 12, SpeedWalk: 30, STR: 5, DEX: 14, CON: 10, INT: 9, WIS: 11, CHA: 10, Attacks: []SRDStatAttack{{Name: "Claws", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "slashing"}}, Traits: []string{"Death Burst", "Innate Spellcasting (1/Day)"}},
|
||||
"giant_goat": {Slug: "giant_goat", Name: "Giant Goat", Size: "large", Type: "beast", CR: 0.5, XP: 100, HP: 19, AC: 11, SpeedWalk: 40, STR: 17, DEX: 11, CON: 12, INT: 3, WIS: 12, CHA: 6, Attacks: []SRDStatAttack{{Name: "Ram", AttackBonus: 5, DamageDice: "2d4", DamageBonus: 3, AvgDamage: 8, DamageType: "bludgeoning"}}, Traits: []string{"Charge", "Sure-Footed"}},
|
||||
"giant_sea_horse": {Slug: "giant_sea_horse", Name: "Giant Sea Horse", Size: "large", Type: "beast", CR: 0.5, XP: 100, HP: 16, AC: 13, SpeedWalk: 40, STR: 12, DEX: 15, CON: 11, INT: 2, WIS: 12, CHA: 5, Attacks: []SRDStatAttack{{Name: "Ram", AttackBonus: 3, DamageDice: "1d6", DamageBonus: 1, AvgDamage: 4, DamageType: "bludgeoning"}}, Traits: []string{"Charge", "Water Breathing"}},
|
||||
"giant_wasp": {Slug: "giant_wasp", Name: "Giant Wasp", Size: "medium", Type: "beast", CR: 0.5, XP: 100, HP: 13, AC: 12, SpeedWalk: 10, STR: 10, DEX: 14, CON: 10, INT: 1, WIS: 10, CHA: 3, Attacks: []SRDStatAttack{{Name: "Sting", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}}},
|
||||
"gnoll": {Slug: "gnoll", Name: "Gnoll", Size: "medium", Type: "humanoid", CR: 0.5, XP: 100, HP: 22, AC: 15, SpeedWalk: 30, STR: 14, DEX: 12, CON: 11, INT: 6, WIS: 10, CHA: 7, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "piercing"}, {Name: "Spear", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}, {Name: "Longbow", AttackBonus: 3, DamageDice: "1d8", DamageBonus: 1, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Rampage"}},
|
||||
"gray_ooze": {Slug: "gray_ooze", Name: "Gray Ooze", Size: "medium", Type: "ooze", CR: 0.5, XP: 100, HP: 22, AC: 8, SpeedWalk: 10, STR: 12, DEX: 6, CON: 16, INT: 1, WIS: 6, CHA: 2, Attacks: []SRDStatAttack{{Name: "Pseudopod", AttackBonus: 3, DamageDice: "1d6", DamageBonus: 1, AvgDamage: 4, DamageType: "bludgeoning"}}, Traits: []string{"Amorphous", "Corrode Metal", "False Appearance"}},
|
||||
"hobgoblin": {Slug: "hobgoblin", Name: "Hobgoblin", Size: "medium", Type: "humanoid", CR: 0.5, XP: 100, HP: 11, AC: 18, SpeedWalk: 30, STR: 13, DEX: 12, CON: 12, INT: 10, WIS: 10, CHA: 9, Attacks: []SRDStatAttack{{Name: "Longsword", AttackBonus: 3, DamageDice: "1d8", DamageBonus: 1, AvgDamage: 5, DamageType: "slashing"}, {Name: "Longbow", AttackBonus: 3, DamageDice: "1d8", DamageBonus: 1, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Martial Advantage"}},
|
||||
"ice_mephit": {Slug: "ice_mephit", Name: "Ice Mephit", Size: "small", Type: "elemental", CR: 0.5, XP: 100, HP: 21, AC: 11, SpeedWalk: 30, STR: 7, DEX: 13, CON: 10, INT: 9, WIS: 11, CHA: 12, Attacks: []SRDStatAttack{{Name: "Claws", AttackBonus: 3, DamageDice: "1d4", DamageBonus: 1, AvgDamage: 3, DamageType: "slashing"}}, Traits: []string{"Death Burst", "False Appearance", "Innate Spellcasting (1/Day)"}},
|
||||
"lizardfolk": {Slug: "lizardfolk", Name: "Lizardfolk", Size: "medium", Type: "humanoid", CR: 0.5, XP: 100, HP: 22, AC: 15, SpeedWalk: 30, STR: 15, DEX: 10, CON: 13, INT: 7, WIS: 12, CHA: 7, Multiattack: "The lizardfolk makes two melee attacks, each one with a different weapon.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}, {Name: "Heavy Club", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "bludgeoning"}, {Name: "Javelin", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}, {Name: "Spiked Shield", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Hold Breath"}},
|
||||
"magma_mephit": {Slug: "magma_mephit", Name: "Magma Mephit", Size: "small", Type: "elemental", CR: 0.5, XP: 100, HP: 22, AC: 11, SpeedWalk: 30, STR: 8, DEX: 12, CON: 12, INT: 7, WIS: 10, CHA: 10, Attacks: []SRDStatAttack{{Name: "Claws", AttackBonus: 3, DamageDice: "1d4", DamageBonus: 1, AvgDamage: 3, DamageType: "slashing"}}, Traits: []string{"Death Burst", "False Appearance", "Innate Spellcasting (1/Day)"}},
|
||||
"magmin": {Slug: "magmin", Name: "Magmin", Size: "small", Type: "elemental", CR: 0.5, XP: 100, HP: 9, AC: 14, SpeedWalk: 30, STR: 7, DEX: 15, CON: 12, INT: 8, WIS: 11, CHA: 10, Attacks: []SRDStatAttack{{Name: "Touch", AttackBonus: 4, DamageDice: "2d6", DamageBonus: 0, AvgDamage: 7, DamageType: "fire"}}, Traits: []string{"Death Burst", "Ignited Illumination"}},
|
||||
"orc": {Slug: "orc", Name: "Orc", Size: "medium", Type: "humanoid", CR: 0.5, XP: 100, HP: 15, AC: 13, SpeedWalk: 30, STR: 16, DEX: 12, CON: 16, INT: 7, WIS: 11, CHA: 10, Attacks: []SRDStatAttack{{Name: "Greataxe", AttackBonus: 5, DamageDice: "1d12", DamageBonus: 3, AvgDamage: 9, DamageType: "slashing"}, {Name: "Javelin", AttackBonus: 5, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "piercing"}}, Traits: []string{"Aggressive"}},
|
||||
"reef_shark": {Slug: "reef_shark", Name: "Reef Shark", Size: "medium", Type: "beast", CR: 0.5, XP: 100, HP: 22, AC: 12, SpeedWalk: 40, STR: 14, DEX: 13, CON: 13, INT: 1, WIS: 10, CHA: 4, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d8", DamageBonus: 2, AvgDamage: 6, DamageType: "piercing"}}, Traits: []string{"Pack Tactics", "Water Breathing"}},
|
||||
"rust_monster": {Slug: "rust_monster", Name: "Rust Monster", Size: "medium", Type: "monstrosity", CR: 0.5, XP: 100, HP: 27, AC: 14, SpeedWalk: 40, STR: 13, DEX: 12, CON: 13, INT: 2, WIS: 13, CHA: 6, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 3, DamageDice: "1d8", DamageBonus: 1, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Iron Scent", "Rust Metal"}},
|
||||
"sahuagin": {Slug: "sahuagin", Name: "Sahuagin", Size: "medium", Type: "humanoid", CR: 0.5, XP: 100, HP: 22, AC: 12, SpeedWalk: 30, STR: 13, DEX: 11, CON: 12, INT: 12, WIS: 13, CHA: 9, Multiattack: "The sahuagin makes two melee attacks: one with its bite and one with its claws or spear.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 3, DamageDice: "1d4", DamageBonus: 1, AvgDamage: 3, DamageType: "piercing"}, {Name: "Claws", AttackBonus: 3, DamageDice: "1d4", DamageBonus: 1, AvgDamage: 3, DamageType: "slashing"}, {Name: "Spear", AttackBonus: 3, DamageDice: "1d6", DamageBonus: 1, AvgDamage: 4, DamageType: "piercing"}}, Traits: []string{"Blood Frenzy", "Limited Amphibiousness", "Shark Telepathy"}},
|
||||
"satyr": {Slug: "satyr", Name: "Satyr", Size: "medium", Type: "fey", CR: 0.5, XP: 100, HP: 31, AC: 14, SpeedWalk: 40, STR: 12, DEX: 16, CON: 11, INT: 12, WIS: 10, CHA: 14, Attacks: []SRDStatAttack{{Name: "Ram", AttackBonus: 3, DamageDice: "2d4", DamageBonus: 1, AvgDamage: 6, DamageType: "bludgeoning"}, {Name: "Shortsword", AttackBonus: 5, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "piercing"}, {Name: "Shortbow", AttackBonus: 5, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "piercing"}}, Traits: []string{"Magic Resistance"}},
|
||||
"scout": {Slug: "scout", Name: "Scout", Size: "medium", Type: "humanoid", CR: 0.5, XP: 100, HP: 16, AC: 13, SpeedWalk: 30, STR: 11, DEX: 14, CON: 12, INT: 11, WIS: 13, CHA: 11, Multiattack: "The scout makes two melee attacks or two ranged attacks.", Attacks: []SRDStatAttack{{Name: "Shortsword", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}, {Name: "Longbow", AttackBonus: 4, DamageDice: "1d8", DamageBonus: 2, AvgDamage: 6, DamageType: "piercing"}}, Traits: []string{"Keen Hearing and Sight"}},
|
||||
"shadow": {Slug: "shadow", Name: "Shadow", Size: "medium", Type: "undead", CR: 0.5, XP: 100, HP: 16, AC: 12, SpeedWalk: 40, STR: 6, DEX: 14, CON: 13, INT: 6, WIS: 10, CHA: 8, Attacks: []SRDStatAttack{{Name: "Strength Drain", AttackBonus: 4, DamageDice: "2d6", DamageBonus: 2, AvgDamage: 9, DamageType: "necrotic"}}, Traits: []string{"Amorphous", "Shadow Stealth", "Sunlight Weakness"}},
|
||||
"swarm_of_beetles": {Slug: "swarm_of_beetles", Name: "Swarm of Beetles", Size: "medium", Type: "beast", CR: 0.5, XP: 100, HP: 22, AC: 12, SpeedWalk: 20, STR: 3, DEX: 13, CON: 10, INT: 1, WIS: 7, CHA: 1, Attacks: []SRDStatAttack{{Name: "Bites", AttackBonus: 3, DamageDice: "4d4", DamageBonus: 0, AvgDamage: 10, DamageType: "piercing"}}, Traits: []string{"Swarm"}},
|
||||
"swarm_of_centipedes": {Slug: "swarm_of_centipedes", Name: "Swarm of Centipedes", Size: "medium", Type: "beast", CR: 0.5, XP: 100, HP: 22, AC: 12, SpeedWalk: 20, STR: 3, DEX: 13, CON: 10, INT: 1, WIS: 7, CHA: 1, Attacks: []SRDStatAttack{{Name: "Bites", AttackBonus: 3, DamageDice: "4d4", DamageBonus: 0, AvgDamage: 10, DamageType: "piercing"}}, Traits: []string{"Swarm"}},
|
||||
"swarm_of_insects": {Slug: "swarm_of_insects", Name: "Swarm of Insects", Size: "medium", Type: "beast", CR: 0.5, XP: 100, HP: 22, AC: 12, SpeedWalk: 20, STR: 3, DEX: 13, CON: 10, INT: 1, WIS: 7, CHA: 1, Attacks: []SRDStatAttack{{Name: "Bites", AttackBonus: 3, DamageDice: "4d4", DamageBonus: 0, AvgDamage: 10, DamageType: "piercing"}}, Traits: []string{"Swarm"}},
|
||||
"swarm_of_spiders": {Slug: "swarm_of_spiders", Name: "Swarm of Spiders", Size: "medium", Type: "beast", CR: 0.5, XP: 100, HP: 22, AC: 12, SpeedWalk: 20, STR: 3, DEX: 13, CON: 10, INT: 1, WIS: 7, CHA: 1, Attacks: []SRDStatAttack{{Name: "Bites", AttackBonus: 3, DamageDice: "4d4", DamageBonus: 0, AvgDamage: 10, DamageType: "piercing"}}, Traits: []string{"Swarm", "Spider Climb", "Web Sense", "Web Walker"}},
|
||||
"swarm_of_wasps": {Slug: "swarm_of_wasps", Name: "Swarm of Wasps", Size: "medium", Type: "beast", CR: 0.5, XP: 100, HP: 22, AC: 12, SpeedWalk: 5, STR: 3, DEX: 13, CON: 10, INT: 1, WIS: 7, CHA: 1, Attacks: []SRDStatAttack{{Name: "Bites", AttackBonus: 3, DamageDice: "4d4", DamageBonus: 0, AvgDamage: 10, DamageType: "piercing"}}, Traits: []string{"Swarm"}},
|
||||
"thug": {Slug: "thug", Name: "Thug", Size: "medium", Type: "humanoid", CR: 0.5, XP: 100, HP: 32, AC: 11, SpeedWalk: 30, STR: 15, DEX: 11, CON: 14, INT: 10, WIS: 10, CHA: 11, Multiattack: "The thug makes two melee attacks.", Attacks: []SRDStatAttack{{Name: "Mace", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "bludgeoning"}, {Name: "Heavy Crossbow", AttackBonus: 2, DamageDice: "1d10", DamageBonus: 0, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Pack Tactics"}},
|
||||
"warhorse": {Slug: "warhorse", Name: "Warhorse", Size: "large", Type: "beast", CR: 0.5, XP: 100, HP: 19, AC: 11, SpeedWalk: 60, STR: 18, DEX: 12, CON: 13, INT: 2, WIS: 12, CHA: 7, Attacks: []SRDStatAttack{{Name: "Hooves", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "bludgeoning"}}, Traits: []string{"Trampling Charge"}},
|
||||
"warhorse_skeleton": {Slug: "warhorse_skeleton", Name: "Warhorse Skeleton", Size: "large", Type: "undead", CR: 0.5, XP: 100, HP: 22, AC: 13, SpeedWalk: 60, STR: 18, DEX: 12, CON: 15, INT: 2, WIS: 8, CHA: 5, Attacks: []SRDStatAttack{{Name: "Hooves", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "bludgeoning"}}},
|
||||
"worg": {Slug: "worg", Name: "Worg", Size: "large", Type: "monstrosity", CR: 0.5, XP: 100, HP: 26, AC: 13, SpeedWalk: 50, STR: 16, DEX: 13, CON: 13, INT: 7, WIS: 11, CHA: 8, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 5, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "piercing"}}, Traits: []string{"Keen Hearing and Smell"}},
|
||||
"animated_armor": {Slug: "animated_armor", Name: "Animated Armor", Size: "medium", Type: "construct", CR: 1, XP: 200, HP: 33, AC: 18, SpeedWalk: 25, STR: 14, DEX: 11, CON: 13, INT: 1, WIS: 3, CHA: 1, Multiattack: "The armor makes two melee attacks.", Attacks: []SRDStatAttack{{Name: "Slam", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "bludgeoning"}}, Traits: []string{"Antimagic Susceptibility", "False Appearance"}},
|
||||
"brass_dragon_wyrmling": {Slug: "brass_dragon_wyrmling", Name: "Brass Dragon Wyrmling", Size: "medium", Type: "dragon", CR: 1, XP: 200, HP: 16, AC: 16, SpeedWalk: 30, STR: 15, DEX: 10, CON: 13, INT: 10, WIS: 11, CHA: 13, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d10", DamageBonus: 2, AvgDamage: 7, DamageType: "piercing"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "4d6", DamageBonus: 0, AvgDamage: 14, DamageType: "fire"}}},
|
||||
"brown_bear": {Slug: "brown_bear", Name: "Brown Bear", Size: "large", Type: "beast", CR: 1, XP: 200, HP: 34, AC: 11, SpeedWalk: 40, STR: 19, DEX: 10, CON: 16, INT: 2, WIS: 13, CHA: 7, Multiattack: "The bear makes two attacks: one with its bite and one with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 5, DamageDice: "1d8", DamageBonus: 4, AvgDamage: 8, DamageType: "piercing"}, {Name: "Claws", AttackBonus: 5, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "slashing"}}, Traits: []string{"Keen Smell"}},
|
||||
"bugbear": {Slug: "bugbear", Name: "Bugbear", Size: "medium", Type: "humanoid", CR: 1, XP: 200, HP: 27, AC: 16, SpeedWalk: 30, STR: 15, DEX: 14, CON: 13, INT: 8, WIS: 11, CHA: 9, Attacks: []SRDStatAttack{{Name: "Morningstar", AttackBonus: 4, DamageDice: "2d8", DamageBonus: 2, AvgDamage: 11, DamageType: "piercing"}, {Name: "Javelin", AttackBonus: 4, DamageDice: "2d6", DamageBonus: 2, AvgDamage: 9, DamageType: "piercing"}}, Traits: []string{"Brute", "Surprise Attack"}},
|
||||
"copper_dragon_wyrmling": {Slug: "copper_dragon_wyrmling", Name: "Copper Dragon Wyrmling", Size: "medium", Type: "dragon", CR: 1, XP: 200, HP: 22, AC: 16, SpeedWalk: 30, STR: 15, DEX: 12, CON: 13, INT: 14, WIS: 11, CHA: 13, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d10", DamageBonus: 2, AvgDamage: 7, DamageType: "piercing"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "4d8", DamageBonus: 0, AvgDamage: 18, DamageType: "acid"}}},
|
||||
"death_dog": {Slug: "death_dog", Name: "Death Dog", Size: "medium", Type: "monstrosity", CR: 1, XP: 200, HP: 39, AC: 12, SpeedWalk: 40, STR: 15, DEX: 14, CON: 14, INT: 3, WIS: 13, CHA: 6, Multiattack: "The dog makes two bite attacks.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Two-Headed"}},
|
||||
"dire_wolf": {Slug: "dire_wolf", Name: "Dire Wolf", Size: "large", Type: "beast", CR: 1, XP: 200, HP: 37, AC: 14, SpeedWalk: 50, STR: 17, DEX: 15, CON: 15, INT: 3, WIS: 12, CHA: 7, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 5, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "piercing"}}, Traits: []string{"Keen Hearing and Smell", "Pack Tactics"}},
|
||||
"dryad": {Slug: "dryad", Name: "Dryad", Size: "medium", Type: "fey", CR: 1, XP: 200, HP: 22, AC: 11, SpeedWalk: 30, STR: 10, DEX: 12, CON: 11, INT: 14, WIS: 15, CHA: 18, Attacks: []SRDStatAttack{{Name: "Club", AttackBonus: 2, DamageDice: "1d4", DamageBonus: 0, AvgDamage: 2, DamageType: "bludgeoning"}}, Traits: []string{"Innate Spellcasting", "Magic Resistance", "Speak with Beasts and Plants", "Tree Stride"}},
|
||||
"duergar": {Slug: "duergar", Name: "Duergar", Size: "medium", Type: "humanoid", CR: 1, XP: 200, HP: 26, AC: 16, SpeedWalk: 25, STR: 14, DEX: 11, CON: 14, INT: 11, WIS: 10, CHA: 9, Attacks: []SRDStatAttack{{Name: "War Pick", AttackBonus: 4, DamageDice: "1d8", DamageBonus: 2, AvgDamage: 6, DamageType: "piercing"}, {Name: "Javelin", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Duergar Resilience", "Sunlight Sensitivity"}},
|
||||
"ghoul": {Slug: "ghoul", Name: "Ghoul", Size: "medium", Type: "undead", CR: 1, XP: 200, HP: 22, AC: 12, SpeedWalk: 30, STR: 13, DEX: 15, CON: 10, INT: 7, WIS: 10, CHA: 6, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 2, DamageDice: "2d6", DamageBonus: 2, AvgDamage: 9, DamageType: "piercing"}}},
|
||||
"giant_eagle": {Slug: "giant_eagle", Name: "Giant Eagle", Size: "large", Type: "beast", CR: 1, XP: 200, HP: 26, AC: 13, SpeedWalk: 10, STR: 16, DEX: 17, CON: 13, INT: 8, WIS: 14, CHA: 10, Multiattack: "The eagle makes two attacks: one with its beak and one with its talons.", Attacks: []SRDStatAttack{{Name: "Beak", AttackBonus: 5, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "piercing"}, {Name: "Talons", AttackBonus: 5, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "slashing"}}, Traits: []string{"Keen Sight"}},
|
||||
"giant_hyena": {Slug: "giant_hyena", Name: "Giant Hyena", Size: "large", Type: "beast", CR: 1, XP: 200, HP: 45, AC: 12, SpeedWalk: 50, STR: 16, DEX: 14, CON: 14, INT: 2, WIS: 12, CHA: 7, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 5, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "piercing"}}, Traits: []string{"Rampage"}},
|
||||
"giant_octopus": {Slug: "giant_octopus", Name: "Giant Octopus", Size: "large", Type: "beast", CR: 1, XP: 200, HP: 52, AC: 11, SpeedWalk: 10, STR: 17, DEX: 13, CON: 13, INT: 4, WIS: 10, CHA: 4, Attacks: []SRDStatAttack{{Name: "Tentacles", AttackBonus: 5, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "bludgeoning"}}, Traits: []string{"Hold Breath", "Underwater Camouflage", "Water Breathing"}},
|
||||
"giant_spider": {Slug: "giant_spider", Name: "Giant Spider", Size: "large", Type: "beast", CR: 1, XP: 200, HP: 26, AC: 14, SpeedWalk: 30, STR: 14, DEX: 16, CON: 12, INT: 2, WIS: 11, CHA: 4, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 5, DamageDice: "1d8", DamageBonus: 3, AvgDamage: 7, DamageType: "piercing"}}, Traits: []string{"Spider Climb", "Web Sense", "Web Walker"}},
|
||||
"giant_toad": {Slug: "giant_toad", Name: "Giant Toad", Size: "large", Type: "beast", CR: 1, XP: 200, HP: 39, AC: 11, SpeedWalk: 20, STR: 15, DEX: 13, CON: 13, INT: 2, WIS: 10, CHA: 3, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d10", DamageBonus: 2, AvgDamage: 7, DamageType: "piercing"}}, Traits: []string{"Amphibious", "Standing Leap"}},
|
||||
"giant_vulture": {Slug: "giant_vulture", Name: "Giant Vulture", Size: "large", Type: "beast", CR: 1, XP: 200, HP: 22, AC: 10, SpeedWalk: 10, STR: 15, DEX: 10, CON: 15, INT: 6, WIS: 12, CHA: 7, Multiattack: "The vulture makes two attacks: one with its beak and one with its talons.", Attacks: []SRDStatAttack{{Name: "Beak", AttackBonus: 4, DamageDice: "2d4", DamageBonus: 2, AvgDamage: 7, DamageType: "piercing"}, {Name: "Talons", AttackBonus: 4, DamageDice: "2d6", DamageBonus: 2, AvgDamage: 9, DamageType: "slashing"}}, Traits: []string{"Keen Sight and Smell", "Pack Tactics"}},
|
||||
"harpy": {Slug: "harpy", Name: "Harpy", Size: "medium", Type: "monstrosity", CR: 1, XP: 200, HP: 38, AC: 11, SpeedWalk: 20, STR: 12, DEX: 13, CON: 12, INT: 7, WIS: 10, CHA: 13, Multiattack: "The harpy makes two attacks: one with its claws and one with its club.", Attacks: []SRDStatAttack{{Name: "Claws", AttackBonus: 3, DamageDice: "2d4", DamageBonus: 1, AvgDamage: 6, DamageType: "slashing"}, {Name: "Club", AttackBonus: 3, DamageDice: "1d4", DamageBonus: 1, AvgDamage: 3, DamageType: "bludgeoning"}}},
|
||||
"hippogriff": {Slug: "hippogriff", Name: "Hippogriff", Size: "large", Type: "monstrosity", CR: 1, XP: 200, HP: 19, AC: 11, SpeedWalk: 40, STR: 17, DEX: 13, CON: 13, INT: 2, WIS: 12, CHA: 8, Multiattack: "The hippogriff makes two attacks: one with its beak and one with its claws.", Attacks: []SRDStatAttack{{Name: "Beak", AttackBonus: 5, DamageDice: "1d10", DamageBonus: 3, AvgDamage: 8, DamageType: "piercing"}, {Name: "Claws", AttackBonus: 5, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "slashing"}}, Traits: []string{"Keen Sight"}},
|
||||
"imp": {Slug: "imp", Name: "Imp", Size: "tiny", Type: "fiend", CR: 1, XP: 200, HP: 10, AC: 13, SpeedWalk: 20, STR: 6, DEX: 17, CON: 13, INT: 11, WIS: 12, CHA: 14, Attacks: []SRDStatAttack{{Name: "Sting (Bite in Beast Form)", AttackBonus: 5, DamageDice: "1d4", DamageBonus: 3, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Shapechanger", "Devil's Sight", "Magic Resistance", "Variant: Familiar"}},
|
||||
"lion": {Slug: "lion", Name: "Lion", Size: "large", Type: "beast", CR: 1, XP: 200, HP: 26, AC: 12, SpeedWalk: 50, STR: 17, DEX: 15, CON: 13, INT: 3, WIS: 12, CHA: 8, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 5, DamageDice: "1d8", DamageBonus: 3, AvgDamage: 7, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 5, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "slashing"}}, Traits: []string{"Keen Smell", "Pack Tactics", "Pounce", "Running Leap"}},
|
||||
"quasit": {Slug: "quasit", Name: "Quasit", Size: "tiny", Type: "fiend", CR: 1, XP: 200, HP: 7, AC: 13, SpeedWalk: 40, STR: 5, DEX: 17, CON: 10, INT: 7, WIS: 10, CHA: 10, Attacks: []SRDStatAttack{{Name: "Claw (Bite in Beast Form)", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 3, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Shapechanger", "Magic Resistance", "Variant: Familiar"}},
|
||||
"specter": {Slug: "specter", Name: "Specter", Size: "medium", Type: "undead", CR: 1, XP: 200, HP: 22, AC: 12, SpeedWalk: 50, STR: 1, DEX: 14, CON: 11, INT: 10, WIS: 10, CHA: 11, Attacks: []SRDStatAttack{{Name: "Life Drain", AttackBonus: 4, DamageDice: "3d6", DamageBonus: 0, AvgDamage: 10, DamageType: "necrotic"}}, Traits: []string{"Incorporeal Movement", "Sunlight Sensitivity"}},
|
||||
"spy": {Slug: "spy", Name: "Spy", Size: "medium", Type: "humanoid", CR: 1, XP: 200, HP: 27, AC: 12, SpeedWalk: 30, STR: 10, DEX: 15, CON: 10, INT: 12, WIS: 14, CHA: 16, Multiattack: "The spy makes two melee attacks.", Attacks: []SRDStatAttack{{Name: "Shortsword", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}, {Name: "Hand Crossbow", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Cunning Action", "Sneak Attack (1/Turn)"}},
|
||||
"swarm_of_quippers": {Slug: "swarm_of_quippers", Name: "Swarm of Quippers", Size: "medium", Type: "beast", CR: 1, XP: 200, HP: 28, AC: 13, SpeedWalk: 40, STR: 13, DEX: 16, CON: 9, INT: 1, WIS: 7, CHA: 2, Attacks: []SRDStatAttack{{Name: "Bites", AttackBonus: 5, DamageDice: "4d6", DamageBonus: 0, AvgDamage: 14, DamageType: "piercing"}}, Traits: []string{"Blood Frenzy", "Swarm", "Water Breathing"}},
|
||||
"tiger": {Slug: "tiger", Name: "Tiger", Size: "large", Type: "beast", CR: 1, XP: 200, HP: 37, AC: 12, SpeedWalk: 40, STR: 17, DEX: 15, CON: 14, INT: 3, WIS: 12, CHA: 8, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 5, DamageDice: "1d10", DamageBonus: 3, AvgDamage: 8, DamageType: "piercing"}}, Traits: []string{"Keen Smell", "Pounce"}},
|
||||
"ankheg": {Slug: "ankheg", Name: "Ankheg", Size: "large", Type: "monstrosity", CR: 2, XP: 450, HP: 39, AC: 14, SpeedWalk: 30, STR: 17, DEX: 11, CON: 13, INT: 1, WIS: 13, CHA: 6, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 5, DamageDice: "2d6+1d6", DamageBonus: 3, AvgDamage: 3, DamageType: "slashing"}, {Name: "Acid Spray (Recharge 6)", AttackBonus: 0, DamageDice: "3d6", DamageBonus: 0, AvgDamage: 10, DamageType: "acid"}}},
|
||||
"awakened_tree": {Slug: "awakened_tree", Name: "Awakened Tree", Size: "huge", Type: "plant", CR: 2, XP: 450, HP: 59, AC: 13, SpeedWalk: 20, STR: 19, DEX: 6, CON: 15, INT: 10, WIS: 10, CHA: 7, Attacks: []SRDStatAttack{{Name: "Slam", AttackBonus: 6, DamageDice: "3d6", DamageBonus: 4, AvgDamage: 14, DamageType: "bludgeoning"}}, Traits: []string{"False Appearance"}},
|
||||
"azer": {Slug: "azer", Name: "Azer", Size: "medium", Type: "elemental", CR: 2, XP: 450, HP: 39, AC: 17, SpeedWalk: 30, STR: 17, DEX: 12, CON: 15, INT: 12, WIS: 13, CHA: 10, Attacks: []SRDStatAttack{{Name: "Warhammer", AttackBonus: 5, DamageDice: "1d8+1d6", DamageBonus: 3, AvgDamage: 3, DamageType: "bludgeoning"}}, Traits: []string{"Heated Body", "Heated Weapons", "Illumination"}},
|
||||
"bandit_captain": {Slug: "bandit_captain", Name: "Bandit Captain", Size: "medium", Type: "humanoid", CR: 2, XP: 450, HP: 65, AC: 15, SpeedWalk: 30, STR: 15, DEX: 16, CON: 14, INT: 14, WIS: 11, CHA: 14, Multiattack: "The captain makes three melee attacks: two with its scimitar and one with its dagger.", Attacks: []SRDStatAttack{{Name: "Scimitar", AttackBonus: 5, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "slashing"}, {Name: "Dagger", AttackBonus: 5, DamageDice: "1d4", DamageBonus: 3, AvgDamage: 5, DamageType: "piercing"}}},
|
||||
"berserker": {Slug: "berserker", Name: "Berserker", Size: "medium", Type: "humanoid", CR: 2, XP: 450, HP: 67, AC: 13, SpeedWalk: 30, STR: 16, DEX: 12, CON: 17, INT: 9, WIS: 11, CHA: 9, Attacks: []SRDStatAttack{{Name: "Greataxe", AttackBonus: 5, DamageDice: "1d12", DamageBonus: 3, AvgDamage: 9, DamageType: "slashing"}}, Traits: []string{"Reckless"}},
|
||||
"black_dragon_wyrmling": {Slug: "black_dragon_wyrmling", Name: "Black Dragon Wyrmling", Size: "medium", Type: "dragon", CR: 2, XP: 450, HP: 33, AC: 17, SpeedWalk: 30, STR: 15, DEX: 14, CON: 13, INT: 10, WIS: 11, CHA: 13, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d10", DamageBonus: 2, AvgDamage: 7, DamageType: "piercing"}, {Name: "Acid Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "5d8", DamageBonus: 0, AvgDamage: 22, DamageType: "acid"}}, Traits: []string{"Amphibious"}},
|
||||
"bronze_dragon_wyrmling": {Slug: "bronze_dragon_wyrmling", Name: "Bronze Dragon Wyrmling", Size: "medium", Type: "dragon", CR: 2, XP: 450, HP: 32, AC: 17, SpeedWalk: 30, STR: 17, DEX: 10, CON: 15, INT: 12, WIS: 11, CHA: 15, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 5, DamageDice: "1d10", DamageBonus: 3, AvgDamage: 8, DamageType: "piercing"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "3d10", DamageBonus: 0, AvgDamage: 16, DamageType: "lightning"}}, Traits: []string{"Amphibious"}},
|
||||
"centaur": {Slug: "centaur", Name: "Centaur", Size: "large", Type: "monstrosity", CR: 2, XP: 450, HP: 45, AC: 12, SpeedWalk: 50, STR: 18, DEX: 14, CON: 14, INT: 9, WIS: 13, CHA: 11, Multiattack: "The centaur makes two attacks: one with its pike and one with its hooves or two with its longbow.", Attacks: []SRDStatAttack{{Name: "Pike", AttackBonus: 6, DamageDice: "1d10", DamageBonus: 4, AvgDamage: 9, DamageType: "piercing"}, {Name: "Hooves", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "bludgeoning"}, {Name: "Longbow", AttackBonus: 4, DamageDice: "1d8", DamageBonus: 2, AvgDamage: 6, DamageType: "piercing"}}, Traits: []string{"Charge"}},
|
||||
"cult_fanatic": {Slug: "cult_fanatic", Name: "Cult Fanatic", Size: "medium", Type: "humanoid", CR: 2, XP: 450, HP: 22, AC: 13, SpeedWalk: 30, STR: 11, DEX: 14, CON: 12, INT: 10, WIS: 13, CHA: 14, Multiattack: "The fanatic makes two melee attacks.", Attacks: []SRDStatAttack{{Name: "Dagger", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "piercing"}}, Traits: []string{"Dark Devotion", "Spellcasting"}},
|
||||
"druid": {Slug: "druid", Name: "Druid", Size: "medium", Type: "humanoid", CR: 2, XP: 450, HP: 27, AC: 11, SpeedWalk: 30, STR: 10, DEX: 12, CON: 13, INT: 12, WIS: 15, CHA: 11, Attacks: []SRDStatAttack{{Name: "Quarterstaff", AttackBonus: 2, DamageDice: "1d6", DamageBonus: 0, AvgDamage: 3, DamageType: "bludgeoning"}}, Traits: []string{"Spellcasting"}},
|
||||
"ettercap": {Slug: "ettercap", Name: "Ettercap", Size: "medium", Type: "monstrosity", CR: 2, XP: 450, HP: 44, AC: 13, SpeedWalk: 30, STR: 14, DEX: 15, CON: 13, INT: 7, WIS: 12, CHA: 8, Multiattack: "The ettercap makes two attacks: one with its bite and one with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d8", DamageBonus: 2, AvgDamage: 6, DamageType: "piercing"}, {Name: "Claws", AttackBonus: 4, DamageDice: "2d4", DamageBonus: 2, AvgDamage: 7, DamageType: "slashing"}, {Name: "Variant: Web Garrote", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "bludgeoning"}}, Traits: []string{"Spider Climb", "Web Sense", "Web Walker"}},
|
||||
"gargoyle": {Slug: "gargoyle", Name: "Gargoyle", Size: "medium", Type: "elemental", CR: 2, XP: 450, HP: 52, AC: 15, SpeedWalk: 30, STR: 15, DEX: 11, CON: 16, INT: 6, WIS: 11, CHA: 7, Multiattack: "The gargoyle makes two attacks: one with its bite and one with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}, {Name: "Claws", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "slashing"}}, Traits: []string{"False Appearance"}},
|
||||
"gelatinous_cube": {Slug: "gelatinous_cube", Name: "Gelatinous Cube", Size: "large", Type: "ooze", CR: 2, XP: 450, HP: 84, AC: 6, SpeedWalk: 15, STR: 14, DEX: 3, CON: 20, INT: 1, WIS: 6, CHA: 1, Attacks: []SRDStatAttack{{Name: "Pseudopod", AttackBonus: 4, DamageDice: "3d6", DamageBonus: 0, AvgDamage: 10, DamageType: "acid"}}, Traits: []string{"Ooze Cube", "Transparent"}},
|
||||
"ghast": {Slug: "ghast", Name: "Ghast", Size: "medium", Type: "undead", CR: 2, XP: 450, HP: 36, AC: 13, SpeedWalk: 30, STR: 16, DEX: 17, CON: 10, INT: 11, WIS: 10, CHA: 8, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 3, DamageDice: "2d8", DamageBonus: 3, AvgDamage: 12, DamageType: "piercing"}, {Name: "Claws", AttackBonus: 5, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "slashing"}}, Traits: []string{"Stench", "Turn Defiance"}},
|
||||
"giant_boar": {Slug: "giant_boar", Name: "Giant Boar", Size: "large", Type: "beast", CR: 2, XP: 450, HP: 42, AC: 12, SpeedWalk: 40, STR: 17, DEX: 10, CON: 16, INT: 2, WIS: 7, CHA: 5, Attacks: []SRDStatAttack{{Name: "Tusk", AttackBonus: 5, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "slashing"}}, Traits: []string{"Charge", "Relentless (Recharges after a Short or Long Rest)"}},
|
||||
"giant_constrictor_snake": {Slug: "giant_constrictor_snake", Name: "Giant Constrictor Snake", Size: "huge", Type: "beast", CR: 2, XP: 450, HP: 60, AC: 12, SpeedWalk: 30, STR: 19, DEX: 14, CON: 12, INT: 1, WIS: 10, CHA: 3, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "piercing"}, {Name: "Constrict", AttackBonus: 6, DamageDice: "2d8", DamageBonus: 4, AvgDamage: 13, DamageType: "bludgeoning"}}},
|
||||
"giant_elk": {Slug: "giant_elk", Name: "Giant Elk", Size: "huge", Type: "beast", CR: 2, XP: 450, HP: 42, AC: 15, SpeedWalk: 60, STR: 19, DEX: 16, CON: 14, INT: 7, WIS: 14, CHA: 10, Attacks: []SRDStatAttack{{Name: "Ram", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "bludgeoning"}, {Name: "Hooves", AttackBonus: 6, DamageDice: "4d8", DamageBonus: 4, AvgDamage: 22, DamageType: "bludgeoning"}}, Traits: []string{"Charge"}},
|
||||
"gibbering_mouther": {Slug: "gibbering_mouther", Name: "Gibbering Mouther", Size: "medium", Type: "aberration", CR: 2, XP: 450, HP: 67, AC: 9, SpeedWalk: 10, STR: 10, DEX: 8, CON: 16, INT: 3, WIS: 10, CHA: 6, Multiattack: "The gibbering mouther makes one bite attack and, if it can, uses its Blinding Spittle.", Attacks: []SRDStatAttack{{Name: "Bites", AttackBonus: 2, DamageDice: "5d6", DamageBonus: 0, AvgDamage: 17, DamageType: "piercing"}}, Traits: []string{"Aberrant Ground", "Gibbering"}},
|
||||
"green_dragon_wyrmling": {Slug: "green_dragon_wyrmling", Name: "Green Dragon Wyrmling", Size: "medium", Type: "dragon", CR: 2, XP: 450, HP: 38, AC: 17, SpeedWalk: 30, STR: 15, DEX: 12, CON: 13, INT: 14, WIS: 11, CHA: 13, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d10+1d6", DamageBonus: 3, AvgDamage: 3, DamageType: "piercing"}, {Name: "Poison Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "6d6", DamageBonus: 0, AvgDamage: 21, DamageType: "poison"}}, Traits: []string{"Amphibious"}},
|
||||
"grick": {Slug: "grick", Name: "Grick", Size: "medium", Type: "monstrosity", CR: 2, XP: 450, HP: 27, AC: 14, SpeedWalk: 30, STR: 14, DEX: 14, CON: 11, INT: 3, WIS: 14, CHA: 5, Multiattack: "The grick makes one attack with its tentacles.", Attacks: []SRDStatAttack{{Name: "Tentacles", AttackBonus: 4, DamageDice: "2d6", DamageBonus: 2, AvgDamage: 9, DamageType: "slashing"}, {Name: "Beak", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Stone Camouflage"}},
|
||||
"griffon": {Slug: "griffon", Name: "Griffon", Size: "large", Type: "monstrosity", CR: 2, XP: 450, HP: 59, AC: 12, SpeedWalk: 30, STR: 18, DEX: 15, CON: 16, INT: 2, WIS: 13, CHA: 8, Multiattack: "The griffon makes two attacks: one with its beak and one with its claws.", Attacks: []SRDStatAttack{{Name: "Beak", AttackBonus: 6, DamageDice: "1d8", DamageBonus: 4, AvgDamage: 8, DamageType: "piercing"}, {Name: "Claws", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "slashing"}}, Traits: []string{"Keen Sight"}},
|
||||
"hunter_shark": {Slug: "hunter_shark", Name: "Hunter Shark", Size: "large", Type: "beast", CR: 2, XP: 450, HP: 45, AC: 12, SpeedWalk: 40, STR: 18, DEX: 13, CON: 15, INT: 1, WIS: 10, CHA: 4, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 6, DamageDice: "2d8", DamageBonus: 4, AvgDamage: 13, DamageType: "piercing"}}, Traits: []string{"Blood Frenzy", "Water Breathing"}},
|
||||
"merrow": {Slug: "merrow", Name: "Merrow", Size: "large", Type: "monstrosity", CR: 2, XP: 450, HP: 45, AC: 13, SpeedWalk: 10, STR: 18, DEX: 10, CON: 15, INT: 8, WIS: 10, CHA: 9, Multiattack: "The merrow makes two attacks: one with its bite and one with its claws or harpoon.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 6, DamageDice: "1d8", DamageBonus: 4, AvgDamage: 8, DamageType: "piercing"}, {Name: "Claws", AttackBonus: 6, DamageDice: "2d4", DamageBonus: 4, AvgDamage: 9, DamageType: "slashing"}, {Name: "Harpoon", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "piercing"}}, Traits: []string{"Amphibious"}},
|
||||
"mimic": {Slug: "mimic", Name: "Mimic", Size: "medium", Type: "monstrosity", CR: 2, XP: 450, HP: 58, AC: 12, SpeedWalk: 15, STR: 17, DEX: 12, CON: 15, INT: 5, WIS: 13, CHA: 8, Attacks: []SRDStatAttack{{Name: "Pseudopod", AttackBonus: 5, DamageDice: "1d8", DamageBonus: 3, AvgDamage: 7, DamageType: "bludgeoning"}, {Name: "Bite", AttackBonus: 5, DamageDice: "1d8+1d8", DamageBonus: 3, AvgDamage: 3, DamageType: "piercing"}}, Traits: []string{"Shapechanger", "Adhesive (Object Form Only)", "False Appearance (Object Form Only)", "Grappler"}},
|
||||
"minotaur_skeleton": {Slug: "minotaur_skeleton", Name: "Minotaur Skeleton", Size: "large", Type: "undead", CR: 2, XP: 450, HP: 67, AC: 12, SpeedWalk: 40, STR: 18, DEX: 11, CON: 15, INT: 6, WIS: 8, CHA: 5, Attacks: []SRDStatAttack{{Name: "Greataxe", AttackBonus: 6, DamageDice: "2d12", DamageBonus: 4, AvgDamage: 17, DamageType: "slashing"}, {Name: "Gore", AttackBonus: 6, DamageDice: "2d8", DamageBonus: 4, AvgDamage: 13, DamageType: "piercing"}}, Traits: []string{"Charge"}},
|
||||
"ochre_jelly": {Slug: "ochre_jelly", Name: "Ochre Jelly", Size: "large", Type: "ooze", CR: 2, XP: 450, HP: 45, AC: 8, SpeedWalk: 10, STR: 15, DEX: 6, CON: 14, INT: 2, WIS: 6, CHA: 1, Attacks: []SRDStatAttack{{Name: "Pseudopod", AttackBonus: 4, DamageDice: "2d6", DamageBonus: 2, AvgDamage: 9, DamageType: "bludgeoning"}}, Traits: []string{"Amorphous", "Spider Climb"}},
|
||||
"ogre": {Slug: "ogre", Name: "Ogre", Size: "large", Type: "giant", CR: 2, XP: 450, HP: 59, AC: 11, SpeedWalk: 40, STR: 19, DEX: 8, CON: 16, INT: 5, WIS: 7, CHA: 7, Attacks: []SRDStatAttack{{Name: "Greatclub", AttackBonus: 6, DamageDice: "2d8", DamageBonus: 4, AvgDamage: 13, DamageType: "bludgeoning"}, {Name: "Javelin", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "piercing"}}},
|
||||
"ogre_zombie": {Slug: "ogre_zombie", Name: "Ogre Zombie", Size: "large", Type: "undead", CR: 2, XP: 450, HP: 85, AC: 8, SpeedWalk: 30, STR: 19, DEX: 6, CON: 18, INT: 3, WIS: 6, CHA: 5, Attacks: []SRDStatAttack{{Name: "Morningstar", AttackBonus: 6, DamageDice: "2d8", DamageBonus: 4, AvgDamage: 13, DamageType: "bludgeoning"}}, Traits: []string{"Undead Fortitude"}},
|
||||
"pegasus": {Slug: "pegasus", Name: "Pegasus", Size: "large", Type: "celestial", CR: 2, XP: 450, HP: 59, AC: 12, SpeedWalk: 60, STR: 18, DEX: 15, CON: 16, INT: 10, WIS: 15, CHA: 13, Attacks: []SRDStatAttack{{Name: "Hooves", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "bludgeoning"}}},
|
||||
"plesiosaurus": {Slug: "plesiosaurus", Name: "Plesiosaurus", Size: "large", Type: "beast", CR: 2, XP: 450, HP: 68, AC: 13, SpeedWalk: 20, STR: 18, DEX: 15, CON: 16, INT: 2, WIS: 12, CHA: 5, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 6, DamageDice: "3d6", DamageBonus: 4, AvgDamage: 14, DamageType: "piercing"}}, Traits: []string{"Hold Breath"}},
|
||||
"polar_bear": {Slug: "polar_bear", Name: "Polar Bear", Size: "large", Type: "beast", CR: 2, XP: 450, HP: 42, AC: 12, SpeedWalk: 40, STR: 20, DEX: 10, CON: 16, INT: 2, WIS: 13, CHA: 7, Multiattack: "The bear makes two attacks: one with its bite and one with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 7, DamageDice: "1d8", DamageBonus: 5, AvgDamage: 9, DamageType: "piercing"}, {Name: "Claws", AttackBonus: 7, DamageDice: "2d6", DamageBonus: 5, AvgDamage: 12, DamageType: "slashing"}}, Traits: []string{"Keen Smell"}},
|
||||
"priest": {Slug: "priest", Name: "Priest", Size: "medium", Type: "humanoid", CR: 2, XP: 450, HP: 27, AC: 13, SpeedWalk: 25, STR: 10, DEX: 10, CON: 12, INT: 13, WIS: 16, CHA: 13, Attacks: []SRDStatAttack{{Name: "Mace", AttackBonus: 2, DamageDice: "1d6", DamageBonus: 0, AvgDamage: 3, DamageType: "bludgeoning"}}, Traits: []string{"Divine Eminence", "Spellcasting"}},
|
||||
"rhinoceros": {Slug: "rhinoceros", Name: "Rhinoceros", Size: "large", Type: "beast", CR: 2, XP: 450, HP: 45, AC: 11, SpeedWalk: 40, STR: 21, DEX: 8, CON: 15, INT: 2, WIS: 12, CHA: 6, Attacks: []SRDStatAttack{{Name: "Gore", AttackBonus: 7, DamageDice: "2d8", DamageBonus: 5, AvgDamage: 14, DamageType: "bludgeoning"}}, Traits: []string{"Charge"}},
|
||||
"rug_of_smothering": {Slug: "rug_of_smothering", Name: "Rug of Smothering", Size: "large", Type: "construct", CR: 2, XP: 450, HP: 33, AC: 12, SpeedWalk: 10, STR: 17, DEX: 14, CON: 10, INT: 1, WIS: 3, CHA: 1, Attacks: []SRDStatAttack{{Name: "Smother", AttackBonus: 0, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "bludgeoning"}}, Traits: []string{"Antimagic Susceptibility", "Damage Transfer", "False Appearance"}},
|
||||
"saber_toothed_tiger": {Slug: "saber_toothed_tiger", Name: "Saber-Toothed Tiger", Size: "large", Type: "beast", CR: 2, XP: 450, HP: 52, AC: 12, SpeedWalk: 40, STR: 18, DEX: 14, CON: 15, INT: 3, WIS: 12, CHA: 8, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 6, DamageDice: "1d10", DamageBonus: 5, AvgDamage: 10, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 5, AvgDamage: 12, DamageType: "slashing"}}, Traits: []string{"Keen Smell", "Pounce"}},
|
||||
"sea_hag": {Slug: "sea_hag", Name: "Sea Hag", Size: "medium", Type: "fey", CR: 2, XP: 450, HP: 52, AC: 14, SpeedWalk: 30, STR: 16, DEX: 13, CON: 16, INT: 12, WIS: 12, CHA: 13, Attacks: []SRDStatAttack{{Name: "Claws", AttackBonus: 5, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "slashing"}}, Traits: []string{"Amphibious", "Horrific Appearance", "Hag Coven", "Shared Spellcasting (Coven Only)", "Hag Eye (Coven Only)"}},
|
||||
"silver_dragon_wyrmling": {Slug: "silver_dragon_wyrmling", Name: "Silver Dragon Wyrmling", Size: "medium", Type: "dragon", CR: 2, XP: 450, HP: 45, AC: 17, SpeedWalk: 30, STR: 19, DEX: 10, CON: 17, INT: 12, WIS: 11, CHA: 15, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 6, DamageDice: "1d10", DamageBonus: 4, AvgDamage: 9, DamageType: "piercing"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "4d8", DamageBonus: 0, AvgDamage: 18, DamageType: "cold"}}},
|
||||
"swarm_of_poisonous_snakes": {Slug: "swarm_of_poisonous_snakes", Name: "Swarm of Poisonous Snakes", Size: "medium", Type: "beast", CR: 2, XP: 450, HP: 36, AC: 14, SpeedWalk: 30, STR: 8, DEX: 18, CON: 11, INT: 1, WIS: 10, CHA: 3, Attacks: []SRDStatAttack{{Name: "Bites", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 0, AvgDamage: 7, DamageType: "piercing"}}, Traits: []string{"Swarm"}},
|
||||
"wererat": {Slug: "wererat", Name: "Wererat", Size: "medium", Type: "humanoid", CR: 2, XP: 450, HP: 33, AC: 12, SpeedWalk: 30, STR: 10, DEX: 15, CON: 12, INT: 11, WIS: 10, CHA: 8, Attacks: []SRDStatAttack{{Name: "Bite (Rat or Hybrid Form Only).", AttackBonus: 4, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "piercing"}, {Name: "Shortsword (Humanoid or Hybrid Form Only)", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}, {Name: "Hand Crossbow (Humanoid or Hybrid Form Only)", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Shapechanger", "Keen Smell"}},
|
||||
"white_dragon_wyrmling": {Slug: "white_dragon_wyrmling", Name: "White Dragon Wyrmling", Size: "medium", Type: "dragon", CR: 2, XP: 450, HP: 32, AC: 16, SpeedWalk: 30, STR: 14, DEX: 10, CON: 14, INT: 5, WIS: 10, CHA: 11, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d10+1d4", DamageBonus: 2, AvgDamage: 2, DamageType: "piercing"}, {Name: "Cold Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "5d8", DamageBonus: 0, AvgDamage: 22, DamageType: "cold"}}},
|
||||
"will_o_wisp": {Slug: "will_o_wisp", Name: "Will-o'-Wisp", Size: "tiny", Type: "undead", CR: 2, XP: 450, HP: 22, AC: 19, SpeedWalk: 50, STR: 1, DEX: 28, CON: 10, INT: 13, WIS: 14, CHA: 11, Attacks: []SRDStatAttack{{Name: "Shock", AttackBonus: 4, DamageDice: "2d8", DamageBonus: 0, AvgDamage: 9, DamageType: "lightning"}}, Traits: []string{"Consume Life", "Ephemeral", "Incorporeal Movement", "Variable Illumination"}},
|
||||
"basilisk": {Slug: "basilisk", Name: "Basilisk", Size: "medium", Type: "monstrosity", CR: 3, XP: 700, HP: 52, AC: 12, SpeedWalk: 20, STR: 16, DEX: 8, CON: 15, INT: 2, WIS: 8, CHA: 7, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 5, DamageDice: "2d6+2d6", DamageBonus: 3, AvgDamage: 3, DamageType: "piercing"}}, Traits: []string{"Petrifying Gaze"}},
|
||||
"bearded_devil": {Slug: "bearded_devil", Name: "Bearded Devil", Size: "medium", Type: "fiend", CR: 3, XP: 700, HP: 52, AC: 13, SpeedWalk: 30, STR: 16, DEX: 15, CON: 15, INT: 9, WIS: 11, CHA: 11, Multiattack: "The devil makes two attacks: one with its beard and one with its glaive.", Attacks: []SRDStatAttack{{Name: "Beard", AttackBonus: 5, DamageDice: "1d8", DamageBonus: 2, AvgDamage: 6, DamageType: "piercing"}, {Name: "Glaive", AttackBonus: 5, DamageDice: "1d10", DamageBonus: 3, AvgDamage: 8, DamageType: "slashing"}}, Traits: []string{"Devil's Sight", "Magic Resistance", "Steadfast"}},
|
||||
"blue_dragon_wyrmling": {Slug: "blue_dragon_wyrmling", Name: "Blue Dragon Wyrmling", Size: "medium", Type: "dragon", CR: 3, XP: 700, HP: 52, AC: 17, SpeedWalk: 30, STR: 17, DEX: 10, CON: 15, INT: 12, WIS: 11, CHA: 15, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 5, DamageDice: "1d10+1d6", DamageBonus: 3, AvgDamage: 3, DamageType: "piercing"}, {Name: "Lightning Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "4d10", DamageBonus: 0, AvgDamage: 22, DamageType: "lightning"}}},
|
||||
"doppelganger": {Slug: "doppelganger", Name: "Doppelganger", Size: "medium", Type: "monstrosity", CR: 3, XP: 700, HP: 52, AC: 14, SpeedWalk: 30, STR: 11, DEX: 18, CON: 14, INT: 11, WIS: 12, CHA: 14, Multiattack: "The doppelganger makes two melee attacks.", Attacks: []SRDStatAttack{{Name: "Slam", AttackBonus: 6, DamageDice: "1d6", DamageBonus: 4, AvgDamage: 7, DamageType: "bludgeoning"}}, Traits: []string{"Shapechanger", "Ambusher", "Surprise Attack"}},
|
||||
"giant_scorpion": {Slug: "giant_scorpion", Name: "Giant Scorpion", Size: "large", Type: "beast", CR: 3, XP: 700, HP: 52, AC: 15, SpeedWalk: 40, STR: 15, DEX: 13, CON: 15, INT: 1, WIS: 9, CHA: 3, Multiattack: "The scorpion makes three attacks: two with its claws and one with its sting.", Attacks: []SRDStatAttack{{Name: "Claw", AttackBonus: 4, DamageDice: "1d8", DamageBonus: 2, AvgDamage: 6, DamageType: "bludgeoning"}, {Name: "Sting", AttackBonus: 4, DamageDice: "1d10", DamageBonus: 2, AvgDamage: 7, DamageType: "piercing"}}},
|
||||
"gold_dragon_wyrmling": {Slug: "gold_dragon_wyrmling", Name: "Gold Dragon Wyrmling", Size: "medium", Type: "dragon", CR: 3, XP: 700, HP: 60, AC: 17, SpeedWalk: 30, STR: 19, DEX: 14, CON: 17, INT: 14, WIS: 11, CHA: 16, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 6, DamageDice: "1d10", DamageBonus: 4, AvgDamage: 9, DamageType: "piercing"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "4d10", DamageBonus: 0, AvgDamage: 22, DamageType: "fire"}}, Traits: []string{"Amphibious"}},
|
||||
"green_hag": {Slug: "green_hag", Name: "Green Hag", Size: "medium", Type: "fey", CR: 3, XP: 700, HP: 82, AC: 17, SpeedWalk: 30, STR: 18, DEX: 12, CON: 16, INT: 13, WIS: 14, CHA: 14, Attacks: []SRDStatAttack{{Name: "Claws", AttackBonus: 6, DamageDice: "2d8", DamageBonus: 4, AvgDamage: 13, DamageType: "slashing"}}, Traits: []string{"Amphibious", "Innate Spellcasting", "Mimicry", "Hag Coven", "Shared Spellcasting (Coven Only)", "Hag Eye (Coven Only)"}},
|
||||
"hell_hound": {Slug: "hell_hound", Name: "Hell Hound", Size: "medium", Type: "fiend", CR: 3, XP: 700, HP: 45, AC: 15, SpeedWalk: 50, STR: 17, DEX: 12, CON: 14, INT: 6, WIS: 13, CHA: 6, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 5, DamageDice: "1d8", DamageBonus: 3, AvgDamage: 7, DamageType: "piercing"}, {Name: "Fire Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "6d6", DamageBonus: 0, AvgDamage: 21, DamageType: "fire"}}, Traits: []string{"Keen Hearing and Smell", "Pack Tactics"}},
|
||||
"killer_whale": {Slug: "killer_whale", Name: "Killer Whale", Size: "huge", Type: "beast", CR: 3, XP: 700, HP: 90, AC: 12, SpeedWalk: 60, STR: 19, DEX: 10, CON: 13, INT: 3, WIS: 12, CHA: 7, Traits: []string{"Echolocation", "Hold Breath", "Keen Hearing"}},
|
||||
"knight": {Slug: "knight", Name: "Knight", Size: "medium", Type: "humanoid", CR: 3, XP: 700, HP: 52, AC: 18, SpeedWalk: 30, STR: 16, DEX: 11, CON: 14, INT: 11, WIS: 11, CHA: 15, Multiattack: "The knight makes two melee attacks.", Attacks: []SRDStatAttack{{Name: "Greatsword", AttackBonus: 5, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "slashing"}, {Name: "Heavy Crossbow", AttackBonus: 2, DamageDice: "1d10", DamageBonus: 0, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Brave"}},
|
||||
"manticore": {Slug: "manticore", Name: "Manticore", Size: "large", Type: "monstrosity", CR: 3, XP: 700, HP: 68, AC: 14, SpeedWalk: 30, STR: 17, DEX: 16, CON: 17, INT: 7, WIS: 12, CHA: 8, Multiattack: "The manticore makes three attacks: one with its bite and two with its claws or three with its tail spikes.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 5, DamageDice: "1d8", DamageBonus: 3, AvgDamage: 7, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 5, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "slashing"}, {Name: "Tail Spike", AttackBonus: 5, DamageDice: "1d8", DamageBonus: 3, AvgDamage: 7, DamageType: "piercing"}}, Traits: []string{"Tail Spike Regrowth"}},
|
||||
"minotaur": {Slug: "minotaur", Name: "Minotaur", Size: "large", Type: "monstrosity", CR: 3, XP: 700, HP: 76, AC: 14, SpeedWalk: 40, STR: 18, DEX: 11, CON: 16, INT: 6, WIS: 16, CHA: 9, Attacks: []SRDStatAttack{{Name: "Greataxe", AttackBonus: 6, DamageDice: "2d12", DamageBonus: 4, AvgDamage: 17, DamageType: "slashing"}, {Name: "Gore", AttackBonus: 6, DamageDice: "2d8", DamageBonus: 4, AvgDamage: 13, DamageType: "piercing"}}, Traits: []string{"Charge", "Labyrinthine Recall", "Reckless"}},
|
||||
"mummy": {Slug: "mummy", Name: "Mummy", Size: "medium", Type: "undead", CR: 3, XP: 700, HP: 58, AC: 11, SpeedWalk: 20, STR: 16, DEX: 8, CON: 15, INT: 6, WIS: 10, CHA: 12, Multiattack: "The mummy can use its Dreadful Glare and makes one attack with its rotting fist.", Attacks: []SRDStatAttack{{Name: "Rotting Fist", AttackBonus: 5, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "bludgeoning"}}},
|
||||
"nightmare": {Slug: "nightmare", Name: "Nightmare", Size: "large", Type: "fiend", CR: 3, XP: 700, HP: 68, AC: 13, SpeedWalk: 60, STR: 18, DEX: 15, CON: 16, INT: 10, WIS: 13, CHA: 15, Attacks: []SRDStatAttack{{Name: "Hooves", AttackBonus: 6, DamageDice: "2d8+2d6", DamageBonus: 4, AvgDamage: 4, DamageType: "bludgeoning"}}, Traits: []string{"Confer Fire Resistance", "Illumination"}},
|
||||
"owlbear": {Slug: "owlbear", Name: "Owlbear", Size: "large", Type: "monstrosity", CR: 3, XP: 700, HP: 59, AC: 13, SpeedWalk: 40, STR: 20, DEX: 12, CON: 17, INT: 3, WIS: 12, CHA: 7, Multiattack: "The owlbear makes two attacks: one with its beak and one with its claws.", Attacks: []SRDStatAttack{{Name: "Beak", AttackBonus: 7, DamageDice: "1d10", DamageBonus: 5, AvgDamage: 10, DamageType: "piercing"}, {Name: "Claws", AttackBonus: 7, DamageDice: "2d8", DamageBonus: 5, AvgDamage: 14, DamageType: "slashing"}}, Traits: []string{"Keen Sight and Smell"}},
|
||||
"phase_spider": {Slug: "phase_spider", Name: "Phase Spider", Size: "large", Type: "monstrosity", CR: 3, XP: 700, HP: 32, AC: 13, SpeedWalk: 30, STR: 15, DEX: 15, CON: 12, INT: 6, WIS: 10, CHA: 6, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 4, DamageDice: "1d10", DamageBonus: 2, AvgDamage: 7, DamageType: "piercing"}}, Traits: []string{"Ethereal Jaunt", "Spider Climb", "Web Walker"}},
|
||||
"veteran": {Slug: "veteran", Name: "Veteran", Size: "medium", Type: "humanoid", CR: 3, XP: 700, HP: 58, AC: 17, SpeedWalk: 30, STR: 16, DEX: 13, CON: 14, INT: 10, WIS: 11, CHA: 10, Multiattack: "The veteran makes two longsword attacks.", Attacks: []SRDStatAttack{{Name: "Longsword", AttackBonus: 5, DamageDice: "1d8", DamageBonus: 3, AvgDamage: 7, DamageType: "slashing"}, {Name: "Shortsword", AttackBonus: 5, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "piercing"}, {Name: "Heavy Crossbow", AttackBonus: 3, DamageDice: "1d10", DamageBonus: 1, AvgDamage: 6, DamageType: "piercing"}}},
|
||||
"werewolf": {Slug: "werewolf", Name: "Werewolf", Size: "medium", Type: "humanoid", CR: 3, XP: 700, HP: 58, AC: 11, SpeedWalk: 30, STR: 15, DEX: 13, CON: 14, INT: 10, WIS: 11, CHA: 10, Attacks: []SRDStatAttack{{Name: "Bite (Wolf or Hybrid Form Only)", AttackBonus: 4, DamageDice: "1d8", DamageBonus: 2, AvgDamage: 6, DamageType: "piercing"}, {Name: "Claws (Hybrid Form Only)", AttackBonus: 4, DamageDice: "2d4", DamageBonus: 2, AvgDamage: 7, DamageType: "slashing"}, {Name: "Spear (Humanoid Form Only)", AttackBonus: 4, DamageDice: "1d6", DamageBonus: -2, AvgDamage: 1, DamageType: "piercing"}}, Traits: []string{"Shapechanger", "Keen Hearing and Smell"}},
|
||||
"wight": {Slug: "wight", Name: "Wight", Size: "medium", Type: "undead", CR: 3, XP: 700, HP: 45, AC: 14, SpeedWalk: 30, STR: 15, DEX: 14, CON: 16, INT: 10, WIS: 13, CHA: 15, Multiattack: "The wight makes two longsword attacks or two longbow attacks.", Attacks: []SRDStatAttack{{Name: "Life Drain", AttackBonus: 4, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "necrotic"}, {Name: "Longsword", AttackBonus: 4, DamageDice: "1d8", DamageBonus: 2, AvgDamage: 6, DamageType: "slashing"}, {Name: "Longbow", AttackBonus: 4, DamageDice: "1d8", DamageBonus: 2, AvgDamage: 6, DamageType: "piercing"}}, Traits: []string{"Sunlight Sensitivity"}},
|
||||
"winter_wolf": {Slug: "winter_wolf", Name: "Winter Wolf", Size: "large", Type: "monstrosity", CR: 3, XP: 700, HP: 75, AC: 13, SpeedWalk: 50, STR: 18, DEX: 13, CON: 14, INT: 7, WIS: 12, CHA: 8, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "piercing"}, {Name: "Cold Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "4d8", DamageBonus: 0, AvgDamage: 18, DamageType: "cold"}}, Traits: []string{"Keen Hearing and Smell", "Pack Tactics", "Snow Camouflage"}},
|
||||
"black_pudding": {Slug: "black_pudding", Name: "Black Pudding", Size: "large", Type: "ooze", CR: 4, XP: 1100, HP: 85, AC: 7, SpeedWalk: 20, STR: 16, DEX: 5, CON: 16, INT: 1, WIS: 6, CHA: 1, Attacks: []SRDStatAttack{{Name: "Pseudopod", AttackBonus: 5, DamageDice: "1d6+4d8", DamageBonus: 3, AvgDamage: 3, DamageType: "bludgeoning"}}, Traits: []string{"Amorphous", "Corrosive Form", "Spider Climb"}},
|
||||
"chuul": {Slug: "chuul", Name: "Chuul", Size: "large", Type: "aberration", CR: 4, XP: 1100, HP: 93, AC: 16, SpeedWalk: 30, STR: 19, DEX: 10, CON: 16, INT: 5, WIS: 11, CHA: 5, Multiattack: "The chuul makes two pincer attacks.", Attacks: []SRDStatAttack{{Name: "Pincer", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "bludgeoning"}}, Traits: []string{"Amphibious", "Sense Magic"}},
|
||||
"couatl": {Slug: "couatl", Name: "Couatl", Size: "medium", Type: "celestial", CR: 4, XP: 1100, HP: 97, AC: 19, SpeedWalk: 30, STR: 16, DEX: 20, CON: 17, INT: 18, WIS: 20, CHA: 18, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 8, DamageDice: "1d6", DamageBonus: 5, AvgDamage: 8, DamageType: "piercing"}, {Name: "Constrict", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "bludgeoning"}}, Traits: []string{"Innate Spellcasting", "Magic Weapons", "Shielded Mind"}},
|
||||
"elephant": {Slug: "elephant", Name: "Elephant", Size: "huge", Type: "beast", CR: 4, XP: 1100, HP: 76, AC: 12, SpeedWalk: 40, STR: 22, DEX: 9, CON: 17, INT: 3, WIS: 11, CHA: 6, Attacks: []SRDStatAttack{{Name: "Gore", AttackBonus: 8, DamageDice: "3d8", DamageBonus: 6, AvgDamage: 19, DamageType: "piercing"}, {Name: "Stomp", AttackBonus: 8, DamageDice: "3d10", DamageBonus: 6, AvgDamage: 22, DamageType: "bludgeoning"}}, Traits: []string{"Trampling Charge"}},
|
||||
"ettin": {Slug: "ettin", Name: "Ettin", Size: "large", Type: "giant", CR: 4, XP: 1100, HP: 85, AC: 12, SpeedWalk: 40, STR: 21, DEX: 8, CON: 17, INT: 6, WIS: 10, CHA: 8, Multiattack: "The ettin makes two attacks: one with its battleaxe and one with its morningstar.", Attacks: []SRDStatAttack{{Name: "Battleaxe", AttackBonus: 7, DamageDice: "2d8", DamageBonus: 5, AvgDamage: 14, DamageType: "slashing"}, {Name: "Morningstar", AttackBonus: 7, DamageDice: "2d8", DamageBonus: 5, AvgDamage: 14, DamageType: "piercing"}}, Traits: []string{"Two Heads", "Wakeful"}},
|
||||
"ghost": {Slug: "ghost", Name: "Ghost", Size: "medium", Type: "undead", CR: 4, XP: 1100, HP: 45, AC: 11, SpeedWalk: 40, STR: 7, DEX: 13, CON: 10, INT: 10, WIS: 12, CHA: 17, Attacks: []SRDStatAttack{{Name: "Withering Touch", AttackBonus: 5, DamageDice: "4d6", DamageBonus: 3, AvgDamage: 17, DamageType: "necrotic"}}, Traits: []string{"Ethereal Sight", "Incorporeal Movement"}},
|
||||
"lamia": {Slug: "lamia", Name: "Lamia", Size: "large", Type: "monstrosity", CR: 4, XP: 1100, HP: 97, AC: 13, SpeedWalk: 30, STR: 16, DEX: 13, CON: 15, INT: 14, WIS: 15, CHA: 16, Multiattack: "The lamia makes two attacks: one with its claws and one with its dagger or Intoxicating Touch.", Attacks: []SRDStatAttack{{Name: "Claws", AttackBonus: 5, DamageDice: "2d10", DamageBonus: 3, AvgDamage: 14, DamageType: "slashing"}, {Name: "Dagger", AttackBonus: 5, DamageDice: "1d4", DamageBonus: 3, AvgDamage: 5, DamageType: "piercing"}}, Traits: []string{"Innate Spellcasting"}},
|
||||
"red_dragon_wyrmling": {Slug: "red_dragon_wyrmling", Name: "Red Dragon Wyrmling", Size: "medium", Type: "dragon", CR: 4, XP: 1100, HP: 75, AC: 17, SpeedWalk: 30, STR: 19, DEX: 10, CON: 17, INT: 12, WIS: 11, CHA: 15, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 6, DamageDice: "1d10+1d6", DamageBonus: 4, AvgDamage: 4, DamageType: "piercing"}, {Name: "Fire Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "7d6", DamageBonus: 0, AvgDamage: 24, DamageType: "fire"}}},
|
||||
"succubusincubus": {Slug: "succubusincubus", Name: "Succubus/Incubus", Size: "medium", Type: "fiend", CR: 4, XP: 1100, HP: 66, AC: 15, SpeedWalk: 30, STR: 8, DEX: 17, CON: 13, INT: 15, WIS: 12, CHA: 20, Attacks: []SRDStatAttack{{Name: "Claw (Fiend Form Only)", AttackBonus: 5, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "slashing"}, {Name: "Draining Kiss", AttackBonus: 0, DamageDice: "5d10", DamageBonus: 5, AvgDamage: 32, DamageType: "psychic"}}, Traits: []string{"Telepathic Bond", "Shapechanger"}},
|
||||
"wereboar": {Slug: "wereboar", Name: "Wereboar", Size: "medium", Type: "humanoid", CR: 4, XP: 1100, HP: 78, AC: 10, SpeedWalk: 30, STR: 17, DEX: 10, CON: 15, INT: 10, WIS: 11, CHA: 8, Attacks: []SRDStatAttack{{Name: "Maul (Humanoid or Hybrid Form Only)", AttackBonus: 5, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "bludgeoning"}, {Name: "Tusks (Boar or Hybrid Form Only)", AttackBonus: 5, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "slashing"}}, Traits: []string{"Shapechanger", "Charge (Boar or Hybrid Form Only)", "Relentless (Recharges after a Short or Long Rest)"}},
|
||||
"weretiger": {Slug: "weretiger", Name: "Weretiger", Size: "medium", Type: "humanoid", CR: 4, XP: 1100, HP: 120, AC: 12, SpeedWalk: 30, STR: 17, DEX: 15, CON: 16, INT: 10, WIS: 13, CHA: 11, Attacks: []SRDStatAttack{{Name: "Bite (Tiger or Hybrid Form Only)", AttackBonus: 5, DamageDice: "1d10", DamageBonus: 3, AvgDamage: 8, DamageType: "piercing"}, {Name: "Claw (Tiger or Hybrid Form Only)", AttackBonus: 5, DamageDice: "1d8", DamageBonus: 3, AvgDamage: 7, DamageType: "slashing"}, {Name: "Scimitar (Humanoid or Hybrid Form Only)", AttackBonus: 5, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "slashing"}, {Name: "Longbow (Humanoid or Hybrid Form Only)", AttackBonus: 4, DamageDice: "1d8", DamageBonus: 2, AvgDamage: 6, DamageType: "piercing"}}, Traits: []string{"Shapechanger", "Keen Hearing and Smell", "Pounce (Tiger or Hybrid Form Only)"}},
|
||||
"air_elemental": {Slug: "air_elemental", Name: "Air Elemental", Size: "large", Type: "elemental", CR: 5, XP: 1800, HP: 90, AC: 15, SpeedWalk: 90, STR: 14, DEX: 20, CON: 14, INT: 6, WIS: 10, CHA: 6, Multiattack: "The elemental makes two slam attacks.", Attacks: []SRDStatAttack{{Name: "Slam", AttackBonus: 8, DamageDice: "2d8", DamageBonus: 5, AvgDamage: 14, DamageType: "bludgeoning"}}, Traits: []string{"Air Form"}},
|
||||
"barbed_devil": {Slug: "barbed_devil", Name: "Barbed Devil", Size: "medium", Type: "fiend", CR: 5, XP: 1800, HP: 110, AC: 15, SpeedWalk: 30, STR: 16, DEX: 17, CON: 18, INT: 12, WIS: 14, CHA: 14, Multiattack: "The devil makes three melee attacks: one with its tail and two with its claws.", Attacks: []SRDStatAttack{{Name: "Claw", AttackBonus: 6, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "piercing"}, {Name: "Tail", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "piercing"}, {Name: "Hurl Flame", AttackBonus: 5, DamageDice: "3d6", DamageBonus: 0, AvgDamage: 10, DamageType: "fire"}}, Traits: []string{"Barbed Hide", "Devil's Sight", "Magic Resistance"}},
|
||||
"bulette": {Slug: "bulette", Name: "Bulette", Size: "large", Type: "monstrosity", CR: 5, XP: 1800, HP: 94, AC: 17, SpeedWalk: 40, STR: 19, DEX: 11, CON: 21, INT: 2, WIS: 10, CHA: 5, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 7, DamageDice: "4d12", DamageBonus: 4, AvgDamage: 30, DamageType: "piercing"}}, Traits: []string{"Standing Leap"}},
|
||||
"earth_elemental": {Slug: "earth_elemental", Name: "Earth Elemental", Size: "large", Type: "elemental", CR: 5, XP: 1800, HP: 126, AC: 17, SpeedWalk: 30, STR: 20, DEX: 8, CON: 20, INT: 5, WIS: 10, CHA: 5, Multiattack: "The elemental makes two slam attacks.", Attacks: []SRDStatAttack{{Name: "Slam", AttackBonus: 8, DamageDice: "2d8", DamageBonus: 5, AvgDamage: 14, DamageType: "bludgeoning"}}, Traits: []string{"Earth Glide", "Siege Monster"}},
|
||||
"fire_elemental": {Slug: "fire_elemental", Name: "Fire Elemental", Size: "large", Type: "elemental", CR: 5, XP: 1800, HP: 102, AC: 13, SpeedWalk: 50, STR: 10, DEX: 17, CON: 16, INT: 6, WIS: 10, CHA: 7, Multiattack: "The elemental makes two touch attacks.", Attacks: []SRDStatAttack{{Name: "Touch", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "fire"}}, Traits: []string{"Fire Form", "Illumination", "Water Susceptibility"}},
|
||||
"flesh_golem": {Slug: "flesh_golem", Name: "Flesh Golem", Size: "medium", Type: "construct", CR: 5, XP: 1800, HP: 93, AC: 9, SpeedWalk: 30, STR: 19, DEX: 9, CON: 18, INT: 6, WIS: 10, CHA: 5, Multiattack: "The golem makes two slam attacks.", Attacks: []SRDStatAttack{{Name: "Slam", AttackBonus: 7, DamageDice: "2d8", DamageBonus: 4, AvgDamage: 13, DamageType: "bludgeoning"}}, Traits: []string{"Berserk", "Aversion of Fire", "Immutable Form", "Lightning Absorption", "Magic Resistance", "Magic Weapons"}},
|
||||
"giant_crocodile": {Slug: "giant_crocodile", Name: "Giant Crocodile", Size: "huge", Type: "beast", CR: 5, XP: 1800, HP: 85, AC: 14, SpeedWalk: 30, STR: 21, DEX: 9, CON: 17, INT: 2, WIS: 10, CHA: 7, Multiattack: "The crocodile makes two attacks: one with its bite and one with its tail.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 8, DamageDice: "3d10", DamageBonus: 5, AvgDamage: 21, DamageType: "piercing"}, {Name: "Tail", AttackBonus: 8, DamageDice: "2d8", DamageBonus: 5, AvgDamage: 14, DamageType: "bludgeoning"}}, Traits: []string{"Hold Breath"}},
|
||||
"giant_shark": {Slug: "giant_shark", Name: "Giant Shark", Size: "huge", Type: "beast", CR: 5, XP: 1800, HP: 126, AC: 13, SpeedWalk: 50, STR: 23, DEX: 11, CON: 21, INT: 1, WIS: 10, CHA: 5, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 9, DamageDice: "3d10", DamageBonus: 6, AvgDamage: 22, DamageType: "piercing"}}, Traits: []string{"Blood Frenzy", "Water Breathing"}},
|
||||
"gladiator": {Slug: "gladiator", Name: "Gladiator", Size: "medium", Type: "humanoid", CR: 5, XP: 1800, HP: 112, AC: 16, SpeedWalk: 30, STR: 18, DEX: 15, CON: 16, INT: 10, WIS: 12, CHA: 15, Multiattack: "The gladiator makes three melee attacks or two ranged attacks.", Attacks: []SRDStatAttack{{Name: "Spear", AttackBonus: 7, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "piercing"}, {Name: "Shield Bash", AttackBonus: 7, DamageDice: "2d4", DamageBonus: 4, AvgDamage: 9, DamageType: "bludgeoning"}}, Traits: []string{"Brave", "Brute"}},
|
||||
"gorgon": {Slug: "gorgon", Name: "Gorgon", Size: "large", Type: "monstrosity", CR: 5, XP: 1800, HP: 114, AC: 19, SpeedWalk: 40, STR: 20, DEX: 11, CON: 18, INT: 2, WIS: 12, CHA: 7, Attacks: []SRDStatAttack{{Name: "Gore", AttackBonus: 8, DamageDice: "2d12", DamageBonus: 5, AvgDamage: 18, DamageType: "piercing"}, {Name: "Hooves", AttackBonus: 8, DamageDice: "2d10", DamageBonus: 5, AvgDamage: 16, DamageType: "bludgeoning"}}, Traits: []string{"Trampling Charge"}},
|
||||
"half_red_dragon_veteran": {Slug: "half_red_dragon_veteran", Name: "Half-Red Dragon Veteran", Size: "medium", Type: "humanoid", CR: 5, XP: 1800, HP: 65, AC: 18, SpeedWalk: 30, STR: 16, DEX: 13, CON: 14, INT: 10, WIS: 11, CHA: 10, Multiattack: "The veteran makes two longsword attacks.", Attacks: []SRDStatAttack{{Name: "Longsword", AttackBonus: 5, DamageDice: "1d8", DamageBonus: 3, AvgDamage: 7, DamageType: "slashing"}, {Name: "Shortsword", AttackBonus: 5, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "piercing"}, {Name: "Heavy Crossbow", AttackBonus: 3, DamageDice: "1d10", DamageBonus: 1, AvgDamage: 6, DamageType: "piercing"}, {Name: "Fire Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "7d6", DamageBonus: 0, AvgDamage: 24, DamageType: "fire"}}},
|
||||
"hill_giant": {Slug: "hill_giant", Name: "Hill Giant", Size: "huge", Type: "giant", CR: 5, XP: 1800, HP: 105, AC: 13, SpeedWalk: 40, STR: 21, DEX: 8, CON: 19, INT: 5, WIS: 9, CHA: 6, Multiattack: "The giant makes two greatclub attacks.", Attacks: []SRDStatAttack{{Name: "Greatclub", AttackBonus: 8, DamageDice: "3d8", DamageBonus: 5, AvgDamage: 18, DamageType: "bludgeoning"}, {Name: "Rock", AttackBonus: 8, DamageDice: "3d10", DamageBonus: 5, AvgDamage: 21, DamageType: "bludgeoning"}}},
|
||||
"night_hag": {Slug: "night_hag", Name: "Night Hag", Size: "medium", Type: "fiend", CR: 5, XP: 1800, HP: 112, AC: 17, SpeedWalk: 30, STR: 18, DEX: 15, CON: 16, INT: 16, WIS: 14, CHA: 16, Attacks: []SRDStatAttack{{Name: "Claws (Hag Form Only)", AttackBonus: 7, DamageDice: "2d8", DamageBonus: 4, AvgDamage: 13, DamageType: "slashing"}}, Traits: []string{"Innate Spellcasting", "Magic Resistance", "Night Hag Items", "Hag Coven", "Shared Spellcasting (Coven Only)", "Hag Eye (Coven Only)"}},
|
||||
"otyugh": {Slug: "otyugh", Name: "Otyugh", Size: "large", Type: "aberration", CR: 5, XP: 1800, HP: 114, AC: 14, SpeedWalk: 30, STR: 16, DEX: 11, CON: 19, INT: 6, WIS: 13, CHA: 6, Multiattack: "The otyugh makes three attacks: one with its bite and two with its tentacles.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 6, DamageDice: "2d8", DamageBonus: 3, AvgDamage: 12, DamageType: "piercing"}, {Name: "Tentacle", AttackBonus: 6, DamageDice: "1d8", DamageBonus: 3, AvgDamage: 7, DamageType: "bludgeoning"}}, Traits: []string{"Limited Telepathy"}},
|
||||
"roper": {Slug: "roper", Name: "Roper", Size: "large", Type: "monstrosity", CR: 5, XP: 1800, HP: 93, AC: 20, SpeedWalk: 10, STR: 18, DEX: 8, CON: 17, INT: 7, WIS: 16, CHA: 6, Multiattack: "The roper makes four attacks with its tendrils, uses Reel, and makes one attack with its bite.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 7, DamageDice: "4d8", DamageBonus: 4, AvgDamage: 22, DamageType: "piercing"}}, Traits: []string{"False Appearance", "Grasping Tendrils", "Spider Climb"}},
|
||||
"salamander": {Slug: "salamander", Name: "Salamander", Size: "large", Type: "elemental", CR: 5, XP: 1800, HP: 90, AC: 15, SpeedWalk: 30, STR: 18, DEX: 14, CON: 15, INT: 11, WIS: 10, CHA: 12, Multiattack: "The salamander makes two attacks: one with its spear and one with its tail.", Attacks: []SRDStatAttack{{Name: "Spear", AttackBonus: 7, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "piercing"}, {Name: "Tail", AttackBonus: 7, DamageDice: "2d6+2d6", DamageBonus: 4, AvgDamage: 4, DamageType: "bludgeoning"}}, Traits: []string{"Heated Body", "Heated Weapons"}},
|
||||
"shambling_mound": {Slug: "shambling_mound", Name: "Shambling Mound", Size: "large", Type: "plant", CR: 5, XP: 1800, HP: 136, AC: 15, SpeedWalk: 20, STR: 18, DEX: 8, CON: 16, INT: 5, WIS: 10, CHA: 5, Multiattack: "The shambling mound makes two slam attacks.", Attacks: []SRDStatAttack{{Name: "Slam", AttackBonus: 7, DamageDice: "2d8", DamageBonus: 4, AvgDamage: 13, DamageType: "bludgeoning"}}, Traits: []string{"Lightning Absorption"}},
|
||||
"triceratops": {Slug: "triceratops", Name: "Triceratops", Size: "huge", Type: "beast", CR: 5, XP: 1800, HP: 95, AC: 13, SpeedWalk: 50, STR: 22, DEX: 9, CON: 17, INT: 2, WIS: 11, CHA: 5, Attacks: []SRDStatAttack{{Name: "Gore", AttackBonus: 9, DamageDice: "4d8", DamageBonus: 6, AvgDamage: 24, DamageType: "piercing"}, {Name: "Stomp", AttackBonus: 9, DamageDice: "3d10", DamageBonus: 6, AvgDamage: 22, DamageType: "bludgeoning"}}, Traits: []string{"Trampling Charge"}},
|
||||
"troll": {Slug: "troll", Name: "Troll", Size: "large", Type: "giant", CR: 5, XP: 1800, HP: 84, AC: 15, SpeedWalk: 30, STR: 18, DEX: 13, CON: 20, INT: 7, WIS: 9, CHA: 7, Multiattack: "The troll makes three attacks: one with its bite and two with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 7, DamageDice: "1d6", DamageBonus: 4, AvgDamage: 7, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 7, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "slashing"}}, Traits: []string{"Keen Smell", "Regeneration", "Variant: Loathsome Limbs"}},
|
||||
"unicorn": {Slug: "unicorn", Name: "Unicorn", Size: "large", Type: "celestial", CR: 5, XP: 1800, HP: 67, AC: 12, SpeedWalk: 50, STR: 18, DEX: 14, CON: 15, INT: 11, WIS: 17, CHA: 16, Multiattack: "The unicorn makes two attacks: one with its hooves and one with its horn.", Attacks: []SRDStatAttack{{Name: "Hooves", AttackBonus: 7, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "bludgeoning"}, {Name: "Horn", AttackBonus: 7, DamageDice: "1d8", DamageBonus: 4, AvgDamage: 8, DamageType: "piercing"}}, Traits: []string{"Charge", "Innate Spellcasting", "Magic Resistance", "Magic Weapons"}, Legendary: true},
|
||||
"vampire_spawn": {Slug: "vampire_spawn", Name: "Vampire Spawn", Size: "medium", Type: "undead", CR: 5, XP: 1800, HP: 82, AC: 15, SpeedWalk: 30, STR: 16, DEX: 16, CON: 16, INT: 11, WIS: 10, CHA: 12, Multiattack: "The vampire makes two attacks, only one of which can be a bite attack.", Attacks: []SRDStatAttack{{Name: "Claws", AttackBonus: 6, DamageDice: "2d4", DamageBonus: 3, AvgDamage: 8, DamageType: "slashing"}}, Traits: []string{"Regeneration", "Spider Climb", "Vampire Weaknesses"}},
|
||||
"water_elemental": {Slug: "water_elemental", Name: "Water Elemental", Size: "large", Type: "elemental", CR: 5, XP: 1800, HP: 114, AC: 14, SpeedWalk: 30, STR: 18, DEX: 14, CON: 18, INT: 5, WIS: 10, CHA: 8, Multiattack: "The elemental makes two slam attacks.", Attacks: []SRDStatAttack{{Name: "Slam", AttackBonus: 7, DamageDice: "2d8", DamageBonus: 4, AvgDamage: 13, DamageType: "bludgeoning"}}, Traits: []string{"Water Form", "Freeze"}},
|
||||
"werebear": {Slug: "werebear", Name: "Werebear", Size: "medium", Type: "humanoid", CR: 5, XP: 1800, HP: 135, AC: 10, SpeedWalk: 30, STR: 19, DEX: 10, CON: 17, INT: 11, WIS: 12, CHA: 12, Multiattack: "In bear form, the werebear makes two claw attacks.", Attacks: []SRDStatAttack{{Name: "Bite (Bear or Hybrid Form Only)", AttackBonus: 7, DamageDice: "2d10", DamageBonus: 4, AvgDamage: 15, DamageType: "piercing"}, {Name: "Claw (Bear or Hybrid Form Only)", AttackBonus: 7, DamageDice: "2d8", DamageBonus: 4, AvgDamage: 13, DamageType: "slashing"}, {Name: "Greataxe (Humanoid or Hybrid Form Only)", AttackBonus: 7, DamageDice: "1d12", DamageBonus: 4, AvgDamage: 10, DamageType: "slashing"}}, Traits: []string{"Shapechanger", "Keen Smell"}},
|
||||
"wraith": {Slug: "wraith", Name: "Wraith", Size: "medium", Type: "undead", CR: 5, XP: 1800, HP: 67, AC: 13, SpeedWalk: 60, STR: 6, DEX: 16, CON: 16, INT: 12, WIS: 14, CHA: 15, Attacks: []SRDStatAttack{{Name: "Life Drain", AttackBonus: 6, DamageDice: "4d8", DamageBonus: 3, AvgDamage: 21, DamageType: "necrotic"}}, Traits: []string{"Incorporeal Movement", "Sunlight Sensitivity"}},
|
||||
"xorn": {Slug: "xorn", Name: "Xorn", Size: "medium", Type: "elemental", CR: 5, XP: 1800, HP: 73, AC: 19, SpeedWalk: 20, STR: 17, DEX: 10, CON: 22, INT: 11, WIS: 10, CHA: 11, Multiattack: "The xorn makes three claw attacks and one bite attack.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 6, DamageDice: "3d6", DamageBonus: 3, AvgDamage: 13, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 6, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "slashing"}}, Traits: []string{"Earth Glide", "Stone Camouflage", "Treasure Sense"}},
|
||||
"chimera": {Slug: "chimera", Name: "Chimera", Size: "large", Type: "monstrosity", CR: 6, XP: 2300, HP: 114, AC: 14, SpeedWalk: 30, STR: 19, DEX: 11, CON: 19, INT: 3, WIS: 14, CHA: 10, Multiattack: "The chimera makes three attacks: one with its bite, one with its horns, and one with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 7, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "piercing"}, {Name: "Horns", AttackBonus: 7, DamageDice: "1d12", DamageBonus: 4, AvgDamage: 10, DamageType: "bludgeoning"}, {Name: "Claws", AttackBonus: 7, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "slashing"}, {Name: "Fire Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "7d8", DamageBonus: 0, AvgDamage: 31, DamageType: "fire"}}},
|
||||
"drider": {Slug: "drider", Name: "Drider", Size: "large", Type: "monstrosity", CR: 6, XP: 2300, HP: 123, AC: 19, SpeedWalk: 30, STR: 16, DEX: 16, CON: 18, INT: 13, WIS: 14, CHA: 12, Multiattack: "The drider makes three attacks, either with its longsword or its longbow.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 6, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "piercing"}, {Name: "Longsword", AttackBonus: 6, DamageDice: "1d8", DamageBonus: 3, AvgDamage: 7, DamageType: "slashing"}, {Name: "Longbow", AttackBonus: 6, DamageDice: "1d8", DamageBonus: 3, AvgDamage: 7, DamageType: "piercing"}}, Traits: []string{"Fey Ancestry", "Innate Spellcasting", "Spider Climb", "Sunlight Sensitivity", "Web Walker"}},
|
||||
"invisible_stalker": {Slug: "invisible_stalker", Name: "Invisible Stalker", Size: "medium", Type: "elemental", CR: 6, XP: 2300, HP: 104, AC: 14, SpeedWalk: 50, STR: 16, DEX: 19, CON: 14, INT: 10, WIS: 15, CHA: 11, Multiattack: "The stalker makes two slam attacks.", Attacks: []SRDStatAttack{{Name: "Slam", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "bludgeoning"}}, Traits: []string{"Invisibility", "Faultless Tracker"}},
|
||||
"mage": {Slug: "mage", Name: "Mage", Size: "medium", Type: "humanoid", CR: 6, XP: 2300, HP: 40, AC: 12, SpeedWalk: 30, STR: 9, DEX: 14, CON: 11, INT: 17, WIS: 12, CHA: 11, Attacks: []SRDStatAttack{{Name: "Dagger", AttackBonus: 5, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "piercing"}}, Traits: []string{"Spellcasting"}},
|
||||
"mammoth": {Slug: "mammoth", Name: "Mammoth", Size: "huge", Type: "beast", CR: 6, XP: 2300, HP: 126, AC: 13, SpeedWalk: 40, STR: 24, DEX: 9, CON: 21, INT: 3, WIS: 11, CHA: 6, Attacks: []SRDStatAttack{{Name: "Gore", AttackBonus: 10, DamageDice: "4d8", DamageBonus: 7, AvgDamage: 25, DamageType: "piercing"}, {Name: "Stomp", AttackBonus: 10, DamageDice: "4d10", DamageBonus: 7, AvgDamage: 29, DamageType: "bludgeoning"}}, Traits: []string{"Trampling Charge"}},
|
||||
"medusa": {Slug: "medusa", Name: "Medusa", Size: "medium", Type: "monstrosity", CR: 6, XP: 2300, HP: 127, AC: 15, SpeedWalk: 30, STR: 10, DEX: 15, CON: 16, INT: 12, WIS: 13, CHA: 15, Multiattack: "The medusa makes either three melee attacks - one with its snake hair and two with its shortsword - or two ranged attacks with its longbow.", Attacks: []SRDStatAttack{{Name: "Snake Hair", AttackBonus: 5, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "piercing"}, {Name: "Shortsword", AttackBonus: 5, DamageDice: "1d6", DamageBonus: 2, AvgDamage: 5, DamageType: "piercing"}, {Name: "Longbow", AttackBonus: 5, DamageDice: "2d6", DamageBonus: 0, AvgDamage: 7, DamageType: "piercing"}}, Traits: []string{"Petrifying Gaze"}},
|
||||
"vrock": {Slug: "vrock", Name: "Vrock", Size: "large", Type: "fiend", CR: 6, XP: 2300, HP: 104, AC: 15, SpeedWalk: 40, STR: 17, DEX: 15, CON: 18, INT: 8, WIS: 13, CHA: 8, Multiattack: "The vrock makes two attacks: one with its beak and one with its talons.", Attacks: []SRDStatAttack{{Name: "Beak", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "piercing"}, {Name: "Talons", AttackBonus: 6, DamageDice: "2d10", DamageBonus: 3, AvgDamage: 14, DamageType: "slashing"}}, Traits: []string{"Magic Resistance"}},
|
||||
"wyvern": {Slug: "wyvern", Name: "Wyvern", Size: "large", Type: "dragon", CR: 6, XP: 2300, HP: 110, AC: 13, SpeedWalk: 20, STR: 19, DEX: 10, CON: 16, INT: 5, WIS: 12, CHA: 6, Multiattack: "The wyvern makes two attacks: one with its bite and one with its stinger.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 7, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "piercing"}, {Name: "Claws", AttackBonus: 7, DamageDice: "2d8", DamageBonus: 4, AvgDamage: 13, DamageType: "slashing"}, {Name: "Stinger", AttackBonus: 7, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "piercing"}}},
|
||||
"young_brass_dragon": {Slug: "young_brass_dragon", Name: "Young Brass Dragon", Size: "large", Type: "dragon", CR: 6, XP: 2300, HP: 110, AC: 17, SpeedWalk: 40, STR: 19, DEX: 10, CON: 17, INT: 12, WIS: 11, CHA: 15, Multiattack: "The dragon makes three attacks: one with its bite and two with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 7, DamageDice: "2d10", DamageBonus: 4, AvgDamage: 15, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 7, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "slashing"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "12d6", DamageBonus: 0, AvgDamage: 42, DamageType: "fire"}}},
|
||||
"young_white_dragon": {Slug: "young_white_dragon", Name: "Young White Dragon", Size: "large", Type: "dragon", CR: 6, XP: 2300, HP: 133, AC: 17, SpeedWalk: 40, STR: 18, DEX: 10, CON: 18, INT: 6, WIS: 11, CHA: 12, Multiattack: "The dragon makes three attacks: one with its bite and two with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 7, DamageDice: "2d10+1d8", DamageBonus: 4, AvgDamage: 4, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 7, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "slashing"}, {Name: "Cold Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "10d8", DamageBonus: 0, AvgDamage: 45, DamageType: "cold"}}, Traits: []string{"Ice Walk"}},
|
||||
"giant_ape": {Slug: "giant_ape", Name: "Giant Ape", Size: "huge", Type: "beast", CR: 7, XP: 2900, HP: 157, AC: 12, SpeedWalk: 40, STR: 23, DEX: 14, CON: 18, INT: 7, WIS: 12, CHA: 7, Multiattack: "The ape makes two fist attacks.", Attacks: []SRDStatAttack{{Name: "Fist", AttackBonus: 9, DamageDice: "3d10", DamageBonus: 6, AvgDamage: 22, DamageType: "bludgeoning"}, {Name: "Rock", AttackBonus: 9, DamageDice: "7d6", DamageBonus: 6, AvgDamage: 30, DamageType: "bludgeoning"}}},
|
||||
"oni": {Slug: "oni", Name: "Oni", Size: "large", Type: "giant", CR: 7, XP: 2900, HP: 110, AC: 16, SpeedWalk: 30, STR: 19, DEX: 11, CON: 16, INT: 14, WIS: 12, CHA: 15, Multiattack: "The oni makes two attacks, either with its claws or its glaive.", Attacks: []SRDStatAttack{{Name: "Claw (Oni Form Only)", AttackBonus: 7, DamageDice: "1d8", DamageBonus: 4, AvgDamage: 8, DamageType: "slashing"}, {Name: "Glaive", AttackBonus: 7, DamageDice: "2d10", DamageBonus: 4, AvgDamage: 15, DamageType: "slashing"}}, Traits: []string{"Innate Spellcasting", "Magic Weapons", "Regeneration"}},
|
||||
"shield_guardian": {Slug: "shield_guardian", Name: "Shield Guardian", Size: "large", Type: "construct", CR: 7, XP: 2900, HP: 142, AC: 17, SpeedWalk: 30, STR: 18, DEX: 8, CON: 18, INT: 7, WIS: 10, CHA: 3, Multiattack: "The guardian makes two fist attacks.", Attacks: []SRDStatAttack{{Name: "Fist", AttackBonus: 7, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "bludgeoning"}}, Traits: []string{"Bound", "Regeneration", "Spell Storing"}},
|
||||
"stone_giant": {Slug: "stone_giant", Name: "Stone Giant", Size: "huge", Type: "giant", CR: 7, XP: 2900, HP: 126, AC: 17, SpeedWalk: 40, STR: 23, DEX: 15, CON: 20, INT: 10, WIS: 12, CHA: 9, Multiattack: "The giant makes two greatclub attacks.", Attacks: []SRDStatAttack{{Name: "Greatclub", AttackBonus: 9, DamageDice: "3d8", DamageBonus: 6, AvgDamage: 19, DamageType: "bludgeoning"}, {Name: "Rock", AttackBonus: 9, DamageDice: "4d10", DamageBonus: 6, AvgDamage: 28, DamageType: "bludgeoning"}}, Traits: []string{"Stone Camouflage"}},
|
||||
"young_black_dragon": {Slug: "young_black_dragon", Name: "Young Black Dragon", Size: "large", Type: "dragon", CR: 7, XP: 2900, HP: 127, AC: 18, SpeedWalk: 40, STR: 19, DEX: 14, CON: 17, INT: 12, WIS: 11, CHA: 15, Multiattack: "The dragon makes three attacks: one with its bite and two with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 7, DamageDice: "2d10+1d8", DamageBonus: 4, AvgDamage: 4, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 7, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "slashing"}, {Name: "Acid Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "11d8", DamageBonus: 0, AvgDamage: 49, DamageType: "acid"}}, Traits: []string{"Amphibious"}},
|
||||
"young_copper_dragon": {Slug: "young_copper_dragon", Name: "Young Copper Dragon", Size: "large", Type: "dragon", CR: 7, XP: 2900, HP: 119, AC: 17, SpeedWalk: 40, STR: 19, DEX: 12, CON: 17, INT: 16, WIS: 13, CHA: 15, Multiattack: "The dragon makes three attacks: one with its bite and two with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 7, DamageDice: "2d10", DamageBonus: 4, AvgDamage: 15, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 7, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "slashing"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "9d8", DamageBonus: 0, AvgDamage: 40, DamageType: "acid"}}},
|
||||
"assassin": {Slug: "assassin", Name: "Assassin", Size: "medium", Type: "humanoid", CR: 8, XP: 3900, HP: 78, AC: 15, SpeedWalk: 30, STR: 11, DEX: 16, CON: 14, INT: 13, WIS: 11, CHA: 10, Multiattack: "The assassin makes two shortsword attacks.", Attacks: []SRDStatAttack{{Name: "Shortsword", AttackBonus: 6, DamageDice: "1d6", DamageBonus: 3, AvgDamage: 6, DamageType: "piercing"}, {Name: "Light Crossbow", AttackBonus: 6, DamageDice: "1d8", DamageBonus: 3, AvgDamage: 7, DamageType: "piercing"}}, Traits: []string{"Assassinate", "Evasion", "Sneak Attack (1/Turn)"}},
|
||||
"cloaker": {Slug: "cloaker", Name: "Cloaker", Size: "large", Type: "aberration", CR: 8, XP: 3900, HP: 78, AC: 14, SpeedWalk: 10, STR: 17, DEX: 15, CON: 12, INT: 13, WIS: 12, CHA: 14, Multiattack: "The cloaker makes two attacks: one with its bite and one with its tail.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 6, DamageDice: "2d6", DamageBonus: 3, AvgDamage: 10, DamageType: "piercing"}, {Name: "Tail", AttackBonus: 6, DamageDice: "1d8", DamageBonus: 3, AvgDamage: 7, DamageType: "slashing"}}, Traits: []string{"Damage Transfer", "False Appearance", "Light Sensitivity"}},
|
||||
"frost_giant": {Slug: "frost_giant", Name: "Frost Giant", Size: "huge", Type: "giant", CR: 8, XP: 3900, HP: 138, AC: 15, SpeedWalk: 40, STR: 23, DEX: 9, CON: 21, INT: 9, WIS: 10, CHA: 12, Multiattack: "The giant makes two greataxe attacks.", Attacks: []SRDStatAttack{{Name: "Greataxe", AttackBonus: 9, DamageDice: "3d12", DamageBonus: 6, AvgDamage: 25, DamageType: "slashing"}, {Name: "Rock", AttackBonus: 9, DamageDice: "4d10", DamageBonus: 6, AvgDamage: 28, DamageType: "bludgeoning"}}},
|
||||
"hezrou": {Slug: "hezrou", Name: "Hezrou", Size: "large", Type: "fiend", CR: 8, XP: 3900, HP: 136, AC: 16, SpeedWalk: 30, STR: 19, DEX: 17, CON: 20, INT: 5, WIS: 12, CHA: 13, Multiattack: "The hezrou makes three attacks: one with its bite and two with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 7, DamageDice: "2d10", DamageBonus: 4, AvgDamage: 15, DamageType: "piercing"}, {Name: "Claws", AttackBonus: 7, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "slashing"}}, Traits: []string{"Magic Resistance", "Stench"}},
|
||||
"hydra": {Slug: "hydra", Name: "Hydra", Size: "huge", Type: "monstrosity", CR: 8, XP: 3900, HP: 172, AC: 15, SpeedWalk: 30, STR: 20, DEX: 12, CON: 20, INT: 2, WIS: 10, CHA: 7, Multiattack: "The hydra makes as many bite attacks as it has heads.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 8, DamageDice: "1d10", DamageBonus: 5, AvgDamage: 10, DamageType: "piercing"}}, Traits: []string{"Hold Breath", "Multiple Heads", "Reactive Heads", "Wakeful"}},
|
||||
"spirit_naga": {Slug: "spirit_naga", Name: "Spirit Naga", Size: "large", Type: "monstrosity", CR: 8, XP: 3900, HP: 75, AC: 15, SpeedWalk: 40, STR: 18, DEX: 17, CON: 14, INT: 16, WIS: 15, CHA: 16, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 7, DamageDice: "1d6", DamageBonus: 4, AvgDamage: 7, DamageType: "piercing"}}, Traits: []string{"Rejuvenation", "Spellcasting"}},
|
||||
"tyrannosaurus_rex": {Slug: "tyrannosaurus_rex", Name: "Tyrannosaurus Rex", Size: "huge", Type: "beast", CR: 8, XP: 3900, HP: 136, AC: 13, SpeedWalk: 50, STR: 25, DEX: 10, CON: 19, INT: 2, WIS: 12, CHA: 9, Multiattack: "The tyrannosaurus makes two attacks: one with its bite and one with its tail.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 10, DamageDice: "4d12", DamageBonus: 7, AvgDamage: 33, DamageType: "piercing"}, {Name: "Tail", AttackBonus: 10, DamageDice: "3d8", DamageBonus: 7, AvgDamage: 20, DamageType: "bludgeoning"}}},
|
||||
"young_bronze_dragon": {Slug: "young_bronze_dragon", Name: "Young Bronze Dragon", Size: "large", Type: "dragon", CR: 8, XP: 3900, HP: 142, AC: 18, SpeedWalk: 40, STR: 21, DEX: 10, CON: 19, INT: 14, WIS: 13, CHA: 17, Multiattack: "The dragon makes three attacks: one with its bite and two with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 8, DamageDice: "2d10", DamageBonus: 5, AvgDamage: 16, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 8, DamageDice: "2d6", DamageBonus: 5, AvgDamage: 12, DamageType: "slashing"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "10d10", DamageBonus: 0, AvgDamage: 55, DamageType: "lightning"}}, Traits: []string{"Amphibious"}},
|
||||
"young_green_dragon": {Slug: "young_green_dragon", Name: "Young Green Dragon", Size: "large", Type: "dragon", CR: 8, XP: 3900, HP: 136, AC: 18, SpeedWalk: 40, STR: 19, DEX: 12, CON: 17, INT: 16, WIS: 13, CHA: 15, Multiattack: "The dragon makes three attacks: one with its bite and two with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 7, DamageDice: "2d10+2d6", DamageBonus: 4, AvgDamage: 4, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 7, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "slashing"}, {Name: "Poison Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "12d6", DamageBonus: 0, AvgDamage: 42, DamageType: "poison"}}, Traits: []string{"Amphibious"}},
|
||||
"bone_devil": {Slug: "bone_devil", Name: "Bone Devil", Size: "large", Type: "fiend", CR: 9, XP: 5000, HP: 142, AC: 19, SpeedWalk: 40, STR: 18, DEX: 16, CON: 18, INT: 13, WIS: 14, CHA: 16, Multiattack: "The devil makes three attacks: two with its claws and one with its sting.", Attacks: []SRDStatAttack{{Name: "Claw", AttackBonus: 8, DamageDice: "1d8", DamageBonus: 4, AvgDamage: 8, DamageType: "slashing"}, {Name: "Sting", AttackBonus: 8, DamageDice: "2d8", DamageBonus: 4, AvgDamage: 13, DamageType: "piercing"}}, Traits: []string{"Devil's Sight", "Magic Resistance"}},
|
||||
"clay_golem": {Slug: "clay_golem", Name: "Clay Golem", Size: "large", Type: "construct", CR: 9, XP: 5000, HP: 133, AC: 14, SpeedWalk: 20, STR: 20, DEX: 9, CON: 18, INT: 3, WIS: 8, CHA: 1, Multiattack: "The golem makes two slam attacks.", Attacks: []SRDStatAttack{{Name: "Slam", AttackBonus: 8, DamageDice: "2d10", DamageBonus: 5, AvgDamage: 16, DamageType: "bludgeoning"}}, Traits: []string{"Acid Absorption", "Berserk", "Immutable Form", "Magic Resistance", "Magic Weapons"}},
|
||||
"cloud_giant": {Slug: "cloud_giant", Name: "Cloud Giant", Size: "huge", Type: "giant", CR: 9, XP: 5000, HP: 200, AC: 14, SpeedWalk: 40, STR: 27, DEX: 10, CON: 22, INT: 12, WIS: 16, CHA: 16, Multiattack: "The giant makes two morningstar attacks.", Attacks: []SRDStatAttack{{Name: "Morningstar", AttackBonus: 12, DamageDice: "3d8", DamageBonus: 8, AvgDamage: 21, DamageType: "piercing"}, {Name: "Rock", AttackBonus: 12, DamageDice: "4d10", DamageBonus: 8, AvgDamage: 30, DamageType: "bludgeoning"}}, Traits: []string{"Keen Smell", "Innate Spellcasting"}},
|
||||
"fire_giant": {Slug: "fire_giant", Name: "Fire Giant", Size: "huge", Type: "giant", CR: 9, XP: 5000, HP: 162, AC: 18, SpeedWalk: 30, STR: 25, DEX: 9, CON: 23, INT: 10, WIS: 14, CHA: 13, Multiattack: "The giant makes two greatsword attacks.", Attacks: []SRDStatAttack{{Name: "Greatsword", AttackBonus: 11, DamageDice: "6d6", DamageBonus: 7, AvgDamage: 28, DamageType: "slashing"}, {Name: "Rock", AttackBonus: 11, DamageDice: "4d10", DamageBonus: 7, AvgDamage: 29, DamageType: "bludgeoning"}}},
|
||||
"glabrezu": {Slug: "glabrezu", Name: "Glabrezu", Size: "large", Type: "fiend", CR: 9, XP: 5000, HP: 157, AC: 17, SpeedWalk: 40, STR: 20, DEX: 15, CON: 21, INT: 19, WIS: 17, CHA: 16, Multiattack: "The glabrezu makes four attacks: two with its pincers and two with its fists.", Attacks: []SRDStatAttack{{Name: "Pincer", AttackBonus: 9, DamageDice: "2d10", DamageBonus: 5, AvgDamage: 16, DamageType: "bludgeoning"}, {Name: "Fist", AttackBonus: 9, DamageDice: "2d4", DamageBonus: 2, AvgDamage: 7, DamageType: "bludgeoning"}}, Traits: []string{"Innate Spellcasting", "Magic Resistance"}},
|
||||
"treant": {Slug: "treant", Name: "Treant", Size: "huge", Type: "plant", CR: 9, XP: 5000, HP: 138, AC: 16, SpeedWalk: 30, STR: 23, DEX: 8, CON: 21, INT: 12, WIS: 16, CHA: 12, Multiattack: "The treant makes two slam attacks.", Attacks: []SRDStatAttack{{Name: "Slam", AttackBonus: 10, DamageDice: "3d6", DamageBonus: 6, AvgDamage: 16, DamageType: "bludgeoning"}, {Name: "Rock", AttackBonus: 10, DamageDice: "4d10", DamageBonus: 6, AvgDamage: 28, DamageType: "bludgeoning"}}, Traits: []string{"False Appearance", "Siege Monster"}},
|
||||
"young_blue_dragon": {Slug: "young_blue_dragon", Name: "Young Blue Dragon", Size: "large", Type: "dragon", CR: 9, XP: 5000, HP: 152, AC: 18, SpeedWalk: 40, STR: 21, DEX: 10, CON: 19, INT: 14, WIS: 13, CHA: 17, Multiattack: "The dragon makes three attacks: one with its bite and two with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 9, DamageDice: "2d10+1d10", DamageBonus: 5, AvgDamage: 5, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 9, DamageDice: "2d6", DamageBonus: 5, AvgDamage: 12, DamageType: "slashing"}, {Name: "Lightning Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "10d10", DamageBonus: 0, AvgDamage: 55, DamageType: "lightning"}}},
|
||||
"young_silver_dragon": {Slug: "young_silver_dragon", Name: "Young Silver Dragon", Size: "large", Type: "dragon", CR: 9, XP: 5000, HP: 168, AC: 18, SpeedWalk: 40, STR: 23, DEX: 10, CON: 21, INT: 14, WIS: 11, CHA: 19, Multiattack: "The dragon makes three attacks: one with its bite and two with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 10, DamageDice: "2d10", DamageBonus: 6, AvgDamage: 17, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 10, DamageDice: "2d6", DamageBonus: 6, AvgDamage: 13, DamageType: "slashing"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "12d8", DamageBonus: 0, AvgDamage: 54, DamageType: "cold"}}},
|
||||
"aboleth": {Slug: "aboleth", Name: "Aboleth", Size: "large", Type: "aberration", CR: 10, XP: 5900, HP: 135, AC: 17, SpeedWalk: 10, STR: 21, DEX: 9, CON: 15, INT: 18, WIS: 15, CHA: 18, Multiattack: "The aboleth makes three tentacle attacks.", Attacks: []SRDStatAttack{{Name: "Tentacle", AttackBonus: 9, DamageDice: "2d6", DamageBonus: 5, AvgDamage: 12, DamageType: "bludgeoning"}, {Name: "Tail", AttackBonus: 9, DamageDice: "3d6", DamageBonus: 5, AvgDamage: 15, DamageType: "bludgeoning"}}, Traits: []string{"Amphibious", "Mucous Cloud", "Probing Telepathy"}, Legendary: true},
|
||||
"deva": {Slug: "deva", Name: "Deva", Size: "medium", Type: "celestial", CR: 10, XP: 5900, HP: 136, AC: 17, SpeedWalk: 30, STR: 18, DEX: 18, CON: 18, INT: 17, WIS: 20, CHA: 20, Multiattack: "The deva makes two melee attacks.", Attacks: []SRDStatAttack{{Name: "Mace", AttackBonus: 8, DamageDice: "1d6+4d8", DamageBonus: 4, AvgDamage: 4, DamageType: "bludgeoning"}}, Traits: []string{"Angelic Weapons", "Innate Spellcasting", "Magic Resistance"}},
|
||||
"guardian_naga": {Slug: "guardian_naga", Name: "Guardian Naga", Size: "large", Type: "monstrosity", CR: 10, XP: 5900, HP: 127, AC: 18, SpeedWalk: 40, STR: 19, DEX: 18, CON: 16, INT: 16, WIS: 19, CHA: 18, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 8, DamageDice: "1d8", DamageBonus: 4, AvgDamage: 8, DamageType: "piercing"}, {Name: "Spit Poison", AttackBonus: 8, DamageDice: "10d8", DamageBonus: 0, AvgDamage: 45, DamageType: "poison"}}, Traits: []string{"Rejuvenation", "Spellcasting"}},
|
||||
"stone_golem": {Slug: "stone_golem", Name: "Stone Golem", Size: "large", Type: "construct", CR: 10, XP: 5900, HP: 178, AC: 17, SpeedWalk: 30, STR: 22, DEX: 9, CON: 20, INT: 3, WIS: 11, CHA: 1, Multiattack: "The golem makes two slam attacks.", Attacks: []SRDStatAttack{{Name: "Slam", AttackBonus: 10, DamageDice: "3d8", DamageBonus: 6, AvgDamage: 19, DamageType: "bludgeoning"}}, Traits: []string{"Immutable Form", "Magic Resistance", "Magic Weapons"}},
|
||||
"young_gold_dragon": {Slug: "young_gold_dragon", Name: "Young Gold Dragon", Size: "large", Type: "dragon", CR: 10, XP: 5900, HP: 178, AC: 18, SpeedWalk: 40, STR: 23, DEX: 14, CON: 21, INT: 16, WIS: 13, CHA: 20, Multiattack: "The dragon makes three attacks: one with its bite and two with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 10, DamageDice: "2d10", DamageBonus: 6, AvgDamage: 17, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 10, DamageDice: "2d6", DamageBonus: 6, AvgDamage: 13, DamageType: "slashing"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "10d10", DamageBonus: 0, AvgDamage: 55, DamageType: "fire"}}, Traits: []string{"Amphibious"}},
|
||||
"young_red_dragon": {Slug: "young_red_dragon", Name: "Young Red Dragon", Size: "large", Type: "dragon", CR: 10, XP: 5900, HP: 178, AC: 18, SpeedWalk: 40, STR: 23, DEX: 10, CON: 21, INT: 14, WIS: 11, CHA: 19, Multiattack: "The dragon makes three attacks: one with its bite and two with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 10, DamageDice: "2d10+1d6", DamageBonus: 6, AvgDamage: 6, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 10, DamageDice: "2d6", DamageBonus: 6, AvgDamage: 13, DamageType: "slashing"}, {Name: "Fire Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "16d6", DamageBonus: 0, AvgDamage: 56, DamageType: "fire"}}},
|
||||
"behir": {Slug: "behir", Name: "Behir", Size: "huge", Type: "monstrosity", CR: 11, XP: 7200, HP: 168, AC: 17, SpeedWalk: 50, STR: 23, DEX: 16, CON: 18, INT: 7, WIS: 14, CHA: 12, Multiattack: "The behir makes two attacks: one with its bite and one to constrict.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 10, DamageDice: "3d10", DamageBonus: 6, AvgDamage: 22, DamageType: "piercing"}, {Name: "Constrict", AttackBonus: 10, DamageDice: "2d10+2d10", DamageBonus: 6, AvgDamage: 6, DamageType: "bludgeoning"}, {Name: "Lightning Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "12d10", DamageBonus: 0, AvgDamage: 66, DamageType: "lightning"}, {Name: "Swallow", AttackBonus: 0, DamageDice: "6d6", DamageBonus: 0, AvgDamage: 21, DamageType: "acid"}}},
|
||||
"chain_devil": {Slug: "chain_devil", Name: "Chain Devil", Size: "medium", Type: "fiend", CR: 11, XP: 7200, HP: 85, AC: 16, SpeedWalk: 30, STR: 18, DEX: 15, CON: 18, INT: 11, WIS: 12, CHA: 14, Multiattack: "The devil makes two attacks with its chains.", Attacks: []SRDStatAttack{{Name: "Chain", AttackBonus: 8, DamageDice: "2d6", DamageBonus: 4, AvgDamage: 11, DamageType: "slashing"}}, Traits: []string{"Devil's Sight", "Magic Resistance"}},
|
||||
"djinni": {Slug: "djinni", Name: "Djinni", Size: "large", Type: "elemental", CR: 11, XP: 7200, HP: 161, AC: 17, SpeedWalk: 30, STR: 21, DEX: 15, CON: 22, INT: 15, WIS: 16, CHA: 20, Multiattack: "The djinni makes three scimitar attacks.", Attacks: []SRDStatAttack{{Name: "Scimitar", AttackBonus: 9, DamageDice: "2d6+1d6", DamageBonus: 5, AvgDamage: 5, DamageType: "slashing"}}, Traits: []string{"Elemental Demise", "Innate Spellcasting", "Variant: Genie Powers"}},
|
||||
"efreeti": {Slug: "efreeti", Name: "Efreeti", Size: "large", Type: "elemental", CR: 11, XP: 7200, HP: 200, AC: 17, SpeedWalk: 40, STR: 22, DEX: 12, CON: 24, INT: 16, WIS: 15, CHA: 16, Multiattack: "The efreeti makes two scimitar attacks or uses its Hurl Flame twice.", Attacks: []SRDStatAttack{{Name: "Scimitar", AttackBonus: 10, DamageDice: "2d6+2d6", DamageBonus: 6, AvgDamage: 6, DamageType: "slashing"}, {Name: "Hurl Flame", AttackBonus: 7, DamageDice: "5d6", DamageBonus: 0, AvgDamage: 17, DamageType: "fire"}}, Traits: []string{"Elemental Demise", "Innate Spellcasting", "Variant: Genie Powers"}},
|
||||
"gynosphinx": {Slug: "gynosphinx", Name: "Gynosphinx", Size: "large", Type: "monstrosity", CR: 11, XP: 7200, HP: 136, AC: 17, SpeedWalk: 40, STR: 18, DEX: 15, CON: 16, INT: 18, WIS: 18, CHA: 18, Multiattack: "The sphinx makes two claw attacks.", Attacks: []SRDStatAttack{{Name: "Claw", AttackBonus: 9, DamageDice: "2d8", DamageBonus: 4, AvgDamage: 13, DamageType: "slashing"}}, Traits: []string{"Inscrutable", "Magic Weapons", "Spellcasting"}, Legendary: true},
|
||||
"horned_devil": {Slug: "horned_devil", Name: "Horned Devil", Size: "large", Type: "fiend", CR: 11, XP: 7200, HP: 178, AC: 18, SpeedWalk: 20, STR: 22, DEX: 17, CON: 21, INT: 12, WIS: 16, CHA: 17, Multiattack: "The devil makes three melee attacks: two with its fork and one with its tail.", Attacks: []SRDStatAttack{{Name: "Fork", AttackBonus: 10, DamageDice: "2d8", DamageBonus: 6, AvgDamage: 15, DamageType: "piercing"}, {Name: "Tail", AttackBonus: 10, DamageDice: "1d8", DamageBonus: 6, AvgDamage: 10, DamageType: "piercing"}, {Name: "Hurl Flame", AttackBonus: 7, DamageDice: "4d6", DamageBonus: 0, AvgDamage: 14, DamageType: "fire"}}, Traits: []string{"Devil's Sight", "Magic Resistance"}},
|
||||
"remorhaz": {Slug: "remorhaz", Name: "Remorhaz", Size: "huge", Type: "monstrosity", CR: 11, XP: 7200, HP: 195, AC: 17, SpeedWalk: 30, STR: 24, DEX: 13, CON: 21, INT: 4, WIS: 10, CHA: 5, Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 11, DamageDice: "6d10+3d6", DamageBonus: 7, AvgDamage: 7, DamageType: "piercing"}}, Traits: []string{"Heated Body"}},
|
||||
"roc": {Slug: "roc", Name: "Roc", Size: "gargantuan", Type: "monstrosity", CR: 11, XP: 7200, HP: 248, AC: 15, SpeedWalk: 20, STR: 28, DEX: 10, CON: 20, INT: 3, WIS: 10, CHA: 9, Multiattack: "The roc makes two attacks: one with its beak and one with its talons.", Attacks: []SRDStatAttack{{Name: "Beak", AttackBonus: 13, DamageDice: "4d8", DamageBonus: 9, AvgDamage: 27, DamageType: "piercing"}, {Name: "Talons", AttackBonus: 13, DamageDice: "4d6", DamageBonus: 9, AvgDamage: 23, DamageType: "slashing"}}, Traits: []string{"Keen Sight"}},
|
||||
"archmage": {Slug: "archmage", Name: "Archmage", Size: "medium", Type: "humanoid", CR: 12, XP: 8400, HP: 99, AC: 12, SpeedWalk: 30, STR: 10, DEX: 14, CON: 12, INT: 20, WIS: 15, CHA: 16, Attacks: []SRDStatAttack{{Name: "Dagger", AttackBonus: 6, DamageDice: "1d4", DamageBonus: 2, AvgDamage: 4, DamageType: "piercing"}}, Traits: []string{"Magic Resistance", "Spellcasting"}},
|
||||
"erinyes": {Slug: "erinyes", Name: "Erinyes", Size: "medium", Type: "fiend", CR: 12, XP: 8400, HP: 153, AC: 18, SpeedWalk: 30, STR: 18, DEX: 16, CON: 18, INT: 14, WIS: 14, CHA: 18, Multiattack: "The erinyes makes three attacks", Attacks: []SRDStatAttack{{Name: "Longsword", AttackBonus: 8, DamageDice: "1d8+3d8", DamageBonus: 4, AvgDamage: 4, DamageType: "slashing"}, {Name: "Longbow", AttackBonus: 7, DamageDice: "1d8+3d8", DamageBonus: 3, AvgDamage: 3, DamageType: "piercing"}}, Traits: []string{"Hellish Weapons", "Magic Resistance"}},
|
||||
"adult_brass_dragon": {Slug: "adult_brass_dragon", Name: "Adult Brass Dragon", Size: "huge", Type: "dragon", CR: 13, XP: 10000, HP: 172, AC: 18, SpeedWalk: 40, STR: 23, DEX: 10, CON: 21, INT: 14, WIS: 13, CHA: 17, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 11, DamageDice: "2d10", DamageBonus: 6, AvgDamage: 17, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 11, DamageDice: "2d6", DamageBonus: 6, AvgDamage: 13, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 11, DamageDice: "2d8", DamageBonus: 6, AvgDamage: 15, DamageType: "bludgeoning"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "13d6", DamageBonus: 0, AvgDamage: 45, DamageType: "fire"}}, Traits: []string{"Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"adult_white_dragon": {Slug: "adult_white_dragon", Name: "Adult White Dragon", Size: "huge", Type: "dragon", CR: 13, XP: 10000, HP: 200, AC: 18, SpeedWalk: 40, STR: 22, DEX: 10, CON: 22, INT: 8, WIS: 12, CHA: 12, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 11, DamageDice: "2d10+1d8", DamageBonus: 6, AvgDamage: 6, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 11, DamageDice: "2d6", DamageBonus: 6, AvgDamage: 13, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 11, DamageDice: "2d8", DamageBonus: 6, AvgDamage: 15, DamageType: "bludgeoning"}, {Name: "Cold Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "12d8", DamageBonus: 0, AvgDamage: 54, DamageType: "cold"}}, Traits: []string{"Ice Walk", "Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"nalfeshnee": {Slug: "nalfeshnee", Name: "Nalfeshnee", Size: "large", Type: "fiend", CR: 13, XP: 10000, HP: 184, AC: 18, SpeedWalk: 20, STR: 21, DEX: 10, CON: 22, INT: 19, WIS: 12, CHA: 15, Multiattack: "The nalfeshnee uses Horror Nimbus if it can.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 10, DamageDice: "5d10", DamageBonus: 5, AvgDamage: 32, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 10, DamageDice: "3d6", DamageBonus: 5, AvgDamage: 15, DamageType: "slashing"}}, Traits: []string{"Magic Resistance"}},
|
||||
"rakshasa": {Slug: "rakshasa", Name: "Rakshasa", Size: "medium", Type: "fiend", CR: 13, XP: 10000, HP: 110, AC: 16, SpeedWalk: 40, STR: 14, DEX: 17, CON: 18, INT: 13, WIS: 16, CHA: 20, Multiattack: "The rakshasa makes two claw attacks", Attacks: []SRDStatAttack{{Name: "Claw", AttackBonus: 7, DamageDice: "2d6", DamageBonus: 2, AvgDamage: 9, DamageType: "slashing"}}, Traits: []string{"Limited Magic Immunity", "Innate Spellcasting"}},
|
||||
"storm_giant": {Slug: "storm_giant", Name: "Storm Giant", Size: "huge", Type: "giant", CR: 13, XP: 10000, HP: 230, AC: 16, SpeedWalk: 50, STR: 29, DEX: 14, CON: 20, INT: 16, WIS: 18, CHA: 18, Multiattack: "The giant makes two greatsword attacks.", Attacks: []SRDStatAttack{{Name: "Greatsword", AttackBonus: 14, DamageDice: "6d6", DamageBonus: 9, AvgDamage: 30, DamageType: "slashing"}, {Name: "Rock", AttackBonus: 14, DamageDice: "4d12", DamageBonus: 9, AvgDamage: 35, DamageType: "bludgeoning"}, {Name: "Lightning Strike (Recharge 5-6)", AttackBonus: 0, DamageDice: "12d8", DamageBonus: 0, AvgDamage: 54, DamageType: "lightning"}}, Traits: []string{"Amphibious", "Innate Spellcasting"}},
|
||||
"vampire": {Slug: "vampire", Name: "Vampire", Size: "medium", Type: "undead", CR: 13, XP: 10000, HP: 144, AC: 16, SpeedWalk: 30, STR: 18, DEX: 18, CON: 18, INT: 17, WIS: 15, CHA: 18, Attacks: []SRDStatAttack{{Name: "Unarmed Strike (Vampire Form Only)", AttackBonus: 9, DamageDice: "1d8", DamageBonus: 4, AvgDamage: 8, DamageType: "bludgeoning"}, {Name: "Bite (Bat or Vampire Form Only)", AttackBonus: 9, DamageDice: "1d6+3d6", DamageBonus: 4, AvgDamage: 4, DamageType: "piercing"}}, Traits: []string{"Shapechanger", "Legendary Resistance (3/Day)", "Misty Escape", "Regeneration", "Spider Climb", "Vampire Weaknesses"}, Legendary: true},
|
||||
"adult_black_dragon": {Slug: "adult_black_dragon", Name: "Adult Black Dragon", Size: "huge", Type: "dragon", CR: 14, XP: 11500, HP: 195, AC: 19, SpeedWalk: 40, STR: 23, DEX: 14, CON: 21, INT: 14, WIS: 13, CHA: 17, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 11, DamageDice: "2d10+1d8", DamageBonus: 6, AvgDamage: 6, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 11, DamageDice: "2d6", DamageBonus: 6, AvgDamage: 13, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 11, DamageDice: "2d8", DamageBonus: 6, AvgDamage: 15, DamageType: "bludgeoning"}, {Name: "Acid Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "12d8", DamageBonus: 0, AvgDamage: 54, DamageType: "acid"}}, Traits: []string{"Amphibious", "Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"adult_copper_dragon": {Slug: "adult_copper_dragon", Name: "Adult Copper Dragon", Size: "huge", Type: "dragon", CR: 14, XP: 11500, HP: 184, AC: 18, SpeedWalk: 40, STR: 23, DEX: 12, CON: 21, INT: 18, WIS: 15, CHA: 17, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 11, DamageDice: "2d10", DamageBonus: 6, AvgDamage: 17, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 11, DamageDice: "2d6", DamageBonus: 6, AvgDamage: 13, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 11, DamageDice: "2d8", DamageBonus: 6, AvgDamage: 15, DamageType: "bludgeoning"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "12d8", DamageBonus: 0, AvgDamage: 54, DamageType: "acid"}}, Traits: []string{"Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"ice_devil": {Slug: "ice_devil", Name: "Ice Devil", Size: "large", Type: "fiend", CR: 14, XP: 11500, HP: 180, AC: 18, SpeedWalk: 40, STR: 21, DEX: 14, CON: 18, INT: 18, WIS: 15, CHA: 18, Multiattack: "The devil makes three attacks: one with its bite, one with its claws, and one with its tail.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 10, DamageDice: "2d6+3d6", DamageBonus: 5, AvgDamage: 5, DamageType: "piercing"}, {Name: "Claws", AttackBonus: 10, DamageDice: "2d4+3d6", DamageBonus: 5, AvgDamage: 5, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 10, DamageDice: "2d6+3d6", DamageBonus: 5, AvgDamage: 5, DamageType: "bludgeoning"}}, Traits: []string{"Devil's Sight", "Magic Resistance"}},
|
||||
"adult_bronze_dragon": {Slug: "adult_bronze_dragon", Name: "Adult Bronze Dragon", Size: "huge", Type: "dragon", CR: 15, XP: 13000, HP: 212, AC: 19, SpeedWalk: 40, STR: 25, DEX: 10, CON: 23, INT: 16, WIS: 15, CHA: 19, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 12, DamageDice: "2d10", DamageBonus: 7, AvgDamage: 18, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 12, DamageDice: "2d6", DamageBonus: 7, AvgDamage: 14, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 12, DamageDice: "2d8", DamageBonus: 7, AvgDamage: 16, DamageType: "bludgeoning"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "12d10", DamageBonus: 0, AvgDamage: 66, DamageType: "lightning"}}, Traits: []string{"Amphibious", "Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"adult_green_dragon": {Slug: "adult_green_dragon", Name: "Adult Green Dragon", Size: "huge", Type: "dragon", CR: 15, XP: 13000, HP: 207, AC: 19, SpeedWalk: 40, STR: 23, DEX: 12, CON: 21, INT: 18, WIS: 15, CHA: 17, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 11, DamageDice: "2d10+2d6", DamageBonus: 6, AvgDamage: 6, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 11, DamageDice: "2d6", DamageBonus: 6, AvgDamage: 13, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 11, DamageDice: "2d8", DamageBonus: 6, AvgDamage: 15, DamageType: "bludgeoning"}, {Name: "Poison Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "16d6", DamageBonus: 0, AvgDamage: 56, DamageType: "poison"}}, Traits: []string{"Amphibious", "Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"mummy_lord": {Slug: "mummy_lord", Name: "Mummy Lord", Size: "medium", Type: "undead", CR: 15, XP: 13000, HP: 97, AC: 17, SpeedWalk: 20, STR: 18, DEX: 10, CON: 17, INT: 11, WIS: 18, CHA: 16, Multiattack: "The mummy can use its Dreadful Glare and makes one attack with its rotting fist.", Attacks: []SRDStatAttack{{Name: "Rotting Fist", AttackBonus: 9, DamageDice: "3d6+6d6", DamageBonus: 4, AvgDamage: 4, DamageType: "bludgeoning"}}, Traits: []string{"Magic Resistance", "Rejuvenation", "Spellcasting"}, Legendary: true},
|
||||
"purple_worm": {Slug: "purple_worm", Name: "Purple Worm", Size: "gargantuan", Type: "monstrosity", CR: 15, XP: 13000, HP: 247, AC: 18, SpeedWalk: 50, STR: 28, DEX: 7, CON: 22, INT: 1, WIS: 8, CHA: 4, Multiattack: "The worm makes two attacks: one with its bite and one with its stinger.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 9, DamageDice: "3d8", DamageBonus: 9, AvgDamage: 22, DamageType: "piercing"}, {Name: "Tail Stinger", AttackBonus: 9, DamageDice: "3d6", DamageBonus: 9, AvgDamage: 19, DamageType: "piercing"}}, Traits: []string{"Tunneler"}},
|
||||
"adult_blue_dragon": {Slug: "adult_blue_dragon", Name: "Adult Blue Dragon", Size: "huge", Type: "dragon", CR: 16, XP: 15000, HP: 225, AC: 19, SpeedWalk: 40, STR: 25, DEX: 10, CON: 23, INT: 16, WIS: 15, CHA: 19, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 12, DamageDice: "2d10+1d10", DamageBonus: 7, AvgDamage: 7, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 12, DamageDice: "2d6", DamageBonus: 7, AvgDamage: 14, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 12, DamageDice: "2d8", DamageBonus: 7, AvgDamage: 16, DamageType: "bludgeoning"}, {Name: "Lightning Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "12d10", DamageBonus: 0, AvgDamage: 66, DamageType: "lightning"}}, Traits: []string{"Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"adult_silver_dragon": {Slug: "adult_silver_dragon", Name: "Adult Silver Dragon", Size: "huge", Type: "dragon", CR: 16, XP: 15000, HP: 243, AC: 19, SpeedWalk: 40, STR: 27, DEX: 10, CON: 25, INT: 16, WIS: 13, CHA: 21, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 13, DamageDice: "2d10", DamageBonus: 8, AvgDamage: 19, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 13, DamageDice: "2d6", DamageBonus: 8, AvgDamage: 15, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 13, DamageDice: "2d8", DamageBonus: 8, AvgDamage: 17, DamageType: "bludgeoning"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "13d8", DamageBonus: 0, AvgDamage: 58, DamageType: "cold"}}, Traits: []string{"Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"iron_golem": {Slug: "iron_golem", Name: "Iron Golem", Size: "large", Type: "construct", CR: 16, XP: 15000, HP: 210, AC: 20, SpeedWalk: 30, STR: 24, DEX: 9, CON: 20, INT: 3, WIS: 11, CHA: 1, Multiattack: "The golem makes two melee attacks.", Attacks: []SRDStatAttack{{Name: "Slam", AttackBonus: 13, DamageDice: "3d8", DamageBonus: 7, AvgDamage: 20, DamageType: "bludgeoning"}, {Name: "Sword", AttackBonus: 13, DamageDice: "3d10", DamageBonus: 7, AvgDamage: 23, DamageType: "slashing"}, {Name: "Poison Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "10d8", DamageBonus: 0, AvgDamage: 45, DamageType: "poison"}}, Traits: []string{"Fire Absorption", "Immutable Form", "Magic Resistance", "Magic Weapons"}},
|
||||
"marilith": {Slug: "marilith", Name: "Marilith", Size: "large", Type: "fiend", CR: 16, XP: 15000, HP: 189, AC: 18, SpeedWalk: 40, STR: 18, DEX: 20, CON: 20, INT: 18, WIS: 16, CHA: 20, Multiattack: "The marilith can make seven attacks: six with its longswords and one with its tail.", Attacks: []SRDStatAttack{{Name: "Longsword", AttackBonus: 9, DamageDice: "2d8", DamageBonus: 4, AvgDamage: 13, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 9, DamageDice: "2d10", DamageBonus: 4, AvgDamage: 15, DamageType: "bludgeoning"}}, Traits: []string{"Magic Resistance", "Magic Weapons", "Reactive"}},
|
||||
"planetar": {Slug: "planetar", Name: "Planetar", Size: "large", Type: "celestial", CR: 16, XP: 15000, HP: 200, AC: 19, SpeedWalk: 40, STR: 24, DEX: 20, CON: 24, INT: 19, WIS: 22, CHA: 25, Multiattack: "The planetar makes two melee attacks.", Attacks: []SRDStatAttack{{Name: "Greatsword", AttackBonus: 12, DamageDice: "4d6+5d8", DamageBonus: 7, AvgDamage: 7, DamageType: "slashing"}}, Traits: []string{"Angelic Weapons", "Divine Awareness", "Innate Spellcasting", "Magic Resistance"}},
|
||||
"adult_gold_dragon": {Slug: "adult_gold_dragon", Name: "Adult Gold Dragon", Size: "huge", Type: "dragon", CR: 17, XP: 18000, HP: 256, AC: 19, SpeedWalk: 40, STR: 27, DEX: 14, CON: 25, INT: 16, WIS: 15, CHA: 24, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 14, DamageDice: "2d10", DamageBonus: 8, AvgDamage: 19, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 14, DamageDice: "2d6", DamageBonus: 8, AvgDamage: 15, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 14, DamageDice: "2d8", DamageBonus: 8, AvgDamage: 17, DamageType: "bludgeoning"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "12d10", DamageBonus: 0, AvgDamage: 66, DamageType: "fire"}}, Traits: []string{"Amphibious", "Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"adult_red_dragon": {Slug: "adult_red_dragon", Name: "Adult Red Dragon", Size: "huge", Type: "dragon", CR: 17, XP: 18000, HP: 256, AC: 19, SpeedWalk: 40, STR: 27, DEX: 10, CON: 25, INT: 16, WIS: 13, CHA: 21, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 14, DamageDice: "2d10+2d6", DamageBonus: 8, AvgDamage: 8, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 14, DamageDice: "2d6", DamageBonus: 8, AvgDamage: 15, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 14, DamageDice: "2d8", DamageBonus: 8, AvgDamage: 17, DamageType: "bludgeoning"}, {Name: "Fire Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "18d6", DamageBonus: 0, AvgDamage: 63, DamageType: "fire"}}, Traits: []string{"Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"androsphinx": {Slug: "androsphinx", Name: "Androsphinx", Size: "large", Type: "monstrosity", CR: 17, XP: 18000, HP: 199, AC: 17, SpeedWalk: 40, STR: 22, DEX: 10, CON: 20, INT: 16, WIS: 18, CHA: 23, Multiattack: "The sphinx makes two claw attacks.", Attacks: []SRDStatAttack{{Name: "Claw", AttackBonus: 12, DamageDice: "2d10", DamageBonus: 6, AvgDamage: 17, DamageType: "slashing"}}, Traits: []string{"Inscrutable", "Magic Weapons", "Spellcasting"}, Legendary: true},
|
||||
"dragon_turtle": {Slug: "dragon_turtle", Name: "Dragon Turtle", Size: "gargantuan", Type: "dragon", CR: 17, XP: 18000, HP: 341, AC: 20, SpeedWalk: 20, STR: 25, DEX: 10, CON: 20, INT: 10, WIS: 12, CHA: 12, Multiattack: "The dragon turtle makes three attacks: one with its bite and two with its claws.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 13, DamageDice: "3d12", DamageBonus: 7, AvgDamage: 26, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 13, DamageDice: "2d8", DamageBonus: 7, AvgDamage: 16, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 13, DamageDice: "3d12", DamageBonus: 7, AvgDamage: 26, DamageType: "bludgeoning"}, {Name: "Steam Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "15d6", DamageBonus: 0, AvgDamage: 52, DamageType: "fire"}}, Traits: []string{"Amphibious"}},
|
||||
"balor": {Slug: "balor", Name: "Balor", Size: "huge", Type: "fiend", CR: 19, XP: 22000, HP: 262, AC: 19, SpeedWalk: 40, STR: 26, DEX: 15, CON: 22, INT: 20, WIS: 16, CHA: 22, Multiattack: "The balor makes two attacks: one with its longsword and one with its whip.", Attacks: []SRDStatAttack{{Name: "Longsword", AttackBonus: 14, DamageDice: "3d8+3d8", DamageBonus: 8, AvgDamage: 8, DamageType: "slashing"}, {Name: "Whip", AttackBonus: 14, DamageDice: "2d6+3d6", DamageBonus: 8, AvgDamage: 8, DamageType: "slashing"}}, Traits: []string{"Death Throes", "Fire Aura", "Magic Resistance", "Magic Weapons"}},
|
||||
"ancient_brass_dragon": {Slug: "ancient_brass_dragon", Name: "Ancient Brass Dragon", Size: "gargantuan", Type: "dragon", CR: 20, XP: 25000, HP: 297, AC: 20, SpeedWalk: 40, STR: 27, DEX: 10, CON: 25, INT: 16, WIS: 15, CHA: 19, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 14, DamageDice: "2d10", DamageBonus: 8, AvgDamage: 19, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 14, DamageDice: "2d6", DamageBonus: 8, AvgDamage: 15, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 14, DamageDice: "2d8", DamageBonus: 8, AvgDamage: 17, DamageType: "bludgeoning"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "16d6", DamageBonus: 0, AvgDamage: 56, DamageType: "fire"}}, Traits: []string{"Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"ancient_white_dragon": {Slug: "ancient_white_dragon", Name: "Ancient White Dragon", Size: "gargantuan", Type: "dragon", CR: 20, XP: 25000, HP: 333, AC: 20, SpeedWalk: 40, STR: 26, DEX: 10, CON: 26, INT: 10, WIS: 13, CHA: 14, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 14, DamageDice: "2d10+2d8", DamageBonus: 8, AvgDamage: 8, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 14, DamageDice: "2d6", DamageBonus: 8, AvgDamage: 15, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 14, DamageDice: "2d8", DamageBonus: 8, AvgDamage: 17, DamageType: "bludgeoning"}, {Name: "Cold Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "16d8", DamageBonus: 0, AvgDamage: 72, DamageType: "cold"}}, Traits: []string{"Ice Walk", "Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"pit_fiend": {Slug: "pit_fiend", Name: "Pit Fiend", Size: "large", Type: "fiend", CR: 20, XP: 25000, HP: 300, AC: 19, SpeedWalk: 30, STR: 26, DEX: 14, CON: 24, INT: 22, WIS: 18, CHA: 24, Multiattack: "The pit fiend makes four attacks: one with its bite, one with its claw, one with its mace, and one with its tail.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 14, DamageDice: "4d6", DamageBonus: 8, AvgDamage: 22, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 14, DamageDice: "2d8", DamageBonus: 8, AvgDamage: 17, DamageType: "slashing"}, {Name: "Mace", AttackBonus: 14, DamageDice: "2d6", DamageBonus: 8, AvgDamage: 15, DamageType: "bludgeoning"}, {Name: "Tail", AttackBonus: 14, DamageDice: "3d10", DamageBonus: 8, AvgDamage: 24, DamageType: "bludgeoning"}}, Traits: []string{"Fear Aura", "Magic Resistance", "Magic Weapons", "Innate Spellcasting"}},
|
||||
"ancient_black_dragon": {Slug: "ancient_black_dragon", Name: "Ancient Black Dragon", Size: "gargantuan", Type: "dragon", CR: 21, XP: 33000, HP: 367, AC: 22, SpeedWalk: 40, STR: 27, DEX: 14, CON: 25, INT: 16, WIS: 15, CHA: 19, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 15, DamageDice: "2d10+2d8", DamageBonus: 8, AvgDamage: 8, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 15, DamageDice: "2d6", DamageBonus: 8, AvgDamage: 15, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 15, DamageDice: "2d8", DamageBonus: 8, AvgDamage: 17, DamageType: "bludgeoning"}}, Traits: []string{"Amphibious", "Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"ancient_copper_dragon": {Slug: "ancient_copper_dragon", Name: "Ancient Copper Dragon", Size: "gargantuan", Type: "dragon", CR: 21, XP: 33000, HP: 350, AC: 21, SpeedWalk: 40, STR: 27, DEX: 12, CON: 25, INT: 20, WIS: 17, CHA: 19, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 15, DamageDice: "2d10", DamageBonus: 8, AvgDamage: 19, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 15, DamageDice: "2d6", DamageBonus: 8, AvgDamage: 15, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 15, DamageDice: "2d8", DamageBonus: 8, AvgDamage: 17, DamageType: "bludgeoning"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "14d8", DamageBonus: 0, AvgDamage: 63, DamageType: "acid"}}, Traits: []string{"Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"lich": {Slug: "lich", Name: "Lich", Size: "medium", Type: "undead", CR: 21, XP: 33000, HP: 135, AC: 17, SpeedWalk: 30, STR: 11, DEX: 16, CON: 16, INT: 20, WIS: 14, CHA: 16, Attacks: []SRDStatAttack{{Name: "Paralyzing Touch", AttackBonus: 12, DamageDice: "3d6", DamageBonus: 0, AvgDamage: 10, DamageType: "cold"}}, Traits: []string{"Legendary Resistance (3/Day)", "Rejuvenation", "Spellcasting", "Turn Resistance"}, Legendary: true},
|
||||
"solar": {Slug: "solar", Name: "Solar", Size: "large", Type: "celestial", CR: 21, XP: 33000, HP: 243, AC: 21, SpeedWalk: 50, STR: 26, DEX: 22, CON: 26, INT: 25, WIS: 25, CHA: 30, Multiattack: "The solar makes two greatsword attacks.", Attacks: []SRDStatAttack{{Name: "Greatsword", AttackBonus: 15, DamageDice: "4d6+6d8", DamageBonus: 8, AvgDamage: 8, DamageType: "slashing"}, {Name: "Slaying Longbow", AttackBonus: 13, DamageDice: "2d8+6d8", DamageBonus: 6, AvgDamage: 6, DamageType: "piercing"}}, Traits: []string{"Angelic Weapons", "Divine Awareness", "Innate Spellcasting", "Magic Resistance"}, Legendary: true},
|
||||
"ancient_bronze_dragon": {Slug: "ancient_bronze_dragon", Name: "Ancient Bronze Dragon", Size: "gargantuan", Type: "dragon", CR: 22, XP: 41000, HP: 444, AC: 22, SpeedWalk: 40, STR: 29, DEX: 10, CON: 27, INT: 18, WIS: 17, CHA: 21, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 16, DamageDice: "2d10", DamageBonus: 9, AvgDamage: 20, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 16, DamageDice: "1d6", DamageBonus: 9, AvgDamage: 12, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 16, DamageDice: "2d8", DamageBonus: 9, AvgDamage: 18, DamageType: "bludgeoning"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "16d10", DamageBonus: 0, AvgDamage: 88, DamageType: "lightning"}}, Traits: []string{"Amphibious", "Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"ancient_green_dragon": {Slug: "ancient_green_dragon", Name: "Ancient Green Dragon", Size: "gargantuan", Type: "dragon", CR: 22, XP: 41000, HP: 385, AC: 21, SpeedWalk: 40, STR: 27, DEX: 12, CON: 25, INT: 20, WIS: 17, CHA: 19, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 15, DamageDice: "2d10+3d6", DamageBonus: 9, AvgDamage: 9, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 15, DamageDice: "4d6", DamageBonus: 8, AvgDamage: 22, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 15, DamageDice: "2d8", DamageBonus: 8, AvgDamage: 17, DamageType: "bludgeoning"}, {Name: "Poison Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "22d6", DamageBonus: 0, AvgDamage: 77, DamageType: "poison"}}, Traits: []string{"Amphibious", "Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"ancient_blue_dragon": {Slug: "ancient_blue_dragon", Name: "Ancient Blue Dragon", Size: "gargantuan", Type: "dragon", CR: 23, XP: 50000, HP: 481, AC: 22, SpeedWalk: 40, STR: 29, DEX: 10, CON: 27, INT: 18, WIS: 17, CHA: 21, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 16, DamageDice: "2d10+2d10", DamageBonus: 9, AvgDamage: 9, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 16, DamageDice: "2d6", DamageBonus: 9, AvgDamage: 16, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 16, DamageDice: "2d8", DamageBonus: 9, AvgDamage: 18, DamageType: "bludgeoning"}, {Name: "Lightning Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "16d10", DamageBonus: 0, AvgDamage: 88, DamageType: "lightning"}}, Traits: []string{"Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"ancient_silver_dragon": {Slug: "ancient_silver_dragon", Name: "Ancient Silver Dragon", Size: "gargantuan", Type: "dragon", CR: 23, XP: 50000, HP: 487, AC: 22, SpeedWalk: 40, STR: 30, DEX: 10, CON: 29, INT: 18, WIS: 15, CHA: 23, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 17, DamageDice: "2d10", DamageBonus: 10, AvgDamage: 21, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 17, DamageDice: "2d6", DamageBonus: 10, AvgDamage: 17, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 17, DamageDice: "2d8", DamageBonus: 10, AvgDamage: 19, DamageType: "bludgeoning"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "15d8", DamageBonus: 0, AvgDamage: 67, DamageType: "cold"}}, Traits: []string{"Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"kraken": {Slug: "kraken", Name: "Kraken", Size: "gargantuan", Type: "monstrosity", CR: 23, XP: 50000, HP: 472, AC: 18, SpeedWalk: 20, STR: 30, DEX: 11, CON: 25, INT: 22, WIS: 18, CHA: 20, Multiattack: "The kraken makes three tentacle attacks, each of which it can replace with one use of Fling.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 7, DamageDice: "3d8", DamageBonus: 10, AvgDamage: 23, DamageType: "piercing"}, {Name: "Tentacle", AttackBonus: 7, DamageDice: "3d6", DamageBonus: 10, AvgDamage: 20, DamageType: "bludgeoning"}, {Name: "Lightning Storm", AttackBonus: 0, DamageDice: "4d10", DamageBonus: 0, AvgDamage: 22, DamageType: "lightning"}}, Traits: []string{"Amphibious", "Freedom of Movement", "Siege Monster"}, Legendary: true},
|
||||
"ancient_gold_dragon": {Slug: "ancient_gold_dragon", Name: "Ancient Gold Dragon", Size: "gargantuan", Type: "dragon", CR: 24, XP: 62000, HP: 546, AC: 22, SpeedWalk: 40, STR: 30, DEX: 14, CON: 29, INT: 18, WIS: 17, CHA: 28, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 17, DamageDice: "2d10", DamageBonus: 10, AvgDamage: 21, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 17, DamageDice: "2d6", DamageBonus: 10, AvgDamage: 17, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 17, DamageDice: "2d8", DamageBonus: 10, AvgDamage: 19, DamageType: "bludgeoning"}, {Name: "Breath Weapons (Recharge 5-6)", AttackBonus: 0, DamageDice: "13d10", DamageBonus: 0, AvgDamage: 71, DamageType: "fire"}}, Traits: []string{"Amphibious", "Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"ancient_red_dragon": {Slug: "ancient_red_dragon", Name: "Ancient Red Dragon", Size: "gargantuan", Type: "dragon", CR: 24, XP: 62000, HP: 546, AC: 22, SpeedWalk: 40, STR: 30, DEX: 10, CON: 29, INT: 18, WIS: 15, CHA: 23, Multiattack: "The dragon can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 17, DamageDice: "2d10+4d6", DamageBonus: 10, AvgDamage: 10, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 17, DamageDice: "2d6", DamageBonus: 10, AvgDamage: 17, DamageType: "slashing"}, {Name: "Tail", AttackBonus: 17, DamageDice: "2d8", DamageBonus: 10, AvgDamage: 19, DamageType: "bludgeoning"}, {Name: "Fire Breath (Recharge 5-6)", AttackBonus: 0, DamageDice: "26d6", DamageBonus: 0, AvgDamage: 91, DamageType: "fire"}}, Traits: []string{"Legendary Resistance (3/Day)"}, Legendary: true},
|
||||
"tarrasque": {Slug: "tarrasque", Name: "Tarrasque", Size: "gargantuan", Type: "monstrosity", CR: 30, XP: 155000, HP: 676, AC: 25, SpeedWalk: 40, STR: 30, DEX: 11, CON: 30, INT: 3, WIS: 11, CHA: 11, Multiattack: "The tarrasque can use its Frightful Presence.", Attacks: []SRDStatAttack{{Name: "Bite", AttackBonus: 19, DamageDice: "4d12", DamageBonus: 10, AvgDamage: 36, DamageType: "piercing"}, {Name: "Claw", AttackBonus: 19, DamageDice: "4d8", DamageBonus: 10, AvgDamage: 28, DamageType: "slashing"}, {Name: "Horns", AttackBonus: 19, DamageDice: "4d10", DamageBonus: 10, AvgDamage: 32, DamageType: "piercing"}, {Name: "Tail", AttackBonus: 19, DamageDice: "4d6", DamageBonus: 10, AvgDamage: 24, DamageType: "bludgeoning"}}, Traits: []string{"Legendary Resistance (3/Day)", "Magic Resistance", "Reflective Carapace", "Siege Monster"}, Legendary: true},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestSRDStagingBestiary_Populated guards the generated raw SRD staging table:
|
||||
// the full SRD dump should be present and structurally sane. Regenerate with
|
||||
// `go run ./cmd/open5e-import (fetch|gen) bestiary` if the SRD set changes.
|
||||
func TestSRDStagingBestiary_Populated(t *testing.T) {
|
||||
if len(srdStagingBestiary) < 300 {
|
||||
t.Fatalf("srdStagingBestiary has %d entries, want the full SRD set (~322)", len(srdStagingBestiary))
|
||||
}
|
||||
|
||||
for slug, m := range srdStagingBestiary {
|
||||
if m.Slug != slug {
|
||||
t.Errorf("%s: Slug field = %q, want map key", slug, m.Slug)
|
||||
}
|
||||
if m.Name == "" {
|
||||
t.Errorf("%s: empty Name", slug)
|
||||
}
|
||||
if m.HP <= 0 || m.AC <= 0 {
|
||||
t.Errorf("%s: HP=%d AC=%d, want both positive", slug, m.HP, m.AC)
|
||||
}
|
||||
for _, a := range m.Attacks {
|
||||
// AvgDamage is precomputed; a parsed attack should carry dice and a
|
||||
// non-negative average (flat negative bonuses can zero it, not less).
|
||||
if a.DamageDice == "" {
|
||||
t.Errorf("%s: attack %q has no DamageDice", slug, a.Name)
|
||||
}
|
||||
if a.AvgDamage < 0 {
|
||||
t.Errorf("%s: attack %q AvgDamage=%d, want >= 0", slug, a.Name, a.AvgDamage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSRDStagingBestiary_KnownEntry spot-checks a canonical stat block so a
|
||||
// classifier regression surfaces as a value mismatch, not a silent drift.
|
||||
func TestSRDStagingBestiary_KnownEntry(t *testing.T) {
|
||||
ab, ok := srdStagingBestiary["aboleth"]
|
||||
if !ok {
|
||||
t.Fatal("aboleth missing from staging bestiary")
|
||||
}
|
||||
if ab.HP != 135 || ab.AC != 17 || ab.CR != 10 {
|
||||
t.Errorf("aboleth = HP %d / AC %d / CR %v, want 135 / 17 / 10", ab.HP, ab.AC, ab.CR)
|
||||
}
|
||||
if ab.XP != 5900 {
|
||||
t.Errorf("aboleth XP = %d, want 5900 (derived from CR 10)", ab.XP)
|
||||
}
|
||||
if ab.Multiattack == "" {
|
||||
t.Error("aboleth should have a Multiattack description")
|
||||
}
|
||||
if !ab.Legendary {
|
||||
t.Error("aboleth should be flagged Legendary")
|
||||
}
|
||||
// Tentacle: +9 to hit, 2d6+5 → avg 12.
|
||||
var tentacle *SRDStatAttack
|
||||
for i := range ab.Attacks {
|
||||
if ab.Attacks[i].Name == "Tentacle" {
|
||||
tentacle = &ab.Attacks[i]
|
||||
}
|
||||
}
|
||||
if tentacle == nil {
|
||||
t.Fatal("aboleth missing Tentacle attack")
|
||||
}
|
||||
if tentacle.AttackBonus != 9 || tentacle.DamageDice != "2d6" || tentacle.AvgDamage != 12 {
|
||||
t.Errorf("aboleth Tentacle = %+v, want +9 / 2d6 / avg 12", *tentacle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package plugin
|
||||
|
||||
// Raw Open5e SRD stat blocks — the balance-baseline staging table.
|
||||
//
|
||||
// Unlike dndBestiary (hand-tuned named monsters) and srdProfiles (hand-tuned
|
||||
// turn-based multiattack), this table is the *unmodified* SRD: HP, AC, ability
|
||||
// scores, and per-attack damage dice straight from the 5e SRD dump. Raw SRD
|
||||
// damage is calibrated for a 4-PC party and one-shots gogobee's single player
|
||||
// (see bestiary_srd.go's balance-basis note), so this is NOT a drop-in roster —
|
||||
// nothing here feeds the combat engine directly.
|
||||
//
|
||||
// What it IS: the reference the tuning pass reads against. When authoring or
|
||||
// rebalancing a dndBestiary entry, srdStagingBestiary[id] gives the canonical
|
||||
// SRD numbers to scale down from, so tuning is a deliberate derivation rather
|
||||
// than a guess. Generated by cmd/open5e-import — see bestiary_srd_data.go.
|
||||
//
|
||||
// File is intentionally NOT dnd_-prefixed (see memory: no new dnd_* identifiers).
|
||||
|
||||
// SRDStatAttack is one weapon attack from a creature's SRD action list: the
|
||||
// d20 to-hit bonus plus the raw damage dice (e.g. "2d6") and flat bonus, with
|
||||
// AvgDamage precomputed as dice average + bonus for quick comparison.
|
||||
type SRDStatAttack struct {
|
||||
Name string
|
||||
AttackBonus int
|
||||
DamageDice string
|
||||
DamageBonus int
|
||||
AvgDamage int
|
||||
DamageType string
|
||||
}
|
||||
|
||||
// SRDStatBlock is a raw SRD creature stat block. CR/XP/HP/AC and the ability
|
||||
// scores are verbatim SRD; Attacks holds each non-Multiattack weapon action;
|
||||
// Multiattack carries the multiattack action's text so the tuning pass knows
|
||||
// how many strikes a turn the creature makes. Traits lists the creature's
|
||||
// special-ability names (Pack Tactics, Regeneration, …) — names only, since
|
||||
// their mechanics live in the hand-authored MonsterAbility profiles.
|
||||
type SRDStatBlock struct {
|
||||
Slug string
|
||||
Name string
|
||||
Size string
|
||||
Type string
|
||||
CR float64
|
||||
XP int
|
||||
HP int
|
||||
AC int
|
||||
SpeedWalk int // feet; falls back to fastest other mode for flyers/swimmers
|
||||
STR, DEX int
|
||||
CON, INT int
|
||||
WIS, CHA int
|
||||
Multiattack string
|
||||
Attacks []SRDStatAttack
|
||||
Traits []string
|
||||
Legendary bool
|
||||
}
|
||||
|
||||
// srdStagingBestiary is the full raw SRD bestiary, keyed by slug (hyphens → underscores).
|
||||
var srdStagingBestiary = buildSRDStagingBestiary()
|
||||
@@ -0,0 +1,333 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnemyAttackProfile_Fallback(t *testing.T) {
|
||||
// An unregistered creature makes a single attack derived from its template
|
||||
// stats — same behaviour as before the multiattack upgrade.
|
||||
stats := CombatStats{Attack: 9, AttackBonus: 4}
|
||||
got := enemyAttackProfile("not_in_registry", stats)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1 (single fallback attack)", len(got))
|
||||
}
|
||||
if got[0].Damage != 9 || got[0].AttackBonus != 4 {
|
||||
t.Errorf("fallback attack = %+v, want damage 9 / bonus 4", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnemyAttackProfile_Registered(t *testing.T) {
|
||||
// A registered elite returns its full multiattack profile.
|
||||
got := enemyAttackProfile("owlbear", CombatStats{Attack: 99, AttackBonus: 99})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("owlbear len = %d, want 2 (Beak + Claws)", len(got))
|
||||
}
|
||||
// Profile values win over the template stats.
|
||||
if got[0].AttackBonus == 99 || got[1].AttackBonus == 99 {
|
||||
t.Errorf("profile should not inherit template AttackBonus: %+v", got)
|
||||
}
|
||||
|
||||
// A zero per-attack AttackBonus falls back to the (tier-scaled) template
|
||||
// bonus so authoring can leave it blank.
|
||||
srdProfiles["__test_zero_bonus"] = SRDProfile{Attacks: []SRDAttack{{Name: "Slam", Damage: 5}}}
|
||||
defer delete(srdProfiles, "__test_zero_bonus")
|
||||
zb := enemyAttackProfile("__test_zero_bonus", CombatStats{Attack: 1, AttackBonus: 7})
|
||||
if zb[0].AttackBonus != 7 {
|
||||
t.Errorf("zero-bonus attack = %+v, want AttackBonus 7 from template", zb[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnAbilityFires(t *testing.T) {
|
||||
enemy := baseEnemy() // MaxHP 60
|
||||
st := &combatState{rng: rand.New(rand.NewPCG(1, 1))}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
phase string
|
||||
round int
|
||||
enemyHP int
|
||||
eligible bool
|
||||
}{
|
||||
{"any always eligible", "any", 5, 60, true},
|
||||
{"opening round 1", "opening", 1, 60, true},
|
||||
{"opening past round 1", "opening", 2, 60, false},
|
||||
{"clash past round 1", "clash", 2, 60, true},
|
||||
{"clash round 1", "clash", 1, 60, false},
|
||||
{"decisive when bloodied", "decisive", 3, 20, true},
|
||||
{"decisive when healthy", "decisive", 3, 60, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
st.round = c.round
|
||||
st.enemyHP = c.enemyHP
|
||||
// ProcChance 1.0: fires iff eligible. ProcChance 0.0: never fires.
|
||||
alwaysProc := &MonsterAbility{Phase: c.phase, ProcChance: 1.0}
|
||||
if got := turnAbilityFires(alwaysProc, st, &enemy); got != c.eligible {
|
||||
t.Errorf("%s: fired=%v, want %v", c.name, got, c.eligible)
|
||||
}
|
||||
neverProc := &MonsterAbility{Phase: c.phase, ProcChance: 0.0}
|
||||
if turnAbilityFires(neverProc, st, &enemy) {
|
||||
t.Errorf("%s: a 0%% proc chance ability should never fire", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyAbility_Slice2Effects covers the immediate-resolution monster
|
||||
// ability effects: damage riders and the enemy self-heal. All resolve fully
|
||||
// within applyAbility with no persistent state.
|
||||
func TestApplyAbility_Slice2Effects(t *testing.T) {
|
||||
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
|
||||
st := &combatState{
|
||||
playerHP: playerHP, enemyHP: enemyHP, round: 1,
|
||||
rng: rand.New(rand.NewPCG(7, 7)),
|
||||
}
|
||||
return st, basePlayer(), baseEnemy()
|
||||
}
|
||||
phase := &turnCombatPhase
|
||||
res := &CombatResult{}
|
||||
|
||||
damageEffects := []string{"bonus_damage", "aoe", "aoe_fire", "death_aoe", "execute"}
|
||||
for _, eff := range damageEffects {
|
||||
st, player, enemy := newState(100, 60)
|
||||
enemy.Ability = &MonsterAbility{Name: "Test " + eff, Effect: eff}
|
||||
if applyAbility(st, &player, &enemy, phase, res) {
|
||||
t.Errorf("%s: should not down a full-HP player", eff)
|
||||
}
|
||||
if st.playerHP >= 100 {
|
||||
t.Errorf("%s: player HP = %d, want < 100 (damage rider)", eff, st.playerHP)
|
||||
}
|
||||
if len(st.events) != 1 || st.events[0].Damage <= 0 {
|
||||
t.Errorf("%s: want one event with positive damage, got %+v", eff, st.events)
|
||||
}
|
||||
}
|
||||
|
||||
// execute hits harder once the player is under 30% HP.
|
||||
stLow, pl, en := newState(20, 60)
|
||||
en.Ability = &MonsterAbility{Name: "Wail", Effect: "execute"}
|
||||
applyAbility(stLow, &pl, &en, phase, res)
|
||||
lowDmg := 20 - stLow.playerHP
|
||||
stHigh, pl2, en2 := newState(100, 60)
|
||||
en2.Ability = &MonsterAbility{Name: "Wail", Effect: "execute"}
|
||||
applyAbility(stHigh, &pl2, &en2, phase, res)
|
||||
highDmg := 100 - stHigh.playerHP
|
||||
if lowDmg <= highDmg {
|
||||
t.Errorf("execute: low-HP damage %d should exceed full-HP damage %d", lowDmg, highDmg)
|
||||
}
|
||||
|
||||
// self_heal restores enemy HP, capped at MaxHP.
|
||||
stHeal, plH, enH := newState(100, 30)
|
||||
enH.Ability = &MonsterAbility{Name: "Regrow", Effect: "self_heal"}
|
||||
applyAbility(stHeal, &plH, &enH, phase, res)
|
||||
if stHeal.enemyHP <= 30 || stHeal.enemyHP > enH.Stats.MaxHP {
|
||||
t.Errorf("self_heal: enemy HP = %d, want in (30, %d]", stHeal.enemyHP, enH.Stats.MaxHP)
|
||||
}
|
||||
|
||||
// A damage rider that drops the player returns true (no death save armed).
|
||||
stKill, plK, enK := newState(1, 60)
|
||||
enK.Ability = &MonsterAbility{Name: "Smash", Effect: "bonus_damage"}
|
||||
if !applyAbility(stKill, &plK, &enK, phase, res) {
|
||||
t.Error("bonus_damage: lethal hit on a 1-HP player should return true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyAbility_Slice3Effects covers the stateful monster-ability effects:
|
||||
// applyAbility arms a combatState flag/counter (and emits an announcement for
|
||||
// all but evade), and the shared resolution primitives read that state.
|
||||
func TestApplyAbility_Slice3Effects(t *testing.T) {
|
||||
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
|
||||
st := &combatState{
|
||||
playerHP: playerHP, enemyHP: enemyHP, round: 1,
|
||||
rng: rand.New(rand.NewPCG(9, 9)),
|
||||
}
|
||||
return st, basePlayer(), baseEnemy()
|
||||
}
|
||||
phase := &turnCombatPhase
|
||||
res := &CombatResult{}
|
||||
|
||||
// Each effect arms its combatState field and (except evade) announces itself.
|
||||
st, pl, en := newState(100, 60)
|
||||
en.Ability = &MonsterAbility{Name: "Scurry", Effect: "evade"}
|
||||
applyAbility(st, &pl, &en, phase, res)
|
||||
if !st.enemyEvadeNext || len(st.events) != 0 {
|
||||
t.Errorf("evade: want enemyEvadeNext set and no event, got flag=%v events=%d", st.enemyEvadeNext, len(st.events))
|
||||
}
|
||||
|
||||
armChecks := []struct {
|
||||
effect string
|
||||
armed func(*combatState) bool
|
||||
}{
|
||||
{"block", func(s *combatState) bool { return s.enemyBlockUp }},
|
||||
{"advantage", func(s *combatState) bool { return s.enemyAdvantage }},
|
||||
{"retaliate", func(s *combatState) bool { return s.enemyRetaliateFrac > 0 }},
|
||||
{"regenerate", func(s *combatState) bool { return s.enemyRegen > 0 }},
|
||||
{"survive_at_1", func(s *combatState) bool { return s.enemySurviveArmed }},
|
||||
{"stat_drain", func(s *combatState) bool { return s.playerAtkDrain > 0 }},
|
||||
{"debuff", func(s *combatState) bool { return s.playerACDebuff > 0 }},
|
||||
}
|
||||
for _, c := range armChecks {
|
||||
st, pl, en := newState(100, 60)
|
||||
en.Ability = &MonsterAbility{Name: "Test " + c.effect, Effect: c.effect}
|
||||
if applyAbility(st, &pl, &en, phase, res) {
|
||||
t.Errorf("%s: should not down a full-HP player", c.effect)
|
||||
}
|
||||
if !c.armed(st) {
|
||||
t.Errorf("%s: combatState flag not armed", c.effect)
|
||||
}
|
||||
if len(st.events) != 1 {
|
||||
t.Errorf("%s: want one announcement event, got %d", c.effect, len(st.events))
|
||||
}
|
||||
}
|
||||
|
||||
// max_hp_drain lowers effective MaxHP and deals that much immediate damage.
|
||||
stD, plD, enD := newState(100, 60)
|
||||
enD.Ability = &MonsterAbility{Name: "Corrupting Touch", Effect: "max_hp_drain"}
|
||||
applyAbility(stD, &plD, &enD, phase, res)
|
||||
if stD.maxHPDrain <= 0 || stD.playerHP != 100-stD.maxHPDrain {
|
||||
t.Errorf("max_hp_drain: drain=%d playerHP=%d, want playerHP = 100-drain", stD.maxHPDrain, stD.playerHP)
|
||||
}
|
||||
|
||||
// enemyDown lets survive_at_1 cheat death exactly once.
|
||||
stS := &combatState{enemyHP: 0, enemySurviveArmed: true}
|
||||
if enemyDown(stS, "Duel") {
|
||||
t.Error("survive_at_1: armed enemy at 0 HP should not be down")
|
||||
}
|
||||
if stS.enemyHP != 1 || stS.enemySurviveArmed {
|
||||
t.Errorf("survive_at_1: want enemyHP=1 and flag cleared, got hp=%d armed=%v", stS.enemyHP, stS.enemySurviveArmed)
|
||||
}
|
||||
stS.enemyHP = 0
|
||||
if !enemyDown(stS, "Duel") {
|
||||
t.Error("survive_at_1: second drop to 0 should be lethal (one-shot spent)")
|
||||
}
|
||||
|
||||
// evade is consumed by the next player weapon attack — a guaranteed miss.
|
||||
stE, plE, enE := newState(100, 60)
|
||||
stE.enemyEvadeNext = true
|
||||
if resolvePlayerAttack(stE, &plE, &enE, phase, res) {
|
||||
t.Error("evade: a whiffed attack should not end the fight")
|
||||
}
|
||||
if stE.enemyEvadeNext || stE.enemyHP != 60 {
|
||||
t.Errorf("evade: want flag cleared and enemyHP unchanged, got flag=%v hp=%d", stE.enemyEvadeNext, stE.enemyHP)
|
||||
}
|
||||
|
||||
// retaliate reflects a fraction of every player hit back at the player.
|
||||
stR, plR, enR := newState(100, 60)
|
||||
stR.enemyRetaliateFrac = 0.5
|
||||
resolvePlayerAttack(stR, &plR, &enR, phase, res)
|
||||
if stR.playerHP >= 100 {
|
||||
t.Errorf("retaliate: player HP = %d, want < 100 (reflected damage)", stR.playerHP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyAbility_Slice4Effects covers the former flavor-only placeholders,
|
||||
// now backed by real state: applyAbility arms a combatState field and announces
|
||||
// itself, and the shared resolution primitives / helpers read that state.
|
||||
func TestApplyAbility_Slice4Effects(t *testing.T) {
|
||||
newState := func(playerHP, enemyHP int) (*combatState, Combatant, Combatant) {
|
||||
st := &combatState{
|
||||
playerHP: playerHP, enemyHP: enemyHP, round: 1,
|
||||
rng: rand.New(rand.NewPCG(11, 11)),
|
||||
}
|
||||
return st, basePlayer(), baseEnemy()
|
||||
}
|
||||
phase := &turnCombatPhase
|
||||
res := &CombatResult{}
|
||||
|
||||
armChecks := []struct {
|
||||
effect string
|
||||
armed func(*combatState) bool
|
||||
}{
|
||||
{"spell_resist", func(s *combatState) bool { return s.enemySpellResist }},
|
||||
{"reveal_action", func(s *combatState) bool { return s.enemyRevealNext }},
|
||||
{"fear_immune", func(s *combatState) bool { return s.enemyFearImmune }},
|
||||
{"ally_buff", func(s *combatState) bool { return s.enemyAtkBuff > 0 }},
|
||||
}
|
||||
for _, c := range armChecks {
|
||||
st, pl, en := newState(100, 60)
|
||||
en.Ability = &MonsterAbility{Name: "Test " + c.effect, Effect: c.effect}
|
||||
if applyAbility(st, &pl, &en, phase, res) {
|
||||
t.Errorf("%s: should not down a full-HP player", c.effect)
|
||||
}
|
||||
if !c.armed(st) {
|
||||
t.Errorf("%s: combatState field not armed", c.effect)
|
||||
}
|
||||
if st.playerHP != 100 || st.enemyHP != 60 {
|
||||
t.Errorf("%s: HP changed (%d/%d), want 100/60", c.effect, st.playerHP, st.enemyHP)
|
||||
}
|
||||
if len(st.events) != 1 {
|
||||
t.Errorf("%s: want one announcement event, got %d", c.effect, len(st.events))
|
||||
}
|
||||
}
|
||||
|
||||
// The passive immunities are honoured straight off the ability profile,
|
||||
// before the per-round proc has armed the flag.
|
||||
st, _, en := newState(100, 60)
|
||||
en.Ability = &MonsterAbility{Name: "Spell Immunity", Effect: "spell_resist"}
|
||||
if !enemyResistsSpells(&en, st) {
|
||||
t.Error("enemyResistsSpells: should be true from the ability profile alone")
|
||||
}
|
||||
en.Ability = &MonsterAbility{Name: "Draconic Loyalty", Effect: "fear_immune"}
|
||||
if !enemyImmuneToControl(&en, st) {
|
||||
t.Error("enemyImmuneToControl: should be true from the ability profile alone")
|
||||
}
|
||||
|
||||
// ally_buff accumulates but is capped.
|
||||
stB, plB, enB := newState(100, 60)
|
||||
enB.Ability = &MonsterAbility{Name: "Rally", Effect: "ally_buff"}
|
||||
for i := 0; i < 20; i++ {
|
||||
applyAbility(stB, &plB, &enB, phase, res)
|
||||
}
|
||||
if stB.enemyAtkBuff != 15 {
|
||||
t.Errorf("ally_buff: accumulated to %d, want the 15 cap", stB.enemyAtkBuff)
|
||||
}
|
||||
if got := enemyAttackStat(&enB, stB, 1.0); got != enB.Stats.Attack+15 {
|
||||
t.Errorf("enemyAttackStat: got %d, want %d (base + buff)", got, enB.Stats.Attack+15)
|
||||
}
|
||||
|
||||
// reveal_action is consumed by the next player weapon attack.
|
||||
stR, plR, enR := newState(100, 60)
|
||||
stR.enemyRevealNext = true
|
||||
resolvePlayerAttack(stR, &plR, &enR, phase, res)
|
||||
if stR.enemyRevealNext {
|
||||
t.Error("reveal_action: flag should be cleared after the player attack")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnEngine_Multiattack drives a single enemy_turn and confirms a
|
||||
// registered multiattack creature resolves one attack roll per profile entry,
|
||||
// while an unregistered creature still resolves exactly one.
|
||||
func TestTurnEngine_Multiattack(t *testing.T) {
|
||||
countEnemyEvents := func(events []CombatEvent) int {
|
||||
n := 0
|
||||
for _, e := range events {
|
||||
if e.Actor == "enemy" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Registered: owlbear has a 2-attack profile. Pools are huge so the fight
|
||||
// can't end mid-loop and every swing emits its own enemy event.
|
||||
sess := turnSession(CombatPhaseEnemyTurn, 100000, 100000)
|
||||
sess.EnemyID = "owlbear"
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := countEnemyEvents(events); got != 2 {
|
||||
t.Errorf("owlbear enemy events = %d, want 2 (multiattack profile)", got)
|
||||
}
|
||||
|
||||
// Unregistered: a single attack, as before the upgrade.
|
||||
sess2 := turnSession(CombatPhaseEnemyTurn, 100000, 100000)
|
||||
sess2.EnemyID = "goblin"
|
||||
events2, err := stepEngine(sess2, &player, &enemy, PlayerAction{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := countEnemyEvents(events2); got != 1 {
|
||||
t.Errorf("goblin enemy events = %d, want 1 (single-attack fallback)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package plugin
|
||||
|
||||
// Tuned SRD bestiary — the engine-ready layer derived from the raw staging
|
||||
// table.
|
||||
//
|
||||
// srdStagingBestiary holds verbatim SRD stat blocks, which one-shot gogobee's
|
||||
// solo player and are not a drop-in roster (see bestiary_srd_staging.go).
|
||||
// tunedBestiarySRD is what the codified tuning pass produces from it: a
|
||||
// DnDMonsterTemplate per SRD monster, scaled to the engine's gameplay shape.
|
||||
// The formula lives in cmd/open5e-import/tuned.go; regenerate the data with
|
||||
// `go run ./cmd/open5e-import gen tuned`.
|
||||
//
|
||||
// Abilities — the generator classifies each creature's SRD trait names onto an
|
||||
// engine MonsterAbility effect (see abilityFromTraits in tuned.go). A creature
|
||||
// whose traits are all non-combat (Keen Smell, Amphibious, …) keeps a nil
|
||||
// Ability. The Notes field records the raw SRD multiattack/trait text and which
|
||||
// ability was wired, so a hand-tuning pass can refine the pick — and since the
|
||||
// roster merge below lets hand-authored entries win, refining is just a matter
|
||||
// of adding the creature to dndBestiary.
|
||||
//
|
||||
// File is intentionally NOT dnd_-prefixed (see memory: no new dnd_* identifiers).
|
||||
|
||||
var tunedBestiarySRD = buildTunedBestiarySRD()
|
||||
|
||||
// Merge the tuned templates into the canonical dndBestiary lookup. Hand-authored
|
||||
// roster entries WIN: a tuned entry is only added for an ID the roster doesn't
|
||||
// already define, so playtested numbers and wired abilities are never clobbered.
|
||||
var _ = func() bool {
|
||||
for id, m := range tunedBestiarySRD {
|
||||
if _, ok := dndBestiary[id]; !ok {
|
||||
dndBestiary[id] = m
|
||||
}
|
||||
}
|
||||
return true
|
||||
}()
|
||||
@@ -0,0 +1,340 @@
|
||||
// Code generated by cmd/open5e-import. DO NOT EDIT.
|
||||
//
|
||||
// Source: Open5e SRD monster dump (data/open5e/monsters.json), 5e SRD content
|
||||
// under CC-BY-4.0 — see NOTICE. Regenerate with:
|
||||
//
|
||||
// go run ./cmd/open5e-import fetch bestiary
|
||||
// go run ./cmd/open5e-import gen tuned
|
||||
//
|
||||
// Engine-ready DnDMonsterTemplates derived from the raw SRD staging table by
|
||||
// the tuning formula in cmd/open5e-import/tuned.go. Hand-authored dndBestiary
|
||||
// entries take precedence — see bestiary_tuned.go for the merge.
|
||||
|
||||
package plugin
|
||||
|
||||
func buildTunedBestiarySRD() map[string]DnDMonsterTemplate {
|
||||
return map[string]DnDMonsterTemplate{
|
||||
"awakened_shrub": {ID: "awakened_shrub", Name: "Awakened Shrub", CR: 0, HP: 10, AC: 10, Attack: 1, AttackBonus: 1, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: False Appearance"},
|
||||
"baboon": {ID: "baboon", Name: "Baboon", CR: 0, HP: 3, AC: 12, Attack: 1, AttackBonus: 1, Speed: 12, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Pack Tactics"},
|
||||
"badger": {ID: "badger", Name: "Badger", CR: 0, HP: 3, AC: 10, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Smell"},
|
||||
"bat": {ID: "bat", Name: "Bat", CR: 0, HP: 1, AC: 12, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Echolocation, Keen Hearing"},
|
||||
"cat": {ID: "cat", Name: "Cat", CR: 0, HP: 2, AC: 12, Attack: 1, AttackBonus: 0, Speed: 16, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Smell"},
|
||||
"commoner": {ID: "commoner", Name: "Commoner", CR: 0, HP: 4, AC: 10, Attack: 1, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"crab": {ID: "crab", Name: "Crab", CR: 0, HP: 2, AC: 11, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
|
||||
"deer": {ID: "deer", Name: "Deer", CR: 0, HP: 4, AC: 13, Attack: 1, AttackBonus: 2, Speed: 18, BlockRate: 0.05, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"eagle": {ID: "eagle", Name: "Eagle", CR: 0, HP: 3, AC: 12, Attack: 1, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Sight"},
|
||||
"frog": {ID: "frog", Name: "Frog", CR: 0, HP: 1, AC: 11, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Amphibious, Standing Leap"},
|
||||
"giant_fire_beetle": {ID: "giant_fire_beetle", Name: "Giant Fire Beetle", CR: 0, HP: 4, AC: 13, Attack: 1, AttackBonus: 1, Speed: 12, BlockRate: 0.05, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Illumination"},
|
||||
"goat": {ID: "goat", Name: "Goat", CR: 0, HP: 4, AC: 10, Attack: 1, AttackBonus: 3, Speed: 16, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge, Sure-Footed"},
|
||||
"hawk": {ID: "hawk", Name: "Hawk", CR: 0, HP: 1, AC: 13, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0.05, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Sight"},
|
||||
"homunculus": {ID: "homunculus", Name: "Homunculus", CR: 0, HP: 5, AC: 13, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0.05, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Telepathic Bond"},
|
||||
"hyena": {ID: "hyena", Name: "Hyena", CR: 0, HP: 5, AC: 11, Attack: 1, AttackBonus: 2, Speed: 18, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Pack Tactics"},
|
||||
"jackal": {ID: "jackal", Name: "Jackal", CR: 0, HP: 3, AC: 12, Attack: 1, AttackBonus: 1, Speed: 16, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Hearing and Smell, Pack Tactics"},
|
||||
"lemure": {ID: "lemure", Name: "Lemure", CR: 0, HP: 13, AC: 10, Attack: 1, AttackBonus: 3, Speed: 6, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Regeneration", Phase: "any", ProcChance: 0.5, Effect: "regenerate"}, Notes: "SRD-tuned baseline — Regeneration (regenerate) wired from traits. Traits: Devil's Sight, Hellish Rejuvenation"},
|
||||
"lizard": {ID: "lizard", Name: "Lizard", CR: 0, HP: 2, AC: 10, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"octopus": {ID: "octopus", Name: "Octopus", CR: 0, HP: 3, AC: 12, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Hold Breath, Underwater Camouflage, Water Breathing"},
|
||||
"owl": {ID: "owl", Name: "Owl", CR: 0, HP: 1, AC: 11, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Flyby, Keen Hearing and Sight"},
|
||||
"quipper": {ID: "quipper", Name: "Quipper", CR: 0, HP: 1, AC: 13, Attack: 1, AttackBonus: 0, Speed: 16, BlockRate: 0.05, XPValue: 10, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Blood Frenzy, Water Breathing"},
|
||||
"rat": {ID: "rat", Name: "Rat", CR: 0, HP: 1, AC: 10, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Smell"},
|
||||
"raven": {ID: "raven", Name: "Raven", CR: 0, HP: 1, AC: 12, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Mimicry"},
|
||||
"scorpion": {ID: "scorpion", Name: "Scorpion", CR: 0, HP: 1, AC: 11, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"sea_horse": {ID: "sea_horse", Name: "Sea Horse", CR: 0, HP: 1, AC: 11, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Water Breathing"},
|
||||
"shrieker": {ID: "shrieker", Name: "Shrieker", CR: 0, HP: 13, AC: 10, Attack: 1, AttackBonus: 0, Speed: 10, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: False Appearance"},
|
||||
"spider": {ID: "spider", Name: "Spider", CR: 0, HP: 1, AC: 12, Attack: 1, AttackBonus: 0, Speed: 8, BlockRate: 0, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Spider Climb, Web Sense, Web Walker"},
|
||||
"vulture": {ID: "vulture", Name: "Vulture", CR: 0, HP: 5, AC: 10, Attack: 1, AttackBonus: 2, Speed: 6, BlockRate: 0, XPValue: 10, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Sight and Smell, Pack Tactics"},
|
||||
"weasel": {ID: "weasel", Name: "Weasel", CR: 0, HP: 1, AC: 13, Attack: 1, AttackBonus: 0, Speed: 12, BlockRate: 0.05, XPValue: 10, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Hearing and Smell"},
|
||||
"bandit": {ID: "bandit", Name: "Bandit", CR: 0.125, HP: 11, AC: 12, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"blood_hawk": {ID: "blood_hawk", Name: "Blood Hawk", CR: 0.125, HP: 7, AC: 12, Attack: 1, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 25, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Sight, Pack Tactics"},
|
||||
"camel": {ID: "camel", Name: "Camel", CR: 0.125, HP: 15, AC: 10, Attack: 1, AttackBonus: 5, Speed: 18, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"cultist": {ID: "cultist", Name: "Cultist", CR: 0.125, HP: 9, AC: 12, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Dark Devotion"},
|
||||
"flying_snake": {ID: "flying_snake", Name: "Flying Snake", CR: 0.125, HP: 5, AC: 14, Attack: 1, AttackBonus: 0, Speed: 12, BlockRate: 0.05, XPValue: 25, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Flyby"},
|
||||
"giant_crab": {ID: "giant_crab", Name: "Giant Crab", CR: 0.125, HP: 13, AC: 15, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0.1, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
|
||||
"giant_rat": {ID: "giant_rat", Name: "Giant Rat", CR: 0.125, HP: 7, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 25, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Smell, Pack Tactics"},
|
||||
"giant_rat_diseased": {ID: "giant_rat_diseased", Name: "Giant Rat", CR: 0.125, HP: 7, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"giant_weasel": {ID: "giant_weasel", Name: "Giant Weasel", CR: 0.125, HP: 9, AC: 13, Attack: 1, AttackBonus: 5, Speed: 16, BlockRate: 0.05, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Hearing and Smell"},
|
||||
"guard": {ID: "guard", Name: "Guard", CR: 0.125, HP: 11, AC: 16, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0.1, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"kobold": {ID: "kobold", Name: "Kobold", CR: 0.125, HP: 5, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 25, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Sunlight Sensitivity, Pack Tactics"},
|
||||
"mastiff": {ID: "mastiff", Name: "Mastiff", CR: 0.125, HP: 5, AC: 12, Attack: 1, AttackBonus: 3, Speed: 16, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Hearing and Smell"},
|
||||
"merfolk": {ID: "merfolk", Name: "Merfolk", CR: 0.125, HP: 11, AC: 11, Attack: 1, AttackBonus: 2, Speed: 6, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
|
||||
"mule": {ID: "mule", Name: "Mule", CR: 0.125, HP: 11, AC: 10, Attack: 1, AttackBonus: 4, Speed: 16, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Beast of Burden, Sure-Footed"},
|
||||
"noble": {ID: "noble", Name: "Noble", CR: 0.125, HP: 9, AC: 15, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0.1, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"poisonous_snake": {ID: "poisonous_snake", Name: "Poisonous Snake", CR: 0.125, HP: 2, AC: 13, Attack: 1, AttackBonus: 0, Speed: 12, BlockRate: 0.05, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"pony": {ID: "pony", Name: "Pony", CR: 0.125, HP: 11, AC: 10, Attack: 1, AttackBonus: 4, Speed: 16, BlockRate: 0, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"stirge": {ID: "stirge", Name: "Stirge", CR: 0.125, HP: 2, AC: 14, Attack: 1, AttackBonus: 5, Speed: 6, BlockRate: 0.05, XPValue: 25, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"tribal_warrior": {ID: "tribal_warrior", Name: "Tribal Warrior", CR: 0.125, HP: 11, AC: 12, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 25, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Pack Tactics"},
|
||||
"acolyte": {ID: "acolyte", Name: "Acolyte", CR: 0.25, HP: 9, AC: 10, Attack: 1, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Spellcasting"},
|
||||
"axe_beak": {ID: "axe_beak", Name: "Axe Beak", CR: 0.25, HP: 19, AC: 11, Attack: 1, AttackBonus: 4, Speed: 18, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"blink_dog": {ID: "blink_dog", Name: "Blink Dog", CR: 0.25, HP: 22, AC: 13, Attack: 1, AttackBonus: 3, Speed: 16, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Hearing and Smell"},
|
||||
"boar": {ID: "boar", Name: "Boar", CR: 0.25, HP: 11, AC: 11, Attack: 1, AttackBonus: 3, Speed: 16, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge, Relentless (Recharges after a Short or Long Rest)"},
|
||||
"constrictor_snake": {ID: "constrictor_snake", Name: "Constrictor Snake", CR: 0.25, HP: 13, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"draft_horse": {ID: "draft_horse", Name: "Draft Horse", CR: 0.25, HP: 19, AC: 10, Attack: 1, AttackBonus: 6, Speed: 16, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"dretch": {ID: "dretch", Name: "Dretch", CR: 0.25, HP: 18, AC: 11, Attack: 1, AttackBonus: 2, Speed: 8, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dretch makes two attacks: one with its bite and one with its claws."},
|
||||
"drow": {ID: "drow", Name: "Drow", CR: 0.25, HP: 13, AC: 15, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 50, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Fey Ancestry, Innate Spellcasting, Sunlight Sensitivity"},
|
||||
"elk": {ID: "elk", Name: "Elk", CR: 0.25, HP: 13, AC: 10, Attack: 1, AttackBonus: 0, Speed: 18, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge"},
|
||||
"flying_sword": {ID: "flying_sword", Name: "Flying Sword", CR: 0.25, HP: 17, AC: 17, Attack: 1, AttackBonus: 3, Speed: 18, BlockRate: 0.1, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Antimagic Susceptibility, False Appearance"},
|
||||
"giant_badger": {ID: "giant_badger", Name: "Giant Badger", CR: 0.25, HP: 13, AC: 10, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The badger makes two attacks: one with its bite and one with its claws. Traits: Keen Smell"},
|
||||
"giant_bat": {ID: "giant_bat", Name: "Giant Bat", CR: 0.25, HP: 22, AC: 13, Attack: 1, AttackBonus: 4, Speed: 6, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Echolocation, Keen Hearing"},
|
||||
"giant_centipede": {ID: "giant_centipede", Name: "Giant Centipede", CR: 0.25, HP: 4, AC: 13, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"giant_frog": {ID: "giant_frog", Name: "Giant Frog", CR: 0.25, HP: 18, AC: 11, Attack: 1, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Amphibious, Standing Leap"},
|
||||
"giant_lizard": {ID: "giant_lizard", Name: "Giant Lizard", CR: 0.25, HP: 19, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Variant: Hold Breath, Variant: Spider Climb"},
|
||||
"giant_owl": {ID: "giant_owl", Name: "Giant Owl", CR: 0.25, HP: 19, AC: 12, Attack: 1, AttackBonus: 3, Speed: 6, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Flyby, Keen Hearing and Sight"},
|
||||
"giant_poisonous_snake": {ID: "giant_poisonous_snake", Name: "Giant Poisonous Snake", CR: 0.25, HP: 11, AC: 14, Attack: 1, AttackBonus: 6, Speed: 12, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"giant_wolf_spider": {ID: "giant_wolf_spider", Name: "Giant Wolf Spider", CR: 0.25, HP: 11, AC: 13, Attack: 1, AttackBonus: 3, Speed: 16, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Spider Climb, Web Sense, Web Walker"},
|
||||
"goblin": {ID: "goblin", Name: "Goblin", CR: 0.25, HP: 7, AC: 15, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 50, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Nimble Escape"},
|
||||
"grimlock": {ID: "grimlock", Name: "Grimlock", CR: 0.25, HP: 11, AC: 11, Attack: 1, AttackBonus: 5, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Blind Senses, Keen Hearing and Smell, Stone Camouflage"},
|
||||
"panther": {ID: "panther", Name: "Panther", CR: 0.25, HP: 13, AC: 12, Attack: 1, AttackBonus: 4, Speed: 18, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Keen Smell, Pounce"},
|
||||
"pseudodragon": {ID: "pseudodragon", Name: "Pseudodragon", CR: 0.25, HP: 7, AC: 13, Attack: 1, AttackBonus: 4, Speed: 6, BlockRate: 0.05, XPValue: 50, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Traits: Keen Senses, Magic Resistance, Limited Telepathy, Variant: Familiar"},
|
||||
"riding_horse": {ID: "riding_horse", Name: "Riding Horse", CR: 0.25, HP: 13, AC: 10, Attack: 1, AttackBonus: 5, Speed: 18, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"skeleton": {ID: "skeleton", Name: "Skeleton", CR: 0.25, HP: 13, AC: 13, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"sprite": {ID: "sprite", Name: "Sprite", CR: 0.25, HP: 2, AC: 15, Attack: 1, AttackBonus: 0, Speed: 6, BlockRate: 0.1, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"steam_mephit": {ID: "steam_mephit", Name: "Steam Mephit", CR: 0.25, HP: 21, AC: 10, Attack: 1, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Traits: Death Burst, Innate Spellcasting (1/Day)"},
|
||||
"swarm_of_bats": {ID: "swarm_of_bats", Name: "Swarm of Bats", CR: 0.25, HP: 22, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Echolocation, Keen Hearing, Swarm"},
|
||||
"swarm_of_rats": {ID: "swarm_of_rats", Name: "Swarm of Rats", CR: 0.25, HP: 24, AC: 10, Attack: 1, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Smell, Swarm"},
|
||||
"swarm_of_ravens": {ID: "swarm_of_ravens", Name: "Swarm of Ravens", CR: 0.25, HP: 24, AC: 12, Attack: 1, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm"},
|
||||
"violet_fungus": {ID: "violet_fungus", Name: "Violet Fungus", CR: 0.25, HP: 18, AC: 10, Attack: 1, AttackBonus: 2, Speed: 6, BlockRate: 0, XPValue: 50, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The fungus makes 1d4 Rotting Touch attacks. Traits: False Appearance"},
|
||||
"wolf": {ID: "wolf", Name: "Wolf", CR: 0.25, HP: 11, AC: 13, Attack: 1, AttackBonus: 4, Speed: 16, BlockRate: 0.05, XPValue: 50, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Hearing and Smell, Pack Tactics"},
|
||||
"zombie": {ID: "zombie", Name: "Zombie", CR: 0.25, HP: 22, AC: 10, Attack: 1, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 50, Ability: &MonsterAbility{Name: "Undead Fortitude", Phase: "decisive", ProcChance: 0.5, Effect: "survive_at_1"}, Notes: "SRD-tuned baseline — Undead Fortitude (survive_at_1) wired from traits. Traits: Undead Fortitude"},
|
||||
"ape": {ID: "ape", Name: "Ape", CR: 0.5, HP: 19, AC: 12, Attack: 2, AttackBonus: 5, Speed: 12, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The ape makes two fist attacks."},
|
||||
"black_bear": {ID: "black_bear", Name: "Black Bear", CR: 0.5, HP: 19, AC: 11, Attack: 2, AttackBonus: 3, Speed: 16, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The bear makes two attacks: one with its bite and one with its claws. Traits: Keen Smell"},
|
||||
"cockatrice": {ID: "cockatrice", Name: "Cockatrice", CR: 0.5, HP: 27, AC: 11, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"crocodile": {ID: "crocodile", Name: "Crocodile", CR: 0.5, HP: 19, AC: 12, Attack: 2, AttackBonus: 4, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Hold Breath"},
|
||||
"darkmantle": {ID: "darkmantle", Name: "Darkmantle", CR: 0.5, HP: 22, AC: 11, Attack: 2, AttackBonus: 5, Speed: 6, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Echolocation, False Appearance"},
|
||||
"deep_gnome_svirfneblin": {ID: "deep_gnome_svirfneblin", Name: "Deep Gnome", CR: 0.5, HP: 16, AC: 15, Attack: 2, AttackBonus: 4, Speed: 8, BlockRate: 0.1, XPValue: 100, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Stone Camouflage, Gnome Cunning, Innate Spellcasting"},
|
||||
"dust_mephit": {ID: "dust_mephit", Name: "Dust Mephit", CR: 0.5, HP: 17, AC: 12, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Traits: Death Burst, Innate Spellcasting (1/Day)"},
|
||||
"giant_goat": {ID: "giant_goat", Name: "Giant Goat", CR: 0.5, HP: 19, AC: 11, Attack: 2, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge, Sure-Footed"},
|
||||
"giant_sea_horse": {ID: "giant_sea_horse", Name: "Giant Sea Horse", CR: 0.5, HP: 16, AC: 13, Attack: 2, AttackBonus: 3, Speed: 16, BlockRate: 0.05, XPValue: 100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge, Water Breathing"},
|
||||
"giant_wasp": {ID: "giant_wasp", Name: "Giant Wasp", CR: 0.5, HP: 13, AC: 12, Attack: 2, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"gnoll": {ID: "gnoll", Name: "Gnoll", CR: 0.5, HP: 22, AC: 15, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 100, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Rampage"},
|
||||
"gray_ooze": {ID: "gray_ooze", Name: "Gray Ooze", CR: 0.5, HP: 22, AC: 10, Attack: 2, AttackBonus: 3, Speed: 6, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amorphous, Corrode Metal, False Appearance"},
|
||||
"hobgoblin": {ID: "hobgoblin", Name: "Hobgoblin", CR: 0.5, HP: 11, AC: 18, Attack: 2, AttackBonus: 3, Speed: 12, BlockRate: 0.15, XPValue: 100, Ability: &MonsterAbility{Name: "Martial Advantage", Phase: "any", ProcChance: 0.35, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Martial Advantage (bonus_damage) wired from traits. Traits: Martial Advantage"},
|
||||
"ice_mephit": {ID: "ice_mephit", Name: "Ice Mephit", CR: 0.5, HP: 21, AC: 11, Attack: 2, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Traits: Death Burst, False Appearance, Innate Spellcasting (1/Day)"},
|
||||
"lizardfolk": {ID: "lizardfolk", Name: "Lizardfolk", CR: 0.5, HP: 22, AC: 15, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The lizardfolk makes two melee attacks, each one with a different weapon. Traits: Hold Breath"},
|
||||
"magma_mephit": {ID: "magma_mephit", Name: "Magma Mephit", CR: 0.5, HP: 22, AC: 11, Attack: 2, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Traits: Death Burst, False Appearance, Innate Spellcasting (1/Day)"},
|
||||
"magmin": {ID: "magmin", Name: "Magmin", CR: 0.5, HP: 9, AC: 14, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 100, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Traits: Death Burst, Ignited Illumination", FireAttacker: true},
|
||||
"orc": {ID: "orc", Name: "Orc", CR: 0.5, HP: 15, AC: 13, Attack: 2, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 100, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Aggressive"},
|
||||
"reef_shark": {ID: "reef_shark", Name: "Reef Shark", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 4, Speed: 16, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Pack Tactics, Water Breathing"},
|
||||
"rust_monster": {ID: "rust_monster", Name: "Rust Monster", CR: 0.5, HP: 27, AC: 14, Attack: 2, AttackBonus: 3, Speed: 16, BlockRate: 0.05, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Iron Scent, Rust Metal"},
|
||||
"sahuagin": {ID: "sahuagin", Name: "Sahuagin", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 12, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Multiattack: The sahuagin makes two melee attacks: one with its bite and one with its claws or spear. Traits: Blood Frenzy, Limited Amphibiousness, Shark Telepathy"},
|
||||
"satyr": {ID: "satyr", Name: "Satyr", CR: 0.5, HP: 31, AC: 14, Attack: 2, AttackBonus: 3, Speed: 16, BlockRate: 0.05, XPValue: 100, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Traits: Magic Resistance"},
|
||||
"scout": {ID: "scout", Name: "Scout", CR: 0.5, HP: 16, AC: 13, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The scout makes two melee attacks or two ranged attacks. Traits: Keen Hearing and Sight"},
|
||||
"shadow": {ID: "shadow", Name: "Shadow", CR: 0.5, HP: 16, AC: 12, Attack: 2, AttackBonus: 4, Speed: 16, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Surprise Attack", Phase: "opening", ProcChance: 0.8, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Surprise Attack (bonus_damage) wired from traits. Traits: Amorphous, Shadow Stealth, Sunlight Weakness"},
|
||||
"swarm_of_beetles": {ID: "swarm_of_beetles", Name: "Swarm of Beetles", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm"},
|
||||
"swarm_of_centipedes": {ID: "swarm_of_centipedes", Name: "Swarm of Centipedes", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm"},
|
||||
"swarm_of_insects": {ID: "swarm_of_insects", Name: "Swarm of Insects", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm"},
|
||||
"swarm_of_spiders": {ID: "swarm_of_spiders", Name: "Swarm of Spiders", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm, Spider Climb, Web Sense, Web Walker"},
|
||||
"swarm_of_wasps": {ID: "swarm_of_wasps", Name: "Swarm of Wasps", CR: 0.5, HP: 22, AC: 12, Attack: 2, AttackBonus: 3, Speed: 6, BlockRate: 0, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm"},
|
||||
"thug": {ID: "thug", Name: "Thug", CR: 0.5, HP: 32, AC: 11, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Multiattack: The thug makes two melee attacks. Traits: Pack Tactics"},
|
||||
"warhorse": {ID: "warhorse", Name: "Warhorse", CR: 0.5, HP: 19, AC: 11, Attack: 2, AttackBonus: 6, Speed: 18, BlockRate: 0, XPValue: 100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Trampling Charge"},
|
||||
"warhorse_skeleton": {ID: "warhorse_skeleton", Name: "Warhorse Skeleton", CR: 0.5, HP: 22, AC: 13, Attack: 2, AttackBonus: 6, Speed: 18, BlockRate: 0.05, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"worg": {ID: "worg", Name: "Worg", CR: 0.5, HP: 26, AC: 13, Attack: 2, AttackBonus: 5, Speed: 18, BlockRate: 0.05, XPValue: 100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Keen Hearing and Smell"},
|
||||
"animated_armor": {ID: "animated_armor", Name: "Animated Armor", CR: 1, HP: 33, AC: 18, Attack: 2, AttackBonus: 4, Speed: 10, BlockRate: 0.15, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The armor makes two melee attacks. Traits: Antimagic Susceptibility, False Appearance"},
|
||||
"brass_dragon_wyrmling": {ID: "brass_dragon_wyrmling", Name: "Brass Dragon Wyrmling", CR: 1, HP: 16, AC: 16, Attack: 2, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat).", FireAttacker: true},
|
||||
"brown_bear": {ID: "brown_bear", Name: "Brown Bear", CR: 1, HP: 34, AC: 11, Attack: 2, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The bear makes two attacks: one with its bite and one with its claws. Traits: Keen Smell"},
|
||||
"bugbear": {ID: "bugbear", Name: "Bugbear", CR: 1, HP: 27, AC: 16, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 200, Ability: &MonsterAbility{Name: "Surprise Attack", Phase: "opening", ProcChance: 0.8, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Surprise Attack (bonus_damage) wired from traits. Traits: Brute, Surprise Attack"},
|
||||
"copper_dragon_wyrmling": {ID: "copper_dragon_wyrmling", Name: "Copper Dragon Wyrmling", CR: 1, HP: 22, AC: 16, Attack: 2, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"death_dog": {ID: "death_dog", Name: "Death Dog", CR: 1, HP: 39, AC: 12, Attack: 2, AttackBonus: 4, Speed: 16, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dog makes two bite attacks. Traits: Two-Headed"},
|
||||
"dire_wolf": {ID: "dire_wolf", Name: "Dire Wolf", CR: 1, HP: 37, AC: 14, Attack: 2, AttackBonus: 5, Speed: 18, BlockRate: 0.05, XPValue: 200, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Hearing and Smell, Pack Tactics"},
|
||||
"dryad": {ID: "dryad", Name: "Dryad", CR: 1, HP: 22, AC: 11, Attack: 2, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Innate Spellcasting, Magic Resistance, Speak with Beasts and Plants, Tree Stride"},
|
||||
"duergar": {ID: "duergar", Name: "Duergar", CR: 1, HP: 26, AC: 16, Attack: 2, AttackBonus: 4, Speed: 10, BlockRate: 0.1, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Duergar Resilience, Sunlight Sensitivity"},
|
||||
"ghoul": {ID: "ghoul", Name: "Ghoul", CR: 1, HP: 22, AC: 12, Attack: 2, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"giant_eagle": {ID: "giant_eagle", Name: "Giant Eagle", CR: 1, HP: 26, AC: 13, Attack: 2, AttackBonus: 5, Speed: 6, BlockRate: 0.05, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The eagle makes two attacks: one with its beak and one with its talons. Traits: Keen Sight"},
|
||||
"giant_hyena": {ID: "giant_hyena", Name: "Giant Hyena", CR: 1, HP: 45, AC: 12, Attack: 2, AttackBonus: 5, Speed: 18, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Rampage"},
|
||||
"giant_octopus": {ID: "giant_octopus", Name: "Giant Octopus", CR: 1, HP: 52, AC: 11, Attack: 2, AttackBonus: 5, Speed: 6, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Hold Breath, Underwater Camouflage, Water Breathing"},
|
||||
"giant_spider": {ID: "giant_spider", Name: "Giant Spider", CR: 1, HP: 26, AC: 14, Attack: 2, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Spider Climb, Web Sense, Web Walker"},
|
||||
"giant_toad": {ID: "giant_toad", Name: "Giant Toad", CR: 1, HP: 39, AC: 11, Attack: 2, AttackBonus: 4, Speed: 8, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Amphibious, Standing Leap"},
|
||||
"giant_vulture": {ID: "giant_vulture", Name: "Giant Vulture", CR: 1, HP: 22, AC: 10, Attack: 2, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Multiattack: The vulture makes two attacks: one with its beak and one with its talons. Traits: Keen Sight and Smell, Pack Tactics"},
|
||||
"harpy": {ID: "harpy", Name: "Harpy", CR: 1, HP: 38, AC: 11, Attack: 2, AttackBonus: 3, Speed: 8, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The harpy makes two attacks: one with its claws and one with its club."},
|
||||
"hippogriff": {ID: "hippogriff", Name: "Hippogriff", CR: 1, HP: 19, AC: 11, Attack: 2, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The hippogriff makes two attacks: one with its beak and one with its claws. Traits: Keen Sight"},
|
||||
"imp": {ID: "imp", Name: "Imp", CR: 1, HP: 10, AC: 13, Attack: 2, AttackBonus: 5, Speed: 8, BlockRate: 0.05, XPValue: 200, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Traits: Shapechanger, Devil's Sight, Magic Resistance, Variant: Familiar"},
|
||||
"lion": {ID: "lion", Name: "Lion", CR: 1, HP: 26, AC: 12, Attack: 2, AttackBonus: 5, Speed: 18, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Keen Smell, Pack Tactics, Pounce, Running Leap"},
|
||||
"quasit": {ID: "quasit", Name: "Quasit", CR: 1, HP: 7, AC: 13, Attack: 2, AttackBonus: 4, Speed: 16, BlockRate: 0.05, XPValue: 200, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Traits: Shapechanger, Magic Resistance, Variant: Familiar"},
|
||||
"specter": {ID: "specter", Name: "Specter", CR: 1, HP: 22, AC: 12, Attack: 2, AttackBonus: 4, Speed: 18, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Incorporeal Movement, Sunlight Sensitivity"},
|
||||
"spy": {ID: "spy", Name: "Spy", CR: 1, HP: 27, AC: 12, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Martial Advantage", Phase: "any", ProcChance: 0.35, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Martial Advantage (bonus_damage) wired from traits. Multiattack: The spy makes two melee attacks. Traits: Cunning Action, Sneak Attack (1/Turn)"},
|
||||
"swarm_of_quippers": {ID: "swarm_of_quippers", Name: "Swarm of Quippers", CR: 1, HP: 28, AC: 13, Attack: 2, AttackBonus: 5, Speed: 16, BlockRate: 0.05, XPValue: 200, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Blood Frenzy, Swarm, Water Breathing"},
|
||||
"tiger": {ID: "tiger", Name: "Tiger", CR: 1, HP: 37, AC: 12, Attack: 2, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 200, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Keen Smell, Pounce"},
|
||||
"ankheg": {ID: "ankheg", Name: "Ankheg", CR: 2, HP: 39, AC: 14, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"awakened_tree": {ID: "awakened_tree", Name: "Awakened Tree", CR: 2, HP: 59, AC: 13, Attack: 4, AttackBonus: 6, Speed: 8, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: False Appearance"},
|
||||
"azer": {ID: "azer", Name: "Azer", CR: 2, HP: 39, AC: 17, Attack: 4, AttackBonus: 5, Speed: 12, BlockRate: 0.1, XPValue: 450, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Traits: Heated Body, Heated Weapons, Illumination"},
|
||||
"bandit_captain": {ID: "bandit_captain", Name: "Bandit Captain", CR: 2, HP: 65, AC: 15, Attack: 4, AttackBonus: 5, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The captain makes three melee attacks: two with its scimitar and one with its dagger."},
|
||||
"berserker": {ID: "berserker", Name: "Berserker", CR: 2, HP: 67, AC: 13, Attack: 4, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 450, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Reckless"},
|
||||
"black_dragon_wyrmling": {ID: "black_dragon_wyrmling", Name: "Black Dragon Wyrmling", CR: 2, HP: 33, AC: 17, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
|
||||
"bronze_dragon_wyrmling": {ID: "bronze_dragon_wyrmling", Name: "Bronze Dragon Wyrmling", CR: 2, HP: 32, AC: 17, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
|
||||
"centaur": {ID: "centaur", Name: "Centaur", CR: 2, HP: 45, AC: 12, Attack: 4, AttackBonus: 6, Speed: 18, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Multiattack: The centaur makes two attacks: one with its pike and one with its hooves or two with its longbow. Traits: Charge"},
|
||||
"cult_fanatic": {ID: "cult_fanatic", Name: "Cult Fanatic", CR: 2, HP: 22, AC: 13, Attack: 4, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 450, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The fanatic makes two melee attacks. Traits: Dark Devotion, Spellcasting"},
|
||||
"druid": {ID: "druid", Name: "Druid", CR: 2, HP: 27, AC: 11, Attack: 4, AttackBonus: 2, Speed: 12, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Spellcasting"},
|
||||
"ettercap": {ID: "ettercap", Name: "Ettercap", CR: 2, HP: 44, AC: 13, Attack: 4, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The ettercap makes two attacks: one with its bite and one with its claws. Traits: Spider Climb, Web Sense, Web Walker"},
|
||||
"gargoyle": {ID: "gargoyle", Name: "Gargoyle", CR: 2, HP: 52, AC: 15, Attack: 4, AttackBonus: 4, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The gargoyle makes two attacks: one with its bite and one with its claws. Traits: False Appearance"},
|
||||
"gelatinous_cube": {ID: "gelatinous_cube", Name: "Gelatinous Cube", CR: 2, HP: 84, AC: 10, Attack: 4, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Ooze Cube, Transparent"},
|
||||
"ghast": {ID: "ghast", Name: "Ghast", CR: 2, HP: 36, AC: 13, Attack: 4, AttackBonus: 3, Speed: 12, BlockRate: 0.05, XPValue: 450, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Traits: Stench, Turn Defiance"},
|
||||
"giant_boar": {ID: "giant_boar", Name: "Giant Boar", CR: 2, HP: 42, AC: 12, Attack: 4, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge, Relentless (Recharges after a Short or Long Rest)"},
|
||||
"giant_constrictor_snake": {ID: "giant_constrictor_snake", Name: "Giant Constrictor Snake", CR: 2, HP: 60, AC: 12, Attack: 4, AttackBonus: 6, Speed: 12, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"giant_elk": {ID: "giant_elk", Name: "Giant Elk", CR: 2, HP: 42, AC: 15, Attack: 4, AttackBonus: 6, Speed: 18, BlockRate: 0.1, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge"},
|
||||
"gibbering_mouther": {ID: "gibbering_mouther", Name: "Gibbering Mouther", CR: 2, HP: 67, AC: 10, Attack: 4, AttackBonus: 2, Speed: 6, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The gibbering mouther makes one bite attack and, if it can, uses its Blinding Spittle. Traits: Aberrant Ground, Gibbering"},
|
||||
"green_dragon_wyrmling": {ID: "green_dragon_wyrmling", Name: "Green Dragon Wyrmling", CR: 2, HP: 38, AC: 17, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious"},
|
||||
"grick": {ID: "grick", Name: "Grick", CR: 2, HP: 27, AC: 14, Attack: 4, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The grick makes one attack with its tentacles. Traits: Stone Camouflage"},
|
||||
"griffon": {ID: "griffon", Name: "Griffon", CR: 2, HP: 59, AC: 12, Attack: 4, AttackBonus: 6, Speed: 12, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The griffon makes two attacks: one with its beak and one with its claws. Traits: Keen Sight"},
|
||||
"hunter_shark": {ID: "hunter_shark", Name: "Hunter Shark", CR: 2, HP: 45, AC: 12, Attack: 4, AttackBonus: 6, Speed: 16, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Blood Frenzy, Water Breathing"},
|
||||
"merrow": {ID: "merrow", Name: "Merrow", CR: 2, HP: 45, AC: 13, Attack: 4, AttackBonus: 6, Speed: 6, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The merrow makes two attacks: one with its bite and one with its claws or harpoon. Traits: Amphibious"},
|
||||
"mimic": {ID: "mimic", Name: "Mimic", CR: 2, HP: 58, AC: 12, Attack: 4, AttackBonus: 5, Speed: 6, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Shapechanger, Adhesive (Object Form Only), False Appearance (Object Form Only), Grappler"},
|
||||
"minotaur_skeleton": {ID: "minotaur_skeleton", Name: "Minotaur Skeleton", CR: 2, HP: 67, AC: 12, Attack: 4, AttackBonus: 6, Speed: 16, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge"},
|
||||
"ochre_jelly": {ID: "ochre_jelly", Name: "Ochre Jelly", CR: 2, HP: 45, AC: 10, Attack: 4, AttackBonus: 4, Speed: 6, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amorphous, Spider Climb"},
|
||||
"ogre": {ID: "ogre", Name: "Ogre", CR: 2, HP: 59, AC: 11, Attack: 4, AttackBonus: 6, Speed: 16, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"ogre_zombie": {ID: "ogre_zombie", Name: "Ogre Zombie", CR: 2, HP: 85, AC: 10, Attack: 4, AttackBonus: 6, Speed: 12, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Undead Fortitude", Phase: "decisive", ProcChance: 0.5, Effect: "survive_at_1"}, Notes: "SRD-tuned baseline — Undead Fortitude (survive_at_1) wired from traits. Traits: Undead Fortitude"},
|
||||
"pegasus": {ID: "pegasus", Name: "Pegasus", CR: 2, HP: 59, AC: 12, Attack: 4, AttackBonus: 6, Speed: 18, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"plesiosaurus": {ID: "plesiosaurus", Name: "Plesiosaurus", CR: 2, HP: 68, AC: 13, Attack: 4, AttackBonus: 6, Speed: 8, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Hold Breath"},
|
||||
"polar_bear": {ID: "polar_bear", Name: "Polar Bear", CR: 2, HP: 42, AC: 12, Attack: 4, AttackBonus: 7, Speed: 16, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The bear makes two attacks: one with its bite and one with its claws. Traits: Keen Smell"},
|
||||
"priest": {ID: "priest", Name: "Priest", CR: 2, HP: 27, AC: 13, Attack: 4, AttackBonus: 2, Speed: 10, BlockRate: 0.05, XPValue: 450, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Divine Eminence, Spellcasting"},
|
||||
"rhinoceros": {ID: "rhinoceros", Name: "Rhinoceros", CR: 2, HP: 45, AC: 11, Attack: 4, AttackBonus: 7, Speed: 16, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Charge"},
|
||||
"rug_of_smothering": {ID: "rug_of_smothering", Name: "Rug of Smothering", CR: 2, HP: 33, AC: 12, Attack: 4, AttackBonus: 0, Speed: 6, BlockRate: 0, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Antimagic Susceptibility, Damage Transfer, False Appearance"},
|
||||
"saber_toothed_tiger": {ID: "saber_toothed_tiger", Name: "Saber-Toothed Tiger", CR: 2, HP: 52, AC: 12, Attack: 4, AttackBonus: 6, Speed: 16, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Keen Smell, Pounce"},
|
||||
"sea_hag": {ID: "sea_hag", Name: "Sea Hag", CR: 2, HP: 52, AC: 14, Attack: 4, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 450, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Amphibious, Horrific Appearance, Hag Coven, Shared Spellcasting (Coven Only), Hag Eye (Coven Only)"},
|
||||
"silver_dragon_wyrmling": {ID: "silver_dragon_wyrmling", Name: "Silver Dragon Wyrmling", CR: 2, HP: 45, AC: 17, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"swarm_of_poisonous_snakes": {ID: "swarm_of_poisonous_snakes", Name: "Swarm of Poisonous Snakes", CR: 2, HP: 36, AC: 14, Attack: 4, AttackBonus: 6, Speed: 12, BlockRate: 0.05, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Swarm"},
|
||||
"wererat": {ID: "wererat", Name: "Wererat", CR: 2, HP: 33, AC: 12, Attack: 4, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 450, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Shapechanger, Keen Smell"},
|
||||
"white_dragon_wyrmling": {ID: "white_dragon_wyrmling", Name: "White Dragon Wyrmling", CR: 2, HP: 32, AC: 16, Attack: 4, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 450, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"will_o_wisp": {ID: "will_o_wisp", Name: "Will-o'-Wisp", CR: 2, HP: 22, AC: 19, Attack: 4, AttackBonus: 4, Speed: 18, BlockRate: 0.15, XPValue: 450, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Consume Life, Ephemeral, Incorporeal Movement, Variable Illumination"},
|
||||
"basilisk": {ID: "basilisk", Name: "Basilisk", CR: 3, HP: 52, AC: 12, Attack: 6, AttackBonus: 5, Speed: 8, BlockRate: 0, XPValue: 700, Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.4, Effect: "stun"}, Notes: "SRD-tuned baseline — Frightful Presence (stun) wired from traits. Traits: Petrifying Gaze"},
|
||||
"bearded_devil": {ID: "bearded_devil", Name: "Bearded Devil", CR: 3, HP: 52, AC: 13, Attack: 6, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 700, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes two attacks: one with its beard and one with its glaive. Traits: Devil's Sight, Magic Resistance, Steadfast"},
|
||||
"blue_dragon_wyrmling": {ID: "blue_dragon_wyrmling", Name: "Blue Dragon Wyrmling", CR: 3, HP: 52, AC: 17, Attack: 6, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat)."},
|
||||
"doppelganger": {ID: "doppelganger", Name: "Doppelganger", CR: 3, HP: 52, AC: 14, Attack: 6, AttackBonus: 6, Speed: 12, BlockRate: 0.05, XPValue: 700, Ability: &MonsterAbility{Name: "Surprise Attack", Phase: "opening", ProcChance: 0.8, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Surprise Attack (bonus_damage) wired from traits. Multiattack: The doppelganger makes two melee attacks. Traits: Shapechanger, Ambusher, Surprise Attack"},
|
||||
"giant_scorpion": {ID: "giant_scorpion", Name: "Giant Scorpion", CR: 3, HP: 52, AC: 15, Attack: 6, AttackBonus: 4, Speed: 16, BlockRate: 0.1, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The scorpion makes three attacks: two with its claws and one with its sting."},
|
||||
"gold_dragon_wyrmling": {ID: "gold_dragon_wyrmling", Name: "Gold Dragon Wyrmling", CR: 3, HP: 60, AC: 17, Attack: 6, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amphibious", FireAttacker: true},
|
||||
"green_hag": {ID: "green_hag", Name: "Green Hag", CR: 3, HP: 82, AC: 17, Attack: 6, AttackBonus: 6, Speed: 12, BlockRate: 0.1, XPValue: 700, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Amphibious, Innate Spellcasting, Mimicry, Hag Coven, Shared Spellcasting (Coven Only), Hag Eye (Coven Only)"},
|
||||
"hell_hound": {ID: "hell_hound", Name: "Hell Hound", CR: 3, HP: 45, AC: 15, Attack: 6, AttackBonus: 0, Speed: 18, BlockRate: 0.1, XPValue: 700, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Hearing and Smell, Pack Tactics", FireAttacker: true},
|
||||
"killer_whale": {ID: "killer_whale", Name: "Killer Whale", CR: 3, HP: 90, AC: 12, Attack: 6, AttackBonus: 0, Speed: 18, BlockRate: 0, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Echolocation, Hold Breath, Keen Hearing"},
|
||||
"knight": {ID: "knight", Name: "Knight", CR: 3, HP: 52, AC: 18, Attack: 6, AttackBonus: 5, Speed: 12, BlockRate: 0.15, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The knight makes two melee attacks. Traits: Brave"},
|
||||
"manticore": {ID: "manticore", Name: "Manticore", CR: 3, HP: 68, AC: 14, Attack: 6, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The manticore makes three attacks: one with its bite and two with its claws or three with its tail spikes. Traits: Tail Spike Regrowth"},
|
||||
"minotaur": {ID: "minotaur", Name: "Minotaur", CR: 3, HP: 76, AC: 14, Attack: 6, AttackBonus: 6, Speed: 16, BlockRate: 0.05, XPValue: 700, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Charge, Labyrinthine Recall, Reckless"},
|
||||
"mummy": {ID: "mummy", Name: "Mummy", CR: 3, HP: 58, AC: 11, Attack: 6, AttackBonus: 5, Speed: 8, BlockRate: 0, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The mummy can use its Dreadful Glare and makes one attack with its rotting fist."},
|
||||
"nightmare": {ID: "nightmare", Name: "Nightmare", CR: 3, HP: 68, AC: 13, Attack: 6, AttackBonus: 6, Speed: 18, BlockRate: 0.05, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Confer Fire Resistance, Illumination"},
|
||||
"owlbear": {ID: "owlbear", Name: "Owlbear", CR: 3, HP: 59, AC: 13, Attack: 6, AttackBonus: 7, Speed: 16, BlockRate: 0.05, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The owlbear makes two attacks: one with its beak and one with its claws. Traits: Keen Sight and Smell"},
|
||||
"phase_spider": {ID: "phase_spider", Name: "Phase Spider", CR: 3, HP: 32, AC: 13, Attack: 6, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 700, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Ethereal Jaunt, Spider Climb, Web Walker"},
|
||||
"veteran": {ID: "veteran", Name: "Veteran", CR: 3, HP: 58, AC: 17, Attack: 6, AttackBonus: 5, Speed: 12, BlockRate: 0.1, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The veteran makes two longsword attacks."},
|
||||
"werewolf": {ID: "werewolf", Name: "Werewolf", CR: 3, HP: 58, AC: 11, Attack: 6, AttackBonus: 4, Speed: 12, BlockRate: 0, XPValue: 700, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Shapechanger, Keen Hearing and Smell"},
|
||||
"wight": {ID: "wight", Name: "Wight", CR: 3, HP: 45, AC: 14, Attack: 6, AttackBonus: 4, Speed: 12, BlockRate: 0.05, XPValue: 700, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The wight makes two longsword attacks or two longbow attacks. Traits: Sunlight Sensitivity"},
|
||||
"winter_wolf": {ID: "winter_wolf", Name: "Winter Wolf", CR: 3, HP: 75, AC: 13, Attack: 6, AttackBonus: 0, Speed: 18, BlockRate: 0.05, XPValue: 700, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.3, Effect: "advantage"}, Notes: "SRD-tuned baseline — Pack Tactics (advantage) wired from traits. Traits: Keen Hearing and Smell, Pack Tactics, Snow Camouflage"},
|
||||
"black_pudding": {ID: "black_pudding", Name: "Black Pudding", CR: 4, HP: 85, AC: 10, Attack: 7, AttackBonus: 5, Speed: 8, BlockRate: 0, XPValue: 1100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Traits: Amorphous, Corrosive Form, Spider Climb"},
|
||||
"chuul": {ID: "chuul", Name: "Chuul", CR: 4, HP: 93, AC: 16, Attack: 7, AttackBonus: 6, Speed: 12, BlockRate: 0.1, XPValue: 1100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The chuul makes two pincer attacks. Traits: Amphibious, Sense Magic"},
|
||||
"couatl": {ID: "couatl", Name: "Couatl", CR: 4, HP: 97, AC: 19, Attack: 7, AttackBonus: 6, Speed: 12, BlockRate: 0.15, XPValue: 1100, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Innate Spellcasting, Magic Weapons, Shielded Mind"},
|
||||
"elephant": {ID: "elephant", Name: "Elephant", CR: 4, HP: 76, AC: 12, Attack: 7, AttackBonus: 8, Speed: 16, BlockRate: 0, XPValue: 1100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Trampling Charge"},
|
||||
"ettin": {ID: "ettin", Name: "Ettin", CR: 4, HP: 85, AC: 12, Attack: 7, AttackBonus: 7, Speed: 16, BlockRate: 0, XPValue: 1100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The ettin makes two attacks: one with its battleaxe and one with its morningstar. Traits: Two Heads, Wakeful"},
|
||||
"ghost": {ID: "ghost", Name: "Ghost", CR: 4, HP: 45, AC: 11, Attack: 7, AttackBonus: 5, Speed: 16, BlockRate: 0, XPValue: 1100, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Ethereal Sight, Incorporeal Movement"},
|
||||
"lamia": {ID: "lamia", Name: "Lamia", CR: 4, HP: 97, AC: 13, Attack: 7, AttackBonus: 5, Speed: 12, BlockRate: 0.05, XPValue: 1100, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The lamia makes two attacks: one with its claws and one with its dagger or Intoxicating Touch. Traits: Innate Spellcasting"},
|
||||
"red_dragon_wyrmling": {ID: "red_dragon_wyrmling", Name: "Red Dragon Wyrmling", CR: 4, HP: 75, AC: 17, Attack: 7, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 1100, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat).", FireAttacker: true},
|
||||
"succubusincubus": {ID: "succubusincubus", Name: "Succubus/Incubus", CR: 4, HP: 66, AC: 15, Attack: 7, AttackBonus: 0, Speed: 12, BlockRate: 0.1, XPValue: 1100, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Telepathic Bond, Shapechanger"},
|
||||
"wereboar": {ID: "wereboar", Name: "Wereboar", CR: 4, HP: 78, AC: 10, Attack: 7, AttackBonus: 5, Speed: 12, BlockRate: 0, XPValue: 1100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Shapechanger, Charge (Boar or Hybrid Form Only), Relentless (Recharges after a Short or Long Rest)"},
|
||||
"weretiger": {ID: "weretiger", Name: "Weretiger", CR: 4, HP: 120, AC: 12, Attack: 7, AttackBonus: 5, Speed: 12, BlockRate: 0, XPValue: 1100, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Shapechanger, Keen Hearing and Smell, Pounce (Tiger or Hybrid Form Only)"},
|
||||
"air_elemental": {ID: "air_elemental", Name: "Air Elemental", CR: 5, HP: 90, AC: 15, Attack: 8, AttackBonus: 8, Speed: 18, BlockRate: 0.1, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The elemental makes two slam attacks. Traits: Air Form"},
|
||||
"barbed_devil": {ID: "barbed_devil", Name: "Barbed Devil", CR: 5, HP: 110, AC: 15, Attack: 8, AttackBonus: 6, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes three melee attacks: one with its tail and two with its claws. Traits: Barbed Hide, Devil's Sight, Magic Resistance"},
|
||||
"bulette": {ID: "bulette", Name: "Bulette", CR: 5, HP: 94, AC: 17, Attack: 8, AttackBonus: 7, Speed: 16, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Standing Leap"},
|
||||
"earth_elemental": {ID: "earth_elemental", Name: "Earth Elemental", CR: 5, HP: 126, AC: 17, Attack: 8, AttackBonus: 8, Speed: 12, BlockRate: 0.1, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The elemental makes two slam attacks. Traits: Earth Glide, Siege Monster"},
|
||||
"fire_elemental": {ID: "fire_elemental", Name: "Fire Elemental", CR: 5, HP: 102, AC: 13, Attack: 8, AttackBonus: 6, Speed: 18, BlockRate: 0.05, XPValue: 1800, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Multiattack: The elemental makes two touch attacks. Traits: Fire Form, Illumination, Water Susceptibility", FireAttacker: true},
|
||||
"flesh_golem": {ID: "flesh_golem", Name: "Flesh Golem", CR: 5, HP: 93, AC: 10, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0, XPValue: 1800, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The golem makes two slam attacks. Traits: Berserk, Aversion of Fire, Immutable Form, Lightning Absorption, Magic Resistance, Magic Weapons"},
|
||||
"giant_crocodile": {ID: "giant_crocodile", Name: "Giant Crocodile", CR: 5, HP: 85, AC: 14, Attack: 8, AttackBonus: 8, Speed: 12, BlockRate: 0.05, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The crocodile makes two attacks: one with its bite and one with its tail. Traits: Hold Breath"},
|
||||
"giant_shark": {ID: "giant_shark", Name: "Giant Shark", CR: 5, HP: 126, AC: 13, Attack: 8, AttackBonus: 9, Speed: 18, BlockRate: 0.05, XPValue: 1800, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Traits: Blood Frenzy, Water Breathing"},
|
||||
"gladiator": {ID: "gladiator", Name: "Gladiator", CR: 5, HP: 112, AC: 16, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Frenzy", Phase: "any", ProcChance: 0.4, Effect: "enrage"}, Notes: "SRD-tuned baseline — Frenzy (enrage) wired from traits. Multiattack: The gladiator makes three melee attacks or two ranged attacks. Traits: Brave, Brute"},
|
||||
"gorgon": {ID: "gorgon", Name: "Gorgon", CR: 5, HP: 114, AC: 19, Attack: 8, AttackBonus: 8, Speed: 16, BlockRate: 0.15, XPValue: 1800, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Trampling Charge"},
|
||||
"half_red_dragon_veteran": {ID: "half_red_dragon_veteran", Name: "Half-Red Dragon Veteran", CR: 5, HP: 65, AC: 18, Attack: 8, AttackBonus: 0, Speed: 12, BlockRate: 0.15, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The veteran makes two longsword attacks.", FireAttacker: true},
|
||||
"hill_giant": {ID: "hill_giant", Name: "Hill Giant", CR: 5, HP: 105, AC: 13, Attack: 8, AttackBonus: 8, Speed: 16, BlockRate: 0.05, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The giant makes two greatclub attacks."},
|
||||
"night_hag": {ID: "night_hag", Name: "Night Hag", CR: 5, HP: 112, AC: 17, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Innate Spellcasting, Magic Resistance, Night Hag Items, Hag Coven, Shared Spellcasting (Coven Only), Hag Eye (Coven Only)"},
|
||||
"otyugh": {ID: "otyugh", Name: "Otyugh", CR: 5, HP: 114, AC: 14, Attack: 8, AttackBonus: 6, Speed: 12, BlockRate: 0.05, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The otyugh makes three attacks: one with its bite and two with its tentacles. Traits: Limited Telepathy"},
|
||||
"roper": {ID: "roper", Name: "Roper", CR: 5, HP: 93, AC: 20, Attack: 8, AttackBonus: 7, Speed: 6, BlockRate: 0.15, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The roper makes four attacks with its tendrils, uses Reel, and makes one attack with its bite. Traits: False Appearance, Grasping Tendrils, Spider Climb"},
|
||||
"salamander": {ID: "salamander", Name: "Salamander", CR: 5, HP: 90, AC: 15, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Multiattack: The salamander makes two attacks: one with its spear and one with its tail. Traits: Heated Body, Heated Weapons"},
|
||||
"shambling_mound": {ID: "shambling_mound", Name: "Shambling Mound", CR: 5, HP: 136, AC: 15, Attack: 8, AttackBonus: 7, Speed: 8, BlockRate: 0.1, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The shambling mound makes two slam attacks. Traits: Lightning Absorption"},
|
||||
"triceratops": {ID: "triceratops", Name: "Triceratops", CR: 5, HP: 95, AC: 13, Attack: 8, AttackBonus: 9, Speed: 18, BlockRate: 0.05, XPValue: 1800, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Trampling Charge"},
|
||||
"troll": {ID: "troll", Name: "Troll", CR: 5, HP: 84, AC: 15, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Regeneration", Phase: "any", ProcChance: 0.5, Effect: "regenerate"}, Notes: "SRD-tuned baseline — Regeneration (regenerate) wired from traits. Multiattack: The troll makes three attacks: one with its bite and two with its claws. Traits: Keen Smell, Regeneration, Variant: Loathsome Limbs"},
|
||||
"unicorn": {ID: "unicorn", Name: "Unicorn", CR: 5, HP: 67, AC: 12, Attack: 8, AttackBonus: 7, Speed: 18, BlockRate: 0, XPValue: 1800, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The unicorn makes two attacks: one with its hooves and one with its horn. Traits: Charge, Innate Spellcasting, Magic Resistance, Magic Weapons"},
|
||||
"vampire_spawn": {ID: "vampire_spawn", Name: "Vampire Spawn", CR: 5, HP: 82, AC: 15, Attack: 8, AttackBonus: 6, Speed: 12, BlockRate: 0.1, XPValue: 1800, Ability: &MonsterAbility{Name: "Regeneration", Phase: "any", ProcChance: 0.5, Effect: "regenerate"}, Notes: "SRD-tuned baseline — Regeneration (regenerate) wired from traits. Multiattack: The vampire makes two attacks, only one of which can be a bite attack. Traits: Regeneration, Spider Climb, Vampire Weaknesses"},
|
||||
"water_elemental": {ID: "water_elemental", Name: "Water Elemental", CR: 5, HP: 114, AC: 14, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.05, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The elemental makes two slam attacks. Traits: Water Form, Freeze"},
|
||||
"werebear": {ID: "werebear", Name: "Werebear", CR: 5, HP: 135, AC: 10, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0, XPValue: 1800, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Multiattack: In bear form, the werebear makes two claw attacks. Traits: Shapechanger, Keen Smell"},
|
||||
"wraith": {ID: "wraith", Name: "Wraith", CR: 5, HP: 67, AC: 13, Attack: 8, AttackBonus: 6, Speed: 18, BlockRate: 0.05, XPValue: 1800, Ability: &MonsterAbility{Name: "Evasive", Phase: "any", ProcChance: 0.3, Effect: "evade"}, Notes: "SRD-tuned baseline — Evasive (evade) wired from traits. Traits: Incorporeal Movement, Sunlight Sensitivity"},
|
||||
"xorn": {ID: "xorn", Name: "Xorn", CR: 5, HP: 73, AC: 19, Attack: 8, AttackBonus: 6, Speed: 8, BlockRate: 0.15, XPValue: 1800, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The xorn makes three claw attacks and one bite attack. Traits: Earth Glide, Stone Camouflage, Treasure Sense"},
|
||||
"chimera": {ID: "chimera", Name: "Chimera", CR: 6, HP: 114, AC: 14, Attack: 10, AttackBonus: 0, Speed: 12, BlockRate: 0.05, XPValue: 2300, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The chimera makes three attacks: one with its bite, one with its horns, and one with its claws.", FireAttacker: true},
|
||||
"drider": {ID: "drider", Name: "Drider", CR: 6, HP: 123, AC: 19, Attack: 10, AttackBonus: 6, Speed: 12, BlockRate: 0.15, XPValue: 2300, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The drider makes three attacks, either with its longsword or its longbow. Traits: Fey Ancestry, Innate Spellcasting, Spider Climb, Sunlight Sensitivity, Web Walker"},
|
||||
"invisible_stalker": {ID: "invisible_stalker", Name: "Invisible Stalker", CR: 6, HP: 104, AC: 14, Attack: 10, AttackBonus: 6, Speed: 18, BlockRate: 0.05, XPValue: 2300, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The stalker makes two slam attacks. Traits: Invisibility, Faultless Tracker"},
|
||||
"mage": {ID: "mage", Name: "Mage", CR: 6, HP: 40, AC: 12, Attack: 10, AttackBonus: 5, Speed: 12, BlockRate: 0, XPValue: 2300, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Spellcasting"},
|
||||
"mammoth": {ID: "mammoth", Name: "Mammoth", CR: 6, HP: 126, AC: 13, Attack: 10, AttackBonus: 10, Speed: 16, BlockRate: 0.05, XPValue: 2300, Ability: &MonsterAbility{Name: "Charge", Phase: "opening", ProcChance: 0.4, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Charge (bonus_damage) wired from traits. Traits: Trampling Charge"},
|
||||
"medusa": {ID: "medusa", Name: "Medusa", CR: 6, HP: 127, AC: 15, Attack: 10, AttackBonus: 5, Speed: 12, BlockRate: 0.1, XPValue: 2300, Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.4, Effect: "stun"}, Notes: "SRD-tuned baseline — Frightful Presence (stun) wired from traits. Multiattack: The medusa makes either three melee attacks - one with its snake hair and two with its shortsword - or two ranged attacks with its longbow. Traits: Petrifying Gaze"},
|
||||
"vrock": {ID: "vrock", Name: "Vrock", CR: 6, HP: 104, AC: 15, Attack: 10, AttackBonus: 6, Speed: 16, BlockRate: 0.1, XPValue: 2300, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The vrock makes two attacks: one with its beak and one with its talons. Traits: Magic Resistance"},
|
||||
"wyvern": {ID: "wyvern", Name: "Wyvern", CR: 6, HP: 110, AC: 13, Attack: 10, AttackBonus: 7, Speed: 8, BlockRate: 0.05, XPValue: 2300, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The wyvern makes two attacks: one with its bite and one with its stinger."},
|
||||
"young_brass_dragon": {ID: "young_brass_dragon", Name: "Young Brass Dragon", CR: 6, HP: 110, AC: 17, Attack: 10, AttackBonus: 0, Speed: 16, BlockRate: 0.1, XPValue: 2300, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws.", FireAttacker: true},
|
||||
"young_white_dragon": {ID: "young_white_dragon", Name: "Young White Dragon", CR: 6, HP: 133, AC: 17, Attack: 10, AttackBonus: 0, Speed: 16, BlockRate: 0.1, XPValue: 2300, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws. Traits: Ice Walk"},
|
||||
"giant_ape": {ID: "giant_ape", Name: "Giant Ape", CR: 7, HP: 157, AC: 12, Attack: 12, AttackBonus: 9, Speed: 16, BlockRate: 0, XPValue: 2900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The ape makes two fist attacks."},
|
||||
"oni": {ID: "oni", Name: "Oni", CR: 7, HP: 110, AC: 16, Attack: 12, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 2900, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The oni makes two attacks, either with its claws or its glaive. Traits: Innate Spellcasting, Magic Weapons, Regeneration"},
|
||||
"shield_guardian": {ID: "shield_guardian", Name: "Shield Guardian", CR: 7, HP: 142, AC: 17, Attack: 12, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 2900, Ability: &MonsterAbility{Name: "Regeneration", Phase: "any", ProcChance: 0.5, Effect: "regenerate"}, Notes: "SRD-tuned baseline — Regeneration (regenerate) wired from traits. Multiattack: The guardian makes two fist attacks. Traits: Bound, Regeneration, Spell Storing"},
|
||||
"stone_giant": {ID: "stone_giant", Name: "Stone Giant", CR: 7, HP: 126, AC: 17, Attack: 12, AttackBonus: 9, Speed: 16, BlockRate: 0.1, XPValue: 2900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The giant makes two greatclub attacks. Traits: Stone Camouflage"},
|
||||
"young_black_dragon": {ID: "young_black_dragon", Name: "Young Black Dragon", CR: 7, HP: 127, AC: 18, Attack: 12, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 2900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws. Traits: Amphibious"},
|
||||
"young_copper_dragon": {ID: "young_copper_dragon", Name: "Young Copper Dragon", CR: 7, HP: 119, AC: 17, Attack: 12, AttackBonus: 0, Speed: 16, BlockRate: 0.1, XPValue: 2900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws."},
|
||||
"assassin": {ID: "assassin", Name: "Assassin", CR: 8, HP: 78, AC: 15, Attack: 13, AttackBonus: 6, Speed: 12, BlockRate: 0.1, XPValue: 3900, Ability: &MonsterAbility{Name: "Surprise Attack", Phase: "opening", ProcChance: 0.8, Effect: "bonus_damage"}, Notes: "SRD-tuned baseline — Surprise Attack (bonus_damage) wired from traits. Multiattack: The assassin makes two shortsword attacks. Traits: Assassinate, Evasion, Sneak Attack (1/Turn)"},
|
||||
"cloaker": {ID: "cloaker", Name: "Cloaker", CR: 8, HP: 78, AC: 14, Attack: 13, AttackBonus: 6, Speed: 6, BlockRate: 0.05, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The cloaker makes two attacks: one with its bite and one with its tail. Traits: Damage Transfer, False Appearance, Light Sensitivity"},
|
||||
"frost_giant": {ID: "frost_giant", Name: "Frost Giant", CR: 8, HP: 138, AC: 15, Attack: 13, AttackBonus: 9, Speed: 16, BlockRate: 0.1, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The giant makes two greataxe attacks."},
|
||||
"hezrou": {ID: "hezrou", Name: "Hezrou", CR: 8, HP: 136, AC: 16, Attack: 13, AttackBonus: 7, Speed: 12, BlockRate: 0.1, XPValue: 3900, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The hezrou makes three attacks: one with its bite and two with its claws. Traits: Magic Resistance, Stench"},
|
||||
"hydra": {ID: "hydra", Name: "Hydra", CR: 8, HP: 172, AC: 15, Attack: 13, AttackBonus: 8, Speed: 12, BlockRate: 0.1, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The hydra makes as many bite attacks as it has heads. Traits: Hold Breath, Multiple Heads, Reactive Heads, Wakeful"},
|
||||
"spirit_naga": {ID: "spirit_naga", Name: "Spirit Naga", CR: 8, HP: 75, AC: 15, Attack: 13, AttackBonus: 7, Speed: 16, BlockRate: 0.1, XPValue: 3900, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Rejuvenation, Spellcasting"},
|
||||
"tyrannosaurus_rex": {ID: "tyrannosaurus_rex", Name: "Tyrannosaurus Rex", CR: 8, HP: 136, AC: 13, Attack: 13, AttackBonus: 10, Speed: 18, BlockRate: 0.05, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The tyrannosaurus makes two attacks: one with its bite and one with its tail."},
|
||||
"young_bronze_dragon": {ID: "young_bronze_dragon", Name: "Young Bronze Dragon", CR: 8, HP: 142, AC: 18, Attack: 13, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws. Traits: Amphibious"},
|
||||
"young_green_dragon": {ID: "young_green_dragon", Name: "Young Green Dragon", CR: 8, HP: 136, AC: 18, Attack: 13, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 3900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws. Traits: Amphibious"},
|
||||
"bone_devil": {ID: "bone_devil", Name: "Bone Devil", CR: 9, HP: 142, AC: 19, Attack: 14, AttackBonus: 8, Speed: 16, BlockRate: 0.15, XPValue: 5000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes three attacks: two with its claws and one with its sting. Traits: Devil's Sight, Magic Resistance"},
|
||||
"clay_golem": {ID: "clay_golem", Name: "Clay Golem", CR: 9, HP: 133, AC: 14, Attack: 14, AttackBonus: 8, Speed: 8, BlockRate: 0.05, XPValue: 5000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The golem makes two slam attacks. Traits: Acid Absorption, Berserk, Immutable Form, Magic Resistance, Magic Weapons"},
|
||||
"cloud_giant": {ID: "cloud_giant", Name: "Cloud Giant", CR: 9, HP: 200, AC: 14, Attack: 14, AttackBonus: 12, Speed: 16, BlockRate: 0.05, XPValue: 5000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The giant makes two morningstar attacks. Traits: Keen Smell, Innate Spellcasting"},
|
||||
"fire_giant": {ID: "fire_giant", Name: "Fire Giant", CR: 9, HP: 162, AC: 18, Attack: 14, AttackBonus: 11, Speed: 12, BlockRate: 0.15, XPValue: 5000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The giant makes two greatsword attacks."},
|
||||
"glabrezu": {ID: "glabrezu", Name: "Glabrezu", CR: 9, HP: 157, AC: 17, Attack: 14, AttackBonus: 9, Speed: 16, BlockRate: 0.1, XPValue: 5000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The glabrezu makes four attacks: two with its pincers and two with its fists. Traits: Innate Spellcasting, Magic Resistance"},
|
||||
"treant": {ID: "treant", Name: "Treant", CR: 9, HP: 138, AC: 16, Attack: 14, AttackBonus: 10, Speed: 12, BlockRate: 0.1, XPValue: 5000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The treant makes two slam attacks. Traits: False Appearance, Siege Monster"},
|
||||
"young_blue_dragon": {ID: "young_blue_dragon", Name: "Young Blue Dragon", CR: 9, HP: 152, AC: 18, Attack: 14, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 5000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws."},
|
||||
"young_silver_dragon": {ID: "young_silver_dragon", Name: "Young Silver Dragon", CR: 9, HP: 168, AC: 18, Attack: 14, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 5000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws."},
|
||||
"aboleth": {ID: "aboleth", Name: "Aboleth", CR: 10, HP: 135, AC: 17, Attack: 16, AttackBonus: 9, Speed: 6, BlockRate: 0.1, XPValue: 5900, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Multiattack: The aboleth makes three tentacle attacks. Traits: Amphibious, Mucous Cloud, Probing Telepathy"},
|
||||
"deva": {ID: "deva", Name: "Deva", CR: 10, HP: 136, AC: 17, Attack: 16, AttackBonus: 8, Speed: 12, BlockRate: 0.1, XPValue: 5900, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The deva makes two melee attacks. Traits: Angelic Weapons, Innate Spellcasting, Magic Resistance"},
|
||||
"guardian_naga": {ID: "guardian_naga", Name: "Guardian Naga", CR: 10, HP: 127, AC: 18, Attack: 16, AttackBonus: 8, Speed: 16, BlockRate: 0.15, XPValue: 5900, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Rejuvenation, Spellcasting"},
|
||||
"stone_golem": {ID: "stone_golem", Name: "Stone Golem", CR: 10, HP: 178, AC: 17, Attack: 16, AttackBonus: 10, Speed: 12, BlockRate: 0.1, XPValue: 5900, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The golem makes two slam attacks. Traits: Immutable Form, Magic Resistance, Magic Weapons"},
|
||||
"young_gold_dragon": {ID: "young_gold_dragon", Name: "Young Gold Dragon", CR: 10, HP: 178, AC: 18, Attack: 16, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 5900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws. Traits: Amphibious", FireAttacker: true},
|
||||
"young_red_dragon": {ID: "young_red_dragon", Name: "Young Red Dragon", CR: 10, HP: 178, AC: 18, Attack: 16, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 5900, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon makes three attacks: one with its bite and two with its claws.", FireAttacker: true},
|
||||
"behir": {ID: "behir", Name: "Behir", CR: 11, HP: 168, AC: 17, Attack: 18, AttackBonus: 0, Speed: 18, BlockRate: 0.1, XPValue: 7200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The behir makes two attacks: one with its bite and one to constrict."},
|
||||
"chain_devil": {ID: "chain_devil", Name: "Chain Devil", CR: 11, HP: 85, AC: 16, Attack: 18, AttackBonus: 8, Speed: 12, BlockRate: 0.1, XPValue: 7200, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes two attacks with its chains. Traits: Devil's Sight, Magic Resistance"},
|
||||
"djinni": {ID: "djinni", Name: "Djinni", CR: 11, HP: 161, AC: 17, Attack: 18, AttackBonus: 9, Speed: 12, BlockRate: 0.1, XPValue: 7200, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Multiattack: The djinni makes three scimitar attacks. Traits: Elemental Demise, Innate Spellcasting, Variant: Genie Powers"},
|
||||
"efreeti": {ID: "efreeti", Name: "Efreeti", CR: 11, HP: 200, AC: 17, Attack: 18, AttackBonus: 7, Speed: 16, BlockRate: 0.1, XPValue: 7200, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Multiattack: The efreeti makes two scimitar attacks or uses its Hurl Flame twice. Traits: Elemental Demise, Innate Spellcasting, Variant: Genie Powers", FireAttacker: true},
|
||||
"gynosphinx": {ID: "gynosphinx", Name: "Gynosphinx", CR: 11, HP: 136, AC: 17, Attack: 18, AttackBonus: 9, Speed: 16, BlockRate: 0.1, XPValue: 7200, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The sphinx makes two claw attacks. Traits: Inscrutable, Magic Weapons, Spellcasting"},
|
||||
"horned_devil": {ID: "horned_devil", Name: "Horned Devil", CR: 11, HP: 178, AC: 18, Attack: 18, AttackBonus: 10, Speed: 8, BlockRate: 0.15, XPValue: 7200, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes three melee attacks: two with its fork and one with its tail. Traits: Devil's Sight, Magic Resistance"},
|
||||
"remorhaz": {ID: "remorhaz", Name: "Remorhaz", CR: 11, HP: 195, AC: 17, Attack: 18, AttackBonus: 11, Speed: 12, BlockRate: 0.1, XPValue: 7200, Ability: &MonsterAbility{Name: "Damaging Aura", Phase: "any", ProcChance: 0.4, Effect: "retaliate"}, Notes: "SRD-tuned baseline — Damaging Aura (retaliate) wired from traits. Traits: Heated Body"},
|
||||
"roc": {ID: "roc", Name: "Roc", CR: 11, HP: 248, AC: 15, Attack: 18, AttackBonus: 13, Speed: 8, BlockRate: 0.1, XPValue: 7200, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The roc makes two attacks: one with its beak and one with its talons. Traits: Keen Sight"},
|
||||
"archmage": {ID: "archmage", Name: "Archmage", CR: 12, HP: 99, AC: 12, Attack: 19, AttackBonus: 6, Speed: 12, BlockRate: 0, XPValue: 8400, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Magic Resistance, Spellcasting"},
|
||||
"erinyes": {ID: "erinyes", Name: "Erinyes", CR: 12, HP: 153, AC: 18, Attack: 19, AttackBonus: 8, Speed: 12, BlockRate: 0.15, XPValue: 8400, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The erinyes makes three attacks Traits: Hellish Weapons, Magic Resistance"},
|
||||
"adult_brass_dragon": {ID: "adult_brass_dragon", Name: "Adult Brass Dragon", CR: 13, HP: 172, AC: 18, Attack: 21, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 10000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)", FireAttacker: true},
|
||||
"adult_white_dragon": {ID: "adult_white_dragon", Name: "Adult White Dragon", CR: 13, HP: 200, AC: 18, Attack: 21, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 10000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Ice Walk, Legendary Resistance (3/Day)"},
|
||||
"nalfeshnee": {ID: "nalfeshnee", Name: "Nalfeshnee", CR: 13, HP: 184, AC: 18, Attack: 21, AttackBonus: 10, Speed: 8, BlockRate: 0.15, XPValue: 10000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The nalfeshnee uses Horror Nimbus if it can. Traits: Magic Resistance"},
|
||||
"rakshasa": {ID: "rakshasa", Name: "Rakshasa", CR: 13, HP: 110, AC: 16, Attack: 21, AttackBonus: 7, Speed: 16, BlockRate: 0.1, XPValue: 10000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The rakshasa makes two claw attacks Traits: Limited Magic Immunity, Innate Spellcasting"},
|
||||
"storm_giant": {ID: "storm_giant", Name: "Storm Giant", CR: 13, HP: 230, AC: 16, Attack: 21, AttackBonus: 0, Speed: 18, BlockRate: 0.1, XPValue: 10000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The giant makes two greatsword attacks. Traits: Amphibious, Innate Spellcasting"},
|
||||
"vampire": {ID: "vampire", Name: "Vampire", CR: 13, HP: 144, AC: 16, Attack: 21, AttackBonus: 9, Speed: 12, BlockRate: 0.1, XPValue: 10000, Ability: &MonsterAbility{Name: "Regeneration", Phase: "any", ProcChance: 0.5, Effect: "regenerate"}, Notes: "SRD-tuned baseline — Regeneration (regenerate) wired from traits. Traits: Shapechanger, Legendary Resistance (3/Day), Misty Escape, Regeneration, Spider Climb, Vampire Weaknesses"},
|
||||
"adult_black_dragon": {ID: "adult_black_dragon", Name: "Adult Black Dragon", CR: 14, HP: 195, AC: 19, Attack: 23, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 11500, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
|
||||
"adult_copper_dragon": {ID: "adult_copper_dragon", Name: "Adult Copper Dragon", CR: 14, HP: 184, AC: 18, Attack: 23, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 11500, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
|
||||
"ice_devil": {ID: "ice_devil", Name: "Ice Devil", CR: 14, HP: 180, AC: 18, Attack: 23, AttackBonus: 10, Speed: 16, BlockRate: 0.15, XPValue: 11500, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The devil makes three attacks: one with its bite, one with its claws, and one with its tail. Traits: Devil's Sight, Magic Resistance"},
|
||||
"adult_bronze_dragon": {ID: "adult_bronze_dragon", Name: "Adult Bronze Dragon", CR: 15, HP: 212, AC: 19, Attack: 24, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 13000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
|
||||
"adult_green_dragon": {ID: "adult_green_dragon", Name: "Adult Green Dragon", CR: 15, HP: 207, AC: 19, Attack: 24, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 13000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
|
||||
"mummy_lord": {ID: "mummy_lord", Name: "Mummy Lord", CR: 15, HP: 97, AC: 17, Attack: 24, AttackBonus: 9, Speed: 8, BlockRate: 0.1, XPValue: 13000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The mummy can use its Dreadful Glare and makes one attack with its rotting fist. Traits: Magic Resistance, Rejuvenation, Spellcasting"},
|
||||
"purple_worm": {ID: "purple_worm", Name: "Purple Worm", CR: 15, HP: 247, AC: 18, Attack: 24, AttackBonus: 9, Speed: 18, BlockRate: 0.15, XPValue: 13000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The worm makes two attacks: one with its bite and one with its stinger. Traits: Tunneler"},
|
||||
"adult_blue_dragon": {ID: "adult_blue_dragon", Name: "Adult Blue Dragon", CR: 16, HP: 225, AC: 19, Attack: 26, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 15000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
|
||||
"adult_silver_dragon": {ID: "adult_silver_dragon", Name: "Adult Silver Dragon", CR: 16, HP: 243, AC: 19, Attack: 26, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 15000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
|
||||
"iron_golem": {ID: "iron_golem", Name: "Iron Golem", CR: 16, HP: 210, AC: 20, Attack: 26, AttackBonus: 0, Speed: 12, BlockRate: 0.15, XPValue: 15000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The golem makes two melee attacks. Traits: Fire Absorption, Immutable Form, Magic Resistance, Magic Weapons"},
|
||||
"marilith": {ID: "marilith", Name: "Marilith", CR: 16, HP: 189, AC: 18, Attack: 26, AttackBonus: 9, Speed: 16, BlockRate: 0.15, XPValue: 15000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The marilith can make seven attacks: six with its longswords and one with its tail. Traits: Magic Resistance, Magic Weapons, Reactive"},
|
||||
"planetar": {ID: "planetar", Name: "Planetar", CR: 16, HP: 200, AC: 19, Attack: 26, AttackBonus: 12, Speed: 16, BlockRate: 0.15, XPValue: 15000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The planetar makes two melee attacks. Traits: Angelic Weapons, Divine Awareness, Innate Spellcasting, Magic Resistance"},
|
||||
"adult_gold_dragon": {ID: "adult_gold_dragon", Name: "Adult Gold Dragon", CR: 17, HP: 256, AC: 19, Attack: 28, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 18000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)", FireAttacker: true},
|
||||
"adult_red_dragon": {ID: "adult_red_dragon", Name: "Adult Red Dragon", CR: 17, HP: 256, AC: 19, Attack: 28, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 18000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)", FireAttacker: true},
|
||||
"androsphinx": {ID: "androsphinx", Name: "Androsphinx", CR: 17, HP: 199, AC: 17, Attack: 28, AttackBonus: 12, Speed: 16, BlockRate: 0.1, XPValue: 18000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The sphinx makes two claw attacks. Traits: Inscrutable, Magic Weapons, Spellcasting"},
|
||||
"dragon_turtle": {ID: "dragon_turtle", Name: "Dragon Turtle", CR: 17, HP: 341, AC: 20, Attack: 28, AttackBonus: 0, Speed: 8, BlockRate: 0.15, XPValue: 18000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The dragon turtle makes three attacks: one with its bite and two with its claws. Traits: Amphibious", FireAttacker: true},
|
||||
"balor": {ID: "balor", Name: "Balor", CR: 19, HP: 262, AC: 19, Attack: 31, AttackBonus: 14, Speed: 16, BlockRate: 0.15, XPValue: 22000, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1, Effect: "death_aoe"}, Notes: "SRD-tuned baseline — Death Burst (death_aoe) wired from traits. Multiattack: The balor makes two attacks: one with its longsword and one with its whip. Traits: Death Throes, Fire Aura, Magic Resistance, Magic Weapons"},
|
||||
"ancient_brass_dragon": {ID: "ancient_brass_dragon", Name: "Ancient Brass Dragon", CR: 20, HP: 297, AC: 20, Attack: 33, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 25000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)", FireAttacker: true},
|
||||
"ancient_white_dragon": {ID: "ancient_white_dragon", Name: "Ancient White Dragon", CR: 20, HP: 333, AC: 20, Attack: 33, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 25000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Ice Walk, Legendary Resistance (3/Day)"},
|
||||
"pit_fiend": {ID: "pit_fiend", Name: "Pit Fiend", CR: 20, HP: 300, AC: 19, Attack: 33, AttackBonus: 14, Speed: 12, BlockRate: 0.15, XPValue: 25000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The pit fiend makes four attacks: one with its bite, one with its claw, one with its mace, and one with its tail. Traits: Fear Aura, Magic Resistance, Magic Weapons, Innate Spellcasting"},
|
||||
"ancient_black_dragon": {ID: "ancient_black_dragon", Name: "Ancient Black Dragon", CR: 21, HP: 367, AC: 22, Attack: 34, AttackBonus: 15, Speed: 16, BlockRate: 0.15, XPValue: 33000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
|
||||
"ancient_copper_dragon": {ID: "ancient_copper_dragon", Name: "Ancient Copper Dragon", CR: 21, HP: 350, AC: 21, Attack: 34, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 33000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
|
||||
"lich": {ID: "lich", Name: "Lich", CR: 21, HP: 135, AC: 17, Attack: 34, AttackBonus: 12, Speed: 12, BlockRate: 0.1, XPValue: 33000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Traits: Legendary Resistance (3/Day), Rejuvenation, Spellcasting, Turn Resistance"},
|
||||
"solar": {ID: "solar", Name: "Solar", CR: 21, HP: 243, AC: 21, Attack: 34, AttackBonus: 15, Speed: 18, BlockRate: 0.15, XPValue: 33000, Ability: &MonsterAbility{Name: "Spellcasting", Phase: "opening", ProcChance: 0.5, Effect: "aoe"}, Notes: "SRD-tuned baseline — Spellcasting (aoe) wired from traits. Multiattack: The solar makes two greatsword attacks. Traits: Angelic Weapons, Divine Awareness, Innate Spellcasting, Magic Resistance"},
|
||||
"ancient_bronze_dragon": {ID: "ancient_bronze_dragon", Name: "Ancient Bronze Dragon", CR: 22, HP: 444, AC: 22, Attack: 36, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 41000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
|
||||
"ancient_green_dragon": {ID: "ancient_green_dragon", Name: "Ancient Green Dragon", CR: 22, HP: 385, AC: 21, Attack: 36, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 41000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)"},
|
||||
"ancient_blue_dragon": {ID: "ancient_blue_dragon", Name: "Ancient Blue Dragon", CR: 23, HP: 481, AC: 22, Attack: 37, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 50000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
|
||||
"ancient_silver_dragon": {ID: "ancient_silver_dragon", Name: "Ancient Silver Dragon", CR: 23, HP: 487, AC: 22, Attack: 37, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 50000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)"},
|
||||
"kraken": {ID: "kraken", Name: "Kraken", CR: 23, HP: 472, AC: 18, Attack: 37, AttackBonus: 7, Speed: 8, BlockRate: 0.15, XPValue: 50000, Notes: "SRD-tuned baseline — no ability wired (traits are non-combat). Multiattack: The kraken makes three tentacle attacks, each of which it can replace with one use of Fling. Traits: Amphibious, Freedom of Movement, Siege Monster"},
|
||||
"ancient_gold_dragon": {ID: "ancient_gold_dragon", Name: "Ancient Gold Dragon", CR: 24, HP: 546, AC: 22, Attack: 38, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 62000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Amphibious, Legendary Resistance (3/Day)", FireAttacker: true},
|
||||
"ancient_red_dragon": {ID: "ancient_red_dragon", Name: "Ancient Red Dragon", CR: 24, HP: 546, AC: 22, Attack: 38, AttackBonus: 0, Speed: 16, BlockRate: 0.15, XPValue: 62000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The dragon can use its Frightful Presence. Traits: Legendary Resistance (3/Day)", FireAttacker: true},
|
||||
"tarrasque": {ID: "tarrasque", Name: "Tarrasque", CR: 30, HP: 676, AC: 25, Attack: 46, AttackBonus: 19, Speed: 16, BlockRate: 0.15, XPValue: 155000, Ability: &MonsterAbility{Name: "Magic Resistance", Phase: "any", ProcChance: 0.5, Effect: "spell_resist"}, Notes: "SRD-tuned baseline — Magic Resistance (spell_resist) wired from traits. Multiattack: The tarrasque can use its Frightful Presence. Traits: Legendary Resistance (3/Day), Magic Resistance, Reflective Carapace, Siege Monster"},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestTunedBestiarySRD_Populated guards the generated tuned table: every SRD
|
||||
// monster should derive a structurally sane DnDMonsterTemplate. Regenerate with
|
||||
// `go run ./cmd/open5e-import gen tuned` if the SRD set or formula changes.
|
||||
func TestTunedBestiarySRD_Populated(t *testing.T) {
|
||||
if len(tunedBestiarySRD) < 300 {
|
||||
t.Fatalf("tunedBestiarySRD has %d entries, want the full SRD set (~322)", len(tunedBestiarySRD))
|
||||
}
|
||||
for id, m := range tunedBestiarySRD {
|
||||
if m.ID != id {
|
||||
t.Errorf("%s: ID field = %q, want map key", id, m.ID)
|
||||
}
|
||||
if m.Name == "" {
|
||||
t.Errorf("%s: empty Name", id)
|
||||
}
|
||||
if m.HP <= 0 {
|
||||
t.Errorf("%s: HP=%d, want positive", id, m.HP)
|
||||
}
|
||||
if m.AC < 10 {
|
||||
t.Errorf("%s: AC=%d, want >= 10 (engine minimum)", id, m.AC)
|
||||
}
|
||||
if m.Attack < 1 {
|
||||
t.Errorf("%s: Attack=%d, want >= 1", id, m.Attack)
|
||||
}
|
||||
if m.Speed < 6 || m.Speed > 18 {
|
||||
t.Errorf("%s: Speed=%d, want within [6,18]", id, m.Speed)
|
||||
}
|
||||
// Ability is optional (nil when the creature's traits are all
|
||||
// non-combat), but a wired one must be structurally sane.
|
||||
if m.Ability != nil {
|
||||
a := m.Ability
|
||||
if a.Name == "" {
|
||||
t.Errorf("%s: Ability has empty Name", id)
|
||||
}
|
||||
if !tunedAbilityPhases[a.Phase] {
|
||||
t.Errorf("%s: Ability.Phase=%q, want a known combat phase", id, a.Phase)
|
||||
}
|
||||
if a.ProcChance <= 0 || a.ProcChance > 1 {
|
||||
t.Errorf("%s: Ability.ProcChance=%v, want within (0,1]", id, a.ProcChance)
|
||||
}
|
||||
if !tunedAbilityEffects[a.Effect] {
|
||||
t.Errorf("%s: Ability.Effect=%q, want an engine-recognised effect", id, a.Effect)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tunedAbilityPhases / tunedAbilityEffects are the values the trait classifier
|
||||
// (abilityFromTraits in cmd/open5e-import/tuned.go) is allowed to emit. The
|
||||
// effect set is the subset of applyAbility's vocabulary the classifier uses;
|
||||
// if a new trait rule introduces another effect, add it here too.
|
||||
var tunedAbilityPhases = map[string]bool{
|
||||
"opening": true, "clash": true, "decisive": true, "any": true,
|
||||
}
|
||||
|
||||
var tunedAbilityEffects = map[string]bool{
|
||||
"death_aoe": true, "aoe": true, "survive_at_1": true, "regenerate": true,
|
||||
"spell_resist": true, "bonus_damage": true, "stun": true, "enrage": true,
|
||||
"retaliate": true, "advantage": true, "evade": true,
|
||||
}
|
||||
|
||||
// TestTunedBestiarySRD_AbilityWiring spot-checks the trait→ability classifier:
|
||||
// a creature with a combat trait gets the expected effect, and one whose traits
|
||||
// are all non-combat stays nil.
|
||||
func TestTunedBestiarySRD_AbilityWiring(t *testing.T) {
|
||||
cases := []struct {
|
||||
id string
|
||||
effect string // "" means Ability should be nil
|
||||
}{
|
||||
{"troll", "regenerate"}, // Regeneration
|
||||
{"goblin", "evade"}, // Nimble Escape
|
||||
{"balor", "death_aoe"}, // Death Throes (priority over Magic Resistance)
|
||||
{"lich", "aoe"}, // Spellcasting
|
||||
{"awakened_shrub", ""}, // False Appearance — no rule, stays nil
|
||||
{"badger", ""}, // Keen Smell — non-combat
|
||||
}
|
||||
for _, tc := range cases {
|
||||
m, ok := tunedBestiarySRD[tc.id]
|
||||
if !ok {
|
||||
t.Errorf("%s missing from tuned bestiary", tc.id)
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case tc.effect == "" && m.Ability != nil:
|
||||
t.Errorf("%s: Ability=%+v, want nil", tc.id, m.Ability)
|
||||
case tc.effect != "" && m.Ability == nil:
|
||||
t.Errorf("%s: Ability is nil, want effect %q", tc.id, tc.effect)
|
||||
case tc.effect != "" && m.Ability.Effect != tc.effect:
|
||||
t.Errorf("%s: Ability.Effect=%q, want %q", tc.id, m.Ability.Effect, tc.effect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTunedBestiarySRD_MergedIntoRoster confirms the init-time merge ran and
|
||||
// that hand-authored dndBestiary entries win over the generated baseline.
|
||||
func TestTunedBestiarySRD_MergedIntoRoster(t *testing.T) {
|
||||
// A tuned-only creature (no hand-authored entry) should be in the roster.
|
||||
if _, ok := dndBestiary["tarrasque"]; !ok {
|
||||
t.Error("tarrasque missing from dndBestiary — tuned merge did not run")
|
||||
}
|
||||
// Owlbear is hand-authored; the roster entry must keep the playtested
|
||||
// values (and its wired Ability), not the generated baseline.
|
||||
ob, ok := dndBestiary["owlbear"]
|
||||
if !ok {
|
||||
t.Fatal("owlbear missing from dndBestiary")
|
||||
}
|
||||
if ob.Ability == nil {
|
||||
t.Error("owlbear lost its hand-authored Ability — tuned baseline clobbered the roster entry")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttackByCR_KnownAnchors spot-checks the Attack curve against playtested
|
||||
// roster values so a formula regression surfaces as a mismatch.
|
||||
func TestTunedBestiarySRD_KnownEntry(t *testing.T) {
|
||||
ab, ok := tunedBestiarySRD["aboleth"]
|
||||
if !ok {
|
||||
t.Fatal("aboleth missing from tuned bestiary")
|
||||
}
|
||||
// HP/AC verbatim SRD; Attack from the CR-10 anchor; AttackBonus from the
|
||||
// hardest-hitting attack (Tentacle, +9).
|
||||
if ab.HP != 135 || ab.AC != 17 || ab.Attack != 16 || ab.AttackBonus != 9 {
|
||||
t.Errorf("aboleth = HP %d / AC %d / Attack %d / AB %d, want 135 / 17 / 16 / 9",
|
||||
ab.HP, ab.AC, ab.Attack, ab.AttackBonus)
|
||||
}
|
||||
if ab.XPValue != 5900 {
|
||||
t.Errorf("aboleth XPValue = %d, want 5900", ab.XPValue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// bootstrapPhase5BHPRefresh recomputes hp_max for every dnd_character
|
||||
// row after the Phase 5-B HP multiplier (phase5BHPMult in dnd.go)
|
||||
// shipped. Without this, existing characters keep their pre-Phase-5-B
|
||||
// hp_max until something else recomputes it (level-up, character reset,
|
||||
// migration), so the durability lift wouldn't reach live players for
|
||||
// weeks. See gogobee_expedition_difficulty.md Phase 5-B.
|
||||
//
|
||||
// Pattern mirrors bumpDexFloorForExistingCharacters: enumerate, derive
|
||||
// new value, update only rows where the new value differs. hp_current
|
||||
// is bumped by the same delta so a full-HP character stays at full HP
|
||||
// after the bump; a wounded character keeps the same absolute wound
|
||||
// (e.g. -7 HP) so the lift only raises the *ceiling*, not the present
|
||||
// HP — players don't get a free heal on top of a permanent buff.
|
||||
//
|
||||
// Idempotent via the db.JobCompleted gate. The job key is bumped if we
|
||||
// ever ship a second HP-curve change so the migration runs once per
|
||||
// shipped change.
|
||||
func bootstrapPhase5BHPRefresh() {
|
||||
const jobName = "phase5b_hp_refresh_v1"
|
||||
if db.JobCompleted(jobName, "once") {
|
||||
return
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`
|
||||
SELECT user_id, class, con_score, dnd_level, hp_max, hp_current
|
||||
FROM dnd_character WHERE dnd_level > 0`)
|
||||
if err != nil {
|
||||
slog.Error("phase5b hp refresh: enumerate failed", "err", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type row struct {
|
||||
userID string
|
||||
class DnDClass
|
||||
conScore int
|
||||
level int
|
||||
hpMax int
|
||||
hpCurrent int
|
||||
}
|
||||
var batch []row
|
||||
for rows.Next() {
|
||||
var r row
|
||||
var classStr string
|
||||
if err := rows.Scan(&r.userID, &classStr, &r.conScore, &r.level, &r.hpMax, &r.hpCurrent); err != nil {
|
||||
slog.Warn("phase5b hp refresh: scan failed", "err", err)
|
||||
continue
|
||||
}
|
||||
r.class = DnDClass(classStr)
|
||||
batch = append(batch, r)
|
||||
}
|
||||
|
||||
refreshed := 0
|
||||
for _, r := range batch {
|
||||
conMod := abilityModifier(r.conScore)
|
||||
newMax := computeMaxHP(r.class, conMod, r.level)
|
||||
if newMax <= r.hpMax {
|
||||
// Either already at/above target (re-runs, or the row was
|
||||
// hand-edited above formula), or the formula change reduced
|
||||
// HP for this class — leave it alone in that case so we
|
||||
// never *lower* a player's HP via bootstrap.
|
||||
continue
|
||||
}
|
||||
delta := newMax - r.hpMax
|
||||
newCurrent := r.hpCurrent + delta
|
||||
if newCurrent > newMax {
|
||||
newCurrent = newMax
|
||||
}
|
||||
if newCurrent < 1 {
|
||||
newCurrent = 1
|
||||
}
|
||||
if _, err := d.Exec(`UPDATE dnd_character
|
||||
SET hp_max = ?, hp_current = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = ?`,
|
||||
newMax, newCurrent, r.userID); err != nil {
|
||||
slog.Warn("phase5b hp refresh: update failed", "user", r.userID, "err", err)
|
||||
continue
|
||||
}
|
||||
refreshed++
|
||||
}
|
||||
|
||||
db.MarkJobCompleted(jobName, "once")
|
||||
if refreshed > 0 {
|
||||
slog.Info("phase5b hp refresh: refreshed character HPMax to Phase 5-B floor", "count", refreshed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// bootstrapRestoreExpeditionStreakDecay reverses the one-time streak halving
|
||||
// applied by midnightReset to players who were on an active expedition. The
|
||||
// idle reaper used to ignore expedition activity (it only inspected the
|
||||
// legacy CombatActionsUsed/HarvestActionsUsed counters), so expeditioners
|
||||
// were shamed and had their streak divided by 2.
|
||||
//
|
||||
// Restoration: CurrentStreak doubles (recovering the integer-divide), capped
|
||||
// at BestStreak. The odd-half is recovered by adding 1 when BestStreak
|
||||
// allows — pre-decay value was either CurrentStreak*2 or CurrentStreak*2+1,
|
||||
// and BestStreak is a monotonic ceiling that bounds the original.
|
||||
//
|
||||
// Idempotent: gated on a fixed job key so it runs at most once per DB.
|
||||
func bootstrapRestoreExpeditionStreakDecay() {
|
||||
const jobName = "idle_reaper_expedition_streak_restore_v1"
|
||||
if db.JobCompleted(jobName, "once") {
|
||||
return
|
||||
}
|
||||
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("bootstrap: streak restore — failed to load characters", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
restored := 0
|
||||
for _, char := range chars {
|
||||
if !char.StreakDecayed || char.CurrentStreak == 0 {
|
||||
continue
|
||||
}
|
||||
exp, err := getActiveExpedition(char.UserID)
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap: streak restore — expedition lookup failed", "user", char.UserID, "err", err)
|
||||
continue
|
||||
}
|
||||
if exp == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
old := char.CurrentStreak
|
||||
candidate := old*2 + 1
|
||||
if char.BestStreak > 0 && candidate > char.BestStreak {
|
||||
candidate = char.BestStreak
|
||||
}
|
||||
if candidate < old*2 {
|
||||
candidate = old * 2
|
||||
}
|
||||
char.CurrentStreak = candidate
|
||||
char.StreakDecayed = false
|
||||
if err := saveAdvCharacter(&char); err != nil {
|
||||
slog.Error("bootstrap: streak restore — save failed", "user", char.UserID, "err", err)
|
||||
continue
|
||||
}
|
||||
slog.Info("bootstrap: streak restored", "user", char.UserID, "old", old, "new", candidate, "best", char.BestStreak)
|
||||
restored++
|
||||
}
|
||||
|
||||
db.MarkJobCompleted(jobName, "once")
|
||||
if restored > 0 {
|
||||
slog.Warn("bootstrap: expedition streak decay restored", "count", restored)
|
||||
}
|
||||
}
|
||||
@@ -55,11 +55,12 @@ func (p *AdventurePlugin) runDungeonCombat(
|
||||
|
||||
playerStats, playerMods := DerivePlayerStats(char, equip, bonuses, chatLvl, char.CurrentStreak, hasGrudge)
|
||||
|
||||
// Penalty zone: player is weakened (mirrors +5% death, -15% success from old system)
|
||||
// Penalty zone Attack/Defense reduction (mirrors +5% death, -15% success
|
||||
// from old system). The MaxHP penalty is applied below, after
|
||||
// applyDnDPlayerLayer reseats MaxHP onto the dnd HP scale.
|
||||
if inPenaltyZone {
|
||||
playerStats.Attack = int(float64(playerStats.Attack) * 0.85)
|
||||
playerStats.Defense = int(float64(playerStats.Defense) * 0.85)
|
||||
playerStats.MaxHP = int(float64(playerStats.MaxHP) * 0.90)
|
||||
}
|
||||
|
||||
// Load consumables from inventory and auto-select
|
||||
@@ -75,17 +76,25 @@ func (p *AdventurePlugin) runDungeonCombat(
|
||||
applyDnDPlayerLayer(&playerStats, dndChar)
|
||||
applyDnDEquipmentLayer(&playerStats, dndChar, equip)
|
||||
applyDnDHPScaling(&playerStats, dndChar)
|
||||
if inPenaltyZone {
|
||||
playerStats.MaxHP = int(float64(playerStats.MaxHP) * 0.90)
|
||||
if playerStats.StartHP > 0 {
|
||||
playerStats.StartHP = int(float64(playerStats.StartHP) * 0.90)
|
||||
}
|
||||
}
|
||||
applyClassPassives(&playerStats, &playerMods, dndChar)
|
||||
applyRacePassives(&playerStats, &playerMods, dndChar)
|
||||
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
||||
trySimAutoArm(dndChar)
|
||||
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
||||
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
|
||||
}
|
||||
applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier)
|
||||
applyPendingCast(userID, dndChar, &playerStats, &playerMods, &enemyStats)
|
||||
|
||||
selected := SelectConsumables(consumables, playerStats, enemyStats, 0, loc.Tier)
|
||||
ApplyConsumableMods(&playerStats, &playerMods, selected)
|
||||
// Auto-consumable: panic heal only (see dnd_boss_consumables.go).
|
||||
setupAutoHealFromInventory(consumables, &playerMods)
|
||||
|
||||
// Dungeon monsters T3+ can have abilities
|
||||
var ability *MonsterAbility
|
||||
@@ -113,18 +122,16 @@ func (p *AdventurePlugin) runDungeonCombat(
|
||||
}
|
||||
|
||||
result := SimulateCombat(player, enemy, dungeonCombatPhases)
|
||||
result = injectConsumableEvents(result, selected, len(consumables))
|
||||
dumpCombatEventsIfDebug(fmt.Sprintf("dungeon:%s vs %s", loc.Name, displayName), result)
|
||||
|
||||
// Consume used items from inventory
|
||||
for _, c := range selected {
|
||||
if err := removeAdvInventoryItem(c.InventoryID); err != nil {
|
||||
slog.Error("combat: failed to consume item", "id", c.InventoryID, "name", c.Def.Name, "err", err)
|
||||
}
|
||||
}
|
||||
// Remove the actual heal items consumed during combat. No more
|
||||
// burning Coal Bombs / Ink Vials on chump fights — those stay put
|
||||
// until a player-driven use command lands.
|
||||
consumeFiredHealingItems(userID, countHealEventsFired(result))
|
||||
|
||||
p.grantCombatAchievements(userID, result)
|
||||
|
||||
persistDnDHPAfterCombat(userID, result.PlayerStartHP, result.PlayerEndHP)
|
||||
persistDnDHPAfterCombat(userID, result.PlayerEndHP)
|
||||
if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil {
|
||||
slog.Error("dnd: post-combat subclass persist (dungeon)", "user", userID, "err", err)
|
||||
}
|
||||
@@ -238,6 +245,13 @@ func (p *AdventurePlugin) sendCombatMessages(userID id.UserID, phaseMessages []s
|
||||
|
||||
// sendZoneCombatMessages is the zone/expedition variant — same staging as
|
||||
// arena, but tighter pacing (2–3s) so dungeon advances feel snappier.
|
||||
//
|
||||
// Contract: returns a done channel that closes after the final DM has
|
||||
// been sent. Callers that need to chain post-flush work (death follow-ups,
|
||||
// hospital ads, tier countdowns) MUST consume it. Callers in the middle
|
||||
// of a command-handler flow that have no post-flush work should explicitly
|
||||
// discard with `_ = ...` — blocking on flush would stall the handler
|
||||
// behind the 2–3s/message pacing, which is the whole point of streaming.
|
||||
func (p *AdventurePlugin) sendZoneCombatMessages(userID id.UserID, phaseMessages []string, finalMessage string) <-chan struct{} {
|
||||
return p.sendCombatMessagesWithDelay(userID, phaseMessages, finalMessage, 2, 2) // 2–3s
|
||||
}
|
||||
@@ -393,12 +407,25 @@ func checkDeathSaveEvent(events []CombatEvent) bool {
|
||||
|
||||
// injectConsumableEvents prepends use_consumable events so the narrative shows which items were consumed.
|
||||
// If items were available but skipped (trivial threat), a skip event is injected instead.
|
||||
//
|
||||
// Uses PlayerEntryHP / EnemyEntryHP — the actual HP each side entered combat
|
||||
// with — so the pre-combat HP line reflects wounded carry-over honestly. Was
|
||||
// using PlayerStartHP (= MaxHP), which made wounded entries display "47/47"
|
||||
// and made any HP loss in opening look like the player took silent damage.
|
||||
func injectConsumableEvents(result CombatResult, selected []ConsumableItem, inventorySize int) CombatResult {
|
||||
entryHP := result.PlayerEntryHP
|
||||
if entryHP == 0 {
|
||||
entryHP = result.PlayerStartHP
|
||||
}
|
||||
enemyEntryHP := result.EnemyEntryHP
|
||||
if enemyEntryHP == 0 {
|
||||
enemyEntryHP = result.EnemyStartHP
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
if inventorySize > 0 {
|
||||
result.Events = append([]CombatEvent{{
|
||||
Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "consumable_skip",
|
||||
PlayerHP: result.PlayerStartHP, EnemyHP: result.EnemyStartHP,
|
||||
PlayerHP: entryHP, EnemyHP: enemyEntryHP,
|
||||
}}, result.Events...)
|
||||
}
|
||||
return result
|
||||
@@ -407,7 +434,7 @@ func injectConsumableEvents(result CombatResult, selected []ConsumableItem, inve
|
||||
for _, c := range selected {
|
||||
events = append(events, CombatEvent{
|
||||
Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "use_consumable",
|
||||
PlayerHP: result.PlayerStartHP, EnemyHP: result.EnemyStartHP,
|
||||
PlayerHP: entryHP, EnemyHP: enemyEntryHP,
|
||||
Desc: c.Def.Name,
|
||||
})
|
||||
}
|
||||
@@ -451,6 +478,8 @@ func combatDegradation(result CombatResult, equip map[EquipmentSlot]*AdvEquipmen
|
||||
damage[SlotHelmet] += 1 + rand.IntN(2)
|
||||
case ev.Actor == "enemy" && ev.Action == "cleave":
|
||||
damage[SlotArmor] += 2 + rand.IntN(3)
|
||||
case ev.Actor == "enemy" && (ev.Action == "bonus_damage" || ev.Action == "aoe" || ev.Action == "execute" || ev.Action == "retaliate" || ev.Action == "max_hp_drain"):
|
||||
damage[SlotArmor] += 1 + rand.IntN(2)
|
||||
case ev.Actor == "enemy" && ev.Action == "lifesteal":
|
||||
damage[SlotArmor] += 1 + rand.IntN(2)
|
||||
case ev.Actor == "player" && (ev.Action == "hit" || ev.Action == "crit"):
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase 13 — turn-based combat command surface.
|
||||
//
|
||||
// !fight — engage the Elite/Boss room the player is standing at, opening a
|
||||
// persisted CombatSession.
|
||||
// !attack — resolve one full round (player turn → enemy turn → round end).
|
||||
// !flee — break off; the run ends with a light penalty.
|
||||
//
|
||||
// !zone advance stops at an Elite/Boss doorway (see zoneCmdAdvance); the
|
||||
// player explicitly opts into the fight here. While a session is active,
|
||||
// !zone advance / enter / go are blocked — one fight locks the run.
|
||||
|
||||
// encounterIDForRoom is the stable per-room key tying a CombatSession to the
|
||||
// room it was opened in, so a won session can be recognised by the room
|
||||
// resolver. Unique within a run; combined with run_id it's globally unique.
|
||||
func encounterIDForRoom(roomIdx int) string {
|
||||
return fmt.Sprintf("room%d", roomIdx)
|
||||
}
|
||||
|
||||
// ── !fight ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
run, err := getActiveZoneRun(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
|
||||
}
|
||||
if run == nil {
|
||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>` first.")
|
||||
}
|
||||
roomType := run.CurrentRoomType()
|
||||
if roomType != RoomElite && roomType != RoomBoss {
|
||||
return p.SendDM(ctx.Sender, "Nothing to fight here — `!fight` is for Elite and Boss rooms. Use `!zone advance`.")
|
||||
}
|
||||
|
||||
encID := encounterIDForRoom(run.CurrentRoom)
|
||||
if existing, _ := getCombatSessionForEncounter(run.RunID, encID); existing != nil {
|
||||
switch existing.Status {
|
||||
case CombatStatusActive:
|
||||
return p.SendDM(ctx.Sender, "You're already in this fight — `!attack` or `!flee`.")
|
||||
case CombatStatusWon:
|
||||
return p.SendDM(ctx.Sender, "You've already cleared this room. `!zone advance` to move on.")
|
||||
default:
|
||||
return p.SendDM(ctx.Sender, "This fight is already over. `!zone status` for where you stand.")
|
||||
}
|
||||
}
|
||||
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
isBoss := roomType == RoomBoss
|
||||
var monster DnDMonsterTemplate
|
||||
var ok bool
|
||||
if isBoss {
|
||||
monster, ok = dndBestiary[zone.Boss.BestiaryID]
|
||||
} else {
|
||||
monster, ok = pickZoneEnemy(zone, run.RunID, run.CurrentRoom, true)
|
||||
}
|
||||
if !ok {
|
||||
return p.SendDM(ctx.Sender, "_(No bestiary entry for this encounter — file a bug. `!zone abandon` to bail.)_")
|
||||
}
|
||||
|
||||
player, enemy, _, err := p.buildZoneCombatants(ctx.Sender, monster, int(zone.Tier), run.DMMood)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't set up the fight: "+err.Error())
|
||||
}
|
||||
|
||||
playerHP, playerMax := dndHPSnapshot(ctx.Sender)
|
||||
if playerHP <= 0 {
|
||||
return p.SendDM(ctx.Sender, "You're in no shape to fight. `!rest` first.")
|
||||
}
|
||||
enemyHP := enemy.Stats.MaxHP
|
||||
|
||||
sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP)
|
||||
if err != nil {
|
||||
if err == ErrCombatSessionAlreadyActive {
|
||||
return p.SendDM(ctx.Sender, "You're already in a fight. Finish it with `!attack` / `!flee`.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Couldn't start the fight: "+err.Error())
|
||||
}
|
||||
|
||||
// Carry fight-start one-shot resources (Abjuration Arcane Ward, etc.) onto
|
||||
// the session so they survive the turn engine's resume/commit cycle, and
|
||||
// make the one-and-only per-fight pet-attack roll.
|
||||
seeded := seedCombatSessionOneShots(sess, player.Mods)
|
||||
pet := rollCombatSessionPetProc(sess, player.Mods)
|
||||
if seeded || pet {
|
||||
if err := saveCombatSession(sess); err != nil {
|
||||
slog.Error("combat: seed session one-shots", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
if isBoss {
|
||||
if line := composeBossEntry(zone.ID, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line + "\n\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("👑 **Boss — %s** (HP %d, AC %d)\n", monster.Name, enemyHP, enemy.Stats.AC))
|
||||
} else {
|
||||
if line := eliteRoomEntryLine(zone.ID, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line + "\n\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("⚔️ **Elite — %s** (HP %d, AC %d)\n", monster.Name, enemyHP, enemy.Stats.AC))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", playerHP, playerMax))
|
||||
if curios := activeMagicItemsLine(ctx.Sender); curios != "" {
|
||||
b.WriteString(curios)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(combatTurnPrompt(sess))
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
|
||||
// ── !attack / !flee ─────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleAttackCmd(ctx MessageContext) error {
|
||||
return p.handleCombatActionCmd(ctx, PlayerAction{Kind: ActionAttack})
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleFleeCmd(ctx MessageContext) error {
|
||||
return p.handleCombatActionCmd(ctx, PlayerAction{Kind: ActionFlee})
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleCombatActionCmd(ctx MessageContext, action PlayerAction) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
sess, err := getActiveCombatSession(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error())
|
||||
}
|
||||
if sess == nil {
|
||||
return p.SendDM(ctx.Sender, "You're not in a fight. `!fight` at an Elite or Boss room to start one.")
|
||||
}
|
||||
|
||||
player, enemy, err := p.combatantsForSession(sess)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
|
||||
}
|
||||
|
||||
events, err := runCombatRound(sess, &player, &enemy, action)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
|
||||
}
|
||||
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
|
||||
}
|
||||
|
||||
// renderRoundResult turns a resolved round into the player-facing block: the
|
||||
// play-by-play, then either the HP/turn-prompt footer (fight continues) or the
|
||||
// close-out block (terminal status). Shared by !attack/!flee, !cast, !consume.
|
||||
func (p *AdventurePlugin) renderRoundResult(userID id.UserID, sess *CombatSession, events []CombatEvent, playerName string, enemy Combatant) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(RenderTurnRound(events, playerName, enemy.Name))
|
||||
if sess.IsActive() {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(fmt.Sprintf("You: **%d/%d** · %s: **%d/%d**\n",
|
||||
sess.PlayerHP, sess.PlayerHPMax, enemy.Name, sess.EnemyHP, sess.EnemyHPMax))
|
||||
b.WriteString(combatTurnPrompt(sess))
|
||||
return b.String()
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(p.finishCombatSession(userID, sess, enemy))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// runCombatRound resolves one full round: the player's chosen action, then the
|
||||
// enemy turn and the round-end status tick, advancing the session until it is
|
||||
// back at a player_turn or has reached a terminal status. Returns every event
|
||||
// the round produced. Each advanceCombatSession call persists the session, so
|
||||
// a crash mid-round resumes cleanly from the last phase.
|
||||
func runCombatRound(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
|
||||
events, err := advanceCombatSession(sess, player, enemy, action)
|
||||
if err != nil {
|
||||
return events, err
|
||||
}
|
||||
for sess.IsActive() && sess.Phase != CombatPhasePlayerTurn {
|
||||
more, merr := advanceCombatSession(sess, player, enemy, PlayerAction{})
|
||||
if merr != nil {
|
||||
return events, merr
|
||||
}
|
||||
events = append(events, more...)
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// combatTurnPrompt is the "your move" footer shown after every non-terminal
|
||||
// round and on the opening !fight message.
|
||||
func combatTurnPrompt(sess *CombatSession) string {
|
||||
return fmt.Sprintf("**Round %d.** Your move — `!attack`, `!cast <spell>`, `!consume <item>`, or `!flee`.", sess.Round)
|
||||
}
|
||||
|
||||
// ── close-out ───────────────────────────────────────────────────────────────
|
||||
|
||||
// finishCombatSession runs the post-fight side effects once a CombatSession
|
||||
// has reached a terminal status, and returns the player-facing outcome block.
|
||||
// The graph is NOT advanced here: the terminal session row is the record that
|
||||
// the room's manual combat is done, and a fresh !zone advance clears the room.
|
||||
func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSession, enemy Combatant) string {
|
||||
persistDnDHPAfterCombat(userID, sess.PlayerHP)
|
||||
|
||||
run, _ := getZoneRun(sess.RunID)
|
||||
var zone ZoneDefinition
|
||||
elite := true
|
||||
cadence := sess.Round
|
||||
if run != nil {
|
||||
zone = zoneOrFallback(run.ZoneID)
|
||||
elite = run.CurrentRoomType() != RoomBoss
|
||||
cadence = narrationCadence(run)
|
||||
}
|
||||
monster := dndBestiary[sess.EnemyID]
|
||||
|
||||
// nat20/nat1 mood deltas from the whole fight's event log.
|
||||
scanMoodEventsFromEvents(sess.RunID, sess.TurnLog)
|
||||
|
||||
var b strings.Builder
|
||||
switch sess.Status {
|
||||
case CombatStatusWon:
|
||||
recordZoneKillForUser(userID, sess.EnemyID)
|
||||
applyRoomCombatThreatForUser(userID, elite)
|
||||
// zoneCombatXP only reads PlayerWon + NearDeath off the result.
|
||||
nearDeath := sess.PlayerHPMax > 0 && sess.PlayerHP*5 < sess.PlayerHPMax
|
||||
tier := 1
|
||||
if run != nil {
|
||||
tier = int(zone.Tier)
|
||||
}
|
||||
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
|
||||
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
||||
slog.Error("combat: grantDnDXP turn-based", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
if !elite {
|
||||
// §8.1 — zone boss defeat drops expedition threat. Silent no-op
|
||||
// for standalone zone runs (no active expedition).
|
||||
if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil {
|
||||
_ = applyBossDefeatThreat(exp.ID)
|
||||
}
|
||||
}
|
||||
if line := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence); line != "" {
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
emoji := "✅"
|
||||
if !elite {
|
||||
emoji = "🏆"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s **%s** down. You finished at **%d/%d HP**.\n",
|
||||
emoji, enemy.Name, sess.PlayerHP, sess.PlayerHPMax))
|
||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, !elite); drop != "" {
|
||||
b.WriteString(drop + "\n")
|
||||
}
|
||||
b.WriteString("`!zone advance` to move on.")
|
||||
|
||||
case CombatStatusLost:
|
||||
if run != nil {
|
||||
_, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath)
|
||||
}
|
||||
_ = abandonZoneRun(userID)
|
||||
forceExtractExpeditionForRunLoss(userID, "combat death")
|
||||
markAdventureDead(userID, "zone", zone.Display)
|
||||
if line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence); line != "" {
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", enemy.Name))
|
||||
|
||||
case CombatStatusFled:
|
||||
// Flee = run ends, light penalty: wounds persist (HP already saved),
|
||||
// but no death timer. Chosen candidate from the migration plan's
|
||||
// open question on flee outcome.
|
||||
_ = abandonZoneRun(userID)
|
||||
forceExtractExpeditionForRunLoss(userID, "combat flee")
|
||||
b.WriteString(fmt.Sprintf("🏃 You broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name))
|
||||
|
||||
default:
|
||||
b.WriteString("The fight is over.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── !cast (in-combat) ───────────────────────────────────────────────────────
|
||||
//
|
||||
// handleDnDCastCmd routes here when the player has an active CombatSession:
|
||||
// !cast resolves as the player's turn for the round instead of queuing for
|
||||
// "next combat". Out-of-combat !cast is unchanged.
|
||||
|
||||
// parseCombatCast validates a !cast invocation for a caster mid-fight and
|
||||
// returns the spell plus the resolved slot level. errMsg is non-empty and
|
||||
// player-facing on any validation failure. It performs NO resource spend —
|
||||
// the caller debits the slot only once the round is about to resolve.
|
||||
func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefinition, int, string) {
|
||||
tokens := strings.Fields(args)
|
||||
upcast := 0
|
||||
var spellTokens []string
|
||||
for i := 0; i < len(tokens); i++ {
|
||||
switch tokens[i] {
|
||||
case "--upcast":
|
||||
if i+1 < len(tokens) {
|
||||
if n, err := strconv.Atoi(tokens[i+1]); err == nil {
|
||||
upcast = n
|
||||
}
|
||||
i++
|
||||
}
|
||||
case "--target":
|
||||
if i+1 < len(tokens) {
|
||||
i++
|
||||
}
|
||||
default:
|
||||
spellTokens = append(spellTokens, tokens[i])
|
||||
}
|
||||
}
|
||||
if len(spellTokens) == 0 {
|
||||
return SpellDefinition{}, 0, "Usage: `!cast <spell> [--upcast N]` — casts as your turn this round. `!spells` lists options."
|
||||
}
|
||||
spell, ok := parseSpell(strings.Join(spellTokens, " "))
|
||||
if !ok {
|
||||
return SpellDefinition{}, 0, fmt.Sprintf("Unknown spell %q. Run `!spells` to list what you know.", strings.Join(spellTokens, " "))
|
||||
}
|
||||
// Class gate — Arcane Trickster Rogues use the Mage list.
|
||||
effectiveClass := c.Class
|
||||
if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster {
|
||||
effectiveClass = ClassMage
|
||||
}
|
||||
classOK := false
|
||||
for _, cl := range spell.Classes {
|
||||
if cl == effectiveClass {
|
||||
classOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !classOK {
|
||||
return SpellDefinition{}, 0, fmt.Sprintf("%s is not on the %s spell list.", spell.Name, titleClass(c.Class))
|
||||
}
|
||||
if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster {
|
||||
if mx := highestAvailableSlotForChar(c); spell.Level > mx {
|
||||
return SpellDefinition{}, 0, fmt.Sprintf("At level %d, your Arcane Trickster magic only reaches level-%d spells.", c.Level, mx)
|
||||
}
|
||||
}
|
||||
known, prepared, err := playerKnowsSpell(userID, spell.ID)
|
||||
if err != nil {
|
||||
return SpellDefinition{}, 0, "Couldn't check your spell list."
|
||||
}
|
||||
if !known {
|
||||
return SpellDefinition{}, 0, fmt.Sprintf("You don't know %s yet.", spell.Name)
|
||||
}
|
||||
if !prepared {
|
||||
return SpellDefinition{}, 0, fmt.Sprintf("%s isn't prepared today. Run `!prepare %s` first.", spell.Name, spell.ID)
|
||||
}
|
||||
slotLevel := spell.Level
|
||||
if upcast > slotLevel && spell.Level > 0 {
|
||||
slotLevel = upcast
|
||||
}
|
||||
if slotLevel < 0 || slotLevel > 5 {
|
||||
return SpellDefinition{}, 0, "Slot level out of range (1–5)."
|
||||
}
|
||||
return spell, slotLevel, ""
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
sess, err := getActiveCombatSession(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error())
|
||||
}
|
||||
if sess == nil {
|
||||
// Race: the fight closed between the route check and the lock.
|
||||
return p.SendDM(ctx.Sender, "You're not in a fight anymore.")
|
||||
}
|
||||
|
||||
advChar, _ := loadAdvCharacter(ctx.Sender)
|
||||
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
|
||||
if err != nil || c == nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
|
||||
}
|
||||
if !isSpellcaster(c) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"%s isn't a caster class. `!attack` or `!consume <item>` instead.", titleClass(c.Class)))
|
||||
}
|
||||
|
||||
spell, slotLevel, errMsg := parseCombatCast(ctx.Sender, c, strings.TrimSpace(args))
|
||||
if errMsg != "" {
|
||||
return p.SendDM(ctx.Sender, errMsg)
|
||||
}
|
||||
if spell.Effect == EffectReaction {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"%s is a reaction spell — those still aren't usable. Pick a damage, healing, or control spell.", spell.Name))
|
||||
}
|
||||
|
||||
player, enemy, err := p.combatantsForSession(sess)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
|
||||
}
|
||||
|
||||
var eff *turnActionEffect
|
||||
if spell.Effect == EffectBuffSelf || spell.Effect == EffectBuffAlly {
|
||||
// Buff path — resolve the buff against a throwaway combatant, fold the
|
||||
// marginal effect into the session's persisted state, then rebuild the
|
||||
// pair so the buff is live for this round's enemy turn.
|
||||
as, am := player.Stats, player.Mods
|
||||
applySpellBuff(spell, c, &as, &am, slotLevel)
|
||||
d := diffTurnBuff(player.Stats, as, player.Mods, am)
|
||||
if !d.any() {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"%s has no effect the turn-based engine can apply yet.", spell.Name))
|
||||
}
|
||||
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
|
||||
return p.SendDM(ctx.Sender, msg)
|
||||
}
|
||||
sess.Statuses.applyBuffDelta(d)
|
||||
player, enemy, err = p.combatantsForSession(sess)
|
||||
if err != nil {
|
||||
if spell.Level > 0 {
|
||||
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
|
||||
}
|
||||
label := spell.Name + " — active"
|
||||
if d.heal > 0 {
|
||||
label = fmt.Sprintf("%s — +%d HP", spell.Name, d.heal)
|
||||
}
|
||||
eff = &turnActionEffect{
|
||||
Action: "spell_cast", Label: label,
|
||||
PlayerHeal: d.heal, EnemySkip: d.enemySkip,
|
||||
}
|
||||
} else {
|
||||
out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats)
|
||||
if !supported {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"%s is a utility spell — those aren't usable in turn-based fights yet. Try a damage, healing, control, or buff spell.", spell.Name))
|
||||
}
|
||||
if msg := p.chargeSpellCost(ctx.Sender, spell, slotLevel); msg != "" {
|
||||
return p.SendDM(ctx.Sender, msg)
|
||||
}
|
||||
eff = &turnActionEffect{
|
||||
Label: out.Desc,
|
||||
Action: "spell_cast",
|
||||
EnemyDamage: out.EnemyDamage,
|
||||
PlayerHeal: out.PlayerHeal,
|
||||
EnemySkip: out.EnemySkip,
|
||||
}
|
||||
}
|
||||
|
||||
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff})
|
||||
if err != nil {
|
||||
if spell.Level > 0 {
|
||||
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
|
||||
}
|
||||
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
|
||||
}
|
||||
|
||||
// chargeSpellCost debits a spell's material component and leveled slot for a
|
||||
// turn-based cast. It returns a non-empty player-facing message on failure; on
|
||||
// success the caller owns the slot and must refundSpellSlot if the round itself
|
||||
// errors. Material components (rare in a fight) are not refunded if the slot
|
||||
// debit then fails — matching the auto-resolve cast path.
|
||||
func (p *AdventurePlugin) chargeSpellCost(userID id.UserID, spell SpellDefinition, slotLevel int) string {
|
||||
if spell.MaterialCost > 0 {
|
||||
if p.euro == nil || !p.euro.Debit(userID, float64(spell.MaterialCost), "dnd_spell_component") {
|
||||
return fmt.Sprintf("%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost)
|
||||
}
|
||||
}
|
||||
if spell.Level > 0 {
|
||||
ok, serr := consumeSpellSlot(userID, slotLevel)
|
||||
if serr != nil {
|
||||
return "Couldn't consume slot: " + serr.Error()
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Sprintf("You're out of level-%d energy. %s", slotLevel, renderSlotsBrief(userID))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── !consume (in-combat) ────────────────────────────────────────────────────
|
||||
//
|
||||
// !consume <item> spends one combat consumable as the player's turn. Heal and
|
||||
// flat-damage items resolve fully within the round; buff-type items (ward,
|
||||
// atk/def boost, spore, reflect, auto-crit) fold into the session's persisted
|
||||
// fight-scoped state and carry for the rest of the fight.
|
||||
|
||||
// matchConsumable resolves a player-typed name against the player's consumable
|
||||
// inventory: case-insensitive exact match first, then a unique prefix match.
|
||||
func matchConsumable(inv []ConsumableItem, query string) (*ConsumableItem, string) {
|
||||
q := strings.ToLower(strings.TrimSpace(query))
|
||||
if q == "" {
|
||||
return nil, ""
|
||||
}
|
||||
var prefix []*ConsumableItem
|
||||
for i := range inv {
|
||||
name := strings.ToLower(inv[i].Def.Name)
|
||||
if name == q {
|
||||
return &inv[i], ""
|
||||
}
|
||||
if strings.HasPrefix(name, q) {
|
||||
prefix = append(prefix, &inv[i])
|
||||
}
|
||||
}
|
||||
switch len(prefix) {
|
||||
case 0:
|
||||
return nil, ""
|
||||
case 1:
|
||||
return prefix[0], ""
|
||||
default:
|
||||
names := make([]string, len(prefix))
|
||||
for i, c := range prefix {
|
||||
names[i] = c.Def.Name
|
||||
}
|
||||
return nil, "Ambiguous — matches: " + strings.Join(names, ", ") + "."
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
sess, err := getActiveCombatSession(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read combat state: "+err.Error())
|
||||
}
|
||||
if sess == nil {
|
||||
return p.SendDM(ctx.Sender, "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically.")
|
||||
}
|
||||
|
||||
inv := p.loadConsumableInventory(ctx.Sender)
|
||||
args = strings.TrimSpace(args)
|
||||
if args == "" {
|
||||
if len(inv) == 0 {
|
||||
return p.SendDM(ctx.Sender, "You have no combat consumables. `!attack` / `!cast` / `!flee`.")
|
||||
}
|
||||
names := make([]string, len(inv))
|
||||
for i, c := range inv {
|
||||
names[i] = c.Def.Name
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Usage: `!consume <item>`. You're carrying: "+strings.Join(names, ", ")+".")
|
||||
}
|
||||
|
||||
item, ambig := matchConsumable(inv, args)
|
||||
if ambig != "" {
|
||||
return p.SendDM(ctx.Sender, ambig)
|
||||
}
|
||||
if item == nil {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("No consumable matching %q in your inventory.", args))
|
||||
}
|
||||
|
||||
def := item.Def
|
||||
player, enemy, err := p.combatantsForSession(sess)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
|
||||
}
|
||||
|
||||
eff := &turnActionEffect{Action: "use_consumable"}
|
||||
switch def.Effect {
|
||||
case EffectHeal:
|
||||
eff.PlayerHeal = int(def.Value)
|
||||
eff.Label = fmt.Sprintf("%s — +%d HP", def.Name, eff.PlayerHeal)
|
||||
case EffectFlatDmg:
|
||||
eff.EnemyDamage = int(def.Value)
|
||||
eff.Label = fmt.Sprintf("%s — %d damage", def.Name, eff.EnemyDamage)
|
||||
default:
|
||||
// Buff-type consumable — resolve the marginal effect against a
|
||||
// throwaway combatant, fold it into the session's persisted state, and
|
||||
// rebuild the pair so the buff is live for this round's enemy turn.
|
||||
as, am := player.Stats, player.Mods
|
||||
ApplyConsumableMods(&as, &am, []ConsumableItem{{Def: def}})
|
||||
d := diffTurnBuff(player.Stats, as, player.Mods, am)
|
||||
if !d.any() {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"**%s** has no effect the turn-based engine can apply yet.", def.Name))
|
||||
}
|
||||
sess.Statuses.applyBuffDelta(d)
|
||||
player, enemy, err = p.combatantsForSession(sess)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
|
||||
}
|
||||
eff.Label = def.Name + " — active"
|
||||
eff.PlayerHeal = d.heal
|
||||
eff.EnemySkip = d.enemySkip
|
||||
}
|
||||
|
||||
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff})
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
|
||||
}
|
||||
// Round resolved and persisted — now burn the item. A removal failure here
|
||||
// leaves the player a free use (logged, rare); better than charging them
|
||||
// for a round that errored out.
|
||||
if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil {
|
||||
slog.Error("combat: consume remove inventory item failed", "user", ctx.Sender, "item", def.Name, "err", rerr)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func TestEncounterIDForRoom(t *testing.T) {
|
||||
if got := encounterIDForRoom(0); got != "room0" {
|
||||
t.Errorf("encounterIDForRoom(0) = %q, want room0", got)
|
||||
}
|
||||
if encounterIDForRoom(3) == encounterIDForRoom(4) {
|
||||
t.Error("distinct rooms must produce distinct encounter ids")
|
||||
}
|
||||
}
|
||||
|
||||
// ── getCombatSessionForEncounter ───────────────────────────────────────────
|
||||
|
||||
func TestGetCombatSessionForEncounter(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@combat-enc:example.org")
|
||||
defer cleanupCombatSessions(uid)
|
||||
|
||||
if got, err := getCombatSessionForEncounter("run-enc", "room3"); err != nil || got != nil {
|
||||
t.Fatalf("unknown encounter: got %v / %v, want nil/nil", got, err)
|
||||
}
|
||||
|
||||
s, err := startCombatSession(uid, "run-enc", "room3", "goblin", 40, 40, 12, 12)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := getCombatSessionForEncounter("run-enc", "room3")
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("getCombatSessionForEncounter: %v / %v", got, err)
|
||||
}
|
||||
if got.SessionID != s.SessionID {
|
||||
t.Errorf("id mismatch: %q vs %q", got.SessionID, s.SessionID)
|
||||
}
|
||||
if got, _ := getCombatSessionForEncounter("run-enc", "room9"); got != nil {
|
||||
t.Errorf("wrong room should not match: %+v", got)
|
||||
}
|
||||
|
||||
// A terminal session is still returned — the room resolver relies on this
|
||||
// to tell "already won" apart from "not yet fought".
|
||||
s.Status = CombatStatusWon
|
||||
s.Phase = CombatPhaseOver
|
||||
if err := saveCombatSession(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = getCombatSessionForEncounter("run-enc", "room3")
|
||||
if err != nil || got == nil || got.Status != CombatStatusWon {
|
||||
t.Errorf("terminal session lookup: %+v / %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── runCombatRound ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestRunCombatRound_FullRoundReturnsToPlayerTurn(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@combat-round:example.org")
|
||||
defer cleanupCombatSessions(uid)
|
||||
|
||||
// HP pools large enough that one round can't end the fight.
|
||||
sess, err := startCombatSession(uid, "r", "room0", "goblin", 100000, 100000, 100000, 100000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
|
||||
if err != nil {
|
||||
t.Fatalf("runCombatRound: %v", err)
|
||||
}
|
||||
if !sess.IsActive() {
|
||||
t.Fatalf("status = %q, want active after one non-lethal round", sess.Status)
|
||||
}
|
||||
if sess.Phase != CombatPhasePlayerTurn {
|
||||
t.Errorf("phase = %q, want player_turn (round resolved fully)", sess.Phase)
|
||||
}
|
||||
if sess.Round != 2 {
|
||||
t.Errorf("round = %d, want 2 after one full round", sess.Round)
|
||||
}
|
||||
if len(events) == 0 {
|
||||
t.Error("expected the round to produce at least one event")
|
||||
}
|
||||
// The persisted row should match the in-memory session.
|
||||
reloaded, _ := getCombatSession(sess.SessionID)
|
||||
if reloaded == nil || reloaded.Round != 2 || reloaded.Phase != CombatPhasePlayerTurn {
|
||||
t.Errorf("round not persisted: %+v", reloaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCombatRound_FleeEndsFight(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@combat-flee:example.org")
|
||||
defer cleanupCombatSessions(uid)
|
||||
|
||||
sess, err := startCombatSession(uid, "r", "room0", "goblin", 50, 50, 50, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionFlee})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Status != CombatStatusFled || sess.Phase != CombatPhaseOver {
|
||||
t.Errorf("after flee: status=%q phase=%q", sess.Status, sess.Phase)
|
||||
}
|
||||
if len(events) != 1 || events[0].Action != "flee" {
|
||||
t.Errorf("expected one flee event, got %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCombatRound_PlayerWinsTerminates(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@combat-win:example.org")
|
||||
defer cleanupCombatSessions(uid)
|
||||
|
||||
// Enemy on 1 HP, player with a huge pool: a connecting hit ends it, and
|
||||
// the player can't be dropped first. A few rounds covers attack misses.
|
||||
sess, err := startCombatSession(uid, "r", "room0", "goblin", 100000, 100000, 1, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
for i := 0; i < 15 && sess.IsActive(); i++ {
|
||||
if _, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if sess.Status != CombatStatusWon {
|
||||
t.Fatalf("status = %q, want won", sess.Status)
|
||||
}
|
||||
if sess.Phase != CombatPhaseOver || sess.EnemyHP > 0 {
|
||||
t.Errorf("terminal state wrong: phase=%q enemyHP=%d", sess.Phase, sess.EnemyHP)
|
||||
}
|
||||
}
|
||||
|
||||
// ── runCombatRound: !cast / !consume turn effects ─────────────────────────
|
||||
|
||||
func TestRunCombatRound_ConsumeHealClampsToMax(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@combat-heal:example.org")
|
||||
defer cleanupCombatSessions(uid)
|
||||
|
||||
// Player at 10/50, big enemy pool so the round can't end the fight.
|
||||
sess, err := startCombatSession(uid, "r", "room0", "goblin", 10, 50, 100000, 100000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
eff := &turnActionEffect{Action: "use_consumable", Label: "Spirit Tonic", PlayerHeal: 1000}
|
||||
if _, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionConsume, Effect: eff}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Heal of 1000 must clamp to PlayerHPMax, even after the enemy's swing this
|
||||
// round chipped some HP back off.
|
||||
if sess.PlayerHP > sess.PlayerHPMax {
|
||||
t.Errorf("playerHP = %d exceeds max %d", sess.PlayerHP, sess.PlayerHPMax)
|
||||
}
|
||||
if sess.PlayerHP <= 10 {
|
||||
t.Errorf("playerHP = %d, expected the heal to raise it above the 10 it started at", sess.PlayerHP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCombatRound_CastDamageKillsEnemy(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@combat-cast-kill:example.org")
|
||||
defer cleanupCombatSessions(uid)
|
||||
|
||||
sess, err := startCombatSession(uid, "r", "room0", "goblin", 100000, 100000, 30, 30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
eff := &turnActionEffect{Action: "spell_cast", Label: "Fireball — 99 dmg", EnemyDamage: 99}
|
||||
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Status != CombatStatusWon || sess.Phase != CombatPhaseOver {
|
||||
t.Errorf("after lethal cast: status=%q phase=%q", sess.Status, sess.Phase)
|
||||
}
|
||||
if sess.EnemyHP > 0 {
|
||||
t.Errorf("enemyHP = %d, want 0", sess.EnemyHP)
|
||||
}
|
||||
if len(events) != 1 || events[0].Action != "spell_cast" {
|
||||
t.Errorf("expected a single spell_cast event, got %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCombatRound_CastControlSkipsEnemyTurn(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@combat-control:example.org")
|
||||
defer cleanupCombatSessions(uid)
|
||||
|
||||
sess, err := startCombatSession(uid, "r", "room0", "goblin", 200, 200, 100000, 100000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
hpBefore := sess.PlayerHP
|
||||
|
||||
eff := &turnActionEffect{Action: "spell_cast", Label: "Hold Person — controlled", EnemySkip: true}
|
||||
events, err := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The enemy forfeits its swing this round, so the player takes no damage.
|
||||
if sess.PlayerHP != hpBefore {
|
||||
t.Errorf("playerHP = %d, want unchanged %d (enemy was held)", sess.PlayerHP, hpBefore)
|
||||
}
|
||||
held := false
|
||||
for _, ev := range events {
|
||||
if ev.Action == "spell_held" {
|
||||
held = true
|
||||
}
|
||||
}
|
||||
if !held {
|
||||
t.Errorf("expected a spell_held event, got %+v", events)
|
||||
}
|
||||
// The skip is a one-round effect — it must not persist into the next round.
|
||||
if sess.Statuses.EnemySkipNext {
|
||||
t.Error("EnemySkipNext should be cleared once the enemy turn consumed it")
|
||||
}
|
||||
if !sess.IsActive() || sess.Phase != CombatPhasePlayerTurn {
|
||||
t.Errorf("fight should continue: status=%q phase=%q", sess.Status, sess.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
// ── RenderTurnRound ────────────────────────────────────────────────────────
|
||||
|
||||
func TestRenderTurnRound(t *testing.T) {
|
||||
if got := RenderTurnRound(nil, "Hero", "Goblin"); !strings.Contains(got, "without a clean blow") {
|
||||
t.Errorf("empty events render = %q", got)
|
||||
}
|
||||
events := []CombatEvent{
|
||||
{Actor: "player", Action: "hit", Damage: 9},
|
||||
{Actor: "enemy", Action: "miss"},
|
||||
{Actor: "enemy", Action: "poison_tick", Damage: 3},
|
||||
}
|
||||
got := RenderTurnRound(events, "Hero", "Goblin")
|
||||
// One line per event (damage events interpolate their amount; the random
|
||||
// pool choice is what varies, so assert structure + the damage numbers
|
||||
// rather than specific flavor strings).
|
||||
if lines := strings.Count(got, "\n"); lines != 2 {
|
||||
t.Errorf("expected 3 lines, got %d: %q", lines+1, got)
|
||||
}
|
||||
if !strings.Contains(got, "9") {
|
||||
t.Errorf("player hit damage not rendered: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "3") {
|
||||
t.Errorf("poison tick damage not rendered: %q", got)
|
||||
}
|
||||
// Turn-specific actions get their own pools.
|
||||
cast := RenderTurnRound([]CombatEvent{
|
||||
{Actor: "player", Action: "spell_cast", Desc: "Fireball — 24 dmg", Damage: 24},
|
||||
}, "Hero", "Goblin")
|
||||
if !strings.Contains(cast, "Fireball — 24 dmg") {
|
||||
t.Errorf("spell_cast label not rendered: %q", cast)
|
||||
}
|
||||
flee := RenderTurnRound([]CombatEvent{{Actor: "player", Action: "flee"}}, "Hero", "Goblin")
|
||||
if flee == "" || strings.Contains(flee, "without a clean blow") {
|
||||
t.Errorf("flee should render a line: %q", flee)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// dumpCombatEventsIfDebug logs every event in a CombatResult to slog.Info
|
||||
// when GOGOBEE_COMBAT_DEBUG=1 is set. Renders one event per line in a
|
||||
// compact form: "[round phase actor.action] hp=P/E roll=R vsAC=V dmg=D desc".
|
||||
//
|
||||
// Used to diagnose ghost damage / dropped events: the renderer silently
|
||||
// returns "" for unknown actions, which makes damage that hit the engine
|
||||
// state but never reached the chat invisible. This dump shows everything.
|
||||
func dumpCombatEventsIfDebug(label string, result CombatResult) {
|
||||
if os.Getenv("GOGOBEE_COMBAT_DEBUG") != "1" {
|
||||
return
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("\n=== combat dump: %s ===\n", label))
|
||||
b.WriteString(fmt.Sprintf("PlayerStartHP=%d PlayerEntryHP=%d EnemyStartHP=%d EnemyEntryHP=%d PlayerEndHP=%d EnemyEndHP=%d won=%v\n",
|
||||
result.PlayerStartHP, result.PlayerEntryHP, result.EnemyStartHP, result.EnemyEntryHP,
|
||||
result.PlayerEndHP, result.EnemyEndHP, result.PlayerWon))
|
||||
prevP := result.PlayerEntryHP
|
||||
if prevP == 0 {
|
||||
prevP = result.PlayerStartHP
|
||||
}
|
||||
prevE := result.EnemyEntryHP
|
||||
if prevE == 0 {
|
||||
prevE = result.EnemyStartHP
|
||||
}
|
||||
for i, e := range result.Events {
|
||||
dp := e.PlayerHP - prevP
|
||||
de := e.EnemyHP - prevE
|
||||
b.WriteString(fmt.Sprintf(" %3d [r%d %s %s.%s] hp=%d/%d (Δp=%+d Δe=%+d) roll=%d vsAC=%d dmg=%d desc=%q\n",
|
||||
i, e.Round, e.Phase, e.Actor, e.Action, e.PlayerHP, e.EnemyHP, dp, de,
|
||||
e.Roll, e.RollAgainst, e.Damage, e.Desc))
|
||||
prevP, prevE = e.PlayerHP, e.EnemyHP
|
||||
}
|
||||
b.WriteString("=== end dump ===\n")
|
||||
slog.Info("combat debug dump", "log", b.String())
|
||||
}
|
||||
+706
-172
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
||||
package plugin
|
||||
|
||||
// Characterization test for the auto-resolve combat engine.
|
||||
//
|
||||
// This pins the *exact* event stream SimulateCombat produces for a curated set
|
||||
// of scenarios, driven by a seeded RNG so the output is reproducible. It is a
|
||||
// safety net for the upcoming shared-primitives extraction (turn-based engine
|
||||
// work): any change that perturbs the auto-resolve event sequence will fail
|
||||
// here, forcing a deliberate `-update` regeneration + diff review.
|
||||
//
|
||||
// It asserts behavior, not correctness — a failing diff means "you changed
|
||||
// something", not "you broke something". Review the diff, and if the change is
|
||||
// intended, regenerate with:
|
||||
//
|
||||
// go test ./internal/plugin -run TestCombatCharacterization -update
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var updateCharacterization = flag.Bool("update", false, "regenerate combat characterization golden file")
|
||||
|
||||
// charScenario is one pinned combat setup. The same player/enemy/phases are
|
||||
// run across a fixed set of seeds so the snapshot captures multiple RNG
|
||||
// branches (hit/miss/crit/proc) of the same configuration.
|
||||
type charScenario struct {
|
||||
name string
|
||||
player Combatant
|
||||
enemy Combatant
|
||||
phases []CombatPhase
|
||||
}
|
||||
|
||||
func characterizationScenarios() []charScenario {
|
||||
// Local builders so the scenarios don't depend on mutation order of the
|
||||
// shared basePlayer()/baseEnemy() helpers.
|
||||
weaponPlayer := func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Stats.Weapon = weaponByID("wpn_longsword")
|
||||
return p
|
||||
}
|
||||
|
||||
return []charScenario{
|
||||
{"basic", basePlayer(), baseEnemy(), defaultCombatPhases},
|
||||
{"dungeon_phases", basePlayer(), baseEnemy(), dungeonCombatPhases},
|
||||
{"boss_phases", func() Combatant {
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 220
|
||||
e.Stats.Attack = 22
|
||||
return basePlayer()
|
||||
}(), func() Combatant {
|
||||
e := baseEnemy()
|
||||
e.Name = "Boss"
|
||||
e.Stats.MaxHP = 220
|
||||
e.Stats.Attack = 22
|
||||
return e
|
||||
}(), bossCombatPhases},
|
||||
{"strong_enemy", func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Stats.MaxHP = 50
|
||||
p.Stats.Attack = 8
|
||||
return p
|
||||
}(), func() Combatant {
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 200
|
||||
e.Stats.Attack = 30
|
||||
e.Stats.Defense = 12
|
||||
return e
|
||||
}(), defaultCombatPhases},
|
||||
{"sniper_kill", func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Mods.SniperKillProc = 1.0
|
||||
return p
|
||||
}(), baseEnemy(), defaultCombatPhases},
|
||||
{"flat_dmg_start", func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Mods.FlatDmgStart = 20
|
||||
return p
|
||||
}(), baseEnemy(), defaultCombatPhases},
|
||||
{"spell_pre_damage", func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Mods.SpellPreDamage = 18
|
||||
p.Mods.SpellPreDamageDesc = "Fireball"
|
||||
return p
|
||||
}(), baseEnemy(), defaultCombatPhases},
|
||||
{"spell_enemy_skip_first", func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Mods.SpellPreDamage = 5
|
||||
p.Mods.SpellPreDamageDesc = "Hold Person"
|
||||
p.Mods.SpellEnemySkipFirst = true
|
||||
return p
|
||||
}(), baseEnemy(), defaultCombatPhases},
|
||||
{"heal_item", func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Mods.HealItem = 30
|
||||
return p
|
||||
}(), func() Combatant {
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 20
|
||||
return e
|
||||
}(), defaultCombatPhases},
|
||||
{"ward_absorb", func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Mods.WardCharges = 1
|
||||
return p
|
||||
}(), func() Combatant {
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 30
|
||||
return e
|
||||
}(), defaultCombatPhases},
|
||||
{"spore_cloud", func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Mods.SporeCloud = 10
|
||||
return p
|
||||
}(), func() Combatant {
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 15
|
||||
return e
|
||||
}(), defaultCombatPhases},
|
||||
{"reflect_next", func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Mods.ReflectNext = 0.5
|
||||
return p
|
||||
}(), func() Combatant {
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 20
|
||||
return e
|
||||
}(), defaultCombatPhases},
|
||||
{"auto_crit_first", func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Mods.AutoCritFirst = true
|
||||
p.Stats.CritRate = 0
|
||||
return p
|
||||
}(), baseEnemy(), defaultCombatPhases},
|
||||
{"pet_attack", func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Mods.PetAttackProc = 1.0
|
||||
p.Mods.PetAttackDmg = 10
|
||||
return p
|
||||
}(), baseEnemy(), defaultCombatPhases},
|
||||
{"misty_heal", func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Mods.MistyHealProc = 1.0
|
||||
p.Mods.MistyHealAmt = 10
|
||||
return p
|
||||
}(), func() Combatant {
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 15
|
||||
return e
|
||||
}(), defaultCombatPhases},
|
||||
{"death_save", func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Stats.MaxHP = 30
|
||||
p.Mods.DeathSave = true
|
||||
return p
|
||||
}(), func() Combatant {
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 40
|
||||
e.Stats.MaxHP = 200
|
||||
return e
|
||||
}(), defaultCombatPhases},
|
||||
{"ability_poison", basePlayer(), func() Combatant {
|
||||
e := baseEnemy()
|
||||
e.Ability = &MonsterAbility{Name: "Venom Bite", Phase: "any", ProcChance: 1.0, Effect: "poison"}
|
||||
return e
|
||||
}(), defaultCombatPhases},
|
||||
{"ability_enrage", func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Stats.Attack = 12
|
||||
return p
|
||||
}(), func() Combatant {
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 50
|
||||
e.Stats.Attack = 6
|
||||
e.Ability = &MonsterAbility{Name: "Berserk", Phase: "any", ProcChance: 1.0, Effect: "enrage"}
|
||||
return e
|
||||
}(), defaultCombatPhases},
|
||||
{"ability_armor_break", basePlayer(), func() Combatant {
|
||||
e := baseEnemy()
|
||||
e.Ability = &MonsterAbility{Name: "Rend", Phase: "opening", ProcChance: 1.0, Effect: "armor_break"}
|
||||
return e
|
||||
}(), defaultCombatPhases},
|
||||
{"ability_stun", basePlayer(), func() Combatant {
|
||||
e := baseEnemy()
|
||||
e.Ability = &MonsterAbility{Name: "Bash", Phase: "any", ProcChance: 1.0, Effect: "stun"}
|
||||
return e
|
||||
}(), defaultCombatPhases},
|
||||
{"ability_lifesteal", basePlayer(), func() Combatant {
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 100
|
||||
e.Ability = &MonsterAbility{Name: "Drain", Phase: "any", ProcChance: 1.0, Effect: "lifesteal"}
|
||||
return e
|
||||
}(), defaultCombatPhases},
|
||||
{"ability_cleave", basePlayer(), func() Combatant {
|
||||
e := baseEnemy()
|
||||
e.Ability = &MonsterAbility{Name: "Cleave", Phase: "clash", ProcChance: 1.0, Effect: "cleave"}
|
||||
return e
|
||||
}(), defaultCombatPhases},
|
||||
{"weapon_profile", weaponPlayer(), baseEnemy(), defaultCombatPhases},
|
||||
}
|
||||
}
|
||||
|
||||
// charSeeds drives each scenario. Multiple seeds widen RNG-branch coverage
|
||||
// without exploding the golden file.
|
||||
var charSeeds = []uint64{1, 2, 3, 7, 42}
|
||||
|
||||
func TestCombatCharacterization(t *testing.T) {
|
||||
var b strings.Builder
|
||||
for _, sc := range characterizationScenarios() {
|
||||
for _, seed := range charSeeds {
|
||||
// PCG with a per-(scenario,seed) stream so scenarios don't share
|
||||
// a sequence. The literal constant just disambiguates the stream.
|
||||
rng := rand.New(rand.NewPCG(seed, 0xC0FFEE))
|
||||
res := simulateCombatWithRNG(sc.player, sc.enemy, sc.phases, rng)
|
||||
fmt.Fprintf(&b, "=== %s seed=%d ===\n", sc.name, seed)
|
||||
b.WriteString(formatResult(res))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
got := b.String()
|
||||
|
||||
goldenPath := filepath.Join("testdata", "combat_characterization.golden")
|
||||
if *updateCharacterization {
|
||||
if err := os.WriteFile(goldenPath, []byte(got), 0o644); err != nil {
|
||||
t.Fatalf("write golden: %v", err)
|
||||
}
|
||||
t.Logf("updated %s", goldenPath)
|
||||
return
|
||||
}
|
||||
|
||||
want, err := os.ReadFile(goldenPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read golden (run with -update to create): %v", err)
|
||||
}
|
||||
if got != string(want) {
|
||||
t.Errorf("combat event stream diverged from golden file.\n"+
|
||||
"If this change is intentional, regenerate with:\n"+
|
||||
" go test ./internal/plugin -run TestCombatCharacterization -update\n"+
|
||||
"then review the diff.\n\n%s", firstDiff(string(want), got))
|
||||
}
|
||||
}
|
||||
|
||||
// formatResult serializes a CombatResult to a stable, line-oriented text form.
|
||||
// Every event is dumped; summary fields follow. Field order is fixed so a diff
|
||||
// pinpoints exactly what moved.
|
||||
func formatResult(r CombatResult) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "result: won=%v timedOut=%v rounds=%d\n", r.PlayerWon, r.TimedOut, r.TotalRounds)
|
||||
fmt.Fprintf(&b, " hp: playerStart=%d playerEntry=%d playerEnd=%d enemyStart=%d enemyEntry=%d enemyEnd=%d\n",
|
||||
r.PlayerStartHP, r.PlayerEntryHP, r.PlayerEndHP, r.EnemyStartHP, r.EnemyEntryHP, r.EnemyEndHP)
|
||||
fmt.Fprintf(&b, " flags: nearDeath=%v pet=%v petDeflect=%v sniper=%v misty=%v closeness=%.4f\n",
|
||||
r.NearDeath, r.PetAttacked, r.PetDeflected, r.SniperKilled, r.MistyHealed, r.Closeness)
|
||||
for i, e := range r.Events {
|
||||
fmt.Fprintf(&b, " [%02d] r%d %-12s %-8s %-16s dmg=%-5d php=%-4d ehp=%-4d roll=%d/%d desc=%q\n",
|
||||
i, e.Round, e.Phase, e.Actor, e.Action, e.Damage, e.PlayerHP, e.EnemyHP, e.Roll, e.RollAgainst, e.Desc)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// firstDiff returns a short window around the first differing line, so test
|
||||
// output points straight at the divergence instead of dumping the whole file.
|
||||
func firstDiff(want, got string) string {
|
||||
wl := strings.Split(want, "\n")
|
||||
gl := strings.Split(got, "\n")
|
||||
for i := 0; i < len(wl) || i < len(gl); i++ {
|
||||
var w, g string
|
||||
if i < len(wl) {
|
||||
w = wl[i]
|
||||
}
|
||||
if i < len(gl) {
|
||||
g = gl[i]
|
||||
}
|
||||
if w != g {
|
||||
lo := i - 3
|
||||
if lo < 0 {
|
||||
lo = 0
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "first divergence at line %d:\n", i+1)
|
||||
for j := lo; j < i; j++ {
|
||||
if j < len(gl) {
|
||||
fmt.Fprintf(&b, " ctx: %s\n", gl[j])
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, " want: %s\n", w)
|
||||
fmt.Fprintf(&b, " got: %s\n", g)
|
||||
return b.String()
|
||||
}
|
||||
}
|
||||
return "(files differ but no line-level diff found — trailing whitespace?)"
|
||||
}
|
||||
@@ -11,6 +11,7 @@ type actionPicker struct {
|
||||
enemy map[int]bool
|
||||
player map[int]bool
|
||||
playerMiss map[int]bool
|
||||
enemyMiss map[int]bool
|
||||
block map[int]bool
|
||||
environment map[int]bool
|
||||
}
|
||||
@@ -20,6 +21,7 @@ func newActionPicker() *actionPicker {
|
||||
enemy: make(map[int]bool),
|
||||
player: make(map[int]bool),
|
||||
playerMiss: make(map[int]bool),
|
||||
enemyMiss: make(map[int]bool),
|
||||
block: make(map[int]bool),
|
||||
environment: make(map[int]bool),
|
||||
}
|
||||
@@ -95,7 +97,24 @@ func renderPhaseBlock(pg phaseGroup, playerName, enemyName string, result Combat
|
||||
header := phaseHeader(pg.Name)
|
||||
sb.WriteString(header + "\n")
|
||||
|
||||
for _, e := range pg.Events {
|
||||
// Walk events with a 3+ consecutive-trivial collapse: long runs of
|
||||
// "miss / miss / miss" get one TwinBee yadda-yadda line instead of N
|
||||
// individual lines. Keeps boss fights (16 rounds) from drowning in
|
||||
// repetition while still rendering every impactful beat.
|
||||
i := 0
|
||||
for i < len(pg.Events) {
|
||||
e := pg.Events[i]
|
||||
if isTrivialEvent(e) {
|
||||
run := 1
|
||||
for i+run < len(pg.Events) && isTrivialEvent(pg.Events[i+run]) {
|
||||
run++
|
||||
}
|
||||
if run >= 3 {
|
||||
sb.WriteString(pickRand(narrativeFillerSlog) + "\n")
|
||||
i += run
|
||||
continue
|
||||
}
|
||||
}
|
||||
line := renderEvent(e, playerName, enemyName, result, picker)
|
||||
if line != "" {
|
||||
if roll := rollAnnotation(e); roll != "" {
|
||||
@@ -103,6 +122,7 @@ func renderPhaseBlock(pg phaseGroup, playerName, enemyName string, result Combat
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
if len(pg.Events) == 0 {
|
||||
@@ -116,6 +136,28 @@ func renderPhaseBlock(pg phaseGroup, playerName, enemyName string, result Combat
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// isTrivialEvent — events that don't move HP or set up a tactical hook.
|
||||
// A run of 3+ of these collapses into a TwinBee filler line.
|
||||
func isTrivialEvent(e CombatEvent) bool {
|
||||
switch e.Action {
|
||||
case "miss", "spore_miss", "pet_whiff":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// narrativeFillerSlog covers a run of consecutive trivial events
|
||||
// (misses, whiffs) so long boss fights don't read as a wall of
|
||||
// "you swing wide / they swing wide" lines.
|
||||
var narrativeFillerSlog = []string{
|
||||
"_The exchange settles into a rhythm. A few rounds of nothing meaningful — I skip to the next thing that matters._",
|
||||
"_I narrate the next few seconds in a single sigh. Both sides keep swinging. Both sides keep missing._",
|
||||
"_And the battle goes on. Yadda yadda yadda. I will narrate the next part where something actually lands._",
|
||||
"_The grind continues. I use the time to mentally redesign the dungeon's lighting._",
|
||||
"_A flurry of misses on both sides. Nobody is impressing anybody. I wait for the next hit._",
|
||||
"_I politely decline to narrate this stretch. It was, in my professional opinion, a waste of everyone's time._",
|
||||
}
|
||||
|
||||
func clampHP(hp int) int {
|
||||
if hp < 0 {
|
||||
return 0
|
||||
@@ -186,7 +228,10 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
||||
return fmt.Sprintf(pickRand(narrativePlayerBlock), e.Damage)
|
||||
|
||||
case "miss":
|
||||
return pickFromNoFmt(narrativeMiss, picker.playerMiss)
|
||||
if e.Actor == "player" {
|
||||
return pickFromNoFmt(narrativePlayerMiss, picker.playerMiss)
|
||||
}
|
||||
return pickFromNoFmt(narrativeEnemyMiss, picker.enemyMiss)
|
||||
|
||||
case "pet_attack":
|
||||
return fmt.Sprintf(pickRand(narrativePetAttack), e.Damage)
|
||||
@@ -250,6 +295,58 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
||||
return fmt.Sprintf(pickRand(narrativeLifesteal), e.Damage)
|
||||
case "cleave":
|
||||
return fmt.Sprintf(pickRand(narrativeCleave), e.Damage)
|
||||
case "bonus_damage":
|
||||
return fmt.Sprintf(pickRand(narrativeBonusDamage), e.Damage)
|
||||
case "aoe":
|
||||
return fmt.Sprintf(pickRand(narrativeAoE), e.Damage)
|
||||
case "execute":
|
||||
return fmt.Sprintf(pickRand(narrativeExecute), e.Damage)
|
||||
case "self_heal":
|
||||
return fmt.Sprintf(pickRand(narrativeSelfHeal), e.Damage)
|
||||
case "ability_flavor":
|
||||
return pickRand(narrativeAbilityFlavor)
|
||||
|
||||
// Monster abilities — slice 3 stateful effects
|
||||
case "evade":
|
||||
return pickRand(narrativeEvade)
|
||||
case "parry_stance":
|
||||
return pickRand(narrativeParryStance)
|
||||
case "advantage":
|
||||
return pickRand(narrativeAdvantage)
|
||||
case "retaliate_aura":
|
||||
return pickRand(narrativeRetaliateAura)
|
||||
case "retaliate":
|
||||
return fmt.Sprintf(pickRand(narrativeRetaliate), e.Damage)
|
||||
case "regenerate":
|
||||
return pickRand(narrativeRegenerate)
|
||||
case "regen_tick":
|
||||
return fmt.Sprintf(pickRand(narrativeRegenTick), e.Damage)
|
||||
case "survive_armed":
|
||||
return pickRand(narrativeSurviveArmed)
|
||||
case "survive_at_1":
|
||||
return pickRand(narrativeSurvive)
|
||||
case "stat_drain":
|
||||
return pickRand(narrativeStatDrain)
|
||||
case "debuff":
|
||||
return pickRand(narrativeDebuff)
|
||||
case "max_hp_drain":
|
||||
return fmt.Sprintf(pickRand(narrativeMaxHPDrain), e.Damage)
|
||||
|
||||
// Monster abilities — slice 4 (former flavor-only placeholders)
|
||||
case "spell_resist":
|
||||
return pickRand(narrativeSpellResist)
|
||||
case "spell_fizzle":
|
||||
return fmt.Sprintf(pickRand(narrativeSpellFizzle), e.Damage)
|
||||
case "reveal_armed":
|
||||
return pickRand(narrativeRevealArmed)
|
||||
case "revealed":
|
||||
return pickRand(narrativeRevealed)
|
||||
case "fear_immune":
|
||||
return pickRand(narrativeFearImmune)
|
||||
case "fear_resist":
|
||||
return pickRand(narrativeFearResist)
|
||||
case "ally_buff":
|
||||
return pickRand(narrativeAllyBuff)
|
||||
|
||||
case "timeout":
|
||||
return pickRand(narrativeTimeout)
|
||||
@@ -403,7 +500,7 @@ var narrativeEnemyBlock = []string{
|
||||
"The enemy's defense absorbs most of it. %d damage. The enemy's forearm speaks to the pathetic nature of your striking.",
|
||||
}
|
||||
|
||||
var narrativeMiss = []string{
|
||||
var narrativePlayerMiss = []string{
|
||||
"Your swing goes wide. Nothing connects. The air you hit had it coming.",
|
||||
"The enemy anticipated that one. Complete miss. They're already bored.",
|
||||
"You overcommit and hit nothing but air. The air files no complaint.",
|
||||
@@ -412,6 +509,16 @@ var narrativeMiss = []string{
|
||||
"You close your eyes for the strike because it feels more dramatic. You miss. Everything about this was predictable.",
|
||||
}
|
||||
|
||||
var narrativeEnemyMiss = []string{
|
||||
"The enemy lunges. Finds only air. The air takes the slight personally.",
|
||||
"Their strike misses by inches. They curse in a language you don't recognize.",
|
||||
"A wild swing, deflected by your stance. They expected better of themselves.",
|
||||
"The blow comes in too predictable. You're already gone. They commit. They miss.",
|
||||
"The enemy whiffs. The look on their face — what passes for one — is priceless.",
|
||||
"Their weapon meets your guard, slides past harmless. They've used this combo before. It worked then.",
|
||||
"The strike is fast. You are slightly faster. The math works out in your favor for once.",
|
||||
}
|
||||
|
||||
var narrativePetAttack = []string{
|
||||
"🐾 Your pet lunges at the enemy from absolutely nowhere. %d damage. Your pet has been waiting for this.",
|
||||
"🐾 Your pet strikes from the side with zero hesitation. %d additional damage. The enemy was not monitoring the pet. Mistake.",
|
||||
@@ -550,6 +657,150 @@ var narrativeCleave = []string{
|
||||
"⚔️⚔️ A devastating combo. %d damage from both hits. The second one was personal.",
|
||||
}
|
||||
|
||||
var narrativeBonusDamage = []string{
|
||||
"💢 The enemy finds an opening you didn't know you'd left. %d extra damage. Lesson noted.",
|
||||
"💢 A second strike slips past your guard before the first one finished. %d damage on top.",
|
||||
"💢 The enemy presses the advantage — %d more damage than the round had any right to deal.",
|
||||
}
|
||||
|
||||
var narrativeAoE = []string{
|
||||
"💥 The attack erupts outward. %d damage, and your armor barely slows it. Nowhere to dodge.",
|
||||
"💥 A burst of force washes over you. %d damage — there was no good way to stand for that.",
|
||||
"💥 The enemy unleashes something wide and indiscriminate. %d damage. Cover would have been nice.",
|
||||
}
|
||||
|
||||
var narrativeExecute = []string{
|
||||
"☠️ The enemy goes for the kill — %d damage aimed squarely at finishing you.",
|
||||
"☠️ A finishing blow. %d damage. The enemy can smell the end of this fight.",
|
||||
"☠️ The enemy commits everything to one last strike. %d damage. They want this over.",
|
||||
}
|
||||
|
||||
var narrativeSelfHeal = []string{
|
||||
"✨ The enemy knits its wounds closed. +%d HP. That's going to make this longer.",
|
||||
"✨ The enemy mends itself before your eyes. +%d HP restored. Rude.",
|
||||
"✨ Something restorative passes over the enemy. +%d HP. The progress bar moved the wrong way.",
|
||||
}
|
||||
|
||||
var narrativeAbilityFlavor = []string{
|
||||
"🌀 The enemy does *something* — you feel the shape of it more than the effect. Stay wary.",
|
||||
"🌀 The air shifts around the enemy. Whatever that was, it wasn't nothing.",
|
||||
"🌀 The enemy invokes a power you can't quite read. File it under 'concerning'.",
|
||||
}
|
||||
|
||||
var narrativeEvade = []string{
|
||||
"💨 Your strike lands on nothing — the enemy was never quite where it looked.",
|
||||
"💨 The enemy slips the blow. Your weapon finds only the air it left behind.",
|
||||
"💨 A clean swing, a clean miss. The enemy ghosts aside at the last instant.",
|
||||
}
|
||||
|
||||
var narrativeParryStance = []string{
|
||||
"🛡️ The enemy settles into a tight defensive guard. Hits are going to come harder now.",
|
||||
"🛡️ The enemy raises its guard and *means* it — every blow from here will have to earn it.",
|
||||
"🛡️ The enemy shifts its stance, weapon angled to turn your strikes aside.",
|
||||
}
|
||||
|
||||
var narrativeAdvantage = []string{
|
||||
"🎯 The enemy reads your rhythm. Its strikes start coming with unsettling certainty.",
|
||||
"🎯 Something clicks for the enemy — it's anticipating you now, and it shows.",
|
||||
"🎯 The enemy presses an advantage you can't quite see. Its aim has sharpened.",
|
||||
}
|
||||
|
||||
var narrativeRetaliateAura = []string{
|
||||
"🔥 The enemy flares with a punishing aura. Hitting it is about to cost you.",
|
||||
"🔥 A searing field wraps the enemy — every blow you land will bite back.",
|
||||
"🔥 The enemy wreathes itself in retaliation. Strike it and you share the pain.",
|
||||
}
|
||||
|
||||
var narrativeRetaliate = []string{
|
||||
"🔥 The enemy's aura lashes back — %d damage for the privilege of hitting it.",
|
||||
"🔥 Your strike lands, and the recoil burns. %d damage bounces straight back into you.",
|
||||
"🔥 %d damage reflected. The enemy made you pay for that hit in real time.",
|
||||
}
|
||||
|
||||
var narrativeRegenerate = []string{
|
||||
"♻️ The enemy's wounds begin to close on their own. This just got slower.",
|
||||
"♻️ Torn flesh knits and seals. The enemy has started regenerating.",
|
||||
"♻️ The enemy's body refuses to stay damaged — it's healing between blows now.",
|
||||
}
|
||||
|
||||
var narrativeRegenTick = []string{
|
||||
"♻️ The enemy mends another %d HP. The damage you're doing keeps un-doing itself.",
|
||||
"♻️ +%d HP for the enemy as its wounds seal over. Frustrating.",
|
||||
"♻️ The enemy claws back %d HP. You'll have to out-pace the regeneration.",
|
||||
}
|
||||
|
||||
var narrativeSurviveArmed = []string{
|
||||
"🕯️ The enemy refuses the idea of dying. Something keeps it on its feet past where it should fall.",
|
||||
"🕯️ A grim resilience settles over the enemy — it will not go down easy.",
|
||||
"🕯️ The enemy digs in. Whatever's holding it together, it isn't ready to let go.",
|
||||
}
|
||||
|
||||
var narrativeSurvive = []string{
|
||||
"🕯️ The killing blow lands clean — and the enemy *stays standing* at 1 HP. Unbelievable.",
|
||||
"🕯️ That should have ended it. The enemy clings to a single point of HP through sheer spite.",
|
||||
"🕯️ The enemy by all rights should be down. It is, instead, very barely up.",
|
||||
}
|
||||
|
||||
var narrativeStatDrain = []string{
|
||||
"🩸 The enemy saps your strength — your swings feel heavier, weaker. (-%d hit damage)",
|
||||
"🩸 Something drains out of your limbs. Your hits won't bite as deep now. (-%d damage)",
|
||||
"🩸 The enemy leeches your vigour. -%d damage on every strike from here.",
|
||||
}
|
||||
|
||||
var narrativeDebuff = []string{
|
||||
"😖 The enemy fouls your footing — your guard slips. (-%d AC)",
|
||||
"😖 A wave of something sickly rolls over you. Harder to defend now. (-%d AC)",
|
||||
"😖 The enemy's effect frays your defence. -%d AC, and their hits will notice.",
|
||||
}
|
||||
|
||||
var narrativeMaxHPDrain = []string{
|
||||
"☠️ The enemy drains your life force — %d HP gone, and your maximum drops with it.",
|
||||
"☠️ Something cold pulls %d HP out of you and won't give it back. Your ceiling just fell.",
|
||||
"☠️ %d HP siphoned away, max and all. There's less of you to work with now.",
|
||||
}
|
||||
|
||||
var narrativeSpellResist = []string{
|
||||
"🚫 The enemy's form shimmers with anti-magic. Spells are going to land soft from here.",
|
||||
"🚫 A warding sheen wraps the enemy — magic slides off it like rain off glass.",
|
||||
"🚫 The enemy shrugs the weave aside. Whatever you cast, it'll only half-stick.",
|
||||
}
|
||||
|
||||
var narrativeSpellFizzle = []string{
|
||||
"🚫 Your spell breaks against the enemy's anti-magic — only %d damage bleeds through.",
|
||||
"🚫 The weave fizzles on contact. The enemy's resistance eats half of it; %d lands.",
|
||||
"🚫 Half your magic just evaporates. %d damage is all the enemy lets through.",
|
||||
}
|
||||
|
||||
var narrativeRevealArmed = []string{
|
||||
"👁️ The enemy's eyes track your intent — it knows what you're about to do.",
|
||||
"👁️ The enemy reads you. Your next swing is telegraphed before you've thrown it.",
|
||||
"👁️ Something behind the enemy's gaze clicks. It's already seen your next move.",
|
||||
}
|
||||
|
||||
var narrativeRevealed = []string{
|
||||
"👁️ The enemy saw it coming — your strike is forced wide before it begins.",
|
||||
"👁️ Telegraphed and countered. The enemy was already moving as you committed.",
|
||||
"👁️ Your swing meets a defence that read it a beat early. No clean angle left.",
|
||||
}
|
||||
|
||||
var narrativeFearImmune = []string{
|
||||
"🐲 The enemy's resolve is iron — nothing you do is going to rattle it.",
|
||||
"🐲 Whatever fear you were counting on, the enemy doesn't have the receptors for it.",
|
||||
"🐲 The enemy stands utterly unshaken. Control magic is going to slide right off.",
|
||||
}
|
||||
|
||||
var narrativeFearResist = []string{
|
||||
"🐲 Your control spell washes over the enemy and finds no purchase. It acts anyway.",
|
||||
"🐲 The enemy shrugs off the compulsion mid-cast and keeps coming.",
|
||||
"🐲 The spell should have held it. The enemy's will simply refuses the idea.",
|
||||
}
|
||||
|
||||
var narrativeAllyBuff = []string{
|
||||
"📢 The enemy rallies itself with a roar — its blows are about to hit harder. (+%d attack)",
|
||||
"📢 Something emboldens the enemy. Its strikes pick up weight. (+%d attack)",
|
||||
"📢 The enemy steadies and surges. Expect heavier hits from here. (+%d attack)",
|
||||
}
|
||||
|
||||
// Outcome flavor
|
||||
var narrativeNearDeathWin = []string{
|
||||
"You survived by the skin of your teeth. Barely standing. The healers are on standby.",
|
||||
@@ -586,3 +837,84 @@ var narrativeCloseLoss = []string{
|
||||
"You gave it everything. It wasn't quite sufficient. Sufficiency is the enemy's department today.",
|
||||
"The enemy earned that one. So did you. Only one of you gets paid.",
|
||||
}
|
||||
|
||||
// ── Turn-based per-round narration ───────────────────────────────────────────
|
||||
//
|
||||
// RenderTurnRound narrates one resolved round of a manual elite/boss fight.
|
||||
// Where RenderCombatLog (auto-resolve) groups a whole fight into phased,
|
||||
// multi-message blocks, a turn-based round is one message: the events from
|
||||
// player_turn → enemy_turn → round_end, in order. The shared hit/crit/miss/
|
||||
// ability events the turn engine emits through the same primitives as
|
||||
// auto-resolve fall through to renderEvent and reuse the full narrative pools,
|
||||
// so TwinBee's voice is identical across both engines. Only the four
|
||||
// turn-specific actions (flee, spell_cast, use_consumable, spell_held) get
|
||||
// their own pools here.
|
||||
//
|
||||
// A fresh actionPicker per round is intentional: a round emits at most a
|
||||
// handful of events, and across a long boss fight the picker resetting each
|
||||
// round reads as variety rather than staleness.
|
||||
func RenderTurnRound(events []CombatEvent, playerName, enemyName string) string {
|
||||
picker := newActionPicker()
|
||||
var lines []string
|
||||
for _, e := range events {
|
||||
line := renderTurnEvent(e, playerName, enemyName, picker)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if roll := rollAnnotation(e); roll != "" {
|
||||
line += " " + roll
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return "_The round passes without a clean blow landed._"
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func renderTurnEvent(e CombatEvent, playerName, enemyName string, picker *actionPicker) string {
|
||||
switch e.Action {
|
||||
case "flee":
|
||||
return pickRand(narrativeTurnFlee)
|
||||
case "spell_held":
|
||||
return pickRand(narrativeTurnSpellHeld)
|
||||
case "spell_cast":
|
||||
label := e.Desc
|
||||
if label == "" {
|
||||
label = "a spell"
|
||||
}
|
||||
return fmt.Sprintf(pickRand(narrativeTurnSpellCast), label)
|
||||
case "use_consumable":
|
||||
label := e.Desc
|
||||
if label == "" {
|
||||
label = "an item"
|
||||
}
|
||||
return fmt.Sprintf(pickRand(narrativeTurnConsumable), label)
|
||||
}
|
||||
return renderEvent(e, playerName, enemyName, CombatResult{}, picker)
|
||||
}
|
||||
|
||||
var narrativeTurnFlee = []string{
|
||||
"🏃 You break off and run. No shame in it — the dead don't get a sequel.",
|
||||
"🏃 You decide this fight isn't yours to win and make for the exit. I respect the math.",
|
||||
"🏃 You disengage and bolt. The enemy doesn't chase. The enemy didn't have to.",
|
||||
}
|
||||
|
||||
var narrativeTurnSpellHeld = []string{
|
||||
"🌀 The enemy strains against the spell and gets nowhere. No attack this round.",
|
||||
"🌀 Held fast. The enemy's turn dissolves into furious, useless effort.",
|
||||
"🌀 The control holds. The enemy spends the round being a statue with opinions.",
|
||||
}
|
||||
|
||||
var narrativeTurnSpellCast = []string{
|
||||
"✨ You loose the spell. **%s**.",
|
||||
"✨ The weave answers. **%s**.",
|
||||
"✨ Words, gesture, intent — **%s**.",
|
||||
"✨ You spend the magic where it counts. **%s**.",
|
||||
}
|
||||
|
||||
var narrativeTurnConsumable = []string{
|
||||
"🧪 You crack it open mid-fight. **%s**.",
|
||||
"🧪 No time to be precious about it. **%s**.",
|
||||
"🧪 You burn a consumable on the spot. **%s**.",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package plugin
|
||||
|
||||
// Shared combat resolution primitives.
|
||||
//
|
||||
// These are the building blocks both the auto-resolve engine (SimulateCombat)
|
||||
// and the upcoming turn-based engine call into, so a single attack resolves
|
||||
// identically regardless of which loop drives it. Everything here is a
|
||||
// behavior-preserving extraction from combat_engine.go — the combat
|
||||
// characterization golden file pins that no event stream shifted.
|
||||
|
||||
// effectiveAttackBonus returns the d20 attack bonus for a combatant, applying
|
||||
// the appendix §8 non-proficient-weapon penalty (-4) when the combatant wields
|
||||
// a D&D weapon without proficiency. Monsters and weaponless combatants have no
|
||||
// Weapon set, so they fall through to the raw bonus.
|
||||
func effectiveAttackBonus(s CombatStats) int {
|
||||
ab := s.AttackBonus
|
||||
if s.Weapon != nil && !s.WeaponProficient {
|
||||
ab -= 4
|
||||
}
|
||||
return ab
|
||||
}
|
||||
|
||||
// attackCritFloor returns the lowest d20 face that counts as a critical hit.
|
||||
// Default 20; Champion Improved Critical (and later tiers) lower it via
|
||||
// CritThreshold. A zero or out-of-range CritThreshold means "use default".
|
||||
func attackCritFloor(m CombatModifiers) int {
|
||||
if m.CritThreshold > 0 && m.CritThreshold < 20 {
|
||||
return m.CritThreshold
|
||||
}
|
||||
return 20
|
||||
}
|
||||
|
||||
// attackConnects decides whether a d20 attack lands. roll is the raw d20 face
|
||||
// (post-reroll), total is roll+bonus, ac is the defender's armor class, and
|
||||
// critFloor is the crit threshold from attackCritFloor. A raw 1 always misses
|
||||
// (fumble); a roll at/above critFloor always hits; otherwise total must meet AC.
|
||||
func attackConnects(roll, total, ac, critFloor int) bool {
|
||||
if roll == 1 {
|
||||
return false
|
||||
}
|
||||
if roll >= critFloor {
|
||||
return true
|
||||
}
|
||||
return total >= ac
|
||||
}
|
||||
|
||||
// applyPlayerHitDamageMods runs the post-hit player damage stack on a connected
|
||||
// attack: crit doubling (natural or AutoCritFirst consumable), Orc Rage, the
|
||||
// Berserker rage flat+multiplicative bumps, Cleric Divine Strike, and the
|
||||
// Assassinate first-hit bonus. It consumes the relevant one-shot state on st
|
||||
// (autoCrit, pendingRageAttack, assassinateBonusUsed) and returns the final
|
||||
// damage plus the event action/desc labels. dmg is clamped to >=1.
|
||||
func applyPlayerHitDamageMods(st *combatState, player *Combatant, dmg int, isCritRoll, blocked bool) (int, string, string) {
|
||||
isCrit := isCritRoll
|
||||
autoCritFired := false
|
||||
if !isCritRoll && st.autoCrit {
|
||||
isCrit = true
|
||||
autoCritFired = true
|
||||
st.autoCrit = false
|
||||
} else if st.autoCrit && isCritRoll {
|
||||
// Natural crit consumes the auto-crit charge but doesn't get the
|
||||
// "passive fired" flavor — the dice already explain it.
|
||||
st.autoCrit = false
|
||||
}
|
||||
if isCrit {
|
||||
// Crit: double damage. (5e rolls extra dice; we double total to
|
||||
// match the engine's pre-Phase-8 crit semantics.)
|
||||
dmg *= 2
|
||||
}
|
||||
// Orc Rage: +50% damage on this attack, then consume.
|
||||
if st.pendingRageAttack {
|
||||
dmg = int(float64(dmg) * 1.5)
|
||||
st.pendingRageAttack = false
|
||||
}
|
||||
// Phase 10 SUB2a — Berserker rage: +flat per hit, plus Frenzy
|
||||
// multiplicative bump that approximates the bonus-attack-per-turn we
|
||||
// can't model in one-shot combat.
|
||||
if player.Mods.BerserkerRage {
|
||||
if player.Mods.RageMeleeDmg > 0 {
|
||||
dmg += player.Mods.RageMeleeDmg
|
||||
}
|
||||
if player.Mods.FrenzyDmgBonus > 0 {
|
||||
dmg = int(float64(dmg) * (1 + player.Mods.FrenzyDmgBonus))
|
||||
}
|
||||
}
|
||||
// Phase 10 SUB3c — Divine Strike. Flat per-hit bonus on weapon hits only
|
||||
// (5e specs "weapon hit"; no Weapon means we're on the legacy/non-weapon
|
||||
// damage path and Divine Strike doesn't apply). Lands every hit because
|
||||
// our 1v1 model has no concept of "once per turn" turn boundaries.
|
||||
if player.Mods.DivineStrikePerHit > 0 && player.Stats.Weapon != nil {
|
||||
dmg += player.Mods.DivineStrikePerHit
|
||||
}
|
||||
// Class-identity audit (2026-05-16) — Rogue Sneak Attack. Add Nd6 on
|
||||
// every player hit. Same "once per turn = every hit" convention as
|
||||
// Divine Strike. Rogues swing once per round so this naturally lands
|
||||
// once/round at the cadence 5e expects. The bonus rides through the
|
||||
// crit doubling above (already applied to dmg) so a sneak-attack crit
|
||||
// reads correctly: doubled weapon dmg, then +Nd6 unaffected by the
|
||||
// crit. (5e doubles the sneak attack dice on crit too — we under-
|
||||
// shoot by ~Nd6 on the rare crit, which is acceptable for now.)
|
||||
if player.Mods.SneakAttackDie > 0 {
|
||||
for i := 0; i < player.Mods.SneakAttackDie; i++ {
|
||||
dmg += 1 + st.roll(6)
|
||||
}
|
||||
}
|
||||
// Class-identity audit (2026-05-16) — Ranger Hunter's Mark. +Nd6 on
|
||||
// every player hit. The implicit "you marked this enemy on round 0"
|
||||
// model: in 1v1, the only foe is always the marked one.
|
||||
if player.Mods.HuntersMarkDie > 0 {
|
||||
for i := 0; i < player.Mods.HuntersMarkDie; i++ {
|
||||
dmg += 1 + st.roll(6)
|
||||
}
|
||||
}
|
||||
// Phase 10 SUB2a-ii — Assassin Death Strike proxy: bonus damage on the
|
||||
// first hit only. Stacks on top of the Rogue's Sneak Attack auto-crit
|
||||
// (which already doubled the base damage above) — the bonus itself is
|
||||
// applied AFTER the crit doubling so the math is "double base + flat
|
||||
// surprise damage". Consumed on first hit regardless of crit status.
|
||||
if player.Mods.AssassinateBonusDmg > 0 && !st.assassinateBonusUsed {
|
||||
dmg += player.Mods.AssassinateBonusDmg
|
||||
st.assassinateBonusUsed = true
|
||||
}
|
||||
dmg = max(1, dmg)
|
||||
|
||||
action := "hit"
|
||||
desc := ""
|
||||
if isCrit {
|
||||
action = "crit"
|
||||
if autoCritFired {
|
||||
desc = "auto_crit"
|
||||
}
|
||||
} else if blocked {
|
||||
action = "block"
|
||||
}
|
||||
return dmg, action, desc
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase 13 — turn-based combat timeout reaper.
|
||||
//
|
||||
// A CombatSession expires 1h after its last action (combatSessionTTL). Per the
|
||||
// 2026-05-13 timeout decision, an abandoned fight is not flatly marked as a
|
||||
// retreat — the reaper resumes it from persisted mid-state and auto-plays the
|
||||
// rest of the fight through the same shared resolver a live !attack uses,
|
||||
// landing on a real win or loss. The player is DM'd the outcome.
|
||||
//
|
||||
// Sessions that cannot be reconstructed (zone run deleted, enemy no longer in
|
||||
// the bestiary) or that fail to converge within reaperRoundCap fall back to the
|
||||
// terminal 'expired' status with no fatal-blow side effects — see
|
||||
// markCombatSessionExpired.
|
||||
|
||||
// reaperRoundCap bounds the auto-play loop. A turn-based round always lands at
|
||||
// least one damage roll eventually, so a real fight converges well within this;
|
||||
// the cap only guards against a pathological non-terminating session.
|
||||
const reaperRoundCap = 300
|
||||
|
||||
// reapExpiredCombatSessions is the timeout reaper, driven off eventTicker (one
|
||||
// pass per minute). It auto-plays every stale session to a terminal status.
|
||||
func (p *AdventurePlugin) reapExpiredCombatSessions() {
|
||||
expired, err := listExpiredCombatSessions()
|
||||
if err != nil {
|
||||
slog.Error("combat: reaper failed to list expired sessions", "err", err)
|
||||
return
|
||||
}
|
||||
for _, sess := range expired {
|
||||
p.reapCombatSession(sess.UserID, sess.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// reapCombatSession auto-plays a single stale session under the player's lock.
|
||||
// It re-fetches the session after locking: the player may have finished the
|
||||
// fight in the window between listing and locking, in which case there is
|
||||
// nothing to do.
|
||||
func (p *AdventurePlugin) reapCombatSession(userIDStr, sessionID string) {
|
||||
userID := id.UserID(userIDStr)
|
||||
userMu := p.advUserLock(userID)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
sess, err := getActiveCombatSession(userID)
|
||||
if err != nil {
|
||||
slog.Error("combat: reaper failed to reload session", "user", userID, "err", err)
|
||||
return
|
||||
}
|
||||
// Gone, replaced, or no longer the stale one — the player acted first.
|
||||
if sess == nil || sess.SessionID != sessionID {
|
||||
return
|
||||
}
|
||||
|
||||
player, enemy, err := p.combatantsForSession(sess)
|
||||
if err != nil {
|
||||
// Can't reconstruct the fight — park it terminal so it isn't retried
|
||||
// every minute forever.
|
||||
slog.Warn("combat: reaper cannot rebuild session, marking expired",
|
||||
"user", userID, "session", sess.SessionID, "err", err)
|
||||
if merr := markCombatSessionExpired(sess.SessionID); merr != nil {
|
||||
slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
rounds := 0
|
||||
for sess.IsActive() {
|
||||
if rounds >= reaperRoundCap {
|
||||
slog.Warn("combat: reaper hit round cap, marking expired",
|
||||
"user", userID, "session", sess.SessionID)
|
||||
if merr := markCombatSessionExpired(sess.SessionID); merr != nil {
|
||||
slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, rerr := runCombatRound(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}); rerr != nil {
|
||||
slog.Error("combat: reaper round failed", "user", userID, "session", sess.SessionID, "err", rerr)
|
||||
if merr := markCombatSessionExpired(sess.SessionID); merr != nil {
|
||||
slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr)
|
||||
}
|
||||
return
|
||||
}
|
||||
rounds++
|
||||
}
|
||||
|
||||
outcome := p.finishCombatSession(userID, sess, enemy)
|
||||
preamble := fmt.Sprintf("⏳ Your fight with **%s** timed out — I finished it for you.\n\n", enemy.Name)
|
||||
if err := p.SendDM(userID, preamble+outcome); err != nil {
|
||||
slog.Error("combat: reaper failed to DM outcome", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
cryptorand "crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase 13 — Turn-based combat session persistence.
|
||||
//
|
||||
// One persisted CombatSession per manual elite/boss fight. The schema
|
||||
// (combat_session in db.go) was added in the prior commit; this layer adds
|
||||
// the struct, accessors, and the timeout reaper, mirroring the dnd_expedition
|
||||
// persistence shape. The intra-round phase state machine that drives a fight
|
||||
// across these rows lives in combat_turn_engine.go.
|
||||
|
||||
// Combat session lifecycle status (combat_session.status).
|
||||
const (
|
||||
CombatStatusActive = "active"
|
||||
CombatStatusWon = "won"
|
||||
CombatStatusLost = "lost"
|
||||
CombatStatusFled = "fled"
|
||||
CombatStatusExpired = "expired"
|
||||
)
|
||||
|
||||
// Intra-round phase state-machine positions (combat_session.phase).
|
||||
const (
|
||||
CombatPhasePlayerTurn = "player_turn"
|
||||
CombatPhaseEnemyTurn = "enemy_turn"
|
||||
CombatPhaseRoundEnd = "round_end"
|
||||
CombatPhaseOver = "over"
|
||||
)
|
||||
|
||||
// combatSessionTTL is how long a session may sit idle before the timeout
|
||||
// reaper sweeps it. Matches the started_at + 1h note in the schema.
|
||||
const combatSessionTTL = time.Hour
|
||||
|
||||
// CombatStatuses is the serialized between-round effect state for a fight: the
|
||||
// subset of combatState that genuinely persists round-to-round, plus the
|
||||
// fight-scoped buffs a mid-fight !cast / !consume layers on. Everything here
|
||||
// round-trips combatState -> Statuses -> combatState across every engine step,
|
||||
// so a fight resumes from exact mid-state after a suspend or bot restart.
|
||||
//
|
||||
// All fields are scalar by design — CombatStatuses must stay comparable so the
|
||||
// persistence round-trip can be asserted with ==.
|
||||
type CombatStatuses struct {
|
||||
// Monster-ability effects — the original between-round persistence set.
|
||||
PoisonTicks int `json:"poison_ticks,omitempty"`
|
||||
PoisonDmg int `json:"poison_dmg,omitempty"`
|
||||
StunPlayer bool `json:"stun_player,omitempty"`
|
||||
Enraged bool `json:"enraged,omitempty"`
|
||||
ArmorBroken bool `json:"armor_broken,omitempty"`
|
||||
ArmorBreakAmt float64 `json:"armor_break_amt,omitempty"`
|
||||
// EnemySkipNext carries a control-spell skip (Hold Person, Sleep) from the
|
||||
// player_turn phase it was cast in to the enemy_turn phase that consumes it
|
||||
// — those phases resolve as separate engine steps with separate in-memory
|
||||
// combatState, so the flag must survive the commit between them.
|
||||
EnemySkipNext bool `json:"enemy_skip_next,omitempty"`
|
||||
|
||||
// PetProcReady is the per-fight pet-attack outcome. Auto-resolve rolls the
|
||||
// pet proc every round; a manual fight can run many rounds, so the roll is
|
||||
// decided once at fight start (rollCombatSessionPetProc) and parked here.
|
||||
// The pet then lands a single hit on the player's first acting turn, which
|
||||
// clears the flag — persisted so a suspend/resume or reaper auto-play sees
|
||||
// the same outcome.
|
||||
PetProcReady bool `json:"pet_proc_ready,omitempty"`
|
||||
|
||||
// Fight-scoped depleting resources — mirror the combatState charges that
|
||||
// genuinely carry round-to-round. Seeded at fight start (Arcane Ward) or by
|
||||
// a mid-fight !cast / !consume, restored into combatState on every resume,
|
||||
// written back on every commit so a depleting charge can't silently reset
|
||||
// when a fight is suspended and resumed.
|
||||
WardCharges int `json:"ward_charges,omitempty"`
|
||||
SporeRounds int `json:"spore_rounds,omitempty"`
|
||||
ReflectFrac float64 `json:"reflect_frac,omitempty"`
|
||||
AutoCritFirst bool `json:"auto_crit_first,omitempty"`
|
||||
ArcaneWardHP int `json:"arcane_ward_hp,omitempty"`
|
||||
HealChargesLeft int `json:"heal_charges_left,omitempty"`
|
||||
|
||||
// Once-per-fight class/race/subclass one-shots: the "already used" flags.
|
||||
// Without persistence these reset every round on resume, letting a Halfling
|
||||
// reroll a nat 1 or an Orc rage every single round of a turn-based fight.
|
||||
DeathSaveUsed bool `json:"death_save_used,omitempty"`
|
||||
LuckyUsed bool `json:"lucky_used,omitempty"`
|
||||
Raged bool `json:"raged,omitempty"`
|
||||
PendingRage bool `json:"pending_rage,omitempty"`
|
||||
FirstAtkBonusUsed bool `json:"first_atk_bonus_used,omitempty"`
|
||||
AssassinateReroll bool `json:"assassinate_reroll_used,omitempty"`
|
||||
AssassinateBonus bool `json:"assassinate_bonus_used,omitempty"`
|
||||
|
||||
// Slice-3 stateful monster-ability effects — armed by applyAbility, read by
|
||||
// the shared resolution primitives, round-tripped through combatState so a
|
||||
// suspended/resumed fight (or a reaper auto-play) keeps the same effect
|
||||
// state. EnemyEvadeNext is a one-shot; the rest persist for the fight.
|
||||
EnemyEvadeNext bool `json:"enemy_evade_next,omitempty"`
|
||||
EnemyBlockUp bool `json:"enemy_block_up,omitempty"`
|
||||
EnemyAdvantage bool `json:"enemy_advantage,omitempty"`
|
||||
EnemyRetaliateFrac float64 `json:"enemy_retaliate_frac,omitempty"`
|
||||
EnemyRegen int `json:"enemy_regen,omitempty"`
|
||||
EnemySurviveArmed bool `json:"enemy_survive_armed,omitempty"`
|
||||
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
|
||||
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
|
||||
MaxHPDrain int `json:"max_hp_drain,omitempty"`
|
||||
|
||||
// Slice-4 monster-ability effects — the former flavor-only placeholders.
|
||||
// EnemyRevealNext is a one-shot; the other three persist for the fight.
|
||||
EnemySpellResist bool `json:"enemy_spell_resist,omitempty"`
|
||||
EnemyRevealNext bool `json:"enemy_reveal_next,omitempty"`
|
||||
EnemyFearImmune bool `json:"enemy_fear_immune,omitempty"`
|
||||
EnemyAtkBuff int `json:"enemy_atk_buff,omitempty"`
|
||||
|
||||
// Persistent stat buffs from mid-fight !cast / !consume, accumulated as
|
||||
// deltas against the freshly-rebuilt combatant. applySessionBuffs folds
|
||||
// these back onto the player every round; diffTurnBuff produces them.
|
||||
// BuffDamageReductMul is multiplicative (0 = none / 1.0 neutral).
|
||||
BuffACBonus int `json:"buff_ac_bonus,omitempty"`
|
||||
BuffAtkBonus int `json:"buff_atk_bonus,omitempty"`
|
||||
BuffSpeedBonus int `json:"buff_speed_bonus,omitempty"`
|
||||
BuffPetDmg int `json:"buff_pet_dmg,omitempty"`
|
||||
BuffCritRate float64 `json:"buff_crit_rate,omitempty"`
|
||||
BuffDamageBonus float64 `json:"buff_damage_bonus,omitempty"`
|
||||
BuffPetProc float64 `json:"buff_pet_proc,omitempty"`
|
||||
BuffDamageReductMul float64 `json:"buff_damage_reduct_mul,omitempty"`
|
||||
}
|
||||
|
||||
// applyBuffDelta folds one resolved buff (the result of a !cast / !consume
|
||||
// player turn) into the persisted fight-scoped state. Stat components
|
||||
// accumulate as deltas; depleting resources add to their running counters.
|
||||
func (s *CombatStatuses) applyBuffDelta(d turnBuffDelta) {
|
||||
s.BuffACBonus += d.dAC
|
||||
s.BuffAtkBonus += d.dAtk
|
||||
s.BuffSpeedBonus += d.dSpeed
|
||||
s.BuffPetDmg += d.dPetDmg
|
||||
s.BuffCritRate += d.dCrit
|
||||
s.BuffDamageBonus += d.dDmgBonus
|
||||
s.BuffPetProc += d.dPetProc
|
||||
if d.dReductMul > 0 && d.dReductMul != 1 {
|
||||
if s.BuffDamageReductMul == 0 {
|
||||
s.BuffDamageReductMul = d.dReductMul
|
||||
} else {
|
||||
s.BuffDamageReductMul *= d.dReductMul
|
||||
}
|
||||
}
|
||||
s.WardCharges += d.ward
|
||||
s.SporeRounds += d.spore
|
||||
s.ReflectFrac += d.reflect
|
||||
if d.autoCrit {
|
||||
s.AutoCritFirst = true
|
||||
}
|
||||
s.ArcaneWardHP += d.arcaneWard
|
||||
}
|
||||
|
||||
// seedCombatSessionOneShots copies the fight-start one-shot resources off the
|
||||
// freshly-built player combatant into the session's persisted statuses. Only
|
||||
// the Abjuration Arcane Ward is normally non-zero at fight start — the
|
||||
// turn-based build deliberately omits pre-combat consumables and queued casts —
|
||||
// but the full set is seeded for robustness. Returns true if anything was set.
|
||||
func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) bool {
|
||||
st := &s.Statuses
|
||||
st.WardCharges = playerMods.WardCharges
|
||||
st.SporeRounds = playerMods.SporeCloud
|
||||
st.ReflectFrac = playerMods.ReflectNext
|
||||
st.AutoCritFirst = playerMods.AutoCritFirst
|
||||
st.ArcaneWardHP = playerMods.ArcaneWardHP
|
||||
st.HealChargesLeft = playerMods.HealItemCharges
|
||||
return st.WardCharges != 0 || st.SporeRounds != 0 || st.ReflectFrac != 0 ||
|
||||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0
|
||||
}
|
||||
|
||||
// CombatSession is the in-memory shape of a combat_session row.
|
||||
type CombatSession struct {
|
||||
SessionID string
|
||||
UserID string
|
||||
RunID string
|
||||
EncounterID string
|
||||
EnemyID string
|
||||
Round int
|
||||
Phase string
|
||||
PlayerHP int
|
||||
PlayerHPMax int
|
||||
EnemyHP int
|
||||
EnemyHPMax int
|
||||
Statuses CombatStatuses
|
||||
TurnLog []CombatEvent
|
||||
Status string
|
||||
StartedAt time.Time
|
||||
LastActionAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// IsActive reports whether the fight is still in flight.
|
||||
func (s *CombatSession) IsActive() bool { return s.Status == CombatStatusActive }
|
||||
|
||||
// Errors returned by the combat session layer.
|
||||
var (
|
||||
ErrCombatSessionAlreadyActive = errors.New("combat session already active for player")
|
||||
ErrNoActiveCombatSession = errors.New("no active combat session for player")
|
||||
)
|
||||
|
||||
// combatSessionCols is the shared SELECT column list, kept in sync with
|
||||
// scanCombatSession's Scan order.
|
||||
const combatSessionCols = `
|
||||
session_id, user_id, run_id, encounter_id, enemy_id,
|
||||
round, phase, player_hp, player_hp_max, enemy_hp, enemy_hp_max,
|
||||
statuses_json, turn_log_json, status,
|
||||
started_at, last_action_at, expires_at`
|
||||
|
||||
// newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions.
|
||||
func newCombatSessionID() string {
|
||||
var b [8]byte
|
||||
if _, err := cryptorand.Read(b[:]); err != nil {
|
||||
// Vanishingly unlikely; fall through with a zeroed prefix.
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// startCombatSession opens a new manual fight for the player. The caller has
|
||||
// already resolved the encounter and built the enemy/player HP pools. At most
|
||||
// one row per user may be 'active' — enforced here, not by a DB constraint.
|
||||
func startCombatSession(userID id.UserID, runID, encounterID, enemyID string, playerHP, playerHPMax, enemyHP, enemyHPMax int) (*CombatSession, error) {
|
||||
existing, err := getActiveCombatSession(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, ErrCombatSessionAlreadyActive
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
s := &CombatSession{
|
||||
SessionID: newCombatSessionID(),
|
||||
UserID: string(userID),
|
||||
RunID: runID,
|
||||
EncounterID: encounterID,
|
||||
EnemyID: enemyID,
|
||||
Round: 1,
|
||||
Phase: CombatPhasePlayerTurn,
|
||||
PlayerHP: playerHP,
|
||||
PlayerHPMax: playerHPMax,
|
||||
EnemyHP: enemyHP,
|
||||
EnemyHPMax: enemyHPMax,
|
||||
TurnLog: []CombatEvent{},
|
||||
Status: CombatStatusActive,
|
||||
StartedAt: now,
|
||||
LastActionAt: now,
|
||||
ExpiresAt: now.Add(combatSessionTTL),
|
||||
}
|
||||
statusesJSON, _ := json.Marshal(s.Statuses)
|
||||
logJSON, _ := json.Marshal(s.TurnLog)
|
||||
|
||||
if _, err := db.Get().Exec(`
|
||||
INSERT INTO combat_session
|
||||
(session_id, user_id, run_id, encounter_id, enemy_id,
|
||||
round, phase, player_hp, player_hp_max, enemy_hp, enemy_hp_max,
|
||||
statuses_json, turn_log_json, status,
|
||||
started_at, last_action_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`,
|
||||
s.SessionID, s.UserID, s.RunID, s.EncounterID, s.EnemyID,
|
||||
s.Phase, s.PlayerHP, s.PlayerHPMax, s.EnemyHP, s.EnemyHPMax,
|
||||
string(statusesJSON), string(logJSON), now, now, s.ExpiresAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert combat session: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
|
||||
func getActiveCombatSession(userID id.UserID) (*CombatSession, error) {
|
||||
row := db.Get().QueryRow(`SELECT `+combatSessionCols+`
|
||||
FROM combat_session
|
||||
WHERE user_id = ? AND status = 'active'
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1`, string(userID))
|
||||
s, err := scanCombatSession(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return s, err
|
||||
}
|
||||
|
||||
// hasActiveCombatSession reports whether the player is currently locked into a
|
||||
// turn-based elite/boss fight. Daily-cron and periodic-ticker paths consult
|
||||
// this to avoid firing DMs or mutating run state into a live fight: an active
|
||||
// session locks the run (no `!zone advance`), so the player can't move it
|
||||
// forward and a cron path shouldn't either. The session carries a 1h TTL, so
|
||||
// the collision window is bounded — but real, since a fight can straddle any
|
||||
// scheduled tick. On a lookup error this returns false (fail-open): a missed
|
||||
// guard is a confusing DM, not corruption.
|
||||
func hasActiveCombatSession(userID id.UserID) bool {
|
||||
s, err := getActiveCombatSession(userID)
|
||||
if err != nil {
|
||||
slog.Warn("combat: cron guard failed to check active session", "user", userID, "err", err)
|
||||
return false
|
||||
}
|
||||
return s != nil
|
||||
}
|
||||
|
||||
// getCombatSession fetches by ID regardless of status. Test/admin/reaper use.
|
||||
func getCombatSession(sessionID string) (*CombatSession, error) {
|
||||
row := db.Get().QueryRow(`SELECT `+combatSessionCols+`
|
||||
FROM combat_session WHERE session_id = ?`, sessionID)
|
||||
s, err := scanCombatSession(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return s, err
|
||||
}
|
||||
|
||||
// getCombatSessionForEncounter returns the most recent session for a
|
||||
// (run, encounter) pair regardless of status, or (nil, nil) if none exists.
|
||||
// The room-resolution path uses it to tell "elite not yet fought" (nil) apart
|
||||
// from "elite already won" (a terminal session) without an active-only filter.
|
||||
func getCombatSessionForEncounter(runID, encounterID string) (*CombatSession, error) {
|
||||
row := db.Get().QueryRow(`SELECT `+combatSessionCols+`
|
||||
FROM combat_session
|
||||
WHERE run_id = ? AND encounter_id = ?
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1`, runID, encounterID)
|
||||
s, err := scanCombatSession(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return s, err
|
||||
}
|
||||
|
||||
func scanCombatSession(row scanner) (*CombatSession, error) {
|
||||
var (
|
||||
s CombatSession
|
||||
statusesJSON string
|
||||
logJSON string
|
||||
)
|
||||
if err := row.Scan(
|
||||
&s.SessionID, &s.UserID, &s.RunID, &s.EncounterID, &s.EnemyID,
|
||||
&s.Round, &s.Phase, &s.PlayerHP, &s.PlayerHPMax, &s.EnemyHP, &s.EnemyHPMax,
|
||||
&statusesJSON, &logJSON, &s.Status,
|
||||
&s.StartedAt, &s.LastActionAt, &s.ExpiresAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if statusesJSON != "" {
|
||||
if err := json.Unmarshal([]byte(statusesJSON), &s.Statuses); err != nil {
|
||||
return nil, fmt.Errorf("decode statuses_json: %w", err)
|
||||
}
|
||||
}
|
||||
if logJSON != "" {
|
||||
if err := json.Unmarshal([]byte(logJSON), &s.TurnLog); err != nil {
|
||||
return nil, fmt.Errorf("decode turn_log_json: %w", err)
|
||||
}
|
||||
}
|
||||
if s.TurnLog == nil {
|
||||
s.TurnLog = []CombatEvent{}
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// saveCombatSession persists the mutable fields after a state-machine step.
|
||||
// session_id / user_id / run_id / encounter_id / enemy_id / *_hp_max are
|
||||
// immutable for a fight's lifetime and are not rewritten.
|
||||
func saveCombatSession(s *CombatSession) error {
|
||||
statusesJSON, _ := json.Marshal(s.Statuses)
|
||||
logJSON, _ := json.Marshal(s.TurnLog)
|
||||
now := time.Now().UTC()
|
||||
if _, err := db.Get().Exec(`
|
||||
UPDATE combat_session
|
||||
SET round = ?, phase = ?,
|
||||
player_hp = ?, enemy_hp = ?,
|
||||
statuses_json = ?, turn_log_json = ?,
|
||||
status = ?, last_action_at = ?
|
||||
WHERE session_id = ?`,
|
||||
s.Round, s.Phase, s.PlayerHP, s.EnemyHP,
|
||||
string(statusesJSON), string(logJSON), s.Status, now, s.SessionID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
s.LastActionAt = now
|
||||
return nil
|
||||
}
|
||||
|
||||
// listExpiredCombatSessions returns every active session past its expires_at.
|
||||
// The timeout reaper (reapExpiredCombatSessions) auto-plays each of these to a
|
||||
// real win/loss from persisted mid-state — per the 2026-05-13 decision, an
|
||||
// abandoned fight is finished by the engine, not flatly marked as a retreat.
|
||||
func listExpiredCombatSessions() ([]*CombatSession, error) {
|
||||
rows, err := db.Get().Query(`SELECT `+combatSessionCols+`
|
||||
FROM combat_session
|
||||
WHERE status = 'active'
|
||||
AND expires_at <= CURRENT_TIMESTAMP
|
||||
ORDER BY started_at ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*CombatSession
|
||||
for rows.Next() {
|
||||
s, err := scanCombatSession(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// markCombatSessionExpired is the fallback terminal outcome for a stale session
|
||||
// the reaper cannot auto-play (zone run gone, enemy missing from the bestiary,
|
||||
// or a runaway fight that won't converge). It parks the row in 'expired'/'over'
|
||||
// with no fatal-blow side effects — treated like a retreat.
|
||||
func markCombatSessionExpired(sessionID string) error {
|
||||
_, err := db.Get().Exec(`
|
||||
UPDATE combat_session
|
||||
SET status = 'expired',
|
||||
phase = 'over',
|
||||
last_action_at = CURRENT_TIMESTAMP
|
||||
WHERE session_id = ? AND status = 'active'`, sessionID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase 13 — Combatant reconstruction for turn-based fights.
|
||||
//
|
||||
// The auto-resolve path (runZoneCombat) builds a player/enemy Combatant pair,
|
||||
// runs SimulateCombat, and discards them. The turn-based path needs the same
|
||||
// pair, but rebuilt fresh on every player command from persisted session
|
||||
// state — so the build is extracted here as buildZoneCombatants, and
|
||||
// combatantsForSession wraps it for the resume case.
|
||||
//
|
||||
// HP is deliberately NOT carried by the Combatant pair: the turn engine reads
|
||||
// live HP from the CombatSession row (sess.PlayerHP / sess.EnemyHP), so the
|
||||
// rebuilt Combatants only need correct Stats/Mods/Ability. Re-deriving from
|
||||
// current character state each round is safe because zone equipment, streaks,
|
||||
// and bonuses don't change mid-fight.
|
||||
//
|
||||
// applyPendingCast and the auto-heal consumable setup are intentionally left
|
||||
// out of the shared builder — those are one-shot mutations that runZoneCombat
|
||||
// applies once before SimulateCombat. Re-applying them on every turn-based
|
||||
// round would double-consume. Mid-fight !cast / !consume buffs are instead
|
||||
// persisted on the CombatSession and folded back in by combatantsForSession
|
||||
// via applySessionBuffs.
|
||||
|
||||
// buildZoneCombatants derives the player/enemy Combatant pair for a zone
|
||||
// encounter: the player's full D&D layer (stats + class/race/subclass passives
|
||||
// + armed ability) and the monster's bestiary stat block with tier scaling and
|
||||
// the DM-mood combat tilt folded in. The returned dndChar is handed back so
|
||||
// callers can run post-combat subclass persistence without reloading it.
|
||||
func (p *AdventurePlugin) buildZoneCombatants(
|
||||
userID id.UserID,
|
||||
monster DnDMonsterTemplate,
|
||||
tier int,
|
||||
dmMood int,
|
||||
) (player Combatant, enemy Combatant, dndChar *DnDCharacter, err error) {
|
||||
tilt := dmMoodCombatTilt(dmMood)
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char == nil {
|
||||
return Combatant{}, Combatant{}, nil, fmt.Errorf("load adv character: %w", err)
|
||||
}
|
||||
equip, err := loadAdvEquipment(userID)
|
||||
if err != nil {
|
||||
return Combatant{}, Combatant{}, nil, fmt.Errorf("load equipment: %w", err)
|
||||
}
|
||||
bonuses := p.loadCombatBonuses(userID, char)
|
||||
chatLvl := p.chatLevel(userID)
|
||||
|
||||
playerStats, playerMods := DerivePlayerStats(char, equip, bonuses, chatLvl, char.CurrentStreak, false)
|
||||
|
||||
dndChar, _, err = ensureDnDCharacterForCombat(userID, char)
|
||||
if err != nil {
|
||||
return Combatant{}, Combatant{}, nil, fmt.Errorf("ensure dnd character: %w", err)
|
||||
}
|
||||
applyDnDPlayerLayer(&playerStats, dndChar)
|
||||
applyDnDEquipmentLayer(&playerStats, dndChar, equip)
|
||||
applyDnDHPScaling(&playerStats, dndChar)
|
||||
applyClassPassives(&playerStats, &playerMods, dndChar)
|
||||
applyRacePassives(&playerStats, &playerMods, dndChar)
|
||||
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
||||
trySimAutoArm(dndChar)
|
||||
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
||||
slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName)
|
||||
}
|
||||
|
||||
enemyStats, enemyMods := monster.toCombatStats()
|
||||
// Tier scaling mirrors runZoneCombat: only raise AC/AttackBonus to a
|
||||
// tier floor — boss/elite stat blocks already encode their challenge, so
|
||||
// we never double-scale a block that's already above the floor.
|
||||
if tier > 1 {
|
||||
floorAC := dndDungeonACBase + tier
|
||||
if enemyStats.AC < floorAC {
|
||||
enemyStats.AC = floorAC
|
||||
}
|
||||
floorAB := dndDungeonAtkBase + tier
|
||||
if enemyStats.AttackBonus < floorAB {
|
||||
enemyStats.AttackBonus = floorAB
|
||||
}
|
||||
}
|
||||
|
||||
// DM mood tilts: monster Attack delta + player initiative bias.
|
||||
enemyStats.Attack += tilt.EnemyAttackDelta
|
||||
if enemyStats.Attack < 1 {
|
||||
enemyStats.Attack = 1
|
||||
}
|
||||
playerMods.InitiativeBias += tilt.InitiativeBias
|
||||
|
||||
displayName, _ := loadDisplayName(userID)
|
||||
player = Combatant{
|
||||
Name: displayName,
|
||||
Stats: playerStats,
|
||||
Mods: playerMods,
|
||||
IsPlayer: true,
|
||||
}
|
||||
enemy = Combatant{
|
||||
Name: monster.Name,
|
||||
Stats: enemyStats,
|
||||
Mods: enemyMods,
|
||||
Ability: monster.Ability,
|
||||
}
|
||||
return player, enemy, dndChar, nil
|
||||
}
|
||||
|
||||
// combatantsForSession reconstructs the Combatant pair for an in-flight
|
||||
// CombatSession. It reloads the zone run for the DM mood + tier and looks the
|
||||
// enemy up in the bestiary by the session's EnemyID. The pair carries no HP —
|
||||
// the turn engine reads that from the session row.
|
||||
func (p *AdventurePlugin) combatantsForSession(sess *CombatSession) (player Combatant, enemy Combatant, err error) {
|
||||
run, rerr := getZoneRun(sess.RunID)
|
||||
if rerr != nil {
|
||||
return Combatant{}, Combatant{}, fmt.Errorf("load zone run: %w", rerr)
|
||||
}
|
||||
if run == nil {
|
||||
return Combatant{}, Combatant{}, fmt.Errorf("combat session %s: zone run %s not found", sess.SessionID, sess.RunID)
|
||||
}
|
||||
monster, ok := dndBestiary[sess.EnemyID]
|
||||
if !ok {
|
||||
return Combatant{}, Combatant{}, fmt.Errorf("combat session %s: enemy %q not in bestiary", sess.SessionID, sess.EnemyID)
|
||||
}
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
player, enemy, _, err = p.buildZoneCombatants(id.UserID(sess.UserID), monster, int(zone.Tier), run.DMMood)
|
||||
if err != nil {
|
||||
return player, enemy, err
|
||||
}
|
||||
// Fold any fight-scoped buffs a mid-fight !cast / !consume layered on back
|
||||
// onto the freshly-rebuilt player. The depleting one-shots (ward/spore/…)
|
||||
// live on the session's Statuses and flow through the turn engine's
|
||||
// resume/commit cycle, so only the persistent stat deltas are applied here.
|
||||
applySessionBuffs(&player, sess.Statuses)
|
||||
return player, enemy, err
|
||||
}
|
||||
|
||||
// applySessionBuffs re-derives the persistent stat effect of every mid-fight
|
||||
// buff onto the rebuilt player. The buffs are stored as accumulated deltas
|
||||
// (diffTurnBuff produced them against the player's state at cast time), so
|
||||
// re-applying them to a deterministic rebuild reproduces the same totals every
|
||||
// round without double-counting.
|
||||
func applySessionBuffs(player *Combatant, s CombatStatuses) {
|
||||
player.Stats.AC += s.BuffACBonus
|
||||
player.Stats.AttackBonus += s.BuffAtkBonus
|
||||
player.Stats.Speed += s.BuffSpeedBonus
|
||||
player.Stats.CritRate += s.BuffCritRate
|
||||
player.Mods.DamageBonus += s.BuffDamageBonus
|
||||
player.Mods.PetAttackProc += s.BuffPetProc
|
||||
player.Mods.PetAttackDmg += s.BuffPetDmg
|
||||
if s.BuffDamageReductMul > 0 {
|
||||
player.Mods.DamageReduct *= s.BuffDamageReductMul
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func cleanupCombatSessions(uid id.UserID) {
|
||||
_, _ = db.Get().Exec(`DELETE FROM combat_session WHERE user_id = ?`, string(uid))
|
||||
}
|
||||
|
||||
// ── Persistence layer ──────────────────────────────────────────────────────
|
||||
|
||||
func TestStartCombatSession_RoundTrip(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@combat-roundtrip:example.org")
|
||||
defer cleanupCombatSessions(uid)
|
||||
|
||||
s, err := startCombatSession(uid, "run-1", "node-7", "owlbear", 80, 80, 120, 120)
|
||||
if err != nil {
|
||||
t.Fatalf("startCombatSession: %v", err)
|
||||
}
|
||||
if s.Status != CombatStatusActive || s.Phase != CombatPhasePlayerTurn {
|
||||
t.Errorf("fresh session: status=%q phase=%q", s.Status, s.Phase)
|
||||
}
|
||||
if s.Round != 1 {
|
||||
t.Errorf("round = %d, want 1", s.Round)
|
||||
}
|
||||
|
||||
got, err := getActiveCombatSession(uid)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("getActiveCombatSession: %v / %v", got, err)
|
||||
}
|
||||
if got.SessionID != s.SessionID {
|
||||
t.Errorf("id mismatch: %q vs %q", got.SessionID, s.SessionID)
|
||||
}
|
||||
if got.RunID != "run-1" || got.EncounterID != "node-7" || got.EnemyID != "owlbear" {
|
||||
t.Errorf("identity round-trip wrong: %+v", got)
|
||||
}
|
||||
if got.PlayerHP != 80 || got.PlayerHPMax != 80 || got.EnemyHP != 120 || got.EnemyHPMax != 120 {
|
||||
t.Errorf("hp round-trip wrong: %+v", got)
|
||||
}
|
||||
if len(got.TurnLog) != 0 {
|
||||
t.Errorf("expected empty turn log, got %v", got.TurnLog)
|
||||
}
|
||||
|
||||
// Mutable-field persistence.
|
||||
got.Round = 3
|
||||
got.Phase = CombatPhaseRoundEnd
|
||||
got.PlayerHP = 40
|
||||
got.EnemyHP = 15
|
||||
got.Statuses = CombatStatuses{PoisonTicks: 2, PoisonDmg: 5, Enraged: true}
|
||||
got.TurnLog = append(got.TurnLog, CombatEvent{Round: 3, Actor: "player", Action: "hit", Damage: 9})
|
||||
if err := saveCombatSession(got); err != nil {
|
||||
t.Fatalf("saveCombatSession: %v", err)
|
||||
}
|
||||
reloaded, err := getCombatSession(s.SessionID)
|
||||
if err != nil || reloaded == nil {
|
||||
t.Fatalf("getCombatSession: %v / %v", reloaded, err)
|
||||
}
|
||||
if reloaded.Round != 3 || reloaded.Phase != CombatPhaseRoundEnd {
|
||||
t.Errorf("round/phase not persisted: %+v", reloaded)
|
||||
}
|
||||
if reloaded.PlayerHP != 40 || reloaded.EnemyHP != 15 {
|
||||
t.Errorf("hp not persisted: %+v", reloaded)
|
||||
}
|
||||
if reloaded.Statuses != (CombatStatuses{PoisonTicks: 2, PoisonDmg: 5, Enraged: true}) {
|
||||
t.Errorf("statuses not persisted: %+v", reloaded.Statuses)
|
||||
}
|
||||
if len(reloaded.TurnLog) != 1 || reloaded.TurnLog[0].Action != "hit" {
|
||||
t.Errorf("turn log not persisted: %+v", reloaded.TurnLog)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartCombatSession_RejectsConcurrent(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@combat-concur:example.org")
|
||||
defer cleanupCombatSessions(uid)
|
||||
|
||||
if _, err := startCombatSession(uid, "r", "n", "rat", 50, 50, 30, 30); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := startCombatSession(uid, "r", "n", "wolf", 50, 50, 30, 30); err != ErrCombatSessionAlreadyActive {
|
||||
t.Errorf("err = %v, want ErrCombatSessionAlreadyActive", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListExpiredCombatSessions(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@combat-sweep:example.org")
|
||||
fresh := id.UserID("@combat-fresh:example.org")
|
||||
defer cleanupCombatSessions(uid)
|
||||
defer cleanupCombatSessions(fresh)
|
||||
|
||||
s, err := startCombatSession(uid, "r", "n", "boss", 60, 60, 200, 200)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A second, non-stale session should never show up in the list.
|
||||
if _, err := startCombatSession(fresh, "r2", "n2", "rat", 40, 40, 20, 20); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Backdate expiry so the reaper considers the first one stale.
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE combat_session SET expires_at = datetime('now', '-1 hour') WHERE session_id = ?`,
|
||||
s.SessionID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expired, err := listExpiredCombatSessions()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(expired) != 1 {
|
||||
t.Fatalf("expired count = %d, want 1", len(expired))
|
||||
}
|
||||
if expired[0].SessionID != s.SessionID {
|
||||
t.Errorf("expired[0] = %q, want %q", expired[0].SessionID, s.SessionID)
|
||||
}
|
||||
|
||||
// markCombatSessionExpired is the non-auto-play fallback path.
|
||||
if err := markCombatSessionExpired(s.SessionID); err != nil {
|
||||
t.Fatalf("mark expired: %v", err)
|
||||
}
|
||||
if active, _ := getActiveCombatSession(uid); active != nil {
|
||||
t.Errorf("expected no active session after mark, got %+v", active)
|
||||
}
|
||||
reaped, _ := getCombatSession(s.SessionID)
|
||||
if reaped.Status != CombatStatusExpired || reaped.Phase != CombatPhaseOver {
|
||||
t.Errorf("reaped session: status=%q phase=%q", reaped.Status, reaped.Phase)
|
||||
}
|
||||
// The non-stale session is untouched.
|
||||
if again, _ := listExpiredCombatSessions(); len(again) != 0 {
|
||||
t.Errorf("expected 0 expired after mark, got %d", len(again))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasActiveCombatSession(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@combat-guard:example.org")
|
||||
defer cleanupCombatSessions(uid)
|
||||
|
||||
if hasActiveCombatSession(uid) {
|
||||
t.Fatal("no session yet, want false")
|
||||
}
|
||||
|
||||
s, err := startCombatSession(uid, "r", "n", "boss", 60, 60, 120, 120)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !hasActiveCombatSession(uid) {
|
||||
t.Error("active session, want true")
|
||||
}
|
||||
|
||||
// A terminal session no longer counts as active.
|
||||
s.Status = CombatStatusWon
|
||||
s.Phase = CombatPhaseOver
|
||||
if err := saveCombatSession(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hasActiveCombatSession(uid) {
|
||||
t.Error("session is won, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// ── State machine ──────────────────────────────────────────────────────────
|
||||
|
||||
// turnSession builds an in-memory CombatSession for state-machine tests that
|
||||
// don't touch the DB.
|
||||
func turnSession(phase string, playerHP, enemyHP int) *CombatSession {
|
||||
return &CombatSession{
|
||||
SessionID: "test-session", UserID: "@t:x", Round: 1, Phase: phase,
|
||||
PlayerHP: playerHP, PlayerHPMax: playerHP,
|
||||
EnemyHP: enemyHP, EnemyHPMax: enemyHP,
|
||||
TurnLog: []CombatEvent{}, Status: CombatStatusActive,
|
||||
}
|
||||
}
|
||||
|
||||
func stepEngine(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
|
||||
rng := rand.New(rand.NewPCG(42, phaseOrdinal(sess.Phase)))
|
||||
te := resumeTurnEngine(sess, player, enemy, rng)
|
||||
events, err := te.step(action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
te.commit()
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func TestTurnEngine_PhaseProgression(t *testing.T) {
|
||||
// Pools large enough that no single phase can end the fight.
|
||||
sess := turnSession(CombatPhasePlayerTurn, 10000, 10000)
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Phase != CombatPhaseEnemyTurn {
|
||||
t.Fatalf("after player_turn: phase=%q, want enemy_turn", sess.Phase)
|
||||
}
|
||||
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Phase != CombatPhaseRoundEnd {
|
||||
t.Fatalf("after enemy_turn: phase=%q, want round_end", sess.Phase)
|
||||
}
|
||||
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Phase != CombatPhasePlayerTurn {
|
||||
t.Fatalf("after round_end: phase=%q, want player_turn", sess.Phase)
|
||||
}
|
||||
if sess.Round != 2 {
|
||||
t.Errorf("round = %d after a full cycle, want 2", sess.Round)
|
||||
}
|
||||
if sess.Status != CombatStatusActive {
|
||||
t.Errorf("status = %q, want active", sess.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_PoisonTickAtRoundEnd(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseRoundEnd, 50, 80)
|
||||
sess.Statuses = CombatStatuses{PoisonTicks: 2, PoisonDmg: 7}
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.PlayerHP != 43 {
|
||||
t.Errorf("player_hp = %d, want 43 (50 - 7 poison)", sess.PlayerHP)
|
||||
}
|
||||
if sess.Statuses.PoisonTicks != 1 {
|
||||
t.Errorf("poison_ticks = %d, want 1", sess.Statuses.PoisonTicks)
|
||||
}
|
||||
if sess.Round != 2 || sess.Phase != CombatPhasePlayerTurn {
|
||||
t.Errorf("round=%d phase=%q, want 2/player_turn", sess.Round, sess.Phase)
|
||||
}
|
||||
if len(events) != 1 || events[0].Action != "poison_tick" || events[0].Damage != 7 {
|
||||
t.Errorf("expected one poison_tick event, got %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_PoisonTickCanBeLethal(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseRoundEnd, 4, 80)
|
||||
sess.Statuses = CombatStatuses{PoisonTicks: 1, PoisonDmg: 9}
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Status != CombatStatusLost {
|
||||
t.Errorf("status = %q, want lost (poison dropped player to 0)", sess.Status)
|
||||
}
|
||||
if sess.Phase != CombatPhaseOver {
|
||||
t.Errorf("phase = %q, want over", sess.Phase)
|
||||
}
|
||||
if sess.PlayerHP != 0 {
|
||||
t.Errorf("player_hp = %d, want 0", sess.PlayerHP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_Flee(t *testing.T) {
|
||||
sess := turnSession(CombatPhasePlayerTurn, 50, 50)
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionFlee})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Status != CombatStatusFled || sess.Phase != CombatPhaseOver {
|
||||
t.Errorf("after flee: status=%q phase=%q", sess.Status, sess.Phase)
|
||||
}
|
||||
if len(events) != 1 || events[0].Action != "flee" {
|
||||
t.Errorf("expected one flee event, got %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_PlayerWinsWhenEnemyDrops(t *testing.T) {
|
||||
// Enemy on 1 HP: any connecting player hit ends it. Drive the fight to a
|
||||
// terminal state and confirm the player wins (strong attacker vs. 1 HP).
|
||||
sess := turnSession(CombatPhasePlayerTurn, 100, 1)
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
for i := 0; i < 12 && sess.Status == CombatStatusActive; i++ {
|
||||
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if sess.Status != CombatStatusWon {
|
||||
t.Fatalf("status = %q, want won", sess.Status)
|
||||
}
|
||||
if sess.Phase != CombatPhaseOver {
|
||||
t.Errorf("phase = %q, want over", sess.Phase)
|
||||
}
|
||||
if sess.EnemyHP > 0 {
|
||||
t.Errorf("enemy_hp = %d, want 0", sess.EnemyHP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_StepRejectsTerminalSession(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseOver, 50, 50)
|
||||
sess.Status = CombatStatusWon
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
rng := rand.New(rand.NewPCG(1, 1))
|
||||
te := resumeTurnEngine(sess, &player, &enemy, rng)
|
||||
if _, err := te.step(PlayerAction{Kind: ActionAttack}); err != errCombatSessionOver {
|
||||
t.Errorf("err = %v, want errCombatSessionOver", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fight-scoped buff persistence ──────────────────────────────────────────
|
||||
|
||||
func TestDiffTurnBuff(t *testing.T) {
|
||||
bs := CombatStats{AC: 14, AttackBonus: 3, MaxHP: 50, Speed: 10, CritRate: 0.05}
|
||||
bm := CombatModifiers{DamageBonus: 0.1, DamageReduct: 1.0}
|
||||
|
||||
// A buff that bumps AC, grants ward charges, reduces damage taken, and
|
||||
// raises max HP (Aid-style → collapses to a heal).
|
||||
as, am := bs, bm
|
||||
as.AC = 16
|
||||
as.MaxHP = 55
|
||||
am.WardCharges = 2
|
||||
am.DamageReduct = 0.8
|
||||
d := diffTurnBuff(bs, as, bm, am)
|
||||
if d.dAC != 2 || d.ward != 2 || d.heal != 5 {
|
||||
t.Errorf("delta = %+v, want dAC 2 / ward 2 / heal 5", d)
|
||||
}
|
||||
if d.dReductMul < 0.79 || d.dReductMul > 0.81 {
|
||||
t.Errorf("dReductMul = %v, want ~0.8", d.dReductMul)
|
||||
}
|
||||
if !d.statComponent() || !d.any() {
|
||||
t.Errorf("buff with AC + reduct should report statComponent and any")
|
||||
}
|
||||
|
||||
// A no-op buff (utility spell with no combat hook) diffs to nothing.
|
||||
none := diffTurnBuff(bs, bs, bm, bm)
|
||||
if none.statComponent() || none.any() {
|
||||
t.Errorf("no-op buff should report neither statComponent nor any: %+v", none)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBuffDelta(t *testing.T) {
|
||||
var s CombatStatuses
|
||||
s.applyBuffDelta(turnBuffDelta{dAC: 2, ward: 1, dReductMul: 0.8, autoCrit: true})
|
||||
if s.BuffACBonus != 2 || s.WardCharges != 1 || !s.AutoCritFirst {
|
||||
t.Errorf("first delta not folded in: %+v", s)
|
||||
}
|
||||
if s.BuffDamageReductMul < 0.79 || s.BuffDamageReductMul > 0.81 {
|
||||
t.Errorf("BuffDamageReductMul = %v, want ~0.8", s.BuffDamageReductMul)
|
||||
}
|
||||
// A second buff stacks: deltas accumulate, reduction multipliers compound.
|
||||
s.applyBuffDelta(turnBuffDelta{dAC: 1, dReductMul: 0.5})
|
||||
if s.BuffACBonus != 3 {
|
||||
t.Errorf("BuffACBonus = %d, want 3 after stacking", s.BuffACBonus)
|
||||
}
|
||||
if s.BuffDamageReductMul < 0.39 || s.BuffDamageReductMul > 0.41 {
|
||||
t.Errorf("BuffDamageReductMul = %v, want ~0.4 (0.8 * 0.5)", s.BuffDamageReductMul)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySessionBuffs(t *testing.T) {
|
||||
player := basePlayer() // AC 0, AttackBonus 0, CritRate 0.05, Speed 10, DamageReduct 1.0
|
||||
applySessionBuffs(&player, CombatStatuses{
|
||||
BuffACBonus: 3, BuffAtkBonus: 2, BuffSpeedBonus: 5, BuffCritRate: 0.15,
|
||||
BuffDamageBonus: 0.25, BuffDamageReductMul: 0.8, BuffPetProc: 0.5, BuffPetDmg: 6,
|
||||
})
|
||||
if player.Stats.AC != 3 || player.Stats.AttackBonus != 2 || player.Stats.Speed != 15 {
|
||||
t.Errorf("stat deltas wrong: %+v", player.Stats)
|
||||
}
|
||||
if player.Stats.CritRate < 0.19 || player.Stats.CritRate > 0.21 {
|
||||
t.Errorf("CritRate = %v, want ~0.20", player.Stats.CritRate)
|
||||
}
|
||||
if player.Mods.DamageBonus != 0.25 || player.Mods.PetAttackProc != 0.5 || player.Mods.PetAttackDmg != 6 {
|
||||
t.Errorf("mod deltas wrong: %+v", player.Mods)
|
||||
}
|
||||
if player.Mods.DamageReduct < 0.79 || player.Mods.DamageReduct > 0.81 {
|
||||
t.Errorf("DamageReduct = %v, want ~0.8 (1.0 * 0.8)", player.Mods.DamageReduct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedCombatSessionOneShots(t *testing.T) {
|
||||
// Arcane Ward is the one resource normally live at fight start.
|
||||
s := &CombatSession{}
|
||||
if !seedCombatSessionOneShots(s, CombatModifiers{ArcaneWardHP: 15}) {
|
||||
t.Error("seed should report true when Arcane Ward is present")
|
||||
}
|
||||
if s.Statuses.ArcaneWardHP != 15 {
|
||||
t.Errorf("ArcaneWardHP = %d, want 15", s.Statuses.ArcaneWardHP)
|
||||
}
|
||||
// Nothing to seed → false, statuses untouched.
|
||||
empty := &CombatSession{}
|
||||
if seedCombatSessionOneShots(empty, CombatModifiers{}) {
|
||||
t.Error("seed should report false with no fight-start resources")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnEngine_OneShotsRoundTrip drives a no-op round_end step and confirms
|
||||
// every fight-scoped one-shot survives the resume -> combatState -> commit
|
||||
// cycle. Without that round-trip a Halfling could reroll a nat 1 — or a player
|
||||
// re-bank a depleted ward charge — on every resumed round.
|
||||
func TestTurnEngine_OneShotsRoundTrip(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseRoundEnd, 50, 50)
|
||||
sess.Statuses = CombatStatuses{
|
||||
WardCharges: 3, SporeRounds: 2, ReflectFrac: 0.5, AutoCritFirst: true,
|
||||
ArcaneWardHP: 10, HealChargesLeft: 1,
|
||||
Raged: true, LuckyUsed: true, DeathSaveUsed: true, PendingRage: true,
|
||||
FirstAtkBonusUsed: true, AssassinateReroll: true, AssassinateBonus: true,
|
||||
// Buff stat deltas are owned by the command layer — commit must leave
|
||||
// them untouched rather than zeroing them on every step.
|
||||
BuffACBonus: 2, BuffDamageBonus: 0.15,
|
||||
}
|
||||
want := sess.Statuses // round_end with no poison mutates none of these
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Statuses != want {
|
||||
t.Errorf("one-shots not round-tripped:\n got %+v\n want %+v", sess.Statuses, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet proc (per-fight) ───────────────────────────────────────────────────
|
||||
|
||||
func TestRollCombatSessionPetProc(t *testing.T) {
|
||||
// No pet proc on the player → never rolls true, never touches statuses.
|
||||
none := &CombatSession{SessionID: "no-pet"}
|
||||
if rollCombatSessionPetProc(none, CombatModifiers{}) || none.Statuses.PetProcReady {
|
||||
t.Error("zero PetAttackProc should not arm a pet strike")
|
||||
}
|
||||
// A guaranteed proc arms the flag; the draw is deterministic per session id.
|
||||
sure := &CombatSession{SessionID: "pet-fight"}
|
||||
if !rollCombatSessionPetProc(sure, CombatModifiers{PetAttackProc: 1.0}) || !sure.Statuses.PetProcReady {
|
||||
t.Error("PetAttackProc 1.0 should always arm a pet strike")
|
||||
}
|
||||
again := &CombatSession{SessionID: "pet-fight"}
|
||||
rollCombatSessionPetProc(again, CombatModifiers{PetAttackProc: 1.0})
|
||||
if again.Statuses.PetProcReady != sure.Statuses.PetProcReady {
|
||||
t.Error("same session id should roll the pet proc deterministically")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnEngine_PetStrike confirms an armed pet lands exactly one hit on the
|
||||
// player's first acting turn, then never again — the per-fight roll model.
|
||||
func TestTurnEngine_PetStrike(t *testing.T) {
|
||||
// Pools huge so no phase can end the fight; isolate the pet behaviour.
|
||||
sess := turnSession(CombatPhasePlayerTurn, 100000, 100000)
|
||||
sess.Statuses.PetProcReady = true
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
player.Mods.PetAttackDmg = 8
|
||||
|
||||
events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
petHits := 0
|
||||
for _, e := range events {
|
||||
if e.Action == "pet_attack" {
|
||||
petHits++
|
||||
if e.Damage <= 0 {
|
||||
t.Errorf("pet_attack damage = %d, want > 0", e.Damage)
|
||||
}
|
||||
}
|
||||
}
|
||||
if petHits != 1 {
|
||||
t.Fatalf("pet_attack events = %d on first player turn, want 1", petHits)
|
||||
}
|
||||
if sess.Statuses.PetProcReady {
|
||||
t.Error("PetProcReady should clear after the pet strikes")
|
||||
}
|
||||
|
||||
// Cycle back to the next player turn — the pet must not strike again.
|
||||
for sess.Phase != CombatPhasePlayerTurn {
|
||||
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
events, err = stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range events {
|
||||
if e.Action == "pet_attack" {
|
||||
t.Error("pet struck twice — the roll is per fight, not per round")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombatSessionRNG_DeterministicPerPhase(t *testing.T) {
|
||||
sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhaseEnemyTurn}
|
||||
a := combatSessionRNG(sess)
|
||||
b := combatSessionRNG(sess)
|
||||
if a.Uint64() != b.Uint64() {
|
||||
t.Error("same session+round+phase should seed identical streams")
|
||||
}
|
||||
// A different phase of the same round must draw a distinct stream.
|
||||
sess.Phase = CombatPhasePlayerTurn
|
||||
c := combatSessionRNG(sess)
|
||||
sess.Phase = CombatPhaseEnemyTurn
|
||||
d := combatSessionRNG(sess)
|
||||
if c.Uint64() == d.Uint64() {
|
||||
t.Error("distinct phases of a round should seed distinct streams")
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,17 @@ func DerivePlayerStats(
|
||||
) (CombatStats, CombatModifiers) {
|
||||
arenaSets := advEquippedArenaSets(equip)
|
||||
|
||||
// Base stats from combat level
|
||||
// Stat baselines. CombatLevel is dead — player power keys off gear here
|
||||
// and dnd_level / ability scores via applyDnDPlayerLayer afterwards.
|
||||
// MaxHP is computed below only so the armor% / arena% / housing% bonus
|
||||
// formulas have something to scale against; the base is subtracted out
|
||||
// when we capture stats.HPBonus, so it never reaches combat.
|
||||
const legacyBase = 50
|
||||
stats := CombatStats{
|
||||
MaxHP: 50 + char.CombatLevel*2,
|
||||
Attack: 5 + char.CombatLevel + bonuses.CombatBonus,
|
||||
Defense: 3 + char.CombatLevel/2 + bonuses.CombatBonus/2,
|
||||
Speed: 5 + char.CombatLevel/3,
|
||||
MaxHP: legacyBase,
|
||||
Attack: 5 + bonuses.CombatBonus,
|
||||
Defense: 3 + bonuses.CombatBonus/2,
|
||||
Speed: 5,
|
||||
CritRate: 0.03,
|
||||
DodgeRate: 0.02,
|
||||
BlockRate: 0.02,
|
||||
@@ -75,6 +80,13 @@ func DerivePlayerStats(
|
||||
// Housing HP bonus
|
||||
stats.MaxHP += int(float64(stats.MaxHP) * char.HouseHPBonus())
|
||||
|
||||
// Capture the equipment/arena/housing HP delta as fight-only headroom.
|
||||
// applyDnDPlayerLayer adds this to c.HPMax to form combat MaxHP — gear
|
||||
// power preserved, dnd_character.hp_current is the canonical wound store
|
||||
// with no scale conversion. The legacy 50+CL*2 base is intentionally
|
||||
// dropped; monster damage is tuned to the dnd HP scale.
|
||||
stats.HPBonus = stats.MaxHP - legacyBase
|
||||
|
||||
// Streak bonuses
|
||||
switch {
|
||||
case streak >= 30:
|
||||
@@ -196,22 +208,19 @@ func DeriveArenaMonsterStats(monster *ArenaMonster) (CombatStats, CombatModifier
|
||||
}
|
||||
|
||||
// DeriveDungeonMonsterStats synthesizes monster stats from a dungeon location.
|
||||
// Target: the stat block should produce a death rate roughly matching BaseDeathPct
|
||||
// when the player is at the location's MinLevel with tier-appropriate equipment.
|
||||
// The player typically has higher base stats, so monsters compensate with HP bulk
|
||||
// and just enough Attack to be threatening over the fight's duration.
|
||||
// Tuned to the dnd HP scale (post HP-unification): well-equipped players at
|
||||
// the location's MinLevel should win the vast majority of fights, with deaths
|
||||
// coming from bad luck (crits, hazards, initiative). Old quadratic Attack
|
||||
// formula assumed legacy ~100 HP players; the new linear scaling matches the
|
||||
// ~12–80 HP range of dnd-scale fighters.
|
||||
func DeriveDungeonMonsterStats(loc *AdvLocation) (CombatStats, CombatModifiers) {
|
||||
t := float64(loc.Tier)
|
||||
death := loc.BaseDeathPct // 8 to 60
|
||||
|
||||
// Well-equipped players at the right level should be dominant (win 85-95%+).
|
||||
// Deaths come from bad luck: enemy crits, environmental hazards, unlucky initiative.
|
||||
// Monster Attack needs to be high enough to threaten over multiple rounds via
|
||||
// penetration damage, but not enough to kill quickly.
|
||||
stats := CombatStats{
|
||||
MaxHP: 25 + int(t*t*6+death*0.6), // quadratic: T1=33, T5=187
|
||||
Attack: 3 + int(death*0.2) + int(t*t*1.5), // quadratic: T1=5, T5=49
|
||||
Defense: 2 + int(t*1.5),
|
||||
MaxHP: 12 + int(t*7+death*0.3), // T1≈22, T5≈65 — sized so dnd-scale players can finish a kill within the phase budget
|
||||
Attack: int(t*1.5) + int(death*0.04), // T1=1, T5=9 — tuned to dnd HP pools (players have 13–80 HP, not 100+)
|
||||
Defense: 2 + int(t*1.2),
|
||||
Speed: 4 + int(t*1.5),
|
||||
CritRate: 0.03 + death*0.003,
|
||||
DodgeRate: 0.02 + t*0.008,
|
||||
|
||||
@@ -23,20 +23,45 @@ func testChar(combatLevel int) *AdventureCharacter {
|
||||
}
|
||||
}
|
||||
|
||||
// balanceTestDnDChar synthesizes a fighter sheet at the given dnd level for
|
||||
// balance regression sims. Mirrors ensureDnDCharacterForCombat →
|
||||
// autoBuildCharacter. CombatLevel is dead — combat keys off this sheet
|
||||
// (HP/AC/AB/weapon dice) plus gear bonuses, nothing else.
|
||||
func balanceTestDnDChar(dndLevel int) *DnDCharacter {
|
||||
scores := classStatPriority(ClassFighter)
|
||||
c := &DnDCharacter{
|
||||
Class: ClassFighter, Level: dndLevel,
|
||||
STR: scores[0], DEX: scores[1], CON: scores[2],
|
||||
INT: scores[3], WIS: scores[4], CHA: scores[5],
|
||||
}
|
||||
conMod := abilityModifier(c.CON)
|
||||
dexMod := abilityModifier(c.DEX)
|
||||
c.HPMax = computeMaxHP(c.Class, conMod, c.Level)
|
||||
c.HPCurrent = c.HPMax
|
||||
c.ArmorClass = computeAC(c.Class, dexMod)
|
||||
return c
|
||||
}
|
||||
|
||||
func zeroBonuses() *AdvBonusSummary {
|
||||
return &AdvBonusSummary{}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_BaseStats(t *testing.T) {
|
||||
// Post HP-unification + CL-strip: DerivePlayerStats no longer scales
|
||||
// off CombatLevel. With zero gear, output is the constant baseline that
|
||||
// applyDnDPlayerLayer then layers c.HPMax / weapon dice / etc. on top.
|
||||
char := testChar(10)
|
||||
equip := testEquip(0)
|
||||
stats, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 0, false)
|
||||
|
||||
if stats.MaxHP < 70 {
|
||||
t.Errorf("level 10 base MaxHP = %d, want >= 70", stats.MaxHP)
|
||||
if stats.MaxHP != 50 {
|
||||
t.Errorf("zero-gear MaxHP = %d, want 50 (legacy base used only for HPBonus calc)", stats.MaxHP)
|
||||
}
|
||||
if stats.Attack < 15 {
|
||||
t.Errorf("level 10 base Attack = %d, want >= 15", stats.Attack)
|
||||
if stats.HPBonus != 0 {
|
||||
t.Errorf("zero-gear HPBonus = %d, want 0 (no gear/arena/housing buffs)", stats.HPBonus)
|
||||
}
|
||||
if stats.Attack != 5 {
|
||||
t.Errorf("zero-gear Attack = %d, want 5 (legacy fallback only; production uses weapon dice)", stats.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,12 +274,21 @@ func TestBalanceRegression_DungeonDeathRates(t *testing.T) {
|
||||
maxDeath float64
|
||||
minDeath float64
|
||||
}
|
||||
// `level` is the dnd_level (CombatLevel is no longer consulted for combat
|
||||
// scaling — gear + dnd sheet drive everything).
|
||||
// Phase 5-B bumped the player power floor (+3 AC, +3 to-hit, +3
|
||||
// damage, HP ×1.5), so well-equipped at-tier players are now
|
||||
// effectively dominant across the board. "Real danger" at T5 is
|
||||
// no longer the gate — the difficulty curve is driven by the
|
||||
// expedition layer (interrupt cadence, supply burn, threat
|
||||
// drift), not 1:1 well-equipped combat. These bounds reflect the
|
||||
// new "fairly breezy with some death" target.
|
||||
cases := []tc{
|
||||
{5, 1, advDungeons[0], 0.05, 0.0}, // T1: trivial with proper gear
|
||||
{12, 2, advDungeons[1], 0.10, 0.0}, // T2: very easy at level
|
||||
{25, 3, advDungeons[2], 0.15, 0.0}, // T3: low risk at level with T3 gear
|
||||
{38, 4, advDungeons[3], 0.25, 0.0}, // T4: some risk even geared
|
||||
{48, 5, advDungeons[4], 0.35, 0.01}, // T5: real danger — monster stats catch up. Lower bound is loose because the Sudden Death phase added round headroom that lets geared players close fights more often.
|
||||
{1, 1, advDungeons[0], 0.03, 0.0}, // T1: trivial with proper gear
|
||||
{2, 2, advDungeons[1], 0.05, 0.0}, // T2: trivial at level
|
||||
{5, 3, advDungeons[2], 0.08, 0.0}, // T3: low risk
|
||||
{7, 4, advDungeons[3], 0.15, 0.0}, // T4: still mostly safe
|
||||
{9, 5, advDungeons[4], 0.25, 0.0}, // T5: occasional bad luck
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -264,6 +298,14 @@ func TestBalanceRegression_DungeonDeathRates(t *testing.T) {
|
||||
stats, mods := DerivePlayerStats(char, equip, bonuses, 0, 0, false)
|
||||
eStats, eMods := DeriveDungeonMonsterStats(&c.dungeon)
|
||||
|
||||
// Apply the D&D layers so the test reflects production combat
|
||||
// HP/AC/AB/weapon-dice (post HP-unification, raw DerivePlayerStats
|
||||
// no longer matches what players actually fight with).
|
||||
dndChar := balanceTestDnDChar(c.level)
|
||||
applyDnDPlayerLayer(&stats, dndChar)
|
||||
applyDnDEquipmentLayer(&stats, dndChar, equip)
|
||||
applyDnDDungeonMonsterLayer(&eStats, c.dungeon.Tier)
|
||||
|
||||
player := Combatant{Name: "Hero", Stats: stats, Mods: mods, IsPlayer: true}
|
||||
enemy := Combatant{Name: c.dungeon.Name, Stats: eStats, Mods: eMods}
|
||||
|
||||
@@ -276,10 +318,10 @@ func TestBalanceRegression_DungeonDeathRates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
rate := float64(deaths) / float64(N)
|
||||
t.Logf("%s (L%d T%d): P[HP=%d Atk=%d Def=%d Spd=%d] E[HP=%d Atk=%d Def=%d Spd=%d] → death=%.2f",
|
||||
t.Logf("%s (L%d T%d): P[HP=%d AC=%d AB=%d Def=%d] E[HP=%d AC=%d AB=%d Atk=%d] → death=%.2f",
|
||||
c.dungeon.Name, c.level, c.equipT,
|
||||
stats.MaxHP, stats.Attack, stats.Defense, stats.Speed,
|
||||
eStats.MaxHP, eStats.Attack, eStats.Defense, eStats.Speed,
|
||||
stats.MaxHP, stats.AC, stats.AttackBonus, stats.Defense,
|
||||
eStats.MaxHP, eStats.AC, eStats.AttackBonus, eStats.Attack,
|
||||
rate)
|
||||
if rate < c.minDeath || rate > c.maxDeath {
|
||||
t.Errorf(" FAIL: death rate %.2f outside [%.2f, %.2f]",
|
||||
@@ -297,11 +339,20 @@ func TestBalanceRegression_UnderleveledPlayers(t *testing.T) {
|
||||
dungeon AdvLocation
|
||||
minDeath float64
|
||||
}
|
||||
// `level` is dnd_level. Underleveled = dnd_level low for the dungeon tier.
|
||||
// Phase 5-B player floor (+3 AC, +3 to-hit, +3 damage, HP ×1.5)
|
||||
// is a flat bonus that lifts low-level characters proportionally
|
||||
// more than high-level ones — underleveled vs. tier is no longer
|
||||
// the failure mode it was. Real-game pressure on underleveled
|
||||
// players comes from the *expedition* layer (interrupt cadence,
|
||||
// supply burn, threat clock), not from 1:1 combat. Bounds here
|
||||
// only check that combat still terminates and doesn't pin a
|
||||
// difficulty floor that Phase 5-B intentionally removed.
|
||||
cases := []tc{
|
||||
{"L1 in T2 dungeon", 1, 0, advDungeons[1], 0.40},
|
||||
{"L8 in T3 dungeon", 8, 1, advDungeons[2], 0.30},
|
||||
{"L20 in T4 dungeon, T2 gear", 20, 2, advDungeons[3], 0.25},
|
||||
{"L30 in T5 dungeon, T3 gear", 30, 3, advDungeons[4], 0.30},
|
||||
{"L1 in T2 dungeon", 1, 0, advDungeons[1], 0.0},
|
||||
{"L2 in T3 dungeon", 2, 1, advDungeons[2], 0.0},
|
||||
{"L4 in T4 dungeon, T2 gear", 4, 2, advDungeons[3], 0.0},
|
||||
{"L6 in T5 dungeon, T3 gear", 6, 3, advDungeons[4], 0.0},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -311,6 +362,14 @@ func TestBalanceRegression_UnderleveledPlayers(t *testing.T) {
|
||||
stats, mods := DerivePlayerStats(char, equip, bonuses, 0, 0, false)
|
||||
eStats, eMods := DeriveDungeonMonsterStats(&c.dungeon)
|
||||
|
||||
// Apply the D&D layers so the test reflects production combat
|
||||
// HP/AC/AB/weapon-dice (post HP-unification, raw DerivePlayerStats
|
||||
// no longer matches what players actually fight with).
|
||||
dndChar := balanceTestDnDChar(c.level)
|
||||
applyDnDPlayerLayer(&stats, dndChar)
|
||||
applyDnDEquipmentLayer(&stats, dndChar, equip)
|
||||
applyDnDDungeonMonsterLayer(&eStats, c.dungeon.Tier)
|
||||
|
||||
player := Combatant{Name: "Hero", Stats: stats, Mods: mods, IsPlayer: true}
|
||||
enemy := Combatant{Name: c.dungeon.Name, Stats: eStats, Mods: eMods}
|
||||
|
||||
@@ -323,9 +382,9 @@ func TestBalanceRegression_UnderleveledPlayers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
rate := float64(deaths) / float64(N)
|
||||
t.Logf("%s: P[HP=%d Atk=%d Def=%d] E[HP=%d Atk=%d Def=%d] → death=%.2f (want ≥%.2f)",
|
||||
c.name, stats.MaxHP, stats.Attack, stats.Defense,
|
||||
eStats.MaxHP, eStats.Attack, eStats.Defense, rate, c.minDeath)
|
||||
t.Logf("%s: P[HP=%d AC=%d AB=%d] E[HP=%d AC=%d AB=%d Atk=%d] → death=%.2f (want ≥%.2f)",
|
||||
c.name, stats.MaxHP, stats.AC, stats.AttackBonus,
|
||||
eStats.MaxHP, eStats.AC, eStats.AttackBonus, eStats.Attack, rate, c.minDeath)
|
||||
if rate < c.minDeath {
|
||||
t.Errorf(" FAIL: death rate %.2f below minimum %.2f", rate, c.minDeath)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
)
|
||||
|
||||
// Phase 13 — Turn-based combat state machine.
|
||||
//
|
||||
// Where SimulateCombat runs a whole fight in one call, the turn-based engine
|
||||
// advances a persisted CombatSession one phase at a time:
|
||||
//
|
||||
// player_turn -> enemy_turn -> round_end -> player_turn (next round) -> ...
|
||||
//
|
||||
// each `step` resolving exactly one phase so a manual elite/boss fight can be
|
||||
// suspended and resumed (or reaped) from exact mid-state. Attack resolution
|
||||
// reuses the shared primitives in combat_primitives.go / combat_engine.go, so
|
||||
// a hit lands identically to auto-resolve. The round_end phase is where
|
||||
// between-round status effects (poison, etc.) tick — the deferred poison tick
|
||||
// from the schema commit lands here, now that the round-loop shape exists.
|
||||
|
||||
// errCombatSessionOver is returned by step when the fight has already reached
|
||||
// a terminal status — callers should not keep advancing it.
|
||||
var errCombatSessionOver = errors.New("combat session already over")
|
||||
|
||||
// turnCombatPhase is the single neutral phase the turn-based engine resolves
|
||||
// attacks under. Auto-resolve sequences named phases with weight curves; a
|
||||
// manual duel has no phase clock, so weights are flat 1.0 — neutral for the
|
||||
// legacy penetration formula (calcDamage) and irrelevant on the weapon-dice
|
||||
// path. The name surfaces on every CombatEvent the fight emits.
|
||||
var turnCombatPhase = CombatPhase{
|
||||
Name: "Duel",
|
||||
Rounds: 0,
|
||||
AttackWeight: 1.0,
|
||||
DefenseWeight: 1.0,
|
||||
SpeedWeight: 1.0,
|
||||
}
|
||||
|
||||
// Player action kinds accepted by the state machine on a player_turn step.
|
||||
const (
|
||||
ActionAttack = "attack"
|
||||
ActionFlee = "flee"
|
||||
ActionCast = "cast"
|
||||
ActionConsume = "consume"
|
||||
)
|
||||
|
||||
// PlayerAction is the player's choice for a player_turn step. It is ignored on
|
||||
// enemy_turn / round_end steps (pass the zero value).
|
||||
type PlayerAction struct {
|
||||
Kind string
|
||||
// Effect is set for ActionCast / ActionConsume: a pre-resolved outcome
|
||||
// handed in by the command handler. The handler owns spell/item lookup,
|
||||
// dice rolls, and the resource spend; the engine just applies the HP
|
||||
// deltas and emits the event so the round flows on into the enemy turn.
|
||||
Effect *turnActionEffect
|
||||
}
|
||||
|
||||
// turnActionEffect is the resolved payload of a !cast / !consume player turn.
|
||||
// Damage, heal, and the one-round enemy-skip all land within the casting round.
|
||||
type turnActionEffect struct {
|
||||
Label string // "Fireball — crit!", "Berry Poultice"
|
||||
Action string // CombatEvent.Action: "spell_cast" or "use_consumable"
|
||||
EnemyDamage int
|
||||
PlayerHeal int
|
||||
EnemySkip bool // control spell: enemy forfeits its attack this round
|
||||
}
|
||||
|
||||
// turnEngine wraps a combatState reconstructed from a persisted CombatSession
|
||||
// so a single phase can be resolved and then written back.
|
||||
type turnEngine struct {
|
||||
sess *CombatSession
|
||||
player *Combatant
|
||||
enemy *Combatant
|
||||
st *combatState
|
||||
result *CombatResult
|
||||
}
|
||||
|
||||
// combatSessionRNG seeds a deterministic generator from the session id and the
|
||||
// phase being resolved, so a fight replays reproducibly no matter how many bot
|
||||
// restarts occur mid-fight. Each (round, phase) gets a distinct stream — a flat
|
||||
// per-session seed would correlate the player's and enemy's d20s within a round.
|
||||
func combatSessionRNG(sess *CombatSession) *rand.Rand {
|
||||
var seed uint64 = 1469598103934665603 // FNV-ish offset basis
|
||||
for _, c := range sess.SessionID {
|
||||
seed = (seed ^ uint64(c)) * 1099511628211
|
||||
}
|
||||
stream := uint64(sess.Round)*4 + phaseOrdinal(sess.Phase)
|
||||
return rand.New(rand.NewPCG(seed, stream))
|
||||
}
|
||||
|
||||
func phaseOrdinal(phase string) uint64 {
|
||||
switch phase {
|
||||
case CombatPhasePlayerTurn:
|
||||
return 0
|
||||
case CombatPhaseEnemyTurn:
|
||||
return 1
|
||||
case CombatPhaseRoundEnd:
|
||||
return 2
|
||||
default: // CombatPhaseOver
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
// resumeTurnEngine rebuilds the in-memory combatState from a persisted session.
|
||||
// rng is the deterministic source for this step (see combatSessionRNG).
|
||||
func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.Rand) *turnEngine {
|
||||
st := &combatState{
|
||||
playerHP: sess.PlayerHP,
|
||||
enemyHP: sess.EnemyHP,
|
||||
round: sess.Round,
|
||||
poisonTicks: sess.Statuses.PoisonTicks,
|
||||
poisonDmg: sess.Statuses.PoisonDmg,
|
||||
stunPlayer: sess.Statuses.StunPlayer,
|
||||
enraged: sess.Statuses.Enraged,
|
||||
armorBroken: sess.Statuses.ArmorBroken,
|
||||
armorBreakAmt: sess.Statuses.ArmorBreakAmt,
|
||||
enemySkipFirst: sess.Statuses.EnemySkipNext,
|
||||
petProcReady: sess.Statuses.PetProcReady,
|
||||
// Fight-scoped depleting resources + once-per-fight one-shots: restored
|
||||
// from the persisted statuses so a charge or "already used" flag can't
|
||||
// reset across a suspend/resume. commit writes the updated values back.
|
||||
wardCharges: sess.Statuses.WardCharges,
|
||||
sporeRounds: sess.Statuses.SporeRounds,
|
||||
reflectFrac: sess.Statuses.ReflectFrac,
|
||||
autoCrit: sess.Statuses.AutoCritFirst,
|
||||
arcaneWardHP: sess.Statuses.ArcaneWardHP,
|
||||
healChargesLeft: sess.Statuses.HealChargesLeft,
|
||||
deathSaveUsed: sess.Statuses.DeathSaveUsed,
|
||||
luckyUsed: sess.Statuses.LuckyUsed,
|
||||
raged: sess.Statuses.Raged,
|
||||
pendingRageAttack: sess.Statuses.PendingRage,
|
||||
firstAttackBonusUsed: sess.Statuses.FirstAtkBonusUsed,
|
||||
assassinateRerollUsed: sess.Statuses.AssassinateReroll,
|
||||
assassinateBonusUsed: sess.Statuses.AssassinateBonus,
|
||||
// Slice-3 stateful monster-ability effects — armed by applyAbility,
|
||||
// round-tripped here so they survive a suspend/resume or reaper auto-play.
|
||||
enemyEvadeNext: sess.Statuses.EnemyEvadeNext,
|
||||
enemyBlockUp: sess.Statuses.EnemyBlockUp,
|
||||
enemyAdvantage: sess.Statuses.EnemyAdvantage,
|
||||
enemyRetaliateFrac: sess.Statuses.EnemyRetaliateFrac,
|
||||
enemyRegen: sess.Statuses.EnemyRegen,
|
||||
enemySurviveArmed: sess.Statuses.EnemySurviveArmed,
|
||||
playerAtkDrain: sess.Statuses.PlayerAtkDrain,
|
||||
playerACDebuff: sess.Statuses.PlayerACDebuff,
|
||||
maxHPDrain: sess.Statuses.MaxHPDrain,
|
||||
// Slice-4 monster-ability effects — the former flavor-only placeholders.
|
||||
enemySpellResist: sess.Statuses.EnemySpellResist,
|
||||
enemyRevealNext: sess.Statuses.EnemyRevealNext,
|
||||
enemyFearImmune: sess.Statuses.EnemyFearImmune,
|
||||
enemyAtkBuff: sess.Statuses.EnemyAtkBuff,
|
||||
rng: rng,
|
||||
}
|
||||
return &turnEngine{
|
||||
sess: sess,
|
||||
player: player,
|
||||
enemy: enemy,
|
||||
st: st,
|
||||
result: &CombatResult{},
|
||||
}
|
||||
}
|
||||
|
||||
// step resolves exactly one phase of the fight and advances sess.Phase. The
|
||||
// events generated this step are returned (also accumulated by commit into
|
||||
// sess.TurnLog). It does not persist — call commit then saveCombatSession, or
|
||||
// use advanceCombatSession which does both.
|
||||
func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
|
||||
if !te.sess.IsActive() {
|
||||
return nil, errCombatSessionOver
|
||||
}
|
||||
te.st.events = nil
|
||||
switch te.sess.Phase {
|
||||
case CombatPhasePlayerTurn:
|
||||
te.stepPlayerTurn(action)
|
||||
case CombatPhaseEnemyTurn:
|
||||
te.stepEnemyTurn()
|
||||
case CombatPhaseRoundEnd:
|
||||
te.stepRoundEnd()
|
||||
case CombatPhaseOver:
|
||||
return nil, errCombatSessionOver
|
||||
default:
|
||||
return nil, fmt.Errorf("combat session %s in unknown phase %q", te.sess.SessionID, te.sess.Phase)
|
||||
}
|
||||
return te.st.events, nil
|
||||
}
|
||||
|
||||
func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
|
||||
switch action.Kind {
|
||||
case ActionFlee:
|
||||
te.st.events = append(te.st.events, CombatEvent{
|
||||
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: "flee",
|
||||
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
|
||||
})
|
||||
te.finish(CombatStatusFled)
|
||||
return
|
||||
case ActionCast, ActionConsume:
|
||||
te.stepPlayerActionEffect(action.Effect)
|
||||
return
|
||||
}
|
||||
// Default: weapon attack. resolvePlayerSwings rolls the base swing plus
|
||||
// Mods.ExtraAttacks follow-ups (5e Extra Attack at Fighter L5/L11/L20,
|
||||
// Ranger/Paladin L5, Bard College of Valor L7). One !attack press in the
|
||||
// turn-based engine == one 5e Attack action == all swings in sequence.
|
||||
// Before this, the turn path called single-swing resolvePlayerAttack and
|
||||
// Extra Attack only fired in the auto-resolve SimulateCombat path; that
|
||||
// left every elite/boss !fight at L11+ short two swings/turn, which the
|
||||
// J1 boss-trace surfaced as Fighter's T3+ wall.
|
||||
// Returns true once the fight is decided — usually the enemy is down,
|
||||
// but a retaliate aura can drop the player on their own swing, so
|
||||
// disambiguate the outcome by HP.
|
||||
if resolvePlayerSwings(te.st, te.player, te.enemy, &turnCombatPhase, te.result) {
|
||||
if te.st.playerHP <= 0 {
|
||||
te.finish(CombatStatusLost)
|
||||
} else {
|
||||
te.finish(CombatStatusWon)
|
||||
}
|
||||
return
|
||||
}
|
||||
if te.petStrike() {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
te.sess.Phase = CombatPhaseEnemyTurn
|
||||
}
|
||||
|
||||
// petStrike resolves the player's pet attack for a turn-based fight. Whether
|
||||
// the pet lands a hit was decided once at fight start (rollCombatSessionPetProc)
|
||||
// and parked on the session; the pet then strikes a single time on the player's
|
||||
// first acting turn — this clears the flag so it never repeats. Damage reuses
|
||||
// the auto-resolve formula (PetAttackDmg + d5), and PetAttackDmg already carries
|
||||
// any mid-fight buff delta via applySessionBuffs. Returns true if the strike
|
||||
// dropped the enemy.
|
||||
func (te *turnEngine) petStrike() bool {
|
||||
st := te.st
|
||||
if !st.petProcReady {
|
||||
return false
|
||||
}
|
||||
st.petProcReady = false
|
||||
petDmg := te.player.Mods.PetAttackDmg + st.roll(5)
|
||||
st.enemyHP = max(0, st.enemyHP-petDmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: turnCombatPhase.Name, Actor: "pet", Action: "pet_attack",
|
||||
Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return enemyDown(st, turnCombatPhase.Name)
|
||||
}
|
||||
|
||||
// rollCombatSessionPetProc makes the one-and-only per-fight pet-attack roll and
|
||||
// parks the result on the session. Called once at fight start. The draw is
|
||||
// deterministic — seeded off the session id on a stream distinct from the
|
||||
// per-(round,phase) combat streams — so a reaper auto-play of an abandoned
|
||||
// fight reproduces the same outcome. Returns true if the pet will attack (so
|
||||
// the caller can decide whether the session needs persisting).
|
||||
//
|
||||
// Note: only the base PetAttackProc (class/race/subclass passives) is rolled
|
||||
// here — a pet-proc buff cast mid-fight gets no fresh roll, consistent with the
|
||||
// per-fight rule. Such a buff still raises PetAttackDmg if the pet does strike.
|
||||
func rollCombatSessionPetProc(sess *CombatSession, playerMods CombatModifiers) bool {
|
||||
if playerMods.PetAttackProc <= 0 {
|
||||
return false
|
||||
}
|
||||
var seed uint64 = 1469598103934665603
|
||||
for _, c := range sess.SessionID {
|
||||
seed = (seed ^ uint64(c)) * 1099511628211
|
||||
}
|
||||
rng := rand.New(rand.NewPCG(seed, 0x9E3779B97F4A7C15))
|
||||
if rngFloat(rng) < playerMods.PetAttackProc {
|
||||
sess.Statuses.PetProcReady = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stepPlayerActionEffect resolves a !cast / !consume turn: the command handler
|
||||
// has already rolled the spell / picked the item and spent the resource, so the
|
||||
// engine only applies the HP deltas and emits the event before handing off to
|
||||
// the enemy turn. A control-spell skip is parked in st.enemySkipFirst, which
|
||||
// commit() persists as Statuses.EnemySkipNext for the enemy_turn step to read.
|
||||
func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
||||
st := te.st
|
||||
if eff == nil {
|
||||
// Defensive: a kind with no payload just passes the turn.
|
||||
te.sess.Phase = CombatPhaseEnemyTurn
|
||||
return
|
||||
}
|
||||
action := eff.Action
|
||||
if action == "" {
|
||||
action = "spell_cast"
|
||||
}
|
||||
// spell_resist (Spell Immunity): a spell's damage against the enemy is
|
||||
// halved. Consumables that happen to deal damage are not spells, so the
|
||||
// resist is gated on the spell_cast action.
|
||||
enemyDmg := eff.EnemyDamage
|
||||
spellFizzled := enemyDmg > 0 && action == "spell_cast" && enemyResistsSpells(te.enemy, st)
|
||||
if spellFizzled {
|
||||
enemyDmg = max(1, enemyDmg/2)
|
||||
}
|
||||
if enemyDmg > 0 {
|
||||
st.enemyHP = max(0, st.enemyHP-enemyDmg)
|
||||
}
|
||||
if eff.PlayerHeal > 0 {
|
||||
// Respect any max_hp_drain monster ability — a drained player can't be
|
||||
// healed back past the lowered ceiling.
|
||||
hpCap := max(1, te.sess.PlayerHPMax-st.maxHPDrain)
|
||||
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
|
||||
}
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: turnCombatPhase.Name, Actor: "player", Action: action,
|
||||
Damage: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Desc: eff.Label,
|
||||
})
|
||||
if spellFizzled {
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_fizzle",
|
||||
Damage: enemyDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
if enemyDown(st, turnCombatPhase.Name) {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
if te.petStrike() {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
if eff.EnemySkip {
|
||||
// fear_immune enemies shrug off control spells — the skip never arms.
|
||||
if enemyImmuneToControl(te.enemy, st) {
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
} else {
|
||||
st.enemySkipFirst = true
|
||||
}
|
||||
}
|
||||
te.sess.Phase = CombatPhaseEnemyTurn
|
||||
}
|
||||
|
||||
func (te *turnEngine) stepEnemyTurn() {
|
||||
// A control spell cast last phase forfeits the enemy's attack this round —
|
||||
// unless the enemy is fear_immune, in which case the control fizzled and it
|
||||
// acts as normal.
|
||||
if te.st.enemySkipFirst {
|
||||
te.st.enemySkipFirst = false
|
||||
if enemyImmuneToControl(te.enemy, te.st) {
|
||||
te.st.events = append(te.st.events, CombatEvent{
|
||||
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "fear_resist",
|
||||
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
|
||||
})
|
||||
} else {
|
||||
te.st.events = append(te.st.events, CombatEvent{
|
||||
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held",
|
||||
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
|
||||
})
|
||||
te.sess.Phase = CombatPhaseRoundEnd
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Monster ability fires at the top of the enemy turn. cleave / lifesteal
|
||||
// resolve their own damage and stand in for the attack action this round;
|
||||
// every other effect (poison / stun / enrage / armor_break) is a rider that
|
||||
// leaves the multiattack below intact. applyAbility returns true when the
|
||||
// player went down without a save.
|
||||
abilityDealtDamage := false
|
||||
if te.enemy.Ability != nil && turnAbilityFires(te.enemy.Ability, te.st, te.enemy) {
|
||||
if applyAbility(te.st, te.player, te.enemy, &turnCombatPhase, te.result) {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
switch te.enemy.Ability.Effect {
|
||||
case "cleave", "lifesteal":
|
||||
abilityDealtDamage = true
|
||||
}
|
||||
}
|
||||
|
||||
if !abilityDealtDamage {
|
||||
// SRD multiattack: each profile entry is one attack roll resolved
|
||||
// through the shared primitive. A registered elite/boss swings its full
|
||||
// profile; everyone else gets a single attack from the template stats.
|
||||
// resolveEnemyAttack returns true when the fight is decided — either the
|
||||
// player went down without a death save, or a reflect consumable killed
|
||||
// the enemy. Disambiguate by inspecting HP.
|
||||
for _, atk := range enemyAttackProfile(te.sess.EnemyID, te.enemy.Stats) {
|
||||
swing := *te.enemy
|
||||
swing.Stats.Attack = atk.Damage
|
||||
swing.Stats.AttackBonus = atk.AttackBonus
|
||||
decided := resolveEnemyAttack(te.st, te.player, &swing, &turnCombatPhase, te.result, false, false, false)
|
||||
if te.st.playerHP <= 0 {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
if decided || te.st.enemyHP <= 0 {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
te.sess.Phase = CombatPhaseRoundEnd
|
||||
}
|
||||
|
||||
// turnAbilityFires decides whether a monster ability triggers this enemy turn.
|
||||
// The auto-resolve engine gates abilities on its named phase clock (Opening /
|
||||
// Clash / Decisive); the turn-based duel has no phase clock, so the phase tag
|
||||
// is remapped to fight progress: "opening" is round-1 only, "decisive" unlocks
|
||||
// once the enemy is bloodied (<40% HP), "clash" covers the rounds in between,
|
||||
// and "any" is always eligible. ProcChance is rolled once eligible.
|
||||
func turnAbilityFires(ability *MonsterAbility, st *combatState, enemy *Combatant) bool {
|
||||
eligible := false
|
||||
switch ability.Phase {
|
||||
case "any":
|
||||
eligible = true
|
||||
case "opening":
|
||||
eligible = st.round <= 1
|
||||
case "clash":
|
||||
eligible = st.round > 1
|
||||
case "decisive":
|
||||
eligible = st.enemyHP < int(float64(enemy.Stats.MaxHP)*0.4)
|
||||
}
|
||||
if !eligible {
|
||||
return false
|
||||
}
|
||||
return st.randFloat() < ability.ProcChance
|
||||
}
|
||||
|
||||
// stepRoundEnd applies between-round status effects, then opens the next round.
|
||||
func (te *turnEngine) stepRoundEnd() {
|
||||
st := te.st
|
||||
if st.poisonTicks > 0 {
|
||||
st.playerHP = max(0, st.playerHP-st.poisonDmg)
|
||||
st.poisonTicks--
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "poison_tick",
|
||||
Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.playerHP <= 0 {
|
||||
if !trySave(st, te.player, CombatPhaseRoundEnd) {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Regenerate (monster ability): the enemy knits its wounds at round end.
|
||||
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < te.enemy.Stats.MaxHP {
|
||||
st.enemyHP = min(te.enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "regen_tick",
|
||||
Damage: st.enemyRegen, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
st.round++
|
||||
te.sess.Phase = CombatPhasePlayerTurn
|
||||
}
|
||||
|
||||
// finish parks the session in a terminal status + the 'over' phase.
|
||||
func (te *turnEngine) finish(status string) {
|
||||
te.sess.Status = status
|
||||
te.sess.Phase = CombatPhaseOver
|
||||
}
|
||||
|
||||
// commit folds this step's combatState back into the session struct: HP,
|
||||
// round, the between-round status snapshot, and the appended event log.
|
||||
// Phase / Status were already set by step. saveCombatSession persists it.
|
||||
//
|
||||
// The Buff* stat deltas on Statuses are NOT combatState fields — they're owned
|
||||
// by the command layer (a !cast / !consume folds them in) and applied to the
|
||||
// rebuilt combatant by applySessionBuffs — so commit mutates Statuses in place
|
||||
// rather than replacing it, leaving those deltas untouched.
|
||||
func (te *turnEngine) commit() {
|
||||
st := te.st
|
||||
te.sess.Round = st.round
|
||||
te.sess.PlayerHP = st.playerHP
|
||||
te.sess.EnemyHP = st.enemyHP
|
||||
s := &te.sess.Statuses
|
||||
s.PoisonTicks = st.poisonTicks
|
||||
s.PoisonDmg = st.poisonDmg
|
||||
s.StunPlayer = st.stunPlayer
|
||||
s.Enraged = st.enraged
|
||||
s.ArmorBroken = st.armorBroken
|
||||
s.ArmorBreakAmt = st.armorBreakAmt
|
||||
s.EnemySkipNext = st.enemySkipFirst
|
||||
s.PetProcReady = st.petProcReady
|
||||
s.WardCharges = st.wardCharges
|
||||
s.SporeRounds = st.sporeRounds
|
||||
s.ReflectFrac = st.reflectFrac
|
||||
s.AutoCritFirst = st.autoCrit
|
||||
s.ArcaneWardHP = st.arcaneWardHP
|
||||
s.HealChargesLeft = st.healChargesLeft
|
||||
s.DeathSaveUsed = st.deathSaveUsed
|
||||
s.LuckyUsed = st.luckyUsed
|
||||
s.Raged = st.raged
|
||||
s.PendingRage = st.pendingRageAttack
|
||||
s.FirstAtkBonusUsed = st.firstAttackBonusUsed
|
||||
s.AssassinateReroll = st.assassinateRerollUsed
|
||||
s.AssassinateBonus = st.assassinateBonusUsed
|
||||
s.EnemyEvadeNext = st.enemyEvadeNext
|
||||
s.EnemyBlockUp = st.enemyBlockUp
|
||||
s.EnemyAdvantage = st.enemyAdvantage
|
||||
s.EnemyRetaliateFrac = st.enemyRetaliateFrac
|
||||
s.EnemyRegen = st.enemyRegen
|
||||
s.EnemySurviveArmed = st.enemySurviveArmed
|
||||
s.PlayerAtkDrain = st.playerAtkDrain
|
||||
s.PlayerACDebuff = st.playerACDebuff
|
||||
s.MaxHPDrain = st.maxHPDrain
|
||||
s.EnemySpellResist = st.enemySpellResist
|
||||
s.EnemyRevealNext = st.enemyRevealNext
|
||||
s.EnemyFearImmune = st.enemyFearImmune
|
||||
s.EnemyAtkBuff = st.enemyAtkBuff
|
||||
te.sess.TurnLog = append(te.sess.TurnLog, st.events...)
|
||||
}
|
||||
|
||||
// advanceCombatSession resolves one phase of the fight and persists the result.
|
||||
// It is the single entry point callers (commands, reaper) should use: it seeds
|
||||
// the deterministic RNG, resumes the engine, steps, commits, and saves. The
|
||||
// passed sess is mutated in place. The events generated this step are returned.
|
||||
func advanceCombatSession(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
|
||||
if sess == nil {
|
||||
return nil, ErrNoActiveCombatSession
|
||||
}
|
||||
rng := combatSessionRNG(sess)
|
||||
te := resumeTurnEngine(sess, player, enemy, rng)
|
||||
events, err := te.step(action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
te.commit()
|
||||
if err := saveCombatSession(sess); err != nil {
|
||||
return events, fmt.Errorf("persist combat session %s: %w", sess.SessionID, err)
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
+89
-20
@@ -35,16 +35,32 @@ const (
|
||||
ClassMage DnDClass = "mage"
|
||||
ClassCleric DnDClass = "cleric"
|
||||
ClassRanger DnDClass = "ranger"
|
||||
|
||||
// Caster classes — fully playable. Wired through the mechanical layer
|
||||
// (HP/AC, slot tables, spellcasting ability) and shipped alongside the
|
||||
// Open5e spell-data import: Playable=true and spell lists populated.
|
||||
// Each chassis has a Phase-2/3 balance rider in applyClassPassives plus
|
||||
// a subclass entry at L3/L5 (see dnd_subclass*.go).
|
||||
ClassDruid DnDClass = "druid"
|
||||
ClassBard DnDClass = "bard"
|
||||
ClassSorcerer DnDClass = "sorcerer"
|
||||
ClassWarlock DnDClass = "warlock"
|
||||
ClassPaladin DnDClass = "paladin"
|
||||
)
|
||||
|
||||
type DnDRaceInfo struct {
|
||||
Key DnDRace
|
||||
Display string
|
||||
// Stat modifiers applied at setup-confirm time. STR/DEX/CON/INT/WIS/CHA.
|
||||
// Human's "+1 to any" is not yet implemented in Phase 1 — Human gets +0
|
||||
// across the board and we'll add the floating bonus in a later phase.
|
||||
// Human takes the Standard Human flavor: +1 to every stat, no setup-wizard
|
||||
// choice (keeps !setup uniform across races).
|
||||
Mods [6]int
|
||||
Passive string
|
||||
// BestFit is the menu-time hint that frames a stat spread as a specialist
|
||||
// build rather than a list of penalties (Orc with three -1s reads brutal
|
||||
// out of context; "best with: Barbarian, Fighter" reframes it). Comma-
|
||||
// separated class display names; rendered by renderRaceMenu.
|
||||
BestFit string
|
||||
}
|
||||
|
||||
type DnDClassInfo struct {
|
||||
@@ -54,24 +70,43 @@ type DnDClassInfo struct {
|
||||
HPAvg int // per-level average after L1 (roundup of die/2 + 1)
|
||||
PrimaryA string // primary stat for narrative (not mechanical in Phase 1)
|
||||
PrimaryB string
|
||||
// Playable gates whether the class is offered at !setup. The Open5e
|
||||
// caster classes are wired through the engine but stay false until
|
||||
// their spell lists exist — see the ClassDruid… block above.
|
||||
Playable bool
|
||||
}
|
||||
|
||||
// Race mods are tuned for equal *effective* power, not equal net total.
|
||||
// The weighted balance pass (dnd_race_balance.go) scores each race's mods
|
||||
// against a blend of per-class combat priorities and class-independent
|
||||
// non-combat utility (zone locks, harvest, skills, haggling). These blocks
|
||||
// are tuned so every race's mean score across all playable classes lands
|
||||
// near the Standard Human baseline of 6.0. Net mod totals vary (Elf +7,
|
||||
// Orc +6) — a spiky race concentrated into high-value stats needs fewer
|
||||
// points to match a flat one.
|
||||
var dndRaces = []DnDRaceInfo{
|
||||
{RaceHuman, "Human", [6]int{0, 0, 0, 0, 0, 0}, "Versatile (floating +1 bonus not yet implemented)"},
|
||||
{RaceElf, "Elf", [6]int{0, 2, -1, 1, 1, 0}, "Darkvision; immune to sleep effects"},
|
||||
{RaceDwarf, "Dwarf", [6]int{1, -1, 2, 0, 1, -1}, "Poison resistance; bonus vs. underground enemies"},
|
||||
{RaceHalfling, "Halfling", [6]int{0, 2, 0, 0, 1, 0}, "Lucky: once per combat, reroll a natural 1"},
|
||||
{RaceOrc, "Orc", [6]int{3, -1, 2, -1, -1, -1}, "Rage: once per combat, +50% damage for one turn"},
|
||||
{RaceTiefling, "Tiefling", [6]int{0, 1, 0, 1, 0, 2}, "Fire resistance; bonus on CHA checks"},
|
||||
{RaceHalfElf, "Half-Elf", [6]int{0, 1, 0, 1, 0, 2}, "Two bonus skill proficiencies"},
|
||||
{RaceHuman, "Human", [6]int{1, 1, 1, 1, 1, 1}, "Versatile: +1 to every ability score", "any class"},
|
||||
{RaceElf, "Elf", [6]int{0, 3, -1, 2, 3, 0}, "Keen senses; trance keeps you sharp through long marches", "Ranger, Mage, Druid"},
|
||||
{RaceDwarf, "Dwarf", [6]int{2, -1, 3, 1, 1, -1}, "Poison resistance; sure-footed in the deep places", "Fighter, Cleric, Paladin"},
|
||||
{RaceHalfling, "Halfling", [6]int{0, 3, 1, 0, 2, 0}, "Lucky: once per combat, reroll a natural 1", "Rogue, Bard, Ranger"},
|
||||
{RaceOrc, "Orc", [6]int{6, -1, 3, -1, -1, 0}, "Rage: once per combat, +50% damage for one turn", "Fighter, Ranger"},
|
||||
{RaceTiefling, "Tiefling", [6]int{0, 2, 0, 1, 0, 3}, "Fiendish heritage: incoming fire damage halved", "Sorcerer, Warlock, Bard"},
|
||||
{RaceHalfElf, "Half-Elf", [6]int{0, 2, 0, 1, 1, 2}, "Adaptable: cross-cultural know-how, equally at ease in any company", "Bard, Paladin, Sorcerer"},
|
||||
}
|
||||
|
||||
var dndClasses = []DnDClassInfo{
|
||||
{ClassFighter, "Fighter", 10, 6, "STR", "CON"},
|
||||
{ClassRogue, "Rogue", 8, 5, "DEX", "INT"},
|
||||
{ClassMage, "Mage", 6, 4, "INT", "WIS"},
|
||||
{ClassCleric, "Cleric", 8, 5, "WIS", "CHA"},
|
||||
{ClassRanger, "Ranger", 8, 5, "DEX", "WIS"},
|
||||
{ClassFighter, "Fighter", 10, 6, "STR", "CON", true},
|
||||
{ClassRogue, "Rogue", 8, 5, "DEX", "INT", true},
|
||||
{ClassMage, "Mage", 6, 4, "INT", "WIS", true},
|
||||
{ClassCleric, "Cleric", 8, 5, "WIS", "CHA", true},
|
||||
{ClassRanger, "Ranger", 8, 5, "DEX", "WIS", true},
|
||||
// Open5e caster scaffold — Playable flipped on once the SRD spell import
|
||||
// gave each of them a real spell list (see dnd_spells_srd_data.go).
|
||||
{ClassDruid, "Druid", 8, 5, "WIS", "CON", true},
|
||||
{ClassBard, "Bard", 8, 5, "CHA", "DEX", true},
|
||||
{ClassSorcerer, "Sorcerer", 6, 4, "CHA", "CON", true},
|
||||
{ClassWarlock, "Warlock", 8, 5, "CHA", "CON", true},
|
||||
{ClassPaladin, "Paladin", 10, 6, "STR", "CHA", true},
|
||||
}
|
||||
|
||||
func raceInfo(r DnDRace) (DnDRaceInfo, bool) {
|
||||
@@ -110,8 +145,26 @@ func abilityModifier(score int) int {
|
||||
return diff/2 - 1
|
||||
}
|
||||
|
||||
// phase5BHPMult is the Phase 5-B HP-floor multiplier. Applied uniformly
|
||||
// in computeMaxHP so every class/level scales together — preserves the
|
||||
// class-balance harness's parity assertions (which test in-tier spread,
|
||||
// not absolute win rates) while lifting durability enough to land the
|
||||
// expedition harness in the "fairly breezy with some death" band the
|
||||
// user chose for Phase 5-B. See gogobee_expedition_difficulty.md for
|
||||
// the sweep that picked 1.5 (gear-bonus floor +3, HP ×1.5 → T1=85 T2=73
|
||||
// T3=60 T4=78 T5=81 mean completion at the tier-centerline cell).
|
||||
//
|
||||
// Bumping this changes the *persisted* HPMax for new characters and for
|
||||
// existing characters on their next computeMaxHP recall (level-up,
|
||||
// character reset, bootstrap refresh). The Phase 5-B bootstrap
|
||||
// (bootstrap_phase5b_hp.go) walks the dnd_character table once at
|
||||
// startup to refresh stale rows.
|
||||
const phase5BHPMult = 1.5
|
||||
|
||||
// computeMaxHP — D&D5e style. L1: full HP die + CON mod (min 1).
|
||||
// Per level after: average HP die (roundup of die/2 + 1) + CON mod.
|
||||
// The result is scaled by phase5BHPMult — see that constant for the
|
||||
// difficulty-pass motivation.
|
||||
func computeMaxHP(class DnDClass, conMod, level int) int {
|
||||
ci, ok := classInfo(class)
|
||||
if !ok {
|
||||
@@ -128,7 +181,12 @@ func computeMaxHP(class DnDClass, conMod, level int) int {
|
||||
}
|
||||
hp += gain
|
||||
}
|
||||
return hp
|
||||
// Phase 5-B player power floor — round to nearest int.
|
||||
scaled := int(float64(hp)*phase5BHPMult + 0.5)
|
||||
if scaled < 1 {
|
||||
scaled = 1
|
||||
}
|
||||
return scaled
|
||||
}
|
||||
|
||||
// computeAC — Phase 1 baseline. Equipment-derived AC arrives in Phase 4
|
||||
@@ -137,11 +195,11 @@ func computeMaxHP(class DnDClass, conMod, level int) int {
|
||||
func computeAC(class DnDClass, dexMod int) int {
|
||||
floor := 0
|
||||
switch class {
|
||||
case ClassFighter:
|
||||
floor = 6 // medium-armor baseline
|
||||
case ClassCleric, ClassRanger:
|
||||
case ClassFighter, ClassPaladin:
|
||||
floor = 6 // heavy/medium-armor baseline
|
||||
case ClassCleric, ClassRanger, ClassDruid:
|
||||
floor = 3
|
||||
case ClassRogue:
|
||||
case ClassRogue, ClassBard, ClassWarlock:
|
||||
floor = 1
|
||||
}
|
||||
return 10 + dexMod + floor
|
||||
@@ -187,6 +245,11 @@ type DnDCharacter struct {
|
||||
// long rest. 5e caps at 6 (death); we let it grow and let consumers
|
||||
// clamp where needed.
|
||||
Exhaustion int
|
||||
// 2026-05-10 immersion: short rest charges (1/level, restored on long
|
||||
// rest). resting_until gates !zone enter / !expedition start while the
|
||||
// character is "still resting" — short rest = 1h, long rest = 8h.
|
||||
ShortRestCharges int
|
||||
RestingUntil *time.Time
|
||||
LastRespecAt *time.Time
|
||||
LastShortRestAt *time.Time
|
||||
LastLongRestAt *time.Time
|
||||
@@ -216,6 +279,7 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) {
|
||||
pending_setup, auto_migrated, onboarding_sent, armed_ability,
|
||||
pending_cast, concentration_spell, concentration_expires_at,
|
||||
subclass, last_subclass_respec_at, exhaustion,
|
||||
short_rest_charges, resting_until,
|
||||
last_respec_at, last_short_rest_at, last_long_rest_at,
|
||||
created_at, updated_at
|
||||
FROM dnd_character WHERE user_id = ?`, string(userID))
|
||||
@@ -229,6 +293,7 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) {
|
||||
&pending, &autoMig, &onboard, &c.ArmedAbility,
|
||||
&c.PendingCast, &c.ConcentrationSpell, &c.ConcentrationExpiresAt,
|
||||
&subclassStr, &c.LastSubclassRespecAt, &c.Exhaustion,
|
||||
&c.ShortRestCharges, &c.RestingUntil,
|
||||
&c.LastRespecAt, &c.LastShortRestAt, &c.LastLongRestAt,
|
||||
&c.CreatedAt, &c.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -268,8 +333,9 @@ func SaveDnDCharacter(c *DnDCharacter) error {
|
||||
pending_setup, auto_migrated, onboarding_sent, armed_ability,
|
||||
pending_cast, concentration_spell, concentration_expires_at,
|
||||
subclass, last_subclass_respec_at, exhaustion,
|
||||
short_rest_charges, resting_until,
|
||||
last_respec_at, last_short_rest_at, last_long_rest_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
race=excluded.race, class=excluded.class,
|
||||
dnd_level=excluded.dnd_level, dnd_xp=excluded.dnd_xp,
|
||||
@@ -288,6 +354,8 @@ func SaveDnDCharacter(c *DnDCharacter) error {
|
||||
subclass=excluded.subclass,
|
||||
last_subclass_respec_at=excluded.last_subclass_respec_at,
|
||||
exhaustion=excluded.exhaustion,
|
||||
short_rest_charges=excluded.short_rest_charges,
|
||||
resting_until=excluded.resting_until,
|
||||
last_respec_at=excluded.last_respec_at,
|
||||
last_short_rest_at=excluded.last_short_rest_at,
|
||||
last_long_rest_at=excluded.last_long_rest_at,
|
||||
@@ -298,6 +366,7 @@ func SaveDnDCharacter(c *DnDCharacter) error {
|
||||
pending, autoMig, onboard, c.ArmedAbility,
|
||||
c.PendingCast, c.ConcentrationSpell, c.ConcentrationExpiresAt,
|
||||
string(c.Subclass), c.LastSubclassRespecAt, c.Exhaustion,
|
||||
c.ShortRestCharges, c.RestingUntil,
|
||||
c.LastRespecAt, c.LastShortRestAt, c.LastLongRestAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save dnd_character: %w", err)
|
||||
|
||||
@@ -357,6 +357,59 @@ func displayAbility(id string) string {
|
||||
|
||||
// ── Combat hook ──────────────────────────────────────────────────────────────
|
||||
|
||||
// simAutoArmEnabled, when true, lets the combat builders pre-arm a
|
||||
// class-default ability for any character entering combat with an empty
|
||||
// ArmedAbility slot. The expedition-sim flips this on so synthetic
|
||||
// players model a competent real player (who would `!arm` Second Wind
|
||||
// before each fight) instead of a player who never touches the
|
||||
// !arm command. Untouched in production code paths.
|
||||
var simAutoArmEnabled = false
|
||||
|
||||
// simAutoArmDefaultFor returns the class-default ability id the sim
|
||||
// should pre-arm. Defensive heals (Second Wind, Healing Word) only —
|
||||
// burning a Mage's spell slot every fight would mask J2, not help it.
|
||||
func simAutoArmDefaultFor(class DnDClass) string {
|
||||
switch class {
|
||||
case ClassFighter:
|
||||
return "second_wind"
|
||||
case ClassCleric:
|
||||
return "healing_word"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// trySimAutoArm pre-arms the class-default ability if the global toggle
|
||||
// is on, no ability is currently armed, and the resource pool has at
|
||||
// least one charge. Mirrors handleDnDArmCmd's spend-and-save flow.
|
||||
// Returns the ability name (or "" when nothing was armed).
|
||||
func trySimAutoArm(c *DnDCharacter) string {
|
||||
if !simAutoArmEnabled || c == nil || c.ArmedAbility != "" {
|
||||
return ""
|
||||
}
|
||||
id := simAutoArmDefaultFor(c.Class)
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
ab, ok := dndActiveAbilities[id]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
cur, _, err := getResource(c.UserID, ab.Resource)
|
||||
if err != nil || cur < 1 {
|
||||
return ""
|
||||
}
|
||||
c.ArmedAbility = ab.ID
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
return ""
|
||||
}
|
||||
if ok, _ := spendResource(c.UserID, ab.Resource, 1); !ok {
|
||||
c.ArmedAbility = ""
|
||||
_ = SaveDnDCharacter(c)
|
||||
return ""
|
||||
}
|
||||
return ab.Name
|
||||
}
|
||||
|
||||
// applyArmedAbility checks for a pre-armed ability on the character and
|
||||
// applies its effect to the player's CombatModifiers, then clears the armed
|
||||
// flag. Called from combat_bridge.go before SimulateCombat.
|
||||
|
||||
@@ -294,11 +294,16 @@ func TestSetupStatus_NoStubOnFirstSetup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDnDHPScaling_NeverExceedsOriginalMax(t *testing.T) {
|
||||
stats := CombatStats{MaxHP: 100}
|
||||
c := &DnDCharacter{HPMax: 50, HPCurrent: 100} // pathological: 200% — shouldn't happen but be safe
|
||||
func TestApplyDnDHPScaling_NeverExceedsCombatMax(t *testing.T) {
|
||||
// Pathological: hp_current somehow above hp_max — early-return path
|
||||
// (full HP) leaves MaxHP unchanged and StartHP unset.
|
||||
stats := CombatStats{MaxHP: 100, HPBonus: 50}
|
||||
c := &DnDCharacter{HPMax: 50, HPCurrent: 100}
|
||||
applyDnDHPScaling(&stats, c)
|
||||
if stats.MaxHP > 100 {
|
||||
t.Errorf("clamp failed: MaxHP scaled to %d, original was 100", stats.MaxHP)
|
||||
if stats.MaxHP != 100 {
|
||||
t.Errorf("MaxHP clobbered: %d, want unchanged 100", stats.MaxHP)
|
||||
}
|
||||
if stats.StartHP > stats.MaxHP {
|
||||
t.Errorf("StartHP=%d exceeds MaxHP=%d", stats.StartHP, stats.MaxHP)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,13 +23,19 @@ type DnDMonsterTemplate struct {
|
||||
Ability *MonsterAbility
|
||||
XPValue int
|
||||
Notes string
|
||||
|
||||
// FireAttacker: signature damage is fire. Tieflings (FireResist) take half
|
||||
// damage from the enemy's main attack and from any aoe_fire ability rider.
|
||||
// Tuned-bestiary generator sets this from SRD attack DamageType; the
|
||||
// hand-authored entries below tag it explicitly.
|
||||
FireAttacker bool
|
||||
}
|
||||
|
||||
// dndBestiary is the canonical lookup. Keyed by ID.
|
||||
var dndBestiary = map[string]DnDMonsterTemplate{
|
||||
"goblin": {
|
||||
ID: "goblin", Name: "Goblin",
|
||||
CR: 0.25, HP: 7, AC: 13, Attack: 6, AttackBonus: 4, Speed: 14,
|
||||
CR: 0.25, HP: 7, AC: 13, Attack: 1, AttackBonus: 4, Speed: 14,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Nimble Escape", Phase: "any", ProcChance: 0.20, Effect: "stun"},
|
||||
XPValue: 30,
|
||||
@@ -37,7 +43,7 @@ var dndBestiary = map[string]DnDMonsterTemplate{
|
||||
},
|
||||
"skeleton": {
|
||||
ID: "skeleton", Name: "Skeleton",
|
||||
CR: 0.25, HP: 13, AC: 13, Attack: 8, AttackBonus: 4, Speed: 10,
|
||||
CR: 0.25, HP: 13, AC: 13, Attack: 1, AttackBonus: 4, Speed: 10,
|
||||
BlockRate: 0.10,
|
||||
// Skeletons are immune to poison; we'd model that with a future
|
||||
// CombatModifiers.PoisonImmunity flag. For Phase 7, no ability.
|
||||
@@ -46,7 +52,7 @@ var dndBestiary = map[string]DnDMonsterTemplate{
|
||||
},
|
||||
"orc_grunt": {
|
||||
ID: "orc_grunt", Name: "Orc Grunt",
|
||||
CR: 0.5, HP: 15, AC: 13, Attack: 10, AttackBonus: 5, Speed: 11,
|
||||
CR: 0.5, HP: 15, AC: 13, Attack: 2, AttackBonus: 5, Speed: 11,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Aggressive", Phase: "opening", ProcChance: 0.30, Effect: "enrage"},
|
||||
XPValue: 100,
|
||||
@@ -54,7 +60,7 @@ var dndBestiary = map[string]DnDMonsterTemplate{
|
||||
},
|
||||
"troll": {
|
||||
ID: "troll", Name: "Troll",
|
||||
CR: 5, HP: 84, AC: 15, Attack: 22, AttackBonus: 7, Speed: 8,
|
||||
CR: 5, HP: 84, AC: 15, Attack: 8, AttackBonus: 7, Speed: 8,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Regeneration", Phase: "any", ProcChance: 0.50, Effect: "lifesteal"},
|
||||
XPValue: 1800,
|
||||
@@ -62,7 +68,7 @@ var dndBestiary = map[string]DnDMonsterTemplate{
|
||||
},
|
||||
"wyvern": {
|
||||
ID: "wyvern", Name: "Wyvern",
|
||||
CR: 6, HP: 110, AC: 13, Attack: 28, AttackBonus: 7, Speed: 16,
|
||||
CR: 6, HP: 110, AC: 13, Attack: 10, AttackBonus: 7, Speed: 16,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Poison Sting", Phase: "decisive", ProcChance: 0.60, Effect: "poison"},
|
||||
XPValue: 2300,
|
||||
@@ -70,7 +76,7 @@ var dndBestiary = map[string]DnDMonsterTemplate{
|
||||
},
|
||||
"ancient_dragon": {
|
||||
ID: "ancient_dragon", Name: "Ancient Dragon",
|
||||
CR: 20, HP: 367, AC: 22, Attack: 65, AttackBonus: 14, Speed: 18,
|
||||
CR: 20, HP: 367, AC: 22, Attack: 33, AttackBonus: 11, Speed: 18,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.80, Effect: "stun"},
|
||||
XPValue: 25000,
|
||||
@@ -89,7 +95,7 @@ var _ = func() bool {
|
||||
tier1 := map[string]DnDMonsterTemplate{
|
||||
"goblin_sneak": {
|
||||
ID: "goblin_sneak", Name: "Goblin Sneak",
|
||||
CR: 0.25, HP: 7, AC: 13, Attack: 6, AttackBonus: 4, Speed: 14,
|
||||
CR: 0.25, HP: 7, AC: 13, Attack: 1, AttackBonus: 4, Speed: 14,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.25, Effect: "advantage"},
|
||||
XPValue: 30,
|
||||
@@ -97,7 +103,7 @@ var _ = func() bool {
|
||||
},
|
||||
"goblin_archer": {
|
||||
ID: "goblin_archer", Name: "Goblin Archer",
|
||||
CR: 0.25, HP: 7, AC: 13, Attack: 5, AttackBonus: 4, Speed: 14,
|
||||
CR: 0.25, HP: 7, AC: 13, Attack: 1, AttackBonus: 4, Speed: 14,
|
||||
BlockRate: 0.0,
|
||||
Ability: &MonsterAbility{Name: "Scurry", Phase: "any", ProcChance: 0.30, Effect: "evade"},
|
||||
XPValue: 30,
|
||||
@@ -105,7 +111,7 @@ var _ = func() bool {
|
||||
},
|
||||
"hobgoblin_grunt": {
|
||||
ID: "hobgoblin_grunt", Name: "Hobgoblin Grunt",
|
||||
CR: 0.5, HP: 11, AC: 18, Attack: 9, AttackBonus: 3, Speed: 11,
|
||||
CR: 0.5, HP: 11, AC: 18, Attack: 2, AttackBonus: 3, Speed: 11,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Martial Advantage", Phase: "any", ProcChance: 0.30, Effect: "bonus_damage"},
|
||||
XPValue: 100,
|
||||
@@ -113,7 +119,7 @@ var _ = func() bool {
|
||||
},
|
||||
"worg": {
|
||||
ID: "worg", Name: "Worg",
|
||||
CR: 0.5, HP: 26, AC: 13, Attack: 10, AttackBonus: 5, Speed: 17,
|
||||
CR: 0.5, HP: 26, AC: 13, Attack: 2, AttackBonus: 5, Speed: 17,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Knock Prone", Phase: "any", ProcChance: 0.25, Effect: "stun"},
|
||||
XPValue: 100,
|
||||
@@ -121,7 +127,7 @@ var _ = func() bool {
|
||||
},
|
||||
"goblin_shaman": {
|
||||
ID: "goblin_shaman", Name: "Goblin Shaman",
|
||||
CR: 1, HP: 22, AC: 11, Attack: 12, AttackBonus: 4, Speed: 12,
|
||||
CR: 1, HP: 22, AC: 11, Attack: 2, AttackBonus: 4, Speed: 12,
|
||||
BlockRate: 0.0,
|
||||
Ability: &MonsterAbility{Name: "Burning Hands", Phase: "opening", ProcChance: 0.50, Effect: "aoe_fire"},
|
||||
XPValue: 200,
|
||||
@@ -129,7 +135,7 @@ var _ = func() bool {
|
||||
},
|
||||
"hobgoblin_warchief": {
|
||||
ID: "hobgoblin_warchief", Name: "Hobgoblin Warchief",
|
||||
CR: 2, HP: 52, AC: 18, Attack: 18, AttackBonus: 5, Speed: 11,
|
||||
CR: 2, HP: 52, AC: 18, Attack: 4, AttackBonus: 5, Speed: 11,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Leadership Aura", Phase: "any", ProcChance: 0.40, Effect: "ally_buff"},
|
||||
XPValue: 450,
|
||||
@@ -137,7 +143,7 @@ var _ = func() bool {
|
||||
},
|
||||
"zombie": {
|
||||
ID: "zombie", Name: "Zombie",
|
||||
CR: 0.25, HP: 22, AC: 10, Attack: 5, AttackBonus: 3, Speed: 6,
|
||||
CR: 0.25, HP: 22, AC: 10, Attack: 1, AttackBonus: 3, Speed: 6,
|
||||
BlockRate: 0.0,
|
||||
Ability: &MonsterAbility{Name: "Undead Fortitude", Phase: "decisive", ProcChance: 0.50, Effect: "survive_at_1"},
|
||||
XPValue: 50,
|
||||
@@ -145,7 +151,7 @@ var _ = func() bool {
|
||||
},
|
||||
"shadow": {
|
||||
ID: "shadow", Name: "Shadow",
|
||||
CR: 0.5, HP: 16, AC: 12, Attack: 9, AttackBonus: 4, Speed: 12,
|
||||
CR: 0.5, HP: 16, AC: 12, Attack: 2, AttackBonus: 4, Speed: 12,
|
||||
BlockRate: 0.0,
|
||||
Ability: &MonsterAbility{Name: "Strength Drain", Phase: "any", ProcChance: 0.35, Effect: "stat_drain"},
|
||||
XPValue: 100,
|
||||
@@ -153,7 +159,7 @@ var _ = func() bool {
|
||||
},
|
||||
"specter": {
|
||||
ID: "specter", Name: "Specter",
|
||||
CR: 1, HP: 22, AC: 12, Attack: 10, AttackBonus: 4, Speed: 14,
|
||||
CR: 1, HP: 22, AC: 12, Attack: 2, AttackBonus: 4, Speed: 14,
|
||||
BlockRate: 0.0,
|
||||
Ability: &MonsterAbility{Name: "Life Drain", Phase: "any", ProcChance: 0.40, Effect: "lifesteal"},
|
||||
XPValue: 200,
|
||||
@@ -161,7 +167,7 @@ var _ = func() bool {
|
||||
},
|
||||
"wight": {
|
||||
ID: "wight", Name: "Wight",
|
||||
CR: 3, HP: 45, AC: 14, Attack: 14, AttackBonus: 4, Speed: 12,
|
||||
CR: 3, HP: 45, AC: 14, Attack: 6, AttackBonus: 4, Speed: 12,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Life Drain", Phase: "any", ProcChance: 0.45, Effect: "lifesteal"},
|
||||
XPValue: 700,
|
||||
@@ -169,15 +175,16 @@ var _ = func() bool {
|
||||
},
|
||||
"flameskull": {
|
||||
ID: "flameskull", Name: "Flameskull",
|
||||
CR: 4, HP: 40, AC: 13, Attack: 18, AttackBonus: 5, Speed: 15,
|
||||
CR: 4, HP: 40, AC: 13, Attack: 7, AttackBonus: 5, Speed: 15,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Fireball", Phase: "decisive", ProcChance: 0.55, Effect: "aoe_fire"},
|
||||
XPValue: 1100,
|
||||
Notes: "Elite. Fireball 8d6 DEX DC 13. Rejuvenation in 1h unless holy water used.",
|
||||
FireAttacker: true,
|
||||
},
|
||||
"boss_grol_unbroken": {
|
||||
ID: "boss_grol_unbroken", Name: "Grol the Unbroken",
|
||||
CR: 3, HP: 65, AC: 17, Attack: 22, AttackBonus: 5, Speed: 12,
|
||||
CR: 3, HP: 65, AC: 17, Attack: 6, AttackBonus: 5, Speed: 12,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Surprise Attack", Phase: "opening", ProcChance: 1.0, Effect: "bonus_damage"},
|
||||
XPValue: 700,
|
||||
@@ -185,7 +192,7 @@ var _ = func() bool {
|
||||
},
|
||||
"boss_valdris_unburied": {
|
||||
ID: "boss_valdris_unburied", Name: "Valdris the Unburied",
|
||||
CR: 5, HP: 97, AC: 17, Attack: 28, AttackBonus: 7, Speed: 12,
|
||||
CR: 5, HP: 97, AC: 17, Attack: 8, AttackBonus: 7, Speed: 12,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Corrupting Touch", Phase: "any", ProcChance: 0.50, Effect: "max_hp_drain"},
|
||||
XPValue: 1800,
|
||||
@@ -209,7 +216,7 @@ var _ = func() bool {
|
||||
tier2 := map[string]DnDMonsterTemplate{
|
||||
"dire_wolf": {
|
||||
ID: "dire_wolf", Name: "Dire Wolf",
|
||||
CR: 1, HP: 37, AC: 14, Attack: 10, AttackBonus: 5, Speed: 16,
|
||||
CR: 1, HP: 37, AC: 14, Attack: 2, AttackBonus: 5, Speed: 16,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.30, Effect: "advantage"},
|
||||
XPValue: 200,
|
||||
@@ -217,7 +224,7 @@ var _ = func() bool {
|
||||
},
|
||||
"bandit_captain": {
|
||||
ID: "bandit_captain", Name: "Bandit Captain",
|
||||
CR: 2, HP: 65, AC: 15, Attack: 14, AttackBonus: 5, Speed: 12,
|
||||
CR: 2, HP: 65, AC: 15, Attack: 4, AttackBonus: 5, Speed: 12,
|
||||
BlockRate: 0.20,
|
||||
Ability: &MonsterAbility{Name: "Parry", Phase: "any", ProcChance: 0.35, Effect: "block"},
|
||||
XPValue: 450,
|
||||
@@ -225,7 +232,7 @@ var _ = func() bool {
|
||||
},
|
||||
"owlbear": {
|
||||
ID: "owlbear", Name: "Owlbear",
|
||||
CR: 3, HP: 59, AC: 13, Attack: 16, AttackBonus: 7, Speed: 12,
|
||||
CR: 3, HP: 59, AC: 13, Attack: 6, AttackBonus: 7, Speed: 12,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Grapple", Phase: "any", ProcChance: 0.40, Effect: "stun"},
|
||||
XPValue: 700,
|
||||
@@ -233,7 +240,7 @@ var _ = func() bool {
|
||||
},
|
||||
"dryad_corrupted": {
|
||||
ID: "dryad_corrupted", Name: "Corrupted Dryad",
|
||||
CR: 2, HP: 22, AC: 11, Attack: 11, AttackBonus: 4, Speed: 12,
|
||||
CR: 2, HP: 22, AC: 11, Attack: 4, AttackBonus: 4, Speed: 12,
|
||||
BlockRate: 0.0,
|
||||
Ability: &MonsterAbility{Name: "Fey Charm", Phase: "any", ProcChance: 0.40, Effect: "stun"},
|
||||
XPValue: 450,
|
||||
@@ -241,7 +248,7 @@ var _ = func() bool {
|
||||
},
|
||||
"displacer_beast": {
|
||||
ID: "displacer_beast", Name: "Displacer Beast",
|
||||
CR: 3, HP: 85, AC: 13, Attack: 14, AttackBonus: 6, Speed: 14,
|
||||
CR: 3, HP: 85, AC: 13, Attack: 6, AttackBonus: 6, Speed: 14,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Displacement", Phase: "opening", ProcChance: 0.50, Effect: "evade"},
|
||||
XPValue: 700,
|
||||
@@ -249,7 +256,7 @@ var _ = func() bool {
|
||||
},
|
||||
"green_hag": {
|
||||
ID: "green_hag", Name: "Green Hag",
|
||||
CR: 3, HP: 82, AC: 17, Attack: 15, AttackBonus: 5, Speed: 12,
|
||||
CR: 3, HP: 82, AC: 17, Attack: 6, AttackBonus: 5, Speed: 12,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Invisible Passage", Phase: "any", ProcChance: 0.35, Effect: "evade"},
|
||||
XPValue: 700,
|
||||
@@ -257,7 +264,7 @@ var _ = func() bool {
|
||||
},
|
||||
"kuo_toa": {
|
||||
ID: "kuo_toa", Name: "Kuo-toa",
|
||||
CR: 0.25, HP: 18, AC: 13, Attack: 5, AttackBonus: 3, Speed: 10,
|
||||
CR: 0.25, HP: 18, AC: 13, Attack: 1, AttackBonus: 3, Speed: 10,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Sticky Shield", Phase: "any", ProcChance: 0.25, Effect: "block"},
|
||||
XPValue: 30,
|
||||
@@ -265,7 +272,7 @@ var _ = func() bool {
|
||||
},
|
||||
"kuo_toa_whip": {
|
||||
ID: "kuo_toa_whip", Name: "Kuo-toa Whip",
|
||||
CR: 1, HP: 65, AC: 11, Attack: 11, AttackBonus: 3, Speed: 10,
|
||||
CR: 1, HP: 65, AC: 11, Attack: 2, AttackBonus: 3, Speed: 10,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Divine Eminence", Phase: "decisive", ProcChance: 0.50, Effect: "bonus_damage"},
|
||||
XPValue: 200,
|
||||
@@ -273,7 +280,7 @@ var _ = func() bool {
|
||||
},
|
||||
"water_elemental": {
|
||||
ID: "water_elemental", Name: "Water Elemental",
|
||||
CR: 5, HP: 114, AC: 14, Attack: 26, AttackBonus: 7, Speed: 12,
|
||||
CR: 5, HP: 114, AC: 14, Attack: 8, AttackBonus: 7, Speed: 12,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Whelm", Phase: "any", ProcChance: 0.40, Effect: "stun"},
|
||||
XPValue: 1800,
|
||||
@@ -281,7 +288,7 @@ var _ = func() bool {
|
||||
},
|
||||
"merrow": {
|
||||
ID: "merrow", Name: "Merrow",
|
||||
CR: 2, HP: 45, AC: 13, Attack: 13, AttackBonus: 5, Speed: 12,
|
||||
CR: 2, HP: 45, AC: 13, Attack: 4, AttackBonus: 5, Speed: 12,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Harpoon Pull", Phase: "opening", ProcChance: 0.40, Effect: "stun"},
|
||||
XPValue: 450,
|
||||
@@ -289,7 +296,7 @@ var _ = func() bool {
|
||||
},
|
||||
"aboleth_thrall": {
|
||||
ID: "aboleth_thrall", Name: "Aboleth Thrall",
|
||||
CR: 3, HP: 60, AC: 12, Attack: 14, AttackBonus: 5, Speed: 11,
|
||||
CR: 3, HP: 60, AC: 12, Attack: 6, AttackBonus: 5, Speed: 11,
|
||||
BlockRate: 0.0,
|
||||
Ability: &MonsterAbility{Name: "Psychic Bond", Phase: "any", ProcChance: 0.35, Effect: "reveal_action"},
|
||||
XPValue: 700,
|
||||
@@ -297,7 +304,7 @@ var _ = func() bool {
|
||||
},
|
||||
"boss_hollow_king": {
|
||||
ID: "boss_hollow_king", Name: "The Hollow King",
|
||||
CR: 6, HP: 142, AC: 15, Attack: 30, AttackBonus: 7, Speed: 13,
|
||||
CR: 6, HP: 142, AC: 15, Attack: 10, AttackBonus: 7, Speed: 13,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Root Surge", Phase: "any", ProcChance: 0.45, Effect: "stun"},
|
||||
XPValue: 2300,
|
||||
@@ -305,7 +312,7 @@ var _ = func() bool {
|
||||
},
|
||||
"boss_dreaming_aboleth": {
|
||||
ID: "boss_dreaming_aboleth", Name: "The Dreaming Aboleth",
|
||||
CR: 10, HP: 135, AC: 17, Attack: 36, AttackBonus: 9, Speed: 10,
|
||||
CR: 10, HP: 135, AC: 17, Attack: 16, AttackBonus: 9, Speed: 10,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Enslave", Phase: "any", ProcChance: 0.40, Effect: "stun"},
|
||||
XPValue: 5900,
|
||||
@@ -329,7 +336,7 @@ var _ = func() bool {
|
||||
tier3 := map[string]DnDMonsterTemplate{
|
||||
"poltergeist": {
|
||||
ID: "poltergeist", Name: "Poltergeist",
|
||||
CR: 2, HP: 22, AC: 12, Attack: 10, AttackBonus: 4, Speed: 12,
|
||||
CR: 2, HP: 22, AC: 12, Attack: 4, AttackBonus: 4, Speed: 12,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Telekinetic Thrust", Phase: "any", ProcChance: 0.40, Effect: "stun"},
|
||||
XPValue: 450,
|
||||
@@ -337,7 +344,7 @@ var _ = func() bool {
|
||||
},
|
||||
"banshee": {
|
||||
ID: "banshee", Name: "Banshee",
|
||||
CR: 4, HP: 58, AC: 12, Attack: 14, AttackBonus: 4, Speed: 12,
|
||||
CR: 4, HP: 58, AC: 12, Attack: 7, AttackBonus: 4, Speed: 12,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Wail", Phase: "decisive", ProcChance: 0.20, Effect: "execute"},
|
||||
XPValue: 1100,
|
||||
@@ -345,7 +352,7 @@ var _ = func() bool {
|
||||
},
|
||||
"wraith": {
|
||||
ID: "wraith", Name: "Wraith",
|
||||
CR: 5, HP: 67, AC: 13, Attack: 21, AttackBonus: 6, Speed: 16,
|
||||
CR: 5, HP: 67, AC: 13, Attack: 8, AttackBonus: 6, Speed: 16,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Life Drain", Phase: "any", ProcChance: 0.40, Effect: "max_hp_drain"},
|
||||
XPValue: 1800,
|
||||
@@ -353,7 +360,7 @@ var _ = func() bool {
|
||||
},
|
||||
"vampire_spawn": {
|
||||
ID: "vampire_spawn", Name: "Vampire Spawn",
|
||||
CR: 5, HP: 82, AC: 15, Attack: 16, AttackBonus: 6, Speed: 12,
|
||||
CR: 5, HP: 82, AC: 15, Attack: 8, AttackBonus: 6, Speed: 12,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Bite (Life Drain)", Phase: "any", ProcChance: 0.40, Effect: "self_heal"},
|
||||
XPValue: 1800,
|
||||
@@ -361,7 +368,7 @@ var _ = func() bool {
|
||||
},
|
||||
"revenant": {
|
||||
ID: "revenant", Name: "Revenant",
|
||||
CR: 5, HP: 136, AC: 13, Attack: 25, AttackBonus: 7, Speed: 12,
|
||||
CR: 5, HP: 136, AC: 13, Attack: 8, AttackBonus: 7, Speed: 12,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Vengeful Regeneration", Phase: "any", ProcChance: 0.50, Effect: "regenerate"},
|
||||
XPValue: 1800,
|
||||
@@ -369,7 +376,7 @@ var _ = func() bool {
|
||||
},
|
||||
"boss_aldric_blackspire": {
|
||||
ID: "boss_aldric_blackspire", Name: "Lord Aldric Blackspire",
|
||||
CR: 13, HP: 144, AC: 16, Attack: 38, AttackBonus: 9, Speed: 14,
|
||||
CR: 13, HP: 144, AC: 16, Attack: 21, AttackBonus: 9, Speed: 14,
|
||||
BlockRate: 0.20,
|
||||
Ability: &MonsterAbility{Name: "Charm", Phase: "any", ProcChance: 0.40, Effect: "stun"},
|
||||
XPValue: 10000,
|
||||
@@ -377,39 +384,43 @@ var _ = func() bool {
|
||||
},
|
||||
"magmin": {
|
||||
ID: "magmin", Name: "Magmin",
|
||||
CR: 0.5, HP: 9, AC: 14, Attack: 7, AttackBonus: 3, Speed: 10,
|
||||
CR: 0.5, HP: 9, AC: 14, Attack: 2, AttackBonus: 3, Speed: 10,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1.00, Effect: "death_aoe"},
|
||||
XPValue: 100,
|
||||
Notes: "Death Burst: 2d6 fire in 10 ft on death. Ignite flammable objects on touch.",
|
||||
FireAttacker: true,
|
||||
},
|
||||
"azer": {
|
||||
ID: "azer", Name: "Azer",
|
||||
CR: 2, HP: 39, AC: 17, Attack: 11, AttackBonus: 5, Speed: 12,
|
||||
CR: 2, HP: 39, AC: 17, Attack: 4, AttackBonus: 5, Speed: 12,
|
||||
BlockRate: 0.20,
|
||||
Ability: &MonsterAbility{Name: "Fire Aura", Phase: "any", ProcChance: 0.50, Effect: "retaliate"},
|
||||
XPValue: 450,
|
||||
Notes: "1d10 fire to melee attackers; immune to fire; weapons deal heated bonus damage.",
|
||||
FireAttacker: true,
|
||||
},
|
||||
"salamander": {
|
||||
ID: "salamander", Name: "Salamander",
|
||||
CR: 5, HP: 90, AC: 15, Attack: 22, AttackBonus: 7, Speed: 12,
|
||||
CR: 5, HP: 90, AC: 15, Attack: 8, AttackBonus: 7, Speed: 12,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Constrict", Phase: "any", ProcChance: 0.40, Effect: "stun"},
|
||||
XPValue: 1800,
|
||||
Notes: "Constrict grapple + 2d6 fire/turn; spear multiattack; fire aura.",
|
||||
FireAttacker: true,
|
||||
},
|
||||
"fire_elemental": {
|
||||
ID: "fire_elemental", Name: "Fire Elemental",
|
||||
CR: 5, HP: 102, AC: 13, Attack: 20, AttackBonus: 6, Speed: 14,
|
||||
CR: 5, HP: 102, AC: 13, Attack: 8, AttackBonus: 6, Speed: 14,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Fire Form", Phase: "any", ProcChance: 0.50, Effect: "retaliate"},
|
||||
XPValue: 1800,
|
||||
Notes: "1d10 contact fire damage; immune to fire; vulnerable to water/cold.",
|
||||
FireAttacker: true,
|
||||
},
|
||||
"helmed_horror": {
|
||||
ID: "helmed_horror", Name: "Helmed Horror",
|
||||
CR: 4, HP: 60, AC: 20, Attack: 16, AttackBonus: 6, Speed: 12,
|
||||
CR: 4, HP: 60, AC: 20, Attack: 7, AttackBonus: 6, Speed: 12,
|
||||
BlockRate: 0.25,
|
||||
Ability: &MonsterAbility{Name: "Spell Immunity", Phase: "any", ProcChance: 1.00, Effect: "spell_resist"},
|
||||
XPValue: 1100,
|
||||
@@ -417,11 +428,12 @@ var _ = func() bool {
|
||||
},
|
||||
"boss_emberlord_thyrak": {
|
||||
ID: "boss_emberlord_thyrak", Name: "Emberlord Thyrak",
|
||||
CR: 9, HP: 178, AC: 18, Attack: 34, AttackBonus: 8, Speed: 11,
|
||||
CR: 9, HP: 178, AC: 18, Attack: 14, AttackBonus: 8, Speed: 11,
|
||||
BlockRate: 0.20,
|
||||
Ability: &MonsterAbility{Name: "Forge Breath", Phase: "any", ProcChance: 0.30, Effect: "aoe"},
|
||||
XPValue: 5000,
|
||||
Notes: "Underforge boss. Phase 2 below 50% HP — spawns 2 Fire Elementals; breath recharge 4–6.",
|
||||
FireAttacker: true,
|
||||
},
|
||||
}
|
||||
for id, m := range tier3 {
|
||||
@@ -443,7 +455,7 @@ var _ = func() bool {
|
||||
// ── Underdark ────────────────────────────────────────────────────
|
||||
"drow": {
|
||||
ID: "drow", Name: "Drow",
|
||||
CR: 0.25, HP: 13, AC: 15, Attack: 5, AttackBonus: 4, Speed: 12,
|
||||
CR: 0.25, HP: 13, AC: 15, Attack: 1, AttackBonus: 4, Speed: 12,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Drow Poison", Phase: "any", ProcChance: 0.30, Effect: "stun"},
|
||||
XPValue: 50,
|
||||
@@ -451,7 +463,7 @@ var _ = func() bool {
|
||||
},
|
||||
"drow_elite_warrior": {
|
||||
ID: "drow_elite_warrior", Name: "Drow Elite Warrior",
|
||||
CR: 5, HP: 71, AC: 18, Attack: 18, AttackBonus: 7, Speed: 12,
|
||||
CR: 5, HP: 71, AC: 18, Attack: 8, AttackBonus: 7, Speed: 12,
|
||||
BlockRate: 0.20,
|
||||
Ability: &MonsterAbility{Name: "Parry", Phase: "any", ProcChance: 0.35, Effect: "block"},
|
||||
XPValue: 1800,
|
||||
@@ -459,7 +471,7 @@ var _ = func() bool {
|
||||
},
|
||||
"drow_mage": {
|
||||
ID: "drow_mage", Name: "Drow Mage",
|
||||
CR: 7, HP: 45, AC: 12, Attack: 22, AttackBonus: 5, Speed: 12,
|
||||
CR: 7, HP: 45, AC: 12, Attack: 12, AttackBonus: 5, Speed: 12,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Lightning Bolt", Phase: "decisive", ProcChance: 0.45, Effect: "aoe"},
|
||||
XPValue: 2900,
|
||||
@@ -467,7 +479,7 @@ var _ = func() bool {
|
||||
},
|
||||
"mind_flayer": {
|
||||
ID: "mind_flayer", Name: "Mind Flayer",
|
||||
CR: 7, HP: 71, AC: 15, Attack: 26, AttackBonus: 7, Speed: 12,
|
||||
CR: 7, HP: 71, AC: 15, Attack: 12, AttackBonus: 7, Speed: 12,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Mind Blast", Phase: "opening", ProcChance: 0.55, Effect: "stun"},
|
||||
XPValue: 2900,
|
||||
@@ -475,7 +487,7 @@ var _ = func() bool {
|
||||
},
|
||||
"hook_horror": {
|
||||
ID: "hook_horror", Name: "Hook Horror",
|
||||
CR: 3, HP: 75, AC: 15, Attack: 14, AttackBonus: 6, Speed: 10,
|
||||
CR: 3, HP: 75, AC: 15, Attack: 6, AttackBonus: 6, Speed: 10,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.30, Effect: "advantage"},
|
||||
XPValue: 700,
|
||||
@@ -483,7 +495,7 @@ var _ = func() bool {
|
||||
},
|
||||
"roper": {
|
||||
ID: "roper", Name: "Roper",
|
||||
CR: 5, HP: 93, AC: 20, Attack: 22, AttackBonus: 7, Speed: 4,
|
||||
CR: 5, HP: 93, AC: 20, Attack: 8, AttackBonus: 7, Speed: 4,
|
||||
BlockRate: 0.20,
|
||||
Ability: &MonsterAbility{Name: "Reel", Phase: "any", ProcChance: 0.50, Effect: "stun"},
|
||||
XPValue: 1800,
|
||||
@@ -491,7 +503,7 @@ var _ = func() bool {
|
||||
},
|
||||
"boss_ilvaras_xunyl": {
|
||||
ID: "boss_ilvaras_xunyl", Name: "Ilvaras Xunyl, Drow High Priestess",
|
||||
CR: 12, HP: 162, AC: 16, Attack: 38, AttackBonus: 9, Speed: 12,
|
||||
CR: 12, HP: 162, AC: 16, Attack: 19, AttackBonus: 9, Speed: 12,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Divine Word", Phase: "decisive", ProcChance: 0.40, Effect: "execute"},
|
||||
XPValue: 8400,
|
||||
@@ -500,7 +512,7 @@ var _ = func() bool {
|
||||
// ── Feywild Crossing ─────────────────────────────────────────────
|
||||
"redcap": {
|
||||
ID: "redcap", Name: "Redcap",
|
||||
CR: 3, HP: 45, AC: 13, Attack: 16, AttackBonus: 6, Speed: 14,
|
||||
CR: 3, HP: 45, AC: 13, Attack: 6, AttackBonus: 6, Speed: 14,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Outsize Strength", Phase: "any", ProcChance: 0.40, Effect: "bonus_damage"},
|
||||
XPValue: 700,
|
||||
@@ -508,7 +520,7 @@ var _ = func() bool {
|
||||
},
|
||||
"will_o_wisp": {
|
||||
ID: "will_o_wisp", Name: "Will-o'-Wisp",
|
||||
CR: 2, HP: 22, AC: 19, Attack: 9, AttackBonus: 4, Speed: 14,
|
||||
CR: 2, HP: 22, AC: 19, Attack: 4, AttackBonus: 4, Speed: 14,
|
||||
BlockRate: 0.0,
|
||||
Ability: &MonsterAbility{Name: "Consume Life", Phase: "any", ProcChance: 0.40, Effect: "lifesteal"},
|
||||
XPValue: 450,
|
||||
@@ -516,7 +528,7 @@ var _ = func() bool {
|
||||
},
|
||||
"quickling": {
|
||||
ID: "quickling", Name: "Quickling",
|
||||
CR: 1, HP: 10, AC: 16, Attack: 8, AttackBonus: 5, Speed: 18,
|
||||
CR: 1, HP: 10, AC: 16, Attack: 2, AttackBonus: 5, Speed: 18,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Blur", Phase: "any", ProcChance: 0.50, Effect: "evade"},
|
||||
XPValue: 200,
|
||||
@@ -524,7 +536,7 @@ var _ = func() bool {
|
||||
},
|
||||
"night_hag": {
|
||||
ID: "night_hag", Name: "Night Hag",
|
||||
CR: 5, HP: 112, AC: 17, Attack: 21, AttackBonus: 7, Speed: 12,
|
||||
CR: 5, HP: 112, AC: 17, Attack: 8, AttackBonus: 7, Speed: 12,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Etherealness", Phase: "any", ProcChance: 0.30, Effect: "evade"},
|
||||
XPValue: 1800,
|
||||
@@ -532,7 +544,7 @@ var _ = func() bool {
|
||||
},
|
||||
"fomorian": {
|
||||
ID: "fomorian", Name: "Fomorian",
|
||||
CR: 8, HP: 149, AC: 14, Attack: 30, AttackBonus: 9, Speed: 12,
|
||||
CR: 8, HP: 149, AC: 14, Attack: 13, AttackBonus: 9, Speed: 12,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Evil Eye", Phase: "any", ProcChance: 0.45, Effect: "stun"},
|
||||
XPValue: 3900,
|
||||
@@ -540,7 +552,7 @@ var _ = func() bool {
|
||||
},
|
||||
"boss_thornmother": {
|
||||
ID: "boss_thornmother", Name: "The Thornmother",
|
||||
CR: 11, HP: 187, AC: 17, Attack: 32, AttackBonus: 8, Speed: 12,
|
||||
CR: 11, HP: 187, AC: 17, Attack: 18, AttackBonus: 8, Speed: 12,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Beguiling Bargain", Phase: "opening", ProcChance: 0.50, Effect: "stun"},
|
||||
XPValue: 7200,
|
||||
@@ -549,7 +561,7 @@ var _ = func() bool {
|
||||
// ── Dragon's Lair ────────────────────────────────────────────────
|
||||
"kobold": {
|
||||
ID: "kobold", Name: "Kobold",
|
||||
CR: 0.125, HP: 5, AC: 12, Attack: 4, AttackBonus: 4, Speed: 12,
|
||||
CR: 0.125, HP: 5, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.40, Effect: "advantage"},
|
||||
XPValue: 25,
|
||||
@@ -557,7 +569,7 @@ var _ = func() bool {
|
||||
},
|
||||
"guard_drake": {
|
||||
ID: "guard_drake", Name: "Guard Drake",
|
||||
CR: 2, HP: 52, AC: 14, Attack: 12, AttackBonus: 5, Speed: 12,
|
||||
CR: 2, HP: 52, AC: 14, Attack: 4, AttackBonus: 5, Speed: 12,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Draconic Loyalty", Phase: "any", ProcChance: 1.00, Effect: "fear_immune"},
|
||||
XPValue: 450,
|
||||
@@ -565,7 +577,7 @@ var _ = func() bool {
|
||||
},
|
||||
"kobold_scale_sorcerer": {
|
||||
ID: "kobold_scale_sorcerer", Name: "Kobold Scale Sorcerer",
|
||||
CR: 1, HP: 27, AC: 15, Attack: 11, AttackBonus: 4, Speed: 12,
|
||||
CR: 1, HP: 27, AC: 15, Attack: 2, AttackBonus: 4, Speed: 12,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Chromatic Orb", Phase: "decisive", ProcChance: 0.50, Effect: "bonus_damage"},
|
||||
XPValue: 200,
|
||||
@@ -573,7 +585,7 @@ var _ = func() bool {
|
||||
},
|
||||
"dragonborn_cultist": {
|
||||
ID: "dragonborn_cultist", Name: "Dragonborn Cultist",
|
||||
CR: 4, HP: 71, AC: 16, Attack: 17, AttackBonus: 6, Speed: 12,
|
||||
CR: 4, HP: 71, AC: 16, Attack: 7, AttackBonus: 6, Speed: 12,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Draconic Gift", Phase: "opening", ProcChance: 0.50, Effect: "aoe"},
|
||||
XPValue: 1100,
|
||||
@@ -581,24 +593,26 @@ var _ = func() bool {
|
||||
},
|
||||
"young_red_dragon": {
|
||||
ID: "young_red_dragon", Name: "Young Red Dragon",
|
||||
CR: 10, HP: 178, AC: 18, Attack: 36, AttackBonus: 10, Speed: 16,
|
||||
CR: 10, HP: 178, AC: 18, Attack: 16, AttackBonus: 10, Speed: 16,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Fire Breath", Phase: "any", ProcChance: 0.30, Effect: "aoe_fire"},
|
||||
XPValue: 5900,
|
||||
Notes: "Elite. Fire Breath 16d6 DEX DC 21; Multiattack; Frightful Presence WIS DC 16.",
|
||||
FireAttacker: true,
|
||||
},
|
||||
"boss_infernax": {
|
||||
ID: "boss_infernax", Name: "Infernax the Undying",
|
||||
CR: 24, HP: 546, AC: 22, Attack: 65, AttackBonus: 14, Speed: 18,
|
||||
CR: 24, HP: 546, AC: 22, Attack: 38, AttackBonus: 11, Speed: 18,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.80, Effect: "stun"},
|
||||
XPValue: 62000,
|
||||
Notes: "Dragon's Lair boss. Ancient Red Dragon. Legendary Actions; Lair Actions; phase 2 below 50% HP — fire ignores resistance.",
|
||||
FireAttacker: true,
|
||||
},
|
||||
// ── Abyss Portal ─────────────────────────────────────────────────
|
||||
"quasit": {
|
||||
ID: "quasit", Name: "Quasit",
|
||||
CR: 1, HP: 7, AC: 13, Attack: 6, AttackBonus: 4, Speed: 14,
|
||||
CR: 1, HP: 7, AC: 13, Attack: 2, AttackBonus: 4, Speed: 14,
|
||||
BlockRate: 0.0,
|
||||
Ability: &MonsterAbility{Name: "Invisibility", Phase: "any", ProcChance: 0.40, Effect: "evade"},
|
||||
XPValue: 200,
|
||||
@@ -606,7 +620,7 @@ var _ = func() bool {
|
||||
},
|
||||
"vrock": {
|
||||
ID: "vrock", Name: "Vrock",
|
||||
CR: 6, HP: 104, AC: 15, Attack: 25, AttackBonus: 6, Speed: 12,
|
||||
CR: 6, HP: 104, AC: 15, Attack: 10, AttackBonus: 6, Speed: 12,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Stunning Screech", Phase: "opening", ProcChance: 0.50, Effect: "stun"},
|
||||
XPValue: 2300,
|
||||
@@ -614,7 +628,7 @@ var _ = func() bool {
|
||||
},
|
||||
"hezrou": {
|
||||
ID: "hezrou", Name: "Hezrou",
|
||||
CR: 8, HP: 136, AC: 16, Attack: 30, AttackBonus: 7, Speed: 13,
|
||||
CR: 8, HP: 136, AC: 16, Attack: 13, AttackBonus: 7, Speed: 13,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Stench", Phase: "any", ProcChance: 0.50, Effect: "debuff"},
|
||||
XPValue: 3900,
|
||||
@@ -622,7 +636,7 @@ var _ = func() bool {
|
||||
},
|
||||
"nalfeshnee": {
|
||||
ID: "nalfeshnee", Name: "Nalfeshnee",
|
||||
CR: 13, HP: 184, AC: 18, Attack: 42, AttackBonus: 10, Speed: 12,
|
||||
CR: 13, HP: 184, AC: 18, Attack: 21, AttackBonus: 10, Speed: 12,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Horror Nimbus", Phase: "opening", ProcChance: 0.55, Effect: "stun"},
|
||||
XPValue: 10000,
|
||||
@@ -630,7 +644,7 @@ var _ = func() bool {
|
||||
},
|
||||
"marilith": {
|
||||
ID: "marilith", Name: "Marilith",
|
||||
CR: 16, HP: 189, AC: 18, Attack: 50, AttackBonus: 11, Speed: 12,
|
||||
CR: 16, HP: 189, AC: 18, Attack: 26, AttackBonus: 11, Speed: 12,
|
||||
BlockRate: 0.25,
|
||||
Ability: &MonsterAbility{Name: "Reactive Parry", Phase: "any", ProcChance: 0.50, Effect: "block"},
|
||||
XPValue: 15000,
|
||||
@@ -638,11 +652,12 @@ var _ = func() bool {
|
||||
},
|
||||
"boss_belaxath": {
|
||||
ID: "boss_belaxath", Name: "Belaxath the Undivided",
|
||||
CR: 19, HP: 262, AC: 19, Attack: 58, AttackBonus: 12, Speed: 14,
|
||||
CR: 19, HP: 262, AC: 19, Attack: 31, AttackBonus: 11, Speed: 14,
|
||||
BlockRate: 0.20,
|
||||
Ability: &MonsterAbility{Name: "Lightning Discharge", Phase: "decisive", ProcChance: 0.40, Effect: "aoe"},
|
||||
XPValue: 22000,
|
||||
Notes: "Abyss Portal boss. Balor. Fire Aura; Death Throes on death; Demonic Resilience; Legendary Resistance 3/combat; phase 2 below 40% HP — Huge size, advantage on attacks.",
|
||||
FireAttacker: true,
|
||||
},
|
||||
}
|
||||
for id, m := range tier45 {
|
||||
@@ -675,6 +690,7 @@ func (m DnDMonsterTemplate) toCombatStats() (CombatStats, CombatModifiers) {
|
||||
BlockRate: m.BlockRate,
|
||||
AC: m.AC,
|
||||
AttackBonus: m.AttackBonus,
|
||||
FireAttacker: m.FireAttacker,
|
||||
}
|
||||
mods := CombatModifiers{DamageReduct: 1.0}
|
||||
return stats, mods
|
||||
|
||||
@@ -51,8 +51,8 @@ func TestBestiaryToCombatStats(t *testing.T) {
|
||||
if stats.AC != 22 {
|
||||
t.Errorf("dragon AC = %d, want 22", stats.AC)
|
||||
}
|
||||
if stats.AttackBonus != 14 {
|
||||
t.Errorf("dragon AttackBonus = %d, want 14", stats.AttackBonus)
|
||||
if stats.AttackBonus != 11 {
|
||||
t.Errorf("dragon AttackBonus = %d, want 11 (capped at 11 by 2026-05-10 rebalance)", stats.AttackBonus)
|
||||
}
|
||||
if mods.DamageReduct != 1.0 {
|
||||
t.Errorf("DamageReduct = %v, want 1.0", mods.DamageReduct)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Auto-consumable rule (kept deliberately simple): the only pre-fight
|
||||
// auto-pick is the panic heal. Other consumables (Coal Bombs, Ink Vials,
|
||||
// Wards, etc.) sit in inventory until the player explicitly engages them
|
||||
// — no threat-assessor magic deciding to burn a Voidstone Shard on a
|
||||
// shadow.
|
||||
//
|
||||
// Heal triggers in the engine when player HP drops below 60% MaxHP. Each
|
||||
// trigger consumes one charge; charges = number of healing consumables
|
||||
// in the inventory. Post-combat, that many heal items are removed from
|
||||
// the inventory bag (cheapest tier first so players burn through low
|
||||
// stock before high-tier potions).
|
||||
//
|
||||
// The HealItem mod amount is the BEST heal value in inventory — every
|
||||
// fire heals at the highest-tier-available rate. Slight balance pad
|
||||
// (favors player), big simplification (engine doesn't need a per-charge
|
||||
// values slice).
|
||||
|
||||
// setupAutoHealFromInventory wires the heal-on-low-HP behavior from the
|
||||
// player's current consumable inventory. Returns the count of charges
|
||||
// it set up (0 if no healing items at all).
|
||||
func setupAutoHealFromInventory(consumables []ConsumableItem, mods *CombatModifiers) int {
|
||||
bestValue := 0
|
||||
count := 0
|
||||
for i := range consumables {
|
||||
c := &consumables[i]
|
||||
if c.Def == nil || c.Def.Effect != EffectHeal {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if v := int(c.Def.Value); v > bestValue {
|
||||
bestValue = v
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return 0
|
||||
}
|
||||
mods.HealItem = bestValue
|
||||
if mods.HealItemCharges < count {
|
||||
mods.HealItemCharges = count
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// isBossPhases compares by slice header (same backing array) — boss fights
|
||||
// are routed through bossCombatPhases (defined in combat_engine.go) and the
|
||||
// caller passes that exact reference.
|
||||
func isBossPhases(phases []CombatPhase) bool {
|
||||
if len(phases) != len(bossCombatPhases) {
|
||||
return false
|
||||
}
|
||||
if len(phases) == 0 {
|
||||
return false
|
||||
}
|
||||
return &phases[0] == &bossCombatPhases[0]
|
||||
}
|
||||
|
||||
// countHealEventsFired counts how many heal_item events the engine emitted
|
||||
// during this fight — equals the number of heal charges actually consumed
|
||||
// and therefore the number of inventory items to remove.
|
||||
func countHealEventsFired(result CombatResult) int {
|
||||
n := 0
|
||||
for _, e := range result.Events {
|
||||
if e.Action == "heal_item" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// consumeFiredHealingItems removes `fired` healing consumables from the
|
||||
// player's inventory, cheapest-tier first so players burn cheap stock
|
||||
// before high-tier potions. No-op if fired <= 0.
|
||||
func consumeFiredHealingItems(userID id.UserID, fired int) {
|
||||
if fired <= 0 {
|
||||
return
|
||||
}
|
||||
items, err := loadAdvInventory(userID)
|
||||
if err != nil {
|
||||
slog.Error("auto-heal cleanup: load inventory failed", "user", userID, "err", err)
|
||||
return
|
||||
}
|
||||
// Filter to heal items, sort cheapest-tier-first.
|
||||
type entry struct {
|
||||
id int64
|
||||
tier int
|
||||
name string
|
||||
}
|
||||
var heals []entry
|
||||
for _, item := range items {
|
||||
if item.Type != "consumable" {
|
||||
continue
|
||||
}
|
||||
def := consumableDefByName(item.Name)
|
||||
if def == nil || def.Effect != EffectHeal {
|
||||
continue
|
||||
}
|
||||
heals = append(heals, entry{id: item.ID, tier: def.Tier, name: item.Name})
|
||||
}
|
||||
sort.SliceStable(heals, func(i, j int) bool { return heals[i].tier < heals[j].tier })
|
||||
removed := 0
|
||||
for _, h := range heals {
|
||||
if removed >= fired {
|
||||
break
|
||||
}
|
||||
if rerr := removeAdvInventoryItem(h.id); rerr != nil {
|
||||
slog.Error("auto-heal cleanup: remove failed", "user", userID, "item", h.name, "err", rerr)
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
}
|
||||
if removed < fired {
|
||||
slog.Warn("auto-heal cleanup: short on inventory",
|
||||
"user", userID, "wanted", fired, "removed", removed)
|
||||
}
|
||||
}
|
||||
+53
-20
@@ -61,6 +61,13 @@ func decodePendingCast(s string) (PendingCast, bool) {
|
||||
// ── !cast command ────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) error {
|
||||
// In a turn-based Elite/Boss fight, !cast resolves as the player's turn
|
||||
// for the round rather than queuing for "next combat". handleCombatCastCmd
|
||||
// takes the per-user lock itself and re-checks the session under it.
|
||||
if sess, _ := getActiveCombatSession(ctx.Sender); sess != nil {
|
||||
return p.handleCombatCastCmd(ctx, args)
|
||||
}
|
||||
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
@@ -73,8 +80,7 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
||||
}
|
||||
if !isSpellcaster(c) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"%s isn't a caster class. `!cast` is for Mage, Cleric, Ranger, and Arcane Trickster Rogues (L5+).",
|
||||
titleClass(c.Class)))
|
||||
"%s doesn't cast spells.", titleClass(c.Class)))
|
||||
}
|
||||
|
||||
if args == "" {
|
||||
@@ -141,7 +147,7 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
||||
if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster {
|
||||
if max := highestAvailableSlotForChar(c); spell.Level > max {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"Arcane Trickster L%d only has up to L%d slots.", c.Level, max))
|
||||
"At level %d, your Arcane Trickster magic only reaches level-%d spells.", c.Level, max))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +207,7 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
||||
}
|
||||
if !ok {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"No L%d slot available. %s", slotLevel, renderSlotsBrief(ctx.Sender)))
|
||||
"You're out of level-%d energy. %s", slotLevel, renderSlotsBrief(ctx.Sender)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,7 +342,7 @@ func (p *AdventurePlugin) queuePendingCast(ctx MessageContext, c *DnDCharacter,
|
||||
|
||||
msg := fmt.Sprintf("✨ **%s** queued for next combat.", spell.Name)
|
||||
if slotLevel > spell.Level {
|
||||
msg += fmt.Sprintf(" _(upcast to L%d)_", slotLevel)
|
||||
msg += " _(empowered)_"
|
||||
}
|
||||
if supersedes != "" {
|
||||
msg += fmt.Sprintf("\n_(Concentration shifts: %s ended.)_", displaySpellName(supersedes))
|
||||
@@ -356,8 +362,10 @@ func (p *AdventurePlugin) dndCastDrop(ctx MessageContext, c *DnDCharacter) error
|
||||
|
||||
// Refund the queued slot (if any) — voluntary drop is the player's
|
||||
// choice; restore the slot rather than forfeit it.
|
||||
slotRefunded := false
|
||||
if pc, ok := decodePendingCast(c.PendingCast); ok && pc.SlotLevel > 0 {
|
||||
_ = refundSpellSlot(ctx.Sender, pc.SlotLevel)
|
||||
slotRefunded = true
|
||||
}
|
||||
|
||||
dropped := []string{}
|
||||
@@ -374,7 +382,11 @@ func (p *AdventurePlugin) dndCastDrop(ctx MessageContext, c *DnDCharacter) error
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't drop: "+err.Error())
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Dropped: "+strings.Join(dropped, ", ")+".")
|
||||
msg := "Dropped: " + strings.Join(dropped, ", ") + "."
|
||||
if slotRefunded {
|
||||
msg += " _(slot refunded)_"
|
||||
}
|
||||
return p.SendDM(ctx.Sender, msg)
|
||||
}
|
||||
|
||||
// ── !spells command ─────────────────────────────────────────────────────────
|
||||
@@ -426,9 +438,9 @@ func renderSpellsList(c *DnDCharacter) string {
|
||||
}
|
||||
sort.Ints(levels)
|
||||
for _, lvl := range levels {
|
||||
label := fmt.Sprintf("L%d", lvl)
|
||||
label := fmt.Sprintf("Level %d", lvl)
|
||||
if lvl == 0 {
|
||||
label = "Cantrip"
|
||||
label = "Cantrips"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("__%s__\n", label))
|
||||
for _, k := range byLevel[lvl] {
|
||||
@@ -448,23 +460,17 @@ func renderSpellsList(c *DnDCharacter) string {
|
||||
|
||||
slots, _ := getSpellSlots(c.UserID)
|
||||
b.WriteString("\n**Slots:** " + renderSlotLine(slots) + "\n")
|
||||
b.WriteString(fmt.Sprintf("**Spell DC:** %d **Spell Atk:** +%d\n",
|
||||
spellSaveDC(c), spellAttackBonus(c)))
|
||||
|
||||
if c.Class == ClassMage {
|
||||
count, _ := mageLeveledKnownCount(c.UserID)
|
||||
cap := mageKnownSpellsCap(c.Level)
|
||||
b.WriteString(fmt.Sprintf("**Spellbook:** %d/%d leveled spells", count, cap))
|
||||
if avail := cap - count; avail > 0 {
|
||||
b.WriteString(fmt.Sprintf(" _(can learn %d more)_", avail))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf("**Spellbook:** %d / %d leveled spells learned\n", count, cap))
|
||||
}
|
||||
|
||||
if pc, ok := decodePendingCast(c.PendingCast); ok {
|
||||
b.WriteString(fmt.Sprintf("\n_Queued: **%s**", displaySpellName(pc.SpellID)))
|
||||
if pc.SlotLevel > 0 {
|
||||
b.WriteString(fmt.Sprintf(" (L%d slot)", pc.SlotLevel))
|
||||
if s, ok := lookupSpell(pc.SpellID); ok && pc.SlotLevel > s.Level {
|
||||
b.WriteString(" (empowered)")
|
||||
}
|
||||
b.WriteString(" — fires next combat._\n")
|
||||
}
|
||||
@@ -480,7 +486,8 @@ func renderSpellsList(c *DnDCharacter) string {
|
||||
|
||||
func (p *AdventurePlugin) handleSpellsLearn(ctx MessageContext, c *DnDCharacter, raw string) error {
|
||||
if c.Class != ClassMage {
|
||||
return p.SendDM(ctx.Sender, "`!spells learn` is for Mage. Cleric uses `!prepare`; Ranger spells are auto-known.")
|
||||
return p.SendDM(ctx.Sender, "`!spells learn` is for Mage. "+spellRouteHintFor(c)+
|
||||
" Run `!spells` to see what you already know.")
|
||||
}
|
||||
if raw == "" {
|
||||
return p.SendDM(ctx.Sender, "Usage: `!spells learn <spell name>`")
|
||||
@@ -554,7 +561,8 @@ func (p *AdventurePlugin) handleDnDPrepareCmd(ctx MessageContext, args string) e
|
||||
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
|
||||
}
|
||||
if c.Class != ClassCleric {
|
||||
return p.SendDM(ctx.Sender, "`!prepare` is for Cleric. Mage uses `!spells learn`; Ranger spells are auto-known.")
|
||||
return p.SendDM(ctx.Sender, "`!prepare` is for Cleric. "+spellRouteHintFor(c)+
|
||||
" Run `!spells` to see what you already know.")
|
||||
}
|
||||
args = strings.TrimSpace(args)
|
||||
if args == "" {
|
||||
@@ -604,7 +612,8 @@ func (p *AdventurePlugin) handleDnDPrepareCmd(ctx MessageContext, args string) e
|
||||
}
|
||||
if prepCount+1 > cap {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"Prep cap is %d. Unprepare a spell first: `!prepare clear <name>`.", cap))
|
||||
"Your prepared list is full (%d/%d). Unprepare one first: `!prepare clear <name>`.",
|
||||
prepCount, cap))
|
||||
}
|
||||
if err := setSpellPrepared(ctx.Sender, spell.ID, true); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't prepare: "+err.Error())
|
||||
@@ -633,6 +642,30 @@ func renderClericPrepStatus(c *DnDCharacter) string {
|
||||
prepCount, cap)
|
||||
}
|
||||
|
||||
// spellRouteHintFor returns a one-sentence pointer telling the player which
|
||||
// command actually advances their spellbook, scoped to their class. Used by
|
||||
// the !spells learn / !prepare wrong-class error messages so the player isn't
|
||||
// left guessing after we reject them.
|
||||
func spellRouteHintFor(c *DnDCharacter) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
switch c.Class {
|
||||
case ClassMage:
|
||||
return "Mage learns spells with `!spells learn <name>`."
|
||||
case ClassCleric:
|
||||
return "Cleric picks today's spells with `!prepare <name>`."
|
||||
case ClassRogue:
|
||||
if c.Subclass == SubclassArcaneTrickster {
|
||||
return "Arcane Trickster learns spells with `!spells learn <name>` once it unlocks."
|
||||
}
|
||||
return ""
|
||||
case ClassDruid, ClassBard, ClassSorcerer, ClassWarlock, ClassPaladin, ClassRanger:
|
||||
return "Your class learns spells automatically as you level — nothing to prepare."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func renderCastHelp(c *DnDCharacter) string {
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Measurement harness for the class-balance pass (gogobee_class_balance.md).
|
||||
// Phase 0 introduced this for a Fighter-vs-Mage spike; Phase 1 extended it
|
||||
// to drive the full 10-class × 30-subclass matrix (subclass=="" at L1–L4,
|
||||
// each of a class's three subclasses at the L5/L7/L10/L15/L20 checkpoints).
|
||||
//
|
||||
// Sibling to dnd_race_balance.go — same spirit, different method. Races
|
||||
// don't fight, so race balance had to use a hand-weighted scoring proxy.
|
||||
// Classes do fight: combat collapses to a single seedable call into the
|
||||
// engine, so class balance is *measured*, not modeled. This file is the
|
||||
// measurement harness.
|
||||
//
|
||||
// Scope here is Phase 0 only: build a synthetic Fighter and Mage at a
|
||||
// handful of levels, layer equipment and a queued spell the same way live
|
||||
// combat does, run N fights per dungeon tier, read the win rates. The
|
||||
// goal is to sanity-check the two policies the doc flags in §3 — the
|
||||
// equipment loadout and the spell-selection heuristic — before Phase 1
|
||||
// generalizes to all 10 classes × 30 subclasses.
|
||||
//
|
||||
// Bypassed deliberately (Phase 0 simplifying constraints, doc §2):
|
||||
//
|
||||
// - DB-touching layers: applyMagicItemEffects, applyArmedAbility, and
|
||||
// the SaveDnDCharacter inside applyPendingCast. The harness is pure
|
||||
// Go; tests run without a sqlite instance.
|
||||
// - Race passives beyond Human (+1 all): neutral baseline, again per §2.
|
||||
// - Inventory consumables: empty.
|
||||
//
|
||||
// Everything else flows through the production code paths
|
||||
// (applyDnDPlayerLayer, applyClassPassives, applyRacePassives, the
|
||||
// equipment-derived AC/weapon-dice resolution inside SimulateCombat) so
|
||||
// numbers from this harness are directly comparable to live fights at
|
||||
// the same character level.
|
||||
|
||||
// ── Build profile ────────────────────────────────────────────────────────────
|
||||
|
||||
// classBalanceProfile is one row of the matrix: a single class build at a
|
||||
// single level. Race is fixed to Human (the +1-to-all neutral baseline
|
||||
// shipped in the race-balance pass) so class numbers aren't skewed by
|
||||
// racial mods.
|
||||
type classBalanceProfile struct {
|
||||
Class DnDClass
|
||||
Subclass DnDSubclass // empty below L5, per doc §2
|
||||
Level int
|
||||
}
|
||||
|
||||
// classBalanceResult is the empirical performance of one profile against
|
||||
// one dungeon tier. WinRate is the headline number; the rest are
|
||||
// diagnostics logged but not asserted on (per doc §4).
|
||||
type classBalanceResult struct {
|
||||
Profile classBalanceProfile
|
||||
Tier int
|
||||
Trials int
|
||||
Wins int
|
||||
AvgHPRemainingPct float64 // mean of endHP/MaxHP across won trials; 0 if no wins
|
||||
NearDeathRate float64 // fraction of trials flagged NearDeath
|
||||
}
|
||||
|
||||
// WinRate is the cell value the doc's parity rule asserts on once we get
|
||||
// to Phase 2 tuning.
|
||||
func (r classBalanceResult) WinRate() float64 {
|
||||
if r.Trials == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(r.Wins) / float64(r.Trials)
|
||||
}
|
||||
|
||||
// ── Equipment loadout policy (doc §3.1) ──────────────────────────────────────
|
||||
//
|
||||
// One of the two policies Phase 0 exists to de-risk. The kit must be
|
||||
// standardized fairly across classes — otherwise downstream win rates
|
||||
// reflect the kit, not the class. The mapping below treats character
|
||||
// level as a proxy for "what tier of gear would a player at this level
|
||||
// realistically be holding," using the dungeon tier MinLevel ladder
|
||||
// (advDungeons in adventure_activities.go) as the reference.
|
||||
//
|
||||
// L1–L4 → T1 mundane kit (no magic bonus)
|
||||
// L5–L8 → T2, +1 weapon and armor
|
||||
// L9–L12 → T3, +2
|
||||
// L13–L16 → T4, +3
|
||||
// L17–L20 → T5, +3 (cap — the appendix doesn't go higher)
|
||||
//
|
||||
// Per-class kit choice tracks the character's primary attack stat: STR
|
||||
// martials wear heavy armor and swing martial-melee; DEX skirmishers
|
||||
// take light armor and a finesse weapon; casters keep a quarterstaff
|
||||
// and rely on Mage Armor / class AC floors instead of armor proficiency.
|
||||
|
||||
// gearTier maps a character level to a 1..5 magic/quality tier for the
|
||||
// loadout policy. Kept private and tunable in one place.
|
||||
func gearTier(level int) int {
|
||||
switch {
|
||||
case level >= 17:
|
||||
return 5
|
||||
case level >= 13:
|
||||
return 4
|
||||
case level >= 9:
|
||||
return 3
|
||||
case level >= 5:
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// magicBonusForTier is the +X enchantment we hand the player at this tier.
|
||||
// T1 is mundane; the +1/+2/+3 ladder mirrors gogobee_equipment_appendix.md
|
||||
// §7 magic-weapon tiers.
|
||||
func magicBonusForTier(tier int) int {
|
||||
switch tier {
|
||||
case 1:
|
||||
return 0
|
||||
case 2:
|
||||
return 1
|
||||
case 3:
|
||||
return 2
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
// classLoadout is the standardized weapon + armor + shield kit a class
|
||||
// fights with at this level. Returning copies so the caller can mutate
|
||||
// MagicBonus without poisoning the registry. armor or shield may be nil.
|
||||
func classLoadout(class DnDClass, level int) (weapon *WeaponProfile, armor *ArmorProfile, shield *ArmorProfile) {
|
||||
tier := gearTier(level)
|
||||
mb := magicBonusForTier(tier)
|
||||
|
||||
weaponID, armorID, useShield := classLoadoutIDs(class)
|
||||
if w := weaponByID(weaponID); w != nil {
|
||||
copy := *w
|
||||
copy.MagicBonus = mb
|
||||
weapon = ©
|
||||
}
|
||||
if armorID != "" {
|
||||
if a := armorByID(armorID); a != nil {
|
||||
copy := *a
|
||||
copy.MagicBonus = mb
|
||||
armor = ©
|
||||
}
|
||||
}
|
||||
if useShield {
|
||||
if s := armorByID("arm_shield"); s != nil {
|
||||
copy := *s
|
||||
// Shields don't get the weapon-tier enchantment in this kit;
|
||||
// keeping them mundane avoids double-counting the +X.
|
||||
shield = ©
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// classLoadoutIDs picks the canonical weapon / armor / shield set per
|
||||
// class. Choices follow the class's PrimaryA stat and 5e proficiency
|
||||
// expectations — Fighter swings martial melee in heavy armor; Mage stays
|
||||
// behind a quarterstaff and lets Mage Armor / DEX carry AC.
|
||||
func classLoadoutIDs(class DnDClass) (weapon, armor string, shield bool) {
|
||||
switch class {
|
||||
case ClassFighter, ClassPaladin:
|
||||
return "wpn_longsword", "arm_chain_mail", true
|
||||
case ClassRanger:
|
||||
// DEX skirmisher with a finesse-friendly bow; light armor, no
|
||||
// shield (two-handed bow occupies the off-hand anyway).
|
||||
return "wpn_longbow", "arm_studded", false
|
||||
case ClassRogue:
|
||||
return "wpn_shortsword", "arm_studded", false
|
||||
case ClassCleric:
|
||||
return "wpn_mace", "arm_chain_shirt", true
|
||||
case ClassDruid:
|
||||
// Druids canonically eschew metal armor; hide is the SRD default.
|
||||
return "wpn_scimitar", "arm_hide", false
|
||||
case ClassBard:
|
||||
return "wpn_rapier", "arm_leather", false
|
||||
case ClassMage, ClassSorcerer, ClassWarlock:
|
||||
// No armor proficiency. The Mage's class AC floor + DEX + a future
|
||||
// queued Mage Armor cast carry survival. Quarterstaff is the
|
||||
// canonical caster sidearm.
|
||||
return "wpn_quarterstaff", "", false
|
||||
}
|
||||
return "wpn_club", "", false
|
||||
}
|
||||
|
||||
// ── Spell-selection policy (doc §3.2) ────────────────────────────────────────
|
||||
//
|
||||
// The other Phase 0 policy. Without a "what would a caster cast here"
|
||||
// heuristic, the 8 caster classes fight as naked weapon-users — a
|
||||
// measurement artifact, not real imbalance.
|
||||
//
|
||||
// Phase 0 simplification: pick the single best damage spell from the
|
||||
// class's available spells (level ≤ highest slot the build owns). "Best"
|
||||
// is the expected damage of one cast under generous assumptions — avg
|
||||
// dice × cantrip/upcast scaling — ignoring hit chance and save-half
|
||||
// (those would require knowing the target's AC/save, which we don't have
|
||||
// at selection time). This gets the Mage casting Magic Missile / Fireball
|
||||
// instead of Fire Bolt's weaker auto-damage, which is the whole point.
|
||||
//
|
||||
// Phase 1 will refine this: per-fight slot bookkeeping for multi-round
|
||||
// fights, fight-context selection (control vs. damage), buff pre-casts.
|
||||
|
||||
// pickBestDamageSpell returns the spell a caster of this class+level
|
||||
// would queue for one fight, plus the slot level to upcast at. Returns
|
||||
// (zero, 0, false) for non-casters and classes with no damage spells.
|
||||
func pickBestDamageSpell(c *DnDCharacter) (SpellDefinition, int, bool) {
|
||||
if !classIsCaster(c.Class) {
|
||||
return SpellDefinition{}, 0, false
|
||||
}
|
||||
slots := slotsForClassLevel(c.Class, c.Level)
|
||||
maxSlot := 0
|
||||
for lvl := range slots {
|
||||
if slots[lvl] > 0 && lvl > maxSlot {
|
||||
maxSlot = lvl
|
||||
}
|
||||
}
|
||||
candidates := spellsForClass(c.Class, maxSlot)
|
||||
var best SpellDefinition
|
||||
var bestSlot int
|
||||
bestScore := -1.0
|
||||
for _, s := range candidates {
|
||||
switch s.Effect {
|
||||
case EffectDamageAttack, EffectDamageSave, EffectDamageAuto:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
// Cantrip → always castable, no slot cost.
|
||||
// Leveled → upcast to maxSlot when we own a slot ≥ spell level.
|
||||
slot := s.Level
|
||||
if s.Level == 0 {
|
||||
slot = 0
|
||||
} else if slots[s.Level] == 0 && s.Level > 0 {
|
||||
continue
|
||||
} else if maxSlot > s.Level {
|
||||
slot = maxSlot
|
||||
}
|
||||
score := spellExpectedDamage(s, slot, c.Level)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
best = s
|
||||
bestSlot = slot
|
||||
}
|
||||
}
|
||||
if bestScore < 0 {
|
||||
return SpellDefinition{}, 0, false
|
||||
}
|
||||
return best, bestSlot, true
|
||||
}
|
||||
|
||||
// spellExpectedDamage estimates the average raw damage of one cast — dice
|
||||
// count × avg-face + flat, with cantrip/upcast scaling identical to
|
||||
// rollSpellDamageDice. No hit-chance or save-half weighting (see policy
|
||||
// note above). Magic Missile's auto-damage path gets a small explicit
|
||||
// bonus to reflect that it never misses; the auto-damage flag alone
|
||||
// already steers picks correctly in practice.
|
||||
func spellExpectedDamage(s SpellDefinition, slot, charLevel int) float64 {
|
||||
dice, faces, flat := parseDamageDice(s.DamageDice)
|
||||
if dice == 0 || faces == 0 {
|
||||
return 0
|
||||
}
|
||||
if s.Level == 0 {
|
||||
switch {
|
||||
case charLevel >= 17:
|
||||
dice *= 4
|
||||
case charLevel >= 11:
|
||||
dice *= 3
|
||||
case charLevel >= 5:
|
||||
dice *= 2
|
||||
}
|
||||
} else if extra := slot - s.Level; extra > 0 {
|
||||
dice += extra
|
||||
}
|
||||
avgFace := (float64(faces) + 1) / 2
|
||||
avg := float64(dice)*avgFace + float64(flat)
|
||||
// Auto-damage (Magic Missile) doesn't roll to hit — count its
|
||||
// expected-on-table value at face. Attack/save spells roll, and the
|
||||
// engine will resolve hit chance at cast time.
|
||||
return avg
|
||||
}
|
||||
|
||||
// applyHarnessSpellCast is the DB-free version of applyPendingCast: same
|
||||
// damage resolution, no SaveDnDCharacter. Mirrors the live path's choice
|
||||
// of attack/save/auto handlers so the Mage's contribution to a fight is
|
||||
// the same shape as it would be in production.
|
||||
func applyHarnessSpellCast(
|
||||
c *DnDCharacter,
|
||||
spell SpellDefinition,
|
||||
slot int,
|
||||
playerStats *CombatStats,
|
||||
playerMods *CombatModifiers,
|
||||
enemyStats *CombatStats,
|
||||
) {
|
||||
dc := spellSaveDC(c)
|
||||
atk := spellAttackBonus(c)
|
||||
preDmgBefore := playerMods.SpellPreDamage
|
||||
switch spell.Effect {
|
||||
case EffectDamageAttack:
|
||||
applySpellDamageAttack(spell, atk, playerMods, enemyStats, slot, c.Level)
|
||||
case EffectDamageSave:
|
||||
applySpellDamageSave(spell, dc, c, playerMods, enemyStats, slot)
|
||||
case EffectDamageAuto:
|
||||
applySpellDamageAuto(spell, playerMods, slot, c.Level)
|
||||
}
|
||||
if playerMods.SpellPreDamage > preDmgBefore && c.Class == ClassMage {
|
||||
// Mage evocation/necromancy hooks live in the live spell-combat
|
||||
// path; we never set a subclass in Phase 0, so the call is a
|
||||
// no-op today but keeps shape parity for Phase 1.
|
||||
applyMageSubclassSpellHooks(c, spell, slot, playerMods)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Synthesizing the build ───────────────────────────────────────────────────
|
||||
|
||||
// buildHarnessCharacter constructs the DnDCharacter for one profile. Uses
|
||||
// the class's stat priority + Human's +1-to-all racial mods, then derives
|
||||
// HP and the baseline AC from the class.
|
||||
func buildHarnessCharacter(p classBalanceProfile) *DnDCharacter {
|
||||
scores := classStatPriority(p.Class)
|
||||
scores = applyRaceMods(RaceHuman, scores)
|
||||
c := &DnDCharacter{
|
||||
Race: RaceHuman,
|
||||
Class: p.Class,
|
||||
Subclass: p.Subclass,
|
||||
Level: p.Level,
|
||||
STR: scores[0], DEX: scores[1], CON: scores[2],
|
||||
INT: scores[3], WIS: scores[4], CHA: scores[5],
|
||||
}
|
||||
conMod := abilityModifier(c.CON)
|
||||
dexMod := abilityModifier(c.DEX)
|
||||
c.HPMax = computeMaxHP(c.Class, conMod, c.Level)
|
||||
c.HPCurrent = c.HPMax
|
||||
c.ArmorClass = computeAC(c.Class, dexMod)
|
||||
return c
|
||||
}
|
||||
|
||||
// buildHarnessPlayer assembles the Combatant the engine will fight with.
|
||||
// Layers the same calls runDungeonCombat makes, in order, minus the
|
||||
// DB-touching ones (per the file header). Returns the Combatant; the
|
||||
// caller decides whether to queue a spell on top.
|
||||
func buildHarnessPlayer(c *DnDCharacter) Combatant {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{DamageReduct: 1.0}
|
||||
|
||||
// 1. Player layer (HP/AC/AttackBonus from the sheet).
|
||||
applyDnDPlayerLayer(&stats, c)
|
||||
|
||||
// 2. Equipment layer — inlined from applyDnDEquipmentLayer to avoid
|
||||
// the AdvEquipment synthesis chain. Same net effect on stats.
|
||||
weapon, armor, shield := classLoadout(c.Class, c.Level)
|
||||
if weapon != nil {
|
||||
stats.Weapon = weapon
|
||||
stats.AbilityModForDamage = pickWeaponAbilityMod(weapon, c)
|
||||
stats.WeaponProficient = dndClassWeaponProficiency(c.Class, weapon)
|
||||
stats.AttackBonus += weapon.MagicBonus
|
||||
if weapon.HasProperty(PropTwoHanded) || (weapon.HasProperty(PropVersatile) && shield == nil) {
|
||||
stats.TwoHandedMode = true
|
||||
}
|
||||
}
|
||||
// Two-handed weapons forbid shields (appendix §5.4).
|
||||
if weapon != nil && weapon.HasProperty(PropTwoHanded) {
|
||||
shield = nil
|
||||
}
|
||||
if armor != nil || shield != nil {
|
||||
stats.AC = computeArmorAC(armor, shield, abilityModifier(c.DEX))
|
||||
}
|
||||
|
||||
// Phase 5-B player power floor. applyDnDEquipmentLayer applies this
|
||||
// at the same point in the live combat path — keep the harness's
|
||||
// measurement aligned with what live players experience by calling
|
||||
// the same helper here, before passives stack on top.
|
||||
applyPhase5BPlayerFloor(&stats)
|
||||
|
||||
// 3. Passives. Live order is class → race → subclass (see
|
||||
// combat_bridge.go and combat_session_build.go). Subclass passives are
|
||||
// a no-op when c.Subclass == "" — the harness uses that for the L1–L4
|
||||
// pre-unlock rows.
|
||||
applyClassPassives(&stats, &mods, c)
|
||||
applyRacePassives(&stats, &mods, c)
|
||||
applySubclassPassives(&stats, &mods, c)
|
||||
|
||||
return Combatant{
|
||||
Name: string(c.Class),
|
||||
Stats: stats,
|
||||
Mods: mods,
|
||||
IsPlayer: true,
|
||||
}
|
||||
}
|
||||
|
||||
// buildHarnessEnemy mirrors runDungeonCombat's enemy assembly: the
|
||||
// per-tier stat curve from DeriveDungeonMonsterStats, then the d20
|
||||
// AC/AttackBonus overlay from applyDnDDungeonMonsterLayer. No
|
||||
// MonsterAbility — Phase 0 measures the base case.
|
||||
func buildHarnessEnemy(tier int) Combatant {
|
||||
loc := dungeonLocForTier(tier)
|
||||
stats, mods := DeriveDungeonMonsterStats(loc)
|
||||
applyDnDDungeonMonsterLayer(&stats, tier)
|
||||
return Combatant{Name: loc.Denizens, Stats: stats, Mods: mods}
|
||||
}
|
||||
|
||||
// dungeonLocForTier returns the canonical advDungeons row for a tier.
|
||||
// Falls back to T1 for out-of-range input.
|
||||
func dungeonLocForTier(tier int) *AdvLocation {
|
||||
for i := range advDungeons {
|
||||
if advDungeons[i].Tier == tier {
|
||||
return &advDungeons[i]
|
||||
}
|
||||
}
|
||||
return &advDungeons[0]
|
||||
}
|
||||
|
||||
// ── Monte Carlo runner ───────────────────────────────────────────────────────
|
||||
|
||||
// runClassBalanceTrial runs one fight: build player + enemy fresh, queue
|
||||
// the caster's best spell if applicable, simulate, return the result.
|
||||
// Each trial constructs fresh combatants so the per-fight RNG (rand.IntN
|
||||
// in spell rolls + the engine's package-global rand) drives variance.
|
||||
func runClassBalanceTrial(p classBalanceProfile, tier int) CombatResult {
|
||||
c := buildHarnessCharacter(p)
|
||||
player := buildHarnessPlayer(c)
|
||||
enemy := buildHarnessEnemy(tier)
|
||||
if spell, slot, ok := pickBestDamageSpell(c); ok {
|
||||
applyHarnessSpellCast(c, spell, slot, &player.Stats, &player.Mods, &enemy.Stats)
|
||||
}
|
||||
return SimulateCombat(player, enemy, dungeonCombatPhases)
|
||||
}
|
||||
|
||||
// runClassBalanceCell is one cell of the matrix: N trials of (profile,
|
||||
// tier). Returns aggregated win rate + diagnostics.
|
||||
func runClassBalanceCell(p classBalanceProfile, tier, trials int) classBalanceResult {
|
||||
r := classBalanceResult{Profile: p, Tier: tier, Trials: trials}
|
||||
var hpSum float64
|
||||
var hpWonTrials int
|
||||
for i := 0; i < trials; i++ {
|
||||
res := runClassBalanceTrial(p, tier)
|
||||
if res.PlayerWon {
|
||||
r.Wins++
|
||||
if res.PlayerStartHP > 0 {
|
||||
hpSum += float64(res.PlayerEndHP) / float64(res.PlayerStartHP)
|
||||
hpWonTrials++
|
||||
}
|
||||
}
|
||||
if res.NearDeath {
|
||||
r.NearDeathRate++
|
||||
}
|
||||
}
|
||||
if hpWonTrials > 0 {
|
||||
r.AvgHPRemainingPct = hpSum / float64(hpWonTrials)
|
||||
}
|
||||
if trials > 0 {
|
||||
r.NearDeathRate /= float64(trials)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// runClassBalanceMatrix sweeps a list of profiles across the full T1..T5
|
||||
// dungeon ladder. Returns results sorted by (Tier asc, Class, Level) for
|
||||
// deterministic test output.
|
||||
func runClassBalanceMatrix(profiles []classBalanceProfile, trials int) []classBalanceResult {
|
||||
tiers := []int{1, 2, 3, 4, 5}
|
||||
out := make([]classBalanceResult, 0, len(profiles)*len(tiers))
|
||||
for _, p := range profiles {
|
||||
for _, t := range tiers {
|
||||
out = append(out, runClassBalanceCell(p, t, trials))
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Tier != out[j].Tier {
|
||||
return out[i].Tier < out[j].Tier
|
||||
}
|
||||
if out[i].Profile.Class != out[j].Profile.Class {
|
||||
return out[i].Profile.Class < out[j].Profile.Class
|
||||
}
|
||||
return out[i].Profile.Level < out[j].Profile.Level
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Phase 1 matrix builder ───────────────────────────────────────────────────
|
||||
|
||||
// phase1SubclassLevels is the post-unlock checkpoint ladder from doc §2.
|
||||
// L5/L7/L10/L15/L20 line up with the subclass tier-unlock structure in
|
||||
// dnd_subclass_combat.go — each row reads a class's behaviour at one more
|
||||
// unlocked tier than the row above it.
|
||||
var phase1SubclassLevels = []int{5, 7, 10, 15, 20}
|
||||
|
||||
// phase1PreSubclassLevels is the L1–L4 ladder run with Subclass=="". Doc §2
|
||||
// notes that subclasses aren't selected until L5, so these rows measure the
|
||||
// raw class chassis.
|
||||
var phase1PreSubclassLevels = []int{1, 2, 3, 4}
|
||||
|
||||
// buildPhase1Profiles assembles the full Phase 1 build matrix: every class
|
||||
// at L1–L4 (no subclass), then each of that class's three subclasses at
|
||||
// each of the five tier-unlock checkpoints. 10 × 4 + 10 × 3 × 5 = 190 rows.
|
||||
// Order is registry order (dndClasses, then subclassesForClass) so the
|
||||
// matrix log reads the same way as the design doc and the !class help.
|
||||
func buildPhase1Profiles() []classBalanceProfile {
|
||||
out := make([]classBalanceProfile, 0, 10*4+10*3*5)
|
||||
for _, ci := range dndClasses {
|
||||
for _, lvl := range phase1PreSubclassLevels {
|
||||
out = append(out, classBalanceProfile{Class: ci.Key, Level: lvl})
|
||||
}
|
||||
for _, si := range subclassesForClass(ci.Key) {
|
||||
for _, lvl := range phase1SubclassLevels {
|
||||
out = append(out, classBalanceProfile{
|
||||
Class: ci.Key,
|
||||
Subclass: si.ID,
|
||||
Level: lvl,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// _ keeps the math/rand/v2 import live in case future iterations of this
|
||||
// file want to draw directly (e.g. for harness-level RNG control). Today
|
||||
// every randomized step is inside production helpers.
|
||||
var _ = rand.IntN
|
||||
@@ -0,0 +1,410 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fmtSpread renders a (min, max) winrate cell for the Phase-2 spread table.
|
||||
// "min..max (Δpp)" with Δ in percentage points. Cells where every class is
|
||||
// within 5pp render as "balanced" so the eye skips them.
|
||||
func fmtSpread(minV, maxV float64) string {
|
||||
delta := maxV - minV
|
||||
return fmt.Sprintf("%.2f..%.2f (%2dpp)", minV, maxV, int(delta*100+0.5))
|
||||
}
|
||||
|
||||
// Phase 0 spike — Fighter vs. Mage sanity run. Per gogobee_class_balance.md
|
||||
// §5 Phase 0: "run Fighter vs. Mage only across tiers and sanity-check
|
||||
// plausibility (both win something; casters not at 0%)."
|
||||
//
|
||||
// This test is the gate before Phase 1 generalizes the matrix. It does
|
||||
// NOT assert balance — only that the harness produces plausible numbers.
|
||||
// Phase 2 promotes the assertions to per-tier win-rate parity bands.
|
||||
//
|
||||
// Skipped under -short. Even 200 trials × 2 classes × 5 tiers is fast
|
||||
// (<1s on a laptop), but it's pure measurement noise to anything else.
|
||||
func TestClassBalance_Phase0_FighterVsMage(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("phase-0 spike — measurement only")
|
||||
}
|
||||
|
||||
profiles := []classBalanceProfile{
|
||||
{Class: ClassFighter, Level: 1},
|
||||
{Class: ClassFighter, Level: 3},
|
||||
{Class: ClassMage, Level: 1},
|
||||
{Class: ClassMage, Level: 3},
|
||||
}
|
||||
const trials = 400
|
||||
results := runClassBalanceMatrix(profiles, trials)
|
||||
|
||||
t.Logf("class-balance Phase 0 — Fighter vs. Mage, %d trials/cell", trials)
|
||||
t.Logf("%-8s %-5s T1 T2 T3 T4 T5", "class", "lvl")
|
||||
type key struct {
|
||||
Class DnDClass
|
||||
Level int
|
||||
}
|
||||
byProf := make(map[key]map[int]classBalanceResult)
|
||||
for _, r := range results {
|
||||
k := key{r.Profile.Class, r.Profile.Level}
|
||||
if byProf[k] == nil {
|
||||
byProf[k] = make(map[int]classBalanceResult)
|
||||
}
|
||||
byProf[k][r.Tier] = r
|
||||
}
|
||||
for _, p := range profiles {
|
||||
row := byProf[key{p.Class, p.Level}]
|
||||
t.Logf("%-8s %-5d %.3f %.3f %.3f %.3f %.3f",
|
||||
p.Class, p.Level,
|
||||
row[1].WinRate(), row[2].WinRate(), row[3].WinRate(),
|
||||
row[4].WinRate(), row[5].WinRate())
|
||||
}
|
||||
|
||||
// Plausibility gates — these are NOT the Phase 2 parity assertions.
|
||||
// They catch a fully broken harness: e.g. spells never resolving and
|
||||
// the Mage reading 0% across the board, or the Fighter losing every
|
||||
// T1 fight because the loadout layer didn't wire weapon dice in.
|
||||
for _, r := range results {
|
||||
// Every profile should win *something* at T1 (the entry-level
|
||||
// dungeon). 0% there means the build is incapable of damage —
|
||||
// either the equipment layer or the spell layer is dead.
|
||||
if r.Tier == 1 && r.WinRate() == 0 {
|
||||
t.Errorf("%s L%d T1 win rate is 0%% — the build can't deal damage; check loadout/spell policies",
|
||||
r.Profile.Class, r.Profile.Level)
|
||||
}
|
||||
// And every profile should lose *something* at T5 (the
|
||||
// endgame) at low level — a 100% win rate at T5 with an L1
|
||||
// build means monster scaling isn't doing its job and the
|
||||
// harness numbers downstream will be useless.
|
||||
if r.Tier == 5 && r.Profile.Level == 1 && r.WinRate() == 1 {
|
||||
t.Errorf("%s L1 T5 win rate is 100%% — monster scaling looks broken",
|
||||
r.Profile.Class)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 0's specific concern from the doc: caster reads 0% because
|
||||
// no spell got queued. Cross-check that Mage T1 win rate is at least
|
||||
// in the ballpark of Fighter T1 — within a 50pp band. If Mage is
|
||||
// catastrophically below Fighter at the entry tier, the spell
|
||||
// selection policy isn't biting.
|
||||
fighterT1 := byProf[key{ClassFighter, 1}][1].WinRate()
|
||||
mageT1 := byProf[key{ClassMage, 1}][1].WinRate()
|
||||
if fighterT1-mageT1 > 0.50 {
|
||||
t.Errorf("Mage L1 T1 win rate %.2f vs Fighter %.2f — gap > 50pp suggests spell policy isn't firing",
|
||||
mageT1, fighterT1)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1 — full matrix measurement. Per gogobee_class_balance.md §5
|
||||
// Phase 1: "Generalize to all 10 classes × 30 subclasses; TestClassBalance
|
||||
// logs the full report. No tuning yet — just measurement."
|
||||
//
|
||||
// This test does not assert balance. The only failures it catches are
|
||||
// harness-broken pathologies — a profile that's 0% at T1 across the board
|
||||
// (build can't damage anything), or an L1-pre-subclass build that's 100%
|
||||
// at T5 (monster scaling collapsed). Per-tier parity bands land in Phase 2
|
||||
// once we have data to calibrate the tolerance.
|
||||
//
|
||||
// Skipped under -short. 190 profiles × 5 tiers × 200 trials = 190k
|
||||
// simulated fights; runs in a few seconds.
|
||||
func TestClassBalance_Phase1_FullMatrix(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("phase-1 matrix — measurement only")
|
||||
}
|
||||
|
||||
profiles := buildPhase1Profiles()
|
||||
const trials = 200
|
||||
results := runClassBalanceMatrix(profiles, trials)
|
||||
|
||||
// Index results for table layout: rows = (class, subclass, level),
|
||||
// columns = tier. Group by class so the log reads class-by-class.
|
||||
type rowKey struct {
|
||||
Class DnDClass
|
||||
Subclass DnDSubclass
|
||||
Level int
|
||||
}
|
||||
rows := make(map[rowKey]map[int]classBalanceResult, len(profiles))
|
||||
for _, r := range results {
|
||||
k := rowKey{r.Profile.Class, r.Profile.Subclass, r.Profile.Level}
|
||||
if rows[k] == nil {
|
||||
rows[k] = make(map[int]classBalanceResult, 5)
|
||||
}
|
||||
rows[k][r.Tier] = r
|
||||
}
|
||||
|
||||
t.Logf("class-balance Phase 1 — full matrix, %d trials/cell", trials)
|
||||
t.Logf("%-10s %-18s %-3s T1 T2 T3 T4 T5", "class", "subclass", "lvl")
|
||||
|
||||
// Per-tier accumulators for a tail summary — mean win rate by class
|
||||
// across all of its rows at each tier, plus the cross-class spread.
|
||||
type tierAgg struct {
|
||||
sum float64
|
||||
count int
|
||||
minVal float64
|
||||
maxVal float64
|
||||
}
|
||||
classTier := make(map[DnDClass]map[int]*tierAgg)
|
||||
for _, ci := range dndClasses {
|
||||
classTier[ci.Key] = map[int]*tierAgg{
|
||||
1: {minVal: 1}, 2: {minVal: 1}, 3: {minVal: 1},
|
||||
4: {minVal: 1}, 5: {minVal: 1},
|
||||
}
|
||||
}
|
||||
|
||||
for _, ci := range dndClasses {
|
||||
// pre-subclass rows first, then each subclass's L5+ rows.
|
||||
for _, lvl := range phase1PreSubclassLevels {
|
||||
row := rows[rowKey{ci.Key, "", lvl}]
|
||||
t.Logf("%-10s %-18s %-3d %.3f %.3f %.3f %.3f %.3f",
|
||||
ci.Key, "—", lvl,
|
||||
row[1].WinRate(), row[2].WinRate(), row[3].WinRate(),
|
||||
row[4].WinRate(), row[5].WinRate())
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
ta := classTier[ci.Key][tier]
|
||||
wr := row[tier].WinRate()
|
||||
ta.sum += wr
|
||||
ta.count++
|
||||
if wr < ta.minVal {
|
||||
ta.minVal = wr
|
||||
}
|
||||
if wr > ta.maxVal {
|
||||
ta.maxVal = wr
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, si := range subclassesForClass(ci.Key) {
|
||||
for _, lvl := range phase1SubclassLevels {
|
||||
row := rows[rowKey{ci.Key, si.ID, lvl}]
|
||||
t.Logf("%-10s %-18s %-3d %.3f %.3f %.3f %.3f %.3f",
|
||||
ci.Key, si.ID, lvl,
|
||||
row[1].WinRate(), row[2].WinRate(), row[3].WinRate(),
|
||||
row[4].WinRate(), row[5].WinRate())
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
ta := classTier[ci.Key][tier]
|
||||
wr := row[tier].WinRate()
|
||||
ta.sum += wr
|
||||
ta.count++
|
||||
if wr < ta.minVal {
|
||||
ta.minVal = wr
|
||||
}
|
||||
if wr > ta.maxVal {
|
||||
ta.maxVal = wr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-class summary: mean win rate per tier, sorted by overall mean
|
||||
// (lowest first). Useful at a glance to spot the outliers Phase 2 will
|
||||
// tune.
|
||||
t.Logf("")
|
||||
t.Logf("per-class mean win rate by tier (range in brackets):")
|
||||
t.Logf("%-10s T1 T2 T3 T4 T5", "class")
|
||||
classKeys := make([]DnDClass, 0, len(dndClasses))
|
||||
for _, ci := range dndClasses {
|
||||
classKeys = append(classKeys, ci.Key)
|
||||
}
|
||||
overall := func(c DnDClass) float64 {
|
||||
var s float64
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
ta := classTier[c][tier]
|
||||
if ta.count > 0 {
|
||||
s += ta.sum / float64(ta.count)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
sort.SliceStable(classKeys, func(i, j int) bool {
|
||||
return overall(classKeys[i]) < overall(classKeys[j])
|
||||
})
|
||||
for _, c := range classKeys {
|
||||
t.Logf("%-10s %.2f [%.2f-%.2f] %.2f [%.2f-%.2f] %.2f [%.2f-%.2f] %.2f [%.2f-%.2f] %.2f [%.2f-%.2f]",
|
||||
c,
|
||||
classTier[c][1].sum/float64(classTier[c][1].count), classTier[c][1].minVal, classTier[c][1].maxVal,
|
||||
classTier[c][2].sum/float64(classTier[c][2].count), classTier[c][2].minVal, classTier[c][2].maxVal,
|
||||
classTier[c][3].sum/float64(classTier[c][3].count), classTier[c][3].minVal, classTier[c][3].maxVal,
|
||||
classTier[c][4].sum/float64(classTier[c][4].count), classTier[c][4].minVal, classTier[c][4].maxVal,
|
||||
classTier[c][5].sum/float64(classTier[c][5].count), classTier[c][5].minVal, classTier[c][5].maxVal,
|
||||
)
|
||||
}
|
||||
|
||||
// Phase-2 diagnostic: cross-class spread at each (level, tier) cell.
|
||||
// Within a level row, average subclasses per class (pre-subclass levels
|
||||
// have no subclass dimension so the "average" is the single cell). The
|
||||
// per-class-mean summary above is dominated by floor+ceiling saturation
|
||||
// (L1-4 at high tier ≈ 0 for casters; L10+ at all tiers ≈ 1 for everyone),
|
||||
// which masks the actual gaps Phase 2 needs to close. This view shows the
|
||||
// max-min spread per (level, tier) — the cells with the largest spread
|
||||
// are the ones to tune.
|
||||
allLevels := append(append([]int{}, phase1PreSubclassLevels...), phase1SubclassLevels...)
|
||||
t.Logf("")
|
||||
t.Logf("per-(level, tier) cross-class spread — class winrate is mean over subclasses (or single cell pre-L5):")
|
||||
t.Logf("%-5s T1 T2 T3 T4 T5", "lvl")
|
||||
for _, lvl := range allLevels {
|
||||
var line string
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
minV, maxV := 1.0, 0.0
|
||||
for _, ci := range dndClasses {
|
||||
var sum float64
|
||||
var n int
|
||||
if lvl < 5 {
|
||||
if r, ok := rows[rowKey{ci.Key, "", lvl}]; ok {
|
||||
sum = r[tier].WinRate()
|
||||
n = 1
|
||||
}
|
||||
} else {
|
||||
for _, si := range subclassesForClass(ci.Key) {
|
||||
if r, ok := rows[rowKey{ci.Key, si.ID, lvl}]; ok {
|
||||
sum += r[tier].WinRate()
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
wr := sum / float64(n)
|
||||
if wr < minV {
|
||||
minV = wr
|
||||
}
|
||||
if wr > maxV {
|
||||
maxV = wr
|
||||
}
|
||||
}
|
||||
line += " " + fmtSpread(minV, maxV)
|
||||
}
|
||||
t.Logf("L%-4d %s", lvl, line)
|
||||
}
|
||||
|
||||
// Phase-3 diagnostic — off-tier trailer per (level, tier). For each cell
|
||||
// where the level is *below* the in-tier band (e.g. L1 at T2-T5), print the
|
||||
// trailing class and its win rate. This is what Phase 3 lifts: the casual
|
||||
// player who walks into a slightly-too-hard dungeon underleveled. Not
|
||||
// asserted — Phase 3 tunes against the diagnostic numbers and the next
|
||||
// phase decides whether to lock a soft off-tier band.
|
||||
offTierLevels := map[int][]int{
|
||||
2: {1, 2},
|
||||
3: {1, 2, 3, 4},
|
||||
4: {1, 2, 3, 4, 5},
|
||||
5: {1, 2, 3, 4, 5, 7},
|
||||
}
|
||||
t.Logf("")
|
||||
t.Logf("off-tier trailer per (level, tier) — class winrate is mean over subclasses (or single cell pre-L5):")
|
||||
t.Logf("%-5s T2 T3 T4 T5", "lvl")
|
||||
for _, lvl := range allLevels {
|
||||
var line string
|
||||
for tier := 2; tier <= 5; tier++ {
|
||||
levels := offTierLevels[tier]
|
||||
matched := false
|
||||
for _, l := range levels {
|
||||
if l == lvl {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
line += " " + " — "
|
||||
continue
|
||||
}
|
||||
var trailerClass DnDClass
|
||||
minV := 1.01
|
||||
for _, ci := range dndClasses {
|
||||
var sum float64
|
||||
var n int
|
||||
if lvl < 5 {
|
||||
if r, ok := rows[rowKey{ci.Key, "", lvl}]; ok {
|
||||
sum = r[tier].WinRate()
|
||||
n = 1
|
||||
}
|
||||
} else {
|
||||
for _, si := range subclassesForClass(ci.Key) {
|
||||
if r, ok := rows[rowKey{ci.Key, si.ID, lvl}]; ok {
|
||||
sum += r[tier].WinRate()
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
wr := sum / float64(n)
|
||||
if wr < minV {
|
||||
minV, trailerClass = wr, ci.Key
|
||||
}
|
||||
}
|
||||
line += fmt.Sprintf(" %-10s %.2f ", trailerClass, minV)
|
||||
}
|
||||
t.Logf("L%-4d %s", lvl, line)
|
||||
}
|
||||
|
||||
// Harness-broken gates first.
|
||||
for _, r := range results {
|
||||
if r.Tier == 1 && r.WinRate() == 0 {
|
||||
t.Errorf("%s/%s L%d T1 win rate is 0%% — the build can't damage anything; loadout or spell policy is dead",
|
||||
r.Profile.Class, r.Profile.Subclass, r.Profile.Level)
|
||||
}
|
||||
if r.Tier == 5 && r.Profile.Level == 1 && r.WinRate() == 1 {
|
||||
t.Errorf("%s L1 T5 win rate is 100%% — monster scaling looks broken", r.Profile.Class)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2 parity band — locked at 35pp cross-class spread for in-tier
|
||||
// cells (the (level, tier) pairs where every class is "level-appropriate"
|
||||
// for the tier). Off-tier cells — L1 mage at T5, L1 fighter at T5 etc. —
|
||||
// aren't asserted: those are level-vs-tier mismatches, and casters at L1-4
|
||||
// cannot muscle through a T2-T3 monster on a single low-slot spell + a
|
||||
// quarterstaff the way martials muscle through with weapon dice. They
|
||||
// stay in the diagnostic log above.
|
||||
//
|
||||
// In-tier ranges below are calibrated empirically from the post-Phase-2
|
||||
// matrix: each cell's mean and spread is informative (not pinned to 0 or
|
||||
// 1), and a 35pp band gives Monte-Carlo headroom (~5pp at 200 trials/cell)
|
||||
// over the 29pp worst in-tier spread the tuned harness produces.
|
||||
inTierLevels := map[int][]int{
|
||||
1: {1, 2, 3, 4},
|
||||
2: {3, 4, 5, 7},
|
||||
3: {5, 7, 10},
|
||||
4: {7, 10, 15},
|
||||
5: {10, 15, 20},
|
||||
}
|
||||
const parityBandPP = 35
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
for _, lvl := range inTierLevels[tier] {
|
||||
minV, maxV := 1.0, 0.0
|
||||
var leader, trailer DnDClass
|
||||
for _, ci := range dndClasses {
|
||||
var sum float64
|
||||
var n int
|
||||
if lvl < 5 {
|
||||
if r, ok := rows[rowKey{ci.Key, "", lvl}]; ok {
|
||||
sum = r[tier].WinRate()
|
||||
n = 1
|
||||
}
|
||||
} else {
|
||||
for _, si := range subclassesForClass(ci.Key) {
|
||||
if r, ok := rows[rowKey{ci.Key, si.ID, lvl}]; ok {
|
||||
sum += r[tier].WinRate()
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
wr := sum / float64(n)
|
||||
if wr < minV {
|
||||
minV, trailer = wr, ci.Key
|
||||
}
|
||||
if wr > maxV {
|
||||
maxV, leader = wr, ci.Key
|
||||
}
|
||||
}
|
||||
spread := int((maxV-minV)*100 + 0.5)
|
||||
if spread > parityBandPP {
|
||||
t.Errorf("in-tier parity violated at L%d/T%d: spread %dpp > band %dpp (leader %s %.2f, trailer %s %.2f)",
|
||||
lvl, tier, spread, parityBandPP, leader, maxV, trailer, minV)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+113
-84
@@ -2,31 +2,34 @@ package plugin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase 2 — D&D combat layer hookup.
|
||||
// D&D combat layer.
|
||||
//
|
||||
// Phase 2 strategy: keep legacy HP/damage/dodge-rate scaling intact. The D&D
|
||||
// layer adds AC + d20-vs-AC hit resolution on top. This preserves the
|
||||
// existing balance while making combat read as D&D for opted-in players.
|
||||
//
|
||||
// HP rescaling and condition-system overhaul are deferred to later phases.
|
||||
// applyDnDPlayerLayer reseats stats.MaxHP onto the dnd_character HP scale
|
||||
// (c.HPMax + the gear/arena/housing bonus DerivePlayerStats captured in
|
||||
// HPBonus). Wound carry-over via applyDnDHPScaling is a direct integer
|
||||
// copy — there is no longer a percentage bridge between scales.
|
||||
|
||||
// ── Tunable constants ────────────────────────────────────────────────────────
|
||||
|
||||
// Class-derived "weapon proficiency" bonus baked into AttackBonus.
|
||||
// Fighter is a martial class; Mage uses spell-attack baseline.
|
||||
var dndClassWeaponBonus = map[DnDClass]int{
|
||||
ClassFighter: 2,
|
||||
ClassRanger: 1,
|
||||
ClassRogue: 1,
|
||||
ClassCleric: 0,
|
||||
ClassMage: 0,
|
||||
ClassFighter: 2,
|
||||
ClassPaladin: 2,
|
||||
ClassRanger: 1,
|
||||
ClassRogue: 1,
|
||||
ClassDruid: 1,
|
||||
ClassBard: 1,
|
||||
ClassCleric: 0,
|
||||
ClassMage: 0,
|
||||
ClassSorcerer: 0,
|
||||
ClassWarlock: 0,
|
||||
}
|
||||
|
||||
// Monster AC formulas. Tuned so a typical L1 player (+5 attack bonus) hits
|
||||
@@ -63,14 +66,16 @@ func proficiencyBonus(level int) int {
|
||||
// uses INT (spell attack), Cleric uses WIS.
|
||||
func classAttackStatMod(c *DnDCharacter) int {
|
||||
switch c.Class {
|
||||
case ClassFighter:
|
||||
case ClassFighter, ClassPaladin:
|
||||
return abilityModifier(c.STR)
|
||||
case ClassRogue, ClassRanger:
|
||||
return abilityModifier(c.DEX)
|
||||
case ClassMage:
|
||||
return abilityModifier(c.INT)
|
||||
case ClassCleric:
|
||||
case ClassCleric, ClassDruid:
|
||||
return abilityModifier(c.WIS)
|
||||
case ClassBard, ClassSorcerer, ClassWarlock:
|
||||
return abilityModifier(c.CHA)
|
||||
}
|
||||
return abilityModifier(c.STR)
|
||||
}
|
||||
@@ -80,12 +85,15 @@ func dndPlayerAttackBonus(c *DnDCharacter) int {
|
||||
return classAttackStatMod(c) + proficiencyBonus(c.Level) + dndClassWeaponBonus[c.Class]
|
||||
}
|
||||
|
||||
// applyDnDPlayerLayer sets AC and AttackBonus on a stat block from the
|
||||
// player's D&D character. Called after DerivePlayerStats has populated
|
||||
// HP/Attack/Defense/etc. Phase 8: also wires equipped weapon/armor profiles
|
||||
// into CombatStats so the d20 attack path uses real weapon dice and AC
|
||||
// computation per gogobee_equipment_appendix.md.
|
||||
// applyDnDPlayerLayer sets HP/AC/AttackBonus on a stat block from the
|
||||
// player's D&D character. Combat MaxHP is the sheet HPMax plus the
|
||||
// equipment/arena/housing bonus DerivePlayerStats accumulated into
|
||||
// stats.HPBonus — so dnd_character.hp_current can be the single source of
|
||||
// truth for wounds while gear keeps adding flat HP to the fight.
|
||||
//
|
||||
// Phase 8 wires equipped weapon/armor profiles in via applyDnDEquipmentLayer.
|
||||
func applyDnDPlayerLayer(stats *CombatStats, c *DnDCharacter) {
|
||||
stats.MaxHP = c.HPMax + stats.HPBonus
|
||||
stats.AC = c.ArmorClass
|
||||
stats.AttackBonus = dndPlayerAttackBonus(c)
|
||||
}
|
||||
@@ -137,6 +145,46 @@ func applyDnDEquipmentLayer(stats *CombatStats, c *DnDCharacter, equip map[Equip
|
||||
if armor != nil || shield != nil {
|
||||
stats.AC = computeArmorAC(armor, shield, dexMod)
|
||||
}
|
||||
|
||||
// Phase 5-B player power floor — see applyPhase5BPlayerFloor doc.
|
||||
// Lives at the end of the equipment layer so it lands AFTER the AC
|
||||
// override above (otherwise the +3 AC bonus from the floor would be
|
||||
// stomped by computeArmorAC's overwrite).
|
||||
applyPhase5BPlayerFloor(stats)
|
||||
}
|
||||
|
||||
// Phase 5-B player power floor — uniform combat-stat lift added on top
|
||||
// of class + equipment to land the expedition harness in the band the
|
||||
// user chose for the "fairly breezy with some death" difficulty target.
|
||||
// See gogobee_expedition_difficulty.md Phase 5-B for the sweep that
|
||||
// picked these values (gear floor +3, paired with HP ×phase5BHPMult in
|
||||
// computeMaxHP).
|
||||
//
|
||||
// Applied as a *flat* lift, not via gearTier — keeps the in-game gear
|
||||
// narrative untouched (a player's +1 longsword still reads as +1 in
|
||||
// their inventory; the floor stacks invisibly at combat-stat time).
|
||||
// Uniform across classes so the class-balance harness's in-tier parity
|
||||
// spread is unaffected.
|
||||
//
|
||||
// Called from both applyDnDEquipmentLayer (live combat) and
|
||||
// buildHarnessPlayer (expedition / class-balance harnesses) so the
|
||||
// harness's measurement matches what live players experience.
|
||||
const (
|
||||
phase5BACBonus = 3
|
||||
phase5BAttackBonus = 3
|
||||
phase5BWeaponMagicBonus = 3
|
||||
)
|
||||
|
||||
func applyPhase5BPlayerFloor(stats *CombatStats) {
|
||||
stats.AC += phase5BACBonus
|
||||
stats.AttackBonus += phase5BAttackBonus
|
||||
if stats.Weapon != nil {
|
||||
// Mutate the weapon copy in place — applyDnDEquipmentLayer and
|
||||
// buildHarnessPlayer both already hand us a fresh copy (the
|
||||
// synthesize* / classLoadout chain returns a new profile), so
|
||||
// this doesn't leak into the registry.
|
||||
stats.Weapon.MagicBonus += phase5BWeaponMagicBonus
|
||||
}
|
||||
}
|
||||
|
||||
// pickWeaponAbilityMod returns the ability modifier added to weapon damage:
|
||||
@@ -176,17 +224,35 @@ func applyDnDDungeonMonsterLayer(stats *CombatStats, tier int) {
|
||||
// (which is allowed without respec cooldown when auto_migrated=1).
|
||||
func classStatPriority(class DnDClass) [6]int {
|
||||
// Returned array is in STR, DEX, CON, INT, WIS, CHA order.
|
||||
//
|
||||
// Design note: every class gets DEX ≥ 14 baseline. DEX drives AC (via
|
||||
// armor synthesis) and initiative, so dumping it leaves players in
|
||||
// armor dead-zones where T3 chain shirt provides no AC over their
|
||||
// class floor. The previous Cleric DEX=10 / Mage DEX=12 spread was
|
||||
// faithful to standard array prioritization but produced unfun
|
||||
// outcomes — gear upgrades didn't visibly help. Pumping the floor
|
||||
// trades canonical purity for "armor upgrades feel like upgrades."
|
||||
switch class {
|
||||
case ClassFighter:
|
||||
return [6]int{15, 13, 14, 8, 12, 10} // STR, CON, DEX prioritized
|
||||
return [6]int{15, 14, 13, 8, 12, 10} // STR, DEX, CON
|
||||
case ClassRogue:
|
||||
return [6]int{8, 15, 13, 14, 10, 12} // DEX, INT, CON
|
||||
case ClassMage:
|
||||
return [6]int{8, 12, 13, 15, 14, 10} // INT, WIS, CON
|
||||
return [6]int{8, 14, 13, 15, 12, 10} // INT, DEX, CON
|
||||
case ClassCleric:
|
||||
return [6]int{12, 10, 13, 8, 15, 14} // WIS, CHA, CON
|
||||
return [6]int{12, 14, 13, 8, 15, 10} // WIS, DEX, CON
|
||||
case ClassRanger:
|
||||
return [6]int{12, 15, 13, 10, 14, 8} // DEX, WIS, CON
|
||||
case ClassDruid:
|
||||
return [6]int{8, 14, 13, 10, 15, 12} // WIS, DEX, CON
|
||||
case ClassBard:
|
||||
return [6]int{8, 14, 13, 10, 12, 15} // CHA, DEX, CON
|
||||
case ClassSorcerer:
|
||||
return [6]int{8, 14, 13, 12, 10, 15} // CHA, DEX, CON
|
||||
case ClassWarlock:
|
||||
return [6]int{8, 14, 13, 12, 10, 15} // CHA, DEX, CON
|
||||
case ClassPaladin:
|
||||
return [6]int{15, 14, 13, 8, 10, 12} // STR, DEX, CON
|
||||
}
|
||||
return [6]int{15, 14, 13, 12, 10, 8} // fallback: martial-ish
|
||||
}
|
||||
@@ -280,6 +346,7 @@ func autoBuildCharacter(userID id.UserID, char *AdventureCharacter) *DnDCharacte
|
||||
c.HPMax = computeMaxHP(c.Class, conMod, c.Level)
|
||||
c.HPCurrent = c.HPMax
|
||||
c.ArmorClass = computeAC(c.Class, dexMod)
|
||||
c.ShortRestCharges = c.Level
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -406,79 +473,42 @@ func formatN(n int, word string) string {
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// ── Combat HP scaling (rest teeth) ───────────────────────────────────────────
|
||||
// ── Combat HP carry-over ─────────────────────────────────────────────────────
|
||||
|
||||
// dndWoundFloor — when scaling combat MaxHP from sheet HP%, never reduce
|
||||
// below this fraction of the legacy max. Prevents one-shot deaths when the
|
||||
// sheet is at 0 HP.
|
||||
const dndWoundFloor = 0.25
|
||||
|
||||
// applyDnDHPScaling scales playerStats.MaxHP based on the player's current
|
||||
// dnd_character HP fraction. A fully-rested player fights at full legacy HP;
|
||||
// a wounded player fights at reduced HP, with a floor at dndWoundFloor.
|
||||
//
|
||||
// This is what makes !rest mechanically meaningful — without it, the rest
|
||||
// system is purely cosmetic.
|
||||
// applyDnDHPScaling sets stats.StartHP from the player's persisted wounds.
|
||||
// A full-HP character enters at MaxHP (StartHP=0 means "use MaxHP"); a
|
||||
// wounded character enters at c.HPCurrent + HPBonus — the persistent dnd
|
||||
// wound layered with the fresh equipment cushion. There is no scale
|
||||
// conversion; persistDnDHPAfterCombat is the inverse direct copy.
|
||||
func applyDnDHPScaling(stats *CombatStats, c *DnDCharacter) {
|
||||
if c == nil || c.HPMax <= 0 {
|
||||
if c == nil || c.HPMax <= 0 || c.HPCurrent >= c.HPMax {
|
||||
return
|
||||
}
|
||||
pct := float64(c.HPCurrent) / float64(c.HPMax)
|
||||
if pct >= 1.0 {
|
||||
return
|
||||
startHP := c.HPCurrent + stats.HPBonus
|
||||
if startHP < 1 {
|
||||
startHP = 1
|
||||
}
|
||||
if pct < dndWoundFloor {
|
||||
pct = dndWoundFloor
|
||||
if startHP > stats.MaxHP {
|
||||
startHP = stats.MaxHP
|
||||
}
|
||||
// Round half-up. Naive int truncation loses up to 1 HP on every
|
||||
// round-trip through the dndChar scale (which is coarser than the
|
||||
// legacy combat scale): 101/123 → persist as 64/78 → restore as
|
||||
// int(100.92) = 100, silently dropping a HP between fights.
|
||||
scaled := int(math.Round(float64(stats.MaxHP) * pct))
|
||||
if scaled < 1 {
|
||||
scaled = 1
|
||||
}
|
||||
if scaled > stats.MaxHP {
|
||||
scaled = stats.MaxHP
|
||||
}
|
||||
// Set StartHP rather than overwriting MaxHP. The combat engine reads
|
||||
// StartHP as the entry-HP, but display denominators (e.g. "101/123")
|
||||
// keep using MaxHP — so wounded carry-over reads "wounded out of full"
|
||||
// rather than "full out of shrunk-max", which previously made wound
|
||||
// state invisible across battles.
|
||||
stats.StartHP = scaled
|
||||
stats.StartHP = startHP
|
||||
}
|
||||
|
||||
// ── HP persistence ───────────────────────────────────────────────────────────
|
||||
|
||||
// persistDnDHPAfterCombat updates dnd_character.hp_current to reflect wounds
|
||||
// from a fight, scaled to the D&D HP scale (since combat uses legacy HP).
|
||||
//
|
||||
// We compute the % of legacy HP the player ended with and apply the same %
|
||||
// to dnd_character.hp_max. This keeps the player's "displayed health" in
|
||||
// the sheet honest even though the combat engine itself uses legacy HP.
|
||||
//
|
||||
// No-op if the player has no dnd_character row or hasn't completed setup.
|
||||
func persistDnDHPAfterCombat(userID id.UserID, legacyStartHP, legacyEndHP int) {
|
||||
// persistDnDHPAfterCombat copies endHP into dnd_character.hp_current,
|
||||
// clamped to [0, c.HPMax]. The HPBonus from gear is fight-only and does
|
||||
// not carry over — anything above c.HPMax is treated as unused armor
|
||||
// cushion and clamped down. No-op if the row is missing or pending setup.
|
||||
func persistDnDHPAfterCombat(userID id.UserID, endHP int) {
|
||||
c, err := LoadDnDCharacter(userID)
|
||||
if err != nil || c == nil || c.PendingSetup {
|
||||
return
|
||||
}
|
||||
if legacyStartHP <= 0 || c.HPMax <= 0 {
|
||||
if c.HPMax <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
pct := float64(legacyEndHP) / float64(legacyStartHP)
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
} else if pct > 1 {
|
||||
pct = 1
|
||||
}
|
||||
|
||||
// Round half-up to mirror applyDnDHPScaling — a fight that ends at
|
||||
// 101/123 should round-trip cleanly through the dndChar (78-scale)
|
||||
// store and come back as 101, not 100.
|
||||
newHP := int(math.Round(float64(c.HPMax) * pct))
|
||||
newHP := endHP
|
||||
if newHP < 0 {
|
||||
newHP = 0
|
||||
}
|
||||
@@ -494,11 +524,10 @@ func persistDnDHPAfterCombat(userID id.UserID, legacyStartHP, legacyEndHP int) {
|
||||
}
|
||||
}
|
||||
|
||||
// dndHPSnapshot returns the user's D&D-scale (current, max) HP. Caller
|
||||
// captures pre-combat values via this helper, runs combat (which calls
|
||||
// persistDnDHPAfterCombat internally), then re-snapshots for the post
|
||||
// values. Used so combat outcome narration shows sheet HP rather than
|
||||
// the legacy combat-engine HP scale.
|
||||
// dndHPSnapshot returns the user's persisted (current, max) HP. Combat
|
||||
// callers snapshot pre-combat, run the fight (persistDnDHPAfterCombat
|
||||
// updates hp_current), then re-snapshot for the post values to render
|
||||
// the sheet's wound state in narration.
|
||||
func dndHPSnapshot(userID id.UserID) (cur, max int) {
|
||||
c, err := LoadDnDCharacter(userID)
|
||||
if err != nil || c == nil {
|
||||
|
||||
@@ -54,8 +54,10 @@ func TestDnDPlayerAttackBonus(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApplyDnDPlayerLayer(t *testing.T) {
|
||||
stats := CombatStats{MaxHP: 50, Attack: 10, Defense: 5}
|
||||
c := &DnDCharacter{Class: ClassFighter, Level: 1, STR: 17, ArmorClass: 16}
|
||||
// Combat MaxHP is now c.HPMax + stats.HPBonus. The HPBonus carries the
|
||||
// equipment/arena/housing buffs DerivePlayerStats accumulated.
|
||||
stats := CombatStats{MaxHP: 50, HPBonus: 8, Attack: 10, Defense: 5}
|
||||
c := &DnDCharacter{Class: ClassFighter, Level: 1, STR: 17, HPMax: 12, ArmorClass: 16}
|
||||
applyDnDPlayerLayer(&stats, c)
|
||||
if stats.AC != 16 {
|
||||
t.Errorf("AC = %d, want 16", stats.AC)
|
||||
@@ -63,9 +65,11 @@ func TestApplyDnDPlayerLayer(t *testing.T) {
|
||||
if stats.AttackBonus != 7 {
|
||||
t.Errorf("AttackBonus = %d, want 7", stats.AttackBonus)
|
||||
}
|
||||
// HP/Attack scaling fields unchanged
|
||||
if stats.MaxHP != 50 || stats.Attack != 10 {
|
||||
t.Errorf("non-D&D fields mutated: %+v", stats)
|
||||
if stats.MaxHP != 20 { // 12 + 8
|
||||
t.Errorf("MaxHP = %d, want 20 (HPMax 12 + HPBonus 8)", stats.MaxHP)
|
||||
}
|
||||
if stats.Attack != 10 {
|
||||
t.Errorf("Attack mutated: %d, want unchanged 10", stats.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// bumpDexFloorForExistingCharacters raises the DEX score of any
|
||||
// dnd_character row below 14 to 14, then recomputes armor_class from the
|
||||
// new DEX modifier. One-shot bootstrap to align prod-DB characters with
|
||||
// the 2026-05-10 fun-balance change to classStatPriority (cleric/mage
|
||||
// DEX baselines moved up so armor tier upgrades feel like upgrades).
|
||||
//
|
||||
// Idempotent: the UPDATE only touches rows where dex_score < 14. After
|
||||
// the first run, it's a no-op until someone manually drops a character's
|
||||
// DEX below the floor.
|
||||
//
|
||||
// Trade-off: this departs from canonical D&D standard-array purity. The
|
||||
// game prioritizes "armor and AC matter" over "your stat dump should
|
||||
// punish you forever." If we ever want to back this out, the migration
|
||||
// is a single SQL UPDATE — but the canonicality gain isn't worth the
|
||||
// fun loss for an asynchronous-combat game.
|
||||
func bumpDexFloorForExistingCharacters() {
|
||||
const dexFloor = 14
|
||||
d := db.Get()
|
||||
res, err := d.Exec(`UPDATE dnd_character SET dex_score = ? WHERE dex_score < ?`, dexFloor, dexFloor)
|
||||
if err != nil {
|
||||
slog.Error("dnd: dex floor migration failed", "err", err)
|
||||
return
|
||||
}
|
||||
bumped, _ := res.RowsAffected()
|
||||
if bumped == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Recompute armor_class for everyone we touched. Combat re-derives AC
|
||||
// each fight via applyDnDEquipmentLayer, so this is mostly cosmetic
|
||||
// (sheet display, !stats), but stale ArmorClass on the character row
|
||||
// would mislead players reading their sheet.
|
||||
rows, err := d.Query(`SELECT user_id, class, dex_score FROM dnd_character WHERE dex_score = ?`, dexFloor)
|
||||
if err != nil {
|
||||
slog.Error("dnd: dex floor — load post-bump rows failed", "err", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
type touched struct {
|
||||
userID string
|
||||
class DnDClass
|
||||
dex int
|
||||
}
|
||||
var batch []touched
|
||||
for rows.Next() {
|
||||
var t touched
|
||||
var classStr string
|
||||
if err := rows.Scan(&t.userID, &classStr, &t.dex); err != nil {
|
||||
slog.Warn("dnd: dex floor scan failed", "err", err)
|
||||
continue
|
||||
}
|
||||
t.class = DnDClass(classStr)
|
||||
batch = append(batch, t)
|
||||
}
|
||||
for _, t := range batch {
|
||||
ac := computeAC(t.class, abilityModifier(t.dex))
|
||||
if _, err := d.Exec(`UPDATE dnd_character SET armor_class = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?`,
|
||||
ac, t.userID); err != nil {
|
||||
slog.Warn("dnd: dex floor armor_class update failed", "user", t.userID, "err", err)
|
||||
}
|
||||
}
|
||||
slog.Info("dnd: applied DEX floor 14 to existing characters",
|
||||
"bumped", bumped, "ac_recomputed", len(batch))
|
||||
}
|
||||
|
||||
// seedShortRestChargesForExistingCharacters fills short_rest_charges =
|
||||
// dnd_level for any character whose charges are still 0 (the schema
|
||||
// default for new columns on existing rows). One-shot, idempotent —
|
||||
// after the first run only newly-created chars need seeding, and those
|
||||
// already get charges = level via autoBuildCharacter / setup confirm.
|
||||
func seedShortRestChargesForExistingCharacters() {
|
||||
d := db.Get()
|
||||
res, err := d.Exec(`UPDATE dnd_character SET short_rest_charges = dnd_level WHERE short_rest_charges = 0 AND dnd_level > 0`)
|
||||
if err != nil {
|
||||
slog.Error("dnd: short rest charge seed failed", "err", err)
|
||||
return
|
||||
}
|
||||
if seeded, _ := res.RowsAffected(); seeded > 0 {
|
||||
slog.Info("dnd: seeded short rest charges = level for existing characters", "seeded", seeded)
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,17 @@ const (
|
||||
DnDSlotRing1 DnDSlot = "ring_1"
|
||||
DnDSlotRing2 DnDSlot = "ring_2"
|
||||
DnDSlotAmulet DnDSlot = "amulet"
|
||||
// DnDSlotCloak is a magic-item-only slot. Cloaks/capes/mantles drape over
|
||||
// chest armor in 5e fiction, so giving them their own slot lets a player
|
||||
// wear a Cloak of Protection on top of a Mithral Plate without one
|
||||
// silently evicting the other. The legacy equipment mapper never targets
|
||||
// this slot — only magic_items_srd_data.go writes to it.
|
||||
DnDSlotCloak DnDSlot = "cloak"
|
||||
)
|
||||
|
||||
// dndSlotOrder controls display order for !sheet.
|
||||
var dndSlotOrder = []DnDSlot{
|
||||
DnDSlotHead, DnDSlotChest, DnDSlotLegs, DnDSlotHands, DnDSlotFeet,
|
||||
DnDSlotHead, DnDSlotChest, DnDSlotCloak, DnDSlotLegs, DnDSlotHands, DnDSlotFeet,
|
||||
DnDSlotMainHand, DnDSlotOffHand,
|
||||
DnDSlotRing1, DnDSlotRing2, DnDSlotAmulet,
|
||||
}
|
||||
@@ -78,7 +84,7 @@ func rarityIcon(r DnDRarity) string {
|
||||
return "🟩"
|
||||
case RarityRare:
|
||||
return "🟦"
|
||||
case RarityEpic:
|
||||
case RarityEpic, RarityVeryRare:
|
||||
return "🟪"
|
||||
case RarityLegendary:
|
||||
return "🟧"
|
||||
|
||||
@@ -239,13 +239,13 @@ func dndClassArmorProficiency(class DnDClass, a *ArmorProfile) bool {
|
||||
// + magic bonus. If twoHanded is true and the weapon is versatile, rolls
|
||||
// the larger versatile die instead. Returns the unmodified dice total too
|
||||
// for the crit doubling math.
|
||||
func rollWeaponDamage(w *WeaponProfile, abilityMod int, twoHanded bool) (total, dice int) {
|
||||
func rollWeaponDamage(rng *rand.Rand, w *WeaponProfile, abilityMod int, twoHanded bool) (total, dice int) {
|
||||
count, sides := w.DamageCount, w.DamageSides
|
||||
if twoHanded && w.HasProperty(PropVersatile) && w.VersaCount > 0 {
|
||||
count, sides = w.VersaCount, w.VersaSides
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
dice += 1 + rand.IntN(sides)
|
||||
dice += 1 + rngIntN(rng, sides)
|
||||
}
|
||||
total = dice + abilityMod + w.MagicBonus
|
||||
if total < 1 {
|
||||
|
||||
@@ -179,7 +179,7 @@ func TestComputeArmorAC_MagicBonus(t *testing.T) {
|
||||
func TestRollWeaponDamage_Bounds(t *testing.T) {
|
||||
greatsword := weaponByID("wpn_greatsword") // 2d6
|
||||
for i := 0; i < 1000; i++ {
|
||||
total, dice := rollWeaponDamage(greatsword, 3, false)
|
||||
total, dice := rollWeaponDamage(nil, greatsword, 3, false)
|
||||
if dice < 2 || dice > 12 {
|
||||
t.Fatalf("greatsword dice out of [2,12]: %d", dice)
|
||||
}
|
||||
@@ -193,14 +193,14 @@ func TestRollWeaponDamage_VersatileTwoHanded(t *testing.T) {
|
||||
longsword := weaponByID("wpn_longsword") // 1d8 / 1d10
|
||||
// One-handed
|
||||
for i := 0; i < 100; i++ {
|
||||
_, dice := rollWeaponDamage(longsword, 0, false)
|
||||
_, dice := rollWeaponDamage(nil, longsword, 0, false)
|
||||
if dice < 1 || dice > 8 {
|
||||
t.Errorf("longsword 1H dice = %d, want [1,8]", dice)
|
||||
}
|
||||
}
|
||||
// Two-handed (versatile)
|
||||
for i := 0; i < 100; i++ {
|
||||
_, dice := rollWeaponDamage(longsword, 0, true)
|
||||
_, dice := rollWeaponDamage(nil, longsword, 0, true)
|
||||
if dice < 1 || dice > 10 {
|
||||
t.Errorf("longsword 2H dice = %d, want [1,10]", dice)
|
||||
}
|
||||
@@ -211,7 +211,7 @@ func TestRollWeaponDamage_FloorAt1(t *testing.T) {
|
||||
// Pathological: 1d4 with -10 mod always rolls 1+(-10) = negative, floored to 1.
|
||||
dagger := weaponByID("wpn_dagger")
|
||||
for i := 0; i < 100; i++ {
|
||||
total, _ := rollWeaponDamage(dagger, -10, false)
|
||||
total, _ := rollWeaponDamage(nil, dagger, -10, false)
|
||||
if total < 1 {
|
||||
t.Errorf("damage floor violated: %d", total)
|
||||
}
|
||||
@@ -469,9 +469,10 @@ func TestApplyDnDEquipmentLayer_FighterFullKit(t *testing.T) {
|
||||
if !stats.WeaponProficient {
|
||||
t.Error("Fighter should be proficient with synthesized weapon")
|
||||
}
|
||||
// Plate (tier 6) → +1 plate, AC = 18+1 = 19, no DEX, no shield
|
||||
if stats.AC != 19 {
|
||||
t.Errorf("Fighter+plate+1 AC = %d, want 19", stats.AC)
|
||||
// Plate (tier 6) → +1 plate, AC = 18+1 = 19; Phase 5-B player floor
|
||||
// adds +3, so combat-stat AC = 22.
|
||||
if stats.AC != 22 {
|
||||
t.Errorf("Fighter+plate+1 AC = %d, want 22 (19 base + phase5BACBonus)", stats.AC)
|
||||
}
|
||||
// Two-handed mode: greatsword has TwoHanded property (no shield).
|
||||
// Greatsword's properties include Heavy + TwoHanded — TwoHandedMode set.
|
||||
|
||||
@@ -80,7 +80,7 @@ func TestDnDSlotOrderComplete(t *testing.T) {
|
||||
seen[s] = true
|
||||
}
|
||||
expected := []DnDSlot{
|
||||
DnDSlotHead, DnDSlotChest, DnDSlotLegs, DnDSlotHands, DnDSlotFeet,
|
||||
DnDSlotHead, DnDSlotChest, DnDSlotCloak, DnDSlotLegs, DnDSlotHands, DnDSlotFeet,
|
||||
DnDSlotMainHand, DnDSlotOffHand,
|
||||
DnDSlotRing1, DnDSlotRing2, DnDSlotAmulet,
|
||||
}
|
||||
|
||||
@@ -85,8 +85,12 @@ type Expedition struct {
|
||||
DMMood int
|
||||
LastBriefingAt *time.Time
|
||||
LastRecapAt *time.Time
|
||||
LastActivity time.Time
|
||||
CompletedAt *time.Time
|
||||
// LastAmbientKind — the Kind of the most recent ambient event the
|
||||
// ticker fired (e.g. "pack_rat", "monologue"). Empty when no ambient
|
||||
// has fired yet. Used by pickAmbientEvent for back-to-back anti-repeat.
|
||||
LastAmbientKind string
|
||||
LastActivity time.Time
|
||||
CompletedAt *time.Time
|
||||
}
|
||||
|
||||
// IsActive reports whether the expedition is in flight.
|
||||
@@ -189,7 +193,8 @@ func getActiveExpedition(userID id.UserID) (*Expedition, error) {
|
||||
supplies_json, camp_json, threat_level, threat_siege,
|
||||
threat_events, temporal_stack, region_state,
|
||||
xp_earned, coins_earned, gm_mood,
|
||||
last_briefing_at, last_recap_at, last_activity, completed_at
|
||||
last_briefing_at, last_recap_at, last_ambient_kind,
|
||||
last_activity, completed_at
|
||||
FROM dnd_expedition
|
||||
WHERE user_id = ?
|
||||
AND status = 'active'
|
||||
@@ -210,7 +215,8 @@ func getExpedition(id string) (*Expedition, error) {
|
||||
supplies_json, camp_json, threat_level, threat_siege,
|
||||
threat_events, temporal_stack, region_state,
|
||||
xp_earned, coins_earned, gm_mood,
|
||||
last_briefing_at, last_recap_at, last_activity, completed_at
|
||||
last_briefing_at, last_recap_at, last_ambient_kind,
|
||||
last_activity, completed_at
|
||||
FROM dnd_expedition WHERE expedition_id = ?`, id)
|
||||
e, err := scanExpedition(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -240,7 +246,8 @@ func scanExpedition(row scanner) (*Expedition, error) {
|
||||
&suppliesJSON, &campJSON, &e.ThreatLevel, &siegeI,
|
||||
&threatJSON, &e.TemporalStack, ®ionJSON,
|
||||
&e.XPEarned, &e.CoinsEarned, &e.DMMood,
|
||||
&lastBriefingRaw, &lastRecapRaw, &e.LastActivity, &completedRaw,
|
||||
&lastBriefingRaw, &lastRecapRaw, &e.LastAmbientKind,
|
||||
&e.LastActivity, &completedRaw,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase 2 of expedition autopilot — auto-harvest on room entry.
|
||||
// H2 extends this so `!zone advance` calls the same path for standalone
|
||||
// (no-expedition) zone runs. When exp is nil the loop uses the per-run
|
||||
// storage in dnd_zone_run.harvest_nodes_json and skips the
|
||||
// expedition-only side effects (threat, log, region conditions,
|
||||
// combat-interrupt roll).
|
||||
//
|
||||
// Design (Josie semantics, settled 2026-05-17):
|
||||
// - All applicable actions auto-run on every walked room. Class- and
|
||||
// kill-gated nodes filter themselves out via the existing per-resource
|
||||
// restrictions.
|
||||
// - Every node — Common through Legendary — is auto-attempted. Rarity
|
||||
// no longer pauses the walk; a whiff on a Rare node is a whiff.
|
||||
// - A node spawns with N charges. Auto-harvest swings exactly once per
|
||||
// remaining charge. Success or failure both consume a charge — no
|
||||
// retry-to-success. This is the entire point of the redesign.
|
||||
// - Noise interrupts apply threat++ and continue the per-charge loop
|
||||
// (parity with manual). Standard/Elite/Patrol combat interrupts
|
||||
// hard-stop the walk: stopEnded on death, stopHarvestCombat otherwise.
|
||||
// Standalone (exp==nil) mode skips the interrupt roll entirely —
|
||||
// zone runs already have a patrol mechanism baked into the advance
|
||||
// pipeline.
|
||||
// - No SU surcharge — manual !harvest is free; autopilot matches.
|
||||
// - Per-room footer ("+2 Iron, +1 Sage; 3 fails") + a walk-end tally
|
||||
// across all rooms.
|
||||
|
||||
// autoHarvestSummary holds the aggregated outcome for one room's
|
||||
// auto-harvest pass. Per-room footer renders from this; expeditionCmdRun
|
||||
// also folds it into a walk-wide tally for the closing block.
|
||||
type autoHarvestSummary struct {
|
||||
Yields map[string]int // resourceID -> total qty granted
|
||||
Names map[string]string // resourceID -> display name (capture once)
|
||||
Fails int
|
||||
NoiseInts int
|
||||
}
|
||||
|
||||
// autoHarvestResult is what autoHarvestRoom returns to the autopilot loop.
|
||||
type autoHarvestResult struct {
|
||||
Summary autoHarvestSummary
|
||||
CombatNarr string // non-empty = a combat interrupt fired during the pass
|
||||
PlayerEnded bool // true = combat killed the player
|
||||
}
|
||||
|
||||
// allHarvestActions lists every action autopilot will try per room. Order
|
||||
// matters only for footer readability — class- and gate-restricted nodes
|
||||
// drop out via the existing filters.
|
||||
var allHarvestActions = []HarvestAction{
|
||||
HarvestForage,
|
||||
HarvestScavenge,
|
||||
HarvestMine,
|
||||
HarvestFish,
|
||||
HarvestEssence,
|
||||
HarvestCommune,
|
||||
}
|
||||
|
||||
// autoHarvestRoom runs a single auto-harvest pass on the player's current
|
||||
// room. exp may be nil for standalone zone runs (no expedition); in that
|
||||
// mode the function uses per-run storage and skips threat/interrupts/log.
|
||||
// Does not advance the room; only resolves nodes already at the player's
|
||||
// feet.
|
||||
func (p *AdventurePlugin) autoHarvestRoom(
|
||||
userID id.UserID, run *DungeonRun, char *DnDCharacter, exp *Expedition,
|
||||
) (autoHarvestResult, error) {
|
||||
res := autoHarvestResult{
|
||||
Summary: autoHarvestSummary{
|
||||
Yields: map[string]int{},
|
||||
Names: map[string]string{},
|
||||
},
|
||||
}
|
||||
if run == nil {
|
||||
return res, nil
|
||||
}
|
||||
if !currentRoomCleared(run) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
zoneID := run.ZoneID
|
||||
if exp != nil {
|
||||
zoneID = exp.ZoneID
|
||||
}
|
||||
|
||||
nodeID := harvestNodeIDFor(run)
|
||||
var nodes []HarvestNode
|
||||
if exp != nil {
|
||||
nodes = loadHarvestNodes(exp, nodeID)
|
||||
} else {
|
||||
loaded, lerr := loadStandaloneHarvestNodes(run.RunID, zoneID, nodeID)
|
||||
if lerr != nil {
|
||||
return res, nil
|
||||
}
|
||||
nodes = loaded
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
zone, _ := getZone(zoneID)
|
||||
persistNodes := false
|
||||
|
||||
for _, action := range allHarvestActions {
|
||||
if action == HarvestFish && !isFishingZone(zoneID) {
|
||||
continue
|
||||
}
|
||||
if exp != nil {
|
||||
if blockReason := zoneConditionHarvestBlock(exp, action); blockReason != "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// One pass per action. Iterate every eligible node (not just the
|
||||
// lowest-DC one) so a room with two foragables gets two attempts.
|
||||
for i := range nodes {
|
||||
n := &nodes[i]
|
||||
if n.CurrentCharges <= 0 {
|
||||
continue
|
||||
}
|
||||
rdef, ok := FindZoneResource(zoneID, n.ResourceID)
|
||||
if !ok || rdef.Action != action {
|
||||
continue
|
||||
}
|
||||
if rdef.ClassRestrict != "" && rdef.ClassRestrict != char.Class {
|
||||
continue
|
||||
}
|
||||
if rdef.RequiresKill != "" {
|
||||
if exp == nil || !regionKillsContains(exp, rdef.RequiresKill) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if rdef.ConditionalEvent != "" {
|
||||
if exp == nil || !regionEventActive(exp, rdef.ConditionalEvent) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Josie semantics: swing exactly once per remaining charge.
|
||||
// Each swing consumes a charge regardless of outcome.
|
||||
for n.CurrentCharges > 0 {
|
||||
// Interrupt roll, expedition-only. Standalone zone runs
|
||||
// already have a patrol mechanism in the advance pipeline.
|
||||
if exp != nil {
|
||||
interrupt, intTotal := resolveCombatInterrupt(
|
||||
exp.ThreatLevel, int(zone.Tier), char.Class, zoneID, nil)
|
||||
switch interrupt {
|
||||
case InterruptNoise:
|
||||
_ = applyThreatDelta(exp.ID, 2, "harvest noise (§4.2, autopilot)")
|
||||
res.Summary.NoiseInts++
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
|
||||
fmt.Sprintf("autopilot noise %s total=%d", string(action), intTotal), "")
|
||||
// fall through to the d20 roll, same as manual harvest.
|
||||
case InterruptStandard, InterruptElite, InterruptPatrol:
|
||||
body, ended := p.runHarvestInterrupt(userID, exp, run, zone, interrupt)
|
||||
res.CombatNarr = body
|
||||
res.PlayerEnded = ended
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
|
||||
fmt.Sprintf("autopilot %s kind=%d total=%d", string(action), int(interrupt), intTotal), "")
|
||||
if persistNodes {
|
||||
if exp != nil {
|
||||
_ = saveHarvestNodes(exp, nodeID, nodes)
|
||||
} else {
|
||||
_ = saveStandaloneHarvestNodes(run.RunID, zoneID, nodeID, nodes)
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the harvest attempt.
|
||||
roll := rand.IntN(20) + 1
|
||||
mod := harvestActionAbility(char, action) + classHarvestBonus(char.Class, action)
|
||||
if action == HarvestFish {
|
||||
if adv, _ := loadAdvCharacter(userID); adv != nil {
|
||||
mod += fishingSkillBonus(adv.FishingSkill)
|
||||
}
|
||||
}
|
||||
total := roll + mod
|
||||
dcDelta := 0
|
||||
if exp != nil {
|
||||
dcDelta, _ = zoneConditionHarvestDCMod(exp, action)
|
||||
}
|
||||
effectiveDC := rdef.DC + dcDelta
|
||||
outcome := resolveHarvestOutcome(total, effectiveDC)
|
||||
outcome = rangerRareCatchUpgrade(char.Class, action, outcome, nil)
|
||||
|
||||
var grantedQty int
|
||||
switch outcome {
|
||||
case OutcomeNothing:
|
||||
res.Summary.Fails++
|
||||
case OutcomeCommon:
|
||||
grantedQty = 1
|
||||
case OutcomeStandard:
|
||||
grantedQty = rand.IntN(3) + 1
|
||||
case OutcomeRich:
|
||||
grantedQty = rand.IntN(3) + 1
|
||||
if bonus := pickRichBonus(zoneID, rdef.ID); bonus != nil {
|
||||
_ = grantHarvestYield(userID, *bonus, 1)
|
||||
res.Summary.Yields[bonus.ID] += 1
|
||||
res.Summary.Names[bonus.ID] = bonus.Name
|
||||
}
|
||||
}
|
||||
if grantedQty > 0 {
|
||||
_ = grantHarvestYield(userID, rdef, grantedQty)
|
||||
res.Summary.Yields[rdef.ID] += grantedQty
|
||||
res.Summary.Names[rdef.ID] = rdef.Name
|
||||
}
|
||||
// Charge is spent either way under Josie semantics.
|
||||
n.CurrentCharges--
|
||||
persistNodes = true
|
||||
|
||||
if exp != nil {
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
|
||||
fmt.Sprintf("autopilot %s %s d20=%d total=%d dc=%d → %s",
|
||||
string(action), rdef.ID, roll, total, rdef.DC, string(outcome)), "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if persistNodes {
|
||||
var perr error
|
||||
if exp != nil {
|
||||
perr = saveHarvestNodes(exp, nodeID, nodes)
|
||||
} else {
|
||||
perr = saveStandaloneHarvestNodes(run.RunID, zoneID, nodeID, nodes)
|
||||
}
|
||||
if perr != nil && exp != nil {
|
||||
_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
|
||||
fmt.Sprintf("autopilot persistence error: %v", perr), "")
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// renderAutoHarvestFooter formats one room's pass as a single compact
|
||||
// line: "_+2 Scrap Iron, +1 Shadow Herb · 1 fail · 1 close call._".
|
||||
// Empty string means there's nothing worth showing.
|
||||
func renderAutoHarvestFooter(s autoHarvestSummary) string {
|
||||
if len(s.Yields) == 0 && s.Fails == 0 && s.NoiseInts == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := []string{}
|
||||
// Stable ordering — sort yield keys so test output is deterministic
|
||||
// and the footer reads consistently across rooms.
|
||||
keys := make([]string, 0, len(s.Yields))
|
||||
for k := range s.Yields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("+%d %s", s.Yields[k], s.Names[k]))
|
||||
}
|
||||
if s.Fails > 0 {
|
||||
noun := "fail"
|
||||
if s.Fails > 1 {
|
||||
noun = "fails"
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d %s", s.Fails, noun))
|
||||
}
|
||||
if s.NoiseInts > 0 {
|
||||
noun := "close call"
|
||||
if s.NoiseInts > 1 {
|
||||
noun = "close calls"
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d %s", s.NoiseInts, noun))
|
||||
}
|
||||
return "_🎒 " + strings.Join(parts, " · ") + "._"
|
||||
}
|
||||
|
||||
// renderWalkTally formats the cross-room cumulative haul for the walk's
|
||||
// final block. Empty string when nothing was gathered.
|
||||
func renderWalkTally(yields map[string]int, names map[string]string) string {
|
||||
if len(yields) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(yields))
|
||||
for k := range yields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var b strings.Builder
|
||||
b.WriteString("**Walk haul:** ")
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%d %s", yields[k], names[k]))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Pure-logic tests for the Phase 2 auto-harvest helpers. The full
|
||||
// autoHarvestRoom path needs the prod DB (loads zone runs / expeditions /
|
||||
// region state) and lives behind the same setupAuditTestDB gate as the
|
||||
// other expedition tests.
|
||||
|
||||
func TestRenderAutoHarvestFooter_EmptyIsEmpty(t *testing.T) {
|
||||
s := autoHarvestSummary{Yields: map[string]int{}, Names: map[string]string{}}
|
||||
if got := renderAutoHarvestFooter(s); got != "" {
|
||||
t.Errorf("expected empty footer for empty summary, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAutoHarvestFooter_YieldsAndFailsAndNoise(t *testing.T) {
|
||||
s := autoHarvestSummary{
|
||||
Yields: map[string]int{"iron": 2, "herb": 1},
|
||||
Names: map[string]string{"iron": "Scrap Iron", "herb": "Shadow Herb"},
|
||||
Fails: 1,
|
||||
NoiseInts: 2,
|
||||
}
|
||||
got := renderAutoHarvestFooter(s)
|
||||
// Deterministic ordering: yield keys sorted alphabetically.
|
||||
for _, want := range []string{"+1 Shadow Herb", "+2 Scrap Iron", "1 fail", "2 close calls"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("footer %q missing %q", got, want)
|
||||
}
|
||||
}
|
||||
// Singular "close call" when noise=1.
|
||||
s.NoiseInts = 1
|
||||
got = renderAutoHarvestFooter(s)
|
||||
if !strings.Contains(got, "1 close call") || strings.Contains(got, "close calls") {
|
||||
t.Errorf("expected singular 'close call', got %q", got)
|
||||
}
|
||||
// Plural "fails" when fails>1 — Josie semantics make this common.
|
||||
s.Fails = 3
|
||||
got = renderAutoHarvestFooter(s)
|
||||
if !strings.Contains(got, "3 fails") {
|
||||
t.Errorf("expected plural 'fails', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderWalkTally_EmptyIsEmpty(t *testing.T) {
|
||||
if got := renderWalkTally(map[string]int{}, map[string]string{}); got != "" {
|
||||
t.Errorf("expected empty tally, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderWalkTally_SortsByID(t *testing.T) {
|
||||
got := renderWalkTally(
|
||||
map[string]int{"zinc": 1, "alpha": 2, "mid": 3},
|
||||
map[string]string{"zinc": "Zinc", "alpha": "Alphite", "mid": "Mid Ore"},
|
||||
)
|
||||
// "alpha" < "mid" < "zinc" so the rendered order is Alphite, Mid Ore, Zinc.
|
||||
if !strings.HasPrefix(got, "**Walk haul:** 2 Alphite") {
|
||||
t.Errorf("tally not sorted by id: %q", got)
|
||||
}
|
||||
if iAlpha, iZinc := strings.Index(got, "Alphite"), strings.Index(got, "Zinc"); iAlpha >= iZinc {
|
||||
t.Errorf("Alphite should precede Zinc: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -49,17 +49,28 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
||||
case "":
|
||||
// If active, show status; otherwise help.
|
||||
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
|
||||
return p.expeditionCmdStatus(ctx)
|
||||
return p.expeditionCmdStatus(ctx, "")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, expeditionHelpText())
|
||||
case "help", "?":
|
||||
return p.SendDM(ctx.Sender, expeditionHelpText())
|
||||
case "list", "ls":
|
||||
return p.expeditionCmdList(ctx, c)
|
||||
case "start", "begin", "go":
|
||||
case "start", "begin":
|
||||
return p.expeditionCmdStart(ctx, c, rest)
|
||||
case "go", "choose":
|
||||
// Context-sensitive: with an active expedition + numeric rest,
|
||||
// treat as "take fork path N" (forwards to zoneCmdGo so the
|
||||
// player doesn't have to remember the !zone seam). Otherwise
|
||||
// fall back to the historical alias for `!expedition start`.
|
||||
if rest != "" && isAllDigits(strings.TrimSpace(rest)) {
|
||||
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
|
||||
return p.zoneCmdGo(ctx, rest)
|
||||
}
|
||||
}
|
||||
return p.expeditionCmdStart(ctx, c, rest)
|
||||
case "status", "info":
|
||||
return p.expeditionCmdStatus(ctx)
|
||||
return p.expeditionCmdStatus(ctx, rest)
|
||||
case "log", "history":
|
||||
return p.expeditionCmdLog(ctx)
|
||||
case "abandon", "quit":
|
||||
@@ -70,6 +81,8 @@ func (p *AdventurePlugin) handleDnDExpeditionCmd(ctx MessageContext, args string
|
||||
return p.handleResumeCmd(ctx, rest)
|
||||
case "map", "m":
|
||||
return p.handleExpeditionMapCmd(ctx, "")
|
||||
case "run", "explore", "advance":
|
||||
return p.expeditionCmdRun(ctx)
|
||||
default:
|
||||
return p.SendDM(ctx.Sender, expeditionHelpText())
|
||||
}
|
||||
@@ -83,6 +96,7 @@ func expeditionHelpText() string {
|
||||
b.WriteString(" `Ns` = N standard packs (10 SU, 50 coins, max 3)\n")
|
||||
b.WriteString(" `Md` = M deluxe packs (20 SU, 90 coins, max 1)\n")
|
||||
b.WriteString(" default: `1s`\n")
|
||||
b.WriteString("`!expedition run` — autopilot: walk rooms until something needs you (alias `!explore`)\n")
|
||||
b.WriteString("`!expedition status` — current expedition snapshot\n")
|
||||
b.WriteString("`!expedition log` — last 5 log entries\n")
|
||||
b.WriteString("`!expedition abandon` — end the expedition (no rewards)\n")
|
||||
@@ -102,8 +116,12 @@ func (p *AdventurePlugin) expeditionCmdList(ctx MessageContext, c *DnDCharacter)
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("**Expeditions available at L%d** (you can enter zones up to 2 tiers above your current tier):\n\n", c.Level))
|
||||
for i, z := range zones {
|
||||
b.WriteString(fmt.Sprintf("**%d.** %s — _T%d, L%d–%d_ `!expedition start %s`\n",
|
||||
i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID))
|
||||
suffix := ""
|
||||
if raidContentWarning(z.ID) != "" {
|
||||
suffix = " _⚠ raid-shaped — solo runs not yet survivable_"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("**%d.** %s — _T%d, L%d–%d_ `!expedition start %s`%s\n",
|
||||
i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID, suffix))
|
||||
b.WriteString(fmt.Sprintf(" %s\n", z.Atmosphere))
|
||||
}
|
||||
if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
|
||||
@@ -165,6 +183,11 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
return p.SendDM(ctx.Sender,
|
||||
"`!expedition start <zone> [Ns] [Md]` — pick from `!expedition list`. Example: `!expedition start goblin_warrens 2s` (2 standard packs).")
|
||||
}
|
||||
if remaining := restingLockoutRemaining(c); remaining > 0 {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"🛌 You're still resting — %s remaining. Pack up after.",
|
||||
formatRespecDuration(remaining)))
|
||||
}
|
||||
zoneTok, packTok := splitFirstWord(rest)
|
||||
available := zonesForLevel(c.Level)
|
||||
zoneID, ok := resolveZoneInput(zoneTok, available)
|
||||
@@ -250,10 +273,31 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
b.WriteString(startLine)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
if w := raidContentWarning(zoneID); w != "" {
|
||||
b.WriteString(w)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString("Use `!expedition status` for the daily briefing format. Day 1 begins now.")
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
|
||||
// raidContentWarning returns a TwinBee-voiced heads-up for zones whose
|
||||
// boss is tuned for a party rather than a solo adventurer. T5 zones
|
||||
// (Dragon's Lair / Abyss Portal) have boss HP / damage curves that the
|
||||
// solo combat path can't realistically clear — the J3 trace sweep at
|
||||
// L12 showed 0% solo clears across all 10 classes. Until multiplayer
|
||||
// expeditions ship, this is the surface that tells a player what
|
||||
// they're walking into without nerfing the encounter for parties later.
|
||||
func raidContentWarning(zoneID ZoneID) string {
|
||||
switch zoneID {
|
||||
case ZoneDragonsLair:
|
||||
return "⚠ A note before we commit. Infernax doesn't go down to one sword. I've watched better-prepared adventurers walk in here and not walk back out, and I haven't yet seen the lone exception. Bring friends when you can. For tonight — I'm with you anyway."
|
||||
case ZoneAbyssPortal:
|
||||
return "⚠ A note before we commit. Belaxath is the kind of enemy you bring a band to. Not one solo hero has put him down yet, and I'd rather you weren't the first to try. We can still go. I just want the record to show I said this."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func estimateDays(maxSU, dailyBurn float32) int {
|
||||
if dailyBurn <= 0 {
|
||||
return 0
|
||||
@@ -263,7 +307,18 @@ func estimateDays(maxSU, dailyBurn float32) int {
|
||||
|
||||
// ── status ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) expeditionCmdStatus(ctx MessageContext) error {
|
||||
func (p *AdventurePlugin) expeditionCmdStatus(ctx MessageContext, args string) error {
|
||||
debug := false
|
||||
for _, tok := range strings.Fields(args) {
|
||||
switch strings.ToLower(tok) {
|
||||
case "--debug", "-d", "debug", "raw":
|
||||
debug = true
|
||||
}
|
||||
}
|
||||
return p.expeditionCmdStatusImpl(ctx, debug)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool) error {
|
||||
exp, err := getActiveExpedition(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
|
||||
@@ -290,21 +345,36 @@ func (p *AdventurePlugin) expeditionCmdStatus(ctx MessageContext) error {
|
||||
if c != nil {
|
||||
b.WriteString(fmt.Sprintf("❤️ **HP:** %d / %d\n", c.HPCurrent, c.HPMax))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("🎒 **Supplies:** %.1f / %.1f SU _(burn %.1f/day → ~%d days left)_\n",
|
||||
exp.Supplies.Current, exp.Supplies.Max, currentBurn(exp),
|
||||
estimateDays(exp.Supplies.Current, currentBurn(exp))))
|
||||
b.WriteString(fmt.Sprintf("⏰ **Threat:** %d / 100 — %s\n",
|
||||
exp.ThreatLevel, threatThresholdLabel(exp.ThreatLevel, exp.SiegeMode)))
|
||||
if exp.TemporalStack != 0 {
|
||||
b.WriteString(fmt.Sprintf("🌡 **Zone stack:** %d\n", exp.TemporalStack))
|
||||
// Default view is verb/outcome-led: days-left summary + threat label,
|
||||
// no raw SU / threat-out-of-100 / zone-stack / roll-modifier numbers.
|
||||
// Pass `--debug` to !expedition status for the engineering view.
|
||||
days := estimateDays(exp.Supplies.Current, currentBurn(exp))
|
||||
if debug {
|
||||
b.WriteString(fmt.Sprintf("🎒 **Supplies:** %.1f / %.1f SU _(burn %.1f/day → ~%d days left)_\n",
|
||||
exp.Supplies.Current, exp.Supplies.Max, currentBurn(exp), days))
|
||||
b.WriteString(fmt.Sprintf("⏰ **Threat:** %d / 100 — %s\n",
|
||||
exp.ThreatLevel, threatThresholdLabel(exp.ThreatLevel, exp.SiegeMode)))
|
||||
if exp.TemporalStack != 0 {
|
||||
b.WriteString(fmt.Sprintf("🌡 **Zone stack:** %d\n", exp.TemporalStack))
|
||||
}
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("🎒 **Supplies:** ~%d day%s left\n",
|
||||
days, plural(days)))
|
||||
b.WriteString(fmt.Sprintf("⏰ **Threat:** %s\n",
|
||||
threatThresholdLabel(exp.ThreatLevel, exp.SiegeMode)))
|
||||
}
|
||||
if exp.Camp != nil && exp.Camp.Active {
|
||||
b.WriteString(fmt.Sprintf("⛺ **Camp:** %s (room %d)\n", exp.Camp.Type, exp.Camp.RoomIndex+1))
|
||||
}
|
||||
state := supplyDepletion(exp.Supplies)
|
||||
if state != SupplyNormal {
|
||||
b.WriteString(fmt.Sprintf("⚠ **%s** — roll modifier %d\n",
|
||||
depletionLabel(state), supplyRollModifier(state)))
|
||||
if debug {
|
||||
b.WriteString(fmt.Sprintf("⚠ **%s** — roll modifier %d\n",
|
||||
depletionLabel(state), supplyRollModifier(state)))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("⚠ **%s** — you're slower and clumsier than usual.\n",
|
||||
depletionLabel(state)))
|
||||
}
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\nStarted: %s Last activity: %s",
|
||||
exp.StartDate.Format("2006-01-02 15:04"),
|
||||
@@ -423,3 +493,271 @@ func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
|
||||
|
||||
// helper: ensure we don't shadow id.UserID import in test harness.
|
||||
var _ id.UserID
|
||||
|
||||
// isAllDigits reports whether s is a non-empty string of ASCII digits.
|
||||
// Used to route `!expedition go N` to the fork-choice handler when N is
|
||||
// numeric; non-numeric rest (`!expedition go ironforge`) still falls
|
||||
// through to expeditionCmdStart.
|
||||
func isAllDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── run (autopilot) ─────────────────────────────────────────────────────────
|
||||
|
||||
// autopilotRoomCap bounds a single `!expedition run` invocation. Real-time
|
||||
// background ticking is the planned long-game (see chat 2026-05-14); this
|
||||
// cap keeps the foreground walk from monopolising the bot for an
|
||||
// unbounded stretch and gives the player a natural "press to continue"
|
||||
// breath between long runs.
|
||||
const autopilotRoomCap = 6
|
||||
|
||||
// autopilotLowHPPct stops the walk when current HP drops at or below this
|
||||
// fraction of max. 0.30 = "you're hurt enough that the next bad room
|
||||
// could end the run; pause, heal, or commit."
|
||||
const autopilotLowHPPct = 0.30
|
||||
|
||||
// autopilotWalkResult bundles the staged narration plus structured
|
||||
// metadata so foreground (streamFlow) and background (single DM, with
|
||||
// progress-based DM suppression) callers can share the same walk loop.
|
||||
type autopilotWalkResult struct {
|
||||
stream []string
|
||||
finalMsg string
|
||||
rooms int
|
||||
reason stopReason
|
||||
// initErr — populated when the walk couldn't start (no expedition,
|
||||
// no run). Foreground surfaces it as a DM; background swallows it.
|
||||
initErr string
|
||||
}
|
||||
|
||||
// expeditionCmdRun is the autopilot surface. It loops advanceOnce until a
|
||||
// natural interrupt fires (fork, elite/boss doorway, death, complete) or
|
||||
// an injected interrupt fires (low HP, low SU, room cap). Each iteration's
|
||||
// staged narration is concatenated into a single 2–3s-paced stream so the
|
||||
// player reads the full multi-room walk as one continuous beat. Trash
|
||||
// combat already auto-resolves inside resolveCombatRoom; elite/boss
|
||||
// doorways stop here so the player can choose !fight on their own terms.
|
||||
func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
|
||||
r := p.runAutopilotWalk(ctx, autopilotRoomCap, false)
|
||||
if r.initErr != "" {
|
||||
return p.SendDM(ctx.Sender, r.initErr)
|
||||
}
|
||||
return p.streamFlow(ctx.Sender, r.stream, r.finalMsg)
|
||||
}
|
||||
|
||||
// runAutopilotWalk runs the autopilot loop up to maxRooms times and
|
||||
// returns a bundle the caller can either streamFlow (foreground) or
|
||||
// post as a single DM (background ticker). Pure side effects on the
|
||||
// run graph / harvest tally / supplies / threat — same as before, just
|
||||
// no streamFlow here. compact==true switches the underlying combat
|
||||
// narration into terse mode and auto-resolves elite (not boss) rooms.
|
||||
func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact bool) autopilotWalkResult {
|
||||
exp, err := getActiveExpedition(ctx.Sender)
|
||||
if err != nil {
|
||||
return autopilotWalkResult{initErr: "Couldn't read expedition state: " + err.Error()}
|
||||
}
|
||||
if exp == nil {
|
||||
return autopilotWalkResult{initErr: "You're not on an expedition. `!expedition list` to pick one."}
|
||||
}
|
||||
if exp.RunID == "" {
|
||||
return autopilotWalkResult{initErr: "No active region run. Try `!region` to refresh."}
|
||||
}
|
||||
|
||||
var stream []string
|
||||
var finalMsg string
|
||||
rooms := 0
|
||||
reason := stopOK // sticky: last loop-exit reason
|
||||
|
||||
// Phase 2 — walk-wide auto-harvest tally. Per-room footers go into the
|
||||
// stream as we go; the cumulative haul renders on the final block.
|
||||
walkYields := map[string]int{}
|
||||
walkNames := map[string]string{}
|
||||
|
||||
for i := 0; i < maxRooms; i++ {
|
||||
// Pre-iteration interrupts: low HP / low SU. Skip on the first
|
||||
// iteration so the player always gets at least one room out of
|
||||
// `!expedition run` even if they limped in — autopilot stops on
|
||||
// thresholds *between* rooms, not before the first one.
|
||||
if i > 0 {
|
||||
if msg, stop := autopilotPreflight(ctx.Sender, exp); stop {
|
||||
finalMsg = msg
|
||||
reason = stopPreflight
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
res, aerr := p.advanceOnceWithOpts(ctx, compact)
|
||||
if aerr != nil {
|
||||
return autopilotWalkResult{initErr: aerr.Error()}
|
||||
}
|
||||
|
||||
// Roll this step's beats into the running stream.
|
||||
stream = append(stream, res.preStream...)
|
||||
if res.intro != "" {
|
||||
stream = append(stream, res.intro)
|
||||
}
|
||||
stream = append(stream, res.phases...)
|
||||
|
||||
// Doorway/blocked stops fire *before* the current room actually
|
||||
// resolved — those don't count as a walked room. Everything else
|
||||
// (OK, fork after a clear, ended after combat, complete) does.
|
||||
if res.reason != stopBlocked && res.reason != stopElite && res.reason != stopBoss {
|
||||
rooms++
|
||||
}
|
||||
|
||||
if res.reason != stopOK {
|
||||
footer := autopilotFooter(res.reason, rooms)
|
||||
if footer != "" {
|
||||
finalMsg = res.final + "\n\n" + footer
|
||||
} else {
|
||||
finalMsg = res.final
|
||||
}
|
||||
reason = res.reason
|
||||
break
|
||||
}
|
||||
|
||||
// Walked into the next room. Push this step's "✓ cleared / next
|
||||
// room" block as a phase so the next iteration's intro lands
|
||||
// after it, then loop. Refresh exp for the next preflight read
|
||||
// (HP/SU may have moved during combat).
|
||||
stream = append(stream, res.final)
|
||||
if fresh, ferr := getActiveExpedition(ctx.Sender); ferr == nil && fresh != nil {
|
||||
exp = fresh
|
||||
}
|
||||
|
||||
// Arrived at a Boss doorway: stop here. The "Room X/Y — Boss.
|
||||
// !fight when ready." line in res.final already tells the player
|
||||
// what to do; another loop iteration would just hit the gate and
|
||||
// emit a duplicate "Room X/Y — Boss" message.
|
||||
//
|
||||
// For Elite + non-compact, do the same. In compact mode we let
|
||||
// the next iteration run because the gate will auto-resolve the
|
||||
// elite inline (which is the whole point of compact mode).
|
||||
if res.nextRoomType == RoomBoss || (res.nextRoomType == RoomElite && !compact) {
|
||||
r := stopBoss
|
||||
if res.nextRoomType == RoomElite {
|
||||
r = stopElite
|
||||
}
|
||||
finalMsg = autopilotFooter(r, rooms)
|
||||
reason = r
|
||||
break
|
||||
}
|
||||
|
||||
// Phase 2 — auto-harvest the room we just walked into.
|
||||
// advanceOnceWithOpts now runs the pass inline and surfaces the
|
||||
// result via res.harvest + res.harvestFooter. The "✓ cleared /
|
||||
// next room" line in res.final already has the per-room footer
|
||||
// appended (and the combat-interrupt narration if one fired);
|
||||
// we still need to aggregate into walkYields/walkNames and to
|
||||
// split out the harvest-combat path so the walk-end footer/tally
|
||||
// rendering matches the pre-H2 behavior.
|
||||
hr := res.harvest
|
||||
for k, v := range hr.Summary.Yields {
|
||||
walkYields[k] += v
|
||||
walkNames[k] = hr.Summary.Names[k]
|
||||
}
|
||||
if hr.CombatNarr != "" {
|
||||
// A harvest interrupt fired combat. res.final already
|
||||
// includes the ✓-cleared block, the harvest footer, and the
|
||||
// combat narration; just attach the walk footer/tally and
|
||||
// stop.
|
||||
r := stopHarvestCombat
|
||||
if hr.PlayerEnded {
|
||||
r = stopEnded
|
||||
}
|
||||
footer := autopilotFooter(r, rooms)
|
||||
finalMsg = res.final
|
||||
if footer != "" {
|
||||
finalMsg += "\n\n" + footer
|
||||
}
|
||||
if tally := renderWalkTally(walkYields, walkNames); tally != "" && !hr.PlayerEnded {
|
||||
finalMsg += "\n\n" + tally
|
||||
}
|
||||
// Drop the just-pushed res.final from the stream so the
|
||||
// combined finalMsg lands as the closer, not as a duplicate
|
||||
// phase entry.
|
||||
if len(stream) > 0 && stream[len(stream)-1] == res.final {
|
||||
stream = stream[:len(stream)-1]
|
||||
}
|
||||
reason = r
|
||||
break
|
||||
}
|
||||
// Refresh exp once more — auto-harvest may have bumped threat
|
||||
// via noise interrupts.
|
||||
if fresh, ferr := getActiveExpedition(ctx.Sender); ferr == nil && fresh != nil {
|
||||
exp = fresh
|
||||
}
|
||||
}
|
||||
|
||||
if finalMsg == "" {
|
||||
// Hit the room cap without a hard interrupt — synthesize a
|
||||
// "press to continue" footer so the player knows autopilot
|
||||
// stopped on its own clock, not because something needs them.
|
||||
finalMsg = autopilotFooter(stopOK, rooms)
|
||||
}
|
||||
// Walk-wide haul tally — appended to whatever the final block is,
|
||||
// unless a combat-interrupt branch already added it (death suppresses
|
||||
// it; harvest-combat survival adds it inline).
|
||||
if tally := renderWalkTally(walkYields, walkNames); tally != "" &&
|
||||
!strings.Contains(finalMsg, "Walk haul:") {
|
||||
finalMsg += "\n\n" + tally
|
||||
}
|
||||
return autopilotWalkResult{
|
||||
stream: stream,
|
||||
finalMsg: finalMsg,
|
||||
rooms: rooms,
|
||||
reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
// autopilotPreflight checks the threshold-based interrupts that fire
|
||||
// between rooms. Returns (footer, true) when autopilot should stop.
|
||||
func autopilotPreflight(userID id.UserID, exp *Expedition) (string, bool) {
|
||||
cur, max := dndHPSnapshot(userID)
|
||||
if max > 0 && float64(cur) <= float64(max)*autopilotLowHPPct {
|
||||
return fmt.Sprintf(
|
||||
"⏸ **Autopilot paused — HP low** (%d/%d). `!camp` to rest, `!cast` healing, or `!expedition run` to push on.",
|
||||
cur, max), true
|
||||
}
|
||||
if exp.Supplies.DailyBurn > 0 && exp.Supplies.Current < exp.Supplies.DailyBurn {
|
||||
return fmt.Sprintf(
|
||||
"⏸ **Autopilot paused — supplies low** (%.1f / %.1f SU, under one day). `!extract` to bail or `!expedition run` to push on.",
|
||||
exp.Supplies.Current, exp.Supplies.DailyBurn), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// autopilotFooter renders the closing line for an autopilot walk. The
|
||||
// rooms-walked tally is informational; reason controls the verb and the
|
||||
// suggested next step.
|
||||
func autopilotFooter(reason stopReason, rooms int) string {
|
||||
roomsStr := "1 room"
|
||||
if rooms != 1 {
|
||||
roomsStr = fmt.Sprintf("%d rooms", rooms)
|
||||
}
|
||||
switch reason {
|
||||
case stopFork:
|
||||
return fmt.Sprintf("⏸ **Autopilot paused at a fork** (after %s). `!expedition go <n>` to choose; auto-walk resumes automatically.", roomsStr)
|
||||
case stopElite:
|
||||
return fmt.Sprintf("⏸ **Autopilot paused — elite ahead** (after %s). `!fight` when ready, then `!expedition run` to continue.", roomsStr)
|
||||
case stopBoss:
|
||||
return fmt.Sprintf("⏸ **Autopilot paused — boss ahead** (after %s). `!fight` when ready.", roomsStr)
|
||||
case stopEnded:
|
||||
return "" // death narration is the final; no footer
|
||||
case stopComplete:
|
||||
return "" // run-complete block is the final; no footer
|
||||
case stopBlocked:
|
||||
return "" // "finish your fight first" is the final; no footer
|
||||
case stopHarvestCombat:
|
||||
return fmt.Sprintf("⏸ **Autopilot paused — interrupted while gathering** (after %s). Reassess and `!expedition run` to continue.", roomsStr)
|
||||
default: // stopOK — hit the room cap
|
||||
return fmt.Sprintf("⏸ **Autopilot stretch complete** (%s). `!expedition run` to keep walking, or `!resources` / `!camp` first.", roomsStr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
@@ -189,6 +190,139 @@ func TestExpeditionCmd_StartInsufficientCoins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpeditionCmd_RunNoExpedition: !expedition run with no active
|
||||
// expedition is a friendly no-op, not an error.
|
||||
func TestExpeditionCmd_RunNoExpedition(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@exp-cmd-run-noexp:example")
|
||||
expeditionCmdTestCharacter(t, uid, 1)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "run"); err != nil {
|
||||
t.Fatalf("run with no expedition: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpeditionCmd_RunWalksRooms: starting an expedition then calling
|
||||
// `!expedition run` advances the underlying zone-run by at least one
|
||||
// room (and stops cleanly at a natural interrupt — fork, elite, boss,
|
||||
// complete, or the autopilot room cap).
|
||||
func TestExpeditionCmd_RunWalksRooms(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@exp-cmd-run-walk:example")
|
||||
expeditionCmdTestCharacter(t, uid, 3)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
euro := &EuroPlugin{}
|
||||
euro.ensureBalance(uid)
|
||||
euro.Credit(uid, 200, "test setup")
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start goblin_warrens"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp, _ := getActiveExpedition(uid)
|
||||
if exp == nil || exp.RunID == "" {
|
||||
t.Fatal("expected active expedition with run")
|
||||
}
|
||||
startRun, _ := getZoneRun(exp.RunID)
|
||||
if startRun == nil {
|
||||
t.Fatal("expected zone run for expedition")
|
||||
}
|
||||
startRoom := startRun.CurrentRoom
|
||||
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "run"); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
|
||||
// After autopilot, the run should have advanced. Either the room
|
||||
// pointer moved, or the run completed (TotalRooms reached). For a
|
||||
// fresh L3 run at GoblinWarrens we expect at least one room walked
|
||||
// — the entry room is narration-only and never blocks.
|
||||
endRun, _ := getZoneRun(exp.RunID)
|
||||
if endRun == nil {
|
||||
// Run could legitimately complete and be cleared; that's also fine.
|
||||
return
|
||||
}
|
||||
if endRun.CurrentRoom <= startRoom {
|
||||
t.Errorf("autopilot did not advance: room %d → %d", startRoom, endRun.CurrentRoom)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutopilotPreflight_LowHP: HP at or below the threshold trips the
|
||||
// preflight stop with the low-HP footer.
|
||||
func TestAutopilotPreflight_LowHP(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@exp-autopilot-lowhp:example")
|
||||
expeditionCmdTestCharacter(t, uid, 1)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
c, _ := LoadDnDCharacter(uid)
|
||||
c.HPCurrent = 5 // 5/20 = 25%, below 30% threshold
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp := &Expedition{
|
||||
Supplies: ExpeditionSupplies{Current: 10, DailyBurn: 1},
|
||||
}
|
||||
msg, stop := autopilotPreflight(uid, exp)
|
||||
if !stop {
|
||||
t.Fatal("expected low-HP stop")
|
||||
}
|
||||
if !strings.Contains(msg, "HP low") {
|
||||
t.Errorf("expected HP-low footer, got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutopilotPreflight_LowSU: supplies under one day's burn trips.
|
||||
func TestAutopilotPreflight_LowSU(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@exp-autopilot-lowsu:example")
|
||||
expeditionCmdTestCharacter(t, uid, 1)
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
exp := &Expedition{
|
||||
Supplies: ExpeditionSupplies{Current: 0.5, DailyBurn: 1.5},
|
||||
}
|
||||
msg, stop := autopilotPreflight(uid, exp)
|
||||
if !stop {
|
||||
t.Fatal("expected low-SU stop")
|
||||
}
|
||||
if !strings.Contains(msg, "supplies low") {
|
||||
t.Errorf("expected SU-low footer, got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutopilotFooter_Reasons: each stop reason gets a distinct,
|
||||
// non-empty footer (or empty for stops where the final block already
|
||||
// ends the message — death, complete, blocked).
|
||||
func TestAutopilotFooter_Reasons(t *testing.T) {
|
||||
cases := []struct {
|
||||
r stopReason
|
||||
want string // substring; empty means footer must be empty
|
||||
wantHas bool
|
||||
}{
|
||||
{stopFork, "fork", true},
|
||||
{stopElite, "elite", true},
|
||||
{stopBoss, "boss", true},
|
||||
{stopOK, "stretch", true}, // hit room cap
|
||||
{stopEnded, "", false},
|
||||
{stopComplete, "", false},
|
||||
{stopBlocked, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := autopilotFooter(c.r, 3)
|
||||
if c.wantHas {
|
||||
if got == "" || !strings.Contains(got, c.want) {
|
||||
t.Errorf("%v: got %q, want substring %q", c.r, got, c.want)
|
||||
}
|
||||
} else if got != "" {
|
||||
t.Errorf("%v: expected empty footer, got %q", c.r, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpeditionCmd_ListWithoutCharBlocked(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@exp-cmd-nochar:example")
|
||||
|
||||
@@ -30,6 +30,23 @@ import (
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// retreatThreatBump is the threat penalty applied when the player times
|
||||
// out of a combat (PlayerEndHP > 0 but PlayerWon == false). Retreats
|
||||
// represent the player breaking off wounded — the run continues, but the
|
||||
// zone's awareness ratchets up. Tuned alongside the expedition-difficulty
|
||||
// pass (see gogobee_expedition_difficulty.md): low enough not to compound
|
||||
// brutally with chained retreats, high enough that 3–4 retreats walks the
|
||||
// threat clock toward Stirring.
|
||||
//
|
||||
// History: pre-Phase-2 the engine's timeout-loss path called
|
||||
// abandonZoneRun + retireAllRegionRuns, ending the expedition outright
|
||||
// despite the engine's contract ("Timeout = retreat, not lethal blow").
|
||||
// That made any single fight loss across a 14-day expedition an
|
||||
// auto-fail, which the sim harness exposed as uniform-0% completion
|
||||
// across every tier. Splitting the retreat path here was Phase 2's
|
||||
// first lever.
|
||||
const retreatThreatBump = 5
|
||||
|
||||
// ── Combat Interrupt (§4.2) ─────────────────────────────────────────────────
|
||||
|
||||
// CombatInterruptKind is the bucket the d20+tier roll lands in.
|
||||
@@ -71,11 +88,23 @@ func resolveCombatInterrupt(
|
||||
mod -= 3
|
||||
}
|
||||
total := r + mod
|
||||
// Phase 5-B: elite bracket raised from 19 to 23 (Phase 3-A finding).
|
||||
// At low threat, base d20 + tier+class mod can reach ~22 at most,
|
||||
// so elite-from-interrupt is now effectively a *high-threat* event
|
||||
// (the +1-per-20-threat-above-40 mod is what pushes totals into
|
||||
// the 23+ band). Elite monsters still appear via the Patrol pool,
|
||||
// just less often from the d20 interrupt roll itself. Combined
|
||||
// with the player power floor + supply-burn cut + threat-drift
|
||||
// cut, this lands the live completion curve in the "fairly breezy
|
||||
// with some death" band. Elite case sits *above* Patrol in the
|
||||
// switch so a 23+ total prefers Elite (single dangerous fight)
|
||||
// over Patrol (multi-enemy harvest fail). See
|
||||
// gogobee_expedition_difficulty.md Phase 5-B.
|
||||
switch {
|
||||
case total >= 23:
|
||||
return InterruptElite, total
|
||||
case total >= 22:
|
||||
return InterruptPatrol, total
|
||||
case total >= 19:
|
||||
return InterruptElite, total
|
||||
case total >= 15:
|
||||
return InterruptStandard, total
|
||||
case total >= 9:
|
||||
@@ -104,22 +133,20 @@ func (p *AdventurePlugin) runHarvestInterrupt(
|
||||
}
|
||||
|
||||
// Surprise round (§4.1): a single free enemy swing nicks HP. We cap at
|
||||
// HP-1 so the nick alone can't KO; real combat resolves below.
|
||||
// HP-1 so the nick alone can't KO; real combat resolves below. The
|
||||
// wounded-entrant clamp (clampSurpriseNick) softens the nick further
|
||||
// when the fighter is already below full HP — see that helper for
|
||||
// the death-spiral motivation.
|
||||
preDmg := surpriseRoundNick(monster, int(zone.Tier))
|
||||
dndChar, _ := LoadDnDCharacter(userID)
|
||||
if dndChar != nil && preDmg > 0 {
|
||||
if preDmg >= dndChar.HPCurrent {
|
||||
preDmg = dndChar.HPCurrent - 1
|
||||
if preDmg < 0 {
|
||||
preDmg = 0
|
||||
}
|
||||
}
|
||||
preDmg = clampSurpriseNick(preDmg, dndChar.HPCurrent, dndChar.HPMax)
|
||||
dndChar.HPCurrent -= preDmg
|
||||
_ = SaveDnDCharacter(dndChar)
|
||||
}
|
||||
|
||||
preCombatHP, _ := dndHPSnapshot(userID)
|
||||
result, err := p.runZoneCombat(userID, monster, int(zone.Tier))
|
||||
result, err := p.runZoneCombat(userID, monster, int(zone.Tier), nil, run.DMMood)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("_(Interrupt combat error: %v.)_", err), false
|
||||
}
|
||||
@@ -146,9 +173,22 @@ func (p *AdventurePlugin) runHarvestInterrupt(
|
||||
}
|
||||
|
||||
if !result.PlayerWon {
|
||||
if result.TimedOut {
|
||||
// Retreat: fighter broke off wounded but alive. The engine's
|
||||
// contract (combat_engine.go: "Timeout = retreat, not lethal
|
||||
// blow") means HP stays where the engine left it and we keep
|
||||
// the run going. The harvest slot is forfeit (no kill, no
|
||||
// loot) and threat ticks up.
|
||||
_ = applyThreatDelta(exp.ID, retreatThreatBump, "combat retreat")
|
||||
b.WriteString(fmt.Sprintf("⏳ **%s** outlasts you. You break off, wounded but alive. (Threat +%d.)",
|
||||
monster.Name, retreatThreatBump))
|
||||
return b.String(), false
|
||||
}
|
||||
// True death.
|
||||
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
|
||||
_ = abandonZoneRun(userID)
|
||||
_ = retireAllRegionRuns(exp)
|
||||
_, _, _ = forcedExtractExpedition(exp.ID, "interrupt death")
|
||||
markAdventureDead(userID, "expedition", zone.Display)
|
||||
if line := flavor.Pick(flavor.PlayerDeath); line != "" {
|
||||
b.WriteString(line)
|
||||
@@ -176,17 +216,89 @@ func (p *AdventurePlugin) runHarvestInterrupt(
|
||||
// surpriseRoundNick computes a small HP nick representing the enemy's
|
||||
// free first swing. Roughly attack-bonus + 1d4, with a tier-based floor.
|
||||
func surpriseRoundNick(m DnDMonsterTemplate, tier int) int {
|
||||
return surpriseRoundNickF(m, tier, -1)
|
||||
}
|
||||
|
||||
// surpriseRoundNickF is the floor-parameterized form used by the Phase
|
||||
// 3-B sim harness lever sweep. floorOverride < 0 means "use live"
|
||||
// (floor = tier); floorOverride >= 0 substitutes that absolute value
|
||||
// as the floor (0 disables the floor entirely). Live callers always go
|
||||
// through surpriseRoundNick. See gogobee_expedition_difficulty.md
|
||||
// Phase 3-B.
|
||||
func surpriseRoundNickF(m DnDMonsterTemplate, tier, floorOverride int) int {
|
||||
if tier < 1 {
|
||||
tier = 1
|
||||
}
|
||||
dmg := 1 + rand.IntN(4) + m.AttackBonus/2
|
||||
floor := tier
|
||||
if floorOverride >= 0 {
|
||||
floor = floorOverride
|
||||
}
|
||||
if dmg < floor {
|
||||
dmg = floor
|
||||
}
|
||||
return dmg
|
||||
}
|
||||
|
||||
// clampSurpriseNick scales the surprise-round nick down for fighters who
|
||||
// enter combat already wounded. The raw nick is fine on a fresh entry
|
||||
// (full HP), but chained interrupts create a death-spiral: an over-tier
|
||||
// elite drops the fighter to ~25% HP and a retreat, then the next
|
||||
// standard fight's surprise nick — landing on already-low HP — pre-empts
|
||||
// the combat engine entirely. The Phase 2 tier-lethality trace
|
||||
// (TestExpeditionBalance_Phase2_TierLethality) showed this cascade was
|
||||
// the cause of death in 4 of 5 tier traces post-2a, not the elites
|
||||
// themselves.
|
||||
//
|
||||
// Policy: at full HP, raw nick stands. When wounded (HPCurrent < HPMax),
|
||||
// cap the nick at max(1, HPCurrent/5) so a wounded fighter loses at most
|
||||
// ~20% of remaining HP to the free swing — they enter the fight bruised
|
||||
// but with margin to fight back. The existing KO-guard (nick < HP) is
|
||||
// preserved as a hard backstop.
|
||||
//
|
||||
// Tuning surface: the /5 divisor is the wounded-fighter lethality knob.
|
||||
// Tighter (e.g. /10) is gentler; looser (/3) re-opens the cascade. See
|
||||
// gogobee_expedition_difficulty.md Phase 2b. liveSurpriseNickDivisor
|
||||
// names the shipped value; the harness Phase 2 lever sweep
|
||||
// (TestExpeditionBalance_Phase2_LeverSweep) calls clampSurpriseNickD
|
||||
// directly with alternate divisors. Live callers always go through
|
||||
// clampSurpriseNick.
|
||||
const liveSurpriseNickDivisor = 5
|
||||
|
||||
func clampSurpriseNick(rawNick, hpCurrent, hpMax int) int {
|
||||
return clampSurpriseNickD(rawNick, hpCurrent, hpMax, liveSurpriseNickDivisor)
|
||||
}
|
||||
|
||||
// clampSurpriseNickD is the divisor-parameterized form used by the
|
||||
// sim harness lever sweep. divisor <= 0 falls back to the shipped
|
||||
// value so a zero-valued harness override behaves as "use live."
|
||||
func clampSurpriseNickD(rawNick, hpCurrent, hpMax, divisor int) int {
|
||||
if rawNick <= 0 || hpCurrent <= 0 {
|
||||
return 0
|
||||
}
|
||||
if divisor <= 0 {
|
||||
divisor = liveSurpriseNickDivisor
|
||||
}
|
||||
nick := rawNick
|
||||
if hpCurrent < hpMax {
|
||||
cap := hpCurrent / divisor
|
||||
if cap < 1 {
|
||||
cap = 1
|
||||
}
|
||||
if nick > cap {
|
||||
nick = cap
|
||||
}
|
||||
}
|
||||
// KO-guard: surprise alone never finishes the fighter.
|
||||
if nick >= hpCurrent {
|
||||
nick = hpCurrent - 1
|
||||
}
|
||||
if nick < 0 {
|
||||
nick = 0
|
||||
}
|
||||
return nick
|
||||
}
|
||||
|
||||
// ── Kill-log writer ─────────────────────────────────────────────────────────
|
||||
|
||||
// monsterKillTags maps a bestiary ID to the kill-log tags it satisfies.
|
||||
@@ -359,8 +471,7 @@ func (p *AdventurePlugin) tryPatrolEncounter(
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
preHP, _ := dndHPSnapshot(userID)
|
||||
result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier))
|
||||
result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier), nil, run.DMMood)
|
||||
if rerr != nil {
|
||||
err = rerr
|
||||
return
|
||||
@@ -385,9 +496,25 @@ func (p *AdventurePlugin) tryPatrolEncounter(
|
||||
|
||||
var ob strings.Builder
|
||||
if !result.PlayerWon {
|
||||
if result.TimedOut {
|
||||
// Retreat — see retreatThreatBump doc. Run continues; threat
|
||||
// ticks; the patrol's awareness lingers as a soft penalty
|
||||
// instead of an auto-fail.
|
||||
_ = applyThreatDelta(exp.ID, retreatThreatBump, "patrol retreat")
|
||||
ob.WriteString(fmt.Sprintf("⏳ The patrol drags on. You break off, wounded but alive. (Threat +%d.)",
|
||||
retreatThreatBump))
|
||||
if rollLine := dndRollSummaryLine(result); rollLine != "" {
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(rollLine)
|
||||
}
|
||||
outcome = ob.String()
|
||||
ended = false
|
||||
return
|
||||
}
|
||||
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
|
||||
_ = abandonZoneRun(userID)
|
||||
_ = retireAllRegionRuns(exp)
|
||||
_, _, _ = forcedExtractExpedition(exp.ID, "patrol death")
|
||||
markAdventureDead(userID, "patrol", zone.Display)
|
||||
if line := flavor.Pick(flavor.PlayerDeath); line != "" {
|
||||
ob.WriteString(line)
|
||||
@@ -403,8 +530,8 @@ func (p *AdventurePlugin) tryPatrolEncounter(
|
||||
return
|
||||
}
|
||||
_ = recordZoneKill(exp, monster.ID)
|
||||
ob.WriteString(fmt.Sprintf("✅ Patrol dispatched (HP %d→%d / %d).",
|
||||
preHP, postHP, maxHP))
|
||||
ob.WriteString(fmt.Sprintf("✅ Patrol dispatched. You finished at **%d/%d HP**.",
|
||||
postHP, maxHP))
|
||||
if rollLine := dndRollSummaryLine(result); rollLine != "" {
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(rollLine)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user