mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
89 Commits
forex-cryp
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b4e6833b0 | ||
|
|
1f211564d9 | ||
|
|
3369d7d8fe | ||
|
|
08d3053368 | ||
|
|
32e3148755 | ||
|
|
a063e0ccd0 | ||
|
|
b333d05443 | ||
|
|
1928f75c19 | ||
|
|
0f144fa335 | ||
|
|
e8d06195ac | ||
|
|
d7d0230223 | ||
|
|
ec614e84f1 | ||
|
|
41f98b721a | ||
|
|
fc0dff710e | ||
|
|
ae5762fc91 | ||
|
|
31e3d69d8d | ||
|
|
adcc62112b | ||
|
|
0c603cfece | ||
|
|
1adcd05dc7 | ||
|
|
ba7b20dfe5 | ||
|
|
b5493a0e79 | ||
|
|
c9df282fde | ||
|
|
e8f4863ae0 | ||
|
|
a4f162d2ee | ||
|
|
0f82a088f9 | ||
|
|
d3f42009f7 | ||
|
|
6387380d0a | ||
|
|
20d1f92f97 | ||
|
|
48330be3d5 | ||
|
|
c07d228be6 | ||
|
|
f93fddd1d6 | ||
|
|
7c19788b52 | ||
|
|
8122973b74 | ||
|
|
cbfca525f5 | ||
|
|
f4a39b46e9 | ||
|
|
6a47be34bc | ||
|
|
667f87f9d0 | ||
|
|
1b8d13e0dd | ||
|
|
d80b437525 | ||
|
|
4934383a9a | ||
|
|
a46b773750 | ||
|
|
b80de43db1 | ||
|
|
81dda51907 | ||
|
|
d7ced471a1 | ||
|
|
63ad423b79 | ||
|
|
da94d51857 | ||
|
|
151d6abc01 | ||
|
|
631764bbbd | ||
|
|
ad2a8258ba | ||
|
|
2fdb280477 | ||
|
|
4576c75722 | ||
|
|
3b29d10461 | ||
|
|
29cad7972a | ||
|
|
a2992ea06c | ||
|
|
6c4b14e113 | ||
|
|
9be85ba954 | ||
|
|
26cda148fb | ||
|
|
040cfba514 | ||
|
|
aaec0ba225 | ||
|
|
5a6e395805 | ||
|
|
68ed8e7c60 | ||
|
|
c729433353 | ||
|
|
7115c536ef | ||
|
|
42fb805ee0 | ||
|
|
d99f975074 | ||
|
|
bbc25fe958 | ||
|
|
89edc76829 | ||
|
|
4999368031 | ||
|
|
92b99a0399 | ||
|
|
0f72484653 | ||
|
|
2e6274c1b7 | ||
|
|
9eed921e4b | ||
|
|
2ce56cf76a | ||
|
|
7dbfa0b56f | ||
|
|
b167882e3e | ||
|
|
95e0995c7f | ||
|
|
56f896b941 | ||
|
|
ef8fbe5496 | ||
|
|
bcd4a873a5 | ||
|
|
01d2329993 | ||
|
|
e4518c9c39 | ||
|
|
20b0d027b2 | ||
|
|
28a90292f0 | ||
|
|
5d7c76fb20 | ||
|
|
100a4f1054 | ||
|
|
f6a457ae84 | ||
|
|
513cf32e42 | ||
|
|
978dc5e25f | ||
|
|
6e4928ca17 |
54
.env.example
54
.env.example
@@ -1,12 +1,50 @@
|
||||
# ---- Matrix Connection ----
|
||||
# GogoBee authenticates via the MAS (Matrix Authentication Service) OAuth 2.0
|
||||
# device grant. No password/token goes here: on first run the bot prints a URL
|
||||
# + code to approve ONCE in a browser (logged in as BOT_USER_ID), then stores a
|
||||
# refresh token in DATA_DIR/mas_auth.json and refreshes silently thereafter.
|
||||
# To force re-authorization, delete data/mas_auth.json.
|
||||
HOMESERVER_URL=https://matrix.example.com
|
||||
BOT_USER_ID=@gogobee:example.com
|
||||
BOT_PASSWORD=your_password_here
|
||||
BOT_DISPLAY_NAME=GogoBee
|
||||
|
||||
# ---- Auth mode ----
|
||||
# masdevice (default) — MAS OAuth 2.0 device grant over /sync (above).
|
||||
# appservice — Matrix appservice: the registration's as_token is the
|
||||
# credential (no login/MFA/expiry, MAS-durable). The bot
|
||||
# runs an HTTP listener and receives events over Synapse's
|
||||
# transaction push instead of /sync (Synapse forbids AS
|
||||
# users from /sync). E2EE rides MSC3202/MSC2409 + MSC4190.
|
||||
AUTH_MODE=masdevice
|
||||
# The following are required only when AUTH_MODE=appservice:
|
||||
AS_REGISTRATION= # path to the appservice registration YAML (as_token/hs_token/namespaces)
|
||||
AS_LISTEN_HOST= # bind address for the transaction listener (e.g. 0.0.0.0 or a tailnet IP)
|
||||
AS_LISTEN_PORT= # bind port; Synapse's registration url must reach host:port
|
||||
HOMESERVER_DOMAIN= # server_name, e.g. example.com (derives the bot MXID from sender_localpart)
|
||||
|
||||
# ---- Cross-signing (optional; both auth modes) ----
|
||||
# Controls only whether the bot shows as VERIFIED in clients — E2EE works without it.
|
||||
# Normally you set NEITHER of these. On its first start the bot mints a cross-signing
|
||||
# identity and persists the recovery key itself to DATA_DIR/cross_signing.json (0600);
|
||||
# every later start reuses that identity untouched, so users verify the bot once, ever.
|
||||
#
|
||||
# Set to 1 for exactly ONE start to throw away the published identity and mint a fresh
|
||||
# one (the bot stores the new key itself). Every user must then verify the bot again.
|
||||
# Needed only if the identity was lost, or to adopt one the bot can't re-sign.
|
||||
CROSS_SIGNING_REGENERATE=
|
||||
#
|
||||
# Imports an existing recovery key into DATA_DIR/cross_signing.json on next start.
|
||||
# Only useful to adopt an identity created before the bot persisted its own key.
|
||||
CROSS_SIGNING_RECOVERY_KEY=
|
||||
|
||||
# Which rooms the bot posts scheduled content to (comma-separated room IDs)
|
||||
BROADCAST_ROOMS=!roomid:example.com
|
||||
|
||||
# The daily 08:00 WOTD auto-post is disabled by default.
|
||||
# Set to true to enable it. The !wotd command and passive WOTD
|
||||
# usage tracking work regardless of this setting.
|
||||
ENABLE_WOTD_POST=false
|
||||
|
||||
# Admins who can run admin-only commands (comma-separated Matrix user IDs)
|
||||
ADMIN_USERS=@yourmxid:example.com
|
||||
|
||||
@@ -123,3 +161,17 @@ MINIFLUX_MAX_PER_POLL=5 # max entries per feed per poll (default 5)
|
||||
RATELIMIT_WEATHER=5
|
||||
RATELIMIT_TRANSLATE=20
|
||||
RATELIMIT_CONCERTS=10
|
||||
|
||||
# ---- Email Nag (one-shot migration helper: collect+verify missing Authentik emails over Matrix DM) ----
|
||||
FEATURE_EMAIL_NAG= # set to "true" to enable
|
||||
EMAIL_NAG_AUTHENTIK_URL= # e.g. https://authentik.parodia.dev
|
||||
EMAIL_NAG_AUTHENTIK_TOKEN= # Authentik service-account token, scoped to user write
|
||||
EMAIL_NAG_RESEND_KEY= # Resend API key (sends the verification code)
|
||||
EMAIL_NAG_MAIL_FROM= # e.g. Parodia <noreply@mail.parodia.dev>
|
||||
EMAIL_NAG_HOME_DOMAIN= # Matrix server name, e.g. parodia.dev (builds @user:domain)
|
||||
EMAIL_NAG_TARGETS= # comma-separated Authentik usernames to nag (== Matrix localparts)
|
||||
EMAIL_NAG_CODE_TTL=30m # verification code lifetime (default 30m)
|
||||
EMAIL_NAG_MAX_ATTEMPTS=5 # wrong-code attempts before restart (default 5)
|
||||
EMAIL_NAG_MAX_SENDS_PER_HOUR=5 # cap on verification emails per user per rolling hour (anti-spam, default 5)
|
||||
EMAIL_NAG_SWEEP_DELAY=2s # delay between DMs during the startup sweep (default 2s)
|
||||
EMAIL_NAG_REPROMPT_COOLDOWN= # re-nag non-responders whose last prompt is older than this (e.g. 72h); empty/0 = nag once
|
||||
|
||||
@@ -91,7 +91,14 @@ Everything is configured through environment variables or a `.env` file.
|
||||
|----------|-------------|
|
||||
| `HOMESERVER_URL` | Matrix homeserver URL, e.g. `https://matrix.org` |
|
||||
| `BOT_USER_ID` | Bot's Matrix user ID, e.g. `@gogobee:matrix.org` |
|
||||
| `BOT_PASSWORD` | Bot's Matrix password |
|
||||
|
||||
GogoBee authenticates via the **Matrix Authentication Service (MAS) OAuth 2.0
|
||||
device grant** — no password or token in the environment. On first run it prints
|
||||
a verification URL and user code; approve it once in a browser while logged in
|
||||
as `BOT_USER_ID`. The bot then stores a refresh token in `DATA_DIR/mas_auth.json`
|
||||
and refreshes its access token silently for the life of the deployment. Delete
|
||||
`data/mas_auth.json` to force re-authorization. Requires a homeserver with MAS
|
||||
enabled (advertised via the `org.matrix.msc2965.authentication` well-known).
|
||||
|
||||
### Core (optional)
|
||||
|
||||
|
||||
@@ -3,22 +3,33 @@
|
||||
// outcomes mirror what live players hit.
|
||||
//
|
||||
// Single run:
|
||||
// expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens]
|
||||
// [-bank 1000] [-cap 50] [-log] [-data DIR]
|
||||
//
|
||||
// expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens]
|
||||
// [-bank 1000] [-cap 50] [-log] [-data DIR]
|
||||
//
|
||||
// Party run (N3/P7 — the leader is -class; followers clone it unless named):
|
||||
//
|
||||
// expedition-sim -party 3 -level 15 -zone dragons_lair
|
||||
// expedition-sim -party 2 -class cleric -party-classes fighter -level 16 ...
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// expedition-sim -matrix -classes fighter,mage -levels 5,10 \
|
||||
// -zones goblin_warrens,wolf_den -runs 20
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gogobee/internal/plugin"
|
||||
|
||||
@@ -32,6 +43,7 @@ func main() {
|
||||
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)")
|
||||
days = flag.Int("days", 0, "stop after N synthetic day rollovers (0 = unbounded; the -cap safety net still applies)")
|
||||
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)")
|
||||
@@ -43,10 +55,26 @@ func main() {
|
||||
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")
|
||||
|
||||
petLevel = flag.Int("pet-level", 0, "attach a base housing pet at this level (1-10) to every sim character; 0 = no pet (default, matches prod char-creation)")
|
||||
|
||||
party = flag.Int("party", 1, "seat a party of N (1 = solo, the historical path). Followers are invited on Day 1 and buy their own supplies")
|
||||
partyClasses = flag.String("party-classes", "", "comma-separated classes for the N-1 followers (default: clones of -class)")
|
||||
|
||||
jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *petLevel < 0 || *petLevel > 10 {
|
||||
fail("pet-level must be 0-10, got", *petLevel)
|
||||
}
|
||||
if *party < 1 || *party > plugin.ExpeditionPartyMax {
|
||||
fail("party must be 1-", plugin.ExpeditionPartyMax, ", got", *party)
|
||||
}
|
||||
followers := followerClasses(*class, *party, *partyClasses)
|
||||
|
||||
plugin.SetSimIncludeTrace(*trace)
|
||||
plugin.SetSimPetLevel(*petLevel)
|
||||
|
||||
if *matrix {
|
||||
// Matrix default: drop log to keep stdout manageable; explicit
|
||||
@@ -58,14 +86,40 @@ func main() {
|
||||
includeLog = *logFlag
|
||||
}
|
||||
})
|
||||
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, includeLog)
|
||||
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel, *party, *partyClasses)
|
||||
return
|
||||
}
|
||||
|
||||
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *logFlag)
|
||||
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag, followers)
|
||||
}
|
||||
|
||||
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap int, includeLog bool) {
|
||||
// followerClasses expands -party / -party-classes into the class of each
|
||||
// follower seat. An explicit list must name every seat: a party of 3 whose
|
||||
// second follower silently defaulted to the leader's class would quietly
|
||||
// answer a different question than the one asked.
|
||||
func followerClasses(leaderClass string, party int, spec string) []string {
|
||||
n := party - 1
|
||||
if n == 0 {
|
||||
if strings.TrimSpace(spec) != "" {
|
||||
fail("-party-classes given but -party is 1")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(spec) == "" {
|
||||
out := make([]string, n)
|
||||
for i := range out {
|
||||
out[i] = leaderClass
|
||||
}
|
||||
return out
|
||||
}
|
||||
out := splitNonEmpty(spec)
|
||||
if len(out) != n {
|
||||
fail(fmt.Sprintf("-party %d needs %d follower classes, got %d", party, n, len(out)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool, followers []string) {
|
||||
dir := dataDir
|
||||
if dir == "" {
|
||||
var err error
|
||||
@@ -76,7 +130,7 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa
|
||||
defer os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap)
|
||||
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days, followers)
|
||||
if err != nil {
|
||||
if res != nil {
|
||||
if !includeLog {
|
||||
@@ -92,47 +146,133 @@ func runSingle(class string, level int, zone, userTag, dataDir string, bank floa
|
||||
emitIndented(res)
|
||||
}
|
||||
|
||||
func runMatrix(classes, levels, zones string, runs int, bank float64, cap int, includeLog bool) {
|
||||
// matrixJob is one (class, level, zone, replicate-index) cell of the
|
||||
// matrix sweep. Each job is run by a worker as a single-run subprocess so
|
||||
// it gets its own SQLite handle — the plugin package's db.* globals
|
||||
// preclude in-process parallelism.
|
||||
type matrixJob struct {
|
||||
class string
|
||||
level int
|
||||
zone string
|
||||
rep int
|
||||
}
|
||||
|
||||
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel, party int, partyClasses string) {
|
||||
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)
|
||||
if jobs <= 0 {
|
||||
jobs = runtime.NumCPU()
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
fail("os.Executable:", err)
|
||||
}
|
||||
|
||||
work := make([]matrixJob, 0, len(cs)*len(ls)*len(zs)*runs)
|
||||
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)
|
||||
work = append(work, matrixJob{class: c, level: lv, zone: z, rep: r})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
workCh := make(chan matrixJob)
|
||||
resCh := make(chan *plugin.SimResult, len(work))
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < jobs; i++ {
|
||||
wg.Add(1)
|
||||
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel, party, partyClasses)
|
||||
}
|
||||
go func() {
|
||||
for _, j := range work {
|
||||
workCh <- j
|
||||
}
|
||||
close(workCh)
|
||||
}()
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resCh)
|
||||
}()
|
||||
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
for r := range resCh {
|
||||
_ = enc.Encode(r)
|
||||
}
|
||||
}
|
||||
|
||||
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap int) (*plugin.SimResult, error) {
|
||||
func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel, party int, partyClasses string) {
|
||||
defer wg.Done()
|
||||
for j := range in {
|
||||
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
|
||||
dir, err := os.MkdirTemp("", "expedition-sim-")
|
||||
if err != nil {
|
||||
out <- &plugin.SimResult{UserID: uid, Class: j.class, Level: j.level, Zone: j.zone, Outcome: "halted"}
|
||||
continue
|
||||
}
|
||||
args := []string{
|
||||
"-class", j.class,
|
||||
"-level", strconv.Itoa(j.level),
|
||||
"-zone", j.zone,
|
||||
"-bank", strconv.FormatFloat(bank, 'f', -1, 64),
|
||||
"-cap", strconv.Itoa(cap),
|
||||
"-days", strconv.Itoa(days),
|
||||
"-data", dir,
|
||||
"-user", uid,
|
||||
fmt.Sprintf("-log=%t", includeLog),
|
||||
fmt.Sprintf("-pet-level=%d", petLevel),
|
||||
fmt.Sprintf("-party=%d", party),
|
||||
}
|
||||
// Left empty, each cell's followers clone that cell's own -class.
|
||||
if partyClasses != "" {
|
||||
args = append(args, "-party-classes", partyClasses)
|
||||
}
|
||||
if trace {
|
||||
args = append(args, "-trace")
|
||||
}
|
||||
cmd := exec.Command(exe, args...)
|
||||
var stderrBuf bytes.Buffer
|
||||
cmd.Stderr = &stderrBuf
|
||||
stdout, runErr := cmd.Output()
|
||||
var res plugin.SimResult
|
||||
jerr := json.Unmarshal(stdout, &res)
|
||||
if jerr != nil {
|
||||
res = plugin.SimResult{UserID: uid, Class: j.class, Level: j.level, Zone: j.zone, Outcome: "halted"}
|
||||
}
|
||||
if runErr != nil && res.Outcome == "" {
|
||||
res.Outcome = "halted"
|
||||
}
|
||||
// Surface subprocess failures: parse error with non-empty stdout
|
||||
// (corrupted JSON) and non-zero exits both get a stderr dump so the
|
||||
// user sees the underlying cause instead of just a halted row.
|
||||
if jerr != nil || runErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "sim cell %s halted: runErr=%v jerr=%v\n", uid, runErr, jerr)
|
||||
if stderrBuf.Len() > 0 {
|
||||
fmt.Fprintf(os.Stderr, " child stderr:\n%s\n", stderrBuf.String())
|
||||
}
|
||||
if jerr != nil && len(stdout) > 0 {
|
||||
snip := stdout
|
||||
if len(snip) > 200 {
|
||||
snip = snip[:200]
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " child stdout (first 200B): %q\n", snip)
|
||||
}
|
||||
}
|
||||
if !includeLog {
|
||||
res.Log = nil
|
||||
}
|
||||
out <- &res
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
}
|
||||
|
||||
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap, days int, followers []string) (*plugin.SimResult, error) {
|
||||
runner, err := plugin.NewSimRunner(dataDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init runner: %w", err)
|
||||
@@ -143,7 +283,32 @@ func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zon
|
||||
return nil, fmt.Errorf("build character: %w", err)
|
||||
}
|
||||
runner.Euro.Credit(uid, bank, "expedition-sim bankroll")
|
||||
return runner.RunExpedition(uid, zone, cap)
|
||||
|
||||
// Followers share the leader's level — a party is not a carry (the tier
|
||||
// gate refuses an under-levelled invitee anyway) — and each buys their own
|
||||
// loadout, so each needs their own bankroll.
|
||||
var members []id.UserID
|
||||
for i, fc := range followers {
|
||||
muid := followerUserID(uid, i)
|
||||
if _, err := runner.BuildCharacter(muid, plugin.DnDClass(fc), level); err != nil {
|
||||
return nil, fmt.Errorf("build follower %d (%s): %w", i+1, fc, err)
|
||||
}
|
||||
runner.Euro.Credit(muid, bank, "expedition-sim bankroll")
|
||||
members = append(members, muid)
|
||||
}
|
||||
return runner.RunPartyExpedition(uid, members, zone, cap, days)
|
||||
}
|
||||
|
||||
// followerUserID derives a follower's Matrix id from the leader's. It must keep
|
||||
// the ":" — ResolveUser treats a colon as "this is already a full mxid" and
|
||||
// short-circuits the room-membership lookup the sim has no client for.
|
||||
func followerUserID(leader id.UserID, i int) id.UserID {
|
||||
s := string(leader)
|
||||
local, server, ok := strings.Cut(strings.TrimPrefix(s, "@"), ":")
|
||||
if !ok {
|
||||
local, server = strings.TrimPrefix(s, "@"), "sim"
|
||||
}
|
||||
return id.UserID(fmt.Sprintf("@%s-m%d:%s", local, i+1, server))
|
||||
}
|
||||
|
||||
func emitIndented(res *plugin.SimResult) {
|
||||
|
||||
@@ -129,7 +129,7 @@ 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{
|
||||
descScrub = []*regexp.Regexp{
|
||||
// Range / distance jargon.
|
||||
regexp.MustCompile(`(?i)\s*\bwithin range\b`),
|
||||
regexp.MustCompile(`(?i)\s*\bwithin \d+ feet(?: of [a-z ]+?)?`),
|
||||
|
||||
@@ -93,11 +93,11 @@ func TestCleanDescRepairsOrphans(t *testing.T) {
|
||||
// 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",
|
||||
"Giant Rat (Diseased)": "Giant Rat",
|
||||
"Deep Gnome (Svirfneblin)": "Deep Gnome",
|
||||
"Stone of Good Luck (Luckstone)": "Stone of Good Luck",
|
||||
"Ordinary Name": "Ordinary Name",
|
||||
"": "",
|
||||
"Ordinary Name": "Ordinary Name",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := stripNameParenthetical(in); got != want {
|
||||
|
||||
@@ -88,18 +88,18 @@ func must(err error) {
|
||||
// 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"`
|
||||
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
|
||||
|
||||
24
go.mod
24
go.mod
@@ -11,37 +11,39 @@ require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/olebedev/when v1.1.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/rs/zerolog v1.35.1
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
golang.org/x/image v0.40.0
|
||||
maunium.net/go/mautrix v0.28.0
|
||||
modernc.org/sqlite v1.50.1
|
||||
maunium.net/go/mautrix v0.28.1
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
|
||||
require (
|
||||
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/coder/websocket v1.8.15 // 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.44 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.45 // 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.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.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
|
||||
go.mau.fi/util v0.9.10 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
67
go.sum
67
go.sum
@@ -10,6 +10,8 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
|
||||
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/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
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=
|
||||
@@ -36,8 +38,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
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.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
|
||||
github.com/mattn/go-sqlite3 v1.14.45/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=
|
||||
@@ -73,18 +75,18 @@ 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.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE=
|
||||
go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY=
|
||||
go.mau.fi/util v0.9.10 h1:wzvz5iDHyqDXB8vgisD4d3SzucLXNM3iNY+1O1RoHtg=
|
||||
go.mau.fi/util v0.9.10/go.mod h1:YQOxySn+ZE3qSYqNxvyX7Yi3suA8YK17PS6QqBREW7A=
|
||||
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.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/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
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=
|
||||
@@ -92,8 +94,8 @@ 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.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
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=
|
||||
@@ -103,8 +105,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.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/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
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=
|
||||
@@ -112,8 +114,8 @@ 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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.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=
|
||||
@@ -126,8 +128,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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.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=
|
||||
@@ -146,37 +148,38 @@ 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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
|
||||
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
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.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=
|
||||
maunium.net/go/mautrix v0.28.1 h1:Hic3oDMPbLbQu1fhboTRAKZcORMjzzkjxsa+SGk60b0=
|
||||
maunium.net/go/mautrix v0.28.1/go.mod h1:mWXQNmOlrq4VTDU9f1HO03BSIswdUIyyY4wUKHqwzzY=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
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/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/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/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
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=
|
||||
@@ -185,8 +188,8 @@ 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.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
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=
|
||||
|
||||
280
gogobee_long_expedition_plan.md
Normal file
280
gogobee_long_expedition_plan.md
Normal file
@@ -0,0 +1,280 @@
|
||||
# Long Expeditions — multi-session plan
|
||||
|
||||
> Goal: dungeon expeditions become real multi-day journeys (T1 ≈ 2 days → T5 ≈ 7 days). Player picks **camp supplies** at launch, then watches it play out. Camp pitch/break, elite engagement, and **boss engagement** all become autonomous. Foreground `!camp` / `!fight` survive as overrides, not the expected path.
|
||||
|
||||
## Status @ 2026-05-28
|
||||
|
||||
**Core track (D1–D7) — DONE.** All phases shipped; v1 of the long-expedition mechanics is live on `long-expeditions-d1`.
|
||||
|
||||
**§6 decisions (2026-05-28) re-opened two threads** that ride after D8:
|
||||
- **D9** — T1–T3 room-count bump toward the 3–5× target band (D1 landed T1–T3 at ~2×). Sim-first: read T1/T2 day-counts from a fresh corpus *after* D8 lands.
|
||||
- **D10** — T4/T5 anchor-room variety (2nd elite + 2nd trap mid-zone). Bundle with D9 to avoid touching graphs twice.
|
||||
|
||||
**Active work — §8 (J3 caster sim-picker):** D8-a / D8-b / D8-prereq / D8-d-diagnostic shipped; **D8-c (concentration), D8-d-fix (AC floor lift), D8-e (martial regression triage)** queued. D8 is sequentially the gate before D9/D10 — room counts only make sense once the sim accurately scores the classes that will walk through them.
|
||||
|
||||
**Sequence:** D8-c → D8-d-fix → D8-e → D9 → D10. Re-baseline once at the end.
|
||||
|
||||
## 1. Why the current shape is "too short"
|
||||
|
||||
| Tier | Zones | Rooms | Real-time exit |
|
||||
|------|-------|------|----------------|
|
||||
| T1 | Goblin Warrens, Crypt Valdris | 6–7 | usually <1 calendar day |
|
||||
| T2 | Forest Shadows, Sunken Temple | 7–8 | usually 1 day |
|
||||
| T3 | Manor Blackspire, Underforge | 7–9 | 1–2 days |
|
||||
| T4 | Underdark, Feywild Crossing | 8–10 (×regions) | 2–3 days |
|
||||
| T5 | Dragon's Lair, Abyss Portal | 9–10 (×regions) | 2–4 days |
|
||||
|
||||
(Room counts from `internal/plugin/dnd_zone.go:70-134`; tier zones from memory `project_expedition_difficulty`.)
|
||||
|
||||
Because the autopilot walks ~3 rooms / 2h (`expedition_autorun.go:43,53`) and boss/elite stops are manual, a player who pays attention clears T1–T3 in an evening. Briefing/recap UTC ticks fire but rarely matter — the expedition is over before "day 2" ever lands.
|
||||
|
||||
## 2. Target shape
|
||||
|
||||
| Tier | Target duration | New room budget (single-region) | Notes |
|
||||
|------|-----------------|---------------------------------|-------|
|
||||
| T1 | 2 days | 12–14 | one autopilot-camp |
|
||||
| T2 | 3 days | 16–20 | two camps |
|
||||
| T3 | 4 days | 22–26 | three camps; threat starts to bite |
|
||||
| T4 | 5–6 days | 28–34 (split across 3–4 regions) | base camp emerges |
|
||||
| T5 | 7 days | 36–44 (split across 3–4 regions) | full siege/temporal pressure |
|
||||
|
||||
(Numbers are first-draft anchors; class-balance sim drives the actual dial in D7.)
|
||||
|
||||
Duration is measured in **expedition-days advanced by autopilot camp**, not real-time UTC days. Each autopilot night-camp = day++. Real-time UTC still drives the morning briefing DM cadence, but day-count and supply burn become event-driven (camp = sleep = day++ = burn).
|
||||
|
||||
The autopilot decides:
|
||||
- when to camp (and which camp type)
|
||||
- whether to engage elites and bosses, or to camp first and retry
|
||||
- whether to extract early on starvation/HP collapse
|
||||
|
||||
The player decides:
|
||||
- supply pack purchases at launch (only meaningful pre-expedition choice)
|
||||
- whether to override (`!camp <kind>`, `!fight`, `!expedition extract`)
|
||||
|
||||
## 3. Phasing
|
||||
|
||||
Built across multiple sessions; each phase ships independently with tests and a small DM-side feedback loop.
|
||||
|
||||
### D1 — Length + room-count rework
|
||||
**Files:** `dnd_zone.go` (per-zone Min/MaxRooms), `dnd_expedition_region.go` (multi-region region sizes), `dnd_zone_run.go:generateRoomSequence` (still terminates Entry → … → Elite → Boss, but with more Explorations).
|
||||
**Work:**
|
||||
- Re-pitch Min/MaxRooms per zone to the table in §2.
|
||||
- Multi-region zones: extend per-region room budgets so the total stays in band when summed.
|
||||
- Keep the fixed Entry / Trap / Elite / Boss anchors; only Exploration count scales.
|
||||
- Consider 2 traps + 2 elites for T4–T5 to break up the long middle (deferred decision — see §6).
|
||||
- Update existing zone graphs in `zone_graph_*.go` for the new lengths. *Auditing whether each zone has a hand-tuned graph or relies on the linear compiler is part of D1 scoping.*
|
||||
|
||||
**Exit criteria:** `expedition-sim` shows mean-room-cleared per zone in the new band; no test fixtures hard-code old counts.
|
||||
|
||||
### D2 — Autopilot camp scheduler
|
||||
|
||||
**D2-a (shipped 2026-05-27):** new `expedition_autocamp.go` with pure `decideAutopilotCamp` + `pitchAutopilotCamp` + dwell-window lifecycle (`shouldSkipAutoRunForCamp`, `breakAutoCampIfDue`). Wired into `tryAutoRun`: skip while inside `minAutoCampDwell` (4h), break + walk past it, post-walk scheduler pitches HP-low rest camps and region-boss base-camp waypoints. CampState gained `AutoPitched` so auto- vs player-pitched lifetimes diverge. Day rollover is still UTC-anchored; D2-b moves it event-anchored.
|
||||
|
||||
**D2-b (shipped 2026-05-27):** event-anchored day rollover. `dnd_expedition_cycle.go` adds `eventAnchoredCutoff` + `isEventAnchored`, splits the briefing body into `nightRolloverBurn` + `nightRolloverDrift` (+ a convenience `processNightCamp` that runs both back-to-back). For expeditions started ≥ cutoff, `deliverBriefing` skips mutators and posts a re-engagement DM; if `last_briefing_at` is older than `nightSafetyNet` (28h) it force-fires `processNightCamp` so a stalled autopilot doesn't freeze the expedition. The autopilot scheduler grew a `Night` flag — `decideAutopilotCamp` sets it when `time.Since(LastBriefingAt) ≥ nightCampWindow` (16h); `pitchAutopilotCamp` then runs the burn → camp → rest → drift sequence in one pitch. Manual `!camp <type>` runs the same flow when it's the first camp since the last rollover. Legacy UTC-anchored expeditions (start_date < cutoff) keep the original mutator flow via the same staged helpers, preserving rest-before-drift ordering through `processOvernightCamp`. Tests fence cutoff to year 9999 in `TestMain` so existing legacy assertions stand; new tests exercise the night decision, night-pitch rollover, event-anchored skip, and safety-net force.
|
||||
|
||||
|
||||
**Files:** new `expedition_autocamp.go`; hooks in `expedition_autorun.go:tryAutoRun`; reuse `dnd_expedition_camp.go:campPitch` / `applyCampRest`.
|
||||
**Heuristics (in priority order):**
|
||||
1. **Day-budget pacing** — pitch when rooms-walked-this-day ≥ tier-specific day-room target. Standard if room cleared, rough if not.
|
||||
2. **Resource gates** — pitch if HP < 35% of max, or supplies projected to cover <1 more day's burn (`exp.Supplies.Current < exp.Supplies.DailyBurn * harshMod`).
|
||||
3. **Post-elite breath** — auto-pitch a standard camp in the cleared elite room.
|
||||
4. **Region boss cleared in multi-region zone** — auto-pitch base camp at first eligible site.
|
||||
5. **Don't pitch when** mid-fight, in a trap/boss room, in a fork-pending state, or already camped.
|
||||
|
||||
Auto-break already happens on move (`autoBreakCampOnMove` in `dnd_expedition_camp.go:401`). Autopilot just needs to *not* break a freshly-pitched camp by immediately walking on — gate by `time.Since(camp.EstablishedAt) > minCampDwell` (15min real-time? or simply: don't auto-walk while camped).
|
||||
|
||||
**Day-rollover semantics change (decided event-anchored):** today, day++ happens at the 06:00 UTC briefing (`dnd_expedition_cycle.go:236`). After D2, the autopilot **night-camp pitch** is the event that fires day++, supply burn, overnight rest, and threat drift — in one atomic rollover the camp scheduler triggers. The 06:00 UTC briefing tick survives only as a re-engagement DM anchor (and a safety net: if for some reason no camp pitched in N hours, force one). Concretely:
|
||||
- Move the body of `deliverBriefing` (`dnd_expedition_cycle.go:186-`) into a `processNightCamp(exp)` helper called from the autopilot camp scheduler when it pitches a *night* camp (rough/standard/fortified — not mid-day breath stops).
|
||||
- Distinguish "night camp" (advances day) from "rest stop" (doesn't). Heuristic: a camp pitched at the end of the day-budget room count is night; one pitched purely for HP/SU recovery mid-day is a rest stop. Both apply `applyCampRest`; only night also runs supply burn + day++ + threat drift.
|
||||
- The existing UTC briefing ticker becomes: if `last_briefing_at` is older than ~20h *and* the user is active, post a "Day N — here's where you stand" re-engagement DM. It calls **no** mutators.
|
||||
- Manual `!camp <type>` from the player still does what it does today (apply rest immediately) but **also** counts as a night camp if invoked with no other camp today — keep parity with the autopilot behavior so the override doesn't accidentally stall day advancement.
|
||||
|
||||
**Migration:** in-flight expeditions at deploy time keep their existing 06:00 UTC day++ until they end (fence by `start_date`). New expeditions use the event-anchored model.
|
||||
|
||||
**Exit criteria:** sim shows autopilot pitches the right kind of camp at the right room with no player input; supply economy still survives a 7-day T5.
|
||||
|
||||
### D3 — Autonomous elite + boss engagement
|
||||
**Files:** `dnd_zone_cmd.go:497-535` (the prev==RoomElite / RoomBoss branch).
|
||||
|
||||
**Shipped 2026-05-27.** `dnd_zone_cmd.go` adds `stopBossSafety` + `bossSafetyGate(uid, exp)` (HP < 80%, supplies < daily burn, exhaustion ≥ 3 — the "active low status" interpretation). The elite/boss doorway branch drops the `prev == RoomBoss || !compact` carve-out: in compact mode bosses fall through to the same `resolveCombatRoom` path elites have used, after the gate clears. `resolveCombatRoom` selects monster + label + loot-drop by `run.CurrentRoomType()` so the same call site handles boss kills (zone.Boss bestiary, "👑 Boss — name down", boss-loot drop, elite-tier threat bump). Loss on auto-resolved boss falls through to the existing player-death narration / forceExtractExpeditionForRunLoss path — no special treatment.
|
||||
|
||||
The walk loop (`dnd_expedition_cmd.go:717`) only breaks at the elite/boss doorway when `!compact`; compact lets the next iteration auto-resolve. `stopBossSafety` is excluded from the rooms-walked tally and its `autopilotFooter` returns "" — `res.final` already carries the held-back line.
|
||||
|
||||
When the gate trips, `tryAutoRun` calls `pitchBossSafetyCamp(exp)` (new in `expedition_autocamp.go`): force-pitches Standard (or Rough fallback) regardless of `decideAutopilotCamp`'s HP threshold or its RoomBoss room-type block, with the same `Night` event-anchored rollover handling. Dwell expires → next tick retries the boss. Foreground `!fight` and foreground `!expedition run` are unchanged: both still stop at the boss doorway for the player who wants to watch. Sim path is unaffected — `stopBossSafety` falls into `expedition_sim.go`'s default branch (tick-a-day, retry next walk).
|
||||
|
||||
**Open question:** do we want a "set autopilot off" toggle for players who actively want to play the boss themselves? My take: default-on, single-flag per expedition (`autopilot_engage`) settable at start or via `!expedition autopilot off`. Defer until D3 lands and we see how it reads.
|
||||
|
||||
**Exit criteria:** sim runs a full T5 expedition launch → boss kill with zero player commands besides `!expedition start`.
|
||||
|
||||
### D4 — DM volume + day surfaces
|
||||
The big risk: a 7-day T5 with autopilot walking, camping, fighting elites, and killing bosses will spam DMs unless we bundle. The user has already pushed back on recap chatter (`feedback_skip_recaps`).
|
||||
|
||||
**D4-a (shipped 2026-05-27):** autopilot DM bundling. `expedition_autorun.go:tryAutoRun` now drops per-tick auto-walk DMs in compact mode. New surface rules:
|
||||
- Fork / death / run-complete still DM the walk stream — interactive + climax beats.
|
||||
- A Night=true camp pitch flushes the day as an end-of-day digest (`renderEndOfDayDigest` in new `expedition_autorun_digest.go`) followed by the camp block.
|
||||
- Boss-safety camp pitch gets a short "holding before the boss" header + camp block (walk stream dropped).
|
||||
- Non-night auto-camp (mid-day rest / base-camp waypoint) surfaces the camp block by itself.
|
||||
- Everything else — uneventful walks, preflight pauses, harvest interrupts — goes silent.
|
||||
Each successful background walk now logs a `walk` entry (`appendExpeditionLog(... "walk" ...)`) so the digest can count rooms walked from structured logs (the raw `r.stream` narration is no longer persisted across ticks). Digest groups counts of walk/harvest/interrupt and inlines threat/milestone/narrative lines for the prior day (`dayExpeditionLog` helper). `maybeAutoCamp` + `pitchBossSafetyCamp` now return the `autoCampDecision` so `tryAutoRun` can branch on `dec.Night`.
|
||||
|
||||
**D4-b (shipped 2026-05-27):** morning re-engagement DM anchored to user activity, not 06:00 UTC. `fireExpeditionBriefings` now skips event-anchored expeditions whose `last_activity` is older than today's 06:00 UTC threshold (and we're still inside `nightSafetyNet` — stalled-autopilot force-fires still win). New `maybeDeliverDeferredBriefing` runs at the top of `AdventurePlugin.OnMessage` on every inbound message in any room: it stamps `last_activity` (so the next ticker pass sees the player as present, even from non-bot chatter) and posts the deferred briefing if one is owed (CAS-gated by today's 06:00 threshold; pre-06:00 lazy fires are suppressed to avoid double-emit with the ticker sweep that follows). Day-1 inbounds don't trigger a briefing before the first night camp has happened.
|
||||
|
||||
**Remaining work:**
|
||||
- TwinBee voice + no-recap copy pass on the digest once the shape settles.
|
||||
|
||||
**Exit criteria:** a 7-day T5 produces ≤ 10 DMs across the whole expedition (1 launch + 6 end-of-day + 1 boss + 1 run-complete + 1 emergency).
|
||||
|
||||
### D5 — Supplies economics retune
|
||||
|
||||
**D5-a (shipped 2026-05-27):** per-tier pack caps. `dnd_expedition_supplies.go` retires the global `SupplyPackStandardMax`/`SupplyPackDeluxeMax` constants in favor of `supplyPackCaps(tier) (std, dlx int)` — T1/T2: (2,1); T3: (3,1) unchanged; T4: (5,1); T5: (7,2). `SupplyPurchase.Validate` is now `Validate(tier ZoneTier)`; both call sites (`!expedition start`, `!resume`) pass the resolved zone's tier. The cap clears `DailyBurn(raw) × intendedDays × 1.3` for every tier under the §2 target durations even with harsh×3 layered on top, so a player who wants to buy for the long shape can. DailyBurn / harsh-multiplier / `phase5BDailyBurnRatePct` are unchanged here — they're a D7 lever, alongside the sim work that should unblock empirical baselining. The help text now describes the cap as "scales by zone tier"; the holiday +1 standard pack still bypasses the cap on purpose (it's a freebie on top).
|
||||
|
||||
> **Note:** the original "Empirical (sim-driven)" path was blocked: under D2-b event-anchored expeditions, `SimRunner.TickDay` calls `deliverBriefing` → `deliverBriefingEventAnchored`, which reads `time.Now().UTC()` for its safety-net check, so synthetic ticks never advance `CurrentDay` and `DaysAtEnd` stays at 0. Re-baselining off real day-counts requires teaching the sim to drive the event-anchored rollover (D7).
|
||||
|
||||
**D5-b (shipped 2026-05-27):** "Pick your loadout" preset prompt at launch. `!expedition start <zone>` with no pack arg now DMs a Lean / Balanced / Heavy menu (sized per zone tier via new `loadoutPurchase(tier, SupplyLoadout)` in `dnd_expedition_supplies.go`); player replies with `!expedition start <zone> lean|balanced|heavy` (short forms `l`/`b`/`h` also work). `!resume` got the same treatment. Raw `Ns Md` syntax remains the advanced override — `resolveLoadoutOrParse` in `dnd_expedition_cmd.go` tries a single-token preset first, falls back to `parseSupplyArgs`. Heavy always equals `supplyPackCaps` (the D5-a harsh×3 ceiling); Lean covers intended days at raw burn; Balanced sits between. `parseSupplyArgs("")` still returns 1 standard for any tier — it's no longer reachable from `!expedition start` (the empty path is intercepted to prompt), but the `1s` default is preserved so future callers don't get surprise zero-pack purchases. Help text updated; two existing scenario tests now pass `lean` explicitly, three new tests cover preset resolution + the prompt-vs-start guard.
|
||||
|
||||
**D5-c (shipped 2026-05-27):** Ranger forage wired against the new caps. Audit found `SupplyForageMaxSU = 4` defined in `dnd_expedition_supplies.go` since Phase 12 E1b but never referenced — `ForagedToday` was only ever reset to `false`, never set to `true`, so the §4.2 "Ranger, WIS DC 12, 1d4 SU/day" perk had never actually granted supplies. New helper `applyRangerForage(e, c, rng)` rolls 1d4 SU once per day for Ranger characters, headroom-capped against `Supplies.Max` so a Heavy loadout doesn't overflow its purchased ceiling; non-Rangers and post-roll repeats are no-ops. Fires at the top of `nightRolloverBurn` (before the daily burn) so the +SU lands on today's bag and a "forage" log entry flows into the end-of-day digest via a new `renderEndOfDayDigest` case. No DC roll — accessibility over crunch (`feedback_accessibility_over_dnd_crunch`); the DC-12 gate would've added a fail case with no upside given the new headroom. Average +2.5 SU/day off a Ranger is a quiet Lean-loadout cushion (~1 extra day of T5 late-stage burn over a 7-day run), not a Heavy multiplier. Tests cover ranger-grants, non-ranger no-op, repeat-day no-op, headroom-cap, full-bag-still-stamps-flag, nil-safety.
|
||||
|
||||
**Remaining work (D5-d+):**
|
||||
- DailyBurn / `phase5BDailyBurnRatePct` revisit once D7 sim measures real elapsed-day counts.
|
||||
|
||||
**Exit criteria:** balanced loadout completes intended-duration expedition in ≥ 85% of sim runs without starvation extraction.
|
||||
|
||||
### D6 — Player-facing surface cleanup
|
||||
|
||||
**D6 (shipped 2026-05-27):** player-facing copy now matches the autopilot-first reality.
|
||||
- `expeditionHelpText` rewritten around the loop "pick a zone → pick a loadout → `!expedition run` watches it play out"; commands grouped into **Run an expedition** / **Mid-expedition** / **Overrides** with a one-line shape paragraph at the top.
|
||||
- `!camp` and `!fight` were already absent from the primary `!expedition` help; both are now explicitly listed under **Overrides** and framed as "autopilot covers these — only reach for them if you want manual control." `campHelpText` itself opens with the same override framing so a player who types `!camp` lands on the right mental model.
|
||||
- `expeditionCmdStatusImpl` (default `!expedition status`) now leads with **Day X / ~Y expected** (driven by new `expeditionTargetDays(tier ZoneTier)` in `dnd_expedition_supplies.go` — T1=2 → T5=7, the §2 anchor table), adds a **Rooms: N / Total in this region** line under the region marker (sourced from `getActiveZoneRun(...).CurrentRoom/TotalRooms`), and appends a **Recent:** block with the last 3 log entries (summary fallback to flavor). Supplies and threat lines unchanged; the `--debug` view is untouched.
|
||||
- No new public command surface, no schema change, no test churn (no existing test asserted on the rewritten strings); full `go test ./...` green.
|
||||
|
||||
### D7 — Sim + class re-baseline
|
||||
|
||||
**D7-a (shipped 2026-05-27):** `SimRunner.TickDay` now drives event-anchored expeditions. `expedition_sim.go` short-circuits when `isEventAnchored(exp)` is true: instead of calling `deliverBriefing` (whose `deliverBriefingEventAnchored` branch reads `time.Now().UTC()` and would never trip the safety-net under synthetic ticks), it runs `nightRolloverBurn` → optional `applyCampRest(Standard)` if affordable (Rough fallback, skipped on low-SU) → `nightRolloverDrift(briefAt)`. Mirrors `pitchAutopilotCamp` with `Night=true`. Production paths untouched. New `expedition_sim_test.go` covers (i) 3 ticks advancing day 1→4 with supply burn + stamped `LastBriefingAt`, and (ii) the low-SU branch where the rest is skipped but burn/drift still fire. Unblocks D5-d empirical re-baseline of `phase5BDailyBurnRatePct` and the class corpus re-run.
|
||||
|
||||
**D7-b (shipped 2026-05-27):** `SimRunner.RunExpedition` now drives the production camp scheduler under a synthetic clock (`simWalkInterval = autoRunCooldown`, 2h per walk). After every soft-stop walk it calls `maybeAutoCamp(exp, simNow)`; `stopBossSafety` routes to `pitchBossSafetyCamp(exp, simNow)`. The helpers (`applyAutoCamp` / `applyAutoCampBossSafety`) advance `simNow` past `minAutoCampDwell` (4h) and break the auto-pitched camp via `breakAutoCampIfDue` so the next walk proceeds. `maybeAutoCamp`, `pitchAutopilotCamp`, and `pitchBossSafetyCamp` gained a `now time.Time` parameter (the only prod caller, `tryAutoRun`, still passes `time.Now().UTC()`). Effect: HP-low mid-day rests, multi-region base-camp waypoints, Night-camp rollovers, and boss-safety holds all fire under the sim — the D7-a `tickEventAnchoredRollover` shortcut is no longer used by RunExpedition (kept on TickDay for tests + the pre-cutoff legacy path). Coverage in `expedition_sim_test.go`: Night-camp day advance, HP-low mid-day rest, boss-safety force-pitch, no-op when neither trigger fires.
|
||||
|
||||
**D7-c (shipped 2026-05-27):** `cmd/expedition-sim` gained a `-days N` flag — when set, `RunExpedition` exits with `Outcome="day_capped"` after the Nth synthetic day rollover; 0 (default) is unbounded and only the existing `-cap` walk safety net applies. `SimResult` gained `DaySnapshots []SimDaySnapshot` ({Day, HPCurrent, HPMax, Supplies, Threat, Rooms}), captured at expedition start (Day 0), after every `applyAutoCamp`/`applyAutoCampBossSafety` Night-camp rollover, and one final end-of-run entry (reads `getExpedition(mostRecentExpeditionID(...))` when the row already extracted so closed runs still close their trajectory). `RunExpedition` signature picked up the `maxDays int` parameter; sole caller is the CLI. Coverage: `TestSimRunner_CaptureDaySnapshot_PopulatesFields` exercises the helper in isolation (HP from the live character row, SU/threat/day from the live expedition, Rooms from `res.Rooms`). Unblocks empirical D5-d retune of `phase5BDailyBurnRatePct` against per-day SU draws.
|
||||
|
||||
**D7-d (shipped 2026-05-27):** class corpus re-run + D5-d retune decision. First fixed a sim regression `expedition_sim.go` introduced by D5-b — bare `expedition start <zone>` now returns the loadout prompt instead of outfitting, so the sim was halting before persisting any expedition; harness now passes the `heavy` preset. Corpus: `sim_results/d7d_corpus.jsonl` (n=100 × 10 classes × 5 zones × L10 = 5000 runs, `heavy` preset, `-cap 300`). Analysis: `sim_results/d7d_analyze.py`. Writeup: `sim_results/d7d_findings.md`. **Key reads:** (i) leaderboard mirrors [[project_j2_sim_artifact]] — martials 78–82%, casters 21–42%, ~40pp cluster gap unchanged by long-expedition mechanics; (ii) bard/cleric trailers **not** relieved by autopilot camp pacing — cleric actually regressed at L10 (21% vs J2b L12 39%) on spell-pool richness, J3-territory; (iii) T3 hits target 4-day duration (median 3), T4 hits 5–6 days (median 5), T5 hits 5 of 7 (only 21 clears); (iv) **D5-d retune: no change.** `phase5BDailyBurnRatePct=50` + per-tier `DailyBurn` stay as-is — heavy-preset cleared runs end with 78–98% SU remaining; supply economy is not a binding constraint and players lose to HP zero, not starvation. New memory entry: [[project_d7d_baseline]].
|
||||
|
||||
**Remaining work (D7-e+):** none in the core D7 scope. Two follow-up phases (D9 room-count bump, D10 T4/T5 anchor variety) were opened by the 2026-05-28 §6 resolution and are sequenced after D8. See §9 below.
|
||||
|
||||
## 8. J3 caster-picker rework (next session)
|
||||
|
||||
**Why split out:** D7-d corpus confirmed the martial/caster gap is class-roster + sim-picker driven, not autopilot/burn/pacing driven. Long-expedition mechanics are settled; the trailer fix lives in the sim picker, not the expedition shell.
|
||||
|
||||
**D8-a (shipped 2026-05-27, this session):** quick data-layer cleanup. Bard L1 prepared list gained `thunderwave`; L2 gained `heat_metal`. Cleric L1 gained `inflict_wounds`. `shatter` overlay Classes broadened to mage/bard/sorcerer (overlay was already unioning via `mergeClassList`, so this is defensive). New `simPickSpiritualWeapon` runs before `simPickSpell` in `simPickCombatAction`: cleric with an L2 slot + spiritual_weapon prepared + `sess.Statuses.BuffSpiritProc == 0` opens the fight with it, picking up the existing `spiritWeaponStrike` per-round bonus-action attack. **Measured impact (L10, n=100/zone, heavy preset):** cleric 21.0 → 23.2% (+2.2pp), bard 34.2 → 32.8% (within noise). T3+ wall unchanged — the data fixes are correct but the picker math is what's binding. Files touched: `internal/plugin/dnd_spells_data.go:192`, `internal/plugin/dnd_spells.go:822,881,884`, `internal/plugin/expedition_sim.go:simPickCombatAction + new simPickSpiritualWeapon`. All green.
|
||||
|
||||
**D8-b (implemented 2026-05-27, **inert** — see D8-prereq below): sim picker upcasting.** `simPickSpell` (`internal/plugin/expedition_sim.go:933`) now enumerates one candidate per available slot ≥ native for every prepared damage spell, scored via `spellExpectedDamage(sp, slotLevel, c.Level)`. Cantrips contribute a single slot-0 candidate. Tie-break: highest slot first, then highest expDmg. When the winning slot exceeds the spell's native level, the picker returns `"<id> --upcast N"` so `parseCombatCast` upcasts in the engine. Build + tests green.
|
||||
|
||||
**Measured impact: nothing.** d8b corpus (`sim_results/d8b_corpus.jsonl`, same matrix as d7d) lands every class within ±1.5pp of the d7d baseline — bard −1.0, cleric −0.4, mage −1.2, sorcerer +1.4, warlock −0.8, druid +0.6, martials ±0.2. Pure noise band. Expected bard +5–10pp / mage+sorcerer+warlock +2–8pp never materialized.
|
||||
|
||||
**Root cause (discovered post-corpus, this session):** `simPickSpell` is **dead code** in the current sim path. Commit `68ed8e7` ("Long expeditions D3: compact autopilot auto-resolves boss rooms") added a `!compact` gate at `dnd_expedition_cmd.go:810` so compact autopilot inline-resolves boss/elite rooms via `resolveCombatRoom` → `runZoneCombat` → `SimulateCombat` instead of returning `stopBoss/stopElite` for `autoResolveCombat` to handle. The sim uses `compact=true` (`expedition_sim.go:433`), so `autoResolveCombat` — the only caller of `simPickCombatAction`/`simPickSpell` — never fires. Verified empirically: a stderr-traced `simPickSpell` produced zero hits across full bard/cleric L10 runs.
|
||||
|
||||
This rewrites the picker-era retrospective. **J2's `baseline_j2a_v2_all10.jsonl` (bard 40%, cleric 39%) was measured before D3, with the picker live.** d7d's L10 baseline (bard 34%, cleric 21%) was measured *after* D3, with the picker disconnected — that ~6–18pp drop is the post-D3 caster regression, not "long-expedition mechanics didn't help casters" as the d7d writeup framed it. D8-a's "+2.2pp cleric" was also noise (1σ ≈ 1.8pp on n=500).
|
||||
|
||||
**D8-prereq (SHIPPED 2026-05-28, commit `631764b`):** split the `compact` flag in `runAutopilotWalk` / `advanceOnceWithOpts` into `compact` + `inlineBossCombat`. Production background autorun (`expedition_autorun.go:173`) passes `(true, true)` — unchanged inline auto-resolve. Sim (`expedition_sim.go:432`) passes `(true, false)` so boss/elite doorways return `stopBoss`/`stopElite` after the safety gate; `autoResolveCombat` → `simPickCombatAction` → `simPickSpell` / `simPickSpiritualWeapon` now drive boss/elite fights through the turn-based `!fight` engine. D8-b upcasting went live with the rewire. Foreground (`!expedition run`) flow unchanged (compact=false short-circuits).
|
||||
|
||||
Also parallelized matrix mode via subprocess workers (`cmd/expedition-sim/main.go`, `-jobs` flag; defaults to NumCPU). Each worker is its own process so the plugin's global SQLite handle isn't a contention point.
|
||||
|
||||
**Measured impact (5000-run L10 corpus, `sim_results/d8prereq_corpus.jsonl` — full diff in `sim_results/d8prereq_findings.md`):**
|
||||
|
||||
| Class | d7d | d8prereq | Δ |
|
||||
|----------|-----:|---------:|-------:|
|
||||
| cleric | 21.0 | 46.8 | +25.8 |
|
||||
| sorcerer | 29.4 | 52.0 | +22.6 |
|
||||
| warlock | 35.4 | 55.6 | +20.2 |
|
||||
| mage | 36.4 | 54.2 | +17.8 |
|
||||
| druid | 42.2 | 56.6 | +14.4 |
|
||||
| bard | 34.2 | 46.2 | +12.0 |
|
||||
| (martials) | ~80 | ~78 | -2 to -4 (noise + T4/T5 turn-engine regression) |
|
||||
|
||||
Caster/martial gap closed from ~40pp to ~22pp. T2/T3 zones are now the picker payoff (forest_shadows 75 → 99, manor_blackspire 45 → 75). **T4 caster wall persists** (every caster 0% at underdark); **T5 is universally 0%** including martials. Both require separate diagnostics — picker isn't the binding constraint past T3.
|
||||
|
||||
**D8-c (SHIPPED 2026-05-28):** concentration-damage multiplier. `spellExpectedDamage` (`internal/plugin/dnd_class_balance.go:280`) multiplies expected damage by 3 when `sp.Concentration && sp.Effect ∈ {EffectDamageSave, EffectDamageAuto}`. `EffectDamageAttack` excluded by design — single-target attack-roll spells aren't typically concentration; hex-style cases get their lift from mods. Test: `TestSpellExpectedDamage_ConcentrationMultiplier`.
|
||||
|
||||
**Measured impact (d8c_corpus.jsonl vs d8prereq, full diff in `sim_results/d8c_findings.md`):** every class within ±2.4pp on the leaderboard (1σ ≈ 2.2pp on n=500) — *macro-leaderboard unchanged*. Picker swap landed where expected at **T3 manor_blackspire**: bard +11pp (32→43), mage +10pp (71→81), sorcerer +9pp (60→69), druid +5pp manor + +1pp underdark. T4/T5 caster wall unchanged (still 0% for every caster except druid 0→1 underdark) — confirms [[project_d8d_diagnostic]]: past T3 the binding constraint is HP+AC, not picker quality.
|
||||
|
||||
**Side discovery (engine modelling gap):** cleric stayed flat across all zones despite having spirit_guardians (L3, concentration, save) prepared — and warlock dropped 6pp at manor. Likely the sim's `EffectDamageSave` path resolves the AOE save *once* rather than re-ticking per round while concentration holds, so the multiplier overrates spells the engine then under-delivers on. **Filed as a separate follow-up** (concentration re-tick gap in `SimulateCombat`/turn-engine); does *not* unwind D8-c — bard/mage/sorcerer/druid picker swaps are clean and within plan. Cross-link [[project_concentration_retick_gap]] when written.
|
||||
|
||||
**D8-d (DIAGNOSTIC SHIPPED 2026-05-28):** T4 caster wall diagnosed; no code change landed. Writeup: `sim_results/d8d_findings.md`. **Hypothesis disambiguation across cleric/bard/mage/sorcerer/warlock/druid L10 underdark (n=3–5 each):** (a) multi-region transit damage — REJECTED. All casters TPK in r1 (`underdark_surface_tunnels`) before any cross-region transit; `Combats=0` (the elite/boss session table is empty); deaths are entirely inline `SimulateCombat` mob rooms. (b) T4 attack-bonus vs caster AC — confirmed contributor. `computeAC` floor gives casters AC 11–14 vs martial 16–17; T4 standard roster (Atk +5 to +7) hits ~60–65%. (c) HP scaling — confirmed contributor. Caster HPMax 93–110 vs martial 141 (~30% gap). (d) Heal-stock — minor lever, NOT the lever. Direct experiment: bumped `simConsumableBundle` T4 from 2 → 5 Spirit Tonics, re-ran n=5 per class. Median rooms-before-TPK lifted +3–5 but 0/5 clears across every caster. Reverted. **Recommended tuning lever (next session, NOT shipped here):** lift `computeAC` class floors (cleric/druid 3 → 5, bard/warlock 1 → 3, mage/sorcerer 0 → 2) — single function, easy to revert, doesn't move the martial leaders. Validate against `d8prereq_corpus.jsonl`. Hit-dice rescale is the second option but is bigger surface area.
|
||||
|
||||
**D8-e (TODO next session): martial T4/T5 regression triage.** Paladin -18pp T4 / fighter -6pp T4 -5pp T5 / ranger -13pp T5 — the cost of moving boss/elite fights from inline `SimulateCombat` to the turn-based `!fight` engine. Two possibilities: (1) turn-engine plays martials slightly worse than inline-sim (action economy / multiattack handling differs — see [[project_sim_multiattack_gap]]); (2) inline-sim was over-rosy and the new numbers are the real ones. Read: trace one paladin underdark run pre/post and diff damage exchanges. If turn-engine is correct, prod martial numbers were inflated and we should not "fix" the regression.
|
||||
|
||||
**Sequence:** D8-c → D8-d → D8-e. D8-c is the smallest lever and confirms the picker model. D8-d is the highest-value (caster T4 is the next gap to close). D8-e is informational — settles whether to trust the new numbers.
|
||||
|
||||
**Open questions for D8:**
|
||||
1. ~~D8-b upcast aggressively or conservatively?~~ **Resolved:** aggressive — the T3 lift (mage +61, warlock +65, druid +68) confirms the model.
|
||||
2. D8-c concentration-rounds factor: 3 fixed, or scale with tier (longer fights at T4/T5)? Fixed is simpler; tier-scaled is more correct.
|
||||
3. ~~Cleric T3+ cliff~~ — **partial resolution:** picker rewire took cleric T3 0% → 39%. Cleric T4 is still 0% (and so is every other caster); now subsumed by D8-d.
|
||||
|
||||
## 4. Cross-cutting risks
|
||||
|
||||
- **Existing in-flight expeditions** at deploy time. Either fence by `start_date` (old expeditions finish under old rules) or write a one-shot migration that retunes their room counts. Lean: fence — these are typically <2 days.
|
||||
- **The 24h zone-run inactivity timeout** (`dnd_zone_run.go:254`) — must be raised, or the autopilot must self-poke `last_action_at` per tick. With multi-day expeditions, a quiet stretch overnight is now expected.
|
||||
- **Idle reaper** (commit `0f72484`, see `expedition_autorun.go` references) — already holds streak on autopilot; verify it tolerates 5-day expeditions.
|
||||
- **Babysit perk** — `BabysitSafeRest` already upgrades Standard → Fortified at rest (`dnd_expedition_camp.go:241-245`); confirm interaction with autopilot picking Standard (no change needed; the upgrade fires inside `applyCampRest`).
|
||||
- **Multi-region travel still unwired in autopilot** (memory `project_multiregion_travel_unwired`). For T4/T5 to actually take 5–7 days, autopilot **must** auto-advance regions on region-clear. D2/D3 covers this implicitly but call it out — likely a small dedicated commit.
|
||||
- **Pet arrival** (memory `project_pet_event_reachability`) — emergence seam is at run-complete; longer expedition just delays the roll, no code change needed.
|
||||
- **Twinbee voice + secret NPC buffs** (memories `feedback_twinbee_voice`, `feedback_npc_buffs_are_secret`) — none of the new copy can break these.
|
||||
|
||||
## 5. Schema impact
|
||||
|
||||
Likely none. Existing columns cover it:
|
||||
- `current_day` → autopilot increments via `advanceExpeditionDay`.
|
||||
- `camp_json`, `supplies_json`, `region_state`, `threat_*` already in place.
|
||||
- One **possible** new column: `expected_days` int (set at launch from zone tier + loadout) so status/end-of-day DMs can render "Day 3/5". Cheaper to derive than store — defer.
|
||||
|
||||
## 6. Open questions for next session
|
||||
|
||||
1. ~~Day-advance semantics~~ — **decided 2026-05-27: event-anchored.** day++ fires on autopilot camp-pitch (autopilot night-camp = sleep = day burn + briefing). UTC clock becomes a re-engagement-DM anchor only, not a state mutator. See §3-D2 for implementation impact.
|
||||
2. ~~Boss autopilot default~~ — **decided 2026-05-28: default ON.** D3 already ships compact-mode boss auto-resolve gated by `bossSafetyGate` (HP/SU/exhaustion). No opt-out flag for now; revisit if players want manual final-blow framing.
|
||||
3. ~~Per-tier room counts: sim-first vs ship-and-refine~~ — **decided 2026-05-28: sim-first refinement in D7-d.** D1 shipped a guess; we're much closer now. Measured vs prior: T1 ~2x (7→16), T2 ~1.8x (9→19), T3 ~2x (11→35), T4 ~3x (10→46), T5 ~3x (13→47). Target band is 3–5x; T1–T3 likely want another bump but defer until the D7-d sim corpus reads day-counts. Don't churn graphs blindly.
|
||||
4. ~~Extra anchor rooms (T4/T5)~~ — **decided 2026-05-28: yes, add anchor-room variety in T4/T5.** Second elite + second trap mid-zone (not just rely on multi-region/patrols/threat). Slot into D7-d alongside the room-count retune so we don't re-touch the graphs twice.
|
||||
5. ~~Mobile/AFK / DM cadence~~ — **decided in D4-a (already shipped):** event-anchored to in-game day. Night-camp pitch flushes the day as a digest; fork/death/run-complete still DM the walk stream; mid-day rests/waypoints surface only the camp block; per-tick auto-walks silent. No real-time pacing knob.
|
||||
|
||||
## 7. Sequence
|
||||
|
||||
**Original D1–D7 plan (all shipped):** D1 → D2 → D3 → D5 → D4 → D6 → D7.
|
||||
|
||||
**Current sequence (post-2026-05-28):** D8-c → D8-d-fix → D8-e → D9 → D10 → final re-baseline.
|
||||
|
||||
| Phase | What | Blocks |
|
||||
|------|------|--------|
|
||||
| D8-c | Concentration-damage multiplier in `spellExpectedDamage` | D8-d-fix read |
|
||||
| D8-d-fix | Lift `computeAC` caster floors (cleric/druid 3→5, bard/warlock 1→3, mage/sorcerer 0→2) | D8-e read |
|
||||
| D8-e | Diff turn-engine vs inline-sim for paladin/fighter/ranger on T4/T5; decide whether martial regression is real or a "fix" target | D9 (need stable class baseline) |
|
||||
| D9 | T1–T3 room-count bump toward 3–5× band, sim-validated against fresh corpus | D10 |
|
||||
| D10 | T4/T5 anchor-room variety (2nd elite + 2nd trap mid-zone) | final re-baseline |
|
||||
| — | One final L10 + L12 corpus pass to confirm tier day-counts + class spread | — |
|
||||
|
||||
## 9. Post-§6 follow-ups (D9 / D10)
|
||||
|
||||
### D9 — T1–T3 room-count bump
|
||||
|
||||
**Why now:** §6 fork 3 resolved to "sim-first refinement"; D7-d corpus shows T3 hitting only median 3 days (target 4) and T1/T2 weren't even measured because the corpus only matrixed T3+. T1–T3 graphs landed at ~2× prior length in D1; target band is 3–5×.
|
||||
|
||||
**Plan:**
|
||||
- Pull T1/T2 day-counts from a post-D8 corpus run (matrix needs to include `goblin_warrens`, `crypt_valdris`, `forest_shadows`, `sunken_temple` at L4/L6).
|
||||
- Lift graph sizes only if median days < tier target (T1=2, T2=3, T3=4). Don't churn graphs that already hit target.
|
||||
- Reuse the D1-a..e fork/anchor/merge patterns (`internal/plugin/zone_graph_*.go`); preserve existing topology — only add Exploration nodes + the second-anchor slot D10 will fill.
|
||||
- One-shot bootstrap: existing in-flight expeditions fence by `start_date` per §4 (consistent with D1 deploy).
|
||||
|
||||
**Risk:** if D8-d-fix lifts caster T3 clears materially, day-counts move too; measure after D8 stabilizes, not before.
|
||||
|
||||
### D10 — T4/T5 anchor-room variety
|
||||
|
||||
**Why now:** §6 fork 4 resolved "yes". T4/T5 zones run 30–50 rooms with only one Trap + one Elite anchor; the long middle reads same-y. Bundle with D9 so we touch each `zone_graph_*.go` once.
|
||||
|
||||
**Plan:**
|
||||
- Add a second Trap + second Elite anchor mid-zone for all T4/T5 zones (`underdark`, `feywild_crossing`, `dragons_lair`, `abyss_portal`).
|
||||
- Place the second Elite at the region-boundary spurs (good narrative justification — region-guardian rather than zone-guardian) so multi-region travel finally gets a teeth-y interrupt.
|
||||
- Place the second Trap on the long Exploration runs of fork branches, not on the merge node (keeps fork-choice loot/risk asymmetric).
|
||||
- Preserve longest-path length within current band — added anchors replace Exploration nodes, not append.
|
||||
|
||||
**Validation:** same final corpus pass as D9. Look for boss-clear rate dipping by <5pp (anchor difficulty is intended, not punishing) and median day-count stable.
|
||||
|
||||
### Cross-links picked up here
|
||||
- [[project_multiregion_travel_unwired]] — boss-clear → next-region auto-advance is wired (verified 2026-05-27); D10's region-boundary elites give it something to interrupt.
|
||||
- [[project_j3_caster_picker]] / [[project_d8d_diagnostic]] — D8 gates the whole sequence.
|
||||
- [[project_d8prereq_baseline]] — the corpus to diff against, not d7d.
|
||||
310
internal/bot/appservice.go
Normal file
310
internal/bot/appservice.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/appservice"
|
||||
"maunium.net/go/mautrix/crypto/cryptohelper"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// presenceHeartbeatInterval is how often the appservice re-asserts its online
|
||||
// presence. In masdevice mode the /sync long-poll refreshed presence implicitly;
|
||||
// appservice mode has no /sync, so we push presence explicitly. Synapse ties
|
||||
// "online" to recent activity and force-offlines a non-syncing user ~30s after its
|
||||
// last update (SYNC_ONLINE_TIMEOUT). A slower tick makes the bot FLAP (green right
|
||||
// after each PUT, then offline until the next) — empirically observed at 60s. So we
|
||||
// tick well under 30s to hold it continuously green. A hard crash stops the
|
||||
// heartbeat and Synapse offlines the bot within ~30s on its own.
|
||||
const presenceHeartbeatInterval = 20 * time.Second
|
||||
|
||||
// Waits applied when an inbound event arrives before its megolm session does.
|
||||
// The short wait covers the common race (keys moments behind the event); only
|
||||
// after it lapses do we spend an m.room_key_request and wait the long one. The
|
||||
// budget bounds the whole recovery so a permanently-unreadable event can't pin a
|
||||
// goroutine forever. Mirrors cryptohelper's own 3s/22s ladder.
|
||||
const (
|
||||
initialSessionWait = 3 * time.Second
|
||||
extendedSessionWait = 22 * time.Second
|
||||
sessionRecoveryBudget = initialSessionWait + extendedSessionWait + 30*time.Second
|
||||
)
|
||||
|
||||
// cryptoToDeviceTypes are the to-device event types the crypto machine must see
|
||||
// to establish Olm/Megolm sessions and share/receive room keys. In /sync mode
|
||||
// the cryptohelper gets these automatically; under the appservice transaction
|
||||
// model we route each one to mach.HandleToDeviceEvent ourselves.
|
||||
var cryptoToDeviceTypes = []event.Type{
|
||||
event.ToDeviceEncrypted,
|
||||
event.ToDeviceRoomKey,
|
||||
event.ToDeviceForwardedRoomKey,
|
||||
event.ToDeviceRoomKeyRequest,
|
||||
event.ToDeviceRoomKeyWithheld,
|
||||
event.ToDeviceOrgMatrixRoomKeyWithheld,
|
||||
event.ToDeviceSecretRequest,
|
||||
event.ToDeviceSecretSend,
|
||||
event.ToDeviceDummy,
|
||||
}
|
||||
|
||||
// newAppserviceSession builds the appservice-mode Session: as_token auth, a
|
||||
// cryptohelper that mints the bot's device via MSC4190, and an EventProcessor
|
||||
// fed by Synapse's transaction pushes (in place of /sync). The bot is an
|
||||
// appservice user — Synapse forbids AS users from /sync, so all events, plus the
|
||||
// E2EE extensions (to-device / device lists / OTK counts), arrive over the
|
||||
// transaction API instead.
|
||||
func newAppserviceSession(cfg Config) (*Session, error) {
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
if cfg.UserID == "" {
|
||||
return nil, fmt.Errorf("BOT_USER_ID is required")
|
||||
}
|
||||
if cfg.RegistrationPath == "" {
|
||||
return nil, fmt.Errorf("AS_REGISTRATION is required in appservice mode")
|
||||
}
|
||||
if cfg.HomeserverDomain == "" {
|
||||
return nil, fmt.Errorf("HOMESERVER_DOMAIN is required in appservice mode (server_name, e.g. parodia.dev)")
|
||||
}
|
||||
if !((&appservice.HostConfig{Hostname: cfg.ListenHost, Port: cfg.ListenPort}).IsConfigured()) {
|
||||
return nil, fmt.Errorf("AS_LISTEN_HOST/AS_LISTEN_PORT must be set in appservice mode")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
reg, err := appservice.LoadRegistration(cfg.RegistrationPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load appservice registration %q: %w", cfg.RegistrationPath, err)
|
||||
}
|
||||
// Gate for to-device delivery: handleTransaction only pumps to-device events
|
||||
// when this is set (appservice/http.go). Force it on regardless of the yaml so
|
||||
// E2EE key exchange can't silently break on a missing field.
|
||||
reg.EphemeralEvents = true
|
||||
|
||||
as, err := appservice.CreateFull(appservice.CreateOpts{
|
||||
Registration: reg,
|
||||
HomeserverDomain: cfg.HomeserverDomain,
|
||||
HomeserverURL: cfg.Homeserver,
|
||||
HostConfig: appservice.HostConfig{Hostname: cfg.ListenHost, Port: cfg.ListenPort},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create appservice: %w", err)
|
||||
}
|
||||
// Surface the HTTP listener + transaction logs (default is a silent Nop).
|
||||
as.Log = zerolog.New(os.Stderr).With().Timestamp().Str("component", "appservice").Logger().
|
||||
Level(zerolog.InfoLevel)
|
||||
|
||||
userID := id.UserID(cfg.UserID)
|
||||
if as.BotMXID() != userID {
|
||||
return nil, fmt.Errorf("registration sender_localpart resolves to %s but BOT_USER_ID is %s", as.BotMXID(), userID)
|
||||
}
|
||||
|
||||
client := as.BotClient() // as_token auth + SetAppServiceUserID (?user_id=) assertion
|
||||
// Assert the device via ?org.matrix.msc3202.device_id= on E2EE requests, and
|
||||
// satisfy cryptohelper.Init's syncer check: with this set, Init permits a nil
|
||||
// Syncer (we drive the crypto machine from transactions, not /sync). The param
|
||||
// is only emitted once DeviceID is non-empty (url.go), so setting it now is a
|
||||
// no-op for the whoami/device-create calls below; CreateDeviceMSC4190 re-sets it.
|
||||
client.SetAppServiceDeviceID = true
|
||||
|
||||
// Validate the token + identity before we start listening.
|
||||
whoami, err := client.Whoami(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("appservice token validation failed (whoami): %w", err)
|
||||
}
|
||||
if whoami.UserID != userID {
|
||||
return nil, fmt.Errorf("appservice identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID)
|
||||
}
|
||||
slog.Info("appservice token valid", "user_id", whoami.UserID)
|
||||
|
||||
// ---- E2EE via cryptohelper (MSC4190 device creation, no /login) ----
|
||||
cryptoDBPath := filepath.Join(cfg.DataDir, "crypto.db")
|
||||
ch, err := cryptohelper.NewCryptoHelper(client, []byte("gogobee_pickle_key"), cryptoDBPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init crypto helper: %w", err)
|
||||
}
|
||||
// MSC4190: the appservice creates/refreshes its own device via PUT /devices
|
||||
// instead of the UIA-gated login. The crypto store persists the device ID, so
|
||||
// restarts reuse it. LoginAs carries only the display name (never calls /login
|
||||
// in MSC4190 mode). client.Syncer is nil here, so Init does NOT wire /sync
|
||||
// handlers — we drive the crypto machine from transactions below instead.
|
||||
ch.MSC4190 = true
|
||||
ch.LoginAs = &mautrix.ReqLogin{InitialDeviceDisplayName: cfg.DisplayName}
|
||||
if err := ch.Init(ctx); err != nil {
|
||||
return nil, fmt.Errorf("crypto helper init (MSC4190 device create): %w", err)
|
||||
}
|
||||
client.Crypto = ch
|
||||
|
||||
// ---- Event processor: replicate the cryptohelper's /sync wiring against
|
||||
// the appservice transaction channels ----
|
||||
ep := appservice.NewEventProcessor(as)
|
||||
mach := ch.Machine()
|
||||
|
||||
// Best-effort cross-signing bootstrap so the bot's device shows as verified to
|
||||
// users who trust its master key. Best-effort: under MAS the key upload may be
|
||||
// refused, which we log and ignore — E2EE still functions without it.
|
||||
bootstrapCrossSigning(ctx, mach, cfg.DataDir)
|
||||
|
||||
// Crypto plumbing that /sync would otherwise carry:
|
||||
ep.OnOTK(mach.HandleOTKCounts)
|
||||
ep.OnDeviceList(mach.HandleDeviceLists)
|
||||
for _, t := range cryptoToDeviceTypes {
|
||||
ep.On(t, mach.HandleToDeviceEvent)
|
||||
}
|
||||
|
||||
// Keep the client's StateStore current so outgoing sends know which rooms are
|
||||
// encrypted and who to share keys with (no /sync backfill here), and let the
|
||||
// crypto machine track membership for key sharing. PrependHandler so state is
|
||||
// updated before app handlers (auto-join/moderation) run for the same event.
|
||||
ep.PrependHandler(event.StateEncryption, func(ctx context.Context, evt *event.Event) {
|
||||
mautrix.UpdateStateStore(ctx, as.StateStore, evt)
|
||||
})
|
||||
ep.PrependHandler(event.StateMember, func(ctx context.Context, evt *event.Event) {
|
||||
mautrix.UpdateStateStore(ctx, as.StateStore, evt)
|
||||
mach.HandleMemberEvent(ctx, evt)
|
||||
})
|
||||
|
||||
// Without /sync there is no state backfill, so the client's StateStore starts
|
||||
// empty. Before we hand off a decrypted event (whose reply may need to be
|
||||
// encrypted), resolve the room once: mark it encrypted and populate its member
|
||||
// list. Otherwise outbound sends go plaintext (IsEncrypted=false) and, worse,
|
||||
// the group session is shared to nobody (GetRoomJoinedOrInvitedMembers empty)
|
||||
// so recipients can't decrypt the bot's replies.
|
||||
var resolved sync.Map // roomID -> struct{}, resolved once
|
||||
resolveRoom := func(ctx context.Context, roomID id.RoomID) {
|
||||
if _, done := resolved.LoadOrStore(roomID, struct{}{}); done {
|
||||
return
|
||||
}
|
||||
var enc event.EncryptionEventContent
|
||||
if err := client.StateEvent(ctx, roomID, event.StateEncryption, "", &enc); err == nil && enc.Algorithm != "" {
|
||||
_ = as.StateStore.SetEncryptionEvent(ctx, roomID, &enc)
|
||||
}
|
||||
if members, err := client.Members(ctx, roomID); err == nil {
|
||||
_ = as.StateStore.ReplaceCachedMembers(ctx, roomID, members.Chunk)
|
||||
} else {
|
||||
slog.Warn("appservice: failed to fetch room members; will retry", "room", roomID, "err", err)
|
||||
resolved.Delete(roomID) // allow a later event to retry
|
||||
}
|
||||
}
|
||||
|
||||
// recoverSession handles an event whose megolm session we don't have yet. The
|
||||
// keys are often merely in flight (a to-device m.room_key racing the room
|
||||
// event), so wait briefly; if they never land, ask the sender to re-share and
|
||||
// wait longer. Only once that fails is the event genuinely unreadable.
|
||||
//
|
||||
// This duplicates cryptohelper.HandleEncrypted's wait/request ladder on
|
||||
// purpose: that path is gated on a /sync token being present in the context
|
||||
// (mautrix.SyncTokenContextKey), which appservice transactions never carry, so
|
||||
// wiring ch.ASEventProcessor would still leave us dropping these events.
|
||||
//
|
||||
// Runs detached from the transaction context, which is cancelled as soon as we
|
||||
// ACK the transaction — long before the keys could arrive.
|
||||
recoverSession := func(evt *event.Event) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), sessionRecoveryBudget)
|
||||
defer cancel()
|
||||
|
||||
content := evt.Content.AsEncrypted()
|
||||
got := ch.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, initialSessionWait)
|
||||
if !got {
|
||||
ch.RequestSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, evt.Sender, content.DeviceID)
|
||||
got = ch.WaitForSession(ctx, evt.RoomID, content.SenderKey, content.SessionID, extendedSessionWait)
|
||||
}
|
||||
if !got {
|
||||
slog.Warn("appservice: gave up decrypting event, no room key",
|
||||
"room", evt.RoomID, "event", evt.ID, "session", content.SessionID)
|
||||
return
|
||||
}
|
||||
|
||||
decrypted, err := ch.Decrypt(ctx, evt)
|
||||
if err != nil {
|
||||
slog.Warn("appservice: failed to decrypt event after key request",
|
||||
"room", evt.RoomID, "event", evt.ID, "err", err)
|
||||
return
|
||||
}
|
||||
slog.Debug("appservice: recovered event after key request", "room", evt.RoomID, "event", evt.ID)
|
||||
ep.Dispatch(ctx, decrypted)
|
||||
}
|
||||
|
||||
// Decrypt inbound encrypted room events and re-dispatch the plaintext so the
|
||||
// normal message/reaction handlers fire (mirrors cryptohelper.HandleEncrypted).
|
||||
ep.On(event.EventEncrypted, func(ctx context.Context, evt *event.Event) {
|
||||
resolveRoom(ctx, evt.RoomID)
|
||||
decrypted, err := ch.Decrypt(ctx, evt)
|
||||
if err != nil {
|
||||
if errors.Is(err, cryptohelper.NoSessionFound) {
|
||||
go recoverSession(evt)
|
||||
return
|
||||
}
|
||||
slog.Warn("appservice: failed to decrypt event", "room", evt.RoomID, "event", evt.ID, "err", err)
|
||||
return
|
||||
}
|
||||
ep.Dispatch(ctx, decrypted)
|
||||
})
|
||||
|
||||
return &Session{
|
||||
Client: client,
|
||||
mode: "appservice",
|
||||
as: as,
|
||||
ep: ep,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// runAppservice starts the transaction dispatchers and the HTTP listener, then
|
||||
// blocks until ctx is cancelled.
|
||||
func (s *Session) runAppservice(ctx context.Context) error {
|
||||
s.ep.Start(ctx)
|
||||
s.as.Ready = true
|
||||
|
||||
errCh := make(chan struct{})
|
||||
go func() {
|
||||
s.as.Start() // blocks in ListenAndServe until Stop()
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
slog.Info("appservice listener started", "address", s.as.Host.Address())
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-errCh:
|
||||
return fmt.Errorf("appservice HTTP listener exited unexpectedly")
|
||||
}
|
||||
}
|
||||
|
||||
// runPresenceHeartbeat keeps the bot's Matrix presence "online" while the
|
||||
// appservice runs. Without /sync, nothing else refreshes presence, so it would
|
||||
// otherwise freeze at its last value. On graceful shutdown it best-effort sets
|
||||
// presence "offline"; on a hard crash the heartbeat simply stops and Synapse
|
||||
// decays the stale "online" state on its own.
|
||||
func (s *Session) runPresenceHeartbeat(ctx context.Context) {
|
||||
setPresence := func(ctx context.Context, presence event.Presence) {
|
||||
if err := s.Client.SetPresence(ctx, mautrix.ReqPresence{Presence: presence}); err != nil {
|
||||
slog.Warn("appservice: set presence failed", "presence", presence, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
setPresence(ctx, event.PresenceOnline)
|
||||
|
||||
ticker := time.NewTicker(presenceHeartbeatInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Detach from the cancelled ctx so the final PUT still goes out.
|
||||
offCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
setPresence(offCtx, event.PresenceOffline)
|
||||
cancel()
|
||||
return
|
||||
case <-ticker.C:
|
||||
setPresence(ctx, event.PresenceOnline)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,203 +2,174 @@ package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gogobee/internal/util"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/crypto/cryptohelper"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// DeviceInfo holds persisted device credentials.
|
||||
type DeviceInfo struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
DeviceID string `json:"device_id"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
// Config holds the bot's startup configuration.
|
||||
type Config struct {
|
||||
Homeserver string
|
||||
UserID string
|
||||
Password string
|
||||
UserID string // Bot's full Matrix user ID, e.g. @twinbee:parodia.dev
|
||||
DataDir string
|
||||
DisplayName string
|
||||
|
||||
// AuthMode selects how the bot connects to Matrix:
|
||||
// "masdevice" (default) — MAS OAuth 2.0 device grant + /sync (masauth.go).
|
||||
// "appservice" — Matrix appservice: as_token auth + Synapse→bot
|
||||
// transaction push (appservice.go). MAS-durable:
|
||||
// no login, no MFA, no token expiry ever.
|
||||
// The device-grant path is retained as an instant rollback: flip AUTH_MODE
|
||||
// back to masdevice and restart, no rebuild.
|
||||
AuthMode string
|
||||
|
||||
// ---- appservice mode only ----
|
||||
// RegistrationPath points at the appservice registration YAML (id, as_token,
|
||||
// hs_token, sender_localpart, namespaces). Synapse and the bot share it.
|
||||
RegistrationPath string
|
||||
// ListenHost/ListenPort is where the bot's HTTP listener binds to receive
|
||||
// Synapse's transaction pushes (Synapse reaches it via the registration url).
|
||||
ListenHost string
|
||||
ListenPort uint16
|
||||
// HomeserverDomain is the server_name (e.g. parodia.dev), needed to derive
|
||||
// the bot's MXID from sender_localpart.
|
||||
HomeserverDomain string
|
||||
}
|
||||
|
||||
// NewClient creates and configures a mautrix client with E2EE support.
|
||||
// The cryptohelper handles:
|
||||
// - Persistent crypto store in SQLite (device keys, sessions, cross-signing keys)
|
||||
// - Automatic cross-signing bootstrap (self-signs the device on first run)
|
||||
// - Automatic device trust via cross-signing (no manual verification needed)
|
||||
// - Megolm session sharing and key exchange
|
||||
// - Olm session management for device-to-device encryption
|
||||
// NewClient creates and configures a mautrix client authenticated against
|
||||
// Matrix Authentication Service (MAS) via the OAuth 2.0 device grant, with
|
||||
// E2EE via the cryptohelper.
|
||||
//
|
||||
// This solves the TS version's device verification issues because:
|
||||
// 1. Crypto state persists across restarts (not in-memory like fake-indexeddb)
|
||||
// 2. Cross-signing makes other devices trust this bot automatically
|
||||
// 3. The bot trusts all users' devices by default (appropriate for a bot)
|
||||
// 4. No manual emoji/SAS verification needed
|
||||
// Auth flow (see masauth.go for detail):
|
||||
// - Discover MAS OAuth endpoints from the homeserver's well-known + OIDC docs.
|
||||
// - If we have a persisted refresh token, refresh it for a fresh access token.
|
||||
// - Otherwise run the device-authorization grant: print a URL + code for a
|
||||
// human to approve ONCE in a browser (as the bot's user), then store the
|
||||
// resulting access + refresh tokens.
|
||||
// - A background goroutine refreshes the access token before it expires and
|
||||
// updates the live client, so the bot runs indefinitely without re-auth.
|
||||
//
|
||||
// The bot stays a normal Matrix user (not an appservice), so /sync works — an
|
||||
// appservice user is forbidden from /sync by Synapse. E2EE is unchanged: the
|
||||
// cryptohelper persists device keys, olm/megolm sessions and cross-signing in
|
||||
// its own SQLite store, and the bot trusts all users' devices by default.
|
||||
func NewClient(cfg Config) (*mautrix.Client, error) {
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
if cfg.UserID == "" {
|
||||
return nil, fmt.Errorf("BOT_USER_ID is required")
|
||||
}
|
||||
|
||||
devicePath := filepath.Join(cfg.DataDir, "device.json")
|
||||
ctx := context.Background()
|
||||
|
||||
// Try to load existing device credentials
|
||||
device, err := loadDevice(devicePath)
|
||||
// ---- MAS OAuth device-grant authentication ----
|
||||
auth := newMASAuth(cfg.DataDir)
|
||||
auth.load()
|
||||
if err := auth.discover(cfg.Homeserver); err != nil {
|
||||
return nil, fmt.Errorf("MAS discovery: %w", err)
|
||||
}
|
||||
|
||||
if auth.refreshToken != "" {
|
||||
// Returning start: refresh the stored token.
|
||||
if err := auth.refresh(); err != nil {
|
||||
return nil, fmt.Errorf("MAS token refresh failed (delete %s to re-authorize): %w",
|
||||
filepath.Join(cfg.DataDir, "mas_auth.json"), err)
|
||||
}
|
||||
slog.Info("MAS auth: refreshed access token from stored session", "device_id", auth.deviceID)
|
||||
} else {
|
||||
// First run: register a client and run the interactive device grant.
|
||||
if err := auth.ensureClient(cfg.DisplayName); err != nil {
|
||||
return nil, fmt.Errorf("MAS client registration: %w", err)
|
||||
}
|
||||
if err := auth.deviceFlow(ctx); err != nil {
|
||||
return nil, fmt.Errorf("MAS device authorization: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
userID := id.UserID(cfg.UserID)
|
||||
client, err := mautrix.NewClient(cfg.Homeserver, userID, auth.token())
|
||||
if err != nil {
|
||||
slog.Info("no existing device found, will login fresh")
|
||||
return nil, fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
client.DeviceID = id.DeviceID(auth.deviceID)
|
||||
|
||||
var client *mautrix.Client
|
||||
|
||||
if device != nil {
|
||||
// Validate existing token
|
||||
valid, _ := util.IsTokenValid(cfg.Homeserver, device.AccessToken)
|
||||
if valid {
|
||||
slog.Info("existing device credentials valid", "device_id", device.DeviceID)
|
||||
userID := id.UserID(device.UserID)
|
||||
client, err = mautrix.NewClient(cfg.Homeserver, userID, device.AccessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create client with existing token: %w", err)
|
||||
}
|
||||
client.DeviceID = id.DeviceID(device.DeviceID)
|
||||
} else {
|
||||
slog.Warn("existing device credentials invalid, logging in again")
|
||||
device = nil
|
||||
}
|
||||
// Validate the token + identity before proceeding.
|
||||
whoami, err := client.Whoami(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token validation failed (whoami): %w", err)
|
||||
}
|
||||
|
||||
if device == nil {
|
||||
// Fresh login
|
||||
loginResp, err := util.LoginWithPassword(cfg.Homeserver, cfg.UserID, cfg.Password, cfg.DisplayName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("login: %w", err)
|
||||
}
|
||||
|
||||
userID := id.UserID(loginResp.UserID)
|
||||
client, err = mautrix.NewClient(cfg.Homeserver, userID, loginResp.AccessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create client: %w", err)
|
||||
}
|
||||
client.DeviceID = id.DeviceID(loginResp.DeviceID)
|
||||
|
||||
// Save device info
|
||||
device = &DeviceInfo{
|
||||
AccessToken: loginResp.AccessToken,
|
||||
DeviceID: loginResp.DeviceID,
|
||||
UserID: loginResp.UserID,
|
||||
}
|
||||
if err := saveDevice(devicePath, device); err != nil {
|
||||
slog.Warn("failed to save device info", "err", err)
|
||||
}
|
||||
|
||||
slog.Info("logged in successfully",
|
||||
"user_id", loginResp.UserID,
|
||||
"device_id", loginResp.DeviceID,
|
||||
)
|
||||
if whoami.UserID != userID {
|
||||
return nil, fmt.Errorf("identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID)
|
||||
}
|
||||
slog.Info("MAS auth: token valid", "user_id", whoami.UserID, "device_id", client.DeviceID)
|
||||
|
||||
// Set up E2EE via cryptohelper — stores crypto state in its own SQLite DB,
|
||||
// separate from the main app database. Unlike the TS version which used an
|
||||
// in-memory fake-indexeddb store that was lost on restart (causing constant
|
||||
// re-verification), mautrix-go's cryptohelper persists everything in SQLite:
|
||||
// device keys, olm/megolm sessions, cross-signing keys, and device trust state.
|
||||
//
|
||||
// We pass just the raw file path — the cryptohelper wraps it in a file: URI
|
||||
// with _txlock=immediate internally (see cryptohelper.go line 82).
|
||||
// ---- E2EE via cryptohelper ----
|
||||
// The crypto store persists device keys, olm/megolm sessions, cross-signing
|
||||
// and device trust in its own SQLite DB. The stored device ID must match the
|
||||
// one bound to our OAuth token (device: scope); a fresh mas_auth.json is
|
||||
// paired with a fresh crypto.db.
|
||||
cryptoDBPath := filepath.Join(cfg.DataDir, "crypto.db")
|
||||
ch, err := cryptohelper.NewCryptoHelper(client, []byte("gogobee_pickle_key"), cryptoDBPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init crypto helper: %w", err)
|
||||
}
|
||||
|
||||
// LoginAs enables the cryptohelper to re-login if the token expires,
|
||||
// and to bootstrap cross-signing on first run. Cross-signing means:
|
||||
// - The bot's master key signs its own device key
|
||||
// - Other users/devices that have verified the bot's master key
|
||||
// will automatically trust this device
|
||||
// - No interactive emoji/SAS verification needed
|
||||
ch.LoginAs = &mautrix.ReqLogin{
|
||||
Type: mautrix.AuthTypePassword,
|
||||
Identifier: mautrix.UserIdentifier{
|
||||
Type: mautrix.IdentifierTypeUser,
|
||||
User: cfg.UserID,
|
||||
},
|
||||
Password: cfg.Password,
|
||||
InitialDeviceDisplayName: cfg.DisplayName,
|
||||
}
|
||||
|
||||
if err := ch.Init(context.Background()); err != nil {
|
||||
// No LoginAs: we already have a token + device ID; the cryptohelper just
|
||||
// attaches E2EE to the existing session.
|
||||
if err := ch.Init(ctx); err != nil {
|
||||
return nil, fmt.Errorf("crypto helper init: %w", err)
|
||||
}
|
||||
|
||||
// Attach crypto helper to client
|
||||
client.Crypto = ch
|
||||
|
||||
// Bootstrap cross-signing: generate keys, sign own device, sign master key.
|
||||
// This makes the bot's device show as "verified" to other users.
|
||||
// Best-effort cross-signing bootstrap (makes the bot's device show as
|
||||
// verified to users who trust its master key). Not required for E2EE to
|
||||
// function; under MAS the key upload may be refused, which we ignore.
|
||||
mach := ch.Machine()
|
||||
recoveryKey, _, err := mach.GenerateAndUploadCrossSigningKeys(context.Background(), func(ui *mautrix.RespUserInteractive) interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": mautrix.AuthTypePassword,
|
||||
"identifier": map[string]interface{}{
|
||||
"type": mautrix.IdentifierTypeUser,
|
||||
"user": cfg.UserID,
|
||||
},
|
||||
"password": cfg.Password,
|
||||
"session": ui.Session,
|
||||
}
|
||||
}, "")
|
||||
if err != nil {
|
||||
slog.Warn("cross-signing: key upload failed (may already exist)", "err", err)
|
||||
} else {
|
||||
slog.Info("cross-signing: keys uploaded", "recovery_key", recoveryKey)
|
||||
}
|
||||
bootstrapCrossSigning(ctx, mach, cfg.DataDir)
|
||||
|
||||
if err := mach.SignOwnDevice(context.Background(), mach.OwnIdentity()); err != nil {
|
||||
slog.Warn("cross-signing: sign own device failed", "err", err)
|
||||
} else {
|
||||
slog.Info("cross-signing: own device signed")
|
||||
}
|
||||
|
||||
if err := mach.SignOwnMasterKey(context.Background()); err != nil {
|
||||
slog.Warn("cross-signing: sign master key failed", "err", err)
|
||||
} else {
|
||||
slog.Info("cross-signing: master key signed")
|
||||
}
|
||||
// ---- Background token refresher ----
|
||||
// Refresh ~60s before expiry and push the new token into the live client so
|
||||
// in-flight /sync and API calls keep authenticating.
|
||||
go refreshLoop(context.Background(), auth, client)
|
||||
|
||||
slog.Info("E2EE initialized",
|
||||
"user_id", client.UserID,
|
||||
"device_id", client.DeviceID,
|
||||
"crypto_store", "sqlite-persistent",
|
||||
"auth", "mas-oauth-device-grant",
|
||||
)
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func loadDevice(path string) (*DeviceInfo, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// refreshLoop keeps the client's access token fresh for the life of the process.
|
||||
func refreshLoop(ctx context.Context, auth *masAuth, client *mautrix.Client) {
|
||||
for {
|
||||
wait := time.Until(auth.expiry()) - 60*time.Second
|
||||
if wait < 10*time.Second {
|
||||
wait = 10 * time.Second
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(wait):
|
||||
}
|
||||
if err := auth.refresh(); err != nil {
|
||||
slog.Error("MAS token refresh failed; retrying in 30s", "err", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
}
|
||||
continue
|
||||
}
|
||||
client.AccessToken = auth.token()
|
||||
slog.Debug("MAS token refreshed", "expires_at", auth.expiry().Format(time.RFC3339))
|
||||
}
|
||||
var info DeviceInfo
|
||||
if err := json.Unmarshal(data, &info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func saveDevice(path string, info *DeviceInfo) error {
|
||||
data, err := json.MarshalIndent(info, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
137
internal/bot/crosssigning.go
Normal file
137
internal/bot/crosssigning.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/crypto"
|
||||
)
|
||||
|
||||
// crossSigningFile is the on-disk store for the bot's cross-signing recovery key
|
||||
// (data/cross_signing.json), alongside mas_auth.json and crypto.db. The key
|
||||
// decrypts the private cross-signing keys the bot parks in SSSS, so it is the
|
||||
// only thing that lets a rebuilt crypto.db re-sign the bot's device instead of
|
||||
// minting a whole new identity and forcing everyone to re-verify.
|
||||
//
|
||||
// It is deliberately NOT logged: the screen wrapper pipes the bot's output
|
||||
// through tee, which truncates the log on every restart, so a key that only ever
|
||||
// exists in a log line is a key you lose on the next boot.
|
||||
const crossSigningFile = "cross_signing.json"
|
||||
|
||||
type crossSigningStore struct {
|
||||
RecoveryKey string `json:"recovery_key"`
|
||||
}
|
||||
|
||||
func crossSigningPath(dataDir string) string {
|
||||
return filepath.Join(dataDir, crossSigningFile)
|
||||
}
|
||||
|
||||
// loadRecoveryKey returns the stored key, or "" when there is none.
|
||||
func loadRecoveryKey(dataDir string) string {
|
||||
data, err := os.ReadFile(crossSigningPath(dataDir))
|
||||
if err != nil {
|
||||
return "" // fresh install
|
||||
}
|
||||
var s crossSigningStore
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
slog.Warn("cross-signing: corrupt recovery-key store, ignoring", "path", crossSigningPath(dataDir), "err", err)
|
||||
return ""
|
||||
}
|
||||
return s.RecoveryKey
|
||||
}
|
||||
|
||||
func saveRecoveryKey(dataDir, key string) {
|
||||
data, err := json.MarshalIndent(crossSigningStore{RecoveryKey: key}, "", " ")
|
||||
if err != nil {
|
||||
slog.Error("cross-signing: marshal recovery key failed", "err", err)
|
||||
return
|
||||
}
|
||||
path := crossSigningPath(dataDir)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
slog.Error("cross-signing: could not persist recovery key; a crypto.db rebuild will need a re-verify", "path", path, "err", err)
|
||||
return
|
||||
}
|
||||
slog.Info("cross-signing: recovery key persisted", "path", path)
|
||||
}
|
||||
|
||||
// uiaSession answers a user-interactive-auth challenge with just the session ID.
|
||||
// Under MAS the key upload is either allowed outright or refused; there is no
|
||||
// password to offer.
|
||||
func uiaSession(ui *mautrix.RespUserInteractive) interface{} {
|
||||
return map[string]interface{}{"session": ui.Session}
|
||||
}
|
||||
|
||||
// bootstrapCrossSigning establishes the bot's cross-signing identity, which is
|
||||
// what lets clients show it as verified rather than as an unknown device. E2EE
|
||||
// works without it; only the verification badge depends on it.
|
||||
//
|
||||
// It must never mint a second identity by accident. mautrix's
|
||||
// GenerateAndUploadCrossSigningKeys is unconditional: every call generates a fresh
|
||||
// master/self/user-signing trio and overwrites the published one. Calling it on
|
||||
// each start reminted the bot's identity every boot, which is why clients kept
|
||||
// asking users to re-verify. So generate only when there is no identity to keep,
|
||||
// or when the operator explicitly asks for a reset.
|
||||
//
|
||||
// The private keys live server-side in SSSS, never in crypto.db, and mach.Load
|
||||
// does not restore them. A bot that keeps its crypto.db stays signed from its
|
||||
// first signing and needs nothing here. A bot whose crypto.db was rebuilt has a
|
||||
// brand-new device that only the recovery key can re-sign.
|
||||
//
|
||||
// Set CROSS_SIGNING_REGENERATE=1 to deliberately reset the identity (costs one
|
||||
// re-verify per user, and stores the fresh key). CROSS_SIGNING_RECOVERY_KEY
|
||||
// imports an existing key into the store, for adopting an identity created before
|
||||
// the bot persisted its own.
|
||||
func bootstrapCrossSigning(ctx context.Context, mach *crypto.OlmMachine, dataDir string) {
|
||||
stored := loadRecoveryKey(dataDir)
|
||||
if envKey := os.Getenv("CROSS_SIGNING_RECOVERY_KEY"); envKey != "" && envKey != stored {
|
||||
saveRecoveryKey(dataDir, envKey)
|
||||
stored = envKey
|
||||
}
|
||||
|
||||
hasKeys, isVerified, err := mach.GetOwnVerificationStatus(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("cross-signing: could not determine verification status, leaving identity alone", "err", err)
|
||||
return
|
||||
}
|
||||
regenerate := os.Getenv("CROSS_SIGNING_REGENERATE") != ""
|
||||
|
||||
switch {
|
||||
case regenerate || !hasKeys:
|
||||
if regenerate && hasKeys {
|
||||
slog.Warn("cross-signing: CROSS_SIGNING_REGENERATE set — replacing the published identity; every user must verify the bot once more. Unset it after this start.")
|
||||
}
|
||||
key, _, err := mach.GenerateAndUploadCrossSigningKeys(ctx, uiaSession, "")
|
||||
if err != nil {
|
||||
slog.Warn("cross-signing: key upload failed, bot will show unverified", "err", err)
|
||||
return
|
||||
}
|
||||
saveRecoveryKey(dataDir, key)
|
||||
if err := mach.SignOwnDevice(ctx, mach.OwnIdentity()); err != nil {
|
||||
slog.Warn("cross-signing: sign own device failed", "err", err)
|
||||
}
|
||||
if err := mach.SignOwnMasterKey(ctx); err != nil {
|
||||
slog.Warn("cross-signing: sign master key failed", "err", err)
|
||||
}
|
||||
slog.Info("cross-signing: identity created and device signed")
|
||||
|
||||
case isVerified:
|
||||
slog.Info("cross-signing: identity already published and this device is signed")
|
||||
|
||||
case stored != "":
|
||||
// Pulls the private keys back out of SSSS, then signs this device and the
|
||||
// master key with them.
|
||||
if err := mach.VerifyWithRecoveryKey(ctx, stored); err != nil {
|
||||
slog.Warn("cross-signing: recovery-key restore failed, bot will show unverified", "err", err)
|
||||
return
|
||||
}
|
||||
slog.Info("cross-signing: device re-signed from the stored recovery key")
|
||||
|
||||
default:
|
||||
slog.Warn("cross-signing: this device is unsigned and no recovery key is stored, so the bot will show unverified (E2EE still works). Set CROSS_SIGNING_REGENERATE=1 once to mint a fresh identity.",
|
||||
"store", crossSigningPath(dataDir))
|
||||
}
|
||||
}
|
||||
56
internal/bot/crosssigning_test.go
Normal file
56
internal/bot/crosssigning_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRecoveryKeyRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if got := loadRecoveryKey(dir); got != "" {
|
||||
t.Fatalf("fresh install returned %q, want empty", got)
|
||||
}
|
||||
|
||||
const key = "EsTd o49d mMLt 3Uf9 Gjn9 x5fv YE9H wF6n aadC q2D8 Fv7j rQ4c"
|
||||
saveRecoveryKey(dir, key)
|
||||
if got := loadRecoveryKey(dir); got != key {
|
||||
t.Fatalf("loadRecoveryKey() = %q, want %q", got, key)
|
||||
}
|
||||
}
|
||||
|
||||
// The key is crypto material sitting next to crypto.db; it must not be readable
|
||||
// by other users on the host.
|
||||
func TestRecoveryKeyFileIsPrivate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
saveRecoveryKey(dir, "some-key")
|
||||
info, err := os.Stat(filepath.Join(dir, crossSigningFile))
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("recovery key file mode = %o, want 600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
// A corrupt store must degrade to "no key" rather than panicking or returning
|
||||
// garbage that would be fed to VerifyWithRecoveryKey.
|
||||
func TestRecoveryKeyCorruptStore(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, crossSigningFile), []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if got := loadRecoveryKey(dir); got != "" {
|
||||
t.Fatalf("corrupt store returned %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Overwriting an adopted key must not leave trailing bytes from the longer value.
|
||||
func TestRecoveryKeyOverwrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
saveRecoveryKey(dir, "a-much-longer-original-recovery-key-value")
|
||||
saveRecoveryKey(dir, "short")
|
||||
if got := loadRecoveryKey(dir); got != "short" {
|
||||
t.Fatalf("loadRecoveryKey() = %q, want %q", got, "short")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package bot
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gogobee/internal/plugin"
|
||||
@@ -9,13 +11,24 @@ import (
|
||||
|
||||
// Registry manages plugin registration and event dispatch.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
plugins []plugin.Plugin
|
||||
mu sync.RWMutex
|
||||
plugins []plugin.Plugin
|
||||
ignoredBots map[string]struct{}
|
||||
}
|
||||
|
||||
// NewRegistry creates an empty plugin registry.
|
||||
// NewRegistry creates an empty plugin registry. Senders listed in the
|
||||
// IGNORED_BOTS env var (comma-separated full Matrix user IDs, e.g.
|
||||
// "@pete:parodia.dev") are dropped before any plugin sees them.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{}
|
||||
ignored := make(map[string]struct{})
|
||||
if raw := os.Getenv("IGNORED_BOTS"); raw != "" {
|
||||
for _, u := range strings.Split(raw, ",") {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
ignored[u] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return &Registry{ignoredBots: ignored}
|
||||
}
|
||||
|
||||
// Register adds a plugin to the registry.
|
||||
@@ -42,6 +55,9 @@ func (r *Registry) Init() error {
|
||||
|
||||
// DispatchMessage sends a message context to all plugins in order.
|
||||
func (r *Registry) DispatchMessage(ctx plugin.MessageContext) {
|
||||
if _, ok := r.ignoredBots[string(ctx.Sender)]; ok {
|
||||
return
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, p := range r.plugins {
|
||||
@@ -71,6 +87,9 @@ func (r *Registry) safeOnMessage(p plugin.Plugin, ctx plugin.MessageContext) {
|
||||
|
||||
// DispatchReaction sends a reaction context to all plugins in order.
|
||||
func (r *Registry) DispatchReaction(ctx plugin.ReactionContext) {
|
||||
if _, ok := r.ignoredBots[string(ctx.Sender)]; ok {
|
||||
return
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, p := range r.plugins {
|
||||
|
||||
375
internal/bot/masauth.go
Normal file
375
internal/bot/masauth.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// masAuth performs MAS (Matrix Authentication Service) OAuth 2.0 device-grant
|
||||
// authentication for the bot and keeps a valid access token available.
|
||||
//
|
||||
// Why this exists: MAS replaces Matrix's legacy password login with OAuth2. A
|
||||
// bot can't do the interactive authorization-code flow, so we use the OAuth 2.0
|
||||
// Device Authorization Grant (RFC 8628): on first run the bot prints a URL +
|
||||
// user code, a human approves the login as the bot's user ONCE in a browser,
|
||||
// and MAS returns an access token + refresh token. Thereafter the bot refreshes
|
||||
// silently forever — no password, no expiry surprises, and the bot stays a
|
||||
// normal Matrix user so /sync keeps working (unlike an appservice user, which
|
||||
// Synapse forbids from /sync).
|
||||
//
|
||||
// The refresh token is rotated by MAS on every refresh, so we persist the new
|
||||
// one each time.
|
||||
type masAuth struct {
|
||||
http *http.Client
|
||||
storePath string
|
||||
scope string
|
||||
|
||||
// discovered OAuth endpoints
|
||||
deviceEndpoint string
|
||||
tokenEndpoint string
|
||||
registrationEndpoint string
|
||||
|
||||
mu sync.RWMutex
|
||||
clientID string
|
||||
deviceID string
|
||||
accessToken string
|
||||
refreshToken string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// masStore is the on-disk persisted state (data/mas_auth.json).
|
||||
type masStore struct {
|
||||
ClientID string `json:"client_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
const masScopeAPI = "urn:matrix:org.matrix.msc2967.client:api:*"
|
||||
|
||||
func newMASAuth(dataDir string) *masAuth {
|
||||
return &masAuth{
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
storePath: filepath.Join(dataDir, "mas_auth.json"),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *masAuth) load() {
|
||||
data, err := os.ReadFile(m.storePath)
|
||||
if err != nil {
|
||||
return // fresh install
|
||||
}
|
||||
var s masStore
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
slog.Warn("mas_auth: corrupt store, ignoring", "err", err)
|
||||
return
|
||||
}
|
||||
m.clientID = s.ClientID
|
||||
m.deviceID = s.DeviceID
|
||||
m.accessToken = s.AccessToken
|
||||
m.refreshToken = s.RefreshToken
|
||||
m.expiresAt = s.ExpiresAt
|
||||
}
|
||||
|
||||
func (m *masAuth) save() {
|
||||
m.mu.RLock()
|
||||
s := masStore{
|
||||
ClientID: m.clientID,
|
||||
DeviceID: m.deviceID,
|
||||
AccessToken: m.accessToken,
|
||||
RefreshToken: m.refreshToken,
|
||||
ExpiresAt: m.expiresAt,
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
slog.Error("mas_auth: marshal failed", "err", err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(m.storePath, data, 0o600); err != nil {
|
||||
slog.Error("mas_auth: write failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *masAuth) token() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.accessToken
|
||||
}
|
||||
|
||||
// discover resolves the MAS OAuth endpoints from the homeserver's well-known
|
||||
// document and the issuer's OIDC discovery document.
|
||||
func (m *masAuth) discover(homeserver string) error {
|
||||
var wk struct {
|
||||
Auth struct {
|
||||
Issuer string `json:"issuer"`
|
||||
} `json:"org.matrix.msc2965.authentication"`
|
||||
}
|
||||
if err := m.getJSON(strings.TrimRight(homeserver, "/")+"/.well-known/matrix/client", &wk); err != nil {
|
||||
return fmt.Errorf("fetch well-known: %w", err)
|
||||
}
|
||||
if wk.Auth.Issuer == "" {
|
||||
return fmt.Errorf("homeserver does not advertise a MAS issuer (msc2965) — is MAS enabled?")
|
||||
}
|
||||
var oidc struct {
|
||||
DeviceEndpoint string `json:"device_authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
RegistrationEndpoint string `json:"registration_endpoint"`
|
||||
}
|
||||
if err := m.getJSON(strings.TrimRight(wk.Auth.Issuer, "/")+"/.well-known/openid-configuration", &oidc); err != nil {
|
||||
return fmt.Errorf("fetch OIDC discovery: %w", err)
|
||||
}
|
||||
if oidc.DeviceEndpoint == "" || oidc.TokenEndpoint == "" {
|
||||
return fmt.Errorf("MAS issuer %q does not advertise a device/token endpoint", wk.Auth.Issuer)
|
||||
}
|
||||
m.deviceEndpoint = oidc.DeviceEndpoint
|
||||
m.tokenEndpoint = oidc.TokenEndpoint
|
||||
m.registrationEndpoint = oidc.RegistrationEndpoint
|
||||
slog.Info("mas_auth: discovered endpoints", "issuer", wk.Auth.Issuer)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureClient registers a public OAuth client via dynamic registration if we
|
||||
// don't already have a client_id persisted.
|
||||
func (m *masAuth) ensureClient(displayName string) error {
|
||||
if m.clientID != "" {
|
||||
return nil
|
||||
}
|
||||
if m.registrationEndpoint == "" {
|
||||
return fmt.Errorf("no registration endpoint discovered")
|
||||
}
|
||||
body := map[string]any{
|
||||
"client_name": displayName + " (bot)",
|
||||
"application_type": "native",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"grant_types": []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"},
|
||||
"response_types": []string{},
|
||||
"client_uri": "https://github.com/prosolis/gogobee",
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
resp, err := m.http.Post(m.registrationEndpoint, "application/json", strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("client registration request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 201 {
|
||||
return fmt.Errorf("client registration failed (HTTP %d): %s", resp.StatusCode, string(rb))
|
||||
}
|
||||
var out struct {
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
if err := json.Unmarshal(rb, &out); err != nil || out.ClientID == "" {
|
||||
return fmt.Errorf("client registration: no client_id in response: %s", string(rb))
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.clientID = out.ClientID
|
||||
m.mu.Unlock()
|
||||
m.save()
|
||||
slog.Info("mas_auth: registered OAuth client", "client_id", out.ClientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// deviceFlow runs the interactive device-authorization grant. It blocks until
|
||||
// the user approves the login (or the code expires).
|
||||
func (m *masAuth) deviceFlow(ctx context.Context) error {
|
||||
if m.deviceID == "" {
|
||||
m.deviceID = randomDeviceID()
|
||||
}
|
||||
scope := masScopeAPI + " urn:matrix:org.matrix.msc2967.client:device:" + m.deviceID
|
||||
|
||||
form := url.Values{"client_id": {m.clientID}, "scope": {scope}}
|
||||
var da struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
if err := m.postForm(m.deviceEndpoint, form, &da); err != nil {
|
||||
return fmt.Errorf("device authorization request: %w", err)
|
||||
}
|
||||
|
||||
// Surface the approval prompt prominently — the operator must act on it.
|
||||
uri := da.VerificationURI
|
||||
if da.VerificationURIComplete != "" {
|
||||
uri = da.VerificationURIComplete
|
||||
}
|
||||
banner := fmt.Sprintf(`
|
||||
|
||||
================= TwinBee needs you to authorize its login =================
|
||||
Open this URL in a browser (logged in as the bot's Matrix user):
|
||||
%s
|
||||
and enter code: %s
|
||||
(expires in %d minutes)
|
||||
============================================================================
|
||||
|
||||
`, uri, da.UserCode, da.ExpiresIn/60)
|
||||
fmt.Print(banner)
|
||||
slog.Warn("mas_auth: device authorization required", "verification_uri", da.VerificationURI, "user_code", da.UserCode, "expires_in_s", da.ExpiresIn)
|
||||
|
||||
interval := da.Interval
|
||||
if interval <= 0 {
|
||||
interval = 5
|
||||
}
|
||||
deadline := time.Now().Add(time.Duration(da.ExpiresIn) * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Duration(interval) * time.Second):
|
||||
}
|
||||
pending, err := m.pollToken(url.Values{
|
||||
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
|
||||
"device_code": {da.DeviceCode},
|
||||
"client_id": {m.clientID},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !pending {
|
||||
slog.Info("mas_auth: device authorization approved", "device_id", m.deviceID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("device authorization timed out (not approved within %d minutes)", da.ExpiresIn/60)
|
||||
}
|
||||
|
||||
// pollToken hits the token endpoint. Returns pending=true if the user hasn't
|
||||
// approved yet (keep polling); on success it stores the tokens.
|
||||
func (m *masAuth) pollToken(form url.Values) (pending bool, err error) {
|
||||
resp, err := m.http.PostForm(m.tokenEndpoint, form)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("token request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == 200 {
|
||||
return false, m.storeTokenResponse(rb)
|
||||
}
|
||||
var oerr struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(rb, &oerr)
|
||||
switch oerr.Error {
|
||||
case "authorization_pending":
|
||||
return true, nil
|
||||
case "slow_down":
|
||||
return true, nil
|
||||
default:
|
||||
return false, fmt.Errorf("token endpoint error (HTTP %d): %s", resp.StatusCode, string(rb))
|
||||
}
|
||||
}
|
||||
|
||||
// refresh exchanges the stored refresh token for a fresh access token. MAS
|
||||
// rotates the refresh token, so we persist the new one.
|
||||
func (m *masAuth) refresh() error {
|
||||
m.mu.RLock()
|
||||
rt, cid := m.refreshToken, m.clientID
|
||||
m.mu.RUnlock()
|
||||
if rt == "" {
|
||||
return fmt.Errorf("no refresh token")
|
||||
}
|
||||
resp, err := m.http.PostForm(m.tokenEndpoint, url.Values{
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {rt},
|
||||
"client_id": {cid},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("refresh request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("refresh failed (HTTP %d): %s", resp.StatusCode, string(rb))
|
||||
}
|
||||
return m.storeTokenResponse(rb)
|
||||
}
|
||||
|
||||
func (m *masAuth) storeTokenResponse(rb []byte) error {
|
||||
var tr struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(rb, &tr); err != nil {
|
||||
return fmt.Errorf("parse token response: %w", err)
|
||||
}
|
||||
if tr.AccessToken == "" {
|
||||
return fmt.Errorf("token response missing access_token: %s", string(rb))
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.accessToken = tr.AccessToken
|
||||
if tr.RefreshToken != "" {
|
||||
m.refreshToken = tr.RefreshToken
|
||||
}
|
||||
ttl := tr.ExpiresIn
|
||||
if ttl <= 0 {
|
||||
ttl = 300
|
||||
}
|
||||
m.expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
|
||||
m.mu.Unlock()
|
||||
m.save()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *masAuth) expiry() time.Time {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.expiresAt
|
||||
}
|
||||
|
||||
// getJSON GETs a URL and decodes JSON.
|
||||
func (m *masAuth) getJSON(u string, out any) error {
|
||||
resp, err := m.http.Get(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("GET %s: HTTP %d: %s", u, resp.StatusCode, string(b))
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// postForm POSTs a form and decodes a JSON success body (200).
|
||||
func (m *masAuth) postForm(u string, form url.Values, out any) error {
|
||||
resp, err := m.http.PostForm(u, form)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("POST %s: HTTP %d: %s", u, resp.StatusCode, string(rb))
|
||||
}
|
||||
return json.Unmarshal(rb, out)
|
||||
}
|
||||
|
||||
// randomDeviceID mirrors mautrix's device-id style: 10 uppercase letters.
|
||||
func randomDeviceID() string {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
b := make([]byte, 10)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// rand.Read essentially never fails; fall back to a fixed prefix.
|
||||
return "GOGOBEEBOT"
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = alphabet[int(b[i])%len(alphabet)]
|
||||
}
|
||||
return "GB" + string(b[:8])
|
||||
}
|
||||
140
internal/bot/session.go
Normal file
140
internal/bot/session.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/appservice"
|
||||
"maunium.net/go/mautrix/event"
|
||||
)
|
||||
|
||||
// Session is the bot's live Matrix connection plus its event source. It hides
|
||||
// the difference between the two transports the bot can run under:
|
||||
//
|
||||
// - masdevice: a normal Matrix user authenticated via the MAS OAuth device
|
||||
// grant, receiving events over /sync (mautrix DefaultSyncer).
|
||||
// - appservice: a Synapse appservice authenticated by its as_token, receiving
|
||||
// events pushed over the appservice transaction API (mautrix EventProcessor).
|
||||
//
|
||||
// Callers register the same three handlers (message/member/reaction) via
|
||||
// OnEventType and call Run regardless of mode; the plugin layer never knows which
|
||||
// transport is in use.
|
||||
type Session struct {
|
||||
Client *mautrix.Client
|
||||
mode string
|
||||
|
||||
// masdevice
|
||||
syncer *mautrix.DefaultSyncer
|
||||
|
||||
// appservice
|
||||
as *appservice.AppService
|
||||
ep *appservice.EventProcessor
|
||||
presenceOnce sync.Once
|
||||
}
|
||||
|
||||
// EventHandler matches both DefaultSyncer.OnEventType and EventProcessor.On.
|
||||
type EventHandler = func(ctx context.Context, evt *event.Event)
|
||||
|
||||
// NewSession builds a Session for the configured auth mode. Default and
|
||||
// "masdevice" use the MAS device grant; "appservice" uses the transaction model.
|
||||
func NewSession(cfg Config) (*Session, error) {
|
||||
switch cfg.AuthMode {
|
||||
case "appservice":
|
||||
return newAppserviceSession(cfg)
|
||||
case "", "masdevice":
|
||||
return newMASDeviceSession(cfg)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown AUTH_MODE %q (want appservice or masdevice)", cfg.AuthMode)
|
||||
}
|
||||
}
|
||||
|
||||
// newMASDeviceSession wraps the existing device-grant client (NewClient) and its
|
||||
// DefaultSyncer. This is the retained rollback path — unchanged behaviour.
|
||||
func newMASDeviceSession(cfg Config) (*Session, error) {
|
||||
client, err := NewClient(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Session{
|
||||
Client: client,
|
||||
mode: "masdevice",
|
||||
syncer: client.Syncer.(*mautrix.DefaultSyncer),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OnEventType registers a handler for the given event type against whichever
|
||||
// event source backs this session.
|
||||
func (s *Session) OnEventType(evtType event.Type, fn EventHandler) {
|
||||
switch s.mode {
|
||||
case "appservice":
|
||||
s.ep.On(evtType, fn)
|
||||
default:
|
||||
s.syncer.OnEventType(evtType, fn)
|
||||
}
|
||||
}
|
||||
|
||||
// StartPresence begins actively maintaining the bot's Matrix presence, tied to
|
||||
// ctx. In appservice mode it launches the online heartbeat (runPresenceHeartbeat);
|
||||
// masdevice mode relies on the /sync long-poll's implicit presence refresh, so it
|
||||
// is a no-op there. Call this early — before the slow plugin init — so the bot
|
||||
// shows online from boot rather than only once the transaction listener starts
|
||||
// (~2min later). The presence PUT is outbound-only and needs no event handlers, so
|
||||
// starting it ahead of the listener is safe. Idempotent: extra calls do nothing.
|
||||
func (s *Session) StartPresence(ctx context.Context) {
|
||||
if s.mode != "appservice" {
|
||||
return
|
||||
}
|
||||
s.presenceOnce.Do(func() {
|
||||
go s.runPresenceHeartbeat(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// Run blocks, delivering events to registered handlers, until ctx is cancelled.
|
||||
func (s *Session) Run(ctx context.Context) error {
|
||||
switch s.mode {
|
||||
case "appservice":
|
||||
return s.runAppservice(ctx)
|
||||
default:
|
||||
return s.runSync(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop halts the event source. Safe to call once.
|
||||
func (s *Session) Stop() {
|
||||
switch s.mode {
|
||||
case "appservice":
|
||||
if s.ep != nil {
|
||||
s.ep.Stop()
|
||||
}
|
||||
if s.as != nil {
|
||||
s.as.Stop()
|
||||
}
|
||||
default:
|
||||
s.Client.StopSync()
|
||||
}
|
||||
}
|
||||
|
||||
// runSync is the device-grant /sync loop (moved verbatim from main.go): restart
|
||||
// on transient failure, exit on context cancel.
|
||||
func (s *Session) runSync(ctx context.Context) error {
|
||||
for {
|
||||
err := s.Client.SyncWithContext(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return nil // shutdown requested
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("sync stopped, restarting in 5s", "err", err)
|
||||
} else {
|
||||
slog.Warn("sync returned without error, restarting in 5s")
|
||||
}
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,6 +333,30 @@ func runMigrations(d *sql.DB) error {
|
||||
// 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`,
|
||||
// URL link previews now post the page's og:image/twitter:image
|
||||
// thumbnail; cache it alongside the title/description.
|
||||
`ALTER TABLE url_cache ADD COLUMN image_url TEXT NOT NULL DEFAULT ''`,
|
||||
// Tempering (gogobee_engagement_plan.md B1). A magic item's rarity
|
||||
// lives on its registry definition, so an upgraded instance needs
|
||||
// somewhere of its own to record how far it has been pushed. temper
|
||||
// counts rarity steps above the definition's base; effective rarity
|
||||
// is derived at the effect/render/sell boundary, never written back.
|
||||
// DEFAULT 0 is the correct value for every pre-existing row, so this
|
||||
// needs no bootstrap backfill.
|
||||
`ALTER TABLE adventure_inventory ADD COLUMN temper INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE magic_item_equipped ADD COLUMN temper INTEGER NOT NULL DEFAULT 0`,
|
||||
// Revisit R1 (gogobee_revisit_plan.md §R1). Until now "how far along
|
||||
// is this run" and "which room am I standing in" were the same number,
|
||||
// both read off len(visited_nodes). Backtracking splits them: the
|
||||
// path index stops being monotonic, so effort gets its own counter.
|
||||
// DEFAULT 0 is wrong for in-flight rows — bootstrapRoomsTraversed
|
||||
// backfills them from visited_nodes.
|
||||
`ALTER TABLE dnd_zone_run ADD COLUMN rooms_traversed INTEGER NOT NULL DEFAULT 0`,
|
||||
// N3/P4 party combat. Every pre-existing fight is solo, and solo is
|
||||
// exactly roster_size 1, so the DEFAULT is the correct value for every
|
||||
// row and no bootstrap backfill is needed. The column is a read guard:
|
||||
// loadCombatParticipants is skipped entirely when it reads 1.
|
||||
`ALTER TABLE combat_session ADD COLUMN roster_size INTEGER NOT NULL DEFAULT 1`,
|
||||
}
|
||||
for _, stmt := range columnMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
@@ -1124,6 +1148,7 @@ CREATE TABLE IF NOT EXISTS url_cache (
|
||||
url TEXT PRIMARY KEY,
|
||||
title TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
image_url TEXT NOT NULL DEFAULT '',
|
||||
cached_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
@@ -1496,6 +1521,21 @@ CREATE TABLE IF NOT EXISTS arena_stats (
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Arena seasons (gogobee_engagement_plan.md C4). Season standings are DERIVED
|
||||
-- from arena_history.created_at, so arena_stats stays lifetime and no
|
||||
-- quarterly wipe ever runs. Only the awarded titles are archived here, one row
|
||||
-- per (season, kind) — the champion for that quarter, frozen.
|
||||
CREATE TABLE IF NOT EXISTS arena_season_titles (
|
||||
season TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, -- 'earnings' | 'streak'
|
||||
user_id TEXT NOT NULL,
|
||||
value INTEGER NOT NULL,
|
||||
awarded_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (season, kind)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_arena_season_titles_user ON arena_season_titles(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_arena_history_created ON arena_history(created_at);
|
||||
|
||||
-- Rival System
|
||||
CREATE TABLE IF NOT EXISTS adventure_rival_records (
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -1997,6 +2037,24 @@ CREATE TABLE IF NOT EXISTS space_inviter_prompts (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_space_inviter_user ON space_inviter_prompts(user_id);
|
||||
|
||||
-- ── Email nag — collect+verify missing Authentik emails over Matrix DM ─────
|
||||
-- One row per target user (MXID). Verified state survives restarts so the
|
||||
-- startup sweep never re-nags someone already done or mid-flow.
|
||||
CREATE TABLE IF NOT EXISTS email_nag_prompts (
|
||||
user_id TEXT PRIMARY KEY, -- full MXID
|
||||
username TEXT NOT NULL, -- Authentik username == Matrix localpart
|
||||
dm_room_id TEXT NOT NULL,
|
||||
stage TEXT NOT NULL, -- awaiting_email | awaiting_code | done
|
||||
pending_email TEXT,
|
||||
code TEXT,
|
||||
code_expires INTEGER,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
prompt_sent_at INTEGER NOT NULL,
|
||||
verified_email TEXT,
|
||||
verified_at INTEGER,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- ── 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
|
||||
@@ -2027,12 +2085,85 @@ CREATE TABLE IF NOT EXISTS combat_session (
|
||||
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
|
||||
expires_at DATETIME NOT NULL,
|
||||
-- N3/P4: seated player characters, seat 0 (this row) included. 1 == solo,
|
||||
-- which is what every pre-N3 row is, so the DEFAULT needs no backfill. Read
|
||||
-- as a guard: only a roster_size > 1 makes the loader touch
|
||||
-- combat_participant, keeping the solo fight at one query.
|
||||
roster_size INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
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);
|
||||
|
||||
-- ── N3/P4 — party combat: the seats a session row cannot hold ─────────────
|
||||
-- One row per party member from seat 1 up. Seat 0 is the session's own
|
||||
-- user_id/player_hp/statuses_json, so a solo fight writes no rows here and
|
||||
-- reads none: absent == solo, which is why this table needs no backfill.
|
||||
--
|
||||
-- seat: index into the combat roster. 1..N-1; seat 0 is the session.
|
||||
-- hp / hp_max: this member's live pool. Seat 0's lives on combat_session.
|
||||
-- statuses_json: serialized ActorStatuses — the per-character half of the
|
||||
-- effect state (their concentration, their debuffs, their
|
||||
-- once-per-fight one-shots). The enemy's stance and the round
|
||||
-- cursor are fight-scoped and stay on combat_session.
|
||||
CREATE TABLE IF NOT EXISTS combat_participant (
|
||||
session_id TEXT NOT NULL,
|
||||
seat INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
hp INTEGER NOT NULL,
|
||||
hp_max INTEGER NOT NULL,
|
||||
statuses_json TEXT NOT NULL DEFAULT '{}',
|
||||
PRIMARY KEY (session_id, seat)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_combat_participant_user
|
||||
ON combat_participant(user_id);
|
||||
|
||||
-- ── N3/P4 — expedition parties ────────────────────────────────────────────
|
||||
-- Membership for a co-op expedition. The leader's dnd_expedition row stays
|
||||
-- the single source of truth for the shared clock, threat, and supply pool;
|
||||
-- members reference it through here rather than owning a row of their own.
|
||||
-- A solo expedition has no rows: absent == solo, so no backfill is needed.
|
||||
--
|
||||
-- There is deliberately no party_id on dnd_expedition. The expedition_id is
|
||||
-- already the party's identity, and a second key would be a second source of
|
||||
-- truth for "who is in this party" that could disagree with this table.
|
||||
--
|
||||
-- role: 'leader' | 'member'. Exactly one leader per expedition, matching
|
||||
-- dnd_expedition.user_id; enforced in code, not by constraint (the
|
||||
-- same discipline combat_session uses for one-active-per-user).
|
||||
CREATE TABLE IF NOT EXISTS expedition_party (
|
||||
expedition_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (expedition_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_expedition_party_user
|
||||
ON expedition_party(user_id);
|
||||
|
||||
-- ── N3/P6b — pending party invites ────────────────────────────────────────
|
||||
-- An invite the leader has sent and the invitee has not yet answered. It is
|
||||
-- deleted on accept, on decline, and when the expedition ends; it expires on
|
||||
-- read past expeditionInviteTTL, so a forgotten invite cannot pin an
|
||||
-- expedition's autopilot forever.
|
||||
--
|
||||
-- Absent == nobody has been asked, which is true of every expedition that
|
||||
-- existed before N3, so there is nothing to backfill.
|
||||
--
|
||||
-- While any row here names an expedition, the autopilot will not walk it: the
|
||||
-- leader must not be dragged into a boss room while their friend is still
|
||||
-- reading the invite DM.
|
||||
CREATE TABLE IF NOT EXISTS expedition_invite (
|
||||
expedition_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
invited_by TEXT NOT NULL,
|
||||
invited_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (expedition_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_expedition_invite_user
|
||||
ON expedition_invite(user_id);
|
||||
`
|
||||
|
||||
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||
@@ -2041,15 +2172,15 @@ func SeedSchedulerDefaults(d *sql.DB) error {
|
||||
name string
|
||||
cron string
|
||||
}{
|
||||
{"prefetch", "5 0 * * *"}, // 00:05 daily
|
||||
{"maintenance", "0 3 * * *"}, // 03:00 daily
|
||||
{"wotd", "0 8 * * *"}, // 08:00 daily
|
||||
{"holidays", "0 7 * * *"}, // 07:00 daily
|
||||
{"releases", "0 9 * * 1"}, // 09:00 Monday
|
||||
{"birthday_check", "0 6 * * *"}, // 06:00 daily
|
||||
{"anime_releases", "0 10 * * *"},// 10:00 daily
|
||||
{"movie_releases", "0 11 * * *"},// 11:00 daily
|
||||
{"concert_digest", "0 12 * * 0"},// 12:00 Sunday
|
||||
{"prefetch", "5 0 * * *"}, // 00:05 daily
|
||||
{"maintenance", "0 3 * * *"}, // 03:00 daily
|
||||
{"wotd", "0 8 * * *"}, // 08:00 daily
|
||||
{"holidays", "0 7 * * *"}, // 07:00 daily
|
||||
{"releases", "0 9 * * 1"}, // 09:00 Monday
|
||||
{"birthday_check", "0 6 * * *"}, // 06:00 daily
|
||||
{"anime_releases", "0 10 * * *"}, // 10:00 daily
|
||||
{"movie_releases", "0 11 * * *"}, // 11:00 daily
|
||||
{"concert_digest", "0 12 * * 0"}, // 12:00 Sunday
|
||||
}
|
||||
|
||||
stmt, err := d.Prepare(`INSERT OR IGNORE INTO scheduler_config (job_name, cron_expr) VALUES (?, ?)`)
|
||||
|
||||
@@ -2,6 +2,7 @@ package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
@@ -1137,9 +1138,160 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
return false
|
||||
},
|
||||
},
|
||||
|
||||
// ── Expeditions ──
|
||||
// All passive: they read the expedition rows the extract path already
|
||||
// writes. Tier comes from the zone registry, not a column, so the
|
||||
// checks map zone_id → Tier in Go.
|
||||
{
|
||||
ID: "expedition_clear_t1", Name: "Off the Porch", Description: "Cleared your first Tier 1 zone. It only gets worse from here.",
|
||||
Emoji: "🗺️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 1) },
|
||||
},
|
||||
{
|
||||
ID: "expedition_clear_t2", Name: "Reasonably Lost", Description: "Cleared a Tier 2 zone. The map stops being a suggestion.",
|
||||
Emoji: "🗺️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 2) },
|
||||
},
|
||||
{
|
||||
ID: "expedition_clear_t3", Name: "Deep Enough", Description: "Cleared a Tier 3 zone. Someone should have stopped you.",
|
||||
Emoji: "🗺️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 3) },
|
||||
},
|
||||
{
|
||||
ID: "expedition_clear_t4", Name: "Past the Warnings", Description: "Cleared a Tier 4 zone. The signs were quite clear.",
|
||||
Emoji: "🗺️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 4) },
|
||||
},
|
||||
{
|
||||
ID: "expedition_clear_t5", Name: "Where the Map Ends", Description: "Cleared a Tier 5 zone. There was nothing left to be brave about.",
|
||||
Emoji: "🗺️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return clearedAnyZoneOfTier(d, u, 5) },
|
||||
},
|
||||
{
|
||||
ID: "expedition_master_t1", Name: "Warren Cartography", Description: "Cleared every Tier 1 zone. Thorough.",
|
||||
Emoji: "🧭",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 1) },
|
||||
},
|
||||
{
|
||||
ID: "expedition_master_t2", Name: "No Stone Unturned", Description: "Cleared every Tier 2 zone.",
|
||||
Emoji: "🧭",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 2) },
|
||||
},
|
||||
{
|
||||
ID: "expedition_master_t3", Name: "Completionist's Limp", Description: "Cleared every Tier 3 zone. Walk it off.",
|
||||
Emoji: "🧭",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 3) },
|
||||
},
|
||||
{
|
||||
ID: "expedition_master_t4", Name: "Nowhere Left to Go Wrong", Description: "Cleared every Tier 4 zone.",
|
||||
Emoji: "🧭",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 4) },
|
||||
},
|
||||
{
|
||||
ID: "expedition_master_t5", Name: "The Long Way Down", Description: "Cleared every Tier 5 zone. Both of them. All the way.",
|
||||
Emoji: "🧭",
|
||||
Check: func(d *sql.DB, u id.UserID) bool { return clearedEveryZoneOfTier(d, u, 5) },
|
||||
},
|
||||
{
|
||||
ID: "expedition_quiet_clear", Name: "Nobody Saw Anything", Description: "Cleared a zone without threat ever passing 50. You were never here.",
|
||||
Emoji: "🌑",
|
||||
Check: clearedUnderThreat50,
|
||||
},
|
||||
{
|
||||
ID: "temper_legendary", Name: "Hot Enough", Description: "Tempered an item all the way to Legendary. The blacksmith needed a moment.",
|
||||
Emoji: "🔨",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by the temper path on reaching Legendary
|
||||
return false
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ── Expedition achievement helpers ──────────────────────────────────────────
|
||||
|
||||
// clearedZoneIDs returns the zones this player has beaten outright. Boss-
|
||||
// defeated gates out extractions that merely ended in 'complete'.
|
||||
func clearedZoneIDs(d *sql.DB, userID id.UserID) map[ZoneID]bool {
|
||||
out := map[ZoneID]bool{}
|
||||
rows, err := d.Query(
|
||||
`SELECT DISTINCT zone_id FROM dnd_expedition
|
||||
WHERE user_id = ? AND status = ? AND boss_defeated = 1`,
|
||||
string(userID), ExpeditionStatusComplete)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var z string
|
||||
if err := rows.Scan(&z); err != nil {
|
||||
return out
|
||||
}
|
||||
out[ZoneID(z)] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clearedAnyZoneOfTier(d *sql.DB, userID id.UserID, tier ZoneTier) bool {
|
||||
cleared := clearedZoneIDs(d, userID)
|
||||
for zid := range cleared {
|
||||
if z, ok := getZone(zid); ok && z.Tier == tier {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clearedEveryZoneOfTier(d *sql.DB, userID id.UserID, tier ZoneTier) bool {
|
||||
cleared := clearedZoneIDs(d, userID)
|
||||
seen := 0
|
||||
for _, z := range allZones() {
|
||||
if z.Tier != tier {
|
||||
continue
|
||||
}
|
||||
if !cleared[z.ID] {
|
||||
return false
|
||||
}
|
||||
seen++
|
||||
}
|
||||
return seen > 0
|
||||
}
|
||||
|
||||
// clearedUnderThreat50 looks for a cleared expedition whose peak threat never
|
||||
// crossed 50 — the same max_threat_seen sample the Patient Zero milestone
|
||||
// reads, so the two reward the same play.
|
||||
//
|
||||
// The key's *presence* is required, not just a low value. recordMaxThreat only
|
||||
// samples on day rollover and never stores a zero, so an absent key means the
|
||||
// expedition has no threat history at all (a same-day clear) — which is not
|
||||
// evidence that threat stayed low.
|
||||
func clearedUnderThreat50(d *sql.DB, userID id.UserID) bool {
|
||||
rows, err := d.Query(
|
||||
`SELECT region_state FROM dnd_expedition
|
||||
WHERE user_id = ? AND status = ? AND boss_defeated = 1`,
|
||||
string(userID), ExpeditionStatusComplete)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var raw string
|
||||
if err := rows.Scan(&raw); err != nil {
|
||||
return false
|
||||
}
|
||||
var state map[string]any
|
||||
if json.Unmarshal([]byte(raw), &state) != nil {
|
||||
continue
|
||||
}
|
||||
peak, ok := state[regionStateMaxThreatKey].(float64)
|
||||
if ok && int(peak) < 50 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// statGTE checks if a user_stats column is >= threshold.
|
||||
var allowedStatColumns = map[string]bool{
|
||||
"total_messages": true,
|
||||
|
||||
184
internal/plugin/achievements_expedition_test.go
Normal file
184
internal/plugin/achievements_expedition_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// expeditionRowSeq gives each synthetic row a unique primary key; two rows can
|
||||
// otherwise differ only in boss_defeated.
|
||||
var expeditionRowSeq int
|
||||
|
||||
// insertClearedExpedition writes an expedition row directly. status and
|
||||
// boss_defeated are the two gates the achievement checks read.
|
||||
func insertClearedExpedition(t *testing.T, user id.UserID, zoneID ZoneID, status string, bossDefeated int, regionState string) {
|
||||
t.Helper()
|
||||
expeditionRowSeq++
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, status, boss_defeated, region_state)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
fmt.Sprintf("exp-%d", expeditionRowSeq), string(user), string(zoneID), status, bossDefeated, regionState)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// zonesOfTier lists the zone ids at a tier, straight from the registry, so the
|
||||
// test tracks content edits instead of hardcoding a zone roster.
|
||||
func zonesOfTier(tier ZoneTier) []ZoneID {
|
||||
var out []ZoneID
|
||||
for _, z := range allZones() {
|
||||
if z.Tier == tier {
|
||||
out = append(out, z.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newAchievementTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
func TestExpeditionClearAchievements(t *testing.T) {
|
||||
newAchievementTestDB(t)
|
||||
d := db.Get()
|
||||
user := id.UserID("@clears:test.invalid")
|
||||
|
||||
t1 := zonesOfTier(1)
|
||||
if len(t1) < 2 {
|
||||
t.Skipf("tier 1 has %d zones; test wants at least 2", len(t1))
|
||||
}
|
||||
|
||||
if clearedAnyZoneOfTier(d, user, 1) {
|
||||
t.Error("clean slate reported a tier-1 clear")
|
||||
}
|
||||
|
||||
// A boss-defeated complete run is the only thing that counts.
|
||||
insertClearedExpedition(t, user, t1[0], ExpeditionStatusComplete, 1, "{}")
|
||||
if !clearedAnyZoneOfTier(d, user, 1) {
|
||||
t.Error("cleared tier-1 zone not detected")
|
||||
}
|
||||
if clearedEveryZoneOfTier(d, user, 1) {
|
||||
t.Error("one of several tier-1 zones counted as all of them")
|
||||
}
|
||||
if clearedAnyZoneOfTier(d, user, 2) {
|
||||
t.Error("a tier-1 clear leaked into tier 2")
|
||||
}
|
||||
|
||||
for _, z := range t1[1:] {
|
||||
insertClearedExpedition(t, user, z, ExpeditionStatusComplete, 1, "{}")
|
||||
}
|
||||
if !clearedEveryZoneOfTier(d, user, 1) {
|
||||
t.Error("clearing every tier-1 zone did not satisfy the master check")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpeditionClearIgnoresUnfinishedRuns: an abandoned run, or one where the
|
||||
// player walked out without killing the boss, must not count as a clear.
|
||||
func TestExpeditionClearIgnoresUnfinishedRuns(t *testing.T) {
|
||||
newAchievementTestDB(t)
|
||||
d := db.Get()
|
||||
user := id.UserID("@partial:test.invalid")
|
||||
|
||||
t2 := zonesOfTier(2)
|
||||
if len(t2) == 0 {
|
||||
t.Skip("no tier-2 zones")
|
||||
}
|
||||
|
||||
insertClearedExpedition(t, user, t2[0], ExpeditionStatusAbandoned, 1, "{}")
|
||||
if clearedAnyZoneOfTier(d, user, 2) {
|
||||
t.Error("an abandoned expedition counted as a clear")
|
||||
}
|
||||
|
||||
insertClearedExpedition(t, user, t2[0], ExpeditionStatusComplete, 0, "{}")
|
||||
if clearedAnyZoneOfTier(d, user, 2) {
|
||||
t.Error("a complete run with the boss alive counted as a clear")
|
||||
}
|
||||
|
||||
insertClearedExpedition(t, user, t2[0], ExpeditionStatusComplete, 1, "{}")
|
||||
if !clearedAnyZoneOfTier(d, user, 2) {
|
||||
t.Error("a real clear was not detected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearedUnderThreat50 pins the presence-vs-zero distinction. recordMaxThreat
|
||||
// samples only on day rollover and never writes a zero, so an empty region_state
|
||||
// means "no threat history", not "threat stayed at zero".
|
||||
func TestClearedUnderThreat50(t *testing.T) {
|
||||
newAchievementTestDB(t)
|
||||
d := db.Get()
|
||||
|
||||
zones := zonesOfTier(1)
|
||||
if len(zones) == 0 {
|
||||
t.Skip("no tier-1 zones")
|
||||
}
|
||||
z := zones[0]
|
||||
|
||||
quiet := id.UserID("@quiet:test.invalid")
|
||||
loud := id.UserID("@loud:test.invalid")
|
||||
sameDay := id.UserID("@sameday:test.invalid")
|
||||
|
||||
insertClearedExpedition(t, quiet, z, ExpeditionStatusComplete, 1, `{"max_threat_seen":30}`)
|
||||
if !clearedUnderThreat50(d, quiet) {
|
||||
t.Error("peak threat 30 did not satisfy the under-50 check")
|
||||
}
|
||||
|
||||
insertClearedExpedition(t, loud, z, ExpeditionStatusComplete, 1, `{"max_threat_seen":60}`)
|
||||
if clearedUnderThreat50(d, loud) {
|
||||
t.Error("peak threat 60 satisfied the under-50 check")
|
||||
}
|
||||
|
||||
insertClearedExpedition(t, sameDay, z, ExpeditionStatusComplete, 1, "{}")
|
||||
if clearedUnderThreat50(d, sameDay) {
|
||||
t.Error("an expedition with no threat samples was awarded the quiet clear")
|
||||
}
|
||||
|
||||
// Exactly 50 is not under 50.
|
||||
edge := id.UserID("@edge:test.invalid")
|
||||
insertClearedExpedition(t, edge, z, ExpeditionStatusComplete, 1, `{"max_threat_seen":50}`)
|
||||
if clearedUnderThreat50(d, edge) {
|
||||
t.Error("peak threat of exactly 50 counted as under 50")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpeditionAchievementIDsRegistered guards the wiring: every expedition
|
||||
// achievement referenced by the tier helpers must exist in the registry, and a
|
||||
// tier with no zones must not ship a master achievement nobody can earn.
|
||||
func TestExpeditionAchievementIDsRegistered(t *testing.T) {
|
||||
p := &AchievementsPlugin{}
|
||||
defs := p.buildAchievements()
|
||||
ids := map[string]bool{}
|
||||
for _, a := range defs {
|
||||
if ids[a.ID] {
|
||||
t.Errorf("duplicate achievement id %q", a.ID)
|
||||
}
|
||||
ids[a.ID] = true
|
||||
}
|
||||
|
||||
for tier := ZoneTier(1); tier <= 5; tier++ {
|
||||
if len(zonesOfTier(tier)) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, prefix := range []string{"expedition_clear_t", "expedition_master_t"} {
|
||||
id := prefix + string(rune('0'+tier))
|
||||
if !ids[id] {
|
||||
t.Errorf("tier %d has zones but %q is not registered", tier, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"expedition_quiet_clear", "temper_legendary"} {
|
||||
if !ids[id] {
|
||||
t.Errorf("%q is not registered", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,10 +221,9 @@ func TestAdv2Scenario_ExpeditionCryptValdris(t *testing.T) {
|
||||
t.Errorf("expected outfitting to debit coins (%.2f → %.2f)", balBefore, balAfter)
|
||||
}
|
||||
|
||||
// Backdate start so deliverBriefing's same-day guard passes.
|
||||
if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
// Backdate start so deliverBriefing's same-day guard passes, and to
|
||||
// before eventAnchoredCutoff so the legacy mutator path still fires.
|
||||
rewindToLegacyAnchor(t, exp)
|
||||
|
||||
// Drive 3 daily briefings — verify supply burn + day advance.
|
||||
now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC)
|
||||
@@ -305,7 +304,7 @@ func TestAdv2Scenario_HarvestForestShadows(t *testing.T) {
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
|
||||
// Forage-friendly expedition.
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start forest_shadows"); err != nil {
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start forest_shadows lean"); err != nil {
|
||||
t.Fatalf("expedition start: %v", err)
|
||||
}
|
||||
exp, _ := getActiveExpedition(uid)
|
||||
|
||||
@@ -21,13 +21,14 @@ import (
|
||||
|
||||
type AdventurePlugin struct {
|
||||
Base
|
||||
euro *EuroPlugin
|
||||
xp *XPPlugin
|
||||
achievements *AchievementsPlugin
|
||||
mu sync.Mutex
|
||||
euro *EuroPlugin
|
||||
xp *XPPlugin
|
||||
achievements *AchievementsPlugin
|
||||
mu sync.Mutex
|
||||
dmToPlayer map[id.RoomID]id.UserID
|
||||
pending sync.Map // userID string -> *advPendingInteraction
|
||||
userLocks sync.Map // userID string -> *sync.Mutex
|
||||
expLocks sync.Map // expedition ID string -> *sync.Mutex
|
||||
dmRemindedDate sync.Map // userID string -> "2006-01-02" date string
|
||||
dmMenuSentAt sync.Map // userID string -> time.Time (last time actionable menu was DM'd)
|
||||
arenaDeadlines sync.Map // userID string -> time.Time (auto-cashout deadline)
|
||||
@@ -37,8 +38,8 @@ type AdventurePlugin struct {
|
||||
hospitalNudges sync.Map // userID string -> time.Time (when to send nudge)
|
||||
craftResults sync.Map // userID string -> []CraftResult (pending craft results for narrative)
|
||||
treasureUndo sync.Map // userID string -> *advTreasureUndoToken (10-min auto-swap reversal)
|
||||
morningHour int
|
||||
summaryHour int
|
||||
morningHour int
|
||||
summaryHour int
|
||||
|
||||
// stopCh is closed by Stop() to signal all background tickers
|
||||
// (morning/summary/midnight/event/rival/robbie/hospital/mortgage/
|
||||
@@ -70,6 +71,16 @@ func (p *AdventurePlugin) advUserLock(userID id.UserID) *sync.Mutex {
|
||||
return val.(*sync.Mutex)
|
||||
}
|
||||
|
||||
// advExpeditionLock returns a per-expedition mutex, for state a whole party
|
||||
// shares rather than one player. advUserLock cannot stand in: it is keyed by
|
||||
// sender, so two members racing the same expedition row take two different
|
||||
// mutexes and exclude nobody. Handlers run one goroutine per event, so any
|
||||
// read-modify-write of the shared supply pool needs this.
|
||||
func (p *AdventurePlugin) advExpeditionLock(expID string) *sync.Mutex {
|
||||
val, _ := p.expLocks.LoadOrStore(expID, &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
|
||||
@@ -177,6 +188,7 @@ func (p *AdventurePlugin) Init() error {
|
||||
// through the L4-L5h dual-write soak (fresh deploys, restored backups).
|
||||
bootstrapPlayerMetaFromLegacy()
|
||||
bootstrapRestoreExpeditionStreakDecay()
|
||||
bootstrapRoomsTraversed()
|
||||
|
||||
// Rehydrate DM room mappings for existing characters
|
||||
chars, err := loadAllAdvCharacters()
|
||||
@@ -220,6 +232,16 @@ func (p *AdventurePlugin) Init() error {
|
||||
// migration walks dnd_character once at startup; idempotent via
|
||||
// JobCompleted gate.
|
||||
bootstrapPhase5BHPRefresh()
|
||||
// J3 D8-d-fix: caster HP lift (casterHPMult in dnd.go). Refresh
|
||||
// existing caster rows once at startup so the lift reaches live
|
||||
// players without waiting for level-up.
|
||||
bootstrapCasterHPRefresh()
|
||||
// 2026-06-18 caster-aid: backfill default spells that postdate a
|
||||
// character's roll (ensureSpellsForCharacter only seeds an empty book),
|
||||
// and a one-off pet gift for an endgame player who never got the morning
|
||||
// arrival roll. Both idempotent via JobCompleted gates.
|
||||
bootstrapCasterSpellBackfill()
|
||||
bootstrapGrantStarterPet()
|
||||
// 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
|
||||
@@ -279,6 +301,12 @@ func (p *AdventurePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
// ── Message Dispatch ─────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
// D4-b: lazy morning briefing. When the 06:00 UTC ticker skips an idle
|
||||
// player's event-anchored expedition, the briefing fires here on their
|
||||
// next inbound message. Fast-paths to a no-op for users with no active
|
||||
// expedition.
|
||||
p.maybeDeliverDeferredBriefing(ctx.Sender, time.Now().UTC())
|
||||
|
||||
// 0. D&D layer commands (Phase 1 — work in rooms and DMs)
|
||||
if p.IsCommand(ctx.Body, "setup") {
|
||||
return p.handleDnDSetupCmd(ctx, p.GetArgs(ctx.Body, "setup"))
|
||||
@@ -325,6 +353,9 @@ 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, "revisit") {
|
||||
return p.handleRevisitCmd(ctx, p.GetArgs(ctx.Body, "revisit"))
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "fight") {
|
||||
return p.handleFightCmd(ctx)
|
||||
}
|
||||
@@ -468,6 +499,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
||||
return p.handleRepairAllCmd(ctx)
|
||||
case strings.HasPrefix(lower, "repair "):
|
||||
return p.handleRepairSlotCmd(ctx, strings.TrimSpace(args[7:]))
|
||||
case lower == "temper":
|
||||
return p.handleTemperCmd(ctx)
|
||||
case lower == "boost":
|
||||
return p.handleBoostCmd(ctx)
|
||||
case lower == "recipes":
|
||||
@@ -502,6 +535,7 @@ const advHelpText = `**Adventure Commands**
|
||||
` + "`!adventure blacksmith`" + ` — Visit the blacksmith (view repair costs)
|
||||
` + "`!adventure repair all`" + ` — Repair all damaged equipment
|
||||
` + "`!adventure repair <slot>`" + ` — Repair a specific slot
|
||||
` + "`!adventure temper`" + ` — Push a magic item one rarity step higher (blacksmith)
|
||||
` + "`!adventure recipes`" + ` — List crafting recipes known at your current Foraging level
|
||||
` + "`!adventure mastery`" + ` — Per-slot equipment mastery progress and active bonus
|
||||
` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps
|
||||
@@ -691,7 +725,10 @@ func (p *AdventurePlugin) handleSellCmd(ctx MessageContext, args string) error {
|
||||
} else {
|
||||
result = p.advSellItem(ctx.Sender, args)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, result)
|
||||
err = p.SendDM(ctx.Sender, result)
|
||||
// N1/A6 — a sale at Thom's is a moment the player is present and reading.
|
||||
p.maybeFireAnchoredEvent(ctx.Sender, advEventChanceSell)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleInventoryCmd(ctx MessageContext) error {
|
||||
@@ -834,6 +871,10 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
||||
return p.resolveBlacksmithSlotChoice(ctx, interaction)
|
||||
case "blacksmith_confirm":
|
||||
return p.resolveBlacksmithConfirm(ctx, interaction)
|
||||
case "temper_pick":
|
||||
return p.resolveTemperPick(ctx, interaction)
|
||||
case "temper_confirm":
|
||||
return p.resolveTemperConfirm(ctx, interaction)
|
||||
case "shop_category":
|
||||
return p.resolveShopCategoryChoice(ctx, interaction)
|
||||
case "shop_item":
|
||||
@@ -853,7 +894,7 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
||||
case "pet_type":
|
||||
return p.resolvePetType(ctx)
|
||||
case "pet_name":
|
||||
return p.resolvePetName(ctx)
|
||||
return p.resolvePetName(ctx, interaction)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1056,13 +1097,19 @@ func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharact
|
||||
|
||||
// ── Treasure Drop Check ─────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCharacter, loc *AdvLocation) {
|
||||
drop, roll, rate := rollAdvTreasureDropDetailed(loc.Tier, userID, p.chatLevel(userID))
|
||||
// checkTreasureDrop rolls the treasure table for one earned moment. weight
|
||||
// scales the drop rate by how big that moment was (see advTreasureWeight*).
|
||||
func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCharacter, loc *AdvLocation, weight float64) {
|
||||
drop, roll, rate := rollAdvTreasureDropDetailed(loc.Tier, userID, p.chatLevel(userID), weight)
|
||||
if drop == nil {
|
||||
// Near-miss feedback: when the roll was within 2× of the drop rate,
|
||||
// tell the player they almost got it. Treasure rates are 0.15–1.5%
|
||||
// so without this signal the system feels invisible.
|
||||
if rate > 0 && roll < rate*2 {
|
||||
//
|
||||
// Only for weighted moments (boss/elite/zone clear). A standard kill
|
||||
// fires dozens of times a day on autopilot, and a near-miss DM on 3%
|
||||
// of them turns the signal into noise.
|
||||
if weight > 1 && rate > 0 && roll < rate*2 {
|
||||
p.SendDM(userID, fmt.Sprintf("🎁 *Treasure: just missed* — rolled %.2f%% against %.2f%% drop chance.",
|
||||
roll*100, rate*100))
|
||||
}
|
||||
@@ -1522,4 +1569,3 @@ func (p *AdventurePlugin) handleBoostCmd(ctx MessageContext) error {
|
||||
advSetBoost(true)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **enabled**! All adventure XP and loot values are doubled.")
|
||||
}
|
||||
|
||||
|
||||
@@ -642,7 +642,7 @@ type AdvActionResult struct {
|
||||
FlavorKey string
|
||||
EquipBroken []EquipmentSlot
|
||||
NearDeath bool
|
||||
StreakBonus int
|
||||
StreakBonus int
|
||||
CombatLog *CombatResult
|
||||
// MasteryCrossings records equipment slots whose ActionsUsed crossed a
|
||||
// mastery threshold (advMasteryThresholds) on this action. The caller
|
||||
|
||||
@@ -116,12 +116,12 @@ func loadAdvDailyActivity(date string) (map[id.UserID][]AdvDailyActivity, error)
|
||||
}
|
||||
for rows.Next() {
|
||||
var (
|
||||
uid, zoneID string
|
||||
visitedJSON string
|
||||
totalRooms int
|
||||
abandoned, bossDefeated int
|
||||
completedAt *time.Time
|
||||
lastAction time.Time
|
||||
uid, zoneID string
|
||||
visitedJSON string
|
||||
totalRooms int
|
||||
abandoned, bossDefeated int
|
||||
completedAt *time.Time
|
||||
lastAction time.Time
|
||||
)
|
||||
if err := rows.Scan(&uid, &zoneID, &visitedJSON, &totalRooms,
|
||||
&abandoned, &bossDefeated, &completedAt, &lastAction); err != nil {
|
||||
|
||||
@@ -16,20 +16,20 @@ import (
|
||||
// ── Arena Run State ─────────────────────────────────────────────────────────
|
||||
|
||||
type ArenaRun struct {
|
||||
ID int64
|
||||
UserID id.UserID
|
||||
RoomID id.RoomID
|
||||
StartTier int
|
||||
Tier int
|
||||
Round int
|
||||
Status string // "active", "awaiting", "completed", "dead", "cashed_out"
|
||||
Earnings int64 // session total (multiplied euros accumulated across completed tiers)
|
||||
TierEarnings int64 // current tier's raw earnings (reset each tier)
|
||||
XPAccumulated int // session XP accumulator (raw, multiplied at payout)
|
||||
RoundsSurvived int
|
||||
LastMonster string
|
||||
StartedAt time.Time
|
||||
EndedAt *time.Time
|
||||
ID int64
|
||||
UserID id.UserID
|
||||
RoomID id.RoomID
|
||||
StartTier int
|
||||
Tier int
|
||||
Round int
|
||||
Status string // "active", "awaiting", "completed", "dead", "cashed_out"
|
||||
Earnings int64 // session total (multiplied euros accumulated across completed tiers)
|
||||
TierEarnings int64 // current tier's raw earnings (reset each tier)
|
||||
XPAccumulated int // session XP accumulator (raw, multiplied at payout)
|
||||
RoundsSurvived int
|
||||
LastMonster string
|
||||
StartedAt time.Time
|
||||
EndedAt *time.Time
|
||||
}
|
||||
|
||||
// ── Command Dispatch ────────────────────────────────────────────────────────
|
||||
@@ -304,13 +304,17 @@ func (p *AdventurePlugin) handleArenaStats(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, renderArenaPersonalStats(displayName, wins, losses, stats))
|
||||
}
|
||||
|
||||
// handleArenaLeaderboard shows the current season's standings (C4). Lifetime
|
||||
// totals stay reachable via `!arena stats`.
|
||||
func (p *AdventurePlugin) handleArenaLeaderboard(ctx MessageContext) error {
|
||||
entries, err := loadArenaLeaderboard()
|
||||
now := time.Now().UTC()
|
||||
start, end := arenaSeasonBounds(now)
|
||||
entries, err := loadArenaSeasonLeaderboard(start, end)
|
||||
if err != nil {
|
||||
slog.Error("arena: failed to load leaderboard", "err", err)
|
||||
slog.Error("arena: failed to load season leaderboard", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load arena leaderboard.")
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, renderArenaLeaderboard(entries))
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, renderArenaLeaderboard(arenaSeasonKey(now), entries))
|
||||
}
|
||||
|
||||
// ── Combat Resolution ───────────────────────────────────────────────────────
|
||||
@@ -624,7 +628,10 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun,
|
||||
text += fmt.Sprintf("\n\n🎉 **Combat Level %d!**", newLevel)
|
||||
}
|
||||
|
||||
return p.SendDM(userID, text)
|
||||
err := p.SendDM(userID, text)
|
||||
// N1/A6 — cashing out is the third mid-day event anchor.
|
||||
p.maybeFireAnchoredEvent(userID, advEventChanceArena)
|
||||
return err
|
||||
}
|
||||
|
||||
// arenaProcessBail handles bail payout (called from handleArenaBail or countdown).
|
||||
@@ -1008,8 +1015,8 @@ func loadArenaPersonalStats(userID id.UserID) *ArenaPersonalStats {
|
||||
|
||||
type ArenaGearSet struct {
|
||||
Tier int
|
||||
SetKey string // DB key: "bloodied", "ironclad", etc.
|
||||
SetName string // Display: "Bloodied", "Ironclad", etc.
|
||||
SetKey string // DB key: "bloodied", "ironclad", etc.
|
||||
SetName string // Display: "Bloodied", "Ironclad", etc.
|
||||
HelmetName string
|
||||
Description string
|
||||
DropRate float64
|
||||
|
||||
@@ -138,12 +138,12 @@ func TestArenaBossOutcome_WinSurfacesStagedLogAndBossDeath(t *testing.T) {
|
||||
|
||||
victoryHeadline := "🏆 **" + monster.Name + "** falls (HP " + "60" + "→" + "42" + " / 60)."
|
||||
outcome := renderBossOutcome(BossOutcomeInputs{
|
||||
ZoneID: ZoneArena,
|
||||
RunID: "arena-stagedlog-test",
|
||||
RoomIdx: 11,
|
||||
Monster: monster,
|
||||
Result: result,
|
||||
PreHP: 60, PostHP: 42, MaxHP: 60,
|
||||
ZoneID: ZoneArena,
|
||||
RunID: "arena-stagedlog-test",
|
||||
RoomIdx: 11,
|
||||
Monster: monster,
|
||||
Result: result,
|
||||
PreHP: 60, PostHP: 42, MaxHP: 60,
|
||||
PhaseTwoAt: arenaBossPhaseTwoAt(1),
|
||||
Nat20s: 1,
|
||||
Nat1s: 0,
|
||||
|
||||
@@ -126,21 +126,21 @@ func renderArenaStatus(run *ArenaRun) string {
|
||||
// ── Leaderboard ─────────────────────────────────────────────────────────────
|
||||
|
||||
type ArenaLeaderboardEntry struct {
|
||||
DisplayName string
|
||||
TotalEarnings int64
|
||||
HighestTier int
|
||||
DisplayName string
|
||||
TotalEarnings int64
|
||||
HighestTier int
|
||||
Tier5Completions int
|
||||
TotalRuns int
|
||||
TotalDeaths int
|
||||
TotalRuns int
|
||||
TotalDeaths int
|
||||
}
|
||||
|
||||
func renderArenaLeaderboard(entries []ArenaLeaderboardEntry) string {
|
||||
func renderArenaLeaderboard(season string, entries []ArenaLeaderboardEntry) string {
|
||||
if len(entries) == 0 {
|
||||
return "⚔️ **Arena Leaderboard**\n\nNo arena runs recorded yet. Be the first."
|
||||
return fmt.Sprintf("⚔️ **Arena Leaderboard — Season %s**\n\nNobody has entered the arena this season. Be the first.", season)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("⚔️ **Arena Leaderboard**\n\n")
|
||||
b.WriteString(fmt.Sprintf("⚔️ **Arena Leaderboard — Season %s**\n\n", season))
|
||||
|
||||
medals := []string{"🥇", "🥈", "🥉"}
|
||||
for i, e := range entries {
|
||||
@@ -157,6 +157,7 @@ func renderArenaLeaderboard(entries []ArenaLeaderboardEntry) string {
|
||||
b.WriteString(fmt.Sprintf("%s **%s** — €%d earned | %s | %d runs | %d deaths\n",
|
||||
prefix, e.DisplayName, e.TotalEarnings, tierLabel, e.TotalRuns, e.TotalDeaths))
|
||||
}
|
||||
b.WriteString("\n_Season standings. `!arena stats` for your lifetime record._")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
235
internal/plugin/adventure_arena_season.go
Normal file
235
internal/plugin/adventure_arena_season.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Arena seasons ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Quarterly standings (gogobee_engagement_plan.md C4). The plan called for a
|
||||
// quarterly *reset* of arena_stats; this derives season standings from
|
||||
// arena_history.created_at instead. Same player-visible effect — the board
|
||||
// clears every quarter — but lifetime totals survive for `!arena stats`, and
|
||||
// there is no destructive job that can fire twice or half-way.
|
||||
//
|
||||
// Season titles are archived to their own table rather than player_meta.title:
|
||||
// that column already carries the Survivalist milestone, and a season champion
|
||||
// overwriting someone's expedition title would silently destroy it.
|
||||
|
||||
// arenaSeasonTitleKinds are the two crowns awarded per season.
|
||||
const (
|
||||
arenaTitleEarnings = "earnings"
|
||||
arenaTitleStreak = "streak"
|
||||
)
|
||||
|
||||
// arenaSeasonKey names the quarter containing t, e.g. "2026-Q3".
|
||||
func arenaSeasonKey(t time.Time) string {
|
||||
t = t.UTC()
|
||||
return fmt.Sprintf("%d-Q%d", t.Year(), (int(t.Month())-1)/3+1)
|
||||
}
|
||||
|
||||
// arenaSeasonStart is the first instant of the quarter containing t.
|
||||
func arenaSeasonStart(t time.Time) time.Time {
|
||||
t = t.UTC()
|
||||
firstMonth := time.Month(((int(t.Month())-1)/3)*3 + 1)
|
||||
return time.Date(t.Year(), firstMonth, 1, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// arenaSeasonBounds returns [start, end) for the quarter containing t.
|
||||
func arenaSeasonBounds(t time.Time) (time.Time, time.Time) {
|
||||
start := arenaSeasonStart(t)
|
||||
return start, start.AddDate(0, 3, 0)
|
||||
}
|
||||
|
||||
// previousArenaSeason returns the key and bounds of the quarter before t's.
|
||||
func previousArenaSeason(t time.Time) (string, time.Time, time.Time) {
|
||||
prev := arenaSeasonStart(t).AddDate(0, -1, 0) // any instant inside the prior quarter
|
||||
start, end := arenaSeasonBounds(prev)
|
||||
return arenaSeasonKey(prev), start, end
|
||||
}
|
||||
|
||||
// loadArenaSeasonLeaderboard aggregates arena_history over [start, end) into
|
||||
// the same shape the lifetime board renders.
|
||||
func loadArenaSeasonLeaderboard(start, end time.Time) ([]ArenaLeaderboardEntry, error) {
|
||||
rows, err := db.Get().Query(`
|
||||
SELECT h.user_id, COALESCE(c.display_name, h.user_id),
|
||||
SUM(h.earnings),
|
||||
MAX(h.tier),
|
||||
SUM(CASE WHEN h.tier = 5 AND h.outcome = 'completed' THEN 1 ELSE 0 END),
|
||||
COUNT(*),
|
||||
SUM(CASE WHEN h.outcome = 'dead' THEN 1 ELSE 0 END)
|
||||
FROM arena_history h
|
||||
LEFT JOIN player_meta c ON c.user_id = h.user_id
|
||||
WHERE h.created_at >= ? AND h.created_at < ?
|
||||
GROUP BY h.user_id
|
||||
ORDER BY SUM(h.earnings) DESC
|
||||
LIMIT 10`, start.Unix(), end.Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []ArenaLeaderboardEntry
|
||||
for rows.Next() {
|
||||
var e ArenaLeaderboardEntry
|
||||
var uid string
|
||||
if err := rows.Scan(&uid, &e.DisplayName, &e.TotalEarnings, &e.HighestTier,
|
||||
&e.Tier5Completions, &e.TotalRuns, &e.TotalDeaths); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
// arenaSeasonChampion finds the single top row for a season by the given
|
||||
// metric. Returns ok=false when nobody entered the arena that quarter.
|
||||
func arenaSeasonChampion(kind string, start, end time.Time) (id.UserID, int64, bool) {
|
||||
var metric string
|
||||
switch kind {
|
||||
case arenaTitleEarnings:
|
||||
metric = "SUM(earnings)"
|
||||
case arenaTitleStreak:
|
||||
metric = "MAX(rounds_survived)"
|
||||
default:
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
// Only runs that earned or survived something can crown anyone: a season of
|
||||
// nothing but deaths at round zero should award no streak title.
|
||||
q := fmt.Sprintf(`
|
||||
SELECT user_id, %s AS metric
|
||||
FROM arena_history
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
GROUP BY user_id
|
||||
HAVING metric > 0
|
||||
ORDER BY metric DESC, user_id ASC
|
||||
LIMIT 1`, metric)
|
||||
|
||||
var uid string
|
||||
var value int64
|
||||
err := db.Get().QueryRow(q, start.Unix(), end.Unix()).Scan(&uid, &value)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", 0, false
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("arena season: champion query", "kind", kind, "err", err)
|
||||
return "", 0, false
|
||||
}
|
||||
return id.UserID(uid), value, true
|
||||
}
|
||||
|
||||
// recordArenaSeasonTitle archives a crown. Idempotent on (season, kind).
|
||||
func recordArenaSeasonTitle(season, kind string, userID id.UserID, value int64, at time.Time) error {
|
||||
_, err := db.Get().Exec(`
|
||||
INSERT INTO arena_season_titles (season, kind, user_id, value, awarded_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(season, kind) DO NOTHING`,
|
||||
season, kind, string(userID), value, at.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
// loadArenaSeasonTitles returns every crown a player has ever taken.
|
||||
func loadArenaSeasonTitles(userID id.UserID) ([]string, error) {
|
||||
rows, err := db.Get().Query(`
|
||||
SELECT season, kind FROM arena_season_titles
|
||||
WHERE user_id = ? ORDER BY season DESC`, string(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var season, kind string
|
||||
if err := rows.Scan(&season, &kind); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s %s", season, arenaTitleName(kind)))
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func arenaTitleName(kind string) string {
|
||||
switch kind {
|
||||
case arenaTitleEarnings:
|
||||
return "Coinlord of the Arena"
|
||||
case arenaTitleStreak:
|
||||
return "Longest Walk"
|
||||
}
|
||||
return kind
|
||||
}
|
||||
|
||||
// ── Rollover ────────────────────────────────────────────────────────────────
|
||||
|
||||
// arenaSeasonRollover awards the previous season's crowns exactly once, and
|
||||
// announces them. Safe to call every midnight: JobCompleted keyed on the season
|
||||
// makes it a no-op for the rest of the quarter, and a bot that was down on the
|
||||
// rollover day still catches up the next time it wakes.
|
||||
func (p *AdventurePlugin) arenaSeasonRollover(now time.Time) {
|
||||
season, start, end := previousArenaSeason(now)
|
||||
jobName := "arena_season_rollover"
|
||||
if db.JobCompleted(jobName, season) {
|
||||
return
|
||||
}
|
||||
// Guard against awarding a season that hasn't finished yet — only possible
|
||||
// if a caller passes a doctored clock.
|
||||
if !now.UTC().After(end) {
|
||||
return
|
||||
}
|
||||
|
||||
var lines []string
|
||||
failed := false
|
||||
for _, kind := range []string{arenaTitleEarnings, arenaTitleStreak} {
|
||||
uid, value, ok := arenaSeasonChampion(kind, start, end)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := recordArenaSeasonTitle(season, kind, uid, value, now); err != nil {
|
||||
slog.Error("arena season: record title failed", "season", season, "kind", kind, "err", err)
|
||||
failed = true
|
||||
continue // don't announce a crown we failed to persist
|
||||
}
|
||||
name, _ := loadDisplayName(uid)
|
||||
switch kind {
|
||||
case arenaTitleEarnings:
|
||||
lines = append(lines, fmt.Sprintf("🥇 **%s** — _%s_ (€%d earned)",
|
||||
name, arenaTitleName(kind), value))
|
||||
case arenaTitleStreak:
|
||||
lines = append(lines, fmt.Sprintf("🏃 **%s** — _%s_ (%d rounds in one run)",
|
||||
name, arenaTitleName(kind), value))
|
||||
}
|
||||
}
|
||||
|
||||
// Marking the job done is what stops the next midnight from retrying, so a
|
||||
// crown we failed to persist must not mark it. recordArenaSeasonTitle is
|
||||
// idempotent on (season, kind), and a past season's data is frozen, so the
|
||||
// retry re-derives the same champions and no-ops the ones already stored.
|
||||
if failed {
|
||||
slog.Warn("arena season: deferring completion after title failure", "season", season)
|
||||
return
|
||||
}
|
||||
db.MarkJobCompleted(jobName, season)
|
||||
if len(lines) == 0 {
|
||||
slog.Info("arena season: closed with no entrants", "season", season)
|
||||
return
|
||||
}
|
||||
slog.Info("arena season: crowned", "season", season, "titles", len(lines))
|
||||
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("⚔️ **Arena Season %s has ended.**\n\n", season))
|
||||
sb.WriteString(strings.Join(lines, "\n"))
|
||||
sb.WriteString("\n\nThe board is clear. Season " + arenaSeasonKey(now) + " starts now.")
|
||||
p.SendMessage(id.RoomID(gr), sb.String())
|
||||
}
|
||||
187
internal/plugin/adventure_arena_season_test.go
Normal file
187
internal/plugin/adventure_arena_season_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func TestArenaSeasonKeyAndBounds(t *testing.T) {
|
||||
tests := []struct {
|
||||
when time.Time
|
||||
key string
|
||||
wantStart time.Time
|
||||
}{
|
||||
{time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), "2026-Q1", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)},
|
||||
{time.Date(2026, 3, 31, 23, 59, 59, 0, time.UTC), "2026-Q1", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)},
|
||||
{time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC), "2026-Q2", time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)},
|
||||
{time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC), "2026-Q3", time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)},
|
||||
{time.Date(2026, 12, 31, 23, 0, 0, 0, time.UTC), "2026-Q4", time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := arenaSeasonKey(tc.when); got != tc.key {
|
||||
t.Errorf("arenaSeasonKey(%s) = %s, want %s", tc.when, got, tc.key)
|
||||
}
|
||||
start, end := arenaSeasonBounds(tc.when)
|
||||
if !start.Equal(tc.wantStart) {
|
||||
t.Errorf("season start for %s = %s, want %s", tc.when, start, tc.wantStart)
|
||||
}
|
||||
if !end.Equal(start.AddDate(0, 3, 0)) {
|
||||
t.Errorf("season end for %s is not start+3mo", tc.when)
|
||||
}
|
||||
if !tc.when.Before(end) || tc.when.Before(start) {
|
||||
t.Errorf("%s does not fall inside its own season bounds", tc.when)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreviousArenaSeasonWrapsYear pins the Q1 → prior-year-Q4 edge.
|
||||
func TestPreviousArenaSeasonWrapsYear(t *testing.T) {
|
||||
key, start, end := previousArenaSeason(time.Date(2026, 2, 14, 0, 0, 0, 0, time.UTC))
|
||||
if key != "2025-Q4" {
|
||||
t.Errorf("previous season = %s, want 2025-Q4", key)
|
||||
}
|
||||
if !start.Equal(time.Date(2025, 10, 1, 0, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("start = %s, want 2025-10-01", start)
|
||||
}
|
||||
if !end.Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("end = %s, want 2026-01-01", end)
|
||||
}
|
||||
}
|
||||
|
||||
func insertArenaRun(t *testing.T, user id.UserID, tier, rounds int, earnings int64, outcome string, at time.Time) {
|
||||
t.Helper()
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO arena_history (user_id, start_tier, tier, rounds_survived, earnings, outcome, monster_name, created_at)
|
||||
VALUES (?, 1, ?, ?, ?, ?, 'Test Monster', ?)`,
|
||||
string(user), tier, rounds, earnings, outcome, at.Unix())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArenaSeasonLeaderboardWindowing: only runs inside the season window count.
|
||||
func TestArenaSeasonLeaderboardWindowing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
inSeason := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
||||
start, end := arenaSeasonBounds(inSeason)
|
||||
|
||||
alice := id.UserID("@alice:test.invalid")
|
||||
bob := id.UserID("@bob:test.invalid")
|
||||
|
||||
insertArenaRun(t, alice, 3, 4, 5000, "completed", inSeason)
|
||||
insertArenaRun(t, alice, 5, 2, 1000, "dead", inSeason.AddDate(0, 0, 1))
|
||||
insertArenaRun(t, bob, 2, 3, 9000, "cashed_out", inSeason)
|
||||
// Last season — must not appear.
|
||||
insertArenaRun(t, alice, 5, 4, 999999, "completed", start.AddDate(0, 0, -1))
|
||||
|
||||
entries, err := loadArenaSeasonLeaderboard(start, end)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("got %d entries, want 2", len(entries))
|
||||
}
|
||||
// Bob out-earned Alice this season (9000 vs 6000).
|
||||
if entries[0].DisplayName != string(bob) {
|
||||
t.Errorf("top entry = %s, want bob", entries[0].DisplayName)
|
||||
}
|
||||
if entries[0].TotalEarnings != 9000 {
|
||||
t.Errorf("bob earnings = %d, want 9000", entries[0].TotalEarnings)
|
||||
}
|
||||
if entries[1].TotalEarnings != 6000 {
|
||||
t.Errorf("alice earnings = %d, want 6000 (last season's 999999 must not count)", entries[1].TotalEarnings)
|
||||
}
|
||||
if entries[1].TotalDeaths != 1 {
|
||||
t.Errorf("alice deaths = %d, want 1", entries[1].TotalDeaths)
|
||||
}
|
||||
if entries[1].HighestTier != 5 {
|
||||
t.Errorf("alice highest tier = %d, want 5", entries[1].HighestTier)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArenaSeasonChampions: earnings crown goes by total, streak crown by the
|
||||
// single longest run — they can be different players.
|
||||
func TestArenaSeasonChampions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
when := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
|
||||
start, end := arenaSeasonBounds(when)
|
||||
|
||||
rich := id.UserID("@rich:test.invalid")
|
||||
tough := id.UserID("@tough:test.invalid")
|
||||
|
||||
insertArenaRun(t, rich, 5, 2, 50000, "cashed_out", when)
|
||||
insertArenaRun(t, tough, 1, 12, 500, "completed", when)
|
||||
|
||||
uid, val, ok := arenaSeasonChampion(arenaTitleEarnings, start, end)
|
||||
if !ok || uid != rich || val != 50000 {
|
||||
t.Errorf("earnings champion = (%s, %d, %v), want rich/50000", uid, val, ok)
|
||||
}
|
||||
uid, val, ok = arenaSeasonChampion(arenaTitleStreak, start, end)
|
||||
if !ok || uid != tough || val != 12 {
|
||||
t.Errorf("streak champion = (%s, %d, %v), want tough/12", uid, val, ok)
|
||||
}
|
||||
|
||||
// An empty season crowns nobody.
|
||||
emptyStart, emptyEnd := arenaSeasonBounds(when.AddDate(1, 0, 0))
|
||||
if _, _, ok := arenaSeasonChampion(arenaTitleEarnings, emptyStart, emptyEnd); ok {
|
||||
t.Error("an empty season produced an earnings champion")
|
||||
}
|
||||
|
||||
// A season of nothing but round-zero deaths crowns no streak.
|
||||
deathsOnly := time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC)
|
||||
dStart, dEnd := arenaSeasonBounds(deathsOnly)
|
||||
insertArenaRun(t, tough, 1, 0, 0, "dead", deathsOnly)
|
||||
if _, _, ok := arenaSeasonChampion(arenaTitleStreak, dStart, dEnd); ok {
|
||||
t.Error("a season of round-zero deaths produced a streak champion")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordArenaSeasonTitleIdempotent: the rollover must be safe to re-run.
|
||||
func TestRecordArenaSeasonTitleIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
now := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
winner := id.UserID("@winner:test.invalid")
|
||||
usurper := id.UserID("@usurper:test.invalid")
|
||||
|
||||
if err := recordArenaSeasonTitle("2026-Q2", arenaTitleEarnings, winner, 100, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A second write for the same (season, kind) must not overwrite the crown.
|
||||
if err := recordArenaSeasonTitle("2026-Q2", arenaTitleEarnings, usurper, 999, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
titles, err := loadArenaSeasonTitles(winner)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(titles) != 1 {
|
||||
t.Fatalf("winner has %d titles, want 1", len(titles))
|
||||
}
|
||||
stolen, _ := loadArenaSeasonTitles(usurper)
|
||||
if len(stolen) != 0 {
|
||||
t.Errorf("usurper took the crown: %v", stolen)
|
||||
}
|
||||
}
|
||||
@@ -637,9 +637,9 @@ func TestRenderArenaPersonalStats_WithStats(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRenderArenaLeaderboard_Empty(t *testing.T) {
|
||||
text := renderArenaLeaderboard(nil)
|
||||
if !strings.Contains(text, "No arena runs") {
|
||||
t.Error("empty leaderboard should say no runs")
|
||||
text := renderArenaLeaderboard("2026-Q3", nil)
|
||||
if !strings.Contains(text, "Nobody has entered") {
|
||||
t.Error("empty leaderboard should say nobody entered")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,7 +648,7 @@ func TestRenderArenaLeaderboard_WithEntries(t *testing.T) {
|
||||
{DisplayName: "Alice", TotalEarnings: 100000, HighestTier: 5, Tier5Completions: 1, TotalRuns: 5, TotalDeaths: 2},
|
||||
{DisplayName: "Bob", TotalEarnings: 50000, HighestTier: 3, TotalRuns: 10, TotalDeaths: 7},
|
||||
}
|
||||
text := renderArenaLeaderboard(entries)
|
||||
text := renderArenaLeaderboard("2026-Q3", entries)
|
||||
if !strings.Contains(text, "Alice") {
|
||||
t.Error("leaderboard should contain Alice")
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro
|
||||
return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+
|
||||
"Hire a babysitter to look after your camp and tend the pet while you sleep:\n"+
|
||||
" • Daily pet XP trickle (your pet still grows while you focus elsewhere)\n"+
|
||||
" • Standard camps act like fortified ones — rest deeply, no need for boss-cleared rooms\n"+
|
||||
" • Standard camps act like fortified ones — rest deeply, no need to have downed the zone boss\n"+
|
||||
" • Rival duels declined on your behalf\n\n"+
|
||||
"`!adventure babysit week` — 7 days of service\n"+
|
||||
"`!adventure babysit month` — 30 days of service\n"+
|
||||
@@ -318,15 +318,15 @@ func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) stri
|
||||
// ── Babysit Log CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
type babysitLogEntry struct {
|
||||
ID int64
|
||||
UserID id.UserID
|
||||
LogDate string
|
||||
Activity string
|
||||
Outcome string
|
||||
GoldEarned int
|
||||
XPGained int
|
||||
ItemsDropped string
|
||||
RivalRefused string
|
||||
ID int64
|
||||
UserID id.UserID
|
||||
LogDate string
|
||||
Activity string
|
||||
Outcome string
|
||||
GoldEarned int
|
||||
XPGained int
|
||||
ItemsDropped string
|
||||
RivalRefused string
|
||||
}
|
||||
|
||||
func logBabysitActivity(userID id.UserID, activity, outcome string, gold, xp int, items string) {
|
||||
|
||||
@@ -66,11 +66,11 @@ func TestRunBabysitDailyTrickle_AccumulatesAndLevels(t *testing.T) {
|
||||
defer func() { _ = recover() }()
|
||||
|
||||
char := &AdventureCharacter{
|
||||
UserID: id.UserID("@accum:example"),
|
||||
PetType: "dog",
|
||||
PetName: "Rex",
|
||||
PetLevel: 1, // needs 10 XP = 1000 centi-XP to L2
|
||||
PetXP: 900,
|
||||
UserID: id.UserID("@accum:example"),
|
||||
PetType: "dog",
|
||||
PetName: "Rex",
|
||||
PetLevel: 1, // needs 10 XP = 1000 centi-XP to L2
|
||||
PetXP: 900,
|
||||
BabysitActive: true,
|
||||
}
|
||||
expires := time.Now().UTC().Add(48 * time.Hour)
|
||||
|
||||
@@ -24,32 +24,32 @@ var allSlots = []EquipmentSlot{SlotWeapon, SlotArmor, SlotHelmet, SlotBoots, Slo
|
||||
// ── Core Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
type AdventureCharacter struct {
|
||||
UserID id.UserID
|
||||
DisplayName string
|
||||
CombatLevel int
|
||||
MiningSkill int
|
||||
ForagingSkill int
|
||||
FishingSkill int // v2
|
||||
CombatXP int
|
||||
MiningXP int
|
||||
ForagingXP int
|
||||
FishingXP int // v2
|
||||
Alive bool
|
||||
DeadUntil *time.Time
|
||||
ActionTakenToday bool
|
||||
HolidayActionTaken bool
|
||||
CombatActionsUsed int
|
||||
HarvestActionsUsed int
|
||||
ArenaWins int // v2
|
||||
ArenaLosses int // v2
|
||||
InvasionScore int // v2
|
||||
Title string // v2
|
||||
CurrentStreak int
|
||||
BestStreak int
|
||||
LastActionDate string
|
||||
GrudgeLocation string
|
||||
CreatedAt time.Time
|
||||
LastActiveAt time.Time
|
||||
UserID id.UserID
|
||||
DisplayName string
|
||||
CombatLevel int
|
||||
MiningSkill int
|
||||
ForagingSkill int
|
||||
FishingSkill int // v2
|
||||
CombatXP int
|
||||
MiningXP int
|
||||
ForagingXP int
|
||||
FishingXP int // v2
|
||||
Alive bool
|
||||
DeadUntil *time.Time
|
||||
ActionTakenToday bool
|
||||
HolidayActionTaken bool
|
||||
CombatActionsUsed int
|
||||
HarvestActionsUsed int
|
||||
ArenaWins int // v2
|
||||
ArenaLosses int // v2
|
||||
InvasionScore int // v2
|
||||
Title string // v2
|
||||
CurrentStreak int
|
||||
BestStreak int
|
||||
LastActionDate string
|
||||
GrudgeLocation string
|
||||
CreatedAt time.Time
|
||||
LastActiveAt time.Time
|
||||
DeathReprieveLast *time.Time
|
||||
MasterworkDropsReceived int
|
||||
RivalPool int
|
||||
@@ -78,27 +78,27 @@ type AdventureCharacter struct {
|
||||
HouseAutopay bool
|
||||
HouseCurrentRate float64
|
||||
// Pets
|
||||
PetType string
|
||||
PetName string
|
||||
PetXP int
|
||||
PetLevel int
|
||||
PetArmorTier int
|
||||
PetChasedAway bool
|
||||
PetReactivated bool
|
||||
PetArrived bool
|
||||
MistyEncounterCount int
|
||||
MistyDonatedCount int
|
||||
ThomAnimalLineFired bool
|
||||
PetType string
|
||||
PetName string
|
||||
PetXP int
|
||||
PetLevel int
|
||||
PetArmorTier int
|
||||
PetChasedAway bool
|
||||
PetReactivated bool
|
||||
PetArrived bool
|
||||
MistyEncounterCount int
|
||||
MistyDonatedCount int
|
||||
ThomAnimalLineFired bool
|
||||
PetSupplyShopUnlocked bool
|
||||
PetLevel10Date string
|
||||
PetMorningDefense bool
|
||||
AutoBabysit bool
|
||||
AutoBabysitFocus string // mining|fishing|foraging — preferred skill for auto-babysit; "" defaults to weakest
|
||||
TreasuresLocked bool // when true, treasure drops at cap are refused instead of auto-swapping
|
||||
StreakDecayed bool
|
||||
CraftsSucceeded int
|
||||
DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure)
|
||||
DeathLocation string // human-readable location of last death; cleared on revive is not required
|
||||
PetLevel10Date string
|
||||
PetMorningDefense bool
|
||||
AutoBabysit bool
|
||||
AutoBabysitFocus string // mining|fishing|foraging — preferred skill for auto-babysit; "" defaults to weakest
|
||||
TreasuresLocked bool // when true, treasure drops at cap are refused instead of auto-swapping
|
||||
StreakDecayed bool
|
||||
CraftsSucceeded int
|
||||
DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure)
|
||||
DeathLocation string // human-readable location of last death; cleared on revive is not required
|
||||
}
|
||||
|
||||
type AdvEquipment struct {
|
||||
@@ -121,6 +121,7 @@ type AdvItem struct {
|
||||
Value int64
|
||||
Slot EquipmentSlot // non-empty for MasterworkGear
|
||||
SkillSource string // non-empty for MasterworkGear
|
||||
Temper int // rarity steps above base; magic_item rows only
|
||||
}
|
||||
|
||||
type AdvBuff struct {
|
||||
@@ -311,7 +312,30 @@ func (c *AdventureCharacter) CanDoHarvest(isHoliday bool) bool {
|
||||
}
|
||||
|
||||
func (c *AdventureCharacter) HasActedToday() bool {
|
||||
return c.CombatActionsUsed > 0 || c.HarvestActionsUsed > 0
|
||||
if c.CombatActionsUsed > 0 || c.HarvestActionsUsed > 0 {
|
||||
return true
|
||||
}
|
||||
// DnD-side flows (expedition / zone / rest / autopilot) don't touch
|
||||
// the legacy action counters; they credit the day via LastActionDate
|
||||
// instead. Honor that so a player who ran an expedition all day and
|
||||
// extracted before midnight still counts as having acted.
|
||||
return c.LastActionDate == time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// markActedToday stamps the player's LastActionDate to today so the
|
||||
// midnight reset credits the day. Safe to call on every DnD-side action;
|
||||
// no-ops if the date is already today.
|
||||
func markActedToday(userID id.UserID) {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
c, err := loadAdvCharacter(userID)
|
||||
if err != nil || c == nil {
|
||||
return
|
||||
}
|
||||
if c.LastActionDate == today {
|
||||
return
|
||||
}
|
||||
c.LastActionDate = today
|
||||
_ = saveAdvCharacter(c)
|
||||
}
|
||||
|
||||
func (c *AdventureCharacter) AllActionsUsed(isHoliday bool) bool {
|
||||
@@ -537,7 +561,7 @@ func saveAdvEquipment(userID id.UserID, eq *AdvEquipment) error {
|
||||
func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`
|
||||
SELECT id, name, item_type, tier, value, slot, skill_source
|
||||
SELECT id, name, item_type, tier, value, slot, skill_source, temper
|
||||
FROM adventure_inventory WHERE user_id = ?
|
||||
ORDER BY tier DESC, value DESC`, string(userID))
|
||||
if err != nil {
|
||||
@@ -549,7 +573,7 @@ func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
|
||||
for rows.Next() {
|
||||
var it AdvItem
|
||||
var slot string
|
||||
if err := rows.Scan(&it.ID, &it.Name, &it.Type, &it.Tier, &it.Value, &slot, &it.SkillSource); err != nil {
|
||||
if err := rows.Scan(&it.ID, &it.Name, &it.Type, &it.Tier, &it.Value, &slot, &it.SkillSource, &it.Temper); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
it.Slot = EquipmentSlot(slot)
|
||||
@@ -561,9 +585,19 @@ func loadAdvInventory(userID id.UserID) ([]AdvItem, error) {
|
||||
func addAdvInventoryItem(userID id.UserID, item AdvItem) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`
|
||||
INSERT INTO adventure_inventory (user_id, name, item_type, tier, value, slot, skill_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
string(userID), item.Name, item.Type, item.Tier, item.Value, string(item.Slot), item.SkillSource)
|
||||
INSERT INTO adventure_inventory (user_id, name, item_type, tier, value, slot, skill_source, temper)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
string(userID), item.Name, item.Type, item.Tier, item.Value, string(item.Slot), item.SkillSource, item.Temper)
|
||||
return err
|
||||
}
|
||||
|
||||
// temperInventoryItem records a tempering step on an un-equipped magic item.
|
||||
// Tier and value move with the item's new effective rarity so it keeps sorting
|
||||
// and selling correctly.
|
||||
func temperInventoryItem(itemID int64, temper, tier int, value int64) error {
|
||||
_, err := db.Get().Exec(
|
||||
`UPDATE adventure_inventory SET temper = ?, tier = ?, value = ? WHERE id = ?`,
|
||||
temper, tier, value, itemID)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -17,26 +17,26 @@ import (
|
||||
type ConsumableEffect string
|
||||
|
||||
const (
|
||||
EffectHeal ConsumableEffect = "heal"
|
||||
EffectDefBoost ConsumableEffect = "def_boost"
|
||||
EffectAtkBoost ConsumableEffect = "atk_boost"
|
||||
EffectWard ConsumableEffect = "ward"
|
||||
EffectHeal ConsumableEffect = "heal"
|
||||
EffectDefBoost ConsumableEffect = "def_boost"
|
||||
EffectAtkBoost ConsumableEffect = "atk_boost"
|
||||
EffectWard ConsumableEffect = "ward"
|
||||
EffectSpeedBoost ConsumableEffect = "speed_boost"
|
||||
EffectCritBoost ConsumableEffect = "crit_boost"
|
||||
EffectFlatDmg ConsumableEffect = "flat_dmg"
|
||||
EffectSpore ConsumableEffect = "spore"
|
||||
EffectReflect ConsumableEffect = "reflect"
|
||||
EffectAutoCrit ConsumableEffect = "auto_crit"
|
||||
EffectCritBoost ConsumableEffect = "crit_boost"
|
||||
EffectFlatDmg ConsumableEffect = "flat_dmg"
|
||||
EffectSpore ConsumableEffect = "spore"
|
||||
EffectReflect ConsumableEffect = "reflect"
|
||||
EffectAutoCrit ConsumableEffect = "auto_crit"
|
||||
)
|
||||
|
||||
type ConsumableDef struct {
|
||||
Name string
|
||||
Effect ConsumableEffect
|
||||
Value float64 // meaning depends on effect
|
||||
Tier int
|
||||
Buyable bool
|
||||
Price int64
|
||||
Slot string // "offensive" or "defensive"
|
||||
Name string
|
||||
Effect ConsumableEffect
|
||||
Value float64 // meaning depends on effect
|
||||
Tier int
|
||||
Buyable bool
|
||||
Price int64
|
||||
Slot string // "offensive" or "defensive"
|
||||
}
|
||||
|
||||
// ConsumableItem is an AdvItem that has consumable properties.
|
||||
@@ -234,8 +234,13 @@ func RollConsumableDrop(activity AdvActivityType, tier int) *AdvItem {
|
||||
if rand.Float64() >= 0.15 {
|
||||
return nil
|
||||
}
|
||||
name := names[rand.IntN(len(names))]
|
||||
def := consumableDefByName(name)
|
||||
return consumableAdvItem(consumableDefByName(names[rand.IntN(len(names))]))
|
||||
}
|
||||
|
||||
// consumableAdvItem builds the inventory row for a consumable def. Drop-only
|
||||
// consumables carry no shop price, so their sell value falls back to a
|
||||
// per-tier baseline.
|
||||
func consumableAdvItem(def *ConsumableDef) *AdvItem {
|
||||
if def == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -252,6 +257,23 @@ func RollConsumableDrop(activity AdvActivityType, tier int) *AdvItem {
|
||||
}
|
||||
}
|
||||
|
||||
// consumableCache draws n consumables from the dungeon drop pool at `tier`,
|
||||
// used by guaranteed grants (milestone caches) rather than the drop roll.
|
||||
// Tier 1 has no dungeon pool, so it yields the buyable Berry Poultice.
|
||||
func consumableCache(tier, n int) []AdvItem {
|
||||
names := consumableDropTable[AdvActivityDungeon][tier]
|
||||
if len(names) == 0 {
|
||||
names = []string{"Berry Poultice"}
|
||||
}
|
||||
out := make([]AdvItem, 0, n)
|
||||
for range n {
|
||||
if item := consumableAdvItem(consumableDefByName(names[rand.IntN(len(names))])); item != nil {
|
||||
out = append(out, *item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuyableConsumables returns all consumable defs that can be purchased from the shop.
|
||||
func BuyableConsumables() []ConsumableDef {
|
||||
var result []ConsumableDef
|
||||
|
||||
@@ -23,18 +23,65 @@ type advActiveEvent struct {
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// ── In-memory schedule ───────────────────────────────────────────────────────
|
||||
// When a player acts on a given day, the next ticker iteration assigns them
|
||||
// a one-shot roll-minute 60–180 minutes in the future. At that minute, the
|
||||
// 0.5% trigger roll fires. Each player rolls at most once per UTC day.
|
||||
// ── Event anchors ────────────────────────────────────────────────────────────
|
||||
// N1/A6 — events used to roll at 0.5%/player/day from a deferred ticker slot,
|
||||
// which put one sighting in front of a daily player roughly every 200 days.
|
||||
// They now roll at moments the player is demonstrably present and reading a
|
||||
// DM: the end-of-day digest, a sale at Thom's, and an arena cashout. Each
|
||||
// player still sees at most one event per UTC day.
|
||||
//
|
||||
// The per-anchor chances below put a player who hits all three at ~1 event
|
||||
// per week; see TestAnchoredEventWeeklyRate.
|
||||
const (
|
||||
advEventChanceDigest = 0.08
|
||||
advEventChanceSell = 0.05
|
||||
advEventChanceArena = 0.05
|
||||
)
|
||||
|
||||
var (
|
||||
advEventScheduleMu sync.Mutex
|
||||
advEventSchedule map[string]int // userID -> minute-of-day for the deferred roll
|
||||
advEventRolled map[string]bool // userID -> already rolled today
|
||||
advEventScheduleDay string // "2006-01-02" the maps belong to
|
||||
advEventFiredMu sync.Mutex
|
||||
advEventFired map[string]bool // userID -> an event already fired today
|
||||
advEventFiredDay string // "2006-01-02" the map belongs to
|
||||
)
|
||||
|
||||
// claimDailyEventSlot reserves today's single event slot for `userID`.
|
||||
// Returns false when the player already had one fire today.
|
||||
func claimDailyEventSlot(userID id.UserID, day string) bool {
|
||||
advEventFiredMu.Lock()
|
||||
defer advEventFiredMu.Unlock()
|
||||
if advEventFiredDay != day {
|
||||
advEventFired = make(map[string]bool)
|
||||
advEventFiredDay = day
|
||||
}
|
||||
if advEventFired[string(userID)] {
|
||||
return false
|
||||
}
|
||||
advEventFired[string(userID)] = true
|
||||
return true
|
||||
}
|
||||
|
||||
// releaseDailyEventSlot hands the slot back when the trigger bailed before
|
||||
// anything reached the player, so a later anchor the same day can still fire.
|
||||
func releaseDailyEventSlot(userID id.UserID) {
|
||||
advEventFiredMu.Lock()
|
||||
defer advEventFiredMu.Unlock()
|
||||
delete(advEventFired, string(userID))
|
||||
}
|
||||
|
||||
// maybeFireAnchoredEvent rolls `chance` at one of the A6 anchor moments and,
|
||||
// on a hit, triggers the player's one mid-day event for today.
|
||||
func (p *AdventurePlugin) maybeFireAnchoredEvent(userID id.UserID, chance float64) {
|
||||
if rand.Float64() >= chance {
|
||||
return
|
||||
}
|
||||
if !claimDailyEventSlot(userID, time.Now().UTC().Format("2006-01-02")) {
|
||||
return
|
||||
}
|
||||
if !p.tryTriggerEvent(userID) {
|
||||
releaseDailyEventSlot(userID)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event Ticker ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) eventTicker() {
|
||||
@@ -46,90 +93,45 @@ func (p *AdventurePlugin) eventTicker() {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
now := time.Now().UTC()
|
||||
dateKey := now.Format("2006-01-02")
|
||||
currentMinute := now.Hour()*60 + now.Minute()
|
||||
|
||||
// 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)
|
||||
advEventRolled = make(map[string]bool)
|
||||
advEventScheduleDay = dateKey
|
||||
}
|
||||
// Latch away party members onto autopilot so one absent player
|
||||
// can't hold a co-op fight hostage (N3/P5). Solo fights are never
|
||||
// listed — they answer to the reaper above.
|
||||
p.nudgeStalledPartyTurns()
|
||||
|
||||
// Schedule deferred rolls for any newly-acted players
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("adventure: events: failed to load chars", "err", err)
|
||||
advEventScheduleMu.Unlock()
|
||||
continue
|
||||
}
|
||||
for _, c := range chars {
|
||||
uid := string(c.UserID)
|
||||
if !c.Alive || advEventRolled[uid] {
|
||||
continue
|
||||
}
|
||||
if _, scheduled := advEventSchedule[uid]; scheduled {
|
||||
continue
|
||||
}
|
||||
if !c.HasActedToday() {
|
||||
continue
|
||||
}
|
||||
// Assign a one-shot roll 60–180 minutes from now, capped to 23:50 UTC.
|
||||
rollMinute := currentMinute + 60 + rand.IntN(121)
|
||||
if rollMinute > 23*60+50 {
|
||||
rollMinute = 23*60 + 50
|
||||
}
|
||||
advEventSchedule[uid] = rollMinute
|
||||
slog.Info("adventure: event roll scheduled", "user", uid, "minute", rollMinute)
|
||||
}
|
||||
|
||||
// Find players whose roll-minute has arrived
|
||||
var toRoll []id.UserID
|
||||
for uid, minute := range advEventSchedule {
|
||||
if minute <= currentMinute && !advEventRolled[uid] {
|
||||
toRoll = append(toRoll, id.UserID(uid))
|
||||
advEventRolled[uid] = true
|
||||
}
|
||||
}
|
||||
advEventScheduleMu.Unlock()
|
||||
|
||||
for _, uid := range toRoll {
|
||||
p.tryTriggerEvent(uid)
|
||||
}
|
||||
// Reclaim invites nobody answered (N3/P6b). Every read already
|
||||
// filters on the TTL; this just stops the rows accumulating.
|
||||
purgeExpiredInvites()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
|
||||
// Load character — must be alive and have acted today
|
||||
// tryTriggerEvent fires the player's mid-day event. Reports whether an event
|
||||
// actually reached them — a false return means the caller's daily slot was
|
||||
// never spent. The trigger roll itself lives in maybeFireAnchoredEvent.
|
||||
func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) bool {
|
||||
// Load character — must be alive.
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char == nil || !char.Alive || !char.HasActedToday() {
|
||||
return
|
||||
if err != nil || char == nil || !char.Alive {
|
||||
return false
|
||||
}
|
||||
|
||||
// Already has an active event?
|
||||
active, _ := loadAdvActiveEvent(userID)
|
||||
if active != nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// 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
|
||||
return false
|
||||
}
|
||||
|
||||
// Determine today's activity for filtering
|
||||
@@ -138,7 +140,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
|
||||
// Pick an event
|
||||
event := advPickRandomEvent(userID, activityType)
|
||||
if event == nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// Insert into DB
|
||||
@@ -147,7 +149,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
|
||||
eventID, err := insertAdvEvent(userID, event.Key, expiresAt)
|
||||
if err != nil {
|
||||
slog.Error("adventure: events: failed to insert event", "user", userID, "err", err)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
slog.Info("adventure: mid-day event triggered", "user", userID, "event", event.Key, "id", eventID)
|
||||
@@ -170,6 +172,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
|
||||
})
|
||||
_ = p.SendMessage(id.RoomID(gr), roomLine)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleEventRespond processes `!adventure respond`.
|
||||
|
||||
83
internal/plugin/adventure_events_test.go
Normal file
83
internal/plugin/adventure_events_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// TestAnchoredEventWeeklyRate pins the A6 rebalance: a player who reads an
|
||||
// end-of-day digest, sells at Thom's, and cashes out of the arena every day
|
||||
// should see roughly one mid-day event per week. The old 0.5%/day roll put
|
||||
// that at one per ~200 days.
|
||||
//
|
||||
// The simulation drives its own seeded RNG rather than the package-global
|
||||
// one, so it measures the policy — the chance constants and the one-event-
|
||||
// per-day cap — and never flakes on RNG ordering elsewhere in the suite.
|
||||
func TestAnchoredEventWeeklyRate(t *testing.T) {
|
||||
anchors := []float64{advEventChanceDigest, advEventChanceSell, advEventChanceArena}
|
||||
|
||||
const weeks = 20000
|
||||
rng := rand.New(rand.NewPCG(0x5EED, 0xA6))
|
||||
events := 0
|
||||
for range weeks {
|
||||
for range 7 {
|
||||
firedToday := false
|
||||
for _, chance := range anchors {
|
||||
if firedToday {
|
||||
break // one event per player per UTC day
|
||||
}
|
||||
if rng.Float64() < chance {
|
||||
firedToday = true
|
||||
}
|
||||
}
|
||||
if firedToday {
|
||||
events++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
perWeek := float64(events) / weeks
|
||||
if perWeek < 0.8 || perWeek > 1.5 {
|
||||
t.Errorf("anchored events = %.3f/week, want ~1 (0.8–1.5)", perWeek)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimDailyEventSlot_OnePerDay guards the cap the rate test assumes.
|
||||
func TestClaimDailyEventSlot_OnePerDay(t *testing.T) {
|
||||
uid := id.UserID("@events-slot:example")
|
||||
const day = "2026-07-09"
|
||||
|
||||
advEventFiredMu.Lock()
|
||||
advEventFired = nil
|
||||
advEventFiredDay = ""
|
||||
advEventFiredMu.Unlock()
|
||||
|
||||
if !claimDailyEventSlot(uid, day) {
|
||||
t.Fatal("first claim of the day should succeed")
|
||||
}
|
||||
if claimDailyEventSlot(uid, day) {
|
||||
t.Error("second claim on the same day should be refused")
|
||||
}
|
||||
// A trigger that bailed before reaching the player hands the slot back.
|
||||
releaseDailyEventSlot(uid)
|
||||
if !claimDailyEventSlot(uid, day) {
|
||||
t.Error("claim after release should succeed")
|
||||
}
|
||||
// A new UTC day resets everyone.
|
||||
if !claimDailyEventSlot(uid, "2026-07-10") {
|
||||
t.Error("claim on a new day should succeed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClaimDailyEventSlot_PerUser — one player's event doesn't consume another's.
|
||||
func TestClaimDailyEventSlot_PerUser(t *testing.T) {
|
||||
const day = "2026-07-11"
|
||||
if !claimDailyEventSlot(id.UserID("@events-a:example"), day) {
|
||||
t.Fatal("player A should claim")
|
||||
}
|
||||
if !claimDailyEventSlot(id.UserID("@events-b:example"), day) {
|
||||
t.Error("player B should claim independently of A")
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,22 @@ var blacksmithArena = []string{
|
||||
"The Arena gear gets the good tools. I don't use these for everything. Just for things that matter.",
|
||||
}
|
||||
|
||||
var blacksmithTemperGreetings = []string{
|
||||
"_sets aside the repair work_ You want me to make something _more_ than it is. That's different. That's the good work.",
|
||||
"Tempering. _rolls his shoulders_ I don't repair it. I take it apart and I convince it to come back stronger. It doesn't always want to.",
|
||||
"_wipes the counter clean with one sweep_ Put it down. Right there. Let me see what it could be instead of what it is.",
|
||||
"Anything can be pushed further. Anything. It's just a question of what you're willing to spend and how much heat it can take before it gives in.",
|
||||
"_eyes light up_ Oh, you've come for the real thing. Not a patch. A _change_. Show me. Show me everything you've got.",
|
||||
}
|
||||
|
||||
var blacksmithTemperCompletion = []string{
|
||||
"_lifts it from the quench, steam everywhere_ Feel the weight of it now. It's not the same thing you handed me. It never will be again.",
|
||||
"_breathing hard_ It fought me. Right to the end it fought me. And now look at it. Look what it became.",
|
||||
"There. _sets it down with enormous care_ I pushed it further than it wanted to go, and it thanked me for it. They always do.",
|
||||
"_holds it up, turning it slowly_ I've been doing this a long time and this one still surprised me. Take it before I change my mind about giving it back.",
|
||||
"It's still hot. It'll be hot for a while. That's what happens when you ask something to become more than it was.",
|
||||
}
|
||||
|
||||
var blacksmithFullCondition = []string{
|
||||
"_looks it over_ There's nothing to do here. It doesn't need me. _sounds slightly disappointed_ Come back when it does.",
|
||||
"Full condition. You've been taking care of it. Good. I appreciate that in a person.",
|
||||
@@ -63,4 +79,3 @@ var blacksmithBrokenCondition = []string{
|
||||
"_looks at the condition, looks at you, looks back at the condition_ You know it costs more when you let it get like this. Of course you know. You just didn't care. That's fine. I care enough for both of us. It'll cost you.",
|
||||
"This could have been avoided with regular visits. _slides the cost estimate across the counter without breaking eye contact_",
|
||||
}
|
||||
|
||||
|
||||
@@ -924,4 +924,4 @@ var DungeonExceptional = map[int][]string{
|
||||
"in a dungeon. Write it down. " +
|
||||
"The dungeon will tell this story differently.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,6 +275,7 @@ var PetCatDeflect = []string{
|
||||
"Your cat is already somewhere else.\n\nIt blinks.\n\nNot at you.\n\nAt nothing.\n\n" +
|
||||
"The way cats do.",
|
||||
}
|
||||
|
||||
// Fires randomly in the morning DM. Cat has left something.
|
||||
// Results in a defense boost for the day.
|
||||
// The cat is proud. The cat will never stop doing this.
|
||||
|
||||
@@ -97,12 +97,12 @@ func TestAdvMasteryRowSegment(t *testing.T) {
|
||||
|
||||
func TestAdvMasteryRollup(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
used map[EquipmentSlot]int
|
||||
wantCrossed int
|
||||
wantMaxed int
|
||||
wantBonus float64
|
||||
wantAnyProgress bool
|
||||
name string
|
||||
used map[EquipmentSlot]int
|
||||
wantCrossed int
|
||||
wantMaxed int
|
||||
wantBonus float64
|
||||
wantAnyProgress bool
|
||||
}{
|
||||
{"empty", nil, 0, 0, 0, false},
|
||||
{"sub-50 only", map[EquipmentSlot]int{SlotWeapon: 30}, 0, 0, 0, true},
|
||||
@@ -240,12 +240,12 @@ func TestAdvTreasureIrreplaceable(t *testing.T) {
|
||||
|
||||
func TestRenderCraftingTeaser_BracketBoundaries(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
foraging int
|
||||
craftsSucceeded int
|
||||
wantEmpty bool
|
||||
wantContainsAny []string // any of these (OR)
|
||||
wantNotContains []string
|
||||
name string
|
||||
foraging int
|
||||
craftsSucceeded int
|
||||
wantEmpty bool
|
||||
wantContainsAny []string // any of these (OR)
|
||||
wantNotContains []string
|
||||
}{
|
||||
{"foraging 6 — out of pre-window", 6, 0, true, nil, nil},
|
||||
{"foraging 7 — pre-unlock with 3 levels left, plural", 7, 0, false, []string{"3 Foraging level"}, []string{"unlocks in 1 ", "unlocks in 0"}},
|
||||
@@ -297,7 +297,7 @@ func TestCraftingReminderWeekday_Spread(t *testing.T) {
|
||||
|
||||
func TestCraftingReminderWeekday_StableWithinWeek(t *testing.T) {
|
||||
uid := id.UserID("@stable:test")
|
||||
mon := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC) // Monday
|
||||
mon := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC) // Monday
|
||||
sun := time.Date(2026, 5, 10, 23, 0, 0, 0, time.UTC) // Sunday same ISO week
|
||||
if craftingReminderWeekday(uid, mon) != craftingReminderWeekday(uid, sun) {
|
||||
t.Error("weekday should be stable within an ISO week")
|
||||
|
||||
@@ -207,7 +207,7 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
|
||||
|
||||
// Store pending interaction
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "hospital_pay",
|
||||
Type: "hospital_pay",
|
||||
Data: &advPendingHospitalPay{
|
||||
Cost: afterInsurance,
|
||||
Discounted: haggleOutcome == haggleWin,
|
||||
@@ -277,7 +277,7 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
name, _ := loadDisplayName(char.UserID)
|
||||
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, name))
|
||||
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -144,10 +144,10 @@ func TestHouseHPBonus(t *testing.T) {
|
||||
expected float64
|
||||
}{
|
||||
{0, 0},
|
||||
{1, 0}, // Base house — no bonus
|
||||
{2, 0.05}, // Livable
|
||||
{3, 0.12}, // Comfortable
|
||||
{4, 0.20}, // Established
|
||||
{1, 0}, // Base house — no bonus
|
||||
{2, 0.05}, // Livable
|
||||
{3, 0.12}, // Comfortable
|
||||
{4, 0.20}, // Established
|
||||
}
|
||||
for _, tt := range tests {
|
||||
char := &AdventureCharacter{HouseTier: tt.tier}
|
||||
|
||||
96
internal/plugin/adventure_ingredients_test.go
Normal file
96
internal/plugin/adventure_ingredients_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// N1/A3 — every crafting ingredient must be obtainable from some live drop
|
||||
// source. Phase R retired the legacy activity loop, which orphaned
|
||||
// generateAdvLoot (reachable only from the now-uncalled resolveDungeonAction)
|
||||
// and with it every ingredient in the game. rollZoneIngredient reconnects the
|
||||
// legacy tables to zone combat; this test is the guard that keeps them
|
||||
// reachable.
|
||||
|
||||
// liveIngredientSources returns every item name a player can obtain from the
|
||||
// loot tables zone combat draws on, keyed to the tier it drops at.
|
||||
func liveIngredientSources() map[string][]int {
|
||||
sources := map[string][]int{}
|
||||
for _, act := range advIngredientActivities {
|
||||
table := advLootTable(act)
|
||||
for tier, defs := range table {
|
||||
for _, d := range defs {
|
||||
sources[d.Name] = append(sources[d.Name], tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func TestEveryRecipeIngredientHasALiveSource(t *testing.T) {
|
||||
sources := liveIngredientSources()
|
||||
for _, recipe := range craftingRecipes {
|
||||
for _, ing := range recipe.Ingredients {
|
||||
if tiers, ok := sources[ing]; !ok || len(tiers) == 0 {
|
||||
t.Errorf("recipe %q needs %q, which no live loot table drops",
|
||||
recipe.Result, ing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The drop is keyed by zone tier, so an ingredient that only exists at
|
||||
// legacy tier N is only reachable in a tier-N zone. Assert each recipe is
|
||||
// completable by someone — i.e. every ingredient sits in tier 1..5.
|
||||
func TestIngredientTiersAreReachableByZoneTier(t *testing.T) {
|
||||
sources := liveIngredientSources()
|
||||
for _, recipe := range craftingRecipes {
|
||||
for _, ing := range recipe.Ingredients {
|
||||
for _, tier := range sources[ing] {
|
||||
if tier < 1 || tier > 5 {
|
||||
t.Errorf("%q drops at tier %d, outside the zone tier range",
|
||||
ing, tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every zone tier must be able to produce an ingredient, or that tier's
|
||||
// players are cut out of crafting entirely.
|
||||
func TestRollZoneIngredient_EveryTierCanDrop(t *testing.T) {
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
t.Run(fmt.Sprintf("tier%d", tier), func(t *testing.T) {
|
||||
got := false
|
||||
for i := 0; i < 500 && !got; i++ {
|
||||
if item := rollZoneIngredient(tier); item != nil {
|
||||
got = true
|
||||
if item.Tier != tier {
|
||||
t.Errorf("tier %d drop carried tier %d", tier, item.Tier)
|
||||
}
|
||||
if item.Value <= 0 {
|
||||
t.Errorf("%q dropped with value %d", item.Name, item.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !got {
|
||||
t.Errorf("tier %d never dropped an ingredient in 500 rolls", tier)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinLootLines_SkipsEmpties(t *testing.T) {
|
||||
if got := joinLootLines("", ""); got != "" {
|
||||
t.Errorf("all-empty = %q, want empty", got)
|
||||
}
|
||||
if got := joinLootLines("a", ""); got != "a" {
|
||||
t.Errorf("trailing empty = %q, want %q", got, "a")
|
||||
}
|
||||
if got := joinLootLines("", "b"); got != "b" {
|
||||
t.Errorf("leading empty = %q, want %q", got, "b")
|
||||
}
|
||||
if got := joinLootLines("a", "b"); got != "a\nb" {
|
||||
t.Errorf("both = %q, want %q", got, "a\nb")
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
type MasterworkDef struct {
|
||||
Slot EquipmentSlot
|
||||
Activity AdvActivityType
|
||||
SkillSource string // "mining", "fishing", "foraging"
|
||||
Tier int // 1-5, matches location tier
|
||||
SkillSource string // "mining", "fishing", "foraging"
|
||||
Tier int // 1-5, matches location tier
|
||||
Name string
|
||||
Description string // character sheet / trade listing
|
||||
DropRate float64 // per-tier: 0.05, 0.04, 0.03, 0.02, 0.005
|
||||
@@ -115,10 +115,37 @@ func masterworkDefFor(activity AdvActivityType, tier int) *MasterworkDef {
|
||||
return nil
|
||||
}
|
||||
|
||||
// masterworkSlotActivities are the three activity lines the catalog covers,
|
||||
// one equipment slot each: mining→weapon, fishing→armor, foraging→boots.
|
||||
var masterworkSlotActivities = []AdvActivityType{
|
||||
AdvActivityMining, AdvActivityFishing, AdvActivityForaging,
|
||||
}
|
||||
|
||||
// masterworkDefForZone picks a masterwork for a dungeon kill. The catalog is
|
||||
// keyed by gathering activity — there is no dungeon line — so a zone drop
|
||||
// rolls across all three slots and hands back that line's item at the zone's
|
||||
// tier. The item keeps its own SkillSource: a masterwork blade found in a
|
||||
// crypt still helps you mine.
|
||||
func masterworkDefForZone(tier int) *MasterworkDef {
|
||||
act := masterworkSlotActivities[rand.IntN(len(masterworkSlotActivities))]
|
||||
return masterworkDefFor(act, tier)
|
||||
}
|
||||
|
||||
// ── Drop Flavor Text (DM) ─────────────────────────────────────────────────
|
||||
|
||||
func masterworkDropFlavorText(activity AdvActivityType, tier int) string {
|
||||
switch activity {
|
||||
case AdvActivityDungeon:
|
||||
switch {
|
||||
case tier <= 2:
|
||||
return "The thing you killed was carrying it, which raises the question of where it got it. You check the body for a maker's mark and find none. You check the body for anything else and find nothing. You take the gear and you do not think about the question."
|
||||
case tier == 3:
|
||||
return "It's propped against the wall behind the corpse, upright, deliberate. Not dropped. Placed. Someone came down here, set this down carefully, and did not come back for it. You pick it up. It fits your hand better than anything you paid for."
|
||||
case tier == 4:
|
||||
return "There's a pile of gear in the corner, most of it ruined, all of it belonging to people who came this far and no further. One piece is untouched. The rust stopped at its edge, as if the rust knew better. You add it to your kit and you leave the rest as you found it."
|
||||
default:
|
||||
return "It is the only thing in the room that isn't broken. Whatever lived here kept it, and kept it well, and cleaned it, and did not use it. You take it because you won. That is the arrangement. On the way out you do not turn around, and you tell yourself that is a choice."
|
||||
}
|
||||
case AdvActivityMining:
|
||||
switch {
|
||||
case tier <= 2:
|
||||
@@ -164,9 +191,15 @@ func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, equip map[Equipm
|
||||
return
|
||||
}
|
||||
|
||||
def := masterworkDefFor(loc.Activity, loc.Tier)
|
||||
// Dungeons have no catalog line of their own; they roll across all three.
|
||||
var def *MasterworkDef
|
||||
if loc.Activity == AdvActivityDungeon {
|
||||
def = masterworkDefForZone(loc.Tier)
|
||||
} else {
|
||||
def = masterworkDefFor(loc.Activity, loc.Tier)
|
||||
}
|
||||
if def == nil {
|
||||
return // no masterwork available for this activity+tier (e.g. dungeon)
|
||||
return // no masterwork available for this activity+tier
|
||||
}
|
||||
|
||||
// Roll for drop (chat level rare bonus applied additively)
|
||||
@@ -250,8 +283,9 @@ func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, equip map[Equipm
|
||||
sb.WriteString("_This doesn't come from the shop._\n\n")
|
||||
}
|
||||
|
||||
// Flavor text
|
||||
flavor := masterworkDropFlavorText(def.Activity, def.Tier)
|
||||
// Flavor follows where the item was found, not which catalog line it came
|
||||
// from — a dungeon drop must not open with a pickaxe.
|
||||
flavor := masterworkDropFlavorText(loc.Activity, def.Tier)
|
||||
if flavor != "" {
|
||||
sb.WriteString(fmt.Sprintf("_%s_\n\n", flavor))
|
||||
}
|
||||
|
||||
68
internal/plugin/adventure_masterwork_test.go
Normal file
68
internal/plugin/adventure_masterwork_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// N1/A2 — the masterwork catalog is keyed to gathering activities, so the
|
||||
// zone-combat seam had nothing to look up. masterworkDefForZone bridges it.
|
||||
|
||||
func TestMasterworkDefForZone_CoversEveryTier(t *testing.T) {
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
if def := masterworkDefForZone(tier); def == nil {
|
||||
t.Errorf("tier %d has no masterwork def", tier)
|
||||
} else if def.Tier != tier {
|
||||
t.Errorf("tier %d returned a tier-%d def", tier, def.Tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A dungeon drop must be able to land in any of the three slots — otherwise
|
||||
// zone play would only ever upgrade one piece of gear.
|
||||
func TestMasterworkDefForZone_RollsAllSlots(t *testing.T) {
|
||||
seen := map[EquipmentSlot]bool{}
|
||||
for i := 0; i < 300; i++ {
|
||||
if def := masterworkDefForZone(3); def != nil {
|
||||
seen[def.Slot] = true
|
||||
}
|
||||
}
|
||||
for _, slot := range []EquipmentSlot{SlotWeapon, SlotArmor, SlotBoots} {
|
||||
if !seen[slot] {
|
||||
t.Errorf("slot %v never rolled in 300 draws", slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The pre-existing lookup must keep working for the gathering activities.
|
||||
func TestMasterworkDefFor_GatheringUnchanged(t *testing.T) {
|
||||
for _, act := range masterworkSlotActivities {
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
def := masterworkDefFor(act, tier)
|
||||
if def == nil {
|
||||
t.Fatalf("%s tier %d lost its def", act, tier)
|
||||
}
|
||||
if def.Activity != act {
|
||||
t.Errorf("%s tier %d returned activity %s", act, tier, def.Activity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flavor follows the place, not the catalog line. A crypt boss must not
|
||||
// narrate a pickaxe striking ore.
|
||||
func TestMasterworkDropFlavorText_DungeonHasItsOwnVoice(t *testing.T) {
|
||||
gatheringWords := []string{"pickaxe", "fishing", "foraging", "vein", "ore", "branch"}
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
text := masterworkDropFlavorText(AdvActivityDungeon, tier)
|
||||
if text == "" {
|
||||
t.Fatalf("dungeon tier %d has no flavor", tier)
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
for _, w := range gatheringWords {
|
||||
if strings.Contains(lower, w) {
|
||||
t.Errorf("dungeon tier %d flavor leaks gathering word %q", tier, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,11 @@ import (
|
||||
// ── NPC Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
mistyCost = 100
|
||||
arinaCost = 5000
|
||||
npcCooldownDays = 7
|
||||
mistyCost = 100
|
||||
arinaCost = 5000
|
||||
npcCooldownDays = 7
|
||||
npcEncounterChance = 0.075 // 7.5%
|
||||
npcBuffDuration = 7 * 24 * time.Hour
|
||||
npcBuffDuration = 7 * 24 * time.Hour
|
||||
|
||||
// Arena effect chances per round
|
||||
mistyEffectChance = 0.20 // 20%
|
||||
@@ -337,10 +337,10 @@ func (p *AdventurePlugin) resolveArina(ctx MessageContext, char *AdventureCharac
|
||||
// Returns: extra text to append to round log, enemy HP modifier, player damage taken.
|
||||
type npcArenaResult struct {
|
||||
Text string
|
||||
EnemyDmg int // damage dealt to enemy
|
||||
PlayerDmg int // damage dealt to player
|
||||
SniperKill bool // enemy instant kill
|
||||
CondRepair int // equipment condition repair amount
|
||||
EnemyDmg int // damage dealt to enemy
|
||||
PlayerDmg int // damage dealt to player
|
||||
SniperKill bool // enemy instant kill
|
||||
CondRepair int // equipment condition repair amount
|
||||
}
|
||||
|
||||
func npcCheckArenaEffects(char *AdventureCharacter, monsterName string) *npcArenaResult {
|
||||
|
||||
@@ -329,8 +329,11 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error {
|
||||
article, titleCase(petType)))
|
||||
}
|
||||
|
||||
// resolvePetName handles naming the pet.
|
||||
func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error {
|
||||
// resolvePetName handles naming the pet. The dispatcher (handlePendingReply)
|
||||
// has already deleted the pending interaction and passes it in, so this must
|
||||
// NOT re-load from p.pending — doing so previously made every adoption fail
|
||||
// silently (the carried PetType was lost and the save never ran).
|
||||
func (p *AdventurePlugin) resolvePetName(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
@@ -340,20 +343,15 @@ func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
val, ok := p.pending.LoadAndDelete(string(ctx.Sender))
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
pi := val.(*advPendingInteraction)
|
||||
data := pi.Data.(*advPendingPetName)
|
||||
data := interaction.Data.(*advPendingPetName)
|
||||
|
||||
name := strings.TrimSpace(ctx.Body)
|
||||
if len(name) == 0 || len(name) > 30 {
|
||||
p.pending.Store(string(ctx.Sender), pi)
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "Name must be 1-30 characters. Try again.")
|
||||
}
|
||||
if !petNameValid.MatchString(name) {
|
||||
p.pending.Store(string(ctx.Sender), pi)
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "Name can only contain letters, numbers, spaces, hyphens, and apostrophes. Try again.")
|
||||
}
|
||||
|
||||
|
||||
51
internal/plugin/adventure_pets_dispatch_test.go
Normal file
51
internal/plugin/adventure_pets_dispatch_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// TestResolvePetName_ThroughDispatcher is a regression test for the bug where
|
||||
// resolvePendingInteraction deleted the pending entry before dispatch, while
|
||||
// resolvePetName then tried to LoadAndDelete it again — always missing, so the
|
||||
// adoption silently no-op'd and no pet was ever persisted. The fix passes the
|
||||
// already-loaded interaction into resolvePetName. This test drives the real
|
||||
// dispatcher path (resolvePendingInteraction) to lock that in.
|
||||
func TestResolvePetName_ThroughDispatcher(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@pet-name-dispatch:example")
|
||||
if err := createAdvCharacter(uid, "petnamer"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
interaction := &advPendingInteraction{
|
||||
Type: "pet_name",
|
||||
Data: &advPendingPetName{PetType: "dog"},
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
p.pending.Store(string(uid), interaction)
|
||||
|
||||
if err := p.resolvePendingInteraction(MessageContext{Sender: uid, Body: "Pepper"}, interaction); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pet, err := loadPetState(uid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !pet.HasPet() {
|
||||
t.Fatalf("expected pet to be adopted; got %+v", pet)
|
||||
}
|
||||
if pet.Name != "Pepper" {
|
||||
t.Errorf("pet name = %q, want Pepper", pet.Name)
|
||||
}
|
||||
if pet.Type != "dog" {
|
||||
t.Errorf("pet type = %q, want dog", pet.Type)
|
||||
}
|
||||
if !pet.Arrived || pet.Level != 1 {
|
||||
t.Errorf("pet arrived=%v level=%d, want true/1", pet.Arrived, pet.Level)
|
||||
}
|
||||
}
|
||||
@@ -729,12 +729,12 @@ type AdvPlayerDaySummary struct {
|
||||
ForagingSkill int
|
||||
FishingSkill int
|
||||
|
||||
Activity string
|
||||
Location string
|
||||
Outcome string
|
||||
LootValue int64
|
||||
IsDead bool
|
||||
DeadUntil string
|
||||
Activity string
|
||||
Location string
|
||||
Outcome string
|
||||
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.
|
||||
@@ -744,11 +744,11 @@ type AdvPlayerDaySummary struct {
|
||||
// {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
|
||||
DeathSource string
|
||||
DeathLocation string
|
||||
IsResting bool
|
||||
SummaryLine string
|
||||
HolidayActions int // 0 = not holiday or no action; 1 = took one; 2 = took both
|
||||
DeathSource string
|
||||
DeathLocation string
|
||||
}
|
||||
|
||||
// advPlayerHeadline renders the per-player headline for the daily report.
|
||||
@@ -920,7 +920,6 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
|
||||
// Standout
|
||||
if bestPlayer != nil && bestPlayer.LootValue > 0 {
|
||||
pool := SummaryStandoutGood
|
||||
@@ -984,8 +983,8 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
|
||||
// Sort by score
|
||||
type entry struct {
|
||||
Name string
|
||||
Score int
|
||||
Name string
|
||||
Score int
|
||||
Levels string
|
||||
Streak int
|
||||
}
|
||||
|
||||
@@ -389,6 +389,9 @@ func (p *AdventurePlugin) midnightTicker() {
|
||||
slog.Error("adventure: midnight reset failed, will retry next tick", "err", err)
|
||||
continue
|
||||
}
|
||||
// Close out the arena season if one just ended. Self-dedups on the
|
||||
// season key, so this is a cheap no-op on every other night.
|
||||
p.arenaSeasonRollover(time.Now().UTC())
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
lastRanDate = dateKey
|
||||
}
|
||||
@@ -396,7 +399,6 @@ func (p *AdventurePlugin) midnightTicker() {
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) midnightReset() error {
|
||||
// Send idle shame DMs to players who didn't act
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load chars: %w", err)
|
||||
@@ -405,79 +407,89 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
|
||||
// Unified activity oracle — same source the daily report uses. Unions
|
||||
// adventure_activity_log + dnd_zone_run + dnd_expedition_log, so
|
||||
// background-autopilot walks, expedition extracts/completions, and any
|
||||
// subsystem that logged a beat all count as "something happened on this
|
||||
// player's behalf." LastActionDate alone misses the autopilot path
|
||||
// (dnd_zone_cmd.go gates markActedToday on !compact to keep autopilot
|
||||
// out of streak credit) — without this oracle, a player who let the
|
||||
// autopilot run a full expedition gets shamed at midnight.
|
||||
todayActs, _ := loadAdvDailyActivity(today)
|
||||
yesterdayActs, _ := loadAdvDailyActivity(yesterday)
|
||||
|
||||
dmsSent := 0
|
||||
for _, char := range chars {
|
||||
if !char.HasActedToday() {
|
||||
// If the player died today or yesterday, they couldn't act — no shame,
|
||||
// no streak reset. This covers both currently-dead players and players
|
||||
// who were just revived at midnight (Alive already flipped to true by
|
||||
// the reminder loop before midnightReset runs).
|
||||
if char.LastDeathDate == today || char.LastDeathDate == yesterday {
|
||||
continue
|
||||
}
|
||||
// Died inside the window — no shame, no streak change. Covers both
|
||||
// currently-dead players and players revived at midnight (Alive
|
||||
// already flipped to true by the reminder loop before this runs).
|
||||
if char.LastDeathDate == today || char.LastDeathDate == yesterday {
|
||||
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
|
||||
}
|
||||
// Player-initiated engagement — only this credits the streak.
|
||||
// LastActionDate is stamped at action time by markActedToday + !rest;
|
||||
// the legacy counters get bumped by the legacy CanDo... paths.
|
||||
engaged := char.LastActionDate == today || char.LastActionDate == yesterday ||
|
||||
char.CombatActionsUsed > 0 || char.HarvestActionsUsed > 0
|
||||
|
||||
// Jitter between DMs to avoid Matrix rate limits
|
||||
if dmsSent > 0 {
|
||||
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
|
||||
}
|
||||
dmsSent++
|
||||
|
||||
// Idle shame DM
|
||||
text := renderAdvIdleShameDM(char.UserID)
|
||||
if char.CurrentStreak > 0 {
|
||||
oldStreak := char.CurrentStreak
|
||||
char.CurrentStreak /= 2
|
||||
char.StreakDecayed = true
|
||||
text += fmt.Sprintf("\n\n🔥 Streak: %d → %d days", oldStreak, char.CurrentStreak)
|
||||
if char.CurrentStreak > 0 {
|
||||
text += " — not all is lost."
|
||||
}
|
||||
_ = saveAdvCharacter(&char)
|
||||
}
|
||||
if err := p.SendDM(char.UserID, text); err != nil {
|
||||
slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err)
|
||||
}
|
||||
} else {
|
||||
// Update streak — LastActionDate was set at action time
|
||||
if engaged {
|
||||
if char.LastActionDate == yesterday || char.LastActionDate == today {
|
||||
char.CurrentStreak++
|
||||
} else {
|
||||
// Gap in activity — start fresh
|
||||
// Legacy-only path: counters bumped but LastActionDate stale.
|
||||
// Without this fall-through the streak would reset to 1 every
|
||||
// night even with continuous play.
|
||||
char.CurrentStreak = 1
|
||||
}
|
||||
if char.CurrentStreak > char.BestStreak {
|
||||
char.BestStreak = char.CurrentStreak
|
||||
}
|
||||
char.LastActionDate = today
|
||||
_ = saveAdvCharacter(&char)
|
||||
continue
|
||||
}
|
||||
|
||||
// Activity happened on the player's behalf without an explicit tap —
|
||||
// autopilot walked rooms, expedition log gained beats, a fight session
|
||||
// is still locked open. Hold the streak: no bump, no shame, no decay.
|
||||
// The player engaged earlier to kick this off; autopilot is a feature,
|
||||
// not a way to game streaks, and absence of taps shouldn't strip
|
||||
// progress they earned through real play.
|
||||
if len(todayActs[char.UserID]) > 0 || len(yesterdayActs[char.UserID]) > 0 {
|
||||
continue
|
||||
}
|
||||
// Safety net for live state the activity logs don't reflect yet
|
||||
// (e.g. an active expedition that's been quiet today, or a combat
|
||||
// session locked open across midnight without a log append).
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
if hasActiveCombatSession(char.UserID) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Truly idle — shame DM + streak halve.
|
||||
if dmsSent > 0 {
|
||||
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
|
||||
}
|
||||
dmsSent++
|
||||
|
||||
text := renderAdvIdleShameDM(char.UserID)
|
||||
if char.CurrentStreak > 0 {
|
||||
oldStreak := char.CurrentStreak
|
||||
char.CurrentStreak /= 2
|
||||
char.StreakDecayed = true
|
||||
text += fmt.Sprintf("\n\n🔥 Streak: %d → %d days", oldStreak, char.CurrentStreak)
|
||||
if char.CurrentStreak > 0 {
|
||||
text += " — not all is lost."
|
||||
}
|
||||
_ = saveAdvCharacter(&char)
|
||||
}
|
||||
if err := p.SendDM(char.UserID, text); err != nil {
|
||||
slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,6 +507,12 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
return fmt.Errorf("reset daily actions after 3 attempts: %w", resetErr)
|
||||
}
|
||||
|
||||
// Clear the one-day pet morning-defense buff so it re-rolls fresh each
|
||||
// morning (briefing or overworld DM) instead of leaking permanently.
|
||||
if err := resetAllPetMorningDefense(); err != nil {
|
||||
slog.Error("adventure: failed to reset pet morning defense", "err", err)
|
||||
}
|
||||
|
||||
// Prune expired buffs
|
||||
if err := pruneAdvExpiredBuffs(); err != nil {
|
||||
slog.Error("adventure: failed to prune expired buffs", "err", err)
|
||||
|
||||
145
internal/plugin/adventure_scheduler_test.go
Normal file
145
internal/plugin/adventure_scheduler_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// TestMidnightReset_Branching exercises the three idle-reaper branches:
|
||||
// - engaged: LastActionDate stamped today/yesterday → streak bumps
|
||||
// - activity-without-tap: autopilot/background activity logged today but
|
||||
// no LastActionDate stamp → streak holds, no shame DM, no decay
|
||||
// - truly idle: nothing anywhere → streak halves, StreakDecayed=true
|
||||
//
|
||||
// Regression guard for the autopilot bug: a player who let the background
|
||||
// auto-run walk an expedition all day used to get shamed at midnight because
|
||||
// markActedToday is gated on !compact at dnd_zone_cmd.go.
|
||||
func TestMidnightReset_Branching(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
|
||||
mk := func(uid, lastAction string, streak int) id.UserID {
|
||||
u := id.UserID(uid)
|
||||
if err := createAdvCharacter(u, uid); err != nil {
|
||||
t.Fatalf("createAdvCharacter %s: %v", uid, err)
|
||||
}
|
||||
c, err := loadAdvCharacter(u)
|
||||
if err != nil {
|
||||
t.Fatalf("load %s: %v", uid, err)
|
||||
}
|
||||
c.LastActionDate = lastAction
|
||||
c.CurrentStreak = streak
|
||||
c.BestStreak = streak
|
||||
if err := saveAdvCharacter(c); err != nil {
|
||||
t.Fatalf("save %s: %v", uid, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
engaged := mk("@engaged:example", yesterday, 5)
|
||||
autopilot := mk("@autopilot:example", "2020-01-01", 7)
|
||||
idle := mk("@idle:example", "2020-01-01", 8)
|
||||
|
||||
// Simulate autopilot activity: a legacy activity-log row dated today.
|
||||
// loadAdvDailyActivity unions this in, so the reaper sees the player as
|
||||
// "had activity" without their LastActionDate being current.
|
||||
if _, err := db.Get().Exec(
|
||||
`INSERT INTO adventure_activity_log
|
||||
(user_id, activity_type, location, outcome, loot_value, xp_gained, flavor_key, logged_at)
|
||||
VALUES (?, 'zone', 'Test Zone', 'in_progress', 0, 0, '', CURRENT_TIMESTAMP)`,
|
||||
string(autopilot),
|
||||
); err != nil {
|
||||
t.Fatalf("insert activity row: %v", err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.midnightReset(); err != nil {
|
||||
t.Fatalf("midnightReset: %v", err)
|
||||
}
|
||||
|
||||
// Engaged → streak bumped, LastActionDate restamped to today, no decay.
|
||||
if c, _ := loadAdvCharacter(engaged); c != nil {
|
||||
if c.CurrentStreak != 6 {
|
||||
t.Errorf("engaged: streak = %d, want 6", c.CurrentStreak)
|
||||
}
|
||||
if c.LastActionDate != today {
|
||||
t.Errorf("engaged: LastActionDate = %q, want %q", c.LastActionDate, today)
|
||||
}
|
||||
if c.StreakDecayed {
|
||||
t.Error("engaged: StreakDecayed = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// Autopilot → hold. Streak unchanged, LastActionDate stays stale, no decay.
|
||||
if c, _ := loadAdvCharacter(autopilot); c != nil {
|
||||
if c.CurrentStreak != 7 {
|
||||
t.Errorf("autopilot: streak = %d, want 7 (held)", c.CurrentStreak)
|
||||
}
|
||||
if c.StreakDecayed {
|
||||
t.Error("autopilot: StreakDecayed = true, want false (shamed by mistake)")
|
||||
}
|
||||
if c.LastActionDate == today {
|
||||
t.Errorf("autopilot: LastActionDate restamped to today — autopilot must not credit streak via LastActionDate")
|
||||
}
|
||||
}
|
||||
|
||||
// Idle → shame DM (no-op without Matrix client) + streak halved + decay flagged.
|
||||
if c, _ := loadAdvCharacter(idle); c != nil {
|
||||
if c.CurrentStreak != 4 {
|
||||
t.Errorf("idle: streak = %d, want 4 (halved from 8)", c.CurrentStreak)
|
||||
}
|
||||
if !c.StreakDecayed {
|
||||
t.Error("idle: StreakDecayed = false, want true")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMidnightReset_DeathWindowSkips: a player who died today/yesterday is
|
||||
// left untouched — no shame, no streak change, no decay flag.
|
||||
func TestMidnightReset_DeathWindowSkips(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
u := id.UserID("@dead:example")
|
||||
if err := createAdvCharacter(u, "dead"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
c, _ := loadAdvCharacter(u)
|
||||
c.LastActionDate = "2020-01-01"
|
||||
c.CurrentStreak = 10
|
||||
c.BestStreak = 10
|
||||
c.LastDeathDate = today
|
||||
if err := saveAdvCharacter(c); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.midnightReset(); err != nil {
|
||||
t.Fatalf("midnightReset: %v", err)
|
||||
}
|
||||
|
||||
got, _ := loadAdvCharacter(u)
|
||||
if got.CurrentStreak != 10 {
|
||||
t.Errorf("dead: streak = %d, want 10 (untouched)", got.CurrentStreak)
|
||||
}
|
||||
if got.StreakDecayed {
|
||||
t.Error("dead: StreakDecayed = true, want false")
|
||||
}
|
||||
}
|
||||
@@ -281,17 +281,17 @@ func TestLuigiCategoryIntros_Coverage(t *testing.T) {
|
||||
|
||||
func TestLuigiFlavorPools_NonEmpty(t *testing.T) {
|
||||
pools := map[string][]string{
|
||||
"luigiGreetings": luigiGreetings,
|
||||
"luigiPurchaseConfirm": luigiPurchaseConfirm,
|
||||
"luigiTier5Confirm": luigiTier5Confirm,
|
||||
"luigiComboConfirm": luigiComboConfirm,
|
||||
"luigiGreetings": luigiGreetings,
|
||||
"luigiPurchaseConfirm": luigiPurchaseConfirm,
|
||||
"luigiTier5Confirm": luigiTier5Confirm,
|
||||
"luigiComboConfirm": luigiComboConfirm,
|
||||
"luigiInsufficientFunds": luigiInsufficientFunds,
|
||||
"luigiBrowseTimeout": luigiBrowseTimeout,
|
||||
"luigiMaxedOut": luigiMaxedOut,
|
||||
"luigiMasterworkAck": luigiMasterworkAck,
|
||||
"luigiShowAllComment": luigiShowAllComment,
|
||||
"luigiCommentary": luigiCommentary,
|
||||
"luigiCancellation": luigiCancellation,
|
||||
"luigiBrowseTimeout": luigiBrowseTimeout,
|
||||
"luigiMaxedOut": luigiMaxedOut,
|
||||
"luigiMasterworkAck": luigiMasterworkAck,
|
||||
"luigiShowAllComment": luigiShowAllComment,
|
||||
"luigiCommentary": luigiCommentary,
|
||||
"luigiCancellation": luigiCancellation,
|
||||
}
|
||||
for name, pool := range pools {
|
||||
if len(pool) == 0 {
|
||||
|
||||
406
internal/plugin/adventure_temper.go
Normal file
406
internal/plugin/adventure_temper.go
Normal file
@@ -0,0 +1,406 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Tempering ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The blacksmith's endgame role: push an owned magic item one rung up the
|
||||
// rarity ladder (gogobee_engagement_plan.md B1). Rarity flows through the
|
||||
// existing scalar formulas in magic_items_gameplay.go, so tempering adds no
|
||||
// new combat math — and because it caps at Legendary it accelerates reaching
|
||||
// the existing best-in-slot ceiling rather than raising it. Every duplicate
|
||||
// drop becomes a candidate instead of vendor trash.
|
||||
//
|
||||
// Rarity itself lives on the registry definition, so a tempered instance
|
||||
// records its progress as a `temper` step count on its own row; effective
|
||||
// rarity is derived on read (see temperedItem).
|
||||
|
||||
// temperMaterialNames are the legendary crafting materials the final rung
|
||||
// consumes. Both drop as UniqueAlways entries on their T5 zone slate, so one
|
||||
// clear of the Underforge or the Abyss Portal yields exactly one.
|
||||
var temperMaterialNames = []string{"Thyraks Core", "Portal Fragment"}
|
||||
|
||||
// temperStepCost is what one rung costs, keyed by the *target* rarity's tier.
|
||||
// The euro costs and foraging gates mirror the crafting recipe tiers in
|
||||
// adventure_consumables.go, so the blacksmith reads as an extension of the
|
||||
// crafting ladder rather than a parallel economy.
|
||||
type temperStepCost struct {
|
||||
Euros int
|
||||
MinForaging int
|
||||
NeedsMaterial bool
|
||||
}
|
||||
|
||||
// Only the final rung demands a T5 material. Gating the lower rungs on one too
|
||||
// would make tempering unreachable until a player already clears T5 content —
|
||||
// which is exactly when they stop needing it.
|
||||
var temperCosts = map[int]temperStepCost{
|
||||
2: {Euros: 10_000, MinForaging: 15},
|
||||
3: {Euros: 25_000, MinForaging: 20},
|
||||
4: {Euros: 60_000, MinForaging: 25},
|
||||
5: {Euros: 150_000, MinForaging: 30, NeedsMaterial: true},
|
||||
}
|
||||
|
||||
// temperTarget is one temperable item, from either side of the equip line.
|
||||
type temperTarget struct {
|
||||
Equipped bool
|
||||
Slot DnDSlot // set when Equipped
|
||||
InvID int64 // set when not Equipped
|
||||
Base MagicItem
|
||||
Temper int
|
||||
}
|
||||
|
||||
// Effective is the item as it stands today, before this temper.
|
||||
func (t temperTarget) Effective() MagicItem { return temperedItem(t.Base, t.Temper) }
|
||||
|
||||
// NextRarity is the rung this temper would buy.
|
||||
func (t temperTarget) NextRarity() DnDRarity { return temperedRarity(t.Base.Rarity, t.Temper+1) }
|
||||
|
||||
// cost is the price of this target's next rung.
|
||||
func (t temperTarget) cost() temperStepCost { return temperCosts[rarityLootTierNum(t.NextRarity())] }
|
||||
|
||||
// sameItemAs reports whether other is the same physical instance, used to
|
||||
// re-find a target after a fresh reload so a concurrent equip/sell can't let a
|
||||
// stale pending confirm temper the wrong thing.
|
||||
func (t temperTarget) sameItemAs(other temperTarget) bool {
|
||||
if t.Equipped != other.Equipped {
|
||||
return false
|
||||
}
|
||||
if t.Equipped {
|
||||
return t.Slot == other.Slot && t.Base.ID == other.Base.ID
|
||||
}
|
||||
return t.InvID == other.InvID
|
||||
}
|
||||
|
||||
type advPendingTemperPick struct {
|
||||
Targets []temperTarget
|
||||
}
|
||||
|
||||
type advPendingTemperConfirm struct {
|
||||
Target temperTarget
|
||||
Material *AdvItem // consumed on confirm; nil when the rung needs none
|
||||
}
|
||||
|
||||
// collectTemperTargets gathers every magic item the player owns that still has
|
||||
// a rung ahead of it — worn or stowed. Potions and scrolls are excluded: they
|
||||
// carry a zero combat effect, so a rarity bump would buy nothing.
|
||||
func collectTemperTargets(userID id.UserID) ([]temperTarget, error) {
|
||||
var out []temperTarget
|
||||
|
||||
equipped, err := loadEquippedMagicItems(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ds := range dndSlotOrder {
|
||||
e, ok := equipped[ds]
|
||||
if !ok || e.Item.ID == "" {
|
||||
continue
|
||||
}
|
||||
if temperStepsToLegendary(e.Item.Rarity, e.Temper) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, temperTarget{Equipped: true, Slot: ds, Base: e.Item, Temper: e.Temper})
|
||||
}
|
||||
|
||||
items, err := loadAdvInventory(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.Type != "magic_item" {
|
||||
continue
|
||||
}
|
||||
mi, ok := magicItemFromAdvItem(it)
|
||||
if !ok || mi.Kind == MagicItemPotion || mi.Kind == MagicItemScroll {
|
||||
continue
|
||||
}
|
||||
if temperStepsToLegendary(mi.Rarity, it.Temper) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, temperTarget{InvID: it.ID, Base: mi, Temper: it.Temper})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// findTemperMaterial returns the first legendary material row in inventory.
|
||||
func findTemperMaterial(items []AdvItem) (AdvItem, bool) {
|
||||
for _, it := range items {
|
||||
for _, name := range temperMaterialNames {
|
||||
if it.Name == name {
|
||||
return it, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return AdvItem{}, false
|
||||
}
|
||||
|
||||
// parseTemperIndex reads a leading 1-indexed number and bounds-checks it.
|
||||
func parseTemperIndex(reply string, n int) (int, bool) {
|
||||
idx, parsed := 0, false
|
||||
for _, c := range strings.TrimSpace(reply) {
|
||||
if c < '0' || c > '9' {
|
||||
break
|
||||
}
|
||||
idx = idx*10 + int(c-'0')
|
||||
parsed = true
|
||||
}
|
||||
idx--
|
||||
if !parsed || idx < 0 || idx >= n {
|
||||
return 0, false
|
||||
}
|
||||
return idx, true
|
||||
}
|
||||
|
||||
// ── Command ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleTemperCmd(ctx MessageContext) error {
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
targets, err := collectTemperTargets(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to read your curios.")
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return p.SendDM(ctx.Sender,
|
||||
"_He looks over your kit and shrugs._ Nothing here I can push any further. "+
|
||||
"Bring me something that isn't already Legendary.")
|
||||
}
|
||||
|
||||
greeting, _ := advPickFlavor(blacksmithTemperGreetings, ctx.Sender, "temper_greet")
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🔨 **Tempering**\n\n")
|
||||
sb.WriteString(greeting)
|
||||
sb.WriteString("\n\n")
|
||||
for i, t := range targets {
|
||||
cur := t.Effective()
|
||||
cost := t.cost()
|
||||
where := "stowed"
|
||||
if t.Equipped {
|
||||
where = "worn — " + string(t.Slot)
|
||||
}
|
||||
gate := ""
|
||||
switch {
|
||||
case char.ForagingSkill < cost.MinForaging:
|
||||
gate = fmt.Sprintf(" — 🔒 needs Foraging %d", cost.MinForaging)
|
||||
case cost.NeedsMaterial:
|
||||
gate = " — needs a legendary material"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. **%s** _(%s → %s)_ — €%d _(%s)_%s\n",
|
||||
i+1, cur.Name, cur.Rarity, t.NextRarity(), cost.Euros, where, gate))
|
||||
}
|
||||
sb.WriteString("\nReply with a number to temper it, or \"cancel\".")
|
||||
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "temper_pick",
|
||||
Data: &advPendingTemperPick{Targets: targets},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, sb.String())
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) resolveTemperPick(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
data := interaction.Data.(*advPendingTemperPick)
|
||||
reply := strings.TrimSpace(ctx.Body)
|
||||
if strings.EqualFold(reply, "cancel") {
|
||||
return p.SendDM(ctx.Sender, "_The forge keeps burning._ Come back when you've decided.")
|
||||
}
|
||||
idx, ok := parseTemperIndex(reply, len(data.Targets))
|
||||
if !ok {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "Reply with a number from the list, or \"cancel\".")
|
||||
}
|
||||
return p.buildTemperConfirm(ctx.Sender, data.Targets[idx])
|
||||
}
|
||||
|
||||
// buildTemperConfirm re-checks every gate against fresh state before quoting a
|
||||
// price, then parks a confirm. executeTemper re-checks them all again — this
|
||||
// pass exists to give the player a real error message, not to be trusted.
|
||||
func (p *AdventurePlugin) buildTemperConfirm(userID id.UserID, t temperTarget) error {
|
||||
char, _, err := p.ensureCharacter(userID)
|
||||
if err != nil {
|
||||
return p.SendDM(userID, "Failed to load your character.")
|
||||
}
|
||||
cost := t.cost()
|
||||
if char.ForagingSkill < cost.MinForaging {
|
||||
return p.SendDM(userID, fmt.Sprintf(
|
||||
"_He turns the piece over, then sets it down._ I could ruin this. Come back when you know your "+
|
||||
"materials better — **Foraging %d**. You're at %d.", cost.MinForaging, char.ForagingSkill))
|
||||
}
|
||||
|
||||
items, err := loadAdvInventory(userID)
|
||||
if err != nil {
|
||||
return p.SendDM(userID, "Failed to read your inventory.")
|
||||
}
|
||||
var material *AdvItem
|
||||
if cost.NeedsMaterial {
|
||||
mat, ok := findTemperMaterial(items)
|
||||
if !ok {
|
||||
return p.SendDM(userID, fmt.Sprintf(
|
||||
"_He exhales through his teeth._ Legendary is a different conversation. I need a **%s** on this "+
|
||||
"table before I touch it. They come out of the deepest places — the Underforge, the Portal.",
|
||||
strings.Join(temperMaterialNames, "** or a **")))
|
||||
}
|
||||
material = &mat
|
||||
}
|
||||
|
||||
if bal := p.euro.GetBalance(userID); bal < float64(cost.Euros) {
|
||||
return p.SendDM(userID, fmt.Sprintf(
|
||||
"_He names the price without apology._ €%d. You have €%.0f. The forge doesn't run on good intentions.",
|
||||
cost.Euros, bal))
|
||||
}
|
||||
|
||||
cur := t.Effective()
|
||||
next := temperedItem(t.Base, t.Temper+1)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🔨 **Temper %s?**\n\n", cur.Name))
|
||||
sb.WriteString(fmt.Sprintf(" %s _(%s)_ → %s _(%s)_\n", cur.Name, cur.Rarity, next.Name, next.Rarity))
|
||||
sb.WriteString(fmt.Sprintf(" %s\n ↳ %s\n\n", magicItemEffectSummary(cur), magicItemEffectSummary(next)))
|
||||
sb.WriteString(fmt.Sprintf(" Cost: **€%d**", cost.Euros))
|
||||
if material != nil {
|
||||
sb.WriteString(fmt.Sprintf(" + one **%s**", material.Name))
|
||||
}
|
||||
sb.WriteString("\n\nReply \"yes\" to hand it over, or anything else to keep it as it is.")
|
||||
|
||||
p.pending.Store(string(userID), &advPendingInteraction{
|
||||
Type: "temper_confirm",
|
||||
Data: &advPendingTemperConfirm{Target: t, Material: material},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
return p.SendDM(userID, sb.String())
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) resolveTemperConfirm(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
data := interaction.Data.(*advPendingTemperConfirm)
|
||||
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||
if reply != "yes" && reply != "y" && reply != "confirm" {
|
||||
return p.SendDM(ctx.Sender, "_He nods once and turns back to the fire._ It's a good piece as it is.")
|
||||
}
|
||||
return p.executeTemper(ctx.Sender, data)
|
||||
}
|
||||
|
||||
// executeTemper re-validates against fresh state, then charges and applies.
|
||||
// Order matters: the euro debit is the only atomic guard we have, so it goes
|
||||
// first; the material and the temper write both roll back onto it.
|
||||
func (p *AdventurePlugin) executeTemper(userID id.UserID, data *advPendingTemperConfirm) error {
|
||||
char, _, err := p.ensureCharacter(userID)
|
||||
if err != nil {
|
||||
return p.SendDM(userID, "Failed to load your character.")
|
||||
}
|
||||
|
||||
// Re-find the target. Between the confirm prompt and this reply the
|
||||
// player may have equipped, sold, or already tempered it.
|
||||
fresh, err := collectTemperTargets(userID)
|
||||
if err != nil {
|
||||
return p.SendDM(userID, "Failed to read your curios.")
|
||||
}
|
||||
var target temperTarget
|
||||
found := false
|
||||
for _, t := range fresh {
|
||||
if t.sameItemAs(data.Target) {
|
||||
target, found = t, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return p.SendDM(userID, "_He looks at the empty table._ That piece isn't here any more.")
|
||||
}
|
||||
if target.Temper != data.Target.Temper {
|
||||
return p.SendDM(userID, "_He raises an eyebrow._ Someone's already been at this one. Ask me again.")
|
||||
}
|
||||
|
||||
cost := target.cost()
|
||||
if char.ForagingSkill < cost.MinForaging {
|
||||
return p.SendDM(userID, fmt.Sprintf("You need **Foraging %d** for that.", cost.MinForaging))
|
||||
}
|
||||
|
||||
// Re-locate the material by row id — a concurrent craft may have eaten it.
|
||||
var material *AdvItem
|
||||
if cost.NeedsMaterial {
|
||||
items, err := loadAdvInventory(userID)
|
||||
if err != nil {
|
||||
return p.SendDM(userID, "Failed to read your inventory.")
|
||||
}
|
||||
mat, ok := findTemperMaterial(items)
|
||||
if !ok {
|
||||
return p.SendDM(userID, "_He taps the empty spot on the table._ The material's gone. Find another.")
|
||||
}
|
||||
material = &mat
|
||||
}
|
||||
|
||||
if !p.euro.Debit(userID, float64(cost.Euros), "temper") {
|
||||
return p.SendDM(userID, fmt.Sprintf("Payment failed — tempering costs €%d.", cost.Euros))
|
||||
}
|
||||
|
||||
refund := func(reason string) {
|
||||
p.euro.Credit(userID, float64(cost.Euros), "temper_refund")
|
||||
slog.Error("temper: rolled back", "user", userID, "item", target.Base.ID, "reason", reason)
|
||||
}
|
||||
|
||||
if material != nil {
|
||||
if err := removeAdvInventoryItem(material.ID); err != nil {
|
||||
refund("material consume failed")
|
||||
return p.SendDM(userID, "Something went wrong at the forge. Your money is back.")
|
||||
}
|
||||
}
|
||||
|
||||
newTemper := target.Temper + 1
|
||||
if target.Equipped {
|
||||
err = temperEquippedItem(userID, target.Slot, newTemper)
|
||||
} else {
|
||||
bumped := temperedItem(target.Base, newTemper)
|
||||
err = temperInventoryItem(target.InvID, newTemper, rarityLootTierNum(bumped.Rarity), int64(bumped.Value))
|
||||
}
|
||||
if err != nil {
|
||||
refund("temper write failed")
|
||||
if material != nil {
|
||||
if rbErr := addAdvInventoryItem(userID, *material); rbErr != nil {
|
||||
slog.Error("temper: material rollback failed",
|
||||
"user", userID, "material", material.Name, "err", rbErr)
|
||||
}
|
||||
}
|
||||
return p.SendDM(userID, "Something went wrong at the forge. Your money is back.")
|
||||
}
|
||||
|
||||
result := temperedItem(target.Base, newTemper)
|
||||
line, _ := advPickFlavor(blacksmithTemperCompletion, userID, "temper_done")
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(line)
|
||||
sb.WriteString(fmt.Sprintf("\n\n🔨 **%s** is now **%s**.\n%s\n",
|
||||
result.Name, result.Rarity, magicItemEffectSummary(result)))
|
||||
if remaining := temperStepsToLegendary(target.Base.Rarity, newTemper); remaining > 0 {
|
||||
sb.WriteString(fmt.Sprintf("\n_%d more temper%s would make it Legendary._",
|
||||
remaining, pluralS(remaining)))
|
||||
}
|
||||
if err := p.SendDM(userID, sb.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.Rarity == RarityLegendary {
|
||||
if p.achievements != nil {
|
||||
p.achievements.GrantAchievement(userID, "temper_legendary")
|
||||
}
|
||||
if gr := gamesRoom(); gr != "" {
|
||||
displayName, _ := loadDisplayName(userID)
|
||||
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
|
||||
"🔨 The forge in town ran hot all night. **%s** walked out with a **Legendary %s**.",
|
||||
displayName, result.Name))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
355
internal/plugin/adventure_temper_test.go
Normal file
355
internal/plugin/adventure_temper_test.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// TestTemperLadderWalk pins the rung order and the Legendary clamp. VeryRare
|
||||
// must fold onto Epic — it shares Epic's tier and power scalar, so treating it
|
||||
// as its own rung would sell a step that changes no number.
|
||||
func TestTemperLadderWalk(t *testing.T) {
|
||||
tests := []struct {
|
||||
base DnDRarity
|
||||
steps int
|
||||
want DnDRarity
|
||||
}{
|
||||
{RarityCommon, 0, RarityCommon},
|
||||
{RarityCommon, 1, RarityUncommon},
|
||||
{RarityCommon, 4, RarityLegendary},
|
||||
{RarityCommon, 99, RarityLegendary}, // clamp, never past the ceiling
|
||||
{RarityRare, 1, RarityEpic},
|
||||
{RarityEpic, 1, RarityLegendary},
|
||||
{RarityLegendary, 1, RarityLegendary},
|
||||
{RarityVeryRare, 0, RarityVeryRare}, // untempered items keep their label
|
||||
{RarityVeryRare, 1, RarityLegendary},
|
||||
{RarityUncommon, -1, RarityUncommon}, // negative steps are inert
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := temperedRarity(tc.base, tc.steps); got != tc.want {
|
||||
t.Errorf("temperedRarity(%s, %d) = %s, want %s", tc.base, tc.steps, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemperStepsToLegendary(t *testing.T) {
|
||||
tests := []struct {
|
||||
base DnDRarity
|
||||
steps int
|
||||
want int
|
||||
}{
|
||||
{RarityCommon, 0, 4},
|
||||
{RarityCommon, 2, 2},
|
||||
{RarityCommon, 4, 0},
|
||||
{RarityCommon, 10, 0},
|
||||
{RarityVeryRare, 0, 1}, // folds onto Epic, one rung short
|
||||
{RarityLegendary, 0, 0},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := temperStepsToLegendary(tc.base, tc.steps); got != tc.want {
|
||||
t.Errorf("temperStepsToLegendary(%s, %d) = %d, want %d", tc.base, tc.steps, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemperCostsCoverEveryRung guards against a ladder edit that leaves a rung
|
||||
// priced at the zero value — a free temper.
|
||||
func TestTemperCostsCoverEveryRung(t *testing.T) {
|
||||
for rung := 1; rung < len(temperLadder); rung++ {
|
||||
target := temperLadder[rung]
|
||||
cost, ok := temperCosts[rarityLootTierNum(target)]
|
||||
if !ok {
|
||||
t.Fatalf("no temper cost for target rarity %s", target)
|
||||
}
|
||||
if cost.Euros <= 0 || cost.MinForaging <= 0 {
|
||||
t.Errorf("temper to %s is free: %+v", target, cost)
|
||||
}
|
||||
}
|
||||
// Only the Legendary rung consumes a material.
|
||||
for tier, cost := range temperCosts {
|
||||
if want := tier == 5; cost.NeedsMaterial != want {
|
||||
t.Errorf("tier %d NeedsMaterial = %v, want %v", tier, cost.NeedsMaterial, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemperedItemScalesPowerAndValue asserts a tempered item is exactly an
|
||||
// item of its effective rarity — the whole point of B1 is that rarity flows
|
||||
// through the existing scalar formulas with no new combat math.
|
||||
func TestTemperedItemScalesPowerAndValue(t *testing.T) {
|
||||
base := MagicItem{ID: "x", Name: "Thing", Kind: MagicItemWeapon, Rarity: RarityRare, Value: 300}
|
||||
|
||||
if got := temperedItem(base, 0); got != base {
|
||||
t.Errorf("temper 0 mutated the item: %+v", got)
|
||||
}
|
||||
|
||||
up := temperedItem(base, 1)
|
||||
if up.Rarity != RarityEpic {
|
||||
t.Fatalf("rarity = %s, want Epic", up.Rarity)
|
||||
}
|
||||
// Value tracks the power axis: Rare=3 → Epic=4.
|
||||
if wantVal := int(300 * (rarityPowerScalar(RarityEpic) / rarityPowerScalar(RarityRare))); up.Value != wantVal {
|
||||
t.Errorf("value = %d, want %d", up.Value, wantVal)
|
||||
}
|
||||
// A tempered Epic must fight exactly like a native Epic. (Its Value is
|
||||
// deliberately higher than a native Epic definition's — value tracks the
|
||||
// power axis, and the player paid to move it there.)
|
||||
native := base
|
||||
native.Rarity = RarityEpic
|
||||
if magicItemEffectFor(up) != magicItemEffectFor(native) {
|
||||
t.Errorf("tempered effect diverges from native Epic effect")
|
||||
}
|
||||
// Base must be untouched — temperedItem takes a copy.
|
||||
if base.Rarity != RarityRare || base.Value != 300 {
|
||||
t.Errorf("temperedItem mutated its argument: %+v", base)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemperNeverDoubleBumps is the invariant the whole design hangs on: the
|
||||
// stored row keeps BASE rarity plus a step count, so loading and re-saving an
|
||||
// item any number of times must not compound the upgrade.
|
||||
func TestTemperNeverDoubleBumps(t *testing.T) {
|
||||
base := MagicItem{ID: "x", Name: "Thing", Kind: MagicItemWeapon, Rarity: RarityCommon, Value: 100}
|
||||
e := EquippedMagicItem{Item: base, Temper: 2}
|
||||
|
||||
first := e.Effective()
|
||||
// Simulate a load→save→load cycle: Item stays base, Temper stays put.
|
||||
again := EquippedMagicItem{Item: e.Item, Temper: e.Temper}.Effective()
|
||||
if first != again {
|
||||
t.Errorf("round-trip changed the item: %+v vs %+v", first, again)
|
||||
}
|
||||
if e.Item.Rarity != RarityCommon {
|
||||
t.Errorf("Effective() mutated the stored base rarity to %s", e.Item.Rarity)
|
||||
}
|
||||
if first.Rarity != RarityRare {
|
||||
t.Errorf("Common + 2 tempers = %s, want Rare", first.Rarity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemperSurvivesEquipRoundTrip: a tempered item that gets swapped back to
|
||||
// inventory must carry its temper with it, priced at its effective rarity.
|
||||
// This is the path resolveMagicEquipReply takes on a slot swap.
|
||||
func TestTemperSurvivesEquipRoundTrip(t *testing.T) {
|
||||
base := MagicItem{ID: "x", Name: "Thing", Kind: MagicItemWeapon, Rarity: RarityCommon, Value: 100}
|
||||
|
||||
back := magicItemSellAt(base, 3)
|
||||
if back.Temper != 3 {
|
||||
t.Errorf("swap-back lost temper: %d", back.Temper)
|
||||
}
|
||||
if back.Tier != rarityLootTierNum(RarityEpic) {
|
||||
t.Errorf("swap-back tier = %d, want Epic tier", back.Tier)
|
||||
}
|
||||
if back.Value <= int64(base.Value) {
|
||||
t.Errorf("swap-back value %d not scaled up from %d", back.Value, base.Value)
|
||||
}
|
||||
// An untempered sell must be byte-identical to the old behaviour.
|
||||
if magicItemSellAt(base, 0) != magicItemSell(base) {
|
||||
t.Errorf("magicItemSellAt(_, 0) diverges from magicItemSell")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemperPersistence exercises the two DB writes: an equipped item's temper
|
||||
// and a stowed item's temper both survive a reload, and loadEquippedMagicItems
|
||||
// hands back a base-rarity Item with the step count beside it.
|
||||
func TestTemperPersistence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
user := id.UserID("@temper:test.invalid")
|
||||
|
||||
var item MagicItem
|
||||
for _, mi := range magicItemRegistry {
|
||||
if mi.Slot != "" && mi.Rarity == RarityCommon {
|
||||
item = mi
|
||||
break
|
||||
}
|
||||
}
|
||||
if item.ID == "" {
|
||||
for _, mi := range magicItemRegistry {
|
||||
if mi.Slot != "" && temperStepsToLegendary(mi.Rarity, 0) > 0 {
|
||||
item = mi
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if item.ID == "" {
|
||||
t.Skip("no slotted sub-Legendary item in registry")
|
||||
}
|
||||
|
||||
if err := equipMagicItem(user, item.Slot, item.ID, false, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := temperEquippedItem(user, item.Slot, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipped, err := loadEquippedMagicItems(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := equipped[item.Slot]
|
||||
if e.Temper != 1 {
|
||||
t.Fatalf("equipped temper = %d, want 1", e.Temper)
|
||||
}
|
||||
if e.Item.Rarity != item.Rarity {
|
||||
t.Errorf("stored Item.Rarity = %s, want the untouched base %s", e.Item.Rarity, item.Rarity)
|
||||
}
|
||||
if want := temperedRarity(item.Rarity, 1); e.Effective().Rarity != want {
|
||||
t.Errorf("Effective().Rarity = %s, want %s", e.Effective().Rarity, want)
|
||||
}
|
||||
|
||||
// Stowed side: a fresh inventory row defaults to temper 0, then bumps.
|
||||
inv := AdvItem{Name: item.Name, Type: "magic_item", Tier: 1, Value: 100,
|
||||
SkillSource: "magic_item:" + item.ID}
|
||||
if err := addAdvInventoryItem(user, inv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := loadAdvInventory(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Temper != 0 {
|
||||
t.Fatalf("fresh inventory row should default to temper 0, got %+v", items)
|
||||
}
|
||||
bumped := temperedItem(item, 2)
|
||||
if err := temperInventoryItem(items[0].ID, 2, rarityLootTierNum(bumped.Rarity), int64(bumped.Value)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, _ = loadAdvInventory(user)
|
||||
if items[0].Temper != 2 {
|
||||
t.Errorf("inventory temper = %d, want 2", items[0].Temper)
|
||||
}
|
||||
if items[0].Tier != rarityLootTierNum(bumped.Rarity) {
|
||||
t.Errorf("inventory tier = %d, want %d", items[0].Tier, rarityLootTierNum(bumped.Rarity))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectTemperTargetsFiltering: Legendary items and consumable-kind magic
|
||||
// items (potions/scrolls, whose combat effect is zero) are not offerable.
|
||||
func TestCollectTemperTargetsFiltering(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
user := id.UserID("@filter:test.invalid")
|
||||
|
||||
var legendary, potion, upgradable MagicItem
|
||||
for _, mi := range magicItemRegistry {
|
||||
if legendary.ID == "" && mi.Rarity == RarityLegendary && mi.Slot != "" {
|
||||
legendary = mi
|
||||
}
|
||||
if potion.ID == "" && (mi.Kind == MagicItemPotion || mi.Kind == MagicItemScroll) {
|
||||
potion = mi
|
||||
}
|
||||
if upgradable.ID == "" && mi.Slot != "" && mi.Kind == MagicItemWeapon &&
|
||||
temperStepsToLegendary(mi.Rarity, 0) > 0 {
|
||||
upgradable = mi
|
||||
}
|
||||
}
|
||||
|
||||
add := func(mi MagicItem) {
|
||||
if mi.ID == "" {
|
||||
return
|
||||
}
|
||||
if err := addAdvInventoryItem(user, AdvItem{
|
||||
Name: mi.Name, Type: "magic_item", Tier: rarityLootTierNum(mi.Rarity),
|
||||
Value: int64(mi.Value), SkillSource: "magic_item:" + mi.ID,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
add(legendary)
|
||||
add(potion)
|
||||
add(upgradable)
|
||||
|
||||
targets, err := collectTemperTargets(user)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tg := range targets {
|
||||
if legendary.ID != "" && tg.Base.ID == legendary.ID {
|
||||
t.Errorf("Legendary %s offered for tempering", legendary.Name)
|
||||
}
|
||||
if potion.ID != "" && tg.Base.ID == potion.ID {
|
||||
t.Errorf("consumable %s offered for tempering", potion.Name)
|
||||
}
|
||||
}
|
||||
if upgradable.ID != "" {
|
||||
found := false
|
||||
for _, tg := range targets {
|
||||
if tg.Base.ID == upgradable.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("upgradable %s (%s) was not offered", upgradable.Name, upgradable.Rarity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemperMaterialsAreObtainable guards the economy: every material name the
|
||||
// final rung demands must actually drop from some zone's loot slate. If a slate
|
||||
// is renamed, tempering to Legendary silently becomes impossible.
|
||||
func TestTemperMaterialsAreObtainable(t *testing.T) {
|
||||
live := map[string]bool{}
|
||||
for _, zone := range allZones() {
|
||||
for _, entry := range zone.Loot {
|
||||
live[titleCaseUnderscored(entry.ItemID)] = true
|
||||
}
|
||||
}
|
||||
for _, name := range temperMaterialNames {
|
||||
if !live[name] {
|
||||
t.Errorf("temper material %q drops from no zone loot slate", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindTemperMaterial picks the first material regardless of which one.
|
||||
func TestFindTemperMaterial(t *testing.T) {
|
||||
if _, ok := findTemperMaterial(nil); ok {
|
||||
t.Error("found a material in an empty inventory")
|
||||
}
|
||||
if _, ok := findTemperMaterial([]AdvItem{{Name: "Iron Ore"}}); ok {
|
||||
t.Error("Iron Ore accepted as a legendary material")
|
||||
}
|
||||
for _, name := range temperMaterialNames {
|
||||
got, ok := findTemperMaterial([]AdvItem{{Name: "Iron Ore"}, {ID: 7, Name: name}})
|
||||
if !ok || got.ID != 7 {
|
||||
t.Errorf("did not find material %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTemperIndex(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
n int
|
||||
want int
|
||||
wantOK bool
|
||||
}{
|
||||
{"1", 3, 0, true},
|
||||
{"3", 3, 2, true},
|
||||
{" 2 ", 3, 1, true},
|
||||
{"2. Thing", 3, 1, true},
|
||||
{"0", 3, 0, false},
|
||||
{"4", 3, 0, false},
|
||||
{"", 3, 0, false},
|
||||
{"cancel", 3, 0, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, ok := parseTemperIndex(tc.in, tc.n)
|
||||
if ok != tc.wantOK || (ok && got != tc.want) {
|
||||
t.Errorf("parseTemperIndex(%q, %d) = (%d, %v), want (%d, %v)",
|
||||
tc.in, tc.n, got, ok, tc.want, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,13 +231,13 @@ func TestCalculateAdvProbabilities_BetterGearReducesDeath(t *testing.T) {
|
||||
|
||||
func TestAdvEffectiveSkill(t *testing.T) {
|
||||
char := &AdventureCharacter{
|
||||
CombatLevel: 10,
|
||||
MiningSkill: 15,
|
||||
CombatLevel: 10,
|
||||
MiningSkill: 15,
|
||||
ForagingSkill: 20,
|
||||
}
|
||||
bonuses := &AdvBonusSummary{
|
||||
CombatBonus: 5,
|
||||
MiningBonus: 3,
|
||||
CombatBonus: 5,
|
||||
MiningBonus: 3,
|
||||
ForagingBonus: 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -246,12 +246,17 @@ var advAllTreasures = map[int][]AdvTreasureDef{
|
||||
// and the effective drop rate, so callers can surface near-miss feedback
|
||||
// ("rolled 1.8% vs 1.5% chance — just missed"). Players never see treasure
|
||||
// math otherwise, which makes rare drops feel mythical.
|
||||
func rollAdvTreasureDropDetailed(tier int, userID id.UserID, chatLevel int) (drop *AdvTreasureDrop, roll, rate float64) {
|
||||
//
|
||||
// weight scales the base rate for the moment that produced the roll: a boss
|
||||
// kill is worth more than a corridor skirmish. The chat-level bonus is added
|
||||
// after scaling so it stays a flat contribution rather than being multiplied
|
||||
// up alongside it.
|
||||
func rollAdvTreasureDropDetailed(tier int, userID id.UserID, chatLevel int, weight float64) (drop *AdvTreasureDrop, roll, rate float64) {
|
||||
r, ok := advTreasureDropRates[tier]
|
||||
if !ok {
|
||||
return nil, 0, 0
|
||||
}
|
||||
rate = r + chatLevelRareBonus(chatLevel)
|
||||
rate = r*weight + chatLevelRareBonus(chatLevel)
|
||||
roll = rand.Float64()
|
||||
|
||||
if roll >= rate {
|
||||
@@ -283,7 +288,7 @@ func rollAdvTreasureDropDetailed(tier int, userID id.UserID, chatLevel int) (dro
|
||||
// rollAdvTreasureDrop is the legacy single-return variant used by call sites
|
||||
// that don't surface near-miss feedback (auto-babysit, twinbee shares).
|
||||
func rollAdvTreasureDrop(tier int, userID id.UserID, chatLevel int) *AdvTreasureDrop {
|
||||
d, _, _ := rollAdvTreasureDropDetailed(tier, userID, chatLevel)
|
||||
d, _, _ := rollAdvTreasureDropDetailed(tier, userID, chatLevel, 1)
|
||||
return d
|
||||
}
|
||||
|
||||
|
||||
81
internal/plugin/adventure_treasure_test.go
Normal file
81
internal/plugin/adventure_treasure_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// N1/A1 — treasure drops were orphaned by the Phase R transition and now
|
||||
// hang off zone combat. The weight is what makes that safe: expedition
|
||||
// combat rolls far more often than the one-a-day legacy activity the base
|
||||
// rates were tuned for, so only boss/elite moments get a multiplier.
|
||||
|
||||
func TestAdvTreasureDropRate_ScalesWithWeight(t *testing.T) {
|
||||
// A roll under the (weighted) rate reaches the duplicate check, which
|
||||
// reads the treasure table.
|
||||
if err := db.Init(t.TempDir()); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
const user = "@weight:example.org"
|
||||
base := advTreasureDropRates[1]
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
weight float64
|
||||
want float64
|
||||
}{
|
||||
{"standard kill", advTreasureWeightStandard, base},
|
||||
{"elite doubles", advTreasureWeightElite, base * 2},
|
||||
{"boss quadruples", advTreasureWeightBoss, base * 4},
|
||||
{"zone clear is one bonus roll", advTreasureWeightZoneClear, base},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, _, rate := rollAdvTreasureDropDetailed(1, user, 0, c.weight)
|
||||
if rate != c.want {
|
||||
t.Fatalf("rate for weight %v = %v, want %v", c.weight, rate, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvTreasureDropRate_ZeroWeightNeverDrops(t *testing.T) {
|
||||
for i := 0; i < 200; i++ {
|
||||
drop, _, rate := rollAdvTreasureDropDetailed(1, "@zero:example.org", 0, 0)
|
||||
if drop != nil || rate != 0 {
|
||||
t.Fatalf("weight 0 produced drop=%v rate=%v", drop, rate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A forced roll must actually yield a treasure — this is the "the seam is
|
||||
// live again" assertion. A weight above 1/rate drives the effective rate
|
||||
// past 1.0, so every roll lands on the drop path.
|
||||
func TestAdvTreasureDropDetailed_ForcedRollGrantsTreasure(t *testing.T) {
|
||||
if err := db.Init(t.TempDir()); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
|
||||
drop, _, _ := rollAdvTreasureDropDetailed(1, "@forced:example.org", 0, 1000)
|
||||
if drop == nil || drop.Def == nil {
|
||||
t.Fatal("forced roll produced no treasure")
|
||||
}
|
||||
if drop.Def.Tier != 1 {
|
||||
t.Fatalf("tier-1 roll produced a tier-%d treasure", drop.Def.Tier)
|
||||
}
|
||||
}
|
||||
|
||||
// The treasure and masterwork systems predate zones and speak AdvLocation.
|
||||
func TestAdvLocForZone(t *testing.T) {
|
||||
loc := advLocForZone(ZoneGoblinWarrens)
|
||||
if loc.Activity != AdvActivityDungeon {
|
||||
t.Errorf("activity = %q, want dungeon", loc.Activity)
|
||||
}
|
||||
if want := zoneTierFromID(ZoneGoblinWarrens); loc.Tier != want {
|
||||
t.Errorf("tier = %d, want %d", loc.Tier, want)
|
||||
}
|
||||
if loc.Name == "" || loc.Name == string(ZoneGoblinWarrens) {
|
||||
t.Errorf("name = %q, want the zone's display name", loc.Name)
|
||||
}
|
||||
}
|
||||
@@ -322,11 +322,11 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
|
||||
// ── TwinBee Gifts (Temporary Buffs) ──────────────────────────────────────────
|
||||
|
||||
type twinBeeGiftDef struct {
|
||||
BuffType string
|
||||
BuffName string
|
||||
Modifier float64
|
||||
Duration time.Duration
|
||||
Flavor string
|
||||
BuffType string
|
||||
BuffName string
|
||||
Modifier float64
|
||||
Duration time.Duration
|
||||
Flavor string
|
||||
}
|
||||
|
||||
var twinBeeGifts = []twinBeeGiftDef{
|
||||
|
||||
@@ -20,15 +20,15 @@ import (
|
||||
|
||||
// jikanAnime represents an anime entry from the Jikan API.
|
||||
type jikanAnime struct {
|
||||
MalID int `json:"mal_id"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
TitleEng string `json:"title_english"`
|
||||
Type string `json:"type"`
|
||||
Episodes int `json:"episodes"`
|
||||
Status string `json:"status"`
|
||||
MalID int `json:"mal_id"`
|
||||
URL string `json:"url"`
|
||||
Title string `json:"title"`
|
||||
TitleEng string `json:"title_english"`
|
||||
Type string `json:"type"`
|
||||
Episodes int `json:"episodes"`
|
||||
Status string `json:"status"`
|
||||
Score float64 `json:"score"`
|
||||
Synopsis string `json:"synopsis"`
|
||||
Synopsis string `json:"synopsis"`
|
||||
Aired struct {
|
||||
String string `json:"string"`
|
||||
From string `json:"from"`
|
||||
|
||||
@@ -29,51 +29,51 @@ const (
|
||||
thLinkmasterMinMsgs = 50
|
||||
|
||||
// Temporal
|
||||
thNightOwlPct = 40
|
||||
thNightOwlMinMsgs = 100
|
||||
thEarlyBirdPct = 40
|
||||
thNightOwlPct = 40
|
||||
thNightOwlMinMsgs = 100
|
||||
thEarlyBirdPct = 40
|
||||
thEarlyBirdMinMsgs = 100
|
||||
|
||||
// Emotional (LLM-gated)
|
||||
thEmotionalMinClassified = 100
|
||||
thCheerleaderPosPct = 50
|
||||
thPhilosopherNeutPct = 40
|
||||
thPhilosopherQPct = 10
|
||||
thPhilosopherAvgWords = 8
|
||||
thAgitatorNegPct = 30
|
||||
thAgitatorMinMsgs = 200
|
||||
thWildcardStdDev = 0.5
|
||||
thWildcardMinClassified = 150
|
||||
thHypeMachineExclPct = 20
|
||||
thHypeMachinePosPct = 60
|
||||
thEmotionalMinClassified = 100
|
||||
thCheerleaderPosPct = 50
|
||||
thPhilosopherNeutPct = 40
|
||||
thPhilosopherQPct = 10
|
||||
thPhilosopherAvgWords = 8
|
||||
thAgitatorNegPct = 30
|
||||
thAgitatorMinMsgs = 200
|
||||
thWildcardStdDev = 0.5
|
||||
thWildcardMinClassified = 150
|
||||
thHypeMachineExclPct = 20
|
||||
thHypeMachinePosPct = 60
|
||||
|
||||
// Economy
|
||||
thBrokeSpiritedMaxBalance = 100.0
|
||||
thDegenerateMinLosses = 10
|
||||
|
||||
// Games
|
||||
thSharkWinRate = 55
|
||||
thSharkMinGames = 15
|
||||
thWordleMinPuzzles = 10
|
||||
thArenaChampMinTier = 4
|
||||
thArenaChampWinRate = 50
|
||||
thArenaCowardMinRuns = 3
|
||||
thArenaCowardMaxAvgTier = 2
|
||||
thTriviaNerdMinCorrect = 10
|
||||
thSharkWinRate = 55
|
||||
thSharkMinGames = 15
|
||||
thWordleMinPuzzles = 10
|
||||
thArenaChampMinTier = 4
|
||||
thArenaChampWinRate = 50
|
||||
thArenaCowardMinRuns = 3
|
||||
thArenaCowardMaxAvgTier = 2
|
||||
thTriviaNerdMinCorrect = 10
|
||||
|
||||
// Adventure
|
||||
thAdvMinDays = 10
|
||||
thAdvDiverseMinDays = 15
|
||||
thAdvDiverseMinTypes = 3
|
||||
thAdvDiverseMaxShare = 40
|
||||
thAdvMinDays = 10
|
||||
thAdvDiverseMinDays = 15
|
||||
thAdvDiverseMinTypes = 3
|
||||
thAdvDiverseMaxShare = 40
|
||||
thGearheadMinMasterwork = 3
|
||||
|
||||
// Communication (vocabulary)
|
||||
thWordsmithMinFancyWords = 10
|
||||
|
||||
// Social
|
||||
thPatronMinRepGiven = 5
|
||||
thPatronRatioMultiplier = 2
|
||||
thPatronMinRepGiven = 5
|
||||
thPatronRatioMultiplier = 2
|
||||
|
||||
// Display
|
||||
maxDisplayArchetypes = 6
|
||||
@@ -83,13 +83,13 @@ const (
|
||||
|
||||
var archetypeFlavors = map[string]string{
|
||||
// Communication
|
||||
"Novelist": "Writes in paragraphs. Has opinions. Probably re-reads their own messages.",
|
||||
"Minimalist": "Says a lot with very little. You're never sure if they're fine or not.",
|
||||
"Inquisitor": "Always asking. Never satisfied with the first answer. Probably has follow-ups.",
|
||||
"Enthusiast": "Genuinely excited about things. All the things. Possibly all at once.",
|
||||
"Chatterbox": "Has thoughts. Many thoughts. Shares them all. You wouldn't have it any other way.",
|
||||
"Linkmaster": "The community's unofficial curator. Their tab count is not your business.",
|
||||
"Wordsmith": "Uses words most people have to look up. The thesaurus fears them.",
|
||||
"Novelist": "Writes in paragraphs. Has opinions. Probably re-reads their own messages.",
|
||||
"Minimalist": "Says a lot with very little. You're never sure if they're fine or not.",
|
||||
"Inquisitor": "Always asking. Never satisfied with the first answer. Probably has follow-ups.",
|
||||
"Enthusiast": "Genuinely excited about things. All the things. Possibly all at once.",
|
||||
"Chatterbox": "Has thoughts. Many thoughts. Shares them all. You wouldn't have it any other way.",
|
||||
"Linkmaster": "The community's unofficial curator. Their tab count is not your business.",
|
||||
"Wordsmith": "Uses words most people have to look up. The thesaurus fears them.",
|
||||
|
||||
// Temporal
|
||||
"Night Owl": "Awake when they probably shouldn't be. Thriving despite all evidence.",
|
||||
@@ -103,8 +103,8 @@ var archetypeFlavors = map[string]string{
|
||||
"Hype Machine": "Arrived and immediately made everything louder. The room is better for it.",
|
||||
|
||||
// Economy
|
||||
"Whale": "Has money. Spends money. Has more money somehow. The math is unclear.",
|
||||
"Degenerate": "Knows exactly what they're doing. Does it anyway. Respects it.",
|
||||
"Whale": "Has money. Spends money. Has more money somehow. The math is unclear.",
|
||||
"Degenerate": "Knows exactly what they're doing. Does it anyway. Respects it.",
|
||||
"Broke But Spirited": "Down but not out. The pot fears them anyway.",
|
||||
|
||||
// Games
|
||||
@@ -221,16 +221,16 @@ type userData struct {
|
||||
userID string
|
||||
|
||||
// user_stats
|
||||
totalMsgs int
|
||||
totalWords int
|
||||
totalLinks int
|
||||
totalImages int
|
||||
totalMsgs int
|
||||
totalWords int
|
||||
totalLinks int
|
||||
totalImages int
|
||||
totalQuestions int
|
||||
totalExcl int
|
||||
totalEmojis int
|
||||
nightMsgs int
|
||||
morningMsgs int
|
||||
fancyWords int
|
||||
totalExcl int
|
||||
totalEmojis int
|
||||
nightMsgs int
|
||||
morningMsgs int
|
||||
fancyWords int
|
||||
|
||||
// sentiment_stats
|
||||
sentPositive int
|
||||
@@ -242,7 +242,7 @@ type userData struct {
|
||||
sentVariance float64
|
||||
|
||||
// economy
|
||||
balance float64
|
||||
balance float64
|
||||
gamblingLosses int
|
||||
recentGaming bool
|
||||
|
||||
@@ -251,7 +251,7 @@ type userData struct {
|
||||
hmPlayed, hmWon int
|
||||
holdemPlayed int
|
||||
holdemNetPositive bool
|
||||
unoSoloPlayed, unoSoloWon int
|
||||
unoSoloPlayed, unoSoloWon int
|
||||
unoMultiPlayed, unoMultiWon int
|
||||
wordlePlayed int
|
||||
triviaCorrect int
|
||||
@@ -263,13 +263,13 @@ type userData struct {
|
||||
arenaAvgCashTier float64
|
||||
|
||||
// adventure
|
||||
advDays int
|
||||
advActivities map[string]int // activity_type -> count
|
||||
advDays int
|
||||
advActivities map[string]int // activity_type -> count
|
||||
masterworkCount int
|
||||
|
||||
// social
|
||||
repGiven int
|
||||
repReceived int
|
||||
repGiven int
|
||||
repReceived int
|
||||
reactionsGiven int
|
||||
}
|
||||
|
||||
|
||||
@@ -83,18 +83,19 @@ var srdProfiles = map[string]SRDProfile{
|
||||
{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_thornmother": {Attacks: []SRDAttack{ // D8-f #2: 2→3 lashes, ~23→~36 (faceroll → band)
|
||||
{Name: "Thorned Lash", AttackBonus: 9, Damage: 13},
|
||||
{Name: "Thorned Lash", AttackBonus: 9, Damage: 12},
|
||||
{Name: "Thorned Lash", AttackBonus: 9, 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_infernax": {Attacks: []SRDAttack{ // D11 T5 lift: 49 → ~42 (nerf; was an impossible wall at L15-16)
|
||||
{Name: "Bite", AttackBonus: 11, Damage: 16},
|
||||
{Name: "Claw", AttackBonus: 11, Damage: 13},
|
||||
{Name: "Claw", AttackBonus: 11, Damage: 13},
|
||||
}},
|
||||
"boss_belaxath": {Attacks: []SRDAttack{ // Attack 31 → ~40
|
||||
"boss_belaxath": {Attacks: []SRDAttack{ // D11 T5 lift: 40 → ~41 (buff; was a leader faceroll)
|
||||
{Name: "Longsword", AttackBonus: 11, Damage: 24},
|
||||
{Name: "Whip", AttackBonus: 11, Damage: 16},
|
||||
{Name: "Whip", AttackBonus: 11, Damage: 17},
|
||||
}},
|
||||
|
||||
// ── Multiattack elites ───────────────────────────────────────────────
|
||||
@@ -169,6 +170,24 @@ var srdProfiles = map[string]SRDProfile{
|
||||
{Name: "Unarmed Strike", AttackBonus: 6, Damage: 6},
|
||||
{Name: "Bite", AttackBonus: 6, Damage: 4},
|
||||
}},
|
||||
|
||||
// ── Feywild elites (D8-f #2) ─────────────────────────────────────────
|
||||
// Feywild martials facerolled at 97–100% even after an HP/AC raise: the
|
||||
// roster was single-attack and low-damage, so tankier monsters just took
|
||||
// longer to kill. Multiattack is the lever that actually pulls leaders
|
||||
// into band (mirrors underdark's drow_elite). Casters trail (by design).
|
||||
"fomorian": {Attacks: []SRDAttack{ // Attack 13 → ~21 (2 big fists)
|
||||
{Name: "Greatclub", AttackBonus: 9, Damage: 11},
|
||||
{Name: "Greatclub", AttackBonus: 9, Damage: 10},
|
||||
}},
|
||||
"night_hag": {Attacks: []SRDAttack{ // Attack 8 → ~12 (claw flurry)
|
||||
{Name: "Claws", AttackBonus: 7, Damage: 6},
|
||||
{Name: "Claws", AttackBonus: 7, Damage: 6},
|
||||
}},
|
||||
"green_hag": {Attacks: []SRDAttack{ // Attack 6 → ~10 (claw flurry)
|
||||
{Name: "Claws", AttackBonus: 6, Damage: 5},
|
||||
{Name: "Claws", AttackBonus: 6, Damage: 5},
|
||||
}},
|
||||
}
|
||||
|
||||
// enemyAttackProfile returns the attack rolls a creature makes on its turn.
|
||||
|
||||
@@ -39,9 +39,23 @@ func TestEnemyAttackProfile_Registered(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// testCombatState seats a single actor, the shape every direct-primitive test
|
||||
// wants. The engine reads per-actor state through the embedded cursor, so a
|
||||
// combatState with a nil actor panics on the first st.playerHP touch.
|
||||
func testCombatState(playerHP, enemyHP, round int, rng *rand.Rand) *combatState {
|
||||
a := &actor{playerHP: playerHP}
|
||||
return &combatState{
|
||||
actor: a,
|
||||
actors: []*actor{a},
|
||||
enemyHP: enemyHP,
|
||||
round: round,
|
||||
rng: rng,
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnAbilityFires(t *testing.T) {
|
||||
enemy := baseEnemy() // MaxHP 60
|
||||
st := &combatState{rng: rand.New(rand.NewPCG(1, 1))}
|
||||
st := testCombatState(0, 0, 0, rand.New(rand.NewPCG(1, 1)))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -78,10 +92,7 @@ func TestTurnAbilityFires(t *testing.T) {
|
||||
// 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)),
|
||||
}
|
||||
st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(7, 7)))
|
||||
return st, basePlayer(), baseEnemy()
|
||||
}
|
||||
phase := &turnCombatPhase
|
||||
@@ -136,10 +147,7 @@ func TestApplyAbility_Slice2Effects(t *testing.T) {
|
||||
// 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)),
|
||||
}
|
||||
st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(9, 9)))
|
||||
return st, basePlayer(), baseEnemy()
|
||||
}
|
||||
phase := &turnCombatPhase
|
||||
@@ -188,7 +196,8 @@ func TestApplyAbility_Slice3Effects(t *testing.T) {
|
||||
}
|
||||
|
||||
// enemyDown lets survive_at_1 cheat death exactly once.
|
||||
stS := &combatState{enemyHP: 0, enemySurviveArmed: true}
|
||||
stS := testCombatState(100, 0, 1, rand.New(rand.NewPCG(13, 13)))
|
||||
stS.enemySurviveArmed = true
|
||||
if enemyDown(stS, "Duel") {
|
||||
t.Error("survive_at_1: armed enemy at 0 HP should not be down")
|
||||
}
|
||||
@@ -224,10 +233,7 @@ func TestApplyAbility_Slice3Effects(t *testing.T) {
|
||||
// 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)),
|
||||
}
|
||||
st := testCombatState(playerHP, enemyHP, 1, rand.New(rand.NewPCG(11, 11)))
|
||||
return st, basePlayer(), baseEnemy()
|
||||
}
|
||||
phase := &turnCombatPhase
|
||||
|
||||
@@ -14,327 +14,327 @@ 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)."},
|
||||
"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"},
|
||||
"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"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,11 +141,11 @@ type bjTable struct {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type bjConfig struct {
|
||||
TimeoutSeconds int
|
||||
AutoplayThreshold int
|
||||
MinBet float64
|
||||
MaxBet float64
|
||||
DebtLimit float64
|
||||
TimeoutSeconds int
|
||||
AutoplayThreshold int
|
||||
MinBet float64
|
||||
MaxBet float64
|
||||
DebtLimit float64
|
||||
}
|
||||
|
||||
func loadBJConfig() bjConfig {
|
||||
@@ -877,5 +877,3 @@ func (p *BlackjackPlugin) recordBJScore(userID id.UserID, net float64) {
|
||||
recordBotDefeat(userID, "blackjack")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
84
internal/plugin/bootstrap_caster_hp.go
Normal file
84
internal/plugin/bootstrap_caster_hp.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// bootstrapCasterHPRefresh recomputes hp_max for caster characters after
|
||||
// the J3 D8-d-fix caster HP multiplier (casterHPMult in dnd.go) shipped.
|
||||
// Without this, existing caster rows keep their pre-lift hp_max until
|
||||
// the next computeMaxHP recall (level-up / reset). Mirrors
|
||||
// bootstrapPhase5BHPRefresh — only ever raises hp_max, preserves the
|
||||
// absolute wound (delta added to hp_current, capped at new max). Run
|
||||
// once per startup, idempotent via db.JobCompleted.
|
||||
func bootstrapCasterHPRefresh() {
|
||||
const jobName = "caster_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("caster 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("caster hp refresh: scan failed", "err", err)
|
||||
continue
|
||||
}
|
||||
r.class = DnDClass(classStr)
|
||||
batch = append(batch, r)
|
||||
}
|
||||
|
||||
refreshed := 0
|
||||
for _, r := range batch {
|
||||
if casterHPMult(r.class) == 1.0 {
|
||||
continue
|
||||
}
|
||||
conMod := abilityModifier(r.conScore)
|
||||
newMax := computeMaxHP(r.class, conMod, r.level)
|
||||
if newMax <= r.hpMax {
|
||||
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("caster hp refresh: update failed", "user", r.userID, "err", err)
|
||||
continue
|
||||
}
|
||||
refreshed++
|
||||
}
|
||||
|
||||
db.MarkJobCompleted(jobName, "once")
|
||||
if refreshed > 0 {
|
||||
slog.Info("caster hp refresh: refreshed caster HPMax to D8-d-fix floor", "count", refreshed)
|
||||
}
|
||||
}
|
||||
144
internal/plugin/bootstrap_josie_caster_aid.go
Normal file
144
internal/plugin/bootstrap_josie_caster_aid.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Two one-shot startup bootstraps that lift a low-DPS caster who was stuck in a
|
||||
// boss-wall "death loop". Diagnosis: the boss isn't overtuned — the player is an
|
||||
// over-levelled cleric whose damage output is structurally low. Two account
|
||||
// gaps, fixed idempotently here so they reach the live player without a respec.
|
||||
//
|
||||
// 1. bootstrapCasterSpellBackfill — characters created before a default spell
|
||||
// was added to defaultKnownSpells keep their original spellbook forever:
|
||||
// ensureSpellsForCharacter only seeds when the known-spell list is EMPTY, so
|
||||
// later default additions never reach existing casters. This backfills any
|
||||
// missing default into known+prepared (e.g. inflict_wounds, added to the
|
||||
// cleric defaults after the affected character was rolled). General + future
|
||||
// proof — it fixes any caster with the same stale-default gap.
|
||||
//
|
||||
// 2. bootstrapGrantStarterPet — a targeted gift of a combat companion to a
|
||||
// specific endgame player who never received the 25% morning pet-arrival
|
||||
// roll. A pet adds sustained per-round damage + deflect mitigation, which
|
||||
// helps caster trailers most.
|
||||
|
||||
// bootstrapCasterSpellBackfill adds any missing defaultKnownSpells entry to
|
||||
// every existing caster as known+prepared. addKnownSpell is idempotent and
|
||||
// leaves the prepared flag of already-known spells untouched (ON CONFLICT only
|
||||
// refreshes source), so this only ever adds the genuinely-missing defaults.
|
||||
func bootstrapCasterSpellBackfill() {
|
||||
const jobName = "caster_default_spell_backfill_v1"
|
||||
if db.JobCompleted(jobName, "once") {
|
||||
return
|
||||
}
|
||||
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("bootstrap: caster spell backfill — load characters failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
added := 0
|
||||
for _, ac := range chars {
|
||||
c, err := LoadDnDCharacter(ac.UserID)
|
||||
if err != nil || c == nil || !isSpellcaster(c) {
|
||||
continue
|
||||
}
|
||||
known, err := listKnownSpells(c.UserID)
|
||||
if err != nil {
|
||||
slog.Warn("bootstrap: caster spell backfill — list known failed", "user", c.UserID, "err", err)
|
||||
continue
|
||||
}
|
||||
have := make(map[string]bool, len(known))
|
||||
for _, k := range known {
|
||||
have[k.SpellID] = true
|
||||
}
|
||||
defaults := defaultKnownSpells(c.Class, c.Level)
|
||||
if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster {
|
||||
defaults = arcaneTricksterDefaultSpells(c.Level)
|
||||
}
|
||||
for _, sid := range defaults {
|
||||
if have[sid] {
|
||||
continue
|
||||
}
|
||||
if err := addKnownSpell(c.UserID, sid, "class", true); err != nil {
|
||||
slog.Error("bootstrap: caster spell backfill — add failed", "user", c.UserID, "spell", sid, "err", err)
|
||||
continue
|
||||
}
|
||||
slog.Info("bootstrap: backfilled default spell", "user", c.UserID, "spell", sid)
|
||||
added++
|
||||
}
|
||||
}
|
||||
|
||||
db.MarkJobCompleted(jobName, "once")
|
||||
if added > 0 {
|
||||
slog.Warn("bootstrap: caster default-spell backfill complete", "spells_added", added)
|
||||
}
|
||||
}
|
||||
|
||||
// josieStarterPet identifies the one player the pet gift targets and the pet
|
||||
// it grants. Kept as data so the intent is legible: this is an admin gift, not
|
||||
// a game-wide policy.
|
||||
var josieStarterPet = struct {
|
||||
userID id.UserID
|
||||
typ string
|
||||
name string
|
||||
level int
|
||||
}{
|
||||
userID: "@holymachina:parodia.dev",
|
||||
typ: "dog",
|
||||
name: "Biscuit",
|
||||
level: 10,
|
||||
}
|
||||
|
||||
// bootstrapGrantStarterPet gives the targeted player a combat companion if they
|
||||
// have none. No-op once they have a pet (this gift, a later arrival, or one
|
||||
// chased away — we don't override the player's own pet history). Idempotent via
|
||||
// the job gate AND the has-pet guard.
|
||||
func bootstrapGrantStarterPet() {
|
||||
const jobName = "grant_starter_pet_holymachina_v1"
|
||||
if db.JobCompleted(jobName, "once") {
|
||||
return
|
||||
}
|
||||
g := josieStarterPet
|
||||
|
||||
char, err := loadAdvCharacter(g.userID)
|
||||
if err != nil || char == nil {
|
||||
// Target not present in this DB (e.g. fresh deploy) — mark done so we
|
||||
// don't re-scan every startup; the gift is a one-off, not a standing rule.
|
||||
db.MarkJobCompleted(jobName, "once")
|
||||
return
|
||||
}
|
||||
if char.PetType != "" || char.PetArrived {
|
||||
slog.Info("bootstrap: starter pet — target already has a pet, skipping", "user", g.userID)
|
||||
db.MarkJobCompleted(jobName, "once")
|
||||
return
|
||||
}
|
||||
|
||||
char.PetType = g.typ
|
||||
char.PetName = g.name
|
||||
char.PetLevel = g.level
|
||||
char.PetXP = 0
|
||||
char.PetArrived = true
|
||||
char.PetChasedAway = false
|
||||
if g.level >= 10 {
|
||||
// Mirror the babysit path that stamps the L10 date when a pet first
|
||||
// reaches the cap, so milestone/supply-shop logic stays consistent.
|
||||
char.PetLevel10Date = time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("bootstrap: starter pet — save failed", "user", g.userID, "err", err)
|
||||
return
|
||||
}
|
||||
if err := upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char)); err != nil {
|
||||
slog.Error("bootstrap: starter pet — player_meta mirror failed", "user", g.userID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
db.MarkJobCompleted(jobName, "once")
|
||||
slog.Warn("bootstrap: granted starter pet", "user", g.userID, "pet", g.name, "level", g.level)
|
||||
}
|
||||
35
internal/plugin/bootstrap_rooms_traversed.go
Normal file
35
internal/plugin/bootstrap_rooms_traversed.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// bootstrapRoomsTraversed backfills dnd_zone_run.rooms_traversed for rows
|
||||
// that predate the revisit R1 column. Before R1 the step counter was implicit
|
||||
// — len(visited_nodes) — because navigation was forward-only, so replaying
|
||||
// that identity is an exact reconstruction, not an estimate.
|
||||
//
|
||||
// The ALTER lands DEFAULT 0, which would tell an in-flight run it has walked
|
||||
// nowhere: ambient narration cadence would reset to the entry-room line and
|
||||
// R2's revisit preflight would read a fresh run. Hence the backfill.
|
||||
//
|
||||
// Idempotent, and safe to run against live rows: every row written after R1
|
||||
// inserts rooms_traversed >= 1 (the entry node counts as one traversal), so
|
||||
// `rooms_traversed = 0` uniquely identifies a row this has not yet touched.
|
||||
// Kept in-tree permanently — a fresh deploy restoring an old backup needs it
|
||||
// (feedback_loader_rewire_needs_bootstrap).
|
||||
func bootstrapRoomsTraversed() {
|
||||
res, err := db.Get().Exec(`
|
||||
UPDATE dnd_zone_run
|
||||
SET rooms_traversed = MAX(json_array_length(visited_nodes), 1)
|
||||
WHERE rooms_traversed = 0`)
|
||||
if err != nil {
|
||||
slog.Error("bootstrap: rooms_traversed backfill failed", "err", err)
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
slog.Warn("bootstrap: rooms_traversed backfilled from visited_nodes", "rows", n)
|
||||
}
|
||||
}
|
||||
@@ -592,4 +592,3 @@ func (p *AdventurePlugin) resolveDungeonAction(
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -27,34 +27,53 @@ func encounterIDForRoom(roomIdx int) string {
|
||||
return fmt.Sprintf("room%d", roomIdx)
|
||||
}
|
||||
|
||||
// replyDM sends a player-facing combat reply unless ctx.Silent is set. The
|
||||
// turn-engine combat commands route all their DMs through here so the
|
||||
// background autopilot can drive a boss/elite fight on the real engine
|
||||
// (long-expedition D8-f) without spamming the player a DM per round — the
|
||||
// state mutations (HP, XP, threat, run-clear) still happen; only the
|
||||
// narration is dropped. Non-silent callers (manual !fight) are unchanged.
|
||||
func (p *AdventurePlugin) replyDM(ctx MessageContext, text string) error {
|
||||
// An empty body means the caller already answered the player another way —
|
||||
// a party fan-out, say. Sending it would post a blank DM.
|
||||
if ctx.Silent || text == "" {
|
||||
return nil
|
||||
}
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
// ── !fight ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
// Resolve the roster before locking — a member's `!fight` opens the leader's
|
||||
// fight, under the leader's lock, and seat 0 names that leader. fightRoster
|
||||
// deliberately does not touch getActiveZoneRun: that lookup carries the §4.3
|
||||
// idle reap, and it must only ever fire under the lock, on its owner's behalf.
|
||||
roster := fightRoster(ctx.Sender)
|
||||
release := p.lockCombatFight(roster[0], ctx.Sender)
|
||||
defer release()
|
||||
|
||||
run, err := getActiveZoneRun(ctx.Sender)
|
||||
run, _, err := activeZoneRunFor(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't read run state: "+err.Error())
|
||||
return p.replyDM(ctx, "Couldn't read run state: "+err.Error())
|
||||
}
|
||||
if run == nil {
|
||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>` first.")
|
||||
return p.replyDM(ctx, "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`.")
|
||||
return p.replyDM(ctx, "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`.")
|
||||
return p.replyDM(ctx, "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.")
|
||||
return p.replyDM(ctx, "You've already cleared this room. "+continueHint(ctx.Sender))
|
||||
default:
|
||||
return p.SendDM(ctx.Sender, "This fight is already over. `!zone status` for where you stand.")
|
||||
return p.replyDM(ctx, "This fight is already over. `!zone status` for where you stand.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,38 +86,33 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
||||
} 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.)_")
|
||||
if !ok || monster.ID == "" {
|
||||
// monster.ID == "" guards a malformed bestiary entry (e.g. one whose
|
||||
// ID field was dropped): startCombatSession would otherwise persist a
|
||||
// session with an empty EnemyID, and the turn engine — having no enemy
|
||||
// to resolve — spins inertly until autoDriveCombat's round cap. Fail
|
||||
// loudly instead of stalling.
|
||||
return p.replyDM(ctx, "_(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.")
|
||||
// Seat the whole party, leader first. A solo player is a one-seat roster and
|
||||
// takes the path they always took: one build, one INSERT, no participant rows.
|
||||
seats, enemy, senderSkip, refusal := p.buildFightSeats(ctx.Sender, roster, monster, int(zone.Tier), run.DMMood)
|
||||
if refusal != "" {
|
||||
return p.replyDM(ctx, refusal)
|
||||
}
|
||||
enemyHP := enemy.Stats.MaxHP
|
||||
|
||||
sess, err := startCombatSession(ctx.Sender, run.RunID, encID, monster.ID, playerHP, playerMax, enemyHP, enemyHP)
|
||||
// Fight-start one-shot resources (Abjuration Arcane Ward, etc.) are seeded
|
||||
// per seat onto the session and its participant rows, so they survive the
|
||||
// turn engine's resume/commit cycle. The pet rolls per-turn inside the
|
||||
// engine, so there's no fight-start roll.
|
||||
sess, err := p.startPartyCombatSession(run.RunID, encID, monster.ID, enemy, seats)
|
||||
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)
|
||||
return p.replyDM(ctx, "You're already in a fight. Finish it with `!attack` / `!flee`.")
|
||||
}
|
||||
return p.replyDM(ctx, "Couldn't start the fight: "+err.Error())
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
@@ -113,14 +127,52 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error {
|
||||
}
|
||||
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 != "" {
|
||||
|
||||
if sess.IsParty() {
|
||||
players := seatCombatants(seats)
|
||||
// The enemy may have won initiative. Resolve everything the round owes
|
||||
// before anyone is asked to act, so the opening block narrates the hit
|
||||
// rather than showing its damage with no explanation.
|
||||
opening, serr := settleCombatSession(sess, players, enemy)
|
||||
if serr != nil {
|
||||
return p.replyDM(ctx, "Couldn't resolve the round: "+serr.Error())
|
||||
}
|
||||
ct := &combatTurn{sess: sess, players: players, enemy: enemy, seat: 0, uid: roster[0]}
|
||||
outcomes := p.closePartyRound(ct)
|
||||
if !ctx.Silent {
|
||||
// The opening block is per-reader for the same reason a round's
|
||||
// narration is: "You: 40/40 HP" has to be the reader's own pool.
|
||||
p.announcePartyFightStart(sess, players, enemy, b.String(), opening, outcomes)
|
||||
}
|
||||
// A member the roster left behind is owed an answer of their own: nothing
|
||||
// above was addressed to them, because they are not seated.
|
||||
if senderSkip != "" {
|
||||
return p.replyDM(ctx, senderSkip)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// One seat. Usually that is a solo player fighting their own fight, and this
|
||||
// is the block they have always been sent. It can also be a leader whose only
|
||||
// companion was left behind — in which case the block is the leader's and the
|
||||
// sender is owed the reason they are not in it.
|
||||
owner := id.UserID(sess.UserID)
|
||||
b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", sess.PlayerHP, sess.PlayerHPMax))
|
||||
if curios := activeMagicItemsLine(owner); curios != "" {
|
||||
b.WriteString(curios)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(combatTurnPrompt(sess))
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
if senderSkip != "" {
|
||||
if !ctx.Silent {
|
||||
if err := p.SendDM(owner, b.String()); err != nil {
|
||||
slog.Error("combat: fight-start DM to leader failed", "user", owner, "err", err)
|
||||
}
|
||||
}
|
||||
return p.replyDM(ctx, senderSkip)
|
||||
}
|
||||
return p.replyDM(ctx, b.String())
|
||||
}
|
||||
|
||||
// ── !attack / !flee ─────────────────────────────────────────────────────────
|
||||
@@ -133,29 +185,43 @@ func (p *AdventurePlugin) handleFleeCmd(ctx MessageContext) error {
|
||||
return p.handleCombatActionCmd(ctx, PlayerAction{Kind: ActionFlee})
|
||||
}
|
||||
|
||||
const noFightMsg = "You're not in a fight. `!fight` at an Elite or Boss room to start one."
|
||||
|
||||
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())
|
||||
ct, release, msg := p.beginCombatTurn(ctx.Sender, noFightMsg)
|
||||
if ct == nil {
|
||||
return p.replyDM(ctx, msg)
|
||||
}
|
||||
if sess == nil {
|
||||
return p.SendDM(ctx.Sender, "You're not in a fight. `!fight` at an Elite or Boss room to start one.")
|
||||
defer release()
|
||||
|
||||
// Fleeing ends the run for the whole party, so it is the leader's call —
|
||||
// the same reasoning that makes `!extract` leader-only.
|
||||
if action.Kind == ActionFlee && ct.isParty() && ct.seat != 0 {
|
||||
return p.replyDM(ctx, "Only your party leader can break off a fight.")
|
||||
}
|
||||
|
||||
player, enemy, err := p.combatantsForSession(sess)
|
||||
events, err := p.driveCombatRound(ct, action)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't rebuild the fight: "+err.Error())
|
||||
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
|
||||
}
|
||||
return p.replyCombatRound(ctx, ct, events)
|
||||
}
|
||||
|
||||
events, err := runCombatRound(sess, &player, &enemy, action)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't resolve the round: "+err.Error())
|
||||
// replyCombatRound narrates a resolved round. A solo fight answers the one
|
||||
// player who typed, exactly as it always has. A party fight fans out: every
|
||||
// member gets the play-by-play with the right names on it, and their own footer
|
||||
// or close-out. Terminal side effects run either way — a silent autopilot round
|
||||
// still owes the party its XP and loot.
|
||||
func (p *AdventurePlugin) replyCombatRound(ctx MessageContext, ct *combatTurn, events []CombatEvent) error {
|
||||
if !ct.isParty() {
|
||||
return p.replyDM(ctx, p.renderRoundResult(ctx.Sender, ct.sess, events, ct.players[0].Name, *ct.enemy))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, p.renderRoundResult(ctx.Sender, sess, events, player.Name, enemy))
|
||||
outcomes := p.closePartyRound(ct)
|
||||
if ctx.Silent {
|
||||
return nil
|
||||
}
|
||||
p.announcePartyRound(ct, events, "", outcomes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderRoundResult turns a resolved round into the player-facing block: the
|
||||
@@ -176,24 +242,64 @@ func (p *AdventurePlugin) renderRoundResult(userID id.UserID, sess *CombatSessio
|
||||
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
|
||||
// runCombatRound resolves one full round of a solo fight: 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 advance 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)
|
||||
return runPartyCombatRound(sess, []*Combatant{player}, enemy, action)
|
||||
}
|
||||
|
||||
// partyRoundStepCap bounds the drain loop below. A round is at most one step per
|
||||
// seat plus the enemy turn and the round-end tick, so a 3-player party settles
|
||||
// in 5; the cap only turns a hypothetical non-advancing phase into a loud error
|
||||
// instead of a hung goroutine holding the fight's lock.
|
||||
const partyRoundStepCap = 64
|
||||
|
||||
// runPartyCombatRound resolves the acting seat's action and then drains every
|
||||
// phase after it that needs no human: the enemy turn, the round-end status tick,
|
||||
// and any seat that is down (which forfeits its turn silently). It comes to rest
|
||||
// on a standing player's turn, or on a terminal status.
|
||||
//
|
||||
// A latched-onto-autopilot seat is *not* drained here — resolving its turn means
|
||||
// running the picker, which needs the plugin to reach the character's spells and
|
||||
// inventory. driveCombatRound layers that on top.
|
||||
//
|
||||
// For a solo roster this is exactly the old loop: a solo player_turn always
|
||||
// belongs to a standing player, since a downed one has already ended the fight.
|
||||
func runPartyCombatRound(sess *CombatSession, players []*Combatant, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
|
||||
events, err := advancePartyCombatSession(sess, players, enemy, action)
|
||||
if err != nil {
|
||||
return events, err
|
||||
}
|
||||
for sess.IsActive() && sess.Phase != CombatPhasePlayerTurn {
|
||||
more, merr := advanceCombatSession(sess, player, enemy, PlayerAction{})
|
||||
more, err := settleCombatSession(sess, players, enemy)
|
||||
return append(events, more...), err
|
||||
}
|
||||
|
||||
// settleCombatSession drains every phase the engine can resolve without a human:
|
||||
// the enemy turn, the round-end status tick, and any seat that is down. It comes
|
||||
// to rest on a standing player's turn, or on a terminal status.
|
||||
//
|
||||
// It is a no-op on a session already parked on a standing player's turn, which
|
||||
// is where every solo fight sits between commands.
|
||||
func settleCombatSession(sess *CombatSession, players []*Combatant, enemy *Combatant) ([]CombatEvent, error) {
|
||||
var events []CombatEvent
|
||||
for i := 0; i < partyRoundStepCap; i++ {
|
||||
if !sess.IsActive() {
|
||||
return events, nil
|
||||
}
|
||||
if seat, waiting := actingSeat(sess, players, enemy); waiting && sess.seatAlive(seat) {
|
||||
return events, nil
|
||||
}
|
||||
more, merr := advancePartyCombatSession(sess, players, enemy, PlayerAction{})
|
||||
if merr != nil {
|
||||
return events, merr
|
||||
}
|
||||
events = append(events, more...)
|
||||
}
|
||||
return events, nil
|
||||
return events, fmt.Errorf("combat session %s: round did not settle within %d steps",
|
||||
sess.SessionID, partyRoundStepCap)
|
||||
}
|
||||
|
||||
// combatTurnPrompt is the "your move" footer shown after every non-terminal
|
||||
@@ -204,6 +310,22 @@ func combatTurnPrompt(sess *CombatSession) string {
|
||||
|
||||
// ── close-out ───────────────────────────────────────────────────────────────
|
||||
|
||||
// continueHint returns the verb the player uses to keep moving after a
|
||||
// manual Elite/Boss fight, phrased for their current mode. On an
|
||||
// expedition the autopilot drives the walk, so `!zone advance` is the
|
||||
// wrong surface — point them at `!expedition run` instead. Standalone
|
||||
// zone runs still advance with `!zone advance`.
|
||||
func continueHint(userID id.UserID) string {
|
||||
exp, isLeader, err := activeExpeditionFor(userID)
|
||||
switch {
|
||||
case err != nil || exp == nil:
|
||||
return "`!zone advance` to move on."
|
||||
case !isLeader:
|
||||
return "Your leader marches the party on."
|
||||
}
|
||||
return "`!expedition run` to keep going."
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -241,11 +363,13 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
||||
slog.Error("combat: grantDnDXP turn-based", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
bossOnExpedition := false
|
||||
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)
|
||||
bossOnExpedition = true
|
||||
}
|
||||
}
|
||||
if line := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence); line != "" {
|
||||
@@ -257,10 +381,18 @@ func (p *AdventurePlugin) finishCombatSession(userID id.UserID, sess *CombatSess
|
||||
}
|
||||
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 != "" {
|
||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, !elite, elite); drop != "" {
|
||||
b.WriteString(drop + "\n")
|
||||
}
|
||||
b.WriteString("`!zone advance` to move on.")
|
||||
if bossOnExpedition {
|
||||
// The boss is the expedition's climax. Frame the close-out as
|
||||
// the win rather than a "keep walking" nudge. One more
|
||||
// `!expedition run` walks out the cleared room and triggers
|
||||
// finalizeExpeditionOnZoneClear (rewards + status flip).
|
||||
b.WriteString("🎉 **Zone cleared — the expedition is won.** `!expedition run` to march out and claim your spoils.")
|
||||
} else {
|
||||
b.WriteString(continueHint(userID))
|
||||
}
|
||||
|
||||
case CombatStatusLost:
|
||||
if run != nil {
|
||||
@@ -367,65 +499,87 @@ func parseCombatCast(userID id.UserID, c *DnDCharacter, args string) (SpellDefin
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
// "not in a fight anymore" — this handler is only routed to when the caller
|
||||
// already saw an active session, so a miss here means it closed under them.
|
||||
ct, release, msg := p.beginCombatTurn(ctx.Sender, "You're not in a fight anymore.")
|
||||
if ct == nil {
|
||||
return p.replyDM(ctx, msg)
|
||||
}
|
||||
defer release()
|
||||
|
||||
sess, err := getActiveCombatSession(ctx.Sender)
|
||||
action, settle, errMsg := p.castActionForSeat(ct, ct.seat, args)
|
||||
if errMsg != "" {
|
||||
return p.replyDM(ctx, errMsg)
|
||||
}
|
||||
events, err := p.driveCombatRound(ct, action)
|
||||
settle(err == nil)
|
||||
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.")
|
||||
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
|
||||
}
|
||||
return p.replyCombatRound(ctx, ct, events)
|
||||
}
|
||||
|
||||
advChar, _ := loadAdvCharacter(ctx.Sender)
|
||||
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
|
||||
// castActionForSeat resolves a `!cast` for one seat into a PlayerAction, having
|
||||
// already spent the slot and any material component. It is shared by the command
|
||||
// handler and by the autopilot that plays an absent member's turn, so both spend
|
||||
// resources through exactly one code path.
|
||||
//
|
||||
// The returned settle(ok) must be called once the round has been attempted: on
|
||||
// failure it refunds the slot. On refusal (non-empty msg) nothing was spent.
|
||||
//
|
||||
// A buff spell folds its delta into *this seat's* persisted statuses and rebuilds
|
||||
// the roster so the buff is live for the round's enemy turn. Before P5 that
|
||||
// delta went to the session's embedded copy — seat 0 — so a party member
|
||||
// buffing themselves would have buffed the leader.
|
||||
func (p *AdventurePlugin) castActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) {
|
||||
noop := func(bool) {}
|
||||
uid := id.UserID(ct.sess.seatUserID(seat))
|
||||
|
||||
advChar, _ := loadAdvCharacter(uid)
|
||||
c, err := p.ensureCharForDnDCmd(uid, advChar)
|
||||
if err != nil || c == nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
|
||||
return PlayerAction{}, noop, "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)))
|
||||
return PlayerAction{}, noop, 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))
|
||||
spell, slotLevel, errMsg := parseCombatCast(uid, c, strings.TrimSpace(args))
|
||||
if errMsg != "" {
|
||||
return p.SendDM(ctx.Sender, errMsg)
|
||||
return PlayerAction{}, noop, 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))
|
||||
return PlayerAction{}, noop, 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())
|
||||
refund := func(ok bool) {
|
||||
if !ok && spell.Level > 0 {
|
||||
_ = refundSpellSlot(uid, slotLevel)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
// marginal effect into that seat's persisted state, then rebuild the
|
||||
// roster so the buff is live for this round's enemy turn.
|
||||
player := ct.players[seat]
|
||||
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))
|
||||
return PlayerAction{}, noop, 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)
|
||||
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
|
||||
return PlayerAction{}, noop, 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())
|
||||
ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
|
||||
if rerr := p.rebuildRoster(ct); rerr != nil {
|
||||
refund(false)
|
||||
return PlayerAction{}, noop, "Couldn't rebuild the fight: " + rerr.Error()
|
||||
}
|
||||
label := spell.Name + " — active"
|
||||
if d.heal > 0 {
|
||||
@@ -436,13 +590,13 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
|
||||
PlayerHeal: d.heal, EnemySkip: d.enemySkip,
|
||||
}
|
||||
} else {
|
||||
out, supported := resolveTurnSpell(c, spell, slotLevel, &enemy.Stats)
|
||||
out, supported := resolveTurnSpell(c, spell, slotLevel, &ct.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))
|
||||
return PlayerAction{}, noop, 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)
|
||||
if msg := p.chargeSpellCost(uid, spell, slotLevel); msg != "" {
|
||||
return PlayerAction{}, noop, msg
|
||||
}
|
||||
eff = &turnActionEffect{
|
||||
Label: out.Desc,
|
||||
@@ -451,16 +605,27 @@ func (p *AdventurePlugin) handleCombatCastCmd(ctx MessageContext, args string) e
|
||||
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)
|
||||
// Concentration AOE damage spells linger: the burst lands this round
|
||||
// (EnemyDamage) and the same value re-ticks every round_end after, via
|
||||
// the engine's concentration aura. spiritual_weapon already covers the
|
||||
// cleric's bonus-action half of the combo; this restores the action half.
|
||||
if spell.Concentration &&
|
||||
(spell.Effect == EffectDamageAuto || spell.Effect == EffectDamageSave) {
|
||||
eff.ConcentrationDmg = out.EnemyDamage
|
||||
}
|
||||
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))
|
||||
return PlayerAction{Kind: ActionCast, Effect: eff}, refund, ""
|
||||
}
|
||||
|
||||
// rebuildRoster re-derives the seated combatants after a mid-fight buff changed
|
||||
// a seat's persisted statuses, so the buff is live for the rest of the round.
|
||||
func (p *AdventurePlugin) rebuildRoster(ct *combatTurn) error {
|
||||
players, enemy, err := p.partyCombatantsForSession(ct.sess)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ct.players, ct.enemy = players, enemy
|
||||
return nil
|
||||
}
|
||||
|
||||
// chargeSpellCost debits a spell's material component and leveled slot for a
|
||||
@@ -525,45 +690,70 @@ func matchConsumable(inv []ConsumableItem, query string) (*ConsumableItem, strin
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
const notFighting = "`!consume <item>` spends an item on your turn in an Elite or Boss fight. You're not in one — outside combat, consumables fire automatically."
|
||||
|
||||
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 == "" {
|
||||
// Listing the pack reads no turn state, so it neither takes the fight's
|
||||
// lock nor settles a phase — a player peeking at their options between
|
||||
// rounds must not advance the fight.
|
||||
probe, err := activeCombatSessionFor(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.replyDM(ctx, "Couldn't read combat state: "+err.Error())
|
||||
}
|
||||
if probe == nil {
|
||||
return p.replyDM(ctx, notFighting)
|
||||
}
|
||||
inv := p.loadConsumableInventory(ctx.Sender)
|
||||
if len(inv) == 0 {
|
||||
return p.SendDM(ctx.Sender, "You have no combat consumables. `!attack` / `!cast` / `!flee`.")
|
||||
return p.replyDM(ctx, "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, ", ")+".")
|
||||
return p.replyDM(ctx, "Usage: `!consume <item>`. You're carrying: "+strings.Join(names, ", ")+".")
|
||||
}
|
||||
|
||||
ct, release, msg := p.beginCombatTurn(ctx.Sender, notFighting)
|
||||
if ct == nil {
|
||||
return p.replyDM(ctx, msg)
|
||||
}
|
||||
defer release()
|
||||
|
||||
action, settle, errMsg := p.consumeActionForSeat(ct, ct.seat, args)
|
||||
if errMsg != "" {
|
||||
return p.replyDM(ctx, errMsg)
|
||||
}
|
||||
events, err := p.driveCombatRound(ct, action)
|
||||
settle(err == nil)
|
||||
if err != nil {
|
||||
return p.replyDM(ctx, "Couldn't resolve the round: "+err.Error())
|
||||
}
|
||||
return p.replyCombatRound(ctx, ct, events)
|
||||
}
|
||||
|
||||
// consumeActionForSeat resolves a `!consume` for one seat into a PlayerAction.
|
||||
// Shared by the command handler and by the autopilot that plays an absent
|
||||
// member's turn.
|
||||
//
|
||||
// The returned settle(ok) burns the item only once the round has resolved: a
|
||||
// removal failure leaves the player a free use (logged, rare), which beats
|
||||
// charging them for a round that errored out.
|
||||
func (p *AdventurePlugin) consumeActionForSeat(ct *combatTurn, seat int, args string) (PlayerAction, func(bool), string) {
|
||||
noop := func(bool) {}
|
||||
uid := id.UserID(ct.sess.seatUserID(seat))
|
||||
|
||||
inv := p.loadConsumableInventory(uid)
|
||||
item, ambig := matchConsumable(inv, args)
|
||||
if ambig != "" {
|
||||
return p.SendDM(ctx.Sender, ambig)
|
||||
return PlayerAction{}, noop, ambig
|
||||
}
|
||||
if item == nil {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("No consumable matching %q in your inventory.", args))
|
||||
return PlayerAction{}, noop, 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:
|
||||
@@ -574,34 +764,32 @@ func (p *AdventurePlugin) handleConsumeCmd(ctx MessageContext, args string) erro
|
||||
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.
|
||||
// throwaway combatant, fold it into that seat's persisted state, and
|
||||
// rebuild the roster so the buff is live for this round's enemy turn.
|
||||
player := ct.players[seat]
|
||||
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))
|
||||
return PlayerAction{}, noop, 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())
|
||||
ct.sess.actorStatusesPtr(seat).applyBuffDelta(d)
|
||||
if rerr := p.rebuildRoster(ct); rerr != nil {
|
||||
return PlayerAction{}, noop, "Couldn't rebuild the fight: " + rerr.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())
|
||||
burn := func(ok bool) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if rerr := removeAdvInventoryItem(item.InventoryID); rerr != nil {
|
||||
slog.Error("combat: consume remove inventory item failed", "user", uid, "item", def.Name, "err", rerr)
|
||||
}
|
||||
}
|
||||
// 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))
|
||||
return PlayerAction{Kind: ActionConsume, Effect: eff}, burn, ""
|
||||
}
|
||||
|
||||
@@ -46,13 +46,18 @@ type CombatStats struct {
|
||||
}
|
||||
|
||||
type CombatModifiers struct {
|
||||
DamageBonus float64 // additive: 0 = neutral, 0.25 = +25% damage. Applied as (1 + DamageBonus).
|
||||
DamageReduct float64 // multiplicative damage-taken reduction, 1.0 = neutral
|
||||
DeathSave bool // Sovereign reprieve — survive one lethal hit
|
||||
PetAttackProc float64
|
||||
PetAttackDmg int
|
||||
PetDeflectProc float64
|
||||
PetWhiffProc float64 // pet distracts enemy → guaranteed miss
|
||||
DamageBonus float64 // additive: 0 = neutral, 0.25 = +25% damage. Applied as (1 + DamageBonus).
|
||||
DamageReduct float64 // multiplicative damage-taken reduction, 1.0 = neutral
|
||||
DeathSave bool // Sovereign reprieve — survive one lethal hit
|
||||
PetAttackProc float64
|
||||
PetAttackDmg int
|
||||
PetDeflectProc float64
|
||||
PetWhiffProc float64 // pet distracts enemy → guaranteed miss
|
||||
// Spiritual Weapon — separate channel from the pet so the spectral mace
|
||||
// gets its own narration when a cleric without a companion casts it.
|
||||
// Damage formula mirrors PetAttack (Dmg + d5), proc rolls per round.
|
||||
SpiritWeaponProc float64
|
||||
SpiritWeaponDmg int
|
||||
SniperKillProc float64 // Arina instant-kill
|
||||
MistyHealProc float64
|
||||
MistyHealAmt int
|
||||
@@ -206,6 +211,15 @@ type CombatEvent struct {
|
||||
// Roll is the raw d20 (1..20); RollAgainst is the target AC.
|
||||
Roll int
|
||||
RollAgainst int
|
||||
// Seat is the roster index of the character this event is about: the one
|
||||
// who swung, whose pet struck, who the enemy hit, whose poison ticked. The
|
||||
// turn engine stamps it (turnEngine.stampSeat); the auto-resolve engine
|
||||
// leaves it 0, which is correct for its one and only combatant.
|
||||
//
|
||||
// It exists so a party's play-by-play can name the right person. Solo events
|
||||
// are all seat 0, and the omitempty tag keeps the field out of every solo
|
||||
// turn_log_json — rows written before N3/P5 decode unchanged.
|
||||
Seat int `json:"Seat,omitempty"`
|
||||
}
|
||||
|
||||
type CombatResult struct {
|
||||
@@ -287,10 +301,28 @@ var bossCombatPhases = []CombatPhase{
|
||||
|
||||
// ── Simulation ───────────────────────────────────────────────────────────────
|
||||
|
||||
// combatState tracks mutable state during the simulation.
|
||||
type combatState struct {
|
||||
// actor is the per-combatant half of the simulation state — everything that
|
||||
// belongs to one player character rather than to the fight as a whole.
|
||||
//
|
||||
// It is embedded into combatState as a *pointer*, which promotes its fields:
|
||||
// `st.playerHP` still resolves, now to `st.actor.playerHP`. The embedded
|
||||
// pointer is a cursor naming whoever is currently resolving. Solo combat has a
|
||||
// one-element roster and never moves the cursor, so every read and every RNG
|
||||
// draw is bit-identical to the pre-roster engine — which is what keeps the
|
||||
// solo balance corpus (sim_results/d8prereq_corpus.jsonl lineage) valid.
|
||||
//
|
||||
// Field names are deliberately unchanged from when they lived on combatState.
|
||||
type actor struct {
|
||||
// c is the Combatant this state belongs to. Nil only in tests that drive
|
||||
// a primitive directly without a roster.
|
||||
c *Combatant
|
||||
|
||||
playerHP int
|
||||
enemyHP int
|
||||
// hpMax is this actor's HP ceiling for in-fight healing. It tracks
|
||||
// c.Stats.MaxHP except in the turn-based engine, where seat 0 restores the
|
||||
// session's persisted player_hp_max — that snapshot comes from
|
||||
// dndHPSnapshot and can differ from the rebuilt combatant's MaxHP.
|
||||
hpMax int
|
||||
|
||||
// Consumable one-shots
|
||||
healChargesLeft int // remaining heal-at-<50% triggers
|
||||
@@ -299,13 +331,10 @@ type combatState struct {
|
||||
reflectFrac float64
|
||||
autoCrit bool
|
||||
|
||||
// Monster ability effects
|
||||
poisonTicks int
|
||||
poisonDmg int
|
||||
stunPlayer bool
|
||||
enraged bool
|
||||
armorBroken bool
|
||||
armorBreakAmt float64
|
||||
// Monster ability effects landing on this actor
|
||||
poisonTicks int
|
||||
poisonDmg int
|
||||
stunPlayer bool
|
||||
|
||||
// Sovereign reprieve
|
||||
deathSaveUsed bool
|
||||
@@ -315,14 +344,6 @@ type combatState struct {
|
||||
raged bool // Orc Rage already triggered this fight
|
||||
pendingRageAttack bool // next player attack gets +50% damage
|
||||
|
||||
// Phase 9 spell — enemy skip-first-attack (consumed on the first round
|
||||
// the enemy would otherwise attack).
|
||||
enemySkipFirst bool
|
||||
|
||||
// Phase 13 turn-based — pet attack decided once at fight start; the pet
|
||||
// strikes once on the player's first acting turn, which clears this.
|
||||
petProcReady bool
|
||||
|
||||
// Phase 10 SUB2a-ii first-attack one-shots.
|
||||
firstAttackBonusUsed bool
|
||||
assassinateRerollUsed bool
|
||||
@@ -331,19 +352,56 @@ type combatState struct {
|
||||
// Phase 10 SUB2b — Abjuration Arcane Ward HP buffer.
|
||||
arcaneWardHP int
|
||||
|
||||
// Debuffs the enemy has stacked onto this actor specifically.
|
||||
playerAtkDrain int // stat_drain: flat reduction to this actor's hit damage
|
||||
playerACDebuff int // debuff: flat reduction to this actor's effective AC
|
||||
maxHPDrain int // max_hp_drain: reduction to this actor's effective MaxHP
|
||||
|
||||
// concentrationDmg — per-round damage of an active concentration AOE
|
||||
// (Spirit Guardians et al.). Concentration is per-caster, so it lives
|
||||
// here rather than on the fight.
|
||||
concentrationDmg int
|
||||
}
|
||||
|
||||
// combatState tracks mutable state during the simulation. The embedded *actor
|
||||
// is the cursor: the player character currently resolving. Everything declared
|
||||
// directly on combatState is shared by the whole fight — the enemy, the round
|
||||
// counter, the event log, and the RNG stream.
|
||||
type combatState struct {
|
||||
*actor // cursor into actors; promotes the per-actor fields
|
||||
actors []*actor // the player roster, in seating order. len == 1 for solo.
|
||||
// seatIdx is the roster index the cursor points at. Kept in step with the
|
||||
// embedded *actor by seat(); read only by the turn engine, to attribute the
|
||||
// events a phase emitted to the character they happened to.
|
||||
seatIdx int
|
||||
|
||||
enemyHP int
|
||||
|
||||
// Monster ability effects (enemy-side stance — shared across the roster)
|
||||
enraged bool
|
||||
armorBroken bool
|
||||
armorBreakAmt float64
|
||||
|
||||
// Phase 9 spell — enemy skip-first-attack (consumed on the first round
|
||||
// the enemy would otherwise attack). Holding the enemy holds it for
|
||||
// everyone, so this is fight-scoped, not per-caster.
|
||||
enemySkipFirst bool
|
||||
|
||||
// Phase 13 bestiary slice 3 — stateful monster-ability effects. Each is
|
||||
// armed by applyAbility and read by the shared resolution primitives, so
|
||||
// both engines honour them; the turn-based engine additionally round-trips
|
||||
// them through CombatStatuses so they survive a suspend/resume.
|
||||
//
|
||||
// These describe the *enemy's* stance, so they are fight-scoped: an enemy
|
||||
// holding a parry stance parries the next swing from anyone. The debuffs
|
||||
// it stacks onto a specific character (stat_drain / debuff / max_hp_drain)
|
||||
// live on actor instead.
|
||||
enemyEvadeNext bool // evade: next player weapon attack auto-misses
|
||||
enemyBlockUp bool // block: enemy holds a parry stance (~50% block on player hits)
|
||||
enemyAdvantage bool // advantage: enemy rolls its attacks with advantage
|
||||
enemyRetaliateFrac float64 // retaliate: fraction of a player hit reflected back
|
||||
enemyRegen int // regenerate: enemy heals this much each round end
|
||||
enemySurviveArmed bool // survive_at_1: enemy cheats death once, dropping to 1 HP
|
||||
playerAtkDrain int // stat_drain: flat reduction to the player's hit damage
|
||||
playerACDebuff int // debuff: flat reduction to the player's effective AC
|
||||
maxHPDrain int // max_hp_drain: reduction to the player's effective MaxHP
|
||||
|
||||
// Phase 13 bestiary slice 4 — the former flavor-only placeholders, now
|
||||
// backed by real state.
|
||||
@@ -368,6 +426,50 @@ type combatState struct {
|
||||
// Auto-resolve leaves st.rng nil — behaviorally identical to the
|
||||
// pre-injection code; the turn-based engine and the timeout reaper seed
|
||||
// it per session so a fight can be resumed and replayed reproducibly.
|
||||
// newActor seats one player character, deriving its opening per-fight state
|
||||
// from that character's modifiers.
|
||||
func newActor(c *Combatant) *actor {
|
||||
startHP := c.Stats.MaxHP
|
||||
if c.Stats.StartHP > 0 && c.Stats.StartHP < c.Stats.MaxHP {
|
||||
startHP = c.Stats.StartHP
|
||||
}
|
||||
a := &actor{
|
||||
c: c,
|
||||
playerHP: startHP,
|
||||
hpMax: c.Stats.MaxHP,
|
||||
wardCharges: c.Mods.WardCharges,
|
||||
sporeRounds: c.Mods.SporeCloud,
|
||||
reflectFrac: c.Mods.ReflectNext,
|
||||
autoCrit: c.Mods.AutoCritFirst,
|
||||
arcaneWardHP: c.Mods.ArcaneWardHP,
|
||||
}
|
||||
// HealItemCharges: explicit count overrides legacy one-shot. Backfill
|
||||
// to 1 charge if the caller set a HealItem amount but no count.
|
||||
a.healChargesLeft = c.Mods.HealItemCharges
|
||||
if a.healChargesLeft == 0 && c.Mods.HealItem > 0 {
|
||||
a.healChargesLeft = 1
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// seat points the cursor at roster index i. Every per-actor read in the
|
||||
// resolution primitives (st.playerHP, st.wardCharges, …) follows the cursor.
|
||||
// Solo combat seats index 0 once and never moves it.
|
||||
// seat moves the cursor to a roster index. seatIdx trails it so the turn engine
|
||||
// can tag the events a phase emits with the character they are about.
|
||||
func (st *combatState) seat(i int) { st.actor, st.seatIdx = st.actors[i], i }
|
||||
|
||||
// anyAlive reports whether at least one seated character is still standing.
|
||||
// Solo fights read this as "the player is alive".
|
||||
func (st *combatState) anyAlive() bool {
|
||||
for _, a := range st.actors {
|
||||
if a.playerHP > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (st *combatState) roll(n int) int { return rngIntN(st.rng, n) }
|
||||
func (st *combatState) randFloat() float64 { return rngFloat(st.rng) }
|
||||
|
||||
@@ -380,325 +482,12 @@ func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult
|
||||
// The characterization test and the turn-based engine pass a seeded *rand.Rand
|
||||
// so a fight is fully reproducible. Passing nil is behaviorally identical to
|
||||
// the pre-injection code.
|
||||
//
|
||||
// This is the one-seat case of the N-body engine in combat_engine_party.go.
|
||||
// Every roster short-circuit in there collapses for a single player, so the
|
||||
// draw order — and therefore TestCombatCharacterization — is unchanged.
|
||||
func simulateCombatWithRNG(player, enemy Combatant, phases []CombatPhase, rng *rand.Rand) CombatResult {
|
||||
playerStart := player.Stats.MaxHP
|
||||
if player.Stats.StartHP > 0 && player.Stats.StartHP < player.Stats.MaxHP {
|
||||
playerStart = player.Stats.StartHP
|
||||
}
|
||||
enemyStart := enemy.Stats.MaxHP
|
||||
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
|
||||
enemyStart = enemy.Stats.StartHP
|
||||
}
|
||||
st := &combatState{
|
||||
playerHP: playerStart,
|
||||
enemyHP: enemyStart,
|
||||
wardCharges: player.Mods.WardCharges,
|
||||
sporeRounds: player.Mods.SporeCloud,
|
||||
reflectFrac: player.Mods.ReflectNext,
|
||||
autoCrit: player.Mods.AutoCritFirst,
|
||||
enemySkipFirst: player.Mods.SpellEnemySkipFirst,
|
||||
arcaneWardHP: player.Mods.ArcaneWardHP,
|
||||
rng: rng,
|
||||
}
|
||||
// HealItemCharges: explicit count overrides legacy one-shot. Backfill
|
||||
// to 1 charge if the caller set a HealItem amount but no count.
|
||||
st.healChargesLeft = player.Mods.HealItemCharges
|
||||
if st.healChargesLeft == 0 && player.Mods.HealItem > 0 {
|
||||
st.healChargesLeft = 1
|
||||
}
|
||||
|
||||
result := CombatResult{
|
||||
PlayerStartHP: player.Stats.MaxHP,
|
||||
PlayerEntryHP: playerStart,
|
||||
EnemyStartHP: enemy.Stats.MaxHP,
|
||||
EnemyEntryHP: enemyStart,
|
||||
}
|
||||
|
||||
// Pre-combat: Arina sniper check
|
||||
if player.Mods.SniperKillProc > 0 && st.randFloat() < player.Mods.SniperKillProc {
|
||||
st.enemyHP = 0
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: 0, Phase: "pre_combat", Actor: "npc", Action: "sniper_kill",
|
||||
Damage: enemy.Stats.MaxHP, PlayerHP: st.playerHP, EnemyHP: 0,
|
||||
Desc: "Arina",
|
||||
})
|
||||
result.SniperKilled = true
|
||||
return finalize(result, st, player, enemy)
|
||||
}
|
||||
|
||||
// Pre-combat: Coal Bomb / flat start damage
|
||||
if player.Mods.FlatDmgStart > 0 {
|
||||
dmg := player.Mods.FlatDmgStart
|
||||
st.enemyHP = max(0, st.enemyHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "flat_damage",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.enemyHP <= 0 {
|
||||
return finalize(result, st, player, enemy)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-combat: queued spell. Resolved by applyPendingCast() before this
|
||||
// runs — the modifiers carry the resolved damage and narrative hook.
|
||||
if player.Mods.SpellPreDamageDesc != "" {
|
||||
dmg := player.Mods.SpellPreDamage
|
||||
resisted := dmg > 0 && enemyResistsSpells(&enemy, st)
|
||||
if resisted {
|
||||
dmg = max(1, dmg/2)
|
||||
}
|
||||
if dmg > 0 {
|
||||
st.enemyHP = max(0, st.enemyHP-dmg)
|
||||
}
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: 0, Phase: "pre_combat", Actor: "player", Action: "spell_cast",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: player.Mods.SpellPreDamageDesc,
|
||||
})
|
||||
if resisted {
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: 0, Phase: "pre_combat", Actor: "enemy", Action: "spell_fizzle",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
if st.enemyHP <= 0 {
|
||||
return finalize(result, st, player, enemy)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-combat: Misty gourmet is now a per-round heal, no pre-combat damage
|
||||
|
||||
// Main simulation loop
|
||||
for _, phase := range phases {
|
||||
roundsThisPhase := phase.Rounds
|
||||
// Add slight variance: ±1 round for non-Decisive phases
|
||||
if phase.Name != "Decisive" && roundsThisPhase > 1 {
|
||||
roundsThisPhase += st.roll(2) // 0 or +1
|
||||
}
|
||||
for r := 0; r < roundsThisPhase; r++ {
|
||||
st.round++
|
||||
if simulateRound(st, &player, &enemy, &phase, &result) {
|
||||
return finalize(result, st, player, enemy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we exhaust all phases without a kill, tiebreak by HP percentage
|
||||
// to decide the *outcome* (PlayerWon flag) — but DO NOT zero out HP
|
||||
// on the loser. Timeout = retreat, not lethal blow. Caller treats a
|
||||
// timeout loss as "fight ended, no character death".
|
||||
//
|
||||
// Absolute-HP tiebreak (briefly used on the legacy ~120 HP scale) was
|
||||
// wrong post HP-unification: monster pools (~30–175) are 2-3× player
|
||||
// pools (~13–83), so absolute always favored the larger combatant
|
||||
// even when the player took less proportional damage. Slight bias
|
||||
// toward the player on exact ties (frac >=).
|
||||
result.TimedOut = true
|
||||
playerFrac := float64(st.playerHP) / float64(max(1, player.Stats.MaxHP))
|
||||
enemyFrac := float64(st.enemyHP) / float64(max(1, enemy.Stats.MaxHP))
|
||||
playerWonTiebreak := playerFrac >= enemyFrac
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: "exhaust", Actor: "system", Action: "timeout",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
out := finalize(result, st, player, enemy)
|
||||
out.PlayerWon = playerWonTiebreak
|
||||
return out
|
||||
}
|
||||
|
||||
// simulateRound runs one round. Returns true if combat is over.
|
||||
func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
|
||||
phaseName := phase.Name
|
||||
|
||||
// Phase 9 spell: control effect (Hold Person, Sleep, Command) skips the
|
||||
// enemy's attack for one round. Logged as a dedicated event so narrative
|
||||
// can read it as a held/stunned beat rather than a generic miss.
|
||||
enemyHeldThisRound := false
|
||||
if st.enemySkipFirst {
|
||||
st.enemySkipFirst = false
|
||||
if enemyImmuneToControl(enemy, st) {
|
||||
// fear_immune: the control spell can't take hold — the enemy acts
|
||||
// as normal this round.
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "fear_resist",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
} else {
|
||||
enemyHeldThisRound = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "spell_held",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Monster ability: check at round start
|
||||
abilityDealtDamage := enemyHeldThisRound
|
||||
if enemy.Ability != nil {
|
||||
if abilityFires(enemy.Ability, phaseName, st) {
|
||||
if applyAbility(st, player, enemy, phase, result) {
|
||||
return true
|
||||
}
|
||||
// Cleave and lifesteal deal damage — skip normal enemy attack this round
|
||||
switch enemy.Ability.Effect {
|
||||
case "cleave", "lifesteal":
|
||||
abilityDealtDamage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Poison tick from previous round
|
||||
if st.poisonTicks > 0 {
|
||||
st.playerHP = max(0, st.playerHP-st.poisonDmg)
|
||||
st.poisonTicks--
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "poison_tick",
|
||||
Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.playerHP <= 0 {
|
||||
if trySave(st, player, phaseName) {
|
||||
// survived
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pet whiff: if proc'd, enemy's attack this round is a guaranteed miss
|
||||
petWhiff := player.Mods.PetWhiffProc > 0 && st.randFloat() < player.Mods.PetWhiffProc
|
||||
// Pet deflect: halves incoming damage to player this round
|
||||
petDeflect := player.Mods.PetDeflectProc > 0 && st.randFloat() < player.Mods.PetDeflectProc
|
||||
if petDeflect {
|
||||
result.PetDeflected = true
|
||||
}
|
||||
|
||||
// Spore cloud: enemy has 15% miss chance (decremented when enemy actually attacks)
|
||||
sporeMiss := st.sporeRounds > 0 && st.randFloat() < 0.15
|
||||
|
||||
// Determine initiative. DM mood (Effusive/Hostile) biases the player's
|
||||
// roll via player.Mods.InitiativeBias — +X means player goes first
|
||||
// more often, -X means the enemy does.
|
||||
playerSpeed := float64(player.Stats.Speed) * phase.SpeedWeight
|
||||
enemySpeed := float64(enemy.Stats.Speed) * phase.SpeedWeight
|
||||
playerInit := playerSpeed + st.randFloat()*10 + player.Mods.InitiativeBias
|
||||
enemyInit := enemySpeed + st.randFloat()*10
|
||||
playerFirst := playerInit >= enemyInit
|
||||
|
||||
if playerFirst {
|
||||
if resolvePlayerSwings(st, player, enemy, phase, result) {
|
||||
return true
|
||||
}
|
||||
if !abilityDealtDamage {
|
||||
if resolveEnemyAttack(st, player, enemy, phase, result, petWhiff, petDeflect, sporeMiss) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !abilityDealtDamage {
|
||||
if resolveEnemyAttack(st, player, enemy, phase, result, petWhiff, petDeflect, sporeMiss) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if resolvePlayerSwings(st, player, enemy, phase, result) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Environmental hazard
|
||||
if phase.EnvironmentProc > 0 && st.randFloat() < phase.EnvironmentProc {
|
||||
envDmg := 2 + st.roll(5)
|
||||
st.playerHP = max(0, st.playerHP-envDmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "environment", Action: "environmental",
|
||||
Damage: envDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.playerHP <= 0 {
|
||||
if !trySave(st, player, phaseName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Misty crowd revenge (debuff for declining Misty)
|
||||
if player.Mods.CrowdRevengeProc > 0 && st.randFloat() < player.Mods.CrowdRevengeProc {
|
||||
dmg := player.Mods.CrowdRevengeDmg
|
||||
st.playerHP = max(0, st.playerHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: "Misty's crowd",
|
||||
})
|
||||
if st.playerHP <= 0 {
|
||||
if !trySave(st, player, phaseName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pet attack
|
||||
if player.Mods.PetAttackProc > 0 && st.randFloat() < player.Mods.PetAttackProc {
|
||||
petDmg := player.Mods.PetAttackDmg + st.roll(5)
|
||||
st.enemyHP = max(0, st.enemyHP-petDmg)
|
||||
result.PetAttacked = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_attack",
|
||||
Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if enemyDown(st, phaseName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Misty heal
|
||||
if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc {
|
||||
healAmt := player.Mods.MistyHealAmt
|
||||
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
|
||||
result.MistyHealed = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal",
|
||||
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: "Misty",
|
||||
})
|
||||
}
|
||||
|
||||
// Consumable heal: triggers when player drops below 60% HP. Fires up
|
||||
// to HealItemCharges times per fight (1 = legacy one-shot; bosses can
|
||||
// stack from inventory).
|
||||
//
|
||||
// Threshold is 60% rather than 50% to give low-HP classes (cleric,
|
||||
// mage) more breathing room — at 50% a cleric was bleeding into the
|
||||
// danger zone before the heal fired.
|
||||
if st.healChargesLeft > 0 && player.Mods.HealItem > 0 &&
|
||||
st.playerHP > 0 && st.playerHP*5 < player.Stats.MaxHP*3 {
|
||||
st.healChargesLeft--
|
||||
healAmt := player.Mods.HealItem
|
||||
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "heal_item",
|
||||
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
|
||||
// Regenerate (monster ability): the enemy knits its wounds at the close of
|
||||
// every round once the ability has armed st.enemyRegen.
|
||||
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < enemy.Stats.MaxHP {
|
||||
st.enemyHP = min(enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "regen_tick",
|
||||
Damage: st.enemyRegen, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
|
||||
// End-of-round Orc Rage backstop. The primary trigger sits at the
|
||||
// top of resolvePlayerAttack so rage fires same-round when the player
|
||||
// swings after taking the threshold-crossing hit. But if the enemy
|
||||
// goes first this round AND next, the player can be two-shot without
|
||||
// ever getting back to that check. Re-checking here ensures the rage
|
||||
// event always fires while HP > 0 and below 50%, even if the buff
|
||||
// goes unused.
|
||||
maybeTriggerOrcRage(st, player, phaseName)
|
||||
|
||||
return false
|
||||
return simulatePartyWithRNG([]Combatant{player}, enemy, phases, rng).Seats[0]
|
||||
}
|
||||
|
||||
// maybeTriggerOrcRage emits the "rage" event and arms pendingRageAttack
|
||||
@@ -1509,25 +1298,3 @@ func trySave(st *combatState, player *Combatant, phaseName string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func finalize(result CombatResult, st *combatState, player, enemy Combatant) CombatResult {
|
||||
result.Events = st.events
|
||||
result.PlayerEndHP = st.playerHP
|
||||
result.EnemyEndHP = st.enemyHP
|
||||
result.TotalRounds = st.round
|
||||
result.PlayerWon = st.enemyHP <= 0
|
||||
|
||||
playerMax := max(1, player.Stats.MaxHP)
|
||||
enemyMax := max(1, enemy.Stats.MaxHP)
|
||||
if result.PlayerWon && st.playerHP > 0 {
|
||||
result.NearDeath = float64(st.playerHP) < float64(playerMax)*0.15
|
||||
winnerRemaining := float64(st.playerHP) / float64(playerMax)
|
||||
result.Closeness = 1.0 - winnerRemaining
|
||||
} else if !result.PlayerWon {
|
||||
enemyRemaining := float64(st.enemyHP) / float64(enemyMax)
|
||||
result.NearDeath = enemyRemaining < 0.15
|
||||
result.Closeness = 1.0 - enemyRemaining
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -202,9 +202,138 @@ func characterizationScenarios() []charScenario {
|
||||
return e
|
||||
}(), defaultCombatPhases},
|
||||
{"weapon_profile", weaponPlayer(), baseEnemy(), defaultCombatPhases},
|
||||
|
||||
// --- Class-identity mods (2026-05-16 audit + J1). ExtraAttacks is the
|
||||
// lever J1 moved, so it needs a pin before the roster refactor.
|
||||
{"extra_attacks_1", modPlayer(func(m *CombatModifiers) { m.ExtraAttacks = 1 }), tankyEnemy(), defaultCombatPhases},
|
||||
{"extra_attacks_3", modPlayer(func(m *CombatModifiers) { m.ExtraAttacks = 3 }), tankyEnemy(), defaultCombatPhases},
|
||||
{"sneak_attack", modPlayer(func(m *CombatModifiers) { m.SneakAttackDie = 4 }), tankyEnemy(), defaultCombatPhases},
|
||||
{"hunters_mark", modPlayer(func(m *CombatModifiers) { m.HuntersMarkDie = 3 }), tankyEnemy(), defaultCombatPhases},
|
||||
{"divine_strike", weaponMods(func(m *CombatModifiers) { m.DivineStrikePerHit = 4 }), tankyEnemy(), defaultCombatPhases},
|
||||
{"thorn_lash", modPlayer(func(m *CombatModifiers) { m.ThornLashDmg = 3 }), hardHitEnemy(), defaultCombatPhases},
|
||||
{"crit_threshold_19", modPlayer(func(m *CombatModifiers) { m.CritThreshold = 19 }), tankyEnemy(), defaultCombatPhases},
|
||||
{"first_attack_bonus", modPlayer(func(m *CombatModifiers) { m.FirstAttackBonus = 8 }), tankyEnemy(), defaultCombatPhases},
|
||||
{"assassinate", modPlayer(func(m *CombatModifiers) {
|
||||
m.AssassinateAdvantage = true
|
||||
m.AssassinateBonusDmg = 12
|
||||
}), tankyEnemy(), defaultCombatPhases},
|
||||
{"berserker_rage", modPlayer(func(m *CombatModifiers) {
|
||||
m.BerserkerRage = true
|
||||
m.RageMeleeDmg = 2
|
||||
m.PhysicalResistRage = true
|
||||
m.FrenzyDmgBonus = 0.25
|
||||
}), hardHitEnemy(), defaultCombatPhases},
|
||||
{"spirit_weapon", modPlayer(func(m *CombatModifiers) {
|
||||
m.SpiritWeaponProc = 1.0
|
||||
m.SpiritWeaponDmg = 8
|
||||
}), tankyEnemy(), defaultCombatPhases},
|
||||
{"arcane_ward", modPlayer(func(m *CombatModifiers) { m.ArcaneWardHP = 25 }), hardHitEnemy(), defaultCombatPhases},
|
||||
{"heal_charges_3", modPlayer(func(m *CombatModifiers) {
|
||||
m.HealItem = 20
|
||||
m.HealItemCharges = 3
|
||||
}), hardHitEnemy(), defaultCombatPhases},
|
||||
{"crowd_revenge", modPlayer(func(m *CombatModifiers) {
|
||||
m.CrowdRevengeProc = 1.0
|
||||
m.CrowdRevengeDmg = 4
|
||||
}), baseEnemy(), defaultCombatPhases},
|
||||
|
||||
// --- Race passives.
|
||||
{"lucky_reroll", modPlayer(func(m *CombatModifiers) { m.LuckyReroll = true }), tankyEnemy(), defaultCombatPhases},
|
||||
{"orc_rage", modPlayer(func(m *CombatModifiers) { m.RageReady = true }), hardHitEnemy(), defaultCombatPhases},
|
||||
{"poison_resist", modPlayer(func(m *CombatModifiers) { m.PoisonResist = true }), abilityEnemy("Venom", "poison", "any"), defaultCombatPhases},
|
||||
{"fire_resist_aoe", modPlayer(func(m *CombatModifiers) { m.FireResist = true }), abilityEnemy("Flame Breath", "aoe_fire", "any"), defaultCombatPhases},
|
||||
|
||||
// --- Phase 13 bestiary slices 3 + 4. None of these were pinned.
|
||||
{"ability_bonus_damage", basePlayer(), abilityEnemy("Smash", "bonus_damage", "any"), defaultCombatPhases},
|
||||
{"ability_aoe", basePlayer(), abilityEnemy("Blast", "aoe", "any"), defaultCombatPhases},
|
||||
{"ability_death_aoe", basePlayer(), abilityEnemy("Last Breath", "death_aoe", "any"), defaultCombatPhases},
|
||||
{"ability_execute", func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Stats.MaxHP = 40
|
||||
return p
|
||||
}(), abilityEnemy("Finisher", "execute", "any"), defaultCombatPhases},
|
||||
{"ability_self_heal", basePlayer(), abilityEnemy("Mend", "self_heal", "any"), defaultCombatPhases},
|
||||
{"ability_evade", basePlayer(), abilityEnemy("Blink", "evade", "any"), defaultCombatPhases},
|
||||
{"ability_block", basePlayer(), abilityEnemy("Parry", "block", "any"), defaultCombatPhases},
|
||||
{"ability_advantage", basePlayer(), abilityEnemy("Focus", "advantage", "any"), defaultCombatPhases},
|
||||
{"ability_retaliate", basePlayer(), abilityEnemy("Spines", "retaliate", "any"), defaultCombatPhases},
|
||||
{"ability_regenerate", basePlayer(), abilityEnemy("Regrow", "regenerate", "any"), defaultCombatPhases},
|
||||
{"ability_survive_at_1", basePlayer(), func() Combatant {
|
||||
e := abilityEnemy("Undying", "survive_at_1", "any")
|
||||
e.Stats.MaxHP = 40
|
||||
return e
|
||||
}(), defaultCombatPhases},
|
||||
{"ability_stat_drain", basePlayer(), abilityEnemy("Sap", "stat_drain", "any"), defaultCombatPhases},
|
||||
{"ability_debuff", basePlayer(), abilityEnemy("Curse", "debuff", "any"), defaultCombatPhases},
|
||||
{"ability_max_hp_drain", basePlayer(), abilityEnemy("Wither", "max_hp_drain", "any"), defaultCombatPhases},
|
||||
{"ability_spell_resist", func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Mods.SpellPreDamage = 30
|
||||
p.Mods.SpellPreDamageDesc = "Fireball"
|
||||
return p
|
||||
}(), abilityEnemy("Warded", "spell_resist", "any"), defaultCombatPhases},
|
||||
{"ability_reveal_action", basePlayer(), abilityEnemy("Expose", "reveal_action", "any"), defaultCombatPhases},
|
||||
{"ability_fear_immune", func() Combatant {
|
||||
p := basePlayer()
|
||||
p.Mods.SpellPreDamage = 5
|
||||
p.Mods.SpellPreDamageDesc = "Hold Person"
|
||||
p.Mods.SpellEnemySkipFirst = true
|
||||
return p
|
||||
}(), abilityEnemy("Fearless", "fear_immune", "any"), defaultCombatPhases},
|
||||
{"ability_ally_buff", basePlayer(), abilityEnemy("Warcry", "ally_buff", "any"), defaultCombatPhases},
|
||||
|
||||
// --- Phase-scoped ability gating + environment hazard.
|
||||
{"ability_opening_phase", basePlayer(), abilityEnemy("Ambush", "bonus_damage", "opening"), defaultCombatPhases},
|
||||
{"environment_heavy", basePlayer(), baseEnemy(), []CombatPhase{
|
||||
{"Opening", 2, 0.6, 0.8, 1.5, 1.0},
|
||||
{"Clash", 3, 1.2, 1.0, 0.8, 1.0},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// modPlayer returns basePlayer with a mutation applied to its modifiers.
|
||||
func modPlayer(f func(*CombatModifiers)) Combatant {
|
||||
p := basePlayer()
|
||||
f(&p.Mods)
|
||||
return p
|
||||
}
|
||||
|
||||
// weaponMods is modPlayer for effects that only fire on the weapon-dice
|
||||
// damage path (Divine Strike has no effect without a Weapon).
|
||||
func weaponMods(f func(*CombatModifiers)) Combatant {
|
||||
p := basePlayer()
|
||||
p.Stats.Weapon = weaponByID("wpn_longsword")
|
||||
f(&p.Mods)
|
||||
return p
|
||||
}
|
||||
|
||||
// tankyEnemy survives long enough for per-hit and per-swing riders to
|
||||
// accumulate across several rounds instead of dying in the opening.
|
||||
func tankyEnemy() Combatant {
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 300
|
||||
e.Stats.Defense = 10
|
||||
return e
|
||||
}
|
||||
|
||||
// hardHitEnemy hits hard enough to exercise damage-taken paths (wards,
|
||||
// resists, retaliation, heal triggers) before the fight resolves.
|
||||
func hardHitEnemy() Combatant {
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 150
|
||||
e.Stats.Attack = 28
|
||||
return e
|
||||
}
|
||||
|
||||
// abilityEnemy is baseEnemy carrying an always-proccing ability, so the
|
||||
// golden pins the effect rather than the proc roll.
|
||||
func abilityEnemy(name, effect, phase string) Combatant {
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 120
|
||||
e.Ability = &MonsterAbility{Name: name, Phase: phase, ProcChance: 1.0, Effect: effect}
|
||||
return e
|
||||
}
|
||||
|
||||
// charSeeds drives each scenario. Multiple seeds widen RNG-branch coverage
|
||||
// without exploding the golden file.
|
||||
var charSeeds = []uint64{1, 2, 3, 7, 42}
|
||||
|
||||
599
internal/plugin/combat_engine_party.go
Normal file
599
internal/plugin/combat_engine_party.go
Normal file
@@ -0,0 +1,599 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// ── N-body auto-resolve ──────────────────────────────────────────────────────
|
||||
//
|
||||
// The auto-resolve engine seats a roster, exactly as the turn-based engine has
|
||||
// since P3. `SimulateCombat` is the one-seat case and nothing more: for a solo
|
||||
// roster every short-circuit below collapses to the pre-roster code path and
|
||||
// the engine draws from the RNG in precisely the pre-roster order. That is what
|
||||
// keeps `TestCombatCharacterization` byte-identical and the d8prereq_corpus
|
||||
// baselines comparable. If the golden moves, solo balance moved — stop.
|
||||
//
|
||||
// The invariants the solo path rests on, all of them mirrored from P3:
|
||||
//
|
||||
// - enemyTargetSeat draws nothing for a one-seat roster (there is only one
|
||||
// target), so the enemy's choice costs no randomness.
|
||||
// - the initiative loop draws one player roll then one enemy roll, which is
|
||||
// the pre-roster order; ties go to the player, as `playerInit >= enemyInit`
|
||||
// always did.
|
||||
// - the per-seat loops run exactly once.
|
||||
//
|
||||
// Per-actor state (poison, charges, rage, wards) follows the `combatState`
|
||||
// cursor, so the resolution primitives need no changes: they already read
|
||||
// `st.c` — the turn engine has called them that way since P3.
|
||||
|
||||
// PartyCombatResult is one fight, seen from every seat at once. The fight-scoped
|
||||
// fields (the enemy, the round count, the event log) are shared; `Seats` holds
|
||||
// the per-character view, and `Seats[i]` is a complete `CombatResult` so a solo
|
||||
// caller can take `Seats[0]` and be handed exactly what `SimulateCombat` always
|
||||
// returned.
|
||||
type PartyCombatResult struct {
|
||||
PlayerWon bool
|
||||
TimedOut bool
|
||||
TotalRounds int
|
||||
Events []CombatEvent
|
||||
|
||||
EnemyStartHP int
|
||||
EnemyEntryHP int
|
||||
EnemyEndHP int
|
||||
|
||||
Seats []CombatResult
|
||||
}
|
||||
|
||||
// AnySurvivor reports whether at least one seat is still standing. A party can
|
||||
// win a fight it did not all walk away from.
|
||||
func (r PartyCombatResult) AnySurvivor() bool {
|
||||
for i := range r.Seats {
|
||||
if r.Seats[i].PlayerEndHP > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// simulateParty auto-resolves one enemy against a roster of N player characters.
|
||||
// Production auto-resolve passes a nil rng (package global); the sim harness and
|
||||
// the characterization test seed it.
|
||||
func simulateParty(players []Combatant, enemy Combatant, phases []CombatPhase) PartyCombatResult {
|
||||
return simulatePartyWithRNG(players, enemy, phases, nil)
|
||||
}
|
||||
|
||||
func simulatePartyWithRNG(players []Combatant, enemy Combatant, phases []CombatPhase, rng *rand.Rand) PartyCombatResult {
|
||||
enemyStart := enemy.Stats.MaxHP
|
||||
if enemy.Stats.StartHP > 0 && enemy.Stats.StartHP < enemy.Stats.MaxHP {
|
||||
enemyStart = enemy.Stats.StartHP
|
||||
}
|
||||
|
||||
actors := make([]*actor, len(players))
|
||||
seats := make([]CombatResult, len(players))
|
||||
for i := range players {
|
||||
actors[i] = newActor(&players[i])
|
||||
seats[i] = CombatResult{
|
||||
PlayerStartHP: players[i].Stats.MaxHP,
|
||||
PlayerEntryHP: actors[i].playerHP,
|
||||
EnemyStartHP: enemy.Stats.MaxHP,
|
||||
EnemyEntryHP: enemyStart,
|
||||
}
|
||||
}
|
||||
|
||||
st := &combatState{
|
||||
actor: actors[0],
|
||||
actors: actors,
|
||||
enemyHP: enemyStart,
|
||||
rng: rng,
|
||||
}
|
||||
// Holding the enemy holds it for everyone, so the control effect is
|
||||
// fight-scoped: any caster who queued one arms it.
|
||||
for i := range players {
|
||||
if players[i].Mods.SpellEnemySkipFirst {
|
||||
st.enemySkipFirst = true
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-combat one-shots, grouped by kind rather than by seat. A one-seat
|
||||
// roster walks these in the pre-roster order.
|
||||
for i := range actors {
|
||||
st.seat(i)
|
||||
if st.c.Mods.SniperKillProc > 0 && st.randFloat() < st.c.Mods.SniperKillProc {
|
||||
st.enemyHP = 0
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: 0, Phase: "pre_combat", Actor: "npc", Action: "sniper_kill",
|
||||
Damage: enemy.Stats.MaxHP, PlayerHP: st.playerHP, EnemyHP: 0,
|
||||
Seat: i, Desc: "Arina",
|
||||
})
|
||||
seats[i].SniperKilled = true
|
||||
return finalizeParty(seats, st, players, enemy)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range actors {
|
||||
st.seat(i)
|
||||
if st.c.Mods.FlatDmgStart > 0 {
|
||||
dmg := st.c.Mods.FlatDmgStart
|
||||
st.enemyHP = max(0, st.enemyHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "flat_damage",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
|
||||
})
|
||||
if st.enemyHP <= 0 {
|
||||
return finalizeParty(seats, st, players, enemy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Queued spells. Resolved by applyPendingCast() before this runs — the
|
||||
// modifiers carry the resolved damage and narrative hook.
|
||||
for i := range actors {
|
||||
st.seat(i)
|
||||
if st.c.Mods.SpellPreDamageDesc == "" {
|
||||
continue
|
||||
}
|
||||
dmg := st.c.Mods.SpellPreDamage
|
||||
resisted := dmg > 0 && enemyResistsSpells(&enemy, st)
|
||||
if resisted {
|
||||
dmg = max(1, dmg/2)
|
||||
}
|
||||
if dmg > 0 {
|
||||
st.enemyHP = max(0, st.enemyHP-dmg)
|
||||
}
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: 0, Phase: "pre_combat", Actor: "player", Action: "spell_cast",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
|
||||
Desc: st.c.Mods.SpellPreDamageDesc,
|
||||
})
|
||||
if resisted {
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: 0, Phase: "pre_combat", Actor: "enemy", Action: "spell_fizzle",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
|
||||
})
|
||||
}
|
||||
if st.enemyHP <= 0 {
|
||||
return finalizeParty(seats, st, players, enemy)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-combat: Misty gourmet is now a per-round heal, no pre-combat damage
|
||||
|
||||
for _, phase := range phases {
|
||||
roundsThisPhase := phase.Rounds
|
||||
// Add slight variance: ±1 round for non-Decisive phases
|
||||
if phase.Name != "Decisive" && roundsThisPhase > 1 {
|
||||
roundsThisPhase += st.roll(2) // 0 or +1
|
||||
}
|
||||
for r := 0; r < roundsThisPhase; r++ {
|
||||
st.round++
|
||||
if simulatePartyRound(st, &enemy, &phase, seats) {
|
||||
return finalizeParty(seats, st, players, enemy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exhausted the phase clock without a kill. Tiebreak on HP percentage to
|
||||
// decide the outcome — but DO NOT zero out HP on the loser. Timeout =
|
||||
// retreat, not a lethal blow, so no character-death side effects fire.
|
||||
//
|
||||
// The party reads its fraction off the pooled roster, which for one seat is
|
||||
// that seat's own fraction, i.e. the pre-roster comparison. Slight bias to
|
||||
// the players on exact ties (frac >=).
|
||||
var partyHP, partyMax int
|
||||
for i := range st.actors {
|
||||
partyHP += st.actors[i].playerHP
|
||||
partyMax += players[i].Stats.MaxHP
|
||||
}
|
||||
playerFrac := float64(partyHP) / float64(max(1, partyMax))
|
||||
enemyFrac := float64(st.enemyHP) / float64(max(1, enemy.Stats.MaxHP))
|
||||
playerWonTiebreak := playerFrac >= enemyFrac
|
||||
st.seat(0)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: "exhaust", Actor: "system", Action: "timeout",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
for i := range seats {
|
||||
seats[i].TimedOut = true
|
||||
}
|
||||
out := finalizeParty(seats, st, players, enemy)
|
||||
out.TimedOut = true
|
||||
out.PlayerWon = playerWonTiebreak
|
||||
for i := range out.Seats {
|
||||
out.Seats[i].PlayerWon = playerWonTiebreak
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// combatOver reads the fight's terminal condition off HP rather than off a
|
||||
// primitive's bool. resolvePlayerAttack documents why: a retaliate aura can
|
||||
// drop the swinger with the enemy still standing, so `true` means "something
|
||||
// decisive happened", not "the players won". For a one-seat roster this is the
|
||||
// same answer the old `return true` gave.
|
||||
func combatOver(st *combatState) bool {
|
||||
return st.enemyHP <= 0 || !st.anyAlive()
|
||||
}
|
||||
|
||||
// enemyTargetSeat picks who the enemy swings at. A one-seat roster draws no
|
||||
// randomness — there is only one target — which is what keeps the solo RNG
|
||||
// stream identical. Shared with the turn engine.
|
||||
func enemyTargetSeat(st *combatState) (int, bool) {
|
||||
if len(st.actors) == 1 {
|
||||
return 0, st.actors[0].playerHP > 0
|
||||
}
|
||||
standing := make([]int, 0, len(st.actors))
|
||||
for i, a := range st.actors {
|
||||
if a.playerHP > 0 {
|
||||
standing = append(standing, i)
|
||||
}
|
||||
}
|
||||
if len(standing) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return standing[st.roll(len(standing))], true
|
||||
}
|
||||
|
||||
// eventsForSeat is the sub-log a single character is responsible for. Events the
|
||||
// engine never stamped — the enemy regenerating, the phase clock running out —
|
||||
// carry seat 0, so they read as the leader's. Only ever called for a party: a
|
||||
// solo seat is handed the whole log by identity.
|
||||
func eventsForSeat(events []CombatEvent, seat int) []CombatEvent {
|
||||
out := make([]CombatEvent, 0, len(events))
|
||||
for _, e := range events {
|
||||
if e.Seat == seat {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stampEventSeats attributes every event appended since `mark` to a seat, so
|
||||
// party narration can say who did what. Seat 0 stamps a zero, which is
|
||||
// `omitempty` — a solo fight's event log is byte-identical.
|
||||
func stampEventSeats(st *combatState, mark, seat int) {
|
||||
for i := mark; i < len(st.events); i++ {
|
||||
st.events[i].Seat = seat
|
||||
}
|
||||
}
|
||||
|
||||
// roundInitiative rolls the round's turn order: every seat, then the enemy, in
|
||||
// seating order — the pre-roster draw order when there is one seat. Ties favour
|
||||
// the players, then the lower seat.
|
||||
func roundInitiative(st *combatState, enemy *Combatant, phase *CombatPhase) []int {
|
||||
type entry struct {
|
||||
seat int
|
||||
init float64
|
||||
}
|
||||
entries := make([]entry, 0, len(st.actors)+1)
|
||||
for i, a := range st.actors {
|
||||
speed := float64(a.c.Stats.Speed) * phase.SpeedWeight
|
||||
entries = append(entries, entry{i, speed + st.randFloat()*10 + a.c.Mods.InitiativeBias})
|
||||
}
|
||||
enemySpeed := float64(enemy.Stats.Speed) * phase.SpeedWeight
|
||||
entries = append(entries, entry{enemySeat, enemySpeed + st.randFloat()*10})
|
||||
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
a, b := entries[i], entries[j]
|
||||
if a.init != b.init {
|
||||
return a.init > b.init
|
||||
}
|
||||
if (a.seat == enemySeat) != (b.seat == enemySeat) {
|
||||
return b.seat == enemySeat
|
||||
}
|
||||
return a.seat < b.seat
|
||||
})
|
||||
order := make([]int, len(entries))
|
||||
for i, e := range entries {
|
||||
order[i] = e.seat
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// simulatePartyRound runs one round for the whole roster. Returns true if the
|
||||
// fight is over.
|
||||
func simulatePartyRound(st *combatState, enemy *Combatant, phase *CombatPhase, seats []CombatResult) bool {
|
||||
phaseName := phase.Name
|
||||
|
||||
// Whoever the enemy is looking at this round. Chosen before the ability
|
||||
// fires, because the ability lands on its target. Costs no RNG when solo.
|
||||
target, alive := enemyTargetSeat(st)
|
||||
if !alive {
|
||||
return true
|
||||
}
|
||||
st.seat(target)
|
||||
|
||||
// Phase 9 spell: control effect (Hold Person, Sleep, Command) skips the
|
||||
// enemy's attack for one round. Fight-scoped — holding it holds it for all.
|
||||
enemyHeldThisRound := false
|
||||
if st.enemySkipFirst {
|
||||
st.enemySkipFirst = false
|
||||
if enemyImmuneToControl(enemy, st) {
|
||||
// fear_immune: the control spell can't take hold — the enemy acts
|
||||
// as normal this round.
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "fear_resist",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
} else {
|
||||
enemyHeldThisRound = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "spell_held",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Monster ability: check at round start. It resolves against the enemy's
|
||||
// target, which the cursor already points at.
|
||||
abilityDealtDamage := enemyHeldThisRound
|
||||
if enemy.Ability != nil {
|
||||
if abilityFires(enemy.Ability, phaseName, st) {
|
||||
mark := len(st.events)
|
||||
over := applyAbility(st, st.c, enemy, phase, &seats[target])
|
||||
stampEventSeats(st, mark, target)
|
||||
if over && combatOver(st) {
|
||||
return true
|
||||
}
|
||||
// Cleave and lifesteal deal damage — skip normal enemy attack this round
|
||||
switch enemy.Ability.Effect {
|
||||
case "cleave", "lifesteal":
|
||||
abilityDealtDamage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Poison tick from previous round. Stacked per character, so every seat
|
||||
// carrying it bleeds.
|
||||
for i := range st.actors {
|
||||
st.seat(i)
|
||||
if st.poisonTicks <= 0 {
|
||||
continue
|
||||
}
|
||||
st.playerHP = max(0, st.playerHP-st.poisonDmg)
|
||||
st.poisonTicks--
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "poison_tick",
|
||||
Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
|
||||
})
|
||||
if st.playerHP <= 0 && !trySave(st, st.c, phaseName) && !st.anyAlive() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// The poison may have dropped the seat the enemy had picked. Re-target
|
||||
// before the swing procs, which read the target's own modifiers.
|
||||
if st.actors[target].playerHP <= 0 {
|
||||
if target, alive = enemyTargetSeat(st); !alive {
|
||||
return true
|
||||
}
|
||||
}
|
||||
st.seat(target)
|
||||
|
||||
// Pet whiff: if proc'd, enemy's attack this round is a guaranteed miss
|
||||
petWhiff := st.c.Mods.PetWhiffProc > 0 && st.randFloat() < st.c.Mods.PetWhiffProc
|
||||
// Pet deflect: halves incoming damage to the target this round
|
||||
petDeflect := st.c.Mods.PetDeflectProc > 0 && st.randFloat() < st.c.Mods.PetDeflectProc
|
||||
if petDeflect {
|
||||
seats[target].PetDeflected = true
|
||||
}
|
||||
// Spore cloud: enemy has 15% miss chance (decremented when enemy actually attacks)
|
||||
sporeMiss := st.sporeRounds > 0 && st.randFloat() < 0.15
|
||||
|
||||
// Determine initiative. DM mood (Effusive/Hostile) biases a player's roll
|
||||
// via Mods.InitiativeBias — +X means they go first more often.
|
||||
for _, s := range roundInitiative(st, enemy, phase) {
|
||||
if s == enemySeat {
|
||||
if abilityDealtDamage {
|
||||
continue
|
||||
}
|
||||
st.seat(target)
|
||||
mark := len(st.events)
|
||||
over := resolveEnemyAttack(st, st.c, enemy, phase, &seats[target], petWhiff, petDeflect, sporeMiss)
|
||||
stampEventSeats(st, mark, target)
|
||||
if over && combatOver(st) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if st.actors[s].playerHP <= 0 {
|
||||
// A seat that went down earlier this round forfeits its swing.
|
||||
continue
|
||||
}
|
||||
st.seat(s)
|
||||
mark := len(st.events)
|
||||
over := resolvePlayerSwings(st, st.c, enemy, phase, &seats[s])
|
||||
stampEventSeats(st, mark, s)
|
||||
if over && combatOver(st) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// End-of-round, per surviving character.
|
||||
for i := range st.actors {
|
||||
st.seat(i)
|
||||
if st.playerHP <= 0 {
|
||||
continue
|
||||
}
|
||||
mark := len(st.events)
|
||||
if over := endOfRoundForSeat(st, phase, &seats[i]); over {
|
||||
stampEventSeats(st, mark, i)
|
||||
return true
|
||||
}
|
||||
stampEventSeats(st, mark, i)
|
||||
}
|
||||
|
||||
// Regenerate (monster ability): the enemy knits its wounds at the close of
|
||||
// every round once the ability has armed st.enemyRegen. Fight-scoped.
|
||||
if st.enemyRegen > 0 && st.enemyHP > 0 && st.enemyHP < enemy.Stats.MaxHP {
|
||||
st.enemyHP = min(enemy.Stats.MaxHP, st.enemyHP+st.enemyRegen)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "regen_tick",
|
||||
Damage: st.enemyRegen, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
|
||||
// End-of-round Orc Rage backstop. The primary trigger sits at the top of
|
||||
// resolvePlayerAttack so rage fires same-round when the character swings
|
||||
// after taking the threshold-crossing hit. But if the enemy goes first this
|
||||
// round AND next, they can be two-shot without ever getting back to that
|
||||
// check. Re-checking here ensures the rage event always fires while HP > 0
|
||||
// and below 50%, even if the buff goes unused.
|
||||
for i := range st.actors {
|
||||
st.seat(i)
|
||||
mark := len(st.events)
|
||||
maybeTriggerOrcRage(st, st.c, phaseName)
|
||||
stampEventSeats(st, mark, i)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// endOfRoundForSeat runs the per-character close of a round against the seat the
|
||||
// cursor already points at: environment, Misty's crowd, the pet, the spiritual
|
||||
// weapon, Misty's heal, and the consumable auto-heal — in that order, which is
|
||||
// the pre-roster order. Returns true if the fight is over.
|
||||
func endOfRoundForSeat(st *combatState, phase *CombatPhase, result *CombatResult) bool {
|
||||
phaseName := phase.Name
|
||||
player := st.c
|
||||
|
||||
// Environmental hazard
|
||||
if phase.EnvironmentProc > 0 && st.randFloat() < phase.EnvironmentProc {
|
||||
envDmg := 2 + st.roll(5)
|
||||
st.playerHP = max(0, st.playerHP-envDmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "environment", Action: "environmental",
|
||||
Damage: envDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Misty crowd revenge (debuff for declining Misty)
|
||||
if player.Mods.CrowdRevengeProc > 0 && st.randFloat() < player.Mods.CrowdRevengeProc {
|
||||
dmg := player.Mods.CrowdRevengeDmg
|
||||
st.playerHP = max(0, st.playerHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: "Misty's crowd",
|
||||
})
|
||||
if st.playerHP <= 0 && !trySave(st, player, phaseName) && !st.anyAlive() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// A character the round just killed stops acting, but the fight goes on if
|
||||
// anyone else is standing.
|
||||
if st.playerHP <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Pet attack
|
||||
if player.Mods.PetAttackProc > 0 && st.randFloat() < player.Mods.PetAttackProc {
|
||||
petDmg := player.Mods.PetAttackDmg + st.roll(5)
|
||||
st.enemyHP = max(0, st.enemyHP-petDmg)
|
||||
result.PetAttacked = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_attack",
|
||||
Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if enemyDown(st, phaseName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Spiritual Weapon strike
|
||||
if player.Mods.SpiritWeaponProc > 0 && st.randFloat() < player.Mods.SpiritWeaponProc {
|
||||
swDmg := player.Mods.SpiritWeaponDmg + st.roll(5)
|
||||
st.enemyHP = max(0, st.enemyHP-swDmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "spirit_weapon", Action: "spirit_weapon_strike",
|
||||
Damage: swDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if enemyDown(st, phaseName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Misty heal
|
||||
if player.Mods.MistyHealProc > 0 && st.randFloat() < player.Mods.MistyHealProc {
|
||||
healAmt := player.Mods.MistyHealAmt
|
||||
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
|
||||
result.MistyHealed = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal",
|
||||
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: "Misty",
|
||||
})
|
||||
}
|
||||
|
||||
// Consumable heal: triggers when the character drops below 60% HP. Fires up
|
||||
// to HealItemCharges times per fight (1 = legacy one-shot; bosses can
|
||||
// stack from inventory).
|
||||
//
|
||||
// Threshold is 60% rather than 50% to give low-HP classes (cleric, mage)
|
||||
// more breathing room — at 50% a cleric was bleeding into the danger zone
|
||||
// before the heal fired.
|
||||
if st.healChargesLeft > 0 && player.Mods.HealItem > 0 &&
|
||||
st.playerHP > 0 && st.playerHP*5 < player.Stats.MaxHP*3 {
|
||||
st.healChargesLeft--
|
||||
healAmt := player.Mods.HealItem
|
||||
st.playerHP = min(effPlayerMaxHP(player, st), st.playerHP+healAmt)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "heal_item",
|
||||
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// finalizeParty closes the fight, giving every seat its own view. The enemy's
|
||||
// numbers and the event log are shared; HP, closeness and near-death are read
|
||||
// per character. For one seat this is byte-for-byte the old `finalize`.
|
||||
func finalizeParty(seats []CombatResult, st *combatState, players []Combatant, enemy Combatant) PartyCombatResult {
|
||||
won := st.enemyHP <= 0
|
||||
enemyMax := max(1, enemy.Stats.MaxHP)
|
||||
|
||||
// A solo fight's seat holds the whole log, which is the same slice header the
|
||||
// pre-roster engine returned. A party's seats each hold their own events, so
|
||||
// the per-seat close-out (heal items burned, combat achievements) counts what
|
||||
// that character actually did rather than what the party did.
|
||||
solo := len(seats) == 1
|
||||
|
||||
for i := range seats {
|
||||
a := st.actors[i]
|
||||
r := &seats[i]
|
||||
if solo {
|
||||
r.Events = st.events
|
||||
} else {
|
||||
r.Events = eventsForSeat(st.events, i)
|
||||
}
|
||||
r.PlayerEndHP = a.playerHP
|
||||
r.EnemyEndHP = st.enemyHP
|
||||
r.TotalRounds = st.round
|
||||
r.PlayerWon = won
|
||||
|
||||
playerMax := max(1, players[i].Stats.MaxHP)
|
||||
if won && a.playerHP > 0 {
|
||||
r.NearDeath = float64(a.playerHP) < float64(playerMax)*0.15
|
||||
winnerRemaining := float64(a.playerHP) / float64(playerMax)
|
||||
r.Closeness = 1.0 - winnerRemaining
|
||||
} else if !won {
|
||||
enemyRemaining := float64(st.enemyHP) / float64(enemyMax)
|
||||
r.NearDeath = enemyRemaining < 0.15
|
||||
r.Closeness = 1.0 - enemyRemaining
|
||||
}
|
||||
}
|
||||
|
||||
return PartyCombatResult{
|
||||
PlayerWon: won,
|
||||
TotalRounds: st.round,
|
||||
Events: st.events,
|
||||
EnemyStartHP: enemy.Stats.MaxHP,
|
||||
EnemyEntryHP: seats[0].EnemyEntryHP,
|
||||
EnemyEndHP: st.enemyHP,
|
||||
Seats: seats,
|
||||
}
|
||||
}
|
||||
217
internal/plugin/combat_engine_party_test.go
Normal file
217
internal/plugin/combat_engine_party_test.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seededRNG gives each test its own deterministic stream, so none of these can
|
||||
// join the flaky set that rode the package global before P4 seeded them.
|
||||
func seededRNG(seed uint64) *rand.Rand {
|
||||
return rand.New(rand.NewPCG(seed, seed^0x9e3779b9))
|
||||
}
|
||||
|
||||
// The load-bearing claim of P6e: a one-seat roster draws from the RNG in exactly
|
||||
// the pre-roster order, so SimulateCombat is the degenerate case of the N-body
|
||||
// engine and the d8prereq_corpus baselines still compare. TestCombatCharacterization
|
||||
// is the real proof (57 scenarios); this pins the delegation itself.
|
||||
func TestSimulateCombat_IsTheOneSeatPartyCase(t *testing.T) {
|
||||
for seed := uint64(1); seed <= 40; seed++ {
|
||||
solo := simulateCombatWithRNG(basePlayer(), baseEnemy(), dungeonCombatPhases, seededRNG(seed))
|
||||
party := simulatePartyWithRNG([]Combatant{basePlayer()}, baseEnemy(), dungeonCombatPhases, seededRNG(seed))
|
||||
|
||||
if len(party.Seats) != 1 {
|
||||
t.Fatalf("seed %d: one player seated %d seats", seed, len(party.Seats))
|
||||
}
|
||||
got := party.Seats[0]
|
||||
if got.PlayerWon != solo.PlayerWon || got.PlayerEndHP != solo.PlayerEndHP ||
|
||||
got.EnemyEndHP != solo.EnemyEndHP || got.TotalRounds != solo.TotalRounds ||
|
||||
got.TimedOut != solo.TimedOut {
|
||||
t.Fatalf("seed %d: one-seat party diverged from solo\n solo=%+v\nparty=%+v", seed, solo, got)
|
||||
}
|
||||
if len(got.Events) != len(solo.Events) {
|
||||
t.Fatalf("seed %d: event count %d != solo %d", seed, len(got.Events), len(solo.Events))
|
||||
}
|
||||
for i := range solo.Events {
|
||||
if got.Events[i] != solo.Events[i] {
|
||||
t.Fatalf("seed %d: event %d differs\n solo=%+v\nparty=%+v", seed, i, solo.Events[i], got.Events[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A solo fight never stamps a seat, because seat 0 is the zero value and the
|
||||
// field is omitempty. If this regresses, the golden file moves.
|
||||
func TestSimulateCombat_SoloEventsCarryNoSeat(t *testing.T) {
|
||||
res := simulateCombatWithRNG(basePlayer(), baseEnemy(), dungeonCombatPhases, seededRNG(7))
|
||||
for i, e := range res.Events {
|
||||
if e.Seat != 0 {
|
||||
t.Fatalf("event %d (%s/%s) stamped seat %d in a solo fight", i, e.Phase, e.Action, e.Seat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of P6e. Three characters swinging at one monster must land more
|
||||
// player attacks per round than one character does — before this, the members
|
||||
// stood in the doorway while the leader fought.
|
||||
func TestSimulateParty_EverySeatSwings(t *testing.T) {
|
||||
tank := baseEnemy()
|
||||
tank.Stats.MaxHP = 5000 // outlast the phase clock so we can count swings
|
||||
|
||||
countSwings := func(events []CombatEvent, seat int) int {
|
||||
n := 0
|
||||
for _, e := range events {
|
||||
if e.Actor == "player" && e.Seat == seat && e.Roll > 0 {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
res := simulatePartyWithRNG(
|
||||
[]Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(11))
|
||||
|
||||
if len(res.Seats) != 3 {
|
||||
t.Fatalf("seated %d, want 3", len(res.Seats))
|
||||
}
|
||||
for seat := range 3 {
|
||||
if got := countSwings(res.Events, seat); got == 0 {
|
||||
t.Fatalf("seat %d never swung — the party is not fighting together", seat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A member going down is not the end of the fight. Only an empty roster is.
|
||||
// This is the bug P7 measured from the outside: every party loss was the leader
|
||||
// dying alone, because a member was never in the fight to begin with.
|
||||
func TestSimulateParty_DownedMemberDoesNotEndTheFight(t *testing.T) {
|
||||
glass := basePlayer()
|
||||
glass.Stats.MaxHP = 1
|
||||
glass.Stats.Defense = 0
|
||||
|
||||
brute := basePlayer()
|
||||
brute.Stats.MaxHP = 4000
|
||||
|
||||
killer := baseEnemy()
|
||||
killer.Stats.MaxHP = 3000
|
||||
killer.Stats.Attack = 40
|
||||
|
||||
res := simulatePartyWithRNG([]Combatant{brute, glass}, killer, dungeonCombatPhases, seededRNG(3))
|
||||
|
||||
if res.Seats[1].PlayerEndHP > 0 {
|
||||
t.Skip("the glass cannon survived this seed; nothing to assert")
|
||||
}
|
||||
if !res.AnySurvivor() {
|
||||
t.Fatalf("both seats down — pick a seed where the brute lives")
|
||||
}
|
||||
if res.TotalRounds <= 1 {
|
||||
t.Fatalf("fight ended in %d round(s) when a member fell; the roster should have fought on",
|
||||
res.TotalRounds)
|
||||
}
|
||||
// The brute must have kept swinging after the member fell.
|
||||
lastMemberEvent, lastBruteEvent := -1, -1
|
||||
for i, e := range res.Events {
|
||||
if e.Seat == 1 && e.Actor == "player" {
|
||||
lastMemberEvent = i
|
||||
}
|
||||
if e.Seat == 0 && e.Actor == "player" && e.Roll > 0 {
|
||||
lastBruteEvent = i
|
||||
}
|
||||
}
|
||||
if lastBruteEvent < lastMemberEvent {
|
||||
t.Fatalf("the fight stopped when seat 1 fell (last brute swing %d, last member event %d)",
|
||||
lastBruteEvent, lastMemberEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// The enemy swings at one seat per round, not at everyone.
|
||||
func TestSimulateParty_EnemyStrikesOneSeatPerRound(t *testing.T) {
|
||||
tank := baseEnemy()
|
||||
tank.Stats.MaxHP = 5000
|
||||
|
||||
res := simulatePartyWithRNG(
|
||||
[]Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(23))
|
||||
|
||||
perRound := map[int]map[int]bool{}
|
||||
for _, e := range res.Events {
|
||||
if e.Actor != "enemy" || e.Roll == 0 {
|
||||
continue
|
||||
}
|
||||
if perRound[e.Round] == nil {
|
||||
perRound[e.Round] = map[int]bool{}
|
||||
}
|
||||
perRound[e.Round][e.Seat] = true
|
||||
}
|
||||
if len(perRound) == 0 {
|
||||
t.Fatal("the enemy never attacked")
|
||||
}
|
||||
for round, seats := range perRound {
|
||||
if len(seats) != 1 {
|
||||
t.Fatalf("round %d: enemy attack rolls landed on %d seats, want 1", round, len(seats))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Over many seeds the enemy must spread its attention across the roster, or
|
||||
// "targeting" is really "always seat 0" and members take no risk.
|
||||
func TestSimulateParty_EnemySpreadsItsTargetsAcrossTheRoster(t *testing.T) {
|
||||
hit := map[int]bool{}
|
||||
for seed := uint64(1); seed <= 30; seed++ {
|
||||
tank := baseEnemy()
|
||||
tank.Stats.MaxHP = 5000
|
||||
res := simulatePartyWithRNG(
|
||||
[]Combatant{basePlayer(), basePlayer(), basePlayer()}, tank, dungeonCombatPhases, seededRNG(seed))
|
||||
for _, e := range res.Events {
|
||||
if e.Actor == "enemy" && e.Roll > 0 {
|
||||
hit[e.Seat] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for seat := range 3 {
|
||||
if !hit[seat] {
|
||||
t.Fatalf("seat %d was never targeted across 30 seeds — the enemy is not really choosing", seat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-seat close-out reads its own events, not the party's. If eventsForSeat
|
||||
// leaked, a member's potions would be burned for the leader's heals.
|
||||
func TestEventsForSeat_PartitionsTheLog(t *testing.T) {
|
||||
events := []CombatEvent{
|
||||
{Action: "heal_item", Seat: 0},
|
||||
{Action: "heal_item", Seat: 1},
|
||||
{Action: "heal_item", Seat: 1},
|
||||
{Action: "regen_tick"}, // unstamped: reads as the leader's
|
||||
}
|
||||
if got := len(eventsForSeat(events, 0)); got != 2 {
|
||||
t.Fatalf("seat 0 saw %d events, want 2", got)
|
||||
}
|
||||
if got := countHealEventsFired(CombatResult{Events: eventsForSeat(events, 1)}); got != 2 {
|
||||
t.Fatalf("seat 1 fired %d heal items, want 2", got)
|
||||
}
|
||||
if got := countHealEventsFired(CombatResult{Events: eventsForSeat(events, 0)}); got != 1 {
|
||||
t.Fatalf("seat 0 fired %d heal items, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A party that wins with a casualty is a win, and the casualty is still a
|
||||
// casualty: survival is read per seat off HP, never off the fight's outcome.
|
||||
func TestAnySurvivor_ReadsHPNotOutcome(t *testing.T) {
|
||||
res := PartyCombatResult{
|
||||
PlayerWon: true,
|
||||
Seats: []CombatResult{
|
||||
{PlayerEndHP: 12},
|
||||
{PlayerEndHP: 0},
|
||||
{PlayerEndHP: 3},
|
||||
},
|
||||
}
|
||||
if !res.AnySurvivor() {
|
||||
t.Fatal("AnySurvivor said nobody lived")
|
||||
}
|
||||
|
||||
// A won fight nobody walked away from is still nobody walking away.
|
||||
wiped := PartyCombatResult{PlayerWon: true, Seats: []CombatResult{{PlayerEndHP: 0}}}
|
||||
if wiped.AnySurvivor() {
|
||||
t.Fatal("AnySurvivor counted a downed seat as standing")
|
||||
}
|
||||
}
|
||||
@@ -236,6 +236,12 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
||||
case "pet_attack":
|
||||
return fmt.Sprintf(pickRand(narrativePetAttack), e.Damage)
|
||||
|
||||
case "spirit_weapon_strike":
|
||||
return fmt.Sprintf(pickRand(narrativeSpiritWeapon), e.Damage)
|
||||
|
||||
case "concentration_tick":
|
||||
return fmt.Sprintf(pickRand(narrativeConcentrationTick), e.Damage)
|
||||
|
||||
case "pet_deflect":
|
||||
return pickRand(narrativePetDeflect)
|
||||
|
||||
@@ -326,9 +332,9 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
||||
case "survive_at_1":
|
||||
return pickRand(narrativeSurvive)
|
||||
case "stat_drain":
|
||||
return pickRand(narrativeStatDrain)
|
||||
return fmt.Sprintf(pickRand(narrativeStatDrain), e.Damage)
|
||||
case "debuff":
|
||||
return pickRand(narrativeDebuff)
|
||||
return fmt.Sprintf(pickRand(narrativeDebuff), e.Damage)
|
||||
case "max_hp_drain":
|
||||
return fmt.Sprintf(pickRand(narrativeMaxHPDrain), e.Damage)
|
||||
|
||||
@@ -346,7 +352,7 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul
|
||||
case "fear_resist":
|
||||
return pickRand(narrativeFearResist)
|
||||
case "ally_buff":
|
||||
return pickRand(narrativeAllyBuff)
|
||||
return fmt.Sprintf(pickRand(narrativeAllyBuff), e.Damage)
|
||||
|
||||
case "timeout":
|
||||
return pickRand(narrativeTimeout)
|
||||
@@ -526,6 +532,20 @@ var narrativePetAttack = []string{
|
||||
"🐾 Your faithful companion lands a hit for %d damage. More faithful than accurate, but today both applied.",
|
||||
}
|
||||
|
||||
var narrativeSpiritWeapon = []string{
|
||||
"✨ The spectral mace swings on its own and lands for %d damage. Floating menace, well-balanced.",
|
||||
"✨ Your spiritual weapon hovers, picks an angle, strikes — %d damage. No grip, all conviction.",
|
||||
"✨ A glowing weapon arcs in from beside you. %d damage. The enemy keeps trying to track it. Cannot.",
|
||||
"✨ The spectral blade flickers, then bites. %d damage. It does not tire. It does not blink.",
|
||||
}
|
||||
|
||||
var narrativeConcentrationTick = []string{
|
||||
"🌀 The lingering aura grinds the enemy down — %d damage. Still humming. Still hungry.",
|
||||
"🌀 Your spell hasn't let go: the spirits sweep through again for %d damage.",
|
||||
"🌀 The radiant field pulses once more — %d damage. Concentration holds.",
|
||||
"🌀 The enemy steps wrong and the standing magic answers, %d damage. It does not move on.",
|
||||
}
|
||||
|
||||
var narrativePetDeflect = []string{
|
||||
"🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.",
|
||||
"🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.",
|
||||
@@ -854,16 +874,46 @@ var narrativeCloseLoss = []string{
|
||||
// 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 {
|
||||
return RenderPartyTurnRound(events, []string{playerName}, enemyName, 0)
|
||||
}
|
||||
|
||||
// RenderPartyTurnRound renders a round for one member of a party — the reader at
|
||||
// viewerSeat.
|
||||
//
|
||||
// The turn narration pool is written in the second person: "You score 9 damage",
|
||||
// "A hit gets through your guard". That is exactly right for the reader's own
|
||||
// events and exactly wrong for everybody else's, so this splits on the event's
|
||||
// Seat. The reader's own events go through the same flavor pool a solo fight
|
||||
// uses, untouched; an ally's are summarised third-person by renderAllySeatEvent.
|
||||
//
|
||||
// A solo fight has one seat, every event belongs to it, and the reader is it —
|
||||
// so it renders the same bytes it always has.
|
||||
func RenderPartyTurnRound(events []CombatEvent, seatNames []string, enemyName string, viewerSeat int) string {
|
||||
if len(seatNames) == 0 {
|
||||
seatNames = []string{"You"}
|
||||
}
|
||||
seatName := func(seat int) string {
|
||||
if seat > 0 && seat < len(seatNames) {
|
||||
return seatNames[seat]
|
||||
}
|
||||
return seatNames[0]
|
||||
}
|
||||
|
||||
picker := newActionPicker()
|
||||
var lines []string
|
||||
for _, e := range events {
|
||||
line := renderTurnEvent(e, playerName, enemyName, picker)
|
||||
var line string
|
||||
if e.Seat == viewerSeat {
|
||||
line = renderTurnEvent(e, seatName(e.Seat), enemyName, picker)
|
||||
if roll := rollAnnotation(e); line != "" && roll != "" {
|
||||
line += " " + roll
|
||||
}
|
||||
} else {
|
||||
line = renderAllySeatEvent(e, seatName(e.Seat), enemyName)
|
||||
}
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if roll := rollAnnotation(e); roll != "" {
|
||||
line += " " + roll
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
@@ -872,6 +922,80 @@ func RenderTurnRound(events []CombatEvent, playerName, enemyName string) string
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// renderAllySeatEvent summarises one event belonging to somebody else's
|
||||
// character, for a party member's DM. Terse on purpose: the reader wants the
|
||||
// shape of the round, not three sentences of flavor about their friend's pet.
|
||||
// Anything not worth a line in someone else's DM renders as "".
|
||||
func renderAllySeatEvent(e CombatEvent, name, enemyName string) string {
|
||||
who := "**" + name + "**"
|
||||
down := func(line string) string {
|
||||
if e.PlayerHP <= 0 {
|
||||
return line + " " + who + " is down."
|
||||
}
|
||||
return line
|
||||
}
|
||||
switch e.Action {
|
||||
case "hit":
|
||||
if e.Actor == "player" {
|
||||
return fmt.Sprintf("%s hits %s for %d.", who, enemyName, e.Damage)
|
||||
}
|
||||
return down(fmt.Sprintf("%s hits %s for %d.", enemyName, who, e.Damage))
|
||||
case "crit":
|
||||
if e.Actor == "player" {
|
||||
return fmt.Sprintf("%s crits %s for %d!", who, enemyName, e.Damage)
|
||||
}
|
||||
return down(fmt.Sprintf("%s crits %s for %d!", enemyName, who, e.Damage))
|
||||
case "miss":
|
||||
if e.Actor == "player" {
|
||||
return fmt.Sprintf("%s misses.", who)
|
||||
}
|
||||
return fmt.Sprintf("%s misses %s.", enemyName, who)
|
||||
case "block":
|
||||
// renderEvent's convention: Actor "player" means the *enemy* blocked the
|
||||
// player's swing, and vice versa.
|
||||
if e.Actor == "player" {
|
||||
return fmt.Sprintf("%s blocks %s (%d).", enemyName, who, e.Damage)
|
||||
}
|
||||
return fmt.Sprintf("%s blocks (%d).", who, e.Damage)
|
||||
case "spell_cast":
|
||||
label := e.Desc
|
||||
if label == "" {
|
||||
label = "a spell"
|
||||
}
|
||||
return fmt.Sprintf("%s casts %s.", who, label)
|
||||
case "use_consumable":
|
||||
label := e.Desc
|
||||
if label == "" {
|
||||
label = "an item"
|
||||
}
|
||||
return fmt.Sprintf("%s uses %s.", who, label)
|
||||
case "pet_attack":
|
||||
return fmt.Sprintf("🐾 %s's pet strikes for %d.", who, e.Damage)
|
||||
case "spirit_weapon_strike":
|
||||
return fmt.Sprintf("%s's spirit weapon strikes for %d.", who, e.Damage)
|
||||
case "concentration_tick":
|
||||
return fmt.Sprintf("%s's aura burns %s for %d.", who, enemyName, e.Damage)
|
||||
case "poison_tick":
|
||||
return down(fmt.Sprintf("☠️ %s takes %d from poison.", who, e.Damage))
|
||||
case "environmental":
|
||||
return down(fmt.Sprintf("%s takes %d from the room.", who, e.Damage))
|
||||
case "flat_damage":
|
||||
return fmt.Sprintf("%s deals %d.", who, e.Damage)
|
||||
case "heal_item", "misty_heal":
|
||||
return fmt.Sprintf("%s recovers %d.", who, e.Damage)
|
||||
case "death_save":
|
||||
return fmt.Sprintf("%s clings on.", who)
|
||||
case "stun", "stunned":
|
||||
return fmt.Sprintf("%s is stunned.", who)
|
||||
case "flee":
|
||||
return fmt.Sprintf("%s breaks off.", who)
|
||||
default:
|
||||
// Ward absorbs, spore misses, reflects, enrage cues: real, but noise in
|
||||
// somebody else's DM. The owner sees them in full in their own.
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func renderTurnEvent(e CombatEvent, playerName, enemyName string, picker *actionPicker) string {
|
||||
switch e.Action {
|
||||
case "flee":
|
||||
|
||||
24
internal/plugin/combat_narrative_drain_test.go
Normal file
24
internal/plugin/combat_narrative_drain_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestRenderEvent_StatefulDrainsFormatMagnitude is a regression test for the
|
||||
// leaked "%d" in stateful enemy-effect narration. stat_drain, debuff and
|
||||
// ally_buff carry their magnitude in CombatEvent.Damage and their flavor
|
||||
// pools contain a %d placeholder; renderEvent must fmt.Sprintf them rather
|
||||
// than emit the template raw (which surfaced "-%d damage" to players).
|
||||
func TestRenderEvent_StatefulDrainsFormatMagnitude(t *testing.T) {
|
||||
for _, action := range []string{"stat_drain", "debuff", "ally_buff"} {
|
||||
e := CombatEvent{Actor: "enemy", Action: action, Damage: 4}
|
||||
out := renderEvent(e, "Rurina", "Shadow", CombatResult{}, newActionPicker())
|
||||
if strings.Contains(out, "%") {
|
||||
t.Errorf("%s: leaked format placeholder in output: %q", action, out)
|
||||
}
|
||||
if !strings.Contains(out, "4") {
|
||||
t.Errorf("%s: expected magnitude 4 in output: %q", action, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
331
internal/plugin/combat_participant_test.go
Normal file
331
internal/plugin/combat_participant_test.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// setupEmptyTestDB opens a fresh schema in a temp dir. Unlike setupZoneRunTestDB
|
||||
// it copies nothing from data/gogobee.db, so it runs everywhere instead of
|
||||
// skipping when the prod db is absent — the session/participant/party tables are
|
||||
// the only ones these tests touch, and none of them need seeded characters.
|
||||
func setupEmptyTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
db.Close()
|
||||
if err := db.Init(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
// ── The wire format ─────────────────────────────────────────────────────────
|
||||
|
||||
// The ActorStatuses split moved ~25 fields behind an anonymous embed. That is a
|
||||
// no-op on the wire only because the embed is untagged: encoding/json flattens
|
||||
// it into the parent object. If someone ever gives it a json tag, every
|
||||
// in-flight prod session silently loses its poison, its charges, and its
|
||||
// once-per-fight one-shots on the next resume. Pin the flattening.
|
||||
func TestCombatStatuses_JSONStaysFlat(t *testing.T) {
|
||||
s := CombatStatuses{
|
||||
ActorStatuses: ActorStatuses{PoisonTicks: 2, PoisonDmg: 5, WardCharges: 3},
|
||||
Enraged: true,
|
||||
EnemyRegen: 4,
|
||||
}
|
||||
raw, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"poison_ticks", "poison_dmg", "ward_charges", "enraged", "enemy_regen"} {
|
||||
if _, ok := obj[key]; !ok {
|
||||
t.Errorf("key %q missing — ActorStatuses is nested, not flattened: %s", key, raw)
|
||||
}
|
||||
}
|
||||
if _, nested := obj["ActorStatuses"]; nested {
|
||||
t.Errorf("ActorStatuses serialized as a nested object: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// A statuses_json blob written before the split must decode unchanged.
|
||||
func TestCombatStatuses_DecodesPreSplitRow(t *testing.T) {
|
||||
const preSplit = `{"poison_ticks":3,"poison_dmg":6,"enraged":true,` +
|
||||
`"ward_charges":2,"lucky_used":true,"enemy_retaliate_frac":0.25,` +
|
||||
`"buff_ac_bonus":4,"max_hp_drain":7}`
|
||||
var s CombatStatuses
|
||||
if err := json.Unmarshal([]byte(preSplit), &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.PoisonTicks != 3 || s.PoisonDmg != 6 || s.WardCharges != 2 ||
|
||||
s.LuckyUsed != true || s.BuffACBonus != 4 || s.MaxHPDrain != 7 {
|
||||
t.Errorf("per-actor fields lost: %+v", s.ActorStatuses)
|
||||
}
|
||||
if !s.Enraged || s.EnemyRetaliateFrac != 0.25 {
|
||||
t.Errorf("fight-scoped fields lost: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Solo stays solo ─────────────────────────────────────────────────────────
|
||||
|
||||
// The whole balance corpus rides the solo path. It must write no participant
|
||||
// rows, and its roster_size must stay at the DEFAULT so the loader never issues
|
||||
// the second query (project_combat_session_cache_deferred: don't make it worse).
|
||||
func TestSoloSessionWritesNoParticipantRows(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@solo-noparts:example.org")
|
||||
defer cleanupCombatSessions(uid)
|
||||
|
||||
s, err := startCombatSession(uid, "run-solo", "node-1", "owlbear", 40, 40, 60, 60)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := saveCombatSession(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var rows int
|
||||
if err := db.Get().QueryRow(
|
||||
`SELECT COUNT(*) FROM combat_participant WHERE session_id = ?`, s.SessionID,
|
||||
).Scan(&rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows != 0 {
|
||||
t.Errorf("solo fight wrote %d participant rows, want 0", rows)
|
||||
}
|
||||
|
||||
var rosterSize int
|
||||
if err := db.Get().QueryRow(
|
||||
`SELECT roster_size FROM combat_session WHERE session_id = ?`, s.SessionID,
|
||||
).Scan(&rosterSize); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rosterSize != 1 {
|
||||
t.Errorf("roster_size = %d, want 1", rosterSize)
|
||||
}
|
||||
|
||||
got, err := getActiveCombatSession(uid)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("getActiveCombatSession: %v / %v", got, err)
|
||||
}
|
||||
if got.IsParty() || got.RosterSize() != 1 || len(got.Participants) != 0 {
|
||||
t.Errorf("solo session reads back as a party: %+v", got.Participants)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Party seats ─────────────────────────────────────────────────────────────
|
||||
|
||||
func seatParty(t *testing.T, s *CombatSession, members ...CombatParticipant) {
|
||||
t.Helper()
|
||||
if err := insertCombatParticipants(s.SessionID, members); err != nil {
|
||||
t.Fatalf("insertCombatParticipants: %v", err)
|
||||
}
|
||||
s.Participants = members
|
||||
}
|
||||
|
||||
func TestCombatParticipants_RoundTrip(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@party-lead:example.org")
|
||||
defer cleanupCombatSessions(uid)
|
||||
|
||||
s, err := startCombatSession(uid, "run-party", "node-1", "owlbear", 40, 40, 200, 200)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seatParty(t, s,
|
||||
CombatParticipant{Seat: 1, UserID: "@b:x", HP: 30, HPMax: 30,
|
||||
Statuses: ActorStatuses{WardCharges: 2}},
|
||||
CombatParticipant{Seat: 2, UserID: "@c:x", HP: 25, HPMax: 25,
|
||||
Statuses: ActorStatuses{ConcentrationDmg: 9, LuckyUsed: true}},
|
||||
)
|
||||
|
||||
got, err := getActiveCombatSession(uid)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("getActiveCombatSession: %v / %v", got, err)
|
||||
}
|
||||
if !got.IsParty() || got.RosterSize() != 3 {
|
||||
t.Fatalf("roster = %d, want 3", got.RosterSize())
|
||||
}
|
||||
if got.Participants[0].UserID != "@b:x" || got.Participants[0].Statuses.WardCharges != 2 {
|
||||
t.Errorf("seat 1 round-trip wrong: %+v", got.Participants[0])
|
||||
}
|
||||
if got.Participants[1].Statuses.ConcentrationDmg != 9 || !got.Participants[1].Statuses.LuckyUsed {
|
||||
t.Errorf("seat 2 round-trip wrong: %+v", got.Participants[1])
|
||||
}
|
||||
if want := []string{string(uid), "@b:x", "@c:x"}; !equalStrings(got.SeatUserIDs(), want) {
|
||||
t.Errorf("SeatUserIDs = %v, want %v", got.SeatUserIDs(), want)
|
||||
}
|
||||
|
||||
// Mutable half writes back; hp_max and user_id do not move.
|
||||
got.Participants[0].HP = 11
|
||||
got.Participants[0].Statuses.WardCharges = 0
|
||||
got.Participants[0].Statuses.Raged = true
|
||||
if err := saveCombatSession(got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := getCombatSession(s.SessionID)
|
||||
if err != nil || again == nil {
|
||||
t.Fatalf("getCombatSession: %v / %v", again, err)
|
||||
}
|
||||
p := again.Participants[0]
|
||||
if p.HP != 11 || p.HPMax != 30 || p.Statuses.WardCharges != 0 || !p.Statuses.Raged {
|
||||
t.Errorf("seat 1 save round-trip wrong: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// A gap in the seat sequence would shift every member down one index, because
|
||||
// the engine addresses the roster positionally. Fail the load instead.
|
||||
func TestLoadCombatParticipants_RejectsSeatGap(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@party-gap:example.org")
|
||||
defer cleanupCombatSessions(uid)
|
||||
|
||||
s, err := startCombatSession(uid, "run-gap", "node-1", "owlbear", 40, 40, 200, 200)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Seat 2 with no seat 1.
|
||||
if err := insertCombatParticipants(s.SessionID, []CombatParticipant{
|
||||
{Seat: 2, UserID: "@c:x", HP: 25, HPMax: 25},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadCombatParticipants(s.SessionID); err == nil {
|
||||
t.Error("loadCombatParticipants accepted a seat gap")
|
||||
}
|
||||
}
|
||||
|
||||
// roster_size disagreeing with the persisted seats means a half-written fight.
|
||||
// Reading it as solo would silently drop the party mid-combat.
|
||||
func TestHydrateCombatParticipants_RejectsRosterMismatch(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
uid := id.UserID("@party-mismatch:example.org")
|
||||
defer cleanupCombatSessions(uid)
|
||||
|
||||
s, err := startCombatSession(uid, "run-mm", "node-1", "owlbear", 40, 40, 200, 200)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seatParty(t, s, CombatParticipant{Seat: 1, UserID: "@b:x", HP: 30, HPMax: 30})
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE combat_session SET roster_size = 3 WHERE session_id = ?`, s.SessionID,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := getActiveCombatSession(uid); err == nil {
|
||||
t.Error("hydrate accepted roster_size 3 with 2 seats persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActiveCombatSessionForMember(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
lead := id.UserID("@lead-lookup:example.org")
|
||||
member := id.UserID("@member-lookup:example.org")
|
||||
defer cleanupCombatSessions(lead)
|
||||
|
||||
s, err := startCombatSession(lead, "run-lookup", "node-1", "owlbear", 40, 40, 200, 200)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seatParty(t, s, CombatParticipant{Seat: 1, UserID: string(member), HP: 30, HPMax: 30})
|
||||
|
||||
// The member owns no session row, so the seat-0 lookup must miss them...
|
||||
if got, err := getActiveCombatSession(member); err != nil || got != nil {
|
||||
t.Errorf("getActiveCombatSession found a row for a party member: %v / %v", got, err)
|
||||
}
|
||||
// ...and the member lookup must find the fight, fully hydrated.
|
||||
got, err := getActiveCombatSessionForMember(member)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("getActiveCombatSessionForMember: %v / %v", got, err)
|
||||
}
|
||||
if got.SessionID != s.SessionID || got.RosterSize() != 2 {
|
||||
t.Errorf("wrong session for member: %+v", got)
|
||||
}
|
||||
// The leader is not a participant row, so the member lookup must miss them.
|
||||
if got, err := getActiveCombatSessionForMember(lead); err != nil || got != nil {
|
||||
t.Errorf("member lookup matched the leader: %v / %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── The engine writes every seat, not just the cursor ───────────────────────
|
||||
|
||||
// P3 shipped with seats 1+ opening fresh from their Mods on every resume: a
|
||||
// party member's once-per-fight one-shots rearmed every single engine step. P4
|
||||
// is what fixes that, so pin it. A round_end step mutates no one-shot, so an
|
||||
// exact round-trip is the assertion.
|
||||
func TestTurnEngine_PartySeatStatusesSurviveAStep(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseRoundEnd, 50, 50)
|
||||
sess.Participants = []CombatParticipant{{
|
||||
Seat: 1, UserID: "@b:x", HP: 30, HPMax: 30,
|
||||
Statuses: ActorStatuses{
|
||||
Raged: true, LuckyUsed: true, DeathSaveUsed: true,
|
||||
WardCharges: 2, ArcaneWardHP: 8,
|
||||
BuffACBonus: 3, // command-layer owned; commit must not zero it
|
||||
},
|
||||
}}
|
||||
want := sess.Participants[0].Statuses
|
||||
|
||||
a, b, enemy := basePlayer(), basePlayer(), baseEnemy()
|
||||
rng := rand.New(rand.NewPCG(42, phaseOrdinal(sess.Phase)))
|
||||
te := resumeTurnEngine(sess, []*Combatant{&a, &b}, &enemy, rng)
|
||||
if _, err := te.step(PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
te.commit()
|
||||
|
||||
if got := sess.Participants[0].Statuses; got != want {
|
||||
t.Errorf("seat 1 statuses moved across a step:\n got %+v\nwant %+v", got, want)
|
||||
}
|
||||
if sess.Participants[0].HP != 30 {
|
||||
t.Errorf("seat 1 HP = %d, want 30", sess.Participants[0].HP)
|
||||
}
|
||||
}
|
||||
|
||||
// commit reads each seat by index, never off the cursor — round_end parks the
|
||||
// cursor at seat 0, and the enemy turn parks it on its target. A seat's damage
|
||||
// must land on that seat's row.
|
||||
func TestTurnEngine_CommitWritesSeatsByIndex(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseRoundEnd, 50, 50)
|
||||
sess.Participants = []CombatParticipant{
|
||||
{Seat: 1, UserID: "@b:x", HP: 30, HPMax: 30, Statuses: ActorStatuses{PoisonTicks: 1, PoisonDmg: 4}},
|
||||
{Seat: 2, UserID: "@c:x", HP: 25, HPMax: 25},
|
||||
}
|
||||
a, b, c, enemy := basePlayer(), basePlayer(), basePlayer(), baseEnemy()
|
||||
rng := rand.New(rand.NewPCG(42, phaseOrdinal(sess.Phase)))
|
||||
te := resumeTurnEngine(sess, []*Combatant{&a, &b, &c}, &enemy, rng)
|
||||
if _, err := te.step(PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
te.commit()
|
||||
|
||||
// Seat 1's poison ticked on seat 1 alone.
|
||||
if sess.Participants[0].HP != 26 {
|
||||
t.Errorf("seat 1 HP = %d, want 26 (30 - 4 poison)", sess.Participants[0].HP)
|
||||
}
|
||||
if sess.Participants[0].Statuses.PoisonTicks != 0 {
|
||||
t.Errorf("seat 1 poison ticks = %d, want 0", sess.Participants[0].Statuses.PoisonTicks)
|
||||
}
|
||||
if sess.Participants[1].HP != 25 {
|
||||
t.Errorf("seat 2 HP = %d, want 25 — seat 1's poison leaked", sess.Participants[1].HP)
|
||||
}
|
||||
if sess.PlayerHP != 50 {
|
||||
t.Errorf("seat 0 HP = %d, want 50 — seat 1's poison leaked", sess.PlayerHP)
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
183
internal/plugin/combat_party_finish.go
Normal file
183
internal/plugin/combat_party_finish.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// N3/P5 — closing out a party fight.
|
||||
//
|
||||
// finishCombatSession (combat_cmd.go) is the solo close-out and stays exactly
|
||||
// what it was. This is its sibling for a seated roster, and the split it makes
|
||||
// is the one the data model already forced:
|
||||
//
|
||||
// - Run- and expedition-scoped effects fire ONCE, for the owner. Threat, the
|
||||
// zone-kill record, the boss-defeat threat drop, the run teardown on a loss
|
||||
// — every one of them resolves through getActiveExpedition(userID) or
|
||||
// getActiveZoneRun(userID), and a party member owns neither row. Fanning
|
||||
// them out would be a no-op for members and would triple the threat the
|
||||
// owner pays for a single kill.
|
||||
// - Character-scoped effects fan out. HP, XP, loot, and death belong to the
|
||||
// person, not the expedition, and every seat gets their own.
|
||||
//
|
||||
// A member can be dead in a fight the party won: they dropped, the others
|
||||
// finished the job. So death is decided per seat off HP, not off the session's
|
||||
// terminal status.
|
||||
|
||||
// finishPartyCombatSession runs the terminal side effects for a seated roster
|
||||
// and returns each seat's own player-facing close-out, indexed by seat. A solo
|
||||
// session delegates to finishCombatSession untouched.
|
||||
func (p *AdventurePlugin) finishPartyCombatSession(ct *combatTurn) []string {
|
||||
sess, enemy := ct.sess, *ct.enemy
|
||||
owner := id.UserID(sess.UserID)
|
||||
if !ct.isParty() {
|
||||
return []string{p.finishCombatSession(owner, sess, enemy)}
|
||||
}
|
||||
|
||||
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 — the run's, so once.
|
||||
scanMoodEventsFromEvents(sess.RunID, sess.TurnLog)
|
||||
|
||||
// Every seat's HP lands on their sheet regardless of how the fight ended.
|
||||
for seat := range sess.RosterSize() {
|
||||
persistDnDHPAfterCombat(id.UserID(sess.seatUserID(seat)), sess.seatHP(seat))
|
||||
}
|
||||
|
||||
switch sess.Status {
|
||||
case CombatStatusWon:
|
||||
return p.finishPartyWin(ct, run, zone, monster, elite, cadence)
|
||||
case CombatStatusLost:
|
||||
return p.finishPartyLoss(ct, zone, cadence)
|
||||
case CombatStatusFled:
|
||||
_ = abandonZoneRun(owner)
|
||||
forceExtractExpeditionForRunLoss(owner, "combat flee")
|
||||
return p.eachSeat(ct, fmt.Sprintf(
|
||||
"🏃 The party broke off from **%s** and slipped away. Run ended — you keep your wounds, but you live.", enemy.Name))
|
||||
default:
|
||||
return p.eachSeat(ct, "The fight is over.")
|
||||
}
|
||||
}
|
||||
|
||||
// finishPartyWin pays the party out. The kill is the expedition's, so threat and
|
||||
// the zone-kill record resolve once through the owner; XP and loot are the
|
||||
// character's, so every member rolls their own — loot independently, per the C1
|
||||
// no-split rule, and XP in full, per accessibility over crunch.
|
||||
func (p *AdventurePlugin) finishPartyWin(
|
||||
ct *combatTurn, run *DungeonRun, zone ZoneDefinition, monster DnDMonsterTemplate, elite bool, cadence int,
|
||||
) []string {
|
||||
sess := ct.sess
|
||||
owner := id.UserID(sess.UserID)
|
||||
|
||||
recordZoneKillForUser(owner, sess.EnemyID)
|
||||
applyRoomCombatThreatForUser(owner, elite)
|
||||
|
||||
bossOnExpedition := false
|
||||
if !elite {
|
||||
if exp, eerr := getActiveExpedition(owner); eerr == nil && exp != nil {
|
||||
_ = applyBossDefeatThreat(exp.ID)
|
||||
bossOnExpedition = true
|
||||
}
|
||||
}
|
||||
|
||||
tier := 1
|
||||
if run != nil {
|
||||
tier = int(zone.Tier)
|
||||
}
|
||||
shared := twinBeeLine(zone.ID, DMCombatEnd, sess.RunID, cadence)
|
||||
emoji := "✅"
|
||||
if !elite {
|
||||
emoji = "🏆"
|
||||
}
|
||||
|
||||
out := make([]string, sess.RosterSize())
|
||||
for seat := range sess.RosterSize() {
|
||||
uid := id.UserID(sess.seatUserID(seat))
|
||||
hp, hpMax := sess.seatHP(seat), sess.seatHPMax(seat)
|
||||
|
||||
// A member who went down before the killing blow still earns the kill —
|
||||
// but at a fifth of their pool or less, the XP path calls it near-death.
|
||||
nearDeath := hpMax > 0 && hp*5 < hpMax
|
||||
if xp := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: nearDeath}, monster.CR, tier); xp > 0 {
|
||||
if _, err := p.grantDnDXP(uid, xp); err != nil {
|
||||
slog.Error("combat: party grantDnDXP", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
if shared != "" && seat == 0 {
|
||||
b.WriteString(shared + "\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s **%s** down. The party stands.\n", emoji, ct.enemy.Name))
|
||||
if hp <= 0 {
|
||||
// They won it from the floor. Down is down: the hospital takes them.
|
||||
markAdventureDead(uid, "zone", zone.Display)
|
||||
b.WriteString("💀 You didn't see it fall — you were already down.\n")
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("You finished at **%d/%d HP**.\n", hp, hpMax))
|
||||
if drop := p.dropZoneLoot(uid, zone.ID, monster, !elite, elite); drop != "" {
|
||||
b.WriteString(drop + "\n")
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case bossOnExpedition && seat == 0:
|
||||
b.WriteString("🎉 **Zone cleared — the expedition is won.** `!expedition run` to march out and claim your spoils.")
|
||||
case bossOnExpedition:
|
||||
b.WriteString("🎉 **Zone cleared — the expedition is won.** Your leader marches the party out.")
|
||||
case seat == 0:
|
||||
b.WriteString(continueHint(owner))
|
||||
default:
|
||||
b.WriteString("Waiting on your leader to move the party on.")
|
||||
}
|
||||
out[seat] = b.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// finishPartyLoss ends the run for everyone. The whole roster is down — the turn
|
||||
// engine only reports Lost once anyAlive() goes false — so every member takes
|
||||
// the death, and the owner's run and expedition are torn down once.
|
||||
func (p *AdventurePlugin) finishPartyLoss(ct *combatTurn, zone ZoneDefinition, cadence int) []string {
|
||||
sess := ct.sess
|
||||
owner := id.UserID(sess.UserID)
|
||||
|
||||
if run, _ := getZoneRun(sess.RunID); run != nil {
|
||||
_, _ = applyMoodEvent(sess.RunID, MoodEventPlayerDeath)
|
||||
}
|
||||
_ = abandonZoneRun(owner)
|
||||
forceExtractExpeditionForRunLoss(owner, "combat death")
|
||||
|
||||
line := twinBeeLine(zone.ID, DMPlayerDeath, sess.RunID, cadence)
|
||||
out := make([]string, sess.RosterSize())
|
||||
for seat := range sess.RosterSize() {
|
||||
markAdventureDead(id.UserID(sess.seatUserID(seat)), "zone", zone.Display)
|
||||
var b strings.Builder
|
||||
if line != "" && seat == 0 {
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("💀 The party fell to **%s**. Run ended.", ct.enemy.Name))
|
||||
out[seat] = b.String()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// eachSeat gives every seat the same close-out block.
|
||||
func (p *AdventurePlugin) eachSeat(ct *combatTurn, block string) []string {
|
||||
out := make([]string, ct.sess.RosterSize())
|
||||
for i := range out {
|
||||
out[i] = block
|
||||
}
|
||||
return out
|
||||
}
|
||||
196
internal/plugin/combat_party_start.go
Normal file
196
internal/plugin/combat_party_start.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// N3/P6c — seating the party at the doorway.
|
||||
//
|
||||
// P5 built startPartyCombatSession and left it with no production caller: there
|
||||
// was no roster to seat it with. P6b filled the roster. This is the join.
|
||||
//
|
||||
// The rule the whole file turns on is that **seat 0 is the expedition leader**.
|
||||
// Every seat-0 invariant P4 and P5 laid down rests on it: combat_session.user_id
|
||||
// is the fight's lock key, the run- and expedition-scoped close-out effects fire
|
||||
// once through it, and `!flee` is refused to any seat but zero. A party whose
|
||||
// seat 0 were merely "whoever typed !fight" would flee the leader's run on a
|
||||
// member's say-so.
|
||||
|
||||
// fightRoster is the seating order for a fight opened on this run: the
|
||||
// expedition's leader first, then their party in join order. A bare zone run —
|
||||
// and a solo expedition, whose roster table is empty — resolves to the one
|
||||
// player who owns it.
|
||||
//
|
||||
// It doubles as the answer to "under whose lock is this fight taken", since
|
||||
// roster[0] is the session's owner. It is resolved *before* the lock, so it
|
||||
// must not touch getActiveZoneRun — that lookup carries the §4.3 idle reap.
|
||||
func fightRoster(sender id.UserID) []id.UserID {
|
||||
e, _, err := activeExpeditionFor(sender)
|
||||
if err != nil || e == nil {
|
||||
return []id.UserID{sender}
|
||||
}
|
||||
return expeditionAudience(e)
|
||||
}
|
||||
|
||||
// buildFightSeats turns a roster into the seats that will actually sit down, and
|
||||
// the enemy they face. A member who is down, or somehow already fighting, is left
|
||||
// out: they sit this one out rather than blocking the party. The leader is not
|
||||
// optional — if seat 0 cannot fight, nobody does, and `refusal` says why in terms
|
||||
// of whoever typed `!fight`.
|
||||
//
|
||||
// senderSkip is the sender's own reason for being left out, empty when they are
|
||||
// seated. Without it a downed member's `!fight` opens the party's fight and then
|
||||
// answers them with silence.
|
||||
//
|
||||
// The enemy is built once. Every seat's build derives the identical stat block
|
||||
// from (monster, tier, dmMood); only the player half varies.
|
||||
func (p *AdventurePlugin) buildFightSeats(
|
||||
sender id.UserID, roster []id.UserID, monster DnDMonsterTemplate, tier, dmMood int,
|
||||
) (seats []CombatSeatSetup, enemy *Combatant, senderSkip, refusal string) {
|
||||
skip := func(uid id.UserID, why string) {
|
||||
if uid == sender {
|
||||
senderSkip = why
|
||||
}
|
||||
}
|
||||
for i, uid := range roster {
|
||||
leader := i == 0
|
||||
player, e, _, err := p.buildZoneCombatants(uid, monster, tier, dmMood)
|
||||
if err != nil {
|
||||
if leader {
|
||||
return nil, nil, "", "Couldn't set up the fight: " + err.Error()
|
||||
}
|
||||
slog.Warn("combat: party member left out of fight", "user", uid, "err", err)
|
||||
skip(uid, "Couldn't bring you into the fight: "+err.Error())
|
||||
continue
|
||||
}
|
||||
if leader {
|
||||
enemy = &e
|
||||
}
|
||||
|
||||
hp, hpMax := dndHPSnapshot(uid)
|
||||
if hp <= 0 {
|
||||
if leader {
|
||||
return nil, nil, "", seatZeroRefusal(sender, uid,
|
||||
"You're in no shape to fight. `!rest` first.",
|
||||
"Your party leader is in no shape to fight. The fight waits for them.")
|
||||
}
|
||||
skip(uid, "You're in no shape to fight — the party goes in without you. `!rest` when you can.")
|
||||
continue
|
||||
}
|
||||
// A member's live fight lives on a combat_participant row, so
|
||||
// hasActiveCombatSession — which keys on combat_session.user_id — would
|
||||
// answer "no" for them mid-fight. The caller has already ruled out this
|
||||
// room's own encounter, so anything found here is a different fight.
|
||||
if s, serr := activeCombatSessionFor(uid); serr == nil && s != nil {
|
||||
if leader {
|
||||
return nil, nil, "", seatZeroRefusal(sender, uid,
|
||||
"You're already in a fight. Finish it with `!attack` / `!flee`.",
|
||||
"Your party leader is already in a fight somewhere else.")
|
||||
}
|
||||
slog.Info("combat: party member busy in another fight", "user", uid, "session", s.SessionID)
|
||||
skip(uid, "You're already in a fight elsewhere — the party goes in without you.")
|
||||
continue
|
||||
}
|
||||
|
||||
seats = append(seats, CombatSeatSetup{UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player})
|
||||
}
|
||||
return seats, enemy, senderSkip, ""
|
||||
}
|
||||
|
||||
// seatCombatants is the roster's freshly-built characters in seating order — the
|
||||
// same slice partyCombatantsForSession would rebuild from the persisted session,
|
||||
// threaded through from the build that seated them instead.
|
||||
func seatCombatants(seats []CombatSeatSetup) []*Combatant {
|
||||
players := make([]*Combatant, len(seats))
|
||||
for i, s := range seats {
|
||||
players[i] = s.C
|
||||
}
|
||||
return players
|
||||
}
|
||||
|
||||
// seatZeroRefusal picks the copy for a blocked seat 0. The leader hears about
|
||||
// themselves in the second person; a member hears who the party is waiting on.
|
||||
func seatZeroRefusal(sender, leader id.UserID, own, aboutLeader string) string {
|
||||
if sender == leader {
|
||||
return own
|
||||
}
|
||||
return aboutLeader
|
||||
}
|
||||
|
||||
// announcePartyFightStart DMs every seated member the opening block: the shared
|
||||
// header, their own HP and curios, the roster's initiative order, whatever the
|
||||
// enemy did before anyone could stop it, and either "your move" or who the round
|
||||
// is waiting on.
|
||||
//
|
||||
// opening carries the events of a round-1 enemy turn — the monster can win
|
||||
// initiative, and a party that reads "your move" over an unnarrated 12-point hit
|
||||
// has been lied to. outcomes, when set, is the per-seat close-out of a fight that
|
||||
// ended before it started.
|
||||
//
|
||||
// Solo does not come through here — handleFightCmd answers the one player who
|
||||
// typed, with the bytes it always sent.
|
||||
func (p *AdventurePlugin) announcePartyFightStart(
|
||||
sess *CombatSession, players []*Combatant, enemy *Combatant, header string,
|
||||
opening []CombatEvent, outcomes []string,
|
||||
) {
|
||||
acting, waiting := actingSeat(sess, players, enemy)
|
||||
names := make([]string, len(players))
|
||||
for i, c := range players {
|
||||
names[i] = c.Name
|
||||
}
|
||||
for seat, uid := range sess.SeatUserIDs() {
|
||||
var b strings.Builder
|
||||
b.WriteString(header)
|
||||
b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", sess.seatHP(seat), sess.seatHPMax(seat)))
|
||||
if curios := activeMagicItemsLine(id.UserID(uid)); curios != "" {
|
||||
b.WriteString(curios + "\n")
|
||||
}
|
||||
b.WriteString("\n**Marching order:** ")
|
||||
b.WriteString(strings.Join(initiativeNames(players, sess, enemy), " → "))
|
||||
b.WriteString("\n\n")
|
||||
if len(opening) > 0 {
|
||||
b.WriteString(RenderPartyTurnRound(opening, names, enemy.Name, seat))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
switch {
|
||||
case seat < len(outcomes):
|
||||
b.WriteString(outcomes[seat])
|
||||
case !waiting:
|
||||
b.WriteString(fmt.Sprintf("**Round %d.** The round is resolving.", sess.Round))
|
||||
case seat == acting:
|
||||
b.WriteString(partyMovePrompt(sess.Round))
|
||||
default:
|
||||
b.WriteString(fmt.Sprintf("**Round %d.** Waiting on **%s**.", sess.Round, players[acting].Name))
|
||||
}
|
||||
if err := p.SendDM(id.UserID(uid), b.String()); err != nil {
|
||||
slog.Error("combat: party fight-start DM failed", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// partyMovePrompt is the line a seat sees when the round is its own. `!flee` is
|
||||
// missing on purpose: in a party it is the leader's call, and P5 refuses it to
|
||||
// every other seat.
|
||||
func partyMovePrompt(round int) string {
|
||||
return fmt.Sprintf("**Round %d.** Your move — `!attack`, `!cast <spell>`, `!consume <item>`.", round)
|
||||
}
|
||||
|
||||
// initiativeNames renders this round's turn order for the opening block. It is
|
||||
// the party's one look at the initiative P3 rolls for them — the number itself
|
||||
// stays hidden (accessibility over crunch); the order is the useful part.
|
||||
func initiativeNames(players []*Combatant, sess *CombatSession, enemy *Combatant) []string {
|
||||
order := turnOrder(sess, sess.Round, players, enemy)
|
||||
names := make([]string, 0, len(order))
|
||||
for _, seat := range order {
|
||||
if seat == enemySeat {
|
||||
names = append(names, enemy.Name)
|
||||
continue
|
||||
}
|
||||
names = append(names, players[seat].Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
308
internal/plugin/combat_party_start_test.go
Normal file
308
internal/plugin/combat_party_start_test.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// N3/P6c — seating the roster, and paying for it.
|
||||
//
|
||||
// The invariant every test here defends is the same one: a solo player must be
|
||||
// unable to tell that parties exist. The seat-0 owner, the burn rate, and the
|
||||
// opening block are all shapes a solo fight has already written thousands of
|
||||
// times, and the balance corpus is the receipt.
|
||||
|
||||
// ── who owns the fight ───────────────────────────────────────────────────────
|
||||
|
||||
// roster[0] is the fight's owner: its lock key and its session row.
|
||||
|
||||
// A bare zone run — no expedition row anywhere — must not fall over looking for
|
||||
// a leader. This is the `!zone enter` path, which predates expeditions entirely.
|
||||
func TestFightRoster_BareZoneRunOwnsItsOwnFight(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
wanderer := id.UserID("@wanderer:example.org")
|
||||
|
||||
if got := fightRoster(wanderer); len(got) != 1 || got[0] != wanderer {
|
||||
t.Errorf("fightRoster(wanderer) = %v, want just the player themselves", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The reason the lookup exists: a member's `!fight` opens the *leader's* fight,
|
||||
// under the leader's lock and on the leader's session row.
|
||||
func TestFightRoster_MembersFightBelongsToTheLeader(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@lead:example.org")
|
||||
member := id.UserID("@member:example.org")
|
||||
seedExpedition(t, "exp-1", leader, "active")
|
||||
if err := joinParty("exp-1", member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, who := range []id.UserID{member, leader} {
|
||||
if got := fightRoster(who)[0]; got != leader {
|
||||
t.Errorf("fightRoster(%s)[0] = %q, want the leader %q", who, got, leader)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── who sits down ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestFightRoster_SoloSeatsExactlyOne(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
solo := id.UserID("@solo:example.org")
|
||||
seedExpedition(t, "exp-solo", solo, "active")
|
||||
|
||||
roster := fightRoster(solo)
|
||||
if len(roster) != 1 || roster[0] != solo {
|
||||
t.Fatalf("solo roster = %v, want [%s]", roster, solo)
|
||||
}
|
||||
}
|
||||
|
||||
// Seat 0 is the leader whoever typed `!fight`. Every seat-0 invariant in the
|
||||
// combat layer — the lock key, the once-only close-out effects, the leader-only
|
||||
// `!flee` — reads the roster's head, so a member-first ordering would flee the
|
||||
// leader's run on a member's say-so.
|
||||
func TestFightRoster_LeaderIsAlwaysSeatZero(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@lead:example.org")
|
||||
member := id.UserID("@member:example.org")
|
||||
seedExpedition(t, "exp-1", leader, "active")
|
||||
if err := joinParty("exp-1", member); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, who := range []id.UserID{leader, member} {
|
||||
roster := fightRoster(who)
|
||||
if len(roster) != 2 {
|
||||
t.Fatalf("fightRoster(%s) = %v, want 2 seats", who, roster)
|
||||
}
|
||||
if roster[0] != leader {
|
||||
t.Errorf("fightRoster(%s) seats %q at 0, want the leader", who, roster[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── who actually gets a seat ─────────────────────────────────────────────────
|
||||
|
||||
// fightTestChar creates the full character stack buildZoneCombatants reads:
|
||||
// the adventure character, then the D&D sheet its HP snapshot comes from.
|
||||
func fightTestChar(t *testing.T, uid id.UserID, hp int) {
|
||||
t.Helper()
|
||||
if err := createAdvCharacter(uid, string(uid)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5,
|
||||
STR: 14, DEX: 12, CON: 14, INT: 10, WIS: 10, CHA: 10,
|
||||
HPMax: 30, HPCurrent: hp, ArmorClass: 14,
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFightSeats_SoloSeatsExactlyThePlayer(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
solo := id.UserID("@solo:example.org")
|
||||
fightTestChar(t, solo, 30)
|
||||
|
||||
seats, enemy, skip, refusal := (&AdventurePlugin{}).buildFightSeats(
|
||||
solo, []id.UserID{solo}, dndBestiary["goblin"], 1, 0)
|
||||
if refusal != "" || skip != "" {
|
||||
t.Fatalf("solo fight refused: %s / %s", refusal, skip)
|
||||
}
|
||||
if len(seats) != 1 || seats[0].UserID != solo {
|
||||
t.Fatalf("seats = %+v, want exactly the solo player", seats)
|
||||
}
|
||||
if seats[0].HP != 30 || seats[0].HPMax != 30 {
|
||||
t.Errorf("seat HP = %d/%d, want 30/30", seats[0].HP, seats[0].HPMax)
|
||||
}
|
||||
if seats[0].C == nil || seats[0].C.Name == "" {
|
||||
t.Errorf("seat carries no built combatant: %+v", seats[0])
|
||||
}
|
||||
if enemy == nil || enemy.Name == "" || enemy.Stats.MaxHP <= 0 {
|
||||
t.Errorf("enemy not built: %+v", enemy)
|
||||
}
|
||||
}
|
||||
|
||||
// A downed member is not a blocked party: they sit the fight out, and the seats
|
||||
// that remain still line up leader-first.
|
||||
func TestBuildFightSeats_DownedMemberSitsOut(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@lead:example.org")
|
||||
standing := id.UserID("@standing:example.org")
|
||||
downed := id.UserID("@downed:example.org")
|
||||
fightTestChar(t, leader, 30)
|
||||
fightTestChar(t, standing, 25)
|
||||
fightTestChar(t, downed, 0)
|
||||
|
||||
roster := []id.UserID{leader, downed, standing}
|
||||
seats, _, skip, refusal := (&AdventurePlugin{}).buildFightSeats(
|
||||
leader, roster, dndBestiary["goblin"], 1, 0)
|
||||
if refusal != "" {
|
||||
t.Fatalf("party refused over a downed member: %s", refusal)
|
||||
}
|
||||
if skip != "" {
|
||||
t.Errorf("the leader was seated, so nothing was skipped on their behalf: %q", skip)
|
||||
}
|
||||
if len(seats) != 2 {
|
||||
t.Fatalf("seated %d, want the leader and the standing member", len(seats))
|
||||
}
|
||||
if seats[0].UserID != leader || seats[1].UserID != standing {
|
||||
t.Errorf("seats = [%s %s], want [leader standing]", seats[0].UserID, seats[1].UserID)
|
||||
}
|
||||
|
||||
// The one who was left behind typed `!fight` too, and silence is not an answer.
|
||||
_, _, skip, refusal = (&AdventurePlugin{}).buildFightSeats(
|
||||
downed, roster, dndBestiary["goblin"], 1, 0)
|
||||
if refusal != "" {
|
||||
t.Fatalf("a downed member must not refuse the party's fight: %s", refusal)
|
||||
}
|
||||
if !strings.Contains(skip, "`!rest`") {
|
||||
t.Errorf("the downed member should be told to rest, got %q", skip)
|
||||
}
|
||||
}
|
||||
|
||||
// Seat 0 is not optional, and the copy depends on who is reading it.
|
||||
func TestBuildFightSeats_DownedLeaderRefusesTheFightForEveryone(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@lead:example.org")
|
||||
member := id.UserID("@member:example.org")
|
||||
fightTestChar(t, leader, 0)
|
||||
fightTestChar(t, member, 25)
|
||||
|
||||
roster := []id.UserID{leader, member}
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
seats, _, _, refusal := p.buildFightSeats(leader, roster, dndBestiary["goblin"], 1, 0)
|
||||
if len(seats) != 0 || refusal == "" {
|
||||
t.Fatalf("downed leader seated %d players, refusal %q", len(seats), refusal)
|
||||
}
|
||||
if !strings.Contains(refusal, "`!rest`") {
|
||||
t.Errorf("the leader should be told to rest, got %q", refusal)
|
||||
}
|
||||
|
||||
_, _, _, refusal = p.buildFightSeats(member, roster, dndBestiary["goblin"], 1, 0)
|
||||
if !strings.Contains(refusal, "leader") {
|
||||
t.Errorf("the member should be told it is the leader holding things up, got %q", refusal)
|
||||
}
|
||||
if strings.Contains(refusal, "`!rest`") {
|
||||
t.Errorf("the member cannot rest on the leader's behalf, got %q", refusal)
|
||||
}
|
||||
}
|
||||
|
||||
// ── the opening block ────────────────────────────────────────────────────────
|
||||
|
||||
// initiativeNames walks the turn order, which carries the enemy as sentinel seat
|
||||
// -1. Indexing players with it would panic on every party's first `!fight`.
|
||||
func TestInitiativeNames_RendersTheEnemySentinelNotAPanic(t *testing.T) {
|
||||
players, enemy := biasedParty()
|
||||
sess := partySession(CombatPhasePlayerTurn, 100, 100, 100)
|
||||
|
||||
names := initiativeNames(players, sess, enemy)
|
||||
if len(names) != 4 {
|
||||
t.Fatalf("order = %v, want 3 players + the enemy", names)
|
||||
}
|
||||
// biasedParty stacks initiative 300/200/100 against a speed-1 enemy.
|
||||
if want := []string{"Ada", "Bram", "Cass", enemy.Name}; strings.Join(names, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("order = %v, want %v", names, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiativeNames_SoloIsPlayerThenEnemy(t *testing.T) {
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
|
||||
|
||||
if got := initiativeNames([]*Combatant{&p}, sess, &e); len(got) != 2 || got[1] != e.Name {
|
||||
t.Errorf("solo order = %v, want [player, enemy]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── what the party eats ──────────────────────────────────────────────────────
|
||||
|
||||
// The rate a solo expedition burns at is a tuned constant that the whole
|
||||
// difficulty corpus (Phase 3-B / 5-B) was measured against. It must come out of
|
||||
// the party-aware path untouched.
|
||||
func TestExpeditionBurnRatePct_SoloIsTheShippedRate(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
solo := id.UserID("@solo:example.org")
|
||||
seedExpedition(t, "exp-solo", solo, "active")
|
||||
|
||||
if got := expeditionBurnRatePct("exp-solo"); got != phase5BDailyBurnRatePct {
|
||||
t.Errorf("solo burn rate = %d, want the shipped %d", got, phase5BDailyBurnRatePct)
|
||||
}
|
||||
// An expedition with no roster row at all is the pre-N3 world: same answer.
|
||||
if got := expeditionBurnRatePct("exp-never-seen"); got != phase5BDailyBurnRatePct {
|
||||
t.Errorf("rosterless burn rate = %d, want the shipped %d", got, phase5BDailyBurnRatePct)
|
||||
}
|
||||
}
|
||||
|
||||
// N × 0.8: a party eats more than one player and less than N of them.
|
||||
func TestExpeditionBurnRatePct_PartyEatsMoreButNotProRata(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@lead:example.org")
|
||||
seedExpedition(t, "exp-1", leader, "active")
|
||||
|
||||
want := map[int]int{1: 50, 2: 80, 3: 120}
|
||||
for seat := 1; seat <= 2; seat++ {
|
||||
if err := joinParty("exp-1", id.UserID(memberID(seat))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
size := seat + 1
|
||||
if got := expeditionBurnRatePct("exp-1"); got != want[size] {
|
||||
t.Errorf("party of %d burns at %d%%, want %d%%", size, got, want[size])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bit-identity, not approximate agreement: a solo expedition's supplies snapshot
|
||||
// after the party-aware burn must equal the one applyDailyBurn produced.
|
||||
func TestApplyExpeditionDailyBurn_SoloMatchesApplyDailyBurnExactly(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
solo := id.UserID("@solo:example.org")
|
||||
seedExpedition(t, "exp-solo", solo, "active")
|
||||
|
||||
supplies := ExpeditionSupplies{Current: 40, Max: 40, DailyBurn: 3, HarshMod: 1.5}
|
||||
e := &Expedition{ID: "exp-solo", UserID: string(solo), Supplies: supplies}
|
||||
|
||||
for _, tc := range []struct{ harsh, siege bool }{{false, false}, {true, false}, {false, true}} {
|
||||
wantS, wantBurn := applyDailyBurn(supplies, tc.harsh, tc.siege)
|
||||
gotS, gotBurn := applyExpeditionDailyBurn(e, tc.harsh, tc.siege)
|
||||
if gotBurn != wantBurn || gotS != wantS {
|
||||
t.Errorf("harsh=%v siege=%v: got (%v, %g), want (%v, %g)",
|
||||
tc.harsh, tc.siege, gotS, gotBurn, wantS, wantBurn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExpeditionDailyBurn_PartyOfThreeBurnsTwoPointFourShares(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
leader := id.UserID("@lead:example.org")
|
||||
seedExpedition(t, "exp-1", leader, "active")
|
||||
for seat := 1; seat <= 2; seat++ {
|
||||
if err := joinParty("exp-1", id.UserID(memberID(seat))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
supplies := ExpeditionSupplies{Current: 40, Max: 40, DailyBurn: 3, HarshMod: 1}
|
||||
e := &Expedition{ID: "exp-1", UserID: string(leader), Supplies: supplies}
|
||||
|
||||
_, solo := applyDailyBurn(supplies, false, false)
|
||||
_, party := applyExpeditionDailyBurn(e, false, false)
|
||||
|
||||
// DailyBurn 3 at the party-of-3 rate of 120% — exactly 2.4 solo shares, and
|
||||
// exactly the value the int rate yields. A float32 0.8 would land at 3.6000001.
|
||||
if want := float32(3.6); party != want {
|
||||
t.Errorf("party of 3 burned %v SU, want %v", party, want)
|
||||
}
|
||||
if party >= solo*3 {
|
||||
t.Errorf("party of 3 burned %g, which is no better than three solo runs (%g)", party, solo*3)
|
||||
}
|
||||
if party <= solo {
|
||||
t.Errorf("party of 3 burned %g, no more than one player alone (%g)", party, solo)
|
||||
}
|
||||
}
|
||||
424
internal/plugin/combat_party_turn.go
Normal file
424
internal/plugin/combat_party_turn.go
Normal file
@@ -0,0 +1,424 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// N3/P5 — the party turn layer.
|
||||
//
|
||||
// A solo fight is a conversation between one player and the engine: they type,
|
||||
// it answers, and nothing happens in between. A party fight is a queue. Three
|
||||
// things follow from that, and this file is all three:
|
||||
//
|
||||
// 1. Turn ownership. Only the seat on the clock may act, so every combat
|
||||
// command has to ask "is it mine?" before it spends a slot or burns an item.
|
||||
// 2. A fight-scoped lock. Three members typing `!attack` at once take three
|
||||
// *different* user locks, and the check above would pass for all three.
|
||||
// 3. A turn deadline. One member who wanders off must not freeze the other two
|
||||
// until the 1h session reaper wakes up.
|
||||
//
|
||||
// Solo pays for none of it: the fight lock collapses to the user lock it always
|
||||
// took, the turn check is trivially true, and nothing latches a solo seat onto
|
||||
// autopilot — a lone player who walks away is the session reaper's problem, as
|
||||
// they have always been.
|
||||
|
||||
// partyTurnDeadline is how long a party fight waits on one member before
|
||||
// resolving their turn for them. Swept by the existing one-minute eventTicker
|
||||
// (D4: no net-new tickers), so a lapse actually fires somewhere in
|
||||
// [deadline, deadline+1m) — 3–4 minutes here.
|
||||
//
|
||||
// The number is a compromise between two failure modes. Too short and a player
|
||||
// reading the room on their phone loses their boss turn; too long and two people
|
||||
// sit staring at a prompt. Expeditions run for days, so the asymmetry favours
|
||||
// patience.
|
||||
const partyTurnDeadline = 3 * time.Minute
|
||||
|
||||
// combatTurn is a fight, opened for one member, with that member verified to be
|
||||
// the seat on the clock. Held only for the duration of one command, under the
|
||||
// locks release() drops.
|
||||
type combatTurn struct {
|
||||
sess *CombatSession
|
||||
players []*Combatant
|
||||
enemy *Combatant
|
||||
seat int
|
||||
// uid is the member acting, i.e. the player at seat.
|
||||
uid id.UserID
|
||||
}
|
||||
|
||||
// isParty reports whether more than one character is seated.
|
||||
func (ct *combatTurn) isParty() bool { return ct.sess.IsParty() }
|
||||
|
||||
// seatNames is the roster's display names in seating order, for narration.
|
||||
func (ct *combatTurn) seatNames() []string {
|
||||
names := make([]string, len(ct.players))
|
||||
for i, c := range ct.players {
|
||||
names[i] = c.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// activeCombatSessionFor finds the in-flight fight a player is in, whether they
|
||||
// are its owner (seat 0) or a seated member. getActiveCombatSession alone keys
|
||||
// on combat_session.user_id and so tells a party member they are not fighting.
|
||||
func activeCombatSessionFor(userID id.UserID) (*CombatSession, error) {
|
||||
if s, err := getActiveCombatSession(userID); err != nil || s != nil {
|
||||
return s, err
|
||||
}
|
||||
return getActiveCombatSessionForMember(userID)
|
||||
}
|
||||
|
||||
// lockCombatFight takes the locks a combat command needs, in an order that
|
||||
// cannot deadlock: the fight's lock first — keyed on seat 0, the session's owner
|
||||
// — and then the acting member's own lock. Every other command in the codebase
|
||||
// takes at most one user lock, so there is no cycle to close.
|
||||
//
|
||||
// A solo fight's owner *is* the sender, and sync.Mutex is not reentrant, so that
|
||||
// case takes exactly one lock: the same one handleAttackCmd has always taken.
|
||||
func (p *AdventurePlugin) lockCombatFight(owner, sender id.UserID) func() {
|
||||
fight := p.advUserLock(owner)
|
||||
fight.Lock()
|
||||
if owner == sender {
|
||||
return fight.Unlock
|
||||
}
|
||||
self := p.advUserLock(sender)
|
||||
self.Lock()
|
||||
return func() {
|
||||
self.Unlock()
|
||||
fight.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// beginCombatTurn opens the sender's fight for an action: it locates the
|
||||
// session, takes its locks, settles any phase the engine still owes (an enemy
|
||||
// turn left half-resolved by a crash), rebuilds the roster, and verifies the
|
||||
// sender is the seat on the clock.
|
||||
//
|
||||
// On any refusal it releases what it took and returns a player-facing message
|
||||
// with a nil turn; noFightMsg is the caller's own copy for "you are not in a
|
||||
// fight", which differs per command. On success the caller must call release().
|
||||
//
|
||||
// Acting also unlatches the member from autopilot: typing anything is proof they
|
||||
// are back at the keyboard.
|
||||
func (p *AdventurePlugin) beginCombatTurn(sender id.UserID, noFightMsg string) (*combatTurn, func(), string) {
|
||||
probe, err := activeCombatSessionFor(sender)
|
||||
if err != nil {
|
||||
return nil, nil, "Couldn't read combat state: " + err.Error()
|
||||
}
|
||||
if probe == nil {
|
||||
return nil, nil, noFightMsg
|
||||
}
|
||||
|
||||
release := p.lockCombatFight(id.UserID(probe.UserID), sender)
|
||||
fail := func(msg string) (*combatTurn, func(), string) {
|
||||
release()
|
||||
return nil, nil, msg
|
||||
}
|
||||
|
||||
// Re-read under the lock. The probe was unlocked, so the fight may have
|
||||
// ended, or been replaced by a fresh one, in the window since.
|
||||
sess, err := getCombatSession(probe.SessionID)
|
||||
if err != nil {
|
||||
return fail("Couldn't read combat state: " + err.Error())
|
||||
}
|
||||
if sess == nil || !sess.IsActive() {
|
||||
return fail(noFightMsg)
|
||||
}
|
||||
seat, seated := sess.seatOf(sender)
|
||||
if !seated {
|
||||
return fail(noFightMsg)
|
||||
}
|
||||
|
||||
players, enemy, err := p.partyCombatantsForSession(sess)
|
||||
if err != nil {
|
||||
return fail("Couldn't rebuild the fight: " + err.Error())
|
||||
}
|
||||
|
||||
// Settle any phase the engine still owes before reading whose turn it is. A
|
||||
// fight interrupted mid enemy-turn resumes parked there; without this the
|
||||
// player's !attack would be refused as "not your turn" and the fight would
|
||||
// never advance past it again.
|
||||
if _, serr := settleCombatSession(sess, players, enemy); serr != nil {
|
||||
return fail("Couldn't resolve the round: " + serr.Error())
|
||||
}
|
||||
if !sess.IsActive() {
|
||||
// The owed phase was lethal: the settle above ended the fight. The
|
||||
// terminal status is already persisted, and the reaper only scans for
|
||||
// active sessions, so this is the last chance anyone has to pay the
|
||||
// party out. Close it here rather than answering "you're not in a
|
||||
// fight" over a win nobody was credited for.
|
||||
ct := &combatTurn{sess: sess, players: players, enemy: enemy, seat: seat, uid: sender}
|
||||
outcomes := p.closePartyRound(ct)
|
||||
if !ct.isParty() {
|
||||
return fail(outcomes[0])
|
||||
}
|
||||
p.announcePartyRound(ct, nil, "", outcomes)
|
||||
return fail("")
|
||||
}
|
||||
|
||||
acting, waiting := actingSeat(sess, players, enemy)
|
||||
if !waiting || acting != seat {
|
||||
return fail(notYourTurnMsg(players, acting, waiting))
|
||||
}
|
||||
|
||||
// They typed, so they are here. Hand back the wheel.
|
||||
sess.actorStatusesPtr(seat).Autopilot = false
|
||||
|
||||
return &combatTurn{sess: sess, players: players, enemy: enemy, seat: seat, uid: sender}, release, ""
|
||||
}
|
||||
|
||||
// notYourTurnMsg tells a member who is holding the round up.
|
||||
func notYourTurnMsg(players []*Combatant, acting int, waiting bool) string {
|
||||
if !waiting || acting < 0 || acting >= len(players) {
|
||||
return "The round is still resolving — try again in a moment."
|
||||
}
|
||||
return fmt.Sprintf("It's **%s**'s turn. Hang tight — I'll act for them if they're away.", players[acting].Name)
|
||||
}
|
||||
|
||||
// ── driving a round ──────────────────────────────────────────────────────────
|
||||
|
||||
// driveCombatRound resolves the acting member's action, then keeps the fight
|
||||
// moving until it comes to rest on a player who can actually answer: the enemy
|
||||
// turn and round-end tick resolve, downed seats forfeit, and seats latched onto
|
||||
// autopilot are played by the picker.
|
||||
//
|
||||
// Solo never enters the autopilot loop — nothing latches a solo seat.
|
||||
func (p *AdventurePlugin) driveCombatRound(ct *combatTurn, action PlayerAction) ([]CombatEvent, error) {
|
||||
events, err := runPartyCombatRound(ct.sess, ct.players, ct.enemy, action)
|
||||
if err != nil {
|
||||
return events, err
|
||||
}
|
||||
more, err := p.driveAutopilotedSeats(ct)
|
||||
return append(events, more...), err
|
||||
}
|
||||
|
||||
// driveAutopilotedSeats plays out every latched seat standing between the fight
|
||||
// and its next live human turn.
|
||||
func (p *AdventurePlugin) driveAutopilotedSeats(ct *combatTurn) ([]CombatEvent, error) {
|
||||
var events []CombatEvent
|
||||
for i := 0; i < partyRoundStepCap; i++ {
|
||||
seat, waiting := actingSeat(ct.sess, ct.players, ct.enemy)
|
||||
if !waiting || !ct.sess.seatIsAutopiloted(seat) {
|
||||
return events, nil
|
||||
}
|
||||
more, err := p.runAutoSeatTurn(ct, seat)
|
||||
if err != nil {
|
||||
return events, err
|
||||
}
|
||||
events = append(events, more...)
|
||||
}
|
||||
return events, fmt.Errorf("combat session %s: autopilot did not settle within %d turns",
|
||||
ct.sess.SessionID, partyRoundStepCap)
|
||||
}
|
||||
|
||||
// runAutoSeatTurn resolves one seat's turn with nobody at the keyboard, through
|
||||
// the same decision tree the headless sim and the expedition autopilot use. A
|
||||
// cast or consume the picker chose but the resolver then refuses (the slot went
|
||||
// missing, the item was sold from another room) degrades to a weapon attack
|
||||
// rather than stalling the round.
|
||||
func (p *AdventurePlugin) runAutoSeatTurn(ct *combatTurn, seat int) ([]CombatEvent, error) {
|
||||
uid := id.UserID(ct.sess.seatUserID(seat))
|
||||
kind, arg := p.pickAutoCombatActionForSeat(uid, ct.sess, seat)
|
||||
|
||||
action := PlayerAction{Kind: ActionAttack}
|
||||
settle := func(bool) {}
|
||||
switch kind {
|
||||
case "cast":
|
||||
if a, s, msg := p.castActionForSeat(ct, seat, arg); msg == "" {
|
||||
action, settle = a, s
|
||||
} else {
|
||||
slog.Debug("combat: autopilot cast refused, swinging instead",
|
||||
"session", ct.sess.SessionID, "seat", seat, "spell", arg, "why", msg)
|
||||
}
|
||||
case "consume":
|
||||
if a, s, msg := p.consumeActionForSeat(ct, seat, arg); msg == "" {
|
||||
action, settle = a, s
|
||||
} else {
|
||||
slog.Debug("combat: autopilot consume refused, swinging instead",
|
||||
"session", ct.sess.SessionID, "seat", seat, "item", arg, "why", msg)
|
||||
}
|
||||
}
|
||||
|
||||
events, err := runPartyCombatRound(ct.sess, ct.players, ct.enemy, action)
|
||||
settle(err == nil)
|
||||
return events, err
|
||||
}
|
||||
|
||||
// ── the turn deadline ────────────────────────────────────────────────────────
|
||||
|
||||
// nudgeStalledPartyTurns latches every party seat whose turn deadline has lapsed
|
||||
// onto autopilot and plays the fight forward. Driven off eventTicker, beside the
|
||||
// session reaper, so it costs no new ticker.
|
||||
//
|
||||
// Solo sessions are never listed: a lone player who walks away owns their own
|
||||
// fight, and the 1h reaper already finishes it for them.
|
||||
func (p *AdventurePlugin) nudgeStalledPartyTurns() {
|
||||
stalled, err := listStalledPartyCombatSessions()
|
||||
if err != nil {
|
||||
slog.Error("combat: failed to list stalled party turns", "err", err)
|
||||
return
|
||||
}
|
||||
for _, sess := range stalled {
|
||||
p.nudgeStalledPartyTurn(sess.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// nudgeStalledPartyTurn resolves one stalled fight under its lock. It re-reads
|
||||
// the session after locking: the member may have acted in the window between the
|
||||
// sweep's query and the lock, in which case there is nothing to do.
|
||||
func (p *AdventurePlugin) nudgeStalledPartyTurn(sessionID string) {
|
||||
probe, err := getCombatSession(sessionID)
|
||||
if err != nil || probe == nil {
|
||||
return
|
||||
}
|
||||
owner := id.UserID(probe.UserID)
|
||||
release := p.lockCombatFight(owner, owner)
|
||||
defer release()
|
||||
|
||||
sess, err := getCombatSession(sessionID)
|
||||
if err != nil {
|
||||
slog.Error("combat: stalled-turn reload failed", "session", sessionID, "err", err)
|
||||
return
|
||||
}
|
||||
if sess == nil || !sess.IsActive() || !sess.IsParty() || !turnDeadlineLapsed(sess) {
|
||||
return
|
||||
}
|
||||
|
||||
players, enemy, err := p.partyCombatantsForSession(sess)
|
||||
if err != nil {
|
||||
slog.Warn("combat: cannot rebuild stalled party fight", "session", sessionID, "err", err)
|
||||
return
|
||||
}
|
||||
seat, waiting := actingSeat(sess, players, enemy)
|
||||
if !waiting {
|
||||
return
|
||||
}
|
||||
|
||||
// Latch the absent member. From here their turns resolve the moment the
|
||||
// round reaches them, until they type something.
|
||||
sess.actorStatusesPtr(seat).Autopilot = true
|
||||
away := id.UserID(sess.seatUserID(seat))
|
||||
|
||||
ct := &combatTurn{sess: sess, players: players, enemy: enemy, seat: seat, uid: away}
|
||||
events, err := p.driveAutopilotedSeats(ct)
|
||||
if err != nil {
|
||||
slog.Error("combat: stalled-turn autopilot failed", "session", sessionID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
preamble := fmt.Sprintf("⏳ **%s** was away — I'm taking their turns.\n\n", players[seat].Name)
|
||||
p.announcePartyRound(ct, events, preamble, p.closePartyRound(ct))
|
||||
}
|
||||
|
||||
// turnDeadlineLapsed reports whether the fight has sat on one member's turn past
|
||||
// partyTurnDeadline. LastActionAt is stamped by every saveCombatSession, and the
|
||||
// save that parked the fight on this seat's player_turn was the last one — so it
|
||||
// is exactly when the member's clock started.
|
||||
func turnDeadlineLapsed(sess *CombatSession) bool {
|
||||
return sess.Phase == CombatPhasePlayerTurn &&
|
||||
time.Since(sess.LastActionAt) >= partyTurnDeadline
|
||||
}
|
||||
|
||||
// listStalledPartyCombatSessions returns every active *party* fight parked on a
|
||||
// player's turn past the deadline. The roster_size filter keeps solo fights —
|
||||
// which is every fight that has ever run — out of the sweep entirely.
|
||||
func listStalledPartyCombatSessions() ([]*CombatSession, error) {
|
||||
cutoff := time.Now().UTC().Add(-partyTurnDeadline)
|
||||
rows, err := db.Get().Query(`SELECT `+combatSessionCols+`
|
||||
FROM combat_session
|
||||
WHERE status = 'active'
|
||||
AND roster_size > 1
|
||||
AND phase = 'player_turn'
|
||||
AND last_action_at <= ?
|
||||
ORDER BY last_action_at ASC`, cutoff)
|
||||
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)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Hydrate only after the cursor is closed: a nested query while iterating
|
||||
// can stall on a single-connection pool.
|
||||
rows.Close()
|
||||
for _, s := range out {
|
||||
if err := hydrateCombatParticipants(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── telling the party what happened ──────────────────────────────────────────
|
||||
|
||||
// closePartyRound runs the terminal side effects exactly once and returns each
|
||||
// seat's own close-out block. It returns nil while the fight is still in flight.
|
||||
//
|
||||
// Callers must invoke it whether or not they intend to narrate: a silent
|
||||
// autopilot round still owes the party its XP, loot, and death bookkeeping.
|
||||
func (p *AdventurePlugin) closePartyRound(ct *combatTurn) []string {
|
||||
if ct.sess.IsActive() {
|
||||
return nil
|
||||
}
|
||||
return p.finishPartyCombatSession(ct)
|
||||
}
|
||||
|
||||
// announcePartyRound DMs every seated member the round that just resolved. It is
|
||||
// the fan-out the single-recipient SendDM seam does not have: one call per seat,
|
||||
// with each member's own footer — or their own close-out, if outcomes is set.
|
||||
//
|
||||
// Solo callers do not use it — handleCombatActionCmd replies to the one player
|
||||
// directly, as it always has.
|
||||
func (p *AdventurePlugin) announcePartyRound(ct *combatTurn, events []CombatEvent, preamble string, outcomes []string) {
|
||||
names := ct.seatNames()
|
||||
acting, waiting := actingSeat(ct.sess, ct.players, ct.enemy)
|
||||
for seat, uid := range ct.sess.SeatUserIDs() {
|
||||
// Rendered once per reader: the flavor pool speaks in the second person,
|
||||
// so each member's own events must be theirs and nobody else's.
|
||||
body := RenderPartyTurnRound(events, names, ct.enemy.Name, seat)
|
||||
tail := ""
|
||||
switch {
|
||||
case seat < len(outcomes):
|
||||
tail = outcomes[seat]
|
||||
case waiting:
|
||||
tail = partyRoundFooter(ct, seat, acting)
|
||||
}
|
||||
if err := p.SendDM(id.UserID(uid), preamble+body+"\n\n"+tail); err != nil {
|
||||
slog.Error("combat: party round DM failed", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// partyRoundFooter is the per-member close of a round: the roster's HP, then
|
||||
// either "your move" or who everyone is waiting on.
|
||||
func partyRoundFooter(ct *combatTurn, seat, acting int) string {
|
||||
var b strings.Builder
|
||||
for i, c := range ct.players {
|
||||
down := ""
|
||||
if !ct.sess.seatAlive(i) {
|
||||
down = " _(down)_"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s: **%d/%d**%s\n", c.Name, ct.sess.seatHP(i), ct.sess.seatHPMax(i), down))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s: **%d/%d**\n\n", ct.enemy.Name, ct.sess.EnemyHP, ct.sess.EnemyHPMax))
|
||||
if seat == acting {
|
||||
b.WriteString(partyMovePrompt(ct.sess.Round))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("**Round %d.** Waiting on **%s**.", ct.sess.Round, ct.players[acting].Name))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
537
internal/plugin/combat_party_turn_test.go
Normal file
537
internal/plugin/combat_party_turn_test.go
Normal file
@@ -0,0 +1,537 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// N3/P5 — the session layer.
|
||||
//
|
||||
// Everything here exercises a seated party, which production cannot yet build:
|
||||
// P6 supplies the invite. The point of testing it now is that P5's mistakes are
|
||||
// silent ones — a buff landing on the wrong sheet, a latch that resets on every
|
||||
// step, a solo row that grew a key — and none of them announce themselves.
|
||||
|
||||
// ── Seat addressing ──────────────────────────────────────────────────────────
|
||||
|
||||
// partySession builds a 3-seat roster in memory, with each seat's HP and
|
||||
// statuses distinguishable so a mix-up cannot pass.
|
||||
func partySession(phase string, seatHP ...int) *CombatSession {
|
||||
s := turnSession(phase, seatHP[0], 100)
|
||||
s.UserID = "@leader:x"
|
||||
for i, hp := range seatHP[1:] {
|
||||
s.Participants = append(s.Participants, CombatParticipant{
|
||||
Seat: i + 1, UserID: memberID(i + 1), HP: hp, HPMax: hp + 10,
|
||||
})
|
||||
}
|
||||
s.rosterSize = len(seatHP)
|
||||
return s
|
||||
}
|
||||
|
||||
func memberID(seat int) string {
|
||||
return string(rune('a'+seat-1)) + "member:x"
|
||||
}
|
||||
|
||||
func TestSeatAccessors_AddressTheRightRow(t *testing.T) {
|
||||
s := partySession(CombatPhasePlayerTurn, 40, 55, 70)
|
||||
s.PlayerHPMax = 90
|
||||
|
||||
for seat, want := range []int{40, 55, 70} {
|
||||
if got := s.seatHP(seat); got != want {
|
||||
t.Errorf("seatHP(%d) = %d, want %d", seat, got, want)
|
||||
}
|
||||
}
|
||||
if got := s.seatHPMax(0); got != 90 {
|
||||
t.Errorf("seatHPMax(0) = %d, want the session row's 90", got)
|
||||
}
|
||||
if got := s.seatHPMax(2); got != 80 {
|
||||
t.Errorf("seatHPMax(2) = %d, want the participant row's 80", got)
|
||||
}
|
||||
if got := s.seatUserID(0); got != "@leader:x" {
|
||||
t.Errorf("seatUserID(0) = %q, want the session owner", got)
|
||||
}
|
||||
if got := s.seatUserID(1); got != memberID(1) {
|
||||
t.Errorf("seatUserID(1) = %q, want %q", got, memberID(1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeatOf_FindsMembersAndRejectsStrangers(t *testing.T) {
|
||||
s := partySession(CombatPhasePlayerTurn, 40, 55, 70)
|
||||
|
||||
if seat, ok := s.seatOf("@leader:x"); !ok || seat != 0 {
|
||||
t.Errorf("seatOf(leader) = (%d,%v), want (0,true)", seat, ok)
|
||||
}
|
||||
if seat, ok := s.seatOf(id.UserID(memberID(2))); !ok || seat != 2 {
|
||||
t.Errorf("seatOf(member 2) = (%d,%v), want (2,true)", seat, ok)
|
||||
}
|
||||
if _, ok := s.seatOf("@nobody:x"); ok {
|
||||
t.Error("seatOf(stranger) reported a seat — an outsider could act in the fight")
|
||||
}
|
||||
}
|
||||
|
||||
// A member's mid-fight buff must land on their own sheet. Before P5 the cast
|
||||
// path folded every delta into the session's embedded ActorStatuses — seat 0 —
|
||||
// so a party member casting Shield on themselves armoured the leader instead.
|
||||
func TestActorStatusesPtr_WritesToTheCastingSeat(t *testing.T) {
|
||||
s := partySession(CombatPhasePlayerTurn, 40, 55, 70)
|
||||
|
||||
s.actorStatusesPtr(2).applyBuffDelta(turnBuffDelta{dAC: 5})
|
||||
|
||||
if got := s.Statuses.BuffACBonus; got != 0 {
|
||||
t.Errorf("seat 0 picked up seat 2's buff: BuffACBonus = %d, want 0", got)
|
||||
}
|
||||
if got := s.Participants[1].Statuses.BuffACBonus; got != 5 {
|
||||
t.Errorf("seat 2's BuffACBonus = %d, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── The autopilot latch ──────────────────────────────────────────────────────
|
||||
|
||||
// The latch is per-seat session state with no combatState counterpart, so it
|
||||
// rides through the engine on snapshotActor's carry-over of the prior snapshot —
|
||||
// the same road the Buff* deltas take. If it did not, an away member would be
|
||||
// handed the wheel back on every single step and the party would stall anew each
|
||||
// round.
|
||||
func TestSnapshotActor_CarriesTheAutopilotLatchAcrossAStep(t *testing.T) {
|
||||
c := basePlayer()
|
||||
prior := ActorStatuses{Autopilot: true, BuffACBonus: 3}
|
||||
|
||||
got := snapshotActor(newActor(&c), prior)
|
||||
|
||||
if !got.Autopilot {
|
||||
t.Error("snapshotActor dropped the autopilot latch — an away member unlatches every step")
|
||||
}
|
||||
if got.BuffACBonus != 3 {
|
||||
t.Errorf("snapshotActor dropped a carried-over buff: BuffACBonus = %d, want 3", got.BuffACBonus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeatNeedsNoHuman(t *testing.T) {
|
||||
s := partySession(CombatPhasePlayerTurn, 40, 0, 70)
|
||||
s.Participants[1].Statuses.Autopilot = true
|
||||
|
||||
if s.seatNeedsNoHuman(0) {
|
||||
t.Error("a standing, unlatched seat needs its human")
|
||||
}
|
||||
if !s.seatNeedsNoHuman(1) {
|
||||
t.Error("a downed seat must not block the round")
|
||||
}
|
||||
if !s.seatNeedsNoHuman(2) {
|
||||
t.Error("a latched seat must resolve without waiting")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wire compatibility ───────────────────────────────────────────────────────
|
||||
|
||||
// Two fields landed on persisted structs in P5. Both must vanish from a solo
|
||||
// row, which is every row prod holds today: statuses_json and turn_log_json are
|
||||
// decoded by readers that predate them, and a fight in flight across the deploy
|
||||
// must resume byte-identically.
|
||||
func TestP5Fields_StayOffSoloRows(t *testing.T) {
|
||||
raw, err := json.Marshal(CombatStatuses{ActorStatuses: ActorStatuses{PoisonTicks: 1}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "autopilot") {
|
||||
t.Errorf("a solo statuses_json carries the autopilot key: %s", raw)
|
||||
}
|
||||
|
||||
raw, err = json.Marshal(CombatEvent{Actor: "player", Action: "hit", Damage: 4})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "Seat") {
|
||||
t.Errorf("a solo turn_log event carries the Seat key: %s", raw)
|
||||
}
|
||||
|
||||
// …and both must survive the round trip when they are set.
|
||||
raw, _ = json.Marshal(ActorStatuses{Autopilot: true})
|
||||
var back ActorStatuses
|
||||
if err := json.Unmarshal(raw, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !back.Autopilot {
|
||||
t.Errorf("autopilot did not round-trip: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event attribution ────────────────────────────────────────────────────────
|
||||
|
||||
// biasedParty builds a roster whose initiative order is fixed at [0,1,2,enemy],
|
||||
// so a test can park the cursor on a known seat.
|
||||
func biasedParty() ([]*Combatant, *Combatant) {
|
||||
a, b, c := basePlayer(), basePlayer(), basePlayer()
|
||||
a.Name, b.Name, c.Name = "Ada", "Bram", "Cass"
|
||||
a.Mods.InitiativeBias, b.Mods.InitiativeBias, c.Mods.InitiativeBias = 300, 200, 100
|
||||
e := baseEnemy()
|
||||
e.Stats.Speed = 1
|
||||
return []*Combatant{&a, &b, &c}, &e
|
||||
}
|
||||
|
||||
func TestTurnEngine_StampsEventsWithTheSeatThatActed(t *testing.T) {
|
||||
players, enemy := biasedParty()
|
||||
sess := partySession(CombatPhasePlayerTurn, 100, 100, 100)
|
||||
sess.Statuses.TurnIdx = 1 // Bram's turn
|
||||
|
||||
events, err := partyStep(sess, players, enemy, PlayerAction{Kind: ActionAttack})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) == 0 {
|
||||
t.Fatal("Bram's turn produced no events")
|
||||
}
|
||||
for _, e := range events {
|
||||
if e.Seat != 1 {
|
||||
t.Errorf("event %+v stamped seat %d, want 1 (Bram swung)", e, e.Seat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Solo events must all be seat 0 — that is what keeps the field omitted from
|
||||
// every solo turn_log_json.
|
||||
func TestTurnEngine_SoloEventsAreAllSeatZero(t *testing.T) {
|
||||
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
|
||||
p, e := basePlayer(), baseEnemy()
|
||||
|
||||
events, err := partyStep(sess, []*Combatant{&p}, &e, PlayerAction{Kind: ActionAttack})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, ev := range events {
|
||||
if ev.Seat != 0 {
|
||||
t.Errorf("solo event %+v stamped seat %d, want 0", ev, ev.Seat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The flavor pool speaks in the second person. A round must therefore read
|
||||
// differently to each member: your own swing is "You score 9 damage", your
|
||||
// ally's is "**Cass** hits Rat for 9". Getting this backwards tells three people
|
||||
// they each personally landed the same blow.
|
||||
func TestRenderPartyTurnRound_SecondPersonForTheReaderThirdForAllies(t *testing.T) {
|
||||
events := []CombatEvent{
|
||||
{Actor: "player", Action: "hit", Damage: 7, Seat: 0},
|
||||
{Actor: "player", Action: "hit", Damage: 9, Seat: 2},
|
||||
}
|
||||
names := []string{"Ada", "Bram", "Cass"}
|
||||
|
||||
// The reader's own line comes from the flavor pool, whose phrasings vary
|
||||
// ("You score 9 damage", "A measured strike. 9 damage."). What is invariant
|
||||
// is that it never names them in the third person, and that the ally's line
|
||||
// always does.
|
||||
ada := RenderPartyTurnRound(events, names, "Rat", 0)
|
||||
if strings.Contains(ada, "**Ada**") {
|
||||
t.Errorf("Ada's own swing was rendered in the third person:\n%s", ada)
|
||||
}
|
||||
if !strings.Contains(ada, "**Cass** hits Rat for 9.") {
|
||||
t.Errorf("Ada should read Cass's swing in the third person:\n%s", ada)
|
||||
}
|
||||
if strings.Contains(ada, "Bram") {
|
||||
t.Errorf("Bram did nothing this round but was named:\n%s", ada)
|
||||
}
|
||||
|
||||
cass := RenderPartyTurnRound(events, names, "Rat", 2)
|
||||
if strings.Contains(cass, "**Cass**") {
|
||||
t.Errorf("Cass's own swing was rendered in the third person:\n%s", cass)
|
||||
}
|
||||
if !strings.Contains(cass, "**Ada** hits Rat for 7.") {
|
||||
t.Errorf("Cass should read Ada's swing in the third person:\n%s", cass)
|
||||
}
|
||||
}
|
||||
|
||||
// A downed ally is the one thing a member must not miss in their own DM.
|
||||
func TestRenderAllySeatEvent_FlagsAnAllyGoingDown(t *testing.T) {
|
||||
got := renderAllySeatEvent(
|
||||
CombatEvent{Actor: "enemy", Action: "hit", Damage: 12, PlayerHP: 0, Seat: 1}, "Bram", "Rat")
|
||||
if !strings.Contains(got, "is down") {
|
||||
t.Errorf("an ally dropping to 0 HP should be called out, got %q", got)
|
||||
}
|
||||
|
||||
standing := renderAllySeatEvent(
|
||||
CombatEvent{Actor: "enemy", Action: "hit", Damage: 3, PlayerHP: 20, Seat: 1}, "Bram", "Rat")
|
||||
if strings.Contains(standing, "is down") {
|
||||
t.Errorf("a standing ally was reported down: %q", standing)
|
||||
}
|
||||
}
|
||||
|
||||
// An event whose seat has fallen off the roster falls back to seat 0 rather
|
||||
// than panicking on a replayed turn_log.
|
||||
func TestRenderPartyTurnRound_OutOfRangeSeatFallsBack(t *testing.T) {
|
||||
got := RenderPartyTurnRound(
|
||||
[]CombatEvent{{Actor: "player", Action: "hit", Damage: 3, Seat: 9}}, []string{"Ada"}, "Rat", 0)
|
||||
if got == "" || strings.Contains(got, "without a clean blow") {
|
||||
t.Errorf("out-of-range seat should still render a line, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── The turn deadline ────────────────────────────────────────────────────────
|
||||
|
||||
func TestTurnDeadlineLapsed(t *testing.T) {
|
||||
fresh := time.Now().UTC()
|
||||
stale := fresh.Add(-partyTurnDeadline - time.Second)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
phase string
|
||||
at time.Time
|
||||
want bool
|
||||
}{
|
||||
{"fresh player turn", CombatPhasePlayerTurn, fresh, false},
|
||||
{"stale player turn", CombatPhasePlayerTurn, stale, true},
|
||||
// The enemy's turn belongs to the engine, not to a player who might be
|
||||
// away — a fight parked there is mid-resolution, not stalled on anyone.
|
||||
{"stale enemy turn", CombatPhaseEnemyTurn, stale, false},
|
||||
{"stale round end", CombatPhaseRoundEnd, stale, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
s := partySession(tc.phase, 40, 40)
|
||||
s.LastActionAt = tc.at
|
||||
if got := turnDeadlineLapsed(s); got != tc.want {
|
||||
t.Errorf("%s: turnDeadlineLapsed = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Locking ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// A solo fight's owner is the sender. sync.Mutex is not reentrant, so taking the
|
||||
// fight lock and then the user lock would deadlock the very command every solo
|
||||
// player types. This test hangs rather than fails if that regresses.
|
||||
func TestLockCombatFight_SoloTakesExactlyOneLock(t *testing.T) {
|
||||
p := &AdventurePlugin{}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
release := p.lockCombatFight("@solo:x", "@solo:x")
|
||||
release()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("lockCombatFight self-deadlocked on a solo fight")
|
||||
}
|
||||
}
|
||||
|
||||
// A party member's command holds both the fight's lock and their own, so no
|
||||
// other command of theirs races the round they are resolving.
|
||||
func TestLockCombatFight_PartyHoldsBothLocks(t *testing.T) {
|
||||
p := &AdventurePlugin{}
|
||||
release := p.lockCombatFight("@leader:x", "@member:x")
|
||||
|
||||
if p.advUserLock("@leader:x").TryLock() {
|
||||
t.Error("the fight's lock (seat 0) was not held")
|
||||
}
|
||||
if p.advUserLock("@member:x").TryLock() {
|
||||
t.Error("the acting member's own lock was not held")
|
||||
}
|
||||
release()
|
||||
|
||||
if !p.advUserLock("@leader:x").TryLock() {
|
||||
t.Error("release() left the fight's lock held")
|
||||
}
|
||||
if !p.advUserLock("@member:x").TryLock() {
|
||||
t.Error("release() left the member's lock held")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotYourTurnMsg_NamesWhoWeAreWaitingOn(t *testing.T) {
|
||||
players, _ := biasedParty()
|
||||
if got := notYourTurnMsg(players, 1, true); !strings.Contains(got, "Bram") {
|
||||
t.Errorf("message should name the acting player, got %q", got)
|
||||
}
|
||||
if got := notYourTurnMsg(players, 0, false); strings.Contains(got, "Ada") {
|
||||
t.Errorf("nobody is on the clock mid-resolution, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Starting a fight ─────────────────────────────────────────────────────────
|
||||
|
||||
// enemyWithHP is the monster the seats below face. Only its HP pool and its
|
||||
// Speed matter here: the pool sizes the fight, the speed feeds initiative.
|
||||
func enemyWithHP(hp int) *Combatant {
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = hp
|
||||
return &e
|
||||
}
|
||||
|
||||
// partySeats pairs each combatant with a seat setup, as buildFightSeats does.
|
||||
func partySeats(players []*Combatant, hp int) []CombatSeatSetup {
|
||||
seats := make([]CombatSeatSetup, len(players))
|
||||
for i, c := range players {
|
||||
seats[i] = CombatSeatSetup{UserID: id.UserID(memberID(i)), HP: hp, HPMax: hp, C: c}
|
||||
}
|
||||
seats[0].UserID = "@leader:x"
|
||||
return seats
|
||||
}
|
||||
|
||||
func TestStartPartyCombatSession_SoloWritesNoParticipantRows(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
|
||||
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemyWithHP(60),
|
||||
[]CombatSeatSetup{{UserID: "@solo:x", HP: 40, HPMax: 40}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.IsParty() || sess.RosterSize() != 1 {
|
||||
t.Fatalf("solo session reports a roster of %d", sess.RosterSize())
|
||||
}
|
||||
ps, err := loadCombatParticipants(sess.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ps) != 0 {
|
||||
t.Errorf("solo fight wrote %d participant rows, want 0", len(ps))
|
||||
}
|
||||
}
|
||||
|
||||
// Each seat's fight-start one-shots are read off their own combatant. A party
|
||||
// Abjurer brings their own Arcane Ward; the leader does not inherit it.
|
||||
func TestStartPartyCombatSession_SeedsEachSeatsOwnOneShots(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
|
||||
leader, abjurer := basePlayer(), basePlayer()
|
||||
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemyWithHP(60), []CombatSeatSetup{
|
||||
{UserID: "@leader:x", HP: 40, HPMax: 40, C: &leader},
|
||||
{UserID: "@abjurer:x", HP: 30, HPMax: 30, C: &abjurer,
|
||||
Mods: CombatModifiers{ArcaneWardHP: 12, WardCharges: 2}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sess.IsParty() || sess.RosterSize() != 2 {
|
||||
t.Fatalf("roster size = %d, want 2", sess.RosterSize())
|
||||
}
|
||||
if sess.Statuses.ArcaneWardHP != 0 {
|
||||
t.Errorf("the leader inherited the abjurer's ward: %d", sess.Statuses.ArcaneWardHP)
|
||||
}
|
||||
|
||||
// …and it must be there after a round trip through the DB.
|
||||
back, err := getCombatSession(sess.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := back.actorStatusesForSeat(1); got.ArcaneWardHP != 12 || got.WardCharges != 2 {
|
||||
t.Errorf("seat 1 statuses after reload = %+v, want ArcaneWardHP 12 / WardCharges 2", got)
|
||||
}
|
||||
if back.seatHP(1) != 30 || back.seatUserID(1) != "@abjurer:x" {
|
||||
t.Errorf("seat 1 = %d HP / %s", back.seatHP(1), back.seatUserID(1))
|
||||
}
|
||||
}
|
||||
|
||||
// A monster that wins initiative swings first. startCombatSession parks every
|
||||
// fight on a player_turn, which is right for the hardcoded solo order and wrong
|
||||
// for a party: the resume path would snap the cursor to the first player slot
|
||||
// and the enemy would silently forfeit round 1.
|
||||
func TestStartPartyCombatSession_EnemyThatWinsInitiativeOpensTheRound(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
players, enemy := biasedParty()
|
||||
// Invert the bias biasedParty stacks: the monster now outruns everyone.
|
||||
for _, c := range players {
|
||||
c.Mods.InitiativeBias = -1000
|
||||
}
|
||||
enemy.Stats.Speed = 500
|
||||
|
||||
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemy,
|
||||
partySeats(players, 100))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Phase != CombatPhaseEnemyTurn {
|
||||
t.Fatalf("round 1 opened on phase %q; the enemy won initiative and must act first", sess.Phase)
|
||||
}
|
||||
if order := turnOrder(sess, 1, players, enemy); order[0] != enemySeat {
|
||||
t.Fatalf("turn order = %v, want the enemy at the head", order)
|
||||
}
|
||||
|
||||
// And the phase survives the round trip, since a resume reads it back.
|
||||
back, err := getCombatSession(sess.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.Phase != CombatPhaseEnemyTurn {
|
||||
t.Errorf("reloaded phase = %q, want the enemy still on the clock", back.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same rule: when the party wins, nothing moved.
|
||||
func TestStartPartyCombatSession_PartyThatWinsInitiativeStillOpensOnAPlayer(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
players, enemy := biasedParty()
|
||||
|
||||
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemy,
|
||||
partySeats(players, 100))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Phase != CombatPhasePlayerTurn || sess.Statuses.TurnIdx != 0 {
|
||||
t.Fatalf("round 1 opened on %q/%d, want player_turn at the head of the order",
|
||||
sess.Phase, sess.Statuses.TurnIdx)
|
||||
}
|
||||
}
|
||||
|
||||
// ── The round driver ─────────────────────────────────────────────────────────
|
||||
|
||||
// The old loop rested on any player_turn. A party's downed seat still holds a
|
||||
// player_turn, so the round would come to rest on a corpse and the fight would
|
||||
// wait forever for a dead member to type `!attack`.
|
||||
func TestSettleCombatSession_StepsPastADownedSeat(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
players, enemy := biasedParty()
|
||||
|
||||
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemy,
|
||||
partySeats(players, 100))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Bram is down, and it is his turn. biasedParty stacks initiative so the
|
||||
// party leads the round; force the phase back to it in case the roll disagrees.
|
||||
sess.Participants[0].HP = 0
|
||||
sess.Phase = CombatPhasePlayerTurn
|
||||
sess.Statuses.TurnIdx = 1
|
||||
|
||||
if _, err := settleCombatSession(sess, players, enemy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sess.IsActive() {
|
||||
t.Fatalf("fight ended; two members were still standing (status %q)", sess.Status)
|
||||
}
|
||||
seat, waiting := actingSeat(sess, players, enemy)
|
||||
if !waiting {
|
||||
t.Fatal("settle came to rest on no player's turn")
|
||||
}
|
||||
if seat != 2 {
|
||||
t.Errorf("settled on seat %d, want seat 2 — seat 1 is down and forfeits", seat)
|
||||
}
|
||||
}
|
||||
|
||||
// Settling a session already parked on a standing player's turn must do nothing
|
||||
// at all. Every solo fight sits exactly there between commands, and a stray step
|
||||
// would resolve a round the player never asked for.
|
||||
func TestSettleCombatSession_IsANoOpOnASoloPlayerTurn(t *testing.T) {
|
||||
setupEmptyTestDB(t)
|
||||
p, e := basePlayer(), baseEnemy()
|
||||
|
||||
sess, err := (&AdventurePlugin{}).startPartyCombatSession("run1", "room3", "goblin", enemyWithHP(60),
|
||||
[]CombatSeatSetup{{UserID: "@solo:x", HP: 40, HPMax: 40}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := *sess
|
||||
|
||||
events, err := settleCombatSession(sess, []*Combatant{&p}, &e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Errorf("settle emitted %d events on an idle solo turn", len(events))
|
||||
}
|
||||
if sess.Round != before.Round || sess.Phase != before.Phase || sess.PlayerHP != before.PlayerHP {
|
||||
t.Errorf("settle advanced an idle solo fight: %d/%s/%d -> %d/%s/%d",
|
||||
before.Round, before.Phase, before.PlayerHP, sess.Round, sess.Phase, sess.PlayerHP)
|
||||
}
|
||||
}
|
||||
@@ -44,9 +44,10 @@ func (p *AdventurePlugin) reapExpiredCombatSessions() {
|
||||
// nothing to do.
|
||||
func (p *AdventurePlugin) reapCombatSession(userIDStr, sessionID string) {
|
||||
userID := id.UserID(userIDStr)
|
||||
userMu := p.advUserLock(userID)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
// The session's owner is seat 0, which is the user the sweep listed — so the
|
||||
// fight lock and the user lock are the same mutex here, taken once.
|
||||
release := p.lockCombatFight(userID, userID)
|
||||
defer release()
|
||||
|
||||
sess, err := getActiveCombatSession(userID)
|
||||
if err != nil {
|
||||
@@ -58,41 +59,51 @@ func (p *AdventurePlugin) reapCombatSession(userIDStr, sessionID string) {
|
||||
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)
|
||||
expire := func(why string, args ...any) {
|
||||
slog.Warn("combat: reaper "+why, append([]any{"user", userID, "session", sess.SessionID}, args...)...)
|
||||
if merr := markCombatSessionExpired(sess.SessionID); merr != nil {
|
||||
slog.Error("combat: reaper failed to mark session expired", "session", sess.SessionID, "err", merr)
|
||||
}
|
||||
}
|
||||
|
||||
players, enemy, err := p.partyCombatantsForSession(sess)
|
||||
if err != nil {
|
||||
// Can't reconstruct the fight — park it terminal so it isn't retried
|
||||
// every minute forever.
|
||||
expire("cannot rebuild session, marking expired", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Every seat swings until the fight lands. Deliberately *not* the auto-picker
|
||||
// the turn deadline uses: this is an abandoned fight, and finishing it should
|
||||
// not quietly burn the player's spell slots and potions on their behalf. The
|
||||
// turn-deadline latch is different — that member is mid-fight and their party
|
||||
// is waiting, so playing their character properly is the whole point.
|
||||
//
|
||||
// Each pass resolves one seat's turn and drains the enemy turn, the round-end
|
||||
// tick, and any downed seat behind it, so a solo fight walks exactly the loop
|
||||
// it always did.
|
||||
ct := &combatTurn{sess: sess, players: players, enemy: enemy, seat: 0, uid: userID}
|
||||
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)
|
||||
}
|
||||
expire("hit round cap, marking expired")
|
||||
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)
|
||||
}
|
||||
if _, rerr := runPartyCombatRound(sess, players, enemy, PlayerAction{Kind: ActionAttack}); rerr != nil {
|
||||
expire("round failed", "err", rerr)
|
||||
return
|
||||
}
|
||||
rounds++
|
||||
}
|
||||
|
||||
outcome := p.finishCombatSession(userID, sess, enemy)
|
||||
outcomes := p.closePartyRound(ct)
|
||||
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)
|
||||
if !ct.isParty() {
|
||||
if err := p.SendDM(userID, preamble+outcomes[0]); err != nil {
|
||||
slog.Error("combat: reaper failed to DM outcome", "user", userID, "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
p.announcePartyRound(ct, nil, preamble, outcomes)
|
||||
}
|
||||
|
||||
153
internal/plugin/combat_roster_test.go
Normal file
153
internal/plugin/combat_roster_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package plugin
|
||||
|
||||
// Roster/cursor tests for the N-player combat state (N3/P2).
|
||||
//
|
||||
// The solo path is pinned bit-for-bit by TestCombatCharacterization; these
|
||||
// cover the machinery that pin cannot see, because solo never moves the
|
||||
// cursor off seat 0.
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewActor_DerivesPerFightStateFromMods(t *testing.T) {
|
||||
c := basePlayer()
|
||||
c.Mods.WardCharges = 2
|
||||
c.Mods.SporeCloud = 3
|
||||
c.Mods.ReflectNext = 0.5
|
||||
c.Mods.AutoCritFirst = true
|
||||
c.Mods.ArcaneWardHP = 25
|
||||
|
||||
a := newActor(&c)
|
||||
|
||||
if a.c != &c {
|
||||
t.Error("actor should point back at its Combatant")
|
||||
}
|
||||
if a.playerHP != c.Stats.MaxHP {
|
||||
t.Errorf("playerHP = %d, want MaxHP %d", a.playerHP, c.Stats.MaxHP)
|
||||
}
|
||||
if a.wardCharges != 2 || a.sporeRounds != 3 || a.reflectFrac != 0.5 || !a.autoCrit || a.arcaneWardHP != 25 {
|
||||
t.Errorf("consumable one-shots not carried from Mods: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewActor_StartHPAndHealChargeBackfill(t *testing.T) {
|
||||
// StartHP below MaxHP means the character walks in wounded.
|
||||
wounded := basePlayer()
|
||||
wounded.Stats.StartHP = 40
|
||||
if got := newActor(&wounded).playerHP; got != 40 {
|
||||
t.Errorf("wounded entry HP = %d, want 40", got)
|
||||
}
|
||||
|
||||
// StartHP at or above MaxHP is ignored (guards a stale snapshot).
|
||||
full := basePlayer()
|
||||
full.Stats.StartHP = 999
|
||||
if got := newActor(&full).playerHP; got != full.Stats.MaxHP {
|
||||
t.Errorf("StartHP above MaxHP should not raise entry HP, got %d", got)
|
||||
}
|
||||
|
||||
// Legacy one-shot: a HealItem amount with no explicit count backfills to 1.
|
||||
legacy := basePlayer()
|
||||
legacy.Mods.HealItem = 30
|
||||
if got := newActor(&legacy).healChargesLeft; got != 1 {
|
||||
t.Errorf("legacy heal backfill = %d charges, want 1", got)
|
||||
}
|
||||
|
||||
// An explicit count wins over the backfill.
|
||||
stocked := basePlayer()
|
||||
stocked.Mods.HealItem = 30
|
||||
stocked.Mods.HealItemCharges = 4
|
||||
if got := newActor(&stocked).healChargesLeft; got != 4 {
|
||||
t.Errorf("explicit heal charges = %d, want 4", got)
|
||||
}
|
||||
|
||||
// No HealItem at all means no charges, even if a count leaked through.
|
||||
none := basePlayer()
|
||||
if got := newActor(&none).healChargesLeft; got != 0 {
|
||||
t.Errorf("no heal item should mean 0 charges, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The cursor is the whole point of the embed: per-actor fields must follow
|
||||
// seat(), and fight-scoped fields must not.
|
||||
func TestCombatState_SeatSwitchesPerActorStateOnly(t *testing.T) {
|
||||
alice, bob := basePlayer(), basePlayer()
|
||||
alice.Name, bob.Name = "Alice", "Bob"
|
||||
a0, a1 := newActor(&alice), newActor(&bob)
|
||||
|
||||
st := &combatState{
|
||||
actor: a0,
|
||||
actors: []*actor{a0, a1},
|
||||
enemyHP: 100,
|
||||
rng: rand.New(rand.NewPCG(1, 1)),
|
||||
}
|
||||
|
||||
// Wound seat 0 and burn one of its once-per-fight one-shots.
|
||||
st.seat(0)
|
||||
st.playerHP = 10
|
||||
st.luckyUsed = true
|
||||
|
||||
// Seat 1 must be untouched — a promoted write goes to the cursor, not the
|
||||
// struct. This is the assertion that would fail if actor were embedded by
|
||||
// value instead of by pointer.
|
||||
st.seat(1)
|
||||
if st.playerHP != bob.Stats.MaxHP {
|
||||
t.Errorf("seat 1 HP = %d, want a full pool %d — seat 0's wound leaked", st.playerHP, bob.Stats.MaxHP)
|
||||
}
|
||||
if st.luckyUsed {
|
||||
t.Error("seat 1 saw seat 0's consumed Lucky reroll")
|
||||
}
|
||||
if st.c.Name != "Bob" {
|
||||
t.Errorf("cursor Combatant = %q, want Bob", st.c.Name)
|
||||
}
|
||||
|
||||
// Fight-scoped state is shared: writing it under one seat is visible
|
||||
// under the other.
|
||||
st.enemyHP = 42
|
||||
st.enemyBlockUp = true
|
||||
st.seat(0)
|
||||
if st.playerHP != 10 || !st.luckyUsed {
|
||||
t.Error("seat 0 lost its own state across a cursor round-trip")
|
||||
}
|
||||
if st.enemyHP != 42 || !st.enemyBlockUp {
|
||||
t.Error("enemy stance should be fight-scoped, not per-actor")
|
||||
}
|
||||
if st.c.Name != "Alice" {
|
||||
t.Errorf("cursor Combatant = %q, want Alice", st.c.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombatState_AnyAlive(t *testing.T) {
|
||||
alice, bob := basePlayer(), basePlayer()
|
||||
a0, a1 := newActor(&alice), newActor(&bob)
|
||||
st := &combatState{actor: a0, actors: []*actor{a0, a1}}
|
||||
|
||||
if !st.anyAlive() {
|
||||
t.Error("a fresh roster should be alive")
|
||||
}
|
||||
a0.playerHP = 0
|
||||
if !st.anyAlive() {
|
||||
t.Error("one downed member should not end the fight while another stands")
|
||||
}
|
||||
a1.playerHP = 0
|
||||
if st.anyAlive() {
|
||||
t.Error("a fully downed roster should not be alive")
|
||||
}
|
||||
}
|
||||
|
||||
// Solo fights seat exactly one actor and park the cursor on it. If this ever
|
||||
// regresses, the characterization golden stops proving anything about the
|
||||
// production auto-resolve path.
|
||||
func TestSimulateCombat_SeatsExactlyOneActor(t *testing.T) {
|
||||
p, e := basePlayer(), baseEnemy()
|
||||
seat0 := newActor(&p)
|
||||
st := &combatState{actor: seat0, actors: []*actor{seat0}, enemyHP: e.Stats.MaxHP}
|
||||
|
||||
if len(st.actors) != 1 {
|
||||
t.Fatalf("solo roster length = %d, want 1", len(st.actors))
|
||||
}
|
||||
if st.actor != st.actors[0] {
|
||||
t.Error("solo cursor must point at seat 0")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -43,41 +44,34 @@ const (
|
||||
// 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.
|
||||
// ActorStatuses is the per-seat half of the persisted effect state: everything
|
||||
// that belongs to one character rather than to the fight. It mirrors the fields
|
||||
// of `actor` (combat_engine.go) that must survive a suspend/resume.
|
||||
//
|
||||
// All fields are scalar by design — CombatStatuses must stay comparable so the
|
||||
// The split follows P2's rule. A debuff the enemy stacked onto one character
|
||||
// (stat_drain / debuff / max_hp_drain), that character's concentration, their
|
||||
// depleting charges, and their once-per-fight one-shots are all per-actor — a
|
||||
// party of three carries three independent copies. The enemy's own stance and
|
||||
// the round cursor are fight-scoped and live on CombatStatuses.
|
||||
//
|
||||
// Seat 0's copy is embedded in the session row's statuses_json (see
|
||||
// CombatStatuses); seats 1+ each get a combat_participant row carrying this
|
||||
// struct alone. That asymmetry is deliberate: a solo fight writes exactly the
|
||||
// bytes it wrote before parties existed, and no participant rows at all.
|
||||
//
|
||||
// All fields are scalar by design — ActorStatuses 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"`
|
||||
type ActorStatuses struct {
|
||||
// Monster-ability effects landing on this character.
|
||||
PoisonTicks int `json:"poison_ticks,omitempty"`
|
||||
PoisonDmg int `json:"poison_dmg,omitempty"`
|
||||
StunPlayer bool `json:"stun_player,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.
|
||||
// Depleting resources — mirror the actor charges that genuinely carry
|
||||
// round-to-round. Seeded at fight start (Arcane Ward) or by a mid-fight
|
||||
// !cast / !consume, restored into the actor 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"`
|
||||
@@ -85,6 +79,15 @@ type CombatStatuses struct {
|
||||
ArcaneWardHP int `json:"arcane_ward_hp,omitempty"`
|
||||
HealChargesLeft int `json:"heal_charges_left,omitempty"`
|
||||
|
||||
// ConcentrationDmg is the per-round damage of this character's active
|
||||
// concentration AOE (Spirit Guardians, Spike Growth, Call Lightning,
|
||||
// Flaming Sphere…). A one-shot !cast lands its burst the casting round,
|
||||
// then this re-ticks the aura at round_end every round until the fight
|
||||
// ends or another concentration spell overwrites it — the lingering half
|
||||
// of the spell the engine used to drop on the floor, which left clerics
|
||||
// and druids with no sustained DPS once their burst landed.
|
||||
ConcentrationDmg int `json:"concentration_dmg,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.
|
||||
@@ -96,6 +99,75 @@ type CombatStatuses struct {
|
||||
AssassinateReroll bool `json:"assassinate_reroll_used,omitempty"`
|
||||
AssassinateBonus bool `json:"assassinate_bonus_used,omitempty"`
|
||||
|
||||
// Autopilot latches this seat onto the auto-picker for the rest of the
|
||||
// fight, set when its turn deadline lapses once (see partyTurnDeadline).
|
||||
// Without the latch an away member taxes the party the full deadline every
|
||||
// single round; with it, they cost one wait and then resolve instantly. Any
|
||||
// combat command from that member clears it.
|
||||
//
|
||||
// Like the Buff* deltas it is session-layer state with no combatState
|
||||
// counterpart, so snapshotActor carries it over from the prior snapshot
|
||||
// rather than re-deriving it. omitempty keeps it off every solo row — a solo
|
||||
// fight has no turn deadline, only the 1h session reaper.
|
||||
Autopilot bool `json:"autopilot,omitempty"`
|
||||
|
||||
// Debuffs the enemy has stacked onto this character specifically.
|
||||
PlayerAtkDrain int `json:"player_atk_drain,omitempty"`
|
||||
PlayerACDebuff int `json:"player_ac_debuff,omitempty"`
|
||||
MaxHPDrain int `json:"max_hp_drain,omitempty"`
|
||||
|
||||
// Persistent stat buffs from this character's mid-fight !cast / !consume,
|
||||
// accumulated as deltas against their freshly-rebuilt combatant.
|
||||
// applySessionBuffs folds these back on 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"`
|
||||
BuffSpiritProc float64 `json:"buff_spirit_proc,omitempty"`
|
||||
BuffSpiritDmg int `json:"buff_spirit_dmg,omitempty"`
|
||||
}
|
||||
|
||||
// CombatStatuses is the serialized between-round effect state for a fight: the
|
||||
// fight-scoped half (the enemy's stance, the round cursor) plus seat 0's
|
||||
// ActorStatuses, embedded. 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.
|
||||
//
|
||||
// ActorStatuses is embedded anonymously and untagged, so it flattens into the
|
||||
// same one-level JSON object the field list used to produce. Rows written
|
||||
// before the split decode unchanged, and rows written after are byte-compatible
|
||||
// with a reader that predates it.
|
||||
//
|
||||
// All fields are scalar by design — CombatStatuses must stay comparable so the
|
||||
// persistence round-trip can be asserted with ==.
|
||||
type CombatStatuses struct {
|
||||
// Seat 0's per-character state. Seats 1+ carry their own copy in
|
||||
// combat_participant rows.
|
||||
ActorStatuses
|
||||
|
||||
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. Holding the
|
||||
// enemy holds it for the whole party, hence fight-scoped.
|
||||
EnemySkipNext bool `json:"enemy_skip_next,omitempty"`
|
||||
|
||||
// TurnIdx is the position within the round's initiative order (see
|
||||
// turnOrder) of whoever acts next. A solo fight's order is the fixed
|
||||
// [player, enemy], so TurnIdx mirrors the phase and the field is dead
|
||||
// weight — omitempty keeps it out of every solo row. Rows written before
|
||||
// this field existed decode as 0 and are reconciled against Phase on
|
||||
// resume, so a mid-fight upgrade lands on the right slot.
|
||||
TurnIdx int `json:"turn_idx,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
|
||||
@@ -106,9 +178,6 @@ type CombatStatuses struct {
|
||||
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.
|
||||
@@ -116,25 +185,12 @@ type CombatStatuses struct {
|
||||
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
|
||||
// player turn) into that character's persisted state. Stat components
|
||||
// accumulate as deltas; depleting resources add to their running counters.
|
||||
func (s *CombatStatuses) applyBuffDelta(d turnBuffDelta) {
|
||||
func (s *ActorStatuses) applyBuffDelta(d turnBuffDelta) {
|
||||
s.BuffACBonus += d.dAC
|
||||
s.BuffAtkBonus += d.dAtk
|
||||
s.BuffSpeedBonus += d.dSpeed
|
||||
@@ -142,6 +198,8 @@ func (s *CombatStatuses) applyBuffDelta(d turnBuffDelta) {
|
||||
s.BuffCritRate += d.dCrit
|
||||
s.BuffDamageBonus += d.dDmgBonus
|
||||
s.BuffPetProc += d.dPetProc
|
||||
s.BuffSpiritProc += d.dSpiritProc
|
||||
s.BuffSpiritDmg += d.dSpiritDmg
|
||||
if d.dReductMul > 0 && d.dReductMul != 1 {
|
||||
if s.BuffDamageReductMul == 0 {
|
||||
s.BuffDamageReductMul = d.dReductMul
|
||||
@@ -164,7 +222,14 @@ func (s *CombatStatuses) applyBuffDelta(d turnBuffDelta) {
|
||||
// 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
|
||||
return seedActorOneShots(&s.Statuses.ActorStatuses, playerMods)
|
||||
}
|
||||
|
||||
// seedActorOneShots copies one character's fight-start one-shot resources onto
|
||||
// their persisted statuses. Seat 0's live on the session row; a party member's
|
||||
// live on their participant row, and each seat reads its own combatant's mods —
|
||||
// a party Abjurer brings their own Arcane Ward, not the leader's.
|
||||
func seedActorOneShots(st *ActorStatuses, playerMods CombatModifiers) bool {
|
||||
st.WardCharges = playerMods.WardCharges
|
||||
st.SporeRounds = playerMods.SporeCloud
|
||||
st.ReflectFrac = playerMods.ReflectNext
|
||||
@@ -175,6 +240,17 @@ func seedCombatSessionOneShots(s *CombatSession, playerMods CombatModifiers) boo
|
||||
st.AutoCritFirst || st.ArcaneWardHP != 0 || st.HealChargesLeft != 0
|
||||
}
|
||||
|
||||
// CombatParticipant is one party member's seat in a fight, from seat 1 up.
|
||||
// Seat 0 is the session row itself (UserID / PlayerHP / Statuses.ActorStatuses)
|
||||
// and never appears here.
|
||||
type CombatParticipant struct {
|
||||
Seat int
|
||||
UserID string
|
||||
HP int
|
||||
HPMax int
|
||||
Statuses ActorStatuses
|
||||
}
|
||||
|
||||
// CombatSession is the in-memory shape of a combat_session row.
|
||||
type CombatSession struct {
|
||||
SessionID string
|
||||
@@ -194,11 +270,116 @@ type CombatSession struct {
|
||||
StartedAt time.Time
|
||||
LastActionAt time.Time
|
||||
ExpiresAt time.Time
|
||||
|
||||
// Participants are the party's seats 1..N-1, ordered by seat and contiguous
|
||||
// (loadCombatParticipants enforces both). Empty for a solo fight — the only
|
||||
// kind that existed before N3 — so nothing in the solo path allocates or
|
||||
// queries for it.
|
||||
Participants []CombatParticipant
|
||||
|
||||
// rosterSize mirrors the combat_session.roster_size column as scanned. It
|
||||
// exists only so a loader can tell "solo, skip the participant query" from
|
||||
// "party, go fetch the seats" in one round trip. In memory Participants is
|
||||
// the single source of truth; every write re-derives the column from it.
|
||||
rosterSize int
|
||||
}
|
||||
|
||||
// IsActive reports whether the fight is still in flight.
|
||||
func (s *CombatSession) IsActive() bool { return s.Status == CombatStatusActive }
|
||||
|
||||
// RosterSize is the number of seated player characters: seat 0 plus the party.
|
||||
func (s *CombatSession) RosterSize() int { return 1 + len(s.Participants) }
|
||||
|
||||
// IsParty reports whether more than one character is seated.
|
||||
func (s *CombatSession) IsParty() bool { return len(s.Participants) > 0 }
|
||||
|
||||
// SeatUserIDs lists every seated member's user id in seat order.
|
||||
func (s *CombatSession) SeatUserIDs() []string {
|
||||
out := make([]string, 0, s.RosterSize())
|
||||
out = append(out, s.UserID)
|
||||
for _, p := range s.Participants {
|
||||
out = append(out, p.UserID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// actorStatusesForSeat returns the persisted per-character state for a seat.
|
||||
// Seat 0 reads through to the session's embedded copy.
|
||||
func (s *CombatSession) actorStatusesForSeat(seat int) ActorStatuses {
|
||||
if seat == 0 {
|
||||
return s.Statuses.ActorStatuses
|
||||
}
|
||||
return s.Participants[seat-1].Statuses
|
||||
}
|
||||
|
||||
// actorStatusesPtr is actorStatusesForSeat for writers. The mid-fight buff path
|
||||
// (!cast / !consume) folds its delta into the *casting* seat's statuses; before
|
||||
// P5 it wrote unconditionally to the session's embedded copy, which is seat 0 —
|
||||
// so a party member's buff would have landed on the leader.
|
||||
func (s *CombatSession) actorStatusesPtr(seat int) *ActorStatuses {
|
||||
if seat == 0 {
|
||||
return &s.Statuses.ActorStatuses
|
||||
}
|
||||
return &s.Participants[seat-1].Statuses
|
||||
}
|
||||
|
||||
// seatHP / seatHPMax read one seat's HP pool. Seat 0's lives on the session row
|
||||
// (PlayerHP / PlayerHPMax); seats 1+ carry their own on their participant row.
|
||||
func (s *CombatSession) seatHP(seat int) int {
|
||||
if seat == 0 {
|
||||
return s.PlayerHP
|
||||
}
|
||||
return s.Participants[seat-1].HP
|
||||
}
|
||||
|
||||
func (s *CombatSession) seatHPMax(seat int) int {
|
||||
if seat == 0 {
|
||||
return s.PlayerHPMax
|
||||
}
|
||||
return s.Participants[seat-1].HPMax
|
||||
}
|
||||
|
||||
// seatUserID names the player sitting at a seat.
|
||||
func (s *CombatSession) seatUserID(seat int) string {
|
||||
if seat == 0 {
|
||||
return s.UserID
|
||||
}
|
||||
return s.Participants[seat-1].UserID
|
||||
}
|
||||
|
||||
// seatOf locates a player on the roster. The false return is the "you are not in
|
||||
// this fight" answer every party-aware command needs before it touches a turn.
|
||||
func (s *CombatSession) seatOf(userID id.UserID) (int, bool) {
|
||||
u := string(userID)
|
||||
if s.UserID == u {
|
||||
return 0, true
|
||||
}
|
||||
for i, p := range s.Participants {
|
||||
if p.UserID == u {
|
||||
return i + 1, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// seatAlive reports whether a seat is still standing. A downed seat forfeits its
|
||||
// turn silently rather than blocking the round on a corpse.
|
||||
func (s *CombatSession) seatAlive(seat int) bool { return s.seatHP(seat) > 0 }
|
||||
|
||||
// seatIsAutopiloted reports whether a seat has been latched onto the auto-picker
|
||||
// by a lapsed turn deadline.
|
||||
func (s *CombatSession) seatIsAutopiloted(seat int) bool {
|
||||
return s.actorStatusesForSeat(seat).Autopilot
|
||||
}
|
||||
|
||||
// seatNeedsNoHuman reports whether the engine can resolve a seat's turn without
|
||||
// waiting on its player: it is down (forfeits silently) or latched onto
|
||||
// autopilot. driveCombatRound keeps stepping while this holds, so a round only
|
||||
// comes to rest on a live human's turn.
|
||||
func (s *CombatSession) seatNeedsNoHuman(seat int) bool {
|
||||
return !s.seatAlive(seat) || s.seatIsAutopiloted(seat)
|
||||
}
|
||||
|
||||
// Errors returned by the combat session layer.
|
||||
var (
|
||||
ErrCombatSessionAlreadyActive = errors.New("combat session already active for player")
|
||||
@@ -211,7 +392,7 @@ 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`
|
||||
started_at, last_action_at, expires_at, roster_size`
|
||||
|
||||
// newCombatSessionID — 16-char hex token. Same scheme as zone runs / expeditions.
|
||||
func newCombatSessionID() string {
|
||||
@@ -272,6 +453,80 @@ func startCombatSession(userID id.UserID, runID, encounterID, enemyID string, pl
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// startPartyCombatSession opens a fight for a seated roster. seats[0] owns the
|
||||
// session row (and is the expedition's leader); seats 1..N-1 get their own
|
||||
// combat_participant rows. Each seat carries the HP pool and the fight-start
|
||||
// one-shot resources (Abjuration's Arcane Ward, …) of its own character.
|
||||
//
|
||||
// A one-seat roster is exactly startCombatSession: no participant rows, no
|
||||
// roster_size bump, and the single unwrapped INSERT the solo path has always
|
||||
// issued. That is the invariant the whole balance corpus rests on.
|
||||
func (p *AdventurePlugin) startPartyCombatSession(
|
||||
runID, encounterID, enemyID string, enemy *Combatant, seats []CombatSeatSetup,
|
||||
) (*CombatSession, error) {
|
||||
if len(seats) == 0 {
|
||||
return nil, fmt.Errorf("start combat session: empty roster")
|
||||
}
|
||||
enemyHP := enemy.Stats.MaxHP
|
||||
owner := seats[0]
|
||||
sess, err := startCombatSession(owner.UserID, runID, encounterID, enemyID,
|
||||
owner.HP, owner.HPMax, enemyHP, enemyHP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Seat 0's one-shots live on the session row; seeding them is a mutation of
|
||||
// sess.Statuses that the save below flushes along with the participants.
|
||||
dirty := seedCombatSessionOneShots(sess, owner.Mods)
|
||||
|
||||
if len(seats) > 1 {
|
||||
ps := make([]CombatParticipant, 0, len(seats)-1)
|
||||
for i, s := range seats[1:] {
|
||||
var st ActorStatuses
|
||||
seedActorOneShots(&st, s.Mods)
|
||||
ps = append(ps, CombatParticipant{
|
||||
Seat: i + 1, UserID: string(s.UserID), HP: s.HP, HPMax: s.HPMax, Statuses: st,
|
||||
})
|
||||
}
|
||||
if err := insertCombatParticipants(sess.SessionID, ps); err != nil {
|
||||
return nil, fmt.Errorf("seat party: %w", err)
|
||||
}
|
||||
sess.Participants = ps
|
||||
sess.rosterSize = len(seats)
|
||||
|
||||
// startCombatSession parks every fight on a player_turn, because a solo
|
||||
// order is hardcoded [player, enemy] and the player always leads. A party
|
||||
// rolls for initiative and the monster can win it, so round 1's phase has
|
||||
// to come from the order rather than from that assumption — otherwise the
|
||||
// cursor snaps forward to the first player slot on resume and the enemy
|
||||
// silently forfeits its opening turn. Round 2+ already derives this in
|
||||
// stepRoundEnd.
|
||||
order := turnOrder(sess, sess.Round, seatCombatants(seats), enemy)
|
||||
sess.Phase = phaseForSeat(order[0])
|
||||
sess.Statuses.TurnIdx = 0
|
||||
dirty = true
|
||||
}
|
||||
|
||||
if dirty {
|
||||
if err := saveCombatSession(sess); err != nil {
|
||||
return nil, fmt.Errorf("seed combat session: %w", err)
|
||||
}
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// CombatSeatSetup is one character's entry into a fight: who they are, the HP
|
||||
// pool they bring, the modifiers their fight-start one-shots are read off, and
|
||||
// the built combatant itself — which the initiative roll needs, and which the
|
||||
// caller threads on to the opening block rather than rebuilding the roster.
|
||||
type CombatSeatSetup struct {
|
||||
UserID id.UserID
|
||||
HP int
|
||||
HPMax int
|
||||
Mods CombatModifiers
|
||||
C *Combatant
|
||||
}
|
||||
|
||||
// getActiveCombatSession returns the player's in-flight fight, or (nil, nil).
|
||||
func getActiveCombatSession(userID id.UserID) (*CombatSession, error) {
|
||||
row := db.Get().QueryRow(`SELECT `+combatSessionCols+`
|
||||
@@ -283,7 +538,10 @@ func getActiveCombatSession(userID id.UserID) (*CombatSession, error) {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return s, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, hydrateCombatParticipants(s)
|
||||
}
|
||||
|
||||
// hasActiveCombatSession reports whether the player is currently locked into a
|
||||
@@ -311,7 +569,10 @@ func getCombatSession(sessionID string) (*CombatSession, error) {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return s, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, hydrateCombatParticipants(s)
|
||||
}
|
||||
|
||||
// getCombatSessionForEncounter returns the most recent session for a
|
||||
@@ -328,7 +589,10 @@ func getCombatSessionForEncounter(runID, encounterID string) (*CombatSession, er
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return s, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, hydrateCombatParticipants(s)
|
||||
}
|
||||
|
||||
func scanCombatSession(row scanner) (*CombatSession, error) {
|
||||
@@ -341,7 +605,7 @@ func scanCombatSession(row scanner) (*CombatSession, error) {
|
||||
&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,
|
||||
&s.StartedAt, &s.LastActionAt, &s.ExpiresAt, &s.rosterSize,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -364,32 +628,68 @@ func scanCombatSession(row scanner) (*CombatSession, error) {
|
||||
// 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.
|
||||
//
|
||||
// A party fight also writes back its seats, in the same transaction as the
|
||||
// session row: a crash between the two would leave seat 0 a round ahead of the
|
||||
// rest of the roster. The solo path keeps its single unwrapped UPDATE — there
|
||||
// are no seats to desync from, and it is the path the whole balance corpus and
|
||||
// every pre-N3 fight take.
|
||||
//
|
||||
// roster_size is not rewritten here: insertCombatParticipants stamped it at
|
||||
// fight start and the roster is fixed for the fight's lifetime (a dead member
|
||||
// keeps their seat at 0 HP).
|
||||
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 = ?`,
|
||||
|
||||
if len(s.Participants) == 0 {
|
||||
if _, err := db.Get().Exec(saveCombatSessionSQL,
|
||||
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
|
||||
}
|
||||
|
||||
tx, err := db.Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(saveCombatSessionSQL,
|
||||
s.Round, s.Phase, s.PlayerHP, s.EnemyHP,
|
||||
string(statusesJSON), string(logJSON), s.Status, now, s.SessionID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveCombatParticipantsTx(tx, s.SessionID, s.Participants); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.LastActionAt = now
|
||||
return nil
|
||||
}
|
||||
|
||||
const saveCombatSessionSQL = `
|
||||
UPDATE combat_session
|
||||
SET round = ?, phase = ?,
|
||||
player_hp = ?, enemy_hp = ?,
|
||||
statuses_json = ?, turn_log_json = ?,
|
||||
status = ?, last_action_at = ?
|
||||
WHERE session_id = ?`
|
||||
|
||||
// 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+`
|
||||
rows, err := db.Get().Query(`SELECT ` + combatSessionCols + `
|
||||
FROM combat_session
|
||||
WHERE status = 'active'
|
||||
AND expires_at <= CURRENT_TIMESTAMP
|
||||
@@ -407,9 +707,162 @@ func listExpiredCombatSessions() ([]*CombatSession, error) {
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Hydrate only after the cursor is closed: a nested query while iterating
|
||||
// can stall on a single-connection pool.
|
||||
rows.Close()
|
||||
for _, s := range out {
|
||||
if err := hydrateCombatParticipants(s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── Party seats (N3/P4) ──────────────────────────────────────────────────────
|
||||
|
||||
// hydrateCombatParticipants fills s.Participants for a party session. A solo
|
||||
// session (roster_size 1, which is every row written before N3) returns
|
||||
// immediately without touching combat_participant, so the common path stays at
|
||||
// the single query it has always been.
|
||||
func hydrateCombatParticipants(s *CombatSession) error {
|
||||
if s == nil || s.rosterSize <= 1 {
|
||||
return nil
|
||||
}
|
||||
ps, err := loadCombatParticipants(s.SessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if got := 1 + len(ps); got != s.rosterSize {
|
||||
return fmt.Errorf("combat session %s: roster_size %d but %d seats persisted",
|
||||
s.SessionID, s.rosterSize, got)
|
||||
}
|
||||
s.Participants = ps
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadCombatParticipants returns seats 1..N-1 in seat order, verifying that the
|
||||
// seats are contiguous from 1. The engine indexes the roster positionally, so a
|
||||
// gap would silently shift every member down a seat — better to fail the load.
|
||||
func loadCombatParticipants(sessionID string) ([]CombatParticipant, error) {
|
||||
rows, err := db.Get().Query(`
|
||||
SELECT seat, user_id, hp, hp_max, statuses_json
|
||||
FROM combat_participant
|
||||
WHERE session_id = ?
|
||||
ORDER BY seat ASC`, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []CombatParticipant
|
||||
for rows.Next() {
|
||||
var (
|
||||
p CombatParticipant
|
||||
statusesJSON string
|
||||
)
|
||||
if err := rows.Scan(&p.Seat, &p.UserID, &p.HP, &p.HPMax, &statusesJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if statusesJSON != "" {
|
||||
if err := json.Unmarshal([]byte(statusesJSON), &p.Statuses); err != nil {
|
||||
return nil, fmt.Errorf("decode participant statuses (seat %d): %w", p.Seat, err)
|
||||
}
|
||||
}
|
||||
if want := len(out) + 1; p.Seat != want {
|
||||
return nil, fmt.Errorf("combat session %s: seat %d out of order (want %d)",
|
||||
sessionID, p.Seat, want)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// insertCombatParticipants seats the party at fight start and stamps
|
||||
// roster_size so later loads know to come back for them. Seat 0 is the session
|
||||
// row's own user and is not written here; callers pass seats 1..N-1.
|
||||
func insertCombatParticipants(sessionID string, ps []CombatParticipant) error {
|
||||
if len(ps) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := db.Get().Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
for _, p := range ps {
|
||||
statusesJSON, _ := json.Marshal(p.Statuses)
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO combat_participant (session_id, seat, user_id, hp, hp_max, statuses_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
sessionID, p.Seat, p.UserID, p.HP, p.HPMax, string(statusesJSON),
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert participant seat %d: %w", p.Seat, err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE combat_session SET roster_size = ? WHERE session_id = ?`,
|
||||
1+len(ps), sessionID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("stamp roster_size: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// saveCombatParticipantsTx writes back the mutable half of every seat after an
|
||||
// engine step. session_id / seat / user_id / hp_max are immutable for a fight's
|
||||
// lifetime and are not rewritten. It runs inside the caller's transaction so
|
||||
// the seats commit together with the session row that indexes them.
|
||||
func saveCombatParticipantsTx(tx *sql.Tx, sessionID string, ps []CombatParticipant) error {
|
||||
for _, p := range ps {
|
||||
statusesJSON, _ := json.Marshal(p.Statuses)
|
||||
if _, err := tx.Exec(`
|
||||
UPDATE combat_participant
|
||||
SET hp = ?, statuses_json = ?
|
||||
WHERE session_id = ? AND seat = ?`,
|
||||
p.HP, string(statusesJSON), sessionID, p.Seat,
|
||||
); err != nil {
|
||||
return fmt.Errorf("save participant seat %d: %w", p.Seat, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getActiveCombatSessionForMember finds the in-flight fight a player is seated
|
||||
// in at seat 1+, i.e. the one their own user_id does not key. Seat 0's fight is
|
||||
// getActiveCombatSession's job; this is the party-member equivalent, and it
|
||||
// returns (nil, nil) when there is none.
|
||||
func getActiveCombatSessionForMember(userID id.UserID) (*CombatSession, error) {
|
||||
row := db.Get().QueryRow(`SELECT `+prefixCols("cs", combatSessionCols)+`
|
||||
FROM combat_session cs
|
||||
JOIN combat_participant cp ON cp.session_id = cs.session_id
|
||||
WHERE cp.user_id = ? AND cs.status = 'active'
|
||||
ORDER BY cs.started_at DESC
|
||||
LIMIT 1`, string(userID))
|
||||
s, err := scanCombatSession(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, hydrateCombatParticipants(s)
|
||||
}
|
||||
|
||||
// prefixCols qualifies every column in a SELECT list with a table alias, so the
|
||||
// shared combatSessionCols can be reused inside a join without ambiguity on the
|
||||
// columns both tables carry (session_id, user_id, statuses_json).
|
||||
func prefixCols(alias, cols string) string {
|
||||
fields := strings.Split(cols, ",")
|
||||
for i, f := range fields {
|
||||
fields[i] = alias + "." + strings.TrimSpace(f)
|
||||
}
|
||||
return strings.Join(fields, ", ")
|
||||
}
|
||||
|
||||
// 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'
|
||||
|
||||
@@ -107,41 +107,79 @@ func (p *AdventurePlugin) buildZoneCombatants(
|
||||
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.
|
||||
// combatantsForSession reconstructs the Combatant pair for an in-flight solo
|
||||
// CombatSession — the shape every caller wanted before parties existed. It is
|
||||
// partyCombatantsForSession reduced to seat 0, and it errors rather than
|
||||
// silently dropping the rest of a party's roster on the floor.
|
||||
func (p *AdventurePlugin) combatantsForSession(sess *CombatSession) (player Combatant, enemy Combatant, err error) {
|
||||
if sess.IsParty() {
|
||||
return Combatant{}, Combatant{}, fmt.Errorf(
|
||||
"combat session %s seats %d players; use partyCombatantsForSession", sess.SessionID, sess.RosterSize())
|
||||
}
|
||||
players, e, err := p.partyCombatantsForSession(sess)
|
||||
if err != nil {
|
||||
return Combatant{}, Combatant{}, err
|
||||
}
|
||||
return *players[0], *e, nil
|
||||
}
|
||||
|
||||
// partyCombatantsForSession reconstructs the seated roster and the enemy 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 combatants
|
||||
// carry no HP — the turn engine reads that from the session row and the
|
||||
// participant rows.
|
||||
//
|
||||
// Every seat is rebuilt from *its own* player: their sheet, their equipment,
|
||||
// their passives, their magic items. That is N times the per-round DB chatter
|
||||
// combatantsForSession already had (project_combat_session_cache_deferred), and
|
||||
// a party of 3 pays it three times over. Solo — every fight that has ever run,
|
||||
// and the whole balance corpus — still issues exactly one build.
|
||||
func (p *AdventurePlugin) partyCombatantsForSession(sess *CombatSession) ([]*Combatant, *Combatant, error) {
|
||||
run, rerr := getZoneRun(sess.RunID)
|
||||
if rerr != nil {
|
||||
return Combatant{}, Combatant{}, fmt.Errorf("load zone run: %w", rerr)
|
||||
return nil, nil, 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)
|
||||
return nil, nil, 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)
|
||||
return nil, nil, 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
|
||||
|
||||
seats := sess.SeatUserIDs()
|
||||
players := make([]*Combatant, len(seats))
|
||||
var enemy Combatant
|
||||
for seat, uid := range seats {
|
||||
player, e, _, err := p.buildZoneCombatants(id.UserID(uid), monster, int(zone.Tier), run.DMMood)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("seat %d (%s): %w", seat, uid, err)
|
||||
}
|
||||
// Fold any fight-scoped buffs this seat's mid-fight !cast / !consume
|
||||
// layered on back onto their freshly-rebuilt combatant. The depleting
|
||||
// one-shots (ward/spore/…) live on their persisted statuses and flow
|
||||
// through the turn engine's resume/commit cycle, so only the persistent
|
||||
// stat deltas are applied here — and only that seat's own.
|
||||
applySessionBuffs(&player, sess.actorStatusesForSeat(seat))
|
||||
players[seat] = &player
|
||||
if seat == 0 {
|
||||
// The enemy build reads only (monster, tier, dmMood): every seat
|
||||
// rebuilds the identical stat block, so seat 0's copy is the fight's.
|
||||
// Only the *player* half of the build varies by seat.
|
||||
enemy = e
|
||||
}
|
||||
}
|
||||
// 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
|
||||
return players, &enemy, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// buff onto one rebuilt character. The buffs are stored as accumulated deltas
|
||||
// (diffTurnBuff produced them against that character'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) {
|
||||
// round without double-counting. It takes ActorStatuses rather than the whole
|
||||
// session because buffs are per-caster: each seat folds in only its own.
|
||||
func applySessionBuffs(player *Combatant, s ActorStatuses) {
|
||||
player.Stats.AC += s.BuffACBonus
|
||||
player.Stats.AttackBonus += s.BuffAtkBonus
|
||||
player.Stats.Speed += s.BuffSpeedBonus
|
||||
@@ -149,6 +187,8 @@ func applySessionBuffs(player *Combatant, s CombatStatuses) {
|
||||
player.Mods.DamageBonus += s.BuffDamageBonus
|
||||
player.Mods.PetAttackProc += s.BuffPetProc
|
||||
player.Mods.PetAttackDmg += s.BuffPetDmg
|
||||
player.Mods.SpiritWeaponProc += s.BuffSpiritProc
|
||||
player.Mods.SpiritWeaponDmg += s.BuffSpiritDmg
|
||||
if s.BuffDamageReductMul > 0 {
|
||||
player.Mods.DamageReduct *= s.BuffDamageReductMul
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestStartCombatSession_RoundTrip(t *testing.T) {
|
||||
got.Phase = CombatPhaseRoundEnd
|
||||
got.PlayerHP = 40
|
||||
got.EnemyHP = 15
|
||||
got.Statuses = CombatStatuses{PoisonTicks: 2, PoisonDmg: 5, Enraged: true}
|
||||
got.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{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)
|
||||
@@ -67,7 +67,7 @@ func TestStartCombatSession_RoundTrip(t *testing.T) {
|
||||
if reloaded.PlayerHP != 40 || reloaded.EnemyHP != 15 {
|
||||
t.Errorf("hp not persisted: %+v", reloaded)
|
||||
}
|
||||
if reloaded.Statuses != (CombatStatuses{PoisonTicks: 2, PoisonDmg: 5, Enraged: true}) {
|
||||
if reloaded.Statuses != (CombatStatuses{ActorStatuses: ActorStatuses{PoisonTicks: 2, PoisonDmg: 5}, Enraged: true}) {
|
||||
t.Errorf("statuses not persisted: %+v", reloaded.Statuses)
|
||||
}
|
||||
if len(reloaded.TurnLog) != 1 || reloaded.TurnLog[0].Action != "hit" {
|
||||
@@ -181,7 +181,7 @@ func turnSession(phase string, playerHP, enemyHP int) *CombatSession {
|
||||
|
||||
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)
|
||||
te := resumeTurnEngine(sess, []*Combatant{player}, enemy, rng)
|
||||
events, err := te.step(action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -223,7 +223,7 @@ func TestTurnEngine_PhaseProgression(t *testing.T) {
|
||||
|
||||
func TestTurnEngine_PoisonTickAtRoundEnd(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseRoundEnd, 50, 80)
|
||||
sess.Statuses = CombatStatuses{PoisonTicks: 2, PoisonDmg: 7}
|
||||
sess.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{PoisonTicks: 2, PoisonDmg: 7}}
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
|
||||
@@ -246,7 +246,7 @@ func TestTurnEngine_PoisonTickAtRoundEnd(t *testing.T) {
|
||||
|
||||
func TestTurnEngine_PoisonTickCanBeLethal(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseRoundEnd, 4, 80)
|
||||
sess.Statuses = CombatStatuses{PoisonTicks: 1, PoisonDmg: 9}
|
||||
sess.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{PoisonTicks: 1, PoisonDmg: 9}}
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
|
||||
@@ -263,6 +263,74 @@ func TestTurnEngine_PoisonTickCanBeLethal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_ConcentrationTickAtRoundEnd(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseRoundEnd, 50, 80)
|
||||
sess.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{ConcentrationDmg: 12}}
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.EnemyHP != 68 {
|
||||
t.Errorf("enemy_hp = %d, want 68 (80 - 12 aura)", sess.EnemyHP)
|
||||
}
|
||||
if sess.Statuses.ConcentrationDmg != 12 {
|
||||
t.Errorf("concentration_dmg = %d, want 12 (aura persists)", sess.Statuses.ConcentrationDmg)
|
||||
}
|
||||
if len(events) != 1 || events[0].Action != "concentration_tick" || events[0].Damage != 12 {
|
||||
t.Errorf("expected one concentration_tick event, got %+v", events)
|
||||
}
|
||||
if sess.Round != 2 || sess.Phase != CombatPhasePlayerTurn {
|
||||
t.Errorf("round=%d phase=%q, want 2/player_turn", sess.Round, sess.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_ConcentrationTickCanBeLethal(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseRoundEnd, 50, 9)
|
||||
sess.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{ConcentrationDmg: 12}}
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Status != CombatStatusWon || sess.Phase != CombatPhaseOver {
|
||||
t.Errorf("status=%q phase=%q, want won/over (aura dropped enemy)", sess.Status, sess.Phase)
|
||||
}
|
||||
if sess.EnemyHP != 0 {
|
||||
t.Errorf("enemy_hp = %d, want 0", sess.EnemyHP)
|
||||
}
|
||||
}
|
||||
|
||||
// A concentration damage cast lands its burst this round AND arms the aura so
|
||||
// it re-ticks at every subsequent round_end.
|
||||
func TestTurnEngine_ConcentrationCastArmsAura(t *testing.T) {
|
||||
sess := turnSession(CombatPhasePlayerTurn, 50, 200)
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
eff := &turnActionEffect{
|
||||
Label: "Spirit Guardians", Action: "spell_cast",
|
||||
EnemyDamage: 15, ConcentrationDmg: 15,
|
||||
}
|
||||
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionCast, Effect: eff}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.EnemyHP != 185 {
|
||||
t.Errorf("enemy_hp = %d, want 185 (200 - 15 burst)", sess.EnemyHP)
|
||||
}
|
||||
if sess.Statuses.ConcentrationDmg != 15 {
|
||||
t.Fatalf("concentration_dmg = %d, want 15 (aura armed)", sess.Statuses.ConcentrationDmg)
|
||||
}
|
||||
// Drive to round_end and confirm the aura bites again.
|
||||
sess.Phase = CombatPhaseRoundEnd
|
||||
if _, err := stepEngine(sess, &player, &enemy, PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.EnemyHP != 170 {
|
||||
t.Errorf("enemy_hp = %d, want 170 (185 - 15 aura tick)", sess.EnemyHP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_Flee(t *testing.T) {
|
||||
sess := turnSession(CombatPhasePlayerTurn, 50, 50)
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
@@ -307,7 +375,7 @@ func TestTurnEngine_StepRejectsTerminalSession(t *testing.T) {
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
|
||||
rng := rand.New(rand.NewPCG(1, 1))
|
||||
te := resumeTurnEngine(sess, &player, &enemy, rng)
|
||||
te := resumeTurnEngine(sess, []*Combatant{&player}, &enemy, rng)
|
||||
if _, err := te.step(PlayerAction{Kind: ActionAttack}); err != errCombatSessionOver {
|
||||
t.Errorf("err = %v, want errCombatSessionOver", err)
|
||||
}
|
||||
@@ -365,7 +433,7 @@ func TestApplyBuffDelta(t *testing.T) {
|
||||
|
||||
func TestApplySessionBuffs(t *testing.T) {
|
||||
player := basePlayer() // AC 0, AttackBonus 0, CritRate 0.05, Speed 10, DamageReduct 1.0
|
||||
applySessionBuffs(&player, CombatStatuses{
|
||||
applySessionBuffs(&player, ActorStatuses{
|
||||
BuffACBonus: 3, BuffAtkBonus: 2, BuffSpeedBonus: 5, BuffCritRate: 0.15,
|
||||
BuffDamageBonus: 0.25, BuffDamageReductMul: 0.8, BuffPetProc: 0.5, BuffPetDmg: 6,
|
||||
})
|
||||
@@ -405,7 +473,7 @@ func TestSeedCombatSessionOneShots(t *testing.T) {
|
||||
// re-bank a depleted ward charge — on every resumed round.
|
||||
func TestTurnEngine_OneShotsRoundTrip(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseRoundEnd, 50, 50)
|
||||
sess.Statuses = CombatStatuses{
|
||||
sess.Statuses = CombatStatuses{ActorStatuses: ActorStatuses{
|
||||
WardCharges: 3, SporeRounds: 2, ReflectFrac: 0.5, AutoCritFirst: true,
|
||||
ArcaneWardHP: 10, HealChargesLeft: 1,
|
||||
Raged: true, LuckyUsed: true, DeathSaveUsed: true, PendingRage: true,
|
||||
@@ -413,7 +481,7 @@ func TestTurnEngine_OneShotsRoundTrip(t *testing.T) {
|
||||
// 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()
|
||||
|
||||
@@ -425,72 +493,120 @@ func TestTurnEngine_OneShotsRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet proc (per-fight) ───────────────────────────────────────────────────
|
||||
// ── Pet proc (per-round) ───────────────────────────────────────────────────
|
||||
|
||||
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.
|
||||
// TestTurnEngine_PetStrike confirms a pet with a guaranteed proc strikes on
|
||||
// every player-acting turn — the per-round roll model, matching auto-resolve.
|
||||
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.PetAttackProc = 1.0
|
||||
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)
|
||||
petHitsOnPlayerTurn := func() int {
|
||||
events, err := stepEngine(sess, &player, &enemy, PlayerAction{Kind: ActionAttack})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits := 0
|
||||
for _, e := range events {
|
||||
if e.Action == "pet_attack" {
|
||||
hits++
|
||||
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")
|
||||
return hits
|
||||
}
|
||||
|
||||
// Cycle back to the next player turn — the pet must not strike again.
|
||||
if got := petHitsOnPlayerTurn(); got != 1 {
|
||||
t.Fatalf("pet_attack events = %d on first player turn, want 1", got)
|
||||
}
|
||||
|
||||
// Cycle back to the next player turn — the pet must 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 got := petHitsOnPlayerTurn(); got != 1 {
|
||||
t.Errorf("pet_attack events = %d on second player turn, want 1 (per-round)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnEngine_PetNoProc confirms a player without the pet proc never sees a
|
||||
// pet strike.
|
||||
func TestTurnEngine_PetNoProc(t *testing.T) {
|
||||
sess := turnSession(CombatPhasePlayerTurn, 100000, 100000)
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
player.Mods.PetAttackProc = 0
|
||||
|
||||
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")
|
||||
t.Error("pet struck with zero PetAttackProc")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnEngine_PetWhiff confirms a guaranteed whiff makes the enemy's turn a
|
||||
// total miss — a pet_whiff event fires and the player takes no damage.
|
||||
func TestTurnEngine_PetWhiff(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseEnemyTurn, 100000, 100000)
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
player.Mods.PetWhiffProc = 1.0
|
||||
enemy.Stats.AttackBonus = 50 // would connect easily if not whiffed
|
||||
startHP := sess.PlayerHP
|
||||
|
||||
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
whiffs := 0
|
||||
for _, e := range events {
|
||||
if e.Action == "pet_whiff" {
|
||||
whiffs++
|
||||
}
|
||||
if e.Actor == "enemy" && e.Action == "hit" {
|
||||
t.Error("enemy landed a hit despite a guaranteed pet whiff")
|
||||
}
|
||||
}
|
||||
if whiffs == 0 {
|
||||
t.Error("expected a pet_whiff event with PetWhiffProc 1.0")
|
||||
}
|
||||
if sess.PlayerHP != startHP {
|
||||
t.Errorf("player took %d damage on a whiffed turn, want 0", startHP-sess.PlayerHP)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnEngine_PetDeflect confirms a guaranteed deflect emits a pet_deflect
|
||||
// event on a connecting enemy attack.
|
||||
func TestTurnEngine_PetDeflect(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseEnemyTurn, 100000, 100000)
|
||||
player, enemy := basePlayer(), baseEnemy()
|
||||
player.Mods.PetDeflectProc = 1.0
|
||||
enemy.Stats.AttackBonus = 50 // guarantee the swing connects
|
||||
|
||||
events, err := stepEngine(sess, &player, &enemy, PlayerAction{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deflects := 0
|
||||
for _, e := range events {
|
||||
if e.Action == "pet_deflect" {
|
||||
deflects++
|
||||
}
|
||||
}
|
||||
if deflects == 0 {
|
||||
t.Error("expected a pet_deflect event with PetDeflectProc 1.0 on a connecting hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombatSessionRNG_DeterministicPerPhase(t *testing.T) {
|
||||
sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhaseEnemyTurn}
|
||||
a := combatSessionRNG(sess)
|
||||
|
||||
@@ -218,8 +218,8 @@ func DeriveDungeonMonsterStats(loc *AdvLocation) (CombatStats, CombatModifiers)
|
||||
death := loc.BaseDeathPct // 8 to 60
|
||||
|
||||
stats := CombatStats{
|
||||
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+)
|
||||
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,
|
||||
|
||||
@@ -284,11 +284,11 @@ func TestBalanceRegression_DungeonDeathRates(t *testing.T) {
|
||||
// drift), not 1:1 well-equipped combat. These bounds reflect the
|
||||
// new "fairly breezy with some death" target.
|
||||
cases := []tc{
|
||||
{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
|
||||
{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 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Phase 13 — Turn-based combat state machine.
|
||||
@@ -64,31 +65,76 @@ type turnActionEffect struct {
|
||||
EnemyDamage int
|
||||
PlayerHeal int
|
||||
EnemySkip bool // control spell: enemy forfeits its attack this round
|
||||
// ConcentrationDmg arms a per-round aura tick when a concentration damage
|
||||
// spell is cast: EnemyDamage is the burst that lands this round, this is
|
||||
// what re-ticks at every round_end after. Zero for one-shot spells; a
|
||||
// non-zero value overwrites any aura already running (5e: one
|
||||
// concentration at a time).
|
||||
ConcentrationDmg int
|
||||
}
|
||||
|
||||
// enemySeat is the sentinel roster index the monster occupies in a turn order.
|
||||
// Player seats are the roster indices 0..len(actors)-1.
|
||||
const enemySeat = -1
|
||||
|
||||
// turnEngine wraps a combatState reconstructed from a persisted CombatSession
|
||||
// so a single phase can be resolved and then written back.
|
||||
//
|
||||
// players is the seated roster in roster order; order is this round's seat
|
||||
// sequence, which interleaves the roster with enemySeat. A solo fight's order
|
||||
// is always [0, enemySeat] — the fixed player-then-enemy sequence the engine
|
||||
// has always run — so no initiative is rolled and its RNG stream is untouched.
|
||||
type turnEngine struct {
|
||||
sess *CombatSession
|
||||
player *Combatant
|
||||
enemy *Combatant
|
||||
st *combatState
|
||||
result *CombatResult
|
||||
sess *CombatSession
|
||||
players []*Combatant
|
||||
enemy *Combatant
|
||||
order []int
|
||||
st *combatState
|
||||
result *CombatResult
|
||||
}
|
||||
|
||||
// combatSessionSeed hashes the session id into the base PCG seed.
|
||||
func combatSessionSeed(sess *CombatSession) uint64 {
|
||||
var seed uint64 = 1469598103934665603 // FNV-ish offset basis
|
||||
for _, c := range sess.SessionID {
|
||||
seed = (seed ^ uint64(c)) * 1099511628211
|
||||
}
|
||||
return seed
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// This is the solo form of combatSessionStepRNG, kept as the name the engine's
|
||||
// single-seat callers and tests use.
|
||||
func combatSessionRNG(sess *CombatSession) *rand.Rand {
|
||||
var seed uint64 = 1469598103934665603 // FNV-ish offset basis
|
||||
for _, c := range sess.SessionID {
|
||||
seed = (seed ^ uint64(c)) * 1099511628211
|
||||
return combatSessionStepRNG(sess, 0)
|
||||
}
|
||||
|
||||
// combatSessionStepRNG seeds the generator for one step resolved by one seat.
|
||||
// A round holds one player_turn step per party member, and they all share a
|
||||
// (round, phase) pair — so the acting seat is mixed into the *seed* to keep
|
||||
// their d20s independent. Seat 0 and the enemy sentinel mix to nothing, which
|
||||
// is what makes a solo fight draw exactly the pre-roster stream.
|
||||
func combatSessionStepRNG(sess *CombatSession, seat int) *rand.Rand {
|
||||
seed := combatSessionSeed(sess)
|
||||
if seat > 0 {
|
||||
seed ^= uint64(seat) * 0x9E3779B97F4A7C15 // golden-ratio odd multiplier
|
||||
}
|
||||
stream := uint64(sess.Round)*4 + phaseOrdinal(sess.Phase)
|
||||
return rand.New(rand.NewPCG(seed, stream))
|
||||
}
|
||||
|
||||
// combatInitiativeRNG seeds the once-per-round initiative roll. It takes the
|
||||
// round explicitly because stepRoundEnd must order the round it is opening,
|
||||
// not the one it is closing, and sess.Round is only advanced by commit.
|
||||
// A distinct seed mix keeps it clear of every step stream.
|
||||
func combatInitiativeRNG(sess *CombatSession, round int) *rand.Rand {
|
||||
return rand.New(rand.NewPCG(combatSessionSeed(sess)^0xD1B54A32D192ED03, uint64(round)))
|
||||
}
|
||||
|
||||
func phaseOrdinal(phase string) uint64 {
|
||||
switch phase {
|
||||
case CombatPhasePlayerTurn:
|
||||
@@ -102,37 +148,200 @@ func phaseOrdinal(phase string) uint64 {
|
||||
}
|
||||
}
|
||||
|
||||
// turnOrder returns the seat sequence for one round: the player roster and the
|
||||
// enemy sentinel, highest initiative first. Initiative mirrors the auto-resolve
|
||||
// engine's formula (speed + d10 + InitiativeBias); ties break toward the lower
|
||||
// seat, and the enemy loses every tie.
|
||||
//
|
||||
// A solo roster short-circuits to the engine's historical fixed order and rolls
|
||||
// nothing — the turn-based engine has never had initiative, and giving one seat
|
||||
// a coin-flip on who swings first would be a live balance change.
|
||||
func turnOrder(sess *CombatSession, round int, players []*Combatant, enemy *Combatant) []int {
|
||||
if len(players) <= 1 {
|
||||
return []int{0, enemySeat}
|
||||
}
|
||||
type entry struct {
|
||||
seat int
|
||||
init float64
|
||||
}
|
||||
rng := combatInitiativeRNG(sess, round)
|
||||
entries := make([]entry, 0, len(players)+1)
|
||||
for i, p := range players {
|
||||
entries = append(entries, entry{i, float64(p.Stats.Speed) + rngFloat(rng)*10 + p.Mods.InitiativeBias})
|
||||
}
|
||||
entries = append(entries, entry{enemySeat, float64(enemy.Stats.Speed) + rngFloat(rng)*10})
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
a, b := entries[i], entries[j]
|
||||
if a.init != b.init {
|
||||
return a.init > b.init
|
||||
}
|
||||
if (a.seat == enemySeat) != (b.seat == enemySeat) {
|
||||
return b.seat == enemySeat
|
||||
}
|
||||
return a.seat < b.seat
|
||||
})
|
||||
order := make([]int, len(entries))
|
||||
for i, e := range entries {
|
||||
order[i] = e.seat
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// phaseForSeat names the phase under which a seat resolves its turn.
|
||||
func phaseForSeat(seat int) string {
|
||||
if seat == enemySeat {
|
||||
return CombatPhaseEnemyTurn
|
||||
}
|
||||
return CombatPhasePlayerTurn
|
||||
}
|
||||
|
||||
// stepSeat is the seat that resolves the session's current phase: the acting
|
||||
// player on a player_turn, the enemy sentinel on anything else.
|
||||
//
|
||||
// It is the *single* derivation of "whose step is this". advancePartyCombatSession
|
||||
// needs it before the engine is resumed, because the seat seeds that step's RNG;
|
||||
// the command layer needs it to tell a member it is not their turn. Both read
|
||||
// only (sessionID, round, roster), so they agree by construction.
|
||||
func stepSeat(sess *CombatSession, players []*Combatant, enemy *Combatant) int {
|
||||
if sess.Phase != CombatPhasePlayerTurn {
|
||||
return enemySeat
|
||||
}
|
||||
order := turnOrder(sess, sess.Round, players, enemy)
|
||||
return order[turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)]
|
||||
}
|
||||
|
||||
// actingSeat names the player seat currently on the clock. The false return
|
||||
// means the fight is not waiting on anybody: it is mid enemy-turn, mid
|
||||
// round-end, or over.
|
||||
func actingSeat(sess *CombatSession, players []*Combatant, enemy *Combatant) (int, bool) {
|
||||
if !sess.IsActive() || sess.Phase != CombatPhasePlayerTurn {
|
||||
return 0, false
|
||||
}
|
||||
return stepSeat(sess, players, enemy), true
|
||||
}
|
||||
|
||||
// turnIdxForPhase reconciles a persisted cursor against the persisted phase.
|
||||
// They can disagree for exactly one reason: a fight that was in flight when
|
||||
// TurnIdx was introduced decodes as 0 regardless of whose turn it is. Phase is
|
||||
// the older, load-bearing field, so it wins; the cursor snaps to the first slot
|
||||
// of the matching kind. On a solo order ([player, enemy]) that is exact.
|
||||
func turnIdxForPhase(order []int, idx int, phase string) int {
|
||||
if idx >= 0 && idx < len(order) && phaseForSeat(order[idx]) == phase {
|
||||
return idx
|
||||
}
|
||||
for i, seat := range order {
|
||||
if phaseForSeat(seat) == phase {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// rng is the deterministic source for this step (see combatSessionStepRNG).
|
||||
//
|
||||
// restoreActor rebuilds one seat from its persisted state: live HP, the pool
|
||||
// ceiling, and the per-character effect set. Depleting resources and the
|
||||
// once-per-fight "already used" flags are restored rather than rearmed, so a
|
||||
// charge can't silently reset across a suspend/resume or a reaper auto-play.
|
||||
// snapshotActor is its exact inverse — keep the two field lists in step.
|
||||
func restoreActor(c *Combatant, hp, hpMax int, s ActorStatuses) *actor {
|
||||
return &actor{
|
||||
c: c,
|
||||
playerHP: hp,
|
||||
hpMax: hpMax,
|
||||
|
||||
poisonTicks: s.PoisonTicks,
|
||||
poisonDmg: s.PoisonDmg,
|
||||
stunPlayer: s.StunPlayer,
|
||||
|
||||
wardCharges: s.WardCharges,
|
||||
sporeRounds: s.SporeRounds,
|
||||
reflectFrac: s.ReflectFrac,
|
||||
autoCrit: s.AutoCritFirst,
|
||||
arcaneWardHP: s.ArcaneWardHP,
|
||||
healChargesLeft: s.HealChargesLeft,
|
||||
|
||||
concentrationDmg: s.ConcentrationDmg,
|
||||
|
||||
deathSaveUsed: s.DeathSaveUsed,
|
||||
luckyUsed: s.LuckyUsed,
|
||||
raged: s.Raged,
|
||||
pendingRageAttack: s.PendingRage,
|
||||
firstAttackBonusUsed: s.FirstAtkBonusUsed,
|
||||
assassinateRerollUsed: s.AssassinateReroll,
|
||||
assassinateBonusUsed: s.AssassinateBonus,
|
||||
|
||||
// Enemy debuffs stacked onto this character specifically.
|
||||
playerAtkDrain: s.PlayerAtkDrain,
|
||||
playerACDebuff: s.PlayerACDebuff,
|
||||
maxHPDrain: s.MaxHPDrain,
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotActor folds one seat's live state back into its persisted form. The
|
||||
// Buff* deltas are owned by the command layer (a !cast / !consume folds them in
|
||||
// via applyBuffDelta) and are not combatState fields, so they are carried over
|
||||
// from the prior snapshot rather than re-derived.
|
||||
func snapshotActor(a *actor, prior ActorStatuses) ActorStatuses {
|
||||
s := prior
|
||||
s.PoisonTicks = a.poisonTicks
|
||||
s.PoisonDmg = a.poisonDmg
|
||||
s.StunPlayer = a.stunPlayer
|
||||
|
||||
s.WardCharges = a.wardCharges
|
||||
s.SporeRounds = a.sporeRounds
|
||||
s.ReflectFrac = a.reflectFrac
|
||||
s.AutoCritFirst = a.autoCrit
|
||||
s.ArcaneWardHP = a.arcaneWardHP
|
||||
s.HealChargesLeft = a.healChargesLeft
|
||||
|
||||
s.ConcentrationDmg = a.concentrationDmg
|
||||
|
||||
s.DeathSaveUsed = a.deathSaveUsed
|
||||
s.LuckyUsed = a.luckyUsed
|
||||
s.Raged = a.raged
|
||||
s.PendingRage = a.pendingRageAttack
|
||||
s.FirstAtkBonusUsed = a.firstAttackBonusUsed
|
||||
s.AssassinateReroll = a.assassinateRerollUsed
|
||||
s.AssassinateBonus = a.assassinateBonusUsed
|
||||
|
||||
s.PlayerAtkDrain = a.playerAtkDrain
|
||||
s.PlayerACDebuff = a.playerACDebuff
|
||||
s.MaxHPDrain = a.maxHPDrain
|
||||
return s
|
||||
}
|
||||
|
||||
// Every seat restores its own persisted per-character statuses: seat 0 from the
|
||||
// session row's embedded ActorStatuses, seats 1+ from their combat_participant
|
||||
// row. Without that, a party member's once-per-fight one-shots would rearm on
|
||||
// every engine step. players[i] must be the combatant for seat i — the caller
|
||||
// owns that ordering, and it must match sess.Participants[i-1].UserID.
|
||||
func resumeTurnEngine(sess *CombatSession, players []*Combatant, enemy *Combatant, rng *rand.Rand) *turnEngine {
|
||||
seat0 := restoreActor(players[0], sess.PlayerHP, sess.PlayerHPMax, sess.Statuses.ActorStatuses)
|
||||
actors := make([]*actor, len(players))
|
||||
actors[0] = seat0
|
||||
for i := 1; i < len(players); i++ {
|
||||
// A roster longer than the persisted seats can only happen if a caller
|
||||
// hands us combatants the session never seated. Open those fresh rather
|
||||
// than panicking mid-fight; hydrateCombatParticipants already rejects
|
||||
// the reverse (a session claiming more seats than it persisted).
|
||||
if i-1 >= len(sess.Participants) {
|
||||
actors[i] = newActor(players[i])
|
||||
continue
|
||||
}
|
||||
p := sess.Participants[i-1]
|
||||
actors[i] = restoreActor(players[i], p.HP, p.HPMax, p.Statuses)
|
||||
}
|
||||
st := &combatState{
|
||||
playerHP: sess.PlayerHP,
|
||||
actor: seat0,
|
||||
actors: actors,
|
||||
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,
|
||||
@@ -141,9 +350,6 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
|
||||
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,
|
||||
@@ -151,19 +357,48 @@ func resumeTurnEngine(sess *CombatSession, player, enemy *Combatant, rng *rand.R
|
||||
enemyAtkBuff: sess.Statuses.EnemyAtkBuff,
|
||||
rng: rng,
|
||||
}
|
||||
return &turnEngine{
|
||||
sess: sess,
|
||||
player: player,
|
||||
enemy: enemy,
|
||||
st: st,
|
||||
result: &CombatResult{},
|
||||
order := turnOrder(sess, sess.Round, players, enemy)
|
||||
sess.Statuses.TurnIdx = turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)
|
||||
if sess.Phase == CombatPhasePlayerTurn {
|
||||
st.seat(order[sess.Statuses.TurnIdx])
|
||||
}
|
||||
return &turnEngine{
|
||||
sess: sess,
|
||||
players: players,
|
||||
enemy: enemy,
|
||||
order: order,
|
||||
st: st,
|
||||
result: &CombatResult{},
|
||||
}
|
||||
}
|
||||
|
||||
// advance moves the round cursor to the next seat, or to round_end once every
|
||||
// seat has acted.
|
||||
func (te *turnEngine) advance() {
|
||||
te.sess.Statuses.TurnIdx++
|
||||
if te.sess.Statuses.TurnIdx >= len(te.order) {
|
||||
te.sess.Phase = CombatPhaseRoundEnd
|
||||
return
|
||||
}
|
||||
te.sess.Phase = phaseForSeat(te.order[te.sess.Statuses.TurnIdx])
|
||||
}
|
||||
|
||||
// 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.
|
||||
// stampSeat tags every event appended since mark with the roster seat it is
|
||||
// about, so a party's play-by-play can name the right character. The primitives
|
||||
// in combat_primitives.go emit against the cursor and know nothing of seats, so
|
||||
// the tag is applied here, once per phase step, rather than at ~20 append sites.
|
||||
//
|
||||
// Solo stamps seat 0 onto seat-0 events — a no-op that omitempty erases.
|
||||
func (te *turnEngine) stampSeat(mark, seat int) {
|
||||
for i := mark; i < len(te.st.events); i++ {
|
||||
te.st.events[i].Seat = seat
|
||||
}
|
||||
}
|
||||
|
||||
func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
|
||||
if !te.sess.IsActive() {
|
||||
return nil, errCombatSessionOver
|
||||
@@ -171,9 +406,18 @@ func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
|
||||
te.st.events = nil
|
||||
switch te.sess.Phase {
|
||||
case CombatPhasePlayerTurn:
|
||||
// The cursor was seated on the acting player by resumeTurnEngine, and
|
||||
// stepPlayerTurn never moves it, so every event this phase emits — the
|
||||
// swings, the pet, the spirit weapon, the retaliate that kills them —
|
||||
// belongs to that seat.
|
||||
acting := te.st.seatIdx
|
||||
te.stepPlayerTurn(action)
|
||||
te.stampSeat(0, acting)
|
||||
case CombatPhaseEnemyTurn:
|
||||
te.stepEnemyTurn()
|
||||
// stepEnemyTurn seats its target before anything resolves, and the whole
|
||||
// phase lands on that one character.
|
||||
te.stampSeat(0, te.st.seatIdx)
|
||||
case CombatPhaseRoundEnd:
|
||||
te.stepRoundEnd()
|
||||
case CombatPhaseOver:
|
||||
@@ -185,6 +429,13 @@ func (te *turnEngine) step(action PlayerAction) ([]CombatEvent, error) {
|
||||
}
|
||||
|
||||
func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
|
||||
// A seat that went down earlier this round forfeits its turn silently.
|
||||
// Unreachable while the roster is solo — a downed solo player has already
|
||||
// ended the fight.
|
||||
if te.st.playerHP <= 0 {
|
||||
te.advance()
|
||||
return
|
||||
}
|
||||
switch action.Kind {
|
||||
case ActionFlee:
|
||||
te.st.events = append(te.st.events, CombatEvent{
|
||||
@@ -205,38 +456,50 @@ func (te *turnEngine) stepPlayerTurn(action PlayerAction) {
|
||||
// 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() {
|
||||
// Returns true once the swing decided something — usually the enemy is
|
||||
// down, but a retaliate aura can drop the swinger on their own attack. A
|
||||
// downed swinger only ends the fight if nobody else is standing.
|
||||
// The outcome is read off HP rather than the bool: resolvePlayerSwings also
|
||||
// returns false when a retaliate aura kills the swinger between extra
|
||||
// attacks, and the old code walked that corpse into the enemy's turn.
|
||||
resolvePlayerSwings(te.st, te.st.c, te.enemy, &turnCombatPhase, te.result)
|
||||
if te.st.enemyHP <= 0 {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
te.sess.Phase = CombatPhaseEnemyTurn
|
||||
if !te.st.anyAlive() {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
// A swinger who went down to retaliate gets no bonus-action follow-ups.
|
||||
// Solo never reaches here dead — anyAlive above would have ended the fight.
|
||||
if te.st.playerHP > 0 {
|
||||
if te.petStrike() {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
if te.spiritWeaponStrike() {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
}
|
||||
te.advance()
|
||||
}
|
||||
|
||||
// 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.
|
||||
// petStrike resolves the player's pet attack for a turn-based fight. The pet
|
||||
// rolls fresh on every player-acting turn (PetAttackProc), mirroring the
|
||||
// auto-resolve engine's per-round chance rather than a once-per-fight strike.
|
||||
// The roll rides the per-(round,phase) step RNG, so a suspend/resume or reaper
|
||||
// auto-play of the same turn reproduces the same outcome. 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 {
|
||||
if st.c.Mods.PetAttackProc <= 0 || st.randFloat() >= st.c.Mods.PetAttackProc {
|
||||
return false
|
||||
}
|
||||
st.petProcReady = false
|
||||
petDmg := te.player.Mods.PetAttackDmg + st.roll(5)
|
||||
petDmg := st.c.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",
|
||||
@@ -245,30 +508,22 @@ func (te *turnEngine) petStrike() bool {
|
||||
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 {
|
||||
// spiritWeaponStrike resolves the spell's bonus-action attack each round when
|
||||
// the spiritual_weapon buff is active. Same per-turn cadence as petStrike, but
|
||||
// rolls and narrates on its own channel so the spectral mace doesn't borrow
|
||||
// pet flavor on a petless caster. Returns true if the strike dropped the enemy.
|
||||
func (te *turnEngine) spiritWeaponStrike() bool {
|
||||
st := te.st
|
||||
if st.c.Mods.SpiritWeaponProc <= 0 || st.randFloat() >= st.c.Mods.SpiritWeaponProc {
|
||||
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
|
||||
dmg := st.c.Mods.SpiritWeaponDmg + st.roll(5)
|
||||
st.enemyHP = max(0, st.enemyHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: turnCombatPhase.Name, Actor: "spirit_weapon", Action: "spirit_weapon_strike",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return enemyDown(st, turnCombatPhase.Name)
|
||||
}
|
||||
|
||||
// stepPlayerActionEffect resolves a !cast / !consume turn: the command handler
|
||||
@@ -280,7 +535,7 @@ 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
|
||||
te.advance()
|
||||
return
|
||||
}
|
||||
action := eff.Action
|
||||
@@ -301,9 +556,15 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
||||
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)
|
||||
hpCap := max(1, st.hpMax-st.maxHPDrain)
|
||||
st.playerHP = min(hpCap, st.playerHP+eff.PlayerHeal)
|
||||
}
|
||||
// Arm / replace the concentration aura. A new concentration cast overwrites
|
||||
// the old one (5e: one concentration at a time); non-concentration casts
|
||||
// leave any running aura alone.
|
||||
if eff.ConcentrationDmg > 0 {
|
||||
st.concentrationDmg = eff.ConcentrationDmg
|
||||
}
|
||||
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,
|
||||
@@ -322,6 +583,10 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
if te.spiritWeaponStrike() {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
if eff.EnemySkip {
|
||||
// fear_immune enemies shrug off control spells — the skip never arms.
|
||||
if enemyImmuneToControl(te.enemy, st) {
|
||||
@@ -333,10 +598,23 @@ func (te *turnEngine) stepPlayerActionEffect(eff *turnActionEffect) {
|
||||
st.enemySkipFirst = true
|
||||
}
|
||||
}
|
||||
te.sess.Phase = CombatPhaseEnemyTurn
|
||||
te.advance()
|
||||
}
|
||||
|
||||
// enemyTarget picks the seat the monster swings at this turn: uniformly among
|
||||
// the standing roster. A solo roster draws nothing, so its RNG stream keeps the
|
||||
// pre-roster shape. Returns false when the whole roster is down.
|
||||
func (te *turnEngine) enemyTarget() (int, bool) { return enemyTargetSeat(te.st) }
|
||||
|
||||
func (te *turnEngine) stepEnemyTurn() {
|
||||
// Seat the target before anything reads the cursor: the ability path, the
|
||||
// pet procs, and the swing loop all resolve against whoever is targeted.
|
||||
target, ok := te.enemyTarget()
|
||||
if !ok {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
te.st.seat(target)
|
||||
// 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.
|
||||
@@ -352,7 +630,7 @@ func (te *turnEngine) stepEnemyTurn() {
|
||||
Round: te.st.round, Phase: turnCombatPhase.Name, Actor: "enemy", Action: "spell_held",
|
||||
PlayerHP: te.st.playerHP, EnemyHP: te.st.enemyHP,
|
||||
})
|
||||
te.sess.Phase = CombatPhaseRoundEnd
|
||||
te.advance()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -364,8 +642,15 @@ func (te *turnEngine) stepEnemyTurn() {
|
||||
// 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)
|
||||
if applyAbility(te.st, te.st.c, te.enemy, &turnCombatPhase, te.result) {
|
||||
// The target went down without a save. The fight only ends if it
|
||||
// took the last member with it; otherwise the enemy has spent its
|
||||
// turn on that kill.
|
||||
if !te.st.anyAlive() {
|
||||
te.finish(CombatStatusLost)
|
||||
} else {
|
||||
te.advance()
|
||||
}
|
||||
return
|
||||
}
|
||||
switch te.enemy.Ability.Effect {
|
||||
@@ -375,20 +660,40 @@ func (te *turnEngine) stepEnemyTurn() {
|
||||
}
|
||||
|
||||
if !abilityDealtDamage {
|
||||
// Pet defensive procs are a single proc per enemy turn: roll once, then
|
||||
// spend it on the first swing only. Whiff makes that one swing a
|
||||
// guaranteed miss; deflect halves its damage. Against a multiattack the
|
||||
// remaining swings resolve normally — a single proc shouldn't nullify a
|
||||
// boss's whole multiattack round. (This deliberately diverges from the
|
||||
// auto-resolve engine's apply-to-all model.)
|
||||
petWhiff := te.st.c.Mods.PetWhiffProc > 0 && te.st.randFloat() < te.st.c.Mods.PetWhiffProc
|
||||
petDeflect := te.st.c.Mods.PetDeflectProc > 0 && te.st.randFloat() < te.st.c.Mods.PetDeflectProc
|
||||
if petDeflect {
|
||||
te.result.PetDeflected = true
|
||||
}
|
||||
|
||||
// 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) {
|
||||
for i, 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)
|
||||
// Spend the proc on the first swing only; later swings see false.
|
||||
swingWhiff := petWhiff && i == 0
|
||||
swingDeflect := petDeflect && i == 0
|
||||
decided := resolveEnemyAttack(te.st, te.st.c, &swing, &turnCombatPhase, te.result, swingWhiff, swingDeflect, false)
|
||||
if te.st.playerHP <= 0 {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
// The target is down. The enemy stops swinging at a corpse; the
|
||||
// fight ends only if the roster is empty.
|
||||
if !te.st.anyAlive() {
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
if decided || te.st.enemyHP <= 0 {
|
||||
te.finish(CombatStatusWon)
|
||||
@@ -396,7 +701,7 @@ func (te *turnEngine) stepEnemyTurn() {
|
||||
}
|
||||
}
|
||||
}
|
||||
te.sess.Phase = CombatPhaseRoundEnd
|
||||
te.advance()
|
||||
}
|
||||
|
||||
// turnAbilityFires decides whether a monster ability triggers this enemy turn.
|
||||
@@ -424,22 +729,50 @@ func turnAbilityFires(ability *MonsterAbility, st *combatState, enemy *Combatant
|
||||
}
|
||||
|
||||
// stepRoundEnd applies between-round status effects, then opens the next round.
|
||||
// Per-actor effects (poison, concentration) tick once per seat, in seating
|
||||
// order; the enemy's regen is fight-scoped and ticks once. A solo roster walks
|
||||
// each loop exactly once, so it draws the same RNG the pre-roster engine did.
|
||||
func (te *turnEngine) stepRoundEnd() {
|
||||
st := te.st
|
||||
if st.poisonTicks > 0 {
|
||||
for i := range st.actors {
|
||||
st.seat(i)
|
||||
if st.poisonTicks <= 0 {
|
||||
continue
|
||||
}
|
||||
mark := len(st.events)
|
||||
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
|
||||
}
|
||||
if st.playerHP <= 0 && !trySave(st, st.c, CombatPhaseRoundEnd) && !st.anyAlive() {
|
||||
te.stampSeat(mark, i)
|
||||
te.finish(CombatStatusLost)
|
||||
return
|
||||
}
|
||||
te.stampSeat(mark, i)
|
||||
}
|
||||
// Concentration aura (Spirit Guardians et al.): the lingering spell bites
|
||||
// the enemy each round it stays up. Concentration is per-caster, so every
|
||||
// seat holding one pulses. Ticks before enemy regen so a lethal pulse
|
||||
// settles the fight before the enemy knits its wounds back.
|
||||
for i := range st.actors {
|
||||
st.seat(i)
|
||||
if st.concentrationDmg <= 0 || st.enemyHP <= 0 || st.playerHP <= 0 {
|
||||
continue
|
||||
}
|
||||
st.enemyHP = max(0, st.enemyHP-st.concentrationDmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "player", Action: "concentration_tick",
|
||||
Damage: st.concentrationDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP, Seat: i,
|
||||
})
|
||||
if st.enemyHP <= 0 {
|
||||
te.finish(CombatStatusWon)
|
||||
return
|
||||
}
|
||||
}
|
||||
st.seat(0)
|
||||
// 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)
|
||||
@@ -449,7 +782,11 @@ func (te *turnEngine) stepRoundEnd() {
|
||||
})
|
||||
}
|
||||
st.round++
|
||||
te.sess.Phase = CombatPhasePlayerTurn
|
||||
// Initiative is re-rolled each round, so the next round's order is derived
|
||||
// here — off st.round, since commit has not yet pushed it onto the session.
|
||||
te.order = turnOrder(te.sess, st.round, te.players, te.enemy)
|
||||
te.sess.Statuses.TurnIdx = 0
|
||||
te.sess.Phase = phaseForSeat(te.order[0])
|
||||
}
|
||||
|
||||
// finish parks the session in a terminal status + the 'over' phase.
|
||||
@@ -464,61 +801,72 @@ func (te *turnEngine) finish(status string) {
|
||||
//
|
||||
// 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.
|
||||
// rebuilt combatant by applySessionBuffs — so snapshotActor carries them over
|
||||
// from the prior state rather than re-deriving them.
|
||||
//
|
||||
// Per-actor state is read off each seat by index, never off the cursor: the
|
||||
// enemy turn parks the cursor on its target and round_end walks it across the
|
||||
// roster, so whoever it points at when commit runs is an accident of the phase.
|
||||
// Seat 0 lands on the session row; seats 1+ on their participant rows.
|
||||
func (te *turnEngine) commit() {
|
||||
st := te.st
|
||||
te.sess.Round = st.round
|
||||
te.sess.PlayerHP = st.playerHP
|
||||
te.sess.EnemyHP = st.enemyHP
|
||||
|
||||
seat0 := st.actors[0]
|
||||
te.sess.PlayerHP = seat0.playerHP
|
||||
s := &te.sess.Statuses
|
||||
s.PoisonTicks = st.poisonTicks
|
||||
s.PoisonDmg = st.poisonDmg
|
||||
s.StunPlayer = st.stunPlayer
|
||||
s.ActorStatuses = snapshotActor(seat0, s.ActorStatuses)
|
||||
|
||||
for i := 1; i < len(st.actors) && i-1 < len(te.sess.Participants); i++ {
|
||||
p := &te.sess.Participants[i-1]
|
||||
p.HP = st.actors[i].playerHP
|
||||
p.Statuses = snapshotActor(st.actors[i], p.Statuses)
|
||||
}
|
||||
|
||||
// Fight-scoped: the enemy's own stance, and the debuffs it wears.
|
||||
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.
|
||||
// advanceCombatSession resolves one phase of a solo fight and persists the
|
||||
// result. It is the single entry point callers (commands, reaper) should use.
|
||||
func advanceCombatSession(sess *CombatSession, player, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
|
||||
return advancePartyCombatSession(sess, []*Combatant{player}, enemy, action)
|
||||
}
|
||||
|
||||
// advancePartyCombatSession resolves one phase of the fight for the seated
|
||||
// roster and persists the result: it derives the acting seat from this round's
|
||||
// turn order, seeds that seat's deterministic RNG, resumes the engine, steps,
|
||||
// commits, and saves. The passed sess is mutated in place. The events generated
|
||||
// this step are returned.
|
||||
func advancePartyCombatSession(sess *CombatSession, players []*Combatant, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
|
||||
if sess == nil {
|
||||
return nil, ErrNoActiveCombatSession
|
||||
}
|
||||
rng := combatSessionRNG(sess)
|
||||
te := resumeTurnEngine(sess, player, enemy, rng)
|
||||
if len(players) == 0 {
|
||||
return nil, fmt.Errorf("combat session %s: empty roster", sess.SessionID)
|
||||
}
|
||||
// The seat is needed before the engine is resumed, because it seeds the
|
||||
// step's RNG — so the order is derived once here and once inside
|
||||
// resumeTurnEngine. Both derivations read only (sessionID, round, roster),
|
||||
// so they agree by construction.
|
||||
rng := combatSessionStepRNG(sess, stepSeat(sess, players, enemy))
|
||||
te := resumeTurnEngine(sess, players, enemy, rng)
|
||||
events, err := te.step(action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
277
internal/plugin/combat_turn_party_test.go
Normal file
277
internal/plugin/combat_turn_party_test.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package plugin
|
||||
|
||||
// Initiative + N-body turn-engine tests (N3/P3).
|
||||
//
|
||||
// The solo path is pinned bit-for-bit by TestCombatCharacterization for
|
||||
// auto-resolve, and by the fixed-order assertions below for the turn engine.
|
||||
// Everything else here exercises seats the production code cannot reach until
|
||||
// P4 gives a session its participant rows.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// partyStep drives one engine step over a roster without touching the DB.
|
||||
func partyStep(sess *CombatSession, players []*Combatant, enemy *Combatant, action PlayerAction) ([]CombatEvent, error) {
|
||||
seat := enemySeat
|
||||
if sess.Phase == CombatPhasePlayerTurn {
|
||||
order := turnOrder(sess, sess.Round, players, enemy)
|
||||
seat = order[turnIdxForPhase(order, sess.Statuses.TurnIdx, sess.Phase)]
|
||||
}
|
||||
te := resumeTurnEngine(sess, players, enemy, combatSessionStepRNG(sess, seat))
|
||||
events, err := te.step(action)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
te.commit()
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func TestTurnOrder_SoloIsAlwaysPlayerThenEnemy(t *testing.T) {
|
||||
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
|
||||
p, e := basePlayer(), baseEnemy()
|
||||
// A blisteringly fast monster still swings second: the turn engine has
|
||||
// never rolled initiative for a duel, and P3 must not start.
|
||||
e.Stats.Speed = 999
|
||||
p.Mods.InitiativeBias = -50
|
||||
|
||||
for round := 1; round <= 5; round++ {
|
||||
got := turnOrder(sess, round, []*Combatant{&p}, &e)
|
||||
if len(got) != 2 || got[0] != 0 || got[1] != enemySeat {
|
||||
t.Fatalf("round %d solo order = %v, want [0 %d]", round, got, enemySeat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnOrder_PartySeatsEveryoneOnceAndIsDeterministic(t *testing.T) {
|
||||
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
|
||||
a, b, c := basePlayer(), basePlayer(), basePlayer()
|
||||
e := baseEnemy()
|
||||
players := []*Combatant{&a, &b, &c}
|
||||
|
||||
order := turnOrder(sess, 3, players, &e)
|
||||
if len(order) != 4 {
|
||||
t.Fatalf("order = %v, want 4 slots (3 players + enemy)", order)
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
for _, seat := range order {
|
||||
if seen[seat] {
|
||||
t.Fatalf("seat %d appears twice in %v", seat, order)
|
||||
}
|
||||
seen[seat] = true
|
||||
}
|
||||
if !seen[0] || !seen[1] || !seen[2] || !seen[enemySeat] {
|
||||
t.Fatalf("order %v does not seat every combatant", order)
|
||||
}
|
||||
|
||||
// Same (session, round) must rebuild the same order — that is what lets a
|
||||
// resumed fight land on the seat it left off on.
|
||||
again := turnOrder(sess, 3, players, &e)
|
||||
for i := range order {
|
||||
if order[i] != again[i] {
|
||||
t.Fatalf("order not stable across rebuilds: %v vs %v", order, again)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnOrder_InitiativeBiasWins(t *testing.T) {
|
||||
sess := turnSession(CombatPhasePlayerTurn, 100, 100)
|
||||
slow, fast := basePlayer(), basePlayer()
|
||||
fast.Mods.InitiativeBias = 100 // dwarfs the d10 spread
|
||||
e := baseEnemy()
|
||||
|
||||
order := turnOrder(sess, 1, []*Combatant{&slow, &fast}, &e)
|
||||
if order[0] != 1 {
|
||||
t.Errorf("order = %v, want the biased seat 1 first", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCombatSessionStepRNG_SeatZeroMatchesLegacyStream(t *testing.T) {
|
||||
sess := &CombatSession{SessionID: "abc123", Round: 2, Phase: CombatPhasePlayerTurn}
|
||||
if combatSessionStepRNG(sess, 0).Uint64() != combatSessionRNG(sess).Uint64() {
|
||||
t.Error("seat 0 must draw the pre-roster stream — the solo turn engine depends on it")
|
||||
}
|
||||
// The enemy sentinel shares seat 0's seed; its phase already separates it.
|
||||
if combatSessionStepRNG(sess, enemySeat).Uint64() != combatSessionRNG(sess).Uint64() {
|
||||
t.Error("the enemy sentinel must not perturb the seed")
|
||||
}
|
||||
// Party members share a (round, phase) pair, so only the seed keeps their
|
||||
// d20s apart.
|
||||
if combatSessionStepRNG(sess, 1).Uint64() == combatSessionStepRNG(sess, 0).Uint64() {
|
||||
t.Error("seats 0 and 1 drew the same stream in the same phase")
|
||||
}
|
||||
if combatSessionStepRNG(sess, 1).Uint64() == combatSessionStepRNG(sess, 2).Uint64() {
|
||||
t.Error("seats 1 and 2 drew the same stream in the same phase")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnIdxForPhase_ReconcilesARowWrittenBeforeTheCursorExisted(t *testing.T) {
|
||||
solo := []int{0, enemySeat}
|
||||
// A fight suspended mid-enemy-turn decodes TurnIdx as 0. Phase is the older
|
||||
// field and wins, so the cursor must snap to the enemy's slot — otherwise
|
||||
// the engine re-runs the enemy turn forever.
|
||||
if got := turnIdxForPhase(solo, 0, CombatPhaseEnemyTurn); got != 1 {
|
||||
t.Errorf("legacy enemy_turn row reconciled to idx %d, want 1", got)
|
||||
}
|
||||
if got := turnIdxForPhase(solo, 0, CombatPhasePlayerTurn); got != 0 {
|
||||
t.Errorf("player_turn row = idx %d, want 0", got)
|
||||
}
|
||||
// An out-of-range cursor (roster shrank) falls back to the phase.
|
||||
if got := turnIdxForPhase(solo, 7, CombatPhaseEnemyTurn); got != 1 {
|
||||
t.Errorf("out-of-range cursor = idx %d, want 1", got)
|
||||
}
|
||||
// A party order keeps an already-consistent cursor exactly where it is.
|
||||
party := []int{1, enemySeat, 0}
|
||||
if got := turnIdxForPhase(party, 2, CombatPhasePlayerTurn); got != 2 {
|
||||
t.Errorf("consistent cursor moved to idx %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_SoloRoundIsPlayerEnemyRoundEnd(t *testing.T) {
|
||||
sess := turnSession(CombatPhasePlayerTurn, 10000, 10000)
|
||||
p, e := basePlayer(), baseEnemy()
|
||||
players := []*Combatant{&p}
|
||||
|
||||
for _, want := range []string{CombatPhaseEnemyTurn, CombatPhaseRoundEnd, CombatPhasePlayerTurn} {
|
||||
if _, err := partyStep(sess, players, &e, PlayerAction{Kind: ActionAttack}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Phase != want {
|
||||
t.Fatalf("phase = %q, want %q", sess.Phase, want)
|
||||
}
|
||||
}
|
||||
if sess.Round != 2 {
|
||||
t.Errorf("round = %d, want 2 after one full round", sess.Round)
|
||||
}
|
||||
if sess.Statuses.TurnIdx != 0 {
|
||||
t.Errorf("solo cursor = %d, want 0 — it should never leave the first slot at a player turn", sess.Statuses.TurnIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_PartyRoundGivesEverySeatATurn(t *testing.T) {
|
||||
sess := turnSession(CombatPhasePlayerTurn, 10000, 10000)
|
||||
a, b := basePlayer(), basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 100000
|
||||
players := []*Combatant{&a, &b}
|
||||
order := turnOrder(sess, 1, players, &e)
|
||||
|
||||
// Walk the round slot by slot, asserting each phase matches the order.
|
||||
for i, seat := range order {
|
||||
if sess.Phase != phaseForSeat(seat) {
|
||||
t.Fatalf("slot %d: phase = %q, want %q for seat %d", i, sess.Phase, phaseForSeat(seat), seat)
|
||||
}
|
||||
if sess.Statuses.TurnIdx != i {
|
||||
t.Fatalf("slot %d: cursor = %d", i, sess.Statuses.TurnIdx)
|
||||
}
|
||||
if _, err := partyStep(sess, players, &e, PlayerAction{Kind: ActionAttack}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if sess.Phase != CombatPhaseRoundEnd {
|
||||
t.Fatalf("phase after every seat acted = %q, want round_end", sess.Phase)
|
||||
}
|
||||
if _, err := partyStep(sess, players, &e, PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Round != 2 || sess.Statuses.TurnIdx != 0 {
|
||||
t.Errorf("round_end left round=%d cursor=%d, want 2 and 0", sess.Round, sess.Statuses.TurnIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_DownedSeatForfeitsItsTurnSilently(t *testing.T) {
|
||||
// Force a party order whose first slot is a player, then kill that player.
|
||||
sess := turnSession(CombatPhasePlayerTurn, 10000, 10000)
|
||||
a, b := basePlayer(), basePlayer()
|
||||
e := baseEnemy()
|
||||
players := []*Combatant{&a, &b}
|
||||
order := turnOrder(sess, 1, players, &e)
|
||||
first := order[0]
|
||||
if first == enemySeat {
|
||||
t.Skip("this round's initiative put the enemy first; the seat-skip path is covered by the next slot")
|
||||
}
|
||||
|
||||
te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, first))
|
||||
te.st.actors[first].playerHP = 0
|
||||
events, err := te.step(PlayerAction{Kind: ActionAttack})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Errorf("a downed seat emitted %d events, want none", len(events))
|
||||
}
|
||||
if !sess.IsActive() {
|
||||
t.Error("one downed member ended the fight while another still stands")
|
||||
}
|
||||
if sess.Statuses.TurnIdx != 1 {
|
||||
t.Errorf("cursor = %d, want the next slot", sess.Statuses.TurnIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_EnemyOnlySwingsAtStandingMembers(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseEnemyTurn, 10000, 10000)
|
||||
a, b := basePlayer(), basePlayer()
|
||||
a.Stats.MaxHP, b.Stats.MaxHP = 10000, 10000
|
||||
e := baseEnemy()
|
||||
players := []*Combatant{&a, &b}
|
||||
|
||||
// Seat 0 is down. Every enemy turn must land on seat 1, never on the corpse,
|
||||
// and never end the fight.
|
||||
for round := 1; round <= 25; round++ {
|
||||
sess.Round = round
|
||||
sess.Phase = CombatPhaseEnemyTurn
|
||||
sess.Statuses.TurnIdx = 1
|
||||
te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, enemySeat))
|
||||
te.st.actors[0].playerHP = 0
|
||||
before := te.st.actors[1].playerHP
|
||||
te.step(PlayerAction{})
|
||||
if te.st.actors[0].playerHP != 0 {
|
||||
t.Fatal("the enemy healed a corpse")
|
||||
}
|
||||
if !sess.IsActive() {
|
||||
t.Fatalf("round %d: fight ended with a member still standing (%d HP)", round, before)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnEngine_RosterWipeEndsTheFight(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseEnemyTurn, 1, 10000)
|
||||
a, b := basePlayer(), basePlayer()
|
||||
e := baseEnemy()
|
||||
players := []*Combatant{&a, &b}
|
||||
|
||||
te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, enemySeat))
|
||||
te.st.actors[0].playerHP = 0
|
||||
te.st.actors[1].playerHP = 0
|
||||
if _, err := te.step(PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sess.Status != CombatStatusLost {
|
||||
t.Errorf("status = %q, want %q once the whole roster is down", sess.Status, CombatStatusLost)
|
||||
}
|
||||
}
|
||||
|
||||
// commit persists seat 0, not whoever the cursor happens to be parked on. The
|
||||
// enemy turn parks it on its target; round_end walks it across the roster.
|
||||
func TestTurnEngine_CommitPersistsSeatZeroNotTheCursor(t *testing.T) {
|
||||
sess := turnSession(CombatPhaseRoundEnd, 500, 10000)
|
||||
a, b := basePlayer(), basePlayer()
|
||||
e := baseEnemy()
|
||||
players := []*Combatant{&a, &b}
|
||||
|
||||
te := resumeTurnEngine(sess, players, &e, combatSessionStepRNG(sess, enemySeat))
|
||||
te.st.actors[0].playerHP = 300
|
||||
te.st.actors[1].playerHP = 77
|
||||
te.st.actors[1].luckyUsed = true
|
||||
if _, err := te.step(PlayerAction{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
te.commit()
|
||||
|
||||
if sess.PlayerHP != 300 {
|
||||
t.Errorf("persisted PlayerHP = %d, want seat 0's 300", sess.PlayerHP)
|
||||
}
|
||||
if sess.Statuses.LuckyUsed {
|
||||
t.Error("seat 1's consumed Lucky reroll leaked onto the session row")
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,9 @@ type bandsintownEvent struct {
|
||||
Latitude string `json:"latitude"`
|
||||
Longitude string `json:"longitude"`
|
||||
} `json:"venue"`
|
||||
Lineup []string `json:"lineup"`
|
||||
OnSaleAt string `json:"on_sale_datetime"`
|
||||
Offers []struct {
|
||||
Lineup []string `json:"lineup"`
|
||||
OnSaleAt string `json:"on_sale_datetime"`
|
||||
Offers []struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
|
||||
@@ -161,6 +161,20 @@ func abilityModifier(score int) int {
|
||||
// startup to refresh stale rows.
|
||||
const phase5BHPMult = 1.5
|
||||
|
||||
// casterHPMult is the J3 D8-d-fix caster durability lift. T4 sim showed
|
||||
// caster HPMax sat ~30% below martial (~95 vs 141 at L10) and the AC-floor
|
||||
// lift alone wasn't enough to crack the wall. Applied multiplicatively in
|
||||
// computeMaxHP on top of phase5BHPMult so the existing Phase 5-B floor
|
||||
// stays intact for martials. Refreshed for existing rows by
|
||||
// bootstrapCasterHPRefresh (job key caster_hp_refresh_v1).
|
||||
func casterHPMult(class DnDClass) float64 {
|
||||
switch class {
|
||||
case ClassCleric, ClassDruid, ClassBard, ClassWarlock, ClassMage, ClassSorcerer:
|
||||
return 1.25
|
||||
}
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -181,8 +195,8 @@ func computeMaxHP(class DnDClass, conMod, level int) int {
|
||||
}
|
||||
hp += gain
|
||||
}
|
||||
// Phase 5-B player power floor — round to nearest int.
|
||||
scaled := int(float64(hp)*phase5BHPMult + 0.5)
|
||||
// Phase 5-B player power floor + J3 D8-d-fix caster lift — round to nearest int.
|
||||
scaled := int(float64(hp)*phase5BHPMult*casterHPMult(class) + 0.5)
|
||||
if scaled < 1 {
|
||||
scaled = 1
|
||||
}
|
||||
@@ -197,10 +211,16 @@ func computeAC(class DnDClass, dexMod int) int {
|
||||
switch class {
|
||||
case ClassFighter, ClassPaladin:
|
||||
floor = 6 // heavy/medium-armor baseline
|
||||
case ClassCleric, ClassRanger, ClassDruid:
|
||||
case ClassCleric, ClassDruid:
|
||||
floor = 5
|
||||
case ClassRanger:
|
||||
floor = 3
|
||||
case ClassRogue, ClassBard, ClassWarlock:
|
||||
case ClassBard, ClassWarlock:
|
||||
floor = 3
|
||||
case ClassRogue:
|
||||
floor = 1
|
||||
case ClassMage, ClassSorcerer:
|
||||
floor = 2
|
||||
}
|
||||
return 10 + dexMod + floor
|
||||
}
|
||||
@@ -208,21 +228,21 @@ func computeAC(class DnDClass, dexMod int) int {
|
||||
// ── DnDCharacter struct + repository ─────────────────────────────────────────
|
||||
|
||||
type DnDCharacter struct {
|
||||
UserID id.UserID
|
||||
Race DnDRace
|
||||
Class DnDClass
|
||||
Level int
|
||||
XP int
|
||||
STR, DEX, CON int
|
||||
INT, WIS, CHA int
|
||||
HPCurrent int
|
||||
HPMax int
|
||||
TempHP int
|
||||
ArmorClass int
|
||||
PendingSetup bool
|
||||
AutoMigrated bool
|
||||
OnboardingSent bool
|
||||
ArmedAbility string
|
||||
UserID id.UserID
|
||||
Race DnDRace
|
||||
Class DnDClass
|
||||
Level int
|
||||
XP int
|
||||
STR, DEX, CON int
|
||||
INT, WIS, CHA int
|
||||
HPCurrent int
|
||||
HPMax int
|
||||
TempHP int
|
||||
ArmorClass int
|
||||
PendingSetup bool
|
||||
AutoMigrated bool
|
||||
OnboardingSent bool
|
||||
ArmedAbility string
|
||||
// Phase 9 — spell layer.
|
||||
// PendingCast: JSON blob describing the queued spell to fire on next
|
||||
// one-shot combat (empty string = nothing queued). Set by !cast for
|
||||
@@ -231,30 +251,30 @@ type DnDCharacter struct {
|
||||
// (empty = nothing active). ConcentrationExpiresAt is the wall-clock
|
||||
// expiry; nil for indefinite. Casting another concentration spell
|
||||
// supersedes whatever's here.
|
||||
PendingCast string
|
||||
ConcentrationSpell string
|
||||
ConcentrationExpiresAt *time.Time
|
||||
PendingCast string
|
||||
ConcentrationSpell string
|
||||
ConcentrationExpiresAt *time.Time
|
||||
// Phase 10 — subclass.
|
||||
// Subclass: empty until the player runs !subclass at L5+. Once set,
|
||||
// changing requires `!subclass <name>` again subject to a 30-day
|
||||
// cooldown (LastSubclassRespecAt) and 500-coin cost.
|
||||
Subclass DnDSubclass
|
||||
LastSubclassRespecAt *time.Time
|
||||
Subclass DnDSubclass
|
||||
LastSubclassRespecAt *time.Time
|
||||
// Phase 10 SUB2a — Berserker Frenzy increments after each rage'd
|
||||
// combat; future class abilities will also tick this. Cleared on
|
||||
// long rest. 5e caps at 6 (death); we let it grow and let consumers
|
||||
// clamp where needed.
|
||||
Exhaustion int
|
||||
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
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
LastRespecAt *time.Time
|
||||
LastShortRestAt *time.Time
|
||||
LastLongRestAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Modifiers returns the six ability modifiers in STR/DEX/CON/INT/WIS/CHA order.
|
||||
|
||||
@@ -24,9 +24,9 @@ import (
|
||||
// ── Ability definitions ──────────────────────────────────────────────────────
|
||||
|
||||
type DnDAbility struct {
|
||||
ID string
|
||||
Name string
|
||||
Class DnDClass
|
||||
ID string
|
||||
Name string
|
||||
Class DnDClass
|
||||
// Subclass: if non-empty, ability is only available when the player's
|
||||
// subclass matches. Phase 10 adds the first such ability (Berserker
|
||||
// rage). Used by parseAbility/arm gating and by classActiveAbilities
|
||||
|
||||
@@ -54,9 +54,9 @@ func TestActiveAbilityCatalogue(t *testing.T) {
|
||||
|
||||
func TestParseAbility(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
id string
|
||||
ok bool
|
||||
in string
|
||||
id string
|
||||
ok bool
|
||||
}{
|
||||
{"second_wind", "second_wind", true},
|
||||
{"second wind", "second_wind", true},
|
||||
|
||||
@@ -38,28 +38,28 @@ func setupAuditTestDB(t *testing.T) {
|
||||
// regardless of MaxHP parity.
|
||||
func TestOrcRage_ThresholdExact(t *testing.T) {
|
||||
cases := []struct {
|
||||
hp, maxHP int
|
||||
hp, maxHP int
|
||||
shouldFire bool
|
||||
}{
|
||||
{50, 100, false}, // exactly 50% — does NOT fire (strict <)
|
||||
{49, 100, true}, // just under
|
||||
{8, 16, false}, // exactly 50% even MaxHP
|
||||
{7, 16, true},
|
||||
{8, 15, true}, // 8/15 = 53% — under former threshold of MaxHP/2=7. New: 8*2=16, 16<15 false. Hmm.
|
||||
{8, 15, true}, // 8/15 = 53% — under former threshold of MaxHP/2=7. New: 8*2=16, 16<15 false. Hmm.
|
||||
}
|
||||
// Re-derive: HP*2 < MaxHP. So 8*2=16 < 15? No. Doesn't fire. That means at MaxHP=15, threshold trips at HP*2 < 15, i.e. HP < 7.5, i.e. HP ≤ 7.
|
||||
// Old behavior used HP < MaxHP/2 = HP < 7 (integer div). So fired at HP < 7, i.e., HP ≤ 6. Different by one.
|
||||
// Pick cases that distinguish.
|
||||
cases = []struct {
|
||||
hp, maxHP int
|
||||
hp, maxHP int
|
||||
shouldFire bool
|
||||
}{
|
||||
{50, 100, false},
|
||||
{49, 100, true},
|
||||
{8, 16, false},
|
||||
{7, 16, true},
|
||||
{6, 15, true}, // 6*2=12 < 15 ✓
|
||||
{7, 15, true}, // 7*2=14 < 15 ✓ (old code's MaxHP/2=7 would have NOT fired at HP=7)
|
||||
{6, 15, true}, // 6*2=12 < 15 ✓
|
||||
{7, 15, true}, // 7*2=14 < 15 ✓ (old code's MaxHP/2=7 would have NOT fired at HP=7)
|
||||
{8, 15, false}, // 8*2=16, not < 15
|
||||
}
|
||||
for _, c := range cases {
|
||||
|
||||
@@ -42,6 +42,145 @@ func TestZoneRun_AutoAbandonsAfter24h(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A run reaped by the idle timeout must also terminate the wrapping active
|
||||
// expedition, or the expedition is orphaned 'active' over a dead run and the
|
||||
// player soft-locks (the original feywild "stuck, can't route" bug).
|
||||
func TestZoneRun_IdleTimeoutExtractsWrappingExpedition(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@orphan-run:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("startZoneRun: %v", err)
|
||||
}
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
|
||||
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("startExpedition: %v", err)
|
||||
}
|
||||
if err := setExpeditionRunID(exp.ID, run.RunID); err != nil {
|
||||
t.Fatalf("link run: %v", err)
|
||||
}
|
||||
// Backdate the run past the 24h stale threshold.
|
||||
if _, err := dbExecZoneRunBackdate(run.RunID, 25*time.Hour); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
|
||||
got, err := getActiveZoneRun(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("getActiveZoneRun: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("expected nil after timeout, got run %q", got.RunID)
|
||||
}
|
||||
// The wrapping expedition must no longer be active.
|
||||
after, err := getExpedition(exp.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("getExpedition: %v", err)
|
||||
}
|
||||
if after == nil {
|
||||
t.Fatal("expedition row vanished")
|
||||
}
|
||||
if after.Status == ExpeditionStatusActive {
|
||||
t.Errorf("expedition still active after run idle-timeout; status=%q", after.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// A stale background fork auto-picks the first unlocked route so the
|
||||
// expedition keeps moving instead of idling out to the reaper.
|
||||
func TestAutoPickStaleFork_TakesAvailableRoute(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@stale-fork:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("startZoneRun: %v", err)
|
||||
}
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
|
||||
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("startExpedition: %v", err)
|
||||
}
|
||||
if err := setExpeditionRunID(exp.ID, run.RunID); err != nil {
|
||||
t.Fatalf("link run: %v", err)
|
||||
}
|
||||
pf := pendingFork{
|
||||
PendingAt: "goblin_warrens.cavern_junction",
|
||||
Options: []pendingChoice{
|
||||
{Index: 1, To: "goblin_warrens.guard_post", Label: "Guard Post",
|
||||
Unlocked: false, Lock: "perception_check", Reason: "Perception 8 vs DC 14"},
|
||||
{Index: 2, To: "goblin_warrens.kennel_path", Label: "Kennel Path",
|
||||
Unlocked: true, Lock: "none"},
|
||||
},
|
||||
}
|
||||
if err := writePendingFork(run.RunID, pf); err != nil {
|
||||
t.Fatalf("writePendingFork: %v", err)
|
||||
}
|
||||
r2, _ := getZoneRun(run.RunID)
|
||||
got, _ := decodePendingFork(r2.NodeChoices)
|
||||
if got == nil {
|
||||
t.Fatal("fork not persisted")
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
if ok := p.autoPickStaleFork(exp, r2, got); !ok {
|
||||
t.Fatal("expected auto-pick to commit a route")
|
||||
}
|
||||
after, _ := getZoneRun(run.RunID)
|
||||
if after.CurrentNode != "goblin_warrens.kennel_path" {
|
||||
t.Errorf("did not advance to the unlocked route: current_node=%q", after.CurrentNode)
|
||||
}
|
||||
if pf2, _ := decodePendingFork(after.NodeChoices); pf2 != nil {
|
||||
t.Errorf("pending fork not cleared after auto-pick")
|
||||
}
|
||||
}
|
||||
|
||||
// When every option is locked there's nothing safe to auto-pick — leave the
|
||||
// fork intact for the player (or the reaper).
|
||||
func TestAutoPickStaleFork_AllLockedLeavesForkIntact(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@locked-fork:example")
|
||||
defer cleanupExpeditions(uid)
|
||||
|
||||
run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("startZoneRun: %v", err)
|
||||
}
|
||||
exp, err := startExpedition(uid, ZoneGoblinWarrens, run.RunID,
|
||||
ExpeditionSupplies{Current: 10, Max: 10, DailyBurn: 1, HarshMod: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("startExpedition: %v", err)
|
||||
}
|
||||
_ = setExpeditionRunID(exp.ID, run.RunID)
|
||||
pf := pendingFork{
|
||||
PendingAt: "goblin_warrens.cavern_junction",
|
||||
Options: []pendingChoice{
|
||||
{Index: 1, To: "goblin_warrens.guard_post", Label: "Guard Post",
|
||||
Unlocked: false, Lock: "perception_check", Reason: "DC 14"},
|
||||
},
|
||||
}
|
||||
if err := writePendingFork(run.RunID, pf); err != nil {
|
||||
t.Fatalf("writePendingFork: %v", err)
|
||||
}
|
||||
r2, _ := getZoneRun(run.RunID)
|
||||
before := r2.CurrentNode
|
||||
got, _ := decodePendingFork(r2.NodeChoices)
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
if ok := p.autoPickStaleFork(exp, r2, got); ok {
|
||||
t.Fatal("expected no auto-pick when every option is locked")
|
||||
}
|
||||
after, _ := getZoneRun(run.RunID)
|
||||
if after.CurrentNode != before {
|
||||
t.Errorf("current_node moved on an all-locked fork: %q → %q", before, after.CurrentNode)
|
||||
}
|
||||
if pf2, _ := decodePendingFork(after.NodeChoices); pf2 == nil {
|
||||
t.Errorf("pending fork was cleared on an all-locked fork")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneRun_FreshRunNotAutoAbandoned(t *testing.T) {
|
||||
setupZoneRunTestDB(t)
|
||||
uid := id.UserID("@fresh-run:example")
|
||||
@@ -168,9 +307,7 @@ func TestDeliverBriefing_StarvationForcesExtraction(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
rewindToLegacyAnchor(t, exp)
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -16,8 +16,8 @@ type DnDMonsterTemplate struct {
|
||||
CR float32 // challenge rating (display only for now)
|
||||
HP int
|
||||
AC int
|
||||
Attack int // engine's "Attack" stat (raw damage value)
|
||||
AttackBonus int // d20 to-hit bonus
|
||||
Attack int // engine's "Attack" stat (raw damage value)
|
||||
AttackBonus int // d20 to-hit bonus
|
||||
Speed int
|
||||
BlockRate float64
|
||||
Ability *MonsterAbility
|
||||
@@ -176,10 +176,10 @@ var _ = func() bool {
|
||||
"flameskull": {
|
||||
ID: "flameskull", Name: "Flameskull",
|
||||
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.",
|
||||
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": {
|
||||
@@ -256,7 +256,7 @@ var _ = func() bool {
|
||||
},
|
||||
"green_hag": {
|
||||
ID: "green_hag", Name: "Green Hag",
|
||||
CR: 3, HP: 82, AC: 17, Attack: 6, AttackBonus: 5, Speed: 12,
|
||||
CR: 3, HP: 105, AC: 18, Attack: 6, AttackBonus: 5, Speed: 12,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Invisible Passage", Phase: "any", ProcChance: 0.35, Effect: "evade"},
|
||||
XPValue: 700,
|
||||
@@ -385,37 +385,37 @@ var _ = func() bool {
|
||||
"magmin": {
|
||||
ID: "magmin", Name: "Magmin",
|
||||
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.",
|
||||
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: 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.",
|
||||
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: 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.",
|
||||
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: 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.",
|
||||
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": {
|
||||
@@ -429,10 +429,10 @@ var _ = func() bool {
|
||||
"boss_emberlord_thyrak": {
|
||||
ID: "boss_emberlord_thyrak", Name: "Emberlord Thyrak",
|
||||
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.",
|
||||
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,
|
||||
},
|
||||
}
|
||||
@@ -463,7 +463,7 @@ var _ = func() bool {
|
||||
},
|
||||
"drow_elite_warrior": {
|
||||
ID: "drow_elite_warrior", Name: "Drow Elite Warrior",
|
||||
CR: 5, HP: 71, AC: 18, Attack: 8, AttackBonus: 7, Speed: 12,
|
||||
CR: 5, HP: 95, AC: 19, Attack: 8, AttackBonus: 7, Speed: 12,
|
||||
BlockRate: 0.20,
|
||||
Ability: &MonsterAbility{Name: "Parry", Phase: "any", ProcChance: 0.35, Effect: "block"},
|
||||
XPValue: 1800,
|
||||
@@ -471,23 +471,23 @@ var _ = func() bool {
|
||||
},
|
||||
"drow_mage": {
|
||||
ID: "drow_mage", Name: "Drow Mage",
|
||||
CR: 7, HP: 45, AC: 12, Attack: 12, AttackBonus: 5, Speed: 12,
|
||||
CR: 7, HP: 65, AC: 12, Attack: 12, AttackBonus: 5, Speed: 12,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Lightning Bolt", Phase: "decisive", ProcChance: 0.45, Effect: "aoe"},
|
||||
Ability: &MonsterAbility{Name: "Lightning Bolt", Phase: "decisive", ProcChance: 0.25, Effect: "aoe"},
|
||||
XPValue: 2900,
|
||||
Notes: "Spells: Lightning Bolt, Fly, Darkness, Faerie Fire. Magic Resistance.",
|
||||
},
|
||||
"mind_flayer": {
|
||||
ID: "mind_flayer", Name: "Mind Flayer",
|
||||
CR: 7, HP: 71, AC: 15, Attack: 12, AttackBonus: 7, Speed: 12,
|
||||
CR: 7, HP: 90, AC: 15, Attack: 12, AttackBonus: 7, Speed: 12,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Mind Blast", Phase: "opening", ProcChance: 0.55, Effect: "stun"},
|
||||
Ability: &MonsterAbility{Name: "Mind Blast", Phase: "opening", ProcChance: 0.30, Effect: "stun"},
|
||||
XPValue: 2900,
|
||||
Notes: "Mind Blast AoE psychic INT DC 15 or Stunned; Extract Brain instakills stunned; telepathy.",
|
||||
},
|
||||
"hook_horror": {
|
||||
ID: "hook_horror", Name: "Hook Horror",
|
||||
CR: 3, HP: 75, AC: 15, Attack: 6, AttackBonus: 6, Speed: 10,
|
||||
CR: 3, HP: 95, AC: 16, Attack: 6, AttackBonus: 6, Speed: 10,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.30, Effect: "advantage"},
|
||||
XPValue: 700,
|
||||
@@ -495,15 +495,15 @@ var _ = func() bool {
|
||||
},
|
||||
"roper": {
|
||||
ID: "roper", Name: "Roper",
|
||||
CR: 5, HP: 93, AC: 20, Attack: 8, AttackBonus: 7, Speed: 4,
|
||||
CR: 5, HP: 115, AC: 20, Attack: 8, AttackBonus: 7, Speed: 4,
|
||||
BlockRate: 0.20,
|
||||
Ability: &MonsterAbility{Name: "Reel", Phase: "any", ProcChance: 0.50, Effect: "stun"},
|
||||
Ability: &MonsterAbility{Name: "Reel", Phase: "any", ProcChance: 0.30, Effect: "stun"},
|
||||
XPValue: 1800,
|
||||
Notes: "Elite. 6 tendrils grapple+restrain; Reel pulls 25 ft; False Appearance.",
|
||||
},
|
||||
"boss_ilvaras_xunyl": {
|
||||
ID: "boss_ilvaras_xunyl", Name: "Ilvaras Xunyl, Drow High Priestess",
|
||||
CR: 12, HP: 162, AC: 16, Attack: 19, AttackBonus: 9, Speed: 12,
|
||||
CR: 12, HP: 210, AC: 18, Attack: 19, AttackBonus: 9, Speed: 12,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Divine Word", Phase: "decisive", ProcChance: 0.40, Effect: "execute"},
|
||||
XPValue: 8400,
|
||||
@@ -512,7 +512,7 @@ var _ = func() bool {
|
||||
// ── Feywild Crossing ─────────────────────────────────────────────
|
||||
"redcap": {
|
||||
ID: "redcap", Name: "Redcap",
|
||||
CR: 3, HP: 45, AC: 13, Attack: 6, AttackBonus: 6, Speed: 14,
|
||||
CR: 3, HP: 60, AC: 15, Attack: 6, AttackBonus: 6, Speed: 14,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Outsize Strength", Phase: "any", ProcChance: 0.40, Effect: "bonus_damage"},
|
||||
XPValue: 700,
|
||||
@@ -520,7 +520,7 @@ var _ = func() bool {
|
||||
},
|
||||
"will_o_wisp": {
|
||||
ID: "will_o_wisp", Name: "Will-o'-Wisp",
|
||||
CR: 2, HP: 22, AC: 19, Attack: 4, AttackBonus: 4, Speed: 14,
|
||||
CR: 2, HP: 30, 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,
|
||||
@@ -528,7 +528,7 @@ var _ = func() bool {
|
||||
},
|
||||
"quickling": {
|
||||
ID: "quickling", Name: "Quickling",
|
||||
CR: 1, HP: 10, AC: 16, Attack: 2, AttackBonus: 5, Speed: 18,
|
||||
CR: 1, HP: 16, AC: 16, Attack: 2, AttackBonus: 5, Speed: 18,
|
||||
BlockRate: 0.05,
|
||||
Ability: &MonsterAbility{Name: "Blur", Phase: "any", ProcChance: 0.50, Effect: "evade"},
|
||||
XPValue: 200,
|
||||
@@ -536,7 +536,7 @@ var _ = func() bool {
|
||||
},
|
||||
"night_hag": {
|
||||
ID: "night_hag", Name: "Night Hag",
|
||||
CR: 5, HP: 112, AC: 17, Attack: 8, AttackBonus: 7, Speed: 12,
|
||||
CR: 5, HP: 135, AC: 18, Attack: 8, AttackBonus: 7, Speed: 12,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Etherealness", Phase: "any", ProcChance: 0.30, Effect: "evade"},
|
||||
XPValue: 1800,
|
||||
@@ -544,7 +544,7 @@ var _ = func() bool {
|
||||
},
|
||||
"fomorian": {
|
||||
ID: "fomorian", Name: "Fomorian",
|
||||
CR: 8, HP: 149, AC: 14, Attack: 13, AttackBonus: 9, Speed: 12,
|
||||
CR: 8, HP: 180, AC: 16, Attack: 13, AttackBonus: 9, Speed: 12,
|
||||
BlockRate: 0.10,
|
||||
Ability: &MonsterAbility{Name: "Evil Eye", Phase: "any", ProcChance: 0.45, Effect: "stun"},
|
||||
XPValue: 3900,
|
||||
@@ -552,7 +552,7 @@ var _ = func() bool {
|
||||
},
|
||||
"boss_thornmother": {
|
||||
ID: "boss_thornmother", Name: "The Thornmother",
|
||||
CR: 11, HP: 187, AC: 17, Attack: 18, AttackBonus: 8, Speed: 12,
|
||||
CR: 11, HP: 235, AC: 18, Attack: 18, AttackBonus: 8, Speed: 12,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Beguiling Bargain", Phase: "opening", ProcChance: 0.50, Effect: "stun"},
|
||||
XPValue: 7200,
|
||||
@@ -594,19 +594,28 @@ var _ = func() bool {
|
||||
"young_red_dragon": {
|
||||
ID: "young_red_dragon", Name: "Young Red Dragon",
|
||||
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.",
|
||||
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: 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.",
|
||||
// T5 lift (D11): dragons_lair was an impossible wall — at L15-16
|
||||
// (mid-range) leaders cleared 0-2%, 100% TPK at the boss (it has
|
||||
// 546 HP / AC22 / 49-dmg multiattack / near-guaranteed stun, a
|
||||
// CR24 block fought ~9 levels under its CR). Nerfed to land
|
||||
// martial leaders in the 60-75% band: HP 546→405, AC 22→20,
|
||||
// frightful-presence stun 0.80→0.40, multiattack 49→42 (srd).
|
||||
// (Pass 1's 360/AC20/36 over-nerfed → 98% leaders; Pass 2's
|
||||
// 460/AC21/42 over-corrected → ~40%; this Pass 3 405/AC20/42
|
||||
// lands leaders in band.)
|
||||
CR: 24, HP: 405, AC: 20, Attack: 38, AttackBonus: 11, Speed: 18,
|
||||
BlockRate: 0.15,
|
||||
Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.40, 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 ─────────────────────────────────────────────────
|
||||
@@ -652,11 +661,19 @@ var _ = func() bool {
|
||||
},
|
||||
"boss_belaxath": {
|
||||
ID: "boss_belaxath", Name: "Belaxath the Undivided",
|
||||
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.",
|
||||
// T5 lift (D11): abyss_portal was a faceroll for leaders — at its
|
||||
// own floor (L15) fighter/ranger cleared 88-92% (above the 60-75%
|
||||
// band), killing the 262-HP boss in 6-13 rounds with HP to spare.
|
||||
// Buffed to bring leaders into band: HP 262→300, AC 19→20,
|
||||
// multiattack 40→41 (srd). Elites (nalfeshnee/marilith) already
|
||||
// wall the trailers, so this only moves the leaders. (Pass 1's
|
||||
// 360/AC20/45 over-buffed → 19-40% leaders; this is the Pass 2
|
||||
// correction back down toward band.)
|
||||
CR: 19, HP: 300, AC: 20, 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,
|
||||
},
|
||||
}
|
||||
@@ -683,13 +700,13 @@ func dndBestiaryByCR(maxCR float32) []DnDMonsterTemplate {
|
||||
// named monsters in the existing combat pipeline.
|
||||
func (m DnDMonsterTemplate) toCombatStats() (CombatStats, CombatModifiers) {
|
||||
stats := CombatStats{
|
||||
MaxHP: m.HP,
|
||||
Attack: m.Attack,
|
||||
Defense: 0,
|
||||
Speed: m.Speed,
|
||||
BlockRate: m.BlockRate,
|
||||
AC: m.AC,
|
||||
AttackBonus: m.AttackBonus,
|
||||
MaxHP: m.HP,
|
||||
Attack: m.Attack,
|
||||
Defense: 0,
|
||||
Speed: m.Speed,
|
||||
BlockRate: m.BlockRate,
|
||||
AC: m.AC,
|
||||
AttackBonus: m.AttackBonus,
|
||||
FireAttacker: m.FireAttacker,
|
||||
}
|
||||
mods := CombatModifiers{DamageReduct: 1.0}
|
||||
|
||||
@@ -90,9 +90,9 @@ func consumeFiredHealingItems(userID id.UserID, fired int) {
|
||||
}
|
||||
// Filter to heal items, sort cheapest-tier-first.
|
||||
type entry struct {
|
||||
id int64
|
||||
tier int
|
||||
name string
|
||||
id int64
|
||||
tier int
|
||||
name string
|
||||
}
|
||||
var heals []entry
|
||||
for _, item := range items {
|
||||
|
||||
@@ -63,8 +63,12 @@ func decodePendingCast(s string) (PendingCast, bool) {
|
||||
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 {
|
||||
// takes the fight's locks itself and re-checks the session under them.
|
||||
//
|
||||
// activeCombatSessionFor, not getActiveCombatSession: a seated party member
|
||||
// owns no session row, and would otherwise fall through to the out-of-combat
|
||||
// path and queue a PendingCast in the middle of their own boss fight.
|
||||
if sess, _ := activeCombatSessionFor(ctx.Sender); sess != nil {
|
||||
return p.handleCombatCastCmd(ctx, args)
|
||||
}
|
||||
|
||||
|
||||
@@ -272,9 +272,19 @@ func spellExpectedDamage(s SpellDefinition, slot, charLevel int) float64 {
|
||||
}
|
||||
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.
|
||||
// Concentration damage spells (heat_metal, spirit_guardians,
|
||||
// flaming_sphere, call_lightning, spike_growth, cloud_of_daggers, …)
|
||||
// re-tick each round while concentration holds. Without this factor
|
||||
// the picker scores them as one-shots and they lose to higher-tier
|
||||
// blasts on every comparison. Conservative ×3 = roughly the median
|
||||
// fight length the picker can hope to keep concentration up; tier-
|
||||
// scaled would be more correct but adds noise here.
|
||||
// EffectDamageAttack is excluded — single-target attack-roll spells
|
||||
// aren't generally concentration; the rare ones (hex-style) get
|
||||
// their lift from mods, not this score.
|
||||
if s.Concentration && (s.Effect == EffectDamageSave || s.Effect == EffectDamageAuto) {
|
||||
avg *= 3
|
||||
}
|
||||
return avg
|
||||
}
|
||||
|
||||
|
||||
@@ -408,3 +408,45 @@ func TestClassBalance_Phase1_FullMatrix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// D8-c: concentration damage spells re-tick each round; the picker has to
|
||||
// score them above one-shots of similar level or it'll never pick them.
|
||||
func TestSpellExpectedDamage_ConcentrationMultiplier(t *testing.T) {
|
||||
const concentrationFactor = 3.0
|
||||
// 2d8 base: avg = 9.
|
||||
const baseAvg = 9.0
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
sp SpellDefinition
|
||||
want float64
|
||||
}{
|
||||
{"oneshot save (shatter-like)",
|
||||
SpellDefinition{Level: 2, Effect: EffectDamageSave, DamageDice: "2d8"},
|
||||
baseAvg},
|
||||
{"oneshot attack",
|
||||
SpellDefinition{Level: 2, Effect: EffectDamageAttack, DamageDice: "2d8"},
|
||||
baseAvg},
|
||||
{"concentration save (spirit_guardians-like)",
|
||||
SpellDefinition{Level: 2, Effect: EffectDamageSave, Concentration: true, DamageDice: "2d8"},
|
||||
baseAvg * concentrationFactor},
|
||||
{"concentration auto (spike_growth-like)",
|
||||
SpellDefinition{Level: 2, Effect: EffectDamageAuto, Concentration: true, DamageDice: "2d8"},
|
||||
baseAvg * concentrationFactor},
|
||||
{"concentration attack stays unmultiplied",
|
||||
SpellDefinition{Level: 2, Effect: EffectDamageAttack, Concentration: true, DamageDice: "2d8"},
|
||||
baseAvg},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := spellExpectedDamage(tc.sp, tc.sp.Level, 10)
|
||||
if got != tc.want {
|
||||
t.Errorf("%s: expDmg=%.2f want %.2f", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Upcast still applies before the multiplier: 2d8 at slot 3 = 3d8 = 13.5, ×3 = 40.5.
|
||||
sp := SpellDefinition{Level: 2, Effect: EffectDamageSave, Concentration: true, DamageDice: "2d8"}
|
||||
if got := spellExpectedDamage(sp, 3, 10); got != 13.5*concentrationFactor {
|
||||
t.Errorf("upcast + concentration: got %.2f want %.2f", got, 13.5*concentrationFactor)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user